aboutsummaryrefslogtreecommitdiff
path: root/auth_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'auth_test.go')
-rw-r--r--auth_test.go395
1 files changed, 395 insertions, 0 deletions
diff --git a/auth_test.go b/auth_test.go
new file mode 100644
index 0000000..35d1818
--- /dev/null
+++ b/auth_test.go
@@ -0,0 +1,395 @@
+package main
+
+import (
+ "bytes"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func testForge(t *testing.T) *Forge {
+ t.Helper()
+ store, err := openStore(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ f, err := newForge(store)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Only close removes the runtime directory newForge makes.
+ t.Cleanup(f.cgit.close)
+ return f
+}
+
+func addUser(t *testing.T, f *Forge, name, password string) {
+ t.Helper()
+ hash := hashPassword(password)
+ err := f.store.UpdateUsers("users: add "+name, f.store.forgeAuthor(), func(m map[string]User) error {
+ m[name] = User{Origin: originLocal, Notify: true, Password: hash}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func post(h http.Handler, path string, form url.Values, cookies []*http.Cookie) *httptest.ResponseRecorder {
+ r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ for _, c := range cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ return w
+}
+
+// withCookies is a request carrying a session, for asking the forge what the
+// form token bound to it is.
+func withCookies(path string, cookies []*http.Cookie) *http.Request {
+ r := httptest.NewRequest("GET", path, nil)
+ for _, c := range cookies {
+ r.AddCookie(c)
+ }
+ return r
+}
+
+func get(h http.Handler, path string, cookies []*http.Cookie) *httptest.ResponseRecorder {
+ r := httptest.NewRequest("GET", path, nil)
+ for _, c := range cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ return w
+}
+
+func TestPasswordHash(t *testing.T) {
+ hash := hashPassword("correct horse battery staple")
+ if !strings.HasPrefix(hash, "$argon2id$v=19$m=") {
+ t.Fatalf("not a PHC string: %q", hash)
+ }
+ if !passwordOK(hash, "correct horse battery staple") {
+ t.Error("the right password was rejected")
+ }
+ if passwordOK(hash, "correct horse battery stapl") {
+ t.Error("a wrong password was accepted")
+ }
+ for _, bad := range []string{"", "hunter2", "$argon2id$v=19$m=1,t=1,p=1$..$..", "$argon2i$v=19$m=1,t=1,p=1$AAAA$AAAA"} {
+ if passwordOK(bad, "hunter2") {
+ t.Errorf("password accepted against hash %q", bad)
+ }
+ }
+}
+
+func TestSessionEpochRevokes(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+
+ w := httptest.NewRecorder()
+ alice, _ := f.store.User("alice")
+ f.setSession(w, "alice", alice.Epoch)
+ cookies := w.Result().Cookies()
+ if len(cookies) != 1 {
+ t.Fatalf("expected one cookie, got %d", len(cookies))
+ }
+ c := cookies[0]
+ if !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || c.Path != "/" {
+ t.Errorf("session cookie is not HttpOnly, Lax, and rooted: %+v", c)
+ }
+
+ r := httptest.NewRequest("GET", "/", nil)
+ r.AddCookie(c)
+ if name, _, ok := f.user(r); !ok || name != "alice" {
+ t.Fatal("a fresh session was not accepted")
+ }
+
+ tampered := httptest.NewRequest("GET", "/", nil)
+ tampered.AddCookie(&http.Cookie{Name: sessionCookie, Value: c.Value[:len(c.Value)-1] + "x"})
+ if _, _, ok := f.user(tampered); ok {
+ t.Error("a tampered session was accepted")
+ }
+
+ err := f.store.UpdateUsers("users: revoke", f.store.forgeAuthor(), func(m map[string]User) error {
+ u := m["alice"]
+ u.Epoch++
+ m["alice"] = u
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, ok := f.user(r); ok {
+ t.Error("a session survived an epoch bump")
+ }
+}
+
+// Signing out everywhere has to take the browser asking for it as well as the
+// others, or the session somebody wants gone most is the one that survives.
+func TestSignOutEverywhere(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ h := f.routes()
+ sessions := [][]*http.Cookie{signIn(t, f, h, "alice"), signIn(t, f, h, "alice")}
+ for i, s := range sessions {
+ if code := get(h, "/settings", s).Code; code != http.StatusOK {
+ t.Fatalf("session %d was not usable to begin with: status %d", i, code)
+ }
+ }
+
+ form := url.Values{"csrf": {f.csrf(withCookies("/", sessions[0]))}}
+ if w := post(h, "/settings/sessions", form, sessions[0]); w.Code != http.StatusSeeOther {
+ t.Fatalf("signing out everywhere: status %d", w.Code)
+ }
+ for i, s := range sessions {
+ if code := get(h, "/settings", s).Code; code != http.StatusSeeOther {
+ t.Errorf("session %d survived: status %d", i, code)
+ }
+ }
+}
+
+func TestLoginLogout(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ h := f.routes()
+
+ if w := post(h, "/login", url.Values{"name": {"alice"}, "password": {"nope"}}, nil); w.Code != http.StatusUnauthorized {
+ t.Fatalf("wrong password: status %d", w.Code)
+ }
+ // The account name is lowercase by construction, so the shift key is forgiven.
+ w := post(h, "/login", url.Values{"name": {"Alice"}, "password": {"hunter2hunter2"}}, nil)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("right password: status %d", w.Code)
+ }
+ session := w.Result().Cookies()
+
+ if body := get(h, "/", session).Body.String(); !strings.Contains(body, `<a href="/settings">alice</a>`) {
+ t.Errorf("the bar does not show the session: %s", body)
+ }
+ if w := get(h, "/settings", nil); w.Code != http.StatusSeeOther {
+ t.Errorf("settings without a session: status %d", w.Code)
+ }
+
+ if w := post(h, "/logout", nil, session); w.Code != http.StatusForbidden {
+ t.Error("a signed in post without the form token was accepted")
+ }
+ r := httptest.NewRequest("GET", "/", nil)
+ for _, c := range session {
+ r.AddCookie(c)
+ }
+ token := url.Values{"csrf": {f.csrf(r)}}
+ if w := post(h, "/logout", token, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("logout: status %d", w.Code)
+ }
+}
+
+func TestPasswordChange(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ h := f.routes()
+
+ session := signIn(t, f, h, "alice")
+ r := httptest.NewRequest("GET", "/settings", nil)
+ for _, c := range session {
+ r.AddCookie(c)
+ }
+ form := url.Values{"csrf": {f.csrf(r)}, "current": {"hunter2hunter2"}, "new": {"opensesame1"}, "again": {"opensesame1"}}
+
+ w := post(h, "/settings/password", form, session)
+ if w.Code != http.StatusOK {
+ t.Fatalf("password change: status %d, %s", w.Code, w.Body.String())
+ }
+ // The bump killed the cookie the request carried, so the response has to
+ // both reissue it and still render as signed in.
+ if len(w.Result().Cookies()) != 1 {
+ t.Error("the password change did not reissue the session cookie")
+ }
+ // The header only names the account when there is a session, so this is
+ // how the page says the reissued cookie took.
+ if !strings.Contains(w.Body.String(), `<a href="/settings">alice</a>`) {
+ t.Error("the page after a password change looks signed out")
+ }
+ if get(h, "/settings", session).Code != http.StatusSeeOther {
+ t.Error("the old session cookie still works")
+ }
+ if get(h, "/settings", w.Result().Cookies()).Code != http.StatusOK {
+ t.Error("the reissued session cookie does not work")
+ }
+ if alice, _ := f.store.User("alice"); !passwordOK(alice.Password, "opensesame1") {
+ t.Error("the new password was not stored")
+ }
+}
+
+func TestCrossSitePostRefused(t *testing.T) {
+ f := testForge(t)
+ h := f.routes()
+
+ r := httptest.NewRequest("POST", "/login", strings.NewReader("name=alice&password=x"))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("Origin", "https://evil.example")
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusForbidden {
+ t.Errorf("cross-site post: status %d", w.Code)
+ }
+}
+
+// A budget spent by successful sign ins would refuse people doing nothing
+// wrong.
+func TestLoginLimitCountsFailuresOnly(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ h := f.routes()
+ key := "login:" + f.peer(httptest.NewRequest("POST", "/login", nil))
+
+ for range 3 {
+ if code := post(h, "/login", url.Values{"name": {"alice"}, "password": {"hunter2hunter2"}}, nil).Code; code != http.StatusSeeOther {
+ t.Fatalf("a correct password was refused: status %d", code)
+ }
+ }
+ if f.loginLimit.over(key, 0) {
+ t.Error("signing in correctly spent the budget for guessing")
+ }
+ for range 3 {
+ post(h, "/login", url.Values{"name": {"alice"}, "password": {"nope"}}, nil)
+ }
+ if !f.loginLimit.over(key, 2) {
+ t.Error("failures were not counted")
+ }
+ if f.loginLimit.over(key, 3) {
+ t.Error("more was counted than was tried")
+ }
+}
+
+// Behind something that terminates the connection, every request arrives from
+// the same address and every budget becomes one bucket. Naming a header is the
+// way out, and it is a trust decision: an unnamed one is never read, and a
+// value that is not a single address is never counted.
+func TestClientAddressFromAHeader(t *testing.T) {
+ f := testForge(t)
+ r := httptest.NewRequest("POST", "/login", nil)
+ r.RemoteAddr = "127.0.0.1:5555"
+ r.Header.Set("CF-Connecting-IP", "203.0.113.9")
+
+ if got := f.peer(r); got != "127.0.0.1" {
+ t.Errorf("a header nobody named was read: %q", got)
+ }
+ f.cfg.ClientIPHeader = "CF-Connecting-IP"
+ if got := f.peer(r); got != "203.0.113.9" {
+ t.Errorf("the named header was not read: %q", got)
+ }
+ // Anything that is not one address is nobody's address.
+ for _, junk := range []string{"", " ", "not-an-ip", "203.0.113.9, 70.41.3.18", "203.0.113.9:443"} {
+ r.Header.Set("CF-Connecting-IP", junk)
+ if got := f.peer(r); got != "127.0.0.1" {
+ t.Errorf("%q was taken as an address: %q", junk, got)
+ }
+ }
+
+ // And the budgets follow it. Filled directly, to save the password checks.
+ for range loginsPerHour + 1 {
+ f.loginLimit.charge("login:203.0.113.9")
+ }
+ h := f.routes()
+ attempt := func(ip string) int {
+ body := url.Values{"name": {"alice"}, "password": {"wrong"}}.Encode()
+ req := httptest.NewRequest("POST", "/login", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("CF-Connecting-IP", ip)
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+ return w.Code
+ }
+ if code := attempt("203.0.113.9"); code != http.StatusTooManyRequests {
+ t.Errorf("the client that spent the budget was not refused: %d", code)
+ }
+ if code := attempt("198.51.100.4"); code != http.StatusUnauthorized {
+ t.Errorf("another client behind the same proxy shared its budget: %d", code)
+ }
+
+ // A name that could never match is refused at startup.
+ cfg := defaultConfig()
+ cfg.ClientIPHeader = "CF-Connecting-IP:"
+ if err := cfg.validate(); err == nil {
+ t.Error("a header name with a colon in it was accepted")
+ }
+ cfg.ClientIPHeader = "CF-Connecting-IP"
+ if err := cfg.validate(); err != nil {
+ t.Errorf("a usable header name was refused: %v", err)
+ }
+}
+
+// A budget spent only by failures is read by over, and a request over refuses
+// never reaches charge. Rolling the window in charge alone would lock an
+// address out for ever.
+func TestLoginLimitLetsGoAfterAnHour(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ h := f.routes()
+
+ key := "login:" + f.peer(httptest.NewRequest("POST", "/login", nil))
+ for range loginsPerHour + 1 {
+ f.loginLimit.charge(key)
+ }
+ right := url.Values{"name": {"alice"}, "password": {"hunter2hunter2"}}
+ if code := post(h, "/login", right, nil).Code; code != http.StatusTooManyRequests {
+ t.Fatalf("the budget did not run out: status %d", code)
+ }
+
+ f.loginLimit.mu.Lock()
+ f.loginLimit.opened = time.Now().Add(-90 * time.Minute)
+ f.loginLimit.mu.Unlock()
+
+ if code := post(h, "/login", right, nil).Code; code != http.StatusSeeOther {
+ t.Errorf("still refused an hour after the window ran out: status %d", code)
+ }
+}
+
+// Without a cap on the body itself, an unauthenticated POST to any route fills
+// the disk. Asserted on the route that needs no account at all.
+func TestFormBodyIsCapped(t *testing.T) {
+ f := testForge(t)
+ h := f.routes()
+
+ var body bytes.Buffer
+ mw := multipart.NewWriter(&body)
+ part, err := mw.CreateFormFile("logo_file", "big.bin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := part.Write(make([]byte, maxUpload+1<<20)); err != nil {
+ t.Fatal(err)
+ }
+ mw.Close()
+
+ r := httptest.NewRequest("POST", "/login", &body)
+ r.Header.Set("Content-Type", mw.FormDataContentType())
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("an oversized multipart body was accepted: status %d", w.Code)
+ }
+
+ long := url.Values{"name": {strings.Repeat("x", maxForm+1)}, "password": {"x"}}
+ if code := post(h, "/login", long, nil).Code; code != http.StatusBadRequest {
+ t.Errorf("an oversized form body was accepted: status %d", code)
+ }
+
+ // And the cap is generous enough for what the forms are for: a wiki page at
+ // the size limit, in text where every byte percent encodes to six.
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers})
+ session := signIn(t, f, h, "alice")
+ page := url.Values{
+ "csrf": {f.csrf(withCookies("/", session))}, "base": {""}, "action": {"save"},
+ "body": {strings.Repeat("é", maxBody/2)},
+ }
+ if code := post(h, "/notes/wiki/", page, session).Code; code != http.StatusSeeOther {
+ t.Errorf("a wiki page at the size limit was refused: status %d", code)
+ }
+}