e2e/emaillogin_test.go
303 lines · 12004 bytes
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 t.Parallel()
15 smtp := startFakeSMTP(t)
16 inst := startInstanceWith(t, fmt.Sprintf(
17 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
18 smtp.addr))
19
20 // No --key: this account has no way to authenticate over SSH at all,
21 // which is the whole point.
22 inst.admin(t, "admin", "user", "create", "dana",
23 "--email", "dana@example.test", "--verified")
24
25 browser := newBrowser(t)
26 status, body := browserPost(t, browser, inst.base()+"/login",
27 url.Values{"identifier": {"dana@example.test"}})
28 if status != 200 {
29 t.Fatalf("POST /login: %d", status)
30 }
31 if !strings.Contains(body, "on its way") {
32 t.Fatalf("no confirmation in body: %s", body)
33 }
34
35 link := loginLinkIn(smtp.waitFor(t, "dana@example.test", "/login?token="))
36
37 if status, _ := browserGet(t, browser, inst.base()+link); status != 200 {
38 t.Fatalf("following the link: %d", status)
39 }
40 status, body = browserGet(t, browser, inst.base()+"/settings")
41 if status != 200 || !strings.Contains(body, "dana@example.test") {
42 t.Fatalf("not logged in after the link: %d", status)
43 }
44
45 // The link is single use. The client follows the logged-out redirect to
46 // /login, so the page body tells the two apart, not the status (the
47 // redirect target is a 200 either way).
48 second := newBrowser(t)
49 browserGet(t, second, inst.base()+link)
50 if _, body := browserGet(t, second, inst.base()+"/settings"); strings.Contains(body, "dana@example.test") {
51 t.Error("login link worked twice")
52 }
53
54 // The identifier can also be a bare username; it resolves to the
55 // account's verified address the same way an email address does.
56 status, body = browserPost(t, browser, inst.base()+"/login",
57 url.Values{"identifier": {"dana"}})
58 if status != 200 || !strings.Contains(body, "on its way") {
59 t.Fatalf("POST /login by username: %d", status)
60 }
61 // Mail goes out through the notification queue, not on the request
62 // path, so give the mailer's poll tick time to pick it up.
63 deadline := time.Now().Add(5 * time.Second)
64 for time.Now().Before(deadline) && len(smtp.mailTo("dana@example.test")) < 2 {
65 time.Sleep(25 * time.Millisecond)
66 }
67 if n := len(smtp.mailTo("dana@example.test")); n != 2 {
68 t.Fatalf("login by username did not mail a second link: got %d mails, want 2", n)
69 }
70}
71
72// A verified secondary address stands in for an unverified primary:
73// resolution by username must not stop at the primary (#158).
74func TestEmailLoginResolvesVerifiedSecondary(t *testing.T) {
75 t.Parallel()
76 smtp := startFakeSMTP(t)
77 inst := startInstanceWith(t, fmt.Sprintf(
78 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
79 smtp.addr))
80
81 key := inst.newKey(t, "gus")
82 inst.admin(t, "admin", "user", "create", "gus", "--key", key+".pub",
83 "--email", "gus@primary.test") // primary added, left unverified
84 if _, errOut, code := inst.ssh(t, key, "", "email", "add", "gus@secondary.test"); code != 0 {
85 t.Fatalf("email add: exit %d %s", code, errOut)
86 }
87 verifyCode := extractCode(t, smtp.waitMail(t, 0))
88 if _, errOut, code := inst.ssh(t, key, "", "email", "verify", verifyCode); code != 0 {
89 t.Fatalf("email verify: exit %d %s", code, errOut)
90 }
91
92 browser := newBrowser(t)
93 status, body := browserPost(t, browser, inst.base()+"/login", url.Values{"identifier": {"gus"}})
94 if status != 200 || !strings.Contains(body, "on its way") {
95 t.Fatalf("POST /login by username with an unverified primary: %d %s", status, body)
96 }
97
98 link := loginLinkIn(smtp.waitFor(t, "gus@secondary.test", "/login?token="))
99 if status, _ := browserGet(t, browser, inst.base()+link); status != 200 {
100 t.Fatalf("following the link: %d", status)
101 }
102 if _, body := browserGet(t, browser, inst.base()+"/settings"); !strings.Contains(body, "gus@secondary.test") {
103 t.Fatal("not logged in via the verified secondary address")
104 }
105 if len(smtp.mailTo("gus@primary.test")) != 0 {
106 t.Error("mailed the unverified primary")
107 }
108}
109
110// The response must not say whether an account exists. A different status,
111// body, or destination answers "is this person here?" to anyone who asks.
112func TestEmailLoginDoesNotEnumerate(t *testing.T) {
113 t.Parallel()
114 smtp := startFakeSMTP(t)
115 inst := startInstanceWith(t, fmt.Sprintf(
116 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
117 smtp.addr))
118 inst.admin(t, "admin", "user", "create", "dana",
119 "--email", "dana@example.test", "--verified")
120 // An account whose address was never verified must look like an absent
121 // one, or an unverified address becomes an oracle.
122 inst.admin(t, "admin", "user", "create", "eve", "--email", "eve@example.test")
123 // An unverified primary with a verified secondary resolves the same as
124 // a normal hit (#158) — this must be indistinguishable too.
125 frankKey := inst.newKey(t, "frank")
126 inst.admin(t, "admin", "user", "create", "frank", "--key", frankKey+".pub",
127 "--email", "frank@example.test")
128 if _, errOut, code := inst.ssh(t, frankKey, "", "email", "add", "frank2@example.test"); code != 0 {
129 t.Fatalf("email add: exit %d %s", code, errOut)
130 }
131 if _, errOut, code := inst.ssh(t, frankKey, "", "email", "verify",
132 extractCode(t, smtp.waitMail(t, 0))); code != 0 {
133 t.Fatalf("email verify: exit %d %s", code, errOut)
134 }
135
136 browser := newBrowser(t)
137 real1, bodyReal := browserPost(t, browser, inst.base()+"/login",
138 url.Values{"identifier": {"dana@example.test"}})
139 // Pin the reference. Without this the comparison below passes just as
140 // well if renderLogin regressed and every case returned an error page.
141 if !strings.Contains(bodyReal, "on its way") {
142 t.Fatalf("a real address did not get the confirmation: %s", bodyReal)
143 }
144 absent, bodyAbsent := browserPost(t, browser, inst.base()+"/login",
145 url.Values{"identifier": {"nobody@example.test"}})
146 unver, bodyUnver := browserPost(t, browser, inst.base()+"/login",
147 url.Values{"identifier": {"eve@example.test"}})
148 empty, bodyEmpty := browserPost(t, browser, inst.base()+"/login",
149 url.Values{"identifier": {""}})
150 absentUser, bodyAbsentUser := browserPost(t, browser, inst.base()+"/login",
151 url.Values{"identifier": {"nosuchuser"}})
152 unverPrimary, bodyUnverPrimary := browserPost(t, browser, inst.base()+"/login",
153 url.Values{"identifier": {"frank"}})
154
155 for _, c := range []struct {
156 name string
157 status int
158 body string
159 }{
160 {"absent", absent, bodyAbsent},
161 {"unverified", unver, bodyUnver},
162 {"empty", empty, bodyEmpty},
163 {"absent-username", absentUser, bodyAbsentUser},
164 {"unverified-primary-verified-secondary", unverPrimary, bodyUnverPrimary},
165 } {
166 if c.status != real1 || c.body != bodyReal {
167 t.Errorf("%s differs from a real address: status %d vs %d", c.name, c.status, real1)
168 }
169 }
170 if len(smtp.mailTo("eve@example.test")) != 0 {
171 t.Error("mailed an unverified address")
172 }
173 if len(smtp.mailTo("nobody@example.test")) != 0 {
174 t.Error("mailed an address with no account")
175 }
176}
177
178// An anonymous endpoint that sends mail needs a durable per-account bound,
179// the same one email verification has (#136).
180func TestEmailLoginThrottled(t *testing.T) {
181 t.Parallel()
182 smtp := startFakeSMTP(t)
183 inst := startInstanceWith(t, fmt.Sprintf(
184 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
185 smtp.addr))
186 inst.admin(t, "admin", "user", "create", "dana",
187 "--email", "dana@example.test", "--verified")
188
189 browser := newBrowser(t)
190 var first, sixth string
191 for i := 0; i < 6; i++ {
192 _, body := browserPost(t, browser, inst.base()+"/login",
193 url.Values{"identifier": {"dana@example.test"}})
194 switch i {
195 case 0:
196 first = body
197 case 5:
198 sixth = body
199 }
200 }
201 // Being over the throttle is one more class whose response must not
202 // differ from an ordinary request.
203 if sixth != first {
204 t.Error("the throttled response differs from the first")
205 }
206
207 // Mail goes out through the notification queue, not on the request
208 // path, so give the last permitted one time to land before counting.
209 var n int
210 deadline := time.Now().Add(5 * time.Second)
211 for time.Now().Before(deadline) {
212 n = len(smtp.mailTo("dana@example.test"))
213 if n >= 5 {
214 break
215 }
216 time.Sleep(25 * time.Millisecond)
217 }
218 if n != 5 {
219 t.Fatalf("sent %d login mails in an hour, want exactly 5", n)
220 }
221}
222
223// A suspended account still controls its verified address, so it can mail
224// itself a link. It must not get a session out of it: read access to the
225// private repos it is a member of is what suspension takes away.
226func TestEmailLoginRefusesDisabledAccount(t *testing.T) {
227 t.Parallel()
228 smtp := startFakeSMTP(t)
229 inst := startInstanceWith(t, fmt.Sprintf(
230 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
231 smtp.addr))
232 inst.admin(t, "admin", "user", "create", "dana",
233 "--email", "dana@example.test", "--verified")
234 inst.admin(t, "admin", "user", "disable", "dana")
235
236 browser := newBrowser(t)
237 status, body := browserPost(t, browser, inst.base()+"/login",
238 url.Values{"identifier": {"dana@example.test"}})
239 if status != 200 || !strings.Contains(body, "on its way") {
240 t.Fatalf("the response gave the suspension away: %d %s", status, body)
241 }
242 // Nothing should be mailed at all, but the assertion that matters is
243 // that no link completes a session, so wait long enough to catch one.
244 deadline := time.Now().Add(2 * time.Second)
245 for time.Now().Before(deadline) && len(smtp.mailTo("dana@example.test")) == 0 {
246 time.Sleep(25 * time.Millisecond)
247 }
248 if n := len(smtp.mailTo("dana@example.test")); n != 0 {
249 t.Errorf("mailed a login link to a disabled account: %d mails", n)
250 }
251
252 // Same check as the single-use one: the client follows the logged-out
253 // redirect to /login, which is a 200 either way, so read the body.
254 if _, body := browserGet(t, browser, inst.base()+"/settings"); strings.Contains(body, "dana@example.test") {
255 t.Error("a disabled account got a browser session")
256 }
257}
258
259// The other ordering: the link is minted while the account is in good
260// standing and followed after it is suspended. Suspension drops the pending
261// login tokens, and login() refuses a disabled account after consuming one,
262// so neither the window nor a token that somehow survives it opens a session.
263func TestEmailLoginRefusesLinkMintedBeforeSuspension(t *testing.T) {
264 t.Parallel()
265 smtp := startFakeSMTP(t)
266 inst := startInstanceWith(t, fmt.Sprintf(
267 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
268 smtp.addr))
269 inst.admin(t, "admin", "user", "create", "dana",
270 "--email", "dana@example.test", "--verified")
271
272 browser := newBrowser(t)
273 if status, _ := browserPost(t, browser, inst.base()+"/login",
274 url.Values{"identifier": {"dana@example.test"}}); status != 200 {
275 t.Fatalf("POST /login: %d", status)
276 }
277 link := loginLinkIn(smtp.waitFor(t, "dana@example.test", "/login?token="))
278
279 inst.admin(t, "admin", "user", "disable", "dana")
280
281 _, body := browserGet(t, browser, inst.base()+link)
282 if !strings.Contains(body, "invalid, expired, or already used") {
283 t.Errorf("no refusal on the login page: %s", body)
284 }
285 // A refusal that reads differently from an ordinary bad token says the
286 // account exists and is suspended, which is the leak the endpoint is
287 // built to avoid.
288 if _, bogus := browserGet(t, browser, inst.base()+"/login?token=notatoken"); body != bogus {
289 t.Error("a suspended account's refusal differs from a bad token's")
290 }
291 if _, body := browserGet(t, browser, inst.base()+"/settings"); strings.Contains(body, "dana@example.test") {
292 t.Error("a link minted before suspension still opened a session")
293 }
294}
295
296// loginLinkIn pulls the /login?token=... path out of a login mail.
297func loginLinkIn(msg string) string {
298 link := msg[strings.Index(msg, "/login?token="):]
299 if j := strings.IndexAny(link, " \r\n"); j >= 0 {
300 link = link[:j]
301 }
302 return link
303}