メールアドレスの確認を行う の履歴(No.1)
更新サインアップ時にメールアドレスの確認を行う†
いきなりサインアップするのではなく、入力されたメールアドレスに確認リンク入りのメールを送って メールアドレスの確認が取れたらサインアップを行う、という動作をしたい。
https://lucia-auth.com/guidebook/email-verification-links/
によると
- authUser テーブルに email_verified フィールドを追加
- emailVerification テーブルを追加
という形で行う形が紹介されているけれど、 user テーブルに email_verified フィールドを追加する代わりに、 そもそも email を verify しないと signup ページに たどり着けない形にした方が間違いが生じない気がする?
- /signup でメールアドレスのみ入力
- メールが届く
- メールに記載のリンク先で名前やパスワードなどメールアドレス以外の情報を入力
- /signup/xxxxxxxx
のような。
これを実現するため emailVerification テーブルには以下のフィールドを定義する。
- token @id @default(uuid())
- createdAt
流れはこんな感じ
¨( skinparam handwritten true actor User participant System database Database User -> System : /signup へアクセス System --> User : メールアドレス入力フォームを表示 User -> System : メールアドレスを入力 System -> Database : トークンとメールアドレスを保存 System --> User : トークン入りの URL をメールで通知 User -> System : トークン入りの URL へアクセス System -> Database : トークンを確認 Database --> System : メールアドレスを返す System --> User : サインアップフォームを表示 User -> System : サインアップフォームを送信 System -> Database : トークンを確認 Database --> System : メールアドレスを返す System -> Database : ユーザー登録 System -> Database : トークンを削除 System --> User : サインアップ完了を表示 );
パスワードリセットもできるようにしたい†
サインアップと同様に以下の流れで行う。
- /reset-password にメールアドレスだけを入力
- メールが届く
- メールに記載のリンク先で新しいパスワードを入力
- /reset-password/xxxxxxxx
¨( skinparam handwritten true actor User participant System database Database User -> System : /reset-password へアクセス System --> User : メールアドレス入力フォームを表示 User -> System : メールアドレスを入力 System -> Database : トークンとメールアドレスを保存 System --> User : トークン入りの URL をメールで通知 User -> System : トークン入りの URL へアクセス System -> Database : トークンを確認 Database --> System : メールアドレスを返す System --> User : パスワード入力フォームを表示 User -> System : パスワード入力フォームを送信 System -> Database : トークンを確認 Database --> System : メールアドレスを返す System -> Database : パスワード変更 System -> Database : トークンを削除 System --> User : パスワード変更完了を表示 );
emailVerification テーブル†
prisma/schema.prisma
+ model emailVerification { + id String @id @default(uuid()) + email String @unique + createdAt DateTime @default(now()) @map("created_at") + }
id を秘密のトークンとして使う
LANG:console $ npx prisma migrate dev --name "add emailVerification table"
メールアドレス入力フォーム†
メールアドレス確認用の正規表現を使いまわせるようライブラリに切り出す
src/lib/emailRegexp.ts
LANG: ts // https://qiita.com/sakuro/items/1eaa307609ceaaf51123 export const emailRegexp = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
メールアドレスのチェックを行うスキーマを用意
src/lib/formSchemas/emailVerification.ts
LANG: ts import { z } from 'zod'; import { emailRegexp } from '$lib/emailRegexp' export const schema = z .object({ email: z.string().regex(emailRegexp, { message: 'メールアドレスが不正です' }), })
サインアップとパスワードリセットの両方を扱えるようにするため matcher を使ったルーティングを行う。
https://kit.svelte.jp/docs/advanced-routing#matching
src/lib/emailVerificationPurposes.ts
LANG: ts export const purposes = { signup: 'サインアップ', 'reset-password': 'パスワードリセット' };
src/params/emailVerificationPurpose.ts
LANG: ts import type { ParamMatcher } from '@sveltejs/kit'; import { purposes } from '$lib/emailVerificationPurposes'; export const match: ParamMatcher = (param) => { return Object.hasOwn(purposes, param); };
src/routes/(loggedOut)/[purpose=emailVerificationPurpose]/+page.svelte
LANG: html <script lang="ts"> import type { PageData } from './$types'; import { superForm } from 'sveltekit-superforms/client'; import { purposes } from '$lib/emailVerificationPurposes'; export let data: PageData; const { form, message, errors, submitting, capture, restore, enhance } = superForm(data.form, { taintedMessage: false }); export const snapshot = { capture, restore }; const purpose = purposes[data.purpose]; </script> <div> <h1>メールアドレスを入力して下さい</h1> <p>入力されたアドレスへ {purpose} を続けるためのリンクを含むメールを送信します<br /> そちらのリンクから {purpose} 手続きを進めて下さい</p> {#if $message}<span class="invalid">{$message}</span>{/if} <form method="POST" use:enhance> <label for="email">メールアドレス</label> <input type="text" name="email" bind:value={$form.email} disabled={$submitting} /> {#if $errors.email}<span class="invalid">{$errors.email}</span>{/if} <div><button disabled={$submitting}>メールを送信</button></div> </form> </div> <style> .invalid { color: red; } </style>
src/routes/(loggedOut)/[purpose=emailVerificationPurpose]/+page.server.ts
LANG: ts import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types'; import { schema } from '$lib/formSchemas/emailVerification'; import { superValidate } from 'sveltekit-superforms/server'; import { fail } from '@sveltejs/kit'; import { redirect } from 'sveltekit-flash-message/server'; import { purposes } from '$lib/emailVerificationPurposes'; import { transporter } from '$lib/server/transporter'; import { db } from '$lib/server/db' async function getPurpose(event: PageServerLoadEvent | RequestEvent) { const purpose = event.params.purpose as keyof typeof purposes; return purpose; } export const load = (async (event) => { const form = await superValidate(schema); const purpose = await getPurpose(event); return { form, purpose }; }) satisfies PageServerLoad; export const actions: Actions = { default: async (event) => { // フォームデータのバリデーション const form = await superValidate(event, schema); const purpose = await getPurpose(event); if (!form.valid) { return fail(400, { form, purpose }); } // TODO: emailVerification レコードを作成 // TODO: メールを送信 throw redirect(302, '/', { type: 'success', message: 'メールを送信しました'}, event); } };
emailVerification レコードを作成†
一定時間以内に作成されたものがすでに存在すればエラーにする
src/routes/(loggedOut)/[purpose=emailVerificationPurpose]/+page.server.ts
LANG: ts // emailVerification レコードが既に存在するか? const existing = await db.emailVerification.findUnique({where: {email: form.data.email}}) if(existing) { // 2時間以内に送っていればエラーにする if(existing.createdAt.getTime() > Date.now() - (1000 * 60 * 60 * 2)) { throw redirect(302, '/', { type: 'error', message: '先ほどメールを送信しましたのでしばらく経ってから試してください'}, event); } // 既存のものを削除 await db.emailVerification.delete({where: {email: form.data.email}}); } // レコードを作成する const record = await db.emailVerification.create({data: {email: form.data.email}}); const token = record.id;
メールを送信†
アドレスは /signup/[token] になる
https://shinobiworks.com/blog/385/ のように nodemailer を使う
LANG: console $ npm i nodemailer && npm i --save-dev @types/nodemailer
src/lib/server/transporter.ts
LANG: ts import { createTransport } from "nodemailer"; export const transporter = createTransport({ host: "localhost", port: 25, });
src/routes/(loggedOut)/[purpose=emailVerificationPurpose]/+page.server.ts
LANG: ts import { transporter } from '$lib/server/transporter'; ... // メールを送信 const url = `${event.url.origin}/${purpose}/${token}` try { await transporter.sendMail({ from: `"authtest system" <authtest@my.server.net>`, to: form.data.email, subject: `[authtest] ${purposes[purpose]} 用のリンクをお送りします`, text: `次の URL より ${purposes[purpose]} の手続きをお勧めください\n${url}` }); } catch (error) { throw redirect(302, event.url, { type: 'error', message: 'メールを送信できませんでした'}, event); }
サインアップフォーム†
フォームからメールアドレス欄を除く
src/lib/formSchemas/signup.ts
LANG: ts import { z } from 'zod'; - // https://qiita.com/sakuro/items/1eaa307609ceaaf51123 - const emailRegexp = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; - export const schema = z .object({ name: z.string().min(3, { message: 'ユーザー名は3文字以上で入力してください' }), - email: z.string().regex(emailRegexp, { message: 'メールアドレスが不正です' }), password: z .string() .regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, { message: 'パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください' }), confirm: z.string(), }) .refine((data) => data.password === data.confirm, { message: '確認用パスワードが一致しません', path: ['confirm'] });
disabled にしたフィールドは表示専用になりフォーム送信時にデータが送られない。
src/routes/(loggedOut)/signup/[token]/+page.svelte
LANG: html <label for="email">メールアドレス</label> + <input type="text" name="email" bind:value={data.email} disabled /> - <input type="text" name="email" bind:value={$form.email} disabled={$submitting} /> - {#if $errors.email}<span class="invalid">{$errors.email}</span>{/if}
以前の src/routes/(loggedOut)/signup/+page.* を src/routes/(loggedOut)/signup/[token]/+page.* に移す。
form と共に email アドレスを返す。
src/routes/(loggedOut)/signup/[token]/+page.server.ts
LANG: ts - import type { Actions, PageServerLoad } from './$types'; + import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types'; ... + async function getEmail(event: PageServerLoadEvent | RequestEvent) { + const record = await db.emailVerification.findUnique({where: {id: event.params.token}}) + if(!record || Date.now() - (1000 * 60 * 60 * 2) > record.createdAt.getTime()) { + // 見つからないあるいは古すぎる + throw redirect(302, '/', {type: 'error', message: '無効なトークンです'}, event); + } + return record.email; + } - export const load = (async () => { + export const load = (async (event) => { const form = await superValidate(schema); - return { form }; + const email = await getEmail(event) + return { form, email }; }) satisfies PageServerLoad; export const actions: Actions = { default: async (event) => { // フォームデータのバリデーション const form = await superValidate(event, schema); + const email = await getEmail(event) if (!form.valid) { return fail(400, { form, email }); } // サインアップ処理 try { const user = await auth.createUser({ key: { providerId: 'email', - providerUserId: form.data.email, + providerUserId: email, password: form.data.password }, attributes: { name: form.data.name, - email: form.data.email, + email: email, } }); ... - return fail(400, { form: { ...form, message: '名前またはメールアドレスが既存のアカウントと重複しています' } }); + return fail(400, { form: { ...form, message: '名前またはメールアドレスが既存のアカウントと重複しています' }, email }); ... - return fail(400, { form: { ...form, message: 'サインアップエラー' } }); + return fail(400, { form: { ...form, message: 'サインアップエラー' }, email }); ... + // レコードを消去 + await db.emailVerification.delete({where: {id: event.params.token}}) throw redirect(302, '/', { type: 'success', message: 'サインアップしました'}, event); } };
これで大丈夫そう。
パスワードリセット†
メール送信フォームでそのメールアドレスを持つアカウントの存在を確認する
src/routes/(loggedOut)/[purpose=emailVerificationPurpose]/+page.server.ts
LANG: ts // フォームデータのバリデーション const form = await superValidate(event, schema); + // パスワードリセットは既存のアカウントがなければエラー + if(purpose == 'reset-password') { + if(!await db.authUser.findUnique({where: {email: form.data.email}})){ + form.errors.email = [...(form.errors.email || []), '登録されていません']; + form.valid = false; + } + } if (!form.valid) { return fail(400, { form, purpose }); }
フォーム検証スキーム
authtest/src/lib/formSchemas/resetPassword.ts
LANG: ts import { z } from 'zod'; export const schema = z .object({ password: z .string() .regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, { message: 'パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください' }), confirm: z.string(), }) .refine((data) => data.password === data.confirm, { message: '確認用パスワードが一致しません', path: ['confirm'] });
ユーザー名とメールアドレスは変更不可
フォームに表示はするが disable なので値は POST されない。
src/routes/(loggedOut)/reset-password/[token]/+page.svelte
LANG: html <script lang="ts"> import type { PageData } from './$types'; import { superForm } from 'sveltekit-superforms/client'; export let data: PageData; const { form, message, errors, submitting, capture, restore, enhance } = superForm(data.form, { taintedMessage: false }); export const snapshot = { capture, restore }; </script> <div> <h1>パスワードの変更</h1> {#if $message}<span class="invalid">{$message}</span>{/if} <form method="POST" use:enhance> <label for="name">ユーザー名</label> <input type="text" name="name" bind:value={data.user.name} disabled /> <label for="email">メールアドレス</label> <input type="text" name="email" bind:value={data.user.email} disabled /> <label for="password">パスワード</label> <input type="password" name="password" bind:value={$form.password} disabled={$submitting} /> {#if $errors.password}<span class="invalid">{$errors.password}</span>{/if} <label for="confirm">パスワード(確認)</label> <input type="password" name="confirm" bind:value={$form.confirm} disabled={$submitting} /> {#if $errors.confirm}<span class="invalid">{$errors.confirm}</span>{/if} <div><button disabled={$submitting}>パスワードを変更</button></div> </form> </div> <style> .invalid { color: red; } </style>
src/routes/(loggedOut)/reset-password/[token]/+page.server.ts
LANG: ts import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types'; import { schema } from '$lib/formSchemas/signup'; import { superValidate } from 'sveltekit-superforms/server'; import { fail } from '@sveltejs/kit'; import { redirect } from 'sveltekit-flash-message/server'; import { auth } from '$lib/server/lucia'; import { db } from '$lib/server/db' async function getUser(event: PageServerLoadEvent | RequestEvent) { const record = await db.emailVerification.findUnique({where: {id: event.params.token}}); if(!record || Date.now() - (1000 * 60 * 60 * 2) > record.createdAt.getTime()) { throw redirect(302, '/', {type: 'error', message: '無効なトークンです'}, event); } const user = await db.authUser.findUnique({where: {email: record.email}}); if (!user) { throw redirect(302, '/', {type: 'error', message: '無効なトークンです'}, event); } return user; } export const load = (async (event) => { const form = await superValidate(schema); const user = await getUser(event); return { form, user }; }) satisfies PageServerLoad; export const actions: Actions = { default: async (event) => { // フォームデータのバリデーション const form = await superValidate(event, schema); const user = await getUser(event); if (!form.valid) { return fail(400, { form, user }); } // パスワードを変更 await auth.updateKeyPassword( 'email', user.email, form.data.password ) // 直接ログインする const session = await auth.createSession({userId: user.id, attributes: {}}); event.locals.auth.setSession(session); // レコードを消去 await db.emailVerification.delete({where: {id: event.params.token}}); throw redirect(302, '/', { type: 'success', message: 'パスワードを変更してログインしました'}, event); } };
メールアドレス変更†
これもメールアドレスを確認後、飛び先で確認ボタンを押したら変更という形にすべき。
'chage-email' というパスを用意
src/lib/emailVerificationPurposes.ts
LANG: ts export const purposes = { signup: 'サインアップ', 'reset-password': 'パスワードリセット', + 'change-email': 'メールアドレス変更', };
signup や reset-password はログアウト状態で行う処理だったのに対して この作業はログイン状態で行うので、
src/routes/(loggedOut)/[purpose=emailVerificationPurpose] を
src/routes/[purpose=emailVerificationPurpose] へ移動した上で、
コード上でログイン状態を確認することにした。
!!session?.user | |||
true | false | ||
purpose == 'change-email' | true | OK | Error |
false | Error | OK |
なので (purpose == 'change-email') !== (!! session?.user) で判断すればいい。
src/routes/[purpose=emailVerificationPurpose]/+page.server.ts
LANG: ts async function getPurpose(event: PageServerLoadEvent | RequestEvent) { const purpose = event.params.purpose as keyof typeof purposes; + const session = await event.locals.auth.validate(); + if((purpose == 'change-email') !== (!! session?.user)) { + throw redirect(302, '/', { type: 'error', message: 'ログイン状態が無効です'}, event); + } return purpose; } ... + // メールアドレス変更は既存のアカウントがあければエラー + if(purpose == 'change-email') { + if(await db.authUser.findUnique({where: {email: form.data.email}})){ + form.errors.email = [...(form.errors.email || []), '既存のアカウントと重複しています']; + form.valid = false; + } + }
フォームは単なる確認用なのでデータは何も受け取らない
したがって、バリデーション用スキームも必要ない
src/routes/(loggedIn)/change-email/[token]/+page.svelte
LANG: html <script lang="ts"> import type { PageData } from './$types'; export let data: PageData; </script> <div> <h1>メールアドレスの変更</h1> <form method="POST"> <label for="name">ユーザー名</label> <input type="text" name="name" value={data.user.name} disabled /> <label for="email">古いメールアドレス</label> <input type="text" name="email" value={data.user.email} disabled /> <label for="email">新しいメールアドレス</label> <input type="text" name="email" value={data.email} disabled /> <div><button>メールアドレスを変更</button></div> </form> </div>
認証キー(ここではメールアドレス)を変更する機能は Lucia には備わっていない?
見つけられなかったのでデータベースを直接書き換えている。
src/routes/(loggedIn)/change-email/[token]/+page.server.ts
LANG: ts import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types'; import { redirect } from 'sveltekit-flash-message/server'; import { db } from '$lib/server/db' async function getEmailAndUser(event: PageServerLoadEvent | RequestEvent) { const record = await db.emailVerification.findUnique({where: {id: event.params.token}}); if(!record || Date.now() - (1000 * 60 * 60 * 2) > record.createdAt.getTime()) { throw redirect(302, '/', {type: 'error', message: '無効なトークンです'}, event); } const session = await event.locals.auth.validate(); if(!session?.user) { throw redirect(302, '/login', {type: 'error', message: '旧メールアドレスでログインしてください'}, event); } return {email: record.email, user: session.user}; } export const load = (async (event) => { return await getEmailAndUser(event); }) satisfies PageServerLoad; export const actions: Actions = { default: async (event) => { const {email, user} = await getEmailAndUser(event); // メールアドレスを変更 await db.$transaction([ db.authUser.update({ where: {id: user.userId}, data: {email}, }), db.authKey.update({ where: {id: `email:${user.email}`, user_id: user.userId}, data: {id: `email:${email}`}, }), ]) // レコードを消去 await db.emailVerification.delete({where: {id: event.params.token}}); throw redirect(302, '/', { type: 'success', message: 'メールアドレスを変更しました'}, event); } };
ユーザー情報変更†
メールアドレス以外を変更できる
パスワードを変更しない場合には空にしておく
src/lib/formSchemas/changeProfile.ts
LANG: ts import { z } from 'zod'; export const schema = z .object({ name: z.string().min(3, { message: 'ユーザー名は3文字以上で入力してください' }), password: z .string() .regex(/^$|^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, { message: 'パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください' }), confirm: z.string(), }) .refine((data) => data.password === data.confirm, { message: '確認用パスワードが一致しません', path: ['confirm'] });
src/routes/(loggedIn)/change-profile/+page.svelte
LANG: html <script lang="ts"> import type { PageData } from './$types'; import { superForm } from 'sveltekit-superforms/client'; export let data: PageData; const { form, message, errors, submitting, capture, restore, enhance } = superForm(data.form, { taintedMessage: false }); export const snapshot = { capture, restore }; </script> <div> <h1>ユーザー情報の変更</h1> {#if $message}<span class="invalid">{$message}</span>{/if} <form method="POST" use:enhance> <label for="name">ユーザー名</label> <input type="text" name="name" bind:value={$form.name} disabled={$submitting} /> {#if $errors.name}<span class="invalid">{$errors.name}</span>{/if} <label for="email">メールアドレス</label> <input type="text" name="email" bind:value={data.user.email} disabled /> <label for="password">パスワード(変更しないなら何も入力しない)</label> <input type="password" name="password" bind:value={$form.password} disabled={$submitting} /> {#if $errors.password}<span class="invalid">{$errors.password}</span>{/if} <label for="confirm">パスワード(確認)</label> <input type="password" name="confirm" bind:value={$form.confirm} disabled={$submitting} /> {#if $errors.confirm}<span class="invalid">{$errors.confirm}</span>{/if} <div><button disabled={$submitting}>ユーザー情報を変更</button></div> </form> </div> <style> .invalid { color: red; } </style>
src/routes/(loggedIn)/change-profile/+page.server.ts
LANG: ts import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types'; import { schema } from '$lib/formSchemas/changeProfile'; import { superValidate } from 'sveltekit-superforms/server'; import { fail } from '@sveltejs/kit'; import { redirect } from 'sveltekit-flash-message/server'; import { auth } from '$lib/server/lucia'; import { db } from '$lib/server/db' async function getUser(event: PageServerLoadEvent | RequestEvent) { const session = await event.locals.auth.validate(); return session!.user; } export const load = (async (event) => { const form = await superValidate(schema); const user = await getUser(event); form.data.name = user.name; return { form, user }; }) satisfies PageServerLoad; export const actions: Actions = { default: async (event) => { // フォームデータのバリデーション const form = await superValidate(event, schema); const user = await getUser(event); if (!form.valid) { return fail(400, { form, user }); } const changed = [] as string[]; if(form.data.password) { await auth.updateKeyPassword('email', user.email, form.data.password); changed.push('パスワード'); } if(form.data.name != user.name) { db.authUser.update({where: {id: user.userId}, data: {name: user.name}}); changed.push('名前'); } let message: string; if(changed.length == 0) { message = '何も変更されませんでした'; } else { message = changed.join('と') + 'が変更されました'; } throw redirect(302, '/', { type: 'success', message}, event); } };
Node サーバーを作成してみる†
デプロイ手順を見てみる
https://kit.svelte.jp/docs/adapter-node
LANG: console $ npm i -D @sveltejs/adapter-node Packages: +21 +++++++++++++++++++++ Progress: resolved 327, reused 284, downloaded 21, added 21, done devDependencies: + @sveltejs/adapter-node 1.3.1 Done in 7s
svelte.config.js
LANG: js - import adapter from '@sveltejs/adapter-auto'; + // import adapter from '@sveltejs/adapter-auto'; + import adapter from '@sveltejs/adapter-node';
あとは npm で build すれば node build/index.js で立ち上がる?
LANG: console $ pnpm build > authtest@0.0.1 build C:\Users\osamu\Desktop\svelte\authtest > vite build vite v4.4.2 building SSR bundle for production... ✓ 159 modules transformed. vite v4.4.2 building for production... ✓ 122 modules transformed. .svelte-kit/output/client/_app/version.json 0.03 kB │ gzip: 0.05 kB .svelte-kit/output/client/.vite/manifest.json 7.03 kB │ gzip: 0.78 kB .svelte-kit/output/client/_app/immutable/assets/4.ff1cfcc4.css 0.04 kB │ gzip: 0.06 kB .svelte-kit/output/client/_app/immutable/assets/0.3a6d0da3.css 4.42 kB │ gzip: 1.20 kB .svelte-kit/output/client/_app/immutable/chunks/emailVerificationPurposes.c76ea6d4.js 0.10 kB │ gzip: 0.15 kB .svelte-kit/output/client/_app/immutable/chunks/navigation.69a4676d.js 0.16 kB │ gzip: 0.14 kB .svelte-kit/output/client/_app/immutable/chunks/stores.d869250f.js 0.24 kB │ gzip: 0.17 kB .svelte-kit/output/client/_app/immutable/nodes/2.02fcdf6e.js 0.66 kB │ gzip: 0.43 kB .svelte-kit/output/client/_app/immutable/nodes/1.43b0e5d2.js 0.84 kB │ gzip: 0.52 kB .svelte-kit/output/client/_app/immutable/chunks/parse.7d180a0f.js 1.31 kB │ gzip: 0.63 kB .svelte-kit/output/client/_app/immutable/nodes/3.f8093b5c.js 2.06 kB │ gzip: 1.05 kB .svelte-kit/output/client/_app/immutable/chunks/scheduler.690599bf.js 2.33 kB │ gzip: 1.08 kB .svelte-kit/output/client/_app/immutable/nodes/8.0a6a485a.js 3.19 kB │ gzip: 1.73 kB .svelte-kit/output/client/_app/immutable/chunks/singletons.0f20aa31.js 3.40 kB │ gzip: 1.76 kB .svelte-kit/output/client/_app/immutable/nodes/5.328fded3.js 3.77 kB │ gzip: 1.71 kB .svelte-kit/output/client/_app/immutable/nodes/6.7265cad3.js 4.53 kB │ gzip: 1.93 kB .svelte-kit/output/client/_app/immutable/nodes/4.41943ab9.js 4.99 kB │ gzip: 2.06 kB .svelte-kit/output/client/_app/immutable/nodes/7.33022ab8.js 5.13 kB │ gzip: 2.09 kB .svelte-kit/output/client/_app/immutable/chunks/index.ee4472c9.js 5.90 kB │ gzip: 2.51 kB .svelte-kit/output/client/_app/immutable/entry/app.2b7fc7d3.js 8.33 kB │ gzip: 2.67 kB .svelte-kit/output/client/_app/immutable/nodes/0.9f73b240.js 23.26 kB │ gzip: 7.68 kB .svelte-kit/output/client/_app/immutable/entry/start.cf548541.js 23.73 kB │ gzip: 9.38 kB .svelte-kit/output/client/_app/immutable/chunks/index.e68bcff4.js 27.10 kB │ gzip: 10.20 kB ✓ built in 3.24s .svelte-kit/output/server/.vite/manifest.json 9.26 kB .svelte-kit/output/server/_app/immutable/assets/_page.ff1cfcc4.css 0.04 kB .svelte-kit/output/server/_app/immutable/assets/_layout.3a6d0da3.css 4.42 kB .svelte-kit/output/server/entries/pages/_layout.server.ts.js 0.08 kB .svelte-kit/output/server/chunks/emailVerificationPurposes.js 0.13 kB .svelte-kit/output/server/entries/pages/_page.server.ts.js 0.14 kB .svelte-kit/output/server/entries/matchers/emailVerificationPurpose.js 0.17 kB .svelte-kit/output/server/internal.js 0.19 kB .svelte-kit/output/server/entries/endpoints/(loggedIn)/logout/_server.ts.js 0.30 kB .svelte-kit/output/server/entries/pages/_page.svelte.js 0.39 kB .svelte-kit/output/server/entries/fallbacks/error.svelte.js 0.47 kB .svelte-kit/output/server/chunks/stores.js 0.52 kB .svelte-kit/output/server/chunks/lucia.js 0.52 kB .svelte-kit/output/server/entries/pages/(loggedIn)/change-email/_token_/_page.svelte.js 0.95 kB .svelte-kit/output/server/entries/pages/(loggedOut)/login/_page.server.ts.js 1.09 kB .svelte-kit/output/server/chunks/db.js 1.14 kB .svelte-kit/output/server/entries/pages/(loggedIn)/change-email/_token_/_page.server.ts.js 1.31 kB .svelte-kit/output/server/entries/pages/(loggedIn)/change-profile/_page.server.ts.js 1.76 kB .svelte-kit/output/server/entries/pages/(loggedOut)/reset-password/_token_/_page.server.ts.js 1.90 kB .svelte-kit/output/server/chunks/_layout.server.js 2.02 kB .svelte-kit/output/server/entries/pages/_purpose_emailVerificationPurpose_/_page.svelte.js 2.15 kB .svelte-kit/output/server/chunks/hooks.server.js 2.22 kB .svelte-kit/output/server/entries/pages/(loggedOut)/login/_page.svelte.js 2.27 kB .svelte-kit/output/server/chunks/index.js 2.29 kB .svelte-kit/output/server/entries/pages/(loggedOut)/signup/_token_/_page.server.ts.js 2.40 kB .svelte-kit/output/server/entries/pages/(loggedOut)/reset-password/_token_/_page.svelte.js 2.54 kB .svelte-kit/output/server/entries/pages/_purpose_emailVerificationPurpose_/_page.server.ts.js 2.61 kB .svelte-kit/output/server/chunks/index3.js 2.65 kB .svelte-kit/output/server/entries/pages/(loggedIn)/change-profile/_page.svelte.js 2.67 kB .svelte-kit/output/server/entries/pages/(loggedOut)/signup/_token_/_page.svelte.js 2.70 kB .svelte-kit/output/server/chunks/navigation.js 2.72 kB .svelte-kit/output/server/chunks/internal.js 5.44 kB .svelte-kit/output/server/chunks/stringify.js 6.44 kB .svelte-kit/output/server/chunks/ssr.js 7.72 kB .svelte-kit/output/server/chunks/superValidate.js 8.93 kB .svelte-kit/output/server/chunks/errors.js 18.22 kB .svelte-kit/output/server/entries/pages/_layout.svelte.js 26.58 kB .svelte-kit/output/server/chunks/index2.js 44.74 kB .svelte-kit/output/server/index.js 102.76 kB Run npm run preview to preview your production build locally. > Using @sveltejs/adapter-node ✔ done ✓ built in 19.50s $ ls build client/ env.js handler.js index.js server/ shims.js $ HOST=127.0.0.1 PORT=4000 node build
ちゃんと build/ にファイルは作成され、 node build でサーバーは立ち上がるのだけれど、 アクセスするとデータベースが読めないという 500 エラーが出る。
LANG: console PrismaClientInitializationError: Invalid `prisma.authSession.findUnique()` invocation: Error querying the database: Error code 14: Unable to open the database file at ni.handleRequestError (C:\Users\osamu\Desktop\svelte\authtest\node_modules\.pnpm\@prisma+client@5.6.0_prisma@5.6.0\node_modules\@prisma\client\runtime\library.js:124:7090) at ni.handleAndLogRequestError (C:\Users\osamu\Desktop\svelte\authtest\node_modules\.pnpm\@prisma+client@5.6.0_prisma@5.6.0\node_modules\@prisma\client\runtime\library.js:124:6206) ...
データベース関連の設定をするにはどうしたら???
https://www.reddit.com/r/sveltejs/comments/14xxgt5/deploy_sveltekit_with_prisma_to_node_server_with/
- npx prisma migrate deploy
- npx prisma generate
が必要らしい。
LANG: console $ mkdir build/db $ DATABASE_URL="file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" npx prisma migrate deploy Environment variables loaded from .env Prisma schema loaded from prisma\schema.prisma Datasource "db": SQLite database "prod.db" at "file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" SQLite database prod.db created at file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db 5 migrations found in prisma/migrations Applying migration `20231121075732_add_tables_for_lucia` Applying migration `20231121093844_alter_tables_for_lucia_v2_again` Applying migration `20231121112232_add_roles` Applying migration `20231121112522_alter_roles` Applying migration `20231122211319_add_email_verification_table` The following migrations have been applied: migrations/ └─ 20231121075732_add_tables_for_lucia/ └─ migration.sql └─ 20231121093844_alter_tables_for_lucia_v2_again/ └─ migration.sql └─ 20231121112232_add_roles/ └─ migration.sql └─ 20231121112522_alter_roles/ └─ migration.sql └─ 20231122211319_add_email_verification_table/ └─ migration.sql $ DATABASE_URL="file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" npx prisma generate Environment variables loaded from .env Prisma schema loaded from prisma\schema.prisma ✔ Generated Prisma Client (v5.6.0) to .\node_modules\.pnpm\@prisma+client@5.6.0_prisma@5.6.0\node_modules\@prisma\client in 308ms Start using Prisma Client in Node.js (See: https://pris.ly/d/client) ``` import { PrismaClient } from '@prisma/client' const prisma = new PrismaClient() ``` or start using Prisma Client at the edge (See: https://pris.ly/d/accelerate) ``` import { PrismaClient } from '@prisma/client/edge' const prisma = new PrismaClient() ``` See other ways of importing Prisma Client: http://pris.ly/d/importing-client $ DATABASE_URL="file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" HOST=127.0.0.1 PORT=4000 node build
これでデータベースには接続できた。
ただ form を POST すると 403 Forbidden になる。
strict-origin-when-cross-origin のせいっぽい。
起動時に ORIGIN を指定してやれば大丈夫だった。
LANG: console $ DATABASE_URL="file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" HOST=127.0.0.1 PORT=4000 ORIGIN="http://localhost:4000" node build
ん?
動くには動くのだけれど build/db/prod.db にファイルが見つからない???
データベース本体はどこに作られているのか?!
うわぁ、ここにあった。
C:\c\Users\osamu\Desktop\svelte\authtest\build\db
これは MingW64 限定の問題だけど、データベースファイル位置をフルパスで指定するには
DATABASE_URL="file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db"
ではなく、
DATABASE_URL="file:/Users/osamu/Desktop/svelte/authtest/build/db/prod.db"
としないとダメみたいだった。
ダメなパスワードを検出してエラーにする†
- worst password list
https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10-million-password-list-top-100000.txt
をデータベースに入れておき、ここに載っていたら弾く。
Prisma への seeding†
そのために上記のリストを Prisma のデータベースに入れたい。
https://m-shige1979.hatenablog.com/entry/2021/11/20/213051
あたりを参考にしつつ、
prisma/seed.ts
LANG: ts import { db } from '../src/lib/server/db'; import * as fs from 'fs'; async function main() { await db.worstPassword.deleteMany(); const text = fs.readFileSync('./prisma/seed/10-million-password-list-top-1000000.txt', 'utf-8'); text.split(/\n\r?|\r/).forEach(async (value, rank)=> { await db.worstPassword.create({data:{value, rank}}) }) } main() .catch((e) => { console.error(e) process.exit(1) }) .finally(async () => { await db.$disconnect() })
これを実行するために以下を行う
- prisma/seed に 10-million-password-list-top-100000.txt をダウンロード
- ts-node を入れて .ts ファイルを直接実行可能にする
- そして ./node_modules/.bin/ts-node prisma/seed.ts
LANG: console $ mkdir prisma/seed $ curl https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Common-Credentials/10-million-password-list-top-100000.txt > prisma/seed/10-million-password-list-top-100000.txt $ npm install --save typescript ts-node $ ./node_modules/.bin/ts-node prisma/seed.ts TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for C:\Users\osamu\Desktop\svelte\authtest\prisma\seed.ts at new NodeError (node:internal/errors:399:5) at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:79:11) at defaultGetFormat (node:internal/modules/esm/get_format:121:38) at defaultLoad (node:internal/modules/esm/load:81:20) at nextLoad (node:internal/modules/esm/loader:163:28) at ESMLoader.load (node:internal/modules/esm/loader:605:26) at ESMLoader.moduleProvider (node:internal/modules/esm/loader:457:22) at new ModuleJob (node:internal/modules/esm/module_job:64:26) at ESMLoader.#createModuleJob (node:internal/modules/esm/loader:480:17) at ESMLoader.getModuleJob (node:internal/modules/esm/loader:434:34) { code: 'ERR_UNKNOWN_FILE_EXTENSION' }
エラーが出た。
https://qiita.com/nyanchu/items/82903e0463fa9d558639 を参考にして tsconfig.json を書き換える。
tsconfig.json
LANG: json { "extends": "./.svelte-kit/tsconfig.json", + "ts-node": { + "esm": true, + "experimentalSpecifierResolution": "node" + }, ...
これで実行はされるようになった・・・が、
LANG: console $ ./node_modules/.bin/ts-node prisma/seed.ts <--- Last few GCs ---> [54920:00000183318518E0] 42008 ms: Mark-sweep (reduce) 2047.2 (2083.1) -> 2046.6 (2083.6) MB, 765.8 / 0.0 ms (+ 46.6 ms in 14 steps since start of marking, biggest step 4.6 ms, walltime since start of marking 828 ms) (average mu = 0.382, current mu = [54920:00000183318518E0] 44566 ms: Mark-sweep (reduce) 2047.7 (2083.6) -> 2047.3 (2084.4) MB, 2164.6 / 0.0 ms (+ 58.2 ms in 12 steps since start of marking, biggest step 7.2 ms, walltime since start of marking 2238 ms) (average mu = 0.228, current mu <--- JS stacktrace ---> FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory ...
メモリ不足で落ちた。
prisma/seed.ts
LANG: ts - text.split(/\n\r?|\r/).forEach(async (value, rank)=> { - await db.worstPassword.create({data:{value, rank}}) - }) + const items = text.split(/\n\r?|\r/); + for(let rank = 0; rank < items.length; rank++) { + const value = items[rank]; + await db.worstPassword.create({data:{value, rank}}) + }
あー、考えてみれば元のコードでは forEach に与えてる async 関数を誰も await していなかったのでひたすら Promise の山が生成されてしまっていた。
素人丸出しのダメコードだった。
1つ1つの create を await するようにしたところ、時間はかかったものの、 ちゃんとデータが追加された。
npm で動かせるようにする†
package.json
LANG: json "dependencies": { "@lucia-auth/adapter-prisma": "^3.0.2", "@prisma/client": "^5.6.0", "lucia": "^2.7.4", "nodemailer": "^6.9.7", "svelte-french-toast": "^1.2.0", "ts-node": "^10.9.1" }, + "prisma": { + "seed": "ts-node prisma/seed.ts" + } }
これで、
LANG: console $ npm prisma db seed Environment variables loaded from .env Running seed command `ts-node prisma/seed.ts` ...
の形で seeding が行える。
ただ、 https://github.com/prisma/prisma/discussions/18057 などによると seeding というのはあくまで開発途上でデータベースにテストデータを入れたりするのに 使われるものなので、今回の用途のように本番環境でも使うようなのはいわゆる seeding の用途とは異なるようだ。
上記の処理は非常に長い時間もかかるので seed コマンドとは異なる形で実行できるように しておくべきっぽい。
どうするべきか要検討。
パスワードを検証するのに使う†
src/lib/formSchemas/changeProfile.ts
LANG: ts import { z } from 'zod'; + import { db } from '$lib/server/db'; export const schema = z .object({ name: z.string().min(3, { message: 'ユーザー名は3文字以上で入力してください' }), password: z.string().regex(/^$|^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, { message: 'パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください' }), confirm: z.string() }) .refine((data) => data.password === data.confirm, { message: '確認用パスワードが一致しません', path: ['confirm'] + }) + .superRefine(async (data, ctx) => { + const record = await db.worstPassword.findUnique({where: {value: data.password}, select: {rank: true}}); + if(record) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `容易に推測可能なパスワードです (rank = ${record.rank})`, + fatal: true, + path: ['password'], + }); + return z.NEVER; + } });
うまく判定されるようになった。
src/lib/formSchemas/resetPassword.ts や src/lib/formSchemas/signup.ts も同様に変更するため、 判定部分を切り出した。
authtest\src\lib\formSchemas\superRefine\password.ts
LANG: ts import { z } from 'zod'; import { db } from '$lib/server/db'; export async function refinerForPassword(data: {password: string}, ctx: z.RefinementCtx) { const record = await db.worstPassword.findUnique({where: {value: data.password}, select: {rank: true}}); if(record) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `容易に推測可能なパスワードです (rank = ${record.rank})`, fatal: true, path: ['password'], }); return z.NEVER; } }
これを使うと個々の schema では1行追加するだけで済む。
.superRefine(refinerForPassword);
src/lib/formSchemas/changeProfile.ts
LANG: ts import { z } from 'zod'; * import { refinerForPassword } from './superRefine/password' export const schema = z .object({ name: z.string().min(3, { message: 'ユーザー名は3文字以上で入力してください' }), password: z.string().regex(/^$|^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, { message: 'パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください' }), confirm: z.string() }) .refine((data) => data.password === data.confirm, { message: '確認用パスワードが一致しません', path: ['confirm'] }) * .superRefine(refinerForPassword);
src/lib/formSchemas/resetPassword.ts
src/lib/formSchemas/signup.ts
も同様にする。
パスワード検証 API を用意する†
サブミットしてみなくても脆弱性が分かるよう、 クライアントサイドから確認するための API を用意する。
src/routes/password-ranking/+server.ts
LANG: ts import { json } from '@sveltejs/kit'; import { db } from '$lib/server/db' export async function POST({ request }) { const { password } = await request.json(); const record = await db.worstPassword.findUnique({where: {value: password}, select: {rank: true}}); if(record) { return json(record.rank + 1); } return json(0); }
パスワード入力フォームから使う†
LANG: html <input type="password" name="password" bind:value={$form.password} disabled={$submitting} on:input={checkPassword} />
のように onInput イベントを拾って、
LANG: ts let timer: NodeJS.Timeout; function checkPassword(e: Event) { const password = (e.target as HTMLInputElement).value; if (password && password.length < 8) { errors.update((e)=>{e.password = ['パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください']; return e}); return; } // 一旦エラーをキャンセル errors.update((e)=>{e.password = undefined; return e}); // 設定待ちがあればキャンセル clearTimeout(timer); // 500 ms 後に設定 timer = setTimeout(()=> checkPasswordSub(password), 500); } function checkPasswordSub(password: string) { if(!password.match(/^$|^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/)) { errors.update((e)=>{e.password = ['パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください']; return e}); } else { const method = "POST"; const body = JSON.stringify({ password }); const headers = { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' }; fetch("/password-ranking", {method, headers, body}) .then((res)=> res.json()) .then((rank) => { if(rank) { errors.update((e)=>{e.password = [`容易に推測可能なパスワードです (rank = ${rank})`]; return e}); } else { errors.update((e)=>{e.password = undefined; return e}); } }) } }
のようにエラーチェックする。
他でも使えるようこれをコンポーネント化したい。
コンポーネント化する†
ラベル + Input + エラー表示をひとまとめにする。
type を動的に変更すると文句を言われるので、
LANG: html <input {...{ name, type }}
の形にしている。
src/lib/components/LabeledInput.svelte
LANG: html <script lang="ts"> import type { Writable } from 'svelte/store'; export let name: string; export let label: string; export let type = "text"; export let value: string; export let disabled = false; export let errors: Writable<{}> | undefined = undefined; let key = name as keyof typeof errors; </script> <label for={name}>{label}</label> <input {...{ name, type }} bind:value disabled={disabled} on:input /> {#if errors && $errors[key]}<span class="invalid">{$errors[key]}</span>{/if} <style> .invalid { color: red; } </style>
パスワードに特化してエラーチェックを行うのがこちら。
src/lib/components/PasswordInput.svelte
LANG: html <script lang="ts"> import type { Writable } from 'svelte/store'; import LabeledInput from '$lib/components/LabeledInput.svelte'; export let name: string; export let label: string; export let value: string; export let disabled = false; export let errors: Writable<{password?:string[]}> | undefined = undefined; function checkPasswordSub(password: string) { if(!password.match(/^$|^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/)) { errors?.update((e)=>{e.password = ['パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください']; return e}); } else { const method = "POST"; const body = JSON.stringify({ password }); const headers = { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' }; fetch("/password-ranking", {method, headers, body}) .then((res)=> res.json()) .then((rank) => { if(rank) { errors?.update((e)=>{e.password = [`容易に推測可能なパスワードです (rank = ${rank})`]; return e}); } else { errors?.update((e)=>{e.password = undefined; return e}); } }) } } let timer: NodeJS.Timeout; function checkPassword(e: Event) { console.log('ok'); const password = (e.target as HTMLInputElement).value; if (password && password.length < 8) { errors?.update((e)=>{e.password = ['パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください']; return e}); return; } // 一旦エラーをキャンセル errors?.update((e)=>{e.password = undefined; return e}); // 設定待ちがあればキャンセル clearTimeout(timer); // 500 ms 後に設定 timer = setTimeout(()=> checkPasswordSub(password), 500); } </script> <LabeledInput {...{name, label, disabled, errors}} type="password" bind:value on:input={checkPassword} />
これらを使うとフォーム定義がすっきりする。
src/routes/(loggedIn)/change-profile/+page.svelte
LANG: html <script lang="ts"> import type { PageData } from './$types'; import { superForm } from 'sveltekit-superforms/client'; import LabeledInput from '$lib/components/LabeledInput.svelte'; import PasswordInput from '$lib/components/PasswordInput.svelte'; export let data: PageData; const { form, message, errors, submitting, capture, restore, enhance } = superForm(data.form, { taintedMessage: false }); export const snapshot = { capture, restore }; </script> <div> <h1>ユーザー情報の変更</h1> {#if $message}<span class="invalid">{$message}</span>{/if} <form method="POST" use:enhance> <LabeledInput name="name" label="ユーザー名" bind:value={$form.name} disabled={$submitting} {errors} /> <LabeledInput name="email" label="メールアドレス" value={data.user.email} disabled={true} /> <PasswordInput name="password" label="パスワード(変更しないなら何も入力しない)" value={$form.password} disabled={$submitting} errors={errors} /> <LabeledInput name="confirm" label="パスワード(確認)" type="password" value={$form.confirm} disabled={$submitting} {errors} /> <div><button disabled={$submitting}>ユーザー情報を変更</button></div> </form> </div>
他の form もこれに準じて直す。
いまさらテストを追加†
サーバーの起動とテストの実行を切り離した方が使いやすそう?
サーバーの起動†
デバッグサーバーでテストするならこう
LANG: console $ DATABASE_URL="file:test.db" bash -c 'prisma migrate reset -f && pnpm dev --port 4173'
ビルド済みでテストするならこう
LANG: console $ DATABASE_URL="file:test.db" bash -c 'prisma migrate reset -f && pnpm build && pnpm preview --port 4173'
これを package.json に入れる方法が良く分からない?
https://qiita.com/riversun/items/d45b26f4a7aad6e51b69 によると、
LANG: console $ npm install --save-dev cross-env
しておいて、
package.json
+ "test-dev": "cross-env DATABASE_URL='file:test.db' prisma migrate reset -f && cross-env DATABASE_URL='file:test.db' vite dev --port 4173", + "test-preview": "cross-env DATABASE_URL='file:test.db' prisma migrate reset -f && cross-env DATABASE_URL='file:test.db' vite build && cross-env DATABASE_URL='file:test.db' vite preview --port 4173"
こうかな?
テストの実行†
上記のコマンドでサーバーを起動しておいて、
playwright.config.ts
LANG: ts import type { PlaywrightTestConfig } from '@playwright/test'; const config: PlaywrightTestConfig = { webServer: { command: "", port: 4173, reuseExistingServer: !process.env.CI, }, testDir: 'tests', testMatch: /(.+\.)?(test|spec)\.[jt]s/, }; export default config;
および
package.json
- "test:integration": "playwright test", + "test:integration": "cross-env DATABASE_URL='file:test.db' playwright test",
の設定で
LANG: console $ pnpm test:integration
とすればいい。
テスト時のメールは実際に送信する代わりにファイルに保存する†
src/lib/server/transporter.ts
LANG: ts import { createTransport, type SentMessageInfo, type SendMailOptions } from "nodemailer"; import { env } from "$env/dynamic/private"; import * as fs from "fs"; export const transporter = createTransport({ host: "localhost", port: 25, }); if(env.DATABASE_URL=='file:dev.db' || env.DATABASE_URL=='file:test.db') { transporter.sendMail = async (mailOptions: SendMailOptions) => { console.log(process.cwd()); console.log(mailOptions); fs.writeFileSync('test-results/mailOptions.txt', JSON.stringify(mailOptions)); return null as SentMessageInfo; }; }
テストを書く†
https://playwright.dev/docs/api/class-test
例えばこんな風に書ける。
LANG: ts test('/signup へフォームを送信', async ({ page }) => { await page.goto('/signup', {waitUntil: 'load'}); await page.locator('input[name="email"]').fill('test@example.com'); Promise.all([ page.locator('input[name="email"]').press('Enter'), page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}) ]); await expect(page.getByRole('heading', { name: 'Welcome to Svelte' })).toBeVisible(); });
でもこれを、
LANG: ts await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('form button').click();
とすると email フィールドへの入力が送信データに反映されないケースがあったり(svelte による reactivity が発揮される前にフォームが送信されてしまうため?)、
なぜか fill を2回繰り返さないと書き込み自体を行えないケースがあったり、
LANG: ts await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('input[name="email"]').fill('test@example.com'); // 念のためもう一度 orz
なかなかに落とし穴も多そうな雰囲気だ?
- locator と WaitForSelector, $, $$ の違い https://qiita.com/ko-he-8/items/85116e1d99ed4b176657
- npm playwright codegen で操作手順の記録ができる https://playwright.dev/docs/codegen-intro
- 以前 --ui オプションで GUI が立ち上がるのを確認した記録があるのだけれど、このプロジェクトにインストールされているバージョンだとそんなオプションないと言われる?
- https://www.summerbud.org/dev-notes/playwright-tips-that-will-make-your-life-easier
- .fill などは readonly や disable の間はそうじゃなくなるまで待つらしい
- race condition を避けるために Promise.all を使う
- DEBUG=pw:api pnpm test:integration で verbose な出力が得られる
- テストは test( を test.skip( にするとスキップできる
tests/auth.test.ts
LANG: ts import { expect, test, type Page, type TestInfo } from '@playwright/test'; import type { SendMailOptions } from "nodemailer"; import * as fs from "fs"; import { db } from '../src/lib/server/db.js'; test.setTimeout(5000); let testId = 0; let screenshotId = 0; let screenshotTitle = ""; async function screenshot(page: Page, info: TestInfo) { if(screenshotTitle != info.title) { testId++; screenshotTitle = info.title; screenshotId = 0; } await page.screenshot({path: `test-results/ss${testId.toString().padStart(4, '0')}-${info.title.replaceAll("/","_")}${++screenshotId}.png`}) } test('データベースをクリアする', async () => { await db.emailVerification.deleteMany() await db.authUser.deleteMany(); expect(await db.emailVerification.count()).toBe(0); expect(await db.authUser.count()).toBe(0); }); test('インデックスページを表示できる', async ({ page }, info) => { await page.goto('/', {waitUntil: 'load'}); await expect(page.getByRole('heading', { name: 'Welcome to Svelte' })).toBeVisible(); await screenshot(page, info); }); test('/signup のメールアドレスの検証', async ({ page }, info) => { await page.goto('/signup', {waitUntil: 'load'}); await page.locator('input[name="email"]').fill(''); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('form button')).not.toHaveProperty('disabled'); await expect(page.locator('input[name="email"]+.invalid')).toContainText('メールアドレスが不正です'); await page.locator('input[name="email"]').fill('abcd@'); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('form button')).not.toHaveProperty('disabled'); await expect(page.locator('input[name="email"]+.invalid')).toContainText('メールアドレスが不正です'); await page.locator('input[name="email"]').fill('@abcc'); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('form button')).not.toHaveProperty('disabled'); await expect(page.locator('input[name="email"]+.invalid')).toContainText('メールアドレスが不正です'); await screenshot(page, info); }); test('メールアドレスを入力する /signup', async ({ page }, info) => { await page.goto('/signup', {waitUntil: 'load'}); await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('form button')).not.toBeVisible(); await page.waitForLoadState('load'); await expect(page.url()).toBe('http://localhost:4173/'); await expect(page.locator('.toaster .message')).toHaveText('メールを送信しました') await page.waitForTimeout(200); await screenshot(page, info); }); test('連続して送ろうとするとエラーになる', async ({ page }, info) => { await page.goto('/signup', {waitUntil: 'load'}); await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('form button')).not.toHaveProperty('disabled'); await expect(page.locator('input[name="email"]+.invalid')).toContainText('先ほどメールを送信しましたのでしばらく経ってから試してください'); await screenshot(page, info); }); test('サインアップを続ける', async ({ page }, info) => { const mailOptions = JSON.parse(fs.readFileSync('test-results/mailOptions.txt', 'utf-8')) as SendMailOptions; await expect(mailOptions.text).toContain('http'); await page.goto((mailOptions.text as string).split(/\n/)[1], {waitUntil: 'load'}); await screenshot(page, info); await page.click('form button'); await expect(page.locator('form button')).not.toHaveProperty('disabled'); await expect(page.locator('input[name="name"]+.invalid')).toContainText('文字'); await expect(page.locator('input[name="name"]')).not.toHaveProperty('disabled'); await page.locator('input[name="name"]').fill('First Family'); await page.locator('input[name="name"]').fill('First Family'); // 1回だと失敗することがある??? await page.locator('input[name="name"]').press('Enter'); await screenshot(page, info); await expect(page.locator('form button')).not.toHaveProperty('disabled'); await expect(page.locator('input[name="email"]+.invalid')).not.toHaveProperty('disabled'); await screenshot(page, info); await expect(page.locator('input[name="name"]+.invalid')).not.toBeVisible(); await expect(page.locator('input[name="password"]+.invalid')).toContainText('文字'); await page.fill('input[name="password"]', 'aaaaaaaa'); await page.click('form button'); await expect(page.locator('form button')).not.toHaveProperty('disabled'); await expect(page.locator('input[name="email"]+.invalid')).not.toHaveProperty('disabled'); await expect(page.locator('input[name="password"]+.invalid')).toContainText('文字'); await page.fill('input[name="password"]', 'Aa1aaaaa'); await page.click('form button'); await expect(page.locator('form button')).not.toHaveProperty('disabled'); await expect(page.locator('input[name="email"]+.invalid')).not.toHaveProperty('disabled'); await expect(page.locator('input[name="password"]+.invalid')).not.toBeVisible(); await expect(page.locator('input[name="confirm"]+.invalid')).toContainText('一致しません'); await page.fill('input[name="confirm"]', 'Aa1aaaaa'); await screenshot(page, info); await page.click('form button'); await expect(page.locator('form button')).not.toHaveProperty('disabled'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toHaveText('サインアップしました') await page.waitForTimeout(200); await screenshot(page, info); // ログアウトする await logout(page, info); await page.waitForTimeout(200); await screenshot(page, info); }); async function logout(page: Page, info: TestInfo) { Promise.all([ page.goto('/logout', {waitUntil: 'load'}), page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}) ]); await expect(page.locator('.toaster .message').nth(0)).toHaveText('ログアウトしました') await page.waitForTimeout(200); await screenshot(page, info); } async function login(page: Page, email='test@example.com', password='Aa1aaaaa') { await page.goto('/login', {waitUntil: 'load'}); await page.locator('input[name="email"]').fill(email); await page.locator('input[name="password"]').fill(password); await page.locator('input[name="password"]').press('Enter'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toHaveText('ログインしました'); } test('ログインしていないのにログアウトする', async ({ page }, info) => { await page.goto('/logout', {waitUntil: 'load'}); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toHaveText('ログインユーザーのみアクセス可能です') await page.waitForTimeout(200); await screenshot(page, info); }); test('ログインする', async ({ page }, info) => { // ログインする await page.goto('/login', {waitUntil: 'load'}); await screenshot(page, info); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('input[name="email"]')).not.toHaveProperty('disabled'); await expect(page.locator('input[name="email"]+.invalid')).toContainText('入力して下さい'); await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('input[name="email"]')).not.toHaveProperty('disabled'); await expect(page.locator('input[name="password"]+.invalid')).toContainText('入力して下さい'); await page.locator('input[name="password"]').fill('Aa1aaaaa'); await screenshot(page, info); await page.locator('input[name="password"]').press('Enter'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toHaveText('ログインしました'); await page.waitForTimeout(200); await screenshot(page, info); }); test('名前やパスワードを変更する', async ({ page }, info)=> { // ログイン await login(page); // 名前・パスワードを変更する(実際にはしない) await page.goto('/change-profile', {waitUntil: 'load'}); await screenshot(page, info); await page.locator('input[name="name"]').press('Enter'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toContainText('変更されませんでした'); await page.waitForTimeout(200); await screenshot(page, info); // 名前を変更する await page.goto('/change-profile', {waitUntil: 'load'}); await page.locator('input[name="name"]').fill('First Middle Family'); await page.locator('input[name="name"]').fill('First Middle Family'); await page.locator('input[name="name"]').press('Enter'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toContainText('名前が変更されました'); await page.waitForTimeout(200); await screenshot(page, info); // パスワードを変更する await page.goto('/change-profile', {waitUntil: 'load'}); await screenshot(page, info); await page.locator('input[name="password"]').fill('Aa1aaaa'); await page.locator('input[name="password"]').fill('Aa1aaaa'); await page.locator('input[name="password"]').press('Enter'); await expect(page.locator('input[name="password"]')).not.toHaveProperty('disabled'); await expect(page.locator('input[name="password"]+.invalid')).toContainText('文字'); await page.locator('input[name="password"]').fill('Aa1aaaab'); await page.locator('input[name="password"]').fill('Aa1aaaab'); await page.locator('input[name="password"]').press('Enter'); await expect(page.locator('input[name="password"]')).not.toHaveProperty('disabled'); await expect(page.locator('input[name="confirm"]+.invalid')).toContainText('一致しません'); await page.locator('input[name="confirm"]').fill('Aa1aaaab'); await page.locator('input[name="confirm"]').fill('Aa1aaaab'); await page.locator('input[name="confirm"]').press('Enter'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toContainText('パスワードが変更されました'); await page.waitForTimeout(200); await screenshot(page, info); // 名前とパスワードを変更する await page.goto('/change-profile', {waitUntil: 'load'}); await screenshot(page, info); await expect(page.locator('input[name="name"]')).toHaveValue('First Middle Family'); await page.locator('input[name="name"]').fill('First Family'); await page.locator('input[name="name"]').fill('First Family'); await page.locator('input[name="password"]').fill('Aa1aaaaa'); await page.locator('input[name="password"]').fill('Aa1aaaaa'); await page.locator('input[name="confirm"]').fill('Aa1aaaaa'); await page.locator('input[name="confirm"]').fill('Aa1aaaaa'); await screenshot(page, info); await page.locator('input[name="confirm"]').press('Enter'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toContainText('パスワードと名前が変更されました'); await page.waitForTimeout(200); await screenshot(page, info); await logout(page, info); }); test('メールアドレスを変更する', async ({ page }, info)=> { // ログイン await login(page); await page.goto('/change-email', {waitUntil: 'load'}); await screenshot(page, info); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('input[name="email"]+.invalid')).toContainText('不正です'); await page.locator('input[name="email"]').fill('changed@example.com'); await page.locator('input[name="email"]').press('Enter'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toContainText('送信しました'); await page.waitForTimeout(200); await screenshot(page, info); const mailOptions = JSON.parse(fs.readFileSync('test-results/mailOptions.txt', 'utf-8')) as SendMailOptions; await expect(mailOptions.text).toContain('http'); await page.goto((mailOptions.text as string).split(/\n/)[1], {waitUntil: 'load'}); await screenshot(page, info); await expect(page.locator('input[name="name"]')).toHaveValue('First Family'); await expect(page.locator('input[name="email-old"]')).toHaveValue('test@example.com'); await expect(page.locator('input[name="email-new"]')).toHaveValue('changed@example.com'); Promise.all([ page.locator('form button').click(), page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}) ]); await expect(page.locator('.toaster .message').nth(0)).toContainText('変更しました'); await page.waitForTimeout(200); await screenshot(page, info); // ログアウト await logout(page, info); }); test('新しいメールアドレスでログインしてから元に戻す', async ({ page }, info)=> { await login(page, 'changed@example.com'); await page.goto('/change-email', {waitUntil: 'load'}); await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('input[name="email"]').fill('test@example.com'); await Promise.all([ page.locator('input[name="email"]').press('Enter'), page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}) ]); await expect(page.locator('.toaster .message').nth(0)).toContainText('送信しました'); const mailOptions2 = JSON.parse(fs.readFileSync('test-results/mailOptions.txt', 'utf-8')) as SendMailOptions; await expect(mailOptions2.text).toContain('http'); await page.goto((mailOptions2.text as string).split(/\n/)[1], {waitUntil: 'load'}); await page.locator('form button').click(); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toContainText('変更しました'); await logout(page, info); }); test('パスワードのリセットを行う', async ({ page }, info) => { await page.goto('/reset-password', {waitUntil: 'load'}); await screenshot(page, info); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('input[name="email"]+.invalid')).toContainText('不正です'); await page.locator('input[name="email"]').fill('notfound@example.com'); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('input[name="email"]+.invalid')).toContainText('登録されていません'); await page.locator('input[name="email"]').fill('test@example.com'); Promise.all([ page.locator('input[name="email"]').press('Enter'), page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}) ]); await expect(page.locator('.toaster .message').nth(0)).toContainText('送信しました'); await page.waitForTimeout(200); await screenshot(page, info); const mailOptions = JSON.parse(fs.readFileSync('test-results/mailOptions.txt', 'utf-8')) as SendMailOptions; await expect(mailOptions.text).toContain('http'); await page.goto((mailOptions.text as string).split(/\n/)[1], {waitUntil: 'load'}); await screenshot(page, info); await expect(page.locator('input[name="name"]')).toHaveValue('First Family'); await expect(page.locator('input[name="email"]')).toHaveValue('test@example.com'); await page.locator('input[name="password"]').press('Enter'); await expect(page.locator('input[name="password"]+.invalid')).toContainText('文字'); await page.locator('input[name="password"]').fill('Aa1aaaaa'); await page.locator('input[name="confirm"]').fill('Aa1aaaaa'); await screenshot(page, info); await page.locator('input[name="confirm"]').press('Enter'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toContainText('パスワードを変更してログインしました'); await page.waitForTimeout(200); await screenshot(page, info); });
LANG: console $ pnpm test:integration > authtest@0.0.1 test:integration C:\Users\osamu\Desktop\svelte\authtest > cross-env DATABASE_URL='file:test.db'; playwright test Running 12 tests using 1 worker ✓ 1 auth.test.ts:13:1 › データベースをクリアする (44ms) ✓ 2 auth.test.ts:21:1 › インデックスページを表示できる (351ms) ✓ 3 auth.test.ts:27:1 › /signup のメールアドレスの検証 (580ms) ✓ 4 auth.test.ts:48:1 › メールアドレスを入力する /signup (749ms) ✓ 5 auth.test.ts:64:1 › 連続して送ろうとするとエラーになる (509ms) ✓ 6 auth.test.ts:75:1 › サインアップを続ける (2s) ✓ 7 auth.test.ts:146:1 › ログインしていないのにログアウトする (569ms) ✓ 8 auth.test.ts:155:1 › ログインする (1s) ✓ 9 auth.test.ts:181:1 › 名前やパスワードを変更する (4s) ✓ 10 auth.test.ts:256:1 › メールアドレスを変更する (2s) ✓ 11 auth.test.ts:297:1 › 新しいメールアドレスでログインしてから元に戻す (1s) ✓ 12 auth.test.ts:321:1 › パスワードのリセットを行う (2s) Slow test file: auth.test.ts (16s) Consider splitting slow test files to speed up parallel execution 12 passed (17s)
権限を管理する†
AuthUser と 多対多 関係を持つ Role を導入する。
prisma/schema.prisma
model AuthUser { ... + roles Role[] } + model Role { + id String @id @default(uuid()) + name String @unique + + users AuthUser[] + }
マイグレーションする。
LANG: console $ npx prisma migrate dev --name "add Role"
Prisma で多対多関係を扱うためのコードは結構煩雑になるようなので、 Role を使うためのユーティリティ関数を db に持たせる。
このやり方が良いのかは疑問が残る?
src/lib/server/db.ts
LANG: ts import { PrismaClient } from '@prisma/client'; export class ExtendedPrismaClient extends PrismaClient { constructor() { super() } // userId が undefined なら権限は空とみなされる async getRoles(userId: string | undefined) { if(!userId) { return [] } return (await this.authUser.findUnique({ where: {id: userId}, select: {roles: true} }))?.roles || [] } async getRolesString(userId: string | undefined) { return (await this.getRoles(userId)).map((role)=> role.name); } async hasRole(userId: string | undefined, role: string) { return (await this.getRolesString(userId)).includes(role); } async addRoles(userId: string, ...roles: string[]) { await this.authUser.update({ where: { id: userId }, data: { roles: { connectOrCreate: roles.map(role=> ({where: {name: role}, create: {name: role}}) ) }, }, }) } async removeRoles(userId: string, ...roles: string[]) { await this.authUser.update({ where: { id: userId }, data: { roles: { deleteMany: roles.map(role=>({name: role})) }, }, }) } } export const db = new ExtendedPrismaClient();
Prisma の言語仕様を見ていると TypeScript と VSCode の存在がいかに偉大か実感する。 こんな DSL はエディタ上でエラー検出してくれなきゃ、とてもじゃないけど書けない。
ところで、
LANG: ts roles.map(role=>({name: role}))
などという記述に注意が必要。これを
LANG: ts roles.map(role=>{name: role})
と書いてしまうと role=>{ の { がブロックの開始として解釈されてしまい、うまく行かない。 (その場合 name: はラベルとして解釈される)
JavaScriptのアロー関数でオブジェクトを返す方法
https://dev.classmethod.jp/articles/arrow-func-return-object/
そこで一見すると無駄に見える括弧で括っている。
サインアップ時、最初のユーザーには admin 権限を持たせることにする。
src/routes/(loggedOut)/signup/+page.server.ts
+ import { db } from '$lib/server/db' ... + // 最初のユーザーには admin 権限を持たせる + if(await db.authUser.count() == 1) { + await db.addRoles(user.userId, 'admin') + }
/(admin) あるいは /admin から始まるパスには admin 権限を持っているユーザーしかアクセスできない。
src/hooks.server.ts
LANG:ts + if (event.route.id?.startsWith('/(admin)') || event.route.id?.startsWith('/admin')) { + if (!await db.hasRole(session?.user?.userId, 'admin')) { + return Response.redirect(`${event.url.origin}/`, 302); + } + }