package httpapi import ( "context" "errors" "net/http" "strings" "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" ) const ( errorCodeGatewayUserNotProvisioned = "GATEWAY_USER_NOT_PROVISIONED" errorCodeGatewayUserDisabled = "GATEWAY_USER_DISABLED" errorCodeGatewayTenantUnavailable = "GATEWAY_TENANT_UNAVAILABLE" errorCodeGatewayProvisioningFailed = "GATEWAY_USER_PROVISIONING_FAILED" ) type oidcUserResolver interface { ResolveOrProvisionOIDCUser(context.Context, store.ResolveOrProvisionOIDCUserInput) (store.ResolveOrProvisionOIDCUserResult, error) } type oidcTenantBindingReader interface { OIDCTenantBindingContext(context.Context, string, string, string) (store.OIDCTenantBindingContext, error) } func (s *Server) requireUser(permission auth.Permission, next http.Handler) http.Handler { return s.auth.Require(permission, s.resolveGatewayUser(next)) } func (s *Server) resolveGatewayUser(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, ok := auth.UserFromContext(r.Context()) if !ok || user == nil || !strings.EqualFold(strings.TrimSpace(user.Source), "oidc") { next.ServeHTTP(w, r) return } result, err := s.resolveOIDCUserProjection(r.Context(), r, user) if err != nil { s.writeOIDCUserResolutionError(w, r, err) return } if result.User == nil || strings.TrimSpace(result.User.GatewayUserID) == "" { s.writeOIDCUserResolutionError(w, r, errors.New("OIDC user resolver returned no local user")) return } if result.Created { s.logger.InfoContext(r.Context(), "OIDC gateway user provisioned", "gatewayUserId", result.User.GatewayUserID, "auditId", result.AuditID, ) } next.ServeHTTP(w, r.WithContext(auth.WithUser(r.Context(), result.User))) }) } func (s *Server) resolveOIDCUserProjection(ctx context.Context, r *http.Request, user *auth.User) (store.ResolveOrProvisionOIDCUserResult, error) { runtime := s.currentIdentityRuntime() if runtime == nil { return store.ResolveOrProvisionOIDCUserResult{}, errors.New("active identity runtime is unavailable") } return s.resolveOIDCUserProjectionForRuntime(ctx, r, user, runtime) } func (s *Server) resolveOIDCUserProjectionForRuntime(ctx context.Context, r *http.Request, user *auth.User, runtime *identityRequestRuntime) (store.ResolveOrProvisionOIDCUserResult, error) { if s.oidcUserResolver == nil { return store.ResolveOrProvisionOIDCUserResult{}, errors.New("OIDC user resolver is unavailable") } revision := runtime.Revision tenantName := "" tenantSlug := "" tenantMetadataStatus := "" tenantMetadataVersion := "" tenantMetadataETag := "" var tenantMetadataUpdatedAt time.Time if revision.TenantMode == "multi_tenant" { if runtime.TenantContext == nil { return store.ResolveOrProvisionOIDCUserResult{}, errors.New("tenant context runtime is unavailable") } var cached store.OIDCTenantBindingContext bindingDisabled := false if reader, ok := s.oidcUserResolver.(oidcTenantBindingReader); ok { cached, _ = reader.OIDCTenantBindingContext(ctx, revision.Issuer, revision.ApplicationID, user.TenantID) bindingDisabled = cached.ID != "" && cached.AccessStatus != "active" if !bindingDisabled && cached.MetadataStatus == "synced" && cached.NextSyncAt.After(time.Now()) { tenantName, tenantSlug = cached.DisplayName, cached.Slug tenantMetadataStatus, tenantMetadataVersion = "synced", cached.Version tenantMetadataETag, tenantMetadataUpdatedAt = cached.ETag, cached.MetadataUpdatedAt } } if tenantMetadataStatus == "synced" { return s.resolveOIDCUserProjectionWithTenantContext(ctx, r, user, revision, tenantName, tenantSlug, tenantMetadataStatus, tenantMetadataVersion, tenantMetadataETag, tenantMetadataUpdatedAt) } etag := cached.ETag if bindingDisabled { // A revoked binding can only be reactivated after a fresh positive // Tenant Context response. Do not let a stale 304 or cached profile // reopen access after reassignment. etag = "" } tenant, unchanged, err := runtime.TenantContext.Get(ctx, user.TenantID, etag) switch { case err == nil && unchanged && !bindingDisabled && cached.MetadataStatus == "synced": tenantName, tenantSlug = cached.DisplayName, cached.Slug tenantMetadataStatus, tenantMetadataVersion = "synced", cached.Version tenantMetadataETag, tenantMetadataUpdatedAt = cached.ETag, cached.MetadataUpdatedAt case err == nil && tenant.Active(): tenantName = tenant.DisplayName tenantSlug = tenant.Slug tenantMetadataStatus = "synced" tenantMetadataVersion = tenant.Version tenantMetadataETag = tenant.ETag tenantMetadataUpdatedAt = tenant.UpdatedAt case err == nil, errors.Is(err, identity.ErrTenantContextNotFound): return store.ResolveOrProvisionOIDCUserResult{}, store.ErrOIDCTenantUnavailable case errors.Is(err, identity.ErrTenantContextUnavailable): if bindingDisabled { return store.ResolveOrProvisionOIDCUserResult{}, store.ErrOIDCTenantUnavailable } if cached.MetadataStatus == "synced" { tenantName, tenantSlug = cached.DisplayName, cached.Slug tenantMetadataStatus, tenantMetadataVersion = "synced", cached.Version tenantMetadataETag, tenantMetadataUpdatedAt = cached.ETag, cached.MetadataUpdatedAt } else { tenantName = oidcTenantPlaceholderName(user.TenantID) tenantMetadataStatus = "metadata_pending" } default: return store.ResolveOrProvisionOIDCUserResult{}, err } } return s.resolveOIDCUserProjectionWithTenantContext(ctx, r, user, revision, tenantName, tenantSlug, tenantMetadataStatus, tenantMetadataVersion, tenantMetadataETag, tenantMetadataUpdatedAt) } func (s *Server) resolveOIDCUserProjectionWithTenantContext( ctx context.Context, r *http.Request, user *auth.User, revision identity.Revision, tenantName, tenantSlug, tenantMetadataStatus, tenantMetadataVersion, tenantMetadataETag string, tenantMetadataUpdatedAt time.Time, ) (store.ResolveOrProvisionOIDCUserResult, error) { return s.oidcUserResolver.ResolveOrProvisionOIDCUser(ctx, store.ResolveOrProvisionOIDCUserInput{ Issuer: revision.Issuer, ApplicationID: revision.ApplicationID, Subject: user.ID, Username: user.Username, Roles: user.Roles, TenantID: user.TenantID, TenantMode: revision.TenantMode, TenantName: tenantName, TenantSlug: tenantSlug, TenantMetadataStatus: tenantMetadataStatus, TenantMetadataVersion: tenantMetadataVersion, TenantMetadataETag: tenantMetadataETag, TenantMetadataUpdatedAt: tenantMetadataUpdatedAt, OIDCClientID: user.OIDCClientID, GatewayTenantKey: revision.LocalTenantKey, ProvisioningEnabled: revision.JITEnabled, RequestIP: limitAuditText(requestIP(r), 128), UserAgent: limitAuditText(r.UserAgent(), 512), }) } func oidcTenantPlaceholderName(tenantID string) string { compact := strings.ReplaceAll(strings.TrimSpace(tenantID), "-", "") if len(compact) > 8 { compact = compact[:8] } return "认证中心租户 " + compact } func (s *Server) writeOIDCUserResolutionError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, store.ErrOIDCUserNotProvisioned): writeError(w, http.StatusForbidden, "该账号尚未开通 EasyAI Gateway", errorCodeGatewayUserNotProvisioned) case errors.Is(err, store.ErrOIDCUserDisabled): writeError(w, http.StatusForbidden, "该 Gateway 账号已停用,请联系管理员", errorCodeGatewayUserDisabled) case errors.Is(err, store.ErrOIDCTenantUnavailable): writeError(w, http.StatusServiceUnavailable, "Gateway 租户尚未就绪,请联系管理员", errorCodeGatewayTenantUnavailable) default: s.logger.ErrorContext(r.Context(), "resolve OIDC gateway user failed", "error", err, "path", r.URL.Path) writeError(w, http.StatusServiceUnavailable, "Gateway 账号初始化失败,请稍后重试", errorCodeGatewayProvisioningFailed) } } func limitAuditText(value string, limit int) string { value = strings.TrimSpace(value) runes := []rune(value) if limit > 0 && len(runes) > limit { return string(runes[:limit]) } return value }