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]>
192 lines
7.5 KiB
Python
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)
|