Implements the account and user-management system behind the new web UI against development repositories only. Nothing here connects to, reads, or modifies the production umbracontrol database, and no account is created in any approved environment. Account service and API: - Local temporary-password and OIDC enrollment, separated by type. - Argon2id credentials, must-change enforcement, server-side sessions with hashed tokens, idle and absolute deadlines, rotation and revocation. - Administrator account management: list, get, create, suspend, reactivate, deactivate, revoke sessions, org-scoped audit view. - Hidden-prompt operator command for the first administrator, refusing production mode and refusing to run without a TTY. Temporary passwords are never accepted from a client. The request schema has no password field and refuses unknown fields; the administrator path generates the one-time password in process, hashes it immediately, and returns it once in the creation response. The account can reach no route, including /api/auth/me, until it replaces that password, after which the one-time value no longer authenticates. Security posture: fail-closed configuration, Secure cookies in production, double-submit CSRF, constant-time token comparison, generic authentication errors, audit detail allow-listing, and logger-tree secret redaction. Reviewed but unapplied: migrations/0001_phase1_identity.sql adds users.username, a separate user_credentials table, and additive session columns, with a rollback and open review questions. The PostgreSQL adapter is deliberately absent, so the application refuses to start in production rather than run against an unreviewed store. Verification: 169 tests pass (148 new, 21 pre-existing); ruff check, ruff format, compileall, and make web-check are clean.
153 lines
5.1 KiB
Python
153 lines
5.1 KiB
Python
"""Authentication routes: ``/api/auth/*``.
|
|
|
|
Handlers are synchronous so Argon2 verification runs in FastAPI's threadpool.
|
|
The session cookie is written only here; the raw token is never logged or
|
|
returned in a body.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends, Request, Response
|
|
|
|
from .app import (
|
|
clear_csrf_cookie,
|
|
clear_session_cookie,
|
|
ensure_csrf_token,
|
|
get_principal_allowing_password_change,
|
|
get_service,
|
|
get_settings,
|
|
read_csrf_token,
|
|
request_id,
|
|
require_principal,
|
|
set_session_cookie,
|
|
)
|
|
from .models import Principal
|
|
from .schemas import (
|
|
ChangeFirstPasswordRequest,
|
|
LoginRequest,
|
|
LoginResponse,
|
|
LogoutResponse,
|
|
SessionUserResponse,
|
|
UserResponse,
|
|
)
|
|
from .service import AccountService
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _profile(service: AccountService, user_id: object) -> UserResponse:
|
|
"""Return the sanitized profile for one account."""
|
|
|
|
return UserResponse.from_view(service.self_view(user_id)) # type: ignore[arg-type]
|
|
|
|
|
|
def _csrf_for(request: Request, response: Response) -> str:
|
|
"""Reuse the caller's CSRF token, or issue one if the request had none."""
|
|
|
|
token = read_csrf_token(request)
|
|
if token:
|
|
return token
|
|
return ensure_csrf_token(response, settings=get_settings(request))
|
|
|
|
|
|
@router.post("/login", response_model=LoginResponse)
|
|
def login(payload: LoginRequest, request: Request, response: Response) -> LoginResponse:
|
|
"""Authenticate a local password and open a server-side session.
|
|
|
|
Failures are reported with a single generic error, so the response cannot
|
|
be used to discover whether an account exists.
|
|
"""
|
|
|
|
settings = get_settings(request)
|
|
service: AccountService = get_service(request)
|
|
result = service.authenticate(
|
|
username=payload.username,
|
|
password=payload.password,
|
|
request_id=request_id(request),
|
|
)
|
|
set_session_cookie(response, token=result.session.token, settings=settings)
|
|
return LoginResponse(
|
|
user=_profile(service, result.user.id),
|
|
must_change_password=result.session.must_change_password,
|
|
csrf_token=_csrf_for(request, response),
|
|
idle_expires_at=result.session.idle_expires_at,
|
|
absolute_expires_at=result.session.absolute_expires_at,
|
|
)
|
|
|
|
|
|
@router.post("/change-first-password", response_model=LoginResponse)
|
|
def change_first_password(
|
|
payload: ChangeFirstPasswordRequest,
|
|
request: Request,
|
|
response: Response,
|
|
principal: Principal = Depends(get_principal_allowing_password_change),
|
|
) -> LoginResponse:
|
|
"""Complete the mandatory first-login change and rotate the session."""
|
|
|
|
settings = get_settings(request)
|
|
service: AccountService = get_service(request)
|
|
result = service.change_first_password(
|
|
principal,
|
|
current_password=payload.current_password,
|
|
new_password=payload.new_password,
|
|
request_id=request_id(request),
|
|
)
|
|
set_session_cookie(response, token=result.session.token, settings=settings)
|
|
return LoginResponse(
|
|
user=_profile(service, result.user.id),
|
|
must_change_password=result.session.must_change_password,
|
|
csrf_token=_csrf_for(request, response),
|
|
idle_expires_at=result.session.idle_expires_at,
|
|
absolute_expires_at=result.session.absolute_expires_at,
|
|
)
|
|
|
|
|
|
@router.post("/logout", response_model=LogoutResponse)
|
|
def logout(
|
|
request: Request,
|
|
response: Response,
|
|
principal: Principal = Depends(get_principal_allowing_password_change),
|
|
) -> LogoutResponse:
|
|
"""Revoke the caller's current session and clear the cookie."""
|
|
|
|
settings = get_settings(request)
|
|
service: AccountService = get_service(request)
|
|
revoked = service.logout(principal, request_id=request_id(request))
|
|
clear_session_cookie(response, settings=settings)
|
|
clear_csrf_cookie(response, settings=settings)
|
|
return LogoutResponse(revoked_sessions=revoked)
|
|
|
|
|
|
@router.post("/logout-everywhere", response_model=LogoutResponse)
|
|
def logout_everywhere(
|
|
request: Request,
|
|
response: Response,
|
|
principal: Principal = Depends(get_principal_allowing_password_change),
|
|
) -> LogoutResponse:
|
|
"""Revoke every session for the caller, including the current one."""
|
|
|
|
settings = get_settings(request)
|
|
service: AccountService = get_service(request)
|
|
revoked = service.logout_everywhere(principal, request_id=request_id(request))
|
|
clear_session_cookie(response, settings=settings)
|
|
clear_csrf_cookie(response, settings=settings)
|
|
return LogoutResponse(revoked_sessions=revoked)
|
|
|
|
|
|
@router.get("/me", response_model=SessionUserResponse)
|
|
def me(
|
|
request: Request,
|
|
response: Response,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> SessionUserResponse:
|
|
"""Return the caller's own identity and the CSRF token to echo back."""
|
|
|
|
service: AccountService = get_service(request)
|
|
return SessionUserResponse(
|
|
user=_profile(service, principal.user_id),
|
|
role=principal.role,
|
|
organization_id=principal.organization_id,
|
|
must_change_password=principal.must_change_password,
|
|
csrf_token=_csrf_for(request, response),
|
|
)
|