3

When it comes to use Identity Framework with Blazor Server, Microsoft's official statement is that it's not supported and to use a Razor login page instead. The issue is that Blazor can't write cookies.

This has a few drawbacks:

  • Cannot re-use the HTML layout files, need to recreate duplicate layout files for Razor.
  • Cannot embed login buttons on Blazor page
  • Poor user experience if need to optionally login as part of a checkout experience

This guy figured out a way to make a Blazor login page work with Blazor WebAssembly... not sure how he worked around the issue nor if a similar approach can work for Blazor Server.

I was thinking of alternative solutions. The issue is to store the cookie. Local storage can be used in Blazor but local storage is not safe for security tokens. Cookies however could also be set via JavaScript interop in a similar way.

Would this approach work, setting the login cookie via JavaScript interop after login, and then the cookie gets sent on any further page load? Has anyone done it?

For single-page admin content, I found a simpler approach of creating a GatedContent component that shows a login form, and then shows ChildContent after login. Of course, this won't preserve the session on page refresh, but it works for some cases.

Etienne Charland
  • 3,424
  • 5
  • 28
  • 58
  • Have you tried to set the cookie using the JavaScript interop? – ˈvɔlə Oct 12 '20 at 18:06
  • Not yet. Storing elsewhere would require creating a custom AuthenticationStateProvider to get the state... and other code elsewhere to set the authentication state. There might be a lot of complex details involved. We might need to also create a custom SignInManager and override SignInAsync. – Etienne Charland Oct 12 '20 at 18:29
  • For AuthenticationStateProvider, I would not want a round-trip for every check. It would keep the standard cookie look-up, but needs to additionally detect a login that happened since... where would that login be stored meanwhile? If noone has done it, I'll put this on the back-burner to try it later on. – Etienne Charland Oct 12 '20 at 19:14
  • Still no answer on this but I've been thinking about it. Posting the cookie via JS interop shouldn't be a problem, but storing the state server-side can be a problem. Plus, querying the client for token info is not an option because the state will be requested before loading the page where JS is not available. What *could* work, however, is sending the cookie via Interop and then immediately reloading the page, sending the cookie back to the server. The only thing that would need to be changed is the way the cookie is written in SignInManager. – Etienne Charland Oct 13 '20 at 03:53
  • 1
    Why don't you just use an ASP.NET Core MVC AuthenticationController to handle the login / logout? Works like a charm for us in Blazor Server-Side. Maybe there will be nice implementation in the future for Blazor-Only authentication, who knows. – ˈvɔlə Oct 13 '20 at 08:27
  • Could you add a link for this bit "Microsoft's official statement is that it's not supported and to use a Razor login page instead"? I'd like to read what they have to say about that – tomRedox Oct 13 '20 at 14:33
  • 1
    https://github.com/dotnet/aspnetcore/issues/23417 – Etienne Charland Oct 13 '20 at 16:09
  • Wolle, having a controller handle Login/Logout is what they did in a link in the main post with Blazor WebAssembly. None of that is required for Web Server scenario; but apparently setting the cookie works natively in WebAssembly and not in Web Server. – Etienne Charland Oct 13 '20 at 16:13
  • I dug through the code and found pretty cool stuff. SignInManager leads to HttpContext.SignInAsync extension method, then IAuthenticationService, then IAuthenticationHandler, then CookieAuthenticationHandler, then... CookieAuthenticationEvents. Look at this: "Use cookie authentication without ASP.NET Core Identity", could also do the reverse "Use ASP.NET Core Identity without cookie authentication" https://learn.microsoft.com/en-us/aspnet/core/security/authentication/cookie?view=aspnetcore-3.1 – Etienne Charland Oct 14 '20 at 02:29
  • Unfortunately, calling SignInAsync throws "System.InvalidOperationException: 'The response headers cannot be modified because the response has already started.'" so CookieAuthenticationEvents won't be reached. – Etienne Charland Oct 14 '20 at 03:39
  • I "almost" succeeded but got stuck at calling IJsRuntime from the Identity Framework pipeline... and Microsoft says that it's not supported. Anything else to be done here? https://github.com/dotnet/aspnetcore/issues/26898 – Etienne Charland Oct 14 '20 at 19:42
  • Why not use a Class Library to store the local content including DAL information?... That way it is encrypted in a DLL file. – Chris Singleton Sep 24 '21 at 02:40

