fix: rate-limit per-IP keying + dev no-op, admin 2FA recovery, 2FA account UI, admin modals, tip totals

Rate limiting (backend):
- RateLimit/ProgressiveRateLimit now derive the per-client key from
  CF-Connecting-IP, then chi's GetClientIP (the X-Real-IP value nginx sets at
  main.go:323), then RemoteAddr. Previously only CF-Connecting-IP/RemoteAddr
  were used, so behind the Docker nginx every client shared ONE bucket per
  limiter — 10 logins/min site-wide blocked all users (the reported
  'Error: Rate limit exceeded' after seeding was the login 10/min bucket
  tripped by the seed's 11 logins, all keyed 127.0.0.1 in dev).
- Real implementation is now //go:build !dev || test; new
  mw/ratelimit_dev.go (//go:build dev && !test) is a no-op passthrough, so
  'go run -tags dev' (the dev harness) never rate-limits dev/seeding traffic,
  while production and tests (-tags test,dev) keep the real limiter. The docs
  (Technical Manual) already claimed dev no-op behaviour — the code now
  matches. NewProgressiveRateLimiter is provided in the no-op build because
  tag-free ratelimit_shared.go:104 initializes the global at package init.

Admin 2FA management (backend):
- users.two_factor_last_used_at TIMESTAMPTZ column (init-script, fresh-DB).
- AdminUserDetail now returns twoFactorEnabled/twoFactorMethod/
  twoFactorLastUsedAt.
- New POST /api/admin/users/{id}/2fa/remove (admin-only): clears all 5 2FA
  columns + drops the user's in-memory attempt/lockout state — an admin
  recovery path when a user loses 2FA access.
- two_factor_last_used_at updated on every successful 2FA verification.

Account page (/account):
- 2FA section moved under the Notifications heading, visible to all roles;
  Email/SMS toggles (Notifications styling) acting as a radio group with
  'none' state; Apply button only when the selection differs from saved;
  unselecting shows a payment-rules warning dialog; the dev-comment
  '2FA is optional right now (REQUIRE_2FA is off)' and the 'Dev code:' debug
  line are removed.
- Cards tab hidden from admin role.

Admin modals:
- User Details modal: new 'Two-Factor Authentication' section above Patch
  Tests showing Enabled/Disabled, method, last-used timestamp, and a Remove
  2FA button with a confirmation dialog (POST to the admin endpoint, refetch
  on success).
- Booking Details modal: the customer's name now links to their User Details
  modal (optional openUserModal prop threaded through admin/+page and
  today/+page; other call sites unaffected).

Take Payment + /today:
- PaymentModal shows pre-tip (netTotal) and post-tip (totalWithTip) totals
  with a tip-amount delta row only when a tip is selected; zero-tip flow
  unchanged.
- The /today Payment button is hidden unless the booking is in_progress or
  completed, matching the backend gate (was shown for confirmed/pending
  bookings, producing the 'Booking must be in_progress or completed' error).

Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok
incl. new admin 2FA tests + mw tests), go build ./... and -tags dev both
compile, go vet clean, svelte-check 0 errors 0 warnings, env-docs gate OK,
docker compose config valid.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 3894f53778
commit 6691cd5657
14 changed files with 725 additions and 169 deletions
+193
View File
@@ -0,0 +1,193 @@
//go:build test
package user
// Tests for admin 2FA management: the 2FA fields exposed by
// GET /api/admin/users/{id} (AdminUserDetail) and the admin-only recovery route
// POST /api/admin/users/{id}/2fa/remove (AdminRemoveUser2FAHandler), plus the
// two_factor_last_used_at stamping on successful verification. Sequential only
// (no t.Parallel): the enforced-mode test flips process-global env vars, and
// the package shares db.Conn state.
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
)
// makeAdmin2FARequest builds a request with the given role in context (admin
// for the happy paths, verified_email for the RequireAdmin gate test) and the
// {id} route param parsed from the path (extractAdminUserID, from
// admin_handlers_test.go).
func makeAdmin2FARequest(handler http.Handler, method, path, role string, ctx context.Context) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
rctx := chi.NewRouteContext()
if id, ok := extractAdminUserID(path); ok {
rctx.URLParams.Add("id", id)
}
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, mw.UserIDKey, "admin-test-id")
ctx = context.WithValue(ctx, mw.UserRoleKey, role)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
// seedUser2FATurnedOn enables 2FA for a user with a method, a pending code, and
// a last-used stamp so admin view/remove tests start from a fully-populated row.
func seedUser2FATurnedOn(t *testing.T, ctx context.Context, q db.Querier, userID string) {
t.Helper()
_, err := q.Exec(ctx, `
UPDATE users
SET two_factor_enabled = true,
two_factor_method = 'sms',
two_factor_pending_code_hash = 'abcdef',
two_factor_pending_code_expires = $2,
two_factor_last_used_at = $3
WHERE id = $1
`, userID, clock.Now().Add(5*time.Minute), clock.Now().Add(-24*time.Hour))
require.NoError(t, err)
}
// TestAdminUsers_Get_IncludesTwoFAState verifies GET /api/admin/users/{id}
// exposes two_factor_enabled, two_factor_method and two_factor_last_used_at.
func TestAdminUsers_Get_IncludesTwoFAState(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedUser2FATurnedOn(t, ctx, tx, userID)
handler := http.HandlerFunc(GetAdminUserHandler)
w := makeAdmin2FARequest(handler, http.MethodGet, "/api/admin/users/"+userID, "admin", ctx)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Equal(t, true, resp["twoFactorEnabled"])
require.Equal(t, "sms", resp["twoFactorMethod"])
lastUsed, ok := resp["twoFactorLastUsedAt"].(string)
require.True(t, ok, "twoFactorLastUsedAt should serialize as a string")
require.NotEmpty(t, lastUsed)
}
// TestAdminUsers_Get_TwoFADisabledIsFalse verifies the 2FA fields serialize
// sanely for a user who never enabled 2FA (false, nil method/last-used).
func TestAdminUsers_Get_TwoFADisabledIsFalse(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
handler := http.HandlerFunc(GetAdminUserHandler)
w := makeAdmin2FARequest(handler, http.MethodGet, "/api/admin/users/"+userID, "admin", ctx)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Equal(t, false, resp["twoFactorEnabled"])
_, hasMethod := resp["twoFactorMethod"]
require.False(t, hasMethod, "twoFactorMethod should be omitted when nil")
_, hasLastUsed := resp["twoFactorLastUsedAt"]
require.False(t, hasLastUsed, "twoFactorLastUsedAt should be omitted when nil")
}
// TestAdminUsers_Remove2FA_ClearsAllColumns verifies the admin recovery route
// clears the enabled flag, method, pending code fields and last-used stamp.
func TestAdminUsers_Remove2FA_ClearsAllColumns(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedUser2FATurnedOn(t, ctx, tx, userID)
handler := http.HandlerFunc(AdminRemoveUser2FAHandler)
w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/"+userID+"/2fa/remove", "admin", ctx)
require.Equal(t, http.StatusOK, w.Code)
var enabled bool
var method, pendingHash sql.NullString
var pendingExpires, lastUsed sql.NullTime
err = tx.QueryRow(ctx, `
SELECT two_factor_enabled, two_factor_method, two_factor_pending_code_hash,
two_factor_pending_code_expires, two_factor_last_used_at
FROM users WHERE id = $1
`, userID).Scan(&enabled, &method, &pendingHash, &pendingExpires, &lastUsed)
require.NoError(t, err)
require.False(t, enabled, "two_factor_enabled should be false after admin removal")
require.False(t, method.Valid, "two_factor_method should be NULL after admin removal")
require.False(t, pendingHash.Valid, "two_factor_pending_code_hash should be NULL after admin removal")
require.False(t, pendingExpires.Valid, "two_factor_pending_code_expires should be NULL after admin removal")
require.False(t, lastUsed.Valid, "two_factor_last_used_at should be NULL after admin removal")
}
// TestAdminUsers_Remove2FA_UnknownUser_NotFound verifies a valid-format ID that
// matches no user row returns 404.
func TestAdminUsers_Remove2FA_UnknownUser_NotFound(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(AdminRemoveUser2FAHandler)
w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/000000000000/2fa/remove", "admin", ctx)
require.Equal(t, http.StatusNotFound, w.Code)
}
// TestAdminUsers_Remove2FA_InvalidID_NotFound verifies a malformed ID is
// rejected before any query runs.
func TestAdminUsers_Remove2FA_InvalidID_NotFound(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(AdminRemoveUser2FAHandler)
w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/nothex/2fa/remove", "admin", ctx)
require.Equal(t, http.StatusNotFound, w.Code)
}
// TestAdminUsers_Remove2FA_NonAdmin_Forbidden verifies the route is admin-gated:
// a non-admin role gets 403 from RequireAdmin before the handler runs.
func TestAdminUsers_Remove2FA_NonAdmin_Forbidden(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedUser2FATurnedOn(t, ctx, tx, userID)
handler := mw.RequireAdmin(http.HandlerFunc(AdminRemoveUser2FAHandler))
w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/"+userID+"/2fa/remove", "verified_email", ctx)
require.Equal(t, http.StatusForbidden, w.Code)
// The user's 2FA must be untouched by the rejected request.
var enabled bool
err = tx.QueryRow(ctx, `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
require.NoError(t, err)
require.True(t, enabled, "2FA should remain enabled after a 403")
}
// TestTwoFAVerify_Enforced_UpdatesLastUsedAt verifies a successful enforced-mode
// code check stamps two_factor_last_used_at.
func TestTwoFAVerify_Enforced_UpdatesLastUsedAt(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID)
require.Equal(t, http.StatusOK, w.Code)
var enabled bool
var lastUsed sql.NullTime
err = tx.QueryRow(ctx, `SELECT two_factor_enabled, two_factor_last_used_at FROM users WHERE id = $1`, userID).Scan(&enabled, &lastUsed)
require.NoError(t, err)
require.True(t, enabled)
require.True(t, lastUsed.Valid, "two_factor_last_used_at should be set after a successful verify")
}
+8 -1
View File
@@ -112,6 +112,11 @@ type AdminUserDetail struct {
// Name change history
PreviousFirstName *string `json:"previousFirstName,omitempty"`
PreviousLastName *string `json:"previousLastName,omitempty"`
// Two-factor authentication state (admin recovery view)
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TwoFactorMethod *string `json:"twoFactorMethod,omitempty"`
TwoFactorLastUsedAt *string `json:"twoFactorLastUsedAt,omitempty"`
}
type SocialLogin struct {
@@ -420,7 +425,8 @@ func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) {
privacy_policy_and_terms_consent,
policy_consent_updated_at::text,
data_retention_consent,
data_consent_updated_at::text
data_consent_updated_at::text,
two_factor_enabled, two_factor_method, two_factor_last_used_at::text
FROM users
WHERE id = $1
`, userID).Scan(
@@ -430,6 +436,7 @@ func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) {
&user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, &user.Notes,
&user.PrivacyPolicyConsent, &user.PolicyConsentUpdatedAt,
&user.DataRetentionConsent, &user.DataConsentUpdatedAt,
&user.TwoFactorEnabled, &user.TwoFactorMethod, &user.TwoFactorLastUsedAt,
)
if err != nil {
+66 -3
View File
@@ -21,7 +21,10 @@ import (
"crussell/clock"
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/validators"
"crussell/mw"
"github.com/go-chi/chi/v5"
)
// twoFARequired reports whether 2FA enforcement is active in this deployment.
@@ -544,14 +547,17 @@ func VerifyTwoFAHandler(w http.ResponseWriter, r *http.Request) {
writeTwoFAEnabled(w)
}
// enableTwoFA persists two_factor_enabled=true and clears the pending code
// fields (the method was set during setup).
// enableTwoFA persists two_factor_enabled=true, records the last successful
// verification in two_factor_last_used_at, and clears the pending code fields
// (the method was set during setup). The last-used stamp is written here so it
// covers both the enforced verify path and the dev bypass.
func enableTwoFA(r *http.Request, userID string) error {
_, err := db.Conn.Exec(r.Context(), `
UPDATE users
SET two_factor_enabled = true,
two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL
two_factor_pending_code_expires = NULL,
two_factor_last_used_at = NOW()
WHERE id = $1
`, userID)
return err
@@ -714,3 +720,60 @@ func disableTwoFA(r *http.Request, userID string) error {
`, userID)
return err
}
// twoFADeleteAttempts removes a user's attempt-map entry entirely, unlike
// twoFAResetAttempts which only zeroes the count in place. The admin 2FA
// removal flow uses it so any lingering lockout/counter/mint-cooldown state is
// dropped wholesale and a re-setup starts from a clean slate.
func twoFADeleteAttempts(userID string) {
twoFAAttemptMapMu.Lock()
defer twoFAAttemptMapMu.Unlock()
delete(twoFAAttemptMap, userID)
}
// removeUser2FA clears all 2FA state for a user: the enabled flag, method,
// pending code fields, and the last-used timestamp. It reports whether a user
// row was actually affected (false means the user does not exist).
func removeUser2FA(r *http.Request, userID string) (bool, error) {
tag, err := db.Conn.Exec(r.Context(), `
UPDATE users
SET two_factor_enabled = false,
two_factor_method = NULL,
two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL,
two_factor_last_used_at = NULL
WHERE id = $1
`, userID)
if err != nil {
return false, err
}
return tag.RowsAffected() > 0, nil
}
// POST /api/admin/users/{id}/2fa/remove
// Admin-only recovery route for when a user loses access to their 2FA device:
// forcibly disables the user's 2FA without requiring their code (bypassing the
// user-facing disable flow's re-verification). The route is mounted inside the
// admin-gated /admin/users group so RequireAdmin runs first. Mirrors
// disableTwoFA's column clearing plus two_factor_last_used_at, and drops the
// user's in-memory attempt/lockout state. A log line records the admin action.
func AdminRemoveUser2FAHandler(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "id")
if userID == "" || !validators.IsValidID(userID) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
affected, err := removeUser2FA(r, userID)
if err != nil {
log.Printf("failed to remove 2FA for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
if !affected {
http.Error(w, "user not found", http.StatusNotFound)
return
}
twoFADeleteAttempts(userID)
log.Printf("[2FA] admin removed 2FA for user %s", userID)
w.WriteHeader(http.StatusOK)
}