|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.JSInterop;
namespace Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
/// <summary>
/// Provides mechanisms for storing and retrieving data in the browser storage.
/// </summary>
public abstract class ProtectedBrowserStorage
{
private readonly string _storeName;
private readonly IJSRuntime _jsRuntime;
private readonly IDataProtectionProvider _dataProtectionProvider;
private readonly ConcurrentDictionary<string, IDataProtector> _cachedDataProtectorsByPurpose
= new ConcurrentDictionary<string, IDataProtector>(StringComparer.Ordinal);
/// <summary>
/// Constructs an instance of <see cref="ProtectedBrowserStorage"/>.
/// </summary>
/// <param name="storeName">The name of the store in which the data should be stored.</param>
/// <param name="jsRuntime">The <see cref="IJSRuntime"/>.</param>
/// <param name="dataProtectionProvider">The <see cref="IDataProtectionProvider"/>.</param>
private protected ProtectedBrowserStorage(string storeName, IJSRuntime jsRuntime, IDataProtectionProvider dataProtectionProvider)
{
// Performing data protection on the client would give users a false sense of security, so we'll prevent this.
if (OperatingSystem.IsBrowser())
{
throw new PlatformNotSupportedException($"{GetType()} cannot be used when running in a browser.");
}
ArgumentException.ThrowIfNullOrEmpty(storeName);
_storeName = storeName;
_jsRuntime = jsRuntime ?? throw new ArgumentNullException(nameof(jsRuntime));
_dataProtectionProvider = dataProtectionProvider ?? throw new ArgumentNullException(nameof(dataProtectionProvider));
}
/// <summary>
/// <para>
/// Asynchronously stores the specified data.
/// </para>
/// <para>
/// Since no data protection purpose is specified with this overload, the purpose is derived from
/// <paramref name="key"/> and the store name. This is a good default purpose to use if the keys come from a
/// fixed set known at compile-time.
/// </para>
/// </summary>
/// <param name="key">A <see cref="string"/> value specifying the name of the storage slot to use.</param>
/// <param name="value">A JSON-serializable value to be stored.</param>
/// <returns>A <see cref="ValueTask"/> representing the completion of the operation.</returns>
public ValueTask SetAsync(string key, object value)
=> SetAsync(CreatePurposeFromKey(key), key, value);
/// <summary>
/// Asynchronously stores the supplied data.
/// </summary>
/// <param name="purpose">
/// A string that defines a scope for the data protection. The protected data can only
/// be unprotected by code that specifies the same purpose.
/// </param>
/// <param name="key">A <see cref="string"/> value specifying the name of the storage slot to use.</param>
/// <param name="value">A JSON-serializable value to be stored.</param>
/// <returns>A <see cref="ValueTask"/> representing the completion of the operation.</returns>
public ValueTask SetAsync(string purpose, string key, object value)
{
ArgumentException.ThrowIfNullOrEmpty(purpose);
ArgumentException.ThrowIfNullOrEmpty(key);
return SetProtectedJsonAsync(key, Protect(purpose, value));
}
/// <summary>
/// <para>
/// Asynchronously retrieves the specified data.
/// </para>
/// <para>
/// Since no data protection purpose is specified with this overload, the purpose is derived from
/// <paramref name="key"/> and the store name. This is a good default purpose to use if the keys come from a
/// fixed set known at compile-time.
/// </para>
/// </summary>
/// <param name="key">A <see cref="string"/> value specifying the name of the storage slot to use.</param>
/// <returns>A <see cref="ValueTask"/> representing the completion of the operation.</returns>
public ValueTask<ProtectedBrowserStorageResult<TValue>> GetAsync<TValue>(string key)
=> GetAsync<TValue>(CreatePurposeFromKey(key), key);
/// <summary>
/// <para>
/// Asynchronously retrieves the specified data.
/// </para>
/// </summary>
/// <param name="purpose">
/// A string that defines a scope for the data protection. The protected data can only
/// be unprotected if the same purpose was previously specified when calling
/// <see cref="SetAsync(string, string, object)"/>.
/// </param>
/// <param name="key">A <see cref="string"/> value specifying the name of the storage slot to use.</param>
/// <returns>A <see cref="ValueTask"/> representing the completion of the operation.</returns>
public async ValueTask<ProtectedBrowserStorageResult<TValue>> GetAsync<TValue>(string purpose, string key)
{
var protectedJson = await GetProtectedJsonAsync(key);
return protectedJson == null ?
new ProtectedBrowserStorageResult<TValue>(false, default) :
new ProtectedBrowserStorageResult<TValue>(true, Unprotect<TValue>(purpose, protectedJson));
}
/// <summary>
/// Asynchronously deletes any data stored for the specified key.
/// </summary>
/// <param name="key">
/// A <see cref="string"/> value specifying the name of the storage slot whose value should be deleted.
/// </param>
/// <returns>A <see cref="ValueTask"/> representing the completion of the operation.</returns>
public ValueTask DeleteAsync(string key)
=> _jsRuntime.InvokeVoidAsync($"{_storeName}.removeItem", key);
private string Protect(string purpose, object value)
{
var json = JsonSerializer.Serialize(value, options: JsonSerializerOptionsProvider.Options);
var protector = GetOrCreateCachedProtector(purpose);
return protector.Protect(json);
}
private TValue Unprotect<TValue>(string purpose, string protectedJson)
{
var protector = GetOrCreateCachedProtector(purpose);
var json = protector.Unprotect(protectedJson);
return JsonSerializer.Deserialize<TValue>(json, options: JsonSerializerOptionsProvider.Options)!;
}
private ValueTask SetProtectedJsonAsync(string key, string protectedJson)
=> _jsRuntime.InvokeVoidAsync($"{_storeName}.setItem", key, protectedJson);
private ValueTask<string?> GetProtectedJsonAsync(string key)
=> _jsRuntime.InvokeAsync<string?>($"{_storeName}.getItem", key);
// IDataProtect isn't disposable, so we're fine holding these indefinitely.
// Only a bounded number of them will be created, as the 'key' values should
// come from a bounded set known at compile-time. There's no use case for
// letting runtime data determine the 'key' values.
private IDataProtector GetOrCreateCachedProtector(string purpose)
=> _cachedDataProtectorsByPurpose.GetOrAdd(
purpose,
_dataProtectionProvider.CreateProtector);
private string CreatePurposeFromKey(string key)
=> $"{GetType().FullName}:{_storeName}:{key}";
}
|