1 of 49

今日からできる!

はじめてのパスキー認証

〜芳醇なSvelteKitを添えて〜

2 of 49

自己紹介

HIBIKI CUBE

@hibiki_cube

3 of 49

突然ですがみなさん

4 of 49

想像してみてください

新しく個人開発

やりたいな〜〜

5 of 49

個人開発を始めるとして…

あんな機能…

こんな機能も…

6 of 49

みなさんなら…

じゃあログインも

できた方がいいな…

7 of 49

みなさんなら…

じゃあログインも

できた方がいいな…

どうやって

ユーザー認証を

実現しますか?

8 of 49

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

9 of 49

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

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

10 of 49

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

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

11 of 49

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

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

12 of 49

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

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

13 of 49

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

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

14 of 49

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

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

15 of 49

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

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

16 of 49

パスワードを管理したくない

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

17 of 49

ここで現る救世主

Passkeys

18 of 49

パスキーとは

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

19 of 49

パスキーとは

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

20 of 49

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

21 of 49

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

22 of 49

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

秘密鍵

公開鍵

23 of 49

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

秘密鍵

公開鍵

俺の鍵持っといて

24 of 49

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

秘密鍵

公開鍵

25 of 49

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

秘密鍵

公開鍵

ログインしたいな

26 of 49

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

秘密鍵

公開鍵

ログインしたいな

じゃあこの問題を

解いてみな

27 of 49

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

秘密鍵

公開鍵

秘密鍵で

署名したよ

28 of 49

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

秘密鍵

公開鍵

秘密鍵で

署名したよ

確かに正しい鍵だ

通ってよし!

29 of 49

パスキー認証に必要なユーザー操作

プロンプトを確認して

生体認証するだけ

30 of 49

フィッシングができない仕組み

  • 各パスキーはRP IDでサービスに紐づいている
  • パスキーと対応するオリジンでないと使えない
  • どんなに見た目を似せたサイトでも、パスキーを使うことは不可能

HIBIKI @ Google

google.com

🔒goo9le.com

31 of 49

それではいよいよ……

パスキー認証

やってみよう!

32 of 49

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

33 of 49

自由度が低いのが難点

34 of 49

それなら……

自前で

実装しちゃおう!

35 of 49

必要なもの

フロントエンド

  • APIとやり取りする仕組み
  • デバイスの認証機とやり取りする仕組み

バックエンド

  • ユーザーとパスキーを管理するDB
  • チャレンジを発行するAPI
  • 署名されたチャレンジを検証するAPI
  • セッションを管理する仕組み

36 of 49

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

37 of 49

ユーザー登録画面

<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>

登録をリクエスト

38 of 49

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

};

新規ユーザーを作成

39 of 49

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

};

チャレンジを発行

40 of 49

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

};

セッションに保存

41 of 49

ユーザー登録画面

<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>

42 of 49

ユーザー認証とチャレンジの署名

<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', {

認証器に登録と

認証を依頼

43 of 49

ユーザー認証とチャレンジの署名

<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>

署名したチャレンジを

送り返す

44 of 49

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

});

チャレンジの応答を検証

45 of 49

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

46 of 49

ユーザー登録完了!

🎉

🎉

47 of 49

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

48 of 49

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

49 of 49

ご清聴

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