ASP.NET Core パスキー認証を実装する① 全体の流れと保存データ
ASP.NET CoreとReactで、パスキーの登録からログインまでを実装する方法を紹介します。
パスキーの実装では、ブラウザーに認証画面を出す処理よりも、その結果を「誰の、どの操作への応答なのか」とサーバーで確認する処理が大切です。登録先の利用者をリクエストの値だけで決めたり、同じ認証結果を何度でも受け付けたりすると、画面上は動いていても安全な認証にはなりません。
長くなるため、次の4記事に分けます。
今回実装する範囲
既存の会員機能へパスキーを追加します。利用者は、いったん既存の方法でログインし、設定画面でパスキーを登録します。その後はログアウトして、パスキーだけでログインできるようにします。
バックエンドはASP.NET CoreとFido2 4.0.1、フロントエンドはReactとTypeScriptを使います。C#のコードは.NET 8を対象にしています。Fido2はバージョンによってメソッドの引数が異なるため、この連載では4.0.1にそろえます。Fido2の移行ガイドにも、バージョン4の変更点がまとまっています。
パスキーを作れたことだけでは、その人が既存アカウントの持ち主だとは確認できません。この連載の登録APIはログイン済みの利用者専用です。
パスキーだけで新規会員登録する場合は、メール確認などで開始した登録セッションと、新しく作るアカウントを結び付ける別の処理が必要です。未ログインの人が任意の利用者IDを送れる形には変更しません。
コードは既存の会員機能へ組み込むための例です。会員作成、既存のログイン、DB接続はアプリ側の機能を使います。DBアクセスはこの記事のIPasskeyStoreとして切り出し、必要な制約とSQLを示します。ORMごとの実装や接続管理は掲載していないため、コードを貼り付けるだけで起動するひな型ではありません。
パスキーでサーバーに保存するもの
パスキーでは、認証器が秘密鍵を管理し、サーバーが公開鍵を保存します。認証器は、端末の認証機能やパスキープロバイダーなど、鍵の作成や署名を担当するものです。指紋や顔の情報をアプリのサーバーへ送る必要はありません。
登録とログインでは、処理が次のように変わります。
| 操作 | ブラウザー・認証器の役割 | サーバーの役割 |
|---|---|---|
| 登録 | 鍵を作り、登録結果を返す | 結果を検証し、公開鍵と利用者の対応を保存する |
| ログイン | 保存された秘密鍵で署名する | 保存済みの公開鍵で署名を検証する |
同期型のパスキーでは、プロバイダーの仕組みによって別の端末でも利用できる場合があります。「秘密鍵は必ず一台の端末から出ない」という前提で設計しないようにします。
なぜAPIを2回呼ぶのか
登録もログインも、次の順番で進めます。
- ブラウザーがサーバーへ認証の開始を要求する
- サーバーがランダムなチャレンジを含む設定を生成し、保存して返す
- ブラウザーが設定を認証器へ渡す
- 利用者が端末の画面で操作し、ブラウザーが結果をサーバーへ送る
- サーバーが保存した設定と結果を照合する
チャレンジは「今回の操作のために作ったランダムな値」です。過去の応答を持っていても、別のチャレンジへの応答としては使えません。さらにサーバーで一度だけ消費することで、同じ操作への再送も拒否します。
連載全体で使うURLは、以下にそろえます。
| メソッド | URL | 役割 |
|---|---|---|
| GET | /api/passkeys/csrf | CSRFトークンとブラウザー識別用Cookieを準備する |
| POST | /api/passkeys/register/options | 登録用の設定を発行する。ログイン必須 |
| POST | /api/passkeys/register/finish | 登録結果を検証して保存する。ログイン必須 |
| POST | /api/passkeys/login/options | ログイン用の設定を発行する |
| POST | /api/passkeys/login/finish | 署名を検証して認証Cookieを発行する |
開始APIは、flowIdとpublicKeyを返します。flowIdはサーバーに保存した操作を探すためのID、publicKeyはブラウザーへ渡すWebAuthnの設定オブジェクトです。ここでいうpublicKeyは、保存済みの公開鍵そのものではありません。
RP IDとOriginを決める
最初につまずきやすいのが、ドメインの設定です。
| 設定 | ローカル開発の例 | 意味 |
|---|---|---|
| ブラウザーで開くURL | https://localhost:5173 | Reactの画面 |
| RP ID | localhost | パスキーをどのサイト用として扱うかを表すドメイン |
| 許可するOrigin | https://localhost:5173 | 認証操作を開始できる画面のオリジン |
| APIの接続先 | 同じOriginの/api | 開発サーバーからバックエンドへ転送する |
RP IDにhttps://やポート番号は付けません。Originにはスキームと、必要ならポート番号を含めます。パスは含めません。
この例は、Reactの開発サーバーで/apiをASP.NET Coreへプロキシする構成です。APIが内部で別のポートを使っていても、ブラウザーがWebAuthnを呼ぶOriginはhttps://localhost:5173です。APIサーバーのURLをそのままWebAuthnの許可Originに設定するわけではありません。
WebAuthnはセキュアコンテキストが前提です。この連載ではSecure属性のCookieも使うため、ローカルもHTTPSにそろえます。Reactの開発サーバーとASP.NET Coreそれぞれで信頼できる開発用証明書を用意してください。RP IDやOriginの条件はMDNの登録オプションの説明でも確認できます。
本番がhttps://app.example.comなら、まずRP IDをapp.example.com、Originをhttps://app.example.comとして考えると対応が分かりやすくなります。既存のパスキーはRP IDに結び付くため、あとから気軽に変更できる設定ではありません。
共通設定を追加する
バックエンドのプロジェクトでFido2を追加します。
パッケージの追加dotnet add package Fido2 --version 4.0.1以降のC#コードは、同じプロジェクトの同じ名前空間に配置します。例では名前空間を省略しています。
- backend
- Program.cs
- Passkeys
- PasskeyModels.cs
- PasskeysController.cs
- PasskeysController.Register.cs
- PasskeysController.Login.cs
Program.csでは、Fido2、Cookie認証、CSRF対策を登録します。既に認証を設定しているアプリでは、その設定へ統合してください。同じ認証方式を重複して登録する必要はありません。
Program.csusing Fido2NetLib;
using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddAuthorization();
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.Name = "__Host-csrf";
options.Cookie.Path = "/";
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
});
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "__Host-auth";
options.Cookie.Path = "/";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromHours(1);
options.SlidingExpiration = false;
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = 401;
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = 403;
return Task.CompletedTask;
};
});
builder.Services.AddSingleton(new Fido2(new Fido2Configuration
{
ServerDomain = "localhost",
ServerName = "サンプルアプリ",
Origins = new HashSet<string> { "https://localhost:5173" },
}));
// IPasskeyStore の実装を既存の DB アクセス層に用意し、ここで DI 登録する。
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();AddControllersWithViewsは、次回使うAutoValidateAntiforgeryTokenのフィルターを登録するために使います。画面自体はReactで描画しますが、この例ではAddControllersだけでは必要なフィルターが登録されません。
APIの認証失敗は、ログイン画面へのリダイレクトではなく401または403にします。fetchがHTMLを受け取ってJSONとして処理しようとする失敗を避けられます。
__Host-で始まるCookie名は、Secure、Path=/、Domain属性なしの条件で使用します。フロントエンドとAPIを同じOriginとして公開する前提なので、認証のためにCORS設定を広げる必要はありません。
Cookie認証の登録方法はASP.NET Coreの公式ドキュメントを参照してください。
保存するデータの型を作る
利用者、パスキー、チャレンジを分けて保存します。
PasskeyModels.csusing Fido2NetLib.Objects;
public sealed record PasskeyUser(
string Id, byte[] UserHandle, string DisplayName, bool IsActive);
public sealed record SavedPasskey(
byte[] CredentialId, string UserId, byte[] PublicKey,
uint SignCount, AuthenticatorTransport[] Transports, long Version);
public sealed record PasskeyChallenge(
string Id, string BrowserId, string Purpose, string? UserId,
string OptionsJson, DateTimeOffset ExpiresAt);
public interface IPasskeyStore
{
Task<PasskeyUser?> FindUserAsync(string userId, CancellationToken ct);
Task<IReadOnlyList<SavedPasskey>> ListAsync(string userId, CancellationToken ct);
Task<SavedPasskey?> FindCredentialAsync(byte[] credentialId, CancellationToken ct);
Task SaveChallengeAsync(PasskeyChallenge challenge, CancellationToken ct);
Task<PasskeyChallenge?> TakeChallengeAsync(
string id, string browserId, string purpose, CancellationToken ct);
Task<bool> TryAddAsync(SavedPasskey passkey, CancellationToken ct);
Task<bool> TryUpdateCounterAsync(
byte[] credentialId, long expectedVersion, uint signCount,
CancellationToken ct);
}UserHandleはWebAuthn用の利用者識別子です。アカウント作成時などに暗号学的な乱数を32バイト生成し、利用者にひも付けて保存します。同じ利用者の2個目のパスキーでも、同じ値を使います。
利用者作成時に一度だけ生成する値var userHandle = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32);メールアドレスや表示名は変更されるため、UserHandleには使いません。既存会員がいる場合も、会員ごとに一度だけ生成して保存します。オプションを発行するたびに作り直さない点が大切です。
CredentialIdはパスキーの識別子、PublicKeyは署名を確認するための鍵です。1人が複数のパスキーを登録できるよう、利用者とは別のテーブルにします。
VersionはDB更新の競合を検出する値です。認証器から返るSignCountとは目的が異なります。
DBに制約を持たせる
PostgreSQLなら、保存構造を次のように表せます。app_userは説明用の会員テーブルです。既存の会員テーブルがある場合は、そのIDと有効・無効の項目に合わせて読み替えてください。
保存構造の例CREATE TABLE app_user (
id text PRIMARY KEY,
display_name text NOT NULL,
is_active boolean NOT NULL DEFAULT true,
webauthn_user_handle bytea NOT NULL UNIQUE
CHECK (octet_length(webauthn_user_handle) = 32)
);
CREATE TABLE passkey (
credential_id bytea PRIMARY KEY,
user_id text NOT NULL REFERENCES app_user(id),
public_key bytea NOT NULL,
sign_count bigint NOT NULL CHECK (sign_count BETWEEN 0 AND 4294967295),
transports jsonb NOT NULL,
version bigint NOT NULL DEFAULT 0
);
CREATE INDEX passkey_user_id_idx ON passkey(user_id);
CREATE TABLE passkey_challenge (
id text PRIMARY KEY,
browser_id text NOT NULL,
purpose text NOT NULL CHECK (purpose IN ('register', 'login')),
user_id text REFERENCES app_user(id),
options_json text NOT NULL,
expires_at timestamptz NOT NULL,
UNIQUE (browser_id, purpose)
);
CREATE INDEX passkey_challenge_expiry_idx ON passkey_challenge(expires_at);Credential IDの一意性は、全利用者を通じて保証します。アプリで「未登録」を確認するだけでは、同時に届いた2件が両方とも確認を通過する可能性があるためです。
OptionsJsonには、ライブラリが作った設定全体を保存します。チャレンジだけでなく、ユーザー検証の要求なども、発行時と検証時で一致させるためです。JSONをブラウザーから送り返させて検証条件に使うことはしません。
IPasskeyStoreの各メソッドは、次の契約で実装します。SQLの値は文字列連結せず、DBドライバーのパラメーターとして渡します。
| メソッド | 実装する処理 |
|---|---|
FindUserAsync | 利用者IDで検索し、UserHandleと有効状態を返す |
ListAsync | 利用者IDでパスキーを一覧取得する |
FindCredentialAsync | Credential IDのバイト列でパスキーを検索する |
SaveChallengeAsync | 同じブラウザー・用途の古い操作を置き換える |
TakeChallengeAsync | 用途・ブラウザー・期限を確認し、一度だけ取り出す |
TryAddAsync | 有効な利用者に保存する。一意制約違反ならfalseを返す |
TryUpdateCounterAsync | 利用者の有効状態とVersionを確認し、競合がなければ更新する |
たとえばSaveChallengeAsyncは、次のSQLで新しい操作へ置き換えます。
チャレンジの保存INSERT INTO passkey_challenge
(id, browser_id, purpose, user_id, options_json, expires_at)
VALUES
(@id, @browser_id, @purpose, @user_id, @options_json, @expires_at)
ON CONFLICT (browser_id, purpose) DO UPDATE SET
id = EXCLUDED.id,
user_id = EXCLUDED.user_id,
options_json = EXCLUDED.options_json,
expires_at = EXCLUDED.expires_at;この設計では、同じブラウザーで同じ操作をやり直すと前の操作は無効になります。別のタブで同時に始めた場合も、最後に開始した操作が有効です。
チャレンジを一度だけ取り出す
TakeChallengeAsyncは、次のSQLの結果を返します。0件ならnullです。
期限と用途を確認して消費するDELETE FROM passkey_challenge
WHERE id = @id
AND browser_id = @browser_id
AND purpose = @purpose
AND expires_at > CURRENT_TIMESTAMP
RETURNING id, browser_id, purpose, user_id, options_json, expires_at;「SELECTで確認してからDELETEする」という別々の処理では、同時に到着した2件が両方とも値を読み取る可能性があります。削除と取得を1つの操作にすると、取り出せるのは1件だけになります。
この連載では、署名の検証前に消費を確定させます。TakeChallengeAsyncは短い独立したトランザクションでコミットしてから結果を返し、後続の検証失敗では削除を巻き戻しません。そのため、通信失敗や検証失敗のあとは、開始APIからやり直します。
有効期限を過ぎた行は、別途定期的に削除します。期限切れを拒否する条件と、不要な行を片付ける処理は両方必要です。
保存層の実装ができたら、Program.csにIPasskeyStoreのDI登録を追加します。クラス名をPasskeyStoreにした場合は、次の形です。
保存層を実装したあとに追加するDI登録builder.Services.AddScoped<IPasskeyStore, PasskeyStore>();次の記事で登録APIを作る
ここまでで、サイトの設定と、利用者・パスキー・使い捨ての操作を保存する場所が決まりました。
次は登録APIを作るで、ブラウザーへ渡す登録オプションの生成と、戻ってきた登録結果の検証を実装します。