メールアドレスの確認を行う のバックアップの現在との差分(No.2)
更新- 追加された行はこの色です。
- 削除された行はこの色です。
[[プログラミング/svelte]] * 目次 [#sad26e9f] #contents * サインアップ時にメールアドレスの確認を行う [#ufa1af42] いきなりサインアップするのではなく、入力されたメールアドレスに確認リンク入りのメールを送って メールアドレスの確認が取れたらサインアップを行う、という動作をしたい。 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()) - email - 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 : サインアップ完了を表示 ); ** パスワードリセットもできるようにしたい [#w3f0baa5] サインアップと同様に以下の流れで行う。 + /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 テーブル [#gc99d89b] 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 `20231129032730_add_email_verification_table` Applying migration `20231129095659_add_email_verification_table` The following migration(s) have been created and applied from new schema changes: migrations/ └─ 20231129032730_add_email_verification_table/ └─ 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 * メールアドレス入力フォーム [#j7548045] メールアドレス確認用の正規表現を使いまわせるようライブラリに切り出す src/lib/emailRegexp.ts 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/emailVerification.ts src/lib/zod/account/emailVerification.ts LANG: ts import { z } from 'zod'; import { emailRegexp } from '$lib/emailRegexp' 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/(loggedOut)/[purpose=emailVerificationPurpose]/+page.server.ts src/routes/account/(logout)/[purpose=emailVerificationPurpose]/+page.server.ts LANG: ts import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types'; import { schema } from '$lib/zod/emailVerification'; 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'; async function getPurpose(event: PageServerLoadEvent | RequestEvent) { return event.params.purpose as keyof typeof purposes; } export const load = (async (event) => { const form = await superValidate(schema); const purpose = await getPurpose(event); 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 = await getPurpose(event); 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/(loggedOut)/[purpose=emailVerificationPurpose]/+page.svelte 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] $ mv src/routes/account/\(logout\)/new/* src/routes/account/\(logout\)/new/[token] mv: cannot move 'src/routes/account/(logout)/new/[token]' to a subdirectory of itself, 'src/routes/account/(logout)/new/[token]/[token]' $ git mv src/routes/account/\(logout\)/new/+page.* src/routes/account/\(logout\)/new/\[token\]/ これでフォームが表示されるようになった。 ** emailVerification レコードを作成 [#z328139c] 一定時間以内に作成されたものがすでに存在すればエラーにする src/routes/account/(loggedOut)/[purpose=emailVerificationPurpose]/+page.server.ts src/routes/account/(logout)/[purpose=emailVerificationPurpose]/+page.server.ts LANG: ts import { db } from '$lib/server/db'; import { path } from '$lib/server'; ... // 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; ** メールを送信 [#hb25fe9c] アドレスは /account/(purpose)/[token] になる 送るアドレスは /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 }); if (env.DATABASE_URL == 'file:dev.db' || env.DATABASE_URL == 'file:test.db') { // 開発環境やテスト環境なら実際にはメールを送らずに // コンソールへ表示 & test-results/mail-sent.txt へ保存 if (env.DATABASE_URL.match(/\b(dev|test)\.db$/)) { transporter.sendMail = async (mailOptions: SendMailOptions) => { console.log(process.cwd()); console.log(mailOptions); if(!fs.existsSync('test-results')) { fs.mkdirSync('test-results', {mode: 0o755}); } fs.writeFileSync('test-results/mailOptions.txt', JSON.stringify(mailOptions)); 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 = "authtext"; export const appEmail = `"authtest system" <authtest@my.server.net>`; 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}`, 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 "メールアドレス確認フォームを作成した" * サインアップフォーム [#j99985ea] * サインアップフォームを改修 [#j99985ea] フォームからメールアドレス欄を除く 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 props={{ placeholder: 'account@example.com' }} /> 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, '/'); + 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) + 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('/')); * 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(+), 15 deletions(-) 3 files changed, 26 insertions(+), 21 deletions(-) * パスワードリセット [#l485ccd3] メール送信フォームでそのメールアドレスを持つアカウントの存在を確認する src/lib/index.ts に以下を追加 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, addErrorToForm } from '$lib'; + 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 に切り出す。 フォーム検証スキームを作成するにあたって、 password + confirm の手続きが2か所に出てくることになるので lib に切り出す。 src/lib/zod/lib/passwordAndConfirm.ts import { z } from 'zod'; export const passwordAndConfirm = (additional = {}) => z .object({ export { z }; export function passwordAndConfirm<KEYS extends string>(additional: { [key in KEYS]: z.ZodSchema }) { return z.object({ ...additional, 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, { .refine(({ password, confirm }) => password === confirm, { message: '確認用パスワードが一致しません', path: ['confirm'], }); } src/lib/zod/account/new.ts LANG: ts import { passwordAndConfirm } from '$lib/zod/lib/passwordAndConfirm'; 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(); export const schema = passwordAndConfirm({}); ユーザー名とメールアドレスは変更不可 パスワードリセットフォームではユーザー名とメールアドレスは変更不可 フォームに表示はするが disable なので値は POST されない。 src/routes/(loggedOut)/reset-password/[token]/+page.svelte 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 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" <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} /> {#if $errors.confirm}<span class="invalid">{$errors.confirm}</span>{/if} <div><button disabled={$submitting}>パスワードを変更</button></div> </form> </div> <style> .invalid { color: red; } </style> <Button disabled={$submitting}>パスワードを変更</Button> </Form> </Dialog> src/routes/(loggedOut)/reset-password/[token]/+page.server.ts src/routes/account/(logout)/reset/[token]/+page.server.ts LANG: ts import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types'; import { schema } from '$lib/formSchemas/signup'; import { schema } from '$lib/zod/account/reset'; import { superValidate } from 'sveltekit-superforms/server'; import { fail } from '@sveltejs/kit'; import { redirect } from 'sveltekit-flash-message/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()) { throw redirect(302, '/', {type: 'error', message: '無効なトークンです'}, event); setFlash({type: 'error', message: '無効なトークンです'}, event); throw redirect(302, path('/')); } const user = await db.authUser.findUnique({where: {email: record.email}}); const user = await db.user.findUnique({where: {email: record.email}}); if (!user) { throw redirect(302, '/', {type: 'error', message: '無効なトークンです'}, event); 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: Actions = { 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}}); throw redirect(302, '/', { type: 'success', message: 'パスワードを変更してログインしました'}, event); setFlash({ type: 'success', message: 'パスワードを変更してログインしました'}, event); throw redirect(302, path('/')); } }; } satisfies Actions; * 以下変更途中 [#e8230287] ** メールアドレス変更 [#g1870852] パスワードを忘れた際にパスワードリセットを行えるようになった。 LANG: console $ git add . && git commit -m "パスワードリセット機能の追加" * メールアドレス変更 [#g1870852] これもメールアドレスを確認後、飛び先で確認ボタンを押したら変更という形にすべき。 'chage-email' というパスを用意 '/account/email' というパスを用意 src/lib/emailVerificationPurposes.ts src/params/emailVerificationPurposes.ts LANG: ts export const purposes = { signup: 'サインアップ', 'reset-password': 'パスワードリセット', + 'change-email': 'メールアドレス変更', reset: 'パスワードリセット', + email: 'メールアドレス変更', }; signup や reset-password はログアウト状態で行う処理だったのに対して signup や reset はログアウト状態で行う処理だったのに対して この作業はログイン状態で行うので、 src/routes/(loggedOut)/[purpose=emailVerificationPurpose] を~ src/routes/[purpose=emailVerificationPurpose] へ移動した上で、 src/routes/[purpose=emailVerificationPurpose] へ移動 LANG: console $ git mv src/routes/account/\(logout\)/\[purpose\=emailVerificationPurpose\] src/routes/account/ コード上でログイン状態を確認することにした。 |>||>|!!session?.user| |~|~|true|false| |purpose == 'change-email'|true|OK|Error| |purpose == 'email'|true|OK|Error| |~|false|Error|OK| なので (purpose == 'change-email') !== (!! session?.user) で判断すればいい。 なので (purpose == 'email') !== (!! session?.user) で判断すればいい。 src/routes/[purpose=emailVerificationPurpose]/+page.server.ts src/routes/account/[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); * 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; } + 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 == 'change-email') { + if(await db.authUser.findUnique({where: {email: form.data.email}})){ + if(purpose == 'email') { + if(await db.user.findUnique({where: {email: form.data.email}})){ + form.errors.email = [...(form.errors.email || []), '既存のアカウントと重複しています']; + form.valid = false; + } + } フォームは単なる確認用なのでデータは何も受け取らない したがって、バリデーション用スキームも必要ない src/routes/(loggedIn)/change-email/[token]/+page.svelte src/routes/account/(login)/email/[token]/+page.svelte LANG: html <script lang="ts"> import type { PageData } from './$types'; export let data: PageData; 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> <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> <Dialog title="メールアドレスの変更"> <Form> <InputText name="name" label="ユーザー名" value={data.user.name} disabled /> <InputText name="old-email" label="旧メールアドレス" bind:value={data.user.email} disabled /> <InputText name="new-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 { redirect } from 'sveltekit-flash-message/server'; import { db } from '$lib/server/db' 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()) { throw redirect(302, '/', {type: 'error', message: '無効なトークンです'}, event); setFlash({type: 'error', message: '無効なトークンです'}, event); throw redirect(302, path('/')); } const session = await event.locals.auth.validate(); if(!session?.user) { throw redirect(302, '/login', {type: 'error', message: '旧メールアドレスでログインしてください'}, event); 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: Actions = { export const actions = { default: async (event) => { const {email, user} = await getEmailAndUser(event); // メールアドレスを変更 await db.$transaction([ db.authUser.update({ 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}}); throw redirect(302, '/', { type: 'success', message: 'メールアドレスを変更しました'}, event); setFlash({ type: 'success', message: 'メールアドレスを変更しました'}, event); throw redirect(302, path('/')); } }; } satisfies Actions; ** ユーザー情報変更 [#ea677d6d] メールアドレスを変更できるようになった LANG: console $ git add . && git commit -m "メールアドレスを変更できるようになった" * いろいろと使いやすいように変更 [#g9e21a6e] 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 "いろいろとリンクを追加した" * ユーザー情報変更 [#ea677d6d] メールアドレス以外を変更できる パスワードを変更しない場合には空にしておく src/lib/formSchemas/changeProfile.ts 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 import { z } from 'zod'; 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 = 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'] }); export const schema = passwordAndConfirm({ name: z.string().min(3, { message: 'ユーザー名は3文字以上で入力してください' }), }, true); src/routes/(loggedIn)/change-profile/+page.svelte 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> <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" <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} /> {#if $errors.confirm}<span class="invalid">{$errors.confirm}</span>{/if} <div><button disabled={$submitting}>ユーザー情報を変更</button></div> </form> </div> <style> .invalid { color: red; } </style> <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/formSchemas/changeProfile'; 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('パスワード'); } 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 サーバーを作成してみる [#sfd1def2] LANG: console $ git add . && git commit -m "ユーザー情報変更機能を追加した" * Node サーバーを作成してみる [#sfd1def2] デプロイ手順を見てみる 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 Packages: +15 +++++++++++++++ Progress: resolved 365, reused 341, downloaded 1, added 15, done devDependencies: + @sveltejs/adapter-node 1.3.1 Done in 7s 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.4.2 building SSR bundle for production... ✓ 159 modules transformed. 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 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 ✓ 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 19.50s ✓ 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/ - npx prisma migrate deploy - npx prisma generate - pnpm prisma migrate deploy - pnpm prisma generate が必要らしい。 LANG: console $ mkdir build/db $ DATABASE_URL="file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" npx prisma migrate deploy $ 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:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" Datasource "db": SQLite database "prod.db" at "file:/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 SQLite database prod.db created at file:/Users/osamu/Desktop/svelte/authtest/build/db/prod.db 5 migrations found in prisma/migrations 2 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` Applying migration `20231128121010_add_tables_for_lucia` Applying migration `20231129095659_add_email_verification_table` The following migrations have been applied: migrations/ └─ 20231121075732_add_tables_for_lucia/ └─ 20231128121010_add_tables_for_lucia/ └─ migration.sql └─ 20231121093844_alter_tables_for_lucia_v2_again/ └─ 20231129095659_add_email_verification_table/ └─ 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 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:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" HOST=127.0.0.1 PORT=4000 node build $ 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:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" HOST=127.0.0.1 PORT=4000 ORIGIN="http://localhost:4000" node build $ 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 限定の問題ではあるのだけれど重大な注意点として、 データベースファイル位置をフルパスで指定するのに 動くには動くのだけれど build/db/prod.db にファイルが見つからない??? DATABASE_URL="file:/Users/osamu/Desktop/svelte/authtest/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" C:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db としないとダメみたいだった。 にファイルができてしまって訳が分からないことになるようだった。 ** ダメなパスワードを検出してエラーにする [#gb55701b] LANG: console $ git add . && git commit -m "デプロイ準備をした" * ダメなパスワードを検出してエラーにする [#gb55701b] - worst password list~ https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10-million-password-list-top-100000.txt をデータベースに入れておき、ここに載っていたら弾く。 *** Prisma への seeding [#w3deb79e] ** Prisma への seeding [#w3deb79e] そのために上記のリストを Prisma のデータベースに入れたい。 https://m-shige1979.hatenablog.com/entry/2021/11/20/213051 あたりを参考にしつつ、 prisma/seed.ts 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 ファイルを直接実行可能にする -- 参照 https://qiita.com/mangano-ito/items/75e65071c9c482ddc335 - そして ./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 $ 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 $ ./node_modules/.bin/ts-node prisma/seed.ts $ 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 ... メモリ不足で落ちた。 prisma/seed.ts あー、考えてみれば元のコードでは 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}}) + } あー、考えてみれば元のコードでは forEach に与えてる async 関数を誰も await していなかったのでひたすら Promise の山が生成されてしまっていた。 1つ1つの create を await するようにしたところ、 ちゃんとデータが追加された。。。 素人丸出しのダメコードだった。 遅すぎる。 1つ1つの create を await するようにしたところ、時間はかかったものの、 ちゃんとデータが追加された。 ** 高速化 [#dfadc5ac] データは追加されるのだけれど遅すぎて待ってられない。 データを追加する部分で 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 で動かせるようにする [#of203763] 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" + "seed": "ts-node prisma/seedWorstPassword.ts" + } } これで、 LANG: console $ npm prisma db seed Environment variables loaded from .env Running seed command `ts-node prisma/seed.ts` ... Running seed command `ts-node seedWorstPassword.ts` ... の形で seeding が行える。 ただ、 https://github.com/prisma/prisma/discussions/18057 などによると seeding というのはあくまで開発途上でデータベースにテストデータを入れたりするのに 使われるものなので、今回の用途のように本番環境でも使うようなのはいわゆる seeding の用途とは異なるようだ。 上記の処理は非常に長い時間もかかるので seed コマンドとは異なる形で実行できるように しておくべきっぽい。 どうするべきか要検討。 さしあたり上記の設定は元に戻しておく。 *** パスワードを検証するのに使う [#g91c4d92] ** パスワードを検証するのに使う [#g91c4d92] src/lib/formSchemas/changeProfile.ts src/lib/zod/lib/passwordAndConfirm.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, { .refine(({ password, confirm }) => password === confirm, { message: '確認用パスワードが一致しません', path: ['confirm'] 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 も同様に変更するため、 判定部分を切り出した。 ** パスワード検証 API を用意する [#oec090e8] 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 を用意する [#oec090e8] サブミットしてみなくても脆弱性が分かるよう、 クライアントサイドから確認するための API を用意する。 src/routes/password-ranking/+server.ts 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}}); const record = await db.worstPassword.findUnique({ where: {value: password}, select: {rank: true} }); if(record) { return json(record.rank + 1); } return json(0); } *** パスワード入力フォームから使う [#d9089ba2] ** パスワード入力フィールドから使う [#d9089ba2] LANG: html <input type="password" name="password" bind:value={$form.password} disabled={$submitting} on:input={checkPassword} /> 文字を入力するとしばらくしてエラーメッセージが出るようにする。 のように onInput イベントを拾って、 src/lib/components/InputPasswordVerifier.ts 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; } import type { Writable } from 'svelte/store'; // 一旦エラーをキャンセル errors.update((e)=>{e.password = undefined; return e}); // 設定待ちがあればキャンセル clearTimeout(timer); // 500 ms 後に設定 timer = setTimeout(()=> checkPasswordSub(password), 500); function verifyPasswordSub(password: string, errors?: Writable<{ 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; }); } }); } } 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; export function verifyPassword(e: Event, errors?: Writable<{ password?: string[] }>) { console.log('ok'); const password = (e.target as HTMLInputElement).value; if (password && password.length < 8) { errors?.update((e) => { e.password = ['小文字・大文字・数字を一文字以上含めて8文字以上で入力してください']; return e; }); return; } のようにエラーチェックする。 他でも使えるようこれをコンポーネント化したい。 *** コンポーネント化する [#f33ce260] ラベル + 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 // 一旦エラーをキャンセル errors?.update((e) => { e.password = undefined; return e; }); export const snapshot = { capture, restore }; </script> <div> <h1>ユーザー情報の変更</h1> {#if $message}<span class="invalid">{$message}</span>{/if} <form method="POST" use:enhance> // 設定待ちがあればキャンセル clearTimeout(timer); <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 もこれに準じて直す。 ** いまさらテストを追加 [#iac1c315] サーバーの起動とテストの実行を切り離した方が使いやすそう? *** サーバーの起動 [#r621d0b1] デバッグサーバーでテストするならこう 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" こうかな? *** テストの実行 [#o5d2e777] 上記のコマンドでサーバーを起動しておいて、 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 とすればいい。 *** テスト時のメールは実際に送信する代わりにファイルに保存する [#l8b2cdac] 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; }; // 500 ms 後に設定 timer = setTimeout(() => verifyPasswordSub(password, errors), 500); } *** テストを書く [#i8e7b89e] InputPassword の on:input から呼び出す。 https://playwright.dev/docs/api/class-test 例えばこんな風に書ける。 LANG: ts test('/signup へフォームを送信', async ({ page }) => { await page.goto('/signup', {waitUntil: 'load'}); src/routes/account/(logout)/reset/[token]/+page.svelte + import { verifyPassword } from '$lib/components/InputPasswordVerifier' ... 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(); }); <InputPassword name="password" label="新しいパスワード" bind:value={$form.password} disabled={$submitting} {errors} + on:input={e=>verifyPassword(e, errors)} /> でもこれを、 このとき、 LANG: ts await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('form button').click(); src/lib/components/InputPassword.svelte - export let errors: Writable<{}> | undefined = undefined; + export let errors: Writable<{ password?: string[] }> | undefined = undefined; とすると email フィールドへの入力が送信データに反映されないケースがあったり(svelte による reactivity が発揮される前にフォームが送信されてしまうため?)、 としておく必要があった。 なぜか fill を2回繰り返さないと書き込み自体を行えないケースがあったり、 src/routes/account/(login)/edit/+page.svelte LANG: ts await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('input[name="email"]').fill('test@example.com'); // 念のためもう一度 orz src/routes/account/(logout)/new/[token]/+page.svelte なかなかに落とし穴も多そうな雰囲気だ? へも同様の変更を行う。 - 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) $ git add . && git commit -m "脆弱なパスワードを検出するようにした" * 権限を管理する [#a538926a] 今更だけど、リストに載っているほとんどは 「小文字・大文字・数字を一文字以上含めて8文字以上」 という制約で回避できているので、このチェックにあまり意味がないかもしれない・・・ AuthUser と 多対多 関係を持つ Role を導入する。 "1qazZAQ!" みたいのは一応はじくことができていた。 prisma/schema.prisma model AuthUser { ... + roles Role[] } + model Role { + id String @id @default(uuid()) + name String @unique + + users AuthUser[] + } worst password との比較を大文字・小文字を無視して行えば良いのかもしれない??? マイグレーションする。 ** 小文字化して比較する [#dccabae2] LANG: console $ npx prisma migrate dev --name "add Role" 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(), Prisma で多対多関係を扱うためのコードは結構煩雑になるようなので、 Role を使うためのユーティリティ関数を db に持たせる。 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() }, 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 はエディタ上でエラー検出してくれなきゃ、とてもじゃないけど書けない。 100,000 個のパスワードのうち、大文字小文字を区別しないで データベースに入れたところ 96,518 個に減った。 ところで、 LANG: ts roles.map(role=>({name: role})) LANG: console $ git add . && git commit -m "脆弱なパスワードの検出で大文字小文字を区別しないようにした" などという記述に注意が必要。これを 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); + } + }
Counter: 413 (from 2010/06/03),
today: 2,
yesterday: 3