@@ -0,0 +1,161 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// walletHandler 处理钱包(SIWE)签名挑战与校验接口。
|
||||
type walletHandler struct {
|
||||
svc *service.WalletService
|
||||
auth *service.AuthService
|
||||
logs *service.SystemLogService
|
||||
}
|
||||
|
||||
// walletChallengeRequest 是签名挑战请求;mode=bind 需有效 Bearer(绑定到当前账号)。
|
||||
type walletChallengeRequest struct {
|
||||
Address string `json:"address" binding:"required,max=64"`
|
||||
Mode string `json:"mode" binding:"omitempty,oneof=login bind"`
|
||||
}
|
||||
|
||||
// challenge 下发 EIP-4361 消息与一次性 nonce;前端对消息原样 personal_sign。
|
||||
//
|
||||
// @Summary 发起钱包签名挑战
|
||||
// @Tags 认证
|
||||
// @Param body body walletChallengeRequest true "钱包地址与模式(bind 需 Bearer)"
|
||||
// @Success 200 {object} walletChallengeResponse "nonce 与待签名消息"
|
||||
// @Failure 400 {object} errorResponse "地址格式不正确"
|
||||
// @Failure 401 {object} errorResponse "bind 模式未登录"
|
||||
// @Failure 409 {object} errorResponse "面板地址未设置"
|
||||
// @Router /api/v1/auth/wallet/challenge [post]
|
||||
func (h *walletHandler) challenge(c *gin.Context) {
|
||||
var req walletChallengeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
mode, username := "login", ""
|
||||
if req.Mode == "bind" {
|
||||
name, ok := (&authxHandler{auth: h.auth}).bearerUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "绑定需要先登录"})
|
||||
return
|
||||
}
|
||||
mode, username = "bind", name
|
||||
}
|
||||
nonce, message, err := h.svc.Challenge(c.Request.Context(), req.Address, mode, username, bearerToken(c))
|
||||
if err != nil {
|
||||
h.respondChallengeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"nonce": nonce, "message": message})
|
||||
}
|
||||
|
||||
// respondChallengeErr 映射挑战阶段错误。
|
||||
func (h *walletHandler) respondChallengeErr(c *gin.Context, err error) {
|
||||
if errors.Is(err, service.ErrWalletAddress) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrWalletNoAppURL) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
respondError(c, err)
|
||||
}
|
||||
|
||||
// walletVerifyRequest 是签名校验请求;signature 为 65 字节 r||s||v 的 hex。
|
||||
type walletVerifyRequest struct {
|
||||
Nonce string `json:"nonce" binding:"required,max=64"`
|
||||
Signature string `json:"signature" binding:"required,max=200"`
|
||||
}
|
||||
|
||||
// verify 校验签名:login 模式签发 JWT,bind 模式绑定后换发新令牌。
|
||||
//
|
||||
// @Summary 校验钱包签名
|
||||
// @Tags 认证
|
||||
// @Param body body walletVerifyRequest true "nonce 与签名"
|
||||
// @Success 200 {object} tokenResponse "token 与 expiresAt(bind 模式为换发的新 token)"
|
||||
// @Failure 400 {object} errorResponse "挑战无效已过期,或 bind 模式签名校验失败"
|
||||
// @Failure 401 {object} errorResponse "签名校验失败(login 模式)"
|
||||
// @Failure 403 {object} errorResponse "地址未绑定账号"
|
||||
// @Failure 409 {object} errorResponse "地址已绑定过"
|
||||
// @Failure 429 {object} errorResponse "连续失败已锁定"
|
||||
// @Router /api/v1/auth/wallet/verify [post]
|
||||
func (h *walletHandler) verify(c *gin.Context) {
|
||||
var req walletVerifyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
res, err := h.svc.Verify(c.Request.Context(), req.Nonce, req.Signature, sessionMetaOf(c))
|
||||
if err != nil {
|
||||
status := walletErrStatus(err)
|
||||
// bind 是已登录流程:验签失败不可回 401,否则前端全局拦截会误登出有效会话
|
||||
if res.Mode == "bind" && status == http.StatusUnauthorized {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
h.recordWallet(c, res.Username, status, err, start)
|
||||
c.JSON(status, gin.H{"error": walletErrText(err)})
|
||||
return
|
||||
}
|
||||
h.recordWallet(c, res.Username, http.StatusOK, nil, start)
|
||||
// login 与 bind 同构响应;bind 的 token 为版本递增后换发的新会话
|
||||
c.JSON(http.StatusOK, gin.H{"token": res.Token, "expiresAt": res.ExpiresAt})
|
||||
}
|
||||
|
||||
// walletErrStatus 映射校验阶段错误码。
|
||||
func walletErrStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrLoginLocked):
|
||||
return http.StatusTooManyRequests
|
||||
case errors.Is(err, service.ErrWalletChallenge):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, service.ErrWalletNotBound):
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, service.ErrWalletBound):
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, service.ErrWalletSig):
|
||||
return http.StatusUnauthorized
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// walletErrText 把流程错误转为用户可读文案;内部错误不透出细节。
|
||||
func walletErrText(err error) string {
|
||||
for _, known := range []error{service.ErrLoginLocked, service.ErrWalletChallenge, service.ErrWalletNotBound, service.ErrWalletBound, service.ErrWalletSig} {
|
||||
if errors.Is(err, known) {
|
||||
return known.Error()
|
||||
}
|
||||
}
|
||||
return "校验失败,请重试"
|
||||
}
|
||||
|
||||
// recordWallet 记录钱包登录/绑定结果——公开端点不经系统日志中间件,须显式留痕。
|
||||
func (h *walletHandler) recordWallet(c *gin.Context, username string, status int, err error, start time.Time) {
|
||||
if h.logs == nil {
|
||||
return
|
||||
}
|
||||
errMsg := ""
|
||||
if err != nil {
|
||||
errMsg = walletErrText(err)
|
||||
}
|
||||
h.logs.Record(model.SystemLog{
|
||||
Username: username,
|
||||
Method: http.MethodPost,
|
||||
Path: requestPath(c),
|
||||
Status: status,
|
||||
DurationMs: time.Since(start).Milliseconds(),
|
||||
ClientIP: requestIP(c),
|
||||
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
|
||||
ErrMsg: errMsg,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user