情報応用演習Ⅰ(2024)

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

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

9a-13. ユーザー情報の編集

次にAccountsコントローラーにユーザー情報の編集と削除のためのアクション/ビューを追加しよう. この機能に関しては, ログインしているユーザーが誰であるかで編集や削除できる対象が異なる ことに注意しよう. ログインしているユーザーと操作可能なアカウントの関係を_にまとめる.

ログインしているユーザーと操作可能なアカウントの関係
操作(→)
(↓) ログインユーザー
自分自身の編集自分自身の削除自分自身以外の
管理者ユーザーの編集
自分自身以外の
管理者ユーザーの削除
自分自身以外の
通常ユーザーの編集
自分自身以外の
通常ユーザーの削除
管理者ユーザー××
通常ユーザー××××

凡例:〇...可能,×...不可

すべてのユーザー,つまり通常ユーザーと管理者ユーザーは基本的に自分自身の情報を編集することができる. それに加え「管理者ユーザー」はすべてのユーザーの情報を編集することができる. また,通常ユーザーは自分自身のみを削除(≒退会処理)をすることができるが,管理者ユーザーはそれができない. ただし管理者ユーザーは自身を除くすべての通常ユーザーを制限なく削除することができる.

編集時の編集可能な項目については,以下の3項目に制限する.

  1. ニックネーム
  2. メールアドレス
  3. パスワード

以前にも説明した通り,基本的に主キーであるIdプロパティは変更させないようにするのが定石である. またユーザー名はそれ自体は主キーではないが,これも安易に変更できるようにするべきものではないので編集可能なデータ項目から除外している. これは,ユーザー名はサービスの外側から(≒閲覧者から)見てユーザーを識別することができる情報であり,容易に変更できるようにしてしまうのは騙り などの問題が発生しうるためである(例えばTwitterなどではユーザーIDの変更は可能だが変更回数などの面でかなり制限がある). その代わり単なる表示名であるニックネームは自由に変更可能である.パスワードも変更可能だが,「管理者ユーザー」が「通常ユーザー」のパスワードを 変更する場合は無条件に変更が可能であるが, 「通常ユーザー」が自分自身のパスワードを変更する際は,その時点でのパスワードも入力しなければならない点も異なる.

ユーザー情報の編集に関しても,ログイン時や新規登録時のアクションと同様に専用のモデルクラスを作成して使用することにしよう. プロジェクト内の Models フォルダを右クリックし,「追加」→「クラス」をクリックする. 作成するクラス名を訊かれるのでEditorialUserInfo(.csは省略可能)と入力して「追加」ボタンをクリックする. すると空のクラス定義が作られるので_の定義を書き込もう.

EditorialUserInfoクラス
 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
using System.ComponentModel.DataAnnotations; // 追記

namespace T9a.Models
{
    // ユーザー情報の編集のためのモデルクラス
    public class EditorialUserInfo : IValidatableObject
    {
        [Display(Name = "ニックネーム")]
        public string? Nickname { get; set; }

        [Display(Name = "メールアドレス")]
        [EmailAddress]
        public string? Email { get; set; }

        [Display(Name = "現在のパスワード")]
        [DataType(DataType.Password)]
        public string? CurrentPassword { get; set; }

        [Display(Name = "新しいパスワード")]
        [DataType(DataType.Password)]
        public string? NewPassword { get; set; }

        [Display(Name = "新しいパスワード(確認)")]
        [DataType(DataType.Password)]
        public string? NewPasswordConfirmation { get; set; }

        public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
        {
            if (!string.IsNullOrEmpty(NewPassword))
            {
                if (string.IsNullOrEmpty(CurrentPassword))
                    yield return new ValidationResult("現在のパスワードを入力して下さい.");

                if (string.IsNullOrEmpty(NewPasswordConfirmation))
                    yield return new ValidationResult("新しいパスワード(確認)を入力して下さい.");

                if (NewPassword != NewPasswordConfirmation)
                    yield return new ValidationResult("入力されたパスワードが一致しません.");
            }
        }
    }
}

