WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

Email Confirmation の実装 (MVC5)

by WebSurfer 16. March 2021 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

EF6 で PK / Unique 制約違反例外をキャッチ

by WebSurfer 8. March 2021 18:08

EF6 を利用して SQL Server のテーブルの CRUD 操作を行う ASP.NET MVC5 アプリで、新規レコードの Create の際に Primary / Unique キー制約違反例外をキャッチし、エラーメッセージを表示する方法を書きます。

Primary / Unique キー制約の検証

ADO.NET + SqlClient を使って SqlCommand.ExecuteNonQuery メソッドで INSERT 操作を行う場合は、例外が発生すると SqlException がスローされますので、try - catch 句でそれを補足して SqlException.Number プロパティを調べれば例外の内容が分かります。ちなみに、2627 が Primary キー制約違反、2601 が Unique キー制約違反になります。

しかしながら、EF6 の DbContext.SaveChanges メソッドで INSERT 操作を行う場合、スローされる例外は DbUpdateException, DbUpdateConcurrencyException などで、SqlException を直接補足することができません。

そこをどうするかですが、ググって調べた記事「EF6とSQL ServerでUniqueKey違反の例外をキャッチするにはどうすればよいですか?」によると、try - catch 句で DbUpdateException を補足し、その InnerException の InnerException から SqlException を取得できるとのことです。

そういうことなので、上の記事の 2 つ目の回答を参考に、Primary / Unique キー制約違反例外をキャッチし、エラーメッセージを表示する機能を実装してみました。

それが以下のコードです。上の画像は SQL Server のテーブルの ProductName 列に付与した Unique キー制約違反を補足してエラーメッセージを表示したものです。

エンティティクラス (Model)

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace Mvc5App2.Models
{
    public class ProductModel
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.None)]
        public int ProductId { get; set; }

        [Required]
        [StringLength(128)]
        [Index(IsUnique = true)]
        public string ProductName { get; set; }

        [Required]
        public decimal UnitPrice { get; set; }
    }
}

コンテキストクラス

using Mvc5App2.Models;
using System.Data.Entity;
using System.Data.Entity.ModelConfiguration.Conventions;

namespace Mvc5App2.DAL
{
    public class ProductContext : DbContext
    {
        public ProductContext() : base("name=DefaultConnection")
        {
        }

        public DbSet<ProductModel> Products { get; set; }

        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();
        }
    }
}

上のエンティティクラスとコンテキストクラスから、EF6 Code First の機能を利用して生成された SQL Server のテーブルの構造は以下の通りです。

生成されたテーブルの構造

Controller / Action Method

スキャフォールディング機能を利用して生成した controller のコードの中の Create アクションメソッドに、上に紹介した記事の Primary / Unique キー制約違反例外を補足するコードを実装したものです。

using System.Data.Entity;
using System.Threading.Tasks;
using System.Net;
using System.Web.Mvc;
using Mvc5App2.DAL;
using Mvc5App2.Models;
using System.Data.Entity.Infrastructure;
using System.Data.SqlClient;

namespace Mvc5App2.Controllers
{
    public class ProductModelsController : Controller
    {
        private ProductContext db = new ProductContext();

        // ・・・中略・・・

        // GET: ProductModels/Create
        public ActionResult Create()
        {
            return View();
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<ActionResult> Create(
                [Bind(Include = "ProductId,ProductName,UnitPrice")] 
                ProductModel productModel)
        {
            if (ModelState.IsValid)
            {
                db.Products.Add(productModel);

                try
                {
                    await db.SaveChangesAsync();
                }
                catch (DbUpdateException e)
                when (e.InnerException?.InnerException is SqlException sqlEx &&
                        (sqlEx.Number == 2601 || sqlEx.Number == 2627))
                {
                    if (sqlEx.Number == 2627)
                    {
                        ModelState.AddModelError("ProductId", "PK 制約違反");
                    }

                    if (sqlEx.Number == 2601)
                    {
                        ModelState.AddModelError("ProductName", "Unique 制約違反");
                    }

                    return View(productModel);
                }
                return RedirectToAction("Index");
            }

            return View(productModel);
        }
    }

    // ・・・中略・・・
}

View

スキャフォールディング機能を利用して生成した create.cshtml のコードのままで手は加えていません。

@model Mvc5App2.Models.ProductModel

@{
    ViewBag.Title = "Create";
}

<h2>Create</h2>

@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()

    <div class="form-horizontal">
        <h4>ProductModel</h4>
        <hr />
        @Html.ValidationSummary(true, "", new { @class = "text-danger" })
        <div class="form-group">
            @Html.LabelFor(model => model.ProductId,
                htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.ProductId,
                    new { htmlAttributes = new { @class = "form-control" } })
                @Html.ValidationMessageFor(model => model.ProductId, "",
                    new { @class = "text-danger" })
            </div>
        </div>