2 Answers2

1

Here's a solution for single-page admin content: GatedContent

It will show a login form, and after successful login, show the gated content.

SpinnerButton is defined here.

@using Microsoft.AspNetCore.Identity
@using System.ComponentModel.DataAnnotations
@inject NavigationManager navManager
@inject SignInManager<ApplicationUser> signInManager
@inject UserManager<ApplicationUser> userManager;

@if (!LoggedIn)
{
<EditForm Context="formContext" class="form-signin" OnValidSubmit="@SubmitAsync" Model="this">
    <DataAnnotationsValidator />

    <div style="margin-bottom: 1rem; margin-top: 1rem;" class="row">
        <Field For="@(() => Username)" Width="4" Required="true">
            <RadzenTextBox @bind-Value="@Username" class="form-control" Style="margin-bottom: 0px;" />
        </Field>
    </div>
    <div style="margin-bottom: 1rem" class="row">
        <Field For="@(() => Password)" Width="4" Required="true">
            <RadzenPassword @bind-Value="@Password" class="form-control" />
        </Field>
    </div>

    <SpinnerButton @ref="ButtonRef" style="width:150px" Text="Login" ButtonType="@((Radzen.ButtonType)ButtonType.Submit)" ButtonStyle="@((Radzen.ButtonStyle)ButtonStyle.Primary)" OnSubmit="LogInAsync" />
    @if (Error.HasValue())
    {
        <div class="red">@Error</div>
    }
</EditForm>
}
else
{
    @ChildContent
}
@code {
    public SpinnerButton? ButtonRef { get; set; }
    public async Task SubmitAsync() => await ButtonRef!.FormSubmitAsync().ConfigureAwait(false);

    [Required]
    public string Username { get; set; } = string.Empty;

    [Required]
    public string Password { get; set; } = string.Empty;

    string? Error { get; set; }
    bool LoggedIn { get; set; }

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

    public async Task LogInAsync()
    {
        Error = null;
        try
        {
            var user = await userManager.FindByNameAsync(Username).ConfigureAwait(false);
            if (user != null)
            {
                var isAdmin = await userManager.IsInRoleAsync(user, ApplicationRole.Admin).ConfigureAwait(false);
                if (isAdmin)
                {
                    var singInResult = await signInManager.CheckPasswordSignInAsync(user, Password, true).ConfigureAwait(false);
                    if (singInResult.Succeeded)
                    {
                        LoggedIn = true;
                    }
                    else
                    {
                        Error = "Invalid password";
                    }
                }
                else
                {
                    Error = "User is not Admin";
                }
            }
            else
            {
                Error = "Username not found";
            }
        }
        catch (Exception ex)
        {
            Error = ex.Message;
        }
    }
}

Use like this

<GatedContent>
    This is an admin page.
</GatedContent>

Of course, this solution doesn't preserve the state after page reload, but it works for simple admin pages.

Etienne Charland
  • 3,424
  • 5
  • 28
  • 58
0

My workaround is like this: I've created a SignInController like this

    public class SignInController : ControllerBase
    {
        private readonly SignInManager<IdentityUser> _signInManager;

        public SignInController(SignInManager<IdentityUser> signInManager)
        {
            _signInManager = signInManager;
        }

        [HttpPost("/signin")]
        public IActionResult Index([FromForm]string username, [FromForm]string password, [FromForm]string rememberMe)
        {
            bool.TryParse(rememberMe, out bool res);
            var signInResult = _signInManager.PasswordSignInAsync(username, password, res, false);
            if (signInResult.Result.Succeeded)
            {
                return Redirect("/");
            }
            return Redirect("/login/"+ signInResult.Result.Succeeded);
        }

        [HttpPost("/signout")]
        public async Task<IActionResult> Logout()
        {
            if (_signInManager.IsSignedIn(User))
            {
                await _signInManager.SignOutAsync();
            }
            return Redirect("/");
        }
    }