_で注目するべき点は,パスワード関連(「現在のパスワード」,「新しいパスワード」,「新しいパスワード(確認)」)も 含めて,すべてのデータ項目をNull許容型(?つきの型)にしている,つまりは すべての入力項目が任意入力となっている 点である. これは変更しない場合は空欄のままでも送信できるようにするためである. ただし,IValidatableObjectインターフェースを実装して,「新しいパスワード」が入力済みの場合はこれら「現在のパスワード」 「新しいパスワード(確認)」が空ではないこと,またリスト9a-11-1と同様に 「新しいパスワード」と「新しいパスワード(確認)」の入力内容が一致していることをチェックしている. ちなみに入力された「現在のパスワード」が本当にそのユーザーのパスワードと一致するかどうかはこの時点では検証していないが, それに関してはアクションメソッドにおけるパスワード変更処理時に検証するようにしている.

それではまずユーザー情報の編集のためのアクションメソッドを定義しよう. ユーザー情報の編集はAccountsコントローラーのEditUserアクションで行うので,そのためのアクションメソッドを追加しよう. Controllers/AccountsController.cs のAccountsControllerクラスに__に示す内容を追記しよう.

Accountsコントローラーの追記内容(EditUser)
 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
// EditUserアクション(GET用)
[Authorize(Roles = IdentityDataSeeder.AllRoles)]
public async Task<IActionResult> EditUser(string? id)
{
    if (string.IsNullOrEmpty(id))                                  // ルーティングパラメーターが指定されていなければ
        return NotFound();                                         // 404 Not Found を返す.

    var userName = id;                                             // ルーティングパラメーターには実際には
                                                                   // ユーザー名が格納されている(Idではない)

    var targetUser = await _userManager.FindByNameAsync(userName); // 指定されたユーザー名から,操作対象のユーザー情報を検索する.

    if (targetUser == null)                                        // 見つからなければ
        return NotFound();                                         // 404 Not Found を返す.
            
    var currentUser = await _userManager.GetUserAsync(User);       // 現在ログイン中のユーザー情報を取得して
    bool isAdminUser = await IsAdminUserAsync(currentUser);        // それが管理者ユーザーかどうかを調べる.

    if (!(isAdminUser || targetUser.Id == currentUser!.Id))         // 管理者ユーザーであるか,もしくは
        return Forbid();                                           // 編集対象が自分自身である場合以外は,
                                                                   // アクセスを拒否する.

    ViewBag.TargetUser = targetUser;                               // 操作対象のユーザー情報をViewBagに入れておく(表示に使う).
    ViewBag.IsAdminUser = isAdminUser;                             // 現在のユーザーが管理者かどうかもViewBagに入れておく(表示に使う).

    return View(new EditorialUserInfo()                            //
    {                                                              // EditorialUserInfoオブジェクトを生成して,
        Nickname = targetUser.Nickname,                            // 操作対象のユーザーの情報の一部を転記してビューに渡す.
        Email = targetUser.Email,                                  //
    });
}
Accountsコントローラーの追記内容(EditUser)
 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
// EditUserアクション(POST用)
[Authorize(Roles = IdentityDataSeeder.AllRoles)]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditUser(string? id, EditorialUserInfo editorialUserInfo)
{
    if (string.IsNullOrEmpty(id))
        return NotFound();

    var userName = id;

    var targetUser = await _userManager.FindByNameAsync(userName);

    if (targetUser == null)
        return NotFound();

    var currentUser = await _userManager.GetUserAsync(User);
    bool isAdminUser = await IsAdminUserAsync(currentUser);

    if (!(isAdminUser || targetUser.Id == currentUser!.Id))
        return Forbid();

    ViewBag.TargetUser = targetUser;
    ViewBag.IsAdminUser = isAdminUser;

    // ↑ここまではGET用の EditUser() メソッドと同じ

    if (ModelState.IsValid)
    {
        try
        {
            // 「ニックネーム」もしくは「メールアドレス」が現在の値とは異なる値である場合:
            if ((targetUser.Nickname != editorialUserInfo.Nickname)
                || (targetUser.Email != editorialUserInfo.Email))
            {

                targetUser.Nickname = editorialUserInfo.Nickname;               // ニックネームは空にできる(=削除)ので強制代入
                targetUser.Email = editorialUserInfo.Email ?? targetUser.Email; // メールアドレスの削除はさせない                

                // ユーザー情報を更新する.
                var res = await _userManager.UpdateAsync(targetUser);
                if (!res.Succeeded) throw new IdentityOperationFailedException(res);
            }// if

            // 「新しいパスワード」の入力欄が空ではない場合:
            if (!string.IsNullOrEmpty(editorialUserInfo.NewPassword))
            {
                if (isAdminUser) // 管理者ユーザーとしてログインしている場合
                {
                    // 操作対象のユーザーのパスワードを強制リセットする.
                    var token = await _userManager.GeneratePasswordResetTokenAsync(targetUser);
                    var res = await _userManager.ResetPasswordAsync(targetUser, token, editorialUserInfo.NewPassword);
                    if (!res.Succeeded) throw new IdentityOperationFailedException(res);
                }
                else // 通常ユーザーとしてログインしている場合
                {
                    // パスワードの変更を試みる.
                    var res = await _userManager.ChangePasswordAsync(targetUser, editorialUserInfo.CurrentPassword!, editorialUserInfo.NewPassword);
                    if (!res.Succeeded) throw new IdentityOperationFailedException(res);
                }
            }//if

            return RedirectToAction(nameof(UserDetails), new { id = targetUser.UserName }); // ユーザー詳細画面へ遷移する.
        }
        catch (CompositeMessagesException ex)
        {
            ViewBag.ErrorMessages = ex.ErrorMessages;
        }
    }// if

    return View(editorialUserInfo);
}

