auth_test.go
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "crypto/rand" |
| 7 | "io" |
| 8 | "io/fs" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "net/url" |
| 12 | "path/filepath" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | |
| 16 | "github.com/go-chi/chi/v5" |
| 17 | gossh "golang.org/x/crypto/ssh" |
| 18 | |
| 19 | hearthforge "hearthforge" |
| 20 | "hearthforge/internal/config" |
| 21 | "hearthforge/internal/db" |
| 22 | ) |
| 23 | |
| 24 | const testAdminPassword = "correct-horse" |
| 25 | |
| 26 | // newTestServer builds a server on a throwaway database with the admin |
| 27 | // account already created. |
| 28 | func newTestServer(t *testing.T) (*Server, http.Handler) { |
| 29 | t.Helper() |
| 30 | dir := t.TempDir() |
| 31 | database, err := db.Open(filepath.Join(dir, "test.db")) |
| 32 | if err != nil { |
| 33 | t.Fatalf("open db: %v", err) |
| 34 | } |
| 35 | t.Cleanup(func() { database.Close() }) |
| 36 | if _, err := database.InitAdmin(context.Background(), testAdminPassword); err != nil { |
| 37 | t.Fatalf("init admin: %v", err) |
| 38 | } |
| 39 | |
| 40 | s := &Server{ |
| 41 | DB: database, |
| 42 | Cfg: &config.Config{ |
| 43 | DataDir: dir, |
| 44 | OwnerDisplayName: "Owner", |
| 45 | BaseURL: "http://localhost:3000", |
| 46 | PublicOrigin: "http://localhost:3000", |
| 47 | RegistrationType: "enabled", |
| 48 | MaxUsernameBytes: 64, |
| 49 | MaxPasswordBytes: 1024, |
| 50 | MaxTextBodyBytes: 100000, |
| 51 | MaxUserUploadBytes: 2 << 20, |
| 52 | RateLimitDisabled: true, |
| 53 | }, |
| 54 | } |
| 55 | |
| 56 | r := chi.NewRouter() |
| 57 | r.Use(s.withSession) |
| 58 | static, _ := fs.Sub(hearthforge.StaticFS, "web/static") |
| 59 | r.Handle("/assets/*", http.FileServerFS(static)) |
| 60 | s.authRoutes(r) |
| 61 | s.settingsRoutes(r) |
| 62 | s.avatarRoutes(r) |
| 63 | return s, r |
| 64 | } |
| 65 | |
| 66 | // postForm sends a form request with the given cookies. |
| 67 | func postForm(t *testing.T, h http.Handler, path string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { |
| 68 | t.Helper() |
| 69 | req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) |
| 70 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 71 | for _, c := range cookies { |
| 72 | req.AddCookie(c) |
| 73 | } |
| 74 | rec := httptest.NewRecorder() |
| 75 | h.ServeHTTP(rec, req) |
| 76 | return rec |
| 77 | } |
| 78 | |
| 79 | // loginAsAdmin signs in and returns the session cookie. |
| 80 | func loginAsAdmin(t *testing.T, h http.Handler) *http.Cookie { |
| 81 | t.Helper() |
| 82 | rec := postForm(t, h, "/login", url.Values{ |
| 83 | "username": {db.AdminUsername}, |
| 84 | "password": {testAdminPassword}, |
| 85 | }) |
| 86 | if rec.Code != http.StatusFound { |
| 87 | t.Fatalf("login status = %d, want 302", rec.Code) |
| 88 | } |
| 89 | for _, c := range rec.Result().Cookies() { |
| 90 | if c.Name == sessionCookie && c.Value != "" { |
| 91 | return c |
| 92 | } |
| 93 | } |
| 94 | t.Fatal("login set no session cookie") |
| 95 | return nil |
| 96 | } |
| 97 | |
| 98 | func TestLoginPage(t *testing.T) { |
| 99 | _, h := newTestServer(t) |
| 100 | rec := httptest.NewRecorder() |
| 101 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) |
| 102 | if rec.Code != http.StatusOK { |
| 103 | t.Fatalf("status = %d, want 200", rec.Code) |
| 104 | } |
| 105 | if !strings.Contains(rec.Body.String(), "Sign in with password") { |
| 106 | t.Error("login form is missing") |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestLoginWrongPassword(t *testing.T) { |
| 111 | _, h := newTestServer(t) |
| 112 | rec := postForm(t, h, "/login", url.Values{ |
| 113 | "username": {db.AdminUsername}, |
| 114 | "password": {"wrong"}, |
| 115 | }) |
| 116 | if rec.Code != http.StatusOK { |
| 117 | t.Fatalf("status = %d, want 200", rec.Code) |
| 118 | } |
| 119 | if !strings.Contains(rec.Body.String(), "Invalid username or password") { |
| 120 | t.Error("error message is missing") |
| 121 | } |
| 122 | if len(rec.Result().Cookies()) != 0 { |
| 123 | t.Error("a failed login must not set a cookie") |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | func TestLoginSuccessAndSettings(t *testing.T) { |
| 128 | _, h := newTestServer(t) |
| 129 | cookie := loginAsAdmin(t, h) |
| 130 | |
| 131 | req := httptest.NewRequest(http.MethodGet, "/settings", nil) |
| 132 | req.AddCookie(cookie) |
| 133 | rec := httptest.NewRecorder() |
| 134 | h.ServeHTTP(rec, req) |
| 135 | if rec.Code != http.StatusOK { |
| 136 | t.Fatalf("settings status = %d, want 200", rec.Code) |
| 137 | } |
| 138 | if !strings.Contains(rec.Body.String(), "SSH Keys") { |
| 139 | t.Error("settings page is missing the SSH key section") |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | func TestAddSSHKeyStoresFingerprint(t *testing.T) { |
| 144 | s, h := newTestServer(t) |
| 145 | cookie := loginAsAdmin(t, h) |
| 146 | |
| 147 | pub, _, err := ed25519.GenerateKey(rand.Reader) |
| 148 | if err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | sshPub, err := gossh.NewPublicKey(pub) |
| 152 | if err != nil { |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | line := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(sshPub))) + " me@host" |
| 156 | |
| 157 | rec := postForm(t, h, "/settings/ssh-keys", url.Values{ |
| 158 | "name": {"My Laptop"}, |
| 159 | "public_key": {line}, |
| 160 | }, cookie) |
| 161 | if got := rec.Header().Get("Location"); !strings.Contains(got, "success=ssh_key_added") { |
| 162 | t.Fatalf("Location = %q, body = %q", got, rec.Body.String()) |
| 163 | } |
| 164 | |
| 165 | keys, err := s.DB.ListSSHKeys(context.Background(), 1) |
| 166 | if err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | if len(keys) != 1 { |
| 170 | t.Fatalf("stored %d keys, want 1", len(keys)) |
| 171 | } |
| 172 | parsed, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keys[0].PublicKey)) |
| 173 | if err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | if want := gossh.FingerprintSHA256(parsed); keys[0].Fingerprint != want { |
| 177 | t.Errorf("fingerprint = %q, want %q", keys[0].Fingerprint, want) |
| 178 | } |
| 179 | if keys[0].Name != "My Laptop" { |
| 180 | t.Errorf("name = %q", keys[0].Name) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func TestAddSSHKeyRejectsBadType(t *testing.T) { |
| 185 | _, h := newTestServer(t) |
| 186 | cookie := loginAsAdmin(t, h) |
| 187 | rec := postForm(t, h, "/settings/ssh-keys", url.Values{ |
| 188 | "name": {"bad"}, |
| 189 | "public_key": {"ssh-dss AAAAB3NzaC1kc3M="}, |
| 190 | }, cookie) |
| 191 | if got := rec.Header().Get("Location"); !strings.Contains(got, "error=") { |
| 192 | t.Fatalf("Location = %q", got) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | func TestAvatarRoutes(t *testing.T) { |
| 197 | _, h := newTestServer(t) |
| 198 | |
| 199 | // The admin avatar is written on demand, and only with a version query. |
| 200 | rec := httptest.NewRecorder() |
| 201 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/avatars/1.png", nil)) |
| 202 | if rec.Code != http.StatusNotFound { |
| 203 | t.Errorf("status without ?v = %d, want 404", rec.Code) |
| 204 | } |
| 205 | |
| 206 | rec = httptest.NewRecorder() |
| 207 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/avatars/1.png?v=1", nil)) |
| 208 | if rec.Code != http.StatusOK { |
| 209 | t.Fatalf("status = %d, want 200", rec.Code) |
| 210 | } |
| 211 | if ct := rec.Header().Get("Content-Type"); ct != "image/png" { |
| 212 | t.Errorf("Content-Type = %q", ct) |
| 213 | } |
| 214 | if !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") { |
| 215 | t.Errorf("Cache-Control = %q", rec.Header().Get("Cache-Control")) |
| 216 | } |
| 217 | |
| 218 | rec = httptest.NewRecorder() |
| 219 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/avatars/999.png?v=1", nil)) |
| 220 | if rec.Code != http.StatusNotFound { |
| 221 | t.Errorf("unknown user status = %d, want 404", rec.Code) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | // The literal /assets/code-theme.css must win over the /assets/* static file |
| 226 | // handler that server.go mounts first. |
| 227 | func TestCodeThemeBeatsStaticMount(t *testing.T) { |
| 228 | _, h := newTestServer(t) |
| 229 | rec := httptest.NewRecorder() |
| 230 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/code-theme.css", nil)) |
| 231 | if rec.Code != http.StatusOK { |
| 232 | t.Fatalf("status = %d, want 200", rec.Code) |
| 233 | } |
| 234 | if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/css") { |
| 235 | t.Errorf("Content-Type = %q", ct) |
| 236 | } |
| 237 | if !strings.Contains(rec.Body.String(), ".ch-k {") { |
| 238 | t.Error("body is not the generated chroma stylesheet") |
| 239 | } |
| 240 | |
| 241 | // The static mount still answers for a real file. |
| 242 | rec = httptest.NewRecorder() |
| 243 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/theme.js", nil)) |
| 244 | if rec.Code != http.StatusOK { |
| 245 | t.Errorf("static asset status = %d, want 200", rec.Code) |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | func TestRegisterCreatesSessionAndAvatar(t *testing.T) { |
| 250 | s, h := newTestServer(t) |
| 251 | rec := postForm(t, h, "/register", url.Values{ |
| 252 | "username": {"newbie"}, |
| 253 | "password": {"hunter2hunter2"}, |
| 254 | "password2": {"hunter2hunter2"}, |
| 255 | }) |
| 256 | if rec.Code != http.StatusFound { |
| 257 | t.Fatalf("status = %d, want 302, body %q", rec.Code, rec.Body.String()) |
| 258 | } |
| 259 | user, err := s.DB.UserByName(context.Background(), "newbie") |
| 260 | if err != nil || user == nil { |
| 261 | t.Fatalf("user not created: %v", err) |
| 262 | } |
| 263 | |
| 264 | req := httptest.NewRequest(http.MethodGet, "/avatars/2.png?v=1", nil) |
| 265 | rr := httptest.NewRecorder() |
| 266 | h.ServeHTTP(rr, req) |
| 267 | if rr.Code != http.StatusOK { |
| 268 | t.Errorf("default avatar status = %d, want 200", rr.Code) |
| 269 | } |
| 270 | if body, _ := io.ReadAll(rr.Body); len(body) == 0 { |
| 271 | t.Error("default avatar is empty") |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | func TestRegisterRejectsShortPassword(t *testing.T) { |
| 276 | _, h := newTestServer(t) |
| 277 | rec := postForm(t, h, "/register", url.Values{ |
| 278 | "username": {"shorty"}, |
| 279 | "password": {"abc"}, |
| 280 | "password2": {"abc"}, |
| 281 | }) |
| 282 | if !strings.Contains(rec.Body.String(), "at least 8 characters") { |
| 283 | t.Errorf("missing error, body = %q", rec.Body.String()) |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | func TestSettingsRequiresAuth(t *testing.T) { |
| 288 | _, h := newTestServer(t) |
| 289 | rec := httptest.NewRecorder() |
| 290 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/settings", nil)) |
| 291 | if rec.Code != http.StatusFound { |
| 292 | t.Fatalf("status = %d, want 302", rec.Code) |
| 293 | } |
| 294 | if got := rec.Header().Get("Location"); got != "/login" { |
| 295 | t.Errorf("Location = %q", got) |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | func TestPasskeyRegisterOptionsNeedsSession(t *testing.T) { |
| 300 | _, h := newTestServer(t) |
| 301 | rec := postForm(t, h, "/auth/passkey/register/options", nil) |
| 302 | if rec.Code != http.StatusUnauthorized { |
| 303 | t.Fatalf("status = %d, want 401", rec.Code) |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | // The begin endpoints must send the bare WebAuthn JSON options, because |
| 308 | // @simplewebauthn/browser is passed the body unchanged. |
| 309 | func TestPasskeyLoginOptionsShape(t *testing.T) { |
| 310 | _, h := newTestServer(t) |
| 311 | rec := postForm(t, h, "/auth/passkey/login/options", nil) |
| 312 | if rec.Code != http.StatusOK { |
| 313 | t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) |
| 314 | } |
| 315 | body := rec.Body.String() |
| 316 | for _, want := range []string{`"challenge"`, `"rpId":"localhost"`, `"userVerification":"required"`} { |
| 317 | if !strings.Contains(body, want) { |
| 318 | t.Errorf("body %q is missing %s", body, want) |
| 319 | } |
| 320 | } |
| 321 | if strings.Contains(body, `"publicKey"`) { |
| 322 | t.Error("options must not be wrapped in a publicKey object") |
| 323 | } |
| 324 | } |
| 325 |