Skip to content

Multi-Factor Authentication (MFA)

Status: ✅ Shipped — TOTP-based MFA (pyotp + QR provisioning) with backup codes is live for admin users: setup/verify/check/disable/status/backup-code endpoints, an MFAAttempt audit trail, and per-user MFASecret storage. There is no dedicated MFA doc page prior to this one — coverage was previously scattered across DECISIONS.md, FINDINGS.md, a few DATA_MODELS pages, and admin-ui.md.

MFA adds a second factor (TOTP code from an authenticator app, or a one-time backup code) on top of normal password auth. Endpoints are scoped by the same RBAC chain as the rest of admin — see ./rbac-admin-scopes.md — not a separate MFA-specific permission.

Schema

  • MFASecret (one row per user, unique FK to User.id): secret (TOTP shared secret), is_verified, backup_codes (list, hashed/stored), created_at, verified_at, last_used.
  • MFAAttempt: user_id, code_entered, success, ip_address, user_agent, attempt_time, failure_reason — every check attempt is logged, success or failure.

Source: services/gameserver/src/models/mfa.py.

Endpoints

All under /auth/mfa (mounted via api/api.py). Tip mfa.py gates every listed route with get_current_user (authenticated bearer) — not require_scope(PLAYERS_VIEW). Route contract also lives in ../ARCHITECTURE/auth.md § Multi-factor authentication (TOTP).

Route Method Purpose
/auth/mfa/generate POST Generate a new TOTP secret + QR provisioning URL for the current user. Does not enable MFA yet.
/auth/mfa/verify POST Confirm setup with a TOTP code; on success, MFA becomes enabled and backup codes are returned once.
/auth/mfa/check POST Validate a TOTP or backup code (login-flow use — only get_current_user, not scope-gated). Logs an MFAAttempt every call.
/auth/mfa/status GET Enabled/verified flags, remaining backup-code count, last-used timestamp.
/auth/mfa/disable POST Disable MFA for the current user.
/auth/mfa/backup-codes GET List remaining backup codes (404 if MFA not enabled).
/auth/mfa/regenerate-backup-codes POST Invalidate all existing backup codes and issue a fresh set (404 if MFA not enabled).
/auth/mfa/attempts GET Recent MFAAttempt rows for the current user (hours query param, default 24) — security-monitoring surface.

Source: services/gameserver/src/api/routes/mfa.py.

Service behavior

MFAService (services/gameserver/src/services/mfa_service.py):

  • generate_secret — creates a pyotp TOTP secret, builds a provisioning_uri (issuer "Sectorwars2102 Admin") and a QR code data URL (qrcode library). Does not persist as verified.
  • verify_setup — validates the submitted code against the pending secret (valid_window=1, ±1 time-step tolerance). On success: marks the secret verified, generates 10 backup codes, returns them once in the response (not retrievable again except via regenerate).
  • verify_code — the shared check used by both /check and any internal caller: tries a backup code first, falls back to TOTP; every attempt (success or failure) is written to MFAAttempt with IP/user-agent.
  • is_mfa_enabled, disable_mfa, get_backup_codes, regenerate_backup_codes, get_recent_attempts — status/management surface backing the endpoints above 1:1.
  • _generate_backup_codes — 10 codes, 8 alphanumeric characters each.
  • _is_backup_code / _use_backup_code — backup codes are single-use; a consumed code is removed from the stored set.

What this is not

Admin-ui remains the setup/verify UI (MFASetup.tsx / MFAVerification.tsx). /auth/mfa/* management is any authenticated user (get_current_user), not admin-scope-only. Player password login is MFA-gated on the gameserver (auth.py _mfa_gate_and_mint_tokens / requires_mfa) and player-client AuthContext — that login gate is documented in ../ARCHITECTURE/auth.md; this page previously claimed player login was unwired.

Source map

Concern Path
Models (MFASecret, MFAAttempt) services/gameserver/src/models/mfa.py
Service services/gameserver/src/services/mfa_service.py
Routes services/gameserver/src/api/routes/mfa.py
Router mount services/gameserver/src/api/api.py