WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

Email Confirmation の実装 (MVC5)

by WebSurfer 2021年3月16日 21:22

.NET Framework 版の ASP.NET MVC5 で ASP.NET Identity をユーザー認証に使用し、Email Confirmation を実装する方法を備忘録として書いておきます。

Email Confirmation

以前は Microsoft のチュートリアル「ログイン、電子メール確認、パスワード リセットを使用して安全な ASP.NET MVC 5 Web アプリを作成する (C#)」に従って生成されたコードに手を加えれば実装できたのですが、SendGrid が id と password による認証をサポートしなくなり、Api Key による認証を使うように変更する必要が生じました。

Core 版の MVC アプリ用には Api Key による認証を使う SendGrid による Email Confirmation のチュートリアルがあります(先の記事「Email Confirmation の実装 (CORE)」とそれからリンクが貼ってある Microsoft の記事を見てください)。それを参考に実装してみました。以下にその概要を書きます。

(1) プロジェクトの作成

Visual Studio 2019 の[新規作成(N)]⇒[プロジェクト(P)...]で「新しいプロジェクト」ダイアログを表示し、その中の「ASP.NET Web アプリケーション (.NET Framework)」テンプレートを選択して ASP.NET MVC アプリを作成します。

ASP.NET MVC アプリの作成

ASP.NET Identity による認証を実装する必要がありますので、上の画像のように認証に「個別のユーザーアカウント」を使用するように設定してください。

テンプレートで自動生成された認証関係のコードに手を加えて Email Confirmation の機能を実装します。

テンプレートが生成したデフォルトのコードでは、Accout/Register アクションメソッドでユーザーが登録に成功すると直ちにログインした状態になります。アクションメソッドにはメールを送信するコードが含まれていますがコメントアウトされています。

App_Data/IdentityConfig.cs ファイルに EmailService クラスというメール送信のためのクラスが定義されていて、クライアントからの要求を受けるとインスタンス化されて ApplicationUserManager に登録されます。ただし、EmailService クラスの SendAsync メソッドの中身が実装されてないのでメールを送ることはできません。

メールを送信するためには SendAsync メソッドに SendGrid を利用したメール送信機能を実装します。そして、Accout/Register に含まれているメール送信のためのコードのコメントアウトを解除します。

そうすることにより、ユーザー登録完了時点で登録したメールアドレスにメールが送信されます。ユーザーが受け取ったメール本文に含まれる url をクリックすると Accout/ConfirmEmail にクエリ文字列で設定された情報が送信され、サーバー側ではそれを確認してデータベースの AspNetUsers テーブルの EmailConfirmed フィールドを ture に設定します。

Account/Login アクションメソッドには、EmailConfirmed フィールドが ture になっている場合のみログインを許可するコードを追加します。これによりユーザーが送られてきたメール本文に含まれる url をクリックしない限りログインできないということになります。

(2) API Key の入手

メールの送信には SendGrid を利用します。ユーザー登録して API Key を入手してください。(先の記事「Email Confirmation の実装 (CORE)」のステップ (3) を見てください)

SendGrid は基本的に有償ですが、開発目的で送信するようなメールの数が少ない(一日 100 件以内だそうです)の場合は Free のサービスでよさそうです。

(3) SendGrid のインストール

NuGet を利用してプロジェクトに SendGrid をインストールします。

SendGrid をインストール

この記事では自分がインストールした時点での最新版 v9.22.0 を使いました。Microsoft のチュートリアル「ログイン、電子メール確認、パスワード リセットを使用して安全な ASP.NET MVC 5 Web アプリを作成する (C#)」にある SendGrid.Net40 とは違うので注意してください(それも一緒にインストールされますが)。

(4) API Key の保存

メールの差出人と SendGrid の API Key は web.config に保存します。暗号化などを考えた方が良いのかもしれませんが、この記事では簡略化のため生の Api Key を保存しています。

<appSettings>
  <add key="SendGridUser" value="WebSurfer" />
  <add key="SenGridKey" value="SG...t-A" />
</appSettings>

(5) EmailSender クラスの実装

メールを送信するため App_Data/IdentityConfig.cs の EmailService クラスにメール送信機能を実装します。

Microsoft のチュートリアル「Account confirmation and password recovery in ASP.NET Core」にあるコードを参考に以下のようにしました。

using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.EntityFramework;
using Microsoft.AspNet.Identity.Owin;
using Microsoft.Owin;
using Microsoft.Owin.Security;
using Mvc5EmailConfirmation.Models;

using SendGrid;
using SendGrid.Helpers.Mail;
using System.Configuration;

namespace Mvc5EmailConfirmation
{
    public class EmailService : IIdentityMessageService
    {
        public Task SendAsync(IdentityMessage message)
        {
            return Execute(ConfigurationManager.AppSettings["SenGridKey"], 
                       message.Subject, message.Body, message.Destination);
        }

        public Task Execute(string apiKey, string subject, 
                            string message, string email)
        {
            var client = new SendGridClient(apiKey);
            var msg = new SendGridMessage()
            {
                From = new EmailAddress("差出人メールアドレス",
                       ConfigurationManager.AppSettings["SendGridUser"]),
                Subject = subject,
                PlainTextContent = message,
                HtmlContent = message
            };
            msg.AddTo(new EmailAddress(email));

            msg.SetClickTracking(false, false);

            return client.SendEmailAsync(msg);
        }
    }

    // ・・・中略・・・
}

Core 版 MVC のチュートリアルの場合は Register メソッドから宛先メールアドレス、Subject、本文が直接渡されるのですが、.NET Framework 版 MVC5 のテンプレートで生成される SendAsync メソッドには UserManager 経由で IdentityMessage オブジェクトが渡されます。

その中に宛先メールアドレス、Subject、本文が含まれていますので、それぞれ Destination, Subject, Body プロパティを使って取得しています。

(6) Account/Register ページの修正

テンプレートで生成された SignInManager.SignInAsync メソッドをコメントアウトし、code の生成、メール本文に含める Accout/ConfirmEmail への url 作成、メール送信のためのコードのコメントアウトを解除します。

さらに、Home/Index にリダイレクトする既存のコードをコメントアウトし、代わりに登録完了後メール確認を促すページ Info を表示するようコードを追加します。Views/Shared/Info.cshtml をチュートリアルに従い作成・追加してください。

修正後のコードは以下の通りです。(修正は POST 側のみで GET 側は不要です)

// POST: /Account/Register
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Register(RegisterViewModel model)
{
    if (ModelState.IsValid)
    {
        var user = new ApplicationUser 
        { 
            UserName = model.Email, 
            Email = model.Email 
        };

        var result = await UserManager.CreateAsync(user, 
                                                   model.Password);

        if (result.Succeeded)
        {
            // 登録時にはログインさせないようコメントアウト
            //await SignInManager.SignInAsync(user, 
            //                                isPersistent:false, 
            //                                rememberBrowser:false);

            // code の生成、メール本文に含める Accout/ConfirmEmail
            // への url 作成(既存のコードのコメントアウトを解除)
            string code = await UserManager
                          .GenerateEmailConfirmationTokenAsync(user.Id);
            var callbackUrl = Url.Action("ConfirmEmail", 
                              "Account", 
                              new { userId = user.Id, code = code }, 
                              protocol: Request.Url.Scheme);

            // メール送信。既存のコードのコメントアウト解除でもよいが、
            // ユーザーのメーラーが html の表示を許可していない可能性を
            // 考えて、html の a 要素を組み立てて送るのではなく、url そ
            // のものを送信するように変更した
            await UserManager.SendEmailAsync(user.Id, "Confirm your email",
                "Please confirm your account by: " +
                Server.HtmlEncode(callbackUrl).Replace("&amp;", "&"));

            // メール確認を促すページ Info に表示するメッセージ
            ViewBag.Message = "Check your email and confirm your account," +
                " you must be confirmed before you can log in.";

            // チュートリアルに従い Views/Shared/Info.cshtml を別途作成要
            return View("Info");

            // これはコメントアウト
            //return RedirectToAction("Index", "Home");
        }
        AddErrors(result);
    }
    return View(model);
}

以上により、ユーザーが登録に成功するとこの記事の一番上の画像のとおり Info ページが表示され、ユーザーにメール確認を促します。

さらに、ユーザーが登録したメールアドレスに以下のメールが送信されます。メール本文にある url が Accout/ConfirmEmail となっており、クエリ文字列に userId と code が設定されています。ユーザーがこれをクリックすることにより Accout/ConfirmEmail に userId と code が送信され、メール確認が完了します(AspNetUsers テーブルの EmailConfirmed フィールドが ture に設定されます)。

確認メール

(7) Account/Login ページの修正

EmailConfirmed フィールドが ture になっているか否かを確認し true の場合のみログインを許可するコードを追加します。これによりユーザーが送られてきたメール本文に含まれる url をクリックしない限りログインできないということになります。

さらに、メール確認なしでログインしようとするとエラーメッセージを表示するためのコードも追加します。メッセージは Error ページ表示しますので、チュートリアルに従い Views/Shared/Error.cshtml を書き換えてください。

修正後のコードは以下の通りです。(修正は POST 側のみで GET 側は不要です)

// POST: /Account/Login
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Login(LoginViewModel model, 
                                      string returnUrl)
{
    if (!ModelState.IsValid)
    {
        return View(model);
    }

    // メール確認がされてない場合はログインできないようにする
    var user = await UserManager.FindByNameAsync(model.Email);
    if (user != null)
    {
        if (!await UserManager.IsEmailConfirmedAsync(user.Id))
        {
            ViewBag.errorMessage = 
                "You must have a confirmed email to log on.";

            // チュートリアルに従い Views/Shared/Error.cshtml 
            // を書き換えないと上のエラーメッセージは表示され
            // ないので注意
            return View("Error");
        }
    }

    var result = await SignInManager
                 .PasswordSignInAsync(model.Email, 
                                      model.Password, 
                                      model.RememberMe, 
                                      shouldLockout: false);
    switch (result)
    {
        case SignInStatus.Success:
            return RedirectToLocal(returnUrl);
        case SignInStatus.LockedOut:
            return View("Lockout");
        case SignInStatus.RequiresVerification:
            return RedirectToAction("SendCode", 
                new { ReturnUrl = returnUrl, 
                      RememberMe = model.RememberMe });
        case SignInStatus.Failure:
        default:
            ModelState.AddModelError("", "無効なログイン試行です。");
            return View(model);
    }
}

次はユーザーがパスワードを忘れてしまった場合の Password Recovery の実装ですが、この記事で続けると長くなりすぎるので別の記事「Password Recovery の実装 (MVC5)」に書きます。

Tags: , ,

MVC

ロールに属するユーザー一覧を表示 (CORE)

by WebSurfer 2021年2月13日 11:19

ASP.NET Core MVC アプリでユーザー認証・承認を行うのに ASP.NET Core Identity を利用するケースで、ロールにアサインされたユーザー一覧を表示するサンプルを書きます。

ロールに属するユーザー一覧の表示

先の記事「ASP.NET Identity のロール管理 (CORE)」で、管理者がロールの表示・追加・変更・削除を行うためのサンプルを紹介しましたが、それの Details アクションメソッド / ビューに実装してみます。(先の記事では Details は未実装でした)

(1) ビューモデルの追加

コントローラーからビューに渡すモデルとして UsersInRole クラスを追加します。

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

// 追加
using Microsoft.AspNetCore.Identity;
using MySQLIdentity.Areas.Identity.Data;

namespace MySQLIdentity.Models
{
    // ・・・中略(先の記事のコードと同じ)・・・

    // 追加
    public class UsersInRole
    {
        public IdentityRole Role { set; get; }
        public IList<MySQLIdentityUser> Users { set; get; }
    }
}

(2) Details アクションメソッドを追加

先の記事のコントローラー RoleController にアクションメソッド Details を追加します。

using System.Collections.Generic;
using System.Linq;
// ・・・中略(先の記事のコードと同じ)・・・

namespace MySQLIdentity.Controllers
{
    public class RoleController : Controller
    {
        // ・・・中略(先の記事のコードと同じ)・・・

        // 以下のアクションメソッドを追加
        public async Task<IActionResult> Details(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var role = await _roleManager.FindByIdAsync(id);
            if (role == null)
            {
                return NotFound();
            }

            var model = new UsersInRole
            {
                Role = role,
                Users = await _userManager.GetUsersInRoleAsync(role.Name)
            };

            return View(model);
        }

        // ・・・中略(先の記事のコードと同じ)・・・
    }
}

(3) ビュー Details.cshtml を追加

@model MySQLIdentity.Models.UsersInRole

@{
    ViewData["Title"] = "Details";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>Details</h1>

<h4>Users in Role "@Model.Role.Name"</h4>

<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Users.First().UserName)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Users.First().HandleName)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Users.First().PhoneNumber)
            </th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.Users)
        {
            <tr>

                <td>
                    @Html.DisplayFor(modelItem => item.UserName)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.HandleName)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.PhoneNumber)
                </td>
            </tr>
        }
    </tbody>
