Multi-factor Authentication & Advanced Security
At a glance
Multi-factor Authentication & Advanced Security adds TOTP, SMS-OTP via 46elks, email-OTP fallback, WebAuthn passkeys, recovery codes and trusted-device skip on top of the authentication core, with per-tenant enforcement policy, exponential brute-force backoff, complete session management and security email notifications on every meaningful event. SecurityEvent provides an append-only audit trail across the entire surface, surfaced to the user via GET /auth/security-events.
How it works
MFA is decoupled from authentication: the user proves identity once via F02.02 or F02.06, and step-up factors are demanded by policy at sensitive endpoints. GET /auth/mfa/methods reports which factors a user has enrolled (TOTP, WebAuthn credential count, SMS-bound phone, email). TOTP integrates with Google Authenticator, 1Password, Authy and any RFC 6238 client.
SMS-OTP uses 46elks: POST /auth/mfa/sms/challenge issues a code stored as SHA-256, the user POSTs to /auth/mfa/sms/verify, and a 5-attempt cap with PII-masked phone numbers prevents enumeration. Email-OTP is the break-glass fallback for lost SIMs/authenticators. WebAuthn passkeys are surfaced via the same enrollment-detection endpoint and are preferred for high-assurance roles.
Enforcement is per-tenant, per-role: PUT /auth/mfa/policy lets a tenant admin require MFA for selected roles, choose allowed methods, set a grace_period_days for new users to enrol, and define fresh_window_seconds for step-up. GET /auth/mfa/enforcement tells a client whether the current session needs to enrol now, is in grace period, or is fully compliant. Recovery codes (10 single-use codes per user) are generated via POST /auth/mfa/recovery-codes/generate, stored hashed, verified via POST /auth/mfa/recovery-codes/verify and immediately upgrade the token to MFA-satisfied; regenerating invalidates the previous batch.
Trusted devices skip MFA for 30 days: POST /auth/mfa/trusted-devices stores a SHA-256-hashed fingerprint after a successful MFA, GET .../check confirms a fingerprint at login, and the user can list and DELETE individual devices from settings. Sessions are tracked as RefreshTokenFamily entries: GET /auth/sessions lists active sessions with device/location metadata, DELETE /auth/sessions/{id} revokes one, POST /auth/sessions/revoke-all keeps only the current device. Brute-force protection on the MFA verify endpoints uses a BruteForceRecord with exponential backoff (1→2→4→8→16→30 s, capped) per consecutive failure, reset on success.
Every meaningful event — new_login, mfa_enrolled, mfa_disabled, recovery_code_used, trusted_device_added/removed, session_revoked — produces a SecurityEvent and dispatches an email through SecurityNotificationService, giving users real-time visibility and an auditable history at GET /auth/security-events.
Key capabilities
- TOTP, SMS-OTP (46elks), email-OTP and WebAuthn passkeys with enrolment detection
- Per-tenant MFA policy: required roles, allowed methods, grace period, fresh window
- 10 recovery codes per user, hashed, single-use, regenerable
- Trusted-device skip for 30 days with SHA-256-hashed fingerprints
- Session management: list, revoke single, revoke all but current
- Exponential brute-force backoff on MFA verification
- SecurityEvent append-only audit with email notifications via SecurityNotificationService
In practice
A national federation enables MFA-required for all Club Treasurers with a 14-day grace period. A treasurer logs in the next morning, sees a banner showing 12 days remaining, and enrols TOTP using 1Password. He generates 10 recovery codes and stores them in his password manager.
A week later he opens the financial export endpoint; the fresh-auth window has expired, so he is prompted for a TOTP code. On a business trip he loses his phone; from his laptop he uses a recovery code to sign in, the system burns the code, sends a security email, and creates a SecurityEvent. He then opens GET /auth/sessions, revokes the now-stolen device, and adds his replacement phone as a trusted device after re-enrolling TOTP — all without contacting support.
Features in this subsystem
34| ID | Status | Features |
|---|---|---|
| F02.07.01 | Shipped | TOTP authenticator app (Google Authenticator, 1Password, Authy) — enrollment detection via GET /auth/mfa/methods ✅ PL-F0207a |
| F02.07.02 | Shipped | SMS-OTP via 46elks — POST /auth/mfa/sms/challenge, POST /auth/mfa/sms/verify, SHA-256-hashad kodlagring, PII-maskering av telefonnummer, single-use med 5-försökstak ✅ PL-F0207a |
| F02.07.03 | Shipped | Email-OTP fallback — POST /auth/mfa/email/challenge, POST /auth/mfa/email/verify, break-glass recovery för tappad SIM/authenticator ✅ PL-F0207a |
| F02.07.04 | Shipped | WebAuthn / Passkeys — enrollment detection via GET /auth/mfa/methods, WebAuthnCredential-count ✅ PL-F0207a |
| F02.07.05 | Shipped | MFA enforcement per roll — GET /auth/mfa/enforcement med policy-besked (required/enrolled/grace_period), GET /auth/mfa/policy, PUT /auth/mfa/policy admin-upsert, per-tenant konfigurerbara required_roles/allowed_methods/grace_period_days/fresh_window_seconds ✅ PL-F0207a |
| F02.07.06 | Shipped | Recovery codes — 10 engångskoder per användare, POST /auth/mfa/recovery-codes/generate, GET /auth/mfa/recovery-codes/count, POST /auth/mfa/recovery-codes/verify med token-upgrade, SHA-256-hashad lagring, regenerering invaliderar gamla koder ✅ PL-F0207b |
| F02.07.07 | Shipped | Trusted devices med 30-dagars skip — POST /auth/mfa/trusted-devices registrering efter MFA, GET /auth/mfa/trusted-devices/check fingerprint-kontroll, GET /auth/mfa/trusted-devices lista, DELETE /auth/mfa/trusted-devices/{id} revokering, SHA-256-hashade fingerprints ✅ PL-F0207b |
| F02.07.08 | Shipped | Session management — GET /auth/sessions lista aktiva sessioner via RefreshTokenFamily, DELETE /auth/sessions/{id} revokera enskild session, POST /auth/sessions/revoke-all revokera alla utom aktuell ✅ PL-F0207b |
| F02.07.09 | Shipped | Brute-force-skydd med exponentiell backoff — BruteForceRecord-modell, 1s→2s→4s→8s→16s→30s (cap) delay per konsekutivt misslyckande, automatisk reset vid lyckad inloggning, applicerat på recovery-code-verify ✅ PL-F0207b |
| F02.07.10 | Shipped | Säkerhetsmail vid nya inloggningar och MFA-ändringar — SecurityEvent-modell med append-only audit, SecurityNotificationService dispatchar email vid new_login, mfa_enrolled, mfa_disabled, recovery_code_used, trusted_device_added/removed, session_revoked, GET /auth/security-events lista ✅ PL-F0207b |
| F02.08.01 | Shipped | individual_context — IndividualContext Beanie-dokument med user_id (unikt), display_name, country, language, birth_year, preferred_position (pointer/shooter/middle), handicap_hint, privacy_public_profile, subscription_tier (FREE/PREMIUM), premium_valid_until, Stripe-fält; 30-sekunders signup via POST /public/signup/individual; profil via GET/PATCH /me/individual-context ✅ PL-T005 |
| F02.08.02 | Shipped | casual_match_p2p — IndividualCasualMatch venue-lös match mellan två individual-kontext; POST /me/casual-matches med FREE-tier-gating (50/månad), POST /me/casual-matches/{id}/verify motpartsverifiering, GET /me/casual-matches historik ✅ PL-T005 |
| F02.08.03 | Shipped | friend_graph — IndividualFriend med status PENDING/ACCEPTED/BLOCKED; POST /me/friends/request, POST /me/friends/{id}/accept, POST /me/friends/{id}/block, GET /me/friends filtrering ✅ PL-T005 |
| F02.08.04 | Shipped | subscription_tier_gate — individual_tier.py service med is_premium_active() (inkl. grace-period), check_casual_match_limit() (50/månad FREE), require_premium(), 402 Payment Required med error_code="premium_required"; upgrade-to-premium dev-stub, downgrade med bevarad premium_valid_until ✅ PL-T005 |
| F02.09.01 | Shipped | chain_tenant — TenantType.CHAIN med ChainMembership (chain↔venue-länk), super-admin-onboarding via POST /admin/tenants/chain, venue-länkning via POST /chain/{id}/add-venue + POST /venue/{id}/accept-chain/{id}, bryt-länk via DELETE /chain/{id}/venues/{id} (soft-deactivate) ✅ PL-T016 |
| F02.09.02 | Shipped | cross_venue_league — ChainLeague + ChainLeagueEntry; POST /chain/{id}/leagues med venue-multiselect, GET /chain/{id}/leagues/{id}/leaderboard aggregerat cross-venue-rankning, season_start/end-validering ✅ PL-T016 |
| F02.09.03 | Shipped | chain_passport — ChainPassport loyalty-tracker med BRONZE (5+)/SILVER (20+)/GOLD (50+) tier-progression baserat på rolling visits; GET /chain/{id}/passport/{user_id}, POST /chain/{id}/passport/{user_id}/visit QR-check-in ✅ PL-T016 |
| F02.09.04 | Shipped | corporate_event_module — CorporateEvent med INQUIRY→QUOTED→BOOKED→COMPLETED→CANCELLED pipeline; POST/GET/PATCH /chain/{id}/corporate-events, host_venue_tenant_id validerad mot aktiv ChainMembership ✅ PL-T016 |
| F02.09.05 | Shipped | white_label_app — ChainWhiteLabelConfig med app_name, colors, logos, custom_domain, hide_petanque_life_branding; PUT/GET /chain/{id}/white-label, ChainBrandingProvider i app för dynamisk re-branding ✅ PL-T016 |
| F02.09.06 | Shipped | multi_currency_chain_invoice — ChainInvoiceBatch med per-country ChainInvoiceLineItem (local_currency, ecb_rate_used, ecb_rate_date); kvartalsvis generering via tools/generate_chain_invoice_batch.py med ECB-fixing ✅ PL-T016 |
| F02.09.07 | Shipped | chain_passport_v2 — ChainPassport19 med points-baserad tier-progression (REGULAR/GOLD/PLATINUM), passport_id format {CHAIN_CODE}-PASS-NNNNNN, unique index (tenant_id, auth_identity_id), ChainVenueVisit per-visit records; POST /chain/passports (create, 409 duplicate), GET /chain/passports/me, GET /chain/passports/{id}, PUT /chain/passports/{id} (profile + tier override + ban) ✅ PL-T019 |
| F02.09.08 | Shipped | chain_checkin_points — POST /chain/passports/{id}/check-in (QR-scan, creates visit, increments total_games, 403 if banned), POST /chain/passports/{id}/award-points (manual points by venue staff, creates visit with points_earned, updates total_points) ✅ PL-T019 |
| F02.09.09 | Shipped | chain_leaderboard — GET /chain/leaderboard med ?venue_id= (venue-filtrerat via aggregation), ?period=month year|alltime (tidsfiltrerat), top-50 paginering; alltime query direkt på total_points, period-baserat via MongoDB aggregation pipeline på ChainVenueVisit | ✅ PL-T019 |
| F02.09.10 | Shipped | chain_visit_history — GET /chain/passports/{id}/visits paginerad venue-besökshistorik sorterad senast-först; composite index (chain_passport_id, visited_at DESC) för performance ✅ PL-T019 |
| F02.09.11 | Shipped | chain_passport_admin_ui — PassportSearch admin-vy med passport-ID-sökning, detail panel (namn, tier badge, status badge, stats, visit timeline), tier override (confirmation dialog), ban/unban actions; LeaderboardView med period tabs (All Time/This Year/This Month), venue filter dropdown, auto-refresh 60s, rank badges (#1 gold, #2 silver, #3 bronze) ✅ PL-T019 |
| F02.10.01 | Shipped | tenant_tier_matrix — TENANT_TIER_MATRIX i services/tenant_tier.py mappar varje TenantType till included_domains + excluded_subsystems + all_access-flagga; FEDERATION/CONTINENTAL/FIPJP/SANDBOX = all_access, CLUB/AFFILIATE/VENUE/CHAIN/EVENT/INDIVIDUAL = matrix-baserat; byte-equivalent med www/src/data/tenant-tiers.ts (verifieras av services/tenant_tier_matrix_sync.py + test_pl225_tier_matrix_sync.py) ✅ PL-T225 |
| F02.10.02 | Shipped | requires_subsystem — FastAPI-dependency i security/tenant_tier.py som gatear endpoints på subsystem-nivå (F<NN>.<MM>-format); andra gating-lagret efter capability-RBAC; 401 unauthenticated_tenant_context när tenant saknas, 403 tenant_type_not_eligible med subsystem + tenant_type i body när matrix/addon ej täcker ✅ PL-T225 |
| F02.10.03 | Shipped | tenant_tier_resolver — TenantTierResolver med Redis 5-min cache (tenant_tier:{tenant_id}-nycklar), graceful fallback till per-request Mongo-lookup när Redis saknas; is_subsystem_allowed() kollar all_access → matrix → aktiva addons; invalidate_cache(tenant_id) anropas av alla mutationer (grant/revoke/change_type) ✅ PL-T225 |
| F02.10.04 | Shipped | tenant_tier_addon — TenantTierAddon Beanie-modell med unique index (tenant_id, subsystem_id), optional expires_at (null = permanent), billing_reference, required reason (3–500 chars), granted_at/granted_by-audit; addons låser upp enskilda subsystems utan att flippa tenant_type ✅ PL-T225 |
| F02.10.05 | Shipped | sys_tenant_tier_admin — routes/sys_tenant_tier.py med GET /sys/tenants/{id}/tier (read, support/finance/security), POST /sys/tenants/{id}/tier/addons (grant, finance/security), DELETE /sys/tenants/{id}/tier/addons/{addon_id} (revoke, finance/security), PATCH /sys/tenants/{id}/tier/type (security only, fresh-auth-required); alla emitterar tenant.tier.*-platform events + AuditLog-rad ✅ PL-T225 |
| F02.10.06 | Shipped | me_tenant_tier — GET /me/tenant-tier returnerar tenant-snapshot (tenant_type, all_access, allowed_subsystems, addons-trim, included_domains, excluded_subsystems) för app/admin client-side hide; ["*"] när all_access så frontend slipper enumerera hela katalogen; useAllowedSubsystems()-hook i admin cachar 5 min med tenant-switch-bump ✅ PL-T225 |
| F02.10.07 | Shipped | tier_aware_admin_ui — useAllowedSubsystems() filtrerar visibleSectionsFor() (sektioner med subsystem-mapping döljs i sidebar/CommandPalette när delsystemet ej är allowed); TenantTierUpgradeModal öppnas från modul-event-bus när någon endpoint returnerar 403 tenant_type_not_eligible; modal visar subsystem-id + tenant_type + kontaktuppgifter ✅ PL-T225 |
| F02.10.08 | Shipped | sys_tenant_tier_view — sys/app/(dashboard)/tenants/[id]/tier.tsx är operator-arbetsytan: tier-summary-card (tenant_type-chip, included domains, excluded subsystems), addons-card (active addons med revoke), grant-addon-form (subsystem_id-regex, expiry, billing_reference, reason), change-tenant-type-card (chip-väljare för 9 TenantType-värden, fresh-auth-prompt) ✅ PL-T225 |
| F02.10.09 | Shipped | tier_jobs — jobs/tenant_tier_jobs.py med tenant-tier-addon-expiry-tick (15 min, hård-raderar TenantTierAddon där expires_at <= now, behåller permanenta) + tenant-tier-matrix-audit-tick (1 h, jämför API- och www-matrix byte-by-byte, emitterar tenant.tier.matrix_drift på diff för operator-larm) ✅ PL-T225 |