13

I am building a sample login razor component for an Asp.net core 3.0 Blazor Server-Side app. Whenever the code reaches the SignInAsyc method it just appears to hang or lock-up, as the code ceases further execution. I also tried switching up the logic, by using the PasswordSignInAsync method which gave me the exact same result. All code would execute before that method, but then freeze upon execution of that statement. What am I missing here?

Razor component page:

<div class="text-center">
    <Login FieldsetAttr="fieldsetAttr" UsernameAttr="usernameAttr" PasswordAttr="passwordInput"
           ButtonAttr="buttonAttr" ButtonText="Sign In" InvalidAttr="invalidAttr" />

</div>

@code {
    Dictionary<string, object> fieldsetAttr =
        new Dictionary<string, object>()
        {
            {"class", "form-group" }
        };

    Dictionary<string, object> usernameAttr =
        new Dictionary<string, object>()
        {
            {"class", "form-control" },
            {"type", "text" },
            {"placeholder", "Enter your user name here." }
        };

    Dictionary<string, object> passwordInput =
        new Dictionary<string, object>()
        {
            {"class", "form-control" },
            {"type", "password" }
        };

    Dictionary<string, object> buttonAttr =
        new Dictionary<string, object>()
        {
            {"type", "button" }
        };

    Dictionary<string, object> invalidAttr =
        new Dictionary<string, object>()
        {
            {"class", "" },
            {"style", "color: red;" }
        };

    Dictionary<string, object> validAttr =
        new Dictionary<string, object>()
        {
            {"class", "" },
            {"style", "color: green;" }
        };

}

Razor component:

@inject SignInManager<IdentityUser> signInManager
@inject UserManager<IdentityUser> userManager

<div @attributes="FormParentAttr">
    <form @attributes="LoginFormAttr">
        <fieldset @attributes="FieldsetAttr">
            <legend>Login</legend>
            <label for="usernameId">Username</label><br />
            <input @attributes="UsernameAttr" id="usernameId" @bind="UserName" /><br />
            <label for="upasswordId">Password</label><br />
            <input @attributes="PasswordAttr" id="passwordId" @bind="Password" /><br />
            <button @attributes="ButtonAttr" @onclick="@(async e => await LoginUser())">@ButtonText</button>
            @if (errorMessage != null && errorMessage.Length > 0)
            {
                <div @attributes="InvalidAttr">
                    @errorMessage
                </div>
            }
            else if(successMessage != null && successMessage.Length > 0)
            {
                <div @attributes="ValidAttr">
                    @successMessage
                </div>
            }
        </fieldset>
    </form>
</div>

@code {

    string successMessage = "";

    private async Task LoginUser()
    {
        if(!String.IsNullOrEmpty(UserName))
        {
            var user = await userManager.FindByNameAsync(UserName);
            var loginResult =
                await signInManager.CheckPasswordSignInAsync(user, Password, false);



            if(loginResult.Succeeded)
            {
                await signInManager.SignInAsync(user, true);
                successMessage = $"{UserName}, signed in.";
                errorMessage = "";
            }
            else
            {
                successMessage = "";
                errorMessage = "Username or password is incorrect.";
            }
        }
        else
        {
            successMessage = "";
            errorMessage = "Provide a username.";
        }
    }

    [Parameter]
    public Dictionary<string, object> FormParentAttr { get; set; }

    [Parameter]
    public Dictionary<string, object> LoginFormAttr { get; set; }

    [Parameter]
    public Dictionary<string, object> FieldsetAttr { get; set; }

    [Parameter]
    public Dictionary<string, object> UsernameAttr { get; set; }

    [Parameter]
    public Dictionary<string, object> PasswordAttr { get; set; }

    [Parameter]
    public Dictionary<string,object> ButtonAttr { get; set; }

    [Parameter]
    public Dictionary<string, object> InvalidAttr { get; set; }

    private string UserName { get; set; }
    private string Password { get; set; }

    [Parameter]
    public string ButtonText { get; set; }

    [Parameter]
    public Dictionary<string, object> ValidAttr { get;set; }

    public string errorMessage { get; set; }

}
jazb
  • 5,498
  • 6
  • 37
  • 44
