Browse Source

feat(identity): T4-6 main 装配 + token 包单测 + 登录 e2e(W2 身份模块全链路落地)

- cmd/api/main.go:identity 全装配(fail-fast,带病不启动)
  · DB 连接(infra/db.NewDB)+ 核心表 AutoMigrate(**guarded**:仅 UNIFIED_AUTO_MIGRATE=1 执行,
    生产默认关——schema 演进走受控迁移,避免启动时 AutoMigrate 增列与"目标态 DDL 先行"纪律冲突)
    迁 identity 4 表 + org 核心表(membership→institution 引用,缺一登录查不到作用域)
  · Redis 连接 + RefreshStore/RevocationStore/LoginTicketStore(共享 Client,Close 一次)
  · RS256 签发器(私钥/issuer/accessTTL/refreshTTL 来自 config,缺省兜底 30min/30d)
  · 验签公钥:未配 PublicKeysPEM 时从私钥派生(单机双实例 ADR-015 共享密钥对),WARN 提示生产显式配
  · 3 真仓储 + AuthService + Handler + NewAuth 中间件
  · 路由(铁律 8:公开在 Auth Group 前):/auth/login、/auth/issue、/auth/refresh、
    /auth/wechat/qrcode|status 公开;/auth/me、/auth/logout 经 authMw 验签+scope 注入
- identity/token:新增 PublicKeysFromPrivateKey(私钥→SPKI 公钥 PEM,作未配公钥时的单钥兜底)
- **token 包单测 18 例全绿**(in-memory 存储 + 真 RS256,不连 Redis):
  NewIssuer 非法 PEM/PKCS8 + Issue Sub 必填/RoundTrip + Verify 吊销/存储故障 fail-closed/
  iss 不符/签名错/垃圾串/过期 + Rotate 成功/旧串失效/格式/不在白名单 +
  DeleteRefreshToken 成功/格式/幂等 + RevokeAll
- **登录 e2e 2 例(DB-gated,UNIFIED_TEST_DSN 未设则跳过,同仓储测试纪律)**:
  真 GORM 仓储 + 真 Issuer + miniredis,全链路 POST /auth/login①(真查 t_identity/t_user + argon2 验密)
  → POST /auth/issue②(真查 t_membership + 签 token,access 可被本公钥验签)→ GET /auth/me(经
  NewAuth 验签 + scope 注入,断言 code=200 + 正确用户/作用域)→ 重放票据 1001 + 越权作用域 1003 + 错密 1001
- 全模块 build/vet/test 全绿;**测试函数总数 94(identity 域 73,远超 T4 要求 ≥30)**

后续:W2 身份模块(T4-1~T4-6)全部落地;下一步 T6 org 模块(scoping 越权拦截)/ W3 消费方盘点。
后端新 commit push 需逐次确认(024fa15/6033852/9d74dac/2404d5c/04844e5/本次)。
Q 3 days ago
parent
commit
583c6e5be5

+ 114 - 5
cmd/api/main.go

@@ -11,13 +11,15 @@
 //     公开路由须在 Auth Group 之前——铁律 8 路由纪律);
 //  7. 任务工作池启动与优雅退出(task 域,GracePeriodSec 内排空在途任务,§3.7)。
 //
-// 当前状态:域模块尚未实现(internal/{org,academic,teaching,ai} 为空),
-// 本入口提供"配置加载 + 健康检查 + 优雅退出"骨架,保证 go build ./... 全绿、
-// 部署链路(systemd/nginx/Dockerfile)可先演练。
+// 当前状态:identity 模块(两步登录 / me / refresh / logout)已装配(T4-6),
+// 含 DB 连接 + 核心表 AutoMigrate(guarded:仅 UNIFIED_AUTO_MIGRATE=1 时执行,生产幂等不漂移)、
+// Redis 连接 + token 存储 + RS256 签发器 + 仓储 + AuthService + Handler + 验签中间件 + /auth/* 路由。
+// 其余域(org/academic/teaching/ai)尚未实现,域模块就绪后在"业务域路由挂载点"注册。
 //
 // 启动方式(dev):
 //
