защищенное ASP.NET Core хранилище браузера Blazor

Для хранения временных данных, создаваемых пользователем, обычно используются коллекции браузера localStorage и sessionStorage.

  • localStorage распространяется на экземпляр браузера. Если пользователь перезагрузит страницу или закрывается и повторно открывает браузер, состояние сохраняется. Если пользователь открывает несколько вкладок браузера, состояние между ними будет общим. Данные сохраняются в localStorage до тех пор, пока они не будут явно очищены. Данные localStorage для документа, загруженного в сеансе "частный просмотр" или "инкогнито", очищаются при закрытии последней вкладки "частный".
  • sessionStorage ограничена текущей вкладкой браузера. Если пользователь перезагрузит вкладку, состояние сохраняется. Если пользователь закрывает вкладку или браузер, состояние теряется. Если пользователь открывает несколько вкладок браузера, каждая вкладка имеет собственную независимую версию данных.

Как правило, sessionStorage более безопасно для использования. sessionStorage позволяет избежать риска, когда пользователь открывает несколько вкладок и сталкивается со следующими проблемами.

  • Ошибки в хранилище состояний на разных вкладках.
  • Запутанное поведение, когда одна вкладка перезаписывает состояние других.

localStorage лучше, если приложение должно сохранять состояние при закрытии и повторном открытии браузера.

Предостережения при использовании хранилища браузера.

  • Аналогично использованию базы данных на стороне сервера, загрузка и сохранение данных выполняются асинхронно.
  • Запрошенная страница не существует в браузере во время предварительной подготовки, поэтому локальное хранилище недоступно во время предварительной отрисовки.
  • Хранение нескольких килобайт данных целесообразно для серверных Blazor приложений. За пределами нескольких килобайт необходимо учитывать влияние на производительность, поскольку данные загружаются и сохраняются по сети.
  • Пользователи могут просматривать и изменять данные. Защита данных ASP.NET Core может снизить этот риск. Например, защищенное хранилище браузера ASP.NET Core использует защиту данных ASP.NET Core.

Сторонние пакеты NuGet предоставляют интерфейсы API для работы с localStorage и sessionStorage. Рекомендуется выбрать пакет, который прозрачно использует защиту данных ASP.NET Core. Функция защиты данных шифрует хранимые данные и уменьшает потенциальный риск несанкционированного изменения хранимых данных. Если сериализованные данные JSON хранятся в виде обычного текста, пользователи могут просматривать данные с помощью средств разработчика браузера, а также изменять сохраненные данные. Защита тривиальных данных не является проблемой. Например, чтение или изменение сохраненного цвета элемента пользовательского интерфейса не является серьезной угрозой безопасности для пользователя или организации. Не разрешайте пользователям проверять или изменять конфиденциальные данные.

Защищенное хранилище браузера ASP.NET Core

Защищенное хранилище браузера ASP.NET Core использует защиту данных ASP.NET Core для localStorage и sessionStorage.

Замечание

Защищенное хранилище браузеров использует ASP.NET Core Data Protection и поддерживается только для серверных Blazor приложений.

Предупреждение

Microsoft.AspNetCore.ProtectedBrowserStorage является неподдерживаемым экспериментальным пакетом, который не подходит для использования в рабочей среде.

Пакет можно использовать только в приложениях для ASP.NET Core 3.1.

Конфигурация

  1. Добавьте ссылку на пакет для Microsoft.AspNetCore.ProtectedBrowserStorage.

    Замечание

    Рекомендации по добавлению пакетов в приложения .NET см. в статьях в разделе "Установка пакетов и управление пакетами" в рабочем процессе потребления пакетов (документация NuGet). Проверьте правильность версий пакета на сайте NuGet.org.

  2. Добавьте следующий скрипт в файл _Host.cshtml до закрывающего тега </body>.

    <script src="_content/Microsoft.AspNetCore.ProtectedBrowserStorage/protectedBrowserStorage.js"></script>
    
  3. В Startup.ConfigureServices вызовите AddProtectedBrowserStorage, чтобы добавить службы localStorage и sessionStorage в коллекцию служб.

    services.AddProtectedBrowserStorage();
    

Сохранение и загрузка данных в компоненте

В любом компоненте, требующем загрузки или сохранения данных в хранилище браузера, используйте директиву @inject для вставки экземпляра одного из следующих компонентов.

  • ProtectedLocalStorage
  • ProtectedSessionStorage

Выбор зависит от расположения хранилища браузера, которое требуется использовать. В следующем примере используется sessionStorage.

