Ctrl K
ComunidadGuías…

internal/auth/auth_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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)
	}
}