veza/veza-backend-api/internal/services/sms_2fa_service.go
senke a1000ce7fb style(backend): gofmt -w on 85 files (whitespace only)
backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.

The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
2026-04-14 12:22:14 +02: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
}