|
|
@@ -0,0 +1,320 @@
|
|
|
+// token 包单测(T4-6 验收):Issue / Verify / Rotate / Revoke / DeleteRefreshToken + 错误路径。
|
|
|
+//
|
|
|
+// 用 **in-memory** 的 RefreshStore / RevocationStore(纯 map,不连 Redis)+ 真 RS256 密钥,
|
|
|
+// 专注 token 包的签发/验签/轮换/吊销逻辑(Redis 存储层的语义已由各 *Store 的 miniredis 测试覆盖,
|
|
|
+// 见 infra/redis/store_test.go)。
|
|
|
+//
|
|
|
+// 覆盖:
|
|
|
+// - NewIssuer:私钥解析成功 / PKCS8 / 非法 PEM 报错 / 非 RSA 报错
|
|
|
+// - Issue:Sub 必填校验 / 签出可验的 access + refresh(rjti 写白名单)
|
|
|
+// - Verify:合法 token 通过 / 签名错 / 过期 / iss 不符 / 黑名单命中→ErrRevoked
|
|
|
+// - Rotate:旧 refresh 换新对 / 旧串立即失效(再 Rotate→ErrRefreshInvalid)/ 非法格式
|
|
|
+// - DeleteRefreshToken:合法 64hex 删除 / 非法格式报错 / 幂等(不存在不报错)
|
|
|
+package token
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ stderrors "errors"
|
|
|
+ "fmt"
|
|
|
+ "testing"
|
|
|
+ "time"
|
|
|
+)
|
|
|
+
|
|
|
+// ---- in-memory 存储 ----
|
|
|
+
|
|
|
+// memRefresh 纯 map 的 refresh 白名单。
|
|
|
+type memRefresh struct{ m map[string][]byte }
|
|
|
+
|
|
|
+func newMemRefresh() *memRefresh { return &memRefresh{m: map[string][]byte{}} }
|
|
|
+func (s *memRefresh) SetRefresh(_ context.Context, jti string, payload []byte, _ time.Duration) error {
|
|
|
+ s.m[jti] = payload
|
|
|
+ return nil
|
|
|
+}
|
|
|
+func (s *memRefresh) GetRefresh(_ context.Context, jti string) ([]byte, error) {
|
|
|
+ b, ok := s.m[jti]
|
|
|
+ if !ok {
|
|
|
+ return nil, nil
|
|
|
+ }
|
|
|
+ return b, nil
|
|
|
+}
|
|
|
+func (s *memRefresh) DeleteRefresh(_ context.Context, jti string) error {
|
|
|
+ delete(s.m, jti) // 幂等:不存在不报错
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// memRevocation 纯 map 的吊销黑名单(可注入故障模拟 Redis 不可用)。
|
|
|
+type memRevocation struct {
|
|
|
+ m map[string]struct{}
|
|
|
+ failOn bool // 置 true 时 IsRevoked 返 error(模拟存储故障,验 fail-closed)
|
|
|
+}
|
|
|
+
|
|
|
+func newMemRevocation() *memRevocation { return &memRevocation{m: map[string]struct{}{}} }
|
|
|
+func (s *memRevocation) AddRevoked(_ context.Context, jti string, _ time.Duration) error {
|
|
|
+ s.m[jti] = struct{}{}
|
|
|
+ return nil
|
|
|
+}
|
|
|
+func (s *memRevocation) IsRevoked(_ context.Context, jti string) (bool, error) {
|
|
|
+ if s.failOn {
|
|
|
+ return false, stderrors.New("mem: 模拟存储故障")
|
|
|
+ }
|
|
|
+ _, ok := s.m[jti]
|
|
|
+ return ok, nil
|
|
|
+}
|
|
|
+
|
|
|
+var (
|
|
|
+ _ RefreshStore = (*memRefresh)(nil)
|
|
|
+ _ RevocationStore = (*memRevocation)(nil)
|
|
|
+)
|
|
|
+
|
|
|
+// testKey 造一对 RS2048 密钥(PKCS1 私钥 + PKIX 公钥)+ 就绪 Issuer。
|
|
|
+func testKey(t *testing.T, accessTTL time.Duration) (privPEM string, pubPEM string, iss *Issuer, ref *memRefresh, rev *memRevocation) {
|
|
|
+ t.Helper()
|
|
|
+ privPEM, pubPEM = genKeyPair(t)
|
|
|
+ ref = newMemRefresh()
|
|
|
+ rev = newMemRevocation()
|
|
|
+ var err error
|
|
|
+ iss, err = NewIssuer(privPEM, "https://unified.test", accessTTL, 30*24*time.Hour, ref, rev)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("NewIssuer: %v", err)
|
|
|
+ }
|
|
|
+ return privPEM, pubPEM, iss, ref, rev
|
|
|
+}
|
|
|
+
|
|
|
+// ---- NewIssuer ----
|
|
|
+
|
|
|
+// TestNewIssuer_InvalidPEM 非法 PEM:报错(fail-fast 不启动)。
|
|
|
+func TestNewIssuer_InvalidPEM(t *testing.T) {
|
|
|
+ _, err := NewIssuer("not-a-pem", "https://x", 30*time.Minute, 30*24*time.Hour, newMemRefresh(), newMemRevocation())
|
|
|
+ if err == nil {
|
|
|
+ t.Fatal("期望非法 PEM 报错,实际 nil")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestNewIssuer_PKCS8 PKCS8 私钥也能解析(与 PKCS1 并列支持)。
|
|
|
+func TestNewIssuer_PKCS8(t *testing.T) {
|
|
|
+ priv8, pubPEM := genKeyPairPKCS8(t)
|
|
|
+ iss, err := NewIssuer(priv8, "https://x", 30*time.Minute, 30*24*time.Hour, newMemRefresh(), newMemRevocation())
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("PKCS8 解析失败: %v", err)
|
|
|
+ }
|
|
|
+ access, _, err := iss.Issue(context.Background(), Claims{Sub: "uid-1"})
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("Issue: %v", err)
|
|
|
+ }
|
|
|
+ if _, err := Verify(context.Background(), []string{pubPEM}, newMemRevocation(), "https://x", access); err != nil {
|
|
|
+ t.Fatalf("PKCS8 签的 token 应可验: %v", err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ---- Issue ----
|
|
|
+
|
|
|
+// TestIssue_EmptySub Sub 为空:报错(调用方 bug,单测暴露)。
|
|
|
+func TestIssue_EmptySub(t *testing.T) {
|
|
|
+ _, _, iss, _, _ := testKey(t, 30*time.Minute)
|
|
|
+ _, _, err := iss.Issue(context.Background(), Claims{})
|
|
|
+ if err == nil {
|
|
|
+ t.Fatal("期望空 Sub 报错,实际 nil")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestIssue_RoundTrip 签发后 access 可被验签、refresh 写入白名单。
|
|
|
+func TestIssue_RoundTrip(t *testing.T) {
|
|
|
+ _, pubPEM, iss, ref, rev := testKey(t, 30*time.Minute)
|
|
|
+ ctx := context.Background()
|
|
|
+ access, refresh, err := iss.Issue(ctx, Claims{Sub: "uid-1", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3})
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("Issue: %v", err)
|
|
|
+ }
|
|
|
+ if access == "" || refresh == "" {
|
|
|
+ t.Fatalf("token 对缺失: %q / %q", access, refresh)
|
|
|
+ }
|
|
|
+ claims, err := Verify(ctx, []string{pubPEM}, rev, "https://unified.test", access)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("Verify: %v", err)
|
|
|
+ }
|
|
|
+ if claims.Sub != "uid-1" || claims.InstitutionID != 11 || claims.CampusID != 3 {
|
|
|
+ t.Fatalf("claims=%+v(期望 uid-1/11/3)", claims)
|
|
|
+ }
|
|
|
+ if len(claims.Roles) != 1 || claims.Roles[0] != "teacher" {
|
|
|
+ t.Fatalf("roles=%v(期望 [teacher])", claims.Roles)
|
|
|
+ }
|
|
|
+ // refresh 应已写白名单(jti = 前 8 字节 hex)
|
|
|
+ rjti := refresh[:16] // 前 8 字节 → 16 hex 字符
|
|
|
+ if _, ok := ref.m[rjti]; !ok {
|
|
|
+ t.Fatalf("refresh 白名单未写入 jti=%s", rjti)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ---- Verify ----
|
|
|
+
|
|
|
+// TestVerify_Revoked 黑名单命中:ErrRevoked。
|
|
|
+func TestVerify_Revoked(t *testing.T) {
|
|
|
+ _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute)
|
|
|
+ access, _, err := iss.Issue(context.Background(), Claims{Sub: "uid-1"})
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("Issue: %v", err)
|
|
|
+ }
|
|
|
+ claims, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", access)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("预验签取 jti: %v", err)
|
|
|
+ }
|
|
|
+ _ = rev.AddRevoked(context.Background(), claims.ID, time.Minute)
|
|
|
+ if _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", access); !stderrors.Is(err, ErrRevoked) {
|
|
|
+ t.Fatalf("期望 ErrRevoked,实际 %v", err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestVerify_StorageFailClosed 黑名单存储故障:返回 error(fail-closed,非 ErrRevoked/ErrInvalidToken)。
|
|
|
+func TestVerify_StorageFailClosed(t *testing.T) {
|
|
|
+ _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute)
|
|
|
+ access, _, _ := iss.Issue(context.Background(), Claims{Sub: "uid-1"})
|
|
|
+ rev.failOn = true // 模拟 Redis 故障
|
|
|
+ _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", access)
|
|
|
+ if err == nil || stderrors.Is(err, ErrInvalidToken) || stderrors.Is(err, ErrRevoked) {
|
|
|
+ t.Fatalf("期望存储故障 error(fail-closed),实际 %v", err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestVerify_WrongIssuer iss 不符:ErrInvalidToken。
|
|
|
+func TestVerify_WrongIssuer(t *testing.T) {
|
|
|
+ _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute)
|
|
|
+ access, _, _ := iss.Issue(context.Background(), Claims{Sub: "uid-1"})
|
|
|
+ if _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://other.test", access); !stderrors.Is(err, ErrInvalidToken) {
|
|
|
+ t.Fatalf("期望 ErrInvalidToken(iss 不符),实际 %v", err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestVerify_BadSignature 签名无效:ErrInvalidToken(用不同公钥验)。
|
|
|
+func TestVerify_BadSignature(t *testing.T) {
|
|
|
+ _, _, iss, _, rev := testKey(t, 30*time.Minute)
|
|
|
+ access, _, _ := iss.Issue(context.Background(), Claims{Sub: "uid-1"})
|
|
|
+ _, pubOther := genKeyPair(t) // 另一对公钥
|
|
|
+ if _, err := Verify(context.Background(), []string{pubOther}, rev, "https://unified.test", access); !stderrors.Is(err, ErrInvalidToken) {
|
|
|
+ t.Fatalf("期望 ErrInvalidToken(签名错),实际 %v", err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestVerify_Garbage 非 JWT 串:ErrInvalidToken。
|
|
|
+func TestVerify_Garbage(t *testing.T) {
|
|
|
+ _, pubPEM, _, _, rev := testKey(t, 30*time.Minute)
|
|
|
+ if _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", "not-a-jwt"); !stderrors.Is(err, ErrInvalidToken) {
|
|
|
+ t.Fatalf("期望 ErrInvalidToken(格式损坏),实际 %v", err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestVerify_Expired 过期 token:ErrInvalidToken。
|
|
|
+func TestVerify_Expired(t *testing.T) {
|
|
|
+ _, pubPEM, iss, _, rev := testKey(t, 1*time.Second) // 1s 有效期
|
|
|
+ access, _, _ := iss.Issue(context.Background(), Claims{Sub: "uid-1"})
|
|
|
+ time.Sleep(1100 * time.Millisecond)
|
|
|
+ if _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", access); !stderrors.Is(err, ErrInvalidToken) {
|
|
|
+ t.Fatalf("期望 ErrInvalidToken(过期),实际 %v", err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ---- Rotate ----
|
|
|
+
|
|
|
+// TestRotate_Success 旧 refresh 换新对,claims 原样带入。
|
|
|
+func TestRotate_Success(t *testing.T) {
|
|
|
+ _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute)
|
|
|
+ ctx := context.Background()
|
|
|
+ _, oldRefresh, err := iss.Issue(ctx, Claims{Sub: "uid-1", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3})
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("Issue: %v", err)
|
|
|
+ }
|
|
|
+ newAccess, newRefresh, err := iss.Rotate(ctx, oldRefresh)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("Rotate: %v", err)
|
|
|
+ }
|
|
|
+ if newAccess == "" || newRefresh == "" {
|
|
|
+ t.Fatal("新 token 对缺失")
|
|
|
+ }
|
|
|
+ claims, err := Verify(ctx, []string{pubPEM}, rev, "https://unified.test", newAccess)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("验新 access: %v", err)
|
|
|
+ }
|
|
|
+ if claims.Sub != "uid-1" || claims.InstitutionID != 11 {
|
|
|
+ t.Fatalf("claims=%+v(期望 uid-1/11)", claims)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestRotate_OldInvalidAfter 旧 refresh 轮换后立即失效(防重放)。
|
|
|
+func TestRotate_OldInvalidAfter(t *testing.T) {
|
|
|
+ _, _, iss, _, _ := testKey(t, 30*time.Minute)
|
|
|
+ ctx := context.Background()
|
|
|
+ _, oldRefresh, _ := iss.Issue(ctx, Claims{Sub: "uid-1"})
|
|
|
+ if _, _, err := iss.Rotate(ctx, oldRefresh); err != nil {
|
|
|
+ t.Fatalf("首次 Rotate: %v", err)
|
|
|
+ }
|
|
|
+ if _, _, err := iss.Rotate(ctx, oldRefresh); !stderrors.Is(err, ErrRefreshInvalid) {
|
|
|
+ t.Fatalf("期望旧串二次 Rotate 返回 ErrRefreshInvalid,实际 %v", err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestRotate_BadFormat 非 64hex:ErrRefreshInvalid。
|
|
|
+func TestRotate_BadFormat(t *testing.T) {
|
|
|
+ _, _, iss, _, _ := testKey(t, 30*time.Minute)
|
|
|
+ if _, _, err := iss.Rotate(context.Background(), "too-short"); !stderrors.Is(err, ErrRefreshInvalid) {
|
|
|
+ t.Fatalf("期望 ErrRefreshInvalid(格式非法),实际 %v", err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestRotate_NotInWhitelist 合法格式但白名单无:ErrRefreshInvalid。
|
|
|
+func TestRotate_NotInWhitelist(t *testing.T) {
|
|
|
+ _, _, iss, _, _ := testKey(t, 30*time.Minute)
|
|
|
+ if _, _, err := iss.Rotate(context.Background(), fmt.Sprintf("%064x", 0)); !stderrors.Is(err, ErrRefreshInvalid) {
|
|
|
+ t.Fatalf("期望 ErrRefreshInvalid(不在白名单),实际 %v", err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ---- DeleteRefreshToken ----
|
|
|
+
|
|
|
+// TestDeleteRefreshToken_Success 合法 64hex:删白名单键,再 Rotate 失败。
|
|
|
+func TestDeleteRefreshToken_Success(t *testing.T) {
|
|
|
+ _, _, iss, _, _ := testKey(t, 30*time.Minute)
|
|
|
+ ctx := context.Background()
|
|
|
+ _, refresh, _ := iss.Issue(ctx, Claims{Sub: "uid-1"})
|
|
|
+ if err := iss.DeleteRefreshToken(ctx, refresh); err != nil {
|
|
|
+ t.Fatalf("DeleteRefreshToken: %v", err)
|
|
|
+ }
|
|
|
+ if _, _, err := iss.Rotate(ctx, refresh); !stderrors.Is(err, ErrRefreshInvalid) {
|
|
|
+ t.Fatalf("期望删除后 Rotate 返回 ErrRefreshInvalid,实际 %v", err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestDeleteRefreshToken_BadFormat 非 64hex:报错。
|
|
|
+func TestDeleteRefreshToken_BadFormat(t *testing.T) {
|
|
|
+ _, _, iss, _, _ := testKey(t, 30*time.Minute)
|
|
|
+ if err := iss.DeleteRefreshToken(context.Background(), "bad"); err == nil {
|
|
|
+ t.Fatal("期望非法格式报错,实际 nil")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestDeleteRefreshToken_Idempotent 键已删/不存在:幂等不报错。
|
|
|
+func TestDeleteRefreshToken_Idempotent(t *testing.T) {
|
|
|
+ _, _, iss, _, _ := testKey(t, 30*time.Minute)
|
|
|
+ ctx := context.Background()
|
|
|
+ _, refresh, _ := iss.Issue(ctx, Claims{Sub: "uid-1"})
|
|
|
+ if err := iss.DeleteRefreshToken(ctx, refresh); err != nil {
|
|
|
+ t.Fatalf("首次删: %v", err)
|
|
|
+ }
|
|
|
+ if err := iss.DeleteRefreshToken(ctx, refresh); err != nil {
|
|
|
+ t.Fatalf("二次删应幂等不报错,实际 %v", err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ---- RevokeAll ----
|
|
|
+
|
|
|
+// TestRevokeAll 写黑名单(jti 维度),后续 Verify 命中 ErrRevoked。
|
|
|
+func TestRevokeAll(t *testing.T) {
|
|
|
+ _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute)
|
|
|
+ ctx := context.Background()
|
|
|
+ access, _, _ := iss.Issue(ctx, Claims{Sub: "uid-1"})
|
|
|
+ claims, _ := Verify(ctx, []string{pubPEM}, rev, "https://unified.test", access)
|
|
|
+ if err := iss.RevokeAll(ctx, claims.ID, time.Minute); err != nil {
|
|
|
+ t.Fatalf("RevokeAll: %v", err)
|
|
|
+ }
|
|
|
+ if _, err := Verify(ctx, []string{pubPEM}, rev, "https://unified.test", access); !stderrors.Is(err, ErrRevoked) {
|
|
|
+ t.Fatalf("期望吊销后 Verify 返回 ErrRevoked,实际 %v", err)
|
|
|
+ }
|
|
|
+}
|