diff --git a/Dockerfile b/Dockerfile index 77ae13c23..84704e0c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ RUN dotnet publish --no-restore src/Squidex/Squidex.csproj --output /build/ --co # # Stage 2, Build Frontend # -FROM buildkite/puppeteer:8.0.0 as frontend +FROM buildkite/puppeteer:5.2.1 as frontend WORKDIR /src @@ -48,8 +48,6 @@ COPY frontend/package*.json /tmp/ # Copy patches for broken npm packages COPY frontend/patches /tmp/patches -RUN cd /tmp/patches && dir - # Install Node packages RUN cd /tmp && npm set unsafe-perm true && npm install --loglevel=error diff --git a/backend/src/Squidex.Domain.Users.MongoDb/Infrastructure/MongoPersistedGrantStore.cs b/backend/src/Squidex.Domain.Users.MongoDb/Infrastructure/MongoPersistedGrantStore.cs deleted file mode 100644 index 79eca1a9e..000000000 --- a/backend/src/Squidex.Domain.Users.MongoDb/Infrastructure/MongoPersistedGrantStore.cs +++ /dev/null @@ -1,113 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using IdentityServer4.Models; -using IdentityServer4.Stores; -using MongoDB.Bson; -using MongoDB.Bson.Serialization; -using MongoDB.Driver; -using Squidex.Infrastructure.MongoDb; - -namespace Squidex.Domain.Users.MongoDb.Infrastructure -{ - public class MongoPersistedGrantStore : MongoRepositoryBase, IPersistedGrantStore - { - static MongoPersistedGrantStore() - { - BsonClassMap.RegisterClassMap(cm => - { - cm.AutoMap(); - - cm.MapIdProperty(x => x.Key); - }); - } - - public MongoPersistedGrantStore(IMongoDatabase database) - : base(database) - { - } - - protected override string CollectionName() - { - return "Identity_PersistedGrants"; - } - - protected override Task SetupCollectionAsync(IMongoCollection collection, CancellationToken ct = default) - { - return collection.Indexes.CreateManyAsync(new[] - { - new CreateIndexModel(Index.Ascending(x => x.ClientId)), - new CreateIndexModel(Index.Ascending(x => x.SubjectId)) - }, ct); - } - - public async Task> GetAllAsync(string subjectId) - { - return await Collection.Find(x => x.SubjectId == subjectId).ToListAsync(); - } - - public async Task> GetAllAsync(PersistedGrantFilter filter) - { - return await Collection.Find(CreateFilter(filter)).ToListAsync(); - } - - public Task GetAsync(string key) - { - return Collection.Find(x => x.Key == key).FirstOrDefaultAsync(); - } - - public Task RemoveAllAsync(PersistedGrantFilter filter) - { - return Collection.DeleteManyAsync(CreateFilter(filter)); - } - - public Task RemoveAsync(string key) - { - return Collection.DeleteManyAsync(x => x.Key == key); - } - - public Task StoreAsync(PersistedGrant grant) - { - return Collection.ReplaceOneAsync(x => x.Key == grant.Key, grant, UpsertReplace); - } - - private static FilterDefinition CreateFilter(PersistedGrantFilter filter) - { - var filters = new List>(); - - if (!string.IsNullOrWhiteSpace(filter.ClientId)) - { - filters.Add(Filter.Eq(x => x.ClientId, filter.ClientId)); - } - - if (!string.IsNullOrWhiteSpace(filter.SessionId)) - { - filters.Add(Filter.Eq(x => x.SessionId, filter.SessionId)); - } - - if (!string.IsNullOrWhiteSpace(filter.SubjectId)) - { - filters.Add(Filter.Eq(x => x.SubjectId, filter.SubjectId)); - } - - if (!string.IsNullOrWhiteSpace(filter.Type)) - { - filters.Add(Filter.Eq(x => x.Type, filter.Type)); - } - - if (filters.Count > 0) - { - return Filter.And(filters); - } - - return new BsonDocument(); - } - } -} diff --git a/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj b/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj index b225634bd..7027871a3 100644 --- a/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj +++ b/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj @@ -18,7 +18,6 @@ - diff --git a/backend/src/Squidex.Domain.Users/DefaultKeyStore.cs b/backend/src/Squidex.Domain.Users/DefaultKeyStore.cs index 694d9df9d..5a7e305fe 100644 --- a/backend/src/Squidex.Domain.Users/DefaultKeyStore.cs +++ b/backend/src/Squidex.Domain.Users/DefaultKeyStore.cs @@ -6,23 +6,20 @@ // ========================================================================== using System; -using System.Collections.Generic; using System.Security.Cryptography; using System.Threading.Tasks; using IdentityModel; -using IdentityServer4.Models; -using IdentityServer4.Stores; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using OpenIddict.Server; using Squidex.Infrastructure; using Squidex.Infrastructure.States; namespace Squidex.Domain.Users { - public sealed class DefaultKeyStore : ISigningCredentialStore, IValidationKeysStore + public sealed class DefaultKeyStore : IConfigureOptions { private readonly ISnapshotStore store; - private SigningCredentials? cachedKey; - private SecurityKeyInfo[]? cachedKeyInfo; [CollectionName("Identity_Keys")] public sealed class State @@ -39,32 +36,28 @@ namespace Squidex.Domain.Users this.store = store; } - public async Task GetSigningCredentialsAsync() + public void Configure(OpenIddictServerOptions options) { - var (_, key) = await GetOrCreateKeyAsync(); + var securityKey = GetOrCreateKeyAsync().Result; - return key; - } - - public async Task> GetValidationKeysAsync() - { - var (info, _) = await GetOrCreateKeyAsync(); + options.SigningCredentials.Add( + new SigningCredentials(securityKey, + SecurityAlgorithms.RsaSha256)); - return info; + options.EncryptionCredentials.Add(new EncryptingCredentials(securityKey, + SecurityAlgorithms.RsaOAEP, + SecurityAlgorithms.Aes256CbcHmacSha512)); } - private async Task<(SecurityKeyInfo[], SigningCredentials)> GetOrCreateKeyAsync() + private async Task GetOrCreateKeyAsync() { - if (cachedKey != null && cachedKeyInfo != null) - { - return (cachedKeyInfo, cachedKey); - } - var (state, _, _) = await store.ReadAsync(default); RsaSecurityKey securityKey; - if (state == null) + var attempts = 0; + + while (state == null && attempts < 10) { securityKey = new RsaSecurityKey(RSA.Create(2048)) { @@ -88,7 +81,7 @@ namespace Squidex.Domain.Users { await store.WriteAsync(default, state, 0, 0); - return CreateCredentialsPair(securityKey); + return securityKey; } catch (InconsistentStateException) { @@ -106,19 +99,7 @@ namespace Squidex.Domain.Users KeyId = state.Key }; - return CreateCredentialsPair(securityKey); - } - - private (SecurityKeyInfo[], SigningCredentials) CreateCredentialsPair(RsaSecurityKey securityKey) - { - cachedKey = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256); - - cachedKeyInfo = new[] - { - new SecurityKeyInfo { Key = cachedKey.Key, SigningAlgorithm = cachedKey.Algorithm } - }; - - return (cachedKeyInfo, cachedKey); + return securityKey; } } } diff --git a/backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerExtensions.cs b/backend/src/Squidex.Domain.Users/InMemory/Extensions.cs similarity index 58% rename from backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerExtensions.cs rename to backend/src/Squidex.Domain.Users/InMemory/Extensions.cs index 713517923..bd8382a66 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerExtensions.cs +++ b/backend/src/Squidex.Domain.Users/InMemory/Extensions.cs @@ -5,17 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Microsoft.AspNetCore.Builder; +using System.Threading.Tasks; -namespace Squidex.Areas.IdentityServer.Config +namespace Squidex.Domain.Users.InMemory { - public static class IdentityServerExtensions + public static class Extensions { - public static IApplicationBuilder UseSquidexIdentityServer(this IApplicationBuilder app) + public static ValueTask AsValueTask(this T value) { - app.UseIdentityServer(); - - return app; + return new ValueTask(value); } } } diff --git a/backend/src/Squidex.Domain.Users/InMemory/ImmutableApplication.cs b/backend/src/Squidex.Domain.Users/InMemory/ImmutableApplication.cs new file mode 100644 index 000000000..f49aacdd7 --- /dev/null +++ b/backend/src/Squidex.Domain.Users/InMemory/ImmutableApplication.cs @@ -0,0 +1,58 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using OpenIddict.Abstractions; + +namespace Squidex.Domain.Users.InMemory +{ + public sealed class ImmutableApplication + { + public string Id { get; } + + public string? ClientId { get; } + + public string? ClientSecret { get; } + + public string? ConsentType { get; } + + public string? DisplayName { get; } + + public string? Type { get; } + + public ImmutableDictionary DisplayNames { get; } + + public ImmutableArray Permissions { get; } + + public ImmutableArray PostLogoutRedirectUris { get; } + + public ImmutableArray RedirectUris { get; } + + public ImmutableArray Requirements { get; } + + public ImmutableDictionary Properties { get; } + + public ImmutableApplication(string id, OpenIddictApplicationDescriptor descriptor) + { + Id = id; + ClientId = descriptor.ClientId; + ClientSecret = descriptor.ClientSecret; + ConsentType = descriptor.ConsentType; + DisplayName = descriptor.DisplayName; + DisplayNames = descriptor.DisplayNames.ToImmutableDictionary(); + Permissions = descriptor.Permissions.ToImmutableArray(); + PostLogoutRedirectUris = descriptor.PostLogoutRedirectUris.Select(x => x.ToString()).ToImmutableArray(); + Properties = descriptor.Properties.ToImmutableDictionary(); + RedirectUris = descriptor.RedirectUris.Select(x => x.ToString()).ToImmutableArray(); + Requirements = descriptor.Requirements.ToImmutableArray(); + Type = descriptor.Type; + } + } +} diff --git a/backend/src/Squidex.Domain.Users/InMemory/ImmutableScope.cs b/backend/src/Squidex.Domain.Users/InMemory/ImmutableScope.cs new file mode 100644 index 000000000..c6c823689 --- /dev/null +++ b/backend/src/Squidex.Domain.Users/InMemory/ImmutableScope.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Immutable; +using System.Globalization; +using System.Text.Json; +using OpenIddict.Abstractions; + +namespace Squidex.Domain.Users.InMemory +{ + public sealed class ImmutableScope + { + public string Id { get; } + + public string? Name { get; } + + public string? Description { get; } + + public string? DisplayName { get; } + + public ImmutableDictionary Descriptions { get; } + + public ImmutableDictionary DisplayNames { get; } + + public ImmutableDictionary Properties { get; } + + public ImmutableArray Resources { get; } + + public ImmutableScope(string id, OpenIddictScopeDescriptor descriptor) + { + Id = id; + Description = descriptor.Description; + Descriptions = descriptor.Descriptions.ToImmutableDictionary(); + Name = descriptor.Name; + DisplayName = descriptor.DisplayName; + DisplayNames = descriptor.DisplayNames.ToImmutableDictionary(); + Properties = descriptor.Properties.ToImmutableDictionary(); + Resources = descriptor.Resources.ToImmutableArray(); + } + } +} diff --git a/backend/src/Squidex.Domain.Users/InMemory/InMemoryApplicationStore.cs b/backend/src/Squidex.Domain.Users/InMemory/InMemoryApplicationStore.cs new file mode 100644 index 000000000..f8e1ac2c9 --- /dev/null +++ b/backend/src/Squidex.Domain.Users/InMemory/InMemoryApplicationStore.cs @@ -0,0 +1,241 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenIddict.Abstractions; + +namespace Squidex.Domain.Users.InMemory +{ + public class InMemoryApplicationStore : IOpenIddictApplicationStore + { + private readonly List applications; + + public InMemoryApplicationStore(params (string Id, OpenIddictApplicationDescriptor Descriptor)[] applications) + { + this.applications = applications.Select(x => new ImmutableApplication(x.Id, x.Descriptor)).ToList(); + } + + public InMemoryApplicationStore(IEnumerable<(string Id, OpenIddictApplicationDescriptor Descriptor)> applications) + { + this.applications = applications.Select(x => new ImmutableApplication(x.Id, x.Descriptor)).ToList(); + } + + public virtual ValueTask CountAsync(CancellationToken cancellationToken) + { + return new ValueTask(applications.Count); + } + + public virtual ValueTask CountAsync(Func, IQueryable> query, CancellationToken cancellationToken) + { + return query(applications.AsQueryable()).LongCount().AsValueTask(); + } + + public virtual ValueTask GetAsync(Func, TState, IQueryable> query, TState state, CancellationToken cancellationToken) + { + var result = query(applications.AsQueryable(), state).First(); + + return result.AsValueTask(); + } + + public virtual ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken) + { + var result = applications.Find(x => x.Id == identifier); + + return result.AsValueTask(); + } + + public virtual ValueTask FindByClientIdAsync(string identifier, CancellationToken cancellationToken) + { + var result = applications.Find(x => x.ClientId == identifier); + + return result.AsValueTask(); + } + + public virtual async IAsyncEnumerable FindByPostLogoutRedirectUriAsync(string address, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var result = applications.Where(x => x.PostLogoutRedirectUris.Contains(address)); + + foreach (var item in result) + { + yield return await Task.FromResult(item); + } + } + + public virtual async IAsyncEnumerable FindByRedirectUriAsync(string address, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var result = applications.Where(x => x.RedirectUris.Contains(address)); + + foreach (var item in result) + { + yield return await Task.FromResult(item); + } + } + + public virtual async IAsyncEnumerable ListAsync(int? count, int? offset, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var result = applications; + + foreach (var item in result) + { + yield return await Task.FromResult(item); + } + } + + public virtual async IAsyncEnumerable ListAsync(Func, TState, IQueryable> query, TState state, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var result = query(applications.AsQueryable(), state); + + foreach (var item in result) + { + yield return await Task.FromResult(item); + } + } + + public virtual ValueTask GetIdAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + return new ValueTask(application.Id); + } + + public virtual ValueTask GetClientIdAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + return application.ClientId.AsValueTask(); + } + + public virtual ValueTask GetClientSecretAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + return application.ClientSecret.AsValueTask(); + } + + public virtual ValueTask GetClientTypeAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + return application.Type.AsValueTask(); + } + + public virtual ValueTask GetConsentTypeAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + return application.ConsentType.AsValueTask(); + } + + public virtual ValueTask GetDisplayNameAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + return application.DisplayName.AsValueTask(); + } + + public virtual ValueTask> GetDisplayNamesAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + return application.DisplayNames.AsValueTask(); + } + + public virtual ValueTask> GetPermissionsAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + return application.Permissions.AsValueTask(); + } + + public virtual ValueTask> GetPostLogoutRedirectUrisAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + return application.PostLogoutRedirectUris.AsValueTask(); + } + + public virtual ValueTask> GetRedirectUrisAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + return application.RedirectUris.AsValueTask(); + } + + public virtual ValueTask> GetRequirementsAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + return application.Requirements.AsValueTask(); + } + + public virtual ValueTask> GetPropertiesAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + return application.Properties.AsValueTask(); + } + + public virtual ValueTask CreateAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask UpdateAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask DeleteAsync(ImmutableApplication application, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask InstantiateAsync(CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetClientIdAsync(ImmutableApplication application, string? identifier, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetClientSecretAsync(ImmutableApplication application, string? secret, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetClientTypeAsync(ImmutableApplication application, string? type, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetConsentTypeAsync(ImmutableApplication application, string? type, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetDisplayNameAsync(ImmutableApplication application, string? name, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetDisplayNamesAsync(ImmutableApplication application, ImmutableDictionary names, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetPermissionsAsync(ImmutableApplication application, ImmutableArray permissions, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetPostLogoutRedirectUrisAsync(ImmutableApplication application, ImmutableArray addresses, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetRedirectUrisAsync(ImmutableApplication application, ImmutableArray addresses, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetPropertiesAsync(ImmutableApplication application, ImmutableDictionary properties, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetRequirementsAsync(ImmutableApplication application, ImmutableArray requirements, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + } +} diff --git a/backend/src/Squidex.Domain.Users/InMemory/InMemoryScopeStore.cs b/backend/src/Squidex.Domain.Users/InMemory/InMemoryScopeStore.cs new file mode 100644 index 000000000..e437d0878 --- /dev/null +++ b/backend/src/Squidex.Domain.Users/InMemory/InMemoryScopeStore.cs @@ -0,0 +1,201 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenIddict.Abstractions; + +namespace Squidex.Domain.Users.InMemory +{ + public class InMemoryScopeStore : IOpenIddictScopeStore + { + private readonly List scopes; + + public InMemoryScopeStore(params (string Id, OpenIddictScopeDescriptor Descriptor)[] scopes) + { + this.scopes = scopes.Select(x => new ImmutableScope(x.Id, x.Descriptor)).ToList(); + } + + public InMemoryScopeStore(IEnumerable<(string Id, OpenIddictScopeDescriptor Descriptor)> scopes) + { + this.scopes = scopes.Select(x => new ImmutableScope(x.Id, x.Descriptor)).ToList(); + } + + public virtual ValueTask CountAsync(CancellationToken cancellationToken) + { + return new ValueTask(scopes.Count); + } + + public virtual ValueTask CountAsync(Func, IQueryable> query, CancellationToken cancellationToken) + { + return query(scopes.AsQueryable()).LongCount().AsValueTask(); + } + + public virtual ValueTask GetAsync(Func, TState, IQueryable> query, TState state, CancellationToken cancellationToken) + { + var result = query(scopes.AsQueryable(), state).First(); + + return result.AsValueTask(); + } + + public virtual ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken) + { + var result = scopes.Find(x => x.Id == identifier); + + return result.AsValueTask(); + } + + public virtual ValueTask FindByNameAsync(string name, CancellationToken cancellationToken) + { + var result = scopes.Find(x => x.Name == name); + + return result.AsValueTask(); + } + + public virtual async IAsyncEnumerable FindByNamesAsync(ImmutableArray names, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var result = scopes.Where(x => x.Name != null && names.Contains(x.Name)); + + foreach (var item in result) + { + yield return await Task.FromResult(item); + } + } + + public virtual async IAsyncEnumerable FindByResourceAsync(string resource, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var result = scopes.Where(x => x.Resources.Contains(resource)); + + foreach (var item in result) + { + yield return await Task.FromResult(item); + } + } + + public virtual async IAsyncEnumerable ListAsync(int? count, int? offset, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var result = scopes; + + foreach (var item in result) + { + yield return await Task.FromResult(item); + } + } + + public virtual async IAsyncEnumerable ListAsync(Func, TState, IQueryable> query, TState state, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var result = query(scopes.AsQueryable(), state); + + foreach (var item in result) + { + yield return await Task.FromResult(item); + } + } + + public virtual ValueTask GetIdAsync(ImmutableScope scope, CancellationToken cancellationToken) + { + return new ValueTask(scope.Id); + } + + public virtual ValueTask GetNameAsync(ImmutableScope scope, CancellationToken cancellationToken) + { + return scope.Name.AsValueTask(); + } + + public virtual ValueTask GetDescriptionAsync(ImmutableScope scope, CancellationToken cancellationToken) + { + return scope.Description.AsValueTask(); + } + + public virtual ValueTask GetDisplayNameAsync(ImmutableScope scope, CancellationToken cancellationToken) + { + return scope.DisplayName.AsValueTask(); + } + + public virtual ValueTask> GetDescriptionsAsync(ImmutableScope scope, CancellationToken cancellationToken) + { + return scope.Descriptions.AsValueTask(); + } + + public virtual ValueTask> GetDisplayNamesAsync(ImmutableScope scope, CancellationToken cancellationToken) + { + return scope.DisplayNames.AsValueTask(); + } + + public virtual ValueTask> GetPropertiesAsync(ImmutableScope scope, CancellationToken cancellationToken) + { + return scope.Properties.AsValueTask(); + } + + public virtual ValueTask> GetResourcesAsync(ImmutableScope scope, CancellationToken cancellationToken) + { + return scope.Resources.AsValueTask(); + } + + public virtual ValueTask CreateAsync(ImmutableScope scope, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask UpdateAsync(ImmutableScope scope, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask DeleteAsync(ImmutableScope scope, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask InstantiateAsync(CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetDescriptionAsync(ImmutableScope scope, string? description, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetDescriptionsAsync(ImmutableScope scope, ImmutableDictionary descriptions, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetDisplayNameAsync(ImmutableScope scope, string? name, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetDisplayNamesAsync(ImmutableScope scope, ImmutableDictionary names, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetNameAsync(ImmutableScope scope, string? name, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetPropertiesAsync(ImmutableScope scope, ImmutableDictionary properties, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public virtual ValueTask SetResourcesAsync(ImmutableScope scope, ImmutableArray resources, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + } +} diff --git a/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj b/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj index edeb2bb09..173b054b3 100644 --- a/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj +++ b/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj @@ -16,9 +16,10 @@ - + + diff --git a/backend/src/Squidex.Infrastructure/Security/Extensions.cs b/backend/src/Squidex.Infrastructure/Security/Extensions.cs index 0d27ddf74..d359f96cd 100644 --- a/backend/src/Squidex.Infrastructure/Security/Extensions.cs +++ b/backend/src/Squidex.Infrastructure/Security/Extensions.cs @@ -17,18 +17,18 @@ namespace Squidex.Infrastructure.Security { var subjectId = principal.OpenIdSubject(); - if (!string.IsNullOrWhiteSpace(subjectId)) - { - return RefToken.User(subjectId); - } - var clientId = principal.OpenIdClientId(); - if (!string.IsNullOrWhiteSpace(clientId)) + if (!string.IsNullOrWhiteSpace(clientId) && (string.Equals(clientId, subjectId, StringComparison.Ordinal) || string.IsNullOrWhiteSpace(subjectId))) { return RefToken.Client(clientId); } + if (!string.IsNullOrWhiteSpace(subjectId)) + { + return RefToken.User(subjectId); + } + return null; } diff --git a/backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs b/backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs index e204ab5bf..9547904da 100644 --- a/backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs +++ b/backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs @@ -9,26 +9,26 @@ namespace Squidex.Shared.Identity { public static class SquidexClaimTypes { - public static readonly string ClientSecret = "urn:squidex:clientSecret"; + public const string ClientSecret = "urn:squidex:clientSecret"; - public static readonly string Consent = "urn:squidex:consent"; + public const string Consent = "urn:squidex:consent"; - public static readonly string ConsentForEmails = "urn:squidex:consent:emails"; + public const string ConsentForEmails = "urn:squidex:consent:emails"; - public static readonly string CustomPrefix = "urn:squidex:custom"; + public const string CustomPrefix = "urn:squidex:custom"; - public static readonly string DisplayName = "urn:squidex:name"; + public const string DisplayName = "urn:squidex:name"; - public static readonly string Hidden = "urn:squidex:hidden"; + public const string Hidden = "urn:squidex:hidden"; - public static readonly string Invited = "urn:squidex:invited"; + public const string Invited = "urn:squidex:invited"; - public static readonly string NotifoKey = "urn:squidex:notifo"; + public const string NotifoKey = "urn:squidex:notifo"; - public static readonly string Permissions = "urn:squidex:permissions"; + public const string Permissions = "urn:squidex:permissions"; - public static readonly string PictureUrl = "urn:squidex:picture"; + public const string PictureUrl = "urn:squidex:picture"; - public static readonly string PictureUrlStore = "store"; + public const string PictureUrlStore = "store"; } } diff --git a/backend/src/Squidex.Web/Constants.cs b/backend/src/Squidex.Web/Constants.cs index 03fba81f6..a7adb4c07 100644 --- a/backend/src/Squidex.Web/Constants.cs +++ b/backend/src/Squidex.Web/Constants.cs @@ -14,32 +14,30 @@ namespace Squidex.Web { public static readonly string SecurityDefinition = "squidex-oauth-auth"; - public static readonly string ApiPrefix = "/api"; - - public static readonly string ApiScope = "squidex-api"; + public static readonly string OrleansClusterId = "squidex-v2"; - public static readonly string ApiSecurityScheme = "custom"; + public static readonly string ApiSecurityScheme = "API"; - public static readonly string OrleansClusterId = "squidex-v2"; + public static readonly string PrefixApi = "/api"; - public static readonly string OrleansPrefix = "/orleans"; + public static readonly string PrefixOrleans = "/orleans"; - public static readonly string PortalPrefix = "/portal"; + public static readonly string PrefixPortal = "/portal"; - public static readonly string EmailScope = "email"; + public static readonly string PrefixIdentityServer = "/identity-server"; - public static readonly string RoleScope = "role"; + public static readonly string ScopePermissions = "permissions"; - public static readonly string PermissionsScope = "permissions"; + public static readonly string ScopeProfile = "squidex-profile"; - public static readonly string ProfileScope = "squidex-profile"; + public static readonly string ScopeRole = "role"; - public static readonly string FrontendClient = DefaultClients.Frontend; + public static readonly string ScopeApi = "squidex-api"; - public static readonly string InternalClientId = "squidex-internal"; + public static readonly string ClientFrontendId = DefaultClients.Frontend; - public static readonly string InternalClientSecret = "squidex-internal".ToSha256Base64(); + public static readonly string ClientInternalId = "squidex-internal"; - public static readonly string IdentityServerPrefix = "/identity-server"; + public static readonly string ClientInternalSecret = "squidex-internal".ToSha256Base64(); } } diff --git a/backend/src/Squidex/Areas/Api/Config/IdentityServerPathMiddleware.cs b/backend/src/Squidex/Areas/Api/Config/IdentityServerPathMiddleware.cs deleted file mode 100644 index 9e63d229d..000000000 --- a/backend/src/Squidex/Areas/Api/Config/IdentityServerPathMiddleware.cs +++ /dev/null @@ -1,31 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Threading.Tasks; -using IdentityServer4.Extensions; -using Microsoft.AspNetCore.Http; -using Squidex.Web; - -namespace Squidex.Areas.Api.Config -{ - public sealed class IdentityServerPathMiddleware - { - private readonly RequestDelegate next; - - public IdentityServerPathMiddleware(RequestDelegate next) - { - this.next = next; - } - - public Task InvokeAsync(HttpContext context) - { - context.SetIdentityServerBasePath(Constants.IdentityServerPrefix); - - return next(context); - } - } -} diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/CommonProcessor.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/CommonProcessor.cs index 23a53cf83..39537e783 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/CommonProcessor.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/CommonProcessor.cs @@ -37,7 +37,7 @@ namespace Squidex.Areas.Api.Config.OpenApi public void Process(DocumentProcessorContext context) { - context.Document.BasePath = Constants.ApiPrefix; + context.Document.BasePath = Constants.PrefixApi; context.Document.Info.Version = version; context.Document.Info.ExtensionData = new Dictionary diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/SecurityProcessor.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/SecurityProcessor.cs index 2e0acd454..ad0d371aa 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/SecurityProcessor.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/SecurityProcessor.cs @@ -28,7 +28,7 @@ namespace Squidex.Areas.Api.Config.OpenApi Type = OpenApiSecuritySchemeType.OAuth2 }; - var tokenUrl = urlGenerator.BuildUrl($"{Constants.IdentityServerPrefix}/connect/token", false); + var tokenUrl = urlGenerator.BuildUrl($"{Constants.PrefixIdentityServer}/connect/token", false); security.TokenUrl = tokenUrl; @@ -48,7 +48,7 @@ namespace Squidex.Areas.Api.Config.OpenApi { security.Scopes = new Dictionary { - [Constants.ApiScope] = "Read and write access to the API" + [Constants.ScopeApi] = "Read and write access to the API" }; } diff --git a/backend/src/Squidex/Areas/Api/Startup.cs b/backend/src/Squidex/Areas/Api/Startup.cs index 6af4b30fe..18a65c9db 100644 --- a/backend/src/Squidex/Areas/Api/Startup.cs +++ b/backend/src/Squidex/Areas/Api/Startup.cs @@ -6,7 +6,6 @@ // ========================================================================== using Microsoft.AspNetCore.Builder; -using Squidex.Areas.Api.Config; using Squidex.Areas.Api.Config.OpenApi; using Squidex.Web; using Squidex.Web.Pipeline; @@ -17,10 +16,8 @@ namespace Squidex.Areas.Api { public static void ConfigureApi(this IApplicationBuilder app) { - app.Map(Constants.ApiPrefix, appApi => + app.Map(Constants.PrefixApi, appApi => { - appApi.UseMiddleware(); - appApi.UseAccessTokenQueryString(); appApi.UseRouting(); diff --git a/backend/src/Squidex/Areas/IdentityServer/Config/ApplicationExtensions.cs b/backend/src/Squidex/Areas/IdentityServer/Config/ApplicationExtensions.cs new file mode 100644 index 000000000..61c504369 --- /dev/null +++ b/backend/src/Squidex/Areas/IdentityServer/Config/ApplicationExtensions.cs @@ -0,0 +1,59 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text.Json; +using OpenIddict.Abstractions; +using Squidex.Shared; +using Squidex.Shared.Identity; +using Squidex.Shared.Users; + +namespace Squidex.Areas.IdentityServer.Config +{ + public static class ApplicationExtensions + { + public static OpenIddictApplicationDescriptor SetAdmin(this OpenIddictApplicationDescriptor application) + { + application.Properties[SquidexClaimTypes.Permissions] = CreateParameter(Enumerable.Repeat(Permissions.All, 1)); + + return application; + } + + public static OpenIddictApplicationDescriptor CopyClaims(this OpenIddictApplicationDescriptor application, IUser claims) + { + foreach (var group in claims.Claims.GroupBy(x => x.Type)) + { + application.Properties[group.Key] = CreateParameter(group.Select(x => x.Value)); + } + + return application; + } + + private static JsonElement CreateParameter(IEnumerable values) + { + return (JsonElement)new OpenIddictParameter(values.ToArray()); + } + + public static IEnumerable Claims(this IReadOnlyDictionary properties) + { + foreach (var (key, value) in properties) + { + var values = (string[]?)new OpenIddictParameter(value); + + if (values != null) + { + foreach (var claimValue in values) + { + yield return new Claim(key, claimValue); + } + } + } + } + } +} diff --git a/backend/src/Squidex/Areas/IdentityServer/Config/ApplicationManager.cs b/backend/src/Squidex/Areas/IdentityServer/Config/ApplicationManager.cs new file mode 100644 index 000000000..40be5a71f --- /dev/null +++ b/backend/src/Squidex/Areas/IdentityServer/Config/ApplicationManager.cs @@ -0,0 +1,33 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Core; + +namespace Squidex.Areas.IdentityServer.Config +{ + public sealed class ApplicationManager : OpenIddictApplicationManager where T : class + { + public ApplicationManager( + IOptionsMonitor options, + IOpenIddictApplicationCache cache, + IOpenIddictApplicationStoreResolver resolver, + ILogger> logger) + : base(cache, logger, options, resolver) + { + } + + protected override ValueTask ValidateClientSecretAsync(string secret, string comparand, CancellationToken cancellationToken = default) + { + return new ValueTask(string.Equals(secret, comparand)); + } + } +} diff --git a/backend/src/Squidex/Areas/IdentityServer/Config/DynamicApplicationStore.cs b/backend/src/Squidex/Areas/IdentityServer/Config/DynamicApplicationStore.cs new file mode 100644 index 000000000..0f71b8283 --- /dev/null +++ b/backend/src/Squidex/Areas/IdentityServer/Config/DynamicApplicationStore.cs @@ -0,0 +1,243 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using Squidex.Config; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities; +using Squidex.Domain.Users; +using Squidex.Domain.Users.InMemory; +using Squidex.Hosting; +using Squidex.Infrastructure; +using Squidex.Shared.Identity; +using Squidex.Shared.Users; +using Squidex.Web; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Squidex.Areas.IdentityServer.Config +{ + public class DynamicApplicationStore : InMemoryApplicationStore + { + private readonly IServiceProvider serviceProvider; + + public DynamicApplicationStore(IServiceProvider serviceProvider) + : base(CreateStaticClients(serviceProvider)) + { + Guard.NotNull(serviceProvider, nameof(serviceProvider)); + + this.serviceProvider = serviceProvider; + } + + public override async ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken) + { + var application = await base.FindByIdAsync(identifier, cancellationToken); + + if (application == null) + { + application = await GetDynamicAsync(identifier); + } + + return application; + } + + public override async ValueTask FindByClientIdAsync(string identifier, CancellationToken cancellationToken) + { + var application = await base.FindByClientIdAsync(identifier, cancellationToken); + + if (application == null) + { + application = await GetDynamicAsync(identifier); + } + + return application; + } + + private async Task GetDynamicAsync(string clientId) + { + var (appName, appClientId) = clientId.GetClientParts(); + + var appProvider = serviceProvider.GetRequiredService(); + + if (!string.IsNullOrWhiteSpace(appName) && !string.IsNullOrWhiteSpace(appClientId)) + { + var app = await appProvider.GetAppAsync(appName, true); + + var appClient = app?.Clients.GetOrDefault(appClientId); + + if (appClient != null) + { + return CreateClientFromApp(clientId, appClient); + } + } + + using (var scope = serviceProvider.CreateScope()) + { + var userService = scope.ServiceProvider.GetRequiredService(); + + var user = await userService.FindByIdAsync(clientId); + + if (user == null) + { + return null; + } + + var secret = user.Claims.ClientSecret(); + + if (!string.IsNullOrWhiteSpace(secret)) + { + return CreateClientFromUser(user, secret); + } + } + + return null; + } + + private static ImmutableApplication CreateClientFromUser(IUser user, string secret) + { + return new ImmutableApplication(user.Id, new OpenIddictApplicationDescriptor + { + DisplayName = $"{user.Email} Client", + ClientId = user.Id, + ClientSecret = secret, + Permissions = + { + Permissions.Endpoints.Token, + Permissions.GrantTypes.ClientCredentials, + Permissions.ResponseTypes.Token, + Permissions.Scopes.Email, + Permissions.Scopes.Profile, + Permissions.Scopes.Roles, + Permissions.Prefixes.Scope + Constants.ScopeApi, + Permissions.Prefixes.Scope + Constants.ScopePermissions + } + }.CopyClaims(user)); + } + + private static ImmutableApplication CreateClientFromApp(string id, AppClient appClient) + { + return new ImmutableApplication(id, new OpenIddictApplicationDescriptor + { + DisplayName = id, + ClientId = id, + ClientSecret = appClient.Secret, + Permissions = + { + Permissions.Endpoints.Token, + Permissions.GrantTypes.ClientCredentials, + Permissions.ResponseTypes.Token, + Permissions.Scopes.Email, + Permissions.Scopes.Profile, + Permissions.Scopes.Roles, + Permissions.Prefixes.Scope + Constants.ScopeApi, + Permissions.Prefixes.Scope + Constants.ScopePermissions + } + }); + } + + private static IEnumerable<(string, OpenIddictApplicationDescriptor)> CreateStaticClients(IServiceProvider serviceProvider) + { + var identityOptions = serviceProvider.GetRequiredService>().Value; + + var urlGenerator = serviceProvider.GetRequiredService(); + + var frontendId = Constants.ClientFrontendId; + + yield return (frontendId, new OpenIddictApplicationDescriptor + { + DisplayName = "Frontend Client", + ClientId = frontendId, + ClientSecret = null, + RedirectUris = + { + new Uri(urlGenerator.BuildUrl("login;")), + new Uri(urlGenerator.BuildUrl("client-callback-silent", false)), + new Uri(urlGenerator.BuildUrl("client-callback-popup", false)) + }, + PostLogoutRedirectUris = + { + new Uri(urlGenerator.BuildUrl("logout", false)) + }, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Logout, + Permissions.Endpoints.Token, + Permissions.GrantTypes.AuthorizationCode, + Permissions.GrantTypes.RefreshToken, + Permissions.ResponseTypes.Code, + Permissions.Scopes.Email, + Permissions.Scopes.Profile, + Permissions.Scopes.Roles, + Permissions.Prefixes.Scope + Constants.ScopeApi, + Permissions.Prefixes.Scope + Constants.ScopePermissions + }, + Type = ClientTypes.Public + }); + + var internalClientId = Constants.ClientInternalId; + + yield return (internalClientId, new OpenIddictApplicationDescriptor + { + DisplayName = "Internal Client", + ClientId = internalClientId, + ClientSecret = Constants.ClientInternalSecret, + RedirectUris = + { + new Uri(urlGenerator.BuildUrl($"{Constants.PrefixPortal}/signin-internal", false)), + new Uri(urlGenerator.BuildUrl($"{Constants.PrefixOrleans}/signin-internal", false)) + }, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Logout, + Permissions.Endpoints.Token, + Permissions.GrantTypes.Implicit, + Permissions.ResponseTypes.IdToken, + Permissions.ResponseTypes.IdTokenToken, + Permissions.ResponseTypes.Token, + Permissions.Scopes.Email, + Permissions.Scopes.Profile, + Permissions.Scopes.Roles, + Permissions.Prefixes.Scope + Constants.ScopeApi, + Permissions.Prefixes.Scope + Constants.ScopePermissions + }, + Type = ClientTypes.Public + }); + + if (!identityOptions.IsAdminClientConfigured()) + { + yield break; + } + + var adminClientId = identityOptions.AdminClientId; + + yield return (adminClientId, new OpenIddictApplicationDescriptor + { + DisplayName = "Admin Client", + ClientId = adminClientId, + ClientSecret = identityOptions.AdminClientSecret, + Permissions = + { + Permissions.Endpoints.Token, + Permissions.GrantTypes.ClientCredentials, + Permissions.ResponseTypes.Token, + Permissions.Scopes.Email, + Permissions.Scopes.Profile, + Permissions.Scopes.Roles, + Permissions.Prefixes.Scope + Constants.ScopeApi, + Permissions.Prefixes.Scope + Constants.ScopePermissions + } + }.SetAdmin()); + } + } +} diff --git a/backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerConfiguration.cs b/backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerConfiguration.cs new file mode 100644 index 000000000..9edb68d95 --- /dev/null +++ b/backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerConfiguration.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using OpenIddict.Abstractions; +using Squidex.Domain.Users.InMemory; +using Squidex.Web; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Squidex.Areas.IdentityServer.Config +{ + public static class IdentityServerConfiguration + { + public sealed class Scopes : InMemoryScopeStore + { + public Scopes() + : base(BuildScopes()) + { + } + + private static IEnumerable<(string, OpenIddictScopeDescriptor)> BuildScopes() + { + yield return (Constants.ScopeApi, new OpenIddictScopeDescriptor + { + Name = Constants.ScopeApi, + Resources = + { + Permissions.Prefixes.Scope + Constants.ScopeApi + } + }); + } + } + } +} diff --git a/backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs b/backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs index dd360387a..09dece9a4 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs @@ -5,20 +5,21 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Collections.Generic; -using IdentityModel; -using IdentityServer4.Models; -using IdentityServer4.Stores; +using System; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection.KeyManagement; using Microsoft.AspNetCore.DataProtection.Repositories; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; +using OpenIddict.Server; using Squidex.Domain.Users; -using Squidex.Shared.Identity; +using Squidex.Domain.Users.InMemory; +using Squidex.Hosting; using Squidex.Web; using Squidex.Web.Pipeline; +using static OpenIddict.Abstractions.OpenIddictConstants; namespace Squidex.Areas.IdentityServer.Config { @@ -40,74 +41,86 @@ namespace Squidex.Areas.IdentityServer.Config services.AddSingletonAs() .As(); - services.AddSingletonAs() - .As().As(); + services.AddSingletonAs() + .As>(); services.AddScopedAs() .As(); - services.AddSingletonAs() - .As>(); - services.AddScopedAs() .As>(); services.AddSingletonAs() .As(); - services.AddSingletonAs() - .As(); - - services.AddSingletonAs() - .As(); - services.AddSingletonAs() .AsSelf(); - services.AddIdentityServer(options => - { - options.UserInteraction.ErrorUrl = "/error/"; - }) - .AddAspNetIdentity() - .AddInMemoryApiScopes(GetApiScopes()) - .AddInMemoryIdentityResources(GetIdentityResources()); - } + services.ConfigureOptions(); - private static IEnumerable GetApiScopes() - { - yield return new ApiScope(Constants.ApiScope) + services.Configure(options => { - UserClaims = new List - { - JwtClaimTypes.Email, - JwtClaimTypes.Role, - SquidexClaimTypes.Permissions - } - }; - } + options.ClaimsIdentity.UserIdClaimType = Claims.Subject; + options.ClaimsIdentity.UserNameClaimType = Claims.Name; + options.ClaimsIdentity.RoleClaimType = Claims.Role; + }); - private static IEnumerable GetIdentityResources() - { - yield return new IdentityResources.OpenId(); - yield return new IdentityResources.Profile(); - yield return new IdentityResources.Email(); - yield return new IdentityResource(Constants.RoleScope, - new[] + services.AddOpenIddict() + .AddCore(builder => { - JwtClaimTypes.Role - }); - yield return new IdentityResource(Constants.PermissionsScope, - new[] + builder.Services.AddSingletonAs() + .As>(); + + builder.Services.AddSingletonAs() + .As>(); + + builder.ReplaceApplicationManager(typeof(ApplicationManager<>)); + }) + .AddServer(builder => { - SquidexClaimTypes.Permissions - }); - yield return new IdentityResource(Constants.ProfileScope, - new[] + builder + .SetAuthorizationEndpointUris("/connect/authorize") + .SetIntrospectionEndpointUris("/connect/introspect") + .SetLogoutEndpointUris("/connect/logout") + .SetTokenEndpointUris("/connect/token") + .SetUserinfoEndpointUris("/connect/userinfo"); + + builder.DisableAccessTokenEncryption(); + + builder.RegisterScopes( + Scopes.Email, + Scopes.Profile, + Scopes.Roles, + Constants.ScopeApi, + Constants.ScopePermissions); + + builder.SetAccessTokenLifetime(TimeSpan.FromDays(30)); + + builder.AllowClientCredentialsFlow(); + builder.AllowImplicitFlow(); + builder.AllowAuthorizationCodeFlow(); + + builder.UseAspNetCore() + // Disable it mainly for our tests. + .DisableTransportSecurityRequirement() + .EnableAuthorizationEndpointPassthrough() + .EnableLogoutEndpointPassthrough() + .EnableStatusCodePagesIntegration() + .EnableTokenEndpointPassthrough() + .EnableUserinfoEndpointPassthrough(); + }) + .AddValidation(options => { - SquidexClaimTypes.DisplayName, - SquidexClaimTypes.PictureUrl, - SquidexClaimTypes.NotifoKey + options.UseLocalServer(); + options.UseAspNetCore(); }); + + services.Configure((services, options) => + { + var urlGenerator = services.GetRequiredService(); + + options.Issuer = new Uri(urlGenerator.BuildUrl("/identity-server", false)); + }); } } } diff --git a/backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs b/backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs deleted file mode 100644 index 9e69a45a0..000000000 --- a/backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs +++ /dev/null @@ -1,253 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using IdentityServer4; -using IdentityServer4.Models; -using IdentityServer4.Stores; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Squidex.Config; -using Squidex.Domain.Apps.Core.Apps; -using Squidex.Domain.Apps.Entities; -using Squidex.Domain.Users; -using Squidex.Hosting; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Security; -using Squidex.Shared; -using Squidex.Shared.Identity; -using Squidex.Shared.Users; -using Squidex.Web; - -namespace Squidex.Areas.IdentityServer.Config -{ - public class LazyClientStore : IClientStore - { - private readonly IServiceProvider serviceProvider; - private readonly Dictionary staticClients = new Dictionary(StringComparer.OrdinalIgnoreCase); - - public LazyClientStore(IServiceProvider serviceProvider) - { - Guard.NotNull(serviceProvider, nameof(serviceProvider)); - - this.serviceProvider = serviceProvider; - - CreateStaticClients(); - } - - public async Task FindClientByIdAsync(string clientId) - { - var client = staticClients.GetOrDefault(clientId); - - if (client != null) - { - return client; - } - - var (appName, appClientId) = clientId.GetClientParts(); - - var appProvider = serviceProvider.GetRequiredService(); - - if (!string.IsNullOrWhiteSpace(appName) && !string.IsNullOrWhiteSpace(appClientId)) - { - var app = await appProvider.GetAppAsync(appName, true); - - var appClient = app?.Clients.GetOrDefault(appClientId); - - if (appClient != null) - { - return CreateClientFromApp(clientId, appClient); - } - } - - using (var scope = serviceProvider.CreateScope()) - { - var userService = scope.ServiceProvider.GetRequiredService(); - - var user = await userService.FindByIdAsync(clientId); - - if (user == null) - { - return null; - } - - var secret = user.Claims.ClientSecret(); - - if (!string.IsNullOrWhiteSpace(secret)) - { - return CreateClientFromUser(user, secret); - } - } - - return null; - } - - private static Client CreateClientFromUser(IUser user, string secret) - { - return new Client - { - ClientId = user.Id, - ClientName = $"{user.Email} Client", - ClientClaimsPrefix = null, - ClientSecrets = new List - { - new Secret(secret.Sha256()) - }, - AccessTokenLifetime = (int)TimeSpan.FromDays(30).TotalSeconds, - AllowedGrantTypes = GrantTypes.ClientCredentials, - AllowedScopes = new List - { - Constants.ApiScope, - Constants.RoleScope, - Constants.PermissionsScope - }, - Claims = GetClaims(user) - }; - } - - private static Client CreateClientFromApp(string id, AppClient appClient) - { - return new Client - { - ClientId = id, - ClientName = id, - ClientSecrets = new List - { - new Secret(appClient.Secret.Sha256()) - }, - AccessTokenLifetime = (int)TimeSpan.FromDays(30).TotalSeconds, - AllowedGrantTypes = GrantTypes.ClientCredentials, - AllowedScopes = new List - { - Constants.ApiScope, - Constants.RoleScope, - Constants.PermissionsScope - } - }; - } - - private void CreateStaticClients() - { - var identityOptions = serviceProvider.GetRequiredService>().Value; - - var urlGenerator = serviceProvider.GetRequiredService(); - - foreach (var client in CreateStaticClients(urlGenerator, identityOptions)) - { - staticClients[client.ClientId] = client; - } - } - - private static IEnumerable CreateStaticClients(IUrlGenerator urlGenerator, MyIdentityOptions identityOptions) - { - var frontendId = Constants.FrontendClient; - - yield return new Client - { - ClientId = frontendId, - ClientName = frontendId, - RedirectUris = new List - { - urlGenerator.BuildUrl("login;"), - urlGenerator.BuildUrl("client-callback-silent", false), - urlGenerator.BuildUrl("client-callback-popup", false) - }, - PostLogoutRedirectUris = new List - { - urlGenerator.BuildUrl("logout", false) - }, - AllowAccessTokensViaBrowser = true, - AllowedGrantTypes = GrantTypes.Implicit, - AllowedScopes = new List - { - IdentityServerConstants.StandardScopes.OpenId, - IdentityServerConstants.StandardScopes.Profile, - IdentityServerConstants.StandardScopes.Email, - Constants.ApiScope, - Constants.PermissionsScope, - Constants.ProfileScope, - Constants.RoleScope - }, - RequireConsent = false - }; - - var internalClient = Constants.InternalClientId; - - yield return new Client - { - ClientId = internalClient, - ClientName = internalClient, - ClientSecrets = new List - { - new Secret(Constants.InternalClientSecret) - }, - RedirectUris = new List - { - urlGenerator.BuildUrl($"{Constants.PortalPrefix}/signin-internal", false), - urlGenerator.BuildUrl($"{Constants.OrleansPrefix}/signin-internal", false) - }, - AccessTokenLifetime = (int)TimeSpan.FromDays(30).TotalSeconds, - AllowedGrantTypes = GrantTypes.ImplicitAndClientCredentials, - AllowedScopes = new List - { - IdentityServerConstants.StandardScopes.OpenId, - IdentityServerConstants.StandardScopes.Profile, - IdentityServerConstants.StandardScopes.Email, - Constants.ApiScope, - Constants.PermissionsScope, - Constants.ProfileScope, - Constants.RoleScope - }, - RequireConsent = false - }; - - if (identityOptions.IsAdminClientConfigured()) - { - var id = identityOptions.AdminClientId; - - yield return new Client - { - ClientId = id, - ClientName = id, - ClientSecrets = new List - { - new Secret(identityOptions.AdminClientSecret.Sha256()) - }, - AccessTokenLifetime = (int)TimeSpan.FromDays(30).TotalSeconds, - AllowedGrantTypes = GrantTypes.ClientCredentials, - AllowedScopes = new List - { - Constants.ApiScope, - Constants.RoleScope, - Constants.PermissionsScope - }, - Claims = new List - { - new ClientClaim(SquidexClaimTypes.Permissions, Permissions.All) - } - }; - } - } - - private static List GetClaims(IUser user) - { - var claims = new List - { - new ClientClaim(OpenIdClaims.Subject, user.Id) - }; - - claims.AddRange( - user.Claims.Where(x => x.Type == SquidexClaimTypes.Permissions) - .Select(x => new ClientClaim(x.Type, x.Value))); - - return claims; - } - } -} diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs index 7beaf77fc..3a421bc1c 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs @@ -1,4 +1,4 @@ -// ========================================================================== +// ========================================================================== // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex UG (haftungsbeschraenkt) @@ -8,11 +8,6 @@ using System; using System.Linq; using System.Threading.Tasks; -using IdentityModel; -using IdentityServer4; -using IdentityServer4.Extensions; -using IdentityServer4.Models; -using IdentityServer4.Services; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; @@ -31,15 +26,12 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account { private readonly IUserService userService; private readonly MyIdentityOptions identityOptions; - private readonly IIdentityServerInteractionService interactions; public AccountController( IUserService userService, - IOptions identityOptions, - IIdentityServerInteractionService interactions) + IOptions identityOptions) { this.identityOptions = identityOptions.Value; - this.interactions = interactions; this.userService = userService; } @@ -130,24 +122,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account { await SignInManager.SignOutAsync(); - if (User.Identity?.IsAuthenticated == true) - { - var provider = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value; - - if (provider != null && provider != IdentityServerConstants.LocalIdentityProvider) - { - var providerSupportsSignout = await HttpContext.GetSchemeSupportsSignOutAsync(provider); - - if (providerSupportsSignout) - { - return SignOut(provider); - } - } - } - - var context = await interactions.GetLogoutContextAsync(logoutId); - - return RedirectToLogoutUrl(context); + return Redirect("~/../"); } [HttpGet] @@ -341,17 +316,5 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account return (result.Succeeded, result.IsLockedOut); } - - private IActionResult RedirectToLogoutUrl(LogoutRequest context) - { - if (!string.IsNullOrWhiteSpace(context.PostLogoutRedirectUri)) - { - return Redirect(context.PostLogoutRedirectUri); - } - else - { - return Redirect("~/../"); - } - } } } diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Connect/ConnectController.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Connect/ConnectController.cs new file mode 100644 index 000000000..ecb126e97 --- /dev/null +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Connect/ConnectController.cs @@ -0,0 +1,287 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using Squidex.Areas.IdentityServer.Config; +using Squidex.Areas.IdentityServer.Controllers; +using Squidex.Domain.Users; +using Squidex.Shared.Identity; +using Squidex.Web; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Notifo.Areas.Account.Controllers +{ + public class AuthorizationController : IdentityServerController + { + private readonly IOpenIddictScopeManager scopeManager; + private readonly IOpenIddictApplicationManager applicationManager; + private readonly IUserService userService; + + public AuthorizationController( + IOpenIddictScopeManager scopeManager, + IOpenIddictApplicationManager applicationManager, + IUserService userService) + { + this.scopeManager = scopeManager; + this.applicationManager = applicationManager; + this.userService = userService; + } + + [HttpPost("connect/token")] + [Produces("application/json")] + public async Task Exchange() + { + var request = HttpContext.GetOpenIddictServerRequest(); + + if (request == null) + { + throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + } + + if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType() || request.IsImplicitFlow()) + { + var principal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal; + + if (principal == null) + { + throw new InvalidOperationException("The user details cannot be retrieved."); + } + + var user = await userService.GetAsync(principal); + + if (user == null) + { + return Forbid( + new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The token is no longer valid." + }), + OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + if (!await SignInManager.CanSignInAsync((IdentityUser)user.Identity)) + { + return Forbid( + new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is no longer allowed to sign in." + }), + OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + foreach (var claim in principal.Claims) + { + claim.SetDestinations(GetDestinations(claim, principal, false)); + } + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + if (request.IsClientCredentialsGrantType()) + { + if (request.ClientId == null) + { + throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + } + + var application = await applicationManager.FindByClientIdAsync(request.ClientId); + + if (application == null) + { + throw new InvalidOperationException("The application details cannot be found in the database."); + } + + var principal = await CreateApplicationPrinicpalAsync(request, application); + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + throw new InvalidOperationException("The specified grant type is not supported."); + } + + [HttpGet("connect/authorize")] + public async Task Authorize() + { + var request = HttpContext.GetOpenIddictServerRequest(); + if (request == null) + { + throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + } + + if (User.Identity?.IsAuthenticated != true) + { + if (request.HasPrompt(Prompts.None)) + { + var properties = new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in." + }); + + return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + var query = QueryString.Create( + Request.HasFormContentType ? + Request.Form.ToList() : + Request.Query.ToList()); + + var redirectUri = Request.PathBase + Request.Path + query; + + return Challenge( + new AuthenticationProperties + { + RedirectUri = redirectUri + }); + } + + var user = await userService.GetAsync(User); + + if (user == null) + { + throw new InvalidOperationException("The user details cannot be retrieved."); + } + + var principal = await SignInManager.CreateUserPrincipalAsync((IdentityUser)user.Identity); + + await EnrichPrincipalAsync(request, principal, false); + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + [HttpGet("connect/logout")] + public async Task Logout() + { + await SignInManager.SignOutAsync(); + + return SignOut(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private async Task CreateApplicationPrinicpalAsync(OpenIddictRequest request, object application) + { + var identity = new ClaimsIdentity( + TokenValidationParameters.DefaultAuthenticationType, + Claims.Name, + Claims.Role); + + var principal = new ClaimsPrincipal(identity); + + var clientId = request.ClientId; + var clientName = await applicationManager.GetDisplayNameAsync(application); + + if (clientId != null) + { + identity.AddClaim(Claims.Subject, clientId, + Destinations.AccessToken, Destinations.IdentityToken); + } + + if (clientName != null) + { + identity.AddClaim(Claims.Name, clientName, + Destinations.AccessToken, Destinations.IdentityToken); + } + + var properties = await applicationManager.GetPropertiesAsync(application); + + foreach (var claim in properties.Claims()) + { + identity.AddClaim(claim); + } + + await EnrichPrincipalAsync(request, principal, true); + + return principal; + } + + private async Task EnrichPrincipalAsync(OpenIddictRequest request, ClaimsPrincipal principal, bool alwaysDeliverPermissions) + { + var scopes = request.GetScopes(); + + principal.SetScopes(scopes); + principal.SetResources(await scopeManager.ListResourcesAsync(scopes).ToListAsync()); + + foreach (var claim in principal.Claims) + { + claim.SetDestinations(GetDestinations(claim, principal, alwaysDeliverPermissions)); + } + } + + private static IEnumerable GetDestinations(Claim claim, ClaimsPrincipal principal, bool alwaysDeliverPermissions) + { + switch (claim.Type) + { + case SquidexClaimTypes.DisplayName when principal.HasScope(Scopes.Profile): + yield return Destinations.IdentityToken; + yield break; + + case SquidexClaimTypes.PictureUrl when principal.HasScope(Scopes.Profile): + yield return Destinations.IdentityToken; + yield break; + + case SquidexClaimTypes.NotifoKey when principal.HasScope(Scopes.Profile): + yield return Destinations.IdentityToken; + yield break; + + case SquidexClaimTypes.Permissions when principal.HasScope(Constants.ScopePermissions) || alwaysDeliverPermissions: + yield return Destinations.AccessToken; + yield return Destinations.IdentityToken; + yield break; + + case Claims.Name: + yield return Destinations.AccessToken; + + if (principal.HasScope(Scopes.Profile)) + { + yield return Destinations.IdentityToken; + } + + yield break; + + case Claims.Email: + yield return Destinations.AccessToken; + + if (principal.HasScope(Scopes.Email)) + { + yield return Destinations.IdentityToken; + } + + yield break; + + case Claims.Role: + yield return Destinations.AccessToken; + + if (principal.HasScope(Scopes.Roles)) + { + yield return Destinations.IdentityToken; + } + + yield break; + + case "AspNet.Identity.SecurityStamp": + yield break; + + default: + yield return Destinations.AccessToken; + yield break; + } + } + } +} diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Error/ErrorController.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Error/ErrorController.cs index 4a2b4e6a5..0a5e85fb7 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Controllers/Error/ErrorController.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Error/ErrorController.cs @@ -6,23 +6,14 @@ // ========================================================================== using System.Threading.Tasks; -using IdentityServer4.Models; -using IdentityServer4.Services; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; -using Squidex.Infrastructure; namespace Squidex.Areas.IdentityServer.Controllers.Error { public sealed class ErrorController : IdentityServerController { - private readonly IIdentityServerInteractionService interaction; - - public ErrorController(IIdentityServerInteractionService interaction) - { - this.interaction = interaction; - } - [Route("error/")] public async Task Error(string? errorId = null) { @@ -30,28 +21,16 @@ namespace Squidex.Areas.IdentityServer.Controllers.Error var vm = new ErrorVM(); - if (!string.IsNullOrWhiteSpace(errorId)) - { - var message = await interaction.GetErrorContextAsync(errorId); + var response = HttpContext.GetOpenIddictServerResponse(); - if (message != null) - { - vm.Error = message; - } - } + vm.ErrorMessage = response?.ErrorDescription; + vm.ErrorCode = response?.Error; - if (vm.Error == null) + if (string.IsNullOrWhiteSpace(vm.ErrorMessage)) { - var error = HttpContext.Features.Get()?.Error; - - if (error is DomainException exception) - { - vm.Error = new ErrorMessage { ErrorDescription = exception.Message }; - } - else if (error?.InnerException is DomainException exception2) - { - vm.Error = new ErrorMessage { ErrorDescription = exception2.Message }; - } + var exception = HttpContext.Features.Get()?.Error; + + vm.ErrorMessage = exception?.Message; } return View("Error", vm); diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Error/ErrorVM.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Error/ErrorVM.cs index 141070081..9985a0ccf 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Controllers/Error/ErrorVM.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Error/ErrorVM.cs @@ -5,12 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using IdentityServer4.Models; - namespace Squidex.Areas.IdentityServer.Controllers.Error { public class ErrorVM { - public ErrorMessage Error { get; set; } + public string? ErrorMessage { get; set; } + + public string? ErrorCode { get; set; } = "400"; } } diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/IdentityServerController.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/IdentityServerController.cs index e8cbdead9..a5ff45cf6 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Controllers/IdentityServerController.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/IdentityServerController.cs @@ -6,7 +6,6 @@ // ========================================================================== using System; -using IdentityServer4.Services; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -20,10 +19,7 @@ namespace Squidex.Areas.IdentityServer.Controllers { public SignInManager SignInManager { - get - { - return HttpContext.RequestServices.GetRequiredService>(); - } + get => HttpContext.RequestServices.GetRequiredService>(); } public override void OnActionExecuting(ActionExecutingContext context) @@ -50,13 +46,6 @@ namespace Squidex.Areas.IdentityServer.Controllers return Redirect(returnUrl); } - var interactions = HttpContext.RequestServices.GetRequiredService(); - - if (interactions.IsValidReturnUrl(returnUrl)) - { - return Redirect(returnUrl); - } - return Redirect("~/../"); } } diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/UserInfo/UserInfoController.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/UserInfo/UserInfoController.cs new file mode 100644 index 000000000..b3f9fd425 --- /dev/null +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/UserInfo/UserInfoController.cs @@ -0,0 +1,68 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using Squidex.Domain.Users; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Squidex.Areas.IdentityServer.Controllers.UserInfo +{ + public class UserInfoController : IdentityServerController + { + private readonly IUserService userService; + + public UserInfoController(IUserService userService) + { + this.userService = userService; + } + + [Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)] + [HttpGet("connect/userinfo")] + [HttpPost("connect/userinfo")] + [Produces("application/json")] + public async Task UserInfo() + { + var user = await userService.GetAsync(User); + + if (user == null) + { + return Challenge( + new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidToken, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The specified access token is bound to an account that no longer exists." + }), + OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + var claims = new Dictionary(StringComparer.Ordinal) + { + [Claims.Subject] = user.Id + }; + + if (User.HasScope(Scopes.Email)) + { + claims[Claims.Email] = user.Email; + claims[Claims.EmailVerified] = true; + } + + if (User.HasScope(Scopes.Roles)) + { + claims[Claims.Role] = Array.Empty(); + } + + return Ok(claims); + } + } +} diff --git a/backend/src/Squidex/Areas/IdentityServer/Startup.cs b/backend/src/Squidex/Areas/IdentityServer/Startup.cs index 6afd55676..2b452789a 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Startup.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Startup.cs @@ -9,7 +9,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Squidex.Areas.IdentityServer.Config; using Squidex.Web; namespace Squidex.Areas.IdentityServer @@ -20,7 +19,7 @@ namespace Squidex.Areas.IdentityServer { var environment = app.ApplicationServices.GetRequiredService(); - app.Map(Constants.IdentityServerPrefix, identityApp => + app.Map(Constants.PrefixIdentityServer, identityApp => { if (environment.IsDevelopment()) { @@ -31,13 +30,19 @@ namespace Squidex.Areas.IdentityServer identityApp.UseExceptionHandler("/error"); } + identityApp.Use((context, next) => + { + // OpenId dict core only works with https, which sucks in our tests. + context.Request.IsHttps = true; + + return next(); + }); + identityApp.UseRouting(); identityApp.UseAuthentication(); identityApp.UseAuthorization(); - identityApp.UseSquidexIdentityServer(); - identityApp.UseEndpoints(endpoints => { endpoints.MapControllers(); diff --git a/backend/src/Squidex/Areas/IdentityServer/Views/Error/Error.cshtml b/backend/src/Squidex/Areas/IdentityServer/Views/Error/Error.cshtml index 849aa36d8..846bb03b5 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Views/Error/Error.cshtml +++ b/backend/src/Squidex/Areas/IdentityServer/Views/Error/Error.cshtml @@ -11,9 +11,9 @@

