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 }