# Delta for Auth — console-totp-2fa ## MODIFIED Requirements ### Requirement: Login Response Shape The login endpoint MUST return a structured 401 with `requires_2fa: true` when `two_factor_enabled = true` and no TOTP or recovery code was supplied. It MUST NOT return HTTP 500 for this case. When `two_factor_enabled = true` and a valid `totp_code` or `recovery_code` is supplied, the endpoint MUST return 200 with `access_token` and set the refresh cookie. When `two_factor_enabled = false`, existing login behavior MUST remain unchanged. (Previously: absent code returned HTTP 500 with an unstructured Spanish error message; no `requires_2fa` field existed.) #### Scenario: 2FA enabled — code absent → structured 401 - GIVEN a user with `two_factor_enabled = true` - WHEN `POST /api/auth/login` is called with valid email + password but no `totp_code` or `recovery_code` - THEN the response is HTTP 401 - AND the body contains `{ "error": "...", "requires_2fa": true }` #### Scenario: 2FA enabled — valid TOTP code → 200 - GIVEN a user with `two_factor_enabled = true` - WHEN `POST /api/auth/login` is called with valid credentials and a valid 6-digit `totp_code` - THEN the response is HTTP 200 with `access_token` - AND the refresh cookie is set #### Scenario: 2FA enabled — invalid TOTP code → 401 TwoFactorFailed - GIVEN a user with `two_factor_enabled = true` - WHEN `POST /api/auth/login` is called with valid credentials but a wrong `totp_code` - THEN the response is HTTP 401 with error code `TwoFactorFailed` #### Scenario: 2FA enabled — valid recovery code → 200, code consumed - GIVEN a user with `two_factor_enabled = true` and at least one unused recovery code - WHEN `POST /api/auth/login` is called with `recovery_code` matching a stored hash - THEN the response is HTTP 200 with `access_token` - AND that recovery code row is DELETED from `two_factor_recovery_codes` #### Scenario: 2FA enabled — already-used recovery code → 401 - GIVEN a user with `two_factor_enabled = true` - WHEN `POST /api/auth/login` is called with a `recovery_code` that has already been consumed - THEN the response is HTTP 401 with error code `RecoveryCodeInvalid` #### Scenario: 2FA disabled — login unchanged - GIVEN a user with `two_factor_enabled = false` - WHEN `POST /api/auth/login` is called with valid email + password - THEN the response is HTTP 200 with `access_token` (existing behavior preserved) --- ## ADDED Requirements ### Requirement: Lockout After Repeated TOTP Failures The system MUST track failed TOTP attempts per user. After 5 consecutive failures within 15 minutes, `users.totp_locked_until` MUST be set to `now() + 30 minutes`. While `users.totp_locked_until > now()`, all login attempts for that user MUST return HTTP 401 with error code `AccountLocked` — regardless of whether the password is correct (to avoid leaking password validity during lockout). On a successful TOTP verification, the failure counter MUST be reset. #### Scenario: Lockout threshold reached → account locked - GIVEN a user has submitted 5 wrong TOTP codes within 15 minutes - WHEN the 5th failure is processed - THEN `users.totp_locked_until` is set to approximately `now() + 30 minutes` #### Scenario: Login while locked → 401 AccountLocked (correct password) - GIVEN `users.totp_locked_until > now()` for a user - WHEN `POST /api/auth/login` is called with the correct password - THEN the response is HTTP 401 with error code `AccountLocked` - AND no information about password correctness is revealed #### Scenario: Login while locked → 401 AccountLocked (wrong password) - GIVEN `users.totp_locked_until > now()` for a user - WHEN `POST /api/auth/login` is called with an incorrect password - THEN the response is HTTP 401 with error code `AccountLocked` #### Scenario: Successful TOTP resets failure counter - GIVEN a user has 3 previous TOTP failures within 15 minutes - WHEN `POST /api/auth/login` succeeds with a valid TOTP code - THEN the TOTP failure counter for that user is reset to 0 --- ### Requirement: TOTP Replay Protection The system MUST record `users.last_totp_step_used` on every successful TOTP verification. A TOTP code MUST only be accepted if the step it validates is strictly greater than `last_totp_step_used` (or `last_totp_step_used` is NULL), within the ±1 skew window. #### Scenario: First successful TOTP stores step - GIVEN `users.last_totp_step_used` is NULL - WHEN a valid TOTP code is accepted - THEN `users.last_totp_step_used` is set to the current step value #### Scenario: Re-use of same step within 30s window → 401 - GIVEN `users.last_totp_step_used = S` for some user - WHEN `POST /api/auth/login` is submitted with a code that maps to step S (or S-1) - THEN the response is HTTP 401 with error code `TwoFactorFailed` - AND `last_totp_step_used` is NOT updated #### Scenario: New step (S+1 or later) accepted - GIVEN `users.last_totp_step_used = S` - WHEN a code mapping to step S+1 (or later) within ±1 skew is submitted - THEN the code is accepted and `last_totp_step_used` is updated to the new step --- ### Requirement: TOTP Secret Encryption Invariant `users.two_factor_secret_encrypted` MUST NEVER contain plaintext. The secret MUST be encrypted with AES-256-GCM using `TOTP_SECRET_ENCRYPTION_KEY` before writing to the database. The application MUST read `TOTP_SECRET_ENCRYPTION_KEY` from the environment at startup. If the key is absent or invalid, the application MUST refuse to start. Decrypted secrets MUST be held in-memory only for the duration of the operation and MUST NOT be logged or persisted. #### Scenario: Startup without encryption key → boot failure - GIVEN `TOTP_SECRET_ENCRYPTION_KEY` is not set in the environment - WHEN the backend application starts - THEN the process exits with a non-zero status code before accepting connections #### Scenario: Startup with valid key → normal boot - GIVEN `TOTP_SECRET_ENCRYPTION_KEY` is a valid 32-byte base64 string - WHEN the backend application starts - THEN the process boots normally #### Scenario: DB dump never exposes plaintext - GIVEN a user has `two_factor_enabled = true` - WHEN the `users` table is dumped from the database - THEN `two_factor_secret_encrypted` contains ciphertext (BYTEA), never a readable TOTP seed