e2e/emaillogin_test.go
243 lines · 9336 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 deadline := time.Now().Add(2 * time.Second)
61 for time.Now().Before(deadline) && len(smtp.mailTo("dana@example.test")) < 2 {
62 time.Sleep(25 * time.Millisecond)
63 }
64 if n := len(smtp.mailTo("dana@example.test")); n != 2 {
65 t.Fatalf("login by username did not mail a second link: got %d mails, want 2", n)
66 }
67}
68
69// The response must not say whether an account exists. A different status,
70// body, or destination answers "is this person here?" to anyone who asks.
71func TestEmailLoginDoesNotEnumerate(t *testing.T) {
72 smtp := startFakeSMTP(t)
73 inst := startInstanceWith(t, fmt.Sprintf(
74 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
75 smtp.addr))
76 inst.admin(t, "admin", "user", "create", "dana",
77 "--email", "dana@example.test", "--verified")
78 // An account whose address was never verified must look like an absent
79 // one, or an unverified address becomes an oracle.
80 inst.admin(t, "admin", "user", "create", "eve", "--email", "eve@example.test")
81
82 browser := newBrowser(t)
83 real1, bodyReal := browserPost(t, browser, inst.base()+"/login",
84 url.Values{"identifier": {"dana@example.test"}})
85 // Pin the reference. Without this the comparison below passes just as
86 // well if renderLogin regressed and every case returned an error page.
87 if !strings.Contains(bodyReal, "on its way") {
88 t.Fatalf("a real address did not get the confirmation: %s", bodyReal)
89 }
90 absent, bodyAbsent := browserPost(t, browser, inst.base()+"/login",
91 url.Values{"identifier": {"nobody@example.test"}})
92 unver, bodyUnver := browserPost(t, browser, inst.base()+"/login",
93 url.Values{"identifier": {"eve@example.test"}})
94 empty, bodyEmpty := browserPost(t, browser, inst.base()+"/login",
95 url.Values{"identifier": {""}})
96 absentUser, bodyAbsentUser := browserPost(t, browser, inst.base()+"/login",
97 url.Values{"identifier": {"nosuchuser"}})
98
99 for _, c := range []struct {
100 name string
101 status int
102 body string
103 }{
104 {"absent", absent, bodyAbsent},
105 {"unverified", unver, bodyUnver},
106 {"empty", empty, bodyEmpty},
107 {"absent-username", absentUser, bodyAbsentUser},
108 } {
109 if c.status != real1 || c.body != bodyReal {
110 t.Errorf("%s differs from a real address: status %d vs %d", c.name, c.status, real1)
111 }
112 }
113 if len(smtp.mailTo("eve@example.test")) != 0 {
114 t.Error("mailed an unverified address")
115 }
116 if len(smtp.mailTo("nobody@example.test")) != 0 {
117 t.Error("mailed an address with no account")
118 }
119}
120
121// An anonymous endpoint that sends mail needs a durable per-account bound,
122// the same one email verification has (#136).
123func TestEmailLoginThrottled(t *testing.T) {
124 smtp := startFakeSMTP(t)
125 inst := startInstanceWith(t, fmt.Sprintf(
126 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
127 smtp.addr))
128 inst.admin(t, "admin", "user", "create", "dana",
129 "--email", "dana@example.test", "--verified")
130
131 browser := newBrowser(t)
132 var first, sixth string
133 for i := 0; i < 6; i++ {
134 _, body := browserPost(t, browser, inst.base()+"/login",
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)
159 }
160 if n != 5 {
161 t.Fatalf("sent %d login mails in an hour, want exactly 5", n)
162 }
163}
164
165// A suspended account still controls its verified address, so it can mail
166// itself a link. It must not get a session out of it: read access to the
167// private repos it is a member of is what suspension takes away.
168func TestEmailLoginRefusesDisabledAccount(t *testing.T) {
169 smtp := startFakeSMTP(t)
170 inst := startInstanceWith(t, fmt.Sprintf(
171 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
172 smtp.addr))
173 inst.admin(t, "admin", "user", "create", "dana",
174 "--email", "dana@example.test", "--verified")
175 inst.admin(t, "admin", "user", "disable", "dana")
176
177 browser := newBrowser(t)
178 status, body := browserPost(t, browser, inst.base()+"/login",
179 url.Values{"identifier": {"dana@example.test"}})
180 if status != 200 || !strings.Contains(body, "on its way") {
181 t.Fatalf("the response gave the suspension away: %d %s", status, body)
182 }
183 // Nothing should be mailed at all, but the assertion that matters is
184 // that no link completes a session, so wait long enough to catch one.
185 deadline := time.Now().Add(2 * time.Second)
186 for time.Now().Before(deadline) && len(smtp.mailTo("dana@example.test")) == 0 {
187 time.Sleep(25 * time.Millisecond)
188 }
189 if n := len(smtp.mailTo("dana@example.test")); n != 0 {
190 t.Errorf("mailed a login link to a disabled account: %d mails", n)
191 }
192
193 // Same check as the single-use one: the client follows the logged-out
194 // redirect to /login, which is a 200 either way, so read the body.
195 if _, body := browserGet(t, browser, inst.base()+"/settings"); strings.Contains(body, "dana@example.test") {
196 t.Error("a disabled account got a browser session")
197 }
198}
199
200// The other ordering: the link is minted while the account is in good
201// standing and followed after it is suspended. Suspension drops the pending
202// login tokens, and login() refuses a disabled account after consuming one,
203// so neither the window nor a token that somehow survives it opens a session.
204func TestEmailLoginRefusesLinkMintedBeforeSuspension(t *testing.T) {
205 smtp := startFakeSMTP(t)
206 inst := startInstanceWith(t, fmt.Sprintf(
207 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
208 smtp.addr))
209 inst.admin(t, "admin", "user", "create", "dana",
210 "--email", "dana@example.test", "--verified")
211
212 browser := newBrowser(t)
213 if status, _ := browserPost(t, browser, inst.base()+"/login",
214 url.Values{"identifier": {"dana@example.test"}}); status != 200 {
215 t.Fatalf("POST /login: %d", status)
216 }
217 link := loginLinkIn(smtp.waitFor(t, "dana@example.test", "/login?token="))
218
219 inst.admin(t, "admin", "user", "disable", "dana")
220
221 _, body := browserGet(t, browser, inst.base()+link)
222 if !strings.Contains(body, "invalid, expired, or already used") {
223 t.Errorf("no refusal on the login page: %s", body)
224 }
225 // A refusal that reads differently from an ordinary bad token says the
226 // account exists and is suspended, which is the leak the endpoint is
227 // built to avoid.
228 if _, bogus := browserGet(t, browser, inst.base()+"/login?token=notatoken"); body != bogus {
229 t.Error("a suspended account's refusal differs from a bad token's")
230 }
231 if _, body := browserGet(t, browser, inst.base()+"/settings"); strings.Contains(body, "dana@example.test") {
232 t.Error("a link minted before suspension still opened a session")
233 }
234}
235
236// loginLinkIn pulls the /login?token=... path out of a login mail.
237func loginLinkIn(msg string) string {
238 link := msg[strings.Index(msg, "/login?token="):]
239 if j := strings.IndexAny(link, " \r\n"); j >= 0 {
240 link = link[:j]
241 }
242 return link
243}