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

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/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