diff --git a/README.md b/README.md
index 67eae55..596b3b3 100644
--- a/README.md
+++ b/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
+```
## 前端技术栈
diff --git a/demo-oauth-flow.md b/demo-oauth-flow.md
new file mode 100644
index 0000000..499e2b9
--- /dev/null
+++ b/demo-oauth-flow.md
@@ -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授权的安全性和用户体验的平衡。
\ No newline at end of file
diff --git a/package.json b/package.json
index 6c5d325..eeb7f05 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/public/third-party-app.html b/public/third-party-app.html
new file mode 100644
index 0000000..82a8ce5
--- /dev/null
+++ b/public/third-party-app.html
@@ -0,0 +1,289 @@
+
+
+
+
+
+
+ 第三方应用示例
+
+
+
+
+
+
+
+
+
+
+
+
+
第三方应用示例
+
演示OAuth 2.0授权流程
+
+
+
+
+
+
+
+
+
+
+
+
+
+
使用说明
+
+ - 在个人中心创建OAuth客户端
+ - 将客户端ID和密钥填入上方配置
+ - 点击"开始OAuth授权"按钮
+ - 在授权页面选择同意或拒绝
+ - 授权成功后可以获取用户信息
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/routes/oauth.js b/routes/oauth.js
index 12048cf..7e77409 100644
--- a/routes/oauth.js
+++ b/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;
diff --git a/src/pages/OAuthAuthorize.jsx b/src/pages/OAuthAuthorize.jsx
index e845481..223489c 100644
--- a/src/pages/OAuthAuthorize.jsx
+++ b/src/pages/OAuthAuthorize.jsx
@@ -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 (