Add mobile unlock scaffolding to AliasVault.client web app (#1347)

This commit is contained in:
Leendert de Borst
2025-11-17 16:32:09 +01:00
parent 1097218ee1
commit 18a5e062a5
7 changed files with 575 additions and 4 deletions

View File

@@ -112,10 +112,32 @@ else
<a href="/user/forgot-password" class="ml-auto text-sm text-primary-700 hover:underline dark:text-primary-500">@Localizer["LostPasswordLink"]</a>
</div>
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white bg-primary-700 rounded-lg 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">@Localizer["LoginToAccountButton"]</button>
<button type="submit" class="w-full px-5 py-3 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">
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
</svg>
@Localizer["LoginToAccountButton"]
</button>
<div class="relative hidden md:block">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 text-gray-500 bg-white dark:bg-gray-800 dark:text-gray-400">@Localizer["OrText"]</span>
</div>
</div>
<a href="/user/mobile-unlock" class="hidden md:flex w-full px-5 py-3 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">
<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["MobileDeviceLink"]
</a>
@if (Config.PublicRegistrationEnabled)
{
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400 text-center">
@Localizer["NoAccountYetText"] <a href="/user/setup" class="text-primary-700 hover:underline dark:text-primary-500">@Localizer["CreateNewVaultLink"]</a>
</div>
}

View File

@@ -0,0 +1,249 @@
@page "/user/mobile-unlock"
@inherits AliasVault.Client.Auth.Pages.Base.LoginBase
@layout Auth.Layout.MainLayout
@attribute [AllowAnonymous]
@using System.Text.Json
@using AliasVault.Client.Auth.Components
@using AliasVault.Client.Auth.Services
@using AliasVault.Client.Utilities
@using AliasVault.Cryptography.Client
@using Microsoft.Extensions.Localization
@implements IDisposable
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
@Localizer["PageTitle"]
</h2>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="mb-4 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400">
@_errorMessage
</div>
}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm">
<p class="text-gray-700 dark:text-gray-300 mb-6">
@Localizer["ScanQrCodeDescription"]
</p>
<div class="flex flex-col items-center mb-6">
@if (_isLoading)
{
<SmallLoadingIndicator />
}
@if (!string.IsNullOrEmpty(_qrCodeUrl))
{
<div id="mobile-unlock-qr" data-url="@_qrCodeUrl" class="@(_isLoading ? "hidden" : "") mb-4 p-4 bg-white rounded-lg border-4 border-gray-200 dark:border-gray-600">
<!-- QR code will be rendered here -->
</div>
@if (!_isLoading)
{
<div class="text-gray-700 dark:text-gray-300 text-sm">
@FormatTime(_timeRemaining)
</div>
}
}
</div>
<div class="flex w-full">
<button type="button" @onclick="HandleCancel" class="w-full text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700">
@Localizer["CancelButton"]
</button>
</div>
</div>
<FooterLogin />
@code {
private string? _qrCodeUrl;
private string? _errorMessage;
private int _timeRemaining = 120; // 2 minutes in seconds
private MobileUnlockUtility? _mobileUnlockUtility;
private bool _isLoading = true;
private System.Threading.Timer? _countdownTimer;
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Auth.MobileUnlock", "AliasVault.Client");
private IStringLocalizer ApiErrorLocalizer => LocalizerFactory.Create("ApiErrors", "AliasVault.Client");
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Check if already authenticated
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (authState.User.Identity?.IsAuthenticated == true)
{
NavigationManager.NavigateTo("/");
return;
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
await InitiateMobileUnlockAsync();
}
}
/// <summary>
/// Initialize mobile unlock on component mount.
/// </summary>
private async Task InitiateMobileUnlockAsync()
{
try
{
_isLoading = true;
_errorMessage = null;
StateHasChanged();
// Initialize mobile unlock utility
_mobileUnlockUtility = new MobileUnlockUtility(Http, JsInteropService, ScopedServices.GetRequiredService<ILogger<MobileUnlockUtility>>());
// Initiate mobile unlock and get QR code data
var requestId = await _mobileUnlockUtility.InitiateAsync();
// Generate QR code with AliasVault prefix for mobile unlock
_qrCodeUrl = $"aliasvault://mobile-unlock/{requestId}";
// Render QR code first while still showing loading animation
StateHasChanged();
await Task.Delay(100); // Give DOM time to render
await JsInteropService.GenerateQrCode("mobile-unlock-qr");
// Wait for QR code to be fully rendered before hiding loading animation
await Task.Delay(500);
_isLoading = false;
StateHasChanged();
// Start countdown timer
StartCountdownTimer();
// Start polling for response
await _mobileUnlockUtility.StartPollingAsync(
HandleSuccessfulAuthAsync,
HandleErrorAsync);
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// 404 means the mobile unlock endpoint doesn't exist - server version is too old
// TODO: this check can be removed at a later time when v1.0 is ready and 0.25.0 release has been out for a while.
_isLoading = false;
_errorMessage = Localizer["ServerVersionTooOld"];
StateHasChanged();
}
catch (Exception)
{
_isLoading = false;
_errorMessage = Localizer["InitializationError"];
StateHasChanged();
}
}
/// <summary>
/// Handle successful authentication.
/// </summary>
private async Task HandleSuccessfulAuthAsync(string username, string token, string refreshToken, string decryptionKey, string salt, string encryptionType, string encryptionSettings)
{
try
{
_isLoading = true;
_qrCodeUrl = null;
StateHasChanged();
// Store the tokens in local storage
await AuthService.StoreAccessTokenAsync(token);
await AuthService.StoreRefreshTokenAsync(refreshToken);
// Convert decryption key from base64 string to byte array
var decryptionKeyBytes = Convert.FromBase64String(decryptionKey);
// Store the encryption key in memory
await AuthService.StoreEncryptionKeyAsync(decryptionKeyBytes);
await AuthStateProvider.GetAuthenticationStateAsync();
GlobalNotificationService.ClearMessages();
// 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("/");
}
}
catch (Exception)
{
_isLoading = false;
_errorMessage = Localizer["LoginError"];
StateHasChanged();
}
}
/// <summary>
/// Handle error.
/// </summary>
private void HandleErrorAsync(string errorMessage)
{
_isLoading = false;
_errorMessage = errorMessage;
StateHasChanged();
}
/// <summary>
/// Handle cancel button.
/// </summary>
private void HandleCancel()
{
_mobileUnlockUtility?.Cleanup();
_countdownTimer?.Dispose();
NavigationManager.NavigateTo("/user/login");
}
/// <summary>
/// Format time remaining as MM:SS.
/// </summary>
private string FormatTime(int seconds)
{
var mins = seconds / 60;
var secs = seconds % 60;
return $"{mins}:{secs:D2}";
}
/// <summary>
/// Start countdown timer.
/// </summary>
private void StartCountdownTimer()
{
_countdownTimer = new System.Threading.Timer(_ =>
{
if (_timeRemaining > 0)
{
_timeRemaining--;
InvokeAsync(StateHasChanged);
}
else
{
_countdownTimer?.Dispose();
_mobileUnlockUtility?.StopPolling();
}
}, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
/// <inheritdoc/>
public void Dispose()
{
_mobileUnlockUtility?.Dispose();
_countdownTimer?.Dispose();
}
}

View File

@@ -0,0 +1,189 @@
//-----------------------------------------------------------------------
// <copyright file="MobileUnlockUtility.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.Auth.Services;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AliasVault.Client.Services.JsInterop;
using AliasVault.Shared.Models.WebApi.Auth;
using Microsoft.Extensions.Logging;
/// <summary>
/// Utility class for logging in with mobile app functionality.
/// </summary>
public sealed class MobileUnlockUtility : IDisposable
{
private readonly HttpClient _httpClient;
private readonly JsInteropService _jsInteropService;
private readonly ILogger<MobileUnlockUtility> _logger;
private Timer? _pollingTimer;
private string? _requestId;
private string? _privateKey;
private CancellationTokenSource? _cancellationTokenSource;
/// <summary>
/// Initializes a new instance of the <see cref="MobileUnlockUtility"/> class.
/// </summary>
/// <param name="httpClient">The HTTP client.</param>
/// <param name="jsInteropService">The JS interop service.</param>
/// <param name="logger">The logger.</param>
public MobileUnlockUtility(HttpClient httpClient, JsInteropService jsInteropService, ILogger<MobileUnlockUtility> logger)
{
_httpClient = httpClient;
_jsInteropService = jsInteropService;
_logger = logger;
}
/// <summary>
/// Initiates a mobile unlock request and returns the request ID for QR code generation.
/// </summary>
/// <returns>The request ID.</returns>
/// <exception cref="HttpRequestException">Thrown when the request fails with status code.</exception>
public async Task<string> InitiateAsync()
{
// Generate RSA key pair
var keyPair = await _jsInteropService.GenerateRsaKeyPair();
_privateKey = keyPair.PrivateKey;
// Send public key to server
var request = new MobileUnlockInitiateRequest(keyPair.PublicKey);
var response = await _httpClient.PostAsJsonAsync("v1/Auth/mobile-unlock/initiate", request);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Failed to initiate mobile unlock: {response.StatusCode}", null, response.StatusCode);
}
var result = await response.Content.ReadFromJsonAsync<MobileUnlockInitiateResponse>();
if (result == null)
{
throw new InvalidOperationException("Failed to parse mobile unlock initiate response");
}
_requestId = result.RequestId;
return _requestId;
}
/// <summary>
/// Starts polling the server for mobile unlock response.
/// </summary>
/// <param name="onSuccess">Callback for successful authentication.</param>
/// <param name="onError">Callback for errors.</param>
/// <returns>Task.</returns>
public Task StartPollingAsync(Func<string, string, string, string, string, string, string, Task> onSuccess, Action<string> onError)
{
if (string.IsNullOrEmpty(_requestId) || string.IsNullOrEmpty(_privateKey))
{
throw new InvalidOperationException("Must call InitiateAsync() before starting polling");
}
_cancellationTokenSource = new CancellationTokenSource();
// Start polling timer (every 3 seconds)
_pollingTimer = new Timer(async _ => await PollServerAsync(onSuccess, onError), null, TimeSpan.Zero, TimeSpan.FromSeconds(3));
// Auto-stop after 2 minutes
Task.Delay(TimeSpan.FromMinutes(2), _cancellationTokenSource.Token)
.ContinueWith(
_ =>
{
if (!_cancellationTokenSource.IsCancellationRequested)
{
StopPolling();
onError("Mobile unlock request timed out");
}
},
TaskScheduler.Default);
return Task.CompletedTask;
}
/// <summary>
/// Stops polling the server.
/// </summary>
public void StopPolling()
{
_pollingTimer?.Dispose();
_pollingTimer = null;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
/// <summary>
/// Cleans up resources.
/// </summary>
public void Cleanup()
{
StopPolling();
_privateKey = null;
_requestId = null;
}
/// <inheritdoc/>
public void Dispose()
{
Cleanup();
}
private async Task PollServerAsync(Func<string, string, string, string, string, string, string, Task> onSuccess, Action<string> onError)
{
if (string.IsNullOrEmpty(_requestId) || _cancellationTokenSource?.IsCancellationRequested == true)
{
return;
}
try
{
var response = await _httpClient.GetAsync($"v1/Auth/mobile-unlock/poll/{_requestId}");
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
StopPolling();
_privateKey = null;
_requestId = null;
onError("Mobile unlock request expired or not found");
return;
}
throw new InvalidOperationException($"Polling failed: {response.StatusCode}");
}
var result = await response.Content.ReadFromJsonAsync<MobileUnlockPollResponse>();
if (result?.Fulfilled == true && !string.IsNullOrEmpty(result.EncryptedDecryptionKey) && !string.IsNullOrEmpty(result.Username) && result.Token != null && !string.IsNullOrEmpty(result.Salt) && !string.IsNullOrEmpty(result.EncryptionType) && !string.IsNullOrEmpty(result.EncryptionSettings))
{
// Stop polling
StopPolling();
// Decrypt the decryption key using private key
var decryptionKey = await _jsInteropService.DecryptWithPrivateKey(result.EncryptedDecryptionKey, _privateKey!);
// Clear sensitive data
_privateKey = null;
_requestId = null;
// Call success callback
await onSuccess(result.Username, result.Token.Token, result.Token.RefreshToken, decryptionKey, result.Salt, result.EncryptionType, result.EncryptionSettings);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during mobile unlock polling");
StopPolling();
_privateKey = null;
_requestId = null;
onError(ex.Message);
}
}
}

