Files
affiliate_dash/backend/internal/service/login_sms_service.go
T
yml2213 bab522bcb3 feat(auth): 接入 DCAuth v2 登录并完善成员管理
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名
- 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone
- 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号
- 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码
- 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定
2026-10-21 22:37:49 +08:00

350 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/applog"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
smsPurposeLogin = model.LoginSMSPurposeLogin
smsMaxVerifyAttempts = 5
smsDefaultTTLSeconds = 300
smsDefaultCooldown = 60
smsDefaultDailyLimit = 10
aliyunDysmsEndpoint = "https://dysmsapi.aliyuncs.com/"
aliyunDysmsAPIVersion = "2017-05-25"
)
var phoneRegexp = regexp.MustCompile(`^1[3-9]\d{9}$`)
// SMSConfig 对应 WORKER_SMS_* 环境变量。
type SMSConfig struct {
Provider string
AccessKeyID string
AccessKeySecret string
SignName string
TemplateCode string
CodeTTLSeconds int
CooldownSeconds int
DailyLimit int
}
// SendLoginCodeResult 是发送验证码接口的返回。
type SendLoginCodeResult struct {
Sent bool `json:"sent"`
PhoneMasked string `json:"phoneMasked"`
ExpiresInSeconds int `json:"expiresInSeconds"`
}
// LoginSMSService 负责短信验证码的签发、校验与渠道调用。
type LoginSMSService struct {
db *gorm.DB
cfg SMSConfig
now func() time.Time
}
func NewLoginSMSService(db *gorm.DB, cfg SMSConfig) *LoginSMSService {
if cfg.CodeTTLSeconds < 60 {
cfg.CodeTTLSeconds = smsDefaultTTLSeconds
}
if cfg.CooldownSeconds < 10 {
cfg.CooldownSeconds = smsDefaultCooldown
}
if cfg.DailyLimit < 1 {
cfg.DailyLimit = smsDefaultDailyLimit
}
return &LoginSMSService{db: db, cfg: cfg, now: time.Now}
}
func (s *LoginSMSService) effectiveProvider() string {
provider := strings.ToLower(strings.TrimSpace(s.cfg.Provider))
if provider == "" {
provider = "mock"
}
if provider != "aliyun" {
return "mock"
}
if strings.TrimSpace(s.cfg.AccessKeyID) == "" ||
strings.TrimSpace(s.cfg.AccessKeySecret) == "" ||
strings.TrimSpace(s.cfg.SignName) == "" ||
strings.TrimSpace(s.cfg.TemplateCode) == "" {
applog.Warnf("[SMS] 阿里云短信配置不完整,已降级为 Mock 模式")
return "mock"
}
return "aliyun"
}
// SendLoginCode 生成 6 位验证码并发送到用户已绑定手机号。
func (s *LoginSMSService) SendLoginCode(user *model.User, requestIP string) (*SendLoginCodeResult, error) {
if s.db == nil {
return nil, newAuthError(http.StatusServiceUnavailable, "sms_unavailable", "短信服务未配置")
}
if user == nil {
return nil, newAuthError(http.StatusUnauthorized, "login_failed", "用户名或密码错误")
}
phone, err := NormalizePhone(user.Phone)
if err != nil {
return nil, err
}
now := s.now()
cooldown := time.Duration(s.cfg.CooldownSeconds) * time.Second
var latest model.LoginSMSCode
if err := s.db.Where("user_id = ? AND purpose = ?", user.ID, smsPurposeLogin).
Order("id DESC").First(&latest).Error; err == nil {
if latest.ConsumedAt == nil && now.Sub(latest.CreatedAt) < cooldown {
return nil, newAuthError(http.StatusTooManyRequests, "sms_cooldown", "验证码发送过于频繁,请稍后再试")
}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
provider := s.effectiveProvider()
if provider != "mock" {
start, end := chinaDayRange(now)
var count int64
if err := s.db.Model(&model.LoginSMSCode{}).
Where("user_id = ? AND purpose = ? AND created_at >= ? AND created_at < ?", user.ID, smsPurposeLogin, start, end).
Count(&count).Error; err != nil {
return nil, err
}
if count >= int64(s.cfg.DailyLimit) {
return nil, newAuthError(http.StatusTooManyRequests, "sms_daily_limit", "今日验证码发送次数已达上限,请明天再试")
}
}
code, err := generateSMSCode()
if err != nil {
return nil, err
}
if provider == "aliyun" {
if err := s.sendViaAliyun(phone, code); err != nil {
return nil, err
}
} else {
applog.Warnf("[SMS/MOCK] 【开发模式】登录验证码:%s -> %s(生产环境请配置 WORKER_SMS_PROVIDER=aliyun)", phone, code)
}
ttl := time.Duration(s.cfg.CodeTTLSeconds) * time.Second
row := &model.LoginSMSCode{
UserID: user.ID,
Phone: phone,
Purpose: smsPurposeLogin,
CodeHash: HashSMSCode(code),
ExpiresAt: now.Add(ttl),
RequestIP: truncateString(strings.TrimSpace(requestIP), 64),
CreatedAt: now,
}
if err := s.db.Create(row).Error; err != nil {
return nil, err
}
return &SendLoginCodeResult{
Sent: true,
PhoneMasked: MaskPhone(phone),
ExpiresInSeconds: int(ttl.Seconds()),
}, nil
}
// VerifyLoginCode 校验并一次性消费验证码;同一验证码错 5 次即作废。
func (s *LoginSMSService) VerifyLoginCode(userID uint, codeValue string) error {
if s.db == nil {
return newAuthError(http.StatusServiceUnavailable, "sms_unavailable", "短信服务未配置")
}
code := strings.TrimSpace(codeValue)
if code == "" {
return newAuthError(http.StatusBadRequest, "sms_code_required", "请输入短信验证码")
}
var latest model.LoginSMSCode
if err := s.db.Where("user_id = ? AND purpose = ?", userID, smsPurposeLogin).
Order("id DESC").First(&latest).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return newAuthError(http.StatusBadRequest, "sms_code_invalid", "短信验证码错误或已过期")
}
return err
}
now := s.now()
valid := latest.ConsumedAt == nil &&
latest.ExpiresAt.After(now) &&
subtle.ConstantTimeCompare([]byte(latest.CodeHash), []byte(HashSMSCode(code))) == 1
if !valid {
if latest.ConsumedAt == nil {
var row struct {
FailedAttemptCount int
ConsumedAt *time.Time
}
result := s.db.Raw(`
UPDATE login_sms_codes
SET failed_attempt_count = failed_attempt_count + 1,
consumed_at = CASE WHEN failed_attempt_count + 1 >= ? THEN ? ELSE consumed_at END
WHERE id = ? AND consumed_at IS NULL
RETURNING failed_attempt_count, consumed_at`,
smsMaxVerifyAttempts, now, latest.ID,
).Scan(&row)
if result.Error != nil {
return result.Error
}
if row.FailedAttemptCount >= smsMaxVerifyAttempts || row.ConsumedAt != nil {
return newAuthError(http.StatusBadRequest, "sms_code_locked", "验证码错误次数过多,请重新获取验证码")
}
}
return newAuthError(http.StatusBadRequest, "sms_code_invalid", "短信验证码错误或已过期")
}
result := s.db.Model(&model.LoginSMSCode{}).
Where("id = ? AND consumed_at IS NULL", latest.ID).
Update("consumed_at", now)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return newAuthError(http.StatusBadRequest, "sms_code_invalid", "验证码已失效,请重新获取")
}
return nil
}
// NormalizePhone 校验中国大陆手机号。
func NormalizePhone(value string) (string, error) {
phone := strings.TrimSpace(value)
if phone == "" {
return "", newAuthError(http.StatusConflict, "phone_unbound", "该账号尚未绑定手机号,请联系管理员")
}
if !phoneRegexp.MatchString(phone) {
return "", newAuthError(http.StatusBadRequest, "phone_invalid", "手机号格式不正确")
}
return phone, nil
}
// MaskPhone 返回 138****8000 形式的脱敏号码。
func MaskPhone(value string) string {
phone := strings.TrimSpace(value)
if !phoneRegexp.MatchString(phone) {
return phone
}
return phone[:3] + "****" + phone[len(phone)-4:]
}
// HashSMSCode 计算短信验证码摘要(只存哈希)。
func HashSMSCode(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
func generateSMSCode() (string, error) {
raw := make([]byte, 6)
if _, err := rand.Read(raw); err != nil {
return "", err
}
var builder strings.Builder
builder.Grow(6)
for _, value := range raw {
builder.WriteByte(byte('0' + value%10))
}
return builder.String(), nil
}
func chinaDayRange(now time.Time) (time.Time, time.Time) {
china := time.FixedZone("CST", 8*60*60)
local := now.In(china)
start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, china)
return start, start.Add(24 * time.Hour)
}
func truncateString(value string, max int) string {
if len(value) <= max {
return value
}
return value[:max]
}
// sendViaAliyun 使用阿里云 Dysmsapi RPC 签名(HMAC-SHA1)直接发起 HTTP 调用。
func (s *LoginSMSService) sendViaAliyun(phone, code string) error {
params := map[string]string{
"AccessKeyId": strings.TrimSpace(s.cfg.AccessKeyID),
"Action": "SendSms",
"Format": "JSON",
"PhoneNumbers": phone,
"RegionId": "cn-hangzhou",
"SignName": strings.TrimSpace(s.cfg.SignName),
"SignatureMethod": "HMAC-SHA1",
"SignatureNonce": uuid.NewString(),
"SignatureVersion": "1.0",
"TemplateCode": strings.TrimSpace(s.cfg.TemplateCode),
"TemplateParam": fmt.Sprintf(`{"code":%q}`, code),
"Timestamp": s.now().UTC().Format("2006-01-02T15:04:05Z"),
"Version": aliyunDysmsAPIVersion,
}
canonical := canonicalAliyunQuery(params)
stringToSign := "GET&%2F&" + aliyunPercentEncode(canonical)
mac := hmac.New(sha1.New, []byte(strings.TrimSpace(s.cfg.AccessKeySecret)+"&"))
_, _ = mac.Write([]byte(stringToSign))
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
endpoint := aliyunDysmsEndpoint + "?" + canonical + "&Signature=" + aliyunPercentEncode(signature)
client := &http.Client{Timeout: 10 * time.Second}
response, err := client.Get(endpoint)
if err != nil {
return newAuthError(http.StatusBadGateway, "sms_send_failed", "短信发送失败:"+err.Error())
}
defer response.Body.Close()
body, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024))
var payload struct {
Code string `json:"Code"`
Message string `json:"Message"`
}
_ = json.Unmarshal(body, &payload)
if response.StatusCode < 200 || response.StatusCode >= 300 || payload.Code != "OK" {
message := strings.TrimSpace(payload.Message)
if message == "" {
message = strings.TrimSpace(string(body))
}
if message == "" {
message = "未知错误"
}
return newAuthError(http.StatusBadGateway, "sms_send_failed", "短信发送失败:"+message)
}
return nil
}
func canonicalAliyunQuery(params map[string]string) string {
keys := make([]string, 0, len(params))
for key := range params {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, aliyunPercentEncode(key)+"="+aliyunPercentEncode(params[key]))
}
return strings.Join(parts, "&")
}
func aliyunPercentEncode(value string) string {
encoded := url.QueryEscape(value)
encoded = strings.ReplaceAll(encoded, "+", "%20")
encoded = strings.ReplaceAll(encoded, "*", "%2A")
encoded = strings.ReplaceAll(encoded, "%7E", "~")
return encoded
}