From d06e91ce4cab7581ae2ad5e4bc72f9442e029146 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Thu, 12 Feb 2026 21:59:48 +0100 Subject: [PATCH] Add password confirm to vault export, refactor vault reset to use same logic (#1687) --- .../Shared/PasswordConfirmationModal.razor | 122 ++++++++++++++++++ .../Settings/ImportExport/ImportExport.razor | 81 +++++++++++- .../Settings/ImportExport/ResetVault.razor | 46 ++----- .../Shared/PasswordConfirmationModal.en.resx | 81 ++++++++++++ .../ImportExport/ImportExport.en.resx | 20 +++ .../Services/Auth/AuthService.cs | 51 ++++++++ .../Auth/Enums/PasswordVerificationResult.cs | 29 +++++ 7 files changed, 397 insertions(+), 33 deletions(-) create mode 100644 apps/server/AliasVault.Client/Main/Components/Shared/PasswordConfirmationModal.razor create mode 100644 apps/server/AliasVault.Client/Resources/Components/Main/Shared/PasswordConfirmationModal.en.resx create mode 100644 apps/server/AliasVault.Client/Services/Auth/Enums/PasswordVerificationResult.cs diff --git a/apps/server/AliasVault.Client/Main/Components/Shared/PasswordConfirmationModal.razor b/apps/server/AliasVault.Client/Main/Components/Shared/PasswordConfirmationModal.razor new file mode 100644 index 000000000..be504e75a --- /dev/null +++ b/apps/server/AliasVault.Client/Main/Components/Shared/PasswordConfirmationModal.razor @@ -0,0 +1,122 @@ +@using Microsoft.Extensions.Localization + + + + + + + + +

+ @Description +

+ + @if (!string.IsNullOrEmpty(ErrorMessage)) + { + + } + +
+ + +
+
+
+ +@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"); + + /// + /// Gets or sets whether the modal is open. + /// + [Parameter] + public bool IsOpen { get; set; } + + /// + /// Gets or sets the modal title. + /// + [Parameter] + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the description text shown in the modal. + /// + [Parameter] + public string Description { get; set; } = string.Empty; + + /// + /// Gets or sets the error message to display. + /// + [Parameter] + public string ErrorMessage { get; set; } = string.Empty; + + /// + /// Gets or sets the callback when password is submitted. Returns the entered password. + /// + [Parameter] + public EventCallback OnPasswordSubmitted { get; set; } + + /// + /// Gets or sets the callback when modal is closed. + /// + [Parameter] + public EventCallback OnClose { get; set; } + + private string _password = string.Empty; + + /// + protected override void OnParametersSet() + { + // Reset password when modal is opened + if (IsOpen && string.IsNullOrEmpty(_password)) + { + _password = string.Empty; + } + } + + /// + /// Handles the confirm button click - submits the password to the parent. + /// + private async Task HandleConfirm() + { + if (string.IsNullOrEmpty(_password)) + { + return; + } + + // Submit password to parent for verification + await OnPasswordSubmitted.InvokeAsync(_password); + _password = string.Empty; + } + + /// + /// Handles the modal close event. + /// + private async Task HandleClose() + { + _password = string.Empty; + await OnClose.InvokeAsync(); + } +} diff --git a/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/ImportExport.razor b/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/ImportExport.razor index e524522ce..d3d9005e5 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/ImportExport.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/ImportExport.razor @@ -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 @Localizer["PageTitle"] @@ -59,7 +62,18 @@ + + @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(); + } + + /// + /// Handles the password submission - verifies password and proceeds with export. + /// + 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(); + } + } + + /// + /// Closes the password confirmation modal. + /// + private void ClosePasswordConfirmation() + { + _showPasswordConfirmation = false; + _passwordError = string.Empty; + StateHasChanged(); } private async Task HandleExportConfirmed() diff --git a/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/ResetVault.razor b/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/ResetVault.razor index 8d642c066..2df4f361f 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/ResetVault.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/ResetVault.razor @@ -1,14 +1,11 @@ @page "/settings/import-export/reset-vault" @inherits MainBase -@inject HttpClient Http @inject ItemService ItemService @inject ILogger 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 @Localizer["PageTitle"] @@ -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"); /// /// 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(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 diff --git a/apps/server/AliasVault.Client/Resources/Components/Main/Shared/PasswordConfirmationModal.en.resx b/apps/server/AliasVault.Client/Resources/Components/Main/Shared/PasswordConfirmationModal.en.resx new file mode 100644 index 000000000..bf0062f74 --- /dev/null +++ b/apps/server/AliasVault.Client/Resources/Components/Main/Shared/PasswordConfirmationModal.en.resx @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Confirm + Button text to confirm password + + + Enter your password + Placeholder text for password input + + + Password is required. + Error message when password field is empty + + + The password you entered is incorrect. Please try again. + Error message when password verification fails + + + An error occurred while verifying your password. Please try again. + Generic error message for password verification failure + + diff --git a/apps/server/AliasVault.Client/Resources/Pages/Main/Settings/ImportExport/ImportExport.en.resx b/apps/server/AliasVault.Client/Resources/Pages/Main/Settings/ImportExport/ImportExport.en.resx index 8a131dfa3..b9487202f 100644 --- a/apps/server/AliasVault.Client/Resources/Pages/Main/Settings/ImportExport/ImportExport.en.resx +++ b/apps/server/AliasVault.Client/Resources/Pages/Main/Settings/ImportExport/ImportExport.en.resx @@ -112,4 +112,24 @@ Are you sure you want to continue with the export? 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. Reset vault section description + + Confirm Export + Title for password confirmation modal during export + + + For security reasons, please enter your master password to confirm this export. + Description for password confirmation modal during export + + + Verifying password... + Message shown while verifying password + + + The password you entered is incorrect. Please try again. + Error message when password verification fails + + + An error occurred while verifying your password. Please try again. + Generic error message for password verification failure + \ No newline at end of file diff --git a/apps/server/AliasVault.Client/Services/Auth/AuthService.cs b/apps/server/AliasVault.Client/Services/Auth/AuthService.cs index 44a330817..d95c0de69 100644 --- a/apps/server/AliasVault.Client/Services/Auth/AuthService.cs +++ b/apps/server/AliasVault.Client/Services/Auth/AuthService.cs @@ -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 } } + /// + /// 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. + /// + /// The username for the account. + /// The password to verify. + /// A result indicating success or the type of failure. + public async Task 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(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; + } + } + /// /// Stores the new refresh token asynchronously. /// diff --git a/apps/server/AliasVault.Client/Services/Auth/Enums/PasswordVerificationResult.cs b/apps/server/AliasVault.Client/Services/Auth/Enums/PasswordVerificationResult.cs new file mode 100644 index 000000000..b2af29023 --- /dev/null +++ b/apps/server/AliasVault.Client/Services/Auth/Enums/PasswordVerificationResult.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Client.Services.Auth.Enums; + +/// +/// Result of a password verification attempt. +/// +public enum PasswordVerificationResult +{ + /// + /// Password was verified successfully. + /// + Success, + + /// + /// The password entered was incorrect. + /// + InvalidPassword, + + /// + /// A server error occurred during verification. + /// + ServerError, +}