diff --git a/docs/decisions/0100-authentication.md b/docs/decisions/0100-authentication.md new file mode 100644 index 00000000..ae566a59 --- /dev/null +++ b/docs/decisions/0100-authentication.md @@ -0,0 +1,57 @@ +# Authentication + +* status: accepted +* date: 2024-01-01 +* deciders: jezzsantos + +# Context and Problem Statement + +AuthN (short for Authentication) is not to be confused with AuthZ (short for Authorization). + +AuthN is essentially the process of identifying an end-user given some kind of proof that they provide (common forms include usernames+passwords, pin numbers etc). Once that proof is verified, the end-user can be identified, and information about the end-user can be used downstream to authorize the user and apply rules to their use of the software. + +Today there are many options for integrating AuthN into a SaaS product, and each and everyone of those options represents a set of trade-offs in many things, including: + +* Cost +* Maintainability +* Complexity +* Flexibility +* Capability and Familiarity, +* Vendor Lock-In, +* Security, etc + +Given this is a SaaS context, for small teams, and given we are building a backend API plus a separate front end web app, we need to aim to: + +* Choose a decent (well-known) integration that can added easily (at low cost), +* Offer reasonable flexibility (to expand to changing needs of a particular business). +* The chosen solution must be customized easily, and can be easily upgraded or replaced later with other integrations. +* It must be secure and multi-client friendly (e.g., web app and mobile app and machine-to-machine) when applied to a backend API (i.e. not bound to cookies). +* We prefer to utilize refreshable, transparent, signed JWT tokens. +* We will need it to be extensible to accommodate Single-Sign-On (SSO) scenarios. +* It needs to be very secure, and we need to ship user credentials solution out of the box. + +Lastly, we need to introduce some minimal abstractions to make that integration easier to understand and to rip out and change later (e.g., ports and adapters). + +## Considered Options + +The options are: + +1. Custom Implementation +2. ASP.NET Core Identity +3. Auth0 +4. Duende IdentityServer (https://duendesoftware.com/products/identityserver) +5. OpenIddict (https://documentation.openiddict.com/) +6. etc + +## Decision Outcome + +`Custom Implementation` + +- No additional operational costs, (unlike IdentityServer, Auth0, etc) +- Can be swapped out for an implementation of IdentityServer, Auth0, Okta, or other solution. +- Has decent support for most of the most common capabilities for SaaS business e.g. Transparent JWT tokens, custom claims, Single Sign On integrations, MFA, Authenticator apps, password management, etc) +- Is a superior option to `ASP.NET Core Identity` since we are not limited to opaque non-JWT tokens (as they are in ANCI), and we can control the behaviour of each of the APIs, which cannot be done in ANCI. + +## More Information + +See Andrew Locks discussion on using the [ASP.NET Core Identity APIs in .NET 8.0](https://andrewlock.net/should-you-use-the-dotnet-8-identity-api-endpoints/#what-are-the-new-identity-api-endpoints-) diff --git a/docs/design-principles/0090-authentication-authorization.md.md b/docs/design-principles/0090-authentication-authorization.md.md new file mode 100644 index 00000000..20818abb --- /dev/null +++ b/docs/design-principles/0090-authentication-authorization.md.md @@ -0,0 +1,15 @@ +# Authentication & Authorization + +## Design Principles + +## Implementation + +Cookie Authentication + +Usually performed by a BackendForFrontend component, reverse-proxies the token hidden in the cookie, into a token passed to the backend + +Authorization + +For marked endpoints, verifies that the cookie exists. + + \ No newline at end of file diff --git a/docs/images/Physical-Architecture-AWS.png b/docs/images/Physical-Architecture-AWS.png index c59f5f4a..ab1f9ec9 100644 Binary files a/docs/images/Physical-Architecture-AWS.png and b/docs/images/Physical-Architecture-AWS.png differ diff --git a/docs/images/Physical-Architecture-Azure.png b/docs/images/Physical-Architecture-Azure.png index cb610806..0de057cb 100644 Binary files a/docs/images/Physical-Architecture-Azure.png and b/docs/images/Physical-Architecture-Azure.png differ diff --git a/docs/images/Recorder-AWS.png b/docs/images/Recorder-AWS.png new file mode 100644 index 00000000..49f93cad Binary files /dev/null and b/docs/images/Recorder-AWS.png differ diff --git a/docs/images/Sources.pptx b/docs/images/Sources.pptx index 43fc1aa5..8e3a1b73 100644 Binary files a/docs/images/Sources.pptx and b/docs/images/Sources.pptx differ diff --git a/docs/images/Subdomains.png b/docs/images/Subdomains.png index de6b5734..0e05e730 100644 Binary files a/docs/images/Subdomains.png and b/docs/images/Subdomains.png differ diff --git a/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs b/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs index a1d3d25d..865eec22 100644 --- a/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs +++ b/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs @@ -1,5 +1,5 @@ using Application.Interfaces.Services; -using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Common; using Common.Configuration; using Common.Recording; diff --git a/src/AWSLambdas.Api.WorkerHost/Lambdas/DeliverAudit.cs b/src/AWSLambdas.Api.WorkerHost/Lambdas/DeliverAudit.cs index af7c07c7..2201eff6 100644 --- a/src/AWSLambdas.Api.WorkerHost/Lambdas/DeliverAudit.cs +++ b/src/AWSLambdas.Api.WorkerHost/Lambdas/DeliverAudit.cs @@ -1,7 +1,7 @@ using Amazon.Lambda.Annotations; using Amazon.Lambda.Core; using Amazon.Lambda.SQSEvents; -using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using AWSLambdas.Api.WorkerHost.Extensions; using Infrastructure.Workers.Api; diff --git a/src/AWSLambdas.Api.WorkerHost/Lambdas/DeliverUsage.cs b/src/AWSLambdas.Api.WorkerHost/Lambdas/DeliverUsage.cs index 06baa066..54b5864d 100644 --- a/src/AWSLambdas.Api.WorkerHost/Lambdas/DeliverUsage.cs +++ b/src/AWSLambdas.Api.WorkerHost/Lambdas/DeliverUsage.cs @@ -1,7 +1,7 @@ using Amazon.Lambda.Annotations; using Amazon.Lambda.Core; using Amazon.Lambda.SQSEvents; -using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using AWSLambdas.Api.WorkerHost.Extensions; using Infrastructure.Workers.Api; diff --git a/src/AncillaryApplication.UnitTests/AncillaryApplicationSpec.cs b/src/AncillaryApplication.UnitTests/AncillaryApplicationSpec.cs index 6a915bbd..c5c7a477 100644 --- a/src/AncillaryApplication.UnitTests/AncillaryApplicationSpec.cs +++ b/src/AncillaryApplication.UnitTests/AncillaryApplicationSpec.cs @@ -2,6 +2,7 @@ using AncillaryDomain; using Application.Interfaces; using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Common; using Common.Extensions; using Domain.Common.Identity; diff --git a/src/AncillaryApplication/AncillaryApplication.cs b/src/AncillaryApplication/AncillaryApplication.cs index 8498ce05..f9d08151 100644 --- a/src/AncillaryApplication/AncillaryApplication.cs +++ b/src/AncillaryApplication/AncillaryApplication.cs @@ -4,6 +4,7 @@ using Application.Interfaces; using Application.Persistence.Interfaces; using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Application.Resources.Shared; using Common; using Common.Extensions; diff --git a/src/AncillaryInfrastructure.IntegrationTests/AuditsApiSpec.cs b/src/AncillaryInfrastructure.IntegrationTests/AuditsApiSpec.cs index 062277e3..543ee7e6 100644 --- a/src/AncillaryInfrastructure.IntegrationTests/AuditsApiSpec.cs +++ b/src/AncillaryInfrastructure.IntegrationTests/AuditsApiSpec.cs @@ -1,6 +1,7 @@ using AncillaryInfrastructure.IntegrationTests.Stubs; using ApiHost1; using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Common; using Common.Extensions; using FluentAssertions; diff --git a/src/AncillaryInfrastructure.IntegrationTests/UsagesApiSpec.cs b/src/AncillaryInfrastructure.IntegrationTests/UsagesApiSpec.cs index 80b12fe0..7db627ef 100644 --- a/src/AncillaryInfrastructure.IntegrationTests/UsagesApiSpec.cs +++ b/src/AncillaryInfrastructure.IntegrationTests/UsagesApiSpec.cs @@ -1,6 +1,7 @@ using AncillaryInfrastructure.IntegrationTests.Stubs; using ApiHost1; using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Common; using Common.Extensions; using FluentAssertions; diff --git a/src/AncillaryInfrastructure/AncillaryInfrastructure.csproj b/src/AncillaryInfrastructure/AncillaryInfrastructure.csproj index f8f43fef..b6f81fb5 100644 --- a/src/AncillaryInfrastructure/AncillaryInfrastructure.csproj +++ b/src/AncillaryInfrastructure/AncillaryInfrastructure.csproj @@ -7,6 +7,7 @@ + diff --git a/src/AncillaryInfrastructure/AncillaryModule.cs b/src/AncillaryInfrastructure/AncillaryModule.cs index 327eedb0..473298a7 100644 --- a/src/AncillaryInfrastructure/AncillaryModule.cs +++ b/src/AncillaryInfrastructure/AncillaryModule.cs @@ -31,12 +31,7 @@ public class AncillaryModule : ISubDomainModule { typeof(AuditRoot), "audit" } }; - public Action MinimalApiRegistrationFunction - { - get { return app => app.RegisterRoutes(); } - } - - public Action RegisterServicesFunction + public Action RegisterServices { get { @@ -48,6 +43,8 @@ public Action RegisterServicesFunction new UsageMessageQueueRepository(c.Resolve(), c.ResolveForPlatform())); services.RegisterUnshared(c => new AuditMessageQueueRepository(c.Resolve(), c.ResolveForPlatform())); + services.RegisterUnshared(c => + new EmailMessageQueueRepository(c.Resolve(), c.ResolveForPlatform())); services.RegisterUnshared(c => new AuditRepository(c.ResolveForUnshared(), c.ResolveForUnshared(), c.ResolveForUnshared>(), @@ -60,4 +57,9 @@ public Action RegisterServicesFunction }; } } + + public Action ConfigureMiddleware + { + get { return app => app.RegisterRoutes(); } + } } \ No newline at end of file diff --git a/src/AncillaryInfrastructure/Persistence/AuditRepository.cs b/src/AncillaryInfrastructure/Persistence/AuditRepository.cs index 29493d6e..9331ae84 100644 --- a/src/AncillaryInfrastructure/Persistence/AuditRepository.cs +++ b/src/AncillaryInfrastructure/Persistence/AuditRepository.cs @@ -51,15 +51,16 @@ public async Task> SaveAsync(AuditRoot audit, Cancellat public async Task, Error>> SearchAllAsync(Identifier organizationId, SearchOptions searchOptions, CancellationToken cancellationToken) { - var audits = await _auditQueries.QueryAsync(Query.From() + var queried = await _auditQueries.QueryAsync(Query.From() .Where(u => u.OrganizationId, ConditionOperator.EqualTo, organizationId) .WithSearchOptions(searchOptions), cancellationToken: cancellationToken); - if (!audits.IsSuccessful) + if (!queried.IsSuccessful) { - return audits.Error; + return queried.Error; } - return audits.Value.Results; + var audits = queried.Value.Results; + return audits; } public async Task> LoadAsync(Identifier organizationId, Identifier id, diff --git a/src/ApiHost1/Api/TestingOnly/TestingWebApi.cs b/src/ApiHost1/Api/TestingOnly/TestingWebApi.cs index 13770725..0b7db32d 100644 --- a/src/ApiHost1/Api/TestingOnly/TestingWebApi.cs +++ b/src/ApiHost1/Api/TestingOnly/TestingWebApi.cs @@ -7,6 +7,23 @@ namespace ApiHost1.Api.TestingOnly; public sealed class TestingWebApi : IWebApiService { + // ReSharper disable once InconsistentNaming + public async Task> AuthNHMAC( + AuthNHMACTestingOnlyRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + return () => new Result(new StringMessageTestingOnlyResponse + { Message = "amessage" }); + } + + public async Task> AuthNToken( + AuthNTokenTestingOnlyRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + return () => new Result(new StringMessageTestingOnlyResponse + { Message = "amessage" }); + } + public async Task> ContentNegotiationGet( ContentNegotiationsTestingOnlyRequest request, CancellationToken cancellationToken) { diff --git a/src/ApiHost1/ApiHost1.csproj b/src/ApiHost1/ApiHost1.csproj index 29fdfac6..e141ea30 100644 --- a/src/ApiHost1/ApiHost1.csproj +++ b/src/ApiHost1/ApiHost1.csproj @@ -6,6 +6,8 @@ + + diff --git a/src/ApiHost1/ApiHostModule.cs b/src/ApiHost1/ApiHostModule.cs new file mode 100644 index 00000000..8fe23507 --- /dev/null +++ b/src/ApiHost1/ApiHostModule.cs @@ -0,0 +1,47 @@ +using System.Reflection; +using AncillaryInfrastructure.Api.Usages; +using Application.Persistence.Shared; +using Application.Services.Shared; +using Common; +using Domain.Services.Shared.DomainServices; +using Infrastructure.Hosting.Common.Extensions; +using Infrastructure.Persistence.Interfaces; +using Infrastructure.Persistence.Shared.ApplicationServices; +using Infrastructure.Shared.ApplicationServices; +using Infrastructure.Web.Hosting.Common; + +namespace ApiHost1; + +/// +/// Provides a module for common services of a API host +/// +public class ApiHostModule : ISubDomainModule +{ + public Assembly ApiAssembly => typeof(UsagesApi).Assembly; + + public Assembly DomainAssembly => null!; + + public Dictionary AggregatePrefixes => new(); + + public Action RegisterServices + { + get + { + return (_, services) => + { + services.RegisterUnshared(c => + new EmailMessageQueueRepository(c.Resolve(), c.ResolveForPlatform())); + + services.RegisterUnshared(); + services.RegisterUnshared(); + services.RegisterUnshared(); + services.RegisterUnshared(); + }; + } + } + + public Action ConfigureMiddleware + { + get { return _ => { }; } + } +} \ No newline at end of file diff --git a/src/ApiHost1/HostedModules.cs b/src/ApiHost1/HostedModules.cs index e732d1ea..a079f854 100644 --- a/src/ApiHost1/HostedModules.cs +++ b/src/ApiHost1/HostedModules.cs @@ -1,6 +1,8 @@ using AncillaryInfrastructure; using BookingsInfrastructure; using CarsInfrastructure; +using EndUsersInfrastructure; +using IdentityInfrastructure; using Infrastructure.Web.Hosting.Common; namespace ApiHost1; @@ -10,6 +12,9 @@ public static class HostedModules public static SubDomainModules Get() { var modules = new SubDomainModules(); + modules.Register(new ApiHostModule()); + modules.Register(new EndUsersModule()); + modules.Register(new IdentityModule()); modules.Register(new AncillaryModule()); #if TESTINGONLY modules.Register(new TestingOnlyApiModule()); diff --git a/src/ApiHost1/TestingOnlyApiModule.cs b/src/ApiHost1/TestingOnlyApiModule.cs index 576d68e4..f7ea5ffa 100644 --- a/src/ApiHost1/TestingOnlyApiModule.cs +++ b/src/ApiHost1/TestingOnlyApiModule.cs @@ -9,18 +9,18 @@ public class TestingOnlyApiModule : ISubDomainModule { public Assembly ApiAssembly => typeof(TestingWebApi).Assembly; - public Assembly DomainAssembly => typeof(TestingWebApi).Assembly; + public Assembly DomainAssembly => null!; public Dictionary AggregatePrefixes => new(); - public Action MinimalApiRegistrationFunction + public Action RegisterServices { - get { return app => app.RegisterRoutes(); } + get { return (_, _) => { }; } } - public Action RegisterServicesFunction + public Action ConfigureMiddleware { - get { return (_, _) => { }; } + get { return app => app.RegisterRoutes(); } } } #endif \ No newline at end of file diff --git a/src/ApiHost1/appsettings.json b/src/ApiHost1/appsettings.json index 7f6f67cb..f9dd3e41 100644 --- a/src/ApiHost1/appsettings.json +++ b/src/ApiHost1/appsettings.json @@ -18,6 +18,11 @@ "LocalMachineJsonFileStore": { "RootPath": "./saastack/local" } + }, + "Notifications": { + "SenderProductName": "SaaStack", + "SenderEmailAddress": "noreply@saastack.com", + "SenderDisplayName": "Support" } }, "Hosts": { @@ -25,6 +30,17 @@ "BaseUrl": "https://localhost:5001", "HmacAuthNSecret": "asecret" }, + "IdentityApi": { + "BaseUrl": "https://localhost:5001", + "PasswordCredential": { + "CooldownPeriodInMinutes": 5, + "MaxFailedLogins": 5 + }, + "JWT": { + "SigningSecret": "asecretsigningkey", + "DefaultExpiryInMinutes": 15 + } + }, "WebsiteHost": { "BaseUrl": "https://localhost:5101" } diff --git a/src/Application.Common.UnitTests/CallerSpec.cs b/src/Application.Common.UnitTests/CallerSpec.cs index af2a6dbe..2c2cbba1 100644 --- a/src/Application.Common.UnitTests/CallerSpec.cs +++ b/src/Application.Common.UnitTests/CallerSpec.cs @@ -1,6 +1,6 @@ using Common; -using Domain.Common.Authorization; using Domain.Interfaces; +using Domain.Interfaces.Authorization; using FluentAssertions; using Moq; using Xunit; @@ -19,7 +19,7 @@ public void WhenCreateAsAnonymous_ThenReturnsANewCallForAnonymousCaller() result.Authorization.Should().BeNull(); result.IsAuthenticated.Should().BeFalse(); result.Roles.All.Should().BeEmpty(); - result.FeatureSets.All.Should().ContainSingle(UserFeatureSets.Basic); + result.FeatureLevels.All.Should().ContainSingle(PlatformFeatureLevels.Basic.Name); result.TenantId.Should().BeNull(); result.CallerId.Should().Be(CallerConstants.AnonymousUserId); result.CallId.Should().NotBeNull(); @@ -34,7 +34,7 @@ public void WhenCreateAsAnonymousTenant_ThenReturnsANewCallForAnonymousTenantedC result.Authorization.Should().BeNull(); result.IsAuthenticated.Should().BeFalse(); result.Roles.All.Should().BeEmpty(); - result.FeatureSets.All.Should().ContainSingle(UserFeatureSets.Basic); + result.FeatureLevels.All.Should().ContainSingle(PlatformFeatureLevels.Basic.Name); result.TenantId.Should().Be("atenantid"); result.CallerId.Should().Be(CallerConstants.AnonymousUserId); result.CallId.Should().NotBeNullOrEmpty(); @@ -54,7 +54,7 @@ public void WhenCreateAsCallerFromCall_ThenReturnsACustomCaller() result.Authorization.Should().BeNull(); result.IsAuthenticated.Should().BeFalse(); result.Roles.All.Should().BeEmpty(); - result.FeatureSets.All.Should().ContainSingle(UserFeatureSets.Basic); + result.FeatureLevels.All.Should().ContainSingle(PlatformFeatureLevels.Basic.Name); result.TenantId.Should().BeNull(); result.CallerId.Should().Be("acallerid"); result.CallId.Should().Be("acallid"); @@ -68,8 +68,8 @@ public void WhenCreateAsExternalWebHook_ThenReturnsWebhookServiceAccountCaller() result.IsServiceAccount.Should().BeTrue(); result.Authorization.Should().BeNull(); result.IsAuthenticated.Should().BeTrue(); - result.Roles.All.Should().ContainSingle(UserRoles.ServiceAccount); - result.FeatureSets.All.Should().ContainSingle(UserFeatureSets.Basic); + result.Roles.All.Should().ContainSingle(PlatformRoles.ServiceAccount); + result.FeatureLevels.All.Should().ContainSingle(PlatformFeatureLevels.Basic.Name); result.TenantId.Should().BeNull(); result.CallerId.Should().Be(CallerConstants.ExternalWebhookAccountUserId); result.CallId.Should().Be("acallid"); @@ -83,8 +83,8 @@ public void WhenCreateAsMaintenanceWithNoCall_ThenReturnsMaintenanceServiceAccou result.IsServiceAccount.Should().BeTrue(); result.Authorization.Should().BeNull(); result.IsAuthenticated.Should().BeTrue(); - result.Roles.All.Should().ContainSingle(UserRoles.ServiceAccount); - result.FeatureSets.All.Should().Contain(UserFeatureSets.Basic, UserFeatureSets.Pro, UserFeatureSets.Premium); + result.Roles.All.Should().ContainSingle(PlatformRoles.ServiceAccount); + result.FeatureLevels.All.Should().ContainSingle(PlatformFeatureLevels.Premium.Name); result.TenantId.Should().BeNull(); result.CallerId.Should().Be(CallerConstants.MaintenanceAccountUserId); result.CallId.Should().NotBeNullOrEmpty(); @@ -98,8 +98,8 @@ public void WhenCreateAsMaintenance_ThenReturnsMaintenanceServiceAccountWithAllF result.IsServiceAccount.Should().BeTrue(); result.Authorization.Should().BeNull(); result.IsAuthenticated.Should().BeTrue(); - result.Roles.All.Should().ContainSingle(UserRoles.ServiceAccount); - result.FeatureSets.All.Should().Contain(UserFeatureSets.Basic, UserFeatureSets.Pro, UserFeatureSets.Premium); + result.Roles.All.Should().ContainSingle(PlatformRoles.ServiceAccount); + result.FeatureLevels.All.Should().ContainSingle(PlatformFeatureLevels.Premium.Name); result.TenantId.Should().BeNull(); result.CallerId.Should().Be(CallerConstants.MaintenanceAccountUserId); result.CallId.Should().Be("acallid"); @@ -113,8 +113,8 @@ public void WhenCreateAsMaintenanceTenant_ThenReturnsMaintenanceServiceAccountWi result.IsServiceAccount.Should().BeTrue(); result.Authorization.Should().BeNull(); result.IsAuthenticated.Should().BeTrue(); - result.Roles.All.Should().ContainSingle(UserRoles.ServiceAccount); - result.FeatureSets.All.Should().Contain(UserFeatureSets.Basic, UserFeatureSets.Pro, UserFeatureSets.Premium); + result.Roles.All.Should().ContainSingle(PlatformRoles.ServiceAccount); + result.FeatureLevels.All.Should().ContainSingle(PlatformFeatureLevels.Premium.Name); result.TenantId.Should().Be("atenantid"); result.CallerId.Should().Be(CallerConstants.MaintenanceAccountUserId); result.CallId.Should().NotBeNullOrEmpty(); @@ -128,8 +128,8 @@ public void WhenCreateAsServiceClient_ThenReturnsServiceClientCaller() result.IsServiceAccount.Should().BeTrue(); result.Authorization.Should().BeNull(); result.IsAuthenticated.Should().BeTrue(); - result.Roles.All.Should().ContainSingle(UserRoles.ServiceAccount); - result.FeatureSets.All.Should().Contain(UserFeatureSets.Basic, UserFeatureSets.Pro, UserFeatureSets.Premium); + result.Roles.All.Should().ContainSingle(PlatformRoles.ServiceAccount); + result.FeatureLevels.All.Should().ContainSingle(PlatformFeatureLevels.Premium.Name); result.TenantId.Should().BeNull(); result.CallerId.Should().Be(CallerConstants.ServiceClientAccountUserId); result.CallId.Should().NotBeNullOrEmpty(); diff --git a/src/Application.Common/Caller.cs b/src/Application.Common/Caller.cs index 0b8378db..e1250656 100644 --- a/src/Application.Common/Caller.cs +++ b/src/Application.Common/Caller.cs @@ -1,7 +1,7 @@ using Application.Interfaces; using Common; -using Domain.Common.Authorization; using Domain.Interfaces; +using Domain.Interfaces.Authorization; namespace Application.Common; @@ -100,12 +100,12 @@ public AnonymousCaller(string? tenantId = null) { TenantId = tenantId; Roles = new ICallerContext.CallerRoles(); - FeatureSets = new ICallerContext.CallerFeatureSets(new[] { UserFeatureSets.Basic }, null); + FeatureLevels = new ICallerContext.CallerFeatureLevels(new[] { PlatformFeatureLevels.Basic }, null); } public ICallerContext.CallerRoles Roles { get; } - public ICallerContext.CallerFeatureSets FeatureSets { get; } + public ICallerContext.CallerFeatureLevels FeatureLevels { get; } public string? Authorization => null; @@ -129,15 +129,15 @@ public MaintenanceAccountCaller(string? callId = null, string? tenantId = null) { CallId = callId ?? GenerateCallId(); TenantId = tenantId; - Roles = new ICallerContext.CallerRoles(new[] { UserRoles.ServiceAccount }, null); - FeatureSets = - new ICallerContext.CallerFeatureSets( - new[] { UserFeatureSets.Basic, UserFeatureSets.Pro, UserFeatureSets.Premium }, null); + Roles = new ICallerContext.CallerRoles(new[] { PlatformRoles.ServiceAccount }, null); + FeatureLevels = + new ICallerContext.CallerFeatureLevels( + new[] { PlatformFeatureLevels.Premium }, null); } public ICallerContext.CallerRoles Roles { get; } - public ICallerContext.CallerFeatureSets FeatureSets { get; } + public ICallerContext.CallerFeatureLevels FeatureLevels { get; } public string? Authorization => null; @@ -157,10 +157,10 @@ public MaintenanceAccountCaller(string? callId = null, string? tenantId = null) /// private sealed class ServiceClientAccountCaller : ICallerContext { - public ICallerContext.CallerRoles Roles { get; } = new(new[] { UserRoles.ServiceAccount }, null); + public ICallerContext.CallerRoles Roles { get; } = new(new[] { PlatformRoles.ServiceAccount }, null); - public ICallerContext.CallerFeatureSets FeatureSets { get; } = new( - new[] { UserFeatureSets.Basic, UserFeatureSets.Pro, UserFeatureSets.Premium }, null); + public ICallerContext.CallerFeatureLevels FeatureLevels { get; } = new( + new[] { PlatformFeatureLevels.Premium }, null); public string? Authorization => null; @@ -183,13 +183,13 @@ private sealed class ExternalWebHookAccountCaller : ICallerContext public ExternalWebHookAccountCaller(string? callId = null) { CallId = callId ?? GenerateCallId(); - Roles = new ICallerContext.CallerRoles(new[] { UserRoles.ServiceAccount }, null); - FeatureSets = new ICallerContext.CallerFeatureSets(new[] { UserFeatureSets.Basic }, null); + Roles = new ICallerContext.CallerRoles(new[] { PlatformRoles.ServiceAccount }, null); + FeatureLevels = new ICallerContext.CallerFeatureLevels(new[] { PlatformFeatureLevels.Basic }, null); } public ICallerContext.CallerRoles Roles { get; } - public ICallerContext.CallerFeatureSets FeatureSets { get; } + public ICallerContext.CallerFeatureLevels FeatureLevels { get; } public string? Authorization => null; @@ -215,12 +215,12 @@ public CustomCaller(ICallContext call) CallId = call.CallId; TenantId = call.TenantId; Roles = new ICallerContext.CallerRoles(); - FeatureSets = new ICallerContext.CallerFeatureSets(new[] { UserFeatureSets.Basic }, null); + FeatureLevels = new ICallerContext.CallerFeatureLevels(new[] { PlatformFeatureLevels.Basic }, null); } public ICallerContext.CallerRoles Roles { get; } - public ICallerContext.CallerFeatureSets FeatureSets { get; } + public ICallerContext.CallerFeatureLevels FeatureLevels { get; } public string? Authorization => null; diff --git a/src/Application.Interfaces/Application.Interfaces.csproj b/src/Application.Interfaces/Application.Interfaces.csproj index 8617f9ef..faa44246 100644 --- a/src/Application.Interfaces/Application.Interfaces.csproj +++ b/src/Application.Interfaces/Application.Interfaces.csproj @@ -6,11 +6,26 @@ - + + + + PublicResXFileCodeGenerator + Audits.Designer.cs + + + + + + True + True + Audits.resx + + + diff --git a/src/Application.Interfaces/Audits.Designer.cs b/src/Application.Interfaces/Audits.Designer.cs new file mode 100644 index 00000000..a7ca1e4e --- /dev/null +++ b/src/Application.Interfaces/Audits.Designer.cs @@ -0,0 +1,107 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Application.Interfaces { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Audits { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Audits() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Application.Interfaces.Audits", typeof(Audits).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Authentication.Failed.AccountLocked. + /// + public static string PasswordCredentialsApplication_Authenticate_AccountLocked { + get { + return ResourceManager.GetString("PasswordCredentialsApplication_Authenticate_AccountLocked", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Authentication.Failed.AccountSuspended. + /// + public static string PasswordCredentialsApplication_Authenticate_AccountSuspended { + get { + return ResourceManager.GetString("PasswordCredentialsApplication_Authenticate_AccountSuspended", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Authentication.Failed.BeforeVerified. + /// + public static string PasswordCredentialsApplication_Authenticate_BeforeVerified { + get { + return ResourceManager.GetString("PasswordCredentialsApplication_Authenticate_BeforeVerified", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Authentication.Failed.InvalidCredentials. + /// + public static string PasswordCredentialsApplication_Authenticate_InvalidCredentials { + get { + return ResourceManager.GetString("PasswordCredentialsApplication_Authenticate_InvalidCredentials", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Authentication.Passed. + /// + public static string PasswordCredentialsApplication_Authenticate_Succeeded { + get { + return ResourceManager.GetString("PasswordCredentialsApplication_Authenticate_Succeeded", resourceCulture); + } + } + } +} diff --git a/src/Application.Interfaces/Audits.resx b/src/Application.Interfaces/Audits.resx new file mode 100644 index 00000000..be06a945 --- /dev/null +++ b/src/Application.Interfaces/Audits.resx @@ -0,0 +1,42 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + Authentication.Failed.AccountSuspended + + + Authentication.Failed.AccountLocked + + + Authentication.Failed.InvalidCredentials + + + Authentication.Failed.BeforeVerified + + + Authentication.Passed + + \ No newline at end of file diff --git a/src/Application.Interfaces/ICallerContext.cs b/src/Application.Interfaces/ICallerContext.cs index 1548f2d1..2d42f03b 100644 --- a/src/Application.Interfaces/ICallerContext.cs +++ b/src/Application.Interfaces/ICallerContext.cs @@ -1,3 +1,5 @@ +using Domain.Interfaces.Authorization; + namespace Application.Interfaces; /// @@ -23,7 +25,7 @@ public interface ICallerContext /// /// The feature sets belonging to the caller /// - CallerFeatureSets FeatureSets { get; } + CallerFeatureLevels FeatureLevels { get; } /// /// Whether the called is authenticated or not @@ -75,27 +77,27 @@ public CallerRoles(string[]? user, string[]? organization) /// /// Defines the sets of features that a caller can have /// - public class CallerFeatureSets + public class CallerFeatureLevels { - public CallerFeatureSets() + public CallerFeatureLevels() { - All = Array.Empty(); - User = Array.Empty(); - Organization = Array.Empty(); + All = Array.Empty(); + Platform = Array.Empty(); + Organization = Array.Empty(); } - public CallerFeatureSets(string[]? user, string[]? organization) + public CallerFeatureLevels(FeatureLevel[]? platform, FeatureLevel[]? organization) { - User = user ?? Array.Empty(); - Organization = organization ?? Array.Empty(); - All = User.Concat(Organization) + Platform = platform ?? Array.Empty(); + Organization = organization ?? Array.Empty(); + All = Platform.Concat(Organization) .ToArray(); } - public string[] All { get; } + public FeatureLevel[] All { get; } - public string[] Organization { get; } + public FeatureLevel[] Organization { get; } - public string[] User { get; } + public FeatureLevel[] Platform { get; } } } \ No newline at end of file diff --git a/src/Application.Persistence.Interfaces/IMessageQueueStore.cs b/src/Application.Persistence.Interfaces/IMessageQueueStore.cs index 4a29f73e..d76f2a8a 100644 --- a/src/Application.Persistence.Interfaces/IMessageQueueStore.cs +++ b/src/Application.Persistence.Interfaces/IMessageQueueStore.cs @@ -30,5 +30,5 @@ Task> PopSingleAsync( /// /// Adds a new message to the queue /// - Task> PushAsync(ICallContext call, TMessage message, CancellationToken cancellationToken); + Task> PushAsync(ICallContext call, TMessage message, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Application.Persistence.Shared/Application.Persistence.Shared.csproj b/src/Application.Persistence.Shared/Application.Persistence.Shared.csproj index c64b5399..c7562ece 100644 --- a/src/Application.Persistence.Shared/Application.Persistence.Shared.csproj +++ b/src/Application.Persistence.Shared/Application.Persistence.Shared.csproj @@ -15,8 +15,4 @@ - - - - diff --git a/src/Application.Persistence.Shared/IAuditMessageQueueRepository.cs b/src/Application.Persistence.Shared/IAuditMessageQueueRepository.cs index 5637e353..7fb68f2a 100644 --- a/src/Application.Persistence.Shared/IAuditMessageQueueRepository.cs +++ b/src/Application.Persistence.Shared/IAuditMessageQueueRepository.cs @@ -1,4 +1,5 @@ using Application.Persistence.Interfaces; +using Application.Persistence.Shared.ReadModels; using Common; namespace Application.Persistence.Shared; diff --git a/src/Application.Persistence.Shared/IEmailMessageQueueRepository.cs b/src/Application.Persistence.Shared/IEmailMessageQueueRepository.cs new file mode 100644 index 00000000..ba8f7d88 --- /dev/null +++ b/src/Application.Persistence.Shared/IEmailMessageQueueRepository.cs @@ -0,0 +1,10 @@ +using Application.Persistence.Interfaces; +using Application.Persistence.Shared.ReadModels; +using Common; + +namespace Application.Persistence.Shared; + +public interface IEmailMessageQueueRepository : IMessageQueueStore, IApplicationRepository +{ + new Task> DestroyAllAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Application.Persistence.Shared/IUsageMessageQueueRepository.cs b/src/Application.Persistence.Shared/IUsageMessageQueueRepository.cs index 362166de..59652482 100644 --- a/src/Application.Persistence.Shared/IUsageMessageQueueRepository.cs +++ b/src/Application.Persistence.Shared/IUsageMessageQueueRepository.cs @@ -1,4 +1,5 @@ using Application.Persistence.Interfaces; +using Application.Persistence.Shared.ReadModels; using Common; namespace Application.Persistence.Shared; diff --git a/src/Application.Persistence.Shared/AuditMessage.cs b/src/Application.Persistence.Shared/ReadModels/AuditMessage.cs similarity index 83% rename from src/Application.Persistence.Shared/AuditMessage.cs rename to src/Application.Persistence.Shared/ReadModels/AuditMessage.cs index 50e893e2..10238e7d 100644 --- a/src/Application.Persistence.Shared/AuditMessage.cs +++ b/src/Application.Persistence.Shared/ReadModels/AuditMessage.cs @@ -1,6 +1,6 @@ using QueryAny; -namespace Application.Persistence.Shared; +namespace Application.Persistence.Shared.ReadModels; [EntityName("audits")] public class AuditMessage : QueuedMessage diff --git a/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs b/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs new file mode 100644 index 00000000..239209fa --- /dev/null +++ b/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs @@ -0,0 +1,24 @@ +using QueryAny; + +namespace Application.Persistence.Shared.ReadModels; + +[EntityName("emails")] +public class EmailMessage : QueuedMessage +{ + public QueuedEmailHtmlMessage? Html { get; set; } +} + +public class QueuedEmailHtmlMessage +{ + public string? FromDisplayName { get; set; } + + public string? FromEmail { get; set; } + + public string? HtmlBody { get; set; } + + public string? Subject { get; set; } + + public string? ToDisplayName { get; set; } + + public string? ToEmail { get; set; } +} \ No newline at end of file diff --git a/src/Application.Persistence.Shared/QueuedMessage.cs b/src/Application.Persistence.Shared/ReadModels/QueuedMessage.cs similarity index 83% rename from src/Application.Persistence.Shared/QueuedMessage.cs rename to src/Application.Persistence.Shared/ReadModels/QueuedMessage.cs index fbc937c0..f697eb7f 100644 --- a/src/Application.Persistence.Shared/QueuedMessage.cs +++ b/src/Application.Persistence.Shared/ReadModels/QueuedMessage.cs @@ -1,6 +1,6 @@ using Application.Persistence.Interfaces; -namespace Application.Persistence.Shared; +namespace Application.Persistence.Shared.ReadModels; public class QueuedMessage : IQueuedMessage { diff --git a/src/Application.Persistence.Shared/UsageMessage.cs b/src/Application.Persistence.Shared/ReadModels/UsageMessage.cs similarity index 81% rename from src/Application.Persistence.Shared/UsageMessage.cs rename to src/Application.Persistence.Shared/ReadModels/UsageMessage.cs index c02e3eae..88b23018 100644 --- a/src/Application.Persistence.Shared/UsageMessage.cs +++ b/src/Application.Persistence.Shared/ReadModels/UsageMessage.cs @@ -1,6 +1,6 @@ using QueryAny; -namespace Application.Persistence.Shared; +namespace Application.Persistence.Shared.ReadModels; [EntityName("usages")] public class UsageMessage : QueuedMessage diff --git a/src/Application.Resources.Shared/EndUser.cs b/src/Application.Resources.Shared/EndUser.cs new file mode 100644 index 00000000..7c89a690 --- /dev/null +++ b/src/Application.Resources.Shared/EndUser.cs @@ -0,0 +1,38 @@ +using Application.Interfaces.Resources; + +namespace Application.Resources.Shared; + +public class EndUser : IIdentifiableResource +{ + public EndUserAccess Access { get; set; } + + public List FeatureLevels { get; set; } = new(); + + public List Roles { get; set; } = new(); + + public EndUserStatus Status { get; set; } + + public required string Id { get; set; } +} + +public enum EndUserStatus +{ + Unregistered = 0, + Registered = 1 +} + +public enum EndUserAccess +{ + Enabled = 0, + Suspended = 1 +} + +public class RegisteredEndUser : EndUser +{ + public DefaultMembershipProfile? Profile { get; set; } +} + +public class DefaultMembershipProfile : Profile +{ + public string? DefaultOrganisationId { get; set; } +} \ No newline at end of file diff --git a/src/Application.Resources.Shared/Identity.cs b/src/Application.Resources.Shared/Identity.cs new file mode 100644 index 00000000..2cac5f96 --- /dev/null +++ b/src/Application.Resources.Shared/Identity.cs @@ -0,0 +1,10 @@ +namespace Application.Resources.Shared; + +public class AuthenticateTokens +{ + public required string AccessToken { get; set; } + + public required DateTime ExpiresOn { get; set; } + + public required string RefreshToken { get; set; } +} \ No newline at end of file diff --git a/src/Application.Resources.Shared/MachineCredential.cs b/src/Application.Resources.Shared/MachineCredential.cs new file mode 100644 index 00000000..4569d536 --- /dev/null +++ b/src/Application.Resources.Shared/MachineCredential.cs @@ -0,0 +1,12 @@ +using Application.Interfaces.Resources; + +namespace Application.Resources.Shared; + +public class MachineCredential : IIdentifiableResource +{ + public required string ApiKey { get; set; } + + public required string CreatorId { get; set; } + + public required string Id { get; set; } +} \ No newline at end of file diff --git a/src/Application.Resources.Shared/PasswordCredential.cs b/src/Application.Resources.Shared/PasswordCredential.cs new file mode 100644 index 00000000..37ffc4b5 --- /dev/null +++ b/src/Application.Resources.Shared/PasswordCredential.cs @@ -0,0 +1,10 @@ +using Application.Interfaces.Resources; + +namespace Application.Resources.Shared; + +public class PasswordCredential : IIdentifiableResource +{ + public required RegisteredEndUser User { get; set; } + + public required string Id { get; set; } +} \ No newline at end of file diff --git a/src/Application.Resources.Shared/Profile.cs b/src/Application.Resources.Shared/Profile.cs new file mode 100644 index 00000000..3edc7245 --- /dev/null +++ b/src/Application.Resources.Shared/Profile.cs @@ -0,0 +1,46 @@ +using Application.Interfaces.Resources; + +namespace Application.Resources.Shared; + +public class Profile : IIdentifiableResource +{ + public ProfileAddress? Address { get; set; } + + public string? AvatarUrl { get; set; } + + public required string DisplayName { get; set; } + + public required string EmailAddress { get; set; } + + public required PersonName Name { get; set; } + + public string? PhoneNumber { get; set; } + + public string? Timezone { get; set; } + + public required string Id { get; set; } +} + +public class PersonName +{ + public required string FirstName { get; set; } + + public string? LastName { get; set; } +} + +public class ProfileAddress +{ + public string? City { get; set; } + + public required string CountryCode { get; set; } + + public string? Line1 { get; set; } + + public string? Line2 { get; set; } + + public string? Line3 { get; set; } + + public string? State { get; set; } + + public string? Zip { get; set; } +} \ No newline at end of file diff --git a/src/Application.Services.Shared/IEmailQueuingService.cs b/src/Application.Services.Shared/IEmailQueuingService.cs new file mode 100644 index 00000000..0b587802 --- /dev/null +++ b/src/Application.Services.Shared/IEmailQueuingService.cs @@ -0,0 +1,30 @@ +using Application.Interfaces; +using Common; + +namespace Application.Services.Shared; + +/// +/// Defines an asynchronous email delivery service, that will queue messages for delivery +/// +public interface IEmailQueuingService +{ + Task> SendHtmlEmail(ICallerContext caller, HtmlEmail htmlEmail, CancellationToken cancellationToken); +} + +/// +/// Defines the contents of an HTML email message +/// +public class HtmlEmail +{ + public required string Body { get; set; } + + public required string FromDisplayName { get; set; } + + public required string FromEmailAddress { get; set; } + + public required string Subject { get; set; } + + public required string ToDisplayName { get; set; } + + public required string ToEmailAddress { get; set; } +} \ No newline at end of file diff --git a/src/Application.Services.Shared/IEndUsersService.cs b/src/Application.Services.Shared/IEndUsersService.cs new file mode 100644 index 00000000..90b5d339 --- /dev/null +++ b/src/Application.Services.Shared/IEndUsersService.cs @@ -0,0 +1,14 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + +namespace Application.Services.Shared; + +public interface IEndUsersService +{ + Task> GetPersonAsync(ICallerContext caller, string id, CancellationToken cancellationToken); + + Task> RegisterPersonAsync(ICallerContext caller, string emailAddress, + string firstName, string lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Application.Services.Shared/INotificationsService.cs b/src/Application.Services.Shared/INotificationsService.cs new file mode 100644 index 00000000..f5f10083 --- /dev/null +++ b/src/Application.Services.Shared/INotificationsService.cs @@ -0,0 +1,16 @@ +using Application.Interfaces; +using Common; + +namespace Application.Services.Shared; + +/// +/// Defines a notifications service for alerting users +/// +public interface INotificationsService +{ + /// + /// Notifies a user, via email, to confirm their account registration + /// + Task> NotifyPasswordRegistrationConfirmationAsync(ICallerContext caller, string emailAddress, + string name, string token, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Application.Services.Shared/IWebsiteUiService.cs b/src/Application.Services.Shared/IWebsiteUiService.cs new file mode 100644 index 00000000..0d76a3ba --- /dev/null +++ b/src/Application.Services.Shared/IWebsiteUiService.cs @@ -0,0 +1,9 @@ +namespace Application.Services.Shared; + +/// +/// Defines a service for constructing resources based on a known Website UI Application +/// +public interface IWebsiteUiService +{ + string ConstructPasswordRegistrationConfirmationPageUrl(string token); +} \ No newline at end of file diff --git a/src/AzureFunctions.Api.WorkerHost/Functions/DeliverAudit.cs b/src/AzureFunctions.Api.WorkerHost/Functions/DeliverAudit.cs index 3bfacf9c..2932560e 100644 --- a/src/AzureFunctions.Api.WorkerHost/Functions/DeliverAudit.cs +++ b/src/AzureFunctions.Api.WorkerHost/Functions/DeliverAudit.cs @@ -1,4 +1,4 @@ -using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Infrastructure.Workers.Api; using Infrastructure.Workers.Api.Workers; using Microsoft.Azure.Functions.Worker; diff --git a/src/AzureFunctions.Api.WorkerHost/Functions/DeliverUsage.cs b/src/AzureFunctions.Api.WorkerHost/Functions/DeliverUsage.cs index 1980739d..a1c62c80 100644 --- a/src/AzureFunctions.Api.WorkerHost/Functions/DeliverUsage.cs +++ b/src/AzureFunctions.Api.WorkerHost/Functions/DeliverUsage.cs @@ -1,4 +1,4 @@ -using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Infrastructure.Workers.Api; using Infrastructure.Workers.Api.Workers; using Microsoft.Azure.Functions.Worker; diff --git a/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs b/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs index 07c1697f..13246ff9 100644 --- a/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs +++ b/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs @@ -1,5 +1,5 @@ using Application.Interfaces.Services; -using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Common; using Common.Configuration; using Common.Recording; diff --git a/src/BookingsInfrastructure/BookingsModule.cs b/src/BookingsInfrastructure/BookingsModule.cs index df5cf0a0..f1776a57 100644 --- a/src/BookingsInfrastructure/BookingsModule.cs +++ b/src/BookingsInfrastructure/BookingsModule.cs @@ -23,12 +23,7 @@ public class BookingsModule : ISubDomainModule { typeof(BookingRoot), "booking" } }; - public Action MinimalApiRegistrationFunction - { - get { return app => app.RegisterRoutes(); } - } - - public Action RegisterServicesFunction + public Action RegisterServices { get { @@ -40,4 +35,9 @@ public Action RegisterServicesFunction }; } } + + public Action ConfigureMiddleware + { + get { return app => app.RegisterRoutes(); } + } } \ No newline at end of file diff --git a/src/BookingsInfrastructure/Persistence/BookingRepository.cs b/src/BookingsInfrastructure/Persistence/BookingRepository.cs index b5cb3650..091ede8e 100644 --- a/src/BookingsInfrastructure/Persistence/BookingRepository.cs +++ b/src/BookingsInfrastructure/Persistence/BookingRepository.cs @@ -83,16 +83,17 @@ public async Task, Error>> SearchAllBookingsAsync( DateTime from, DateTime to, SearchOptions searchOptions, CancellationToken cancellationToken) { - var bookings = await _bookingQueries.QueryAsync(Query.From() + var queried = await _bookingQueries.QueryAsync(Query.From() .Where(u => u.OrganizationId, ConditionOperator.EqualTo, organizationId) .AndWhere(u => u.Start, ConditionOperator.GreaterThanEqualTo, from) .AndWhere(u => u.End, ConditionOperator.LessThanEqualTo, to) .WithSearchOptions(searchOptions), cancellationToken: cancellationToken); - if (!bookings.IsSuccessful) + if (!queried.IsSuccessful) { - return bookings.Error; + return queried.Error; } - return bookings.Value.Results; + var bookings = queried.Value.Results; + return bookings; } } \ No newline at end of file diff --git a/src/CarsDomain/CausedBy.cs b/src/CarsDomain/CausedBy.cs index 63d13cf6..157676a5 100644 --- a/src/CarsDomain/CausedBy.cs +++ b/src/CarsDomain/CausedBy.cs @@ -10,7 +10,7 @@ public sealed class CausedBy : ValueObjectBase { public static Result Create(UnavailabilityCausedBy reason, string? reference) { - if (reference.HasValue()) + if (reference.Exists()) { if (reference.IsInvalidParameter(Validations.Unavailability.Reference, nameof(reference), Resources.CausedBy_InvalidReference, out var error1)) @@ -20,11 +20,12 @@ public static Result Create(UnavailabilityCausedBy reason, stri } else { - if (reference.IsInvalidParameter(_ => reason != UnavailabilityCausedBy.Reservation, + if (reason.IsInvalidParameter(r => r != UnavailabilityCausedBy.Reservation, nameof(reference), Resources.CausedBy_ReservationWithoutReference, out var error2)) { return error2; } + } return new CausedBy(reason, reference); diff --git a/src/CarsInfrastructure/CarsModule.cs b/src/CarsInfrastructure/CarsModule.cs index 37349242..59125e8d 100644 --- a/src/CarsInfrastructure/CarsModule.cs +++ b/src/CarsInfrastructure/CarsModule.cs @@ -30,12 +30,7 @@ public class CarsModule : ISubDomainModule { typeof(UnavailabilityEntity), "unavail" } }; - public Action MinimalApiRegistrationFunction - { - get { return app => app.RegisterRoutes(); } - } - - public Action RegisterServicesFunction + public Action RegisterServices { get { @@ -52,4 +47,9 @@ public Action RegisterServicesFunction }; } } + + public Action ConfigureMiddleware + { + get { return app => app.RegisterRoutes(); } + } } \ No newline at end of file diff --git a/src/CarsInfrastructure/Persistence/CarRepository.cs b/src/CarsInfrastructure/Persistence/CarRepository.cs index e0f0d0fd..b7b32b07 100644 --- a/src/CarsInfrastructure/Persistence/CarRepository.cs +++ b/src/CarsInfrastructure/Persistence/CarRepository.cs @@ -67,31 +67,33 @@ public async Task> SaveAsync(CarRoot car, CancellationTok public async Task, Error>> SearchAllAvailableCarsAsync(Identifier organizationId, DateTime from, DateTime to, SearchOptions searchOptions, CancellationToken cancellationToken) { - var unavailabilities = await _unavailabilitiesQueries.QueryAsync(Query.From() + var queriedUnavailabilities = await _unavailabilitiesQueries.QueryAsync(Query.From() .Where(u => u.OrganizationId, ConditionOperator.EqualTo, organizationId) .AndWhere(u => u.From, ConditionOperator.LessThanEqualTo, from) .AndWhere(u => u.To, ConditionOperator.GreaterThanEqualTo, to), cancellationToken: cancellationToken); - if (!unavailabilities.IsSuccessful) + if (!queriedUnavailabilities.IsSuccessful) { - return unavailabilities.Error; + return queriedUnavailabilities.Error; } + var unavailabilities = queriedUnavailabilities.Value.Results; var limit = searchOptions.Limit; var offset = searchOptions.Offset; searchOptions.ClearLimitAndOffset(); - var cars = await _carQueries.QueryAsync(Query.From() + var queriedCars = await _carQueries.QueryAsync(Query.From() .Where(u => u.OrganizationId, ConditionOperator.EqualTo, organizationId) .AndWhere(c => c.Status, ConditionOperator.EqualTo, CarStatus.Registered.ToString()) .WithSearchOptions(searchOptions), cancellationToken: cancellationToken); - if (!cars.IsSuccessful) + if (!queriedCars.IsSuccessful) { - return cars.Error; + return queriedCars.Error; } - return cars.Value.Results - .Where(car => unavailabilities.Value.Results.All(unavailability => unavailability.CarId != car.Id)) + var cars = queriedCars.Value.Results; + return cars + .Where(car => unavailabilities.All(unavailability => unavailability.CarId != car.Id)) .Skip(offset) .Take(limit) .ToList(); @@ -100,29 +102,31 @@ public async Task, Error>> SearchAllAvailableCarsAsync public async Task, Error>> SearchAllCarsAsync(Identifier organizationId, SearchOptions searchOptions, CancellationToken cancellationToken) { - var cars = await _carQueries.QueryAsync(Query.From() + var queried = await _carQueries.QueryAsync(Query.From() .Where(u => u.OrganizationId, ConditionOperator.EqualTo, organizationId) .WithSearchOptions(searchOptions), cancellationToken: cancellationToken); - if (!cars.IsSuccessful) + if (!queried.IsSuccessful) { - return cars.Error; + return queried.Error; } - return cars.Value.Results; + var cars = queried.Value.Results; + return cars; } public async Task, Error>> SearchAllCarUnavailabilitiesAsync( Identifier organizationId, Identifier id, SearchOptions searchOptions, CancellationToken cancellationToken) { - var unavailabilities = await _unavailabilitiesQueries.QueryAsync(Query.From() + var queried = await _unavailabilitiesQueries.QueryAsync(Query.From() .Where(u => u.OrganizationId, ConditionOperator.EqualTo, organizationId) .AndWhere(u => u.CarId, ConditionOperator.EqualTo, id) .WithSearchOptions(searchOptions), cancellationToken: cancellationToken); - if (!unavailabilities.IsSuccessful) + if (!queried.IsSuccessful) { - return unavailabilities.Error; + return queried.Error; } - return unavailabilities.Value.Results; + var unavailabilities = queried.Value.Results; + return unavailabilities; } } \ No newline at end of file diff --git a/src/Common.UnitTests/CountryCodesSpec.cs b/src/Common.UnitTests/CountryCodesSpec.cs new file mode 100644 index 00000000..d782164a --- /dev/null +++ b/src/Common.UnitTests/CountryCodesSpec.cs @@ -0,0 +1,132 @@ +using FluentAssertions; +using ISO._3166; +using Xunit; + +namespace Common.UnitTests; + +[Trait("Category", "Unit")] +public class CountryCodesSpec +{ + [Fact] + public void WhenExistsAndUnknown_ThenReturnsFalse() + { + var result = CountryCodes.Exists("notacountrycode"); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenExistsByAlpha2_ThenReturnsTrue() + { + var result = CountryCodes.Exists(CountryCodes.Default.Alpha2); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenExistsByAlpha3_ThenReturnsTrue() + { + var result = CountryCodes.Exists(CountryCodes.Default.Alpha3); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenExistsByNumeric_ThenReturnsTrue() + { + var result = CountryCodes.Exists(CountryCodes.Default.Numeric); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenFindAndUnknown_ThenReturnsNull() + { + var result = CountryCodes.Find("notacountrycode"); + + result.Should().BeNull(); + } + + [Fact] + public void WhenFindByAlpha2_ThenReturnsTrue() + { + var result = CountryCodes.Find(CountryCodes.Default.Alpha2); + + result.Should().Be(CountryCodes.Default); + } + + [Fact] + public void WhenFindByAlpha3_ThenReturnsTrue() + { + var result = CountryCodes.Find(CountryCodes.Default.Alpha3); + + result.Should().Be(CountryCodes.Default); + } + + [Fact] + public void WhenFindByNumeric_ThenReturnsTrue() + { + var result = CountryCodes.Find(CountryCodes.Default.Numeric); + + result.Should().Be(CountryCodes.Default); + } + + [Fact] + public void WhenFindForEveryCountryCode_ThenReturnsCode() + { + var countryCodes = CountryCodesResolver.GetList(); + foreach (var countryCode in countryCodes) + { + var result = CountryCodes.Find(countryCode.Alpha2); + + result.Should().NotBeNull($"{countryCode.Name} should have been found by Alpha2"); + } + + foreach (var countryCode in countryCodes) + { + var result = CountryCodes.Find(countryCode.Alpha3); + + result.Should().NotBeNull($"{countryCode.Name} should have been found by Alpha3"); + } + + foreach (var countryCode in countryCodes) + { + var result = CountryCodes.Find(countryCode.NumericCode); + + result.Should().NotBeNull($"{countryCode.Name} should have been found by NumericCode"); + } + } + + [Fact] + public void WhenCreateIso3166_ThenReturnsInstance() + { + var result = CountryCodeIso3166.Create("ashortname", "analpha2", "analpha3", "100"); + + result.ShortName.Should().Be("ashortname"); + result.Alpha2.Should().Be("analpha2"); + result.Alpha3.Should().Be("analpha3"); + result.Numeric.Should().Be("100"); + } + + [Fact] + public void WhenEqualsAndNotTheSameNumeric_ThenReturnsFalse() + { + var countryCode1 = CountryCodeIso3166.Create("ashortname", "analpha2", "analpha3", "100"); + var countryCode2 = CountryCodeIso3166.Create("ashortname", "analpha2", "analpha3", "101"); + + var result = countryCode1 == countryCode2; + + result.Should().BeFalse(); + } + + [Fact] + public void WhenEqualsAndSameNumeric_ThenReturnsTrue() + { + var countryCode1 = CountryCodeIso3166.Create("ashortname1", "analpha21", "analpha31", "100"); + var countryCode2 = CountryCodeIso3166.Create("ashortname2", "analpha22", "analpha32", "100"); + + var result = countryCode1 == countryCode2; + + result.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/src/Common.UnitTests/Extensions/DateTimeExtensionsSpec.cs b/src/Common.UnitTests/Extensions/DateTimeExtensionsSpec.cs index a02e7c10..64276db3 100644 --- a/src/Common.UnitTests/Extensions/DateTimeExtensionsSpec.cs +++ b/src/Common.UnitTests/Extensions/DateTimeExtensionsSpec.cs @@ -301,4 +301,70 @@ public void WhenHasValueAndNotMinValue_ThenReturnsTrue() result.Should().BeTrue(); } + + [Fact] + public void WhenIsBeforeAndAfter_ThenReturnsFalse() + { + var datum = DateTime.UtcNow; + var other = datum.SubtractSeconds(1); + + var result = datum.IsBefore(other); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenIsBeforeAndSame_ThenReturnsFalse() + { + var datum = DateTime.UtcNow; + var other = datum; + + var result = datum.IsBefore(other); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenIsBeforeAndBefore_ThenReturnsTrue() + { + var datum = DateTime.UtcNow; + var other = datum.AddSeconds(1); + + var result = datum.IsBefore(other); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenIsAfterAndBefore_ThenReturnsFalse() + { + var datum = DateTime.UtcNow; + var other = datum.AddSeconds(1); + + var result = datum.IsAfter(other); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenIsAfterAndSame_ThenReturnsFalse() + { + var datum = DateTime.UtcNow; + var other = datum; + + var result = datum.IsAfter(other); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenIsAfterAndAfter_ThenReturnsTrue() + { + var datum = DateTime.UtcNow; + var other = datum.SubtractSeconds(1); + + var result = datum.IsAfter(other); + + result.Should().BeTrue(); + } } \ No newline at end of file diff --git a/src/Common.UnitTests/Extensions/ObjectExtensionsSpec.cs b/src/Common.UnitTests/Extensions/ObjectExtensionsSpec.cs index 010070be..e5f1230f 100644 --- a/src/Common.UnitTests/Extensions/ObjectExtensionsSpec.cs +++ b/src/Common.UnitTests/Extensions/ObjectExtensionsSpec.cs @@ -141,4 +141,36 @@ public void WhenThrowIfNotValuedParameterAndValid_ThenReturns() FluentActions.Invoking(() => "avalue".ThrowIfNotValuedParameter("aparametername", "amessage")) .Should().NotThrow(); } + + [Fact] + public void WhenThrowIfInvalidParameterAndNullButValidates_ThenThrows() + { + FluentActions.Invoking(() => ((string?)null).ThrowIfInvalidParameter(_ => true, "aparametername", "amessage")) + .Should().Throw() + .WithMessage("amessage (Parameter 'aparametername')") + .And.ParamName.Should().Be("aparametername"); + } + + [Fact] + public void WhenThrowIfInvalidParameterAndEmptyButValidates_ThenReturns() + { + FluentActions.Invoking(() => string.Empty.ThrowIfInvalidParameter(_ => true, "aparametername", "amessage")) + .Should().NotThrow(); + } + + [Fact] + public void WhenThrowIfInvalidParameterAndInvalid_ThenReturns() + { + FluentActions.Invoking(() => "avalue".ThrowIfInvalidParameter(_ => false, "aparametername", "amessage")) + .Should().Throw() + .WithMessage("amessage (Parameter 'aparametername')") + .And.ParamName.Should().Be("aparametername"); + } + + [Fact] + public void WhenThrowIfInvalidParameterAndValid_ThenReturns() + { + FluentActions.Invoking(() => "avalue".ThrowIfInvalidParameter(_ => true, "aparametername", "amessage")) + .Should().NotThrow(); + } } \ No newline at end of file diff --git a/src/Common.UnitTests/Extensions/StringExtensionsSpec.cs b/src/Common.UnitTests/Extensions/StringExtensionsSpec.cs index 343f00c6..a5cbb39e 100644 --- a/src/Common.UnitTests/Extensions/StringExtensionsSpec.cs +++ b/src/Common.UnitTests/Extensions/StringExtensionsSpec.cs @@ -411,6 +411,38 @@ public void WhenWithoutTrailingSlashWithSlashes_ThenReturnsPathWithoutSlash() result.Should().Be("apath"); } + [Fact] + public void WhenTrimNonAlphaAndContainsNumbers_ThenReturnsOnlyAlphas() + { + var result = "a1b2c3".TrimNonAlpha(); + + result.Should().Be("abc"); + } + + [Fact] + public void WhenTrimNonAlphaAndContainsWhitespaceAndPunctuations_ThenReturnsOnlyAlphas() + { + var result = "a b\"c'".TrimNonAlpha(); + + result.Should().Be("abc"); + } + + [Fact] + public void WhenToTitleCaseWithSingleWord_ThenCases() + { + var result = "aword".ToTitleCase(); + + result.Should().Be("Aword"); + } + + [Fact] + public void WhenToTitleCaseWithWords_ThenCases() + { + var result = "aword1 aword2 aword3".ToTitleCase(); + + result.Should().Be("Aword1 Aword2 Aword3"); + } + private class SerializableClass { public string? AProperty { get; set; } diff --git a/src/Common.UnitTests/Extensions/TimeSpanExtensionsSpec.cs b/src/Common.UnitTests/Extensions/TimeSpanExtensionsSpec.cs new file mode 100644 index 00000000..9df49027 --- /dev/null +++ b/src/Common.UnitTests/Extensions/TimeSpanExtensionsSpec.cs @@ -0,0 +1,73 @@ +using Common.Extensions; +using FluentAssertions; +using Xunit; + +namespace Common.UnitTests.Extensions; + +[Trait("Category", "Unit")] +public class TimeSpanExtensionsSpec +{ + [Fact] + public void WhenToTimeSpanOrDefaultWithNullValue_ThenReturnsZero() + { + var result = ((string?)null).ToTimeSpanOrDefault(); + + result.Should().Be(TimeSpan.Zero); + } + + [Fact] + public void WhenToTimeSpanOrDefaultWithNonZeroValue_ThenReturnsValue() + { + var result = "PT1H".ToTimeSpanOrDefault(); + + result.Should().Be(TimeSpan.FromHours(1)); + } + + [Fact] + public void WhenToTimeSpanOrDefaultWithOtherValue_ThenReturnsZero() + { + var span = TimeSpan.FromDays(1); + + var result = span.ToString().ToTimeSpanOrDefault(); + + result.Should().Be(span); + } + + [Fact] + public void WhenToTimeSpanOrDefaultWithNullValueAndDefaultValue_ThenReturnsDefaultValue() + { + var defaultValue = TimeSpan.FromHours(1); + + var result = ((string?)null).ToTimeSpanOrDefault(defaultValue); + + result.Should().Be(defaultValue); + } + + [Fact] + public void WhenToTimeSpanOrDefaultWithInvalidTimeSpanValue_ThenReturnsZero() + { + var result = "notavalidtimespan".ToTimeSpanOrDefault(); + + result.Should().Be(TimeSpan.Zero); + } + + [Fact] + public void WhenToTimeSpanOrDefaultWithInvalidTimeSpanValueAndDefaultValue_ThenReturnsDefaultValue() + { + var defaultValue = TimeSpan.FromHours(1); + + var result = "notavalidtimespan".ToTimeSpanOrDefault(defaultValue); + + result.Should().Be(defaultValue); + } + + [Fact] + public void WhenToTimeSpanOrDefaultWithStringSerializedSpan_ThenReturnsTimeSpan() + { + var span = TimeSpan.FromHours(1); + + var result = span.ToString().ToTimeSpanOrDefault(); + + result.Should().Be(span); + } +} \ No newline at end of file diff --git a/src/Common.UnitTests/TimezonesSpec.cs b/src/Common.UnitTests/TimezonesSpec.cs new file mode 100644 index 00000000..c22f10c2 --- /dev/null +++ b/src/Common.UnitTests/TimezonesSpec.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using NodaTime; +using Xunit; + +namespace Common.UnitTests; + +[Trait("Category", "Unit")] +public class TimezonesSpec +{ + [Fact] + public void WhenExistsAndUnknown_ThenReturnsFalse() + { + var result = Timezones.Exists("notatimezone"); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenExistsById_ThenReturnsTrue() + { + var result = Timezones.Exists(Timezones.Default.Id); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenFindTimeZoneAndUnknown_ThenReturnsNull() + { + var result = Timezones.Find("notatimezone"); + + result.Should().BeNull(); + } + + [Fact] + public void WhenFindForNewZealand_ThenReturnsZone() + { + var result = Timezones.Find(Timezones.NewZealandIANA); + + result!.Id.Should().Be(Timezones.NewZealandIANA); + result.StandardCode.Should().Be("NZST"); + result.StandardOffset.Should().Be(TimeSpan.FromHours(12)); + result.HasDaylightSavings.Should().BeTrue(); + result.DaylightSavingsCode.Should().Be("NZDT"); + result.DaylightSavingsOffset.Should().Be(TimeSpan.FromHours(13)); + } + + [Fact] + public void WhenFindForNonDaylightSavingsTimezone_ThenReturnsZone() + { + var result = Timezones.Find("Pacific/Honolulu"); + + result!.Id.Should().Be("Pacific/Honolulu"); + result.StandardCode.Should().Be("HST"); + result.StandardOffset.Should().Be(TimeSpan.FromHours(-10)); + result.HasDaylightSavings.Should().BeFalse(); + result.DaylightSavingsCode.Should().BeNull(); + result.DaylightSavingsOffset.Should().Be(TimeSpan.Zero); + } + + [Fact] + public void WhenFindForEveryCountryCode_ThenReturnsCode() + { + var timezones = DateTimeZoneProviders.Tzdb.Ids; + foreach (var timezone in timezones) + { + var result = Timezones.Find(timezone); + + result.Should().NotBeNull($"{timezone} should have been found by Id"); + } + } +} \ No newline at end of file diff --git a/src/Common/Common.csproj b/src/Common/Common.csproj index 6323b3fd..80f6321d 100644 --- a/src/Common/Common.csproj +++ b/src/Common/Common.csproj @@ -7,7 +7,9 @@ + + diff --git a/src/Common/Configuration/ISettings.cs b/src/Common/Configuration/ISettings.cs index 498660af..ebbcbab9 100644 --- a/src/Common/Configuration/ISettings.cs +++ b/src/Common/Configuration/ISettings.cs @@ -7,9 +7,9 @@ public interface ISettings { public bool IsConfigured { get; } - public bool GetBool(string key); + public bool GetBool(string key, bool? defaultValue = null); - public double GetNumber(string key); + public double GetNumber(string key, double? defaultValue = null); - public string GetString(string key); + public string GetString(string key, string? defaultValue = null); } \ No newline at end of file diff --git a/src/Common/CountryCodes.cs b/src/Common/CountryCodes.cs new file mode 100644 index 00000000..d906eebc --- /dev/null +++ b/src/Common/CountryCodes.cs @@ -0,0 +1,154 @@ +using Common.Extensions; +using ISO._3166; + +namespace Common; + +public static class CountryCodes +{ + public static readonly CountryCodeIso3166 Australia = CountryCodeIso3166.Create("Australia", "AU", "AUS", "036"); + public static readonly CountryCodeIso3166 NewZealand = CountryCodeIso3166.Create("New Zealand", "NZ", "NZL", "554"); + public static readonly CountryCodeIso3166 Default = NewZealand; + +#if TESTINGONLY + public static readonly CountryCodeIso3166 Test = CountryCodeIso3166.Create("Test", "XX", "XXX", "001"); +#endif + /// + /// Whether the specified exists + /// + public static bool Exists(string? countryCode) + { + if (countryCode.NotExists()) + { + return false; + } + + return Find(countryCode).Exists(); + } + + /// + /// Returns the specified if it exists + /// + public static CountryCodeIso3166? Find(string countryCode) + { +#if TESTINGONLY + if (countryCode == Test.Alpha3 + || countryCode == Test.Alpha2 + || countryCode == Test.Numeric) + { + return Test; + } +#endif + var alpha3 = CountryCodesResolver.GetByAlpha3Code(countryCode); + if (alpha3.Exists()) + { + return CountryCodeIso3166.Create(alpha3.Name, alpha3.Alpha2, alpha3.Alpha3, alpha3.NumericCode); + } + + var alpha2 = CountryCodesResolver.GetByAlpha2Code(countryCode); + if (alpha2.Exists()) + { + return CountryCodeIso3166.Create(alpha2.Name, alpha2.Alpha2, alpha2.Alpha3, alpha2.NumericCode); + } + + var numeric = CountryCodesResolver.GetList().FirstOrDefault(cc => cc.NumericCode == countryCode); + if (numeric.Exists()) + { + return CountryCodeIso3166.Create(numeric.Name, numeric.Alpha2, numeric.Alpha3, numeric.NumericCode); + } + + return null; + } +} + +/// +/// See: https://en.wikipedia.org/wiki/ISO_3166-1 for details +/// +public sealed class CountryCodeIso3166 : IEquatable +{ + private CountryCodeIso3166(string numeric, string shortName, string alpha2, string alpha3) + { + numeric.ThrowIfInvalidParameter(num => + { + if (!int.TryParse(num, out var integer)) + { + return false; + } + + return integer is >= 1 and < 1000; + }, nameof(numeric), Resources.CountryCodeIso3166_InvalidNumeric.Format(numeric)); + Numeric = numeric; + ShortName = shortName; + Alpha2 = alpha2; + Alpha3 = alpha3; + } + + public string Alpha2 { get; } + + public string Alpha3 { get; } + + public string Numeric { get; } + + public string ShortName { get; private set; } + + public bool Equals(CountryCodeIso3166? other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Numeric == other.Numeric; + } + + internal static CountryCodeIso3166 Create(string shortName, string alpha2, + string alpha3, string numeric) + { + shortName.ThrowIfNotValuedParameter(nameof(shortName)); + alpha2.ThrowIfNotValuedParameter(nameof(alpha2)); + alpha3.ThrowIfNotValuedParameter(nameof(alpha3)); + + var instance = new CountryCodeIso3166(numeric, shortName, alpha2, alpha3); + + return instance; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != GetType()) + { + return false; + } + + return Equals((CountryCodeIso3166)obj); + } + + public override int GetHashCode() + { + return Numeric.GetHashCode(); + } + + public static bool operator ==(CountryCodeIso3166 left, CountryCodeIso3166 right) + { + return Equals(left, right); + } + + public static bool operator !=(CountryCodeIso3166 left, CountryCodeIso3166 right) + { + return !Equals(left, right); + } +} \ No newline at end of file diff --git a/src/Common/Extensions/DateTimeExtensions.cs b/src/Common/Extensions/DateTimeExtensions.cs index 46afca9f..3e2f8a67 100644 --- a/src/Common/Extensions/DateTimeExtensions.cs +++ b/src/Common/Extensions/DateTimeExtensions.cs @@ -106,6 +106,22 @@ public static bool HasValue(this DateOnly? value) return value.Value.HasValue(); } + /// + /// Whether the is after the + /// + public static bool IsAfter(this DateTime value, DateTime other) + { + return value > other; + } + + /// + /// Whether the is before the + /// + public static bool IsBefore(this DateTime value, DateTime other) + { + return value < other; + } + /// /// Subtracts the from the /// @@ -168,7 +184,7 @@ public static string ToIso8601(this DateTimeOffset value) return utcDateTime.ToString("O"); } - + /// /// Truncates the to the nearest second. /// diff --git a/src/Common/Extensions/ObjectExtensions.cs b/src/Common/Extensions/ObjectExtensions.cs index cdc8f053..7904a293 100644 --- a/src/Common/Extensions/ObjectExtensions.cs +++ b/src/Common/Extensions/ObjectExtensions.cs @@ -17,27 +17,41 @@ public static bool Exists([NotNullWhen(true)] this object? instance) /// /// Whether the parameter from being invalid according to the , - /// and if invalid, returns a + /// and if invalid, returns a error /// - public static bool IsInvalidParameter(this TValue value, Func validator, + public static bool IsInvalidParameter(this TValue? value, Func validator, string parameterName, string? errorMessage, out Error error) { + if (value.NotExists()) + { + error = errorMessage.HasValue() + ? Error.Validation(errorMessage) + : Error.Validation(parameterName); + return true; + } + return IsInvalidParameter(() => validator(value), parameterName, errorMessage, out error); } /// /// Whether the parameter from being invalid according to the , - /// and if invalid, returns a + /// and if invalid, returns a error /// - public static bool IsInvalidParameter(this TValue value, Func validator, + public static bool IsInvalidParameter(this TValue? value, Func validator, string parameterName, out Error error) { + if (value.NotExists()) + { + error = Error.Validation(parameterName); + return true; + } + return IsInvalidParameter(() => validator(value), parameterName, null, out error); } /// /// Whether the parameter has any value, - /// and if invalid, returns a + /// and if invalid, returns a error /// public static bool IsNotValuedParameter(this string? value, string parameterName, string? errorMessage, out Error error) @@ -47,7 +61,7 @@ public static bool IsNotValuedParameter(this string? value, string parameterName /// /// Whether the parameter has any value, - /// and if invalid, returns a + /// and if invalid, returns a error /// public static bool IsNotValuedParameter(this string? value, string parameterName, out Error error) { @@ -85,6 +99,18 @@ public static void PopulateWith(this TType target, IReadOnlyDictionary + /// Throws an if the specified is invalid + /// + public static void ThrowIfInvalidParameter(this TValue? value, Func validator, + string parameterName, string? errorMessage = null) + { + if (value.IsInvalidParameter(validator, parameterName, errorMessage, out _)) + { + throw new ArgumentOutOfRangeException(parameterName, errorMessage); + } + } + /// /// Throws an if the specified does not have a value /// diff --git a/src/Common/Extensions/StringExtensions.cs b/src/Common/Extensions/StringExtensions.cs index d439ca9b..896ffd75 100644 --- a/src/Common/Extensions/StringExtensions.cs +++ b/src/Common/Extensions/StringExtensions.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; @@ -10,7 +11,7 @@ namespace Common.Extensions; public static class StringExtensions { /// - /// Defines the casing used in JSON serialization + /// Defines the casing used in JSON serialization /// public enum JsonCasing { @@ -144,7 +145,7 @@ public static string ReplaceWith(this string value, [RegexPattern] string patter /// /// Converts the to a boolean value /// - public static bool ToBool(this string value) + public static bool ToBool(this string? value) { if (value.HasNoValue()) { @@ -176,7 +177,7 @@ public static bool ToBoolOrDefault(this string value, bool defaultValue) /// /// Converts the to a integer value /// - public static int ToInt(this string value) + public static int ToInt(this string? value) { if (value.HasNoValue()) { @@ -188,9 +189,9 @@ public static int ToInt(this string value) /// /// Converts the to a integer value, - /// and in the case where the value cannot be converted, uses the ; + /// and in the case where the value cannot be converted, uses the /// - public static int ToIntOrDefault(this string value, int defaultValue) + public static int ToIntOrDefault(this string? value, int defaultValue) { if (value.HasNoValue()) { @@ -232,6 +233,27 @@ public static int ToIntOrDefault(this string value, int defaultValue) }); } + /// + /// Returns the specified in title-case. i.e. first letter of words are capitalized + /// + public static string ToTitleCase(this string value) + { + return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(value).Replace("_", string.Empty); + } + + /// + /// Returns the specified including only letters (no numbers, or whitespace) + /// + public static string TrimNonAlpha(this string value) + { + if (value.HasNoValue()) + { + return value; + } + + return value.ReplaceWith(@"[^\p{L}]", string.Empty); + } + /// /// Returns the specified without any trailing slashes /// diff --git a/src/Common/Extensions/TimeSpanExtensions.cs b/src/Common/Extensions/TimeSpanExtensions.cs new file mode 100644 index 00000000..5e1caa9d --- /dev/null +++ b/src/Common/Extensions/TimeSpanExtensions.cs @@ -0,0 +1,43 @@ +using System.Xml; + +namespace Common.Extensions; + +public static class TimeSpanExtensions +{ + /// + /// Converts the to a TimeSpan value, + /// and in the case where the value cannot be converted, uses the + /// + public static TimeSpan ToTimeSpanOrDefault(this string? value, TimeSpan? defaultValue = null) + { + if (value.HasNoValue()) + { + return defaultValue ?? TimeSpan.Zero; + } + + if (TimeSpan.TryParse(value, out var span)) + { + return span; + } + + var iso8601 = ToIso8601TimeSpan(value); + if (iso8601 != TimeSpan.Zero) + { + return iso8601; + } + + return defaultValue ?? TimeSpan.Zero; + } + + private static TimeSpan ToIso8601TimeSpan(this string value) + { + try + { + return XmlConvert.ToTimeSpan(value); + } + catch (FormatException) + { + return TimeSpan.Zero; + } + } +} \ No newline at end of file diff --git a/src/Common/Repeat.cs b/src/Common/Repeat.cs index 9cf2d7c6..fa624417 100644 --- a/src/Common/Repeat.cs +++ b/src/Common/Repeat.cs @@ -1,10 +1,13 @@ -namespace Common; +using System.Diagnostics; + +namespace Common; public static class Repeat { /// /// Executes the specified times over in a loop. /// + [DebuggerStepThrough] public static void Times(Action action, int count) { Times(action, 0, count); @@ -13,17 +16,20 @@ public static void Times(Action action, int count) /// /// Executes the specified times over in a loop. /// + [DebuggerStepThrough] public static void Times(Action action, int count) { Times(action, 0, count); } + [DebuggerStepThrough] private static void Times(Action action, int from, int to) { var counter = Enumerable.Range(from, to).ToList(); counter.ForEach(_ => { action(); }); } + [DebuggerStepThrough] private static void Times(Action action, int from, int to) { var counter = Enumerable.Range(from, to).ToList(); diff --git a/src/Common/Resources.Designer.cs b/src/Common/Resources.Designer.cs index 5f0dd189..d12d74fe 100644 --- a/src/Common/Resources.Designer.cs +++ b/src/Common/Resources.Designer.cs @@ -59,6 +59,15 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to The Numeric '{0}' must be a 3 decimal number between 1 and 1000. + /// + internal static string CountryCodeIso3166_InvalidNumeric { + get { + return ResourceManager.GetString("CountryCodeIso3166_InvalidNumeric", resourceCulture); + } + } + /// /// Looks up a localized string similar to The value is null. /// @@ -105,11 +114,38 @@ internal static string Result_FetchValueWhenFaulted { } /// - /// Looks up a localized string similar to An unexpected error occurred. + /// Looks up a localized string similar to The DaylightSavingsCode '{0}' must be a three or four letter code, or the offset in hrs. + /// + internal static string TimezoneIana_InvalidDaylightSavingsCode { + get { + return ResourceManager.GetString("TimezoneIana_InvalidDaylightSavingsCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The DaylightSavingsOffset '{0}' must be between +14hrs and -14hrs. + /// + internal static string TimezoneIana_InvalidDaylightSavingsOffset { + get { + return ResourceManager.GetString("TimezoneIana_InvalidDaylightSavingsOffset", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The StandardCode '{0}' must be a three or four letter code, or the offset in hrs. + /// + internal static string TimezoneIana_InvalidStandardCode { + get { + return ResourceManager.GetString("TimezoneIana_InvalidStandardCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The StandardOffset '{0}' must be between +14hrs and -14hrs. /// - internal static string WebApplicationExtensions_AddExceptionShielding_UnexpectedExceptionMessage { + internal static string TimezoneIana_InvalidStandardOffset { get { - return ResourceManager.GetString("WebApplicationExtensions_AddExceptionShielding_UnexpectedExceptionMessage", resourceCulture); + return ResourceManager.GetString("TimezoneIana_InvalidStandardOffset", resourceCulture); } } } diff --git a/src/Common/Resources.resx b/src/Common/Resources.resx index 1308c3c6..16f16ebe 100644 --- a/src/Common/Resources.resx +++ b/src/Common/Resources.resx @@ -39,7 +39,20 @@ The result is not faulted, but has a value: `{0}` - - An unexpected error occurred + + The Numeric '{0}' must be a 3 decimal number between 1 and 1000 + + The StandardOffset '{0}' must be between +14hrs and -14hrs + + + The StandardCode '{0}' must be a three or four letter code, or the offset in hrs + + + The DaylightSavingsOffset '{0}' must be between +14hrs and -14hrs + + + The DaylightSavingsCode '{0}' must be a three or four letter code, or the offset in hrs + + \ No newline at end of file diff --git a/src/Common/Result.cs b/src/Common/Result.cs index 055039c5..98540936 100644 --- a/src/Common/Result.cs +++ b/src/Common/Result.cs @@ -152,7 +152,10 @@ public Result(TError error) /// /// Whether the contained result has a value /// - public bool IsSuccessful => !_error.HasValue; + public bool IsSuccessful + { + [DebuggerStepThrough] get => !_error.HasValue; + } /// /// Returns the contained if there is one @@ -200,12 +203,18 @@ public TError Error /// /// Returns whether the contained has a value /// - public bool HasValue => IsSuccessful && _value.HasValue; + public bool HasValue + { + [DebuggerStepThrough] get => IsSuccessful && _value.HasValue; + } /// /// Returns whether the contained has a value /// - public bool Exists => HasValue; + public bool Exists + { + [DebuggerStepThrough] get => HasValue; + } /// /// Creates a new in its faulted state, with the diff --git a/src/Common/Timezones.cs b/src/Common/Timezones.cs new file mode 100644 index 00000000..0dd2a14d --- /dev/null +++ b/src/Common/Timezones.cs @@ -0,0 +1,203 @@ +using Common.Extensions; +using NodaTime; + +namespace Common; + +public static class Timezones +{ + public const string NewZealandIANA = "Pacific/Auckland"; + public const string NewZealandWindows = "New Zealand Standard Time"; + public const string SydneyIANA = "Australia/Sydney"; + public const string UniversalCoordinatedIANA = "Etc/UTC"; + public const string UniversalCoordinatedWindows = "UTC"; + public static readonly TimezoneIANA Sydney = TimezoneIANA.Create(SydneyIANA, TimeSpan.FromHours(10), + "AEST", TimeSpan.FromHours(11), "AEDT"); + public static readonly TimezoneIANA NewZealand = TimezoneIANA.Create(NewZealandIANA, TimeSpan.FromHours(12), + "NZST", TimeSpan.FromHours(13), "NZDT"); + public static readonly TimezoneIANA Default = NewZealand; + +#if TESTINGONLY + public static readonly TimezoneIANA Test = TimezoneIANA.Create("testTimezone", TimeSpan.FromHours(1), "TSST", + TimeSpan.FromHours(1), "TSDT"); +#endif + + /// + /// Whether the specified exists + /// + public static bool Exists(string? timezone) + { + if (timezone.NotExists()) + { + return false; + } + + return Find(timezone).Exists(); + } + + /// + /// Returns the specified if it exists + /// + public static TimezoneIANA? Find(string timezone) + { +#if TESTINGONLY + if (timezone == Test.Id) + { + return Test; + } +#endif + var zone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(timezone); + if (zone.Exists()) + { + var startOfThisYear = + Instant.FromDateTimeUtc(DateTime.SpecifyKind(new DateTime(DateTime.UtcNow.Year, 1, 1), + DateTimeKind.Utc)); + var endOfYear = startOfThisYear.Plus(Duration.FromDays(365)).Minus(Duration.FromDays(1)); + + var intervals = zone.GetZoneIntervals(startOfThisYear, endOfYear) + .DistinctBy(z => z.Name) + .ToList(); + var standard = intervals.First(interval => interval.Savings == Offset.Zero); + var standardOffset = standard.WallOffset.ToTimeSpan(); + var standardCode = standard.Name; + var daylightSavings = intervals.FirstOrDefault(interval => interval.Savings > Offset.Zero); + var daylightSavingsOffset = daylightSavings.Exists() + ? daylightSavings.WallOffset.ToTimeSpan() + : TimeSpan.Zero; + var daylightSavingsCode = daylightSavings.Exists() + ? daylightSavings.Name + : null; + + return TimezoneIANA.Create(zone.Id, standardOffset, standardCode, daylightSavingsOffset, + daylightSavingsCode); + } + + return null; + } +} + +/// +/// Provides a IANA timezone +/// +public sealed class TimezoneIANA : IEquatable +{ + public static TimezoneIANA Create(string id, TimeSpan standardOffset, string standardCode, + TimeSpan daylightSavingsOffset, string? daylightSavingsCode) + { + id.ThrowIfNotValuedParameter(nameof(id)); + standardOffset.ThrowIfInvalidParameter(IsValidOffset, nameof(standardOffset), + Resources.TimezoneIana_InvalidStandardOffset.Format(standardOffset)); + standardCode.ThrowIfInvalidParameter(IsValidCode, nameof(standardCode), + Resources.TimezoneIana_InvalidStandardCode.Format(standardCode)); + daylightSavingsOffset.ThrowIfInvalidParameter(IsValidOffset, nameof(daylightSavingsOffset), + Resources.TimezoneIana_InvalidDaylightSavingsOffset.Format(daylightSavingsOffset)); + if (daylightSavingsCode.Exists()) + { + daylightSavingsCode.ThrowIfInvalidParameter(IsValidCode, + nameof(daylightSavingsCode), + Resources.TimezoneIana_InvalidDaylightSavingsCode.Format(daylightSavingsCode)); + } + + var instance = new TimezoneIANA(id, standardCode, daylightSavingsCode) + { + StandardOffset = standardOffset, + HasDaylightSavings = daylightSavingsCode.Exists(), + DaylightSavingsOffset = daylightSavingsOffset + }; + + return instance; + + bool IsValidOffset(TimeSpan num) + { + return num.TotalHours is >= -14 and <= 14; + } + + bool IsValidCode(string? code) + { + if (code.HasNoValue()) + { + return false; + } + + return code.IsMatchWith(@"^[\w]{3,4}$") + || code.IsMatchWith(@"^[\+\-]{1}([\d]{2})(:)?([\d]{2})?$"); + } + } + + private TimezoneIANA(string id, string standardCode, string? daylightSavingsCode) + { + id.ThrowIfNotValuedParameter(nameof(id)); + standardCode.ThrowIfNotValuedParameter(nameof(standardCode)); + if (daylightSavingsCode.Exists()) + { + daylightSavingsCode.ThrowIfNotValuedParameter(nameof(daylightSavingsCode)); + } + + Id = id; + StandardCode = standardCode; + DaylightSavingsCode = daylightSavingsCode; + } + + public string? DaylightSavingsCode { get; private set; } + + public TimeSpan DaylightSavingsOffset { get; private set; } + + public bool HasDaylightSavings { get; private set; } + + public string Id { get; } + + public string StandardCode { get; private set; } + + public TimeSpan StandardOffset { get; private set; } + + public bool Equals(TimezoneIANA? other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Id == other.Id; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != GetType()) + { + return false; + } + + return Equals((TimezoneIANA)obj); + } + + public override int GetHashCode() + { + ArgumentException.ThrowIfNullOrEmpty(Id); + + return Id.GetHashCode(); + } + + public static bool operator ==(TimezoneIANA left, TimezoneIANA right) + { + return Equals(left, right); + } + + public static bool operator !=(TimezoneIANA left, TimezoneIANA right) + { + return !Equals(left, right); + } +} \ No newline at end of file diff --git a/src/Domain.Common.UnitTests/Authorization/OrganizationFeatureSetsSpec.cs b/src/Domain.Common.UnitTests/Authorization/OrganizationFeatureSetsSpec.cs deleted file mode 100644 index 2da920f9..00000000 --- a/src/Domain.Common.UnitTests/Authorization/OrganizationFeatureSetsSpec.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Domain.Common.Authorization; -using FluentAssertions; -using Xunit; - -namespace Domain.Common.UnitTests.Authorization; - -[Trait("Category", "Unit")] -public class OrganizationFeatureSetsSpec -{ -#if TESTINGONLY - [Fact] - public void WhenAssignableFeatureSets_ThenReturnsSome() - { - var result = OrganizationFeatureSets.AssignableFeatureSets; - - result.Count.Should().BeGreaterThan(0); - result.Should().Contain(OrganizationFeatureSets.TestingOnlyFeatures); - } -#endif -} \ No newline at end of file diff --git a/src/Domain.Common.UnitTests/Authorization/UserFeatureSetsSpec.cs b/src/Domain.Common.UnitTests/Authorization/UserFeatureSetsSpec.cs deleted file mode 100644 index adc95559..00000000 --- a/src/Domain.Common.UnitTests/Authorization/UserFeatureSetsSpec.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Domain.Common.Authorization; -using FluentAssertions; -using Xunit; - -namespace Domain.Common.UnitTests.Authorization; - -[Trait("Category", "Unit")] -public class UserFeatureSetsSpec -{ -#if TESTINGONLY - [Fact] - public void WhenAssignableFeatureSets_ThenReturnsSome() - { - var result = UserFeatureSets.AssignableFeatureSets; - - result.Count.Should().BeGreaterThan(0); - result.Should().Contain(UserFeatureSets.TestingOnlyFeatures); - } -#endif -} \ No newline at end of file diff --git a/src/Domain.Common/Authorization/OrganizationFeatureSets.cs b/src/Domain.Common/Authorization/OrganizationFeatureSets.cs deleted file mode 100644 index d9d69a61..00000000 --- a/src/Domain.Common/Authorization/OrganizationFeatureSets.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Domain.Common.Authorization; - -/// -/// Defines the available feature sets of the product (features for tenanted resources) -/// -public static class OrganizationFeatureSets -{ - // EXTEND: Add new feature sets - public const string Basic = UserFeatureSets.Basic; // Free features, everyone can use - public const string Premium = UserFeatureSets.Premium; // Premium plan features - public const string Pro = UserFeatureSets.Pro; // Professional plan features - -#if TESTINGONLY - public const string TestingOnlyFeatures = "testingonly_organization_features"; -#endif - - public static readonly IReadOnlyList AssignableFeatureSets = new List - { - // EXTEND: Add new features that Memberships will have, to control access to tenanted resources - Basic, - Pro, - Premium, -#if TESTINGONLY - TestingOnlyFeatures -#endif - }; -} \ No newline at end of file diff --git a/src/Domain.Common/Authorization/UserFeatureSets.cs b/src/Domain.Common/Authorization/UserFeatureSets.cs deleted file mode 100644 index d75de7b7..00000000 --- a/src/Domain.Common/Authorization/UserFeatureSets.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Domain.Common.Authorization; - -/// -/// Defines the available feature sets of the product (features for un-tenanted resources) -/// -public static class UserFeatureSets -{ - // EXTEND: Add new feature sets - public const string Basic = "basic_features"; // Free features, everyone can use - public const string Premium = "prem_features"; // Premium plan features - public const string Pro = "pro_features"; // Professional plan features - -#if TESTINGONLY - public const string TestingOnlyFeatures = "testingonly_user_features"; -#endif - - public static readonly IReadOnlyList AssignableFeatureSets = new List - { - // EXTEND: Add new roles that UserAccounts will have, to control access to un-tenanted resources - Basic, - Pro, - Premium, -#if TESTINGONLY - TestingOnlyFeatures -#endif - }; -} \ No newline at end of file diff --git a/src/Domain.Interfaces.UnitTests/Authorization/MemberFeatureLevelsSpec.cs b/src/Domain.Interfaces.UnitTests/Authorization/MemberFeatureLevelsSpec.cs new file mode 100644 index 00000000..2ce62f87 --- /dev/null +++ b/src/Domain.Interfaces.UnitTests/Authorization/MemberFeatureLevelsSpec.cs @@ -0,0 +1,20 @@ +using Domain.Interfaces.Authorization; +using FluentAssertions; +using Xunit; + +namespace Domain.Interfaces.UnitTests.Authorization; + +[Trait("Category", "Unit")] +public class MemberFeatureLevelsSpec +{ +#if TESTINGONLY + [Fact] + public void WhenAssignableFeatureSets_ThenReturnsSome() + { + var result = MemberFeatureLevels.AssignableFeatureSets; + + result.Count.Should().BeGreaterThan(0); + result.Should().Contain(MemberFeatureLevels.TestingOnlyFeatures); + } +#endif +} \ No newline at end of file diff --git a/src/Domain.Common.UnitTests/Authorization/OrganizationRolesSpec.cs b/src/Domain.Interfaces.UnitTests/Authorization/MemberRolesSpec.cs similarity index 55% rename from src/Domain.Common.UnitTests/Authorization/OrganizationRolesSpec.cs rename to src/Domain.Interfaces.UnitTests/Authorization/MemberRolesSpec.cs index 2a189111..49be714c 100644 --- a/src/Domain.Common.UnitTests/Authorization/OrganizationRolesSpec.cs +++ b/src/Domain.Interfaces.UnitTests/Authorization/MemberRolesSpec.cs @@ -1,16 +1,16 @@ -using Domain.Common.Authorization; +using Domain.Interfaces.Authorization; using FluentAssertions; using Xunit; -namespace Domain.Common.UnitTests.Authorization; +namespace Domain.Interfaces.UnitTests.Authorization; [Trait("Category", "Unit")] -public class OrganizationRolesSpec +public class MemberRolesSpec { [Fact] public void WhenIsMemberAssignableRoleForUnknownRole_ThenReturnsFalse() { - var result = OrganizationRoles.IsMemberAssignableRole("arole"); + var result = MemberRoles.IsMemberAssignableRole("arole"); result.Should().BeFalse(); } @@ -19,7 +19,7 @@ public void WhenIsMemberAssignableRoleForUnknownRole_ThenReturnsFalse() [Fact] public void WhenIsMemberAssignableRoleForAssignableRole_ThenReturnsTrue() { - var result = OrganizationRoles.IsMemberAssignableRole(OrganizationRoles.TestingOnlyOrganization); + var result = MemberRoles.IsMemberAssignableRole(MemberRoles.TestingOnlyTenant); result.Should().BeTrue(); } diff --git a/src/Domain.Interfaces.UnitTests/Authorization/PlatformFeatureLevelsSpec.cs b/src/Domain.Interfaces.UnitTests/Authorization/PlatformFeatureLevelsSpec.cs new file mode 100644 index 00000000..49764db6 --- /dev/null +++ b/src/Domain.Interfaces.UnitTests/Authorization/PlatformFeatureLevelsSpec.cs @@ -0,0 +1,20 @@ +using Domain.Interfaces.Authorization; +using FluentAssertions; +using Xunit; + +namespace Domain.Interfaces.UnitTests.Authorization; + +[Trait("Category", "Unit")] +public class PlatformFeatureLevelsSpec +{ +#if TESTINGONLY + [Fact] + public void WhenAssignableFeatureSets_ThenReturnsSome() + { + var result = PlatformFeatureLevels.AssignableFeatureSets; + + result.Count.Should().BeGreaterThan(0); + result.Should().Contain(PlatformFeatureLevels.TestingOnlyFeatures); + } +#endif +} \ No newline at end of file diff --git a/src/Domain.Common.UnitTests/Authorization/UserRolesSpec.cs b/src/Domain.Interfaces.UnitTests/Authorization/PlatformRolesSpec.cs similarity index 55% rename from src/Domain.Common.UnitTests/Authorization/UserRolesSpec.cs rename to src/Domain.Interfaces.UnitTests/Authorization/PlatformRolesSpec.cs index 50c8bb4d..a604e6f6 100644 --- a/src/Domain.Common.UnitTests/Authorization/UserRolesSpec.cs +++ b/src/Domain.Interfaces.UnitTests/Authorization/PlatformRolesSpec.cs @@ -1,16 +1,16 @@ -using Domain.Common.Authorization; +using Domain.Interfaces.Authorization; using FluentAssertions; using Xunit; -namespace Domain.Common.UnitTests.Authorization; +namespace Domain.Interfaces.UnitTests.Authorization; [Trait("Category", "Unit")] -public class UserRolesSpec +public class PlatformRolesSpec { [Fact] public void WhenIsUserAssignableRoleForUnknownRole_ThenReturnsFalse() { - var result = UserRoles.IsUserAssignableRole("arole"); + var result = PlatformRoles.IsPlatformAssignableRole("arole"); result.Should().BeFalse(); } @@ -19,7 +19,7 @@ public void WhenIsUserAssignableRoleForUnknownRole_ThenReturnsFalse() [Fact] public void WhenIsUserAssignableRoleForAssignableRole_ThenReturnsTrue() { - var result = UserRoles.IsUserAssignableRole(UserRoles.TestingOnlyUser); + var result = PlatformRoles.IsPlatformAssignableRole(PlatformRoles.TestingOnlyUser); result.Should().BeTrue(); } diff --git a/src/Domain.Interfaces/Authorization/FeatureLevel.cs b/src/Domain.Interfaces/Authorization/FeatureLevel.cs new file mode 100644 index 00000000..c9c25744 --- /dev/null +++ b/src/Domain.Interfaces/Authorization/FeatureLevel.cs @@ -0,0 +1,17 @@ +namespace Domain.Interfaces.Authorization; + +/// +/// Defines a feature level +/// +public sealed class FeatureLevel +{ + public FeatureLevel(string name, params FeatureLevel[] childLevels) + { + Name = name; + ChildLevels = childLevels; + } + + public IReadOnlyList ChildLevels { get; } + + public string Name { get; } +} \ No newline at end of file diff --git a/src/Domain.Interfaces/Authorization/MemberFeatureLevels.cs b/src/Domain.Interfaces/Authorization/MemberFeatureLevels.cs new file mode 100644 index 00000000..b2e71df2 --- /dev/null +++ b/src/Domain.Interfaces/Authorization/MemberFeatureLevels.cs @@ -0,0 +1,28 @@ +namespace Domain.Interfaces.Authorization; + +/// +/// Defines the available feature levels of the product (for tenanted resources) +/// +public static class MemberFeatureLevels +{ + public static readonly FeatureLevel Basic = PlatformFeatureLevels.Basic; // Basic features, everyone can use + public static readonly FeatureLevel Premium = PlatformFeatureLevels.Premium; // Premium plan features + public static readonly FeatureLevel Pro = PlatformFeatureLevels.Pro; // Professional plan features + public static readonly FeatureLevel TestingOnlyFeatures = PlatformFeatureLevels.TestingOnlyFeatures; + public static readonly IReadOnlyList AssignableFeatureSets = new List + { + // EXTEND: Add new features that Memberships will have, to control access to tenanted resources + Basic, //Lowest/free tier features, that anyone can use at any time + Pro, // Lowest/paid tier, trials run on this tier + Premium, // Highest/paid tier +#if TESTINGONLY + TestingOnlyFeatures +#endif + }; + + // EXTEND: Add new feature sets + +#if TESTINGONLY + +#endif +} \ No newline at end of file diff --git a/src/Domain.Common/Authorization/OrganizationRoles.cs b/src/Domain.Interfaces/Authorization/MemberRoles.cs similarity index 64% rename from src/Domain.Common/Authorization/OrganizationRoles.cs rename to src/Domain.Interfaces/Authorization/MemberRoles.cs index c89edd63..aa821061 100644 --- a/src/Domain.Common/Authorization/OrganizationRoles.cs +++ b/src/Domain.Interfaces/Authorization/MemberRoles.cs @@ -1,19 +1,20 @@ using Common.Extensions; -namespace Domain.Common.Authorization; +namespace Domain.Interfaces.Authorization; /// -/// Defines the available organization scoped roles (access to tenanted resources) +/// Defines the available organization scoped roles +/// (i.e. access to tenanted resources by members or organizations) /// -public static class OrganizationRoles +public static class MemberRoles { - public const string BillingAdmin = "org_billing_admin"; + public const string BillingAdmin = "member_billing_admin"; // EXTEND: Add other roles that Memberships can be assigned to control tenanted resources - public const string Owner = "org_owner"; + public const string Owner = "member_owner"; #if TESTINGONLY - public const string TestingOnlyOrganization = "testingonly_organization"; + public const string TestingOnlyTenant = "testingonly_organization"; #endif private static readonly IReadOnlyList MemberAssignableRoles = new List { @@ -22,7 +23,7 @@ public static class OrganizationRoles BillingAdmin, #if TESTINGONLY - TestingOnlyOrganization + TestingOnlyTenant #endif }; diff --git a/src/Domain.Interfaces/Authorization/PlatformFeatureLevels.cs b/src/Domain.Interfaces/Authorization/PlatformFeatureLevels.cs new file mode 100644 index 00000000..ea006024 --- /dev/null +++ b/src/Domain.Interfaces/Authorization/PlatformFeatureLevels.cs @@ -0,0 +1,28 @@ +namespace Domain.Interfaces.Authorization; + +/// +/// Defines the available feature sets of the product (for un-tenanted/platform resources) +/// +public static class PlatformFeatureLevels +{ + public static readonly FeatureLevel Basic = new("basic_features"); // Basic features, everyone can use + public static readonly FeatureLevel Premium = new("prem_features", Basic); // Premium plan features + public static readonly FeatureLevel Pro = new("pro_features", Premium); // Professional plan features + public static readonly FeatureLevel TestingOnlyFeatures = new("testingonly_user_features"); + public static readonly IReadOnlyList AssignableFeatureSets = new List + { + // EXTEND: Add new roles that UserAccounts will have, to control access to un-tenanted resources + Basic, //Lowest/free tier features, that anyone can use at any time + Pro, // Lowest/paid tier, trials run on this tier + Premium, // Highest/paid tier +#if TESTINGONLY + TestingOnlyFeatures +#endif + }; + + // EXTEND: Add new feature levels + +#if TESTINGONLY + +#endif +} \ No newline at end of file diff --git a/src/Domain.Common/Authorization/UserRoles.cs b/src/Domain.Interfaces/Authorization/PlatformRoles.cs similarity index 51% rename from src/Domain.Common/Authorization/UserRoles.cs rename to src/Domain.Interfaces/Authorization/PlatformRoles.cs index d64041ec..f6490212 100644 --- a/src/Domain.Common/Authorization/UserRoles.cs +++ b/src/Domain.Interfaces/Authorization/PlatformRoles.cs @@ -1,24 +1,25 @@ using Common.Extensions; -namespace Domain.Common.Authorization; +namespace Domain.Interfaces.Authorization; /// -/// Defines the available user scoped roles (access to un-tenanted resources) +/// Defines the available platform scoped roles +/// (i.e. access to un-tenanted resources by end users) /// -public static class UserRoles +public static class PlatformRoles { public const string ExternalWebhookService = "external_webhook_service"; public const string ServiceAccount = "service"; - // EXTEND: Add other roles that UserAccounts can be assigned to control un-tenanted resources + // EXTEND: Add other roles that EndUsers can be assigned to control un-tenanted resources public const string Standard = "standard"; #if TESTINGONLY public const string TestingOnlyUser = "testingonly_user"; #endif - private static readonly IReadOnlyList UserAssignableRoles = new List + private static readonly IReadOnlyList PlatformAssignableRoles = new List { - // EXTEND: Add roles above that can be assigned by other users, to control access to un-tenanted resources + // EXTEND: Add roles above that can be assigned by other endusers, to control access to un-tenanted resources Standard, #if TESTINGONLY @@ -29,8 +30,8 @@ public static class UserRoles /// /// Whether the is an assignable role /// - public static bool IsUserAssignableRole(string role) + public static bool IsPlatformAssignableRole(string role) { - return UserAssignableRoles.ContainsIgnoreCase(role); + return PlatformAssignableRoles.ContainsIgnoreCase(role); } } \ No newline at end of file diff --git a/src/Domain.Interfaces/Validations/CommonValidations.cs b/src/Domain.Interfaces/Validations/CommonValidations.cs index 60316c0a..d3a119db 100644 --- a/src/Domain.Interfaces/Validations/CommonValidations.cs +++ b/src/Domain.Interfaces/Validations/CommonValidations.cs @@ -1,4 +1,5 @@ -using Common.Extensions; +using Common; +using Common.Extensions; namespace Domain.Interfaces.Validations; @@ -7,18 +8,15 @@ namespace Domain.Interfaces.Validations; /// public static class CommonValidations { + public static readonly Validation CountryCode = new(CountryCodes.Exists); public static readonly Validation EmailAddress = new( @"^(?:[\w\!\#\$\%\&\'\*\+\-\/\=\?\^\`\{\|\}\~]+\.)*[\w\!\#\$\%\&\'\*\+\-\/\=\?\^\`\{\|\}\~]+@(?:(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-](?!\.)){0,61}[a-zA-Z0-9]?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9\-](?!$)){0,61}[a-zA-Z0-9]?)|(?:\[(?:(?:[01]?\d{1,2}|2[0-4]\d|25[0-5])\.){3}(?:[01]?\d{1,2}|2[0-4]\d|25[0-5])\]))$"); - public static readonly Validation Identifier = new(@"^[\w]{1,20}_[\d\w]{10,22}$", 12, 43); - public static readonly Validation IdentifierPrefix = new(@"^[^\W_]*$", 1, 20); - + public static readonly Validation Timezone = new(Timezones.Exists); public static readonly Validation Url = new(s => Uri.IsWellFormedUriString(s, UriKind.Absolute)); - private static readonly string Emojis = "😀😁😂😃😉😋😎😍😗🤗🤔😣😫😴😌🤓😛😜😠😇😷😈👻😺😸😹😻😼😽🙀🙈🙉🙊👼👮🕵💂👳🎅👸👰👲🙍🙇🚶🏃💃⛷🏂🏌🏄🚣🏊⛹🏋🚴👫💪👈👉👆🖕👇🖖🤘🖐👌👍👎✊👊👏🙌🙏🐵🐶🐇🐥🐸🐌🐛🐜🐝🍉🍄🍔🍤🍨🍪🎂🍰🍾🍷🍸🍺🌍🚑⏰🌙🌝🌞⭐🌟🌠🌨🌩⛄🔥🎄🎈🎉🎊🎁🎗🏀🏈🎲🔇🔈📣🔔🎵🎷💰🖊📅✅❎💯"; - private static readonly string FreeFormTextAllowedCharacters = @"\d\w\`\~\!\@\#\$\%\:\&\*\(\)\-\+\=\[\]\{{\}}\:\;\'\""\<\,\>\.\?\|\/ \r\n"; @@ -103,4 +101,72 @@ public static class Recording { public static readonly Validation AdditionalStringValue = DescriptiveName(1, 300); } + + public static class Passwords + { + public static readonly Validation PasswordHash = + new(@"^[$]2[abxy]?[$](?:0[4-9]|[12][0-9]|3[01])[$][./0-9a-zA-Z]{53}$", 60, 60); + + public static class Password + { + public static readonly int MaxLength = 200; + public static readonly int MinLength = 8; + /// + /// Loose policy requires that the password contains any character, and matches length + /// requirements. + /// + public static readonly Validation Loose = new( + @"^[\w\d \!""\#\$\%\&\'\(\)\*\+\,\-\.\/\:\;\<\=\>\?\@\[\]\^_\`\{\|\}\~]*$", MinLength, MaxLength); + + /// + /// Strict policy requires that the password contains at least 3 of the 4 character classes, and matches length + /// requirements. + /// The three character classes are: + /// 1. at least one uppercase character (including unicode) + /// 2. at least one lowercase character (including unicode) + /// 3. at least one number character (ie. 0123456789 ) + /// 4. at least one special character (ie: /?]]> ) + /// + public static readonly Validation Strict = new(password => + { + if (!password.HasValue()) + { + return false; + } + + if (password.Length < MinLength) + { + return false; + } + + if (password.Length > MaxLength) + { + return false; + } + + var characterClassCount = 0; + if (password.IsMatchWith(@"[\d]{1,}")) + { + characterClassCount++; + } + + if (password.IsMatchWith(@"[\p{Ll}]{1,}")) + { + characterClassCount++; + } + + if (password.IsMatchWith(@"[\p{Lu}]{1,}")) + { + characterClassCount++; + } + + if (password.IsMatchWith(@"[ \!""\#\$\%\&\'\(\)\*\+\,\-\.\/\:\;\<\=\>\?\@\[\]\^_\`\{\|\}\~]{1,}")) + { + characterClassCount++; + } + + return characterClassCount >= 3; + }); + } + } } \ No newline at end of file diff --git a/src/Domain.Services.Shared/Domain.Services.Shared.csproj b/src/Domain.Services.Shared/Domain.Services.Shared.csproj new file mode 100644 index 00000000..39c6dcf7 --- /dev/null +++ b/src/Domain.Services.Shared/Domain.Services.Shared.csproj @@ -0,0 +1,13 @@ + + + + net7.0 + + + + + <_Parameter1>$(AssemblyName).UnitTests + + + + diff --git a/src/Domain.Services.Shared/DomainServices/ITokensService.cs b/src/Domain.Services.Shared/DomainServices/ITokensService.cs new file mode 100644 index 00000000..cb189335 --- /dev/null +++ b/src/Domain.Services.Shared/DomainServices/ITokensService.cs @@ -0,0 +1,10 @@ +namespace Domain.Services.Shared.DomainServices; + +public interface ITokensService +{ + string CreateTokenForJwtRefresh(); + + string CreateTokenForPasswordReset(); + + string CreateTokenForVerification(); +} \ No newline at end of file diff --git a/src/Domain.Shared.UnitTests/EmailAddressSpec.cs b/src/Domain.Shared.UnitTests/EmailAddressSpec.cs new file mode 100644 index 00000000..6d444e7d --- /dev/null +++ b/src/Domain.Shared.UnitTests/EmailAddressSpec.cs @@ -0,0 +1,98 @@ +using Common; +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace Domain.Shared.UnitTests; + +[Trait("Category", "Unit")] +public class EmailAddressSpec +{ + [Fact] + public void WhenConstructedAndEmptyEmail_ThenReturnsError() + { + var result = EmailAddress.Create(string.Empty); + + result.Should().BeError(ErrorCode.Validation); + } + + [Fact] + public void WhenConstructedAndInvalidEmail_ThenReturnsError() + { + var result = EmailAddress.Create("notanemail"); + + result.Should().BeError(ErrorCode.Validation, Resources.EmailAddress_InvalidAddress); + } + + [Fact] + public void WhenGuessPersonNameFromEmailAndPlainUsername_ThenReturnsName() + { + var name = EmailAddress.Create("auser@company.com").Value.GuessPersonName(); + + name.FirstName.Text.Should().Be("Auser"); + name.LastName.Should().BeNone(); + } + + [Fact] + public void WhenGuessPersonNameFromEmailAndMultipleDottedUsername_ThenReturnsName() + { + var name = EmailAddress.Create("afirstname.amiddlename.alastname@company.com").Value.GuessPersonName(); + + name.FirstName.Text.Should().Be("Afirstname"); + name.LastName.Value.Text.Should().Be("Alastname"); + } + + [Fact] + public void WhenGuessPersonNameFromEmailAndTwoDottedUsername_ThenReturnsName() + { + var name = EmailAddress.Create("afirstname.alastname@company.com").Value.GuessPersonName(); + + name.FirstName.Text.Should().Be("Afirstname"); + name.LastName.Value.Text.Should().Be("Alastname"); + } + + [Fact] + public void WhenGuessPersonNameFromEmailAndContainsPlusSign_ThenReturnsName() + { + var name = EmailAddress.Create("afirstname+anothername@company.com").Value.GuessPersonName(); + + name.FirstName.Text.Should().Be("Afirstname"); + name.LastName.Should().BeNone(); + } + + [Fact] + public void WhenGuessPersonNameFromEmailAndContainsPlusSignAndNumber_ThenReturnsName() + { + var name = EmailAddress.Create("afirstname+9@company.com").Value.GuessPersonName(); + + name.FirstName.Text.Should().Be("Afirstname"); + name.LastName.Should().BeNone(); + } + + [Fact] + public void WhenGuessPersonNameFromEmailAndGuessedFirstNameNotValid_ThenReturnsNameWithFallbackFirstName() + { + var name = EmailAddress.Create("-@company.com").Value.GuessPersonName(); + + name.FirstName.Text.Should().Be(Resources.EmailAddress_FallbackGuessedFirstName); + name.LastName.Should().BeNone(); + } + + [Fact] + public void WhenGuessPersonNameFromEmailAndGuessedLastNameNotValid_ThenReturnsNameWithNoLastName() + { + var name = EmailAddress.Create("afirstname.b@company.com").Value.GuessPersonName(); + + name.FirstName.Text.Should().Be("Afirstname"); + name.LastName.Should().BeNone(); + } + + [Fact] + public void WhenGuessPersonNameFromEmailAndGuessedFirstAndLastNameNotValid_ThenReturnsNameWithNoLastName() + { + var name = EmailAddress.Create("1.2@company.com").Value.GuessPersonName(); + + name.FirstName.Text.Should().Be(Resources.EmailAddress_FallbackGuessedFirstName); + name.LastName.Should().BeNone(); + } +} \ No newline at end of file diff --git a/src/Domain.Shared.UnitTests/NameSpec.cs b/src/Domain.Shared.UnitTests/NameSpec.cs index 9b0e0d94..2b0a0745 100644 --- a/src/Domain.Shared.UnitTests/NameSpec.cs +++ b/src/Domain.Shared.UnitTests/NameSpec.cs @@ -19,10 +19,8 @@ public void WhenConstructWithEmptyName_ThenReturnsError() [Fact] public void WhenText_ThenReturnsValue() { - var name = Name.Create("aname").Value; + var result = Name.Create("aname").Value; - var result = name.Text; - - result.Should().Be("aname"); + result.Text.Should().Be("aname"); } } \ No newline at end of file diff --git a/src/Domain.Shared.UnitTests/PersonDisplayNameSpec.cs b/src/Domain.Shared.UnitTests/PersonDisplayNameSpec.cs new file mode 100644 index 00000000..8927c3b7 --- /dev/null +++ b/src/Domain.Shared.UnitTests/PersonDisplayNameSpec.cs @@ -0,0 +1,26 @@ +using Common; +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace Domain.Shared.UnitTests; + +[Trait("Category", "Unit")] +public class PersonDisplayNameSpec +{ + [Fact] + public void WhenConstructWithEmptyName_ThenReturnsError() + { + var result = PersonDisplayName.Create(string.Empty); + + result.Should().BeError(ErrorCode.Validation); + } + + [Fact] + public void WhenText_ThenReturnsValue() + { + var result = PersonDisplayName.Create("aname").Value; + + result.Text.Should().Be("aname"); + } +} \ No newline at end of file diff --git a/src/Domain.Shared.UnitTests/PersonNameSpec.cs b/src/Domain.Shared.UnitTests/PersonNameSpec.cs new file mode 100644 index 00000000..7f6b4efc --- /dev/null +++ b/src/Domain.Shared.UnitTests/PersonNameSpec.cs @@ -0,0 +1,44 @@ +using Common; +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace Domain.Shared.UnitTests; + +[Trait("Category", "Unit")] +public class PersonNameSpec +{ + [Fact] + public void WhenWhenConstructWithFirstName_ThenReturnsFirstName() + { + var result = PersonName.Create("afirstname", Optional.None).Value; + + result.FirstName.Text.Should().Be("afirstname"); + result.LastName.Should().BeNone(); + } + + [Fact] + public void WhenWhenConstructWithFirstNameAndLastName_ThenReturnsBothNames() + { + var result = PersonName.Create("afirstname", "alastname").Value; + + result.FirstName.Text.Should().Be("afirstname"); + result.LastName.Value.Text.Should().Be("alastname"); + } + + [Fact] + public void WhenFullNameWithFirstName_ThenReturnsFullName() + { + var result = PersonName.Create("afirstname", Optional.None).Value.FullName; + + result.Text.Should().Be("afirstname"); + } + + [Fact] + public void WhenFullNameWithFirstNameAndLastName_ThenReturnsFullName() + { + var result = PersonName.Create("afirstname", "alastname").Value.FullName; + + result.Text.Should().Be("afirstname alastname"); + } +} \ No newline at end of file diff --git a/src/Domain.Shared/Domain.Shared.csproj b/src/Domain.Shared/Domain.Shared.csproj index d22284d2..582ca227 100644 --- a/src/Domain.Shared/Domain.Shared.csproj +++ b/src/Domain.Shared/Domain.Shared.csproj @@ -14,4 +14,19 @@ + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + True + True + Resources.resx + + + diff --git a/src/Domain.Shared/EmailAddress.cs b/src/Domain.Shared/EmailAddress.cs new file mode 100644 index 00000000..13969ca1 --- /dev/null +++ b/src/Domain.Shared/EmailAddress.cs @@ -0,0 +1,92 @@ +using Common; +using Common.Extensions; +using Domain.Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.Validations; +using Domain.Interfaces.ValueObjects; + +namespace Domain.Shared; + +public sealed class EmailAddress : SingleValueObjectBase +{ + public static Result Create(string emailAddress) + { + if (emailAddress.IsNotValuedParameter(nameof(emailAddress), out var error1)) + { + return error1; + } + + if (emailAddress.IsInvalidParameter(CommonValidations.EmailAddress, nameof(emailAddress), + Resources.EmailAddress_InvalidAddress, out var error2)) + { + return error2; + } + + return new EmailAddress(emailAddress); + } + + private EmailAddress(string emailAddress) : base(emailAddress) + { + } + + public string Address => Value; + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => new EmailAddress(property); + } + + [SkipImmutabilityCheck] + public PersonName GuessPersonName() + { + var name = GuessPersonNameFromEmailAddress(Value); + + return PersonName.Create(name.FirstName, name.LastName).Value; + } + + /// + /// Tries to guess the first and last names of the specified . + /// + private static (string FirstName, Optional LastName) GuessPersonNameFromEmailAddress(string emailAddress) + { + if (emailAddress.HasNoValue()) + { + return (emailAddress, Optional.None); + } + + const char usernameDelimiter = '.'; + var parts = emailAddress.Split(new[] { '+', '@' }, StringSplitOptions.RemoveEmptyEntries); + var username = parts[0]; + + var usernameParts = username.Split(usernameDelimiter); + + var firstName = RefineName(usernameParts[0]); + var lastName = usernameParts.Length > 1 + ? RefineName(usernameParts[^1]).ToOptional() + : Optional.None; + + if (!IsValidName(firstName)) + { + firstName = Resources.EmailAddress_FallbackGuessedFirstName; + } + + if (lastName.HasValue + && !IsValidName(lastName.Value)) + { + lastName = Optional.None; + } + + return (firstName, lastName); + + bool IsValidName(string name) + { + return name.IsMatchWith(@"^[\w]{2,}$"); + } + + string RefineName(string name) + { + return name.TrimNonAlpha().ToTitleCase(); + } + } +} \ No newline at end of file diff --git a/src/Domain.Shared/Name.cs b/src/Domain.Shared/Name.cs index 65000166..b66c71a8 100644 --- a/src/Domain.Shared/Name.cs +++ b/src/Domain.Shared/Name.cs @@ -9,13 +9,12 @@ public sealed class Name : SingleValueObjectBase { public static Result Create(string name) { - var value = new Name(name); if (name.IsNotValuedParameter(nameof(name), out var error)) { return error; } - return value; + return new Name(name); } private Name(string name) : base(name) diff --git a/src/Domain.Shared/PersonDisplayName.cs b/src/Domain.Shared/PersonDisplayName.cs new file mode 100644 index 00000000..c77c3178 --- /dev/null +++ b/src/Domain.Shared/PersonDisplayName.cs @@ -0,0 +1,30 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + +namespace Domain.Shared; + +public sealed class PersonDisplayName : SingleValueObjectBase +{ + public static Result Create(string displayName) + { + if (displayName.IsNotValuedParameter(nameof(displayName), out var error)) + { + return error; + } + + return new PersonDisplayName(displayName); + } + + private PersonDisplayName(string displayName) : base(displayName) + { + } + + public string Text => Value; + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => new PersonDisplayName(property); + } +} \ No newline at end of file diff --git a/src/Domain.Shared/PersonName.cs b/src/Domain.Shared/PersonName.cs new file mode 100644 index 00000000..ca638bba --- /dev/null +++ b/src/Domain.Shared/PersonName.cs @@ -0,0 +1,66 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + +namespace Domain.Shared; + +public sealed class PersonName : ValueObjectBase +{ + public static Result Create(string firstName, Optional lastName) + { + var name1 = Name.Create(firstName); + if (!name1.IsSuccessful) + { + return name1.Error; + } + + if (lastName.HasValue) + { + var name2 = Name.Create(lastName); + if (!name2.IsSuccessful) + { + return name2.Error; + } + + return new PersonName(name1.Value, name2.Value); + } + + return new PersonName(name1.Value, Optional.None); + } + + public static Result Create(Name firstName, Optional lastName) + { + return new PersonName(firstName, lastName); + } + + private PersonName(Name firstName, Optional lastName) + { + FirstName = firstName; + LastName = lastName; + } + + public Name FirstName { get; } + + public Name FullName => LastName.HasValue + ? Name.Create($"{FirstName} {LastName}").Value + : Name.Create($"{FirstName}").Value; + + public Optional LastName { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, container) => + { + var parts = RehydrateToList(property, false); + return new PersonName(Name.Rehydrate()(parts[0]!, container), parts[1].HasValue() + ? Name.Rehydrate()(parts[1]!, container).ToOptional() + : Optional.None); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object[] { FirstName, LastName }; + } +} \ No newline at end of file diff --git a/src/Domain.Shared/Resources.Designer.cs b/src/Domain.Shared/Resources.Designer.cs new file mode 100644 index 00000000..d32a807f --- /dev/null +++ b/src/Domain.Shared/Resources.Designer.cs @@ -0,0 +1,80 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Domain.Shared { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Domain.Shared.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Mr or Mrs. + /// + internal static string EmailAddress_FallbackGuessedFirstName { + get { + return ResourceManager.GetString("EmailAddress_FallbackGuessedFirstName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Email is not a valid email address. + /// + internal static string EmailAddress_InvalidAddress { + get { + return ResourceManager.GetString("EmailAddress_InvalidAddress", resourceCulture); + } + } + } +} diff --git a/src/Domain.Shared/Resources.resx b/src/Domain.Shared/Resources.resx new file mode 100644 index 00000000..6d99c14b --- /dev/null +++ b/src/Domain.Shared/Resources.resx @@ -0,0 +1,33 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + The Email is not a valid email address + + + Mr or Mrs + + \ No newline at end of file diff --git a/src/EndUsersApplication/EndUsersApplication.cs b/src/EndUsersApplication/EndUsersApplication.cs new file mode 100644 index 00000000..7e5ee8d5 --- /dev/null +++ b/src/EndUsersApplication/EndUsersApplication.cs @@ -0,0 +1,33 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; +using Domain.Common.Identity; + +namespace EndUsersApplication; + +public class EndUsersApplication : IEndUsersApplication +{ + private readonly IIdentifierFactory _identifierFactory; + private readonly IRecorder _recorder; + + public EndUsersApplication(IRecorder recorder, IIdentifierFactory identifierFactory) + { + _recorder = recorder; + _identifierFactory = identifierFactory; + } + + public async Task> GetPersonAsync(ICallerContext caller, string id, + CancellationToken cancellationToken) + { + await Task.CompletedTask; + throw new NotImplementedException(); + } + + public async Task> RegisterPersonAsync(ICallerContext caller, string emailAddress, + string firstName, string lastName, + string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken) + { + await Task.CompletedTask; + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/EndUsersApplication/EndUsersApplication.csproj b/src/EndUsersApplication/EndUsersApplication.csproj new file mode 100644 index 00000000..ebf6f1e6 --- /dev/null +++ b/src/EndUsersApplication/EndUsersApplication.csproj @@ -0,0 +1,19 @@ + + + + net7.0 + + + + + <_Parameter1>$(AssemblyName).UnitTests + + + + + + + + + + diff --git a/src/EndUsersApplication/IEndUsersApplication.cs b/src/EndUsersApplication/IEndUsersApplication.cs new file mode 100644 index 00000000..b8a16276 --- /dev/null +++ b/src/EndUsersApplication/IEndUsersApplication.cs @@ -0,0 +1,14 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + +namespace EndUsersApplication; + +public interface IEndUsersApplication +{ + Task> GetPersonAsync(ICallerContext caller, string id, CancellationToken cancellationToken); + + Task> RegisterPersonAsync(ICallerContext caller, string emailAddress, + string firstName, string lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs b/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs new file mode 100644 index 00000000..0299bef7 --- /dev/null +++ b/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs @@ -0,0 +1,7 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace EndUsersInfrastructure.Api.EndUsers; + +public class EndUsersApi : IWebApiService +{ +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs new file mode 100644 index 00000000..bc0adb97 --- /dev/null +++ b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs @@ -0,0 +1,35 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using EndUsersApplication; + +namespace EndUsersInfrastructure.ApplicationServices; + +/// +/// Provides an in-process service client to be used to make cross-domain calls, +/// when the EndUsers subdomain is deployed in the same host as the consumer of this service +/// +public class EndUsersInProcessServiceClient : IEndUsersService +{ + private readonly IEndUsersApplication _endUsersApplication; + + public EndUsersInProcessServiceClient(IEndUsersApplication endUsersApplication) + { + _endUsersApplication = endUsersApplication; + } + + public async Task> GetPersonAsync(ICallerContext caller, string id, + CancellationToken cancellationToken) + { + return await _endUsersApplication.GetPersonAsync(caller, id, cancellationToken); + } + + public async Task> RegisterPersonAsync(ICallerContext caller, string emailAddress, + string firstName, string lastName, + string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken) + { + return await _endUsersApplication.RegisterPersonAsync(caller, emailAddress, firstName, lastName, timezone, + countryCode, termsAndConditionsAccepted, cancellationToken); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/EndUsersInfrastructure.csproj b/src/EndUsersInfrastructure/EndUsersInfrastructure.csproj new file mode 100644 index 00000000..76a22c92 --- /dev/null +++ b/src/EndUsersInfrastructure/EndUsersInfrastructure.csproj @@ -0,0 +1,42 @@ + + + + net7.0 + + + + + + + + + + + + + + <_Parameter1>$(AssemblyName).UnitTests + + + + + + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + True + True + Resources.resx + + + + diff --git a/src/EndUsersInfrastructure/EndUsersModule.cs b/src/EndUsersInfrastructure/EndUsersModule.cs new file mode 100644 index 00000000..6640cc66 --- /dev/null +++ b/src/EndUsersInfrastructure/EndUsersModule.cs @@ -0,0 +1,39 @@ +using System.Reflection; +using Application.Services.Shared; +using EndUsersApplication; +using EndUsersInfrastructure.Api.EndUsers; +using EndUsersInfrastructure.ApplicationServices; +using Infrastructure.Hosting.Common.Extensions; +using Infrastructure.Web.Hosting.Common; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EndUsersInfrastructure; + +public class EndUsersModule : ISubDomainModule +{ + public Assembly ApiAssembly => typeof(EndUsersApi).Assembly; + + public Assembly DomainAssembly => null!; + + public Dictionary AggregatePrefixes => new(); + + public Action RegisterServices + { + get + { + return (_, services) => + { + services.RegisterUnshared(); + + services.RegisterUnshared(); + }; + } + } + + public Action ConfigureMiddleware + { + get { return app => { app.RegisterRoutes(); }; } + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Resources.Designer.cs b/src/EndUsersInfrastructure/Resources.Designer.cs new file mode 100644 index 00000000..c1ccea61 --- /dev/null +++ b/src/EndUsersInfrastructure/Resources.Designer.cs @@ -0,0 +1,62 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace EndUsersInfrastructure { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("EndUsersInfrastructure.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} diff --git a/src/EndUsersInfrastructure/Resources.resx b/src/EndUsersInfrastructure/Resources.resx new file mode 100644 index 00000000..755958fe --- /dev/null +++ b/src/EndUsersInfrastructure/Resources.resx @@ -0,0 +1,27 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + \ No newline at end of file diff --git a/src/IdentityApplication.UnitTests/AuthTokensApplicationSpec.cs b/src/IdentityApplication.UnitTests/AuthTokensApplicationSpec.cs new file mode 100644 index 00000000..54fd786c --- /dev/null +++ b/src/IdentityApplication.UnitTests/AuthTokensApplicationSpec.cs @@ -0,0 +1,153 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; +using FluentAssertions; +using IdentityApplication.ApplicationServices; +using IdentityApplication.Persistence; +using IdentityDomain; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace IdentityApplication.UnitTests; + +[Trait("Category", "Unit")] +public class AuthTokensApplicationSpec +{ + private readonly AuthTokensApplication _application; + private readonly Mock _caller; + private readonly Mock _endUsersService; + private readonly Mock _idFactory; + private readonly Mock _jwtTokensService; + private readonly Mock _recorder; + private readonly Mock _repository; + + public AuthTokensApplicationSpec() + { + _recorder = new Mock(); + _idFactory = new Mock(); + _idFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns("anid".ToId()); + _caller = new Mock(); + _caller.Setup(cc => cc.CallerId) + .Returns("acallerid"); + _jwtTokensService = new Mock(); + _repository = new Mock(); + _endUsersService = new Mock(); + _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) + .Returns((AuthTokensRoot root, CancellationToken _) => + Task.FromResult>(root)); + + _application = new AuthTokensApplication(_recorder.Object, _idFactory.Object, _jwtTokensService.Object, + _endUsersService.Object, _repository.Object); + } + + [Fact] + public async Task WhenIssueTokensAsyncAndUserNotExist_ThenReturnsTokens() + { + var user = new EndUser + { + Id = "anid" + }; + var expiresOn = DateTime.UtcNow.AddMinutes(1); + _jwtTokensService.Setup(jts => jts.IssueTokensAsync(It.IsAny())) + .Returns(Task.FromResult>( + new AccessTokens("anaccesstoken", "arefreshtoken", expiresOn))); + _repository.Setup(rep => rep.FindByUserIdAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(Optional.None)); + + var result = await _application.IssueTokensAsync(_caller.Object, user, CancellationToken.None); + + result.Value.AccessToken.Should().Be("anaccesstoken"); + result.Value.RefreshToken.Should().Be("arefreshtoken"); + result.Value.ExpiresOn.Should().Be(expiresOn); + _jwtTokensService.Verify(jts => jts.IssueTokensAsync(user)); + _repository.Verify(rep => rep.SaveAsync(It.Is(at => + at.Id == "anid" + && at.AccessToken == "anaccesstoken" + && at.RefreshToken == "arefreshtoken" + && at.ExpiresOn == expiresOn + ), It.IsAny())); + } + + [Fact] + public async Task WhenIssueTokensAsyncAndUserExists_ThenReturnsTokens() + { + var user = new EndUser + { + Id = "anid" + }; + var authTokens = AuthTokensRoot.Create(_recorder.Object, _idFactory.Object, "auserid".ToId()).Value; + var expiresOn = DateTime.UtcNow.AddMinutes(1); + _jwtTokensService.Setup(jts => jts.IssueTokensAsync(It.IsAny())) + .Returns(Task.FromResult>( + new AccessTokens("anaccesstoken", "arefreshtoken", expiresOn))); + _repository.Setup(rep => rep.FindByUserIdAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(authTokens.ToOptional())); + + var result = await _application.IssueTokensAsync(_caller.Object, user, CancellationToken.None); + + result.Value.AccessToken.Should().Be("anaccesstoken"); + result.Value.RefreshToken.Should().Be("arefreshtoken"); + result.Value.ExpiresOn.Should().Be(expiresOn); + _jwtTokensService.Verify(jts => jts.IssueTokensAsync(user)); + _repository.Verify(rep => rep.SaveAsync(It.Is(at => + at.Id == "anid" + && at.AccessToken == "anaccesstoken" + && at.RefreshToken == "arefreshtoken" + && at.ExpiresOn == expiresOn + ), It.IsAny())); + } + + [Fact] + public async Task WhenRefreshTokenAsyncAndTokensNotExist_ThenReturnsError() + { + _repository.Setup(rep => rep.FindByRefreshTokenAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(Optional.None)); + + var result = await _application.RefreshTokenAsync(_caller.Object, "arefreshtoken", CancellationToken.None); + + result.Should().BeError(ErrorCode.NotAuthenticated); + _jwtTokensService.Verify(jts => jts.IssueTokensAsync(It.IsAny()), Times.Never); + _repository.Verify(rep => rep.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WhenRefreshTokenAsyncAndTokensExist_ThenReturnsRefreshedTokens() + { + var user = new EndUser + { + Id = "anid" + }; + var expiresOn1 = DateTime.UtcNow.AddMinutes(1); + var expiresOn2 = DateTime.UtcNow.AddMinutes(2); + var authTokens = AuthTokensRoot.Create(_recorder.Object, _idFactory.Object, "auserid".ToId()).Value; + authTokens.SetTokens("anaccesstoken1", "arefreshtoken1", expiresOn1); + _repository.Setup(rep => rep.FindByRefreshTokenAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(authTokens.ToOptional())); + _endUsersService.Setup(eus => + eus.GetPersonAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult>(user)); + _jwtTokensService.Setup(jts => jts.IssueTokensAsync(It.IsAny())) + .Returns(Task.FromResult>( + new AccessTokens("anaccesstoken2", "arefreshtoken2", expiresOn2))); + + var result = await _application.RefreshTokenAsync(_caller.Object, "arefreshtoken1", CancellationToken.None); + + result.Value.AccessToken.Should().Be("anaccesstoken2"); + result.Value.RefreshToken.Should().Be("arefreshtoken2"); + result.Value.ExpiresOn.Should().Be(expiresOn2); + _jwtTokensService.Verify(jts => jts.IssueTokensAsync(user)); + _repository.Verify(rep => rep.SaveAsync(It.Is(at => + at.Id == "anid" + && at.AccessToken == "anaccesstoken2" + && at.RefreshToken == "arefreshtoken2" + && at.ExpiresOn == expiresOn2 + ), It.IsAny())); + } +} \ No newline at end of file diff --git a/src/IdentityApplication.UnitTests/IdentityApplication.UnitTests.csproj b/src/IdentityApplication.UnitTests/IdentityApplication.UnitTests.csproj new file mode 100644 index 00000000..deff4495 --- /dev/null +++ b/src/IdentityApplication.UnitTests/IdentityApplication.UnitTests.csproj @@ -0,0 +1,18 @@ + + + + net7.0 + true + + + + + + + + + + + + + diff --git a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs new file mode 100644 index 00000000..f9d99691 --- /dev/null +++ b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs @@ -0,0 +1,373 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Common.Configuration; +using Common.Extensions; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; +using Domain.Services.Shared.DomainServices; +using Domain.Shared; +using FluentAssertions; +using IdentityApplication.ApplicationServices; +using IdentityApplication.Persistence; +using IdentityDomain; +using IdentityDomain.DomainServices; +using Moq; +using UnitTesting.Common; +using Xunit; +using PersonName = Application.Resources.Shared.PersonName; +using Task = System.Threading.Tasks.Task; + +namespace IdentityApplication.UnitTests; + +[Trait("Category", "Unit")] +public class PasswordCredentialsApplicationSpec +{ + private readonly PasswordCredentialsApplication _application; + private readonly Mock _authTokensService; + private readonly Mock _caller; + private readonly Mock _emailAddressService; + private readonly Mock _endUsersService; + private readonly Mock _idFactory; + private readonly Mock _notificationsService; + private readonly Mock _passwordHasherService; + private readonly Mock _recorder; + private readonly Mock _repository; + private readonly Mock _settings; + private readonly Mock _tokensService; + + public PasswordCredentialsApplicationSpec() + { + _recorder = new Mock(); + _idFactory = new Mock(); + _idFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns("anid".ToId()); + _caller = new Mock(); + _caller.Setup(cc => cc.CallerId) + .Returns("acallerid"); + _endUsersService = new Mock(); + _notificationsService = new Mock(); + _settings = new Mock(); + _settings.Setup(s => s.Platform.GetString(It.IsAny(), It.IsAny())) + .Returns((string?)null!); + _settings.Setup(s => s.Platform.GetNumber(It.IsAny(), It.IsAny())) + .Returns(5); + _emailAddressService = new Mock(); + _emailAddressService.Setup(eas => eas.EnsureUniqueAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(true)); + _tokensService = new Mock(); + _tokensService.Setup(ts => ts.CreateTokenForVerification()) + .Returns("averificationtoken"); + _passwordHasherService = new Mock(); + _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) + .Returns(true); + _passwordHasherService.Setup(phs => phs.HashPassword(It.IsAny())) + .Returns("apasswordhash"); + _passwordHasherService.Setup(phs => phs.ValidatePasswordHash(It.IsAny())) + .Returns(true); + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(true); + _authTokensService = new Mock(); + _repository = new Mock(); + _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) + .Returns((PasswordCredentialRoot root, CancellationToken _) => + Task.FromResult>(root)); + + _application = new PasswordCredentialsApplication(_recorder.Object, _idFactory.Object, _endUsersService.Object, + _notificationsService.Object, _settings.Object, _emailAddressService.Object, _tokensService.Object, + _passwordHasherService.Object, _authTokensService.Object, _repository.Object); + } + + [Fact] + public async Task WhenAuthenticateAsyncAndNoCredentials_ThenReturnsError() + { + _repository.Setup(rep => rep.FindCredentialsByUserEmailAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(Optional + .None)); + + var result = + await _application.AuthenticateAsync(_caller.Object, "ausername", "apassword", CancellationToken.None); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public async Task WhenAuthenticateAsyncAndUnknownUser_ThenReturnsError() + { + var credential = CreateCredential(); + _repository.Setup(rep => rep.FindCredentialsByUserEmailAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(credential.ToOptional())); + _endUsersService.Setup(eus => + eus.GetPersonAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult>(Error.EntityNotFound())); + + var result = + await _application.AuthenticateAsync(_caller.Object, "ausername", "apassword", CancellationToken.None); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public async Task WhenAuthenticateAsyncAndEndUserIsNotRegistered_ThenReturnsError() + { + var credential = CreateCredential(); + _repository.Setup(rep => rep.FindCredentialsByUserEmailAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(credential.ToOptional())); + _endUsersService.Setup(eus => + eus.GetPersonAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult>(new EndUser + { + Id = "anid", + Status = EndUserStatus.Unregistered + })); + + var result = + await _application.AuthenticateAsync(_caller.Object, "ausername", "apassword", CancellationToken.None); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public async Task WhenAuthenticateAsyncAndEndUserIsSuspended_ThenReturnsError() + { + var credential = CreateCredential(); + _repository.Setup(rep => rep.FindCredentialsByUserEmailAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(credential.ToOptional())); + _endUsersService.Setup(eus => + eus.GetPersonAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult>(new EndUser + { + Id = "auserid", + Status = EndUserStatus.Registered, + Access = EndUserAccess.Suspended + })); + + var result = + await _application.AuthenticateAsync(_caller.Object, "ausername", "apassword", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityExists, Resources.PasswordCredentialsApplication_AccountSuspended); + _repository.Verify(rep => rep.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + _recorder.Verify(rec => rec.AuditAgainst(It.IsAny(), "auserid", + Audits.PasswordCredentialsApplication_Authenticate_AccountSuspended, It.IsAny(), + It.IsAny())); + } + + [Fact] + public async Task WhenAuthenticateAsyncAndCredentialsIsLocked_ThenReturnsError() + { + var credential = CreateVerifiedCredential(); + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(false); +#if TESTINGONLY + credential.TestingOnly_LockAccount("awrongpassword"); +#endif + _repository.Setup(rep => rep.FindCredentialsByUserEmailAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(credential.ToOptional())); + _endUsersService.Setup(eus => + eus.GetPersonAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult>(new EndUser + { + Id = "auserid", + Status = EndUserStatus.Registered, + Access = EndUserAccess.Enabled + })); + + var result = + await _application.AuthenticateAsync(_caller.Object, "ausername", "apassword", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityExists, Resources.PasswordCredentialsApplication_AccountLocked); + _repository.Verify(rep => rep.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + _recorder.Verify(rec => rec.AuditAgainst(It.IsAny(), "auserid", + Audits.PasswordCredentialsApplication_Authenticate_AccountLocked, It.IsAny(), + It.IsAny())); + } + + [Fact] + public async Task WhenAuthenticateAsyncAndWrongPassword_ThenReturnsError() + { + var credential = CreateUnVerifiedCredential(); + _repository.Setup(rep => rep.FindCredentialsByUserEmailAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(credential.ToOptional())); + _endUsersService.Setup(eus => + eus.GetPersonAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult>(new EndUser + { + Id = "auserid", + Status = EndUserStatus.Registered, + Access = EndUserAccess.Enabled + })); + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(false); + + var result = + await _application.AuthenticateAsync(_caller.Object, "ausername", "awrongpassword", CancellationToken.None); + + result.Should().BeError(ErrorCode.NotAuthenticated); + _repository.Verify(rep => rep.SaveAsync(It.IsAny(), It.IsAny())); + _recorder.Verify(rec => rec.AuditAgainst(It.IsAny(), "auserid", + Audits.PasswordCredentialsApplication_Authenticate_InvalidCredentials, It.IsAny(), + It.IsAny())); + } + + [Fact] + public async Task WhenAuthenticateAsyncAndCredentialsNotYetVerified_ThenReturnsError() + { + var credential = CreateUnVerifiedCredential(); + _repository.Setup(rep => rep.FindCredentialsByUserEmailAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(credential.ToOptional())); + _endUsersService.Setup(eus => + eus.GetPersonAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult>(new EndUser + { + Id = "auserid", + Status = EndUserStatus.Registered, + Access = EndUserAccess.Enabled + })); + + var result = + await _application.AuthenticateAsync(_caller.Object, "ausername", "apassword", CancellationToken.None); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialsApplication_RegistrationNotVerified); + _repository.Verify(rep => rep.SaveAsync(It.IsAny(), It.IsAny())); + _recorder.Verify(rec => rec.AuditAgainst(It.IsAny(), "auserid", + Audits.PasswordCredentialsApplication_Authenticate_BeforeVerified, It.IsAny(), + It.IsAny())); + } + + [Fact] + public async Task WhenAuthenticateAsyncWithCorrectPassword_ThenReturnsError() + { + var credential = CreateVerifiedCredential(); + _repository.Setup(rep => rep.FindCredentialsByUserEmailAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(credential.ToOptional())); + var user = new EndUser + { + Id = "auserid", + Status = EndUserStatus.Registered, + Access = EndUserAccess.Enabled + }; + _endUsersService.Setup(eus => + eus.GetPersonAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult>(user)); + var expiresOn = DateTime.UtcNow; + _authTokensService.Setup(jts => + jts.IssueTokensAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult>(new AccessTokens("anaccesstoken", "arefreshtoken", + expiresOn))); + + var result = + await _application.AuthenticateAsync(_caller.Object, "ausername", "apassword", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.AccessToken.Should().Be("anaccesstoken"); + result.Value.RefreshToken.Should().Be("arefreshtoken"); + result.Value.ExpiresOn.Should().Be(expiresOn); + _repository.Verify(rep => rep.SaveAsync(It.IsAny(), It.IsAny())); + _recorder.Verify(rec => rec.AuditAgainst(It.IsAny(), "auserid", + Audits.PasswordCredentialsApplication_Authenticate_Succeeded, It.IsAny(), + It.IsAny())); + _authTokensService.Verify(jts => + jts.IssueTokensAsync(It.IsAny(), user, It.IsAny())); + } + + [Fact] + public async Task WhenRegisterPersonUserAccountAndAlreadyExists_ThenDoesNothing() + { + var endUser = new RegisteredEndUser + { + Id = "auserid" + }; + _endUsersService.Setup(uas => uas.RegisterPersonAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.FromResult>(endUser)); + var credential = CreateUnVerifiedCredential(); + _repository.Setup(s => s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(credential.ToOptional())); + + var result = await _application.RegisterPersonAsync(_caller.Object, "afirstname", + "alastname", "auser@company.com", "apassword", "atimezone", "acountrycode", true, CancellationToken.None); + + result.Value.User.Should().Be(endUser); + _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + _endUsersService.Verify(uas => uas.RegisterPersonAsync(_caller.Object, + "auser@company.com", "afirstname", "alastname", "atimezone", "acountrycode", true, + It.IsAny())); + } + + [Fact] + public async Task WhenRegisterPersonUserAccountAndNotExists_ThenCreatesAndSendsConfirmation() + { + var registeredAccount = new RegisteredEndUser + { + Id = "auserid", + Profile = new DefaultMembershipProfile + { + Id = "anid", + Name = new PersonName + { + FirstName = "aname" + }, + DisplayName = "adisplayname", + EmailAddress = "auser@company.com" + } + }; + _endUsersService.Setup(uas => uas.RegisterPersonAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.FromResult>(registeredAccount)); + _repository.Setup(s => s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(Optional + .None)); + + var result = await _application.RegisterPersonAsync(_caller.Object, "afirstname", + "alastname", "auser@company.com", "apassword", "atimezone", "acountrycode", true, CancellationToken.None); + + result.Value.User.Should().Be(registeredAccount); + _repository.Verify(s => s.SaveAsync(It.Is(uc => + uc.Id == "anid" + && uc.UserId == "auserid" + && uc.Registration.Value.Name == "adisplayname" + && uc.Registration.Value.EmailAddress == "auser@company.com" + && uc.Password.PasswordHash == "apasswordhash" + && uc.Login.Exists() + && !uc.Verification.IsVerified + ), It.IsAny())); + _notificationsService.Verify(ns => + ns.NotifyPasswordRegistrationConfirmationAsync(_caller.Object, "auser@company.com", "adisplayname", + "averificationtoken", It.IsAny())); + _endUsersService.Verify(eus => eus.RegisterPersonAsync(_caller.Object, + "auser@company.com", "afirstname", "alastname", "atimezone", "acountrycode", true, + It.IsAny())); + } + + private PasswordCredentialRoot CreateUnVerifiedCredential() + { + var credential = CreateCredential(); + credential.SetCredential("apassword"); + credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + return credential; + } + + private PasswordCredentialRoot CreateVerifiedCredential() + { + var credential = CreateUnVerifiedCredential(); + credential.InitiateRegistrationVerification(); + credential.VerifyRegistration(); + return credential; + } + + private PasswordCredentialRoot CreateCredential() + { + return PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, _settings.Object, + _emailAddressService.Object, _tokensService.Object, _passwordHasherService.Object, + "auserid".ToId()).Value; + } +} \ No newline at end of file diff --git a/src/IdentityApplication/ApiKeysApplication.cs b/src/IdentityApplication/ApiKeysApplication.cs new file mode 100644 index 00000000..506d04a2 --- /dev/null +++ b/src/IdentityApplication/ApiKeysApplication.cs @@ -0,0 +1,16 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + +namespace IdentityApplication; + +public class ApiKeysApplication : IApiKeysApplication +{ + public async Task> RegisterMachineAsync(ICallerContext context, string name, + string? timezone, string? countryCode, + CancellationToken cancellationToken) + { + await Task.CompletedTask; + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/IdentityApplication/ApplicationServices/DelayGenerator.cs b/src/IdentityApplication/ApplicationServices/DelayGenerator.cs new file mode 100644 index 00000000..ceefb0a4 --- /dev/null +++ b/src/IdentityApplication/ApplicationServices/DelayGenerator.cs @@ -0,0 +1,11 @@ +namespace IdentityApplication.ApplicationServices; + +public class DelayGenerator : IDelayGenerator +{ + private static readonly Random Random = Random.Shared; + + public TimeSpan GetNextRandom(double fromSeconds, double toSeconds) + { + return TimeSpan.FromMilliseconds(Random.Next((int)(fromSeconds * 1000), (int)(toSeconds * 1000))); + } +} \ No newline at end of file diff --git a/src/IdentityApplication/ApplicationServices/IAuthTokensService.cs b/src/IdentityApplication/ApplicationServices/IAuthTokensService.cs new file mode 100644 index 00000000..8ee970cf --- /dev/null +++ b/src/IdentityApplication/ApplicationServices/IAuthTokensService.cs @@ -0,0 +1,11 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + +namespace IdentityApplication.ApplicationServices; + +public interface IAuthTokensService +{ + Task> IssueTokensAsync(ICallerContext context, EndUser user, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/IdentityApplication/ApplicationServices/IDelayGenerator.cs b/src/IdentityApplication/ApplicationServices/IDelayGenerator.cs new file mode 100644 index 00000000..4fc8c78b --- /dev/null +++ b/src/IdentityApplication/ApplicationServices/IDelayGenerator.cs @@ -0,0 +1,6 @@ +namespace IdentityApplication.ApplicationServices; + +public interface IDelayGenerator +{ + TimeSpan GetNextRandom(double fromSeconds, double toSeconds); +} \ No newline at end of file diff --git a/src/IdentityApplication/ApplicationServices/IJWTTokensService.cs b/src/IdentityApplication/ApplicationServices/IJWTTokensService.cs new file mode 100644 index 00000000..179c4f7a --- /dev/null +++ b/src/IdentityApplication/ApplicationServices/IJWTTokensService.cs @@ -0,0 +1,25 @@ +using Application.Resources.Shared; +using Common; + +namespace IdentityApplication.ApplicationServices; + +public interface IJWTTokensService +{ + Task> IssueTokensAsync(EndUser user); +} + +public struct AccessTokens +{ + public AccessTokens(string accessToken, string refreshToken, DateTime expiresOn) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + ExpiresOn = expiresOn; + } + + public string AccessToken { get; } + + public string RefreshToken { get; } + + public DateTime ExpiresOn { get; } +} \ No newline at end of file diff --git a/src/IdentityApplication/AuthTokensApplication.cs b/src/IdentityApplication/AuthTokensApplication.cs new file mode 100644 index 00000000..72a307c8 --- /dev/null +++ b/src/IdentityApplication/AuthTokensApplication.cs @@ -0,0 +1,126 @@ +using Application.Common; +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using IdentityApplication.ApplicationServices; +using IdentityApplication.Persistence; +using IdentityDomain; + +namespace IdentityApplication; + +public class AuthTokensApplication : IAuthTokensApplication +{ + private readonly IEndUsersService _endUsersService; + private readonly IIdentifierFactory _idFactory; + private readonly IJWTTokensService _jwtTokensService; + private readonly IRecorder _recorder; + private readonly IAuthTokensRepository _repository; + + public AuthTokensApplication(IRecorder recorder, IIdentifierFactory idFactory, IJWTTokensService jwtTokensService, + IEndUsersService endUsersService, IAuthTokensRepository repository) + { + _recorder = recorder; + _idFactory = idFactory; + _jwtTokensService = jwtTokensService; + _endUsersService = endUsersService; + _repository = repository; + } + + public async Task> IssueTokensAsync(ICallerContext context, EndUser user, + CancellationToken cancellationToken) + { + var issued = await _jwtTokensService.IssueTokensAsync(user); + if (!issued.IsSuccessful) + { + return issued.Error; + } + + var tokens = issued.Value; + var retrieved = await _repository.FindByUserIdAsync(user.Id.ToId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + AuthTokensRoot authTokens; + if (retrieved.Value.HasValue) + { + authTokens = retrieved.Value.Value; + } + else + { + var root = AuthTokensRoot.Create(_recorder, _idFactory, user.Id.ToId()); + if (!root.IsSuccessful) + { + return root.Error; + } + + authTokens = root.Value; + } + + var set = authTokens.SetTokens(tokens.AccessToken, tokens.RefreshToken, tokens.ExpiresOn); + if (!set.IsSuccessful) + { + return set.Error; + } + + var updated = await _repository.SaveAsync(authTokens, cancellationToken); + if (!updated.IsSuccessful) + { + return updated.Error; + } + + _recorder.TraceInformation(context.ToCall(), "AuthTokens were issued for {Id}", updated.Value.Id); + + return tokens; + } + + public async Task> RefreshTokenAsync(ICallerContext context, string refreshToken, + CancellationToken cancellationToken) + { + var retrieved = await _repository.FindByRefreshTokenAsync(refreshToken, cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.NotAuthenticated(); + } + + var authTokens = retrieved.Value.Value; + var retrievedUser = await _endUsersService.GetPersonAsync(context, authTokens.UserId, cancellationToken); + if (!retrievedUser.IsSuccessful) + { + return retrievedUser.Error; + } + + var user = retrievedUser.Value; + var issued = await _jwtTokensService.IssueTokensAsync(user); + if (!issued.IsSuccessful) + { + return issued.Error; + } + + var tokens = issued.Value; + var renewed = authTokens.RenewTokens(refreshToken, tokens.AccessToken, tokens.RefreshToken, tokens.ExpiresOn); + if (!renewed.IsSuccessful) + { + return Error.NotAuthenticated(); + } + + var updated = await _repository.SaveAsync(authTokens, cancellationToken); + if (!updated.IsSuccessful) + { + return updated.Error; + } + + _recorder.TraceInformation(context.ToCall(), "AuthTokens were refreshed for {Id}", updated.Value.Id); + + return tokens; + } +} \ No newline at end of file diff --git a/src/IdentityApplication/IApiKeysApplication.cs b/src/IdentityApplication/IApiKeysApplication.cs new file mode 100644 index 00000000..d93941b3 --- /dev/null +++ b/src/IdentityApplication/IApiKeysApplication.cs @@ -0,0 +1,11 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + +namespace IdentityApplication; + +public interface IApiKeysApplication +{ + Task> RegisterMachineAsync(ICallerContext context, string name, string? timezone, + string? countryCode, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/IdentityApplication/IAuthTokensApplication.cs b/src/IdentityApplication/IAuthTokensApplication.cs new file mode 100644 index 00000000..164a92a2 --- /dev/null +++ b/src/IdentityApplication/IAuthTokensApplication.cs @@ -0,0 +1,15 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; +using IdentityApplication.ApplicationServices; + +namespace IdentityApplication; + +public interface IAuthTokensApplication +{ + Task> IssueTokensAsync(ICallerContext context, EndUser user, + CancellationToken cancellationToken); + + Task> RefreshTokenAsync(ICallerContext context, string refreshToken, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/IdentityApplication/IMachineCredentialsApplication.cs b/src/IdentityApplication/IMachineCredentialsApplication.cs new file mode 100644 index 00000000..9f455cae --- /dev/null +++ b/src/IdentityApplication/IMachineCredentialsApplication.cs @@ -0,0 +1,11 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + +namespace IdentityApplication; + +public interface IMachineCredentialsApplication +{ + Task> RegisterMachineAsync(ICallerContext context, string name, + string? timezone, string? countryCode, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/IdentityApplication/IPasswordCredentialsApplication.cs b/src/IdentityApplication/IPasswordCredentialsApplication.cs new file mode 100644 index 00000000..07131c24 --- /dev/null +++ b/src/IdentityApplication/IPasswordCredentialsApplication.cs @@ -0,0 +1,16 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + +namespace IdentityApplication; + +public interface IPasswordCredentialsApplication +{ + Task> AuthenticateAsync(ICallerContext context, string username, string password, + CancellationToken cancellationToken); + + Task> RegisterPersonAsync(ICallerContext context, string firstName, + string lastName, + string emailAddress, string password, string? timezone, string? countryCode, bool termsAndConditionsAccepted, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/IdentityApplication/IdentityApplication.csproj b/src/IdentityApplication/IdentityApplication.csproj new file mode 100644 index 00000000..2a8ebd63 --- /dev/null +++ b/src/IdentityApplication/IdentityApplication.csproj @@ -0,0 +1,39 @@ + + + + net7.0 + + + + + <_Parameter1>$(AssemblyName).UnitTests + + + + + + + + + + + + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + True + True + Resources.resx + + + + diff --git a/src/IdentityApplication/MachineCredentialsApplication.cs b/src/IdentityApplication/MachineCredentialsApplication.cs new file mode 100644 index 00000000..59b9a802 --- /dev/null +++ b/src/IdentityApplication/MachineCredentialsApplication.cs @@ -0,0 +1,16 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + +namespace IdentityApplication; + +public class MachineCredentialsApplication : IMachineCredentialsApplication +{ + public async Task> RegisterMachineAsync(ICallerContext context, string name, + string? timezone, string? countryCode, + CancellationToken cancellationToken) + { + await Task.CompletedTask; + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/IdentityApplication/PasswordCredentialsApplication.cs b/src/IdentityApplication/PasswordCredentialsApplication.cs new file mode 100644 index 00000000..71b7a316 --- /dev/null +++ b/src/IdentityApplication/PasswordCredentialsApplication.cs @@ -0,0 +1,286 @@ +using Application.Common; +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Common.Configuration; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Services.Shared.DomainServices; +using Domain.Shared; +using IdentityApplication.ApplicationServices; +using IdentityApplication.Persistence; +using IdentityDomain; +using IdentityDomain.DomainServices; + +namespace IdentityApplication; + +public class PasswordCredentialsApplication : IPasswordCredentialsApplication +{ +#if TESTINGONLY + private const double MinAuthenticateDelayInSecs = 0; + private const double MaxAuthenticateDelayInSecs = 0; +#else + private const double MinAuthenticateDelayInSecs = 1.5; + private const double MaxAuthenticateDelayInSecs = 4.0; +#endif + private readonly IEndUsersService _endUsersService; + private readonly INotificationsService _notificationsService; + private readonly IConfigurationSettings _settings; + private readonly IEmailAddressService _emailAddressService; + private readonly ITokensService _tokensService; + private readonly IPasswordHasherService _passwordHasherService; + private readonly IAuthTokensService _authTokensService; + private readonly IRecorder _recorder; + private readonly IIdentifierFactory _identifierFactory; + private readonly IPasswordCredentialsRepository _repository; + private readonly IDelayGenerator _delayGenerator; + + public PasswordCredentialsApplication(IRecorder recorder, IIdentifierFactory identifierFactory, + IEndUsersService endUsersService, INotificationsService notificationsService, IConfigurationSettings settings, + IEmailAddressService emailAddressService, ITokensService tokensService, + IPasswordHasherService passwordHasherService, IAuthTokensService authTokensService, + IPasswordCredentialsRepository repository) : this(recorder, + identifierFactory, endUsersService, notificationsService, settings, emailAddressService, tokensService, + passwordHasherService, authTokensService, repository, new DelayGenerator()) + { + _recorder = recorder; + _endUsersService = endUsersService; + _repository = repository; + } + + private PasswordCredentialsApplication(IRecorder recorder, IIdentifierFactory identifierFactory, + IEndUsersService endUsersService, INotificationsService notificationsService, IConfigurationSettings settings, + IEmailAddressService emailAddressService, ITokensService tokensService, + IPasswordHasherService passwordHasherService, IAuthTokensService authTokensService, + IPasswordCredentialsRepository repository, + IDelayGenerator delayGenerator) + { + _recorder = recorder; + _identifierFactory = identifierFactory; + _endUsersService = endUsersService; + _notificationsService = notificationsService; + _settings = settings; + _emailAddressService = emailAddressService; + _tokensService = tokensService; + _passwordHasherService = passwordHasherService; + _authTokensService = authTokensService; + _repository = repository; + _delayGenerator = delayGenerator; + } + + public async Task> AuthenticateAsync(ICallerContext context, string username, + string password, CancellationToken cancellationToken) + { + await DelayForRandomPeriodAsync(MinAuthenticateDelayInSecs, MaxAuthenticateDelayInSecs, cancellationToken); + + var fetched = await _repository.FindCredentialsByUserEmailAsync(username, cancellationToken); + if (!fetched.IsSuccessful) + { + return Error.NotAuthenticated(); + } + + if (!fetched.Value.HasValue) + { + return Error.NotAuthenticated(); + } + + var credentials = fetched.Value.Value; + var retrieved = await _endUsersService.GetPersonAsync(context, credentials.UserId, cancellationToken); + if (!retrieved.IsSuccessful) + { + return Error.NotAuthenticated(); + } + + var user = retrieved.Value; + if (user.Status != EndUserStatus.Registered) + { + return Error.NotAuthenticated(); + } + + if (user.Access == EndUserAccess.Suspended) + { + _recorder.AuditAgainst(context.ToCall(), user.Id, + Audits.PasswordCredentialsApplication_Authenticate_AccountSuspended, + "User {Id} tried to authenticate a password with a suspended account", user.Id); + return Error.EntityExists(Resources.PasswordCredentialsApplication_AccountSuspended); + } + + if (credentials.IsLocked) + { + _recorder.AuditAgainst(context.ToCall(), user.Id, + Audits.PasswordCredentialsApplication_Authenticate_AccountLocked, + "User {Id} tried to authenticate a password with a locked account", user.Id); + return Error.EntityExists(Resources.PasswordCredentialsApplication_AccountLocked); + } + + var verifyPassword = await VerifyPasswordAsync(); + if (!verifyPassword.IsSuccessful) + { + return verifyPassword.Error; + } + + var isVerified = verifyPassword.Value; + if (!isVerified) + { + _recorder.AuditAgainst(context.ToCall(), user.Id, + Audits.PasswordCredentialsApplication_Authenticate_InvalidCredentials, + "User {Id} failed to authenticate with an invalid password", user.Id); + return Error.NotAuthenticated(); + } + + if (!credentials.IsVerified) + { + _recorder.AuditAgainst(context.ToCall(), user.Id, + Audits.PasswordCredentialsApplication_Authenticate_BeforeVerified, + "User {Id} tried to authenticate with a password before verifying their account", user.Id); + return Error.PreconditionViolation(Resources.PasswordCredentialsApplication_RegistrationNotVerified); + } + + _recorder.AuditAgainst(context.ToCall(), user.Id, + Audits.PasswordCredentialsApplication_Authenticate_Succeeded, + "User {Id} succeeded to authenticate with a password", user.Id); + + var issued = await _authTokensService.IssueTokensAsync(context, user, cancellationToken); + if (!issued.IsSuccessful) + { + return issued.Error; + } + + var tokens = issued.Value; + return new Result(new AuthenticateTokens + { + AccessToken = tokens.AccessToken, + RefreshToken = tokens.RefreshToken, + ExpiresOn = tokens.ExpiresOn + }); + + async Task> VerifyPasswordAsync() + { + var verify = credentials.VerifyPassword(password); + if (!verify.IsSuccessful) + { + return verify.Error; + } + + var saved1 = await _repository.SaveAsync(credentials, cancellationToken); + if (!saved1.IsSuccessful) + { + return saved1.Error; + } + + _recorder.TraceInformation(context.ToCall(), "Credentials were verified for {Id}", saved1.Value.Id); + + return verify.Value; + } + } + + public async Task> RegisterPersonAsync(ICallerContext context, string firstName, + string lastName, string emailAddress, string password, string? timezone, string? countryCode, + bool termsAndConditionsAccepted, + CancellationToken cancellationToken) + { + var registered = await _endUsersService.RegisterPersonAsync(context, emailAddress, firstName, lastName, + timezone, countryCode, termsAndConditionsAccepted, cancellationToken); + if (!registered.IsSuccessful) + { + return registered.Error; + } + + return await RegisterPersonInternalAsync(context, password, registered.Value, cancellationToken); + } + + private async Task> RegisterPersonInternalAsync(ICallerContext context, + string password, RegisteredEndUser user, CancellationToken cancellationToken) + { + var fetched = await _repository.FindCredentialsByUserIdAsync(user.Id.ToId(), cancellationToken); + if (!fetched.IsSuccessful) + { + return fetched.Error; + } + + if (fetched.Value.HasValue) + { + return fetched.Value.Value.ToCredential(user); + } + + var created = PasswordCredentialRoot.Create(_recorder, _identifierFactory, _settings, _emailAddressService, + _tokensService, _passwordHasherService, user.Id.ToId()); + if (!created.IsSuccessful) + { + return created.Error; + } + + var emailAddress = EmailAddress.Create(user.Profile!.EmailAddress); + if (!emailAddress.IsSuccessful) + { + return emailAddress.Error; + } + + var name = PersonDisplayName.Create(user.Profile.DisplayName); + if (!name.IsSuccessful) + { + return name.Error; + } + + var credentials = created.Value; + var credentialed = credentials.SetCredential(password); + if (!credentialed.IsSuccessful) + { + return credentialed.Error; + } + + var registered = credentials.SetRegistrationDetails(emailAddress.Value, name.Value); + if (!registered.IsSuccessful) + { + return registered.Error; + } + + var initiated = credentials.InitiateRegistrationVerification(); + if (!initiated.IsSuccessful) + { + return initiated.Error; + } + + var updated = await _repository.SaveAsync(credentials, cancellationToken); + if (!updated.IsSuccessful) + { + return updated.Error; + } + + await _notificationsService.NotifyPasswordRegistrationConfirmationAsync(context, + updated.Value.Registration.Value.EmailAddress, + updated.Value.Registration.Value.Name, updated.Value.Verification.Token, cancellationToken); + + _recorder.TraceInformation(context.ToCall(), "Password credentials created for {UserId}", updated.Value.UserId); + + return updated.Value.ToCredential(user); + } + + /// + /// Provides a random time delay to mitigate against authentication timing attacks + /// + private async Task DelayForRandomPeriodAsync(double fromSeconds, double toSeconds, + CancellationToken cancellationToken) + { + if (Math.Abs(fromSeconds - toSeconds) < 1) + { + return; + } + + var delay = _delayGenerator.GetNextRandom(fromSeconds, toSeconds); + await Task.Delay(delay, cancellationToken); + } +} + +internal static class PasswordCredentialConversionExtensions +{ + public static PasswordCredential ToCredential(this PasswordCredentialRoot credential, RegisteredEndUser user) + { + return new PasswordCredential + { + Id = credential.Id, + User = user + }; + } +} \ No newline at end of file diff --git a/src/IdentityApplication/Persistence/IAuthTokensRepository.cs b/src/IdentityApplication/Persistence/IAuthTokensRepository.cs new file mode 100644 index 00000000..17845dd9 --- /dev/null +++ b/src/IdentityApplication/Persistence/IAuthTokensRepository.cs @@ -0,0 +1,17 @@ +using Application.Persistence.Interfaces; +using Common; +using Domain.Common.ValueObjects; +using IdentityDomain; + +namespace IdentityApplication.Persistence; + +public interface IAuthTokensRepository : IApplicationRepository +{ + Task, Error>> FindByRefreshTokenAsync(string refreshToken, + CancellationToken cancellationToken); + + Task, Error>> FindByUserIdAsync(Identifier userId, + CancellationToken cancellationToken); + + Task> SaveAsync(AuthTokensRoot tokens, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/IdentityApplication/Persistence/IPasswordCredentialsRepository.cs b/src/IdentityApplication/Persistence/IPasswordCredentialsRepository.cs new file mode 100644 index 00000000..ee88a6e7 --- /dev/null +++ b/src/IdentityApplication/Persistence/IPasswordCredentialsRepository.cs @@ -0,0 +1,18 @@ +using Application.Persistence.Interfaces; +using Common; +using Domain.Common.ValueObjects; +using IdentityDomain; + +namespace IdentityApplication.Persistence; + +public interface IPasswordCredentialsRepository : IApplicationRepository +{ + Task, Error>> FindCredentialsByUserEmailAsync(string username, + CancellationToken cancellationToken); + + Task, Error>> FindCredentialsByUserIdAsync(Identifier userId, + CancellationToken cancellationToken); + + Task> SaveAsync(PasswordCredentialRoot credential, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/IdentityApplication/Persistence/ReadModels/AuthToken.cs b/src/IdentityApplication/Persistence/ReadModels/AuthToken.cs new file mode 100644 index 00000000..b7313879 --- /dev/null +++ b/src/IdentityApplication/Persistence/ReadModels/AuthToken.cs @@ -0,0 +1,17 @@ +using Application.Persistence.Common; +using Common; +using QueryAny; + +namespace IdentityApplication.Persistence.ReadModels; + +[EntityName("AuthToken")] +public class AuthToken : ReadModelEntity +{ + public Optional AccessToken { get; set; } + + public Optional ExpiresOn { get; set; } + + public Optional RefreshToken { get; set; } + + public Optional UserId { get; set; } +} \ No newline at end of file diff --git a/src/IdentityApplication/Persistence/ReadModels/PasswordCredential.cs b/src/IdentityApplication/Persistence/ReadModels/PasswordCredential.cs new file mode 100644 index 00000000..753bed5a --- /dev/null +++ b/src/IdentityApplication/Persistence/ReadModels/PasswordCredential.cs @@ -0,0 +1,23 @@ +using Application.Persistence.Common; +using Common; +using QueryAny; + +namespace IdentityApplication.Persistence.ReadModels; + +[EntityName("PasswordCredential")] +public class PasswordCredential : ReadModelEntity +{ + public Optional AccountLocked { get; set; } + + public Optional PasswordResetToken { get; set; } + + public Optional RegistrationVerificationToken { get; set; } + + public Optional RegistrationVerified { get; set; } + + public Optional UserEmailAddress { get; set; } + + public Optional UserId { get; set; } + + public Optional UserName { get; set; } +} \ No newline at end of file diff --git a/src/IdentityApplication/Resources.Designer.cs b/src/IdentityApplication/Resources.Designer.cs new file mode 100644 index 00000000..1a673758 --- /dev/null +++ b/src/IdentityApplication/Resources.Designer.cs @@ -0,0 +1,107 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace IdentityApplication { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("IdentityApplication.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to The user account is locked out. + /// + internal static string PasswordCredentialsApplication_AccountLocked { + get { + return ResourceManager.GetString("PasswordCredentialsApplication_AccountLocked", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The user account is suspended. + /// + internal static string PasswordCredentialsApplication_AccountSuspended { + get { + return ResourceManager.GetString("PasswordCredentialsApplication_AccountSuspended", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The user account ID is not a valid identifier. + /// + internal static string PasswordCredentialsApplication_InvalidUserAccountId { + get { + return ResourceManager.GetString("PasswordCredentialsApplication_InvalidUserAccountId", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The username is not a valid email address. + /// + internal static string PasswordCredentialsApplication_InvalidUsername { + get { + return ResourceManager.GetString("PasswordCredentialsApplication_InvalidUsername", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The user account has not been verified. + /// + internal static string PasswordCredentialsApplication_RegistrationNotVerified { + get { + return ResourceManager.GetString("PasswordCredentialsApplication_RegistrationNotVerified", resourceCulture); + } + } + } +} diff --git a/src/IdentityApplication/Resources.resx b/src/IdentityApplication/Resources.resx new file mode 100644 index 00000000..f3cb7ca9 --- /dev/null +++ b/src/IdentityApplication/Resources.resx @@ -0,0 +1,42 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + The user account is locked out + + + The user account is suspended + + + The user account has not been verified + + + The username is not a valid email address + + + The user account ID is not a valid identifier + + \ No newline at end of file diff --git a/src/IdentityDomain.UnitTests/AuthTokensRootSpec.cs b/src/IdentityDomain.UnitTests/AuthTokensRootSpec.cs new file mode 100644 index 00000000..c8390d28 --- /dev/null +++ b/src/IdentityDomain.UnitTests/AuthTokensRootSpec.cs @@ -0,0 +1,115 @@ +using Common; +using Common.Extensions; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; +using FluentAssertions; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace IdentityDomain.UnitTests; + +[Trait("Category", "Unit")] +public class AuthTokensRootSpec +{ + private readonly AuthTokensRoot _authTokens; + + public AuthTokensRootSpec() + { + var recorder = new Mock(); + var idFactory = new Mock(); + idFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns("anid".ToId()); + + _authTokens = AuthTokensRoot.Create(recorder.Object, idFactory.Object, "auserid".ToId()).Value; + } + + [Fact] + public void WhenConstructed_ThenInitialized() + { + _authTokens.AccessToken.Should().BeNone(); + _authTokens.RefreshToken.Should().BeNone(); + _authTokens.ExpiresOn.Should().BeNone(); + } + + [Fact] + public void WhenSetTokens_ThenSetsTokens() + { + var expiresOn = DateTime.UtcNow.AddMinutes(1); + + _authTokens.SetTokens("anaccesstoken", "arefreshtoken", expiresOn); + + _authTokens.AccessToken.Should().BeSome("anaccesstoken"); + _authTokens.RefreshToken.Should().BeSome("arefreshtoken"); + _authTokens.ExpiresOn.Should().BeSome(expiresOn); + Enumerable.Last(_authTokens.Events).Should().BeOfType(); + } + + [Fact] + public void WhenRenewTokensAndOldTokenNotMatch_ThenReturnsError() + { + var expiresOn1 = DateTime.UtcNow.AddMinutes(1); + var expiresOn2 = expiresOn1.AddMinutes(2); + _authTokens.SetTokens("anaccesstoken1", "arefreshtoken1", expiresOn1); + + var result = _authTokens.RenewTokens("anotherrefreshtoken", "anaccesstoken2", "arefreshtoken2", expiresOn2); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.AuthTokensRoot_RefreshTokenNotMatched); + } + + [Fact] + public void WhenRenewTokensAndRevoked_ThenReturnsError() + { + var expiresOn1 = DateTime.UtcNow.AddMinutes(1); + var expiresOn2 = expiresOn1.AddMinutes(2); + _authTokens.SetTokens("anaccesstoken1", "arefreshtoken1", expiresOn1); + _authTokens.Revoke(); + + var result = _authTokens.RenewTokens("arefreshtoken1", "anaccesstoken2", "arefreshtoken2", expiresOn2); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.AuthTokensRoot_TokensRevoked); + } + + [Fact] + public void WhenRenewTokensAndOldTokenIsExpired_ThenReturnsError() + { + var expiresOn1 = DateTime.UtcNow.SubtractSeconds(1); + var expiresOn2 = DateTime.UtcNow.AddMinutes(2); +#if TESTINGONLY + _authTokens.TestingOnly_SetTokens("anaccesstoken1", "arefreshtoken1", expiresOn1); +#endif + var result = _authTokens.RenewTokens("arefreshtoken1", "anaccesstoken2", "arefreshtoken2", expiresOn2); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.AuthTokensRoot_RefreshTokenExpired); + } + + [Fact] + public void WhenRenewTokens_ThenUpdatesTokens() + { + var expiresOn1 = DateTime.UtcNow.AddMinutes(1); + var expiresOn2 = expiresOn1.AddMinutes(2); + _authTokens.SetTokens("anaccesstoken1", "arefreshtoken1", expiresOn1); + + _authTokens.RenewTokens("arefreshtoken1", "anaccesstoken2", "arefreshtoken2", expiresOn2); + + _authTokens.AccessToken.Should().BeSome("anaccesstoken2"); + _authTokens.RefreshToken.Should().BeSome("arefreshtoken2"); + _authTokens.ExpiresOn.Should().BeSome(expiresOn2); + Enumerable.Last(_authTokens.Events).Should().BeOfType(); + } + + [Fact] + public void WhenRevoke_ThenDeletesTokens() + { + var expiresOn = DateTime.UtcNow.AddMinutes(1); + _authTokens.SetTokens("anaccesstoken", "arefreshtoken", expiresOn); + + _authTokens.Revoke(); + + _authTokens.AccessToken.Should().BeNone(); + _authTokens.RefreshToken.Should().BeNone(); + _authTokens.ExpiresOn.Should().BeNone(); + Enumerable.Last(_authTokens.Events).Should().BeOfType(); + } +} \ No newline at end of file diff --git a/src/IdentityDomain.UnitTests/IdentityDomain.UnitTests.csproj b/src/IdentityDomain.UnitTests/IdentityDomain.UnitTests.csproj new file mode 100644 index 00000000..066e02e4 --- /dev/null +++ b/src/IdentityDomain.UnitTests/IdentityDomain.UnitTests.csproj @@ -0,0 +1,18 @@ + + + + net7.0 + true + + + + + + + + + + + + + diff --git a/src/IdentityDomain.UnitTests/LoginMonitorSpec.cs b/src/IdentityDomain.UnitTests/LoginMonitorSpec.cs new file mode 100644 index 00000000..adf230fc --- /dev/null +++ b/src/IdentityDomain.UnitTests/LoginMonitorSpec.cs @@ -0,0 +1,290 @@ +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace IdentityDomain.UnitTests; + +[Trait("Category", "Unit")] +public class LoginMonitorSpec +{ + [Fact] + public void WhenConstructed_ThenPropertiesAssigned() + { + var history = LoginMonitor.Create(1, TimeSpan.FromHours(1)).Value; + + history.MaxFailedPasswordAttempts.Should().Be(1); + history.FailedPasswordAttempts.Should().Be(0); + history.CooldownPeriod.Should().Be(TimeSpan.FromHours(1)); + history.FailedPasswordAttempts.Should().Be(0); + history.LastAttemptUtc.Should().BeNone(); + history.IsLocked.Should().BeFalse(); + history.ToggledLocked.Should().BeFalse(); + history.IsReset.Should().BeTrue(); + } + + [Fact] + public void WhenAttemptedSuccessfulLoginOnce_TheReturnsHistory() + { + var datum = DateTime.UtcNow; + var history = LoginMonitor.Create(1, TimeSpan.FromHours(1)).Value; + history = history.AttemptedSuccessfulLogin(datum); + + history.IsLocked.Should().BeFalse(); + history.FailedPasswordAttempts.Should().Be(0); + ((object)history.LastAttemptUtc).Should().Be(datum); + history.ToggledLocked.Should().BeFalse(); + history.IsReset.Should().BeFalse(); + } + + [Fact] + public void WhenAttemptedFailedLoginOnceAndFailedAttemptsIsLessThanMax_TheReturnsHistory() + { + var datum = DateTime.UtcNow; + var history = LoginMonitor.Create(2, TimeSpan.FromHours(1)).Value; + history = history.AttemptedFailedLogin(datum); + + history.IsLocked.Should().BeFalse(); + history.FailedPasswordAttempts.Should().Be(1); + ((object)history.LastAttemptUtc).Should().Be(datum); + history.ToggledLocked.Should().BeFalse(); + history.IsReset.Should().BeFalse(); + } + + [Fact] + public void WhenAttemptedFailedLoginsTwiceAndFailedAttemptsIsLessThanMax_TheReturnsHistory() + { + var datum1 = DateTime.UtcNow; + var datum2 = datum1.AddSeconds(1); + var history = LoginMonitor.Create(3, TimeSpan.FromHours(1)).Value; + history = history.AttemptedFailedLogin(datum1); + history = history.AttemptedFailedLogin(datum2); + + history.IsLocked.Should().BeFalse(); + history.FailedPasswordAttempts.Should().Be(2); + ((object)history.LastAttemptUtc).Should().Be(datum2); + history.ToggledLocked.Should().BeFalse(); + history.IsReset.Should().BeFalse(); + } + + [Fact] + public void WhenAttemptedFailedLoginAndFailedAttemptsIsLessThanMaxAndThenLoginSuccessful_TheReturnsHistory() + { + var datum1 = DateTime.UtcNow; + var datum2 = datum1.AddSeconds(1); + var datum3 = datum2.AddSeconds(1); + var history = LoginMonitor.Create(3, TimeSpan.FromHours(1)).Value; + history = history.AttemptedFailedLogin(datum1); + history = history.AttemptedFailedLogin(datum2); + history = history.AttemptedSuccessfulLogin(datum3); + + history.IsLocked.Should().BeFalse(); + history.FailedPasswordAttempts.Should().Be(0); + ((object)history.LastAttemptUtc).Should().Be(datum3); + history.ToggledLocked.Should().BeFalse(); + history.IsReset.Should().BeFalse(); + } + + [Fact] + public void WhenAttemptedFailedLoginAndFailedAttemptsIsMax_TheReturnsHistory() + { + var datum1 = DateTime.UtcNow; + var datum2 = datum1.AddSeconds(1); + var history = LoginMonitor.Create(2, TimeSpan.FromHours(1)).Value; + history = history.AttemptedFailedLogin(datum1); + history = history.AttemptedFailedLogin(datum2); + + history.IsLocked.Should().BeTrue(); + history.FailedPasswordAttempts.Should().Be(2); + ((object)history.LastAttemptUtc).Should().Be(datum2); + history.ToggledLocked.Should().BeTrue(); + history.IsReset.Should().BeFalse(); + } + + [Fact] + public void WhenAttemptedFailedLoginAndFailedAttemptsIsOneMoreThanMax_TheReturnsHistory() + { + var datum1 = DateTime.UtcNow; + var datum2 = datum1.AddSeconds(1); + var datum3 = datum2.AddSeconds(1); + var history = LoginMonitor.Create(2, TimeSpan.FromHours(1)).Value; + history = history.AttemptedFailedLogin(datum1); + history = history.AttemptedFailedLogin(datum2); + history = history.AttemptedFailedLogin(datum3); + + history.IsLocked.Should().BeTrue(); + history.FailedPasswordAttempts.Should().Be(3); + ((object)history.LastAttemptUtc).Should().Be(datum3); + history.ToggledLocked.Should().BeFalse(); + history.IsReset.Should().BeFalse(); + } + + [Fact] + public void WhenAttemptedFailedLoginAndFailedAttemptsIsManyMoreThanMax_TheReturnsHistory() + { + var datum1 = DateTime.UtcNow; + var datum2 = datum1.AddSeconds(1); + var datum3 = datum2.AddSeconds(1); + var datum4 = datum3.AddSeconds(1); + var history = LoginMonitor.Create(2, TimeSpan.FromHours(1)).Value; + history = history.AttemptedFailedLogin(datum1); + history = history.AttemptedFailedLogin(datum2); + history = history.AttemptedFailedLogin(datum3); + history = history.AttemptedFailedLogin(datum4); + + history.IsLocked.Should().BeTrue(); + history.FailedPasswordAttempts.Should().Be(4); + ((object)history.LastAttemptUtc).Should().Be(datum4); + history.ToggledLocked.Should().BeFalse(); + history.IsReset.Should().BeFalse(); + } + + [Fact] + public void + WhenAttemptedSuccessfulLoginAndFailedAttemptsIsMoreThanMaxWithinCooldownPeriod_ThenStillReturnsTrue() + { + var datum1 = DateTime.UtcNow; + var datum2 = datum1.AddSeconds(1); + var datum3 = datum2.AddSeconds(1); + var datum4 = datum3.AddSeconds(1); + var datum5 = datum4.AddSeconds(1); + var history = LoginMonitor.Create(2, TimeSpan.FromHours(1)).Value; + history = history.AttemptedSuccessfulLogin(datum1); + history = history.AttemptedFailedLogin(datum2); + history = history.AttemptedFailedLogin(datum3); + history = history.AttemptedFailedLogin(datum4); + history = history.AttemptedSuccessfulLogin(datum5); + + history.IsLocked.Should().BeTrue(); + history.FailedPasswordAttempts.Should().Be(3); + ((object)history.LastAttemptUtc).Should().Be(datum5); + history.ToggledLocked.Should().BeFalse(); + history.IsReset.Should().BeFalse(); + } + + [Fact] + public void WhenAttemptedSuccessfulLoginAndFailedAttemptsIsMoreThanMaxAfterCooldownPeriod_ThenReturnsFalse() + { + var datum1 = DateTime.UtcNow; + var datum2 = datum1.AddSeconds(1); + var datum3 = datum2.AddSeconds(1); + var datum4 = datum3.AddSeconds(1); + var cooldownPeriod = TimeSpan.FromSeconds(1); + var history = LoginMonitor.Create(2, cooldownPeriod).Value; + history = history.AttemptedSuccessfulLogin(datum1); + history = history.AttemptedFailedLogin(datum2); + history = history.AttemptedFailedLogin(datum3); + history = history.AttemptedFailedLogin(datum4); + + history.IsLocked.Should().BeTrue(); + + var remainingSleep = datum4.Add(cooldownPeriod).AddSeconds(1).Subtract(DateTime.UtcNow); + Thread.Sleep(remainingSleep); + + history.IsLocked.Should().BeFalse(); + + var datum5 = datum4.AddSeconds(1); + history = history.AttemptedSuccessfulLogin(datum5); + + history.IsLocked.Should().BeFalse(); + history.FailedPasswordAttempts.Should().Be(0); + ((object)history.LastAttemptUtc).Should().Be(datum5); + history.ToggledLocked.Should().BeTrue(); + history.IsReset.Should().BeFalse(); + } + + [Fact] + public void WhenAttemptedFailedLoginAndFailedAttemptsIsMoreThanMaxAndAfterCooldownPeriod_ThenReturnsFalse() + { + var datum1 = DateTime.UtcNow; + var datum2 = datum1.AddSeconds(1); + var datum3 = datum2.AddSeconds(1); + var datum4 = datum3.AddSeconds(1); + var cooldownPeriod = TimeSpan.FromSeconds(1); + var history = LoginMonitor.Create(2, cooldownPeriod).Value; + history = history.AttemptedFailedLogin(datum2); + history = history.AttemptedFailedLogin(datum3); + history = history.AttemptedFailedLogin(datum4); + + history.IsLocked.Should().BeTrue(); + + var remainingSleep = datum4.Add(cooldownPeriod).AddSeconds(1).Subtract(DateTime.UtcNow); + Thread.Sleep(remainingSleep); + + history.IsLocked.Should().BeFalse(); + + var datum5 = datum4.AddSeconds(1); + history = history.AttemptedFailedLogin(datum5); + + history.IsLocked.Should().BeFalse(); + history.FailedPasswordAttempts.Should().Be(1); + ((object)history.LastAttemptUtc).Should().Be(datum5); + history.ToggledLocked.Should().BeFalse(); + history.IsReset.Should().BeFalse(); + } + + [Fact] + public void WhenUnlockAndUnlocked_ThenReturnsUnlocked() + { + var datum = DateTime.UtcNow; + var cooldownPeriod = TimeSpan.FromSeconds(1); + var history = LoginMonitor.Create(2, cooldownPeriod).Value; + + history = history.Unlock(datum); + + history.IsLocked.Should().BeFalse(); + history.FailedPasswordAttempts.Should().Be(0); + ((object)history.LastAttemptUtc).Should().Be(datum); + history.ToggledLocked.Should().BeFalse(); + history.IsReset.Should().BeFalse(); + } + + [Fact] + public void WhenUnlockAndLockedAndInCooldownPeriod_ThenReturnsUnlocked() + { + var datum1 = DateTime.UtcNow; + var datum2 = datum1.AddSeconds(1); + var datum3 = datum2.AddSeconds(1); + var datum4 = datum3.AddSeconds(1); + var cooldownPeriod = TimeSpan.FromSeconds(1); + var history = LoginMonitor.Create(2, cooldownPeriod).Value; + history = history.AttemptedFailedLogin(datum2); + history = history.AttemptedFailedLogin(datum3); + + history.IsLocked.Should().BeTrue(); + + history = history.Unlock(datum4); + + history.IsLocked.Should().BeFalse(); + history.FailedPasswordAttempts.Should().Be(0); + ((object)history.LastAttemptUtc).Should().Be(datum4); + history.ToggledLocked.Should().BeTrue(); + history.IsReset.Should().BeFalse(); + } + + [Fact] + public void WhenUnlockAndLockedAndAfterCooldownPeriod_ThenReturnsUnlocked() + { + var datum1 = DateTime.UtcNow; + var datum2 = datum1.AddSeconds(1); + var datum3 = datum2.AddSeconds(1); + var cooldownPeriod = TimeSpan.FromSeconds(1); + var history = LoginMonitor.Create(2, cooldownPeriod).Value; + history = history.AttemptedFailedLogin(datum2); + history = history.AttemptedFailedLogin(datum3); + + history.IsLocked.Should().BeTrue(); + + var remainingSleep = datum3.Add(cooldownPeriod).AddSeconds(1).Subtract(DateTime.UtcNow); + Thread.Sleep(remainingSleep); + + history.IsLocked.Should().BeFalse(); + + history = history.Unlock(datum3); + + history.IsLocked.Should().BeFalse(); + history.FailedPasswordAttempts.Should().Be(0); + ((object)history.LastAttemptUtc).Should().Be(datum3); + history.ToggledLocked.Should().BeTrue(); + history.IsReset.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs b/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs new file mode 100644 index 00000000..a716eeca --- /dev/null +++ b/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs @@ -0,0 +1,445 @@ +using Common; +using Common.Configuration; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; +using Domain.Services.Shared.DomainServices; +using Domain.Shared; +using FluentAssertions; +using IdentityDomain.DomainServices; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace IdentityDomain.UnitTests; + +[Trait("Category", "Unit")] +public class PasswordCredentialRootSpec +{ + private readonly PasswordCredentialRoot _credential; + private readonly Mock _emailAddressService; + private readonly Mock _passwordHasherService; + private readonly Mock _tokensService; + + public PasswordCredentialRootSpec() + { + var recorder = new Mock(); + var idFactory = new Mock(); + idFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns("anid".ToId()); + + _passwordHasherService = new Mock(); + _passwordHasherService.Setup(phs => phs.HashPassword(It.IsAny())) + .Returns("apasswordhash"); + _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) + .Returns(true); + _passwordHasherService.Setup(phs => phs.ValidatePasswordHash(It.IsAny())) + .Returns(true); + _tokensService = new Mock(); + _tokensService.Setup(ts => ts.CreateTokenForVerification()) + .Returns("averificationtoken"); + var settings = new Mock(); + settings.Setup(s => s.Platform.GetString(It.IsAny(), It.IsAny())) + .Returns((string?)null!); + settings.Setup(s => s.Platform.GetNumber(It.IsAny(), It.IsAny())) + .Returns(5); + _emailAddressService = new Mock(); + _emailAddressService.Setup(eas => eas.EnsureUniqueAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(true)); + + _credential = PasswordCredentialRoot.Create(recorder.Object, idFactory.Object, + settings.Object, _emailAddressService.Object, _tokensService.Object, _passwordHasherService.Object, + "auserid".ToId()).Value; + } + + [Fact] + public void WhenConstructed_ThenInitialized() + { + _credential.UserId.Should().Be("auserid".ToId()); + _credential.Registration.Should().BeNone(); + _credential.Login.IsReset.Should().BeTrue(); + _credential.Password.PasswordHash.Should().BeNone(); + } + + [Fact] + public void WhenInitiateRegistrationVerificationAndAlreadyVerified_ThenReturnsError() + { + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + + var result = _credential.InitiateRegistrationVerification(); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialsRoot_RegistrationVerified); + } + + [Fact] + public void WhenInitiateRegistrationVerificationAndNotVerified_ThenInitiates() + { + _credential.InitiateRegistrationVerification(); + + _credential.Verification.IsStillVerifying.Should().BeTrue(); + _credential.Events.Last().Should() + .BeOfType(); + } + + [Fact] + public void WhenSetCredentialAndInvalidPassword_ThenReturnsError() + { + _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) + .Returns(false); + + var result = _credential.SetCredential("notavalidpassword"); + + result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_InvalidPassword); + } + + [Fact] + public void WhenSetCredentials_ThenSetsCredentials() + { + _credential.SetCredential("apassword"); + + _credential.Password.PasswordHash.Should().Be("apasswordhash"); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenSetRegistrationDetails_ThenSetsRegistration() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("adisplayname").Value); + + _credential.Registration.Value.EmailAddress.Should().Be(EmailAddress.Create("auser@company.com").Value); + _credential.Registration.Value.Name.Should().Be(PersonDisplayName.Create("adisplayname").Value); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenVerifyPasswordWithInvalidPassword_ThenReturnsError() + { + _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) + .Returns(false); + + var result = _credential.VerifyPassword("1WrongPassword!"); + + result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_InvalidPassword); + _credential.Login.FailedPasswordAttempts.Should().Be(0); + _credential.Login.IsLocked.Should().BeFalse(); + _credential.Login.ToggledLocked.Should().BeFalse(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenVerifyPasswordAndWrongPasswordAndAudit_ThenAuditsFailedLogin() + { + _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) + .Returns(true); + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(false); + _credential.SetCredential("apassword"); + var result = _credential.VerifyPassword("1WrongPassword!"); + + result.Should().BeSuccess(); + result.Value.Should().BeFalse(); + _credential.Login.FailedPasswordAttempts.Should().Be(1); + _credential.Login.IsLocked.Should().BeFalse(); + _credential.Login.ToggledLocked.Should().BeFalse(); + _credential.Events[1].Should().BeOfType(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenVerifyPasswordAndAndAudit_ThenResetsLoginMonitor() + { + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(true); + _credential.SetCredential("apassword"); + var result = _credential.VerifyPassword("apassword"); + + result.Should().BeSuccess(); + result.Value.Should().BeTrue(); + _credential.Login.FailedPasswordAttempts.Should().Be(0); + _credential.Login.IsLocked.Should().BeFalse(); + _credential.Login.ToggledLocked.Should().BeFalse(); + _credential.Events[1].Should().BeOfType(); + _credential.Events.Last().Should().BeOfType(); + _passwordHasherService.Verify(ph => ph.ValidatePassword("apassword", false)); + } + + [Fact] + public void WhenVerifyPasswordAndFailsAndLocksAccount_ThenLocksLogin() + { + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(false); + _credential.SetCredential("apassword"); +#if TESTINGONLY + _credential.TestingOnly_LockAccount("awrongpassword"); +#endif + _credential.Login.FailedPasswordAttempts.Should() + .Be(Validations.Login.DefaultMaxFailedPasswordAttempts); + _credential.Login.IsLocked.Should().BeTrue(); + _credential.Login.ToggledLocked.Should().BeTrue(); + _credential.Events[1].Should().BeOfType(); + _credential.Events[2].Should().BeOfType(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenVerifyPasswordAndSucceedsAfterCooldown_ThenUnlocksCredentials() + { + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(false); + _credential.SetCredential("apassword"); +#if TESTINGONLY + _credential.TestingOnly_LockAccount("awrongpassword"); + _credential.TestingOnly_ResetLoginCooldownPeriod(); +#endif + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(true); + _credential.VerifyPassword("apassword"); + + _credential.Login.FailedPasswordAttempts.Should().Be(0); + _credential.Login.IsLocked.Should().BeFalse(); + _credential.Login.ToggledLocked.Should().BeTrue(); + _credential.Events[1].Should().BeOfType(); + _credential.Events[2].Should().BeOfType(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenVerifyRegistrationAndRegistered_ThenReturnsError() + { + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + + var result = _credential.VerifyRegistration(); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialsRoot_RegistrationNotVerifying); + } + + [Fact] + public void WhenVerifyRegistrationAndExpired_ThenReturnsError() + { + _credential.InitiateRegistrationVerification(); +#if TESTINGONLY + _credential.TestingOnly_ExpireRegistrationVerification(); +#endif + + var result = _credential.VerifyRegistration(); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialsRoot_RegistrationVerifyingExpired); + } + + [Fact] + public void WhenVerifyRegistration_ThenVerified() + { + _credential.InitiateRegistrationVerification(); + + _credential.VerifyRegistration(); + + _credential.Verification.IsVerified.Should().BeTrue(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenInitiatePasswordResetAndPasswordNotSet_ThenReturnsError() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + _tokensService.Setup(ts => ts.CreateTokenForPasswordReset()) + .Returns(token); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + + var result = _credential.InitiatePasswordReset(); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordKeep_NoPasswordHash); + } + + [Fact] + public void WhenInitiatePasswordResetAndNotVerified_ThenReturnsError() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + _tokensService.Setup(ts => ts.CreateTokenForPasswordReset()) + .Returns(token); +#if TESTINGONLY + _credential.TestingOnly_RenewVerification(token); +#endif + var result = _credential.InitiatePasswordReset(); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialsRoot_RegistrationUnverified); + } + + [Fact] + public void WhenInitiatePasswordReset_ThenInitiated() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + _tokensService.Setup(ts => ts.CreateTokenForPasswordReset()) + .Returns(token); + _credential.SetCredential("apassword"); + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + + _credential.InitiatePasswordReset(); + + _credential.Password.IsInitiating.Should().BeTrue(); + _credential.Events[1].Should().BeOfType(); + _credential.Events[2].Should().BeOfType(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenResetPasswordWithInvalidPassword_ThenReturnsError() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) + .Returns(false); + + var result = _credential.ResetPassword(token, "apassword"); + + result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_InvalidPassword); + + _passwordHasherService.Verify(ph => ph.VerifyPassword(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void WhenResetPasswordAndNoExistingPassword_ThenReturnsError() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) + .Returns(true); + + var result = _credential.ResetPassword(token, "apassword"); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialsRoot_NoPassword); + + _passwordHasherService.Verify(ph => ph.VerifyPassword(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void WhenResetPasswordAndSamePassword_ThenReturnsError() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) + .Returns(true); + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(false); + _credential.SetCredential("apassword"); + + var result = _credential.ResetPassword(token, "apassword"); + + result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_DuplicatePassword); + + _passwordHasherService.Verify(ph => ph.VerifyPassword("apassword", "apasswordhash")); + } + + [Fact] + public void WhenResetPasswordAndExpired_ThenReturnsError() + { + _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) + .Returns(true); + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(true); + _credential.SetCredential("apassword"); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); +#if TESTINGONLY + _credential.TestingOnly_ExpirePasswordResetVerification(); +#endif + var result = _credential.ResetPassword("atoken", "apassword"); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialsRoot_PasswordResetTokenExpired); + } + + [Fact] + public void WhenResetPasswordAndCredentialsLocked_ThenResetsPasswordAndUnlocks() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + _tokensService.Setup(ts => ts.CreateTokenForPasswordReset()) + .Returns(token); + _credential.SetCredential("apassword"); + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(false); +#if TESTINGONLY + _credential.TestingOnly_LockAccount("awrongpassword"); +#endif + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.InitiatePasswordReset(); + _passwordHasherService.Setup(es => es.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(true); + + _credential.ResetPassword(_credential.Password.Token, "anewpassword"); + + _passwordHasherService.Verify(ph => ph.ValidatePassword("apassword", true)); + _passwordHasherService.Verify(ph => ph.ValidatePassword("anewpassword", false)); + _credential.Login.IsLocked.Should().BeFalse(); + _credential.Login.ToggledLocked.Should().BeTrue(); + _credential.Events[12].Should().BeOfType(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenResetPassword_ThenResetsPassword() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + _tokensService.Setup(ts => ts.CreateTokenForPasswordReset()) + .Returns(token); + _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) + .Returns(true); + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(true); + _credential.SetCredential("apassword"); + _credential.SetCredential("apassword"); + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.InitiatePasswordReset(); + + _credential.ResetPassword(_credential.Password.Token, "anewpassword"); + _passwordHasherService.Verify(ph => ph.ValidatePassword("apassword", true)); + _passwordHasherService.Verify(ph => ph.ValidatePassword("anewpassword", false)); + } + + [Fact] + public void WhenEnsureInvariantsAndRegisteredButEmailNotUnique_ThenReturnsErrors() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("adisplayname").Value); + _emailAddressService.Setup(eas => eas.EnsureUniqueAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(false)); + + var result = _credential.EnsureInvariants(); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordCredentialsRoot_EmailNotUnique); + } + + [Fact] + public void WhenEnsureInvariantsAndInitiatingPasswordResetButUnRegistered_ThenReturnsErrors() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + _tokensService.Setup(ts => ts.CreateTokenForPasswordReset()) + .Returns(token); + _credential.SetCredential("apassword"); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.InitiatePasswordReset(); + +#if TESTINGONLY + _credential.TestingOnly_Unregister(); +#endif + + var result = _credential.EnsureInvariants(); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.PasswordCredentialsRoot_PasswordInitiatedWithoutRegistration); + } +} \ No newline at end of file diff --git a/src/IdentityDomain.UnitTests/PasswordKeepSpec.cs b/src/IdentityDomain.UnitTests/PasswordKeepSpec.cs new file mode 100644 index 00000000..4fb95255 --- /dev/null +++ b/src/IdentityDomain.UnitTests/PasswordKeepSpec.cs @@ -0,0 +1,298 @@ +using Common; +using FluentAssertions; +using IdentityDomain.DomainServices; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace IdentityDomain.UnitTests; + +[Trait("Category", "Unit")] +public class PasswordKeepSpec +{ + private readonly Mock _passwordHasherService; + + public PasswordKeepSpec() + { + _passwordHasherService = new Mock(); + _passwordHasherService.Setup(es => es.HashPassword(It.IsAny())) + .Returns("apasswordhash"); + _passwordHasherService.Setup(es => es.ValidatePasswordHash("apasswordhash")) + .Returns(true); + _passwordHasherService.Setup(es => es.ValidatePassword(It.IsAny(), It.IsAny())) + .Returns(true); + } + + [Fact] + public void WhenConstructed_ThenPropertiesAssigned() + { + var password = PasswordKeep.Create().Value; + + password.PasswordHash.Should().BeNone(); + password.Token.Should().BeNone(); + password.TokenExpiresUtc.Should().BeNone(); + } + + [Fact] + public void WhenConstructedWithEmptyPasswordHash_ThenReturnsError() + { + var result = PasswordKeep.Create(_passwordHasherService.Object, string.Empty); + + result.Should().BeError(ErrorCode.Validation); + } + + [Fact] + public void WhenConstructedWithInvalidPasswordHash_ThenReturnsError() + { + var result = PasswordKeep.Create(_passwordHasherService.Object, "aninvalidpasswordhash"); + + result.Should().BeError(ErrorCode.Validation, Resources.PasswordKeep_InvalidPasswordHash); + } + + [Fact] + public void WhenConstructedWithHash_ThenPropertiesAssigned() + { + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; + + password.PasswordHash.Should().Be("apasswordhash"); + password.Token.Should().BeNone(); + password.TokenExpiresUtc.Should().BeNone(); + } + + [Fact] + public void WhenInitiatePasswordResetAndNoPasswordSet_ThenReturnsError() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + var password = PasswordKeep.Create().Value; + + var result = password.InitiatePasswordReset(token); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordKeep_NoPasswordHash); + } + + [Fact] + public void WhenInitiatePasswordReset_ThenCreatesResetToken() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; + + password = password.InitiatePasswordReset(token).Value; + + password.PasswordHash.Should().Be("apasswordhash"); + password.Token.Should().Be(token); + password.TokenExpiresUtc.Should().BeNear(DateTime.UtcNow.Add(PasswordKeep.DefaultResetExpiry)); + } + + [Fact] + public void WhenInitiatePasswordResetTwice_ThenCreatesNewResetToken() + { + var token1 = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + var token2 = Convert.ToBase64String(Enumerable.Repeat((byte)0x02, 32).ToArray()); + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; + + password = password.InitiatePasswordReset(token1).Value; + password = password.InitiatePasswordReset(token2).Value; + + password.PasswordHash.Should().Be("apasswordhash"); + password.Token.Should().Be(token2); + password.TokenExpiresUtc.Should().BeNear(DateTime.UtcNow.Add(PasswordKeep.DefaultResetExpiry)); + } + + [Fact] + public void WhenVerifyAndEmptyPassword_ThenReturnsError() + { + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; + + var result = password.Verify(_passwordHasherService.Object, string.Empty); + + result.Should().BeError(ErrorCode.Validation); + } + + [Fact] + public void WhenVerifyAndInvalidPassword_ThenReturnsError() + { + _passwordHasherService.Setup(ph => ph.ValidatePassword(It.IsAny(), It.IsAny())) + .Returns(false); + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; + + var result = password.Verify(_passwordHasherService.Object, "apassword"); + + result.Should().BeError(ErrorCode.Validation, Resources.PasswordKeep_InvalidPassword); + _passwordHasherService.Verify(ph => ph.ValidatePassword("apassword", false)); + } + + [Fact] + public void WhenVerifyAndNoPasswordSet_ThenReturnsError() + { + var password = PasswordKeep.Create().Value; + + var result = password.Verify(_passwordHasherService.Object, "apassword"); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordKeep_NoPasswordHash); + } + + [Fact] + public void WhenVerifyAndNotMatchesHash_ThenReturnsFalse() + { + _passwordHasherService.Setup(es => es.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(false); + + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; + + var result = password.Verify(_passwordHasherService.Object, "anotherpassword"); + + result.Should().BeSuccess(); + result.Value.Should().BeFalse(); + _passwordHasherService.Verify(es => es.VerifyPassword("anotherpassword", "apasswordhash")); + } + + [Fact] + public void WhenVerifyAndMatchesHash_ThenReturnsTrue() + { + _passwordHasherService.Setup(es => es.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(true); + + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; + + var result = password.Verify(_passwordHasherService.Object, "apassword"); + + result.Should().BeSuccess(); + result.Value.Should().BeTrue(); + _passwordHasherService.Verify(es => es.VerifyPassword("apassword", "apasswordhash")); + } + + [Fact] + public void WhenConfirmResetWithEmptyToken_ThenReturnsError() + { + var password = PasswordKeep.Create().Value; + + var result = password.ConfirmReset(string.Empty); + + result.Should().BeError(ErrorCode.Validation); + } + + [Fact] + public void WhenConfirmResetWithInvalidToken_ThenReturnsError() + { + var password = PasswordKeep.Create().Value; + + var result = password.ConfirmReset("aninvalidtoken"); + + result.Should().BeError(ErrorCode.Validation, Resources.PasswordKeep_InvalidToken); + } + + [Fact] + public void WhenConfirmResetAndTokensNotMatch_ThenReturnsError() + { + var token1 = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; + password = password.InitiatePasswordReset(token1).Value; + var token2 = Convert.ToBase64String(Enumerable.Repeat((byte)0x02, 32).ToArray()); + + var result = password.ConfirmReset(token2); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordKeep_TokensNotMatch); + } + + [Fact] + public void WhenConfirmResetAndTokenExpired_ThenReturnsError() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; + password = password.InitiatePasswordReset(token).Value; +#if TESTINGONLY + password = password.TestingOnly_ExpireToken(); +#endif + + var result = password.ConfirmReset(token); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordKeep_TokenExpired); + } + + [Fact] + public void WhenResetPasswordAndEmptyPasswordHash_ThenReturnsError() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; + + var result = password.ResetPassword(_passwordHasherService.Object, token, string.Empty); + + result.Should().BeError(ErrorCode.Validation); + } + + [Fact] + public void WhenResetPasswordAndTokenInvalid_ThenReturnsError() + { + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; + + var result = password.ResetPassword(_passwordHasherService.Object, "aninvalidtoken", "apassword"); + + result.Should().BeError(ErrorCode.Validation, Resources.PasswordKeep_InvalidToken); + } + + [Fact] + public void WhenResetPasswordAndPasswordHashInvalid_ThenReturnsError() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; + _passwordHasherService.Setup(ph => ph.ValidatePasswordHash(It.IsAny())) + .Returns(false); + + var result = password.ResetPassword(_passwordHasherService.Object, token, "aninvalidpasswordhash"); + + result.Should().BeError(ErrorCode.Validation, Resources.PasswordKeep_InvalidPasswordHash); + } + + [Fact] + public void WhenResetPasswordAndTokenNotMatch_ThenReturnsError() + { + var token1 = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; + password = password.InitiatePasswordReset(token1).Value; + var token2 = Convert.ToBase64String(Enumerable.Repeat((byte)0x02, 32).ToArray()); + + var result = password.ResetPassword(_passwordHasherService.Object, token2, "apasswordhash"); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordKeep_TokensNotMatch); + } + + [Fact] + public void WhenResetPasswordAndTokenExpired_ThenReturnsError() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; + password = password.InitiatePasswordReset(token).Value; +#if TESTINGONLY + password = password.TestingOnly_ExpireToken(); +#endif + + var result = password.ResetPassword(_passwordHasherService.Object, token, "apasswordhash"); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordKeep_TokenExpired); + } + + [Fact] + public void WhenResetPasswordAndNoPasswordSet_ThenReturnsError() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + var password = PasswordKeep.Create().Value; + + var result = password.ResetPassword(_passwordHasherService.Object, token, "apasswordhash"); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordKeep_NoPasswordHash); + } + + [Fact] + public void WhenResetPassword_ThenReturnsNewPassword() + { + var token = Convert.ToBase64String(Enumerable.Repeat((byte)0x01, 32).ToArray()); + var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value + .InitiatePasswordReset(token).Value; + + password = password.ResetPassword(_passwordHasherService.Object, password.Token, "apasswordhash").Value; + + password.PasswordHash.Should().Be("apasswordhash"); + password.Token.Should().BeNone(); + password.TokenExpiresUtc.Should().BeNone(); + } +} \ No newline at end of file diff --git a/src/IdentityDomain.UnitTests/VerificationSpec.cs b/src/IdentityDomain.UnitTests/VerificationSpec.cs new file mode 100644 index 00000000..d70b66b6 --- /dev/null +++ b/src/IdentityDomain.UnitTests/VerificationSpec.cs @@ -0,0 +1,39 @@ +using FluentAssertions; +using UnitTesting.Common; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityDomain.UnitTests; + +[Trait("Category", "Unit")] +public class VerificationSpec +{ + [Fact] + public void WhenConstructed_ThenIsNotSet() + { + var invitation = Verification.Create().Value; + + invitation.IsStillVerifying.Should().BeFalse(); + } + + [Fact] + public void WhenIsStillValidAndNoToken_ThenReturnsFalse() + { + var invitation = Verification.Create().Value; + + invitation.IsStillVerifying.Should().BeFalse(); + invitation.Token.Should().BeNone(); + invitation.ExpiresUtc.Should().BeNone(); + } + + [Fact] + public void WhenIsStillValidAfterSet_ThenReturnsTrue() + { + var invitation = Verification.Create().Value; + invitation = invitation.Renew("atoken"); + + invitation.IsStillVerifying.Should().BeTrue(); + ((object)invitation.Token).Should().Be("atoken"); + invitation.ExpiresUtc.Value.Should().BeNear(DateTime.UtcNow.Add(Verification.DefaultTokenExpiry)); + } +} \ No newline at end of file diff --git a/src/IdentityDomain/AuthTokensRoot.cs b/src/IdentityDomain/AuthTokensRoot.cs new file mode 100644 index 00000000..e994a764 --- /dev/null +++ b/src/IdentityDomain/AuthTokensRoot.cs @@ -0,0 +1,149 @@ +using Common; +using Domain.Common.Entities; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.Entities; +using Domain.Interfaces.ValueObjects; + +namespace IdentityDomain; + +public sealed class AuthTokensRoot : AggregateRootBase +{ + public static Result Create(IRecorder recorder, IIdentifierFactory idFactory, + Identifier userId) + { + var root = new AuthTokensRoot(recorder, idFactory); + root.RaiseCreateEvent(IdentityDomain.Events.PasswordCredentials.Created.Create(root.Id, userId)); + return root; + } + + private AuthTokensRoot(IRecorder recorder, IIdentifierFactory idFactory) : base(recorder, idFactory) + { + } + + private AuthTokensRoot(IRecorder recorder, IIdentifierFactory idFactory, + ISingleValueObject identifier) : base( + recorder, idFactory, identifier) + { + } + + public Optional AccessToken { get; private set; } + + public Optional ExpiresOn { get; private set; } + + public bool IsExpired => !IsRevoked && DateTime.UtcNow > ExpiresOn.Value; + + public bool IsRevoked => !RefreshToken.HasValue && !AccessToken.HasValue && !ExpiresOn.HasValue; + + public Optional RefreshToken { get; private set; } + + public Identifier UserId { get; private set; } = Identifier.Empty(); + + public static AggregateRootFactory Rehydrate() + { + return (identifier, container, _) => new AuthTokensRoot(container.Resolve(), + container.Resolve(), identifier); + } + + public override Result EnsureInvariants() + { + var ensureInvariants = base.EnsureInvariants(); + if (!ensureInvariants.IsSuccessful) + { + return ensureInvariants.Error; + } + + return Result.Ok; + } + + protected override Result OnStateChanged(IDomainEvent @event, bool isReconstituting) + { + switch (@event) + { + case Events.AuthTokens.Created created: + { + UserId = created.UserId.ToId(); + return Result.Ok; + } + + case Events.AuthTokens.TokensChanged changed: + { + AccessToken = changed.AccessToken; + RefreshToken = changed.RefreshToken; + ExpiresOn = changed.ExpiresOn; + Recorder.TraceDebug(null, "AuthTokens {Id} were changed for {UserId}", Id, changed.UserId); + return Result.Ok; + } + + case Events.AuthTokens.TokensRefreshed changed: + { + AccessToken = changed.AccessToken; + RefreshToken = changed.RefreshToken; + ExpiresOn = changed.ExpiresOn; + Recorder.TraceDebug(null, "AuthTokens {Id} were refreshed for {UserId}", Id, changed.UserId); + return Result.Ok; + } + + case Events.AuthTokens.TokensRevoked changed: + { + AccessToken = Optional.None; + RefreshToken = Optional.None; + ExpiresOn = Optional.None; + Recorder.TraceDebug(null, "AuthTokens {Id} were deleted for {UserId}", Id, changed.UserId); + return Result.Ok; + } + + default: + return HandleUnKnownStateChangedEvent(@event); + } + } + + public Result RenewTokens(string refreshTokenToRenew, string accessToken, string refreshToken, + DateTime expiresOn) + { + if (IsRevoked) + { + return Error.RuleViolation(Resources.AuthTokensRoot_TokensRevoked); + } + + if (RefreshToken != refreshTokenToRenew) + { + return Error.RuleViolation(Resources.AuthTokensRoot_RefreshTokenNotMatched); + } + + if (IsExpired) + { + return Error.RuleViolation(Resources.AuthTokensRoot_RefreshTokenExpired); + } + + return RaiseChangeEvent( + IdentityDomain.Events.AuthTokens.TokensRefreshed.Create(Id, UserId, accessToken, refreshToken, expiresOn)); + } + + public Result Revoke() + { + return RaiseChangeEvent( + IdentityDomain.Events.AuthTokens.TokensRevoked.Create(Id, UserId)); + } + + public Result SetTokens(string accessToken, string refreshToken, DateTime expiresOn) + { + var threshold = DateTime.UtcNow.AddSeconds(5); + if (expiresOn < threshold) + { + return Error.RuleViolation(Resources.AuthTokensRoot_TokensExpired); + } + + return RaiseChangeEvent( + IdentityDomain.Events.AuthTokens.TokensChanged.Create(Id, UserId, accessToken, refreshToken, expiresOn)); + } + +#if TESTINGONLY + public Result TestingOnly_SetTokens(string accessToken, string refreshToken, DateTime expiresOn) + { + return RaiseChangeEvent( + IdentityDomain.Events.AuthTokens.TokensChanged.Create(Id, UserId, accessToken, refreshToken, expiresOn)); + } +#endif +} \ No newline at end of file diff --git a/src/IdentityDomain/DomainServices/IEmailAddressService.cs b/src/IdentityDomain/DomainServices/IEmailAddressService.cs new file mode 100644 index 00000000..337df3da --- /dev/null +++ b/src/IdentityDomain/DomainServices/IEmailAddressService.cs @@ -0,0 +1,9 @@ +using Domain.Common.ValueObjects; +using Domain.Shared; + +namespace IdentityDomain.DomainServices; + +public interface IEmailAddressService +{ + Task EnsureUniqueAsync(EmailAddress emailAddress, Identifier userId); +} \ No newline at end of file diff --git a/src/IdentityDomain/DomainServices/IPasswordHasherService.cs b/src/IdentityDomain/DomainServices/IPasswordHasherService.cs new file mode 100644 index 00000000..63af48a1 --- /dev/null +++ b/src/IdentityDomain/DomainServices/IPasswordHasherService.cs @@ -0,0 +1,12 @@ +namespace IdentityDomain.DomainServices; + +public interface IPasswordHasherService +{ + string HashPassword(string password); + + bool ValidatePassword(string password, bool isStrict); + + bool ValidatePasswordHash(string passwordHash); + + bool VerifyPassword(string password, string passwordHash); +} \ No newline at end of file diff --git a/src/IdentityDomain/Events.cs b/src/IdentityDomain/Events.cs new file mode 100644 index 00000000..e2dc5d1e --- /dev/null +++ b/src/IdentityDomain/Events.cs @@ -0,0 +1,301 @@ +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; +using Domain.Shared; + +namespace IdentityDomain; + +public static class Events +{ + public static class PasswordCredentials + { + public class Created : IDomainEvent + { + public static Created Create(Identifier id, Identifier userId) + { + return new Created + { + RootId = id, + UserId = userId, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string UserId { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + + public class CredentialsChanged : IDomainEvent + { + public static CredentialsChanged Create(Identifier id, string passwordHash) + { + return new CredentialsChanged + { + RootId = id, + PasswordHash = passwordHash, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string PasswordHash { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + + public class RegistrationChanged : IDomainEvent + { + public static RegistrationChanged Create(Identifier id, EmailAddress emailAddress, PersonDisplayName name) + { + return new RegistrationChanged + { + RootId = id, + EmailAddress = emailAddress, + Name = name, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string EmailAddress { get; set; } + + public required string Name { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + + public class PasswordVerified : IDomainEvent + { + public static PasswordVerified Create(Identifier id, bool isVerified, + bool auditAttempt) + { + return new PasswordVerified + { + RootId = id, + IsVerified = isVerified, + AuditAttempt = auditAttempt, + OccurredUtc = DateTime.UtcNow + }; + } + + public bool AuditAttempt { get; set; } + + public bool IsVerified { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + + public class AccountLocked : IDomainEvent + { + public static AccountLocked Create(Identifier id) + { + return new AccountLocked + { + RootId = id, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + + public class AccountUnlocked : IDomainEvent + { + public static AccountUnlocked Create(Identifier id) + { + return new AccountUnlocked + { + RootId = id, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + + public class RegistrationVerificationCreated : IDomainEvent + { + public static RegistrationVerificationCreated Create(Identifier id, string token) + { + return new RegistrationVerificationCreated + { + RootId = id, + Token = token, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string Token { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + + public class RegistrationVerificationVerified : IDomainEvent + { + public static RegistrationVerificationVerified Create(Identifier id) + { + return new RegistrationVerificationVerified + { + RootId = id, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + + public class PasswordResetInitiated : IDomainEvent + { + public static PasswordResetInitiated Create(Identifier id, string token) + { + return new PasswordResetInitiated + { + RootId = id, + Token = token, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string Token { get; set; } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + } + + public class PasswordResetCompleted : IDomainEvent + { + public static PasswordResetCompleted Create(Identifier id, string token, string passwordHash) + { + return new PasswordResetCompleted + { + RootId = id, + Token = token, + PasswordHash = passwordHash, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string PasswordHash { get; set; } + + public required string Token { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + } + + public static class AuthTokens + { + public class Created : IDomainEvent + { + public static Created Create(Identifier id, Identifier userId) + { + return new Created + { + RootId = id, + UserId = userId, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string UserId { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + + public class TokensChanged : IDomainEvent + { + public static TokensChanged Create(Identifier id, Identifier userId, string accessToken, + string refreshToken, DateTime expiresOn) + { + return new TokensChanged + { + RootId = id, + UserId = userId, + AccessToken = accessToken, + RefreshToken = refreshToken, + ExpiresOn = expiresOn, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string AccessToken { get; set; } + + public required DateTime ExpiresOn { get; set; } + + public required string RefreshToken { get; set; } + + public required string UserId { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + + public class TokensRefreshed : IDomainEvent + { + public static TokensRefreshed Create(Identifier id, Identifier userId, string accessToken, + string refreshToken, DateTime expiresOn) + { + return new TokensRefreshed + { + RootId = id, + UserId = userId, + AccessToken = accessToken, + RefreshToken = refreshToken, + ExpiresOn = expiresOn, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string AccessToken { get; set; } + + public required DateTime ExpiresOn { get; set; } + + public required string RefreshToken { get; set; } + + public required string UserId { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + + public class TokensRevoked : IDomainEvent + { + public static TokensRevoked Create(Identifier id, Identifier userId) + { + return new TokensRevoked + { + RootId = id, + UserId = userId, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string UserId { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + } +} \ No newline at end of file diff --git a/src/IdentityDomain/IdentityDomain.csproj b/src/IdentityDomain/IdentityDomain.csproj new file mode 100644 index 00000000..ec72d307 --- /dev/null +++ b/src/IdentityDomain/IdentityDomain.csproj @@ -0,0 +1,35 @@ + + + + net7.0 + + + + + <_Parameter1>$(AssemblyName).UnitTests + + + + + + + + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + True + True + Resources.resx + + + + diff --git a/src/IdentityDomain/LoginMonitor.cs b/src/IdentityDomain/LoginMonitor.cs new file mode 100644 index 00000000..b78437d1 --- /dev/null +++ b/src/IdentityDomain/LoginMonitor.cs @@ -0,0 +1,160 @@ +using Common; +using Common.Extensions; +using Domain.Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + +namespace IdentityDomain; + +public sealed class LoginMonitor : ValueObjectBase +{ + private bool _hasCooldownPeriodJustExpired; + + public static Result Create(int maxFailedPasswordAttempts, TimeSpan cooldownPeriod) + { + if (maxFailedPasswordAttempts.IsInvalidParameter(Validations.Login.MaxFailedPasswordAttempts, + nameof(maxFailedPasswordAttempts), Resources.LoginMonitor_InvalidMaxFailedLogins, out var error1)) + { + return error1; + } + + if (cooldownPeriod.IsInvalidParameter(Validations.Login.CooldownPeriod, nameof(cooldownPeriod), + Resources.LoginMonitor_InvalidCooldownPeriod, out var error2)) + { + return error2; + } + + return new LoginMonitor(maxFailedPasswordAttempts, cooldownPeriod, 0, Optional.None, false); + } + + private LoginMonitor(int maxFailedPasswordAttempts, TimeSpan cooldownPeriod, int failedPasswordAttempts, + Optional lastAttemptUtc, bool hasToggleLocked) + { + MaxFailedPasswordAttempts = maxFailedPasswordAttempts; + CooldownPeriod = cooldownPeriod; + FailedPasswordAttempts = failedPasswordAttempts; + LastAttemptUtc = lastAttemptUtc; + ToggledLocked = hasToggleLocked; + _hasCooldownPeriodJustExpired = false; + } + + public TimeSpan CooldownPeriod { get; } + + public int FailedPasswordAttempts { get; } + + public bool HasJustLocked => ToggledLocked && IsLocked; + + public bool HasJustUnlocked => ToggledLocked && !IsLocked; + + private bool HasJustUnlockedInternal => !IsLocked && _hasCooldownPeriodJustExpired; + + public bool IsLocked + { + get + { + var hasFailedTooManyTimes = FailedPasswordAttempts >= MaxFailedPasswordAttempts; + if (!hasFailedTooManyTimes) + { + return false; + } + + var isInCooldown = IsAttemptStillWithinCooldownPeriod(); + if (!isInCooldown) + { + _hasCooldownPeriodJustExpired = true; + } + + return isInCooldown; + } + } + + public bool IsReset => !LastAttemptUtc.HasValue && FailedPasswordAttempts == 0; + + public Optional LastAttemptUtc { get; } + + internal int MaxFailedPasswordAttempts { get; } + + internal bool ToggledLocked { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => + { + var parts = RehydrateToList(property, false); + return new LoginMonitor(parts[0].ToIntOrDefault(0), parts[1].ToTimeSpanOrDefault(TimeSpan.Zero), + parts[2].ToIntOrDefault(0), + parts[3].FromIso8601(), parts[4].ToBool()); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object[] + { + MaxFailedPasswordAttempts, CooldownPeriod, FailedPasswordAttempts, LastAttemptUtc, ToggledLocked + }; + } + + public LoginMonitor AttemptedFailedLogin(DateTime attemptedUtc) + { + var incrementedFailedAttempts = HasJustUnlockedInternal + ? 1 + : FailedPasswordAttempts + 1; + var hasReachedMaxFailed = incrementedFailedAttempts == MaxFailedPasswordAttempts; + var isAboutToBecomeLocked = hasReachedMaxFailed && !IsLocked && !ToggledLocked; + + return Attempt(incrementedFailedAttempts, attemptedUtc, isAboutToBecomeLocked); + } + + public LoginMonitor AttemptedSuccessfulLogin(DateTime attemptedUtc) + { + if (IsLocked) + { + return Attempt(attemptedUtc, false); + } + + return Attempt(0, attemptedUtc, HasJustUnlockedInternal); + } + +#if TESTINGONLY + + public LoginMonitor TestingOnly_ResetCooldownPeriod() + { + return Attempt(DateTime.MinValue, false); + } +#endif + + public LoginMonitor Unlock(DateTime attemptedUtc) + { + if (IsLocked || HasJustUnlockedInternal) + { + return Attempt(0, attemptedUtc, true); + } + + return Attempt(attemptedUtc, false); + } + + private bool IsAttemptStillWithinCooldownPeriod() + { + if (!LastAttemptUtc.HasValue) + { + return false; + } + + var endOfCooldownPeriod = LastAttemptUtc.Value + .Add(CooldownPeriod); + + return DateTime.UtcNow.IsBefore(endOfCooldownPeriod); + } + + private LoginMonitor Attempt(DateTime lastAttemptUtc, bool hasToggleLocked) + { + return Attempt(FailedPasswordAttempts, lastAttemptUtc, hasToggleLocked); + } + + private LoginMonitor Attempt(int failedPasswordAttempts, DateTime lastAttemptUtc, bool hasToggleLocked) + { + return new LoginMonitor(MaxFailedPasswordAttempts, CooldownPeriod, failedPasswordAttempts, + lastAttemptUtc, hasToggleLocked); + } +} \ No newline at end of file diff --git a/src/IdentityDomain/PasswordCredentialRoot.cs b/src/IdentityDomain/PasswordCredentialRoot.cs new file mode 100644 index 00000000..71921366 --- /dev/null +++ b/src/IdentityDomain/PasswordCredentialRoot.cs @@ -0,0 +1,440 @@ +using Common; +using Common.Configuration; +using Common.Extensions; +using Domain.Common.Entities; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.Entities; +using Domain.Interfaces.ValueObjects; +using Domain.Services.Shared.DomainServices; +using Domain.Shared; +using IdentityDomain.DomainServices; + +namespace IdentityDomain; + +public sealed class PasswordCredentialRoot : AggregateRootBase +{ + public const string CooldownPeriodInMinutesSettingName = "IdentityApi:PasswordCredential:CooldownPeriodInMinutes"; + public const string MaxFailedLoginsSettingName = "IdentityApi:PasswordCredential:MaxFailedLogins"; + private readonly IEmailAddressService _emailAddressService; + private readonly IPasswordHasherService _passwordHasherService; + private readonly ITokensService _tokensService; + + public static Result Create(IRecorder recorder, IIdentifierFactory idFactory, + IConfigurationSettings settings, IEmailAddressService emailAddressService, ITokensService tokensService, + IPasswordHasherService passwordHasherService, Identifier userId) + { + var root = new PasswordCredentialRoot(recorder, idFactory, settings, emailAddressService, tokensService, + passwordHasherService); + root.RaiseCreateEvent(IdentityDomain.Events.PasswordCredentials.Created.Create(root.Id, userId)); + return root; + } + + private PasswordCredentialRoot(IRecorder recorder, IIdentifierFactory idFactory, IConfigurationSettings settings, + IEmailAddressService emailAddressService, ITokensService tokensService, + IPasswordHasherService passwordHasherService) : + base(recorder, idFactory) + { + _emailAddressService = emailAddressService; + _tokensService = tokensService; + _passwordHasherService = passwordHasherService; + Login = CreateLoginMonitor(settings); + } + + private PasswordCredentialRoot(IRecorder recorder, IIdentifierFactory idFactory, IConfigurationSettings settings, + IEmailAddressService emailAddressService, ITokensService tokensService, + IPasswordHasherService passwordHasherService, ISingleValueObject identifier) : base( + recorder, idFactory, identifier) + { + _emailAddressService = emailAddressService; + _tokensService = tokensService; + _passwordHasherService = passwordHasherService; + Login = CreateLoginMonitor(settings); + } + + public bool IsLocked => Login.IsLocked; + + public bool IsPasswordInitiated => Password.IsInitiated; + + public bool IsPasswordResetStillValid => Password.IsInitiatingStillValid; + + public bool IsRegistrationVerified => Verification.IsVerified; + + public bool IsVerificationStillVerifying => Verification.IsStillVerifying; + + public bool IsVerificationVerifying => Verification.IsVerifying; + + public bool IsVerified => Verification.IsVerified; + + public LoginMonitor Login { get; private set; } + + public PasswordKeep Password { get; private set; } = PasswordKeep.Create().Value; + + public Optional Registration { get; private set; } + + public Identifier UserId { get; private set; } = Identifier.Empty(); + + public Verification Verification { get; private set; } = Verification.Create().Value; + + public static AggregateRootFactory Rehydrate() + { + return (identifier, container, _) => new PasswordCredentialRoot(container.Resolve(), + container.Resolve(), container.Resolve(), + container.Resolve(), container.Resolve(), + container.Resolve(), identifier); + } + + public override Result EnsureInvariants() + { + var ensureInvariants = base.EnsureInvariants(); + if (!ensureInvariants.IsSuccessful) + { + return ensureInvariants.Error; + } + + if (Registration.HasValue) + { + var isEmailUnique = _emailAddressService.EnsureUniqueAsync(Registration.Value.EmailAddress, UserId) + .GetAwaiter().GetResult(); + if (!isEmailUnique) + { + return Error.RuleViolation(Resources.PasswordCredentialsRoot_EmailNotUnique); + } + } + + if (!Registration.HasValue + && Password.IsInitiating) + { + return Error.RuleViolation(Resources.PasswordCredentialsRoot_PasswordInitiatedWithoutRegistration); + } + + return Result.Ok; + } + + protected override Result OnStateChanged(IDomainEvent @event, bool isReconstituting) + { + switch (@event) + { + case Events.PasswordCredentials.Created created: + { + UserId = created.UserId.ToId(); + Recorder.TraceDebug(null, "Password credential {Id} was created for {UserId}", Id, created.UserId); + return Result.Ok; + } + + case Events.PasswordCredentials.CredentialsChanged changed: + { + var set = Password.SetPassword(_passwordHasherService, changed.PasswordHash); + if (!set.IsSuccessful) + { + return set.Error; + } + + Password = set.Value; + Recorder.TraceDebug(null, "Password credential {Id} changed the credential", Id); + return Result.Ok; + } + + case Events.PasswordCredentials.RegistrationChanged changed: + { + var registration = IdentityDomain.Registration.Create(changed.EmailAddress, changed.Name); + if (!registration.IsSuccessful) + { + return registration.Error; + } + + Registration = registration.Value; + Recorder.TraceDebug(null, "Password credential {Id} changed the registration details", Id); + return Result.Ok; + } + + case Events.PasswordCredentials.PasswordVerified changed: + { + if (changed.AuditAttempt) + { + Login = changed.IsVerified + ? Login.AttemptedSuccessfulLogin(changed.OccurredUtc) + : Login.AttemptedFailedLogin(changed.OccurredUtc); + } + + Recorder.TraceDebug(null, "Password credential {Id} verified and audited", Id); + return Result.Ok; + } + + case Events.PasswordCredentials.AccountLocked _: + { + Recorder.TraceDebug(null, "Password credential {Id} was locked", Id); + return Result.Ok; + } + + case Events.PasswordCredentials.AccountUnlocked _: + { + Recorder.TraceDebug(null, "Password credential {Id} was unlocked", Id); + return Result.Ok; + } + + case Events.PasswordCredentials.RegistrationVerificationCreated created: + { + Verification = Verification.Renew(created.Token); + Recorder.TraceDebug(null, "Password credential {Id} verification has been renewed", Id); + return Result.Ok; + } + + case Events.PasswordCredentials.RegistrationVerificationVerified _: + { + Verification = Verification.Verify(); + Recorder.TraceDebug(null, "Password credential {Id} has been verified", Id); + return Result.Ok; + } + + case Events.PasswordCredentials.PasswordResetInitiated changed: + { + var reset = Password.InitiatePasswordReset(changed.Token); + if (!reset.IsSuccessful) + { + return reset.Error; + } + + Password = reset.Value; + Recorder.TraceDebug(null, "Password credential {Id} has initiated a password reset", Id); + return Result.Ok; + } + + case Events.PasswordCredentials.PasswordResetCompleted changed: + { + var reset = Password.ResetPassword(_passwordHasherService, changed.Token, changed.PasswordHash); + if (!reset.IsSuccessful) + { + return reset.Error; + } + + Password = reset.Value; + Login = Login.Unlock(changed.OccurredUtc); + Recorder.TraceDebug(null, "Credentials {Id} password reset has been completed", Id); + return Result.Ok; + } + + default: + return HandleUnKnownStateChangedEvent(@event); + } + } + + public Result ConfirmPasswordReset(string token) + { + if (token.IsNotValuedParameter(nameof(token), out var error)) + { + return error; + } + + var confirmed = Password.ConfirmReset(token); + if (!confirmed.IsSuccessful) + { + return confirmed.Error; + } + + Password = confirmed.Value; + + return Result.Ok; + } + + public Result InitiatePasswordReset() + { + if (!IsRegistrationVerified) + { + return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_RegistrationUnverified); + } + + var token = _tokensService.CreateTokenForPasswordReset(); + return RaiseChangeEvent(IdentityDomain.Events.PasswordCredentials.PasswordResetInitiated.Create(Id, token)); + } + + public Result InitiateRegistrationVerification() + { + if (IsVerified) + { + return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_RegistrationVerified); + } + + var token = _tokensService.CreateTokenForVerification(); + return RaiseChangeEvent( + IdentityDomain.Events.PasswordCredentials.RegistrationVerificationCreated.Create(Id, token)); + } + + public Result ResetPassword(string token, string password) + { + if (token.IsNotValuedParameter(nameof(token), out var error1)) + { + return error1; + } + + if (password.IsInvalidParameter(pwd => _passwordHasherService.ValidatePassword(pwd, false), + nameof(password), Resources.PasswordCredentialsRoot_InvalidPassword, out var error2)) + { + return error2; + } + + if (!IsPasswordInitiated) + { + return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_NoPassword); + } + + if (password.IsInvalidParameter(pwd => _passwordHasherService.VerifyPassword(pwd, Password.PasswordHash), + nameof(password), Resources.PasswordCredentialsRoot_DuplicatePassword, out var error3)) + { + return error3; + } + + if (!IsRegistrationVerified) + { + return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_RegistrationUnverified); + } + + if (!IsPasswordResetStillValid) + { + return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_PasswordResetTokenExpired); + } + + var passwordHash = _passwordHasherService.HashPassword(password); + RaiseChangeEvent( + IdentityDomain.Events.PasswordCredentials.PasswordResetCompleted.Create(Id, token, passwordHash)); + + if (Login.HasJustUnlocked) + { + RaiseChangeEvent(IdentityDomain.Events.PasswordCredentials.AccountUnlocked.Create(Id)); + } + + return Result.Ok; + } + + public Result SetCredential(string password) + { + if (password.IsInvalidParameter(pwd => _passwordHasherService.ValidatePassword(pwd, true), + nameof(password), Resources.PasswordCredentialsRoot_InvalidPassword, out var error1)) + { + return error1; + } + + return RaiseChangeEvent( + IdentityDomain.Events.PasswordCredentials.CredentialsChanged.Create(Id, + _passwordHasherService.HashPassword(password))); + } + + public Result SetRegistrationDetails(EmailAddress emailAddress, PersonDisplayName displayName) + { + return RaiseChangeEvent( + IdentityDomain.Events.PasswordCredentials.RegistrationChanged.Create(Id, emailAddress, displayName)); + } + +#if TESTINGONLY + public void TestingOnly_ExpirePasswordResetVerification() + { + Password = Password.TestingOnly_ExpireToken(); + } +#endif + +#if TESTINGONLY + public void TestingOnly_ExpireRegistrationVerification() + { + Verification = Verification.TestingOnly_ExpireToken(); + } +#endif + +#if TESTINGONLY + public void TestingOnly_LockAccount(string wrongPassword) + { + Repeat.Times(() => { VerifyPassword(wrongPassword); }, + Validations.Login.DefaultMaxFailedPasswordAttempts); + } +#endif + +#if TESTINGONLY + public void TestingOnly_RenewVerification(string token) + { + Verification = Verification.Renew(token); + } +#endif + +#if TESTINGONLY + public void TestingOnly_ResetLoginCooldownPeriod() + { + Login = Login.TestingOnly_ResetCooldownPeriod(); + } +#endif + +#if TESTINGONLY + public void TestingOnly_Unregister() + { + Registration = Optional.None; + } +#endif + + public Result VerifyPassword(string password, bool auditAttempt = true) + { + if (password.IsInvalidParameter(pwd => _passwordHasherService.ValidatePassword(pwd, false), + nameof(password), Resources.PasswordCredentialsRoot_InvalidPassword, out var error1)) + { + return error1; + } + + var verify = Password.Verify(_passwordHasherService, password); + if (!verify.IsSuccessful) + { + return verify.Error; + } + + var isVerified = verify.Value; + var raised = + RaiseChangeEvent( + IdentityDomain.Events.PasswordCredentials.PasswordVerified.Create(Id, isVerified, auditAttempt)); + if (!raised.IsSuccessful) + { + return raised.Error; + } + + if (Login.HasJustLocked) + { + var locked = RaiseChangeEvent(IdentityDomain.Events.PasswordCredentials.AccountLocked.Create(Id)); + if (!locked.IsSuccessful) + { + return locked.Error; + } + } + + if (Login.HasJustUnlocked) + { + var unlocked = RaiseChangeEvent(IdentityDomain.Events.PasswordCredentials.AccountUnlocked.Create(Id)); + if (!unlocked.IsSuccessful) + { + return unlocked.Error; + } + } + + return isVerified; + } + + public Result VerifyRegistration() + { + if (!IsVerificationStillVerifying) + { + if (!IsVerificationVerifying) + { + return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_RegistrationNotVerifying); + } + + return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_RegistrationVerifyingExpired); + } + + return RaiseChangeEvent(IdentityDomain.Events.PasswordCredentials.RegistrationVerificationVerified.Create(Id)); + } + + private static LoginMonitor CreateLoginMonitor(IConfigurationSettings settings) + { + return LoginMonitor.Create( + (int)settings.Platform.GetNumber(MaxFailedLoginsSettingName, + Validations.Login.DefaultMaxFailedPasswordAttempts), + TimeSpan.FromMinutes(settings.Platform.GetNumber(CooldownPeriodInMinutesSettingName, + Validations.Login.DefaultCooldownPeriodMinutes) + )).Value; + } +} \ No newline at end of file diff --git a/src/IdentityDomain/PasswordKeep.cs b/src/IdentityDomain/PasswordKeep.cs new file mode 100644 index 00000000..52669119 --- /dev/null +++ b/src/IdentityDomain/PasswordKeep.cs @@ -0,0 +1,204 @@ +using Common; +using Common.Extensions; +using Domain.Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.ValueObjects; +using IdentityDomain.DomainServices; + +namespace IdentityDomain; + +public sealed class PasswordKeep : ValueObjectBase +{ + public static readonly TimeSpan DefaultResetExpiry = TimeSpan.FromHours(2); + + public static Result Create() + { + return new PasswordKeep(Optional.None, Optional.None, Optional.None); + } + + public static Result Create(IPasswordHasherService passwordHasherService, string passwordHash) + { + if (passwordHash.IsInvalidParameter(passwordHasherService.ValidatePasswordHash, nameof(passwordHash), + Resources.PasswordKeep_InvalidPasswordHash, out var error1)) + { + return error1; + } + + return new PasswordKeep(passwordHash, Optional.None, Optional.None); + } + + private PasswordKeep(Optional passwordHash, Optional token, + Optional tokenExpiresUtc) + { + PasswordHash = passwordHash; + Token = token; + TokenExpiresUtc = tokenExpiresUtc; + } + + public bool IsInitiated => PasswordHash.HasValue; + + public bool IsInitiating => Token.HasValue && TokenExpiresUtc.HasValue; + + public bool IsInitiatingStillValid => IsInitiating && TokenExpiresUtc > DateTime.UtcNow; + + public bool IsReset => !Token.HasValue && !TokenExpiresUtc.HasValue; + + public Optional PasswordHash { get; } + + public Optional Token { get; } + + public Optional TokenExpiresUtc { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => + { + var parts = RehydrateToList(property, false); + return new PasswordKeep(parts[0].ToOptional(), parts[1].ToOptional(), + parts[2].FromIso8601().ToOptional()); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object[] { PasswordHash, Token, TokenExpiresUtc }; + } + + public Result ConfirmReset(string token) + { + if (token.IsNotValuedParameter(nameof(token), out var error1)) + { + return error1; + } + + if (token.IsInvalidParameter(Validations.Password.ResetToken, nameof(token), + Resources.PasswordKeep_InvalidToken, out var error2)) + { + return error2; + } + + if (token.NotEqualsOrdinal(Token)) + { + return Error.RuleViolation(Resources.PasswordKeep_TokensNotMatch); + } + + if (!IsInitiatingStillValid) + { + return Error.PreconditionViolation(Resources.PasswordKeep_TokenExpired); + } + + return this; + } + + public Result InitiatePasswordReset(string token) + { + if (token.IsNotValuedParameter(nameof(token), out var error1)) + { + return error1; + } + + if (token.IsInvalidParameter(Validations.Password.ResetToken, nameof(token), + Resources.PasswordKeep_InvalidToken, out var error2)) + { + return error2; + } + + if (!IsInitiated) + { + return Error.RuleViolation(Resources.PasswordKeep_NoPasswordHash); + } + + var expiry = DateTime.UtcNow.Add(DefaultResetExpiry); + return new PasswordKeep(PasswordHash, token, expiry); + } + + public Result ResetPassword(IPasswordHasherService passwordHasherService, string token, + string passwordHash) + { + if (token.IsNotValuedParameter(nameof(token), out var error1)) + { + return error1; + } + + if (token.IsInvalidParameter(Validations.Password.ResetToken, nameof(token), + Resources.PasswordKeep_InvalidToken, out var error2)) + { + return error2; + } + + if (passwordHash.IsNotValuedParameter(nameof(passwordHash), out var error3)) + { + return error3; + } + + if (passwordHash.IsInvalidParameter(passwordHasherService.ValidatePasswordHash, + nameof(passwordHash), Resources.PasswordKeep_InvalidPasswordHash, out var error4)) + { + return error4; + } + + if (!IsInitiated) + { + return Error.RuleViolation(Resources.PasswordKeep_NoPasswordHash); + } + + if (token.NotEqualsOrdinal(Token)) + { + return Error.RuleViolation(Resources.PasswordKeep_TokensNotMatch); + } + + if (!IsInitiatingStillValid) + { + return Error.PreconditionViolation(Resources.PasswordKeep_TokenExpired); + } + + return new PasswordKeep(passwordHash, Optional.None, Optional.None); + } + + public Result SetPassword(IPasswordHasherService passwordHasherService, string passwordHash) + { + if (passwordHash.IsNotValuedParameter(nameof(passwordHash), out var error1)) + { + return error1; + } + + if (passwordHash.IsInvalidParameter(passwordHasherService.ValidatePasswordHash, + nameof(passwordHash), Resources.PasswordKeep_InvalidPasswordHash, out var error2)) + { + return error2; + } + + return new PasswordKeep(passwordHash, Optional.None, Optional.None); + } + +#if TESTINGONLY + + public PasswordKeep TestingOnly_ExpireToken() + { + return new PasswordKeep(PasswordHash, Token, DateTime.UtcNow.SubtractSeconds(1)); + } +#endif + + [SkipImmutabilityCheck] + public Result Verify(IPasswordHasherService passwordHasherService, string password) + { + if (password.IsNotValuedParameter(nameof(password), out var error1)) + { + return error1; + } + + if (password.IsInvalidParameter(pwd => passwordHasherService.ValidatePassword(pwd, false), + nameof(password), Resources.PasswordKeep_InvalidPassword, out var error2)) + { + return error2; + } + + if (!IsInitiated) + { + return Error.RuleViolation(Resources.PasswordKeep_NoPasswordHash); + } + + return passwordHasherService.VerifyPassword(password, PasswordHash); + } +} \ No newline at end of file diff --git a/src/IdentityDomain/Registration.cs b/src/IdentityDomain/Registration.cs new file mode 100644 index 00000000..80c7fdce --- /dev/null +++ b/src/IdentityDomain/Registration.cs @@ -0,0 +1,57 @@ +using Common; +using Domain.Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Shared; + +namespace IdentityDomain; + +public sealed class Registration : ValueObjectBase +{ + public static Result Create(string emailAddress, string name) + { + if (name.IsInvalidParameter(Validations.Credentials.Name, nameof(name), null, out var error1)) + { + return error1; + } + + var em = EmailAddress.Create(emailAddress); + if (!em.IsSuccessful) + { + return em.Error; + } + + var pdm = PersonDisplayName.Create(name); + if (!pdm.IsSuccessful) + { + return pdm.Error; + } + + return new Registration(em.Value, pdm.Value); + } + + private Registration(EmailAddress emailAddress, PersonDisplayName name) + { + EmailAddress = emailAddress; + Name = name; + } + + public EmailAddress EmailAddress { get; } + + public PersonDisplayName Name { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, container) => + { + var parts = RehydrateToList(property, false); + return new Registration(EmailAddress.Rehydrate()(parts[0]!, container), + PersonDisplayName.Rehydrate()(parts[1]!, container)); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object[] { EmailAddress, Name }; + } +} \ No newline at end of file diff --git a/src/IdentityDomain/Resources.Designer.cs b/src/IdentityDomain/Resources.Designer.cs new file mode 100644 index 00000000..dc790d54 --- /dev/null +++ b/src/IdentityDomain/Resources.Designer.cs @@ -0,0 +1,260 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace IdentityDomain { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("IdentityDomain.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to This refresh token has expired. + /// + internal static string AuthTokensRoot_RefreshTokenExpired { + get { + return ResourceManager.GetString("AuthTokensRoot_RefreshTokenExpired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This refresh token is unknown or for another user. + /// + internal static string AuthTokensRoot_RefreshTokenNotMatched { + get { + return ResourceManager.GetString("AuthTokensRoot_RefreshTokenNotMatched", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to These tokens have already expired. + /// + internal static string AuthTokensRoot_TokensExpired { + get { + return ResourceManager.GetString("AuthTokensRoot_TokensExpired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This refresh token has been revoked. + /// + internal static string AuthTokensRoot_TokensRevoked { + get { + return ResourceManager.GetString("AuthTokensRoot_TokensRevoked", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The cooldown period is either zero or too large. + /// + internal static string LoginMonitor_InvalidCooldownPeriod { + get { + return ResourceManager.GetString("LoginMonitor_InvalidCooldownPeriod", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The max failed logins is either too large or negative. + /// + internal static string LoginMonitor_InvalidMaxFailedLogins { + get { + return ResourceManager.GetString("LoginMonitor_InvalidMaxFailedLogins", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to New password cannot be the same as old password. + /// + internal static string PasswordCredentialsRoot_DuplicatePassword { + get { + return ResourceManager.GetString("PasswordCredentialsRoot_DuplicatePassword", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The email is already in use by another user. + /// + internal static string PasswordCredentialsRoot_EmailNotUnique { + get { + return ResourceManager.GetString("PasswordCredentialsRoot_EmailNotUnique", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The password is not valid. + /// + internal static string PasswordCredentialsRoot_InvalidPassword { + get { + return ResourceManager.GetString("PasswordCredentialsRoot_InvalidPassword", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No password has yet been set for this user. + /// + internal static string PasswordCredentialsRoot_NoPassword { + get { + return ResourceManager.GetString("PasswordCredentialsRoot_NoPassword", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot initiate password reset before the user is registered. + /// + internal static string PasswordCredentialsRoot_PasswordInitiatedWithoutRegistration { + get { + return ResourceManager.GetString("PasswordCredentialsRoot_PasswordInitiatedWithoutRegistration", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The password reset confirmation has expired. + /// + internal static string PasswordCredentialsRoot_PasswordResetTokenExpired { + get { + return ResourceManager.GetString("PasswordCredentialsRoot_PasswordResetTokenExpired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The user's registration confirmation cannot be confirmed. + /// + internal static string PasswordCredentialsRoot_RegistrationNotVerifying { + get { + return ResourceManager.GetString("PasswordCredentialsRoot_RegistrationNotVerifying", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The user's registration has not been verified yet. + /// + internal static string PasswordCredentialsRoot_RegistrationUnverified { + get { + return ResourceManager.GetString("PasswordCredentialsRoot_RegistrationUnverified", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The user's registration is already verified. + /// + internal static string PasswordCredentialsRoot_RegistrationVerified { + get { + return ResourceManager.GetString("PasswordCredentialsRoot_RegistrationVerified", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The user's registration confirmation window has expired. + /// + internal static string PasswordCredentialsRoot_RegistrationVerifyingExpired { + get { + return ResourceManager.GetString("PasswordCredentialsRoot_RegistrationVerifyingExpired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The password is not valid. + /// + internal static string PasswordKeep_InvalidPassword { + get { + return ResourceManager.GetString("PasswordKeep_InvalidPassword", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The password hash is not valid. + /// + internal static string PasswordKeep_InvalidPasswordHash { + get { + return ResourceManager.GetString("PasswordKeep_InvalidPasswordHash", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The password reset token is either missing or invalid. + /// + internal static string PasswordKeep_InvalidToken { + get { + return ResourceManager.GetString("PasswordKeep_InvalidToken", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No password has yet been set for this user. + /// + internal static string PasswordKeep_NoPasswordHash { + get { + return ResourceManager.GetString("PasswordKeep_NoPasswordHash", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The password reset token has expired. + /// + internal static string PasswordKeep_TokenExpired { + get { + return ResourceManager.GetString("PasswordKeep_TokenExpired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The password reset token is invalid. + /// + internal static string PasswordKeep_TokensNotMatch { + get { + return ResourceManager.GetString("PasswordKeep_TokensNotMatch", resourceCulture); + } + } + } +} diff --git a/src/IdentityDomain/Resources.resx b/src/IdentityDomain/Resources.resx new file mode 100644 index 00000000..b10aa210 --- /dev/null +++ b/src/IdentityDomain/Resources.resx @@ -0,0 +1,93 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + The max failed logins is either too large or negative + + + The cooldown period is either zero or too large + + + The password is not valid + + + No password has yet been set for this user + + + The password hash is not valid + + + The password reset token is invalid + + + The password reset token has expired + + + The password reset token is either missing or invalid + + + The email is already in use by another user + + + Cannot initiate password reset before the user is registered + + + The password is not valid + + + The user's registration is already verified + + + The user's registration confirmation cannot be confirmed + + + The user's registration confirmation window has expired + + + The user's registration has not been verified yet + + + New password cannot be the same as old password + + + The password reset confirmation has expired + + + No password has yet been set for this user + + + These tokens have already expired + + + This refresh token is unknown or for another user + + + This refresh token has been revoked + + + This refresh token has expired + + \ No newline at end of file diff --git a/src/IdentityDomain/Validations.cs b/src/IdentityDomain/Validations.cs new file mode 100644 index 00000000..70d99d4b --- /dev/null +++ b/src/IdentityDomain/Validations.cs @@ -0,0 +1,40 @@ +using Domain.Interfaces.Validations; + +namespace IdentityDomain; + +public static class Validations +{ + public static class Person + { + public static readonly Validation Name = CommonValidations.DescriptiveName(1, 50); + } + + public static class Machine + { + public static readonly Validation Name = CommonValidations.DescriptiveName(1, 200); + } + + public static class Login + { + public const int DefaultMaxFailedPasswordAttempts = 5; +#if TESTINGONLY + public const int DefaultCooldownPeriodMinutes = 5; +#else + public const int DefaultCooldownPeriodMinutes = 10; +#endif + public static readonly Validation MaxFailedPasswordAttempts = new(x => x is > 0 and < 100); + public static readonly Validation CooldownPeriod = new( + x => x > TimeSpan.Zero && x <= TimeSpan.FromDays(1) + ); + } + + public static class Password + { + public static readonly Validation ResetToken = new("^[a-zA-Z0-9+/]{41,44}[=]{0,3}$"); + } + + public static class Credentials + { + public static readonly Validation Name = Person.Name; + } +} \ No newline at end of file diff --git a/src/IdentityDomain/Verification.cs b/src/IdentityDomain/Verification.cs new file mode 100644 index 00000000..1eeb833f --- /dev/null +++ b/src/IdentityDomain/Verification.cs @@ -0,0 +1,81 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + +namespace IdentityDomain; + +public sealed class Verification : ValueObjectBase +{ + public static readonly TimeSpan DefaultTokenExpiry = TimeSpan.FromDays(1); + + public static Result Create() + { + return new Verification(Optional.None, Optional.None, Optional.None); + } + + public static Result Create(Optional token, Optional expiresUtc, + Optional verifiedUtc) + { + return new Verification(token, expiresUtc, verifiedUtc); + } + + private Verification(Optional token, Optional expiresUtc, Optional verifiedUtc) + { + Token = token; + ExpiresUtc = expiresUtc; + VerifiedUtc = verifiedUtc; + } + + public Optional ExpiresUtc { get; } + + public bool IsStillVerifying => IsVerifying && ExpiresUtc > DateTime.UtcNow; + + public bool IsVerifiable => !Token.HasValue && !ExpiresUtc.HasValue && !VerifiedUtc.HasValue; + + public bool IsVerified => !Token.HasValue && !ExpiresUtc.HasValue && VerifiedUtc.HasValue; + + public bool IsVerifying => Token.HasValue && ExpiresUtc.HasValue; + + public Optional Token { get; } + + public Optional VerifiedUtc { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => + { + var parts = RehydrateToList(property, false); + return new Verification(parts[0].ToOptional(), parts[1].FromIso8601().ToOptional(), + parts[2].FromIso8601().ToOptional()); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object[] { Token, ExpiresUtc, VerifiedUtc }; + } + +#pragma warning disable CA1822 + public Verification Renew(string token) +#pragma warning restore CA1822 + { + ArgumentException.ThrowIfNullOrEmpty(token); + + return new Verification(token, DateTime.UtcNow.Add(DefaultTokenExpiry), Optional.None); + } + +#if TESTINGONLY + public Verification TestingOnly_ExpireToken() + { + return new Verification(Token, DateTime.UtcNow, Optional.None); + } +#endif + +#pragma warning disable CA1822 + public Verification Verify() +#pragma warning restore CA1822 + { + return new Verification(Optional.None, Optional.None, DateTime.UtcNow.SubtractSeconds(1)); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.IntegrationTests/IdentityInfrastructure.IntegrationTests.csproj b/src/IdentityInfrastructure.IntegrationTests/IdentityInfrastructure.IntegrationTests.csproj new file mode 100644 index 00000000..246ee85d --- /dev/null +++ b/src/IdentityInfrastructure.IntegrationTests/IdentityInfrastructure.IntegrationTests.csproj @@ -0,0 +1,23 @@ + + + + net7.0 + true + + + + + + + + + + + + + + Always + + + + diff --git a/src/IdentityInfrastructure.IntegrationTests/MachineCredentialsApiSpec.cs b/src/IdentityInfrastructure.IntegrationTests/MachineCredentialsApiSpec.cs new file mode 100644 index 00000000..e795a918 --- /dev/null +++ b/src/IdentityInfrastructure.IntegrationTests/MachineCredentialsApiSpec.cs @@ -0,0 +1,23 @@ +using ApiHost1; +using IntegrationTesting.WebApi.Common; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace IdentityInfrastructure.IntegrationTests; + +[Trait("Category", "Integration.Web")] +public class MachineCredentialsApiSpec : WebApiSpec +{ + public MachineCredentialsApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(setup); + } + + private static void OverrideDependencies(IServiceCollection services) + { + //TODO: remove this is method if you are not overriding any dependencies with any stubs + throw new NotImplementedException(); + } + + //TIP: type testm or testma to create a new test method +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs b/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs new file mode 100644 index 00000000..0a2ce2f3 --- /dev/null +++ b/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs @@ -0,0 +1,34 @@ +using System.Net; +using ApiHost1; +using FluentAssertions; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using IntegrationTesting.WebApi.Common; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace IdentityInfrastructure.IntegrationTests; + +[Trait("Category", "Integration.Web")] +public class PasswordCredentialsApiSpec : WebApiSpec +{ + public PasswordCredentialsApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(setup); + } + + [Fact] + public async Task WhenAuthenticateAndUserNotExists_ThenReturnsNotAuthenticated() + { + var result = await Api.PostAsync(new AuthenticatePasswordRequest + { + Username = "auser@company.com", + Password = "Password1!" + }); + + result.Content.Error.Status.Should().Be((int)HttpStatusCode.Unauthorized); + } + + private static void OverrideDependencies(IServiceCollection services) + { + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.IntegrationTests/appsettings.Testing.json b/src/IdentityInfrastructure.IntegrationTests/appsettings.Testing.json new file mode 100644 index 00000000..efc16afd --- /dev/null +++ b/src/IdentityInfrastructure.IntegrationTests/appsettings.Testing.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "ApplicationServices": { + "Persistence": { + "LocalMachineJsonFileStore": { + "RootPath": "./saastack/testing/unassigned" + } + } + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/MachineCredentials/RegisterMachineUserRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/MachineCredentials/RegisterMachineUserRequestValidatorSpec.cs new file mode 100644 index 00000000..e2374fbf --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/MachineCredentials/RegisterMachineUserRequestValidatorSpec.cs @@ -0,0 +1,82 @@ +using Common; +using FluentAssertions; +using FluentValidation; +using IdentityInfrastructure.Api.MachineCredentials; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.MachineCredentials; + +[Trait("Category", "Unit")] +public class RegisterMachineRequestValidatorSpec +{ + private readonly RegisterMachineRequest _dto; + private readonly RegisterMachineRequestValidator _validator; + + public RegisterMachineRequestValidatorSpec() + { + _validator = new RegisterMachineRequestValidator(); + _dto = new RegisterMachineRequest + { + Name = "amachinename", + Timezone = Timezones.Default.Id, + CountryCode = CountryCodes.Default.Alpha3 + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenNameIsInvalid_ThenThrows() + { + _dto.Name = "aninvalidname^"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.RegisterMachineRequestValidator_InvalidName); + } + + [Fact] + public void WhenTimezoneIsMissing_ThenSucceeds() + { + _dto.Timezone = null; + + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenTimezoneIsInvalid_ThenThrows() + { + _dto.Timezone = "aninvalidtimezone^"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.RegisterAnyRequestValidator_InvalidTimezone); + } + + [Fact] + public void WhenCountryCodeIsMissing_ThenSucceeds() + { + _dto.CountryCode = null; + + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenCountryCodeIsInvalid_ThenThrows() + { + _dto.CountryCode = "aninvalidcountrycode^"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.RegisterAnyRequestValidator_InvalidCountryCode); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/AuthenticatePasswordRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/AuthenticatePasswordRequestValidatorSpec.cs new file mode 100644 index 00000000..92b75a1f --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/AuthenticatePasswordRequestValidatorSpec.cs @@ -0,0 +1,64 @@ +using FluentAssertions; +using FluentValidation; +using IdentityInfrastructure.Api.PasswordCredentials; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.PasswordCredentials; + +[Trait("Category", "Unit")] +public class AuthenticatePasswordRequestValidatorSpec +{ + private readonly AuthenticatePasswordRequest _dto; + private readonly AuthenticatePasswordRequestValidator _validator; + + public AuthenticatePasswordRequestValidatorSpec() + { + _validator = new AuthenticatePasswordRequestValidator(); + _dto = new AuthenticatePasswordRequest + { + Username = "auser@company.com", + Password = "1Password!" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenUsernameIsEmpty_ThenThrows() + { + _dto.Username = string.Empty; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AuthenticatePasswordRequestValidator_InvalidUsername); + } + + [Fact] + public void WhenUsernameIsNotEmail_ThenThrows() + { + _dto.Username = "notanemail"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AuthenticatePasswordRequestValidator_InvalidUsername); + } + + [Fact] + public void WhenPasswordIsEmpty_ThenThrows() + { + _dto.Password = string.Empty; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AuthenticatePasswordRequestValidator_InvalidPassword); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/RegisterPersonRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/RegisterPersonRequestValidatorSpec.cs new file mode 100644 index 00000000..6f40e0b4 --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/RegisterPersonRequestValidatorSpec.cs @@ -0,0 +1,163 @@ +using Common; +using FluentAssertions; +using FluentValidation; +using IdentityInfrastructure.Api.PasswordCredentials; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.PasswordCredentials; + +[Trait("Category", "Unit")] +public class RegisterPersonRequestValidatorSpec +{ + private readonly RegisterPersonRequest _dto; + private readonly RegisterPersonRequestValidator _validator; + + public RegisterPersonRequestValidatorSpec() + { + _validator = new RegisterPersonRequestValidator(); + _dto = new RegisterPersonRequest + { + FirstName = "afirstname", + LastName = "alastname", + EmailAddress = "auser@company.com", + Password = "1Password!", + Timezone = Timezones.Default.Id, + CountryCode = CountryCodes.Default.Alpha3, + TermsAndConditionsAccepted = true + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenEmailIsEmpty_ThenThrows() + { + _dto.EmailAddress = string.Empty; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidEmail); + } + + [Fact] + public void WhenEmailIsNotEmail_ThenThrows() + { + _dto.EmailAddress = "notanemail"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidEmail); + } + + [Fact] + public void WhenPasswordIsEmpty_ThenThrows() + { + _dto.Password = string.Empty; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidPassword); + } + + [Fact] + public void WhenFirstNameIsEmpty_ThenThrows() + { + _dto.FirstName = string.Empty; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidFirstName); + } + + [Fact] + public void WhenFirstNameIsInvalid_ThenThrows() + { + _dto.FirstName = "aninvalidfirstname^"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidFirstName); + } + + [Fact] + public void WhenLastNameIsEmpty_ThenThrows() + { + _dto.LastName = string.Empty; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidLastName); + } + + [Fact] + public void WhenLastNameIsInvalid_ThenThrows() + { + _dto.LastName = "aninvalidlastname^"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidLastName); + } + + [Fact] + public void WhenTimezoneIsMissing_ThenSucceeds() + { + _dto.Timezone = null; + + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenTimezoneIsInvalid_ThenThrows() + { + _dto.Timezone = "aninvalidtimezone^"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.RegisterAnyRequestValidator_InvalidTimezone); + } + + [Fact] + public void WhenCountryCodeIsMissing_ThenSucceeds() + { + _dto.CountryCode = null; + + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenCountryCodeIsInvalid_ThenThrows() + { + _dto.CountryCode = "aninvalidcountrycode^"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.RegisterAnyRequestValidator_InvalidCountryCode); + } + + [Fact] + public void WhenTermsAndConditionsAcceptedIsFalse_ThenThrows() + { + _dto.TermsAndConditionsAccepted = false; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidTermsAndConditionsAccepted); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs b/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs new file mode 100644 index 00000000..8174ff32 --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs @@ -0,0 +1,69 @@ +using System.IdentityModel.Tokens.Jwt; +using Application.Resources.Shared; +using Common.Configuration; +using Domain.Services.Shared.DomainServices; +using FluentAssertions; +using IdentityInfrastructure.ApplicationServices; +using Infrastructure.Web.Hosting.Common.Auth; +using Moq; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.ApplicationServices; + +[Trait("Category", "Unit")] +public class JWTTokensServiceSpec +{ + private readonly JWTTokensService _service; + private readonly Mock _tokensService; + + public JWTTokensServiceSpec() + { + var settings = new Mock(); + settings.Setup(s => s.Platform.GetString(JWTTokensService.BaseUrlSettingName, null)) + .Returns("https://localhost"); + settings.Setup(s => s.Platform.GetString(JWTTokensService.SecretSettingName, null)) + .Returns("asecretsigningkey"); + settings.Setup(s => s.Platform.GetNumber(It.IsAny(), It.IsAny())) + .Returns((string _, double defaultValue) => defaultValue); + _tokensService = new Mock(); + _tokensService.Setup(ts => ts.CreateTokenForJwtRefresh()) + .Returns("arefreshtoken"); + + _service = new JWTTokensService(settings.Object, _tokensService.Object); + } + + [Fact] + public async Task WhenIssueTokensAsync_ThenReturnsTokens() + { + var user = new EndUser + { + Access = EndUserAccess.Enabled, + Status = EndUserStatus.Unregistered, + Id = "anid", + Roles = new List { "arole" }, + FeatureLevels = new List { "afeaturelevel" } + }; + + var result = await _service.IssueTokensAsync(user); + + result.Value.AccessToken.Should().NotBeEmpty(); + result.Value.RefreshToken.Should().Be("arefreshtoken"); + result.Value.ExpiresOn.Should().BeNear(DateTime.UtcNow.Add(JWTTokensService.DefaultExpiry)); + + var token = new JwtSecurityTokenHandler().ReadJwtToken(result.Value.AccessToken); + + token.Issuer.Should().Be("https://localhost"); + token.Audiences.Should().ContainSingle(aud => aud == "https://localhost"); + token.ValidTo.Should().BeNear(DateTime.UtcNow.Add(JWTTokensService.DefaultExpiry)); + token.Claims.Count().Should().Be(6); + token.Claims.Should() + .Contain(claim => claim.Type == AuthenticationConstants.ClaimForId && claim.Value == "anid"); + token.Claims.Should() + .Contain(claim => claim.Type == AuthenticationConstants.ClaimForRole && claim.Value == "arole"); + token.Claims.Should() + .Contain(claim => + claim.Type == AuthenticationConstants.ClaimForFeatureLevel && claim.Value == "afeaturelevel"); + _tokensService.Verify(ts => ts.CreateTokenForJwtRefresh()); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs b/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs new file mode 100644 index 00000000..0395f6f2 --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs @@ -0,0 +1,91 @@ +using Common; +using Common.Configuration; +using Domain.Common.ValueObjects; +using Domain.Services.Shared.DomainServices; +using Domain.Shared; +using FluentAssertions; +using IdentityApplication.Persistence; +using IdentityDomain; +using IdentityDomain.DomainServices; +using IdentityInfrastructure.DomainServices; +using Moq; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.DomainServices; + +[Trait("Category", "Unit")] +public class EmailAddressServiceSpec +{ + private readonly Mock _emailAddressService; + private readonly Mock _passwordHasherService; + private readonly Mock _recorder; + private readonly Mock _repository; + private readonly IEmailAddressService _service; + private readonly Mock _settings; + private readonly Mock _tokensService; + + public EmailAddressServiceSpec() + { + _repository = new Mock(); + _recorder = new Mock(); + _emailAddressService = new Mock(); + _emailAddressService.Setup(es => es.EnsureUniqueAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(true)); + _tokensService = new Mock(); + _passwordHasherService = new Mock(); + _settings = new Mock(); + _settings.Setup(s => s.Platform.GetString(It.IsAny(), It.IsAny())) + .Returns((string?)null!); + _settings.Setup(s => s.Platform.GetNumber(It.IsAny(), It.IsAny())) + .Returns(5); + + _service = new EmailAddressService(_repository.Object); + } + + [Fact] + public async Task WhenEnsureUniqueAsyncAndNoEmailMatch_ThenReturnsTrue() + { + _repository.Setup(s => s.FindCredentialsByUserEmailAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(Optional + .None)); + + var result = await _service.EnsureUniqueAsync(EmailAddress.Create("auser@company.com").Value, "auserid".ToId()); + + result.Should().BeTrue(); + } + + [Fact] + public async Task WhenEnsureUniqueAsyncAndMatchesUserId_ThenReturnsTrue() + { + var credential = CreateCredential("auserid"); + _repository.Setup(s => s.FindCredentialsByUserEmailAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(credential.ToOptional())); + + var result = await _service.EnsureUniqueAsync(EmailAddress.Create("auser@company.com").Value, "auserid".ToId()); + + result.Should().BeTrue(); + } + + [Fact] + public async Task WhenEnsureUniqueAsyncAndNotMatchesUserId_ThenReturnsFalse() + { + var credential = CreateCredential("anotheruserid"); + _repository.Setup(s => s.FindCredentialsByUserEmailAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult, Error>>(credential.ToOptional())); + + var result = await _service.EnsureUniqueAsync(EmailAddress.Create("auser@company.com").Value, "auserid".ToId()); + + result.Should().BeFalse(); + } + + private PasswordCredentialRoot CreateCredential(string userId) + { + var credential = PasswordCredentialRoot.Create(_recorder.Object, "acredentialid".ToIdentifierFactory(), + _settings.Object, _emailAddressService.Object, _tokensService.Object, _passwordHasherService.Object, + userId.ToId()).Value; + credential.SetCredential("apassword"); + credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + return credential; + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/DomainServices/PasswordHasherServiceSpec.cs b/src/IdentityInfrastructure.UnitTests/DomainServices/PasswordHasherServiceSpec.cs new file mode 100644 index 00000000..2c885de9 --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/DomainServices/PasswordHasherServiceSpec.cs @@ -0,0 +1,76 @@ +using Domain.Interfaces.Validations; +using FluentAssertions; +using IdentityInfrastructure.DomainServices; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.DomainServices; + +[Trait("Category", "Unit")] +public class PasswordHasherServiceSpec +{ + private readonly PasswordHasherService _service = new(); + + [Fact] + public void WhenVerifyPasswordWithEmptyHash_ThenReturnsFalse() + { + var result = _service.VerifyPassword("apassword", string.Empty); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenVerifyPasswordWithEmptyPassword_ThenReturnsFalse() + { + var result = _service.VerifyPassword(string.Empty, "awrongpasswordhash"); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenVerifyPasswordWithInCorrectHash_ThenReturnsFalse() + { + var result = _service.VerifyPassword("apassword", "awrongpasswordhash"); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenVerifyPasswordWithDifferentHash_ThenReturnsFalse() + { + var hash = _service.HashPassword("apassword1"); + + var result = _service.VerifyPassword("apassword2", hash); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenVerifyPasswordWithCorrectHash_ThenReturnsTrue() + { + var hash = _service.HashPassword("apassword"); + + var result = _service.VerifyPassword("apassword", hash); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenHashPasswordWithShortestPassword_ThenReturnsHash() + { + var password = new string('a', CommonValidations.Passwords.Password.MinLength); + var result = _service.HashPassword(password); + + result.Should().NotBeNullOrEmpty(); + _service.ValidatePasswordHash(result).Should().BeTrue(); + } + + [Fact] + public void WhenHashPasswordWithLongestPassword_ThenReturnsHash() + { + var password = new string('a', CommonValidations.Passwords.Password.MaxLength); + var result = _service.HashPassword(password); + + result.Should().NotBeNullOrEmpty(); + _service.ValidatePasswordHash(result).Should().BeTrue(); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/IdentityInfrastructure.UnitTests.csproj b/src/IdentityInfrastructure.UnitTests/IdentityInfrastructure.UnitTests.csproj new file mode 100644 index 00000000..153273f1 --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/IdentityInfrastructure.UnitTests.csproj @@ -0,0 +1,18 @@ + + + + net7.0 + true + + + + + + + + + + + + + diff --git a/src/IdentityInfrastructure/Api/ApiKeys/ApiKeysApi.cs b/src/IdentityInfrastructure/Api/ApiKeys/ApiKeysApi.cs new file mode 100644 index 00000000..bec20070 --- /dev/null +++ b/src/IdentityInfrastructure/Api/ApiKeys/ApiKeysApi.cs @@ -0,0 +1,17 @@ +using Application.Interfaces; +using IdentityApplication; +using Infrastructure.Web.Api.Interfaces; + +namespace IdentityInfrastructure.Api.ApiKeys; + +public class ApiKeysApi : IWebApiService +{ + private readonly IApiKeysApplication _apiKeysApplication; + private readonly ICallerContext _context; + + public ApiKeysApi(ICallerContext context, IApiKeysApplication apiKeysApplication) + { + _context = context; + _apiKeysApplication = apiKeysApplication; + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/AuthTokens/AuthTokensApi.cs b/src/IdentityInfrastructure/Api/AuthTokens/AuthTokensApi.cs new file mode 100644 index 00000000..b81c36d5 --- /dev/null +++ b/src/IdentityInfrastructure/Api/AuthTokens/AuthTokensApi.cs @@ -0,0 +1,36 @@ +using Application.Interfaces; +using IdentityApplication; +using IdentityApplication.ApplicationServices; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.AuthTokens; + +public class AuthTokensApi : IWebApiService +{ + private readonly IAuthTokensApplication _authTokensApplication; + + private readonly ICallerContext _context; + + public AuthTokensApi(ICallerContext context, + IAuthTokensApplication authTokensApplication) + { + _context = context; + _authTokensApplication = authTokensApplication; + } + + public async Task> Refresh(RefreshTokenRequest request, + CancellationToken cancellationToken) + { + var tokens = await _authTokensApplication.RefreshTokenAsync(_context, request.RefreshToken, cancellationToken); + + return () => tokens.HandleApplicationResult(x => + new PostResult(new RefreshTokenResponse + { + AccessToken = x.AccessToken, + RefreshToken = x.RefreshToken, + ExpiresOnUtc = x.ExpiresOn + })); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/MFA/2FAApi.cs b/src/IdentityInfrastructure/Api/MFA/2FAApi.cs new file mode 100644 index 00000000..0fcd5815 --- /dev/null +++ b/src/IdentityInfrastructure/Api/MFA/2FAApi.cs @@ -0,0 +1,7 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace IdentityInfrastructure.Api.MFA; + +public class MFAApi : IWebApiService +{ +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/MachineCredentials/MachineCredentialsApi.cs b/src/IdentityInfrastructure/Api/MachineCredentials/MachineCredentialsApi.cs new file mode 100644 index 00000000..d12c387e --- /dev/null +++ b/src/IdentityInfrastructure/Api/MachineCredentials/MachineCredentialsApi.cs @@ -0,0 +1,31 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using IdentityApplication; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.MachineCredentials; + +public class MachineCredentialsApi : IWebApiService +{ + private readonly ICallerContext _context; + private readonly IMachineCredentialsApplication _machineCredentialsApplication; + + public MachineCredentialsApi(ICallerContext context, IMachineCredentialsApplication machineCredentialsApplication) + { + _context = context; + _machineCredentialsApplication = machineCredentialsApplication; + } + + public async Task> RegisterMachine( + RegisterMachineRequest request, + CancellationToken cancellationToken) + { + var machine = await _machineCredentialsApplication.RegisterMachineAsync(_context, request.Name, + request.Timezone, request.CountryCode, cancellationToken); + + return () => machine.HandleApplicationResult(x => + new PostResult(new RegisterMachineResponse { Machine = x })); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/MachineCredentials/RegisterMachineRequestValidator.cs b/src/IdentityInfrastructure/Api/MachineCredentials/RegisterMachineRequestValidator.cs new file mode 100644 index 00000000..7ef9b6c3 --- /dev/null +++ b/src/IdentityInfrastructure/Api/MachineCredentials/RegisterMachineRequestValidator.cs @@ -0,0 +1,29 @@ +using Common.Extensions; +using Domain.Interfaces.Validations; +using FluentValidation; +using IdentityDomain; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.MachineCredentials; + +public class RegisterMachineRequestValidator : AbstractValidator +{ + public RegisterMachineRequestValidator() + { + RuleFor(dto => dto.Name) + .NotEmpty() + .Matches(Validations.Machine.Name) + .WithMessage(Resources.RegisterMachineRequestValidator_InvalidName); + RuleFor(dto => dto.Timezone) + .NotEmpty() + .Matches(CommonValidations.Timezone) + .WithMessage(Resources.RegisterAnyRequestValidator_InvalidTimezone) + .When(dto => dto.Timezone.HasValue()); + RuleFor(dto => dto.CountryCode) + .NotEmpty() + .Matches(CommonValidations.CountryCode) + .WithMessage(Resources.RegisterAnyRequestValidator_InvalidCountryCode) + .When(dto => dto.CountryCode.HasValue()); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/AutenticatePasswordRequestValidator.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/AutenticatePasswordRequestValidator.cs new file mode 100644 index 00000000..4a8d9fab --- /dev/null +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/AutenticatePasswordRequestValidator.cs @@ -0,0 +1,21 @@ +using Domain.Interfaces.Validations; +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.PasswordCredentials; + +public class AuthenticatePasswordRequestValidator : AbstractValidator +{ + public AuthenticatePasswordRequestValidator() + { + RuleFor(dto => dto.Username) + .NotEmpty() + .IsEmailAddress() + .WithMessage(Resources.AuthenticatePasswordRequestValidator_InvalidUsername); + RuleFor(dto => dto.Password) + .NotEmpty() + .Matches(CommonValidations.Passwords.Password.Strict) + .WithMessage(Resources.AuthenticatePasswordRequestValidator_InvalidPassword); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs new file mode 100644 index 00000000..a2c3aa99 --- /dev/null +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs @@ -0,0 +1,50 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using IdentityApplication; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.PasswordCredentials; + +public class PasswordCredentialsApi : IWebApiService +{ + private readonly ICallerContext _context; + private readonly IPasswordCredentialsApplication _passwordCredentialsApplication; + + public PasswordCredentialsApi(ICallerContext context, + IPasswordCredentialsApplication passwordCredentialsApplication) + { + _context = context; + _passwordCredentialsApplication = passwordCredentialsApplication; + } + + public async Task> Authenticate( + AuthenticatePasswordRequest request, + CancellationToken cancellationToken) + { + var authenticated = + await _passwordCredentialsApplication.AuthenticateAsync(_context, request.Username, request.Password, + cancellationToken); + + return () => authenticated.HandleApplicationResult(x => + new PostResult(new AuthenticatePasswordResponse + { + AccessToken = x.AccessToken, + RefreshToken = x.RefreshToken, + ExpiresOnUtc = x.ExpiresOn + })); + } + + public async Task> RegisterPerson( + RegisterPersonRequest request, + CancellationToken cancellationToken) + { + var person = await _passwordCredentialsApplication.RegisterPersonAsync(_context, request.FirstName, + request.LastName, request.EmailAddress, request.Password, request.Timezone, request.CountryCode, + request.TermsAndConditionsAccepted, cancellationToken); + + return () => person.HandleApplicationResult(x => + new PostResult(new RegisterPersonResponse { Person = x })); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/RegisterPersonRequestValidator.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/RegisterPersonRequestValidator.cs new file mode 100644 index 00000000..a28fe4e1 --- /dev/null +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/RegisterPersonRequestValidator.cs @@ -0,0 +1,45 @@ +using Common.Extensions; +using Domain.Interfaces.Validations; +using FluentValidation; +using IdentityDomain; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.PasswordCredentials; + +public class RegisterPersonRequestValidator : AbstractValidator +{ + public RegisterPersonRequestValidator() + { + RuleFor(req => req.FirstName) + .NotEmpty() + .Matches(Validations.Person.Name) + .WithMessage(Resources.RegisterPersonRequestValidator_InvalidFirstName); + RuleFor(req => req.LastName) + .NotEmpty() + .Matches(Validations.Person.Name) + .WithMessage(Resources.RegisterPersonRequestValidator_InvalidLastName); + RuleFor(dto => dto.EmailAddress) + .NotEmpty() + .IsEmailAddress() + .WithMessage(Resources.RegisterPersonRequestValidator_InvalidEmail); + RuleFor(dto => dto.Password) + .NotEmpty() + .Matches(CommonValidations.Passwords.Password.Strict) + .WithMessage(Resources.RegisterPersonRequestValidator_InvalidPassword); + RuleFor(dto => dto.Timezone) + .NotEmpty() + .Matches(CommonValidations.Timezone) + .WithMessage(Resources.RegisterAnyRequestValidator_InvalidTimezone) + .When(dto => dto.Timezone.HasValue()); + RuleFor(dto => dto.CountryCode) + .NotEmpty() + .Matches(CommonValidations.CountryCode) + .WithMessage(Resources.RegisterAnyRequestValidator_InvalidCountryCode) + .When(dto => dto.CountryCode.HasValue()); + RuleFor(dto => dto.TermsAndConditionsAccepted) + .NotEmpty() + .Must(dto => dto) + .WithMessage(Resources.RegisterPersonRequestValidator_InvalidTermsAndConditionsAccepted); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/ApplicationServices/AuthTokensService.cs b/src/IdentityInfrastructure/ApplicationServices/AuthTokensService.cs new file mode 100644 index 00000000..51321e4e --- /dev/null +++ b/src/IdentityInfrastructure/ApplicationServices/AuthTokensService.cs @@ -0,0 +1,23 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; +using IdentityApplication; +using IdentityApplication.ApplicationServices; + +namespace IdentityInfrastructure.ApplicationServices; + +public class AuthTokensService : IAuthTokensService +{ + private readonly IAuthTokensApplication _authTokensApplication; + + public AuthTokensService(IAuthTokensApplication authTokensApplication) + { + _authTokensApplication = authTokensApplication; + } + + public async Task> IssueTokensAsync(ICallerContext context, EndUser user, + CancellationToken cancellationToken) + { + return await _authTokensApplication.IssueTokensAsync(context, user, cancellationToken); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/ApplicationServices/JWTTokensService.cs b/src/IdentityInfrastructure/ApplicationServices/JWTTokensService.cs new file mode 100644 index 00000000..fff45ffb --- /dev/null +++ b/src/IdentityInfrastructure/ApplicationServices/JWTTokensService.cs @@ -0,0 +1,68 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Application.Resources.Shared; +using Common; +using Common.Configuration; +using Domain.Services.Shared.DomainServices; +using IdentityApplication.ApplicationServices; +using Infrastructure.Web.Hosting.Common.Auth; +using Microsoft.IdentityModel.Tokens; + +namespace IdentityInfrastructure.ApplicationServices; + +public class JWTTokensService : IJWTTokensService +{ + public const string BaseUrlSettingName = "Hosts:IdentityApi:BaseUrl"; + public const string DefaultExpirySettingName = "Hosts:IdentityApi:JWT:DefaultExpiryInMinutes"; + public const string SecretSettingName = "Hosts:IdentityApi:JWT:SigningSecret"; + public static readonly TimeSpan DefaultExpiry = TimeSpan.FromMinutes(15); + private readonly string _baseUrl; + private readonly TimeSpan _expiresAfter; + private readonly string _signingSecret; + private readonly ITokensService _tokensService; + + public JWTTokensService(IConfigurationSettings settings, ITokensService tokensService) + { + _tokensService = tokensService; + _signingSecret = settings.Platform.GetString(SecretSettingName); + _baseUrl = settings.Platform.GetString(BaseUrlSettingName); + _expiresAfter = + TimeSpan.FromMinutes(settings.Platform.GetNumber(DefaultExpirySettingName, DefaultExpiry.TotalMinutes)); + } + + public Task> IssueTokensAsync(EndUser user) + { + var tokens = IssueTokens(user); + + return Task.FromResult(tokens); + } + + private Result IssueTokens(EndUser user) + { + var expiresOn = DateTime.UtcNow.Add(_expiresAfter); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_signingSecret)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha512Signature); + + var claims = new List + { + new(AuthenticationConstants.ClaimForId, user.Id) + }; + user.Roles.ForEach(role => claims.Add(new Claim(AuthenticationConstants.ClaimForRole, role))); + user.FeatureLevels.ForEach(feature => + claims.Add(new Claim(AuthenticationConstants.ClaimForFeatureLevel, feature))); + + var token = new JwtSecurityToken( + claims: claims, + expires: expiresOn, + signingCredentials: credentials, + issuer: _baseUrl, + audience: _baseUrl + ); + + var accessToken = new JwtSecurityTokenHandler().WriteToken(token); + var refreshToken = _tokensService.CreateTokenForJwtRefresh(); + + return new AccessTokens(accessToken, refreshToken, expiresOn); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/DomainServices/EmailAddressService.cs b/src/IdentityInfrastructure/DomainServices/EmailAddressService.cs new file mode 100644 index 00000000..753dcfdc --- /dev/null +++ b/src/IdentityInfrastructure/DomainServices/EmailAddressService.cs @@ -0,0 +1,33 @@ +using Domain.Common.ValueObjects; +using Domain.Shared; +using IdentityApplication.Persistence; +using IdentityDomain.DomainServices; + +namespace IdentityInfrastructure.DomainServices; + +public sealed class EmailAddressService : IEmailAddressService +{ + private readonly IPasswordCredentialsRepository _repository; + + public EmailAddressService(IPasswordCredentialsRepository repository) + { + _repository = repository; + } + + public async Task EnsureUniqueAsync(EmailAddress emailAddress, Identifier userId) + { + var retrieved = await _repository.FindCredentialsByUserEmailAsync(emailAddress.Address, CancellationToken.None); + if (!retrieved.IsSuccessful) + { + return false; + } + + var credential = retrieved.Value; + if (credential.HasValue) + { + return credential.Value.UserId.Equals(userId); + } + + return true; + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/DomainServices/PasswordHasherService.cs b/src/IdentityInfrastructure/DomainServices/PasswordHasherService.cs new file mode 100644 index 00000000..c22a0d65 --- /dev/null +++ b/src/IdentityInfrastructure/DomainServices/PasswordHasherService.cs @@ -0,0 +1,43 @@ +using BCrypt.Net; +using Domain.Interfaces.Validations; +using IdentityDomain.DomainServices; + +namespace IdentityInfrastructure.DomainServices; + +public class PasswordHasherService : IPasswordHasherService +{ + public string HashPassword(string password) + { + var salt = BCrypt.Net.BCrypt.GenerateSalt(12); + var passwordHash = BCrypt.Net.BCrypt.HashPassword(password, salt); + return passwordHash; + } + + public bool VerifyPassword(string password, string passwordHash) + { + try + { + return BCrypt.Net.BCrypt.Verify(password, passwordHash); + } + catch (SaltParseException) + { + return false; + } + catch (ArgumentException) + { + return false; + } + } + + public bool ValidatePasswordHash(string passwordHash) + { + return CommonValidations.Passwords.PasswordHash.Matches(passwordHash); + } + + public bool ValidatePassword(string password, bool isStrict) + { + return isStrict + ? CommonValidations.Passwords.Password.Strict.Matches(password) + : CommonValidations.Passwords.Password.Loose.Matches(password); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/IdentityInfrastructure.csproj b/src/IdentityInfrastructure/IdentityInfrastructure.csproj new file mode 100644 index 00000000..ce5098b2 --- /dev/null +++ b/src/IdentityInfrastructure/IdentityInfrastructure.csproj @@ -0,0 +1,44 @@ + + + + net7.0 + + + + + + + + + + + + + + + + + <_Parameter1>$(AssemblyName).UnitTests + + + + + + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + True + True + Resources.resx + + + + diff --git a/src/IdentityInfrastructure/IdentityModule.cs b/src/IdentityInfrastructure/IdentityModule.cs new file mode 100644 index 00000000..d1c97859 --- /dev/null +++ b/src/IdentityInfrastructure/IdentityModule.cs @@ -0,0 +1,77 @@ +using System.Reflection; +using Application.Persistence.Interfaces; +using Common; +using Domain.Interfaces; +using IdentityApplication; +using IdentityApplication.ApplicationServices; +using IdentityApplication.Persistence; +using IdentityDomain; +using IdentityDomain.DomainServices; +using IdentityInfrastructure.Api.PasswordCredentials; +using IdentityInfrastructure.ApplicationServices; +using IdentityInfrastructure.DomainServices; +using IdentityInfrastructure.Persistence; +using IdentityInfrastructure.Persistence.ReadModels; +using Infrastructure.Hosting.Common.Extensions; +using Infrastructure.Persistence.Interfaces; +using Infrastructure.Web.Hosting.Common; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace IdentityInfrastructure; + +public class IdentityModule : ISubDomainModule +{ + public Assembly ApiAssembly => typeof(PasswordCredentialsApi).Assembly; + + public Assembly DomainAssembly => typeof(PasswordCredentialRoot).Assembly; + + public Dictionary AggregatePrefixes => new() + { + { typeof(PasswordCredentialRoot), "password" }, + { typeof(AuthTokensRoot), "tokens" } + }; + + public Action RegisterServices + { + get + { + return (_, services) => + { + services.RegisterUnshared(); + services.RegisterUnshared(); + services.RegisterUnshared(); + services.RegisterUnshared(); + + services.RegisterUnshared(); + services.RegisterUnshared(); + services.RegisterUnshared(); + services.RegisterUnshared(); + services.RegisterUnshared(c => new PasswordCredentialsRepository( + c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForUnshared>(), + c.ResolveForPlatform())); + services.RegisterUnTenantedEventing( + c => new PasswordCredentialProjection(c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForPlatform())); + services.RegisterUnshared(c => new AuthTokensRepository( + c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForUnshared>(), + c.ResolveForPlatform())); + services.RegisterUnTenantedEventing( + c => new AuthTokensProjection(c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForPlatform())); + }; + } + } + + public Action ConfigureMiddleware + { + get { return app => { app.RegisterRoutes(); }; } + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Persistence/AuthTokensRepository.cs b/src/IdentityInfrastructure/Persistence/AuthTokensRepository.cs new file mode 100644 index 00000000..ddecd77d --- /dev/null +++ b/src/IdentityInfrastructure/Persistence/AuthTokensRepository.cs @@ -0,0 +1,81 @@ +using Application.Persistence.Interfaces; +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using IdentityApplication.Persistence; +using IdentityApplication.Persistence.ReadModels; +using IdentityDomain; +using Infrastructure.Persistence.Common; +using Infrastructure.Persistence.Interfaces; +using QueryAny; + +namespace IdentityInfrastructure.Persistence; + +public class AuthTokensRepository : IAuthTokensRepository +{ + private readonly ISnapshottingQueryStore _tokenQueries; + private readonly IEventSourcingDddCommandStore _tokens; + + public AuthTokensRepository(IRecorder recorder, IDomainFactory domainFactory, + IEventSourcingDddCommandStore tokensStore, IDataStore store) + { + _tokenQueries = new SnapshottingQueryStore(recorder, domainFactory, store); + _tokens = tokensStore; + } + + public async Task> DestroyAllAsync(CancellationToken cancellationToken) + { + await _tokens.DestroyAllAsync(cancellationToken); + await _tokenQueries.DestroyAllAsync(cancellationToken); + return Result.Ok; + } + + public async Task, Error>> FindByRefreshTokenAsync(string refreshToken, + CancellationToken cancellationToken) + { + var query = Query.From() + .Where(at => at.RefreshToken, ConditionOperator.EqualTo, refreshToken); + return await FindFirstByQueryAsync(query, cancellationToken); + } + + public async Task, Error>> FindByUserIdAsync(Identifier userId, + CancellationToken cancellationToken) + { + var query = Query.From() + .Where(at => at.UserId, ConditionOperator.EqualTo, userId); + return await FindFirstByQueryAsync(query, cancellationToken); + } + + public async Task> SaveAsync(AuthTokensRoot tokens, + CancellationToken cancellationToken) + { + await _tokens.SaveAsync(tokens, cancellationToken); + + return tokens; + } + + private async Task, Error>> FindFirstByQueryAsync(QueryClause query, + CancellationToken cancellationToken) + { + var queried = await _tokenQueries.QueryAsync(query, false, cancellationToken); + if (!queried.IsSuccessful) + { + return queried.Error; + } + + var matching = queried.Value.Results.FirstOrDefault(); + if (matching.NotExists()) + { + return Optional.None; + } + + var tokens = await _tokens.LoadAsync(matching.Id.Value.ToId(), cancellationToken); + if (!tokens.IsSuccessful) + { + return tokens.Error; + } + + return tokens.Value.ToOptional(); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Persistence/PasswordCredentialsRepository.cs b/src/IdentityInfrastructure/Persistence/PasswordCredentialsRepository.cs new file mode 100644 index 00000000..227a8a26 --- /dev/null +++ b/src/IdentityInfrastructure/Persistence/PasswordCredentialsRepository.cs @@ -0,0 +1,82 @@ +using Application.Persistence.Interfaces; +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using IdentityApplication.Persistence; +using IdentityApplication.Persistence.ReadModels; +using IdentityDomain; +using Infrastructure.Persistence.Common; +using Infrastructure.Persistence.Interfaces; +using QueryAny; + +namespace IdentityInfrastructure.Persistence; + +public class PasswordCredentialsRepository : IPasswordCredentialsRepository +{ + private readonly ISnapshottingQueryStore _credentialQueries; + private readonly IEventSourcingDddCommandStore _credentials; + + public PasswordCredentialsRepository(IRecorder recorder, IDomainFactory domainFactory, + IEventSourcingDddCommandStore credentialsStore, IDataStore store) + { + _credentialQueries = new SnapshottingQueryStore(recorder, domainFactory, store); + _credentials = credentialsStore; + } + + public async Task> DestroyAllAsync(CancellationToken cancellationToken) + { + await _credentials.DestroyAllAsync(cancellationToken); + await _credentialQueries.DestroyAllAsync(cancellationToken); + return Result.Ok; + } + + public async Task, Error>> FindCredentialsByUserEmailAsync(string username, + CancellationToken cancellationToken) + { + var query = Query.From() + .Where(at => at.UserName, ConditionOperator.EqualTo, username); + return await FindFirstByQueryAsync(query, cancellationToken); + } + + public async Task, Error>> FindCredentialsByUserIdAsync(Identifier userId, + CancellationToken cancellationToken) + { + var query = Query.From() + .Where(at => at.UserId, ConditionOperator.EqualTo, userId); + return await FindFirstByQueryAsync(query, cancellationToken); + } + + public async Task> SaveAsync(PasswordCredentialRoot credential, + CancellationToken cancellationToken) + { + await _credentials.SaveAsync(credential, cancellationToken); + + return credential; + } + + private async Task, Error>> FindFirstByQueryAsync( + QueryClause query, + CancellationToken cancellationToken) + { + var queried = await _credentialQueries.QueryAsync(query, false, cancellationToken); + if (!queried.IsSuccessful) + { + return queried.Error; + } + + var matching = queried.Value.Results.FirstOrDefault(); + if (matching.NotExists()) + { + return Optional.None; + } + + var tokens = await _credentials.LoadAsync(matching.Id.Value.ToId(), cancellationToken); + if (!tokens.IsSuccessful) + { + return tokens.Error; + } + + return tokens.Value.ToOptional(); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Persistence/ReadModels/AuthTokensProjection.cs b/src/IdentityInfrastructure/Persistence/ReadModels/AuthTokensProjection.cs new file mode 100644 index 00000000..42f18504 --- /dev/null +++ b/src/IdentityInfrastructure/Persistence/ReadModels/AuthTokensProjection.cs @@ -0,0 +1,62 @@ +using Application.Persistence.Common.Extensions; +using Application.Persistence.Interfaces; +using Common; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.Entities; +using IdentityApplication.Persistence.ReadModels; +using IdentityDomain; +using Infrastructure.Persistence.Common; +using Infrastructure.Persistence.Interfaces; + +namespace IdentityInfrastructure.Persistence.ReadModels; + +public class AuthTokensProjection : IReadModelProjection +{ + private readonly IReadModelProjectionStore _authTokens; + + public AuthTokensProjection(IRecorder recorder, IDomainFactory domainFactory, IDataStore store) + { + _authTokens = new ReadModelProjectionStore(recorder, domainFactory, store); + } + + public Type RootAggregateType => typeof(AuthTokensRoot); + + public async Task> ProjectEventAsync(IDomainEvent changeEvent, + CancellationToken cancellationToken) + { + switch (changeEvent) + { + case Events.AuthTokens.Created e: + return await _authTokens.HandleCreateAsync(e.RootId.ToId(), dto => { dto.UserId = e.UserId; }, + cancellationToken); + + case Events.AuthTokens.TokensChanged e: + return await _authTokens.HandleUpdateAsync(e.RootId.ToId(), dto => + { + dto.AccessToken = e.AccessToken; + dto.RefreshToken = e.RefreshToken; + dto.ExpiresOn = e.ExpiresOn; + }, cancellationToken); + + case Events.AuthTokens.TokensRefreshed e: + return await _authTokens.HandleUpdateAsync(e.RootId.ToId(), dto => + { + dto.AccessToken = e.AccessToken; + dto.RefreshToken = e.RefreshToken; + dto.ExpiresOn = e.ExpiresOn; + }, cancellationToken); + + case Events.AuthTokens.TokensRevoked e: + return await _authTokens.HandleUpdateAsync(e.RootId.ToId(), dto => + { + dto.AccessToken = Optional.None; + dto.RefreshToken = Optional.None; + dto.ExpiresOn = Optional.None; + }, cancellationToken); + + default: + return false; + } + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Persistence/ReadModels/PasswordCredentialProjection.cs b/src/IdentityInfrastructure/Persistence/ReadModels/PasswordCredentialProjection.cs new file mode 100644 index 00000000..cd3ea3f3 --- /dev/null +++ b/src/IdentityInfrastructure/Persistence/ReadModels/PasswordCredentialProjection.cs @@ -0,0 +1,86 @@ +using Application.Persistence.Common.Extensions; +using Application.Persistence.Interfaces; +using Common; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.Entities; +using IdentityApplication.Persistence.ReadModels; +using IdentityDomain; +using Infrastructure.Persistence.Common; +using Infrastructure.Persistence.Interfaces; + +namespace IdentityInfrastructure.Persistence.ReadModels; + +public class PasswordCredentialProjection : IReadModelProjection +{ + private readonly IReadModelProjectionStore _credentials; + + public PasswordCredentialProjection(IRecorder recorder, IDomainFactory domainFactory, IDataStore store) + { + _credentials = new ReadModelProjectionStore(recorder, domainFactory, store); + } + + public Type RootAggregateType => typeof(AuthTokensRoot); + + public async Task> ProjectEventAsync(IDomainEvent changeEvent, + CancellationToken cancellationToken) + { + switch (changeEvent) + { + case Events.PasswordCredentials.Created e: + return await _credentials.HandleCreateAsync(e.RootId.ToId(), dto => + { + dto.UserId = e.UserId; + dto.RegistrationVerified = false; + dto.AccountLocked = false; + }, + cancellationToken); + + case Events.PasswordCredentials.CredentialsChanged e: + return await _credentials.HandleUpdateAsync(e.RootId.ToId(), + dto => { dto.PasswordResetToken = Optional.None; }, cancellationToken); + + case Events.PasswordCredentials.RegistrationChanged e: + return await _credentials.HandleUpdateAsync(e.RootId.ToId(), dto => + { + dto.UserName = e.Name; + dto.UserEmailAddress = e.EmailAddress; + }, cancellationToken); + + case Events.PasswordCredentials.PasswordVerified _: + return true; + + case Events.PasswordCredentials.AccountLocked e: + return await _credentials.HandleUpdateAsync(e.RootId.ToId(), + dto => { dto.AccountLocked = true; }, + cancellationToken); + + case Events.PasswordCredentials.AccountUnlocked e: + return await _credentials.HandleUpdateAsync(e.RootId.ToId(), + dto => { dto.AccountLocked = false; }, + cancellationToken); + + case Events.PasswordCredentials.RegistrationVerificationCreated e: + return await _credentials.HandleUpdateAsync(e.RootId.ToId(), + dto => { dto.RegistrationVerificationToken = e.Token; }, cancellationToken); + + case Events.PasswordCredentials.RegistrationVerificationVerified e: + return await _credentials.HandleUpdateAsync(e.RootId.ToId(), dto => + { + dto.RegistrationVerificationToken = Optional.None; + dto.RegistrationVerified = true; + }, cancellationToken); + + case Events.PasswordCredentials.PasswordResetInitiated e: + return await _credentials.HandleUpdateAsync(e.RootId.ToId(), + dto => { dto.PasswordResetToken = e.Token; }, cancellationToken); + + case Events.PasswordCredentials.PasswordResetCompleted e: + return await _credentials.HandleUpdateAsync(e.RootId.ToId(), + dto => { dto.PasswordResetToken = Optional.None; }, cancellationToken); + + default: + return false; + } + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Resources.Designer.cs b/src/IdentityInfrastructure/Resources.Designer.cs new file mode 100644 index 00000000..941d35e2 --- /dev/null +++ b/src/IdentityInfrastructure/Resources.Designer.cs @@ -0,0 +1,149 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace IdentityInfrastructure { + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("IdentityInfrastructure.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to The 'Password' is either missing or invalid. + /// + internal static string AuthenticatePasswordRequestValidator_InvalidPassword { + get { + return ResourceManager.GetString("AuthenticatePasswordRequestValidator_InvalidPassword", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'Username' is either missing or invalid. + /// + internal static string AuthenticatePasswordRequestValidator_InvalidUsername { + get { + return ResourceManager.GetString("AuthenticatePasswordRequestValidator_InvalidUsername", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'CountryCode' is not a valid ISO3166 alpha-2 or alpha-3 code or numeric. + /// + internal static string RegisterAnyRequestValidator_InvalidCountryCode { + get { + return ResourceManager.GetString("RegisterAnyRequestValidator_InvalidCountryCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'Timezone' is not a valid IANA name. + /// + internal static string RegisterAnyRequestValidator_InvalidTimezone { + get { + return ResourceManager.GetString("RegisterAnyRequestValidator_InvalidTimezone", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'Name' is either missing or invalid. + /// + internal static string RegisterMachineRequestValidator_InvalidName { + get { + return ResourceManager.GetString("RegisterMachineRequestValidator_InvalidName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'Email' is either missing or is an invalid email address. + /// + internal static string RegisterPersonRequestValidator_InvalidEmail { + get { + return ResourceManager.GetString("RegisterPersonRequestValidator_InvalidEmail", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'FirstName' was either missing or is invalid. + /// + internal static string RegisterPersonRequestValidator_InvalidFirstName { + get { + return ResourceManager.GetString("RegisterPersonRequestValidator_InvalidFirstName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'LastName' was either missing or is invalid. + /// + internal static string RegisterPersonRequestValidator_InvalidLastName { + get { + return ResourceManager.GetString("RegisterPersonRequestValidator_InvalidLastName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'Password' is either missing or invalid. + /// + internal static string RegisterPersonRequestValidator_InvalidPassword { + get { + return ResourceManager.GetString("RegisterPersonRequestValidator_InvalidPassword", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'TermsAndConditionsAccepted' must be True. + /// + internal static string RegisterPersonRequestValidator_InvalidTermsAndConditionsAccepted { + get { + return ResourceManager.GetString("RegisterPersonRequestValidator_InvalidTermsAndConditionsAccepted", resourceCulture); + } + } + } +} diff --git a/src/IdentityInfrastructure/Resources.resx b/src/IdentityInfrastructure/Resources.resx new file mode 100644 index 00000000..b025db17 --- /dev/null +++ b/src/IdentityInfrastructure/Resources.resx @@ -0,0 +1,57 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + The 'FirstName' was either missing or is invalid + + + The 'LastName' was either missing or is invalid + + + The 'Email' is either missing or is an invalid email address + + + The 'Password' is either missing or invalid + + + The 'Timezone' is not a valid IANA name + + + The 'CountryCode' is not a valid ISO3166 alpha-2 or alpha-3 code or numeric + + + The 'TermsAndConditionsAccepted' must be True + + + The 'Name' is either missing or invalid + + + The 'Username' is either missing or invalid + + + The 'Password' is either missing or invalid + + \ No newline at end of file diff --git a/src/Infrastructure.Common/Recording/QueuedAuditReporter.cs b/src/Infrastructure.Common/Recording/QueuedAuditReporter.cs index 308c95f2..1a766f45 100644 --- a/src/Infrastructure.Common/Recording/QueuedAuditReporter.cs +++ b/src/Infrastructure.Common/Recording/QueuedAuditReporter.cs @@ -9,6 +9,7 @@ using Infrastructure.Persistence.Interfaces; #endif using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Common; using Common.Configuration; using Common.Extensions; diff --git a/src/Infrastructure.Common/Recording/QueuedUsageReporter.cs b/src/Infrastructure.Common/Recording/QueuedUsageReporter.cs index f5a53bb4..7a8e42d0 100644 --- a/src/Infrastructure.Common/Recording/QueuedUsageReporter.cs +++ b/src/Infrastructure.Common/Recording/QueuedUsageReporter.cs @@ -10,6 +10,7 @@ #endif using Application.Interfaces; using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Common; using Common.Configuration; using Common.Extensions; diff --git a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/HostSettingsSpec.cs b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/HostSettingsSpec.cs index aca686e9..e45cf0f6 100644 --- a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/HostSettingsSpec.cs +++ b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/HostSettingsSpec.cs @@ -20,7 +20,7 @@ public HostSettingsSpec() [Fact] public void WhenGetWebsiteHostBaseUrl_ThenReturnsBaseUrl() { - _settings.Setup(s => s.Platform.GetString(HostSettings.WebsiteHostBaseUrlSettingName)) + _settings.Setup(s => s.Platform.GetString(HostSettings.WebsiteHostBaseUrlSettingName, It.IsAny())) .Returns("http://localhost/api/"); var result = _service.GetWebsiteHostBaseUrl(); @@ -31,7 +31,7 @@ public void WhenGetWebsiteHostBaseUrl_ThenReturnsBaseUrl() [Fact] public void WhenGetAncillaryApiHostBaseUrl_ThenReturnsBaseUrl() { - _settings.Setup(s => s.Platform.GetString(HostSettings.AncillaryApiHostBaseUrlSettingName)) + _settings.Setup(s => s.Platform.GetString(HostSettings.AncillaryApiHostBaseUrlSettingName, It.IsAny())) .Returns("http://localhost/api/"); var result = _service.GetAncillaryApiHostBaseUrl(); @@ -42,7 +42,7 @@ public void WhenGetAncillaryApiHostBaseUrl_ThenReturnsBaseUrl() [Fact] public void WhenGetAncillaryApiHostHmacAuthSecret_ThenReturnsBaseUrl() { - _settings.Setup(s => s.Platform.GetString(HostSettings.AncillaryApiHmacSecretSettingName)) + _settings.Setup(s => s.Platform.GetString(HostSettings.AncillaryApiHmacSecretSettingName, It.IsAny())) .Returns("asecret"); var result = _service.GetAncillaryApiHostHmacAuthSecret(); diff --git a/src/Infrastructure.Hosting.Common/AspNetConfigurationSettings.cs b/src/Infrastructure.Hosting.Common/AspNetConfigurationSettings.cs index 25170079..07b249e3 100644 --- a/src/Infrastructure.Hosting.Common/AspNetConfigurationSettings.cs +++ b/src/Infrastructure.Hosting.Common/AspNetConfigurationSettings.cs @@ -55,11 +55,16 @@ public AppSettingsWrapper(IConfiguration configuration) _configuration = configuration; } - public bool GetBool(string key) + public bool GetBool(string key, bool? defaultValue = null) { var value = _configuration.GetValue(key); if (value.HasNoValue()) { + if (defaultValue.HasValue) + { + return defaultValue.Value; + } + throw new InvalidOperationException(Resources.AspNetConfigurationSettings_KeyNotFound.Format(key)); } @@ -71,11 +76,16 @@ public bool GetBool(string key) return boolean; } - public double GetNumber(string key) + public double GetNumber(string key, double? defaultValue = null) { var value = _configuration.GetValue(key); if (value.HasNoValue()) { + if (defaultValue.HasValue) + { + return defaultValue.Value; + } + throw new InvalidOperationException(Resources.AspNetConfigurationSettings_KeyNotFound.Format(key)); } @@ -87,11 +97,16 @@ public double GetNumber(string key) return number; } - public string GetString(string key) + public string GetString(string key, string? defaultValue = null) { var value = _configuration.GetValue(key); if (value.NotExists()) { + if (defaultValue.Exists()) + { + return defaultValue; + } + throw new InvalidOperationException(Resources.AspNetConfigurationSettings_KeyNotFound.Format(key)); } @@ -110,11 +125,16 @@ public TenantedSettings(ITenancyContext tenancy) _tenancy = tenancy; } - public bool GetBool(string key) + public bool GetBool(string key, bool? defaultValue = null) { var settings = _tenancy.Settings; if (!settings.TryGetValue(key, out var value)) { + if (defaultValue.HasValue) + { + return defaultValue.Value; + } + throw new InvalidOperationException(Resources.AspNetConfigurationSettings_KeyNotFound.Format(key)); } @@ -126,11 +146,16 @@ public bool GetBool(string key) return boolean; } - public double GetNumber(string key) + public double GetNumber(string key, double? defaultValue = null) { var settings = _tenancy.Settings; if (!settings.TryGetValue(key, out var value)) { + if (defaultValue.HasValue) + { + return defaultValue.Value; + } + throw new InvalidOperationException(Resources.AspNetConfigurationSettings_KeyNotFound.Format(key)); } @@ -142,11 +167,16 @@ public double GetNumber(string key) return number; } - public string GetString(string key) + public string GetString(string key, string? defaultValue = null) { var settings = _tenancy.Settings; if (!settings.TryGetValue(key, out var value)) { + if (defaultValue.Exists()) + { + return defaultValue; + } + throw new InvalidOperationException(Resources.AspNetConfigurationSettings_KeyNotFound.Format(key)); } diff --git a/src/Infrastructure.Persistence.Common/MessageQueueStore.cs b/src/Infrastructure.Persistence.Common/MessageQueueStore.cs index 78a90df8..e56509c7 100644 --- a/src/Infrastructure.Persistence.Common/MessageQueueStore.cs +++ b/src/Infrastructure.Persistence.Common/MessageQueueStore.cs @@ -65,7 +65,8 @@ public async Task> PopSingleAsync( }, cancellationToken); } - public async Task> PushAsync(ICallContext call, TMessage message, CancellationToken cancellationToken) + public async Task> PushAsync(ICallContext call, TMessage message, + CancellationToken cancellationToken) { message.TenantId = message.TenantId.HasValue() ? message.TenantId @@ -88,7 +89,7 @@ public async Task> PushAsync(ICallContext call, TMessage message, _recorder.TraceDebug(null, "Message {Message} was added to the queue {Queue} in the {Store} store", messageJson, _queueName, _queueStore.GetType().Name); - return Result.Ok; + return message; } private string CreateMessageId() diff --git a/src/Infrastructure.Persistence.Shared/ApplicationServices/AuditMessageQueueRepository.cs b/src/Infrastructure.Persistence.Shared/ApplicationServices/AuditMessageQueueRepository.cs index 6984d214..a6cfc5a0 100644 --- a/src/Infrastructure.Persistence.Shared/ApplicationServices/AuditMessageQueueRepository.cs +++ b/src/Infrastructure.Persistence.Shared/ApplicationServices/AuditMessageQueueRepository.cs @@ -1,5 +1,6 @@ using Application.Persistence.Interfaces; using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Common; using Infrastructure.Persistence.Common; using Infrastructure.Persistence.Interfaces; @@ -32,7 +33,8 @@ public Task> PopSingleAsync( return _messageQueue.PopSingleAsync(onMessageReceivedAsync, cancellationToken); } - public Task> PushAsync(ICallContext call, AuditMessage message, CancellationToken cancellationToken) + public Task> PushAsync(ICallContext call, AuditMessage message, + CancellationToken cancellationToken) { return _messageQueue.PushAsync(call, message, cancellationToken); } diff --git a/src/Infrastructure.Persistence.Shared/ApplicationServices/EmailMessageQueueRepository.cs b/src/Infrastructure.Persistence.Shared/ApplicationServices/EmailMessageQueueRepository.cs new file mode 100644 index 00000000..6201185d --- /dev/null +++ b/src/Infrastructure.Persistence.Shared/ApplicationServices/EmailMessageQueueRepository.cs @@ -0,0 +1,46 @@ +using Application.Persistence.Interfaces; +using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; +using Common; +using Infrastructure.Persistence.Common; +using Infrastructure.Persistence.Interfaces; + +namespace Infrastructure.Persistence.Shared.ApplicationServices; + +public class EmailMessageQueueRepository : IEmailMessageQueueRepository +{ + private readonly MessageQueueStore _messageQueue; + + public EmailMessageQueueRepository(IRecorder recorder, IQueueStore store) + { + _messageQueue = new MessageQueueStore(recorder, store); + } + + public Task> CountAsync(CancellationToken cancellationToken) + { + return _messageQueue.CountAsync(cancellationToken); + } + + public Task> DestroyAllAsync(CancellationToken cancellationToken) + { + return _messageQueue.DestroyAllAsync(cancellationToken); + } + + public Task> PopSingleAsync( + Func>> onMessageReceivedAsync, + CancellationToken cancellationToken) + { + return _messageQueue.PopSingleAsync(onMessageReceivedAsync, cancellationToken); + } + + public Task> PushAsync(ICallContext call, EmailMessage message, + CancellationToken cancellationToken) + { + return _messageQueue.PushAsync(call, message, cancellationToken); + } + + Task> IApplicationRepository.DestroyAllAsync(CancellationToken cancellationToken) + { + return DestroyAllAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Persistence.Shared/ApplicationServices/UsageMessageQueueRepository.cs b/src/Infrastructure.Persistence.Shared/ApplicationServices/UsageMessageQueueRepository.cs index bb817f68..af80b690 100644 --- a/src/Infrastructure.Persistence.Shared/ApplicationServices/UsageMessageQueueRepository.cs +++ b/src/Infrastructure.Persistence.Shared/ApplicationServices/UsageMessageQueueRepository.cs @@ -1,5 +1,6 @@ using Application.Persistence.Interfaces; using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Common; using Infrastructure.Persistence.Common; using Infrastructure.Persistence.Interfaces; @@ -32,7 +33,8 @@ public Task> PopSingleAsync( return _messageQueue.PopSingleAsync(onMessageReceivedAsync, cancellationToken); } - public Task> PushAsync(ICallContext call, UsageMessage message, CancellationToken cancellationToken) + public Task> PushAsync(ICallContext call, UsageMessage message, + CancellationToken cancellationToken) { return _messageQueue.PushAsync(call, message, cancellationToken); } diff --git a/src/Infrastructure.Shared.UnitTests/ApplicationServices/TokensServiceSpec.cs b/src/Infrastructure.Shared.UnitTests/ApplicationServices/TokensServiceSpec.cs new file mode 100644 index 00000000..e23d1b6b --- /dev/null +++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/TokensServiceSpec.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using Infrastructure.Shared.ApplicationServices; +using Xunit; + +namespace Infrastructure.Shared.UnitTests.ApplicationServices; + +[Trait("Category", "Unit")] +public class TokensServiceSpec +{ + private readonly TokensService _service = new(); + + [Fact] + public void WhenCreateTokenForVerification_ThenReturnsRandomValue() + { + var result1 = _service.CreateTokenForVerification(); + var result2 = _service.CreateTokenForVerification(); + var result3 = _service.CreateTokenForVerification(); + + result1.Should().NotBeEmpty(); + result1.Should().NotBe(result2); + result2.Should().NotBe(result3); + result3.Should().NotBe(result1); + } + + [Fact] + public void WhenCreateTokenForPasswordReset_ThenReturnsRandomValue() + { + var result1 = _service.CreateTokenForPasswordReset(); + var result2 = _service.CreateTokenForPasswordReset(); + var result3 = _service.CreateTokenForPasswordReset(); + + result1.Should().NotBeEmpty(); + result1.Should().NotBe(result2); + result2.Should().NotBe(result3); + result3.Should().NotBe(result1); + } + + [Fact] + public void WhenCreateTokenForJwtRefresh_ThenReturnsRandomValue() + { + var result1 = _service.CreateTokenForJwtRefresh(); + var result2 = _service.CreateTokenForJwtRefresh(); + var result3 = _service.CreateTokenForJwtRefresh(); + + result1.Should().NotBeEmpty(); + result1.Should().NotBe(result2); + result2.Should().NotBe(result3); + result3.Should().NotBe(result1); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared.UnitTests/Infrastructure.Shared.UnitTests.csproj b/src/Infrastructure.Shared.UnitTests/Infrastructure.Shared.UnitTests.csproj new file mode 100644 index 00000000..65d4d953 --- /dev/null +++ b/src/Infrastructure.Shared.UnitTests/Infrastructure.Shared.UnitTests.csproj @@ -0,0 +1,18 @@ + + + + net7.0 + true + + + + + + + + + + + + + diff --git a/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs b/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs new file mode 100644 index 00000000..1e1d90e4 --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs @@ -0,0 +1,61 @@ +using Application.Interfaces; +using Application.Interfaces.Services; +using Application.Services.Shared; +using Common; +using Common.Configuration; +using Common.Extensions; + +namespace Infrastructure.Shared.ApplicationServices; + +/// +/// Provides a that delivers notifications via asynchronous email delivery using +/// +/// +public class EmailNotificationsService : INotificationsService +{ + public const string ProductNameSettingName = "ApplicationServices:Notifications:SenderProductName"; + public const string SenderDisplayNameSettingName = "ApplicationServices:Notifications:SenderDisplayName"; + public const string SenderEmailAddressSettingName = "ApplicationServices:Notifications:SenderEmailAddress"; + private readonly IEmailQueuingService _emailQueuingService; + private readonly IHostSettings _hostSettings; + private readonly string _productName; + private readonly string _senderEmailAddress; + private readonly string _senderName; + private readonly IWebsiteUiService _websiteUiService; + + public EmailNotificationsService(IConfigurationSettings settings, IHostSettings hostSettings, + IWebsiteUiService websiteUiService, IEmailQueuingService emailQueuingService) + { + _hostSettings = hostSettings; + _websiteUiService = websiteUiService; + _emailQueuingService = emailQueuingService; + _productName = settings.Platform.GetString(ProductNameSettingName, nameof(EmailNotificationsService)); + _senderEmailAddress = + settings.Platform.GetString(SenderEmailAddressSettingName, nameof(EmailNotificationsService)); + _senderName = settings.Platform.GetString(SenderDisplayNameSettingName, nameof(EmailNotificationsService)); + } + + public async Task> NotifyPasswordRegistrationConfirmationAsync(ICallerContext caller, + string emailAddress, string name, string token, + CancellationToken cancellationToken) + { + var webSiteUrl = _hostSettings.GetWebsiteHostBaseUrl(); + var webSiteRoute = _websiteUiService.ConstructPasswordRegistrationConfirmationPageUrl(token); + var link = webSiteUrl.WithoutTrailingSlash() + webSiteRoute; + var htmlBody = + $"

Hello {name},

" + + $"

Thank you for signing up at {_productName}.

" + + $"

Please click this link to confirm your email address

" + + $"

This is an automated email from the support team at {_productName}

"; + + return await _emailQueuingService.SendHtmlEmail(caller, new HtmlEmail + { + Subject = $"Welcome to {_productName}", + Body = htmlBody, + FromEmailAddress = _senderEmailAddress, + FromDisplayName = _senderName, + ToEmailAddress = emailAddress, + ToDisplayName = name + }, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/EmailQueuingService.cs b/src/Infrastructure.Shared/ApplicationServices/EmailQueuingService.cs new file mode 100644 index 00000000..3e7af82e --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/EmailQueuingService.cs @@ -0,0 +1,51 @@ +using Application.Common; +using Application.Interfaces; +using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; +using Application.Services.Shared; +using Common; + +namespace Infrastructure.Shared.ApplicationServices; + +/// +/// Provides a queueing service for asynchronous delivery of emails +/// +public class EmailQueuingService : IEmailQueuingService +{ + private readonly IRecorder _recorder; + private readonly IEmailMessageQueueRepository _repository; + + public EmailQueuingService(IRecorder recorder, IEmailMessageQueueRepository repository) + { + _recorder = recorder; + _repository = repository; + } + + public async Task> SendHtmlEmail(ICallerContext caller, HtmlEmail htmlEmail, + CancellationToken cancellationToken) + { + var queued = await _repository.PushAsync(caller.ToCall(), new EmailMessage + { + Html = new QueuedEmailHtmlMessage + { + Subject = htmlEmail.Subject, + FromEmail = htmlEmail.FromEmailAddress, + FromDisplayName = htmlEmail.FromDisplayName, + HtmlBody = htmlEmail.Body, + ToEmail = htmlEmail.ToEmailAddress, + ToDisplayName = htmlEmail.ToDisplayName + } + }, cancellationToken); + if (!queued.IsSuccessful) + { + return queued.Error; + } + + var message = queued.Value; + _recorder.TraceInformation(caller.ToCall(), + "Pended email message {Id} for {To} with subject {Subject}", message.MessageId!, + htmlEmail.ToEmailAddress, htmlEmail.Subject); + + return Result.Ok; + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/TokensService.cs b/src/Infrastructure.Shared/ApplicationServices/TokensService.cs new file mode 100644 index 00000000..dd84d433 --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/TokensService.cs @@ -0,0 +1,34 @@ +using System.Security.Cryptography; +using Domain.Services.Shared.DomainServices; + +namespace Infrastructure.Shared.ApplicationServices; + +public sealed class TokensService : ITokensService +{ + private const int DefaultTokenSizeInBytes = 32; + + public string CreateTokenForPasswordReset() + { + return GenerateRandomToken(); + } + + public string CreateTokenForJwtRefresh() + { + return GenerateRandomToken(); + } + + public string CreateTokenForVerification() + { + return GenerateRandomToken(); + } + + private static string GenerateRandomToken(int keySize = DefaultTokenSizeInBytes) + { + using (var random = RandomNumberGenerator.Create()) + { + var bytes = new byte[keySize]; + random.GetNonZeroBytes(bytes); + return Convert.ToBase64String(bytes); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs b/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs new file mode 100644 index 00000000..0cd000af --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs @@ -0,0 +1,17 @@ +using Application.Services.Shared; + +namespace Infrastructure.Shared.ApplicationServices; + +/// +/// Provides a service for constructing resources based on a known Website UI Application +/// +public sealed class WebsiteUiService : IWebsiteUiService +{ + private const string RegistrationConfirmationPageRoute = "/confirm-registeration"; + + public string ConstructPasswordRegistrationConfirmationPageUrl(string token) + { + var escapedToken = Uri.EscapeDataString(token); + return $"{RegistrationConfirmationPageRoute}?token={escapedToken}"; + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/Infrastructure.Shared.csproj b/src/Infrastructure.Shared/Infrastructure.Shared.csproj new file mode 100644 index 00000000..c61bb82a --- /dev/null +++ b/src/Infrastructure.Shared/Infrastructure.Shared.csproj @@ -0,0 +1,39 @@ + + + + net7.0 + + + + + <_Parameter1>$(AssemblyName).UnitTests + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + True + True + Resources.resx + + + + + + + + + + + + + + + diff --git a/src/Infrastructure.Shared/Resources.Designer.cs b/src/Infrastructure.Shared/Resources.Designer.cs new file mode 100644 index 00000000..7e7ebbfd --- /dev/null +++ b/src/Infrastructure.Shared/Resources.Designer.cs @@ -0,0 +1,62 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Infrastructure.Shared { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Infrastructure.Shared.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} diff --git a/src/Infrastructure.Shared/Resources.resx b/src/Infrastructure.Shared/Resources.resx new file mode 100644 index 00000000..755958fe --- /dev/null +++ b/src/Infrastructure.Shared/Resources.resx @@ -0,0 +1,27 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Common.UnitTests/Extensions/RequestExtensionsSpec.cs b/src/Infrastructure.Web.Api.Common.UnitTests/Extensions/RequestExtensionsSpec.cs index 62005c98..a46ef9f9 100644 --- a/src/Infrastructure.Web.Api.Common.UnitTests/Extensions/RequestExtensionsSpec.cs +++ b/src/Infrastructure.Web.Api.Common.UnitTests/Extensions/RequestExtensionsSpec.cs @@ -178,7 +178,7 @@ public void WhenGetRequestInfoAndRouteTemplateHasPlaceholdersWithDataValuesForGe result.Route.Should() .Be( - "/aroute/anid/apath1/xxx999yyy/apath2/avalue1avalue2/apath3?adatetimeproperty=2023-10-29T12%3a30%3a00Z&astringproperty3=avalue3"); + "/aroute/anid/apath1/xxx999yyy/apath2/avalue1/avalue2/apath3?adatetimeproperty=2023-10-29T12%3a30%3a00Z&astringproperty3=avalue3"); result.Operation.Should().Be(ServiceOperation.Get); result.IsTestingOnly.Should().BeFalse(); } @@ -241,7 +241,7 @@ public void WhenGetRequestInfoAndRouteTemplateHasPlaceholdersWithDataValuesForPo var result = request.GetRequestInfo(); - result.Route.Should().Be("/aroute/anid/apath1/xxx999yyy/apath2/avalue1avalue2/apath3"); + result.Route.Should().Be("/aroute/anid/apath1/xxx999yyy/apath2/avalue1/avalue2/apath3"); result.Operation.Should().Be(ServiceOperation.Post); result.IsTestingOnly.Should().BeFalse(); } @@ -343,7 +343,7 @@ private class HasUnknownPlaceholderPostRequest : IWebRequest public string? Id { get; set; } } - [Route("/aroute/{id}/apath1/xxx{anumberproperty}yyy/apath2/{astringproperty1}{astringproperty2}/apath3", + [Route("/aroute/{id}/apath1/xxx{anumberproperty}yyy/apath2/{astringproperty1}/{astringproperty2}/apath3", ServiceOperation.Get)] private class HasPlaceholdersGetRequest : IWebRequest { @@ -360,7 +360,7 @@ private class HasPlaceholdersGetRequest : IWebRequest public string? Id { get; set; } } - [Route("/aroute/{id}/apath1/xxx{anumberproperty}yyy/apath2/{astringproperty1}{astringproperty2}/apath3", + [Route("/aroute/{id}/apath1/xxx{anumberproperty}yyy/apath2/{astringproperty1}/{astringproperty2}/apath3", ServiceOperation.Post)] private class HasPlaceholdersPostRequest : IWebRequest { diff --git a/src/Infrastructure.Web.Api.Common.UnitTests/RequestCorrelationFilterSpec.cs b/src/Infrastructure.Web.Api.Common.UnitTests/RequestCorrelationFilterSpec.cs index 5ad43773..8f203249 100644 --- a/src/Infrastructure.Web.Api.Common.UnitTests/RequestCorrelationFilterSpec.cs +++ b/src/Infrastructure.Web.Api.Common.UnitTests/RequestCorrelationFilterSpec.cs @@ -56,7 +56,7 @@ public async Task WhenInvokeAsyncAndInAnAcceptedRequestHeader_ThenUses() { var acceptedHeader = RequestCorrelationFilter.AcceptedRequestHeaderNames[0]; var httpContext = new DefaultHttpContext(); - httpContext.Request.Headers.Add(acceptedHeader, "acorrelationid"); + httpContext.Request.Headers[acceptedHeader] = "acorrelationid"; var context = new DefaultEndpointFilterInvocationContext(httpContext); var next = new EndpointFilterDelegate(_ => new ValueTask()); diff --git a/src/Infrastructure.Web.Api.Common/Extensions/HttpRequestExtensions.cs b/src/Infrastructure.Web.Api.Common/Extensions/HttpRequestExtensions.cs index 511a241d..cdcc3b05 100644 --- a/src/Infrastructure.Web.Api.Common/Extensions/HttpRequestExtensions.cs +++ b/src/Infrastructure.Web.Api.Common/Extensions/HttpRequestExtensions.cs @@ -31,7 +31,21 @@ public static void SetBearerToken(this HttpRequestMessage message, ICallerContex return; } - message.Headers.Add(HttpHeaders.Authorization, $"Bearer: {token}"); + SetBearerToken(message, token); + } + + /// + /// Sets the header of the specified + /// to the + /// + public static void SetBearerToken(this HttpRequestMessage message, string token) + { + if (token.HasNoValue()) + { + return; + } + + message.Headers.Add(HttpHeaders.Authorization, $"Bearer {token}"); } /// diff --git a/src/Infrastructure.Web.Api.Common/RequestCorrelationFilter.cs b/src/Infrastructure.Web.Api.Common/RequestCorrelationFilter.cs index c6dbcfd4..eb7068d0 100644 --- a/src/Infrastructure.Web.Api.Common/RequestCorrelationFilter.cs +++ b/src/Infrastructure.Web.Api.Common/RequestCorrelationFilter.cs @@ -64,6 +64,6 @@ private static void SaveToRequestPipeline(HttpContext httpContext, object? corre private static void SetOnResponse(HttpContext httpContext, object? correlationId) { - httpContext.Response.Headers.Add(ResponseHeaderName, new StringValues(correlationId!.ToString())); + httpContext.Response.Headers[ResponseHeaderName] = new StringValues(correlationId!.ToString()); } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.IntegrationTests/AuthNApiSpec.cs b/src/Infrastructure.Web.Api.IntegrationTests/AuthNApiSpec.cs new file mode 100644 index 00000000..b00f4665 --- /dev/null +++ b/src/Infrastructure.Web.Api.IntegrationTests/AuthNApiSpec.cs @@ -0,0 +1,95 @@ +#if TESTINGONLY +using System.Net; +using ApiHost1; +using Application.Resources.Shared; +using Common.Configuration; +using Domain.Services.Shared.DomainServices; +using FluentAssertions; +using IdentityInfrastructure.ApplicationServices; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.TestingOnly; +using IntegrationTesting.WebApi.Common; +using Xunit; + +namespace Infrastructure.Web.Api.IntegrationTests; + +[Trait("Category", "Integration.Web")] +public class AuthNApiSpec : WebApiSpec +{ + private readonly IConfigurationSettings _settings; + private readonly ITokensService _tokensService; + + public AuthNApiSpec(WebApiSetup setup) : base(setup) + { + _settings = setup.GetRequiredService(); + _tokensService = setup.GetRequiredService(); + } + + [Fact] + public async Task WhenGetHMACRequestWithNoHMACSignature_ThenReturns401() + { + var result = await Api.GetAsync(new AuthNHMACTestingOnlyRequest()); + + result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + result.Content.Error.Title.Should().Be("Unauthorized"); + } + + [Fact] + public async Task WhenGetHMACRequestWithWrongSignature_ThenReturns401() + { + var request = new AuthNHMACTestingOnlyRequest(); + var result = await Api.GetAsync(request, req => req.SetHmacAuth(request, "awrongsecret")); + + result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + result.Content.Error.Title.Should().Be("Unauthorized"); + } + + [Fact] + public async Task WhenGetHMACRequestWithSignature_ThenReturnsSuccess() + { + var request = new AuthNHMACTestingOnlyRequest(); + var result = await Api.GetAsync(request, req => req.SetHmacAuth(request, "asecret")); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + result.Content.Value.Message.Should().Be("amessage"); + } + + [Fact] + public async Task WhenGetTokenRequestWithNoBearer_ThenReturns401() + { + var result = await Api.GetAsync(new AuthNTokenTestingOnlyRequest()); + + result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + result.Content.Error.Title.Should().Be("Unauthorized"); + } + + [Fact] + public async Task WhenGetTokenRequestWithWrongToken_ThenReturns401() + { + var result = await Api.GetAsync(new AuthNTokenTestingOnlyRequest(), req => req.SetBearerToken("awrongtoken")); + + result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + result.Content.Error.Title.Should().Be("Unauthorized"); + } + + [Fact] + public async Task WhenGetTokenRequestWithToken_ThenReturnsSuccess() + { + var token = CreateJwtToken(_settings, _tokensService); + + var result = await Api.GetAsync(new AuthNTokenTestingOnlyRequest(), req => req.SetBearerToken(token)); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + result.Content.Value.Message.Should().Be("amessage"); + } + + private static string CreateJwtToken(IConfigurationSettings settings, ITokensService tokensService) + { + return new JWTTokensService(settings, tokensService) + .IssueTokensAsync(new EndUser + { + Id = "auserid" + }).GetAwaiter().GetResult().Value.AccessToken; + } +} +#endif \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.IntegrationTests/ApiContentNegotiationSpec.cs b/src/Infrastructure.Web.Api.IntegrationTests/ContentNegotiationApiSpec.cs similarity index 97% rename from src/Infrastructure.Web.Api.IntegrationTests/ApiContentNegotiationSpec.cs rename to src/Infrastructure.Web.Api.IntegrationTests/ContentNegotiationApiSpec.cs index a9322702..51165b94 100644 --- a/src/Infrastructure.Web.Api.IntegrationTests/ApiContentNegotiationSpec.cs +++ b/src/Infrastructure.Web.Api.IntegrationTests/ContentNegotiationApiSpec.cs @@ -10,9 +10,9 @@ namespace Infrastructure.Web.Api.IntegrationTests; [Trait("Category", "Integration.Web")] -public class ApiContentNegotiationSpec : WebApiSpec +public class ContentNegotiationApiSpec : WebApiSpec { - public ApiContentNegotiationSpec(WebApiSetup setup) : base(setup) + public ContentNegotiationApiSpec(WebApiSetup setup) : base(setup) { } diff --git a/src/Infrastructure.Web.Api.IntegrationTests/ApiDataFormatsSpec.cs b/src/Infrastructure.Web.Api.IntegrationTests/DataFormatsApiSpec.cs similarity index 98% rename from src/Infrastructure.Web.Api.IntegrationTests/ApiDataFormatsSpec.cs rename to src/Infrastructure.Web.Api.IntegrationTests/DataFormatsApiSpec.cs index 1759dbe7..eb2c8da8 100644 --- a/src/Infrastructure.Web.Api.IntegrationTests/ApiDataFormatsSpec.cs +++ b/src/Infrastructure.Web.Api.IntegrationTests/DataFormatsApiSpec.cs @@ -12,9 +12,9 @@ namespace Infrastructure.Web.Api.IntegrationTests; [Trait("Category", "Integration.Web")] -public class ApiDataFormatsSpec : WebApiSpec +public class DataFormatsApiSpec : WebApiSpec { - public ApiDataFormatsSpec(WebApiSetup setup) : base(setup) + public DataFormatsApiSpec(WebApiSetup setup) : base(setup) { } diff --git a/src/Infrastructure.Web.Api.IntegrationTests/ApiDefaultStatusCodeSpec.cs b/src/Infrastructure.Web.Api.IntegrationTests/DefaultStatusCodeApiSpec.cs similarity index 94% rename from src/Infrastructure.Web.Api.IntegrationTests/ApiDefaultStatusCodeSpec.cs rename to src/Infrastructure.Web.Api.IntegrationTests/DefaultStatusCodeApiSpec.cs index 018b0afc..d44455d9 100644 --- a/src/Infrastructure.Web.Api.IntegrationTests/ApiDefaultStatusCodeSpec.cs +++ b/src/Infrastructure.Web.Api.IntegrationTests/DefaultStatusCodeApiSpec.cs @@ -9,9 +9,9 @@ namespace Infrastructure.Web.Api.IntegrationTests; [Trait("Category", "Integration.Web")] -public class ApiDefaultStatusCode : WebApiSpec +public class DefaultStatusCodeApiSpec : WebApiSpec { - public ApiDefaultStatusCode(WebApiSetup setup) : base(setup) + public DefaultStatusCodeApiSpec(WebApiSetup setup) : base(setup) { } diff --git a/src/Infrastructure.Web.Api.IntegrationTests/ApiErrorSpec.cs b/src/Infrastructure.Web.Api.IntegrationTests/ErrorApiSpec.cs similarity index 92% rename from src/Infrastructure.Web.Api.IntegrationTests/ApiErrorSpec.cs rename to src/Infrastructure.Web.Api.IntegrationTests/ErrorApiSpec.cs index 581359a7..3f0bdfc8 100644 --- a/src/Infrastructure.Web.Api.IntegrationTests/ApiErrorSpec.cs +++ b/src/Infrastructure.Web.Api.IntegrationTests/ErrorApiSpec.cs @@ -9,9 +9,9 @@ namespace Infrastructure.Web.Api.IntegrationTests; [Trait("Category", "Integration.Web")] -public class ApiErrorSpec : WebApiSpec +public class ErrorApiSpec : WebApiSpec { - public ApiErrorSpec(WebApiSetup setup) : base(setup) + public ErrorApiSpec(WebApiSetup setup) : base(setup) { } diff --git a/src/Infrastructure.Web.Api.IntegrationTests/HealthCheckApiSpec.cs b/src/Infrastructure.Web.Api.IntegrationTests/HealthCheckApiSpec.cs index f78f5577..bcd507c8 100644 --- a/src/Infrastructure.Web.Api.IntegrationTests/HealthCheckApiSpec.cs +++ b/src/Infrastructure.Web.Api.IntegrationTests/HealthCheckApiSpec.cs @@ -6,7 +6,7 @@ namespace Infrastructure.Web.Api.IntegrationTests; -[Trait("Category", "Unit")] +[Trait("Category", "Integration.Web")] public class HealthCheckApiSpec : WebApiSpec { public HealthCheckApiSpec(WebApiSetup setup) : base(setup) diff --git a/src/Infrastructure.Web.Api.IntegrationTests/ApiRequestCorrelationSpec.cs b/src/Infrastructure.Web.Api.IntegrationTests/RequestCorrelationApiSpec.cs similarity index 98% rename from src/Infrastructure.Web.Api.IntegrationTests/ApiRequestCorrelationSpec.cs rename to src/Infrastructure.Web.Api.IntegrationTests/RequestCorrelationApiSpec.cs index dd679212..9bdd0519 100644 --- a/src/Infrastructure.Web.Api.IntegrationTests/ApiRequestCorrelationSpec.cs +++ b/src/Infrastructure.Web.Api.IntegrationTests/RequestCorrelationApiSpec.cs @@ -11,7 +11,7 @@ namespace Infrastructure.Web.Api.IntegrationTests; [UsedImplicitly] -public class ApiRequestCorrelationSpec +public class RequestCorrelationApiSpec { [Trait("Category", "Integration.Web")] public class GivenAnHttpClient : WebApiSpec diff --git a/src/Infrastructure.Web.Api.IntegrationTests/ApiValidationSpec.cs b/src/Infrastructure.Web.Api.IntegrationTests/ValidationApiSpec.cs similarity index 96% rename from src/Infrastructure.Web.Api.IntegrationTests/ApiValidationSpec.cs rename to src/Infrastructure.Web.Api.IntegrationTests/ValidationApiSpec.cs index f41c3bf3..ba25f704 100644 --- a/src/Infrastructure.Web.Api.IntegrationTests/ApiValidationSpec.cs +++ b/src/Infrastructure.Web.Api.IntegrationTests/ValidationApiSpec.cs @@ -10,9 +10,9 @@ namespace Infrastructure.Web.Api.IntegrationTests; [Trait("Category", "Integration.Web")] -public class ApiValidationSpec : WebApiSpec +public class ValidationApiSpec : WebApiSpec { - public ApiValidationSpec(WebApiSetup setup) : base(setup) + public ValidationApiSpec(WebApiSetup setup) : base(setup) { } diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticatePasswordRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticatePasswordRequest.cs new file mode 100644 index 00000000..83557230 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticatePasswordRequest.cs @@ -0,0 +1,11 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +[Route("/passwords/auth", ServiceOperation.Post)] +public class AuthenticatePasswordRequest : UnTenantedRequest +{ + public required string Password { get; set; } + + public required string Username { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticatePasswordResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticatePasswordResponse.cs new file mode 100644 index 00000000..fc15577d --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticatePasswordResponse.cs @@ -0,0 +1,12 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +public class AuthenticatePasswordResponse : IWebResponse +{ + public required string AccessToken { get; set; } + + public required DateTime ExpiresOnUtc { get; set; } + + public required string RefreshToken { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/RefreshTokenRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RefreshTokenRequest.cs new file mode 100644 index 00000000..9f574a63 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RefreshTokenRequest.cs @@ -0,0 +1,9 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +[Route("token/refresh", ServiceOperation.Post)] +public class RefreshTokenRequest : UnTenantedRequest +{ + public required string RefreshToken { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/RefreshTokenResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RefreshTokenResponse.cs new file mode 100644 index 00000000..87ac0282 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RefreshTokenResponse.cs @@ -0,0 +1,12 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +public class RefreshTokenResponse : IWebResponse +{ + public required string AccessToken { get; set; } + + public required DateTime ExpiresOnUtc { get; set; } + + public required string RefreshToken { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/RegisterMachineRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RegisterMachineRequest.cs new file mode 100644 index 00000000..da807f80 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RegisterMachineRequest.cs @@ -0,0 +1,13 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +[Route("/machines/register", ServiceOperation.Post)] +public class RegisterMachineRequest : UnTenantedRequest +{ + public string? CountryCode { get; set; } + + public required string Name { get; set; } + + public string? Timezone { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/RegisterMachineResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RegisterMachineResponse.cs new file mode 100644 index 00000000..3036aa5f --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RegisterMachineResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +public class RegisterMachineResponse : IWebResponse +{ + public MachineCredential? Machine { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/RegisterPersonRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RegisterPersonRequest.cs new file mode 100644 index 00000000..2ed5175e --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RegisterPersonRequest.cs @@ -0,0 +1,21 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +[Route("/passwords/register", ServiceOperation.Post)] +public class RegisterPersonRequest : UnTenantedRequest +{ + public string? CountryCode { get; set; } + + public required string EmailAddress { get; set; } + + public required string FirstName { get; set; } + + public required string LastName { get; set; } + + public required string Password { get; set; } + + public bool TermsAndConditionsAccepted { get; set; } + + public string? Timezone { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/RegisterPersonResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RegisterPersonResponse.cs new file mode 100644 index 00000000..5c4e22e9 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RegisterPersonResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +public class RegisterPersonResponse : IWebResponse +{ + public PasswordCredential? Person { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/TestingOnly/AuthNHMACTestingOnlyRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/TestingOnly/AuthNHMACTestingOnlyRequest.cs new file mode 100644 index 00000000..c26be9c2 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/TestingOnly/AuthNHMACTestingOnlyRequest.cs @@ -0,0 +1,13 @@ +#if TESTINGONLY +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared.TestingOnly; + +[Route("/testingonly/authn/hmac/get", ServiceOperation.Get, AccessType.HMAC, true)] +[UsedImplicitly] +// ReSharper disable once InconsistentNaming +public class AuthNHMACTestingOnlyRequest : IWebRequest +{ +} +#endif \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/TestingOnly/AuthNTokenTestingOnlyRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/TestingOnly/AuthNTokenTestingOnlyRequest.cs new file mode 100644 index 00000000..10fe1b33 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/TestingOnly/AuthNTokenTestingOnlyRequest.cs @@ -0,0 +1,12 @@ +#if TESTINGONLY +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared.TestingOnly; + +[Route("/testingonly/authn/token/get", ServiceOperation.Get, AccessType.Token, true)] +[UsedImplicitly] +public class AuthNTokenTestingOnlyRequest : IWebRequest +{ +} +#endif \ No newline at end of file diff --git a/src/Infrastructure.Web.Common.UnitTests/Extensions/ResponseProblemExtensionsSpec.cs b/src/Infrastructure.Web.Common.UnitTests/Extensions/ResponseProblemExtensionsSpec.cs index 691132a7..2d21f1b4 100644 --- a/src/Infrastructure.Web.Common.UnitTests/Extensions/ResponseProblemExtensionsSpec.cs +++ b/src/Infrastructure.Web.Common.UnitTests/Extensions/ResponseProblemExtensionsSpec.cs @@ -240,4 +240,22 @@ public void WhenToErrorAndHas409Status_ThenReturnsEntityExistsError() result.Should().BeError(ErrorCode.EntityExists, "atitle"); } + + [Fact] + public void WhenToResponseProblemAndReasonIsNull_ThenReturnsProblem() + { + var result = HttpStatusCode.InternalServerError.ToResponseProblem(null); + + result.Title.Should().Be(nameof(HttpStatusCode.InternalServerError)); + result.Status.Should().Be((int)HttpStatusCode.InternalServerError); + } + + [Fact] + public void WhenToResponseProblemAndHasReason_ThenReturnsProblem() + { + var result = HttpStatusCode.InternalServerError.ToResponseProblem("areason"); + + result.Title.Should().Be("areason"); + result.Status.Should().Be((int)HttpStatusCode.InternalServerError); + } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Common/Clients/JsonClient.cs b/src/Infrastructure.Web.Common/Clients/JsonClient.cs index 438621e2..33ae12d0 100644 --- a/src/Infrastructure.Web.Common/Clients/JsonClient.cs +++ b/src/Infrastructure.Web.Common/Clients/JsonClient.cs @@ -195,7 +195,12 @@ private static async Task> GetContentAsync> GetContentAsync + /// Converts the given to a + /// + public static ResponseProblem ToResponseProblem(this HttpStatusCode status, string? reason) + { + return new ResponseProblem + { + Title = reason ?? status.ToString(), + Status = (int)status + }; + } + /// /// Converts the given to a /// diff --git a/src/Infrastructure.Web.Hosting.Common.UnitTests/Auth/HMACAuthenticationHandlerSpec.cs b/src/Infrastructure.Web.Hosting.Common.UnitTests/Auth/HMACAuthenticationHandlerSpec.cs index 71163f9e..86aebdc5 100644 --- a/src/Infrastructure.Web.Hosting.Common.UnitTests/Auth/HMACAuthenticationHandlerSpec.cs +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/Auth/HMACAuthenticationHandlerSpec.cs @@ -1,11 +1,10 @@ -using System.Security.Claims; using System.Text.Encodings.Web; using Application.Interfaces; using Application.Interfaces.Services; using Common; using Common.Extensions; -using Domain.Common.Authorization; using Domain.Interfaces; +using Domain.Interfaces.Authorization; using FluentAssertions; using Infrastructure.Interfaces; using Infrastructure.Web.Api.Common; @@ -87,7 +86,7 @@ public async Task WhenHandleAuthenticateAsyncAndNoSignatureHeader_ThenReturnsFai [Fact] public async Task WhenHandleAuthenticateAsyncAndNoSecret_ThenReturnsAuthenticated() { - _httpContext.Request.Headers.Add(HttpHeaders.HmacSignature, "asignature"); + _httpContext.Request.Headers[HttpHeaders.HmacSignature] = "asignature"; _hostSettings.Setup(hs => hs.GetAncillaryApiHostHmacAuthSecret()).Returns(string.Empty); _httpContext.RequestServices = _serviceCollection.BuildServiceProvider(); await _handler.InitializeAsync(new AuthenticationScheme(HMACAuthenticationHandler.AuthenticationScheme, null, @@ -97,19 +96,19 @@ await _handler.InitializeAsync(new AuthenticationScheme(HMACAuthenticationHandle result.Succeeded.Should().BeTrue(); result.Ticket!.Principal.Claims.Should().Contain(claim => - claim.Type == ClaimTypes.NameIdentifier && claim.Value == CallerConstants.MaintenanceAccountUserId); + claim.Type == AuthenticationConstants.ClaimForId + && claim.Value == CallerConstants.MaintenanceAccountUserId); result.Ticket.Principal.Claims.Should().Contain(claim => - claim.Type == ClaimTypes.Role && claim.Value == UserRoles.ServiceAccount); + claim.Type == AuthenticationConstants.ClaimForRole && claim.Value == PlatformRoles.ServiceAccount); result.Ticket.Principal.Claims.Should().Contain(claim => - claim.Type == ClaimTypes.UserData && claim.Value == UserFeatureSets.Basic); - result.Ticket.Principal.Claims.Should().Contain(claim => - claim.Type == ClaimTypes.UserData && claim.Value == UserFeatureSets.Pro); + claim.Type == AuthenticationConstants.ClaimForFeatureLevel + && claim.Value == PlatformFeatureLevels.Basic.Name); } [Fact] public async Task WhenHandleAuthenticateAsyncAndWrongSignature_ThenReturnsFailure() { - _httpContext.Request.Headers.Add(HttpHeaders.HmacSignature, "asignature"); + _httpContext.Request.Headers[HttpHeaders.HmacSignature] = "asignature"; await _handler.InitializeAsync(new AuthenticationScheme(HMACAuthenticationHandler.AuthenticationScheme, null, typeof(HMACAuthenticationHandler)), _httpContext); @@ -128,7 +127,7 @@ public async Task WhenHandleAuthenticateAsyncAndSignatureMatches_ThenReturnsAuth { var body = new byte[] { 0x01 }; var signature = new HMACSigner(body, "asecret").Sign(); - _httpContext.Request.Headers.Add(HttpHeaders.HmacSignature, signature); + _httpContext.Request.Headers[HttpHeaders.HmacSignature] = signature; _httpContext.Request.Body = new MemoryStream(body); await _handler.InitializeAsync(new AuthenticationScheme(HMACAuthenticationHandler.AuthenticationScheme, null, typeof(HMACAuthenticationHandler)), _httpContext); @@ -137,13 +136,13 @@ await _handler.InitializeAsync(new AuthenticationScheme(HMACAuthenticationHandle result.Succeeded.Should().BeTrue(); result.Ticket!.Principal.Claims.Should().Contain(claim => - claim.Type == ClaimTypes.NameIdentifier && claim.Value == CallerConstants.MaintenanceAccountUserId); - result.Ticket.Principal.Claims.Should().Contain(claim => - claim.Type == ClaimTypes.Role && claim.Value == UserRoles.ServiceAccount); + claim.Type == AuthenticationConstants.ClaimForId + && claim.Value == CallerConstants.MaintenanceAccountUserId); result.Ticket.Principal.Claims.Should().Contain(claim => - claim.Type == ClaimTypes.UserData && claim.Value == UserFeatureSets.Basic); + claim.Type == AuthenticationConstants.ClaimForRole && claim.Value == PlatformRoles.ServiceAccount); result.Ticket.Principal.Claims.Should().Contain(claim => - claim.Type == ClaimTypes.UserData && claim.Value == UserFeatureSets.Pro); + claim.Type == AuthenticationConstants.ClaimForFeatureLevel + && claim.Value == PlatformFeatureLevels.Basic.Name); _recorder.Verify(rec => rec.Audit(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } diff --git a/src/Infrastructure.Web.Hosting.Common.UnitTests/SubDomainModulesSpec.cs b/src/Infrastructure.Web.Hosting.Common.UnitTests/SubDomainModulesSpec.cs index 037175ea..0f7855af 100644 --- a/src/Infrastructure.Web.Hosting.Common.UnitTests/SubDomainModulesSpec.cs +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/SubDomainModulesSpec.cs @@ -61,7 +61,7 @@ public void WhenRegisterAndNullMinimalApiRegistrationFunction_ThenThrows() AggregatePrefixes = new Dictionary(), ApiAssembly = typeof(SubDomainModulesSpec).Assembly, DomainAssembly = typeof(SubDomainModulesSpec).Assembly, - RegisterServicesFunction = (_, _) => { } + RegisterServices = (_, _) => { } })) .Should().Throw(); } @@ -74,8 +74,8 @@ public void WhenRegisterAndNullRegisterServicesFunction_ThenRegisters() AggregatePrefixes = new Dictionary(), ApiAssembly = typeof(SubDomainModulesSpec).Assembly, DomainAssembly = typeof(SubDomainModulesSpec).Assembly, - MinimalApiRegistrationFunction = _ => { }, - RegisterServicesFunction = null! + ConfigureMiddleware = _ => { }, + RegisterServices = null! }); _modules.ApiAssemblies.Should().ContainSingle(); @@ -101,8 +101,8 @@ public void WhenRegisterServices_ThenAppliedToAllModules() ApiAssembly = typeof(SubDomainModulesSpec).Assembly, DomainAssembly = typeof(SubDomainModulesSpec).Assembly, AggregatePrefixes = new Dictionary(), - MinimalApiRegistrationFunction = _ => { }, - RegisterServicesFunction = (_, _) => { wasCalled = true; } + ConfigureMiddleware = _ => { }, + RegisterServices = (_, _) => { wasCalled = true; } }); _modules.RegisterServices(configuration, services); @@ -128,8 +128,8 @@ public void WhenConfigureHost_ThenAppliedToAllModules() ApiAssembly = typeof(SubDomainModulesSpec).Assembly, DomainAssembly = typeof(SubDomainModulesSpec).Assembly, AggregatePrefixes = new Dictionary(), - MinimalApiRegistrationFunction = _ => { wasCalled = true; }, - RegisterServicesFunction = (_, _) => { } + ConfigureMiddleware = _ => { wasCalled = true; }, + RegisterServices = (_, _) => { } }); _modules.ConfigureHost(app); @@ -146,7 +146,7 @@ public class TestModule : ISubDomainModule public Dictionary AggregatePrefixes { get; init; } = null!; - public Action MinimalApiRegistrationFunction { get; init; } = null!; + public Action ConfigureMiddleware { get; init; } = null!; - public Action? RegisterServicesFunction { get; init; } + public Action? RegisterServices { get; init; } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common/AnonymousCallerContext.cs b/src/Infrastructure.Web.Hosting.Common/AnonymousCallerContext.cs index b3a4cf00..c157bd4b 100644 --- a/src/Infrastructure.Web.Hosting.Common/AnonymousCallerContext.cs +++ b/src/Infrastructure.Web.Hosting.Common/AnonymousCallerContext.cs @@ -1,7 +1,7 @@ using Application.Common; using Application.Interfaces; -using Domain.Common.Authorization; using Domain.Interfaces; +using Domain.Interfaces.Authorization; using Infrastructure.Web.Api.Common; using Microsoft.AspNetCore.Http; @@ -28,7 +28,7 @@ public AnonymousCallerContext(IHttpContextAccessor httpContext) public ICallerContext.CallerRoles Roles => new(); - public ICallerContext.CallerFeatureSets FeatureSets => new(new[] { UserFeatureSets.Basic }, null); + public ICallerContext.CallerFeatureLevels FeatureLevels => new(new[] { PlatformFeatureLevels.Basic }, null); public string? Authorization => null; diff --git a/src/Infrastructure.Web.Hosting.Common/Auth/AuthenticationConstants.cs b/src/Infrastructure.Web.Hosting.Common/Auth/AuthenticationConstants.cs index 97c2b068..6d9cbfd8 100644 --- a/src/Infrastructure.Web.Hosting.Common/Auth/AuthenticationConstants.cs +++ b/src/Infrastructure.Web.Hosting.Common/Auth/AuthenticationConstants.cs @@ -1,6 +1,12 @@ +using System.Security.Claims; + namespace Infrastructure.Web.Hosting.Common.Auth; public static class AuthenticationConstants { + public const string ClaimForFeatureLevel = "Feature"; + public const string ClaimForId = "sub"; + public const string ClaimForRole = ClaimTypes.Role; public const string HMACPolicyName = "HMAC"; + public const string TokenPolicyName = "Token"; } \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common/Auth/HMACAuthentication.cs b/src/Infrastructure.Web.Hosting.Common/Auth/HMACAuthentication.cs index eedc1f31..3e269667 100644 --- a/src/Infrastructure.Web.Hosting.Common/Auth/HMACAuthentication.cs +++ b/src/Infrastructure.Web.Hosting.Common/Auth/HMACAuthentication.cs @@ -5,8 +5,8 @@ using Application.Interfaces.Services; using Common; using Common.Extensions; -using Domain.Common.Authorization; using Domain.Interfaces; +using Domain.Interfaces.Authorization; using Infrastructure.Interfaces; using Infrastructure.Web.Api.Common; using Infrastructure.Web.Api.Common.Extensions; @@ -67,14 +67,7 @@ protected override async Task HandleAuthenticateAsync() AuthenticationTicket IssueTicket() { - var claims = new[] - { - new Claim(ClaimTypes.NameIdentifier, CallerConstants.MaintenanceAccountUserId), - new Claim(ClaimTypes.Role, UserRoles.ServiceAccount), - new Claim(ClaimTypes.UserData, UserFeatureSets.Basic), - new Claim(ClaimTypes.UserData, UserFeatureSets.Pro) - }; - var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name)); + var principal = new ClaimsPrincipal(new ClaimsIdentity(GetClaimsForServiceAccount(), Scheme.Name)); return new AuthenticationTicket(principal, Scheme.Name) { Properties = @@ -85,6 +78,16 @@ AuthenticationTicket IssueTicket() }; } } + + private static Claim[] GetClaimsForServiceAccount() + { + return new[] + { + new Claim(AuthenticationConstants.ClaimForId, CallerConstants.MaintenanceAccountUserId), + new Claim(AuthenticationConstants.ClaimForRole, PlatformRoles.ServiceAccount), + new Claim(AuthenticationConstants.ClaimForFeatureLevel, PlatformFeatureLevels.Basic.Name) + }; + } } /// diff --git a/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs b/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs index 0afa83ae..0927d059 100644 --- a/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs +++ b/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Application.Interfaces; @@ -6,9 +7,9 @@ using Common.Configuration; using Common.Extensions; using Domain.Common; -using Domain.Common.Authorization; using Domain.Common.Identity; using Domain.Interfaces; +using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; using Domain.Interfaces.Services; using Infrastructure.Common; @@ -26,10 +27,13 @@ using Infrastructure.Web.Api.Interfaces; using Infrastructure.Web.Hosting.Common.ApplicationServices; using Infrastructure.Web.Hosting.Common.Auth; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; #if TESTINGONLY using Infrastructure.Persistence.Interfaces.ApplicationServices; using Infrastructure.Web.Api.Operations.Shared.Ancillary; @@ -71,7 +75,7 @@ public static WebApplication ConfigureApiHost(this WebApplicationBuilder appBuil ConfigureConfiguration(hostOptions.IsMultiTenanted); ConfigureRecording(); ConfigureMultiTenancy(hostOptions.IsMultiTenanted); - ConfigureAuthenticationAuthorization(hostOptions.UsesAuth); + ConfigureAuthenticationAuthorization(hostOptions.Authentication); ConfigureWireFormats(); ConfigureApiRequests(); ConfigureApplicationServices(); @@ -83,11 +87,11 @@ public static WebApplication ConfigureApiHost(this WebApplicationBuilder appBuil app.EnableOtherOptions(hostOptions); app.EnableRequestRewind(); app.AddExceptionShielding(); - //TODO: app.AddMultiTenancyDetection(); we need a TenantDetective + app.EnableMultiTenancy(hostOptions.IsMultiTenanted); app.EnableEventingListeners(hostOptions.Persistence.UsesEventing); app.EnableApiUsageTracking(hostOptions.TrackApiUsage); app.EnableCORS(hostOptions.CORS); - app.EnableSecureAccess(hostOptions.UsesAuth); //Note: AuthN must be registered after CORS + app.EnableSecureAccess(hostOptions.Authentication); //Note: AuthN must be registered after CORS modules.ConfigureHost(app); @@ -175,26 +179,91 @@ void ConfigureMultiTenancy(bool isMultiTenanted) } } - void ConfigureAuthenticationAuthorization(bool usesAuth) + void ConfigureAuthenticationAuthorization(AuthenticationOptions authentication) { - if (!usesAuth) + if (authentication is { VerifiesHMAC: false, UsesCookies: false, UsesTokens: AuthTokenOptions.None }) { return; } - AppContext.SetSwitch("Microsoft.AspNetCore.Authentication.SuppressAutoDefaultScheme", true); - appBuilder.Services.AddAuthentication() - .AddScheme(HMACAuthenticationHandler.AuthenticationScheme, + var defaultScheme = string.Empty; + if (authentication.UsesCookies) + { + defaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + } + + if (authentication.UsesTokens == AuthTokenOptions.Verifies) + { + defaultScheme = JwtBearerDefaults.AuthenticationScheme; + } + + if (authentication is { VerifiesHMAC: true, UsesCookies: false, UsesTokens: AuthTokenOptions.None }) + { + AppContext.SetSwitch("Microsoft.AspNetCore.Authentication.SuppressAutoDefaultScheme", true); + } + + var authBuilder = defaultScheme.HasValue() + ? appBuilder.Services.AddAuthentication(defaultScheme) + : appBuilder.Services.AddAuthentication(); + + if (authentication.VerifiesHMAC) + { + authBuilder.AddScheme( + HMACAuthenticationHandler.AuthenticationScheme, _ => { }); - appBuilder.Services.AddAuthorization(configure => + appBuilder.Services.AddAuthorization(configure => + { + configure.AddPolicy(AuthenticationConstants.HMACPolicyName, builder => + { + builder.AddAuthenticationSchemes(HMACAuthenticationHandler.AuthenticationScheme); + builder.RequireAuthenticatedUser(); + builder.RequireRole(PlatformRoles.ServiceAccount); + }); + }); + } + + if (authentication.UsesCookies) { - configure.AddPolicy(AuthenticationConstants.HMACPolicyName, builder => + //TODO: Is this how we are going to reverse proxy the cookie? + //TODO: What about the API to relay logins requests to the backend, and manage refresh etc? + // https://auth0.com/blog/building-a-reverse-proxy-in-dot-net-core/ + authBuilder.AddCookie(cookieOptions => { - builder.AddAuthenticationSchemes(HMACAuthenticationHandler.AuthenticationScheme); - builder.RequireAuthenticatedUser(); - builder.RequireRole(UserRoles.ServiceAccount); + cookieOptions.LoginPath = "/api/user/login"; + cookieOptions.LogoutPath = "/api/user/logout"; }); - }); + } + + if (authentication.UsesTokens == AuthTokenOptions.Verifies) + { + var configuration = appBuilder.Configuration; + authBuilder.AddJwtBearer(jwtOptions => + { + jwtOptions.RequireHttpsMetadata = true; + jwtOptions.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidAudience = configuration["Hosts:IdentityApi:BaseUrl"], + ValidIssuer = configuration["Hosts:IdentityApi:BaseUrl"], + ValidateIssuerSigningKey = true, + IssuerSigningKey = + new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(configuration["Hosts:IdentityApi:JWT:SigningSecret"]!)) + }; + }); + appBuilder.Services.AddAuthorization(configure => + { + configure.AddPolicy(AuthenticationConstants.TokenPolicyName, builder => + { + builder.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme); + builder.RequireAuthenticatedUser(); + }); + }); + } + + appBuilder.Services.AddAuthorization(); } void ConfigureApiRequests() diff --git a/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs b/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs index 3e8fc724..b6fc1e69 100644 --- a/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs +++ b/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs @@ -155,6 +155,17 @@ public static IApplicationBuilder EnableEventingListeners(this WebApplication ap }); } + /// + /// Enables tenant detection + /// + public static IApplicationBuilder EnableMultiTenancy(this WebApplication app, bool isEnabled) + { + app.Logger.LogInformation(""); + //TODO: app.AddMultiTenancyDetection(); we need a TenantDetective + + return app; + } + /// /// Enables other options /// @@ -214,17 +225,35 @@ public static IApplicationBuilder EnableRequestRewind(this WebApplication app) /// /// Enables authentication and authorization /// - public static IApplicationBuilder EnableSecureAccess(this WebApplication app, bool usesAuth) + public static IApplicationBuilder EnableSecureAccess(this WebApplication app, AuthenticationOptions authentication) { - if (!usesAuth) + if (authentication is { VerifiesHMAC: false, UsesCookies: false, UsesTokens: AuthTokenOptions.None }) { return app; } - app.Logger.LogInformation("Authentication is enabled"); - app.Logger.LogInformation("RBAC Authorization is enabled"); - return app.UseAuthentication() - .UseAuthorization(); + if (authentication.UsesCookies) + { + app.Logger.LogInformation("Authentication using Cookies is enabled"); + } + + if (authentication.VerifiesHMAC) + { + app.Logger.LogInformation("Authentication using HMAC signatures is enabled"); + } + + if (authentication.UsesTokens != AuthTokenOptions.None) + { + app.Logger.LogInformation("Authentication using JWT tokens is enabled"); + } + + app.UseAuthentication(); + + app.Logger.LogInformation("Authorization using RoleBasedAccessControl is enabled"); + app.Logger.LogInformation("Authorization using FeatureLevelAccessControl is enabled"); + app.UseAuthorization(); + + return app; } private static void TrackUsage(HttpContext httpContext, IRecorder recorder, ICallerContext caller) diff --git a/src/Infrastructure.Web.Hosting.Common/ISubDomainModule.cs b/src/Infrastructure.Web.Hosting.Common/ISubDomainModule.cs index 53396992..448f4a9b 100644 --- a/src/Infrastructure.Web.Hosting.Common/ISubDomainModule.cs +++ b/src/Infrastructure.Web.Hosting.Common/ISubDomainModule.cs @@ -21,17 +21,17 @@ public interface ISubDomainModule Assembly ApiAssembly { get; } /// - /// Returns the assembly containing the DDD domain types + /// Returns a function that handles the minimal API registration /// - Assembly? DomainAssembly { get; } + Action ConfigureMiddleware { get; } /// - /// Returns a function that handles the minimal API registration + /// Returns the assembly containing the DDD domain types /// - Action MinimalApiRegistrationFunction { get; } + Assembly? DomainAssembly { get; } /// /// Returns a function for using to register additional dependencies for this module /// - Action? RegisterServicesFunction { get; } + Action? RegisterServices { get; } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common/SubDomainModules.cs b/src/Infrastructure.Web.Hosting.Common/SubDomainModules.cs index 57463ec0..520e513e 100644 --- a/src/Infrastructure.Web.Hosting.Common/SubDomainModules.cs +++ b/src/Infrastructure.Web.Hosting.Common/SubDomainModules.cs @@ -33,8 +33,8 @@ public void Register(ISubDomainModule module) ArgumentNullException.ThrowIfNull(module); ArgumentNullException.ThrowIfNull(module.ApiAssembly, nameof(module.ApiAssembly)); ArgumentNullException.ThrowIfNull(module.ApiAssembly, nameof(module.AggregatePrefixes)); - ArgumentNullException.ThrowIfNull(module.MinimalApiRegistrationFunction, - nameof(module.MinimalApiRegistrationFunction)); + ArgumentNullException.ThrowIfNull(module.ConfigureMiddleware, + nameof(module.ConfigureMiddleware)); _apiAssemblies.Add(module.ApiAssembly); if (module.DomainAssembly.Exists()) @@ -43,10 +43,10 @@ public void Register(ISubDomainModule module) } _aggregatePrefixes.Merge(module.AggregatePrefixes); - _minimalApiRegistrationFunctions.Add(module.MinimalApiRegistrationFunction); - if (module.RegisterServicesFunction is not null) + _minimalApiRegistrationFunctions.Add(module.ConfigureMiddleware); + if (module.RegisterServices is not null) { - _serviceCollectionFunctions.Add(module.RegisterServicesFunction); + _serviceCollectionFunctions.Add(module.RegisterServices); } } diff --git a/src/Infrastructure.Web.Hosting.Common/WebHostOptions.cs b/src/Infrastructure.Web.Hosting.Common/WebHostOptions.cs index d5df5ec4..e14f6598 100644 --- a/src/Infrastructure.Web.Hosting.Common/WebHostOptions.cs +++ b/src/Infrastructure.Web.Hosting.Common/WebHostOptions.cs @@ -11,40 +11,61 @@ public class WebHostOptions : HostOptions { CORS = CORSOption.AnyOrigin, TrackApiUsage = true, - UsesAuth = true + Authentication = new AuthenticationOptions + { + UsesCookies = false, + UsesTokens = AuthTokenOptions.Verifies, + VerifiesHMAC = true + } }; public new static readonly WebHostOptions BackEndApiHost = new(HostOptions.BackEndApiHost) { CORS = CORSOption.AnyOrigin, TrackApiUsage = true, - UsesAuth = true + Authentication = new AuthenticationOptions + { + UsesCookies = false, + UsesTokens = AuthTokenOptions.Verifies, + VerifiesHMAC = true + } }; public new static readonly WebHostOptions BackEndForFrontEndWebHost = new(HostOptions.BackEndForFrontEndWebHost) { CORS = CORSOption.SameOrigin, TrackApiUsage = false, - UsesAuth = false + Authentication = new AuthenticationOptions + { + UsesCookies = true, + UsesTokens = AuthTokenOptions.None, + VerifiesHMAC = false + } }; public new static readonly WebHostOptions TestingStubsHost = new(HostOptions.TestingStubsHost) { CORS = CORSOption.AnyOrigin, TrackApiUsage = false, - UsesAuth = false + Authentication = new AuthenticationOptions + { + UsesCookies = false, + UsesTokens = AuthTokenOptions.None, + VerifiesHMAC = false + } }; private WebHostOptions(HostOptions options) : base(options) { CORS = CORSOption.None; TrackApiUsage = false; + Authentication = new AuthenticationOptions(); } + public AuthenticationOptions Authentication { get; private init; } + public CORSOption CORS { get; private init; } public bool TrackApiUsage { get; private set; } - - public bool UsesAuth { get; private init; } } /// @@ -55,4 +76,25 @@ public enum CORSOption None = 0, SameOrigin = 1, AnyOrigin = 2 +} + +/// +/// Defines options for handling authentication and authorization +/// +public class AuthenticationOptions +{ + public bool UsesCookies { get; set; } + + public AuthTokenOptions UsesTokens { get; set; } = AuthTokenOptions.None; + + public bool VerifiesHMAC { get; set; } +} + +/// +/// Defines a primary authentication scheme +/// +public enum AuthTokenOptions +{ + None = 0, + Verifies = 1 } \ No newline at end of file diff --git a/src/Infrastructure.Web.Website.IntegrationTests/AuthNApiSpec.cs b/src/Infrastructure.Web.Website.IntegrationTests/AuthNApiSpec.cs new file mode 100644 index 00000000..29525b6b --- /dev/null +++ b/src/Infrastructure.Web.Website.IntegrationTests/AuthNApiSpec.cs @@ -0,0 +1,17 @@ +#if TESTINGONLY +using IntegrationTesting.WebApi.Common; +using WebsiteHost; +using Xunit; + +namespace Infrastructure.Web.Website.IntegrationTests; + +[Trait("Category", "Integration.Web")] +public class AuthNApiSpec : WebApiSpec +{ + public AuthNApiSpec(WebApiSetup setup) : base(setup) + { + } + + //TODO: tests to check cookie authenticated endpoints +} +#endif \ No newline at end of file diff --git a/src/Infrastructure.Web.Website.IntegrationTests/HealthCheckApiSpec.cs b/src/Infrastructure.Web.Website.IntegrationTests/HealthCheckApiSpec.cs index 76b40807..773188c8 100644 --- a/src/Infrastructure.Web.Website.IntegrationTests/HealthCheckApiSpec.cs +++ b/src/Infrastructure.Web.Website.IntegrationTests/HealthCheckApiSpec.cs @@ -6,7 +6,7 @@ namespace Infrastructure.Web.Website.IntegrationTests; -[Trait("Category", "Unit")] +[Trait("Category", "Integration.Web")] public class HealthCheckApiSpec : WebApiSpec { public HealthCheckApiSpec(WebApiSetup setup) : base(setup) diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdasApiSpec.cs b/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdasApiSpec.cs index 1ced0319..309f9586 100644 --- a/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdasApiSpec.cs +++ b/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdasApiSpec.cs @@ -1,4 +1,4 @@ -using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Common.Extensions; using FluentAssertions; using Infrastructure.Web.Api.Operations.Shared.Ancillary; diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionsApiSpec.cs b/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionsApiSpec.cs index e12d5dd1..8253a3d9 100644 --- a/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionsApiSpec.cs +++ b/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionsApiSpec.cs @@ -1,4 +1,4 @@ -using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Common.Extensions; using FluentAssertions; using Infrastructure.Web.Api.Operations.Shared.Ancillary; diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/DeliverUsageSpecBase.cs b/src/Infrastructure.Worker.Api.IntegrationTests/DeliverUsageSpecBase.cs index 53e6207f..f0fc15e5 100644 --- a/src/Infrastructure.Worker.Api.IntegrationTests/DeliverUsageSpecBase.cs +++ b/src/Infrastructure.Worker.Api.IntegrationTests/DeliverUsageSpecBase.cs @@ -1,4 +1,4 @@ -using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using FluentAssertions; using Infrastructure.Web.Api.Operations.Shared.Ancillary; using Infrastructure.Web.Interfaces.Clients; diff --git a/src/Infrastructure.Workers.Api/Workers/DeliverAuditRelayWorker.cs b/src/Infrastructure.Workers.Api/Workers/DeliverAuditRelayWorker.cs index 926c7dca..b8a1fb03 100644 --- a/src/Infrastructure.Workers.Api/Workers/DeliverAuditRelayWorker.cs +++ b/src/Infrastructure.Workers.Api/Workers/DeliverAuditRelayWorker.cs @@ -1,5 +1,5 @@ using Application.Interfaces.Services; -using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Common; using Common.Extensions; using Infrastructure.Web.Api.Operations.Shared.Ancillary; diff --git a/src/Infrastructure.Workers.Api/Workers/DeliverUsageRelayWorker.cs b/src/Infrastructure.Workers.Api/Workers/DeliverUsageRelayWorker.cs index 789594f6..59f6662c 100644 --- a/src/Infrastructure.Workers.Api/Workers/DeliverUsageRelayWorker.cs +++ b/src/Infrastructure.Workers.Api/Workers/DeliverUsageRelayWorker.cs @@ -1,5 +1,5 @@ using Application.Interfaces.Services; -using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; using Common; using Common.Extensions; using Infrastructure.Web.Api.Operations.Shared.Ancillary; diff --git a/src/IntegrationTesting.WebApi.Common/IntegrationTesting.WebApi.Common.csproj b/src/IntegrationTesting.WebApi.Common/IntegrationTesting.WebApi.Common.csproj index 2e0e04b0..6ceaaa09 100644 --- a/src/IntegrationTesting.WebApi.Common/IntegrationTesting.WebApi.Common.csproj +++ b/src/IntegrationTesting.WebApi.Common/IntegrationTesting.WebApi.Common.csproj @@ -6,6 +6,7 @@ + diff --git a/src/IntegrationTesting.WebApi.Common/Stubs/StubNotificationsService.cs b/src/IntegrationTesting.WebApi.Common/Stubs/StubNotificationsService.cs new file mode 100644 index 00000000..41c4e93d --- /dev/null +++ b/src/IntegrationTesting.WebApi.Common/Stubs/StubNotificationsService.cs @@ -0,0 +1,51 @@ +using Application.Interfaces; +using Application.Services.Shared; +using Common; + +namespace IntegrationTesting.WebApi.Common.Stubs; + +/// +/// Provides a stub for testing +/// +public class StubNotificationsService : INotificationsService +{ + public string? LastEmailChangeConfirmationToken { get; private set; } + + public string? LastEmailChangeRecipient { get; private set; } + + public string? LastGuestInvitationToken { get; private set; } + + public string? LastPasswordResetCourtesyEmailRecipient { get; private set; } + + public string? LastPasswordResetEmailRecipient { get; private set; } + + public string? LastPasswordResetToken { get; private set; } + + public string? LastRegistrationConfirmationEmailRecipient { get; private set; } + + public string? LastRegistrationConfirmationToken { get; private set; } + + public string? LastReRegistrationCourtesyEmailRecipient { get; private set; } + + public Task> NotifyPasswordRegistrationConfirmationAsync(ICallerContext caller, string emailAddress, + string name, string token, + CancellationToken cancellationToken) + { + LastRegistrationConfirmationEmailRecipient = emailAddress; + LastRegistrationConfirmationToken = token; + return Task.FromResult(Result.Ok); + } + + public void Reset() + { + LastRegistrationConfirmationEmailRecipient = null; + LastEmailChangeRecipient = null; + LastPasswordResetEmailRecipient = null; + LastPasswordResetCourtesyEmailRecipient = null; + LastReRegistrationCourtesyEmailRecipient = null; + LastRegistrationConfirmationToken = null; + LastEmailChangeConfirmationToken = null; + LastGuestInvitationToken = null; + LastPasswordResetToken = null; + } +} \ No newline at end of file diff --git a/src/SaaStack.sln b/src/SaaStack.sln index ecdbfe4f..1d8ece6d 100644 --- a/src/SaaStack.sln +++ b/src/SaaStack.sln @@ -38,9 +38,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Ancillary", "Ancillary", "{ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Bookings", "Bookings", "{8BD4E8A7-95BE-43EA-8627-A002D440DFDF}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AuthN", "AuthN", "{8B850C5F-D5AB-4992-B343-6501A70ED801}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Workers", "Workers", "{FE0D2E76-EFF3-4CD4-917A-19CE35874F32}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identities", "Identities", "{8B850C5F-D5AB-4992-B343-6501A70ED801}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Images", "Images", "{ABB5758C-648C-4B18-B261-67510E696545}" EndProject @@ -256,6 +254,32 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Persistence. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AWSLambdas.Api.WorkerHost", "AWSLambdas.Api.WorkerHost\AWSLambdas.Api.WorkerHost.csproj", "{1734A5D1-B2C8-4107-9DAA-E3F99F49ABEC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityInfrastructure", "IdentityInfrastructure\IdentityInfrastructure.csproj", "{5630C518-92EB-482E-A547-99E80FBBD34D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityApplication", "IdentityApplication\IdentityApplication.csproj", "{A07C0093-5681-447E-BBF7-A0A5C958F14B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityDomain", "IdentityDomain\IdentityDomain.csproj", "{664F1BF8-70F9-4BD8-98BD-632F92F67F81}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{145BE8AD-60D7-46CF-A93B-DB76707A0767}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityInfrastructure.UnitTests", "IdentityInfrastructure.UnitTests\IdentityInfrastructure.UnitTests.csproj", "{FEAC1046-C925-480D-99D8-B5E291414D12}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityInfrastructure.IntegrationTests", "IdentityInfrastructure.IntegrationTests\IdentityInfrastructure.IntegrationTests.csproj", "{F4FC8EBF-22F5-4E2D-A1BE-6192CEF5FF78}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityApplication.UnitTests", "IdentityApplication.UnitTests\IdentityApplication.UnitTests.csproj", "{FB36DEBC-4F55-4B59-B3A3-721293D28325}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityDomain.UnitTests", "IdentityDomain.UnitTests\IdentityDomain.UnitTests.csproj", "{FC2206B5-C7DF-4EA4-A73A-F643DE720B00}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Shared", "Infrastructure.Shared\Infrastructure.Shared.csproj", "{E6B54671-823D-47AC-8BE8-534C6E602AF2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain.Services.Shared", "Domain.Services.Shared\Domain.Services.Shared.csproj", "{608EB36D-3831-40D8-BC2E-778D30DB7D05}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Shared.UnitTests", "Infrastructure.Shared.UnitTests\Infrastructure.Shared.UnitTests.csproj", "{4BC73E55-18BB-40BC-B62D-0092ACAEA662}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EndUsersInfrastructure", "EndUsersInfrastructure\EndUsersInfrastructure.csproj", "{84C0AEA1-662D-423A-9088-1C86F00C02F7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EndUsersApplication", "EndUsersApplication\EndUsersApplication.csproj", "{BE5E132A-AADE-4192-A56D-9F1C4AD6E338}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -773,6 +797,78 @@ Global {1734A5D1-B2C8-4107-9DAA-E3F99F49ABEC}.Release|Any CPU.Build.0 = Release|Any CPU {1734A5D1-B2C8-4107-9DAA-E3F99F49ABEC}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU {1734A5D1-B2C8-4107-9DAA-E3F99F49ABEC}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {5630C518-92EB-482E-A547-99E80FBBD34D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5630C518-92EB-482E-A547-99E80FBBD34D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5630C518-92EB-482E-A547-99E80FBBD34D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5630C518-92EB-482E-A547-99E80FBBD34D}.Release|Any CPU.Build.0 = Release|Any CPU + {5630C518-92EB-482E-A547-99E80FBBD34D}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {5630C518-92EB-482E-A547-99E80FBBD34D}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {A07C0093-5681-447E-BBF7-A0A5C958F14B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A07C0093-5681-447E-BBF7-A0A5C958F14B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A07C0093-5681-447E-BBF7-A0A5C958F14B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A07C0093-5681-447E-BBF7-A0A5C958F14B}.Release|Any CPU.Build.0 = Release|Any CPU + {A07C0093-5681-447E-BBF7-A0A5C958F14B}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {A07C0093-5681-447E-BBF7-A0A5C958F14B}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {664F1BF8-70F9-4BD8-98BD-632F92F67F81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {664F1BF8-70F9-4BD8-98BD-632F92F67F81}.Debug|Any CPU.Build.0 = Debug|Any CPU + {664F1BF8-70F9-4BD8-98BD-632F92F67F81}.Release|Any CPU.ActiveCfg = Release|Any CPU + {664F1BF8-70F9-4BD8-98BD-632F92F67F81}.Release|Any CPU.Build.0 = Release|Any CPU + {664F1BF8-70F9-4BD8-98BD-632F92F67F81}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {664F1BF8-70F9-4BD8-98BD-632F92F67F81}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {FEAC1046-C925-480D-99D8-B5E291414D12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FEAC1046-C925-480D-99D8-B5E291414D12}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FEAC1046-C925-480D-99D8-B5E291414D12}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FEAC1046-C925-480D-99D8-B5E291414D12}.Release|Any CPU.Build.0 = Release|Any CPU + {FEAC1046-C925-480D-99D8-B5E291414D12}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {FEAC1046-C925-480D-99D8-B5E291414D12}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {F4FC8EBF-22F5-4E2D-A1BE-6192CEF5FF78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F4FC8EBF-22F5-4E2D-A1BE-6192CEF5FF78}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4FC8EBF-22F5-4E2D-A1BE-6192CEF5FF78}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F4FC8EBF-22F5-4E2D-A1BE-6192CEF5FF78}.Release|Any CPU.Build.0 = Release|Any CPU + {F4FC8EBF-22F5-4E2D-A1BE-6192CEF5FF78}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {F4FC8EBF-22F5-4E2D-A1BE-6192CEF5FF78}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {FB36DEBC-4F55-4B59-B3A3-721293D28325}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB36DEBC-4F55-4B59-B3A3-721293D28325}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB36DEBC-4F55-4B59-B3A3-721293D28325}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB36DEBC-4F55-4B59-B3A3-721293D28325}.Release|Any CPU.Build.0 = Release|Any CPU + {FB36DEBC-4F55-4B59-B3A3-721293D28325}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {FB36DEBC-4F55-4B59-B3A3-721293D28325}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {FC2206B5-C7DF-4EA4-A73A-F643DE720B00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC2206B5-C7DF-4EA4-A73A-F643DE720B00}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC2206B5-C7DF-4EA4-A73A-F643DE720B00}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC2206B5-C7DF-4EA4-A73A-F643DE720B00}.Release|Any CPU.Build.0 = Release|Any CPU + {FC2206B5-C7DF-4EA4-A73A-F643DE720B00}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {FC2206B5-C7DF-4EA4-A73A-F643DE720B00}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {E6B54671-823D-47AC-8BE8-534C6E602AF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6B54671-823D-47AC-8BE8-534C6E602AF2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6B54671-823D-47AC-8BE8-534C6E602AF2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6B54671-823D-47AC-8BE8-534C6E602AF2}.Release|Any CPU.Build.0 = Release|Any CPU + {E6B54671-823D-47AC-8BE8-534C6E602AF2}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {E6B54671-823D-47AC-8BE8-534C6E602AF2}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {608EB36D-3831-40D8-BC2E-778D30DB7D05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {608EB36D-3831-40D8-BC2E-778D30DB7D05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {608EB36D-3831-40D8-BC2E-778D30DB7D05}.Release|Any CPU.ActiveCfg = Release|Any CPU + {608EB36D-3831-40D8-BC2E-778D30DB7D05}.Release|Any CPU.Build.0 = Release|Any CPU + {608EB36D-3831-40D8-BC2E-778D30DB7D05}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {608EB36D-3831-40D8-BC2E-778D30DB7D05}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {4BC73E55-18BB-40BC-B62D-0092ACAEA662}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BC73E55-18BB-40BC-B62D-0092ACAEA662}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4BC73E55-18BB-40BC-B62D-0092ACAEA662}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BC73E55-18BB-40BC-B62D-0092ACAEA662}.Release|Any CPU.Build.0 = Release|Any CPU + {4BC73E55-18BB-40BC-B62D-0092ACAEA662}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {4BC73E55-18BB-40BC-B62D-0092ACAEA662}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {84C0AEA1-662D-423A-9088-1C86F00C02F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {84C0AEA1-662D-423A-9088-1C86F00C02F7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {84C0AEA1-662D-423A-9088-1C86F00C02F7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {84C0AEA1-662D-423A-9088-1C86F00C02F7}.Release|Any CPU.Build.0 = Release|Any CPU + {84C0AEA1-662D-423A-9088-1C86F00C02F7}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {84C0AEA1-662D-423A-9088-1C86F00C02F7}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {BE5E132A-AADE-4192-A56D-9F1C4AD6E338}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE5E132A-AADE-4192-A56D-9F1C4AD6E338}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE5E132A-AADE-4192-A56D-9F1C4AD6E338}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE5E132A-AADE-4192-A56D-9F1C4AD6E338}.Release|Any CPU.Build.0 = Release|Any CPU + {BE5E132A-AADE-4192-A56D-9F1C4AD6E338}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {BE5E132A-AADE-4192-A56D-9F1C4AD6E338}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {F5C77A86-38AF-40E4-82FC-617E624B2754} = {508E7DA4-4DF2-4201-955D-CCF70C41AD05} @@ -818,7 +914,6 @@ Global {8BD4E8A7-95BE-43EA-8627-A002D440DFDF} = {33E2D4C7-525A-41CE-858C-F6A944160618} {57FDFB31-D6B6-4369-A78C-6F3D3AEA0D79} = {33E2D4C7-525A-41CE-858C-F6A944160618} {3D4737A4-7C63-428F-946A-9D0C091CEEF9} = {864DED88-9252-46EB-9D13-00269C7333F9} - {FE0D2E76-EFF3-4CD4-917A-19CE35874F32} = {3D4737A4-7C63-428F-946A-9D0C091CEEF9} {124D8FF5-43D1-4019-B07C-7F55DC4A1807} = {3D4737A4-7C63-428F-946A-9D0C091CEEF9} {8BB22358-7F43-462F-B26E-D83B9A4711CC} = {3D4737A4-7C63-428F-946A-9D0C091CEEF9} {8B850C5F-D5AB-4992-B343-6501A70ED801} = {3D4737A4-7C63-428F-946A-9D0C091CEEF9} @@ -895,5 +990,18 @@ Global {11F60901-1E1C-4B1B-83E8-261269D2681B} = {19ADDB2F-B589-49EF-9BDA-BD9908057D60} {5DD98C48-F081-4CD1-9F01-1FF19323FC1E} = {3782A767-2274-4F44-80C6-D6C6EEB9C9A5} {1734A5D1-B2C8-4107-9DAA-E3F99F49ABEC} = {4B1A213C-36A7-41A7-BFC7-B3CFF5795912} + {5630C518-92EB-482E-A547-99E80FBBD34D} = {8B850C5F-D5AB-4992-B343-6501A70ED801} + {A07C0093-5681-447E-BBF7-A0A5C958F14B} = {8B850C5F-D5AB-4992-B343-6501A70ED801} + {664F1BF8-70F9-4BD8-98BD-632F92F67F81} = {8B850C5F-D5AB-4992-B343-6501A70ED801} + {145BE8AD-60D7-46CF-A93B-DB76707A0767} = {8B850C5F-D5AB-4992-B343-6501A70ED801} + {FEAC1046-C925-480D-99D8-B5E291414D12} = {145BE8AD-60D7-46CF-A93B-DB76707A0767} + {F4FC8EBF-22F5-4E2D-A1BE-6192CEF5FF78} = {145BE8AD-60D7-46CF-A93B-DB76707A0767} + {FB36DEBC-4F55-4B59-B3A3-721293D28325} = {145BE8AD-60D7-46CF-A93B-DB76707A0767} + {FC2206B5-C7DF-4EA4-A73A-F643DE720B00} = {145BE8AD-60D7-46CF-A93B-DB76707A0767} + {E6B54671-823D-47AC-8BE8-534C6E602AF2} = {3782A767-2274-4F44-80C6-D6C6EEB9C9A5} + {608EB36D-3831-40D8-BC2E-778D30DB7D05} = {3782A767-2274-4F44-80C6-D6C6EEB9C9A5} + {4BC73E55-18BB-40BC-B62D-0092ACAEA662} = {9B6B0235-BD3F-4604-8E93-B0112A241C63} + {84C0AEA1-662D-423A-9088-1C86F00C02F7} = {806F1A7A-5D96-44ED-A9D9-C61660DD5488} + {BE5E132A-AADE-4192-A56D-9F1C4AD6E338} = {806F1A7A-5D96-44ED-A9D9-C61660DD5488} EndGlobalSection EndGlobal diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index c253eba1..5483933b 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -8,6 +8,7 @@ WARNING WARNING DO_NOT_SHOW + HINT DO_NOT_SHOW WARNING WARNING @@ -17,13 +18,16 @@ Required Required Required + 1 0 + 1 1 1 1 1 1 1 + True True False True @@ -307,11 +311,17 @@ </TypePattern> </Patterns> AWSSQS + AWSSQS CORS AWS HMAC + IANA + MFA + WT <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="Configure" Suffix="" Style="AaBb" /></Policy> <Policy Inspect="True" Prefix="When" Suffix="" Style="AaBb_AaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb" /><ExtraRule Prefix="Setup" Suffix="" Style="AaBb" /><ExtraRule Prefix="Configure" Suffix="" Style="AaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="Configure" Suffix="" Style="AaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> True True True @@ -774,27 +784,37 @@ public void When$condition$_Then$outcome$() True True True + True + True True True True True True True + True True + True + True True True True + True True True True + True True True True True True True + True True + True True + True True True True @@ -812,19 +832,25 @@ public void When$condition$_Then$outcome$() True True True + True True True True + True True + True True True True True True + True True + True True True True + True True True True @@ -837,6 +863,9 @@ public void When$condition$_Then$outcome$() True True True + True + True + True True True True @@ -847,16 +876,20 @@ public void When$condition$_Then$outcome$() True True True + True True True True True + True True True True True True + True True + True True True True @@ -868,12 +901,21 @@ public void When$condition$_Then$outcome$() True True True + True True + True True True + True + True True True + True + True + True + True True + True True True True @@ -888,9 +930,16 @@ public void When$condition$_Then$outcome$() True True True + True + True True True + True + True + True True + True + True True True True diff --git a/src/TestingStubApiHost/StubApiModule.cs b/src/TestingStubApiHost/StubApiModule.cs index 5bad10f7..e8050332 100644 --- a/src/TestingStubApiHost/StubApiModule.cs +++ b/src/TestingStubApiHost/StubApiModule.cs @@ -13,12 +13,7 @@ public class StubApiModule : ISubDomainModule public Dictionary AggregatePrefixes => new(); - public Action MinimalApiRegistrationFunction - { - get { return app => app.RegisterRoutes(); } - } - - public Action RegisterServicesFunction + public Action RegisterServices { get { @@ -40,5 +35,10 @@ public Action RegisterServicesFunction }; } } + + public Action ConfigureMiddleware + { + get { return app => app.RegisterRoutes(); } + } } #endif \ No newline at end of file diff --git a/src/Tools.Analyzers.Platform/Tools.Analyzers.Platform.csproj b/src/Tools.Analyzers.Platform/Tools.Analyzers.Platform.csproj index d1cf1ace..4ed2c2d1 100644 --- a/src/Tools.Analyzers.Platform/Tools.Analyzers.Platform.csproj +++ b/src/Tools.Analyzers.Platform/Tools.Analyzers.Platform.csproj @@ -1,7 +1,7 @@ - .net7.0 + net7.0 true true true diff --git a/src/Tools.Analyzers.Subdomain/Tools.Analyzers.Subdomain.csproj b/src/Tools.Analyzers.Subdomain/Tools.Analyzers.Subdomain.csproj index d011ec08..e3a862c8 100644 --- a/src/Tools.Analyzers.Subdomain/Tools.Analyzers.Subdomain.csproj +++ b/src/Tools.Analyzers.Subdomain/Tools.Analyzers.Subdomain.csproj @@ -1,7 +1,7 @@ - .net7.0 + net7.0 true true true diff --git a/src/Tools.Generators.WebApi.UnitTests/MinimalApiMediatRGeneratorSpec.cs b/src/Tools.Generators.WebApi.UnitTests/MinimalApiMediatRGeneratorSpec.cs index 87844add..d99cf8ac 100644 --- a/src/Tools.Generators.WebApi.UnitTests/MinimalApiMediatRGeneratorSpec.cs +++ b/src/Tools.Generators.WebApi.UnitTests/MinimalApiMediatRGeneratorSpec.cs @@ -5,7 +5,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Xunit; -using WebApi_MinimalApiMediatRGenerator = Generators::Tools.Generators.WebApi.MinimalApiMediatRGenerator; +using MinimalApiMediatRGenerator = Generators::Tools.Generators.WebApi.MinimalApiMediatRGenerator; namespace Tools.Generators.WebApi.UnitTests; @@ -23,7 +23,7 @@ private static CSharpCompilation CreateCompilation(string sourceCode) }, new[] { - MetadataReference.CreateFromFile(typeof(WebApi_MinimalApiMediatRGenerator).Assembly.Location), + MetadataReference.CreateFromFile(typeof(MinimalApiMediatRGenerator).Assembly.Location), MetadataReference.CreateFromFile(typeof(Binder).GetTypeInfo().Assembly.Location), MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Runtime.dll")) //HACK: this is required to make custom attributes work @@ -40,7 +40,7 @@ public class GivenAServiceCLass public GivenAServiceCLass() { - var generator = new WebApi_MinimalApiMediatRGenerator(); + var generator = new MinimalApiMediatRGenerator(); _driver = CSharpGeneratorDriver.Create(generator); } @@ -470,6 +470,86 @@ public class AMethod_ARequest_Handler : global::MediatR.IRequestHandler + { + } + public class AServiceClass : IWebApiService + { + public async Task AMethod(ARequest request, CancellationToken cancellationToken) + { + return ""; + } + } + """); + + var result = Generate(compilation); + + result.Should().Be( + """ + // + using System.Threading; + using System; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Builder; + using Infrastructure.Web.Api.Interfaces; + using Infrastructure.Web.Api.Common.Extensions; + + namespace compilation + { + public static class MinimalApiRegistration + { + public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebApplication app) + { + var aserviceclassGroup = app.MapGroup(string.Empty) + .WithGroupName("AServiceClass") + .RequireCors("__DefaultCorsPolicy") + .AddEndpointFilter() + .AddEndpointFilter(); + #if TESTINGONLY + aserviceclassGroup.MapGet("aroute", + async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => + await mediator.Send(request, global::System.Threading.CancellationToken.None)) + .RequireAuthorization("Token"); + #endif + + } + } + } + + namespace ANamespace.AServiceClassMediatRHandlers + { + #if TESTINGONLY + public class AMethod_ARequest_Handler : global::MediatR.IRequestHandler + { + public async Task Handle(global::ANamespace.ARequest request, global::System.Threading.CancellationToken cancellationToken) + { + var api = new global::ANamespace.AServiceClass(); + var result = await api.AMethod(request, cancellationToken); + return result.HandleApiResult(global::Infrastructure.Web.Api.Interfaces.ServiceOperation.Get); + } + } + #endif + + } + + + """); + } + [Fact] public void WhenDefinesAMethodAndClassConstructor_ThenGenerates() { diff --git a/src/Tools.Generators.WebApi/MinimalApiMediatRGenerator.cs b/src/Tools.Generators.WebApi/MinimalApiMediatRGenerator.cs index e9aecdc8..c63c9209 100644 --- a/src/Tools.Generators.WebApi/MinimalApiMediatRGenerator.cs +++ b/src/Tools.Generators.WebApi/MinimalApiMediatRGenerator.cs @@ -131,11 +131,20 @@ private static void BuildEndpointRegistrations( endpointRegistrations.Append( " await mediator.Send(request, global::System.Threading.CancellationToken.None))"); - if (registration.OperationAccess == AccessType.HMAC) + if (registration.OperationAccess != AccessType.Anonymous) { endpointRegistrations.AppendLine(); - endpointRegistrations.Append( - $@" .RequireAuthorization(""{AuthenticationConstants.HMACPolicyName}"")"); + var policyName = registration.OperationAccess switch + { + AccessType.Token => AuthenticationConstants.TokenPolicyName, + AccessType.HMAC => AuthenticationConstants.HMACPolicyName, + _ => string.Empty + }; + if (policyName.HasValue()) + { + endpointRegistrations.Append( + $@" .RequireAuthorization(""{policyName}"")"); + } } endpointRegistrations.AppendLine(";"); diff --git a/src/WebsiteHost/BackEndForFrontEndModule.cs b/src/WebsiteHost/BackEndForFrontEndModule.cs index 303dd6f5..441f2b7e 100644 --- a/src/WebsiteHost/BackEndForFrontEndModule.cs +++ b/src/WebsiteHost/BackEndForFrontEndModule.cs @@ -14,13 +14,13 @@ public class BackEndForFrontEndModule : ISubDomainModule public Dictionary AggregatePrefixes => new(); - public Action MinimalApiRegistrationFunction + public Action RegisterServices { - get { return app => app.RegisterRoutes(); } + get { return (_, services) => { services.RegisterUnshared(); }; } } - public Action RegisterServicesFunction + public Action ConfigureMiddleware { - get { return (_, services) => { services.RegisterUnshared(); }; } + get { return app => app.RegisterRoutes(); } } } \ No newline at end of file