| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231 |
- // 验签中间件单测(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)
- }
- }
|