1.この記事の目的
- 以前 Blazor + JWT認証 をまとめました
https://qiita.com/masayahak/items/e54ff025d29b3caf85bc - こちらはもっとシンプルに Blazor + Cookie認証 です
2.前提条件
- Visual studio 2022 Version 17.12.2
- .Net 8
- UI:Blazor Web App
- Blazor rendermode: Static Server + InteractiveServerRenderMode
- WebAPIを利用しない
- 認証・認可にはCookieを利用
- 認証ロジックはここでは単純化し、あえてデータベースを利用しない
3.ソリューションのソース
4.求めている挙動
4.1 ログインの流れ
ログイン成功
↓
- Cookieが作成される
- Blazorのフォーム上でユーザー名や権限(ロール)を取得できる
4.2 ログインユーザーのみ表示できるページの挙動
weatherページには @attribute [Authorize]
を指定してる。
@page "/weather"
@attribute [Authorize]
https://localhost:7022/weather
をGETすると
ログインページへリダイレクトされる
5.認証方法
5.1 利用する認証スキーム
- AuthenticationHttpContextExtensions.SignInAsync
( ASP.Net Coreの既定の認証スキーム )
https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.aspnetcore.authentication.authenticationhttpcontextextensions.signinasync?view=aspnetcore-9.0
5.2 SignInAsyncを呼び出す認証サービスの作成
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
namespace BlazorCookie.Services
{
public class AuthenticationService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public AuthenticationService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
}
public async Task<bool> LoginAsync(LoginUser loginUser)
{
var claims = new List<Claim>();
// 本来の認証判定はもっと複雑だが、ここではテスト用にシンプルに認証
if (loginUser.UserName == "admin@test.com" && loginUser.Password == "test")
{
claims.Add(new Claim(ClaimTypes.Name, loginUser.UserName));
claims.Add(new Claim(ClaimTypes.Role, "Administrator"));
}
else if (loginUser.UserName == "user@test.com" && loginUser.Password == "test")
{
claims.Add(new Claim(ClaimTypes.Name, loginUser.UserName));
claims.Add(new Claim(ClaimTypes.Role, "User"));
}
else
{
return false;
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null)
throw new InvalidOperationException("HttpContext is not available.");
await httpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity));
return true;
}
public async Task LogoutAsync()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null)
throw new InvalidOperationException("HttpContext is not available.");
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
}
public class LoginUser
{
public string UserName { get; set; } = "";
public string Password { get; set; } = "";
}
}
5.3 ログインページ
@page "/login"
@inject NavigationManager NavigationManager
@inject AuthenticationService AuthService
@attribute [AllowAnonymous]
@* rendermodeは Static Server 固定 *@
<h3>Login</h3>
<p>admin@test.com / test</p>
<p>user@test.com / test</p>
<EditForm Model="@loginUser" FormName="login-form" OnValidSubmit="LoginAsync">
<div>
<div class="mb-3">
<label class="form-label">ユーザー名</label>
<InputText @bind-Value="loginUser.UserName" class="form-control"></InputText>
</div>
<div class="mb-3">
<label class="form-label">パスワード</label>
<InputText @bind-Value="loginUser.Password" class="form-control"></InputText>
</div>
</div>
<button type="submit" class="btn btn-primary">ログイン</button>
</EditForm>
@code {
[SupplyParameterFromForm]
private LoginUser loginUser { get; set; } = new();
private async Task LoginAsync()
{
var success = await AuthService.LoginAsync(loginUser);
if (success)
{
NavigationManager.NavigateTo("", forceLoad: true);
}
}
}
5.4 ログアウトページ
@page "/logout"
@inject AuthenticationService AuthService
@inject NavigationManager NavigationManager
@code {
protected override async Task OnInitializedAsync()
{
await AuthService.LogoutAsync();
NavigationManager.NavigateTo("", forceLoad: true);
}
}
5.5 認証状態を判定するサンプル(Homeページ)
@page "/"
@attribute [AllowAnonymous]
<PageTitle>Home</PageTitle>
<h1>Home</h1>
<div class="mb-3">
<AuthorizeView Roles="Administrator">
<div class="mb-3">
あなたのロールは "Administrator" です。
</div>
</AuthorizeView>
<AuthorizeView Roles="User">
<div class="mb-3">
あなたのロールは "User" です。
</div>
</AuthorizeView>
<AuthorizeView>
<Authorized>
<p>ログイン済み: @context.User.Identity!.Name</p>
<a href="/logout">LogOut</a>
</Authorized>
<NotAuthorized>
<div class="mb-3">
<p>ログインしてください。</p>
<a href="/login">Login</a>
</div>
<div class="mb-3">
<p>テスト用:InteractiveServerのフォームではEXCEPTIONが発生する</p>
<a href="/login-interactive">Login - InteracticeServer</a>
</div>
</NotAuthorized>
</AuthorizeView>
</div>
6.注意点
- Loginページの rendermode は Static Server 一択
- InteractiveServer や WebAssermbly は利用できない
色々試して納得できたので共有します。
6.1 まずはStatic ServerでLoginした場合の挙動
(1) loginページにユーザー名+パスワードを入力しPOSTする
(2) POSTの応答ヘッダーに
set-cookie:.AspNetCore.Cookies=CfDJ8ODTl-is4Y5Im-eN4-9_d.....がある。
(3) ログイン成功後、homeへのリダイレクト要求ヘッダーには当然このCookieが含まれる
ここまでの挙動は目的通り
6.2 InteraciveServerでLoginした場合の挙動
(1) loginページにユーザー名+パスワードを入力しSUBMITしても、POSTは発生しない
( InteractiveServer であれば SignalRのバイナリ通信だけが発生する )
(3) エラーメッセージを要約すると
- ログインページのGETはすでにレスポンスした
- ログインボタンのSUBMITはSignalRでの通信でPOSTやGETではない
- SignalRの通信内で、GETの応答ヘッダーに含めるCookieを発行するのはダメ!
言われてみればその通りなので納得です。