From 2a5e64e72adfb7fa5ea950d7b46a0f94e6c4f883 Mon Sep 17 00:00:00 2001 From: Carl Sixsmith Date: Thu, 8 Aug 2024 10:31:13 +0100 Subject: [PATCH] Changes as a result of password and lockout review. - enable sms 2FA - remember previous passwords and prevent re-use - lockout account after 5 failed attempts --- db/seed/001_development_seed.sql | 5 + .../Interfaces/IApplicationDbContext.cs | 2 + .../SendFactorCodeNotification.cs | 4 - .../SendFactorCodeNotificationHandler.cs | 13 - .../SendTwoFactorEmailCodeNotification.cs | 4 + ...ndTwoFactorEmailCodeNotificationHandler.cs | 13 + .../SendTwoFactorTextCodeNotification.cs | 4 + ...endTwoFactorTextCodeNotificationHandler.cs | 11 + src/Domain/Identity/PasswordHistory.cs | 9 + .../Configurations/IdentitySettings.cs | 10 +- .../Constants/Database/DatabaseConstants.cs | 4 +- src/Infrastructure/DependencyInjection.cs | 8 +- .../Persistence/ApplicationDbContext.cs | 2 + .../Identity/PasswordHistoryConfiguration.cs | 23 + .../Services/CommunicationsService.cs | 20 +- .../Identity/ApplicationUserManager.cs | 106 + .../Services/Identity/CustomSigninManager.cs | 29 +- ...240812110232_PasswordHistories.Designer.cs | 2324 +++++++++++++++++ .../20240812110232_PasswordHistories.cs | 39 + .../ApplicationDbContextModelSnapshot.cs | 25 + .../Pages/Identity/Authentication/Login.razor | 7 +- .../Authentication/LoginWith2fa.razor | 121 +- src/Server.UI/appsettings.Development.json | 2 +- src/Server.UI/appsettings.json | 3 +- 24 files changed, 2705 insertions(+), 83 deletions(-) delete mode 100644 src/Application/Features/Identity/Notifications/SendFactorCode/SendFactorCodeNotification.cs delete mode 100644 src/Application/Features/Identity/Notifications/SendFactorCode/SendFactorCodeNotificationHandler.cs create mode 100644 src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorEmailCodeNotification.cs create mode 100644 src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorEmailCodeNotificationHandler.cs create mode 100644 src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorTextCodeNotification.cs create mode 100644 src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorTextCodeNotificationHandler.cs create mode 100644 src/Domain/Identity/PasswordHistory.cs create mode 100644 src/Infrastructure/Persistence/Configurations/Identity/PasswordHistoryConfiguration.cs create mode 100644 src/Infrastructure/Services/Identity/ApplicationUserManager.cs create mode 100644 src/Migrators/Migrators.MSSQL/Migrations/20240812110232_PasswordHistories.Designer.cs create mode 100644 src/Migrators/Migrators.MSSQL/Migrations/20240812110232_PasswordHistories.cs diff --git a/db/seed/001_development_seed.sql b/db/seed/001_development_seed.sql index 79aac7d5..43243192 100644 --- a/db/seed/001_development_seed.sql +++ b/db/seed/001_development_seed.sql @@ -160,6 +160,11 @@ BEGIN TRY INSERT INTO [Identity].[User] (Id, DisplayName, ProviderId, TenantId, TenantName, ProfilePictureDataUrl, IsActive, IsLive, MemorablePlace, MemorableDate, RefreshToken, RequiresPasswordReset, RefreshTokenExpiryTime, SuperiorId, Created, CreatedBy, LastModified, LastModifiedBy, UserName, NormalizedUserName, Email, NormalizedEmail, EmailConfirmed, PasswordHash, SecurityStamp, ConcurrencyStamp, PhoneNumber, PhoneNumberConfirmed, TwoFactorEnabled, LockoutEnd, LockoutEnabled, AccessFailedCount) VALUES (N'f9c42459-01e8-4ad3-8fb6-47e159d59693', N'North East Contract User', N'1.1.2.1.', N'1.1.2.1.', N'North East Contract', null, 1, 0, N'Password123!', N'123456845', null, 1, N'0001-01-01 00:00:00.0000000', null, N'2024-07-24 11:48:12.5771633', @CreatedUserId, N'2024-07-24 11:51:05.7875573', @CreatedUserId, N'NorthEastContract.user@northeastcontract.org', N'NORTHEASTCONTRACT.USER@NORTHEASTCONTRACT.ORG', N'NorthEastContract.user@northeastcontract.org', N'NORTHEASTCONTRACT.USER@NORTHEASTCONTRACT.ORG', 1, N'AQAAAAIAAYagAAAAEJNkbDpuFwA7sbXkuv972OM1M4TESjPC643BaciFBzaC7BXw4yG1Gn+BLa+VC3A0yA==', N'NVJ2GJEQUGMSXF2M5SLTMJSGTNFUFIVT', N'd3fd54de-7056-4740-af53-f5e2ef0c9eff', N'07123123256', 0, 0, null, 1, 0); INSERT INTO [Identity].[User] (Id, DisplayName, ProviderId, TenantId, TenantName, ProfilePictureDataUrl, IsActive, IsLive, MemorablePlace, MemorableDate, RefreshToken, RequiresPasswordReset, RefreshTokenExpiryTime, SuperiorId, Created, CreatedBy, LastModified, LastModifiedBy, UserName, NormalizedUserName, Email, NormalizedEmail, EmailConfirmed, PasswordHash, SecurityStamp, ConcurrencyStamp, PhoneNumber, PhoneNumberConfirmed, TwoFactorEnabled, LockoutEnd, LockoutEnabled, AccessFailedCount) VALUES (N'ffe079b0-b976-4e60-bf70-06cf2bd2b065', N'London Contract User', N'1.1.5.1.', N'1.1.5.1.', N'London Contract', null, 1, 0, N'Password123!', N'123659', null, 1, N'0001-01-01 00:00:00.0000000', null, N'2024-07-24 12:09:57.1828400', @CreatedUserId, N'2024-07-24 12:12:11.9286715', @CreatedUserId, N'LondonContract.user@londoncontract.org', N'LONDONCONTRACT.USER@LONDONCONTRACT.ORG', N'LondonContract.user@londoncontract.org', N'LONDONCONTRACT.USER@LONDONCONTRACT.ORG', 1, N'AQAAAAIAAYagAAAAEBNwqdcVkVZ8RzYXP+5/sp387yt5j2nNG3alHojVXNCjxxz74jcb4+e1SEzIr5FL4g==', N'JN6P4JZVSMUDBGICD6ZGDU7XII427FAB', N'2f3fdd1b-dc57-4658-b22a-0f4aa94c8320', N'07412356894', 0, 0, null, 1, 0); + -- remember first time passwords to prevent them being re-used + INSERT INTO [Identity].PasswordHistory ( [UserId], [PasswordHash], [CreatedAt] ) + SELECT [Id], [PasswordHash], SYSUTCDATETIME() FROM [Identity].[User] + WHERE [PasswordHash] IS NOT NULL; + INSERT INTO [Identity].UserRole (UserId, RoleId) VALUES (N'0240afb1-78ee-497a-b42f-25f61cce5ecc', N'a6bc93b6-0c06-43e7-bc8f-8d1f1ff6f136'); INSERT INTO [Identity].UserRole (UserId, RoleId) VALUES (N'06098b9b-2a53-4bca-81fa-81e8e1befc47', N'a6bc93b6-0c06-43e7-bc8f-8d1f1ff6f136'); INSERT INTO [Identity].UserRole (UserId, RoleId) VALUES (N'08cdee0b-a6c2-4c5a-8ebe-2cff53789052', N'a6bc93b6-0c06-43e7-bc8f-8d1f1ff6f136'); diff --git a/src/Application/Common/Interfaces/IApplicationDbContext.cs b/src/Application/Common/Interfaces/IApplicationDbContext.cs index 2886ede6..f322507d 100644 --- a/src/Application/Common/Interfaces/IApplicationDbContext.cs +++ b/src/Application/Common/Interfaces/IApplicationDbContext.cs @@ -45,6 +45,8 @@ public interface IApplicationDbContext ChangeTracker ChangeTracker { get; } DbSet DataProtectionKeys { get; } + + DbSet PasswordHistories { get; } } diff --git a/src/Application/Features/Identity/Notifications/SendFactorCode/SendFactorCodeNotification.cs b/src/Application/Features/Identity/Notifications/SendFactorCode/SendFactorCodeNotification.cs deleted file mode 100644 index 72085981..00000000 --- a/src/Application/Features/Identity/Notifications/SendFactorCode/SendFactorCodeNotification.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Cfo.Cats.Application.Features.Identity.Notifications.SendFactorCode; - -public record SendFactorCodeNotification(string Email, string UserName, string AuthenticatorCode) - : INotification; \ No newline at end of file diff --git a/src/Application/Features/Identity/Notifications/SendFactorCode/SendFactorCodeNotificationHandler.cs b/src/Application/Features/Identity/Notifications/SendFactorCode/SendFactorCodeNotificationHandler.cs deleted file mode 100644 index bc08679e..00000000 --- a/src/Application/Features/Identity/Notifications/SendFactorCode/SendFactorCodeNotificationHandler.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Cfo.Cats.Application.Features.Identity.Notifications.SendFactorCode; - -public class SendFactorCodeNotificationHandler( - ILogger logger, - ICommunicationsService communicationsService -) : INotificationHandler -{ - public async Task Handle(SendFactorCodeNotification notification, CancellationToken cancellationToken) - { - await communicationsService.SendEmailCodeAsync(notification.Email, notification.AuthenticatorCode); - logger.LogInformation("Verification Code email sent to {Email})", notification.Email); - } -} \ No newline at end of file diff --git a/src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorEmailCodeNotification.cs b/src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorEmailCodeNotification.cs new file mode 100644 index 00000000..bfc0fe6f --- /dev/null +++ b/src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorEmailCodeNotification.cs @@ -0,0 +1,4 @@ +namespace Cfo.Cats.Application.Features.Identity.Notifications.SendTwoFactorCode; + +public record SendTwoFactorEmailCodeNotification(string Email, string UserName, string AuthenticatorCode) + : INotification; \ No newline at end of file diff --git a/src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorEmailCodeNotificationHandler.cs b/src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorEmailCodeNotificationHandler.cs new file mode 100644 index 00000000..5d2565c1 --- /dev/null +++ b/src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorEmailCodeNotificationHandler.cs @@ -0,0 +1,13 @@ +namespace Cfo.Cats.Application.Features.Identity.Notifications.SendTwoFactorCode; + +public class SendTwoFactorEmailCodeNotificationHandler( + ILogger logger, + ICommunicationsService communicationsService +) : INotificationHandler +{ + public async Task Handle(SendTwoFactorEmailCodeNotification notification, CancellationToken cancellationToken) + { + await communicationsService.SendEmailCodeAsync(notification.Email, notification.AuthenticatorCode); + logger.LogInformation("Verification Code email sent to {UserName})", notification.UserName); + } +} \ No newline at end of file diff --git a/src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorTextCodeNotification.cs b/src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorTextCodeNotification.cs new file mode 100644 index 00000000..dd3a16d9 --- /dev/null +++ b/src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorTextCodeNotification.cs @@ -0,0 +1,4 @@ +namespace Cfo.Cats.Application.Features.Identity.Notifications.SendTwoFactorCode; + +public record SendTwoFactorTextCodeNotification(string MobileNumber, string UserName, string AuthenticatorCode) + : INotification; \ No newline at end of file diff --git a/src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorTextCodeNotificationHandler.cs b/src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorTextCodeNotificationHandler.cs new file mode 100644 index 00000000..5577b6be --- /dev/null +++ b/src/Application/Features/Identity/Notifications/SendTwoFactorCode/SendTwoFactorTextCodeNotificationHandler.cs @@ -0,0 +1,11 @@ +namespace Cfo.Cats.Application.Features.Identity.Notifications.SendTwoFactorCode; + +public class SendTwoFactorTextCodeNotificationHandler(ICommunicationsService communicationsService, ILogger logger) + : INotificationHandler +{ + public async Task Handle(SendTwoFactorTextCodeNotification notification, CancellationToken cancellationToken) + { + await communicationsService.SendSmsCodeAsync(notification.MobileNumber, notification.AuthenticatorCode); + logger.LogDebug("Verification Code email sent to {UserName})", notification.UserName); + } +} diff --git a/src/Domain/Identity/PasswordHistory.cs b/src/Domain/Identity/PasswordHistory.cs new file mode 100644 index 00000000..e2f9c401 --- /dev/null +++ b/src/Domain/Identity/PasswordHistory.cs @@ -0,0 +1,9 @@ +namespace Cfo.Cats.Domain.Identity; + +public class PasswordHistory +{ + public int Id { get; set; } + public string UserId { get; set; } = default!; + public string PasswordHash { get; set; } = default!; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Infrastructure/Configurations/IdentitySettings.cs b/src/Infrastructure/Configurations/IdentitySettings.cs index 438ada46..cd51721a 100644 --- a/src/Infrastructure/Configurations/IdentitySettings.cs +++ b/src/Infrastructure/Configurations/IdentitySettings.cs @@ -42,7 +42,13 @@ public class IdentitySettings : IIdentitySettings // Lockout settings. /// - /// Gets or sets a value indicating what the default lockout TimeSpan should be, measured in minutes. + /// Gets or sets a value indicating what the default lockout TimeSpan should be, measured in years. /// - public int DefaultLockoutTimeSpan { get; set; } = 30; + public int DefaultLockoutTimeSpan { get; set; } = 10; + + /// + /// Gets or sets a value indicating how many failed attempts will trigger an account lockout + /// + public int MaxFailedAccessAttempts { get; set; } = 5; + } diff --git a/src/Infrastructure/Constants/Database/DatabaseConstants.cs b/src/Infrastructure/Constants/Database/DatabaseConstants.cs index 0e54a4f8..b5bd1460 100644 --- a/src/Infrastructure/Constants/Database/DatabaseConstants.cs +++ b/src/Infrastructure/Constants/Database/DatabaseConstants.cs @@ -38,7 +38,9 @@ public static class Tables public const string EnrolmentQa1Queue = "Qa1Queue"; public const string EnrolmentQa2Queue = "Qa2Queue"; public const string EnrolmentEscalationQueue = "EscalationQueue"; - + + public const string PasswordHistory = nameof(PasswordHistory); + } diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index 246f039b..ff2d8812 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -189,10 +189,12 @@ private static IServiceCollection AddAuthenticationService(this IServiceCollecti .AddIdentityCore() .AddRoles() .AddEntityFrameworkStores() + .AddUserManager() //.AddSignInManager() .AddClaimsPrincipalFactory() .AddDefaultTokenProviders(); + services.AddScoped, ApplicationUserManager>(); services.AddScoped, CustomSigninManager>(); services.AddScoped>(); @@ -210,10 +212,8 @@ private static IServiceCollection AddAuthenticationService(this IServiceCollecti options.Password.RequireLowercase = identitySettings.RequireLowerCase; // Lockout settings - options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes( - identitySettings.DefaultLockoutTimeSpan - ); - options.Lockout.MaxFailedAccessAttempts = 10; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromDays(identitySettings.DefaultLockoutTimeSpan * 365); + options.Lockout.MaxFailedAccessAttempts = identitySettings.MaxFailedAccessAttempts; options.Lockout.AllowedForNewUsers = true; // Default SignIn settings. diff --git a/src/Infrastructure/Persistence/ApplicationDbContext.cs b/src/Infrastructure/Persistence/ApplicationDbContext.cs index 9dae66a3..87a78da2 100644 --- a/src/Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/Infrastructure/Persistence/ApplicationDbContext.cs @@ -54,6 +54,8 @@ public ApplicationDbContext(DbContextOptions options) public DbSet EnrolmentQa2Queue => Set(); public DbSet EnrolmentEscalationQueue => Set(); + public DbSet PasswordHistories => Set(); + public DbSet DataProtectionKeys => Set(); protected override void OnModelCreating(ModelBuilder builder) diff --git a/src/Infrastructure/Persistence/Configurations/Identity/PasswordHistoryConfiguration.cs b/src/Infrastructure/Persistence/Configurations/Identity/PasswordHistoryConfiguration.cs new file mode 100644 index 00000000..67f1bc20 --- /dev/null +++ b/src/Infrastructure/Persistence/Configurations/Identity/PasswordHistoryConfiguration.cs @@ -0,0 +1,23 @@ +using Cfo.Cats.Domain.Identity; +using Cfo.Cats.Infrastructure.Constants.Database; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Cfo.Cats.Infrastructure.Persistence.Configurations.Identity; + +public class PasswordHistoryConfiguration + : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(DatabaseConstants.Tables.PasswordHistory, DatabaseConstants.Schemas.Identity); + builder.Property(ph => ph.UserId) + .HasMaxLength(DatabaseConstants.FieldLengths.GuidId) + .IsRequired(); + + builder.Property(ph => ph.PasswordHash) + .IsRequired(); + + builder.Property(ph => ph.CreatedAt) + .IsRequired(); + } +} diff --git a/src/Infrastructure/Services/CommunicationsService.cs b/src/Infrastructure/Services/CommunicationsService.cs index bfca35f9..89d93e20 100644 --- a/src/Infrastructure/Services/CommunicationsService.cs +++ b/src/Infrastructure/Services/CommunicationsService.cs @@ -4,9 +4,25 @@ namespace Cfo.Cats.Infrastructure.Services; public class CommunicationsService(IOptions options, ILogger logger) : ICommunicationsService { - public Task SendSmsCodeAsync(string mobileNumber, string code) + public async Task SendSmsCodeAsync(string mobileNumber, string code) { - throw new NotImplementedException(); + try + { + var client = Client(); + var response = await client.SendSmsAsync(mobileNumber: mobileNumber, + templateId: options.Value.SmsTemplate, + personalisation: new Dictionary() + { + { + "body", + $"Your two factor authentication code is {code}" + } + }); + } + catch (Exception e) + { + logger.LogError("Failed to send SMS code. {e}", e); + } } public async Task SendEmailCodeAsync(string email, string code) { diff --git a/src/Infrastructure/Services/Identity/ApplicationUserManager.cs b/src/Infrastructure/Services/Identity/ApplicationUserManager.cs new file mode 100644 index 00000000..b2a4213b --- /dev/null +++ b/src/Infrastructure/Services/Identity/ApplicationUserManager.cs @@ -0,0 +1,106 @@ +using Cfo.Cats.Domain.Identity; + +namespace Cfo.Cats.Infrastructure.Services.Identity; + +public class ApplicationUserManager(IUserStore store, IOptions optionsAccessor, IPasswordHasher passwordHasher, IEnumerable> userValidators, IEnumerable> passwordValidators, ILookupNormalizer keyNormalizer, IdentityErrorDescriber errors, IServiceProvider services, ILogger logger) + : UserManager(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger) +{ + private readonly IServiceProvider _serviceProvider = services; + + public override async Task ChangePasswordAsync(ApplicationUser user, string currentPassword, string newPassword) + { + if (await IsPasswordInHistory(user, newPassword)) + { + IdentityError error = new IdentityError() + { + Code = "CATS_01", + Description = "You cannot reuse your previous passwords" + }; + return IdentityResult.Failed(error); + } + + var result = await base.ChangePasswordAsync(user, currentPassword, newPassword); + if (result.Succeeded) + { + await AddPasswordToHistory(user, user.PasswordHash!); + } + return result; + } + + public override async Task ResetPasswordAsync(ApplicationUser user, string token, string newPassword) + { + ThrowIfDisposed(); + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + // Verify the reset token + if (!await VerifyUserTokenAsync(user, Options.Tokens.PasswordResetTokenProvider, ResetPasswordTokenPurpose, token)) + { + return IdentityResult.Failed(ErrorDescriber.InvalidToken()); + } + + // Check password history + if (await IsPasswordInHistory(user, newPassword)) + { + IdentityError error = new IdentityError() + { + Code = "CATS_01", + Description = "You cannot reuse your previous passwords" + }; + return IdentityResult.Failed([error]); + } + + // Update the password hash + var result = await UpdatePasswordHash(user, newPassword, validatePassword: true); + if (result.Succeeded == false) + { + return result; + } + + // Save the new password to the history + await AddPasswordToHistory(user, user.PasswordHash!); + + // Update the user + return await UpdateUserAsync(user); + } + + private async ValueTask IsPasswordInHistory(ApplicationUser user, string newPassword) + { + var passwordHasher = new PasswordHasher(); + + using var scope = _serviceProvider.CreateScope(); + var uow = scope.ServiceProvider.GetRequiredService(); + + var passwordHistories = await uow.DbContext.PasswordHistories + .AsNoTracking() + .Where(ph => ph.UserId == user.Id) + .ToListAsync(); + + foreach (var passwordHistory in passwordHistories) + { + if (passwordHasher.VerifyHashedPassword(user, passwordHistory.PasswordHash, newPassword) != PasswordVerificationResult.Failed) + { + return true; + } + } + return false; + } + + private async Task AddPasswordToHistory(ApplicationUser user, string userPasswordHash) + { + using var scope = _serviceProvider.CreateScope(); + var uow = scope.ServiceProvider.GetRequiredService(); + var passwordHistory = new PasswordHistory() + { + UserId = user.Id, + PasswordHash = userPasswordHash, + CreatedAt = DateTime.UtcNow + }; + uow.DbContext.PasswordHistories.Add(passwordHistory); + await uow.SaveChangesAsync(); + } + +} + diff --git a/src/Infrastructure/Services/Identity/CustomSigninManager.cs b/src/Infrastructure/Services/Identity/CustomSigninManager.cs index 965ba554..2c32d758 100644 --- a/src/Infrastructure/Services/Identity/CustomSigninManager.cs +++ b/src/Infrastructure/Services/Identity/CustomSigninManager.cs @@ -17,24 +17,25 @@ public override async Task PasswordSignInAsync(string userName, st } var ipAddress = httpContextAccessor.HttpContext!.Connection.RemoteIpAddress?.ToString(); - if (string.IsNullOrWhiteSpace(ipAddress) == false && allowlistOptions.Value.AllowedIPs.Contains(ipAddress)) - { - var result = await CheckPasswordSignInAsync(user, password, lockoutOnFailure); - - if (result.Succeeded) - { - if (user.RequiresPasswordReset) - { - return CustomSignInResult.PasswordResetRequired; - } - - await SignInAsync(user, isPersistent); - } + + var passwordCheckResult = await CheckPasswordSignInAsync(user, password, lockoutOnFailure); - return result; + if (PasswordChecksOutAndRequiresPasswordReset(passwordCheckResult, user)) + { + return CustomSignInResult.PasswordResetRequired; + } + + if (PasswordCheckSucceededAndTwoFactorDisabledForIpRange(passwordCheckResult, ipAddress)) + { + await SignInAsync(user, isPersistent); + return passwordCheckResult; } + + return await base.PasswordSignInAsync(userName, password, isPersistent, lockoutOnFailure); } + private bool PasswordCheckSucceededAndTwoFactorDisabledForIpRange(SignInResult passwordCheckResult, string? ipAddress) => passwordCheckResult.Succeeded && string.IsNullOrWhiteSpace(ipAddress) == false && allowlistOptions.Value.AllowedIPs.Contains(ipAddress); + private static bool PasswordChecksOutAndRequiresPasswordReset(SignInResult passwordCheckResult, ApplicationUser user) => passwordCheckResult.Succeeded && user.RequiresPasswordReset; public class CustomSignInResult : SignInResult { diff --git a/src/Migrators/Migrators.MSSQL/Migrations/20240812110232_PasswordHistories.Designer.cs b/src/Migrators/Migrators.MSSQL/Migrations/20240812110232_PasswordHistories.Designer.cs new file mode 100644 index 00000000..555405c0 --- /dev/null +++ b/src/Migrators/Migrators.MSSQL/Migrations/20240812110232_PasswordHistories.Designer.cs @@ -0,0 +1,2324 @@ +// +using System; +using Cfo.Cats.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cfo.Cats.Migrators.MSSQL.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20240812110232_PasswordHistories")] + partial class PasswordHistories + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Administration.Contract", b => + { + b.Property("Id") + .HasMaxLength(12) + .HasColumnType("nvarchar(12)"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("LotNumber") + .HasColumnType("int"); + + b.Property("_tenantId") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("LotNumber") + .IsUnique(); + + b.HasIndex("_tenantId"); + + b.ToTable("Contract", "Configuration"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Administration.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("_contractId") + .HasMaxLength(12) + .HasColumnType("nvarchar(12)") + .HasColumnName("ContractId"); + + b.Property("_genderProvisionId") + .HasColumnType("int") + .HasColumnName("GenderProvisionId"); + + b.Property("_locationTypeId") + .HasColumnType("int") + .HasColumnName("LocationTypeId"); + + b.Property("_parentLocationId") + .HasColumnType("int") + .HasColumnName("ParentLocationId"); + + b.HasKey("Id"); + + b.HasIndex("_contractId"); + + b.HasIndex("_parentLocationId"); + + b.ToTable("Location", "Configuration"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Administration.LocationMapping", b => + { + b.Property("Code") + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b.Property("CodeType") + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("DeliveryRegion") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("_locationId") + .HasColumnType("int") + .HasColumnName("LocationId"); + + b.HasKey("Code", "CodeType"); + + b.HasIndex("_locationId"); + + b.ToTable("LocationMapping", "Dms"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Administration.Tenant", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("Tenant", "Configuration"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Assessments.ParticipantAssessment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssessmentJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("EditorId") + .HasColumnType("nvarchar(36)"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("OwnerId") + .HasColumnType("nvarchar(36)"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("TenantId") + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("EditorId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParticipantId"); + + b.HasIndex("TenantId"); + + b.ToTable("Assessment", "Participant"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.AuditTrail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AffectedColumns") + .HasColumnType("nvarchar(max)"); + + b.Property("AuditType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DateTime") + .HasColumnType("datetime2"); + + b.Property("NewValues") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValues") + .HasColumnType("nvarchar(max)"); + + b.Property("PrimaryKey") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TableName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AuditTrail", "Audit"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Documents.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EditorId") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("IsPublic") + .HasColumnType("bit"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("OwnerId") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("TenantId") + .HasColumnType("nvarchar(50)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("URL") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("LastModifiedBy"); + + b.HasIndex("TenantId"); + + b.ToTable("Document", "Document"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.KeyValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("Description") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("KeyValue", "Configuration"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.EnrolmentEscalationQueueEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("EditorId") + .HasColumnType("nvarchar(36)"); + + b.Property("IsAccepted") + .HasColumnType("bit"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerId") + .HasColumnType("nvarchar(36)"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("EditorId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParticipantId"); + + b.HasIndex("TenantId"); + + b.ToTable("EscalationQueue", "Enrolment"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.EnrolmentPqaQueueEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("EditorId") + .HasColumnType("nvarchar(36)"); + + b.Property("IsAccepted") + .HasColumnType("bit"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerId") + .HasColumnType("nvarchar(36)"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("EditorId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParticipantId"); + + b.HasIndex("TenantId"); + + b.ToTable("PqaQueue", "Enrolment"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.EnrolmentQa1QueueEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("EditorId") + .HasColumnType("nvarchar(36)"); + + b.Property("IsAccepted") + .HasColumnType("bit"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerId") + .HasColumnType("nvarchar(36)"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("EditorId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParticipantId"); + + b.HasIndex("TenantId"); + + b.ToTable("Qa1Queue", "Enrolment"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.EnrolmentQa2QueueEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("EditorId") + .HasColumnType("nvarchar(36)"); + + b.Property("IsAccepted") + .HasColumnType("bit"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerId") + .HasColumnType("nvarchar(36)"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("EditorId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParticipantId"); + + b.HasIndex("TenantId"); + + b.ToTable("Qa2Queue", "Enrolment"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.Participant", b => + { + b.Property("Id") + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.Property("ConsentStatus") + .HasColumnType("int"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("DateOfBirth") + .IsRequired() + .HasColumnType("date"); + + b.Property("EditorId") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("EnrolmentLocationJustification") + .HasColumnType("nvarchar(max)"); + + b.Property("EnrolmentStatus") + .HasColumnType("int"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MiddleName") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("OwnerId") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("ReferralComments") + .HasColumnType("nvarchar(max)"); + + b.Property("ReferralSource") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("_currentLocationId") + .HasColumnType("int") + .HasColumnName("CurrentLocationId"); + + b.Property("_enrolmentLocationId") + .HasColumnType("int") + .HasColumnName("EnrolmentLocationId"); + + b.HasKey("Id"); + + b.HasIndex("EditorId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("_currentLocationId"); + + b.HasIndex("_enrolmentLocationId"); + + b.ToTable("Participant", "Participant"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.ParticipantEnrolmentHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("EnrolmentStatus") + .HasColumnType("int"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("nvarchar(9)"); + + b.HasKey("Id"); + + b.HasIndex("ParticipantId"); + + b.ToTable("EnrolmentHistory", "Participant"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.Risk", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivityRecommendations") + .HasColumnType("nvarchar(max)"); + + b.Property("ActivityRecommendationsReceived") + .HasColumnType("datetime2"); + + b.Property("ActivityRestrictions") + .HasColumnType("nvarchar(max)"); + + b.Property("ActivityRestrictionsReceived") + .HasColumnType("datetime2"); + + b.Property("AdditionalInformation") + .HasColumnType("nvarchar(max)"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("DeclarationSigned") + .HasColumnType("bit"); + + b.Property("IsRelevantToCommunity") + .HasColumnType("bit"); + + b.Property("IsRelevantToCustody") + .HasColumnType("bit"); + + b.Property("IsSubjectToSHPO") + .HasColumnType("int"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("LicenseConditions") + .HasColumnType("nvarchar(max)"); + + b.Property("LicenseEnd") + .HasColumnType("datetime2"); + + b.Property("MappaCategory") + .HasColumnType("int"); + + b.Property("MappaLevel") + .HasColumnType("int"); + + b.Property("NSDCase") + .HasColumnType("int"); + + b.Property("PSFRestrictions") + .HasColumnType("nvarchar(max)"); + + b.Property("PSFRestrictionsReceived") + .HasColumnType("datetime2"); + + b.Property("ParticipantId") + .IsRequired() + .HasColumnType("nvarchar(9)"); + + b.Property("ReferredOn") + .HasColumnType("datetime2"); + + b.Property("ReferrerEmail") + .HasColumnType("nvarchar(max)"); + + b.Property("ReferrerName") + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewJustification") + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewReason") + .HasColumnType("int"); + + b.Property("RiskToChildrenInCommunity") + .HasColumnType("int"); + + b.Property("RiskToChildrenInCustody") + .HasColumnType("int"); + + b.Property("RiskToKnownAdultInCommunity") + .HasColumnType("int"); + + b.Property("RiskToKnownAdultInCustody") + .HasColumnType("int"); + + b.Property("RiskToOtherPrisonersInCommunity") + .HasColumnType("int"); + + b.Property("RiskToOtherPrisonersInCustody") + .HasColumnType("int"); + + b.Property("RiskToPublicInCommunity") + .HasColumnType("int"); + + b.Property("RiskToPublicInCustody") + .HasColumnType("int"); + + b.Property("RiskToSelfInCommunity") + .HasColumnType("int"); + + b.Property("RiskToSelfInCustody") + .HasColumnType("int"); + + b.Property("RiskToStaffInCommunity") + .HasColumnType("int"); + + b.Property("RiskToStaffInCustody") + .HasColumnType("int"); + + b.Property("SpecificRisk") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ParticipantId"); + + b.ToTable("Risk", "Participant"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.Timeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(36)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Line2") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Line3") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ParticipantId") + .IsRequired() + .HasColumnType("nvarchar(9)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ParticipantId"); + + b.ToTable("Timeline", "Participant"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.ApplicationRole", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RoleRank") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Role", "Identity"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.ApplicationRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RoleId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaim", "Identity"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsLive") + .HasColumnType("bit"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("MemorableDate") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MemorablePlace") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("ProfilePictureDataUrl") + .HasColumnType("text"); + + b.Property("ProviderId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RefreshToken") + .HasColumnType("nvarchar(max)"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("datetime2"); + + b.Property("RequiresPasswordReset") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("SuperiorId") + .HasColumnType("nvarchar(36)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TenantName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.HasIndex("SuperiorId"); + + b.HasIndex("TenantId"); + + b.ToTable("User", "Identity"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.ApplicationUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaim", "Identity"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.ApplicationUserRole", b => + { + b.Property("UserId") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("RoleId") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRole", "Identity"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.ApplicationUserToken", b => + { + b.Property("UserId") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserToken", "Identity"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.PasswordHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.HasKey("Id"); + + b.ToTable("PasswordHistory", "Identity"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogin", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("TenantLocation", b => + { + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("nvarchar(50)"); + + b.HasKey("LocationId", "TenantId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantLocation", "Configuration"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Administration.Contract", b => + { + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Tenant", "Tenant") + .WithMany() + .HasForeignKey("_tenantId"); + + b.OwnsOne("Cfo.Cats.Domain.ValueObjects.Lifetime", "Lifetime", b1 => + { + b1.Property("ContractId") + .HasColumnType("nvarchar(12)"); + + b1.Property("EndDate") + .HasColumnType("datetime2") + .HasColumnName("LifetimeEnd"); + + b1.Property("StartDate") + .HasColumnType("datetime2") + .HasColumnName("LifetimeStart"); + + b1.HasKey("ContractId"); + + b1.ToTable("Contract", "Configuration"); + + b1.WithOwner() + .HasForeignKey("ContractId"); + }); + + b.Navigation("Lifetime") + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Administration.Location", b => + { + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Contract", "Contract") + .WithMany("Locations") + .HasForeignKey("_contractId"); + + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Location", "ParentLocation") + .WithMany("ChildLocations") + .HasForeignKey("_parentLocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.OwnsOne("Cfo.Cats.Domain.ValueObjects.Lifetime", "Lifetime", b1 => + { + b1.Property("LocationId") + .HasColumnType("int"); + + b1.Property("EndDate") + .HasColumnType("datetime2") + .HasColumnName("LifetimeEnd"); + + b1.Property("StartDate") + .HasColumnType("datetime2") + .HasColumnName("LifetimeStart"); + + b1.HasKey("LocationId"); + + b1.ToTable("Location", "Configuration"); + + b1.WithOwner() + .HasForeignKey("LocationId"); + }); + + b.Navigation("Contract"); + + b.Navigation("Lifetime") + .IsRequired(); + + b.Navigation("ParentLocation"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Administration.LocationMapping", b => + { + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Location", "Location") + .WithMany("LocationMappings") + .HasForeignKey("_locationId"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Administration.Tenant", b => + { + b.OwnsMany("Cfo.Cats.Domain.ValueObjects.TenantDomain", "Domains", b1 => + { + b1.Property("TenantId") + .HasColumnType("nvarchar(50)"); + + b1.Property("Domain") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b1.Property("Created") + .HasColumnType("datetime2"); + + b1.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("LastModified") + .HasColumnType("datetime2"); + + b1.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.HasKey("TenantId", "Domain"); + + b1.ToTable("TenantDomain", "Configuration"); + + b1.WithOwner() + .HasForeignKey("TenantId"); + }); + + b.Navigation("Domains"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Assessments.ParticipantAssessment", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Editor") + .WithMany() + .HasForeignKey("EditorId"); + + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + + b.HasOne("Cfo.Cats.Domain.Entities.Participants.Participant", null) + .WithMany() + .HasForeignKey("ParticipantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsMany("Cfo.Cats.Domain.ValueObjects.PathwayScore", "Scores", b1 => + { + b1.Property("AssessmentId") + .HasColumnType("uniqueidentifier"); + + b1.Property("Pathway") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b1.Property("Score") + .HasColumnType("float"); + + b1.HasKey("AssessmentId", "Pathway"); + + b1.ToTable("AssessmentPathwayScore", "Participant"); + + b1.WithOwner() + .HasForeignKey("AssessmentId"); + }); + + b.Navigation("Editor"); + + b.Navigation("Owner"); + + b.Navigation("Scores"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.AuditTrail", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Documents.Document", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Editor") + .WithMany() + .HasForeignKey("LastModifiedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId"); + + b.Navigation("Editor"); + + b.Navigation("Owner"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.EnrolmentEscalationQueueEntry", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Editor") + .WithMany() + .HasForeignKey("EditorId"); + + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + + b.HasOne("Cfo.Cats.Domain.Entities.Participants.Participant", "Participant") + .WithMany() + .HasForeignKey("ParticipantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Cfo.Cats.Domain.ValueObjects.Note", "Notes", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("CallReference") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b1.Property("Created") + .HasColumnType("datetime2"); + + b1.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("EnrolmentEscalationQueueEntryId") + .HasColumnType("uniqueidentifier"); + + b1.Property("LastModified") + .HasColumnType("datetime2"); + + b1.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b1.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.HasKey("Id"); + + b1.HasIndex("CreatedBy"); + + b1.HasIndex("EnrolmentEscalationQueueEntryId"); + + b1.HasIndex("LastModifiedBy"); + + b1.ToTable("EscalationNote", "Enrolment"); + + b1.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy"); + + b1.WithOwner() + .HasForeignKey("EnrolmentEscalationQueueEntryId"); + + b1.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedBy"); + + b1.Navigation("CreatedByUser"); + + b1.Navigation("LastModifiedByUser"); + }); + + b.Navigation("Editor"); + + b.Navigation("Notes"); + + b.Navigation("Owner"); + + b.Navigation("Participant"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.EnrolmentPqaQueueEntry", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Editor") + .WithMany() + .HasForeignKey("EditorId"); + + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + + b.HasOne("Cfo.Cats.Domain.Entities.Participants.Participant", "Participant") + .WithMany() + .HasForeignKey("ParticipantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Cfo.Cats.Domain.ValueObjects.Note", "Notes", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("CallReference") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b1.Property("Created") + .HasColumnType("datetime2"); + + b1.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("EnrolmentPqaQueueEntryId") + .HasColumnType("uniqueidentifier"); + + b1.Property("LastModified") + .HasColumnType("datetime2"); + + b1.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b1.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.HasKey("Id"); + + b1.HasIndex("CreatedBy"); + + b1.HasIndex("EnrolmentPqaQueueEntryId"); + + b1.HasIndex("LastModifiedBy"); + + b1.ToTable("PqaQueueNote", "Enrolment"); + + b1.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy"); + + b1.WithOwner() + .HasForeignKey("EnrolmentPqaQueueEntryId"); + + b1.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedBy"); + + b1.Navigation("CreatedByUser"); + + b1.Navigation("LastModifiedByUser"); + }); + + b.Navigation("Editor"); + + b.Navigation("Notes"); + + b.Navigation("Owner"); + + b.Navigation("Participant"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.EnrolmentQa1QueueEntry", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Editor") + .WithMany() + .HasForeignKey("EditorId"); + + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + + b.HasOne("Cfo.Cats.Domain.Entities.Participants.Participant", "Participant") + .WithMany() + .HasForeignKey("ParticipantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Cfo.Cats.Domain.ValueObjects.Note", "Notes", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("CallReference") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b1.Property("Created") + .HasColumnType("datetime2"); + + b1.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("EnrolmentQa1QueueEntryId") + .HasColumnType("uniqueidentifier"); + + b1.Property("LastModified") + .HasColumnType("datetime2"); + + b1.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b1.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.HasKey("Id"); + + b1.HasIndex("CreatedBy"); + + b1.HasIndex("EnrolmentQa1QueueEntryId"); + + b1.HasIndex("LastModifiedBy"); + + b1.ToTable("Qa1QueueNote", "Enrolment"); + + b1.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy"); + + b1.WithOwner() + .HasForeignKey("EnrolmentQa1QueueEntryId"); + + b1.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedBy"); + + b1.Navigation("CreatedByUser"); + + b1.Navigation("LastModifiedByUser"); + }); + + b.Navigation("Editor"); + + b.Navigation("Notes"); + + b.Navigation("Owner"); + + b.Navigation("Participant"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.EnrolmentQa2QueueEntry", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Editor") + .WithMany() + .HasForeignKey("EditorId"); + + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + + b.HasOne("Cfo.Cats.Domain.Entities.Participants.Participant", "Participant") + .WithMany() + .HasForeignKey("ParticipantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Cfo.Cats.Domain.ValueObjects.Note", "Notes", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("CallReference") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b1.Property("Created") + .HasColumnType("datetime2"); + + b1.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("EnrolmentQa2QueueEntryId") + .HasColumnType("uniqueidentifier"); + + b1.Property("LastModified") + .HasColumnType("datetime2"); + + b1.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b1.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.HasKey("Id"); + + b1.HasIndex("CreatedBy"); + + b1.HasIndex("EnrolmentQa2QueueEntryId"); + + b1.HasIndex("LastModifiedBy"); + + b1.ToTable("Qa2QueueNote", "Enrolment"); + + b1.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy"); + + b1.WithOwner() + .HasForeignKey("EnrolmentQa2QueueEntryId"); + + b1.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedBy"); + + b1.Navigation("CreatedByUser"); + + b1.Navigation("LastModifiedByUser"); + }); + + b.Navigation("Editor"); + + b.Navigation("Notes"); + + b.Navigation("Owner"); + + b.Navigation("Participant"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.Participant", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Editor") + .WithMany() + .HasForeignKey("EditorId"); + + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Location", "CurrentLocation") + .WithMany() + .HasForeignKey("_currentLocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_Participant_Location"); + + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Location", "EnrolmentLocation") + .WithMany() + .HasForeignKey("_enrolmentLocationId") + .HasConstraintName("FK_Participant_EnrolmentLocation"); + + b.OwnsMany("Cfo.Cats.Domain.Entities.Participants.Consent", "Consents", b1 => + { + b1.Property("ParticipantId") + .HasColumnType("nvarchar(9)"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Created") + .HasColumnType("datetime2"); + + b1.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("LastModified") + .HasColumnType("datetime2"); + + b1.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("_documentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("DocumentId"); + + b1.HasKey("ParticipantId", "Id"); + + b1.HasIndex("_documentId"); + + b1.ToTable("Consent", "Participant"); + + b1.WithOwner() + .HasForeignKey("ParticipantId"); + + b1.HasOne("Cfo.Cats.Domain.Entities.Documents.Document", "Document") + .WithMany() + .HasForeignKey("_documentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.OwnsOne("Cfo.Cats.Domain.ValueObjects.Lifetime", "Lifetime", b2 => + { + b2.Property("ConsentParticipantId") + .HasColumnType("nvarchar(9)"); + + b2.Property("ConsentId") + .HasColumnType("int"); + + b2.Property("EndDate") + .HasColumnType("datetime2") + .HasColumnName("ValidTo"); + + b2.Property("StartDate") + .HasColumnType("datetime2") + .HasColumnName("ValidFrom"); + + b2.HasKey("ConsentParticipantId", "ConsentId"); + + b2.ToTable("Consent", "Participant"); + + b2.WithOwner() + .HasForeignKey("ConsentParticipantId", "ConsentId"); + }); + + b1.Navigation("Document"); + + b1.Navigation("Lifetime") + .IsRequired(); + }); + + b.OwnsMany("Cfo.Cats.Domain.ValueObjects.Note", "Notes", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("CallReference") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b1.Property("Created") + .HasColumnType("datetime2"); + + b1.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("LastModified") + .HasColumnType("datetime2"); + + b1.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b1.Property("ParticipantId") + .IsRequired() + .HasColumnType("nvarchar(9)"); + + b1.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.HasKey("Id"); + + b1.HasIndex("CreatedBy"); + + b1.HasIndex("LastModifiedBy"); + + b1.HasIndex("ParticipantId"); + + b1.ToTable("Note", "Participant"); + + b1.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy"); + + b1.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedBy"); + + b1.WithOwner() + .HasForeignKey("ParticipantId"); + + b1.Navigation("CreatedByUser"); + + b1.Navigation("LastModifiedByUser"); + }); + + b.OwnsMany("Cfo.Cats.Domain.Entities.Participants.RightToWork", "RightToWorks", b1 => + { + b1.Property("ParticipantId") + .HasColumnType("nvarchar(9)"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Created") + .HasColumnType("datetime2"); + + b1.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("LastModified") + .HasColumnType("datetime2"); + + b1.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("_documentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("DocumentId"); + + b1.HasKey("ParticipantId", "Id"); + + b1.HasIndex("_documentId"); + + b1.ToTable("RightToWork", "Participant"); + + b1.WithOwner() + .HasForeignKey("ParticipantId"); + + b1.HasOne("Cfo.Cats.Domain.Entities.Documents.Document", "Document") + .WithMany() + .HasForeignKey("_documentId"); + + b1.OwnsOne("Cfo.Cats.Domain.ValueObjects.Lifetime", "Lifetime", b2 => + { + b2.Property("RightToWorkParticipantId") + .HasColumnType("nvarchar(9)"); + + b2.Property("RightToWorkId") + .HasColumnType("int"); + + b2.Property("EndDate") + .HasColumnType("datetime2") + .HasColumnName("ValidTo"); + + b2.Property("StartDate") + .HasColumnType("datetime2") + .HasColumnName("ValidFrom"); + + b2.HasKey("RightToWorkParticipantId", "RightToWorkId"); + + b2.ToTable("RightToWork", "Participant"); + + b2.WithOwner() + .HasForeignKey("RightToWorkParticipantId", "RightToWorkId"); + }); + + b1.Navigation("Document"); + + b1.Navigation("Lifetime") + .IsRequired(); + }); + + b.Navigation("Consents"); + + b.Navigation("CurrentLocation"); + + b.Navigation("Editor"); + + b.Navigation("EnrolmentLocation"); + + b.Navigation("Notes"); + + b.Navigation("Owner"); + + b.Navigation("RightToWorks"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.Risk", b => + { + b.HasOne("Cfo.Cats.Domain.Entities.Participants.Participant", null) + .WithMany() + .HasForeignKey("ParticipantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Participants.Timeline", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Cfo.Cats.Domain.Entities.Participants.Participant", null) + .WithMany() + .HasForeignKey("ParticipantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.ApplicationRoleClaim", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationRole", "Role") + .WithMany("RoleClaims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.ApplicationUser", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "Superior") + .WithMany() + .HasForeignKey("SuperiorId"); + + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Cfo.Cats.Domain.ValueObjects.Note", "Notes", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("CallReference") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b1.Property("Created") + .HasColumnType("datetime2"); + + b1.Property("CreatedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("LastModified") + .HasColumnType("datetime2"); + + b1.Property("LastModifiedBy") + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.Property("Message") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b1.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.Property("UserId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b1.HasKey("Id"); + + b1.HasIndex("CreatedBy"); + + b1.HasIndex("LastModifiedBy"); + + b1.HasIndex("UserId"); + + b1.ToTable("Note", "Identity"); + + b1.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy"); + + b1.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedBy"); + + b1.WithOwner() + .HasForeignKey("UserId"); + + b1.Navigation("CreatedByUser"); + + b1.Navigation("LastModifiedByUser"); + }); + + b.Navigation("Notes"); + + b.Navigation("Superior"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.ApplicationUserClaim", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "User") + .WithMany("UserClaims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.ApplicationUserRole", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.ApplicationUserToken", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.UserLogin", b => + { + b.HasOne("Cfo.Cats.Domain.Identity.ApplicationUser", "User") + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TenantLocation", b => + { + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Location", null) + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cfo.Cats.Domain.Entities.Administration.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Administration.Contract", b => + { + b.Navigation("Locations"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Entities.Administration.Location", b => + { + b.Navigation("ChildLocations"); + + b.Navigation("LocationMappings"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.ApplicationRole", b => + { + b.Navigation("RoleClaims"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("Cfo.Cats.Domain.Identity.ApplicationUser", b => + { + b.Navigation("Logins"); + + b.Navigation("Tokens"); + + b.Navigation("UserClaims"); + + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrators/Migrators.MSSQL/Migrations/20240812110232_PasswordHistories.cs b/src/Migrators/Migrators.MSSQL/Migrations/20240812110232_PasswordHistories.cs new file mode 100644 index 00000000..9dec4132 --- /dev/null +++ b/src/Migrators/Migrators.MSSQL/Migrations/20240812110232_PasswordHistories.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cfo.Cats.Migrators.MSSQL.Migrations +{ + /// + public partial class PasswordHistories : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PasswordHistory", + schema: "Identity", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(36)", maxLength: 36, nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PasswordHistory", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PasswordHistory", + schema: "Identity"); + } + } +} diff --git a/src/Migrators/Migrators.MSSQL/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrators/Migrators.MSSQL/Migrations/ApplicationDbContextModelSnapshot.cs index 1ddda9ea..29c936b9 100644 --- a/src/Migrators/Migrators.MSSQL/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrators/Migrators.MSSQL/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1181,6 +1181,31 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserToken", "Identity"); }); + modelBuilder.Entity("Cfo.Cats.Domain.Identity.PasswordHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("nvarchar(36)"); + + b.HasKey("Id"); + + b.ToTable("PasswordHistory", "Identity"); + }); + modelBuilder.Entity("Cfo.Cats.Domain.Identity.UserLogin", b => { b.Property("LoginProvider") diff --git a/src/Server.UI/Pages/Identity/Authentication/Login.razor b/src/Server.UI/Pages/Identity/Authentication/Login.razor index 4553274f..4b32e9c7 100644 --- a/src/Server.UI/Pages/Identity/Authentication/Login.razor +++ b/src/Server.UI/Pages/Identity/Authentication/Login.razor @@ -1,12 +1,13 @@ @page "/pages/authentication/login" -@using Cfo.Cats.Application.Features.Identity.Notifications.SendFactorCode + @using Microsoft.AspNetCore.Authentication @using Cfo.Cats.Domain.Identity @using Cfo.Cats.Application.Common.Interfaces.Identity @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.WebUtilities @using System.Text +@using Cfo.Cats.Application.Features.Identity.Notifications.SendTwoFactorCode @using static Cfo.Cats.Infrastructure.Services.Identity.CustomSigninManager @inject ILogger Logger @@ -164,9 +165,9 @@ { user = await SignInManager.GetTwoFactorAuthenticationUserAsync(); var token = await UserManager.GenerateTwoFactorTokenAsync(user!, "Email"); - await Sender.Publish(new SendFactorCodeNotification(user!.Email!, user.UserName!, token)); + await Sender.Publish(new SendTwoFactorEmailCodeNotification(user!.Email!, user.UserName!, token)); - RedirectManager.RedirectTo(LoginWith2fa.PageUrl, new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = false }); + RedirectManager.RedirectTo(LoginWith2fa.PageUrl); } else if (result.IsLockedOut) { diff --git a/src/Server.UI/Pages/Identity/Authentication/LoginWith2fa.razor b/src/Server.UI/Pages/Identity/Authentication/LoginWith2fa.razor index 7b1be66e..f175ef12 100644 --- a/src/Server.UI/Pages/Identity/Authentication/LoginWith2fa.razor +++ b/src/Server.UI/Pages/Identity/Authentication/LoginWith2fa.razor @@ -1,6 +1,7 @@ @page "/pages/authentication/loginwith2fa" @using System.ComponentModel.DataAnnotations +@using Cfo.Cats.Application.Features.Identity.Notifications.SendTwoFactorCode @using Cfo.Cats.Domain.Identity @using Microsoft.AspNetCore.Identity @@ -9,6 +10,7 @@ @inject UserManager UserManager @inject IdentityRedirectManager RedirectManager @inject ILogger Logger +@inject IMediator Sender @L["Two-factor authentication"] @L["Two-factor authentication"] @@ -18,8 +20,6 @@
- -
@@ -34,18 +34,23 @@
-
-
- -
-
+ + @if (Input.UseSms == false) + { +
+ +
+ }
+ FullWidth="false"> @L["Sign In"]
@@ -61,56 +66,96 @@ [SupplyParameterFromForm] private InputModel Input { get; set; } = new(); - [SupplyParameterFromQuery] - private string? ReturnUrl { get; set; } - - [SupplyParameterFromQuery] - private bool RememberMe { get; set; } - - protected override async Task OnInitializedAsync() { // Ensure the user has gone through the username & password screen first user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? - throw new InvalidOperationException(L["Unable to load two-factor authentication user."]); + throw new InvalidOperationException(L["Unable to load two-factor authentication user."]); + } - } private async Task OnValidSubmitAsync() { - var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty); - var isCodeValid = await UserManager.VerifyTwoFactorTokenAsync(user, "Email", authenticatorCode); - if (!isCodeValid) - { - message = L["Error: Invalid authenticator code."]; - return; - } - var result = await SignInManager.TwoFactorSignInAsync("Email", authenticatorCode, false, false); - var userId = await UserManager.GetUserIdAsync(user); - if (result.Succeeded) + if (Input.UseSms) { - Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId); - RedirectManager.RedirectTo(ReturnUrl); - } - else if (result.IsLockedOut) - { - Logger.LogWarning("User with ID '{UserId}' account locked out.", userId); - RedirectManager.RedirectTo(Lockout.PageUrl); + var token = await UserManager.GenerateTwoFactorTokenAsync(user!, "Email"); + await Sender.Publish(new SendTwoFactorTextCodeNotification(user!.PhoneNumber!, user.UserName!, token)); + Input.UseSms = false; + message = L["Info: A new code has been sent to your registered phone number"]; } else { - Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId); - message = L["Error: Invalid authenticator code."]; + var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty); + var isCodeValid = await UserManager.VerifyTwoFactorTokenAsync(user, "Email", authenticatorCode); + if (isCodeValid == false) + { + await UserManager.AccessFailedAsync(user); + if (await UserManager.IsLockedOutAsync(user)) + { + Logger.LogWarning("User with ID '{UserId}' account locked out.", user.Id); + RedirectManager.RedirectTo(Lockout.PageUrl); + } + else + { + message = L["Error: Invalid authenticator code."]; + } + return; + } + var result = await SignInManager.TwoFactorSignInAsync("Email", authenticatorCode, false, false); + var userId = await UserManager.GetUserIdAsync(user); + if (result.Succeeded) + { + Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId); + RedirectManager.RedirectTo("/"); + } + else if (result.IsLockedOut) + { + Logger.LogWarning("User with ID '{UserId}' account locked out.", userId); + RedirectManager.RedirectTo(Lockout.PageUrl); + } + else + { + Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId); + message = L["Error: Invalid authenticator code."]; + } } + } - private sealed class InputModel + private sealed class InputModel : IValidatableObject { [Required] - [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [Display(Name = "Use SMS Instead")] + public bool UseSms { get; set; } + [DataType(DataType.Text)] [Display(Name = "Authenticator code")] public string? TwoFactorCode { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (UseSms == false) + { + if (string.IsNullOrEmpty(TwoFactorCode)) + { + yield return new ValidationResult("Two factor code is required", [nameof(TwoFactorCode)]); + } + else if (TwoFactorCode.Length is > 7 or < 6) + { + yield return new ValidationResult("Invalid authenticator code", [nameof(TwoFactorCode)]); + } + } + + if (UseSms) + { + if (string.IsNullOrEmpty(TwoFactorCode) == false) + { + yield return new ValidationResult("Authenticator code should not be provided when asking for a text message", [nameof(UseSms)]); + } + } + + } } + } diff --git a/src/Server.UI/appsettings.Development.json b/src/Server.UI/appsettings.Development.json index a5362cc9..a0e2a166 100644 --- a/src/Server.UI/appsettings.Development.json +++ b/src/Server.UI/appsettings.Development.json @@ -2,7 +2,7 @@ "DetailedErrors": true, "AllowlistOptions": { "AllowedIPs": [ - "::1" + /*"::1"*/ ] } } \ No newline at end of file diff --git a/src/Server.UI/appsettings.json b/src/Server.UI/appsettings.json index 1db24c3d..83c5ded0 100644 --- a/src/Server.UI/appsettings.json +++ b/src/Server.UI/appsettings.json @@ -67,7 +67,8 @@ "RequireNonAlphanumeric": true, "RequireUpperCase": true, "RequireLowerCase": true, - "DefaultLockoutTimeSpan": 30 + "DefaultLockoutTimeSpan": 30, + "MaxFailedAccessAttempts": 5 }, "Notify": { "ApiKey": "",