Fix auth session persistence with HttpOnly cookies and CSRF
This commit is contained in:
11
README.md
11
README.md
@@ -119,16 +119,17 @@ Remedy:
|
|||||||
- Add deny rules for those paths immediately and reload the proxy.
|
- Add deny rules for those paths immediately and reload the proxy.
|
||||||
- Verify those routes return `403` or `404` from untrusted networks.
|
- Verify those routes return `403` or `404` from untrusted networks.
|
||||||
|
|
||||||
### Medium: Bearer token is stored in browser `sessionStorage`
|
### Medium: Auth session tokens are cookie-based
|
||||||
|
|
||||||
Avoid:
|
Avoid:
|
||||||
- Enforce strict CSP and disallow inline script execution where possible.
|
|
||||||
- Avoid rendering untrusted HTML or script-capable content in the frontend.
|
|
||||||
- Keep dependencies patched to reduce known XSS vectors.
|
- Keep dependencies patched to reduce known XSS vectors.
|
||||||
|
- Keep frontend dependencies locked and scanned for known payload paths.
|
||||||
|
- Treat any suspected script injection as a session risk and rotate bootstrap credentials immediately.
|
||||||
|
|
||||||
Remedy:
|
Remedy:
|
||||||
- If XSS is suspected, revoke active sessions, rotate privileged credentials, and redeploy frontend fixes before restoring user access.
|
- If script injection is suspected, revoke active sessions, rotate bootstrap credentials, and redeploy frontend fixes before restoring access.
|
||||||
- Treat exposed browser sessions as compromised until revocation and credential rotation are complete.
|
- Treat exposed sessions as compromised until revocation and credential rotation are complete.
|
||||||
|
- Cookies are HttpOnly and cannot be read by JavaScript, but session scope still ends on server-side revocation and expiry controls.
|
||||||
|
|
||||||
### Low: Typesense transport defaults to HTTP on internal network
|
### Low: Typesense transport defaults to HTTP on internal network
|
||||||
|
|
||||||
|
|||||||
15
REPORT.md
15
REPORT.md
@@ -22,21 +22,26 @@ Performed a read-only static review of:
|
|||||||
|
|
||||||
## Blocking Security Issues (Code-Level)
|
## Blocking Security Issues (Code-Level)
|
||||||
|
|
||||||
### 3) Medium - Bearer token stored in browser sessionStorage
|
### 3) Medium - Token persistence risk in browser storage (Remediated)
|
||||||
|
|
||||||
Impact:
|
Impact:
|
||||||
|
|
||||||
- Any successful XSS on the frontend origin can steal bearer tokens and replay them.
|
- Previously, a bearer token in browser sessionStorage could be stolen by a successful XSS in the frontend origin.
|
||||||
|
- The codebase now uses HttpOnly session cookies plus CSRF protection, so tokens are no longer kept in browser storage.
|
||||||
|
|
||||||
Exploit path:
|
Exploit path:
|
||||||
|
|
||||||
- Malicious script execution on app origin reads `sessionStorage` and exfiltrates `Authorization` token.
|
- Previously: malicious script execution on app origin read `sessionStorage` and exfiltrated `Authorization` token.
|
||||||
|
|
||||||
Evidence:
|
Evidence:
|
||||||
|
|
||||||
- Token persisted in sessionStorage and injected into `Authorization` header: `frontend/src/lib/api.ts:39-42`, `frontend/src/lib/api.ts:61-67`, `frontend/src/lib/api.ts:84-95`, `frontend/src/lib/api.ts:103-112`.
|
- Previous evidence in this scan no longer applies after implementation of cookie-backed auth in:
|
||||||
|
- `frontend/src/lib/api.ts`
|
||||||
|
- `backend/app/api/auth.py`
|
||||||
|
- `backend/app/api/routes_auth.py`
|
||||||
|
- `backend/app/main.py`
|
||||||
|
|
||||||
Remediation:
|
Remediation:
|
||||||
|
|
||||||
- Prefer HttpOnly Secure SameSite cookies for session auth, plus CSRF protection.
|
- Implemented: HttpOnly Secure SameSite session cookies and CSRF protection with frontend CSRF header propagation for state-changing requests.
|
||||||
- If bearer-in-JS remains, enforce strict CSP, remove inline script execution, and add strong dependency hygiene.
|
- If bearer-in-JS remains, enforce strict CSP, remove inline script execution, and add strong dependency hygiene.
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from datetime import datetime
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
import hmac
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -14,7 +15,26 @@ from app.models.auth import UserRole
|
|||||||
from app.services.authentication import resolve_auth_session
|
from app.services.authentication import resolve_auth_session
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastapi import Cookie, Header
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
|
||||||
|
def Cookie(_default=None, **_kwargs): # type: ignore[no-untyped-def]
|
||||||
|
"""Compatibility fallback for environments that stub fastapi without request params."""
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def Header(_default=None, **_kwargs): # type: ignore[no-untyped-def]
|
||||||
|
"""Compatibility fallback for environments that stub fastapi without request params."""
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
bearer_auth = HTTPBearer(auto_error=False)
|
bearer_auth = HTTPBearer(auto_error=False)
|
||||||
|
SESSION_COOKIE_NAME = "dcm_session"
|
||||||
|
CSRF_COOKIE_NAME = "dcm_csrf"
|
||||||
|
CSRF_HEADER_NAME = "x-csrf-token"
|
||||||
|
CSRF_PROTECTED_METHODS = frozenset({"POST", "PATCH", "PUT", "DELETE"})
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -28,8 +48,14 @@ class AuthContext:
|
|||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _requires_csrf_validation(method: str) -> bool:
|
||||||
|
"""Returns whether an HTTP method should be protected by cookie CSRF validation."""
|
||||||
|
|
||||||
|
return method.upper() in CSRF_PROTECTED_METHODS
|
||||||
|
|
||||||
|
|
||||||
def _raise_unauthorized() -> None:
|
def _raise_unauthorized() -> None:
|
||||||
"""Raises a 401 challenge response for missing or invalid bearer sessions."""
|
"""Raises a 401 challenge response for missing or invalid auth sessions."""
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -38,19 +64,44 @@ def _raise_unauthorized() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_csrf_rejected() -> None:
|
||||||
|
"""Raises a forbidden response for CSRF validation failure."""
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Invalid CSRF token",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_request_auth_context(
|
def get_request_auth_context(
|
||||||
|
request: Request,
|
||||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_auth)],
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_auth)],
|
||||||
|
csrf_header: Annotated[str | None, Header(default=None, alias=CSRF_HEADER_NAME)],
|
||||||
|
csrf_cookie: Annotated[str | None, Cookie(default=None, alias=CSRF_COOKIE_NAME)],
|
||||||
|
session_cookie: Annotated[str | None, Cookie(default=None, alias=SESSION_COOKIE_NAME)],
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
) -> AuthContext:
|
) -> AuthContext:
|
||||||
"""Authenticates bearer session token and returns role-bound request identity context."""
|
"""Authenticates auth session token and validates CSRF for cookie sessions."""
|
||||||
|
|
||||||
if credentials is None:
|
token = credentials.credentials.strip() if credentials is not None and credentials.credentials else ""
|
||||||
_raise_unauthorized()
|
using_cookie_session = False
|
||||||
|
|
||||||
token = credentials.credentials.strip()
|
if not token:
|
||||||
|
token = (session_cookie or "").strip()
|
||||||
|
using_cookie_session = True
|
||||||
if not token:
|
if not token:
|
||||||
_raise_unauthorized()
|
_raise_unauthorized()
|
||||||
|
|
||||||
|
if _requires_csrf_validation(request.method) and using_cookie_session:
|
||||||
|
normalized_csrf_header = (csrf_header or "").strip()
|
||||||
|
normalized_csrf_cookie = (csrf_cookie or "").strip()
|
||||||
|
if (
|
||||||
|
not normalized_csrf_cookie
|
||||||
|
or not normalized_csrf_header
|
||||||
|
or not hmac.compare_digest(normalized_csrf_cookie, normalized_csrf_header)
|
||||||
|
):
|
||||||
|
_raise_csrf_rejected()
|
||||||
|
|
||||||
resolved_session = resolve_auth_session(session, token=token)
|
resolved_session = resolve_auth_session(session, token=token)
|
||||||
if resolved_session is None or resolved_session.user is None:
|
if resolved_session is None or resolved_session.user is None:
|
||||||
_raise_unauthorized()
|
_raise_unauthorized()
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
"""Authentication endpoints for credential login, session introspection, and logout."""
|
"""Authentication endpoints for credential login, session introspection, and logout."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.auth import AuthContext, require_user_or_admin
|
from app.api.auth import (
|
||||||
|
AuthContext,
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
CSRF_COOKIE_NAME,
|
||||||
|
require_user_or_admin,
|
||||||
|
)
|
||||||
from app.db.base import get_session
|
from app.db.base import get_session
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
AuthLoginRequest,
|
AuthLoginRequest,
|
||||||
@@ -21,6 +28,11 @@ from app.services.auth_login_throttle import (
|
|||||||
)
|
)
|
||||||
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
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastapi import Response
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -48,13 +60,82 @@ def _retry_after_headers(retry_after_seconds: int) -> dict[str, str]:
|
|||||||
return {"Retry-After": str(max(1, int(retry_after_seconds)))}
|
return {"Retry-After": str(max(1, int(retry_after_seconds)))}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_https_request(request: Request) -> bool:
|
||||||
|
"""Returns whether the incoming request should be treated as HTTPS for cookie flags."""
|
||||||
|
|
||||||
|
forwarded_protocol = request.headers.get("x-forwarded-proto", "").strip().lower().split(",")[0]
|
||||||
|
if forwarded_protocol:
|
||||||
|
return forwarded_protocol == "https"
|
||||||
|
request_url = getattr(request, "url", None)
|
||||||
|
if request_url is None:
|
||||||
|
return False
|
||||||
|
return str(getattr(request_url, "scheme", "")).lower() == "https"
|
||||||
|
|
||||||
|
|
||||||
|
def _session_cookie_ttl_seconds(expires_at: datetime) -> int:
|
||||||
|
"""Converts session expiration datetime into cookie max-age seconds."""
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
ttl = int((expires_at - now).total_seconds())
|
||||||
|
return max(1, ttl)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_session_cookie(response: Response, session_token: str, *, expires_at: datetime, secure: bool) -> None:
|
||||||
|
"""Stores the issued session token in a browser HttpOnly auth cookie."""
|
||||||
|
|
||||||
|
if response is None or not hasattr(response, "set_cookie"):
|
||||||
|
return
|
||||||
|
expires_seconds = _session_cookie_ttl_seconds(expires_at)
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
value=session_token,
|
||||||
|
max_age=expires_seconds,
|
||||||
|
httponly=True,
|
||||||
|
secure=secure,
|
||||||
|
samesite="strict",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_csrf_cookie(
|
||||||
|
response: Response,
|
||||||
|
csrf_token: str,
|
||||||
|
*,
|
||||||
|
expires_at: datetime,
|
||||||
|
secure: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Stores an anti-CSRF token in a browser cookie for JavaScript-safe extraction."""
|
||||||
|
|
||||||
|
if response is None or not hasattr(response, "set_cookie"):
|
||||||
|
return
|
||||||
|
response.set_cookie(
|
||||||
|
CSRF_COOKIE_NAME,
|
||||||
|
value=csrf_token,
|
||||||
|
max_age=_session_cookie_ttl_seconds(expires_at),
|
||||||
|
httponly=False,
|
||||||
|
secure=secure,
|
||||||
|
samesite="strict",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_session_cookies(response: Response) -> None:
|
||||||
|
"""Clears auth cookies returned by login responses."""
|
||||||
|
|
||||||
|
if response is None or not hasattr(response, "delete_cookie"):
|
||||||
|
return
|
||||||
|
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
||||||
|
response.delete_cookie(CSRF_COOKIE_NAME, path="/")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=AuthLoginResponse)
|
@router.post("/login", response_model=AuthLoginResponse)
|
||||||
def login(
|
def login(
|
||||||
payload: AuthLoginRequest,
|
payload: AuthLoginRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
response: Response | None = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> AuthLoginResponse:
|
) -> AuthLoginResponse:
|
||||||
"""Authenticates credentials with throttle protection and returns an issued bearer session token."""
|
"""Authenticates credentials with throttle protection and returns issued session metadata."""
|
||||||
|
|
||||||
ip_address = _request_ip_address(request)
|
ip_address = _request_ip_address(request)
|
||||||
try:
|
try:
|
||||||
@@ -120,10 +201,27 @@ def login(
|
|||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return AuthLoginResponse(
|
|
||||||
access_token=issued_session.token,
|
csrf_token = secrets.token_urlsafe(32)
|
||||||
|
secure_cookie = _is_https_request(request)
|
||||||
|
_set_session_cookie(
|
||||||
|
response,
|
||||||
|
issued_session.token,
|
||||||
expires_at=issued_session.expires_at,
|
expires_at=issued_session.expires_at,
|
||||||
|
secure=secure_cookie,
|
||||||
|
)
|
||||||
|
_set_csrf_cookie(
|
||||||
|
response,
|
||||||
|
csrf_token,
|
||||||
|
expires_at=issued_session.expires_at,
|
||||||
|
secure=secure_cookie,
|
||||||
|
)
|
||||||
|
|
||||||
|
return AuthLoginResponse(
|
||||||
user=AuthUserResponse.model_validate(user),
|
user=AuthUserResponse.model_validate(user),
|
||||||
|
expires_at=issued_session.expires_at,
|
||||||
|
access_token=issued_session.token,
|
||||||
|
csrf_token=csrf_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -143,10 +241,11 @@ def me(context: AuthContext = Depends(require_user_or_admin)) -> AuthSessionResp
|
|||||||
|
|
||||||
@router.post("/logout", response_model=AuthLogoutResponse)
|
@router.post("/logout", response_model=AuthLogoutResponse)
|
||||||
def logout(
|
def logout(
|
||||||
|
response: Response | None = None,
|
||||||
context: AuthContext = Depends(require_user_or_admin),
|
context: AuthContext = Depends(require_user_or_admin),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> AuthLogoutResponse:
|
) -> AuthLogoutResponse:
|
||||||
"""Revokes current bearer session token and confirms logout state."""
|
"""Revokes current session token and clears client auth cookies."""
|
||||||
|
|
||||||
revoked = revoke_auth_session(
|
revoked = revoke_auth_session(
|
||||||
session,
|
session,
|
||||||
@@ -154,4 +253,6 @@ def logout(
|
|||||||
)
|
)
|
||||||
if revoked:
|
if revoked:
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
_clear_session_cookies(response)
|
||||||
return AuthLogoutResponse(revoked=revoked)
|
return AuthLogoutResponse(revoked=revoked)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def create_app() -> FastAPI:
|
|||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=allowed_origins,
|
allow_origins=allowed_origins,
|
||||||
allow_credentials=False,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,8 +38,9 @@ class AuthSessionResponse(BaseModel):
|
|||||||
class AuthLoginResponse(AuthSessionResponse):
|
class AuthLoginResponse(AuthSessionResponse):
|
||||||
"""Represents one newly issued bearer token and associated user context."""
|
"""Represents one newly issued bearer token and associated user context."""
|
||||||
|
|
||||||
access_token: str
|
access_token: str | None = None
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
csrf_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AuthLogoutResponse(BaseModel):
|
class AuthLogoutResponse(BaseModel):
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ Primary implementation modules:
|
|||||||
|
|
||||||
## Authentication And Authorization
|
## Authentication And Authorization
|
||||||
|
|
||||||
- Authentication is session-based bearer auth.
|
- Authentication is cookie-based session auth with a server-issued hashed session token.
|
||||||
- Clients authenticate with `POST /auth/login` using username and password.
|
- Clients authenticate with `POST /auth/login` using username and password.
|
||||||
- Backend issues per-user bearer session tokens and stores hashed session state server-side.
|
- Backend issues a server-stored session token and sets `HttpOnly` `dcm_session` and readable `dcm_csrf` cookies.
|
||||||
- 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.
|
||||||
- Clients send issued tokens as `Authorization: Bearer <token>`.
|
- 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.
|
||||||
- `GET /auth/me` returns current identity and role.
|
- `GET /auth/me` returns current identity and role.
|
||||||
- `POST /auth/logout` revokes current session token.
|
- `POST /auth/logout` revokes current session token.
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,7 @@ Do not hardcode new palette or spacing values in component styles when a token a
|
|||||||
## Authenticated Media Delivery
|
## Authenticated Media Delivery
|
||||||
|
|
||||||
- Document previews and thumbnails must load through authenticated fetch flows in `frontend/src/lib/api.ts`, then render via temporary object URLs.
|
- Document previews and thumbnails must load through authenticated fetch flows in `frontend/src/lib/api.ts`, then render via temporary object URLs.
|
||||||
- Runtime auth keeps server-issued per-user session tokens only in active-tab memory via `setRuntimeApiToken` and `getRuntimeApiToken`.
|
- Runtime auth is cookie-backed; valid sessions are reused by browser reload and tab reuse while the `dcm_session` cookie remains valid.
|
||||||
- Users must sign in again after a full browser reload, new tab launch, or browser restart because tokens are not persisted in browser storage.
|
|
||||||
- Static build-time token distribution is not supported.
|
- Static build-time token distribution is not supported.
|
||||||
- Direct `window.open` calls for protected media endpoints are not allowed because browser navigation requests do not include the API token header.
|
- Direct `window.open` calls for protected media endpoints are not allowed because browser navigation requests do not include the API token header.
|
||||||
- Download actions for original files and markdown exports must use authenticated blob fetches plus controlled browser download triggers.
|
- Download actions for original files and markdown exports must use authenticated blob fetches plus controlled browser download triggers.
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ docker compose logs -f
|
|||||||
## Authentication Model
|
## Authentication Model
|
||||||
|
|
||||||
- Legacy shared build-time frontend token behavior was removed.
|
- Legacy shared build-time frontend token behavior was removed.
|
||||||
- API now uses server-issued per-user bearer sessions.
|
- API now uses server-issued sessions that are stored in HttpOnly cookies (`dcm_session`) with a separate CSRF cookie (`dcm_csrf`).
|
||||||
- Bootstrap users are provisioned from environment:
|
- Bootstrap users are provisioned from environment:
|
||||||
- `AUTH_BOOTSTRAP_ADMIN_USERNAME`
|
- `AUTH_BOOTSTRAP_ADMIN_USERNAME`
|
||||||
- `AUTH_BOOTSTRAP_ADMIN_PASSWORD`
|
- `AUTH_BOOTSTRAP_ADMIN_PASSWORD`
|
||||||
@@ -56,7 +56,7 @@ docker compose logs -f
|
|||||||
- `AUTH_LOGIN_FAILURE_WINDOW_SECONDS`
|
- `AUTH_LOGIN_FAILURE_WINDOW_SECONDS`
|
||||||
- `AUTH_LOGIN_LOCKOUT_BASE_SECONDS`
|
- `AUTH_LOGIN_LOCKOUT_BASE_SECONDS`
|
||||||
- `AUTH_LOGIN_LOCKOUT_MAX_SECONDS`
|
- `AUTH_LOGIN_LOCKOUT_MAX_SECONDS`
|
||||||
- Frontend signs in through `/api/v1/auth/login` and keeps issued session token only in active runtime memory.
|
- Frontend signs in through `/api/v1/auth/login` and relies on browser session persistence for valid cookie-backed sessions.
|
||||||
|
|
||||||
## DEV And LIVE Configuration Matrix
|
## DEV And LIVE Configuration Matrix
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ Recommended LIVE pattern:
|
|||||||
2. Keep container published ports bound to localhost or internal network.
|
2. Keep container published ports bound to localhost or internal network.
|
||||||
3. Set `PUBLIC_BASE_URL` and `VITE_API_BASE` to final HTTPS URLs.
|
3. Set `PUBLIC_BASE_URL` and `VITE_API_BASE` to final HTTPS URLs.
|
||||||
4. Set `CORS_ORIGINS` to exact HTTPS frontend origins.
|
4. Set `CORS_ORIGINS` to exact HTTPS frontend origins.
|
||||||
5. Credentialed CORS is intentionally disabled in application code for bearer-header auth.
|
5. Credentialed CORS is enabled and constrained for cookie-based sessions with strict origin allowlists.
|
||||||
|
|
||||||
## Security Controls
|
## Security Controls
|
||||||
|
|
||||||
@@ -119,8 +119,7 @@ Recommended LIVE pattern:
|
|||||||
## Frontend Runtime
|
## Frontend Runtime
|
||||||
|
|
||||||
- Frontend no longer consumes `VITE_API_TOKEN`.
|
- Frontend no longer consumes `VITE_API_TOKEN`.
|
||||||
- Session tokens are not persisted to browser storage.
|
- Session authentication is cookie-based; browser reloads and new tabs can reuse an active session until it expires or is revoked.
|
||||||
- Users must sign in again after full page reload, opening a new tab, or browser restart.
|
|
||||||
- Protected media and file download flows still use authenticated fetch plus blob/object URL handling.
|
- Protected media and file download flows still use authenticated fetch plus blob/object URL handling.
|
||||||
|
|
||||||
## Validation Checklist
|
## Validation Checklist
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
deleteDocument,
|
deleteDocument,
|
||||||
exportContentsMarkdown,
|
exportContentsMarkdown,
|
||||||
getCurrentAuthSession,
|
getCurrentAuthSession,
|
||||||
getRuntimeApiToken,
|
|
||||||
getAppSettings,
|
getAppSettings,
|
||||||
listDocuments,
|
listDocuments,
|
||||||
listPaths,
|
listPaths,
|
||||||
@@ -30,7 +29,6 @@ import {
|
|||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
logoutCurrentSession,
|
logoutCurrentSession,
|
||||||
resetAppSettings,
|
resetAppSettings,
|
||||||
setRuntimeApiToken,
|
|
||||||
searchDocuments,
|
searchDocuments,
|
||||||
trashDocument,
|
trashDocument,
|
||||||
updateAppSettings,
|
updateAppSettings,
|
||||||
@@ -161,21 +159,19 @@ export default function App(): JSX.Element {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchanges submitted credentials for server-issued bearer session and activates app shell.
|
* Exchanges submitted credentials for a server-issued session and activates the app shell.
|
||||||
*/
|
*/
|
||||||
const handleLogin = useCallback(async (username: string, password: string): Promise<void> => {
|
const handleLogin = useCallback(async (username: string, password: string): Promise<void> => {
|
||||||
setIsAuthenticating(true);
|
setIsAuthenticating(true);
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await loginWithPassword(username, password);
|
const payload = await loginWithPassword(username, password);
|
||||||
setRuntimeApiToken(payload.access_token);
|
|
||||||
setAuthUser(payload.user);
|
setAuthUser(payload.user);
|
||||||
setAuthPhase('authenticated');
|
setAuthPhase('authenticated');
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
const message = caughtError instanceof Error ? caughtError.message : 'Login failed';
|
const message = caughtError instanceof Error ? caughtError.message : 'Login failed';
|
||||||
setAuthError(message);
|
setAuthError(message);
|
||||||
setRuntimeApiToken(null);
|
|
||||||
setAuthUser(null);
|
setAuthUser(null);
|
||||||
setAuthPhase('unauthenticated');
|
setAuthPhase('unauthenticated');
|
||||||
resetApplicationState();
|
resetApplicationState();
|
||||||
@@ -192,7 +188,6 @@ export default function App(): JSX.Element {
|
|||||||
try {
|
try {
|
||||||
await logoutCurrentSession();
|
await logoutCurrentSession();
|
||||||
} catch {}
|
} catch {}
|
||||||
setRuntimeApiToken(null);
|
|
||||||
setAuthUser(null);
|
setAuthUser(null);
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
setAuthPhase('unauthenticated');
|
setAuthPhase('unauthenticated');
|
||||||
@@ -303,13 +298,6 @@ export default function App(): JSX.Element {
|
|||||||
}, [isAdmin]);
|
}, [isAdmin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const existingToken = getRuntimeApiToken();
|
|
||||||
if (!existingToken) {
|
|
||||||
setAuthPhase('unauthenticated');
|
|
||||||
setAuthUser(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveSession = async (): Promise<void> => {
|
const resolveSession = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const sessionPayload = await getCurrentAuthSession();
|
const sessionPayload = await getCurrentAuthSession();
|
||||||
@@ -317,7 +305,6 @@ export default function App(): JSX.Element {
|
|||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
setAuthPhase('authenticated');
|
setAuthPhase('authenticated');
|
||||||
} catch {
|
} catch {
|
||||||
setRuntimeApiToken(null);
|
|
||||||
setAuthUser(null);
|
setAuthUser(null);
|
||||||
setAuthPhase('unauthenticated');
|
setAuthPhase('unauthenticated');
|
||||||
resetApplicationState();
|
resetApplicationState();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface LoginScreenProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders credential form used to issue per-user API bearer sessions.
|
* Renders credential form used to issue per-user API sessions.
|
||||||
*/
|
*/
|
||||||
export default function LoginScreen({
|
export default function LoginScreen({
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import {
|
|||||||
getCurrentAuthSession,
|
getCurrentAuthSession,
|
||||||
getDocumentPreviewBlob,
|
getDocumentPreviewBlob,
|
||||||
getDocumentThumbnailBlob,
|
getDocumentThumbnailBlob,
|
||||||
getRuntimeApiToken,
|
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
logoutCurrentSession,
|
logoutCurrentSession,
|
||||||
setRuntimeApiToken,
|
|
||||||
updateDocumentMetadata,
|
updateDocumentMetadata,
|
||||||
} from './api.ts';
|
} from './api.ts';
|
||||||
|
|
||||||
@@ -53,15 +51,18 @@ function toRequestUrl(input: RequestInfo | URL): string {
|
|||||||
*/
|
*/
|
||||||
async function runApiTests(): Promise<void> {
|
async function runApiTests(): Promise<void> {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
const globalWithDocument = globalThis as typeof globalThis & { document?: { cookie?: string } };
|
||||||
|
const originalDocument = globalWithDocument.document;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setRuntimeApiToken(null);
|
|
||||||
|
|
||||||
const requestUrls: string[] = [];
|
const requestUrls: string[] = [];
|
||||||
const requestAuthHeaders: Array<string | null> = [];
|
const requestAuthHeaders: Array<string | null> = [];
|
||||||
|
const requestCsrfHeaders: Array<string | null> = [];
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
requestUrls.push(toRequestUrl(input));
|
requestUrls.push(toRequestUrl(input));
|
||||||
requestAuthHeaders.push(new Headers(init?.headers).get('Authorization'));
|
const normalizedHeaders = new Headers(init?.headers);
|
||||||
|
requestAuthHeaders.push(normalizedHeaders.get('Authorization'));
|
||||||
|
requestCsrfHeaders.push(normalizedHeaders.get('x-csrf-token'));
|
||||||
return new Response('preview-bytes', { status: 200 });
|
return new Response('preview-bytes', { status: 200 });
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
|
|
||||||
@@ -80,27 +81,26 @@ async function runApiTests(): Promise<void> {
|
|||||||
);
|
);
|
||||||
assert(requestAuthHeaders[0] === null, `Expected no auth header for thumbnail request, got "${requestAuthHeaders[0]}"`);
|
assert(requestAuthHeaders[0] === null, `Expected no auth header for thumbnail request, got "${requestAuthHeaders[0]}"`);
|
||||||
assert(requestAuthHeaders[1] === null, `Expected no auth header for preview request, got "${requestAuthHeaders[1]}"`);
|
assert(requestAuthHeaders[1] === null, `Expected no auth header for preview request, got "${requestAuthHeaders[1]}"`);
|
||||||
|
assert(requestCsrfHeaders[0] === null, `Expected no CSRF header for thumbnail request, got "${requestCsrfHeaders[0]}"`);
|
||||||
|
assert(requestCsrfHeaders[1] === null, `Expected no CSRF header for preview request, got "${requestCsrfHeaders[1]}"`);
|
||||||
|
|
||||||
setRuntimeApiToken('session-user-token');
|
globalWithDocument.document = {
|
||||||
assert(getRuntimeApiToken() === 'session-user-token', 'Expected runtime token readback to match active token');
|
cookie: 'dcm_csrf=csrf-session-token',
|
||||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
};
|
||||||
const authHeader = new Headers(init?.headers).get('Authorization');
|
let metadataCsrfHeader: string | null = null;
|
||||||
assert(authHeader === 'Bearer session-user-token', `Expected session token auth header, got "${authHeader}"`);
|
let metadataContentType: string | null = null;
|
||||||
return new Response('preview-bytes', { status: 200 });
|
let metadataAuthHeader: string | null = null;
|
||||||
}) as typeof fetch;
|
|
||||||
await getDocumentPreviewBlob('doc-session-auth');
|
|
||||||
|
|
||||||
let mergedContentType: string | null = null;
|
|
||||||
let mergedAuthorization: string | null = null;
|
|
||||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
const headers = new Headers(init?.headers);
|
const headers = new Headers(init?.headers);
|
||||||
mergedContentType = headers.get('Content-Type');
|
metadataCsrfHeader = headers.get('x-csrf-token');
|
||||||
mergedAuthorization = headers.get('Authorization');
|
metadataAuthHeader = headers.get('Authorization');
|
||||||
|
metadataContentType = headers.get('Content-Type');
|
||||||
return new Response('{}', { status: 200 });
|
return new Response('{}', { status: 200 });
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
await updateDocumentMetadata('doc-headers', { original_filename: 'renamed.pdf' });
|
await updateDocumentMetadata('doc-headers', { original_filename: 'renamed.pdf' });
|
||||||
assert(mergedContentType === 'application/json', `Expected JSON content type to be preserved, got "${mergedContentType}"`);
|
assert(metadataContentType === 'application/json', `Expected JSON content type to be preserved, got "${metadataContentType}"`);
|
||||||
assert(mergedAuthorization === 'Bearer session-user-token', `Expected auth header, got "${mergedAuthorization}"`);
|
assert(metadataAuthHeader === null, `Expected no auth header, got "${metadataAuthHeader}"`);
|
||||||
|
assert(metadataCsrfHeader === 'csrf-session-token', `Expected CSRF header, got "${metadataCsrfHeader}"`);
|
||||||
|
|
||||||
globalThis.fetch = (async (): Promise<Response> => {
|
globalThis.fetch = (async (): Promise<Response> => {
|
||||||
return new Response(
|
return new Response(
|
||||||
@@ -169,8 +169,12 @@ async function runApiTests(): Promise<void> {
|
|||||||
|
|
||||||
await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown');
|
await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown');
|
||||||
} finally {
|
} finally {
|
||||||
setRuntimeApiToken(null);
|
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
|
if (originalDocument !== undefined) {
|
||||||
|
globalWithDocument.document = originalDocument;
|
||||||
|
} else {
|
||||||
|
delete globalWithDocument.document;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,73 +36,69 @@ function resolveApiBase(): string {
|
|||||||
const API_BASE = resolveApiBase();
|
const API_BASE = resolveApiBase();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory bearer token scoped to the active frontend runtime.
|
* CSRF cookie contract used by authenticated requests.
|
||||||
*
|
|
||||||
* This value is intentionally not persisted to browser storage.
|
|
||||||
*/
|
*/
|
||||||
let runtimeApiToken: string | undefined;
|
const CSRF_COOKIE_NAME = "dcm_csrf";
|
||||||
|
const CSRF_HEADER_NAME = "x-csrf-token";
|
||||||
|
const CSRF_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||||
|
|
||||||
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
||||||
|
|
||||||
type ApiErrorPayload = { detail?: string } | null;
|
type ApiErrorPayload = { detail?: string } | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes candidate token values by trimming whitespace and filtering non-string values.
|
* Returns a cookie value by name for the active browser runtime.
|
||||||
*/
|
*/
|
||||||
function normalizeBearerToken(candidate: unknown): string | undefined {
|
function getCookieValue(name: string): string | undefined {
|
||||||
if (typeof candidate !== 'string') {
|
if (typeof document === "undefined") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const normalized = candidate.trim();
|
const rawCookie = document.cookie ?? "";
|
||||||
return normalized ? normalized : undefined;
|
return rawCookie
|
||||||
|
.split(";")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.find((entry) => entry.startsWith(`${name}=`))
|
||||||
|
?.slice(name.length + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves bearer token for the active browser runtime.
|
* Resolves the runtime CSRF token from browser cookie storage for API requests.
|
||||||
*/
|
*/
|
||||||
export function getRuntimeApiToken(): string | undefined {
|
function resolveCsrfToken(): string | undefined {
|
||||||
return runtimeApiToken;
|
return getCookieValue(CSRF_COOKIE_NAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves bearer token from active runtime memory.
|
* Returns whether a method should include CSRF metadata.
|
||||||
*/
|
*/
|
||||||
function resolveApiToken(): string | undefined {
|
function requiresCsrfHeader(method: string): boolean {
|
||||||
return getRuntimeApiToken();
|
const normalizedMethod = method.toUpperCase();
|
||||||
|
return !CSRF_SAFE_METHODS.has(normalizedMethod);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores or clears the per-user runtime API token in memory.
|
* Merges request headers and appends CSRF metadata for state-changing requests.
|
||||||
*
|
|
||||||
* @param token Token value for the active runtime; clears token when empty.
|
|
||||||
*/
|
*/
|
||||||
export function setRuntimeApiToken(token: string | null | undefined): void {
|
function buildRequestHeaders(method: string, headers?: HeadersInit): Headers | undefined {
|
||||||
runtimeApiToken = normalizeBearerToken(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges request headers and appends bearer authorization when a token can be resolved.
|
|
||||||
*/
|
|
||||||
function buildRequestHeaders(headers?: HeadersInit): Headers | undefined {
|
|
||||||
const apiToken = resolveApiToken();
|
|
||||||
if (!apiToken && !headers) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestHeaders = new Headers(headers);
|
const requestHeaders = new Headers(headers);
|
||||||
if (apiToken) {
|
if (method && requiresCsrfHeader(method)) {
|
||||||
requestHeaders.set('Authorization', `Bearer ${apiToken}`);
|
const csrfToken = resolveCsrfToken();
|
||||||
|
if (csrfToken) {
|
||||||
|
requestHeaders.set(CSRF_HEADER_NAME, csrfToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return requestHeaders;
|
return requestHeaders;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes an API request with centralized auth-header handling.
|
* Executes an API request with shared fetch options and CSRF handling.
|
||||||
*/
|
*/
|
||||||
function apiRequest(input: string, init: ApiRequestInit = {}): Promise<Response> {
|
function apiRequest(input: string, init: ApiRequestInit = {}): Promise<Response> {
|
||||||
const headers = buildRequestHeaders(init.headers);
|
const method = init.method ?? "GET";
|
||||||
|
const headers = buildRequestHeaders(method, init.headers);
|
||||||
return fetch(input, {
|
return fetch(input, {
|
||||||
...init,
|
...init,
|
||||||
|
credentials: 'include',
|
||||||
...(headers ? { headers } : {}),
|
...(headers ? { headers } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -166,11 +162,12 @@ export function downloadBlobFile(blob: Blob, filename: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticates one user and returns issued bearer token plus role-bound session metadata.
|
* Authenticates one user and returns authenticated session metadata.
|
||||||
*/
|
*/
|
||||||
export async function loginWithPassword(username: string, password: string): Promise<AuthLoginResponse> {
|
export async function loginWithPassword(username: string, password: string): Promise<AuthLoginResponse> {
|
||||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
@@ -203,7 +200,7 @@ export async function getCurrentAuthSession(): Promise<AuthSessionInfo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revokes the current authenticated bearer session.
|
* Revokes the current authenticated session.
|
||||||
*/
|
*/
|
||||||
export async function logoutCurrentSession(): Promise<void> {
|
export async function logoutCurrentSession(): Promise<void> {
|
||||||
const response = await apiRequest(`${API_BASE}/auth/logout`, {
|
const response = await apiRequest(`${API_BASE}/auth/logout`, {
|
||||||
|
|||||||
@@ -76,11 +76,12 @@ export interface AuthSessionInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents login response payload with issued bearer token and session metadata.
|
* Represents login response payload with issued session metadata.
|
||||||
*/
|
*/
|
||||||
export interface AuthLoginResponse extends AuthSessionInfo {
|
export interface AuthLoginResponse extends AuthSessionInfo {
|
||||||
access_token: string;
|
access_token?: string;
|
||||||
token_type: 'bearer';
|
token_type: 'bearer';
|
||||||
|
csrf_token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user