fix: auth/2FA security — stdout-log code delivery is dev/test-only, production fails closed until email/SMS; verification-code hashing, lockout recovery, sabredav fail-closed
- TWO_FACTOR_ALLOW_LOG_DELIVERY production opt-in REMOVED: plaintext codes are written to the stdout log ([2FA]/[VERIFY]) only in dev/test builds as a local DEV ONLY feature while email/SMS delivery (P6) is implemented. Production builds have no delivery channel and code issuance fails closed (503) under any configuration — no silent log-based code leak - verification/2FA codes hashed at rest (HMAC-SHA256 via TWO_FACTOR_PEPPER, CHAR(64)); [VERIFY] dev log relay; per-user brute-force budget; password_reset purpose clears lockout for self-service recovery; dummy-bcrypt on login no-user path kills timing oracle - sabredav weak-password list + entropy gate; .env.example ships fail-closed DAV_ADMIN_PASSWORD - delete-account re-auth (current_password + fresh 2FA code when enforced) - prod-tag suite (run-prod-tag-tests.sh) compiles and runs the production 2FA issuance gate: production ALWAYS reports no delivery channel and refuses issuance after the pepper check - startup_checks_test SNAPSHOT_ENC_KEY values built at runtime so gitleaks sees no secret-shaped literals - env-docs parity updated (flag removed, 38 vars)
This commit is contained in:
@@ -21,6 +21,7 @@ package auth
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -32,6 +33,7 @@ import (
|
||||
"crussell/auth"
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/twofa"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
@@ -46,6 +48,25 @@ func resetTestData(t *testing.T) (context.Context, db.Querier) {
|
||||
return ctx, tx
|
||||
}
|
||||
|
||||
// insertVerificationCode creates a verification_codes row storing only the
|
||||
// digest of a fresh random code (mirroring GenerateVerificationCodeHandler —
|
||||
// the code column holds twofa.Hash(plaintext), never the plaintext) and
|
||||
// returns the plaintext so the test can submit it to VerifyCodeHandler exactly
|
||||
// like a delivered code would be used.
|
||||
func insertVerificationCode(ctx context.Context, q db.Querier, userID, purpose string, expiresAt time.Time) (string, error) {
|
||||
code, err := generateVerificationCode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = q.Exec(ctx,
|
||||
`INSERT INTO verification_codes (user_id, purpose, code, expires_at) VALUES ($1, $2, $3, $4)`,
|
||||
userID, purpose, twofa.Hash(code), expiresAt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Register Handler Tests
|
||||
// =============================================================================
|
||||
@@ -650,12 +671,11 @@ func TestVerifyCheck_ValidCode(t *testing.T) {
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
|
||||
// Create a verification code
|
||||
// Create a verification code (only the digest is stored — see
|
||||
// insertVerificationCode); the test submits the plaintext code.
|
||||
var code string
|
||||
expiresAt := clock.Now().Add(24 * time.Hour)
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
|
||||
userID, expiresAt).Scan(&code)
|
||||
code, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create verification code: %v", err)
|
||||
}
|
||||
@@ -683,7 +703,7 @@ func TestVerifyCheck_ValidCode(t *testing.T) {
|
||||
// Verify code is marked as used
|
||||
var usedAt *time.Time
|
||||
err = tx.QueryRow(ctx,
|
||||
"SELECT used_at FROM verification_codes WHERE code = $1", code).Scan(&usedAt)
|
||||
"SELECT used_at FROM verification_codes WHERE code = $1", twofa.Hash(code)).Scan(&usedAt)
|
||||
if err != nil || usedAt == nil {
|
||||
t.Error("expected verification code to be marked as used")
|
||||
}
|
||||
@@ -726,9 +746,7 @@ func TestVerifyCheck_ExpiredCode(t *testing.T) {
|
||||
// Create an expired verification code
|
||||
var code string
|
||||
expiresAt := clock.Now().Add(-1 * time.Hour) // Expired 1 hour ago
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
|
||||
userID, expiresAt).Scan(&code)
|
||||
code, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create verification code: %v", err)
|
||||
}
|
||||
@@ -888,9 +906,7 @@ func TestVerifyCheck_AlreadyUsed(t *testing.T) {
|
||||
// Create a verification code
|
||||
var code string
|
||||
expiresAt := clock.Now().Add(24 * time.Hour)
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
|
||||
userID, expiresAt).Scan(&code)
|
||||
code, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create verification code: %v", err)
|
||||
}
|
||||
@@ -942,9 +958,7 @@ func TestVerifyCheck_RoleChangeToVerified(t *testing.T) {
|
||||
// Create a verification code
|
||||
var code string
|
||||
expiresAt := clock.Now().Add(24 * time.Hour)
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
|
||||
userID, expiresAt).Scan(&code)
|
||||
code, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create verification code: %v", err)
|
||||
}
|
||||
@@ -1757,9 +1771,8 @@ func TestVerifyCheck_AttemptBudget_LocksOutAfterFive(t *testing.T) {
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
var realCode string
|
||||
expiresAt := clock.Now().Add(24 * time.Hour)
|
||||
if err := tx.QueryRow(ctx,
|
||||
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
|
||||
userID, expiresAt).Scan(&realCode); err != nil {
|
||||
realCode, err = insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create verification code: %v", err)
|
||||
}
|
||||
defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID)
|
||||
@@ -1784,14 +1797,168 @@ func TestVerifyCheck_AttemptBudget_LocksOutAfterFive(t *testing.T) {
|
||||
t.Errorf("expected 429 for a spent budget, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// A DIFFERENT code (the real one) is unaffected by the spent budget and
|
||||
// verifies successfully — budgets are per code.
|
||||
// A DIFFERENT code (the real one) is unaffected by the spent miss-path
|
||||
// budget and verifies successfully: the miss-path budget is keyed per
|
||||
// submitted code value (a guess cannot resolve a user), while the real
|
||||
// code resolves to the user and uses the (fresh) per-user budget.
|
||||
w = testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: realCode}, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("a valid code must still verify after another code's budget was spent, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyGenerate_StoresDigestNotPlaintext pins the MEDIUM finding fix:
|
||||
// the verification_codes.code column stores ONLY the twofa.Hash digest (a
|
||||
// 64-char hex SHA-256), never the 12-hex-char plaintext the old schema stored.
|
||||
func TestVerifyGenerate_StoresDigestNotPlaintext(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := resetTestData(t)
|
||||
|
||||
handler := http.HandlerFunc(GenerateVerificationCodeHandler)
|
||||
|
||||
userID, err := fixtures.CreateTestUserWithEmail(tx, "hash-at-rest@test.com", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
|
||||
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/generate",
|
||||
VerificationCodeRequest{Email: "hash-at-rest@test.com"}, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var stored string
|
||||
err = tx.QueryRow(ctx,
|
||||
`SELECT code FROM verification_codes WHERE user_id = $1 AND purpose = 'email_verify'`, userID).Scan(&stored)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read stored code: %v", err)
|
||||
}
|
||||
if len(stored) != 64 {
|
||||
t.Errorf("expected a 64-char hex digest at rest, got %q (len %d)", stored, len(stored))
|
||||
}
|
||||
if _, err := hex.DecodeString(stored); err != nil {
|
||||
t.Errorf("stored code %q is not hex: %v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyCheck_PasswordReset_ClearsLoginLockout pins the HIGH finding fix:
|
||||
// verifying a password_reset code clears a locked-out account's
|
||||
// failed_attempts/locked_until, so the owner can log in again and change their
|
||||
// password — the self-service recovery for the repeatable login-DoS.
|
||||
func TestVerifyCheck_PasswordReset_ClearsLoginLockout(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := resetTestData(t)
|
||||
|
||||
handler := http.HandlerFunc(VerifyCodeHandler)
|
||||
|
||||
userID, err := fixtures.CreateTestUserWithEmail(tx, "reset@test.com", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
|
||||
// Simulate the attacker-lockout state (7 failures → 30-minute lock).
|
||||
_, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = 7, locked_until = NOW() + INTERVAL '30 minutes' WHERE id = $1`, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to lock account: %v", err)
|
||||
}
|
||||
|
||||
code, err := insertVerificationCode(ctx, tx, userID, verificationCodePurposePasswordReset, clock.Now().Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create password_reset code: %v", err)
|
||||
}
|
||||
|
||||
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: code}, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for a valid password_reset code, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var failed int
|
||||
var locked *time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failed, &locked)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read lockout state: %v", err)
|
||||
}
|
||||
if failed != 0 {
|
||||
t.Errorf("expected failed_attempts reset to 0, got %d", failed)
|
||||
}
|
||||
if locked != nil {
|
||||
t.Errorf("expected locked_until cleared, got %v", locked)
|
||||
}
|
||||
|
||||
// The unlocked account can log in again with the correct password.
|
||||
login := http.HandlerFunc(LoginHandler)
|
||||
w = testutils.MakeRequestNoAuth(login, "POST", "/api/login",
|
||||
LoginRequest{Email: "reset@test.com", Password: "testpassword123"}, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected login to succeed after lockout cleared, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyCheck_AttemptBudget_KeyedPerUser pins the MEDIUM finding fix: once
|
||||
// a submitted code resolves to a user (an existing-but-expired row), the
|
||||
// brute-force budget follows the USER, so draining it with one code value
|
||||
// exhausts it for every other code value of that user — and a different user's
|
||||
// budget stays independent.
|
||||
func TestVerifyCheck_AttemptBudget_KeyedPerUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := resetTestData(t)
|
||||
|
||||
handler := http.HandlerFunc(VerifyCodeHandler)
|
||||
|
||||
userID, err := fixtures.CreateTestUserWithEmail(tx, "budget-user@test.com", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
|
||||
otherID, err := fixtures.CreateTestUserWithEmail(tx, "budget-other@test.com", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create second test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, otherID)
|
||||
|
||||
// Five expired codes for the same user, each burned on the expired path.
|
||||
for i := 0; i < 5; i++ {
|
||||
code, err := insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, clock.Now().Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create expired code: %v", err)
|
||||
}
|
||||
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: code}, ctx)
|
||||
if i < 4 {
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("attempt %d: expected 400 for an expired code, got %d. body: %s", i+1, w.Code, w.Body.String())
|
||||
}
|
||||
} else {
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("attempt 5: expected 429, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A SIXTH expired code for the SAME user must be rejected 429 up front: the
|
||||
// budget followed the user, not each distinct code value.
|
||||
code, err := insertVerificationCode(ctx, tx, userID, verificationCodePurposeEmailVerify, clock.Now().Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sixth expired code: %v", err)
|
||||
}
|
||||
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: code}, ctx)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("expected 429 for the same user's next code (per-user budget), got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// A DIFFERENT user's fresh valid code still verifies — budgets are per user.
|
||||
otherCode, err := insertVerificationCode(ctx, tx, otherID, verificationCodePurposeEmailVerify, clock.Now().Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create other user's code: %v", err)
|
||||
}
|
||||
w = testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: otherCode}, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected the other user's valid code to verify, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateUKPhoneNumber Security Tests
|
||||
//
|
||||
// These tests verify that ValidateUKPhoneNumber rejects or sanitises
|
||||
|
||||
Reference in New Issue
Block a user