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

@@ -9,8 +9,8 @@
<ServerValidationErrors @ref="ServerValidationErrors" />
<p class="text-gray-700 dark:text-gray-300 mb-6">
You have requested to log in with a recovery code. This login will not be remembered until you provide
an authenticator app code at log in or disable 2FA and log in again.
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 max-w-md">
<EditForm Model="Input" FormName="login-with-recovery-code" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">

View File

@@ -12,9 +12,9 @@
<LayoutPageTitle>Configure authenticator app</LayoutPageTitle>
@if (recoveryCodes is not null)
@if (RecoveryCodes is not null)
{
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()"/>
<ShowRecoveryCodes RecoveryCodes="RecoveryCodes.ToArray()"/>
}
else
{
@@ -34,8 +34,8 @@ else
</p>
</li>
<li>
<p class="text-gray-700 dark:text-gray-300">Scan the QR Code or enter this key <kbd class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg dark:bg-gray-600 dark:text-gray-100 dark:border-gray-500">@sharedKey</kbd> into your two factor authenticator app. Spaces and casing do not matter.</p>
<div id="authenticator-uri" data-url="@authenticatorUri" class="mt-4"></div>
<p class="text-gray-700 dark:text-gray-300">Scan the QR Code or enter this key <kbd class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg dark:bg-gray-600 dark:text-gray-100 dark:border-gray-500">@SharedKey</kbd> into your two factor authenticator app. Spaces and casing do not matter.</p>
<div id="authenticator-uri" data-url="@AuthenticatorUri" class="mt-4"></div>
</li>
<li>
<p class="text-gray-700 dark:text-gray-300">
@@ -67,9 +67,9 @@ else
@code {
private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
private string? sharedKey;
private string? authenticatorUri;
private IEnumerable<string>? recoveryCodes;
private string? SharedKey { get; set; }
private string? AuthenticatorUri { get; set; }
private IEnumerable<string>? RecoveryCodes { get; set; }
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
@@ -77,9 +77,7 @@ else
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadSharedKeyAndQrCodeUriAsync(UserService.User());
await JsInvokeService.RetryInvokeAsync("generateQrCode", TimeSpan.Zero, 5, "authenticator-uri");
}
@@ -88,10 +86,10 @@ else
// Strip spaces and hyphens
var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty);
var is2faTokenValid = await UserManager.VerifyTwoFactorTokenAsync(
var is2FaTokenValid = await UserManager.VerifyTwoFactorTokenAsync(
UserService.User(), UserManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
if (!is2faTokenValid)
if (!is2FaTokenValid)
{
GlobalNotificationService.AddErrorMessage("Error: Verification code is invalid.");
return;
@@ -105,7 +103,7 @@ else
if (await UserManager.CountRecoveryCodesAsync(UserService.User()) == 0)
{
recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10);
RecoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10);
}
else
{
@@ -124,10 +122,10 @@ else
unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user);
}
sharedKey = FormatKey(unformattedKey!);
SharedKey = FormatKey(unformattedKey!);
var username = await UserManager.GetUserNameAsync(user);
authenticatorUri = GenerateQrCodeUri(username!, unformattedKey!);
AuthenticatorUri = GenerateQrCodeUri(username!, unformattedKey!);
}
private string FormatKey(string unformattedKey)

View File

