指南 08
Accounts
本页尚未翻译,因此显示的是英文原文。 在 GitHub 上翻译
本页内容
- The auth starter
- Trying it
- How it works
- Who can see a page
- Finding the request's user
auth.useron every page- Registering
- Verifying an email
- Logging in and out
- Two-factor logins
- Passkeys
- Asking for the password again
- Settings
- Browsers
- API tokens
- A forgotten password, or a lost passkey
- Notifications
- The database
- Throttles
- The sessions
- The cache
- Broadcasting
- Health checks
- The frontend
- Package auth
- Passwords
- Logins
- Back to the page after logging in
- Asking for the password again
- Reset tokens
- Verification tokens
- Registration tokens
- Two-factor logins
- Passkeys
- Throttle
- Access tokens
- Abilities
- Package mail
- Copies, replies and files
- Unsubscribing in one click
- Testing
- Before going live
- What the starter leaves out
tug new -auth makes an app with accounts, complete enough to ship: people
register and verify their email, log in, with a code from an authenticator
app too once they turn that on, reset a forgotten password with a link sent
by email, change their profile, email, photo, password and appearance
in their settings, see the browsers they're logged in from and log one
out, make API tokens for a script or another service, or delete their
account. The handlers are the app's own code, to
change as the app does. They stand on package auth, which has the parts
where a slip is a security hole, and package mail, which sends the links.
The frontend is React with Tailwind and shadcn/ui, as Laravel's React
starter kit has it, or with -vue or -svelte, Vue or Svelte with
shadcn's components for them, as Laravel's Vue and Svelte kits have them.
tug new -auth blog
cd blog
tug devThe app is the plain starter (Getting started)
with accounts, and auth.user on every page, error pages included: the
user who's logged in, or null. The users are in SQLite, through
database/sql, or with -postgres or -mysql, in Postgres or MySQL,
which the app's compose.yaml runs in development:
tug new -auth -postgres blog
cd blog
docker compose up -d
tug devThe database is chosen as the app is made, as laravel new asks, and
the app's SQL is written for it: there's no layer between them, and no
variable to switch it later. The drivers are pure Go, modernc.org/sqlite,
pgx and go-sql-driver/mysql, so tug build still makes a static binary.
The database has what differs between them.
| Route | Name | For |
|---|---|---|
GET / |
home |
everyone |
GET /up |
health checks | |
GET /files/{key...} |
anyone with a signed link | |
GET /dashboard |
dashboard |
verified users |
GET, POST /register |
register, register.store |
guests |
POST /register/passkey |
register.passkey |
guests |
GET, POST /register/passkey/{token} |
register.passkey.create, register.passkey.store |
guests with the link |
POST /register/passkey/{token}/options |
register.passkey.options |
guests with the link |
GET, POST /login |
login, login.store |
guests |
GET, POST /two-factor-challenge |
two-factor.login, two-factor.login.store |
a login waiting for its code |
POST /logout |
logout |
users |
GET, POST /forgot-password |
password.request, password.email |
guests |
GET /reset-password/{token} |
password.reset |
guests |
POST /reset-password |
password.store |
guests |
GET, POST /confirm-password |
password.confirm, password.confirm.store |
users |
GET, POST /verify-email |
verification.notice, verification.send |
users |
GET /verify-email/{id}/{token} |
verification.verify |
anyone with the link |
GET /broadcasts |
broadcasts |
users, their own events |
GET /notifications |
notifications.index |
users |
GET /settings |
settings |
goes to the profile |
GET, PATCH, DELETE /settings/profile |
profile.edit, profile.update, profile.destroy |
users, password confirmed |
POST, DELETE /settings/profile/photo |
profile.photo.update, profile.photo.destroy |
users, password confirmed |
GET /settings/security |
security.edit |
verified users, password confirmed |
PUT, DELETE /settings/password |
user-password.update, user-password.destroy |
verified users, password confirmed |
POST, DELETE /settings/two-factor |
two-factor.enable, two-factor.disable |
verified users, password confirmed |
POST /settings/two-factor/confirm |
two-factor.confirm |
verified users, password confirmed |
POST /settings/two-factor/recovery-codes |
two-factor.recovery-codes |
verified users, password confirmed |
DELETE /settings/logins/{id}, /settings/logins |
logins.destroy, logins.destroy-others |
verified users, password confirmed |
GET /settings/appearance |
appearance.edit |
users |
The names are Laravel's. A guest on a route for users is sent to log in, and comes back after; someone logged in on a route for guests goes to the dashboard.
-auth lays the auth starter over the plain one: its files replace the
plain starter's of the same name and add the rest, and the plain
Layout.tsx (Layout.vue, Layout.svelte) is left out. Then its
database's files: its SQL, which is all the Go that differs between
them.
main.go: the environment, the database, andnewApp, with the routes and the health check.auth.go: who's logged in, the wrappers that guard routes, registering, logging in and out, a forgotten password, and asking for the password again.verify.go: verifying an email.twofactor.go: two-factor logins.passkeys.go: passkeys, and registering with one.settings.go: the settings pages.photos.go: a user's photo, on the app's disk.tokens.go: API tokens.broadcasts.go: the events a user's pages follow.notifications.go: what happened to an account, in the app and by mail.logins.go: the browsers a user is logged in from, and logging one out.mail.go: the mail the app sends.jobs.go: the background jobs, in tables of their own, for packagequeue, which runs them beside the server, and thejobscommand, which lists the ones that failed for good and runs them again. The mail goes by jobs, and a job every night deletes the ones that failed over a month ago.users.go:User, and theuserstable. An email is unique whatever its case.- The database's:
db.go, which opens it, from the environment;migrations/, the migrations that make its tables, a file of SQL each, whichmigrations.goruns as the app starts, and has themigratecommand run;users_db.go,passkeys_db.go,jobs_db.go,throttles_db.go,cache_db.go,sessions_db.go,tokens_db.go,broadcasts_db.go,notifications_db.go,logins_db.goandmigrations_db.go, the SQL of each;db_test.go, the tests' databases; and on Postgres or MySQL,compose.yaml, which runs the database in development. main_test.go,auth_test.go,settings_test.go,twofactor_test.go,passkeys_test.go,passwordless_test.go,photos_test.go,jobs_test.go,throttles_test.go,cache_test.go,tokens_test.go,broadcasts_test.go,notifications_test.go,logins_test.go,migrations_test.go: a test of each flow, in browsers of packagetugtest, with the mail kept in memory, the photos in a temporary directory, and a database of each test's own.resources/js:inertia.tsx, which makes the app and picks each page's layout;layouts/, the app's, the login card's, and the settings';components/, the app's own and shadcn's incomponents/ui; and the pages,Home,DashboardandError, and those inAuth/andSettings/..env.example:APP_KEY,APP_URL,TRUSTED_PROXIES, the database'sDB_PATH, orDB_URL,QUEUE_WORKERS, the photos'FILESYSTEM_DISK,FILES_PATHand S3's variables, and the mail's, with what each is for. TheDockerfilekeeps the photos in a/datavolume, and SQLite's database with them.AGENTS.md, for a coding agent, whichCLAUDE.mdhands Claude Code: the plain starter's, with what an app with accounts adds to it, a route for users behindusersOnly, a table, its migration and its SQL, a handler's writes and their jobs in one transaction, and a test as a verified user.
tug new writes a .env with APP_KEY and APP_DEBUG=true, and on
Postgres or MySQL, DB_URL: the database compose.yaml runs, which
docker compose up -d starts. tug dev gives the app APP_URL, the
address it shows it at, so its links lead there; with no MAIL_HOST,
mail is kept in the app's mailbox rather than sent. Register, and
tug dev's output has a line for the mail that verifies the email, with
its link:
app │ mail, kept in the mailbox (MAIL_HOST isn't set): "Verify your email for blog" to ann@example.com: http://localhost:8080/_tug/mail/01M3XF66NHF91FYEN5EC3KZV84The mailbox, at /_tug/mail, lists the mail the app sent, and shows each
as a mail program would, its button opening the link that verifies the
email in a tab of its own, logged in as the browser is, and its text with
the link in it too. A reset link comes the same way. To turn two-factor logins on, go to
Settings, then Security, and scan the QR code with an authenticator app on
a phone; to add a passkey, "Add a passkey" there, and the browser offers
the laptop's, the phone's, or a password manager. Passkeys work at
http://localhost:8080, where tug dev shows the app: browsers make them
for a domain, not an IP address such as 127.0.0.1. SQLite's database is
app.db, or wherever DB_PATH says, and .gitignore leaves it out.
app.Get("/dashboard", a.usersOnly(verified(dashboard))).Name("dashboard")
app.Get("/settings/security", a.usersOnly(verified(passwordConfirmed(a.securityPage)))).Name("security.edit")dashboard takes the user as well as the Ctx:
func dashboard(c *tug.Ctx, user *User) error, a userHandler.
usersOnly finds the request's user and hands it over, or sends a guest to
log in, keeping the page with auth.SetIntended for after. Routes take a
tug.HandlerFunc, and only usersOnly makes one of a userHandler, so a
handler that takes a user can't be routed without it: forgetting it
doesn't compile. That's why it's a wrapper rather than middleware, and why
the user is an argument rather than something to dig out of the request's
context.
Between them, a userHandler can be wrapped in more:
verifiedsends a user whose email isn't verified to the page that asks them to verify it. The dashboard, and the security settings, have it. The profile doesn't, so that someone who typed their email wrong can put it right.passwordConfirmedsends a user who last typed their password over three hours ago to type it again, and back to the page after. It guards the settings that do the most harm in the hands of whoever finds a browser left logged in: the email, since who has it can reset the password; the password; two-factor logins; and deleting the account. Logging in counts as typing it, so right after, the settings open straight away.
guestsOnly wraps the pages for guests, and sends someone who's logged in
to the dashboard instead, even from a reset link.
func (a *app) user(r *http.Request) (*User, error) {
s := session.From(r.Context())
id, ok := auth.UserID(s)
if !ok {
return nil, nil
}
n, err := strconv.ParseInt(id, 10, 64)
if err != nil {
auth.Logout(s)
return nil, nil
}
u, err := a.users.byID(r.Context(), n)
if errors.Is(err, errNoUser) || err == nil && !auth.Current(s, u.PasswordHash) {
auth.Logout(s)
return nil, nil
}
if err != nil {
return nil, err
}
if ok, err := a.stillLoggedIn(r.Context(), s, u); err != nil || !ok {
if err == nil {
auth.Logout(s)
}
return nil, err
}
return u, nil
}auth.UserID reads the ID the session keeps, the user is loaded,
auth.Current checks that the login was made with the password the user
has now, and stillLoggedIn that the login still has its row, which
logging it out from another browser deletes (Browsers). A
user who's gone, or who has set a new password since, or whose login was
ended, is logged out there and then, and the request goes on as a
guest's. When the database fails, there's no user and an error:
usersOnly answers it with a 500, and shareAuth logs it and shows the
page as a guest's.
pages.Share("auth", Auth{})
pages.ShareFunc(a.shareAuth)Auth is a struct with one field, User *User, tagged json:"user", and
shareAuth returns inertia.Props{"auth": Auth{User: u}} with u from
user. ShareFunc works it out for each page as it's rendered, error
pages included: the ErrorHandler renders a 404 outside any usersOnly, and
its layout shows who's logged in too. Share("auth", Auth{}) is a guest's
value, which ShareFunc's replaces, and it's how tug gen learns the
prop's type, since it can't see what a function returns. In
resources/js/tug/pages.ts, SharedProps has auth: Auth, and Auth is
{ user: User | null }, so usePage().props.auth.user is typed in every
component, as in layouts/app-layout.tsx.
User has emailVerifiedAt, null until the email is verified, and
twoFactor, whether logging in takes a code. Its password hash, two-factor
secret and recovery codes are tagged json:"-", so they never reach a
page. shareAuth shares bell beside auth, the notifications the
user hasn't read (Notifications).
Pages has more on shared props, and
TypeScript on the types.
register binds RegisterInput with BindValid. Its tags ask for a
name, an email, and a password of 8 to 200 characters, and two checks of
the handler's own follow: confirmed, that the password was typed the
same twice, and one that the email has no account. The register page
checks each field with the server as it's left, so a taken email shows
before the form is sent (Forms). users.add stores the user
with auth.HashPassword(in.Password). Its insert does nothing when the
email is taken, so two people registering the same email at once make one
account, and the second gets the same error.
The new user is logged in, with a.logIn, which calls auth.Login and
keeps a row of the login (Browsers), and has just typed their
password, so auth.SetPasswordConfirmed records that. A link to verify
the email is mailed to them, and they go to the dashboard, which, for a
user whose email isn't verified, is the page that asks them to follow the
link.
Where the browser makes passkeys, the register page has "Use a passkey
instead": the form sends the name and the email alone, with no password,
to POST /register/passkey, registerWithPasskey in passkeys.go,
which checks them as register does, the email as it's left too, and
mails a link to the email, by a job, registration-mail. The account is
made from the link, not as the form is sent: one made with a passkey
before its email was shown to be the registrant's would let someone
register another's email, and keep a way into it, their passkey, that the
owner's reset wouldn't end, as a new password ends a registrant's old
one. An email gets one such mail a minute, and an address five asks a
minute, by the registration-asks throttle.
The link, /register/passkey/{token}?email=…&name=…, carries a token
auth.Registrations makes, signed over the email and the name, for a
day, and kept nowhere (Registration tokens). Its
page, Auth/RegisterPasskey, has "Make your passkey": the options, POST /register/passkey/{token}/options, with a new handle, then the browser's
passkey, to POST /register/passkey/{token}, which makes the account,
its email verified by the link, with no password, keeps the passkey,
named for the browser, as "Chrome on macOS", and logs in. A link once its
email has an account says so, and goes to log in, so what a link makes,
it makes once; one changed, or expired, goes back to register.
An account with no password has an empty password hash, which no password
matches: a password login fails for it as for a wrong password, as
slowly, and says nothing of it. Its passkeys log it in, and confirm it's
the user; its security page sets a password, and its owner, having lost
their passkeys, gets back in by a reset link, which sets one too (A
forgotten password, or a lost passkey). auth.user's password
says whether the account has one.
// in a request, at registering, or at a new email
a.verifyMail.Push(c.Context(), VerifyMail{User: u.ID, Email: u.Email})
// in the job, as the mail goes
link, err := a.routes.AbsoluteURL("verification.verify", u.ID, a.verifications.Token(u.authID(), u.Email))The mail goes by a job, which makes the link as it sends it, unless the
user has verified or changed their email since. The link is APP_URL,
then /verify-email/{id}/{token}, with a token from
auth.Verifications: signed for the user's ID and their email as it is,
and good for a day. Following it proves the email reaches its owner, so it
works in any browser, logged in or not, such as the phone the mail was read
on: verifyEmail checks the token against the user's email now, stores
email_verified_at, and goes to the dashboard, by way of the login page
for a guest, where "your email is verified" waits.
The page that asks to verify, open in another tab, or on the computer the
user registered on while they read the mail on their phone, moves on to
the dashboard as the link is followed: verifyEmail publishes verified
on the user's own channel, which the page follows
(Broadcasting, below). A page that doesn't hear it, as
when the database is down, moves on as it's next reloaded.
- A link for an email the user has changed since, or one tampered with, verifies nothing: the user goes to the page that asks them to verify, with a message saying the link has expired and to send another.
- "Send another link", on that page and on the profile, mails a new one,
once a minute at most, and goes back to the page it was pressed on
(
c.RedirectBack()). - A new email, from the profile, isn't verified until its own link is followed; the same email in another case stays verified. A password reset verifies the email too: the link came to it.
key := strings.ToLower(in.Email) + "|" + c.IP()
wait, err := a.logins.Try(c.Context(), key)
if err != nil {
return err
}
if wait > 0 {
return validate.Errors{"email": tooMany(wait)}
}
u, err := a.users.byEmail(c.Context(), in.Email)
if err != nil && !errors.Is(err, errNoUser) {
return err
}
hash := ""
if u != nil {
hash = u.PasswordHash
}
if !auth.CheckPassword(hash, in.Password) {
return validate.Errors{"email": "the email and password don't match an account"}
}
if err := a.logins.Clear(c.Context(), key); err != nil {
return err
}- Five tries a minute for an email from one address, and the next waits
for the minute to be up.
a.loginsis anauth.Throttle, whoseTrycounts a try before the password is checked, so tries sent at the same moment count too; a login that works clears them. The tries are counted in the database, in thethrottlestable, so every instance of the app counts them together, and a restart forgets none: see Throttles. - With no user, the password is checked against no hash, which takes as
long as a real check, and the answer is a wrong password's: neither it
nor its timing says which emails have accounts. A handler can return
validate.Errorsof its own, as here, and the form goes back with them. - After the right password, a hash made with older settings is made again
and stored (
auth.NeedsRehash). - "Remember me" makes the session last a month without a visit, with
s.SetLifetime(rememberFor), where the rest last two hours. Each visit starts the month again. The box sends"on"when it's ticked, whichBindreads astrueforLoginInput.Remember, abool, as it reads a form's value (Routing). - A user with two-factor logins on goes on to give a code (below).
Everyone else is logged in with
a.logIn, which callsauth.Login, keeps a row of the login, and tells the user of a login from a browser the account hasn't logged in from before (Browsers), and goes to the pageauth.Intendedkept, or the dashboard.
logout deletes the login's row, then calls auth.Logout, which empties
the whole session, not only the login, and c.ClearHistory(), which has
the client throw away the pages it kept for Back (see
Before going live), and goes home with a flash
message. It's a POST, from a
<Link method="post" as="button"> in the user menu, so
middleware.CSRF() turns it away from another site.
Once a user turns them on, logging in takes a code from an authenticator
app on their phone as well as the password. After the right password,
login holds the login back, with auth.StartTwoFactor, and sends them to
the challenge page. Meanwhile the session isn't logged in as anyone: it
keeps the user's ID for ten minutes, and the challenge takes either a code
or one of the user's recovery codes.
secret, err := a.twoFactor.Open(u.TwoFactorSecret)
if err != nil {
return false, err
}
step, ok := a.twoFactor.Check(secret, code, u.TwoFactorStep)
if !ok {
return false, nil
}
return a.users.setTwoFactorStep(ctx, u.ID, step)- A code works once.
Checktakes the time step of the code that last worked,two_factor_step, and takes no code from then or before; the update stores the new step only when it's later than the one stored, so of two logins with the same code at the same moment, one gets in. A code seen over the user's shoulder is no use. - Five tries a minute for a login, counted before the code is checked, as the password's are: a million codes can't be tried one by one.
- A recovery code works once too, whatever its case, and with or without its dash. Using one stores the codes without it, only if no one else has changed them since.
- A password reset doesn't let a user with two-factor logins on past the code: the link in their mail is one factor, not two, so after the reset they go to the challenge, and log in once they give a code.
Turning them on is in the security settings. "Turn on" makes a secret,
which the session keeps while the user sets their app up, and the page
shows it as a QR code of its otpauth:// URL, and as text to type in. The
user types the code the app shows, which shows it's set up, and only then
does the database get the secret, sealed, and eight new recovery codes,
sealed too. The page shows the codes once, from the flash, for the user
to keep somewhere safe; "Show my recovery codes" fetches them again with a
partial reload of recoveryCodes, an inertia.Optional prop that no other
visit carries, and "Make new codes" replaces them. "Turn off" clears the
secret and the codes. All of it is behind passwordConfirmed.
The secret and the codes are sealed with auth.TwoFactor: encrypted with a
key derived from APP_KEY, so a copy of the database, such as a leaked
backup, doesn't give away the second factor along with the first. After
APP_KEY changes, with the old key in APP_PREVIOUS_KEYS, newApp seals
every user's again with the new one as the app starts (resealTwoFactor),
so the old key can go without taking anyone's second factor with it.
They're sealed as package crypt seals the app's own values, with a key
of their own (Encryption).
A passkey logs in with no email and no password: the user's phone, laptop
or password manager keeps it, and unlocks it with their PIN, fingerprint
or face. That's two factors, the device and what unlocks it, so a login
with a passkey asks for no code from the phone, and counts as the
password typed for passwordConfirmed. The handlers are in passkeys.go,
on auth.Passkeys, which checks what the browser answers.
- Adding one is on the security page, behind
passwordConfirmed: a name for it, then the browser asks where to keep it. The page fetches the options,POST /settings/passkeys/options, which the user's first passkey makes a handle for, a random one the authenticator keeps, never their ID or email; the browser makes the passkey, and the page posts its answer toPOST /settings/passkeys, which keeps it in thepasskeystable, and mails the user, "A passkey was added to your account", as a new way in is one its owner should hear of. An account has ten at most: confirming it's the user with one keeps the IDs of all of theirs in the session, which has to fit in its cookie. - Logging in with one is the login page's "Log in with a passkey", and
the email field's autofill, which offers the user's passkeys as it's
focused, in the browsers that do:
autocomplete="username webauthn", and a request that waits in the background. The passkey says whose login it is. Five tries a minute from an address, as with passwords; "Remember me" counts too. - Confirming it's them takes a passkey as well as the password: the page that asks has "Use a passkey", for a user who has one, and nothing else for one with no password.
- Registering with one, in place of a password, starts on the register page: With a passkey.
- Removing one is its bin on the security page, but the last of an account with no password: "Add a password or another passkey first: an account needs a way in." Deleting the account deletes its passkeys.
The browser's side is resources/js/lib/passkeys.ts: the options, as
JSON, into what navigator.credentials takes, and its answer back into
JSON. A passkey is for the site's domain, from APP_URL, as the links in
mail are: under tug dev, localhost.
passwordConfirmed asks for the password when the user last typed it more
than confirmFor, three hours, ago: at logging in, registering, a reset,
a change of password, or on the page that asks for it,
Auth/ConfirmPassword. That page's form checks the password, five tries a
minute, with auth.SetPasswordConfirmed after the right one, and goes back
to the page the user was on their way to. For an account with no password,
the page asks for a passkey alone. A form sent after the three
hours ran out, such as the profile's left open all afternoon, can't be
sent again for them: they go back to the page it was on, which
auth.SetIntended keeps from the form's Referer, and send it again.
- Profile: the name and email. A new email is mailed a link, and isn't verified until it's followed; one another account has is turned away.
- Photo: a PNG, JPEG or WebP of 2 MB at most, told by its bytes, which
the user menu shows in place of the initials, uploaded with its progress
shown.
photos.gokeeps it on the app's disk with packagestorage:files/, orFILES_PATH, or withFILESYSTEM_DISK=s3, a bucket. The photos are private:a.usergives each page's user a link to theirs, signed until the end of the next day, which is the same link all day so the browser keeps it cached, and served at/filesby that link alone. A new photo replaces the old, and removing it brings the initials back; the file that nothing names any more is deleted by a job,delete-file, pushed in the transaction that drops it. See Files. - Delete your account: a dialog that takes the password once more, since it can't be undone, or for an account with no password, a passkey, whose answer the form sends, checked as confirming with one is. The row goes, and its photo with it, this browser is logged out and its history cleared, and the user's other sessions end at their next request, which finds no user.
- Security: a new password, which takes the current one, or for an
account made with a passkey, a first one, which takes none. The new hash
ends every login made with the old one, and this browser logs in again
with it. An account with a passkey removes its password,
DELETE /settings/password, which ends the other logins as a new one does; without a passkey, the password is its way in, and stays. Then passkeys and two-factor logins, above, and the browsers the account is logged in from, each to log out, as one left on a lost laptop: Browsers. - API tokens: tokens for a script, the user's phone's app or another service, which call the app's API in place of a login, made, listed and revoked: API tokens.
- Appearance: light, dark, or as the system is. It's the browser's
choice, kept in its
localStorage, rather than the account's: the root template's script applies it before the page paints, so there's no flash of the wrong one, andhooks/use-appearance.tskeeps it from then on, as the system's changes too.
Password checks in the settings, as in the page that asks for it again,
count five tries a minute for a user (a.passwords), so whoever has
someone's browser can guess no faster than at the login page.
The security page lists the browsers the account is logged in from, this
one first: which browser, on which system, as its User-Agent says,
"Chrome on macOS", the address it logged in from, and when it logged in
and was last seen. Each of the others logs out by its button, and "Log
out of every other browser" logs them all out, with no new password;
they're behind passwordConfirmed, as the page is.
auth.Login(s, u.authID(), u.PasswordHash)
login, _ := auth.LoginID(s)
browser := a.browserID(c)
return a.inTx(c.Context(), func(tx *sql.Tx) error {
bs := a.browsers.in(tx)
if err := bs.add(c.Context(), u.ID, hashOf(login), userAgent, c.IP(), s.Lifetime(), now); err != nil {
return err
}
known, err := bs.known(c.Context(), u.ID, hashOf(browser))
if err != nil {
return err
}
if err := bs.keepBrowser(c.Context(), u.ID, hashOf(browser), now); err != nil {
return err
}
if known || !tell {
return nil
}
return a.notify(c.Context(), tx, u.ID, "new-login", noticeData{Browser: browserName(userAgent), IP: c.IP()})
})A login lives in its browser's cookie, which the app can't take back, so
it keeps a row of each in the logins table. Every way in, a password, a
code, a passkey, registering and a new password, logs in through
a.logIn, in logins.go: auth.Login gives the login an ID of its own,
random, which auth.LoginID reads, and the row keeps its SHA-256, as an
API token's is kept, with the browser's User-Agent, its first 512 bytes,
the address, c.IP()'s, and the session's lifetime. a.user reads the
row at each request, and a session whose login has none is logged out
(Finding the request's user): a browser
logged out from another finds its row gone at its next request, and goes
to the login page.
- A login ends as its session does: two hours after its last
request, or 30 days with "Remember me". Its row's last sight is written
as it's seen, once a minute at most, as an API token's last use is,
which moves its end on; the page lists the logins that haven't ended,
and
prune-loginsdeletes the rest every hour. - Logging out deletes the login's row. A new password, in the settings or by a reset, and a hash made again with newer settings, delete every row of the account's, as the new hash ends every login, and the browser that set it logs in again. Deleting the account deletes its rows.
- A session from before the app kept its logins has no ID, and no row, and logs in again, once.
- A login from a new browser is a notification, in the bell and by
mail: "A new login, from Chrome on macOS". A browser is told apart by a
cookie of its own,
tug_browser, a random ID it keeps for a year, logged in or out, and thebrowserstable keeps the hash of each one an account has logged in from in the last year: a browser with no such cookie, or one the account hasn't had, is new, and the same browser on another address isn't, as a phone's changes all day. Registering, a reset and a new password are no logins to tell of: the account's own mail has just come. The notification is kept in the login's transaction, as the others are in theirs (Notifications), so a login that can't be told of isn't made, and its browser gets an error. - The browser's name is
browserName's: a few lines that tell Chrome, Edge, Firefox, Safari and Opera apart, on macOS, Windows, iOS, Android, ChromeOS and Linux, and say "a browser on Linux", or "a browser", of one they don't know, as a User-Agent can say anything. - An event stream, as the bell's, of a browser logged out from another stays open until it connects again, but each reload an event starts is a request, which finds no login.
A user makes tokens on the settings' API tokens page, for a script, their
phone's app or another service, sees when each was last used, and revokes
them. A request to the app's API, /api, sends one in place of a login:
curl -H "Authorization: Bearer blog_…" -H "Accept: application/json" https://example.com/api/user- A token is the app's name, an underscore and 52 random letters and
digits, made by
auth.AccessTokens(Access tokens). The page shows it once, in the flash, as the recovery codes are shown, and theaccess_tokenstable, whose SQL is intokens_db.go, keeps its SHA-256 alone, with whose it is, its name, what it may do and when it expires: a copy of the database has no token that works. - What a token may do, its abilities, are the page's checkboxes, from
abilitiesintokens.go:user:read, which/api/userasks for. An app's API adds its own, asposts:write, which its routes ask for. - A token expires in 30 days, a year, or never, as its user chooses. Its last use is kept at most once a minute, so an API's every request isn't a write.
a.tokenUsers(ability, h)makes a route of the API, asapi.Get("/user", a.tokenUsers("user:read", apiUser)). A request without a token, or with one that isn't the app's, has expired or was revoked, is a 401, withWWW-Authenticate: Bearer, and one whose token may not doabilityis a 403. The session's cookie counts for nothing there, so a page of another site can't borrow a login: the app's CSRF check lets/api/through,middleware.CSRF("/api/"), and CORS lets the pages of the sitesCORS_ORIGINSnames call it (Package middleware).- Each token has 60 requests a minute, counted in the
throttlestable by the token's ID, as the app's other throttles count, with a 429 andRetry-Afterpast them. - The page takes the password confirmed, as the security settings do. A new password leaves the tokens be, as GitHub's are: they're the user's to revoke. Deleting the account deletes them.
wait, err := a.resetAsks.Try(c.Context(), c.IP())
if err == nil && wait == 0 {
wait, err = a.mails.Try(c.Context(), "reset|"+strings.ToLower(in.Email))
}
if err != nil {
return err
}
if wait == 0 {
if err := a.resetMail.Push(c.Context(), ResetMail{Email: in.Email}); err != nil {
return err
}
}
c.Flash("success", "If that email has an account, a link to reset its password is on its way.")
return c.RedirectRoute("password.request")- The answer is the same for any email, and so is the work behind it: the
request pushes a
ResetMailjob whatever the email, and the job looks it up. How long the form takes to answer can't say who has an account. - An email gets one mail a minute at most, so no one's inbox can be
flooded from the form:
a.mailsis aThrottlewith aMaxof 1, for reset links by email and verification links by user. Each ask is a job in the database, so an address has five a minute, ina.resetAsks. - The job,
mailReset, finds the account, makes the token from its password hash as it is then, and sends the mail, so no token waits in the database. A mail that doesn't go runs again later. - The link is
APP_URL, then the path ofpassword.resetwith a token fromauth.Resets, then the email in the query.
The links are never made from the request's Host, which is whatever
the request says: someone could ask for a reset of your email with their
own site in it, and the mail you get would link there, with a working
token. So newApp won't start without APP_URL, which tug dev sets in
development.
The link opens Auth/ResetPassword with the token and the email as props,
and the form sends them back with the new password. The token is checked
against the user's password hash as it is now. The new hash ends the
token, which was made for the old one, and every other session logged in
with the old password. The email is verified, as the link came to it, and
this browser logs in with the new password, or, with two-factor logins on,
goes on to give a code.
The page, "Forgotten your password, or lost your passkey?", is for an account with no password too, whose owner has lost their passkeys. Its mail says "Set a password", and the link, made from the empty hash, sets a first one, which the account goes on with, its passkeys kept, and which ends the link as a new password does. Two-factor logins ask for their code after it, as after any reset: for an account with no password, they guard the link that would set one.
mail.go has the app's mail: a message with a greeting, what it's
about, a link, and what to do if it wasn't the user. message.mail()
writes it as plain text, and as HTML with the link as a button, from an
html/template with its style inline, as mail programs take it, and with
what the user typed, such as their name, escaped.
The mail goes by jobs, which jobs.go keeps in the database: a
VerifyMail or a ResetMail names the user, or the email, and the job
makes the mail, link and token, as it runs, the link from APP_URL with
a.routes.AbsoluteURL. So a mail server that's down, or the app
restarting, loses no mail: a job that fails runs again, 10 times over
about four hours, and then stays in the table as failed, with its error,
for a month: a scheduled job, prune-jobs, deletes older ones every
night. main runs the jobs beside the server with app.Go.
jobs.md has the queue.
A mail about what a handler writes is pushed in the same transaction:
the account and the mail that verifies its email, a new email and its
link, and a change to the account and its notification's mail
(Notifications, below). a.inTx runs the
handler's writes in one transaction, with the stores made from it, as
a.users.in(tx), and the jobs pushed with Kind.In(a.jobs.in(tx)),
commits it, and wakes the queue: the two are kept together, or neither
is, so no account waits for a link that was never pushed, and no passkey
is added that its owner isn't told of. A transaction holds what it
writes until it ends, and in SQLite, which has one writer, the lock for
writing from its start, so what the handler writes goes through tx,
and slow work, such as hashing a password, comes before it.
The binary has a command, which newApp adds with app.Command, and
which runs in place of the server, on the same database, and exits:
./blog jobs lists the jobs that failed for good, with their errors, and
./blog jobs retry 42, or retry all, runs them again. In the image,
it's docker exec <container> /server jobs. ./blog help lists the
commands, for an app that adds more.
What happens to an account, of the kind that could hand it to someone else, its owner hears of, in the app and by mail: a new password, set in the settings or by a reset link, a first one, of an account made with a passkey, and a password removed; a new email, told at the old one, the address its owner may still read, as the new one gets its link to verify; two-factor logins turned off; a passkey added; an API token made; and a login from a browser the account hasn't logged in from before (Browsers).
err := a.inTx(c.Context(), func(tx *sql.Tx) error {
if err := a.users.in(tx).setPassword(c.Context(), user.ID, hash); err != nil {
return err
}
return a.notify(c.Context(), tx, user.ID, "password-changed", noticeData{})
})a.notify keeps a notification in the handler's transaction, in the
notifications table, with its kind and its data as JSON; publishes
notification on the user's channel (Broadcasting);
and pushes the job that mails it. As the transaction commits, the three
happen together, and as it rolls back, none does: a change that wasn't
made is never heard of.
- The header's bell counts the notifications the user hasn't read, a
prop every page shares,
bell.unread, and counts a new one at once, as the layout follows the user's channel and reloads it. The reload is a visit, and empties the page's flash, so what a page shows from its flash it keeps until it's left, as the new token and the recovery codes are: a page of the app's that shows its flash in place, not as a toast, does the same. /notificationslists them, the newest first, 20 a page, and marks the ones it shows read, with a dot on those new to it; one that comes while it's open shows at once.- A notification's line is made as it's read, from its kind and data,
in the reader's language, with
c.T: a notification outlasts the language it was made in. Its mail, made as its job runs, goes in the app's words, as the rest of its mail does. notices, innotifications.go, has the kinds: each one's line, the page it leads to, and its mail. Add the app's own there, and calla.notifyin the transaction that makes the change. A kind a version of the app no longer has still shows, with a line of its own, and is marked read.- A notification read over 90 days ago goes, as a scheduled job,
prune-notifications, deletes them every night.
A passkey's mail, which was a job of its own, is now its notification's.
db.go opens the database the environment names, DB_PATH for SQLite
and DB_URL for the others, and migrations/ makes its tables, a file of
SQL for each, with all its columns, named for when it was made, run once,
in order, as the app starts (Migrations):
migrations/20260925171438_create_users.sql
migrations/20260926042744_create_jobs.sql
...
migrations/20261002053505_create_browsers.sqlTo change the tables, add a migration, tug migrate new add_bio_to_users,
and never change one that has run somewhere: the migrations table keeps a
hash of each as it ran, and one changed since stops the next start. A
migration that ran but that the files haven't, a newer version's, as
after a deploy is rolled back, is left as it is. The migrations table,
whose SQL is in migrations_db.go, keeps which have run, and instances
starting at once take turns, each database in its own way:
- SQLite runs each migration in a transaction with its record, which
takes the lock for writing as it begins,
_txlock=immediate, and looks there again whether it has run, as another instance's may have. - Postgres runs each in a transaction with its record too, and instances take turns under an advisory lock, held on one connection while one runs them.
- MySQL commits a statement that changes a table as it runs, not with
the rest of a transaction, so a migration runs a statement at a time,
each whole or not at all, as MySQL 8 does them, and its row keeps the
one under way: a migration the app stopped in, or whose later statement
failed, stops the next start, saying which, for someone to see what
ran, rather than guess. Laravel's migrations have the same gap on MySQL.
An instance holds
GET_LOCKon one connection while it runs them.
The SQL of each table is in a file of its own, users_db.go,
passkeys_db.go, jobs_db.go, throttles_db.go, cache_db.go,
sessions_db.go, tokens_db.go, broadcasts_db.go,
notifications_db.go,
logins_db.go and migrations_db.go, beside the Go that's the same on any database, and
it's written for its database, where they differ:
| SQLite | Postgres | MySQL | |
|---|---|---|---|
| An email, whatever its case | a column COLLATE NOCASE |
a unique index on lower(email), which the queries compare with |
a column COLLATE utf8mb4_0900_as_ci |
| The row a write made | RETURNING |
RETURNING |
read back, by the result's LastInsertId |
| A write a unique index turns away | ON CONFLICT DO NOTHING, UPDATE OR IGNORE |
ON CONFLICT DO NOTHING, or the error, which ends the transaction |
the error, 1062, after which a transaction goes on |
| Times | DATETIME, in UTC |
timestamptz |
DATETIME(6), in UTC, the connection's time zone |
| Placeholders | ? |
$1 |
? |
MySQL's email compares with utf8mb4_0900_as_ci: whatever its case, as
SQLite's NOCASE, but not whatever its accents, as MySQL's default,
utf8mb4_0900_ai_ci, would, which makes ann@example.com and
ánn@example.com one account. Its other strings compare as they are,
with utf8mb4_0900_bin. Its connections read what's committed at each
statement, as Postgres's do, rather than what was as the transaction
began: READ COMMITTED, which the driver sets as each connects, with
the time zone. And MySQL sets an UPDATE's columns in order, each from
the row as the ones before it left it, so the one that reads the old
email comes first.
The starter is tested on Postgres 18 and MySQL 8.4, the older of MySQL's two long-term releases, 9.7 being the newer. MariaDB isn't: its collations aren't MySQL's, and an app on it picks its email column's.
An app that moves to another database takes that database's files from
an app tug new makes on it: db.go, the _db.go files, db_test.go,
compose.yaml and migrations/, with its own migrations written again
in that database's SQL. go mod tidy adds the driver, the app
is given DB_URL, or DB_PATH, where it runs, and the data it moves
itself.
The starter's six throttles count in the database, in a throttles
table, whose SQL is in throttles_db.go: every instance of the app
counts the tries made at any of them, and a restart, as each deploy is,
forgets none.
counts := &throttles{db: e.DB}
a := &app{
logins: &auth.Throttle{Name: "logins", Max: 5, Window: time.Minute, Store: counts},
...
}| Throttle | Counts | By |
|---|---|---|
logins |
logins with a password, and with a passkey, five a minute | the email and the address, or the address |
passwords |
the password, or a passkey, of someone logged in, as they confirm it's them, change it or delete their account, five a minute | the user |
codes |
the codes of a login waiting for its second factor, five a minute | the user |
mails |
reset links, links to register with a passkey, and verification links, one a minute | the email, or the user |
reset-asks |
asks for a reset link, five a minute | the address |
registration-asks |
asks for a link to register with a passkey, five a minute | the address |
A row is a key's tries, and when their window ends. The key is the
SHA-256 package auth makes of the throttle's name and what it counts
by, so the table keeps no email or address, and the passwords and
codes of one user count apart. A try is one statement, an upsert that
counts one more, or starts a new window where the last is over, and reads
back the count: of tries at once, from any of the instances, each gets a
count of its own, and five get in. MySQL has no RETURNING, so there
it's a transaction that reads back what the upsert wrote. Every hour, a
scheduled job, prune-throttles, deletes the rows whose window is over.
A throttle that can't count, as when the database is down, has an error for the handler to return, as any of the database's: the login is a 500, rather than let in unthrottled, or turned away as though it had tried too often.
A session travels in its cookie, as tug keeps them, and
SESSION_STORE=database keeps the sessions themselves in a sessions
table instead, whose SQL is in sessions_db.go, for an app whose sessions
hold more than the 4 KB a cookie takes: the cookie then carries the
session's ID alone. Moving to the table logs nobody out, and moving back
logs everybody out. Every hour a scheduled job, prune-sessions, deletes
the rows that have expired. See Kept on the
server.
The starter gives the app a cache, a.cache, for what's slow to work
out, and for locks that hold across its instances: package cache
(Cache). It's in the database too, in a cache table, whose
SQL is in cache_db.go, and every hour a scheduled job, prune-cache,
deletes what has expired. The starter keeps nothing in it itself.
The starter gives the app a hub, a.hub, which carries events to the
pages open on every instance: package broadcast
(Broadcasting). main runs it with app.Go, and its
store is the database, a.broadcasts, whose SQL is in
broadcasts_db.go: Postgres's NOTIFY, and in SQLite and MySQL a
broadcasts table, which each instance reads four times a second, and
which a scheduled job, prune-broadcasts, rids of what's a minute old,
every minute.
A user's pages follow their own channel, users. and their ID, at
/broadcasts, in broadcasts.go, which only their logins reach, on one
connection a page, which listen, in resources/js/lib/broadcasts.ts,
shares among whatever listens: the page that asks to verify the email
listens for verified, and the layout's bell and the notifications'
page for notification. Each reloads what it shows, as each event
comes, and as the connection is made again, for what it missed: the
verify page's reload goes to the dashboard once the email is verified.
A reload waits while a request of Inertia's is in flight, as a form's
visit loading the page it goes back to: the two at once would each read
the flash the form left, and show it twice.
An event of the app's for the user goes the same way,
a.hub.Publish(ctx, userChannel(user), name, data), and in a handler's
transaction, with what it tells of, through
a.hub.In(a.broadcasts.in(tx)).
GET /up answers 200 up while the app is serving and its database
answers a ping, and a 503 when it doesn't, for a load balancer or a
platform's health check. Like every request, it's in the request log.
resources/js is React with Tailwind and shadcn/ui's components, on
Radix, and lucide's icons. components.json is shadcn's, so
npx shadcn@latest add table adds a component to components/ui that
fits the rest. The theme is in resources/css/app.css: colours as
variables, one set for light and one for dark.
inertia.tsx picks each page's layout by its name:
layouts/auth-layout.tsx, the card around a form, for Auth/...;
layouts/settings-layout.tsx inside layouts/app-layout.tsx for
Settings/...; app-layout for the rest; and none for Home, the
landing page. A page names its card's title with a static layout, as
Login.layout = { title: 'Log in', ... }. The landing page has its title
and description from its handler, home in main.go, with c.Head, so
a search engine, and a link's preview, have them without SSR
(A page's head); the other pages, behind a login,
give theirs with <Head>.
The pages run under the Content-Security-Policy main.go sends
(Security headers): app.html's script,
which sets light or dark before the page paints, carries the page's nonce,
as Vite's tags do, and what the policy blocks, the browser reports to
/csp-reports, which the app logs. Its photos, on a bucket, come from the
bucket's address, which the policy lets images come from.
What a handler flashes with Success.Set(c, ...) or Failure.Set(c, ...),
the keys success and error that flash.go declares with tug.Flash,
shows as a toast, with sonner. app.tsx listens for Inertia's flash
event from the start, so the flash that comes with the first page, as
after following a link in the app's mail, shows too. tug gen types the
flash from flash.go, with the recovery codes, RecoveryCodes, and a new
API token, NewToken, as well.
With -vue, resources/js is the same app in Vue: shadcn-vue's
components, on Reka UI, @lucide/vue's icons and vue-sonner's toasts,
and npx shadcn-vue@latest add for more. With -svelte, it's Svelte 5:
shadcn-svelte's components, on Bits UI, in the classic style that
shadcn/ui's have in React, @lucide/svelte's icons and svelte-sonner's
toasts. shadcn-svelte's CLI adds its newer styles unless it's pointed at
the classic registry, so more are added with
COMPONENTS_REGISTRY_URL=https://shadcn-svelte.com/registry npx shadcn-svelte@latest add.
The pages have the same names, props and words in each, but for the
frontend's own name and files where the landing page and the dashboard
say them, so the Go and its tests are the same whichever it is, and
lib/passkeys.ts and app.css are the same files. A Vue page
names its card's title with
defineOptions({ layout: { title: 'Log in', ... } }), and a Svelte page
with export const layout = { title: 'Log in', ... } in its
<script module>. A Svelte page, the landing page too, gives its title
with Head.svelte, as Svelte sets the document's title itself, which
Inertia's head would take away from it. Neither Inertia has a place for the toasts at the root
of the app, as React's withApp is, so app.ts mounts them on their own
beside it, in the browser, where they outlive each page.
Package auth has no idea what a user is: the app finds its users, by ID
or by email, wherever it keeps them. It doesn't import tug. A login lives
in a *session.Session: c.Session() in a handler, and
session.From(r.Context()) anywhere else.
hash := auth.HashPassword(in.Password) // stored in place of the password
if !auth.CheckPassword(user.PasswordHash, in.Password) {
// the wrong password
}
if auth.NeedsRehash(user.PasswordHash) {
user.PasswordHash = auth.HashPassword(in.Password) // and stored
}HashPassword uses argon2id at OWASP's settings, 19 MiB of memory, two
passes and one thread, about 30 ms on a server's core, with a salt of its
own. The hash is a PHC string, the format other argon2id libraries read
and write, such as PHP's password_hash:
$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>. CheckPassword reads one
made with other settings too, and the tests check the reference
implementation's own test vector: users can move over from another app
with their hashes. NeedsRehash is true for a hash made with other
settings than HashPassword's: hash the password again at the next login,
while the app has it. The new hash logs the user out of their other
sessions, as any new hash does.
Given an empty hash, as when no user has the email, CheckPassword checks
against a decoy at HashPassword's settings, which takes as long, and
returns false. A hash that isn't argon2id's, such as bcrypt's, matches no
password, and logs a warning; so does one asking for more than a sensible
setting would, such as over 4 GiB of memory. At most one hash runs per CPU
at once, checks included, and the rest wait their turn: each takes 19 MiB,
and a burst of logins could otherwise take all the memory there is.
auth.Login(c.Session(), strconv.FormatInt(user.ID, 10), user.PasswordHash)
id, ok := auth.UserID(s) // "42", true, on the requests after
if !auth.Current(s, user.PasswordHash) {
auth.Logout(s) // the password has changed since
}
login, ok := auth.LoginID(s) // this login's own ID, to keep where it can be endedLogin(s, id, passwordHash)keeps the ID in the session, undertug.auth.id; undertug.auth.checka fingerprint of the password hash as it's stored: 12 bytes of SHA-256, which tell one hash from another and give nothing away should a cookie ever be read; and undertug.auth.loginan ID of the login's own. A login starts afresh: a password confirmed before it, or a login waiting for its second factor, is forgotten. It panics without a session: set tug'sConfig.Session.UserID(s)returns the ID, a string whatever the app's IDs are, and whether someone is logged in.Current(s, passwordHash)reports whether the login was made with the password hash the user has now. After a new password it's false for every session logged in with the old one, which is how a reset logs out the other browsers.LoginID(s)returns the login's own ID, 26 characters fromcrypto/rand, new at eachLogin, and whether there's one: a session logged in beforeLogingave IDs has none. A cookie can't be taken back, so an app that lists its logins, and ends one from another browser, keeps them by their IDs, or their hashes, and logs out a session whose login it no longer has, as the auth starter does (Browsers).Logout(s)empties the session, flash data and all: whoever uses the browser next shouldn't find anything kept for the last person.
Everything but Login and StartTwoFactor takes a nil session, as in an
app without Config.Session, where no one is logged in.
auth.SetIntended(c.Session(), c.Request()) // before sending a guest to log in
return c.Redirect(auth.Intended(c.Session(), "/dashboard")) // once they haveSetIntended keeps the request's path and query. A form sent while logged
out, or after a confirmed password ran out, can't be sent again for them,
so for a request other than a GET it keeps the page the form was on
instead, by its Referer, when that's a page of this site. Intended
returns it, and forgets it, or the fallback when there's none. It's always
a path on this site: //evil.example, /\evil.example and full URLs give
the fallback.
if !auth.PasswordConfirmed(c.Session(), 3*time.Hour) {
auth.SetIntended(c.Session(), c.Request())
return c.RedirectRoute("password.confirm")
}
// ... and on the page that asks, after auth.CheckPassword:
auth.SetPasswordConfirmed(c.Session())SetPasswordConfirmed records in the session when the user last typed
their password, and PasswordConfirmed(s, d) reports whether that was
within d. Login forgets it, so the app sets it after a login that took
the password too. The time is in the cookie with the rest, so an old copy
of a cookie is only as confirmed as it was when the password was typed.
keys, err := session.KeysFromEnv() // APP_KEY, then APP_PREVIOUS_KEYS
if err != nil {
log.Fatal(err)
}
resets := &auth.Resets{Keys: keys}
token := resets.Token(id, user.PasswordHash) // for the link
ok := resets.Check(token, id, user.PasswordHash) // false: expired, used, or not one of oursA token is kept nowhere. It's the time it expires, and an HMAC-SHA256 over
that, the user's ID and their password hash, with a key for reset tokens
alone derived from the app's: there's no table, and nothing to clean up.
It works once, because resetting the password changes the hash it was made
for, and any other new password ends it too. It expires after Lifetime,
an hour by default. It's safe in a URL's path:
tlwzi8.pIFKYhVaE1CzWqcFH-bjXw is the expiry in base 36, and 16 bytes of
the MAC. The first of Keys signs, and each of them checks: with a new
APP_KEY, and the old one in APP_PREVIOUS_KEYS, links mailed before the
change still work. Token panics when Keys is empty.
verifications := &auth.Verifications{Keys: keys}
token := verifications.Token(id, user.Email) // for the link
ok := verifications.Check(token, id, user.Email) // false: expired, for another email, or not one of oursVerifications makes tokens as Resets does, signed for the user's ID and
their email, and with a key for verification alone, so a reset token is no
good as a verification token, nor the other way round. A token stops
working once the email changes, and after Lifetime, a day by default. It
can be used again until then, which does no harm: the email is verified
already.
registrations := &auth.Registrations{Keys: keys}
token := registrations.Token(email, name) // for the link
ok := registrations.Check(token, email, name) // false: expired, for another email or name, or not one of oursRegistrations makes the tokens of the links that finish a registration
before the account is made, as the starter's with a passkey: signed for
the email and the name, with a key for registration alone, so a
verification token is no good here, nor a registration token there. A
token works for Lifetime, a day by default, and can be used again
until then: once the email has an account, the app makes no other.
tf := &auth.TwoFactor{Keys: keys, Issuer: "Blog"}
secret := tf.NewSecret() // 160 random bits, in base32
url := tf.URL(user.Email, secret) // otpauth://totp/Blog:ann@example.com?issuer=Blog&secret=...
step, ok := tf.Check(secret, code, last) // the code the app shows, and not one used before
sealed := tf.Seal(secret) // for storing; tf.Open(sealed) gets it back
codes := auth.NewRecoveryCodes() // eight, as "k3p9x-m2w7q"
rest, ok := auth.UseRecoveryCode(codes, typed) // store rest: a code works once- The codes are TOTP's, RFC 6238, as authenticator apps make them: six
digits of HMAC-SHA1 over the number of 30-second steps since 1970.
Checktakes the code of the step now, or of the one either side for a phone whose clock is off, but none from the stepafteror before it, and returns the step of the one that matched, for the app to store in its place. The tests check RFC 6238's own vectors. URLis what the QR code holds, which apps scan: theIssuer, the account, and the secret.Sealencrypts a secret, or a user's recovery codes, with AES-256-GCM and a key for two-factor logins alone derived from the first ofKeys;Opendecrypts with any of them.Stalereports whether a value was sealed with a key other than the first: seal it again while the old key is still amongKeys, as the starter does as it starts.Code(secret, t)is the code an app shows att, for an app's tests, which log in as a user with two-factor logins on.NewRecoveryCodesmakes eight codes of ten random letters and digits, 50 bits each.UseRecoveryCodecompares in constant time, ignoring case, spaces and the dash.
auth.StartTwoFactor(s, id) // the password was right; a code comes next
...
id, ok := auth.TwoFactorPending(s) // on the page that asks for the codeStartTwoFactor holds back the login of a user whose password was right:
the session isn't logged in, and keeps the ID for TwoFactorPending for
ten minutes. Login ends the wait, once the code checks out.
site := &auth.Passkeys{RPID: "example.com", Origin: "https://example.com", Name: "Blog"}
// Adding one: the options for navigator.credentials.create, as JSON...
options, err := site.StartRegistration(s, auth.PasskeyUser{Handle: handle, Name: u.Email, DisplayName: u.Name}, kept)
// ...and, from the next request, the browser's answer, as JSON.
pk, err := site.FinishRegistration(s, answer) // keep pk with the user
// Logging in: options for navigator.credentials.get, any passkey of the site's...
options, err = site.StartLogin(s, nil)
// ...and the answer, checked against the passkey the app has with its ID.
pk, err = site.FinishLogin(s, answer, func(id []byte) (auth.Passkey, error) { ... })Passkeys makes the options a browser needs to make a passkey, or log in
with one, keeps their challenge in the session, and checks the answer,
which comes back in the next request. It knows no users: the app keeps
each passkey with its user, as FinishRegistration returns it, and after
each login as FinishLogin returns it, with its new count.
- The options ask for a passkey the authenticator keeps, so a login
needs no email, and unlocks each time, so a passkey is two factors on
its own. A new passkey's user
HandleisNewPasskeyHandle's random bytes, the same for each of the user's passkeys. - A challenge is 32 random bytes, in the session, answered once,
rightly or not, within five minutes.
Loginforgets one it finds. - An answer has to be to the challenge, for the ceremony it was asked
for, from the site's
Originexactly, for itsRPID, with the user there and the device unlocked. A login's signature has to check out with the passkey's key, the passkey has to be the user's the answer names, and its count has to go on from the count kept: one that goes back has been copied. A passkey that syncs, as a password manager's does, counts nothing, and keeps 0. - Keys are ES256 on P-256, Ed25519, or RSA of 2048 bits or more, as COSE keys; a point off its curve, or any other key, is refused. The attestation, which says what made a passkey, isn't checked, as the options ask for none: any make of authenticator will do.
- CBOR, which an authenticator's data is in, is read by a small, strict reader of what WebAuthn writes, fuzzed so that no answer can crash it.
Package auth/passkeytest is a passkey authenticator in software, for
tests: passkeytest.New(origin) makes one, whose Create and Get
answer the options as a browser does, with real keys and real
signatures. Clone makes a copy, which a site that checks counts turns
away; Synced, Unverified, Alg and Origin make the others a test
needs.
logins := &auth.Throttle{Name: "logins", Max: 5, Window: time.Minute, Store: counts}
wait, err := logins.Try(ctx, key)
if err != nil {
return err
}
if wait > 0 {
// too many; try again in wait
}
// ... the try: a wrong password counts
err = logins.Clear(ctx, key) // after one that worksThrottle counts tries by a key, as login above counts them by email
and address. Try counts one and returns 0, until the key has had Max
(default 5) within Window (default a minute, from the first); then it
returns what's left of the window, and the try shouldn't be made. Checking
and counting are one step, so tries sent at the same moment can't all get
in under Max. Wait returns the same wait without counting a try, and
Clear forgets the key, as after a login that succeeds. The zero value is
ready to use; share one by pointer.
Without a Store, the counts are in memory, this process's own, and
start again when it restarts. A ThrottleStore keeps them where every
instance of the app counts, as the starter's table does:
type ThrottleStore interface {
Hit(ctx context.Context, key []byte, now time.Time, window time.Duration) (tries int, ends time.Time, err error)
Tries(ctx context.Context, key []byte, now time.Time) (tries int, ends time.Time, err error)
Clear(ctx context.Context, key []byte) error
}Hitcounts a try and returns the tries in the key's window, this one included, and when the window ends, starting one atnowwhen the key's has ended, all in one step: of tries at once, each gets a count of its own. A try turned away counts too, and changes nothing that shows, as the window's end doesn't move with it.Triesreads a key's count without adding to it, forWait, andClearforgets it.- A key is 32 bytes, the SHA-256 of the throttle's
Nameand the key it counts by: a store keeps no email or address, and two throttles on one store count apart, as the starter'spasswordsandcodesdo, which both count by user. The times are the app's clock, which theThrottlepasses in. - A store's error is
Try's,Wait's andClear's: a try that can't be counted is the caller's to answer, rather than the throttle's to let in, and stop nothing while its store is down, or turn away, and lock everyone out when a database blinks.
Package throttletest checks that a store keeps these promises: a
store's tests run throttletest.TestStore, with a new, empty store for
each, as the starter's do on its table in each database.
A throttle on a route, rather than in a handler, is tug.Limit
(Routing).
tokens := &auth.AccessTokens{Prefix: "blog"}
token, hash := tokens.New() // show token to its user once; keep hash
sent, ok := auth.BearerToken(r) // Authorization: Bearer <token>
hash = tokens.Hash(sent) // nil for what can't be one of the app's
row, err := table.byHash(ctx, hash)
if !auth.Abilities(row.abilities).Can("posts:write") {
// 403
}Newmakes a token for an app's API: thePrefix, letters and digits, as the app's name, an underscore, and 32 random bytes in base32, in lower case, 52 letters and digits that a double click selects whole. A token pasted where it shouldn't be, in a repository or a log, says whose it is, and a scanner of secrets can look for it.Hashis a token's SHA-256, which the app keeps in its place, and finds a token sent by. A token is 32 random bytes, which no one guesses, so a fast hash does, where a password's is argon2id, for a secret people choose. It's nil for what can't be a token of the app's, another prefix, length or letters, which no lookup needs to find.BearerToken(r)reads the token fromAuthorization: Bearer, the scheme in any case.Abilitiesare what a token may do, asposts:write, or*for everything, andCansays whether it may.- Keeping tokens is the app's, as keeping its users is: whose each is, its name, its abilities, when it expires and when it was last used.
auth.NewAbility, auth.Gate and auth.Deny say what a user may do
with a thing, and a no is a 403 that says why: Authorization
has them.
mailer, err := mail.FromEnv()
if err != nil {
log.Fatal(err)
}
err = mailer.Send(ctx, mail.Message{
To: []string{user.Email},
Subject: "Welcome to the blog",
Text: "Hi " + user.Name + ",\n\nThanks for registering.\n",
})A Message has From, To, Subject, Text, and an optional HTML,
sent alongside Text for the mail programs that show it. An address is
Ann Lee <ann@example.com> or the address alone, and From defaults to
the Mailer's. Send refuses a message to nobody, an address that
doesn't parse, and a subject with a line break in it, so a value from a
form can't add a header such as Bcc. A Mailer is anything with
Send(ctx context.Context, m Message) error.
err := mailer.Send(ctx, mail.Message{
To: []string{customer.Email},
Bcc: []string{"accounts@example.com"},
Subject: "Your invoice for September",
Text: "Your invoice is attached.\n",
Attachments: []mail.Attachment{{Name: "invoice-42.pdf", Content: pdf}},
})-
CcandBccare more people it goes to, each written asTois. Everyone who gets the mail seesToandCc.Bccis told to the server alone, and in no header, so no one sees it. An address in more than one of them gets the mail once. -
ReplyTois where a reply goes, in place ofFrom: a contact form's mail goes from the app, and a reply to it, to whoever filled it in.err := mailer.Send(ctx, mail.Message{ To: []string{"support@example.com"}, ReplyTo: []string{in.Email}, Subject: "A question from " + in.Name, Text: in.Question, }) -
Attachmentsare its files, each amail.Attachment: itsName, which the recipient saves it as, itsContentType, or else the type its first bytes say, and itsContent. They go in base64, after the text and the HTML, asmultipart/mixed. A name with more than plain ASCII in it, as one in Vietnamese, is written as RFC 2231 has it, which mail programs read back as it was, and a line break in one can't end its header. A file is in memory, as the whole mail is before it's sent: a job that mails one makes it, or reads it from its disk, as it runs. -
Headersare headers of the app's, such asX-Campaign, which a provider's reports may go by. They can't be the ones the fields set,From,To,Bcc,Subject, aContent-header and the rest, nor have a line break in a value.
Mail sent in bulk, such as a newsletter, says how to stop it, and Gmail
and Yahoo have asked since 2024 that it do so in one click, as RFC 8058
has it: a List-Unsubscribe header with a link, and
List-Unsubscribe-Post, which says the link takes a POST. Unsubscribe
writes both:
link, err := a.routes.SignedURL("unsubscribe", time.Now().AddDate(1, 0, 0), sub.ID)
if err != nil {
return err
}
err = a.mailer.Send(ctx, mail.Message{
To: []string{sub.Email},
Subject: "What's new in September",
Text: news + "\n\nNo more of these: " + link + "\n",
Unsubscribe: link,
})app.Get("/unsubscribe/{id}", tug.Signed(a.unsubscribePage)).Name("unsubscribe")
app.Post("/unsubscribe/{id}", tug.Signed(a.unsubscribe))The mail program shows a button beside the sender, and a click POSTs
List-Unsubscribe=One-Click to the link from the provider's servers,
with no one logged in. A link SignedURL makes is one no one can forge,
and tug.Signed lets only such a link through
(Routing). The POST has no
Origin header, which middleware.CSRF lets through, as it does any
request that isn't a browser's: the signature is what guards the route.
The link in the mail's text, which a person follows, is a GET, to a page
that asks before it unsubscribes, as a GET is also what a link scanner
follows, before anyone reads the mail. Unsubscribe is an https link,
as RFC 8058 asks.
RFC 8058 also asks that the mail's DKIM signature cover both headers.
DKIM is the provider's, which signs what it sends with the domain's key:
the h= of a sent mail's DKIM-Signature lists the headers it covers.
SMTP sends through the server at its Host and Port, logging in with
its Username and Password when Username is set, and its From is who
mail is from when a message doesn't say.
- On port 465 the connection is TLS from the start. On any other port, 587 by default, it's encrypted with STARTTLS when the server offers it.
- A password only goes over an encrypted connection, or to this machine
(
localhost,127.0.0.1or::1): to a server that doesn't offer STARTTLS,Sendfails rather than send it in the clear. Without aUsername, mail to such a server goes unencrypted. - Connecting has 10 seconds, and the rest has until
ctxis done, or a minute whenctxhas no deadline, so a server that stops answering can't hold on to it.
Mailbox keeps mail instead, for development: each mail as SMTP would
send it, in its Dir, .tug/mail, which git leaves out, and a line in
its W, or the standard error, which tug dev shows: whom it's to, Bcc
too, its subject, and its link at URL, the app's address. Under
tug dev, the App shows the mailbox at /_tug/mail, as it answers
Inertia's DevTools: the mail newest first, the newest 100, and each
mail's headers, its Bcc, its HTML in a sandboxed frame, whose links
open in a tab of their own and in which nothing runs, its text, with its
links, its files, to download, and its source. It refuses the messages
SMTP would, but for one with no From.
Log writes mail out instead: to its W, or the standard error. It writes who the mail is from and
to, Bcc too, which the mail itself doesn't show, its subject and its
link to unsubscribe, Text with each line whole, so a link can be
clicked, and each file's name and size, and it leaves HTML out. It
refuses the messages SMTP would, but for one with no From, so a bad
message is found while developing.
FromEnv returns an SMTP when MAIL_HOST is set, and when it isn't, a
Mailbox under tug dev, which sets TUG_DEV, with APP_URL its
URL, and a Log otherwise, as a deploy without a mail server has, from
the variables Laravel uses:
| Variable | What it is |
|---|---|
MAIL_HOST |
the SMTP server; without it, mail is kept in the mailbox under tug dev, and written out otherwise |
MAIL_PORT |
default 587; 465 for TLS from the start |
MAIL_USERNAME, MAIL_PASSWORD |
for a server that wants them |
MAIL_FROM_ADDRESS |
who mail is from; needed with MAIL_HOST |
MAIL_FROM_NAME |
the name that goes with it |
There's no variable for the encryption: the port and the server decide
it. A MAIL_PORT that isn't a port, or a MAIL_HOST without
MAIL_FROM_ADDRESS, is an error, so the app stops at startup rather than
at its first mail.
The starter's tests hand the app a mailtest.Outbox, a Mailer that
keeps what it's sent, for a test to read mail by mail:
register(c, "Ann", "ann@example.com", "correct horse")
m := app.outbox.Next(t) // the link that verifies the emailNext waits up to five seconds for the next mail, and fails the test
when none comes: a job sends it, so it may not be there yet when the
request is answered. None checks that no more comes, and Down(n) has
the next n mails fail, as they would with the mail server down, for a
test of a mail that goes again (Testing).
Each test app runs its own queue on its own database, asking for jobs
every 10 milliseconds, so a mail that failed goes again as soon as its
wait is over. jobs_test.go runs queuetest.TestStore on the jobs
table.
On SQLite, a test's database is a file in its temporary directory. On
Postgres or MySQL, db_test.go makes one for each test on the server
DB_URL names, or else on compose.yaml's, and drops it after:
docker compose up -d
go test ./...With no server to make them on, every test fails, saying so, where
skipping would pass with nothing tested. The tests don't read .env: a
DB_URL for them is set where they run, as CI sets it for a database of
its own.
The tests log in as a user with two-factor logins on with
auth.TwoFactor.Code, a code for now and one for 30 seconds on, since a
code works once.
Each browser in a test is a tugtest.Client (Testing), so
two logins to one account are two clients. verifiedUser registers a
user, follows the link mailed to them, and returns the browser they did
it in. oldLogin returns one logged in long ago, with no password typed
since, made with Client.Session and a session.Store with the test's
key. The shared auth.user reads into the app's own User, with
tugtest.Prop[*User](r, "auth.user"), and a page's props into its own
struct, tugtest.Props(r, Security). passkeys_test.go adds passkeys and
logs in with them from a passkeytest.Authenticator, as a phone would.
APP_URLand the mail variables: the app won't start withoutAPP_URL, its address such ashttps://example.com. It starts withoutMAIL_HOST, and its mail goes to the log rather than to people, verification links included, so no one can verify their email. Deployment has how to set them.- A secure cookie: a login lives in the session's cookie. An
APP_URLofhttps://marks it for HTTPS only, which behind a proxy that ends TLS, where requests reach the app over plain HTTP, it otherwise wouldn't be. - The client's address: behind a proxy or load balancer, a request
comes from the proxy's address, the same for everyone, so without
TRUSTED_PROXIESthe login throttle counts everyone's wrong passwords for an email together: five from anyone, and its owner waits too. Name the proxies in it, andc.IP()is the client's, read from theirX-Forwarded-For. Deployment has how. - Logins live in the cookie, and the database has a row of each, which
a login needs at every request. Logging out, or out of a browser from
the security page, deletes its row, so a copy of the cookie taken
before is logged out too; a login lasts two hours from its last
request, or a month with "Remember me", and a copy in use keeps itself
alive until then. A new
APP_KEY, without the old one inAPP_PREVIOUS_KEYS, ends every session at once, and every link mailed. - Rotating
APP_KEY:tug keymakes the new one. Keep the old key inAPP_PREVIOUS_KEYSfor a month, as long as a remembered login lasts. The two-factor secrets move to the new key as the app starts, and the sessions as they're used (Encryption). - The browser's history: Inertia keeps each page's props in it for
Back. The starter has it encrypted, with
EncryptHistory: trueinnewApp'sinertia.Config, andlogoutclears it, so after logging out Back fetches the page again, and gets the login page. The browser only encrypts for a page served over HTTPS, or fromlocalhost: over plain HTTP elsewhere, Inertia's client keeps the history unencrypted, and says so in the console. See History encryption. - The login's throttle counts by email and address, so it slows
guessing one account's password, not trying one password on many
accounts, which takes a limit by address alone, as
tug.Limitputs on a route (Routing). - Registering says who has an account: the register form turns away an email that has one, as the field is left, with no throttle, and so does the profile. Logging in and a forgotten password don't say.
- Passwords: 8 to 200 characters, and nothing else is asked of them. Laravel's starter also asks, in production, for mixed case, numbers and symbols, and checks the password against those leaked in breaches.
- Guessing a code: five tries a minute, as Laravel's starter allows,
is 7,200 a day, and each has about three in a million to be right, so
someone with the password could get past the code in a few weeks of
trying. Counting failed codes over a longer window as well, in
twoFactorChallenge, stops that, at the price of letting whoever has the password lock its owner out for as long. Codes that keep failing after the right password mean someone else knows it: mailing the user then is worth doing. - Mail that keeps failing: a mail job runs 10 times over about four
hours, then stays in the
jobstable as failed for a month, with an error in the log../blog jobslists the jobs that failed, and runs them again: see jobs.md. And now and then a mail goes twice: a job runs at least once, and again when the app is killed as it sends. - Where the photos are kept: in
files/beside the binary, orFILES_PATH, which the image sets to/data/files, on its volume, so they outlive the container. Instances of the app on more than one machine don't share a directory: give themFILESYSTEM_DISK=s3and a bucket, which all of them reach. A photo's links are signed withAPP_KEY, and checked withAPP_PREVIOUS_KEYStoo, so the ones on pages already open still work after a new key. - Where the database is: SQLite is a file, on the image's volume, and
its instances share one machine. On Postgres or MySQL,
DB_URLnames the database, with the driver's settings in its query, as Postgres'ssslmode=requireor MySQL'stls=true, and instances anywhere that reach it share it, and its jobs.compose.yamlis for development: it runs the database on127.0.0.1with a password everyone knows.
- Which make of authenticator a passkey is on, which checking its attestation would say, for a site that takes only some.
- Where a browser is, as a place: the security page says the address it logged in from, and a place takes a database of where addresses are, such as MaxMind's.
- Logging in with another site, such as GitHub or Google.