- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone - 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号 - 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码 - 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定
350 lines
11 KiB
Go
350 lines
11 KiB
Go
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
|
||
}
|