メールアドレスの確認を行う

(142d) 更新


プログラミング/svelte

目次

サインアップ時にメールアドレスの確認を行う

いきなりサインアップするのではなく、入力されたメールアドレスに確認リンク入りのメールを送って メールアドレスの確認が取れたらサインアップを行う、という動作をしたい。

https://lucia-auth.com/guidebook/email-verification-links/

によると

  • authUser テーブルに email_verified フィールドを追加
  • emailVerification テーブルを追加

という形で行う形が紹介されているけれど、、、

そもそも email を verify しないと signup ページに たどり着けない形にした方がすっきり解決できそうな?

  1. /account/new でメールアドレスのみ入力
  2. メールが届く
  3. メールに記載のリンク先で名前やパスワードなどメールアドレス以外の情報を入力
    • /account/new/xxxxxxxx

のような。

これを実現するため emailVerification テーブルを作成:

  • token @id @default(uuid())
  • email
  • createdAt

流れはこんな感じ

UserUserSystemSystemDatabaseDatabase/account/new へアクセスメールアドレス入力フォームを表示メールアドレスを入力トークンとメールアドレスを保存トークン入りの URL をメールで通知トークン入りの URL へアクセストークンを確認メールアドレスを返すサインアップフォームを表示サインアップフォームを送信トークンを確認メールアドレスを返すユーザー登録トークンを削除サインアップ完了を表示

パスワードリセットもできるようにしたい

サインアップと同様に以下の流れで行う。

  1. /account/reset にメールアドレスだけを入力
  2. メールが届く
  3. メールに記載のリンク先で新しいパスワードを入力
    • /account/reset/xxxxxxxx

UserUserSystemSystemDatabaseDatabase/account/reset へアクセスメールアドレス入力フォームを表示メールアドレスを入力トークンとメールアドレスを保存トークン入りの URL をメールで通知トークン入りの URL へアクセストークンを確認メールアドレスを返すパスワード入力フォームを表示パスワード入力フォームを送信トークンを確認メールアドレスを返すパスワード変更トークンを削除パスワード変更完了を表示

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
truefalse
purpose == 'email'trueOKError
falseErrorOK

なので (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="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 { 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 "デプロイ準備をした"

ダメなパスワードを検出してエラーにする

をデータベースに入れておき、ここに載っていたら弾く。

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()
  })

これを実行するために以下を行う

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/InputPasswordVerifier.ts

LANG: ts
import type { Writable } from 'svelte/store';

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;
          });
        }
      });
  }
}

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

  // 一旦エラーをキャンセル
  errors?.update((e) => {
    e.password = undefined;
    return e;
  });

  // 設定待ちがあればキャンセル
  clearTimeout(timer);

  // 500 ms 後に設定
  timer = setTimeout(() => verifyPasswordSub(password, errors), 500);
}

InputPassword の on:input から呼び出す。

src/routes/account/(logout)/reset/[token]/+page.svelte

+   import { verifyPassword } from '$lib/components/InputPasswordVerifier'
    ...

      <InputPassword
        name="password"
        label="新しいパスワード"
        bind:value={$form.password}
        disabled={$submitting}
        {errors}
+       on:input={e=>verifyPassword(e, errors)}
      />

このとき、

src/lib/components/InputPassword.svelte

-   export let errors: Writable<{}> | undefined = undefined;
+   export let errors: Writable<{ password?: string[] }> | undefined = undefined;

としておく必要があった。

src/routes/account/(login)/edit/+page.svelte

src/routes/account/(logout)/new/[token]/+page.svelte

へも同様の変更を行う。

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() },

これでいいはず。

100,000 個のパスワードのうち、大文字小文字を区別しないで データベースに入れたところ 96,518 個に減った。

LANG: console
$ git add . && git commit -m "脆弱なパスワードの検出で大文字小文字を区別しないようにした"

Counter: 362 (from 2010/06/03), today: 1, yesterday: 2