package middleware import ( "context" "errors" "net/http" "strings" "time" "github.com/gin-gonic/gin" "go.uber.org/zap" "gorm.io/gorm" "with_you/internal/cache" apperrors "with_you/internal/errors" "with_you/internal/model" "with_you/internal/pkg/auth" "with_you/internal/pkg/jwt" "with_you/internal/pkg/response" "with_you/internal/service" ) // 认证管道相关常量。 const ( // principalCacheTTL Principal 缓存 TTL:账户状态/角色变更后最多延迟生效时间。 // 30s 内可容忍多一次 DB 查询,平衡安全即时性与登录热点流量。 principalCacheTTL = 30 * time.Second // sessionCacheTTL 会话撤销状态缓存 TTL:登出/封禁后旧 token 在此时长内可能仍被放行。 // 由于 session 表由服务端控制,30s 内的撤销延迟可接受;后续可调整为 0(不缓存)。 sessionCacheTTL = 30 * time.Second ) // IdentityProvider 认证管道依赖的身份与角色查询接口。 // // 由 service 层实现并注入,避免中间件直接依赖具体 service; // 同时便于测试以 fake 注入。 type IdentityProvider interface { // GetUserByID 加载用户实体(含状态、verification_status)。 GetUserByID(ctx context.Context, userID string) (*model.User, error) // GetRolesForUser 返回用户的角色集合。 GetRolesForUser(ctx context.Context, userID string) ([]string, error) // GetSessionByID 返回会话实体(用于校验是否撤销/过期)。 // 当 sessionID 为空(旧 access token 兼容窗口)应返回 nil,nil。 GetSessionByID(ctx context.Context, sessionID string) (*model.Session, error) } // principalCacheValue 缓存 Principal 的载体,便于区分 hit/miss 与缓存空角色。 type principalCacheValue struct { Principal *auth.Principal } // RequireAuth 强制认证中间件。 // // 流程: // 1. 提取 Bearer token,缺失 → 401。 // 2. ParseAccessToken(拒绝 refresh token)。 // 3. 从 cache/DB 加载用户与角色,组装 Principal。 // 4. 校验账户状态:active 允许;pending_deletion 允许访问(业务策略:允许取消注销); // banned/inactive/其他 → 403。 // 5. 校验 sessionID(sid 非空时):撤销/过期 → 401。 // 6. Principal 写入 context。 func RequireAuth(jwtService service.JWTService, idp IdentityProvider, cache cache.Cache) gin.HandlerFunc { return func(c *gin.Context) { claims, ok := extractAccessToken(c, jwtService) if !ok { authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, "") return } principal, err := buildPrincipal(c.Request.Context(), idp, cache, claims) if err != nil { // 用户不存在或加载失败:以 401 处理(令牌虽有效但身份已失效)。 authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, claims.UserID) return } if err := checkAccountStatus(principal); err != nil { authDeny(c, http.StatusForbidden, err, principal.UserID) return } if err := validateSession(c.Request.Context(), idp, cache, principal); err != nil { authDeny(c, http.StatusUnauthorized, err, principal.UserID) return } auth.WithContext(c, principal) c.Next() } } // OptionalAuth 可选认证中间件。 // // 行为: // - 缺失 Authorization 头 → 当游客,Principal 不写入 context,c.Next()。 // - 携带了 token 但解析/状态/会话失败 → 401(语义更清晰,避免静默降级到游客)。 func OptionalAuth(jwtService service.JWTService, idp IdentityProvider, cache cache.Cache) gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") if authHeader == "" { c.Next() return } // 格式明显错误(非 Bearer)→ 当游客,保持现有游客可见接口语义。 prefix, token, found := strings.Cut(authHeader, " ") if !found || prefix != "Bearer" || token == "" { c.Next() return } claims, err := jwtService.ParseAccessToken(token) if err != nil { authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, "") return } principal, err := buildPrincipal(c.Request.Context(), idp, cache, claims) if err != nil { authDeny(c, http.StatusUnauthorized, apperrors.ErrInvalidToken, claims.UserID) return } if err := checkAccountStatus(principal); err != nil { authDeny(c, http.StatusForbidden, err, principal.UserID) return } if err := validateSession(c.Request.Context(), idp, cache, principal); err != nil { authDeny(c, http.StatusUnauthorized, err, principal.UserID) return } auth.WithContext(c, principal) c.Next() } } // extractAccessToken 从请求中提取并解析 access token。 // // 第二返回值 ok=false 表示应当拒绝(已写响应);true 表示已拿到 claims。 func extractAccessToken(c *gin.Context, jwtService service.JWTService) (*jwt.Claims, bool) { authHeader := c.GetHeader("Authorization") if authHeader == "" { return nil, false } prefix, token, found := strings.Cut(authHeader, " ") if !found || prefix != "Bearer" || token == "" { return nil, false } claims, err := jwtService.ParseAccessToken(token) if err != nil { return nil, false } return claims, true } // buildPrincipal 从缓存或 IDP 加载用户/角色,组装 Principal。 func buildPrincipal(ctx context.Context, idp IdentityProvider, cch cache.Cache, claims *jwt.Claims) (*auth.Principal, error) { cacheKey := "" if cch != nil { cacheKey = auth.PrincipalCacheKey(claims.UserID) if cached, ok := cch.Get(cacheKey); ok { if v, ok := cached.(*principalCacheValue); ok && v != nil { return v.Principal, nil } } } user, err := idp.GetUserByID(ctx, claims.UserID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, apperrors.ErrUserNotFound } return nil, err } roles, err := idp.GetRolesForUser(ctx, claims.UserID) if err != nil { return nil, err } if roles == nil { roles = []string{} } p := &auth.Principal{ UserID: user.ID, Username: user.Username, SessionID: claims.SessionID, Status: user.Status, VerificationStatus: user.VerificationStatus, Roles: roles, TokenID: claims.ID, IsLegacyToken: claims.TokenType == "", } if cacheKey != "" { cch.Set(cacheKey, &principalCacheValue{Principal: p}, principalCacheTTL) } return p, nil } // checkAccountStatus 校验账户状态。 // // 策略:active 允许;pending_deletion 允许(用户可访问取消注销接口); // banned/inactive/其他拒绝。pending_deletion 在管理后台路由由 RequireActive 单独再加一道。 func checkAccountStatus(p *auth.Principal) error { switch p.Status { case model.UserStatusActive, model.UserStatusPendingDeletion: return nil case model.UserStatusBanned: return apperrors.ErrUserBanned case model.UserStatusInactive: return apperrors.ErrAccountInactive default: return apperrors.ErrAccountInactive } } // validateSession 校验会话有效性。 // // 旧 access token(无 sid)在兼容窗口内跳过会话校验,由 access TTL 自然过期收口。 func validateSession(ctx context.Context, idp IdentityProvider, cch cache.Cache, p *auth.Principal) error { if p.SessionID == "" { // 旧 token 兼容窗口。 return nil } if cch != nil { key := auth.SessionCacheKey(p.SessionID) if cached, ok := cch.Get(key); ok { if valid, ok := cached.(bool); ok && !valid { return apperrors.ErrSessionRevoked } } } session, err := idp.GetSessionByID(ctx, p.SessionID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return apperrors.ErrSessionRevoked } return err } if session == nil || !session.IsValid(time.Now()) { if cch != nil { cch.Set(auth.SessionCacheKey(p.SessionID), false, sessionCacheTTL) } return apperrors.ErrSessionRevoked } if cch != nil { cch.Set(auth.SessionCacheKey(p.SessionID), true, sessionCacheTTL) } return nil } // InvalidatePrincipalCache 与 InvalidateSessionCache 已迁移至 internal/pkg/auth 包, // 以避免 service → middleware 的包循环(service 层修改用户状态后需要失效缓存, // 但 middleware 又依赖 service 接口)。 // // 保留本文件中的 InvalidatePrincipalCache/InvalidateSessionCache 仅作为转发会引入 // 循环,故直接删除;调用方请使用 auth.InvalidatePrincipalCache / auth.InvalidateSessionCache。 // authDeny 拒绝访问的统一响应:记日志 + 返回结构化错误。 func authDeny(c *gin.Context, statusCode int, appErr error, userID string) { zap.L().Warn("auth denied", zap.Int("status", statusCode), zap.String("user_id", userID), zap.String("ip", c.ClientIP()), zap.String("user_agent", c.GetHeader("User-Agent")), zap.String("path", c.Request.URL.Path), zap.Any("err", appErr), ) code := apperrors.ErrInvalidToken.Code msg := apperrors.ErrInvalidToken.Message var target *apperrors.AppError if errors.As(appErr, &target) { code = target.Code msg = target.Message } response.ErrorWithStringCode(c, statusCode, code, msg) c.Abort() }