feat: implement Cloudflare Turnstile, terms acceptance, and email preferences
- Add Cloudflare Turnstile verification to registration flow - Require terms of service and privacy policy acceptance - Add email newsletter and contact preference options - Update User model with email preference fields - Create database migration for email preferences - Add Turnstile service with Cloudflare API integration - Update registration request structure with new required fields - Add Turnstile secret key configuration - Include development bypass for testing Registration now requires: - Turnstile token verification - Terms of service acceptance - Privacy policy acceptance - Optional email newsletter/contact preferences
This commit is contained in:
parent
997d6437be
commit
4eebd27e69
|
|
@ -68,6 +68,9 @@ SOCIAL_RECOVERY_THRESHOLD=3
|
|||
SOCIAL_RECOVERY_SHARES=5
|
||||
SOCIAL_RECOVERY_DELAY_HOURS=24
|
||||
|
||||
# Cloudflare Turnstile Configuration
|
||||
TURNSTILE_SECRET_KEY=your_turnstile_secret_key_here
|
||||
|
||||
# AI Moderation System
|
||||
MODERATION_ENABLED=true
|
||||
OPENAI_API_KEY=sk-your-openai-api-key-here
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ type Config struct {
|
|||
R2SecretKey string
|
||||
R2MediaBucket string
|
||||
R2VideoBucket string
|
||||
TurnstileSecretKey string
|
||||
}
|
||||
|
||||
func LoadConfig() *Config {
|
||||
|
|
@ -76,6 +77,7 @@ func LoadConfig() *Config {
|
|||
R2SecretKey: getEnv("R2_SECRET_KEY", ""),
|
||||
R2MediaBucket: getEnv("R2_MEDIA_BUCKET", "sojorn-media"),
|
||||
R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"),
|
||||
TurnstileSecretKey: getEnv("TURNSTILE_SECRET_KEY", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
-- Add email preference columns to users table
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_newsletter BOOLEAN DEFAULT false;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_contact BOOLEAN DEFAULT false;
|
||||
|
||||
-- Add indexes for performance if needed
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email_newsletter ON users(email_newsletter);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email_contact ON users(email_contact);
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN users.email_newsletter IS 'User has opted in to receive newsletter emails';
|
||||
COMMENT ON COLUMN users.email_contact IS 'User has opted in to receive contact/transactional emails';
|
||||
|
|
@ -33,10 +33,15 @@ func NewAuthHandler(repo *repository.UserRepository, cfg *config.Config, emailSe
|
|||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Handle string `json:"handle" binding:"required,min=3"`
|
||||
DisplayName string `json:"display_name" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Handle string `json:"handle" binding:"required,min=3"`
|
||||
DisplayName string `json:"display_name" binding:"required"`
|
||||
TurnstileToken string `json:"turnstile_token" binding:"required"`
|
||||
AcceptTerms bool `json:"accept_terms" binding:"required,eq=true"`
|
||||
AcceptPrivacy bool `json:"accept_privacy" binding:"required,eq=true"`
|
||||
EmailNewsletter bool `json:"email_newsletter"`
|
||||
EmailContact bool `json:"email_contact"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
|
|
@ -52,6 +57,23 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
|||
}
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
|
||||
// Validate Turnstile token
|
||||
turnstileService := services.NewTurnstileService(h.config.TurnstileSecretKey)
|
||||
remoteIP := c.ClientIP()
|
||||
turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, remoteIP)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Turnstile verification failed: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
|
||||
return
|
||||
}
|
||||
|
||||
if !turnstileResp.Success {
|
||||
errorMsg := turnstileService.GetErrorMessage(turnstileResp.ErrorCodes)
|
||||
log.Printf("[Auth] Turnstile validation failed: %s", errorMsg)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
|
||||
return
|
||||
}
|
||||
|
||||
existingUser, err := h.repo.GetUserByEmail(c.Request.Context(), req.Email)
|
||||
if err == nil && existingUser != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
||||
|
|
@ -72,13 +94,15 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
|||
|
||||
userID := uuid.New()
|
||||
user := &models.User{
|
||||
ID: userID,
|
||||
Email: req.Email,
|
||||
PasswordHash: string(hashedBytes),
|
||||
Status: models.UserStatusPending,
|
||||
MFAEnabled: false,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
ID: userID,
|
||||
Email: req.Email,
|
||||
PasswordHash: string(hashedBytes),
|
||||
Status: models.UserStatusPending,
|
||||
MFAEnabled: false,
|
||||
EmailNewsletter: req.EmailNewsletter,
|
||||
EmailContact: req.EmailContact,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
log.Printf("[Auth] Registering user: %s", req.Email)
|
||||
|
|
|
|||
|
|
@ -15,15 +15,17 @@ const (
|
|||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
Email string `json:"email" db:"email"`
|
||||
PasswordHash string `json:"-" db:"encrypted_password"`
|
||||
Status UserStatus `json:"status" db:"status"`
|
||||
MFAEnabled bool `json:"mfa_enabled" db:"mfa_enabled"`
|
||||
LastLogin *time.Time `json:"last_login" db:"last_login"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
Email string `json:"email" db:"email"`
|
||||
PasswordHash string `json:"-" db:"encrypted_password"`
|
||||
Status UserStatus `json:"status" db:"status"`
|
||||
MFAEnabled bool `json:"mfa_enabled" db:"mfa_enabled"`
|
||||
LastLogin *time.Time `json:"last_login" db:"last_login"`
|
||||
EmailNewsletter bool `json:"email_newsletter" db:"email_newsletter"`
|
||||
EmailContact bool `json:"email_contact" db:"email_contact"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
|
|
|
|||
97
go-backend/internal/services/turnstile_service.go
Normal file
97
go-backend/internal/services/turnstile_service.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TurnstileService struct {
|
||||
secretKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type TurnstileResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorCodes []string `json:"error-codes,omitempty"`
|
||||
ChallengeTS string `json:"challenge_ts,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Cdata string `json:"cdata,omitempty"`
|
||||
}
|
||||
|
||||
func NewTurnstileService(secretKey string) *TurnstileService {
|
||||
return &TurnstileService{
|
||||
secretKey: secretKey,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyToken validates a Turnstile token with Cloudflare
|
||||
func (s *TurnstileService) VerifyToken(token, remoteIP string) (*TurnstileResponse, error) {
|
||||
if s.secretKey == "" {
|
||||
// If no secret key is configured, skip verification (for development)
|
||||
return &TurnstileResponse{Success: true}, nil
|
||||
}
|
||||
|
||||
// Prepare the request data
|
||||
data := fmt.Sprintf(
|
||||
"secret=%s&response=%s",
|
||||
s.secretKey,
|
||||
token,
|
||||
)
|
||||
|
||||
if remoteIP != "" {
|
||||
data += fmt.Sprintf("&remoteip=%s", remoteIP)
|
||||
}
|
||||
|
||||
// Make the request to Cloudflare
|
||||
resp, err := s.client.Post(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
"application/x-www-form-urlencoded",
|
||||
bytes.NewBufferString(data),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify turnstile token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read turnstile response: %w", err)
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
var result TurnstileResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse turnstile response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetErrorMessage returns a user-friendly error message for error codes
|
||||
func (s *TurnstileService) GetErrorMessage(errorCodes []string) string {
|
||||
errorMessages := map[string]string{
|
||||
"missing-input-secret": "Server configuration error",
|
||||
"invalid-input-secret": "Server configuration error",
|
||||
"missing-input-response": "Please complete the security check",
|
||||
"invalid-input-response": "Security check failed, please try again",
|
||||
"bad-request": "Invalid request format",
|
||||
"timeout-or-duplicate": "Security check expired, please try again",
|
||||
"internal-error": "Verification service unavailable",
|
||||
}
|
||||
|
||||
for _, code := range errorCodes {
|
||||
if msg, exists := errorMessages[code]; exists {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return "Security verification failed"
|
||||
}
|
||||
1
test_register_new_flow.json
Normal file
1
test_register_new_flow.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"email": "newflow@example.com", "password": "TestPassword123!", "handle": "newflow", "display_name": "New Flow User", "turnstile_token": "test_token_for_development", "accept_terms": true, "accept_privacy": true, "email_newsletter": true, "email_contact": false}
|
||||
Loading…
Reference in a new issue