token.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. // Package token SSO token 签发/验签/吊销(ADR-014 最小可行 SSO)。
  2. //
  3. // 本包实现架构规格 §7.2 的 Token 规范:
  4. // - access:JWT RS256,有效期 30min;claims 含 sub(canonical_user_id ULID)、
  5. // roles[]、institution_id、campus_id、jti(吊销维度);
  6. // - refresh:不透明随机串(32 字节 hex),有效期 30 天;Redis 存白名单
  7. // (键 refresh:{jti} → 载荷),刷新时**轮换**(旧 jti 立即失效,防重放);
  8. // - 本地验签:验签只用公钥(进程内存),不依赖中心服务——grace 期语义:
  9. // 滚动重启/服务抖动时,30min 内已签发的 access 仍可通过本地验签通过(ADR-015);
  10. // - 吊销:改密/禁用/登出 → 写 Redis 黑名单 jti(TTL = 该 access 剩余有效期),
  11. // 验签中间件**先查黑名单再验签名**(黑名单命中即拒,无需解析 JWT 载荷)。
  12. //
  13. // 典型使用:
  14. //
  15. // // 启动时装配签发器(私钥/公钥/过期参数/Redis 存储来自 config + infra)
  16. // iss, _ := token.NewIssuer(cfg.Auth.PrivateKeyPEM, cfg.Auth.Issuer,
  17. // 30*time.Minute, 30*24*time.Hour, redisStore, redisStore)
  18. //
  19. // // 登录成功后签发一对凭证
  20. // access, refresh, err := iss.Issue(ctx, token.Claims{
  21. // Sub: canonicalUID, Roles: []string{"student"},
  22. // InstitutionID: 11, CampusID: 3,
  23. // })
  24. // // 响应 {accessToken: access, refreshToken: refresh, user: ...}
  25. //
  26. // // 验签中间件(每个受保护 Handler 前置)
  27. // claims, err := token.Verify(c.Context(), cfg.Auth.PublicKeysPEM, redisStore,
  28. // cfg.Auth.Issuer, bearer)
  29. // if err != nil { _ = response.Fail(c, errors.ErrUnauth); return }
  30. // // 把 claims 注入多租户作用域(见 infra/scope)
  31. // scope.Set(c, &scope.Scope{InstitutionID: claims.InstitutionID, CampusID: claims.CampusID, ...})
  32. package token
  33. import (
  34. "context"
  35. "crypto/rand"
  36. "crypto/rsa"
  37. "crypto/x509"
  38. "encoding/hex"
  39. "encoding/json"
  40. "encoding/pem"
  41. "errors"
  42. "fmt"
  43. "strconv"
  44. "strings"
  45. "time"
  46. "github.com/golang-jwt/jwt/v5"
  47. )
  48. // ErrInvalidToken access token 无效(签名错误/exp/iss 不符/格式损坏)。
  49. // 调用方(验签中间件)统一映射为 code=1001 未登录(不向用户细分原因)。
  50. var ErrInvalidToken = errors.New("token: access token 无效")
  51. // ErrRevoked access token 已被吊销(jti 命中 Redis 黑名单)。
  52. // 调用方映射为 code=1002 登录态失效(与"过期"区分,便于前端决定"静默 refresh 还是跳登录")。
  53. var ErrRevoked = errors.New("token: 登录态已失效")
  54. // ErrRefreshInvalid refresh token 无效(不在 Redis 白名单/已轮换/已过期)。
  55. var ErrRefreshInvalid = errors.New("token: refresh token 无效")
  56. // RefreshStore refresh 白名单存储接口(Redis 实现见 infra/redis;
  57. // 接口在 identity 侧定义,消费方 identity,infra 只提供 Client——
  58. // 依赖方向:identity → infra,infra 不依赖 identity)。
  59. type RefreshStore interface {
  60. // SetRefresh 写入 refresh 白名单(键 refresh:{jti},TTL=有效期)。
  61. // 入参:ctx 上下文;jti 唯一标识;payload []byte 载荷(序列化后的 RefreshPayload);ttl 有效期。
  62. // 出参:error——仅 Redis 不可用时非 nil(登录接口此时返回 4001/5001 降级,不放行)。
  63. SetRefresh(ctx context.Context, jti string, payload []byte, ttl time.Duration) error
  64. // GetRefresh 读取 refresh 白名单。
  65. // 入参:ctx、jti。出参:payload []byte(不存在时 nil)+ error。
  66. GetRefresh(ctx context.Context, jti string) ([]byte, error)
  67. // DeleteRefresh 删除(轮换/登出/撤销时调用)。幂等:键不存在不报错。
  68. DeleteRefresh(ctx context.Context, jti string) error
  69. }
  70. // RevocationStore 吊销黑名单存储接口(jti 维度)。
  71. type RevocationStore interface {
  72. // AddRevoked 写入黑名单(键 {prefix}:{jti},TTL=该 access 剩余有效期;
  73. // 剩余 ≤0 时实现方可直接跳过——token 本已过期,无需吊销)。
  74. AddRevoked(ctx context.Context, jti string, ttl time.Duration) error
  75. // IsRevoked 查询 jti 是否被吊销。
  76. // 出参:bool 命中;error——Redis 故障时返回 error,调用方策略:**fail-closed**
  77. // (拒绝请求 + 告警),不可 fail-open(吊销失效 = 改密后旧 token 仍可用,安全红线)。
  78. IsRevoked(ctx context.Context, jti string) (bool, error)
  79. }
  80. // Claims access token 的业务声明(嵌入 jwt.RegisteredClaims)。
  81. //
  82. // 字段(与 ADR-014 契约对齐):
  83. // - Sub:canonical_user_id(ULID,26 字符;**禁止**用旧 int user_id 作 sub,ADR-013/D1)。
  84. // - Roles:角色码数组(**字符串码**,T4 契约 2026-11-06 拍板,与 T1 DDL/T2 model 对齐——
  85. // super_admin/academic/finance/teacher/student/parent/market;市场登录禁用,正常不出现 market)。
  86. // 弃用旧注释的数字码("1"~"8"):数字码是旧 PHP 系统的遗留,新后端统一字符串码,
  87. // PC 前端 LoginResult.roles 须同步由 number[] 改为 string[]。
  88. // - InstitutionID / CampusID:多租户作用域(Repository scoping 依据,scope.FromFiber 来源;
  89. // 0 表示平台级/不限校区,超管视角)。
  90. // - JTI:走嵌入的 RegisteredClaims.ID(JSON 名 "jti")——吊销/轮换锚点;
  91. // **不要**再自定义 jti 字段(浅层字段会遮蔽 RegisteredClaims.ID,导致解析侧 ID 恒空)。
  92. //
  93. // 注:原"UserID int64 内部 ID 兼容过渡期"字段已**移除**——ADR-013 拍板"旧 int 只存 t_legacy_mapping,
  94. // 新表外键一律 ULID",token 里保留 int 会诱导下游误用旧 int;刷新载荷 RefreshPayload 同步移除。
  95. type Claims struct {
  96. Sub string `json:"sub"`
  97. Roles []string `json:"roles"`
  98. InstitutionID int64 `json:"institution_id"`
  99. CampusID int64 `json:"campus_id"`
  100. jwt.RegisteredClaims
  101. }
  102. // RefreshPayload refresh token 在 Redis 白名单中的载荷(不透明串本身只含 jti 的随机因子,
  103. // 业务载荷存 Redis——refresh 泄露时服务端可即时删除,JWT 做不到)。
  104. //
  105. // 字段:
  106. // - Sub / Roles / InstitutionID / CampusID:与 access 同源(刷新时原样带入新 access)。
  107. // - CreatedAt time.Time:签发时刻(审计 + 防"未来 refresh")。
  108. //
  109. // 注:原 UserID int64 字段已移除(同 Claims 说明——新后端一律 ULID,int 只存 legacy_mapping)。
  110. type RefreshPayload struct {
  111. Sub string `json:"sub"`
  112. Roles []string `json:"roles"`
  113. InstitutionID int64 `json:"institution_id"`
  114. CampusID int64 `json:"campus_id"`
  115. CreatedAt time.Time `json:"created_at"`
  116. }
  117. // Issuer access/refresh 签发器(持有 RS256 私钥 + 过期参数 + jti 生成器)。
  118. type Issuer struct {
  119. key *rsa.PrivateKey
  120. issuer string
  121. accessTTL time.Duration
  122. refreshTTL time.Duration
  123. refreshStore RefreshStore
  124. revocation RevocationStore
  125. }
  126. // NewIssuer 构造签发器(启动时装配一次,全进程共享;私钥解析失败应 fail-fast 阻止启动)。
  127. //
  128. // 入参:
  129. // - privateKeyPEM string:RS256 私钥 PEM(PKCS1 或 PKCS8;配置 AuthCfg.PrivateKeyPEM,走环境变量)。
  130. // - issuer string:token iss 声明(如 "https://unified.example.com")。
  131. // - accessTTL time.Duration:access 有效期(固定 30min,架构规格 §7.2)。
  132. // - refreshTTL time.Duration:refresh 有效期(固定 30 天)。
  133. // - refreshStore RefreshStore:refresh 白名单存储(Redis 实现)。
  134. // - revocation RevocationStore:吊销黑名单存储(可为 nil——仅纯签发场景;验签必须提供)。
  135. //
  136. // 出参:
  137. // - *Issuer:就绪的签发器。
  138. // - error:私钥 PEM 解析失败(PKCS1/PKCS8 都不匹配时);其余入参不校验(调用方责任)。
  139. //
  140. // 异常场景:
  141. // - 双实例共享同一对 RS256 密钥(ADR-015 单机双实例):两实例签发 token 互相可验,
  142. // refresh/吊销走共享 Redis,天然一致——**无需** per-instance 密钥。
  143. func NewIssuer(privateKeyPEM, issuer string, accessTTL, refreshTTL time.Duration,
  144. refreshStore RefreshStore, revocation RevocationStore) (*Issuer, error) {
  145. key, err := parseRSAPrivateKey(privateKeyPEM)
  146. if err != nil {
  147. return nil, fmt.Errorf("token: 解析 RS256 私钥失败: %w", err)
  148. }
  149. return &Issuer{
  150. key: key,
  151. issuer: issuer,
  152. accessTTL: accessTTL,
  153. refreshTTL: refreshTTL,
  154. refreshStore: refreshStore,
  155. revocation: revocation,
  156. }, nil
  157. }
  158. // Issue 签发一对 access + refresh(登录/refresh 轮换成功后调用)。
  159. //
  160. // 入参:
  161. // - ctx context.Context:请求上下文(透传给 Redis 存储操作)。
  162. // - claims Claims:业务声明(Sub 必填且应为合法 ULID;JTI 留空由本函数填充)。
  163. //
  164. // 出参:
  165. // - accessToken string:JWT(RS256 签名,含 sub/roles/institution_id/campus_id/jti +
  166. // iss/iat/exp/nbf;exp=iat+accessTTL)。
  167. // - refreshToken string:不透明串(64 字符 hex = 32 字节随机,前 8 字节为 jti 因子)。
  168. // - err error:以下场景非 nil:
  169. // - claims.Sub 为空 → 包装错误(调用方 bug,单测覆盖);
  170. // - 随机数生成失败(熵源故障);
  171. // - refreshStore 写入失败(Redis 不可用)→ 登录整体失败,不返回"只有 access"的半套凭证。
  172. //
  173. // 样例:
  174. //
  175. // access, refresh, err := iss.Issue(ctx, token.Claims{Sub: uid,
  176. // Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3})
  177. func (iss *Issuer) Issue(ctx context.Context, claims Claims) (accessToken, refreshToken string, err error) {
  178. if claims.Sub == "" {
  179. return "", "", errors.New("token: Issue 要求 claims.Sub(canonical_user_id)非空")
  180. }
  181. now := time.Now()
  182. claims.IssuedAt = jwt.NewNumericDate(now)
  183. claims.NotBefore = jwt.NewNumericDate(now)
  184. claims.ExpiresAt = jwt.NewNumericDate(now.Add(iss.accessTTL))
  185. claims.Issuer = iss.issuer
  186. claims.ID = newJTI() // 每次签发唯一(吊销/轮换的锚点)
  187. token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
  188. signed, err := token.SignedString(iss.key)
  189. if err != nil {
  190. return "", "", fmt.Errorf("token: 签发 access 失败: %w", err)
  191. }
  192. // refresh:32 字节随机 → hex;jti 因子取前 8 字节(白名单键)
  193. buf := make([]byte, 32)
  194. if _, err := rand.Read(buf); err != nil {
  195. return "", "", fmt.Errorf("token: 生成 refresh 随机数失败: %w", err)
  196. }
  197. rjti := hex.EncodeToString(buf[:8])
  198. payload, _ := jsonMarshal(RefreshPayload{
  199. Sub: claims.Sub, Roles: claims.Roles,
  200. InstitutionID: claims.InstitutionID, CampusID: claims.CampusID,
  201. CreatedAt: now,
  202. })
  203. if err := iss.refreshStore.SetRefresh(ctx, rjti, payload, iss.refreshTTL); err != nil {
  204. return "", "", fmt.Errorf("token: 写 refresh 白名单失败: %w", err)
  205. }
  206. refreshToken = hex.EncodeToString(buf)
  207. return signed, refreshToken, nil
  208. }
  209. // Verify 验签 access token(验签中间件入口):先查吊销黑名单 → 再验 RS256 签名与 exp/iss。
  210. //
  211. // 入参:
  212. // - ctx context.Context:请求上下文(黑名单查询透传)。
  213. // - rawToken string:Authorization: Bearer <raw> 中的 raw 部分。
  214. //
  215. // 出参:
  216. // - Claims:验签通过的业务声明(含 Sub/Roles/InstitutionID/CampusID/JTI)。
  217. // - error:
  218. // - ErrRevoked:jti 命中黑名单(改密/登出/禁用后);
  219. // - ErrInvalidToken:签名错/exp 过期/iss 不符/格式损坏;
  220. // - 其它 error:黑名单存储故障(fail-closed,调用方映射 5001 + 告警,**不放行**)。
  221. //
  222. // 顺序说明:先查黑名单再验签——黑名单键含 jti,需要解析 JWT 拿 jti;
  223. // 实现上先做轻量格式解析(不验签)取 jti,命中黑名单直接拒(省一次 RSA 验签);
  224. // 未命中再完整验签。解析失败走 ErrInvalidToken。
  225. //
  226. // 样例(验签中间件,c 为 *fiber.Ctx,bearer 取自 Authorization: Bearer <token>):
  227. //
  228. // claims, err := token.Verify(c.Context(), pubKeys, revStore, issuer, bearer)
  229. // switch {
  230. // case errors.Is(err, ErrRevoked), errors.Is(err, ErrInvalidToken):
  231. // return response.Fail(c, errors.ErrUnauth) // 未登录/登录态失效
  232. // case err != nil:
  233. // return response.Fail(c, errors.WithCause(errors.ErrInternal, err)) // 黑名单存储故障(fail-closed)
  234. // }
  235. // sc := scope.Set(c, &scope.Scope{...来自 claims...}) // 注入多租户作用域
  236. func Verify(ctx context.Context, publicKeys []string, revocation RevocationStore, issuer string, rawToken string) (Claims, error) {
  237. var claims Claims
  238. // 轻量解析取 jti(不验签);格式错误直接判无效
  239. parser := jwt.NewParser(jwt.WithoutClaimsValidation())
  240. parsed, _, err := parser.ParseUnverified(rawToken, jwt.MapClaims{})
  241. if err != nil {
  242. return claims, ErrInvalidToken
  243. }
  244. jtiID := jtiFromClaims(parsed.Claims)
  245. if jtiID != "" && revocation != nil {
  246. revoked, rerr := revocation.IsRevoked(ctx, jtiID)
  247. if rerr != nil {
  248. return claims, rerr // fail-closed:存储故障不放行
  249. }
  250. if revoked {
  251. return claims, ErrRevoked
  252. }
  253. }
  254. // 完整验签(签名 + exp + iss);公钥列表支持密钥轮换期新旧并存
  255. for _, pemBlock := range publicKeys {
  256. parsed, err := jwt.ParseWithClaims(rawToken, &claims, func(t *jwt.Token) (interface{}, error) {
  257. if !strings.EqualFold(t.Method.Alg(), "RS256") {
  258. return nil, fmt.Errorf("token: 非预期签名算法 %s", t.Method.Alg())
  259. }
  260. return parseRSAPublicKey(pemBlock)
  261. }, jwt.WithIssuer(issuer), jwt.WithExpirationRequired(), jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}))
  262. if err == nil && parsed != nil && parsed.Valid {
  263. if claims.ID != jtiID {
  264. // 防御:两次解析 jti 不一致(理论不可能)按无效处理
  265. return Claims{}, ErrInvalidToken
  266. }
  267. return claims, nil
  268. }
  269. }
  270. return claims, ErrInvalidToken
  271. }
  272. // Rotate 用旧 refresh 换新 access + 新 refresh(refresh 轮换,防重放)。
  273. //
  274. // 入参:
  275. // - ctx context.Context:请求上下文。
  276. // - oldRefresh string:旧 refresh 不透明串(64 hex = 32 字节随机;格式非法直接 ErrRefreshInvalid)。
  277. //
  278. // 出参:
  279. // - accessToken string:新 access(claims 从旧 refresh 的 Redis 载荷恢复,JTI 换新)。
  280. // - newRefresh string:新 refresh(旧 jti 已从白名单删除——旧串再次使用即失效)。
  281. // - err error:
  282. // - ErrRefreshInvalid:串格式错 / 白名单无此 jti(已轮换/已登出/已过期);
  283. // - 载荷反序列化失败:按 ErrRefreshInvalid(脏数据不产生 5xx);
  284. // - 签发/写白名单失败:同 Issuer.Issue 的错误语义。
  285. //
  286. // 原子性说明:Get-Delete-Issue 三步在 Redis 单线程下靠"先删后写"收窄重放窗口;
  287. // 极端并发(同一旧串双请求)下,后到者 Get 已删 → ErrRefreshInvalid,安全侧收敛。
  288. //
  289. // 样例:
  290. //
  291. // access, newRefresh, err := iss.Rotate(ctx, oldRefresh)
  292. // // 响应 {accessToken, refreshToken, refreshExpiresAt}
  293. func (iss *Issuer) Rotate(ctx context.Context, oldRefresh string) (string, string, error) {
  294. raw, err := hex.DecodeString(oldRefresh)
  295. if err != nil || len(raw) != 32 {
  296. return "", "", ErrRefreshInvalid
  297. }
  298. rjti := hex.EncodeToString(raw[:8])
  299. payloadBytes, err := iss.refreshStore.GetRefresh(ctx, rjti)
  300. if err != nil || payloadBytes == nil {
  301. return "", "", ErrRefreshInvalid // 不存在/Redis 故障:一律按"无效"(故障时前端可重试登录)
  302. }
  303. var rp RefreshPayload
  304. if err := jsonUnmarshal(payloadBytes, &rp); err != nil {
  305. return "", "", ErrRefreshInvalid
  306. }
  307. if err := iss.refreshStore.DeleteRefresh(ctx, rjti); err != nil {
  308. return "", "", fmt.Errorf("token: 删除旧 refresh 失败: %w", err) // 删除失败不签发(宁可让用户重登,不放重放窗口)
  309. }
  310. access, refresh, err := iss.Issue(ctx, Claims{
  311. Sub: rp.Sub, Roles: rp.Roles,
  312. InstitutionID: rp.InstitutionID, CampusID: rp.CampusID,
  313. })
  314. if err != nil {
  315. return "", "", err
  316. }
  317. return access, refresh, nil
  318. }
  319. // RevokeAll 撤销该用户全部 access token(改密/禁用/删号/登出"多端下线"统一入口,
  320. // 架构规格 §7.4 改密链路)。
  321. //
  322. // 入参:
  323. // - ctx context.Context:请求上下文。
  324. // - jti string:目标 token 的 jti(已知单个);多端下线场景由调用方枚举该用户
  325. // 活跃 jti 列表后逐个调用(jti 枚举依赖 Redis 活跃集合,见 infra/redis 扩展)。
  326. // - ttl time.Duration:该 token 剩余有效期(黑名单键 TTL;≤0 时存储实现可跳过写入)。
  327. //
  328. // 出参:
  329. // - error:仅 Redis 写失败时非 nil(调用方:改密场景下失败必须**阻断改密提交**——
  330. // 凭证已变而旧 token 未吊销 = 安全漏洞;登录态场景可降级为告警)。
  331. //
  332. // 样例:
  333. //
  334. // // 改密成功后的撤销(事务外,Redis 操作)
  335. // if err := iss.RevokeAll(ctx, jti, remainingTTL); err != nil {
  336. // return errors.WithCause(errors.ErrInternal, err) // 阻断改密
  337. // }
  338. func (iss *Issuer) RevokeAll(ctx context.Context, jti string, ttl time.Duration) error {
  339. if iss.revocation == nil {
  340. return errors.New("token: Issuer 未装配 RevocationStore,无法吊销")
  341. }
  342. return iss.revocation.AddRevoked(ctx, jti, ttl)
  343. }
  344. // DeleteRefreshToken 删除指定 refresh token 的白名单键(登出/撤销 refresh 链路的入口,
  345. // 架构规格 §7.4 登出链路——让该 refresh 串**立即**失效,前端跳登录后该串再也换不出新 access)。
  346. //
  347. // 入参:
  348. // - ctx context.Context:请求上下文(透传给 Redis 删除)。
  349. // - oldRefresh string:待失效的 refresh 不透明串(64 字符 hex = 32 字节随机,Issue 返回的原样值)。
  350. //
  351. // 出参:
  352. // - error:以下场景非 nil:
  353. // - 串格式非法(非 64 hex)→ 包装错误(调用方传错,单测覆盖);
  354. // - refreshStore 删除失败(Redis 不可用)→ 包装 ErrDBError 类错误(调用方按"登出降级"处理:
  355. // 本地态必清,refresh 失效可延后——access 30min 后自然过期,且已吊销的 jti 黑名单兜底)。
  356. //
  357. // 幂等语义:refresh 键不存在(已轮换/已登出/已过期)时 DeleteRefresh 天然幂等(见 RefreshStore 接口),
  358. // 本方法不区分"删了"与"本来就没有",统一返回 nil——登出接口按幂等处理,前端无需关心。
  359. //
  360. // 与 Rotate 的关系:Rotate 是"换新"(删旧+发新),本方法是"只删不发"(登出场景不需要新凭证)。
  361. func (iss *Issuer) DeleteRefreshToken(ctx context.Context, oldRefresh string) error {
  362. raw, err := hex.DecodeString(oldRefresh)
  363. if err != nil || len(raw) != 32 {
  364. return errors.New("token: 非法的 refresh token 格式(须为 64 字符 hex = 32 字节随机)")
  365. }
  366. rjti := hex.EncodeToString(raw[:8]) // 与 Rotate 一致的 jti 因子提取
  367. if err := iss.refreshStore.DeleteRefresh(ctx, rjti); err != nil {
  368. return fmt.Errorf("token: 删除 refresh 白名单失败: %w", err)
  369. }
  370. return nil
  371. }
  372. // ---- 私有工具 ----
  373. // jtiFromClaims 从解析后的 claims 中取 jti(兼容 string / float64 两种 JSON 解码形态;
  374. // 空/异常形态返回 "",由调用方按"无可吊销信息"处理——不阻断验签)。
  375. func jtiFromClaims(claims jwt.Claims) string {
  376. if mc, ok := claims.(jwt.MapClaims); ok {
  377. switch v := mc["jti"].(type) {
  378. case string:
  379. return v
  380. case float64: // JWT 数字 jti 的 JSON 解码形态(本项目签发恒为 string,此为防御)
  381. return strconv.FormatInt(int64(v), 10)
  382. }
  383. }
  384. return ""
  385. }
  386. // newJTI 生成 token 唯一 ID(16 字节随机 hex;jti 同时用作吊销/轮换锚点)。
  387. func newJTI() string {
  388. buf := make([]byte, 16)
  389. _, _ = rand.Read(buf) // 熵源故障概率≈0;失败时退化为时间戳(仍唯一性足够,不阻断签发)
  390. return hex.EncodeToString(buf)
  391. }
  392. // parseRSAPrivateKey 解析 PEM 私钥(PKCS1 "RSA PRIVATE KEY" 或 PKCS8 "PRIVATE KEY")。
  393. func parseRSAPrivateKey(pemStr string) (*rsa.PrivateKey, error) {
  394. block, _ := pem.Decode([]byte(pemStr))
  395. if block == nil {
  396. return nil, errors.New("token: 私钥不是合法 PEM")
  397. }
  398. if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
  399. return k, nil
  400. }
  401. k8, err := x509.ParsePKCS8PrivateKey(block.Bytes)
  402. if err != nil {
  403. return nil, err
  404. }
  405. k, ok := k8.(*rsa.PrivateKey)
  406. if !ok {
  407. return nil, errors.New("token: 非 RSA 私钥(要求 RS256)")
  408. }
  409. return k, nil
  410. }
  411. // parseRSAPublicKey 解析 PEM 公钥(SPKI "PUBLIC KEY")。
  412. func parseRSAPublicKey(pemStr string) (*rsa.PublicKey, error) {
  413. block, _ := pem.Decode([]byte(pemStr))
  414. if block == nil {
  415. return nil, errors.New("token: 公钥不是合法 PEM")
  416. }
  417. any, err := x509.ParsePKIXPublicKey(block.Bytes)
  418. if err != nil {
  419. return nil, err
  420. }
  421. k, ok := any.(*rsa.PublicKey)
  422. if !ok {
  423. return nil, errors.New("token: 非 RSA 公钥(要求 RS256)")
  424. }
  425. return k, nil
  426. }
  427. // PublicKeysFromPrivateKey 由 RS256 私钥 PEM 推导公钥 PEM(SPKI "PUBLIC KEY")。
  428. //
  429. // 用途(单机双实例 ADR-015):双实例共享同一对 RS256 密钥,验签公钥可从本实例私钥直接推导——
  430. // 当 config.Auth.PublicKeysPEM 未配置时,main 装配用本方法从 PrivateKeyPEM 派生,
  431. // 避免"配了私钥却忘配公钥导致验签 fail-fast"的部署坑。生产密钥轮换期仍应在配置里显式给
  432. // PublicKeysPEM(新旧公钥并存 grace 期双验),本方法只作"未配置时的单钥兜底"。
  433. //
  434. // 入参:privateKeyPEM string:RS256 私钥 PEM(PKCS1 或 PKCS8;与 NewIssuer 同一入口)。
  435. // 出参:
  436. // - string:SPKI 公钥 PEM。
  437. // - error:私钥 PEM 解析失败(与 NewIssuer 同一 fail-fast 语义——带病不启动)。
  438. func PublicKeysFromPrivateKey(privateKeyPEM string) (string, error) {
  439. key, err := parseRSAPrivateKey(privateKeyPEM)
  440. if err != nil {
  441. return "", fmt.Errorf("token: 从私钥推导公钥失败: %w", err)
  442. }
  443. der, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
  444. if err != nil {
  445. return "", fmt.Errorf("token: 序列化公钥失败: %w", err)
  446. }
  447. return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})), nil
  448. }
  449. // jsonMarshal / jsonUnmarshal 薄封装(等价于 json.Marshal/Unmarshal,
  450. // 抽一层便于未来统一换加速实现,调用方不直接依赖 encoding/json)。
  451. func jsonMarshal(v interface{}) ([]byte, error) {
  452. return json.Marshal(v)
  453. }
  454. func jsonUnmarshal(b []byte, v interface{}) error {
  455. return json.Unmarshal(b, v)
  456. }