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.
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
|
|
}
|