ASP.NET Core で空のプロジェクトからスキャフォールディングを使わずに Identity を実装してみる

12 min read読了の目安(約10800字

ASP.NET Core 需要ってあまりない気がするので暫くは Qiita にも同じ記事を載せておきます。

はじめに

認証・認可の仕組みは ASP.NET Core の認証ありテンプレートやスキャフォールディングを使ってなんとなく使っていましたが、頭を整理するために空のプロジェクトからできるだけ自力でIdentityを導入してみました。

この記事はデモ用なのでメールの確認もしていないし、パスワードの制約も緩くしています。
また、認証・認可の専門家ではないので、色々考慮できていない部分もあるかと思います。

空のプロジェクトを作成

先ずはテンプレートから「ASP.NET Core(空)」を選択

実行して https://localhost:5001/ にアクセスすると Hello World! が出ます。

ここまでのコードはこちら

https://github.com/tatsuteb/IdentityFromEmpty/tree/v0.1
https://github.com/tatsuteb/IdentityFromEmpty/releases/tag/v0.1

MVCの導入とルートの保護

ビューの作成

次に https://localhost:5001/[コントローラ名]/[アクション名] でアクセスできるようにしていきます。

StartUp.cs の ConfigureServices にコントローラ・ビューサービスを追加します。

StartUp.cs
services.AddControllersWithViews();

次に、Configure の UseEndpoints 内で行っているルーティングの設定を以下のように変更します。

StartUp.cs
endpoints.MapDefaultControllerRoute();

これでコントローラとビューを使ってページが作れるようになったので、ホーム画面とProtectedView画面を作成します。
ProtectedView画面はログインしているときだけアクセスできる画面になる予定です。

Views/Home フォルダに Index.cshtml、ProtectedView.cshtml を作成します。

Views/Home/Index.cshtml
<h2>Home</h2>
Views/Home/ProtectedView.cshtml
<h2>Protected View</h2>

この後、タグヘルパーを使う予定なので _ViewImports.cshtml を用意してインポートしておきます。

Views/_ViewImports.cshtml
@addTagHelper *,Microsoft.AspNetCore.Mvc.TagHelpers

常に表示されるグローバルヘッダーはレイアウトに置きたいので、_Layout.cshtml を用意して _ViewStart.cs に設定しておきます。
@RenderBody() にはURLに応じてビューが展開されます。

Views/Shared/_Layout.cshtml
<header>
    <nav>
        <h1>AppWithIdentity</h1>
        <a asp-controller="Home" asp-action="Index">Home</a>
        <a asp-controller="Home" asp-action="ProtectedView">ProtectedView</a>
    </nav>
</header>

<main>
    @RenderBody()
</main>
Views/_ViewStart.cshtml
@{
    Layout = "_Layout";
}

Controllers フォルダに HomeController を作成して各ビューに対応したメソッドを用意します。

Controllers/HomeController.cs
public IActionResult Index()
{
    return View();
}

public IActionResult ProtectedView()
{
    return View();
}

実行してアクセスできることを確認します。

ホーム画面

ProtectedView画面

ProtectedView を保護

https://localhost:5001/Home/ProtectedView へのアクセスを保護してみます。

HomeController の ProtectedView に Authorize 属性を付けて動作を見てみます。

Controllers/HomeController.cs
[Authorize]
public IActionResult ProtectedView()
{
    return View();
}

Authorization サービスを有効にしてくれ的なエラーが出て、アクセスできなくなりました。

ここまでのコードはこちら

https://github.com/tatsuteb/IdentityFromEmpty/tree/v0.2
https://github.com/tatsuteb/IdentityFromEmpty/releases/tag/v0.2

Identity を導入

先のエラーメッセージに従って、StartUp.cs で Authorization を追加して再度アクセスしてみます。
app.UseAuthorization() は app.UseRouting() と app.UseEndpoints() の間に書きます。

StartUp.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
	app.UseDeveloperExceptionPage();
    }

    app.UseRouting();

    app.UseAuthorization(); // 追加

    app.UseEndpoints(endpoints =>
    {
	endpoints.MapDefaultControllerRoute();
    });
}

