Updates
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user