// 验签中间件(T4-5):受保护路由前置,验签 access + 注入多租户作用域(架构规格 §3.5)。 // // 数据流(与 infra/scope 包注释对齐): // 1. 取 Authorization: Bearer ;缺失/非 Bearer → 1001 未登录; // 2. token.Verify(**先查吊销黑名单再验签**,黑名单命中即拒,省一次 RSA 验签); // 3. 验签通过: // a. 把 *token.Claims 写入 c.Locals(ClaimsCtxKey)(handler.Me/Logout 经 claimsFromCtx 读回); // b. 由 claims 构造 scope.Scope 并 scope.Set(c, sc)(业务域 Repository 的 scoping 强制过滤依据); // 4. c.Next() 放行到受保护 Handler。 // // 错误映射(ADR-014,与 token 包语义一致): // - token.ErrInvalidToken(签名错/exp 过期/iss 不符/格式损坏)→ code 1001 未登录; // - token.ErrRevoked(jti 命中黑名单:登出/改密/禁用后) → code 1002 登录态失效; // - 其它 error(黑名单存储故障等) → code 5001 + **fail-closed**(不放行—— // 吊销失效 = 改密后旧 token 仍可用,安全红线;故障细节只进日志,不进入响应体)。 // // 双实例(ADR-015):验签只用公钥(进程内存),两实例签发 token 互相可验; // 黑名单/refresh 走共享 Redis,天然一致——无需 per-instance 密钥。 package handler import ( stderrors "errors" "strings" "github.com/gofiber/fiber/v2" "yuansiwei/unified-backend/internal/identity/token" apperrors "yuansiwei/unified-backend/internal/infra/errors" "yuansiwei/unified-backend/internal/infra/response" "yuansiwei/unified-backend/internal/infra/scope" ) // superAdminRole 平台超管角色码(scope.SuperAdmin 置位依据,跨机构操作跳过 scoping 机构过滤)。 // 与 T4 拍板的字符串角色码一致(super_admin/academic/finance/teacher/student/parent/market)。 const superAdminRole = "super_admin" // AuthConfig 验签中间件装配参数(T4-6 main 构造;全部必填)。 type AuthConfig struct { // PublicKeys:验签公钥列表(支持密钥轮换:新旧并存 grace 期双验;来自 config.Auth.PublicKeysPEM)。 PublicKeys []string // Revocation:吊销黑名单存储(验签前查 jti;nil 时跳过黑名单直验签——纯本地场景,生产必给, // 否则登出/改密后旧 token 在 access 有效期内仍可访问 = 安全漏洞)。 Revocation token.RevocationStore // Issuer:token iss 声明(验签比对;须与签发器一致)。 Issuer string } // NewAuth 构造验签中间件(fail-fast:公钥/issuer 缺失时直接报错,不带病启动)。 // // 入参:cfg AuthConfig:见 AuthConfig 字段说明。 // 出参: // - fiber.Handler:可直接 app.Group(...).Use(...) 挂载的中间件。 // - error:PublicKeys 为空 / Issuer 为空(配置错误,fail-fast)。 func NewAuth(cfg AuthConfig) (fiber.Handler, error) { if len(cfg.PublicKeys) == 0 { return nil, apperrors.New(apperrors.CodeInternal, "auth: 验签公钥列表为空(PublicKeys 必填)") } if cfg.Issuer == "" { return nil, apperrors.New(apperrors.CodeInternal, "auth: issuer 为空(Issuer 必填)") } return func(c *fiber.Ctx) error { bearer := extractBearer(c) if bearer == "" { return response.Fail(c, apperrors.ErrUnauth) // 缺 token / 非 Bearer 头 } claims, err := token.Verify(c.Context(), cfg.PublicKeys, cfg.Revocation, cfg.Issuer, bearer) if err != nil { switch { case stderrors.Is(err, token.ErrRevoked): return response.Fail(c, apperrors.ErrTokenExpired) // 1002 登录态失效 case stderrors.Is(err, token.ErrInvalidToken): return response.Fail(c, apperrors.ErrUnauth) // 1001 未登录 default: // 黑名单存储故障等:fail-closed(拒绝 + 5001,细节只进日志) return response.Fail(c, apperrors.WithCause(apperrors.ErrInternal, err)) } } // 注入已验签 claims(handler.Me/Logout 读回;指针保证 Logout 能取到 ExpiresAt 算剩余 TTL) _ = c.Locals(ClaimsCtxKey, &claims) // 注入多租户作用域(业务域 Repository 的 scoping 强制过滤依据) scope.Set(c, buildScope(claims)) return c.Next() }, nil } // extractBearer 从 Authorization 头取 Bearer 后的 token 原串(RFC 6750:scheme 大小写不敏感)。 // // 入参:c *fiber.Ctx。 // 出参:token 原串;无 Authorization 头 / 非 Bearer / 空 token 时返回 ""。 func extractBearer(c *fiber.Ctx) string { h := c.Get("Authorization") if h == "" { return "" } // "Bearer ":按首个空白切分,前段不区分大小写比对 "bearer" i := 0 for i < len(h) && h[i] != ' ' && h[i] != '\t' { i++ } if i == 0 || i == len(h) { return "" } scheme, rest := h[:i], h[i:] for len(rest) > 0 && (rest[0] == ' ' || rest[0] == '\t') { rest = rest[1:] } if !strings.EqualFold(scheme, "bearer") { return "" } return strings.TrimSpace(rest) } // buildScope 由已验签 claims 构造多租户作用域(scope.Set 写入 fiber.Ctx)。 // // 入参:c token.Claims:验签通过的业务声明(Sub/Roles/InstitutionID/CampusID 可信)。 // 出参:scope.Scope: // - InstitutionID/CampusID/UserID:直取 claims(Sub → UserID); // - Role:取 Roles[0](选定作用域通常单角色;多角色时主角色在前,由签发侧保证); // - SuperAdmin:Roles 含 super_admin 时置 true(平台超管跨机构,scoping 跳过机构过滤)。 func buildScope(c token.Claims) *scope.Scope { sc := &scope.Scope{ InstitutionID: c.InstitutionID, CampusID: c.CampusID, UserID: c.Sub, } if len(c.Roles) > 0 { sc.Role = c.Roles[0] } for _, r := range c.Roles { if r == superAdminRole { sc.SuperAdmin = true break } } return sc }