@@ -49,6 +49,9 @@ else
<button @onclick="EnableTwoFactor" class="px-3 py-2 text-xs font-medium text-center text-white bg-red-700 rounded-lg hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800">
Enable 2FA
</button>
<button @onclick="ResetTwoFactor" class="px-3 py-2 text-xs font-medium text-center text-white bg-red-700 rounded-lg hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800">
Remove 2FA keys
</button>
}
}
</div>
@@ -188,6 +191,27 @@ else
StateHasChanged();
}
/// <summary>
/// This method enables two-factor authentication for the user based on existing keys. If no keys are present
/// then 2FA will not work. The user will need to manually set up a new authenticator device.
/// </summary>
private async Task EnableTwoFactor()
{
User = await DbContext.AliasVaultUsers.FindAsync(Id);
if (User != null)
{
User.TwoFactorEnabled = true;
await DbContext.SaveChangesAsync();
await LoadEntryAsync();
}
}
/// <summary>
/// This method disables two-factor authentication for the user. This will NOT remove the authenticator keys.
/// This means the admin can re-enable 2FA for the user without the user having to set up a new authenticator
/// keys.
/// </summary>
private async Task DisableTwoFactor()
{
User = await DbContext.AliasVaultUsers.FindAsync(Id);
@@ -200,13 +224,22 @@ else
}
}
private async Task EnableTwoFactor()
/// <summary>
/// This method resets the two-factor authentication for the user which will remove all authenticator keys. The
/// next time the user enables two-factor authentication new keys will be generated. When keys are removed it
/// also means 2FA cannot be re-enabled until the user manually sets up a new authenticator device.
/// </summary>
private async Task ResetTwoFactor()
{
User = await DbContext.AliasVaultUsers.FindAsync(Id);
if (User != null)
{
User.TwoFactorEnabled = true;
// Remove all authenticator keys and recovery codes.
await DbContext.UserTokens
.Where(x => x.UserId == User.Id && (x.Name == "AuthenticatorKey" || x.Name == "RecoveryCodes"))
.ForEachAsync(x => DbContext.UserTokens.Remove(x));
await DbContext.SaveChangesAsync();
await LoadEntryAsync();
}

View File

@@ -1085,6 +1085,11 @@ video {
border-top-width: 1px;
}
.border-amber-200 {
--tw-border-opacity: 1;
border-color: rgb(253 230 138 / var(--tw-border-opacity));
}
.border-gray-200 {
--tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity));
@@ -1110,24 +1115,19 @@ video {
border-color: rgb(239 68 68 / var(--tw-border-opacity));
}
.border-yellow-500 {
--tw-border-opacity: 1;
border-color: rgb(234 179 8 / var(--tw-border-opacity));
}
.border-blue-300 {
--tw-border-opacity: 1;
border-color: rgb(147 197 253 / var(--tw-border-opacity));
}
.border-sky-200 {
--tw-border-opacity: 1;
border-color: rgb(186 230 253 / var(--tw-border-opacity));
}
.border-amber-200 {
.border-yellow-500 {
--tw-border-opacity: 1;
border-color: rgb(253 230 138 / var(--tw-border-opacity));
border-color: rgb(234 179 8 / var(--tw-border-opacity));
}
.bg-amber-50 {
--tw-bg-opacity: 1;
background-color: rgb(255 251 235 / var(--tw-bg-opacity));
}
.bg-blue-500 {
@@ -1235,6 +1235,16 @@ video {
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
}
.bg-red-700 {
--tw-bg-opacity: 1;
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
.bg-sky-50 {
--tw-bg-opacity: 1;
background-color: rgb(240 249 255 / var(--tw-bg-opacity));
}
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
@@ -1250,26 +1260,6 @@ video {
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
.bg-red-700 {
--tw-bg-opacity: 1;
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
.bg-blue-50 {
--tw-bg-opacity: 1;
background-color: rgb(239 246 255 / var(--tw-bg-opacity));
}
.bg-sky-50 {
--tw-bg-opacity: 1;
background-color: rgb(240 249 255 / var(--tw-bg-opacity));
}
.bg-amber-50 {
--tw-bg-opacity: 1;
background-color: rgb(255 251 235 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -1453,6 +1443,11 @@ video {
line-height: 2.25rem;
}
.text-amber-700 {
--tw-text-opacity: 1;
color: rgb(180 83 9 / var(--tw-text-opacity));
}
.text-blue-600 {
--tw-text-opacity: 1;
color: rgb(37 99 235 / var(--tw-text-opacity));
@@ -1518,6 +1513,11 @@ video {
color: rgb(153 27 27 / var(--tw-text-opacity));
}
.text-sky-700 {
--tw-text-opacity: 1;
color: rgb(3 105 161 / var(--tw-text-opacity));
}
.text-white {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
@@ -1533,21 +1533,6 @@ video {
color: rgb(133 77 14 / var(--tw-text-opacity));
}
.text-blue-800 {
--tw-text-opacity: 1;
color: rgb(30 64 175 / var(--tw-text-opacity));
}
.text-sky-700 {
--tw-text-opacity: 1;
color: rgb(3 105 161 / var(--tw-text-opacity));
}
.text-amber-700 {
--tw-text-opacity: 1;
color: rgb(180 83 9 / var(--tw-text-opacity));
}
.underline {
text-decoration-line: underline;
}
@@ -1755,6 +1740,11 @@ video {
border-color: rgb(75 85 99 / var(--tw-divide-opacity));
}
.dark\:border-amber-800:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(146 64 14 / var(--tw-border-opacity));
}
.dark\:border-gray-500:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(107 114 128 / var(--tw-border-opacity));
@@ -1770,21 +1760,11 @@ video {
border-color: rgb(55 65 81 / var(--tw-border-opacity));
}
.dark\:border-blue-800:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(30 64 175 / var(--tw-border-opacity));
}
.dark\:border-sky-800:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(7 89 133 / var(--tw-border-opacity));
}
.dark\:border-amber-800:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(146 64 14 / var(--tw-border-opacity));
}
.dark\:bg-gray-600:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
@@ -1820,30 +1800,35 @@ video {
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
}
.dark\:bg-red-900:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(127 29 29 / var(--tw-bg-opacity));
}
.dark\:bg-yellow-900:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(113 63 18 / var(--tw-bg-opacity));
}
.dark\:bg-red-600:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
}
.dark\:bg-red-900:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(127 29 29 / var(--tw-bg-opacity));
}
.dark\:bg-slate-800:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(30 41 59 / var(--tw-bg-opacity));
}
.dark\:bg-yellow-900:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(113 63 18 / var(--tw-bg-opacity));
}
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}
.dark\:text-amber-300:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(252 211 77 / var(--tw-text-opacity));
}
.dark\:text-blue-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(96 165 250 / var(--tw-text-opacity));
@@ -1904,6 +1889,11 @@ video {
color: rgb(248 113 113 / var(--tw-text-opacity));
}
.dark\:text-sky-300:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(125 211 252 / var(--tw-text-opacity));
}
.dark\:text-white:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
@@ -1919,16 +1909,6 @@ video {
color: rgb(254 240 138 / var(--tw-text-opacity));
}
.dark\:text-sky-300:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(125 211 252 / var(--tw-text-opacity));
}
.dark\:text-amber-300:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(252 211 77 / var(--tw-text-opacity));
}
.dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity));

