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

ASP.NET Core パスキー認証を実装する② 登録APIを作る

ASP.NET Coreで、ログイン済みの利用者にパスキーを登録するAPIを作ります。

登録は「設定を発行するAPI」と「結果を検証するAPI」に分けます。認証器が鍵を作った時点では登録完了にせず、サーバーの検証とDB保存が成功してから完了とします。

  1. 全体の流れと保存データ
  2. 登録APIを作る(この記事)
  3. ログインAPIを作る
  4. 登録・ログイン画面をつなぐ

この記事は、前回のFido2の設定とIPasskeyStoreを使用します。まずは全体の流れと保存データを用意してください。

登録先の利用者はサーバーで決める

登録APIに、登録先の利用者IDを送る入力欄は用意しません。ログイン済みの認証情報から取得します。

今回の例では、既存のログイン処理が認証CookieへClaimTypes.NameIdentifierを保存している前提です。この値と会員テーブルのIDを一致させておきます。

  1. 認証Cookieから利用者を特定する
  2. その利用者用の登録オプションを作る
  3. 保存するチャレンジにも利用者IDを記録する
  4. 完了APIでも同じ利用者がログインしていることを確認する

開始時にログインしていた利用者と、完了時の利用者が違う場合は登録しません。操作の途中でログアウトしたり、別のアカウントへ切り替えたりした場合を考慮するためです。

CSRF対策と操作の保存を共通化する

まず、登録とログインで共通のコントローラーを用意します。partialは、1つのクラスの定義を複数ファイルへ分けるための指定です。

PasskeysController.cs
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Fido2NetLib;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;

[ApiController]
[Route("api/passkeys")]
[AutoValidateAntiforgeryToken]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public partial class PasskeysController : ControllerBase
{
    private const string FlowCookie = "__Host-passkey-flow";
    private readonly Fido2 fido2;
    private readonly IPasskeyStore store;

    public PasskeysController(Fido2 fido2, IPasskeyStore store)
    {
        this.fido2 = fido2;
        this.store = store;
    }

    private static string NewId() =>
        WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));

    [AllowAnonymous]
    [HttpGet("csrf")]
    public IActionResult Csrf([FromServices] IAntiforgery antiforgery)
    {
        if (!Request.Cookies.ContainsKey(FlowCookie))
        {
            Response.Cookies.Append(FlowCookie, NewId(), new CookieOptions
            {
                HttpOnly = true,
                Secure = true,
                SameSite = SameSiteMode.Strict,
                Path = "/",
            });
        }
        return Ok(new { token = antiforgery.GetAndStoreTokens(HttpContext).RequestToken });
    }

    private string? BrowserId => Request.Cookies[FlowCookie];
    private string? CurrentUserId => User.FindFirstValue(ClaimTypes.NameIdentifier);

    private async Task<IActionResult> SaveOptionsAsync(
        string purpose, string? userId, string optionsJson, CancellationToken ct)
    {
        if (BrowserId is not { Length: > 0 } browserId)
            return BadRequest(new { message = "画面を更新してください。" });

        var id = NewId();
        await store.SaveChallengeAsync(new PasskeyChallenge(
            id, browserId, purpose, userId, optionsJson,
            DateTimeOffset.UtcNow.AddMinutes(5)), ct);
        using var document = JsonDocument.Parse(optionsJson);
        return Ok(new { flowId = id, publicKey = document.RootElement.Clone() });
    }

    private Task<PasskeyChallenge?> TakeAsync(
        string flowId, string purpose, CancellationToken ct) =>
        store.TakeChallengeAsync(flowId, BrowserId ?? "", purpose, ct);

    private IActionResult VerificationFailed() =>
        BadRequest(new { message = "認証を完了できませんでした。もう一度お試しください。" });
}

csrfを呼ぶと、CSRFトークンがJSONで返ります。同時に、ブラウザーを識別するランダムな値をHttpOnlyのCookieに保存します。フロントエンドがこのCookieの内容を読む必要はありません。

このCookieはログイン状態を表す認証Cookieとは別物です。ログイン前でも利用でき、チャレンジを「操作を開始したブラウザー」と結び付けるために使います。

