Codo
Codo

Reputation: 79023

How to store session data in server-side blazor

In a server-side Blazor app I'd like to store some state that is retained between page navigation. How can I do it?

Regular ASP.NET Core session state does not seem to be available as most likely the following note in Session and app sate in ASP.NET Core applies:

Session isn't supported in SignalR apps because a SignalR Hub may execute independent of an HTTP context. For example, this can occur when a long polling request is held open by a hub beyond the lifetime of the request's HTTP context.

The GitHub issue Add support to SignalR for Session mentions that you can use Context.Items. But I have no idea how to use it, i.e. I don't know hot to access the HubConnectionContext instance.

What are my options for session state?

Upvotes: 57

Views: 83758

Answers (9)

Rodion Mostovoi
Rodion Mostovoi

Reputation: 1593

Here is a relevant solution for ASP.NET Core 5.0+ (ProtectedSessionStorage, ProtectedLocalStorage): https://learn.microsoft.com/en-gb/aspnet/core/blazor/state-management?view=aspnetcore-5.0&pivots=server

An example:

@page "/"
@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage
@inject ProtectedSessionStorage ProtectedSessionStore

User name: @UserName
<p/><input value="@UserName" @onchange="args => UserName = args.Value?.ToString()" />
<button class="btn btn-primary" @onclick="SaveUserName">Save</button>

@code {
    private string UserName;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        await base.OnAfterRenderAsync(firstRender);
        if (firstRender)
        {
            UserName = (await ProtectedSessionStore.GetAsync<string>("UserName")).Value ?? "";
            StateHasChanged();
        }
    }
    
    private async Task SaveUserName() {
        await ProtectedSessionStore.SetAsync("UserName", UserName);
    }
}

Note that this method stores data encrypted.

Upvotes: 14

David Brenchley
David Brenchley

Reputation: 86

With .net 5.0 you now have ProtectedSessionStorage which gives you encrypted browser session data.

@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; 
@inject ProtectedSessionStorage storage 

// Set   
await storage.SetAsync("myFlag", "Green");  
  
// Get  
var myFlag= await storage.GetAsync<string>("myFlag");

Uses JavaScript interops, so don't use in OnInitialize, but in OnAfterRender instead.

Upvotes: 2

Jason D
Jason D

Reputation: 2087

I found a method for storing user data in a server-side session. I did this by using the CircuitHandler Id as a ‘token’ for the user to access the system. Only the Username and CircuitId gets stored in the client LocalStorage (using Blazored.LocalStorage); other user data is stored in the server. I know it's a lot of code, but this was the best way I could find to keep user data secure on the server-side.

UserModel.cs (for client-side LocalStorage)

public class UserModel
{
    public string Username { get; set; }

    public string CircuitId { get; set; }
}

SessionModel.cs (the model for my Server-side session)

public class SessionModel
{
    public string Username { get; set; }

    public string CircuitId { get; set; }

    public DateTime DateTimeAdded { get; set; }  //this could be used to timeout the session

    //My user data to be stored server side...
    public int UserRole { get; set; } 
    etc...
}

SessionData.cs (keeps a list of all active sessions on the server)

public class SessionData
{
    private List<SessionModel> sessions = new List<SessionModel>();
    private readonly ILogger _logger;
    public List<SessionModel> Sessions { get { return sessions; } }

    public SessionData(ILogger<SessionData> logger)
    {
        _logger = logger;
    }

    public void Add(SessionModel model)
    {
        model.DateTimeAdded = DateTime.Now;

        sessions.Add(model);
        _logger.LogInformation("Session created. User:{0}, CircuitId:{1}", model.Username, model.CircuitId);
    }

    //Delete the session by username
    public void Delete(string token)
    {
        //Determine if the token matches a current session in progress
        var matchingSession = sessions.FirstOrDefault(s => s.Token == token);
        if (matchingSession != null)
        {
            _logger.LogInformation("Session deleted. User:{0}, Token:{1}", matchingSession.Username, matchingSession.CircuitId);

            //remove the session
            sessions.RemoveAll(s => s.Token == token);
        }
    }

    public SessionModel Get(string circuitId)
    {
        return sessions.FirstOrDefault(s => s.CircuitId == circuitId);
    }
}

CircuitHandlerService.cs

public class CircuitHandlerService : CircuitHandler
{
    public string CircuitId { get; set; }
    public SessionData sessionData { get; set; }

