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,6 +3,8 @@ package identityruntime
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -27,15 +29,20 @@ type Builder interface {
|
||||
Build(context.Context, identity.Revision) (*Runtime, error)
|
||||
}
|
||||
|
||||
type SecurityEventDisconnector interface {
|
||||
Disconnect(context.Context) (securityevents.ConnectionView, error)
|
||||
}
|
||||
|
||||
type Runtime struct {
|
||||
Revision identity.Revision
|
||||
Verifier *auth.OIDCVerifier
|
||||
PublicClient *auth.OIDCPublicClient
|
||||
Sessions *oidcsession.Service
|
||||
SessionCipher *oidcsession.Cipher
|
||||
SecurityEvents *securityevents.ConnectionManager
|
||||
CookieSecure bool
|
||||
close func()
|
||||
Revision identity.Revision
|
||||
Verifier *auth.OIDCVerifier
|
||||
PublicClient *auth.OIDCPublicClient
|
||||
Sessions *oidcsession.Service
|
||||
SessionCipher *oidcsession.Cipher
|
||||
SecurityEvents *securityevents.ConnectionManager
|
||||
CookieSecure bool
|
||||
close func()
|
||||
securityEventDisconnector SecurityEventDisconnector
|
||||
}
|
||||
|
||||
func (runtime *Runtime) Close() {
|
||||
@@ -45,36 +52,126 @@ func (runtime *Runtime) Close() {
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
repository Repository
|
||||
builder Builder
|
||||
operation sync.Mutex
|
||||
current atomic.Pointer[Runtime]
|
||||
repository Repository
|
||||
builder Builder
|
||||
operation sync.Mutex
|
||||
current atomic.Pointer[Runtime]
|
||||
reconciliationRequired atomic.Bool
|
||||
legacyJWTAllowed atomic.Bool
|
||||
trustedWebBaseURL atomic.Pointer[string]
|
||||
}
|
||||
|
||||
const identityRuntimeReconciliationTimeout = 5 * time.Second
|
||||
|
||||
func NewManager(repository Repository, builder Builder) *Manager {
|
||||
return &Manager{repository: repository, builder: builder}
|
||||
manager := &Manager{repository: repository, builder: builder}
|
||||
manager.legacyJWTAllowed.Store(true)
|
||||
return manager
|
||||
}
|
||||
|
||||
func (manager *Manager) Current() *Runtime {
|
||||
return manager.current.Load()
|
||||
}
|
||||
|
||||
func (manager *Manager) ReconciliationRequired() bool {
|
||||
return manager.reconciliationRequired.Load()
|
||||
}
|
||||
|
||||
func (manager *Manager) LegacyJWTEnabled() bool {
|
||||
return manager.legacyJWTAllowed.Load()
|
||||
}
|
||||
|
||||
// TrustedWebBaseURL is the persisted Active Revision's exact browser origin.
|
||||
// It intentionally survives a fail-closed Runtime build so the local
|
||||
// break-glass manager can still repair or disable a broken Active Revision.
|
||||
func (manager *Manager) TrustedWebBaseURL() string {
|
||||
value := manager.trustedWebBaseURL.Load()
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
// SecurityEventReceiver resolves the request-time receiver. During first
|
||||
// onboarding there is no Active Runtime yet, so the builder-owned prepared
|
||||
// receiver must remain reachable for SSF Verification callbacks.
|
||||
func (manager *Manager) SecurityEventReceiver() http.Handler {
|
||||
provider, ok := manager.builder.(interface{ PreparedSecurityEventReceiver() http.Handler })
|
||||
if ok {
|
||||
if prepared := provider.PreparedSecurityEventReceiver(); prepared != nil {
|
||||
return prepared
|
||||
}
|
||||
}
|
||||
if runtime := manager.Current(); runtime != nil && runtime.SecurityEvents != nil {
|
||||
return runtime.SecurityEvents
|
||||
}
|
||||
return manager.SecurityEventManager()
|
||||
}
|
||||
|
||||
// SecurityEventManager resolves the manager used by administrative recovery
|
||||
// operations. A prepared manager may represent an older persisted connection
|
||||
// that must be retired before the first identity Runtime can be activated.
|
||||
func (manager *Manager) SecurityEventManager() *securityevents.ConnectionManager {
|
||||
if runtime := manager.Current(); runtime != nil && runtime.SecurityEvents != nil {
|
||||
return runtime.SecurityEvents
|
||||
}
|
||||
managerProvider, ok := manager.builder.(interface {
|
||||
PreparedSecurityEventManager() *securityevents.ConnectionManager
|
||||
})
|
||||
if ok {
|
||||
return managerProvider.PreparedSecurityEventManager()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *Manager) LoadActive(ctx context.Context) error {
|
||||
manager.operation.Lock()
|
||||
defer manager.operation.Unlock()
|
||||
revision, err := manager.repository.ActiveIdentityConfigurationRevision(ctx)
|
||||
if errors.Is(err, identity.ErrRevisionNotFound) {
|
||||
manager.publishDisabledRuntime()
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
manager.failClosedRuntime()
|
||||
return err
|
||||
}
|
||||
manager.rememberTrustedWebBaseURL(revision.WebBaseURL)
|
||||
runtime, err := manager.builder.Build(ctx, revision)
|
||||
if err != nil {
|
||||
manager.failClosedRuntime()
|
||||
return err
|
||||
}
|
||||
manager.publishRuntime(runtime, revision)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReconcileActive restores a fail-closed Runtime after an uncertain mutation
|
||||
// outcome. It is safe to call periodically: a matching Active Runtime is left
|
||||
// untouched, while a changed or missing Active Revision is published atomically.
|
||||
func (manager *Manager) ReconcileActive(ctx context.Context) error {
|
||||
manager.operation.Lock()
|
||||
defer manager.operation.Unlock()
|
||||
revision, err := manager.repository.ActiveIdentityConfigurationRevision(ctx)
|
||||
if errors.Is(err, identity.ErrRevisionNotFound) {
|
||||
manager.publishDisabledRuntime()
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
manager.reconciliationRequired.Store(true)
|
||||
return err
|
||||
}
|
||||
manager.rememberTrustedWebBaseURL(revision.WebBaseURL)
|
||||
if current := manager.current.Load(); current != nil && current.Revision.ID == revision.ID && current.Revision.Version == revision.Version {
|
||||
manager.reconciliationRequired.Store(false)
|
||||
return nil
|
||||
}
|
||||
runtime, err := manager.builder.Build(ctx, revision)
|
||||
if err != nil {
|
||||
manager.reconciliationRequired.Store(true)
|
||||
return err
|
||||
}
|
||||
runtime.Revision = revision
|
||||
manager.current.Store(runtime)
|
||||
manager.publishRuntime(runtime, revision)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -85,7 +182,7 @@ func (manager *Manager) Validate(ctx context.Context, id string, expectedVersion
|
||||
if err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
if revision.Version != expectedVersion || revision.State != identity.RevisionDraft && revision.State != identity.RevisionSuperseded && revision.State != identity.RevisionActive {
|
||||
if revision.Version != expectedVersion || revision.State != identity.RevisionDraft && revision.State != identity.RevisionActive {
|
||||
return identity.Revision{}, identity.ErrRevisionConflict
|
||||
}
|
||||
candidate, buildErr := manager.builder.Build(ctx, revision)
|
||||
@@ -101,9 +198,7 @@ func (manager *Manager) Validate(ctx context.Context, id string, expectedVersion
|
||||
candidate.Close()
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
candidate.Revision = revalidated
|
||||
old := manager.current.Swap(candidate)
|
||||
retireRuntime(old)
|
||||
manager.publishRuntime(candidate, revalidated)
|
||||
return revalidated, nil
|
||||
}
|
||||
candidate.Close()
|
||||
@@ -126,12 +221,9 @@ func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion
|
||||
}
|
||||
activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, expectedVersion, traceID, auditID)
|
||||
if err != nil {
|
||||
candidate.Close()
|
||||
return identity.Revision{}, err
|
||||
return manager.reconcileActivationMutation(ctx, id, candidate, err)
|
||||
}
|
||||
candidate.Revision = activated
|
||||
old := manager.current.Swap(candidate)
|
||||
retireRuntime(old)
|
||||
manager.publishRuntime(candidate, activated)
|
||||
return activated, nil
|
||||
}
|
||||
|
||||
@@ -145,46 +237,235 @@ func (manager *Manager) Rollback(ctx context.Context, id string, expectedVersion
|
||||
if revision.Version != expectedVersion || revision.State != identity.RevisionSuperseded {
|
||||
return identity.Revision{}, identity.ErrRevisionConflict
|
||||
}
|
||||
candidate, err := manager.builder.Build(ctx, revision)
|
||||
if err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
validated, err := manager.repository.MarkIdentityRevisionValidated(ctx, id, expectedVersion, traceID, auditID)
|
||||
if err != nil {
|
||||
candidate.Close()
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, validated.Version, traceID, auditID)
|
||||
if err != nil {
|
||||
candidate.Close()
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
candidate.Revision = activated
|
||||
old := manager.current.Swap(candidate)
|
||||
retireRuntime(old)
|
||||
return activated, nil
|
||||
// Auth Center currently reuses and mutates OAuth/SSF resources. An older
|
||||
// local Revision therefore cannot prove that its remote redirect URIs,
|
||||
// scopes, audiences, or credentials still match. Require a fresh onboarding
|
||||
// exchange until a versioned remote-resource handoff exists.
|
||||
return identity.Revision{}, identity.ErrRollbackConfigurationHandoffRequired
|
||||
}
|
||||
|
||||
func (manager *Manager) Disable(ctx context.Context, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
|
||||
manager.operation.Lock()
|
||||
defer manager.operation.Unlock()
|
||||
disabled, err := manager.repository.DisableActiveIdentityRevision(ctx, expectedVersion, traceID, auditID)
|
||||
if err != nil {
|
||||
if err := manager.retireActiveSecurityEventsBeforeDisable(ctx, expectedVersion); err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
old := manager.current.Swap(nil)
|
||||
retireRuntime(old)
|
||||
disabled, err := manager.repository.DisableActiveIdentityRevision(ctx, expectedVersion, traceID, auditID)
|
||||
if err != nil {
|
||||
return manager.reconcileDisableMutation(ctx, expectedVersion, err)
|
||||
}
|
||||
manager.publishDisabledRuntime()
|
||||
return disabled, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) retireActiveSecurityEventsBeforeDisable(ctx context.Context, expectedVersion int64) error {
|
||||
active, err := manager.repository.ActiveIdentityConfigurationRevision(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if active.Version != expectedVersion {
|
||||
return identity.ErrRevisionConflict
|
||||
}
|
||||
if !active.SessionRevocation {
|
||||
return nil
|
||||
}
|
||||
runtime := manager.current.Load()
|
||||
var disconnector SecurityEventDisconnector
|
||||
if runtime != nil && runtime.Revision.ID == active.ID {
|
||||
disconnector = runtime.securityEventDisconnector
|
||||
if disconnector == nil && runtime.SecurityEvents != nil {
|
||||
disconnector = runtime.SecurityEvents
|
||||
}
|
||||
}
|
||||
if disconnector == nil {
|
||||
recoverer, ok := manager.builder.(interface {
|
||||
RecoverSecurityEventDisconnector(context.Context, identity.Revision) (SecurityEventDisconnector, error)
|
||||
})
|
||||
if !ok {
|
||||
return identity.ErrSecurityEventRetirementPending
|
||||
}
|
||||
var recoverErr error
|
||||
disconnector, recoverErr = recoverer.RecoverSecurityEventDisconnector(ctx, active)
|
||||
if recoverErr != nil {
|
||||
return recoverErr
|
||||
}
|
||||
// A missing local connection means there is no bound local Stream or
|
||||
// credential left to retire. The already-required audit record makes
|
||||
// this fail-closed recovery decision traceable.
|
||||
if disconnector == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
connection, err := disconnector.Disconnect(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if connection.LifecycleStatus != "retiring" {
|
||||
return identity.ErrSecurityEventRetirementPending
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *Manager) reconcileActivationMutation(ctx context.Context, targetID string, candidate *Runtime, mutationErr error) (identity.Revision, error) {
|
||||
reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx)
|
||||
defer cancel()
|
||||
active, err := manager.repository.ActiveIdentityConfigurationRevision(reconcileCtx)
|
||||
if err == nil && active.ID == targetID && active.State == identity.RevisionActive {
|
||||
manager.publishRuntime(candidate, active)
|
||||
return active, nil
|
||||
}
|
||||
candidate.Close()
|
||||
if errors.Is(err, identity.ErrRevisionNotFound) {
|
||||
manager.publishDisabledRuntime()
|
||||
return identity.Revision{}, mutationErr
|
||||
}
|
||||
if err != nil {
|
||||
manager.failClosedRuntime()
|
||||
return identity.Revision{}, mutationErr
|
||||
}
|
||||
current := manager.current.Load()
|
||||
if current == nil || current.Revision.ID != active.ID {
|
||||
manager.failClosedRuntime()
|
||||
}
|
||||
return identity.Revision{}, mutationErr
|
||||
}
|
||||
|
||||
func (manager *Manager) reconcileRollbackValidation(ctx context.Context, targetID string, expectedVersion int64, mutationErr error) (identity.Revision, error) {
|
||||
reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx)
|
||||
defer cancel()
|
||||
revision, err := manager.repository.IdentityConfigurationRevision(reconcileCtx, targetID)
|
||||
if err == nil && revision.State == identity.RevisionValidated && revision.Version == expectedVersion+1 {
|
||||
return revision, nil
|
||||
}
|
||||
return identity.Revision{}, mutationErr
|
||||
}
|
||||
|
||||
func (manager *Manager) reconcileDisableMutation(ctx context.Context, expectedVersion int64, mutationErr error) (identity.Revision, error) {
|
||||
previous := manager.current.Load()
|
||||
reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx)
|
||||
defer cancel()
|
||||
active, err := manager.repository.ActiveIdentityConfigurationRevision(reconcileCtx)
|
||||
if err == nil {
|
||||
if previous == nil || active.ID != previous.Revision.ID {
|
||||
manager.failClosedRuntime()
|
||||
}
|
||||
return identity.Revision{}, mutationErr
|
||||
}
|
||||
if !errors.Is(err, identity.ErrRevisionNotFound) {
|
||||
manager.failClosedRuntime()
|
||||
return identity.Revision{}, mutationErr
|
||||
}
|
||||
manager.publishDisabledRuntime()
|
||||
if previous == nil || previous.Revision.ID == "" {
|
||||
return identity.Revision{}, mutationErr
|
||||
}
|
||||
disabled, lookupErr := manager.repository.IdentityConfigurationRevision(reconcileCtx, previous.Revision.ID)
|
||||
if lookupErr == nil && disabled.State == identity.RevisionSuperseded && disabled.Version == expectedVersion+1 {
|
||||
return disabled, nil
|
||||
}
|
||||
return identity.Revision{}, mutationErr
|
||||
}
|
||||
|
||||
func identityRuntimeReconciliationContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), identityRuntimeReconciliationTimeout)
|
||||
}
|
||||
|
||||
func (manager *Manager) publishRuntime(candidate *Runtime, revision identity.Revision) {
|
||||
candidate.Revision = revision
|
||||
manager.rememberTrustedWebBaseURL(revision.WebBaseURL)
|
||||
manager.legacyJWTAllowed.Store(revision.LegacyJWTEnabled)
|
||||
old := manager.current.Swap(candidate)
|
||||
manager.adoptPreparedSecurityEvents(candidate, revision.ID)
|
||||
manager.reconciliationRequired.Store(false)
|
||||
retireRuntime(old)
|
||||
}
|
||||
|
||||
func (manager *Manager) publishDisabledRuntime() {
|
||||
old := manager.current.Swap(nil)
|
||||
manager.trustedWebBaseURL.Store(nil)
|
||||
manager.legacyJWTAllowed.Store(true)
|
||||
manager.reconciliationRequired.Store(false)
|
||||
retireRuntime(old)
|
||||
}
|
||||
|
||||
func (manager *Manager) failClosedRuntime() {
|
||||
manager.legacyJWTAllowed.Store(false)
|
||||
manager.reconciliationRequired.Store(true)
|
||||
old := manager.current.Swap(nil)
|
||||
retireRuntime(old)
|
||||
}
|
||||
|
||||
func (manager *Manager) rememberTrustedWebBaseURL(value string) {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" {
|
||||
manager.trustedWebBaseURL.Store(nil)
|
||||
return
|
||||
}
|
||||
manager.trustedWebBaseURL.Store(&normalized)
|
||||
}
|
||||
|
||||
func (manager *Manager) PrepareSecurityEvents(ctx context.Context, revision identity.Revision, secret []byte) error {
|
||||
manager.operation.Lock()
|
||||
defer manager.operation.Unlock()
|
||||
current, err := manager.repository.IdentityConfigurationRevision(ctx, revision.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.State == identity.RevisionActive {
|
||||
// Activation already crossed the prepared-manager adoption point. A
|
||||
// stale restart restore must not insert a second Receiver afterwards.
|
||||
return nil
|
||||
}
|
||||
if current.Version != revision.Version || current.State != identity.RevisionDraft && current.State != identity.RevisionValidated {
|
||||
return identity.ErrRevisionConflict
|
||||
}
|
||||
preparer, ok := manager.builder.(interface {
|
||||
PrepareSecurityEvents(context.Context, identity.Revision, []byte) error
|
||||
})
|
||||
if !ok {
|
||||
return errors.New("security event runtime preparation is unavailable")
|
||||
}
|
||||
return preparer.PrepareSecurityEvents(ctx, revision, secret)
|
||||
return preparer.PrepareSecurityEvents(ctx, current, secret)
|
||||
}
|
||||
|
||||
func (manager *Manager) CleanupPreparedSecurityEvents(ctx context.Context, revision identity.Revision) error {
|
||||
cleaner, ok := manager.builder.(interface {
|
||||
CleanupPreparedSecurityEvents(context.Context, identity.Revision) error
|
||||
})
|
||||
if !ok {
|
||||
return errors.New("security event runtime cleanup is unavailable")
|
||||
}
|
||||
return cleaner.CleanupPreparedSecurityEvents(ctx, revision)
|
||||
}
|
||||
|
||||
func (manager *Manager) RetireConflictingSecurityEvents(ctx context.Context, revision identity.Revision) error {
|
||||
resolver, ok := manager.builder.(interface {
|
||||
RetireConflictingSecurityEvents(context.Context, identity.Revision) error
|
||||
})
|
||||
if !ok {
|
||||
return errors.New("security event conflict recovery is unavailable")
|
||||
}
|
||||
return resolver.RetireConflictingSecurityEvents(ctx, revision)
|
||||
}
|
||||
|
||||
func (manager *Manager) adoptPreparedSecurityEvents(runtime *Runtime, revisionID string) {
|
||||
adopter, ok := manager.builder.(interface {
|
||||
AdoptPreparedSecurityEvents(string) context.CancelFunc
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cancel := adopter.AdoptPreparedSecurityEvents(revisionID)
|
||||
if cancel == nil {
|
||||
return
|
||||
}
|
||||
closeRuntime := runtime.close
|
||||
runtime.close = func() {
|
||||
if closeRuntime != nil {
|
||||
closeRuntime()
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func retireRuntime(runtime *Runtime) {
|
||||
|
||||
Reference in New Issue
Block a user