Files
lijiaoqiao/supply-api/internal/iam/middleware/scope_auth_test.go
Your Name 50225f6822 fix: 修复4个安全漏洞 (HIGH-01, HIGH-02, MED-01, MED-02)
- HIGH-01: CheckScope空scope绕过权限检查
  * 修复: 空scope现在返回false拒绝访问

- HIGH-02: JWT算法验证不严格
  * 修复: 使用token.Method.Alg()严格验证只接受HS256

- MED-01: RequireAnyScope空scope列表逻辑错误
  * 修复: 空列表现在返回403拒绝访问

- MED-02: Token状态缓存未命中时默认返回active
  * 修复: 添加TokenStatusBackend接口,缓存未命中时必须查询后端

影响文件:
- supply-api/internal/iam/middleware/scope_auth.go
- supply-api/internal/middleware/auth.go
- supply-api/cmd/supply-api/main.go (适配新API)

测试覆盖:
- 添加4个新的安全测试用例
- 更新1个原有测试以反映正确的安全行为
2026-04-03 07:52:41 +08:00

572 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"lijiaoqiao/supply-api/internal/middleware"
)
// TestScopeAuth_CheckScope_SuperAdminHasAllScopes 测试超级管理员拥有所有Scope
func TestScopeAuth_CheckScope_SuperAdminHasAllScopes(t *testing.T) {
// arrange
// 创建超级管理员token claims
claims := &IAMTokenClaims{
SubjectID: "user:1",
Role: "super_admin",
Scope: []string{"*"}, // 通配符Scope代表所有权限
TenantID: 0,
}
ctx := WithIAMClaims(context.Background(), claims)
// act
hasScope := CheckScope(ctx, "platform:read")
hasScope2 := CheckScope(ctx, "supply:account:write")
hasScope3 := CheckScope(ctx, "consumer:apikey:create")
// assert
assert.True(t, hasScope, "super_admin should have platform:read")
assert.True(t, hasScope2, "super_admin should have supply:account:write")
assert.True(t, hasScope3, "super_admin should have consumer:apikey:create")
}
// TestScopeAuth_CheckScope_ViewerHasReadOnly 测试Viewer只有只读权限
func TestScopeAuth_CheckScope_ViewerHasReadOnly(t *testing.T) {
// arrange
claims := &IAMTokenClaims{
SubjectID: "user:2",
Role: "viewer",
Scope: []string{"platform:read", "tenant:read", "billing:read"},
TenantID: 1,
}
ctx := WithIAMClaims(context.Background(), claims)
// act & assert
assert.True(t, CheckScope(ctx, "platform:read"), "viewer should have platform:read")
assert.True(t, CheckScope(ctx, "tenant:read"), "viewer should have tenant:read")
assert.True(t, CheckScope(ctx, "billing:read"), "viewer should have billing:read")
assert.False(t, CheckScope(ctx, "platform:write"), "viewer should NOT have platform:write")
assert.False(t, CheckScope(ctx, "tenant:write"), "viewer should NOT have tenant:write")
assert.False(t, CheckScope(ctx, "supply:account:write"), "viewer should NOT have supply:account:write")
}
// TestScopeAuth_CheckScope_Denied 测试Scope被拒绝
func TestScopeAuth_CheckScope_Denied(t *testing.T) {
// arrange
claims := &IAMTokenClaims{
SubjectID: "user:3",
Role: "viewer",
Scope: []string{"platform:read"},
TenantID: 1,
}
ctx := WithIAMClaims(context.Background(), claims)
// act & assert
assert.False(t, CheckScope(ctx, "platform:write"), "viewer should NOT have platform:write")
assert.False(t, CheckScope(ctx, "supply:account:write"), "viewer should NOT have supply:account:write")
}
// TestScopeAuth_CheckScope_MissingTokenClaims 测试缺少Token Claims
func TestScopeAuth_CheckScope_MissingTokenClaims(t *testing.T) {
// arrange
ctx := context.Background() // 没有token claims
// act
hasScope := CheckScope(ctx, "platform:read")
// assert
assert.False(t, hasScope, "should return false when token claims are missing")
}
// TestScopeAuth_CheckScope_EmptyScope 测试空Scope要求
func TestScopeAuth_CheckScope_EmptyScope(t *testing.T) {
// arrange
claims := &IAMTokenClaims{
SubjectID: "user:4",
Role: "viewer",
Scope: []string{"platform:read"},
TenantID: 1,
}
ctx := WithIAMClaims(context.Background(), claims)
// act
hasEmptyScope := CheckScope(ctx, "")
// assert - 空scope应该拒绝访问安全修复
assert.False(t, hasEmptyScope, "empty scope should DENY access (security fix)")
}
// TestScopeAuth_CheckMultipleScopes 测试检查多个Scope需要全部满足
func TestScopeAuth_CheckMultipleScopes(t *testing.T) {
// arrange
claims := &IAMTokenClaims{
SubjectID: "user:5",
Role: "operator",
Scope: []string{"platform:read", "platform:write", "tenant:read", "tenant:write"},
TenantID: 1,
}
ctx := WithIAMClaims(context.Background(), claims)
// act & assert
assert.True(t, CheckAllScopes(ctx, []string{"platform:read", "platform:write"}), "operator should have both read and write")
assert.True(t, CheckAllScopes(ctx, []string{"tenant:read", "tenant:write"}), "operator should have both tenant scopes")
assert.False(t, CheckAllScopes(ctx, []string{"platform:read", "platform:admin"}), "operator should NOT have platform:admin")
}
// TestScopeAuth_CheckAnyScope 测试检查多个Scope只需满足其一
func TestScopeAuth_CheckAnyScope(t *testing.T) {
// arrange
claims := &IAMTokenClaims{
SubjectID: "user:6",
Role: "viewer",
Scope: []string{"platform:read"},
TenantID: 1,
}
ctx := WithIAMClaims(context.Background(), claims)
// act & assert
assert.True(t, CheckAnyScope(ctx, []string{"platform:read", "platform:write"}), "should pass with one matching scope")
assert.False(t, CheckAnyScope(ctx, []string{"platform:write", "platform:admin"}), "should fail when no scopes match")
assert.True(t, CheckAnyScope(ctx, []string{}), "empty scope list should pass")
}
// TestScopeAuth_GetIAMTokenClaims 测试从Context获取IAMTokenClaims
func TestScopeAuth_GetIAMTokenClaims(t *testing.T) {
// arrange
claims := &IAMTokenClaims{
SubjectID: "user:7",
Role: "org_admin",
Scope: []string{"platform:read", "platform:write"},
TenantID: 1,
}
ctx := WithIAMClaims(context.Background(), claims)
// act
retrievedClaims := GetIAMTokenClaims(ctx)
// assert
assert.NotNil(t, retrievedClaims)
assert.Equal(t, claims.SubjectID, retrievedClaims.SubjectID)
assert.Equal(t, claims.Role, retrievedClaims.Role)
assert.Equal(t, claims.Scope, retrievedClaims.Scope)
}
// TestScopeAuth_GetIAMTokenClaims_Missing 测试获取不存在的IAMTokenClaims
func TestScopeAuth_GetIAMTokenClaims_Missing(t *testing.T) {
// arrange
ctx := context.Background()
// act
retrievedClaims := GetIAMTokenClaims(ctx)
// assert
assert.Nil(t, retrievedClaims)
}
// TestScopeAuth_HasRole 测试用户角色检查
func TestScopeAuth_HasRole(t *testing.T) {
// arrange
claims := &IAMTokenClaims{
SubjectID: "user:8",
Role: "operator",
Scope: []string{"platform:read"},
TenantID: 1,
}
ctx := WithIAMClaims(context.Background(), claims)
// act & assert
assert.True(t, HasRole(ctx, "operator"))
assert.False(t, HasRole(ctx, "viewer"))
assert.False(t, HasRole(ctx, "admin"))
}
// TestScopeAuth_HasRole_MissingClaims 测试缺少Claims时的角色检查
func TestScopeAuth_HasRole_MissingClaims(t *testing.T) {
// arrange
ctx := context.Background()
// act & assert
assert.False(t, HasRole(ctx, "operator"))
}
// TestScopeRoleAuthzMiddleware_WithScope 测试带Scope要求的中间件
func TestScopeRoleAuthzMiddleware_WithScope(t *testing.T) {
// arrange
scopeAuth := NewScopeAuthMiddleware()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
})
// 创建一个带scope验证的handler
wrappedHandler := scopeAuth.RequireScope("platform:write")(handler)
// 创建一个带有token claims的请求
claims := &IAMTokenClaims{
SubjectID: "user:9",
Role: "operator",
Scope: []string{"platform:read", "platform:write"},
TenantID: 1,
}
req := httptest.NewRequest("GET", "/test", nil)
req = req.WithContext(WithIAMClaims(req.Context(), claims))
// act
rec := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rec, req)
// assert
assert.Equal(t, http.StatusOK, rec.Code)
}
// TestScopeRoleAuthzMiddleware_Denied 测试Scope不足时中间件拒绝
func TestScopeRoleAuthzMiddleware_Denied(t *testing.T) {
// arrange
scopeAuth := NewScopeAuthMiddleware()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrappedHandler := scopeAuth.RequireScope("platform:admin")(handler)
claims := &IAMTokenClaims{
SubjectID: "user:10",
Role: "viewer",
Scope: []string{"platform:read"}, // viewer没有platform:admin
TenantID: 1,
}
req := httptest.NewRequest("GET", "/test", nil)
req = req.WithContext(WithIAMClaims(req.Context(), claims))
// act
rec := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rec, req)
// assert
assert.Equal(t, http.StatusForbidden, rec.Code)
}
// TestScopeRoleAuthzMiddleware_MissingClaims 测试缺少Claims时中间件拒绝
func TestScopeRoleAuthzMiddleware_MissingClaims(t *testing.T) {
// arrange
scopeAuth := NewScopeAuthMiddleware()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrappedHandler := scopeAuth.RequireScope("platform:read")(handler)
req := httptest.NewRequest("GET", "/test", nil)
// 不设置token claims
// act
rec := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rec, req)
// assert
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}
// TestScopeRoleAuthzMiddleware_RequireAllScopes 测试要求所有Scope的中间件
func TestScopeRoleAuthzMiddleware_RequireAllScopes(t *testing.T) {
// arrange
scopeAuth := NewScopeAuthMiddleware()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrappedHandler := scopeAuth.RequireAllScopes([]string{"platform:read", "tenant:read"})(handler)
claims := &IAMTokenClaims{
SubjectID: "user:11",
Role: "operator",
Scope: []string{"platform:read", "platform:write", "tenant:read"},
TenantID: 1,
}
req := httptest.NewRequest("GET", "/test", nil)
req = req.WithContext(WithIAMClaims(req.Context(), claims))
// act
rec := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rec, req)
// assert
assert.Equal(t, http.StatusOK, rec.Code)
}
// TestScopeRoleAuthzMiddleware_RequireAllScopes_Denied 测试要求所有Scope但不足时拒绝
func TestScopeRoleAuthzMiddleware_RequireAllScopes_Denied(t *testing.T) {
// arrange
scopeAuth := NewScopeAuthMiddleware()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrappedHandler := scopeAuth.RequireAllScopes([]string{"platform:read", "platform:admin"})(handler)
claims := &IAMTokenClaims{
SubjectID: "user:12",
Role: "viewer",
Scope: []string{"platform:read"}, // viewer没有platform:admin
TenantID: 1,
}
req := httptest.NewRequest("GET", "/test", nil)
req = req.WithContext(WithIAMClaims(req.Context(), claims))
// act
rec := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rec, req)
// assert
assert.Equal(t, http.StatusForbidden, rec.Code)
}
// TestScopeAuth_HasRoleLevel 测试角色层级检查
func TestScopeAuth_HasRoleLevel(t *testing.T) {
// arrange
testCases := []struct {
role string
minLevel int
expected bool
}{
{"super_admin", 50, true},
{"super_admin", 100, true},
{"org_admin", 50, true},
{"org_admin", 60, false},
{"operator", 30, true},
{"operator", 40, false},
{"viewer", 10, true},
{"viewer", 20, false},
}
for _, tc := range testCases {
claims := &IAMTokenClaims{
SubjectID: "user:test",
Role: tc.role,
Scope: []string{},
TenantID: 1,
}
ctx := WithIAMClaims(context.Background(), claims)
// act
result := HasRoleLevel(ctx, tc.minLevel)
// assert
assert.Equal(t, tc.expected, result, "role=%s, minLevel=%d", tc.role, tc.minLevel)
}
}
// TestGetRoleLevel 测试获取角色层级
func TestGetRoleLevel(t *testing.T) {
testCases := []struct {
role string
expected int
}{
{"super_admin", 100},
{"org_admin", 50},
{"supply_admin", 40},
{"operator", 30},
{"developer", 20},
{"viewer", 10},
{"unknown_role", 0},
}
for _, tc := range testCases {
// act
level := GetRoleLevel(tc.role)
// assert
assert.Equal(t, tc.expected, level, "role=%s", tc.role)
}
}
// TestScopeAuth_WithIAMClaims 测试设置IAM Claims到Context
func TestScopeAuth_WithIAMClaims(t *testing.T) {
// arrange
claims := &IAMTokenClaims{
SubjectID: "user:13",
Role: "org_admin",
Scope: []string{"platform:read"},
TenantID: 1,
}
// act
ctx := WithIAMClaims(context.Background(), claims)
retrievedClaims := GetIAMTokenClaims(ctx)
// assert
assert.NotNil(t, retrievedClaims)
assert.Equal(t, claims.SubjectID, retrievedClaims.SubjectID)
assert.Equal(t, claims.Role, retrievedClaims.Role)
}
// TestGetClaimsFromLegacy 测试从原有TokenClaims转换
func TestGetClaimsFromLegacy(t *testing.T) {
// arrange
legacyClaims := &middleware.TokenClaims{
SubjectID: "user:14",
Role: "viewer",
Scope: []string{"platform:read"},
TenantID: 1,
}
// act
iamClaims := GetClaimsFromLegacy(legacyClaims)
// assert
assert.NotNil(t, iamClaims)
assert.Equal(t, legacyClaims.SubjectID, iamClaims.SubjectID)
assert.Equal(t, legacyClaims.Role, iamClaims.Role)
assert.Equal(t, legacyClaims.Scope, iamClaims.Scope)
assert.Equal(t, legacyClaims.TenantID, iamClaims.TenantID)
}
// P0-01: 测试WithIAMClaims存储指针返回有效指针而非悬空指针
// 问题GetIAMTokenClaims返回指向栈帧的指针函数返回后指针无效
// 修复:改为存储和获取指针,返回有效堆内存指针
func TestP0_01_WithIAMClaims_ReturnsValidPointer(t *testing.T) {
// arrange - 创建一个claims并存储到context
originalClaims := &IAMTokenClaims{
SubjectID: "user:p0test1",
Role: "operator",
Scope: []string{"platform:read"},
TenantID: 100,
}
ctx := WithIAMClaims(context.Background(), originalClaims)
// act - 从context获取claims获取的应该是有效指针
retrievedClaims := GetIAMTokenClaims(ctx)
// assert - 返回的应该是有效指针指向与原始claims相同的内存
assert.NotNil(t, retrievedClaims, "retrieved claims should not be nil")
assert.Equal(t, originalClaims, retrievedClaims, "should return same pointer as stored")
assert.Equal(t, "user:p0test1", retrievedClaims.SubjectID, "SubjectID should match")
assert.Equal(t, "operator", retrievedClaims.Role, "Role should match")
// 验证修改原始对象后retrievedClaims能看到变化因为共享指针
originalClaims.Role = "super_admin"
assert.Equal(t, "super_admin", retrievedClaims.Role, "retrieved claims should see modification")
}
// P0-01: 测试GetIAMTokenClaims在context返回后仍然有效
func TestP0_01_GetIAMTokenClaims_PointerValidAfterReturn(t *testing.T) {
// arrange
claims := &IAMTokenClaims{
SubjectID: "user:ptrtest",
Role: "viewer",
Scope: []string{"platform:read"},
TenantID: 1,
}
// act - 存储到context
ctx := WithIAMClaims(context.Background(), claims)
// 在函数外获取claims模拟中间件在请求处理中访问
retrievedClaims := GetIAMTokenClaims(ctx)
// assert - 应该返回有效指针而不是nil或无效指针
assert.NotNil(t, retrievedClaims)
assert.Equal(t, claims, retrievedClaims, "should return exact same pointer")
assert.Equal(t, "user:ptrtest", retrievedClaims.SubjectID)
}
// P0-02: 测试writeAuthError写入响应体
func TestP0_02_writeAuthError_WritesResponseBody(t *testing.T) {
// arrange
rec := httptest.NewRecorder()
// act - 调用writeAuthError
writeAuthError(rec, http.StatusUnauthorized, "AUTH_CONTEXT_MISSING", "authentication context is missing")
// assert - 响应体应该包含错误信息
body := rec.Body.String()
assert.NotEmpty(t, body, "response body should not be empty")
// 验证响应体包含错误码和消息
assert.Contains(t, body, "AUTH_CONTEXT_MISSING", "body should contain error code")
assert.Contains(t, body, "authentication context is missing", "body should contain error message")
assert.Equal(t, http.StatusUnauthorized, rec.Code, "status code should match")
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"), "content type should be JSON")
}
// P0-02: 测试writeAuthError在Forbidden状态下也写入响应体
func TestP0_02_writeAuthError_ForbiddenWritesBody(t *testing.T) {
// arrange
rec := httptest.NewRecorder()
// act
writeAuthError(rec, http.StatusForbidden, "AUTH_SCOPE_DENIED", "required scope is not granted")
// assert
body := rec.Body.String()
assert.NotEmpty(t, body, "response body should not be empty for Forbidden status")
assert.Contains(t, body, "AUTH_SCOPE_DENIED")
assert.Contains(t, body, "required scope is not granted")
}
// HIGH-01: CheckScope空scope应该拒绝访问而不应该绕过权限检查
func TestHIGH01_CheckScope_EmptyScopeShouldDenyAccess(t *testing.T) {
// arrange
claims := &IAMTokenClaims{
SubjectID: "user:high01",
Role: "viewer",
Scope: []string{"platform:read"},
TenantID: 1,
}
ctx := WithIAMClaims(context.Background(), claims)
// act - 空scope要求应该拒绝访问安全修复
hasEmptyScope := CheckScope(ctx, "")
// assert - 空scope应该返回false拒绝访问
assert.False(t, hasEmptyScope, "empty scope should DENY access (security fix)")
}
// MED-01: RequireAnyScope当requiredScopes为空时应该拒绝访问
func TestMED01_RequireAnyScope_EmptyScopesShouldDenyAccess(t *testing.T) {
// arrange
scopeAuth := NewScopeAuthMiddleware()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// 传入空的requiredScopes
wrappedHandler := scopeAuth.RequireAnyScope([]string{})(handler)
claims := &IAMTokenClaims{
SubjectID: "user:med01",
Role: "viewer",
Scope: []string{"platform:read"},
TenantID: 1,
}
req := httptest.NewRequest("GET", "/test", nil)
req = req.WithContext(WithIAMClaims(req.Context(), claims))
// act
rec := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rec, req)
// assert - 空scope列表应该拒绝访问安全修复
assert.Equal(t, http.StatusForbidden, rec.Code, "empty required scopes should DENY access (security fix)")
}