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 }