diff --git a/Dockerfile b/Dockerfile index beff27f..f672b4e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -268,7 +268,8 @@ window.__RUNTIME_CONFIG__ = { IS_CLOUD: '\${IS_CLOUD:-false}', GITHUB_CLIENT_ID: '\${GITHUB_CLIENT_ID:-}', GOOGLE_CLIENT_ID: '\${GOOGLE_CLIENT_ID:-}', - IS_EMAIL_CONFIGURED: '\$IS_EMAIL_CONFIGURED' + IS_EMAIL_CONFIGURED: '\$IS_EMAIL_CONFIGURED', + CLOUDFLARE_TURNSTILE_SITE_KEY: '\${CLOUDFLARE_TURNSTILE_SITE_KEY:-}' }; JSEOF diff --git a/README.md b/README.md index 8ce8616..eca8f94 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![MongoDB](https://img.shields.io/badge/MongoDB-47A248?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
[![Apache 2.0 License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) - [![Docker Pulls](https://img.shields.io/docker/pulls/rostislavdugin/postgresus?color=brightgreen)](https://hub.docker.com/r/rostislavdugin/postgresus) + [![Docker Pulls](https://img.shields.io/docker/pulls/databasus/databasus?color=brightgreen)](https://hub.docker.com/r/databasus/databasus) [![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/databasus/databasus) [![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/databasus/databasus) [![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/databasus/databasus) @@ -31,8 +31,6 @@ Databasus Dark Dashboard Databasus Dashboard - - --- diff --git a/backend/.env.development.example b/backend/.env.development.example index 5446a68..df9e424 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -11,6 +11,9 @@ VICTORIA_LOGS_PASSWORD=devpassword # tests TEST_LOCALHOST=localhost IS_SKIP_EXTERNAL_RESOURCES_TESTS=false +# cloudflare turnstile +CLOUDFLARE_TURNSTILE_SITE_KEY= +CLOUDFLARE_TURNSTILE_SECRET_KEY= # db DATABASE_DSN=host=dev-db user=postgres password=Q1234567 dbname=databasus port=5437 sslmode=disable DATABASE_URL=postgres://postgres:Q1234567@dev-db:5437/databasus?sslmode=disable diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f31165f..ab5bc03 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -104,6 +104,10 @@ type EnvVariables struct { GoogleClientID string `env:"GOOGLE_CLIENT_ID"` GoogleClientSecret string `env:"GOOGLE_CLIENT_SECRET"` + // Cloudflare Turnstile + CloudflareTurnstileSecretKey string `env:"CLOUDFLARE_TURNSTILE_SECRET_KEY"` + CloudflareTurnstileSiteKey string `env:"CLOUDFLARE_TURNSTILE_SITE_KEY"` + // testing Telegram TestTelegramBotToken string `env:"TEST_TELEGRAM_BOT_TOKEN"` TestTelegramChatID string `env:"TEST_TELEGRAM_CHAT_ID"` diff --git a/backend/internal/features/users/controllers/user_controller.go b/backend/internal/features/users/controllers/user_controller.go index 570f92d..df7d401 100644 --- a/backend/internal/features/users/controllers/user_controller.go +++ b/backend/internal/features/users/controllers/user_controller.go @@ -11,6 +11,7 @@ import ( user_middleware "databasus-backend/internal/features/users/middleware" users_services "databasus-backend/internal/features/users/services" cache_utils "databasus-backend/internal/util/cache" + cloudflare_turnstile "databasus-backend/internal/util/cloudflare_turnstile" "github.com/gin-gonic/gin" ) @@ -61,6 +62,28 @@ func (c *UserController) SignUp(ctx *gin.Context) { return } + // Verify Cloudflare Turnstile if enabled + turnstileService := cloudflare_turnstile.GetCloudflareTurnstileService() + if turnstileService.IsEnabled() { + if request.CloudflareTurnstileToken == nil || *request.CloudflareTurnstileToken == "" { + ctx.JSON( + http.StatusBadRequest, + gin.H{"error": "Cloudflare Turnstile verification required"}, + ) + return + } + + clientIP := ctx.ClientIP() + isValid, err := turnstileService.VerifyToken(*request.CloudflareTurnstileToken, clientIP) + if err != nil || !isValid { + ctx.JSON( + http.StatusBadRequest, + gin.H{"error": "Cloudflare Turnstile verification failed"}, + ) + return + } + } + err := c.userService.SignUp(&request) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -88,6 +111,28 @@ func (c *UserController) SignIn(ctx *gin.Context) { return } + // Verify Cloudflare Turnstile if enabled + turnstileService := cloudflare_turnstile.GetCloudflareTurnstileService() + if turnstileService.IsEnabled() { + if request.CloudflareTurnstileToken == nil || *request.CloudflareTurnstileToken == "" { + ctx.JSON( + http.StatusBadRequest, + gin.H{"error": "Cloudflare Turnstile verification required"}, + ) + return + } + + clientIP := ctx.ClientIP() + isValid, err := turnstileService.VerifyToken(*request.CloudflareTurnstileToken, clientIP) + if err != nil || !isValid { + ctx.JSON( + http.StatusBadRequest, + gin.H{"error": "Cloudflare Turnstile verification failed"}, + ) + return + } + } + allowed, _ := c.rateLimiter.CheckLimit(request.Email, "signin", 10, 1*time.Minute) if !allowed { ctx.JSON( @@ -363,6 +408,28 @@ func (c *UserController) SendResetPasswordCode(ctx *gin.Context) { return } + // Verify Cloudflare Turnstile if enabled + turnstileService := cloudflare_turnstile.GetCloudflareTurnstileService() + if turnstileService.IsEnabled() { + if request.CloudflareTurnstileToken == nil || *request.CloudflareTurnstileToken == "" { + ctx.JSON( + http.StatusBadRequest, + gin.H{"error": "Cloudflare Turnstile verification required"}, + ) + return + } + + clientIP := ctx.ClientIP() + isValid, err := turnstileService.VerifyToken(*request.CloudflareTurnstileToken, clientIP) + if err != nil || !isValid { + ctx.JSON( + http.StatusBadRequest, + gin.H{"error": "Cloudflare Turnstile verification failed"}, + ) + return + } + } + allowed, _ := c.rateLimiter.CheckLimit( request.Email, "reset-password", diff --git a/backend/internal/features/users/dto/dto.go b/backend/internal/features/users/dto/dto.go index 40b2a79..5e224f9 100644 --- a/backend/internal/features/users/dto/dto.go +++ b/backend/internal/features/users/dto/dto.go @@ -9,14 +9,16 @@ import ( ) type SignUpRequestDTO struct { - Email string `json:"email" binding:"required"` - Password string `json:"password" binding:"required,min=8"` - Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required,min=8"` + Name string `json:"name" binding:"required"` + CloudflareTurnstileToken *string `json:"cloudflareTurnstileToken"` } type SignInRequestDTO struct { - Email string `json:"email" binding:"required"` - Password string `json:"password" binding:"required"` + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` + CloudflareTurnstileToken *string `json:"cloudflareTurnstileToken"` } type SignInResponseDTO struct { @@ -94,7 +96,8 @@ type OAuthCallbackResponseDTO struct { } type SendResetPasswordCodeRequestDTO struct { - Email string `json:"email" binding:"required,email"` + Email string `json:"email" binding:"required,email"` + CloudflareTurnstileToken *string `json:"cloudflareTurnstileToken"` } type ResetPasswordRequestDTO struct { diff --git a/backend/internal/features/users/services/user_services.go b/backend/internal/features/users/services/user_services.go index 8988dac..3bfb689 100644 --- a/backend/internal/features/users/services/user_services.go +++ b/backend/internal/features/users/services/user_services.go @@ -463,6 +463,178 @@ func (s *UserService) HandleGitHubOAuth( ) } +func (s *UserService) HandleGoogleOAuth( + code, redirectUri string, +) (*users_dto.OAuthCallbackResponseDTO, error) { + return s.handleGoogleOAuthWithEndpoint( + code, + redirectUri, + google.Endpoint, + "https://www.googleapis.com/oauth2/v2/userinfo", + ) +} + +func (s *UserService) SendResetPasswordCode(email string) error { + user, err := s.userRepository.GetUserByEmail(email) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + // Silently succeed for non-existent users to prevent enumeration attacks + if user == nil { + return nil + } + + // Only active users can reset passwords + if user.Status != users_enums.UserStatusActive { + return errors.New("only active users can reset their password") + } + + // Check rate limiting - max 3 codes per hour + oneHourAgo := time.Now().UTC().Add(-1 * time.Hour) + recentCount, err := s.passwordResetRepository.CountRecentCodesByUserID(user.ID, oneHourAgo) + if err != nil { + return fmt.Errorf("failed to check rate limit: %w", err) + } + + if recentCount >= 3 { + return errors.New("too many password reset attempts, please try again later") + } + + // Generate 6-digit random code using crypto/rand for better randomness + codeNum := make([]byte, 4) + _, err = io.ReadFull(rand.Reader, codeNum) + if err != nil { + return fmt.Errorf("failed to generate random code: %w", err) + } + + // Convert bytes to uint32 and modulo to get 6 digits + randomInt := uint32( + codeNum[0], + )<<24 | uint32( + codeNum[1], + )<<16 | uint32( + codeNum[2], + )<<8 | uint32( + codeNum[3], + ) + code := fmt.Sprintf("%06d", randomInt%1000000) + + // Hash the code + hashedCode, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash code: %w", err) + } + + // Store in database with 1 hour expiration + resetCode := &users_models.PasswordResetCode{ + ID: uuid.New(), + UserID: user.ID, + HashedCode: string(hashedCode), + ExpiresAt: time.Now().UTC().Add(1 * time.Hour), + IsUsed: false, + CreatedAt: time.Now().UTC(), + } + + if err := s.passwordResetRepository.CreateResetCode(resetCode); err != nil { + return fmt.Errorf("failed to create reset code: %w", err) + } + + // Send email with code + if s.emailSender != nil { + subject := "Password Reset Code" + body := fmt.Sprintf(` + + + + + + + +
+

Password Reset Request

+

+ You have requested to reset your password. Please use the following code to complete the password reset process: +

+
+

%s

+
+

+ This code will expire in 1 hour. +

+

+ If you did not request a password reset, please ignore this email. Your password will remain unchanged. +

+
+

+ This is an automated message. Please do not reply to this email. +

+
+ + +`, code) + + if err := s.emailSender.SendEmail(user.Email, subject, body); err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + } + + // Audit log + if s.auditLogWriter != nil { + s.auditLogWriter.WriteAuditLog( + fmt.Sprintf("Password reset code sent to: %s", user.Email), + &user.ID, + nil, + ) + } + + return nil +} + +func (s *UserService) ResetPassword(email, code, newPassword string) error { + user, err := s.userRepository.GetUserByEmail(email) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + if user == nil { + return errors.New("user with this email does not exist") + } + + // Get valid reset code for user + resetCode, err := s.passwordResetRepository.GetValidCodeByUserID(user.ID) + if err != nil { + return errors.New("invalid or expired reset code") + } + + // Verify code matches + err = bcrypt.CompareHashAndPassword([]byte(resetCode.HashedCode), []byte(code)) + if err != nil { + return errors.New("invalid reset code") + } + + // Mark code as used + if err := s.passwordResetRepository.MarkCodeAsUsed(resetCode.ID); err != nil { + return fmt.Errorf("failed to mark code as used: %w", err) + } + + // Update user password + if err := s.ChangeUserPassword(user.ID, newPassword); err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + // Audit log + if s.auditLogWriter != nil { + s.auditLogWriter.WriteAuditLog( + "Password reset via email code", + &user.ID, + nil, + ) + } + + return nil +} + func (s *UserService) handleGitHubOAuthWithEndpoint( code, redirectUri string, endpoint oauth2.Endpoint, @@ -529,17 +701,6 @@ func (s *UserService) handleGitHubOAuthWithEndpoint( return s.getOrCreateUserFromOAuth(oauthID, email, name, "github") } -func (s *UserService) HandleGoogleOAuth( - code, redirectUri string, -) (*users_dto.OAuthCallbackResponseDTO, error) { - return s.handleGoogleOAuthWithEndpoint( - code, - redirectUri, - google.Endpoint, - "https://www.googleapis.com/oauth2/v2/userinfo", - ) -} - func (s *UserService) handleGoogleOAuthWithEndpoint( code, redirectUri string, endpoint oauth2.Endpoint, @@ -805,164 +966,3 @@ func (s *UserService) fetchGitHubPrimaryEmail( return "", errors.New("github account has no accessible email") } - -func (s *UserService) SendResetPasswordCode(email string) error { - user, err := s.userRepository.GetUserByEmail(email) - if err != nil { - return fmt.Errorf("failed to get user: %w", err) - } - - // Silently succeed for non-existent users to prevent enumeration attacks - if user == nil { - return nil - } - - // Only active users can reset passwords - if user.Status != users_enums.UserStatusActive { - return errors.New("only active users can reset their password") - } - - // Check rate limiting - max 3 codes per hour - oneHourAgo := time.Now().UTC().Add(-1 * time.Hour) - recentCount, err := s.passwordResetRepository.CountRecentCodesByUserID(user.ID, oneHourAgo) - if err != nil { - return fmt.Errorf("failed to check rate limit: %w", err) - } - - if recentCount >= 3 { - return errors.New("too many password reset attempts, please try again later") - } - - // Generate 6-digit random code using crypto/rand for better randomness - codeNum := make([]byte, 4) - _, err = io.ReadFull(rand.Reader, codeNum) - if err != nil { - return fmt.Errorf("failed to generate random code: %w", err) - } - - // Convert bytes to uint32 and modulo to get 6 digits - randomInt := uint32( - codeNum[0], - )<<24 | uint32( - codeNum[1], - )<<16 | uint32( - codeNum[2], - )<<8 | uint32( - codeNum[3], - ) - code := fmt.Sprintf("%06d", randomInt%1000000) - - // Hash the code - hashedCode, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("failed to hash code: %w", err) - } - - // Store in database with 1 hour expiration - resetCode := &users_models.PasswordResetCode{ - ID: uuid.New(), - UserID: user.ID, - HashedCode: string(hashedCode), - ExpiresAt: time.Now().UTC().Add(1 * time.Hour), - IsUsed: false, - CreatedAt: time.Now().UTC(), - } - - if err := s.passwordResetRepository.CreateResetCode(resetCode); err != nil { - return fmt.Errorf("failed to create reset code: %w", err) - } - - // Send email with code - if s.emailSender != nil { - subject := "Password Reset Code" - body := fmt.Sprintf(` - - - - - - - -
-

Password Reset Request

-

- You have requested to reset your password. Please use the following code to complete the password reset process: -

-
-

%s

-
-

- This code will expire in 1 hour. -

-

- If you did not request a password reset, please ignore this email. Your password will remain unchanged. -

-
-

- This is an automated message. Please do not reply to this email. -

-
- - -`, code) - - if err := s.emailSender.SendEmail(user.Email, subject, body); err != nil { - return fmt.Errorf("failed to send email: %w", err) - } - } - - // Audit log - if s.auditLogWriter != nil { - s.auditLogWriter.WriteAuditLog( - fmt.Sprintf("Password reset code sent to: %s", user.Email), - &user.ID, - nil, - ) - } - - return nil -} - -func (s *UserService) ResetPassword(email, code, newPassword string) error { - user, err := s.userRepository.GetUserByEmail(email) - if err != nil { - return fmt.Errorf("failed to get user: %w", err) - } - - if user == nil { - return errors.New("user with this email does not exist") - } - - // Get valid reset code for user - resetCode, err := s.passwordResetRepository.GetValidCodeByUserID(user.ID) - if err != nil { - return errors.New("invalid or expired reset code") - } - - // Verify code matches - err = bcrypt.CompareHashAndPassword([]byte(resetCode.HashedCode), []byte(code)) - if err != nil { - return errors.New("invalid reset code") - } - - // Mark code as used - if err := s.passwordResetRepository.MarkCodeAsUsed(resetCode.ID); err != nil { - return fmt.Errorf("failed to mark code as used: %w", err) - } - - // Update user password - if err := s.ChangeUserPassword(user.ID, newPassword); err != nil { - return fmt.Errorf("failed to update password: %w", err) - } - - // Audit log - if s.auditLogWriter != nil { - s.auditLogWriter.WriteAuditLog( - "Password reset via email code", - &user.ID, - nil, - ) - } - - return nil -} diff --git a/backend/internal/util/cloudflare_turnstile/cloudflare_turnstile_service.go b/backend/internal/util/cloudflare_turnstile/cloudflare_turnstile_service.go new file mode 100644 index 0000000..b95a474 --- /dev/null +++ b/backend/internal/util/cloudflare_turnstile/cloudflare_turnstile_service.go @@ -0,0 +1,71 @@ +package cloudflare_turnstile + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +type CloudflareTurnstileService struct { + secretKey string + siteKey string +} + +type cloudflareTurnstileResponse struct { + Success bool `json:"success"` + ChallengeTS time.Time `json:"challenge_ts"` + Hostname string `json:"hostname"` + ErrorCodes []string `json:"error-codes"` +} + +const cloudflareTurnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + +func (s *CloudflareTurnstileService) IsEnabled() bool { + return s.secretKey != "" +} + +func (s *CloudflareTurnstileService) VerifyToken(token, remoteIP string) (bool, error) { + if !s.IsEnabled() { + return true, nil + } + + if token == "" { + return false, errors.New("cloudflare Turnstile token is required") + } + + formData := url.Values{} + formData.Set("secret", s.secretKey) + formData.Set("response", token) + formData.Set("remoteip", remoteIP) + + resp, err := http.PostForm(cloudflareTurnstileVerifyURL, formData) + if err != nil { + return false, fmt.Errorf("failed to verify Cloudflare Turnstile: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, fmt.Errorf("failed to read Cloudflare Turnstile response: %w", err) + } + + var turnstileResp cloudflareTurnstileResponse + if err := json.Unmarshal(body, &turnstileResp); err != nil { + return false, fmt.Errorf("failed to parse Cloudflare Turnstile response: %w", err) + } + + if !turnstileResp.Success { + return false, fmt.Errorf( + "cloudflare Turnstile verification failed: %v", + turnstileResp.ErrorCodes, + ) + } + + return true, nil +} diff --git a/backend/internal/util/cloudflare_turnstile/di.go b/backend/internal/util/cloudflare_turnstile/di.go new file mode 100644 index 0000000..fa968c1 --- /dev/null +++ b/backend/internal/util/cloudflare_turnstile/di.go @@ -0,0 +1,14 @@ +package cloudflare_turnstile + +import ( + "databasus-backend/internal/config" +) + +var cloudflareTurnstileService = &CloudflareTurnstileService{ + config.GetEnv().CloudflareTurnstileSecretKey, + config.GetEnv().CloudflareTurnstileSiteKey, +} + +func GetCloudflareTurnstileService() *CloudflareTurnstileService { + return cloudflareTurnstileService +} diff --git a/frontend/.env.development.example b/frontend/.env.development.example index 5644f3f..7c84987 100644 --- a/frontend/.env.development.example +++ b/frontend/.env.development.example @@ -2,4 +2,5 @@ MODE=development VITE_GITHUB_CLIENT_ID= VITE_GOOGLE_CLIENT_ID= VITE_IS_EMAIL_CONFIGURED=false -VITE_IS_CLOUD=false \ No newline at end of file +VITE_IS_CLOUD=false +VITE_CLOUDFLARE_TURNSTILE_SITE_KEY= \ No newline at end of file diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index d073b7b..a21b16b 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -3,6 +3,7 @@ interface RuntimeConfig { GITHUB_CLIENT_ID?: string; GOOGLE_CLIENT_ID?: string; IS_EMAIL_CONFIGURED?: string; + CLOUDFLARE_TURNSTILE_SITE_KEY?: string; } declare global { @@ -39,6 +40,11 @@ export const IS_EMAIL_CONFIGURED = window.__RUNTIME_CONFIG__?.IS_EMAIL_CONFIGURED === 'true' || import.meta.env.VITE_IS_EMAIL_CONFIGURED === 'true'; +export const CLOUDFLARE_TURNSTILE_SITE_KEY = + window.__RUNTIME_CONFIG__?.CLOUDFLARE_TURNSTILE_SITE_KEY || + import.meta.env.VITE_CLOUDFLARE_TURNSTILE_SITE_KEY || + ''; + export function getOAuthRedirectUri(): string { return `${window.location.origin}/auth/callback`; } diff --git a/frontend/src/entity/users/model/SendResetPasswordCodeRequest.ts b/frontend/src/entity/users/model/SendResetPasswordCodeRequest.ts index cfe649b..a7bffe2 100644 --- a/frontend/src/entity/users/model/SendResetPasswordCodeRequest.ts +++ b/frontend/src/entity/users/model/SendResetPasswordCodeRequest.ts @@ -1,3 +1,4 @@ export interface SendResetPasswordCodeRequest { email: string; + cloudflareTurnstileToken?: string; } diff --git a/frontend/src/entity/users/model/SignInRequest.ts b/frontend/src/entity/users/model/SignInRequest.ts index 7f0ecfb..c4c6271 100644 --- a/frontend/src/entity/users/model/SignInRequest.ts +++ b/frontend/src/entity/users/model/SignInRequest.ts @@ -1,4 +1,5 @@ export interface SignInRequest { email: string; password: string; + cloudflareTurnstileToken?: string; } diff --git a/frontend/src/entity/users/model/SignUpRequest.ts b/frontend/src/entity/users/model/SignUpRequest.ts index e21aeb0..b7db53e 100644 --- a/frontend/src/entity/users/model/SignUpRequest.ts +++ b/frontend/src/entity/users/model/SignUpRequest.ts @@ -2,4 +2,5 @@ export interface SignUpRequest { email: string; password: string; name: string; + cloudflareTurnstileToken?: string; } diff --git a/frontend/src/features/users/ui/RequestResetPasswordComponent.tsx b/frontend/src/features/users/ui/RequestResetPasswordComponent.tsx index f0a434c..2e7cc77 100644 --- a/frontend/src/features/users/ui/RequestResetPasswordComponent.tsx +++ b/frontend/src/features/users/ui/RequestResetPasswordComponent.tsx @@ -1,9 +1,12 @@ import { Button, Input } from 'antd'; import { type JSX, useState } from 'react'; +import { useCloudflareTurnstile } from '../../../shared/hooks/useCloudflareTurnstile'; + import { userApi } from '../../../entity/users'; import { StringUtils } from '../../../shared/lib'; import { FormValidator } from '../../../shared/lib/FormValidator'; +import { CloudflareTurnstileWidget } from '../../../shared/ui/CloudflareTurnstileWidget'; interface RequestResetPasswordComponentProps { onSwitchToSignIn?: () => void; @@ -20,6 +23,8 @@ export function RequestResetPasswordComponent({ const [error, setError] = useState(''); const [successMessage, setSuccessMessage] = useState(''); + const { token, containerRef, resetCloudflareTurnstile } = useCloudflareTurnstile(); + const validateEmail = (): boolean => { if (!email) { setEmailError(true); @@ -42,7 +47,10 @@ export function RequestResetPasswordComponent({ setLoading(true); try { - const response = await userApi.sendResetPasswordCode({ email }); + const response = await userApi.sendResetPasswordCode({ + email, + cloudflareTurnstileToken: token, + }); setSuccessMessage(response.message); // After successful code send, switch to reset password form @@ -53,6 +61,7 @@ export function RequestResetPasswordComponent({ }, 2000); } catch (e) { setError(StringUtils.capitalizeFirstLetter((e as Error).message)); + resetCloudflareTurnstile(); } setLoading(false); @@ -84,6 +93,8 @@ export function RequestResetPasswordComponent({
+ +