WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

ページャー付き一覧画面から編集後同じページに戻る (MVC5)

by WebSurfer 7. November 2022 14:19

ASP.NET MVC アプリのプロジェクトで、スキャフォールディング機能を使うと、DB の CRUD 操作を行う Controller と View のコードを一式自動生成することができます。

そのレコード一覧の表示のページにページング機能を実装し、例えばページ 5 を表示してから Edit リンクをクリックして編集ページに遷移し、DB の UPDATE 完了で元の一覧ページにリダイレクトされる際、同じページ 5 が表示されるようにする機能を実装してみました。以下に要点を備忘録として残しておきます。

ページャー付き Index

この記事で紹介するのは Visual Studio 2022 のテンプレートで、ターゲットフレームワーク .NET Framework 4.8 として作成した ASP.NET MVC5 アプリです。ASP.NET Core MVC アプリでも同様なことは可能です。

まず、ページング機能ですが、Microsoft のドキュメント「チュートリアル: 並べ替え、フィルター処理、ページングを追加する - ASP.NET MVC と EF Core」で紹介されている PaginatedList.cs を利用して実装します。そのコードはこの記事にも載せておきます。

一覧ページにページングを実装する場合、アクションメソッドで当該ページ部分のレコードを含む PaginatedList<T> のオブジェクトを作成し、それを View に Model として渡すようにします。

PaginatedList<T> オブジェクトからは PageIndex プロパティで現在のページ番号を取得できます。そのページ番号を、「一覧ページ」⇒「編集ページ」⇒「一覧ページ」と遷移していく際に渡していくことで、最初と最後の「一覧ページ」が同じページになるようにしました。その手順は概略以下の通りです。

  1. 一覧ページから編集ページに遷移するには、上の画像にある[Edit]リンクボタンをクリックして編集ページのアクションメソッドを GET 要求しますが、その際、クエリ文字列で現在のページ番号を渡せるようにします。ページ番号は Model.PageIndex で取得できるので、@Html.ActionLink のパラメータに pageIndex = Model.PageIndex を追加すれば OK です。
  2. 編集ページのアクションメソッドでクエリ文字列からページ番号を取得し、ViewBag を使って編集ページの View にページ番号を渡します。
  3. 編集ページの View の form タグ内に隠しフィールドを追加し、それに ViewBag で受け取ったページ番号を保存します。
  4. 編集ページでユーザーが編集を完了し[Save]ボタンをクリックすると [HttpPost] 属性が付与された方の編集ページのアクションメソッドが呼び出されます。その際、隠しフィールドに保存されたページ番号も一緒に送信されてきます。
  5. 編集ページのアクションメソッドで DB の UPDATE が完了すると一覧ページにリダイレクトされるので、クエリ文字列でページ番号を渡せるように、RedirectToAction メソッドの第 2 引数に new { pageNumber = pageIndex } というようにパラメータを追加します。
  6. 一覧ページがリダイレクトにより GET 要求されますが、クエリ文字列でページ番号が指定されるので、指定されたページを表示します。

以下にこの記事の検証に使ったサンプルコードを載せておきます。

コンテキストクラス、エンティティクラス

Microsoft のサンプルデータベース Northwind から Visual Studio の ADO.NET Entity Data Model ウィザードを使って作成した Entity Data Model に含まれるものを使いました。参考までに自動生成されたダイアグラムの Products, Categories, Supliers テーブル部分の画像を下に貼っておきます。

Entity Data Model

PaginatedList.cs

上に紹介した Microsoft のチュートリアルのコードと同じですが、少しコメントを加えて以下にアップしておきます。

このクラスがページングの中核を担うもので、コントローラーが生成した IQueryable<Product> オブジェクトを CreateAsync メソッドで受け取って、Skip, Take メソッドでページに表示する部分のみをデータベースから取得し、PaginatedList<Product> オブジェクトを生成して戻り値として返すようにしています。

using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using System.Web;

namespace Mvc5App2.Models
{
    public class PaginatedList<T> : List<T>
    {
        // 表示するページのページ番号
        public int PageIndex { get; private set; }

        // 全ページ数
        public int TotalPages { get; private set; }

        // コンストラクタ。下の CreateAsync メソッドから呼ばれる
        public PaginatedList(List<T> items,
                             int count,
                             int pageIndex,
                             int pageSize)
        {
            PageIndex = pageIndex;
            TotalPages = (int)Math.Ceiling(count / (double)pageSize);

            this.AddRange(items);
        }

        // 表示するページの前にページがあるか?
        public bool HasPreviousPage
        {
            get
            {
                return (PageIndex > 1);
            }
        }

        // 表示するページの後にページがあるか?
        public bool HasNextPage
        {
            get
            {
                return (PageIndex < TotalPages);
            }
        }

        // 下の静的メソッドがコントローラーから呼ばれて戻り値がモデルとして
        // ビューに渡される。引数の pageSize は 1 ページに表示するレコード
        // 数でコントローラーから渡される
        public static async Task<PaginatedList<T>> CreateAsync(IQueryable<T> source,
                                                               int pageIndex,
                                                               int pageSize)
        {
            var count = await source.CountAsync();
            var items = await source.Skip((pageIndex - 1) * pageSize)
                                     .Take(pageSize)
                                     .ToListAsync();

            return new PaginatedList<T>(items, count, pageIndex, pageSize);
        }
    }
}

