Skip to content

Two-Factor Authentication (2FA)

Summary

Users can optionally enable a second authentication factor on their account. Two methods are supported:

  • Email OTP — a 6-digit code sent to the user's registered email address.
  • TOTP — a time-based one-time password generated by an authenticator app (Google Authenticator, Authy, etc.).

Both methods can be active simultaneously. When at least one method is active, the login flow requires a second verification step before issuing access/refresh tokens.

Clients (agency-scoped accounts) do not have 2FA support.

All 2FA routes in this branch live under /api/identity/two-factor.

Business Rules

  • A user can have at most one method entry per type (EMAIL or TOTP), enforced by a UNIQUE(userId, method) database constraint.
  • Setup requires a verification step (activate) to confirm the user controls the second factor before it becomes active.
  • Deactivation requires a valid code for the method being used as proof of possession.
  • POST /deactivate can disable either one method or every currently active method in the same request via the optional deactivateAll flag.
  • Password change is also protected by 2FA when the user has at least one active method.
  • Email OTP codes are hashed before storage and expire after 15 minutes.
  • A challenge is locked after 5 failed attempts.
  • Sending a new email challenge invalidates previous active challenges for the same user.
  • TOTP secrets are encrypted at rest with AES-256-GCM via IEncryptionService.
  • TOTP verification allows a ±30-second window (window: 1) to account for clock drift.

Supported Flows

Flow Map

flowchart TD
    A[User has no 2FA] --> B[Authenticated setup flow]
    B --> C{Method}
    C -->|EMAIL| D[POST /api/identity/two-factor/email/setup]
    C -->|TOTP| E[POST /api/identity/two-factor/totp/setup]
    D --> F[POST /api/identity/two-factor/email/activate]
    E --> G[POST /api/identity/two-factor/totp/activate]
    F --> H[Method active]
    G --> H

    H --> I[User logs in]
    I --> J[API returns twoFactorToken + availableMethods]
    J --> K{Chosen method}
    K -->|EMAIL| L[POST /api/identity/two-factor/email/send-code]
    K -->|TOTP| M[Enter authenticator code]
    L --> N[POST /api/identity/two-factor/verify]
    M --> N
    N --> O[Access token + refresh token + profile + session + organizations list]

    H --> P[Protected authenticated action]
    P --> Q[POST /api/identity/two-factor/challenge/send-email-code or provide TOTP code]
    Q --> R[Send x-two-factor-method + x-two-factor-code headers]
    R --> S[Guard validates challenge before continuing]

Setup and Activation

Email and TOTP follow a two-step activation model so the method is not marked active until the user proves possession.

sequenceDiagram
    participant U as User
    participant API as Identity API
    participant DB as Identity DB
    participant N as Notifications

    U->>API: POST /api/identity/two-factor/email/setup
    API->>DB: Upsert EMAIL method as inactive
    API->>DB: Invalidate active email challenges
    API->>DB: Store hashed SETUP code
    API->>N: Publish two-factor email event
    API-->>U: 200 Verification code sent

    U->>API: POST /api/identity/two-factor/email/activate { code }
    API->>DB: Load latest active SETUP challenge
    alt Invalid / expired / locked
        API-->>U: 401/404 error
    else Valid code
        API->>DB: Mark challenge used
        API->>DB: Activate EMAIL method
        API-->>U: 200 Email two-factor authentication activated
    end

For TOTP, setup returns a shared secret plus a QR code data URI. Activation verifies the live authenticator code before the method becomes active.

Login Flow with 2FA

When a user with active 2FA methods authenticates through password login or Google OAuth, the API returns a two-factor response instead of access/refresh tokens:

POST /api/identity/auth/user/login  ->  { twoFactorToken, availableMethods: ["EMAIL", "TOTP"] }
  1. The twoFactorToken is a short-lived JWT (5 minutes) with purpose: 'two-factor'.
  2. The client presents a method choice and collects the code from the user.
  3. For email method, the client calls POST /api/identity/two-factor/email/send-code with { purpose: "LOGIN" } to trigger the OTP email.
  4. The client submits POST /api/identity/two-factor/verify with { method, code } using the twoFactorToken as a Bearer token.
  5. On success, the API returns the standard login response (access token, refresh token, user profile, session).

The jwt-two-factor Passport strategy validates the temporary token, ensuring purpose === 'two-factor'.

POST /api/identity/auth/user/verify-email does not branch into 2FA in the current implementation. After email verification, the API issues the normal login payload directly.

sequenceDiagram
    participant C as Client
    participant API as Identity API
    participant DB as Identity DB

    C->>API: POST /api/identity/auth/user/login
    API->>DB: Validate credentials and inspect active 2FA methods
    API-->>C: { twoFactorToken, availableMethods }

    alt EMAIL selected
        C->>API: POST /api/identity/two-factor/email/send-code { purpose: LOGIN }
        API->>DB: Invalidate active email challenges
        API->>DB: Store hashed LOGIN code
        API-->>C: 200 Verification code sent
    else TOTP selected
        C->>C: Read code from authenticator app
    end

    C->>API: POST /api/identity/two-factor/verify { method, code }
    API->>DB: Validate challenge or TOTP code
    API->>DB: Create or refresh session
    API-->>C: Access token + Refresh token + Profile + Session

Authenticated Challenge-Protected Actions

Not all 2FA usage happens at login. Some authenticated actions are guarded by TwoFactorChallengeGuard through @RequiresTwoFactor(...) metadata.

