veza/veza-backend-api/internal/services/captcha_service.go

107 lines
2.5 KiB
Go
Raw Normal View History

package services
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"go.uber.org/zap"
)
// CaptchaService verifies CAPTCHA tokens.
// F027/F029: ORIGIN_FEATURES_REGISTRY.md — CAPTCHA anti-bot.
// Supports Cloudflare Turnstile (preferred over reCAPTCHA per ORIGIN spec).
type CaptchaService struct {
secretKey string
verifyURL string
enabled bool
httpClient *http.Client
logger *zap.Logger
}
// CaptchaConfig holds configuration for the CAPTCHA service.
type CaptchaConfig struct {
Enabled bool
SecretKey string
// Provider: "turnstile" (default) or "hcaptcha"
Provider string
}
// turnstileResponse is the response from Cloudflare Turnstile verification API.
type turnstileResponse struct {
Success bool `json:"success"`
ErrorCodes []string `json:"error-codes"`
}
// NewCaptchaService creates a new CAPTCHA verification service.
func NewCaptchaService(config CaptchaConfig, logger *zap.Logger) *CaptchaService {
verifyURL := "https://challenges.cloudflare.com/turnstile/v0/siteverify"
if config.Provider == "hcaptcha" {
verifyURL = "https://hcaptcha.com/siteverify"
}
return &CaptchaService{
secretKey: config.SecretKey,
verifyURL: verifyURL,
enabled: config.Enabled,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
logger: logger,
}
}
// Verify validates a CAPTCHA response token.
// Returns nil if valid, error if invalid or verification failed.
func (s *CaptchaService) Verify(ctx context.Context, token, remoteIP string) error {
if !s.enabled {
return nil
}
if token == "" {
return fmt.Errorf("captcha token required")
}
form := url.Values{
"secret": {s.secretKey},
"response": {token},
}
if remoteIP != "" {
form.Set("remoteip", remoteIP)
}
resp, err := s.httpClient.PostForm(s.verifyURL, form)
if err != nil {
s.logger.Warn("captcha verification request failed", zap.Error(err))
// Fail open: if CAPTCHA service is down, allow the request
return nil
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
s.logger.Warn("failed to read captcha response", zap.Error(err))
return nil
}
var result turnstileResponse
if err := json.Unmarshal(body, &result); err != nil {
s.logger.Warn("failed to parse captcha response", zap.Error(err))
return nil
}
if !result.Success {
return fmt.Errorf("captcha verification failed: %v", result.ErrorCodes)
}
return nil
}
// IsEnabled returns whether CAPTCHA verification is active.
func (s *CaptchaService) IsEnabled() bool {
return s.enabled
}