Controller / Action Method

以下のコードのアクションメソッドは一覧ページ用の Pager と編集ページ用の EditPaging のみで他は省略していますので注意してください。

using System;
using System.Data;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using System.Net;
using System.Web.Mvc;
using Mvc5App2.Models;

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

        // 引数の pageNumber が表示するページ
        public async Task<ActionResult> Pager(int? pageNumber)
        {
            // IDENTITY で主キーの ProductID 順に並べる
            var products = db.Products
                           .Include(p => p.Categories)
                           .OrderBy(p => p.ProductID);

            // 1 ページに表示するレコード数を指定
            int pageSize = 5;

            // CreateAsync メソッドで pageNumber に指定されるページの
            // レコードのリストを取得
            return View(await PaginatedList<Products>
                              .CreateAsync(products.AsNoTracking(),
                                           pageNumber ?? 1,
                                           pageSize));
        }

        // クエリ文字列で渡されるページ番号を取得するため引数に
        // pageIndex を追加
        public async Task<ActionResult> EditPaging(int? id, int? pageIndex)
        {
            if (id == null)
            {
                return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
            }
            Products products = await db.Products.FindAsync(id);
            if (products == null)
            {
                return HttpNotFound();
            }

            // View に現在のページ番号を渡す
            ViewBag.PageIndex = pageIndex ?? 1;

            ViewBag.CategoryID = new SelectList(db.Categories, 
                                                "CategoryID",
                                                "CategoryName",
                                                products.CategoryID);
            ViewBag.SupplierID = new SelectList(db.Suppliers,
                                                "SupplierID",
                                                "CompanyName",
                                                products.SupplierID);
            return View(products);
        }

        // クエリ文字列で渡されるページ番号を取得するため引数に
        // pageIndex を追加
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<ActionResult> EditPaging(
            [Bind(Include = "ProductID,ProductName,SupplierID,CategoryID," +
            "QuantityPerUnit,UnitPrice,UnitsInStock,UnitsOnOrder," +
            "ReorderLevel,Discontinued")] Products products,
            int pageIndex)
        {
            if (ModelState.IsValid)
            {
                db.Entry(products).State = EntityState.Modified;
                await db.SaveChangesAsync();

                // リダイレクトの際クエリ文字列でページ番号を渡せるよう第 2 引数
                // に new { pageNumber = pageIndex } を追加
                return RedirectToAction("Pager",
                                        new { pageNumber = pageIndex });
            }
            ViewBag.CategoryID = new SelectList(db.Categories,
                                                "CategoryID",
                                                "CategoryName",
                                                products.CategoryID);
            ViewBag.SupplierID = new SelectList(db.Suppliers,
                                                "SupplierID",
                                                "CompanyName",
                                                products.SupplierID);
            return View(products);
        }

        // ・・・中略・・・

        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                db.Dispose();
            }
            base.Dispose(disposing);
        }
    }
}

一覧ページ用の View

上に書いたように、[Edit]リンクボタン用の @Html.ActionLink のパラメータに pageIndex = Model.PageIndex を追加しているところに注目してください。table 要素の下にページャーも実装しています。

@model Mvc5App2.Models.PaginatedList<Products>

@{
    ViewBag.Title = "Pager";
}

<h2>Pager</h2>

<p>
    <a asp-action="Create">Create New</a>
</p>

<table class="table">
    <thead>
        <tr>
            <th>
                Id
            </th>
            <th>
                Product Name
            </th>
            <th>
                Category
            </th>
            <th>
                Unit Price
            </th>
            <th>
                Discontinued
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.ProductID)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.ProductName)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Categories.CategoryName)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.UnitPrice)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Discontinued)
                </td>

                <td>
                    @Html.ActionLink("Edit", "EditPaging", 
                        new { id = item.ProductID, 
                              pageIndex = Model.PageIndex }) |
                    @Html.ActionLink("Details", "Details", 
                        new { id = item.ProductID }) |
                    @Html.ActionLink("Delete", "Delete", 
                        new { id = item.ProductID })
                </td>
            </tr>
        }
    </tbody>
</table>

@*Pagination
    https://getbootstrap.jp/docs/4.2/components/pagination/*@

@{
    // ページャーの First Prev 1 2 3 ... n Next Last の 1 ~ n のボタン数
    // n は奇数にしてください
    int buttonCount = 7;
}

<span>Page @Model.PageIndex of @Model.TotalPages</span>

<br />

