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) ──
+19 -16
View File
@@ -1,4 +1,4 @@
// Nexus — Shared API auth module (JWT Bearer + auto-refresh)
// Nexus — Shared API auth module (JWT Bearer + auto-refresh via HttpOnly cookie)
// Include before page-specific scripts: <script src="/app/api.js"></script>
const API = window.location.origin + '/api';
@@ -6,7 +6,7 @@ const API = window.location.origin + '/api';
function _getTokens() {
return {
access: localStorage.getItem('access_token') || '',
refresh: localStorage.getItem('refresh_token') || '',
// Refresh token is in HttpOnly cookie — not accessible from JS
expires: parseInt(localStorage.getItem('token_expires') || '0'),
};
}
@@ -17,19 +17,16 @@ function _isTokenExpiring() {
}
async function _refreshTokens() {
const { refresh } = _getTokens();
if (!refresh) return false;
try {
// Refresh token is auto-sent as HttpOnly cookie by browser (same-origin)
const res = await fetch(API + '/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refresh }),
});
if (!res.ok) return false;
const data = await res.json();
if (!data.success) return false;
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token || refresh);
localStorage.setItem('token_expires', String(Date.now() + (data.expires_in || 1800) * 1000));
return true;
} catch {
@@ -82,7 +79,6 @@ async function apiFetch(url, options = {}) {
function _logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('token_expires');
localStorage.removeItem('admin');
localStorage.removeItem('last_activity');
@@ -91,14 +87,15 @@ function _logout() {
function doLogout() {
// Notify backend (best-effort, don't block)
const { access, refresh } = _getTokens();
// Refresh token is in HttpOnly cookie — auto-sent by browser
const { access } = _getTokens();
if (access) {
try {
const blob = new Blob(
[JSON.stringify({ refresh_token: refresh })],
{ type: 'application/json' }
);
navigator.sendBeacon(API + '/auth/logout', blob);
fetch(API + '/auth/logout', {
method: 'POST',
keepalive: true,
credentials: 'same-origin',
});
} catch {}
}
_logout();
@@ -121,10 +118,16 @@ function _recordActivity() {
localStorage.setItem('last_activity', String(Date.now()));
}
// Backward-compatible alias
// Backward-compatible alias — try refresh cookie if no access token
const token = localStorage.getItem('access_token') || '';
if (!token || !_checkSessionAge()) {
// Will redirect to login via _checkSessionAge → _logout
if (!token) {
// No access token — try refresh via HttpOnly cookie (auto-sent by browser)
_refreshTokens().then(ok => {
if (!ok) _logout();
else _recordActivity();
});
} else if (!_checkSessionAge()) {
// Session expired — will redirect to login via _checkSessionAge → _logout
} else {
_recordActivity();
}
+22 -2
View File
@@ -44,7 +44,7 @@
<label class="block text-sm font-medium text-slate-300 mb-2">用户名</label>
<input type="text" id="username" name="username" required autocomplete="username"
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
placeholder="admin">
placeholder="请输入用户名">
</div>
<div>
@@ -241,6 +241,9 @@
// Back button
document.getElementById('backBtn').addEventListener('click', () => {
// Clear sensitive data when going back
pendingUsername = '';
pendingPassword = '';
showPasswordStep();
});
@@ -261,9 +264,14 @@
// ── Success Handler ──
function onLoginSuccess(data) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
// Refresh token is now in HttpOnly cookie — not accessible from JS
localStorage.setItem('admin', JSON.stringify(data.admin));
localStorage.setItem('token_expires', String(Date.now() + data.expires_in * 1000));
// Clear sensitive data from memory
pendingUsername = '';
pendingPassword = '';
window.location.href = '/app/index.html';
}
@@ -295,7 +303,19 @@
const expires = localStorage.getItem('token_expires');
if (token && expires && Date.now() < parseInt(expires)) {
window.location.href = '/app/index.html';
return;
}
// Access token expired — try refresh via HttpOnly cookie (auto-sent by browser)
fetch(window.location.origin + '/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()).then(data => {
if (data.success) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('token_expires', String(Date.now() + data.expires_in * 1000));
window.location.href = '/app/index.html';
}
}).catch(() => {});
})();
</script>