    public CircuitHandlerService(SessionData sessionData)
    {
        this.sessionData = sessionData;
    }

    public override Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        CircuitId = circuit.Id;
        return base.OnCircuitOpenedAsync(circuit, cancellationToken);
    }

    public override Task OnCircuitClosedAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        //when the circuit is closing, attempt to delete the session
        //  this will happen if the current circuit represents the main window
        sessionData.Delete(circuit.Id); 

        return base.OnCircuitClosedAsync(circuit, cancellationToken);
    }

    public override Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        return base.OnConnectionDownAsync(circuit, cancellationToken);
    }

    public override Task OnConnectionUpAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        return base.OnConnectionUpAsync(circuit, cancellationToken);
    }
}

Login.razor

@inject ILocalStorageService localStorage
@inject SessionData sessionData
....
public SessionModel session { get; set; } = new SessionModel();
...
if (isUserAuthenticated == true)
{
    //assign the sesssion token based on the current CircuitId
    session.CircuitId = (circuitHandler as CircuitHandlerService).CircuitId;
    sessionData.Add(session);

    //Then, store the username in the browser storage
    //  this username will be used to access the session as needed
    UserModel user = new UserModel
    {
        Username = session.Username,
        CircuitId = session.CircuitId
    };

    await localStorage.SetItemAsync("userSession", user);
    NavigationManager.NavigateTo("Home");
}

Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddServerSideBlazor();
    services.AddScoped<CircuitHandler>((sp) => new CircuitHandlerService(sp.GetRequiredService<SessionData>()));
    services.AddSingleton<SessionData>();
    services.AddBlazoredLocalStorage();
    ...
}

Upvotes: 7

Saeed Ahmad
Saeed Ahmad

Reputation: 83

You can store data in sessions using Blazored.SessionStorage package.

Install Blazored.SessionStorage

`@inject Blazored.SessionStorage.ISessionStorageService sessionStorage` 

    @code {

    protected override async Task OnInitializedAsync()
    {
        await sessionStorage.SetItemAsync("name", "John Smith");
        var name = await sessionStorage.GetItemAsync<string>("name");
    }

}

Upvotes: 5

Codo
Codo

Reputation: 79023

Note: This answer is from December 2018 when an early version of Server-side Blazor was available. Most likely, it is no longer relevant.

The poor man's approach to state is a hinted by @JohnB: Use a scoped service. In server-side Blazor, scoped service as tied to the SignalR connection. This is the closest thing to a session you can get. It's certainly private to a single user. But it's also easily lost. Reloading the page or modifying the URL in the browser's address list loads start a new SignalR connection, creates a new service instance and thereby loses the state.

So first create the state service:

public class SessionState
{
    public string SomeProperty { get; set; }
    public int AnotherProperty { get; set; }
}

Then configure the service in the Startup class of the App project (not server project):

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddScoped<SessionState>();
    }

    public void Configure(IBlazorApplicationBuilder app)
    {
        app.AddComponent<Main>("app");
    }
}

Now you can inject the state into any Blazor page:

@inject SessionState state

 <p>@state.SomeProperty</p>
 <p>@state.AnotherProperty</p>

Better solutions are still super welcome.

Upvotes: 19

Ali Hasan
Ali Hasan

Reputation: 162

Please refer to the following repository for the server side session implementation: https://github.com/alihasan94/BlazorSessionApp

On Login.razor page, write the following code:

@page "/"

@using Microsoft.AspNetCore.Http
@using Helpers;
@using Microsoft.JSInterop;


@inject SessionState session
@inject IJSRuntime JSRuntime
@code{

    public string Username { get; set; }
    public string Password { get; set; }
}

@functions {
    private async Task SignIn()
    {
        if (!session.Items.ContainsKey("Username") && !session.Items.ContainsKey("Password"))
        {
            //Add to the Singleton scoped Item
            session.Items.Add("Username", Username);
            session.Items.Add("Password", Password);
//Redirect to homepage
            await JSRuntime.InvokeAsync<string>(
            "clientJsMethods.RedirectTo", "/home");
        }
    }
}

<div class="col-md-12">
    <h1 class="h3 mb-3 font-weight-normal">Please Sign In</h1>
</div>

<div class="col-md-12 form-group">
    <input type="text" @bind="Username" class="form-control" id="username"
           placeholder="Enter UserName" title="Enter UserName" />
</div>

<div class="col-md-12 form-group">
        <input type="password" @bind="Password" class="form-control" id="password"
               placeholder="Enter Password" title="Enter Password" />
