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