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

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

150 lines · 6471 bytes

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 copies maxEmailAddsPerHour and its reasoning from #136, and survives a restart.
  • Per IP. Reuse the existing apiLimiter token 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.

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, and Secure under TLS.

Phase 2

OIDC becomes another resolver in front of CreateWebSession, reusing the session layer, the cookie decision, and the login page this adds.