今日からできる!
はじめてのパスキー認証
〜芳醇なSvelteKitを添えて〜
自己紹介
HIBIKI CUBE
@hibiki_cube
突然ですがみなさん
想像してみてください
新しく個人開発
やりたいな〜〜
個人開発を始めるとして…
あんな機能…
こんな機能も…
みなさんなら…
じゃあログインも
できた方がいいな…
みなさんなら…
じゃあログインも
できた方がいいな…
どうやって
ユーザー認証を
実現しますか?
ユーザー認証の実装方法イロイロ
ユーザー認証の実装方法イロイロ
ユーザー認証の実装方法イロイロ
ユーザー認証の実装方法イロイロ
ユーザー認証の実装方法イロイロ
ユーザー認証の実装方法イロイロ
ユーザー認証の実装方法イロイロ
ユーザー認証の実装方法イロイロ
パスワードを管理したくない
ここで現る救世主
Passkeys
パスキーとは
パスキーとは
公開鍵暗号で認証する仕組み
公開鍵暗号で認証する仕組み
公開鍵暗号で認証する仕組み
秘密鍵
公開鍵
公開鍵暗号で認証する仕組み
秘密鍵
公開鍵
俺の鍵持っといて
公開鍵暗号で認証する仕組み
秘密鍵
公開鍵
公開鍵暗号で認証する仕組み
秘密鍵
公開鍵
ログインしたいな
公開鍵暗号で認証する仕組み
秘密鍵
公開鍵
ログインしたいな
じゃあこの問題を
解いてみな
公開鍵暗号で認証する仕組み
秘密鍵
公開鍵
秘密鍵で
署名したよ
公開鍵暗号で認証する仕組み
秘密鍵
公開鍵
秘密鍵で
署名したよ
確かに正しい鍵だ
通ってよし!
パスキー認証に必要なユーザー操作
プロンプトを確認して
生体認証するだけ
フィッシングができない仕組み
HIBIKI @ Google
google.com
🔒goo9le.com
それではいよいよ……
パスキー認証
やってみよう!
お手軽な方法もあるが……
自由度が低いのが難点
それなら……
自前で
実装しちゃおう!
必要なもの
フロントエンド
バックエンド
ユーザーとパスキーを管理するDB
ユーザー登録画面
<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に登録
ユーザー登録完了!
🎉
🎉
詳細はアドカレの記事も読んでみてください!
コードはGitHubにもあります!
ご清聴
ありがとうございました!