Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Setup 2FA #19

Merged
merged 2 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/Application/Common/Interfaces/ICommunicationsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Cfo.Cats.Application.Common.Interfaces;

public interface ICommunicationsService
{
Task SendSmsCodeAsync(string mobileNumber, string code);
Task SendEmailCodeAsync(string email, string code);
}
7 changes: 0 additions & 7 deletions src/Application/Common/Interfaces/IMailService.cs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,29 +1,13 @@
namespace Cfo.Cats.Application.Features.Identity.Notifications.SendFactorCode;

public class SendFactorCodeNotificationHandler : INotificationHandler<SendFactorCodeNotification>
public class SendFactorCodeNotificationHandler(
ILogger<SendFactorCodeNotificationHandler> logger,
ICommunicationsService communicationsService
) : INotificationHandler<SendFactorCodeNotification>
{
private readonly IStringLocalizer<SendFactorCodeNotificationHandler> localizer;
private readonly ILogger<SendFactorCodeNotificationHandler> logger;
private readonly IMailService mailService;

public SendFactorCodeNotificationHandler(
IStringLocalizer<SendFactorCodeNotificationHandler> localizer,
ILogger<SendFactorCodeNotificationHandler> logger,
IMailService mailService
)
{
this.localizer = localizer;
this.logger = logger;
this.mailService = mailService;
}

public async Task Handle(
SendFactorCodeNotification notification,
CancellationToken cancellationToken
)
public async Task Handle(SendFactorCodeNotification notification, CancellationToken cancellationToken)
{
var subject = localizer["Your Verification Code"];
await mailService.SendAsync(notification.Email, subject, notification.AuthenticatorCode);
await communicationsService.SendEmailCodeAsync(notification.Email, notification.AuthenticatorCode);
logger.LogInformation("Verification Code email sent to {Email})", notification.Email);
}
}
20 changes: 16 additions & 4 deletions src/Infrastructure/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ public static IServiceCollection AddInfrastructure(
IConfiguration configuration
)
{
services.AddSettings(configuration).AddDatabase(configuration).AddServices();
services.AddSettings(configuration)
.AddDatabase(configuration)
.AddServices(configuration);

services.AddAuthenticationService(configuration).AddFusionCacheService();

Expand Down Expand Up @@ -125,7 +127,7 @@ string connectionString
}
}

private static IServiceCollection AddServices(this IServiceCollection services)
private static IServiceCollection AddServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton<PicklistService>()
.AddSingleton<IPicklistService>(sp =>
Expand All @@ -143,14 +145,16 @@ private static IServiceCollection AddServices(this IServiceCollection services)
service.Initialize();
return service;
});

services.Configure<NotifyOptions>(configuration.GetSection(NotifyOptions.Notify));