</div>


<button @onclick="SignIn">Login</button>

SessionState.cs

using System.Collections.Generic;

namespace BlazorSessionApp.Helpers
{
    public class SessionState
    {
        public SessionState()
        {
            Items = new Dictionary<string, object>();
        }
       public Dictionary<string, object> Items { get; set; }
    }
}

SessionBootstrapper.cs(Contains logic for setting session)

using Microsoft.AspNetCore.Http;

namespace BlazorSessionApp.Helpers
{
    public class SessionBootstrapper
    {
        private readonly IHttpContextAccessor accessor;
        private readonly SessionState session;
        public SessionBootstrapper(IHttpContextAccessor _accessor, SessionState _session)
        {
            accessor = _accessor;
            session = _session;
        }
        public void Bootstrap() 
        {
            //Singleton Item: services.AddSingleton<SessionState>(); in Startup.cs

            //Code to save data in server side session

            //If session already has data
            string Username = accessor.HttpContext.Session.GetString("Username");
            string Password = accessor.HttpContext.Session.GetString("Password");

            //If server session is null
            if (session.Items.ContainsKey("Username") && Username == null)
            {
                //get from singleton item
                Username = session.Items["Username"]?.ToString();
                // save to server side session
                accessor.HttpContext.Session.SetString("Username", Username);
                //remove from singleton Item
                session.Items.Remove("Username");
            }

            if (session.Items.ContainsKey("Password") && Password == null)
            {
                Password = session.Items["Password"].ToString();
                accessor.HttpContext.Session.SetString("Password", Password);
                session.Items.Remove("Password");
            }

            //If Session is not expired yet then  navigate to home
            if (!string.IsNullOrEmpty(Username) && !string.IsNullOrEmpty(Password) && accessor.HttpContext.Request.Path == "/")
            {
                accessor.HttpContext.Response.Redirect("/home");
            }
            //If Session is expired then navigate to login
            else if (string.IsNullOrEmpty(Username) && string.IsNullOrEmpty(Password) && accessor.HttpContext.Request.Path != "/")
            {
                accessor.HttpContext.Response.Redirect("/");
            }
        }
    }
}

_Host.cshtml(Initialize SessionBootstrapper class here)

@page "/"
@namespace BlazorSessionApp.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}
@using BlazorSessionApp.Helpers

@inject SessionBootstrapper bootstrapper

    <!DOCTYPE html>
    <html lang="en">
    <body>

        @{
            bootstrapper.Bootstrap();
        }
        <app>
            <component type="typeof(App)" render-mode="ServerPrerendered" />
        </app>

        <script src="_framework/blazor.server.js"></script>
        <script>
            // use this to redirect from "Login Page" only in order to save the state on server side session
            // because blazor's NavigateTo() won't refresh the page. The function below refresh 
            // the page and runs bootstrapper.Bootstrap(); to save data in server side session.
            window.clientJsMethods = {
              RedirectTo: function (path) {
                    window.location = path;
                }
            };
        </script>
    </body>
    </html>

Upvotes: 1

McGuireV10
McGuireV10

Reputation: 9946

Don't use session state at all (I haven't tried, but I suspect AddSession doesn't even work under Blazor since the session ID is cookie-based and HTTP is mostly not in the picture). Even for non-Blazor web apps, there's no reliable mechanism for detecting the end of a session, so session cleanup is messy at best.

Instead, inject an implementation of IDistributedCache which supports persistence. One of the most popular examples is Redis cache. In one of my projects at work, I'm experimenting with the use of Microsoft Orleans for distributed caching. I'm not at liberty to share our in-house implementation but you can see an early example of this in my repo here.

Under the hood, session state is just a dictionary (keyed on the session ID) containing another dictionary of your key-value pairs. It's trivial to reproduce that approach using a long-term reliable key such as the authenticated user ID. I don't even go that far, though, since constantly serializing and deserializing an entire dictionary when I usually need just one or two keys is a lot of unnecessary overhead. Instead I prefix the individual value keys with my unique user IDs and store each value directly.

Upvotes: 2

jsmars
jsmars

Reputation: 1928

Here is a full code example of how you can use Blazored/LocalStorage to save session data. Used for example for storing the logged in user, etc. Confirmed working as of version 3.0.100-preview9-014004

@page "/login"
@inject Blazored.LocalStorage.ILocalStorageService localStorage

