ASP.NET Core で ASP.NET Core Identity Provider for Amazon Cognito を使って Cognito ユーザープール認証機能を実装してみた

2023.01.27

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

いわさです。

ASP.NET Core では Web アプリケーションを構築することが出来ますが、AWS でもしホスティングした際には認証部分を Amazon Cognito にオフロードさせたいケースがあります。

AWS から ASP.NET Core Identity Provider for Amazon Cognito というライブラリが提供されており、こちらを導入することで Cognito の認証機能を ASP.NET Core 標準のミドルウェアの仕組みを使いながらアプリケーションへ組み込むことが出来ます。

本記事ではライブラリの導入と、既存ユーザーのサインイン部分を実装してみます。
なお、本記事では .NET 7 を使っています。

% dotnet --version
7.0.101

ASP.NET Core + 標準認証

.NET コマンドでテンプレートからプロジェクトを生成出来ますが、ASP.NET Core のテンプレートで認証機能をつけるオプションがあります。
まずはこちらの標準的な挙動を確認してみます。

次のようにwebappテンプレートに対してauthオプションを指定します。

% dotnet new webapp --no-https --auth Individual
The template "ASP.NET Core Web App" was created successfully.
This template contains technologies from parties other than Microsoft, see https://aka.ms/aspnetcore/7.0-third-party-notices for details.

Processing post-creation actions...
Restoring /Users/iwasa.takahito/work/hoge0127aspcore/hoge0127aspcore.csproj:
  Determining projects to restore...
  Restored /Users/iwasa.takahito/work/hoge0127aspcore/hoge0127aspcore.csproj (in 212 ms).
Restore succeeded.

こちらを実行すると、次のようにサインアップやサインインの機能が初めから組み込まれた状態となっています。

なお、データは実行フォルダのapp.dbに格納されます。
今回は Visual Studio Code をエディターに使っているので、以下の SQLite 拡張機能を使って確認することが出来ます。

標準機能ではローカルデータベースにユーザー情報が保存されていることが確認出来ました。

Cognito ユーザープールを用意する

標準で SQLite を ID ソースにする部分を Cognito ユーザープールを使用するように変更していきます。
まずは事前に Cognito ユーザープールを用意します。

ライブラリ導入後に対象ユーザープールの ID やクライアント情報・シークレットが必要になるためです。

今回は折角なので新しいコンソールを使ってみました。

プロバイダーのタイプは Cognito ユーザープールで、サインインオプションは今回は E メールにしておきます。

Cognito のホストされた UI は今回使用しません。ASP.NET Core の UI を使いたいためです。
後ほど ASP.NET 側の構成値としてクライアント ID やシークレットが必要になるので、クライアントのシークレットは生成しておきます。

ユーザープールの作成後、アプリケーションの統合タブからアプリケーションクライアントのクライアント ID とシークレットを取得しておきます。

また、今回はサインアップ機能までは用意しないので事前にユーザー作っておきましょう。

ASP.NET Core Web アプリケーションに組み込む

ここから実際にアプリケーションへ ASP.NET Core Identity Provider for Amazon Cognito を組み込んでいきます。
まずはプロジェクトへライブラリをインストールする必要があるので、NuGet パッケージマネージャーを使ってインストールを行います。

% dotnet add package Amazon.AspNetCore.Identity.Cognito --version 3.0.0
  Determining projects to restore...
  Writing /var/folders/4d/nhd1bp3d161crsn900wjrprm0000gp/T/tmpiakTxr.tmp
