repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

14.4 KB · 486 lines · Go Raw History
  1package main
  2
  3import (
  4	"database/sql"
  5	"io"
  6	"net/http"
  7	"net/http/httptest"
  8	"net/url"
  9	"strings"
 10	"testing"
 11	"time"
 12
 13	"auth.bythewood.me/web"
 14)
 15
 16// newTestSite builds the real thing against a temporary database and a stub
 17// ntfy, so a template referencing a field that does not exist fails here rather
 18// than at execute time on the live site.
 19func newTestSite(t *testing.T) (*site, *stubNtfy) {
 20	t.Helper()
 21
 22	db, err := openDB(t.TempDir() + "/db.sqlite3")
 23	if err != nil {
 24		t.Fatalf("open db: %v", err)
 25	}
 26	t.Cleanup(func() { db.Close() })
 27
 28	dist := distFS()
 29	assets, err := web.LoadAssets(dist)
 30	if err != nil {
 31		t.Fatalf("load assets (run `make build SITE=auth.bythewood.me` first): %v", err)
 32	}
 33
 34	templates, err := templateSub()
 35	if err != nil {
 36		t.Fatalf("templates: %v", err)
 37	}
 38	renderer, err := web.NewRenderer(templates, templateFuncs,
 39		[]string{"base.html", "partials.html"}, allPages)
 40	if err != nil {
 41		t.Fatalf("renderer: %v", err)
 42	}
 43
 44	stub := newStubNtfy(t)
 45	s := &site{
 46		renderer:   renderer,
 47		db:         db,
 48		dist:       dist,
 49		assets:     assets,
 50		notifier:   stub.notifier,
 51		baseScript: assets.Script("static_src/base/index.js"),
 52		baseStyles: assets.Styles("static_src/base/index.js"),
 53	}
 54	return s, stub
 55}
 56
 57type stubNtfy struct {
 58	notifier *Notifier
 59	server   *httptest.Server
 60	bodies   []string
 61	titles   []string
 62}
 63
 64func newStubNtfy(t *testing.T) *stubNtfy {
 65	t.Helper()
 66	s := &stubNtfy{}
 67	s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 68		body, _ := io.ReadAll(r.Body)
 69		s.bodies = append(s.bodies, string(body))
 70		s.titles = append(s.titles, r.Header.Get("Title"))
 71		w.WriteHeader(http.StatusOK)
 72	}))
 73	t.Cleanup(s.server.Close)
 74
 75	s.notifier = &Notifier{
 76		client: s.server.Client(),
 77		base:   s.server.URL,
 78		topic:  ntfyTopic,
 79		token:  "tk_test",
 80	}
 81	return s
 82}
 83
 84// lastCode pulls the six digits out of the stubbed notification title, which is
 85// the only place a test can see them, the same as a phone.
 86func (s *stubNtfy) lastCode(t *testing.T) string {
 87	t.Helper()
 88	if len(s.titles) == 0 {
 89		t.Fatal("no notification was published")
 90	}
 91	title := s.titles[len(s.titles)-1]
 92	fields := strings.Fields(title)
 93	return fields[len(fields)-1]
 94}
 95
 96func TestEveryPageRenders(t *testing.T) {
 97	s, stub := newTestSite(t)
 98	if err := runInit(s.db); err != nil {
 99		t.Fatalf("init: %v", err)
100	}
101
102	// Signed out first: the four public pages.
103	for _, path := range []string{"/", "/login", "/recovery"} {
104		rec := httptest.NewRecorder()
105		s.handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
106		if rec.Code != http.StatusOK {
107			t.Fatalf("GET %s signed out: %d", path, rec.Code)
108		}
109	}
110
111	cookie := signIn(t, s, stub)
112
113	for _, path := range []string{"/account", "/sessions", "/security", "/activity"} {
114		req := httptest.NewRequest(http.MethodGet, path, nil)
115		req.AddCookie(cookie)
116		rec := httptest.NewRecorder()
117		s.handler().ServeHTTP(rec, req)
118		if rec.Code != http.StatusOK {
119			t.Fatalf("GET %s signed in: %d, body %s", path, rec.Code, rec.Body.String())
120		}
121	}
122
123	// The pages with no route of their own.
124	for name, render := range map[string]func(){
125		"notfound": func() {
126			rec := httptest.NewRecorder()
127			s.notFound(rec, httptest.NewRequest(http.MethodGet, "/nope", nil))
128			if rec.Code != http.StatusNotFound {
129				t.Fatalf("notfound: %d", rec.Code)
130			}
131		},
132		"uninitialized": func() {
133			rec := httptest.NewRecorder()
134			s.uninitialized(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
135			if rec.Code != http.StatusServiceUnavailable {
136				t.Fatalf("uninitialized: %d", rec.Code)
137			}
138		},
139		"error": func() {
140			rec := httptest.NewRecorder()
141			s.fail(rec, httptest.NewRequest(http.MethodGet, "/", nil), "testing", sql.ErrNoRows)
142			if rec.Code != http.StatusInternalServerError {
143				t.Fatalf("error: %d", rec.Code)
144			}
145		},
146		"codes": func() {
147			// Rendered by the rotate handler rather than a GET route, so
148			// nothing else here would catch a bad field on it.
149			rec := httptest.NewRecorder()
150			data := s.page(httptest.NewRequest(http.MethodGet, "/security", nil),
151				"Recovery codes", "")
152			data.NewCodes = []string{"aaaa-bbbb-cccc", "dddd-eeee-ffff"}
153			data.Remaining = 2
154			s.renderer.Render(rec, http.StatusOK, "codes.html", data)
155			if rec.Code != http.StatusOK || rec.Body.Len() < 500 {
156				t.Fatalf("codes: %d, %d bytes", rec.Code, rec.Body.Len())
157			}
158		},
159		"code": func() {
160			rec := httptest.NewRecorder()
161			s.codePage(rec, httptest.NewRequest(http.MethodGet, "/code", nil), "", "", http.StatusOK)
162			if rec.Code != http.StatusOK {
163				t.Fatalf("code: %d", rec.Code)
164			}
165		},
166	} {
167		t.Run(name, func(t *testing.T) { render() })
168	}
169}
170
171// signIn walks the real two step flow and returns the session cookie.
172func signIn(t *testing.T, s *site, stub *stubNtfy) *http.Cookie {
173	t.Helper()
174
175	rec := httptest.NewRecorder()
176	s.handler().ServeHTTP(rec, postForm("/login", url.Values{"username": {seedUsername}}))
177	if rec.Code != http.StatusOK {
178		t.Fatalf("POST /login: %d, body %s", rec.Code, rec.Body.String())
179	}
180	pending := cookieNamed(rec.Result().Cookies(), pendingCookie)
181	if pending == nil {
182		t.Fatal("no pending cookie was set")
183	}
184
185	req := postForm("/code", url.Values{"code": {stub.lastCode(t)}})
186	req.AddCookie(pending)
187	rec = httptest.NewRecorder()
188	s.handler().ServeHTTP(rec, req)
189	if rec.Code != http.StatusSeeOther {
190		t.Fatalf("POST /code: %d, body %s", rec.Code, rec.Body.String())
191	}
192
193	session := cookieNamed(rec.Result().Cookies(), sessionCookie)
194	if session == nil {
195		t.Fatal("no session cookie was set")
196	}
197	return session
198}
199
200func postForm(path string, values url.Values) *http.Request {
201	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
202	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
203	return req
204}
205
206func cookieNamed(cookies []*http.Cookie, name string) *http.Cookie {
207	for _, c := range cookies {
208		if c.Name == name && c.Value != "" {
209			return c
210		}
211	}
212	return nil
213}
214
215// A code pushed to the phone has to be useless in a browser that did not ask
216// for it, or somebody who can see the notification can sign in with it.
217func TestCodeIsBoundToTheBrowserThatAskedForIt(t *testing.T) {
218	s, stub := newTestSite(t)
219	if err := runInit(s.db); err != nil {
220		t.Fatal(err)
221	}
222
223	rec := httptest.NewRecorder()
224	s.handler().ServeHTTP(rec, postForm("/login", url.Values{"username": {seedUsername}}))
225	code := stub.lastCode(t)
226
227	// The right code, in a browser carrying no pending cookie.
228	rec = httptest.NewRecorder()
229	s.handler().ServeHTTP(rec, postForm("/code", url.Values{"code": {code}}))
230	if rec.Code == http.StatusSeeOther {
231		t.Fatal("a code was accepted from a browser that never asked for one")
232	}
233
234	// And in a browser carrying somebody else's.
235	req := postForm("/code", url.Values{"code": {code}})
236	req.AddCookie(&http.Cookie{Name: pendingCookie, Value: "someone-elses-token"})
237	rec = httptest.NewRecorder()
238	s.handler().ServeHTTP(rec, req)
239	if rec.Code == http.StatusSeeOther {
240		t.Fatal("a code was accepted with the wrong pending cookie")
241	}
242}
243
244// The rule that turns a flood of requests into one notification.
245func TestOnlyOneCodeIsOutstandingAtATime(t *testing.T) {
246	s, stub := newTestSite(t)
247	if err := runInit(s.db); err != nil {
248		t.Fatal(err)
249	}
250
251	for i := 0; i < 4; i++ {
252		rec := httptest.NewRecorder()
253		s.handler().ServeHTTP(rec, postForm("/login", url.Values{"username": {seedUsername}}))
254	}
255	if len(stub.titles) != 1 {
256		t.Fatalf("four login requests published %d notifications, want 1", len(stub.titles))
257	}
258}
259
260// The ceiling counts published notifications for the account and ignores where
261// the request came from, which is what still holds when every request arrives
262// from a different address.
263func TestSendCeilingIgnoresTheSourceAddress(t *testing.T) {
264	s, stub := newTestSite(t)
265	if err := runInit(s.db); err != nil {
266		t.Fatal(err)
267	}
268
269	for i := 0; i < sendCeiling+3; i++ {
270		// A fresh address every time, and each code consumed so the
271		// one-outstanding rule is not what stops it.
272		req := postForm("/login", url.Values{"username": {seedUsername}})
273		req.Header.Set("CF-Connecting-IP", "203.0.113."+itoa(i+1))
274		rec := httptest.NewRecorder()
275		s.handler().ServeHTTP(rec, req)
276
277		_, _ = s.db.Exec(`UPDATE pending_logins SET consumed = 1`)
278		loginBucket.tokens = loginBucket.burst
279	}
280
281	if len(stub.titles) > sendCeiling {
282		t.Fatalf("published %d notifications from %d addresses, ceiling is %d",
283			len(stub.titles), sendCeiling+3, sendCeiling)
284	}
285}
286
287func TestWrongCodesAreBurnedAfterFiveTries(t *testing.T) {
288	s, stub := newTestSite(t)
289	if err := runInit(s.db); err != nil {
290		t.Fatal(err)
291	}
292
293	rec := httptest.NewRecorder()
294	s.handler().ServeHTTP(rec, postForm("/login", url.Values{"username": {seedUsername}}))
295	pending := cookieNamed(rec.Result().Cookies(), pendingCookie)
296	real := stub.lastCode(t)
297
298	wrong := "000000"
299	if real == wrong {
300		wrong = "111111"
301	}
302	for i := 0; i < maxAttempts; i++ {
303		req := postForm("/code", url.Values{"code": {wrong}})
304		req.AddCookie(pending)
305		rec = httptest.NewRecorder()
306		s.handler().ServeHTTP(rec, req)
307		codeBucket.tokens = codeBucket.burst
308	}
309
310	// The real code must be dead too, or five guesses buys a sixth.
311	req := postForm("/code", url.Values{"code": {real}})
312	req.AddCookie(pending)
313	rec = httptest.NewRecorder()
314	s.handler().ServeHTTP(rec, req)
315	if rec.Code == http.StatusSeeOther {
316		t.Fatal("the code still worked after five wrong guesses")
317	}
318}
319
320func TestRecoveryCodesWorkOnceEach(t *testing.T) {
321	s, _ := newTestSite(t)
322	codes, err := regenerateRecoveryCodes(s.db)
323	if err != nil {
324		t.Fatal(err)
325	}
326	if len(codes) != recoveryCount {
327		t.Fatalf("generated %d codes, want %d", len(codes), recoveryCount)
328	}
329
330	remaining, err := useRecoveryCode(s.db, codes[0])
331	if err != nil {
332		t.Fatalf("first use: %v", err)
333	}
334	if remaining != recoveryCount-1 {
335		t.Fatalf("remaining %d, want %d", remaining, recoveryCount-1)
336	}
337
338	if _, err := useRecoveryCode(s.db, codes[0]); err == nil {
339		t.Fatal("the same recovery code worked twice")
340	}
341	if _, err := useRecoveryCode(s.db, "not-a-real-code"); err == nil {
342		t.Fatal("a made up recovery code was accepted")
343	}
344
345	// Regenerating has to kill the old set, or an old slip of paper still works.
346	if _, err := regenerateRecoveryCodes(s.db); err != nil {
347		t.Fatal(err)
348	}
349	if _, err := useRecoveryCode(s.db, codes[1]); err == nil {
350		t.Fatal("an old code survived a regenerate")
351	}
352}
353
354// Revocation is the reason sessions are opaque rows rather than signed cookies,
355// so it has to actually take effect on the next request.
356func TestRevokingASessionEndsIt(t *testing.T) {
357	s, stub := newTestSite(t)
358	if err := runInit(s.db); err != nil {
359		t.Fatal(err)
360	}
361	cookie := signIn(t, s, stub)
362
363	req := httptest.NewRequest(http.MethodGet, "/account", nil)
364	req.AddCookie(cookie)
365	rec := httptest.NewRecorder()
366	s.handler().ServeHTTP(rec, req)
367	if rec.Code != http.StatusOK {
368		t.Fatalf("account before revoke: %d", rec.Code)
369	}
370
371	l, err := lookupSession(s.db, req)
372	if err != nil {
373		t.Fatal(err)
374	}
375	if err := revokeSession(s.db, l.ID); err != nil {
376		t.Fatal(err)
377	}
378
379	req = httptest.NewRequest(http.MethodGet, "/account", nil)
380	req.AddCookie(cookie)
381	rec = httptest.NewRecorder()
382	s.handler().ServeHTTP(rec, req)
383	if rec.Code != http.StatusSeeOther {
384		t.Fatalf("account after revoke: %d, want a redirect to the login", rec.Code)
385	}
386}
387
388func TestVerifyAnswersForTheOtherSites(t *testing.T) {
389	s, stub := newTestSite(t)
390	if err := runInit(s.db); err != nil {
391		t.Fatal(err)
392	}
393
394	rec := httptest.NewRecorder()
395	s.handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/verify", nil))
396	if rec.Code != http.StatusUnauthorized {
397		t.Fatalf("/verify with no cookie: %d", rec.Code)
398	}
399
400	cookie := signIn(t, s, stub)
401	req := httptest.NewRequest(http.MethodGet, "/verify", nil)
402	req.AddCookie(cookie)
403	rec = httptest.NewRecorder()
404	s.handler().ServeHTTP(rec, req)
405	if rec.Code != http.StatusOK {
406		t.Fatalf("/verify with a live cookie: %d", rec.Code)
407	}
408	if !strings.Contains(rec.Body.String(), `"username":"`+seedUsername+`"`) {
409		t.Fatalf("/verify said %s", rec.Body.String())
410	}
411}
412
413// A login must never be able to bounce somebody off the platform.
414func TestSafeNextStaysOnThePlatform(t *testing.T) {
415	for next, want := range map[string]string{
416		"":                                    defaultNext,
417		"/sessions":                           "/sessions",
418		"//evil.example":                      defaultNext,
419		"/\\evil.example":                     defaultNext,
420		"https://evil.example/":               defaultNext,
421		"http://analytics.bythewood.me/":      defaultNext,
422		"https://analytics.bythewood.me/x":    "https://analytics.bythewood.me/x",
423		"https://bythewood.me.evil.example/":  defaultNext,
424		"https://logging.bythewood.me/search": "https://logging.bythewood.me/search",
425	} {
426		if got := safeNext(next); got != want {
427			t.Errorf("safeNext(%q) = %q, want %q", next, got, want)
428		}
429	}
430}
431
432func TestInitIsIdempotent(t *testing.T) {
433	s, _ := newTestSite(t)
434	if err := runInit(s.db); err != nil {
435		t.Fatal(err)
436	}
437	codes, err := countRecoveryCodes(s.db)
438	if err != nil || codes != recoveryCount {
439		t.Fatalf("after init: %d codes, %v", codes, err)
440	}
441
442	if _, err := useRecoveryCode(s.db, "aaaa-bbbb-cccc"); err == nil {
443		t.Fatal("a made up code was accepted")
444	}
445
446	// A second init must not replace the codes somebody already wrote down.
447	if err := runInit(s.db); err != nil {
448		t.Fatal(err)
449	}
450	again, _ := countRecoveryCodes(s.db)
451	if again != recoveryCount {
452		t.Fatalf("a second init changed the code count to %d", again)
453	}
454}
455
456func TestSudoExpires(t *testing.T) {
457	l := live{SudoAt: time.Now()}
458	if !l.inSudo() {
459		t.Fatal("a fresh login is not in sudo")
460	}
461	l.SudoAt = time.Now().Add(-sudoWindow - time.Minute)
462	if l.inSudo() {
463		t.Fatal("sudo outlived its window")
464	}
465}
466
467// A site's own /login is a redirect to auth, so handing it back as the return
468// address is the infinite loop it caused on logging.bythewood.me: auth sends you
469// there, the stub sends you to auth, auth sees a session and sends you there.
470func TestLoginURLNeverReturnsToALoginStub(t *testing.T) {
471	for path, want := range map[string]string{
472		"/login":                "https://logging.bythewood.me/",
473		"/login?next=/overview": "https://logging.bythewood.me/overview",
474		"/login?next=//evil":    "https://logging.bythewood.me/",
475		"/login?next=/login":    "https://logging.bythewood.me/",
476		"/overview":             "https://logging.bythewood.me/overview",
477	} {
478		r := httptest.NewRequest(http.MethodGet, path, nil)
479		r.Host = "logging.bythewood.me"
480		got := web.LoginURL(r)
481		if !strings.HasSuffix(got, url.QueryEscape(want)) {
482			t.Errorf("LoginURL(%q) = %q, want it to return to %q", path, got, want)
483		}
484	}
485}