情報応用演習Ⅰ(2024)

【T9a】簡易ブログソフトウェアの作成 Part.Ⅰ ~ 認証機能の組み込み(9/14)

プロジェクトタイプASP.NET Core Web アプリ(Model-View-Controller)
プロジェクト名T9a
ソリューション名PIT9
ターゲットフレームワーク.NET 8.0(長期的なサポート)
最上位レベルのステートメントを使用しない使用する(チェックオフ)
注意
  • 本ページの作業内容は 前のページまでの続き になっていることに注意せよ.
    • 先に前のページまでをすべて読み,指示されている作業を済ませてから本ページを読むこと.
    • プロジェクトの作成作業については準備作業を参照せよ.

9a-9. ログイン/ログアウト機能の実装

それではAccountsコントローラーを定義する. まずはすべてのユーザーに共通する部分としてログイン/ログアウトの機能までを実装しよう.

プロジェクト内の Controllers フォルダを右クリックし「追加」→「コントローラー」をクリックしよう. 「MVC コントローラー - 空」を選択して「追加」ボタンをクリックし,「名前」としてAccountsControllerと指定する( .cs は省略可能). AccountsController.cs が追加されるので_に示す内容を記述しよう.

Accountコントローラーの内容1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; // 追記
using Microsoft.AspNetCore.Mvc;
using T9a.Data;   // 追記
using T9a.Models; // 追記

namespace T9a.Controllers
{
    public class AccountsController : Controller
    {
        private readonly UserManager<BlogUser> _userManager;
        private readonly SignInManager<BlogUser> _signInManager;

        public AccountsController(UserManager<BlogUser> userManager, SignInManager<BlogUser> signInManager) 
        {
            _userManager = userManager;
            _signInManager = signInManager;
        }

        // Indexアクション(GETのみ),管理者のみアクセス可
        [Authorize(Roles = IdentityDataSeeder.AdminRoleName)]
        public async Task<IActionResult> Index()
        {
            // 「通常ユーザー」のリストを取得してビューに渡す.
            var users = await _userManager.GetUsersInRoleAsync(IdentityDataSeeder.NormalRoleName);

            return View(users);
        }

        // Loginアクション(GET用)
        public IActionResult Login() 
        {
            return View(new LoginUserInfo());
        }

        // Loginアクション(POST用)
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Login(LoginUserInfo loginUserInfo) 
        {
            if (ModelState.IsValid) 
            {
                try
                {
                    var res = await _signInManager.PasswordSignInAsync(loginUserInfo.Username, loginUserInfo.Password, false, false);
                    if (!res.Succeeded) throw new SignInOperationException(res);

                    return RedirectToAction(nameof(LoginRoute)); // ログインに成功したら,いったん LoginRoute に転送する.
                }
                catch (CompositeMessagesException ex) 
                {
                    ViewBag.ErrorMessages = ex.ErrorMessages;
                }
            }//if

            return View(loginUserInfo);
        }

        // LoginRouteアクション(遷移先の振り分け用),認証済みなら誰でもアクセス可
        [Authorize(Roles = IdentityDataSeeder.AllRoles)]
        public async Task<IActionResult> LoginRoute() 
        {
            // ログイン中のユーザーに対応する BlogUser オブジェクトを取得する.
            var user = await _userManager.GetUserAsync(User);

            if (await IsAdminUserAsync(user))           // 管理者ユーザーの場合:
                return RedirectToAction(nameof(Index)); //     Indexアクションに転送する.
            else                                        // それ以外の場合(通常ユーザーの場合):
                return Redirect("/");                   //     本来はユーザー詳細画面(`UserDetails`)に遷移させるが,
                                                        //     まだ作成していないのでトップページ / に遷移する.
        }

        // Logoutアクション(POSTのみ),認証済みなら誰でもアクセス可
        [Authorize(Roles = IdentityDataSeeder.AllRoles)]
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Logout()
        {
            await _signInManager.SignOutAsync();
            return Redirect("/");
        }

        // AccessDeniedアクション(GETのみ)
        public IActionResult AccessDenied()
        {
            return View();
        }

        // 指定されたユーザーが管理者ユーザー(⇔ adminロールに所属しているかどうか)を
        // チェックするヘルパーメソッド
        private async Task<bool> IsAdminUserAsync(BlogUser? blogUser)
            => blogUser != null && await _userManager.IsInRoleAsync(blogUser, IdentityDataSeeder.AdminRoleName);
    }
}

