| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136 |
- // 验签中间件(T4-5):受保护路由前置,验签 access + 注入多租户作用域(架构规格 §3.5)。
- //
- // 数据流(与 infra/scope 包注释对齐):
- // 1. 取 Authorization: Bearer <token>;缺失/非 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 <token>":按首个空白切分,前段不区分大小写比对 "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
- }
|