oauth_view.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  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.auth_dependency import get_current_user_with_refresh
  22. # 获取logger
  23. logger = logging.getLogger(__name__)
  24. router = APIRouter(prefix="/oauth", tags=["授权管理"])
  25. security = HTTPBearer()
  26. security_optional = HTTPBearer(auto_error=False)
  27. # OAuth2 授权端点
  28. @router.get("/authorize")
  29. async def oauth_authorize(
  30. response_type: str,
  31. client_id: str,
  32. redirect_uri: str,
  33. scope: str = "profile",
  34. state: str = None
  35. ):
  36. """OAuth2授权端点"""
  37. try:
  38. logger.info(f"OAuth授权请求: client_id={client_id}, redirect_uri={redirect_uri}, scope={scope}")
  39. # 验证必要参数
  40. if not response_type or not client_id or not redirect_uri:
  41. error_url = f"{redirect_uri}?error=invalid_request&error_description=Missing required parameters"
  42. if state:
  43. error_url += f"&state={state}"
  44. return {"error": "invalid_request", "redirect_url": error_url}
  45. # 验证response_type
  46. if response_type != "code":
  47. error_url = f"{redirect_uri}?error=unsupported_response_type&error_description=Only authorization code flow is supported"
  48. if state:
  49. error_url += f"&state={state}"
  50. return {"error": "unsupported_response_type", "redirect_url": error_url}
  51. # 调用 service 层验证客户端和重定向URI
  52. oauth_service = OAuthService()
  53. success, app_info, message = await oauth_service.validate_client_and_redirect_uri(client_id, redirect_uri)
  54. if not success:
  55. error_url = f"{redirect_uri}?error=invalid_client&error_description={message}"
  56. if state:
  57. error_url += f"&state={state}"
  58. return {"error": "invalid_client", "redirect_url": error_url}
  59. # 验证scope
  60. app_scopes = app_info.get("scope", [])
  61. requested_scopes = scope.split() if scope else []
  62. invalid_scopes = [s for s in requested_scopes if s not in app_scopes]
  63. if invalid_scopes:
  64. error_url = f"{redirect_uri}?error=invalid_scope&error_description=Invalid scope: {' '.join(invalid_scopes)}"
  65. if state:
  66. error_url += f"&state={state}"
  67. return {"error": "invalid_scope", "redirect_url": error_url}
  68. # TODO: 检查用户登录状态
  69. # 这里应该检查用户是否已登录(通过session或cookie)
  70. # 如果未登录,应该重定向到登录页面
  71. # 临时方案:返回登录页面,让用户先登录
  72. # 生产环境应该使用session管理
  73. # 构建登录页面URL,登录后返回授权页面
  74. login_page_url = f"/oauth/login?response_type={response_type}&client_id={client_id}&redirect_uri={redirect_uri}&scope={scope}"
  75. if state:
  76. login_page_url += f"&state={state}"
  77. logger.info(f"需要用户登录,重定向到登录页面: {login_page_url}")
  78. return RedirectResponse(url=login_page_url, status_code=302)
  79. # 非受信任应用需要用户授权确认
  80. # 这里返回授权页面HTML
  81. # 为简化,暂时跳过授权确认页面,直接跳转到登录
  82. except Exception as e:
  83. logger.exception("OAuth授权错误")
  84. error_url = f"{redirect_uri}?error=server_error&error_description=Internal server error"
  85. if state:
  86. error_url += f"&state={state}"
  87. return {"error": "server_error", "redirect_url": error_url}
  88. @router.get("/login")
  89. async def oauth_login_page(
  90. response_type: str,
  91. client_id: str,
  92. redirect_uri: str,
  93. scope: str = "profile",
  94. state: str = None
  95. ):
  96. """OAuth2登录页面"""
  97. try:
  98. logger.info(f"显示OAuth登录页面: client_id={client_id}")
  99. # 调用 service 层获取应用信息
  100. oauth_service = OAuthService()
  101. success, app_info, message = await oauth_service.validate_client_and_redirect_uri(client_id, redirect_uri)
  102. app_name = app_info.get("name", "未知应用") if success else "未知应用"
  103. # 构建登录页面HTML
  104. login_html = f"""
  105. <!DOCTYPE html>
  106. <html lang="zh-CN">
  107. <head>
  108. <meta charset="UTF-8">
  109. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  110. <title>四川路桥AI后台管理平台 - {app_name}</title>
  111. <style>
  112. body {{
  113. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  114. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  115. margin: 0;
  116. padding: 20px;
  117. min-height: 100vh;
  118. display: flex;
  119. align-items: center;
  120. justify-content: center;
  121. }}
  122. .login-container {{
  123. background: white;
  124. border-radius: 15px;
  125. padding: 40px;
  126. box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
  127. max-width: 400px;
  128. width: 100%;
  129. }}
  130. .login-header {{
  131. text-align: center;
  132. margin-bottom: 30px;
  133. }}
  134. .login-header h1 {{
  135. color: #333;
  136. margin-bottom: 10px;
  137. }}
  138. .app-info {{
  139. background: #f8f9fa;
  140. padding: 15px;
  141. border-radius: 8px;
  142. margin-bottom: 20px;
  143. text-align: center;
  144. }}
  145. .form-group {{
  146. margin-bottom: 20px;
  147. }}
  148. .form-group label {{
  149. display: block;
  150. margin-bottom: 5px;
  151. font-weight: 500;
  152. color: #333;
  153. }}
  154. .form-group input {{
  155. width: 100%;
  156. padding: 12px;
  157. border: 1px solid #ddd;
  158. border-radius: 6px;
  159. font-size: 16px;
  160. box-sizing: border-box;
  161. }}
  162. .form-group input:focus {{
  163. outline: none;
  164. border-color: #007bff;
  165. box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
  166. }}
  167. .btn {{
  168. width: 100%;
  169. padding: 12px;
  170. background: #007bff;
  171. color: white;
  172. border: none;
  173. border-radius: 6px;
  174. font-size: 16px;
  175. font-weight: 500;
  176. cursor: pointer;
  177. transition: background 0.3s;
  178. }}
  179. .btn:hover {{
  180. background: #0056b3;
  181. }}
  182. .btn:disabled {{
  183. background: #6c757d;
  184. cursor: not-allowed;
  185. }}
  186. .error-message {{
  187. color: #dc3545;
  188. font-size: 14px;
  189. margin-top: 10px;
  190. text-align: center;
  191. }}
  192. .success-message {{
  193. color: #28a745;
  194. font-size: 14px;
  195. margin-top: 10px;
  196. text-align: center;
  197. }}
  198. </style>
  199. </head>
  200. <body>
  201. <div class="login-container">
  202. <div class="login-header">
  203. <h2>🔐 四川路桥AI后台管理平台</h2>
  204. <p>请登录以继续访问应用</p>
  205. </div>
  206. <div class="app-info">
  207. <strong>{app_name}</strong> 请求访问您的账户
  208. </div>
  209. <form id="loginForm" onsubmit="handleLogin(event)">
  210. <div class="form-group">
  211. <label for="username">用户名或邮箱</label>
  212. <input type="text" id="username" name="username" required>
  213. </div>
  214. <div class="form-group">
  215. <label for="password">密码</label>
  216. <input type="password" id="password" name="password" required>
  217. </div>
  218. <button type="submit" class="btn" id="loginBtn">登录</button>
  219. <div id="message"></div>
  220. </form>
  221. <div style="margin-top: 20px; text-align: center; font-size: 14px; color: #666;">
  222. <p>测试账号: admin / Admin123456 | admin123</p>
  223. </div>
  224. </div>
  225. <script>
  226. async function handleLogin(event) {{
  227. event.preventDefault();
  228. const loginBtn = document.getElementById('loginBtn');
  229. const messageDiv = document.getElementById('message');
  230. loginBtn.disabled = true;
  231. loginBtn.textContent = '登录中...';
  232. messageDiv.innerHTML = '';
  233. const formData = new FormData(event.target);
  234. const loginData = {{
  235. username: formData.get('username'),
  236. password: formData.get('password'),
  237. remember_me: false
  238. }};
  239. try {{
  240. // 调用登录API
  241. const response = await fetch('/api/v1/auth/login', {{
  242. method: 'POST',
  243. headers: {{
  244. 'Content-Type': 'application/json'
  245. }},
  246. body: JSON.stringify(loginData)
  247. }});
  248. const result = await response.json();
  249. if (result.code === 0 || result.code === '0' || result.code === '000000') {{
  250. messageDiv.innerHTML = '<div class="success-message">登录成功,正在跳转...</div>';
  251. // 登录成功后,重定向到授权页面
  252. 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}}`;
  253. setTimeout(() => {{
  254. window.location.href = authUrl;
  255. }}, 1000);
  256. }} else {{
  257. messageDiv.innerHTML = `<div class="error-message">${{result.message}}</div>`;
  258. }}
  259. }} catch (error) {{
  260. messageDiv.innerHTML = '<div class="error-message">登录失败,请重试</div>';
  261. }} finally {{
  262. loginBtn.disabled = false;
  263. loginBtn.textContent = '登录';
  264. }}
  265. }}
  266. </script>
  267. </body>
  268. </html>
  269. """
  270. from fastapi.responses import HTMLResponse
  271. return HTMLResponse(content=login_html)
  272. except Exception as e:
  273. logger.exception("OAuth登录页面错误")
  274. return {"error": "server_error", "message": "服务器内部错误"}
  275. @router.get("/authorize/authenticated")
  276. async def oauth_authorize_authenticated(
  277. response_type: str,
  278. client_id: str,
  279. redirect_uri: str,
  280. access_token: str,
  281. scope: str = "profile",
  282. state: str = None
  283. ):
  284. """用户已登录后的授权处理"""
  285. try:
  286. logger.info(f"用户已登录,处理授权: client_id={client_id}")
  287. # 验证访问令牌
  288. payload = verify_token(access_token)
  289. if not payload:
  290. error_url = f"{redirect_uri}?error=invalid_token&error_description=Invalid access token"
  291. if state:
  292. error_url += f"&state={state}"
  293. return RedirectResponse(url=error_url, status_code=302)
  294. user_id = payload.get("sub")
  295. username = payload.get("username", "")
  296. logger.info(f"用户已验证: {username} ({user_id})")
  297. # 调用 service 层获取应用信息
  298. oauth_service = OAuthService()
  299. success, app_info, message = await oauth_service.validate_client_and_redirect_uri(client_id, redirect_uri)
  300. if not success:
  301. error_url = f"{redirect_uri}?error=invalid_client&error_description={message}"
  302. if state:
  303. error_url += f"&state={state}"
  304. return RedirectResponse(url=error_url, status_code=302)
  305. app_name = app_info.get("name", "")
  306. is_trusted = app_info.get("is_trusted", False)
  307. # 如果是受信任应用,直接授权
  308. if is_trusted:
  309. # 生成授权码
  310. auth_code = secrets.token_urlsafe(32)
  311. # 调用 service 层存储授权码
  312. await oauth_service.store_authorization_code(user_id, client_id, auth_code, redirect_uri, scope)
  313. # 重定向回应用
  314. callback_url = f"{redirect_uri}?code={auth_code}"
  315. if state:
  316. callback_url += f"&state={state}"
  317. logger.info(f"受信任应用自动授权: {callback_url}")
  318. return RedirectResponse(url=callback_url, status_code=302)
  319. # 非受信任应用,显示授权确认页面
  320. # 为简化,暂时也直接授权
  321. auth_code = secrets.token_urlsafe(32)
  322. # 调用 service 层存储授权码
  323. await oauth_service.store_authorization_code(user_id, client_id, auth_code, redirect_uri, scope)
  324. callback_url = f"{redirect_uri}?code={auth_code}"
  325. if state:
  326. callback_url += f"&state={state}"
  327. logger.info(f"用户授权完成: {callback_url}")
  328. return RedirectResponse(url=callback_url, status_code=302)
  329. except Exception as e:
  330. logger.exception("授权处理错误")
  331. error_url = f"{redirect_uri}?error=server_error&error_description=Authorization failed"
  332. if state:
  333. error_url += f"&state={state}"
  334. return RedirectResponse(url=error_url, status_code=302)
  335. async def oauth_approve(
  336. client_id: str,
  337. redirect_uri: str,
  338. scope: str = "profile",
  339. state: str = None
  340. ):
  341. """用户同意授权"""
  342. try:
  343. logger.info(f"用户同意授权: client_id={client_id}")
  344. # 生成授权码
  345. auth_code = secrets.token_urlsafe(32)
  346. # TODO: 将授权码存储到数据库,关联用户和应用
  347. # 这里简化处理,实际应该:
  348. # 1. 验证用户登录状态
  349. # 2. 将授权码存储到数据库
  350. # 3. 设置过期时间(通常10分钟)
  351. # 构建回调URL
  352. callback_url = f"{redirect_uri}?code={auth_code}"
  353. if state:
  354. callback_url += f"&state={state}"
  355. logger.info(f"重定向到: {callback_url}")
  356. from fastapi.responses import RedirectResponse
  357. return RedirectResponse(url=callback_url, status_code=302)
  358. except Exception as e:
  359. logger.exception("授权确认错误")
  360. error_url = f"{redirect_uri}?error=server_error&error_description=Authorization failed"
  361. if state:
  362. error_url += f"&state={state}"
  363. from fastapi.responses import RedirectResponse
  364. return RedirectResponse(url=error_url, status_code=302)
  365. @router.get("/authorize/deny")
  366. async def oauth_deny(
  367. client_id: str,
  368. redirect_uri: str,
  369. state: str = None
  370. ):
  371. """用户拒绝授权"""
  372. try:
  373. logger.info(f"用户拒绝授权: client_id={client_id}")
  374. # 构建错误回调URL
  375. error_url = f"{redirect_uri}?error=access_denied&error_description=User denied authorization"
  376. if state:
  377. error_url += f"&state={state}"
  378. from fastapi.responses import RedirectResponse
  379. return RedirectResponse(url=error_url, status_code=302)
  380. except Exception as e:
  381. logger.exception("拒绝授权错误")
  382. error_url = f"{redirect_uri}?error=server_error&error_description=Authorization failed"
  383. if state:
  384. error_url += f"&state={state}"
  385. from fastapi.responses import RedirectResponse
  386. return RedirectResponse(url=error_url, status_code=302)
  387. @router.post("/token")
  388. async def oauth_token(request: Request):
  389. """OAuth2令牌端点"""
  390. try:
  391. # 获取请求数据
  392. form_data = await request.form()
  393. grant_type = form_data.get("grant_type")
  394. code = form_data.get("code")
  395. redirect_uri = form_data.get("redirect_uri")
  396. client_id = form_data.get("client_id")
  397. client_secret = form_data.get("client_secret")
  398. logger.info(f"令牌请求: grant_type={grant_type}, client_id={client_id}")
  399. # 验证grant_type
  400. if grant_type != "authorization_code":
  401. return {
  402. "error": "unsupported_grant_type",
  403. "error_description": "Only authorization_code grant type is supported"
  404. }
  405. # 验证必要参数
  406. if not code or not redirect_uri or not client_id:
  407. return {
  408. "error": "invalid_request",
  409. "error_description": "Missing required parameters"
  410. }
  411. # 调用 service 层验证客户端凭据
  412. oauth_service = OAuthService()
  413. success, app_info, message = await oauth_service.validate_client_credentials(client_id, client_secret)
  414. if not success:
  415. return {
  416. "error": "invalid_client",
  417. "error_description": message
  418. }
  419. # 验证redirect_uri
  420. redirect_uris = app_info.get("redirect_uris", [])
  421. if redirect_uri not in redirect_uris:
  422. return {
  423. "error": "invalid_grant",
  424. "error_description": "Invalid redirect_uri"
  425. }
  426. # 调用 service 层验证授权码
  427. success, user_id, message = await oauth_service.validate_authorization_code(code, client_id, redirect_uri)
  428. if not success:
  429. return {
  430. "error": "invalid_grant",
  431. "error_description": message
  432. }
  433. # 调用 service 层生成访问令牌
  434. access_token = await oauth_service.generate_access_token(user_id, client_id, "profile email")
  435. refresh_token = secrets.token_urlsafe(32)
  436. # TODO: 将令牌存储到数据库
  437. # 返回令牌响应
  438. token_response = {
  439. "access_token": access_token,
  440. "token_type": "Bearer",
  441. "expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60,
  442. "refresh_token": refresh_token,
  443. "scope": "profile email"
  444. }
  445. logger.info(f"令牌生成成功: {access_token[:50]}...")
  446. return token_response
  447. except Exception as e:
  448. logger.exception("令牌生成错误")
  449. return {
  450. "error": "server_error",
  451. "error_description": "Internal server error"
  452. }
  453. @router.get("/userinfo")
  454. async def oauth_userinfo(current_user: dict = Depends(get_current_user_with_refresh)):
  455. """OAuth2用户信息端点"""
  456. try:
  457. user_id = current_user.get("sub")
  458. client_id = current_user.get("client_id")
  459. scope = current_user.get("scope", "").split()
  460. logger.info(f"用户信息请求: user_id={user_id}, client_id={client_id}, scope={scope}")
  461. # 调用 service 层获取用户信息
  462. oauth_service = OAuthService()
  463. user_info = await oauth_service.get_user_info(user_id, scope)
  464. if not user_info:
  465. return {
  466. "error": "invalid_token",
  467. "error_description": "User not found or inactive"
  468. }
  469. logger.info(f"返回用户信息: {user_info}")
  470. return user_info
  471. except Exception as e:
  472. logger.exception("获取用户信息错误")
  473. return {
  474. "error": "server_error",
  475. "error_description": "Internal server error"
  476. }