前回に引き続き、Todoリストアプリ開発を題材として、Blazorの基本機能を学びます。
初心者がBlazorでWebアプリ開発をする際にまず押さえるべき以下の要点を学びます。今回は認証基盤です。
- C#コードとHTMLを組み合わせて動的なページを作るRazorコンポーネント
- データベース(DB)アクセスを行うためのEntity Framework Core
- 認証基盤であるASP.NET Core Identity
「最終ゴール」は、認証機能付きでDBへの参照・更新操作を行う簡易なTodoリストアプリを作ることです。
BlazorのServerモード・DBはSQLiteを使い、前回までで以下の部分を作りました。
- Entity Framework Core(EF Core)でクラス定義からDBスキーマを生成
- EF Coreを使ってTodoに対するCRUD操作のビジネスロジックを実装
- RazorコンポーネントでTodoの一覧表示・編集・追加・削除機能を実装
これまでの記事は以下になりますので、ぜひまずは先にこちらをご覧ください。
今回は以下について学びます。
- Blazorのユーザ認証・管理にはどのような方法があるのか?
- ASP.NET Core Identityを使ってユーザごとのTodo管理機能を作る。
ユーザごとのTodo管理機能の完成イメージは以下のようになります。
認証機能は実用的なWebアプリで必須の要素ですね。
一緒に学んでいきましょう!
演習のコード一式をGitHubに公開していますので、そちらも参考にしてください。
YouTube動画でも解説しています。
講義:Blazorにおけるユーザ認証・管理
Webアプリにおけるユーザ認証・管理は以下のような構成になっています。
Blazor/ASP.NET Coreでユーザ認証・管理を実現するには、主に以下の方法があります。
- (1)ASP.NET Core Identityを使う
- (2)OAuth2/OpenID Connectを使う
- (3)独自で実装する
それぞれ簡単に説明します。
ユーザ認証・管理の実現方法
(1) ASP.NET Core Identityを使う
ユーザ認証と管理のための高レベルなフレームワークです。
ユーザ認証における「a.初期認証、b.継続的な認証、c.追加の安全対策」の機能を包括的に提供しています。
例えば、ID・パスワードによる初期認証、セッション管理(Cookie認証・JWT認証等)、二段階認証、パスワードリセットなどの機能が用意されています。
ユーザ管理のため、データモデルもあらかじめ用意されており、DBへマイグレーションすることで対応するテーブルをすぐに作ることができます。
一般的なユーザ認証・管理で必要な機能一式を用意してくれています。
今回の演習では、このCore Identityを使ってユーザごとのTodo管理を実装します!
(2) OAuth2/OpenID Connectを使う
OAuth2/OpenID Connectは外部の認証プロバイダを利用してユーザ認証するプロトコルです。
アプリ要件に応じて、ユーザ管理は外部サービスに依存するかアプリ側で管理するか(もしくは組み合わせるか)を選択します。
これにより、シングルサインオン(SSO)や多要素認証(MFA)などの高度な認証機能を提供できます。
外部の認証プロバイダとしては、例えばFacebook、Google、Microsoft、Twitterなどがあります。
Webサービスでログインするときに「Googleアカウントを使ってログインする」って出てくるけど、あれのことだね。
(3)独自で実装する
ユーザ認証、ユーザ管理を完全にカスタマイズして実装します。
ユーザ認証における初期認証、継続的な認証、追加の安全対策を全て自前で作り、ユーザ管理用にDBテーブルを設計して用意するなど、全て自分で行う必要があります。
最も柔軟性が高いのですが、開発の負担が大きくセキュリティリスクにも注意が必要です。
どれを選べばよいのか?
自分の作るWebアプリで、どの認証機能を選べばよいでしょうか?
認証機能はアプリの要件や規模、セキュリティニーズに応じて最適な方法を選ぶことが大事です。
おおまかには、いかのような基準で選ぶとよいでしょう。
- (1)Core Identityを選ぶ:
- 一般的なユーザ認証・管理機能が必要な中小規模のアプリ
- 開発時間とセキュリティのバランスを取りたい
- (2)OAuth2 / OpenID Connectを選ぶ:
- ユーザの利便性を高めたい(既存アカウントでのログイン)
- セキュリティ要件が高く、多要素認証などの高度な機能が必要
- (3)独自の実装を選ぶ:
- 非常に特殊な認証要件がある場合
- セキュリティに関する十分な知識と経験を保有
場合によっては複数の手段を提供する(既存アカウント認証をユーザが選択可能にする)こともあるでしょう。
既存アカウント認証を選択できるWebサービスはよくみかけるね!
そうですね。ただ、実装の複雑さは若干増すので、プロジェクトの規模や要件に応じて検討するのがよいでしょう。
初心者の場合、包括的な機能が一式用意されている(1)Core Identityをまず使ってみることをすすめます!
演習:ユーザごとにTodo管理 ~Core Identity~
作る機能
Todoリストアプリを以下のように段階的に作り上げていきます。
- 【1】Todoリストを一覧で表示できる。(←前々回記事の演習)
- 【2】Todoを削除、編集、追加できる。(←前回記事の演習)
- 【3】ユーザごとにTodoリストを管理できる。(←今回記事の演習)
- 【4】画面入力に対してバリデーションを行える。
- 【5】DB更新の結果(成功・失敗)を画面UI上に表示できる。
前回のコードをベースとして、ユーザごとにTodoを管理可能にします。
(Readme.mdに書いていますが、このコードにはDBは含まれていないため、最初に「dotnet ef database update」コマンドで作ってください。)
以下のように、画面・ビジネスロジック・データモデルを修正します。
- 手順1:TotoItem.cs(データモデル)を修正、DBへのマイグレーションを実施
- 手順2:TodoListService.cs (ビジネスロジック)を修正
- 手順3:TodoList.razor (画面)を修正
ユーザ登録・ログインなどの認証関連の基本機能や画面は既に用意されているため、これらをそのまま使います。
Components/Accountフォルダ配下をみると、Login.razorなど認証機能関連のコードがプロジェクトのひな型にもともと含まれていることがわかりますね。
Core Identityがユーザ認証で必要になる一般的な機能を用意してくれているんですね。
手順1:データモデル修正とDBへのマイグレーション
コード修正
Models/TodoItem.csのコードを以下のように修正しましょう。
UserIdというフィールド(外部キー)を追加しています。
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using TodoListApp.Data;
namespace TodoListApp.Models;
public class TodoItem
{
[Key]
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
[ForeignKey(nameof(ApplicationUser))]
public string UserId { get; set; } = string.Empty;
}
前々回の記事でも説明した通り、認証機構有りでBlazorのひな型を作成すると、ユーザ管理に関するデータモデルはあらかじめ用意されていて、それを使うことができます。
[ForeignKey(nameof(ApplicationUser))]
で指定しているApplicationUserクラスは以下のように、ひな型作成時に自動で作られています。
ついでに、SQLiteのDBファイル(app.db)をDB Browser for SQLite等で確認しましょう。
以下のように、ApplicationUserクラスに対応するAspNetUsersテーブルなどユーザ管理関連のテーブルが既に存在していますね。
Core Identityがユーザ管理機能を提供してくれているわけですね。
これで、UserIdフィールドの追加により、各Todo要素がどのユーザへ所属するかがわかるようになります。
DBへのマイグレーション実施
データモデルを修正したら、忘れずにDBマイグレーションを実施しましょう。
DBへのマイグレーションは以下の2ステップでしたね。
- Step1:マイグレーション用コード生成
- Step2:コードを実行してマイグレーションをDBへ反映
Step1,2について以下をソリューションファイル「TodoListApp.sln」があるフォルダで実行しましょう。
Step1:マイグレーション用コード生成
dotnet ef migrations add AddForeignKeyToTodoItem -o Data\Migrations
Step2:コードを実行してマイグレーションをDBへ反映
dotnet ef database update
ログが出力され、特にエラーがなく終了すれば成功です。
手順2:ビジネスロジックを修正
Services/TodoListService.csを以下のように修正します。
using Microsoft.EntityFrameworkCore;
using TodoListApp.Data;
using TodoListApp.Models;
namespace TodoListApp.Services;
public class TodoListService
{
private readonly ApplicationDbContext _context;
public TodoListService(ApplicationDbContext context)
{
_context = context;
}
public async Task<List<TodoItem>> GetTodosAsync(string userId)
{
var items = await _context.TodoItems.Where(t => t.UserId == userId).ToListAsync();
return items;
}
public async Task UpdateTodoAsync(string userId, TodoItem todoItemInput)
{
var existingTodoItem = await _context.TodoItems.FindAsync(todoItemInput.Id);
if (existingTodoItem != null && existingTodoItem.UserId == userId)
{
existingTodoItem.Title = todoItemInput.Title;
await _context.SaveChangesAsync();
}
}
public async Task DeleteTodoAsync(string userId, int todoId)
{
var existingTodoItem = await _context.TodoItems.FindAsync(todoId);
if (existingTodoItem != null && existingTodoItem.UserId == userId)
{
_context.TodoItems.Remove(existingTodoItem);
await _context.SaveChangesAsync();
}
}
public async Task AddTodoAsync(string userId, TodoItem newTodoItem)
{
newTodoItem.UserId = userId;
_context.TodoItems.Add(newTodoItem);
await _context.SaveChangesAsync();
}
}
それぞれのメソッドで、「userId」を引数として渡すようにしています。
GetTodosAsyncメソッドでは、指定したuserIdとUserIdフィールドが一致するTodo要素だけ取得するようにしています。
Update/DeleteTodoAsyncメソッドでは、Idで指定して取得したTodo要素について、userIdとUserIdフィールドが一致するかを検証しています。
AddTodoAsyncメソッドでは、追加するTodo要素のUserIdフィールドへuserIdを設定しています。
手順3:画面を修正
コード修正
Components/Pages/TodoList.razorを以下のように修正します。
@page "/todolist"
@rendermode InteractiveServer
@using Microsoft.AspNetCore.Identity
@using TodoListApp.Components.Shared
@using TodoListApp.Data
@using TodoListApp.Models
@using TodoListApp.Services
@inject AuthenticationStateProvider _authenticationStateProvider
@inject UserManager<ApplicationUser> _userManager
@inject TodoListService _todoService
<PageTitle>TodoList</PageTitle>
<h1>Todo List</h1>
@if(IsAuthenticated){
<p>@_userName (Authenticated)</p>
<ul>
@foreach (var todoItem in _todoItems)
{
<li>
<span>Id: @todoItem.Id, @todoItem.Title</span>
@if (_editingTodoItem != null && _editingTodoItem.Id == todoItem.Id)
{
<TodoItemEditor TodoItem="_editingTodoItem" OnSubmit="UpdateTodoAsync" Placeholder="Edit title" ButtonText="Save" />
}
else
{
<button @onclick="() => EnterEditMode(todoItem)">Edit</button>
}
<button @onclick="() => DeleteTodoAsync(todoItem.Id)">Delete</button>
</li>
}
</ul>
<TodoItemEditor TodoItem="_newTodoItem" OnSubmit="AddTodoAsync" Placeholder="New todo title" ButtonText="Add" />
}
else
{
<p>Not authenticated</p>
}
@code {
private List<TodoItem> _todoItems = new();
private TodoItem? _newTodoItem;
private TodoItem? _editingTodoItem;
private string _userId = string.Empty;
private string _userName = string.Empty;
private bool IsAuthenticated => !string.IsNullOrEmpty(_userId);
protected override async Task OnInitializedAsync()
{
var authState = await _authenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
if (user.Identity?.IsAuthenticated == true)
{
_userId = _userManager.GetUserId(user) ?? string.Empty;
_userName = user.Identity.Name ?? string.Empty;
if (IsAuthenticated)
{
CreateNewTodo();
await ReloadTodosAsync();
}
}
}
private void CreateNewTodo()
{
_newTodoItem = new TodoItem { UserId = _userId };
}
private async Task ReloadTodosAsync()
{
_todoItems = await _todoService.GetTodosAsync(_userId);
}
private void EnterEditMode(TodoItem todoItem)
{
_editingTodoItem = new TodoItem
{
Id = todoItem.Id,
Title = todoItem.Title,
UserId = todoItem.UserId
};
}
private async Task UpdateTodoAsync()
{
if (_editingTodoItem != null)
{
await _todoService.UpdateTodoAsync(_userId, _editingTodoItem);
_editingTodoItem = null;
await ReloadTodosAsync();
}
}
private async Task DeleteTodoAsync(int todoId)
{
await _todoService.DeleteTodoAsync(_userId, todoId);
await ReloadTodosAsync();
}
private async Task AddTodoAsync()
{
if(_newTodoItem != null)
{
await _todoService.AddTodoAsync(_userId, _newTodoItem);
CreateNewTodo();
await ReloadTodosAsync();
}
}
}
このコードのおおまかな構造を抜粋したものは以下になります。
…
<h1>Todo List</h1>
@* ログインしているユーザのTodo一覧を表示 *@
@if(IsAuthenticated){
<p>@_userName (Authenticated)</p>
<ul>
@foreach (var todoItem in _todoItems)
…
}
@* ログインしていなければNot authenticatedと表示 *@
else
{
<p>Not authenticated</p>
}
@code {
…
private string _userId = string.Empty;
private string _userName = string.Empty;
private bool IsAuthenticated => !string.IsNullOrEmpty(_userId);
protected override async Task OnInitializedAsync()
{
//ログインしていれば、ユーザ情報を取得して_userId、_userNameへ設定
…
}
…
//DBへのCRUD操作に対応するメソッド呼び出し(_userIdを使う)など
…
ページ初期化時にログイン状態の有無を判別し、ログインしていれば認証情報(ユーザのID・名前)を取得し、画面へそのユーザのTodo一覧を表示しています。
そして、DBへのCRUD操作に対応するTodoListServiceクラスの各メソッド呼び出しでは、取得した_userId(ログイン中のユーザID)を渡しています。
BlazorのServerモードにおける認証の理解を深める
認証機能を作る際には、不正なアクセスが行われないようにすることが重要です。
今回の実装では、_userIdというRazorコンポーネントにおける状態変数を使い、ログインユーザに対応した処理を行っています。
Serverモードでは状態変数がサーバ側にあり、クライアント側から直接アクセスできません。
なので、以下のようにユーザが他人のTodo要素へ不正アクセスできないよう制御できます。
「DeleteTodoAsync」メソッド相当の実行は、SignalR通信でサーバへ送信されます。
このとき、クライアント側でSignal通信を解析してId部分を不正に書き換えることで、別ユーザのTodo要素へアクセスされてしまう危険性があります。
そこで、サーバ側の_userIdの変数値(ログイン中のユーザのId)と、アクセス対象のTodo要素のUserIdフィールド値と照合することで、適切なアクセスかを検証しています。
クライアント側からの送られてくるデータは改ざんの危険があるため信用せず、サーバ側できちんと検証することが大事なのですね。
BlazorのServerモードでは、画面表示以外の全ての処理は基本的にサーバ側で行われます。
クライアント側(ブラウザ)は画面UI表示のみを行い、画面更新が必要な場合はサーバ側から必要な差分情報を取得します。
ユーザの操作(クリックや入力)などのイベントがサーバに送信され、サーバで処理された結果がクライアントに送信されてUIが更新されます。
アプリを実行
アプリをデバッグ実行してみましょう。
ログインせずにTodoListページを見ようとすると以下のように表示されます。
Register画面からユーザ登録しましょう。
パスワードは6文字以上、アルファベットの大文字・小文字・数字・記号を含める必要があります。例えば「Ab123!」ならOKです。
Registerボタンを押すと、以下の画面になるので、赤枠のリンクをクリックしましょう。
本来、メールで送信してそのメールのリンクをクリックすることで検証するのですが、プロジェクトのひな型ではモックになっており、このリンクをクリックすればよいです。
このEメール送信部分は自分で実装する必要があります。実装方法については以下の記事も参考にしてください。
クリックすると以下の画面へ遷移し、これでユーザ登録が完了です。
この時点で、app.dbファイルを覗いてみると、以下のようにユーザ情報が登録されていることがわかります。
登録したEメールとパスワードでログインしてみましょう。
ログインしてから、TodoListページへアクセスすると以下のような画面になります。
これで、ログインしたユーザが自分のTodoリストを管理できるね!
いろいろな操作を試してみてください!
複数のユーザを登録して切り替えてみたり、操作の後にapp.dbの中身を確認してみたりすると理解が深まります。
まとめ
Blazor/ASP.NET Coreでどのような認証手段があるかを学びました。
そして、Todo管理アプリでユーザごとにTodoを管理できるよう、Identity Coreを使った実装方法も学びました。
これで、Blazorを使ってユーザ認証機構を備えたアプリを作れるようになりました。
次回からは、入力バリデーション・エラー処理などを学んでいきます。
引き続き、Webアプリ開発とBlazorを一緒に学んでいきましょう!