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:
+104
-17
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user