Add recovery code support to client 2FA login flow (#70)

This commit is contained in:
Leendert de Borst
2024-08-26 12:36:15 +02:00
parent 329281cd53
commit 9ef078bd57
26 changed files with 507 additions and 209 deletions

View File

@@ -3,9 +3,8 @@
@layout Auth.Layout.MainLayout
@attribute [AllowAnonymous]
@using System.Text.Json
@using AliasVault.Shared.Models
@using AliasVault.Client.Auth.Components
@using AliasVault.Shared.Models.WebApi.Auth
@using AliasVault.Client.Auth.Components
@using Cryptography
@using SecureRemotePassword
@@ -40,7 +39,36 @@
</div>
<p class="mt-6 text-sm text-gray-700 dark:text-gray-300">
Don't have access to your authenticator device? You can
<a href="user/loginWithRecoveryCode" class="text-primary-600 hover:underline dark:text-primary-500">log in with a recovery code</a>.
<button @onclick="LoginWithRecoveryCode" class="text-primary-600 hover:underline dark:text-primary-500">log in with a recovery code</button>.
</p>
}
else if (ShowLoginWithRecoveryCodeStep)
{
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Recovery code verification
</h2>
<ServerValidationErrors @ref="ServerValidationErrors" />
<p class="text-gray-700 dark:text-gray-300 mb-6">
You have requested to log in with a recovery code. A recovery code is a one-time code that can be used to log in to your account.
Note that if you don't manually disable 2FA after login, you will be asked for an authenticator code again at the next login.
</p>
<div class="w-full">
<EditForm Model="LoginModelRecoveryCode" FormName="login-with-recovery-code" OnValidSubmit="HandleRecoveryCode" method="post" class="space-y-6">
<DataAnnotationsValidator/>
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
<div>
<label for="two-factor-code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Recovery Code</label>
<InputText @bind-Value="LoginModelRecoveryCode.RecoveryCode" id="two-factor-code" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" autocomplete="off"/>
<ValidationMessage For="() => LoginModelRecoveryCode.RecoveryCode" class="text-red-600 dark:text-red-400 text-sm mt-1"/>
</div>
<button type="submit" class="w-full text-white bg-primary-600 hover:bg-primary-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Log in</button>
</EditForm>
</div>
<p class="mt-6 text-sm text-gray-700 dark:text-gray-300">
Regained access to your authenticator device? You can
<button @onclick="LoginWithAuthenticator" class="text-primary-600 hover:underline dark:text-primary-500">log in with authenticator code</button> instead.
</p>
}
else
@@ -85,9 +113,11 @@ else
@code {
private readonly LoginModel LoginModel = new();
private readonly LoginModel2Fa LoginModel2Fa = new();
private readonly LoginModelRecoveryCode LoginModelRecoveryCode = new();
private FullScreenLoadingIndicator LoadingIndicator = new();
private ServerValidationErrors ServerValidationErrors = new();
private bool ShowTwoFactorAuthStep = false;
private bool ShowTwoFactorAuthStep;
private bool ShowLoginWithRecoveryCodeStep;
private SrpEphemeral ClientEphemeral = new();
private SrpSession ClientSession = new();
@@ -104,6 +134,20 @@ else
}
}
private void LoginWithAuthenticator()
{
ShowLoginWithRecoveryCodeStep = false;
ShowTwoFactorAuthStep = true;
StateHasChanged();
}
private void LoginWithRecoveryCode()
{
ShowLoginWithRecoveryCodeStep = true;
ShowTwoFactorAuthStep = false;
StateHasChanged();
}
private async Task HandleLogin()
{
LoadingIndicator.Show();
@@ -136,6 +180,61 @@ else
}
}
private async Task HandleRecoveryCode()
{
LoadingIndicator.Show();
ServerValidationErrors.Clear();
try
{
// Sanitize username
var username = LoginModel.Username.ToLowerInvariant().Trim();
// Validate 2-factor auth code auth and login
var result = await Http.PostAsJsonAsync("api/v1/Auth/validate-recovery-code", new ValidateLoginRequestRecoveryCode(username, ClientEphemeral.Public, ClientSession.Proof, LoginModelRecoveryCode.RecoveryCode));
var responseContent = await result.Content.ReadAsStringAsync();
if (!result.IsSuccessStatusCode)
{
foreach (var error in ParseResponse(responseContent))
{
ServerValidationErrors.AddError(error);
return;
}
}
var validateLoginResponse = JsonSerializer.Deserialize<ValidateLoginResponse>(responseContent);
if (validateLoginResponse == null)
{
ServerValidationErrors.AddError("An error occurred while processing the login request.");
return;
}
var errors = await ProcessLoginVerify(validateLoginResponse);
foreach (var error in errors)
{
ServerValidationErrors.AddError(error);
}
}
#if DEBUG
catch (Exception ex)
{
// If in debug mode show the actual exception.
ServerValidationErrors.AddError(ex.ToString());
}
#else
catch
{
// If in release mode show a generic error.
ServerValidationErrors.AddError("An error occurred while processing the login request. Try again (later).");
}
#endif
finally
{
LoadingIndicator.Hide();
}
}
private async Task Handle2Fa()
{
LoadingIndicator.Show();

View File

@@ -6,7 +6,7 @@
@inject NavigationManager NavigationManager
@inject AuthService AuthService
@using System.Text.Json
@using AliasVault.Shared.Models
@using AliasVault.Shared.Models.WebApi.Auth
@using AliasVault.Client.Auth.Components
@using AliasVault.Client.Auth.Pages.Base
@using Cryptography

View File

@@ -11,7 +11,7 @@
</div>
</div>
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Email Settings</h3>
<div class="mb-4">

View File

@@ -0,0 +1,33 @@
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Recovery codes</h3>
<div class="text-sm text-gray-600 dark:text-gray-400 mb-4">
The recovery codes below are used to access your account in case you lose access to your authenticator device.
Make a photo or write them down and store them in a secure location. Do not share them with anyone.
</div>
<div class="bg-primary-100 border-l-4 border-primary-500 text-primary-700 p-4 mb-4" role="alert">
<p class="font-semibold">
Store these recovery codes in a safe place.
</p>
<p>
If you lose your device and don't have the recovery codes you will lose access to your account. These
codes are only shown once!
</p>
</div>
<div class="grid grid-cols-1">
@foreach (var recoveryCode in RecoveryCodes)
{
<div>
<code class="block p-2">@recoveryCode</code>
</div>
}
</div>
</div>
@code {
/// <summary>
/// The recovery codes to show.
/// </summary>
[Parameter]
public string[] RecoveryCodes { get; set; } = [];
}

View File

@@ -24,7 +24,7 @@ else
<div class="mb-3 text-sm text-gray-600 dark:text-gray-400">Two factor authentication is currently enabled. Disable it in order to be able to access your vault with your password only.</div>
<button @onclick="DisableTwoFactor"
class="bg-red-500 text-white py-2 px-4 rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition duration-150 ease-in-out">
Disable Two-Factor Authentication
Confirm Disable Two-Factor Authentication
</button>
</div>
}

