136 lines
5.2 KiB
Go
136 lines
5.2 KiB
Go
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
|
||
}
|