</table>

<p>
    @Html.ActionLink("Edit", "Edit", new { id = Model.Role.Id }) |
    @Html.ActionLink("Back to List", "Index")
</p>

一覧表示画面 Index の Details リンクをクリックし、Member ロールに属するユーザー一覧を表示したのが上の画像です。

Tags: , , ,

CORE

ASP.NET Core Identity タイムアウト判定

by WebSurfer 2020年10月31日 18:18

先の記事「ASP.NET Identity タイムアウト判定」の Core 3.1 版です。(先の記事は .NET Framework 版です。Core 版とはかなりの部分で異なります)

有効期限切れの通知

ASP.NET Identity の認証システムは、基本的に旧来のフォーム認証と同様で、ログイン後はクッキーに認証チケットを入れて要求毎にサーバーに送信するので認証状態が維持されるという仕組みになっています。

一旦認証を受けたユーザーがログオフせずブラウザを立ち上げたままタイムアウトとなって、再度どこかのページを要求したとすると、要求したページが匿名アクセスを許可してなければ、ログインページにリダイレクトされます。

その時、認証チケットが期限切れになった(認証されているか否かではありません)というのを、どのようすれば判定できるかということを書きます。

Web サーバー側では以下の条件で判定できるはずです。

  • 要求 HTTP ヘッダーに認証クッキーが含まれる。
  • 認証クッキーの中の認証チケットが期限切れ。