user1206480
  • 1,798
  • 3
  • 28
  • 45

2 Answers2

19

Basically, it happens because the SigninManger::SignInAsync() will actually try to send a cookie over HTTP to indicate this user has already signed in. But when dealing with Blazor Server Side at this moment, there's no available HTTP Response at all , there's only a WebSocket connection (SignalR).

How to Fix

In a nutshell, Signin is to persist user credentials/cookies/... so that the WebApp knows who the client is. Since you're using a Blazor Server Side, your client is talking to the server within a WebSocket connection. There's no need to send cookies over HTTP. Because your WebApp has already knows who the current user is.

To fix this issue, register an IHostEnvironmentAuthenticationStateProvider service firstly:

services.AddScoped<AuthenticationStateProvider, RevalidatingIdentityAuthenticationStateProvider<IdentityUser>>();
services.AddScoped<IHostEnvironmentAuthenticationStateProvider>(sp => {
    // this is safe because 
    //     the `RevalidatingIdentityAuthenticationStateProvider` extends the `ServerAuthenticationStateProvider`
    var provider = (ServerAuthenticationStateProvider) sp.GetRequiredService<AuthenticationStateProvider>();
    return provider;
});

And then create a principal and replace the old one .

@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IHostEnvironmentAuthenticationStateProvider HostAuthentication
...

var user = await userManager.FindByNameAsync(UserName);
var valid= await signInManager.UserManager.CheckPasswordAsync(user, Password);

if (valid)
{
    var principal = await signInManager.CreateUserPrincipalAsync(user);

    var identity = new ClaimsIdentity(
        principal.Claims,
        Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme
    );
    principal = new System.Security.Claims.ClaimsPrincipal(identity);
    signInManager.Context.User = principal;
    HostAuthentication.SetAuthenticationState(Task.FromResult(new AuthenticationState(principal)));

    // now the authState is updated
    var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();

    successMessage = $"{UserName}, signed in.";
    errorMessage = "";

}
else
{
    successMessage = "";
    errorMessage = "Username or password is incorrect.";
}

Demo

enter image description here

And check the authState:

enter image description here

