security: move refresh token to HttpOnly cookie

- Refresh token stored in HttpOnly + Secure + SameSite=Lax cookie
  instead of localStorage (XSS can no longer steal it)
- Login sets cookie, refresh reads from cookie, logout clears cookie
- Access token remains in localStorage (short-lived, 30min)
- Clear pendingPassword from memory after TOTP success/back
- Remove "admin" from username placeholder
- Cookie path scoped to /api/auth (minimal exposure)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-25 20:22:34 +08:00
parent 9de6ac9904
commit 95aa8a610f
3 changed files with 145 additions and 35 deletions
+104 -17
View File
@@ -2,24 +2,68 @@
Presentation layer — receives HTTP requests, delegates to AuthService.
A2: TOTP endpoints now require JWT authentication via get_current_admin.
Security: Refresh token stored in HttpOnly + Secure + SameSite=Lax cookie.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
import bcrypt
from server.api.dependencies import get_auth_service, get_db
from server.api.auth_jwt import get_current_admin
from server.application.services.auth_service import AuthService
from server.application.services.auth_service import AuthService, JWT_REFRESH_TOKEN_EXPIRE_DAYS
from server.domain.models import Admin, AuditLog
router = APIRouter(prefix="/api/auth", tags=["auth"])
logger = logging.getLogger("nexus.auth")
# ── Refresh Token Cookie Helpers ──
REFRESH_COOKIE_NAME = "nexus_refresh"
REFRESH_COOKIE_PATH = "/api/auth"
def _is_secure_request(request: Request) -> bool:
"""Determine if the request is over HTTPS (direct or via reverse proxy)."""
forwarded_proto = request.headers.get("X-Forwarded-Proto", "").lower()
if forwarded_proto == "https":
return True
return request.url.scheme == "https"
def _set_refresh_cookie(response: Response, refresh_token: str, request: Request):
"""Set HttpOnly cookie for refresh token.
- HttpOnly: not accessible from JavaScript (XSS protection)
- Secure: only sent over HTTPS
- SameSite=Lax: not sent on cross-site POST (CSRF protection)
- Path=/api/auth: only sent to auth endpoints (minimal exposure)
"""
response.set_cookie(
key=REFRESH_COOKIE_NAME,
value=refresh_token,
max_age=JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400,
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
path=REFRESH_COOKIE_PATH,
)
def _clear_refresh_cookie(response: Response, request: Request):
"""Clear the refresh token cookie. Path and flags must match the original set_cookie."""
response.delete_cookie(
key=REFRESH_COOKIE_NAME,
path=REFRESH_COOKIE_PATH,
secure=_is_secure_request(request),
httponly=True,
samesite="lax",
)
# ── Request Models ──
@@ -66,10 +110,15 @@ class WebsshTokenRequest(BaseModel):
@router.post("/login")
async def login(
request: Request,
response: Response,
payload: LoginRequest,
service: AuthService = Depends(get_auth_service),
):
"""Authenticate admin user (password + optional TOTP) → return JWT tokens"""
"""Authenticate admin user (password + optional TOTP) → return JWT tokens
Security: Refresh token is set as HttpOnly cookie (not in JSON body).
Access token is returned in JSON for client-side Bearer auth.
"""
ip_address = request.client.host if request.client else "unknown"
result = await service.login(
username=payload.username,
@@ -84,37 +133,75 @@ async def login(
elif result.get("reason") == "totp_required":
status_code = 202 # Accepted but needs TOTP
raise HTTPException(status_code=status_code, detail=result.get("message", "Login failed"))
return result
# Set refresh token as HttpOnly cookie (not accessible from JS)
_set_refresh_cookie(response, result["refresh_token"], request)
# Return access token + admin info (NOT refresh_token — it's in the cookie)
return {
"success": True,
"access_token": result["access_token"],
"token_type": "bearer",
"expires_in": result["expires_in"],
"admin": result["admin"],
}
@router.post("/refresh")
async def refresh_token(
request: Request,
payload: RefreshRequest,
response: Response,
payload: Optional[RefreshRequest] = None,
service: AuthService = Depends(get_auth_service),
):
"""Exchange refresh token for new access + refresh token pair"""
"""Exchange refresh token for new access token (with rotation + version-based reuse detection)
Security: Refresh token read from HttpOnly cookie (preferred) or request body (fallback).
SameSite=Lax cookie prevents CSRF on cross-site POST requests.
"""
# Read refresh token from cookie (primary) or request body (fallback)
refresh_token = request.cookies.get(REFRESH_COOKIE_NAME)
if not refresh_token and payload and payload.refresh_token:
refresh_token = payload.refresh_token
if not refresh_token:
raise HTTPException(status_code=401, detail="Missing refresh token")
ip_address = request.client.host if request.client else ""
result = await service.refresh_token(payload.refresh_token, ip_address=ip_address)
result = await service.refresh_token(refresh_token, ip_address=ip_address)
if not result["success"]:
# Clear the cookie on failure (invalid/expired token)
_clear_refresh_cookie(response, request)
raise HTTPException(status_code=401, detail=result.get("message", "Invalid refresh token"))
return result
# Rotate: set new refresh token cookie
_set_refresh_cookie(response, result["refresh_token"], request)
return {
"success": True,
"access_token": result["access_token"],
"token_type": "bearer",
"expires_in": result["expires_in"],
}
@router.post("/logout")
async def logout(
request: Request,
payload: LogoutRequest,
response: Response,
service: AuthService = Depends(get_auth_service),
):
"""Invalidate refresh token (client should also discard access token)"""
ip_address = request.client.host if request.client else ""
if payload.refresh_token:
result = await service.logout_by_token(payload.refresh_token)
else:
# No refresh token provided — only client-side cleanup possible
return {"success": True, "message": "客户端已登出(未提供刷新令牌,服务端会话仍有效)"}
return result
"""Invalidate refresh token (read from HttpOnly cookie) and clear cookie.
Security: No request body needed — refresh token comes from cookie.
"""
refresh_token = request.cookies.get(REFRESH_COOKIE_NAME)
if refresh_token:
await service.logout_by_token(refresh_token)
# Always clear the cookie (even if not found, for defense in depth)
_clear_refresh_cookie(response, request)
return {"success": True, "message": "已登出"}
# ── Protected Routes (JWT required) ──