新增活跃会话管理与通行密钥、钱包登录
CI / test (push) Successful in 55s

This commit is contained in:
2026-07-30 12:23:05 +08:00
parent f1914880ac
commit 109c345e5e
49 changed files with 6468 additions and 336 deletions
+161
View File
@@ -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,
})
}