diff --git a/go-backend/.env.example b/go-backend/.env.example index 9c6dddb..2856f55 100644 --- a/go-backend/.env.example +++ b/go-backend/.env.example @@ -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 diff --git a/go-backend/internal/config/config.go b/go-backend/internal/config/config.go index d6ff331..366117b 100644 --- a/go-backend/internal/config/config.go +++ b/go-backend/internal/config/config.go @@ -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", ""), } } diff --git a/go-backend/internal/database/migrations/20260205000003_add_email_preferences.up.sql b/go-backend/internal/database/migrations/20260205000003_add_email_preferences.up.sql new file mode 100644 index 0000000..15be6ca --- /dev/null +++ b/go-backend/internal/database/migrations/20260205000003_add_email_preferences.up.sql @@ -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'; diff --git a/go-backend/internal/handlers/auth_handler.go b/go-backend/internal/handlers/auth_handler.go index 3e7abe6..f2b643c 100644 --- a/go-backend/internal/handlers/auth_handler.go +++ b/go-backend/internal/handlers/auth_handler.go @@ -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) diff --git a/go-backend/internal/models/user.go b/go-backend/internal/models/user.go index b955687..f33e4d6 100644 --- a/go-backend/internal/models/user.go +++ b/go-backend/internal/models/user.go @@ -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 { diff --git a/go-backend/internal/services/turnstile_service.go b/go-backend/internal/services/turnstile_service.go new file mode 100644 index 0000000..30967b8 --- /dev/null +++ b/go-backend/internal/services/turnstile_service.go @@ -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" +} diff --git a/test_register_new_flow.json b/test_register_new_flow.json new file mode 100644 index 0000000..59b346d --- /dev/null +++ b/test_register_new_flow.json @@ -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}