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:
Patrick Britton 2026-02-05 08:59:05 -06:00
parent 997d6437be
commit 4eebd27e69
7 changed files with 160 additions and 20 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View 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"
}

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