Skip to content

feat(auth): add TOTP-based two-factor authentication - #58

Merged
mankatcheung merged 5 commits into
mainfrom
feature/jef-18-two-factor-authentication-totp
Jul 23, 2026
Merged

mankatcheung merged 5 commits into
mainfrom
feature/jef-18-two-factor-authentication-totp

Conversation

@mankatcheung

@mankatcheung mankatcheung commented Jul 22, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Adds optional TOTP-based 2FA (RFC 6238) on top of the existing JWT/password login, using otplib (v13, with its bundled Noble crypto / Scure base32 plugins) and qrcode for the setup QR image
  • New totpSecret/totpEnabled columns on User
  • Account settings gets a setup → QR/manual key → confirm → disable flow: beginTotpSetup, confirmTotpSetup, disableTotp mutations + a totpEnabled query, backed by GenerateTotpSecretUseCase / ConfirmTotpSetupUseCase / DisableTotpUseCase / GetTotpStatusUseCase
  • Breaking change to login: it now returns { success, totpRequired } instead of a plain Boolean. When totpRequired is true, no auth cookies are set — the client must call the new loginWithTotp(email, password, code) mutation (LoginWithTotpUseCase) to complete sign-in
  • New two-step login.tsx UI (credentials, then a 6-digit code) and a Two-factor authentication section in account.tsx

Fixes JEF-18

Test plan

  • pnpm typecheck (api + web)
  • pnpm lint (api + web)
  • pnpm test — 429 API tests + 81 web tests passing, including new coverage for GenerateTotpSecretUseCase, ConfirmTotpSetupUseCase, DisableTotpUseCase, GetTotpStatusUseCase, LoginWithTotpUseCase (using real generated TOTP codes, not mocked crypto), AuthResolver/UserResolver, and the updated LoginPage/AccountPage
  • pnpm build (all packages)
  • Manually verified end-to-end against a live server with an isolated throwaway DB (to avoid touching the shared local dev DB used by other in-flight branches): registered a user → beginTotpSetup → generated a real TOTP code and confirmTotpSetup → confirmed login now returns totpRequired: true and sets no cookies → loginWithTotp with a freshly generated code completed sign-in successfully

Note for reviewers

Since login's return type changed from Boolean to an object, any other in-flight branch that also touches authMutations.ts/login.tsx will need a small merge conflict resolution — flagging in case it overlaps with other open PRs in this batch (#56, #57).

🤖 Generated with Claude Code

https://claude.ai/code/session_01DdEiNRTcUnM6kE5AdFQ3n8

Summary by CodeRabbit

  • New Features
    • Added optional two-factor authentication using authenticator apps and QR-code setup.
    • Added backup codes for account recovery, including one-time use protection.
    • Login now supports a verification step when two-factor authentication is enabled.
    • Added account controls to enable, confirm, view status and disable two-factor authentication.
    • Added rate limiting for verification attempts and encrypted storage for authentication secrets.
  • Bug Fixes
    • Improved handling and client-safe reporting of rate-limited authentication errors.
  • Tests
    • Added comprehensive coverage for setup, login, recovery codes, disabling, storage and user interface flows.

Adds optional 2FA on top of the existing JWT/password login, using
otplib (RFC 6238 TOTP) and qrcode for the setup QR image.

- New totpSecret/totpEnabled columns on User
- GenerateTotpSecretUseCase / ConfirmTotpSetupUseCase / DisableTotpUseCase /
  GetTotpStatusUseCase back a setup-QR-confirm-disable flow in account
  settings (beginTotpSetup, confirmTotpSetup, disableTotp, totpEnabled)
- login now returns { success, totpRequired } instead of a plain boolean —
  when totpRequired is true, no auth cookies are set and the client must
  call the new loginWithTotp(email, password, code) mutation to complete
  sign-in (LoginWithTotpUseCase)
- New login.tsx two-step UI (credentials, then a 6-digit code) and a
  Two-factor authentication section in account.tsx (QR code + manual key,
  confirm, and disable-with-password)