<nav aria-label="Page navigation">
    <ul class="pagination">
        @if (Model.HasPreviousPage)
        {
            <li class="page-item">
                @Html.ActionLink("First", "Pager", 
                    new { pageNumber = 1, 
                          @class = "page-link" })
            </li>
        }

        @if (Model.HasPreviousPage)
        {
            <li class="page-item">
                @Html.ActionLink("Prev", "Pager", 
                    new { pageNumber = (Model.PageIndex - 1), 
                          @class = "page-link" })
            </li>
        }

        @{
            int startPage;
            int stopPage;

            if (Model.TotalPages > buttonCount)
            {
                if (Model.PageIndex <= buttonCount / 2 + 1)
                {
                    startPage = 1;
                    stopPage = buttonCount;
                }
                else if (Model.PageIndex < (Model.TotalPages - buttonCount / 2))
                {
                    startPage = Model.PageIndex - buttonCount / 2;
                    stopPage = Model.PageIndex + buttonCount / 2;
                }
                else
                {
                    startPage = Model.TotalPages - buttonCount + 1;
                    stopPage = Model.TotalPages;
                }
            }
            else
            {
                startPage = 1;
                stopPage = Model.TotalPages;
            }

            for (int i = startPage; i <= stopPage; i++)
            {
                if (Model.PageIndex == i)
                {
                    <li class="page-item active">
                        <span class="page-link">@i</span>
                    </li>
                }
                else
                {
                    <li class="page-item">
                        @Html.ActionLink(i.ToString(), "Pager", 
                            new { pageNumber = i, 
                                  @class = "page-link" })
                    </li>
                }
            }
        }

        @if (Model.HasNextPage)
        {
            <li class="page-item">
                @Html.ActionLink("Next", "Pager", 
                    new { pageNumber = (Model.PageIndex + 1), 
                          @class = "page-link" })
            </li>
        }

        @if (Model.HasNextPage)
        {
            <li class="page-item">
                @Html.ActionLink("Last", "Pager", 
                    new { pageNumber = (Model.TotalPages), 
                          @class = "page-link" })
            </li>
        }
    </ul>
</nav>

編集ページ用の View

以下の画像の赤枠で囲ったコードを追加した以外はスキャフォールディングで生成されるコードと同じです。前者の赤枠部分は隠しフィールドにページ番号を保存するためのもの、後者は編集を途中で止めて一覧ページに戻る際、同じページに戻るためのものです。

編集ページ用の View への追加コード

Tags: , , ,

Paging

ASP.NET MVC5 で Autofac.Mvc5 使って DI

by WebSurfer 25. October 2022 17:56

.NET Framework 4.8 の ASP.NET MVC5 アプリで Autofac.Mvc5 を利用して Dependency Injection (DI) 機能を実装してみました。忘れないように備忘録として残しておきます。

Autofac.MVC5

Visual Studio のテンプレートで作る ASP.NET MVC5 プロジェクトには DI 機能は実装されていません。Microsoft のドキュメント「ASP.NET MVC と ASP.NET Core での依存関係の挿入の相違点」にサードパーティ製の Autofac が紹介されていましたので使ってみました。

(ASP.NET Core で DI に使われている Microsoft.Extensions.DependencyInjection 名前空間にあるクラス類は .NET Framework 4.6.1 以降であれば利用できるそうなので、そちらを使うことを考えた方がいいかもしれません)

ベースとした ASP.NET MVC5 アプリは、先の記事「スキャフォールディング機能」に書いたものと同じです。Microsoft のサンプル SQL Server データベース Northwind から Entity Data Model (EDM) を作り、スキャフォールディング機能を使って Create, Read, Update, Delete (CRUD) 操作を行う Controller と View を一式自動生成しています。

スキャフォールディング機能で自動生成されたコードに手を加えてリポジトリパターンを使うように変更し、下の画像の「本番用クラス」とそれが使う EDM のコンテキストクラスを DI 機能を使って Inject できるようにしてみます。

リポジトリパターン

自動生成される Controller のコードは、内部で以下のようにコンテキストクラス NORTHWINDEntities のインスタンスを生成し、それを使って Linq to Entities で SQL Server にアクセスして操作するコードがハードコーディングされています。まず、その部分のコードを「本番用クラス」に切り出します。

using System;
using System.Data;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using System.Net;
using System.Web.Mvc;

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

        // GET: Products
        public async Task<ActionResult> Index()
        {
            var products = db.Products
                           .Include(p => p.Categories)
                           .Include(p => p.Suppliers);
            return View(await products.ToListAsync());
        }

        // ・・・中略・・・

    }
}

上の図の「インターフェイス」は IProductRepository という名前で以下のようにしました。Controller には Index, Details, Create, Edit, Delete アクションメソッドがありますので、IProductRepository にはそれらが使うメソッドをすべて定義しています。非同期操作を行うので戻り値は Task<T> としています。

using System.Collections.Generic;
using System.Threading.Tasks;

namespace Mvc5AppAutofac.Models
{
    public interface IProductRepository
    {
        Task<IEnumerable<Products>> GetProducts();
        Task<Products> GetProductById(int id);
        Task<IEnumerable<Categories>> GetCatagories();
        Task<IEnumerable<Suppliers>> GetSuppliers();
        Task<int> CreateProduct(Products product);
        Task<int> UpdateProduct(Products product);
        Task<int> DeleteProduct(int id);
    }
}

上の図の「本番用クラス」は上の IProductRepository インターフェイスを継承し、ProductRepository という名前で以下のようにしました。コンテキストクラス NORTHWINDEntities は DI 機能を使ってコンストラクタ経由で Inject することを考えています。

using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.Threading.Tasks;