@T.Get("users.error.headline")

- @if (Model.Error?.ErrorDescription != null) + @if (Model.ErrorMessage != null) { - @Model.Error.ErrorDescription + @Model.ErrorMessage } else { diff --git a/backend/src/Squidex/Areas/OrleansDashboard/Startup.cs b/backend/src/Squidex/Areas/OrleansDashboard/Startup.cs index b5c951ef4..a833cd861 100644 --- a/backend/src/Squidex/Areas/OrleansDashboard/Startup.cs +++ b/backend/src/Squidex/Areas/OrleansDashboard/Startup.cs @@ -16,7 +16,7 @@ namespace Squidex.Areas.OrleansDashboard { public static void ConfigureOrleansDashboard(this IApplicationBuilder app) { - app.Map(Constants.OrleansPrefix, orleansApp => + app.Map(Constants.PrefixOrleans, orleansApp => { orleansApp.UseAuthentication(); orleansApp.UseAuthorization(); diff --git a/backend/src/Squidex/Areas/Portal/Startup.cs b/backend/src/Squidex/Areas/Portal/Startup.cs index 9e008e740..035f39836 100644 --- a/backend/src/Squidex/Areas/Portal/Startup.cs +++ b/backend/src/Squidex/Areas/Portal/Startup.cs @@ -15,7 +15,7 @@ namespace Squidex.Areas.Portal { public static void ConfigurePortal(this IApplicationBuilder app) { - app.Map(Constants.PortalPrefix, portalApp => + app.Map(Constants.PrefixPortal, portalApp => { portalApp.UseAuthentication(); portalApp.UseAuthorization(); diff --git a/backend/src/Squidex/Config/Authentication/IdentityServerServices.cs b/backend/src/Squidex/Config/Authentication/IdentityServerServices.cs index 4c8e05279..031c40e12 100644 --- a/backend/src/Squidex/Config/Authentication/IdentityServerServices.cs +++ b/backend/src/Squidex/Config/Authentication/IdentityServerServices.cs @@ -5,17 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using IdentityServer4; -using IdentityServer4.AccessTokenValidation; -using IdentityServer4.Hosting.LocalApiAuthentication; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; -using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Validation.AspNetCore; using Squidex.Hosting; using Squidex.Web; +using static OpenIddict.Abstractions.OpenIddictConstants; namespace Squidex.Config.Authentication { @@ -27,26 +25,28 @@ namespace Squidex.Config.Authentication if (useCustomAuthorityUrl) { - authBuilder.AddIdentityServerAuthentication(options => + const string ExternalIdentityServerSchema = nameof(ExternalIdentityServerSchema); + + authBuilder.AddOpenIdConnect(ExternalIdentityServerSchema, options => { options.Authority = identityOptions.AuthorityUrl; - options.ApiName = Constants.ApiScope; - options.ApiSecret = null; - options.RequireHttpsMetadata = identityOptions.RequiresHttps; - options.SupportedTokens = SupportedTokens.Jwt; + options.Scope.Add(Scopes.Email); + options.Scope.Add(Scopes.Profile); + options.Scope.Add(Constants.ScopePermissions); + options.Scope.Add(Constants.ScopeApi); + }); + + authBuilder.AddPolicyScheme(Constants.ApiSecurityScheme, Constants.ApiSecurityScheme, options => + { + options.ForwardDefaultSelector = context => ExternalIdentityServerSchema; }); } else { - authBuilder.AddLocalApi(); - - authBuilder.Services.AddOptions(IdentityServerConstants.LocalApi.PolicyName) - .Configure((options, urlGenerator) => - { - options.ClaimsIssuer = urlGenerator.BuildUrl(Constants.IdentityServerPrefix, false); - - options.ExpectedScope = Constants.ApiScope; - }); + authBuilder.AddPolicyScheme(Constants.ApiSecurityScheme, Constants.ApiSecurityScheme, options => + { + options.ForwardDefaultSelector = _ => OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + }); } authBuilder.AddOpenIdConnect(); @@ -60,33 +60,20 @@ namespace Squidex.Config.Authentication } else { - options.Authority = urlGenerator.BuildUrl(Constants.IdentityServerPrefix, false); + options.Authority = urlGenerator.BuildUrl(Constants.PrefixIdentityServer, false); } - options.ClientId = Constants.InternalClientId; - options.ClientSecret = Constants.InternalClientSecret; + options.ClientId = Constants.ClientInternalId; + options.ClientSecret = Constants.ClientInternalSecret; options.CallbackPath = "/signin-internal"; options.RequireHttpsMetadata = identityOptions.RequiresHttps; options.SaveTokens = true; - options.Scope.Add(Constants.PermissionsScope); - options.Scope.Add(Constants.ProfileScope); - options.Scope.Add(Constants.RoleScope); + options.Scope.Add(Scopes.Email); + options.Scope.Add(Scopes.Profile); + options.Scope.Add(Constants.ScopePermissions); options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; }); - authBuilder.AddPolicyScheme(Constants.ApiSecurityScheme, Constants.ApiSecurityScheme, options => - { - options.ForwardDefaultSelector = context => - { - if (useCustomAuthorityUrl) - { - return IdentityServerAuthenticationDefaults.AuthenticationScheme; - } - - return IdentityServerConstants.LocalApi.PolicyName; - }; - }); - return authBuilder; } } diff --git a/backend/src/Squidex/Config/Authentication/OidcHandler.cs b/backend/src/Squidex/Config/Authentication/OidcHandler.cs index b1f77deb8..14592e491 100644 --- a/backend/src/Squidex/Config/Authentication/OidcHandler.cs +++ b/backend/src/Squidex/Config/Authentication/OidcHandler.cs @@ -46,6 +46,7 @@ namespace Squidex.Config.Authentication if (!string.IsNullOrEmpty(options.OidcOnSignoutRedirectUrl)) { var logoutUri = options.OidcOnSignoutRedirectUrl; + context.Response.Redirect(logoutUri); context.HandleResponse(); diff --git a/backend/src/Squidex/Config/Domain/LoggingServices.cs b/backend/src/Squidex/Config/Domain/LoggingServices.cs index 69504ea71..ad0f8bdd8 100644 --- a/backend/src/Squidex/Config/Domain/LoggingServices.cs +++ b/backend/src/Squidex/Config/Domain/LoggingServices.cs @@ -74,6 +74,11 @@ namespace Squidex.Config.Domain return false; } + if (category.StartsWith("OpenIddict", StringComparison.OrdinalIgnoreCase)) + { + return level >= LogLevel.Warning; + } + if (category.StartsWith("Orleans.Runtime.NoOpHostEnvironmentStatistics", StringComparison.OrdinalIgnoreCase)) { return level >= LogLevel.Error; diff --git a/backend/src/Squidex/Config/Domain/StoreServices.cs b/backend/src/Squidex/Config/Domain/StoreServices.cs index 246cdbd6e..5f91fe243 100644 --- a/backend/src/Squidex/Config/Domain/StoreServices.cs +++ b/backend/src/Squidex/Config/Domain/StoreServices.cs @@ -6,8 +6,6 @@ // ========================================================================== using System; -using System.Linq; -using IdentityServer4.Stores; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -29,8 +27,8 @@ using Squidex.Domain.Apps.Entities.MongoDb.Schemas; using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Users; +using Squidex.Domain.Users.InMemory; using Squidex.Domain.Users.MongoDb; -using Squidex.Domain.Users.MongoDb.Infrastructure; using Squidex.Infrastructure; using Squidex.Infrastructure.Diagnostics; using Squidex.Infrastructure.EventSourcing; @@ -127,13 +125,16 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); - var registration = services.FirstOrDefault(x => x.ServiceType == typeof(IPersistedGrantStore)); + services.AddOpenIddict() + .AddCore(builder => + { + builder.UseMongoDb() + .SetScopesCollectionName("Identity_Scopes") + .SetTokensCollectionName("Identity_Tokens"); - if (registration == null || registration.ImplementationType == typeof(InMemoryPersistedGrantStore)) - { - services.AddSingletonAs() - .As(); - } + builder.SetDefaultScopeEntity(); + builder.SetDefaultApplicationEntity(); + }); } }); diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index 5f6d69cce..79453c613 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -37,11 +37,9 @@ - - - + @@ -70,6 +68,7 @@ + diff --git a/backend/tests/Squidex.Domain.Users.Tests/DefaultKeyStoreTests.cs b/backend/tests/Squidex.Domain.Users.Tests/DefaultKeyStoreTests.cs index b096eb2e2..fea09d824 100644 --- a/backend/tests/Squidex.Domain.Users.Tests/DefaultKeyStoreTests.cs +++ b/backend/tests/Squidex.Domain.Users.Tests/DefaultKeyStoreTests.cs @@ -5,8 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Threading.Tasks; +using System.Security.Cryptography; using FakeItEasy; +using IdentityModel; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Server; using Squidex.Infrastructure; using Squidex.Infrastructure.States; using Xunit; @@ -24,39 +27,72 @@ namespace Squidex.Domain.Users } [Fact] - public async Task Should_generate_signing_credentials_once() + public void Should_configure_new_keys() { A.CallTo(() => store.ReadAsync(A._)) .Returns((null!, true, 0)); - var credentials1 = await sut.GetSigningCredentialsAsync(); - var credentials2 = await sut.GetSigningCredentialsAsync(); + var options = new OpenIddictServerOptions(); - Assert.Same(credentials1, credentials2); + sut.Configure(options); - A.CallTo(() => store.ReadAsync(A._)) - .MustHaveHappenedOnceExactly(); + Assert.NotEmpty(options.SigningCredentials); + Assert.NotEmpty(options.EncryptionCredentials); A.CallTo(() => store.WriteAsync(A._, A._, 0, 0)) .MustHaveHappenedOnceExactly(); } [Fact] - public async Task Should_generate_validation_keys_once() + public void Should_configure_existing_keys() { A.CallTo(() => store.ReadAsync(A._)) - .Returns((null!, true, 0)); + .Returns((ExistingKey(), true, 0)); + + var options = new OpenIddictServerOptions(); + + sut.Configure(options); - var credentials1 = await sut.GetValidationKeysAsync(); - var credentials2 = await sut.GetValidationKeysAsync(); + Assert.NotEmpty(options.SigningCredentials); + Assert.NotEmpty(options.EncryptionCredentials); - Assert.Same(credentials1, credentials2); + A.CallTo(() => store.WriteAsync(A._, A._, 0, 0)) + .MustNotHaveHappened(); + } + [Fact] + public void Should_configure_existing_keys_when_initial_setup_failed() + { A.CallTo(() => store.ReadAsync(A._)) - .MustHaveHappenedOnceExactly(); + .Returns((null!, true, 0)).Once() + .Then + .Returns((ExistingKey(), true, 0)); A.CallTo(() => store.WriteAsync(A._, A._, 0, 0)) - .MustHaveHappenedOnceExactly(); + .Throws(new InconsistentStateException(0, 0)); + + var options = new OpenIddictServerOptions(); + + sut.Configure(options); + + Assert.NotEmpty(options.SigningCredentials); + Assert.NotEmpty(options.EncryptionCredentials); + + A.CallTo(() => store.WriteAsync(A._, A._, 0, 0)) + .MustHaveHappened(); + } + + private static DefaultKeyStore.State ExistingKey() + { + var key = new RsaSecurityKey(RSA.Create(2048)) + { + KeyId = CryptoRandom.CreateUniqueId(16) + }; + + return new DefaultKeyStore.State + { + Parameters = key.Rsa.ExportParameters(includePrivateParameters: true) + }; } } } diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/AppCreationTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/AppCreationTests.cs index 3fa61fb41..c5debba86 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/AppCreationTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/AppCreationTests.cs @@ -51,7 +51,7 @@ namespace TestSuite.ApiTests // STEP 3: Check contributors var contributors = await _.Apps.GetContributorsAsync(appName); - // Should not client itself as a contributor. + // Should not add client itself as a contributor. Assert.Empty(contributors.Items); diff --git a/frontend/app/shared/services/auth.service.ts b/frontend/app/shared/services/auth.service.ts index 5bfff8461..c7e7792cf 100644 --- a/frontend/app/shared/services/auth.service.ts +++ b/frontend/app/shared/services/auth.service.ts @@ -77,8 +77,8 @@ export class AuthService { this.userManager = new UserManager({ client_id: 'squidex-frontend', - scope: 'squidex-api openid profile email squidex-profile role permissions', - response_type: 'id_token token', + scope: 'squidex-api openid profile email permissions', + response_type: 'code', redirect_uri: apiUrl.buildUrl('login;'), post_logout_redirect_uri: apiUrl.buildUrl('logout'), silent_redirect_uri: apiUrl.buildUrl('client-callback-silent'), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d80ffe1e9..85433d52c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1299,6 +1299,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "dev": true, "requires": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -1809,7 +1810,8 @@ "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true }, "body-parser": { "version": "1.19.0", @@ -1884,6 +1886,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, "requires": { "fill-range": "^7.0.1" } @@ -2066,6 +2069,7 @@ "version": "3.5.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz", "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==", + "dev": true, "requires": { "anymatch": "~3.1.1", "braces": "~3.0.2", @@ -3313,9 +3317,9 @@ } }, "detect-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.0.5.tgz", - "integrity": "sha512-qi86tE6hRcFHy8jI1m2VG+LaPUR1LhqDa5G8tVjuUXmOrpuAgqsA1pN0+ldgr3aKUH+QLI9hCY/OcRYisERejw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "dev": true }, "di": { @@ -3958,9 +3962,9 @@ } }, "eslint-plugin-import": { - "version": "2.23.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.23.1.tgz", - "integrity": "sha512-epW62znqcFCyQeixVrqy26WpdN1Y3LZH5G9XCuiiTCVuksjC4Je+4o1z5mIpa6P1KMyz1n4RT436VSrZoA5+5A==", + "version": "2.23.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.23.2.tgz", + "integrity": "sha512-LmNoRptHBxOP+nb0PIKz1y6OSzCJlB+0g0IGS3XV4KaKk2q4szqQ6s6F1utVf5ZRkxk/QOTjdxe7v4VjS99Bsg==", "dev": true, "requires": { "array-includes": "^3.1.3", @@ -4565,6 +4569,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, "requires": { "to-regex-range": "^5.0.1" } @@ -4699,6 +4704,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "optional": true }, "function-bind": { @@ -4780,6 +4786,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "requires": { "is-glob": "^4.0.1" } @@ -5768,6 +5775,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "requires": { "binary-extensions": "^2.0.0" } @@ -5883,7 +5891,8 @@ "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true }, "is-finite": { "version": "1.1.0", @@ -5905,6 +5914,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, "requires": { "is-extglob": "^2.1.1" } @@ -5937,7 +5947,8 @@ "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true }, "is-number-object": { "version": "1.0.5", @@ -7241,7 +7252,8 @@ "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true }, "normalize-range": { "version": "0.1.2", @@ -8426,7 +8438,8 @@ "picomatch": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.3.tgz", - "integrity": "sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg==" + "integrity": "sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg==", + "dev": true }, "pify": { "version": "3.0.0", @@ -10363,6 +10376,7 @@ "version": "3.5.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", + "dev": true, "requires": { "picomatch": "^2.2.1" } @@ -10818,6 +10832,7 @@ "version": "1.32.13", "resolved": "https://registry.npmjs.org/sass/-/sass-1.32.13.tgz", "integrity": "sha512-dEgI9nShraqP7cXQH+lEXVf73WOPCse0QlFzSD8k+1TcOxCMwVXfQlr0jtoluZysQOyJGnfr21dLvYKDJq8HkA==", + "dev": true, "requires": { "chokidar": ">=3.0.0 <4.0.0" } @@ -11813,9 +11828,9 @@ } }, "spdx-license-ids": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz", - "integrity": "sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.8.tgz", + "integrity": "sha512-NDgA96EnaLSvtbM7trJj+t1LUR3pirkDCcz9nOUlPb5DMBGsH7oES6C3hs3j7R9oHEa1EMvReS/BUAIT5Tcr0g==", "dev": true }, "spdy": { @@ -12287,6 +12302,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "requires": { "is-number": "^7.0.0" } diff --git a/frontend/package.json b/frontend/package.json index 185b6bde7..a7f933f65 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,7 +55,6 @@ "react": "17.0.2", "react-dom": "17.0.2", "rxjs": "7.0.1", - "sass": "^1.32.13", "simplemde": "1.11.2", "slugify": "1.5.3", "tinymce": "5.8.0", @@ -123,6 +122,7 @@ "raw-loader": "4.0.2", "resize-observer-polyfill": "1.5.1", "rimraf": "3.0.2", + "sass": "^1.32.13", "sass-lint": "1.13.1", "sass-lint-webpack": "1.0.3", "sass-loader": "11.1.1",