ここではIndexLoginLogoutLoginRouteAccessDeniedの5個のアクションを定義している. おおむね前章で登場したものと同じだがその挙動とアクセス権の 制御などの部分が異なる.前章では[Authorize]属性を「ログイン済みかどうか」でアクセス権を設定するために 使用していたが,この属性にRolesプロパティを指定することで「 どのロール(グループ)のユーザーがそのアクションにアクセス可能か 」を 指定することが可能である.このプロパティにはアクセスを許可するロール(グループ)の名前を 文字列で 指定する. 複数のロール(グループ)を指定する場合はカンマ区切りで指定する.たとえばRolesプロパティに,"hoge,piyo,fuga"という 文字列を指定すればhogepiyofugaのいずれかのロール(グループ)に所属するユーザーに対してアクセスを許可することができる. ここではRolesプロパティに使用する文字列として,IdentityDataSeederクラスに定義した定数AdminRoleNameAllRolesを使用している.ちなみに,_では前章とは異なり,一部のアクションにのみ[Authorize]属性を指定することで アクセス権を指定している(前章でいう【方法$\beta$】の方法).

_ではまだ定義していないものを含めて,Accountsコントローラーの各アクションの概要と ロール(グループ)によるアクセス可否を_にまとめる.

Accountsコントローラーの各アクションの概要とアクセス制御リスト
コントローラーアクション認証済みのユーザー:
管理者ユーザーの場合
( administrators )
認証済みのユーザー:
通常ユーザーの場合
( users )
認証済みのユーザー:
匿名ユーザーの場合
概要
AccountsIndex×通常ユーザーの一覧を表示.
Loginログインフォームの表示(GET),およびログイン処理(POST).
Logout×ログアウト処理(POSTのみ).
LoginRoute×ログイン後の遷移先の切り分け(後述).
UserDetails×ユーザーの詳細情報を表示.
AddUser××ユーザーの新規登録.
EditUser×ユーザー情報の編集.
DeleteUser×ユーザー情報の削除.
AccessDeniedアクセス拒否のエラー表示.

凡例:〇...アクセス可,×...アクセス不可

ここで解説が必要なのはLoginRouteアクション(59~71行目)だろう.このアクションでは UserManagerクラス.GetUserAsync()メソッドを使って, 現在ログイン中のユーザーを表すBlogUserオブジェクトを取得し,管理者ユーザーか通常ユーザーかを判定して, 次の遷移先を決定している.ユーザーがあるロール(グループ)に所属しているか否かを判定するには .IsInRoleAsync()メソッドを 使用すればよいが,この判定処理はさまざまな場面で使用するので,記述を短くするためにIsAdminUserAsync()というヘルパーメソッド(89~92行目)を定義している. ちなみに.GetUserAsync()メソッドは閲覧者がログイン済みではない場合は null を返す.

LoginRouteアクション内でもこのIsAdminUserAsync()メソッドで管理者ユーザーか否かの判定をしているが,遷移先を判定するだけであればLoginアクション(POST用)の ログイン処理後に判定すればよいと思う読者もいるだろう.しかし,ASP.NET Core Identity の仕組み上ログイン処理(45行目の.PasswordSignInAsync()メソッドの呼び出し)直後は, 現在ログインしているユーザーを表すオブジェクトを取得することができないので,いったん別のアクションに転送してからこの判定を行っている.

さて_で定義しているアクションのうちビューを持つのは,IndexLoginAccessDeniedの3つのアクションである.これらのアクション用のビュー(.cshtml)を作成しよう.まずはIndexアクションを作成しよう.手動で .cshtml ファイルを作成して適切な場所に配置するか,あるいはVisual Studio の機能を使ってRazorビューを作成しよう. Views/Accounts/Index.cshtml を_のように記述する.

Views/Accounts/Index.cshtmlの内容
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@model IEnumerable<BlogUser>

@{
    ViewData["Title"] = "ユーザー一覧";

    if (Model is null) throw new ArgumentNullException(nameof(Model)); // ad-hoc! 非null保証のための回避策
}

@* ユーザー一覧をリストで表示 *@
<ul>
    @foreach (var user in Model) 
    {
        <li><a asp-action="UserDetails" asp-route-id="@user.UserName">@user.UserName</a></li>
    }
</ul>

<a asp-action="AddUser">新規ユーザー登録</a>
| <a asp-controller="Articles" asp-action="Index">記事一覧</a>

