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授权流程

+
+ +
+
+
+
+ +
用户状态
+

未登录

+
+
+
+
+
+
+ +
访问令牌
+

未获取

+
+
+
+
+ +
+
+
+ 应用配置 +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ + +
+ +
+ +
+ +
+
+
+
+ API响应 +
+
+
+
等待操作...
+
+
+
+
+
+
+
+ + + + + + \ 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 (