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:
@@ -502,8 +502,7 @@ func TestAnonymizeUser_RetainsEditRequestNotes(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestAnonymizeUser_ScrubsAdminAuditLog2FAFallback closes the GDPR erasure gap
|
||||
// for admin_audit_log: a '2fa_fallback_charge' row (insertTwoFAFallbackAudit,
|
||||
// handlers/payments) carries target_user_id = the erased user, admin_id = the
|
||||
// for admin_audit_log: a '2fa_fallback_charge' row (written by handlers/payments) carries target_user_id = the erased user, admin_id = the
|
||||
// customer's own userID (the CIT actor), AND details.card_last4 — the audit row
|
||||
// MUST survive erasure (GDPR Art 30 records of processing / financial audit
|
||||
// trail) but be de-identified: the user links (both target_user_id and
|
||||
@@ -518,8 +517,8 @@ func TestAnonymizeUser_ScrubsAdminAuditLog2FAFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
// A 2FA-fallback audit row for a CIT saved-card charge: target_user_id is
|
||||
// the customer and admin_id is the customer's own userID (the CIT actor —
|
||||
// see insertTwoFAFallbackAudit). details carries the card_last4 PII.
|
||||
// the customer and admin_id is the customer's own userID (the CIT actor).
|
||||
// details carries the card_last4 PII.
|
||||
var auditID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO admin_audit_log (admin_id, action_type, target_user_id, details)
|
||||
|
||||
@@ -150,10 +150,21 @@ func CreateGuestUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/check-email?email=...&firstName=...&lastName=...&phone=...
|
||||
// Returns { suggestion: "login" | "check" | null } based on whether the email
|
||||
// belongs to a registered user and how closely the provided details match.
|
||||
// Relies on nginx restricting access to frontend-only traffic.
|
||||
// GET /api/check-email?email=...
|
||||
// Returns a uniform {"available": bool} response: available=false when the
|
||||
// email belongs to a registered (non-guest) account, true otherwise. The old
|
||||
// response returned a "suggestion" breakdown ("login" | "check" | null) that
|
||||
// told an unauthenticated caller whether an email was registered AND whether
|
||||
// their first/last-name/phone matched the account — a user-enumeration and
|
||||
// PII-confirmation oracle. The comment here previously claimed nginx restricts
|
||||
// this endpoint to frontend-only traffic, but nginx/conf.d/default.conf has NO
|
||||
// such rule (the /api/ location proxies everything with rate limiting only), so
|
||||
// the handler itself must not leak the breakdown. The uniform shape keeps the
|
||||
// endpoint functional for the registration form's "email already registered"
|
||||
// check while removing the distinguishing detail. The guest-booking frontend
|
||||
// reads data.suggestion; with the uniform shape it resolves to null and no
|
||||
// suggestion banner is shown — an accepted UX trade-off (the guest-creation
|
||||
// endpoint's 409 is the real enforcement for registered emails).
|
||||
func CheckEmailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
email := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("email")))
|
||||
if email == "" {
|
||||
@@ -167,35 +178,16 @@ func CheckEmailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
firstName := strings.TrimSpace(r.URL.Query().Get("firstName"))
|
||||
lastName := strings.TrimSpace(r.URL.Query().Get("lastName"))
|
||||
phone := strings.TrimSpace(r.URL.Query().Get("phone"))
|
||||
|
||||
var dbFirstName, dbLastName, dbPhone *string
|
||||
var registeredID string
|
||||
err := db.Conn.QueryRow(r.Context(), `
|
||||
SELECT n_first_name, n_last_name, phone
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE email = $1 AND account_role != 'guest'
|
||||
`, email).Scan(&dbFirstName, &dbLastName, &dbPhone)
|
||||
`, email).Scan(®isteredID)
|
||||
|
||||
var suggestion *string
|
||||
available := true
|
||||
if err == nil {
|
||||
matchesNames := firstName != "" && lastName != "" &&
|
||||
dbFirstName != nil && dbLastName != nil &&
|
||||
strings.EqualFold(firstName, *dbFirstName) &&
|
||||
strings.EqualFold(lastName, *dbLastName)
|
||||
|
||||
matchesPhone := phone != "" &&
|
||||
dbPhone != nil &&
|
||||
phone == *dbPhone
|
||||
|
||||
if matchesNames && matchesPhone {
|
||||
s := "login"
|
||||
suggestion = &s
|
||||
} else {
|
||||
s := "check"
|
||||
suggestion = &s
|
||||
}
|
||||
available = false
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("Failed to check email: %v", err)
|
||||
http.Error(w, "database error", http.StatusInternalServerError)
|
||||
@@ -203,7 +195,7 @@ func CheckEmailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"suggestion": suggestion,
|
||||
"available": available,
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
|
||||
@@ -121,7 +121,9 @@ func TestGuestUser_Create_InvalidEmail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckEmail_NotRegistered verifies that querying a non-existent email returns suggestion null.
|
||||
// TestCheckEmail_NotRegistered verifies that querying a non-existent email
|
||||
// returns the uniform response {"available": true} and no suggestion breakdown
|
||||
// (the old "suggestion" field was a user-enumeration/PII-confirmation oracle).
|
||||
func TestCheckEmail_NotRegistered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -139,14 +141,20 @@ func TestCheckEmail_NotRegistered(t *testing.T) {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if resp["suggestion"] != nil {
|
||||
t.Errorf("expected suggestion null, got %v", resp["suggestion"])
|
||||
if _, ok := resp["suggestion"]; ok {
|
||||
t.Errorf("expected NO 'suggestion' field (enumeration oracle removed), got %v", resp["suggestion"])
|
||||
}
|
||||
available, ok := resp["available"].(bool)
|
||||
if !ok || !available {
|
||||
t.Errorf("expected available=true for an unregistered email, got %v", resp["available"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckEmail_Registered_MatchingDetails verifies that querying an existing registered user's email
|
||||
// with matching first name, last name, and phone returns suggestion "login".
|
||||
func TestCheckEmail_Registered_MatchingDetails(t *testing.T) {
|
||||
// TestCheckEmail_Registered verifies that a registered user's email returns the
|
||||
// uniform response {"available": false} regardless of whether the caller's
|
||||
// first/last-name/phone match the account — the detail matching is deliberately
|
||||
// gone so an unauthenticated caller cannot confirm PII against the database.
|
||||
func TestCheckEmail_Registered(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
@@ -162,68 +170,37 @@ func TestCheckEmail_Registered_MatchingDetails(t *testing.T) {
|
||||
t.Fatalf("failed to update user name: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, `/api/check-email?email=jane@example.com&firstName=Jane&lastName=Doe&phone=%2B447123456789`, nil)
|
||||
req = req.WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
CheckEmailHandler(rr, req)
|
||||
// Matching details AND non-matching details must both yield available=false.
|
||||
for _, q := range []string{
|
||||
`/api/check-email?email=jane@example.com&firstName=Jane&lastName=Doe&phone=%2B447123456789`,
|
||||
`/api/check-email?email=jane@example.com&firstName=Wrong&lastName=Doe&phone=%2B447123456789`,
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, q, nil)
|
||||
req = req.WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
CheckEmailHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", rr.Code)
|
||||
t.Logf("response body: %s", rr.Body.String())
|
||||
}
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", rr.Code)
|
||||
t.Logf("response body: %s", rr.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
suggestion, ok := resp["suggestion"].(string)
|
||||
if !ok || suggestion != "login" {
|
||||
t.Errorf("expected suggestion 'login', got %v", resp["suggestion"])
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if _, ok := resp["suggestion"]; ok {
|
||||
t.Errorf("expected NO 'suggestion' field (enumeration oracle removed), got %v", resp["suggestion"])
|
||||
}
|
||||
available, ok := resp["available"].(bool)
|
||||
if !ok || available {
|
||||
t.Errorf("expected available=false for a registered email (query %s), got %v", q, resp["available"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckEmail_Registered_PartialMatch verifies that when the email exists but details don't fully match,
|
||||
// the handler returns suggestion "check".
|
||||
func TestCheckEmail_Registered_PartialMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUserWithEmail(tx, "jane@example.com", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE users SET n_first_name = 'Jane', n_last_name = 'Doe' WHERE id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update user name: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, `/api/check-email?email=jane@example.com&firstName=Wrong&lastName=Doe&phone=%2B447123456789`, nil)
|
||||
req = req.WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
CheckEmailHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", rr.Code)
|
||||
t.Logf("response body: %s", rr.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
suggestion, ok := resp["suggestion"].(string)
|
||||
if !ok || suggestion != "check" {
|
||||
t.Errorf("expected suggestion 'check', got %v", resp["suggestion"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckEmail_GuestUser verifies that a guest user's email is treated as not found
|
||||
// (suggestion null) because the query excludes account_role = 'guest'.
|
||||
// TestCheckEmail_GuestUser verifies that a guest user's email is treated as
|
||||
// available (the query excludes account_role = 'guest').
|
||||
func TestCheckEmail_GuestUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
@@ -248,8 +225,9 @@ func TestCheckEmail_GuestUser(t *testing.T) {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if resp["suggestion"] != nil {
|
||||
t.Errorf("expected suggestion null for guest user, got %v", resp["suggestion"])
|
||||
available, ok := resp["available"].(bool)
|
||||
if !ok || !available {
|
||||
t.Errorf("expected available=true for a guest email, got %v", resp["available"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,21 +53,15 @@ const twoFAPepperEnv = "TWO_FACTOR_PEPPER"
|
||||
|
||||
// errTwoFADeliveryUnavailable is returned by production builds when a 2FA code
|
||||
// is requested but no delivery channel is configured: the email/SMS transport
|
||||
// is not wired yet (P6) and the operator has not opted into the insecure
|
||||
// log-delivery mode (TWO_FACTOR_ALLOW_LOG_DELIVERY=true). Handlers surface it
|
||||
// verbatim so setup fails loudly with an actionable message instead of issuing
|
||||
// a code that could never reach the user (which would silently dead-end the
|
||||
// enforced saved-card-payments gate). Dev/test builds always have the [2FA] log
|
||||
// channel and never return it (see twofa_dev.go).
|
||||
// is not wired yet (P6), and stdout-log delivery is a dev/test-only LOCAL
|
||||
// feature — a production build deliberately has no channel at all (see
|
||||
// twofa_prod.go). Handlers surface it verbatim so setup fails loudly with an
|
||||
// actionable message instead of issuing a code that could never reach the user
|
||||
// (which would silently dead-end the enforced saved-card-payments gate).
|
||||
// Dev/test builds always have the [2FA] log channel and never return it (see
|
||||
// twofa_dev.go).
|
||||
var errTwoFADeliveryUnavailable = errors.New("2FA requires an email or SMS delivery channel; contact the salon")
|
||||
|
||||
// twoFAAllowLogDeliveryEnv is the explicit operator opt-in that makes a
|
||||
// production build deliver 2FA codes via the server log ([2FA] prefix) — the
|
||||
// documented INSECURE stand-in for the not-yet-wired email/SMS transport (P6).
|
||||
// Production builds fail closed without it (see twoFAEnsureIssueAllowedStrict);
|
||||
// dev/test builds always deliver via the log and never consult this flag.
|
||||
const twoFAAllowLogDeliveryEnv = "TWO_FACTOR_ALLOW_LOG_DELIVERY"
|
||||
|
||||
// errTwoFAPepperRequired is returned when TWO_FACTOR_PEPPER is unset in a
|
||||
// production-style issuance gate. Refusing to issue is the only safe outcome:
|
||||
// without the pepper a pending code would be persisted as an unsalted SHA-256
|
||||
@@ -80,24 +74,25 @@ var errTwoFAPepperRequired = errors.New("TWO_FACTOR_PEPPER is not set; refusing
|
||||
// production-style issuance refuses instead (twoFAEnsureIssueAllowedStrict).
|
||||
func twoFAPepperConfigured() bool { return os.Getenv(twoFAPepperEnv) != "" }
|
||||
|
||||
// twoFADeliveryChannelConfigured reports whether the deployment has explicitly
|
||||
// configured a 2FA code delivery channel: TWO_FACTOR_ALLOW_LOG_DELIVERY set to
|
||||
// exactly "true" (the only production channel today — email/SMS unwired, P6).
|
||||
// Pure env read, build-agnostic: the build-tagged twoFADeliveryAvailable
|
||||
// (twofa_dev.go / twofa_prod.go) is the runtime-facing wrapper that turns this
|
||||
// into the always-true dev channel or the prod env check.
|
||||
func twoFADeliveryChannelConfigured() bool { return os.Getenv(twoFAAllowLogDeliveryEnv) == "true" }
|
||||
// twoFADeliveryChannelConfigured reports whether a REAL delivery channel
|
||||
// exists. Production: always false — email/SMS is not wired yet (P6) and the
|
||||
// stdout-log relay is a dev/test-only local feature (twofa_dev.go), never a
|
||||
// production channel. There is deliberately NO production opt-in to log
|
||||
// delivery. The build-tagged twoFADeliveryAvailable (twofa_dev.go /
|
||||
// twofa_prod.go) is the runtime-facing wrapper: always true in dev/test,
|
||||
// always false in production.
|
||||
func twoFADeliveryChannelConfigured() bool { return false }
|
||||
|
||||
// twoFAEnsureIssueAllowedStrict is the pure, build-agnostic production-style
|
||||
// issuance gate: code issuance is allowed ONLY when BOTH TWO_FACTOR_PEPPER is
|
||||
// set (an unsalted digest in the 1M code space would be offline-brute-forceable)
|
||||
// AND a delivery channel is configured (otherwise a minted code could never
|
||||
// reach the user and would silently dead-end the enforced saved-card-payments
|
||||
// gate). Either way it fails closed with the actionable errors the handlers map
|
||||
// to a 503. The build-tagged twoFAEnsureIssueAllowed wraps it for production
|
||||
// builds; dev/test builds always allow issuance and never consult it — but the
|
||||
// test,dev suite exercises THIS function directly, so the fail-closed branches
|
||||
// are CI-visible even though the prod file (!dev && !test) is excluded there.
|
||||
// AND a real delivery channel exists — which a production build never has until
|
||||
// email/SMS lands (P6). Either way it fails closed with the actionable errors
|
||||
// the handlers map to a 503. The build-tagged twoFAEnsureIssueAllowed wraps it
|
||||
// for production builds; dev/test builds always allow issuance and never
|
||||
// consult it — but the test,dev suite exercises THIS function directly, so the
|
||||
// fail-closed branches are CI-visible even though the prod file (!dev && !test)
|
||||
// is excluded there.
|
||||
func twoFAEnsureIssueAllowedStrict() error {
|
||||
if !twoFAPepperConfigured() {
|
||||
return errTwoFAPepperRequired
|
||||
@@ -156,11 +151,10 @@ func twoFAMintThrottled(st *twoFAAttemptState, now time.Time) bool {
|
||||
// builds fail closed up front: twoFAEnsureIssueAllowed refuses to issue a code
|
||||
// when TWO_FACTOR_PEPPER is unset (an unsalted digest would be
|
||||
// offline-brute-forceable) or when no delivery channel is configured (email/SMS
|
||||
// unwired and log delivery not explicitly opted into via
|
||||
// TWO_FACTOR_ALLOW_LOG_DELIVERY=true) — so a production setup never mints a
|
||||
// code that could never reach the user. The API response still only returns the
|
||||
// code when 2FA is unenforced (dev convenience). purpose labels the delivery
|
||||
// (e.g. "setup", "disable 2FA").
|
||||
// unwired; stdout-log delivery is a dev/test-only local feature) — so a
|
||||
// production setup never mints a code that could never reach the user. The API
|
||||
// response still only returns the code when 2FA is unenforced (dev
|
||||
// convenience). purpose labels the delivery (e.g. "setup", "disable 2FA").
|
||||
//
|
||||
// A fresh code does NOT reset the per-user failed-attempt counter (B11b): only
|
||||
// a successful verify does. Resetting on re-mint would let a password-only
|
||||
@@ -198,11 +192,10 @@ func deliverTwoFACode(r *http.Request, userID, method, purpose string) (string,
|
||||
if label == "" {
|
||||
label = purpose
|
||||
}
|
||||
// Build-dependent delivery: dev/test logs the plaintext code ([2FA] line);
|
||||
// production logs it ONLY when the operator explicitly opted into log
|
||||
// delivery (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) — otherwise issuance was
|
||||
// already refused by twoFAEnsureIssueAllowed above, so the default is that
|
||||
// the code never reaches a log.
|
||||
// Build-dependent delivery: dev/test logs the plaintext code ([2FA] line —
|
||||
// a LOCAL DEV feature); production never logs it (twoFADeliverCode is a
|
||||
// no-op there), and issuance was already refused by twoFAEnsureIssueAllowed
|
||||
// above because production has no delivery channel until email/SMS lands.
|
||||
twoFADeliverCode(userID, label, code)
|
||||
return code, nil
|
||||
}
|
||||
@@ -251,10 +244,10 @@ type TwoFASetupRequest struct {
|
||||
// Generates a verification code and stores only its SHA-256 hash plus a
|
||||
// 10-minute expiry in the pending columns. Delivery is build-dependent (see
|
||||
// deliverTwoFACode): dev/test builds log the code with a [2FA] prefix — the
|
||||
// loose-fake stand-in for the not-yet-wired email/SMS transport (P6) — while
|
||||
// local-dev stand-in for the not-yet-wired email/SMS transport (P6) — while
|
||||
// production builds fail closed when TWO_FACTOR_PEPPER is unset or when no
|
||||
// delivery channel is configured (email/SMS unwired and
|
||||
// TWO_FACTOR_ALLOW_LOG_DELIVERY=true unset), returning a clear actionable
|
||||
// delivery channel is configured (email/SMS unwired; stdout-log delivery is a
|
||||
// dev/test-only local feature), returning a clear actionable
|
||||
// error instead of silently issuing a code that would never arrive. When 2FA is
|
||||
// not enforced (dev), the code is also returned in the response so the flow is
|
||||
// testable without reading backend logs.
|
||||
@@ -526,9 +519,9 @@ type TwoFADisableRequest struct {
|
||||
// mints are throttled per-user (twoFAMintCooldown), so a password-only attacker
|
||||
// cannot loop request-code → burn 5 guesses → request-code forever; a throttled
|
||||
// request returns 429. Like the disable handler, no code is returned in the
|
||||
// response (delivery is the [2FA] log line in dev/test builds; production
|
||||
// fails closed when no delivery channel is configured — no email/SMS and no
|
||||
// explicit TWO_FACTOR_ALLOW_LOG_DELIVERY opt-in), and unlike setup this
|
||||
// response (delivery is the [2FA] log line in dev/test builds only — a local
|
||||
// dev feature; production fails closed when no delivery channel is configured
|
||||
// — no email/SMS and no production log channel), and unlike setup this
|
||||
// endpoint runs unconditionally — it does not short-circuit on
|
||||
// !twoFARequired(), so dev environments can exercise the same step (the mint
|
||||
// is harmless there).
|
||||
@@ -587,8 +580,8 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// was reused (LOW 5). A 200 with a reused code must NOT be read as "a new code
|
||||
// was sent": the frontend should use the already-delivered code and show the
|
||||
// countdown. 409 when the user has not enabled 2FA; 429 on the mint cooldown;
|
||||
// 503 when no delivery channel is configured (production without
|
||||
// TWO_FACTOR_ALLOW_LOG_DELIVERY=true); 500 on DB failure. The route is
|
||||
// 503 when no delivery channel is configured (production — no email/SMS and
|
||||
// stdout-log delivery is dev/test-only); 500 on DB failure. The route is
|
||||
// mounted with RequireAuth + RequireNonGuest + the shared per-user 2FA limiter
|
||||
// (plus the group's per-IP limiter), so an enabled user cannot hammer code
|
||||
// requests faster than the surface budget.
|
||||
@@ -739,9 +732,9 @@ func AdminSendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// and delivered via the build-dependent delivery channel (see deliverTwoFACode)
|
||||
// when no valid pending code exists, and the submitted code is checked under the
|
||||
// shared 5-attempt lockout (wrong code → 400, lockout → 429); only a correct
|
||||
// code clears the flag. When no delivery channel is configured (production
|
||||
// without email/SMS and without the explicit TWO_FACTOR_ALLOW_LOG_DELIVERY
|
||||
// opt-in), the mint fails loudly with the actionable setup error instead of a
|
||||
// code clears the flag. When no delivery channel is configured (production —
|
||||
// email/SMS unwired and stdout-log delivery is dev/test-only), the mint fails
|
||||
// loudly with the actionable setup error instead of a
|
||||
// silent 500. Fresh-code mints are throttled per-user (twoFAMintCooldown) so
|
||||
// the loop above cannot reset the lockout faster than once per cooldown. In
|
||||
// unenforced (dev) environments the loose behavior is kept: no code required,
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
package user
|
||||
|
||||
// Dev/test builds (the `dev` tag, or any build with the `test` tag) keep the
|
||||
// documented loose-fake 2FA delivery: the plaintext code is written to the
|
||||
// server log ([2FA] prefix) as the stand-in for the not-yet-wired email/SMS
|
||||
// transport (P6), and a missing TWO_FACTOR_PEPPER still falls back to the
|
||||
// legacy unsalted SHA-256 digest. Production builds (!dev && !test) instead
|
||||
// never log the code and fail closed without the pepper — see twofa_prod.go.
|
||||
// Dev/test builds (the `dev` tag, or any build with the `test` tag) deliver 2FA
|
||||
// codes to the LOCAL DEV stdout log ([2FA] prefix) as the stand-in for the
|
||||
// not-yet-wired email/SMS transport (P6), and a missing TWO_FACTOR_PEPPER still
|
||||
// falls back to the legacy unsalted SHA-256 digest. Production builds
|
||||
// (!dev && !test) instead NEVER log the code — log delivery is a dev/test-only
|
||||
// local feature, never a production channel — and fail closed without the
|
||||
// pepper — see twofa_prod.go.
|
||||
|
||||
import (
|
||||
"crussell/internal/twofa"
|
||||
@@ -45,10 +46,10 @@ func init() {
|
||||
func twoFAEnsureIssueAllowed() error { return nil }
|
||||
|
||||
// twoFADeliverCode delivers a fresh verification code to the user. Dev/test:
|
||||
// the [2FA] log line is the delivery channel — an operator relays the code to
|
||||
// the user out-of-band until email/SMS lands. Production builds log it ONLY
|
||||
// when the operator explicitly opts in via TWO_FACTOR_ALLOW_LOG_DELIVERY=true
|
||||
// (see twofa_prod.go); otherwise they refuse issuance up front.
|
||||
// the [2FA] log line is the LOCAL DEV delivery channel — an operator (or the
|
||||
// developer) relays the code to the user out-of-band until email/SMS lands
|
||||
// (P6). Production builds NEVER log it (see twofa_prod.go) and refuse issuance
|
||||
// up front — stdout-log delivery is a dev/test-only feature.
|
||||
//
|
||||
// MEDIUM-3b: the user id and the plaintext code are written to SEPARATE log
|
||||
// lines so a log line cannot trivially pair a code with its owner. The two
|
||||
|
||||
@@ -11,6 +11,11 @@ package user
|
||||
// those branches in the STANDARD test,dev run, so a regression in the prod
|
||||
// fail-closed behaviour is CI-visible even though the prod file itself is only
|
||||
// compiled in a genuine production build.
|
||||
//
|
||||
// DELIVERY POSTURE (current): stdout-log delivery of 2FA codes is a
|
||||
// DEV/TEST-ONLY local feature. Production has no delivery channel of any kind
|
||||
// (email/SMS unwired, P6; no production opt-in to log delivery), so the strict
|
||||
// gate refuses issuance unconditionally after the pepper check.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -22,39 +27,29 @@ import (
|
||||
// issuance gate that twofa_prod.go's twoFAEnsureIssueAllowed delegates to:
|
||||
// (a) pepper unset → issuance refused (errTwoFAPepperRequired — an unsalted
|
||||
// digest in the 1M code space would be offline-brute-forceable);
|
||||
// (b) delivery channel absent → issuance refused (errTwoFADeliveryUnavailable —
|
||||
// the 503-style error the handlers surface as StatusServiceUnavailable);
|
||||
// (c) both configured → issuance succeeds.
|
||||
// (b) pepper set but no delivery channel → issuance STILL refused
|
||||
// (errTwoFADeliveryUnavailable — production has no channel until email/SMS
|
||||
// lands, P6; the 503-style error the handlers surface as
|
||||
// StatusServiceUnavailable). Issuance can never succeed in a production build
|
||||
// until a real transport exists.
|
||||
func TestTwoFAEnsureIssueAllowedStrict_FailClosed(t *testing.T) {
|
||||
t.Run("pepper_unset_refuses_issuance", func(t *testing.T) {
|
||||
t.Setenv(twoFAPepperEnv, "")
|
||||
t.Setenv(twoFAAllowLogDeliveryEnv, "true")
|
||||
require.ErrorIs(t, twoFAEnsureIssueAllowedStrict(), errTwoFAPepperRequired)
|
||||
})
|
||||
|
||||
t.Run("delivery_channel_absent_refuses_issuance", func(t *testing.T) {
|
||||
t.Run("no_delivery_channel_refuses_issuance", func(t *testing.T) {
|
||||
t.Setenv(twoFAPepperEnv, "test-pepper")
|
||||
t.Setenv(twoFAAllowLogDeliveryEnv, "")
|
||||
require.ErrorIs(t, twoFAEnsureIssueAllowedStrict(), errTwoFADeliveryUnavailable)
|
||||
})
|
||||
|
||||
t.Run("pepper_and_channel_present_allows_issuance", func(t *testing.T) {
|
||||
t.Setenv(twoFAPepperEnv, "test-pepper")
|
||||
t.Setenv(twoFAAllowLogDeliveryEnv, "true")
|
||||
require.NoError(t, twoFAEnsureIssueAllowedStrict())
|
||||
})
|
||||
}
|
||||
|
||||
// TestTwoFADeliveryChannelConfigured pins the pure delivery-channel predicate
|
||||
// behind the 503 refusal: only the exact value "true" opens the channel.
|
||||
// TestTwoFADeliveryChannelConfigured pins the pure delivery-channel predicate:
|
||||
// production has NO delivery channel — stdout-log delivery is a dev/test-only
|
||||
// local feature and there is no production opt-in — so it is always false.
|
||||
func TestTwoFADeliveryChannelConfigured(t *testing.T) {
|
||||
t.Setenv(twoFAPepperEnv, "test-pepper")
|
||||
for _, v := range []string{"", "1", "yes", "on", "True", "TRUE", "false"} {
|
||||
t.Setenv(twoFAAllowLogDeliveryEnv, v)
|
||||
require.False(t, twoFADeliveryChannelConfigured(), "value %q must NOT open the delivery channel (exact 'true' only)", v)
|
||||
}
|
||||
t.Setenv(twoFAAllowLogDeliveryEnv, "true")
|
||||
require.True(t, twoFADeliveryChannelConfigured())
|
||||
require.False(t, twoFADeliveryChannelConfigured())
|
||||
}
|
||||
|
||||
// TestTwoFAPepperConfigured pins the pure pepper predicate behind the
|
||||
|
||||
@@ -3,37 +3,33 @@
|
||||
package user
|
||||
|
||||
// Production builds (neither the `dev` nor the `test` tag) must never persist
|
||||
// an unsalted digest and must never write a 2FA code in plaintext by default:
|
||||
// the plaintext [2FA] log delivery and the TWO_FACTOR_PEPPER fallback exist
|
||||
// only in dev/test builds (twofa_dev.go). Here code issuance fails closed on
|
||||
// BOTH missing configuration pieces:
|
||||
// an unsalted digest and must never write a 2FA code in plaintext: the
|
||||
// plaintext [2FA] log delivery exists ONLY in dev/test builds (twofa_dev.go)
|
||||
// as a LOCAL-DEV stand-in until the email/SMS transport is wired (P6). In a
|
||||
// production build there is NO delivery channel of any kind, so code issuance
|
||||
// fails closed unconditionally:
|
||||
//
|
||||
// - a missing TWO_FACTOR_PEPPER (an unsalted digest in the 1M code space
|
||||
// would be offline-brute-forceable from a log/DB leak), mirroring how
|
||||
// main.go refuses to start without a strong JWT_SECRET_KEY; and
|
||||
// - a missing delivery channel. The email/SMS transport is not wired yet
|
||||
// (P6), so the ONLY production channel is the operator's explicit opt-in
|
||||
// to the insecure log-delivery mode (TWO_FACTOR_ALLOW_LOG_DELIVERY=true).
|
||||
// Without it, issuing a code would silently dead-end setup — the user
|
||||
// could never receive the code and the enforced saved-card-payments gate
|
||||
// would lock them out with no way forward. Issuance is refused and the
|
||||
// handlers surface errTwoFADeliveryUnavailable ("2FA requires an email or
|
||||
// SMS delivery channel; contact the salon").
|
||||
// - no delivery channel by definition — email/SMS is not wired yet (P6) and
|
||||
// stdout-log delivery is a dev/test-only convenience, never a production
|
||||
// channel. There is deliberately NO production opt-in to log delivery:
|
||||
// writing plaintext codes to a server log anyone with backend access can
|
||||
// read would defeat the account-verification 2FA gate, and issuing a code
|
||||
// that can never reach the user would silently dead-end setup. Issuance is
|
||||
// refused and the handlers surface errTwoFADeliveryUnavailable ("2FA
|
||||
// requires an email or SMS delivery channel; contact the salon") until a
|
||||
// real transport lands.
|
||||
//
|
||||
// The plaintext code is therefore never written to the server log unless the
|
||||
// operator explicitly opted into log delivery and accepted its risk.
|
||||
// The plaintext code is therefore NEVER written to the server log in a
|
||||
// production build, under any configuration.
|
||||
|
||||
import (
|
||||
"crussell/internal/twofa"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
// twoFAAllowLogDeliveryEnv (the TWO_FACTOR_ALLOW_LOG_DELIVERY opt-in) and
|
||||
// errTwoFAPepperRequired are defined in twofa.go — shared by the pure issuance
|
||||
// gate (twoFAEnsureIssueAllowedStrict), which the test,dev suite exercises
|
||||
// directly, and this production build.
|
||||
|
||||
// init registers the production pepper reader into the shared verification
|
||||
// core (crussell/internal/twofa): raw env read, no fallback — code issuance
|
||||
// fails closed via twoFAEnsureIssueAllowed, so no pending code is ever
|
||||
@@ -43,33 +39,28 @@ func init() {
|
||||
}
|
||||
|
||||
// twoFADeliveryAvailable reports whether a 2FA code delivery channel exists in
|
||||
// this build. Production: true only when the operator explicitly opted into the
|
||||
// insecure log-delivery mode (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) or a real
|
||||
// email/SMS transport is wired (not yet — P6). Default false: no channel, so
|
||||
// code issuance is refused and setup surfaces errTwoFADeliveryUnavailable
|
||||
// instead of a silent dead-end. Delegates to the pure build-agnostic
|
||||
// twoFADeliveryChannelConfigured (twofa.go); dev/test builds always return true
|
||||
// (twofa_dev.go).
|
||||
func twoFADeliveryAvailable() bool {
|
||||
return twoFADeliveryChannelConfigured()
|
||||
}
|
||||
// this build. Production: always false — email/SMS is not wired (P6) and
|
||||
// stdout-log delivery is a dev/test-only local feature (twofa_dev.go), never a
|
||||
// production channel. Default false: no channel, so code issuance is refused
|
||||
// and setup surfaces errTwoFADeliveryUnavailable instead of a silent dead-end.
|
||||
func twoFADeliveryAvailable() bool { return false }
|
||||
|
||||
// twoFAEnsureIssueAllowed reports whether a 2FA code may be issued in this
|
||||
// deployment. Production requires BOTH a delivery channel and TWO_FACTOR_PEPPER:
|
||||
// without a channel (no email/SMS, no TWO_FACTOR_ALLOW_LOG_DELIVERY=true) the
|
||||
// code could never reach the user — issuing one would silently lock the user
|
||||
// out of the enforced saved-card-payments gate; and without the pepper every
|
||||
// stored code would be an offline-brute-forceable unsalted digest. Either way
|
||||
// issuance is refused (fail-closed). Delegates to the pure build-agnostic gate
|
||||
// twoFAEnsureIssueAllowedStrict (twofa.go), which the test,dev suite also
|
||||
// exercises directly; dev/test builds always allow issuance (twofa_dev.go).
|
||||
// deployment. Production requires TWO_FACTOR_PEPPER and, after that, a real
|
||||
// delivery channel — which does not exist until email/SMS lands (P6), so
|
||||
// issuance is ALWAYS refused (fail-closed): without a channel a code could
|
||||
// never reach the user and would silently lock them out of the enforced
|
||||
// saved-card-payments gate, and without the pepper every stored code would be
|
||||
// an offline-brute-forceable unsalted digest. Delegates to the pure
|
||||
// build-agnostic gate twoFAEnsureIssueAllowedStrict (twofa.go), which the
|
||||
// test,dev suite also exercises directly; dev/test builds always allow
|
||||
// issuance (twofa_dev.go).
|
||||
//
|
||||
// The pepper check is the ONLY hard gate here (plus the delivery channel), and
|
||||
// it is also the ONLY hard gate on the payments re-issue path
|
||||
// (payments.twoFAReissueIssueAllowed). PEPPER-CHANGE HAZARD (Loop B finding 2):
|
||||
// the pepper keys the HMAC-SHA256 of every stored pending-code hash, so
|
||||
// CHANGING TWO_FACTOR_PEPPER invalidates ALL pending codes — every stored hash
|
||||
// was computed with the old pepper and can never match a code minted under the
|
||||
// The pepper check is the ONLY hard gate here (plus the always-absent
|
||||
// delivery channel). PEPPER-CHANGE HAZARD (Loop B finding 2): the pepper keys
|
||||
// the HMAC-SHA256 of every stored pending-code hash, so CHANGING
|
||||
// TWO_FACTOR_PEPPER invalidates ALL pending codes — every stored hash was
|
||||
// computed with the old pepper and can never match a code minted under the
|
||||
// new one. An operator who changes the pepper must re-mint every user's code
|
||||
// (or have each user re-run 2FA setup), or enforced saved-card charges will
|
||||
// strand customers with 400 ErrMissingOrExpired forever.
|
||||
@@ -77,21 +68,12 @@ func twoFAEnsureIssueAllowed() error {
|
||||
return twoFAEnsureIssueAllowedStrict()
|
||||
}
|
||||
|
||||
// twoFADeliverCode delivers a fresh verification code to the user. Production
|
||||
// has no wired email/SMS transport (P6), so the ONLY channel is the operator's
|
||||
// explicit, insecure opt-in to log delivery (TWO_FACTOR_ALLOW_LOG_DELIVERY=true
|
||||
// — anyone with backend log access could defeat the 2FA gate on saved-card
|
||||
// charges). WITHOUT that flag the plaintext code is NEVER written to the log;
|
||||
// twoFAEnsureIssueAllowed already refused issuance, so this no-op is
|
||||
// unreachable. With the flag set, the code is written to the [2FA] log line
|
||||
// and an operator relays it to the user out-of-band, exactly like the
|
||||
// documented dev flow — the operator has accepted the risk of log-based
|
||||
// delivery. MEDIUM-3b: the user id and the plaintext code go to SEPARATE log
|
||||
// lines so a single record cannot trivially pair a code with its owner.
|
||||
// twoFADeliverCode delivers a fresh verification code to the user. Production:
|
||||
// a deliberate no-op — there is no delivery channel (email/SMS unwired, P6)
|
||||
// and the plaintext code is NEVER written to the server log, so this is
|
||||
// unreachable (twoFAEnsureIssueAllowed already refused issuance). The
|
||||
// dev/test build (twofa_dev.go) writes the [2FA] log line instead.
|
||||
func twoFADeliverCode(userID, label, code string) {
|
||||
if os.Getenv(twoFAAllowLogDeliveryEnv) == "true" {
|
||||
log.Printf("[2FA] code delivery requested (user=%s, purpose=%s)", userID, label)
|
||||
log.Printf("[2FA] code: %s", code)
|
||||
}
|
||||
// Otherwise: deliberate no-op — never log the plaintext code by default.
|
||||
// Deliberate no-op: production never logs plaintext codes, under any
|
||||
// configuration. Delivery is dev/test-only until email/SMS lands (P6).
|
||||
}
|
||||
|
||||
@@ -13,67 +13,65 @@ package user
|
||||
// their assertions ONLY when the prod variant marker reports the real prod
|
||||
// functions are live; under the test tag they skip with the same documented
|
||||
// rationale the payments package uses (twofa_delivery_prod_test.go).
|
||||
//
|
||||
// CLOSING THE GAP: run-prod-tag-tests.sh (backend/) runs `go test -tags
|
||||
// "!dev,!test" ./handlers/user/` — the ONLY build configuration where
|
||||
// twofa_prod.go compiles AND twofaProdVariant is true, so the assertions below
|
||||
// actually execute there. The `if !twofaProdVariant { t.Skip(...) }` guards
|
||||
// MUST stay: under the CI "test,!dev" matrix the dev/test variants are still
|
||||
// the compiled functions (the `test` tag matches `dev || test`), so without
|
||||
// the guards those runs would FAIL rather than skip.
|
||||
//
|
||||
// DELIVERY POSTURE (current): stdout-log delivery of 2FA codes is a
|
||||
// DEV/TEST-ONLY local feature. A production build has NO delivery channel of
|
||||
// any kind — email/SMS is not wired yet (P6) and there is deliberately no
|
||||
// production opt-in to log delivery — so code issuance fails closed
|
||||
// unconditionally (after the pepper check) and twoFADeliveryAvailable is
|
||||
// always false.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// twoFAAllowLogDeliveryEnv is only defined in twofa_prod.go (!dev && !test);
|
||||
// use the literal env name so this test also compiles under `test,!dev`.
|
||||
const allowLogDeliveryEnv = "TWO_FACTOR_ALLOW_LOG_DELIVERY"
|
||||
|
||||
// TestTwoFAEnsureIssueAllowed_ProdPredicate pins the production issuance gate:
|
||||
// it fails closed without TWO_FACTOR_PEPPER (an unsalted digest in the 1M code
|
||||
// space would be offline-brute-forceable) or without a delivery channel, and
|
||||
// allows issuance only when both are configured.
|
||||
// space would be offline-brute-forceable) and, with the pepper set, STILL fails
|
||||
// closed because a production build has no delivery channel (email/SMS unwired,
|
||||
// stdout-log delivery is dev/test-only) — issuance can never succeed until a
|
||||
// real transport lands.
|
||||
func TestTwoFAEnsureIssueAllowed_ProdPredicate(t *testing.T) {
|
||||
if !twofaProdVariant {
|
||||
t.Skip("twoFAEnsureIssueAllowed() is the dev/test build's always-allowed variant (twofa_dev.go, `dev || test`); the prod fail-closed branches are unreachable under the test tag — see the file header for the documented limitation")
|
||||
}
|
||||
|
||||
os.Unsetenv(twoFAPepperEnv)
|
||||
os.Unsetenv(allowLogDeliveryEnv)
|
||||
if err := twoFAEnsureIssueAllowed(); err == nil {
|
||||
t.Error("expected issuance refused without TWO_FACTOR_PEPPER in a production build")
|
||||
} else if err.Error() != "TWO_FACTOR_PEPPER is not set; refusing to issue a 2FA code (an unsalted digest would be offline-brute-forceable)" {
|
||||
t.Errorf("expected the pepper-required error without the pepper, got %v", err)
|
||||
}
|
||||
|
||||
// With the pepper set, a production build STILL refuses: there is no
|
||||
// delivery channel (email/SMS unwired, P6; stdout-log delivery is a
|
||||
// dev/test-only local feature and there is no production opt-in).
|
||||
os.Setenv(twoFAPepperEnv, "test-pepper")
|
||||
os.Unsetenv(allowLogDeliveryEnv)
|
||||
if err := twoFAEnsureIssueAllowed(); err == nil {
|
||||
t.Error("expected issuance refused without a delivery channel in a production build")
|
||||
t.Error("expected issuance refused in a production build with no delivery channel (email/SMS unwired, log delivery dev/test-only)")
|
||||
} else if err != errTwoFADeliveryUnavailable {
|
||||
t.Errorf("expected errTwoFADeliveryUnavailable without a channel, got %v", err)
|
||||
}
|
||||
|
||||
os.Setenv(allowLogDeliveryEnv, "true")
|
||||
if err := twoFAEnsureIssueAllowed(); err != nil {
|
||||
t.Errorf("expected issuance allowed with both the pepper and a delivery channel, got %v", err)
|
||||
t.Errorf("expected errTwoFADeliveryUnavailable with no channel, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoFADeliveryAvailable_ProdPredicate pins the production delivery
|
||||
// predicate: TWO_FACTOR_ALLOW_LOG_DELIVERY unset → no channel (false), exactly
|
||||
// "true" → channel (true), any other value → no channel.
|
||||
// predicate: a production build ALWAYS reports no delivery channel — stdout-log
|
||||
// delivery is a dev/test-only local feature, never a production channel.
|
||||
func TestTwoFADeliveryAvailable_ProdPredicate(t *testing.T) {
|
||||
if !twofaProdVariant {
|
||||
t.Skip("twoFADeliveryAvailable() is the dev/test build's trivially-true variant (twofa_dev.go, `dev || test`); the 503 delivery-unavailable branch is unreachable under the test tag — see the file header for the documented limitation")
|
||||
t.Skip("twoFADeliveryAvailable() is the dev/test build's trivially-true variant (twofa_dev.go, `dev || test`); the always-false production predicate is unreachable under the test tag — see the file header for the documented limitation")
|
||||
}
|
||||
|
||||
os.Unsetenv(allowLogDeliveryEnv)
|
||||
if twoFADeliveryAvailable() {
|
||||
t.Error("production without the explicit opt-in must have NO 2FA delivery channel")
|
||||
}
|
||||
for _, v := range []string{"", "1", "yes", "on", "True", "TRUE", "false"} {
|
||||
os.Setenv(allowLogDeliveryEnv, v)
|
||||
if twoFADeliveryAvailable() {
|
||||
t.Errorf("value %q must NOT open the delivery channel (exact 'true' only)", v)
|
||||
}
|
||||
}
|
||||
os.Setenv(allowLogDeliveryEnv, "true")
|
||||
if !twoFADeliveryAvailable() {
|
||||
t.Error("the explicit insecure log-delivery opt-in must open the channel")
|
||||
t.Error("a production build must ALWAYS report NO 2FA delivery channel (email/SMS unwired; stdout-log delivery is dev/test-only)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user