Build image / build-and-push (push) Successful in 5s
Fixes from the PR #23 adversarial review (graded 35 real / 1 false positive): Security / correctness - Race-free registration: is_admin and the registration gate are now computed atomically inside a single INSERT...SELECT, so concurrent first registrations can't both become admin or bypass closed registration (fixed the whole TOCTOU cluster). - Sliding session now reaches the browser: ResolveSession returns the current expiry and requireAuth re-sets the cookie, so active users aren't logged out 30 days after login regardless of activity. - Login CSRF: csrfGuard rejects state-changing requests whose Origin doesn't match PANSY_BASE_URL (no-op when unset, so the dev proxy is unaffected). SameSite=Lax alone didn't cover this. - argon2id tuned to RFC 9106's second recommended profile (t=3). - Timing equalizer can't fail open: the dummy hash is derived deterministically (fixed salt, no RNG) so it's always present. - Password length (<=1024) enforced in the service for both register and login, not just HTTP binding tags; login rejects over-long input before spending argon2 work. Error handling / robustness - Login logs a malformed stored hash instead of silently treating it as a wrong password. - Best-effort session writes (Touch/Delete during renewal, expiry, and corrupt-expiry cleanup) now log on failure. - index sessions.expires_at via new migration 0002 (0001 is immutable). Maintainability - Extract startSessionAndRespond and abortUnauthenticated; make writeServiceError a free function; consistent error handling in decodeHash; doc/comment fixes. Tests: over-long password, CSRF guard (cross-origin/same-origin/dev no-op), and cookie refresh on authenticated requests; migration-version assertions bumped to 2. 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)
|
|
}
|
|
}
|
|
}
|