Upgrade to Pro — share decks privately, control downloads, hide ads and more …

今日からできる! はじめてのパスキー認証

今日からできる! はじめてのパスキー認証

このスライドでは、次世代のユーザー認証方式「パスキー」の仕組みと実装方法を解説しています。
パスワード管理の煩雑さやセキュリティリスクを回避しつつ、簡単かつ安全な認証を可能にするパスキー認証を、フロントエンドとバックエンドのコード例を交えて具体的に紹介します。

SvelteKitを活用した実装例や、公開鍵暗号の基本もなんとなく網羅しています。

このスライドはSvelte Japan Offline Meetup #3で発表しました。

Google Slideのリンクはこちら
今日からできる! はじめてのパスキー認証

今日からできる! はじめてのパスキー認証 by HIBIKI CUBE is licensed under CC BY-ND 4.0

HIBIKI CUBE

December 16, 2024
Tweet

More Decks by HIBIKI CUBE

Other Decks in Programming

Transcript

  1. ユーザー認証の実装方法イロイロ • BASIC認証 → IDやパスワードの管理が煩雑 • ユーザーID / パスワード •

    メールアドレスにマジックリンクを送信 • GoogleやTwitterなどでソーシャルログイン • 門番の人にワイロを渡す
  2. ユーザー認証の実装方法イロイロ • BASIC認証 → IDやパスワードの管理が煩雑 • ユーザーID / パスワード →

    パスワード管理が必要。再設定とかどうする? • メールアドレスにマジックリンクを送信 • GoogleやTwitterなどでソーシャルログイン • 門番の人にワイロを渡す
  3. ユーザー認証の実装方法イロイロ • BASIC認証 → IDやパスワードの管理が煩雑 • ユーザーID / パスワード →

    パスワード管理が必要。再設定とかどうする? • メールアドレスにマジックリンクを送信 → メールの送受信が煩雑 • GoogleやTwitterなどでソーシャルログイン • 門番の人にワイロを渡す
  4. ユーザー認証の実装方法イロイロ • BASIC認証 → IDやパスワードの管理が煩雑 • ユーザーID / パスワード →

    パスワード管理が必要。再設定とかどうする? • メールアドレスにマジックリンクを送信 → メールの送受信が煩雑 • GoogleやTwitterなどでソーシャルログイン → そこがサ終したらどうする? • 門番の人にワイロを渡す
  5. ユーザー認証の実装方法イロイロ • BASIC認証 → IDやパスワードの管理が煩雑 • ユーザーID / パスワード →

    パスワード管理が必要。再設定とかどうする? • メールアドレスにマジックリンクを送信 → メールの送受信が煩雑 • GoogleやTwitterなどでソーシャルログイン → そこがサ終したらどうする? • 門番の人にワイロを渡す → 毎回お金がなくなって悲しい
  6. ユーザー認証の実装方法イロイロ • BASIC認証 → IDやパスワードの管理が煩雑 • ユーザーID / パスワード →

    パスワード管理が必要。再設定とかどうする? • メールアドレスにマジックリンクを送信 → メールの送受信が煩雑 • GoogleやTwitterなどでソーシャルログイン → そこがサ終したらどうする? • 門番の人にワイロを渡す → 毎回お金がなくなって悲しい
  7. ユーザー登録画面 <script lang='ts'> import type { VerifiedRegistrationResponse } from '@simplewebauthn/server';

    import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types'; import { goto, invalidateAll } from '$app/navigation'; import { startRegistration } from '@simplewebauthn/browser'; let username = $state(''); async function createPasskey() { if (username === '') { return; } const { options } = await (await fetch('/api/auth/register', { method: 'POST', body: JSON.stringify({ username }), credentials: 'same-origin', headers: { 'Content-Type': 'application/json', }, })).json().catch((err) => { console.error(err); }) as { options: PublicKeyCredentialCreationOptionsJSON | null }; if (!options) return; const registrationResponse = await startRegistration({ optionsJSON: options }); const verificationJSON: VerifiedRegistrationResponse = await (await fetch('/api/auth/register/verify-challenge', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(registrationResponse), })).json(); if (verificationJSON.verified) { await invalidateAll(); goto('/'); } } </script> <form action=""> <label>ユーザー名 <input type='text' required bind:value={username}> </label> <button onclick={createPasskey} disabled={username === ''}>登録</button> </form> 登録をリクエスト
  8. ユーザー作成とチャレンジ発行をするAPI import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types'; import type

    { RequestHandler } from './$types'; import { users } from '$lib/db/schema'; import { db } from '$lib/drizzle'; import { generateRegistrationOptions } from '@simplewebauthn/server'; import { error, json } from '@sveltejs/kit'; import { PUBLIC_RP_ID, PUBLIC_RP_NAME } from '$env/static/public'; export const POST: RequestHandler = async ({ request, locals: { session } }) => { const userName = (await request.json() as { username?: string }).username; if (!userName) return error(400, 'Parameter missing'); const [user] = await db.insert(users).values({ name: userName }).returning(); const options: PublicKeyCredentialCreationOptionsJSON = await generateRegistrationOptions({ rpName: PUBLIC_RP_NAME, rpID: PUBLIC_RP_ID, userName, userID: new TextEncoder().encode(user.id), attestationType: 'none', authenticatorSelection: { residentKey: 'required', userVerification: 'preferred', authenticatorAttachment: 'platform', }, }); await session.regenerate(); session.cookie.path = '/'; await session.setData({ userId: user.id, challenge: options.challenge, }); await session.save(); return json({ options }); }; 新規ユーザーを作成
  9. ユーザー作成とチャレンジ発行をするAPI import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types'; import type

    { RequestHandler } from './$types'; import { users } from '$lib/db/schema'; import { db } from '$lib/drizzle'; import { generateRegistrationOptions } from '@simplewebauthn/server'; import { error, json } from '@sveltejs/kit'; import { PUBLIC_RP_ID, PUBLIC_RP_NAME } from '$env/static/public'; export const POST: RequestHandler = async ({ request, locals: { session } }) => { const userName = (await request.json() as { username?: string }).username; if (!userName) return error(400, 'Parameter missing'); const [user] = await db.insert(users).values({ name: userName }).returning(); const options: PublicKeyCredentialCreationOptionsJSON = await generateRegistrationOptions({ rpName: PUBLIC_RP_NAME, rpID: PUBLIC_RP_ID, userName, userID: new TextEncoder().encode(user.id), attestationType: 'none', authenticatorSelection: { residentKey: 'required', userVerification: 'preferred', authenticatorAttachment: 'platform', }, }); await session.regenerate(); session.cookie.path = '/'; await session.setData({ userId: user.id, challenge: options.challenge, }); await session.save(); return json({ options }); }; チャレンジを発行
  10. ユーザー作成とチャレンジ発行をするAPI import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types'; import type

    { RequestHandler } from './$types'; import { users } from '$lib/db/schema'; import { db } from '$lib/drizzle'; import { generateRegistrationOptions } from '@simplewebauthn/server'; import { error, json } from '@sveltejs/kit'; import { PUBLIC_RP_ID, PUBLIC_RP_NAME } from '$env/static/public'; export const POST: RequestHandler = async ({ request, locals: { session } }) => { const userName = (await request.json() as { username?: string }).username; if (!userName) return error(400, 'Parameter missing'); const [user] = await db.insert(users).values({ name: userName }).returning(); const options: PublicKeyCredentialCreationOptionsJSON = await generateRegistrationOptions({ rpName: PUBLIC_RP_NAME, rpID: PUBLIC_RP_ID, userName, userID: new TextEncoder().encode(user.id), attestationType: 'none', authenticatorSelection: { residentKey: 'required', userVerification: 'preferred', authenticatorAttachment: 'platform', }, }); await session.regenerate(); session.cookie.path = '/'; await session.setData({ userId: user.id, challenge: options.challenge, }); await session.save(); return json({ options }); }; セッションに保存
  11. ユーザー登録画面 <script lang='ts'> import type { VerifiedRegistrationResponse } from '@simplewebauthn/server';

    import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types'; import { goto, invalidateAll } from '$app/navigation'; import { startRegistration } from '@simplewebauthn/browser'; let username = $state(''); async function createPasskey() { if (username === '') { return; } const { options } = await (await fetch('/api/auth/register', { method: 'POST', body: JSON.stringify({ username }), credentials: 'same-origin', headers: { 'Content-Type': 'application/json', }, })).json().catch((err) => { console.error(err); }) as { options: PublicKeyCredentialCreationOptionsJSON | null }; if (!options) return; const registrationResponse = await startRegistration({ optionsJSON: options }); const verificationJSON: VerifiedRegistrationResponse = await (await fetch('/api/auth/register/verify-challenge', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(registrationResponse), })).json(); if (verificationJSON.verified) { await invalidateAll(); goto('/'); } } </script> <form action=""> <label>ユーザー名 <input type='text' required bind:value={username}> </label> <button onclick={createPasskey} disabled={username === ''}>登録</button> </form>
  12. ユーザー認証とチャレンジの署名 <script lang='ts'> import type { VerifiedRegistrationResponse } from '@simplewebauthn/server';

    import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types'; import { goto, invalidateAll } from '$app/navigation'; import { startRegistration } from '@simplewebauthn/browser'; let username = $state(''); async function createPasskey() { if (username === '') { return; } const { options } = await (await fetch('/api/auth/register', { method: 'POST', body: JSON.stringify({ username }), credentials: 'same-origin', headers: { 'Content-Type': 'application/json', }, })).json().catch((err) => { console.error(err); }) as { options: PublicKeyCredentialCreationOptionsJSON | null }; if (!options) return; const registrationResponse = await startRegistration({ optionsJSON: options }); const verificationJSON: VerifiedRegistrationResponse = await (await fetch('/api/auth/register/verify-challenge', {auth/register/verify-challenge', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(registrationResponse), })).json(); if (verificationJSON.verified) { await invalidateAll(); goto('/'); } } </script> <form action=""> <label>ユーザー名 <input type='text' required bind:value={username}> </label> <button onclick={createPasskey} disabled={username === ''}>登録</button> </form> auth/register/verify-challenge', { 認証器に登録と 認証を依頼
  13. ユーザー認証とチャレンジの署名 <script lang='ts'> import type { VerifiedRegistrationResponse } from '@simplewebauthn/server';

    import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types'; import { goto, invalidateAll } from '$app/navigation'; import { startRegistration } from '@simplewebauthn/browser'; let username = $state(''); async function createPasskey() { if (username === '') { return; } const { options } = await (await fetch('/api/auth/register', { method: 'POST', body: JSON.stringify({ username }), credentials: 'same-origin', headers: { 'Content-Type': 'application/json', }, })).json().catch((err) => { console.error(err); }) as { options: PublicKeyCredentialCreationOptionsJSON | null }; if (!options) return; const registrationResponse = await startRegistration({ optionsJSON: options }); const verificationJSON: VerifiedRegistrationResponse = await (await fetch('/api/auth/register/verify-challenge', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(registrationResponse), })).json(); if (verificationJSON.verified) { await invalidateAll(); goto('/'); } } </script> <form action=""> <label>ユーザー名 <input type='text' required bind:value={username}> </label> <button onclick={createPasskey} disabled={username === ''}>登録</button> </form> 署名したチャレンジを 送り返す
  14. 署名されたチャレンジを検証するAPI import type { RegistrationResponseJSON } from '@simplewebauthn/types'; import type

    { RequestHandler } from './$types'; import { Buffer } from 'node:buffer'; import { passkeys } from '$lib/db/schema'; import { db } from '$lib/drizzle'; import { verifyRegistrationResponse } from '@simplewebauthn/server'; import { json } from '@sveltejs/kit'; import { PUBLIC_ORIGIN, PUBLIC_RP_ID } from '$env/static/public'; export const POST: RequestHandler = async ({ request, locals: { session } }) => { const registrationResponseJSON: RegistrationResponseJSON = await request.json(); const expectedChallenge = session.data.challenge; if (!expectedChallenge) return json({ error: 'Parameters incorrect' }, { status: 400, statusText: 'Bad Request' }); const verification = await (async () => { try { return await verifyRegistrationResponse({ response: registrationResponseJSON, expectedChallenge, expectedOrigin: PUBLIC_ORIGIN, expectedRPID: PUBLIC_RP_ID, }); } catch (error) { console.error(error); } })(); if (!verification) return json({ error: 'Challenge verification failed' }, { status: 400, statusText: 'Bad Request' }); const { verified } = verification; const { registrationInfo } = verification; const user = await db.query.users.findFirst({ where: ({ id }, { eq }) => eq(id, session.data.userId ?? ''), }); if (verified && registrationInfo && user) { const { credential, credentialDeviceType, credentialBackedUp, } = registrationInfo; await db.insert(passkeys) .values({ user_id: user.id, webauthn_user_id: user.id, id: credential.id, public_key: Buffer.from(credential.publicKey), counter: credential.counter, transports: credential.transports?.join(',') ?? null, device_type: credentialDeviceType, backed_up: credentialBackedUp, }); session.cookie.path = '/'; await session.setData({ userId: user.id }); await session.save(); } return json({ verified }); }; await verifyRegistrationResponse({ response: registrationResponseJSON, expectedChallenge, expectedOrigin: PUBLIC_ORIGIN, expectedRPID: PUBLIC_RP_ID, }); チャレンジの応答を検証
  15. 署名されたチャレンジを検証するAPI import type { RegistrationResponseJSON } from '@simplewebauthn/types'; import type

    { RequestHandler } from './$types'; import { Buffer } from 'node:buffer'; import { passkeys } from '$lib/db/schema'; import { db } from '$lib/drizzle'; import { verifyRegistrationResponse } from '@simplewebauthn/server'; import { json } from '@sveltejs/kit'; import { PUBLIC_ORIGIN, PUBLIC_RP_ID } from '$env/static/public'; export const POST: RequestHandler = async ({ request, locals: { session } }) => { const registrationResponseJSON: RegistrationResponseJSON = await request.json(); const expectedChallenge = session.data.challenge; if (!expectedChallenge) return json({ error: 'Parameters incorrect' }, { status: 400, statusText: 'Bad Request' }); const verification = await (async () => { try { return await verifyRegistrationResponse({ response: registrationResponseJSON, expectedChallenge, expectedOrigin: PUBLIC_ORIGIN, expectedRPID: PUBLIC_RP_ID, }); } catch (error) { console.error(error); } })(); if (!verification) return json({ error: 'Challenge verification failed' }, { status: 400, statusText: 'Bad Request' }); const { verified } = verification; const { registrationInfo } = verification; const user = await db.query.users.findFirst({ where: ({ id }, { eq }) => eq(id, session.data.userId ?? ''), }); if (verified && registrationInfo && user) { const { credential, credentialDeviceType, credentialBackedUp, } = registrationInfo; await db.insert(passkeys) .values({ user_id: user.id, webauthn_user_id: user.id, id: credential.id, public_key: Buffer.from(credential.publicKey), counter: credential.counter, transports: credential.transports?.join(',') ?? null, device_type: credentialDeviceType, backed_up: credentialBackedUp, }); session.cookie.path = '/'; await session.setData({ userId: user.id }); await session.save(); } return json({ verified }); }; await db.insert(passkeys) .values({ user_id: user.id, webauthn_user_id: user.id, id: credential.id, public_key: Buffer.from(credential.publicKey), counter: credential.counter, transports: credential.transports?.join(',') ?? null, device_type: credentialDeviceType, backed_up: credentialBackedUp, }); パスキーをDBに登録