View File

@@ -50,6 +50,11 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
/// </summary>
private static readonly string[] Invalid2FaCode = ["Invalid authenticator code."];
/// <summary>
/// Error message for invalid 2-factor authentication recovery code.
/// </summary>
private static readonly string[] InvalidRecoveryCode = ["Invalid recovery code."];
/// <summary>
/// Login endpoint used to process login attempt using credentials.
/// </summary>
@@ -168,6 +173,58 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
}
}
/// <summary>
/// Validate login including two factor authentication recovery code check.
/// </summary>
/// <param name="model">ValidateLoginRequestRecoveryCode model.</param>
/// <returns>Task.</returns>
[HttpPost("validate-recovery-code")]
public async Task<IActionResult> ValidateRecoveryCode([FromBody] ValidateLoginRequestRecoveryCode model)
{
var user = await userManager.FindByNameAsync(model.Username);
if (user == null)
{
return BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400));
}
if (!cache.TryGetValue(model.Username, out var serverSecretEphemeral) || serverSecretEphemeral is not string)
{
return BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400));
}
try
{
var serverSession = Cryptography.Srp.DeriveSessionServer(
serverSecretEphemeral.ToString() ?? string.Empty,
model.ClientPublicEphemeral,
user.Salt,
model.Username,
user.Verifier,
model.ClientSessionProof);
// If above does not throw an exception., then the client's proof is valid. Next check 2-factor code.
// Sanitize recovery code.
var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty).ToUpper();
// Attempt to redeem the recovery code
var result = await userManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode);
if (!result.Succeeded)
{
return BadRequest(ServerValidationErrorResponse.Create(InvalidRecoveryCode, 400));
}
// Generate and return the JWT token.
var tokenModel = await GenerateNewTokensForUser(user);
return Ok(new ValidateLoginResponse(false, serverSession.Proof, tokenModel));
}
catch
{
return BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400));
}
}
/// <summary>
/// Refresh endpoint used to refresh an expired access token using a valid refresh token.
/// </summary>
@@ -258,7 +315,7 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
}
var encodedKey = urlEncoder.Encode(authenticatorKey!);
var qrCodeUrl = $"otpauth://totp/{urlEncoder.Encode("AliasVault WASM")}:{urlEncoder.Encode(user.UserName!)}?secret={encodedKey}&issuer={urlEncoder.Encode("AliasVault WASM")}";
var qrCodeUrl = $"otpauth://totp/{urlEncoder.Encode("AliasVault")}:{urlEncoder.Encode(user.UserName!)}?secret={encodedKey}&issuer={urlEncoder.Encode("AliasVault")}";
return Ok(new { Secret = authenticatorKey, QrCodeUrl = qrCodeUrl });
}
@@ -278,12 +335,12 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
await using var context = await dbContextFactory.CreateDbContextAsync();
// Disable 2FA and remove any existing authenticator key(s).
// Disable 2FA and remove any existing authenticator key(s) and recovery codes.
await userManager.SetTwoFactorEnabledAsync(user, false);
context.UserTokens.RemoveRange(
context.UserTokens.Where(
x => x.UserId == user.Id &&
x.Name == "AuthenticatorKey").ToList());
(x.Name == "AuthenticatorKey" || x.Name == "RecoveryCodes")).ToList());
await context.SaveChangesAsync();
return Ok();
@@ -308,12 +365,14 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
if (isValid)
{
await userManager.SetTwoFactorEnabledAsync(user, true);
return Ok();
}
else
{
return BadRequest("Invalid code.");
// Generate new recovery codes.
var recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
return Ok(new { RecoveryCodes = recoveryCodes });
}
return BadRequest("Invalid code.");
}
/// <summary>

