Two-factor auth
This pack adds two-factor authentication to your API as JSON endpoints. It fits the guard your app authenticates with, access tokens or session, and enhances your existing login rather than replacing it.
- Enrollment endpoints that return the QR code and secret, confirmed with a six-digit code.
- One-time recovery codes returned once in the response, which the client can regenerate.
- A login that returns a short-lived, purpose-bound challenge token for a two-factor user, exchanged with a code at a rate-limited challenge endpoint.
- An email to the account owner whenever two-factor is turned on, turned off, or its recovery codes change.
Apply the two-factor pack with Flow. Start a new session in your selected coding agent and execute the following slash command inside it.
After applying, Flow will make the following changes to your app.
-
Discover your default guard, access tokens or session, so the challenge finishes on the credential you already issue.
-
Land the backend as shipped, from the migration and model mixin through the validators, the enrollment, recovery-codes, and challenge controllers, routes, and the change-notification mailer.
-
Enhance your login to return a challenge token for a two-factor user instead of the credential.
-
Run the tests, then drive the enrollment, recovery-codes, and challenge endpoints and show you the responses.
Apply the two-factor pack to your API by hand by working through the steps below in order. Each one builds on the last, taking your API from password-only auth to enrollment endpoints, recovery codes, and a challenge-token step at login.
-
Confirm your auth foundation
This pack extends the auth pack. Confirm all before continuing, and apply the auth pack first, in a fresh session, if any is missing.
- Your User model composes
withManagedEmail(), so@adonisplus/personais installed. This pack addswithTotpManagement()to that chain. - You have a login controller that verifies credentials and issues your guard's credential. This pack inserts the challenge-token branch into it.
- The authenticated
accountgroup (undermiddleware.auth()) and the publicauthgroup with login and signup are registered. - Your default guard. In
config/auth.ts, read thedefaultguard. AtokensGuardmeans you follow the Token guard blocks below, asessionGuardmeans the Session guard blocks. The API starter defaults to thewebsession guard.
- Your User model composes
Flow adapts the pack to your app, so the exact set of created and edited files depends on what you already have. This is the shape of an apply onto the API starter kit with the auth pack in place.
Configuration
The withTotpManagement() mixin on the User model backs the whole feature. Pass an options object to change its defaults.
issuer
The name shown next to the code in the user's authenticator app, normally your application or company name. Defaults to AdonisJS App.
window
How many 30-second steps on either side of the current one still accept a code. A window of 1 accepts the previous, current, and next period, about 90 seconds. Defaults to 1.
recoveryCodesCount
How many single-use recovery codes each generation produces. Defaults to 10.
withTotpManagement(encryption, hash, { issuer: 'ACME', window: 2 })
Secrets are encrypted, recovery codes are hashed
The confirmed TOTP secret is encrypted at rest and each recovery code is stored only as a hash. A leaked database row cannot be turned back into a working secret or a usable code.
Enrolling is two requests
The client posts to start enrollment and receives the QR code and secret. Two-factor is not active until the client confirms it with a code from the authenticator, which the verify request checks before promoting the secret. A client that loses the pending secret can start again, which safely regenerates it.
Recovery codes are returned once
The recovery codes come back in the response body, on enable and on regenerate, and cannot be re-fetched. Regenerating replaces the whole set and retires the old codes.
Login returns a challenge token, not the credential
For a two-factor user, login answers { data: { status: 'mfaRequired', challengeToken } } instead of the credential. The client narrows on data.status against the authenticated arm your login already returns. The challenge token is encrypted with a five-minute expiry and bound to the two_factor_challenge purpose, so an expired or wrong-purpose token is rejected with 401.
const challengeToken = encryption.encrypt({ userId: user.id }, '5 minutes', 'two_factor_challenge')
A used code cannot be replayed
Each accepted code's time step is recorded, so the same code cannot be used a second time inside its window.
The challenge is rate limited
Failed challenge attempts run through two limiter keys, one scoped per IP and a stricter one per IP-and-user that adds a 20-minute block after five failures. Both run through penalize, so the budget is spent only when a code is wrong and a correct code is always free.
const ipKey = `two_factor_${request.ip()}`
const userKey = `two_factor_${request.ip()}_${decrypted.userId}`
const challengeLimiter = limiter.multi([
{ duration: '1 min', requests: 10, key: ipKey },
{ duration: '1 min', requests: 5, blockDuration: '20 mins', key: userKey },
])
The challenge finishes on your guard
The verified challenge issues the credential for your default guard. A tokens guard returns a fresh access token, a session guard establishes the session and sets the cookie.
Every change emails the account owner
Turning two-factor on or off, or regenerating the recovery codes, sends the account owner an email out of band. If a compromised session quietly strips two-factor, the change is at least visible to the real owner.
1.0.0
Initial release.