DEV Community

Masui Masanori
Masui Masanori

Posted on

【Blazor Server】SignIn with custom user

Intro

This time, I will try signing in from Blazor (Server) applications.

Environments

  • .NET Core ver.5.0.102

Samples

My posts about Blazor Server

SignIn

Signing In with SignInManager(Failed)

Because Blazor can use DI, I tried SignInManager to sign in first.

ApplicationUserService.cs

...
    public async Task<bool> SignInAsync(string email, string password)
    {
        var target = await applicationUsers.GetByEmailAsync(email);
        if (target == null)
        {
            return false;
        }
        var result = await signInManager.PasswordSignInAsync(target, password, false, false);
        return result.Succeeded;
    }
...
Enter fullscreen mode Exit fullscreen mode

SignIn.razor

@page "/Pages/SignIn"
<div id="background">
    <div id="sign_in_frame">
        <h1>Sign In</h1>
        <div class="sign_in_input_container">
            <input type="text" @bind="Email" class="sign_in_input @AdditionalClassName">
        </div>
        <div class="sign_in_input_container">
            <input type="password" @bind="Password" class="sign_in_input @AdditionalClassName">
        </div>
        <div id="sign_in_controller_container">
            <button @onclick="StartSigningIn">Sign In</button>
        </div>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

SignIn.razor.cs

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ApprovementWorkflowSample.Applications;

namespace ApprovementWorkflowSample.Views
{
    public partial class SignIn
    {
        [Inject]
        public IJSRuntime? JSRuntime { get; init; }
        [Inject]
        public NavigationManager? Navigation { get; init; }
        [Inject]
        public IApplicationUserService? ApplicationUsers{get; init; }

        [Parameter]
        public string Email { get; set; } = "";
        [Parameter]
        public string Password { get; set; } = "";
        [Parameter]
        public string AdditionalClassName { get; set; } = "";