@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage
@inject ProtectedSessionStorage ProtectedSessionStore
@using Microsoft.AspNetCore.ProtectedBrowserStorage
@inject ProtectedSessionStorage ProtectedSessionStore

Директиву @using можно поместить в файл приложения _Imports.razor, а не в компонент. Использование файла _Imports.razor делает пространство имен доступным для больших сегментов приложения или всего приложения.

Чтобы сохранить значение currentCount в компоненте Counter приложения на основе шаблона проекта Blazor, измените метод IncrementCount, чтобы использовать в нем ProtectedSessionStore.SetAsync.

private async Task IncrementCount()
{
    currentCount++;
    await ProtectedSessionStore.SetAsync("count", currentCount);
}

В больших и более реалистичных приложениях хранение отдельных полей является маловероятной ситуацией. Приложения, скорее всего, будут хранить все объекты модели, включающие сложное состояние. ProtectedSessionStore автоматически сериализует и десериализует данные JSON для хранения объектов со сложными состояниями.

В предыдущем примере кода данные currentCount хранятся в виде sessionStorage['count'] в браузере пользователя. Данные не хранятся в виде обычного текста, а защищаются с помощью функции защиты данных ASP.NET Core. Зашифрованные данные можно проверить, если sessionStorage['count'] вычисляется в консоли разработчика браузера.

Чтобы восстановить данные currentCount если пользователь возвращается к компоненту Counter позже, в том числе если пользователь на новой схеме, используйте ProtectedSessionStore.GetAsync.

protected override async Task OnInitializedAsync()
{
    var result = await ProtectedSessionStore.GetAsync<int>("count");
    currentCount = result.Success ? result.Value : 0;
}
protected override async Task OnInitializedAsync()
{
    currentCount = await ProtectedSessionStore.GetAsync<int>("count");
}

Если параметры компонента включают состояние навигации, вызовите ProtectedSessionStore.GetAsync и назначьте отличный от null результат в OnParametersSetAsync, а не в OnInitializedAsync. OnInitializedAsync вызывается только один раз при первом создании экземпляра компонента. OnInitializedAsync не вызывается позже, если пользователь переходит на другой URL-адрес, оставаясь на той же странице. Дополнительные сведения см. в статье Жизненный цикл компонентов Razor ASP.NET Core.

Предупреждение

Примеры в этом разделе работают только в том случае, если на сервере не включена предварительная отрисовка. При включенной предварительной отрисовке выводится сообщение об ошибке, объясняющее, что вызовы взаимодействия с JavaScript осуществить невозможно, поскольку выполняется предварительная отрисовка компонента.

Отключите предварительную отрисовку или добавьте дополнительный код для работы с предварительной отрисовкой. Дополнительные сведения о написании кода, который работает с предварительной отрисовкой, см. в разделе Обработка предварительной отрисовки.

Обработка состояния загрузки

Так как доступ к хранилищу браузера осуществляется асинхронно через сетевое подключение, всегда есть период времени, прежде чем данные будут загружены и станут доступны для компонента. Для достижения лучших результатов отображайте сообщение во время загрузки вместо демонстрации пустых или стандартных данных.

Один из подходов состоит в том, чтобы определить, равно ли значение данных null. Если это так, данные еще загружаются. В компоненте Counter по умолчанию количество хранится в int. Сделайте currentCount допускающим значение NULL, добавив вопросительный знак (?) к типу (int).

private int? currentCount;

Вместо безусловного отображения количества и кнопки Increment отображайте эти элементы только в том случае, если данные загружены, указав HasValue.

@if (currentCount.HasValue)
{
    <p>Current count: <strong>@currentCount</strong></p>
    <button @onclick="IncrementCount">Increment</button>
}
else
{
    <p>Loading...</p>
}

Управление предварительной отрисовкой

Во время предварительной отрисовки происходит следующее:

  • интерактивное подключение к браузеру пользователя отсутствует.
  • в браузере еще нет страницы, на которой можно запустить код JavaScript.

localStorage или sessionStorage недоступны во время предварительной отрисовки. Если компонент пытается взаимодействовать с хранилищем, выводится сообщение об ошибке, объясняющее, что вызовы взаимодействия с JavaScript осуществить невозможно, поскольку выполняется предварительная отрисовка компонента.

Одним из способов устранения этой ошибки является отключение предварительной отрисовки. Обычно это наилучший вариант, если приложение активно использует хранилище в браузере. Предварительная отрисовка увеличивает сложность и не дает приложению никаких преимуществ, поскольку приложение не может предварительно отобразить любое полезное содержимое, пока не станут доступны localStorage или sessionStorage.

