262 lines
8.0 KiB
Go
262 lines
8.0 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"time"
|
|
|
|
dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20180501/v2/client"
|
|
"github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
|
"github.com/alibabacloud-go/tea/tea"
|
|
"github.com/topfans/backend/pkg/logger"
|
|
"github.com/topfans/backend/services/userService/config"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
var smsClient *dysmsapi20170525.Client
|
|
|
|
// InitSMSClient initializes the SMS client as a singleton
|
|
func InitSMSClient() error {
|
|
cfg := config.GetSMSConfig()
|
|
|
|
// If credentials are not set, skip initialization
|
|
if cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" {
|
|
logger.Logger.Warn("SMS credentials not configured, SMS client not initialized")
|
|
return nil
|
|
}
|
|
|
|
openapiConfig := &client.Config{
|
|
AccessKeyId: tea.String(cfg.AccessKeyID),
|
|
AccessKeySecret: tea.String(cfg.AccessKeySecret),
|
|
}
|
|
openapiConfig.Endpoint = tea.String("dysmsapi.aliyuncs.com")
|
|
|
|
var err error
|
|
smsClient, err = dysmsapi20170525.NewClient(openapiConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize SMS client: %w", err)
|
|
}
|
|
|
|
logger.Logger.Info("SMS client initialized successfully")
|
|
return nil
|
|
}
|
|
|
|
// generateVerifyToken generates a verify token with format "vtf_" + 29 random chars
|
|
func generateVerifyToken() (string, error) {
|
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
const tokenLen = 29
|
|
|
|
b := make([]byte, tokenLen)
|
|
charsLen := big.NewInt(int64(len(charset)))
|
|
|
|
for i := range b {
|
|
n, err := rand.Int(rand.Reader, charsLen)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate random token: %w", err)
|
|
}
|
|
b[i] = charset[n.Int64()]
|
|
}
|
|
|
|
return "vtf_" + string(b), nil
|
|
}
|
|
|
|
// maskMobile masks the middle digits of a mobile number
|
|
// Example: 13812345678 -> 138****5678
|
|
func maskMobile(mobile string) string {
|
|
if len(mobile) < 7 {
|
|
return mobile
|
|
}
|
|
return mobile[:3] + "****" + mobile[len(mobile)-4:]
|
|
}
|
|
|
|
// SendVerificationCode sends a verification code to the given mobile number
|
|
func SendVerificationCode(ctx context.Context, mobile, ip string) (int, error) {
|
|
logger := logger.Logger.With(zap.String("mobile", maskMobile(mobile)), zap.String("ip", ip))
|
|
|
|
// Step 1: Check IP blacklist
|
|
blacklisted, err := CheckIPBlacklist(ctx, ip)
|
|
if err != nil {
|
|
logger.Error("Failed to check IP blacklist", zap.Error(err))
|
|
return 0, fmt.Errorf("failed to check IP blacklist: %w", err)
|
|
}
|
|
if blacklisted {
|
|
logger.Warn("IP is blacklisted")
|
|
return 0, errors.New("IP黑名单")
|
|
}
|
|
|
|
// Step 2: Check if code was recently sent (TTL check via GetSMSCode)
|
|
smsData, err := GetSMSCode(ctx, mobile)
|
|
if err != nil {
|
|
logger.Error("Failed to get SMS code from Redis", zap.Error(err))
|
|
return 0, fmt.Errorf("failed to check recent SMS: %w", err)
|
|
}
|
|
if smsData != nil {
|
|
elapsed := time.Now().Unix() - smsData.CreatedAt
|
|
if elapsed < 60 {
|
|
logger.Warn("SMS code recently sent", zap.Int64("elapsed_seconds", elapsed))
|
|
return 0, errors.New("发送过于频繁")
|
|
}
|
|
}
|
|
|
|
// Step 3: Check mobile hourly limit (10/hour)
|
|
allowed, err := CheckRateLimitMobile(ctx, mobile)
|
|
if err != nil {
|
|
logger.Error("Failed to check mobile rate limit", zap.Error(err))
|
|
return 0, fmt.Errorf("failed to check mobile rate limit: %w", err)
|
|
}
|
|
if !allowed {
|
|
logger.Warn("Mobile hourly limit exceeded")
|
|
return 0, errors.New("当前手机号发送次数超限")
|
|
}
|
|
|
|
// Step 4: Check IP hourly limit (30/hour)
|
|
allowed, err = CheckRateLimitIP(ctx, ip)
|
|
if err != nil {
|
|
logger.Error("Failed to check IP rate limit", zap.Error(err))
|
|
return 0, fmt.Errorf("failed to check IP rate limit: %w", err)
|
|
}
|
|
if !allowed {
|
|
logger.Warn("IP hourly limit exceeded")
|
|
return 0, errors.New("请求过于频繁")
|
|
}
|
|
|
|
// Step 5: Generate 6-digit code
|
|
code, err := GenerateCode()
|
|
if err != nil {
|
|
logger.Error("Failed to generate SMS code", zap.Error(err))
|
|
return 0, fmt.Errorf("failed to generate code: %w", err)
|
|
}
|
|
|
|
// Step 6: Call Aliyun SMS API to send (if client is initialized)
|
|
if smsClient != nil {
|
|
cfg := config.GetSMSConfig()
|
|
sendReq := &dysmsapi20170525.SendMessageWithTemplateRequest{
|
|
To: tea.String("86" + mobile), // China area code + mobile
|
|
From: tea.String(cfg.SignName),
|
|
TemplateCode: tea.String(cfg.TemplateCode),
|
|
TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, code)),
|
|
}
|
|
|
|
_, err := smsClient.SendMessageWithTemplate(sendReq)
|
|
if err != nil {
|
|
logger.Error("Failed to send SMS via Aliyun", zap.Error(err))
|
|
return 0, fmt.Errorf("failed to send SMS: %w", err)
|
|
}
|
|
|
|
logger.Info("SMS sent successfully via Aliyun")
|
|
} else {
|
|
logger.Warn("SMS client not initialized, skipping actual SMS send")
|
|
}
|
|
|
|
// Step 7: Save code to Redis with 60s TTL
|
|
err = SaveSMSCode(ctx, mobile, code, 60*time.Second)
|
|
if err != nil {
|
|
logger.Error("Failed to save SMS code to Redis", zap.Error(err))
|
|
return 0, fmt.Errorf("failed to save code: %w", err)
|
|
}
|
|
|
|
// Step 8: Increment mobile and IP counters
|
|
_, err = IncrMobileCount(ctx, mobile)
|
|
if err != nil {
|
|
logger.Error("Failed to increment mobile count", zap.Error(err))
|
|
}
|
|
|
|
_, err = IncrIPCount(ctx, ip)
|
|
if err != nil {
|
|
logger.Error("Failed to increment IP count", zap.Error(err))
|
|
}
|
|
|
|
logger.Info("Verification code sent successfully")
|
|
return 60, nil
|
|
}
|
|
|
|
// VerifyCode verifies the SMS code and returns a verify token on success
|
|
func VerifyCode(ctx context.Context, mobile, code string) (string, error) {
|
|
logger := logger.Logger.With(zap.String("mobile", maskMobile(mobile)))
|
|
|
|
// Step 1: Get stored code data
|
|
smsData, err := GetSMSCode(ctx, mobile)
|
|
if err != nil {
|
|
logger.Error("Failed to get SMS code from Redis", zap.Error(err))
|
|
return "", fmt.Errorf("failed to get SMS code: %w", err)
|
|
}
|
|
if smsData == nil {
|
|
logger.Warn("No SMS code found for mobile")
|
|
return "", errors.New("验证码不存在或已过期")
|
|
}
|
|
|
|
// Step 2: Check if already used
|
|
if smsData.Used {
|
|
logger.Warn("SMS code already used")
|
|
return "", errors.New("验证码已使用")
|
|
}
|
|
|
|
// Step 3: Compare codes
|
|
if smsData.Code != code {
|
|
attempts, _ := IncrementAttempts(ctx, mobile)
|
|
logger.Warn("SMS code mismatch", zap.Int("attempts", attempts))
|
|
|
|
// Delete code if attempts >= 3
|
|
if attempts >= 3 {
|
|
DeleteSMSCode(ctx, mobile)
|
|
logger.Info("SMS code deleted due to max attempts")
|
|
return "", errors.New("验证失败次数过多,请重新获取验证码")
|
|
}
|
|
return "", errors.New("验证码错误")
|
|
}
|
|
|
|
// Step 4: On success - delete code, generate verify_token, save with 300s TTL
|
|
if err := DeleteSMSCode(ctx, mobile); err != nil {
|
|
logger.Error("Failed to delete SMS code", zap.Error(err))
|
|
}
|
|
|
|
verifyToken, err := generateVerifyToken()
|
|
if err != nil {
|
|
logger.Error("Failed to generate verify token", zap.Error(err))
|
|
return "", fmt.Errorf("failed to generate verify token: %w", err)
|
|
}
|
|
|
|
err = SaveVerifyToken(ctx, mobile, verifyToken, 300*time.Second)
|
|
if err != nil {
|
|
logger.Error("Failed to save verify token", zap.Error(err))
|
|
return "", fmt.Errorf("failed to save verify token: %w", err)
|
|
}
|
|
|
|
logger.Info("SMS code verified successfully, verify token issued")
|
|
return verifyToken, nil
|
|
}
|
|
|
|
// VerifyToken verifies the verify_token during registration (one-time use)
|
|
func VerifyToken(ctx context.Context, mobile, token string) error {
|
|
logger := logger.Logger.With(zap.String("mobile", maskMobile(mobile)))
|
|
|
|
// Step 1: Get stored token from Redis
|
|
storedToken, err := GetVerifyToken(ctx, mobile)
|
|
if err != nil {
|
|
logger.Error("Failed to get verify token from Redis", zap.Error(err))
|
|
return fmt.Errorf("failed to get verify token: %w", err)
|
|
}
|
|
|
|
// Step 2: Compare with provided token
|
|
if storedToken == "" {
|
|
logger.Warn("No verify token found for mobile")
|
|
return errors.New("验证令牌不存在或已过期")
|
|
}
|
|
|
|
if storedToken != token {
|
|
logger.Warn("Verify token mismatch")
|
|
return errors.New("验证令牌无效")
|
|
}
|
|
|
|
// Step 3: If match, delete the token (one-time use)
|
|
if err := DeleteVerifyToken(ctx, mobile); err != nil {
|
|
logger.Error("Failed to delete verify token", zap.Error(err))
|
|
return fmt.Errorf("failed to delete verify token: %w", err)
|
|
}
|
|
|
|
logger.Info("Verify token validated and consumed successfully")
|
|
return nil
|
|
} |