| @@ -1,782 +0,0 @@ |
| 1 | # Browser login without an SSH key — Implementation Plan |
| |
| 2 | |
| |
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| |
| 4 | |
| |
| 5 | **Goal:** Let a person with no SSH key log into the web UI by requesting a |
| |
| 6 | one-time link at their verified email address. |
| |
| 7 | |
| |
| 8 | **Architecture:** An anonymous `POST /login` calls a plain exported function in |
| |
| 9 | `internal/control`, which resolves the identifier to a user, mints the same |
| |
| 10 | one-time token `web login` already mints, and mails it. The existing |
| |
| 11 | `GET /login?token=` handler consumes it unchanged. No new control command, |
| |
| 12 | because the caller has no authenticated user to run one as. |
| |
| 13 | |
| |
| 14 | **Tech Stack:** Go, SQLite (hand-written SQL, no ORM), `html/template`, |
| |
| 15 | `internal/mail` over SMTP. |
| |
| 16 | |
| |
| 17 | **Spec:** `docs/specs/2026-09-04-email-login-design.md` |
| |
| 18 | |
| |
| 19 | ## Global Constraints |
| |
| 20 | |
| |
| 21 | - Branch `email-login`. Never push to `main`. One MR, `Closes #155`. |
| |
| 22 | - Never attribute anything to an assistant or model, anywhere. |
| |
| 23 | - No ORM. Hand-written SQL. Migrations in `internal/store/migrations/`. |
| |
| 24 | - Comments state facts, not before/after commentary. Git history holds the rest. |
| |
| 25 | - `--json` output is the contract; human output is not. |
| |
| 26 | - Locally run build, vet, and the unit tests of touched packages plus the one |
| |
| 27 | e2e test being written. Full e2e belongs to CI on bay1. |
| |
| 28 | - Adding a top-level route means adding the word to `internal/policy/names.go`. |
| |
| 29 | `/login` already exists, so this plan adds no reserved name. |
| |
| 30 | |
| |
| 31 | ## Change from the spec |
| |
| 32 | |
| |
| 33 | The spec proposed `RequestLoginLink` returning `(msg, errMsg string, code int)` |
| |
| 34 | to mirror `control.RegisterAccount`. **It returns only `error` instead.** |
| |
| 35 | `RegisterAccount` reports per-case failures because registration is allowed to |
| |
| 36 | say what went wrong; here, reporting anything about the outcome is precisely the |
| |
| 37 | enumeration leak the spec forbids. The returned error is for the server log |
| |
| 38 | only — SMTP down, database failure — and is never rendered. The handler draws |
| |
| 39 | the same page whatever it gets back. |
| |
| 40 | |
| |
| 41 | ## File Structure |
| |
| 42 | |
| |
| 43 | | Path | Responsibility | |
| |
| 44 | |---|---| |
| |
| 45 | | `internal/store/migrations/0040_login_token_index.{up,down}.sql` | index behind the throttle count | |
| |
| 46 | | `internal/store/sessions.go` | `CountLoginTokensSince`, beside `CreateLoginToken` | |
| |
| 47 | | `internal/control/loginlink.go` | new — resolve identifier, throttle, mint, mail | |
| |
| 48 | | `internal/httpd/accounts.go` | `loginSubmit`; session cookie `SameSite` | |
| |
| 49 | | `internal/httpd/routes.go` | `POST /login` | |
| |
| 50 | | `internal/web/templates/login.html` | request form and the sent confirmation | |
| |
| 51 | | `internal/store/sessions_test.go` | new — counter unit test | |
| |
| 52 | | `internal/httpd/logincookie_test.go` | new — cookie attribute test | |
| |
| 53 | | `e2e/emaillogin_test.go` | new — the whole path against real SMTP | |
| |
| 54 | |
| |
| 55 | --- |
| |
| 56 | |
| |
| 57 | ### Task 1: The throttle counter and its index |
| |
| 58 | |
| |
| 59 | `login_tokens` has no index on `user_id`, and rows are never deleted. Counting |
| |
| 60 | per user on an anonymous endpoint would be an unbounded table scan on every |
| |
| 61 | request, which makes the throttle its own denial-of-service vector. |
| |
| 62 | |
| |
| 63 | **Files:** |
| |
| 64 | - Create: `internal/store/migrations/0040_login_token_index.up.sql` |
| |
| 65 | - Create: `internal/store/migrations/0040_login_token_index.down.sql` |
| |
| 66 | - Modify: `internal/store/sessions.go` (append after `CreateLoginToken`, line 36) |
| |
| 67 | - Create: `internal/store/sessions_test.go` |
| |
| 68 | |
| |
| 69 | **Interfaces:** |
| |
| 70 | - Consumes: `Store.CreateLoginToken(userID int64, hash string, ttl time.Duration) error`, `store.NewToken() (token, hash string, err error)` — both exist. |
| |
| 71 | - Produces: `func (s *Store) CountLoginTokensSince(userID int64, since time.Time) (int, error)` |
| |
| 72 | |
| |
| 73 | - [ ] **Step 1: Write the failing test** |
| |
| 74 | |
| |
| 75 | Create `internal/store/sessions_test.go`: |
| |
| 76 | |
| |
| 77 | ```go |
| |
| 78 | package store |
| |
| 79 | |
| |
| 80 | import ( |
| |
| 81 | "testing" |
| |
| 82 | "time" |
| |
| 83 | ) |
| |
| 84 | |
| |
| 85 | func TestCountLoginTokensSince(t *testing.T) { |
| |
| 86 | s := open(t) |
| |
| 87 | if err := s.MigrateUp(); err != nil { |
| |
| 88 | t.Fatal(err) |
| |
| 89 | } |
| |
| 90 | uid, err := s.CreateUser("cmc", true) |
| |
| 91 | if err != nil { |
| |
| 92 | t.Fatal(err) |
| |
| 93 | } |
| |
| 94 | for i := 0; i < 3; i++ { |
| |
| 95 | _, hash, err := NewToken() |
| |
| 96 | if err != nil { |
| |
| 97 | t.Fatal(err) |
| |
| 98 | } |
| |
| 99 | if err := s.CreateLoginToken(uid, hash, time.Minute); err != nil { |
| |
| 100 | t.Fatal(err) |
| |
| 101 | } |
| |
| 102 | } |
| |
| 103 | |
| |
| 104 | n, err := s.CountLoginTokensSince(uid, time.Now().Add(-time.Hour)) |
| |
| 105 | if err != nil || n != 3 { |
| |
| 106 | t.Fatalf("count in the last hour = %d, %v; want 3", n, err) |
| |
| 107 | } |
| |
| 108 | |
| |
| 109 | // A window that opens in the future sees none of them, which is what |
| |
| 110 | // makes the hourly bound a window rather than a lifetime total. |
| |
| 111 | if n, err := s.CountLoginTokensSince(uid, time.Now().Add(time.Hour)); err != nil || n != 0 { |
| |
| 112 | t.Fatalf("count in a future window = %d, %v; want 0", n, err) |
| |
| 113 | } |
| |
| 114 | |
| |
| 115 | // One account's requests must not spend another account's budget. |
| |
| 116 | other, err := s.CreateUser("kim", false) |
| |
| 117 | if err != nil { |
| |
| 118 | t.Fatal(err) |
| |
| 119 | } |
| |
| 120 | if n, err := s.CountLoginTokensSince(other, time.Now().Add(-time.Hour)); err != nil || n != 0 { |
| |
| 121 | t.Fatalf("other account count = %d, %v; want 0", n, err) |
| |
| 122 | } |
| |
| 123 | } |
| |
| 124 | ``` |
| |
| 125 | |
| |
| 126 | - [ ] **Step 2: Run it and watch it fail** |
| |
| 127 | |
| |
| 128 | Run: `go test ./internal/store/ -run TestCountLoginTokensSince -v` |
| |
| 129 | Expected: FAIL — `s.CountLoginTokensSince undefined`. |
| |
| 130 | |
| |
| 131 | - [ ] **Step 3: Write the migration** |
| |
| 132 | |
| |
| 133 | `internal/store/migrations/0040_login_token_index.up.sql`: |
| |
| 134 | |
| |
| 135 | ```sql |
| |
| 136 | CREATE INDEX login_tokens_user_created ON login_tokens(user_id, created_at); |
| |
| 137 | ``` |
| |
| 138 | |
| |
| 139 | `internal/store/migrations/0040_login_token_index.down.sql`: |
| |
| 140 | |
| |
| 141 | ```sql |
| |
| 142 | DROP INDEX login_tokens_user_created; |
| |
| 143 | ``` |
| |
| 144 | |
| |
| 145 | - [ ] **Step 4: Write the counter** |
| |
| 146 | |
| |
| 147 | Append to `internal/store/sessions.go`, directly after `CreateLoginToken`: |
| |
| 148 | |
| |
| 149 | ```go |
| |
| 150 | // CountLoginTokensSince counts the login tokens minted for a user within a |
| |
| 151 | // window. An unauthenticated request can ask for a login link, so the mint |
| |
| 152 | // needs a durable per-account bound the way email verification does (#136). |
| |
| 153 | func (s *Store) CountLoginTokensSince(userID int64, since time.Time) (int, error) { |
| |
| 154 | var n int |
| |
| 155 | err := s.DB.QueryRow( |
| |
| 156 | "SELECT count(*) FROM login_tokens WHERE user_id = ? AND created_at > ?", |
| |
| 157 | userID, fmtTime(since)).Scan(&n) |
| |
| 158 | return n, err |
| |
| 159 | } |
| |
| 160 | ``` |
| |
| 161 | |
| |
| 162 | - [ ] **Step 5: Run the test and the package** |
| |
| 163 | |
| |
| 164 | Run: `go test ./internal/store/ -run TestCountLoginTokensSince -v` |
| |
| 165 | Expected: PASS. |
| |
| 166 | |
| |
| 167 | Run: `go test ./internal/store/` |
| |
| 168 | Expected: PASS — the new migration must not break existing store tests. |
| |
| 169 | |
| |
| 170 | - [ ] **Step 6: Confirm the index is actually used** |
| |
| 171 | |
| |
| 172 | The count runs on every anonymous request, so a sequential scan here would |
| |
| 173 | make the throttle its own denial-of-service vector. Verify the plan names the |
| |
| 174 | index: |
| |
| 175 | |
| |
| 176 | ```bash |
| |
| 177 | sqlite3 "$SCRATCH/plan.db" <<'EOF' |
| |
| 178 | CREATE TABLE login_tokens (token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL, |
| |
| 179 | created_at TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT); |
| |
| 180 | CREATE INDEX login_tokens_user_created ON login_tokens(user_id, created_at); |
| |
| 181 | EXPLAIN QUERY PLAN SELECT count(*) FROM login_tokens WHERE user_id = 1 AND created_at > 'x'; |
| |
| 182 | EOF |
| |
| 183 | ``` |
| |
| 184 | |
| |
| 185 | Expected: the output names `login_tokens_user_created`. A line reading |
| |
| 186 | `SCAN login_tokens` means the index is not being used and the migration is |
| |
| 187 | wrong. |
| |
| 188 | |
| |
| 189 | - [ ] **Step 7: Commit** |
| |
| 190 | |
| |
| 191 | ```bash |
| |
| 192 | git add internal/store/sessions.go internal/store/sessions_test.go \ |
| |
| 193 | internal/store/migrations/0040_login_token_index.up.sql \ |
| |
| 194 | internal/store/migrations/0040_login_token_index.down.sql |
| |
| 195 | git commit -m "store: count login tokens per account, indexed |
| |
| 196 | |
| |
| 197 | Ref #155" |
| |
| 198 | ``` |
| |
| 199 | |
| |
| 200 | --- |
| |
| 201 | |
| |
| 202 | ### Task 2: Session cookie SameSite |
| |
| 203 | |
| |
| 204 | A link clicked in a webmail client is a cross-site top-level navigation. Under |
| |
| 205 | `SameSite=Strict` the redirect chain to `/` can arrive without the cookie, so |
| |
| 206 | the visitor lands logged out and is logged in only after a refresh. Pasting a |
| |
| 207 | URL into the address bar does not hit this, which is why the SSH flow never |
| |
| 208 | showed it. |
| |
| 209 | |
| |
| 210 | `Lax` is safe here, and it was verified rather than assumed: every |
| |
| 211 | cookie-authenticated mutating route carries `checkOrigin`. The four POST routes |
| |
| 212 | without it — `git-upload-pack`, `git-receive-pack`, `lfs/objects/batch`, |
| |
| 213 | `/api/v1/cmd` — do not accept the session cookie at all; the API takes only |
| |
| 214 | `Authorization: Bearer` (`internal/httpd/api.go:126`). For POST, `Lax` is |
| |
| 215 | strictly stronger than the Origin check, because it withholds the cookie |
| |
| 216 | outright. The only `Mutating: true` GET is `/login` itself, whose token is |
| |
| 217 | single-use. |
| |
| 218 | |
| |
| 219 | **Files:** |
| |
| 220 | - Modify: `internal/httpd/accounts.go:92` (set), and the `clearCookie` call in `logout` |
| |
| 221 | - Create: `internal/httpd/logincookie_test.go` |
| |
| 222 | |
| |
| 223 | **Interfaces:** |
| |
| 224 | - Consumes: `Server.clearCookie(name string, sameSite http.SameSite) *http.Cookie` (`internal/httpd/flash.go:55`), `sessionCookie` |
| |
| 225 | - Produces: nothing new; changes an attribute value. |
| |
| 226 | |
| |
| 227 | - [ ] **Step 1: Write the failing test** |
| |
| 228 | |
| |
| 229 | Create `internal/httpd/logincookie_test.go`: |
| |
| 230 | |
| |
| 231 | ```go |
| |
| 232 | package httpd |
| |
| 233 | |
| |
| 234 | import ( |
| |
| 235 | "net/http" |
| |
| 236 | "testing" |
| |
| 237 | |
| |
| 238 | "gitbay.org/gitbay/internal/config" |
| |
| 239 | ) |
| |
| 240 | |
| |
| 241 | // The session cookie must be Lax, not Strict. A login link clicked in a mail |
| |
| 242 | // client is a cross-site top-level navigation, and Strict can withhold the |
| |
| 243 | // cookie through the redirect that follows, so the visitor lands logged out |
| |
| 244 | // (#155). Cross-site POSTs stay protected: Lax withholds the cookie from them, |
| |
| 245 | // and checkOrigin refuses them besides. |
| |
| 246 | func TestSessionCookieIsLax(t *testing.T) { |
| |
| 247 | s := &Server{cfg: config.Config{}} |
| |
| 248 | s.cfg.HTTP.TLS = "acme" |
| |
| 249 | if got := s.clearCookie(sessionCookie, sessionSameSite); got.SameSite != http.SameSiteLaxMode { |
| |
| 250 | t.Errorf("clearing cookie SameSite = %v, want Lax", got.SameSite) |
| |
| 251 | } |
| |
| 252 | if sessionSameSite != http.SameSiteLaxMode { |
| |
| 253 | t.Errorf("sessionSameSite = %v, want Lax", sessionSameSite) |
| |
| 254 | } |
| |
| 255 | } |
| |
| 256 | ``` |
| |
| 257 | |
| |
| 258 | - [ ] **Step 2: Run it and watch it fail** |
| |
| 259 | |
| |
| 260 | Run: `go test ./internal/httpd/ -run TestSessionCookieIsLax -v` |
| |
| 261 | Expected: FAIL — `undefined: sessionSameSite`. |
| |
| 262 | |
| |
| 263 | - [ ] **Step 3: Introduce the constant and use it in both places** |
| |
| 264 | |
| |
| 265 | In `internal/httpd/accounts.go`, near the `sessionCookie` declaration, add: |
| |
| 266 | |
| |
| 267 | ```go |
| |
| 268 | // sessionSameSite is Lax so a login link followed from a mail client keeps |
| |
| 269 | // its session through the redirect. Cross-site POSTs are refused by |
| |
| 270 | // checkOrigin and carry no Lax cookie anyway. |
| |
| 271 | const sessionSameSite = http.SameSiteLaxMode |
| |
| 272 | ``` |
| |
| 273 | |
| |
| 274 | In the `login` handler (`internal/httpd/accounts.go:92`), replace |
| |
| 275 | `SameSite: http.SameSiteStrictMode,` with `SameSite: sessionSameSite,`. |
| |
| 276 | |
| |
| 277 | In the `logout` handler, replace |
| |
| 278 | `s.clearCookie(sessionCookie, http.SameSiteStrictMode)` with |
| |
| 279 | `s.clearCookie(sessionCookie, sessionSameSite)` so the set and clear paths |
| |
| 280 | match, which is the rule `TestClearCookieMirrorsTheSettingCall` exists to keep. |
| |
| 281 | |
| |
| 282 | - [ ] **Step 4: Run the package tests** |
| |
| 283 | |
| |
| 284 | Run: `go test ./internal/httpd/` |
| |
| 285 | Expected: PASS, including the pre-existing |
| |
| 286 | `TestClearCookieMirrorsTheSettingCall`, which passes its own `SameSite` |
| |
| 287 | argument and is unaffected. |
| |
| 288 | |
| |
| 289 | - [ ] **Step 5: Commit** |
| |
| 290 | |
| |
| 291 | ```bash |
| |
| 292 | git add internal/httpd/accounts.go internal/httpd/logincookie_test.go |
| |
| 293 | git commit -m "httpd: session cookie is SameSite=Lax |
| |
| 294 | |
| |
| 295 | A link followed from a mail client is a cross-site navigation; Strict can |
| |
| 296 | drop the cookie through the redirect after /login?token=. Every |
| |
| 297 | cookie-authenticated mutating route carries checkOrigin, and Lax withholds |
| |
| 298 | the cookie from cross-site POSTs regardless. |
| |
| 299 | |
| |
| 300 | Ref #155" |
| |
| 301 | ``` |
| |
| 302 | |
| |
| 303 | --- |
| |
| 304 | |
| |
| 305 | ### Task 3: The login link request |
| |
| 306 | |
| |
| 307 | The deliverable. The function, the route, the handler, and the form land |
| |
| 308 | together because none of them is testable without the others. |
| |
| 309 | |
| |
| 310 | **Files:** |
| |
| 311 | - Create: `internal/control/loginlink.go` |
| |
| 312 | - Modify: `internal/httpd/accounts.go` (`renderLogin`, new `loginSubmit`) |
| |
| 313 | - Modify: `internal/httpd/routes.go:100` (add `POST /login`) |
| |
| 314 | - Modify: `internal/web/templates/login.html` |
| |
| 315 | - Create: `e2e/emaillogin_test.go` |
| |
| 316 | |
| |
| 317 | **Interfaces:** |
| |
| 318 | - Consumes: `store.UserIDByVerifiedEmail(address string) (int64, bool)` (`internal/store/activity.go:11`); `store.PrimaryVerifiedEmail(userID int64) (string, error)` (`internal/store/mrs.go:440`); `store.UserByUsername`; `store.CountLoginTokensSince` (Task 1); `store.NewToken`; `store.CreateLoginToken`; `mail.Send(cfg config.Config, to, subject, body string) error`; `Server.apiLimit.allow(key string, write bool) (bool, time.Duration)`; `Server.clientIP(r)`. |
| |
| 319 | - Produces: `func control.RequestLoginLink(cfg config.Config, st *store.Store, identifier string) error` |
| |
| 320 | |
| |
| 321 | - [ ] **Step 1: Write the failing e2e test** |
| |
| 322 | |
| |
| 323 | Create `e2e/emaillogin_test.go`: |
| |
| 324 | |
| |
| 325 | ```go |
| |
| 326 | package e2e |
| |
| 327 | |
| |
| 328 | import ( |
| |
| 329 | "fmt" |
| |
| 330 | "net/url" |
| |
| 331 | "strings" |
| |
| 332 | "testing" |
| |
| 333 | ) |
| |
| 334 | |
| |
| 335 | // A person with no SSH key can still get into the web UI: they ask for a |
| |
| 336 | // link by username or verified address and it arrives by mail (#155). |
| |
| 337 | func TestEmailLogin(t *testing.T) { |
| |
| 338 | smtp := startFakeSMTP(t) |
| |
| 339 | inst := startInstanceWith(t, fmt.Sprintf( |
| |
| 340 | "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", |
| |
| 341 | smtp.addr)) |
| |
| 342 | |
| |
| 343 | // No --key: this account has no way to authenticate over SSH at all, |
| |
| 344 | // which is the whole point. |
| |
| 345 | inst.admin(t, "admin", "user", "create", "dana", |
| |
| 346 | "--email", "dana@example.test", "--verified") |
| |
| 347 | |
| |
| 348 | browser := newBrowser(t) |
| |
| 349 | status, body := browserPost(t, browser, inst.base()+"/login", |
| |
| 350 | url.Values{"identifier": {"dana@example.test"}}) |
| |
| 351 | if status != 200 { |
| |
| 352 | t.Fatalf("POST /login: %d", status) |
| |
| 353 | } |
| |
| 354 | if !strings.Contains(body, "on its way") { |
| |
| 355 | t.Fatalf("no confirmation in body: %s", body) |
| |
| 356 | } |
| |
| 357 | |
| |
| 358 | msg := smtp.waitFor(t, "dana@example.test", "/login?token=") |
| |
| 359 | i := strings.Index(msg, "/login?token=") |
| |
| 360 | link := msg[i:] |
| |
| 361 | if j := strings.IndexAny(link, " \r\n"); j >= 0 { |
| |
| 362 | link = link[:j] |
| |
| 363 | } |
| |
| 364 | |
| |
| 365 | if status, _ := browserGet(t, browser, inst.base()+link); status != 200 { |
| |
| 366 | t.Fatalf("following the link: %d", status) |
| |
| 367 | } |
| |
| 368 | status, body = browserGet(t, browser, inst.base()+"/settings") |
| |
| 369 | if status != 200 || !strings.Contains(body, "dana@example.test") { |
| |
| 370 | t.Fatalf("not logged in after the link: %d", status) |
| |
| 371 | } |
| |
| 372 | |
| |
| 373 | // The link is single use. |
| |
| 374 | second := newBrowser(t) |
| |
| 375 | browserGet(t, second, inst.base()+link) |
| |
| 376 | if status, _ := browserGet(t, second, inst.base()+"/settings"); status == 200 { |
| |
| 377 | t.Error("login link worked twice") |
| |
| 378 | } |
| |
| 379 | } |
| |
| 380 | |
| |
| 381 | // The response must not say whether an account exists. A different status, |
| |
| 382 | // body, or destination answers "is this person here?" to anyone who asks. |
| |
| 383 | func TestEmailLoginDoesNotEnumerate(t *testing.T) { |
| |
| 384 | smtp := startFakeSMTP(t) |
| |
| 385 | inst := startInstanceWith(t, fmt.Sprintf( |
| |
| 386 | "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", |
| |
| 387 | smtp.addr)) |
| |
| 388 | inst.admin(t, "admin", "user", "create", "dana", |
| |
| 389 | "--email", "dana@example.test", "--verified") |
| |
| 390 | // An account whose address was never verified must look like an absent |
| |
| 391 | // one, or an unverified address becomes an oracle. |
| |
| 392 | inst.admin(t, "admin", "user", "create", "eve", "--email", "eve@example.test") |
| |
| 393 | |
| |
| 394 | browser := newBrowser(t) |
| |
| 395 | real1, bodyReal := browserPost(t, browser, inst.base()+"/login", |
| |
| 396 | url.Values{"identifier": {"dana@example.test"}}) |
| |
| 397 | absent, bodyAbsent := browserPost(t, browser, inst.base()+"/login", |
| |
| 398 | url.Values{"identifier": {"nobody@example.test"}}) |
| |
| 399 | unver, bodyUnver := browserPost(t, browser, inst.base()+"/login", |
| |
| 400 | url.Values{"identifier": {"eve@example.test"}}) |
| |
| 401 | empty, bodyEmpty := browserPost(t, browser, inst.base()+"/login", |
| |
| 402 | url.Values{"identifier": {""}}) |
| |
| 403 | |
| |
| 404 | for _, c := range []struct { |
| |
| 405 | name string |
| |
| 406 | status int |
| |
| 407 | body string |
| |
| 408 | }{ |
| |
| 409 | {"absent", absent, bodyAbsent}, |
| |
| 410 | {"unverified", unver, bodyUnver}, |
| |
| 411 | {"empty", empty, bodyEmpty}, |
| |
| 412 | } { |
| |
| 413 | if c.status != real1 || c.body != bodyReal { |
| |
| 414 | t.Errorf("%s differs from a real address: status %d vs %d", c.name, c.status, real1) |
| |
| 415 | } |
| |
| 416 | } |
| |
| 417 | if len(smtp.mailTo("eve@example.test")) != 0 { |
| |
| 418 | t.Error("mailed an unverified address") |
| |
| 419 | } |
| |
| 420 | if len(smtp.mailTo("nobody@example.test")) != 0 { |
| |
| 421 | t.Error("mailed an address with no account") |
| |
| 422 | } |
| |
| 423 | } |
| |
| 424 | |
| |
| 425 | // An anonymous endpoint that sends mail needs a durable per-account bound, |
| |
| 426 | // the same one email verification has (#136). |
| |
| 427 | func TestEmailLoginThrottled(t *testing.T) { |
| |
| 428 | smtp := startFakeSMTP(t) |
| |
| 429 | inst := startInstanceWith(t, fmt.Sprintf( |
| |
| 430 | "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", |
| |
| 431 | smtp.addr)) |
| |
| 432 | inst.admin(t, "admin", "user", "create", "dana", |
| |
| 433 | "--email", "dana@example.test", "--verified") |
| |
| 434 | |
| |
| 435 | browser := newBrowser(t) |
| |
| 436 | for i := 0; i < 6; i++ { |
| |
| 437 | browserPost(t, browser, inst.base()+"/login", |
| |
| 438 | url.Values{"identifier": {"dana@example.test"}}) |
| |
| 439 | } |
| |
| 440 | if n := len(smtp.mailTo("dana@example.test")); n > 5 { |
| |
| 441 | t.Fatalf("sent %d login mails in an hour, want at most 5", n) |
| |
| 442 | } |
| |
| 443 | } |
| |
| 444 | ``` |
| |
| 445 | |
| |
| 446 | - [ ] **Step 2: Run it and watch it fail** |
| |
| 447 | |
| |
| 448 | Run: `go test ./e2e/ -run TestEmailLogin -v -timeout 20m` |
| |
| 449 | Expected: FAIL — `POST /login: 405`, because no such route exists. |
| |
| 450 | |
| |
| 451 | Note the explicit `-timeout`: `go test` defaults to 10 minutes and the e2e |
| |
| 452 | suite has exceeded it before, which reads as a hang rather than a failure |
| |
| 453 | (#143). |
| |
| 454 | |
| |
| 455 | - [ ] **Step 3: Write the control function** |
| |
| 456 | |
| |
| 457 | Create `internal/control/loginlink.go`: |
| |
| 458 | |
| |
| 459 | ```go |
| |
| 460 | package control |
| |
| 461 | |
| |
| 462 | import ( |
| |
| 463 | "fmt" |
| |
| 464 | "strings" |
| |
| 465 | "time" |
| |
| 466 | |
| |
| 467 | "gitbay.org/gitbay/internal/config" |
| |
| 468 | "gitbay.org/gitbay/internal/mail" |
| |
| 469 | "gitbay.org/gitbay/internal/store" |
| |
| 470 | ) |
| |
| 471 | |
| |
| 472 | // maxLoginLinksPerHour bounds what one account's address can be made to |
| |
| 473 | // receive. It matches maxEmailAddsPerHour: enough for a person who mistypes |
| |
| 474 | // and retries, nothing for a script. |
| |
| 475 | const maxLoginLinksPerHour = 5 |
| |
| 476 | |
| |
| 477 | // loginLinkTTL is longer than the five minutes an SSH-minted link gets. |
| |
| 478 | // That one is pasted from a terminal already open; this one has to survive |
| |
| 479 | // delivery and someone noticing the mail. |
| |
| 480 | const loginLinkTTL = 15 * time.Minute |
| |
| 481 | |
| |
| 482 | // RequestLoginLink mails a one-time login link to the account named by |
| |
| 483 | // identifier, which is a username or a verified email address. |
| |
| 484 | // |
| |
| 485 | // It is not a registered command: the caller is an unauthenticated web |
| |
| 486 | // request, and commands run as c.User. RegisterAccount is exported for the |
| |
| 487 | // same reason. |
| |
| 488 | // |
| |
| 489 | // The returned error is for the server log only. Nothing about the outcome |
| |
| 490 | // may reach the caller — that a request found an account, found one without |
| |
| 491 | // a verified address, or found nothing at all must be indistinguishable, or |
| |
| 492 | // the endpoint answers "does this person have an account here?" to anyone |
| |
| 493 | // who asks. Every miss returns nil. |
| |
| 494 | func RequestLoginLink(cfg config.Config, st *store.Store, identifier string) error { |
| |
| 495 | if cfg.Web.Mode != "accounts" || cfg.Mail.SMTPHost == "" { |
| |
| 496 | return nil |
| |
| 497 | } |
| |
| 498 | identifier = strings.TrimSpace(identifier) |
| |
| 499 | if identifier == "" { |
| |
| 500 | return nil |
| |
| 501 | } |
| |
| 502 | |
| |
| 503 | var userID int64 |
| |
| 504 | var address string |
| |
| 505 | if strings.Contains(identifier, "@") { |
| |
| 506 | id, ok := st.UserIDByVerifiedEmail(identifier) |
| |
| 507 | if !ok { |
| |
| 508 | return nil |
| |
| 509 | } |
| |
| 510 | userID, address = id, identifier |
| |
| 511 | } else { |
| |
| 512 | u, err := st.UserByUsername(identifier) |
| |
| 513 | if err != nil { |
| |
| 514 | return nil |
| |
| 515 | } |
| |
| 516 | addr, err := st.PrimaryVerifiedEmail(u.ID) |
| |
| 517 | if err != nil || addr == "" { |
| |
| 518 | return nil |
| |
| 519 | } |
| |
| 520 | userID, address = u.ID, addr |
| |
| 521 | } |
| |
| 522 | |
| |
| 523 | n, err := st.CountLoginTokensSince(userID, time.Now().Add(-time.Hour)) |
| |
| 524 | if err != nil { |
| |
| 525 | return err |
| |
| 526 | } |
| |
| 527 | if n >= maxLoginLinksPerHour { |
| |
| 528 | return nil |
| |
| 529 | } |
| |
| 530 | |
| |
| 531 | token, hash, err := store.NewToken() |
| |
| 532 | if err != nil { |
| |
| 533 | return err |
| |
| 534 | } |
| |
| 535 | if err := st.CreateLoginToken(userID, hash, loginLinkTTL); err != nil { |
| |
| 536 | return err |
| |
| 537 | } |
| |
| 538 | host := siteHost(cfg) |
| |
| 539 | body := fmt.Sprintf( |
| |
| 540 | "Someone (hopefully you) asked to log in to %s.\n\n"+ |
| |
| 541 | "Open this link within 15 minutes. It works once:\n\n %s/login?token=%s\n\n"+ |
| |
| 542 | "If this wasn't you, ignore this mail. Nothing has changed on the account.\n", |
| |
| 543 | host, strings.TrimSuffix(cfg.Server.SiteURL, "/"), token) |
| |
| 544 | return mail.Send(cfg, address, "log in to "+host, body) |
| |
| 545 | } |
| |
| 546 | ``` |
| |
| 547 | |
| |
| 548 | `UserByUsername(name string) (User, error)` is at `internal/store/users.go:109`. |
| |
| 549 | |
| |
| 550 | - [ ] **Step 4: Write the handler** |
| |
| 551 | |
| |
| 552 | In `internal/httpd/accounts.go`, replace `renderLogin` and add `loginSubmit`: |
| |
| 553 | |
| |
| 554 | ```go |
| |
| 555 | // renderLogin draws the login page. Mode carries the registration mode so |
| |
| 556 | // the page can tell a brand-new visitor how to get an account. EmailLogin |
| |
| 557 | // says whether this instance can mail a link; Sent switches the page to the |
| |
| 558 | // confirmation that follows a request. |
| |
| 559 | func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool) { |
| |
| 560 | s.render(w, "login.html", struct { |
| |
| 561 | basePage |
| |
| 562 | Mode string // closed | invite | open |
| |
| 563 | Error string |
| |
| 564 | EmailLogin bool |
| |
| 565 | Sent bool |
| |
| 566 | }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, |
| |
| 567 | s.cfg.Registration.Mode, errMsg, s.emailLoginEnabled(), sent}) |
| |
| 568 | } |
| |
| 569 | |
| |
| 570 | // emailLoginEnabled reports whether a link can be mailed at all. There is no |
| |
| 571 | // separate switch: the capability is exactly the SMTP the instance already |
| |
| 572 | // configured for verification and notification mail. |
| |
| 573 | func (s *Server) emailLoginEnabled() bool { |
| |
| 574 | return s.cfg.Web.Mode == "accounts" && s.cfg.Mail.SMTPHost != "" |
| |
| 575 | } |
| |
| 576 | |
| |
| 577 | // loginSubmit mails a one-time login link. The response is the same page |
| |
| 578 | // whatever happened, including when nothing happened. |
| |
| 579 | func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) { |
| |
| 580 | if !s.emailLoginEnabled() { |
| |
| 581 | s.notFound(w, r) |
| |
| 582 | return |
| |
| 583 | } |
| |
| 584 | // The per-account bound lives in the store and survives a restart; this |
| |
| 585 | // one stops a single source from spending every account's budget. |
| |
| 586 | if allowed, wait := s.apiLimit.allow("login"+s.clientIP(r), true); !allowed { |
| |
| 587 | w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1)) |
| |
| 588 | http.Error(w, "too many login requests; wait a moment", http.StatusTooManyRequests) |
| |
| 589 | return |
| |
| 590 | } |
| |
| 591 | if err := control.RequestLoginLink(s.cfg, s.st, r.FormValue("identifier")); err != nil { |
| |
| 592 | log.Printf("login link: %v", err) |
| |
| 593 | } |
| |
| 594 | s.renderLogin(w, "", true) |
| |
| 595 | } |
| |
| 596 | ``` |
| |
| 597 | |
| |
| 598 | Update the two existing `renderLogin` calls in the `login` handler to pass |
| |
| 599 | `false` as the new argument. |
| |
| 600 | |
| |
| 601 | Add `"log"` and `"strconv"` to the file's imports if they are not already |
| |
| 602 | there, and `"gitbay.org/gitbay/internal/control"` if absent. |
| |
| 603 | |
| |
| 604 | - [ ] **Step 5: Add the route** |
| |
| 605 | |
| |
| 606 | In `internal/httpd/routes.go`, directly after the `GET /login` line at 100: |
| |
| 607 | |
| |
| 608 | ```go |
| |
| 609 | Route{Method: "POST", Pattern: "/login", Mutating: true, |
| |
| 610 | Handler: s.checkOrigin(s.loginSubmit)}, |
| |
| 611 | ``` |
| |
| 612 | |
| |
| 613 | - [ ] **Step 6: Update the template** |
| |
| 614 | |
| |
| 615 | Replace the top of `internal/web/templates/login.html`, keeping the "New |
| |
| 616 | here?" block below it exactly as it is: |
| |
| 617 | |
| |
| 618 | ```html |
| |
| 619 | {{define "title"}}login · {{.Site}}{{end}} |
| |
| 620 | {{define "content"}} |
| |
| 621 | <h1>Log in</h1> |
| |
| 622 | {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} |
| |
| 623 | {{if .Sent}} |
| |
| 624 | <p>If that account exists, a login link is on its way. It works once and |
| |
| 625 | expires in fifteen minutes.</p> |
| |
| 626 | {{else}} |
| |
| 627 | {{if .EmailLogin}} |
| |
| 628 | <form method="post" action="/login"> |
| |
| 629 | <label for="identifier">Username or email address</label> |
| |
| 630 | <input type="text" id="identifier" name="identifier" autocomplete="username" required> |
| |
| 631 | <button type="submit">Email me a link</button> |
| |
| 632 | </form> |
| |
| 633 | <p>Or, from a machine with your registered key:</p> |
| |
| 634 | {{else}} |
| |
| 635 | <p>Browser sessions are minted over SSH — there is no password. From a machine |
| |
| 636 | with your registered key:</p> |
| |
| 637 | {{end}} |
| |
| 638 | <pre class="message">ssh git@{{.Host}} web login</pre> |
| |
| 639 | <p>then open the printed URL within five minutes.</p> |
| |
| 640 | {{end}} |
| |
| 641 | ``` |
| |
| 642 | |
| |
| 643 | The `<label for>` is not decoration: `Web:InputWithoutLabelCheck` and #133 |
| |
| 644 | cover this, and a placeholder is not an accessible name. |
| |
| 645 | |
| |
| 646 | - [ ] **Step 7: Build, vet, and run the e2e tests** |
| |
| 647 | |
| |
| 648 | ```bash |
| |
| 649 | go build ./... && go vet ./... |
| |
| 650 | go test ./internal/httpd/ ./internal/control/ ./internal/store/ |
| |
| 651 | go test ./e2e/ -run TestEmailLogin -v -timeout 20m |
| |
| 652 | ``` |
| |
| 653 | Expected: all PASS, including `TestEveryCommandIsReachable` and |
| |
| 654 | `TestViewOnlyHasNoMutatingRoutes` in their packages. |
| |
| 655 | |
| |
| 656 | - [ ] **Step 8: Commit** |
| |
| 657 | |
| |
| 658 | ```bash |
| |
| 659 | git add internal/control/loginlink.go internal/httpd/accounts.go \ |
| |
| 660 | internal/httpd/routes.go internal/web/templates/login.html \ |
| |
| 661 | e2e/emaillogin_test.go |
| |
| 662 | git commit -m "web: request a login link by email |
| |
| 663 | |
| |
| 664 | An account with no SSH key had no way into the web UI at all: the only |
| |
| 665 | caller of CreateWebSession consumed a token that only 'web login' over SSH |
| |
| 666 | could mint. An unauthenticated POST /login now mails the same one-time |
| |
| 667 | token, throttled per account and per source, with a response that does not |
| |
| 668 | vary with whether the account exists. |
| |
| 669 | |
| |
| 670 | Closes #155" |
| |
| 671 | ``` |
| |
| 672 | |
| |
| 673 | --- |
| |
| 674 | |
| |
| 675 | ### Task 4: Parity row and the merge request |
| |
| 676 | |
| |
| 677 | The Parity wiki page is a maintained matrix of capability by surface, and the |
| |
| 678 | convention is to update the row in the change that moves it. |
| |
| 679 | |
| |
| 680 | **Files:** |
| |
| 681 | - Modify: `Parity.org` in the `krz/gitbay.wiki` clone |
| |
| 682 | |
| |
| 683 | - [ ] **Step 1: Clone or update the wiki** |
| |
| 684 | |
| |
| 685 | ```bash |
| |
| 686 | cd /tmp && git clone ssh://git@gitbay.org/krz/gitbay.wiki 2>/dev/null || \ |
| |
| 687 | (cd /tmp/gitbay.wiki && git pull) |
| |
| 688 | ``` |
| |
| 689 | |
| |
| 690 | - [ ] **Step 2: Add the row** |
| |
| 691 | |
| |
| 692 | Open `/tmp/gitbay.wiki/Parity.org`, find the table that carries the |
| |
| 693 | authentication and account rows, and add a row for browser login in the same |
| |
| 694 | format the neighbouring rows use: available on web, not applicable to CLI or |
| |
| 695 | SSH (SSH has `web login`, which is the row above). Match the file's existing |
| |
| 696 | markers rather than inventing new ones — read three neighbouring rows first. |
| |
| 697 | |
| |
| 698 | - [ ] **Step 3: Commit and push the wiki** |
| |
| 699 | |
| |
| 700 | ```bash |
| |
| 701 | cd /tmp/gitbay.wiki |
| |
| 702 | git add Parity.org |
| |
| 703 | git commit -m "Parity: browser login by emailed link" |
| |
| 704 | git push |
| |
| 705 | ``` |
| |
| 706 | |
| |
| 707 | - [ ] **Step 4: Push the branch and open the merge request** |
| |
| 708 | |
| |
| 709 | ```bash |
| |
| 710 | cd /Users/cmc/git/krz/gitbay |
| |
| 711 | git push -u origin email-login |
| |
| 712 | gitbay mr create --source email-login --target main \ |
| |
| 713 | --title "Browser login without an SSH key" --file - <<'EOF' |
| |
| 714 | An account with no SSH key could not use the web UI at all. `CreateWebSession` |
| |
| 715 | has one caller, the `/login?token=` handler, and only `web login` over SSH could |
| |
| 716 | mint a token for it. |
| |
| 717 | |
| |
| 718 | An unauthenticated `POST /login` now mails the same one-time token to a verified |
| |
| 719 | address, resolved by username or address. Bounded at five an hour per account in |
| |
| 720 | the store and by the existing token bucket per source. The response does not |
| |
| 721 | vary with whether the account exists, whether its address is verified, or |
| |
| 722 | whether it is over its budget. |
| |
| 723 | |
| |
| 724 | The session cookie moves from `SameSite=Strict` to `Lax`, because a link clicked |
| |
| 725 | in a mail client is a cross-site navigation and Strict can drop the cookie |
| |
| 726 | through the redirect. Every cookie-authenticated mutating route carries |
| |
| 727 | `checkOrigin`; the POST routes that do not take only bearer tokens or no auth at |
| |
| 728 | all. |
| |
| 729 | |
| |
| 730 | This does not widen what a browser session can do. The web dispatches with |
| |
| 731 | `ViaAPI: true`, so no `SSHOnly` command is reachable from one however it was |
| |
| 732 | obtained. |
| |
| 733 | |
| |
| 734 | Design: `docs/specs/2026-09-04-email-login-design.md`. |
| |
| 735 | Plan: `docs/plans/2026-09-04-email-login.md`. |
| |
| 736 | |
| |
| 737 | Closes #155 |
| |
| 738 | EOF |
| |
| 739 | ``` |
| |
| 740 | |
| |
| 741 | - [ ] **Step 5: Wait for CI, then merge** |
| |
| 742 | |
| |
| 743 | ```bash |
| |
| 744 | gitbay build list --json |
| |
| 745 | ``` |
| |
| 746 | |
| |
| 747 | Poll no more often than every 120 seconds: the CLI shares one SSH connection |
| |
| 748 | per instance, and the auth limiter reads a burst of failures as an attack. |
| |
| 749 | |
| |
| 750 | When green: |
| |
| 751 | |
| |
| 752 | ```bash |
| |
| 753 | gitbay mr merge <n> --strategy squash |
| |
| 754 | git checkout main && git pull |
| |
| 755 | git branch -d email-login && git push origin --delete email-login |
| |
| 756 | ``` |
| |
| 757 | |
| |
| 758 | --- |
| |
| 759 | |
| |
| 760 | ## Self-Review |
| |
| 761 | |
| |
| 762 | **Spec coverage.** Every spec section maps to a task: the exported function, |
| |
| 763 | resolution, and mail body to Task 3 Step 3; TTL to `loginLinkTTL`; the durable |
| |
| 764 | per-account throttle to Task 1 and its use in Task 3; the per-IP throttle to |
| |
| 765 | Task 3 Step 4; enumeration to `TestEmailLoginDoesNotEnumerate`; the empty |
| |
| 766 | identifier to the same test; the cookie change to Task 2; the no-new-config |
| |
| 767 | decision to `emailLoginEnabled`; out-of-scope signup untouched. The spec's |
| |
| 768 | implementation gate on `checkOrigin` coverage was discharged before this plan |
| |
| 769 | was written and its result is recorded in Task 2. |
| |
| 770 | |
| |
| 771 | **Deviation.** The return type of `RequestLoginLink` changed from the spec's |
| |
| 772 | triple to a single `error`, recorded above under "Change from the spec". |
| |
| 773 | |
| |
| 774 | **Types.** `RequestLoginLink(config.Config, *store.Store, string) error`; |
| |
| 775 | `CountLoginTokensSince(int64, time.Time) (int, error)`; |
| |
| 776 | `emailLoginEnabled() bool`; `renderLogin(http.ResponseWriter, string, bool)`. |
| |
| 777 | Each is used with that signature everywhere it appears. `sessionSameSite` is |
| |
| 778 | declared in Task 2 and used in Task 2 only. |
| |
| 779 | |
| |
| 780 | **Unverified at plan time.** `Parity.org`'s table format is read at execution |
| |
| 781 | rather than guessed, which is why Task 4 Step 2 says to read neighbouring rows |
| |
| 782 | first rather than giving the row verbatim. |
| |