namespace Mvc5AppAutofac.Models
{
    public class ProductRepository : IProductRepository, 
                                     IDisposable
    {
        private readonly NORTHWINDEntities db;

        // Dispose パターンの実装のための変数
        private bool disposedValue;

        public ProductRepository(NORTHWINDEntities db)
        {
            this.db = db;
        }

        public async Task<IEnumerable<Products>> GetProducts()
        {
            var products = db.Products
                           .Include(p => p.Categories)
                           .Include(p => p.Suppliers);
            return await products.ToListAsync();
        }        

        public async Task<Products> GetProductById(int id)
        {
            Products product = await db.Products.FindAsync(id);
            return product;
        }

        public async Task<IEnumerable<Categories>> GetCatagories()
        {
            var categgories = db.Categories;
            return await categgories.ToListAsync();
        }

        public async Task<IEnumerable<Suppliers>> GetSuppliers()
        {
            var suppliers = db.Suppliers;
            return await suppliers.ToListAsync();
        }

        public async Task<int> CreateProduct(Products product)
        {
            db.Products.Add(product);
            return await db.SaveChangesAsync();
        }

        public async Task<int> UpdateProduct(Products product)
        {
            db.Entry(product).State = EntityState.Modified;
            return await db.SaveChangesAsync();
        }

        public async Task<int> DeleteProduct(int id)
        {
            Products products = await db.Products.FindAsync(id);
            db.Products.Remove(products);
            return await db.SaveChangesAsync();
        }

        // 以下は Dispose パターンの実装
        protected virtual void Dispose(bool disposing)
        {
            if (!disposedValue)
            {
                if (disposing)
                {
                    if (db != null)
                    {
                        db.Dispose();
                    }
                }

                disposedValue = true;
            }
        }

        public void Dispose()
        {
            Dispose(disposing: true);
            GC.SuppressFinalize(this);
        }
    }
}

上の ProductRepository クラスは IDisposable インターフェイスも継承していますが、その理由を以下に説明します。

ProductRepository クラスは、DI 操作によりコンストラクタ経由で NORTHWINDEntities クラスのインスタンスへの参照を受け取り、それを変数 db に保持します。NORTHWINDEntities クラスは DbContext クラスを継承しており、DbContext クラスは IDisposable インターフェイスを継承していますので、使い終わったら Dispose する必要があります。

そのために、ProductRepository クラスには IDisposable インターフェイスも継承させ、Dispose パターンを実装してその中で NORTHWINDEntities オブジェクトを Dispose するようにしています。

ProductRepository クラスの Dispose メソッドは、Autofac のドキュメント Disposal の Automatic Disposal のセクションに書いてあるように、DI 機能により生成されたインスタンスの lifetime の終わりに自動的に呼び出されるそうです。デバッガを使って実際に呼び出されることは確認できました。

自動生成された Controller のコードを、DI 機能を利用してコンストラクタ経由で上の ProductRepository クラスのインスタンスへの参照を受け取れるように変更し、ProductRepository クラスに実装されたメソッドを使って SQL Server にアクセスして必要な操作ができるように書き換えます。

using System.Threading.Tasks;
using System.Net;
using System.Web.Mvc;
using Mvc5AppAutofac.Models;

namespace Mvc5AppAutofac.Controllers
{
    public class ProductsController : Controller
    {
        private readonly IProductRepository rep;

        public ProductsController(IProductRepository rep)
        {
            this.rep = rep;
        }

        // GET: Products
        public async Task<ActionResult> Index()
        {
            return View(await rep.GetProducts());
        }

        // ・・・中略・・・

    }
}

最後に、この記事の一番上の画像の Autofac.Mvc5 v6.1.0 を NuGet からインストールし、その DI 機能が働くように設定します。。

そのためには、Controller, ProductRepository, NORTHWINDEntities を DI コンテナに含めて初期化し、ASP.NET に登録する必要があります。具体的には、Global.asax にある既存の Application_Start メソッドに「Autofac.Mvc5 による DI を行うため以下のコードを追加」とコメントした下のコードを追加します。

using Autofac;
using Autofac.Integration.Mvc;
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;
using Mvc5AppAutofac.Models;

namespace Mvc5AppAutofac
{
    public class MvcApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);


            // Autofac.Mvc5 による DI を行うため以下のコードを追加

            // DI コンテナを作成するビルダのインスタンスを生成
            var builder = new ContainerBuilder();

            // アセンブリをスキャンしてすべての Controller を DI コン
            // テナに登録。下のコードの MvcApplication は Global.asax
            // のクラス名。スコープを指定しない場合はデフォルトの
            // InstancePerDependency になるらしい
            builder.RegisterControllers(typeof(MvcApplication).Assembly)
                   .InstancePerRequest();

            // ProductRepository クラスと NORTHWINDEntities クラスを
            // DI コンテナに登録。スコープを指定しない場合はデフォル
            // トの InstancePerDependency になる
            builder.RegisterType<ProductRepository>()
                   .As<IProductRepository>()
                   .InstancePerRequest();

            builder.RegisterType<NORTHWINDEntities>()
                   .InstancePerRequest();

            // DI コンテナの生成
            var container = builder.Build();

            // DI コンテナを ASP.NET に登録
            DependencyResolver.SetResolver(
                new AutofacDependencyResolver(container));
        }
    }
}

設定の説明は上のコードに付与したコメントを見てください。詳細が必要でしたら Autofac のドキュメント MVC を見てください。

ASP.NET Core に組み込みの DI 機能には DI により生成されたインスタンスの lifetime を、DI コンテナの登録する際に AddTransient, AddScoped, AddSingleton の 3 種類のメソッドを使って設定できますが、それと同様な機能は Autofac にもあります。詳しくは Autofac のドキュメント Instance Scope を見てください。

