Skip to content
This repository was archived by the owner on Apr 10, 2026. It is now read-only.
This repository was archived by the owner on Apr 10, 2026. It is now read-only.

feat(server): 2FA — DB schema, TOTP setup/confirm/disable endpoints #51

@dajbelshaw

Description

@dajbelshaw

Part of #50.

Database changes

Add 3 columns to users via migration in db.ts:

ALTER TABLE users ADD COLUMN totp_secret TEXT;
ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN totp_backup_codes TEXT; -- JSON array of bcrypt hashes

New endpoints (all require jwtAuth)

POST /api/user/totp/setup

Generates a new TOTP secret and returns setup info. Does not enable 2FA yet — user must confirm first.

  • Generate 20-byte random secret, base32-encode it
  • Store in totp_secret (overwrite any previous pending secret; totp_enabled stays 0)
  • Return:
    { "otpauthUrl": "otpauth://totp/TaskDial:user@example.com?secret=BASE32&issuer=TaskDial", "secret": "BASE32" }
  • The frontend renders the otpauthUrl as a QR code

POST /api/user/totp/confirm

User submits the first TOTP code to prove they've set up their authenticator correctly.

  • Body: { "code": "123456" }
  • Verify code against stored totp_secret (window ±1 step for clock drift)
  • If valid:
    • Set totp_enabled = 1
    • Generate 8 backup codes (8 random bytes each, hex-encoded → 16 chars), bcrypt-hash each, store JSON array in totp_backup_codes
    • Return plaintext backup codes (shown once only)
    • Audit log: totp_setup
  • If invalid: 422 with error message (rate-limit: 5 attempts per 10 min)

DELETE /api/user/totp

Disable 2FA. Requires verification to prevent CSRF.

  • Body: { "code": "123456" } — either a current TOTP code or a backup code
  • Verify code (TOTP or backup)
  • Clear totp_secret, totp_enabled = 0, totp_backup_codes = null
  • Audit log: totp_disabled

API key interaction

Creating or revoking an API key (see #56) must require a current TOTP code when 2FA is enabled. API keys are persistent credentials that survive session expiry — minting one without re-verifying 2FA would let an attacker with a live session cookie create permanent access. The /api/user/api-keys POST and DELETE endpoints should accept an optional totpCode field and enforce it when totp_enabled = 1.

Password reset interaction

In the existing reset-password handler, after rotating key_salt and bumping token_version, also:

  1. Clear totp_secret, totp_enabled = 0, totp_backup_codes = null — password reset is the recovery path for a lost authenticator device
  2. Revoke all API keys (UPDATE api_keys SET revoked = 1 WHERE user_id = ?) — API keys wrap the pre-reset encryption key (v2 envelope encryption) or otherwise represent credentials that should not survive a full credential reset. See feat: Public REST API with API key authentication #56.

The full password reset sequence becomes: rotate key_salt → bump token_version → revoke refresh tokens → clear 2FA → revoke API keys.

Rate limiting

Add a totpLimiter: 5 attempts per 10 min, keyed by user ID (not IP), applied to /confirm and the login TOTP step.

Backup code verification helper

async function verifyBackupCode(rawCode: string, storedHashes: string[]): Promise<number | null>
// returns index of matched hash (for removal), or null if no match

After a backup code is used, splice it from the array and update totp_backup_codes in the DB.

Metadata

Metadata

Assignees

No one assigned

    Labels

    authAuthentication & sessionsenhancementNew feature or requestsecuritySecurity concern

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions