- Remove POST /api/auth/register (404); no sign-up UI; users are admin-managed - Add admin-only POST/PATCH/DELETE /api/auth/users (forced canonical roles, self-lockout + reference guards) - Unify role model in app/core/roles.py; reject unknown roles at creation and at login/JWT validation; startup normalizes unambiguous legacy aliases - Login rate limiting ~5 fails/15 min per IP+email -> 429 (in-process, tunable) - WhatsApp webhook requires X-Webhook-Secret; fail-closed when env unset; GET handshake uses constant-time verify token (403 on mismatch) - GET /api/whatsapp/mock-log now requires auth - Security headers middleware: X-Frame-Options DENY, nosniff, CSP on HTML, HSTS behind TLS - Pagination: limit alias for page_size, hard cap enforced, both -> 422
125 lines
4.7 KiB
Python
125 lines
4.7 KiB
Python
"""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)
|