-//	cp config/dev.example.json config/dev.json   # 填入 DSN 等(或走环境变量)
+//	cp config/dev.example.json config/dev.json   # 填入 DSN/Redis/私钥(或走环境变量)
+//	export UNIFIED_AUTO_MIGRATE=1                # 首次:建核心表(生产默认关,防 schema 漂移)
 //	go run ./cmd/api -env=dev
 package main
 
@@ -36,7 +38,15 @@ import (
 	"go.uber.org/zap"
 	"go.uber.org/zap/zapcore"
 
+	"yuansiwei/unified-backend/internal/identity/handler"
+	"yuansiwei/unified-backend/internal/identity/model"
+	"yuansiwei/unified-backend/internal/identity/repository"
+	"yuansiwei/unified-backend/internal/identity/service"
+	"yuansiwei/unified-backend/internal/identity/token"
 	"yuansiwei/unified-backend/internal/infra/config"
+	"yuansiwei/unified-backend/internal/infra/db"
+	"yuansiwei/unified-backend/internal/infra/redis"
+	orgmodel "yuansiwei/unified-backend/internal/org/model"
 )
 
 // main 进程入口:装配 → 监听 → 信号驱动优雅退出。
@@ -75,6 +85,91 @@ func main() {
 		zap.String("instance", cfg.Server.InstanceID),
 		zap.String("addr", cfg.Server.Addr))
 
+	// ---- 基础设施装配(fail-fast:DB/Redis 连不上不允许启动)----
+	gdb, err := db.NewDB(cfg.DB)
+	if err != nil {
+		logger.Error("数据库连接失败", zap.Error(err))
+		os.Exit(1)
+	}
+	defer func() { _ = db.Close(gdb) }()
+
+	// 核心表建表(guarded:UNIFIED_AUTO_MIGRATE=1 才执行)。
+	// 生产默认关闭——schema 演进走受控迁移(W2 割接脚本 / DDL 变更评审),
+	// 启动时 AutoMigrate 会按 GORM model 增列,与"目标态 DDL 先行"纪律冲突(ADR-013/D1)。
+	if os.Getenv("UNIFIED_AUTO_MIGRATE") == "1" {
+		// identity 域 4 表 + org 域核心表(t_membership/t_institution/t_campus 等)
+		// ——登录链路引用 membership→institution,缺一即登录查不到作用域
+		tables := append(model.AllTables(), orgmodel.AllTables()...)
+		if err := db.Migrate(gdb, tables...); err != nil {
+			logger.Error("核心表 AutoMigrate 失败", zap.Error(err))
+			os.Exit(1)
+		}
+		logger.Info("核心表已 AutoMigrate(UNIFIED_AUTO_MIGRATE=1)")
+	}
+
+	rc, err := redis.NewClient(cfg.Redis)
+	if err != nil {
+		logger.Error("Redis 连接失败", zap.Error(err))
+		os.Exit(1)
+	}
+	defer func() { _ = rc.Close() }()
+
+	// ---- identity 模块装配(T4-6)----
+	refreshStore := redis.NewRefreshStore(rc)
+	revocation := redis.NewRevocationStore(rc, cfg.Auth.RevocationKeyPrefix)
+	tickets := redis.NewLoginTicketStore(rc)
+
+	accessTTL := time.Duration(cfg.Auth.AccessTokenTTLMin) * time.Minute
+	if accessTTL <= 0 {
+		accessTTL = 30 * time.Minute // 缺省兜底(架构规格 §7.2 固定 30min)
+	}
+	refreshTTL := time.Duration(cfg.Auth.RefreshTokenTTLDay) * 24 * time.Hour
+	if refreshTTL <= 0 {
+		refreshTTL = 30 * 24 * time.Hour
+	}
+	issuer, err := token.NewIssuer(cfg.Auth.PrivateKeyPEM, cfg.Auth.Issuer, accessTTL, refreshTTL, refreshStore, revocation)
+	if err != nil {
+		logger.Error("RS256 签发器装配失败(私钥解析?)", zap.Error(err))
+		os.Exit(1)
+	}
+	// 验签公钥:未配置 PublicKeysPEM 时从私钥派生(单机双实例 ADR-015 共享同一密钥对)
+	pubKeys := cfg.Auth.PublicKeysPEM
+	if len(pubKeys) == 0 {
+		derived, derr := token.PublicKeysFromPrivateKey(cfg.Auth.PrivateKeyPEM)
+		if derr != nil {
+			logger.Error("验签公钥派生失败", zap.Error(derr))
+			os.Exit(1)
+		}
+		pubKeys = []string{derived}
+		logger.Warn("Auth.PublicKeysPEM 未配置,已从 PrivateKeyPEM 派生公钥(生产建议显式配置以支持密钥轮换)")
+	}
+
+	authSvc, err := service.NewAuthService(service.Config{
+		Users:       repository.NewUserRepo(gdb),
+		Identities:  repository.NewIdentityRepo(gdb),
+		Memberships: repository.NewMembershipRepo(gdb),
+		Issuer:      issuer,
+		Tickets:     tickets,
+		AccessTTL:   accessTTL,
+		PublicKeys:  pubKeys,
+		IssuerStr:   cfg.Auth.Issuer,
+		Revocation:  revocation,
+	})
+	if err != nil {
+		logger.Error("AuthService 装配失败", zap.Error(err))
+		os.Exit(1)
+	}
+	authH, err := handler.New(handler.Config{Svc: authSvc, Log: logger})
+	if err != nil {
+		logger.Error("Handler 装配失败", zap.Error(err))
+		os.Exit(1)
+	}
+	authMw, err := handler.NewAuth(handler.AuthConfig{PublicKeys: pubKeys, Revocation: revocation, Issuer: cfg.Auth.Issuer})
+	if err != nil {
+		logger.Error("验签中间件装配失败", zap.Error(err))
+		os.Exit(1)
+	}
+
 	app := fiber.New(fiber.Config{
 		AppName:               "yuansiwei-unified-backend",
 		ReadTimeout:           time.Duration(cfg.Server.ReadTimeoutSec) * time.Second,
@@ -96,7 +191,21 @@ func main() {
 		})
 	})
 
