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个原有测试以反映正确的安全行为
This commit is contained in:
@@ -124,7 +124,7 @@ func main() {
|
|||||||
CacheTTL: cfg.Token.RevocationCacheTTL,
|
CacheTTL: cfg.Token.RevocationCacheTTL,
|
||||||
Enabled: *env != "dev", // 开发模式禁用鉴权
|
Enabled: *env != "dev", // 开发模式禁用鉴权
|
||||||
}
|
}
|
||||||
authMiddleware := middleware.NewAuthMiddleware(authConfig, tokenCache, nil)
|
authMiddleware := middleware.NewAuthMiddleware(authConfig, tokenCache, nil, nil)
|
||||||
|
|
||||||
// 初始化幂等中间件
|
// 初始化幂等中间件
|
||||||
idempotencyMiddleware := middleware.NewIdempotencyMiddleware(nil, middleware.IdempotencyConfig{
|
idempotencyMiddleware := middleware.NewIdempotencyMiddleware(nil, middleware.IdempotencyConfig{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"lijiaoqiao/supply-api/internal/middleware"
|
"lijiaoqiao/supply-api/internal/middleware"
|
||||||
@@ -25,19 +26,8 @@ type IAMTokenClaims struct {
|
|||||||
Permissions []string `json:"permissions"` // 细粒度权限列表
|
Permissions []string `json:"permissions"` // 细粒度权限列表
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScopeAuthMiddleware Scope权限验证中间件
|
// 角色层级定义
|
||||||
type ScopeAuthMiddleware struct {
|
var roleHierarchyLevels = map[string]int{
|
||||||
// 路由-Scope映射
|
|
||||||
routeScopePolicies map[string][]string
|
|
||||||
// 角色层级
|
|
||||||
roleHierarchy map[string]int
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewScopeAuthMiddleware 创建Scope权限验证中间件
|
|
||||||
func NewScopeAuthMiddleware() *ScopeAuthMiddleware {
|
|
||||||
return &ScopeAuthMiddleware{
|
|
||||||
routeScopePolicies: make(map[string][]string),
|
|
||||||
roleHierarchy: map[string]int{
|
|
||||||
"super_admin": 100,
|
"super_admin": 100,
|
||||||
"org_admin": 50,
|
"org_admin": 50,
|
||||||
"supply_admin": 40,
|
"supply_admin": 40,
|
||||||
@@ -51,7 +41,21 @@ func NewScopeAuthMiddleware() *ScopeAuthMiddleware {
|
|||||||
"consumer_operator": 30,
|
"consumer_operator": 30,
|
||||||
"consumer_viewer": 10,
|
"consumer_viewer": 10,
|
||||||
"viewer": 10,
|
"viewer": 10,
|
||||||
},
|
}
|
||||||
|
|
||||||
|
// ScopeAuthMiddleware Scope权限验证中间件
|
||||||
|
type ScopeAuthMiddleware struct {
|
||||||
|
// 路由-Scope映射
|
||||||
|
routeScopePolicies map[string][]string
|
||||||
|
// 角色层级(已废弃,使用包级变量roleHierarchyLevels)
|
||||||
|
roleHierarchy map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScopeAuthMiddleware 创建Scope权限验证中间件
|
||||||
|
func NewScopeAuthMiddleware() *ScopeAuthMiddleware {
|
||||||
|
return &ScopeAuthMiddleware{
|
||||||
|
routeScopePolicies: make(map[string][]string),
|
||||||
|
roleHierarchy: roleHierarchyLevels,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +71,9 @@ func CheckScope(ctx context.Context, requiredScope string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空scope直接通过
|
// 空scope应该拒绝访问
|
||||||
if requiredScope == "" {
|
if requiredScope == "" {
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return hasScope(claims.Scope, requiredScope)
|
return hasScope(claims.Scope, requiredScope)
|
||||||
@@ -138,23 +142,7 @@ func HasRoleLevel(ctx context.Context, minLevel int) bool {
|
|||||||
|
|
||||||
// GetRoleLevel 获取角色层级数值
|
// GetRoleLevel 获取角色层级数值
|
||||||
func GetRoleLevel(role string) int {
|
func GetRoleLevel(role string) int {
|
||||||
hierarchy := map[string]int{
|
if level, ok := roleHierarchyLevels[role]; ok {
|
||||||
"super_admin": 100,
|
|
||||||
"org_admin": 50,
|
|
||||||
"supply_admin": 40,
|
|
||||||
"consumer_admin": 40,
|
|
||||||
"operator": 30,
|
|
||||||
"developer": 20,
|
|
||||||
"finops": 20,
|
|
||||||
"supply_operator": 30,
|
|
||||||
"supply_finops": 20,
|
|
||||||
"supply_viewer": 10,
|
|
||||||
"consumer_operator": 30,
|
|
||||||
"consumer_viewer": 10,
|
|
||||||
"viewer": 10,
|
|
||||||
}
|
|
||||||
|
|
||||||
if level, ok := hierarchy[role]; ok {
|
|
||||||
return level
|
return level
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
@@ -162,16 +150,16 @@ func GetRoleLevel(role string) int {
|
|||||||
|
|
||||||
// GetIAMTokenClaims 获取IAM Token Claims
|
// GetIAMTokenClaims 获取IAM Token Claims
|
||||||
func GetIAMTokenClaims(ctx context.Context) *IAMTokenClaims {
|
func GetIAMTokenClaims(ctx context.Context) *IAMTokenClaims {
|
||||||
if claims, ok := ctx.Value(IAMTokenClaimsKey).(IAMTokenClaims); ok {
|
if claims, ok := ctx.Value(IAMTokenClaimsKey).(*IAMTokenClaims); ok {
|
||||||
return &claims
|
return claims
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getIAMTokenClaims 内部获取IAM Token Claims
|
// getIAMTokenClaims 内部获取IAM Token Claims
|
||||||
func getIAMTokenClaims(ctx context.Context) *IAMTokenClaims {
|
func getIAMTokenClaims(ctx context.Context) *IAMTokenClaims {
|
||||||
if claims, ok := ctx.Value(IAMTokenClaimsKey).(IAMTokenClaims); ok {
|
if claims, ok := ctx.Value(IAMTokenClaimsKey).(*IAMTokenClaims); ok {
|
||||||
return &claims
|
return claims
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -247,8 +235,8 @@ func (m *ScopeAuthMiddleware) RequireAnyScope(requiredScopes []string) func(http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空列表直接通过
|
// 空列表应该拒绝访问
|
||||||
if len(requiredScopes) > 0 && !hasAnyScope(claims.Scope, requiredScopes) {
|
if len(requiredScopes) == 0 || !hasAnyScope(claims.Scope, requiredScopes) {
|
||||||
writeAuthError(w, http.StatusForbidden, "AUTH_SCOPE_DENIED",
|
writeAuthError(w, http.StatusForbidden, "AUTH_SCOPE_DENIED",
|
||||||
"none of the required scopes are granted")
|
"none of the required scopes are granted")
|
||||||
return
|
return
|
||||||
@@ -328,12 +316,12 @@ func writeAuthError(w http.ResponseWriter, status int, code, message string) {
|
|||||||
"message": message,
|
"message": message,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
_ = resp
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithIAMClaims 设置IAM Claims到Context
|
// WithIAMClaims 设置IAM Claims到Context
|
||||||
func WithIAMClaims(ctx context.Context, claims *IAMTokenClaims) context.Context {
|
func WithIAMClaims(ctx context.Context, claims *IAMTokenClaims) context.Context {
|
||||||
return context.WithValue(ctx, IAMTokenClaimsKey, *claims)
|
return context.WithValue(ctx, IAMTokenClaimsKey, claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetClaimsFromLegacy 从原有middleware.TokenClaims转换为IAMTokenClaims
|
// GetClaimsFromLegacy 从原有middleware.TokenClaims转换为IAMTokenClaims
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func TestScopeAuth_CheckScope_SuperAdminHasAllScopes(t *testing.T) {
|
|||||||
TenantID: 0,
|
TenantID: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(context.Background(), IAMTokenClaimsKey, *claims)
|
ctx := WithIAMClaims(context.Background(), claims)
|
||||||
|
|
||||||
// act
|
// act
|
||||||
hasScope := CheckScope(ctx, "platform:read")
|
hasScope := CheckScope(ctx, "platform:read")
|
||||||
@@ -44,7 +44,7 @@ func TestScopeAuth_CheckScope_ViewerHasReadOnly(t *testing.T) {
|
|||||||
TenantID: 1,
|
TenantID: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(context.Background(), IAMTokenClaimsKey, *claims)
|
ctx := WithIAMClaims(context.Background(), claims)
|
||||||
|
|
||||||
// act & assert
|
// act & assert
|
||||||
assert.True(t, CheckScope(ctx, "platform:read"), "viewer should have platform:read")
|
assert.True(t, CheckScope(ctx, "platform:read"), "viewer should have platform:read")
|
||||||
@@ -66,7 +66,7 @@ func TestScopeAuth_CheckScope_Denied(t *testing.T) {
|
|||||||
TenantID: 1,
|
TenantID: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(context.Background(), IAMTokenClaimsKey, *claims)
|
ctx := WithIAMClaims(context.Background(), claims)
|
||||||
|
|
||||||
// act & assert
|
// act & assert
|
||||||
assert.False(t, CheckScope(ctx, "platform:write"), "viewer should NOT have platform:write")
|
assert.False(t, CheckScope(ctx, "platform:write"), "viewer should NOT have platform:write")
|
||||||
@@ -95,13 +95,13 @@ func TestScopeAuth_CheckScope_EmptyScope(t *testing.T) {
|
|||||||
TenantID: 1,
|
TenantID: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(context.Background(), IAMTokenClaimsKey, *claims)
|
ctx := WithIAMClaims(context.Background(), claims)
|
||||||
|
|
||||||
// act
|
// act
|
||||||
hasEmptyScope := CheckScope(ctx, "")
|
hasEmptyScope := CheckScope(ctx, "")
|
||||||
|
|
||||||
// assert
|
// assert - 空scope应该拒绝访问(安全修复)
|
||||||
assert.True(t, hasEmptyScope, "empty scope should always pass")
|
assert.False(t, hasEmptyScope, "empty scope should DENY access (security fix)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestScopeAuth_CheckMultipleScopes 测试检查多个Scope(需要全部满足)
|
// TestScopeAuth_CheckMultipleScopes 测试检查多个Scope(需要全部满足)
|
||||||
@@ -114,7 +114,7 @@ func TestScopeAuth_CheckMultipleScopes(t *testing.T) {
|
|||||||
TenantID: 1,
|
TenantID: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(context.Background(), IAMTokenClaimsKey, *claims)
|
ctx := WithIAMClaims(context.Background(), claims)
|
||||||
|
|
||||||
// act & assert
|
// 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{"platform:read", "platform:write"}), "operator should have both read and write")
|
||||||
@@ -132,7 +132,7 @@ func TestScopeAuth_CheckAnyScope(t *testing.T) {
|
|||||||
TenantID: 1,
|
TenantID: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(context.Background(), IAMTokenClaimsKey, *claims)
|
ctx := WithIAMClaims(context.Background(), claims)
|
||||||
|
|
||||||
// act & assert
|
// act & assert
|
||||||
assert.True(t, CheckAnyScope(ctx, []string{"platform:read", "platform:write"}), "should pass with one matching scope")
|
assert.True(t, CheckAnyScope(ctx, []string{"platform:read", "platform:write"}), "should pass with one matching scope")
|
||||||
@@ -150,7 +150,7 @@ func TestScopeAuth_GetIAMTokenClaims(t *testing.T) {
|
|||||||
TenantID: 1,
|
TenantID: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(context.Background(), IAMTokenClaimsKey, *claims)
|
ctx := WithIAMClaims(context.Background(), claims)
|
||||||
|
|
||||||
// act
|
// act
|
||||||
retrievedClaims := GetIAMTokenClaims(ctx)
|
retrievedClaims := GetIAMTokenClaims(ctx)
|
||||||
@@ -184,7 +184,7 @@ func TestScopeAuth_HasRole(t *testing.T) {
|
|||||||
TenantID: 1,
|
TenantID: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(context.Background(), IAMTokenClaimsKey, *claims)
|
ctx := WithIAMClaims(context.Background(), claims)
|
||||||
|
|
||||||
// act & assert
|
// act & assert
|
||||||
assert.True(t, HasRole(ctx, "operator"))
|
assert.True(t, HasRole(ctx, "operator"))
|
||||||
@@ -222,7 +222,7 @@ func TestScopeRoleAuthzMiddleware_WithScope(t *testing.T) {
|
|||||||
TenantID: 1,
|
TenantID: 1,
|
||||||
}
|
}
|
||||||
req := httptest.NewRequest("GET", "/test", nil)
|
req := httptest.NewRequest("GET", "/test", nil)
|
||||||
req = req.WithContext(context.WithValue(req.Context(), IAMTokenClaimsKey, *claims))
|
req = req.WithContext(WithIAMClaims(req.Context(), claims))
|
||||||
|
|
||||||
// act
|
// act
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -250,7 +250,7 @@ func TestScopeRoleAuthzMiddleware_Denied(t *testing.T) {
|
|||||||
TenantID: 1,
|
TenantID: 1,
|
||||||
}
|
}
|
||||||
req := httptest.NewRequest("GET", "/test", nil)
|
req := httptest.NewRequest("GET", "/test", nil)
|
||||||
req = req.WithContext(context.WithValue(req.Context(), IAMTokenClaimsKey, *claims))
|
req = req.WithContext(WithIAMClaims(req.Context(), claims))
|
||||||
|
|
||||||
// act
|
// act
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -300,7 +300,7 @@ func TestScopeRoleAuthzMiddleware_RequireAllScopes(t *testing.T) {
|
|||||||
TenantID: 1,
|
TenantID: 1,
|
||||||
}
|
}
|
||||||
req := httptest.NewRequest("GET", "/test", nil)
|
req := httptest.NewRequest("GET", "/test", nil)
|
||||||
req = req.WithContext(context.WithValue(req.Context(), IAMTokenClaimsKey, *claims))
|
req = req.WithContext(WithIAMClaims(req.Context(), claims))
|
||||||
|
|
||||||
// act
|
// act
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -328,7 +328,7 @@ func TestScopeRoleAuthzMiddleware_RequireAllScopes_Denied(t *testing.T) {
|
|||||||
TenantID: 1,
|
TenantID: 1,
|
||||||
}
|
}
|
||||||
req := httptest.NewRequest("GET", "/test", nil)
|
req := httptest.NewRequest("GET", "/test", nil)
|
||||||
req = req.WithContext(context.WithValue(req.Context(), IAMTokenClaimsKey, *claims))
|
req = req.WithContext(WithIAMClaims(req.Context(), claims))
|
||||||
|
|
||||||
// act
|
// act
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -363,7 +363,7 @@ func TestScopeAuth_HasRoleLevel(t *testing.T) {
|
|||||||
Scope: []string{},
|
Scope: []string{},
|
||||||
TenantID: 1,
|
TenantID: 1,
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(context.Background(), IAMTokenClaimsKey, *claims)
|
ctx := WithIAMClaims(context.Background(), claims)
|
||||||
|
|
||||||
// act
|
// act
|
||||||
result := HasRoleLevel(ctx, tc.minLevel)
|
result := HasRoleLevel(ctx, tc.minLevel)
|
||||||
@@ -437,3 +437,135 @@ func TestGetClaimsFromLegacy(t *testing.T) {
|
|||||||
assert.Equal(t, legacyClaims.Scope, iamClaims.Scope)
|
assert.Equal(t, legacyClaims.Scope, iamClaims.Scope)
|
||||||
assert.Equal(t, legacyClaims.TenantID, iamClaims.TenantID)
|
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)")
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,9 +36,15 @@ type AuthConfig struct {
|
|||||||
type AuthMiddleware struct {
|
type AuthMiddleware struct {
|
||||||
config AuthConfig
|
config AuthConfig
|
||||||
tokenCache *TokenCache
|
tokenCache *TokenCache
|
||||||
|
tokenBackend TokenStatusBackend
|
||||||
auditEmitter AuditEmitter
|
auditEmitter AuditEmitter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TokenStatusBackend Token状态后端查询接口
|
||||||
|
type TokenStatusBackend interface {
|
||||||
|
CheckTokenStatus(ctx context.Context, tokenID string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
// AuditEmitter 审计事件发射器
|
// AuditEmitter 审计事件发射器
|
||||||
type AuditEmitter interface {
|
type AuditEmitter interface {
|
||||||
Emit(ctx context.Context, event AuditEvent) error
|
Emit(ctx context.Context, event AuditEvent) error
|
||||||
@@ -57,13 +63,14 @@ type AuditEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthMiddleware 创建鉴权中间件
|
// NewAuthMiddleware 创建鉴权中间件
|
||||||
func NewAuthMiddleware(config AuthConfig, tokenCache *TokenCache, auditEmitter AuditEmitter) *AuthMiddleware {
|
func NewAuthMiddleware(config AuthConfig, tokenCache *TokenCache, tokenBackend TokenStatusBackend, auditEmitter AuditEmitter) *AuthMiddleware {
|
||||||
if config.CacheTTL == 0 {
|
if config.CacheTTL == 0 {
|
||||||
config.CacheTTL = 30 * time.Second
|
config.CacheTTL = 30 * time.Second
|
||||||
}
|
}
|
||||||
return &AuthMiddleware{
|
return &AuthMiddleware{
|
||||||
config: config,
|
config: config,
|
||||||
tokenCache: tokenCache,
|
tokenCache: tokenCache,
|
||||||
|
tokenBackend: tokenBackend,
|
||||||
auditEmitter: auditEmitter,
|
auditEmitter: auditEmitter,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,7 +305,8 @@ func (m *AuthMiddleware) ScopeRoleAuthzMiddleware(requiredScope string) func(htt
|
|||||||
// verifyToken 校验JWT token
|
// verifyToken 校验JWT token
|
||||||
func (m *AuthMiddleware) verifyToken(tokenString string) (*TokenClaims, error) {
|
func (m *AuthMiddleware) verifyToken(tokenString string) (*TokenClaims, error) {
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
// 严格验证算法:只接受HS256
|
||||||
|
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
}
|
}
|
||||||
return []byte(m.config.SecretKey), nil
|
return []byte(m.config.SecretKey), nil
|
||||||
@@ -339,8 +347,13 @@ func (m *AuthMiddleware) checkTokenStatus(tokenID string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 缓存未命中,返回active(实际应该查询数据库)
|
// 缓存未命中,查询后端验证token状态
|
||||||
return "active", nil
|
if m.tokenBackend != nil {
|
||||||
|
return m.tokenBackend.CheckTokenStatus(context.Background(), tokenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有后端实现时,应该拒绝访问而不是默认active
|
||||||
|
return "", errors.New("token status unknown: backend not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTokenClaims 从context获取token claims
|
// GetTokenClaims 从context获取token claims
|
||||||
|
|||||||
@@ -320,6 +320,107 @@ func TestTokenCache(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HIGH-02: JWT算法验证不严格 - 应该拒绝非HS256的算法
|
||||||
|
func TestHIGH02_JWT_RejectNonHS256Algorithm(t *testing.T) {
|
||||||
|
secretKey := "test-secret-key-12345678901234567890"
|
||||||
|
issuer := "test-issuer"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
signingMethod jwt.SigningMethod
|
||||||
|
expectError bool
|
||||||
|
errorContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "HS256 should be accepted",
|
||||||
|
signingMethod: jwt.SigningMethodHS256,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HS384 should be rejected",
|
||||||
|
signingMethod: jwt.SigningMethodHS384,
|
||||||
|
expectError: true,
|
||||||
|
errorContains: "unexpected signing method",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HS512 should be rejected",
|
||||||
|
signingMethod: jwt.SigningMethodHS512,
|
||||||
|
expectError: true,
|
||||||
|
errorContains: "unexpected signing method",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "none algorithm should be rejected",
|
||||||
|
signingMethod: jwt.SigningMethodNone,
|
||||||
|
expectError: true,
|
||||||
|
errorContains: "malformed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
claims := TokenClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: issuer,
|
||||||
|
Subject: "subject:1",
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
SubjectID: "subject:1",
|
||||||
|
Role: "owner",
|
||||||
|
Scope: []string{"read", "write"},
|
||||||
|
TenantID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(tt.signingMethod, claims)
|
||||||
|
tokenString, _ := token.SignedString([]byte(secretKey))
|
||||||
|
|
||||||
|
middleware := &AuthMiddleware{
|
||||||
|
config: AuthConfig{
|
||||||
|
SecretKey: secretKey,
|
||||||
|
Issuer: issuer,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := middleware.verifyToken(tokenString)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error but got nil")
|
||||||
|
} else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
|
||||||
|
t.Errorf("error = %v, want contains %v", err, tt.errorContains)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MED-02: checkTokenStatus缓存未命中时应该查询后端而不是默认返回active
|
||||||
|
func TestMED02_TokenCacheMiss_ShouldNotAssumeActive(t *testing.T) {
|
||||||
|
// arrange
|
||||||
|
middleware := &AuthMiddleware{
|
||||||
|
config: AuthConfig{
|
||||||
|
SecretKey: "test-secret-key-12345678901234567890",
|
||||||
|
Issuer: "test-issuer",
|
||||||
|
},
|
||||||
|
tokenCache: NewTokenCache(), // 空的缓存
|
||||||
|
// 没有设置tokenBackend
|
||||||
|
}
|
||||||
|
|
||||||
|
// act - 查询一个不在缓存中的token
|
||||||
|
status, err := middleware.checkTokenStatus("nonexistent-token-id")
|
||||||
|
|
||||||
|
// assert - 缓存未命中且没有后端时应该返回错误(安全修复)
|
||||||
|
// 修复前bug:缓存未命中时默认返回"active"
|
||||||
|
// 修复后:缓存未命中且没有后端时返回错误
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("MED-02: cache miss without backend should return error, got status='%s'", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
||||||
func createTestToken(secretKey, issuer, subject, role string, expiresAt time.Time) string {
|
func createTestToken(secretKey, issuer, subject, role string, expiresAt time.Time) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user