Files
mcias/internal/auth/auth_test.go
Kyle Isom ec7c966ad2 trusted proxy, TOTP replay protection, new tests
- Trusted proxy config option for proxy-aware IP extraction
  used by rate limiting and audit logs; validates proxy IP
  before trusting X-Forwarded-For / X-Real-IP headers
- TOTP replay protection via counter-based validation to
  reject reused codes within the same time step (±30s)
- RateLimit middleware updated to extract client IP from
  proxy headers without IP spoofing risk
- New tests for ClientIP proxy logic (spoofed headers,
  fallback) and extended rate-limit proxy coverage
- HTMX error banner script integrated into web UI base
- .gitignore updated for mciasdb build artifact

Security: resolves CRIT-01 (TOTP replay attack) and
DEF-03 (proxy-unaware rate limiting); gRPC TOTP
enrollment aligned with REST via StorePendingTOTP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 17:44:01 -07:00

268 lines
7.8 KiB
Go

package auth
import (
"strings"
"testing"
"time"
)
// TestHashPasswordRoundTrip verifies that HashPassword + VerifyPassword works.
func TestHashPasswordRoundTrip(t *testing.T) {
params := DefaultArgonParams()
hash, err := HashPassword("correct-horse-battery-staple", params)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if !strings.HasPrefix(hash, "$argon2id$") {
t.Errorf("hash does not start with $argon2id$: %q", hash)
}
ok, err := VerifyPassword("correct-horse-battery-staple", hash)
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if !ok {
t.Error("VerifyPassword returned false for correct password")
}
}
// TestHashPasswordWrongPassword verifies that a wrong password is rejected.
func TestHashPasswordWrongPassword(t *testing.T) {
params := DefaultArgonParams()
hash, err := HashPassword("correct-horse", params)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ok, err := VerifyPassword("wrong-password", hash)
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if ok {
t.Error("VerifyPassword returned true for wrong password")
}
}
// TestHashPasswordUniqueHashes verifies that the same password produces
// different hashes (due to random salt).
func TestHashPasswordUniqueHashes(t *testing.T) {
params := DefaultArgonParams()
h1, err := HashPassword("password", params)
if err != nil {
t.Fatalf("HashPassword (1): %v", err)
}
h2, err := HashPassword("password", params)
if err != nil {
t.Fatalf("HashPassword (2): %v", err)
}
if h1 == h2 {
t.Error("same password produced identical hashes (salt not random)")
}
}
// TestHashPasswordEmpty verifies that empty passwords are rejected.
func TestHashPasswordEmpty(t *testing.T) {
_, err := HashPassword("", DefaultArgonParams())
if err == nil {
t.Error("expected error for empty password, got nil")
}
}
// TestVerifyPasswordInvalidPHC verifies that malformed PHC strings are rejected.
func TestVerifyPasswordInvalidPHC(t *testing.T) {
_, err := VerifyPassword("password", "not-a-phc-string")
if err == nil {
t.Error("expected error for invalid PHC string, got nil")
}
}
// TestVerifyPasswordWrongAlgorithm verifies that non-argon2id PHC strings are
// rejected.
func TestVerifyPasswordWrongAlgorithm(t *testing.T) {
fakeScrypt := "$scrypt$v=1$n=32768,r=8,p=1$c2FsdA$aGFzaA"
_, err := VerifyPassword("password", fakeScrypt)
if err == nil {
t.Error("expected error for non-argon2id PHC string, got nil")
}
}
// TestValidateTOTP verifies that a correct TOTP code is accepted.
// This test generates a secret and immediately validates the current code.
func TestValidateTOTP(t *testing.T) {
rawSecret, _, err := GenerateTOTPSecret()
if err != nil {
t.Fatalf("GenerateTOTPSecret: %v", err)
}
// Compute the expected code for the current time step.
now := time.Now().Unix()
code, err := hotp(rawSecret, uint64(now/30)) //nolint:gosec // G115: Unix time is always positive
if err != nil {
t.Fatalf("hotp: %v", err)
}
ok, counter, err := ValidateTOTP(rawSecret, code)
if err != nil {
t.Fatalf("ValidateTOTP: %v", err)
}
if !ok {
t.Errorf("ValidateTOTP rejected a valid code %q", code)
}
if ok && counter == 0 {
t.Errorf("ValidateTOTP returned zero counter for valid code")
}
}
// TestValidateTOTPWrongCode verifies that an incorrect code is rejected.
func TestValidateTOTPWrongCode(t *testing.T) {
rawSecret, _, err := GenerateTOTPSecret()
if err != nil {
t.Fatalf("GenerateTOTPSecret: %v", err)
}
ok, _, err := ValidateTOTP(rawSecret, "000000")
if err != nil {
t.Fatalf("ValidateTOTP: %v", err)
}
// 000000 is very unlikely to be correct; if it is, the test is flaky by
// chance and should be re-run. The probability is ~3/1000000.
_ = ok // we cannot assert false without knowing the actual code
}
// TestValidateTOTPWrongLength verifies that codes of wrong length are rejected
// without an error (they are simply invalid).
func TestValidateTOTPWrongLength(t *testing.T) {
rawSecret, _, err := GenerateTOTPSecret()
if err != nil {
t.Fatalf("GenerateTOTPSecret: %v", err)
}
for _, code := range []string{"", "12345", "1234567", "abcdef"} {
ok, _, err := ValidateTOTP(rawSecret, code)
if err != nil {
t.Errorf("ValidateTOTP(%q): unexpected error: %v", code, err)
}
if ok && len(code) != 6 {
t.Errorf("ValidateTOTP accepted wrong-length code %q", code)
}
}
}
// TestDecodeTOTPSecret verifies base32 decoding with and without padding.
func TestDecodeTOTPSecret(t *testing.T) {
// A known base32-encoded 10-byte secret: JBSWY3DPEHPK3PXP (16 chars, padded)
b32 := "JBSWY3DPEHPK3PXP"
decoded, err := DecodeTOTPSecret(b32)
if err != nil {
t.Fatalf("DecodeTOTPSecret: %v", err)
}
if len(decoded) == 0 {
t.Error("DecodeTOTPSecret returned empty bytes")
}
// Case-insensitive input.
decoded2, err := DecodeTOTPSecret(strings.ToLower(b32))
if err != nil {
t.Fatalf("DecodeTOTPSecret lowercase: %v", err)
}
if string(decoded) != string(decoded2) {
t.Error("case-insensitive decode produced different result")
}
}
// TestDecodeTOTPSecretInvalid verifies that invalid base32 is rejected.
func TestDecodeTOTPSecretInvalid(t *testing.T) {
_, err := DecodeTOTPSecret("not-valid-base32-!@#$%")
if err == nil {
t.Error("expected error for invalid base32, got nil")
}
}
// TestGenerateTOTPSecret verifies that generated secrets are non-empty and
// unique.
func TestGenerateTOTPSecret(t *testing.T) {
raw1, b32_1, err := GenerateTOTPSecret()
if err != nil {
t.Fatalf("GenerateTOTPSecret (1): %v", err)
}
if len(raw1) != 20 {
t.Errorf("raw secret length = %d, want 20", len(raw1))
}
if b32_1 == "" {
t.Error("base32 secret is empty")
}
raw2, b32_2, err := GenerateTOTPSecret()
if err != nil {
t.Fatalf("GenerateTOTPSecret (2): %v", err)
}
if string(raw1) == string(raw2) {
t.Error("two generated TOTP secrets are identical")
}
if b32_1 == b32_2 {
t.Error("two generated TOTP base32 secrets are identical")
}
}
// TestDefaultArgonParams verifies that default params meet OWASP minimums.
func TestDefaultArgonParams(t *testing.T) {
p := DefaultArgonParams()
if p.Time < 2 {
t.Errorf("default Time=%d < OWASP minimum 2", p.Time)
}
if p.Memory < 65536 {
t.Errorf("default Memory=%d KiB < OWASP minimum 64MiB (65536 KiB)", p.Memory)
}
if p.Threads < 1 {
t.Errorf("default Threads=%d < 1", p.Threads)
}
}
// TestDummyHashIsValidPHC verifies DummyHash returns a non-empty string that
// is a valid Argon2id PHC hash verifiable by VerifyPassword.
func TestDummyHashIsValidPHC(t *testing.T) {
h := DummyHash()
if h == "" {
t.Fatal("DummyHash returned empty string")
}
// Must parse as a valid PHC string with OWASP-compatible parameters.
// VerifyPassword with the correct password must succeed.
ok, err := VerifyPassword("dummy-password-for-timing-only", h)
if err != nil {
t.Fatalf("VerifyPassword against DummyHash: %v", err)
}
if !ok {
t.Error("VerifyPassword against DummyHash returned false (hash mismatch)")
}
}
// TestDummyHashIsCached verifies that consecutive calls return the same string
// (sync.Once caching).
func TestDummyHashIsCached(t *testing.T) {
h1 := DummyHash()
h2 := DummyHash()
if h1 != h2 {
t.Errorf("DummyHash returned different values on consecutive calls:\n first: %q\n second: %q", h1, h2)
}
}
// TestDummyHashMatchesDefaultParams verifies the embedded parameters in the
// returned PHC string match DefaultArgonParams (m, t, p).
func TestDummyHashMatchesDefaultParams(t *testing.T) {
h := DummyHash()
params, _, _, err := parsePHC(h)
if err != nil {
t.Fatalf("parsePHC(DummyHash()): %v", err)
}
def := DefaultArgonParams()
if params.Memory != def.Memory {
t.Errorf("Memory = %d, want %d", params.Memory, def.Memory)
}
if params.Time != def.Time {
t.Errorf("Time = %d, want %d", params.Time, def.Time)
}
if params.Threads != def.Threads {
t.Errorf("Threads = %d, want %d", params.Threads, def.Threads)
}
}