package middleware import ( "context" "errors" "log/slog" "strings" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/lzcwyjwt" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" ) type ctxKey string const ( userIDKey ctxKey = "userID" ) 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 == "" { slog.WarnContext(c.Request.Context(), "jwt auth: authorization header missing", "method", c.Request.Method, "path", c.Request.URL.Path, "ip", c.ClientIP()) response.ToErrorResponse(errcode.UnauthorizedTokenMissing) c.Abort() return } parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || parts[0] != "Bearer" { slog.WarnContext(c.Request.Context(), "jwt auth: invalid authorization format", "method", c.Request.Method, "path", c.Request.URL.Path, "ip", c.ClientIP()) 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): slog.InfoContext(c.Request.Context(), "jwt auth: token expired", "path", c.Request.URL.Path) response.ToErrorResponse(errcode.UnauthorizedTokenTimeout) case errors.Is(err, lzcwyjwt.ErrTokenWrongType): slog.WarnContext(c.Request.Context(), "jwt auth: token type mismatch", "path", c.Request.URL.Path) response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token type mismatch")) default: slog.WarnContext(c.Request.Context(), "jwt auth: parse token failed", "path", c.Request.URL.Path, "ip", c.ClientIP(), "error", err) response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails(err.Error())) } c.Abort() return } // 2.5 校验 access token 是否仍在会话中(登出/失效后立即拒绝)。 sessionUID, serr := redisdao.AuthUserIDByAccess(c.Request.Context(), parts[1]) if serr != nil { if errors.Is(serr, redis.Nil) { slog.WarnContext(c.Request.Context(), "jwt auth: token session invalid", "user_id", claims.UserID, "path", c.Request.URL.Path) response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token session invalid")) c.Abort() return } // Redis 其它错误降级放行(fail-open): // 已确认的安全权衡决策——优先保证可用性,避免 Redis 抖动导致全员不可用。 // 代价是 Redis 故障窗口内,已登出/被吊销但未过期的 token 仍可访问; // 封禁用户仍会被下方 DB 回查(Status != 1)拒绝,不会因本降级放行而绕过封禁。 slog.WarnContext(c.Request.Context(), "check access token session failed, allow by fallback", "user_id", claims.UserID, "path", c.Request.URL.Path, "ip", c.ClientIP(), "error", serr) } else if sessionUID != claims.UserID { slog.ErrorContext(c.Request.Context(), "jwt auth: token user mismatch", "user_id", claims.UserID, "session_user_id", sessionUID, "path", c.Request.URL.Path, "ip", c.ClientIP()) response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token user mismatch")) c.Abort() return } // 3. 从缓存获取用户信息,未命中或 Redis 出错都回落 DB 并回填缓存,检查封禁状态 userInfo, err := redisdao.UserInfoGet(c.Request.Context(), claims.UserID) if err != nil { if !errors.Is(err, redis.Nil) { slog.WarnContext(c.Request.Context(), "get user info from cache failed, fallback to db", "user_id", claims.UserID, "error", err) } // 缓存未命中或 Redis 抖动:回落数据库。 dbUser, dbErr := mysqldao.NewUserDao().UserGetByID(c.Request.Context(), claims.UserID) if dbErr != nil { slog.WarnContext(c.Request.Context(), "jwt auth: query user failed", "user_id", claims.UserID, "path", c.Request.URL.Path, "error", dbErr) response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("用户不存在")) c.Abort() return } userInfo = dbUser if cerr := redisdao.UserInfoSet(c.Request.Context(), dbUser, 0); cerr != nil { slog.DebugContext(c.Request.Context(), "jwt auth: backfill user cache failed", "user_id", claims.UserID, "error", cerr) } } // 状态非正常(1=正常)一律拒绝 if userInfo.Status != 1 { slog.WarnContext(c.Request.Context(), "jwt auth: user disabled", "user_id", claims.UserID, "status", userInfo.Status, "path", c.Request.URL.Path) response.ToErrorResponse(errcode.UserDisabled) c.Abort() return } // 4. 把 userID注入 context ctx := context.WithValue(c.Request.Context(), userIDKey, claims.UserID) c.Request = c.Request.WithContext(ctx) c.Next() } } // GetUserID 从 context 取登录用户 ID(在 handler 里用) func GetUserID(ctx context.Context) (int64, bool) { uid, ok := ctx.Value(userIDKey).(int64) return uid, ok }