メールアドレスの確認を行う の履歴(No.3)
更新目次†
サインアップ時にメールアドレスの確認を行う†
いきなりサインアップするのではなく、入力されたメールアドレスに確認リンク入りのメールを送って メールアドレスの確認が取れたらサインアップを行う、という動作をしたい。
https://lucia-auth.com/guidebook/email-verification-links/
によると
- authUser テーブルに email_verified フィールドを追加
- emailVerification テーブルを追加
という形で行う形が紹介されているけれど、、、
そもそも email を verify しないと signup ページに たどり着けない形にした方がすっきり解決できそうな?
- /account/new でメールアドレスのみ入力
- メールが届く
- メールに記載のリンク先で名前やパスワードなどメールアドレス以外の情報を入力
- /account/new/xxxxxxxx
のような。
これを実現するため emailVerification テーブルを作成:
- token @id @default(uuid())
- createdAt
流れはこんな感じ
¨( skinparam handwritten true actor User participant System database Database User -> System : /account/new へアクセス 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 : サインアップ完了を表示 );
パスワードリセットもできるようにしたい†
サインアップと同様に以下の流れで行う。
- /account/reset にメールアドレスだけを入力
- メールが届く
- メールに記載のリンク先で新しいパスワードを入力
- /account/reset/xxxxxxxx
¨( skinparam handwritten true actor User participant System database Database User -> System : /account/reset へアクセス 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") + + @@map("email-verification") + }
id を秘密のトークンとして使う
LANG:console $ pnpm prisma migrate dev --name "add emailVerification table" Environment variables loaded from .env Prisma schema loaded from prisma\schema.prisma Datasource "db": SQLite database "dev.db" at "file:./dev.db" Applying migration `20231129095659_add_email_verification_table` The following migration(s) have been created and applied from new schema changes: migrations/ └─ 20231129095659_add_email_verification_table/ └─ migration.sql Your database is now in sync with your schema. ✔ Generated Prisma Client (v5.6.0) to .\node_modules\.pnpm\@prisma+client@5.6.0_prisma@5.6.0\node_modules\@prisma\client in 280ms
メールアドレス入力フォーム†
メールアドレス確認用の正規表現を使いまわせるようライブラリに切り出す
src/lib/zod/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/zod/account/emailVerification.ts
LANG: ts import { z } from 'zod'; import { emailRegexp } from '$lib/zod/lib/emailRegexp'; export const schema = z .object({ email: z.string().regex(emailRegexp, { message: 'メールアドレスが不正です' }), })
サインアップとパスワードリセットの両方を扱えるようにするため matcher を使ったルーティングを行う。
https://kit.svelte.jp/docs/advanced-routing#matching
src/params/emailVerificationPurpose.ts
LANG: ts import type { ParamMatcher } from '@sveltejs/kit'; export const purposes = { 'new': 'サインアップ', 'reset': 'パスワードリセット' }; export const match: ParamMatcher = (param) => { return Object.hasOwn(purposes, param); };
src/params へ $params でアクセスできるようにする
svelte.config.js
kit: { ... + alias: { + $params: 'src/params' + }
src/routes/account/(logout)/[purpose=emailVerificationPurpose]/+page.server.ts
LANG: ts import type { Actions, PageServerLoad } from './$types'; import { schema } from '$lib/zod/account/emailVerification'; import { superValidate } from 'sveltekit-superforms/server'; import { fail, redirect } from '@sveltejs/kit'; import { setFlash } from 'sveltekit-flash-message/server'; import type { purposes } from '$params/emailVerificationPurpose'; import { path } from '$lib/server'; export const load = (async (event) => { const form = await superValidate(schema); const purpose = event.params.purpose as keyof typeof purposes; return { form, purpose }; }) satisfies PageServerLoad; export const actions: Actions = { default: async (event) => { // フォームデータのバリデーション const form = await superValidate(event, schema); const purpose = event.params.purpose as keyof typeof purposes; if (!form.valid) { return fail(400, { form, purpose }); } // TODO: emailVerification レコードを作成 // TODO: メールを送信 setFlash({ type: 'success', message: 'メールを送信しました' }, event); throw redirect(302, path('/')); }, };
src/routes/account/(logout)/[purpose=emailVerificationPurpose]/+page.svelte
LANG: html <script lang="ts"> import type { PageData } from './$types'; import { superForm } from 'sveltekit-superforms/client'; import { purposes } from '$params/emailVerificationPurpose'; import Dialog from '$lib/components/Dialog.svelte'; import Form from '$lib/components/Form.svelte'; import InputText from '$lib/components/InputText.svelte'; import Button from '$lib/components/Button.svelte'; 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> <Dialog title={purpose}> <Form message={$message} {enhance}> <InputText name="email" label="メールアドレス" bind:value={$form.email} disabled={$submitting} {errors} props={{ placeholder: 'account@example.com' }} /> <p>入力されたアドレスへリンクを含むメールを送信します<br> そのリンクから {purpose} 手続きを進めて下さい</p> <Button disabled={$submitting}>メールを送信</Button> </Form> </Dialog>
さしあたり、
src/routes/account/(logout)/new は
src/routes/account/(logout)/new/[token] に変えておく
LANG: console $ mkdir src/routes/account/\(logout\)/new/[token] $ git mv src/routes/account/\(logout\)/new/+page.* src/routes/account/\(logout\)/new/\[token\]/
これでフォームが表示されるようになった。
emailVerification レコードを作成†
一定時間以内に作成されたものがすでに存在すればエラーにする
src/routes/account/(logout)/[purpose=emailVerificationPurpose]/+page.server.ts
LANG: ts import { db } from '$lib/server/db'; ... // 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) { setFlash({ type: 'error', message: '先ほどメールを送信しましたのでしばらく経ってから試してください'}, event); throw redirect(302, path('/')); } // 既存のものを削除 await db.emailVerification.delete({ where: { email: form.data.email } }); } // レコードを作成する const record = await db.emailVerification.create({ data: { email: form.data.email } }); const token = record.id;
メールを送信†
送るアドレスは /account/[purpose]/[token] になる
https://shinobiworks.com/blog/385/ のように nodemailer を使う
LANG: console $ pnpm add nodemailer && pnpm add -D @types/nodemailer Packages: +1 + Progress: resolved 349, reused 326, downloaded 0, added 1, done dependencies: + nodemailer 6.9.7 Done in 5.5s Packages: +1 + Progress: resolved 350, reused 327, downloaded 0, added 1, done devDependencies: + @types/nodemailer 6.4.14 Done in 4s
テスト環境では画面に表示& ./test-result/mailOptions.txt へ出力とする。
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 }); // 開発環境やテスト環境なら実際にはメールを送らずに // コンソールへ表示 & test-results/mail-sent.txt へ保存 if (env.DATABASE_URL.match(/\b(dev|test)\.db$/)) { transporter.sendMail = async (mailOptions: SendMailOptions) => { console.log(mailOptions); if(!fs.existsSync('test-results')) { fs.mkdirSync('test-results', {mode: 0o755}); } fs.writeFileSync('test-results/mail-sent.txt', JSON.stringify(mailOptions)); return null as SentMessageInfo; }; }
LANG: console $ echo "/test-results" >> .gitignore
src/lib/index.ts
LANG: console // place files you want to import through the `$lib` alias in this folder. export const appName = "authtest"; export const appEmail = '"authtest system" <authtest@my.server.net>';
src/routes/account/(logout)/[purpose=emailVerificationPurpose]/+page.server.ts
LANG: ts import { purposes } from '$params/emailVerificationPurpose'; import { transporter } from '$lib/server/transporter'; import { appName, appEmail } from '$lib'; ... // メールを送信 const url = `${event.url.origin}/account/${purpose}/${token}`; try { await transporter.sendMail({ from: appEmail, to: form.data.email, subject: `[${appName}] ${purposes[purpose]} 用のリンクをお送りします`, text: `次の URL より ${purposes[purpose]} の手続きをお進めください\n${url}`, }); } catch (error) { if (error instanceof Object) { console.log(error.toString()); } setFlash({ type: 'error', message: 'メールを送信できませんでした' }, event); throw redirect(302, event.url); }
これでメールの送信まで行えるようになった。
試しに test@example.com と入れてみると、ログには以下が表示された。
LANG: js { from: '"authtest system" <authtest@my.server.net>', to: 'test@example.com', subject: '[authtest] サインアップ 用のリンクをお送りします', text: '次の URL より サインアップ の手続きをお進めください\n' + 'http://localhost:5173/account/new/c0892bd8-4093-4548-95ce-ff9b42d4fdc5' }
LANG: console $ git add . && git commit -m "メールアドレス確認フォームを作成した"
サインアップフォームを改修†
フォームからメールアドレス欄を除く
src/lib/zod/account/new.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/acount/(logout)/new/[token]/+page.svelte
LANG: html <InputText name="email" label="メールアドレス" - bind:value={$form.email} - disabled={$submitting} - {errors} * bind:value={data.email} * disabled />
form と共に email アドレスを返すようにする
src/routes/acount/(logout)/new/[token]/+page.server.ts
LANG: ts - import type { Actions, PageServerLoad } from './$types'; + import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types'; + import { db } from '$lib/server/db'; ... + 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()) { + // 見つからないあるいは古すぎる + setFlash({type: 'error', message: '無効なトークンです'}, event); + throw redirect(302, path('/')); + } + 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}}) * setFlash({ type: 'success', message: 'サインアップ&ログインしました' }, event); throw redirect(302, path('/')); } };
正しくサインアップできることを確認した。
LANG: console $ git add . && git commit -m "サインアップフォームを更新" 🌼 daisyUI 4.4.14 ├─ ✔︎ 1 theme added https://daisyui.com/docs/themes ╰─ ❤︎ Support daisyUI project: https://opencollective.com/daisyui src/lib/zod/account/new.ts 1645ms (unchanged) src/routes/account/(logout)/new/[token]/+page.server.ts 78ms src/routes/account/(logout)/new/[token]/+page.svelte 319ms (unchanged) [master 1c3cbdd] サインアップフォームを更新 3 files changed, 26 insertions(+), 21 deletions(-)
パスワードリセット†
メール送信フォームでそのメールアドレスを持つアカウントの存在を確認する
src/lib/server/index.ts に以下を追加
LANG: ts // superforms の form コントロールにエラーメッセージを追加する // 使用例:formAddError(form, 'email', 'そのアドレスは使えません'); export function addErrorToForm< KEYS extends string, FORM extends { valid: boolean, errors: {[key in KEYS]?: string[]} } >(f: FORM, item: KEYS, message: string) { f.errors[item] = [...(f.errors[item] || []), message]; f.valid = false; };
src/routes/account/(logout)/[purpose=emailVerificationPurpose]/+page.server.ts
LANG: ts + import { appName, appEmail } from '$lib'; + import { addErrorToForm } from '$lib/server'; ... // フォームデータのバリデーション const form = await superValidate(event, schema); const purpose = await getPurpose(event); + + // パスワードリセットは既存のアカウントがなければエラー + if(purpose == 'reset') { + if(!await db.user.findUnique({where: {email: form.data.email}})){ + addErrorToForm(form, 'email', '登録されていません'); + } + } + if (!form.valid) { return fail(400, { form, purpose }); }
フォーム検証スキームを作成するにあたって、 password + confirm の手続きが2か所に出てくることになるので lib に切り出す。
src/lib/zod/lib/passwordAndConfirm.ts
import { z } from 'zod';
export { z }; export function passwordAndConfirm<KEYS extends string>(additional: { [key in KEYS]: z.ZodSchema }) { return z.object({ ...additional, password: z.string().regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, { message: 'パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください', }), confirm: z.string(), }) .refine(({ password, confirm }) => password === confirm, { message: '確認用パスワードが一致しません', path: ['confirm'], }); }
src/lib/zod/account/new.ts
LANG: ts import { z, passwordAndConfirm } from '$lib/zod/lib/passwordAndConfirm'; export const schema = passwordAndConfirm({ name: z.string().min(3, { message: 'ユーザー名は3文字以上で入力してください' }), });
src/lib/zod/account/reset.ts
import { passwordAndConfirm } from '$lib/zod/lib/passwordAndConfirm'; export const schema = passwordAndConfirm({});
パスワードリセットフォームではユーザー名とメールアドレスは変更不可
フォームに表示はするが disable なので値は POST されない。
src/routes/account/(logout)/reset/[token]/+page.svelte
LANG: html <script lang="ts"> import type { PageData } from './$types'; import { superForm } from 'sveltekit-superforms/client'; import Dialog from '$lib/components/Dialog.svelte'; import Form from '$lib/components/Form.svelte'; import InputText from '$lib/components/InputText.svelte'; import InputPassword from '$lib/components/InputPassword.svelte'; import Button from '$lib/components/Button.svelte'; export let data: PageData; const { form, message, errors, submitting, capture, restore, enhance } = superForm(data.form, { taintedMessage: false, }); export const snapshot = { capture, restore }; </script> <Dialog title="パスワードのリセット"> <Form message={$message} {enhance}> <InputText name="name" label="ユーザー名" bind:value={data.user.name} disabled /> <InputText name="email" label="メールアドレス" bind:value={data.user.email} disabled /> <InputPassword name="password" label="新しいパスワード" bind:value={$form.password} disabled={$submitting} {errors} /> <InputPassword name="confirm" label="新しいパスワード(確認)" bind:value={$form.confirm} disabled={$submitting} {errors} /> <Button disabled={$submitting}>パスワードを変更</Button> </Form> </Dialog>
src/routes/account/(logout)/reset/[token]/+page.server.ts
LANG: ts import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types'; import { schema } from '$lib/zod/account/reset'; import { superValidate } from 'sveltekit-superforms/server'; import { fail, redirect } from '@sveltejs/kit'; import { setFlash } from 'sveltekit-flash-message/server'; import { auth } from '$lib/server/lucia'; import { db } from '$lib/server/db' import { path } from '$lib/server'; 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()) { setFlash({type: 'error', message: '無効なトークンです'}, event); throw redirect(302, path('/')); } const user = await db.user.findUnique({where: {email: record.email}}); if (!user) { setFlash({type: 'error', message: '無効なトークンです'}, event); throw redirect(302, path('/')); } 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 = { 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}}); setFlash({ type: 'success', message: 'パスワードを変更してログインしました'}, event); throw redirect(302, path('/')); } } satisfies Actions;
パスワードを忘れた際にパスワードリセットを行えるようになった。
LANG: console $ git add . && git commit -m "パスワードリセット機能の追加"
メールアドレス変更†
これもメールアドレスを確認後、飛び先で確認ボタンを押したら変更という形にすべき。
'/account/email' というパスを用意
src/params/emailVerificationPurposes.ts
LANG: ts export const purposes = { signup: 'サインアップ', reset: 'パスワードリセット', + email: 'メールアドレス変更', };
signup や reset はログアウト状態で行う処理だったのに対して この作業はログイン状態で行うので、
src/routes/(loggedOut)/[purpose=emailVerificationPurpose] を
src/routes/[purpose=emailVerificationPurpose] へ移動
LANG: console $ git mv src/routes/account/\(logout\)/\[purpose\=emailVerificationPurpose\] src/routes/account/
コード上でログイン状態を確認することにした。
!!session?.user | |||
true | false | ||
purpose == 'email' | true | OK | Error |
false | Error | OK |
なので (purpose == 'email') !== (!! session?.user) で判断すればいい。
src/routes/account/[purpose=emailVerificationPurpose]/+page.server.ts
LANG: ts * import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types'; ... + function getPurpose(event: PageServerLoadEvent | RequestEvent) { + const purpose = event.params.purpose as keyof typeof purposes; + if((purpose == 'email') !== (!! event.locals.session?.user)) { + setFlash({ type: 'error', message: 'ログイン状態が無効です'}, event); + throw redirect(302, path('/')); + } + return purpose; + } ... - const purpose = event.params.purpose as keyof typeof purposes; + const purpose = getPurpose(event); ... - const purpose = event.params.purpose as keyof typeof purposes; + const purpose = getPurpose(event); ... + // メールアドレス変更は既存のアカウントがあければエラー + if(purpose == 'email') { + if(await db.user.findUnique({where: {email: form.data.email}})){ + form.errors.email = [...(form.errors.email || []), '既存のアカウントと重複しています']; + form.valid = false; + } + }
フォームは単なる確認用なのでデータは何も受け取らない
したがって、バリデーション用スキームも必要ない
src/routes/account/(login)/email/[token]/+page.svelte
LANG: html <script lang="ts"> import type { PageData } from './$types'; import Dialog from '$lib/components/Dialog.svelte'; import Form from '$lib/components/Form.svelte'; import InputText from '$lib/components/InputText.svelte'; import Button from '$lib/components/Button.svelte'; export let data: PageData; </script> <Dialog title="メールアドレスの変更"> <Form> <InputText name="name" label="ユーザー名" value={data.user.name} disabled /> <InputText name="email" label="旧メールアドレス" bind:value={data.user.email} disabled /> <InputText name="email" label="新メールアドレス" bind:value={data.email} disabled /> <Button>メールアドレスを変更</Button> </Form> </Dialog>
認証キー(ここではメールアドレス)を変更する機能は Lucia には備わっていない?
見つけられなかったのでデータベースを直接書き換えている。
src/routes/(loggedIn)/change-email/[token]/+page.server.ts
LANG: ts import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types'; import { setFlash } from 'sveltekit-flash-message/server'; import { db } from '$lib/server/db'; import { redirect } from '@sveltejs/kit'; import { path } from '$lib/server'; 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()) { setFlash({type: 'error', message: '無効なトークンです'}, event); throw redirect(302, path('/')); } const session = await event.locals.auth.validate(); if(!session?.user) { setFlash({type: 'error', message: '旧メールアドレスでログインしてください'}, event); throw redirect(302, path('/session/new')); } return {email: record.email, user: session.user}; } export const load = (async (event) => { return await getEmailAndUser(event); }) satisfies PageServerLoad; export const actions = { default: async (event) => { const {email, user} = await getEmailAndUser(event); // メールアドレスを変更 await db.$transaction([ db.user.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}}); setFlash({ type: 'success', message: 'メールアドレスを変更しました'}, event); throw redirect(302, path('/')); } } satisfies Actions;
メールアドレスを変更できるようになった
LANG: console $ git add . && git commit -m "メールアドレスを変更できるようになった"
いろいろと使いやすいように変更†
https://zenn.dev/kalan/articles/3d9b352200878495daaa#%E3%81%BE%E3%81%A8%E3%82%81
これのせいで <a href="javascript:history.back()"> にワーニングが出る。
<!-- svelte-ignore a11y-invalid-attribute -->
を付けて回避する。
https://github.com/sveltejs/sapper/issues/1736 ここでも言われてるけど こんな雑な判定で警告を出すのは邪魔だ。
InputText を <div> で囲い忘れていた
src/lib/components/InputText.svelte
LANG: html + <div class="form-control w-full"> ... + </div>
トップページにアクション用のリンクを追加
src/routes/+page.svelte
LANG: html {#if data.user} <p>Hello {data.user.name} !!</p> + <ul> + <li><a href={data.urlRoot + '/session/delete'}>ログアウト</a></li> + <li><a href={data.urlRoot + '/account/email'}>メールアドレスを変更</a></li> + </ul> + {:else} + <ul> + <li><a href={data.urlRoot + '/session/new'}>ログイン</a></li> + <li><a href={data.urlRoot + '/account/new'}>サインアップ</a></li> + <li><a href={data.urlRoot + '/account/reset'}>パスワードを忘れた</a></li> + </ul> {/if}
フォームにリンクを追加
src/routes/account/[purpose=emailVerificationPurpose]/+page.svelte
LANG: html <p> 入力されたアドレスへリンクを含むメールを送信します<br /> - そのリンクから {purpose} 手続きを進めて下さい + そのリンクから {purpose} 手続きを続けて下さい </p> <Button disabled={$submitting}>メールを送信</Button> + <!-- svelte-ignore a11y-invalid-attribute --> + <p><a class="link link-primary" href="javascript:history.back();">前の画面に戻る</a></p> </Form> </Dialog>
src/routes/session/(logout)/new/+page.svelte
LANG: html <Button disabled={$submitting}>ログイン</Button> <p> - アカウントをお持ちでなければ <a class="link link-primary" href={data.urlRoot + '/account/new'} - >サインアップ</a - > + アカウントをお持ちでなければ <a class="link link-primary" href={data.urlRoot + '/account/new'}> + サインアップ</a> </p> + <p> + パスワードを忘れた場合は <a class="link link-primary" href={data.urlRoot + '/account/reset'}> + パスワードのリセット</a> + </p> + <p><a class="link link-primary" href="javascript:history.back()">前の画面に戻る</a></p> </Form> </Dialog>
LANG: console $ git add . && git commit -m "いろいろとリンクを追加した"
ユーザー情報変更†
メールアドレス以外を変更できる
パスワードを変更しない場合には空にしておく
src/routes/+page.svelte
LANG: html <ul> <li><a href={data.urlRoot + '/session/delete'}>ログアウト</a></li> <li><a href={data.urlRoot + '/account/email'}>メールアドレスを変更</a></li> + <li><a href={data.urlRoot + '/account/edit'}>ユーザー情報を変更</a></li> </ul>
zod スキーマは空のパスワードを許す必要がある
src/lib/zod/lib/passwordAndConfirm.ts
LANG: ts export function passwordAndConfirm<KEYS extends string>(additional: { [key in KEYS]: z.ZodSchema; * }, allowEmpty = false) { return z .object({ ...additional, * password: z.string().regex( * new RegExp(`${allowEmpty ? '^$|' : ''}^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$` * ), { message: 'パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください', }),
src/lib/zod/account/edit.ts
LANG: ts import { z, passwordAndConfirm } from '$lib/zod/lib/passwordAndConfirm'; export const schema = passwordAndConfirm({ name: z.string().min(3, { message: 'ユーザー名は3文字以上で入力してください' }), }, true);
src/routes/account/(login)/edit/+page.svelte
LANG: html <script lang="ts"> import type { PageData } from './$types'; import { superForm } from 'sveltekit-superforms/client'; import Dialog from '$lib/components/Dialog.svelte'; import Form from '$lib/components/Form.svelte'; import InputText from '$lib/components/InputText.svelte'; import InputPassword from '$lib/components/InputPassword.svelte'; import Button from '$lib/components/Button.svelte'; export let data: PageData; const { form, message, errors, submitting, capture, restore, enhance } = superForm(data.form, { taintedMessage: false }); export const snapshot = { capture, restore }; </script> <Dialog title="ユーザー情報の変更"> <Form message={$message} {enhance}> <InputText name="name" label="ユーザー名" bind:value={$form.name} disabled={$submitting} {errors} /> <InputText name="email" label="メールアドレス" bind:value={data.user.email} disabled /> <InputPassword name="password" label="パスワード" labelAlt="変更しない場合は空にしておく" bind:value={$form.password} disabled={$submitting} {errors} /> <InputPassword name="confirm" label="パスワード(確認)" labelAlt="変更しない場合は空にしておく" bind:value={$form.confirm} disabled={$submitting} {errors} /> <Button disabled={$submitting}>サインアップ</Button> <p> アカウントをお持ちなら <a class="link link-primary" href={data.urlRoot + '/session/new'} >ログイン</a > </p> <p> メールアドレスを変更するには <a class="link link-primary" href={data.urlRoot + '/session/email'} >メールアドレスの変更</a > </p> <!-- svelte-ignore a11y-invalid-attribute --> <p><a class="link link-primary" href="javascript:history.back();">前の画面に戻る</a></p> </Form> </Dialog>
src/routes/(loggedIn)/change-profile/+page.server.ts
LANG: ts import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types'; import { schema } from '$lib/zod/account/edit'; 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.name != user.name) { await db.user.update({where: {id: user.userId}, data: {name: form.data.name}}); changed.push('名前'); } if(form.data.password) { await auth.updateKeyPassword('email', user.email, form.data.password); changed.push('パスワード'); } let message: string; if(changed.length == 0) { message = '何も変更されませんでした'; } else { message = changed.join('と') + 'が変更されました'; } throw redirect(302, '/', { type: 'success', message}, event); } };
LANG: console $ git add . && git commit -m "ユーザー情報変更機能を追加した"
Node サーバーを作成してみる†
デプロイ手順を見てみる
https://kit.svelte.jp/docs/adapter-node
LANG: console $ npm i -D @sveltejs/adapter-node Packages: +15 +++++++++++++++ Progress: resolved 365, reused 341, downloaded 1, added 15, done devDependencies: + @sveltejs/adapter-node 1.3.1 Done in 5.6s
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.5.0 building SSR bundle for production... transforming (98) node_modules\.pnpm\devalue@4.3.2\node_modules\devalue\index.js 🌼 daisyUI 4.4.14 ├─ ✔︎ 1 theme added https://daisyui.com/docs/themes ╰─ ★ Star daisyUI on GitHub https://github.com/saadeghi/daisyui ✓ 164 modules transformed. vite v4.5.0 building for production... ✓ 123 modules transformed. .svelte-kit/output/client/_app/version.json 0.03 kB │ gzip: 0.05 kB .svelte-kit/output/client/.vite/manifest.json 6.85 kB │ gzip: 0.87 kB .svelte-kit/output/client/_app/immutable/assets/Button.a9b18de0.css 0.14 kB │ gzip: 0.13 kB .svelte-kit/output/client/_app/immutable/assets/0.2a7d7910.css 54.58 kB │ gzip: 8.86 kB .svelte-kit/output/client/_app/immutable/chunks/emailVerificationPurpose.e46b6fd7.js 0.11 kB │ gzip: 0.16 kB .svelte-kit/output/client/_app/immutable/chunks/navigation.b07d858b.js 0.16 kB │ gzip: 0.14 kB .svelte-kit/output/client/_app/immutable/chunks/stores.56d58f87.js 0.24 kB │ gzip: 0.17 kB .svelte-kit/output/client/_app/immutable/chunks/spread.8a54911c.js 0.33 kB │ gzip: 0.23 kB .svelte-kit/output/client/_app/immutable/nodes/1.4cca6d8c.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/chunks/InputPassword.951bb6c6.js 1.47 kB │ gzip: 0.79 kB .svelte-kit/output/client/_app/immutable/chunks/scheduler.32b8d1e6.js 2.45 kB │ gzip: 1.13 kB .svelte-kit/output/client/_app/immutable/nodes/4.2f33bbbd.js 2.58 kB │ gzip: 1.10 kB .svelte-kit/output/client/_app/immutable/nodes/7.232211c6.js 3.06 kB │ gzip: 1.60 kB .svelte-kit/output/client/_app/immutable/nodes/2.b081d330.js 3.33 kB │ gzip: 1.55 kB .svelte-kit/output/client/_app/immutable/chunks/singletons.d0e2d2e2.js 3.40 kB │ gzip: 1.77 kB .svelte-kit/output/client/_app/immutable/nodes/6.eaf7d047.js 3.77 kB │ gzip: 1.58 kB .svelte-kit/output/client/_app/immutable/nodes/8.46eb0aab.js 4.02 kB │ gzip: 1.90 kB .svelte-kit/output/client/_app/immutable/nodes/5.883237f6.js 4.22 kB │ gzip: 1.83 kB .svelte-kit/output/client/_app/immutable/nodes/3.318e9756.js 4.97 kB │ gzip: 2.17 kB .svelte-kit/output/client/_app/immutable/chunks/index.8c5b2f73.js 6.00 kB │ gzip: 2.55 kB .svelte-kit/output/client/_app/immutable/chunks/Button.7611dd86.js 6.43 kB │ gzip: 2.67 kB .svelte-kit/output/client/_app/immutable/entry/app.6e65bc60.js 8.97 kB │ gzip: 2.70 kB .svelte-kit/output/client/_app/immutable/nodes/0.15e33cd7.js 23.65 kB │ gzip: 7.82 kB .svelte-kit/output/client/_app/immutable/entry/start.c519cf4b.js 23.80 kB │ gzip: 9.42 kB .svelte-kit/output/client/_app/immutable/chunks/index.02294475.js 27.37 kB │ gzip: 10.27 kB ✓ built in 3.48s .svelte-kit/output/server/.vite/manifest.json 9.02 kB .svelte-kit/output/server/_app/immutable/assets/Button.a9b18de0.css 0.14 kB .svelte-kit/output/server/_app/immutable/assets/_layout.e9adce87.css 54.56 kB .svelte-kit/output/server/entries/pages/_page.server.ts.js 0.10 kB .svelte-kit/output/server/chunks/db.js 0.10 kB .svelte-kit/output/server/entries/matchers/emailVerificationPurpose.js 0.19 kB .svelte-kit/output/server/entries/pages/_layout.server.ts.js 0.22 kB .svelte-kit/output/server/internal.js 0.24 kB .svelte-kit/output/server/chunks/shared-server.js 0.28 kB .svelte-kit/output/server/chunks/index3.js 0.29 kB .svelte-kit/output/server/entries/endpoints/session/(login)/delete/_server.ts.js 0.42 kB .svelte-kit/output/server/entries/fallbacks/error.svelte.js 0.47 kB .svelte-kit/output/server/chunks/lucia.js 0.51 kB .svelte-kit/output/server/chunks/passwordAndConfirm.js 0.52 kB .svelte-kit/output/server/chunks/stores.js 0.52 kB .svelte-kit/output/server/entries/pages/_page.svelte.js 1.14 kB .svelte-kit/output/server/entries/pages/session/(logout)/new/_page.server.ts.js 1.19 kB .svelte-kit/output/server/entries/pages/account/(login)/email/_token_/_page.server.ts.js 1.50 kB .svelte-kit/output/server/entries/pages/account/(login)/edit/_page.server.ts.js 1.62 kB .svelte-kit/output/server/chunks/InputPassword.js 1.74 kB .svelte-kit/output/server/entries/pages/account/(logout)/reset/_token_/_page.server.ts.js 1.84 kB .svelte-kit/output/server/chunks/hooks.server.js 2.09 kB .svelte-kit/output/server/entries/pages/account/(login)/email/_token_/_page.svelte.js 2.28 kB .svelte-kit/output/server/chunks/index2.js 2.29 kB .svelte-kit/output/server/entries/pages/account/(logout)/new/_token_/_page.server.ts.js 2.30 kB .svelte-kit/output/server/chunks/server.js 2.38 kB .svelte-kit/output/server/chunks/index.js 2.65 kB .svelte-kit/output/server/chunks/navigation.js 2.72 kB .svelte-kit/output/server/entries/pages/account/_purpose_emailVerificationPurpose_/_page.svelte.js 2.77 kB .svelte-kit/output/server/entries/pages/session/(logout)/new/_page.svelte.js 3.39 kB .svelte-kit/output/server/entries/pages/account/_purpose_emailVerificationPurpose_/_page.server.ts.js 3.56 kB .svelte-kit/output/server/entries/pages/account/(logout)/reset/_token_/_page.svelte.js 3.89 kB .svelte-kit/output/server/entries/pages/account/(logout)/new/_token_/_page.svelte.js 4.03 kB .svelte-kit/output/server/chunks/Button.js 4.13 kB .svelte-kit/output/server/entries/pages/account/(login)/edit/_page.svelte.js 4.36 kB .svelte-kit/output/server/chunks/internal.js 5.40 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.21 kB .svelte-kit/output/server/entries/pages/_layout.svelte.js 26.93 kB .svelte-kit/output/server/chunks/index4.js 45.22 kB .svelte-kit/output/server/index.js 102.84 kB Run npm run preview to preview your production build locally. > Using @sveltejs/adapter-node ✔ done ✓ built in 27.63s $ 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/
- pnpm prisma migrate deploy
- pnpm prisma generate
が必要らしい。
LANG: console $ mkdir build/db $ DATABASE_URL="file:/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" pnpm prisma migrate deploy Environment variables loaded from .env Prisma schema loaded from prisma\schema.prisma Datasource "db": SQLite database "prod.db" at "file:/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" SQLite database prod.db created at file:/Users/osamu/Desktop/svelte/authtest/build/db/prod.db 2 migrations found in prisma/migrations Applying migration `20231128121010_add_tables_for_lucia` Applying migration `20231129095659_add_email_verification_table` The following migrations have been applied: migrations/ └─ 20231128121010_add_tables_for_lucia/ └─ migration.sql └─ 20231129095659_add_email_verification_table/ └─ migration.sql All migrations have been successfully applied. $ ls build/db prod.db prod.db-journal $ DATABASE_URL="file:/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" pnpm 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:/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:/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" HOST=127.0.0.1 PORT=4000 ORIGIN="http://localhost:4000" node build
MingW64 限定の問題ではあるのだけれど重大な注意点として、 データベースファイル位置をフルパスで指定するのに
DATABASE_URL="file:/Users/osamu/Desktop/svelte/authtest/build/db/prod.db"
ではなく、
DATABASE_URL="file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db"
としてしまうと、
C:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db
にファイルができてしまって訳が分からないことになるようだった。
LANG: console $ git add . && git commit -m "デプロイ準備をした"
ダメなパスワードを検出してエラーにする†
- 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/schema.prisma
+ model worstPassword { + value String @id + rank Int + }
LANG: console $ pnpm prisma migrate dev --name "add worstPassword table"
prisma/seedWorstPassword.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 $ pnpm add -D --save typescript ts-node $ pnpm ts-node prisma/seedWorstPassword.ts TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for C:\Users\osamu\Desktop\svelte\authtest\prisma\seedWorstPassword.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 $ pnpm ts-node prisma/seedWorstPassword.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 ...
メモリ不足で落ちた。
あー、考えてみれば元のコードでは forEach に与えてる async 関数を誰も await していなかったのでひたすら Promise の山が生成されてしまっていた。
素人丸出しのダメコードだった。
prisma/seedWorstPassword.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}}) + }
1つ1つの create を await するようにしたところ、 ちゃんとデータが追加された。。。
遅すぎる。
高速化†
データは追加されるのだけれど遅すぎて待ってられない。
データを追加する部分で 1000 個ごとに transaction にまとめたところ、 かなり速くなった。
prisma/seedWorstPassword.ts
LANG: ts const commands = new Array(1000); for(let i = 0; i < items.length - 1; i+=1000) { for(let j = 0; j < 1000; j ++) { commands[j] = db.worstPassword.create({data:{ value: items[i+j], rank: i + j + 1 }}); } await db.$transaction(commands); }
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/seedWorstPassword.ts" + } }
これで、
LANG: console $ npm prisma db seed Environment variables loaded from .env Running seed command `ts-node seedWorstPassword.ts` ...
の形で seeding が行える。
ただ、 https://github.com/prisma/prisma/discussions/18057 などによると seeding というのはあくまで開発途上でデータベースにテストデータを入れたりするのに 使われるものなので、今回の用途のように本番環境でも使うようなのはいわゆる seeding の用途とは異なるようだ。
上記の処理は非常に長い時間もかかるので seed コマンドとは異なる形で実行できるように しておくべきっぽい。
さしあたり上記の設定は元に戻しておく。
パスワードを検証するのに使う†
src/lib/zod/lib/passwordAndConfirm.ts
LANG: ts + import { db } from '$lib/server/db'; ... .refine(({ password, confirm }) => password === 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; + } });
うまく判定されるようになった。
パスワード検証 API を用意する†
サブミットしてみなくても脆弱性が分かるよう、 クライアントサイドから確認するための API を用意する。
src/routes/password-rank/+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); }
パスワード入力フィールドから使う†
文字を入力するとしばらくしてエラーメッセージが出る。
src/lib/components/InputPassword.svelte
LANG: ts - export let errors: Writable<{}> | undefined = undefined; + 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-rank", {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> <InputText {...{ name, label, labelAlt, disabled, errors }} props={{ type: 'password', ...props }} bind:value * on:input={ checkPassword } />
LANG: console $ git add . && git commit -m "脆弱なパスワードを検出するようにした"
今更だけど、リストに載っているほとんどは 「小文字・大文字・数字を一文字以上含めて8文字以上」 という制約で回避できているので、このチェックにあまり意味がないかもしれない・・・
"1qazZAQ!" みたいのは一応はじくことができていた。
worst password との比較を大文字・小文字を無視して行えば良いのかもしれない???
小文字化して比較する†
prisma/seedWorstPassword.ts
- commands[j] = db.worstPassword.create({data:{ - value: items[i + j], + commands[j] = db.worstPassword.upsert({ + where: {value: items[i + j].toLocaleLowerCase()}, + update: {}, + create: { + value: items[i + j].toLocaleLowerCase(),
src/lib/zod/lib/passwordAndConfirm.ts
- where: { value: data.password }, + where: { value: (data.password as object).toString().toLocaleLowerCase() },
src/routes/password-rank/+server.ts
- where: { value: password }, + where: { value: password.toLocaleLowerCase() },
これでいいはず。
以下変更途中†
いまさらテストを追加†
サーバーの起動とテストの実行を切り離した方が使いやすそう?
サーバーの起動†
デバッグサーバーでテストするならこう
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); + } + }