Files
OmniOilPersonal/openspec/changes/console-totp-2fa/specs/auth/spec.md
2026-05-08 05:37:24 -05:00

145 lines
6.2 KiB
Markdown

# 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