info : X.509 certificate chain validation will use the fallback certificate bundle at '/usr/local/share/dotnet/sdk/7.0.101/trustedroots/codesignctl.pem'.
info : X.509 certificate chain validation will use the fallback certificate bundle at '/usr/local/share/dotnet/sdk/7.0.101/trustedroots/timestampctl.pem'.
info : Adding PackageReference for package 'Amazon.AspNetCore.Identity.Cognito' into project '/Users/iwasa.takahito/work/hoge0127aspcore/hoge0127aspcore.csproj'.
info : Restoring packages for /Users/iwasa.takahito/work/hoge0127aspcore/hoge0127aspcore.csproj...
info : Package 'Amazon.AspNetCore.Identity.Cognito' is compatible with all the specified frameworks in project '/Users/iwasa.takahito/work/hoge0127aspcore/hoge0127aspcore.csproj'.
info : PackageReference for package 'Amazon.AspNetCore.Identity.Cognito' version '3.0.0' added to file '/Users/iwasa.takahito/work/hoge0127aspcore/hoge0127aspcore.csproj'.
info : Generating MSBuild file /Users/iwasa.takahito/work/hoge0127aspcore/obj/hoge0127aspcore.csproj.nuget.g.props.
info : Writing assets file to disk. Path: /Users/iwasa.takahito/work/hoge0127aspcore/obj/project.assets.json
log  : Restored /Users/iwasa.takahito/work/hoge0127aspcore/hoge0127aspcore.csproj (in 97 ms).

次に、ミドルウェアを構成します。
以下に記述のとおり認証周りは定義の順序を少し気をつける必要があります。

以下でコメントアウトしている部分は標準で Entity Framework を使ってクライアントデータベースへユーザー情報を保存する部分です。

Program.cs

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using hoge0127aspcore.Data;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
// builder.Services.AddDbContext<ApplicationDbContext>(options =>
//     options.UseSqlite(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

// builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
//     .AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddCognitoIdentity();
builder.Services.AddRazorPages();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error");
}
app.UseStaticFiles();

app.UseRouting();

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

app.MapRazorPages();

app.Run();

先程入手した Cognito ユーザープール関連の情報は構成ファイルに設定します。
今回はローカルデバッグ実行のみなので Development 用に設定しておきます。

appsettings.Development.json

{
  "DetailedErrors": true,
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AWS": {
    "Region": "ap-northeast-1",
    "UserPoolClientId": "6apjiqqr9s42ccoqtr0hku8i5u",
    "UserPoolClientSecret": "hogehogehogehogehogehogehogehogehoge",
    "UserPoolId": "ap-northeast-1_jetmo7nTp"
  }
}

実行環境でのポリシー必要

ここまでで機能として利用出来る状態ではないですがベース部分の組み込みは終わっています。
実行環境で設定されている IAM ポリシーに従ってidp:AdminGetUserが実行されるようなので、IAM ポリシーをどのように適用するかは検討したほうが良さそうです。今回はローカルなので AWS CLI のプロファイルをそのまま使っています。

An unhandled exception occurred while processing the request.
HttpErrorResponseException: Exception of type 'Amazon.Runtime.Internal.HttpErrorResponseException' was thrown.
Amazon.Runtime.HttpWebRequestMessage.GetResponseAsync(CancellationToken cancellationToken)

AmazonCognitoIdentityProviderException: User: arn:aws:iam::123456789012:user/hoge-iwasa.takahito is not authorized to perform: cognito-idp:AdminGetUser on resource: arn:aws:cognito-idp:ap-northeast-1:123456789012:userpool/ap-northeast-1_jetmo7nTp with an explicit deny in an identity-based policy

SighInManager と UserManager を変更する

ポリシーの問題が解決しましたが、別のエラーが発生しました。

An unhandled exception occurred while processing the request.
InvalidOperationException: No service for type 'Microsoft.AspNetCore.Identity.UserManager`1[Microsoft.AspNetCore.Identity.IdentityUser]' has been registered.

An unhandled exception occurred while processing the request.
InvalidOperationException: No service for type 'Microsoft.AspNetCore.Identity.SignInManager`1[Microsoft.AspNetCore.Identity.IdentityUser]' has been registered.

これは共通画面上部のサインイン周りのパーツで、ミドルウェアから削除したデフォルトのIdentityUserが使われているためです。
ここは追加したCognitoUserに変更しておきましょう。
ついでに、今回サインイン用の簡単なページを新規作成するので、サインインボタンの遷移先 URL も変更しておきます。

Pages/Shared/_LoginPartial.cshtml

@using Microsoft.AspNetCore.Identity
@* @inject SignInManager<IdentityUser> SignInManager *@
@* @inject UserManager<IdentityUser> UserManager *@
@using Amazon.Extensions.CognitoAuthentication
@inject SignInManager<CognitoUser> SignInManager
@inject UserManager<CognitoUser> UserManager