_の引数なしのEditUser()メソッドはGET用であり,_で定義したEditorialUserInfoクラスを 引数に持つ_EditUser()メソッドがPOST用のアクションメソッドである.後者が実際にユーザー情報の更新を行うメソッドである.

_のGET用のEditUser()メソッドから説明しよう.UserDetailsアクションと同様に,ルーティングパラメーターidには ユーザーIDではなくユーザー名(UserName)が渡されている.これもまたUserDetailsアクションの処理内容と同様に, .FindByNameAsync()を 使って,指定されたユーザー名から操作対象のユーザーのBlogUserオブジェクトを検索している.

19~20行目のチェックが_で示した,ログインしているユーザーと操作可能なアカウントをチェックしている部分である. その直前(16~17行目)で現在ログイン中のユーザーが管理者ユーザーであるかどうかをチェックしているが,以下の条件のいずれかに当てはまる場合以外は Forbid()メソッドを呼び出してアクセスを拒否している.

  • 管理者ユーザーでログインしている.
  • 通常ユーザーでログインしており,かつ操作対象のユーザーとログイン中のユーザーが同じである.

GET用のEditUser()メソッドは最後にEditorialUserInfoオブジェクトを生成して,取得した操作対象のユーザーオブジェクトの情報の一部 (ニックネームとメールアドレス)を転記してビューに渡している.

_のPOST用のEditUser()メソッドの前半の処理はGET用のEditUser()メソッドと全く同じである. 後半ではモデルクラスにエラーがない場合(ModelState.IsValid)にのみユーザー情報やパスワードの更新の処理を行っている. 32~43行目では,ビューから戻された(≒フォームに入力された)EditorialUserInfoオブジェクトの値を見て, ニックネームやメールアドレスが変更されている場合には,UserManager<TUser>クラスの .UpdateAsync()メソッドを使って これらの情報を更新している. 45~61行目ではパスワードの変更処理を行っている.これは管理者ユーザーとしてログインしているか否かで処理方法が異なる. 「管理者ユーザー」でログインしてる場合は, .ResetPasswordAsync()メソッドを使って 指定したユーザーのパスワードを強制変更している. 「通常ユーザー」でログインしている場合には, .ChangePasswordAsync()メソッドを 用いてパスワードの変更を試みている.このメソッドは新しいパスワードだけはなくそのユーザーの現在のパスワードも指定する必要があり, これが正しく入力されていないとパスワードの変更が成功しない.

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

Views/Accounts/EditUser.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
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
@model EditorialUserInfo