-	// 业务域路由挂载点(域模块实现后在此注册;公开路由须在 Auth Group 之前——铁律 8):
+	// ---- 业务域路由挂载(公开路由须在 Auth Group 之前——铁律 8)----
+	// 公开(无需登录态):两步登录①② + refresh 轮换 + 微信扫码占位
+	app.Post("/auth/login", authH.Login)
+	app.Post("/auth/issue", authH.Issue)
+	app.Post("/auth/refresh", authH.Refresh)
+	app.Get("/auth/wechat/qrcode", authH.WechatQrcode)
+	app.Get("/auth/wechat/status", authH.WechatStatus)
+
+	// 受保护(前置验签中间件:验签 + 作用域注入)
+	auth := app.Group("/auth")
+	auth.Use(authMw)
+	auth.Get("/me", authH.Me)
+	auth.Post("/logout", authH.Logout)
+
+	// 其余域(org/academic/teaching/ai)路由挂载点(域模块实现后在此注册):
 	//
 	//	app.Get("/auth/jwks", identityHandler.JWKS)            // 公开
 	//	auth := app.Group("/", middleware.Auth(logger))        // 以下全部需登录

+ 301 - 0
internal/identity/handler/login_e2e_test.go

@@ -0,0 +1,301 @@
+// 登录端到端测试(T4-6 验收):真 DB + 真仓储 + 真 Issuer + miniredis,走 HTTP 全链路。
+//
+// 与仓储/服务单测的差异:这里用**真实 GORM 仓储**(非 mock),覆盖"装配正确性 + 端到端"——
+// ① POST /auth/login(真查 t_identity/t_user,验 argon2 密码)→ code 200 + loginTicket
+// ② POST /auth/issue(真查 t_membership,签 token)→ code 200 + accessToken(可被本公钥验签)
+// ③ GET  /auth/me(经 NewAuth 中间件验签 + scope 注入)→ code 200 + 正确用户
+// ④ 重放/越权 → code 1001/1003
+//
+// 运行前提(同仓储测试纪律:DSN 走环境变量,不入库):
+//
+//	set UNIFIED_TEST_DSN=dev:xxxx@tcp(127.0.0.1:3306)/e2e_test?charset=utf8mb4&parseTime=True&loc=Local
+//	go test ./internal/identity/handler/ -run TestLoginE2E -v
+//
+// 未设置 UNIFIED_TEST_DSN 时自动跳过(CI / 无库环境 go test ./... 不受影响)。
+package handler
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+	miniredis "github.com/alicebob/miniredis/v2"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/identity/credential"
+	idpkg "yuansiwei/unified-backend/internal/identity/id"
+	"yuansiwei/unified-backend/internal/identity/model"
+	"yuansiwei/unified-backend/internal/identity/repository"
+	"yuansiwei/unified-backend/internal/identity/service"
+	"yuansiwei/unified-backend/internal/identity/token"
+	"yuansiwei/unified-backend/internal/infra/config"
+	infra "yuansiwei/unified-backend/internal/infra/db"
+	appredis "yuansiwei/unified-backend/internal/infra/redis"
+	orgmodel "yuansiwei/unified-backend/internal/org/model"
+)
+
+// e2eDSNOrSkip 读 UNIFIED_TEST_DSN,未设置时跳过(同仓储集成测试纪律)。
+func e2eDSNOrSkip(t *testing.T) string {
+	t.Helper()
+	dsn := os.Getenv("UNIFIED_TEST_DSN")
+	if dsn == "" {
+		t.Skip("未设置 UNIFIED_TEST_DSN,跳过登录端到端测试")
+	}
+	return dsn
+}
+
+// e2eApp 端到端测试句柄(真库 + 真仓储 + miniredis + 真 Issuer + 路由应用)。
+type e2eApp struct {
+	t          *testing.T
+	app        *fiber.App
+	pubKeys    []string
+	revocation *appredis.RevocationStore
+	gdb        *gorm.DB
+}
+
+// e2eSetup 建真库 + AutoMigrate 4 表 + 真仓储 + miniredis + 真 Issuer + AuthService + Handler + 路由应用。
+func e2eSetup(t *testing.T) *e2eApp {
+	t.Helper()
+	dsn := e2eDSNOrSkip(t)
+	gdb, err := infra.NewDB(config.DBCfg{DSN: dsn, MaxOpenConns: 5, MaxIdleConns: 2, ConnMaxLifetimeSec: 60})
+	if err != nil {
+		t.Fatalf("NewDB: %v", err)
+	}
+	t.Cleanup(func() { _ = infra.Close(gdb) })
+	if err := infra.Migrate(gdb, &model.User{}, &model.Identity{}, &orgmodel.Membership{}, &orgmodel.Institution{}); err != nil {
+		t.Fatalf("Migrate: %v", err)
+	}
+	t.Cleanup(func() {
+		_ = gdb.Migrator().DropTable("t_user", "t_identity", "t_membership", "t_institution")
+	})
+
+	users := repository.NewUserRepo(gdb)
+	identities := repository.NewIdentityRepo(gdb)
+	memberships := repository.NewMembershipRepo(gdb)
+
+	mr, err := miniredis.Run()
+	if err != nil {
+		t.Fatalf("miniredis.Run: %v", err)
+	}
+	t.Cleanup(mr.Close)
+	rc, err := appredis.NewClient(config.RedisCfg{Addr: mr.Addr()})
+	if err != nil {
+		t.Fatalf("NewClient: %v", err)
+	}
+	t.Cleanup(func() { _ = rc.Close() })
+	refreshStore := appredis.NewRefreshStore(rc)
+	revocation := appredis.NewRevocationStore(rc, "revoked")
+	tickets := appredis.NewLoginTicketStore(rc)
+
+	privPEM, pubKeys := genRSAKey(t)
+	issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation)
+	if err != nil {
+		t.Fatalf("NewIssuer: %v", err)
+	}
+	svc, err := service.NewAuthService(service.Config{
+		Users: users, Identities: identities, Memberships: memberships,
+		Issuer: issuer, Tickets: tickets,
+		AccessTTL: 30 * time.Minute, PublicKeys: pubKeys, IssuerStr: "https://unified.test",
+		Revocation: revocation,
+	})
+	if err != nil {
+		t.Fatalf("NewAuthService: %v", err)
+	}
+	h, err := New(Config{Svc: svc})
+	if err != nil {
+		t.Fatalf("New Handler: %v", err)
+	}
+	mw, err := NewAuth(AuthConfig{PublicKeys: pubKeys, Revocation: revocation, Issuer: "https://unified.test"})
+	if err != nil {
+		t.Fatalf("NewAuth: %v", err)
+	}
+
+	app := fiber.New(fiber.Config{DisableStartupMessage: true})
+	app.Post("/auth/login", h.Login)
+	app.Post("/auth/issue", h.Issue)
+	app.Post("/auth/refresh", h.Refresh)
+	auth := app.Group("/auth")
+	auth.Use(mw)
+	auth.Get("/me", h.Me)
+	auth.Post("/logout", h.Logout)
+
+	return &e2eApp{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb}
+}
+
+// seedUser 落一个 argon2 用户 + username 身份 + 一条 membership(raw SQL 保证 created_at 确定)。
+func (a *e2eApp) seedUser(username, password string, inst, campus int64, role string) string {
+	a.t.Helper()
+	uid, err := idpkg.NewCanonicalUserID()
+	if err != nil {
+		a.t.Fatalf("生成 canonical user id: %v", err)
+	}
+	hash, err := credential.HashArgon2(password)
+	if err != nil {
+		a.t.Fatalf("HashArgon2: %v", err)
+	}
+	ctx := context.Background()
+	now := time.Now()
+	if err := a.gdb.WithContext(ctx).Create(&model.User{
+		CanonicalUserID: uid, Username: ptr(username), PersonType: model.PersonTypeStaff,
+		PasswordHash: &hash, HashVersion: ptr(model.HashVersionArgon2),
+		Status: model.UserStatusActive, CreatedAt: now, UpdatedAt: now,
+	}).Error; err != nil {
+		a.t.Fatalf("插入 t_user: %v", err)
+	}
+	if err := a.gdb.WithContext(ctx).Create(&model.Identity{
+		CanonicalUserID: uid, Type: model.IdentityTypeUsername, Identifier: username,
+		Verified: true, CreatedAt: now,
+	}).Error; err != nil {
+		a.t.Fatalf("插入 t_identity: %v", err)
+	}
+	if err := a.gdb.WithContext(ctx).Exec(
+		`INSERT INTO t_membership (canonical_user_id, institution_id, campus_id, role, status, joined_at, created_at)
+		 VALUES (?, ?, ?, ?, 'active', ?, ?)`,
+		uid, inst, campus, role, now, now,
+	).Error; err != nil {
+		a.t.Fatalf("插入 t_membership: %v", err)
+	}
+	return uid
+}
+
+// post 发 POST 请求并解出统一信封。
+func (a *e2eApp) post(path, body string) *envelope {
+	a.t.Helper()
+	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
+	resp, err := a.app.Test(req, -1)
+	if err != nil {
+		a.t.Fatalf("POST %s 请求失败: %v", path, err)
+	}
+	var env envelope
+	if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
+		a.t.Fatalf("POST %s 解析响应: %v", path, err)
+	}
+	return &env
+}
+
+// getBearer 带 Bearer 头请求并解出统一信封。
+func (a *e2eApp) getBearer(path, bearer string) *envelope {
+	a.t.Helper()
+	req := httptest.NewRequest(http.MethodGet, path, nil)
+	req.Header.Set("Authorization", "Bearer "+bearer)
+	resp, err := a.app.Test(req, -1)
+	if err != nil {
+		a.t.Fatalf("GET %s 请求失败: %v", path, err)
+	}
+	var env envelope
+	if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
+		a.t.Fatalf("GET %s 解析响应: %v", path, err)
+	}
+	return &env
+}
+
+// TestLoginE2E 登录全链路:login① → issue② → me(验签 + scope 注入)→ 重放/越权拒绝。
+func TestLoginE2E(t *testing.T) {
+	a := e2eSetup(t)
+	uid := a.seedUser("e2euser", "e2e-pass-123", 11, 3, "teacher")
+
+	// ① login:真查 t_identity/t_user + argon2 验密 → 200 + loginTicket
+	step1 := a.post(`/auth/login`, `{"username":"e2euser","password":"e2e-pass-123"}`)
+	if step1.Code != 200 {
+		t.Fatalf("login code=%d msg=%s(期望 200)", step1.Code, step1.Message)
+	}
+	var l1 struct {
+		LoginTicket string `json:"loginTicket"`
+		User        struct {
+			ID string `json:"id"`
+		} `json:"user"`
+		Memberships []struct {
+			InstitutionID int64 `json:"institutionId"`
+			CampusID      int64 `json:"campusId"`
+			Role          string `json:"role"`
+		} `json:"memberships"`
+	}
+	if err := json.Unmarshal(step1.Data, &l1); err != nil {
+		t.Fatalf("解析 login data: %v", err)
+	}
+	if l1.User.ID != uid {
+		t.Fatalf("login user.id=%q(期望 %q)", l1.User.ID, uid)
+	}
+	if len(l1.Memberships) != 1 || l1.Memberships[0].Role != "teacher" {
+		t.Fatalf("login memberships=%+v(期望 1 条 teacher)", l1.Memberships)
+	}
+
+	// ② issue:真查 t_membership + 签 token → 200 + accessToken
+	issueBody := `{"loginTicket":"` + l1.LoginTicket + `","institutionId":11,"campusId":3}`
+	issueRes := a.post(`/auth/issue`, issueBody)
+	if issueRes.Code != 200 {
+		t.Fatalf("issue code=%d msg=%s(期望 200)", issueRes.Code, issueRes.Message)
+	}
+	var pair struct {
+		AccessToken   string   `json:"accessToken"`
+		RefreshToken  string   `json:"refreshToken"`
+		InstitutionID int64    `json:"institutionId"`
+		Roles         []string `json:"roles"`
+	}
+	if err := json.Unmarshal(issueRes.Data, &pair); err != nil {
+		t.Fatalf("解析 issue data: %v", err)
+	}
+	if pair.AccessToken == "" || pair.RefreshToken == "" {
+		t.Fatal("accessToken/refreshToken 缺失")
+	}
+	if pair.InstitutionID != 11 || len(pair.Roles) != 1 || pair.Roles[0] != "teacher" {
+		t.Fatalf("issue pair=%+v(期望 inst=11 roles=[teacher])", pair)
+	}
+
+	// access 可被本公钥验签 + claims 正确
+	claims, err := token.Verify(context.Background(), a.pubKeys, a.revocation, "https://unified.test", pair.AccessToken)
+	if err != nil {
+		t.Fatalf("验签 access 失败: %v", err)
+	}
+	if claims.Sub != uid || claims.InstitutionID != 11 || claims.CampusID != 3 {
+		t.Fatalf("claims=%+v(期望 %q/11/3)", claims, uid)
+	}
+
+	// ③ me:经 NewAuth 中间件验签 + scope 注入 → 200 + 正确用户
+	meRes := a.getBearer(`/auth/me`, pair.AccessToken)
+	if meRes.Code != 200 {
+		t.Fatalf("me code=%d msg=%s(期望 200)", meRes.Code, meRes.Message)
+	}
+	var me struct {
+		ID            string `json:"id"`
+		InstitutionID int64  `json:"institutionId"`
+		CampusID      int64  `json:"campusId"`
+	}
+	if err := json.Unmarshal(meRes.Data, &me); err != nil {
+		t.Fatalf("解析 me data: %v", err)
+	}
+	if me.ID != uid || me.InstitutionID != 11 || me.CampusID != 3 {
+		t.Fatalf("me=%+v(期望 %q/11/3)", me, uid)
+	}
+
+	// ④ 重放票据:② 已消费(GETDEL),再用同一 issueBody → 1001
+	if replay := a.post(`/auth/issue`, issueBody); replay.Code != 1001 {
+		t.Fatalf("重放 issue code=%d(期望 1001 票据已消费)", replay.Code)
+	}
+
+	// ⑤ 越权作用域:新票据挑不属于自己的 (inst,campus) → 1003
+	step1b := a.post(`/auth/login`, `{"username":"e2euser","password":"e2e-pass-123"}`)
+	var l1b struct {
+		LoginTicket string `json:"loginTicket"`
+	}
+	_ = json.Unmarshal(step1b.Data, &l1b)
+	if scope := a.post(`/auth/issue`, `{"loginTicket":"`+l1b.LoginTicket+`","institutionId":99,"campusId":99}`); scope.Code != 1003 {
+		t.Fatalf("越权 issue code=%d(期望 1003 作用域越权)", scope.Code)
+	}
+}
+
+// TestLoginE2E_BadPassword 真库错密:code 1001(防枚举统一文案)。
+func TestLoginE2E_BadPassword(t *testing.T) {
+	a := e2eSetup(t)
+	a.seedUser("e2euser", "e2e-pass-123", 11, 3, "teacher")
+	res := a.post(`/auth/login`, `{"username":"e2euser","password":"wrong"}`)
+	if res.Code != 1001 {
+		t.Fatalf("code=%d(期望 1001 统一文案)", res.Code)
+	}
+}

