Slide 1

Slide 1 text

今日からできる! はじめてのパスキー認証 〜芳醇なSvelteKitを添えて〜

Slide 2

Slide 2 text

自己紹介 HIBIKI CUBE @hibiki_cube

Slide 3

Slide 3 text

突然ですがみなさん

Slide 4

Slide 4 text

想像してみてください 新しく個人開発 やりたいな〜〜

Slide 5

Slide 5 text

個人開発を始めるとして… あんな機能… こんな機能も…

Slide 6

Slide 6 text

みなさんなら… じゃあログインも できた方がいいな…

Slide 7

Slide 7 text

みなさんなら… じゃあログインも できた方がいいな… どうやって ユーザー認証を 実現しますか?

Slide 8

Slide 8 text

ユーザー認証の実装方法イロイロ

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

パスワードを管理したくない ● ハッシュ化するとはいえ、パスワードをDB上で管理するドキドキ ● パスワードを入れても大丈夫なフォーム・ページを作るドキドキ ● パスワードが流れても平気な通信経路にするドキドキ ● パスワード忘れた時の再設定処理とか、全然ワクワクしない ● そもそもパスワードが漏洩して何かなった時が怖すぎる ● → できればパスワードは使いたくない

Slide 17

Slide 17 text

ここで現る救世主 Passkeys

Slide 18

Slide 18 text

パスキーとは ● 次世代のユーザー認証の方式 ● 公開鍵暗号の仕組みを使って認証する ● 機密性のある情報は誰にも公開されない ● ログイン時に入力するものは何もなし ● 生体認証とセットで動作 ● フィッシングができない仕組み

Slide 19

Slide 19 text

パスキーとは ● 次世代のユーザー認証の方式 ● 公開鍵暗号の仕組みを使って認証する ● 機密性のある情報は誰にも公開されない ● ログイン時に入力するものは何もなし ● 生体認証とセットで動作 ● フィッシングができない仕組み

Slide 20

Slide 20 text

公開鍵暗号で認証する仕組み

Slide 21

Slide 21 text

公開鍵暗号で認証する仕組み

Slide 22

Slide 22 text

公開鍵暗号で認証する仕組み 秘密鍵 公開鍵

Slide 23

Slide 23 text

公開鍵暗号で認証する仕組み 秘密鍵 公開鍵 俺の鍵持っといて

Slide 24

Slide 24 text

公開鍵暗号で認証する仕組み 秘密鍵 公開鍵

Slide 25

Slide 25 text

公開鍵暗号で認証する仕組み 秘密鍵 公開鍵 ログインしたいな

Slide 26

Slide 26 text

公開鍵暗号で認証する仕組み 秘密鍵 公開鍵 ログインしたいな じゃあこの問題を 解いてみな

Slide 27

Slide 27 text

公開鍵暗号で認証する仕組み 秘密鍵 公開鍵 秘密鍵で 署名したよ

Slide 28

Slide 28 text

公開鍵暗号で認証する仕組み 秘密鍵 公開鍵 秘密鍵で 署名したよ 確かに正しい鍵だ 通ってよし!

Slide 29

Slide 29 text

パスキー認証に必要なユーザー操作 プロンプトを確認して 生体認証するだけ

Slide 30

Slide 30 text

フィッシングができない仕組み ● 各パスキーはRP IDでサービスに紐づいている ● パスキーと対応するオリジンでないと使えない ● どんなに見た目を似せたサイトでも、パスキーを使うことは不可能 HIBIKI @ Google google.com 🔒goo9le.com

Slide 31

Slide 31 text

それではいよいよ…… パスキー認証 やってみよう!

Slide 32

Slide 32 text

お手軽な方法もあるが……

Slide 33

Slide 33 text

自由度が低いのが難点

Slide 34

Slide 34 text

それなら…… 自前で 実装しちゃおう!

Slide 35

Slide 35 text

必要なもの フロントエンド ● APIとやり取りする仕組み ● デバイスの認証機とやり取りする仕組み バックエンド ● ユーザーとパスキーを管理するDB ● チャレンジを発行するAPI ● 署名されたチャレンジを検証するAPI ● セッションを管理する仕組み

Slide 36

Slide 36 text

ユーザーとパスキーを管理するDB

Slide 37

Slide 37 text

ユーザー登録画面 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('/'); } } ユーザー名 登録 登録をリクエスト

Slide 38

Slide 38 text

ユーザー作成とチャレンジ発行をする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 }); }; 新規ユーザーを作成

Slide 39

Slide 39 text

ユーザー作成とチャレンジ発行をする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 }); }; チャレンジを発行

Slide 40

Slide 40 text

ユーザー作成とチャレンジ発行をする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 }); }; セッションに保存

Slide 41

Slide 41 text

ユーザー登録画面 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('/'); } } ユーザー名 登録

Slide 42

Slide 42 text

ユーザー認証とチャレンジの署名 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('/'); } } ユーザー名 登録 auth/register/verify-challenge', { 認証器に登録と 認証を依頼

Slide 43

Slide 43 text

ユーザー認証とチャレンジの署名 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('/'); } } ユーザー名 登録 署名したチャレンジを 送り返す

Slide 44

Slide 44 text

署名されたチャレンジを検証する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, }); チャレンジの応答を検証

Slide 45

Slide 45 text

署名されたチャレンジを検証する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に登録

Slide 46

Slide 46 text

ユーザー登録完了!

Slide 47

Slide 47 text

詳細はアドカレの記事も読んでみてください!

Slide 48

Slide 48 text

コードはGitHubにもあります!

Slide 49

Slide 49 text

ご清聴 ありがとうございました!