auth_test.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. // 验签中间件单测(T4-5 验收):Bearer 解析 + 验签 + 作用域注入 + 错误映射。
  2. //
  3. // 用真 Issuer + miniredis(真 RS256 签发/验签 + 真黑名单),覆盖:
  4. // - 无 Authorization / 非 Bearer → 1001
  5. // - 签名无效(错钥)/ 过期 / iss 不符 → 1001
  6. // - 已吊销 jti → 1002(登录态失效)
  7. // - 合法 token → 放行 + c.Locals 含 *token.Claims + scope.Scope 注入正确(role/superadmin/作用域)
  8. package handler
  9. import (
  10. "context"
  11. "encoding/json"
  12. "net/http"
  13. "net/http/httptest"
  14. "testing"
  15. "time"
  16. "github.com/gofiber/fiber/v2"
  17. "yuansiwei/unified-backend/internal/identity/model"
  18. "yuansiwei/unified-backend/internal/identity/token"
  19. apperrors "yuansiwei/unified-backend/internal/infra/errors"
  20. "yuansiwei/unified-backend/internal/infra/scope"
  21. orgmodel "yuansiwei/unified-backend/internal/org/model"
  22. )
  23. // authApp 起一个挂了 NewAuth 中间件 + 受保护 /auth/me 的 fiber 应用。
  24. func authApp(t *testing.T, fx *fixture) *fiber.App {
  25. t.Helper()
  26. mw, err := NewAuth(AuthConfig{PublicKeys: fx.pubKeys, Revocation: fx.revoke, Issuer: "https://unified.test"})
  27. if err != nil {
  28. t.Fatalf("NewAuth: %v", err)
  29. }
  30. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  31. grp := app.Group("/auth")
  32. grp.Use(mw)
  33. grp.Get("/me", fx.handler.Me)
  34. return app
  35. }
  36. // newIssuer 用指定密钥/issuer/TTL 造一个 Issuer(复用 fixture 的 refresh/revocation 存储)。
  37. func newIssuer(t *testing.T, fx *fixture, privPEM, issuer string, accessTTL time.Duration) *token.Issuer {
  38. t.Helper()
  39. iss, err := token.NewIssuer(privPEM, issuer, accessTTL, 30*24*time.Hour, fx.refresh, fx.revoke)
  40. if err != nil {
  41. t.Fatalf("NewIssuer: %v", err)
  42. }
  43. return iss
  44. }
  45. // issueToken 用指定 issuer 签一个 access(只返回 access,专注验签链路)。
  46. func issueToken(t *testing.T, iss *token.Issuer, c token.Claims) string {
  47. t.Helper()
  48. access, _, err := iss.Issue(context.Background(), c)
  49. if err != nil {
  50. t.Fatalf("Issue: %v", err)
  51. }
  52. return access
  53. }
  54. // doBearer 带 Bearer 头请求 /auth/me 并解出统一信封 code。
  55. func doBearer(t *testing.T, app *fiber.App, authHeader string) int {
  56. t.Helper()
  57. req := httptest.NewRequest(http.MethodGet, "/auth/me", nil)
  58. if authHeader != "" {
  59. req.Header.Set("Authorization", authHeader)
  60. }
  61. resp, err := app.Test(req, -1)
  62. if err != nil {
  63. t.Fatalf("请求失败: %v", err)
  64. }
  65. var env envelope
  66. if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
  67. t.Fatalf("解析响应失败: %v", err)
  68. }
  69. return env.Code
  70. }
  71. // TestAuth_NoAuthorization 无 Authorization 头:1001。
  72. func TestAuth_NoAuthorization(t *testing.T) {
  73. fx := newFixture(t)
  74. app := authApp(t, fx)
  75. if code := doBearer(t, app, ""); code != apperrors.CodeUnauth {
  76. t.Fatalf("code=%d(期望 %d 未登录)", code, apperrors.CodeUnauth)
  77. }
  78. }
  79. // TestAuth_NotBearer 非 Bearer scheme:1001。
  80. func TestAuth_NotBearer(t *testing.T) {
  81. fx := newFixture(t)
  82. app := authApp(t, fx)
  83. if code := doBearer(t, app, "Basic abcdef"); code != apperrors.CodeUnauth {
  84. t.Fatalf("code=%d(期望 %d 未登录)", code, apperrors.CodeUnauth)
  85. }
  86. }
  87. // TestAuth_BadSignature 签名无效(用另一对密钥签):1001。
  88. func TestAuth_BadSignature(t *testing.T) {
  89. fx := newFixture(t)
  90. other := newFixture(t) // 不同密钥对
  91. bad := issueToken(t, other.issuer, token.Claims{
  92. Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3,
  93. })
  94. app := authApp(t, fx)
  95. if code := doBearer(t, app, "Bearer "+bad); code != apperrors.CodeUnauth {
  96. t.Fatalf("code=%d(期望 %d 签名无效)", code, apperrors.CodeUnauth)
  97. }
  98. }
  99. // TestAuth_Expired 过期 token:1001。
  100. func TestAuth_Expired(t *testing.T) {
  101. fx := newFixture(t)
  102. shortIss := newIssuer(t, fx, fx.privPEM, "https://unified.test", 1*time.Second)
  103. exp := issueToken(t, shortIss, token.Claims{
  104. Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3,
  105. })
  106. time.Sleep(1100 * time.Millisecond)
  107. app := authApp(t, fx)
  108. if code := doBearer(t, app, "Bearer "+exp); code != apperrors.CodeUnauth {
  109. t.Fatalf("code=%d(期望 %d 过期)", code, apperrors.CodeUnauth)
  110. }
  111. }
  112. // TestAuth_WrongIssuer iss 不符:1001。
  113. func TestAuth_WrongIssuer(t *testing.T) {
  114. fx := newFixture(t)
  115. otherIss := newIssuer(t, fx, fx.privPEM, "https://other.test", 30*time.Minute) // 同钥不同 iss
  116. bad := issueToken(t, otherIss, token.Claims{
  117. Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3,
  118. })
  119. app := authApp(t, fx) // 验签 issuer = https://unified.test
  120. if code := doBearer(t, app, "Bearer "+bad); code != apperrors.CodeUnauth {
  121. t.Fatalf("code=%d(期望 %d iss 不符)", code, apperrors.CodeUnauth)
  122. }
  123. }
  124. // TestAuth_Revoked 已吊销 jti:1002(登录态失效)。
  125. func TestAuth_Revoked(t *testing.T) {
  126. fx := newFixture(t)
  127. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  128. access := issueToken(t, fx.issuer, token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3})
  129. // 解析 jti 并拉黑(用 revocation=nil 的预验签取 jti,不影响黑名单)
  130. claims, err := token.Verify(context.Background(), fx.pubKeys, nil, "https://unified.test", access)
  131. if err != nil {
  132. t.Fatalf("预验签取 jti 失败: %v", err)
  133. }
  134. if err := fx.revoke.AddRevoked(context.Background(), claims.ID, time.Minute); err != nil {
  135. t.Fatalf("AddRevoked: %v", err)
  136. }
  137. app := authApp(t, fx)
  138. if code := doBearer(t, app, "Bearer "+access); code != apperrors.CodeTokenExpired {
  139. t.Fatalf("code=%d(期望 %d 登录态失效)", code, apperrors.CodeTokenExpired)
  140. }
  141. }
  142. // TestAuth_Success_Scope 合法 token:放行 + Me 成功(code 200)。
  143. func TestAuth_Success_Scope(t *testing.T) {
  144. fx := newFixture(t)
  145. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  146. fx.users.byUID[uid] = &model.User{CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive}
  147. fx.memberships.add(uid, orgmodel.Membership{
  148. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active",
  149. })
  150. access := issueToken(t, fx.issuer, token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3})
  151. app := authApp(t, fx)
  152. if code := doBearer(t, app, "Bearer "+access); code != 200 {
  153. t.Fatalf("code=%d(期望 200 放行)", code)
  154. }
  155. }
  156. // TestAuth_Success_SuperAdmin 超管 token:buildScope 置 SuperAdmin=true(跨机构不 scoping)。
  157. func TestAuth_Success_SuperAdmin(t *testing.T) {
  158. fx := newFixture(t)
  159. access := issueToken(t, fx.issuer, token.Claims{
  160. Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0,
  161. })
  162. claims, err := token.Verify(context.Background(), fx.pubKeys, nil, "https://unified.test", access)
  163. if err != nil {
  164. t.Fatalf("验签: %v", err)
  165. }
  166. sc := buildScope(claims)
  167. if !sc.SuperAdmin {
  168. t.Fatalf("SuperAdmin=%v(期望 true)", sc.SuperAdmin)
  169. }
  170. if sc.Role != "super_admin" {
  171. t.Fatalf("Role=%q(期望 super_admin)", sc.Role)
  172. }
  173. if sc.UserID != "01GAQF0Z6C1V9Y3E5H7K2M8B4T" {
  174. t.Fatalf("UserID=%q(期望 ULID)", sc.UserID)
  175. }
  176. }
  177. // TestAuth_ScopeInjection 合法 token 后 scope 与 claims 均注入 c.Locals。
  178. func TestAuth_ScopeInjection(t *testing.T) {
  179. fx := newFixture(t)
  180. mw, err := NewAuth(AuthConfig{PublicKeys: fx.pubKeys, Revocation: fx.revoke, Issuer: "https://unified.test"})
  181. if err != nil {
  182. t.Fatalf("NewAuth: %v", err)
  183. }
  184. access := issueToken(t, fx.issuer, token.Claims{
  185. Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"academic"}, InstitutionID: 11, CampusID: 3,
  186. })
  187. var gotScope *scope.Scope
  188. var gotClaims *token.Claims
  189. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  190. app.Get("/x", mw, func(c *fiber.Ctx) error {
  191. gotScope = scope.FromFiber(c)
  192. if cl, ok := c.Locals(ClaimsCtxKey).(*token.Claims); ok {
  193. gotClaims = cl
  194. }
  195. return c.SendString("ok")
  196. })
  197. req := httptest.NewRequest(http.MethodGet, "/x", nil)
  198. req.Header.Set("Authorization", "Bearer "+access)
  199. resp, err := app.Test(req, -1)
  200. if err != nil {
  201. t.Fatalf("请求失败: %v", err)
  202. }
  203. if resp.StatusCode != http.StatusOK {
  204. t.Fatalf("HTTP=%d(期望 200 放行)", resp.StatusCode)
  205. }
  206. if gotScope == nil {
  207. t.Fatal("scope 未注入")
  208. }
  209. if gotScope.InstitutionID != 11 || gotScope.CampusID != 3 || gotScope.Role != "academic" {
  210. t.Fatalf("scope=%+v(期望 inst=11 campus=3 role=academic)", gotScope)
  211. }
  212. if gotClaims == nil || gotClaims.Sub != "01GAQF0Z6C1V9Y3E5H7K2M8B4T" {
  213. t.Fatalf("claims 未正确注入: %+v", gotClaims)
  214. }
  215. }