Files
aliasvault/apps/server/AliasVault.Client/Services/Auth/AuthService.cs
2025-06-12 15:07:10 +02:00

362 lines
15 KiB
C#

//-----------------------------------------------------------------------
// <copyright file="AuthService.cs" company="lanedirt">
// Copyright (c) lanedirt. 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;
using System.Net.Http.Json;
using System.Text.Json;
using AliasVault.Shared.Models.WebApi.Auth;
using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
/// <summary>
/// This service is responsible for handling authentication-related operations such as refreshing tokens,
/// storing tokens, and revoking tokens.
/// </summary>
/// <param name="httpClient">The HTTP client.</param>
/// <param name="localStorage">The local storage service.</param>
/// <param name="environment">IWebAssemblyHostEnvironment instance.</param>
/// <param name="config">Config instance.</param>
/// <param name="jsInteropService">JSInteropService instance.</param>
public sealed class AuthService(HttpClient httpClient, ILocalStorageService localStorage, IWebAssemblyHostEnvironment environment, Config config, JsInteropService jsInteropService)
{
private const string AccessTokenKey = "token";
private const string RefreshTokenKey = "refreshToken";
/// <summary>
/// Test string that is stored in local storage in encrypted state. This is used to validate the encryption key
/// locally during future vault unlocks.
/// </summary>
private const string EncryptionTestString = "aliasvault-test-string";
/// <summary>
/// The username of the currently logged-in user to prevent any conflicts during future vault saves.
/// </summary>
private string _username = string.Empty;
/// <summary>
/// The encryption key used to encrypt and decrypt the vault data.
/// </summary>
private byte[] _encryptionKey = new byte[32];
/// <summary>
/// Refreshes the access token asynchronously.
/// </summary>
/// <returns>The new access token.</returns>
public async Task<string?> RefreshTokenAsync()
{
var accessToken = await GetAccessTokenAsync();
var refreshToken = await GetRefreshTokenAsync();
var tokenInput = new TokenModel { Token = accessToken, RefreshToken = refreshToken };
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/Auth/refresh")
{
Content = JsonContent.Create(tokenInput),
};
// Add the X-Ignore-Failure header to the request so any failure does not trigger another refresh token request.
request.Headers.Add("X-Ignore-Failure", "true");
var response = await httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var responseContent = await response.Content.ReadAsStringAsync();
var tokenResponse = JsonSerializer.Deserialize<TokenModel>(responseContent);
if (tokenResponse != null)
{
// Store the token as a plain string in local storage.
await StoreAccessTokenAsync(tokenResponse.Token);
await StoreRefreshTokenAsync(tokenResponse.RefreshToken);
return tokenResponse.Token;
}
}
return null;
}
/// <summary>
/// Retrieves the username of the currently logged-in user.
/// </summary>
/// <returns>The currently logged-in user's username.</returns>
public string GetUsername()
{
return _username;
}
/// <summary>
/// Stores the username of the vault owner in local memory. This value will be sent to the server during
/// vault updates to ensure that the API is updating the correct vault of the correct user preventing any conflicts
/// or vault corruption.
/// </summary>
/// <param name="username">The username of the currently logged-in user and owner of the vault being loaded.</param>
public void StoreUsername(string? username)
{
_username = username ?? string.Empty;
}
/// <summary>
/// Retrieves the stored access token asynchronously.
/// </summary>
/// <returns>The stored access token.</returns>
public async Task<string> GetAccessTokenAsync()
{
return await localStorage.GetItemAsStringAsync(AccessTokenKey) ?? string.Empty;
}
/// <summary>
/// Stores the new access token asynchronously.
/// </summary>
/// <param name="newToken">The new access token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task StoreAccessTokenAsync(string newToken)
{
await localStorage.SetItemAsStringAsync(AccessTokenKey, newToken);
}
/// <summary>
/// Get encryption key.
/// </summary>
/// <returns>SrpArgonEncryption key as byte[].</returns>
public byte[] GetEncryptionKey()
{
return _encryptionKey;
}
/// <summary>
/// Get encryption key as base64 string.
/// </summary>
/// <returns>SrpArgonEncryption key as base64 string.</returns>
public string GetEncryptionKeyAsBase64Async()
{
if (environment.IsDevelopment() && config.UseDebugEncryptionKey)
{
// When project runs in development mode a static encryption key will be used.
// This allows to skip the unlock screen for faster development.
// Use launch profile "http-release" to get the actual user flow.
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB=";
}
return Convert.ToBase64String(GetEncryptionKey());
}
/// <summary>
/// Returns whether the encryption key is set.
/// </summary>
/// <returns>Return true if encryption key is set, otherwise false.</returns>
public bool IsEncryptionKeySet()
{
// Check that encryption key is set. If not, redirect to unlock screen.
var encryptionKey = GetEncryptionKeyAsBase64Async();
if (encryptionKey == string.Empty || encryptionKey == "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
{
// SrpArgonEncryption key is empty or base64 encoded empty string.
return false;
}
return true;
}
/// <summary>
/// Stores the encryption key asynchronously in-memory.
/// </summary>
/// <param name="newKey">SrpArgonEncryption key.</param>
/// <returns>Task.</returns>
public async Task StoreEncryptionKeyAsync(byte[] newKey)
{
_encryptionKey = newKey;
// When storing a new encryption key, encrypt a test string and save it to local storage.
// This test string can then be used to locally validate the password during future unlocks.
var encryptedTestString = await jsInteropService.SymmetricEncrypt(EncryptionTestString, GetEncryptionKeyAsBase64Async());
// Store the encrypted test string in local storage.
await localStorage.SetItemAsStringAsync("encryptionTestString", encryptedTestString);
}
/// <summary>
/// Check if WebAuthn is enabled.
/// </summary>
/// <returns>True if WebAuthn is enabled, otherwise false.</returns>
public async Task<bool> IsWebAuthnEnabledAsync()
{
await localStorage.GetItemAsStringAsync("webAuthnEnabled");
return await localStorage.GetItemAsStringAsync("webAuthnEnabled") == "true";
}
/// <summary>
/// Get the encryption key that is stored in local storage and decrypt it with the WebAuthn derived key.
/// </summary>
/// <param name="username">The username to associate with the credential.</param>
/// <returns>Decrypted encryption key.</returns>
public async Task<byte[]> GetDecryptedWebAuthnEncryptionKeyAsync(string username)
{
var encryptedEncryptionKey = await localStorage.GetItemAsStringAsync("webAuthnEncryptedEncryptionKey");
var webauthnCredentialId = await localStorage.GetItemAsStringAsync("webAuthnCredentialId");
var webauthnSalt = await localStorage.GetItemAsStringAsync("webAuthnSalt");
if (string.IsNullOrEmpty(encryptedEncryptionKey) || string.IsNullOrEmpty(webauthnCredentialId) || string.IsNullOrEmpty(webauthnSalt))
{
throw new InvalidOperationException("WebAuthn encrypted encryption key is not set or WebAuthn credential ID is not set.");
}
var webauthnCredentialDerivedKey = await jsInteropService.GetWebAuthnCredentialDerivedKey(webauthnCredentialId, webauthnSalt);
// Decrypt the encrypted encryption key with the WebAuthn derived key.
var decryptedString = await jsInteropService.SymmetricDecrypt(encryptedEncryptionKey, webauthnCredentialDerivedKey);
return Convert.FromBase64String(decryptedString);
}
/// <summary>
/// Set WebAuthn enabled. This will be used to determine if WebAuthn should be used for attempting to unlock the vault.
/// If set to false, the user will be prompted to enter the master password instead.
/// </summary>
/// <param name="enabled">True if WebAuthn is enabled, otherwise false.</param>
/// <param name="webauthCredentialId">WebAuthn credential ID.</param>
/// <param name="webauthSalt">WebAuthn salt.</param>
/// <param name="webauthCredentialDerivedKey">WebAuthn credential derived key.</param>
/// <returns>Task.</returns>
public async Task SetWebAuthnEnabledAsync(bool enabled, string? webauthCredentialId = null, string? webauthSalt = null, string? webauthCredentialDerivedKey = null)
{
await localStorage.SetItemAsStringAsync("webAuthnEnabled", enabled.ToString().ToLower());
// Encrypt the current encryption key with the webauthn derived key and store it in local storage.
if (enabled && !string.IsNullOrEmpty(webauthCredentialId) && !string.IsNullOrEmpty(webauthSalt) && !string.IsNullOrEmpty(webauthCredentialDerivedKey))
{
var encryptionKeyBase64 = Convert.ToBase64String(GetEncryptionKey());
var encryptedEncryptionKey = await jsInteropService.SymmetricEncrypt(encryptionKeyBase64, webauthCredentialDerivedKey);
await localStorage.SetItemAsStringAsync("webAuthnCredentialId", webauthCredentialId);
await localStorage.SetItemAsStringAsync("webAuthnSalt", webauthSalt);
await localStorage.SetItemAsStringAsync("webAuthnEncryptedEncryptionKey", encryptedEncryptionKey);
}
else
{
// Clear the WebAuthn credential ID, salt and derived key if WebAuthn is disabled.
await localStorage.RemoveItemAsync("webAuthnCredentialId");
await localStorage.RemoveItemAsync("webAuthnSalt");
await localStorage.RemoveItemAsync("webAuthnCredentialDerivedKey");
await localStorage.RemoveItemAsync("webAuthnEncryptedEncryptionKey");
}
}
/// <summary>
/// Check if the encryption test string is stored in local storage which is used to validate
/// the encryption key locally during future vault unlocks. If it's not stored the unlock
/// attempts will fail and user should log in again instead.
/// </summary>
/// <returns>Task.</returns>
public async Task<bool> HasEncryptionKeyTestStringAsync()
{
return await localStorage.GetItemAsStringAsync("encryptionTestString") != null;
}
/// <summary>
/// Validate the encryption locally by attempting to decrypt test string stored in local storage.
/// </summary>
/// <param name="encryptionKey">The encryption key to validate.</param>
/// <returns>True if encryption key is valid, false if not.</returns>
public async Task<bool> ValidateEncryptionKeyAsync(byte[] encryptionKey)
{
// Get the encrypted test string from local storage.
var encryptedTestString = await localStorage.GetItemAsStringAsync("encryptionTestString");
if (encryptedTestString == null)
{
return false;
}
var base64EncryptionKey = Convert.ToBase64String(encryptionKey);
// Decrypt the test string using the provided encryption key.
try
{
var decryptedTestString = await jsInteropService.SymmetricDecrypt(encryptedTestString, base64EncryptionKey);
// If the decrypted test string is not equal to the test string, the encryption key is invalid.
return decryptedTestString == EncryptionTestString;
}
catch
{
// Ignore errors, if decryption fails the encryption key is invalid.
return false;
}
}
/// <summary>
/// Stores the new refresh token asynchronously.
/// </summary>
/// <param name="newToken">The new refresh token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task StoreRefreshTokenAsync(string newToken)
{
await localStorage.SetItemAsStringAsync(RefreshTokenKey, newToken);
}
/// <summary>
/// Removes the stored access and refresh tokens asynchronously, called when logging out.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task RemoveTokensAsync()
{
// Revoke the tokens from the server by calling the webapi.
try
{
await RevokeTokenAsync();
}
catch (Exception)
{
// If an exception occurs we ignore it and continue with removing the tokens from local storage.
}
// Remove the tokens from local storage.
_username = string.Empty;
await localStorage.RemoveItemAsync(AccessTokenKey);
await localStorage.RemoveItemAsync(RefreshTokenKey);
}
/// <summary>
/// Removes the encryption key from memory, called during logout.
/// </summary>
public void RemoveEncryptionKey()
{
_encryptionKey = new byte[32];
}
/// <summary>
/// Revokes the access and refresh tokens on the server asynchronously.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
private async Task RevokeTokenAsync()
{
// Remove webauthn enabled flag.
await SetWebAuthnEnabledAsync(false);
var tokenInput = new TokenModel
{
Token = await GetAccessTokenAsync(),
RefreshToken = await GetRefreshTokenAsync(),
};
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/Auth/revoke")
{
Content = JsonContent.Create(tokenInput),
};
// Add the X-Ignore-Failure header to the request so any failure does not trigger another refresh token request.
request.Headers.Add("X-Ignore-Failure", "true");
await httpClient.SendAsync(request);
}
/// <summary>
/// Retrieves the stored refresh token asynchronously.
/// </summary>
/// <returns>The stored refresh token.</returns>
private async Task<string> GetRefreshTokenAsync()
{
return await localStorage.GetItemAsStringAsync(RefreshTokenKey) ?? string.Empty;
}
}