Skip to content

Two-Factor Authentication (2FA) Architecture

Overview

This document describes the two-factor authentication architecture for the Chaverim ALPR Platform. 2FA adds a critical security layer by requiring users to verify their identity with both something they know (password) and something they have (authenticator app).

Document Status: Approved Last Updated: 2025-12-29


Design Decisions

2FA Method: TOTP Only

Decision Choice Rationale
Method TOTP (Authenticator Apps) Works offline, no SMS costs, widely adopted
Enforcement Mandatory for ALL users Security-critical system handling sensitive data
Supported Apps Google Authenticator, Authy, Microsoft Authenticator, 1Password, etc. Any RFC 6238 compliant app

Explicitly NOT Supported: - SMS codes (vulnerable to SIM swapping, carrier fees) - Email codes (circular dependency, phishing risk) - WebAuthn/Passkeys (complexity, not all users have hardware keys) - Push notifications (requires native app)

Why TOTP for All Users?

  1. Security-Critical Data - ALPR data can be used to track individuals; strong auth is essential
  2. Law Enforcement Integration - Evidence chain-of-custody requires verified user identity
  3. Field Volunteer Access - Mobile users already have smartphones for authenticator apps
  4. Offline Capability - TOTP works without network connectivity
  5. Zero Ongoing Cost - No SMS gateway fees or third-party auth services

Authentication Flow

Login Flow with 2FA

sequenceDiagram participant U as User participant B as Browser participant A as FastAPI participant D as Database U->>B: Enter email + password B->>A: POST /auth/login A->>D: Verify credentials (Argon2id) alt Invalid credentials A->>B: 401 Unauthorized else Valid credentials A->>D: Check 2FA status alt 2FA not enrolled A->>B: 302 Redirect to /auth/2fa/setup Note over U,B: User must enroll before access else 2FA enrolled A->>B: 200 + partial_token (short-lived) B->>U: Show TOTP input form U->>B: Enter 6-digit code B->>A: POST /auth/2fa/verify A->>D: Validate TOTP code alt Valid code A->>D: Log successful auth A->>B: Set JWT cookie + 302 to dashboard else Invalid code A->>D: Log failed attempt A->>B: 401 Invalid code end end end

2FA Enrollment Flow

sequenceDiagram participant U as User participant B as Browser participant A as FastAPI participant D as Database Note over U,D: User authenticated with password, needs 2FA setup U->>B: Navigate to /auth/2fa/setup B->>A: GET /auth/2fa/setup A->>A: Generate TOTP secret A->>D: Store encrypted secret (pending) A->>B: Return QR code + manual key U->>U: Scan QR with authenticator app U->>B: Enter verification code B->>A: POST /auth/2fa/setup/verify A->>D: Validate TOTP code alt Valid code A->>D: Mark 2FA as active A->>A: Generate 10 backup codes A->>D: Store hashed backup codes A->>B: Display backup codes (one time only) U->>U: Save backup codes securely B->>A: POST /auth/2fa/setup/confirm A->>B: 302 Redirect to dashboard else Invalid code A->>B: 400 Invalid code, retry end

Recovery Flow (Lost Device)

sequenceDiagram participant U as User participant B as Browser participant A as FastAPI participant D as Database U->>B: Click "Lost access to authenticator?" B->>A: GET /auth/2fa/recovery A->>B: Show backup code input U->>B: Enter backup code B->>A: POST /auth/2fa/recovery A->>D: Validate + invalidate backup code alt Valid backup code A->>D: Log recovery event A->>D: Disable current 2FA A->>B: 302 Redirect to /auth/2fa/setup Note over U,B: User must set up new 2FA immediately else Invalid code A->>D: Log failed attempt A->>B: 401 Invalid backup code end

Database Schema

New Tables

-- User 2FA settings and TOTP secret
CREATE TABLE auth.user_2fa (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,

    -- TOTP configuration
    totp_secret_encrypted BYTEA NOT NULL,          -- Fernet encrypted
    totp_secret_salt BYTEA NOT NULL,               -- Per-user salt

    -- Status
    is_active BOOLEAN NOT NULL DEFAULT FALSE,      -- True after successful verification
    enrolled_at TIMESTAMPTZ,
    last_used_at TIMESTAMPTZ,
    last_used_code VARCHAR(6),                     -- Replay protection

    -- Metadata
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT unique_user_2fa UNIQUE (user_id)
);

