Implements pansy's local (email + password) authentication and the session layer that OIDC (#5) will also reuse. - store: users.go (create/get-by-id/get-by-email/count) and sessions.go (create/get/touch/delete/delete-expired), scanning the existing 0001 schema. - service: the business-logic seam. auth.go (Register/Login/session lifecycle/Providers) + password.go (argon2id, 64 MiB/1/4, PHC-encoded, constant-time verify) + service.go (Service, clock injection, token hashing). First user is admin; closed registration still allows the bootstrap user; unknown-email and wrong-password are indistinguishable (same error, same argon2 work via a dummy hash). - api: POST /auth/register|login|logout, GET /auth/me|providers, plus a requireAuth middleware that resolves the HttpOnly session cookie (SameSite=Lax, Secure under https) to the actor. Handlers stay thin. - main: wires the service and a periodic expired-session sweep; sessions are also dropped lazily on access. Sliding 30-day expiry. - tests: service (register/login/expiry/renewal/cleanup, password) and api (cookie flow, middleware, validation, providers). Verified end-to-end via curl: register -> me -> restart -> session persists -> logout -> 401. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
55 lines
1.5 KiB
Go
55 lines
1.5 KiB
Go
package service
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestHashAndVerifyPassword(t *testing.T) {
|
|
hash, err := hashPassword("correct-horse-battery-staple")
|
|
if err != nil {
|
|
t.Fatalf("hashPassword: %v", err)
|
|
}
|
|
if !strings.HasPrefix(hash, "$argon2id$v=19$") {
|
|
t.Errorf("hash %q lacks argon2id PHC prefix", hash)
|
|
}
|
|
|
|
ok, err := verifyPassword(hash, "correct-horse-battery-staple")
|
|
if err != nil || !ok {
|
|
t.Errorf("verify correct = (%v, %v), want (true, nil)", ok, err)
|
|
}
|
|
|
|
ok, err = verifyPassword(hash, "wrong-password")
|
|
if err != nil || ok {
|
|
t.Errorf("verify wrong = (%v, %v), want (false, nil)", ok, err)
|
|
}
|
|
}
|
|
|
|
func TestHashPasswordIsSalted(t *testing.T) {
|
|
// Two hashes of the same password must differ (random salt), yet both verify.
|
|
h1, _ := hashPassword("same-password")
|
|
h2, _ := hashPassword("same-password")
|
|
if h1 == h2 {
|
|
t.Error("two hashes of the same password are identical; salt not random")
|
|
}
|
|
for _, h := range []string{h1, h2} {
|
|
if ok, err := verifyPassword(h, "same-password"); err != nil || !ok {
|
|
t.Errorf("verify(%q) = (%v, %v), want (true, nil)", h, ok, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVerifyPasswordRejectsMalformedHash(t *testing.T) {
|
|
for _, bad := range []string{
|
|
"",
|
|
"not-a-hash",
|
|
"$argon2id$v=19$m=65536,t=1,p=4$onlyfourparts",
|
|
"$argon2i$v=19$m=65536,t=1,p=4$c2FsdA$aGFzaA", // wrong variant
|
|
"$argon2id$v=1$m=65536,t=1,p=4$c2FsdA$aGFzaA", // wrong version
|
|
} {
|
|
if _, err := verifyPassword(bad, "whatever"); err == nil {
|
|
t.Errorf("verifyPassword(%q) err = nil, want errBadHash", bad)
|
|
}
|
|
}
|
|
}
|