Share
Embed
今日からできる! はじめてのパスキー認証
このスライドでは、次世代のユーザー認証方式「パスキー」の仕組みと実装方法を解説しています。
パスワード管理の煩雑さやセキュリティリスクを回避しつつ、簡単かつ安全な認証を可能にするパスキー認証を、フロントエンドとバックエンドのコード例を交えて具体的に紹介します。
SvelteKitを活用した実装例や、公開鍵暗号の基本もなんとなく網羅しています。
このスライドはSvelte Japan Offline Meetup #3で発表しました。
Google Slideのリンクはこちら
今日からできる! はじめてのパスキー認証
今日からできる! はじめてのパスキー認証 by HIBIKI CUBE is licensed under CC BY-ND 4.0
HIBIKI CUBE
December 16, 2024
Transcript
49 slides
-
ユーザー認証の実装方法イロイロ • BASIC認証 • ユーザーID / パスワード • メールアドレスにマジックリンクを送信 •
GoogleやTwitterなどでソーシャルログイン • 門番の人にワイロを渡す -
ユーザー認証の実装方法イロイロ • BASIC認証 → IDやパスワードの管理が煩雑 • ユーザーID / パスワード •
メールアドレスにマジックリンクを送信 • GoogleやTwitterなどでソーシャルログイン • 門番の人にワイロを渡す -
ユーザー認証の実装方法イロイロ • BASIC認証 → IDやパスワードの管理が煩雑 • ユーザーID / パスワード →
パスワード管理が必要。再設定とかどうする? • メールアドレスにマジックリンクを送信 • GoogleやTwitterなどでソーシャルログイン • 門番の人にワイロを渡す -
ユーザー認証の実装方法イロイロ • BASIC認証 → IDやパスワードの管理が煩雑 • ユーザーID / パスワード →
パスワード管理が必要。再設定とかどうする? • メールアドレスにマジックリンクを送信 → メールの送受信が煩雑 • GoogleやTwitterなどでソーシャルログイン • 門番の人にワイロを渡す -
ユーザー認証の実装方法イロイロ • BASIC認証 → IDやパスワードの管理が煩雑 • ユーザーID / パスワード →
パスワード管理が必要。再設定とかどうする? • メールアドレスにマジックリンクを送信 → メールの送受信が煩雑 • GoogleやTwitterなどでソーシャルログイン → そこがサ終したらどうする? • 門番の人にワイロを渡す -
ユーザー認証の実装方法イロイロ • BASIC認証 → IDやパスワードの管理が煩雑 • ユーザーID / パスワード →
パスワード管理が必要。再設定とかどうする? • メールアドレスにマジックリンクを送信 → メールの送受信が煩雑 • GoogleやTwitterなどでソーシャルログイン → そこがサ終したらどうする? • 門番の人にワイロを渡す → 毎回お金がなくなって悲しい -
ユーザー認証の実装方法イロイロ • BASIC認証 → IDやパスワードの管理が煩雑 • ユーザーID / パスワード →
パスワード管理が必要。再設定とかどうする? • メールアドレスにマジックリンクを送信 → メールの送受信が煩雑 • GoogleやTwitterなどでソーシャルログイン → そこがサ終したらどうする? • 門番の人にワイロを渡す → 毎回お金がなくなって悲しい -
パスワードを管理したくない • ハッシュ化するとはいえ、パスワードをDB上で管理するドキドキ • パスワードを入れても大丈夫なフォーム・ページを作るドキドキ • パスワードが流れても平気な通信経路にするドキドキ • パスワード忘れた時の再設定処理とか、全然ワクワクしない •
そもそもパスワードが漏洩して何かなった時が怖すぎる • → できればパスワードは使いたくない -
パスキーとは • 次世代のユーザー認証の方式 • 公開鍵暗号の仕組みを使って認証する • 機密性のある情報は誰にも公開されない • ログイン時に入力するものは何もなし •
生体認証とセットで動作 • フィッシングができない仕組み -
パスキーとは • 次世代のユーザー認証の方式 • 公開鍵暗号の仕組みを使って認証する • 機密性のある情報は誰にも公開されない • ログイン時に入力するものは何もなし •
生体認証とセットで動作 • フィッシングができない仕組み -
フィッシングができない仕組み • 各パスキーはRP IDでサービスに紐づいている • パスキーと対応するオリジンでないと使えない • どんなに見た目を似せたサイトでも、パスキーを使うことは不可能 HIBIKI @
Google google.com 🔒goo9le.com -
必要なもの フロントエンド • APIとやり取りする仕組み • デバイスの認証機とやり取りする仕組み バックエンド • ユーザーとパスキーを管理するDB •
チャレンジを発行するAPI • 署名されたチャレンジを検証するAPI • セッションを管理する仕組み -
ユーザー登録画面 <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> 登録をリクエスト -
ユーザー作成とチャレンジ発行をする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 }); }; 新規ユーザーを作成 -
ユーザー作成とチャレンジ発行をする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 }); }; チャレンジを発行 -
ユーザー作成とチャレンジ発行をする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 }); }; セッションに保存 -
ユーザー登録画面 <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> -
ユーザー認証とチャレンジの署名 <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', { 認証器に登録と 認証を依頼 -
ユーザー認証とチャレンジの署名 <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> 署名したチャレンジを 送り返す -
署名されたチャレンジを検証する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, }); チャレンジの応答を検証 -
署名されたチャレンジを検証する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に登録