View File

@@ -1,23 +1,28 @@
@page "/settings/security/enable-2fa"
@using AliasVault.Client.Main.Pages.Settings.Security.Components
@inherits MainBase
@inject HttpClient Http
<LayoutPageTitle>Enable two-factor authentication</LayoutPageTitle>
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
<div class="mb-4 col-span-full xl:mb-2">
<Breadcrumb BreadcrumbItems="BreadcrumbItems"/>
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Enable two-factor authentication</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Enable two-factor authentication to increase the security of your vaults.</p>
</div>
</div>
@if (IsLoading)
{
<LoadingIndicator />
}
else if (RecoveryCodes is not null)
{
<ShowRecoveryCodes RecoveryCodes="RecoveryCodes.ToArray()"/>
}
else
{
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
<div class="mb-4 col-span-full xl:mb-2">
<Breadcrumb BreadcrumbItems="BreadcrumbItems"/>
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Enable two-factor authentication</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Enable two-factor authentication to increase the security of your vaults.</p>
</div>
</div>
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="space-y-6">
<div id="authenticator-uri" data-url="@QrCodeUrl" class="flex justify-center">
@@ -49,6 +54,7 @@ else
private string Secret { get; set; } = string.Empty;
private VerificationModel VerifyModel = new();
private bool IsLoading { get; set; } = true;
private List<string>? RecoveryCodes { get; set; }
/// <inheritdoc />
protected override async Task OnInitializedAsync()
@@ -76,7 +82,11 @@ else
if (result != null)
{
QrCodeUrl = result.QrCodeUrl;
Secret = result.Secret;
// Make secret more readable by adding spaces every 4 characters
Secret = string.Join(" ", Enumerable.Range(0, result.Secret.Length / 4)
.Select(i => result.Secret.Substring(i * 4, 4))).ToLower();
IsLoading = false;
StateHasChanged();
JsInteropService.GenerateQrCode("authenticator-uri");
@@ -93,15 +103,23 @@ else
var response = await Http.PostAsJsonAsync("api/v1/Auth/verify-2fa", VerifyModel.Code);
if (response.IsSuccessStatusCode)
{
GlobalNotificationService.AddSuccessMessage("Two-factor authentication is now successfully enabled. On your " +
"next login you will need to enter your 2FA code.");
NavigationManager.NavigateTo("/settings/security");
}
else
{
GlobalNotificationService.AddErrorMessage("Failed to enable two-factor authentication.", true);
StateHasChanged();
var result = await response.Content.ReadFromJsonAsync<TotpVerifyResult>();
if (result != null)
{
GlobalNotificationService.AddSuccessMessage("Two-factor authentication is now successfully enabled. On your " +
"next login you will need to enter your 2FA code.", true);
// Show recovery codes.
RecoveryCodes = result.RecoveryCodes;
IsLoading = false;
StateHasChanged();
return;
}
}
GlobalNotificationService.AddErrorMessage("Failed to enable two-factor authentication.", true);
StateHasChanged();
}
private class TotpSetupResult
@@ -110,6 +128,11 @@ else
public string QrCodeUrl { get; set; } = string.Empty;
}
private class TotpVerifyResult
{
public List<string> RecoveryCodes { get; set; } = new();
}
private class VerificationModel
{
public string Code { get; set; } = string.Empty;

View File

@@ -9,7 +9,7 @@ namespace AliasVault.Client.Services.Auth;
using System.Net.Http.Json;
using System.Text.Json;
using AliasVault.Shared.Models;
using AliasVault.Shared.Models.WebApi.Auth;
using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;

View File

@@ -17,7 +17,7 @@ using AliasClientDb;
using AliasGenerators.Identity.Implementations;
using AliasGenerators.Identity.Models;
using AliasGenerators.Password.Implementations;
using AliasVault.Shared.Models;
using AliasVault.Shared.Models.WebApi.Favicon;
using Microsoft.EntityFrameworkCore;
using Identity = AliasGenerators.Identity.Models.Identity;

View File

@@ -1206,6 +1206,10 @@ video {
border-left-width: 0px;
}
.border-l-4 {
border-left-width: 4px;
}
.border-t {
border-top-width: 1px;
}
@@ -1245,6 +1249,11 @@ video {
border-color: rgb(248 185 99 / var(--tw-border-opacity));
}
.border-primary-500 {
--tw-border-opacity: 1;
border-color: rgb(244 149 65 / var(--tw-border-opacity));
}
.border-primary-600 {
--tw-border-opacity: 1;
border-color: rgb(214 131 56 / var(--tw-border-opacity));
@@ -1330,11 +1339,21 @@ video {
background-color: rgb(253 222 133 / var(--tw-bg-opacity));
}
.bg-primary-200 {
--tw-bg-opacity: 1;
background-color: rgb(251 203 116 / var(--tw-bg-opacity));
}
.bg-primary-300 {
--tw-bg-opacity: 1;
background-color: rgb(248 185 99 / var(--tw-bg-opacity));
}
.bg-primary-500 {
--tw-bg-opacity: 1;
background-color: rgb(244 149 65 / var(--tw-bg-opacity));
}
.bg-primary-600 {
--tw-bg-opacity: 1;
background-color: rgb(214 131 56 / var(--tw-bg-opacity));
@@ -1370,11 +1389,6 @@ video {
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.bg-primary-500 {
--tw-bg-opacity: 1;
background-color: rgb(244 149 65 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -1872,6 +1886,11 @@ video {
background-color: rgb(246 167 82 / var(--tw-bg-opacity));
}
.hover\:bg-primary-600:hover {
--tw-bg-opacity: 1;
background-color: rgb(214 131 56 / var(--tw-bg-opacity));
}
.hover\:bg-primary-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(184 112 47 / var(--tw-bg-opacity));
@@ -1897,11 +1916,6 @@ video {
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
}
.hover\:bg-primary-600:hover {
--tw-bg-opacity: 1;
background-color: rgb(214 131 56 / var(--tw-bg-opacity));
}
.hover\:from-primary-600:hover {
--tw-gradient-from: #d68338 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(214 131 56 / 0) var(--tw-gradient-to-position);