【Blazor Server】【ASP.NET Core Identity】カスタムユーザーでサインインしたい
はじめに
今回はいつぞやに試した、 ASP.NET Core Identity のカスタムユーザー(プロパティから電話番号などを外すとか)を使って、Blazor Server のページからサインインしてみることにします。
バージョンが古いので今と違っているところもあるかと思いますが、その辺りは GitHub のサンプルを参照して、ということで。。。
Environments
- .NET Core ver.5.0.102
Samples
過去記事
- 【ASP.NET Core】Blazor を試す (Blazor Server)
- 【ASP.NET Core】【Blazor Server】SPA を試す
- 【Blazor Server】【ASP.NET Core】CSS isolation と MapControllers
- 【Blazor Server】ここんぽーねんとで遊びたい
サインイン
SignInManager でサインイン(失敗)
以前試した通り Blazor では DI が使えるので、まずは SignInManager を使ってサインインしてみることにしました。
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; } ...
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>
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 と Password は入力必須です"); return; } var result = await ApplicationUsers!.SignInAsync(Email, Password); if(result) { // サインインに成功したら次のページへ Navigation!.NavigateTo("/Pages/Edit"); return; } AdditionalClassName = "login_failed"; await JSRuntime!.InvokeAsync<object>("Page.showAlert","Email か Password が違います"); } private async Task HandleSigningInFailedAsync(string errorMessage) { AdditionalClassName = "login_failed"; await JSRuntime!.InvokeAsync<object>("Page.showAlert", errorMessage); } } }
実行してみると、「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() ...
この方法はダメなようです。
Controller からサインイン(失敗)
JavaScript などからサインインするのと同じように、 Controller を通してサインインしてみることにしました。
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); } ...
SignInValue.cs
namespace ApprovementWorkflowSample.Applications.Dto { public record SignInValue(string Email, string Password); }
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("アクセス失敗"); 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 か Password が違います"); } ...
例外は発生せず、戻り値としては「true」が得られるのですが、認証済みとは扱われませんでした。
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>
認証処理が完了した後も、「NotAuthorized」の要素が表示されます。
どうもこれは、 Controller を通して認証するのは、 HTTP を使うのに対し、 Blazor Server アプリケーションとサーバーとの接続には SignalR を使っていることが原因のようです。
ClaimsPrincipal 、ClaimsIdentity 、AuthenticationState を使う(OK)
先ほどの記事を参考に、「IHostEnvironmentAuthenticationStateProvider」の追加と「SignIn.razor.cs」の変更を試してみることにします。
Startup.cs
... public void ConfigureServices(IServiceCollection services) { ... services.AddScoped<IHostEnvironmentAuthenticationStateProvider>(sp => (ServerAuthenticationStateProvider) sp.GetRequiredService<AuthenticationStateProvider>() ); ... } ...
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 か Password が違います"); return; } SignInResult loginResult = await SignInManager!.CheckPasswordSignInAsync(user, Password, false); if(loginResult.Succeeded == false) { await HandleSigningInFailedAsync("Email か Password が違います"); return; } if(loginResult.Succeeded) { ClaimsPrincipal principal = await SignInManager.CreateUserPrincipalAsync(user); ClaimsIdentity identity = new ClaimsIdentity(principal.Claims, CookieAuthenticationDefaults.AuthenticationScheme); SignInManager.Context.User = principal; HostAuthentication!.SetAuthenticationState( Task.FromResult(new AuthenticationState(principal))); AuthenticationState authState = await AuthenticationStateProvider!.GetAuthenticationStateAsync(); Navigation!.NavigateTo("/Pages/Edit"); } } ...
ようやくサインインと、サインイン完了後に「認証済み」として扱われるようになりました。
未認証ユーザーの自動リダイレクト
ASP.NET Core では、未認証ユーザーが認証が必要なルートにアクセスした際、自動でログインページに遷移させる仕組みがあります。
では Blazor は?
デフォルトで設定してくれる仕組みは見つけられなかったため、下記の記事を参考にリダイレクトさせることにしました。
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>
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); } } } } }
- 今回端折ってますが、 SignIn.razor で「returnUrl」を受け取る仕組みは必要です