Files
denya-onecare/app/routers/auth.py
T
root 3ae1062d65 P0 security batch: admin-only user mgmt, unified role model, login rate limiting, webhook secret, security headers, pagination caps
- 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
2026-09-08 10:36:16 +00:00

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)