Skip to content

Commit

Permalink
Changes as a result of password and lockout review.
Browse files Browse the repository at this point in the history
- enable sms 2FA
- remember previous passwords and prevent re-use
- lockout account after 5 failed attempts
  • Loading branch information
carlsixsmith-moj committed Aug 12, 2024
1 parent 028ea1a commit 2a5e64e
Show file tree
Hide file tree
Showing 24 changed files with 2,705 additions and 83 deletions.
5 changes: 5 additions & 0 deletions db/seed/001_development_seed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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'[email protected]', N'[email protected]', N'[email protected]', N'[email protected]', 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'[email protected]', N'[email protected]', N'[email protected]', N'[email protected]', 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');
Expand Down
2 changes: 2 additions & 0 deletions src/Application/Common/Interfaces/IApplicationDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ public interface IApplicationDbContext
ChangeTracker ChangeTracker { get; }

DbSet<DataProtectionKey> DataProtectionKeys { get; }

DbSet<PasswordHistory> PasswordHistories { get; }

}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace Cfo.Cats.Application.Features.Identity.Notifications.SendTwoFactorCode;

public record SendTwoFactorEmailCodeNotification(string Email, string UserName, string AuthenticatorCode)
: INotification;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Cfo.Cats.Application.Features.Identity.Notifications.SendTwoFactorCode;

public class SendTwoFactorEmailCodeNotificationHandler(
ILogger<SendTwoFactorEmailCodeNotificationHandler> logger,
ICommunicationsService communicationsService
) : INotificationHandler<SendTwoFactorEmailCodeNotification>
{
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace Cfo.Cats.Application.Features.Identity.Notifications.SendTwoFactorCode;

public record SendTwoFactorTextCodeNotification(string MobileNumber, string UserName, string AuthenticatorCode)
: INotification;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Cfo.Cats.Application.Features.Identity.Notifications.SendTwoFactorCode;

public class SendTwoFactorTextCodeNotificationHandler(ICommunicationsService communicationsService, ILogger<SendTwoFactorTextCodeNotificationHandler> logger)
: INotificationHandler<SendTwoFactorTextCodeNotification>
{
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);
}
}
9 changes: 9 additions & 0 deletions src/Domain/Identity/PasswordHistory.cs
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 8 additions & 2 deletions src/Infrastructure/Configurations/IdentitySettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ public class IdentitySettings : IIdentitySettings

// Lockout settings.
/// <summary>
/// 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.
/// </summary>
public int DefaultLockoutTimeSpan { get; set; } = 30;
public int DefaultLockoutTimeSpan { get; set; } = 10;

/// <summary>
/// Gets or sets a value indicating how many failed attempts will trigger an account lockout
/// </summary>
public int MaxFailedAccessAttempts { get; set; } = 5;

}
4 changes: 3 additions & 1 deletion src/Infrastructure/Constants/Database/DatabaseConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);



}
Expand Down
8 changes: 4 additions & 4 deletions src/Infrastructure/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,12 @@ private static IServiceCollection AddAuthenticationService(this IServiceCollecti
.AddIdentityCore<ApplicationUser>()
.AddRoles<ApplicationRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddUserManager<ApplicationUserManager>()
//.AddSignInManager()
.AddClaimsPrincipalFactory<ApplicationUserClaimsPrincipalFactory>()
.AddDefaultTokenProviders();

services.AddScoped<UserManager<ApplicationUser>, ApplicationUserManager>();
services.AddScoped<SignInManager<ApplicationUser>, CustomSigninManager>();
services.AddScoped<ISecurityStampValidator, SecurityStampValidator<ApplicationUser>>();

Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/Infrastructure/Persistence/ApplicationDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
public DbSet<EnrolmentQa2QueueEntry> EnrolmentQa2Queue => Set<EnrolmentQa2QueueEntry>();
public DbSet<EnrolmentEscalationQueueEntry> EnrolmentEscalationQueue => Set<EnrolmentEscalationQueueEntry>();

public DbSet<PasswordHistory> PasswordHistories => Set<PasswordHistory>();

public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();

protected override void OnModelCreating(ModelBuilder builder)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PasswordHistory>
{
public void Configure(EntityTypeBuilder<PasswordHistory> 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();
}
}
20 changes: 18 additions & 2 deletions src/Infrastructure/Services/CommunicationsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,25 @@ namespace Cfo.Cats.Infrastructure.Services;

public class CommunicationsService(IOptions<NotifyOptions> options, ILogger<CommunicationsService> 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<string, dynamic>()
{
{
"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)
{
Expand Down
106 changes: 106 additions & 0 deletions src/Infrastructure/Services/Identity/ApplicationUserManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using Cfo.Cats.Domain.Identity;

namespace Cfo.Cats.Infrastructure.Services.Identity;

public class ApplicationUserManager(IUserStore<ApplicationUser> store, IOptions<IdentityOptions> optionsAccessor, IPasswordHasher<ApplicationUser> passwordHasher, IEnumerable<IUserValidator<ApplicationUser>> userValidators, IEnumerable<IPasswordValidator<ApplicationUser>> passwordValidators, ILookupNormalizer keyNormalizer, IdentityErrorDescriber errors, IServiceProvider services, ILogger<ApplicationUserManager> logger)
: UserManager<ApplicationUser>(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger)
{
private readonly IServiceProvider _serviceProvider = services;

public override async Task<IdentityResult> 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<IdentityResult> 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<bool> IsPasswordInHistory(ApplicationUser user, string newPassword)
{
var passwordHasher = new PasswordHasher<ApplicationUser>();

using var scope = _serviceProvider.CreateScope();
var uow = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();

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<IUnitOfWork>();
var passwordHistory = new PasswordHistory()
{
UserId = user.Id,
PasswordHash = userPasswordHash,
CreatedAt = DateTime.UtcNow
};
uow.DbContext.PasswordHistories.Add(passwordHistory);
await uow.SaveChangesAsync();
}

}

29 changes: 15 additions & 14 deletions src/Infrastructure/Services/Identity/CustomSigninManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,25 @@ public override async Task<SignInResult> 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
{
Expand Down
Loading

0 comments on commit 2a5e64e

Please sign in to comment.