@{
    ViewData["Title"] = "ユーザー編集";
    
    var blogUser = (BlogUser)ViewBag.TargetUser; // 操作対象のユーザー情報を取得する.    
    var isAdminUser = (bool)ViewBag.IsAdminUser; // ログイン中のユーザーが管理者ユーザーであるかどうかを取得する.

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

<form asp-action="EditUser">
    <table>
        <tr><th>項目</th><th></th></tr>

        @* ユーザー名(Username)のための入力欄 *@
        <tr>
            <td><label asp-for="@blogUser.UserName"></label></td>
            <td>@blogUser.UserName</td>
        </tr>

        @* ニックネーム(Nickname)のための入力欄 *@
        <tr>
            <td><label asp-for="@Model.Nickname"></label></td>
            <td><input asp-for="@Model.Nickname" /></td>
        </tr>

        @* メールアドレス(Email)のための入力欄 *@
        <tr>
            <td><label asp-for="@Model.Email"></label></td>
            <td><input asp-for="@Model.Email" /></td>
        </tr>

        @* 現在のパスワード(CurrentPassoword)の入力欄(管理者ユーザーではないときのみ) *@
        @if (!isAdminUser)
        {        
            <tr>
                <td><label asp-for="@Model.CurrentPassword"></label></td>
                <td><input asp-for="@Model.CurrentPassword" /></td>
            </tr>
        }

        @* 新しいパスワード(NewPassword)のための入力欄 *@
        <tr>
            <td><label asp-for="@Model.NewPassword"></label></td>
            <td><input asp-for="@Model.NewPassword" /></td>
        </tr>

        @* 新しいパスワードの入力確認(NewPasswordConfirmation)のための入力欄 *@
        <tr>
            <td><label asp-for="@Model.NewPasswordConfirmation"></label></td>
            <td><input asp-for="@Model.NewPasswordConfirmation" /></td>
        </tr>
    </table>

    @* 管理者ユーザーの場合は「現在のパスワード」は入力させないので *@
    @* 隠しパラメータとして埋め込む(値は必要ないが入力データとして存在している必要がある) *@
    @if (isAdminUser)
    {
        <input type="hidden" asp-for="@Model.CurrentPassword" value="unused" />
    }

    <input type="submit" value="適用" />
    <div asp-validation-summary="All"></div>
</form>

@if (ViewBag.ErrorMessages != null)
{
    var msgs = (IEnumerable<string>)ViewBag.ErrorMessages;
    <ul>
        @foreach (var msg in msgs)
        {
            <li>@msg</li>
        }
    </ul>
}

<a asp-action="UserDetails" asp-route-id="@blogUser.UserName">詳細に戻る</a>

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

34~41行目と56~61行目ではログイン中のユーザーが管理者ユーザーか否かでフォームの内容を切り替えている. 34~41行目では管理者ユーザーでログインしていないとき,つまり通常ユーザーでログインしている場合にのみ「現在のパスワード」の入力欄を表示するようにしている. 56~61行目では管理者ユーザーでログインしている場合は「現在のパスワード」にあたる情報を隠しパラメータとして決め打ちの値を埋め込んでいる. これは_EditorialUserInfoValidate()メソッドにおけるチェックで, 「新しいパスワード」が入力されているときは「現在のパスワード」が入力されていないと検証エラーとなってしまうためである. 管理者ユーザーが通常ユーザーのパスワードを変更する際は「現在のパスワード」の値は使用しないので,どんな値が入っていても 処理には影響はない.これは実のところあまりきれいな実装方法とは言えない.ログイン中のユーザーによって入力データの 検証ロジックが異なるのであれば,そのためのモデルクラスも別に定義した方がよいのではあるが ここでは簡単のためにこのような方法を採っている.

ここまで書けたら実行してみよう.プロジェクトを起動して「ログイン」のリンクをクリックしadminとして ログインしよう.起動時にログイン済みの場合は画面上部の「ユーザー一覧」のリンクをクリックしてユーザー一覧画面(Index)を表示させる. 適当なユーザーのリンクをクリックしてユーザー情報詳細画面(UserDetails)を表示させよう. 「編集」のリンクをクリックしてユーザー情報編集画面(EditUser)を表示させ(_),パスワードを変更して「適用」ボタンを クリックしてみよう.パスワードが適切なものであればユーザー情報詳細画面(UserDetails)に 遷移するはずである(_).

この変更したパスワードでログインできることも確認しておこう.つまり,ここで初めてadmin以外のユーザーでのログインを試みる. ログアウトして,「ログイン」のリンクをクリックし先ほどパスワードを変更したユーザーとしてログインしてみよう(__).正しくログインできれば, そのユーザーの情報を表示したユーザー情報詳細画面(UserDetails)に遷移するはずである(_). 「編集」のリンクをクリックして,ニックネームやメールドレスが変更できることも確認しておこう(_).

実行結果
Last updated on 2024-06-19
Published on 2024-06-19

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