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_SHARES=5
|
||||||
SOCIAL_RECOVERY_DELAY_HOURS=24
|
SOCIAL_RECOVERY_DELAY_HOURS=24
|
||||||
|
|
||||||
|
# Cloudflare Turnstile Configuration
|
||||||
|
TURNSTILE_SECRET_KEY=your_turnstile_secret_key_here
|
||||||
|
|
||||||
# AI Moderation System
|
# AI Moderation System
|
||||||
MODERATION_ENABLED=true
|
MODERATION_ENABLED=true
|
||||||
OPENAI_API_KEY=sk-your-openai-api-key-here
|
OPENAI_API_KEY=sk-your-openai-api-key-here
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ type Config struct {
|
||||||
R2SecretKey string
|
R2SecretKey string
|
||||||
R2MediaBucket string
|
R2MediaBucket string
|
||||||
R2VideoBucket string
|
R2VideoBucket string
|
||||||
|
TurnstileSecretKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() *Config {
|
func LoadConfig() *Config {
|
||||||
|
|
@ -76,6 +77,7 @@ func LoadConfig() *Config {
|
||||||
R2SecretKey: getEnv("R2_SECRET_KEY", ""),
|
R2SecretKey: getEnv("R2_SECRET_KEY", ""),
|
||||||
R2MediaBucket: getEnv("R2_MEDIA_BUCKET", "sojorn-media"),
|
R2MediaBucket: getEnv("R2_MEDIA_BUCKET", "sojorn-media"),
|
||||||
R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"),
|
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 {
|
type RegisterRequest struct {
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
Password string `json:"password" binding:"required,min=6"`
|
Password string `json:"password" binding:"required,min=6"`
|
||||||
Handle string `json:"handle" binding:"required,min=3"`
|
Handle string `json:"handle" binding:"required,min=3"`
|
||||||
DisplayName string `json:"display_name" binding:"required"`
|
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 {
|
type LoginRequest struct {
|
||||||
|
|
@ -52,6 +57,23 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
}
|
}
|
||||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
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)
|
existingUser, err := h.repo.GetUserByEmail(c.Request.Context(), req.Email)
|
||||||
if err == nil && existingUser != nil {
|
if err == nil && existingUser != nil {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
||||||
|
|
@ -72,13 +94,15 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
|
|
||||||
userID := uuid.New()
|
userID := uuid.New()
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
PasswordHash: string(hashedBytes),
|
PasswordHash: string(hashedBytes),
|
||||||
Status: models.UserStatusPending,
|
Status: models.UserStatusPending,
|
||||||
MFAEnabled: false,
|
MFAEnabled: false,
|
||||||
CreatedAt: time.Now(),
|
EmailNewsletter: req.EmailNewsletter,
|
||||||
UpdatedAt: time.Now(),
|
EmailContact: req.EmailContact,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[Auth] Registering user: %s", req.Email)
|
log.Printf("[Auth] Registering user: %s", req.Email)
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,17 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uuid.UUID `json:"id" db:"id"`
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
Email string `json:"email" db:"email"`
|
Email string `json:"email" db:"email"`
|
||||||
PasswordHash string `json:"-" db:"encrypted_password"`
|
PasswordHash string `json:"-" db:"encrypted_password"`
|
||||||
Status UserStatus `json:"status" db:"status"`
|
Status UserStatus `json:"status" db:"status"`
|
||||||
MFAEnabled bool `json:"mfa_enabled" db:"mfa_enabled"`
|
MFAEnabled bool `json:"mfa_enabled" db:"mfa_enabled"`
|
||||||
LastLogin *time.Time `json:"last_login" db:"last_login"`
|
LastLogin *time.Time `json:"last_login" db:"last_login"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
EmailNewsletter bool `json:"email_newsletter" db:"email_newsletter"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
EmailContact bool `json:"email_contact" db:"email_contact"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
|
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 {
|
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