ASP.NET Core パスキー認証を実装する② 登録APIを作る
ASP.NET Coreで、ログイン済みの利用者にパスキーを登録するAPIを作ります。
登録は「設定を発行するAPI」と「結果を検証するAPI」に分けます。認証器が鍵を作った時点では登録完了にせず、サーバーの検証とDB保存が成功してから完了とします。
この記事は、前回のFido2の設定とIPasskeyStoreを使用します。まずは全体の流れと保存データを用意してください。
登録先の利用者はサーバーで決める
登録APIに、登録先の利用者IDを送る入力欄は用意しません。ログイン済みの認証情報から取得します。
今回の例では、既存のログイン処理が認証CookieへClaimTypes.NameIdentifierを保存している前提です。この値と会員テーブルのIDを一致させておきます。
- 認証Cookieから利用者を特定する
- その利用者用の登録オプションを作る
- 保存するチャレンジにも利用者IDを記録する
- 完了APIでも同じ利用者がログインしていることを確認する
開始時にログインしていた利用者と、完了時の利用者が違う場合は登録しません。操作の途中でログアウトしたり、別のアカウントへ切り替えたりした場合を考慮するためです。
CSRF対策と操作の保存を共通化する
まず、登録とログインで共通のコントローラーを用意します。partialは、1つのクラスの定義を複数ファイルへ分けるための指定です。
PasskeysController.csusing 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.csusing 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は、意図が分かるよう明示しています。
| 設定 | 今回の値 | 理由 |
|---|---|---|
ResidentKey | Required | ログイン時にユーザー名を先に入力せず、パスキーを選べるようにする |
UserVerification | Required | PINや生体認証などによる利用者の検証を要求する |
AttestationPreference | None | 認証器の製造元などの証明を求めない構成にする |
AuthenticatorAttachmentは指定していません。端末内蔵の認証機能だけに絞りたい要件がなければ、最初から認証器の種類を狭める必要はありません。
完了APIは保存済みの設定で検証する
RegisterFinishでは、次の順番で処理します。
flowIdとCookieから、登録用チャレンジを一度だけ取り出す- 登録開始時と現在の利用者が同じことを確認する
- 利用者が有効であることを確認する
- Fido2で登録結果を検証する
- 検証に成功した公開鍵を保存する
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の発行を実装します。
別の端末への追加を、ログイン済み端末の承認で進めたい場合は、パスキーを別の端末に安全に追加する方法も参考にしてください。