docs/specs/2026-09-04-email-login-design.md
150 lines · 6471 bytes
15 symbols in this file
Browser login without an SSH key
Ref #155. Milestone v1.14.0. First half of the identity work; OIDC is the second half and reuses the seam this lands.
Problem
store.CreateWebSession has exactly one caller: the /login?token= handler at
internal/httpd/accounts.go:89. The token it consumes can only be minted by
web login over SSH (internal/control/web.go:runWebLogin). Web signup
requires a pasted SSH public key (internal/httpd/accounts.go:211).
A person without an SSH key therefore cannot use the web UI at all. That is not
a rough edge for non-engineers, it is a closed door. admin user create already
makes --key optional, so an admin can create an account today that has no way
to authenticate anywhere.
web.password_auth exists as a config field and is rejected at startup
(internal/config/config.go:325) with "not implemented yet".
What this does not change
The web dispatches control commands with ViaAPI: true
(internal/httpd/control.go:37), and control.Dispatch refuses SSHOnly
commands under that flag (internal/control/control.go:103). Twenty-nine
commands carry SSHOnly: secrets, API token minting, mirror credentials, the
audit log, session revocation, and the whole admin family.
A browser session cannot reach any of them, whoever holds it and however it was obtained. Adding a second way to get a session widens who may hold one, not what one can do. A keyless account also cannot push, because writes go over SSH and pushing requires a key by definition.
Approach
Email a single-use login link. Rejected alternatives:
- Password plus TOTP. Stores a new secret at rest, needs a lockout policy, and its reset flow needs SMTP anyway — a superset of this design's dependencies rather than an alternative to them.
- Both, config-gated. Two auth paths to secure and test, for one user.
Design
The exported function
The mint must be triggerable by an unauthenticated request, so it cannot be a
registered control command: those run as c.User and there is none. The
precedent is control.RegisterAccount, a plain exported function that
signupSubmit calls for the same reason.
New file internal/control/loginlink.go:
func RequestLoginLink(cfg config.Config, st *store.Store, identifier string) error
The error is for the server log only, and every miss — no such account, no
verified address, disabled, pending, over the throttle — returns nil. A return
triple like RegisterAccount's would hand the caller the distinction the
enumeration rule below forbids it from rendering. No registry entry, so
TestEveryCommandIsReachable is unaffected and no CLI passthrough is needed —
anyone at a terminal has SSH and already has web login.
Resolution: an identifier containing @ goes to store.UserIDByVerifiedEmail;
otherwise look up the username and take store.PrimaryVerifiedEmail. Both
exist. Only verified addresses resolve; an unverified one is treated as no
match. An empty or whitespace-only identifier resolves to no match by the same
path, so it draws the same response as everything else.
The body follows sendVerification (internal/control/register.go:41) and
ends in mail.Send.
TTL
CreateLoginToken already takes a TTL, so no signature changes. SSH-minted
links keep 5 minutes. Emailed links get 15, because delivery plus a
person noticing the mail does not fit in five.
Throttling
Two layers.
- Per account, durable. New
store.CountLoginTokensSince(userID, since), capped at 5 per hour. This copiesmaxEmailAddsPerHourand its reasoning from #136, and survives a restart. - Per IP. Reuse the existing
apiLimitertoken bucket (internal/httpd/apilimit.go) on the POST route. An anonymous endpoint that sends mail is a spam cannon without it.
Enumeration
The response is identical whether the account exists, exists without a verified
address, or is over its throttle: "if that account exists, a link is on its
way." RequestLoginLink reports nothing about which case it took.
Differences in status code, body, or redirect target all count as a leak.
Cookie SameSite
The session cookie is SameSiteStrictMode (internal/httpd/accounts.go:92).
Clicking a link in a webmail client is a cross-site top-level navigation, and
the redirect chain to / can arrive without the cookie: the visitor lands
logged out, refreshes, and is then logged in. Pasting a URL into the address bar
does not hit this, which is why the SSH flow has never shown it.
Change the session cookie to SameSiteLaxMode. Lax still withholds the cookie
from cross-site POSTs, and the Origin check on mutating routes
(internal/httpd/accounts.go:55) is the stronger of the two CSRF defenses.
Implementation gate: confirm that Origin check covers every mutating route
before relying on it. If it does not, extend it in this branch or keep Strict
and add an interstitial "Continue" page on /login?token= instead.
Configuration
No new flag. The form renders when cfg.Mail.SMTPHost != "" and
web.mode = "accounts". A web.email_login switch was considered and dropped
as unneeded.
Out of scope
Web signup keeps requiring an SSH key. A keyless account arrives through
admin user create <name> --email <address> --verified, which works today with
no code change, and matches how a team adds a designer. Keyless self-signup is a
separate policy question that widens the open-registration spam surface.
Files
| Path | Change |
|---|---|
internal/control/loginlink.go |
new — RequestLoginLink |
internal/store/sessions.go |
new — CountLoginTokensSince, beside CreateLoginToken |
internal/httpd/accounts.go |
loginSubmit handler; cookie SameSite |
internal/httpd/routes.go |
POST /login |
internal/web/templates/login.html |
the request form |
e2e/emaillogin_test.go |
new |
Tests
- A verified address queues mail, and the token in it completes a session.
- A nonexistent identifier produces a byte-identical response to a real one.
- An address that exists but is unverified mints nothing.
- The sixth request within an hour mints nothing.
- An expired emailed token is refused (covered by
ConsumeLoginToken). - The session cookie asserts
Lax,HttpOnly, andSecureunder TLS.
Phase 2
OIDC becomes another resolver in front of CreateWebSession, reusing the
session layer, the cookie decision, and the login page this adds.