easyai-ai-gateway/apps/api/internal/httpapi/identity_configuration_handlers.go
chengcheng a312ad880d 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。
2026-07-17 18:31:12 +08:00

821 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type identityConfigurationView struct {
Active *identity.Revision `json:"active"`
Draft *identity.Revision `json:"draft"`
Previous *identity.Revision `json:"previous"`
Pairing *identity.PairingExchange `json:"pairing,omitempty"`
Runtime identityRuntimeStatus `json:"runtime"`
}
type identityRuntimeStatus struct {
Enabled bool `json:"enabled"`
RevisionID string `json:"revisionId,omitempty"`
Login string `json:"login"`
JIT string `json:"jit"`
TokenIntrospection string `json:"tokenIntrospection"`
SessionRevocation string `json:"sessionRevocation"`
LastTraceID string `json:"lastTraceId,omitempty"`
LastAuditID string `json:"lastAuditId,omitempty"`
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
}
type publicIdentityConfiguration struct {
Enabled bool `json:"enabled"`
OIDCLogin bool `json:"oidcLogin"`
LoginURL string `json:"loginUrl,omitempty"`
LogoutURL string `json:"logoutUrl,omitempty"`
Status string `json:"status"`
}
type identityPolicyPatch struct {
LocalTenantKey *string `json:"localTenantKey"`
RolePrefix *string `json:"rolePrefix"`
JITEnabled *bool `json:"jitEnabled"`
LegacyJWTEnabled *bool `json:"legacyJwtEnabled"`
SessionIdleSeconds *int `json:"sessionIdleSeconds"`
SessionAbsoluteSeconds *int `json:"sessionAbsoluteSeconds"`
SessionRefreshSeconds *int `json:"sessionRefreshSeconds"`
}
type storedIdentityResponse struct {
Status int `json:"status"`
Body json.RawMessage `json:"body"`
ETag string `json:"etag,omitempty"`
AuditID string `json:"auditId,omitempty"`
}
type identityWriteOperation struct {
operation, key, requestHash string
release func()
once sync.Once
}
type identityPairingWorker struct {
cancel context.CancelFunc
done chan struct{}
}
func (operation *identityWriteOperation) close() {
if operation != nil {
operation.once.Do(operation.release)
}
}
// getPublicIdentityConfiguration godoc
// @Summary 获取公开统一认证状态
// @Description 返回 Web 运行时需要的启用状态和非敏感登录、退出入口,不返回 Client、Secret 或内部标识。
// @Tags identity
// @Produce json
// @Success 200 {object} publicIdentityConfiguration
// @Router /api/v1/public/identity [get]
func (s *Server) getPublicIdentityConfiguration(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
runtime := s.currentIdentityRuntime()
if runtime == nil || runtime.Revision.State != identity.RevisionActive {
writeJSON(w, http.StatusOK, publicIdentityConfiguration{Status: "disabled"})
return
}
view := publicIdentityConfiguration{Enabled: true, Status: "active"}
if oidcRuntimeReady(runtime) {
view.OIDCLogin = true
view.LoginURL = "/api/v1/auth/oidc/login"
view.LogoutURL = "/api/v1/auth/oidc/logout"
}
writeJSON(w, http.StatusOK, view)
}
// getIdentityConfiguration godoc
// @Summary 获取统一认证配置
// @Description 返回 Active、Draft、Previous Revision、配对进度和脱敏运行时健康状态。
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Success 200 {object} identityConfigurationView
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Router /api/admin/system/identity/configuration [get]
func (s *Server) getIdentityConfiguration(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
view := identityConfigurationView{Runtime: s.identityRuntimeStatus(r)}
if revision, err := s.store.ActiveIdentityConfigurationRevision(r.Context()); err == nil {
view.Active = &revision
} else if !errors.Is(err, identity.ErrRevisionNotFound) {
writeError(w, http.StatusServiceUnavailable, "统一认证配置暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE")
return
}
if revision, err := s.store.LatestInactiveIdentityConfigurationRevision(r.Context()); err == nil {
view.Draft = &revision
if pairing, pairingErr := s.store.IdentityPairingExchangeForRevision(r.Context(), revision.ID); pairingErr == nil {
view.Pairing = &pairing
}
} else if !errors.Is(err, identity.ErrRevisionNotFound) {
writeError(w, http.StatusServiceUnavailable, "统一认证草稿暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE")
return
}
if revision, err := s.store.LatestSupersededIdentityConfigurationRevision(r.Context()); err == nil {
view.Previous = &revision
} else if !errors.Is(err, identity.ErrRevisionNotFound) {
writeError(w, http.StatusServiceUnavailable, "统一认证历史版本暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE")
return
}
writeJSON(w, http.StatusOK, view)
}
// startIdentityPairing godoc
// @Summary 启动统一认证配对
// @Description 使用一次性接入码创建 Draft 和异步 Exchange。接入码仅允许出现在请求 Body。
// @Tags identity
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "首次配对固定为 W/\"0\""
// @Param body body identity.PairingInput true "配对参数"
// @Success 202 {object} identity.PairingExchange
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Router /api/admin/system/identity/pairings [post]
func (s *Server) startIdentityPairing(w http.ResponseWriter, r *http.Request) {
var input identity.PairingInput
if !decodeIdentityRequest(w, r, &input) {
return
}
operation, ok := s.beginIdentityWrite(w, r, "pairing.start", 0, input)
if !ok {
return
}
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
}
pairing.AuthCenterAuditID = ""
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
s.startIdentityPairingWorker(pairing.ID)
}
// getIdentityPairing godoc
// @Summary 查询统一认证配对状态
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Param pairingID path string true "配对 ID"
// @Success 200 {object} identity.PairingExchange
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /api/admin/system/identity/pairings/{pairingID} [get]
func (s *Server) getIdentityPairing(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
pairing, err := s.store.IdentityPairingExchange(r.Context(), r.PathValue("pairingID"))
if err != nil {
s.writeIdentityError(w, r, "pairing.get", r.PathValue("pairingID"), ensureIdentityTraceID(w, r), err)
return
}
w.Header().Set("ETag", identityETag(pairing.Version))
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
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param revisionID path string true "Revision ID"
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "当前 Revision ETag"
// @Param body body identityPolicyPatch true "Gateway 本地策略"
// @Success 200 {object} identity.Revision
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 412 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Router /api/admin/system/identity/revisions/{revisionID} [patch]
func (s *Server) updateIdentityDraftPolicy(w http.ResponseWriter, r *http.Request) {
var patch identityPolicyPatch
if !decodeIdentityRequest(w, r, &patch) {
return
}
expectedVersion, ok := requiredIdentityVersion(w, r)
if !ok {
return
}
operation, ok := s.beginIdentityWriteWithVersion(w, r, "revision.policy", expectedVersion, patch)
if !ok {
return
}
defer operation.close()
revision, err := s.store.IdentityConfigurationRevision(r.Context(), r.PathValue("revisionID"))
if err != nil || revision.Version != expectedVersion {
s.writeIdentityError(w, r, "revision.policy", r.PathValue("revisionID"), ensureIdentityTraceID(w, r), firstIdentityError(err, identity.ErrRevisionConflict))
return
}
policy := identity.RevisionPolicy{
LocalTenantKey: revision.LocalTenantKey, RolePrefix: revision.RolePrefix, JITEnabled: revision.JITEnabled,
LegacyJWTEnabled: revision.LegacyJWTEnabled, SessionIdleSeconds: revision.SessionIdleSeconds,
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, traceID, err)
return
}
s.completeIdentityWrite(w, r, operation, http.StatusOK, updated, updated.Version, auditID)
}
// validateIdentityRevision godoc
// @Summary 验证统一认证 Revision
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Param revisionID path string true "Revision ID"
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "当前 Revision ETag"
// @Success 200 {object} identity.Revision
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 412 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/admin/system/identity/revisions/{revisionID}/validate [post]
func (s *Server) validateIdentityRevision(w http.ResponseWriter, r *http.Request) {
s.runIdentityRevisionAction(w, r, "revision.validate", func(expected int64, traceID, auditID string) (identity.Revision, error) {
return s.identityRuntime.Validate(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID)
})
}
// activateIdentityRevision godoc
// @Summary 激活统一认证 Revision
// @Description 在完整候选 Runtime 验证成功且本地 Break-glass Manager 可用后原子热切换。
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Param revisionID path string true "Revision ID"
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "当前 Revision ETag"
// @Success 200 {object} identity.Revision
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 412 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Router /api/admin/system/identity/revisions/{revisionID}/activate [post]
func (s *Server) activateIdentityRevision(w http.ResponseWriter, r *http.Request) {
s.runIdentityRevisionAction(w, r, "revision.activate", func(expected int64, traceID, auditID string) (identity.Revision, error) {
return s.identityRuntime.Activate(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID)
})
}
// rollbackIdentityRevision godoc
// @Summary 请求恢复历史统一认证 Revision
// @Description 当前远端 OAuth/SSF 资源未版本化,接口会拒绝直接回滚并要求禁用后使用新接入码完成交接。
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Param revisionID path string true "Revision ID"
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "当前 Revision ETag"
// @Success 200 {object} identity.Revision
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 412 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Router /api/admin/system/identity/revisions/{revisionID}/rollback [post]
func (s *Server) rollbackIdentityRevision(w http.ResponseWriter, r *http.Request) {
s.runIdentityRevisionAction(w, r, "revision.rollback", func(expected int64, traceID, auditID string) (identity.Revision, error) {
return s.identityRuntime.Rollback(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID)
})
}
// disableIdentityConfiguration godoc
// @Summary 禁用统一认证
// @Description 将 Revision 转为只读 Superseded 审计历史,清理 BFF Session并继续允许本地管理登录重新接入必须使用新的接入码。
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "当前 Active Revision ETag"
// @Success 200 {object} identity.Revision
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 412 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Router /api/admin/system/identity/disable [post]
func (s *Server) disableIdentityConfiguration(w http.ResponseWriter, r *http.Request) {
expectedVersion, ok := requiredIdentityVersion(w, r)
if !ok {
return
}
operation, ok := s.beginIdentityWriteWithVersion(w, r, "revision.disable", expectedVersion, struct{}{})
if !ok {
return
}
defer operation.close()
traceID := ensureIdentityTraceID(w, r)
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)
return
}
s.completeIdentityWrite(w, r, operation, http.StatusOK, disabled, disabled.Version, auditID)
}
func (s *Server) runIdentityRevisionAction(w http.ResponseWriter, r *http.Request, action string, run func(int64, string, string) (identity.Revision, error)) {
expectedVersion, ok := requiredIdentityVersion(w, r)
if !ok {
return
}
operation, ok := s.beginIdentityWriteWithVersion(w, r, action, expectedVersion, struct{}{})
if !ok {
return
}
defer operation.close()
traceID := ensureIdentityTraceID(w, r)
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)
return
}
s.completeIdentityWrite(w, r, operation, http.StatusOK, revision, revision.Version, auditID)
}
func (s *Server) identityRuntimeStatus(r *http.Request) identityRuntimeStatus {
runtime := s.currentIdentityRuntime()
if runtime == nil {
return identityRuntimeStatus{Login: "disabled", JIT: "disabled", TokenIntrospection: "disabled", SessionRevocation: "disabled"}
}
revision := runtime.Revision
status := identityRuntimeStatus{
Enabled: true, RevisionID: revision.ID, Login: capabilityHealth(runtime.PublicClient != nil),
JIT: capabilityHealth(revision.JITEnabled), TokenIntrospection: capabilityHealth(revision.TokenIntrospection),
SessionRevocation: capabilityHealth(revision.SessionRevocation), LastTraceID: revision.LastTraceID,
LastAuditID: revision.LastAuditID, LastErrorCategory: revision.LastErrorCategory,
}
if revision.SessionRevocation && runtime.SecurityEvents != nil {
if connection, err := runtime.SecurityEvents.Get(r.Context()); err == nil && connection.LifecycleStatus != "active" {
status.SessionRevocation = connection.HealthMode
}
}
return status
}
func capabilityHealth(enabled bool) string {
if enabled {
return "healthy"
}
return "disabled"
}
func decodeIdentityRequest(w http.ResponseWriter, r *http.Request, output any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(output); err != nil {
writeError(w, http.StatusBadRequest, "统一认证请求格式无效", "INVALID_REQUEST")
return false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "统一认证请求格式无效", "INVALID_REQUEST")
return false
}
return true
}
func (s *Server) beginIdentityWrite(w http.ResponseWriter, r *http.Request, operation string, version int64, request any) (*identityWriteOperation, bool) {
if parsed, ok := requiredIdentityVersion(w, r); !ok || parsed != version {
if ok {
writeError(w, http.StatusPreconditionFailed, "统一认证配置版本已变化", "IDENTITY_VERSION_CONFLICT")
}
return nil, false
}
return s.beginIdentityWriteWithVersion(w, r, operation, version, request)
}
func (s *Server) beginIdentityWriteWithVersion(w http.ResponseWriter, r *http.Request, operation string, version int64, request any) (*identityWriteOperation, bool) {
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if key == "" || len(key) > 255 {
writeError(w, http.StatusBadRequest, "Idempotency-Key is required", "IDEMPOTENCY_KEY_REQUIRED")
return nil, false
}
requestHash := identityRequestHash(operation, version, request)
s.identityManagementMu.Lock()
write := &identityWriteOperation{operation: operation, key: key, requestHash: requestHash, release: s.identityManagementMu.Unlock}
if s.store == nil {
return write, true
}
recorded, err := s.store.IdentityManagementRequest(r.Context(), operation, key)
if errors.Is(err, store.ErrIdentityManagementRequestNotFound) {
return write, true
}
if err != nil {
write.close()
writeError(w, http.StatusServiceUnavailable, "幂等状态暂时不可用", "IDENTITY_IDEMPOTENCY_UNAVAILABLE")
return nil, false
}
if recorded.RequestHash != requestHash {
write.close()
writeError(w, http.StatusConflict, "Idempotency-Key 已用于其他请求", "IDEMPOTENCY_KEY_REUSED")
return nil, false
}
var response storedIdentityResponse
if json.Unmarshal(recorded.Response, &response) != nil {
write.close()
writeError(w, http.StatusServiceUnavailable, "幂等响应暂时不可用", "IDENTITY_IDEMPOTENCY_UNAVAILABLE")
return nil, false
}
write.close()
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Idempotent-Replayed", "true")
if response.ETag != "" {
w.Header().Set("ETag", response.ETag)
}
if response.AuditID != "" {
w.Header().Set("X-Audit-Id", response.AuditID)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(response.Status)
_, _ = w.Write(response.Body)
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})
if s.store != nil {
if err := s.store.RecordIdentityManagementRequest(r.Context(), store.IdentityManagementRequest{
Operation: operation.operation, Key: operation.key, RequestHash: operation.requestHash, Response: stored,
}); err != nil && s.logger != nil {
s.logger.ErrorContext(r.Context(), "identity idempotency response could not be recorded", "operation", operation.operation, "error_category", "idempotency_store_failed")
}
}
operation.close()
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("ETag", identityETag(version))
if auditID != "" {
w.Header().Set("X-Audit-Id", auditID)
}
writeJSON(w, status, payload)
}
func requiredIdentityVersion(w http.ResponseWriter, r *http.Request) (int64, bool) {
value := strings.Trim(strings.TrimPrefix(strings.TrimSpace(r.Header.Get("If-Match")), "W/"), `"`)
version, err := strconv.ParseInt(value, 10, 64)
if err != nil || version < 0 {
writeError(w, http.StatusPreconditionRequired, "If-Match is required", "IF_MATCH_REQUIRED")
return 0, false
}
return version, true
}
func identityETag(version int64) string { return fmt.Sprintf(`W/"%d"`, version) }
func applyIdentityPolicyPatch(policy *identity.RevisionPolicy, patch identityPolicyPatch) {
if patch.LocalTenantKey != nil {
policy.LocalTenantKey = strings.TrimSpace(*patch.LocalTenantKey)
}
if patch.RolePrefix != nil {
policy.RolePrefix = strings.TrimSpace(*patch.RolePrefix)
}
if patch.JITEnabled != nil {
policy.JITEnabled = *patch.JITEnabled
}
if patch.LegacyJWTEnabled != nil {
policy.LegacyJWTEnabled = *patch.LegacyJWTEnabled
}
if patch.SessionIdleSeconds != nil {
policy.SessionIdleSeconds = *patch.SessionIdleSeconds
}
if patch.SessionAbsoluteSeconds != nil {
policy.SessionAbsoluteSeconds = *patch.SessionAbsoluteSeconds
}
if patch.SessionRefreshSeconds != nil {
policy.SessionRefreshSeconds = *patch.SessionRefreshSeconds
}
}
func firstIdentityError(actual, fallback error) error {
if actual != nil {
return actual
}
return fallback
}
func ensureIdentityTraceID(w http.ResponseWriter, r *http.Request) string {
return ensureSecurityEventTraceID(w, r)
}
func (s *Server) writeIdentityError(w http.ResponseWriter, r *http.Request, action, targetID, traceID string, err error) {
status, message, code := identityErrorProjection(err)
s.recordIdentityConfigurationAudit(r, action, targetID, "failure", traceID, code)
writeError(w, status, message, code)
}
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"
case errors.Is(err, identity.ErrRevisionConflict):
return http.StatusPreconditionFailed, "统一认证配置版本或状态已变化", "IDENTITY_VERSION_CONFLICT"
case errors.Is(err, identity.ErrBreakGlassRequired):
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:
return http.StatusBadGateway, "认证中心或统一认证服务暂时不可用", "IDENTITY_SERVICE_UNAVAILABLE"
}
}
func (s *Server) recordIdentityConfigurationAudit(r *http.Request, action, targetID, outcome, traceID, errorCategory string) string {
if s.store == nil {
return ""
}
actor, _ := auth.UserFromContext(r.Context())
input := store.AuditLogInput{
Category: "identity", Action: "identity." + action, TargetType: "identity_configuration_revision",
TargetID: firstNonEmptyText(targetID, "pending"), RequestIP: limitAuditText(requestIP(r), 128),
UserAgent: limitAuditText(r.UserAgent(), 512), Metadata: map[string]any{
"outcome": outcome, "traceId": traceID, "errorCategory": errorCategory,
},
}
if actor != nil {
input.ActorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID))
input.ActorUserID, input.ActorUsername, input.ActorSource, input.ActorRoles = actor.ID, actor.Username, actor.Source, actor.Roles
}
audit, err := s.store.RecordAuditLog(r.Context(), input)
if err != nil {
if s.logger != nil {
s.logger.WarnContext(r.Context(), "record identity audit failed", "action", action, "error_category", "audit_store_failed", "trace_id", traceID)
}
return ""
}
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) {
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 func() {
s.identityPairingWorkers.CompareAndDelete(pairingID, worker)
close(worker.done)
}()
delay := time.Second
for {
pairing, err := s.identityPairing.Continue(workerContext, pairingID)
if err == nil {
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", 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
}
}
select {
case <-s.ctx.Done():
return
case <-time.After(delay):
}
}
}()
}