上のコード例では InstancePerRequest に設定していますが、それは ASP.NET Core 組み込みの DI 機能では AddScoped に相当します。これにより、要求ごとに DI コンテナからインスタンスが生成され、応答を返すと廃棄されます。廃棄される際、上の ProductsController クラスに実装した Dispose メソッドが呼び出されます。

以上により、ASP.NET が Controller のインスタンスを作る際 DI 機能が働いて、自動的に ProductRepository, NORTHWINDEntities クラスのインスタンスが生成され、それらへの参照がコンストラクタ経由で inject されます。

Tags: , ,

MVC

MVC5 アプリで Google Authenticator による 2FA

by WebSurfer 4. November 2021 15:27

先の記事「MVC5 アプリに 2 要素認証を実装」で SMS と Email を利用した 2 要素認証の実装に関する記事を書きました。この記事にはそれに認証アプリ Google Authenticator App を利用した 2 要素認証を追加する話を書きます。

Google Authenticator

認証アプリは Google Authenticator App でなければならないという訳ではなく、TOTP (Time-based One-time Password Algorithm) ベースの認証アプリであれば Microsoft Authenticator App なども使えるそうですが、今回は Google Authenticator App を使ってみました。

まず、TOTP というのがどういう仕組みかですが、ITmedia NEWS の記事「SMS認証の仕組みと危険性、「TOTP」とは? 「所有物認証」のハナシ」が分かりやすいと思いますので見てください。リンク切れになると困るので図だけ借用して下に貼っておきます。

Time-based One-time Password Algorithm

今回の ASP.NET MVC5 アプリのケースで説明します。まず、ASP.NET MVC5 アプリ(上の図で「認証サーバ」と書いてあるもの)が発行する秘密キーを Google Authenticator App(上の図で「ソフトウェアトークン」と書いてあるもの)に取得させ共有します。秘密キーは ASP.NET MVC5 アプリに登録されたユーザー毎に異なる 32 文字の文字列です。Google Authenticator App は、そのユーザーが保有するスマートフォンにインストールされていることが前提です。

この記事の一番上にある画像は ASP.NET MVC5 アプリが表示した画面で、それからスマートフォンの Google Authenticator App に秘密キーを共有させます。表示されている QR コードをスキャンするか、「3. QR コードが読めない場合」の下に表示されている 32 文字を手入力することによって共有できます。

その機密キー(上の図で「シード」と書いてあるもの)と時刻を元に、TOTP アルゴリズムで 6 桁の数字のパスワードを計算します。時刻を計算に使うところが Time-based ということだそうです。

さらに 30 秒ごとに計算し直すので、パスワードは 30 秒ごとに違った数字になります。それが One-time ということだそうです。

Google Authenticator App はスタンドアロンで動いています。ASP.NET MVC5 アプリ他どことも通信していませんが秘密キーとスマートフォンの内部時刻でパスワードは計算できます。

秘密キーは共有しているので、ASP.NET MVC5 アプリと Google Authenticator App が取得する時刻が正確に一致していれば、計算で求めたパスワードは両方で同じになります。

という訳で、2 要素認証が必要になっている ASP.NET MVC5 アプリから TOTP ベースのパスワードを求められたら、Google Authenticator App を立ち上げて表示されたパスワードを入力すればログインできるという仕組みになっています。

TOPT ベースの 2 要素認証は Visual Studio のテンプレートで作った MVC5 プロジェクトには実装されておらず、ゼロから作っていくことになります。先人の例がないかググって調べてみましたら、Using Google Authenticator with ASP.NET Identity という記事(以下、参考記事と言います)がありましたので参考にさせていただきました。

以下に実装手順を書きます。参考記事には書いてないが必須な実装は補足・追記しました。また、QR コードがスキャンできない場合の対応など参考記事とは違う実装にしている点もあります。

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

プロジェクトの作成方法は先の記事「MVC5 アプリに 2 要素認証を実装」を見てください。それに Google Authenticator App 利用の 2 要素認証を追加で実装します。

まず、TOTP 用の秘密キーの生成やユーザーが入力したパスワードの検証を行うためのライブラリ OtpSharp を NuGet からインストールします。Base32 という NuGet パッケージも必要ですが一緒にインストールされるはずです。

OtpSharp をインストール

(2) ApplicationUser クラスにプロパティ追加

Models/IdentityModel.cs ファイルの ApplicationUser クラスに以下の 2 つのプロパティを追加します。

public class ApplicationUser : IdentityUser
{
    // Google Authnticator による 2FA を実装するため追加
    public bool IsGoogleAuthenticatorEnabled { get; set; }
    public string GoogleAuthenticatorSecretKey { get; set; }

    // 以下は既存のコード
    public async Task<ClaimsIdentity> GenerateUserIdentityAsync(
                           UserManager<ApplicationUser> manager)
    {
        var userIdentity = await manager.CreateIdentityAsync(this, 
                    DefaultAuthenticationTypes.ApplicationCookie);
        return userIdentity;
    }
}

