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>
132 lines
3.9 KiB
Go
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
|
|
}
|