session_test.go
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "net/url" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/go-webauthn/webauthn/webauthn" |
| 15 | |
| 16 | "hearthforge/internal/db" |
| 17 | ) |
| 18 | |
| 19 | func getWithCookie(h http.Handler, path string, c *http.Cookie) *httptest.ResponseRecorder { |
| 20 | req := httptest.NewRequest(http.MethodGet, path, nil) |
| 21 | req.AddCookie(c) |
| 22 | rec := httptest.NewRecorder() |
| 23 | h.ServeHTTP(rec, req) |
| 24 | return rec |
| 25 | } |
| 26 | |
| 27 | func TestPasswordChangeDropsOtherSessions(t *testing.T) { |
| 28 | _, h := newTestServer(t) |
| 29 | current, other := loginAsAdmin(t, h), loginAsAdmin(t, h) |
| 30 | rec := postForm(t, h, "/settings/password", url.Values{ |
| 31 | "current_password": {testAdminPassword}, |
| 32 | "new_password": {"new-password-1"}, "confirm_password": {"new-password-1"}, |
| 33 | }, current) |
| 34 | if loc := rec.Header().Get("Location"); loc != "/settings?success=password" { |
| 35 | t.Fatalf("Location = %q", loc) |
| 36 | } |
| 37 | if rec := getWithCookie(h, "/settings", other); rec.Code != http.StatusFound { |
| 38 | t.Errorf("other session still works: status %d", rec.Code) |
| 39 | } |
| 40 | if rec := getWithCookie(h, "/settings", current); rec.Code != http.StatusOK { |
| 41 | t.Errorf("current session was dropped: status %d", rec.Code) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestStaleSessionMustSignInAgain(t *testing.T) { |
| 46 | s, h := newTestServer(t) |
| 47 | cookie := loginAsAdmin(t, h) |
| 48 | const note = "needs a recent sign-in" |
| 49 | if strings.Contains(getWithCookie(h, "/settings", cookie).Body.String(), note) { |
| 50 | t.Error("fresh session shows the sign-in note") |
| 51 | } |
| 52 | old := time.Now().UTC().Add(-time.Hour).Format(db.ISOLayout) |
| 53 | if _, err := s.DB.ExecContext(context.Background(), `UPDATE sessions SET created_at = ?`, old); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | if page := getWithCookie(h, "/settings", cookie).Body.String(); !strings.Contains(page, note) || |
| 57 | !strings.Contains(page, `href="/login?next=%2Fsettings"`) { |
| 58 | t.Error("stale session lacks the sign-in note") |
| 59 | } |
| 60 | |
| 61 | rec := postForm(t, h, "/settings/password/remove", nil, cookie) |
| 62 | if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fsettings" { |
| 63 | t.Fatalf("remove password Location = %q", loc) |
| 64 | } |
| 65 | rec = postForm(t, h, "/settings/passkey/revoke", url.Values{"id": {"1"}}, cookie) |
| 66 | if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fsettings" { |
| 67 | t.Fatalf("revoke passkey Location = %q", loc) |
| 68 | } |
| 69 | rec = postForm(t, h, "/admin/users/reset", url.Values{"username": {"bob"}, "password": {"newpass123"}}, cookie) |
| 70 | if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fsettings" { |
| 71 | t.Fatalf("admin reset Location = %q", loc) |
| 72 | } |
| 73 | rec = postForm(t, h, "/auth/passkey/register/options", nil, cookie) |
| 74 | if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), `"reauth"`) { |
| 75 | t.Fatalf("passkey options: %d %s", rec.Code, rec.Body.String()) |
| 76 | } |
| 77 | |
| 78 | page := getWithCookie(h, "/login?next=%2Fsettings", cookie).Body.String() |
| 79 | if !strings.Contains(page, `name="next" value="/settings"`) || !strings.Contains(page, "sign in again") { |
| 80 | t.Error("login page lacks the return path or the notice") |
| 81 | } |
| 82 | for next, want := range map[string]string{"/settings": "/settings", "//evil.example": "/", "/\\evil.example": "/"} { |
| 83 | rec = postForm(t, h, "/login", url.Values{ |
| 84 | "username": {db.AdminUsername}, "password": {testAdminPassword}, "next": {next}, |
| 85 | }) |
| 86 | if loc := rec.Header().Get("Location"); loc != want { |
| 87 | t.Errorf("next %q: Location = %q, want %q", next, loc, want) |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func TestPasswordChangeIsRateLimited(t *testing.T) { |
| 93 | s, h := newTestServer(t) |
| 94 | s.Cfg.RateLimitDisabled = false |
| 95 | cookie := loginAsAdmin(t, h) |
| 96 | var loc string |
| 97 | for range 11 { |
| 98 | rec := postForm(t, h, "/settings/password", url.Values{ |
| 99 | "new_password": {"new-password-1"}, "confirm_password": {"mismatch-1"}, |
| 100 | }, cookie) |
| 101 | loc, _ = url.QueryUnescape(rec.Header().Get("Location")) |
| 102 | } |
| 103 | if !strings.Contains(loc, "Too many attempts") { |
| 104 | t.Errorf("Location = %q", loc) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestReservedUsernameIgnoresCase(t *testing.T) { |
| 109 | _, h := newTestServer(t) |
| 110 | rec := postForm(t, h, "/register", url.Values{ |
| 111 | "username": {"Admin"}, "password": {"password123"}, "password2": {"password123"}, |
| 112 | }) |
| 113 | if !strings.Contains(rec.Body.String(), "reserved") { |
| 114 | t.Errorf("body = %q", rec.Body.String()) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | func registryRequest(user, pass, remote string) *http.Request { |
| 119 | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) |
| 120 | req.SetBasicAuth(user, pass) |
| 121 | req.RemoteAddr = remote |
| 122 | return req |
| 123 | } |
| 124 | |
| 125 | func TestRegistryUserCachesVerifiedCredentials(t *testing.T) { |
| 126 | s, _ := newTestServer(t) |
| 127 | s.Cfg.RateLimitDisabled = false |
| 128 | const ip = "198.51.100.7:1" |
| 129 | // More requests than the limiter allows: only the first runs argon2. |
| 130 | for i := range 15 { |
| 131 | if _, _, ok := s.registryUser(registryRequest(db.AdminUsername, testAdminPassword, ip)); !ok { |
| 132 | t.Fatalf("request %d rejected", i) |
| 133 | } |
| 134 | } |
| 135 | hash, err := db.HashPassword("changed-password") |
| 136 | if err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | if err := s.DB.SetPasswordHash(context.Background(), 1, &hash); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | if _, _, ok := s.registryUser(registryRequest(db.AdminUsername, testAdminPassword, ip)); ok { |
| 143 | t.Error("cached credentials survived a password change") |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | func TestRegistryUserCountsParallelGuesses(t *testing.T) { |
| 148 | s, _ := newTestServer(t) |
| 149 | s.Cfg.RateLimitDisabled = false |
| 150 | const ip = "198.51.100.8:1" |
| 151 | var wg sync.WaitGroup |
| 152 | for range 10 { |
| 153 | wg.Go(func() { s.registryUser(registryRequest(db.AdminUsername, "wrong", ip)) }) |
| 154 | } |
| 155 | wg.Wait() |
| 156 | if _, _, ok := s.registryUser(registryRequest(db.AdminUsername, testAdminPassword, ip)); ok { |
| 157 | t.Error("correct password accepted after the limit was used up") |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | func TestChallengeStoreEvictsOldestWhenFull(t *testing.T) { |
| 162 | c := challengeStore{entries: map[string]challengeEntry{}} |
| 163 | c.put("first", webauthn.SessionData{}) |
| 164 | later := time.Now().Add(time.Hour) |
| 165 | for i := range maxChallenges - 1 { |
| 166 | c.entries[strconv.Itoa(i)] = challengeEntry{expires: later} |
| 167 | } |
| 168 | c.put("new", webauthn.SessionData{}) |
| 169 | if _, ok := c.peek("new"); !ok { |
| 170 | t.Error("new entry was refused") |
| 171 | } |
| 172 | if _, ok := c.peek("first"); ok { |
| 173 | t.Error("oldest entry was not evicted") |
| 174 | } |
| 175 | if len(c.entries) != maxChallenges { |
| 176 | t.Errorf("entries = %d, want %d", len(c.entries), maxChallenges) |
| 177 | } |
| 178 | } |
| 179 |