+ 56 - 0
internal/identity/token/keygen_test.go

@@ -0,0 +1,56 @@
+// token 测试密钥生成助手(PKCS1 / PKCS8 两种私钥形态,均配 PKIX 公钥)。
+//
+// 密钥仅内存生成(不依赖 config/dev.json,不入库);RSA 2048 足够 RS256 测试。
+package token
+
+import (
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/pem"
+	"testing"
+)
+
+// genKeyPair 生成 RS2048 密钥对(PKCS1 私钥 PEM + PKIX 公钥 PEM)。
+func genKeyPair(t *testing.T) (privPEM, pubPEM string) {
+	t.Helper()
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("生成 RSA 私钥: %v", err)
+	}
+	privPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
+	pubPEM, err = pubPEMOf(t, key)
+	if err != nil {
+		t.Fatalf("序列化公钥: %v", err)
+	}
+	return privPEM, pubPEM
+}
+
+// genKeyPairPKCS8 生成 RS2048 密钥对(PKCS8 私钥 PEM + PKIX 公钥 PEM)。
+func genKeyPairPKCS8(t *testing.T) (privPEM, pubPEM string) {
+	t.Helper()
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("生成 RSA 私钥: %v", err)
+	}
+	der, err := x509.MarshalPKCS8PrivateKey(key)
+	if err != nil {
+		t.Fatalf("PKCS8 序列化: %v", err)
+	}
+	privPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
+	pubPEM, err = pubPEMOf(t, key)
+	if err != nil {
+		t.Fatalf("序列化公钥: %v", err)
+	}
+	return privPEM, pubPEM
+}
+
+// pubPEMOf 由 *rsa.PrivateKey 取公钥并序列化为 PKIX PEM。
+func pubPEMOf(t *testing.T, key *rsa.PrivateKey) (string, error) {
+	t.Helper()
+	der, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
+	if err != nil {
+		return "", err
+	}
+	return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})), nil
+}

+ 23 - 0
internal/identity/token/token.go

@@ -448,6 +448,29 @@ func parseRSAPublicKey(pemStr string) (*rsa.PublicKey, error) {
 	return k, nil
 }
 
+// PublicKeysFromPrivateKey 由 RS256 私钥 PEM 推导公钥 PEM(SPKI "PUBLIC KEY")。
+//
+// 用途(单机双实例 ADR-015):双实例共享同一对 RS256 密钥,验签公钥可从本实例私钥直接推导——
+// 当 config.Auth.PublicKeysPEM 未配置时,main 装配用本方法从 PrivateKeyPEM 派生,
+// 避免"配了私钥却忘配公钥导致验签 fail-fast"的部署坑。生产密钥轮换期仍应在配置里显式给
+// PublicKeysPEM(新旧公钥并存 grace 期双验),本方法只作"未配置时的单钥兜底"。
+//
+// 入参:privateKeyPEM string:RS256 私钥 PEM(PKCS1 或 PKCS8;与 NewIssuer 同一入口)。
+// 出参:
+//   - string:SPKI 公钥 PEM。
+//   - error:私钥 PEM 解析失败(与 NewIssuer 同一 fail-fast 语义——带病不启动)。
+func PublicKeysFromPrivateKey(privateKeyPEM string) (string, error) {
+	key, err := parseRSAPrivateKey(privateKeyPEM)
+	if err != nil {
+		return "", fmt.Errorf("token: 从私钥推导公钥失败: %w", err)
+	}
+	der, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
+	if err != nil {
+		return "", fmt.Errorf("token: 序列化公钥失败: %w", err)
+	}
+	return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})), nil
+}
+
 // jsonMarshal / jsonUnmarshal 薄封装(等价于 json.Marshal/Unmarshal,
 // 抽一层便于未来统一换加速实现,调用方不直接依赖 encoding/json)。
 func jsonMarshal(v interface{}) ([]byte, error) {

+ 320 - 0
internal/identity/token/token_test.go

@@ -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)
+	}
+}