feat(ssf): 实现安全事件一键连接与凭据托管

新增数据库驱动的 SecurityEventConnectionManager,复用 RFC 7662 机器 Client,自动完成 Discovery、Push Bearer 生成、Stream 创建、Verification、首次启用、零停机轮换和安全退役。

增加文件与 Kubernetes SecretStore、最小权限 RBAC、动态 Receiver/撤销水位/内省降级,以及系统设置管理页面。已通过全量 Go 测试、go vet、关键包竞态测试、27 个 Web 测试、类型检查、生产构建、Compose 和 Kubernetes dry-run。
This commit is contained in:
2026-07-15 17:25:00 +08:00
parent f30aaeb2d4
commit 9efeb16fd1
31 changed files with 2738 additions and 227 deletions
+10 -4
View File
@@ -48,6 +48,7 @@ type OIDCSecurityEventIdentity struct {
}
type OIDCSecurityEventEvaluation struct {
Enabled bool
Revoked bool
RequireIntrospection bool
}
@@ -91,7 +92,7 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
return nil, errors.New("issuer, audience, tenant and role prefix are required")
}
if (config.IntrospectionEnabled || config.SecurityEventEvaluator != nil) && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
if config.IntrospectionEnabled && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
return nil, errors.New("introspection client credentials are required when introspection is enabled")
}
if config.JWKSCacheTTL <= 0 {
@@ -145,9 +146,6 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
return nil, oidcUnauthorized("nbf is missing", nil)
}
issuedAt, hasIssuedAt := numericDateClaim(claims["iat"])
if v.config.SecurityEventEvaluator != nil && !hasIssuedAt {
return nil, oidcUnauthorized("iat is required when security events are enabled", nil)
}
scopes := scopeClaims(claims)
if !containsAll(scopes, v.config.RequiredScopes) {
return nil, oidcUnauthorized("required scope is missing", nil)
@@ -166,6 +164,9 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
if evaluateErr != nil {
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
}
if evaluation.Enabled && !hasIssuedAt {
return nil, oidcUnauthorized("iat is required when security events are enabled", nil)
}
if evaluation.Revoked {
return nil, oidcUnauthorized("token was issued before the revocation watermark", nil)
}
@@ -177,6 +178,11 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
if !active {
return nil, oidcUnauthorized("token is inactive", nil)
}
} else if !evaluation.Enabled && v.config.IntrospectionEnabled {
active, introspectionErr := v.introspect(ctx, raw)
if introspectionErr != nil || !active {
return nil, oidcUnauthorized("token is inactive", introspectionErr)
}
}
} else if v.config.IntrospectionEnabled {
active, err := v.introspect(ctx, raw)
+16 -1
View File
@@ -179,9 +179,10 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
verifier, err := NewOIDCVerifier(OIDCConfig{
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
IntrospectionEnabled: true,
IntrospectionClientID: "gateway-introspection", IntrospectionClientSecret: "introspection-secret",
SecurityEventEvaluator: func(_ context.Context, identity OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error) {
if identity.Subject != "platform-subject" || identity.IssuedAt.IsZero() {
if identity.Subject != "platform-subject" {
t.Fatal("security event evaluator received incomplete identity")
}
return evaluation, nil
@@ -194,6 +195,10 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 {
t.Fatalf("fallback token error=%v calls=%d", err, introspectionCalls)
}
evaluation = OIDCSecurityEventEvaluation{Enabled: true}
if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 {
t.Fatalf("healthy push token error=%v calls=%d", err, introspectionCalls)
}
evaluation = OIDCSecurityEventEvaluation{Revoked: true}
if _, err := verifier.Verify(context.Background(), raw); err == nil || introspectionCalls != 1 {
t.Fatalf("revoked token error=%v calls=%d", err, introspectionCalls)
@@ -205,6 +210,16 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
if !errors.As(err, &requestError) || requestError.Status != http.StatusServiceUnavailable {
t.Fatalf("introspection outage error=%T %v", err, err)
}
withoutIssuedAt := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) { delete(claims, "iat") })
evaluation = OIDCSecurityEventEvaluation{}
introspectionAvailable = true
if _, err := verifier.Verify(context.Background(), withoutIssuedAt); err != nil {
t.Fatalf("unconfigured security events unexpectedly required iat: %v", err)
}
evaluation = OIDCSecurityEventEvaluation{Enabled: true}
if _, err := verifier.Verify(context.Background(), withoutIssuedAt); err == nil {
t.Fatal("configured security events accepted a token without iat")
}
}
func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string {