auth.go 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. // 验签中间件(T4-5):受保护路由前置,验签 access + 注入多租户作用域(架构规格 §3.5)。
  2. //
  3. // 数据流(与 infra/scope 包注释对齐):
  4. // 1. 取 Authorization: Bearer <token>;缺失/非 Bearer → 1001 未登录;
  5. // 2. token.Verify(**先查吊销黑名单再验签**,黑名单命中即拒,省一次 RSA 验签);
  6. // 3. 验签通过:
  7. // a. 把 *token.Claims 写入 c.Locals(ClaimsCtxKey)(handler.Me/Logout 经 claimsFromCtx 读回);
  8. // b. 由 claims 构造 scope.Scope 并 scope.Set(c, sc)(业务域 Repository 的 scoping 强制过滤依据);
  9. // 4. c.Next() 放行到受保护 Handler。
  10. //
  11. // 错误映射(ADR-014,与 token 包语义一致):
  12. // - token.ErrInvalidToken(签名错/exp 过期/iss 不符/格式损坏)→ code 1001 未登录;
  13. // - token.ErrRevoked(jti 命中黑名单:登出/改密/禁用后) → code 1002 登录态失效;
  14. // - 其它 error(黑名单存储故障等) → code 5001 + **fail-closed**(不放行——
  15. // 吊销失效 = 改密后旧 token 仍可用,安全红线;故障细节只进日志,不进入响应体)。
  16. //
  17. // 双实例(ADR-015):验签只用公钥(进程内存),两实例签发 token 互相可验;
  18. // 黑名单/refresh 走共享 Redis,天然一致——无需 per-instance 密钥。
  19. package handler
  20. import (
  21. stderrors "errors"
  22. "strings"
  23. "github.com/gofiber/fiber/v2"
  24. "yuansiwei/unified-backend/internal/identity/token"
  25. apperrors "yuansiwei/unified-backend/internal/infra/errors"
  26. "yuansiwei/unified-backend/internal/infra/response"
  27. "yuansiwei/unified-backend/internal/infra/scope"
  28. )
  29. // superAdminRole 平台超管角色码(scope.SuperAdmin 置位依据,跨机构操作跳过 scoping 机构过滤)。
  30. // 与 T4 拍板的字符串角色码一致(super_admin/academic/finance/teacher/student/parent/market)。
  31. const superAdminRole = "super_admin"
  32. // AuthConfig 验签中间件装配参数(T4-6 main 构造;全部必填)。
  33. type AuthConfig struct {
  34. // PublicKeys:验签公钥列表(支持密钥轮换:新旧并存 grace 期双验;来自 config.Auth.PublicKeysPEM)。
  35. PublicKeys []string
  36. // Revocation:吊销黑名单存储(验签前查 jti;nil 时跳过黑名单直验签——纯本地场景,生产必给,
  37. // 否则登出/改密后旧 token 在 access 有效期内仍可访问 = 安全漏洞)。
  38. Revocation token.RevocationStore
  39. // Issuer:token iss 声明(验签比对;须与签发器一致)。
  40. Issuer string
  41. }
  42. // NewAuth 构造验签中间件(fail-fast:公钥/issuer 缺失时直接报错,不带病启动)。
  43. //
  44. // 入参:cfg AuthConfig:见 AuthConfig 字段说明。
  45. // 出参:
  46. // - fiber.Handler:可直接 app.Group(...).Use(...) 挂载的中间件。
  47. // - error:PublicKeys 为空 / Issuer 为空(配置错误,fail-fast)。
  48. func NewAuth(cfg AuthConfig) (fiber.Handler, error) {
  49. if len(cfg.PublicKeys) == 0 {
  50. return nil, apperrors.New(apperrors.CodeInternal, "auth: 验签公钥列表为空(PublicKeys 必填)")
  51. }
  52. if cfg.Issuer == "" {
  53. return nil, apperrors.New(apperrors.CodeInternal, "auth: issuer 为空(Issuer 必填)")
  54. }
  55. return func(c *fiber.Ctx) error {
  56. bearer := extractBearer(c)
  57. if bearer == "" {
  58. return response.Fail(c, apperrors.ErrUnauth) // 缺 token / 非 Bearer 头
  59. }
  60. claims, err := token.Verify(c.Context(), cfg.PublicKeys, cfg.Revocation, cfg.Issuer, bearer)
  61. if err != nil {
  62. switch {
  63. case stderrors.Is(err, token.ErrRevoked):
  64. return response.Fail(c, apperrors.ErrTokenExpired) // 1002 登录态失效
  65. case stderrors.Is(err, token.ErrInvalidToken):
  66. return response.Fail(c, apperrors.ErrUnauth) // 1001 未登录
  67. default:
  68. // 黑名单存储故障等:fail-closed(拒绝 + 5001,细节只进日志)
  69. return response.Fail(c, apperrors.WithCause(apperrors.ErrInternal, err))
  70. }
  71. }
  72. // 注入已验签 claims(handler.Me/Logout 读回;指针保证 Logout 能取到 ExpiresAt 算剩余 TTL)
  73. _ = c.Locals(ClaimsCtxKey, &claims)
  74. // 注入多租户作用域(业务域 Repository 的 scoping 强制过滤依据)
  75. scope.Set(c, buildScope(claims))
  76. return c.Next()
  77. }, nil
  78. }
  79. // extractBearer 从 Authorization 头取 Bearer 后的 token 原串(RFC 6750:scheme 大小写不敏感)。
  80. //
  81. // 入参:c *fiber.Ctx。
  82. // 出参:token 原串;无 Authorization 头 / 非 Bearer / 空 token 时返回 ""。
  83. func extractBearer(c *fiber.Ctx) string {
  84. h := c.Get("Authorization")
  85. if h == "" {
  86. return ""
  87. }
  88. // "Bearer <token>":按首个空白切分,前段不区分大小写比对 "bearer"
  89. i := 0
  90. for i < len(h) && h[i] != ' ' && h[i] != '\t' {
  91. i++
  92. }
  93. if i == 0 || i == len(h) {
  94. return ""
  95. }
  96. scheme, rest := h[:i], h[i:]
  97. for len(rest) > 0 && (rest[0] == ' ' || rest[0] == '\t') {
  98. rest = rest[1:]
  99. }
  100. if !strings.EqualFold(scheme, "bearer") {
  101. return ""
  102. }
  103. return strings.TrimSpace(rest)
  104. }
  105. // buildScope 由已验签 claims 构造多租户作用域(scope.Set 写入 fiber.Ctx)。
  106. //
  107. // 入参:c token.Claims:验签通过的业务声明(Sub/Roles/InstitutionID/CampusID 可信)。
  108. // 出参:scope.Scope:
  109. // - InstitutionID/CampusID/UserID:直取 claims(Sub → UserID);
  110. // - Role:取 Roles[0](选定作用域通常单角色;多角色时主角色在前,由签发侧保证);
  111. // - SuperAdmin:Roles 含 super_admin 时置 true(平台超管跨机构,scoping 跳过机构过滤)。
  112. func buildScope(c token.Claims) *scope.Scope {
  113. sc := &scope.Scope{
  114. InstitutionID: c.InstitutionID,
  115. CampusID: c.CampusID,
  116. UserID: c.Sub,
  117. }
  118. if len(c.Roles) > 0 {
  119. sc.Role = c.Roles[0]
  120. }
  121. for _, r := range c.Roles {
  122. if r == superAdminRole {
  123. sc.SuperAdmin = true
  124. break
  125. }
  126. }
  127. return sc
  128. }