itminus
  • 23,772
  • 2
  • 53
  • 88
  • 1
    so the logic works in terms of the password check, but when I check the authState to see if the user IsAuthenticated it is false. Also the Name property is empty as well. Is there something that I'm missing. – user1206480 Oct 16 '19 at 06:23
  • 1
    So, I see that the user is authenticated and accessible via the signInManager.Context.User but not via authState. How does this affect the rest of the identity system in regards to authorization, claims, and policies for page access etc. if any? – user1206480 Oct 16 '19 at 06:37
  • 2
    @user1206480 Sorry, I forget to tirgger the `NotifyAuthenticationStateChanged` event. I've edited my answer to make sure the authState is updated. Please check :) – itminus Oct 16 '19 at 08:53
  • 1
    thanks that did it. I really need to go back and research the whole Identity situation with .net. I would appreciate any links or recommended literature. Thank you. – user1206480 Oct 17 '19 at 06:18
  • 1
    @user1206480 1. To be honest, I don't know whether there're some good links or recommended literature about the Identity scenario that covers something you didn't know. And the `NotifyAuthenticationStateChanged` is in the [Component](https://github.com/aspnet/AspNetCore/blob/darc-master-d397726c-f6bc-4523-a697-498e4c6bb47e/src/Components/Server/src/Circuits/ServerAuthenticationStateProvider.cs) instead of in the `Identity`. This is introduced in ASP.NET 3.0. I doubt whether there's enough docs online about this feature. – itminus Oct 17 '19 at 07:13
  • @user1206480 (too long to post within a single comment) 2. If you're interested in ASP.NET Core security, I would suggest you could read the source of [authentication](https://github.com/aspnet/AspNetCore/tree/master/src/Security/Authentication), and the source code of [Authorization](https://github.com/aspnet/AspNetCore/tree/master/src/Security/Authorization) when you've read the official docs about security. Don't be scared. This source code is not hard to understand. – itminus Oct 17 '19 at 07:13
  • I have discovered another issue with this login scenario. The user stays logged in as long as the user navigates between razor pages ie (something.razor to somethingelse.razor), but when the user navigates to a .cshtml page the authentication is lost. Is there a work around for this? – user1206480 Oct 27 '19 at 04:12
  • @user1206480 To solve that problem, we need persist credentials,for example, create a javascript function to save Cookie/Token to browser/local storage, and invoke this js function by interop. – itminus Oct 28 '19 at 00:51
  • @user1206480 I'm wondering whether it's easier if we redirect current request to a endpoint where we could sign in the user when the user submits the user/password, and then redirect to previous route (in Blazor) such that we don't have to care about the cookie persistence. – itminus Oct 28 '19 at 01:13
  • yes that's basically what I've ended up doing. Redirected request to a standard .cshtml page for login. Much simpler at this time for this scenario, as Blazor doesn't appear to be yet fully geared towards initial user login purposes. – user1206480 Oct 28 '19 at 16:08
  • 3
    It says here https://learn.microsoft.com/en-us/aspnet/core/blazor/security/?view=aspnetcore-3.1 that SignInManger and UserManager are not supported in Razor components. How is this an acceptable answer? Also, RevalidatingIdentityAuthenticationStateProvider does not exist in the namespace and generates a compile error. I just don't understand why there's no docs on this and the "solutions" are so problematic. – CoderSteve Oct 06 '20 at 15:36
  • @CoderSteve For the namespace, see [RevalidatingServerAuthenticationStateProvider](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.components.server.revalidatingserverauthenticationstateprovider?view=aspnetcore-3.1) – itminus Oct 07 '20 at 00:53
  • @itminus Yes, I was using that namespace and it still gave a compile error. It seems to be because of the generics part of it: `RevalidatingIdentityAuthenticationStateProvider` – CoderSteve Oct 08 '20 at 09:38
  • @CoderSteve Did you forget to use the namespace of `IdentityUser`? – itminus Oct 08 '20 at 09:51
  • @itminus Maybe that was the problem. Anyway, I've moved away from this solution for the other reason. Thanks for your help. – CoderSteve Oct 09 '20 at 10:04
  • 2
    RevalidatingIdentityAuthenticationStateProvider doesn't exist in .NET Core 3.1, but RevalidatingServerAuthenticationStateProvider does exist instead. Trying to use that, I get this error on startup: `Cannot instantiate implementation type 'Microsoft.AspNetCore.Components.Server.RevalidatingServerAuthenticationStateProvider' for service type 'Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider'.'` – Etienne Charland Oct 11 '20 at 17:15
  • @itminus While this works great on the face of it. It does not set the cookie. So if you navigate to another page, you are no longer logged in? – YodasMyDad Dec 15 '20 at 17:00
  • @leen3o Yes, I think it's better to sign in the user in a different page and then redirect the user to the Blazor SPA . Or create a javascript function that saves Cookie/Token to browser/local storage, and invoke this js function by interop – itminus Dec 16 '20 at 01:36
  • @itminus I've been looking for an example that saves the identity cookie via JS interop but I can't seem to find one. Have you done this? Or seen an example of this anywhere? – YodasMyDad Dec 16 '20 at 06:25
  • @leen3o Nope, its' just an idea that I believe it should work. I have not implemented it yet. – itminus Dec 17 '20 at 00:48
  • @leen3o: See my answer below. It took me some troubleshooting, as I didn't understand that the CookieAuthenticationOptions were keyed under the scheme name. – James Haug Jan 11 '21 at 17:36
10

One of the issues with the previous answer by itminus and discussed in the comments was keeping the state of the user after a manual refresh, session end, or a link that caused a refresh. This would lose the user's state because the cookie value wasn't being set to the client's browser, which meant the next HTTP request didn't include the cookie. One solution is to use static login/out pages which would allow the cookies to be sent to the client's browser.

This method instead uses JS to write the cookies to the client's browser, allowing Blazor to handle everything. I ran into some issues with the cookie settings not properly setting, because of my misunderstanding of how AddCookie() in the Startup adds the options to the DI container. It uses IOptionsMonitor to use named options, using the Scheme as the key.

I've modified the sign in code to invoke JS that will save the cookie. You can run this after registering a new user or signing in an existing user.

Ensure you DI the IOptionsMonitor<CookieAuthenticationOptions>, allowing you to resolve the named options, using the Scheme as the key. Ensure you use .Get(schemeName) instead of .CurrentValue, else you're TicketDataFormat (and other settings) will be incorrect, as it'll use the default values. It took me hours to realize this.

Note: IOptionsMonitor<CookieAuthenticationOptions> comes from calling services.AddAuthentication().AddCookie(). An example is provided below this.

    _cookieAuthenticationOptions = cookieAuthenticationOptionsMonitor.Get("MyScheme");
    ...
    private async Task SignInAsync(AppUser user, String password)
    {
        //original code from above answer
        var principal = await _signInManager.CreateUserPrincipalAsync(user);

        var identity = new ClaimsIdentity(
            principal.Claims,
            "MyScheme"
        );
        principal = new ClaimsPrincipal(identity);
        _signInManager.Context.User = principal;
        _hostAuthentication.SetAuthenticationState(Task.FromResult(new AuthenticationState(principal)));

        // this is where we create a ticket, encrypt it, and invoke a JS method to save the cookie
        var ticket = new AuthenticationTicket(principal, null, "MyScheme");
        var value = _cookieAuthenticationOptions.TicketDataFormat.Protect(ticket);
        await _jsRuntime.InvokeVoidAsync("blazorExtensions.WriteCookie", "CookieName", value, _cookieAuthenticationOptions.ExpireTimeSpan.TotalDays);
    }

We then write a JS cookie:

    window.blazorExtensions = {

        WriteCookie: function (name, value, days) {

            var expires;
            if (days) {
                var date = new Date();
                date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
                expires = "; expires=" + date.toGMTString();
            }
            else {
                expires = "";
            }
            document.cookie = name + "=" + value + expires + "; path=/";
        }
    }

This will successfully write the cookie to the client's browser. If you are having issues, make sure that your Startup is using the same scheme name. If you don't, then the normal cookie authentication system will not properly parse back the principal that was encoded:

        services.AddIdentityCore<AppUser>()
            .AddRoles<IdentityRole>()
            .AddEntityFrameworkStores<AppDbContext>()
            .AddSignInManager();

        services.AddAuthentication(options =>
        {
            options.DefaultScheme = "MyScheme";
        }).AddCookie("MyScheme", options =>
        {
            options.Cookie.Name = "CookieName";
        });

For completionist, you can also implement the log off the same way:

    private async Task SignOutAsync()
    {
        var principal = _signInManager.Context.User = new ClaimsPrincipal(new ClaimsIdentity());
        _hostAuthentication.SetAuthenticationState(Task.FromResult(new AuthenticationState(principal)));

        await _jsRuntime.InvokeVoidAsync("blazorExtensions.DeleteCookie", _appInfo.CookieName);

        await Task.CompletedTask;
    }

And the JS:

    window.blazorExtensions = {
        DeleteCookie: function (name) {
            document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:01 GMT";
        }
    }
James Haug
  • 1,426
  • 12
  • 27
  • 4
    I'm scared to know that even on .net 5 we don't have or I can't find a easy solution for login in blazor Server, I'll be trying to implement this as well. – Daniel May 25 '21 at 01:54
  • Please update the answer explaining what "IOptionsMonitor" means? I added to the startup services.AddSingleton>(); but it throws an error – Daniel May 26 '21 at 00:20
  • I managed to do it, but I realized that after login and sucessfully navigating onto pages, after refreshing the page (F5) the user is not Authenticated any more, even if the cookie still there. Any Thoughts? – Daniel May 26 '21 at 01:04
  • 1
    ops, I just forgot to set the right options.Cookie.Name = "CookieName"; Now it's working (After One Thousand hours) – Daniel May 27 '21 at 01:20
  • @Daniel: The MS documentation on options and DI is here: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-5.0#use-di-services-to-configure-options – James Haug Jun 03 '21 at 17:02
  • @Daniel: I updated the answer to clarify that services.AddAuthentication().AddCookie() registers the options you're looking for – James Haug Jun 03 '21 at 17:07