Two-factor authentication (2FA) asks for something the user has (a phone with an authenticator app) on top of something they know (the password). A stolen or reused password is then not enough to sign in. Vukem implements TOTP: the app and the server share a secret and both compute a 6-digit code from it and the current time, every 30 seconds.
vukem install:auth --2fa # also works on a project that already ran install:auth
vukem migrateThe scaffold adds Fortify-style TOTP two-factor authentication on top of auth/totp (RFC 6238: SHA-1, 6 digits, 30 seconds, the authenticator-app defaults), for the HTMX starter and the Inertia React, Vue and Svelte starters:
app/models/two_factor.go TwoFactor: the 2FA columns of users
app/accounts/two_factor.go enable, confirm, verify, recovery codes, disable, pending login
app/controllers/account/two_factor*.go settings page and login challenge
app/requests/two_factor.go code, challenge and password confirmation schemas
database/migrations/*_add_two_factor_columns.go
resources/views/auth/two-factor*.html or resources/js/pages/Auth/TwoFactor*.{tsx,vue,svelte}
routes/two_factor.go appended to routes.All()
tests/two_factor_test.go the whole flowIt also patches your Login handler so a correct password leads to the challenge, links the settings page from the dashboard, and makes social sign-in stop at the challenge too. If Login was rewritten by hand, the command fails before writing anything and prints the change to make, so 2FA is never installed without being enforced.
Flows#
| Flow | Behaviour |
|---|---|
| Storage | the secret and the recovery code hashes are encrypted columns keyed from APP_KEY; TwoFactor is its own model, so the secret never reaches User or shared props |
Enable (/user/two-factor) | a new secret, shown as a QR code (inline SVG of the otpauth:// URI, issuer APP_NAME) and a setup key. A confirmed setup is never replaced (409) |
| Confirm | 2FA is on only after a valid code; 8 recovery codes are then shown once (only hashes are stored); every other session of the user ends on every session driver (Accounts.RevokeOtherSessions bumps the session version, then Web.RefreshSession keeps the current one) |
| Recovery codes | regenerating invalidates the old ones; the page shows how many are left |
| Disable | needs the current password |
| Login | a correct password for a user with 2FA does not log in: the session id is regenerated, the pending login is stored for 5 minutes and the browser goes to /two-factor-challenge |
| Challenge | an app code or a recovery code (consumed) logs in with the remember-me choice and goes to the intended URL |
| Replay | a code whose time step is at or before the last accepted one is refused, including the confirmation code; concurrent requests with the same code have one winner |
| Throttling | challenge, confirm and disable: 5 per minute per user + IP (429 with Retry-After) |
| Events | failed codes and passwords log the auth.2fa_failed security event |
The totp package#
secret, err := totp.GenerateSecret() // base32
uri := totp.URI("Vukem", user.Email, secret, totp.Options{}) // otpauth://totp/...
svg, err := totp.QR(uri, 200) // inline SVG
step, ok := totp.Validate(secret, code, time.Now(), lastStep, totp.Options{}) // store step on success
plain, hashes, err := totp.RecoveryCodes(8) // show plain once, store hashes
remaining, ok := totp.UseRecoveryCode(input, hashes)
code, err := totp.Code(secret, time.Now(), totp.Options{}) // in teststotp.QR (and totp.QRMatrix for other renderers) encodes any text in byte mode at error correction level M, versions 1 to 40: up to 2331 bytes, enough for long otpauth:// URIs and PIX BR Codes. Longer text returns totp.ErrTooLong.
The QR SVG is written by totp.QR itself from server data, which is why the scaffold may insert it as raw HTML. Never do that with user input.
In the Inertia starters the scaffold sends the QR code to the page as a data:image/svg+xml;base64,… URL rendered with a plain <img>, so no raw HTML is injected into React, Vue or Svelte.
Step by step#
1. Install and migrate
vukem install:auth --2fa
vukem migrate # adds the two-factor columns to users
go test ./tests/... # the scaffold's tests cover the whole flow2. Enable it as a user
- Sign in and open
/user/two-factor. - Click Enable: a QR code and a setup key appear.
- Scan the QR code with an authenticator app (Google Authenticator, 1Password, Aegis…).
- Type the 6-digit code to confirm. 2FA is on only after this step.
- Save the 8 recovery codes shown once, in a password manager.
3. Sign in with a code
Sign out and sign in again. After the password you land on /two-factor-challenge; type the current code from the app, or one recovery code if the phone is lost.
4. Test it without a phone
totp.Code computes the code the app would show, so tests can drive the whole flow:
code, err := totp.Code(secret, time.Now(), totp.Options{})
if err != nil {
t.Fatal(err)
}
client.PostForm("/two-factor-challenge", url.Values{"code": {code}}).AssertRedirect("/dashboard")A code is accepted only once (replay protection), so a test that signs in twice needs the code of a later time step: totp.Code(secret, time.Now().Add(30*time.Second), totp.Options{}).
Using totp without the scaffold#
// Enrolment: generate and store the secret (encrypted), show the QR code.
secret, err := totp.GenerateSecret()
uri := totp.URI(app.Config.Name, user.Email, secret, totp.Options{})
svg, err := totp.QR(uri, 200)
// Confirmation and login: validate, then store the step to block replays.
step, ok := totp.Validate(secret, input, time.Now(), tf.TwoFactorLastStep, totp.Options{})
if !ok {
return errs.ErrUnprocessable // and count the failure for throttling
}
tf.TwoFactorLastStep = step // models.TwoFactor in the scaffold; save it in the same update that logs the user inValidate accepts the previous and the next 30-second step as well, for clock skew between the phone and the server.
Pitfalls#
- Server clock. TOTP depends on the time. A server more than about 30 seconds off makes every code fail; keep NTP on (containers use the host clock).
- Not storing the step. Without saving the returned step as
lastStep, the same code works again for up to 90 seconds. - Turning 2FA on before confirmation. Only enable it after a valid code, or a user who scanned nothing is locked out. The scaffold does this.
- Losing recovery codes. They are shown once and stored as hashes only. Offer regeneration (it invalidates the old ones) from the settings page.
- A custom
Loginhandler. The scaffold patches the default one; if you rewrite login later, keep the "password OK → challenge" step, or 2FA is silently skipped.
Security notes#
- The secret is a credential: the scaffold stores it in an encrypted column keyed from
APP_KEY, in its own model so it never reaches JSON responses or Inertia props. - Throttle every endpoint that accepts a code (the scaffold allows 5 per minute per user and IP): six digits are guessable without a limit.
- Disabling 2FA and regenerating recovery codes require the current password.
- Confirming 2FA ends the user's other sessions (with
SESSION_DRIVER=database), so a session opened by an attacker before enrolment does not survive it. - Failed codes are logged as the
auth.2fa_failedsecurity event.