162 lines
5.5 KiB
Go
162 lines
5.5 KiB
Go
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,
|
|
})
|
|
}
|