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
+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})
}