|
|
@@ -0,0 +1,425 @@
|
|
|
+// Package token SSO token 签发/验签/吊销(ADR-014 最小可行 SSO)。
|
|
|
+//
|
|
|
+// 本包实现架构规格 §7.2 的 Token 规范:
|
|
|
+// - access:JWT RS256,有效期 30min;claims 含 sub(canonical_user_id ULID)、
|
|
|
+// roles[]、institution_id、campus_id、jti(吊销维度);
|
|
|
+// - refresh:不透明随机串(32 字节 hex),有效期 30 天;Redis 存白名单
|
|
|
+// (键 refresh:{jti} → 载荷),刷新时**轮换**(旧 jti 立即失效,防重放);
|
|
|
+// - 本地验签:验签只用公钥(进程内存),不依赖中心服务——grace 期语义:
|
|
|
+// 滚动重启/服务抖动时,30min 内已签发的 access 仍可通过本地验签通过(ADR-015);
|
|
|
+// - 吊销:改密/禁用/登出 → 写 Redis 黑名单 jti(TTL = 该 access 剩余有效期),
|
|
|
+// 验签中间件**先查黑名单再验签名**(黑名单命中即拒,无需解析 JWT 载荷)。
|
|
|
+//
|
|
|
+// 典型使用:
|
|
|
+//
|
|
|
+// // 启动时装配签发器(私钥/公钥/过期参数/Redis 存储来自 config + infra)
|
|
|
+// iss, _ := token.NewIssuer(cfg.Auth.PrivateKeyPEM, cfg.Auth.Issuer,
|
|
|
+// 30*time.Minute, 30*24*time.Hour, redisStore, redisStore)
|
|
|
+//
|
|
|
+// // 登录成功后签发一对凭证
|
|
|
+// access, refresh, err := iss.Issue(ctx, token.Claims{
|
|
|
+// Sub: canonicalUID, UserID: uid, Roles: []string{"2"},
|
|
|
+// InstitutionID: 11, CampusID: 3,
|
|
|
+// })
|
|
|
+// // 响应 {accessToken: access, refreshToken: refresh, user: ...}
|
|
|
+//
|
|
|
+// // 验签中间件(每个受保护 Handler 前置)
|
|
|
+// claims, err := token.Verify(c.Context(), cfg.Auth.PublicKeysPEM, redisStore,
|
|
|
+// cfg.Auth.Issuer, bearer)
|
|
|
+// if err != nil { _ = response.Fail(c, errors.ErrUnauth); return }
|
|
|
+// // 把 claims 注入多租户作用域(见 infra/scope)
|
|
|
+// scope.Set(c, &scope.Scope{InstitutionID: claims.InstitutionID, CampusID: claims.CampusID, ...})
|
|
|
+package token
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "crypto/rand"
|
|
|
+ "crypto/rsa"
|
|
|
+ "crypto/x509"
|
|
|
+ "encoding/hex"
|
|
|
+ "encoding/json"
|
|
|
+ "encoding/pem"
|
|
|
+ "errors"
|
|
|
+ "fmt"
|
|
|
+ "strconv"
|
|
|
+ "strings"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "github.com/golang-jwt/jwt/v5"
|
|
|
+)
|
|
|
+
|
|
|
+// ErrInvalidToken access token 无效(签名错误/exp/iss 不符/格式损坏)。
|
|
|
+// 调用方(验签中间件)统一映射为 code=1001 未登录(不向用户细分原因)。
|
|
|
+var ErrInvalidToken = errors.New("token: access token 无效")
|
|
|
+
|
|
|
+// ErrRevoked access token 已被吊销(jti 命中 Redis 黑名单)。
|
|
|
+// 调用方映射为 code=1002 登录态失效(与"过期"区分,便于前端决定"静默 refresh 还是跳登录")。
|
|
|
+var ErrRevoked = errors.New("token: 登录态已失效")
|
|
|
+
|
|
|
+// ErrRefreshInvalid refresh token 无效(不在 Redis 白名单/已轮换/已过期)。
|
|
|
+var ErrRefreshInvalid = errors.New("token: refresh token 无效")
|
|
|
+
|
|
|
+// RefreshStore refresh 白名单存储接口(Redis 实现见 infra/redis;
|
|
|
+// 接口在 identity 侧定义,消费方 identity,infra 只提供 Client——
|
|
|
+// 依赖方向:identity → infra,infra 不依赖 identity)。
|
|
|
+type RefreshStore interface {
|
|
|
+ // SetRefresh 写入 refresh 白名单(键 refresh:{jti},TTL=有效期)。
|
|
|
+ // 入参:ctx 上下文;jti 唯一标识;payload []byte 载荷(序列化后的 RefreshPayload);ttl 有效期。
|
|
|
+ // 出参:error——仅 Redis 不可用时非 nil(登录接口此时返回 4001/5001 降级,不放行)。
|
|
|
+ SetRefresh(ctx context.Context, jti string, payload []byte, ttl time.Duration) error
|
|
|
+ // GetRefresh 读取 refresh 白名单。
|
|
|
+ // 入参:ctx、jti。出参:payload []byte(不存在时 nil)+ error。
|
|
|
+ GetRefresh(ctx context.Context, jti string) ([]byte, error)
|
|
|
+ // DeleteRefresh 删除(轮换/登出/撤销时调用)。幂等:键不存在不报错。
|
|
|
+ DeleteRefresh(ctx context.Context, jti string) error
|
|
|
+}
|
|
|
+
|
|
|
+// RevocationStore 吊销黑名单存储接口(jti 维度)。
|
|
|
+type RevocationStore interface {
|
|
|
+ // AddRevoked 写入黑名单(键 {prefix}:{jti},TTL=该 access 剩余有效期;
|
|
|
+ // 剩余 ≤0 时实现方可直接跳过——token 本已过期,无需吊销)。
|
|
|
+ AddRevoked(ctx context.Context, jti string, ttl time.Duration) error
|
|
|
+ // IsRevoked 查询 jti 是否被吊销。
|
|
|
+ // 出参:bool 命中;error——Redis 故障时返回 error,调用方策略:**fail-closed**
|
|
|
+ // (拒绝请求 + 告警),不可 fail-open(吊销失效 = 改密后旧 token 仍可用,安全红线)。
|
|
|
+ IsRevoked(ctx context.Context, jti string) (bool, error)
|
|
|
+}
|
|
|
+
|
|
|
+// Claims access token 的业务声明(嵌入 jwt.RegisteredClaims)。
|
|
|
+//
|
|
|
+// 字段(与 ADR-014 契约对齐):
|
|
|
+// - Sub:canonical_user_id(ULID,26 字符;**禁止**用旧 int user_id 作 sub)。
|
|
|
+// - UserID:内部 int 用户 ID(兼容过渡期旧表外键查询;新表一律 Sub)。
|
|
|
+// - Roles:角色码数组(7 角色字典:"1"超管/"2"教务/"3"财务/"4"老师/"5"学生/"6"家长/"8"市场;
|
|
|
+// 市场登录禁用,正常不会出现 "9")。
|
|
|
+// - InstitutionID / CampusID:多租户作用域(Repository scoping 依据,scope.FromCtx 来源)。
|
|
|
+// - JTI:走嵌入的 RegisteredClaims.ID(JSON 名 "jti")——吊销/轮换锚点;
|
|
|
+// **不要**再自定义 jti 字段(浅层字段会遮蔽 RegisteredClaims.ID,导致解析侧 ID 恒空)。
|
|
|
+type Claims struct {
|
|
|
+ Sub string `json:"sub"`
|
|
|
+ UserID int64 `json:"uid"`
|
|
|
+ Roles []string `json:"roles"`
|
|
|
+ InstitutionID int64 `json:"institution_id"`
|
|
|
+ CampusID int64 `json:"campus_id"`
|
|
|
+ jwt.RegisteredClaims
|
|
|
+}
|
|
|
+
|
|
|
+// RefreshPayload refresh token 在 Redis 白名单中的载荷(不透明串本身只含 jti 的随机因子,
|
|
|
+// 业务载荷存 Redis——refresh 泄露时服务端可即时删除,JWT 做不到)。
|
|
|
+//
|
|
|
+// 字段:
|
|
|
+// - Sub / UserID / Roles / InstitutionID / CampusID:与 access 同源(刷新时原样带入新 access)。
|
|
|
+// - CreatedAt time.Time:签发时刻(审计 + 防"未来 refresh")。
|
|
|
+type RefreshPayload struct {
|
|
|
+ Sub string `json:"sub"`
|
|
|
+ UserID int64 `json:"uid"`
|
|
|
+ Roles []string `json:"roles"`
|
|
|
+ InstitutionID int64 `json:"institution_id"`
|
|
|
+ CampusID int64 `json:"campus_id"`
|
|
|
+ CreatedAt time.Time `json:"created_at"`
|
|
|
+}
|
|
|
+
|
|
|
+// Issuer access/refresh 签发器(持有 RS256 私钥 + 过期参数 + jti 生成器)。
|
|
|
+type Issuer struct {
|
|
|
+ key *rsa.PrivateKey
|
|
|
+ issuer string
|
|
|
+ accessTTL time.Duration
|
|
|
+ refreshTTL time.Duration
|
|
|
+ refreshStore RefreshStore
|
|
|
+ revocation RevocationStore
|
|
|
+}
|
|
|
+
|
|
|
+// NewIssuer 构造签发器(启动时装配一次,全进程共享;私钥解析失败应 fail-fast 阻止启动)。
|
|
|
+//
|
|
|
+// 入参:
|
|
|
+// - privateKeyPEM string:RS256 私钥 PEM(PKCS1 或 PKCS8;配置 AuthCfg.PrivateKeyPEM,走环境变量)。
|
|
|
+// - issuer string:token iss 声明(如 "https://unified.example.com")。
|
|
|
+// - accessTTL time.Duration:access 有效期(固定 30min,架构规格 §7.2)。
|
|
|
+// - refreshTTL time.Duration:refresh 有效期(固定 30 天)。
|
|
|
+// - refreshStore RefreshStore:refresh 白名单存储(Redis 实现)。
|
|
|
+// - revocation RevocationStore:吊销黑名单存储(可为 nil——仅纯签发场景;验签必须提供)。
|
|
|
+//
|
|
|
+// 出参:
|
|
|
+// - *Issuer:就绪的签发器。
|
|
|
+// - error:私钥 PEM 解析失败(PKCS1/PKCS8 都不匹配时);其余入参不校验(调用方责任)。
|
|
|
+//
|
|
|
+// 异常场景:
|
|
|
+// - 双实例共享同一对 RS256 密钥(ADR-015 单机双实例):两实例签发 token 互相可验,
|
|
|
+// refresh/吊销走共享 Redis,天然一致——**无需** per-instance 密钥。
|
|
|
+func NewIssuer(privateKeyPEM, issuer string, accessTTL, refreshTTL time.Duration,
|
|
|
+ refreshStore RefreshStore, revocation RevocationStore) (*Issuer, error) {
|
|
|
+ key, err := parseRSAPrivateKey(privateKeyPEM)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("token: 解析 RS256 私钥失败: %w", err)
|
|
|
+ }
|
|
|
+ return &Issuer{
|
|
|
+ key: key,
|
|
|
+ issuer: issuer,
|
|
|
+ accessTTL: accessTTL,
|
|
|
+ refreshTTL: refreshTTL,
|
|
|
+ refreshStore: refreshStore,
|
|
|
+ revocation: revocation,
|
|
|
+ }, nil
|
|
|
+}
|
|
|
+
|
|
|
+// Issue 签发一对 access + refresh(登录/refresh 轮换成功后调用)。
|
|
|
+//
|
|
|
+// 入参:
|
|
|
+// - ctx context.Context:请求上下文(透传给 Redis 存储操作)。
|
|
|
+// - claims Claims:业务声明(Sub 必填且应为合法 ULID;JTI 留空由本函数填充)。
|
|
|
+//
|
|
|
+// 出参:
|
|
|
+// - accessToken string:JWT(RS256 签名,含 sub/roles/institution_id/campus_id/jti +
|
|
|
+// iss/iat/exp/nbf;exp=iat+accessTTL)。
|
|
|
+// - refreshToken string:不透明串(48 字符 hex:32 字节随机,前 8 字节为 jti 因子)。
|
|
|
+// - err error:以下场景非 nil:
|
|
|
+// - claims.Sub 为空 → 包装错误(调用方 bug,单测覆盖);
|
|
|
+// - 随机数生成失败(熵源故障);
|
|
|
+// - refreshStore 写入失败(Redis 不可用)→ 登录整体失败,不返回"只有 access"的半套凭证。
|
|
|
+//
|
|
|
+// 样例:
|
|
|
+//
|
|
|
+// access, refresh, err := iss.Issue(ctx, token.Claims{Sub: uid, UserID: 123,
|
|
|
+// Roles: []string{"4"}, InstitutionID: 11, CampusID: 3})
|
|
|
+func (iss *Issuer) Issue(ctx context.Context, claims Claims) (accessToken, refreshToken string, err error) {
|
|
|
+ if claims.Sub == "" {
|
|
|
+ return "", "", errors.New("token: Issue 要求 claims.Sub(canonical_user_id)非空")
|
|
|
+ }
|
|
|
+ now := time.Now()
|
|
|
+ claims.IssuedAt = jwt.NewNumericDate(now)
|
|
|
+ claims.NotBefore = jwt.NewNumericDate(now)
|
|
|
+ claims.ExpiresAt = jwt.NewNumericDate(now.Add(iss.accessTTL))
|
|
|
+ claims.Issuer = iss.issuer
|
|
|
+ claims.ID = newJTI() // 每次签发唯一(吊销/轮换的锚点)
|
|
|
+
|
|
|
+ token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
|
|
+ signed, err := token.SignedString(iss.key)
|
|
|
+ if err != nil {
|
|
|
+ return "", "", fmt.Errorf("token: 签发 access 失败: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // refresh:32 字节随机 → hex;jti 因子取前 8 字节(白名单键)
|
|
|
+ buf := make([]byte, 32)
|
|
|
+ if _, err := rand.Read(buf); err != nil {
|
|
|
+ return "", "", fmt.Errorf("token: 生成 refresh 随机数失败: %w", err)
|
|
|
+ }
|
|
|
+ rjti := hex.EncodeToString(buf[:8])
|
|
|
+ payload, _ := jsonMarshal(RefreshPayload{
|
|
|
+ Sub: claims.Sub, UserID: claims.UserID, Roles: claims.Roles,
|
|
|
+ InstitutionID: claims.InstitutionID, CampusID: claims.CampusID,
|
|
|
+ CreatedAt: now,
|
|
|
+ })
|
|
|
+ if err := iss.refreshStore.SetRefresh(ctx, rjti, payload, iss.refreshTTL); err != nil {
|
|
|
+ return "", "", fmt.Errorf("token: 写 refresh 白名单失败: %w", err)
|
|
|
+ }
|
|
|
+ refreshToken = hex.EncodeToString(buf)
|
|
|
+ return signed, refreshToken, nil
|
|
|
+}
|
|
|
+
|
|
|
+// Verify 验签 access token(验签中间件入口):先查吊销黑名单 → 再验 RS256 签名与 exp/iss。
|
|
|
+//
|
|
|
+// 入参:
|
|
|
+// - ctx context.Context:请求上下文(黑名单查询透传)。
|
|
|
+// - rawToken string:Authorization: Bearer <raw> 中的 raw 部分。
|
|
|
+//
|
|
|
+// 出参:
|
|
|
+// - Claims:验签通过的业务声明(含 Sub/Roles/InstitutionID/CampusID/JTI)。
|
|
|
+// - error:
|
|
|
+// - ErrRevoked:jti 命中黑名单(改密/登出/禁用后);
|
|
|
+// - ErrInvalidToken:签名错/exp 过期/iss 不符/格式损坏;
|
|
|
+// - 其它 error:黑名单存储故障(fail-closed,调用方映射 5001 + 告警,**不放行**)。
|
|
|
+//
|
|
|
+// 顺序说明:先查黑名单再验签——黑名单键含 jti,需要解析 JWT 拿 jti;
|
|
|
+// 实现上先做轻量格式解析(不验签)取 jti,命中黑名单直接拒(省一次 RSA 验签);
|
|
|
+// 未命中再完整验签。解析失败走 ErrInvalidToken。
|
|
|
+//
|
|
|
+// 样例(验签中间件,c 为 *fiber.Ctx,bearer 取自 Authorization: Bearer <token>):
|
|
|
+//
|
|
|
+// claims, err := token.Verify(c.Context(), pubKeys, revStore, issuer, bearer)
|
|
|
+// switch {
|
|
|
+// case errors.Is(err, ErrRevoked), errors.Is(err, ErrInvalidToken):
|
|
|
+// return response.Fail(c, errors.ErrUnauth) // 未登录/登录态失效
|
|
|
+// case err != nil:
|
|
|
+// return response.Fail(c, errors.WithCause(errors.ErrInternal, err)) // 黑名单存储故障(fail-closed)
|
|
|
+// }
|
|
|
+// sc := scope.Set(c, &scope.Scope{...来自 claims...}) // 注入多租户作用域
|
|
|
+func Verify(ctx context.Context, publicKeys []string, revocation RevocationStore, issuer string, rawToken string) (Claims, error) {
|
|
|
+ var claims Claims
|
|
|
+ // 轻量解析取 jti(不验签);格式错误直接判无效
|
|
|
+ parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
|
|
+ parsed, _, err := parser.ParseUnverified(rawToken, jwt.MapClaims{})
|
|
|
+ if err != nil {
|
|
|
+ return claims, ErrInvalidToken
|
|
|
+ }
|
|
|
+ jtiID := jtiFromClaims(parsed.Claims)
|
|
|
+ if jtiID != "" && revocation != nil {
|
|
|
+ revoked, rerr := revocation.IsRevoked(ctx, jtiID)
|
|
|
+ if rerr != nil {
|
|
|
+ return claims, rerr // fail-closed:存储故障不放行
|
|
|
+ }
|
|
|
+ if revoked {
|
|
|
+ return claims, ErrRevoked
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 完整验签(签名 + exp + iss);公钥列表支持密钥轮换期新旧并存
|
|
|
+ for _, pemBlock := range publicKeys {
|
|
|
+ parsed, err := jwt.ParseWithClaims(rawToken, &claims, func(t *jwt.Token) (interface{}, error) {
|
|
|
+ if !strings.EqualFold(t.Method.Alg(), "RS256") {
|
|
|
+ return nil, fmt.Errorf("token: 非预期签名算法 %s", t.Method.Alg())
|
|
|
+ }
|
|
|
+ return parseRSAPublicKey(pemBlock)
|
|
|
+ }, jwt.WithIssuer(issuer), jwt.WithExpirationRequired(), jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}))
|
|
|
+ if err == nil && parsed != nil && parsed.Valid {
|
|
|
+ if claims.ID != jtiID {
|
|
|
+ // 防御:两次解析 jti 不一致(理论不可能)按无效处理
|
|
|
+ return Claims{}, ErrInvalidToken
|
|
|
+ }
|
|
|
+ return claims, nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return claims, ErrInvalidToken
|
|
|
+}
|
|
|
+
|
|
|
+// Rotate 用旧 refresh 换新 access + 新 refresh(refresh 轮换,防重放)。
|
|
|
+//
|
|
|
+// 入参:
|
|
|
+// - ctx context.Context:请求上下文。
|
|
|
+// - oldRefresh string:旧 refresh 不透明串(48 hex;格式非法直接 ErrRefreshInvalid)。
|
|
|
+//
|
|
|
+// 出参:
|
|
|
+// - accessToken string:新 access(claims 从旧 refresh 的 Redis 载荷恢复,JTI 换新)。
|
|
|
+// - newRefresh string:新 refresh(旧 jti 已从白名单删除——旧串再次使用即失效)。
|
|
|
+// - err error:
|
|
|
+// - ErrRefreshInvalid:串格式错 / 白名单无此 jti(已轮换/已登出/已过期);
|
|
|
+// - 载荷反序列化失败:按 ErrRefreshInvalid(脏数据不产生 5xx);
|
|
|
+// - 签发/写白名单失败:同 Issuer.Issue 的错误语义。
|
|
|
+//
|
|
|
+// 原子性说明:Get-Delete-Issue 三步在 Redis 单线程下靠"先删后写"收窄重放窗口;
|
|
|
+// 极端并发(同一旧串双请求)下,后到者 Get 已删 → ErrRefreshInvalid,安全侧收敛。
|
|
|
+//
|
|
|
+// 样例:
|
|
|
+//
|
|
|
+// access, newRefresh, err := iss.Rotate(ctx, oldRefresh)
|
|
|
+// // 响应 {accessToken, refreshToken, refreshExpiresAt}
|
|
|
+func (iss *Issuer) Rotate(ctx context.Context, oldRefresh string) (string, string, error) {
|
|
|
+ raw, err := hex.DecodeString(oldRefresh)
|
|
|
+ if err != nil || len(raw) != 32 {
|
|
|
+ return "", "", ErrRefreshInvalid
|
|
|
+ }
|
|
|
+ rjti := hex.EncodeToString(raw[:8])
|
|
|
+ payloadBytes, err := iss.refreshStore.GetRefresh(ctx, rjti)
|
|
|
+ if err != nil || payloadBytes == nil {
|
|
|
+ return "", "", ErrRefreshInvalid // 不存在/Redis 故障:一律按"无效"(故障时前端可重试登录)
|
|
|
+ }
|
|
|
+ var rp RefreshPayload
|
|
|
+ if err := jsonUnmarshal(payloadBytes, &rp); err != nil {
|
|
|
+ return "", "", ErrRefreshInvalid
|
|
|
+ }
|
|
|
+ if err := iss.refreshStore.DeleteRefresh(ctx, rjti); err != nil {
|
|
|
+ return "", "", fmt.Errorf("token: 删除旧 refresh 失败: %w", err) // 删除失败不签发(宁可让用户重登,不放重放窗口)
|
|
|
+ }
|
|
|
+ access, refresh, err := iss.Issue(ctx, Claims{
|
|
|
+ Sub: rp.Sub, UserID: rp.UserID, Roles: rp.Roles,
|
|
|
+ InstitutionID: rp.InstitutionID, CampusID: rp.CampusID,
|
|
|
+ })
|
|
|
+ if err != nil {
|
|
|
+ return "", "", err
|
|
|
+ }
|
|
|
+ return access, refresh, nil
|
|
|
+}
|
|
|
+
|
|
|
+// RevokeAll 撤销该用户全部 access token(改密/禁用/删号/登出"多端下线"统一入口,
|
|
|
+// 架构规格 §7.4 改密链路)。
|
|
|
+//
|
|
|
+// 入参:
|
|
|
+// - ctx context.Context:请求上下文。
|
|
|
+// - jti string:目标 token 的 jti(已知单个);多端下线场景由调用方枚举该用户
|
|
|
+// 活跃 jti 列表后逐个调用(jti 枚举依赖 Redis 活跃集合,见 infra/redis 扩展)。
|
|
|
+// - ttl time.Duration:该 token 剩余有效期(黑名单键 TTL;≤0 时存储实现可跳过写入)。
|
|
|
+//
|
|
|
+// 出参:
|
|
|
+// - error:仅 Redis 写失败时非 nil(调用方:改密场景下失败必须**阻断改密提交**——
|
|
|
+// 凭证已变而旧 token 未吊销 = 安全漏洞;登录态场景可降级为告警)。
|
|
|
+//
|
|
|
+// 样例:
|
|
|
+//
|
|
|
+// // 改密成功后的撤销(事务外,Redis 操作)
|
|
|
+// if err := iss.RevokeAll(ctx, jti, remainingTTL); err != nil {
|
|
|
+// return errors.WithCause(errors.ErrInternal, err) // 阻断改密
|
|
|
+// }
|
|
|
+func (iss *Issuer) RevokeAll(ctx context.Context, jti string, ttl time.Duration) error {
|
|
|
+ if iss.revocation == nil {
|
|
|
+ return errors.New("token: Issuer 未装配 RevocationStore,无法吊销")
|
|
|
+ }
|
|
|
+ return iss.revocation.AddRevoked(ctx, jti, ttl)
|
|
|
+}
|
|
|
+
|
|
|
+// ---- 私有工具 ----
|
|
|
+
|
|
|
+// jtiFromClaims 从解析后的 claims 中取 jti(兼容 string / float64 两种 JSON 解码形态;
|
|
|
+// 空/异常形态返回 "",由调用方按"无可吊销信息"处理——不阻断验签)。
|
|
|
+func jtiFromClaims(claims jwt.Claims) string {
|
|
|
+ if mc, ok := claims.(jwt.MapClaims); ok {
|
|
|
+ switch v := mc["jti"].(type) {
|
|
|
+ case string:
|
|
|
+ return v
|
|
|
+ case float64: // JWT 数字 jti 的 JSON 解码形态(本项目签发恒为 string,此为防御)
|
|
|
+ return strconv.FormatInt(int64(v), 10)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return ""
|
|
|
+}
|
|
|
+
|
|
|
+// newJTI 生成 token 唯一 ID(16 字节随机 hex;jti 同时用作吊销/轮换锚点)。
|
|
|
+func newJTI() string {
|
|
|
+ buf := make([]byte, 16)
|
|
|
+ _, _ = rand.Read(buf) // 熵源故障概率≈0;失败时退化为时间戳(仍唯一性足够,不阻断签发)
|
|
|
+ return hex.EncodeToString(buf)
|
|
|
+}
|
|
|
+
|
|
|
+// parseRSAPrivateKey 解析 PEM 私钥(PKCS1 "RSA PRIVATE KEY" 或 PKCS8 "PRIVATE KEY")。
|
|
|
+func parseRSAPrivateKey(pemStr string) (*rsa.PrivateKey, error) {
|
|
|
+ block, _ := pem.Decode([]byte(pemStr))
|
|
|
+ if block == nil {
|
|
|
+ return nil, errors.New("token: 私钥不是合法 PEM")
|
|
|
+ }
|
|
|
+ if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
|
|
+ return k, nil
|
|
|
+ }
|
|
|
+ k8, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ k, ok := k8.(*rsa.PrivateKey)
|
|
|
+ if !ok {
|
|
|
+ return nil, errors.New("token: 非 RSA 私钥(要求 RS256)")
|
|
|
+ }
|
|
|
+ return k, nil
|
|
|
+}
|
|
|
+
|
|
|
+// parseRSAPublicKey 解析 PEM 公钥(SPKI "PUBLIC KEY")。
|
|
|
+func parseRSAPublicKey(pemStr string) (*rsa.PublicKey, error) {
|
|
|
+ block, _ := pem.Decode([]byte(pemStr))
|
|
|
+ if block == nil {
|
|
|
+ return nil, errors.New("token: 公钥不是合法 PEM")
|
|
|
+ }
|
|
|
+ any, err := x509.ParsePKIXPublicKey(block.Bytes)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ k, ok := any.(*rsa.PublicKey)
|
|
|
+ if !ok {
|
|
|
+ return nil, errors.New("token: 非 RSA 公钥(要求 RS256)")
|
|
|
+ }
|
|
|
+ return k, nil
|
|
|
+}
|
|
|
+
|
|
|
+// jsonMarshal / jsonUnmarshal 薄封装(等价于 json.Marshal/Unmarshal,
|
|
|
+// 抽一层便于未来统一换加速实现,调用方不直接依赖 encoding/json)。
|
|
|
+func jsonMarshal(v interface{}) ([]byte, error) {
|
|
|
+ return json.Marshal(v)
|
|
|
+}
|
|
|
+
|
|
|
+func jsonUnmarshal(b []byte, v interface{}) error {
|
|
|
+ return json.Unmarshal(b, v)
|
|
|
+}
|