mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d45728f73 | ||
|
|
c70ad82c95 |
@@ -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
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
[](https://www.mongodb.com/)
|
||||
<br />
|
||||
[](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 @@
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
14
backend/internal/util/cloudflare_turnstile/di.go
Normal file
14
backend/internal/util/cloudflare_turnstile/di.go
Normal 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
|
||||
}
|
||||
@@ -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=
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export interface SendResetPasswordCodeRequest {
|
||||
email: string;
|
||||
cloudflareTurnstileToken?: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface SignInRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
cloudflareTurnstileToken?: string;
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ export interface SignUpRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
cloudflareTurnstileToken?: string;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
116
frontend/src/shared/hooks/useCloudflareTurnstile.ts
Normal file
116
frontend/src/shared/hooks/useCloudflareTurnstile.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
17
frontend/src/shared/ui/CloudflareTurnstileWidget.tsx
Normal file
17
frontend/src/shared/ui/CloudflareTurnstileWidget.tsx
Normal 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" />;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { CloudflareTurnstileWidget } from './CloudflareTurnstileWidget';
|
||||
export { ConfirmationComponent } from './ConfirmationComponent';
|
||||
export { StarButtonComponent } from './StarButtonComponent';
|
||||
export { ThemeToggleComponent } from './ThemeToggleComponent';
|
||||
|
||||
Reference in New Issue
Block a user