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

ASP.NET Core パスキー認証を実装する③ ログインAPIを作る

ASP.NET Coreで、登録済みのパスキーを使うログインAPIを作ります。

ログインでは、ブラウザーから送られてきたCredential IDで保存済みの公開鍵を探し、その鍵で署名を検証します。利用者IDが送られてきたことや、端末で認証画面が閉じたことだけでログインを許可しないようにします。

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

前回までに作ったIPasskeyStore、PasskeysController、Cookie認証の設定を使用します。先に登録APIを作るを実装してください。

ユーザー名を入力しないログインにする

今回は「パスキーでログイン」ボタンを押し、ブラウザーの画面でアカウントを選ぶ方式にします。

登録時にResidentKeyRequirement.Requiredを指定したのは、このためです。サーバーから利用者ごとのCredential IDを先に送らなくても、認証器が対象サイトのパスキーを選べるようにしています。

メールアドレスを先に入力する方式もありますが、この連載では両方を混ぜず、ユーザー名を入力しない方式に統一します。

ログインAPIを追加する

共通のPasskeysControllerへ、次のファイルを追加します。

PasskeysController.Login.cs
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

public sealed record LoginFinishRequest(
    [property: Required] string FlowId,
    [property: Required] AuthenticatorAssertionRawResponse Credential);

public partial class PasskeysController
{
    [AllowAnonymous]
    [HttpPost("login/options")]
    public async Task<IActionResult> LoginOptions(CancellationToken ct)
    {
        var options = fido2.GetAssertionOptions(new GetAssertionOptionsParams
        {
            AllowedCredentials = Array.Empty<PublicKeyCredentialDescriptor>(),
            UserVerification = UserVerificationRequirement.Required,
        });
        return await SaveOptionsAsync("login", null, options.ToJson(), ct);
    }

    [AllowAnonymous]
    [HttpPost("login/finish")]
    public async Task<IActionResult> LoginFinish(
        LoginFinishRequest request, CancellationToken ct)
    {
        var challenge = await TakeAsync(request.FlowId, "login", ct);
        if (challenge is null) return VerificationFailed();

        var raw = request.Credential;
        if (raw.RawId is not { Length: > 0 } ||
            raw.Response?.UserHandle is not { Length: > 0 } handle)
            return VerificationFailed();
        var key = await store.FindCredentialAsync(raw.RawId, ct);
        if (key is null) return VerificationFailed();
        var user = await store.FindUserAsync(key.UserId, ct);
        if (user is null || !user.IsActive ||
            !handle.AsSpan().SequenceEqual(user.UserHandle))
            return VerificationFailed();

        try
        {
            var result = await fido2.MakeAssertionAsync(new MakeAssertionParams
            {
                AssertionResponse = raw,
                OriginalOptions = AssertionOptions.FromJson(challenge.OptionsJson),
                StoredPublicKey = key.PublicKey,
                StoredSignatureCounter = key.SignCount,
                IsUserHandleOwnerOfCredentialIdCallback = (args, token) =>
                    Task.FromResult(
                        args.CredentialId.AsSpan().SequenceEqual(key.CredentialId) &&
                        args.UserHandle is not null &&
                        args.UserHandle.AsSpan().SequenceEqual(user.UserHandle)),
            }, ct);

            if (!await store.TryUpdateCounterAsync(
                key.CredentialId, key.Version, result.SignCount, ct))
                return VerificationFailed();

            var identity = new ClaimsIdentity(new[]
            {
                new Claim(ClaimTypes.NameIdentifier, user.Id),
                new Claim(ClaimTypes.Name, user.DisplayName),
            }, CookieAuthenticationDefaults.AuthenticationScheme);
            await HttpContext.SignInAsync(
                CookieAuthenticationDefaults.AuthenticationScheme,
                new ClaimsPrincipal(identity),
                new AuthenticationProperties { IsPersistent = false });
            Response.Cookies.Delete(FlowCookie, new CookieOptions
            {
                Secure = true, HttpOnly = true, Path = "/",
            });
            return NoContent();
        }
        catch (Fido2VerificationException)
        {
            return VerificationFailed();
        }
    }
}

LoginOptionsではAllowedCredentialsを空にしています。空の配列は「ログインを全件禁止する」という意味ではなく、Credential IDの候補をサーバー側で限定しない指定です。署名の検証や所有者の確認を省略してよい、という意味ではありません。

GetAssertionOptionsが作った設定を保存し、フロントエンドへ返します。登録と同じく、完了時はこの保存済み設定を使います。

Credential IDとUserHandleを両方確認する

LoginFinishでは、最初にログイン用チャレンジを消費します。登録用のチャレンジは、用途が一致しないため取り出せません。

次にCredential IDからパスキーを検索し、そのレコードが所有する利用者を取得します。

所有者を調べる順番
ブラウザーから返ったCredential ID
    ↓
