auth.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. """用户认证API路由 - 标准JWT(HttpOnly Cookie) + Redis持久化Refresh Token"""
  2. from fastapi import APIRouter, HTTPException, Request, Response
  3. from ...models.schemas import LoginRequest, RegisterRequest
  4. from ...database import create_user, verify_user, get_user_by_id
  5. from ...jwt_utils import (
  6. create_access_token, create_refresh_token,
  7. verify_access_token, verify_refresh_token,
  8. )
  9. from ...redis_service import store_refresh_token, validate_refresh_token, revoke_refresh_token
  10. from ...config import get_settings
  11. from ...rsa_service import get_public_key_pem, decrypt_data
  12. from ...user_context import get_current_user
  13. router = APIRouter(prefix="/auth", tags=["用户认证"])
  14. COOKIE_ACCESS_KEY = "access_token"
  15. COOKIE_REFRESH_KEY = "refresh_token"
  16. COOKIE_USERNAME_KEY = "auth_username" # 前端可读,零请求获取用户名
  17. COOKIE_PATH = "/"
  18. COOKIE_REFRESH_PATH = "/api/auth" # refresh_token 仅随认证请求携带
  19. COOKIE_SAMESITE = "lax"
  20. # 根据是否启用SSL自动设置Secure标志
  21. COOKIE_SECURE = get_settings().ssl_enabled
  22. def _set_auth_cookies(response: Response, access_token: str):
  23. """设置 Access Token HttpOnly Cookie"""
  24. response.set_cookie(
  25. key=COOKIE_ACCESS_KEY, value=access_token,
  26. httponly=True, secure=COOKIE_SECURE, samesite=COOKIE_SAMESITE,
  27. max_age=1800, path=COOKIE_PATH,
  28. )
  29. def _clear_auth_cookies(response: Response):
  30. """清除认证Cookie(同时清理新旧路径的 refresh_token,兼容升级前残留)"""
  31. response.delete_cookie(COOKIE_ACCESS_KEY, path=COOKIE_PATH)
  32. response.delete_cookie(COOKIE_USERNAME_KEY, path=COOKIE_PATH)
  33. response.delete_cookie(COOKIE_REFRESH_KEY, path=COOKIE_PATH) # 旧:path="/" 的残留
  34. response.delete_cookie(COOKIE_REFRESH_KEY, path=COOKIE_REFRESH_PATH) # 新:path="/api/auth"
  35. def require_auth(request: Request = None) -> dict:
  36. """
  37. 从请求上下文获取当前用户(中间件已统一处理 Cookie 和 Header)。
  38. 返回 {"id": user_id},未登录时抛 401。
  39. """
  40. user = get_current_user()
  41. if not user:
  42. raise HTTPException(status_code=401, detail="未登录")
  43. return {"id": user["id"]}
  44. @router.post("/register", summary="用户注册")
  45. async def register(req: RegisterRequest, request: Request, response: Response):
  46. """注册新用户(密码经RSA加密),设置Access Token Cookie + Refresh Token存入Redis"""
  47. if len(req.username) < 2:
  48. raise HTTPException(status_code=400, detail="用户名至少2个字符")
  49. # RSA解密密码
  50. try:
  51. password = decrypt_data(req.encrypted_password)
  52. except ValueError as e:
  53. raise HTTPException(status_code=400, detail=f"密码解密失败: {e}")
  54. if len(password) < 4:
  55. raise HTTPException(status_code=400, detail="密码至少4个字符")
  56. try:
  57. user = create_user(req.username.strip(), password)
  58. user_agent = request.headers.get("User-Agent", "")
  59. access_token, refresh_token = _issue_tokens(response, user["id"], username=user["username"], user_agent=user_agent)
  60. return {
  61. "success": True, "message": "注册成功",
  62. "username": user["username"],
  63. "access_token": access_token,
  64. "refresh_token": refresh_token,
  65. }
  66. except ValueError as e:
  67. raise HTTPException(status_code=409, detail=str(e))
  68. @router.post("/login", summary="用户登录")
  69. async def login(req: LoginRequest, request: Request, response: Response):
  70. """登录(密码经RSA加密),设置Access Token Cookie + Refresh Token存入Redis"""
  71. # RSA解密密码
  72. try:
  73. password = decrypt_data(req.encrypted_password)
  74. except ValueError as e:
  75. raise HTTPException(status_code=400, detail=f"密码解密失败: {e}")
  76. user = verify_user(req.username.strip(), password)
  77. if not user:
  78. raise HTTPException(status_code=401, detail="用户名或密码错误")
  79. user_agent = request.headers.get("User-Agent", "")
  80. access_token, refresh_token = _issue_tokens(response, user["id"], username=user["username"], user_agent=user_agent)
  81. return {
  82. "success": True, "message": "登录成功",
  83. "username": user["username"],
  84. "access_token": access_token,
  85. "refresh_token": refresh_token,
  86. }
  87. @router.get("/public-key", summary="获取RSA公钥")
  88. async def public_key():
  89. """获取RSA公钥(PEM格式),用于前端加密密码"""
  90. return {
  91. "success": True,
  92. "public_key": get_public_key_pem(),
  93. }
  94. def _issue_tokens(response: Response, user_id: int, username: str = "", user_agent: str = "") -> tuple[str, str]:
  95. """签发双Token + 前端可读用户名Cookie
  96. - access_token (HttpOnly, /) → JWT认证
  97. - refresh_token (HttpOnly, /api/auth) → 刷新Token (Redis存设备信息)
  98. - auth_username (可读, /) → 前端直接读,零请求
  99. 返回: (access_token, refresh_token) — 非浏览器设备可拿到令牌
  100. """
  101. # Access Token -> HttpOnly Cookie (全路径携带)
  102. access_token = create_access_token(user_id)
  103. _set_auth_cookies(response, access_token)
  104. # Refresh Token -> JWT + Redis (含设备信息)
  105. refresh_token, jti = create_refresh_token(user_id)
  106. store_refresh_token(user_id, jti, user_agent=user_agent)
  107. # 先清除旧路径的 refresh_token(兼容升级前 path="/" 的残留 cookie)
  108. response.delete_cookie(COOKIE_REFRESH_KEY, path=COOKIE_PATH)
  109. # Refresh Token 仅随 /api/auth/* 路径请求携带
  110. response.set_cookie(
  111. key=COOKIE_REFRESH_KEY, value=refresh_token,
  112. httponly=True, secure=COOKIE_SECURE, samesite=COOKIE_SAMESITE,
  113. max_age=604800, path=COOKIE_REFRESH_PATH,
  114. )
  115. # 前端可读的用户名 Cookie(非 HttpOnly,JS 可直接读取,无需调 profile API)
  116. if username:
  117. response.set_cookie(
  118. key=COOKIE_USERNAME_KEY, value=username,
  119. httponly=False, secure=COOKIE_SECURE, samesite=COOKIE_SAMESITE,
  120. max_age=1800, path=COOKIE_PATH,
  121. )
  122. return access_token, refresh_token
  123. @router.post("/refresh", summary="刷新Token")
  124. async def refresh(request: Request, response: Response):
  125. """用 Refresh Token 换取新的双Token,支持 Cookie 或 X-Refresh-Token Header"""
  126. # 先从 Cookie 取,再尝试 Header(兼容非浏览器设备)
  127. refresh_token = request.cookies.get(COOKIE_REFRESH_KEY) or request.headers.get("X-Refresh-Token", "")
  128. if not refresh_token:
  129. raise HTTPException(status_code=401, detail="未登录,缺少Refresh Token")
  130. # 1. JWT签名验证
  131. try:
  132. payload = verify_refresh_token(refresh_token)
  133. except Exception:
  134. _clear_auth_cookies(response)
  135. raise HTTPException(status_code=401, detail="Refresh Token已过期或无效")
  136. # 2. Redis验证:jti是否有效 + 获取绑定的设备信息
  137. stored = validate_refresh_token(payload["jti"])
  138. if stored is None or stored["user_id"] != payload["id"]:
  139. _clear_auth_cookies(response)
  140. raise HTTPException(status_code=401, detail="Refresh Token已被吊销")
  141. # 3. 设备信息校验(User-Agent不匹配时拒绝刷新,防跨设备盗用)
  142. current_ua = request.headers.get("User-Agent", "")
  143. if stored.get("user_agent") and stored["user_agent"] != current_ua:
  144. _clear_auth_cookies(response)
  145. raise HTTPException(status_code=401, detail="设备不匹配,请重新登录")
  146. # 4. 吊销旧 Refresh Token(轮换)
  147. revoke_refresh_token(payload["jti"])
  148. # 5. 签发新双Token(绑定当前设备信息 + 用户名Cookie)
  149. user_info = get_user_by_id(payload["id"])
  150. new_access, new_refresh = _issue_tokens(response, payload["id"],
  151. username=user_info["username"] if user_info else "",
  152. user_agent=current_ua)
  153. return {
  154. "success": True,
  155. "message": "Token刷新成功",
  156. "access_token": new_access,
  157. "refresh_token": new_refresh,
  158. "username": user_info["username"] if user_info else "",
  159. }
  160. @router.post("/logout", summary="用户登出")
  161. async def logout(request: Request, response: Response):
  162. """登出:清除Cookie + 吊销Redis中的Refresh Token(支持Cookie或Header)"""
  163. # 优先取 Cookie,再尝试 Header(兼容非浏览器设备)
  164. refresh_token = request.cookies.get(COOKIE_REFRESH_KEY) or request.headers.get("X-Refresh-Token", "")
  165. if refresh_token:
  166. try:
  167. payload = verify_refresh_token(refresh_token)
  168. revoke_refresh_token(payload["jti"])
  169. except Exception:
  170. pass
  171. _clear_auth_cookies(response)
  172. return {"success": True, "message": "已退出登录"}
  173. @router.get("/profile", summary="获取用户信息")
  174. async def profile(request: Request):
  175. """获取当前登录用户信息(从上下文直接读取,无需查DB)"""
  176. user = get_current_user()
  177. if not user:
  178. raise HTTPException(status_code=401, detail="未登录")
  179. return {"success": True, "username": user["username"]}