Accounts
The part of authentication an end user touches: registering, confirming an address, signing in and out, recovering a password, and being locked out after enough wrong guesses.
It is an opt-in module. Nothing is served until you mount it, and nothing about an application that does not is changed.
Add it: nucleus add accounts
nucleus add accounts
writes two calls into the nucleus.New() chain of main.go and a block into
nucleus.yml:
nucleus.New().
FromConfigFile("nucleus.yml").
WithoutDefaults().
WithStorage().
WithMail().
Mount(accounts.FromRuntime()).
Start()
mail_driver: log
modules:
accounts:
base_url: http://localhost:8080
from: no-reply@example.com
accounts.FromRuntime() builds the flows at start-up out of what the
application already has: the account tables on its default database
(created if missing; SQLite, PostgreSQL or MySQL), its mail sender, and
its session manager. WithMail() is what gives an application built
WithoutDefaults() — the api starter — a mail sender at all; on the default
stack it changes nothing.
Run it, register, and the confirmation link is in the terminal:
curl -X POST localhost:8080/auth/register -H 'Content-Type: application/json' \
-d '{"email":"ana@example.com","username":"ana","password":"correct horse battery staple"}'
# level=WARN msg="mail not delivered: mail_driver is log (development only)" to=ana@example.com
# subject="Confirm your email address" body="Open http://localhost:8080/auth/verify-email?token=… to confirm …"
Sign-in answers 403 until the address is confirmed; open the link, and it
answers 200.
Mail has to go somewhere, or the application does not start
The flows mail the link that confirms an address and the one that resets a
password. A sender that delivers nothing would make registration answer
202 while every account stayed unconfirmable, so FromRuntime checks at
start-up and refuses, naming what to set:
- no mail sender at all (an application built
WithoutDefaults()withoutWithMail()); mail_driver: noop, the default, which accepts every message and delivers none.
mail_driver: log is the development answer the recipe writes: every
message — the link in it included — is written to the application log at
WARN and none is delivered. It is refused outside env: development, at
configuration load and again at boot, because a log is shipped to places a
password-reset link must never reach. Before the application faces anyone,
set mail_driver: smtp with smtp_host and smtp_port, or a
nucleus-plugin-<driver> (Mail).
modules.accounts
| Key | Default | What it is |
|---|---|---|
base_url | — (required) | The address the browser reaches the application at; the mailed links point there. It is configuration, not the request's Host header, which an attacker chooses. |
from | — (required) | The sender of account mail. |
allow_unverified_login | false | Let an account sign in before its address is confirmed. |
min_password_length | 12 | |
issuer | Nucleus | What an authenticator app shows next to a code. |
mfa_key_env | — | The environment variable holding the key second factors are encrypted with: 32 bytes, base64 (openssl rand -base64 32). Without it second factors are refused; a name whose variable is unset or malformed fails start-up. |
Who a signed-in account is
StartSession records the account's id under SessionKeyAccountID and under
auth.SessionKeySubject, which is what the framework's default-deny layer
reads. A signed-in session is authorised as its account id — the same
subject a bearer token with that user id is, and an API key the account owns
(nucleus apikey create --owner <account id>). A policy row or a role for
the id applies to all three:
g, <account id>, member
p, member, /app/*, *, allow
The session keeps that subject until it ends: disabling an account stops
its next sign-in, not the sessions it already has — Service.RevokeSessions
ends those, as a password reset does.
On the default stack FromRuntime grants the anonymous subject its own
routes — the person registering or signing in has no identity yet. The
routes that need one (a password change, a second factor) check the session
themselves. With csrf_enabled the session-cookie routes are protected like
any other.
Build it yourself: accounts.Module
When the account table is your own, the mail goes through a queue, or the
templates are yours, build the service and mount Module:
store, err := accounts.NewSQLStore(ctx, db, accounts.SQLStoreConfig{
Flavor: accounts.FlavorPostgres,
})
svc, err := accounts.New(store, nil, mailer, templates, accounts.Config{
BaseURL: "https://app.example.com",
From: "no-reply@example.com",
RequireEmailVerification: true,
}, logger)
app := nucleus.New().Mount(accounts.Module(svc))
The module takes the application's own session manager at start-up, so a service built with a different one cannot end up writing into a session the request never carries.
The routes
| Method | Path | What it does |
|---|---|---|
POST | /auth/register | Create an account and send a confirmation link |
GET/POST | /auth/verify-email | Confirm an address with the link's token |
POST | /auth/login | Sign in and start a session |
POST | /auth/logout | End the session |
POST | /auth/password/reset | Send a reset link |
PUT | /auth/password/reset | Set a new password with the link's token |
POST | /auth/password/change | Change the signed-in account's password |
POST | /auth/magic-link | Send a one-time sign-in link |
GET | /auth/magic-link | Sign in with it |
The handlers are thin on purpose. Every decision that matters is in
Service, tested once, instead of in each application's copy of the same
seven handlers.
What the flows decide for you
A registration form does not say whether an address is taken. It answers
202 with the same body either way, and mails the existing owner a
confirmation link instead. An endpoint that answers differently is an
account enumeration oracle — the most-used one on the web. The reset and
magic-link endpoints keep the same property.
A wrong password and an unknown account are indistinguishable, in the error and in the time it takes: an unknown address is still verified against a dummy hash, because answering faster is the same disclosure by another channel.
Tokens are single-use, stored hashed, and scoped to a purpose. The row
holds SHA-256 of the token, so a leaked database does not hand over live
reset links; consuming one is a single conditional UPDATE, so two
concurrent uses of the same link cannot both win; and a token issued to
verify an address cannot reset a password.
A reset ends the account's sessions and drops every other outstanding reset link. A recovery that leaves the attacker signed in has not recovered the account.
Signing in rotates the session token, which is what closes session fixation.
A password change requires the current password — a session left open on a shared machine must not be enough to take the account over — and changes the account in the SESSION, never one named in the body.
One thing to know about magic links
GET /auth/magic-link?token=… consumes the token, because that is what
makes a link work in one click. Mail clients, security scanners and link
previewers fetch URLs they find in mail, and a fetch is indistinguishable
from a click: the token is spent and the person who received it arrives to
an expired link.
The framework cannot tell the two apart, so it does not pretend to. Two things reduce the cost, and both are yours to choose:
- keep the TTL short (15 minutes by default) so a spent link is quickly replaced by asking again;
- if your users are behind a scanner that does this, serve your own page at the link and have it POST the token, so the fetch is harmless and the click is what spends it. The token travels in the JSON body as well as the query string, precisely for that.
Lockout
Failures are counted per identity within a window; past the threshold the
endpoint answers 429 (not 401: a client that retries on 401 would
hammer a locked account), and it answers that way for the correct
password too. A lockout that lets the right password through only slows down
an attacker who is already wrong.
accounts.Config{
LockoutThreshold: 10, // default
LockoutWindow: 15 * time.Minute, // default
}
A successful sign-in clears the counter.
What the failures are counted against has no free answer. Per account is what ASVS asks for and what stops credential stuffing against one user — and it lets anyone lock a known address out for the window by typing ten wrong passwords, which is a denial of service against that person. Per client stops that and lets a botnet spread its guesses.
So the framework does not choose silently. Login counts per account.
LoginFrom folds the caller in, and the mounted module uses it with the
client address: the attacker locks out themselves, the owner still signs in,
and a distributed attack still meets the per-identity rate limiter.
svc.LoginFrom(ctx, email, password, accounts.ClientIP(r))
Where accounts live
Store is the port. NewSQLStore is an implementation with its own three
tables (nucleus_accounts, nucleus_account_tokens,
nucleus_account_failures) for an application that does not have a users
table yet; an application that does implements the same interface against
it.
Whatever implements it must keep three properties, because the flows rely on
them: Create reports a duplicate rather than overwriting, ConsumeToken is
atomic, and RecordFailure returns the count including the failure it just
recorded.
SQLStore.PurgeExpired drops used and expired rows; call it from a
scheduled job. Nothing depends on it for correctness — every read already
filters on time.
Mail
Mailer is one method, so you can hand it the direct sender or a
queue-backed one. Prefer queueing: a confirmation mail that goes out while
the transaction rolls back points at an account that does not exist. See
Queueing mail with the write it announces.
Supply mail.Templates for the wording (verify_email, reset_password,
magic_link); without them the service sends a plain-text fallback, so the
flows work before anybody writes a template.
Second factor
TOTP is implemented against RFC 6238 in about sixty lines of HMAC and a counter, rather than pulled in as a dependency — the framework is stdlib-first, and a dependency that small is a supply-chain entry for every application that links it. The RFC's own test vectors are in the suite, so any authenticator app agrees.
Second factors need a key:
accounts.Config{
Issuer: "Example Co", // what the app shows next to the code
MFAEncryptionKey: key, // exactly 32 bytes
}
Without it, enrolment is refused. A TOTP secret is a password equivalent — anyone holding it mints codes forever — so storing it in the clear would work perfectly and fail once, silently, in a database backup. Secrets are sealed with AES-256-GCM and a per-secret nonce.
The routes
| Method | Path | What it does |
|---|---|---|
POST | /auth/mfa/totp | Start enrolment: returns the secret and the otpauth:// URI to show as a QR code |
PUT | /auth/mfa/totp | Confirm with the first code; returns the recovery codes |
DELETE | /auth/mfa/totp | Remove the factor and its recovery codes |
POST | /auth/mfa/verify | Finish a sign-in that stopped at the password |
POST | /auth/mfa/recovery-codes | Issue a fresh set, invalidating the old one |
The sign-in becomes two steps
With a confirmed factor, POST /auth/login answers 401 with
{"mfa_required": true} and records a pending sign-in under a different
session key from a finished one — so nothing that reads the signed-in
identity mistakes one for the other. POST /auth/mfa/verify completes it.
401 rather than a 2xx is deliberate: a client that treats any 2xx as "signed in" would act on a half-authenticated session.
Properties worth knowing
- Enrolment is not active until confirmed. A secret nobody has proven they can read must not lock an account out of its own sign-in.
- A one-time password is one-time. The accepted counter step is recorded and a code at or below it is refused, so a code read over someone's shoulder does not work for the rest of its thirty seconds.
- Recovery codes are single-use, hashed, and forgiving about typing — case and dashes are normalised before hashing. They are shown once.
- A wrong code counts towards the lockout. A second factor that can be guessed without limit is a six-digit password.
- TOTP is tried before recovery codes, so a mistyped code does not burn one.
Step-up
Changing a second factor requires a sign-in from the last 15 minutes, not
merely a session that still resolves: a session open for three weeks is
evidence somebody signed in three weeks ago, and nothing about who is at the
keyboard now. Service.RequireFreshAuth(ctx, maxAge) is the same guard for
your own sensitive operations, and MarkAuthenticated stamps a fresh proof.
SessionManager.HasSession(ctx) answers whether a context carries session
data at all — a handler mounted outside the session middleware gets an
error instead of a panic from deep inside the session library.