Compare commits

...

2 Commits

Author SHA1 Message Date
Rostislav Dugin
8d45728f73 Merge pull request #362 from databasus/develop
FEATURE (auth): Add optional CloudFlare Turnstile for sign in \ sign …
2026-02-14 23:19:12 +03:00
Rostislav Dugin
c70ad82c95 FEATURE (auth): Add optional CloudFlare Turnstile for sign in \ sign up \ password reset 2026-02-14 23:11:36 +03:00
21 changed files with 524 additions and 191 deletions

View File

@@ -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

View File

@@ -11,7 +11,7 @@
[![MongoDB](https://img.shields.io/badge/MongoDB-47A248?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
<br />
[![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 @@
<img src="assets/dashboard-dark.svg" alt="Databasus Dark Dashboard" width="800" style="margin-bottom: 10px;"/>
<img src="assets/dashboard.svg" alt="Databasus Dashboard" width="800"/>
</div>
---

View File

@@ -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

View File

@@ -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"`

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; padding: 20px;">
<h2 style="color: #333333; margin-bottom: 20px;">Password Reset Request</h2>
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
You have requested to reset your password. Please use the following code to complete the password reset process:
</p>
<div style="background-color: #f8f9fa; border: 2px solid #e9ecef; border-radius: 8px; padding: 20px; text-align: center; margin: 30px 0;">
<h1 style="color: #2c3e50; font-size: 36px; margin: 0; letter-spacing: 8px; font-family: monospace;">%s</h1>
</div>
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
This code will expire in <strong>1 hour</strong>.
</p>
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
If you did not request a password reset, please ignore this email. Your password will remain unchanged.
</p>
<hr style="border: none; border-top: 1px solid #e9ecef; margin: 30px 0;">
<p style="color: #999999; font-size: 12px; line-height: 1.6;">
This is an automated message. Please do not reply to this email.
</p>
</div>
</body>
</html>
`, 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(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; padding: 20px;">
<h2 style="color: #333333; margin-bottom: 20px;">Password Reset Request</h2>
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
You have requested to reset your password. Please use the following code to complete the password reset process:
</p>
<div style="background-color: #f8f9fa; border: 2px solid #e9ecef; border-radius: 8px; padding: 20px; text-align: center; margin: 30px 0;">
<h1 style="color: #2c3e50; font-size: 36px; margin: 0; letter-spacing: 8px; font-family: monospace;">%s</h1>
</div>
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
This code will expire in <strong>1 hour</strong>.
</p>
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
If you did not request a password reset, please ignore this email. Your password will remain unchanged.
</p>
<hr style="border: none; border-top: 1px solid #e9ecef; margin: 30px 0;">
<p style="color: #999999; font-size: 12px; line-height: 1.6;">
This is an automated message. Please do not reply to this email.
</p>
</div>
</body>
</html>
`, 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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -2,4 +2,5 @@ MODE=development
VITE_GITHUB_CLIENT_ID=
VITE_GOOGLE_CLIENT_ID=
VITE_IS_EMAIL_CONFIGURED=false
VITE_IS_CLOUD=false
VITE_IS_CLOUD=false
VITE_CLOUDFLARE_TURNSTILE_SITE_KEY=

View File

@@ -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`;
}

View File

@@ -1,3 +1,4 @@
export interface SendResetPasswordCodeRequest {
email: string;
cloudflareTurnstileToken?: string;
}

View File

@@ -1,4 +1,5 @@
export interface SignInRequest {
email: string;
password: string;
cloudflareTurnstileToken?: string;
}

View File

@@ -2,4 +2,5 @@ export interface SignUpRequest {
email: string;
password: string;
name: string;
cloudflareTurnstileToken?: string;
}

View File

@@ -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({
<div className="mt-3" />
<CloudflareTurnstileWidget containerRef={containerRef} />
<Button
disabled={isLoading}
loading={isLoading}

View File

@@ -2,10 +2,13 @@ import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons';
import { Button, Input } from 'antd';
import { type JSX, useState } from 'react';
import { useCloudflareTurnstile } from '../../../shared/hooks/useCloudflareTurnstile';
import { GITHUB_CLIENT_ID, GOOGLE_CLIENT_ID, IS_EMAIL_CONFIGURED } from '../../../constants';
import { userApi } from '../../../entity/users';
import { StringUtils } from '../../../shared/lib';
import { FormValidator } from '../../../shared/lib/FormValidator';
import { CloudflareTurnstileWidget } from '../../../shared/ui/CloudflareTurnstileWidget';
import { GithubOAuthComponent } from './oauth/GithubOAuthComponent';
import { GoogleOAuthComponent } from './oauth/GoogleOAuthComponent';
@@ -29,6 +32,8 @@ export function SignInComponent({
const [signInError, setSignInError] = useState('');
const { token, containerRef, resetCloudflareTurnstile } = useCloudflareTurnstile();
const validateFieldsForSignIn = (): boolean => {
if (!email) {
setEmailError(true);
@@ -59,9 +64,11 @@ export function SignInComponent({
await userApi.signIn({
email,
password,
cloudflareTurnstileToken: token,
});
} catch (e) {
setSignInError(StringUtils.capitalizeFirstLetter((e as Error).message));
resetCloudflareTurnstile();
}
setLoading(false);
@@ -119,6 +126,8 @@ export function SignInComponent({
<div className="mt-3" />
<CloudflareTurnstileWidget containerRef={containerRef} />
<Button
disabled={isLoading}
loading={isLoading}

View File

@@ -2,10 +2,13 @@ import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons';
import { App, Button, Input } from 'antd';
import { type JSX, useState } from 'react';
import { useCloudflareTurnstile } from '../../../shared/hooks/useCloudflareTurnstile';
import { GITHUB_CLIENT_ID, GOOGLE_CLIENT_ID } from '../../../constants';
import { userApi } from '../../../entity/users';
import { StringUtils } from '../../../shared/lib';
import { FormValidator } from '../../../shared/lib/FormValidator';
import { CloudflareTurnstileWidget } from '../../../shared/ui/CloudflareTurnstileWidget';
import { GithubOAuthComponent } from './oauth/GithubOAuthComponent';
import { GoogleOAuthComponent } from './oauth/GoogleOAuthComponent';
@@ -31,6 +34,8 @@ export function SignUpComponent({ onSwitchToSignIn }: SignUpComponentProps): JSX
const [signUpError, setSignUpError] = useState('');
const { token, containerRef, resetCloudflareTurnstile } = useCloudflareTurnstile();
const validateFieldsForSignUp = (): boolean => {
if (!name || name.trim() === '') {
setNameError(true);
@@ -85,10 +90,16 @@ export function SignUpComponent({ onSwitchToSignIn }: SignUpComponentProps): JSX
email,
password,
name,
cloudflareTurnstileToken: token,
});
await userApi.signIn({
email,
password,
cloudflareTurnstileToken: token,
});
await userApi.signIn({ email, password });
} catch (e) {
setSignUpError(StringUtils.capitalizeFirstLetter((e as Error).message));
resetCloudflareTurnstile();
}
}
@@ -173,6 +184,8 @@ export function SignUpComponent({ onSwitchToSignIn }: SignUpComponentProps): JSX
<div className="mt-3" />
<CloudflareTurnstileWidget containerRef={containerRef} />
<Button
disabled={isLoading}
loading={isLoading}

View File

@@ -1,7 +1,6 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import utc from 'dayjs/plugin/utc';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
@@ -11,8 +10,4 @@ import App from './App.tsx';
dayjs.extend(utc);
dayjs.extend(relativeTime);
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
createRoot(document.getElementById('root')!).render(<App />);

View File

@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react';
import { CLOUDFLARE_TURNSTILE_SITE_KEY } from '../../constants';
declare global {
interface Window {
turnstile?: {
render: (
container: string | HTMLElement,
options: {
sitekey: string;
callback: (token: string) => void;
'error-callback'?: () => void;
'expired-callback'?: () => void;
theme?: 'light' | 'dark' | 'auto';
size?: 'normal' | 'compact' | 'flexible';
appearance?: 'always' | 'execute' | 'interaction-only';
},
) => string;
reset: (widgetId: string) => void;
remove: (widgetId: string) => void;
getResponse: (widgetId: string) => string | undefined;
};
}
}
interface UseCloudflareTurnstileReturn {
containerRef: React.RefObject<HTMLDivElement | null>;
token: string | undefined;
resetCloudflareTurnstile: () => void;
}
const loadCloudflareTurnstileScript = (): Promise<void> => {
if (!CLOUDFLARE_TURNSTILE_SITE_KEY) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
if (document.querySelector('script[src*="turnstile"]')) {
resolve();
return;
}
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Cloudflare Turnstile'));
document.head.appendChild(script);
});
};
export function useCloudflareTurnstile(): UseCloudflareTurnstileReturn {
const [token, setToken] = useState<string | undefined>(undefined);
const containerRef = useRef<HTMLDivElement | null>(null);
const widgetIdRef = useRef<string | null>(null);
useEffect(() => {
if (!CLOUDFLARE_TURNSTILE_SITE_KEY || !containerRef.current) {
return;
}
loadCloudflareTurnstileScript()
.then(() => {
if (!window.turnstile || !containerRef.current) {
return;
}
try {
const widgetId = window.turnstile.render(containerRef.current, {
sitekey: CLOUDFLARE_TURNSTILE_SITE_KEY,
callback: (receivedToken: string) => {
setToken(receivedToken);
},
'error-callback': () => {
setToken(undefined);
},
'expired-callback': () => {
setToken(undefined);
},
theme: 'auto',
size: 'normal',
appearance: 'execute',
});
widgetIdRef.current = widgetId;
} catch (error) {
console.error('Failed to render Cloudflare Turnstile widget:', error);
}
})
.catch((error) => {
console.error('Failed to load Cloudflare Turnstile:', error);
});
return () => {
if (widgetIdRef.current && window.turnstile) {
window.turnstile.remove(widgetIdRef.current);
widgetIdRef.current = null;
}
};
}, []);
const resetCloudflareTurnstile = () => {
if (widgetIdRef.current && window.turnstile) {
window.turnstile.reset(widgetIdRef.current);
setToken(undefined);
}
};
return {
containerRef,
token,
resetCloudflareTurnstile,
};
}

View File

@@ -0,0 +1,17 @@
import { type JSX } from 'react';
import { CLOUDFLARE_TURNSTILE_SITE_KEY } from '../../constants';
interface CloudflareTurnstileWidgetProps {
containerRef: React.RefObject<HTMLDivElement | null>;
}
export function CloudflareTurnstileWidget({
containerRef,
}: CloudflareTurnstileWidgetProps): JSX.Element | null {
if (!CLOUDFLARE_TURNSTILE_SITE_KEY) {
return null;
}
return <div ref={containerRef} className="mb-3" />;
}

View File

@@ -1,3 +1,4 @@
export { CloudflareTurnstileWidget } from './CloudflareTurnstileWidget';
export { ConfirmationComponent } from './ConfirmationComponent';
export { StarButtonComponent } from './StarButtonComponent';
export { ThemeToggleComponent } from './ThemeToggleComponent';