veza/veza-backend-api/internal/services/sms_2fa_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

132 lines
3.9 KiB
Go

package services
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"time"
"veza-backend-api/internal/database"
"github.com/google/uuid"
"go.uber.org/zap"
)
const (
smsCodeLength = 6
smsCodeExpiry = 5 * time.Minute
smsRateLimit = 3 // max SMS per user per hour
)
// SMSProvider defines the interface for sending SMS messages.
// Implementations: TwilioProvider, AWSProvider, MockProvider.
type SMSProvider interface {
SendSMS(ctx context.Context, phoneNumber, message string) error
}
// SMS2FAService handles SMS-based two-factor authentication.
// F019: ORIGIN_FEATURES_REGISTRY.md — SMS 2FA (P3, optional fallback).
// Note: TOTP is preferred; SMS is vulnerable to SIM swap attacks.
type SMS2FAService struct {
db *database.Database
provider SMSProvider
logger *zap.Logger
}
// NewSMS2FAService creates a new SMS 2FA service.
func NewSMS2FAService(db *database.Database, provider SMSProvider, logger *zap.Logger) *SMS2FAService {
return &SMS2FAService{db: db, provider: provider, logger: logger}
}
// SendVerificationCode generates and sends a 6-digit code via SMS.
func (s *SMS2FAService) SendVerificationCode(ctx context.Context, userID uuid.UUID, phoneNumber string) error {
// Rate limit: max 3 SMS per hour per user
var count int
err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM sms_verification_codes
WHERE user_id = $1 AND created_at > $2
`, userID, time.Now().Add(-1*time.Hour)).Scan(&count)
if err == nil && count >= smsRateLimit {
return fmt.Errorf("too many SMS verification attempts — try again later")
}
code, err := generateNumericCode(smsCodeLength)
if err != nil {
return fmt.Errorf("generate code: %w", err)
}
// Store code in database
_, err = s.db.ExecContext(ctx, `
INSERT INTO sms_verification_codes (id, user_id, code, phone_number, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())
`, uuid.New(), userID, code, phoneNumber, time.Now().Add(smsCodeExpiry))
if err != nil {
return fmt.Errorf("store verification code: %w", err)
}
// Send SMS
message := fmt.Sprintf("Veza: Your verification code is %s. Valid for 5 minutes.", code)
if err := s.provider.SendSMS(ctx, phoneNumber, message); err != nil {
s.logger.Error("failed to send SMS", zap.Error(err), zap.String("user_id", userID.String()))
return fmt.Errorf("failed to send SMS verification code")
}
s.logger.Info("SMS verification code sent",
zap.String("user_id", userID.String()),
zap.String("phone", phoneNumber[:3]+"****"),
)
return nil
}
// VerifyCode checks if the provided code matches the most recent unexpired code.
func (s *SMS2FAService) VerifyCode(ctx context.Context, userID uuid.UUID, code string) error {
var storedCode string
err := s.db.QueryRowContext(ctx, `
SELECT code FROM sms_verification_codes
WHERE user_id = $1 AND used = false AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1
`, userID).Scan(&storedCode)
if err != nil {
return fmt.Errorf("no valid verification code found")
}
if storedCode != code {
return fmt.Errorf("invalid verification code")
}
// Mark as used
_, _ = s.db.ExecContext(ctx, `
UPDATE sms_verification_codes SET used = true WHERE user_id = $1 AND code = $2
`, userID, code)
return nil
}
// generateNumericCode generates a cryptographically secure N-digit numeric code.
func generateNumericCode(length int) (string, error) {
code := make([]byte, length)
for i := range code {
n, err := rand.Int(rand.Reader, big.NewInt(10))
if err != nil {
return "", err
}
code[i] = byte('0' + n.Int64())
}
return string(code), nil
}
// LogSMSProvider is a dev/test provider that logs SMS instead of sending.
type LogSMSProvider struct {
Logger *zap.Logger
}
// SendSMS logs the SMS content instead of sending it.
func (p *LogSMSProvider) SendSMS(_ context.Context, phoneNumber, message string) error {
p.Logger.Info("SMS (dev mode)",
zap.String("to", phoneNumber),
zap.String("message", message),
)
return nil
}