<ul class="navbar-nav">
@if (SignInManager.IsSignedIn(User))
{
    <li class="nav-item">
        <a  class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Manage/Index" title="Manage">Hello @User.Identity?.Name!</a>
    </li>
    <li class="nav-item">
        <form class="form-inline" asp-area="Identity" asp-page="/Account/Logout" asp-route-returnUrl="@Url.Page("/", new { area = "" })" method="post" >
            <button  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" asp-area="Identity" asp-page="/Account/Register">Register</a>
    </li> *@
    <li class="nav-item">
        <a class="nav-link text-dark" asp-page="/HogeLogin">Login</a>
    </li>
}
</ul>

一旦デフォルトページの表示に成功するところまでは辿り着きました。

最後にサインインページを実装してます。

サインインページを用意

最後にサインイン用のページを用意します。
新規 Razor ページを追加します。

% dotnet new page -n HogeLogin -o Pages 
The template "Razor Page" was created successfully.

また、以下の公式ドキュメントを参考に入力コントロールを配置しつつコードビハインドで POST リクエスト時に入力情報を受け取れるようにします。

テキストボックス 2 つとボタンが 1 つあるだけの本当に最低限のページです。

Pages/HogeLogin.cshtml

@page
@model MyApp.Namespace.HogeLoginModel
@{
}
<div>
    <form method="post">
        <input asp-for="UserName" />
        <input asp-for="Password"  />
        <button type="submit">ログイン</button>
    </form>
</div>

コードビハインドではユーザー名とパスワードを受け取り、OnPostAsyncでサインイン操作を行います。
サインインの仕組みはMicrosoft.AspNetCore.Identityに則っており、CognitoUserを使っているだけです。

SignInManager.PasswordSignInAsync メソッド (Microsoft.AspNetCore.Identity) | Microsoft Learn

Pages/HogeLogin.cshtml.cs

using Amazon.Extensions.CognitoAuthentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace MyApp.Namespace
{
    public class HogeLoginModel : PageModel
    {
        [BindProperty]
        public string UserName { get; set; } = string.Empty;
        [BindProperty]
        public string Password { get; set; } = string.Empty;

        private readonly SignInManager<CognitoUser> _signInManager;

        public HogeLoginModel(SignInManager<CognitoUser> signInManager)
        {
            _signInManager = signInManager;
        }

        public void OnGet()
        {
        }

        public async Task<IActionResult> OnPostAsync()
        {
            var result = await _signInManager.PasswordSignInAsync(UserName, Password, false, false);
            if (result.Succeeded)
            {
                return LocalRedirect("/");
            }
            else
            {
                System.Console.WriteLine("ログイン失敗");
            }
            return Page();
        }
    }
}

Cognito ユーザーは事前にパスワードを変更しておく

今回はサインアップや初回パスワード変更のプロセスを実装していないので事前に管理者のほうで初期パスワードから変更を行っておかないと、次のようにサインインの結果がRequirePasswordChangeとなってしまいます。

そこで、今回は次の記事を参考に AWS CLI から事前にパスワードを変更しました。
実際には API の結果からパスワードリセット画面へ遷移させる必要があるでしょう。

% aws cognito-idp admin-set-user-password --user-pool-id ap-northeast-1_jetmo7nTp --username ae07c48c-be6a-4259-b646-cb6ec8d2c1a6 --password hogehoge --permanent

パスワード変更後は次のように正しいユーザー名とパスワードであれば認証成功の結果を受け取りことが出来ました。

Microsoft.AspNetCore.Identityの仕組みの上でCognitoUserを使っている形なので、サインイン成功後は次のようにデフォルトでユーザー名が画面へ表示されるようになりました。

さいごに

今回は ASP.NET Core で ASP.NET Core Identity Provider for Amazon Cognito を使って Cognito ユーザープール認証機能を実装してみました。

まだサインイン機能だけなので、サインアウトやサインアップなどの機能も実装してみたいと思います。
自前で API を呼び出すよりも AspNetCore.Identity のインターフェースで実装出来るのでこのライブラリ使ったほうがかなり楽な気がしますね。