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