v.0.2.0-beta
目前oauth已经可以正常使用
This commit is contained in:
97
README.md
97
README.md
@ -192,10 +192,105 @@ npm run dev:frontend
|
||||
|
||||
### 4. 测试OAuth流程
|
||||
|
||||
#### 方法一:使用前端界面
|
||||
1. 注册/登录用户
|
||||
2. 在个人中心创建OAuth客户端
|
||||
3. 访问授权页面:`http://localhost:3001/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:3001/callback&scope=read%20write&state=test123`
|
||||
4. 完成授权流程
|
||||
4. 在授权页面查看应用信息和请求的权限
|
||||
5. 选择"同意授权"或"拒绝授权"
|
||||
6. 系统会重定向到第三方应用并附带授权码或错误信息
|
||||
|
||||
#### 方法二:使用第三方应用示例
|
||||
1. 启动后端和前端服务
|
||||
2. 在个人中心创建OAuth客户端
|
||||
3. 打开第三方应用示例:`http://localhost:3001/third-party-app.html`
|
||||
4. 填入客户端ID和密钥
|
||||
5. 点击"开始OAuth授权"按钮
|
||||
6. 完成授权流程并查看API响应
|
||||
|
||||
#### 方法三:使用测试脚本
|
||||
```bash
|
||||
# 运行完整的OAuth流程测试
|
||||
npm run test:oauth-flow
|
||||
```
|
||||
|
||||
### 5. OAuth 2.0 授权流程详解
|
||||
|
||||
#### 完整的授权码流程 (Authorization Code Flow)
|
||||
|
||||
**步骤 1: 获取授权信息**
|
||||
```
|
||||
GET /api/oauth/authorize?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:3001/callback&scope=read%20write&state=test123
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "授权信息获取成功",
|
||||
"data": {
|
||||
"client": {
|
||||
"id": "YOUR_CLIENT_ID",
|
||||
"name": "应用名称",
|
||||
"description": "应用描述"
|
||||
},
|
||||
"scopes": ["read", "write"],
|
||||
"state": "test123",
|
||||
"redirect_uri": "http://localhost:3001/callback"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**步骤 2: 用户同意/拒绝授权**
|
||||
用户在前端授权页面选择同意或拒绝授权:
|
||||
|
||||
**同意授权:**
|
||||
```
|
||||
POST /api/oauth/authorize/consent
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer USER_JWT_TOKEN
|
||||
|
||||
{
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"redirect_uri": "http://localhost:3001/callback",
|
||||
"scope": "read write",
|
||||
"state": "test123",
|
||||
"approved": true
|
||||
}
|
||||
```
|
||||
|
||||
**拒绝授权:**
|
||||
```
|
||||
POST /api/oauth/authorize/consent
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer USER_JWT_TOKEN
|
||||
|
||||
{
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"redirect_uri": "http://localhost:3001/callback",
|
||||
"scope": "read write",
|
||||
"state": "test123",
|
||||
"approved": false
|
||||
}
|
||||
```
|
||||
|
||||
**步骤 3: 获取授权码或错误**
|
||||
- **同意授权**:重定向到 `redirect_uri` 并附带授权码
|
||||
```
|
||||
http://localhost:3001/callback?code=AUTH_CODE&state=test123
|
||||
```
|
||||
- **拒绝授权**:重定向到 `redirect_uri` 并附带错误信息
|
||||
```
|
||||
http://localhost:3001/callback?error=access_denied&error_description=用户拒绝授权&state=test123
|
||||
```
|
||||
|
||||
**步骤 4: 使用授权码交换访问令牌**
|
||||
```
|
||||
POST /api/oauth/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=authorization_code&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&code=AUTH_CODE&redirect_uri=http://localhost:3001/callback
|
||||
```
|
||||
|
||||
## 前端技术栈
|
||||
|
||||
|
165
demo-oauth-flow.md
Normal file
165
demo-oauth-flow.md
Normal file
@ -0,0 +1,165 @@
|
||||
# OAuth 2.0 授权流程演示
|
||||
|
||||
## 概述
|
||||
|
||||
本演示展示了完整的OAuth 2.0授权码流程,包括用户同意授权的步骤。
|
||||
|
||||
## 系统架构
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ 第三方应用 │ │ OAuth提供商 │ │ 用户 │
|
||||
│ (Client App)│ │ (Your App) │ │ (User) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
## 完整流程演示
|
||||
|
||||
### 步骤 1: 启动服务
|
||||
|
||||
```bash
|
||||
# 启动后端API服务
|
||||
npm start
|
||||
|
||||
# 启动前端服务
|
||||
npm run dev:frontend
|
||||
```
|
||||
|
||||
### 步骤 2: 用户注册和登录
|
||||
|
||||
1. 访问 `http://localhost:3001`
|
||||
2. 注册新用户或登录现有用户
|
||||
3. 进入个人中心
|
||||
|
||||
### 步骤 3: 创建OAuth客户端
|
||||
|
||||
1. 在个人中心点击"创建OAuth客户端"
|
||||
2. 填写应用信息:
|
||||
- 应用名称:测试应用
|
||||
- 应用描述:这是一个测试OAuth流程的应用
|
||||
- 重定向URI:`http://localhost:3001/third-party-app.html`
|
||||
3. 创建客户端并记录客户端ID和密钥
|
||||
|
||||
### 步骤 4: 使用第三方应用示例
|
||||
|
||||
1. 打开第三方应用示例:`http://localhost:3001/third-party-app.html`
|
||||
2. 填入刚才创建的客户端ID和密钥
|
||||
3. 点击"开始OAuth授权"按钮
|
||||
|
||||
### 步骤 5: 授权流程
|
||||
|
||||
1. 系统重定向到授权页面:`http://localhost:3001/oauth/authorize`
|
||||
2. 用户看到应用信息和请求的权限
|
||||
3. 用户选择"同意授权"或"拒绝授权"
|
||||
|
||||
### 步骤 6: 获取访问令牌
|
||||
|
||||
1. 如果用户同意授权,系统会重定向回第三方应用
|
||||
2. 第三方应用使用授权码交换访问令牌
|
||||
3. 使用访问令牌获取用户信息
|
||||
|
||||
## API 端点说明
|
||||
|
||||
### 1. 获取授权信息
|
||||
```
|
||||
GET /api/oauth/authorize
|
||||
```
|
||||
- 验证OAuth请求参数
|
||||
- 返回应用信息和请求的权限
|
||||
- 不直接生成授权码
|
||||
|
||||
### 2. 用户同意/拒绝授权
|
||||
```
|
||||
POST /api/oauth/authorize/consent
|
||||
```
|
||||
- 处理用户的授权决定
|
||||
- 如果同意,生成授权码
|
||||
- 如果拒绝,返回错误信息
|
||||
|
||||
### 3. 交换访问令牌
|
||||
```
|
||||
POST /api/oauth/token
|
||||
```
|
||||
- 使用授权码交换访问令牌
|
||||
- 返回访问令牌和刷新令牌
|
||||
|
||||
### 4. 获取用户信息
|
||||
```
|
||||
GET /api/oauth/userinfo
|
||||
```
|
||||
- 使用访问令牌获取用户信息
|
||||
|
||||
## 安全特性
|
||||
|
||||
### 1. 用户同意机制
|
||||
- ✅ 用户必须明确同意才能获得授权码
|
||||
- ✅ 显示详细的应用信息和权限范围
|
||||
- ✅ 用户可以拒绝授权
|
||||
|
||||
### 2. 令牌安全
|
||||
- ✅ 访问令牌有过期时间
|
||||
- ✅ 支持刷新令牌机制
|
||||
- ✅ 令牌可以撤销
|
||||
|
||||
### 3. 客户端验证
|
||||
- ✅ 验证客户端ID和密钥
|
||||
- ✅ 验证重定向URI
|
||||
- ✅ 防止重放攻击
|
||||
|
||||
## 测试方法
|
||||
|
||||
### 1. 自动化测试
|
||||
```bash
|
||||
npm run test:oauth-flow
|
||||
```
|
||||
|
||||
### 2. 手动测试
|
||||
1. 使用前端界面测试
|
||||
2. 使用第三方应用示例测试
|
||||
3. 使用curl命令测试
|
||||
|
||||
### 3. 错误测试
|
||||
- 测试无效的客户端ID
|
||||
- 测试无效的重定向URI
|
||||
- 测试过期的授权码
|
||||
- 测试用户拒绝授权
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么需要用户同意?
|
||||
A: 这是OAuth 2.0的核心安全特性,确保用户对第三方应用的授权是明确和可控的。
|
||||
|
||||
### Q: 授权码可以重复使用吗?
|
||||
A: 不可以,授权码只能使用一次,使用后立即失效。
|
||||
|
||||
### Q: 访问令牌过期怎么办?
|
||||
A: 使用刷新令牌获取新的访问令牌,无需用户重新授权。
|
||||
|
||||
### Q: 如何撤销授权?
|
||||
A: 可以通过撤销刷新令牌来撤销授权,用户需要重新授权。
|
||||
|
||||
## 扩展功能
|
||||
|
||||
### 1. 权限范围管理
|
||||
- 支持细粒度的权限控制
|
||||
- 用户可以部分授权某些权限
|
||||
|
||||
### 2. 应用管理
|
||||
- 用户可以查看和管理已授权的应用
|
||||
- 支持撤销特定应用的授权
|
||||
|
||||
### 3. 审计日志
|
||||
- 记录所有授权和撤销操作
|
||||
- 提供安全审计功能
|
||||
|
||||
## 总结
|
||||
|
||||
这个OAuth 2.0实现提供了完整的授权码流程,包括:
|
||||
|
||||
1. ✅ 用户同意机制
|
||||
2. ✅ 安全的令牌管理
|
||||
3. ✅ 完整的API端点
|
||||
4. ✅ 友好的用户界面
|
||||
5. ✅ 详细的测试和文档
|
||||
|
||||
这确保了OAuth授权的安全性和用户体验的平衡。
|
@ -8,6 +8,8 @@
|
||||
"dev": "nodemon index.js",
|
||||
"test": "node test-api.js",
|
||||
"test:oauth": "node test-oauth.js",
|
||||
"test:oauth-flow": "node test-oauth-flow.js",
|
||||
"test:redirect-uri": "node test-redirect-uri.js",
|
||||
"dev:frontend": "vite",
|
||||
"build:frontend": "vite build",
|
||||
"preview:frontend": "vite preview"
|
||||
|
289
public/third-party-app.html
Normal file
289
public/third-party-app.html
Normal file
@ -0,0 +1,289 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>第三方应用示例</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.status-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.status-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center align-items-center min-vh-100">
|
||||
<div class="col-md-8 col-lg-6">
|
||||
<div class="app-container p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="fas fa-rocket fa-3x text-primary mb-3"></i>
|
||||
<h2 class="fw-bold">第三方应用示例</h2>
|
||||
<p class="text-muted">演示OAuth 2.0授权流程</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card status-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-user-lock fa-2x text-warning mb-2"></i>
|
||||
<h5 class="card-title">用户状态</h5>
|
||||
<p class="card-text" id="userStatus">未登录</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card status-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-key fa-2x text-success mb-2"></i>
|
||||
<h5 class="card-title">访问令牌</h5>
|
||||
<p class="card-text" id="tokenStatus">未获取</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-cog me-2"></i>应用配置
|
||||
</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">客户端ID</label>
|
||||
<input type="text" class="form-control" id="clientId" placeholder="输入客户端ID">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">客户端密钥</label>
|
||||
<input type="password" class="form-control" id="clientSecret" placeholder="输入客户端密钥">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-primary btn-lg" onclick="startOAuthFlow()">
|
||||
<i class="fas fa-sign-in-alt me-2"></i>开始OAuth授权
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="getUserInfo()" id="getUserInfoBtn" disabled>
|
||||
<i class="fas fa-user me-2"></i>获取用户信息
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<h6><i class="fas fa-info-circle me-2"></i>使用说明</h6>
|
||||
<ol class="mb-0">
|
||||
<li>在个人中心创建OAuth客户端</li>
|
||||
<li>将客户端ID和密钥填入上方配置</li>
|
||||
<li>点击"开始OAuth授权"按钮</li>
|
||||
<li>在授权页面选择同意或拒绝</li>
|
||||
<li>授权成功后可以获取用户信息</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-terminal me-2"></i>API响应
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre id="apiResponse" class="bg-light p-3 rounded"
|
||||
style="max-height: 200px; overflow-y: auto;">等待操作...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
let accessToken = '';
|
||||
let userInfo = null;
|
||||
|
||||
// 检查URL参数
|
||||
function checkUrlParams() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const error = urlParams.get('error');
|
||||
const state = urlParams.get('state');
|
||||
|
||||
if (code) {
|
||||
logResponse('收到授权码', { code, state });
|
||||
exchangeCodeForToken(code);
|
||||
} else if (error) {
|
||||
logResponse('授权被拒绝', { error, error_description: urlParams.get('error_description'), state });
|
||||
updateStatus('用户拒绝授权', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 开始OAuth流程
|
||||
function startOAuthFlow() {
|
||||
const clientId = document.getElementById('clientId').value.trim();
|
||||
const clientSecret = document.getElementById('clientSecret').value.trim();
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
alert('请输入客户端ID和密钥');
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem('client_id', clientId);
|
||||
localStorage.setItem('client_secret', clientSecret);
|
||||
|
||||
const redirectUri = encodeURIComponent(window.location.origin + window.location.pathname);
|
||||
const scope = encodeURIComponent('read write');
|
||||
const state = 'test123';
|
||||
|
||||
const authUrl = `http://localhost:3001/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${state}&response_type=code`;
|
||||
|
||||
logResponse('重定向到授权页面', { authUrl });
|
||||
window.location.href = authUrl;
|
||||
}
|
||||
|
||||
|
||||
// 使用授权码交换访问令牌
|
||||
async function exchangeCodeForToken(code) {
|
||||
let clientId = document.getElementById('clientId').value.trim();
|
||||
let clientSecret = document.getElementById('clientSecret').value.trim();
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
clientId = localStorage.getItem('client_id');
|
||||
clientSecret = localStorage.getItem('client_secret');
|
||||
}
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
logResponse('错误', { message: '请先配置客户端ID和密钥' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectUri = window.location.origin + window.location.pathname;
|
||||
// const redirectUri = window.location.href;
|
||||
|
||||
console.log(redirectUri)
|
||||
const response = await fetch('http://localhost:3000/api/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code: code,
|
||||
redirect_uri: redirectUri
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
accessToken = result.data.access_token;
|
||||
updateStatus('已登录', 'success');
|
||||
updateTokenStatus('已获取');
|
||||
document.getElementById('getUserInfoBtn').disabled = false;
|
||||
logResponse('令牌交换成功', result.data);
|
||||
} else {
|
||||
logResponse('令牌交换失败', result);
|
||||
updateStatus('令牌获取失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
logResponse('请求错误', { error: error.message });
|
||||
updateStatus('网络错误', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
async function getUserInfo() {
|
||||
if (!accessToken) {
|
||||
logResponse('错误', { message: '没有访问令牌' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/api/oauth/userinfo', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
userInfo = result.data;
|
||||
updateStatus(`已登录: ${userInfo.username}`, 'success');
|
||||
logResponse('用户信息获取成功', result.data);
|
||||
} else {
|
||||
logResponse('用户信息获取失败', result);
|
||||
}
|
||||
} catch (error) {
|
||||
logResponse('请求错误', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 更新状态显示
|
||||
function updateStatus(status, type = 'info') {
|
||||
const statusElement = document.getElementById('userStatus');
|
||||
statusElement.textContent = status;
|
||||
statusElement.className = `card-text text-${type === 'success' ? 'success' : type === 'error' ? 'danger' : 'warning'}`;
|
||||
}
|
||||
|
||||
function updateTokenStatus(status) {
|
||||
document.getElementById('tokenStatus').textContent = status;
|
||||
}
|
||||
|
||||
// 记录API响应
|
||||
function logResponse(title, data) {
|
||||
const responseElement = document.getElementById('apiResponse');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const log = `[${timestamp}] ${title}:\n${JSON.stringify(data, null, 2)}\n\n`;
|
||||
responseElement.textContent += log;
|
||||
responseElement.scrollTop = responseElement.scrollHeight;
|
||||
}
|
||||
|
||||
// 页面加载时检查URL参数
|
||||
window.addEventListener('load', () => {
|
||||
checkUrlParams();
|
||||
|
||||
// 从 localStorage 读取客户端ID和密钥
|
||||
const savedClientId = localStorage.getItem('client_id');
|
||||
const savedClientSecret = localStorage.getItem('client_secret');
|
||||
|
||||
if (savedClientId) {
|
||||
document.getElementById('clientId').value = savedClientId;
|
||||
}
|
||||
|
||||
if (savedClientSecret) {
|
||||
document.getElementById('clientSecret').value = savedClientSecret;
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
151
routes/oauth.js
151
routes/oauth.js
@ -8,6 +8,39 @@ const { authenticateOAuthToken, requireScope } = require('../middleware/oauth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 验证重定向URI的通用函数
|
||||
const isValidRedirectUri = (storedUris, requestedUri) => {
|
||||
try {
|
||||
const requestedUrl = new URL(requestedUri);
|
||||
|
||||
for (const storedUri of storedUris) {
|
||||
const storedUrl = new URL(storedUri);
|
||||
|
||||
// 检查协议是否匹配
|
||||
if (requestedUrl.protocol !== storedUrl.protocol) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取文件名(路径的最后一部分)
|
||||
const requestedFilename = requestedUrl.pathname.split('/').pop();
|
||||
const storedFilename = storedUrl.pathname.split('/').pop();
|
||||
|
||||
// 检查文件名是否匹配,忽略路径前缀
|
||||
if (requestedFilename === storedFilename) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果文件名不匹配,检查完整路径是否匹配
|
||||
if (requestedUrl.pathname === storedUrl.pathname) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 验证中间件
|
||||
const handleValidationErrors = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
@ -24,7 +57,7 @@ const handleValidationErrors = (req, res, next) => {
|
||||
next();
|
||||
};
|
||||
|
||||
// 1. 授权端点 - 用户授权第三方应用
|
||||
// 1. 授权端点 - 验证参数并返回授权信息
|
||||
router.get('/authorize', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
@ -61,10 +94,14 @@ router.get('/authorize', authenticateToken, async (req, res) => {
|
||||
}
|
||||
|
||||
// 验证重定向URI
|
||||
if (!client.redirect_uris.includes(redirect_uri)) {
|
||||
if (!isValidRedirectUri(client.redirect_uris, redirect_uri)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的重定向URI'
|
||||
message: '无效的重定向URI',
|
||||
debug: {
|
||||
requested: redirect_uri,
|
||||
allowed: client.redirect_uris
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -77,7 +114,101 @@ router.get('/authorize', authenticateToken, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 生成授权码
|
||||
// 返回授权信息,让前端显示授权页面
|
||||
const scopes = scope ? scope.split(' ') : ['read', 'write'];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '授权信息获取成功',
|
||||
data: {
|
||||
client: {
|
||||
id: client.client_id,
|
||||
name: client.client_name,
|
||||
description: client.description
|
||||
},
|
||||
scopes: scopes,
|
||||
state: state,
|
||||
redirect_uri: redirect_uri
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('授权信息获取失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 用户同意授权端点
|
||||
router.post('/authorize/consent', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
client_id,
|
||||
redirect_uri,
|
||||
scope,
|
||||
state,
|
||||
approved
|
||||
} = req.body;
|
||||
|
||||
// 验证必需参数
|
||||
if (!client_id || !redirect_uri || approved === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '缺少必需参数'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证客户端
|
||||
const client = await OAuthClient.findByClientId(client_id);
|
||||
if (!client) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的客户端ID'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证重定向URI
|
||||
if (!isValidRedirectUri(client.redirect_uris, redirect_uri)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的重定向URI',
|
||||
debug: {
|
||||
requested: redirect_uri,
|
||||
allowed: client.redirect_uris
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const user = await User.findByUsername(req.user.username);
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
if (!approved) {
|
||||
// 用户拒绝授权
|
||||
const errorUrl = new URL(redirect_uri);
|
||||
errorUrl.searchParams.set('error', 'access_denied');
|
||||
errorUrl.searchParams.set('error_description', '用户拒绝授权');
|
||||
if (state) {
|
||||
errorUrl.searchParams.set('state', state);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: '用户拒绝授权',
|
||||
data: {
|
||||
redirect_url: errorUrl.toString()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 用户同意授权,生成授权码
|
||||
const authCode = OAuthToken.generateAuthCode();
|
||||
const scopes = scope ? scope.split(' ') : ['read', 'write'];
|
||||
|
||||
@ -96,8 +227,6 @@ router.get('/authorize', authenticateToken, async (req, res) => {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
|
||||
// 对于API测试,直接返回授权码
|
||||
// 对于生产环境,应该重定向到redirectUrl
|
||||
res.json({
|
||||
success: true,
|
||||
message: '授权成功',
|
||||
@ -109,7 +238,7 @@ router.get('/authorize', authenticateToken, async (req, res) => {
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('授权失败:', error);
|
||||
console.error('授权处理失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
@ -117,7 +246,7 @@ router.get('/authorize', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 令牌端点 - 交换授权码获取访问令牌
|
||||
// 3. 令牌端点 - 交换授权码获取访问令牌
|
||||
router.post('/token', [
|
||||
body('grant_type').notEmpty().withMessage('grant_type不能为空'),
|
||||
body('client_id').notEmpty().withMessage('client_id不能为空'),
|
||||
@ -273,7 +402,7 @@ router.post('/token', [
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 撤销令牌端点
|
||||
// 4. 撤销令牌端点
|
||||
router.post('/revoke', [
|
||||
body('token').notEmpty().withMessage('token不能为空'),
|
||||
body('client_id').notEmpty().withMessage('client_id不能为空'),
|
||||
@ -312,7 +441,7 @@ router.post('/revoke', [
|
||||
}
|
||||
});
|
||||
|
||||
// 4. 用户信息端点
|
||||
// 5. 用户信息端点
|
||||
router.get('/userinfo', authenticateOAuthToken, requireScope('read'), async (req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
@ -333,7 +462,7 @@ router.get('/userinfo', authenticateOAuthToken, requireScope('read'), async (req
|
||||
}
|
||||
});
|
||||
|
||||
// 5. 令牌信息端点
|
||||
// 6. 令牌信息端点
|
||||
router.get('/tokeninfo', async (req, res) => {
|
||||
try {
|
||||
const { access_token } = req.query;
|
||||
|
@ -45,37 +45,64 @@ const OAuthAuthorize = () => {
|
||||
const responseType = searchParams.get('response_type')
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证OAuth参数
|
||||
if (!clientId || !redirectUri || responseType !== 'code') {
|
||||
setError('无效的授权请求')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 这里可以添加客户端信息获取逻辑
|
||||
// 为了演示,我们使用默认信息
|
||||
setClientInfo({
|
||||
name: '第三方应用',
|
||||
description: '请求访问您的账户信息',
|
||||
scopes: scope ? scope.split(' ') : ['read', 'write']
|
||||
})
|
||||
}, [user, clientId, redirectUri, scope, responseType, navigate])
|
||||
// 获取授权信息
|
||||
const fetchAuthInfo = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: scope || 'read write',
|
||||
state: state || ''
|
||||
})
|
||||
|
||||
const response = await axios.get(`/api/oauth/authorize?${params}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(response.data.data.redirect_uri)
|
||||
setClientInfo({
|
||||
name: response.data.data.client.name,
|
||||
description: response.data.data.client.description,
|
||||
scopes: response.data.data.scopes,
|
||||
clientId: response.data.data.client.id,
|
||||
redirectUri: response.data.data.redirect_uri,
|
||||
state: response.data.data.state
|
||||
})
|
||||
} else {
|
||||
setError(response.data.message || '获取授权信息失败')
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error.response?.data?.message || '获取授权信息失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAuthInfo()
|
||||
}, [clientId, redirectUri, scope, responseType, state])
|
||||
|
||||
const handleAuthorize = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: scope || 'read write',
|
||||
state: state || ''
|
||||
})
|
||||
|
||||
const response = await axios.get(`/api/oauth/authorize?${params}`, {
|
||||
const response = await axios.post('/api/oauth/authorize/consent', {
|
||||
client_id: clientInfo.clientId,
|
||||
redirect_uri: clientInfo.redirectUri,
|
||||
scope: clientInfo.scopes.join(' '),
|
||||
state: clientInfo.state,
|
||||
approved: true
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
@ -87,21 +114,37 @@ const OAuthAuthorize = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error.response?.data?.message || '授权失败')
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleDeny = () => {
|
||||
// 拒绝授权,重定向回应用
|
||||
const denyUrl = new URL(redirectUri)
|
||||
denyUrl.searchParams.set('error', 'access_denied')
|
||||
if (state) {
|
||||
denyUrl.searchParams.set('state', state)
|
||||
const handleDeny = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await axios.post('/api/oauth/authorize/consent', {
|
||||
client_id: clientInfo.clientId,
|
||||
redirect_uri: clientInfo.redirectUri,
|
||||
scope: clientInfo.scopes.join(' '),
|
||||
state: clientInfo.state,
|
||||
approved: false
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
const { redirect_url } = response.data.data
|
||||
window.location.href = redirect_url
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error.response?.data?.message || '拒绝授权失败')
|
||||
setLoading(false)
|
||||
}
|
||||
window.location.href = denyUrl.toString()
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
// 显示加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Box
|
||||
|
177
test-oauth-flow.js
Normal file
177
test-oauth-flow.js
Normal file
@ -0,0 +1,177 @@
|
||||
const axios = require('axios');
|
||||
|
||||
// 配置
|
||||
const BASE_URL = 'http://localhost:3000/api';
|
||||
const FRONTEND_URL = 'http://localhost:3001';
|
||||
|
||||
// 测试用户信息
|
||||
const testUser = {
|
||||
username: `testuser_${Date.now()}`,
|
||||
email: `testuser_${Date.now()}@example.com`,
|
||||
password: 'TestPassword123'
|
||||
};
|
||||
|
||||
// 测试客户端信息
|
||||
const testClient = {
|
||||
client_name: '测试应用',
|
||||
description: '这是一个测试OAuth流程的应用',
|
||||
redirect_uris: ['http://localhost:3001/callback']
|
||||
};
|
||||
|
||||
let userToken = '';
|
||||
let clientId = '';
|
||||
let clientSecret = '';
|
||||
|
||||
async function testOAuthFlow() {
|
||||
console.log('🚀 开始测试OAuth授权流程...\n');
|
||||
|
||||
try {
|
||||
// 1. 注册用户
|
||||
console.log('1. 注册测试用户...');
|
||||
const registerResponse = await axios.post(`${BASE_URL}/auth/register`, testUser);
|
||||
if (registerResponse.data.success) {
|
||||
console.log('✅ 用户注册成功');
|
||||
} else {
|
||||
console.log('❌ 用户注册失败:', registerResponse.data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 用户登录
|
||||
console.log('\n2. 用户登录...');
|
||||
const loginResponse = await axios.post(`${BASE_URL}/auth/login`, {
|
||||
username: testUser.username,
|
||||
password: testUser.password
|
||||
});
|
||||
|
||||
if (loginResponse.data.success) {
|
||||
userToken = loginResponse.data.data.token;
|
||||
console.log('✅ 用户登录成功');
|
||||
} else {
|
||||
console.log('❌ 用户登录失败:', loginResponse.data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 创建OAuth客户端
|
||||
console.log('\n3. 创建OAuth客户端...');
|
||||
const clientResponse = await axios.post(`${BASE_URL}/oauth/clients`, testClient, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${userToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (clientResponse.data.success) {
|
||||
clientId = clientResponse.data.data.client_id;
|
||||
clientSecret = clientResponse.data.data.client_secret;
|
||||
console.log('✅ OAuth客户端创建成功');
|
||||
console.log(` 客户端ID: ${clientId}`);
|
||||
console.log(` 客户端密钥: ${clientSecret}`);
|
||||
} else {
|
||||
console.log('❌ OAuth客户端创建失败:', clientResponse.data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 获取授权信息
|
||||
console.log('\n4. 获取授权信息...');
|
||||
const authParams = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: 'http://localhost:3001/callback',
|
||||
scope: 'read write',
|
||||
state: 'test123'
|
||||
});
|
||||
|
||||
const authResponse = await axios.get(`${BASE_URL}/oauth/authorize?${authParams}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${userToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (authResponse.data.success) {
|
||||
console.log('✅ 授权信息获取成功');
|
||||
console.log(` 应用名称: ${authResponse.data.data.client.name}`);
|
||||
console.log(` 应用描述: ${authResponse.data.data.client.description}`);
|
||||
console.log(` 请求权限: ${authResponse.data.data.scopes.join(', ')}`);
|
||||
} else {
|
||||
console.log('❌ 授权信息获取失败:', authResponse.data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 用户同意授权
|
||||
console.log('\n5. 用户同意授权...');
|
||||
const consentResponse = await axios.post(`${BASE_URL}/oauth/authorize/consent`, {
|
||||
client_id: clientId,
|
||||
redirect_uri: 'http://localhost:3001/callback',
|
||||
scope: 'read write',
|
||||
state: 'test123',
|
||||
approved: true
|
||||
}, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${userToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (consentResponse.data.success) {
|
||||
console.log('✅ 用户同意授权成功');
|
||||
console.log(` 重定向URL: ${consentResponse.data.data.redirect_url}`);
|
||||
console.log(` 授权码: ${consentResponse.data.data.code}`);
|
||||
} else {
|
||||
console.log('❌ 用户同意授权失败:', consentResponse.data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. 使用授权码交换访问令牌
|
||||
console.log('\n6. 使用授权码交换访问令牌...');
|
||||
const tokenResponse = await axios.post(`${BASE_URL}/oauth/token`, {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code: consentResponse.data.data.code,
|
||||
redirect_uri: 'http://localhost:3001/callback'
|
||||
});
|
||||
|
||||
if (tokenResponse.data.success) {
|
||||
console.log('✅ 访问令牌获取成功');
|
||||
console.log(` 访问令牌: ${tokenResponse.data.data.access_token.substring(0, 20)}...`);
|
||||
console.log(` 刷新令牌: ${tokenResponse.data.data.refresh_token.substring(0, 20)}...`);
|
||||
console.log(` 过期时间: ${tokenResponse.data.data.expires_in}秒`);
|
||||
} else {
|
||||
console.log('❌ 访问令牌获取失败:', tokenResponse.data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. 使用访问令牌获取用户信息
|
||||
console.log('\n7. 使用访问令牌获取用户信息...');
|
||||
const userInfoResponse = await axios.get(`${BASE_URL}/oauth/userinfo`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${tokenResponse.data.data.access_token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (userInfoResponse.data.success) {
|
||||
console.log('✅ 用户信息获取成功');
|
||||
console.log(` 用户名: ${userInfoResponse.data.data.username}`);
|
||||
console.log(` 邮箱: ${userInfoResponse.data.data.email}`);
|
||||
} else {
|
||||
console.log('❌ 用户信息获取失败:', userInfoResponse.data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n🎉 OAuth授权流程测试完成!');
|
||||
console.log('\n📋 测试总结:');
|
||||
console.log('✅ 用户注册和登录');
|
||||
console.log('✅ OAuth客户端创建');
|
||||
console.log('✅ 授权信息获取');
|
||||
console.log('✅ 用户同意授权');
|
||||
console.log('✅ 授权码交换访问令牌');
|
||||
console.log('✅ 使用访问令牌获取用户信息');
|
||||
|
||||
console.log('\n🔗 前端测试链接:');
|
||||
console.log(`${FRONTEND_URL}/oauth/authorize?client_id=${clientId}&redirect_uri=http://localhost:3001/callback&scope=read%20write&state=test123`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 测试过程中发生错误:', error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
testOAuthFlow();
|
Reference in New Issue
Block a user