3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

BlazorでシンプルにJWT認証・認可(.Net 8 WebApi利用)

Last updated at Posted at 2024-11-28

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など不要なのでそのまま実行できます
  • マルチスタートアップの設定だけしてください

image.png

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.実行イメージ

image.png

ログインに成功すると、ローカルストレージにtoken(JWT)が記録される。
image.png

もしtoken(JWT)の値をブラウザから強制的に変更すると
image.png

(例外をキャッチし)強制的にtokenを消去しログイン画面へリダイレクト
image.png

6.Blazor側の構成

6.1 Layout/MainLayout.razor

  • ページリクエスト時に必ず認証状態を判定する
  • 未認証ならログインページへリダイレクト

この制御により未認証のまま不正なURLをリクエストしても強制的にリダイレクトします。

ログアウト中にURL「localhost:7009/weather」をGETするとログイン画面へリダイレクト
image.png

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が取得できていることを確認します。
image.png

認証できないユーザー名とパスワードの組み合わせをPOSTし、401エラーになることを確認します。
image.png

ログインへ成功したときに取得したJWTを貼り付け、データ取得用APIが実行できることを確認します。
( Authorization: Bearer {ここにJWTを張り付ける} )
image.png

JWTの一部でも文字列を変更した場合、401エラーになることを確認します。
image.png

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.参考にさせて頂きました

3
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?