認証の仕方が分からない的なエラーが出ました。
ここからIdentityを導入していきます。

先ずはIdentityで使うDBを用意します。今回はデモなので手間を省くためインメモリで使えるデータベースを使います。
Data フォルダに IdentityDbContext<IdentityUser> を継承した AppIdentityDbContext クラスを作成します。

Data/AppIdentityDbContext.cs
public class AppIdentityDbContext : IdentityDbContext<IdentityUser>
{
    public AppIdentityDbContext(DbContextOptions options) : base(options)
    {
    }
}

作成したデータベースコンテキストを StartUp.cs の ConfigureServices で DI して使えるようにします。

StartUp.cs
services.AddDbContext<AppIdentityDbContext>(options =>
    options.UseInMemoryDatabase("identityDb"));

次に Identity サービスを追加します。
今回はデモということで制約を緩めに設定しています。
また、ログインパスなどはデフォルト設定ですが、変えたい場合は ConfigureApplicationCookie で変えることができます。

StartUp.cs
services.AddIdentity<IdentityUser, IdentityRole>(options =>
{
    // デモ用に制約を緩めに設定
    if (_environment.IsDevelopment())
    {
	options.User.RequireUniqueEmail = true;

	options.Password.RequireDigit = false;
	options.Password.RequireLowercase = false;
	options.Password.RequireUppercase = false;
	options.Password.RequireNonAlphanumeric = false;
    }

    // その他いろいろ設定できる
    // options.SignIn.RequireConfirmedAccount = true;
    // options.SignIn.RequireConfirmedEmail = true;
    // options.SignIn.RequireConfirmedPhoneNumber = true;
})
.AddDefaultTokenProviders()
.AddEntityFrameworkStores<AppIdentityDbContext>();

// ログイン画面等のパスを変えたい場合はここで指定できる
// services.ConfigureApplicationCookie(config =>
// {
//     config.LoginPath = "/Login";
//     config.LogoutPath = "/Logout";
//     config.AccessDeniedPath = "/AccessDenied";
// });

次に StartUp.cs の Configure で UseAuthentication() を呼んで Authentication サービスが使えるようにします。

StartUp.cs
// 省略
app.UseRouting();

app.UseAuthentication(); // 追加
app.UseAuthorization();

app.UseEndpoints(endpoints =>
{
    endpoints.MapDefaultControllerRoute();
});

実行して https://localhost:5001/Home/ProtectedView にアクセスしてみます。

デフォルトのログイン画面 https://localhost:5001/Account/Login にリダイレクトされました。
クエリの ReturnUrl はログイン成功後に移動するURLが自動で設定されています。

この後 UserManager や SignInManager を使ってアカウントの作成、ログイン、ログアウトの仕組みを作っていきます。

ここまでのコードはこちら

https://github.com/tatsuteb/IdentityFromEmpty/tree/v0.3
https://github.com/tatsuteb/IdentityFromEmpty/releases/tag/v0.3

新規登録、ログイン、ログアウト

Views/Account フォルダに Register.cshtml、Login.cshtml を用意して、入力フォームを作成します。
次に、Controllers フォルダに AccountController.cs を作成して、ここに各ビューに対応したメソッドを用意します。
ログアウトは処理の後ホーム画面へ移動させるためビューは用意していません。

コードを乗せると長くなるので、GitHubを確認してみてください。
Views/Account フォルダ
AccountController.cs

次に、ユーザー作成、ログイン、ログアウトの処理を用意します。
ここも長くなるので一部抜粋して載せます。

ユーザーは UserManager の CreateAsync を使って作成できます。

Controllers/AccountController.cs
[HttpPost]
public async Task<IActionResult> Register([FromForm]Register model, string returnUrl)
{
    // 省略
    var createUserResult = await _userManager.CreateAsync(
        user: user,
        password: model.Password);
    // 省略
}

ログインは SignInManager の PasswordSignInAsync を使います。

