feat(auth): 接入 DCAuth v2 登录并完善成员管理

- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名
- 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone
- 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号
- 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码
- 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定
This commit is contained in:
yml2213
2026-10-21 22:37:49 +08:00
parent aa052d420a
commit bab522bcb3
29 changed files with 5490 additions and 1170 deletions
+14 -1
View File
@@ -79,7 +79,18 @@ func main() {
log.Fatalf("setup secret codec: %v", err)
}
tenantSvc := service.NewTenantService(db)
authSvc := service.NewAuthService(db, jm, tenantSvc)
smsSvc := service.NewLoginSMSService(db, service.SMSConfig{
Provider: cfg.SMSProvider,
AccessKeyID: cfg.SMSAccessKeyID,
AccessKeySecret: cfg.SMSAccessKeySecret,
SignName: cfg.SMSSignName,
TemplateCode: cfg.SMSTemplateCode,
CodeTTLSeconds: cfg.SMSCodeTTLSeconds,
CooldownSeconds: cfg.SMSCooldownSeconds,
DailyLimit: cfg.SMSDailyLimit,
})
deviceSvc := service.NewDCAuthService(db)
authSvc := service.NewAuthService(db, jm, tenantSvc, service.WithLoginSecurity(smsSvc, deviceSvc))
userSvc := service.NewUserService(db, tenantSvc)
callbackSvc := service.NewCallbackService(db, codec, service.WithCallbackConfig(service.CallbackConfig{
MaxAttempts: cfg.CallbackMaxAttempts,
@@ -127,6 +138,7 @@ func main() {
h := &router.Handlers{
Auth: handler.NewAuthHandler(authSvc),
DCAuth: handler.NewDCAuthHandler(deviceSvc),
Dashboard: handler.NewDashboardHandler(fulfillmentSvc),
Delivery: handler.NewDeliveryHandler(deliverySvc),
User: handler.NewUserHandler(userSvc),
@@ -176,6 +188,7 @@ func main() {
if cfg.LogFile != "" {
applog.Infof("日志文件: %s (同时输出控制台)", cfg.LogFile)
}
applog.Infof("登录短信渠道: %s", cfg.SMSProvider)
if err := r.Run(addr); err != nil {
log.Fatalf("server: %v", err)
}
+18
View File
@@ -57,6 +57,16 @@ type Config struct {
// 生产前后端同域(Caddy 统一入口)时无需配置;仅在前后端分离部署时设置。
CORSAllowedOrigins []string
// 登录短信渠道(WORKER_SMS_*)。provider=mock 时只写日志,不做每日限额。
SMSProvider string
SMSAccessKeyID string
SMSAccessKeySecret string
SMSSignName string
SMSTemplateCode string
SMSCodeTTLSeconds int
SMSCooldownSeconds int
SMSDailyLimit int
// 乐玩开放平台配置
LewanBaseURL string
LewanApplicationID string
@@ -96,6 +106,14 @@ func Load() *Config {
CallbackRetryScheduleSeconds: getEnvIntList("CALLBACK_RETRY_SCHEDULE", nil),
UploadDir: getEnv("UPLOAD_DIR", "data/uploads"),
CORSAllowedOrigins: getEnvList("CORS_ALLOWED_ORIGINS", nil),
SMSProvider: getEnv("WORKER_SMS_PROVIDER", "mock"),
SMSAccessKeyID: getEnv("WORKER_SMS_ACCESS_KEY_ID", ""),
SMSAccessKeySecret: getEnv("WORKER_SMS_ACCESS_KEY_SECRET", ""),
SMSSignName: getEnv("WORKER_SMS_SIGN_NAME", ""),
SMSTemplateCode: getEnv("WORKER_SMS_TEMPLATE_CODE", ""),
SMSCodeTTLSeconds: getEnvInt("WORKER_SMS_CODE_TTL_SECONDS", 300),
SMSCooldownSeconds: getEnvInt("WORKER_SMS_COOLDOWN_SECONDS", 60),
SMSDailyLimit: getEnvInt("WORKER_SMS_DAILY_LIMIT", 10),
LewanBaseURL: getEnv("LEWAN_BASE_URL", "https://shop.cloudtentacles.com"),
LewanApplicationID: getEnv("LEWAN_APPLICATION_ID", ""),
LewanAccessKey: getEnv("LEWAN_ACCESS_KEY", ""),
@@ -0,0 +1,98 @@
-- ============================================================================
-- 014_login_sms_device_auth.sql
-- 用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名登录。
-- 未绑定手机号的历史账号仍可使用原密码登录。
-- ============================================================================
ALTER TABLE users
ADD COLUMN IF NOT EXISTS phone VARCHAR(32) NOT NULL DEFAULT '';
ALTER TABLE users
ADD COLUMN IF NOT EXISTS phone_bound_at TIMESTAMPTZ;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS phone_bound_by BIGINT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_users_phone_bound_by'
) THEN
ALTER TABLE users
ADD CONSTRAINT fk_users_phone_bound_by
FOREIGN KEY (phone_bound_by) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_phone
ON users(phone)
WHERE phone <> '';
CREATE TABLE IF NOT EXISTS login_sms_codes (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
phone VARCHAR(32) NOT NULL,
purpose VARCHAR(32) NOT NULL DEFAULT 'login',
code_hash VARCHAR(64) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
failed_attempt_count INTEGER NOT NULL DEFAULT 0,
request_ip VARCHAR(64) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_login_sms_codes_user_created
ON login_sms_codes(user_id, purpose, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_login_sms_codes_phone_created
ON login_sms_codes(phone, created_at DESC);
CREATE TABLE IF NOT EXISTS user_devices (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id VARCHAR(128) NOT NULL,
public_key VARCHAR(128) NOT NULL,
client_version VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'active',
bound_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoked_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_devices_active_user
ON user_devices(user_id)
WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_user_devices_device_id
ON user_devices(device_id);
CREATE TABLE IF NOT EXISTS device_challenges (
id BIGSERIAL PRIMARY KEY,
challenge_id VARCHAR(128) NOT NULL UNIQUE,
app VARCHAR(64) NOT NULL,
purpose VARCHAR(32) NOT NULL DEFAULT 'login',
challenge VARCHAR(512) NOT NULL,
poll_token_hash VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
device_id VARCHAR(128) NOT NULL DEFAULT '',
public_key VARCHAR(128) NOT NULL DEFAULT '',
client_version VARCHAR(64) NOT NULL DEFAULT '',
device_ticket_hash VARCHAR(64) NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ NOT NULL,
authorized_at TIMESTAMPTZ,
ticket_issued_at TIMESTAMPTZ,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_device_challenges_expires
ON device_challenges(expires_at);
CREATE INDEX IF NOT EXISTS idx_device_challenges_user
ON device_challenges(user_id, created_at DESC);
COMMENT ON COLUMN users.phone IS '账号绑定手机号,用于登录短信验证码';
COMMENT ON COLUMN users.phone_bound_by IS '执行手机号绑定的管理员用户 ID';
COMMENT ON TABLE login_sms_codes IS '登录短信验证码(哈希存储、一次性消费)';
COMMENT ON TABLE user_devices IS '账号绑定的 DCAuth 设备(公钥指纹)';
COMMENT ON TABLE device_challenges IS 'DCAuth v2 设备认证 challenge 与一次性 ticket';
+74 -1
View File
@@ -29,12 +29,85 @@ func (h *AuthHandler) Login(c *gin.Context) {
}
result, err := h.svc.Login(req.Username, req.Password)
if err != nil {
response.BadRequest(c, err.Error())
writeServiceError(c, err, "用户名或密码错误")
return
}
response.OK(c, result)
}
type sendSMSCodeReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
func (h *AuthHandler) SendLoginSMSCode(c *gin.Context) {
var req sendSMSCodeReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请输入用户名和密码")
return
}
result, err := h.svc.SendLoginSMSCode(req.Username, req.Password, c.ClientIP())
if err != nil {
writeServiceError(c, err, "验证码发送失败")
return
}
response.OK(c, result)
}
type prepareLoginReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
SMSCode string `json:"smsCode" binding:"required"`
APIBase string `json:"apiBase"`
}
func (h *AuthHandler) PrepareLogin(c *gin.Context) {
var req prepareLoginReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请填写用户名、密码和短信验证码")
return
}
apiBase := req.APIBase
if apiBase == "" {
apiBase = c.GetHeader("Origin")
}
prepared, err := h.svc.PrepareLogin(req.Username, req.Password, req.SMSCode, apiBase)
if err != nil {
writeServiceError(c, err, "安全登录准备失败")
return
}
response.OK(c, prepared)
}
type completeLoginReq struct {
ChallengeID string `json:"challengeId" binding:"required"`
PollToken string `json:"pollToken" binding:"required"`
DeviceTicket string `json:"deviceTicket" binding:"required"`
}
func (h *AuthHandler) CompleteLogin(c *gin.Context) {
var req completeLoginReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "缺少设备认证票据")
return
}
result, err := h.svc.CompleteLogin(req.ChallengeID, req.PollToken, req.DeviceTicket)
if err != nil {
writeServiceError(c, err, "安全登录失败")
return
}
response.OK(c, result)
}
// writeServiceError 保留 AuthError 的 HTTP 状态码;其他错误统一 400。
func writeServiceError(c *gin.Context, err error, fallback string) {
if authErr, ok := service.AsAuthError(err); ok {
response.Fail(c, authErr.Status, authErr.Status, authErr.Message)
return
}
response.BadRequest(c, err.Error())
}
func (h *AuthHandler) Profile(c *gin.Context) {
user, err := h.svc.GetProfile(middleware.GetUserID(c))
if err != nil {
+68
View File
@@ -0,0 +1,68 @@
package handler
import (
"net/http"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
"github.com/gin-gonic/gin"
)
// DCAuthHandler 暴露 DCAuth 2.0.0 协议端点。
type DCAuthHandler struct {
svc *service.DCAuthService
}
func NewDCAuthHandler(svc *service.DCAuthService) *DCAuthHandler {
return &DCAuthHandler{svc: svc}
}
// Authorize 接收 DCAuth 客户端签名并推进 challenge 到 authorized。
func (h *DCAuthHandler) Authorize(c *gin.Context) {
if h == nil || h.svc == nil {
writeDCAuthError(c, newDCAuthUnavailable())
return
}
var req service.DCAuthorizeRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeDCAuthError(c, &service.AuthError{Status: http.StatusBadRequest, Code: "payload_invalid", Message: "设备认证参数无效"})
return
}
if err := h.svc.Authorize(req); err != nil {
writeDCAuthError(c, err)
return
}
response.OK(c, gin.H{"status": "authorized"})
}
// Status 轮询 challenge 状态;authorized 时签发一次性 deviceTicket。
func (h *DCAuthHandler) Status(c *gin.Context) {
if h == nil || h.svc == nil {
writeDCAuthError(c, newDCAuthUnavailable())
return
}
pollToken := c.GetHeader("X-Poll-Token")
if pollToken == "" {
pollToken = c.Query("pollToken")
}
status, err := h.svc.ChallengeStatus(c.Param("challengeId"), pollToken)
if err != nil {
writeDCAuthError(c, err)
return
}
response.OK(c, status)
}
func newDCAuthUnavailable() *service.AuthError {
return &service.AuthError{Status: http.StatusServiceUnavailable, Code: "device_auth_unavailable", Message: "设备认证服务未配置"}
}
// writeDCAuthError 使用协议约定的字符串 error code,便于 DCAuth 客户端识别。
func writeDCAuthError(c *gin.Context, err error) {
if authErr, ok := service.AsAuthError(err); ok {
c.JSON(authErr.Status, gin.H{"code": authErr.Code, "message": authErr.Message})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": "internal_error", "message": "设备认证服务异常"})
}
+50
View File
@@ -0,0 +1,50 @@
package handler
import (
"encoding/json"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"affiliate_dash/internal/service"
"github.com/gin-gonic/gin"
)
func TestDCAuthAuthorizeOldClientVersionReturns426(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewDCAuthHandler(service.NewDCAuthService(nil))
r := gin.New()
r.POST("/api/dcauth/authorize", h.Authorize)
body := `{
"app":"affiliate_dash",
"challengeId":"challenge-id",
"challenge":"challenge-value",
"deviceId":"D1-ABCDEFGH",
"publicKey":"YWJj",
"signature":"ZGVm",
"timestamp":` + strconv.FormatInt(time.Now().Unix(), 10) + `,
"clientVersion":"1.9.9"
}`
req := httptest.NewRequest("POST", "/api/dcauth/authorize", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 426 {
t.Fatalf("expected 426, got %d: %s", w.Code, w.Body.String())
}
var payload struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode body: %v", err)
}
if payload.Code != "client_outdated" {
t.Fatalf("expected client_outdated, got %q", payload.Code)
}
}
+46 -3
View File
@@ -50,9 +50,12 @@ func (h *MerchantHandler) AddCurrentMerchantMember(c *gin.Context) {
}
type updateMemberReq struct {
Role string `json:"role" binding:"required"`
Status int `json:"status"`
IsDefault bool `json:"is_default"`
Username string `json:"username"`
Password string `json:"password"`
Nickname *string `json:"nickname"`
Role string `json:"role" binding:"required"`
Status int `json:"status"`
IsDefault bool `json:"is_default"`
}
func (h *MerchantHandler) UpdateCurrentMerchantMember(c *gin.Context) {
@@ -63,6 +66,7 @@ func (h *MerchantHandler) UpdateCurrentMerchantMember(c *gin.Context) {
return
}
member, err := h.merchantSvc.UpdateMember(middleware.GetMerchantID(c), uint(memberID), service.UpdateMemberInput{
Username: req.Username, Password: req.Password, Nickname: req.Nickname,
Role: req.Role, Status: req.Status, IsDefault: req.IsDefault,
}, middleware.GetUserID(c))
if err != nil {
@@ -132,3 +136,42 @@ func (h *MerchantHandler) UpdateRole(c *gin.Context) {
}
response.OK(c, role)
}
type bindMemberPhoneReq struct {
Phone string `json:"phone" binding:"required"`
}
// BindCurrentMerchantMemberPhone 商户管理员为当前商户成员绑定/修改登录手机号。
func (h *MerchantHandler) BindCurrentMerchantMemberPhone(c *gin.Context) {
memberID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req bindMemberPhoneReq
if memberID == 0 || c.ShouldBindJSON(&req) != nil {
response.BadRequest(c, "请输入手机号")
return
}
user, err := h.merchantSvc.SetMemberPhone(
middleware.GetMerchantID(c), uint(memberID), req.Phone, middleware.GetUserID(c),
)
if err != nil {
writeServiceError(c, err, "手机号绑定失败")
return
}
response.OK(c, user)
}
// ResetCurrentMerchantMemberDevice 重置当前商户成员绑定的 DCAuth 设备。
func (h *MerchantHandler) ResetCurrentMerchantMemberDevice(c *gin.Context) {
memberID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if memberID == 0 {
response.BadRequest(c, "参数错误")
return
}
reset, err := h.merchantSvc.ResetMemberDevice(
middleware.GetMerchantID(c), uint(memberID), middleware.GetUserID(c),
)
if err != nil {
writeServiceError(c, err, "设备重置失败")
return
}
response.OK(c, gin.H{"reset": reset})
}
+31
View File
@@ -104,3 +104,34 @@ func (h *UserHandler) DeleteMerchantAccount(c *gin.Context) {
}
response.OK(c, nil)
}
type bindUserPhoneReq struct {
Phone string `json:"phone" binding:"required"`
}
// BindPlatformUserPhone 管理员为用户绑定/修改登录手机号。
func (h *UserHandler) BindPlatformUserPhone(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req bindUserPhoneReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请输入手机号")
return
}
user, err := h.svc.BindUserPhone(uint(id), req.Phone, middleware.GetUserID(c))
if err != nil {
writeServiceError(c, err, "手机号绑定失败")
return
}
response.OK(c, user)
}
// ResetPlatformUserDevice 管理员重置用户绑定的 DCAuth 设备。
func (h *UserHandler) ResetPlatformUserDevice(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
reset, err := h.svc.ResetUserDevice(uint(id), middleware.GetUserID(c))
if err != nil {
writeServiceError(c, err, "设备重置失败")
return
}
response.OK(c, gin.H{"reset": reset})
}
+76
View File
@@ -0,0 +1,76 @@
package model
import "time"
const (
LoginSMSPurposeLogin = "login"
DeviceChallengeStatusPending = "pending"
DeviceChallengeStatusAuthorized = "authorized"
DeviceChallengeStatusTicketIssued = "ticket_issued"
DeviceChallengeStatusConsumed = "consumed"
DeviceChallengeStatusExpired = "expired"
DeviceChallengeStatusRejected = "rejected"
UserDeviceStatusActive = "active"
UserDeviceStatusRevoked = "revoked"
)
// LoginSMSCode 登录短信验证码。验证码只保存 sha256 哈希,且一次性消费。
type LoginSMSCode struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Phone string `gorm:"size:32;not null" json:"phone"`
Purpose string `gorm:"size:32;not null;default:login" json:"purpose"`
CodeHash string `gorm:"size:64;not null" json:"-"`
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
ConsumedAt *time.Time `json:"consumed_at,omitempty"`
FailedAttemptCount int `gorm:"not null;default:0" json:"failed_attempt_count"`
RequestIP string `gorm:"size:64;not null;default:''" json:"request_ip"`
CreatedAt time.Time `json:"created_at"`
}
func (LoginSMSCode) TableName() string { return "login_sms_codes" }
// UserDevice DCAuth 设备绑定。每个用户同时只能有一台 active 设备。
type UserDevice struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
DeviceID string `gorm:"size:128;not null;index" json:"device_id"`
PublicKey string `gorm:"size:128;not null" json:"public_key"`
ClientVersion string `gorm:"size:64;not null;default:''" json:"client_version"`
Status string `gorm:"size:32;not null;default:active" json:"status"`
BoundAt time.Time `gorm:"not null" json:"bound_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
RevokedBy *uint `json:"revoked_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (UserDevice) TableName() string { return "user_devices" }
// DeviceChallenge DCAuth 登录 challenge 与一次性 ticket 的状态机。
// pending -> authorized -> ticket_issued -> consumed
type DeviceChallenge struct {
ID uint `gorm:"primarykey" json:"id"`
ChallengeID string `gorm:"size:128;not null;uniqueIndex" json:"challenge_id"`
App string `gorm:"size:64;not null" json:"app"`
Purpose string `gorm:"size:32;not null;default:login" json:"purpose"`
Challenge string `gorm:"size:512;not null" json:"challenge"`
PollTokenHash string `gorm:"size:64;not null" json:"-"`
Status string `gorm:"size:32;not null;default:pending" json:"status"`
UserID *uint `json:"user_id,omitempty"`
DeviceID string `gorm:"size:128;not null;default:''" json:"device_id"`
PublicKey string `gorm:"size:128;not null;default:''" json:"public_key"`
ClientVersion string `gorm:"size:64;not null;default:''" json:"client_version"`
DeviceTicketHash string `gorm:"size:64;not null;default:''" json:"-"`
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
AuthorizedAt *time.Time `json:"authorized_at,omitempty"`
TicketIssuedAt *time.Time `json:"ticket_issued_at,omitempty"`
ConsumedAt *time.Time `json:"consumed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (DeviceChallenge) TableName() string { return "device_challenges" }
+11
View File
@@ -26,4 +26,15 @@ type User struct {
Nickname string `gorm:"size:64" json:"nickname"`
Role string `gorm:"size:32;not null;default:merchant" json:"role"`
Status int `gorm:"default:1" json:"status"` // 1启用 0禁用
// 登录安全:绑定手机号后强制走「密码 + 短信验证码 + DCAuth 设备签名」。
Phone string `gorm:"size:32;not null;default:''" json:"phone"`
PhoneBoundAt *time.Time `json:"phone_bound_at,omitempty"`
PhoneBoundBy *uint `json:"phone_bound_by,omitempty"`
// 以下字段只用于管理端列表展示,不落库到 users 表。
PhoneMasked string `gorm:"-" json:"phone_masked,omitempty"`
DeviceID string `gorm:"-" json:"device_id,omitempty"`
DeviceIDMasked string `gorm:"-" json:"device_id_masked,omitempty"`
DeviceBound bool `gorm:"-" json:"device_bound"`
}
+16 -1
View File
@@ -20,6 +20,7 @@ import (
type Handlers struct {
Auth *handler.AuthHandler
DCAuth *handler.DCAuthHandler
Dashboard *handler.DashboardHandler
Delivery *handler.DeliveryHandler
User *handler.UserHandler
@@ -153,7 +154,7 @@ func Setup(h *Handlers) *gin.Engine {
r.Use(cors.New(cors.Config{
AllowOrigins: corsOrigins(h),
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Merchant-ID", "X-App-Key", "X-Api-Key", "X-Timestamp", "X-Nonce", "X-Sign", "X-Request-ID"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Merchant-ID", "X-App-Key", "X-Api-Key", "X-Timestamp", "X-Nonce", "X-Sign", "X-Request-ID", "X-Poll-Token"},
ExposeHeaders: []string{"Content-Length", "X-Request-ID"},
AllowCredentials: true,
}))
@@ -170,6 +171,15 @@ func Setup(h *Handlers) *gin.Engine {
api := r.Group("/api")
{
api.POST("/auth/login", h.Auth.Login)
api.POST("/auth/login/sms-code", h.Auth.SendLoginSMSCode)
api.POST("/auth/login/prepare", h.Auth.PrepareLogin)
api.POST("/auth/login/complete", h.Auth.CompleteLogin)
// DCAuth 2.0.0 客户端协议端点(客户端硬编码 /api/dcauth/authorize)。
if h.DCAuth != nil {
api.POST("/dcauth/authorize", h.DCAuth.Authorize)
api.GET("/dcauth/challenge/:challengeId/status", h.DCAuth.Status)
}
// 乐玩开放平台 Webhook 回调
if h.LewanWebhook != nil {
@@ -235,6 +245,9 @@ func Setup(h *Handlers) *gin.Engine {
platform.POST("/platform/admins", h.User.CreatePlatformAdmin)
platform.PATCH("/platform/admins/:id", h.User.UpdatePlatformAdmin)
platform.DELETE("/platform/admins/:id", h.User.DeletePlatformAdmin)
// 登录安全:管理端绑定手机号与重置 DCAuth 设备。
platform.POST("/admin/users/:id/phone", h.User.BindPlatformUserPhone)
platform.POST("/admin/users/:id/device/reset", h.User.ResetPlatformUserDevice)
platform.GET("/platform/merchants", h.Merchant.ListPlatformMerchants)
// 不分页的精简商户列表,供筛选下拉使用(分页接口的 size 上限是 100)
platform.GET("/platform/merchant-options", h.Merchant.ListMerchantOptions)
@@ -330,6 +343,8 @@ func Setup(h *Handlers) *gin.Engine {
merchant.POST("/members", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.AddCurrentMerchantMember)
merchant.PATCH("/members/:id", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.UpdateCurrentMerchantMember)
merchant.DELETE("/members/:id", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.RemoveCurrentMerchantMember)
merchant.POST("/members/:id/phone", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.BindCurrentMerchantMemberPhone)
merchant.POST("/members/:id/device/reset", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.ResetCurrentMerchantMemberDevice)
merchant.GET("/roles", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.ListRoles)
merchant.POST("/roles", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.CreateRole)
merchant.PATCH("/roles/:id", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.UpdateRole)
+115 -6
View File
@@ -15,10 +15,28 @@ type AuthService struct {
db *gorm.DB
jwt *jwt.Manager
tenant *TenantService
sms *LoginSMSService
device *DCAuthService
}
func NewAuthService(db *gorm.DB, jm *jwt.Manager, tenant *TenantService) *AuthService {
return &AuthService{db: db, jwt: jm, tenant: tenant}
type AuthServiceOption func(*AuthService)
// WithLoginSecurity 注入短信与设备认证依赖;未注入时仅保留旧密码登录。
func WithLoginSecurity(sms *LoginSMSService, device *DCAuthService) AuthServiceOption {
return func(s *AuthService) {
s.sms = sms
s.device = device
}
}
func NewAuthService(db *gorm.DB, jm *jwt.Manager, tenant *TenantService, opts ...AuthServiceOption) *AuthService {
svc := &AuthService{db: db, jwt: jm, tenant: tenant}
for _, opt := range opts {
if opt != nil {
opt(svc)
}
}
return svc
}
type LoginResult struct {
@@ -34,24 +52,115 @@ type UpdateCurrentAccountInput struct {
}
func (s *AuthService) Login(username, password string) (*LoginResult, error) {
user, err := s.authenticatePassword(username, password)
if err != nil {
return nil, err
}
// 已绑定手机号的账号必须走安全登录,避免短信 + 设备绑定被旧入口绕过。
if strings.TrimSpace(user.Phone) != "" {
return nil, newAuthError(403, "device_auth_required", "该账号已启用安全登录,请使用账号密码 + 短信验证码 + 本机验证")
}
return s.issueLogin(user)
}
// authenticatePassword 统一处理账号状态与 bcrypt 校验。
func (s *AuthService) authenticatePassword(username, password string) (*model.User, error) {
username = strings.TrimSpace(username)
if username == "" || password == "" {
return nil, newAuthError(400, "credentials_required", "请输入用户名和密码")
}
var user model.User
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("用户名或密码错误")
return nil, newAuthError(401, "login_failed", "用户名或密码错误")
}
return nil, err
}
if user.Status != 1 {
return nil, errors.New("账号已禁用")
return nil, newAuthError(403, "account_disabled", "账号已禁用")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("用户名或密码错误")
return nil, newAuthError(401, "login_failed", "用户名或密码错误")
}
return &user, nil
}
func (s *AuthService) issueLogin(user *model.User) (*LoginResult, error) {
token, err := s.jwt.Generate(user.ID, user.Username, user.Role)
if err != nil {
return nil, err
}
return &LoginResult{Token: token, User: &user}, nil
return &LoginResult{Token: token, User: user}, nil
}
// SendLoginSMSCode 校验密码后向账号绑定手机号发送验证码。
func (s *AuthService) SendLoginSMSCode(username, password, requestIP string) (*SendLoginCodeResult, error) {
if s.sms == nil {
return nil, newAuthError(503, "sms_unavailable", "短信服务未配置")
}
user, err := s.authenticatePassword(username, password)
if err != nil {
return nil, err
}
if strings.TrimSpace(user.Phone) == "" {
return nil, newAuthError(409, "phone_unbound", "该账号尚未绑定手机号,请联系管理员")
}
return s.sms.SendLoginCode(user, requestIP)
}
// PrepareLogin 校验密码与短信验证码后创建 DCAuth challenge。
func (s *AuthService) PrepareLogin(username, password, smsCode, apiBase string) (*PreparedDeviceLogin, error) {
if s.sms == nil || s.device == nil {
return nil, newAuthError(503, "device_auth_unavailable", "安全登录服务未配置")
}
user, err := s.authenticatePassword(username, password)
if err != nil {
return nil, err
}
if strings.TrimSpace(user.Phone) == "" {
return nil, newAuthError(409, "phone_unbound", "该账号尚未绑定手机号,请联系管理员")
}
if err := s.sms.VerifyLoginCode(user.ID, smsCode); err != nil {
return nil, err
}
prepared, err := s.device.CreateChallenge(user.ID, apiBase)
if err != nil {
return nil, err
}
prepared.PhoneMasked = MaskPhone(user.Phone)
return prepared, nil
}
// CompleteLogin 消费一次性 ticket、校验设备绑定并复用现有 JWT 签发会话。
func (s *AuthService) CompleteLogin(challengeID, pollToken, deviceTicket string) (*LoginResult, error) {
if s.device == nil {
return nil, newAuthError(503, "device_auth_unavailable", "安全登录服务未配置")
}
challenge, err := s.device.ConsumeTicket(challengeID, pollToken, deviceTicket)
if err != nil {
return nil, err
}
if challenge.App != DCAuthApp || challenge.Purpose != DCAuthPurposeLogin ||
challenge.DeviceID == "" || challenge.PublicKey == "" {
return nil, newAuthError(401, "ticket_invalid", "设备认证票据无效或已使用,请重新登录")
}
if challenge.UserID == nil {
return nil, newAuthError(401, "auth_user_invalid", "登录账号已不可用,请重新登录")
}
var user model.User
if err := s.db.First(&user, *challenge.UserID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, newAuthError(401, "auth_user_invalid", "登录账号已不可用,请重新登录")
}
return nil, err
}
if user.Status != 1 {
return nil, newAuthError(401, "auth_user_invalid", "登录账号已不可用,请重新登录")
}
if err := s.device.EnsureDeviceBinding(user.ID, challenge); err != nil {
return nil, err
}
return s.issueLogin(&user)
}
func (s *AuthService) GetProfile(userID uint) (*model.User, error) {
+442
View File
@@ -0,0 +1,442 @@
package service
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"os"
"strings"
"testing"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/jwt"
"affiliate_dash/internal/testdb"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestBuildDCAuthCanonicalExact(t *testing.T) {
got := BuildDCAuthCanonical(
"affiliate_dash",
"challenge-id-1",
"challenge-value",
"D1-ABCDEFGH",
1700000000,
)
want := "dcauth.v2\naffiliate_dash\nchallenge-id-1\nchallenge-value\nD1-ABCDEFGH\n1700000000"
if got != want {
t.Fatalf("canonical mismatch:\nwant %q\n got %q", want, got)
}
}
func TestIsSupportedDCAuthClientVersion(t *testing.T) {
cases := []struct {
version string
want bool
}{
{"2.0.0", true},
{"2", true},
{"10.1.2", true},
{"1.9.9", false},
{"0.1.0", false},
{"v2.0.0", false},
{"", false},
}
for _, tc := range cases {
if got := IsSupportedDCAuthClientVersion(tc.version); got != tc.want {
t.Errorf("IsSupportedDCAuthClientVersion(%q) = %v, want %v", tc.version, got, tc.want)
}
}
}
func TestNormalizeAPIBase(t *testing.T) {
cases := []struct {
name string
input string
want string
wantErr bool
}{
{name: "http origin", input: "http://localhost:15173", want: "http://localhost:15173"},
{name: "https with trailing slash", input: "https://admin.example.com/", want: "https://admin.example.com"},
{name: "case normalization", input: "HTTPS://Admin.Example.com", want: "https://admin.example.com"},
{name: "path rejected", input: "https://admin.example.com/api", wantErr: true},
{name: "query rejected", input: "https://admin.example.com?x=1", wantErr: true},
{name: "scheme rejected", input: "ftp://admin.example.com", wantErr: true},
{name: "empty rejected", input: " ", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := NormalizeAPIBase(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
func TestDCAuthChallengeSingleConsumption(t *testing.T) {
db := loginAuthTestDB(t)
user := model.User{
Username: "dcauth-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""),
PasswordHash: "unused",
Role: model.RoleAdmin,
Status: 1,
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
svc := NewDCAuthService(db)
prepared, err := svc.CreateChallenge(user.ID, "http://localhost:15173")
if err != nil {
t.Fatalf("create challenge: %v", err)
}
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
deviceID := "D1-ABCDEFGH"
timestamp := time.Now().Unix()
canonical := BuildDCAuthCanonical(DCAuthApp, prepared.ChallengeID, prepared.Challenge, deviceID, timestamp)
signature := ed25519.Sign(privateKey, []byte(canonical))
if err := svc.Authorize(DCAuthorizeRequest{
App: DCAuthApp,
ChallengeID: prepared.ChallengeID,
Challenge: prepared.Challenge,
DeviceID: deviceID,
PublicKey: base64.RawURLEncoding.EncodeToString(publicKey),
Signature: base64.RawURLEncoding.EncodeToString(signature),
Timestamp: timestamp,
ClientVersion: "2.0.0",
}); err != nil {
t.Fatalf("authorize: %v", err)
}
status, err := svc.ChallengeStatus(prepared.ChallengeID, prepared.PollToken)
if err != nil {
t.Fatalf("challenge status: %v", err)
}
if status.Status != model.DeviceChallengeStatusAuthorized || status.DeviceTicket == "" {
t.Fatalf("expected authorized ticket, got %+v", status)
}
consumed, err := svc.ConsumeTicket(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket)
if err != nil {
t.Fatalf("consume ticket: %v", err)
}
if consumed.Status != model.DeviceChallengeStatusConsumed || consumed.DeviceID != deviceID {
t.Fatalf("unexpected consumed challenge: %+v", consumed)
}
if _, err := svc.ConsumeTicket(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket); err == nil {
t.Fatal("second consumption must fail")
} else if authErr, ok := AsAuthError(err); !ok || authErr.Status != 401 {
t.Fatalf("expected 401 on second consumption, got %v", err)
}
}
func TestDCAuthBindingMismatchRejected(t *testing.T) {
db := loginAuthTestDB(t)
user := model.User{
Username: "bind-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""),
PasswordHash: "unused",
Role: model.RoleAdmin,
Status: 1,
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
boundKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate bound key: %v", err)
}
otherKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate other key: %v", err)
}
now := time.Now()
if err := db.Create(&model.UserDevice{
UserID: user.ID,
DeviceID: "D1-BOUNDDEV",
PublicKey: base64.RawURLEncoding.EncodeToString(boundKey),
ClientVersion: "2.0.0",
Status: model.UserDeviceStatusActive,
BoundAt: now,
CreatedAt: now,
UpdatedAt: now,
}).Error; err != nil {
t.Fatalf("create bound device: %v", err)
}
svc := NewDCAuthService(db)
err = svc.EnsureDeviceBinding(user.ID, &model.DeviceChallenge{
DeviceID: "D1-OTHERDEV",
PublicKey: base64.RawURLEncoding.EncodeToString(otherKey),
ClientVersion: "2.0.0",
})
if err == nil {
t.Fatal("device mismatch must be rejected")
}
authErr, ok := AsAuthError(err)
if !ok || authErr.Status != 401 {
t.Fatalf("expected 401 AuthError, got %v", err)
}
if !strings.Contains(authErr.Message, "当前设备与账号绑定设备不一致") {
t.Fatalf("unexpected message: %s", authErr.Message)
}
// 同一设备与公钥应通过。
if err := svc.EnsureDeviceBinding(user.ID, &model.DeviceChallenge{
DeviceID: "D1-BOUNDDEV",
PublicKey: base64.RawURLEncoding.EncodeToString(boundKey),
ClientVersion: "2.0.0",
}); err != nil {
t.Fatalf("matching device should pass: %v", err)
}
}
func TestSecureLoginPrepareCompleteReusesJWT(t *testing.T) {
db := loginAuthTestDB(t)
password := "pass123456"
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
user := model.User{
Username: "secure-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""),
PasswordHash: string(hash),
Phone: "13900139000",
Role: model.RoleAdmin,
Status: 1,
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
now := time.Now()
if err := db.Create(&model.LoginSMSCode{
UserID: user.ID,
Phone: user.Phone,
Purpose: model.LoginSMSPurposeLogin,
CodeHash: HashSMSCode("123456"),
ExpiresAt: now.Add(5 * time.Minute),
CreatedAt: now,
}).Error; err != nil {
t.Fatalf("create sms code: %v", err)
}
jm := jwt.NewManager("test-jwt-secret")
smsSvc := NewLoginSMSService(db, SMSConfig{Provider: "mock"})
deviceSvc := NewDCAuthService(db)
authSvc := NewAuthService(db, jm, nil, WithLoginSecurity(smsSvc, deviceSvc))
prepared, err := authSvc.PrepareLogin(user.Username, password, "123456", "http://localhost:15173")
if err != nil {
t.Fatalf("prepare login: %v", err)
}
if prepared.App != DCAuthApp || prepared.PhoneMasked != "139****9000" {
t.Fatalf("unexpected prepared result: %+v", prepared)
}
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
deviceID := "D1-SECUREDEV"
timestamp := time.Now().Unix()
canonical := BuildDCAuthCanonical(DCAuthApp, prepared.ChallengeID, prepared.Challenge, deviceID, timestamp)
if err := deviceSvc.Authorize(DCAuthorizeRequest{
App: DCAuthApp,
ChallengeID: prepared.ChallengeID,
Challenge: prepared.Challenge,
DeviceID: deviceID,
PublicKey: base64.RawURLEncoding.EncodeToString(publicKey),
Signature: base64.RawURLEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(canonical))),
Timestamp: timestamp,
ClientVersion: "2.0.0",
}); err != nil {
t.Fatalf("authorize: %v", err)
}
status, err := deviceSvc.ChallengeStatus(prepared.ChallengeID, prepared.PollToken)
if err != nil || status.DeviceTicket == "" {
t.Fatalf("challenge status: %+v %v", status, err)
}
result, err := authSvc.CompleteLogin(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket)
if err != nil {
t.Fatalf("complete login: %v", err)
}
if result.Token == "" || result.User == nil || result.User.ID != user.ID {
t.Fatalf("unexpected login result: %+v", result)
}
claims, err := jm.Parse(result.Token)
if err != nil || claims.UserID != user.ID {
t.Fatalf("issued JWT should be reusable: %+v %v", claims, err)
}
var device model.UserDevice
if err := db.Where("user_id = ? AND status = ?", user.ID, model.UserDeviceStatusActive).First(&device).Error; err != nil {
t.Fatalf("device should be auto-bound: %v", err)
}
if device.DeviceID != deviceID {
t.Fatalf("unexpected bound device: %+v", device)
}
if _, err := authSvc.CompleteLogin(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket); err == nil {
t.Fatal("ticket must be single-use")
}
if _, err := authSvc.Login(user.Username, password); err == nil {
t.Fatal("password-only login must be rejected once phone is bound")
} else if authErr, ok := AsAuthError(err); !ok || authErr.Status != 403 {
t.Fatalf("expected 403 for bound account legacy login, got %v", err)
}
}
func TestBindUserPhoneAndResetDevice(t *testing.T) {
db := loginAuthTestDB(t)
first := model.User{Username: "bindphone-a-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), PasswordHash: "x", Role: model.RoleAdmin, Status: 1}
second := model.User{Username: "bindphone-b-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), PasswordHash: "x", Role: model.RoleAdmin, Status: 1}
if err := db.Create(&first).Error; err != nil {
t.Fatalf("create first user: %v", err)
}
if err := db.Create(&second).Error; err != nil {
t.Fatalf("create second user: %v", err)
}
actor := first.ID
svc := NewUserService(db, nil)
updated, err := svc.BindUserPhone(first.ID, "13700137000", actor)
if err != nil {
t.Fatalf("bind phone: %v", err)
}
if updated.Phone != "13700137000" || updated.PhoneBoundBy == nil || *updated.PhoneBoundBy != actor {
t.Fatalf("unexpected bound user: %+v", updated)
}
if _, err := svc.BindUserPhone(second.ID, "13700137000", actor); err == nil {
t.Fatal("duplicate phone must be rejected")
} else if authErr, ok := AsAuthError(err); !ok || authErr.Status != 409 {
t.Fatalf("expected 409 duplicate phone, got %v", err)
}
now := time.Now()
device := model.UserDevice{
UserID: first.ID,
DeviceID: "D1-RESETDEV",
PublicKey: "test-public-key",
ClientVersion: "2.0.0",
Status: model.UserDeviceStatusActive,
BoundAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(&device).Error; err != nil {
t.Fatalf("create device: %v", err)
}
reset, err := svc.ResetUserDevice(first.ID, actor)
if err != nil || !reset {
t.Fatalf("reset device: reset=%v err=%v", reset, err)
}
var stored model.UserDevice
if err := db.First(&stored, device.ID).Error; err != nil {
t.Fatalf("reload device: %v", err)
}
if stored.Status != model.UserDeviceStatusRevoked || stored.RevokedAt == nil {
t.Fatalf("device should be revoked: %+v", stored)
}
if reset, err := svc.ResetUserDevice(first.ID, actor); err != nil || reset {
t.Fatalf("second reset should be a no-op: reset=%v err=%v", reset, err)
}
}
func TestLoginSMSCodeLocksAfterFiveFailures(t *testing.T) {
db := loginAuthTestDB(t)
user := model.User{
Username: "sms-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""),
PasswordHash: "unused",
Phone: "13800138000",
Role: model.RoleAdmin,
Status: 1,
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
now := time.Now()
row := model.LoginSMSCode{
UserID: user.ID,
Phone: user.Phone,
Purpose: model.LoginSMSPurposeLogin,
CodeHash: HashSMSCode("123456"),
ExpiresAt: now.Add(5 * time.Minute),
CreatedAt: now,
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("create sms code: %v", err)
}
svc := NewLoginSMSService(db, SMSConfig{})
for attempt := 1; attempt <= 4; attempt++ {
err := svc.VerifyLoginCode(user.ID, "000000")
authErr, ok := AsAuthError(err)
if !ok || authErr.Code != "sms_code_invalid" {
t.Fatalf("attempt %d: expected sms_code_invalid, got %v", attempt, err)
}
}
err := svc.VerifyLoginCode(user.ID, "000000")
authErr, ok := AsAuthError(err)
if !ok || authErr.Code != "sms_code_locked" {
t.Fatalf("fifth attempt should lock the code, got %v", err)
}
var stored model.LoginSMSCode
if err := db.First(&stored, row.ID).Error; err != nil {
t.Fatalf("reload sms code: %v", err)
}
if stored.FailedAttemptCount != 5 || stored.ConsumedAt == nil {
t.Fatalf("expected locked row with 5 failures, got %+v", stored)
}
if err := svc.VerifyLoginCode(user.ID, "123456"); err == nil {
t.Fatal("correct code must not work after lock")
}
}
// loginAuthTestDB 在 PostgreSQL 可用时创建隔离 schema;不可用时跳过集成断言。
func loginAuthTestDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
dsn = os.Getenv("DATABASE_URL")
}
if dsn == "" {
dsn = "postgres://affiliate:affiliate_dev_password@127.0.0.1:15432/affiliate_dash?sslmode=disable"
}
probe, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Skipf("跳过数据库集成测试:PostgreSQL 不可用: %v", err)
}
sqlDB, err := probe.DB()
if err != nil {
t.Skipf("跳过数据库集成测试:获取连接失败: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
_ = sqlDB.Close()
t.Skipf("跳过数据库集成测试:PostgreSQL ping 失败: %v", err)
}
_ = sqlDB.Close()
return testdb.New(t)
}
@@ -0,0 +1,507 @@
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
}
@@ -0,0 +1,349 @@
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
}
+135 -3
View File
@@ -24,6 +24,9 @@ type AddMemberInput struct {
}
type UpdateMemberInput struct {
Username string
Password string
Nickname *string
Role string
Status int
IsDefault bool
@@ -227,8 +230,19 @@ func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUse
func (s *MerchantService) ListMembers(merchantID uint) ([]model.MerchantMember, error) {
var members []model.MerchantMember
err := s.db.Preload("User").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&members).Error
return members, err
if err := s.db.Preload("User").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&members).Error; err != nil {
return nil, err
}
// 成员列表和平台用户列表共用一套安全字段(手机号掩码 / 已绑定设备),
// 否则「员工管理-成员」看不到最新的绑定状态。
users := make([]*model.User, 0, len(members))
for index := range members {
if members[index].User != nil {
users = append(users, members[index].User)
}
}
NewUserService(s.db, s.tenant).enrichLoginSecurity(users)
return members, nil
}
// UpdateMember updates a member's relationship with one merchant without changing the login account.
@@ -257,11 +271,68 @@ func (s *MerchantService) UpdateMember(merchantID, memberID uint, in UpdateMembe
if _, err := (&TenantService{db: tx}).MerchantRolePermissions(merchantID, in.Role); err != nil {
return err
}
username := strings.TrimSpace(in.Username)
if username != "" {
if len(username) < 3 {
return errors.New("员工用户名至少 3 位")
}
var user model.User
if err := tx.First(&user, member.UserID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("成员账号不存在")
}
return err
}
if user.Username != username {
var count int64
if err := tx.Model(&model.User{}).
Where("username = ? AND id <> ?", username, user.ID).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("员工用户名已存在")
}
if err := tx.Model(&user).Update("username", username).Error; err != nil {
return errors.New("员工用户名已存在")
}
}
}
password := strings.TrimSpace(in.Password)
if password != "" {
if len(password) < 6 {
return errors.New("员工密码至少 6 位")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
if err := tx.Model(&model.User{}).
Where("id = ?", member.UserID).
Update("password_hash", string(hash)).Error; err != nil {
return err
}
}
if in.Nickname != nil {
nickname := strings.TrimSpace(*in.Nickname)
if err := tx.Model(&model.User{}).
Where("id = ?", member.UserID).
Update("nickname", nickname).Error; err != nil {
return err
}
}
updates := map[string]interface{}{"role": in.Role, "status": in.Status, "is_default": in.IsDefault}
if err := tx.Model(&member).Updates(updates).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.update", "merchant_member", fmt.Sprint(memberID), map[string]string{"role": in.Role, "status": fmt.Sprint(in.Status)})
metadata := map[string]string{"role": in.Role, "status": fmt.Sprint(in.Status), "username": username}
if password != "" {
metadata["password_reset"] = "true"
}
if in.Nickname != nil {
metadata["nickname"] = strings.TrimSpace(*in.Nickname)
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.update", "merchant_member", fmt.Sprint(memberID), metadata)
})
if err != nil {
return nil, err
@@ -291,6 +362,23 @@ func (s *MerchantService) RemoveMember(merchantID, memberID, actorUserID uint) e
if err := tx.Delete(&member).Error; err != nil {
return err
}
// 该账号不再属于任何商户时,软删除账号以释放用户名:
// users.username 的唯一索引只约束未删除账号,保留账号会让这个名字永远建不回来。
var remaining int64
if err := tx.Model(&model.MerchantMember{}).Where("user_id = ?", member.UserID).Count(&remaining).Error; err != nil {
return err
}
if remaining == 0 {
var user model.User
if err := tx.First(&user, member.UserID).Error; err == nil && user.Role == model.RoleMerchant {
if err := releaseUserIdentity(tx, user.ID); err != nil {
return err
}
if err := tx.Delete(&user).Error; err != nil {
return err
}
}
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.remove", "merchant_member", fmt.Sprint(memberID), nil)
})
}
@@ -445,3 +533,47 @@ func (s *MerchantService) UpdateMerchantOwnerAccount(merchantID uint, in UpdateM
}
return &out, nil
}
// findMember 读取当前商户下的成员关系,避免跨商户操作他人账号。
func (s *MerchantService) findMember(merchantID, memberID uint) (*model.MerchantMember, error) {
var member model.MerchantMember
if err := s.db.Where("id = ? AND merchant_id = ?", memberID, merchantID).First(&member).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("成员不存在")
}
return nil, err
}
return &member, nil
}
// SetMemberPhone 为当前商户的成员绑定/修改登录手机号。
// 负责人账号只能由本人或平台管理员维护,避免被其他成员改绑手机号后接管账号。
func (s *MerchantService) SetMemberPhone(merchantID, memberID uint, phoneValue string, actorUserID uint) (*model.User, error) {
member, err := s.findMember(merchantID, memberID)
if err != nil {
return nil, err
}
if member.Role == model.MemberRoleOwner && member.UserID != actorUserID {
return nil, errors.New("负责人账号的手机号请由平台管理员维护")
}
if strings.TrimSpace(phoneValue) == "" {
return nil, newAuthError(400, "phone_required", "请输入手机号")
}
phone, err := NormalizePhone(phoneValue)
if err != nil {
return nil, err
}
return NewUserService(s.db, s.tenant).BindUserPhone(member.UserID, phone, actorUserID)
}
// ResetMemberDevice 重置当前商户成员绑定的 DCAuth 设备。
func (s *MerchantService) ResetMemberDevice(merchantID, memberID uint, actorUserID uint) (bool, error) {
member, err := s.findMember(merchantID, memberID)
if err != nil {
return false, err
}
if member.Role == model.MemberRoleOwner && member.UserID != actorUserID {
return false, errors.New("负责人账号的设备绑定请由平台管理员维护")
}
return NewUserService(s.db, s.tenant).ResetUserDevice(member.UserID, actorUserID)
}
@@ -0,0 +1,353 @@
package service
import (
"testing"
"time"
"affiliate_dash/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func TestSetMemberPhoneIsMerchantScopedAndProtectsOwner(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantA, ownerAUserID := seedMerchantWithOwner(t, db, "scope-a", "scope-owner-a", 1)
merchantB, _ := seedMerchantWithOwner(t, db, "scope-b", "scope-owner-b", 1)
staff := model.User{Username: "scope-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
staffMember := model.MerchantMember{MerchantID: merchantA, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
if err := db.Create(&staffMember).Error; err != nil {
t.Fatalf("create staff member: %v", err)
}
// 本商户内可以正常绑定。
if _, err := svc.SetMemberPhone(merchantA, staffMember.ID, "13800001111", ownerAUserID); err != nil {
t.Fatalf("bind phone in own merchant: %v", err)
}
var loaded model.User
if err := db.First(&loaded, staff.ID).Error; err != nil {
t.Fatalf("reload staff: %v", err)
}
if loaded.Phone != "13800001111" {
t.Fatalf("phone not persisted: %q", loaded.Phone)
}
// 跨商户不能操作别人的成员。
if _, err := svc.SetMemberPhone(merchantB, staffMember.ID, "13800002222", ownerAUserID); err == nil {
t.Fatal("binding a member through another merchant must be rejected")
}
// 负责人账号不能被普通成员改绑手机号。
var ownerMember model.MerchantMember
if err := db.Where("merchant_id = ? AND user_id = ?", merchantA, ownerAUserID).First(&ownerMember).Error; err != nil {
t.Fatalf("load owner member: %v", err)
}
if _, err := svc.SetMemberPhone(merchantA, ownerMember.ID, "13800003333", staff.ID); err == nil {
t.Fatal("a staff member must not rebind the owner's phone")
}
// 成员列表要带上手机号掩码。
members, err := svc.ListMembers(merchantA)
if err != nil {
t.Fatalf("list members: %v", err)
}
found := false
for _, member := range members {
if member.UserID != staff.ID {
continue
}
found = true
if member.User == nil || member.User.PhoneMasked != "138****1111" {
t.Fatalf("phone mask missing in member list: %+v", member.User)
}
}
if !found {
t.Fatal("staff member missing from list")
}
}
func TestResetMemberDeviceRevokesActiveDevice(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "device-scope", "device-owner", 1)
staff := model.User{Username: "device-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
if err := db.Create(&member).Error; err != nil {
t.Fatalf("create member: %v", err)
}
now := time.Now()
device := model.UserDevice{
UserID: staff.ID,
DeviceID: "D1-AAAA-BBBB-CCCC-DDDD",
PublicKey: "pk",
ClientVersion: "2.0.0",
Status: model.UserDeviceStatusActive,
BoundAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(&device).Error; err != nil {
t.Fatalf("create device: %v", err)
}
// 其他商户不能重置。
otherMerchantID, _ := seedMerchantWithOwner(t, db, "device-other", "device-other-owner", 1)
if _, err := svc.ResetMemberDevice(otherMerchantID, member.ID, ownerUserID); err == nil {
t.Fatal("resetting a member through another merchant must be rejected")
}
reset, err := svc.ResetMemberDevice(merchantID, member.ID, ownerUserID)
if err != nil {
t.Fatalf("reset device: %v", err)
}
if !reset {
t.Fatal("expected an active device to be revoked")
}
var reloaded model.UserDevice
if err := db.First(&reloaded, device.ID).Error; err != nil {
t.Fatalf("reload device: %v", err)
}
if reloaded.Status != model.UserDeviceStatusRevoked {
t.Fatalf("device status = %q, want revoked", reloaded.Status)
}
if reloaded.RevokedBy == nil || *reloaded.RevokedBy != ownerUserID {
t.Fatalf("revoked_by = %v, want %d", reloaded.RevokedBy, ownerUserID)
}
}
func TestSetMemberPhoneRejectsInvalidPhone(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "phone-check", "phone-check-owner", 1)
staff := model.User{Username: "phone-check-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
if err := db.Create(&member).Error; err != nil {
t.Fatalf("create member: %v", err)
}
if _, err := svc.SetMemberPhone(merchantID, member.ID, "12345", ownerUserID); err == nil {
t.Fatal("invalid phone must be rejected")
}
if _, err := svc.SetMemberPhone(merchantID, member.ID, " ", ownerUserID); err == nil {
t.Fatal("empty phone must be rejected")
}
}
func seedOperatorRole(t *testing.T, db *gorm.DB, merchantID uint) {
t.Helper()
if err := db.Create(&model.MerchantRole{
MerchantID: merchantID, Code: model.MemberRoleOperator, Name: "运营",
Permissions: model.PermissionOrdersManage, Status: 1,
}).Error; err != nil {
t.Fatalf("create operator role: %v", err)
}
}
func TestUpdateMemberCanRenameAccountAndReleaseOldName(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "rename-merchant", "rename-owner", 1)
seedOperatorRole(t, db, merchantID)
staff := model.User{Username: "old-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
if err := db.Create(&member).Error; err != nil {
t.Fatalf("create member: %v", err)
}
updated, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
Username: "new-staff", Role: model.MemberRoleOperator, Status: 1,
}, ownerUserID)
if err != nil {
t.Fatalf("rename member: %v", err)
}
if updated.User == nil || updated.User.Username != "new-staff" {
t.Fatalf("unexpected member after rename: %+v", updated.User)
}
// 改名后旧用户名应立即释放。
created, err := svc.AddMember(merchantID, AddMemberInput{
Username: "old-staff", Password: "secret1", Role: model.MemberRoleOperator,
}, ownerUserID)
if err != nil {
t.Fatalf("old username should be reusable: %v", err)
}
if created.User == nil || created.User.Username != "old-staff" {
t.Fatalf("unexpected recreated member: %+v", created.User)
}
}
func TestRemoveMemberReleasesUsernamePhoneAndDevice(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "release-merchant", "release-owner", 1)
seedOperatorRole(t, db, merchantID)
staff := model.User{Username: "release-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1, Phone: "13800009999"}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
if err := db.Create(&member).Error; err != nil {
t.Fatalf("create member: %v", err)
}
now := time.Now()
device := model.UserDevice{
UserID: staff.ID, DeviceID: "D1-RELE-ASE0-0000-0001", PublicKey: "pk",
ClientVersion: "2.0.0", Status: model.UserDeviceStatusActive,
BoundAt: now, CreatedAt: now, UpdatedAt: now,
}
if err := db.Create(&device).Error; err != nil {
t.Fatalf("create device: %v", err)
}
if err := svc.RemoveMember(merchantID, member.ID, ownerUserID); err != nil {
t.Fatalf("remove member: %v", err)
}
var oldUser model.User
if err := db.Unscoped().First(&oldUser, staff.ID).Error; err != nil {
t.Fatalf("reload removed user: %v", err)
}
if !oldUser.DeletedAt.Valid {
t.Fatal("account without memberships should be soft-deleted")
}
if oldUser.Phone != "" {
t.Fatalf("phone should be released, got %q", oldUser.Phone)
}
created, err := svc.AddMember(merchantID, AddMemberInput{
Username: "release-staff", Password: "secret1", Role: model.MemberRoleOperator,
}, ownerUserID)
if err != nil {
t.Fatalf("username should be reusable after removal: %v", err)
}
if _, err := svc.SetMemberPhone(merchantID, created.ID, "13800009999", ownerUserID); err != nil {
t.Fatalf("phone should be reusable after removal: %v", err)
}
var reloaded model.UserDevice
if err := db.First(&reloaded, device.ID).Error; err != nil {
t.Fatalf("reload device: %v", err)
}
if reloaded.Status != model.UserDeviceStatusRevoked {
t.Fatalf("device should be revoked on removal, got %q", reloaded.Status)
}
}
func TestUpdateMemberCanResetPassword(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "password-merchant", "password-owner", 1)
seedOperatorRole(t, db, merchantID)
oldHash, err := bcrypt.GenerateFromPassword([]byte("oldpass123"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("hash old password: %v", err)
}
staff := model.User{Username: "password-staff", PasswordHash: string(oldHash), Role: model.RoleMerchant, Status: 1}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
if err := db.Create(&member).Error; err != nil {
t.Fatalf("create member: %v", err)
}
if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
Username: "password-staff", Password: "newpass123", Role: model.MemberRoleOperator, Status: 1,
}, ownerUserID); err != nil {
t.Fatalf("reset password: %v", err)
}
var user model.User
if err := db.First(&user, staff.ID).Error; err != nil {
t.Fatalf("reload staff: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("newpass123")); err != nil {
t.Fatalf("new password should work: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("oldpass123")); err == nil {
t.Fatal("old password should no longer work")
}
// 留空表示不修改。
before := user.PasswordHash
if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
Username: "password-staff", Role: model.MemberRoleOperator, Status: 1,
}, ownerUserID); err != nil {
t.Fatalf("update without password: %v", err)
}
if err := db.First(&user, staff.ID).Error; err != nil {
t.Fatalf("reload staff: %v", err)
}
if user.PasswordHash != before {
t.Fatal("empty password must keep the current hash")
}
// 过短密码拒绝。
if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
Username: "password-staff", Password: "123", Role: model.MemberRoleOperator, Status: 1,
}, ownerUserID); err == nil {
t.Fatal("short password must be rejected")
}
}
func TestUpdateMemberCanChangeNickname(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "nickname-merchant", "nickname-owner", 1)
seedOperatorRole(t, db, merchantID)
staff := model.User{Username: "nickname-staff", PasswordHash: "h", Nickname: "旧昵称", Role: model.RoleMerchant, Status: 1}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
if err := db.Create(&member).Error; err != nil {
t.Fatalf("create member: %v", err)
}
nickname := " 新昵称 "
updated, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
Username: "nickname-staff", Nickname: &nickname, Role: model.MemberRoleOperator, Status: 1,
}, ownerUserID)
if err != nil {
t.Fatalf("change nickname: %v", err)
}
if updated.User == nil || updated.User.Nickname != "新昵称" {
t.Fatalf("unexpected nickname after update: %+v", updated.User)
}
// 不传昵称时保持原值。
if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
Username: "nickname-staff", Role: model.MemberRoleOperator, Status: 1,
}, ownerUserID); err != nil {
t.Fatalf("update without nickname: %v", err)
}
var user model.User
if err := db.First(&user, staff.ID).Error; err != nil {
t.Fatalf("reload staff: %v", err)
}
if user.Nickname != "新昵称" {
t.Fatalf("nickname should be kept, got %q", user.Nickname)
}
}
+18 -12
View File
@@ -267,7 +267,7 @@ func TestDefaultMerchantRolesCanBeEdited(t *testing.T) {
}
}
func TestMemberCanBeUpdatedOrRemovedWithoutDeletingAccount(t *testing.T) {
func TestMemberCanBeUpdatedOrRemovedWithAccountRetired(t *testing.T) {
db := newServiceTestDB(t)
merchant := model.Merchant{Code: "member-crud", Name: "成员管理商户", Status: model.MerchantStatusActive}
actor := model.User{Username: "member-crud-owner", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1}
@@ -303,8 +303,11 @@ func TestMemberCanBeUpdatedOrRemovedWithoutDeletingAccount(t *testing.T) {
t.Fatalf("remove member: %v", err)
}
var user model.User
if err := db.First(&user, employee.ID).Error; err != nil {
t.Fatalf("employee account should remain: %v", err)
if err := db.Unscoped().First(&user, employee.ID).Error; err != nil {
t.Fatalf("employee account row should be retained for audit: %v", err)
}
if !user.DeletedAt.Valid {
t.Fatal("employee account should be soft-deleted after the last membership is removed")
}
var count int64
if err := db.Model(&model.MerchantMember{}).Where("id = ?", member.ID).Count(&count).Error; err != nil || count != 0 {
@@ -532,9 +535,9 @@ func TestUsernameCanBeReusedAfterMerchantAccountSoftDelete(t *testing.T) {
}
}
// TestRemoveMemberDoesNotReleaseUsername 固化行为:商户侧"移除成员"只删除成员关系,
// 登录账号(含用户名)保留,同名用户名仍被占用,无法新建。
func TestRemoveMemberDoesNotReleaseUsername(t *testing.T) {
// TestRemoveMemberReleasesUsername 固化新行为:移除最后一个商户关系后账号软删除,
// 用户名立即释放,可以用同名账号重新建员工。
func TestRemoveMemberReleasesUsername(t *testing.T) {
db := newServiceTestDB(t)
merchant := model.Merchant{Code: "remove-member-keep", Name: "移除成员保留账号", Status: model.MerchantStatusActive}
actor := model.User{Username: "remove-member-actor", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1}
@@ -560,13 +563,16 @@ func TestRemoveMemberDoesNotReleaseUsername(t *testing.T) {
t.Fatalf("remove member: %v", err)
}
// 登录账号仍在(未被删除),用户名继续占用。
var kept model.User
if err := db.First(&kept, staff.ID).Error; err != nil {
t.Fatalf("login account should be retained after member removal: %v", err)
// 账号已软删除(保留行供审计),用户名立即释放。
var released model.User
if err := db.Unscoped().First(&released, staff.ID).Error; err != nil {
t.Fatalf("account row should be retained for audit: %v", err)
}
if !released.DeletedAt.Valid {
t.Fatal("account should be soft-deleted after member removal")
}
userSvc := NewUserService(db, nil)
if _, err := userSvc.Create("kf-2", "password123", "新员工", model.RoleMerchant, 0); err == nil {
t.Fatalf("username should still be occupied after member removal")
if _, err := userSvc.Create("kf-2", "password123", "新员工", model.RoleMerchant, 0); err != nil {
t.Fatalf("username should be reusable after member removal: %v", err)
}
}
+176 -1
View File
@@ -2,6 +2,9 @@ package service
import (
"errors"
"fmt"
"strings"
"time"
"affiliate_dash/internal/model"
@@ -142,7 +145,16 @@ type UpdatePlatformAdminInput struct {
}
func (s *UserService) ListPlatformAdmins(page, size int) ([]model.User, int64, error) {
return s.List(UserListQuery{Page: page, Size: size, Role: model.RoleAdmin})
list, total, err := s.List(UserListQuery{Page: page, Size: size, Role: model.RoleAdmin})
if err != nil {
return nil, 0, err
}
pointers := make([]*model.User, 0, len(list))
for i := range list {
pointers = append(pointers, &list[i])
}
s.enrichLoginSecurity(pointers)
return list, total, nil
}
// ListMerchantAccountGroups returns merchant employee accounts grouped by
@@ -191,6 +203,13 @@ func (s *UserService) ListMerchantAccountGroups(page, size int) ([]MerchantMembe
}
group.Members = append(group.Members, member)
}
pointers := make([]*model.User, 0, len(members))
for i := range members {
if members[i].User != nil {
pointers = append(pointers, members[i].User)
}
}
s.enrichLoginSecurity(pointers)
return groups, total, nil
}
@@ -317,6 +336,22 @@ func (s *UserService) DeletePlatformAdmin(id, actorUserID uint) error {
})
}
// releaseUserIdentity 在账号软删除前释放手机号与设备绑定。
// users.phone 的唯一索引不区分软删除,不清理会让该手机号永远无法再绑定。
func releaseUserIdentity(tx *gorm.DB, userID uint) error {
now := time.Now()
if err := tx.Model(&model.UserDevice{}).
Where("user_id = ? AND status = ?", userID, model.UserDeviceStatusActive).
Updates(map[string]interface{}{
"status": model.UserDeviceStatusRevoked,
"revoked_at": now,
"updated_at": now,
}).Error; err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", userID).Update("phone", "").Error
}
// DeleteMerchantAccount permanently removes a merchant login account and all
// of its merchant memberships. Platform administrators must be managed from
// the dedicated platform administrator view instead.
@@ -341,6 +376,146 @@ func (s *UserService) DeleteMerchantAccount(id, actorUserID uint) error {
if err := tx.Where("user_id = ?", user.ID).Delete(&model.MerchantMember{}).Error; err != nil {
return err
}
if err := releaseUserIdentity(tx, user.ID); err != nil {
return err
}
return tx.Delete(&user).Error
})
}
// BindUserPhone 由管理员为账号绑定/修改登录手机号(手机号全平台唯一)。
func (s *UserService) BindUserPhone(id uint, phoneValue string, actorUserID uint) (*model.User, error) {
if id == 0 {
return nil, newAuthError(404, "user_not_found", "用户不存在")
}
phone, err := NormalizePhone(phoneValue)
if err != nil {
return nil, err
}
updated := &model.User{}
err = s.db.Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return newAuthError(404, "user_not_found", "用户不存在")
}
return err
}
if user.Phone == phone {
updated = &user
return nil
}
var count int64
if err := tx.Model(&model.User{}).
Where("phone = ? AND id <> ?", phone, user.ID).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return newAuthError(409, "phone_exists", "该手机号已绑定其他账号")
}
now := time.Now()
if err := tx.Model(&user).Updates(map[string]interface{}{
"phone": phone,
"phone_bound_at": now,
"phone_bound_by": actorUserID,
}).Error; err != nil {
return err
}
if err := writeAudit(tx, nil, &actorUserID, nil, "user.phone.bind", "user", fmt.Sprint(user.ID), map[string]interface{}{
"phone": MaskPhone(phone),
}); err != nil {
return err
}
return tx.First(updated, user.ID).Error
})
if err != nil {
return nil, err
}
s.enrichLoginSecurity([]*model.User{updated})
return updated, nil
}
// ResetUserDevice 由管理员撤销账号当前 active 的 DCAuth 设备绑定。
func (s *UserService) ResetUserDevice(id uint, actorUserID uint) (bool, error) {
if id == 0 {
return false, newAuthError(404, "user_not_found", "用户不存在")
}
var reset bool
err := s.db.Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return newAuthError(404, "user_not_found", "用户不存在")
}
return err
}
now := time.Now()
result := tx.Model(&model.UserDevice{}).
Where("user_id = ? AND status = ?", user.ID, model.UserDeviceStatusActive).
Updates(map[string]interface{}{
"status": model.UserDeviceStatusRevoked,
"revoked_at": now,
"revoked_by": actorUserID,
"updated_at": now,
})
if result.Error != nil {
return result.Error
}
reset = result.RowsAffected > 0
return writeAudit(tx, nil, &actorUserID, nil, "user.device.reset", "user", fmt.Sprint(user.ID), map[string]interface{}{
"revoked_devices": result.RowsAffected,
})
})
if err != nil {
return false, err
}
return reset, nil
}
// enrichLoginSecurity 批量补充手机号掩码与 active 设备信息,避免列表 N+1 查询。
func (s *UserService) enrichLoginSecurity(users []*model.User) {
if len(users) == 0 {
return
}
ids := make([]uint, 0, len(users))
for _, user := range users {
if user == nil {
continue
}
user.PhoneMasked = MaskPhone(user.Phone)
ids = append(ids, user.ID)
}
if len(ids) == 0 {
return
}
var devices []model.UserDevice
if err := s.db.Where("user_id IN ? AND status = ?", ids, model.UserDeviceStatusActive).Find(&devices).Error; err != nil {
return
}
byUser := make(map[uint]model.UserDevice, len(devices))
for _, device := range devices {
byUser[device.UserID] = device
}
for _, user := range users {
if user == nil {
continue
}
device, ok := byUser[user.ID]
if !ok {
continue
}
user.DeviceBound = true
user.DeviceID = device.DeviceID
user.DeviceIDMasked = MaskDeviceID(device.DeviceID)
}
}
// MaskDeviceID 返回 D1-ABC-****-1234 形式的设备码。
func MaskDeviceID(value string) string {
deviceID := strings.ToUpper(strings.TrimSpace(value))
if len(deviceID) <= 8 {
return deviceID
}
return deviceID[:6] + "-****-" + deviceID[len(deviceID)-4:]
}