Commit 3f7a91e284
Verified · cmc
Layout: unified · split
e2e/emaillogin_test.go +46 −3
| @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "net/url" |
| 6 | 6 | "strings" |
| 7 | 7 | "testing" |
| 8 | "time" | |
| 8 | 9 | ) |
| 9 | 10 | |
| 10 | 11 | // A person with no SSH key can still get into the web UI: they ask for a |
| @@ -53,6 +54,21 @@ func TestEmailLogin(t *testing.T) { | ||
| 53 | 54 | if _, body := browserGet(t, second, inst.base()+"/settings"); strings.Contains(body, "dana@example.test") { |
| 54 | 55 | t.Error("login link worked twice") |
| 55 | 56 | } |
| 57 | ||
| 58 | // The identifier can also be a bare username; it resolves to the | |
| 59 | // account's verified address the same way an email address does. | |
| 60 | status, body = browserPost(t, browser, inst.base()+"/login", | |
| 61 | url.Values{"identifier": {"dana"}}) | |
| 62 | if status != 200 || !strings.Contains(body, "on its way") { | |
| 63 | t.Fatalf("POST /login by username: %d", status) | |
| 64 | } | |
| 65 | deadline := time.Now().Add(2 * time.Second) | |
| 66 | for time.Now().Before(deadline) && len(smtp.mailTo("dana@example.test")) < 2 { | |
| 67 | time.Sleep(25 * time.Millisecond) | |
| 68 | } | |
| 69 | if n := len(smtp.mailTo("dana@example.test")); n != 2 { | |
| 70 | t.Fatalf("login by username did not mail a second link: got %d mails, want 2", n) | |
| 71 | } | |
| 56 | 72 | } |
| 57 | 73 | |
| 58 | 74 | // The response must not say whether an account exists. A different status, |
| @@ -77,6 +93,8 @@ func TestEmailLoginDoesNotEnumerate(t *testing.T) { | ||
| 77 | 93 | url.Values{"identifier": {"eve@example.test"}}) |
| 78 | 94 | empty, bodyEmpty := browserPost(t, browser, inst.base()+"/login", |
| 79 | 95 | url.Values{"identifier": {""}}) |
| 96 | absentUser, bodyAbsentUser := browserPost(t, browser, inst.base()+"/login", | |
| 97 | url.Values{"identifier": {"nosuchuser"}}) | |
| 80 | 98 | |
| 81 | 99 | for _, c := range []struct { |
| 82 | 100 | name string |
| @@ -86,6 +104,7 @@ func TestEmailLoginDoesNotEnumerate(t *testing.T) { | ||
| 86 | 104 | {"absent", absent, bodyAbsent}, |
| 87 | 105 | {"unverified", unver, bodyUnver}, |
| 88 | 106 | {"empty", empty, bodyEmpty}, |
| 107 | {"absent-username", absentUser, bodyAbsentUser}, | |
| 89 | 108 | } { |
| 90 | 109 | if c.status != real1 || c.body != bodyReal { |
| 91 | 110 | t.Errorf("%s differs from a real address: status %d vs %d", c.name, c.status, real1) |
| @@ -110,11 +129,35 @@ func TestEmailLoginThrottled(t *testing.T) { | ||
| 110 | 129 | "--email", "dana@example.test", "--verified") |
| 111 | 130 | |
| 112 | 131 | browser := newBrowser(t) |
| 132 | var first, sixth string | |
| 113 | 133 | for i := 0; i < 6; i++ { |
| 114 | browserPost(t, browser, inst.base()+"/login", | |
| 134 | _, body := browserPost(t, browser, inst.base()+"/login", | |
| 115 | 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) | |
| 116 | 159 | } |
| 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) | |
| 160 | if n != 5 { | |
| 161 | t.Fatalf("sent %d login mails in an hour, want exactly 5", n) | |
| 119 | 162 | } |
| 120 | 163 | } |
internal/control/loginlink.go +19 −2
| @@ -2,6 +2,7 @@ package control | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "fmt" |
| 5 | "log/slog" | |
| 5 | 6 | "strings" |
| 6 | 7 | "time" |
| 7 | 8 | |
| @@ -12,7 +13,11 @@ import ( | ||
| 12 | 13 | |
| 13 | 14 | // maxLoginLinksPerHour bounds what one account's address can be made to |
| 14 | 15 | // receive. It matches maxEmailAddsPerHour: enough for a person who mistypes |
| 15 | // and retries, nothing for a script. | |
| 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. | |
| 16 | 21 | const maxLoginLinksPerHour = 5 |
| 17 | 22 | |
| 18 | 23 | // loginLinkTTL is longer than the five minutes an SSH-minted link gets. |
| @@ -82,5 +87,17 @@ func RequestLoginLink(cfg config.Config, st *store.Store, identifier string) err | ||
| 82 | 87 | "Open this link within 15 minutes. It works once:\n\n %s/login?token=%s\n\n"+ |
| 83 | 88 | "If this wasn't you, ignore this mail. Nothing has changed on the account.\n", |
| 84 | 89 | host, strings.TrimSuffix(cfg.Server.SiteURL, "/"), token) |
| 85 | return mail.Send(cfg, address, "log in to "+host, body) | |
| 90 | subject := "log in to " + host | |
| 91 | ||
| 92 | // Sent in the background: mail.Send is a synchronous SMTP round trip to | |
| 93 | // the relay, tens to hundreds of milliseconds against the sub-millisecond | |
| 94 | // a miss takes to answer. Returning before it completes keeps every case | |
| 95 | // — hit, miss, unverified, throttled — on the same DB-bound path, so | |
| 96 | // response time cannot answer what the response body is built not to. | |
| 97 | go func() { | |
| 98 | if err := mail.Send(cfg, address, subject, body); err != nil { | |
| 99 | slog.Error("login link mail", "address", address, "err", err) | |
| 100 | } | |
| 101 | }() | |
| 102 | return nil | |
| 86 | 103 | } |