Files
affiliate_dash/backend/internal/service/login_dcauth_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

508 lines
17 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/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
"affiliate_dash/internal/model"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
DCAuthApp = "affiliate_dash"
DCAuthPurposeLogin = "login"
DCAuthChallengeTTL = 120 * time.Second
DCAuthMaxClockSkew = 300 * time.Second
DCAuthRateLimitPerMin = 30
dcAuthDeviceIDPattern = `^D1-[A-Z0-9-]{8,64}$`
dcAuthBase64URLPattern = `^[A-Za-z0-9_-]+$`
)
var (
deviceIDRegexp = regexp.MustCompile(dcAuthDeviceIDPattern)
base64URLRegexp = regexp.MustCompile(dcAuthBase64URLPattern)
)
// AuthError 携带 HTTP 状态码与稳定的错误码,供 handler 转成协议响应。
type AuthError struct {
Status int
Code string
Message string
}
func (e *AuthError) Error() string { return e.Message }
func newAuthError(status int, code, message string) *AuthError {
return &AuthError{Status: status, Code: code, Message: message}
}
// AsAuthError 把业务错误转换为 AuthError。
func AsAuthError(err error) (*AuthError, bool) {
var target *AuthError
if errors.As(err, &target) {
return target, true
}
return nil, false
}
// DCAuthorizeRequest 是 DCAuth 2.0.0 客户端提交的签名请求。
type DCAuthorizeRequest struct {
App string `json:"app"`
ChallengeID string `json:"challengeId"`
Challenge string `json:"challenge"`
DeviceID string `json:"deviceId"`
PublicKey string `json:"publicKey"`
Signature string `json:"signature"`
Timestamp int64 `json:"timestamp"`
ClientVersion string `json:"clientVersion"`
}
// PreparedDeviceLogin 是 prepare 阶段返回给前端的 challenge 信息。
type PreparedDeviceLogin struct {
ChallengeID string `json:"challengeId"`
Challenge string `json:"challenge"`
ExpiresAt time.Time `json:"expiresAt"`
PollToken string `json:"pollToken"`
LaunchURL string `json:"launchUrl"`
PhoneMasked string `json:"phoneMasked"`
App string `json:"app"`
}
// DeviceChallengeStatus 是轮询接口的响应。
type DeviceChallengeStatus struct {
Status string `json:"status"`
DeviceTicket string `json:"deviceTicket,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
Message string `json:"message,omitempty"`
}
type challengeRateLimiter struct {
mu sync.Mutex
buckets map[string]*challengeRateBucket
}
type challengeRateBucket struct {
windowStart time.Time
count int
}
func newChallengeRateLimiter() *challengeRateLimiter {
return &challengeRateLimiter{buckets: make(map[string]*challengeRateBucket)}
}
func (l *challengeRateLimiter) allow(key string, now time.Time) bool {
if l == nil || key == "" {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
if len(l.buckets) > 10000 {
for k, bucket := range l.buckets {
if now.Sub(bucket.windowStart) >= time.Minute {
delete(l.buckets, k)
}
}
}
bucket, ok := l.buckets[key]
if !ok || now.Sub(bucket.windowStart) >= time.Minute {
l.buckets[key] = &challengeRateBucket{windowStart: now, count: 1}
return true
}
if bucket.count >= DCAuthRateLimitPerMin {
return false
}
bucket.count++
return true
}
// DCAuthService 负责 challenge 状态机、Ed25519 验签与设备绑定。
type DCAuthService struct {
db *gorm.DB
limiter *challengeRateLimiter
now func() time.Time
}
func NewDCAuthService(db *gorm.DB) *DCAuthService {
return &DCAuthService{
db: db,
limiter: newChallengeRateLimiter(),
now: time.Now,
}
}
// BuildDCAuthCanonical 生成签名原文(必须与客户端逐字节一致)。
func BuildDCAuthCanonical(app, challengeID, challenge, deviceID string, timestamp int64) string {
return strings.Join([]string{
"dcauth.v2",
app,
challengeID,
challenge,
deviceID,
strconv.FormatInt(timestamp, 10),
}, "\n")
}
// IsSupportedDCAuthClientVersion 判断客户端主版本是否 >= 2。
func IsSupportedDCAuthClientVersion(clientVersion string) bool {
major := strings.Split(strings.TrimSpace(clientVersion), ".")[0]
value, err := strconv.Atoi(major)
return err == nil && value >= 2
}
// NormalizeAPIBase 只接受 scheme://host 形式,防止 launchUrl 被注入路径或查询串。
func NormalizeAPIBase(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", newAuthError(http.StatusBadRequest, "api_base_invalid", "无法确定 API 地址")
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return "", newAuthError(http.StatusBadRequest, "api_base_invalid", "API 地址无效")
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" {
return "", newAuthError(http.StatusBadRequest, "api_base_invalid", "API 地址必须使用 http 或 https")
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", newAuthError(http.StatusBadRequest, "api_base_invalid", "API 地址不允许携带用户信息、查询串或片段")
}
if path := strings.TrimRight(parsed.EscapedPath(), "/"); path != "" {
return "", newAuthError(http.StatusBadRequest, "api_base_invalid", "API 地址必须是不带路径的 origin")
}
return scheme + "://" + strings.ToLower(parsed.Host), nil
}
// CreateChallenge 创建一次登录 challenge,并返回前端直接唤起 DCAuth 的 launchUrl。
func (s *DCAuthService) CreateChallenge(userID uint, apiBase string) (*PreparedDeviceLogin, error) {
if s.db == nil {
return nil, newAuthError(http.StatusServiceUnavailable, "device_auth_unavailable", "设备认证服务未配置")
}
origin, err := NormalizeAPIBase(apiBase)
if err != nil {
return nil, err
}
challengeID := uuid.NewString()
challenge, err := randomURLToken(32)
if err != nil {
return nil, err
}
pollToken, err := randomURLToken(32)
if err != nil {
return nil, err
}
now := s.now()
expiresAt := now.Add(DCAuthChallengeTTL)
user := userID
row := &model.DeviceChallenge{
ChallengeID: challengeID,
App: DCAuthApp,
Purpose: DCAuthPurposeLogin,
Challenge: challenge,
PollTokenHash: hashToken(pollToken),
Status: model.DeviceChallengeStatusPending,
UserID: &user,
ExpiresAt: expiresAt,
}
if err := s.db.Create(row).Error; err != nil {
return nil, newAuthError(http.StatusInternalServerError, "challenge_create_failed", "创建设备认证会话失败,请重试")
}
return &PreparedDeviceLogin{
ChallengeID: challengeID,
Challenge: challenge,
ExpiresAt: expiresAt,
PollToken: pollToken,
LaunchURL: buildDCAuthLaunchURL(origin, challengeID, challenge),
App: DCAuthApp,
}, nil
}
// Authorize 校验客户端签名并原子推进 challenge 到 authorized。
func (s *DCAuthService) Authorize(in DCAuthorizeRequest) error {
app := strings.TrimSpace(in.App)
challengeID := strings.TrimSpace(in.ChallengeID)
challengeValue := strings.TrimSpace(in.Challenge)
deviceID := strings.ToUpper(strings.TrimSpace(in.DeviceID))
publicKey := strings.TrimSpace(in.PublicKey)
signature := strings.TrimSpace(in.Signature)
clientVersion := strings.TrimSpace(in.ClientVersion)
now := s.now()
if app != DCAuthApp {
return newAuthError(http.StatusBadRequest, "app_mismatch", "设备认证应用不匹配")
}
if challengeID == "" || challengeValue == "" || deviceID == "" || publicKey == "" || signature == "" {
return newAuthError(http.StatusBadRequest, "payload_invalid", "设备认证参数不完整")
}
if !s.limiter.allow(challengeID, now) {
return newAuthError(http.StatusTooManyRequests, "rate_limited", "设备认证请求过于频繁,请稍后再试")
}
if !IsSupportedDCAuthClientVersion(clientVersion) {
return newAuthError(http.StatusUpgradeRequired, "client_outdated", "DCAuth 客户端版本过低,请升级到 v2.0.0 或更高版本")
}
if !deviceIDRegexp.MatchString(deviceID) {
return newAuthError(http.StatusBadRequest, "device_id_invalid", "设备码格式不正确")
}
if in.Timestamp == 0 || absDuration(now.Sub(time.Unix(in.Timestamp, 0))) > DCAuthMaxClockSkew {
return newAuthError(http.StatusBadRequest, "clock_invalid", "设备时间与服务器偏差过大,请校准系统时间")
}
if s.db == nil {
return newAuthError(http.StatusServiceUnavailable, "device_auth_unavailable", "设备认证服务未配置")
}
var challenge model.DeviceChallenge
if err := s.db.Where("challenge_id = ?", challengeID).First(&challenge).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return newAuthError(http.StatusNotFound, "challenge_not_found", "设备认证会话不存在")
}
return err
}
if challenge.App != DCAuthApp || challenge.Purpose != DCAuthPurposeLogin {
return newAuthError(http.StatusNotFound, "challenge_not_found", "设备认证会话不存在")
}
if subtle.ConstantTimeCompare([]byte(challenge.Challenge), []byte(challengeValue)) != 1 {
return newAuthError(http.StatusBadRequest, "challenge_mismatch", "设备认证 challenge 不匹配")
}
if challenge.Status != model.DeviceChallengeStatusPending || !challenge.ExpiresAt.After(now) {
return newAuthError(http.StatusConflict, "challenge_expired", "设备认证会话已失效,请重新登录")
}
publicKeyBytes, err := decodeBase64URL(publicKey)
if err != nil {
return newAuthError(http.StatusBadRequest, "signature_invalid", "设备签名编码无效")
}
signatureBytes, err := decodeBase64URL(signature)
if err != nil {
return newAuthError(http.StatusBadRequest, "signature_invalid", "设备签名编码无效")
}
if len(publicKeyBytes) != ed25519.PublicKeySize || len(signatureBytes) != ed25519.SignatureSize {
return newAuthError(http.StatusBadRequest, "signature_invalid", "设备公钥或签名格式不正确")
}
canonical := BuildDCAuthCanonical(app, challengeID, challengeValue, deviceID, in.Timestamp)
if !ed25519.Verify(ed25519.PublicKey(publicKeyBytes), []byte(canonical), signatureBytes) {
return newAuthError(http.StatusUnauthorized, "signature_invalid", "设备签名校验失败")
}
result := s.db.Exec(`
UPDATE device_challenges
SET status = ?, device_id = ?, public_key = ?, client_version = ?,
authorized_at = ?, updated_at = ?
WHERE challenge_id = ? AND app = ? AND purpose = ?
AND status = ? AND expires_at > ?`,
model.DeviceChallengeStatusAuthorized, deviceID, publicKey, clientVersion,
now, now,
challengeID, DCAuthApp, DCAuthPurposeLogin,
model.DeviceChallengeStatusPending, now,
)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return newAuthError(http.StatusConflict, "challenge_used", "设备认证会话已被使用,请重新登录")
}
return nil
}
// ChallengeStatus 校验轮询密钥;authorized 状态下原子签发一次性 ticket。
func (s *DCAuthService) ChallengeStatus(challengeIDValue, pollTokenValue string) (*DeviceChallengeStatus, error) {
challengeID := strings.TrimSpace(challengeIDValue)
pollToken := strings.TrimSpace(pollTokenValue)
if challengeID == "" || pollToken == "" {
return nil, newAuthError(http.StatusBadRequest, "poll_invalid", "缺少设备认证参数")
}
if s.db == nil {
return nil, newAuthError(http.StatusServiceUnavailable, "device_auth_unavailable", "设备认证服务未配置")
}
var challenge model.DeviceChallenge
if err := s.db.Where("challenge_id = ?", challengeID).First(&challenge).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &DeviceChallengeStatus{Status: model.DeviceChallengeStatusRejected, Message: "设备认证会话不存在"}, nil
}
return nil, err
}
expectedPollHash := hashToken(pollToken)
if subtle.ConstantTimeCompare([]byte(challenge.PollTokenHash), []byte(expectedPollHash)) != 1 {
return nil, newAuthError(http.StatusForbidden, "poll_forbidden", "设备认证轮询密钥无效")
}
now := s.now()
if !challenge.ExpiresAt.After(now) && challenge.Status != model.DeviceChallengeStatusConsumed {
return &DeviceChallengeStatus{Status: model.DeviceChallengeStatusExpired, Message: "设备认证会话已过期"}, nil
}
if challenge.Status == model.DeviceChallengeStatusAuthorized {
ticket, err := randomURLToken(32)
if err != nil {
return nil, err
}
result := s.db.Exec(`
UPDATE device_challenges
SET status = ?, device_ticket_hash = ?, ticket_issued_at = ?, updated_at = ?
WHERE challenge_id = ? AND status = ? AND expires_at > ?`,
model.DeviceChallengeStatusTicketIssued, hashToken(ticket), now, now,
challengeID, model.DeviceChallengeStatusAuthorized, now,
)
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 1 {
return &DeviceChallengeStatus{
Status: model.DeviceChallengeStatusAuthorized,
DeviceTicket: ticket,
DeviceID: challenge.DeviceID,
}, nil
}
var latest model.DeviceChallenge
if err := s.db.Where("challenge_id = ?", challengeID).First(&latest).Error; err == nil {
return &DeviceChallengeStatus{Status: latest.Status, DeviceID: latest.DeviceID}, nil
}
return &DeviceChallengeStatus{Status: model.DeviceChallengeStatusPending}, nil
}
return &DeviceChallengeStatus{Status: challenge.Status, DeviceID: challenge.DeviceID}, nil
}
// ConsumeTicket 原子消费 ticket:ticket_issued -> consumed。
func (s *DCAuthService) ConsumeTicket(challengeID, pollToken, deviceTicket string) (*model.DeviceChallenge, error) {
challengeID = strings.TrimSpace(challengeID)
pollToken = strings.TrimSpace(pollToken)
deviceTicket = strings.TrimSpace(deviceTicket)
if challengeID == "" || pollToken == "" || deviceTicket == "" {
return nil, newAuthError(http.StatusBadRequest, "ticket_required", "缺少设备认证票据")
}
if s.db == nil {
return nil, newAuthError(http.StatusServiceUnavailable, "device_auth_unavailable", "设备认证服务未配置")
}
now := s.now()
var challenge model.DeviceChallenge
result := s.db.Raw(`
UPDATE device_challenges
SET status = ?, consumed_at = ?, updated_at = ?
WHERE challenge_id = ? AND poll_token_hash = ? AND device_ticket_hash = ?
AND status = ? AND expires_at > ?
RETURNING *`,
model.DeviceChallengeStatusConsumed, now, now,
challengeID, hashToken(pollToken), hashToken(deviceTicket),
model.DeviceChallengeStatusTicketIssued, now,
).Scan(&challenge)
if result.Error != nil {
return nil, result.Error
}
if challenge.ID == 0 {
return nil, newAuthError(http.StatusUnauthorized, "ticket_invalid", "设备认证票据无效或已使用,请重新登录")
}
return &challenge, nil
}
// EnsureDeviceBinding 校验 active 设备绑定;首次登录时自动绑定当前设备。
func (s *DCAuthService) EnsureDeviceBinding(userID uint, challenge *model.DeviceChallenge) error {
if s.db == nil {
return newAuthError(http.StatusServiceUnavailable, "device_auth_unavailable", "设备认证服务未配置")
}
if challenge == nil {
return newAuthError(http.StatusUnauthorized, "device_binding_invalid", "设备认证信息无效,请重新登录")
}
now := s.now()
current, err := s.activeDevice(userID)
if err != nil {
return err
}
if current != nil {
return s.verifyAndTouchDevice(current, challenge, now)
}
result := s.db.Exec(`
INSERT INTO user_devices (
user_id, device_id, public_key, client_version, status,
bound_at, last_used_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id) WHERE status = 'active' DO NOTHING`,
userID, challenge.DeviceID, challenge.PublicKey, challenge.ClientVersion,
model.UserDeviceStatusActive, now, now, now, now,
)
if result.Error != nil {
return result.Error
}
bound, err := s.activeDevice(userID)
if err != nil {
return err
}
if bound == nil {
return newAuthError(http.StatusInternalServerError, "device_bind_failed", "绑定本机设备失败,请重试")
}
return s.verifyAndTouchDevice(bound, challenge, now)
}
func (s *DCAuthService) verifyAndTouchDevice(device *model.UserDevice, challenge *model.DeviceChallenge, now time.Time) error {
if subtle.ConstantTimeCompare([]byte(device.DeviceID), []byte(challenge.DeviceID)) != 1 ||
subtle.ConstantTimeCompare([]byte(device.PublicKey), []byte(challenge.PublicKey)) != 1 {
return newAuthError(http.StatusUnauthorized, "device_mismatch", "当前设备与账号绑定设备不一致,请联系管理员重置设备")
}
return s.db.Exec(`
UPDATE user_devices SET last_used_at = ?, updated_at = ?
WHERE id = ? AND status = ?`,
now, now, device.ID, model.UserDeviceStatusActive).Error
}
func (s *DCAuthService) activeDevice(userID uint) (*model.UserDevice, error) {
var device model.UserDevice
err := s.db.Where("user_id = ? AND status = ?", userID, model.UserDeviceStatusActive).
Order("id DESC").First(&device).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &device, nil
}
func buildDCAuthLaunchURL(apiBase, challengeID, challenge string) string {
query := url.Values{}
query.Set("app", DCAuthApp)
query.Set("challenge_id", challengeID)
query.Set("challenge", challenge)
query.Set("api", apiBase)
return "dcauth://authorize?" + query.Encode()
}
func decodeBase64URL(value string) ([]byte, error) {
if !base64URLRegexp.MatchString(value) {
return nil, errors.New("invalid base64url")
}
return base64.RawURLEncoding.Strict().DecodeString(value)
}
func randomURLToken(size int) (string, error) {
raw := make([]byte, size)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func hashToken(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func absDuration(value time.Duration) time.Duration {
if value < 0 {
return -value
}
return value
}