return services
.AddSingleton<ISerializer, SystemTextJsonSerializer>()
.AddScoped<ICurrentUserService, CurrentUserService>()
.AddScoped<ITenantProvider, TenantProvider>()
.AddScoped<IValidationService, ValidationService>()
.AddScoped<IDateTime, DateTimeService>()
.AddScoped<IMailService, MailService>()
.AddScoped<ICommunicationsService, CommunicationsService>()
.AddScoped<IExcelService, ExcelService>()
.AddScoped<IUploadService, UploadService>();
}
Expand All @@ -160,13 +164,21 @@ private static IServiceCollection AddAuthenticationService(
IConfiguration configuration
)
{

services.Configure<AllowlistOptions>(configuration.GetSection(nameof(AllowlistOptions)));

services
.AddIdentityCore<ApplicationUser>()
.AddRoles<ApplicationRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddSignInManager()
//.AddSignInManager()
.AddClaimsPrincipalFactory<ApplicationUserClaimsPrincipalFactory>()
.AddDefaultTokenProviders();

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


services.Configure<IdentityOptions>(options =>
{
var identitySettings = configuration
Expand Down
2 changes: 2 additions & 0 deletions src/Infrastructure/Infrastructure.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
<ItemGroup>

<PackageReference Include="EFCore.NamingConventions" Version="8.0.3" />

<PackageReference Include="GovukNotify" Version="7.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
Expand Down
45 changes: 45 additions & 0 deletions src/Infrastructure/Services/CommunicationsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Notify.Client;

namespace Cfo.Cats.Infrastructure.Services;

public class CommunicationsService(IOptions<NotifyOptions> options, ILogger<CommunicationsService> logger) : ICommunicationsService
{
public Task SendSmsCodeAsync(string mobileNumber, string code)
{
throw new NotImplementedException();
}
public async Task SendEmailCodeAsync(string email, string code)
{
try
{
var client = Client();
var response = await client.SendEmailAsync(emailAddress: email,
templateId: options.Value.EmailTemplate,
personalisation: new Dictionary<string, dynamic>() {
{
"subject",
"Your two factor authentication code."
},
{
"body", $"Your two factor authentication code is {code}"
}
});
}
catch (Exception e)
{
logger.LogError("Failed to send Email code. {e}", e);
}
}

private NotificationClient Client() => new(options.Value.ApiKey);
}

public class NotifyOptions
{

public const string Notify = "Notify";
public string ApiKey { get; set; }
public string SmsTemplate { get; set; }

public string EmailTemplate { get; set; }
}
6 changes: 6 additions & 0 deletions src/Infrastructure/Services/Identity/AllowlistOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Cfo.Cats.Infrastructure.Services.Identity;

public class AllowlistOptions
{
public List<string> AllowedIPs { get; set; } = new();
}
33 changes: 33 additions & 0 deletions src/Infrastructure/Services/Identity/CustomSigninManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;

namespace Cfo.Cats.Infrastructure.Services.Identity;

public class CustomSigninManager<TUser>(UserManager<TUser> userManager, IHttpContextAccessor contextAccessor, IUserClaimsPrincipalFactory<TUser> claimsFactory, IOptions<IdentityOptions> optionsAccessor, ILogger<SignInManager<TUser>> logger, IAuthenticationSchemeProvider schemes, IUserConfirmation<TUser> confirmation, IHttpContextAccessor httpContextAccessor, IOptions<AllowlistOptions> allowlistOptions)
: SignInManager<TUser>(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation)
where TUser : class
{
public override async Task<SignInResult> PasswordSignInAsync(string userName, string password, bool isPersistent, bool lockoutOnFailure)
{
var user = await UserManager.FindByNameAsync(userName);

if (user == null)
{
return SignInResult.Failed;
}

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)
{
await SignInAsync(user, isPersistent);
}
return result;
}
return await base.PasswordSignInAsync(userName, password, isPersistent, lockoutOnFailure);
}


}
14 changes: 0 additions & 14 deletions src/Infrastructure/Services/MailService.cs

This file was deleted.

19 changes: 5 additions & 14 deletions src/Server.UI/Pages/Identity/Authentication/Login.razor
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,6 @@
</div>
</div>
<div Class="d-flex justify-space-between align-center mb-1">
<label class="form-label">
<InputCheckbox @bind-Value="Input.RememberMe" class="form-check-input" />
@L["Remember me"]
</label>
<MudLink Href="@Forgot.PageUrl">@L["Forgot password?"]</MudLink>
</div>

Expand Down Expand Up @@ -123,8 +119,7 @@
private InputModel Input { get; set; } = new()
{
UserName = "",
Password = "",
RememberMe = true
Password = ""
};

protected override async Task OnInitializedAsync()
Expand All @@ -141,22 +136,19 @@
{
// This doesn't count login failures towards account lockout
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
var result = await SignInManager.PasswordSignInAsync(Input.UserName, Input.Password, Input.RememberMe, lockoutOnFailure: false);
var result = await SignInManager.PasswordSignInAsync(Input.UserName, Input.Password, false, lockoutOnFailure: true);
if (result.Succeeded)
{
Logger.LogInformation($"{Input.UserName} logged in.");
RedirectManager.RedirectTo(ReturnUrl);
}
else if (result.RequiresTwoFactor)
{
//var user = await UserManager.FindByNameAsync(Input.UserName);
var user = await SignInManager.GetTwoFactorAuthenticationUserAsync();
var token = await UserManager.GenerateTwoFactorTokenAsync(user, "Email");
await Sender.Publish(new SendFactorCodeNotification(user.Email, user.UserName, token));

RedirectManager.RedirectTo(LoginWith2fa.PageUrl, new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe });

var token = await UserManager.GenerateTwoFactorTokenAsync(user!, "Email");
await Sender.Publish(new SendFactorCodeNotification(user.Email!, user.UserName!, token));

RedirectManager.RedirectTo(LoginWith2fa.PageUrl, new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = false });
}
else if (result.IsLockedOut)
{
Expand All @@ -177,6 +169,5 @@
[Required(ErrorMessage = "Password cannot be empty")]
[StringLength(30, ErrorMessage = "Password must be at least 6 characters long.", MinimumLength = 6)]
public string Password { get; set; } = "";
public bool RememberMe { get; set; } = true;
}
}
19 changes: 1 addition & 18 deletions src/Server.UI/Pages/Identity/Authentication/LoginWith2fa.razor
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,6 @@
</div>
</div>
</div>

<div class="checkbox mb-3">
<label for="remember-machine" class="form-label">
<InputCheckbox @bind-Value="Input.RememberMachine" />
Remember this machine
</label>
</div>
<div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Expand All @@ -58,13 +51,6 @@
</div>
</EditForm>
</div>

@*
<MudText Typo="Typo.body1">
@L["Don't have access to your authenticator device? You can"]
</MudText>
<a class="mud-button-root mud-button mud-button-text mud-button-text-default mud-button-text-size-medium mud-ripple" href="@($"{LoginWithRecoveryCode.PageUrl}?ReturnUrl={ReturnUrl}")">log in with a recovery code</a>.
*@
</div>

@code {
Expand Down Expand Up @@ -100,7 +86,7 @@
message = L["Error: Invalid authenticator code."];
return;
}
var result = await SignInManager.TwoFactorSignInAsync("Email", authenticatorCode, true, true);
var result = await SignInManager.TwoFactorSignInAsync("Email", authenticatorCode, false, false);
var userId = await UserManager.GetUserIdAsync(user);
if (result.Succeeded)
{
Expand All @@ -126,8 +112,5 @@
[DataType(DataType.Text)]
[Display(Name = "Authenticator code")]
public string? TwoFactorCode { get; set; }

[Display(Name = "Remember this machine")]
public bool RememberMachine { get; set; }
}
}
11 changes: 6 additions & 5 deletions src/Server.UI/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
/* "DatabaseSettings": {
"DbProvider": "sqlite",
"ConnectionString": "Data Source=./cats.db;"
},*/
"DetailedErrors": true
"DetailedErrors": true,
"AllowlistOptions": {
"AllowedIPs": [
"::1"
]
}
}
10 changes: 10 additions & 0 deletions src/Server.UI/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,15 @@
"RequireUpperCase": false,
"RequireLowerCase": false,
"DefaultLockoutTimeSpan": 30
},
"Notify": {
"ApiKey": "",
"SmsTemplate": "",
"EmailTemplate": ""
},
"AllowlistOptions": {
"AllowedIPs": [

]
}
}
Loading