CREATE INDEX idx_user_2fa_user_id ON auth.user_2fa(user_id);

-- Backup codes for recovery
CREATE TABLE auth.backup_codes (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,

    code_hash VARCHAR(64) NOT NULL,                -- SHA-256 hash
    is_used BOOLEAN NOT NULL DEFAULT FALSE,
    used_at TIMESTAMPTZ,

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT unique_backup_code UNIQUE (user_id, code_hash)
);

CREATE INDEX idx_backup_codes_user_id ON auth.backup_codes(user_id);
CREATE INDEX idx_backup_codes_lookup ON auth.backup_codes(user_id, code_hash)
    WHERE is_used = FALSE;

-- 2FA attempt tracking for rate limiting
CREATE TABLE auth.twofa_attempts (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,

    attempt_type VARCHAR(20) NOT NULL,             -- 'totp', 'backup', 'setup'
    success BOOLEAN NOT NULL,
    ip_address INET,
    user_agent TEXT,
    failure_reason VARCHAR(100),

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);

-- Create partitions for attempt tracking (monthly)
CREATE TABLE auth.twofa_attempts_2025_01 PARTITION OF auth.twofa_attempts
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
-- Add more partitions as needed

CREATE INDEX idx_twofa_attempts_user_time ON auth.twofa_attempts(user_id, created_at DESC);
CREATE INDEX idx_twofa_attempts_rate_limit ON auth.twofa_attempts(user_id, created_at)
    WHERE success = FALSE;

-- Temporary setup sessions (cleaned up after enrollment)
CREATE TABLE auth.totp_setup_sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,

    totp_secret_encrypted BYTEA NOT NULL,
    totp_secret_salt BYTEA NOT NULL,

    expires_at TIMESTAMPTZ NOT NULL,               -- 15 minutes from creation
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT unique_setup_session UNIQUE (user_id)
);

CREATE INDEX idx_setup_sessions_expiry ON auth.totp_setup_sessions(expires_at);

Schema Extension to Users Table

-- Add 2FA status tracking to users table
ALTER TABLE auth.users ADD COLUMN twofa_required BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE auth.users ADD COLUMN twofa_enrolled BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE auth.users ADD COLUMN twofa_enrolled_at TIMESTAMPTZ;

API Endpoints

Authentication Endpoints

Method Endpoint Description Auth Required
POST /auth/login Password login, returns partial token if 2FA needed No
POST /auth/2fa/verify Verify TOTP code, issue full JWT Partial token
POST /auth/2fa/recovery Use backup code for recovery Partial token
POST /auth/logout Clear session Full JWT

2FA Setup Endpoints

Method Endpoint Description Auth Required
GET /auth/2fa/setup Get QR code and manual key for enrollment Password verified
POST /auth/2fa/setup/verify Verify initial TOTP code Password verified
POST /auth/2fa/setup/confirm Confirm backup codes saved Password verified
GET /auth/2fa/status Check current 2FA enrollment status Full JWT

2FA Management Endpoints (Authenticated)

Method Endpoint Description Auth Required
POST /auth/2fa/regenerate-backup-codes Generate new backup codes Full JWT + TOTP
POST /auth/2fa/reset Reset 2FA (requires re-enrollment) Full JWT + TOTP
GET /auth/2fa/backup-codes/remaining Count unused backup codes Full JWT

Admin Endpoints

Method Endpoint Description Auth Required
GET /admin/users/{id}/2fa/status View user's 2FA status Admin
POST /admin/users/{id}/2fa/reset Force reset user's 2FA Admin
GET /admin/2fa/enrollment-report Users without 2FA enrolled Admin

Request/Response Schemas

Login Response (2FA Required)

class LoginResponse(BaseModel):
    """Response when password is valid but 2FA needed."""
    requires_2fa: bool = True
    partial_token: str          # Short-lived token for 2FA step
    expires_in: int             # Seconds until partial token expires (300)

class PartialToken(BaseModel):
    """JWT claims for partial authentication."""
    sub: str                    # User ID
    type: Literal["partial"]
    exp: datetime               # 5 minutes from issue
    iat: datetime

2FA Setup Response

class TwoFASetupResponse(BaseModel):
    """Response with QR code for authenticator app."""
    qr_code_uri: str            # data:image/png;base64,...
    manual_entry_key: str       # Base32 encoded secret for manual entry
    issuer: str                 # "Chaverim ALPR"
    account_name: str           # User's email