        <div class="form-group">
            @Html.LabelFor(model => model.ProductName,
                htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.ProductName,
                    new { htmlAttributes = new { @class = "form-control" } })
                @Html.ValidationMessageFor(model => model.ProductName, "",
                    new { @class = "text-danger" })
            </div>
        </div>

        <div class="form-group">
            @Html.LabelFor(model => model.UnitPrice,
                htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.UnitPrice,
                    new { htmlAttributes = new { @class = "form-control" } })
                @Html.ValidationMessageFor(model => model.UnitPrice, "",
                    new { @class = "text-danger" })
            </div>
        </div>

        <div class="form-group">
            <div class="col-md-offset-2 col-md-10">
                <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </div>
    </div>
}

<div>
    @Html.ActionLink("Back to List", "Index")
</div>

@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")
}

Primary / Unique キー両方に制約違反がある場合は Primary キーの制約違反だけしか補足できません。両方補足して両方のエラーメッセージを表示する方法は今のところ分かりません。今後の課題ということで。

Tags: , , ,

MVC

MVC5 でのロールによるアクセス制限

by WebSurfer 12. October 2020 15:05

.NET Framework 版の ASP.NET MVC5 アプリで、ユーザーはログインしているが、アクセス権がないコントローラーまたはアクションメソッドにアクセスした場合、以下のようなメッセージを表示する方法を書きます。(Core 3.1 版でのデフォルトです)

AccessDenied

ASP.NET Identity ベースのユーザー認証に、先の記事「ASP.NET Identity のロール管理 (MVC)」のようにしてロールを実装していることが条件です。

.NET Framework 版の ASP.NET MVC5 アプリではコントローラーやアクションメソッドに AuthorizeAttribute 属性を付与し、名前付きパラメータ Roles プロパティにアクセスを許可するロール名を指定することにより、指定されたロールに属するユーザー以外のアクセスを制限することができます。

ただし、デフォルトでは、ユーザーがログイン済みでも指定されたロールに属さない場合、ロールで制限されたページにアクセスすると Login ページにリダイレクトされます。ログイン済なのに Login ページに飛ばされるというのがユーザーフレンドリーではない感じです。

Core 3.1 版の ASP.NET MVC アプリでは、ユーザーがログイン済みだが指定された ロールに属さない場合、Login ページではなくて、上の画像のようなアクセス権がないというメッセージを表示するページにリダイレクトされます。

Core 3.1 版の方がユーザーフレンドリーのように思いますので、.NET Framework 版の MVC5 でも同様なことができないかを考えてみました。方法は以下の 2 つがありそうです。

  1. AuthorizeAttribute クラスを継承したカスタム認証フィルターを定義し、OnAuthorization メソッドを override して使う。
  2. FilterAttribute, IAuthorizationFilter を継承したカスタム認証フィルターを自作してそれを利用する。  

後者の方法で作ったカスタム認証フィルターのコードを以下にアップしておきます。(AccessDenied アクションメソッドとビューの追加も必要ですので忘れないようにしてください。追加しないと 404 エラーになります)

using System;
using System.Web;
using System.Web.Mvc;

namespace Mvc5App2.Filters
{
    public class RoleAuthFilterAttribute : 
        FilterAttribute, IAuthorizationFilter
    {
        private string role;

        public RoleAuthFilterAttribute(string role)
        {
            this.role = role;
        }
        
        public void OnAuthorization(AuthorizationContext filterContext)
        {
            if (filterContext == null)
            {
                throw new ArgumentNullException("filterContext");
            }

            if (!HttpContext.Current.Request.IsAuthenticated)
            {
                string path = "/Account/Login?returnUrl=" + 
                              HttpContext.Current.Request.RawUrl;
                filterContext.Result = new RedirectResult(path);
            }
            else 
            {
                if (!HttpContext.Current.User.IsInRole(this.role))
                {
                    filterContext.Result = 
                        new RedirectResult("/Account/AccessDenied");
                }
            }
        }
    }
}

ASP.NET Identity ベースのユーザー認証にロール管理を実装した場合、ユーザーが認証されているか否か、指定されたロールに属しているか否かはフレームワーク組み込みのプロパティ/メソッドを利用して判定することができます。上のコードの IsAuthenticated プロパティ、IsInRole メソッドがそれです。

独自認証の場合はそのあたりは自力で実装することになります。かなり大変かも。

上のコードの認証フィルターの使い方の例は下の画像の通りです。

認証フィルターの使い方

Visual Studio 2019 のテンプレートを使って作った Home/Contact アクションメソッドに認証フィルター属性を付与して Administrator 以外のアクセスを制限しています。

Tags: , ,

MVC

About this blog

2010年5月にこのブログを立ち上げました。その後 ブログ2 を追加し、ここは ASP.NET 関係のトピックス、ブログ2はそれ以外のトピックスに分けました。

Calendar

<<  October 2021  >>
MoTuWeThFrSaSu
27282930123
45678910
11121314151617
18192021222324
25262728293031
1234567

View posts in large calendar