zukucode
主にWEB関連の情報を技術メモとして発信しています。

ASP.NET Core パスキー認証を実装する① 全体の流れと保存データ

ASP.NET CoreとReactで、パスキーの登録からログインまでを実装する方法を紹介します。

パスキーの実装では、ブラウザーに認証画面を出す処理よりも、その結果を「誰の、どの操作への応答なのか」とサーバーで確認する処理が大切です。登録先の利用者をリクエストの値だけで決めたり、同じ認証結果を何度でも受け付けたりすると、画面上は動いていても安全な認証にはなりません。

長くなるため、次の4記事に分けます。

  1. 全体の流れと保存データ(この記事)
  2. 登録APIを作る
  3. ログインAPIを作る
  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回呼ぶのか

登録もログインも、次の順番で進めます。

  1. ブラウザーがサーバーへ認証の開始を要求する
  2. サーバーがランダムなチャレンジを含む設定を生成し、保存して返す
  3. ブラウザーが設定を認証器へ渡す
  4. 利用者が端末の画面で操作し、ブラウザーが結果をサーバーへ送る
  5. サーバーが保存した設定と結果を照合する

チャレンジは「今回の操作のために作ったランダムな値」です。過去の応答を持っていても、別のチャレンジへの応答としては使えません。さらにサーバーで一度だけ消費することで、同じ操作への再送も拒否します。

連載全体で使うURLは、以下にそろえます。

メソッドURL役割
GET/api/passkeys/csrfCSRFトークンとブラウザー識別用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を決める

最初につまずきやすいのが、ドメインの設定です。

設定ローカル開発の例意味
ブラウザーで開くURLhttps://localhost:5173Reactの画面
RP IDlocalhostパスキーをどのサイト用として扱うかを表すドメイン
許可するOriginhttps://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.cs
using 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.cs
using 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でパスキーを一覧取得する
FindCredentialAsyncCredential 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を作るで、ブラウザーへ渡す登録オプションの生成と、戻ってきた登録結果の検証を実装します。


関連記事