class BackupCodesResponse(BaseModel):
    """One-time display of backup codes after enrollment."""
    codes: list[str]            # 10 plaintext codes (display once only)
    warning: str                # "Save these codes securely..."

2FA Verify Request

class TwoFAVerifyRequest(BaseModel):
    """TOTP code verification."""
    code: str = Field(..., min_length=6, max_length=6, pattern=r"^\d{6}$")

class RecoveryRequest(BaseModel):
    """Backup code for recovery."""
    backup_code: str = Field(..., min_length=8, max_length=12)

Implementation Details

Dependencies

# pyproject.toml - use latest versions compatible with Python 3.13
[project]
requires-python = ">=3.13"

[project.dependencies]
pyotp = "*"                    # TOTP implementation (RFC 6238)
qrcode = {extras = ["pil"], version = "*"}  # QR code generation
cryptography = "*"             # Fernet encryption for secrets

TOTP Service

# src/services/totp_service.py
import pyotp
import qrcode
import io
import base64
import hashlib
import secrets
from cryptography.fernet import Fernet
from datetime import datetime, timedelta

class TOTPService:
    """TOTP generation and verification service."""

    ISSUER = "Chaverim ALPR"
    DIGITS = 6
    INTERVAL = 30  # seconds
    VALID_WINDOW = 1  # Allow 1 interval before/after for clock skew

    def __init__(self, encryption_key: bytes):
        self.fernet = Fernet(encryption_key)

    def generate_secret(self) -> tuple[str, bytes, bytes]:
        """
        Generate a new TOTP secret.
        Returns: (plaintext_secret, encrypted_secret, salt)
        """
        secret = pyotp.random_base32()
        salt = secrets.token_bytes(16)
        encrypted = self._encrypt_secret(secret, salt)
        return secret, encrypted, salt

    def _encrypt_secret(self, secret: str, salt: bytes) -> bytes:
        """Encrypt TOTP secret with user-specific salt."""
        salted = salt + secret.encode()
        return self.fernet.encrypt(salted)

    def _decrypt_secret(self, encrypted: bytes, salt: bytes) -> str:
        """Decrypt TOTP secret."""
        decrypted = self.fernet.decrypt(encrypted)
        return decrypted[len(salt):].decode()

    def generate_qr_code(self, secret: str, email: str) -> str:
        """
        Generate QR code as base64 data URI.
        """
        totp = pyotp.TOTP(secret)
        uri = totp.provisioning_uri(name=email, issuer_name=self.ISSUER)

        qr = qrcode.QRCode(version=1, box_size=10, border=5)
        qr.add_data(uri)
        qr.make(fit=True)

        img = qr.make_image(fill_color="black", back_color="white")
        buffer = io.BytesIO()
        img.save(buffer, format="PNG")

        b64 = base64.b64encode(buffer.getvalue()).decode()
        return f"data:image/png;base64,{b64}"

    def verify_code(
        self,
        code: str,
        encrypted_secret: bytes,
        salt: bytes,
        last_used_code: str | None = None
    ) -> bool:
        """
        Verify a TOTP code.

        Args:
            code: 6-digit code from authenticator
            encrypted_secret: Encrypted TOTP secret
            salt: User's salt
            last_used_code: Last successfully used code (replay protection)

        Returns:
            True if valid, False otherwise
        """
        # Replay protection
        if last_used_code and code == last_used_code:
            return False

        secret = self._decrypt_secret(encrypted_secret, salt)
        totp = pyotp.TOTP(secret)

        # Verify with window for clock skew
        return totp.verify(code, valid_window=self.VALID_WINDOW)

    def generate_backup_codes(self, count: int = 10) -> list[tuple[str, str]]:
        """
        Generate backup codes.
        Returns: List of (plaintext_code, sha256_hash) tuples
        """
        codes = []
        for _ in range(count):
            # Format: XXXX-XXXX (8 chars, easy to read)
            code = f"{secrets.token_hex(2).upper()}-{secrets.token_hex(2).upper()}"
            code_hash = hashlib.sha256(code.encode()).hexdigest()
            codes.append((code, code_hash))
        return codes

    def verify_backup_code(self, code: str, stored_hash: str) -> bool:
        """Verify a backup code against stored hash."""
        code_hash = hashlib.sha256(code.upper().replace("-", "").encode()).hexdigest()
        return secrets.compare_digest(code_hash, stored_hash)

Rate Limiting