Чтобы отключить предварительную отрисовку, укажите режим отрисовки для компонента самого высокого уровня в иерархии компонентов приложения, который не является корневым компонентом, параметр prerender должен быть задан как false.

Замечание

Создание интерактивного корневого компонента, например App компонента, не поддерживается. Следовательно, предварительный рендеринг не может быть отключен непосредственно с помощью компонента App.

Для приложений, основанных на шаблоне проекта Blazor Web App, где компонент Routes используется в компоненте App, предварительная отрисовка обычно отключена (Components/App.razor):

<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />

Кроме того, отключите пререндеринг для компонента HeadOutlet.

<HeadOutlet @rendermode="new InteractiveServerRenderMode(prerender: false)" />

Дополнительные сведения см. в разделе Предварительный рендеринг компонентов ASP.NET CoreRazor.

Чтобы отключить предварительную отрисовку, откройте файл _Host.cshtml и измените атрибут render-modeвспомогательной функции тега компонента на Server.

<component type="typeof(App)" render-mode="Server" />

При отключении предварительной отрисовки, предварительная отрисовка содержимого <head> отключается.

Предварительная отрисовка может быть полезной для других страниц, которые не используют localStorage или sessionStorage. Чтобы сохранить предварительную отрисовку, отложите операцию загрузки до тех пор, пока браузер не подключится к каналу. Ниже приведен пример хранения значения счетчика.

@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage
@inject ProtectedLocalStorage ProtectedLocalStore

@if (RendererInfo.IsInteractive)
{
    <p>Current count: <strong>@currentCount</strong></p>
    <button @onclick="IncrementCount">Increment</button>
}
else
{
    <p>Loading...</p>
}

@code {
    private int currentCount;

    protected override async Task OnInitializedAsync()
    {
        if (RendererInfo.IsInteractive)
        {
            await LoadStateAsync();
        }
    }

    private async Task LoadStateAsync()
    {
        var result = await ProtectedLocalStore.GetAsync<int>("count");
        currentCount = result.Success ? result.Value : 0;
    }

    private async Task IncrementCount()
    {
        currentCount++;
        await ProtectedLocalStore.SetAsync("count", currentCount);
    }
}

Дополнительные сведения см. в разделе RendererInfo.IsInteractiveрежимы отрисовки ASP.NET CoreBlazor.

@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage
@inject ProtectedLocalStorage ProtectedLocalStore

@if (isConnected)
{
    <p>Current count: <strong>@currentCount</strong></p>
    <button @onclick="IncrementCount">Increment</button>
}
else
{
    <p>Loading...</p>
}

@code {
    private int currentCount;
    private bool isConnected;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            isConnected = true;
            await LoadStateAsync();
            StateHasChanged();
        }
    }

    private async Task LoadStateAsync()
    {
        var result = await ProtectedLocalStore.GetAsync<int>("count");
        currentCount = result.Success ? result.Value : 0;
    }

    private async Task IncrementCount()
    {
        currentCount++;
        await ProtectedLocalStore.SetAsync("count", currentCount);
    }
}
@using Microsoft.AspNetCore.ProtectedBrowserStorage
@inject ProtectedLocalStorage ProtectedLocalStore

@if (isConnected)
{
    <p>Current count: <strong>@currentCount</strong></p>
    <button @onclick="IncrementCount">Increment</button>
}
else
{
    <p>Loading...</p>
}

@code {
    private int currentCount = 0;
    private bool isConnected = false;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            isConnected = true;
            await LoadStateAsync();
            StateHasChanged();
        }
    }

    private async Task LoadStateAsync()
    {
        currentCount = await ProtectedLocalStore.GetAsync<int>("count");
    }

    private async Task IncrementCount()
    {
        currentCount++;
        await ProtectedLocalStore.SetAsync("count", currentCount);
    }
}

Вынесите сохранение состояния в общий поставщик

Если многие компоненты используют хранилище на основе браузера, реализация кода поставщика состояний многократно создает дублирование кода. Одним из вариантов предотвращения дублирования кода является создание родительского компонента поставщика состояний, который инкапсулирует логику поставщика состояний. Дочерние компоненты могут работать с сохраненными данными без учета механизма сохранения состояния.

В следующем примере компонента CounterStateProvider данные счетчика сохраняются в sessionStorage. Компонент обрабатывает этап загрузки, не отображая дочернее содержимое до завершения загрузки состояния.