Migration 操作によって既存の SQL Server データベースの AspNetUsers テーブルに同名の列が追加されます。アプリを実行して Google Authenticator を有効にすると、IsGoogleAuthenticatorEnabled 列は True になり、GoogleAuthenticatorSecretKey には秘密キーが自動生成され格納されます。

(3) トークンプロバイダの作成

Google Authenticator を使った 2 要素認証用のプロバイダを作成します。(SMS と Email を使った 2 要素認証用のプロバイダは既存です)

IUserTokenProvider<TUser, TKey> インターフェイスを継承した GoogleAuthenticatorTokenProvider クラスを以下のように作成します。クラスファイルは MfaTokenProvider というフォルダを作ってそこに置きました。

using MVC5TwoFactorAuth.Models;
using Microsoft.AspNet.Identity;
using System.Threading.Tasks;
using OtpSharp;
using Base32;

namespace MVC5TwoFactorAuth.MfaTokenProvider
{
    public class GoogleAuthenticatorTokenProvider 
        : IUserTokenProvider<ApplicationUser, string>
    {
        public Task<string> GenerateAsync(string purpose, 
                                          UserManager<ApplicationUser, string> manager, 
                                          ApplicationUser user)
        {
            return Task.FromResult((string)null);
        }

        public Task<bool> IsValidProviderForUserAsync(UserManager<ApplicationUser, string> manager, 
                                                      ApplicationUser user)
        {
            return Task.FromResult(user.IsGoogleAuthenticatorEnabled);
        }

        public Task NotifyAsync(string token, 
                                UserManager<ApplicationUser, string> manager, 
                                ApplicationUser user)
        {
            return Task.FromResult(true);
        }

        public Task<bool> ValidateAsync(string purpose, 
                                        string token, 
                                        UserManager<ApplicationUser, string> manager, 
                                        ApplicationUser user)
        {
            long timeWindowUsed = 0;

            var otp = new Totp(Base32Encoder.Decode(user.GoogleAuthenticatorSecretKey));

            bool valid = otp.VerifyTotp(token, 
                                        out timeWindowUsed, 
                                        new VerificationWindow(previous: 1, future: 1));

            return Task.FromResult(valid);
        }
    }
}

ValidateAsync メソッドで使われている OtpSharp ライブラリの説明は GitHub の記事Otp.NET にあります。

VerifyTotp メソッドの第 1 引数 token はユーザーが Google Authenticator App より取得して入力した 6 桁の数字のパスワード、第 2 引数 timeWindowUsed は検証が行われた回数で、RFC によると 1 回だけにすべきということで、カウントして必要に応じて何かするためのもののようです。

第 3 引数は network delay に関係するもののようですが説明を読んでも分かりませんでした。(汗) 参考記事では new VerificationWindow(2, 2) となっていましたが、RFC 推奨は "one time step delay" ということだそうですので GitHub の記事の通りにしておきました。

GenerateAsync メソッド、NotifyAsync メソッドは今回の実装では使わないということで参考記事の通りにしておきました。(これらのメソッドが呼ばれたら異常事態ということで throw NotImplemantationException() として自爆した方が良かったかもしれません)

(4) GoogleAuthenticatorTokenProvider を登録

上のステップ (3) で作成したプロバイダを登録します。 登録すると、フレームワークがプロバイダに実装されたメソッドを使ってユーザーが入力したパスワードの検証などを行うようです。

App_Start/IdentityConfig.cs ファイルの ApplicationUserManager クラスの Create メソッドに既存のプロバイダ PhoneNumberTokenProvider と EmailTokenProvider を登録するコードがありますので、その下に以下のコードを追加します。

manager.RegisterTwoFactorProvider("GoogleAuthenticator", 
    new GoogleAuthenticatorTokenProvider());

(5) IndexViewModel クラスにプロパティ追加

Models/ManageViewModel.cs ファイルの IndexViewModel クラスに以下のプロパティを追加します。

public class IndexViewModel
{
    public bool HasPassword { get; set; }
    public IList<UserLoginInfo> Logins { get; set; }
    public string PhoneNumber { get; set; }
    public bool TwoFactor { get; set; }
    public bool BrowserRemembered { get; set; }

    // Google Authnticator による 2FA を実装するため追加
    public bool IsGoogleAuthenticatorEnabled { get; set; }
}

さらに、ManageController の Index アクションメソッドに IndexViewModel の IsGoogleAuthenticatorEnabled プロパティを設定するコードを追加します。参考記事には書いてないので注意してください。

var model = new IndexViewModel
{
    HasPassword = HasPassword(),
    PhoneNumber = await UserManager.GetPhoneNumberAsync(userId),
    TwoFactor = await UserManager.GetTwoFactorEnabledAsync(userId),
    Logins = await UserManager.GetLoginsAsync(userId),
    BrowserRemembered = await AuthenticationManager
                              .TwoFactorBrowserRememberedAsync(userId),

    // Google Authnticator による 2FA を実装するため追加
    IsGoogleAuthenticatorEnabled = (await UserManager.FindByIdAsync(userId))
                                   .IsGoogleAuthenticatorEnabled
};

(6) 有効化/無効化を操作するコードを View に追加

Views/Manage/Index.cshtml ファイルの下の方(最後の <dl> 要素の直前)に、Google Authenticator の有効化/無効化を切り替えるための以下のコードを追加します。

<dt>Google Authentication:</dt>
<dd>
    @if (Model.IsGoogleAuthenticatorEnabled)
    {
        using (Html.BeginForm("DisableGoogleAuthenticator", "Manage",
            FormMethod.Post, new { @class = "form-horizontal", role = "form" }))
        {
            @Html.AntiForgeryToken()
            <text>有効
                <input type="submit" value="無効化" class="btn btn-link" />
            </text>
        }
    }
    else
    {
        <text>無効
            @Html.ActionLink("有効化", "EnableGoogleAuthenticator")
        </text>
    }
</dd>

(7) GoogleAuthenticatorViewModel クラスを追加

Models/ManageViewModel.cs ファイルに GoogleAuthenticatorViewModel クラスを追加を追加します。参考記事には書いてないので注意してください。

public class GoogleAuthenticatorViewModel
{
    public string SecretKey { get; set; }
    public string BarcodeUrl { get; set; }

    [Required]
    [Display(Name = "コード")]
    public string Code { get; set; }
}

(8) アクションメソッドの追加

Controllers/ManageController.cs の ManageController に、上のステップ (6) で View に追加した submit ボタンと ActionLink の受信先のアクションメソッド EnableGoogleAuthenticator と DisableGoogleAuthenticator を追加します。

using 句に OtpSharp, Base32, System.Text を追加するのを忘れないようにしてください。

//
// GET: /Manage/DisableGoogleAuthenticator
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> DisableGoogleAuthenticator()
{
    var user = await UserManager
                     .FindByIdAsync(User.Identity.GetUserId());

    if (user != null)
    {
        user.IsGoogleAuthenticatorEnabled = false;
        user.GoogleAuthenticatorSecretKey = null;
        await UserManager.UpdateAsync(user);
        await SignInManager.SignInAsync(user, 
                                        isPersistent: false, 
                                        rememberBrowser: false);
    }

    return RedirectToAction("Index", "Manage");
}

//
// GET: /Manage/EnableGoogleAuthenticator
public ActionResult EnableGoogleAuthenticator()
{
    byte[] secretKey = KeyGeneration.GenerateRandomKey(20);
    string userName = User.Identity.GetUserName();

    string authenticatorUriFormat = 
        "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";

    string authnticatorUri = string.Format(
        authenticatorUriFormat,
        HttpUtility.UrlEncode("MVC5TwoFactorAuth"),  // issuer
        HttpUtility.UrlEncode(userName),
        Base32Encoder.Encode(secretKey));

    // QR コードが読めなかった場合の共有キーの表示
    string keyCode = Base32Encoder.Encode(secretKey);
    ViewBag.KeyCode = FormatKey(keyCode);

    var model = new GoogleAuthenticatorViewModel
    {
        SecretKey = keyCode,
        BarcodeUrl = authnticatorUri
    };

    return View(model);
}

//
// POST: /Manage/EnsableGoogleAuthenticator
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> EnableGoogleAuthenticator(
                                   GoogleAuthenticatorViewModel model)
{
    if (ModelState.IsValid)
    {
        byte[] secretKey = Base32Encoder.Decode(model.SecretKey);

        long timeWindowUsed = 0;
        var otp = new OtpSharp.Totp(secretKey);
        if (otp.VerifyTotp(model.Code,
                           out timeWindowUsed,
                           new VerificationWindow(previous: 1, future: 1)))
        {
            var user = await UserManager
                             .FindByIdAsync(User.Identity.GetUserId());
            user.IsGoogleAuthenticatorEnabled = true;
            user.GoogleAuthenticatorSecretKey = model.SecretKey;
            await UserManager.UpdateAsync(user);

            return RedirectToAction("Index", "Manage");
        }
        else
        {
            ModelState.AddModelError("Code", "コードが有効ではありません");
        }
    }

    ViewBag.KeyCode = FormatKey(model.SecretKey);
    return View(model);
}

// 共有キーを 4 文字ずつ分けて表示するためのヘルパーメソッド
private string FormatKey(string unformattedKey)
{
    var result = new StringBuilder();
    int currentPosition = 0;
    while (currentPosition + 4 < unformattedKey.Length)
    {
        result.Append(unformattedKey.Substring(currentPosition, 4))
              .Append(" ");
        currentPosition += 4;
    }
    if (currentPosition < unformattedKey.Length)
    {
        result.Append(unformattedKey.Substring(currentPosition));
    }

    return result.ToString().ToLowerInvariant();
}

参考記事のコードから変更・追加を行っています。主な点は以下の通りです。

  1. QR コードが読めない場合に認証アプリに秘密キーを直接入力できるよう、この記事の一番上の画像にあるように秘密キーを表示するようにしました。4 文字ずつ分けて表示しているのは見やすくするためです。4 文字ずつ分けるためのヘルパーメソッド FormatKey を追加しています。
  2. QR コードの生成・表示は Mictosoft のドキュメント「ASP.NET Core での TOTP authenticator アプリの QR コード生成を有効にする」に書いてある qrcode.js を使う方法に変更しました。
  3. VerifyTotp メソッドの第 3 引数は、上のステップ (3) で述べたように、RFC 推奨は "one time step delay" ということだそうですので GitHub の記事の通りにしておきました。

(9) View を追加

上のステップ (8) で追加した EnableGoogleAuthenticator アクションメソッドに対応するビュー EnableGoogleAuthenticator.cshtml を追加しします。