<hr class="mb-5" />
<div class="row mb-5">

    <div class="col-md-4">
        @if (UserName == null)
        {
            <div class="input-group">
                <input class="form-control" type="text" placeholder="Username" @bind="LoginName" />
                <div class="input-group-append">
                    <button class="btn btn-primary" @onclick="LoginUser">Login</button>
                </div>
            </div>
        }
        else
        {
            <div>
                <p>Logged in as: <strong>@UserName</strong></p>
                <button class="btn btn-primary" @onclick="Logout">Logout</button>
            </div>
        }
    </div>
</div>

@code {

    string UserName { get; set; }
    string UserSession { get; set; }
    string LoginName { get; set; }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            await GetLocalSession();

            localStorage.Changed += (sender, e) =>
            {
                Console.WriteLine($"Value for key {e.Key} changed from {e.OldValue} to {e.NewValue}");
            };

            StateHasChanged();
        }
    }

    async Task LoginUser()
    {
        await localStorage.SetItemAsync("UserName", LoginName);
        await localStorage.SetItemAsync("UserSession", "PIOQJWDPOIQJWD");
        await GetLocalSession();
    }

    async Task GetLocalSession()
    {
        UserName = await localStorage.GetItemAsync<string>("UserName");
        UserSession = await localStorage.GetItemAsync<string>("UserSession");
    }

    async Task Logout()
    {
        await localStorage.RemoveItemAsync("UserName");
        await localStorage.RemoveItemAsync("UserSession");
        await GetLocalSession();
    }
}

Upvotes: 5

Konrad Bartecki
Konrad Bartecki

Reputation: 448

Steve Sanderson goes in depth how to save the state.

For server-side blazor you will need to use any storage implementation in JavaScript that could be cookies, query parameters or for example you can use local/session storage.

There currently NuGet packages implementing that via IJSRuntime like BlazorStorage or Microsoft.AspNetCore.ProtectedBrowserStorage

Now the tricky part is that server-side blazor is pre-rendering pages, so your Razor view code will be run and executed on a server before it's even displayed to the client's browser. This causes an issue where IJSRuntime and thus localStorage is not available at this time. You will need to either disable prerendering or wait for the server generated page to be sent to the client's browser and estabilish a connection back to the server

During prerendering, there is no interactive connection to the user's browser, and the browser doesn't yet have any page in which it can run JavaScript. So it's not possible to interact with localStorage or sessionStorage at that time. If you try, you'll get an error similar to JavaScript interop calls cannot be issued at this time. This is because the component is being prerendered.

To disable prerendering:

(...) open your _Host.razor file, and remove the call to Html.RenderComponentAsync. Then, open your Startup.cs file, and replace the call to endpoints.MapBlazorHub() with endpoints.MapBlazorHub<App>("app"), where App is the type of your root component and "app" is a CSS selector specifying where in the document the root component should be placed.

When you want to keep prerendering:

@inject YourJSStorageProvider storageProvider

    bool isWaitingForConnection;

    protected override async Task OnInitAsync()
    {
        if (ComponentContext.IsConnected)
        {
            // Looks like we're not prerendering, so we can immediately load
            // the data from browser storage
            string mySessionValue = storageProvider.GetKey("x-my-session-key");
        }
        else
        {
            // We are prerendering, so have to defer the load operation until later
            isWaitingForConnection = true;
        }
    }

    protected override async Task OnAfterRenderAsync()
    {
        // By this stage we know the client has connected back to the server, and
        // browser services are available. So if we didn't load the data earlier,
        // we should do so now, then trigger a new render.
        if (isWaitingForConnection)
        {
            isWaitingForConnection = false;
            //load session data now
            string mySessionValue = storageProvider.GetKey("x-my-session-key");
            StateHasChanged();
        }
    }

Now to the actual answer where you want to persist the state between pages you should use a CascadingParameter. Chris Sainty explains this as

Cascading values and parameters are a way to pass a value from a component to all of its descendants without having to use traditional component parameters.

This would be a parameter which would be a class that holds all your state data and exposes methods that can load/save via a storage provider of your choice. This is explained on Chris Sainty's blog, Steve Sanderson's note or Microsoft docs

Update: Microsoft has published new docs explaining Blazor's state management

Update2: Please note that currently BlazorStorage is not working correctly for server-side Blazor with the most recent .NET SDK preview. You can follow this issue where I posted a temporary workaround

Upvotes: 12

Related Questions