mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-19 05:47:43 -04:00
Add recovery code support to client 2FA login flow (#70)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user