From b96c6942925dadbc390b295042722cc585c05f36 Mon Sep 17 00:00:00 2001 From: konsalex Date: Tue, 15 Sep 2026 22:05:25 +0200 Subject: [PATCH] feat: add abuse blockers --- backend/api-files/openapi.yaml | 68 +++++- backend/internal/config/config.go | 12 + backend/internal/handlers/handlers.go | 74 +++++- backend/internal/handlers/ratelimit.go | 118 ++++++++++ backend/internal/handlers/ratelimit_test.go | 105 +++++++++ backend/internal/handlers/turnstile.go | 111 +++++++++ backend/internal/handlers/turnstile_test.go | 119 ++++++++++ backend/internal/server/server.go | 22 +- backend/test/integration/auth_abuse_test.go | 237 ++++++++++++++++++++ tauri/src/openapi.d.ts | 91 +++++++- web-app/package.json | 1 + web-app/src/components/Turnstile.tsx | 51 +++++ web-app/src/lib/turnstile.ts | 8 + web-app/src/openapi.d.ts | 91 +++++++- web-app/src/pages/ForgotPassword.tsx | 19 +- web-app/src/pages/Login.tsx | 52 ++++- web-app/src/pages/ResetPassword.tsx | 17 +- web-app/src/vite-env.d.ts | 10 + yarn.lock | 11 + 19 files changed, 1189 insertions(+), 28 deletions(-) create mode 100644 backend/internal/handlers/ratelimit.go create mode 100644 backend/internal/handlers/ratelimit_test.go create mode 100644 backend/internal/handlers/turnstile.go create mode 100644 backend/internal/handlers/turnstile_test.go create mode 100644 backend/test/integration/auth_abuse_test.go create mode 100644 web-app/src/components/Turnstile.tsx create mode 100644 web-app/src/lib/turnstile.ts diff --git a/backend/api-files/openapi.yaml b/backend/api-files/openapi.yaml index 0f1138fa..50d41a4e 100644 --- a/backend/api-files/openapi.yaml +++ b/backend/api-files/openapi.yaml @@ -390,7 +390,9 @@ paths: password: type: string format: password - description: User's password + minLength: 12 + maxLength: 72 + description: User's password (12-72 characters) team_name: type: string description: Name of the team (required unless team_invite_uuid is provided) @@ -398,6 +400,9 @@ paths: type: string format: uuid description: UUID for team invitation (if joining an existing team) + turnstile_token: + type: string + description: Cloudflare Turnstile token. Required when Turnstile is enabled server-side; ignored otherwise. responses: "200": description: Successfully signed up @@ -424,6 +429,60 @@ paths: schema: $ref: "#/components/schemas/Error" + /api/sign-in: + post: + summary: Manual sign in endpoint + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - email + - password + properties: + email: + type: string + format: email + description: User's email address + password: + type: string + format: password + description: User's password + turnstile_token: + type: string + description: Cloudflare Turnstile token. Required when Turnstile is enabled server-side; ignored otherwise. + responses: + "200": + description: Successfully signed in + content: + application/json: + schema: + type: object + properties: + token: + type: string + description: JWT authentication token + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Invalid email or password + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "429": + description: Too many sign-in attempts; retry after the window elapses + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/forgot-password: post: summary: Request password reset email @@ -441,6 +500,9 @@ paths: type: string format: email description: User's email address + turnstile_token: + type: string + description: Cloudflare Turnstile token. Required when Turnstile is enabled server-side; ignored otherwise. responses: "200": description: Success message (always returned regardless of whether email exists) @@ -482,7 +544,9 @@ paths: password: type: string format: password - description: New password + minLength: 12 + maxLength: 72 + description: New password (12-72 characters; 72 is bcrypt's byte ceiling) responses: "200": description: Password reset successfully diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 92730395..62ce0b4d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -73,6 +73,9 @@ type Config struct { // at checkout. Set to 0 to disable the trial (charge immediately). TrialPeriodDays int64 } + Turnstile struct { + SecretKey string + } } func Load() (*Config, error) { @@ -200,9 +203,18 @@ func Load() (*Config, error) { c.Stripe.SuccessURL = fmt.Sprintf("https://%s/subscription/success", c.Server.DeployDomain) c.Stripe.CancelURL = fmt.Sprintf("https://%s/subscription/cancel", c.Server.DeployDomain) + c.Turnstile.SecretKey = os.Getenv("CLOUDFLARE_SECRET_KEY") + return c, nil } +// IsTurnstileEnabled reports whether Cloudflare Turnstile verification is +// configured. Self-hosted deployments without CLOUDFLARE_SECRET_KEY skip +// verification so email/password auth remains usable, mirroring IsStripeEnabled. +func (c *Config) IsTurnstileEnabled() bool { + return c.Turnstile.SecretKey != "" +} + // IsStripeEnabled reports whether Stripe billing is configured. Self-hosted // deployments without STRIPE_SECRET_KEY bypass the trial guard and are treated // as Pro. diff --git a/backend/internal/handlers/handlers.go b/backend/internal/handlers/handlers.go index 154e31a8..d2a423e5 100644 --- a/backend/internal/handlers/handlers.go +++ b/backend/internal/handlers/handlers.go @@ -15,6 +15,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/google/uuid" "github.com/labstack/echo-contrib/session" @@ -36,18 +37,42 @@ type AuthHandler struct { } type SignInRequest struct { - Email string `json:"email" validate:"required,email"` - Password string `json:"password" validate:"required"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` + TurnstileToken string `json:"turnstile_token"` } type ForgotPasswordRequest struct { - Email string `json:"email" validate:"required,email"` + Email string `json:"email" validate:"required,email"` + TurnstileToken string `json:"turnstile_token"` } type ResetPasswordRequest struct { Password string `json:"password" validate:"required"` } +const ( + // minPasswordLength is the authoritative minimum for new passwords, applied + // to both signup and reset; all character types are allowed. + minPasswordLength = 12 + // maxPasswordBytes is bcrypt's hard input ceiling. Input beyond 72 bytes is + // silently truncated by bcrypt, so reject it up front with a clear 400 + // instead of leaking a confusing 500 later. + maxPasswordBytes = 72 +) + +// validateNewPassword enforces the shared new-password policy for signup and +// reset. Callers should run it before hashing. +func validateNewPassword(password string) error { + if utf8.RuneCountInString(password) < minPasswordLength { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Password must be at least %d characters long.", minPasswordLength)) + } + if len(password) > maxPasswordBytes { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Password must be at most %d bytes long.", maxPasswordBytes)) + } + return nil +} + func NewAuthHandler(db *gorm.DB, cfg *config.Config, jwt common.JWTIssuer, redis *redis.Client, socialAuth common.SocialAuthProvider) *AuthHandler { return &AuthHandler{ ServerState: common.ServerState{ @@ -340,9 +365,10 @@ func (h *AuthHandler) ManualSignUp(c echo.Context) error { FirstName string `json:"first_name" validate:"required"` LastName string `json:"last_name" validate:"required"` Email string `json:"email" validate:"required,email"` - Password string `json:"password" validate:"required,min=8"` + Password string `json:"password" validate:"required"` TeamName string `json:"team_name"` TeamInviteUUID string `json:"team_invite_uuid"` + TurnstileToken string `json:"turnstile_token"` } req := new(SignUpRequest) @@ -354,6 +380,15 @@ func (h *AuthHandler) ManualSignUp(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } + // Verify Turnstile and the password policy before any DB lookup or hashing. + if err := h.verifyTurnstile(c, req.TurnstileToken, turnstileActionSignUp); err != nil { + return err + } + + if err := validateNewPassword(req.Password); err != nil { + return err + } + u := &models.User{ FirstName: req.FirstName, LastName: req.LastName, @@ -439,6 +474,18 @@ func (h *AuthHandler) ManualSignIn(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } + // Verify Turnstile before touching the throttle, database, or password hash. + if err := h.verifyTurnstile(c, req.TurnstileToken, turnstileActionSignIn); err != nil { + return err + } + + // Throttle by normalized email (five attempts per minute). Runs before the + // user lookup so behavior doesn't diverge by account existence + // (also known as timing attack in my village: https://en.wikipedia.org/wiki/Timing_attack) + if err := h.checkSignInRateLimit(c, req.Email); err != nil { + return err + } + u := &models.User{} result := h.DB.Where("email = ?", req.Email).First(u) if errors.Is(result.Error, gorm.ErrRecordNotFound) { @@ -449,6 +496,8 @@ func (h *AuthHandler) ManualSignIn(c echo.Context) error { return echo.NewHTTPError(http.StatusUnauthorized, "Invalid email or password") } + h.clearSignInRateLimit(c, req.Email) + // Create a JWT token token, err := h.JwtIssuer.GenerateToken(u.Email) if err != nil { @@ -471,6 +520,18 @@ func (h *AuthHandler) ForgotPassword(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } + if err := h.verifyTurnstile(c, req.TurnstileToken, turnstileActionForgotPassword); err != nil { + return err + } + + emailAllowed := h.allowResetEmail(c, req.Email) + + // Throttled: return the same generic response but do not send another email. + if !emailAllowed { + c.Logger().Infof("forgot-password: throttled, skipping email send") + return c.JSON(http.StatusOK, map[string]string{"message": verificationMessage}) + } + // Check if the user exists u := &models.User{} user := h.DB.Where("email = ?", req.Email).First(u) @@ -512,6 +573,11 @@ func (h *AuthHandler) ResetPassword(c echo.Context) error { if err := c.Validate(req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } + + if err := validateNewPassword(req.Password); err != nil { + return err + } + tokenString := c.Param("token") if tokenString == "" { return echo.NewHTTPError(http.StatusBadRequest, "Missing token") diff --git a/backend/internal/handlers/ratelimit.go b/backend/internal/handlers/ratelimit.go new file mode 100644 index 00000000..bb3f1431 --- /dev/null +++ b/backend/internal/handlers/ratelimit.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/labstack/echo/v4" + "github.com/redis/go-redis/v9" +) + +// These throttles use a fixed 60-second-window counter; the simplest +// useful control. A request in the window increments a per-email counter that +// expires after the window elapses. +const ( + rateLimitWindow = 60 * time.Second + + // signInMaxAttempts allows five sign-in attempts per normalized email per + // window; the sixth is rejected. This causes at most a short attacker-induced + // delay, never a permanent account lockout. + signInMaxAttempts = 5 + + // resetEmailMaxPerWindow sends at most one password-reset email per + // normalized address per window. + resetEmailMaxPerWindow = 1 +) + +// normalizedEmailRateKey hashes strings.ToLower(strings.TrimSpace(email)) so raw +// email addresses are never stored in Redis keys. +func normalizedEmailRateKey(scope, email string) string { + normalized := strings.ToLower(strings.TrimSpace(email)) + sum := sha256.Sum256([]byte(normalized)) + return fmt.Sprintf("ratelimit:%s:%s", scope, hex.EncodeToString(sum[:])) +} + +// incrementRateLimit increments the counter at key and returns the new count and +// the TTL remaining on the window. The expiry is set only on the first increment +// so the window stays anchored to the first request instead of sliding. +func incrementRateLimit(ctx context.Context, rdb *redis.Client, key string) (int64, time.Duration, error) { + count, err := rdb.Incr(ctx, key).Result() + if err != nil { + return 0, 0, err + } + if count == 1 { + if err := rdb.Expire(ctx, key, rateLimitWindow).Err(); err != nil { + return count, 0, err + } + return count, rateLimitWindow, nil + } + ttl, err := rdb.TTL(ctx, key).Result() + if err != nil { + return count, 0, err + } + return count, ttl, nil +} + +// checkSignInRateLimit enforces the per-email sign-in throttle. It fails open +// (returns nil) when Redis is unavailable. The limiter short-circuits on a nil +// client, not just on command errors because Turnstile remains active. On the +// sixth attempt within the window it returns a 429 with a Retry-After header. +func (h *AuthHandler) checkSignInRateLimit(c echo.Context, email string) error { + if h.Redis == nil { + return nil + } + + key := normalizedEmailRateKey("signin", email) + count, ttl, err := incrementRateLimit(c.Request().Context(), h.Redis, key) + if err != nil { + c.Logger().Errorf("sign-in rate limit: redis error, failing open: %v", err) + return nil + } + + if count > signInMaxAttempts { + retryAfter := int(ttl.Seconds()) + if retryAfter < 1 { + retryAfter = 1 + } + c.Response().Header().Set("Retry-After", strconv.Itoa(retryAfter)) + return echo.NewHTTPError(http.StatusTooManyRequests, "Too many sign-in attempts. Please try again later.") + } + + return nil +} + +// clearSignInRateLimit resets the sign-in counter after a successful login +func (h *AuthHandler) clearSignInRateLimit(c echo.Context, email string) { + if h.Redis == nil { + return + } + key := normalizedEmailRateKey("signin", email) + if err := h.Redis.Del(c.Request().Context(), key).Err(); err != nil { + c.Logger().Warnf("sign-in rate limit: failed to clear counter: %v", err) + } +} + +// allowResetEmail reports whether a password-reset email may be sent to email +// during the current window. It increments the counter unconditionally (callers +// must invoke it uniformly, before any user lookup, so behavior and timing don't +// diverge by account existence) and fails open when Redis is unavailable. +func (h *AuthHandler) allowResetEmail(c echo.Context, email string) bool { + if h.Redis == nil { + return true + } + + key := normalizedEmailRateKey("reset", email) + count, _, err := incrementRateLimit(c.Request().Context(), h.Redis, key) + if err != nil { + c.Logger().Errorf("reset-email rate limit: redis error, failing open: %v", err) + return true + } + + return count <= resetEmailMaxPerWindow +} diff --git a/backend/internal/handlers/ratelimit_test.go b/backend/internal/handlers/ratelimit_test.go new file mode 100644 index 00000000..2325f566 --- /dev/null +++ b/backend/internal/handlers/ratelimit_test.go @@ -0,0 +1,105 @@ +package handlers + +import ( + "net/http" + "testing" + "time" + + "hopp-backend/internal/common" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newRedisHandler(t *testing.T) (*AuthHandler, *miniredis.Miniredis) { + t.Helper() + mr, err := miniredis.Run() + require.NoError(t, err) + t.Cleanup(mr.Close) + + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + + return &AuthHandler{ServerState: common.ServerState{Redis: client}}, mr +} + +func TestCheckSignInRateLimit_AllowsFiveThenBlocks(t *testing.T) { + h, _ := newRedisHandler(t) + + for i := 0; i < signInMaxAttempts; i++ { + c, _ := newTestContext() + require.NoErrorf(t, h.checkSignInRateLimit(c, "User@Example.com"), "attempt %d should be allowed", i+1) + } + + // The sixth attempt is blocked. Normalization means casing/whitespace map to the same key. + c, rec := newTestContext() + err := h.checkSignInRateLimit(c, " user@example.com ") + assert.Equal(t, http.StatusTooManyRequests, httpErrorCode(t, err)) + assert.NotEmpty(t, rec.Header().Get("Retry-After")) +} + +func TestCheckSignInRateLimit_ClearResetsCounter(t *testing.T) { + h, _ := newRedisHandler(t) + + for i := 0; i < signInMaxAttempts; i++ { + c, _ := newTestContext() + require.NoError(t, h.checkSignInRateLimit(c, "a@b.com")) + } + + clearCtx, _ := newTestContext() + h.clearSignInRateLimit(clearCtx, "a@b.com") + + // After clearing, a fresh window begins. + c, _ := newTestContext() + require.NoError(t, h.checkSignInRateLimit(c, "a@b.com")) +} + +func TestCheckSignInRateLimit_Expiry(t *testing.T) { + h, mr := newRedisHandler(t) + + for i := 0; i < signInMaxAttempts; i++ { + c, _ := newTestContext() + require.NoError(t, h.checkSignInRateLimit(c, "a@b.com")) + } + + blocked, _ := newTestContext() + require.Error(t, h.checkSignInRateLimit(blocked, "a@b.com")) + + // After the window elapses the counter expires and attempts are allowed again. + mr.FastForward(rateLimitWindow + time.Second) + c, _ := newTestContext() + require.NoError(t, h.checkSignInRateLimit(c, "a@b.com")) +} + +func TestAllowResetEmail_OnePerWindow(t *testing.T) { + h, mr := newRedisHandler(t) + + c1, _ := newTestContext() + assert.True(t, h.allowResetEmail(c1, "a@b.com")) + + c2, _ := newTestContext() + assert.False(t, h.allowResetEmail(c2, "A@B.com")) // normalized to same key + + mr.FastForward(rateLimitWindow + time.Second) + c3, _ := newTestContext() + assert.True(t, h.allowResetEmail(c3, "a@b.com")) +} + +func TestRateLimit_NilRedisShortCircuits(t *testing.T) { + h := &AuthHandler{ServerState: common.ServerState{Redis: nil}} + + // With no Redis client the limiter fails open: never blocks, always allows. + for i := 0; i < signInMaxAttempts*3; i++ { + c, _ := newTestContext() + require.NoError(t, h.checkSignInRateLimit(c, "a@b.com")) + } + + c, _ := newTestContext() + assert.True(t, h.allowResetEmail(c, "a@b.com")) + + // clear must be a no-op and not panic. + clearCtx, _ := newTestContext() + h.clearSignInRateLimit(clearCtx, "a@b.com") +} diff --git a/backend/internal/handlers/turnstile.go b/backend/internal/handlers/turnstile.go new file mode 100644 index 00000000..81783ae8 --- /dev/null +++ b/backend/internal/handlers/turnstile.go @@ -0,0 +1,111 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + "time" + + "github.com/labstack/echo/v4" +) + +// Turnstile action identifiers. The widget sends these via data-action and we +// verify them server-side so a token minted for one form can't be replayed on +// another. Keep these in sync with the web app's widget usage. +const ( + turnstileActionSignUp = "signup" + turnstileActionSignIn = "signin" + turnstileActionForgotPassword = "forgot_password" +) + +// maxTurnstileTokenLength is Cloudflare's documented maximum token length. Longer +// input is rejected without hitting Siteverify. +const maxTurnstileTokenLength = 2048 + +// Overridable in tests to point Siteverify at an httptest server. The client +// carries a short timeout so a slow/unreachable Siteverify degrades to a +// retryable 503 instead of hanging the request. +var ( + turnstileSiteVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + turnstileHTTPClient = &http.Client{Timeout: 5 * time.Second} +) + +type turnstileVerifyResponse struct { + Success bool `json:"success"` + Hostname string `json:"hostname"` + Action string `json:"action"` + ChallengeTS string `json:"challenge_ts"` + ErrorCodes []string `json:"error-codes"` +} + +// verifyTurnstile validates a Turnstile token against Cloudflare's Siteverify +// API, checking success, the route-specific action, and the deploy hostname. +// +// When no secret is configured it is a no-op (returns nil) so OSS/self-host +// builds remain usable — including ignoring any turnstile_token a client happens +// to send. On transport failure it returns a retryable 503; on a missing, +// invalid, expired, reused, or mismatched token it returns a 400. +func (h *AuthHandler) verifyTurnstile(c echo.Context, token, expectedAction string) error { + if !h.Config.IsTurnstileEnabled() { + return nil + } + + if token == "" { + return echo.NewHTTPError(http.StatusBadRequest, "Captcha verification is required.") + } + if len(token) > maxTurnstileTokenLength { + return echo.NewHTTPError(http.StatusBadRequest, "Captcha verification failed. Please try again.") + } + + form := url.Values{} + form.Set("secret", h.Config.Turnstile.SecretKey) + form.Set("response", token) + // I don't think it improves accuracy at all, + // but keeping for extra data: + // https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#required-parameters + if ip := c.RealIP(); ip != "" { + form.Set("remoteip", ip) + } + + ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, turnstileSiteVerifyURL, strings.NewReader(form.Encode())) + if err != nil { + c.Logger().Errorf("turnstile: failed to build siteverify request: %v", err) + return echo.NewHTTPError(http.StatusServiceUnavailable, "Captcha verification is temporarily unavailable. Please try again.") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := turnstileHTTPClient.Do(req) + if err != nil { + c.Logger().Errorf("turnstile: siteverify request failed: %v", err) + return echo.NewHTTPError(http.StatusServiceUnavailable, "Captcha verification is temporarily unavailable. Please try again.") + } + defer resp.Body.Close() + + var result turnstileVerifyResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + c.Logger().Errorf("turnstile: failed to decode siteverify response: %v", err) + return echo.NewHTTPError(http.StatusServiceUnavailable, "Captcha verification is temporarily unavailable. Please try again.") + } + + if !result.Success { + c.Logger().Warnf("turnstile: verification failed: %v", result.ErrorCodes) + return echo.NewHTTPError(http.StatusBadRequest, "Captcha verification failed. Please try again.") + } + + if expectedAction != "" && result.Action != expectedAction { + c.Logger().Warnf("turnstile: action mismatch: expected=%q got=%q", expectedAction, result.Action) + return echo.NewHTTPError(http.StatusBadRequest, "Captcha verification failed. Please try again.") + } + + if expectedHost := h.Config.Server.DeployDomain; expectedHost != "" && result.Hostname != expectedHost { + c.Logger().Warnf("turnstile: hostname mismatch: expected=%q got=%q", expectedHost, result.Hostname) + return echo.NewHTTPError(http.StatusBadRequest, "Captcha verification failed. Please try again.") + } + + return nil +} diff --git a/backend/internal/handlers/turnstile_test.go b/backend/internal/handlers/turnstile_test.go new file mode 100644 index 00000000..abc1ca0c --- /dev/null +++ b/backend/internal/handlers/turnstile_test.go @@ -0,0 +1,119 @@ +package handlers + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "hopp-backend/internal/common" + "hopp-backend/internal/config" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestContext builds a throwaway echo context for exercising handler helpers +// that only need a request/response and a logger. +func newTestContext() (echo.Context, *httptest.ResponseRecorder) { + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("")) + rec := httptest.NewRecorder() + return e.NewContext(req, rec), rec +} + +func newTurnstileHandler(secret, deployDomain string) *AuthHandler { + cfg := &config.Config{} + cfg.Turnstile.SecretKey = secret + cfg.Server.DeployDomain = deployDomain + return &AuthHandler{ServerState: common.ServerState{Config: cfg}} +} + +// setSiteVerify points the verifier at a local test server for the duration of a test. +func setSiteVerify(t *testing.T, handler http.HandlerFunc) { + ts := httptest.NewServer(handler) + t.Cleanup(ts.Close) + old := turnstileSiteVerifyURL + turnstileSiteVerifyURL = ts.URL + t.Cleanup(func() { turnstileSiteVerifyURL = old }) +} + +func httpErrorCode(t *testing.T, err error) int { + t.Helper() + require.Error(t, err) + he, ok := err.(*echo.HTTPError) + require.Truef(t, ok, "expected *echo.HTTPError, got %T", err) + return he.Code +} + +func TestVerifyTurnstile_DisabledIgnoresToken(t *testing.T) { + h := newTurnstileHandler("", "localhost:1926") + c, _ := newTestContext() + + // Disabled mode accepts requests with any (or no) token and never calls Siteverify. + require.NoError(t, h.verifyTurnstile(c, "", turnstileActionSignIn)) + require.NoError(t, h.verifyTurnstile(c, "a-client-supplied-token", turnstileActionSignIn)) +} + +func TestVerifyTurnstile_MissingToken(t *testing.T) { + h := newTurnstileHandler("secret", "localhost:1926") + c, _ := newTestContext() + assert.Equal(t, http.StatusBadRequest, httpErrorCode(t, h.verifyTurnstile(c, "", turnstileActionSignIn))) +} + +func TestVerifyTurnstile_Success(t *testing.T) { + setSiteVerify(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"success":true,"hostname":"cracked.alexoglou.com","action":"signin"}`) + }) + + h := newTurnstileHandler("secret", "cracked.alexoglou.com") + c, _ := newTestContext() + require.NoError(t, h.verifyTurnstile(c, "token", turnstileActionSignIn)) +} + +func TestVerifyTurnstile_ActionMismatch(t *testing.T) { + setSiteVerify(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"success":true,"hostname":"cracked.alexoglou.com","action":"signup"}`) + }) + + h := newTurnstileHandler("secret", "cracked.alexoglou.com") + c, _ := newTestContext() + assert.Equal(t, http.StatusBadRequest, httpErrorCode(t, h.verifyTurnstile(c, "token", turnstileActionSignIn))) +} + +func TestVerifyTurnstile_HostnameMismatch(t *testing.T) { + setSiteVerify(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"success":true,"hostname":"evil.example","action":"signin"}`) + }) + + h := newTurnstileHandler("secret", "cracked.alexoglou.com") + c, _ := newTestContext() + assert.Equal(t, http.StatusBadRequest, httpErrorCode(t, h.verifyTurnstile(c, "token", turnstileActionSignIn))) +} + +func TestVerifyTurnstile_Failure(t *testing.T) { + setSiteVerify(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"success":false,"error-codes":["timeout-or-duplicate"]}`) + }) + + h := newTurnstileHandler("secret", "localhost:1926") + c, _ := newTestContext() + assert.Equal(t, http.StatusBadRequest, httpErrorCode(t, h.verifyTurnstile(c, "token", turnstileActionSignIn))) +} + +func TestVerifyTurnstile_TransportFailureReturns503(t *testing.T) { + old := turnstileSiteVerifyURL + // Port 1 refuses connections, simulating a Siteverify transport failure. + turnstileSiteVerifyURL = "http://127.0.0.1:1/siteverify" + t.Cleanup(func() { turnstileSiteVerifyURL = old }) + + h := newTurnstileHandler("secret", "localhost:1926") + c, _ := newTestContext() + assert.Equal(t, http.StatusServiceUnavailable, httpErrorCode(t, h.verifyTurnstile(c, "token", turnstileActionSignIn))) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index a3b520ec..b130d4af 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -3,6 +3,7 @@ package server import ( "context" "encoding/gob" + "errors" "fmt" "hopp-backend/internal/callstate" "hopp-backend/internal/common" @@ -273,7 +274,24 @@ func (s *Server) setupMetrics() { return } - prometheus.MustRegister(prometheus.NewGaugeFunc( + // Collectors register into Prometheus's global registry, so initializing more + // than one Redis-enabled server in a single process (e.g. across tests) would + // otherwise fail on a duplicate registration. Register (unlike MustRegister) + // returns the error, so we can tolerate the already-registered case. + register := func(c prometheus.Collector) { + err := prometheus.Register(c) + if err == nil { + return + } + var alreadyRegistered prometheus.AlreadyRegisteredError + if errors.As(err, &alreadyRegistered) { + s.Echo.Logger.Warn("Prometheus collector already registered, skipping") + return + } + s.Echo.Logger.Errorf("Failed to register Prometheus collector: %v", err) + } + + register(prometheus.NewGaugeFunc( prometheus.GaugeOpts{ Subsystem: "redis", Name: "connected_clients", @@ -293,7 +311,7 @@ func (s *Server) setupMetrics() { )) if s.CallState != nil { - prometheus.MustRegister(prometheus.NewGaugeFunc( + register(prometheus.NewGaugeFunc( prometheus.GaugeOpts{ Subsystem: "callstate", Name: "active_rooms", diff --git a/backend/test/integration/auth_abuse_test.go b/backend/test/integration/auth_abuse_test.go new file mode 100644 index 00000000..77e6cefe --- /dev/null +++ b/backend/test/integration/auth_abuse_test.go @@ -0,0 +1,237 @@ +//go:build integration +// +build integration + +package integration + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/labstack/gommon/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "hopp-backend/internal/config" + "hopp-backend/internal/models" + "hopp-backend/internal/server" +) + +// setupTestServerWithRedis mirrors setupTestServerFast but wires a miniredis so +// the email-based throttles are active. +func setupTestServerWithRedis(t *testing.T) (*server.Server, func()) { + mr, err := miniredis.Run() + require.NoError(t, err) + + cfg := &config.Config{} + cfg.Server.Port = "8080" + cfg.Server.Host = "localhost" + cfg.Server.DeployDomain = "localhost:8080" + cfg.Database.DSN = "file::memory:?cache=shared" + cfg.Database.RedisURI = "redis://" + mr.Addr() + cfg.Auth.SessionSecret = "test-secret-key-for-testing-only" + cfg.Resend.DefaultSender = "test@example.com" + + srv := server.New(cfg) + srv.Echo.Logger.SetLevel(log.ERROR) + require.NoError(t, srv.Initialize()) + + cleanup := func() { + if srv.DB != nil { + if sqlDB, _ := srv.DB.DB(); sqlDB != nil { + sqlDB.Close() + } + } + mr.Close() + } + return srv, cleanup +} + +func postJSON(t *testing.T, srv *server.Server, path string, payload map[string]interface{}) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(payload) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Echo.ServeHTTP(rec, req) + return rec +} + +func TestManualSignUp_ShortPasswordRejected(t *testing.T) { + srv, cleanup := setupTestServerFast(t) + defer cleanup() + + rec := postJSON(t, srv, "/api/sign-up", map[string]interface{}{ + "first_name": "Short", + "last_name": "Pass", + "email": "short.pass@gmail.com", + "password": strings.Repeat("a", 11), // 11 chars + }) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + + var count int64 + srv.DB.Model(&models.User{}).Where("email = ?", "short.pass@gmail.com").Count(&count) + assert.Zero(t, count, "user should not be created with a too-short password") +} + +func TestManualSignUp_ExactlyTwelveAccepted(t *testing.T) { + srv, cleanup := setupTestServerFast(t) + defer cleanup() + + rec := postJSON(t, srv, "/api/sign-up", map[string]interface{}{ + "first_name": "Twelve", + "last_name": "Chars", + "email": "twelve.chars@gmail.com", + "password": strings.Repeat("a", 12), + }) + + assert.Equal(t, http.StatusCreated, rec.Code) +} + +func TestManualSignUp_OverBcryptCeilingRejected(t *testing.T) { + srv, cleanup := setupTestServerFast(t) + defer cleanup() + + rec := postJSON(t, srv, "/api/sign-up", map[string]interface{}{ + "first_name": "Too", + "last_name": "Long", + "email": "too.long@gmail.com", + "password": strings.Repeat("a", 73), + }) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestManualSignUp_DisabledTurnstileIgnoresSuppliedToken(t *testing.T) { + srv, cleanup := setupTestServerFast(t) + defer cleanup() + + // No Turnstile secret is configured in the test config, so a client-supplied + // token must be ignored rather than trigger verification. + rec := postJSON(t, srv, "/api/sign-up", map[string]interface{}{ + "first_name": "Ignored", + "last_name": "Token", + "email": "ignored.token@gmail.com", + "password": "averylongpassword", + "turnstile_token": "this-should-be-ignored-when-disabled", + }) + + assert.Equal(t, http.StatusCreated, rec.Code) +} + +func TestResetPassword_ShortPasswordRejected(t *testing.T) { + srv, cleanup := setupTestServerFast(t) + defer cleanup() + + user := createTestUser(t, srv.DB, "reset.short@gmail.com", "Reset", "Short", "averylongpassword", true) + resetToken := &models.ResetToken{UserID: user.ID} + require.NoError(t, resetToken.CreateResetToken(srv.DB)) + + rec := patchJSON(t, srv, "/api/reset-password/"+resetToken.Token, map[string]interface{}{ + "password": strings.Repeat("a", 11), + }) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestResetPassword_SuccessThenTokenReuseRejected(t *testing.T) { + srv, cleanup := setupTestServerFast(t) + defer cleanup() + + user := createTestUser(t, srv.DB, "reset.reuse@gmail.com", "Reset", "Reuse", "averylongpassword", true) + resetToken := &models.ResetToken{UserID: user.ID} + require.NoError(t, resetToken.CreateResetToken(srv.DB)) + + // First reset with a valid 12-character password succeeds. + rec := patchJSON(t, srv, "/api/reset-password/"+resetToken.Token, map[string]interface{}{ + "password": "brandnewpass12", + }) + assert.Equal(t, http.StatusOK, rec.Code) + + // Reusing the same (now consumed) token is rejected. + rec = patchJSON(t, srv, "/api/reset-password/"+resetToken.Token, map[string]interface{}{ + "password": "anotherpass123", + }) + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestSignIn_RateLimitedAfterFiveAttempts(t *testing.T) { + srv, cleanup := setupTestServerWithRedis(t) + defer cleanup() + + createTestUser(t, srv.DB, "throttle@gmail.com", "Throttle", "Me", "correcthorsebattery", true) + + // Five wrong-password attempts are allowed through to the credential check (401). + for i := 0; i < 5; i++ { + rec := postJSON(t, srv, "/api/sign-in", map[string]interface{}{ + "email": "throttle@gmail.com", + "password": "wrong-password", + }) + require.Equalf(t, http.StatusUnauthorized, rec.Code, "attempt %d expected 401", i+1) + } + + // The sixth is throttled before the credential check. + rec := postJSON(t, srv, "/api/sign-in", map[string]interface{}{ + "email": "throttle@gmail.com", + "password": "wrong-password", + }) + assert.Equal(t, http.StatusTooManyRequests, rec.Code) + assert.NotEmpty(t, rec.Header().Get("Retry-After")) +} + +func TestForgotPassword_ThrottleSendsOneEmailPerWindow(t *testing.T) { + srv, cleanup := setupTestServerWithRedis(t) + defer cleanup() + + user := createTestUser(t, srv.DB, "forgot.throttle@gmail.com", "Forgot", "Throttle", "averylongpassword", true) + + // Two requests within the window both return the generic 200 response. + for i := 0; i < 2; i++ { + rec := postJSON(t, srv, "/api/forgot-password", map[string]interface{}{ + "email": "forgot.throttle@gmail.com", + }) + require.Equal(t, http.StatusOK, rec.Code) + } + + // But the throttle short-circuits the second request before token creation, + // so only one reset token exists. + var count int64 + srv.DB.Model(&models.ResetToken{}).Where("user_id = ?", user.ID).Count(&count) + assert.Equal(t, int64(1), count, "throttle should limit to one reset token per window") +} + +func TestForgotPassword_AntiEnumerationWhileThrottled(t *testing.T) { + srv, cleanup := setupTestServerWithRedis(t) + defer cleanup() + + // A nonexistent address returns the same generic 200 both before and after + // the throttle would kick in — behavior doesn't diverge by account existence. + for i := 0; i < 3; i++ { + rec := postJSON(t, srv, "/api/forgot-password", map[string]interface{}{ + "email": "does-not-exist@gmail.com", + }) + require.Equalf(t, http.StatusOK, rec.Code, "request %d should be a generic 200", i+1) + + var body map[string]string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Contains(t, body["message"], "If the email you specified exists") + } +} + +// patchJSON is a small helper for PATCH requests with a JSON body. +func patchJSON(t *testing.T, srv *server.Server, path string, payload map[string]interface{}) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(payload) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPatch, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Echo.ServeHTTP(rec, req) + return rec +} diff --git a/tauri/src/openapi.d.ts b/tauri/src/openapi.d.ts index bfc12687..c0b9b5b9 100644 --- a/tauri/src/openapi.d.ts +++ b/tauri/src/openapi.d.ts @@ -184,7 +184,7 @@ export interface paths { email: string; /** * Format: password - * @description User's password + * @description User's password (12-72 characters) */ password: string; /** @description Name of the team (required unless team_invite_uuid is provided) */ @@ -194,6 +194,8 @@ export interface paths { * @description UUID for team invitation (if joining an existing team) */ team_invite_uuid?: string; + /** @description Cloudflare Turnstile token. Required when Turnstile is enabled server-side; ignored otherwise. */ + turnstile_token?: string; }; }; }; @@ -237,6 +239,89 @@ export interface paths { patch?: never; trace?: never; }; + "/api/sign-in": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Manual sign in endpoint */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * Format: email + * @description User's email address + */ + email: string; + /** + * Format: password + * @description User's password + */ + password: string; + /** @description Cloudflare Turnstile token. Required when Turnstile is enabled server-side; ignored otherwise. */ + turnstile_token?: string; + }; + }; + }; + responses: { + /** @description Successfully signed in */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description JWT authentication token */ + token?: string; + }; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Invalid email or password */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Too many sign-in attempts; retry after the window elapses */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/forgot-password": { parameters: { query?: never; @@ -265,6 +350,8 @@ export interface paths { * @description User's email address */ email: string; + /** @description Cloudflare Turnstile token. Required when Turnstile is enabled server-side; ignored otherwise. */ + turnstile_token?: string; }; }; }; @@ -330,7 +417,7 @@ export interface paths { "application/json": { /** * Format: password - * @description New password + * @description New password (12-72 characters; 72 is bcrypt's byte ceiling) */ password: string; }; diff --git a/web-app/package.json b/web-app/package.json index 9eb458b4..71fa2ed0 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -14,6 +14,7 @@ "@headlessui/react": "^2.2.9", "@livekit/components-react": "^2.9.17", "@livekit/components-styles": "^1.2.0", + "@marsidev/react-turnstile": "^1.6.1", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.8", diff --git a/web-app/src/components/Turnstile.tsx b/web-app/src/components/Turnstile.tsx new file mode 100644 index 00000000..2e20d1c9 --- /dev/null +++ b/web-app/src/components/Turnstile.tsx @@ -0,0 +1,51 @@ +import { forwardRef, useImperativeHandle, useRef } from "react"; +import { Turnstile as CfTurnstile, TurnstileInstance } from "@marsidev/react-turnstile"; +import { TURNSTILE_SITE_KEY } from "@/lib/turnstile"; + +export interface TurnstileHandle { + reset: () => void; +} + +interface TurnstileProps { + // action is verified server-side and must match the route (e.g. "signin"). + action: string; + // onToken receives the token on success, or "" when it expires/errors. + onToken: (token: string) => void; +} + +/** + * Thin wrapper around @marsidev/react-turnstile. It keeps callers decoupled from + * the library and gates on the build-time site key: when it's unset the widget + * renders nothing and the backend skips verification, so email/password auth + * still works on local/self-host builds. + * + * "interaction-only" keeps the widget hidden unless a human challenge is actually + * required. Tokens are single-use, so reset the widget after every failed + * submission via the ref handle. + */ +export const Turnstile = forwardRef(function Turnstile({ action, onToken }, ref) { + const widgetRef = useRef(null); + + useImperativeHandle(ref, () => ({ + reset: () => { + widgetRef.current?.reset(); + onToken(""); + }, + })); + + if (!TURNSTILE_SITE_KEY) { + return null; + } + + return ( + onToken("")} + onError={() => onToken("")} + className="flex justify-center" + /> + ); +}); diff --git a/web-app/src/lib/turnstile.ts b/web-app/src/lib/turnstile.ts new file mode 100644 index 00000000..cce9b9a3 --- /dev/null +++ b/web-app/src/lib/turnstile.ts @@ -0,0 +1,8 @@ +// The public Turnstile site key is baked in at build time via the standard Vite +// VITE_CLOUDFLARE_SITE_KEY variable. When it's unset the widget renders nothing +// and the backend skips verification, so email/password auth still works on +// local/self-host builds. +export const TURNSTILE_SITE_KEY = import.meta.env.VITE_CLOUDFLARE_SITE_KEY; + +// isTurnstileEnabled lets callers require a token before submitting. +export const isTurnstileEnabled = Boolean(TURNSTILE_SITE_KEY); diff --git a/web-app/src/openapi.d.ts b/web-app/src/openapi.d.ts index bfc12687..c0b9b5b9 100644 --- a/web-app/src/openapi.d.ts +++ b/web-app/src/openapi.d.ts @@ -184,7 +184,7 @@ export interface paths { email: string; /** * Format: password - * @description User's password + * @description User's password (12-72 characters) */ password: string; /** @description Name of the team (required unless team_invite_uuid is provided) */ @@ -194,6 +194,8 @@ export interface paths { * @description UUID for team invitation (if joining an existing team) */ team_invite_uuid?: string; + /** @description Cloudflare Turnstile token. Required when Turnstile is enabled server-side; ignored otherwise. */ + turnstile_token?: string; }; }; }; @@ -237,6 +239,89 @@ export interface paths { patch?: never; trace?: never; }; + "/api/sign-in": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Manual sign in endpoint */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * Format: email + * @description User's email address + */ + email: string; + /** + * Format: password + * @description User's password + */ + password: string; + /** @description Cloudflare Turnstile token. Required when Turnstile is enabled server-side; ignored otherwise. */ + turnstile_token?: string; + }; + }; + }; + responses: { + /** @description Successfully signed in */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description JWT authentication token */ + token?: string; + }; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Invalid email or password */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Too many sign-in attempts; retry after the window elapses */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/forgot-password": { parameters: { query?: never; @@ -265,6 +350,8 @@ export interface paths { * @description User's email address */ email: string; + /** @description Cloudflare Turnstile token. Required when Turnstile is enabled server-side; ignored otherwise. */ + turnstile_token?: string; }; }; }; @@ -330,7 +417,7 @@ export interface paths { "application/json": { /** * Format: password - * @description New password + * @description New password (12-72 characters; 72 is bcrypt's byte ceiling) */ password: string; }; diff --git a/web-app/src/pages/ForgotPassword.tsx b/web-app/src/pages/ForgotPassword.tsx index 9dcba5dc..d8b2897b 100644 --- a/web-app/src/pages/ForgotPassword.tsx +++ b/web-app/src/pages/ForgotPassword.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -6,21 +6,32 @@ import { toast } from "react-hot-toast"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import Logo from "@/assets/Hopp.png"; import { useAPI } from "@/hooks/useQueryClients"; +import { Turnstile, TurnstileHandle } from "@/components/Turnstile"; +import { isTurnstileEnabled } from "@/lib/turnstile"; export function ForgotPassword() { const { useMutation } = useAPI(); const [isFormSubmitted, setFormSubmitted] = useState(false); const [email, setEmail] = useState(""); const [message, setMessage] = useState(""); + const [turnstileToken, setTurnstileToken] = useState(""); + const turnstileRef = useRef(null); const forgotPasswordMutation = useMutation("post", "/api/forgot-password"); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + + if (isTurnstileEnabled && !turnstileToken) { + toast.error("Please complete the captcha before continuing."); + return; + } + try { const data = await forgotPasswordMutation.mutateAsync({ body: { email: email, + ...(turnstileToken && { turnstile_token: turnstileToken }), }, }); setMessage( @@ -28,6 +39,9 @@ export function ForgotPassword() { ); setFormSubmitted(true); } catch (error) { + // Tokens are single-use, so refresh the widget for the next attempt. + turnstileRef.current?.reset(); + setTurnstileToken(""); const errorMessage = error instanceof Error ? error.message : "Something went wrong, please try again."; toast.error(errorMessage); } @@ -52,7 +66,7 @@ export function ForgotPassword() { - : + :
@@ -65,6 +79,7 @@ export function ForgotPassword() { placeholder="e.g. dwight@dundermifflin.com" />
+ diff --git a/web-app/src/pages/Login.tsx b/web-app/src/pages/Login.tsx index 30c52214..ef5ca186 100644 --- a/web-app/src/pages/Login.tsx +++ b/web-app/src/pages/Login.tsx @@ -9,7 +9,7 @@ import Logo from "@/assets/Hopp.png"; import LoginScreen from "@/assets/LoginScreen.png"; import { BACKEND_URLS } from "@/constants"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useHoppStore } from "@/store/store"; import { toast } from "react-hot-toast"; @@ -17,6 +17,8 @@ import { useCookies } from "react-cookie"; import { CgSpinner } from "react-icons/cg"; import { useAPI } from "@/hooks/useQueryClients"; import { AnimatedTooltip } from "@/components/ui/animated-tooltip"; +import { Turnstile, TurnstileHandle } from "@/components/Turnstile"; +import { isTurnstileEnabled } from "@/lib/turnstile"; const people = [ { @@ -79,6 +81,16 @@ export function LoginForm({ className, isInvitation = false, ...props }: LoginFo teamInviteUUID: uuid || "", }); const [isLoading, setIsLoading] = useState(false); + const [turnstileToken, setTurnstileToken] = useState(""); + const turnstileRef = useRef(null); + // The verified action must match the endpoint the token is used on. + const turnstileAction = isSignUp ? "signup" : "signin"; + + // Toggling sign-up/sign-in re-renders the widget with a new action, so drop any + // token minted for the previous action. + useEffect(() => { + setTurnstileToken(""); + }, [turnstileAction]); const { data: invitationDetails, @@ -158,6 +170,12 @@ export function LoginForm({ className, isInvitation = false, ...props }: LoginFo const handleEmailAuth = async (e: React.FormEvent) => { e.preventDefault(); + + if (isTurnstileEnabled && !turnstileToken) { + toast.error("Please complete the captcha before continuing."); + return; + } + setIsLoading(true); try { @@ -170,6 +188,7 @@ export function LoginForm({ className, isInvitation = false, ...props }: LoginFo body: JSON.stringify({ email: formData.email, password: formData.password, + ...(turnstileToken && { turnstile_token: turnstileToken }), ...(isSignUp && { first_name: formData.firstName, last_name: formData.lastName, @@ -183,6 +202,9 @@ export function LoginForm({ className, isInvitation = false, ...props }: LoginFo const data = (await response.json()) as AuthResponse; if (!response.ok) { + // Tokens are single-use, so refresh the widget for the next attempt. + turnstileRef.current?.reset(); + setTurnstileToken(""); throw new Error(data.message || "Authentication failed"); } @@ -253,16 +275,16 @@ export function LoginForm({ className, isInvitation = false, ...props }: LoginFo {isInvitation && invitationDetails ? `Join ${invitationDetails.name} team on Hopp` - : isSignUp ? - "Create an account" - : "Welcome back"} + : isSignUp ? + "Create an account" + : "Welcome back"} {isInvitation && invitationDetails ? "Sign up to join your team" - : isSignUp ? - "Sign up for a new account" - : "Login with your email or social account"} + : isSignUp ? + "Sign up for a new account" + : "Login with your email or social account"} @@ -331,14 +353,22 @@ export function LoginForm({ className, isInvitation = false, ...props }: LoginFo value={formData.password} onChange={handleInputChange} required + {...(isSignUp && { minLength: 12, maxLength: 72 })} + aria-describedby={isSignUp ? "password-help" : undefined} /> + {isSignUp && ( +

+ Must be at least 12 characters. +

+ )} + {!isInvitation && ( @@ -354,7 +384,7 @@ export function LoginForm({ className, isInvitation = false, ...props }: LoginFo Sign in - : <> + : <> Don't have an account?{" "}