| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216 |
- """用户认证API路由 - 标准JWT(HttpOnly Cookie) + Redis持久化Refresh Token"""
- from fastapi import APIRouter, HTTPException, Request, Response
- from ...models.schemas import LoginRequest, RegisterRequest
- from ...database import create_user, verify_user, get_user_by_id
- from ...jwt_utils import (
- create_access_token, create_refresh_token,
- verify_access_token, verify_refresh_token,
- )
- from ...redis_service import store_refresh_token, validate_refresh_token, revoke_refresh_token
- from ...config import get_settings
- from ...rsa_service import get_public_key_pem, decrypt_data
- from ...user_context import get_current_user
- router = APIRouter(prefix="/auth", tags=["用户认证"])
- COOKIE_ACCESS_KEY = "access_token"
- COOKIE_REFRESH_KEY = "refresh_token"
- COOKIE_USERNAME_KEY = "auth_username" # 前端可读,零请求获取用户名
- COOKIE_PATH = "/"
- COOKIE_REFRESH_PATH = "/api/auth" # refresh_token 仅随认证请求携带
- COOKIE_SAMESITE = "lax"
- # 根据是否启用SSL自动设置Secure标志
- COOKIE_SECURE = get_settings().ssl_enabled
- def _set_auth_cookies(response: Response, access_token: str):
- """设置 Access Token HttpOnly Cookie"""
- response.set_cookie(
- key=COOKIE_ACCESS_KEY, value=access_token,
- httponly=True, secure=COOKIE_SECURE, samesite=COOKIE_SAMESITE,
- max_age=1800, path=COOKIE_PATH,
- )
- def _clear_auth_cookies(response: Response):
- """清除认证Cookie(同时清理新旧路径的 refresh_token,兼容升级前残留)"""
- response.delete_cookie(COOKIE_ACCESS_KEY, path=COOKIE_PATH)
- response.delete_cookie(COOKIE_USERNAME_KEY, path=COOKIE_PATH)
- response.delete_cookie(COOKIE_REFRESH_KEY, path=COOKIE_PATH) # 旧:path="/" 的残留
- response.delete_cookie(COOKIE_REFRESH_KEY, path=COOKIE_REFRESH_PATH) # 新:path="/api/auth"
- def require_auth(request: Request = None) -> dict:
- """
- 从请求上下文获取当前用户(中间件已统一处理 Cookie 和 Header)。
- 返回 {"id": user_id},未登录时抛 401。
- """
- user = get_current_user()
- if not user:
- raise HTTPException(status_code=401, detail="未登录")
- return {"id": user["id"]}
- @router.post("/register", summary="用户注册")
- async def register(req: RegisterRequest, request: Request, response: Response):
- """注册新用户(密码经RSA加密),设置Access Token Cookie + Refresh Token存入Redis"""
- if len(req.username) < 2:
- raise HTTPException(status_code=400, detail="用户名至少2个字符")
- # RSA解密密码
- try:
- password = decrypt_data(req.encrypted_password)
- except ValueError as e:
- raise HTTPException(status_code=400, detail=f"密码解密失败: {e}")
- if len(password) < 4:
- raise HTTPException(status_code=400, detail="密码至少4个字符")
- try:
- user = create_user(req.username.strip(), password)
- user_agent = request.headers.get("User-Agent", "")
- access_token, refresh_token = _issue_tokens(response, user["id"], username=user["username"], user_agent=user_agent)
- return {
- "success": True, "message": "注册成功",
- "username": user["username"],
- "access_token": access_token,
- "refresh_token": refresh_token,
- }
- except ValueError as e:
- raise HTTPException(status_code=409, detail=str(e))
- @router.post("/login", summary="用户登录")
- async def login(req: LoginRequest, request: Request, response: Response):
- """登录(密码经RSA加密),设置Access Token Cookie + Refresh Token存入Redis"""
- # RSA解密密码
- try:
- password = decrypt_data(req.encrypted_password)
- except ValueError as e:
- raise HTTPException(status_code=400, detail=f"密码解密失败: {e}")
- user = verify_user(req.username.strip(), password)
- if not user:
- raise HTTPException(status_code=401, detail="用户名或密码错误")
- user_agent = request.headers.get("User-Agent", "")
- access_token, refresh_token = _issue_tokens(response, user["id"], username=user["username"], user_agent=user_agent)
- return {
- "success": True, "message": "登录成功",
- "username": user["username"],
- "access_token": access_token,
- "refresh_token": refresh_token,
- }
- @router.get("/public-key", summary="获取RSA公钥")
- async def public_key():
- """获取RSA公钥(PEM格式),用于前端加密密码"""
- return {
- "success": True,
- "public_key": get_public_key_pem(),
- }
- def _issue_tokens(response: Response, user_id: int, username: str = "", user_agent: str = "") -> tuple[str, str]:
- """签发双Token + 前端可读用户名Cookie
- - access_token (HttpOnly, /) → JWT认证
- - refresh_token (HttpOnly, /api/auth) → 刷新Token (Redis存设备信息)
- - auth_username (可读, /) → 前端直接读,零请求
- 返回: (access_token, refresh_token) — 非浏览器设备可拿到令牌
- """
- # Access Token -> HttpOnly Cookie (全路径携带)
- access_token = create_access_token(user_id)
- _set_auth_cookies(response, access_token)
- # Refresh Token -> JWT + Redis (含设备信息)
- refresh_token, jti = create_refresh_token(user_id)
- store_refresh_token(user_id, jti, user_agent=user_agent)
- # 先清除旧路径的 refresh_token(兼容升级前 path="/" 的残留 cookie)
- response.delete_cookie(COOKIE_REFRESH_KEY, path=COOKIE_PATH)
- # Refresh Token 仅随 /api/auth/* 路径请求携带
- response.set_cookie(
- key=COOKIE_REFRESH_KEY, value=refresh_token,
- httponly=True, secure=COOKIE_SECURE, samesite=COOKIE_SAMESITE,
- max_age=604800, path=COOKIE_REFRESH_PATH,
- )
- # 前端可读的用户名 Cookie(非 HttpOnly,JS 可直接读取,无需调 profile API)
- if username:
- response.set_cookie(
- key=COOKIE_USERNAME_KEY, value=username,
- httponly=False, secure=COOKIE_SECURE, samesite=COOKIE_SAMESITE,
- max_age=1800, path=COOKIE_PATH,
- )
- return access_token, refresh_token
- @router.post("/refresh", summary="刷新Token")
- async def refresh(request: Request, response: Response):
- """用 Refresh Token 换取新的双Token,支持 Cookie 或 X-Refresh-Token Header"""
- # 先从 Cookie 取,再尝试 Header(兼容非浏览器设备)
- refresh_token = request.cookies.get(COOKIE_REFRESH_KEY) or request.headers.get("X-Refresh-Token", "")
- if not refresh_token:
- raise HTTPException(status_code=401, detail="未登录,缺少Refresh Token")
- # 1. JWT签名验证
- try:
- payload = verify_refresh_token(refresh_token)
- except Exception:
- _clear_auth_cookies(response)
- raise HTTPException(status_code=401, detail="Refresh Token已过期或无效")
- # 2. Redis验证:jti是否有效 + 获取绑定的设备信息
- stored = validate_refresh_token(payload["jti"])
- if stored is None or stored["user_id"] != payload["id"]:
- _clear_auth_cookies(response)
- raise HTTPException(status_code=401, detail="Refresh Token已被吊销")
- # 3. 设备信息校验(User-Agent不匹配时拒绝刷新,防跨设备盗用)
- current_ua = request.headers.get("User-Agent", "")
- if stored.get("user_agent") and stored["user_agent"] != current_ua:
- _clear_auth_cookies(response)
- raise HTTPException(status_code=401, detail="设备不匹配,请重新登录")
- # 4. 吊销旧 Refresh Token(轮换)
- revoke_refresh_token(payload["jti"])
- # 5. 签发新双Token(绑定当前设备信息 + 用户名Cookie)
- user_info = get_user_by_id(payload["id"])
- new_access, new_refresh = _issue_tokens(response, payload["id"],
- username=user_info["username"] if user_info else "",
- user_agent=current_ua)
- return {
- "success": True,
- "message": "Token刷新成功",
- "access_token": new_access,
- "refresh_token": new_refresh,
- "username": user_info["username"] if user_info else "",
- }
- @router.post("/logout", summary="用户登出")
- async def logout(request: Request, response: Response):
- """登出:清除Cookie + 吊销Redis中的Refresh Token(支持Cookie或Header)"""
- # 优先取 Cookie,再尝试 Header(兼容非浏览器设备)
- refresh_token = request.cookies.get(COOKIE_REFRESH_KEY) or request.headers.get("X-Refresh-Token", "")
- if refresh_token:
- try:
- payload = verify_refresh_token(refresh_token)
- revoke_refresh_token(payload["jti"])
- except Exception:
- pass
- _clear_auth_cookies(response)
- return {"success": True, "message": "已退出登录"}
- @router.get("/profile", summary="获取用户信息")
- async def profile(request: Request):
- """获取当前登录用户信息(从上下文直接读取,无需查DB)"""
- user = get_current_user()
- if not user:
- raise HTTPException(status_code=401, detail="未登录")
- return {"success": True, "username": user["username"]}
|