- Verified end-to-end against a live server: register → beginTotpSetup →
  confirmTotpSetup with a real generated code → login now requires TOTP →
  loginWithTotp completes sign-in
@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 72ddc3f2-155d-465b-9c51-c8f0dfad6626

📥 Commits

Reviewing files that changed from the base of the PR and between 0d5f8a1 and 5432761.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (52)
  • apps/api/.env.example
  • apps/api/package.json
  • apps/api/prisma/migrations/20260722223632_add_totp/migration.sql
  • apps/api/prisma/migrations/20260723000000_add_totp_backup_codes/migration.sql
  • apps/api/prisma/schema.prisma
  • apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts
  • apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts
  • apps/api/src/__tests__/application/user/DisableTotpUseCase.test.ts
  • apps/api/src/__tests__/application/user/GenerateTotpSecretUseCase.test.ts
  • apps/api/src/__tests__/application/user/GetTotpStatusUseCase.test.ts
  • apps/api/src/__tests__/helpers/createTestDb.ts
  • apps/api/src/__tests__/helpers/mocks.ts
  • apps/api/src/__tests__/http/errors/AppError.test.ts
  • apps/api/src/__tests__/http/errors/formatError.test.ts
  • apps/api/src/__tests__/infrastructure/auth/totpSecretCrypto.test.ts
  • apps/api/src/__tests__/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.test.ts
  • apps/api/src/__tests__/infrastructure/db/repositories/PrismaUserRepository.test.ts
  • apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts
  • apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts
  • apps/api/src/constants.ts
  • apps/api/src/domain/totpBackupCode/TotpBackupCode.ts
  • apps/api/src/domain/user/User.ts
  • apps/api/src/http/container.ts
  • apps/api/src/http/schema/index.ts
  • apps/api/src/http/schema/mutations/authMutations.ts
  • apps/api/src/http/schema/mutations/userMutations.ts
  • apps/api/src/http/schema/queries/userQueries.ts
  • apps/api/src/http/schema/types/ConfirmTotpSetupType.ts
  • apps/api/src/http/schema/types/LoginResultType.ts
  • apps/api/src/http/schema/types/TotpSetupType.ts
  • apps/api/src/infrastructure/auth/totp.ts
  • apps/api/src/infrastructure/auth/totpSecretCrypto.ts
  • apps/api/src/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.ts
  • apps/api/src/infrastructure/db/repositories/PrismaUserRepository.ts
  • apps/api/src/interface-adapters/resolvers/AuthResolver.ts
  • apps/api/src/interface-adapters/resolvers/UserResolver.ts
  • apps/api/src/use-cases/auth/ILoginWithTotpUseCase.ts
  • apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts
  • apps/api/src/use-cases/ports/ITotpBackupCodeRepository.ts
  • apps/api/src/use-cases/ports/IUserRepository.ts
  • apps/api/src/use-cases/user/ConfirmTotpSetupUseCase.ts
  • apps/api/src/use-cases/user/DisableTotpUseCase.ts
  • apps/api/src/use-cases/user/GenerateTotpSecretUseCase.ts
  • apps/api/src/use-cases/user/GetTotpStatusUseCase.ts
  • apps/api/src/use-cases/user/IConfirmTotpSetupUseCase.ts
  • apps/api/src/use-cases/user/IDisableTotpUseCase.ts
  • apps/api/src/use-cases/user/IGenerateTotpSecretUseCase.ts
  • apps/api/src/use-cases/user/IGetTotpStatusUseCase.ts
  • apps/web/src/__tests__/components/AccountPage.test.tsx
  • apps/web/src/__tests__/components/LoginPage.test.tsx
  • apps/web/src/routes/_authenticated/account.tsx
  • apps/web/src/routes/login.tsx

Walkthrough

Adds TOTP-based two-factor authentication across the API and web application, including encrypted secrets, setup and disable flows, single-use backup codes, rate-limited verification, GraphQL integration, and client-side login/account UI.

