This commit is contained in:
2026-05-10 09:46:07 +12:00
parent cfc193b713
commit 2f2466ecac
81 changed files with 2571 additions and 413 deletions
+30 -6
View File
@@ -7,7 +7,7 @@ the current user has, then use those keys to hide/show navigation items.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
@@ -21,12 +21,19 @@ from app.core.access import (
require_permission,
)
from app.core.config import settings
from app.core.http import CLIENT_AUTH_COOKIE
from app.core.rate_limit import SlidingWindowRateLimiter, request_client_key
from app.core.security_logging import log_security_event
from app.core.security import hash_password, issue_token, verify_password
from app.db.session import get_db
from app.models.access import Permission, Role, User
router = APIRouter(prefix="/api/access", tags=["access"])
login_rate_limiter = SlidingWindowRateLimiter(
limit=settings.login_rate_limit_attempts,
window_seconds=settings.login_rate_limit_window_seconds,
)
class LoginRequest(BaseModel):
@@ -75,7 +82,10 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio
role_name = user.role.name if user.role else None
token = None
if include_token:
token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email})
token = issue_token(
{"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email},
ttl_seconds=settings.session_ttl_seconds,
)
# role="internal" is a marker the shared auth deps recognise so internal
# users can hit the same routes as client-portal users without being
# confused with them. Display name lives in role_name / client_role.
@@ -96,14 +106,16 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio
@router.post("/login", response_model=UserSession)
def login(payload: LoginRequest, db: Session = Depends(get_db)):
def login(payload: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)):
"""Internal-user login.
Authenticates against a shared internal password (``ADMIN_PASSWORD``) and
looks up the user by email. Inactive or unknown users are rejected with
a generic 401 to avoid leaking which emails are valid.
"""
login_rate_limiter.hit(request_client_key(request, suffix="internal-login"))
if payload.password != settings.admin_password:
log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
email = payload.email.strip().lower()
@@ -113,15 +125,20 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)):
.options(selectinload(User.role).selectinload(Role.permissions))
)
if user is None or not user.is_active:
log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
return _serialize_session(user, include_token=True)
session = _serialize_session(user, include_token=True)
if session.token:
CLIENT_AUTH_COOKIE.apply(response, session.token)
log_security_event("auth.login_succeeded", audience="internal", role=user.role.name if user.role else None, user_id=user.id)
return session.model_copy(update={"token": None})
@router.get("/me", response_model=UserSession)
def read_me(user: User = Depends(get_current_user)):
"""Return the current user with permission keys for UI navigation gating."""
return _serialize_session(user)
return _serialize_session(user).model_copy(update={"token": None})
@router.get("/me/permissions", response_model=list[str])
@@ -181,7 +198,14 @@ def update_me(
db.commit()
db.refresh(user)
return _serialize_session(user, include_token=True)
return _serialize_session(user, include_token=True).model_copy(update={"token": None})
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(response: Response):
CLIENT_AUTH_COOKIE.clear(response)
response.status_code = status.HTTP_204_NO_CONTENT
return None
# Permission-enforced administrative endpoints. Route bodies should not check