Browser login without an SSH key !261

merged merged by cmc on 2026-09-05 04:20 UTC · krz/gitbay:email-login into main

13 files changed, +702 −24

Layout: unified · split

docs/specs/2026-09-04-email-login-design.md added +150
@@ -0,0 +1,150 @@
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.
e2e/emaillogin_test.go added +243
@@ -0,0 +1,243 @@
1package e2e
2
3import (
4 "fmt"
5 "net/url"
6 "strings"
7 "testing"
8 "time"
9)
10
11// A person with no SSH key can still get into the web UI: they ask for a
12// link by username or verified address and it arrives by mail (#155).
13func TestEmailLogin(t *testing.T) {
14 smtp := startFakeSMTP(t)
15 inst := startInstanceWith(t, fmt.Sprintf(
16 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
17 smtp.addr))
18
19 // No --key: this account has no way to authenticate over SSH at all,
20 // which is the whole point.
21 inst.admin(t, "admin", "user", "create", "dana",
22 "--email", "dana@example.test", "--verified")
23
24 browser := newBrowser(t)
25 status, body := browserPost(t, browser, inst.base()+"/login",
26 url.Values{"identifier": {"dana@example.test"}})
27 if status != 200 {
28 t.Fatalf("POST /login: %d", status)
29 }
30 if !strings.Contains(body, "on its way") {
31 t.Fatalf("no confirmation in body: %s", body)
32 }
33
34 link := loginLinkIn(smtp.waitFor(t, "dana@example.test", "/login?token="))
35
36 if status, _ := browserGet(t, browser, inst.base()+link); status != 200 {
37 t.Fatalf("following the link: %d", status)
38 }
39 status, body = browserGet(t, browser, inst.base()+"/settings")
40 if status != 200 || !strings.Contains(body, "dana@example.test") {
41 t.Fatalf("not logged in after the link: %d", status)
42 }
43
44 // The link is single use. The client follows the logged-out redirect to
45 // /login, so the page body tells the two apart, not the status (the
46 // redirect target is a 200 either way).
47 second := newBrowser(t)
48 browserGet(t, second, inst.base()+link)
49 if _, body := browserGet(t, second, inst.base()+"/settings"); strings.Contains(body, "dana@example.test") {
50 t.Error("login link worked twice")
51 }
52
53 // The identifier can also be a bare username; it resolves to the
54 // account's verified address the same way an email address does.
55 status, body = browserPost(t, browser, inst.base()+"/login",
56 url.Values{"identifier": {"dana"}})
57 if status != 200 || !strings.Contains(body, "on its way") {
58 t.Fatalf("POST /login by username: %d", status)
59 }
60 deadline := time.Now().Add(2 * time.Second)
61 for time.Now().Before(deadline) && len(smtp.mailTo("dana@example.test")) < 2 {
62 time.Sleep(25 * time.Millisecond)
63 }
64 if n := len(smtp.mailTo("dana@example.test")); n != 2 {
65 t.Fatalf("login by username did not mail a second link: got %d mails, want 2", n)
66 }
67}
68
69// The response must not say whether an account exists. A different status,
70// body, or destination answers "is this person here?" to anyone who asks.
71func TestEmailLoginDoesNotEnumerate(t *testing.T) {
72 smtp := startFakeSMTP(t)
73 inst := startInstanceWith(t, fmt.Sprintf(
74 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
75 smtp.addr))
76 inst.admin(t, "admin", "user", "create", "dana",
77 "--email", "dana@example.test", "--verified")
78 // An account whose address was never verified must look like an absent
79 // one, or an unverified address becomes an oracle.
80 inst.admin(t, "admin", "user", "create", "eve", "--email", "eve@example.test")
81
82 browser := newBrowser(t)
83 real1, bodyReal := browserPost(t, browser, inst.base()+"/login",
84 url.Values{"identifier": {"dana@example.test"}})
85 // Pin the reference. Without this the comparison below passes just as
86 // well if renderLogin regressed and every case returned an error page.
87 if !strings.Contains(bodyReal, "on its way") {
88 t.Fatalf("a real address did not get the confirmation: %s", bodyReal)
89 }
90 absent, bodyAbsent := browserPost(t, browser, inst.base()+"/login",
91 url.Values{"identifier": {"nobody@example.test"}})
92 unver, bodyUnver := browserPost(t, browser, inst.base()+"/login",
93 url.Values{"identifier": {"eve@example.test"}})
94 empty, bodyEmpty := browserPost(t, browser, inst.base()+"/login",
95 url.Values{"identifier": {""}})
96 absentUser, bodyAbsentUser := browserPost(t, browser, inst.base()+"/login",
97 url.Values{"identifier": {"nosuchuser"}})
98
99 for _, c := range []struct {
100 name string
101 status int
102 body string
103 }{
104 {"absent", absent, bodyAbsent},
105 {"unverified", unver, bodyUnver},
106 {"empty", empty, bodyEmpty},
107 {"absent-username", absentUser, bodyAbsentUser},
108 } {
109 if c.status != real1 || c.body != bodyReal {
110 t.Errorf("%s differs from a real address: status %d vs %d", c.name, c.status, real1)
111 }
112 }
113 if len(smtp.mailTo("eve@example.test")) != 0 {
114 t.Error("mailed an unverified address")
115 }
116 if len(smtp.mailTo("nobody@example.test")) != 0 {
117 t.Error("mailed an address with no account")
118 }
119}
120
121// An anonymous endpoint that sends mail needs a durable per-account bound,
122// the same one email verification has (#136).
123func TestEmailLoginThrottled(t *testing.T) {
124 smtp := startFakeSMTP(t)
125 inst := startInstanceWith(t, fmt.Sprintf(
126 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
127 smtp.addr))
128 inst.admin(t, "admin", "user", "create", "dana",
129 "--email", "dana@example.test", "--verified")
130
131 browser := newBrowser(t)
132 var first, sixth string
133 for i := 0; i < 6; i++ {
134 _, body := browserPost(t, browser, inst.base()+"/login",
135 url.Values{"identifier": {"dana@example.test"}})
136 switch i {
137 case 0:
138 first = body
139 case 5:
140 sixth = body
141 }
142 }
143 // Being over the throttle is one more class whose response must not
144 // differ from an ordinary request.
145 if sixth != first {
146 t.Error("the throttled response differs from the first")
147 }
148
149 // Mail goes out from a goroutine, not on the request path, so give the
150 // last permitted one time to land before counting.
151 var n int
152 deadline := time.Now().Add(2 * time.Second)
153 for time.Now().Before(deadline) {
154 n = len(smtp.mailTo("dana@example.test"))
155 if n >= 5 {
156 break
157 }
158 time.Sleep(25 * time.Millisecond)
159 }
160 if n != 5 {
161 t.Fatalf("sent %d login mails in an hour, want exactly 5", n)
162 }
163}
164
165// A suspended account still controls its verified address, so it can mail
166// itself a link. It must not get a session out of it: read access to the
167// private repos it is a member of is what suspension takes away.
168func TestEmailLoginRefusesDisabledAccount(t *testing.T) {
169 smtp := startFakeSMTP(t)
170 inst := startInstanceWith(t, fmt.Sprintf(
171 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
172 smtp.addr))
173 inst.admin(t, "admin", "user", "create", "dana",
174 "--email", "dana@example.test", "--verified")
175 inst.admin(t, "admin", "user", "disable", "dana")
176
177 browser := newBrowser(t)
178 status, body := browserPost(t, browser, inst.base()+"/login",
179 url.Values{"identifier": {"dana@example.test"}})
180 if status != 200 || !strings.Contains(body, "on its way") {
181 t.Fatalf("the response gave the suspension away: %d %s", status, body)
182 }
183 // Nothing should be mailed at all, but the assertion that matters is
184 // that no link completes a session, so wait long enough to catch one.
185 deadline := time.Now().Add(2 * time.Second)
186 for time.Now().Before(deadline) && len(smtp.mailTo("dana@example.test")) == 0 {
187 time.Sleep(25 * time.Millisecond)
188 }
189 if n := len(smtp.mailTo("dana@example.test")); n != 0 {
190 t.Errorf("mailed a login link to a disabled account: %d mails", n)
191 }
192
193 // Same check as the single-use one: the client follows the logged-out
194 // redirect to /login, which is a 200 either way, so read the body.
195 if _, body := browserGet(t, browser, inst.base()+"/settings"); strings.Contains(body, "dana@example.test") {
196 t.Error("a disabled account got a browser session")
197 }
198}
199
200// The other ordering: the link is minted while the account is in good
201// standing and followed after it is suspended. Suspension drops the pending
202// login tokens, and login() refuses a disabled account after consuming one,
203// so neither the window nor a token that somehow survives it opens a session.
204func TestEmailLoginRefusesLinkMintedBeforeSuspension(t *testing.T) {
205 smtp := startFakeSMTP(t)
206 inst := startInstanceWith(t, fmt.Sprintf(
207 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
208 smtp.addr))
209 inst.admin(t, "admin", "user", "create", "dana",
210 "--email", "dana@example.test", "--verified")
211
212 browser := newBrowser(t)
213 if status, _ := browserPost(t, browser, inst.base()+"/login",
214 url.Values{"identifier": {"dana@example.test"}}); status != 200 {
215 t.Fatalf("POST /login: %d", status)
216 }
217 link := loginLinkIn(smtp.waitFor(t, "dana@example.test", "/login?token="))
218
219 inst.admin(t, "admin", "user", "disable", "dana")
220
221 _, body := browserGet(t, browser, inst.base()+link)
222 if !strings.Contains(body, "invalid, expired, or already used") {
223 t.Errorf("no refusal on the login page: %s", body)
224 }
225 // A refusal that reads differently from an ordinary bad token says the
226 // account exists and is suspended, which is the leak the endpoint is
227 // built to avoid.
228 if _, bogus := browserGet(t, browser, inst.base()+"/login?token=notatoken"); body != bogus {
229 t.Error("a suspended account's refusal differs from a bad token's")
230 }
231 if _, body := browserGet(t, browser, inst.base()+"/settings"); strings.Contains(body, "dana@example.test") {
232 t.Error("a link minted before suspension still opened a session")
233 }
234}
235
236// loginLinkIn pulls the /login?token=... path out of a login mail.
237func loginLinkIn(msg string) string {
238 link := msg[strings.Index(msg, "/login?token="):]
239 if j := strings.IndexAny(link, " \r\n"); j >= 0 {
240 link = link[:j]
241 }
242 return link
243}
internal/control/loginlink.go added +114
@@ -0,0 +1,114 @@
1package control
2
3import (
4 "fmt"
5 "log/slog"
6 "strings"
7 "time"
8
9 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/mail"
11 "gitbay.org/gitbay/internal/store"
12)
13
14// maxLoginLinksPerHour bounds what one account's address can be made to
15// receive. It matches maxEmailAddsPerHour: enough for a person who mistypes
16// and retries, nothing for a script. The counter is shared with SSH-minted
17// links, not just these: CountLoginTokensSince counts every row in
18// login_tokens, and "web login" over SSH inserts into that same table
19// without consulting this bound, so five "ssh git@host web login" calls in
20// an hour also spend an account's budget here.
21const maxLoginLinksPerHour = 5
22
23// loginLinkTTL is longer than the five minutes an SSH-minted link gets.
24// That one is pasted from a terminal already open; this one has to survive
25// delivery and someone noticing the mail.
26const loginLinkTTL = 15 * time.Minute
27
28// RequestLoginLink mails a one-time login link to the account named by
29// identifier, which is a username or a verified email address.
30//
31// It is not a registered command: the caller is an unauthenticated web
32// request, and commands run as c.User. RegisterAccount is exported for the
33// same reason.
34//
35// The returned error is for the server log only. Nothing about the outcome
36// may reach the caller — that a request found an account, found one without
37// a verified address, or found nothing at all must be indistinguishable, or
38// the endpoint answers "does this person have an account here?" to anyone
39// who asks. Every miss returns nil.
40func RequestLoginLink(cfg config.Config, st *store.Store, identifier string) error {
41 if cfg.Web.Mode != "accounts" || cfg.Mail.SMTPHost == "" {
42 return nil
43 }
44 identifier = strings.TrimSpace(identifier)
45 if identifier == "" {
46 return nil
47 }
48
49 var user store.User
50 var address string
51 if strings.Contains(identifier, "@") {
52 id, ok := st.UserIDByVerifiedEmail(identifier)
53 if !ok {
54 return nil
55 }
56 u, err := st.UserByID(id)
57 if err != nil {
58 return nil
59 }
60 user, address = u, identifier
61 } else {
62 u, err := st.UserByUsername(identifier)
63 if err != nil {
64 return nil
65 }
66 addr, err := st.PrimaryVerifiedEmail(u.ID)
67 if err != nil || addr == "" {
68 return nil
69 }
70 user, address = u, addr
71 }
72 // Dispatch refuses both of these, so a session they reach only renders
73 // read paths — which is the whole of what suspension prevents, and more
74 // than pendingAllowed grants an unverified account. Returning nil rather
75 // than an error keeps the response identical to a miss.
76 if user.Disabled || user.Pending {
77 return nil
78 }
79
80 n, err := st.CountLoginTokensSince(user.ID, time.Now().Add(-time.Hour))
81 if err != nil {
82 return err
83 }
84 if n >= maxLoginLinksPerHour {
85 return nil
86 }
87
88 token, hash, err := store.NewToken()
89 if err != nil {
90 return err
91 }
92 if err := st.CreateLoginToken(user.ID, hash, loginLinkTTL); err != nil {
93 return err
94 }
95 host := siteHost(cfg)
96 body := fmt.Sprintf(
97 "Someone (hopefully you) asked to log in to %s.\n\n"+
98 "Open this link within 15 minutes. It works once:\n\n %s/login?token=%s\n\n"+
99 "If this wasn't you, ignore this mail. Nothing has changed on the account.\n",
100 host, strings.TrimSuffix(cfg.Server.SiteURL, "/"), token)
101 subject := "log in to " + host
102
103 // Sent in the background: mail.Send is a synchronous SMTP round trip to
104 // the relay, tens to hundreds of milliseconds against the sub-millisecond
105 // a miss takes to answer. Returning before it completes keeps every case
106 // — hit, miss, unverified, throttled — on the same DB-bound path, so
107 // response time cannot answer what the response body is built not to.
108 go func() {
109 if err := mail.Send(cfg, address, subject, body); err != nil {
110 slog.Error("login link mail", "user", user.ID, "err", err)
111 }
112 }()
113 return nil
114}
internal/httpd/accounts.go +73 −15
@@ -2,8 +2,10 @@ package httpd
22
33import (
44 "fmt"
5 "log"
56 "net/http"
67 "slices"
8 "strconv"
79 "strings"
810 "time"
911
@@ -18,6 +20,15 @@ import (
1820
1921const sessionCookie = "gitbay_session"
2022
23// sessionSameSite is Lax so a login link followed from a mail client keeps
24// its session through the redirect. Cross-site POSTs are refused by
25// checkOrigin and carry no Lax cookie anyway.
26const sessionSameSite = http.SameSiteLaxMode
27
28// badLoginToken is what every refused /login?token= gets, whatever the
29// reason. The reasons differ in whether the account exists.
30const badLoginToken = "that login link is invalid, expired, or already used — mint a new one"
31
2132// viewer returns the logged-in user, or a zero User for anonymous visitors.
2233// Only meaningful in accounts mode; in view_only no session route exists so
2334// every request is anonymous.
@@ -45,8 +56,9 @@ func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.Us
4556 }
4657}
4758
48// checkOrigin rejects cross-site POSTs. Sessions also use SameSite=Strict;
49// this is the second layer.
59// checkOrigin rejects cross-site POSTs. It is the primary CSRF defense:
60// sessions use SameSite=Lax, which withholds the cookie from a cross-site
61// POST but not from a cross-site top-level GET.
5062func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
5163 return func(w http.ResponseWriter, r *http.Request) {
5264 if origin := r.Header.Get("Origin"); origin != "" && origin != "null" {
@@ -61,24 +73,64 @@ func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
6173}
6274
6375// renderLogin draws the login page. Mode carries the registration mode so
64// the page can tell a brand-new visitor how to get an account.
65func (s *Server) renderLogin(w http.ResponseWriter, errMsg string) {
76// the page can tell a brand-new visitor how to get an account. EmailLogin
77// says whether this instance can mail a link; Sent switches the page to the
78// confirmation that follows a request.
79func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool) {
6680 s.render(w, "login.html", struct {
6781 basePage
68 Mode string // closed | invite | open
69 Error string
70 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, s.cfg.Registration.Mode, errMsg})
82 Mode string // closed | invite | open
83 Error string
84 EmailLogin bool
85 Sent bool
86 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()},
87 s.cfg.Registration.Mode, errMsg, s.emailLoginEnabled(), sent})
88}
89
90// emailLoginEnabled reports whether a link can be mailed at all. There is no
91// separate switch: the capability is exactly the SMTP the instance already
92// configured for verification and notification mail.
93func (s *Server) emailLoginEnabled() bool {
94 return s.cfg.Web.Mode == "accounts" && s.cfg.Mail.SMTPHost != ""
95}
96
97// loginSubmit mails a one-time login link. The response is the same page
98// whatever happened, including when nothing happened.
99func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
100 if !s.emailLoginEnabled() {
101 s.notFound(w, r)
102 return
103 }
104 // The per-account bound lives in the store and survives a restart; this
105 // one stops a single source from spending every account's budget.
106 if allowed, wait := s.apiLimit.allow("login"+s.clientIP(r), true); !allowed {
107 w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
108 http.Error(w, "too many login requests; wait a moment", http.StatusTooManyRequests)
109 return
110 }
111 if err := control.RequestLoginLink(s.cfg, s.st, r.FormValue("identifier")); err != nil {
112 log.Printf("login link: %v", err)
113 }
114 s.renderLogin(w, "", true)
71115}
72116
73117func (s *Server) login(w http.ResponseWriter, r *http.Request) {
74118 token := r.URL.Query().Get("token")
75119 if token == "" {
76 s.renderLogin(w, "")
120 s.renderLogin(w, "", false)
77121 return
78122 }
79123 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
80124 if err != nil {
81 s.renderLogin(w, "that login link is invalid, expired, or already used — mint a new one")
125 s.renderLogin(w, badLoginToken, false)
126 return
127 }
128 // A token minted before the account was suspended is still consumable,
129 // and the session it would create renders every page the account can
130 // read. Checking here covers every mint path. The message is the one a
131 // bad token gets: a distinct one would confirm the account exists.
132 if u, err := s.st.UserByID(userID); err != nil || u.Disabled {
133 s.renderLogin(w, badLoginToken, false)
82134 return
83135 }
84136 sessTok, sessHash, err := store.NewToken()
@@ -90,20 +142,26 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
90142 http.Error(w, "internal error", http.StatusInternalServerError)
91143 return
92144 }
93 http.SetCookie(w, &http.Cookie{
94 Name: sessionCookie, Value: sessTok, Path: "/",
95 HttpOnly: true, SameSite: http.SameSiteStrictMode,
145 http.SetCookie(w, s.sessionCookieFor(sessTok))
146 http.Redirect(w, r, "/", http.StatusSeeOther)
147}
148
149// sessionCookieFor is the cookie a new session ships in. Secure follows TLS
150// the way clearCookie does, so a plain-HTTP deployment still works.
151func (s *Server) sessionCookieFor(tok string) *http.Cookie {
152 return &http.Cookie{
153 Name: sessionCookie, Value: tok, Path: "/",
154 HttpOnly: true, SameSite: sessionSameSite,
96155 Secure: s.cfg.HTTP.TLS != "off",
97156 MaxAge: 7 * 24 * 3600,
98 })
99 http.Redirect(w, r, "/", http.StatusSeeOther)
157 }
100158}
101159
102160func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
103161 if ck, err := r.Cookie(sessionCookie); err == nil {
104162 s.st.DeleteWebSession(store.HashToken(ck.Value))
105163 }
106 http.SetCookie(w, s.clearCookie(sessionCookie, http.SameSiteStrictMode))
164 http.SetCookie(w, s.clearCookie(sessionCookie, sessionSameSite))
107165 http.Redirect(w, r, "/", http.StatusSeeOther)
108166}
109167
internal/httpd/cookieclear_test.go +3 −4
@@ -1,7 +1,6 @@
11package httpd
22
33import (
4 "net/http"
54 "testing"
65
76 "gitbay.org/gitbay/internal/config"
@@ -15,7 +14,7 @@ func TestClearCookieMirrorsTheSettingCall(t *testing.T) {
1514 for _, tls := range []string{"acme", "off"} {
1615 s := &Server{cfg: config.Config{}}
1716 s.cfg.HTTP.TLS = tls
18 c := s.clearCookie(sessionCookie, http.SameSiteStrictMode)
17 c := s.clearCookie(sessionCookie, sessionSameSite)
1918
2019 if c.Value != "" || c.MaxAge >= 0 {
2120 t.Errorf("tls=%s: not an expiring cookie: value=%q maxage=%d", tls, c.Value, c.MaxAge)
@@ -23,8 +22,8 @@ func TestClearCookieMirrorsTheSettingCall(t *testing.T) {
2322 if !c.HttpOnly {
2423 t.Errorf("tls=%s: clearing cookie is not HttpOnly", tls)
2524 }
26 if c.SameSite != http.SameSiteStrictMode {
27 t.Errorf("tls=%s: SameSite = %v, want Strict", tls, c.SameSite)
25 if c.SameSite != sessionSameSite {
26 t.Errorf("tls=%s: SameSite = %v, want %v", tls, c.SameSite, sessionSameSite)
2827 }
2928 if c.Path != "/" {
3029 t.Errorf("tls=%s: Path = %q, want /", tls, c.Path)
internal/httpd/logincookie_test.go added +38
@@ -0,0 +1,38 @@
1package httpd
2
3import (
4 "net/http"
5 "testing"
6
7 "gitbay.org/gitbay/internal/config"
8)
9
10// The session cookie must be Lax, not Strict. A login link clicked in a mail
11// client is a cross-site top-level navigation, and Strict can withhold the
12// cookie through the redirect that follows, so the visitor lands logged out
13// (#155). Cross-site POSTs stay protected: Lax withholds the cookie from them,
14// and checkOrigin refuses them besides.
15//
16// The other two attributes are what keep the token out of a script's reach
17// and off the wire in clear, so they are asserted on the same literal login
18// hands to http.SetCookie.
19func TestSessionCookieAttributes(t *testing.T) {
20 if sessionSameSite != http.SameSiteLaxMode {
21 t.Errorf("sessionSameSite = %v, want Lax", sessionSameSite)
22 }
23 for _, tls := range []string{"acme", "off"} {
24 s := &Server{cfg: config.Config{}}
25 s.cfg.HTTP.TLS = tls
26 c := s.sessionCookieFor("tok")
27
28 if c.SameSite != http.SameSiteLaxMode {
29 t.Errorf("tls=%s: SameSite = %v, want Lax", tls, c.SameSite)
30 }
31 if !c.HttpOnly {
32 t.Errorf("tls=%s: session cookie is not HttpOnly", tls)
33 }
34 if want := tls != "off"; c.Secure != want {
35 t.Errorf("tls=%s: Secure = %v, want %v", tls, c.Secure, want)
36 }
37 }
38}
internal/httpd/routes.go +2
@@ -98,6 +98,8 @@ func (s *Server) Routes() []Route {
9898 if s.cfg.Web.Mode == "accounts" {
9999 routes = append(routes,
100100 Route{Method: "GET", Pattern: "/login", Handler: s.login, Mutating: true}, // consumes a one-time token
101 Route{Method: "POST", Pattern: "/login", Mutating: true,
102 Handler: s.checkOrigin(s.loginSubmit)},
101103 Route{Method: "POST", Pattern: "/logout", Mutating: true,
102104 Handler: s.checkOrigin(s.logout)},
103105 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
internal/store/migrations/0040_login_token_index.down.sql added +1
@@ -0,0 +1 @@
1DROP INDEX login_tokens_user_created;
internal/store/migrations/0040_login_token_index.up.sql added +1
@@ -0,0 +1 @@
1CREATE INDEX login_tokens_user_created ON login_tokens(user_id, created_at);
internal/store/sessions.go +11
@@ -35,6 +35,17 @@ func (s *Store) CreateLoginToken(userID int64, hash string, ttl time.Duration) e
3535 return err
3636}
3737
38// CountLoginTokensSince counts the login tokens minted for a user within a
39// window. An unauthenticated request can ask for a login link, so the mint
40// needs a durable per-account bound the way email verification does (#136).
41func (s *Store) CountLoginTokensSince(userID int64, since time.Time) (int, error) {
42 var n int
43 err := s.DB.QueryRow(
44 "SELECT count(*) FROM login_tokens WHERE user_id = ? AND created_at > ?",
45 userID, fmtTime(since)).Scan(&n)
46 return n, err
47}
48
3849// ConsumeLoginToken redeems a token exactly once; expired or used tokens
3950// fail identically.
4051func (s *Store) ConsumeLoginToken(hash string) (int64, error) {
internal/store/sessions_test.go added +46
@@ -0,0 +1,46 @@
1package store
2
3import (
4 "testing"
5 "time"
6)
7
8func TestCountLoginTokensSince(t *testing.T) {
9 s := open(t)
10 if err := s.MigrateUp(); err != nil {
11 t.Fatal(err)
12 }
13 uid, err := s.CreateUser("cmc", true)
14 if err != nil {
15 t.Fatal(err)
16 }
17 for i := 0; i < 3; i++ {
18 _, hash, err := NewToken()
19 if err != nil {
20 t.Fatal(err)
21 }
22 if err := s.CreateLoginToken(uid, hash, time.Minute); err != nil {
23 t.Fatal(err)
24 }
25 }
26
27 n, err := s.CountLoginTokensSince(uid, time.Now().Add(-time.Hour))
28 if err != nil || n != 3 {
29 t.Fatalf("count in the last hour = %d, %v; want 3", n, err)
30 }
31
32 // A window that opens in the future sees none of them, which is what
33 // makes the hourly bound a window rather than a lifetime total.
34 if n, err := s.CountLoginTokensSince(uid, time.Now().Add(time.Hour)); err != nil || n != 0 {
35 t.Fatalf("count in a future window = %d, %v; want 0", n, err)
36 }
37
38 // One account's requests must not spend another account's budget.
39 other, err := s.CreateUser("kim", false)
40 if err != nil {
41 t.Fatal(err)
42 }
43 if n, err := s.CountLoginTokensSince(other, time.Now().Add(-time.Hour)); err != nil || n != 0 {
44 t.Fatalf("other account count = %d, %v; want 0", n, err)
45 }
46}
internal/store/users.go +6 −5
@@ -167,8 +167,9 @@ func (s *Store) ListEmails(userID int64) ([]Email, error) {
167167 return out, rows.Err()
168168}
169169
170// SetUserDisabled suspends or restores an account. Disabling also drops
171// the user's web sessions; their keys and tokens stay registered but are
170// SetUserDisabled suspends or restores an account. Disabling drops every
171// credential that would grant a session on its own — web sessions, API
172// tokens, unclaimed login links — and leaves the SSH keys registered but
172173// refused at every entry point until re-enabled.
173174func (s *Store) SetUserDisabled(userID int64, disabled bool) error {
174175 v := 0
@@ -183,9 +184,9 @@ func (s *Store) SetUserDisabled(userID int64, disabled bool) error {
183184 return ErrNotFound
184185 }
185186 if disabled {
186 // Every credential the account holds goes with it: browser
187 // sessions and API tokens. Re-enabling means minting again.
188 for _, table := range []string{"web_sessions", "api_tokens"} {
187 // A pending login link is a session in waiting, so it goes with
188 // the sessions and API tokens. Re-enabling means minting again.
189 for _, table := range []string{"web_sessions", "api_tokens", "login_tokens"} {
189190 if _, err = s.DB.Exec("DELETE FROM "+table+" WHERE user_id = ?", userID); err != nil {
190191 return err
191192 }
internal/web/templates/login.html +14
@@ -2,10 +2,24 @@
22{{define "content"}}
33<h1>Log in</h1>
44{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
5{{if .Sent}}
6<p>If that account exists, a login link is on its way. It works once and
7expires in fifteen minutes.</p>
8{{else}}
9{{if .EmailLogin}}
10<form method="post" action="/login">
11 <label for="identifier">Username or email address</label>
12 <input type="text" id="identifier" name="identifier" autocomplete="username" required>
13 <button type="submit">Email me a link</button>
14</form>
15<p>Or, from a machine with your registered key:</p>
16{{else}}
517<p>Browser sessions are minted over SSH — there is no password. From a machine
618with your registered key:</p>
19{{end}}
720<pre class="message">ssh git@{{.Host}} web login</pre>
821<p>then open the printed URL within five minutes.</p>
22{{end}}
923<h2>New here?</h2>
1024{{if eq .Mode "open"}}<p><a href="/register">Create an account</a> — pick a username, paste your SSH
1125public key, verify your email. Or from the terminal:</p>