ここでは特に解説が必要な技術は使用していない._Indexアクションを _に抜粋する.

AccountsコントローラーのIndexアクション(抜粋)
1
2
3
4
5
6
7
8
9
// Indexアクション(GETのみ),管理者のみアクセス可
[Authorize(Roles = IdentityDataSeeder.AdminRoleName)]
public async Task<IActionResult> Index()
{
    // 「通常ユーザー」のリストを取得してビューに渡す.
    var users = await _userManager.GetUsersInRoleAsync(IdentityDataSeeder.NormalRoleName);

    return View(users);
}

ここではUserManager<TUser>クラスの .GetUsersInRoleAsync()メソッドを 使用して指定したロール(グループ)に所属するユーザーのリストを取り出してビューに渡しており, そのビューである_ではそれを表示しているのみである. _の13行目でユーザー名にユーザー詳細画面(UserDetails)へのリンクを貼っているが, ルーティングパラメーターidにユーザーのIdではなく,あえてUserNameを使用している 点がポイントである. これによってhttp://localhost:ポート番号/UserDetails/ユーザー名のようなリンクが生成される. 前述した通り,ASP.NET Core Identity のユーザーIDは長く,またセキュリティ上,表示される部分に使用するべきものではないためこのような対応をしている. ルーティングパラメーターを受け取るアクションメソッドでは,ルーティングパラメーターと同じ名前の引数を持つ必要があるが, このような対応をしているためアクションメソッドのid引数にはUserNameの値が渡されるという点を覚えておこう.

次にLoginアクションのためのビューを作成しよう. Views/Accounts/Login.cshtml を_のように記述する.

Views/Accounts/Login.cshtmlの内容
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@model LoginUserInfo

@{
    ViewData["Title"] = "ユーザーログイン";

    if (Model is null) throw new ArgumentNullException(nameof(Model)); // ad-hoc! 非null保証のための回避策
}

<form asp-action="Login">
    @* ユーザー名(Username)のための入力欄 *@
    <label asp-for="@Model.Username"></label>    <input asp-for="@Model.Username" />
    <br />

    @* パスワード(Password)のための入力欄 *@
    <label asp-for="@Model.Password"></label>    <input asp-for="@Model.Password" />
    <br />

    <input type="submit" value="ログイン" />
    <div asp-validation-summary="All"></div> @* エラーは集約表示する *@
</form>

@* ログインにまつわるエラーメッセージを ul 要素で表示する *@
@if (ViewBag.ErrorMessages != null)
{
    var msgs = (IEnumerable<string>)ViewBag.ErrorMessages;
    <ul>
        @foreach (var msg in msgs)
        {
            <li>@msg</li>
        }
    </ul>
}

