session_test.go
⎇
Raw
1package web
2
3import (
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
19func 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
27func 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
45func 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
92func 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
108func 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
118func 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
125func 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
147func 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
161func 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