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:
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -68,6 +69,11 @@ type identityWriteOperation struct {
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
type identityPairingWorker struct {
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (operation *identityWriteOperation) close() {
|
||||
if operation != nil {
|
||||
operation.once.Do(operation.release)
|
||||
@@ -163,12 +169,15 @@ func (s *Server) startIdentityPairing(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.start", "pending", traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pairing, err := s.identityPairing.Start(r.Context(), input, traceID)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, "pairing.start", "", traceID, err)
|
||||
return
|
||||
}
|
||||
auditID := s.recordIdentityConfigurationAudit(r, "pairing.start", pairing.RevisionID, "accepted", traceID, "")
|
||||
pairing.AuthCenterAuditID = ""
|
||||
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
|
||||
s.startIdentityPairingWorker(pairing.ID)
|
||||
@@ -196,6 +205,95 @@ func (s *Server) getIdentityPairing(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, pairing)
|
||||
}
|
||||
|
||||
// cancelIdentityPairing godoc
|
||||
// @Summary 放弃本地统一认证配对
|
||||
// @Description 原子封存未激活 Revision,并异步清理由该 Revision 拥有的临时 Secret 与 SSF 连接。不会撤销已经完成的远端 Exchange;下一次凭据交付会轮换机器凭据。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param pairingID path string true "配对 ID"
|
||||
// @Param Idempotency-Key header string true "幂等键"
|
||||
// @Param If-Match header string true "当前 Pairing ETag"
|
||||
// @Success 202 {object} identity.PairingExchange
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 409 {object} ErrorEnvelope
|
||||
// @Failure 412 {object} ErrorEnvelope
|
||||
// @Failure 428 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/identity/pairings/{pairingID}/cancel [post]
|
||||
func (s *Server) cancelIdentityPairing(w http.ResponseWriter, r *http.Request) {
|
||||
expectedVersion, ok := requiredIdentityVersion(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pairingID := r.PathValue("pairingID")
|
||||
operation, ok := s.beginIdentityWriteWithVersion(w, r, "pairing.cancel", expectedVersion, struct {
|
||||
PairingID string `json:"pairingId"`
|
||||
}{PairingID: pairingID})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.cancel", pairingID, traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pairing, err := s.identityPairing.Cancel(r.Context(), pairingID, expectedVersion, traceID, auditID)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, "pairing.cancel", pairingID, traceID, err)
|
||||
return
|
||||
}
|
||||
done := s.stopIdentityPairingWorker(pairing.ID)
|
||||
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
|
||||
s.startIdentityPairingCleanupWorker(pairing.ID, done)
|
||||
}
|
||||
|
||||
// retireIdentityPairingSecurityEventConflict godoc
|
||||
// @Summary 安全退役阻塞配对的旧 SSF 连接
|
||||
// @Description 仅当指定 Pairing 仍因 owner 冲突停在 credentials_saved 时,退役不属于该 Revision 的旧连接;陈旧请求不会退役当前 Pairing 已创建的新连接。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param pairingID path string true "配对 ID"
|
||||
// @Param Idempotency-Key header string true "幂等键"
|
||||
// @Param If-Match header string true "当前 Pairing ETag"
|
||||
// @Success 202 {object} identity.PairingExchange
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 409 {object} ErrorEnvelope
|
||||
// @Failure 412 {object} ErrorEnvelope
|
||||
// @Failure 428 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event [post]
|
||||
func (s *Server) retireIdentityPairingSecurityEventConflict(w http.ResponseWriter, r *http.Request) {
|
||||
expectedVersion, ok := requiredIdentityVersion(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pairingID := r.PathValue("pairingID")
|
||||
operation, ok := s.beginIdentityWriteWithVersion(w, r, "pairing.retire_security_event_conflict", expectedVersion, struct {
|
||||
PairingID string `json:"pairingId"`
|
||||
}{PairingID: pairingID})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.retire_security_event_conflict", pairingID, traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pairing, err := s.identityPairing.RetireConflictingSecurityEvents(r.Context(), pairingID, expectedVersion)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, "pairing.retire_security_event_conflict", pairingID, traceID, err)
|
||||
return
|
||||
}
|
||||
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
|
||||
s.startIdentityPairingWorker(pairing.ID)
|
||||
}
|
||||
|
||||
// updateIdentityDraftPolicy godoc
|
||||
// @Summary 修改统一认证 Draft 策略
|
||||
// @Tags identity
|
||||
@@ -238,13 +336,16 @@ func (s *Server) updateIdentityDraftPolicy(w http.ResponseWriter, r *http.Reques
|
||||
SessionAbsoluteSeconds: revision.SessionAbsoluteSeconds, SessionRefreshSeconds: revision.SessionRefreshSeconds,
|
||||
}
|
||||
applyIdentityPolicyPatch(&policy, patch)
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "revision.policy", revision.ID, traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
updated, err := s.store.UpdateIdentityRevisionPolicy(r.Context(), revision.ID, expectedVersion, policy)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, "revision.policy", revision.ID, ensureIdentityTraceID(w, r), err)
|
||||
s.writeIdentityError(w, r, "revision.policy", revision.ID, traceID, err)
|
||||
return
|
||||
}
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID := s.recordIdentityConfigurationAudit(r, "revision.policy", revision.ID, "success", traceID, "")
|
||||
s.completeIdentityWrite(w, r, operation, http.StatusOK, updated, updated.Version, auditID)
|
||||
}
|
||||
|
||||
@@ -292,7 +393,8 @@ func (s *Server) activateIdentityRevision(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
// rollbackIdentityRevision godoc
|
||||
// @Summary 回滚统一认证 Revision
|
||||
// @Summary 请求恢复历史统一认证 Revision
|
||||
// @Description 当前远端 OAuth/SSF 资源未版本化,接口会拒绝直接回滚并要求禁用后使用新接入码完成交接。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
@@ -314,7 +416,7 @@ func (s *Server) rollbackIdentityRevision(w http.ResponseWriter, r *http.Request
|
||||
|
||||
// disableIdentityConfiguration godoc
|
||||
// @Summary 禁用统一认证
|
||||
// @Description 保留 Superseded Revision 供回滚,清理 BFF Session,并继续允许本地管理登录。
|
||||
// @Description 将 Revision 转为只读 Superseded 审计历史,清理 BFF Session,并继续允许本地管理登录;重新接入必须使用新的接入码。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
@@ -338,7 +440,10 @@ func (s *Server) disableIdentityConfiguration(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID := s.recordIdentityConfigurationAudit(r, "revision.disable", "active", "requested", traceID, "")
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "revision.disable", "active", traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
disabled, err := s.identityRuntime.Disable(r.Context(), expectedVersion, traceID, auditID)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, "revision.disable", "active", traceID, err)
|
||||
@@ -358,7 +463,10 @@ func (s *Server) runIdentityRevisionAction(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID := s.recordIdentityConfigurationAudit(r, action, r.PathValue("revisionID"), "requested", traceID, "")
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, action, r.PathValue("revisionID"), traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
revision, err := run(expectedVersion, traceID, auditID)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, action, r.PathValue("revisionID"), traceID, err)
|
||||
@@ -425,9 +533,7 @@ func (s *Server) beginIdentityWriteWithVersion(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusBadRequest, "Idempotency-Key is required", "IDEMPOTENCY_KEY_REQUIRED")
|
||||
return nil, false
|
||||
}
|
||||
encoded, _ := json.Marshal(request)
|
||||
digest := sha256.Sum256(append([]byte(fmt.Sprintf("%s\x00%d\x00", operation, version)), encoded...))
|
||||
requestHash := fmt.Sprintf("%x", digest[:])
|
||||
requestHash := identityRequestHash(operation, version, request)
|
||||
s.identityManagementMu.Lock()
|
||||
write := &identityWriteOperation{operation: operation, key: key, requestHash: requestHash, release: s.identityManagementMu.Unlock}
|
||||
if s.store == nil {
|
||||
@@ -468,6 +574,12 @@ func (s *Server) beginIdentityWriteWithVersion(w http.ResponseWriter, r *http.Re
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func identityRequestHash(operation string, version int64, request any) string {
|
||||
encoded, _ := json.Marshal(request)
|
||||
digest := sha256.Sum256(append([]byte(fmt.Sprintf("%s\x00%d\x00", operation, version)), encoded...))
|
||||
return fmt.Sprintf("%x", digest[:])
|
||||
}
|
||||
|
||||
func (s *Server) completeIdentityWrite(w http.ResponseWriter, r *http.Request, operation *identityWriteOperation, status int, payload any, version int64, auditID string) {
|
||||
body, _ := json.Marshal(payload)
|
||||
stored, _ := json.Marshal(storedIdentityResponse{Status: status, Body: body, ETag: identityETag(version), AuditID: auditID})
|
||||
@@ -541,6 +653,11 @@ func (s *Server) writeIdentityError(w http.ResponseWriter, r *http.Request, acti
|
||||
}
|
||||
|
||||
func identityErrorProjection(err error) (int, string, string) {
|
||||
var categorized interface{ SafeErrorCategory() string }
|
||||
safeCategory := ""
|
||||
if errors.As(err, &categorized) {
|
||||
safeCategory = categorized.SafeErrorCategory()
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, identity.ErrRevisionNotFound):
|
||||
return http.StatusNotFound, "统一认证配置不存在", "IDENTITY_CONFIGURATION_NOT_FOUND"
|
||||
@@ -550,6 +667,20 @@ func identityErrorProjection(err error) (int, string, string) {
|
||||
return http.StatusConflict, "请先保留至少一个可用的本地应急管理员凭据", "BREAK_GLASS_MANAGER_REQUIRED"
|
||||
case errors.Is(err, identity.ErrLocalTenantInvalid):
|
||||
return http.StatusConflict, "本地租户映射无效", "IDENTITY_LOCAL_TENANT_INVALID"
|
||||
case errors.Is(err, identity.ErrPairingInProgress):
|
||||
return http.StatusConflict, "请先完成或放弃当前统一认证配对", "IDENTITY_PAIRING_IN_PROGRESS"
|
||||
case errors.Is(err, identity.ErrActiveConfigurationHandoffRequired):
|
||||
return http.StatusConflict, "当前仍有 Active 统一认证配置;请先禁用,再使用新接入码配对", "IDENTITY_ACTIVE_CONFIGURATION_HANDOFF_REQUIRED"
|
||||
case errors.Is(err, identity.ErrRollbackConfigurationHandoffRequired):
|
||||
return http.StatusConflict, "旧版本关联的远端 OAuth/SSF 资源可能已变化;请禁用后使用新接入码恢复", "IDENTITY_ROLLBACK_CONFIGURATION_HANDOFF_REQUIRED"
|
||||
case errors.Is(err, identity.ErrSecurityEventRetirementPending):
|
||||
return http.StatusConflict, "旧安全事件 Stream 尚未完成断开;系统会继续重试,请稍后再次禁用", "IDENTITY_SECURITY_EVENT_RETIREMENT_PENDING"
|
||||
case errors.Is(err, identity.ErrPairingNotCancellable):
|
||||
return http.StatusConflict, "当前统一认证配置不能放弃", "IDENTITY_PAIRING_NOT_CANCELLABLE"
|
||||
case errors.Is(err, identity.ErrPairingConflictNotResolvable):
|
||||
return http.StatusConflict, "当前配对已不需要退役旧安全事件连接,请刷新状态", "IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE"
|
||||
case safeCategory == "credential_handoff_unsafe":
|
||||
return http.StatusConflict, "旧安全事件连接与本次认证中心不匹配,系统已拒绝发送凭据;请先在原配置下断开旧连接", "IDENTITY_SECURITY_EVENT_HANDOFF_UNSAFE"
|
||||
case err != nil && (strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required")):
|
||||
return http.StatusBadRequest, "统一认证配置无效", "IDENTITY_CONFIGURATION_INVALID"
|
||||
default:
|
||||
@@ -583,23 +714,97 @@ func (s *Server) recordIdentityConfigurationAudit(r *http.Request, action, targe
|
||||
return audit.ID
|
||||
}
|
||||
|
||||
func (s *Server) requireIdentityConfigurationAudit(w http.ResponseWriter, r *http.Request, action, targetID, traceID string) (string, bool) {
|
||||
auditID := s.recordIdentityConfigurationAudit(r, action, targetID, "requested", traceID, "")
|
||||
if auditID == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "统一认证审计暂时不可用,操作未执行", "IDENTITY_AUDIT_UNAVAILABLE")
|
||||
return "", false
|
||||
}
|
||||
w.Header().Set("X-Audit-Id", auditID)
|
||||
return auditID, true
|
||||
}
|
||||
|
||||
func (s *Server) startIdentityPairingWorker(pairingID string) {
|
||||
if _, loaded := s.identityPairingWorkers.LoadOrStore(pairingID, struct{}{}); loaded {
|
||||
workerContext, cancel := context.WithCancel(s.ctx)
|
||||
worker := &identityPairingWorker{cancel: cancel, done: make(chan struct{})}
|
||||
if _, loaded := s.identityPairingWorkers.LoadOrStore(pairingID, worker); loaded {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer s.identityPairingWorkers.Delete(pairingID)
|
||||
defer func() {
|
||||
s.identityPairingWorkers.CompareAndDelete(pairingID, worker)
|
||||
close(worker.done)
|
||||
}()
|
||||
delay := time.Second
|
||||
for {
|
||||
pairing, err := s.identityPairing.Continue(s.ctx, pairingID)
|
||||
pairing, err := s.identityPairing.Continue(workerContext, pairingID)
|
||||
if err == nil {
|
||||
if pairing.Status == identity.PairingCompleted || pairing.Status == identity.PairingFailed || pairing.Status == identity.PairingExpired {
|
||||
if pairing.Status == identity.PairingCompleted || pairing.Status == identity.PairingFailed || pairing.Status == identity.PairingExpired || pairing.Status == identity.PairingCancelled {
|
||||
return
|
||||
}
|
||||
delay = time.Second
|
||||
} else {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("identity pairing step failed and will retry", "pairing_id", pairingID, "error_category", "pairing_step_failed")
|
||||
s.logger.Warn("identity pairing step failed and will retry", "pairing_id", pairingID, "error_category", firstNonEmptyText(pairing.LastErrorCategory, "pairing_step_failed"))
|
||||
}
|
||||
if delay < 15*time.Second {
|
||||
delay *= 2
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-workerContext.Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) stopIdentityPairingWorker(pairingID string) <-chan struct{} {
|
||||
if value, ok := s.identityPairingWorkers.Load(pairingID); ok {
|
||||
worker := value.(*identityPairingWorker)
|
||||
worker.cancel()
|
||||
return worker.done
|
||||
}
|
||||
done := make(chan struct{})
|
||||
close(done)
|
||||
return done
|
||||
}
|
||||
|
||||
func (s *Server) startIdentityPairingCleanupWorker(pairingID string, processingDone <-chan struct{}) {
|
||||
if processingDone == nil {
|
||||
processingDone = s.stopIdentityPairingWorker(pairingID)
|
||||
}
|
||||
if _, loaded := s.identityCleanupWorkers.LoadOrStore(pairingID, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer s.identityCleanupWorkers.Delete(pairingID)
|
||||
if processingDone != nil {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-processingDone:
|
||||
}
|
||||
}
|
||||
delay := time.Second
|
||||
for {
|
||||
pairing, err := s.identityPairing.Cleanup(s.ctx, pairingID)
|
||||
if err == nil {
|
||||
if pairing.CleanupStatus == identity.PairingCleanupCompleted {
|
||||
return
|
||||
}
|
||||
delay = time.Second
|
||||
} else {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, identity.ErrPairingNotCancellable) {
|
||||
return
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("identity pairing cleanup failed and will retry", "pairing_id", pairingID, "error_category", firstNonEmptyText(pairing.LastErrorCategory, "pairing_cleanup_failed"))
|
||||
}
|
||||
if delay < 15*time.Second {
|
||||
delay *= 2
|
||||
|
||||
Reference in New Issue
Block a user