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:
@@ -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 {
|
||||
|
||||
@@ -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": "设备认证服务异常"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user