1.この記事の目的
Blazor Web APPのデフォルトの認証種類:「個別のアカウント」は多機能なために複雑。
そこで、シンプルに以下の機能のみを実装したサンプルです。
- 認証済みの場合のみページを表示
- 未認証ならログインページへリダイレクト
- 認証されているユーザー名を表示
- 認証されているRoleを表示
- 認証状態によって自動制御
- WebAPIコールもJWTを渡し認可された場合のみデータ取得
Blazor Cookie認証もまとめました
https://qiita.com/masayahak/items/b5d363d4fc4f33e6f208
2.前提条件
- Visual studio 2022 Version 17.12.2
- .Net 8
- UI:Blazor Web App
- Blazor rendermode:InteractiveServerRenderMode
- WebAPI:ASP.NET Core Web API
- 認証・認可にはJWTを利用
- 認証ロジックはここでは単純化し、あえてデータベースを利用しない
2.1 ソリューションの構成
BlazorJwt(ソリューション)
├─ BlazorJwt.Api (WebAPI用のプロジェクト ASP.NET Core Web API)
└─ BlazorJwt.Web (Blazor用のプロジェクト Blazor Web App)
2.2 ソリューションのソース
- 全ソースを添付してます
- Databaseなど不要なのでそのまま実行できます
- マルチスタートアップの設定だけしてください
3.仕様
3.1 WebAPI側の仕様
■ 認証用WebAPI
- ユーザー名とパスワードを受け取り、認証に成功した場合のみJWTを発行
認証に失敗した場合は401エラーを返す。
■ データ取得用WebAPI
- JWTを受け取り認可に成功した場合のみデータを返す
認可に失敗した場合は401エラーを返す。
3.2 Blazor側の仕様
ページリクエストを全て判定し、未認証ならログインページへリダイレクト。
- ログイン用WebAPIを実行し、受け取ったJWTをブラウザのローカルストレージに保管
(併せてAuthenticationStateProviderに認証者情報を通知する) - データ取得用WebAPIをコールする時は保管したJWTを自動的に付与
- ログアウト時はローカルストレージに保管したJWTを削除
(併せてAuthenticationStateProviderの認証者情報を消去する)
4.ローカルストレージ(ProtectedLocalStorage)とは
.Netの提供する標準的な機能です。
Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage.ProtectedLocalStorage
- データはサーバー側で暗号化され、クライアント側に保存される
- データの復号化もサーバー側で行われる
- データの整合性と機密性を保護します
直接ブラウザ側でローカルストレージを変更すると、、、
- 暗号化されたデータが破損または不正な状態になる
- サーバー側で復号化する際に、データの復号化に失敗し、例外が発生する
5.実行イメージ
ログインに成功すると、ローカルストレージにtoken(JWT)が記録される。
もしtoken(JWT)の値をブラウザから強制的に変更すると
(例外をキャッチし)強制的にtokenを消去しログイン画面へリダイレクト
6.Blazor側の構成
6.1 Layout/MainLayout.razor
- ページリクエスト時に必ず認証状態を判定する
- 未認証ならログインページへリダイレクト
この制御により未認証のまま不正なURLをリクエストしても強制的にリダイレクトします。
ログアウト中にURL「localhost:7009/weather」をGETするとログイン画面へリダイレクト
6.2 OnInitializedAsyncのoverride
- 「カスタマイズしたAuthenticationStateProvider」から認証状態を取得する
- 認証状態が未認証なら強制的にログインページへリダイレクト
MainLayout.razor
@inherits LayoutComponentBase
@using Microsoft.AspNetCore.Components.Authorization
@using System.Security.Claims
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@if (isAuthenticated)
{
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<div>@userName (権限 @userRole)</div>
<a href="/logout">LogOut</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
}
@code {
private bool isAuthenticated { get; set; }
private string? userName;
private string? userRole;
// すべてのページリクエスト時に認証状態を確認し、未認証ならログインページへリダイレクト
protected override async Task OnInitializedAsync()
{
// 認証状態を取得
var authState = await ((CustomAuthStateProvider)AuthStateProvider).GetAuthenticationStateAsync();
var user = authState.User;
// 未認証ならログインページへリダイレクト
if (user.Identity?.IsAuthenticated != true)
{
Navigation.NavigateTo("/login");
}
// ----------------- 以下認証済みの場合 -----------------
isAuthenticated = true;
//ユーザー名
userName = user.Identity?.Name;
//ロール
var roleClaim = user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Role);
userRole = roleClaim?.Value ?? "No Role";
}
}
6.3 認証状態のカスタマイズ
Blazorではrazor構文内で「認証済みなら表示」や「認証者の権限により表示」を専用のタグで簡単に記述できます。(Authorizedタグなど)
この恩恵を受けるために、.Netの提供するAuthenticationStateProviderをカスタマイズします。
認可状態を判定するタグのサンプル
<AuthorizeView>
<Authorized>
<p>@context.User.Identity?.Name として認可されています</p>
</Authorized>
<NotAuthorized>
<p>認可されていません</p>
</NotAuthorized>
</AuthorizeView>
権限(Role)に基づき判定するタグのサンプル
<AuthorizeView Roles="Administrator">
<p>権限:Administratorは @ROW_COUNT_ADMIN 件の天気予報を表示します。</p>
</AuthorizeView>
<AuthorizeView Roles="User">
<p>権限:Userは @ROW_COUNT_USER 件の天気予報を表示します。</p>
</AuthorizeView>
6.4 AuthenticationStateProviderのカスタマイズ
A) 認証状態の取得
- AuthenticationStateProviderのGetAuthenticationStateAsyncをoverride
- ローカルストレージからJWTを取得し認証状態を取得
JWT取得時に異常を検知した場合、強制的にローカルストレージを消去します。
B) ログイン時の挙動
- JWTを受け取りローカルストレージへ保管
(併せてAuthenticationStateを更新)
C) ログアウト時の挙動
- ローカルストレージのJWTを破棄
(併せてAuthenticationStateを更新)
CustomAuthStateProvider.cs
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace BlazorJwt.Web.Auth
{
// ============================================================
// Blazor上でのログイン状態をJWT取得結果から判定+格納する
// AuthenticationStateProviderを継承することで、
// Blazor特有の<AuthorizeView>タグなのに対応させる。
// ============================================================
public class CustomAuthStateProvider(ProtectedLocalStorage localStorage)
: Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider
{
private const string LOCAL_STORAGE_KEY = "token";
// ローカルストレージのJWTをもとに認証状態を取得する
public async override Task<AuthenticationState> GetAuthenticationStateAsync()
{
string token = string.Empty;
try
{
var tokenResult = await localStorage.GetAsync<string>(LOCAL_STORAGE_KEY);
token = tokenResult.Value ?? string.Empty;
}
// tokenの書き換えなどなんらかの異常を検知
catch (Exception)
{
try
{
// ローカルストレージを消去
await localStorage.DeleteAsync(LOCAL_STORAGE_KEY);
}
catch { }
}
var identity = string.IsNullOrEmpty(token)
? new ClaimsIdentity()
: GetClaimsIdentity(token);
var user = new ClaimsPrincipal(identity);
return new AuthenticationState(user);
}
private ClaimsIdentity GetClaimsIdentity(string token)
{
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(token);
var claims = jwtToken.Claims;
return new ClaimsIdentity(claims, "jwt", ClaimTypes.Name, ClaimTypes.Role);
}
// ログイン成功時にJWTを受け取り、ローカルストレージへJWTを書き込み認証状態を反映する。
public async Task MarkUserAsAuthenticated(string token)
{
await localStorage.SetAsync(LOCAL_STORAGE_KEY, token);
var identity = GetClaimsIdentity(token);
var user = new ClaimsPrincipal(identity);
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(user)));
}
// ログアウト時にローカルストレージのJWTを削除し、認証状態を反映する。
public async Task MarkUserAsLoggedOut()
{
await localStorage.DeleteAsync(LOCAL_STORAGE_KEY);
// 認証者なしにする
var identity = new ClaimsIdentity();
var user = new ClaimsPrincipal(identity);
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(user)));
}
}
}
6.5 Weather.razor(データ取得ページ)
A) カスタマイズされたHttpClient(ApiClient)を利用する
Weather.razor
@page "/weather"
@using BlazorJwt.Api.Models
@rendermode InteractiveServer
@inject ApiClient ApiClient
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
@* 認可状態を判定するサンプル *@
<AuthorizeView>
<Authorized>
<p>@context.User.Identity?.Name として認可されています</p>
</Authorized>
<NotAuthorized>
<p>認可されていません</p>
</NotAuthorized>
</AuthorizeView>
@* 認証ユーザーロールに基づき判定するサンプル *@
<AuthorizeView Roles="Administrator">
<p>権限:Administratorは @ROW_COUNT_ADMIN 件の天気予報を表示します。</p>
</AuthorizeView>
<AuthorizeView Roles="User">
<p>権限:Userは @ROW_COUNT_USER 件の天気予報を表示します。</p>
</AuthorizeView>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherModel[]? forecasts;
private const int ROW_COUNT_ADMIN = 100;
private const int ROW_COUNT_USER = 5;
protected override async Task OnInitializedAsync()
{
// 認証状態を取得
var authState = await ((CustomAuthStateProvider)AuthStateProvider).GetAuthenticationStateAsync();
// ロール
var isAdmin = authState.User.IsInRole("Administrator");
var isUser = authState.User.IsInRole("User");
var rowCount = isAdmin ? ROW_COUNT_ADMIN : isUser ? ROW_COUNT_USER : 0;
// WebAPIコール
forecasts = await ApiClient.PostAsync<WeatherModel[], int>("/api/weather/all", rowCount);
}
}
6.6 ApiClient(HttpClientのカスタマイズ)
A) httpClient通信の拡張
- httpClient.GetFromJsonAsyncなどGET/POST/PUT/DELETEを全てラップ
B) Authorizationヘッダーを自動付与
- ローカルストレージからJWTを取り出し、Authorizationヘッダーへ付与
ApiClient.cs
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
using Newtonsoft.Json;
using System.Net.Http.Headers;
namespace BlazorJwt.Web.Auth
{
// ============================================================
// API CALL用
// ------------------------------------------------------------
// httpClient.GetFromJsonAsyncなどをラップし
// 自動的にローカルストレージに保存したJWTを付与する
// ============================================================
public class ApiClient(
HttpClient httpClient,
ProtectedLocalStorage localStorage
) : System.Net.Http.HttpClient
{
// JWTを付与
private async Task SetAuthorizeHeader()
{
var token = (await localStorage.GetAsync<string>("token")).Value;
if (token is null) return;
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
// ------------------ 以下、GET/POST/PUT/DELETE -----------------------
public async Task<T?> GetFromJsonAsync<T>(string path)
{
await SetAuthorizeHeader();
return await httpClient.GetFromJsonAsync<T>(path);
}
public async Task<T1?> PostAsync<T1, T2>(string path, T2 postModel)
{
await SetAuthorizeHeader();
var res = await httpClient.PostAsJsonAsync(path, postModel);
if (res != null && res.IsSuccessStatusCode)
{
var content = await res.Content.ReadAsStringAsync();
if (typeof(T1) == typeof(string))
{
// 直接文字列として返す
return (T1)(object)content;
}
else
{
// 指定された型にデシリアライズ
return JsonConvert.DeserializeObject<T1>(content);
}
}
return default;
}
public async Task<T1?> PutAsync<T1, T2>(string path, T2 postModel)
{
await SetAuthorizeHeader();
var res = await httpClient.PutAsJsonAsync(path, postModel);
if (res != null && res.IsSuccessStatusCode)
{
var content = await res.Content.ReadAsStringAsync();
if (typeof(T1) == typeof(string))
{
// 直接文字列として返す
return (T1)(object)content;
}
else
{
// 指定された型にデシリアライズ
return JsonConvert.DeserializeObject<T1>(content);
}
}
return default;
}
public async Task<T?> DeleteAsync<T>(string path)
{
await SetAuthorizeHeader();
return await httpClient.DeleteFromJsonAsync<T>(path);
}
}
}
7.WebAPI
7.1 Login(JWT発行ロジック)
Blazor側での利用しやすさを考えて、Claimsには
・ClaimTypes.Name
・ClaimTypes.Role
の2つを含める。
AuthController.cs
using BlazorJwt.Api.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace BlazorJwt.Api.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class AuthController(IConfiguration configuration) : ControllerBase
{
[HttpPost("login")]
public async Task<ActionResult<string>> Login([FromBody] LoginModel loginModel)
{
var token = GenerateJwtToken(loginModel.Username, loginModel.Password);
await Task.CompletedTask;
if (token != string.Empty)
{
return Ok(token);
}
else
{
return Unauthorized(string.Empty);
}
}
// Logoutは不要
// クライアント側で保持していたJWTをクライアント側で削除し
// クライアント側のステータスのみ更新すればログアウトは成功
// サーバー側のログアウト処理はない
// ========================================================
// JWT TOKEN GENERATOR
// ========================================================
private string GenerateJwtToken(string userName, string password)
{
List<Claim>? claims = GenerateClaims(userName, password);
if (claims is null) { return string.Empty; }
var jwtSettings = configuration.GetSection("JwtSettings");
var secretKey = jwtSettings["SecretKey"] ?? string.Empty;
var expiryMinutes = int.Parse(jwtSettings["ExpiryInMinutes"] ?? string.Empty);
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
claims: claims,
expires: DateTime.UtcNow.AddMinutes(expiryMinutes),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private List<Claim>? GenerateClaims(string userName, string password)
{
// 認証チェックはダミー
if (userName.StartsWith("admin@") && password == "admin")
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, userName),
new Claim(ClaimTypes.Role, "Administrator")
};
return claims;
}
else if (userName.StartsWith("user@") && password == "user")
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, userName),
new Claim(ClaimTypes.Role, "User")
};
return claims;
}
return null;
}
}
}
7.2 WebAPIデータ取得
Authorizeアノテーションにて認可判定
WeatherController.cs
using BlazorJwt.Api.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace BlazorJwt.Api.Controllers
{
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class WeatherController() : ControllerBase
{
[HttpPost("all")]
public async Task<ActionResult<WeatherModel[]>> All([FromBody] int ListCount)
{
var startDate = DateOnly.FromDateTime(DateTime.Now);
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
WeatherModel[] weathers = Enumerable.Range(1, ListCount).Select(index => new WeatherModel
{
Date = startDate.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = summaries[Random.Shared.Next(summaries.Length)]
}).ToArray();
await Task.CompletedTask;
return Ok(weathers);
}
}
}
7.3 WebAPI動作確認用HTTPファイル
BlazorJwt.Api.http
@BlazorJwt.Api_HostAddress = https://localhost:7385/api
// ==================== Weather ===================
POST {{BlazorJwt.Api_HostAddress}}/weather/all
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiYWRtaW5AdGVzdC5jb20iLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOiJhZG1pbiIsImV4cCI6MTczMjc2MDI2OH0.IbWGMpZzCEgdyMvTBocBs1kf3CGNG6ccedddrOY08aI
100
###
// ==================== ログイン ===================
// ------ admin -------------------------------
POST {{BlazorJwt.Api_HostAddress}}/Auth/login
Content-Type: application/json
{
"Username": "admin@test.com",
"Password": "admin"
}
###
// ------ user -------------------------------
POST {{BlazorJwt.Api_HostAddress}}/Auth/login
Content-Type: application/json
{
"Username": "user@test.com",
"Password": "user"
}
###
// ------ ログイン失敗 -------------------------------
POST {{BlazorJwt.Api_HostAddress}}/Auth/login
Content-Type: application/json
{
"Username": "user@test.com",
"Password": "xxxxx"
}
###
7.4 WebAPI動作確認
adminでのログインを要求し、JWTが取得できていることを確認します。
認証できないユーザー名とパスワードの組み合わせをPOSTし、401エラーになることを確認します。
ログインへ成功したときに取得したJWTを貼り付け、データ取得用APIが実行できることを確認します。
( Authorization: Bearer {ここにJWTを張り付ける} )
JWTの一部でも文字列を変更した場合、401エラーになることを確認します。
8.参考(Blazor側の ログイン/ログアウト)
8.1 Login.razor
@page "/login"
@using BlazorJwt.Web.Auth
@using BlazorJwt.Web.Components.Layout
@using BlazorJwt.Api.Models
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Identity.Data
@layout EmptyLayout
@inject ApiClient ApiClient
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthStateProvider
<div class="p-5">
<div class="col-md-4">
<div class="form-group">
<p>管理者権限でログイン admin@test.com / admin</p>
<p>ユーザー権限でログイン user@test.com / user</p>
</div>
<EditForm Model="@loginModel" FormName="Login" OnValidSubmit="LoginAsync">
<div class="form-group">
<label for="username">Username</label>
<InputText id="username" class="form-control" @bind-Value="loginModel.Username" />
</div>
<div class="form-group">
<label for="password">Password</label>
<InputText id="password" class="form-control" type="password" @bind-Value="loginModel.Password" />
</div>
<button type="submit" class="btn btn-primary mt-3">Login</button>
</EditForm>
</div>
</div>
@code {
private LoginModel loginModel = new LoginModel();
private async Task LoginAsync()
{
// ログイン用WebAPI呼び出し
var token = await ApiClient.PostAsync<string, LoginModel>("/api/auth/login", loginModel);
if (token != null)
{
await ((CustomAuthStateProvider)AuthStateProvider).MarkUserAsAuthenticated(token);
Navigation.NavigateTo("/");
}
else
{
// ログイン失敗
}
}
}
8.2 Logout.razor
@page "/logout"
@using BlazorJwt.Web.Auth
@using Microsoft.AspNetCore.Components.Authorization
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@code {
protected override async Task OnInitializedAsync()
{
await ((CustomAuthStateProvider)AuthStateProvider).MarkUserAsLoggedOut();
Navigation.NavigateTo("/login");
}
}
9.参考にさせて頂きました