Web サーバーが認証クッキーを取得できれば、それを復号して認証チケットを取得し、チケットの中の有効期限の日時の情報を取得できます。それで上記の 2 つの条件を確認できます。

認証クッキーを復号する方法ですが、旧来のフォーム認証の場合とはもちろん、.NET Framework 版の ASP.NET Identity の場合ともかなり異なっています。

そのあたりはググって調べた stackoverflow の記事 How to decrypt .AspNetCore.Identity.Application cookie in ASP.NET Core 3.0?How to manually decrypt an ASP.NET Core Authentication cookie? の回答を参考にさせていただきました。

それを先の記事と同様に HTTP モジュールとして実装してみようと思いましたが、Core には HTTP モジュールも HTTP ハンドラも web.config もないとのこと。

.NET Framework の ASP.NET の HTTP モジュールに代わるものが ASP.NET Core ではミドルウエアになるらしいです。なので、以下の Microsoft のドキュメントを参考に、ミドルウェアを作ってみました。

以下がそのミドルウェアのコードです。上の画像がこのミドルウェアにより認証チケットの期限切れをチェックして Login 画面上でユーザーに通知したものです。

ただし、ユーザーへの通知を下のコードの最後の方にあるように HttpResponse.WriteAsync を使って書き込んだ場合、見かけは上の画像のようになりますが、実は <html></html> タグの外(この例では最後)に書き込まれています。また、上の記事「ASP.NET Core のミドルウェア」の警告に書いてありますが、next.Invoke の前後で応答本文に書き込むというのはそもそもやってはいけないことのようです。

