Add password confirm to vault export, refactor vault reset to use same logic (#1687)

This commit is contained in:
Leendert de Borst
2026-02-12 21:59:48 +01:00
committed by Leendert de Borst
parent 8c02715c8a
commit d06e91ce4c
7 changed files with 397 additions and 33 deletions

View File

@@ -0,0 +1,122 @@
@using Microsoft.Extensions.Localization
<FormModal
IsOpen="@IsOpen"
Title="@Title"
MaxWidth="sm"
ConfirmText="@Localizer["ConfirmButton"]"
CancelText="@SharedLocalizer["Cancel"]"
ConfirmDisabled="@(string.IsNullOrEmpty(_password))"
IsLoading="false"
OnConfirm="@HandleConfirm"
OnClose="@HandleClose"
SubmitOnEnter="true">
<Icon>
<svg class="h-6 w-6 text-orange-600 dark:text-orange-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</Icon>
<ChildContent>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
@Description
</p>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<AlertMessageError Message="@ErrorMessage" HasTopMargin="false" />
}
<div class="mt-4">
<label for="password-confirm" class="block text-sm font-medium text-gray-900 dark:text-white mb-2">
@SharedLocalizer["Password"]
</label>
<input
type="password"
id="password-confirm"
@bind="_password"
@bind:event="oninput"
placeholder="@Localizer["EnterPasswordPlaceholder"]"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" />
</div>
</ChildContent>
</FormModal>
@code {
[Inject]
private IStringLocalizerFactory LocalizerFactory { get; set; } = default!;
private IStringLocalizer Localizer => LocalizerFactory.Create("Components.Main.Shared.PasswordConfirmationModal", "AliasVault.Client");
private IStringLocalizer SharedLocalizer => LocalizerFactory.Create("SharedResources", "AliasVault.Client");
/// <summary>
/// Gets or sets whether the modal is open.
/// </summary>
[Parameter]
public bool IsOpen { get; set; }
/// <summary>
/// Gets or sets the modal title.
/// </summary>
[Parameter]
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the description text shown in the modal.
/// </summary>
[Parameter]
public string Description { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the error message to display.
/// </summary>
[Parameter]
public string ErrorMessage { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the callback when password is submitted. Returns the entered password.
/// </summary>
[Parameter]
public EventCallback<string> OnPasswordSubmitted { get; set; }
/// <summary>
/// Gets or sets the callback when modal is closed.
/// </summary>
[Parameter]
public EventCallback OnClose { get; set; }
private string _password = string.Empty;
/// <inheritdoc />
protected override void OnParametersSet()
{
// Reset password when modal is opened
if (IsOpen && string.IsNullOrEmpty(_password))
{
_password = string.Empty;
}
}
/// <summary>
/// Handles the confirm button click - submits the password to the parent.
/// </summary>
private async Task HandleConfirm()
{
if (string.IsNullOrEmpty(_password))
{
return;
}
// Submit password to parent for verification
await OnPasswordSubmitted.InvokeAsync(_password);
_password = string.Empty;
}
/// <summary>
/// Handles the modal close event.
/// </summary>
private async Task HandleClose()
{
_password = string.Empty;
await OnClose.InvokeAsync();
}
}

View File

@@ -6,6 +6,9 @@
@using Microsoft.Extensions.Localization
@using AliasVault.RazorComponents.Services
@using AliasVault.Client.Main.Pages.Settings.ImportExport.Components
@using AliasVault.Client.Main.Components.Shared
@using AliasVault.Client.Services.Auth
@using AliasVault.Client.Services.Auth.Enums
@using AliasVault.ImportExport
<LayoutPageTitle>@Localizer["PageTitle"]</LayoutPageTitle>
@@ -59,7 +62,18 @@
<ResetVaultSection />
<PasswordConfirmationModal
IsOpen="@_showPasswordConfirmation"
Title="@Localizer["ExportPasswordConfirmTitle"]"
Description="@Localizer["ExportPasswordConfirmDescription"]"
ErrorMessage="@_passwordError"
OnPasswordSubmitted="@HandlePasswordSubmitted"
OnClose="@ClosePasswordConfirmation" />
@code {
private string _username = string.Empty;
private bool _showPasswordConfirmation;
private string _passwordError = string.Empty;
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Main.Settings.ImportExport.ImportExport", "AliasVault.Client");
private ExportType _currentExportType;
@@ -74,6 +88,9 @@
{
await base.OnInitializedAsync();
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["PageTitle"] });
// Get username for password verification
_username = await GetUsernameAsync();
}
private async Task ShowExportConfirmation(ExportType exportType)
@@ -87,7 +104,69 @@
return;
}
await HandleExportConfirmed();
// Show password confirmation modal
_passwordError = string.Empty;
_showPasswordConfirmation = true;
StateHasChanged();
}
/// <summary>
/// Handles the password submission - verifies password and proceeds with export.
/// </summary>
private async Task HandlePasswordSubmitted(string password)
{
// Close modal first and show global loading spinner
_showPasswordConfirmation = false;
_passwordError = string.Empty;
StateHasChanged();
GlobalLoadingSpinner.Show(Localizer["VerifyingPasswordMessage"]);
try
{
var result = await AuthService.VerifyPasswordAsync(_username, password);
switch (result)
{
case PasswordVerificationResult.Success:
GlobalLoadingSpinner.Hide();
await HandleExportConfirmed();
break;
case PasswordVerificationResult.InvalidPassword:
GlobalLoadingSpinner.Hide();
_passwordError = Localizer["PasswordIncorrect"];
_showPasswordConfirmation = true;
StateHasChanged();
break;
case PasswordVerificationResult.ServerError:
default:
GlobalLoadingSpinner.Hide();
_passwordError = Localizer["PasswordVerificationFailed"];
_showPasswordConfirmation = true;
StateHasChanged();
break;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error verifying password");
GlobalLoadingSpinner.Hide();
_passwordError = Localizer["PasswordVerificationFailed"];
_showPasswordConfirmation = true;
StateHasChanged();
}
}
/// <summary>
/// Closes the password confirmation modal.
/// </summary>
private void ClosePasswordConfirmation()
{
_showPasswordConfirmation = false;
_passwordError = string.Empty;
StateHasChanged();
}
private async Task HandleExportConfirmed()

View File

@@ -1,14 +1,11 @@
@page "/settings/import-export/reset-vault"
@inherits MainBase
@inject HttpClient Http
@inject ItemService ItemService
@inject ILogger<ResetVault> Logger
@using System.Text.Json
@using AliasVault.Client.Utilities
@using AliasVault.Shared.Models.WebApi.Auth
@using AliasVault.Cryptography.Client
@using System.ComponentModel.DataAnnotations
@using AliasVault.Client.Resources
@using AliasVault.Client.Services.Auth
@using AliasVault.Client.Services.Auth.Enums
@using Microsoft.Extensions.Localization
<LayoutPageTitle>@Localizer["PageTitle"]</LayoutPageTitle>
@@ -85,7 +82,6 @@
private readonly ResetVaultPasswordModel _passwordModel = new();
private IStringLocalizer Localizer => LocalizerFactory.Create("Components.Main.Pages.Settings.ImportExport.ResetVault", "AliasVault.Client");
private IStringLocalizer ApiErrorLocalizer => LocalizerFactory.Create("ApiErrors", "AliasVault.Client");
/// <summary>
/// Whether to show the password confirmation step.
@@ -139,36 +135,22 @@
// Get current username
var username = await GetUsernameAsync();
// Send request to server to get user salt and encryption parameters
var result = await Http.PostAsJsonAsync("v1/Auth/login", new LoginInitiateRequest(username));
var responseContent = await result.Content.ReadAsStringAsync();
// Verify the password using centralized service
var verificationResult = await AuthService.VerifyPasswordAsync(username, _passwordModel.Password);
if (!result.IsSuccessStatusCode)
switch (verificationResult)
{
var errors = ApiResponseUtility.ParseErrorResponse(responseContent, ApiErrorLocalizer);
foreach (var error in errors)
{
GlobalNotificationService.AddErrorMessage(error, true);
}
return;
}
case PasswordVerificationResult.InvalidPassword:
GlobalNotificationService.AddErrorMessage(Localizer["ResetVaultPasswordIncorrect"], true);
return;
var loginResponse = JsonSerializer.Deserialize<LoginInitiateResponse>(responseContent);
if (loginResponse == null)
{
GlobalNotificationService.AddErrorMessage(Localizer["ResetVaultErrorMessage"], true);
return;
}
case PasswordVerificationResult.ServerError:
GlobalNotificationService.AddErrorMessage(Localizer["ResetVaultErrorMessage"], true);
return;
// Derive password hash using server parameters
byte[] passwordHash = await Encryption.DeriveKeyFromPasswordAsync(_passwordModel.Password, loginResponse.Salt, loginResponse.EncryptionType, loginResponse.EncryptionSettings);
// Verify the password locally using the derived password hash
var isValidPassword = await AuthService.ValidateEncryptionKeyAsync(passwordHash);
if (!isValidPassword)
{
GlobalNotificationService.AddErrorMessage(Localizer["ResetVaultPasswordIncorrect"], true);
return;
case PasswordVerificationResult.Success:
// Continue with vault reset
break;
}
// Clear local vault data by hard-deleting all items

View File

@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ConfirmButton" xml:space="preserve">
<value>Confirm</value>
<comment>Button text to confirm password</comment>
</data>
<data name="EnterPasswordPlaceholder" xml:space="preserve">
<value>Enter your password</value>
<comment>Placeholder text for password input</comment>
</data>
<data name="PasswordRequired" xml:space="preserve">
<value>Password is required.</value>
<comment>Error message when password field is empty</comment>
</data>
<data name="PasswordIncorrect" xml:space="preserve">
<value>The password you entered is incorrect. Please try again.</value>
<comment>Error message when password verification fails</comment>
</data>
<data name="VerificationFailed" xml:space="preserve">
<value>An error occurred while verifying your password. Please try again.</value>
<comment>Generic error message for password verification failure</comment>
</data>
</root>

View File

@@ -112,4 +112,24 @@ Are you sure you want to continue with the export?</value>
<value>This option allows you to completely empty your vault while keeping your account and email aliases. Use this if you want to start fresh after importing data from another password manager or if you want to clear all existing credentials to start over.</value>
<comment>Reset vault section description</comment>
</data>
<data name="ExportPasswordConfirmTitle" xml:space="preserve">
<value>Confirm Export</value>
<comment>Title for password confirmation modal during export</comment>
</data>
<data name="ExportPasswordConfirmDescription" xml:space="preserve">
<value>For security reasons, please enter your master password to confirm this export.</value>
<comment>Description for password confirmation modal during export</comment>
</data>
<data name="VerifyingPasswordMessage" xml:space="preserve">
<value>Verifying password...</value>
<comment>Message shown while verifying password</comment>
</data>
<data name="PasswordIncorrect" xml:space="preserve">
<value>The password you entered is incorrect. Please try again.</value>
<comment>Error message when password verification fails</comment>
</data>
<data name="PasswordVerificationFailed" xml:space="preserve">
<value>An error occurred while verifying your password. Please try again.</value>
<comment>Generic error message for password verification failure</comment>
</data>
</root>

View File

@@ -9,6 +9,8 @@ namespace AliasVault.Client.Services.Auth;
using System.Net.Http.Json;
using System.Text.Json;
using AliasVault.Client.Services.Auth.Enums;
using AliasVault.Cryptography.Client;
using AliasVault.Shared.Models.WebApi.Auth;
using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
@@ -282,6 +284,55 @@ public sealed class AuthService(HttpClient httpClient, ILocalStorageService loca
}
}
/// <summary>
/// Verifies a password by deriving the encryption key and validating it against the stored test string.
/// This method handles all the heavy lifting of password verification including fetching encryption
/// parameters from the server and deriving the key.
/// </summary>
/// <param name="username">The username for the account.</param>
/// <param name="password">The password to verify.</param>
/// <returns>A result indicating success or the type of failure.</returns>
public async Task<PasswordVerificationResult> VerifyPasswordAsync(string username, string password)
{
try
{
// Get user's encryption parameters from server
var result = await httpClient.PostAsJsonAsync("v1/Auth/login", new LoginInitiateRequest(username));
var responseContent = await result.Content.ReadAsStringAsync();
if (!result.IsSuccessStatusCode)
{
return PasswordVerificationResult.ServerError;
}
var loginResponse = JsonSerializer.Deserialize<LoginInitiateResponse>(responseContent);
if (loginResponse == null)
{
return PasswordVerificationResult.ServerError;
}
// Derive password hash using server parameters
byte[] passwordHash = await Encryption.DeriveKeyFromPasswordAsync(
password,
loginResponse.Salt,
loginResponse.EncryptionType,
loginResponse.EncryptionSettings);
// Verify the password locally using the derived password hash
var isValidPassword = await ValidateEncryptionKeyAsync(passwordHash);
if (!isValidPassword)
{
return PasswordVerificationResult.InvalidPassword;
}
return PasswordVerificationResult.Success;
}
catch
{
return PasswordVerificationResult.ServerError;
}
}
/// <summary>
/// Stores the new refresh token asynchronously.
/// </summary>

View File

@@ -0,0 +1,29 @@
//-----------------------------------------------------------------------
// <copyright file="PasswordVerificationResult.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Services.Auth.Enums;
/// <summary>
/// Result of a password verification attempt.
/// </summary>
public enum PasswordVerificationResult
{
/// <summary>
/// Password was verified successfully.
/// </summary>
Success,
/// <summary>
/// The password entered was incorrect.
/// </summary>
InvalidPassword,
/// <summary>
/// A server error occurred during verification.
/// </summary>
ServerError,
}