@model MVC5TwoFactorAuth.Models.GoogleAuthenticatorViewModel

@{
    ViewBag.Title = "Google Authenticator の利用";
}

<h2>Google Authenticator の有効化</h2>

<div class="row">
    <div class="col-md-8">
        <h3>1. Google Authenticator による 2 要素認証の有効化</h3>
        <p>スマートフォンの Google Authenticator アプリを起動して右の 
        QR コードをスキャンしてください。</p>
        <h3>2. QR コードをスキャンして得られた 6 桁の数字を入力</h3>
        <p>
            QR コードをスキャンすることにより得られた 
            6 桁の数字を下の[コード]欄に入力し、
            [有効化]ボタンをクリックしてください。
        </p>
        <h3>3. QR コードが読めない場合</h3>
        <p>
            共有キー: <kbd>@ViewBag.KeyCode</kbd> を Google Authenticator 
            アプリに入力して得られた 6 桁の数字を下の[コード]欄に入力し、
            [有効化]ボタンをクリックしてください。
        </p>
        <hr />
        @using (Html.BeginForm("EnableGoogleAuthenticator", 
            "Manage", 
            FormMethod.Post, 
            new { @class = "form-horizontal", role = "form" }))
        {
            @Html.AntiForgeryToken()
            @Html.HiddenFor(m => m.SecretKey)
            @Html.HiddenFor(m => m.BarcodeUrl)
            <div class="form-group">
                @Html.LabelFor(m => m.Code, 
                       new { @class = "col-md-2 control-label" })
                <div class="col-md-10">
                    @Html.TextBoxFor(m => m.Code, 
                                     new { @class = "form-control" })
                    @Html.ValidationMessageFor(m => m.Code, "", 
                                     new { @class = "text-danger" })
                </div>
            </div>
            <div class="form-group">
                <div class="col-md-offset-2 col-md-10">
                    <input type="submit" class="btn btn-primary" 
                           value="有効化" />
                </div>
            </div>
        }
    </div>
    <div class="col-md-4">
        <div id="qrCode"></div>
        <div id="qrCodeData" data-url="@Html.Raw(Model.BarcodeUrl)"></div>
    </div>
</div>

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

    <script src="~/Scripts/qrcode.js"></script>
    <script src="~/Scripts/qr.js"></script>
}

上のステップ (8) で述べましたように QR コードの生成・表示は qrcode.js を使う方法に変更しました。

(10) Migration の実施

Visual Studio のパッケージマネージャーコンソールで Add-Migration GoogleAuth, Update-Database を実行します。SQL Server データベースの AspNetUsers テーブルに IsGoogleAuthenticatorEnabled 列と GoogleAuthenticatorSecretKey 列が追加されていることを確認してください。


以上で Google Authenticator を使った 2 要素認証機能は動くようになるはずです。簡単に検証手順を書いておくと:

自分のスマートフォンに Google Authenticator App をインストールします。

MVC5 アプリを実行し、画面右上の[登録]をクリックしてユーザー登録を行ってください。登録に成功すると登録したユーザーでログイン済になるはずです。

画面の右上に「ようこそ・・・」と表示されているはずですが、それをクリックすると以下のように Manage/Index 画面に遷移します。

Manage/Index 画面

まず上の画面の「2 要素認証:」の[有効化]をクリックしてそのユーザーの 2 要素認証を有効にします。その操作で AspNetUsers テーブル の TwoFaxtorEnabled 列が True になります。それをしないと、下の「Google Authenticator」で[有効化]操作を行っても 2 要素認証は働かないので注意してください。

「Google Authenticator」の[有効化]をクリックするとこの記事の一番上の画像の Manage/EnableGoogleAuthenticator 画面に遷移しますので、その画面に書いてある通り、スマートフォンの Google Authenticator App を立ち上げて、QR コードをスキャンするか表示されている共有キーを手入力します。

それによりスマートフォンの Google Authenticator App に 6 桁の数字のパスワードが表示されます。 それを[コード]欄に入力して[有効化]ボタンをクリックすれば MVC5 アプリでそのユーザーの Google Authenticator を使っての 2 要素認証が有効になります。

その後、一旦ログオフしてからログインして 2 要素認証が有効になっているか試します。画面右上の[ログイン]をクリックしてログイン画面で[電子メール]と[パスワード]欄に正しく入力して[ログイン]の欄をクリックすると以下の Account/SendCode 画面に遷移します。

Account/SendCode 画面

そこに表示されているドロップダウンリストにはそのユーザーに対して有効になっている 2 要素認証の項目が表示されます。[GoogleAuthentication]を選択して、[送信]ボタンをクリックすると、以下のように Account/VerifyCode 画面に遷移します。(赤枠の部分は自分が便宜上追加したものでオリジナルのコードには含まれませんので注意))

Account/VerifyCode 画面

スマートフォンの Google Authenticator App を開いてそれに表示されている 6 桁の数字のパスワードを上の画面の[コード]欄に入力して[送信]ボタンをクリックするとログインできます。

Tags: , , , ,

MVC

About this blog

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

Calendar

<<  March 2023  >>
MoTuWeThFrSaSu
272812345
6789101112
13141516171819
20212223242526
272829303112
3456789

View posts in large calendar