今回はミドルウェアに実装してしまいましたが、ユーザーへの通知を行うならミドルウェアでなく Login ページに下のコードを実装して期限切れを判定し、所定の位置に書き込むようにする方がよさそうです。

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
using System.Security.Principal;

namespace MvcCoreApp.Middleware
{
    public class AuthExpireCheckMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly CookieAuthenticationOptions _options;

        // コンストラクタの引数を以下のように設定しておくことにより
        // CookieAuthenticationOptions を DI 機能により取得できる
        public AuthExpireCheckMiddleware(
            RequestDelegate next,
            IOptions<CookieAuthenticationOptions> options)
        {
            _next = next;
            _options = options.Value;
        }

        public async Task InvokeAsync(HttpContext context)
        {
            // ここでの処理は必要ないので即後続のミドルウェアに処理を渡す。
            // 応答がクライアントに送信された後に _next.Invoke を呼び出すと
            // 例外がスローされるそうなので注意(ここで Response.WriteAsync
            // は書けない)

            await _next.Invoke(context);

            // 認証チケットの期限が切れた後、認証が必要なページにアクセス
            // して Login ページにリダイレクトされた時のみ対応。
            // "/Identity/Account/Login" は Visual Studio のテンプレートで
            // プロジェクトを作った際のデフォルト。必要あれば変更可
            if (context.Request.Path.ToString().
                                     Contains("/Identity/Account/Login"))
            {
                // 認証クッキーが送られてくる時のみ対応。クッキー名
                // .AspNetCore.Identity.Application はデフォルト。
                // 必要あれば変更可
                string cookie 
                    = context.Request.Cookies[".AspNetCore.Identity.Application"];
                if (!string.IsNullOrEmpty(cookie))
                {
                    IDataProtectionProvider provider = 
                        _options.DataProtectionProvider;
                    IDataProtector protector = provider.CreateProtector(
                        "Microsoft.AspNetCore.Authentication.Cookies." +
                        "CookieAuthenticationMiddleware",
                        "Identity.Application",
                        "v2");

                    // 認証クッキーから暗号化された認証チケットを復号
                    TicketDataFormat format = new TicketDataFormat(protector);
                    AuthenticationTicket authTicket = format.Unprotect(cookie);

                    // ユーザー名を取得
                    ClaimsPrincipal principal = authTicket.Principal;
                    IIdentity identity = principal.Identity;
                    string userName = identity.Name;

                    // 認証チケットの有効期限の日時を取得
                    AuthenticationProperties property = authTicket.Properties;
                    DateTimeOffset? expiersUrc = property.ExpiresUtc;

                    // ExpiresUtc と現在の時刻を比較して期限切れか否かを判定
                    if (expiersUrc.Value < DateTimeOffset.UtcNow)
                    {    
                        // ユーザーに通知
                        // 以下のコードでは <html></html> 外に書き込まれるので注意
                        await context.Response.WriteAsync("<h3><font color=red>" +
                            userName +
                            " さん、認証チケットの有効期限が切れています" +
                            "</font></h3>");
                    }
                }
            }
        }
    }
}

