docs/specs/2026-09-04-email-login-design.md

bd5cf5d7d1f34fa780660fd7562b9ffd9746ee27
gitbay/docs/specs/2026-09-04-email-login-design.md rendered · source · history · blame · raw

150 lines · 6471 bytes

  1# Browser login without an SSH key
  2
  3Ref #155. Milestone v1.14.0. First half of the identity work; OIDC is the
  4second half and reuses the seam this lands.
  5
  6## Problem
  7
  8`store.CreateWebSession` has exactly one caller: the `/login?token=` handler at
  9`internal/httpd/accounts.go:89`. The token it consumes can only be minted by
 10`web login` over SSH (`internal/control/web.go:runWebLogin`). Web signup
 11requires a pasted SSH public key (`internal/httpd/accounts.go:211`).
 12
 13A person without an SSH key therefore cannot use the web UI at all. That is not
 14a rough edge for non-engineers, it is a closed door. `admin user create` already
 15makes `--key` optional, so an admin can create an account today that has no way
 16to authenticate anywhere.
 17
 18`web.password_auth` exists as a config field and is rejected at startup
 19(`internal/config/config.go:325`) with "not implemented yet".
 20
 21## What this does not change
 22
 23The web dispatches control commands with `ViaAPI: true`
 24(`internal/httpd/control.go:37`), and `control.Dispatch` refuses `SSHOnly`
 25commands under that flag (`internal/control/control.go:103`). Twenty-nine
 26commands carry `SSHOnly`: secrets, API token minting, mirror credentials, the
 27audit log, session revocation, and the whole `admin` family.
 28
 29A browser session cannot reach any of them, whoever holds it and however it was
 30obtained. Adding a second way to get a session widens who may hold one, not what
 31one can do. A keyless account also cannot push, because writes go over SSH and
 32pushing requires a key by definition.
 33
 34## Approach
 35
 36Email a single-use login link. Rejected alternatives:
 37
 38- **Password plus TOTP.** Stores a new secret at rest, needs a lockout policy,
 39  and its reset flow needs SMTP anyway — a superset of this design's
 40  dependencies rather than an alternative to them.
 41- **Both, config-gated.** Two auth paths to secure and test, for one user.
 42
 43## Design
 44
 45### The exported function
 46
 47The mint must be triggerable by an unauthenticated request, so it cannot be a
 48registered control command: those run as `c.User` and there is none. The
 49precedent is `control.RegisterAccount`, a plain exported function that
 50`signupSubmit` calls for the same reason.
 51
 52New file `internal/control/loginlink.go`:
 53
 54```go
 55func RequestLoginLink(cfg config.Config, st *store.Store, identifier string) error
 56```
 57
 58The error is for the server log only, and every miss — no such account, no
 59verified address, disabled, pending, over the throttle — returns nil. A return
 60triple like `RegisterAccount`'s would hand the caller the distinction the
 61enumeration rule below forbids it from rendering. No registry entry, so
 62`TestEveryCommandIsReachable` is unaffected and no CLI passthrough is needed —
 63anyone at a terminal has SSH and already has `web login`.
 64
 65Resolution: an identifier containing `@` goes to `store.UserIDByVerifiedEmail`;
 66otherwise look up the username and take `store.PrimaryVerifiedEmail`. Both
 67exist. Only verified addresses resolve; an unverified one is treated as no
 68match. An empty or whitespace-only identifier resolves to no match by the same
 69path, so it draws the same response as everything else.
 70
 71The body follows `sendVerification` (`internal/control/register.go:41`) and
 72ends in `mail.Send`.
 73
 74### TTL
 75
 76`CreateLoginToken` already takes a TTL, so no signature changes. SSH-minted
 77links keep **5 minutes**. Emailed links get **15**, because delivery plus a
 78person noticing the mail does not fit in five.
 79
 80### Throttling
 81
 82Two layers.
 83
 84- **Per account, durable.** New `store.CountLoginTokensSince(userID, since)`,
 85  capped at 5 per hour. This copies `maxEmailAddsPerHour` and its reasoning from
 86  #136, and survives a restart.
 87- **Per IP.** Reuse the existing `apiLimiter` token bucket
 88  (`internal/httpd/apilimit.go`) on the POST route. An anonymous endpoint that
 89  sends mail is a spam cannon without it.
 90
 91### Enumeration
 92
 93The response is identical whether the account exists, exists without a verified
 94address, or is over its throttle: "if that account exists, a link is on its
 95way." `RequestLoginLink` reports nothing about which case it took.
 96Differences in status code, body, or redirect target all count as a leak.
 97
 98### Cookie SameSite
 99
100The session cookie is `SameSiteStrictMode` (`internal/httpd/accounts.go:92`).
101Clicking a link in a webmail client is a cross-site top-level navigation, and
102the redirect chain to `/` can arrive without the cookie: the visitor lands
103logged out, refreshes, and is then logged in. Pasting a URL into the address bar
104does not hit this, which is why the SSH flow has never shown it.
105
106Change the session cookie to `SameSiteLaxMode`. Lax still withholds the cookie
107from cross-site POSTs, and the Origin check on mutating routes
108(`internal/httpd/accounts.go:55`) is the stronger of the two CSRF defenses.
109
110**Implementation gate:** confirm that Origin check covers every mutating route
111before relying on it. If it does not, extend it in this branch or keep Strict
112and add an interstitial "Continue" page on `/login?token=` instead.
113
114### Configuration
115
116No new flag. The form renders when `cfg.Mail.SMTPHost != ""` and
117`web.mode = "accounts"`. A `web.email_login` switch was considered and dropped
118as unneeded.
119
120### Out of scope
121
122Web signup keeps requiring an SSH key. A keyless account arrives through
123`admin user create <name> --email <address> --verified`, which works today with
124no code change, and matches how a team adds a designer. Keyless self-signup is a
125separate policy question that widens the open-registration spam surface.
126
127## Files
128
129| Path | Change |
130|---|---|
131| `internal/control/loginlink.go` | new — `RequestLoginLink` |
132| `internal/store/sessions.go` | new — `CountLoginTokensSince`, beside `CreateLoginToken` |
133| `internal/httpd/accounts.go` | `loginSubmit` handler; cookie `SameSite` |
134| `internal/httpd/routes.go` | `POST /login` |
135| `internal/web/templates/login.html` | the request form |
136| `e2e/emaillogin_test.go` | new |
137
138## Tests
139
140- A verified address queues mail, and the token in it completes a session.
141- A nonexistent identifier produces a byte-identical response to a real one.
142- An address that exists but is unverified mints nothing.
143- The sixth request within an hour mints nothing.
144- An expired emailed token is refused (covered by `ConsumeLoginToken`).
145- The session cookie asserts `Lax`, `HttpOnly`, and `Secure` under TLS.
146
147## Phase 2
148
149OIDC becomes another resolver in front of `CreateWebSession`, reusing the
150session layer, the cookie decision, and the login page this adds.