# src/services/rate_limiter.py
from datetime import datetime, timedelta
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession

class TwoFARateLimiter:
    """Rate limiting for 2FA attempts."""

    MAX_ATTEMPTS = 5
    WINDOW_MINUTES = 5
    LOCKOUT_MINUTES = 15

    async def check_rate_limit(
        self,
        db: AsyncSession,
        user_id: str
    ) -> tuple[bool, int | None]:
        """
        Check if user is rate limited.

        Returns:
            (is_allowed, seconds_until_unlock or None)
        """
        window_start = datetime.utcnow() - timedelta(minutes=self.WINDOW_MINUTES)

        # Count failed attempts in window
        result = await db.execute(
            select(func.count())
            .select_from(TwoFAAttempt)
            .where(TwoFAAttempt.user_id == user_id)
            .where(TwoFAAttempt.success == False)
            .where(TwoFAAttempt.created_at >= window_start)
        )
        failed_count = result.scalar()

        if failed_count >= self.MAX_ATTEMPTS:
            # Calculate lockout remaining
            lockout_end = window_start + timedelta(
                minutes=self.WINDOW_MINUTES + self.LOCKOUT_MINUTES
            )
            remaining = (lockout_end - datetime.utcnow()).total_seconds()
            return False, max(0, int(remaining))

        return True, None

    async def record_attempt(
        self,
        db: AsyncSession,
        user_id: str,
        attempt_type: str,
        success: bool,
        ip_address: str | None = None,
        user_agent: str | None = None,
        failure_reason: str | None = None
    ):
        """Record a 2FA attempt for rate limiting and audit."""
        attempt = TwoFAAttempt(
            user_id=user_id,
            attempt_type=attempt_type,
            success=success,
            ip_address=ip_address,
            user_agent=user_agent,
            failure_reason=failure_reason
        )
        db.add(attempt)
        await db.commit()

Authentication Middleware Update

# src/auth/dependencies.py
from fastapi import Depends, HTTPException, status, Request
from jose import jwt, JWTError

async def get_current_user(
    request: Request,
    db: AsyncSession = Depends(get_db)
) -> User:
    """
    Validate JWT and ensure 2FA is complete.
    """
    token = request.cookies.get("access_token")
    if not token:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Not authenticated"
        )

    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        token_type = payload.get("type")

        # Reject partial tokens (2FA not completed)
        if token_type == "partial":
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="2FA verification required",
                headers={"X-2FA-Required": "true"}
            )

        user_id = payload.get("sub")
        if not user_id:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Invalid token"
            )

    except JWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token"
        )

    user = await get_user_by_id(db, user_id)
    if not user or not user.is_active:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="User not found or inactive"
        )

    # Ensure 2FA is enrolled (mandatory for all users)
    if not user.twofa_enrolled:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="2FA enrollment required",
            headers={"X-2FA-Setup-Required": "true"}
        )

    return user

Security Controls

TOTP Secret Protection

Layer Protection
Encryption Fernet (AES-128-CBC + HMAC-SHA256)
Key Management Environment variable or secrets manager
Per-User Salt 16 random bytes, prevents rainbow tables
Memory Secrets cleared after use (where possible)

Rate Limiting

Parameter Value Rationale
Max attempts 5 Balance security vs usability
Window 5 minutes Sliding window for attempt counting
Lockout 15 minutes Automatic unlock, no admin intervention
Scope Per-user Prevent credential stuffing

Backup Code Security

Control Implementation
Hashing SHA-256 (no salt needed, codes are random)
One-time use Marked as used immediately
Quantity 10 codes generated at enrollment
Regeneration Requires current 2FA verification
Display Shown once only at enrollment

Audit Logging

All 2FA events are logged to auth.twofa_attempts: - Successful TOTP verification - Failed TOTP verification (with reason) - Backup code usage - 2FA enrollment - 2FA reset - Rate limit triggers


UI Considerations

Mobile-First Design

Since field volunteers use mobile devices, the 2FA UI must be optimized for small screens:

  1. Large Touch Targets - Buttons minimum 44x44px
  2. Numeric Keypad - Use inputmode="numeric" for TOTP input
  3. Auto-Advance - Auto-submit when 6 digits entered
  4. QR Code Size - Large enough to scan reliably on mobile
  5. Manual Entry Option - For users who can't scan QR codes

Enrollment Flow UI

