Files
CIAgent/apps/api/app/api/v1/auth.py
T
sakshamandClaude Sonnet 5 4ee38b6241 Add DB viewer access logging, account deletion, and forced password change
Logs a distinct db_viewer_accessed event (not just the earlier
session_created "requested" event) when an admin's browser actually
completes the hand-off into Adminer. Adds a password-confirmed
account-deletion box to Settings, relying on the existing ON DELETE
CASCADE foreign keys to clean up everything the account owns. Adds an
admin-only "require password change" flag that get_current_user
enforces server-side (403 on everything except /auth/me,
/auth/change-password, /auth/logout) - meant for handing a demo
account to someone with a known sample password.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-05 23:41:21 -04:00

192 lines
7.5 KiB
Python

"""Auth routes. Thin per ARCHITECTURE.md: parse/validate, call one service
method, map the result. All rules live in app.services.auth_service."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_user
from app.core.config import Settings, get_settings
from app.core.errors import ValidationAppError
from app.core.rate_limit import limiter
from app.core.security import get_client_ip, is_localhost
from app.db.session import get_db
from app.models.user import LOCAL_DEV_USER_ID, User
from app.schemas.auth import (
ChangePasswordRequest,
ConfirmPasswordResetRequest,
DeleteAccountRequest,
LoginRequest,
LogoutRequest,
RefreshRequest,
RegisterRequest,
RequestPasswordResetRequest,
ResendVerificationRequest,
SecurityEventResponse,
TokenResponse,
VerifyEmailRequest,
)
from app.schemas.user import MeResponse, UserResponse
from app.services import auth_service, system_secret_service
from app.services.turnstile_service import turnstile_required, verify_turnstile
router = APIRouter(prefix="/auth", tags=["auth"])
async def _enforce_turnstile(
request: Request, settings: Settings, effective: Settings, token: str | None, client_ip: str
) -> None:
"""Required for register/login/request-password-reset when the caller
isn't on loopback and a secret is configured - skipped entirely
otherwise (see turnstile_service.turnstile_required). The secret may be
admin-configured (system_secret_service) rather than only .env-set."""
if not turnstile_required(is_localhost(request, settings), effective):
return
if not token or not await verify_turnstile(token, client_ip, effective):
raise ValidationAppError("Captcha verification required.")
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("5/minute")
async def register(
request: Request,
payload: RegisterRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
) -> UserResponse:
client_ip = get_client_ip(request, settings)
# Effective (DB-override-aware) settings, computed once and reused for
# both Turnstile enforcement and the verification email - the Resend
# API key, like the Turnstile secret, may be admin-configured via
# system_secret_service rather than only .env-set.
effective = await system_secret_service.get_effective_settings(db, settings)
await _enforce_turnstile(request, settings, effective, payload.turnstile_token, client_ip)
user = await auth_service.register(db, effective, client_ip, payload)
return UserResponse.model_validate(user)
@router.post("/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def login(
request: Request,
payload: LoginRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
client_ip = get_client_ip(request, settings)
effective = await system_secret_service.get_effective_settings(db, settings)
await _enforce_turnstile(request, settings, effective, payload.turnstile_token, client_ip)
return await auth_service.login(db, effective, client_ip, payload)
@router.post("/refresh", response_model=TokenResponse)
@limiter.limit("20/minute")
async def refresh_token(
request: Request,
payload: RefreshRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
return await auth_service.refresh(db, settings, payload.refresh_token)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
payload: LogoutRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
) -> None:
await auth_service.logout(db, settings, payload.refresh_token)
@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT)
async def verify_email(
request: Request,
payload: VerifyEmailRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
) -> None:
client_ip = get_client_ip(request, settings)
await auth_service.verify_email(db, client_ip, payload)
@router.post("/resend-verification", status_code=status.HTTP_204_NO_CONTENT)
async def resend_verification(
request: Request,
payload: ResendVerificationRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
) -> None:
client_ip = get_client_ip(request, settings)
effective = await system_secret_service.get_effective_settings(db, settings)
await auth_service.resend_verification(db, effective, client_ip, payload)
@router.post("/request-password-reset", status_code=status.HTTP_204_NO_CONTENT)
async def request_password_reset(
request: Request,
payload: RequestPasswordResetRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
) -> None:
client_ip = get_client_ip(request, settings)
effective = await system_secret_service.get_effective_settings(db, settings)
await _enforce_turnstile(request, settings, effective, payload.turnstile_token, client_ip)
await auth_service.request_password_reset(db, effective, client_ip, payload)
@router.post("/confirm-password-reset", status_code=status.HTTP_204_NO_CONTENT)
async def confirm_password_reset(
request: Request,
payload: ConfirmPasswordResetRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
) -> None:
client_ip = get_client_ip(request, settings)
await auth_service.confirm_password_reset(db, client_ip, payload)
@router.get("/security-events", response_model=list[SecurityEventResponse])
async def security_events(
db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)
) -> list[SecurityEventResponse]:
"""The calling user's own security activity - not admin-gated, it's
their own data (see the app-wide admin log feed in app.api.v1.system for
the operational counterpart)."""
events = await auth_service.list_security_events(db, user.id)
return [SecurityEventResponse.model_validate(e) for e in events]
@router.get("/me", response_model=MeResponse)
async def me(user: User = Depends(get_current_user)) -> MeResponse:
# Reports what actually happened for *this* request, not the raw
# AUTH_MODE setting - the fixed local-dev user only ever comes back via
# the loopback bypass (see get_current_user), so its id is a reliable
# per-request signal even though the setting itself is almost always
# "local".
effective_auth_mode = "local" if user.id == LOCAL_DEV_USER_ID else "jwt"
base = UserResponse.model_validate(user).model_dump()
return MeResponse(**base, auth_mode=effective_auth_mode)
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
async def delete_account(
payload: DeleteAccountRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> None:
await auth_service.delete_account(db, user, payload.password)
@router.post("/change-password", response_model=TokenResponse)
async def change_password(
request: Request,
payload: ChangePasswordRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> TokenResponse:
client_ip = get_client_ip(request, settings)
return await auth_service.change_password(db, settings, client_ip, user, payload)