easyai-ai-gateway/apps/api/internal/httpapi/identity_configuration_handlers.go
chengcheng 1fce3a535d feat(identity): 增加统一认证管理接口
提供接入码配对、配置查询、本地策略修改、验证、激活、回滚、禁用和公开运行时状态接口。写操作强制 Idempotency-Key 与 If-Match,后台 Exchange 支持重启恢复和过期收敛,并记录脱敏 Trace 与审计。\n\n验证:go test 相关 identity、store、identityruntime、httpapi 包;go vet ./apps/api/...
2026-07-17 12:10:44 +08:00

492 lines
19 KiB
Go

package httpapi
import (
"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
}
func (operation *identityWriteOperation) close() {
if operation != nil {
operation.once.Do(operation.release)
}
}
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)
}
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)
}
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)
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)
}
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)
}
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)
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)
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)
}
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)
})
}
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)
})
}
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)
})
}
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 := s.recordIdentityConfigurationAudit(r, "revision.disable", "active", "requested", traceID, "")
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 := s.recordIdentityConfigurationAudit(r, action, r.PathValue("revisionID"), "requested", traceID, "")
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
}
encoded, _ := json.Marshal(request)
digest := sha256.Sum256(append([]byte(fmt.Sprintf("%s\x00%d\x00", operation, version)), encoded...))
requestHash := fmt.Sprintf("%x", digest[:])
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 (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) {
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 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) startIdentityPairingWorker(pairingID string) {
if _, loaded := s.identityPairingWorkers.LoadOrStore(pairingID, struct{}{}); loaded {
return
}
go func() {
defer s.identityPairingWorkers.Delete(pairingID)
delay := time.Second
for {
pairing, err := s.identityPairing.Continue(s.ctx, pairingID)
if err == nil {
if pairing.Status == identity.PairingCompleted || pairing.Status == identity.PairingFailed || pairing.Status == identity.PairingExpired {
return
}
delay = time.Second
} else {
if s.logger != nil {
s.logger.Warn("identity pairing step failed and will retry", "pairing_id", pairingID, "error_category", "pairing_step_failed")
}
if delay < 15*time.Second {
delay *= 2
}
}
select {
case <-s.ctx.Done():
return
case <-time.After(delay):
}
}
}()
}