"""Authentication router — login, refresh, me, and admin user management. Self-registration was removed (P0 hardening): users are created/managed by admins only via ``POST/PATCH/DELETE /api/auth/users``. """ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.ratelimit import login_rate_limiter from app.core.roles import ADMIN_ROLES from app.core.security import get_current_user, require_roles from app.models.user import User from app.schemas.auth import ( AdminCreateUserRequest, AdminUpdateUserRequest, LoginRequest, RefreshRequest, TokenResponse, UserOut, ) from app.services import auth as auth_service router = APIRouter(prefix="/api/auth", tags=["auth"]) _require_admin = require_roles(*ADMIN_ROLES) # ── Public authN ───────────────────────────────────────────────────── @router.post("/login", response_model=TokenResponse) async def login( request: Request, body: LoginRequest, db: Annotated[AsyncSession, Depends(get_db)], ) -> TokenResponse: """Log in. Brute-force limited to ~5 failures / 15 min per IP+email (429).""" client_ip = request.client.host if request.client else "unknown" key = login_rate_limiter.key(client_ip, body.email) login_rate_limiter.check_or_raise(key) try: access, refresh, _user = await auth_service.login(db, body.email, body.password) except HTTPException as exc: # Count only real auth failures toward the limit; success resets it. if exc.status_code == status.HTTP_401_UNAUTHORIZED: login_rate_limiter.record_failure(key) raise login_rate_limiter.clear(key) # successful login resets the failure window return TokenResponse(access_token=access, refresh_token=refresh) @router.post("/refresh", response_model=TokenResponse) async def refresh( body: RefreshRequest, db: Annotated[AsyncSession, Depends(get_db)], ) -> TokenResponse: access, refresh = await auth_service.refresh_access_token(db, body.refresh_token) return TokenResponse(access_token=access, refresh_token=refresh) @router.get("/me", response_model=UserOut) async def me(current_user: Annotated[User, Depends(get_current_user)]) -> User: return current_user @router.get("/admin-only", response_model=UserOut) async def admin_only( current_user: Annotated[User, Depends(_require_admin)], ) -> User: """Example RBAC-protected endpoint — only canonical Admins can access.""" return current_user # ── User directory ─────────────────────────────────────────────────── @router.get("/users", response_model=list[UserOut]) async def list_users( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], ) -> list[User]: """List users (id, name, role) for authenticated assignment pickers.""" result = await db.execute(select(User).order_by(User.full_name)) return list(result.scalars().all()) # ── Admin user management ──────────────────────────────────────────── @router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED) async def create_user( body: AdminCreateUserRequest, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(_require_admin)], ) -> User: """Admin-only: create a user with a forced canonical role. The client cannot self-register or pick an arbitrary role — unknown roles (e.g. ``admin``, ``superadmin``) are rejected with 422. """ return await auth_service.create_user(db, body) @router.patch("/users/{user_id}", response_model=UserOut) async def update_user( user_id: int, body: AdminUpdateUserRequest, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(_require_admin)], ) -> User: """Admin-only: change a user's role and/or deactivate the account.""" return await auth_service.update_user(db, current_user, user_id, body) @router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_user( user_id: int, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(_require_admin)], ) -> None: """Admin-only: delete a user account (guarded; see auth_service).""" await auth_service.delete_user(db, current_user, user_id)