e2e/emaillogin_test.go

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

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}