6.2 KiB
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/loginis called with valid email + password but nototp_codeorrecovery_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/loginis called with valid credentials and a valid 6-digittotp_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/loginis called with valid credentials but a wrongtotp_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 = trueand at least one unused recovery code - WHEN
POST /api/auth/loginis called withrecovery_codematching 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/loginis called with arecovery_codethat 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/loginis 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_untilis set to approximatelynow() + 30 minutes
Scenario: Login while locked → 401 AccountLocked (correct password)
- GIVEN
users.totp_locked_until > now()for a user - WHEN
POST /api/auth/loginis 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/loginis 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/loginsucceeds 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_usedis NULL - WHEN a valid TOTP code is accepted
- THEN
users.last_totp_step_usedis set to the current step value
Scenario: Re-use of same step within 30s window → 401
- GIVEN
users.last_totp_step_used = Sfor some user - WHEN
POST /api/auth/loginis 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_usedis 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_usedis 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_KEYis 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_KEYis 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
userstable is dumped from the database - THEN
two_factor_secret_encryptedcontains ciphertext (BYTEA), never a readable TOTP seed