In this branch, password change uses TwoFactorChallengePurpose.CHANGE_PASSWORD.

  1. The user starts the sensitive action.
  2. If the account has no active 2FA methods, the request continues normally.
  3. If the account has active methods, the guard requires both headers:
    • x-two-factor-method
    • x-two-factor-code
  4. For email-based proof, the client first calls POST /api/identity/two-factor/challenge/send-email-code with { purpose: "CHANGE_PASSWORD" } or { purpose: "DEACTIVATION" }.
  5. The guarded endpoint validates the supplied method and code before executing the action.

If headers are missing, the API returns CORE.TWO_FACTOR_REQUIRED with availableMethods so the client can prompt the user.

API Endpoints

All endpoints are under /api/identity/two-factor.

Setup & Activation (Authenticated)

MethodPathBodyDescription
GET/methodsList active 2FA methods for the current user
POST/email/setupSend a setup OTP code to the user's email
POST/email/activate{ code }Verify setup code and activate email 2FA
POST/totp/setupGenerate TOTP secret + QR code data URI
POST/totp/activate{ code }Verify TOTP code and activate TOTP 2FA
POST/deactivate{ method, code, deactivateAll? }Deactivate the specified method, or all active methods when deactivateAll is true
POST/challenge/send-email-code{ purpose }Send email challenge for authenticated protected actions

Login Verification (Two-Factor Token)

MethodPathBodyAuthDescription
POST/email/send-code{ purpose }jwt-two-factor tokenSend an email OTP for login/deactivation
POST/verify{ method, code }jwt-two-factor tokenExchange 2FA code for access/refresh tokens

Rate Limits

EndpointWindowMax RequestsIdentifier
/email/setup60s3userId
/email/activate60s5userId
/totp/setup60s3userId
/totp/activate60s5userId
/deactivate60s5userId
/challenge/send-email-code60s3userId
/verify60s5ip
/email/send-code60s3ip

Challenge Purposes

PurposeUsed ByNotes
SETUPEmail setup activationSent by /email/setup
LOGINLogin verificationSent by /email/send-code
DEACTIVATION2FA method deactivationUsed when deactivating EMAIL
CHANGE_PASSWORDPassword changeUsed by challenge-protected password change

Data Model

identity.two_factor_methods

ColumnTypeDescription
iduuid PKMethod ID (UUID v7)
user_iduuidOwner user ID
methodvarchar'EMAIL' or 'TOTP'
secrettext (nullable)Encrypted TOTP secret (null for email)
is_activebooleanWhether the method is currently active
last_used_attimestamptz (nullable)Last successful verification
deactivated_attimestamptz (nullable)When the method was deactivated
created_attimestamptzCreation timestamp
updated_attimestamptzLast update timestamp

Unique constraint: (user_id, method).

identity.two_factor_challenges

ColumnTypeDescription
iduuid PKChallenge ID (UUID v7)
user_iduuidUser ID
methodvarchar'EMAIL' or 'TOTP'
codevarcharHashed 6-digit code
purposevarchar'SETUP', 'LOGIN', or 'DEACTIVATION'
expires_attimestamptzCode expiration (15 minutes from creation)
used_attimestamptz (nullable)When the code was successfully used
attemptsintegerFailed verification attempts (max 5)
created_attimestamptzCreation timestamp
updated_attimestamptzLast update timestamp

Failure Modes

Error CodeHTTPTrigger
IDENTITY.TWO_FACTOR_ALREADY_ACTIVE409Setup when method is already active
IDENTITY.TWO_FACTOR_NOT_FOUND404Deactivate a method that doesn't exist
IDENTITY.TWO_FACTOR_SETUP_NOT_FOUND404Activate without a prior setup
IDENTITY.INVALID_TWO_FACTOR_CODE401Code doesn't match
IDENTITY.TWO_FACTOR_CODE_EXPIRED401Challenge past expiresAt
IDENTITY.TWO_FACTOR_CODE_LOCKED4015+ failed attempts on a challenge
IDENTITY.INVALID_TWO_FACTOR_TOKEN401Expired or invalid two-factor JWT
CORE.TWO_FACTOR_REQUIRED403Guarded authenticated route needs second-factor headers
CORE.INVALID_TWO_FACTOR_METHOD400Header method is not active for the user

Notifications Integration

Email OTP delivery uses the cross-module integration event pattern:

  1. Command publishes TwoFactorEmailCodeIntegrationEvent via IdentityPublisher.
  2. TwoFactorEmailCodeConsumer in the Notifications module handles the event.
  3. Uses SendEmailNotificationCommand with the TwoFactorCode email template.
  4. Template renders purpose-specific messaging (setup confirmation, login verification, deactivation confirmation).

CHANGE_PASSWORD is already a supported challenge purpose in the backend, but the current email template falls back to generic wording for that purpose.

Observability

  • Repository operations are wrapped with postgresMetricsService.recordRepositoryOperation() for latency/error tracking.
  • Rate limit violations return 429 with Retry-After header.
  • All endpoints flow through the global WideLogInterceptor for structured request logging.

Frontend Status

The backend 2FA flow is implemented and the panel app now consumes it end-to-end.

  • The login flow supports { twoFactorToken, availableMethods } and presents a dedicated 2FA step that can use either email or TOTP methods.
  • The profile security section exposes a "Doble Factor (2FA)" card where users can enable/disable email OTP and TOTP, including QR-based setup for authenticator apps.
  • Password change uses a confirmation dialog first and, when the API responds with CORE.TWO_FACTOR_REQUIRED, the panel prompts for a second-factor code and resends the request with x-two-factor-method and x-two-factor-code headers.

Change Log

  • 2026-03-12: Documented the optional deactivateAll flag for /deactivate, which lets one verified request disable every active 2FA method.
  • 2026-03-11: Expanded the 2FA doc with authenticated challenge flows, corrected routes/table names, and documented current frontend gaps.
  • 2026-03-10: Initial 2FA feature implementation with email OTP and TOTP methods.