Commit 3f7a91e284
Verified · cmc
Layout: unified · split
e2e/emaillogin_test.go +46 −3
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | "net/url" | 5 | "net/url" |
| 6 | "strings" | 6 | "strings" |
| 7 | "testing" | 7 | "testing" |
| 8 | "time" | ||
| 8 | ) | 9 | ) |
| 9 | 10 | ||
| 10 | // A person with no SSH key can still get into the web UI: they ask for a | 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 | if _, body := browserGet(t, second, inst.base()+"/settings"); strings.Contains(body, "dana@example.test") { | 54 | if _, body := browserGet(t, second, inst.base()+"/settings"); strings.Contains(body, "dana@example.test") { |
| 54 | t.Error("login link worked twice") | 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 | // The response must not say whether an account exists. A different status, | 74 | // The response must not say whether an account exists. A different status, |
| @@ -77,6 +93,8 @@ func TestEmailLoginDoesNotEnumerate(t *testing.T) { | |||
| 77 | url.Values{"identifier": {"eve@example.test"}}) | 93 | url.Values{"identifier": {"eve@example.test"}}) |
| 78 | empty, bodyEmpty := browserPost(t, browser, inst.base()+"/login", | 94 | empty, bodyEmpty := browserPost(t, browser, inst.base()+"/login", |
| 79 | url.Values{"identifier": {""}}) | 95 | url.Values{"identifier": {""}}) |
| 96 | absentUser, bodyAbsentUser := browserPost(t, browser, inst.base()+"/login", | ||
| 97 | url.Values{"identifier": {"nosuchuser"}}) | ||
| 80 | 98 | ||
| 81 | for _, c := range []struct { | 99 | for _, c := range []struct { |
| 82 | name string | 100 | name string |
| @@ -86,6 +104,7 @@ func TestEmailLoginDoesNotEnumerate(t *testing.T) { | |||
| 86 | {"absent", absent, bodyAbsent}, | 104 | {"absent", absent, bodyAbsent}, |
| 87 | {"unverified", unver, bodyUnver}, | 105 | {"unverified", unver, bodyUnver}, |
| 88 | {"empty", empty, bodyEmpty}, | 106 | {"empty", empty, bodyEmpty}, |
| 107 | {"absent-username", absentUser, bodyAbsentUser}, | ||
| 89 | } { | 108 | } { |
| 90 | if c.status != real1 || c.body != bodyReal { | 109 | 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) | 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 | "--email", "dana@example.test", "--verified") | 129 | "--email", "dana@example.test", "--verified") |
| 111 | 130 | ||
| 112 | browser := newBrowser(t) | 131 | browser := newBrowser(t) |
| 132 | var first, sixth string | ||
| 113 | for i := 0; i < 6; i++ { | 133 | for i := 0; i < 6; i++ { |
| 114 | browserPost(t, browser, inst.base()+"/login", | 134 | _, body := browserPost(t, browser, inst.base()+"/login", |
| 115 | url.Values{"identifier": {"dana@example.test"}}) | 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 { | 160 | if n != 5 { |
| 118 | t.Fatalf("sent %d login mails in an hour, want at most 5", n) | 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 | import ( | 3 | import ( |
| 4 | "fmt" | 4 | "fmt" |
| 5 | "log/slog" | ||
| 5 | "strings" | 6 | "strings" |
| 6 | "time" | 7 | "time" |
| 7 | 8 | ||
| @@ -12,7 +13,11 @@ import ( | |||
| 12 | 13 | ||
| 13 | // maxLoginLinksPerHour bounds what one account's address can be made to | 14 | // maxLoginLinksPerHour bounds what one account's address can be made to |
| 14 | // receive. It matches maxEmailAddsPerHour: enough for a person who mistypes | 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 | const maxLoginLinksPerHour = 5 | 21 | const maxLoginLinksPerHour = 5 |
| 17 | 22 | ||
| 18 | // loginLinkTTL is longer than the five minutes an SSH-minted link gets. | 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 | "Open this link within 15 minutes. It works once:\n\n %s/login?token=%s\n\n"+ | 87 | "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", | 88 | "If this wasn't you, ignore this mail. Nothing has changed on the account.\n", |
| 84 | host, strings.TrimSuffix(cfg.Server.SiteURL, "/"), token) | 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 | } |