diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs index abfd5c3ab..497b7fc24 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs @@ -40,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Backup private readonly IServiceProvider serviceProvider; private readonly IStreamNameResolver streamNameResolver; private readonly IUserResolver userResolver; - private readonly IGrainState state; + private readonly IGrainState state; private RestoreContext restoreContext; private RestoreJob CurrentJob @@ -54,7 +54,7 @@ namespace Squidex.Domain.Apps.Entities.Backup ICommandBus commandBus, IEventDataFormatter eventDataFormatter, IEventStore eventStore, - IGrainState state, + IGrainState state, IServiceProvider serviceProvider, IStreamNameResolver streamNameResolver, IUserResolver userResolver, diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState2.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupRestoreState.cs similarity index 93% rename from backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState2.cs rename to backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupRestoreState.cs index b668945a1..aa3bd9743 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState2.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupRestoreState.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Backup.State { - public class RestoreState2 + public class BackupRestoreState { public RestoreJob Job { get; set; } } diff --git a/backend/src/Squidex.Domain.Users.MongoDb/MongoKey.cs b/backend/src/Squidex.Domain.Users.MongoDb/MongoKey.cs deleted file mode 100644 index be77f9292..000000000 --- a/backend/src/Squidex.Domain.Users.MongoDb/MongoKey.cs +++ /dev/null @@ -1,23 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using MongoDB.Bson.Serialization.Attributes; - -namespace Squidex.Domain.Users.MongoDb -{ - public sealed class MongoKey - { - [BsonId] - public string Id { get; set; } - - [BsonElement] - public string Key { get; set; } - - [BsonElement] - public MongoKeyParameters Parameters { get; set; } - } -} diff --git a/backend/src/Squidex.Domain.Users.MongoDb/MongoKeyParameters.cs b/backend/src/Squidex.Domain.Users.MongoDb/MongoKeyParameters.cs deleted file mode 100644 index 16dad01cf..000000000 --- a/backend/src/Squidex.Domain.Users.MongoDb/MongoKeyParameters.cs +++ /dev/null @@ -1,64 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Security.Cryptography; - -#pragma warning disable IDE0017 // Simplify object initialization - -namespace Squidex.Domain.Users.MongoDb -{ - public sealed class MongoKeyParameters - { - public byte[] D { get; set; } - - public byte[] DP { get; set; } - - public byte[] DQ { get; set; } - - public byte[] Exponent { get; set; } - - public byte[] InverseQ { get; set; } - - public byte[] Modulus { get; set; } - - public byte[] P { get; set; } - - public byte[] Q { get; set; } - - public static MongoKeyParameters Create(RSAParameters source) - { - var mongoParameters = new MongoKeyParameters(); - - mongoParameters.D = source.D; - mongoParameters.DP = source.DP; - mongoParameters.DQ = source.DQ; - mongoParameters.Exponent = source.Exponent; - mongoParameters.InverseQ = source.InverseQ; - mongoParameters.Modulus = source.Modulus; - mongoParameters.P = source.P; - mongoParameters.Q = source.Q; - - return mongoParameters; - } - - public RSAParameters ToParameters() - { - var parameters = default(RSAParameters); - - parameters.D = D; - parameters.DP = DP; - parameters.DQ = DQ; - parameters.Exponent = Exponent; - parameters.InverseQ = InverseQ; - parameters.Modulus = Modulus; - parameters.P = P; - parameters.Q = Q; - - return parameters; - } - } -} diff --git a/backend/src/Squidex.Domain.Users.MongoDb/MongoUser.cs b/backend/src/Squidex.Domain.Users.MongoDb/MongoUser.cs index 47aab16e9..3e56eabab 100644 --- a/backend/src/Squidex.Domain.Users.MongoDb/MongoUser.cs +++ b/backend/src/Squidex.Domain.Users.MongoDb/MongoUser.cs @@ -9,23 +9,32 @@ using System.Collections.Generic; using System.Linq; using System.Security.Claims; using Microsoft.AspNetCore.Identity; +using MongoDB.Bson.Serialization.Attributes; using Squidex.Infrastructure; namespace Squidex.Domain.Users.MongoDb { public sealed class MongoUser : IdentityUser { + [BsonRequired] + [BsonElement] public List Claims { get; set; } = new List(); + [BsonRequired] + [BsonElement] public List Tokens { get; set; } = new List(); + [BsonRequired] + [BsonElement] public List Logins { get; set; } = new List(); + [BsonRequired] + [BsonElement] public HashSet Roles { get; set; } = new HashSet(); internal void AddLogin(UserLoginInfo login) { - Logins.Add(new UserLoginInfo(login.LoginProvider, login.ProviderKey, login.ProviderDisplayName)); + Logins.Add(login); } internal void AddRole(string role) @@ -38,9 +47,9 @@ namespace Squidex.Domain.Users.MongoDb Roles.Remove(role); } - internal void RemoveLogin(string loginProvider, string providerKey) + internal void RemoveLogin(string provider, string providerKey) { - Logins.RemoveAll(l => l.LoginProvider == loginProvider && l.ProviderKey == providerKey); + Logins.RemoveAll(x => x.LoginProvider == provider && x.ProviderKey == providerKey); } internal void AddClaim(Claim claim) @@ -55,7 +64,7 @@ namespace Squidex.Domain.Users.MongoDb internal void RemoveClaim(Claim claim) { - Claims.RemoveAll(c => c.Type == claim.Type && c.Value == claim.Value); + Claims.RemoveAll(x => x.Type == claim.Type && x.Value == claim.Value); } internal void RemoveClaims(IEnumerable claims) @@ -63,19 +72,19 @@ namespace Squidex.Domain.Users.MongoDb claims.Foreach((x, _) => RemoveClaim(x)); } - internal string? GetToken(string loginProvider, string name) + internal string? GetToken(string provider, string name) { - return Tokens.FirstOrDefault(t => t.LoginProvider == loginProvider && t.Name == name)?.Value; + return Tokens.FirstOrDefault(x => x.LoginProvider == provider && x.Name == name)?.Value; } - internal void AddToken(string loginProvider, string name, string value) + internal void AddToken(string provider, string name, string value) { - Tokens.Add(new UserTokenInfo { LoginProvider = loginProvider, Name = name, Value = value }); + Tokens.Add(new UserTokenInfo { LoginProvider = provider, Name = name, Value = value }); } - internal void RemoveToken(string loginProvider, string name) + internal void RemoveToken(string provider, string name) { - Tokens.RemoveAll(t => t.LoginProvider == loginProvider && t.Name == name); + Tokens.RemoveAll(x => x.LoginProvider == provider && x.Name == name); } internal void ReplaceClaim(Claim existingClaim, Claim newClaim) diff --git a/backend/src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs b/backend/src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs index 25f086ae2..85b5c8803 100644 --- a/backend/src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs +++ b/backend/src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs @@ -193,27 +193,37 @@ namespace Squidex.Domain.Users.MongoDb public async Task FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken) { - return await Collection.Find(x => x.NormalizedEmail == normalizedEmail).FirstOrDefaultAsync(cancellationToken); + var result = await Collection.Find(x => x.NormalizedEmail == normalizedEmail).FirstOrDefaultAsync(cancellationToken); + + return result; } public async Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken) { - return await Collection.Find(x => x.NormalizedEmail == normalizedUserName).FirstOrDefaultAsync(cancellationToken); + var result = await Collection.Find(x => x.NormalizedEmail == normalizedUserName).FirstOrDefaultAsync(cancellationToken); + + return result; } public async Task FindByLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken) { - return await Collection.Find(x => x.Logins.Any(y => y.LoginProvider == loginProvider && y.ProviderKey == providerKey)).FirstOrDefaultAsync(cancellationToken); + var result = await Collection.Find(x => x.Logins.Any(y => y.LoginProvider == loginProvider && y.ProviderKey == providerKey)).FirstOrDefaultAsync(cancellationToken); + + return result; } public async Task> GetUsersForClaimAsync(Claim claim, CancellationToken cancellationToken) { - return (await Collection.Find(x => x.Claims.Any(y => y.Type == claim.Type && y.Value == claim.Value)).ToListAsync(cancellationToken)).OfType().ToList(); + var result = await Collection.Find(x => x.Claims.Any(y => y.Type == claim.Type && y.Value == claim.Value)).ToListAsync(cancellationToken); + + return result.OfType().ToList(); } public async Task> GetUsersInRoleAsync(string roleName, CancellationToken cancellationToken) { - return (await Collection.Find(x => x.Roles.Contains(roleName)).ToListAsync(cancellationToken)).OfType().ToList(); + var result = await Collection.Find(x => x.Roles.Contains(roleName)).ToListAsync(cancellationToken); + + return result.OfType().ToList(); } public async Task CreateAsync(IdentityUser user, CancellationToken cancellationToken) @@ -241,112 +251,156 @@ namespace Squidex.Domain.Users.MongoDb public Task GetUserIdAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).Id); + var result = user.Id; + + return Task.FromResult(result); } public Task GetUserNameAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).UserName); + var result = user.UserName; + + return Task.FromResult(result); } public Task GetNormalizedUserNameAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).NormalizedUserName); + var result = user.NormalizedUserName; + + return Task.FromResult(result); } public Task GetPasswordHashAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).PasswordHash); + var result = user.PasswordHash; + + return Task.FromResult(result); } public Task> GetRolesAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult>(((MongoUser)user).Roles.ToList()); + var result = ((MongoUser)user).Roles.ToList(); + + return Task.FromResult>(result); } public Task IsInRoleAsync(IdentityUser user, string roleName, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).Roles.Contains(roleName)); + var result = ((MongoUser)user).Roles.Contains(roleName); + + return Task.FromResult(result); } public Task> GetLoginsAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult>(((MongoUser)user).Logins.Select(x => new UserLoginInfo(x.LoginProvider, x.ProviderKey, x.ProviderDisplayName)).ToList()); + var result = ((MongoUser)user).Logins.Select(x => new UserLoginInfo(x.LoginProvider, x.ProviderKey, x.ProviderDisplayName)).ToList(); + + return Task.FromResult>(result); } public Task GetSecurityStampAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).SecurityStamp); + var result = user.SecurityStamp; + + return Task.FromResult(result); } public Task GetEmailAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).Email); + var result = user.Email; + + return Task.FromResult(result); } public Task GetEmailConfirmedAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).EmailConfirmed); + var result = user.EmailConfirmed; + + return Task.FromResult(result); } public Task GetNormalizedEmailAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).NormalizedEmail); + var result = user.NormalizedEmail; + + return Task.FromResult(result); } public Task> GetClaimsAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult>(((MongoUser)user).Claims); + var result = ((MongoUser)user).Claims; + + return Task.FromResult>(result); } public Task GetPhoneNumberAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).PhoneNumber); + var result = user.PhoneNumber; + + return Task.FromResult(result); } public Task GetPhoneNumberConfirmedAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).PhoneNumberConfirmed); + var result = user.PhoneNumberConfirmed; + + return Task.FromResult(result); } public Task GetTwoFactorEnabledAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).TwoFactorEnabled); + var result = user.TwoFactorEnabled; + + return Task.FromResult(result); } public Task GetLockoutEndDateAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).LockoutEnd); + var result = user.LockoutEnd; + + return Task.FromResult(result); } public Task GetAccessFailedCountAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).AccessFailedCount); + var result = user.AccessFailedCount; + + return Task.FromResult(result); } public Task GetLockoutEnabledAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).LockoutEnabled); + var result = user.LockoutEnabled; + + return Task.FromResult(result); } public Task GetTokenAsync(IdentityUser user, string loginProvider, string name, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).GetToken(loginProvider, name)!); + var result = ((MongoUser)user).GetToken(loginProvider, name)!; + + return Task.FromResult(result); } public Task GetAuthenticatorKeyAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).GetToken(InternalLoginProvider, AuthenticatorKeyTokenName)!); + var result = ((MongoUser)user).GetToken(InternalLoginProvider, AuthenticatorKeyTokenName)!; + + return Task.FromResult(result); } public Task HasPasswordAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(!string.IsNullOrWhiteSpace(((MongoUser)user).PasswordHash)); + var result = !string.IsNullOrWhiteSpace(user.PasswordHash); + + return Task.FromResult(result); } public Task CountCodesAsync(IdentityUser user, CancellationToken cancellationToken) { - return Task.FromResult(((MongoUser)user).GetToken(InternalLoginProvider, RecoveryCodeTokenName)?.Split(';').Length ?? 0); + var result = ((MongoUser)user).GetToken(InternalLoginProvider, RecoveryCodeTokenName)?.Split(';').Length ?? 0; + + return Task.FromResult(result); } public Task SetUserNameAsync(IdentityUser user, string userName, CancellationToken cancellationToken) diff --git a/backend/src/Squidex.Domain.Users.MongoDb/MongoXmlEntity.cs b/backend/src/Squidex.Domain.Users.MongoDb/MongoXmlEntity.cs deleted file mode 100644 index fe3df1a1f..000000000 --- a/backend/src/Squidex.Domain.Users.MongoDb/MongoXmlEntity.cs +++ /dev/null @@ -1,20 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using MongoDB.Bson.Serialization.Attributes; - -namespace Squidex.Domain.Users.MongoDb -{ - public sealed class MongoXmlEntity - { - [BsonId] - public string FriendlyName { get; set; } - - [BsonRequired] - public string Xml { get; set; } - } -} diff --git a/backend/src/Squidex.Domain.Users.MongoDb/MongoXmlRepository.cs b/backend/src/Squidex.Domain.Users.MongoDb/MongoXmlRepository.cs deleted file mode 100644 index df00fa621..000000000 --- a/backend/src/Squidex.Domain.Users.MongoDb/MongoXmlRepository.cs +++ /dev/null @@ -1,51 +0,0 @@ -// ========================================================================== -// 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.Xml.Linq; -using Microsoft.AspNetCore.DataProtection.Repositories; -using MongoDB.Bson; -using MongoDB.Driver; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Users.MongoDb -{ - public sealed class MongoXmlRepository : IXmlRepository - { - private static readonly ReplaceOptions UpsertReplace = new ReplaceOptions { IsUpsert = true }; - private readonly IMongoCollection collection; - - public MongoXmlRepository(IMongoDatabase mongoDatabase) - { - Guard.NotNull(mongoDatabase, nameof(mongoDatabase)); - - collection = mongoDatabase.GetCollection("States_Repository"); - } - - public IReadOnlyCollection GetAllElements() - { - var documents = collection.Find(new BsonDocument()).ToList(); - - var elements = documents.Select(x => XElement.Parse(x.Xml)).ToList(); - - return elements; - } - - public void StoreElement(XElement element, string friendlyName) - { - var document = new MongoXmlEntity - { - FriendlyName = friendlyName - }; - - document.Xml = element.ToString(); - - collection.ReplaceOne(x => x.FriendlyName == friendlyName, document, UpsertReplace); - } - } -} diff --git a/backend/src/Squidex.Domain.Users/DefaultCertificateAccountStore.cs b/backend/src/Squidex.Domain.Users/DefaultCertificateAccountStore.cs new file mode 100644 index 000000000..ddfd77285 --- /dev/null +++ b/backend/src/Squidex.Domain.Users/DefaultCertificateAccountStore.cs @@ -0,0 +1,59 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading; +using System.Threading.Tasks; +using LettuceEncrypt.Accounts; +using Squidex.Infrastructure; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Users +{ + public sealed class DefaultCertificateAccountStore : IAccountStore + { + private readonly ISnapshotStore store; + + [CollectionName("Identity_certificateAccount")] + public sealed class State + { + public AccountModel Account { get; set; } + + public State() + { + } + + public State(AccountModel account) + { + Account = account; + } + } + + public DefaultCertificateAccountStore(ISnapshotStore store) + { + Guard.NotNull(store, nameof(store)); + + this.store = store; + } + + public async Task GetAccountAsync(CancellationToken cancellationToken) + { + var (value, _) = await store.ReadAsync(default); + + return value?.Account; + } + + public Task SaveAccountAsync(AccountModel account, CancellationToken cancellationToken) + { + Guard.NotNull(account, nameof(account)); + + var state = new State(account); + + return store.WriteAsync(default, state, EtagVersion.Any, 0); + } + } +} diff --git a/backend/src/Squidex.Domain.Users/DefaultCertificateStore.cs b/backend/src/Squidex.Domain.Users/DefaultCertificateStore.cs new file mode 100644 index 000000000..c56abdc9e --- /dev/null +++ b/backend/src/Squidex.Domain.Users/DefaultCertificateStore.cs @@ -0,0 +1,73 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using LettuceEncrypt; +using Squidex.Infrastructure; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Users +{ + public sealed class DefaultCertificateStore : ICertificateRepository, ICertificateSource + { + private readonly ISnapshotStore store; + + [CollectionName("Identity_Certificates")] + public sealed class State + { + public byte[] Certificate { get; set; } + + public State() + { + } + + public State(X509Certificate2 certificate) + { + Certificate = certificate.Export(X509ContentType.Pfx); + } + + public X509Certificate2 ToCertificate() + { + return new X509Certificate2(Certificate); + } + } + + public DefaultCertificateStore(ISnapshotStore store) + { + Guard.NotNull(store, nameof(store)); + + this.store = store; + } + + public async Task> GetCertificatesAsync(CancellationToken cancellationToken = default) + { + var result = new List(); + + await store.ReadAllAsync((state, _) => + { + result.Add(state.ToCertificate()); + + return Task.CompletedTask; + }, cancellationToken); + + return result; + } + + public Task SaveAsync(X509Certificate2 certificate, CancellationToken cancellationToken = default) + { + Guard.NotNull(certificate, nameof(certificate)); + + var state = new State(certificate); + + return store.WriteAsync(Guid.NewGuid(), state, EtagVersion.Any, 0); + } + } +} diff --git a/backend/src/Squidex.Domain.Users.MongoDb/MongoKeyStore.cs b/backend/src/Squidex.Domain.Users/DefaultKeyStore.cs similarity index 64% rename from backend/src/Squidex.Domain.Users.MongoDb/MongoKeyStore.cs rename to backend/src/Squidex.Domain.Users/DefaultKeyStore.cs index dcc092a5f..528fe0053 100644 --- a/backend/src/Squidex.Domain.Users.MongoDb/MongoKeyStore.cs +++ b/backend/src/Squidex.Domain.Users/DefaultKeyStore.cs @@ -13,30 +13,32 @@ using IdentityModel; using IdentityServer4.Models; using IdentityServer4.Stores; using Microsoft.IdentityModel.Tokens; -using MongoDB.Driver; -using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.States; -namespace Squidex.Domain.Users.MongoDb +namespace Squidex.Domain.Users { - public sealed class MongoKeyStore : MongoRepositoryBase, ISigningCredentialStore, IValidationKeysStore + public sealed class DefaultKeyStore : ISigningCredentialStore, IValidationKeysStore { + private readonly ISnapshotStore store; private SigningCredentials? cachedKey; private SecurityKeyInfo[]? cachedKeyInfo; - public MongoKeyStore(IMongoDatabase database, bool setup = false) - : base(database, setup) + [CollectionName("Identity_Keys")] + public sealed class State { + public string Key { get; set; } + + public RSAParameters Parameters { get; set; } } - protected override string CollectionName() + public DefaultKeyStore(ISnapshotStore store) { - return "Key"; + this.store = store; } public async Task GetSigningCredentialsAsync() { var (_, key) = await GetOrCreateKeyAsync(); - // SignatureProvider signatureProvider = key.CryptoProviderFactory.CreateForVerifying(key, key.Al); return key; } @@ -55,57 +57,50 @@ namespace Squidex.Domain.Users.MongoDb return (cachedKeyInfo, cachedKey); } - var key = await Collection.Find(x => x.Id == "Default").FirstOrDefaultAsync(); + var (state, _) = await store.ReadAsync(default); RsaSecurityKey securityKey; - if (key == null) + if (state == null) { securityKey = new RsaSecurityKey(RSA.Create(2048)) { KeyId = CryptoRandom.CreateUniqueId(16) }; - key = new MongoKey { Id = "Default", Key = securityKey.KeyId }; + state = new State { Key = securityKey.KeyId }; if (securityKey.Rsa != null) { var parameters = securityKey.Rsa.ExportParameters(includePrivateParameters: true); - key.Parameters = MongoKeyParameters.Create(parameters); + state.Parameters = parameters; } else { - key.Parameters = MongoKeyParameters.Create(securityKey.Parameters); + state.Parameters = securityKey.Parameters; } try { - await Collection.InsertOneAsync(key); + await store.WriteAsync(default, state, 0, 0); return CreateCredentialsPair(securityKey); } - catch (MongoWriteException ex) + catch (InconsistentStateException) { - if (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) - { - key = await Collection.Find(x => x.Id == "Default").FirstOrDefaultAsync(); - } - else - { - throw; - } + (state, _) = await store.ReadAsync(default); } } - if (key == null) + if (state == null) { throw new InvalidOperationException("Cannot read key."); } - securityKey = new RsaSecurityKey(key.Parameters.ToParameters()) + securityKey = new RsaSecurityKey(state.Parameters) { - KeyId = key.Key + KeyId = state.Key }; return CreateCredentialsPair(securityKey); diff --git a/backend/src/Squidex.Domain.Users/DefaultXmlRepository.cs b/backend/src/Squidex.Domain.Users/DefaultXmlRepository.cs new file mode 100644 index 000000000..ad15d0598 --- /dev/null +++ b/backend/src/Squidex.Domain.Users/DefaultXmlRepository.cs @@ -0,0 +1,69 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection.Repositories; +using Squidex.Infrastructure; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Users +{ + public sealed class DefaultXmlRepository : IXmlRepository + { + private readonly ISnapshotStore store; + + [CollectionName("Identity_Xml")] + public sealed class State + { + public string Xml { get; set; } + + public State() + { + } + + public State(XElement xml) + { + Xml = xml.ToString(); + } + + public XElement ToXml() + { + return XElement.Parse(Xml); + } + } + + public DefaultXmlRepository(ISnapshotStore store) + { + Guard.NotNull(store, nameof(store)); + + this.store = store; + } + + public IReadOnlyCollection GetAllElements() + { + var result = new List(); + + store.ReadAllAsync((state, _) => + { + result.Add(state.ToXml()); + + return Task.CompletedTask; + }).Wait(); + + return result; + } + + public void StoreElement(XElement element, string friendlyName) + { + var state = new State(element); + + store.WriteAsync(friendlyName, state, EtagVersion.Any, 0); + } + } +} diff --git a/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj b/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj index 49e82abe9..f9ca6cdd6 100644 --- a/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj +++ b/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj @@ -16,6 +16,8 @@ + + diff --git a/backend/src/Squidex.Infrastructure/DependencyInjection/DependencyInjectionExtensions.cs b/backend/src/Squidex.Infrastructure/DependencyInjection/DependencyInjectionExtensions.cs index e7fc8dad6..204b91b45 100644 --- a/backend/src/Squidex.Infrastructure/DependencyInjection/DependencyInjectionExtensions.cs +++ b/backend/src/Squidex.Infrastructure/DependencyInjection/DependencyInjectionExtensions.cs @@ -92,5 +92,19 @@ namespace Microsoft.Extensions.DependencyInjection return new InterfaceRegistrator((t, f) => services.AddSingleton(t, f), services.TryAddSingleton); } + + public static InterfaceRegistrator AddScopedAs(this IServiceCollection services, Func factory) where T : class + { + services.AddScoped(typeof(T), factory); + + return new InterfaceRegistrator((t, f) => services.AddScoped(t, f), services.TryAddScoped); + } + + public static InterfaceRegistrator AddScopedAs(this IServiceCollection services) where T : class + { + services.AddScoped(); + + return new InterfaceRegistrator((t, f) => services.AddScoped(t, f), services.TryAddScoped); + } } } diff --git a/backend/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLoggerFactoryExtensions.cs b/backend/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLoggerFactoryExtensions.cs index 98ee1efc4..249325a51 100644 --- a/backend/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLoggerFactoryExtensions.cs +++ b/backend/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLoggerFactoryExtensions.cs @@ -14,7 +14,8 @@ namespace Squidex.Infrastructure.Log.Adapter { public static ILoggingBuilder AddSemanticLog(this ILoggingBuilder builder) { - builder.Services.AddSingleton(); + builder.Services.AddSingletonAs() + .As(); return builder; } diff --git a/backend/src/Squidex.Web/UrlsOptions.cs b/backend/src/Squidex.Web/UrlsOptions.cs index b6149778b..f00d16d73 100644 --- a/backend/src/Squidex.Web/UrlsOptions.cs +++ b/backend/src/Squidex.Web/UrlsOptions.cs @@ -20,8 +20,12 @@ namespace Squidex.Web public bool EnableXForwardedHost { get; set; } + public bool EnableLetsEncrypt { get; set; } + public bool EnforceHTTPS { get; set; } + public string Email { get; set; } = "admin@squidex.io"; + public string BaseUrl { get diff --git a/backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs b/backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs index 506484244..8e7f82f7e 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs @@ -27,7 +27,7 @@ namespace Squidex.Areas.IdentityServer.Config { public static void AddSquidexIdentityServer(this IServiceCollection services) { - services.AddSingleton>(s => + services.AddSingletonAs>(s => { return new ConfigureOptions(options => { @@ -40,20 +40,26 @@ namespace Squidex.Areas.IdentityServer.Config services.AddIdentity() .AddDefaultTokenProviders(); - services.AddSingleton, - PwnedPasswordValidator>(); + services.AddSingletonAs() + .As(); - services.AddScoped, - UserClaimsPrincipalFactoryWithEmail>(); + services.AddSingletonAs() + .As().As(); - services.AddSingleton(); + services.AddSingletonAs() + .As>(); - services.AddSingleton(); + services.AddScopedAs() + .As>(); - services.AddSingleton(); + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); services.AddIdentityServer(options => { diff --git a/backend/src/Squidex/Config/Domain/StoreServices.cs b/backend/src/Squidex/Config/Domain/StoreServices.cs index 44d7da8b5..e6978b12d 100644 --- a/backend/src/Squidex/Config/Domain/StoreServices.cs +++ b/backend/src/Squidex/Config/Domain/StoreServices.cs @@ -108,12 +108,6 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As>().As(); - services.AddSingletonAs() - .As(); - - services.AddSingletonAs() - .As().As(); - services.AddSingletonAs() .As().As>(); diff --git a/backend/src/Squidex/Config/Orleans/OrleansServices.cs b/backend/src/Squidex/Config/Orleans/OrleansServices.cs index ce836ed82..ee19b136d 100644 --- a/backend/src/Squidex/Config/Orleans/OrleansServices.cs +++ b/backend/src/Squidex/Config/Orleans/OrleansServices.cs @@ -32,10 +32,14 @@ namespace Squidex.Config.Orleans builder.ConfigureServices(siloServices => { - siloServices.AddSingleton(); + siloServices.AddSingletonAs() + .As(); - siloServices.AddSingleton(); - siloServices.AddScoped(); + siloServices.AddSingletonAs() + .As(); + + siloServices.AddScopedAs() + .As(); siloServices.AddScoped(typeof(IGrainState<>), typeof(Infrastructure.Orleans.GrainState<>)); }); diff --git a/backend/src/Squidex/Config/Web/WebServices.cs b/backend/src/Squidex/Config/Web/WebServices.cs index 051239eed..92dc41d6d 100644 --- a/backend/src/Squidex/Config/Web/WebServices.cs +++ b/backend/src/Squidex/Config/Web/WebServices.cs @@ -5,6 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using LettuceEncrypt; +using LettuceEncrypt.Accounts; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -14,6 +17,7 @@ using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; using Squidex.Config.Domain; using Squidex.Domain.Apps.Entities; +using Squidex.Domain.Users; using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Translations; using Squidex.Pipeline.Plugins; @@ -104,5 +108,34 @@ namespace Squidex.Config.Web .AddSquidexPlugins(config) .AddSquidexSerializers(); } + + public static void AddSquidexLetsEncrypt(this IServiceCollection services, IConfiguration config) + { + var urlsOptions = config.GetSection("urls").Get(); + + if (!urlsOptions.EnableLetsEncrypt) + { + return; + } + + services.AddLettuceEncrypt(options => + { + options.AcceptTermsOfService = true; + + options.DomainNames = new[] + { + new Uri(urlsOptions.BaseUrl).Host + }; + + options.EmailAddress = urlsOptions.Email; + }); + + services.AddSingletonAs() + .As() + .As(); + + services.AddSingletonAs() + .As(); + } } } diff --git a/backend/src/Squidex/Startup.cs b/backend/src/Squidex/Startup.cs index 22f3d276e..fb227e754 100644 --- a/backend/src/Squidex/Startup.cs +++ b/backend/src/Squidex/Startup.cs @@ -41,6 +41,7 @@ namespace Squidex services.AddNonBreakingSameSiteCookies(); services.AddSquidexMvcWithPlugins(config); + services.AddSquidexLetsEncrypt(config); services.AddSquidexApps(); services.AddSquidexAssetInfrastructure(config); diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index f60ea555a..657a4e144 100644 --- a/backend/src/Squidex/appsettings.json +++ b/backend/src/Squidex/appsettings.json @@ -20,7 +20,19 @@ /* * Set it to true to use the X-Forwarded-Host header as internal Hostname. */ - "enableXForwardedHost": false + "enableXForwardedHost": false, + + /* + * Enable lets encrypt certificate handling. + * + * Attention: Can only be used in production environment with custom domain names and a single server. + */ + "enableLetsEncrypt": true, + + /* + * The email address for the certificate. + */ + "email": "admin@squidex.io" }, "fullText": { diff --git a/backend/tests/Squidex.Domain.Users.Tests/DefaultCertificateAccountStoreTests.cs b/backend/tests/Squidex.Domain.Users.Tests/DefaultCertificateAccountStoreTests.cs new file mode 100644 index 000000000..a470f3cf0 --- /dev/null +++ b/backend/tests/Squidex.Domain.Users.Tests/DefaultCertificateAccountStoreTests.cs @@ -0,0 +1,51 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using FakeItEasy; +using LettuceEncrypt.Accounts; +using Squidex.Infrastructure.States; +using Xunit; + +namespace Squidex.Domain.Users +{ + public class DefaultCertificateAccountStoreTests + { + private readonly ISnapshotStore store = A.Fake>(); + private readonly DefaultCertificateAccountStore sut; + + public DefaultCertificateAccountStoreTests() + { + sut = new DefaultCertificateAccountStore(store); + } + + [Fact] + public async Task Should_read_from_store() + { + var model = new AccountModel(); + + A.CallTo(() => store.ReadAsync(default)) + .Returns((new DefaultCertificateAccountStore.State { Account = model }, 0)); + + var result = await sut.GetAccountAsync(default); + + Assert.Same(model, result); + } + + [Fact] + public async Task Should_write_to_store() + { + var model = new AccountModel(); + + await sut.SaveAccountAsync(model, default); + + A.CallTo(() => store.WriteAsync(A._, A._, A._, 0)) + .MustHaveHappened(); + } + } +} diff --git a/backend/tests/Squidex.Domain.Users.Tests/DefaultCertificateStoreTests.cs b/backend/tests/Squidex.Domain.Users.Tests/DefaultCertificateStoreTests.cs new file mode 100644 index 000000000..4ae8260eb --- /dev/null +++ b/backend/tests/Squidex.Domain.Users.Tests/DefaultCertificateStoreTests.cs @@ -0,0 +1,72 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Infrastructure.States; +using Xunit; + +namespace Squidex.Domain.Users +{ + public sealed class DefaultCertificateStoreTests + { + private readonly ISnapshotStore store = A.Fake>(); + private readonly DefaultCertificateStore sut; + + public DefaultCertificateStoreTests() + { + sut = new DefaultCertificateStore(store); + } + + [Fact] + public async Task Should_read_from_store() + { + A.CallTo(() => store.ReadAllAsync(A>._, A._)) + .Invokes((Func callback, CancellationToken _) => + { + callback(new DefaultCertificateStore.State + { + Certificate = MakeCert().Export(X509ContentType.Pfx) + }, 0); + + callback(new DefaultCertificateStore.State + { + Certificate = MakeCert().Export(X509ContentType.Pfx) + }, 0); + }); + + var xml = await sut.GetCertificatesAsync(); + + Assert.Equal(2, xml.Count()); + } + + [Fact] + public async Task Should_write_to_store() + { + var certificate = MakeCert(); + + await sut.SaveAsync(certificate, default); + + A.CallTo(() => store.WriteAsync(A._, A._, A._, 0)) + .MustHaveHappened(); + } + + private static X509Certificate2 MakeCert() + { + var ecdsa = ECDsa.Create(); + + var certificateRequest = new CertificateRequest("cn=foobar", ecdsa, HashAlgorithmName.SHA256); + + return certificateRequest.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5)); + } + } +} diff --git a/backend/tests/Squidex.Domain.Users.Tests/DefaultKeyStoreTests.cs b/backend/tests/Squidex.Domain.Users.Tests/DefaultKeyStoreTests.cs new file mode 100644 index 000000000..ea481a613 --- /dev/null +++ b/backend/tests/Squidex.Domain.Users.Tests/DefaultKeyStoreTests.cs @@ -0,0 +1,62 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Infrastructure.States; +using Xunit; + +namespace Squidex.Domain.Users +{ + public class DefaultKeyStoreTests + { + private readonly ISnapshotStore store = A.Fake>(); + private readonly DefaultKeyStore sut; + + public DefaultKeyStoreTests() + { + sut = new DefaultKeyStore(store); + } + + [Fact] + public async Task Should_generate_signing_credentials_once() + { + A.CallTo(() => store.ReadAsync(A._)) + .Returns((null!, 0)); + + var credentials1 = await sut.GetSigningCredentialsAsync(); + var credentials2 = await sut.GetSigningCredentialsAsync(); + + Assert.Same(credentials1, credentials2); + + A.CallTo(() => store.ReadAsync(A._)) + .MustHaveHappenedOnceExactly(); + + A.CallTo(() => store.WriteAsync(A._, A._, 0, 0)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Should_generate_validation_keys_once() + { + A.CallTo(() => store.ReadAsync(A._)) + .Returns((null!, 0)); + + var credentials1 = await sut.GetValidationKeysAsync(); + var credentials2 = await sut.GetValidationKeysAsync(); + + Assert.Same(credentials1, credentials2); + + A.CallTo(() => store.ReadAsync(A._)) + .MustHaveHappenedOnceExactly(); + + A.CallTo(() => store.WriteAsync(A._, A._, 0, 0)) + .MustHaveHappenedOnceExactly(); + } + } +} diff --git a/backend/tests/Squidex.Domain.Users.Tests/DefaultXmlRepositoryTests.cs b/backend/tests/Squidex.Domain.Users.Tests/DefaultXmlRepositoryTests.cs new file mode 100644 index 000000000..9881c5ca5 --- /dev/null +++ b/backend/tests/Squidex.Domain.Users.Tests/DefaultXmlRepositoryTests.cs @@ -0,0 +1,61 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using FakeItEasy; +using Squidex.Infrastructure.States; +using Xunit; + +namespace Squidex.Domain.Users +{ + public sealed class DefaultXmlRepositoryTests + { + private readonly ISnapshotStore store = A.Fake>(); + private readonly DefaultXmlRepository sut; + + public DefaultXmlRepositoryTests() + { + sut = new DefaultXmlRepository(store); + } + + [Fact] + public void Should_read_from_store() + { + A.CallTo(() => store.ReadAllAsync(A< Func>._, A._)) + .Invokes((Func callback, CancellationToken _) => + { + callback(new DefaultXmlRepository.State + { + Xml = new XElement("a").ToString() + }, 0); + + callback(new DefaultXmlRepository.State + { + Xml = new XElement("b").ToString() + }, 0); + }); + + var xml = sut.GetAllElements(); + + Assert.Equal(2, xml.Count); + } + + [Fact] + public void Should_write_to_store() + { + var xml = new XElement("x"); + + sut.StoreElement(xml, "name"); + + A.CallTo(() => store.WriteAsync("name", A._, A._, 0)) + .MustHaveHappened(); + } + } +}