@section Scripts
{
    @{ await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

ここでも特に技術的に特別なことはしていない.しいて言えば,_Loginアクションでは, サインインにまつわるエラーメッセージをViewBag.ErrorMessagesに string のリスト(IEnumerable<string>)として格納しているため,それを ul 要素で表示している(24~34行目)という点である. この部分の仕組みを少し詳しく説明しておこう.AccountsコントローラーのLoginアクションを_に抜粋する.

AccountsコントローラーのLoginアクション(抜粋)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Loginアクション(POST用)
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginUserInfo loginUserInfo) 
{
    if (ModelState.IsValid) 
    {
        try
        {
            var res = await _signInManager.PasswordSignInAsync(loginUserInfo.Username, loginUserInfo.Password, false, false);
            if (!res.Succeeded) throw new SignInOperationException(res);

            return RedirectToAction(nameof(LoginRoute));
        }
        catch (CompositeMessagesException ex) 
        {
            ViewBag.ErrorMessages = ex.ErrorMessages;
        }
    }//if

    return View(loginUserInfo);
}

ここではリスト9a-8-1で導入した例外クラスCompositeMessagesExceptionを使用している. ここで使用している.PasswordSignInAsync()メソッドをはじめとして, SignInManagerクラスの いくつかのメソッドはログイン処理の成否をSignInResultクラスの オブジェクトとして返す.このオブジェクトの.Succeededプロパティが true であればログイン成功, falseあればログイン失敗を表す. ログインに失敗した場合はその理由がこのオブジェクトのいくつかのプロパティ(IsLockedOutIsNotAllowedなど)を通じて報告される. SignInOperationException例外クラスはこのSignInResultオブジェクトを受け取って,エラーメッセージのリストに変換する機能を持つ. このクラスの基底クラスであるCompositeMessagesExceptionは,エラーメッセージのリストをErrorMessages仮想プロパティを通じて取得できるようになっており, catch ブロックではそれを前述したViewBag.ErrorMessagesにセットしている.

次にAccessDeniedアクションのためのビューを作成しよう. Views/Accounts/AccessDenied.cshtml を_のように記述する.

Views/Accounts/AccessDenied.cshtmlの内容
1
2
3
@{
    ViewData["Title"] = "アクセスが拒否されました";
}

これに関しては前章と全く同じである.

つぎにログイン/ログアウトのためのリンクやボタンをすべてのページに表示するために,これまた前章と同じく レイアウトページに手を加えよう. Views/Shared フォルダを右クリックし「追加」→「ビュー」で 「Razorビュー - 空」を選択する.ファイル名は_LoginPartial(.cshtmlは省略可能) にして,_の内容を書き込もう.

Views/Shared/_LoginPartial.cshtmlの内容
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@using Microsoft.AspNetCore.Identity;
@using T9a.Data;

@inject SignInManager<BlogUser> signInManager;
@inject UserManager<BlogUser> userManager;

@{
    var blogUser = await userManager.GetUserAsync(User); // ログイン中のユーザー情報を取得する.
}

@if (signInManager.IsSignedIn(User) && blogUser != null)
{
    <p>@blogUser.UserName としてログイン中(
    <a asp-controller="Accounts" asp-action="UserDetails" asp-route-id="@blogUser.UserName">ユーザー情報詳細</a>
    @if (await userManager.IsInRoleAsync(blogUser, IdentityDataSeeder.AdminRoleName))
    {
        <text></text><a asp-controller="Accounts" asp-action="Index">ユーザー一覧</a>
    }
    )</p>
    <form asp-controller="Accounts" asp-action="Logout">
        <input type="submit" value="ログアウト" />
    </form>
}
else
{
    <a asp-controller="Accounts" asp-action="Login">ログイン</a>
}

前章の _LoginPartial.cshtml との差異は,ログイン中のユーザー情報を取得できることを確認している点1(7~9行目と11行目), 非ログイン状態のときはログインページ,すなわちAccountsコントローラーのLoginアクションへのリンクを表示している点である(24~27行目).

また,ログイン済みの場合は「ユーザー名としてログイン中」の あとに「ユーザー情報詳細」というリンクを設置している(14行目). このリンクはAccountsコントローラーのUserDetailsアクションへのリンクで,ログイン中のユーザーの 情報を編集するページへ遷移するためのリンクである. 管理者ユーザーの場合は,これに加えて「ユーザー一覧」というリンクを設置している(15~18行目). これはAccountsコントローラーのIndexアクションへ遷移するためのリンクである.

次にレイアウトページでこの _LoginPartial.cshtml を読み込むように Views/Shared/_Layout.cshtml に _に示す内容を追記する.

Views/Shared/_Layout.cshtmlの追記内容
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - T9a</title>
    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
    <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body>
    @* ① ヘッダー *@
    <header>
        <h1>チュートリアルT9a</h1>
        <partial name="_LoginPartial" />
    </header>
 
    @* ② ナビゲーション *@
    <nav>
        @* いまのところは空にしておく *@
    </nav>
 
    @* ③ メインコンテンツ *@
    <main>
        <h2>@ViewData["Title"]</h2>
        @RenderBody()
    </main>
 
    @* ④ フッター *@
    <footer>
        &copy; @DateTime.Today.Year - Iryo Taro
    </footer>
 
    @* そのほか *@
    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>
    @await RenderSectionAsync("Scripts", required: false)
</body>

</html>

ここまで書けたら実行してみよう.プロジェクトを起動して「ログイン」のリンクをクリックする(_). するとログイン画面が表示されるので,ユーザー名にはリスト9a-6-1で指定したadmin,パスワードには p@55W0rDを入力して「ログイン」ボタンをクリックする(_). するとAccountsコントローラーのIndexアクションに遷移する(_).また「ログアウト」ボタンをクリックすると, HomeコントローラーのIndexアクションに遷移してログイン済みの表示が消える(_). 何度かログインとログアウトを繰り返して正常に動作することを確認しておこう.

実行結果

  1. これはログイン中に管理者ユーザーによってユーザー情報が削除されることがあるための対処である. ↩︎

Last updated on 2024-06-19
Published on 2024-06-19

Powered by Hugo. Theme by TechDoc. Designed by Thingsym.