first commit
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS 跨域中间件。
|
||||
// 允许来源通过 Server.Domain 配置,自动放行其 http/https 两种协议的 Origin。
|
||||
// 未配置时回退为 *(保持兼容)。
|
||||
func CORS() gin.HandlerFunc {
|
||||
domain := ""
|
||||
if global.ServerSetting != nil {
|
||||
domain = strings.TrimSpace(global.ServerSetting.Domain)
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
allowOrigin := "*"
|
||||
if domain != "" && domain != "*" {
|
||||
allowOrigin = ""
|
||||
if originAllowed(origin, domain) {
|
||||
allowOrigin = origin
|
||||
}
|
||||
}
|
||||
|
||||
if allowOrigin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", allowOrigin)
|
||||
}
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// originAllowed 判断 Origin 是否命中配置域名(http/https,忽略尾部斜杠)。
|
||||
func originAllowed(origin, domain string) bool {
|
||||
o := strings.TrimSuffix(strings.TrimSpace(origin), "/")
|
||||
return o == "https://"+domain || o == "http://"+domain
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/redisdao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/lzcwyjwt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const (
|
||||
adminIDKey ctxKey = "adminID"
|
||||
)
|
||||
|
||||
const (
|
||||
// adminRoleSuper 超级管理员角色(账号管理接口专用)。
|
||||
adminRoleSuper int8 = 1
|
||||
// adminStatusEnabled 启用状态。
|
||||
adminStatusEnabled int8 = 1
|
||||
)
|
||||
|
||||
// JWTAuth 管理员鉴权中间件:
|
||||
// 解析 Bearer token → Redis 会话校验(失败 fail-open)→ 数据库状态回查(禁用即拒绝)→ AdminID 注入 context。
|
||||
func JWTAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
|
||||
// 1. 从 Authorization 头取 token:Bearer xxx.yyy.zzz
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
response.ToErrorResponse(errcode.UnauthorizedTokenMissing)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("invalid Authorization format"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 解析 token
|
||||
claims, err := lzcwyjwt.ParseAccessToken(parts[1])
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, lzcwyjwt.ErrTokenExpired):
|
||||
response.ToErrorResponse(errcode.UnauthorizedTokenTimeout)
|
||||
case errors.Is(err, lzcwyjwt.ErrTokenWrongType):
|
||||
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token type mismatch"))
|
||||
default:
|
||||
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails(err.Error()))
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 2.5 校验 access token 是否仍在会话中(登出/失效后立即拒绝)。
|
||||
sessionID, serr := redisdao.AuthAdminIDByAccess(c.Request.Context(), parts[1])
|
||||
if serr != nil {
|
||||
if errors.Is(serr, redis.Nil) {
|
||||
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token session invalid"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// Redis 其它错误降级放行(fail-open):优先保证可用性;
|
||||
// 被禁用/删除的管理员仍会被下方 DB 回查拒绝,不会因本降级放行而绕过。
|
||||
slog.WarnContext(c.Request.Context(), "check admin token session failed, allow by fallback", "error", serr)
|
||||
} else if sessionID != claims.AdminID {
|
||||
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token user mismatch"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 数据库回查管理员状态(禁用/删除立即生效)。
|
||||
admin, dbErr := mysqldao.NewAdminDao().AdminGetByID(c.Request.Context(), claims.AdminID)
|
||||
if dbErr != nil {
|
||||
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("管理员不存在"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if admin.Status != adminStatusEnabled {
|
||||
response.ToErrorResponse(errcode.AdminDisabled)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 把 adminID 注入 context
|
||||
ctx := context.WithValue(c.Request.Context(), adminIDKey, claims.AdminID)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminSuperAuth 超级管理员权限校验(在 JWTAuth 之后使用)。
|
||||
func AdminSuperAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
|
||||
adminID, ok := GetAdminID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
admin, err := mysqldao.NewAdminDao().AdminGetByID(c.Request.Context(), adminID)
|
||||
if err != nil {
|
||||
response.ToErrorResponse(errcode.AdminNotFound)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if admin.Role != adminRoleSuper {
|
||||
response.ToErrorResponse(errcode.Forbidden.WithDetails("仅超级管理员可操作"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetAdminID 从 context 取登录管理员 ID(在 handler 里用)。
|
||||
func GetAdminID(ctx context.Context) (int64, bool) {
|
||||
uid, ok := ctx.Value(adminIDKey).(int64)
|
||||
return uid, ok
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// sanitizeQuery 对 query 中的敏感参数值脱敏,避免日志泄露 token/code/密钥等。
|
||||
func sanitizeQuery(query string) string {
|
||||
if query == "" {
|
||||
return ""
|
||||
}
|
||||
values, err := url.ParseQuery(query)
|
||||
if err != nil {
|
||||
return "***"
|
||||
}
|
||||
sensitive := map[string]bool{
|
||||
"token": true, "code": true, "secret": true, "password": true,
|
||||
"access_token": true, "refresh_token": true, "key": true,
|
||||
}
|
||||
for k := range values {
|
||||
if sensitive[strings.ToLower(k)] {
|
||||
values.Set(k, "***")
|
||||
}
|
||||
}
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
func GinSlogMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
query := sanitizeQuery(c.Request.URL.RawQuery)
|
||||
|
||||
// 处理请求
|
||||
c.Next()
|
||||
|
||||
// 请求结束后打日志
|
||||
slog.InfoContext(c.Request.Context(), "GIN_REQUEST",
|
||||
"request_id", GetRequestID(c.Request.Context()),
|
||||
"method", c.Request.Method,
|
||||
"path", path,
|
||||
"query", query,
|
||||
"status", c.Writer.Status(),
|
||||
"ip", c.ClientIP(),
|
||||
"latency", time.Since(start).String(),
|
||||
"user_agent", c.Request.UserAgent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
)
|
||||
|
||||
// GinOTelMiddleware 为每个 HTTP 请求自动开启 server span。
|
||||
func GinOTelMiddleware() gin.HandlerFunc {
|
||||
name := "zt-closet-manage-backend"
|
||||
if global.OtelSetting != nil && global.OtelSetting.ServiceName != "" {
|
||||
name = global.OtelSetting.ServiceName
|
||||
}
|
||||
return otelgin.Middleware(name)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const requestIDKey ctxKey = "requestID"
|
||||
|
||||
// RequestID 从请求头取 X-Request-ID,没有则生成 UUID。
|
||||
// 注入 context 并写入响应头,便于全链路追踪。
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rid := c.GetHeader("X-Request-ID")
|
||||
if rid == "" {
|
||||
rid = uuid.New().String()
|
||||
}
|
||||
|
||||
// 写入 gin context(供 handler 取用)
|
||||
c.Set("requestID", rid)
|
||||
// 写入 request context(供 service/dao 的 slog 取用)
|
||||
ctx := context.WithValue(c.Request.Context(), requestIDKey, rid)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
// 响应头
|
||||
c.Header("X-Request-ID", rid)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetRequestID 从 context 取出 request-id。
|
||||
func GetRequestID(ctx context.Context) string {
|
||||
rid, _ := ctx.Value(requestIDKey).(string)
|
||||
return rid
|
||||
}
|
||||
Reference in New Issue
Block a user