fix(identity): 完善统一认证配对恢复与安全退役
修复 credentials_saved 状态无法恢复、配对与激活并发冲突,以及 SSF 和身份 Secret 生命周期不完整的问题。新增持久化协调器、取消与清理状态机、事务级并发门禁、受控 SSF 凭据交接、禁用后的延迟 Secret 清理,并对生产环境统一认证及 Discovery 端点强制 HTTPS。 验证:go test ./...;go test -race ./internal/auth ./internal/identity ./internal/identityruntime ./internal/securityevents ./internal/httpapi ./internal/store -count=1;go vet ./...;真实 PostgreSQL 并发及清理成功/冲突回滚测试;pnpm openapi。
This commit is contained in:
@@ -3,8 +3,10 @@ package identityruntime
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -24,10 +26,23 @@ type RuntimeBuilderConfig struct {
|
||||
}
|
||||
|
||||
type preparedSecurityRuntime struct {
|
||||
manager *securityevents.ConnectionManager
|
||||
cancel context.CancelFunc
|
||||
manager *securityevents.ConnectionManager
|
||||
cancel context.CancelFunc
|
||||
receiverReady bool
|
||||
blockedCategory string
|
||||
}
|
||||
|
||||
type safeRuntimeError struct {
|
||||
category string
|
||||
cause error
|
||||
}
|
||||
|
||||
func (err safeRuntimeError) Error() string {
|
||||
return "identity runtime operation failed: " + err.category
|
||||
}
|
||||
func (err safeRuntimeError) Unwrap() error { return err.cause }
|
||||
func (err safeRuntimeError) SafeErrorCategory() string { return err.category }
|
||||
|
||||
type RuntimeBuilder struct {
|
||||
ctx context.Context
|
||||
store *store.Store
|
||||
@@ -54,7 +69,36 @@ func NewRuntimeBuilder(ctx context.Context, data *store.Store, secrets PairingSe
|
||||
return &RuntimeBuilder{ctx: ctx, store: data, secrets: secrets, config: config, metrics: metrics, prepared: map[string]preparedSecurityRuntime{}}
|
||||
}
|
||||
|
||||
func (builder *RuntimeBuilder) PreparedSecurityEventReceiver() http.Handler {
|
||||
builder.mutex.Lock()
|
||||
defer builder.mutex.Unlock()
|
||||
if len(builder.prepared) != 1 {
|
||||
return nil
|
||||
}
|
||||
for _, prepared := range builder.prepared {
|
||||
if prepared.receiverReady {
|
||||
return prepared.manager
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (builder *RuntimeBuilder) PreparedSecurityEventManager() *securityevents.ConnectionManager {
|
||||
builder.mutex.Lock()
|
||||
defer builder.mutex.Unlock()
|
||||
if len(builder.prepared) != 1 {
|
||||
return nil
|
||||
}
|
||||
for _, prepared := range builder.prepared {
|
||||
return prepared.manager
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revision) (*Runtime, error) {
|
||||
if err := identity.ValidateRevisionURLs(revision, builder.config.AppEnv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if builder.store == nil || builder.secrets == nil || revision.Issuer == "" || revision.TenantID == "" ||
|
||||
revision.Audience == "" || revision.RolePrefix == "" {
|
||||
return nil, errors.New("identity runtime configuration is incomplete")
|
||||
@@ -69,23 +113,28 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi
|
||||
|
||||
var securityManager *securityevents.ConnectionManager
|
||||
if revision.SessionRevocation {
|
||||
prepared := builder.takePreparedSecurityRuntime(revision.ID)
|
||||
prepared := builder.preparedSecurityRuntime(revision.ID)
|
||||
securityManager = prepared.manager
|
||||
if prepared.cancel != nil {
|
||||
runtime.close = func() {
|
||||
if securityManager != nil {
|
||||
if err := securityManager.ValidateConfiguredConnectionBinding(ctx); err != nil {
|
||||
cancel()
|
||||
prepared.cancel()
|
||||
return nil, err
|
||||
}
|
||||
if !prepared.receiverReady {
|
||||
cancel()
|
||||
return nil, safeRuntimeError{category: "security_event_not_ready", cause: store.ErrSecurityEventConnectionConflict}
|
||||
}
|
||||
}
|
||||
if securityManager == nil {
|
||||
var err error
|
||||
securityManager, err = builder.newSecurityEventManager(runtimeCtx, revision)
|
||||
securityManager, err = builder.newSecurityEventManager(runtimeCtx, revision, true)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
runtime.SecurityEvents = securityManager
|
||||
runtime.securityEventDisconnector = securityManager
|
||||
}
|
||||
|
||||
var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error)
|
||||
@@ -100,6 +149,7 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi
|
||||
return revision.MachineClientID, secret, err
|
||||
}
|
||||
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
|
||||
AppEnv: builder.config.AppEnv,
|
||||
Issuer: revision.Issuer, Audience: revision.Audience, TenantID: revision.TenantID,
|
||||
RolePrefix: revision.RolePrefix, RequiredScopes: append([]string(nil), revision.Scopes...),
|
||||
JWKSCacheTTL: builder.config.JWKSCacheTTL, IntrospectionEnabled: revision.TokenIntrospection,
|
||||
@@ -134,6 +184,7 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi
|
||||
return nil, err
|
||||
}
|
||||
client, err := auth.NewOIDCPublicClient(auth.OIDCPublicClientConfig{
|
||||
AppEnv: builder.config.AppEnv,
|
||||
Issuer: revision.Issuer, ClientID: revision.BrowserClientID,
|
||||
RedirectURI: revision.PublicBaseURL + "/api/v1/auth/oidc/callback",
|
||||
PostLogoutRedirectURI: revision.WebBaseURL + "/", Scopes: append([]string{"openid", "profile"}, revision.Scopes...),
|
||||
@@ -161,46 +212,205 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi
|
||||
}
|
||||
|
||||
func (builder *RuntimeBuilder) PrepareSecurityEvents(ctx context.Context, revision identity.Revision, managementSecret []byte) error {
|
||||
if !revision.SessionRevocation || revision.SecurityEventIssuer == "" || revision.MachineClientID == "" {
|
||||
if err := identity.ValidateRevisionURLs(revision, builder.config.AppEnv); err != nil {
|
||||
return safeRuntimeError{category: "configuration_invalid", cause: err}
|
||||
}
|
||||
if !revision.SessionRevocation || revision.SecurityEventIssuer == "" || revision.SecurityEventAudience == "" || revision.MachineClientID == "" {
|
||||
return errors.New("security event configuration is incomplete")
|
||||
}
|
||||
if prepared := builder.preparedSecurityRuntime(revision.ID); prepared.manager != nil && prepared.blockedCategory != "" {
|
||||
view, err := prepared.manager.Get(ctx)
|
||||
if err == nil {
|
||||
category := prepared.blockedCategory
|
||||
if view.LifecycleStatus == "retiring" || view.LifecycleStatus == "disconnect_pending" {
|
||||
category = "retirement_pending"
|
||||
}
|
||||
return safeRuntimeError{category: category, cause: store.ErrSecurityEventConnectionConflict}
|
||||
}
|
||||
if !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
||||
return safeSecurityEventRuntimeError(err)
|
||||
}
|
||||
builder.removePreparedSecurityRuntime(revision.ID, prepared)
|
||||
}
|
||||
runtimeCtx, cancel := context.WithCancel(builder.ctx)
|
||||
manager, err := builder.newSecurityEventManager(runtimeCtx, revision)
|
||||
manager, err := builder.newSecurityEventManager(runtimeCtx, revision, false)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
return safeRuntimeError{category: "configuration_invalid", cause: err}
|
||||
}
|
||||
secretCopy := append([]byte(nil), managementSecret...)
|
||||
_, err = manager.Connect(ctx, revision.SecurityEventIssuer, revision.MachineClientID, secretCopy, "identity-pairing-ssf-"+revision.ID)
|
||||
clear(secretCopy)
|
||||
if err != nil {
|
||||
// A conflicting persisted connection is itself the resource an
|
||||
// administrator must safely retire. Keep this manager reachable by the
|
||||
// recovery API even when there is no Active identity Runtime yet.
|
||||
if errors.Is(err, store.ErrSecurityEventConnectionConflict) {
|
||||
builder.replacePreparedSecurityRuntime(revision.ID, preparedSecurityRuntime{
|
||||
manager: manager, cancel: cancel, blockedCategory: safeSecurityEventCategory(err, "connection_conflict"),
|
||||
})
|
||||
return safeSecurityEventRuntimeError(err)
|
||||
}
|
||||
cancel()
|
||||
return err
|
||||
return safeSecurityEventRuntimeError(err)
|
||||
}
|
||||
if err := manager.ValidateConfiguredConnectionBinding(ctx); err != nil {
|
||||
builder.replacePreparedSecurityRuntime(revision.ID, preparedSecurityRuntime{
|
||||
manager: manager, cancel: cancel, blockedCategory: safeSecurityEventCategory(err, "connection_binding_mismatch"),
|
||||
})
|
||||
return safeSecurityEventRuntimeError(err)
|
||||
}
|
||||
builder.replacePreparedSecurityRuntime(revision.ID, preparedSecurityRuntime{manager: manager, cancel: cancel, receiverReady: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (builder *RuntimeBuilder) removePreparedSecurityRuntime(revisionID string, prepared preparedSecurityRuntime) {
|
||||
builder.mutex.Lock()
|
||||
previous := builder.prepared[revision.ID]
|
||||
builder.prepared[revision.ID] = preparedSecurityRuntime{manager: manager, cancel: cancel}
|
||||
current := builder.prepared[revisionID]
|
||||
if current.manager == prepared.manager {
|
||||
delete(builder.prepared, revisionID)
|
||||
}
|
||||
builder.mutex.Unlock()
|
||||
if prepared.cancel != nil {
|
||||
prepared.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (builder *RuntimeBuilder) replacePreparedSecurityRuntime(revisionID string, replacement preparedSecurityRuntime) {
|
||||
builder.mutex.Lock()
|
||||
previous := builder.prepared[revisionID]
|
||||
builder.prepared[revisionID] = replacement
|
||||
builder.mutex.Unlock()
|
||||
if previous.cancel != nil {
|
||||
previous.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (builder *RuntimeBuilder) CleanupPreparedSecurityEvents(ctx context.Context, revision identity.Revision) error {
|
||||
builder.mutex.Lock()
|
||||
prepared, exists := builder.prepared[revision.ID]
|
||||
builder.mutex.Unlock()
|
||||
if !exists {
|
||||
runtimeCtx, cancel := context.WithCancel(builder.ctx)
|
||||
manager, err := builder.newSecurityEventManager(runtimeCtx, revision, false)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return safeRuntimeError{category: "configuration_invalid", cause: err}
|
||||
}
|
||||
prepared = preparedSecurityRuntime{manager: manager, cancel: cancel}
|
||||
}
|
||||
err := prepared.manager.DiscardPreparedConnection(ctx, "identity-pairing-ssf-"+revision.ID)
|
||||
if err != nil {
|
||||
builder.mutex.Lock()
|
||||
if _, alreadyStored := builder.prepared[revision.ID]; !alreadyStored {
|
||||
builder.prepared[revision.ID] = prepared
|
||||
}
|
||||
builder.mutex.Unlock()
|
||||
return safeSecurityEventRuntimeError(err)
|
||||
}
|
||||
builder.mutex.Lock()
|
||||
current := builder.prepared[revision.ID]
|
||||
if current.manager == prepared.manager {
|
||||
delete(builder.prepared, revision.ID)
|
||||
}
|
||||
builder.mutex.Unlock()
|
||||
if prepared.cancel != nil {
|
||||
prepared.cancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (builder *RuntimeBuilder) newSecurityEventManager(ctx context.Context, revision identity.Revision) (*securityevents.ConnectionManager, error) {
|
||||
return securityevents.NewConnectionManager(ctx, builder.store, builder.secrets, securityevents.ConnectionManagerConfig{
|
||||
AppEnv: builder.config.AppEnv, OIDCEnabled: true, OIDCIssuer: revision.Issuer, OIDCTenantID: revision.TenantID,
|
||||
ManagementClientID: revision.MachineClientID, PublicBaseURL: revision.PublicBaseURL,
|
||||
HeartbeatInterval: builder.config.HeartbeatInterval, StaleAfter: builder.config.StaleAfter, ClockSkew: builder.config.ClockSkew,
|
||||
}, builder.metrics)
|
||||
func (builder *RuntimeBuilder) RetireConflictingSecurityEvents(ctx context.Context, revision identity.Revision) error {
|
||||
prepared := builder.preparedSecurityRuntime(revision.ID)
|
||||
if prepared.manager == nil {
|
||||
runtimeCtx, cancel := context.WithCancel(builder.ctx)
|
||||
manager, err := builder.newSecurityEventManager(runtimeCtx, revision, false)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return safeRuntimeError{category: "configuration_invalid", cause: err}
|
||||
}
|
||||
prepared = preparedSecurityRuntime{manager: manager, cancel: cancel}
|
||||
builder.replacePreparedSecurityRuntime(revision.ID, prepared)
|
||||
}
|
||||
if err := prepared.manager.RetireConflictingConnection(ctx, "identity-pairing-ssf-"+revision.ID); err != nil {
|
||||
return safeSecurityEventRuntimeError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (builder *RuntimeBuilder) takePreparedSecurityRuntime(revisionID string) preparedSecurityRuntime {
|
||||
// RecoverSecurityEventDisconnector reconstructs only the SSF management
|
||||
// surface needed to disable a fail-closed Active Revision. It deliberately
|
||||
// avoids OIDC discovery, JIT, BFF Session and tenant Runtime construction.
|
||||
func (builder *RuntimeBuilder) RecoverSecurityEventDisconnector(_ context.Context, revision identity.Revision) (SecurityEventDisconnector, error) {
|
||||
prepared := builder.preparedSecurityRuntime(revision.ID)
|
||||
if prepared.manager != nil {
|
||||
if err := prepared.manager.ValidateConfiguredConnectionBinding(builder.ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prepared.manager, nil
|
||||
}
|
||||
manager, err := builder.newSecurityEventManager(builder.ctx, revision, true)
|
||||
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// AdoptPreparedSecurityEvents transfers the prepared manager lifetime to an
|
||||
// activated Runtime. Validation only peeks at prepared state, so Verification
|
||||
// callbacks remain available between validation and activation.
|
||||
func (builder *RuntimeBuilder) AdoptPreparedSecurityEvents(revisionID string) context.CancelFunc {
|
||||
builder.mutex.Lock()
|
||||
defer builder.mutex.Unlock()
|
||||
prepared := builder.prepared[revisionID]
|
||||
delete(builder.prepared, revisionID)
|
||||
return prepared
|
||||
return prepared.cancel
|
||||
}
|
||||
|
||||
func safeSecurityEventRuntimeError(err error) error {
|
||||
var categorized interface{ SafeErrorCategory() string }
|
||||
if errors.As(err, &categorized) {
|
||||
return err
|
||||
}
|
||||
if errors.Is(err, store.ErrSecurityEventConnectionConflict) {
|
||||
return safeRuntimeError{category: "connection_conflict", cause: err}
|
||||
}
|
||||
return safeRuntimeError{category: "preparation_failed", cause: err}
|
||||
}
|
||||
|
||||
func safeSecurityEventCategory(err error, fallback string) string {
|
||||
var categorized interface{ SafeErrorCategory() string }
|
||||
if errors.As(err, &categorized) && categorized.SafeErrorCategory() != "" {
|
||||
return categorized.SafeErrorCategory()
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (builder *RuntimeBuilder) newSecurityEventManager(ctx context.Context, revision identity.Revision, strictRevisionBinding bool) (*securityevents.ConnectionManager, error) {
|
||||
return securityevents.NewConnectionManager(ctx, builder.store, builder.secrets, builder.securityEventManagerConfig(revision, strictRevisionBinding), builder.metrics)
|
||||
}
|
||||
|
||||
func (builder *RuntimeBuilder) securityEventManagerConfig(revision identity.Revision, strictRevisionBinding bool) securityevents.ConnectionManagerConfig {
|
||||
return securityevents.ConnectionManagerConfig{
|
||||
AppEnv: builder.config.AppEnv, OIDCEnabled: true, OIDCIssuer: revision.Issuer, OIDCTenantID: revision.TenantID,
|
||||
ManagementClientID: revision.MachineClientID, PublicBaseURL: revision.PublicBaseURL,
|
||||
ExpectedTransmitterIssuer: strings.TrimRight(strings.TrimSpace(revision.SecurityEventIssuer), "/"),
|
||||
ExpectedAudience: revision.SecurityEventAudience,
|
||||
ExpectedOwnerKey: "identity-pairing-ssf-" + revision.ID,
|
||||
StrictRevisionBinding: strictRevisionBinding,
|
||||
HeartbeatInterval: builder.config.HeartbeatInterval,
|
||||
StaleAfter: builder.config.StaleAfter,
|
||||
ClockSkew: builder.config.ClockSkew,
|
||||
}
|
||||
}
|
||||
|
||||
func (builder *RuntimeBuilder) preparedSecurityRuntime(revisionID string) preparedSecurityRuntime {
|
||||
builder.mutex.Lock()
|
||||
defer builder.mutex.Unlock()
|
||||
return builder.prepared[revisionID]
|
||||
}
|
||||
|
||||
func secureCookieFor(baseURL string) bool {
|
||||
|
||||
Reference in New Issue
Block a user