mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-02-20 07:54:10 -05:00
432 lines
17 KiB
Plaintext
432 lines
17 KiB
Plaintext
@page "/unlock"
|
|
@page "/unlock/{SkipWebAuthn:bool}"
|
|
@inherits AliasVault.Client.Auth.Pages.Base.LoginBase
|
|
@inject ILogger<Unlock> Logger
|
|
@inject IJSRuntime JSRuntime
|
|
@layout Auth.Layout.MainLayout
|
|
@using System.Text.Json
|
|
@using AliasVault.Client.Auth.Components
|
|
@using AliasVault.Client.Auth.Models
|
|
@using AliasVault.Client.Utilities
|
|
@using AliasVault.Shared.Models.WebApi.Auth
|
|
@using AliasVault.Cryptography.Client
|
|
@using Microsoft.Extensions.Localization
|
|
|
|
<FullScreenLoadingIndicator @ref="_loadingIndicator" />
|
|
|
|
@if (IsLoading) {
|
|
<ServerValidationErrors @ref="_serverValidationErrors" />
|
|
<BoldLoadingIndicator />
|
|
}
|
|
else if (IsWebAuthnLoading) {
|
|
<ServerValidationErrors @ref="_serverValidationErrors" />
|
|
<BoldLoadingIndicator />
|
|
<p class="mt-6 text-center font-normal text-gray-500 dark:text-gray-400">
|
|
@Localizer["LoggingInWithWebAuthn"]
|
|
</p>
|
|
}
|
|
else
|
|
{
|
|
<div class="flex space-x-4">
|
|
<div class="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
|
<span class="text-primary-600 dark:text-primary-400 text-base font-medium">
|
|
@(Username?.FirstOrDefault().ToString().ToUpper() ?? "?")
|
|
</span>
|
|
</div>
|
|
<h2 class="mb-3 text-2xl font-bold text-gray-900 dark:text-white">@Username</h2>
|
|
</div>
|
|
|
|
@if (ShowWebAuthnButton)
|
|
{
|
|
<div class="mb-6">
|
|
<p class="text-base font-normal text-gray-500 dark:text-gray-400 mb-4">
|
|
@Localizer["QuickUnlockDescription"]
|
|
</p>
|
|
|
|
<ServerValidationErrors @ref="_serverValidationErrors" />
|
|
|
|
<div class="flex space-x-4">
|
|
<button type="button" @onclick="UnlockWithWebAuthn" class="flex-grow inline-flex items-center justify-center px-5 py-2 text-base font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
|
|
<svg class="w-5 h-5 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"></path><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd"></path></svg>
|
|
@Localizer["UnlockWithWebAuthn"]
|
|
</button>
|
|
<button type="button" @onclick="async () => await ShowPasswordLogin()" class="inline-flex items-center justify-center px-5 py-2 text-base font-medium text-center text-gray-900 rounded-lg border border-gray-300 hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 dark:text-white dark:border-gray-700 dark:hover:bg-gray-700 dark:focus:ring-gray-800">
|
|
@Localizer["UnlockWithPassword"]
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<p class="text-base font-normal text-gray-500 dark:text-gray-400 mb-4">
|
|
@Localizer["EnterMasterPasswordDescription"]
|
|
</p>
|
|
|
|
<ServerValidationErrors @ref="_serverValidationErrors" />
|
|
|
|
<EditForm Model="_unlockModel" OnValidSubmit="UnlockSubmit" class="mt-4 space-y-6">
|
|
<DataAnnotationsValidator/>
|
|
<div>
|
|
<label asp-for="Input.Password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Localizer["YourPasswordLabel"]</label>
|
|
<PasswordInputField @ref="passwordField" id="password" @bind-Value="_unlockModel.Password" placeholder="••••••••"/>
|
|
<ValidationMessage For="() => _unlockModel.Password"/>
|
|
</div>
|
|
|
|
<button type="submit" id="unlock-button" class="w-full px-5 py-2 text-base font-medium text-center text-white bg-primary-700 rounded-lg hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800 flex items-center justify-center gap-2">
|
|
@Localizer["UnlockButton"]
|
|
</button>
|
|
</EditForm>
|
|
|
|
<button type="button" id="mobile-unlock-button" @onclick="() => _showMobileUnlockModal = true" class="hidden md:flex w-full px-5 py-2 text-base font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:hover:bg-gray-600 dark:focus:ring-gray-700 items-center justify-center gap-2 mt-4">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
|
</svg>
|
|
@Localizer["UnlockWithMobileButton"]
|
|
</button>
|
|
}
|
|
|
|
<div class="text-sm text-center font-medium text-gray-500 dark:text-gray-400 mt-6">
|
|
@Localizer["SwitchAccountsText"] <a href="/user/logout" class="text-primary-700 hover:underline dark:text-primary-500">@Localizer["LogOutLink"]</a>
|
|
</div>
|
|
}
|
|
|
|
<FooterLogin />
|
|
|
|
<MobileUnlockModal IsOpen="@_showMobileUnlockModal"
|
|
OnClose="() => _showMobileUnlockModal = false"
|
|
OnSuccess="HandleMobileUnlockSuccess"
|
|
Mode="unlock" />
|
|
|
|
@code {
|
|
/// <summary>
|
|
/// Skip automatic WebAuthn unlock during page load if set to true.
|
|
/// </summary>
|
|
[Parameter]
|
|
public bool SkipWebAuthn { get; set; }
|
|
|
|
private string? Username { get; set; }
|
|
private bool IsLoading { get; set; } = true;
|
|
private bool IsWebAuthnLoading { get; set; }
|
|
private bool ShowWebAuthnButton { get; set; }
|
|
private bool IsPasswordFocused { get; set; }
|
|
private bool _showMobileUnlockModal;
|
|
private readonly UnlockModel _unlockModel = new();
|
|
private FullScreenLoadingIndicator _loadingIndicator = new();
|
|
private ServerValidationErrors _serverValidationErrors = new();
|
|
private PasswordInputField? passwordField;
|
|
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Auth.Unlock", "AliasVault.Client");
|
|
private IStringLocalizer ApiErrorLocalizer => LocalizerFactory.Create("ApiErrors", "AliasVault.Client");
|
|
private IStringLocalizer SharedLocalizer => LocalizerFactory.Create("SharedResources", "AliasVault.Client");
|
|
|
|
/// <inheritdoc />
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
if (firstRender)
|
|
{
|
|
// Trigger status API call to check if the user is still authenticated.
|
|
// If user is not authenticated a redirect to the login page will be triggered automatically.
|
|
await StatusCheck();
|
|
|
|
// Always check if WebAuthn is enabled
|
|
ShowWebAuthnButton = await AuthService.IsWebAuthnEnabledAsync();
|
|
|
|
// Try to unlock with WebAuthn if enabled and not explicitly skipped
|
|
if (ShowWebAuthnButton && !SkipWebAuthn)
|
|
{
|
|
// Add delay to ensure the AliasVault browser extension interceptor doesn't filter
|
|
// this as an "automatic request". The extension blocks WebAuthn requests within
|
|
// 1 second of page load to prevent interference with sites that auto-trigger passkeys.
|
|
await Task.Delay(1100);
|
|
await UnlockWithWebAuthn();
|
|
}
|
|
|
|
IsLoading = false;
|
|
StateHasChanged();
|
|
}
|
|
|
|
if (!IsLoading && !IsWebAuthnLoading && !ShowWebAuthnButton && !IsPasswordFocused)
|
|
{
|
|
IsPasswordFocused = true;
|
|
await FocusPasswordField();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Execute the unlock form submission.
|
|
/// </summary>
|
|
private async Task UnlockSubmit()
|
|
{
|
|
_loadingIndicator.Show(Localizer["UnlockingVaultMessage"]);
|
|
_serverValidationErrors.Clear();
|
|
|
|
try
|
|
{
|
|
await StatusCheck();
|
|
|
|
// Send request to server with email to get user salt.
|
|
var result = await Http.PostAsJsonAsync("v1/Auth/login", new LoginInitiateRequest(Username!));
|
|
var responseContent = await result.Content.ReadAsStringAsync();
|
|
|
|
if (!result.IsSuccessStatusCode)
|
|
{
|
|
var errors = ApiResponseUtility.ParseErrorResponse(responseContent, ApiErrorLocalizer);
|
|
foreach (var error in errors)
|
|
{
|
|
_serverValidationErrors.AddError(error);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var loginResponse = JsonSerializer.Deserialize<LoginInitiateResponse>(responseContent);
|
|
if (loginResponse == null)
|
|
{
|
|
_serverValidationErrors.AddError(Localizer["UnlockRequestError"]);
|
|
return;
|
|
}
|
|
|
|
// 3. Client derives shared session key.
|
|
byte[] passwordHash = await Encryption.DeriveKeyFromPasswordAsync(_unlockModel.Password, loginResponse.Salt, loginResponse.EncryptionType, loginResponse.EncryptionSettings);
|
|
|
|
// Check if the password is correct locally by decrypting the test string.
|
|
var validPassword = await AuthService.ValidateEncryptionKeyAsync(passwordHash);
|
|
|
|
if (!validPassword)
|
|
{
|
|
_serverValidationErrors.AddError(Localizer["IncorrectPasswordError"]);
|
|
return;
|
|
}
|
|
|
|
// Store the encryption key in memory.
|
|
await AuthService.StoreEncryptionKeyAsync(passwordHash);
|
|
|
|
// Redirect to the page the user was trying to access before if set.
|
|
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>(ReturnUrlKey);
|
|
if (!string.IsNullOrEmpty(localStorageReturnUrl))
|
|
{
|
|
await LocalStorage.RemoveItemAsync(ReturnUrlKey);
|
|
NavigationManager.NavigateTo(localStorageReturnUrl);
|
|
}
|
|
else
|
|
{
|
|
NavigationManager.NavigateTo("/");
|
|
}
|
|
}
|
|
#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(Localizer["GenericUnlockError"]);
|
|
}
|
|
#endif
|
|
finally
|
|
{
|
|
_loadingIndicator.Hide();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Make a request to the server to check if access token is still valid.
|
|
/// If not, then this call will automatically result in redirect to the login page.
|
|
/// </summary>
|
|
private async Task StatusCheck()
|
|
{
|
|
await AuthStateProvider.GetAuthenticationStateAsync();
|
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
if (authState.User.Identity?.IsAuthenticated == false) {
|
|
// Not authenticated (anymore), redirect to login page.
|
|
GlobalNotificationService.AddErrorMessage(Localizer["SessionTimedOutError"]);
|
|
NavigationManager.NavigateTo("/user/login");
|
|
return;
|
|
}
|
|
|
|
// Check if encryption key test string is available. If not
|
|
// user should log in again.
|
|
if (!await AuthService.HasEncryptionKeyTestStringAsync())
|
|
{
|
|
// Clear all tokens and redirect to login page.
|
|
await AuthService.RemoveTokensAsync();
|
|
GlobalNotificationService.ClearMessages();
|
|
GlobalNotificationService.AddErrorMessage(Localizer["SessionTimedOutError"]);
|
|
NavigationManager.NavigateTo("/user/login");
|
|
return;
|
|
}
|
|
|
|
// Check if username is set.
|
|
// If not, redirect to login page.
|
|
Username = authState.User.Identity?.Name;
|
|
if (Username is null)
|
|
{
|
|
// Clear all tokens and redirect to login page.
|
|
await AuthService.RemoveTokensAsync();
|
|
await AuthStateProvider.GetAuthenticationStateAsync();
|
|
GlobalNotificationService.ClearMessages();
|
|
GlobalNotificationService.AddErrorMessage(Localizer["SessionTimedOutError"]);
|
|
NavigationManager.NavigateTo("/user/login");
|
|
return;
|
|
}
|
|
|
|
// Make a request to the server to check if the user is still authenticated.
|
|
// If user has no valid authentication an automatic redirect to login page will take place.
|
|
try
|
|
{
|
|
await Http.GetAsync("v1/Auth/status");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_serverValidationErrors.AddError(Localizer["ConnectionFailedError"]);
|
|
Logger.LogError(ex, "An error occurred while checking the user status.");
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unlock the vault with WebAuthn if enabled.
|
|
/// </summary>
|
|
/// <returns>Task.</returns>
|
|
private async Task UnlockWithWebAuthn()
|
|
{
|
|
await AuthStateProvider.GetAuthenticationStateAsync();
|
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
|
|
// Check if webauthn is enabled. If so, try to unlock the vault with it.
|
|
if (await AuthService.IsWebAuthnEnabledAsync())
|
|
{
|
|
IsLoading = false;
|
|
IsWebAuthnLoading = true;
|
|
StateHasChanged();
|
|
|
|
try
|
|
{
|
|
var decryptionKey = await AuthService.GetDecryptedWebAuthnEncryptionKeyAsync(authState.User.Identity!.Name!);
|
|
if (decryptionKey.Length == 0)
|
|
{
|
|
Logger.LogWarning("No decryption key found for user {Username}. Falling back to password unlock.", authState.User.Identity!.Name!);
|
|
return;
|
|
}
|
|
|
|
if (await AuthService.ValidateEncryptionKeyAsync(decryptionKey))
|
|
{
|
|
await AuthService.StoreEncryptionKeyAsync(decryptionKey);
|
|
NavigationManager.NavigateTo("/");
|
|
}
|
|
else
|
|
{
|
|
Logger.LogWarning("The decrypted encryption key does not match the persisted encryption key. Falling back to password unlock.");
|
|
}
|
|
}
|
|
catch (NotSupportedException)
|
|
{
|
|
GlobalNotificationService.AddErrorMessage(Localizer["WebAuthnNotSupportedError"], true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "An error occurred while trying to unlock the vault with WebAuthn.");
|
|
}
|
|
finally
|
|
{
|
|
IsWebAuthnLoading = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Show the password login form.
|
|
/// </summary>
|
|
private async Task ShowPasswordLogin()
|
|
{
|
|
ShowWebAuthnButton = false;
|
|
StateHasChanged();
|
|
|
|
// Focus the password field after the form is rendered
|
|
if (!IsPasswordFocused)
|
|
{
|
|
IsPasswordFocused = true;
|
|
await FocusPasswordField();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Focus the password input field using component reference.
|
|
/// </summary>
|
|
private async Task FocusPasswordField()
|
|
{
|
|
try
|
|
{
|
|
if (passwordField != null)
|
|
{
|
|
await passwordField.FocusAsync();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Do nothing
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle successful mobile unlock.
|
|
/// </summary>
|
|
private async Task HandleMobileUnlockSuccess(MobileLoginResult result)
|
|
{
|
|
_loadingIndicator.Show(Localizer["UnlockingVaultMessage"]);
|
|
_serverValidationErrors.Clear();
|
|
|
|
try
|
|
{
|
|
await StatusCheck();
|
|
|
|
// Revoke existing tokens
|
|
await AuthService.RemoveTokensAsync();
|
|
|
|
// Store the new tokens
|
|
await AuthService.StoreAccessTokenAsync(result.Token);
|
|
await AuthService.StoreRefreshTokenAsync(result.RefreshToken);
|
|
|
|
// Convert decryption key from base64 string to byte array
|
|
var decryptionKeyBytes = Convert.FromBase64String(result.DecryptionKey);
|
|
|
|
// Store the encryption key in memory
|
|
await AuthService.StoreEncryptionKeyAsync(decryptionKeyBytes);
|
|
|
|
await AuthStateProvider.GetAuthenticationStateAsync();
|
|
|
|
// Redirect to the page the user was trying to access before if set
|
|
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>(ReturnUrlKey);
|
|
if (!string.IsNullOrEmpty(localStorageReturnUrl))
|
|
{
|
|
await LocalStorage.RemoveItemAsync(ReturnUrlKey);
|
|
NavigationManager.NavigateTo(localStorageReturnUrl);
|
|
}
|
|
else
|
|
{
|
|
NavigationManager.NavigateTo("/");
|
|
}
|
|
}
|
|
#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(Localizer["GenericUnlockError"]);
|
|
}
|
|
#endif
|
|
finally
|
|
{
|
|
_loadingIndicator.Hide();
|
|
}
|
|
}
|
|
}
|