veza/veza-backend-api/internal/services/captcha_service.go
senke e4dd09a909
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
feat(v0.13.0): conformité features partielles — CAPTCHA, password history, login history, SMS 2FA
TASK-CONF-001: SMS 2FA service (sms_2fa_service.go) — SMSProvider interface,
  rate limiting (3/h), 6-digit codes, 5min expiry, LogSMSProvider for dev.
TASK-CONF-002: CAPTCHA service (captcha_service.go) — Cloudflare Turnstile
  verification with fail-open + RequireCaptcha middleware. 11 tests.
TASK-CONF-003: Auth features completed:
  - F014 password history (password_history_service.go) — checks last 5 hashes,
    integrated into PasswordService.ChangePassword. 3 tests.
  - F024 login history (login_history_service.go) — Record, GetUserHistory,
    CountRecentFailures for security auditing.
  - F010/F013/F018/F021/F026 verified already implemented.
TASK-CONF-004: F075 ClamAV verified implemented. F080 watermark deferred (P4).
TASK-CONF-005: ADR-005 handler architecture documented (keep dual, migrate forward).
TASK-CONF-006: Frontend 0 TODO/FIXME, backend 1 — criteria met.

Migration: 970_password_login_history_v0130.sql (password_history, login_history,
sms_verification_codes tables).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:31:50 +01:00

106 lines
2.5 KiB
Go

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
}