注1

上のコードのように DI 機能により CookieAuthenticationOptions オブジェクトを取得できます。それをベースに復号に必要な TicketDataFormat オブジェクトを取得し、認証クッキーから認証チケットを復号しています。

Visual Studio のテンプレートを使って「個別のユーザーアカウント」を認証に選んで作成したプロジェクトでは以下のコードが Startup.cs 含まれています。その場合、コンストラクタの引数に IOptions<CookieAuthenticationOptions> options を含めるだけで DI 機能が働きます。コントローラーのコンストラクタでも同じです。

services.AddDefaultIdentity<IdentityUser>(options => 
    options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();

上の設定が Startup.cs に含まれない場合、上に紹介した stackoverflow の記事 How to decrypt .AspNetCore.Identity.Application cookie in ASP.NET Core 3.0? にあるようなサービスの登録が必要です、

注2

IDataProtectionProvider.CreateProtector の引数ですが、これは .NET Framework の MachineKey.Protect / MachineKey.Unprotect の第 2 引数と同様に暗号化テキストを特定の目的にロックするためのもののようです。

上の紹介した stackoverflow の記事に少し記述があります。Microsoft のドキュメントでは「ASP.NET アプリ間での認証の共有」に記載があるのを見つけました。

注3

上に書いたミドルウェアが動くようにするには、Startup.cs の Configure メソッドでの登録が必要です。その具体例は以下の通りです。場所は既存の app.UseEndpoints の直前で良いです。

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{

    // ・・・中略・・・

    // これを追記
    app.UseMiddleware<MvcCoreApp.Middleware.AuthExpireCheckMiddleware>();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllerRoute(
            name: "default",
            pattern: "{controller=Home}/{action=Index}/{id?}");

        endpoints.MapRazorPages();
    });
}

注4

上にも書きましたが、next.Invoke の前後で応答本文に書き込むというのはそもそもやってはいけないことのようですし(特に next.Invoke の前はエラーになるとのこと)、 <html></html> タグの外に書き込まれてしまうので、ユーザーへの通知を行うならミドルウェアではなく Login ページ内で行うべきと思います。

認証に「個別のユーザーアカウント」を選んでプロジェクトを作成すると Login, Register 他の認証関係の機能は Razor Class Library (RCL) として提供され、ソースコードはプロジェクトには含まれないというところが問題ですが、スキャフォールディング機能を利用して Login ページをオーバーライドすることで対応できそうです。

Tags: , , ,

Authentication

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年4月  >>
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar