先の記事「ASP.NET Core Identity 独自実装(その1)」の続きです。
先の記事では ASP.NET Core Identity のユーザー認証に用いるデータソースとプロバイダを独自実装し、ユーザー情報の表示、追加、変更、削除が可能になるところまでは確認できました。この記事では Register, Login, Logout 機能を実装してみます。

認証に「個別のユーザーアカウント」を選んでプロジェクトを作成した場合 Razor Class Library (RCL) として Login, Register 等の機能は提供され、ソースコードも入手することができます。そのコードを参考に Register, Login, Logout 用のコントローラーとビューを実装しました。
ユーザー認証の動きは、普通に ASP.NET Identity の「個別のユーザーアカウント」を選んで実装したときと同じで、以下のようになります。
初期画面では、上の画像のように、ユーザーがログインしてないときは Register, Login へのリンクを表示します。ページ右上の Login をクリックまたはアクセス制限がかかっているページを呼び出すとログイン画面に遷移します。

有効なユーザー名とパスワードを入力して [Login] ボタンをクリックすると、以下の Fiddler の画像の通り認証クッキーが発行されます。

この例では、アクションメソッドに [Authorize] を付与してアクセス制限がかかっている Home/Pricacy を要求したので、応答ヘッダの Location に /Home/Privacy が設定されています。
Location の設定により /Home/Privacy ページにリダイレクトされます。その際、認証クッキーも送られますのでユーザーは認証され、以下のように /Home/Privacy 画面が表示されます。