一方、CSRFトークンはフロントエンドがX-CSRF-TOKENヘッダーで送信します。AutoValidateAntiforgeryTokenは、コントローラーのPOSTなどに対して検証します。匿名でアクセスできるログインAPIにも適用します。CSRF対策の仕組みはASP.NET Coreの公式ドキュメントを参照してください。

SaveOptionsAsyncは次の情報を保存します。

値検証時に確認したいこと
Idどの操作への応答か
BrowserId同じブラウザーからの応答か
Purpose登録用か、ログイン用か
UserId誰に登録するか
OptionsJsonどの設定で開始したか
ExpiresAt操作が時間切れになっていないか

レスポンスのpublicKeyにはJSONオブジェクトを入れます。optionsJsonを文字列のまま返すと、フロントエンドがもう一度JSONを解析しないと使えなくなります。ここではJsonDocumentでオブジェクトに変換しています。

登録オプションを発行する

次のファイルに、開始APIと完了APIをまとめます。まずはRegisterOptionsから読んでください。

PasskeysController.Register.cs
using System.ComponentModel.DataAnnotations;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

public sealed record RegisterFinishRequest(
    [property: Required] string FlowId,
    [property: Required] AuthenticatorAttestationRawResponse Credential);

public partial class PasskeysController
{
    [Authorize]
    [HttpPost("register/options")]
    public async Task<IActionResult> RegisterOptions(CancellationToken ct)
    {
        if (CurrentUserId is not { } userId) return Unauthorized();
        var user = await store.FindUserAsync(userId, ct);
        if (user is null || !user.IsActive) return Unauthorized();
        var saved = await store.ListAsync(user.Id, ct);
        var options = fido2.RequestNewCredential(new RequestNewCredentialParams
        {
            User = new Fido2User
            {
                Id = user.UserHandle,
                Name = user.DisplayName,
                DisplayName = user.DisplayName,
            },
            ExcludeCredentials = saved.Select(key =>
                new PublicKeyCredentialDescriptor(key.CredentialId)).ToArray(),
            AuthenticatorSelection = new AuthenticatorSelection
            {
                ResidentKey = ResidentKeyRequirement.Required,
                UserVerification = UserVerificationRequirement.Required,
            },
            AttestationPreference = AttestationConveyancePreference.None,
        });
        return await SaveOptionsAsync("register", user.Id, options.ToJson(), ct);
    }

    [Authorize]
    [HttpPost("register/finish")]
    public async Task<IActionResult> RegisterFinish(
        RegisterFinishRequest request, CancellationToken ct)
    {
        var challenge = await TakeAsync(request.FlowId, "register", ct);
        if (challenge is null || challenge.UserId != CurrentUserId)
            return VerificationFailed();
        var user = await store.FindUserAsync(challenge.UserId!, ct);
        if (user is null || !user.IsActive) return VerificationFailed();

        try
        {
            var result = await fido2.MakeNewCredentialAsync(new MakeNewCredentialParams
            {
                AttestationResponse = request.Credential,
                OriginalOptions = CredentialCreateOptions.FromJson(challenge.OptionsJson),
                IsCredentialIdUniqueToUserCallback = async (args, token) =>
                    await store.FindCredentialAsync(args.CredentialId, token) is null,
            }, ct);

            var added = await store.TryAddAsync(new SavedPasskey(
                result.Id, user.Id, result.PublicKey, result.SignCount,
                result.Transports, 0), ct);
            return added ? NoContent() : VerificationFailed();
        }
        catch (Fido2VerificationException)
        {
            return VerificationFailed();
        }
    }
}

RegisterOptionsでは、Fido2のRequestNewCredentialに利用者情報と設定を渡します。

User.Idには、前回DBに保存したUserHandleを使います。NameとDisplayNameは認証器のアカウント選択画面などに使う表示用の値です。同じ表示名の利用者がいる場合は、人が区別できる表示をアプリ側で用意してください。所有者の判定には表示名を使いません。

ExcludeCredentialsには登録済みのCredential IDを渡します。同じ認証器へ重複登録することを避けるための情報です。ただし、これだけでDBの一意性を保証できるわけではありません。

ResidentKeyとUserVerificationは、意図が分かるよう明示しています。

