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>
106 lines
2.5 KiB
Go
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
|
|
}
|