e2e/emaillogin_test.go

00033f022fed6fe0b36e1d7a1e2b3f1df6139ccd
gitbay/e2e/emaillogin_test.go history · blame · raw

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}