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