| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- // 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)
- }
- }
|