And I've a login.razor like this:

@page "/login"
    <form action="/signin" method="post">
            <div class="form-group">
                <p> Username </p>
                <input id="username" Name="username" />
            </div>
            <div class="form-group">
                <p>password<p/>
                <input type="password" id="password" />
            </div>
            <div class="form-group">
                <p> remember me? <p/>
                <input type="checkbox" id="rememberMe" />
            </div>
            <button type="Submit" Text="login" />
    </form>

This issue is caused by the signInManager which is only working if you use HTTP/S requests. Otherwise, it will throw Exceptions. There are also some reports about that on the dotnet/aspcore.net Repo on GitHub.

At the moment this is the only way (I know) to use it with blazor server.

Dharman
  • 30,962
  • 25
  • 85
  • 135
JoeGER94
  • 177
  • 3
  • 14
  • Oh right. You can still do a simple Form Post in Blazor! Only issue I can see is that it will do a full postback on failed login. I like the simplicity of it though! – Etienne Charland Oct 13 '20 at 16:16
  • @EtienneCharland i implemented a full postback because i wanted to keep it simple. But maybe there is a more elegant way. Never thought about. But you've got the full functionality (like cookies etc.). If you like the answer i would appreciate if you mark it – JoeGER94 Oct 13 '20 at 18:17
  • 1
    It's "a" solution. This thread will be a good place to list the various options. I still think we can do better. – Etienne Charland Oct 13 '20 at 21:39
  • i personally think, there are not much more solutions than a Post. The exact implementation can vary - sure – JoeGER94 Oct 14 '20 at 09:05
  • 1
    The main issue is that I'd struggle to show error messages. Still, it satisfies the first criteria of avoiding to duplicate the templates for Razor Pages. – Etienne Charland Oct 14 '20 at 13:27
  • My solution doesnt throw errors?... dont know what you mean but i would like to help you. – JoeGER94 Oct 14 '20 at 13:56
  • hum. Instead of messing around with JS interop, there's also the option of first validating the login via Blazor, and if successful, asking the client to post the form. (This would however result in double-login check.) Also need to add ReturnUrl parameter. – Etienne Charland Oct 14 '20 at 15:43
  • btw you have broken `

    ` tags
    – Etienne Charland Oct 14 '20 at 15:48
  • Your html code needs to set "name" instead of "id" or the controller doesn't get the data. Also your controller crashes if username or password is null. – Etienne Charland Oct 14 '20 at 16:13
  • There's also the issue that I can't embed Blazor controls on a standard `
    ` and that basic validators aren't working. If putting them on a `EditForm`, the HTML looks fine but Submit isn't working... not sure what's blocking it. There's also the "reconnecting..." message that appears while it's posting a form.
    – Etienne Charland Oct 14 '20 at 16:24
  • Here's another idea. It "should" be possible to set the cookie within an IFrame, right? Using your controller. It might even be possible to read the result back, but that might require a bit of JavaScript work. Perhaps if you or someone want to look into this? https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage – Etienne Charland Oct 14 '20 at 20:38
  • this is just an example which should give you an idea. But i will refactor it and will give you a fully working Solution later this day. The appearing "reconnecting..." Message is caused by a dotnet-core bug see: https://github.com/dotnet/aspnetcore/issues/25646 This bug is already fixed in .net5 RC2 but the backport to 3.1 is incoming – JoeGER94 Oct 15 '20 at 06:06
  • actually I was using .NET 5 RC2 – Etienne Charland Oct 15 '20 at 14:09
  • Do you think it would be possible to write a JavaScript function that posts in an IFrame and then waits for and returns the result? If that would be possible, then Blazor could post and get the result back very smoothly in a non-disruptive way. I'm not good enough in JavaScript though. – Etienne Charland Oct 15 '20 at 22:30
  • this is not a too bad idea. i think you could create a JS function which get called from Blazor. This JS function could do the "post" for you. Its not difficult to try but i havent had time to try yet. I will check and give you response – JoeGER94 Oct 16 '20 at 05:58
  • Got any time to look into this approach? My JS skills aren't good enough. – Etienne Charland Dec 09 '20 at 20:51