Commit 090dc2eb20

090dc2eb2012d327e2246052c2e0be3e815dd73c

parent: 63ec505eda

Verified · cmc

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

web: request a login link by email

An account with no SSH key had no way into the web UI at all: the only
caller of CreateWebSession consumed a token that only 'web login' over SSH
could mint. An unauthenticated POST /login now mails the same one-time
token, throttled per account and per source, with a response that does not
vary with whether the account exists.

Closes #155

Layout: unified · split

e2e/emaillogin_test.go added +120
@@ -0,0 +1,120 @@
1package e2e
2
3import (
4 "fmt"
5 "net/url"
6 "strings"
7 "testing"
8)
9
10// A person with no SSH key can still get into the web UI: they ask for a
11// link by username or verified address and it arrives by mail (#155).
12func TestEmailLogin(t *testing.T) {
13 smtp := startFakeSMTP(t)
14 inst := startInstanceWith(t, fmt.Sprintf(
15 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
16 smtp.addr))
17
18 // No --key: this account has no way to authenticate over SSH at all,
19 // which is the whole point.
20 inst.admin(t, "admin", "user", "create", "dana",
21 "--email", "dana@example.test", "--verified")
22
23 browser := newBrowser(t)
24 status, body := browserPost(t, browser, inst.base()+"/login",
25 url.Values{"identifier": {"dana@example.test"}})
26 if status != 200 {
27 t.Fatalf("POST /login: %d", status)
28 }
29 if !strings.Contains(body, "on its way") {
30 t.Fatalf("no confirmation in body: %s", body)
31 }
32
33 msg := smtp.waitFor(t, "dana@example.test", "/login?token=")
34 i := strings.Index(msg, "/login?token=")
35 link := msg[i:]
36 if j := strings.IndexAny(link, " \r\n"); j >= 0 {
37 link = link[:j]
38 }
39
40 if status, _ := browserGet(t, browser, inst.base()+link); status != 200 {
41 t.Fatalf("following the link: %d", status)
42 }
43 status, body = browserGet(t, browser, inst.base()+"/settings")
44 if status != 200 || !strings.Contains(body, "dana@example.test") {
45 t.Fatalf("not logged in after the link: %d", status)
46 }
47
48 // The link is single use. The client follows the logged-out redirect to
49 // /login, so the page body tells the two apart, not the status (the
50 // redirect target is a 200 either way).
51 second := newBrowser(t)
52 browserGet(t, second, inst.base()+link)
53 if _, body := browserGet(t, second, inst.base()+"/settings"); strings.Contains(body, "dana@example.test") {
54 t.Error("login link worked twice")
55 }
56}
57
58// The response must not say whether an account exists. A different status,
59// body, or destination answers "is this person here?" to anyone who asks.
60func TestEmailLoginDoesNotEnumerate(t *testing.T) {
61 smtp := startFakeSMTP(t)
62 inst := startInstanceWith(t, fmt.Sprintf(
63 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
64 smtp.addr))
65 inst.admin(t, "admin", "user", "create", "dana",
66 "--email", "dana@example.test", "--verified")
67 // An account whose address was never verified must look like an absent
68 // one, or an unverified address becomes an oracle.
69 inst.admin(t, "admin", "user", "create", "eve", "--email", "eve@example.test")
70
71 browser := newBrowser(t)
72 real1, bodyReal := browserPost(t, browser, inst.base()+"/login",
73 url.Values{"identifier": {"dana@example.test"}})
74 absent, bodyAbsent := browserPost(t, browser, inst.base()+"/login",
75 url.Values{"identifier": {"nobody@example.test"}})
76 unver, bodyUnver := browserPost(t, browser, inst.base()+"/login",
77 url.Values{"identifier": {"eve@example.test"}})
78 empty, bodyEmpty := browserPost(t, browser, inst.base()+"/login",
79 url.Values{"identifier": {""}})
80
81 for _, c := range []struct {
82 name string
83 status int
84 body string
85 }{
86 {"absent", absent, bodyAbsent},
87 {"unverified", unver, bodyUnver},
88 {"empty", empty, bodyEmpty},
89 } {
90 if c.status != real1 || c.body != bodyReal {
91 t.Errorf("%s differs from a real address: status %d vs %d", c.name, c.status, real1)
92 }
93 }
94 if len(smtp.mailTo("eve@example.test")) != 0 {
95 t.Error("mailed an unverified address")
96 }
97 if len(smtp.mailTo("nobody@example.test")) != 0 {
98 t.Error("mailed an address with no account")
99 }
100}
101
102// An anonymous endpoint that sends mail needs a durable per-account bound,
103// the same one email verification has (#136).
104func TestEmailLoginThrottled(t *testing.T) {
105 smtp := startFakeSMTP(t)
106 inst := startInstanceWith(t, fmt.Sprintf(
107 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
108 smtp.addr))
109 inst.admin(t, "admin", "user", "create", "dana",
110 "--email", "dana@example.test", "--verified")
111
112 browser := newBrowser(t)
113 for i := 0; i < 6; i++ {
114 browserPost(t, browser, inst.base()+"/login",
115 url.Values{"identifier": {"dana@example.test"}})
116 }
117 if n := len(smtp.mailTo("dana@example.test")); n > 5 {
118 t.Fatalf("sent %d login mails in an hour, want at most 5", n)
119 }
120}
internal/control/loginlink.go added +86
@@ -0,0 +1,86 @@
1package control
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "gitbay.org/gitbay/internal/config"
9 "gitbay.org/gitbay/internal/mail"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// maxLoginLinksPerHour bounds what one account's address can be made to
14// receive. It matches maxEmailAddsPerHour: enough for a person who mistypes
15// and retries, nothing for a script.
16const maxLoginLinksPerHour = 5
17
18// loginLinkTTL is longer than the five minutes an SSH-minted link gets.
19// That one is pasted from a terminal already open; this one has to survive
20// delivery and someone noticing the mail.
21const loginLinkTTL = 15 * time.Minute
22
23// RequestLoginLink mails a one-time login link to the account named by
24// identifier, which is a username or a verified email address.
25//
26// It is not a registered command: the caller is an unauthenticated web
27// request, and commands run as c.User. RegisterAccount is exported for the
28// same reason.
29//
30// The returned error is for the server log only. Nothing about the outcome
31// may reach the caller — that a request found an account, found one without
32// a verified address, or found nothing at all must be indistinguishable, or
33// the endpoint answers "does this person have an account here?" to anyone
34// who asks. Every miss returns nil.
35func RequestLoginLink(cfg config.Config, st *store.Store, identifier string) error {
36 if cfg.Web.Mode != "accounts" || cfg.Mail.SMTPHost == "" {
37 return nil
38 }
39 identifier = strings.TrimSpace(identifier)
40 if identifier == "" {
41 return nil
42 }
43
44 var userID int64
45 var address string
46 if strings.Contains(identifier, "@") {
47 id, ok := st.UserIDByVerifiedEmail(identifier)
48 if !ok {
49 return nil
50 }
51 userID, address = id, identifier
52 } else {
53 u, err := st.UserByUsername(identifier)
54 if err != nil {
55 return nil
56 }
57 addr, err := st.PrimaryVerifiedEmail(u.ID)
58 if err != nil || addr == "" {
59 return nil
60 }
61 userID, address = u.ID, addr
62 }
63
64 n, err := st.CountLoginTokensSince(userID, time.Now().Add(-time.Hour))
65 if err != nil {
66 return err
67 }
68 if n >= maxLoginLinksPerHour {
69 return nil
70 }
71
72 token, hash, err := store.NewToken()
73 if err != nil {
74 return err
75 }
76 if err := st.CreateLoginToken(userID, hash, loginLinkTTL); err != nil {
77 return err
78 }
79 host := siteHost(cfg)
80 body := fmt.Sprintf(
81 "Someone (hopefully you) asked to log in to %s.\n\n"+
82 "Open this link within 15 minutes. It works once:\n\n %s/login?token=%s\n\n"+
83 "If this wasn't you, ignore this mail. Nothing has changed on the account.\n",
84 host, strings.TrimSuffix(cfg.Server.SiteURL, "/"), token)
85 return mail.Send(cfg, address, "log in to "+host, body)
86}
internal/httpd/accounts.go +41 −7
@@ -2,8 +2,10 @@ package httpd
22
33import (
44 "fmt"
5 "log"
56 "net/http"
67 "slices"
8 "strconv"
79 "strings"
810 "time"
911
@@ -66,24 +68,56 @@ func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
6668}
6769
6870// renderLogin draws the login page. Mode carries the registration mode so
69// the page can tell a brand-new visitor how to get an account.
70func (s *Server) renderLogin(w http.ResponseWriter, errMsg string) {
71// the page can tell a brand-new visitor how to get an account. EmailLogin
72// says whether this instance can mail a link; Sent switches the page to the
73// confirmation that follows a request.
74func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool) {
7175 s.render(w, "login.html", struct {
7276 basePage
73 Mode string // closed | invite | open
74 Error string
75 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, s.cfg.Registration.Mode, errMsg})
77 Mode string // closed | invite | open
78 Error string
79 EmailLogin bool
80 Sent bool
81 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()},
82 s.cfg.Registration.Mode, errMsg, s.emailLoginEnabled(), sent})
83}
84
85// emailLoginEnabled reports whether a link can be mailed at all. There is no
86// separate switch: the capability is exactly the SMTP the instance already
87// configured for verification and notification mail.
88func (s *Server) emailLoginEnabled() bool {
89 return s.cfg.Web.Mode == "accounts" && s.cfg.Mail.SMTPHost != ""
90}
91
92// loginSubmit mails a one-time login link. The response is the same page
93// whatever happened, including when nothing happened.
94func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
95 if !s.emailLoginEnabled() {
96 s.notFound(w, r)
97 return
98 }
99 // The per-account bound lives in the store and survives a restart; this
100 // one stops a single source from spending every account's budget.
101 if allowed, wait := s.apiLimit.allow("login"+s.clientIP(r), true); !allowed {
102 w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
103 http.Error(w, "too many login requests; wait a moment", http.StatusTooManyRequests)
104 return
105 }
106 if err := control.RequestLoginLink(s.cfg, s.st, r.FormValue("identifier")); err != nil {
107 log.Printf("login link: %v", err)
108 }
109 s.renderLogin(w, "", true)
76110}
77111
78112func (s *Server) login(w http.ResponseWriter, r *http.Request) {
79113 token := r.URL.Query().Get("token")
80114 if token == "" {
81 s.renderLogin(w, "")
115 s.renderLogin(w, "", false)
82116 return
83117 }
84118 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
85119 if err != nil {
86 s.renderLogin(w, "that login link is invalid, expired, or already used — mint a new one")
120 s.renderLogin(w, "that login link is invalid, expired, or already used — mint a new one", false)
87121 return
88122 }
89123 sessTok, sessHash, err := store.NewToken()
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/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>