View File

@@ -105,7 +105,7 @@
<comment>Login button text</comment>
</data>
<data name="LoginToAccountButton" xml:space="preserve">
<value>Login to your account</value>
<value>Log in to your account</value>
<comment>Extended login button text</comment>
</data>
@@ -126,6 +126,14 @@
<value>Log in with an authenticator code instead.</value>
<comment>Link text for logging in with authenticator</comment>
</data>
<data name="MobileDeviceLink" xml:space="preserve">
<value>Log in using mobile app</value>
<comment>Link text for mobile device login</comment>
</data>
<data name="OrText" xml:space="preserve">
<value>or</value>
<comment>Divider text between login options</comment>
</data>
<!-- Descriptions and help text -->
<data name="TwoFactorAuthenticationDescription" xml:space="preserve">

View File

@@ -0,0 +1,85 @@
<?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="PageTitle" xml:space="preserve">
<value>Log in using Mobile App</value>
<comment>Page title for mobile unlock feature</comment>
</data>
<data name="ScanQrCodeDescription" xml:space="preserve">
<value>Scan this QR code with your AliasVault mobile app to login</value>
<comment>Description instructing user to scan QR code</comment>
</data>
<data name="CancelButton" xml:space="preserve">
<value>Cancel</value>
<comment>Button text to cancel mobile unlock</comment>
</data>
<data name="InitializationError" xml:space="preserve">
<value>Failed to initialize mobile login. Please try again.</value>
<comment>Error message when initialization fails</comment>
</data>
<data name="LoginError" xml:space="preserve">
<value>Failed to complete login. Please try again.</value>
<comment>Error message when login fails</comment>
</data>
<data name="ServerVersionTooOld" xml:space="preserve">
<value>The AliasVault server needs to be updated to a newer version in order to use this feature. Please contact the server admin if you need help.</value>
<comment>Error message when server version is too old for mobile unlock</comment>
</data>
</root>

View File

@@ -235,7 +235,7 @@ public sealed class JsInteropService(IJSRuntime jsRuntime)
try
{
// Invoke the JavaScript function and get the result as a byte array
await jsRuntime.InvokeVoidAsync("generateQrCode", "authenticator-uri");
await jsRuntime.InvokeVoidAsync("generateQrCode", elementId);
}
catch (JSException ex)
{

View File

@@ -1515,6 +1515,10 @@ video {
border-width: 2px;
}
.border-4 {
border-width: 4px;
}
.border-b {
border-bottom-width: 1px;
}
@@ -2715,6 +2719,11 @@ video {
--tw-ring-color: rgb(243 244 246 / var(--tw-ring-opacity));
}
.focus\:ring-gray-200:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(229 231 235 / var(--tw-ring-opacity));
}
.focus\:ring-gray-300:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity));
@@ -3190,6 +3199,11 @@ video {
color: rgb(248 185 99 / var(--tw-text-opacity));
}
.dark\:hover\:border-gray-600:hover:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-border-opacity));
}
.dark\:hover\:bg-blue-500:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
@@ -3514,6 +3528,10 @@ video {
display: inline;
}
.md\:flex {
display: flex;
}
.md\:hidden {
display: none;
}