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:
+25
-14
@@ -1,3 +1,5 @@
|
||||
//go:build !dev || test
|
||||
|
||||
package mw
|
||||
|
||||
import (
|
||||
@@ -6,6 +8,8 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||
@@ -103,13 +107,7 @@ func (prl *ProgressiveRateLimiter) Check(ip string) (delayMs int) {
|
||||
|
||||
func ProgressiveRateLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.Header.Get("CF-Connecting-IP")
|
||||
if ip == "" {
|
||||
ip, _, _ = net.SplitHostPort(r.RemoteAddr)
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
}
|
||||
ip := clientIP(r)
|
||||
|
||||
delay := globalProgressiveLimiter.Check(ip)
|
||||
if delay > 0 {
|
||||
@@ -126,13 +124,7 @@ func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler
|
||||
limiter := NewRateLimiter(limit, window)
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.Header.Get("CF-Connecting-IP")
|
||||
if ip == "" {
|
||||
ip, _, _ = net.SplitHostPort(r.RemoteAddr)
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
}
|
||||
ip := clientIP(r)
|
||||
|
||||
if !limiter.Allow(ip) {
|
||||
RespondJSON(w, http.StatusTooManyRequests, map[string]string{"error": "Rate limit exceeded"})
|
||||
@@ -143,3 +135,22 @@ func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP derives the per-client rate-limit key. Priority:
|
||||
// 1. CF-Connecting-IP header (set only when Cloudflare is the edge;
|
||||
// nginx never sets it, so it cannot be spoofed through our proxy)
|
||||
// 2. middleware.GetClientIP(r.Context()) — the X-Real-IP value nginx sets,
|
||||
// captured by middleware.ClientIPFromHeader("X-Real-IP") in main.go
|
||||
// 3. net.SplitHostPort(r.RemoteAddr) / r.RemoteAddr fallback
|
||||
func clientIP(r *http.Request) string {
|
||||
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
if ip := middleware.GetClientIP(r.Context()); ip != "" {
|
||||
return ip
|
||||
}
|
||||
if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil && ip != "" {
|
||||
return ip
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build dev && !test
|
||||
|
||||
// No-op rate limiting for pure dev builds (-tags dev): the dev seed performs
|
||||
// more logins/requests than the real limiters allow in a minute, which would
|
||||
// block local development. Tests always build with the `test` tag, so the real
|
||||
// implementation in ratelimit.go is used there. The types, registration, and
|
||||
// Cleanup methods come from tag-free ratelimit_shared.go.
|
||||
|
||||
package mw
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||
return &RateLimiter{}
|
||||
}
|
||||
|
||||
func NewProgressiveRateLimiter() *ProgressiveRateLimiter {
|
||||
return &ProgressiveRateLimiter{}
|
||||
}
|
||||
|
||||
func ProgressiveRateLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user