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 @@
[](https://www.mongodb.com/)
[](LICENSE)
- [](https://hub.docker.com/r/rostislavdugin/postgresus)
+ [](https://hub.docker.com/r/databasus/databasus)
[](https://github.com/databasus/databasus)
[](https://github.com/databasus/databasus)
[](https://github.com/databasus/databasus)
@@ -31,8 +31,6 @@
-
-
---
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({
+
+