        public async Task StartSigningIn()
        {
            if(string.IsNullOrEmpty(Email) ||
                string.IsNullOrEmpty(Password))
            {
                await HandleSigningInFailedAsync("Email and Password are required");
                return;
            }
            var result = await ApplicationUsers!.SignInAsync(Email, Password);
            if(result)
            {
                Console.WriteLine("Navi");
                Navigation!.NavigateTo("/Pages/Edit");
                return;
            }
            AdditionalClassName = "login_failed";
            await JSRuntime!.InvokeAsync<object>("Page.showAlert","Email or Password are not match");
        }
        private async Task HandleSigningInFailedAsync(string errorMessage)
        {
            AdditionalClassName = "login_failed";
            await JSRuntime!.InvokeAsync<object>("Page.showAlert", errorMessage);   
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

I got an exception when I tried calling "SignInAsync".

Unhandled exception rendering component: Headers are read-only, response has already started. System.InvalidOperationException: Headers are read-only, 
response has already started.
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpHeaders.ThrowHeadersReadOnlyException()
...
Enter fullscreen mode Exit fullscreen mode

Signing in from Controller(Failed)

So I tried signing in from Controller as same as signing in from JavaScript codes.

UserController.cs

...
        [HttpPost]
        [Route("Users/SignIn")]
        public async ValueTask<bool> SignIn([FromBody]SignInValue value)
        {
            if(string.IsNullOrEmpty(value.Email) ||
                string.IsNullOrEmpty(value.Password))
            {
                return false;
            }
            return await users.SignInAsync(value.Email, value.Password);
        }
...
Enter fullscreen mode Exit fullscreen mode

SignInValue.cs

namespace ApprovementWorkflowSample.Applications.Dto
{
    public record SignInValue(string Email, string Password);
}
Enter fullscreen mode Exit fullscreen mode

SignIn.razor.cs

using System.IO;
using System.Text;
using System.Net.Http;
using System.Threading.Tasks;
using ApprovementWorkflowSample.Applications.Dto;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using Microsoft.Extensions.Configuration;
using ApprovementWorkflowSample.Applications;
using Newtonsoft.Json;

namespace ApprovementWorkflowSample.Views
{
    public partial class SignIn
    {
        [Inject]
        public IJSRuntime? JSRuntime { get; init; }
        [Inject]
        public IHttpClientFactory? HttpClients { get; init; }
        [Inject]
        public IConfiguration? Configuration { get; init; }
        [Inject]
        public NavigationManager? Navigation { get; init; }     
...
        public async Task StartSigningIn()
        {
...
            var httpClient = HttpClients.CreateClient();
            var signInValue = new SignInValue(Email, Password);
            var context = new StringContent(JsonConvert.SerializeObject(signInValue), Encoding.UTF8, "application/json");
            var response = await httpClient.PostAsync(Path.Combine(Configuration!["BaseUrl"], "Users/SignIn"), context);
            if(response.IsSuccessStatusCode == false)
            {
                await HandleSigningInFailedAsync("Failed access");
                return;
            }
            string resultText = await response.Content.ReadAsStringAsync();
            bool.TryParse(resultText, out var result);
            if(result)
            {
                Navigation!.NavigateTo("/Pages/Edit");
                return;
            }
            AdditionalClassName = "login_failed";
            await HandleSigningInFailedAsync("Email or Password are not match");
        }
...
Enter fullscreen mode Exit fullscreen mode

I didn't get any exceptions and I could get "true" as results.
But the status didn't be treated as "authenticated".

EditWorkflow.razor

@page "/Pages/Edit"
@attribute [Authorize]

<CascadingAuthenticationState>
    <AuthorizeView>
        <Authorized>
            <h1>Hello, @context.User.Identity!.Name!</h1>
            <p>You can only see this content if you're authorized.</p>
        </Authorized>
        <NotAuthorized>
            <h1>Authentication Failure!</h1>
            <p>You're not signed in.</p>
        </NotAuthorized>
    </AuthorizeView>
</CascadingAuthenticationState>
Enter fullscreen mode Exit fullscreen mode

After authenticated, this page still showed "NotAuthorized" elements.

Because it was authenticated through HTTP connection. But Blazor Server application use SignalR.
So it couldn't get infomations about authentication.

Use ClaimsPrincipal, ClaimsIdentity and AuthenticationState (OK)

According to the article of Stack Overflow, I add "IHostEnvironmentAuthenticationStateProvider" and changed "SignIn.razor.cs".

Startup.cs

...
        public void ConfigureServices(IServiceCollection services)
        {
...
            services.AddScoped<IHostEnvironmentAuthenticationStateProvider>(sp =>
                (ServerAuthenticationStateProvider) sp.GetRequiredService<AuthenticationStateProvider>()
            );
...
        }
...
Enter fullscreen mode Exit fullscreen mode

SignIn.razor.cs

using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ApprovementWorkflowSample.Applications;
using Microsoft.AspNetCore.Identity;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Authentication.Cookies;

namespace ApprovementWorkflowSample.Views
{
    public partial class SignIn
    {
        [Inject]
        public IJSRuntime? JSRuntime { get; init; }
        [Inject]
        public NavigationManager? Navigation { get; init; }
        [Inject]
        public IApplicationUserService? ApplicationUsers{get; init; }
        [Inject]
        public SignInManager<ApplicationUser>? SignInManager { get; init; }
        [Inject]
        public IHostEnvironmentAuthenticationStateProvider? HostAuthentication { get; init; }
        [Inject]
        public AuthenticationStateProvider? AuthenticationStateProvider{get; init; }
...
        public async Task StartSigningIn()
        {
...
            ApplicationUser? user = await ApplicationUsers!.GetUserByEmailAsync(Email);
            if(user == null)
            {
                await HandleSigningInFailedAsync("Email or Password are not match");
                return;
            }
            SignInResult loginResult = await SignInManager!.CheckPasswordSignInAsync(user, Password, false);
            if(loginResult.Succeeded == false)
            {
                await HandleSigningInFailedAsync("Email or Password are not match");
                return;
            }
            if(loginResult.Succeeded)
            {
                ClaimsPrincipal principal = await SignInManager.CreateUserPrincipalAsync(user);
                SignInManager.Context.User = principal;
                HostAuthentication!.SetAuthenticationState(
                    Task.FromResult(new AuthenticationState(principal)));

                // If you don't need doing anything without moving to next page, you can remove this.
                AuthenticationState authState = await AuthenticationStateProvider!.GetAuthenticationStateAsync();

                Navigation!.NavigateTo("/Pages/Edit");
            }
        }
...
Enter fullscreen mode Exit fullscreen mode

Finally, I could sign in and the status was also treated as "authenticated".
Alt Text

Auto redirect for non-authenticated user

ASP.NET Core MVC can redirect automatically when the user isn't authenticated.

How about Blazor?
This time, I decided following this posts.

App.razor

@using Shared
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
            <NotAuthorized>
                <RedirectToSignIn></RedirectToSignIn>
            </NotAuthorized>
        </AuthorizeRouteView>
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(MainLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>
</CascadingAuthenticationState>
Enter fullscreen mode Exit fullscreen mode

RedirectToSignIn.razor.cs

using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;

namespace ApprovementWorkflowSample.Views
{
    public partial class RedirectToSignIn
    {
        [CascadingParameter]
        private Task<AuthenticationState>? AuthenticationStateTask { get; init; }
        [Inject]
        public NavigationManager? Navigation { get; init; }
        protected override async Task OnInitializedAsync()
        {
            var authenticationState = await AuthenticationStateTask!;

            if (authenticationState?.User?.Identity is null || !authenticationState.User.Identity.IsAuthenticated)
            {
                var returnUrl = Navigation!.ToBaseRelativePath(Navigation.Uri);
                if (string.IsNullOrWhiteSpace(returnUrl))
                {
                    Navigation.NavigateTo("Pages/SignIn", true);
                }
                else
                {
                    Navigation.NavigateTo($"Pages/SignIn?returnUrl={returnUrl}", true);
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Resources

Discussion (0)