Skip to content

feat(login): add 2FA#4205

Open
boutterudy wants to merge 43 commits intoumami-software:devfrom
boutterudy:feat-2FA
Open

feat(login): add 2FA#4205
boutterudy wants to merge 43 commits intoumami-software:devfrom
boutterudy:feat-2FA

Conversation

@boutterudy
Copy link
Copy Markdown
Contributor

@boutterudy boutterudy commented Apr 24, 2026

❓ Context

Two-factor authentication is widely anticipated and requested by the community, as evidenced by the discussions #1195, #4167 (comment), #929 and #3163 (comment) 👀

In light of this, I took the initiative to suggest various options during a conversation (#1195 (comment)), and after receiving some feedback, I set to work on implementing 2FA.

💡 Flow & Features

Enforce 2FA

Administrators are given the option to make 2FA required at several levels:

  1. for all users
  2. for all members of a team
  3. for a specific user

When 2FA is required for a user, the next time they perform an action (e.g. navigate on a page) or log in, they will be required to set up 2FA for their account.

When 2FA is required for a user, once it has been set up, they cannot disable it (unless the admin changes the 2FA settings).

Optional 2FA

When a user is not required to set up 2FA, they can still do so by accessing their security settings and enabling it.
When 2FA is not required for a user, they can disable it by entering their password and a code generated by their authentication app.

Backup codes

As a backup login method, backup codes are generated when 2FA is enabled and must be saved by the user. They can then use these codes to log in if necessary.

Login

When a user logs in, if 2FA is enabled for that user, they will need to enter a code generated by the authentication app they have set up in order to log in.

If 2FA is not enabled for a user, the login process remains the same as before.

🔐 Security

As the aim of 2FA is to enhance Umami’s security, I have tried to take every possible precaution to ensure the security of this implementation.

In a nutshell:

  • TWO_FACTOR_ENCRYPTION_KEY env var must be a 64-char hex string
  • The TOTP secret is encrypted at rest using AES-256-GCM (with TWO_FACTOR_ENCRYPTION_KEY env var), so a database leak does not expose it
  • During login, a short-lived intermediate token (valid for 5 minutes) is issued after the password step, ensuring the 2FA challenge cannot be bypassed
  • Each one-time code can only be used once → submitting the same code twice within its validity window is rejected
  • After 5 consecutive failed attempts, the account is locked out from 2FA verification for 15 minutes to prevent brute-force attacks
  • Backup codes are hashed before storage (the plaintext is shown only once at setup) and are invalidated after a single use
  • Disabling 2FA requires both the current password and a valid authentication code, and is blocked entirely when 2FA is enforced by an admin
If you’re interested in understanding the full implementation, take a look at the sequence diagram below
sequenceDiagram
  autonumber
  actor User
  participant Browser
  participant API as Next.js API
  participant DB as Database

  rect rgb(230, 245, 255)
    Note over User,DB: Enrollment
    User->>Browser: Opens 2FA settings
    Browser->>API: GET /api/2fa/status (full session)
    API->>DB: SELECT two_factor_auth WHERE userId
    DB-->>API: row (or null)
    API-->>Browser: { isEnabled: false, isRequired, requiredReason }

    User->>Browser: Clicks "Enable 2FA"
    Browser->>API: POST /api/2fa/setup/initiate (full session)
    API->>API: generateSecret() → plaintext secret
    API->>API: encryptSecret() → AES-256-GCM ciphertext
    API->>DB: UPSERT two_factor_auth (isEnabled=false)
    API-->>Browser: { qrCodeDataUrl, manualKey }

    User->>Browser: Scans QR code in authenticator app
    User->>Browser: Enters first 6-digit OTP
    Browser->>API: POST /api/2fa/setup/confirm { token }
    API->>DB: SELECT two_factor_auth (isEnabled=false)
    API->>DB: SELECT two_factor_rate_limit
    API->>DB: SELECT two_factor_otp_used (replay check)
    API->>API: decryptSecret() + verifyTotp()
    alt TOTP invalid
      API->>DB: INCREMENT rate limit attempts
      API-->>Browser: 400 { code: two-factor-error-invalid-code }
      Browser-->>User: Error message
    else TOTP valid
      API->>DB: UPDATE two_factor_auth SET isEnabled=true
      API->>DB: INSERT two_factor_otp_used (90s TTL)
      API->>DB: DELETE two_factor_rate_limit
      API->>API: generateBackupCodes() → 10 bcrypt-hashed codes
      API->>DB: REPLACE all two_factor_backup_code rows
      API-->>Browser: { backupCodes: string[] }
      Browser-->>User: Shows backup codes (one-time display)
    end
  end

  rect rgb(255, 245, 230)
    Note over User,DB: Login with 2FA
    User->>Browser: Submits username + password
    Browser->>API: POST /api/auth/login { username, password }
    API->>DB: SELECT user WHERE username
    API->>API: checkPassword()
    API->>DB: SELECT two_factor_auth WHERE userId
    alt 2FA not enabled
      API-->>Browser: { token, user }
    else 2FA enabled
      API->>API: createSecureToken({ userId, type:'partial-auth' }, 5m TTL)
      API-->>Browser: { requiresTwoFactor: true, partialToken }
      Browser->>Browser: sessionStorage.setItem('umami.partial-token', partialToken)
      Browser->>Browser: redirect → /login/two-factor
    end

    User->>Browser: Enters 6-digit OTP (or backup code)
    Browser->>Browser: partialToken = sessionStorage.removeItem(...)
    Browser->>API: POST /api/2fa/verify { token } Bearer: partialToken
    API->>API: parseSecureToken() → verify type='partial-auth', extract userId
    API->>DB: SELECT two_factor_auth WHERE userId (isEnabled=true)
    API->>DB: SELECT two_factor_rate_limit
    alt Rate limit exceeded
      API-->>Browser: 429 { code: two-factor-error-too-many-attempts, lockedUntil }
      Browser-->>User: Locked until HH:MM:SS
    else Using TOTP token
      API->>DB: SELECT two_factor_otp_used (replay check)
      API->>API: decryptSecret() + verifyTotp()
      alt TOTP invalid
        API->>DB: INCREMENT rate limit attempts
        API-->>Browser: 400 { code: two-factor-error-invalid-code }
        Browser-->>User: Error message
      else TOTP valid
        API->>DB: INSERT two_factor_otp_used (90s TTL)
        API->>DB: DELETE two_factor_rate_limit
        API->>API: createSecureToken / saveAuth → full session token
        API-->>Browser: { token, user }
        Browser->>Browser: setClientAuthToken(token), setUser(user)
        Browser->>Browser: router.push('/')
      end
    else Using backup code
      API->>DB: SELECT unused two_factor_backup_code WHERE userId
      API->>API: bcrypt.compare() against each hash
      alt No match
        API->>DB: INCREMENT rate limit attempts
        API-->>Browser: 400 { code: two-factor-error-invalid-backup-code }
      else Match found
        API->>DB: UPDATE backup_code SET used=true
        API->>DB: DELETE two_factor_rate_limit
        API->>API: createSecureToken / saveAuth → full session token
        API-->>Browser: { token, user }
        Browser->>Browser: setClientAuthToken(token), setUser(user)
        Browser->>Browser: router.push('/')
      end
    end
  end
Loading

✨ Wonderful Results

🛡️ As admin

Security settings

As.admin.-.Security.settings.mov

Team settings

As.admin.-.Team.settings.mov

User security settings

2FA not required

image

2FA required but not configured

image

2FA required and active

image

👤 As user

2FA setup

As.user.-.2FA.setup.mov

2FA setup when required

Example of what happens when 2FA is now required for them after they logged in, or while they navigates on Umami : a modal that can't be closed is displayed to setup the 2FA.

image

Login with 2FA

As.user.-.Login.with.2FA.mov

Security settings

2FA not required and not configured

image

2FA required and active

image

2FA not required and active

image

Disable 2FA

As.user.-.Disable.2FA.mov

Disable 2FA, error when incorrect password

image

Disable 2FA, error when invalid code

image

Disable 2FA, error when already used code

As user - User settings (disable 2FA, already used code)

Greptile AI review context

This project uses relationMode = "prisma" in prisma/schema.prisma. Foreign key constraints are intentionally enforced at the Prisma ORM layer rather than the database level. Migration files will not contain ADD CONSTRAINT ... FOREIGN KEY or ON DELETE CASCADE statements and this is expected, not an oversight.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 24, 2026

@boutterudy is attempting to deploy a commit to the Umami Software Team on Vercel.

A member of the Team first needs to authorize it.

@boutterudy boutterudy marked this pull request as ready for review April 25, 2026 12:37
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 25, 2026

Greptile Summary

This PR adds a comprehensive TOTP-based 2FA implementation including enrollment, login challenge, backup codes, admin enforcement at global/team/user levels, and rate limiting with replay prevention. Previous review rounds addressed the major security issues (transaction wrapping for confirm, race-condition-safe backup code consumption, serializable rate-limit transactions, and the setup/initiate bypass). Remaining findings are all P2 quality/maintainability items.

Confidence Score: 5/5

Safe to merge — all previously identified P0/P1 issues have been addressed; remaining findings are P2 style and maintenance concerns.

All critical security and data-integrity bugs raised in prior review rounds have been fixed (transaction wrapping, backup code double-use, rate-limit atomicity, setup/initiate bypass). Only P2 suggestions remain: shared helper for the duplicated 2FA-requirement check, lazy-only OTP record cleanup, unhandled max-retry throw, and a React strict-mode development redirect loop.

src/lib/two-factor/replay-prevention.ts (unbounded table growth), src/app/api/2fa/disable/route.ts (duplicated requirement check logic), src/lib/two-factor/rate-limit.ts (unhandled max-retry exception)

Important Files Changed

Filename Overview
src/app/api/2fa/verify/route.ts TOTP and backup-code verification using partial token, with race-condition-safe backup code consumption via updateMany(..., used: false) predicate
src/app/api/2fa/setup/confirm/route.ts 2FA confirmation route; isEnabled + backup code generation wrapped in a single transaction to prevent partial-failure state
src/app/api/2fa/disable/route.ts Disables 2FA after verifying requirement/password/TOTP; 4-query requirement check duplicated from status/route.ts — should be a shared helper
src/lib/two-factor/rate-limit.ts Serializable-transaction rate limiter with P2034 retry; max-retry exhaustion throws uncaught exception that callers don't handle, resulting in 500
src/lib/two-factor/replay-prevention.ts OTP replay prevention via DB-backed expiry; expired records only cleaned up lazily on re-check — table will grow unbounded without a proactive cleanup mechanism
src/lib/two-factor/crypto.ts AES-256-GCM encryption of TOTP secret with random IV and auth tag; correct implementation
src/app/api/auth/login/route.ts Issues short-lived partial-auth token when 2FA is enabled; correctly gates full token on 2FA completion
src/app/login/two-factor/LoginTwoFactorPage.tsx Login 2FA UI; partial token consumed from sessionStorage in useEffect — React strict-mode double-invocation will cause redirect loop in development
prisma/schema.prisma Four new 2FA models with onDelete: Cascade on all user relations; consistent with project's prisma relationMode

Sequence Diagram

sequenceDiagram
  actor User
  participant Browser
  participant API as Next.js API
  participant DB as Database

  rect rgb(230,245,255)
    Note over User,DB: Enrollment
    User->>Browser: Click Enable 2FA
    Browser->>API: POST /api/2fa/setup/initiate
    API->>DB: findUnique twoFactorAuth (isEnabled guard)
    API->>DB: upsert twoFactorAuth isEnabled=false encryptedSecret
    API-->>Browser: qrCodeDataUrl manualKey
    User->>Browser: Scan QR + enter OTP
    Browser->>API: POST /api/2fa/setup/confirm token
    API->>DB: checkRateLimit / isOtpReplayed / verifyTotp
    API->>DB: transaction update isEnabled=true deleteMany+createMany backupCodes markOtpUsed
    API-->>Browser: backupCodes[]
  end

  rect rgb(255,245,230)
    Note over User,DB: Login with 2FA
    User->>Browser: username + password
    Browser->>API: POST /api/auth/login
    API->>DB: findUnique twoFactorAuth
    API-->>Browser: requiresTwoFactor=true partialToken 5m TTL
    Browser->>Browser: sessionStorage.setItem partialToken
    Browser->>Browser: redirect /login/two-factor
    User->>Browser: Enter OTP or backup code
    Browser->>API: POST /api/2fa/verify Bearer partialToken
    API->>API: parseSecureToken userId type=partial-auth
    API->>DB: checkRateLimit / verifyTotp or verifyBackupCode
    API-->>Browser: token user
  end

  rect rgb(230,255,230)
    Note over User,DB: Disable 2FA
    User->>Browser: password + OTP
    Browser->>API: POST /api/2fa/disable
    API->>DB: check global/user/team requirement 4 queries
    API->>DB: checkPassword / checkRateLimit / isOtpReplayed / verifyTotp
    API->>DB: transaction markOtpUsed delete twoFactorAuth deleteMany backupCodes
    API-->>Browser: ok true
  end
Loading

Reviews (6): Last reviewed commit: "fix(2FA): mark OTP used in transaction" | Re-trigger Greptile

Comment thread src/app/api/2fa/setup/confirm/route.ts
Comment thread prisma/schema.prisma
Comment thread src/lib/two-factor/rate-limit.ts
Comment thread src/app/api/2fa/disable/route.ts Outdated
Comment thread src/app/api/2fa/setup/initiate/route.ts
Comment thread src/lib/two-factor/crypto.ts Outdated
@boutterudy boutterudy marked this pull request as draft April 25, 2026 13:25
@boutterudy
Copy link
Copy Markdown
Contributor Author

boutterudy commented Apr 25, 2026

@greptileai You've mentioned two issues, and I've fixed them:

  1. src/lib/two-factor/crypto.ts (hex key validation) → fix(2FA): add hex key validation for TWO_FACTOR_ENCRYPTION_KEY
  2. src/app/api/2fa/disable/route.ts (non-atomic cleanup) → fix(2FA): put disable 2FA cleanup in transaction

Comment thread src/app/api/2fa/verify/route.ts
@boutterudy boutterudy marked this pull request as ready for review April 25, 2026 16:53
Comment thread prisma/migrations/20_add_2fa/migration.sql
Comment thread src/app/api/2fa/setup/initiate/route.ts
@boutterudy
Copy link
Copy Markdown
Contributor Author

@greptileai

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