Changes

TOTP authentication

Layer / File(s) Summary
Persistence and cryptographic foundations
apps/api/prisma/*, apps/api/src/domain/*, apps/api/src/infrastructure/auth/*, apps/api/src/infrastructure/db/repositories/*, apps/api/src/use-cases/ports/*
Adds TOTP user fields, backup-code storage, encrypted secret utilities, TOTP configuration, repository contracts, Prisma repositories, and supporting tests and fixtures.
Enrollment and verification use cases
apps/api/src/use-cases/auth/*, apps/api/src/use-cases/user/*, apps/api/src/__tests__/application/*
Implements secret generation, setup confirmation, backup-code generation and hashing, TOTP or backup-code login, rate limiting, status lookup, and disabling TOTP.
GraphQL API integration
apps/api/src/http/*, apps/api/src/interface-adapters/resolvers/*, apps/api/src/__tests__/interface-adapters/*
Registers dependencies and exposes TOTP setup, confirmation, disable, status, and two-step login operations through resolvers and GraphQL types.
Web login and account flows
apps/web/src/routes/*, apps/web/src/__tests__/components/*
Adds the TOTP login step and account-page controls for setup, confirmation, backup codes, status, and disabling two-factor authentication.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • mankatcheung/job-finder#68 — Covers the backup-code generation and single-use hashing implemented here; this change does not add password re-confirmation to setup initiation.

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant LoginPage
  participant AuthResolver
  participant LoginWithTotpUseCase
  participant RateLimiter
  participant UserRepository
  User->>LoginPage: enter email and password
  LoginPage->>AuthResolver: login credentials
  AuthResolver->>UserRepository: load user
  AuthResolver-->>LoginPage: totpRequired
  User->>LoginPage: enter TOTP or backup code
  LoginPage->>AuthResolver: loginWithTotp credentials and code
  AuthResolver->>LoginWithTotpUseCase: verify code
  LoginWithTotpUseCase->>RateLimiter: consume verification attempt
  LoginWithTotpUseCase-->>AuthResolver: authenticated user
  AuthResolver-->>LoginPage: tokens
Loading

Poem

A bunny now hops past the password gate,
With six little digits deciding your fate.
Backup codes tucked safely in rows,
QR squares bloom where setup flows.
Secrets sleep under encrypted stone—
Two-factor magic, securely grown.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/jef-18-two-factor-authentication-totp

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Checkov (3.3.8)
apps/api/package.json

Traceback (most recent call last):
File "/usr/local/bin/checkov", line 2, in
from checkov.main import Checkov
ModuleNotFoundError: No module named 'checkov'


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mankatcheung mankatcheung left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

This PR adds optional TOTP-based 2FA (RFC 6238) using otplib v13 + qrcode, with a full setup/confirm/disable flow on the account page and a two-step login flow on the client. It's well-structured and follows the repo's Clean Architecture conventions closely (domain → port → use case → resolver → Pothos schema → container wiring → codegen'd UI), and test coverage for the new use cases and resolvers is genuinely thorough, including tests that generate real TOTP codes rather than mocking the crypto. The breaking change to login's return type is called out clearly in the PR description.

However, this is a highly security-sensitive change, and there are two gaps that should block merge before this reaches production users: the TOTP secret is stored in plaintext, and there is no rate limiting anywhere in the API, including on the two new endpoints that accept a brute-forceable 6-digit code.

Security Analysis

  • TOTP secret stored in plaintext (CRITICAL). User.totpSecret is a plain String? column (prisma/schema.prisma, migration 20260722223632_add_totp), and GenerateTotpSecretUseCase writes the raw base32 secret straight to it. Anyone with read access to the DB (backup leak, SQL injection elsewhere, insider, Turso credential leak) can mint valid codes for every enrolled user indefinitely — 2FA is completely defeated. This needs at-rest encryption (e.g., AES-GCM with a key from KMS/env, encrypt in the use case or a repository-level column encryption helper) before this ships, especially since your architecture already treats secrets seriously elsewhere (JWT secrets via env, R2 creds, etc.).

  • No rate limiting / brute-force protection anywhere in the API (CRITICAL for this feature). I checked apps/api/src/http/plugins/ and app.ts — there is no @fastify/rate-limit or any throttling registered at all, and this PR doesn't add any either. A TOTP code is 6 digits (10^6 space) valid for ~30–60s with the tolerance configured here; without per-account/per-IP throttling, loginWithTotp and confirmTotpSetup are brute-forceable well within a code's validity window by an attacker who already has the password (or, for confirmTotpSetup, has hijacked a session). This is the single most important gap to close for a 2FA feature — 2FA is specifically meant to raise the bar against exactly this kind of automated attack. At minimum, add a fixed-window or leaky-bucket limiter keyed on email/userId for loginWithTotp and confirmTotpSetup, with backoff/lockout after a handful of failures.

  • No backup/recovery codes. There's no mechanism issued at enrollment (single-use hashed recovery codes) for a user who loses their authenticator device. Today the only way out is disableTotp, which only requires the current password — see next point. Recovery codes are pretty standard for a 2FA feature and worth adding, even as fixed follow-up work.

  • disableTotp only requires password, not a TOTP code. DisableTotpUseCase checks bcrypt.compare against the password and doesn't ask for a current TOTP code. Since disabling requires an authenticated session (which, if 2FA is already enabled, means the user already passed TOTP to get that session), the practical risk is lower than it looks, but it does mean a compromised-but-still-valid session plus a leaked password is enough to downgrade the account's security silently and immediately. Consider requiring the current TOTP code (or a recovery code) in addition to the password, matching the "step-up" pattern you'd want for a security-lowering action.

  • beginTotpSetup/confirmTotpSetup enrollment doesn't require re-entering the password. Enrollment only needs ctx.user (a valid session) — no password re-confirmation, unlike disableTotp which does require it. This is an inconsistency: enabling 2FA (attacker enrolling their own secret on a hijacked session to... actually this doesn't help an attacker much since they'd need the victim's session either way) is lower risk than disabling, but for consistency and defense-in-depth it's worth requiring current password on beginTotpSetup too, the same way you already require it for email/password changes.

  • Clock-skew tolerance (EPOCH_TOLERANCE_S: 30) is reasonable. This is a sensible ±1 step window, not excessively wide — no concern here.

  • Library choice is solid. otplib (v13) with the Noble crypto / Scure base32 plugins is a well-maintained, purpose-built TOTP implementation — good call over hand-rolling HMAC/base32 logic.

  • Constant-time comparison: not verifiable from the diff since the comparison happens inside otplib's TOTP.verify(). Given otplib is a reputable library this is a lower-priority concern than the two CRITICAL items above, but worth a quick confirmation (or a note in the PR) that verification isn't a naive === on attacker-controlled input at the app layer — from what's in this diff, it isn't (the app layer never does its own string comparison of codes).

  • 2FA enrollment secret/QR isn't exposed to unauthenticated requests — confirmed good: beginTotpSetup throws UNAUTHORIZED without ctx.user.

  • Login response change is safe: login no longer sets cookies when totpRequired is true, and no tokens are issued until loginWithTotp succeeds — correct flow, no partial-auth cookie ever leaks.

Code Quality / Style

  • Clean adherence to existing layering: ILoginWithTotpUseCase/LoginWithTotpUseCase, IGenerateTotpSecretUseCase/GenerateTotpSecretUseCase, etc. all mirror the existing use-case pattern, wired transiently in container.ts exactly like the others.
  • infrastructure/auth/totp.ts centralizing the TOTP instance factory (crypto/base32 plugins, issuer) is a nice small file that avoids repeating plugin wiring across four use cases.
  • Consistent Object.assign(new Error(...), { code }) error pattern matches the rest of the codebase.
  • TOTP_CONFIG in constants.ts avoids a magic number for the tolerance — good, matches the "no hardcoded values" convention.
  • Minor: GenerateTotpSecretUseCase, ConfirmTotpSetupUseCase, DisableTotpUseCase, GetTotpStatusUseCase each re-fetch the user from userRepository independently; not a real problem given TRANSIENT lifetime and single-purpose use cases, just flagging there's no shared "load-and-validate-user" helper (would be a nice-to-have simplification, not a blocker).
  • The account page component (account.tsx) is getting large with the new TOTP section added inline. Given the "many small files" convention, extracting the TOTP setup/disable UI into its own component (e.g. TotpSection.tsx) would keep the file more focused — not a blocker for this PR size, but worth a follow-up.

Issues & Risks

  1. Plaintext totpSecret at rest (blocking).
  2. No rate limiting on loginWithTotp / confirmTotpSetup (blocking).
  3. No recovery codes — availability/UX gap, not strictly a vulnerability, but expected for a shipped 2FA feature.
  4. disableTotp doesn't require a current TOTP code/recovery code, only password.
  5. beginTotpSetup doesn't require password re-confirmation (inconsistent with disableTotp).
  6. Flagged by the author already: merge conflicts likely with #56/#57 on authMutations.ts/login.tsx.

Suggestions

  • Encrypt totpSecret at rest (symmetric encryption with a key from env/KMS, decrypt only inside the use case right before calling otplib).
  • Add @fastify/rate-limit (or equivalent) at least on login, loginWithTotp, and confirmTotpSetup, keyed by account/IP, with lockout/backoff.
  • Add hashed, single-use backup codes generated at confirmTotpSetup time and surfaced once to the user, plus a useRecoveryCode path in loginWithTotp.
  • Require the current TOTP code (or a recovery code) for disableTotp, or at least document why password-only is considered sufficient here.
  • Consider requiring password re-confirmation on beginTotpSetup for consistency with disableTotp.
  • Follow-up: extract the account page's TOTP section into its own component as the file grows.

Test Coverage

Strong. New unit tests cover all four user-facing TOTP use cases and LoginWithTotpUseCase, including negative paths (missing user, already enabled, no setup in progress, wrong password, invalid code) and a happy path using a real generated TOTP code rather than a mocked one — this is exactly the kind of test that would catch a broken clock-skew or base32 encoding bug that mocks would hide. Resolver tests (AuthResolver, UserResolver) and web tests (LoginPage, AccountPage) were updated/added consistently. PrismaUserRepository and createTestDb schema were updated for the new columns. No tests exist (naturally, since the feature doesn't exist) for rate limiting or secret encryption — once those are added, they'll need their own coverage.

Verdict

Requesting changes. The TOTP mechanics, architecture, and test coverage are all solid, but plaintext secret storage and the total absence of rate limiting on code-verification endpoints are real security gaps for a feature whose entire purpose is to defend against credential compromise — both should be fixed before merge.

- Encrypt TOTP secrets at rest with AES-256-GCM (scrypt-derived key) instead
  of storing them in plaintext; require password re-authentication before
  starting 2FA setup, matching the existing disable-2FA re-auth pattern.
- Rate-limit TOTP code verification during login by email and IP to blunt
  brute-forcing of the 6-digit code.
- Issue 10 single-use backup codes on successful 2FA enrollment (shown once),
  accept them as a login fallback when the TOTP code is wrong, and delete all
  backup codes when 2FA is disabled.
Resolves conflicts with the just-merged JEF-16 (forgot/reset password)
branch: both branches independently added a RateLimiter, RATE_LIMIT
constants, and RATE_LIMITED error handling — merged into a single set
of shared constants/infrastructure, keeping both use cases.
@mankatcheung
mankatcheung merged commit 3bcfcc1 into main Jul 23, 2026
4 checks passed
@mankatcheung
mankatcheung deleted the feature/jef-18-two-factor-authentication-totp branch July 23, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant