Explorar el Código

feat(identity): T4-5 验签中间件 + scope/claims 注入(NewAuth)

- identity/handler/auth.go: NewAuth(AuthConfig{PublicKeys,Revocation,Issuer}) → fiber.Handler
  · 取 Authorization: Bearer <token>(RFC 6750,scheme 大小写不敏感;缺/非 Bearer → 1001)
  · token.Verify(先查吊销黑名单再验签,命中即拒省 RSA)
  · 错误映射:ErrInvalidToken→1001 未登录 / ErrRevoked→1002 登录态失效 /
    其它(黑名单存储故障)→5001 + fail-closed(拒绝+告警,不放行:吊销失效=改密后旧 token 仍可用)
  · 验签通过:c.Locals(ClaimsCtxKey,&claims)(handler.Me/Logout 读回)+ scope.Set(c,buildScope(claims))
  · buildScope:Sub→UserID / 作用域直取 / Roles[0]→Role / 含 super_admin→SuperAdmin=true(跨机构不 scoping)
  · 双实例(ADR-015):验签只用公钥内存,黑名单/refresh 走共享 Redis 天然一致
- 单测 9 例全绿(真 Issuer + miniredis):无授权/非 Bearer/错钥/过期/iss 不符→1001;
  已吊销 jti→1002;合法 token→200 放行 + claims/scope 注入正确;超管 SuperAdmin 置位
Q hace 3 días
padre
commit
04844e52a2

+ 136 - 0
internal/identity/handler/auth.go

@@ -0,0 +1,136 @@
+// 验签中间件(T4-5):受保护路由前置,验签 access + 注入多租户作用域(架构规格 §3.5)。
+//
+// 数据流(与 infra/scope 包注释对齐):
+//   1. 取 Authorization: Bearer <token>;缺失/非 Bearer → 1001 未登录;
+//   2. token.Verify(**先查吊销黑名单再验签**,黑名单命中即拒,省一次 RSA 验签);
+//   3. 验签通过:
+//      a. 把 *token.Claims 写入 c.Locals(ClaimsCtxKey)(handler.Me/Logout 经 claimsFromCtx 读回);
+//      b. 由 claims 构造 scope.Scope 并 scope.Set(c, sc)(业务域 Repository 的 scoping 强制过滤依据);
+//   4. c.Next() 放行到受保护 Handler。
+//
+// 错误映射(ADR-014,与 token 包语义一致):
+//   - token.ErrInvalidToken(签名错/exp 过期/iss 不符/格式损坏)→ code 1001 未登录;
+//   - token.ErrRevoked(jti 命中黑名单:登出/改密/禁用后)   → code 1002 登录态失效;
+//   - 其它 error(黑名单存储故障等)                         → code 5001 + **fail-closed**(不放行——
+//     吊销失效 = 改密后旧 token 仍可用,安全红线;故障细节只进日志,不进入响应体)。
+//
+// 双实例(ADR-015):验签只用公钥(进程内存),两实例签发 token 互相可验;
+// 黑名单/refresh 走共享 Redis,天然一致——无需 per-instance 密钥。
+package handler
+
+import (
+	stderrors "errors"
+	"strings"
+
+	"github.com/gofiber/fiber/v2"
+
+	"yuansiwei/unified-backend/internal/identity/token"
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/infra/response"
+	"yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// superAdminRole 平台超管角色码(scope.SuperAdmin 置位依据,跨机构操作跳过 scoping 机构过滤)。
+// 与 T4 拍板的字符串角色码一致(super_admin/academic/finance/teacher/student/parent/market)。
+const superAdminRole = "super_admin"
+
+// AuthConfig 验签中间件装配参数(T4-6 main 构造;全部必填)。
+type AuthConfig struct {
+	// PublicKeys:验签公钥列表(支持密钥轮换:新旧并存 grace 期双验;来自 config.Auth.PublicKeysPEM)。
+	PublicKeys []string
+	// Revocation:吊销黑名单存储(验签前查 jti;nil 时跳过黑名单直验签——纯本地场景,生产必给,
+	// 否则登出/改密后旧 token 在 access 有效期内仍可访问 = 安全漏洞)。
+	Revocation token.RevocationStore
+	// Issuer:token iss 声明(验签比对;须与签发器一致)。
+	Issuer string
+}
+
+// NewAuth 构造验签中间件(fail-fast:公钥/issuer 缺失时直接报错,不带病启动)。
+//
+// 入参:cfg AuthConfig:见 AuthConfig 字段说明。
+// 出参:
+//   - fiber.Handler:可直接 app.Group(...).Use(...) 挂载的中间件。
+//   - error:PublicKeys 为空 / Issuer 为空(配置错误,fail-fast)。
+func NewAuth(cfg AuthConfig) (fiber.Handler, error) {
+	if len(cfg.PublicKeys) == 0 {
+		return nil, apperrors.New(apperrors.CodeInternal, "auth: 验签公钥列表为空(PublicKeys 必填)")
+	}
+	if cfg.Issuer == "" {
+		return nil, apperrors.New(apperrors.CodeInternal, "auth: issuer 为空(Issuer 必填)")
+	}
+	return func(c *fiber.Ctx) error {
+		bearer := extractBearer(c)
+		if bearer == "" {
+			return response.Fail(c, apperrors.ErrUnauth) // 缺 token / 非 Bearer 头
+		}
+		claims, err := token.Verify(c.Context(), cfg.PublicKeys, cfg.Revocation, cfg.Issuer, bearer)
+		if err != nil {
+			switch {
+			case stderrors.Is(err, token.ErrRevoked):
+				return response.Fail(c, apperrors.ErrTokenExpired) // 1002 登录态失效
+			case stderrors.Is(err, token.ErrInvalidToken):
+				return response.Fail(c, apperrors.ErrUnauth) // 1001 未登录
+			default:
+				// 黑名单存储故障等:fail-closed(拒绝 + 5001,细节只进日志)
+				return response.Fail(c, apperrors.WithCause(apperrors.ErrInternal, err))
+			}
+		}
+		// 注入已验签 claims(handler.Me/Logout 读回;指针保证 Logout 能取到 ExpiresAt 算剩余 TTL)
+		_ = c.Locals(ClaimsCtxKey, &claims)
+		// 注入多租户作用域(业务域 Repository 的 scoping 强制过滤依据)
+		scope.Set(c, buildScope(claims))
+		return c.Next()
+	}, nil
+}
+
+// extractBearer 从 Authorization 头取 Bearer 后的 token 原串(RFC 6750:scheme 大小写不敏感)。
+//
+// 入参:c *fiber.Ctx。
+// 出参:token 原串;无 Authorization 头 / 非 Bearer / 空 token 时返回 ""。
+func extractBearer(c *fiber.Ctx) string {
+	h := c.Get("Authorization")
+	if h == "" {
+		return ""
+	}
+	// "Bearer <token>":按首个空白切分,前段不区分大小写比对 "bearer"
+	i := 0
+	for i < len(h) && h[i] != ' ' && h[i] != '\t' {
+		i++
+	}
+	if i == 0 || i == len(h) {
+		return ""
+	}
+	scheme, rest := h[:i], h[i:]
+	for len(rest) > 0 && (rest[0] == ' ' || rest[0] == '\t') {
+		rest = rest[1:]
+	}
+	if !strings.EqualFold(scheme, "bearer") {
+		return ""
+	}
+	return strings.TrimSpace(rest)
+}
+
+// buildScope 由已验签 claims 构造多租户作用域(scope.Set 写入 fiber.Ctx)。
+//
+// 入参:c token.Claims:验签通过的业务声明(Sub/Roles/InstitutionID/CampusID 可信)。
+// 出参:scope.Scope:
+//   - InstitutionID/CampusID/UserID:直取 claims(Sub → UserID);
+//   - Role:取 Roles[0](选定作用域通常单角色;多角色时主角色在前,由签发侧保证);
+//   - SuperAdmin:Roles 含 super_admin 时置 true(平台超管跨机构,scoping 跳过机构过滤)。
+func buildScope(c token.Claims) *scope.Scope {
+	sc := &scope.Scope{
+		InstitutionID: c.InstitutionID,
+		CampusID:      c.CampusID,
+		UserID:        c.Sub,
+	}
+	if len(c.Roles) > 0 {
+		sc.Role = c.Roles[0]
+	}
+	for _, r := range c.Roles {
+		if r == superAdminRole {
+			sc.SuperAdmin = true
+			break
+		}
+	}
+	return sc
+}

+ 231 - 0
internal/identity/handler/auth_test.go

@@ -0,0 +1,231 @@
+// 验签中间件单测(T4-5 验收):Bearer 解析 + 验签 + 作用域注入 + 错误映射。
+//
+// 用真 Issuer + miniredis(真 RS256 签发/验签 + 真黑名单),覆盖:
+//   - 无 Authorization / 非 Bearer → 1001
+//   - 签名无效(错钥)/ 过期 / iss 不符 → 1001
+//   - 已吊销 jti → 1002(登录态失效)
+//   - 合法 token → 放行 + c.Locals 含 *token.Claims + scope.Scope 注入正确(role/superadmin/作用域)
+package handler
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+
+	"yuansiwei/unified-backend/internal/identity/model"
+	"yuansiwei/unified-backend/internal/identity/token"
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/infra/scope"
+	orgmodel "yuansiwei/unified-backend/internal/org/model"
+)
+
+// authApp 起一个挂了 NewAuth 中间件 + 受保护 /auth/me 的 fiber 应用。
+func authApp(t *testing.T, fx *fixture) *fiber.App {
+	t.Helper()
+	mw, err := NewAuth(AuthConfig{PublicKeys: fx.pubKeys, Revocation: fx.revoke, Issuer: "https://unified.test"})
+	if err != nil {
+		t.Fatalf("NewAuth: %v", err)
+	}
+	app := fiber.New(fiber.Config{DisableStartupMessage: true})
+	grp := app.Group("/auth")
+	grp.Use(mw)
+	grp.Get("/me", fx.handler.Me)
+	return app
+}
+
+// newIssuer 用指定密钥/issuer/TTL 造一个 Issuer(复用 fixture 的 refresh/revocation 存储)。
+func newIssuer(t *testing.T, fx *fixture, privPEM, issuer string, accessTTL time.Duration) *token.Issuer {
+	t.Helper()
+	iss, err := token.NewIssuer(privPEM, issuer, accessTTL, 30*24*time.Hour, fx.refresh, fx.revoke)
+	if err != nil {
+		t.Fatalf("NewIssuer: %v", err)
+	}
+	return iss
+}
+
+// issueToken 用指定 issuer 签一个 access(只返回 access,专注验签链路)。
+func issueToken(t *testing.T, iss *token.Issuer, c token.Claims) string {
+	t.Helper()
+	access, _, err := iss.Issue(context.Background(), c)
+	if err != nil {
+		t.Fatalf("Issue: %v", err)
+	}
+	return access
+}
+
+// doBearer 带 Bearer 头请求 /auth/me 并解出统一信封 code。
+func doBearer(t *testing.T, app *fiber.App, authHeader string) int {
+	t.Helper()
+	req := httptest.NewRequest(http.MethodGet, "/auth/me", nil)
+	if authHeader != "" {
+		req.Header.Set("Authorization", authHeader)
+	}
+	resp, err := app.Test(req, -1)
+	if err != nil {
+		t.Fatalf("请求失败: %v", err)
+	}
+	var env envelope
+	if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
+		t.Fatalf("解析响应失败: %v", err)
+	}
+	return env.Code
+}
+
+// TestAuth_NoAuthorization 无 Authorization 头:1001。
+func TestAuth_NoAuthorization(t *testing.T) {
+	fx := newFixture(t)
+	app := authApp(t, fx)
+	if code := doBearer(t, app, ""); code != apperrors.CodeUnauth {
+		t.Fatalf("code=%d(期望 %d 未登录)", code, apperrors.CodeUnauth)
+	}
+}
+
+// TestAuth_NotBearer 非 Bearer scheme:1001。
+func TestAuth_NotBearer(t *testing.T) {
+	fx := newFixture(t)
+	app := authApp(t, fx)
+	if code := doBearer(t, app, "Basic abcdef"); code != apperrors.CodeUnauth {
+		t.Fatalf("code=%d(期望 %d 未登录)", code, apperrors.CodeUnauth)
+	}
+}
+
+// TestAuth_BadSignature 签名无效(用另一对密钥签):1001。
+func TestAuth_BadSignature(t *testing.T) {
+	fx := newFixture(t)
+	other := newFixture(t) // 不同密钥对
+	bad := issueToken(t, other.issuer, token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3,
+	})
+	app := authApp(t, fx)
+	if code := doBearer(t, app, "Bearer "+bad); code != apperrors.CodeUnauth {
+		t.Fatalf("code=%d(期望 %d 签名无效)", code, apperrors.CodeUnauth)
+	}
+}
+
+// TestAuth_Expired 过期 token:1001。
+func TestAuth_Expired(t *testing.T) {
+	fx := newFixture(t)
+	shortIss := newIssuer(t, fx, fx.privPEM, "https://unified.test", 1*time.Second)
+	exp := issueToken(t, shortIss, token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3,
+	})
+	time.Sleep(1100 * time.Millisecond)
+	app := authApp(t, fx)
+	if code := doBearer(t, app, "Bearer "+exp); code != apperrors.CodeUnauth {
+		t.Fatalf("code=%d(期望 %d 过期)", code, apperrors.CodeUnauth)
+	}
+}
+
+// TestAuth_WrongIssuer iss 不符:1001。
+func TestAuth_WrongIssuer(t *testing.T) {
+	fx := newFixture(t)
+	otherIss := newIssuer(t, fx, fx.privPEM, "https://other.test", 30*time.Minute) // 同钥不同 iss
+	bad := issueToken(t, otherIss, token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3,
+	})
+	app := authApp(t, fx) // 验签 issuer = https://unified.test
+	if code := doBearer(t, app, "Bearer "+bad); code != apperrors.CodeUnauth {
+		t.Fatalf("code=%d(期望 %d iss 不符)", code, apperrors.CodeUnauth)
+	}
+}
+
+// TestAuth_Revoked 已吊销 jti:1002(登录态失效)。
+func TestAuth_Revoked(t *testing.T) {
+	fx := newFixture(t)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	access := issueToken(t, fx.issuer, token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3})
+	// 解析 jti 并拉黑(用 revocation=nil 的预验签取 jti,不影响黑名单)
+	claims, err := token.Verify(context.Background(), fx.pubKeys, nil, "https://unified.test", access)
+	if err != nil {
+		t.Fatalf("预验签取 jti 失败: %v", err)
+	}
+	if err := fx.revoke.AddRevoked(context.Background(), claims.ID, time.Minute); err != nil {
+		t.Fatalf("AddRevoked: %v", err)
+	}
+	app := authApp(t, fx)
+	if code := doBearer(t, app, "Bearer "+access); code != apperrors.CodeTokenExpired {
+		t.Fatalf("code=%d(期望 %d 登录态失效)", code, apperrors.CodeTokenExpired)
+	}
+}
+
+// TestAuth_Success_Scope 合法 token:放行 + Me 成功(code 200)。
+func TestAuth_Success_Scope(t *testing.T) {
+	fx := newFixture(t)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.users.byUID[uid] = &model.User{CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive}
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active",
+	})
+	access := issueToken(t, fx.issuer, token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3})
+	app := authApp(t, fx)
+	if code := doBearer(t, app, "Bearer "+access); code != 200 {
+		t.Fatalf("code=%d(期望 200 放行)", code)
+	}
+}
+
+// TestAuth_Success_SuperAdmin 超管 token:buildScope 置 SuperAdmin=true(跨机构不 scoping)。
+func TestAuth_Success_SuperAdmin(t *testing.T) {
+	fx := newFixture(t)
+	access := issueToken(t, fx.issuer, token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0,
+	})
+	claims, err := token.Verify(context.Background(), fx.pubKeys, nil, "https://unified.test", access)
+	if err != nil {
+		t.Fatalf("验签: %v", err)
+	}
+	sc := buildScope(claims)
+	if !sc.SuperAdmin {
+		t.Fatalf("SuperAdmin=%v(期望 true)", sc.SuperAdmin)
+	}
+	if sc.Role != "super_admin" {
+		t.Fatalf("Role=%q(期望 super_admin)", sc.Role)
+	}
+	if sc.UserID != "01GAQF0Z6C1V9Y3E5H7K2M8B4T" {
+		t.Fatalf("UserID=%q(期望 ULID)", sc.UserID)
+	}
+}
+
+// TestAuth_ScopeInjection 合法 token 后 scope 与 claims 均注入 c.Locals。
+func TestAuth_ScopeInjection(t *testing.T) {
+	fx := newFixture(t)
+	mw, err := NewAuth(AuthConfig{PublicKeys: fx.pubKeys, Revocation: fx.revoke, Issuer: "https://unified.test"})
+	if err != nil {
+		t.Fatalf("NewAuth: %v", err)
+	}
+	access := issueToken(t, fx.issuer, token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"academic"}, InstitutionID: 11, CampusID: 3,
+	})
+	var gotScope *scope.Scope
+	var gotClaims *token.Claims
+	app := fiber.New(fiber.Config{DisableStartupMessage: true})
+	app.Get("/x", mw, func(c *fiber.Ctx) error {
+		gotScope = scope.FromFiber(c)
+		if cl, ok := c.Locals(ClaimsCtxKey).(*token.Claims); ok {
+			gotClaims = cl
+		}
+		return c.SendString("ok")
+	})
+	req := httptest.NewRequest(http.MethodGet, "/x", nil)
+	req.Header.Set("Authorization", "Bearer "+access)
+	resp, err := app.Test(req, -1)
+	if err != nil {
+		t.Fatalf("请求失败: %v", err)
+	}
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("HTTP=%d(期望 200 放行)", resp.StatusCode)
+	}
+	if gotScope == nil {
+		t.Fatal("scope 未注入")
+	}
+	if gotScope.InstitutionID != 11 || gotScope.CampusID != 3 || gotScope.Role != "academic" {
+		t.Fatalf("scope=%+v(期望 inst=11 campus=3 role=academic)", gotScope)
+	}
+	if gotClaims == nil || gotClaims.Sub != "01GAQF0Z6C1V9Y3E5H7K2M8B4T" {
+		t.Fatalf("claims 未正确注入: %+v", gotClaims)
+	}
+}

+ 7 - 1
internal/identity/handler/handler_test.go

@@ -127,7 +127,10 @@ type fixture struct {
 	memberships *mockMemberships
 	handler     *Handler
 	pubKeys     []string
+	privPEM     string
 	issuer      *token.Issuer
+	revoke      *appredis.RevocationStore
+	refresh     *appredis.RefreshStore
 }
 
 // newFixture 起 miniredis + 共享 *Client + 真 Issuer(30min/30d) + AuthService + Handler。
@@ -170,7 +173,10 @@ func newFixture(t *testing.T) *fixture {
 	if err != nil {
 		t.Fatalf("New Handler: %v", err)
 	}
-	return &fixture{t: t, users: users, identities: ids, memberships: mergs, handler: h, pubKeys: pubKeys, issuer: issuer}
+	return &fixture{
+		t: t, users: users, identities: ids, memberships: mergs, handler: h,
+		pubKeys: pubKeys, privPEM: privPEM, issuer: issuer, revoke: revocation, refresh: refreshStore,
+	}
 }
 
 // genRSAKey 生成 RS2048 私钥 + 公钥 PEM(PKCS1 私有 / PKIX 公共)。