設定今回の値理由
ResidentKeyRequiredログイン時にユーザー名を先に入力せず、パスキーを選べるようにする
UserVerificationRequiredPINや生体認証などによる利用者の検証を要求する
AttestationPreferenceNone認証器の製造元などの証明を求めない構成にする

AuthenticatorAttachmentは指定していません。端末内蔵の認証機能だけに絞りたい要件がなければ、最初から認証器の種類を狭める必要はありません。

完了APIは保存済みの設定で検証する

RegisterFinishでは、次の順番で処理します。

  1. flowIdとCookieから、登録用チャレンジを一度だけ取り出す
  2. 登録開始時と現在の利用者が同じことを確認する
  3. 利用者が有効であることを確認する
  4. Fido2で登録結果を検証する
  5. 検証に成功した公開鍵を保存する

OriginalOptionsはDBに保存したJSONから復元します。ブラウザーに発行時の設定を返してもらい、それを検証の基準にしてはいけません。

MakeNewCredentialAsyncは、チャレンジ、Origin、RP ID、要求したユーザー検証などを含めて登録結果を検証します。アプリ側で署名形式や認証器データを独自に解析する処理は追加しません。検証項目の背景はWebAuthnの登録手順で確認できます。

成功時にはresult.Id、result.PublicKey、result.SignCountなどが返ります。request.Credentialの中にある値を、そのまま信頼して公開鍵として保存する処理にはしません。

Fido2 4.0.1の受信モデルにはBase64URLの変換指定があります。ASP.NET CoreでAuthenticatorAttestationRawResponseとして受け取れば、フロントエンドのJSONから必要なバイト列へ変換されます。受信モデルの定義も参照できます。

Credential IDの重複をDBでも防ぐ

IsCredentialIdUniqueToUserCallbackでは、Credential IDが既に登録されていないかを確認します。名前にUserが含まれていますが、今回の保存層では全利用者を対象に検索します。

そのうえで、TryAddAsyncも重複を拒否します。PostgreSQLなら、次の形で保存できます。

有効な利用者にだけ登録する
INSERT INTO passkey
    (credential_id, user_id, public_key, sign_count, transports, version)
SELECT
    @credential_id, u.id, @public_key, @sign_count,
    CAST(@transports AS jsonb), 0
FROM app_user AS u
WHERE u.id = @user_id AND u.is_active = true
ON CONFLICT (credential_id) DO NOTHING
RETURNING credential_id;

戻り値が1件ならtrue、0件ならfalseを返します。transportsはライブラリの列挙値配列をJSONとして保存し、読み出すときに同じ型へ復元します。値はすべてパラメーターとして渡します。

利用者が無効になっていた場合も保存しません。接続エラーなどのDB障害はfalseにせず、アプリ共通の例外処理へ渡して記録します。「登録済みだった」と「DBに接続できなかった」を同じ扱いにしないためです。

失敗したら開始APIからやり直す

チャレンジは検証前に消費しています。署名の検証に失敗した場合や、保存が競合した場合は、同じflowIdを再送しても成功しません。

フロントエンドは「もう一度試す」を、新しい登録オプションの取得から始めます。HTTPクライアントの自動リトライで完了APIだけを再送しないようにします。

また、ブラウザー側で鍵を作れたあとに通信が切れると、認証器にはパスキーがあり、サーバーには登録されていない状態になり得ます。画面にはnavigator.credentials.createの完了時ではなく、register/finishの成功後に「登録しました」と表示します。

この例は、有効なログインセッションを持つ利用者に登録を許可します。重要なアカウント設定として扱う場合は、開始APIと完了APIに共通の認可ポリシーを適用し、直前の再認証も要求してください。

再認証の時刻や対象操作はサーバーで管理します。フロントエンドから送った「再認証済み」というフラグでは代用できません。

次の記事でログインAPIを作る

登録APIで保存するのは、公開鍵とCredential ID、それらを所有する利用者の対応です。次はこの情報を使い、ログインAPIを作るで署名の検証と認証Cookieの発行を実装します。

別の端末への追加を、ログイン済み端末の承認で進めたい場合は、パスキーを別の端末に安全に追加する方法も参考にしてください。


関連記事