feat(ssf): 托管机器凭据并支持动态恢复
Gateway 连接表单接收认证中心一次性交付的 machine Client 凭据,后端立即写入 SecretStore,数据库和公开响应只保留引用及公开 Client ID。SSF 管理 Token、Verification 和 RFC 7662 内省统一从动态凭据读取,支持现有连接无重启修复及进程重启恢复,环境变量仅保留为旧部署回退。\n\n验证:go test ./apps/api/...;pnpm nx test web;真实重启、OIDC 登录与 session-revoked 1.29 秒失效验收。
This commit is contained in:
parent
8e33d1d33e
commit
ffb85b73af
@ -33,6 +33,8 @@ OIDC_REQUIRED_SCOPES=gateway.access
|
||||
OIDC_JWKS_CACHE_TTL_SECONDS=300
|
||||
OIDC_ACCEPT_LEGACY_HS256=true
|
||||
OIDC_INTROSPECTION_ENABLED=false
|
||||
# Legacy/static fallback only. New SSF connections receive the machine
|
||||
# credential once in the web flow and persist it in the configured SecretStore.
|
||||
OIDC_INTROSPECTION_CLIENT_ID=
|
||||
OIDC_INTROSPECTION_CLIENT_SECRET=
|
||||
# SSF/CAEP is activated by the durable connection created in System Settings;
|
||||
|
||||
@ -25,19 +25,20 @@ const maxOIDCResponseBytes = 1 << 20
|
||||
const defaultOIDCHTTPTimeout = 10 * time.Second
|
||||
|
||||
type OIDCConfig struct {
|
||||
Issuer string
|
||||
Audience string
|
||||
TenantID string
|
||||
RolePrefix string
|
||||
RequiredScopes []string
|
||||
JWKSCacheTTL time.Duration
|
||||
IntrospectionEnabled bool
|
||||
IntrospectionClientID string
|
||||
IntrospectionClientSecret string
|
||||
HTTPClient *http.Client
|
||||
SecurityEventEvaluator func(context.Context, OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error)
|
||||
IntrospectionObserver func(string)
|
||||
JWKSRefreshFailureObserver func()
|
||||
Issuer string
|
||||
Audience string
|
||||
TenantID string
|
||||
RolePrefix string
|
||||
RequiredScopes []string
|
||||
JWKSCacheTTL time.Duration
|
||||
IntrospectionEnabled bool
|
||||
IntrospectionClientID string
|
||||
IntrospectionClientSecret string
|
||||
IntrospectionCredentialProvider func(context.Context) (string, []byte, error)
|
||||
HTTPClient *http.Client
|
||||
SecurityEventEvaluator func(context.Context, OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error)
|
||||
IntrospectionObserver func(string)
|
||||
JWKSRefreshFailureObserver func()
|
||||
}
|
||||
|
||||
type OIDCSecurityEventIdentity struct {
|
||||
@ -92,7 +93,8 @@ 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 && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
|
||||
if config.IntrospectionEnabled && config.IntrospectionCredentialProvider == nil &&
|
||||
(strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
|
||||
return nil, errors.New("introspection client credentials are required when introspection is enabled")
|
||||
}
|
||||
if config.JWKSCacheTTL <= 0 {
|
||||
@ -295,7 +297,19 @@ func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.SetBasicAuth(v.config.IntrospectionClientID, v.config.IntrospectionClientSecret)
|
||||
clientID := v.config.IntrospectionClientID
|
||||
secret := []byte(v.config.IntrospectionClientSecret)
|
||||
if v.config.IntrospectionCredentialProvider != nil {
|
||||
clientID, secret, err = v.config.IntrospectionCredentialProvider(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
defer clear(secret)
|
||||
if strings.TrimSpace(clientID) == "" || len(secret) < 16 {
|
||||
return false, errors.New("OIDC introspection credential is unavailable")
|
||||
}
|
||||
request.SetBasicAuth(clientID, string(secret))
|
||||
response, err := v.client.Do(request)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
@ -138,8 +138,10 @@ func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing
|
||||
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-api",
|
||||
IntrospectionClientSecret: "introspection-secret",
|
||||
IntrospectionEnabled: true,
|
||||
IntrospectionCredentialProvider: func(context.Context) (string, []byte, error) {
|
||||
return "gateway-api", []byte("introspection-secret"), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@ -16,7 +16,9 @@ import (
|
||||
)
|
||||
|
||||
type securityEventConnectionRequest struct {
|
||||
TransmitterIssuer string `json:"transmitter_issuer"`
|
||||
TransmitterIssuer string `json:"transmitter_issuer"`
|
||||
ManagementClientID string `json:"management_client_id"`
|
||||
ManagementClientSecret string `json:"management_client_secret"`
|
||||
}
|
||||
|
||||
type securityEventConnectionResponse struct {
|
||||
@ -65,14 +67,22 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
request.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(request.TransmitterIssuer), "/")
|
||||
requestHash := securityEventOperationHash("connect", request.TransmitterIssuer)
|
||||
request.ManagementClientID = strings.TrimSpace(request.ManagementClientID)
|
||||
if (request.ManagementClientID == "") != (request.ManagementClientSecret == "") || len(request.ManagementClientID) > 200 || len(request.ManagementClientSecret) > 512 {
|
||||
writeError(w, http.StatusBadRequest, "machine Client ID and Secret must be provided together", "invalid_machine_credential")
|
||||
return
|
||||
}
|
||||
secret := []byte(request.ManagementClientSecret)
|
||||
defer clear(secret)
|
||||
secretDigest := sha256.Sum256(secret)
|
||||
requestHash := securityEventOperationHash("connect", request.TransmitterIssuer+"\x00"+request.ManagementClientID+"\x00"+fmt.Sprintf("%x", secretDigest[:]))
|
||||
if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) {
|
||||
return
|
||||
}
|
||||
if current, err := s.securityEventManager.Get(r.Context()); err == nil && !matchConnectionVersion(w, r, current.Version) {
|
||||
return
|
||||
}
|
||||
connection, err := s.securityEventManager.Connect(r.Context(), request.TransmitterIssuer, idempotencyKey)
|
||||
connection, err := s.securityEventManager.Connect(r.Context(), request.TransmitterIssuer, request.ManagementClientID, secret, idempotencyKey)
|
||||
if err != nil {
|
||||
s.writeSecurityEventConnectionError(w, r, "connect", traceID, err)
|
||||
return
|
||||
@ -128,6 +138,7 @@ func (s *Server) securityEventPrerequisites() map[string]any {
|
||||
"introspectionClientConfigured": s.cfg.OIDCIntrospectionClientID != "" && s.cfg.OIDCIntrospectionClientSecret != "",
|
||||
"publicBaseUrlConfigured": s.cfg.PublicBaseURL != "",
|
||||
"managementClientId": s.cfg.OIDCIntrospectionClientID,
|
||||
"credentialInputSupported": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -94,13 +94,14 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
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,
|
||||
SecurityEventEvaluator: evaluator,
|
||||
IntrospectionObserver: securityEventMetrics.ObserveIntrospection,
|
||||
JWKSRefreshFailureObserver: func() { securityEventMetrics.ObserveJWKSRefreshFailure("oidc") },
|
||||
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())
|
||||
|
||||
@ -34,6 +34,7 @@ type ConnectionRepository interface {
|
||||
CreateSecurityEventConnection(context.Context, store.CreateSecurityEventConnectionInput) (store.SecurityEventConnection, error)
|
||||
SecurityEventConnection(context.Context) (store.SecurityEventConnection, error)
|
||||
BindSecurityEventConnection(context.Context, string, string, string, string, int64) (store.SecurityEventConnection, error)
|
||||
SetSecurityEventManagementCredential(context.Context, string, string, string) (store.SecurityEventConnection, error)
|
||||
UpdateSecurityEventConnectionLifecycle(context.Context, string, string, *string) (store.SecurityEventConnection, error)
|
||||
SetSecurityEventNextCredential(context.Context, string, string) (store.SecurityEventConnection, error)
|
||||
PromoteSecurityEventCredential(context.Context, string, string) (store.SecurityEventConnection, error)
|
||||
@ -181,51 +182,114 @@ func (m *ConnectionManager) Get(ctx context.Context) (ConnectionView, error) {
|
||||
return m.view(connection), nil
|
||||
}
|
||||
|
||||
func (m *ConnectionManager) Connect(ctx context.Context, issuer, idempotencyKey string) (ConnectionView, error) {
|
||||
func (m *ConnectionManager) IntrospectionCredential(ctx context.Context) (string, []byte, error) {
|
||||
connection, err := m.repository.SecurityEventConnection(ctx)
|
||||
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
||||
if m.config.ManagementClientID != "" && m.config.ManagementClientSecret != "" {
|
||||
return m.config.ManagementClientID, []byte(m.config.ManagementClientSecret), nil
|
||||
}
|
||||
return "", nil, errors.New("managed machine credential is unavailable")
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return m.managementCredential(ctx, connection)
|
||||
}
|
||||
|
||||
func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClientID string, managementSecret []byte, idempotencyKey string) (ConnectionView, error) {
|
||||
m.operation.Lock()
|
||||
defer m.operation.Unlock()
|
||||
issuer = strings.TrimRight(strings.TrimSpace(issuer), "/")
|
||||
managementClientID = strings.TrimSpace(managementClientID)
|
||||
if idempotencyKey == "" {
|
||||
return ConnectionView{}, errors.New("idempotency key is required")
|
||||
}
|
||||
if err := m.validatePrerequisites(issuer); err != nil {
|
||||
existing, err := m.repository.SecurityEventConnection(ctx)
|
||||
if err != nil && !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
||||
return ConnectionView{}, err
|
||||
}
|
||||
credentialsProvided := managementClientID != "" || len(managementSecret) > 0
|
||||
if (managementClientID == "") != (len(managementSecret) == 0) {
|
||||
return ConnectionView{}, errors.New("machine client id and secret must be provided together")
|
||||
}
|
||||
if !credentialsProvided {
|
||||
if err == nil {
|
||||
managementClientID, managementSecret, _ = m.managementCredential(ctx, existing)
|
||||
} else {
|
||||
managementClientID = strings.TrimSpace(m.config.ManagementClientID)
|
||||
managementSecret = []byte(m.config.ManagementClientSecret)
|
||||
}
|
||||
}
|
||||
defer clear(managementSecret)
|
||||
if err := m.validatePrerequisites(issuer, managementClientID, managementSecret, errors.Is(err, store.ErrSecurityEventConnectionNotFound)); err != nil {
|
||||
return ConnectionView{}, err
|
||||
}
|
||||
existing, err := m.repository.SecurityEventConnection(ctx)
|
||||
if err == nil {
|
||||
if existing.TransmitterIssuer != issuer {
|
||||
return ConnectionView{}, store.ErrSecurityEventConnectionConflict
|
||||
}
|
||||
if credentialsProvided {
|
||||
existing, err = m.persistManagementCredential(ctx, existing, managementClientID, managementSecret)
|
||||
if err != nil {
|
||||
return ConnectionView{}, err
|
||||
}
|
||||
}
|
||||
if existing.StreamID != nil && !resumableConnectionLifecycle(existing.LifecycleStatus) {
|
||||
if existing.Audience != nil {
|
||||
if err := m.activate(ctx, existing); err != nil {
|
||||
return ConnectionView{}, err
|
||||
}
|
||||
}
|
||||
return m.view(existing), nil
|
||||
}
|
||||
return m.resumeConnecting(ctx, existing)
|
||||
}
|
||||
if !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
||||
return ConnectionView{}, err
|
||||
}
|
||||
connectionID := uuid.NewString()
|
||||
reference := "ssf-push-" + connectionID
|
||||
managementReference := "ssf-management-" + connectionID
|
||||
if err := m.secrets.Put(ctx, managementReference, managementSecret); err != nil {
|
||||
return ConnectionView{}, err
|
||||
}
|
||||
secret, err := randomPushBearer()
|
||||
if err != nil {
|
||||
_ = m.secrets.Delete(ctx, managementReference)
|
||||
return ConnectionView{}, err
|
||||
}
|
||||
defer clear(secret)
|
||||
if err := m.secrets.Put(ctx, reference, secret); err != nil {
|
||||
_ = m.secrets.Delete(ctx, managementReference)
|
||||
return ConnectionView{}, err
|
||||
}
|
||||
endpoint := strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf"
|
||||
connection, err := m.repository.CreateSecurityEventConnection(ctx, store.CreateSecurityEventConnectionInput{
|
||||
ConnectionID: connectionID, TransmitterIssuer: issuer, EndpointURL: endpoint,
|
||||
CredentialRef: reference, IdempotencyKey: idempotencyKey,
|
||||
CredentialRef: reference, ManagementClientID: managementClientID,
|
||||
ManagementCredentialRef: managementReference, IdempotencyKey: idempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
_ = m.secrets.Delete(ctx, reference)
|
||||
_ = m.secrets.Delete(ctx, managementReference)
|
||||
return ConnectionView{}, err
|
||||
}
|
||||
return m.resumeConnecting(ctx, connection)
|
||||
}
|
||||
|
||||
func (m *ConnectionManager) persistManagementCredential(ctx context.Context, connection store.SecurityEventConnection, clientID string, secret []byte) (store.SecurityEventConnection, error) {
|
||||
reference := "ssf-management-" + connection.ConnectionID + "-" + uuid.NewString()
|
||||
if err := m.secrets.Put(ctx, reference, secret); err != nil {
|
||||
return store.SecurityEventConnection{}, err
|
||||
}
|
||||
updated, err := m.repository.SetSecurityEventManagementCredential(ctx, connection.ConnectionID, clientID, reference)
|
||||
if err != nil {
|
||||
_ = m.secrets.Delete(ctx, reference)
|
||||
return store.SecurityEventConnection{}, err
|
||||
}
|
||||
if connection.ManagementCredentialRef != nil {
|
||||
_ = m.secrets.Delete(ctx, *connection.ManagementCredentialRef)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func resumableConnectionLifecycle(status string) bool {
|
||||
switch status {
|
||||
case "connecting", "verifying", "degraded", "error":
|
||||
@ -484,6 +548,11 @@ func (m *ConnectionManager) activate(ctx context.Context, connection store.Secur
|
||||
return err
|
||||
}
|
||||
defer clear(current)
|
||||
managementClientID, managementSecret, err := m.managementCredential(ctx, connection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clear(managementSecret)
|
||||
var next []byte
|
||||
if connection.NextCredentialRef != nil {
|
||||
next, err = m.secrets.Get(ctx, *connection.NextCredentialRef)
|
||||
@ -517,7 +586,7 @@ func (m *ConnectionManager) activate(ctx context.Context, connection store.Secur
|
||||
service, err := NewService(m.repository, ServiceConfig{
|
||||
Issuer: connection.TransmitterIssuer, Audience: *connection.Audience, StreamID: *connection.StreamID,
|
||||
ManagementTokenURL: strings.TrimRight(m.config.OIDCIssuer, "/") + "/token",
|
||||
ManagementClientID: m.config.ManagementClientID, ManagementSecret: m.config.ManagementClientSecret,
|
||||
ManagementClientID: managementClientID, ManagementSecret: string(managementSecret),
|
||||
HeartbeatInterval: m.config.HeartbeatInterval, StaleAfter: m.config.StaleAfter,
|
||||
HTTPClient: safeClient,
|
||||
})
|
||||
@ -656,13 +725,16 @@ func (m *ConnectionManager) finishRetirement(connection store.SecurityEventConne
|
||||
}
|
||||
m.stopRuntime()
|
||||
_ = m.secrets.Delete(m.ctx, connection.CredentialRef)
|
||||
if connection.ManagementCredentialRef != nil {
|
||||
_ = m.secrets.Delete(m.ctx, *connection.ManagementCredentialRef)
|
||||
}
|
||||
if connection.NextCredentialRef != nil {
|
||||
_ = m.secrets.Delete(m.ctx, *connection.NextCredentialRef)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ConnectionManager) validatePrerequisites(issuer string) error {
|
||||
if !m.config.OIDCEnabled || m.config.ManagementClientID == "" || m.config.ManagementClientSecret == "" {
|
||||
func (m *ConnectionManager) validatePrerequisites(issuer, managementClientID string, managementSecret []byte, requirePublicBaseURL bool) error {
|
||||
if !m.config.OIDCEnabled || strings.TrimSpace(managementClientID) == "" || len(managementSecret) < 16 {
|
||||
return errors.New("OIDC and RFC 7662 machine client must be configured")
|
||||
}
|
||||
if _, err := uuid.Parse(m.config.OIDCTenantID); err != nil {
|
||||
@ -671,6 +743,9 @@ func (m *ConnectionManager) validatePrerequisites(issuer string) error {
|
||||
if _, err := validatedIssuerURL(issuer, m.config.AppEnv); err != nil {
|
||||
return err
|
||||
}
|
||||
if !requirePublicBaseURL {
|
||||
return nil
|
||||
}
|
||||
endpoint, err := url.Parse(strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf")
|
||||
if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" {
|
||||
return errors.New("AI_GATEWAY_PUBLIC_BASE_URL is invalid")
|
||||
@ -710,12 +785,21 @@ func (m *ConnectionManager) discover(ctx context.Context, issuer string) (transm
|
||||
}
|
||||
|
||||
func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Client, scopes string) (string, error) {
|
||||
connection, err := m.repository.SecurityEventConnection(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
managementClientID, managementSecret, err := m.managementCredential(ctx, connection)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer clear(managementSecret)
|
||||
form := url.Values{"grant_type": {"client_credentials"}, "scope": {scopes}}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(m.config.OIDCIssuer, "/")+"/token", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request.SetBasicAuth(m.config.ManagementClientID, m.config.ManagementClientSecret)
|
||||
request.SetBasicAuth(managementClientID, string(managementSecret))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
@ -736,6 +820,24 @@ func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Cl
|
||||
return payload.AccessToken, nil
|
||||
}
|
||||
|
||||
func (m *ConnectionManager) managementCredential(ctx context.Context, connection store.SecurityEventConnection) (string, []byte, error) {
|
||||
if connection.ManagementCredentialRef != nil && connection.ManagementClientID != "" {
|
||||
secret, err := m.secrets.Get(ctx, *connection.ManagementCredentialRef)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(secret) < 16 {
|
||||
clear(secret)
|
||||
return "", nil, errors.New("managed machine credential is invalid")
|
||||
}
|
||||
return connection.ManagementClientID, secret, nil
|
||||
}
|
||||
if m.config.ManagementClientID != "" && m.config.ManagementClientSecret != "" {
|
||||
return m.config.ManagementClientID, []byte(m.config.ManagementClientSecret), nil
|
||||
}
|
||||
return "", nil, errors.New("managed machine credential is unavailable")
|
||||
}
|
||||
|
||||
func (m *ConnectionManager) findStream(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, description, endpoint string) (streamConfiguration, error) {
|
||||
var streams []streamConfiguration
|
||||
if err := requestJSON(ctx, client, http.MethodGet, configuration.ConfigurationEndpoint, token, nil, &streams); err != nil {
|
||||
@ -789,11 +891,18 @@ func (m *ConnectionManager) view(connection store.SecurityEventConnection) Conne
|
||||
ConnectionID: connection.ConnectionID, TransmitterIssuer: connection.TransmitterIssuer,
|
||||
ReceiverEndpoint: connection.EndpointURL, Audience: connection.Audience, StreamID: connection.StreamID,
|
||||
LifecycleStatus: connection.LifecycleStatus, HealthMode: connection.HealthMode,
|
||||
ManagementClientID: m.config.ManagementClientID, LastVerificationAt: connection.LastVerificationAt,
|
||||
ManagementClientID: managementClientIDForView(connection.ManagementClientID, m.config.ManagementClientID), LastVerificationAt: connection.LastVerificationAt,
|
||||
LastErrorCategory: connection.LastErrorCategory, Version: connection.Version,
|
||||
}
|
||||
}
|
||||
|
||||
func managementClientIDForView(managed, fallback string) string {
|
||||
if strings.TrimSpace(managed) != "" {
|
||||
return managed
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func validateCreatedStream(stream streamConfiguration, issuer, endpoint string) error {
|
||||
if _, err := uuid.Parse(stream.StreamID); err != nil || stream.Issuer != issuer || stream.Audience == "" ||
|
||||
stream.Delivery.Method != pushDeliveryMethod || stream.Delivery.EndpointURL != endpoint ||
|
||||
|
||||
@ -66,11 +66,21 @@ func (r *memoryConnectionRepository) CreateSecurityEventConnection(_ context.Con
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
value := store.SecurityEventConnection{ConnectionID: input.ConnectionID, TransmitterIssuer: input.TransmitterIssuer,
|
||||
EndpointURL: input.EndpointURL, CredentialRef: input.CredentialRef, LifecycleStatus: "connecting", Version: 1,
|
||||
EndpointURL: input.EndpointURL, CredentialRef: input.CredentialRef, ManagementClientID: input.ManagementClientID,
|
||||
ManagementCredentialRef: &input.ManagementCredentialRef, LifecycleStatus: "connecting", Version: 1,
|
||||
IdempotencyKey: input.IdempotencyKey, CreatedAt: now, UpdatedAt: now, HealthMode: "disabled"}
|
||||
r.connection = &value
|
||||
return value, nil
|
||||
}
|
||||
func (r *memoryConnectionRepository) SetSecurityEventManagementCredential(_ context.Context, id, clientID, reference string) (store.SecurityEventConnection, error) {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
if r.connection == nil || r.connection.ConnectionID != id {
|
||||
return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionNotFound
|
||||
}
|
||||
r.connection.ManagementClientID, r.connection.ManagementCredentialRef = clientID, &reference
|
||||
return *r.connection, nil
|
||||
}
|
||||
func (r *memoryConnectionRepository) SecurityEventConnection(context.Context) (store.SecurityEventConnection, error) {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
@ -183,6 +193,20 @@ func TestConnectionLifecycleCanResumeOnlyIncompleteOrFailedConnections(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingConnectionCredentialRepairDoesNotRequirePublicBaseURL(t *testing.T) {
|
||||
manager := &ConnectionManager{config: ConnectionManagerConfig{
|
||||
AppEnv: "test", OIDCEnabled: true, OIDCTenantID: uuid.NewString(),
|
||||
}}
|
||||
secret := []byte("machine-secret-for-existing-connection")
|
||||
defer clear(secret)
|
||||
if err := manager.validatePrerequisites("https://auth.example/ssf", "gateway-machine", secret, false); err != nil {
|
||||
t.Fatalf("existing connection credential repair unexpectedly required a public base URL: %v", err)
|
||||
}
|
||||
if err := manager.validatePrerequisites("https://auth.example/ssf", "gateway-machine", secret, true); err == nil {
|
||||
t.Fatal("a new connection must still require AI_GATEWAY_PUBLIC_BASE_URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransmitterSSRFProtectionBlocksReservedNetworks(t *testing.T) {
|
||||
for _, address := range []string{
|
||||
"127.0.0.1", "10.0.0.1", "169.254.169.254", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "2001:db8::1",
|
||||
@ -296,7 +320,7 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi
|
||||
})
|
||||
case "/oidc/token":
|
||||
clientID, secret, ok := r.BasicAuth()
|
||||
if !ok || clientID != "gateway-machine" || secret != "machine-secret" {
|
||||
if !ok || clientID != "gateway-machine" || secret != "machine-secret-for-test" {
|
||||
t.Fatal("RFC 7662 machine client was not reused")
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
@ -339,7 +363,7 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi
|
||||
defer transmitter.Close()
|
||||
manager, err := NewConnectionManager(ctx, repository, secrets, ConnectionManagerConfig{
|
||||
AppEnv: "test", OIDCEnabled: true, OIDCIssuer: transmitter.URL + "/oidc", OIDCTenantID: uuid.NewString(),
|
||||
ManagementClientID: "gateway-machine", ManagementClientSecret: "machine-secret",
|
||||
ManagementClientID: "gateway-machine", ManagementClientSecret: "machine-secret-for-test",
|
||||
PublicBaseURL: transmitter.URL, HeartbeatInterval: time.Hour, StaleAfter: 2 * time.Hour, HTTPClient: transmitter.Client(),
|
||||
}, &Metrics{})
|
||||
if err != nil {
|
||||
@ -349,7 +373,7 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi
|
||||
if err != nil || evaluation.Enabled {
|
||||
t.Fatalf("unconfigured manager evaluation=%#v err=%v", evaluation, err)
|
||||
}
|
||||
view, err := manager.Connect(ctx, transmitter.URL+"/ssf", "connect-once")
|
||||
view, err := manager.Connect(ctx, transmitter.URL+"/ssf", "", nil, "connect-once")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -357,12 +381,18 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi
|
||||
t.Fatalf("connection view=%#v", view)
|
||||
}
|
||||
encoded, _ := json.Marshal(view)
|
||||
if strings.Contains(string(encoded), "credential") || strings.Contains(string(encoded), "machine-secret") || strings.Contains(string(encoded), "ssf-push-") {
|
||||
if strings.Contains(string(encoded), "credential") || strings.Contains(string(encoded), "machine-secret-for-test") || strings.Contains(string(encoded), "ssf-push-") {
|
||||
t.Fatalf("secret metadata escaped in response: %s", encoded)
|
||||
}
|
||||
if len(secrets.values) != 1 {
|
||||
if len(secrets.values) != 2 {
|
||||
t.Fatalf("secret count=%d", len(secrets.values))
|
||||
}
|
||||
restarted := &ConnectionManager{repository: repository, secrets: secrets, config: ConnectionManagerConfig{}}
|
||||
restartedClientID, restartedSecret, err := restarted.IntrospectionCredential(ctx)
|
||||
if err != nil || restartedClientID != "gateway-machine" || string(restartedSecret) != "machine-secret-for-test" {
|
||||
t.Fatalf("persisted management credential was not recoverable after restart: client=%q secret_present=%v err=%v", restartedClientID, len(restartedSecret) > 0, err)
|
||||
}
|
||||
clear(restartedSecret)
|
||||
if !managementScopeRequested.Load() {
|
||||
t.Fatal("management scopes were not requested automatically")
|
||||
}
|
||||
|
||||
@ -16,25 +16,27 @@ var (
|
||||
)
|
||||
|
||||
type SecurityEventConnection struct {
|
||||
ConnectionID string `json:"connectionId"`
|
||||
TransmitterIssuer string `json:"transmitterIssuer"`
|
||||
EndpointURL string `json:"endpointUrl"`
|
||||
Audience *string `json:"audience,omitempty"`
|
||||
StreamID *string `json:"streamId,omitempty"`
|
||||
CredentialRef string `json:"-"`
|
||||
NextCredentialRef *string `json:"-"`
|
||||
LifecycleStatus string `json:"lifecycleStatus"`
|
||||
Version int64 `json:"version"`
|
||||
LastErrorCategory *string `json:"lastErrorCategory,omitempty"`
|
||||
IdempotencyKey string `json:"-"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"`
|
||||
HealthMode string `json:"healthMode"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
TransmitterIssuer string `json:"transmitterIssuer"`
|
||||
EndpointURL string `json:"endpointUrl"`
|
||||
Audience *string `json:"audience,omitempty"`
|
||||
StreamID *string `json:"streamId,omitempty"`
|
||||
CredentialRef string `json:"-"`
|
||||
NextCredentialRef *string `json:"-"`
|
||||
ManagementClientID string `json:"managementClientId"`
|
||||
ManagementCredentialRef *string `json:"-"`
|
||||
LifecycleStatus string `json:"lifecycleStatus"`
|
||||
Version int64 `json:"version"`
|
||||
LastErrorCategory *string `json:"lastErrorCategory,omitempty"`
|
||||
IdempotencyKey string `json:"-"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"`
|
||||
HealthMode string `json:"healthMode"`
|
||||
}
|
||||
|
||||
type CreateSecurityEventConnectionInput struct {
|
||||
ConnectionID, TransmitterIssuer, EndpointURL, CredentialRef, IdempotencyKey string
|
||||
ConnectionID, TransmitterIssuer, EndpointURL, CredentialRef, ManagementClientID, ManagementCredentialRef, IdempotencyKey string
|
||||
}
|
||||
|
||||
type SecurityEventConnectionIdempotency struct {
|
||||
@ -65,13 +67,14 @@ func (s *Store) CreateSecurityEventConnection(ctx context.Context, input CreateS
|
||||
var value SecurityEventConnection
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO gateway_security_event_connections(
|
||||
connection_id,transmitter_issuer,endpoint_url,credential_ref,lifecycle_status,idempotency_key
|
||||
) VALUES($1::uuid,$2,$3,$4,'connecting',$5)
|
||||
connection_id,transmitter_issuer,endpoint_url,credential_ref,management_client_id,management_credential_ref,lifecycle_status,idempotency_key
|
||||
) VALUES($1::uuid,$2,$3,$4,$5,$6,'connecting',$7)
|
||||
RETURNING connection_id::text,transmitter_issuer,endpoint_url,audience,stream_id::text,credential_ref,
|
||||
next_credential_ref,lifecycle_status,version,last_error_category,idempotency_key,created_at,updated_at`,
|
||||
input.ConnectionID, input.TransmitterIssuer, input.EndpointURL, input.CredentialRef, input.IdempotencyKey,
|
||||
next_credential_ref,management_client_id,management_credential_ref,lifecycle_status,version,last_error_category,idempotency_key,created_at,updated_at`,
|
||||
input.ConnectionID, input.TransmitterIssuer, input.EndpointURL, input.CredentialRef,
|
||||
input.ManagementClientID, input.ManagementCredentialRef, input.IdempotencyKey,
|
||||
).Scan(&value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID,
|
||||
&value.CredentialRef, &value.NextCredentialRef, &value.LifecycleStatus, &value.Version,
|
||||
&value.CredentialRef, &value.NextCredentialRef, &value.ManagementClientID, &value.ManagementCredentialRef, &value.LifecycleStatus, &value.Version,
|
||||
&value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
@ -90,7 +93,8 @@ func (s *Store) SecurityEventConnection(ctx context.Context) (SecurityEventConne
|
||||
var value SecurityEventConnection
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT connection.connection_id::text,connection.transmitter_issuer,connection.endpoint_url,connection.audience,
|
||||
connection.stream_id::text,connection.credential_ref,connection.next_credential_ref,connection.lifecycle_status,
|
||||
connection.stream_id::text,connection.credential_ref,connection.next_credential_ref,
|
||||
COALESCE(connection.management_client_id,''),connection.management_credential_ref,connection.lifecycle_status,
|
||||
connection.version,connection.last_error_category,connection.idempotency_key,connection.created_at,connection.updated_at,
|
||||
state.last_verification_at,COALESCE(state.mode,'disabled')
|
||||
FROM gateway_security_event_connections connection
|
||||
@ -98,7 +102,8 @@ LEFT JOIN gateway_security_event_stream_state state
|
||||
ON state.issuer=connection.transmitter_issuer AND state.audience=connection.audience
|
||||
WHERE connection.singleton=true`).Scan(
|
||||
&value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID,
|
||||
&value.CredentialRef, &value.NextCredentialRef, &value.LifecycleStatus, &value.Version,
|
||||
&value.CredentialRef, &value.NextCredentialRef, &value.ManagementClientID, &value.ManagementCredentialRef,
|
||||
&value.LifecycleStatus, &value.Version,
|
||||
&value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt,
|
||||
&value.LastVerificationAt, &value.HealthMode,
|
||||
)
|
||||
@ -121,6 +126,19 @@ WHERE connection_id=$1::uuid AND version=$5`, connectionID, audience, streamID,
|
||||
return s.SecurityEventConnection(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) SetSecurityEventManagementCredential(ctx context.Context, connectionID, clientID, reference string) (SecurityEventConnection, error) {
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
|
||||
SET management_client_id=$2,management_credential_ref=$3,last_error_category=NULL,updated_at=now()
|
||||
WHERE connection_id=$1::uuid`, connectionID, clientID, reference)
|
||||
if err != nil {
|
||||
return SecurityEventConnection{}, err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound
|
||||
}
|
||||
return s.SecurityEventConnection(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSecurityEventConnectionLifecycle(ctx context.Context, connectionID, lifecycle string, errorCategory *string) (SecurityEventConnection, error) {
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
|
||||
SET lifecycle_status=$2,last_error_category=$3,version=version+1,updated_at=now() WHERE connection_id=$1::uuid`, connectionID, lifecycle, errorCategory)
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
ALTER TABLE gateway_security_event_connections
|
||||
ADD COLUMN IF NOT EXISTS management_client_id text,
|
||||
ADD COLUMN IF NOT EXISTS management_credential_ref text;
|
||||
|
||||
ALTER TABLE gateway_security_event_connections
|
||||
ADD CONSTRAINT gateway_security_event_management_credential_pair CHECK (
|
||||
(management_client_id IS NULL AND management_credential_ref IS NULL) OR
|
||||
(management_client_id IS NOT NULL AND management_credential_ref IS NOT NULL)
|
||||
);
|
||||
@ -1029,12 +1029,12 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function connectSecurityEvents(transmitterIssuer: string) {
|
||||
async function connectSecurityEvents(transmitterIssuer: string, managementClientId = '', managementClientSecret = '') {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const version = securityEventConnection?.connection?.version;
|
||||
const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, version);
|
||||
const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, managementClientId, managementClientSecret, version);
|
||||
setSecurityEventConnection(connection);
|
||||
setCoreState('ready');
|
||||
setCoreMessage('认证中心安全事件连接正在验证。');
|
||||
|
||||
@ -40,7 +40,7 @@ describe('security event connection transport', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('sends only the public transmitter issuer and concurrency headers', async () => {
|
||||
it('sends the one-time machine credential only in the connection request', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), {
|
||||
status: 202,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@ -48,12 +48,15 @@ describe('security event connection transport', () => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
|
||||
|
||||
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf');
|
||||
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 'gateway-machine', 'one-time-machine-secret');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('PUT');
|
||||
expect(JSON.parse(String(init.body))).toEqual({ transmitter_issuer: 'https://auth.example/ssf' });
|
||||
expect(String(init.body)).not.toMatch(/secret|bearer|scope|credential/i);
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
transmitter_issuer: 'https://auth.example/ssf',
|
||||
management_client_id: 'gateway-machine',
|
||||
management_client_secret: 'one-time-machine-secret',
|
||||
});
|
||||
expect(new Headers(init.headers).get('Idempotency-Key')).toContain('ssf-connect-');
|
||||
expect(new Headers(init.headers).has('If-Match')).toBe(false);
|
||||
});
|
||||
@ -66,7 +69,7 @@ describe('security event connection transport', () => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
|
||||
|
||||
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 7);
|
||||
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 'gateway-machine', 'one-time-machine-secret', 7);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"');
|
||||
|
||||
@ -1007,11 +1007,21 @@ export async function getSecurityEventConnection(token: string): Promise<Securit
|
||||
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, { token });
|
||||
}
|
||||
|
||||
export async function connectSecurityEventTransmitter(token: string, transmitterIssuer: string, version?: number): Promise<SecurityEventConnectionResponse> {
|
||||
export async function connectSecurityEventTransmitter(
|
||||
token: string,
|
||||
transmitterIssuer: string,
|
||||
managementClientId: string,
|
||||
managementClientSecret: string,
|
||||
version?: number,
|
||||
): Promise<SecurityEventConnectionResponse> {
|
||||
const headers: Record<string, string> = { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` };
|
||||
if (version !== undefined) headers['If-Match'] = `W/"${version}"`;
|
||||
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, {
|
||||
body: { transmitter_issuer: transmitterIssuer }, method: 'PUT', token,
|
||||
body: {
|
||||
transmitter_issuer: transmitterIssuer,
|
||||
management_client_id: managementClientId,
|
||||
management_client_secret: managementClientSecret,
|
||||
}, method: 'PUT', token,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
@ -64,7 +64,7 @@ export function SystemSettingsPanel(props: {
|
||||
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
|
||||
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
|
||||
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
|
||||
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
|
||||
onConnectSecurityEvents: (transmitterIssuer: string, managementClientId?: string, managementClientSecret?: string) => Promise<void>;
|
||||
onDisconnectSecurityEvents: () => Promise<void>;
|
||||
onRefreshSecurityEvents: () => Promise<void>;
|
||||
onRotateSecurityEventsCredential: () => Promise<void>;
|
||||
@ -411,7 +411,7 @@ function SecurityEventConnectionPanel(props: {
|
||||
issuer: string;
|
||||
loading: boolean;
|
||||
onIssuerChange(value: string): void;
|
||||
onConnect(issuer: string): Promise<void>;
|
||||
onConnect(issuer: string, managementClientId?: string, managementClientSecret?: string): Promise<void>;
|
||||
onDisconnect(): void;
|
||||
onRefresh(): Promise<void>;
|
||||
onRotate(): Promise<void>;
|
||||
@ -419,9 +419,13 @@ function SecurityEventConnectionPanel(props: {
|
||||
}) {
|
||||
const connection = props.connection?.connection;
|
||||
const prerequisites = props.connection?.prerequisites;
|
||||
const [managementClientId, setManagementClientId] = useState('');
|
||||
const [managementClientSecret, setManagementClientSecret] = useState('');
|
||||
useEffect(() => {
|
||||
if (!managementClientId && prerequisites?.managementClientId) setManagementClientId(prerequisites.managementClientId);
|
||||
}, [managementClientId, prerequisites?.managementClientId]);
|
||||
const missing = [
|
||||
!prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '',
|
||||
!prerequisites?.introspectionClientConfigured ? '配置 RFC 7662 机器 Client ID / Secret' : '',
|
||||
!prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '',
|
||||
].filter(Boolean);
|
||||
return (
|
||||
@ -446,15 +450,22 @@ function SecurityEventConnectionPanel(props: {
|
||||
<p className="mutedText">请先在认证中心 Application 的“安全事件流”中完成“准备 Gateway 接入”。</p>
|
||||
</div>
|
||||
{missing.length > 0 && <div className="formMessage">{missing.join(';')}</div>}
|
||||
<div className="fileStorageMeta">
|
||||
<span>复用机器 Client: {prerequisites?.managementClientId || '未配置'}</span>
|
||||
</div>
|
||||
<Label>
|
||||
SSF Transmitter Issuer
|
||||
<Input value={props.issuer} onChange={(event) => props.onIssuerChange(event.target.value)} placeholder="https://auth.51easyai.com/ssf" />
|
||||
<small>页面只提交公开 Issuer;Endpoint、Scope、Push Bearer 和 Stream 由后端自动处理。</small>
|
||||
<small>Endpoint、Scope、Push Bearer 和 Stream 由后端自动处理。</small>
|
||||
</Label>
|
||||
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim()} onClick={() => void props.onConnect(props.issuer.trim())}>
|
||||
<Label>
|
||||
Machine Client ID
|
||||
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
|
||||
</Label>
|
||||
<Label>
|
||||
Machine Client Secret
|
||||
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
|
||||
<small>浏览器只负责本次传递;Gateway 后端会立即写入 SecretStore,数据库、响应和日志均不保存明文。</small>
|
||||
</Label>
|
||||
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim() || !managementClientId.trim() || !managementClientSecret}
|
||||
onClick={() => void props.onConnect(props.issuer.trim(), managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
|
||||
<Link2 size={15} />连接认证中心
|
||||
</Button>
|
||||
</CardContent>
|
||||
@ -475,6 +486,21 @@ function SecurityEventConnectionPanel(props: {
|
||||
{props.connection?.auditId && <span>最近操作 Audit ID: {props.connection.auditId}</span>}
|
||||
{connection.lastErrorCategory && <span>最近错误: {connection.lastErrorCategory}</span>}
|
||||
</div>
|
||||
{(connection.lastErrorCategory === 'credential_unavailable' || connection.lastErrorCategory === 'management_token_failed') && <div className="pageStack">
|
||||
<div className="formMessage">认证中心机器凭据不可用。请在认证中心重新生成一次性连接凭据,并在这里更新;无需断开 Stream。</div>
|
||||
<Label>
|
||||
Machine Client ID
|
||||
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
|
||||
</Label>
|
||||
<Label>
|
||||
Machine Client Secret
|
||||
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
|
||||
</Label>
|
||||
<Button type="button" disabled={props.loading || !managementClientId.trim() || !managementClientSecret}
|
||||
onClick={() => void props.onConnect(connection.transmitterIssuer, managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
|
||||
<Link2 size={15} />保存凭据并恢复连接
|
||||
</Button>
|
||||
</div>}
|
||||
<div className="fileStorageToolbar">
|
||||
{connection.lifecycleStatus === 'error' && (
|
||||
<Button type="button" size="sm" disabled={props.loading} onClick={() => void props.onConnect(connection.transmitterIssuer)}><Link2 size={14} />重试连接</Button>
|
||||
|
||||
@ -7,17 +7,18 @@ Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Securit
|
||||
用户只执行两项操作:
|
||||
|
||||
1. 在认证中心 Application 的“安全事件流”页面选择现有 RFC 7662 机器 Client,点击“准备 Gateway 接入”。认证中心自动合并 SSF 管理 Scope;不会创建第二个 Client。
|
||||
2. 在 Gateway“系统设置 → 认证中心安全事件”中填写认证中心公开 SSF Issuer,点击“连接认证中心”。
|
||||
2. Receiver 就绪后点击“生成一次性连接凭据”,立即复制 machine Client ID/Secret。
|
||||
3. 在 Gateway“系统设置 → 认证中心安全事件”中填写 SSF Issuer 和这组一次性凭据,点击“连接认证中心”。
|
||||
|
||||
Gateway 后端随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream,并进入 360 秒 RFC 7662 bootstrap。浏览器看不到 Push Bearer、OAuth Secret 或 Secret 引用,管理员不编辑 Secret 文件,也不需要重启 Gateway。
|
||||
Gateway 后端先把 machine Secret 写入 SecretStore,随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream,并进入 360 秒 RFC 7662 bootstrap。浏览器只在两端连接表单的短暂操作期间接触 machine Secret;Push Bearer 和 Secret 引用始终不可见,数据库、响应和日志不保存明文。管理员不编辑 Secret 文件,也不需要重启 Gateway。
|
||||
|
||||
前置配置只有既有 OIDC/RFC 7662 配置和 Gateway 公共地址:
|
||||
环境前置配置只保留 OIDC 公共参数和 Gateway 公共地址:
|
||||
|
||||
- `OIDC_ISSUER`、`OIDC_TENANT_ID`;
|
||||
- `OIDC_INTROSPECTION_ENABLED=true`;
|
||||
- `OIDC_INTROSPECTION_CLIENT_ID/SECRET`,与认证中心准备 Receiver 时选择的机器 Client 相同;
|
||||
- `AI_GATEWAY_PUBLIC_BASE_URL`,生产必须是认证中心可访问的 HTTPS 地址。
|
||||
|
||||
`OIDC_INTROSPECTION_CLIENT_ID/SECRET` 仅作为旧部署兼容回退,不再是新连接必填项。新连接把 machine 凭据动态托管到 SecretStore,OIDC fallback、SSF 管理 Token 和 Verification 共用这一份凭据,重启后继续生效。
|
||||
|
||||
第一版一个 Gateway 部署只允许一个认证中心连接。下游业务服务无需接入 SSF。
|
||||
|
||||
## SecretStore
|
||||
|
||||
@ -976,6 +976,7 @@ export interface SecurityEventConnectionPrerequisites {
|
||||
introspectionClientConfigured: boolean;
|
||||
publicBaseUrlConfigured: boolean;
|
||||
managementClientId: string;
|
||||
credentialInputSupported: boolean;
|
||||
}
|
||||
|
||||
export interface SecurityEventConnectionResponse {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user