Commit b24bfe450b

b24bfe450bb549672485b310673d2d75f79663a8

parent: e671162d07

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-05 03:10 UTC

Design: browser login without an SSH key

Ref #155

Layout: unified · split

docs/specs/2026-09-04-email-login-design.md added +147
@@ -0,0 +1,147 @@
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) (msg, errMsg string, code int)
56```
57
58Same return triple as `RegisterAccount`. No registry entry, so
59`TestEveryCommandIsReachable` is unaffected and no CLI passthrough is needed —
60anyone at a terminal has SSH and already has `web login`.
61
62Resolution: an identifier containing `@` goes to `store.UserIDByVerifiedEmail`;
63otherwise look up the username and take `store.PrimaryVerifiedEmail`. Both
64exist. Only verified addresses resolve; an unverified one is treated as no
65match.
66
67The body follows `sendVerification` (`internal/control/register.go:41`) and
68ends in `mail.Send`.
69
70### TTL
71
72`CreateLoginToken` already takes a TTL, so no signature changes. SSH-minted
73links keep **5 minutes**. Emailed links get **15**, because delivery plus a
74person noticing the mail does not fit in five.
75
76### Throttling
77
78Two layers.
79
80- **Per account, durable.** New `store.CountLoginTokensSince(userID, since)`,
81 capped at 5 per hour. This copies `maxEmailAddsPerHour` and its reasoning from
82 #136, and survives a restart.
83- **Per IP.** Reuse the existing `apiLimiter` token bucket
84 (`internal/httpd/apilimit.go`) on the POST route. An anonymous endpoint that
85 sends mail is a spam cannon without it.
86
87### Enumeration
88
89The response is identical whether the account exists, exists without a verified
90address, or is over its throttle: "if that account exists, a link is on its
91way." `RequestLoginLink` returns a generic `msg` and keeps the distinction
92internal. Differences in status code, body, or redirect target all count as a
93leak.
94
95### Cookie SameSite
96
97The session cookie is `SameSiteStrictMode` (`internal/httpd/accounts.go:92`).
98Clicking a link in a webmail client is a cross-site top-level navigation, and
99the redirect chain to `/` can arrive without the cookie: the visitor lands
100logged out, refreshes, and is then logged in. Pasting a URL into the address bar
101does not hit this, which is why the SSH flow has never shown it.
102
103Change the session cookie to `SameSiteLaxMode`. Lax still withholds the cookie
104from cross-site POSTs, and the Origin check on mutating routes
105(`internal/httpd/accounts.go:55`) is the stronger of the two CSRF defenses.
106
107**Implementation gate:** confirm that Origin check covers every mutating route
108before relying on it. If it does not, extend it in this branch or keep Strict
109and add an interstitial "Continue" page on `/login?token=` instead.
110
111### Configuration
112
113No new flag. The form renders when `cfg.Mail.SMTPHost != ""` and
114`web.mode = "accounts"`. A `web.email_login` switch was considered and dropped
115as unneeded.
116
117### Out of scope
118
119Web signup keeps requiring an SSH key. A keyless account arrives through
120`admin user create <name> --email <address> --verified`, which works today with
121no code change, and matches how a team adds a designer. Keyless self-signup is a
122separate policy question that widens the open-registration spam surface.
123
124## Files
125
126| Path | Change |
127|---|---|
128| `internal/control/loginlink.go` | new — `RequestLoginLink` |
129| `internal/store/` | new — `CountLoginTokensSince` |
130| `internal/httpd/accounts.go` | `loginSubmit` handler; cookie `SameSite` |
131| `internal/httpd/routes.go` | `POST /login` |
132| `internal/web/templates/login.html` | the request form |
133| `e2e/emaillogin_test.go` | new |
134
135## Tests
136
137- A verified address queues mail, and the token in it completes a session.
138- A nonexistent identifier produces a byte-identical response to a real one.
139- An address that exists but is unverified mints nothing.
140- The sixth request within an hour mints nothing.
141- An expired emailed token is refused (covered by `ConsumeLoginToken`).
142- The session cookie asserts `Lax`, `HttpOnly`, and `Secure` under TLS.
143
144## Phase 2
145
146OIDC becomes another resolver in front of `CreateWebSession`, reusing the
147session layer, the cookie decision, and the login page this adds.