3
votes

I'm trying to add authentication/login to my Blazor-server app. I don't want to use the Identity stuff, which setups up pages and a database.

When I did this with razor pages, I could have a page for logging in with code like this:

    var claims = new List<Claim>{
        new Claim(ClaimTypes.Name, Input.Name),
    };

    var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
    var principal = new ClaimsPrincipal(identity);
    await HttpContext.SignInAsync(
        CookieAuthenticationDefaults.AuthenticationScheme, principal);

This would log in using cookies, and I could block access to pages, or show/hide stuff based on the claims or whether you are logged in or not.

Now, if I used Identity it would setup razor pages for the login and user management, but I'm trying to do login functionality from a razor component instead, and I can't really find an approach to this. I can try to inject a HttpContextAccessor and use that:

    HttpContext httpContext = HttpContextAccessor.HttpContext;
    await httpContext.SignInAsync(
        CookieAuthenticationDefaults.AuthenticationScheme, principal);

But it throws an exception: Unhandled exception rendering component: Headers are read-only, response has already started. System.InvalidOperationException: Headers are read-only, response has already started.

Most of what I can find seem to do the razor page approach.

Is it not possible to login using a razor component?

2

2 Answers

3
votes

It can be done. Here's the principle:

  1. Create a Login.razor component and inject SignInManager and NavigationManager. Use SignInManager to verify the password using the method CheckPasswordSignInAsync(). Do NOT call PasswordSignInAsync() as it will throw the exception mentioned earlier. Instead, pass the credentials to a credentials-cache in a custom middleware (see next paragraph). Then call NavigationManager.NagigateTo(/login?key=, true ) to execute a full postback, which is required for setting the cookie.

  2. Create a Middleware class (I called it BlazorCookieLoginMiddleware): In there you use a static dictionary to cache login info from the Blazor login component. Also, you intercept the request to "/login?key=" and then perform the actual sign in using the SignInManager. This works because the middleware is executed earlier in the pipeline, when cookies can still be set. The credentials can be retrieved from the static dictionary cache and should immediately be removed from the dict. If the authentication was successful, you simply redirect the user to the app root "/" or where ever you want.

I tested this, it works like a charm. I also added 2FA successfully, but that would be too much for this post.

Here's some code (please note: Edge cases and errors are not handled correctly for the sake of simplicity; just a PoC):

Login.razor:

@page "/login"
@attribute [AllowAnonymous]
@inject SignInManager<ApplicationUser> SignInMgr
@inject UserManager<ApplicationUser> UserMgr
@inject NavigationManager NavMgr

<h3>Login</h3>

    <label for="email">Email:</label>
    <input type="email" @bind="Email" name="email" />
    <label for="password">Password:</label>
    <input type="password" @bind="password" name="password" />
    @if (!string.IsNullOrEmpty(error))
    {
        <div class="alert-danger">
            <p>@error</p>
        </div>
    }
    <button @onclick="LoginClicked">Login</button>

@code {
    public string Email { get; set; }

    private string password;
    private string error;

    private async Task LoginClicked()
    {
        error = null;
        var usr = await UserMgr.FindByEmailAsync(Email);
        if (usr == null)
        {
            error = "User not found";
            return;
        }


        if (await SignInMgr.CanSignInAsync(usr))
        {
            var result = await SignInMgr.CheckPasswordSignInAsync(usr, password, true);
            if (result == Microsoft.AspNetCore.Identity.SignInResult.Success)
            {
                Guid key = Guid.NewGuid();
                BlazorCookieLoginMiddleware.Logins[key] = new LoginInfo { Email = Email, Password = password };
                NavMgr.NavigateTo($"/login?key={key}", true);
            }
            else
            {
                error = "Login failed. Check your password.";
            }
        }
        else
        {
            error = "Your account is blocked";
        }
    }
}

BlazorCookieLoginMiddleware.cs:

    public class LoginInfo
    {
        public string Email { get; set; }
        public string Password { get; set; }
    }

    public class BlazorCookieLoginMiddleware
    {
        public static IDictionary<Guid, LoginInfo> Logins { get; private set; }
            = new ConcurrentDictionary<Guid, LoginInfo>();        


        private readonly RequestDelegate _next;

        public BlazorCookieLoginMiddleware(RequestDelegate next)
        {
            _next = next;
        }

        public async Task Invoke(HttpContext context, SignInManager<ApplicationUser> signInMgr)
        {
            if (context.Request.Path == "/login" && context.Request.Query.ContainsKey("key"))
            {
                var key = Guid.Parse(context.Request.Query["key"]);
                var info = Logins[key];

                var result = await signInMgr.PasswordSignInAsync(info.Email, info.Password, false, lockoutOnFailure: true);
                info.Password = null;
                if (result.Succeeded)
                {
                    Logins.Remove(key);
                    context.Response.Redirect("/");
                    return;
                }
                else if (result.RequiresTwoFactor)
                {
                    //TODO: redirect to 2FA razor component
                    context.Response.Redirect("/loginwith2fa/" + key);
                    return;
                }
                else
                {
                    //TODO: Proper error handling
                    context.Response.Redirect("/loginfailed");
                    return;
                }    
            }     
            else
            {
                await _next.Invoke(context);
            }
        }
    }

and don't forget to add new middleware to Startup.cs:

        public void Configure(IApplicationBuilder app)
        {
            //.....
            app.UseAuthentication();
            app.UseAuthorization();
            
            app.UseMiddleware<BlazorCookieLoginMiddleware>();
            //.....
        }
2
votes

It's not possible to get an HttpContext in blazor component, see this thread : HttpContext is always null

If you want to access to claims on client side you should use OAuth2 or OIDC. You can use Authfix/Blazor-Oidc or sotsera/sotsera.blazor.oidc for exemple

An other approch is to log the user using a classique Identity razor page but not in your blazor page. Then access to user claims on server-side in your web API. But that means all is deployed on the same host.