┌─────────────────────────────────────────────────────────────┐
│                    Set Up 2FA                               │
│─────────────────────────────────────────────────────────────│
│                                                             │
│  1. Install an authenticator app:                           │
│     • Google Authenticator                                  │
│     • Microsoft Authenticator                               │
│     • Authy                                                 │
│                                                             │
│  2. Scan this QR code:                                      │
│                                                             │
│         ┌─────────────────────┐                             │
│         │  ▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄   │                             │
│         │  █     █ █     █   │                             │
│         │  █ ███ █ █ ███ █   │                             │
│         │  █     █ █     █   │                             │
│         │  ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀   │                             │
│         └─────────────────────┘                             │
│                                                             │
│     Can't scan? Enter this key manually:                    │
│     JBSWY3DPEHPK3PXP                                       │
│                                                             │
│  3. Enter the 6-digit code from your app:                   │
│                                                             │
│     ┌───┬───┬───┬───┬───┬───┐                              │
│     │   │   │   │   │   │   │                              │
│     └───┴───┴───┴───┴───┴───┘                              │
│                                                             │
│              [ Verify Code ]                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Backup Codes Display

┌─────────────────────────────────────────────────────────────┐
│                    Save Your Backup Codes                   │
│─────────────────────────────────────────────────────────────│
│                                                             │
│  ⚠️  IMPORTANT: Save these codes in a safe place!          │
│                                                             │
│  If you lose access to your authenticator app, you can      │
│  use one of these codes to recover your account.            │
│                                                             │
│  Each code can only be used once.                           │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  A7B2-C8D9    E4F5-G6H7    J8K9-L0M1              │   │
│  │  N2P3-Q4R5    S6T7-U8V9    W0X1-Y2Z3              │   │
│  │  A1B2-C3D4    E5F6-G7H8    J9K0-L1M2              │   │
│  │  N3P4-Q5R6                                         │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  [ Copy to Clipboard ]    [ Download as File ]              │
│                                                             │
│  ☑️  I have saved my backup codes                           │
│                                                             │
│              [ Continue to Dashboard ]                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Implementation Phases

Phase 1: MVP (Required for Launch)

Task Priority Effort
Database migrations for 2FA tables High 2h
TOTP service implementation High 4h
2FA enrollment endpoints High 4h
2FA verification endpoints High 3h
Backup codes generation/verification High 3h
Rate limiting High 2h
Update login flow High 3h
Enrollment UI (HTMX/Alpine) High 4h
Verification UI High 2h
Recovery UI High 2h
Total ~29h

Phase 2: Polish

Task Priority Effort
Admin 2FA management UI Medium 4h
Backup codes remaining indicator Medium 1h
Regenerate backup codes flow Medium 2h
2FA reset with re-enrollment Medium 2h
Audit log viewer (admin) Medium 3h
Email notification on 2FA changes Low 2h
Total ~14h

Migration Strategy

For New Deployments

2FA is mandatory from day one. Users must enroll during first login.

For Existing Users (If Applicable)

flowchart TB DEPLOY[Deploy 2FA Update] --> GRACE[Grace Period: 7 days] GRACE --> NOTIFY[Email: "2FA Required Soon"] GRACE --> LOGIN{User Logs In} LOGIN --> ENROLLED{2FA Enrolled?} ENROLLED -->|No| SETUP[Force Enrollment] ENROLLED -->|Yes| ACCESS[Normal Access] SETUP --> ACCESS GRACE --> EXPIRE[Grace Period Ends] EXPIRE --> BLOCK[Block Login Until Enrolled]

Testing Requirements

Unit Tests

  • [ ] TOTP generation produces valid secrets
  • [ ] TOTP verification accepts valid codes
  • [ ] TOTP verification rejects invalid codes
  • [ ] TOTP verification rejects replayed codes
  • [ ] Backup code generation produces unique codes
  • [ ] Backup code verification works correctly
  • [ ] Backup code one-time use enforced
  • [ ] Rate limiting triggers after max attempts
  • [ ] Rate limiting resets after window

Integration Tests

  • [ ] Full enrollment flow
  • [ ] Full login flow with 2FA
  • [ ] Recovery flow with backup code
  • [ ] Rate limiting integration
  • [ ] Partial token rejection on protected endpoints

E2E Tests

  • [ ] Mobile enrollment flow
  • [ ] QR code scanning simulation
  • [ ] Backup codes copy/download


Document Maintainer: Security Team Review Cycle: Quarterly or after security incidents