feat(identity): 实现统一认证运行时热切换

从 Active Revision 构造并验证 OIDC、BFF Session、Introspection 与 SSF Runtime,在数据库激活成功后原子替换内存引用。请求链路使用不可变快照,失败保留当前运行时,本地管理登录不受影响。\n\n验证:go test ./apps/api/...;go vet ./apps/api/...
This commit is contained in:
2026-07-17 12:05:00 +08:00
parent 96bbd3a2f6
commit a9e23cb237
14 changed files with 797 additions and 152 deletions
+35 -71
View File
@@ -11,6 +11,8 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identityruntime"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
@@ -31,6 +33,8 @@ type Server struct {
geminiUploadSessions sync.Map
securityEventReceiver http.Handler
securityEventManager *ssfreceiver.ConnectionManager
identityRuntime *identityruntime.Manager
identityPairing *identity.PairingService
}
type oidcPublicClient interface {
@@ -63,83 +67,43 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
runner: runner.New(cfg, db, logger),
logger: logger,
}
server.auth.LegacyJWTEnabled = !cfg.OIDCEnabled || cfg.OIDCAcceptLegacyHS256
server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey
server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret
securityEventMetrics := &ssfreceiver.Metrics{}
if cfg.OIDCEnabled {
secretStore, err := securityEventSecretStore(cfg)
if err != nil {
panic("invalid OIDC security event secret store: " + err.Error())
}
manager, err := ssfreceiver.NewConnectionManager(ctx, db, secretStore, ssfreceiver.ConnectionManagerConfig{
AppEnv: cfg.AppEnv, OIDCEnabled: cfg.OIDCEnabled, OIDCIssuer: cfg.OIDCIssuer, OIDCTenantID: cfg.OIDCTenantID,
ManagementClientID: cfg.OIDCIntrospectionClientID, ManagementClientSecret: cfg.OIDCIntrospectionClientSecret,
PublicBaseURL: cfg.PublicBaseURL,
HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second,
StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second,
ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second,
}, securityEventMetrics)
if err != nil {
panic("initialize OIDC security event connection manager: " + err.Error())
}
server.securityEventManager = manager
server.securityEventReceiver = manager
secretStore, err := securityEventSecretStore(cfg)
if err != nil {
panic("invalid identity SecretStore: " + err.Error())
}
if cfg.OIDCEnabled {
var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error)
if server.securityEventManager != nil {
evaluator = server.securityEventManager.Evaluate
runtimeBuilder := identityruntime.NewRuntimeBuilder(ctx, db, secretStore, identityruntime.RuntimeBuilderConfig{
AppEnv: cfg.AppEnv, JWKSCacheTTL: 5 * time.Minute,
HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second,
StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second,
ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second,
}, securityEventMetrics)
server.identityRuntime = identityruntime.NewManager(db, runtimeBuilder)
if err := server.identityRuntime.LoadActive(ctx); err != nil && logger != nil {
logger.Error("load active identity runtime failed; local management login remains available", "error_category", "identity_runtime_load_failed")
}
server.identityPairing = identity.NewPairingService(db, secretStore, func(baseURL string) (identity.OnboardingRemote, error) {
return identity.NewOnboardingClient(baseURL, nil)
}, server.identityRuntime)
server.auth.OIDCVerifierProvider = func() *auth.OIDCVerifier {
if runtime := server.identityRuntime.Current(); runtime != nil {
return runtime.Verifier
}
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID,
RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes,
JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second,
IntrospectionEnabled: cfg.OIDCIntrospectionEnabled,
IntrospectionClientID: cfg.OIDCIntrospectionClientID,
IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret,
IntrospectionCredentialProvider: server.securityEventManager.IntrospectionCredential,
SecurityEventEvaluator: evaluator,
IntrospectionObserver: securityEventMetrics.ObserveIntrospection,
JWKSRefreshFailureObserver: func() { securityEventMetrics.ObserveJWKSRefreshFailure("oidc") },
})
if err != nil {
panic("invalid OIDC configuration: " + err.Error())
}
server.auth.OIDCVerifier = verifier
if cfg.OIDCBrowserSessionEnabled {
key, err := cfg.OIDCSessionEncryptionKeyBytes()
if err != nil {
panic("invalid OIDC session configuration: " + err.Error())
}
cipher, err := oidcsession.NewCipher(key)
if err != nil {
panic("invalid OIDC session configuration: " + err.Error())
}
client, err := auth.NewOIDCPublicClient(auth.OIDCPublicClientConfig{
Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, RedirectURI: cfg.OIDCRedirectURI,
PostLogoutRedirectURI: cfg.OIDCPostLogoutRedirectURI,
Scopes: append([]string{"openid", "profile"}, cfg.OIDCRequiredScopes...),
})
if err != nil {
panic("invalid OIDC public client configuration: " + err.Error())
}
sessions, err := oidcsession.NewService(db, cipher, verifier, client, oidcsession.Config{
IdleTTL: time.Duration(cfg.OIDCSessionIdleTTLSeconds) * time.Second,
AbsoluteTTL: time.Duration(cfg.OIDCSessionAbsoluteTTLSeconds) * time.Second,
RefreshBefore: time.Duration(cfg.OIDCSessionRefreshBeforeSeconds) * time.Second,
})
if err != nil {
panic("invalid OIDC session configuration: " + err.Error())
}
server.oidcClient = client
server.oidcSessions = sessions
server.oidcSessionCipher = cipher
server.auth.OIDCSessionResolver = func(ctx context.Context, sessionID string) (*auth.User, error) {
user, resolveErr := sessions.Resolve(ctx, sessionID)
return user, oidcSessionRequestError(resolveErr)
}
return nil
}
server.auth.OIDCSessionResolverProvider = func(ctx context.Context, sessionID string) (*auth.User, error) {
runtime := server.identityRuntime.Current()
if runtime == nil || runtime.Sessions == nil {
return nil, auth.ErrUnauthorized
}
user, resolveErr := runtime.Sessions.Resolve(ctx, sessionID)
return user, oidcSessionRequestError(resolveErr)
}
server.auth.LegacyJWTEnabledProvider = func() bool {
runtime := server.identityRuntime.Current()
return runtime == nil || runtime.Revision.LegacyJWTEnabled
}
server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey
server.runner.StartAsyncQueueWorker(ctx)