Commit 090dc2eb20
Verified · cmc
Layout: unified · split
e2e/emaillogin_test.go added +120
| @@ -0,0 +1,120 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 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). | ||
| 12 | func 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. | ||
| 60 | func 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). | ||
| 104 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 16 | const 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. | ||
| 21 | const 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. | ||
| 35 | func 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 | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "fmt" | 4 | "fmt" |
| 5 | "log" | ||
| 5 | "net/http" | 6 | "net/http" |
| 6 | "slices" | 7 | "slices" |
| 8 | "strconv" | ||
| 7 | "strings" | 9 | "strings" |
| 8 | "time" | 10 | "time" |
| 9 | 11 | ||
| @@ -66,24 +68,56 @@ func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc { | |||
| 66 | } | 68 | } |
| 67 | 69 | ||
| 68 | // renderLogin draws the login page. Mode carries the registration mode so | 70 | // 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. | 71 | // the page can tell a brand-new visitor how to get an account. EmailLogin |
| 70 | func (s *Server) renderLogin(w http.ResponseWriter, errMsg string) { | 72 | // says whether this instance can mail a link; Sent switches the page to the |
| 73 | // confirmation that follows a request. | ||
| 74 | func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool) { | ||
| 71 | s.render(w, "login.html", struct { | 75 | s.render(w, "login.html", struct { |
| 72 | basePage | 76 | basePage |
| 73 | Mode string // closed | invite | open | 77 | Mode string // closed | invite | open |
| 74 | Error string | 78 | Error string |
| 75 | }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, s.cfg.Registration.Mode, errMsg}) | 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. | ||
| 88 | func (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. | ||
| 94 | func (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) | ||
| 76 | } | 110 | } |
| 77 | 111 | ||
| 78 | func (s *Server) login(w http.ResponseWriter, r *http.Request) { | 112 | func (s *Server) login(w http.ResponseWriter, r *http.Request) { |
| 79 | token := r.URL.Query().Get("token") | 113 | token := r.URL.Query().Get("token") |
| 80 | if token == "" { | 114 | if token == "" { |
| 81 | s.renderLogin(w, "") | 115 | s.renderLogin(w, "", false) |
| 82 | return | 116 | return |
| 83 | } | 117 | } |
| 84 | userID, err := s.st.ConsumeLoginToken(store.HashToken(token)) | 118 | userID, err := s.st.ConsumeLoginToken(store.HashToken(token)) |
| 85 | if err != nil { | 119 | 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) |
| 87 | return | 121 | return |
| 88 | } | 122 | } |
| 89 | sessTok, sessHash, err := store.NewToken() | 123 | sessTok, sessHash, err := store.NewToken() |
internal/httpd/routes.go +2
| @@ -98,6 +98,8 @@ func (s *Server) Routes() []Route { | |||
| 98 | if s.cfg.Web.Mode == "accounts" { | 98 | if s.cfg.Web.Mode == "accounts" { |
| 99 | routes = append(routes, | 99 | routes = append(routes, |
| 100 | Route{Method: "GET", Pattern: "/login", Handler: s.login, Mutating: true}, // consumes a one-time token | 100 | 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)}, | ||
| 101 | Route{Method: "POST", Pattern: "/logout", Mutating: true, | 103 | Route{Method: "POST", Pattern: "/logout", Mutating: true, |
| 102 | Handler: s.checkOrigin(s.logout)}, | 104 | Handler: s.checkOrigin(s.logout)}, |
| 103 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, | 105 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, |
internal/web/templates/login.html +14
| @@ -2,10 +2,24 @@ | |||
| 2 | {{define "content"}} | 2 | {{define "content"}} |
| 3 | <h1>Log in</h1> | 3 | <h1>Log in</h1> |
| 4 | {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} | 4 | {{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 | ||
| 7 | expires 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}} | ||
| 5 | <p>Browser sessions are minted over SSH — there is no password. From a machine | 17 | <p>Browser sessions are minted over SSH — there is no password. From a machine |
| 6 | with your registered key:</p> | 18 | with your registered key:</p> |
| 19 | {{end}} | ||
| 7 | <pre class="message">ssh git@{{.Host}} web login</pre> | 20 | <pre class="message">ssh git@{{.Host}} web login</pre> |
| 8 | <p>then open the printed URL within five minutes.</p> | 21 | <p>then open the printed URL within five minutes.</p> |
| 22 | {{end}} | ||
| 9 | <h2>New here?</h2> | 23 | <h2>New here?</h2> |
| 10 | {{if eq .Mode "open"}}<p><a href="/register">Create an account</a> — pick a username, paste your SSH | 24 | {{if eq .Mode "open"}}<p><a href="/register">Create an account</a> — pick a username, paste your SSH |
| 11 | public key, verify your email. Or from the terminal:</p> | 25 | public key, verify your email. Or from the terminal:</p> |