サーバーに保存したパスキー
    ↓
パスキーの所有者である利用者

UserHandleは、登録時に認証器へ渡した利用者識別子です。今回はユーザー名を入力しない方式なので、空のUserHandleを拒否し、サーバーに保存した値との一致を確認します。

IsUserHandleOwnerOfCredentialIdCallbackにも、Credential IDとUserHandleが同じ所有者に対応することを確認する処理を渡します。文字列へ変換せず、バイト列同士で比較します。

なお、ここで受信したCredential IDやUserHandleは、まだ署名の検証前です。DB検索と整合性確認には使いますが、この段階では認証Cookieを発行しません。

署名の検証は保存済みの公開鍵で行う

MakeAssertionAsyncに渡す重要な値は、次の3つです。

引数渡す値
OriginalOptionsサーバーに保存した、今回のログイン設定
StoredPublicKey登録時の検証に成功して保存した公開鍵
StoredSignatureCounter前回保存した署名カウンター

公開鍵を完了APIのリクエストから受け取る形にはしません。受信した署名を、登録済みの鍵で確認することがログインの根拠だからです。

Fido2が検証に成功してから、DBの使用情報を更新し、最後に認証Cookieを発行します。検証手順の詳細はWebAuthnの認証手順を参照できます。

署名カウンターを自前で増やさない

SignCountには、検証結果のresult.SignCountを保存します。アプリ側で「ログインできたから1増やす」という更新はしません。

認証器によってカウンターの扱いは異なり、常に0を返すものもあります。特に同期型のパスキーを、必ず単調に増える端末内カウンターとして扱わないようにします。カウンターの検証はライブラリに任せ、この例に独自の前回より大きくなければ失敗という条件は追加しません。

一方、DB更新の競合はアプリ側で扱います。TryUpdateCounterAsyncは、次のように実装します。

競合していない場合だけ使用情報を更新する
UPDATE passkey AS p
SET sign_count = @sign_count,
    version = p.version + 1
WHERE p.credential_id = @credential_id
  AND p.version = @expected_version
  AND EXISTS (
      SELECT 1 FROM app_user AS u
      WHERE u.id = p.user_id AND u.is_active = true
  )
RETURNING p.credential_id;

1件更新できればtrue、0件ならfalseです。読み出したあとにパスキーが削除された場合、利用者が無効になった場合、別の処理が先に更新した場合を拒否できます。DB障害は例外として扱います。

競合時は、古いカウンターを上書きしてログインを継続せず、操作をやり直してもらいます。カウンターが0の認証器でも、Versionによる競合検出は働きます。

検証後に認証Cookieを発行する

検証とDB更新が成功したあとで、SignInAsyncを呼び出します。

Claimsには、サーバーが確定した利用者IDを入れます。これにより、登録APIと同じClaimTypes.NameIdentifierでログイン中の利用者を取得できます。

今回はIsPersistent = falseなので、ブラウザー終了後も保持する永続Cookieとしては発行しません。認証チケットの有効期限は、第1回の設定で1時間にしています。

ログインが完了したら、操作を結び付けるために使ったCookieを削除します。次の操作では、CSRF準備APIが新しい値を発行します。また、匿名状態で取得したCSRFトークンをログイン後まで使い回さないようにします。フロントエンドでは操作開始時に取得し直します。

ログインできたあとにも認証状態の管理が必要

この例では、ログイン時に利用者の有効状態を確認しています。ただし、発行済みのCookieが毎回DBを参照するわけではありません。

アカウントを無効化した直後にアクセスを止めたい場合は、アプリのセッション失効処理やCookieの検証イベントなどで、発行済みの認証も無効にします。パスキーを削除しただけでは、既にログイン済みのセッションは自動では消えません。

複数台のサーバーで動かす場合は、チャレンジのDBだけでなく、Cookieを暗号化するASP.NET Core Data Protectionの鍵も共有する必要があります。Data Protectionの設定を、既存の認証構成と合わせて確認してください。

失敗理由は画面用と運用用を分ける

画面には、Credential IDが未登録なのか、アカウントが無効なのかを細かく返さず、共通の失敗メッセージを返しています。

運用では、チャレンジの期限切れ、署名の検証失敗、DB更新の競合などを区別して記録すると原因を追いやすくなります。チャレンジやCookie、認証応答の全文を記録するのではなく、エラー分類とリクエストの追跡IDを残します。

匿名で呼べる開始APIには、レート制限と期限切れデータの削除も適用します。ブラウザーごとに未完了操作を1件にしても、Cookieを作り直して多数の操作を開始するアクセスへの制限にはならないためです。

次の記事でReactから呼び出す

ここまでで、登録結果とログイン結果を検証するバックエンドができました。次は登録・ログイン画面をつなぐで、JSONとWebAuthnの変換、ボタン操作、エラー表示を実装します。


関連記事