oauth_view.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762
  1. """
  2. 授权管理视图路由
  3. 包含:SSO验证、授权码生成、Token管理、用户信息获取
  4. """
  5. import sys
  6. import os
  7. # 添加src目录到Python路径
  8. sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
  9. sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../..'))
  10. import logging
  11. import json
  12. import secrets
  13. from fastapi import APIRouter, Depends, HTTPException, Request, Response
  14. from fastapi.responses import HTMLResponse, RedirectResponse
  15. from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
  16. from typing import Optional
  17. from datetime import datetime, timezone
  18. from app.services.jwt_token import verify_token, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES
  19. from app.services.oauth_service import OAuthService
  20. from app.base.async_mysql_connection import get_db_connection
  21. from app.utils import redis_token_manager as rtm
  22. # 获取logger
  23. logger = logging.getLogger(__name__)
  24. router = APIRouter(prefix="/oauth", tags=["授权管理"])
  25. security = HTTPBearer()
  26. security_optional = HTTPBearer(auto_error=False)
  27. async def get_oauth_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
  28. """OAuth2 Token 验证依赖:使用 OAuth 专用 Redis key 验证令牌"""
  29. token = credentials.credentials
  30. payload = verify_token(token)
  31. if not payload:
  32. raise HTTPException(status_code=401, detail="无效的访问令牌")
  33. client_id = payload.get("client_id")
  34. user_id = rtm.get_oauth_access_token_user_id(token, client_id)
  35. if not user_id:
  36. raise HTTPException(status_code=401, detail="访问令牌已失效")
  37. return payload
  38. # OAuth2 授权端点
  39. @router.get("/authorize")
  40. async def oauth_authorize(
  41. response_type: str,
  42. client_id: str,
  43. redirect_uri: str,
  44. scope: str = "profile",
  45. state: str = None
  46. ):
  47. """OAuth2授权端点"""
  48. try:
  49. logger.info(f"OAuth授权请求: client_id={client_id}, redirect_uri={redirect_uri}, scope={scope}")
  50. # 验证必要参数
  51. if not response_type or not client_id or not redirect_uri:
  52. error_url = f"{redirect_uri}?error=invalid_request&error_description=Missing required parameters"
  53. if state:
  54. error_url += f"&state={state}"
  55. return {"error": "invalid_request", "redirect_url": error_url}
  56. # 验证response_type
  57. if response_type != "code":
  58. error_url = f"{redirect_uri}?error=unsupported_response_type&error_description=Only authorization code flow is supported"
  59. if state:
  60. error_url += f"&state={state}"
  61. return {"error": "unsupported_response_type", "redirect_url": error_url}
  62. # 调用 service 层验证客户端和重定向URI
  63. oauth_service = OAuthService()
  64. success, app_info, message = await oauth_service.validate_client_and_redirect_uri(client_id, redirect_uri)
  65. if not success:
  66. error_url = f"{redirect_uri}?error=invalid_client&error_description={message}"
  67. if state:
  68. error_url += f"&state={state}"
  69. return {"error": "invalid_client", "redirect_url": error_url}
  70. # 验证scope
  71. app_scopes = app_info.get("scope", [])
  72. requested_scopes = scope.split() if scope else []
  73. invalid_scopes = [s for s in requested_scopes if s not in app_scopes]
  74. if invalid_scopes:
  75. error_url = f"{redirect_uri}?error=invalid_scope&error_description=Invalid scope: {' '.join(invalid_scopes)}"
  76. if state:
  77. error_url += f"&state={state}"
  78. return {"error": "invalid_scope", "redirect_url": error_url}
  79. # TODO: 检查用户登录状态
  80. # 这里应该检查用户是否已登录(通过session或cookie)
  81. # 如果未登录,应该重定向到登录页面
  82. # 临时方案:返回登录页面,让用户先登录
  83. # 生产环境应该使用session管理
  84. # 构建登录页面URL,登录后返回授权页面
  85. login_page_url = f"/oauth/login?response_type={response_type}&client_id={client_id}&redirect_uri={redirect_uri}&scope={scope}"
  86. if state:
  87. login_page_url += f"&state={state}"
  88. logger.info(f"需要用户登录,重定向到登录页面: {login_page_url}")
  89. return RedirectResponse(url=login_page_url, status_code=302)
  90. # 非受信任应用需要用户授权确认
  91. # 这里返回授权页面HTML
  92. # 为简化,暂时跳过授权确认页面,直接跳转到登录
  93. except Exception as e:
  94. logger.exception("OAuth授权错误")
  95. error_url = f"{redirect_uri}?error=server_error&error_description=Internal server error"
  96. if state:
  97. error_url += f"&state={state}"
  98. return {"error": "server_error", "redirect_url": error_url}
  99. @router.get("/login")
  100. async def oauth_login_page(
  101. response_type: str,
  102. client_id: str,
  103. redirect_uri: str,
  104. scope: str = "profile",
  105. state: str = None
  106. ):
  107. """OAuth2登录页面"""
  108. try:
  109. logger.info(f"显示OAuth登录页面: client_id={client_id}")
  110. # 调用 service 层获取应用信息
  111. oauth_service = OAuthService()
  112. success, app_info, message = await oauth_service.validate_client_and_redirect_uri(client_id, redirect_uri)
  113. app_name = app_info.get("name", "未知应用") if success else "未知应用"
  114. # 构建登录页面HTML
  115. login_html = f"""
  116. <!DOCTYPE html>
  117. <html lang="zh-CN">
  118. <head>
  119. <meta charset="UTF-8">
  120. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  121. <title>四川路桥AI中台统一认证管理 - {app_name}</title>
  122. <style>
  123. * {{ margin: 0; padding: 0; box-sizing: border-box; }}
  124. body {{
  125. font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  126. background: #0a0e27;
  127. margin: 0;
  128. min-height: 100vh;
  129. display: flex;
  130. align-items: center;
  131. justify-content: center;
  132. overflow: hidden;
  133. position: relative;
  134. }}
  135. /* 动态背景网格 */
  136. body::before {{
  137. content: '';
  138. position: absolute;
  139. top: -50%; left: -50%;
  140. width: 200%; height: 200%;
  141. background-image:
  142. linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px),
  143. linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px);
  144. background-size: 50px 50px;
  145. animation: gridMove 20s linear infinite;
  146. pointer-events: none;
  147. }}
  148. @keyframes gridMove {{
  149. 0% {{ transform: translate(0, 0); }}
  150. 100% {{ transform: translate(50px, 50px); }}
  151. }}
  152. /* 浮动光球 */
  153. .orb {{
  154. position: absolute;
  155. border-radius: 50%;
  156. filter: blur(80px);
  157. opacity: 0.4;
  158. pointer-events: none;
  159. animation: float 8s ease-in-out infinite;
  160. }}
  161. .orb-1 {{ width: 300px; height: 300px; background: #00d4ff; top: 10%; left: 10%; animation-delay: 0s; }}
  162. .orb-2 {{ width: 250px; height: 250px; background: #7c3aed; bottom: 10%; right: 10%; animation-delay: -3s; }}
  163. .orb-3 {{ width: 200px; height: 200px; background: #06b6d4; top: 50%; left: 70%; animation-delay: -5s; }}
  164. @keyframes float {{
  165. 0%, 100% {{ transform: translate(0, 0) scale(1); }}
  166. 33% {{ transform: translate(30px, -30px) scale(1.1); }}
  167. 66% {{ transform: translate(-20px, 20px) scale(0.95); }}
  168. }}
  169. /* 登录卡片 */
  170. .login-container {{
  171. position: relative;
  172. z-index: 10;
  173. background: rgba(15, 23, 42, 0.7);
  174. backdrop-filter: blur(20px);
  175. border: 1px solid rgba(0, 212, 255, 0.15);
  176. border-radius: 20px;
  177. padding: 48px 40px;
  178. max-width: 420px;
  179. width: 90%;
  180. box-shadow:
  181. 0 0 0 1px rgba(0, 212, 255, 0.05),
  182. 0 25px 50px -12px rgba(0, 0, 0, 0.5),
  183. 0 0 60px rgba(0, 212, 255, 0.08);
  184. overflow: hidden;
  185. }}
  186. /* 顶部发光条 */
  187. .login-container::before {{
  188. content: '';
  189. position: absolute;
  190. top: 0; left: 0; right: 0;
  191. height: 2px;
  192. background: linear-gradient(90deg, transparent, #00d4ff, #7c3aed, transparent);
  193. animation: shimmer 3s ease-in-out infinite;
  194. }}
  195. @keyframes shimmer {{
  196. 0% {{ opacity: 0.3; }}
  197. 50% {{ opacity: 1; }}
  198. 100% {{ opacity: 0.3; }}
  199. }}
  200. .login-header {{
  201. text-align: center;
  202. margin-bottom: 32px;
  203. }}
  204. .logo-icon {{
  205. width: 64px; height: 64px;
  206. margin: 0 auto 16px;
  207. background: linear-gradient(135deg, #00d4ff, #7c3aed);
  208. border-radius: 16px;
  209. display: flex;
  210. align-items: center;
  211. justify-content: center;
  212. font-size: 32px;
  213. box-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
  214. animation: pulse 2s ease-in-out infinite;
  215. }}
  216. @keyframes pulse {{
  217. 0%, 100% {{ box-shadow: 0 0 20px rgba(0, 212, 255, 0.3); }}
  218. 50% {{ box-shadow: 0 0 30px rgba(0, 212, 255, 0.5); }}
  219. }}
  220. .login-header h2 {{
  221. font-size: 26px;
  222. font-weight: 700;
  223. background: linear-gradient(90deg, #00d4ff, #a78bfa);
  224. -webkit-background-clip: text;
  225. -webkit-text-fill-color: transparent;
  226. background-clip: text;
  227. margin-bottom: 8px;
  228. letter-spacing: 1px;
  229. }}
  230. .login-header p {{
  231. color: rgba(148, 163, 184, 0.8);
  232. font-size: 14px;
  233. }}
  234. .app-info {{
  235. background: rgba(0, 212, 255, 0.05);
  236. border: 1px solid rgba(0, 212, 255, 0.1);
  237. padding: 14px;
  238. border-radius: 12px;
  239. margin-bottom: 28px;
  240. text-align: center;
  241. color: #94a3b8;
  242. font-size: 13px;
  243. }}
  244. .app-info strong {{
  245. color: #00d4ff;
  246. font-weight: 600;
  247. }}
  248. .form-group {{
  249. margin-bottom: 20px;
  250. }}
  251. .form-group label {{
  252. display: block;
  253. margin-bottom: 8px;
  254. font-size: 13px;
  255. font-weight: 500;
  256. color: #94a3b8;
  257. text-transform: uppercase;
  258. letter-spacing: 0.5px;
  259. }}
  260. .form-group input {{
  261. width: 100%;
  262. padding: 14px 16px;
  263. background: rgba(15, 23, 42, 0.6);
  264. border: 1px solid rgba(148, 163, 184, 0.2);
  265. border-radius: 10px;
  266. font-size: 15px;
  267. color: #e2e8f0;
  268. box-sizing: border-box;
  269. transition: all 0.3s ease;
  270. }}
  271. .form-group input::placeholder {{
  272. color: rgba(148, 163, 184, 0.4);
  273. }}
  274. .form-group input:focus {{
  275. outline: none;
  276. border-color: #00d4ff;
  277. box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.15), 0 0 15px rgba(0, 212, 255, 0.1);
  278. background: rgba(15, 23, 42, 0.8);
  279. }}
  280. .btn {{
  281. width: 100%;
  282. padding: 14px;
  283. margin-top: 8px;
  284. background: linear-gradient(135deg, #00d4ff, #7c3aed);
  285. color: white;
  286. border: none;
  287. border-radius: 10px;
  288. font-size: 15px;
  289. font-weight: 600;
  290. cursor: pointer;
  291. transition: all 0.3s ease;
  292. position: relative;
  293. overflow: hidden;
  294. letter-spacing: 1px;
  295. }}
  296. .btn::before {{
  297. content: '';
  298. position: absolute;
  299. top: 0; left: -100%;
  300. width: 100%; height: 100%;
  301. background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
  302. transition: left 0.5s ease;
  303. }}
  304. .btn:hover::before {{
  305. left: 100%;
  306. }}
  307. .btn:hover {{
  308. transform: translateY(-2px);
  309. box-shadow: 0 10px 25px rgba(0, 212, 255, 0.3);
  310. }}
  311. .btn:disabled {{
  312. background: #334155;
  313. cursor: not-allowed;
  314. transform: none;
  315. box-shadow: none;
  316. }}
  317. .btn:disabled::before {{
  318. display: none;
  319. }}
  320. .error-message {{
  321. color: #f87171;
  322. font-size: 13px;
  323. margin-top: 12px;
  324. text-align: center;
  325. padding: 8px;
  326. background: rgba(248, 113, 113, 0.08);
  327. border-radius: 8px;
  328. border: 1px solid rgba(248, 113, 113, 0.15);
  329. }}
  330. .success-message {{
  331. color: #34d399;
  332. font-size: 13px;
  333. margin-top: 12px;
  334. text-align: center;
  335. padding: 8px;
  336. background: rgba(52, 211, 153, 0.08);
  337. border-radius: 8px;
  338. border: 1px solid rgba(52, 211, 153, 0.15);
  339. }}
  340. .test-account {{
  341. margin-top: 24px;
  342. text-align: center;
  343. font-size: 12px;
  344. color: rgba(148, 163, 184, 0.5);
  345. }}
  346. /* 粒子装饰 */
  347. .particles {{
  348. position: fixed;
  349. top: 0; left: 0;
  350. width: 100%; height: 100%;
  351. pointer-events: none;
  352. z-index: 1;
  353. }}
  354. .particle {{
  355. position: absolute;
  356. width: 4px; height: 4px;
  357. background: #00d4ff;
  358. border-radius: 50%;
  359. opacity: 0;
  360. animation: particleFloat 10s infinite;
  361. }}
  362. @keyframes particleFloat {{
  363. 0% {{ transform: translateY(100vh) scale(0); opacity: 0; }}
  364. 10% {{ opacity: 0.6; }}
  365. 90% {{ opacity: 0.6; }}
  366. 100% {{ transform: translateY(-10vh) scale(1); opacity: 0; }}
  367. }}
  368. </style>
  369. </head>
  370. <body>
  371. <div class="particles" id="particles"></div>
  372. <div class="orb orb-1"></div>
  373. <div class="orb orb-2"></div>
  374. <div class="orb orb-3"></div>
  375. <div class="login-container">
  376. <div class="login-header">
  377. <div class="logo-icon">AI</div>
  378. <h2>四川路桥AI中台管理</h2>
  379. <p>统一身份认证中心</p>
  380. </div>
  381. <div class="app-info">
  382. <strong>{app_name}</strong> 请求访问您的账户
  383. </div>
  384. <form id="loginForm" onsubmit="handleLogin(event)">
  385. <div class="form-group">
  386. <label for="username">用户名或邮箱</label>
  387. <input type="text" id="username" name="username" placeholder="请输入用户名" required>
  388. </div>
  389. <div class="form-group">
  390. <label for="password">密码</label>
  391. <input type="password" id="password" name="password" placeholder="请输入密码" required>
  392. </div>
  393. <button type="submit" class="btn" id="loginBtn">登录</button>
  394. <div id="message"></div>
  395. </form>
  396. <div class="test-account">
  397. <p>测试账号: admin / Admin123456 | admin123</p>
  398. </div>
  399. </div>
  400. <script>
  401. // 生成浮动粒子
  402. (function() {{
  403. const container = document.getElementById('particles');
  404. for (let i = 0; i < 30; i++) {{
  405. const p = document.createElement('div');
  406. p.className = 'particle';
  407. p.style.left = Math.random() * 100 + '%';
  408. p.style.animationDelay = Math.random() * 10 + 's';
  409. p.style.animationDuration = (8 + Math.random() * 6) + 's';
  410. const size = 2 + Math.random() * 4;
  411. p.style.width = size + 'px';
  412. p.style.height = size + 'px';
  413. p.style.background = ['#00d4ff', '#7c3aed', '#06b6d4'][Math.floor(Math.random() * 3)];
  414. container.appendChild(p);
  415. }}
  416. }})();
  417. async function handleLogin(event) {{
  418. event.preventDefault();
  419. const loginBtn = document.getElementById('loginBtn');
  420. const messageDiv = document.getElementById('message');
  421. loginBtn.disabled = true;
  422. loginBtn.textContent = '登录中...';
  423. messageDiv.innerHTML = '';
  424. const formData = new FormData(event.target);
  425. const loginData = {{
  426. username: formData.get('username'),
  427. password: formData.get('password'),
  428. remember_me: false
  429. }};
  430. try {{
  431. const response = await fetch('/api/v1/auth/login', {{
  432. method: 'POST',
  433. headers: {{
  434. 'Content-Type': 'application/json'
  435. }},
  436. body: JSON.stringify(loginData)
  437. }});
  438. const result = await response.json();
  439. if (result.code === 0 || result.code === '0' || result.code === '000000') {{
  440. messageDiv.innerHTML = '<div class="success-message">登录成功,正在跳转...</div>';
  441. const authUrl = `/oauth/authorize/authenticated?response_type={response_type}&client_id={client_id}&redirect_uri={redirect_uri}&scope={scope}&state={state or ''}&access_token=${{result.data.access_token}}`;
  442. setTimeout(() => {{
  443. window.location.href = authUrl;
  444. }}, 1000);
  445. }} else {{
  446. messageDiv.innerHTML = `<div class="error-message">${{result.message}}</div>`;
  447. }}
  448. }} catch (error) {{
  449. messageDiv.innerHTML = '<div class="error-message">登录失败,请重试</div>';
  450. }} finally {{
  451. loginBtn.disabled = false;
  452. loginBtn.textContent = '登录';
  453. }}
  454. }}
  455. </script>
  456. </body>
  457. </html>
  458. """
  459. from fastapi.responses import HTMLResponse
  460. return HTMLResponse(content=login_html)
  461. except Exception as e:
  462. logger.exception("OAuth登录页面错误")
  463. return {"error": "server_error", "message": "服务器内部错误"}
  464. @router.get("/authorize/authenticated")
  465. async def oauth_authorize_authenticated(
  466. response_type: str,
  467. client_id: str,
  468. redirect_uri: str,
  469. access_token: str,
  470. scope: str = "profile",
  471. state: str = None
  472. ):
  473. """用户已登录后的授权处理"""
  474. try:
  475. logger.info(f"用户已登录,处理授权: client_id={client_id}")
  476. # 验证访问令牌
  477. payload = verify_token(access_token)
  478. if not payload:
  479. error_url = f"{redirect_uri}?error=invalid_token&error_description=Invalid access token"
  480. if state:
  481. error_url += f"&state={state}"
  482. return RedirectResponse(url=error_url, status_code=302)
  483. user_id = payload.get("sub")
  484. username = payload.get("username", "")
  485. logger.info(f"用户已验证: {username} ({user_id})")
  486. # 调用 service 层获取应用信息
  487. oauth_service = OAuthService()
  488. success, app_info, message = await oauth_service.validate_client_and_redirect_uri(client_id, redirect_uri)
  489. if not success:
  490. error_url = f"{redirect_uri}?error=invalid_client&error_description={message}"
  491. if state:
  492. error_url += f"&state={state}"
  493. return RedirectResponse(url=error_url, status_code=302)
  494. app_name = app_info.get("name", "")
  495. is_trusted = app_info.get("is_trusted", False)
  496. # 如果是受信任应用,直接授权
  497. if is_trusted:
  498. # 生成授权码
  499. auth_code = secrets.token_urlsafe(32)
  500. # 调用 service 层存储授权码
  501. await oauth_service.store_authorization_code(user_id, client_id, auth_code, redirect_uri, scope)
  502. # 重定向回应用
  503. callback_url = f"{redirect_uri}?code={auth_code}"
  504. if state:
  505. callback_url += f"&state={state}"
  506. logger.info(f"受信任应用自动授权: {callback_url}")
  507. return RedirectResponse(url=callback_url, status_code=302)
  508. # 非受信任应用,显示授权确认页面
  509. # 为简化,暂时也直接授权
  510. auth_code = secrets.token_urlsafe(32)
  511. # 调用 service 层存储授权码
  512. await oauth_service.store_authorization_code(user_id, client_id, auth_code, redirect_uri, scope)
  513. callback_url = f"{redirect_uri}?code={auth_code}"
  514. if state:
  515. callback_url += f"&state={state}"
  516. logger.info(f"用户授权完成: {callback_url}")
  517. return RedirectResponse(url=callback_url, status_code=302)
  518. except Exception as e:
  519. logger.exception("授权处理错误")
  520. error_url = f"{redirect_uri}?error=server_error&error_description=Authorization failed"
  521. if state:
  522. error_url += f"&state={state}"
  523. return RedirectResponse(url=error_url, status_code=302)
  524. async def oauth_approve(
  525. client_id: str,
  526. redirect_uri: str,
  527. scope: str = "profile",
  528. state: str = None
  529. ):
  530. """用户同意授权"""
  531. try:
  532. logger.info(f"用户同意授权: client_id={client_id}")
  533. # 生成授权码
  534. auth_code = secrets.token_urlsafe(32)
  535. # TODO: 将授权码存储到数据库,关联用户和应用
  536. # 这里简化处理,实际应该:
  537. # 1. 验证用户登录状态
  538. # 2. 将授权码存储到数据库
  539. # 3. 设置过期时间(通常10分钟)
  540. # 构建回调URL
  541. callback_url = f"{redirect_uri}?code={auth_code}"
  542. if state:
  543. callback_url += f"&state={state}"
  544. logger.info(f"重定向到: {callback_url}")
  545. from fastapi.responses import RedirectResponse
  546. return RedirectResponse(url=callback_url, status_code=302)
  547. except Exception as e:
  548. logger.exception("授权确认错误")
  549. error_url = f"{redirect_uri}?error=server_error&error_description=Authorization failed"
  550. if state:
  551. error_url += f"&state={state}"
  552. from fastapi.responses import RedirectResponse
  553. return RedirectResponse(url=error_url, status_code=302)
  554. @router.get("/authorize/deny")
  555. async def oauth_deny(
  556. client_id: str,
  557. redirect_uri: str,
  558. state: str = None
  559. ):
  560. """用户拒绝授权"""
  561. try:
  562. logger.info(f"用户拒绝授权: client_id={client_id}")
  563. # 构建错误回调URL
  564. error_url = f"{redirect_uri}?error=access_denied&error_description=User denied authorization"
  565. if state:
  566. error_url += f"&state={state}"
  567. from fastapi.responses import RedirectResponse
  568. return RedirectResponse(url=error_url, status_code=302)
  569. except Exception as e:
  570. logger.exception("拒绝授权错误")
  571. error_url = f"{redirect_uri}?error=server_error&error_description=Authorization failed"
  572. if state:
  573. error_url += f"&state={state}"
  574. from fastapi.responses import RedirectResponse
  575. return RedirectResponse(url=error_url, status_code=302)
  576. @router.post("/token")
  577. async def oauth_token(request: Request):
  578. """OAuth2令牌端点"""
  579. try:
  580. # 获取请求数据
  581. form_data = await request.form()
  582. grant_type = form_data.get("grant_type")
  583. code = form_data.get("code")
  584. redirect_uri = form_data.get("redirect_uri")
  585. client_id = form_data.get("client_id")
  586. client_secret = form_data.get("client_secret")
  587. logger.info(f"令牌请求: grant_type={grant_type}, client_id={client_id}")
  588. # 验证grant_type
  589. if grant_type != "authorization_code":
  590. return {
  591. "error": "unsupported_grant_type",
  592. "error_description": "Only authorization_code grant type is supported"
  593. }
  594. # 验证必要参数
  595. if not code or not redirect_uri or not client_id:
  596. return {
  597. "error": "invalid_request",
  598. "error_description": "Missing required parameters"
  599. }
  600. # 调用 service 层验证客户端凭据
  601. oauth_service = OAuthService()
  602. success, app_info, message = await oauth_service.validate_client_credentials(client_id, client_secret)
  603. if not success:
  604. return {
  605. "error": "invalid_client",
  606. "error_description": message
  607. }
  608. # 验证redirect_uri
  609. redirect_uris = app_info.get("redirect_uris", [])
  610. if redirect_uri not in redirect_uris:
  611. return {
  612. "error": "invalid_grant",
  613. "error_description": "Invalid redirect_uri"
  614. }
  615. # 调用 service 层验证授权码
  616. success, user_id, message = await oauth_service.validate_authorization_code(code, client_id, redirect_uri)
  617. if not success:
  618. return {
  619. "error": "invalid_grant",
  620. "error_description": message
  621. }
  622. # 调用 service 层生成访问令牌
  623. access_token = await oauth_service.generate_access_token(user_id, client_id, "profile email")
  624. refresh_token = secrets.token_urlsafe(32)
  625. # TODO: 将令牌存储到数据库
  626. # 返回令牌响应
  627. token_response = {
  628. "access_token": access_token,
  629. "token_type": "Bearer",
  630. "expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60,
  631. "refresh_token": refresh_token,
  632. "scope": "profile email"
  633. }
  634. logger.info(f"令牌生成成功: {access_token[:50]}...")
  635. return token_response
  636. except Exception as e:
  637. logger.exception("令牌生成错误")
  638. return {
  639. "error": "server_error",
  640. "error_description": "Internal server error"
  641. }
  642. @router.get("/userinfo")
  643. async def oauth_userinfo(current_user: dict = Depends(get_oauth_user)):
  644. """OAuth2用户信息端点"""
  645. try:
  646. user_id = current_user.get("sub")
  647. client_id = current_user.get("client_id")
  648. scope = current_user.get("scope", "").split()
  649. logger.info(f"用户信息请求: user_id={user_id}, client_id={client_id}, scope={scope}")
  650. # 调用 service 层获取用户信息
  651. oauth_service = OAuthService()
  652. user_info = await oauth_service.get_user_info(user_id, scope)
  653. if not user_info:
  654. return {
  655. "error": "invalid_token",
  656. "error_description": "User not found or inactive"
  657. }
  658. logger.info(f"返回用户信息: {user_info}")
  659. return user_info
  660. except Exception as e:
  661. logger.exception("获取用户信息错误")
  662. return {
  663. "error": "server_error",
  664. "error_description": "Internal server error"
  665. }