token_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. // token 包单测(T4-6 验收):Issue / Verify / Rotate / Revoke / DeleteRefreshToken + 错误路径。
  2. //
  3. // 用 **in-memory** 的 RefreshStore / RevocationStore(纯 map,不连 Redis)+ 真 RS256 密钥,
  4. // 专注 token 包的签发/验签/轮换/吊销逻辑(Redis 存储层的语义已由各 *Store 的 miniredis 测试覆盖,
  5. // 见 infra/redis/store_test.go)。
  6. //
  7. // 覆盖:
  8. // - NewIssuer:私钥解析成功 / PKCS8 / 非法 PEM 报错 / 非 RSA 报错
  9. // - Issue:Sub 必填校验 / 签出可验的 access + refresh(rjti 写白名单)
  10. // - Verify:合法 token 通过 / 签名错 / 过期 / iss 不符 / 黑名单命中→ErrRevoked
  11. // - Rotate:旧 refresh 换新对 / 旧串立即失效(再 Rotate→ErrRefreshInvalid)/ 非法格式
  12. // - DeleteRefreshToken:合法 64hex 删除 / 非法格式报错 / 幂等(不存在不报错)
  13. package token
  14. import (
  15. "context"
  16. stderrors "errors"
  17. "fmt"
  18. "testing"
  19. "time"
  20. )
  21. // ---- in-memory 存储 ----
  22. // memRefresh 纯 map 的 refresh 白名单。
  23. type memRefresh struct{ m map[string][]byte }
  24. func newMemRefresh() *memRefresh { return &memRefresh{m: map[string][]byte{}} }
  25. func (s *memRefresh) SetRefresh(_ context.Context, jti string, payload []byte, _ time.Duration) error {
  26. s.m[jti] = payload
  27. return nil
  28. }
  29. func (s *memRefresh) GetRefresh(_ context.Context, jti string) ([]byte, error) {
  30. b, ok := s.m[jti]
  31. if !ok {
  32. return nil, nil
  33. }
  34. return b, nil
  35. }
  36. func (s *memRefresh) DeleteRefresh(_ context.Context, jti string) error {
  37. delete(s.m, jti) // 幂等:不存在不报错
  38. return nil
  39. }
  40. // memRevocation 纯 map 的吊销黑名单(可注入故障模拟 Redis 不可用)。
  41. type memRevocation struct {
  42. m map[string]struct{}
  43. failOn bool // 置 true 时 IsRevoked 返 error(模拟存储故障,验 fail-closed)
  44. }
  45. func newMemRevocation() *memRevocation { return &memRevocation{m: map[string]struct{}{}} }
  46. func (s *memRevocation) AddRevoked(_ context.Context, jti string, _ time.Duration) error {
  47. s.m[jti] = struct{}{}
  48. return nil
  49. }
  50. func (s *memRevocation) IsRevoked(_ context.Context, jti string) (bool, error) {
  51. if s.failOn {
  52. return false, stderrors.New("mem: 模拟存储故障")
  53. }
  54. _, ok := s.m[jti]
  55. return ok, nil
  56. }
  57. var (
  58. _ RefreshStore = (*memRefresh)(nil)
  59. _ RevocationStore = (*memRevocation)(nil)
  60. )
  61. // testKey 造一对 RS2048 密钥(PKCS1 私钥 + PKIX 公钥)+ 就绪 Issuer。
  62. func testKey(t *testing.T, accessTTL time.Duration) (privPEM string, pubPEM string, iss *Issuer, ref *memRefresh, rev *memRevocation) {
  63. t.Helper()
  64. privPEM, pubPEM = genKeyPair(t)
  65. ref = newMemRefresh()
  66. rev = newMemRevocation()
  67. var err error
  68. iss, err = NewIssuer(privPEM, "https://unified.test", accessTTL, 30*24*time.Hour, ref, rev)
  69. if err != nil {
  70. t.Fatalf("NewIssuer: %v", err)
  71. }
  72. return privPEM, pubPEM, iss, ref, rev
  73. }
  74. // ---- NewIssuer ----
  75. // TestNewIssuer_InvalidPEM 非法 PEM:报错(fail-fast 不启动)。
  76. func TestNewIssuer_InvalidPEM(t *testing.T) {
  77. _, err := NewIssuer("not-a-pem", "https://x", 30*time.Minute, 30*24*time.Hour, newMemRefresh(), newMemRevocation())
  78. if err == nil {
  79. t.Fatal("期望非法 PEM 报错,实际 nil")
  80. }
  81. }
  82. // TestNewIssuer_PKCS8 PKCS8 私钥也能解析(与 PKCS1 并列支持)。
  83. func TestNewIssuer_PKCS8(t *testing.T) {
  84. priv8, pubPEM := genKeyPairPKCS8(t)
  85. iss, err := NewIssuer(priv8, "https://x", 30*time.Minute, 30*24*time.Hour, newMemRefresh(), newMemRevocation())
  86. if err != nil {
  87. t.Fatalf("PKCS8 解析失败: %v", err)
  88. }
  89. access, _, err := iss.Issue(context.Background(), Claims{Sub: "uid-1"})
  90. if err != nil {
  91. t.Fatalf("Issue: %v", err)
  92. }
  93. if _, err := Verify(context.Background(), []string{pubPEM}, newMemRevocation(), "https://x", access); err != nil {
  94. t.Fatalf("PKCS8 签的 token 应可验: %v", err)
  95. }
  96. }
  97. // ---- Issue ----
  98. // TestIssue_EmptySub Sub 为空:报错(调用方 bug,单测暴露)。
  99. func TestIssue_EmptySub(t *testing.T) {
  100. _, _, iss, _, _ := testKey(t, 30*time.Minute)
  101. _, _, err := iss.Issue(context.Background(), Claims{})
  102. if err == nil {
  103. t.Fatal("期望空 Sub 报错,实际 nil")
  104. }
  105. }
  106. // TestIssue_RoundTrip 签发后 access 可被验签、refresh 写入白名单。
  107. func TestIssue_RoundTrip(t *testing.T) {
  108. _, pubPEM, iss, ref, rev := testKey(t, 30*time.Minute)
  109. ctx := context.Background()
  110. access, refresh, err := iss.Issue(ctx, Claims{Sub: "uid-1", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3})
  111. if err != nil {
  112. t.Fatalf("Issue: %v", err)
  113. }
  114. if access == "" || refresh == "" {
  115. t.Fatalf("token 对缺失: %q / %q", access, refresh)
  116. }
  117. claims, err := Verify(ctx, []string{pubPEM}, rev, "https://unified.test", access)
  118. if err != nil {
  119. t.Fatalf("Verify: %v", err)
  120. }
  121. if claims.Sub != "uid-1" || claims.InstitutionID != 11 || claims.CampusID != 3 {
  122. t.Fatalf("claims=%+v(期望 uid-1/11/3)", claims)
  123. }
  124. if len(claims.Roles) != 1 || claims.Roles[0] != "teacher" {
  125. t.Fatalf("roles=%v(期望 [teacher])", claims.Roles)
  126. }
  127. // refresh 应已写白名单(jti = 前 8 字节 hex)
  128. rjti := refresh[:16] // 前 8 字节 → 16 hex 字符
  129. if _, ok := ref.m[rjti]; !ok {
  130. t.Fatalf("refresh 白名单未写入 jti=%s", rjti)
  131. }
  132. }
  133. // ---- Verify ----
  134. // TestVerify_Revoked 黑名单命中:ErrRevoked。
  135. func TestVerify_Revoked(t *testing.T) {
  136. _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute)
  137. access, _, err := iss.Issue(context.Background(), Claims{Sub: "uid-1"})
  138. if err != nil {
  139. t.Fatalf("Issue: %v", err)
  140. }
  141. claims, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", access)
  142. if err != nil {
  143. t.Fatalf("预验签取 jti: %v", err)
  144. }
  145. _ = rev.AddRevoked(context.Background(), claims.ID, time.Minute)
  146. if _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", access); !stderrors.Is(err, ErrRevoked) {
  147. t.Fatalf("期望 ErrRevoked,实际 %v", err)
  148. }
  149. }
  150. // TestVerify_StorageFailClosed 黑名单存储故障:返回 error(fail-closed,非 ErrRevoked/ErrInvalidToken)。
  151. func TestVerify_StorageFailClosed(t *testing.T) {
  152. _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute)
  153. access, _, _ := iss.Issue(context.Background(), Claims{Sub: "uid-1"})
  154. rev.failOn = true // 模拟 Redis 故障
  155. _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", access)
  156. if err == nil || stderrors.Is(err, ErrInvalidToken) || stderrors.Is(err, ErrRevoked) {
  157. t.Fatalf("期望存储故障 error(fail-closed),实际 %v", err)
  158. }
  159. }
  160. // TestVerify_WrongIssuer iss 不符:ErrInvalidToken。
  161. func TestVerify_WrongIssuer(t *testing.T) {
  162. _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute)
  163. access, _, _ := iss.Issue(context.Background(), Claims{Sub: "uid-1"})
  164. if _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://other.test", access); !stderrors.Is(err, ErrInvalidToken) {
  165. t.Fatalf("期望 ErrInvalidToken(iss 不符),实际 %v", err)
  166. }
  167. }
  168. // TestVerify_BadSignature 签名无效:ErrInvalidToken(用不同公钥验)。
  169. func TestVerify_BadSignature(t *testing.T) {
  170. _, _, iss, _, rev := testKey(t, 30*time.Minute)
  171. access, _, _ := iss.Issue(context.Background(), Claims{Sub: "uid-1"})
  172. _, pubOther := genKeyPair(t) // 另一对公钥
  173. if _, err := Verify(context.Background(), []string{pubOther}, rev, "https://unified.test", access); !stderrors.Is(err, ErrInvalidToken) {
  174. t.Fatalf("期望 ErrInvalidToken(签名错),实际 %v", err)
  175. }
  176. }
  177. // TestVerify_Garbage 非 JWT 串:ErrInvalidToken。
  178. func TestVerify_Garbage(t *testing.T) {
  179. _, pubPEM, _, _, rev := testKey(t, 30*time.Minute)
  180. if _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", "not-a-jwt"); !stderrors.Is(err, ErrInvalidToken) {
  181. t.Fatalf("期望 ErrInvalidToken(格式损坏),实际 %v", err)
  182. }
  183. }
  184. // TestVerify_Expired 过期 token:ErrInvalidToken。
  185. func TestVerify_Expired(t *testing.T) {
  186. _, pubPEM, iss, _, rev := testKey(t, 1*time.Second) // 1s 有效期
  187. access, _, _ := iss.Issue(context.Background(), Claims{Sub: "uid-1"})
  188. time.Sleep(1100 * time.Millisecond)
  189. if _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", access); !stderrors.Is(err, ErrInvalidToken) {
  190. t.Fatalf("期望 ErrInvalidToken(过期),实际 %v", err)
  191. }
  192. }
  193. // ---- Rotate ----
  194. // TestRotate_Success 旧 refresh 换新对,claims 原样带入。
  195. func TestRotate_Success(t *testing.T) {
  196. _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute)
  197. ctx := context.Background()
  198. _, oldRefresh, err := iss.Issue(ctx, Claims{Sub: "uid-1", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3})
  199. if err != nil {
  200. t.Fatalf("Issue: %v", err)
  201. }
  202. newAccess, newRefresh, err := iss.Rotate(ctx, oldRefresh)
  203. if err != nil {
  204. t.Fatalf("Rotate: %v", err)
  205. }
  206. if newAccess == "" || newRefresh == "" {
  207. t.Fatal("新 token 对缺失")
  208. }
  209. claims, err := Verify(ctx, []string{pubPEM}, rev, "https://unified.test", newAccess)
  210. if err != nil {
  211. t.Fatalf("验新 access: %v", err)
  212. }
  213. if claims.Sub != "uid-1" || claims.InstitutionID != 11 {
  214. t.Fatalf("claims=%+v(期望 uid-1/11)", claims)
  215. }
  216. }
  217. // TestRotate_OldInvalidAfter 旧 refresh 轮换后立即失效(防重放)。
  218. func TestRotate_OldInvalidAfter(t *testing.T) {
  219. _, _, iss, _, _ := testKey(t, 30*time.Minute)
  220. ctx := context.Background()
  221. _, oldRefresh, _ := iss.Issue(ctx, Claims{Sub: "uid-1"})
  222. if _, _, err := iss.Rotate(ctx, oldRefresh); err != nil {
  223. t.Fatalf("首次 Rotate: %v", err)
  224. }
  225. if _, _, err := iss.Rotate(ctx, oldRefresh); !stderrors.Is(err, ErrRefreshInvalid) {
  226. t.Fatalf("期望旧串二次 Rotate 返回 ErrRefreshInvalid,实际 %v", err)
  227. }
  228. }
  229. // TestRotate_BadFormat 非 64hex:ErrRefreshInvalid。
  230. func TestRotate_BadFormat(t *testing.T) {
  231. _, _, iss, _, _ := testKey(t, 30*time.Minute)
  232. if _, _, err := iss.Rotate(context.Background(), "too-short"); !stderrors.Is(err, ErrRefreshInvalid) {
  233. t.Fatalf("期望 ErrRefreshInvalid(格式非法),实际 %v", err)
  234. }
  235. }
  236. // TestRotate_NotInWhitelist 合法格式但白名单无:ErrRefreshInvalid。
  237. func TestRotate_NotInWhitelist(t *testing.T) {
  238. _, _, iss, _, _ := testKey(t, 30*time.Minute)
  239. if _, _, err := iss.Rotate(context.Background(), fmt.Sprintf("%064x", 0)); !stderrors.Is(err, ErrRefreshInvalid) {
  240. t.Fatalf("期望 ErrRefreshInvalid(不在白名单),实际 %v", err)
  241. }
  242. }
  243. // ---- DeleteRefreshToken ----
  244. // TestDeleteRefreshToken_Success 合法 64hex:删白名单键,再 Rotate 失败。
  245. func TestDeleteRefreshToken_Success(t *testing.T) {
  246. _, _, iss, _, _ := testKey(t, 30*time.Minute)
  247. ctx := context.Background()
  248. _, refresh, _ := iss.Issue(ctx, Claims{Sub: "uid-1"})
  249. if err := iss.DeleteRefreshToken(ctx, refresh); err != nil {
  250. t.Fatalf("DeleteRefreshToken: %v", err)
  251. }
  252. if _, _, err := iss.Rotate(ctx, refresh); !stderrors.Is(err, ErrRefreshInvalid) {
  253. t.Fatalf("期望删除后 Rotate 返回 ErrRefreshInvalid,实际 %v", err)
  254. }
  255. }
  256. // TestDeleteRefreshToken_BadFormat 非 64hex:报错。
  257. func TestDeleteRefreshToken_BadFormat(t *testing.T) {
  258. _, _, iss, _, _ := testKey(t, 30*time.Minute)
  259. if err := iss.DeleteRefreshToken(context.Background(), "bad"); err == nil {
  260. t.Fatal("期望非法格式报错,实际 nil")
  261. }
  262. }
  263. // TestDeleteRefreshToken_Idempotent 键已删/不存在:幂等不报错。
  264. func TestDeleteRefreshToken_Idempotent(t *testing.T) {
  265. _, _, iss, _, _ := testKey(t, 30*time.Minute)
  266. ctx := context.Background()
  267. _, refresh, _ := iss.Issue(ctx, Claims{Sub: "uid-1"})
  268. if err := iss.DeleteRefreshToken(ctx, refresh); err != nil {
  269. t.Fatalf("首次删: %v", err)
  270. }
  271. if err := iss.DeleteRefreshToken(ctx, refresh); err != nil {
  272. t.Fatalf("二次删应幂等不报错,实际 %v", err)
  273. }
  274. }
  275. // ---- RevokeAll ----
  276. // TestRevokeAll 写黑名单(jti 维度),后续 Verify 命中 ErrRevoked。
  277. func TestRevokeAll(t *testing.T) {
  278. _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute)
  279. ctx := context.Background()
  280. access, _, _ := iss.Issue(ctx, Claims{Sub: "uid-1"})
  281. claims, _ := Verify(ctx, []string{pubPEM}, rev, "https://unified.test", access)
  282. if err := iss.RevokeAll(ctx, claims.ID, time.Minute); err != nil {
  283. t.Fatalf("RevokeAll: %v", err)
  284. }
  285. if _, err := Verify(ctx, []string{pubPEM}, rev, "https://unified.test", access); !stderrors.Is(err, ErrRevoked) {
  286. t.Fatalf("期望吊销后 Verify 返回 ErrRevoked,实际 %v", err)
  287. }
  288. }