View File

@@ -8,7 +8,7 @@
namespace AliasVault.Api.Controllers;
using AliasServerDb;
using AliasVault.Shared.Models;
using AliasVault.Shared.Models.WebApi.Favicon;
using Asp.Versioning;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;

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);

View File

@@ -1,43 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="RegisterModel.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models;
using System.ComponentModel.DataAnnotations;
using AliasVault.Shared.Models.Validation;
/// <summary>
/// Register model.
/// </summary>
public class RegisterModel
{
/// <summary>
/// Gets or sets the username.
/// </summary>
[Required]
public string Username { get; set; } = null!;
/// <summary>
/// Gets or sets the password.
/// </summary>
[Required]
[MinLength(8, ErrorMessage = "Password must be at least 8 characters long.")]
public string Password { get; set; } = null!;
/// <summary>
/// Gets or sets the password confirmation.
/// </summary>
[Required]
[Compare("Password", ErrorMessage = "Passwords do not match.")]
public string PasswordConfirm { get; set; } = null!;
/// <summary>
/// Gets or sets a value indicating whether the terms and conditions are accepted or not.
/// </summary>
[MustBeTrue(ErrorMessage = "You must accept the terms and conditions.")]
public bool AcceptTerms { get; set; } = false;
}

View File

@@ -5,7 +5,7 @@
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models;
namespace AliasVault.Shared.Models.WebApi.Auth;
using System.ComponentModel.DataAnnotations;

View File

