e2e/emaillogin_test.go

v1.21.0
gitbay/e2e/emaillogin_test.go history · blame · raw

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}