Update cookie
This commit is contained in:
@@ -30,9 +30,14 @@ from app.services.auth_login_throttle import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi import Response
|
from fastapi import Cookie, Response
|
||||||
except (ImportError, AttributeError):
|
except (ImportError, AttributeError):
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
def Cookie(_default=None, **_kwargs): # type: ignore[no-untyped-def]
|
||||||
|
"""Compatibility fallback for environments that stub fastapi without request params."""
|
||||||
|
|
||||||
|
return None
|
||||||
from app.services.authentication import authenticate_user, issue_user_session, revoke_auth_session
|
from app.services.authentication import authenticate_user, issue_user_session, revoke_auth_session
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
@@ -255,9 +260,13 @@ def login(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=AuthSessionResponse)
|
@router.get("/me", response_model=AuthSessionResponse)
|
||||||
def me(context: AuthContext = Depends(require_user_or_admin)) -> AuthSessionResponse:
|
def me(
|
||||||
|
context: AuthContext = Depends(require_user_or_admin),
|
||||||
|
csrf_cookie: str | None = Cookie(None, alias=CSRF_COOKIE_NAME),
|
||||||
|
) -> AuthSessionResponse:
|
||||||
"""Returns current authenticated session identity and expiration metadata."""
|
"""Returns current authenticated session identity and expiration metadata."""
|
||||||
|
|
||||||
|
normalized_csrf_cookie = (csrf_cookie or "").strip() or None
|
||||||
return AuthSessionResponse(
|
return AuthSessionResponse(
|
||||||
expires_at=context.expires_at,
|
expires_at=context.expires_at,
|
||||||
user=AuthUserResponse(
|
user=AuthUserResponse(
|
||||||
@@ -265,6 +274,7 @@ def me(context: AuthContext = Depends(require_user_or_admin)) -> AuthSessionResp
|
|||||||
username=context.username,
|
username=context.username,
|
||||||
role=context.role,
|
role=context.role,
|
||||||
),
|
),
|
||||||
|
csrf_token=normalized_csrf_cookie,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class AuthSessionResponse(BaseModel):
|
|||||||
|
|
||||||
user: AuthUserResponse
|
user: AuthUserResponse
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
|
csrf_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AuthLoginResponse(AuthSessionResponse):
|
class AuthLoginResponse(AuthSessionResponse):
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Primary implementation modules:
|
|||||||
- Login brute-force protection enforces Redis-backed throttle checks keyed by username and source IP.
|
- Login brute-force protection enforces Redis-backed throttle checks keyed by username and source IP.
|
||||||
- State-changing requests from browser clients must send `x-csrf-token: <dcm_csrf>` in request headers (double-submit pattern).
|
- State-changing requests from browser clients must send `x-csrf-token: <dcm_csrf>` in request headers (double-submit pattern).
|
||||||
- For non-browser API clients, the optional `Authorization: Bearer <token>` path remains supported when the token is sent explicitly.
|
- For non-browser API clients, the optional `Authorization: Bearer <token>` path remains supported when the token is sent explicitly.
|
||||||
- `GET /auth/me` returns current identity and role.
|
- `GET /auth/me` returns current identity, role, and current CSRF token.
|
||||||
- `POST /auth/logout` revokes current session token.
|
- `POST /auth/logout` revokes current session token.
|
||||||
|
|
||||||
Role matrix:
|
Role matrix:
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const API_BASE = resolveApiBase();
|
|||||||
const CSRF_COOKIE_NAME = "dcm_csrf";
|
const CSRF_COOKIE_NAME = "dcm_csrf";
|
||||||
const CSRF_HEADER_NAME = "x-csrf-token";
|
const CSRF_HEADER_NAME = "x-csrf-token";
|
||||||
const CSRF_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
const CSRF_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||||
|
const CSRF_SESSION_STORAGE_KEY = "dcm_csrf_token";
|
||||||
|
|
||||||
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
||||||
|
|
||||||
@@ -65,7 +66,38 @@ function getCookieValue(name: string): string | undefined {
|
|||||||
* Resolves the runtime CSRF token from browser cookie storage for API requests.
|
* Resolves the runtime CSRF token from browser cookie storage for API requests.
|
||||||
*/
|
*/
|
||||||
function resolveCsrfToken(): string | undefined {
|
function resolveCsrfToken(): string | undefined {
|
||||||
return getCookieValue(CSRF_COOKIE_NAME);
|
const cookieToken = getCookieValue(CSRF_COOKIE_NAME);
|
||||||
|
if (cookieToken) {
|
||||||
|
return cookieToken;
|
||||||
|
}
|
||||||
|
return loadStoredCsrfToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the runtime CSRF token from browser session storage.
|
||||||
|
*/
|
||||||
|
function loadStoredCsrfToken(): string | undefined {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const rawValue = window.sessionStorage.getItem(CSRF_SESSION_STORAGE_KEY);
|
||||||
|
const normalizedValue = rawValue?.trim();
|
||||||
|
return normalizedValue ? normalizedValue : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists or clears a runtime CSRF token in browser session storage.
|
||||||
|
*/
|
||||||
|
function persistCsrfToken(token: string | undefined | null): void {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedValue = typeof token === "string" ? token.trim() : "";
|
||||||
|
if (!normalizedValue) {
|
||||||
|
window.sessionStorage.removeItem(CSRF_SESSION_STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.sessionStorage.setItem(CSRF_SESSION_STORAGE_KEY, normalizedValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,7 +213,9 @@ export async function loginWithPassword(username: string, password: string): Pro
|
|||||||
}
|
}
|
||||||
throw new Error('Login failed');
|
throw new Error('Login failed');
|
||||||
}
|
}
|
||||||
return response.json() as Promise<AuthLoginResponse>;
|
const payload = await (response.json() as Promise<AuthLoginResponse>);
|
||||||
|
persistCsrfToken(payload.csrf_token);
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,7 +230,9 @@ export async function getCurrentAuthSession(): Promise<AuthSessionInfo> {
|
|||||||
}
|
}
|
||||||
throw new Error('Failed to load authentication session');
|
throw new Error('Failed to load authentication session');
|
||||||
}
|
}
|
||||||
return response.json() as Promise<AuthSessionInfo>;
|
const payload = await (response.json() as Promise<AuthSessionInfo>);
|
||||||
|
persistCsrfToken(payload.csrf_token);
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -206,6 +242,7 @@ export async function logoutCurrentSession(): Promise<void> {
|
|||||||
const response = await apiRequest(`${API_BASE}/auth/logout`, {
|
const response = await apiRequest(`${API_BASE}/auth/logout`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
|
persistCsrfToken(undefined);
|
||||||
if (!response.ok && response.status !== 401) {
|
if (!response.ok && response.status !== 401) {
|
||||||
const detail = await responseErrorDetail(response);
|
const detail = await responseErrorDetail(response);
|
||||||
if (detail) {
|
if (detail) {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export interface AuthUser {
|
|||||||
export interface AuthSessionInfo {
|
export interface AuthSessionInfo {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
|
csrf_token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user