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