Controllers/AccountController.cs
[HttpPost]
public async Task<IActionResult> Login([FromForm]Login model, string returnUrl)
{
    // 省略

    var user = await _userManager.FindByEmailAsync(model.Email);

    var signInResult = await _signInManager.PasswordSignInAsync(
        user: user,
        password: model.Password,
        isPersistent: model.RememberMe,
        lockoutOnFailure: false);

    if (!signInResult.Succeeded)
    {
        throw new Exception("ログインに失敗しました。");
    }
	
    // 省略
}

ログアウトは SignInManager の SignOutAsync を使います。

Controllers/AccountController.cs
public async Task<IActionResult> Logout()
{
    await _signInManager.SignOutAsync();

    return RedirectToAction(actionName: "Index", controllerName: "Home");
}

では実行してみます。
先ずはホーム画面。この時点で Identity の Cookie はありません。

ProtectedView をクリックするとログイン画面にリダイレクトされます

アカウントを作っていないので、画面下部の「新規登録する」をクリックして新規登録画面へ移動します

アカウントが作成されると同時に、ProtectedView 画面にリダイレクトされます。
ProtectedView画面が表示されて右上にユーザー名も表示されていることが確認できます。Identity の Cookie も作成されていることが分かります。

画面右上のログアウトをクリックすると Identity の Cookie が削除されます。
ユーザー名も消えて、メニューもログイン前の状態に戻ります。

ここまでのコードはこちら

https://github.com/tatsuteb/IdentityFromEmpty/tree/v0.1
https://github.com/tatsuteb/IdentityFromEmpty/releases/tag/v0.4

最後に

最低限のコードからステップバイステップで Identity 導入したことで少し理解が深まった気がします。

次は IdentityServer4 を使ったシングルサインオンに挑戦してみようと思います。

リポジトリはこちら

https://github.com/tatsuteb/IdentityFromEmpty

番外編

Identity を使わずに保護されたページへアクセスするには HttpContext の SignInAsync をつかいます。

StartUp.cs でクッキーを使った認証サービスを使えるようにします。

StartUp.cs
public void ConfigureServices(IServiceCollection services)
{
    // デモ用に適当なユーザーストアを用意する
    var store = new Dictionary<string, string>();
    services.AddSingleton(store);
    services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
        .AddCookie(options =>
        {
            // ログイン画面等のパスを変えたいときはここで設定する
            // options.AccessDeniedPath = "/AccessDenied";
            // options.LoginPath = "/Login";
            // options.LogoutPath = "/Logout";
        });
	
    // 省略
}
	
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // 省略

    app.UseRouting();

    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
	endpoints.MapDefaultControllerRoute();
    });
}

HttpContext の SignInAsync、SignOutAsync を使ってログイン・ログアウトを行います。
デモ用なので、ユーザーは適当にディクショナリで保持しています。

Controllers/AccountController.cs
[HttpPost]
public async Task<IActionResult> Login([FromForm]LoginModel model, [FromQuery]string returnUrl)
{
    // 省略

    var claims = new List<Claim>
    {
	new Claim(ClaimTypes.Name, model.Email),
	new Claim(ClaimTypes.Email, model.Email),
    };

    var claimsIdentity = new ClaimsIdentity(
	claims, 
	CookieAuthenticationDefaults.AuthenticationScheme);

    var authProperties = new AuthenticationProperties
    {
	IsPersistent = model.RememberMe,
	RedirectUri = returnUrl
    };

    await HttpContext.SignInAsync(
	CookieAuthenticationDefaults.AuthenticationScheme, 
	new ClaimsPrincipal(claimsIdentity), 
	authProperties);

    return LocalRedirect(returnUrl);
}

public async Task<IActionResult> LogOut()
{
    await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

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

実行して ProtectedView をクリックします。
ログイン画面にリダイレクトされるので、「新規登録する」をクリックして新規登録画面へ移動します。

新規登録に成功するとログインして ProtectedView 画面にリダイレクトされます。
Cookie が生成されて、ProtectedView 画面もちゃんと表示されていることが確認できます。

このコードはこちら

https://github.com/tatsuteb/IdentityFromEmpty/tree/feature/AuthWithoutIdentity