どこでどのように実現しているのか分かりませんが、アクセス制限がかかっているページからログインページへの自動リダイレクトや認証クッキーの発行などは自力で実装しなくてもフレームワークが面倒を見てくれるようです。他には以下のことを自動的に行ってくれるのを確認しました。
-
登録時のユーザー名とパスワードの検証は、ビューモデルのプロパティに付与したアノテーション属性に加えて、Microsoft のドキュメントの「Configure Identity services」のセクションのコードにある Password settings と User settings の設定が有効になります。
-
同じユーザー名の二重登録の防止機能も組み込まれているようです。例えば、Register ページで既に登録済みの Surfer というユーザー名を登録しようとすると User name 'Surfer' is already taken. というエラーメッセージが出て登録に失敗します。(DB にユニーク制約は付けてないのですが・・・)
-
パスワードは自動的にハッシュされ、生のパスワードが DB に保存されることはありません。ハッシュのアルゴリズムは、参考にした記事の Customize ASP.NET Core Identity によると、PBKDF2 with HMAC-SHA256, 128-bit salt, 256-bit subkey, 10000 iterations になるそうです。既存の DB にユーザー情報があるなどで、ハッシュのアルゴリズムが上記と違う場合は問題ですが、IPasswordHasher を継承したカスタム PasswordHasher を作成してサービスとして Startup.cs に登録することで対応できるようです。
-
永続化機能も働きます。上のログイン画面で[このアカウントを記憶する]にチェックを入れると、応答ヘッダに含まれる認証クッキーの Set-Cookie: に expires 属性が追加されます。有効期限は 2 週間先になっていました。有効期限は Startup.cs の ConfigureServices メソッドに以下のような設定を追加することで変更できます。
services.ConfigureApplicationCookie(options =>
{
options.ExpireTimeSpan = TimeSpan.FromMinutes(5); // 5 分に設定
});
最後に、この記事で実装した Register, Login, Logout 関係のコードを以下に書いておきます。
_LoginPartial.cshtml
ページの右上にログイン状態を表示するためのパーシャルビュー _LoginPartial.cshtml を作成し、それを _Layout.schtml に配置します。コードは以下の通りです。「個別のユーザーアカウント」を選んで作ったプロジェクトのものをコピーして、それに手を加えました。
ログインすると表示されるユーザー名のリンク先は、「個別のユーザーアカウント」を選んで作ったプロジェクトの場合は管理用のページ Manage になるのですが、この記事では単にユーザー一覧が表示されるだけの Account/Index アクションメソッドにしています。
@using Microsoft.AspNetCore.Identity
@inject SignInManager<User> SignInManager
@inject UserManager<User> UserManager
<ul class="navbar-nav">
@if (SignInManager.IsSignedIn(User))
{
<li class="nav-item">
<a id="manage" class="nav-link text-dark" asp-action="Index"
asp-controller="Account" title="Manage">
Hello @UserManager.GetUserName(User) !
</a>
</li>
<li class="nav-item">
<form id="logoutForm" class="form-inline" asp-action="Logout"
asp-controller="Account"
asp-route-returnUrl="@Url.Action("Index", "Home", new { area = "" })">
<button id="logout" type="submit" class="nav-link btn btn-link text-dark">
Logout
</button>
</form>
</li>
}
else
{
<li class="nav-item">
<a class="nav-link text-dark" id="register" asp-action="Register"
asp-controller="Account">Register</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" id="login" asp-action="Login"
asp-controller="Account">Login</a>
</li>
}
</ul>
ビューモデル
Login と Register 用のビューモデルを作成します。MVC で言う Model で、コントローラとビューの間のデータのやり取りに使います。
using System.ComponentModel.DataAnnotations;
namespace MvcIdCustom.Models
{
// AccountController の Login 用
public class LoginViewModel
{
[Required(ErrorMessage = "{0}は必須です。")]
[Display(Name = "ユーザー名")]
[StringLength(100, ErrorMessage =
"{0}は{2}から{1}文字の範囲で設定してください。",
MinimumLength = 6)]
public string UserName { get; set; }
[Required(ErrorMessage = "{0}は必須です。")]
[StringLength(100, ErrorMessage =
"{0}は{2}から{1}文字の範囲で設定してください。",
MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "パスワード")]
public string Password { get; set; }
[Display(Name = "このアカウントを記憶する")]
public bool RememberMe { get; set; }
}
// AccountController の Register 用
public class RegisterViewModel
{
[Required(ErrorMessage = "{0}は必須です。")]
[Display(Name = "ユーザー名")]
[StringLength(100, ErrorMessage =
"{0}は{2}から{1}文字の範囲で設定してください。",
MinimumLength = 6)]
public string UserName { get; set; }
[Required(ErrorMessage = "{0}は必須です。")]
[StringLength(100, ErrorMessage =
"{0}は{2}から{1}文字の範囲で設定してください。",
MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "パスワード")]
public string Password { get; set; }
[DataType(DataType.Password)]
[Display(Name = "パスワード確認")]
[Compare("Password",
ErrorMessage = "確認パスワードが一致しません。")]
public string ConfirmPassword { get; set; }
}
}
コントローラ / アクションメソッド
アクションメソッド Index へはログインするとページ右上に表示されるユーザー名からリンクが張られています。アクションメソッド Register, Login, Logout を、上に述べた _LoginPartial.cshtml のリンクから呼び出すことができます。
なお、Login ページについては、手動でリンクをクリックしなくとも、アクセス制限されているページを匿名ユーザーが要求した場合は自動的にリダイレクトされます。
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Identity;
using MvcIdCustom.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authorization;
namespace MvcIdCustom.Controllers
{
public class AccountController : Controller
{
private readonly UserManager<User> _userManager;
private readonly SignInManager<User> _signInManager;
public AccountController(UserManager<User> userManager,
SignInManager<User> signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
}
public async Task<IActionResult> Index()
{
// using Microsoft.EntityFrameworkCore;
var users = await _userManager.Users.
OrderBy(user => user.UserName).ToListAsync();
return View(users);
}
// GET: Account/Login/ReturnUrl
// Model は Models/UserViewModel.cs の LoginViewModel
[AllowAnonymous]
public IActionResult Login(string returnUrl)
{
returnUrl = returnUrl ?? Url.Content("~/");
ViewBag.ReturnUrl = returnUrl;
return View();
}
// POST: Account/Login/ReturnUrl
[AllowAnonymous]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(string returnUrl,
[Bind("UserName,Password,RememberMe")] LoginViewModel model)
{
returnUrl = returnUrl ?? Url.Content("~/");
if (ModelState.IsValid)
{
var result = await _signInManager.
PasswordSignInAsync(model.UserName,
model.Password,
model.RememberMe,
lockoutOnFailure: false);
if (result.Succeeded)
{
return LocalRedirect(returnUrl);
}
else
{
ModelState.AddModelError(string.Empty, "無効なログイン");
return View(model);
}
}
return View(model);
}
// POST: Account/Logout
[AllowAnonymous]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout(string returnUrl)
{
await _signInManager.SignOutAsync();
// _LayoutPartial.cshtml の form 要素の
// asp-route-returnUrl="@Url.Action("Index", "Home", new { area = "" })
// により returnUrl は "/" になる。
if (returnUrl != null)
{
return LocalRedirect(returnUrl);
}
else
{
return RedirectToAction("Index", "Home");
}
}
// GET: Account/Register
// Model は Models/UserViewModel.cs の RegisterViewModel
[AllowAnonymous]
public IActionResult Register(string returnUrl)
{
returnUrl = returnUrl ?? Url.Content("~/");
ViewBag.ReturnUrl = returnUrl;
return View();
}
// POST: Account/Register
[AllowAnonymous]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Register(string returnUrl,
[Bind("UserName,Password,ConfirmPassword")] RegisterViewModel model)
{
returnUrl = returnUrl ?? Url.Content("~/");
if (ModelState.IsValid)
{
var user = new User { UserName = model.UserName };
var result = await _userManager.CreateAsync(user, model.Password);
if (result.Succeeded)
{
await _signInManager.SignInAsync(user, isPersistent: false);
return LocalRedirect(returnUrl);
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
}
return View(model);
}
}
}
ビュー (Login.cshtml)
Login アクションメソッド用のビューのコードのみ載せておきます。他はスキャフォールディング機能で生成したものをほぼそのまま使えますので省略します。
タグヘルパー form の asp-route-returnUrl 属性に "@ViewBag.ReturnUrl" を設定しているところに注目してください。アクセス制限がかかっているページにアクセスすると、そのページの URL が設定され、ログイン後 URL に設定されたページが表示されるようになっています。
@model MvcIdCustom.Models.LoginViewModel
@{
ViewData["Title"] = "Login";
}
<h1>Login</h1>
<h4>LoginViewModel</h4>
<hr />
<div class="row">
<div class="col-md-4">
<form asp-action="Login" asp-controller="Account"
asp-route-returnUrl="@ViewBag.ReturnUrl">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group">
<label asp-for="UserName" class="control-label"></label>
<input asp-for="UserName" class="form-control" />
<span asp-validation-for="UserName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Password" class="control-label"></label>
<input asp-for="Password" class="form-control" />
<span asp-validation-for="Password" class="text-danger"></span>
</div>
<div class="form-group form-check">
<label class="form-check-label">
<input class="form-check-input" asp-for="RememberMe" />
@Html.DisplayNameFor(model => model.RememberMe)
</label>
</div>
<div class="form-group">
<input type="submit" value="Login" class="btn btn-primary" />
</div>
</form>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}