@@ -5,12 +5,12 @@
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models;
namespace AliasVault.Shared.Models.WebApi.Auth;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// Login model for two factor authentication step.
/// Login model for two factor authentication step using an authenticator code.
/// </summary>
public class LoginModel2Fa
{

View File

@@ -0,0 +1,22 @@
//-----------------------------------------------------------------------
// <copyright file="LoginModelRecoveryCode.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.Auth;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// Login model for two factor authentication step using a recovery code.
/// </summary>
public class LoginModelRecoveryCode
{
/// <summary>
/// Gets or sets the recovery code.
/// </summary>
[Required]
public string RecoveryCode { get; set; } = null!;
}

View File

@@ -7,37 +7,37 @@
namespace AliasVault.Shared.Models.WebApi.Auth;
using System.ComponentModel.DataAnnotations;
using AliasVault.Shared.Models.Validation;
/// <summary>
/// This class represents the model for registering a new user
/// using SRP (Secure Remote Password) protocol.
/// Register model.
/// </summary>
public class RegisterModel
{
/// <summary>
/// Initializes a new instance of the <see cref="RegisterModel"/> class.
/// Gets or sets the username.
/// </summary>
/// <param name="email">Email.</param>
/// <param name="salt">Salt.</param>
/// <param name="verifier">Verifier.</param>
public RegisterModel(string email, string salt, string verifier)
{
Email = email;
Salt = salt;
Verifier = verifier;
}
[Required]
public string Username { get; set; } = null!;
/// <summary>
/// Gets or sets the email.
/// Gets or sets the password.
/// </summary>
public string Email { get; set; }
[Required]
[MinLength(8, ErrorMessage = "Password must be at least 8 characters long.")]
public string Password { get; set; } = null!;
/// <summary>
/// Gets or sets the salt.
/// Gets or sets the password confirmation.
/// </summary>
public string Salt { get; set; }
[Required]
[Compare("Password", ErrorMessage = "Passwords do not match.")]
public string PasswordConfirm { get; set; } = null!;
/// <summary>
/// Gets or sets the verifier.
/// Gets or sets a value indicating whether the terms and conditions are accepted or not.
/// </summary>
public string Verifier { get; set; }
[MustBeTrue(ErrorMessage = "You must accept the terms and conditions.")]
public bool AcceptTerms { get; set; } = false;
}

View File

@@ -0,0 +1,43 @@
//-----------------------------------------------------------------------
// <copyright file="RegisterModelOld.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.Auth;
/// <summary>
/// This class represents the model for registering a new user
/// using SRP (Secure Remote Password) protocol.
/// </summary>
public class RegisterModelOld
{
/// <summary>
/// Initializes a new instance of the <see cref="RegisterModelOld"/> class.
/// </summary>
/// <param name="email">Email.</param>
/// <param name="salt">Salt.</param>
/// <param name="verifier">Verifier.</param>
public RegisterModelOld(string email, string salt, string verifier)
{
Email = email;
Salt = salt;
Verifier = verifier;
}
/// <summary>
/// Gets or sets the email.
/// </summary>
public string Email { get; set; }
/// <summary>
/// Gets or sets the salt.
/// </summary>
public string Salt { get; set; }
/// <summary>
/// Gets or sets the verifier.
/// </summary>
public string Verifier { get; set; }
}

View File

@@ -5,7 +5,7 @@
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models;
namespace AliasVault.Shared.Models.WebApi.Auth;
using System.Text.Json.Serialization;

View File

@@ -5,7 +5,7 @@
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models;
namespace AliasVault.Shared.Models.WebApi.Auth;
using System.ComponentModel.DataAnnotations;

View File

@@ -0,0 +1,33 @@
//-----------------------------------------------------------------------
// <copyright file="ValidateLoginRequestRecoveryCode.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.Auth
{
/// <summary>
/// Represents a request to validate a login with added 2-factor authentication code.
/// </summary>
public class ValidateLoginRequestRecoveryCode : ValidateLoginRequest
{
/// <summary>
/// Initializes a new instance of the <see cref="ValidateLoginRequestRecoveryCode"/> class.
/// </summary>
/// <param name="username">Username.</param>
/// <param name="clientPublicEphemeral">Client public ephemeral.</param>
/// <param name="clientSessionProof">Client session proof.</param>
/// <param name="recoveryCode">2-factor recovery code.</param>
public ValidateLoginRequestRecoveryCode(string username, string clientPublicEphemeral, string clientSessionProof, string recoveryCode)
: base(username, clientPublicEphemeral, clientSessionProof)
{
RecoveryCode = recoveryCode;
}
/// <summary>
/// Gets the 2-factor authentication recovery code.
/// </summary>
public string RecoveryCode { get; }
}
}

View File

@@ -5,7 +5,7 @@
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models;
namespace AliasVault.Shared.Models.WebApi.Favicon;
/// <summary>
/// FaviconExtractModel model.

View File

@@ -19,7 +19,11 @@ public static class CredentialCsvService
{
private const string CsvVersionIdentifier = "1.0.0";
/// <param name="filePath">The file path of the CSV file.</param>
/// <summary>
/// Export list of credentials to CSV file.
/// </summary>
/// <param name="credentials">List of credentials to export.</param>
/// <returns>CSV file as byte array.</returns>
public static byte[] ExportCredentialsToCsv(List<Credential> credentials)
{
var records = new List<CredentialCsvRecord>();
@@ -30,7 +34,7 @@ public static class CredentialCsvService
{
Version = CsvVersionIdentifier,
Id = credential.Id,
Username = credential.Username,
Username = credential.Username ?? string.Empty,
Notes = credential.Notes ?? string.Empty,
CreatedAt = credential.CreatedAt,
UpdatedAt = credential.UpdatedAt,