Компонент CounterStateProvider занимается предварительной отрисовкой, не загружая состояние до тех пор, пока не произойдёт отрисовка компонента в методе жизненного цикла OnAfterRenderAsync, который не выполняется во время предварительной отрисовки.

Подход в этом разделе не может вызвать перерисовку нескольких подписанных компонентов на одной странице. Если один подписанный компонент изменяет состояние, он перерисовывается и может отображать обновлённые данные, но другой компонент на той же странице, отображающий это состояние, отображает устаревшие данные до тех пор, пока не произойдёт его собственная следующая перерисовка. Поэтому подход, описанный в этом разделе, лучше всего подходит для использования состояния в одном компоненте на странице.

CounterStateProvider.razor:

@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage
@inject ProtectedSessionStorage ProtectedSessionStore

@if (isLoaded)
{
    <CascadingValue Value="this">
        @ChildContent
    </CascadingValue>
}
else
{
    <p>Loading...</p>
}

@code {
    private bool isLoaded;

    [Parameter]
    public RenderFragment? ChildContent { get; set; }

    public int CurrentCount { get; set; }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            isLoaded = true;
            await LoadStateAsync();
            StateHasChanged();
        }
    }

    private async Task LoadStateAsync()
    {
        var result = await ProtectedSessionStore.GetAsync<int>("count");
        CurrentCount = result.Success ? result.Value : 0;
        isLoaded = true;
    }

    public async Task IncrementCount()
    {
        CurrentCount++;
        await ProtectedSessionStore.SetAsync("count", CurrentCount);
    }
}
@using Microsoft.AspNetCore.ProtectedBrowserStorage
@inject ProtectedSessionStorage ProtectedSessionStore

@if (isLoaded)
{
    <CascadingValue Value="this">
        @ChildContent
    </CascadingValue>
}
else
{
    <p>Loading...</p>
}

@code {
    private bool isLoaded;

    [Parameter]
    public RenderFragment ChildContent { get; set; }

    public int CurrentCount { get; set; }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            isLoaded = true;
            await LoadStateAsync();
            StateHasChanged();
        }
    }

    private async Task LoadStateAsync()
    {
        CurrentCount = await ProtectedSessionStore.GetAsync<int>("count");
        isLoaded = true;
    }

    public async Task IncrementCount()
    {
        CurrentCount++;
        await ProtectedSessionStore.SetAsync("count", CurrentCount);
    }
}

Замечание

Дополнительные сведения о RenderFragment см. в статье Компоненты Razor ASP.NET Core.

Чтобы сделать состояние доступным для всех компонентов в приложении, оберните компонент CounterStateProvider вокруг компонента Router (<Router>...</Router>) в компоненте Routes с использованием глобальной интерактивной серверной отрисовки (interactive SSR).

В компоненте App (Components/App.razor):

<Routes @rendermode="InteractiveServer" />

В компоненте Routes (Components/Routes.razor):

Чтобы использовать компонент CounterStateProvider, оберните экземпляр компонента вокруг любого другого компонента, которому требуется доступ к состоянию счетчика. Чтобы сделать состояние доступным для всех компонентов в приложении, оберните CounterStateProvider компонент вокруг Router в компоненте App (App.razor).

<CounterStateProvider>
    <Router ...>
        ...
    </Router>
</CounterStateProvider>

Замечание

В релизе .NET 5.0.1, и для любых дополнительных релизов 5.x, компонент включает набор параметров Router, установленный на PreferExactMatches. Дополнительные сведения см. в разделе "Миграция с ASP.NET Core 3.1 на .NET 5".

Упакованные компоненты получают и могут изменять состояние сохраненного счетчика. Следующий компонент Counter реализует этот шаблон.

@page "/counter"

<p>Current count: <strong>@CounterStateProvider?.CurrentCount</strong></p>
<button @onclick="IncrementCount">Increment</button>

@code {
    [CascadingParameter]
    private CounterStateProvider? CounterStateProvider { get; set; }

    private async Task IncrementCount()
    {
        if (CounterStateProvider is not null)
        {
            await CounterStateProvider.IncrementCount();
        }
    }
}

Предыдущий компонент не требуется для взаимодействия с ProtectedBrowserStorage и не имеет отношения к этапу "загрузки".

В целом шаблон родительского компонента поставщика состояний рекомендуется использовать в следующих случаях:

  • Для потребления состояния во множестве компонентов.
  • Если имеется только один объект состояния верхнего уровня для сохранения.

Чтобы сохранить множество различных объектов состояния и использовать разные подмножества объектов в разных местах, лучше избегать глобального сохранения состояния.