概要
Blazorにおけるログイン認証の手法の個人的なまとめです。
下記のようなログイン画面を実装する際のメモです。
前提
.NET Core SDK 3.1.100
Microsoft.AspNetCore.Blazor.Templates::3.1.0-preview4.19579.2
Visual Studio 2019 16.4.0
その他、記事内で使用しているパッケージ
Microsoft.AspNetCore.Components.Authorization 3.1.0
Blazored.LocalStorage 2.1.1
FirebaseAuthentication.net 3.4.0
MatBlazor 2.0.0
WebAssembly版(Client版)での話となります。
SPAにおける認証の実装
過去VueやNuxtで作成したSPAは下記のような流れで実装を行いました。
(どこまで一般的か不明ですが…)
- ユーザIDとパスワードで認証
- CognitoやFirebaseAuthenticationのようなサービスや自作API等でユーザを認証する
- 認証が通るとトークンが発行されるのでローカルストレージと状態管理ストア(Vuex,Reduxなど)に保存
- 各WEB-APIを呼び出す時にトークンをヘッダ(bearerがよく使われる)に入れることでAPIが認可されて実行可能となる
- トークンが期限切れ等の場合、ログイン画面に戻る
(リフレッシュトークンによる自動認証場合は割愛)
今回も、上記のような考え方で実装を行います。
Blazorにおける認証
準備
BlazorのWebAssembly版(Client版)のテンプレートからプロジェクトを作成後、
認証関係のモジュールのインストールが必要です。
Nugetから下記のモジュールをインストールしてください。
Microsoft.AspNetCore.Components.Authorization
インストール後、_imports.razorに下記の参照を追加
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Authorization
また、Program.csに下記を追記します。
builder.Services.AddOptions();
builder.Services.AddAuthorizationCore();
UI要素
まずは、UI側に関する説明を行います。
AuthorizeView
ページ内の特定の要素だけ認証時に表示させたい内容には、下記のようにAuthorizeViewタグを付与することで実現できます。
@context.User.Identityでユーザ名等を使用することが可能です。
<AuthorizeView>
<Authorized>
<h1>Hello, @context.User.Identity.Name!</h1>
<p>You can only see this content if you're authenticated.</p>
</Authorized>
</AuthorizeView>
Authorize属性
ページ全体を認証時にのみ表示を行いたい場合には、[Authorize]属性を付与します。
なお、未認証時にログイン画面にリダイレクトさせたい場合の方法は下記で紹介しています。
https://qiita.com/nobu17/items/d43b18b8d42e7d0b4535
@page "/counter"
@attribute [Authorize]
<h1>Counter</h1>
<p>Current count: @CurrentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
AuthenticationStateProviderによる認証
先ほど、紹介したUI要素が認証及び未認証といった状態を認識するための仕組みとして
AuthenticationStateProviderといった抽象クラス提供されています。
このクラスを実装することで認証の仕組みが実現できます。
namespace Microsoft.AspNetCore.Components.Authorization
{
public abstract class AuthenticationStateProvider
{
protected AuthenticationStateProvider();
public event AuthenticationStateChangedHandler AuthenticationStateChanged;
public abstract Task<AuthenticationState> GetAuthenticationStateAsync();
protected void NotifyAuthenticationStateChanged(Task<AuthenticationState> task);
}
}
GetAuthenticationStateAsync
認証が必要となったタイミングでこのメソッドが呼び出されます。
戻り値に返されるAuthenticationStateの値により認証されたかどうかを判断します。
NotifyAuthenticationStateChanged
ログイン/ログアウト等で認証状態が変化した場合にこのメソッドを呼び出すことで状態変更がAuthorizeViewなどに通知されます。
実装例
AuthenticationStateProvider
AuthenticationStateProviderを継承した認証プロバイダを作成します。
前述のとおり、ローカルストレージに認証情報を保存するために、Blazored.LocalStorageを使用します。
Nugetからインストールしてください。
インストール後。Program.csにbuilder.Services.AddBlazoredLocalStorage();を追記します。。
public class SpaAuthticateProvider : AuthenticationStateProvider
{
private readonly HttpClient _httpClient;
private readonly ILocalStorageService _localStorage;
public SpaAuthticateProvider(HttpClient httpClient, ILocalStorageService localStorage)
{
_httpClient = httpClient;
_localStorage = localStorage;
}
public async override Task<AuthenticationState> GetAuthenticationStateAsync()
{
// ローカルストレージからトークンとユーザ名を取得
var savedToken = await _localStorage.GetItemAsync<string>("authToken");
var userID = await _localStorage.GetItemAsync<string>("userID");
// トークンのチェックを入れる場合ここで一度だけ実施
// トークンが見つからい場合は未ログイン
if (string.IsNullOrWhiteSpace(savedToken))
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
// HTTPの認証用のトークンを設定
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", savedToken);
// 認証情報を返す
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, userID) }, "apiauth")));
}
public async Task MarkUserAsAuthenticated(string userID, string authToken)
{
// ローカルストレージに認証情報を保持して変更通知を行う
await _localStorage.SetItemAsync("userID", userID);
await _localStorage.SetItemAsync("authToken", authToken);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public async Task MarkUserAsLoggedOut()
{
// ローカルストレージの認証情報を削除して変更通知を行う
await _localStorage.RemoveItemAsync("userID");
await _localStorage.RemoveItemAsync("authToken");
if (_httpClient.DefaultRequestHeaders.Authorization != null)
{
_httpClient.DefaultRequestHeaders.Authorization = null;
}
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
}
ILocalStorageService及びHttpClientをコンストラクタからDIします。
HttpClientをDIする理由は、認証用のトークンをヘッダに設定するためです。
(HttpClientはインスタンスが共有される。)
ローカルストレージに認証情報を保持することで画面をリロードしてもトークンが期限切れになるまで再度ログインする必要がなくなります。
また、外部のサービスから認証変更を通知するためのメソッドを追加します。
認証サービスとの連携サンプル
認証するWEBAPIと連携してAuthenticationStateProviderに認証場を渡すクラスを作成します。
まずは下記のようなログイン用のインタフェースとモデルを定義します。
public interface IAuthService
{
Task<LoginResult> LoginAsync(LoginModel loginModel);
Task LogoutAsync();
}
public class LoginModel
{
public string UserID { get; set; }
public string Password { get; set; }
}
public class LoginResult
{
public bool IsSuccessful { get; set; }
public Exception Error { get; set; }
public string IDToken { get; set; }
}
実際のAPIをコールせずにダミーの応答を返すような場合は下記となります。
public class DummyAuthService : IAuthService
{
private readonly AuthenticationStateProvider _authenticationStateProvider;
public DummyAuthService(AuthenticationStateProvider authenticationStateProvider)
{
_authenticationStateProvider = authenticationStateProvider;
}
public async Task<LoginResult> LoginAsync(LoginModel loginModel)
{
// 3秒待機させて本当の応答のように見せる
await Task.Delay(3000);
if (loginModel.UserID == "demo" && loginModel.Password == "demo")
{
var res = new LoginResult()
{
IsSuccessful = true,
IDToken = "hoge"
};
await ((SPAAuthticateProvider)_authenticationStateProvider).MarkUserAsAuthenticated(loginModel.UserID, res.IDToken);
return res;
}
else
{
return new LoginResult()
{
IsSuccessful = false,
Error = new AuthenticationException("NotAuthrized")
};
}
}
public async Task LogoutAsync()
{
await ((SPAAuthticateProvider)_authenticationStateProvider).MarkUserAsLoggedOut();
}
依存性注入
最後にStartup.csにおいてクラスの指定を行います。
AddAuthorizationCoreメソッドも追加してください。
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<AuthenticationStateProvider, SpaAuthticateProvider>();
services.AddScoped<ILocalStorageService, LocalStorageService>();
services.AddScoped<IAuthService, DummyAuthService>();
services.AddScoped<ILocalStorageAuth, LocalStorageAuthticateProvider>();
services.AddAuthorizationCore();
}
}
ログイン画面からの呼び出し
作成したDummyAuthServiceをログイン画面から呼び出すことで認証処理を行います。
下記に例を示します。
(UIはMatBlazorを使用しています。)
@page "/login"
@inherits LoginViewModel
// LoadingScreenはスピナーの表示を行う自作モジュール
<LoadingScreen IsLoading="@IsLoading" />
<div class="mat-layout-grid">
<div class="mat-layout-grid-inner">
<div class="mat-layout-grid-cell mat-layout-grid-cell-span-12 mat-layout-grid-align-center">
<h3>Login</h3>
</div>
<EditForm Model="@LoginData" OnValidSubmit="SubmitAsync" class="mat-layout-grid-cell mat-layout-grid-cell-span-12">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="mat-layout-grid-inner">
<div class="mat-layout-grid-cell mat-layout-grid-cell-span-12">
<MatTextField FullWidth="true" Label="UserID" @bind-Value="@LoginData.UserID"></MatTextField>
</div>
<div class="mat-layout-grid-cell mat-layout-grid-cell-span-12">
<MatTextField FullWidth="true" Label="Password" @bind-Value="@LoginData.Password" Type="password"></MatTextField>
</div>
<div class="mat-layout-grid-cell mat-layout-grid-cell-span-12 mat-layout-grid-align-right">
<MatButton Label="Login" Outlined="true" Type="submit"></MatButton>
</div>
</div>
<p style="color:red;">@ErrorMessage</p>
</EditForm>
</div>
</div>
public class LoginViewModel : ComponentBase
{
[Inject]
protected NavigationManager NavigationManager { get; set; }
[Inject]
protected IAuthService AuthService { get; set; }
public LoginData LoginData { get; set; } = new LoginData();
public string ErrorMessage { get; set; }
public bool IsLoading { get; set; } = false;
public async Task SubmitAsync()
{
IsLoading = true;
var model = new LoginModel() { UserID = LoginData.UserID, Password = LoginData.Password };
var result = await AuthService.LoginAsync(model);
if (result.IsSuccessful)
{
NavigationManager.NavigateTo("/");
}
else
{
ErrorMessage = "ログインに失敗しました。";
}
IsLoading = false;
}
}
public class LoginData
{
[Required(ErrorMessage = "ユーザIDを入力してください。")]
[StringLength(32, ErrorMessage = "ユーザIDが長すぎます。")]
public string UserID { get; set; }
[Required(ErrorMessage = "パスワードを入力してください。")]
[StringLength(32, ErrorMessage = "パスワードが長すぎます。")]
public string Password { get; set; }
}
FirebaseAuthenticationを使用した場合の例
先ほどはダミーの認証サービスを使用しましたが、FirebaseAuthenticationを使用した場合の例になります。
予め、FirebaseAuthentication.netをNugetからインストールしてください。
認証APIを呼び出して、結果からトークンを取得しています。
Firebase自体の細かい仕組みなどは、下記などが参考になります。
http://kmycode.hatenablog.jp/entry/2017/02/09/205655
public class FirebaseAuthService : IAuthService
{
private readonly AuthenticationStateProvider _authenticationStateProvider;
public FirebaseAuthService(AuthenticationStateProvider authenticationStateProvider)
{
_authenticationStateProvider = authenticationStateProvider;
}
public async Task<LoginResult> LoginAsync(LoginModel loginModel)
{
try
{
var provider = new FirebaseAuthProvider(new FirebaseConfig("ApiKEYを入れる"));
var firebaseResult = await provider.SignInWithEmailAndPasswordAsync(loginModel.UserID, loginModel.Password);
// トークンを取得
var res = new LoginResult()
{
IsSuccessful = true,
IDToken = firebaseResult.FirebaseToken
};
await ((SPAAuthticateProvider)_authenticationStateProvider).MarkUserAsAuthenticated(loginModel.UserID, res.IDToken);
return res;
}
catch (FirebaseAuthException e)
{
return new LoginResult()
{
IsSuccessful = false,
Error = e
};
}
}
public async Task LogoutAsync()
{
await ((SPAAuthticateProvider)_authenticationStateProvider).MarkUserAsLoggedOut();
}
}
権限設定
認証の仕組みによっては、ユーザ毎に権限を設定して、権限に応じて表示があると思います。
(例:管理者だけが操作可能な画面)
Blazorでは以下の2種類の権限の仕組みを提供します。
- Role
- Policy
Role
下記のようにRolesで表示を許可するRoleを指定します。
@page "/counter"
@attribute [Authorize(Roles = "Admin,SuperUser")]
<h1>Counter</h1>
<AuthorizeView Roles="Admin,SuperUser">
<Authorized>
// 略
</Authorized>
</AuthorizeView>
下記にAuthenticationStateProviderにRoleを認識させる場合の例にを示します。
public class SpaAuthticateProvider : AuthenticationStateProvider
{
// 略
public async override Task<AuthenticationState> GetAuthenticationStateAsync()
{
// 保存したロールを取得
var roles = await _localStorage.GetItemAsync<List<string>>("roles");
var claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.Name, userID));
foreach(var role in roles)
{
claims.Add(new Claim(ClaimTypes.Role,role));
}
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims, "User")));
}
トークンと同じく、認証サービス側から取得した情報をローカルストレージに保存しておいて使用するといった形になります。
Policy
PolicyはRoleを発展させたもので、複数のRoleをまとめて扱ことが可能です。
複数のロールがある場合に、纏める場合に便利です。
(例:IsAdminポリシーはAdminロールとSuperUserロールに対して割り当てる)
UIへの割り当てはRoleと違いはありません。
@page "/counter"
@attribute [Authorize(Policy = "IsAdmin")]
<h1>Counter</h1>
<AuthorizeView Policy="IsAdmin">
<Authorized>
// 略
</Authorized>
</AuthorizeView>
StartupのAddAuthorizationCoreメソッド内で、ポリシーに対して対応するRoleを指定します。
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthorizationCore(config =>
{
config.AddPolicy("IsAdmin", policy => policy.RequireRole("Admin", "SuperUser"));
});
}
}
後はRoleの場合と同様に、認証のタイミングでユーザに割り当てるロール設定を行います。
CascadingAuthenticationState
認証状態をコンポーネント内の独自のロジックに組み込みたい場合、CascadingAuthenticationStateを使用します。
下記のようにCascadingAuthenticationStateタグで要素を囲むことで使用可能となります。
App.razorを囲むことで全てのコンポーネント内で使用可能になりますが、特定のコンポーネント内で使用することで部分的に有効化することも可能です。
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
// 略
</Router>
</CascadingAuthenticationState>
各コンポーネント内で、状態を取得するには下記のようにします。
@page "/cascade"
<input type="button" @onclick="@DisplayAuth" value="display state" />
<p>@Message</p>
@code {
[CascadingParameter]
Task<AuthenticationState> AuthenticationStateTask { get; set; }
string Message { get; set; }
async Task DisplayAuth()
{
var user = (await AuthenticationStateTask).User;
Message = string.Empty;
//認証済みの場合
if (user.Identity.IsAuthenticated)
{
Message += $"こんにちは、{user.Identity.Name} さん。";
if (user.IsInRole("Admin"))
{
Message += "あなたは管理者です。";
}
}
else
{
Message += "こんにちは ゲスト さん。";
}
}
}
CascadingParameter属性を付与したTask型の変数を定義することで、認証情報が取得可能になります。
まとめ
BlazorのWebAssembly版における認証の方法を紹介しました。
VueやNuxtを利用していると、自前でVuexのStoreでログイン状態の管理を自前で実装する必要があったりと手間でしたが、フレームワーク側で定義されているとある程度、楽ができていいですね。
おまけ (Cognitoにおける認証)
当初はFirebaseAuthenticationではなく、Cognitoでの認証を考えていました。
Client版のBlazorの場合、HttpClientはそのままでは使用できず、ブラウザ用のHttpClientとしてinjectする必要があるので、内部でHttpClientが使用されていると動きません。
下記を参考にCognitoのSDK内で使用しているHttpClientを置き換えることで認証まではできましたが、非同期処理なのにリクエスト中に画面がフリーズしてしまい、スピナーによるローディング画面が表示できず諦めました。
将来的に使えるようになると良いのですが・・・。
一応ですがCognitoの場合に試したコードも下記にいれてあります。
ソースコード
参考資料等
https://docs.microsoft.com/ja-jp/aspnet/core/security/blazor/?view=aspnetcore-3.1&tabs=visual-studio
https://gunnarpeipman.com/client-side-blazor-authorizeview/
https://gist.github.com/SteveSandersonMS/175a08dcdccb384a52ba760122cd2eda
https://chrissainty.com/securing-your-blazor-apps-configuring-role-based-authorization-with-client-side-blazor/
https://chrissainty.com/securing-your-blazor-apps-configuring-policy-based-authorization-with-blazor/