// SPDX-License-Identifier: Apache-2.0 package auth_test import ( "database/sql" "net/http" "net/http/httptest" "path/filepath" "testing" "time" "github.com/X4Applegate/caddyui/internal/auth" appdb "github.com/X4Applegate/caddyui/internal/db" "github.com/X4Applegate/caddyui/internal/models" ) // v2.30.0: this package had no tests of its own — it was only ever exercised // indirectly through internal/server. It holds session issuance, expiry and // cookie flags, so a regression here is an authentication bug. func newDB(t *testing.T) (*sql.DB, int64) { t.Helper() conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = conn.Close() }) hash, err := auth.HashPassword("correct-horse-battery-staple") if err != nil { t.Fatal(err) } uid, err := models.CreateUser(conn, "user@example.com", hash, "User", models.RoleAdmin) if err != nil { t.Fatal(err) } return conn, uid } func TestPasswordHashing(t *testing.T) { const pw = "correct-horse-battery-staple" hash, err := auth.HashPassword(pw) if err != nil { t.Fatal(err) } if hash == pw { t.Fatal("password stored in plaintext") } if !auth.CheckPassword(hash, pw) { t.Error("the correct password was rejected") } if auth.CheckPassword(hash, pw+"x") { t.Error("a wrong password was accepted") } if auth.CheckPassword(hash, "") { t.Error("an empty password was accepted") } if auth.CheckPassword("not-a-bcrypt-hash", pw) { t.Error("a malformed hash must not validate") } // bcrypt salts, so the same input must not produce the same digest twice. other, err := auth.HashPassword(pw) if err != nil { t.Fatal(err) } if other == hash { t.Error("hashes are unsalted — identical passwords produced identical hashes") } } func TestSessionLifecycle(t *testing.T) { conn, uid := newDB(t) token, expires, err := auth.CreateSession(conn, uid) if err != nil { t.Fatal(err) } if token == "" { t.Fatal("empty session token") } if len(token) < 32 { t.Errorf("session token is only %d chars — too short to resist guessing", len(token)) } if !expires.After(time.Now()) { t.Error("session expires in the past") } got, err := auth.UserFromSession(conn, token) if err != nil || got == nil { t.Fatalf("valid session did not resolve: %v", err) } if got.ID != uid { t.Errorf("session resolved to user %d, want %d", got.ID, uid) } // Two sessions must never collide. token2, _, err := auth.CreateSession(conn, uid) if err != nil { t.Fatal(err) } if token2 == token { t.Error("two sessions produced the same token") } if err := auth.DeleteSession(conn, token); err != nil { t.Fatal(err) } if u, err := auth.UserFromSession(conn, token); err == nil && u != nil { t.Error("a deleted session still resolves to a user") } // Deleting one session must not affect another. if u, err := auth.UserFromSession(conn, token2); err != nil || u == nil { t.Error("deleting one session invalidated an unrelated one") } } func TestUnknownAndExpiredSessionsRejected(t *testing.T) { conn, uid := newDB(t) if u, err := auth.UserFromSession(conn, "definitely-not-a-real-token"); err == nil && u != nil { t.Error("an unknown token resolved to a user") } if u, err := auth.UserFromSession(conn, ""); err == nil && u != nil { t.Error("an empty token resolved to a user") } // Expire a real session by backdating its row. CreateSessionWithTTL // deliberately clamps a non-positive TTL to the default (see below), so it // cannot be used to mint an already-expired session. token, _, err := auth.CreateSession(conn, uid) if err != nil { t.Fatal(err) } if _, err := conn.Exec(`UPDATE sessions SET expires_at = ? WHERE token = ?`, time.Now().Add(-time.Hour), auth.HashSessionToken(token)); err != nil { t.Fatal(err) } if u, err := auth.UserFromSession(conn, token); err == nil && u != nil { t.Error("an expired session still authenticates") } // The expired row should also be cleaned up rather than lingering. var count int if err := conn.QueryRow(`SELECT COUNT(*) FROM sessions WHERE token = ?`, auth.HashSessionToken(token)).Scan(&count); err != nil { t.Fatal(err) } if count != 0 { t.Error("an expired session row was left behind after a failed lookup") } } // A non-positive TTL falls back to the default rather than minting a session // that is already dead — otherwise a misconfigured session-duration setting // would lock every user out instantly. func TestCreateSessionWithTTLClampsNonPositive(t *testing.T) { conn, uid := newDB(t) for _, ttl := range []time.Duration{0, -time.Hour} { token, expires, err := auth.CreateSessionWithTTL(conn, uid, ttl) if err != nil { t.Fatal(err) } if !expires.After(time.Now()) { t.Errorf("ttl=%v produced an already-expired session", ttl) } if u, err := auth.UserFromSession(conn, token); err != nil || u == nil { t.Errorf("ttl=%v produced an unusable session: %v", ttl, err) } } // An explicit short TTL is honoured as given. _, expires, err := auth.CreateSessionWithTTL(conn, uid, 90*time.Minute) if err != nil { t.Fatal(err) } if d := time.Until(expires); d > 2*time.Hour { t.Errorf("explicit 90m TTL produced %v — the caller's value was ignored", d) } } // Cookie flags are the difference between a session that survives XSS and one // that doesn't, so pin them explicitly. func TestSessionCookieFlags(t *testing.T) { t.Run("plain http", func(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "http://caddyui.local/", nil) auth.SetSessionCookie(w, r, "tok", time.Now().Add(time.Hour)) c := parseCookie(t, w, auth.SessionCookie) if !c.HttpOnly { t.Error("session cookie must be HttpOnly") } if c.SameSite != http.SameSiteLaxMode { t.Errorf("SameSite = %v, want Lax", c.SameSite) } if c.Secure { t.Error("Secure must not be set over plain HTTP, or the cookie is silently dropped") } }) t.Run("forwarded https", func(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "http://caddyui.local/", nil) r.Header.Set("X-Forwarded-Proto", "https") auth.SetSessionCookie(w, r, "tok", time.Now().Add(time.Hour)) c := parseCookie(t, w, auth.SessionCookie) if !c.Secure { t.Error("Secure must be set when the request arrived over HTTPS via a proxy") } }) t.Run("clearing expires the cookie", func(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "http://caddyui.local/", nil) auth.ClearSessionCookie(w, r) c := parseCookie(t, w, auth.SessionCookie) if c.Value != "" { t.Errorf("cleared cookie still carries a value %q", c.Value) } if c.MaxAge >= 0 { t.Errorf("MaxAge = %d, want negative so the browser drops it", c.MaxAge) } if !c.HttpOnly { t.Error("the cleared cookie should keep HttpOnly") } }) } func parseCookie(t *testing.T, w *httptest.ResponseRecorder, name string) *http.Cookie { t.Helper() for _, c := range w.Result().Cookies() { if c.Name == name { return c } } t.Fatalf("cookie %q was not set", name) return nil } // TestSessionTokenStoredHashed is the regression test for the v2.52.5 // defence-in-depth change: the sessions table must store the SHA-256 of the // token, never the raw cookie value, so a database disclosure cannot be // replayed as a live session. func TestSessionTokenStoredHashed(t *testing.T) { conn, uid := newDB(t) token, _, err := auth.CreateSession(conn, uid) if err != nil { t.Fatal(err) } // The raw token must not appear in the sessions table. var rawCount int if err := conn.QueryRow(`SELECT COUNT(*) FROM sessions WHERE token = ?`, token).Scan(&rawCount); err != nil { t.Fatal(err) } if rawCount != 0 { t.Fatal("raw session token is stored in the database — a DB leak would be replayable") } // The hash must be present, and must resolve back to the user via the cookie. var hashCount int if err := conn.QueryRow(`SELECT COUNT(*) FROM sessions WHERE token = ?`, auth.HashSessionToken(token)).Scan(&hashCount); err != nil { t.Fatal(err) } if hashCount != 1 { t.Fatalf("expected exactly one hashed session row, got %d", hashCount) } if u, err := auth.UserFromSession(conn, token); err != nil || u == nil { t.Fatalf("raw cookie token should still resolve to the user: %v", err) } }