SvelteKitで作るフルスタック個人ブログ | 第5回: Luciaで認証機能を実装

こんにちは！「SvelteKitで作るフルスタック個人ブログ」シリーズの第5回へようこそ。第4回では、SvelteKitのAPIルートと**Drizzle ORM**を使ってバックエンドを構築し、ブログ記事をデータベースで管理しました。今回は、**Lucia**を使って**認証機能**（ログイン・登録）を実装し、ユーザーがログイン後に新しい記事を作成できるようにします。保護されたページ（ダッシュボード）も作り、Next.jsの認証ライブラリ（NextAuthなど）との比較を通じて、Luciaのシンプルさを体感しましょう！

---

## この記事の目標
- **Lucia**を使ってユーザー登録・ログイン機能を実装する。
- SvelteKitの**フォームアクション**を活用して、サーバーサイドで認証を処理する。
- 保護されたダッシュボードページを作成し、ログイン済みユーザーのみが記事を作成可能にする。
- **Skeleton UI**でモダンな認証UIを構築する。
- NextAuthとの違いを比較し、Luciaの開発体験（DX）の良さを確認する。

最終的には、ユーザーがログインして記事を投稿できるブログになり、フルスタックアプリケーションとしてさらに完成度を高めます。では、始めましょう！

---

## Luciaとは？

**Lucia**は、SvelteKit向けの軽量な認証ライブラリです。NextAuthのようなフル機能のソリューションに比べ、以下のような特徴があります：
- **シンプル**：最小限のAPIで、必要な機能だけを提供。
- **柔軟**：データベースやセッション管理を自由にカスタマイズ可能。
- **型安全**：TypeScriptとの統合が強力。
- **SvelteKitとの相性**：サーバーサイドとクライアントサイドの認証がスムーズ。

Luciaは、認証の基本（ユーザー登録、ログイン、セッション管理）を簡単に実装でき、SvelteKitのフォームアクションと組み合わせることで、クライアントサイドの状態管理を最小限に抑えられます。

---

## 認証機能のセットアップ

### 1. Luciaと依存パッケージのインストール
Luciaと関連パッケージをインストールします。以下のコマンドを実行：

```bash
npm install lucia @lucia-auth/adapter-sqlite
```

- `@lucia-auth/adapter-sqlite`：SQLiteデータベース用のLuciaアダプター。第4回で設定したSQLiteを再利用します。

### 2. データベーススキーマの拡張
ユーザー情報を保存するため、`users`テーブルと`sessions`テーブルを追加します。`src/lib/db/schema.ts`を更新：

```ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

export const users = sqliteTable('users', {
  id: text('id').primaryKey(),
  email: text('email').notNull().unique(),
  password: text('password').notNull()
});

export const sessions = sqliteTable('sessions', {
  id: text('id').primaryKey(),
  userId: text('user_id')
    .notNull()
    .references(() => users.id),
  expiresAt: integer('expires_at').notNull()
});

export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  title: text('title').notNull(),
  slug: text('slug').notNull().unique(),
  excerpt: text('excerpt').notNull(),
  content: text('content').notNull(),
  createdAt: text('created_at').notNull().default(new Date().toISOString())
});
```

- **解説**：
  - `users`：ユーザーID、メールアドレス、パスワードを保存。
  - `sessions`：セッションID、ユーザーID、有効期限を保存。
  - `posts`：第4回で作成したテーブル（変更なし）。

マイグレーションを再実行：

```bash
npx drizzle-kit generate
npx drizzle-kit migrate
```

### 3. Luciaの初期化
Luciaをセットアップします。`src/lib/auth.ts`を作成：

```ts
import { Lucia } from 'lucia';
import { DrizzleSQLiteAdapter } from '@lucia-auth/adapter-sqlite';
import { db } from './db';
import { sessions, users } from './schema';

const adapter = new DrizzleSQLiteAdapter(db, sessions, users);

export const lucia = new Lucia(adapter, {
  sessionCookie: {
    expires: false,
    attributes: {
      secure: process.env.NODE_ENV === 'production'
    }
  },
  getUserAttributes: (attributes) => {
    return {
      email: attributes.email
    };
  }
});

declare module 'lucia' {
  interface Register {
    Lucia: typeof lucia;
    DatabaseUserAttributes: {
      email: string;
    };
  }
}
```

- **解説**：
  - `DrizzleSQLiteAdapter`でSQLiteをLuciaに接続。
  - `sessionCookie`：セッションクッキーの設定。本番環境ではセキュアに。
  - `getUserAttributes`：ユーザー情報の取得時にメールアドレスを返す。

---

## 認証APIとフォームの実装

### 1. ユーザー登録の実装
ユーザー登録用のフォームとAPIを作成します。`src/routes/register/+page.svelte`を作成：

```svelte
<script>
  import { enhance } from '$app/forms';
  import { Input, Button, Card } from '@skeletonlabs/skeleton';
</script>

<div class="container mx-auto p-8">
  <Card class="max-w-md mx-auto">
    <form method="POST" action="/register?/signup" use:enhance class="p-6 space-y-4">
      <h2 class="h2 mb-4">ユーザー登録</h2>
      <Input type="email" name="email" placeholder="メールアドレス" required />
      <Input type="password" name="password" placeholder="パスワード" required />
      <Button type="submit" variant="filled-primary">登録</Button>
      <p class="text-sm">
        すでにアカウントをお持ちですか？ <a href="/login" class="text-primary-500">ログイン</a>
      </p>
    </form>
  </Card>
</div>
```

登録処理をサーバーサイドで実装するため、`src/routes/register/+page.server.ts`を作成：

```ts
import { fail, redirect } from '@sveltejs/kit';
import { lucia } from '$lib/auth';
import { db } from '$lib/db';
import { users } from '$lib/db/schema';
import { generateId } from 'lucia';
import bcrypt from 'bcrypt';

export const actions = {
  signup: async ({ request }) => {
    const form = await request.formData();
    const email = form.get('email')?.toString();
    const password = form.get('password')?.toString();

    if (!email || !password) {
      return fail(400, { message: 'メールアドレスとパスワードを入力してください' });
    }

    const hashedPassword = await bcrypt.hash(password, 10);
    const userId = generateId(15);

    try {
      await db.insert(users).values({
        id: userId,
        email,
        password: hashedPassword
      });

      const session = await lucia.createSession(userId, {});
      const sessionCookie = lucia.createSessionCookie(session.id);
      return {
        headers: {
          'set-cookie': sessionCookie.serialize()
        },
        redirect: '/dashboard'
      };
    } catch (e) {
      return fail(400, { message: 'このメールアドレスはすでに登録されています' });
    }
  }
};
```

- **解説**：
  - `bcrypt`でパスワードをハッシュ化（`npm install bcrypt @types/bcrypt`が必要）。
  - `generateId`で一意なユーザーIDを生成。
  - 登録成功後、セッションを作成し、ダッシュボードにリダイレクト。

### 2. ログインの実装
ログイン用のフォームを作成します。`src/routes/login/+page.svelte`：

```svelte
<script>
  import { enhance } from '$app/forms';
  import { Input, Button, Card } from '@skeletonlabs/skeleton';
</script>

<div class="container mx-auto p-8">
  <Card class="max-w-md mx-auto">
    <form method="POST" action="/login?/signin" use:enhance class="p-6 space-y-4">
      <h2 class="h2 mb-4">ログイン</h2>
      <Input type="email" name="email" placeholder="メールアドレス" required />
      <Input type="password" name="password" placeholder="パスワード" required />
      <Button type="submit" variant="filled-primary">ログイン</Button>
      <p class="text-sm">
        アカウントをお持ちでないですか？ <a href="/register" class="text-primary-500">登録</a>
      </p>
    </form>
  </Card>
</div>
```

ログイン処理を`src/routes/login/+page.server.ts`に実装：

```ts
import { fail, redirect } from '@sveltejs/kit';
import { lucia } from '$lib/auth';
import { db } from '$lib/db';
import { users } from '$lib/db/schema';
import { eq } from 'drizzle-orm';
import bcrypt from 'bcrypt';

export const actions = {
  signin: async ({ request }) => {
    const form = await request.formData();
    const email = form.get('email')?.toString();
    const password = form.get('password')?.toString();

    if (!email || !password) {
      return fail(400, { message: 'メールアドレスとパスワードを入力してください' });
    }

    const user = await db.select().from(users).where(eq(users.email, email)).get();
    if (!user || !(await bcrypt.compare(password, user.password))) {
      return fail(400, { message: 'メールアドレスまたはパスワードが正しくありません' });
    }

    const session = await lucia.createSession(user.id, {});
    const sessionCookie = lucia.createSessionCookie(session.id);
    return {
      headers: {
        'set-cookie': sessionCookie.serialize()
      },
      redirect: '/dashboard'
    };
  }
};
```

- **解説**：
  - メールアドレスでユーザーを検索し、パスワードを`bcrypt.compare`で検証。
  - ログイン成功後、セッションを作成し、ダッシュボードにリダイレクト。

### 3. NextAuthとの比較
NextAuthで同様の認証を実装する場合、以下のような設定が必要です：

```ts
// Next.js (pages/api/auth/[...nextauth].ts)
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';

export default NextAuth({
  providers: [
    CredentialsProvider({
      async authorize(credentials) {
        // 認証ロジック
      }
    })
  ]
});
```

NextAuthは、OAuthや多様なプロバイダに対応する強力なライブラリですが、設定が複雑で、シンプルな認証にはオーバーヘッドがあります。一方、Luciaは**最小限のAPI**で、SvelteKitのフォームアクションと組み合わせることで、サーバーサイドの処理が直感的です。

---

## ダッシュボードと記事作成

### 1. ダッシュボードの実装
ログイン済みユーザー専用のダッシュボードを作成します。`src/routes/dashboard/+page.svelte`：

```svelte
<script>
  import { enhance } from '$app/forms';
  import { Input, Textarea, Button, Card } from '@skeletonlabs/skeleton';
</script>

<div class="container mx-auto p-8">
  <Card class="max-w-2xl mx-auto">
    <form method="POST" action="/dashboard?/createPost" use:enhance class="p-6 space-y-4">
      <h2 class="h2 mb-4">新しい記事を作成</h2>
      <Input type="text" name="title" placeholder="記事のタイトル" required />
      <Input type="text" name="slug" placeholder="スラッグ（例: my-post）" required />
      <Textarea name="excerpt" placeholder="記事の概要" required />
      <Textarea name="content" placeholder="記事の本文" required />
      <Button type="submit" variant="filled-primary">投稿</Button>
    </form>
  </Card>
</div>
```

### 2. ダッシュボードの保護
ダッシュボードをログイン済みユーザーのみに制限します。`src/routes/dashboard/+page.server.ts`：

```ts
import { fail, redirect } from '@sveltejs/kit';
import { lucia } from '$lib/auth';
import { db } from '$lib/db';
import { posts } from '$lib/db/schema';

export const load = async ({ locals }) => {
  if (!locals.session) {
    throw redirect(302, '/login');
  }
};

export const actions = {
  createPost: async ({ request, locals }) => {
    if (!locals.session) {
      throw redirect(302, '/login');
    }

    const form = await request.formData();
    const title = form.get('title')?.toString();
    const slug = form.get('slug')?.toString();
    const excerpt = form.get('excerpt')?.toString();
    const content = form.get('content')?.toString();

    if (!title || !slug || !excerpt || !content) {
      return fail(400, { message: 'すべてのフィールドを入力してください' });
    }

    try {
      await db.insert(posts).values({
        title,
        slug,
        excerpt,
        content,
        createdAt: new Date().toISOString()
      });
      throw redirect(302, '/blog');
    } catch (e) {
      return fail(400, { message: 'このスラッグはすでに使用されています' });
    }
  }
};
```

- **解説**：
  - `load`関数で`locals.session`をチェック。セッションがない場合はログイン画面にリダイレクト。
  - `createPost`アクションで新しい記事をデータベースに保存。

### 3. セッション管理
Luciaのセッションを全ページで利用可能にするため、`src/hooks.server.ts`を作成：

```ts
import { lucia } from '$lib/auth';
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
  const sessionId = event.cookies.get(lucia.sessionCookieName);
  if (!sessionId) {
    event.locals.user = null;
    event.locals.session = null;
    return resolve(event);
  }

  const { session, user } = await lucia.validateSession(sessionId);
  if (session && session.fresh) {
    const sessionCookie = lucia.createSessionCookie(session.id);
    event.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
  }
  if (!session) {
    const sessionCookie = lucia.createBlankSessionCookie();
    event.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
  }

  event.locals.user = user;
  event.locals.session = session;
  return resolve(event);
};
```

これで、`locals.session`と`locals.user`がすべてのサーバー処理で利用可能になります。

---

## 動作確認

プロジェクトを起動：

```bash
npm run dev
```

### 1. ユーザー登録とログイン
- `http://localhost:5173/register`：メールアドレス（例：`test@example.com`）とパスワードを入力して登録。成功後、ダッシュボードにリダイレクト。
- `http://localhost:5173/login`：同じメールアドレスとパスワードでログイン。

### 2. ダッシュボードで記事作成
- `http://localhost:5173/dashboard`：ログイン済みユーザーのみアクセス可能。記事のタイトル、スラッグ、概要、本文を入力して投稿。
- 投稿後、`/blog`で新しい記事が一覧に表示される。

**Skeleton UI**のフォームコンポーネントにより、入力画面はモダンで使いやすいです。Luciaのシンプルなセッション管理とSvelteKitのフォームアクションにより、クライアントサイドの状態管理がほぼ不要です。

---

## やってみよう！（チャレンジ）
ログアウト機能を追加してみましょう！`src/routes/logout/+server.ts`を作成：

```ts
import { lucia } from '$lib/auth';
import { redirect } from '@sveltejs/kit';

export async function POST({ locals, cookies }) {
  if (!locals.session) {
    throw redirect(302, '/login');
  }

  await lucia.invalidateSession(locals.session.id);
  const sessionCookie = lucia.createBlankSessionCookie();
  cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);

  throw redirect(302, '/login');
}
```

次に、ダッシュボードにログアウトボタンを追加：

```svelte
<form method="POST" action="/logout" use:enhance class="mt-4">
  <Button variant="filled-error">ログアウト</Button>
</form>
```

さらに、ナビゲーションバーに「ダッシュボード」と「ログアウト」のリンクを条件付きで表示するように`src/routes/+layout.svelte`を更新してみてください。Luciaのセッション管理の柔軟性を体感できます！

---

## まとめ
この記事では、**Lucia**を使ってユーザー認証（登録・ログイン）を実装し、保護されたダッシュボードで記事作成機能を追加しました。SvelteKitのフォームアクションと**Skeleton UI**により、直感的でモダンなUIを簡単に構築できました。NextAuthと比べ、Luciaは軽量でカスタマイズしやすく、SvelteKitのサーバーサイド処理と相性が良いと感じていただけたと思います。

次回は、SEO最適化やパフォーマンス向上を行い、**Vercel**でブログをデプロイして本番環境に公開します。お楽しみに！

この記事が役に立ったと思ったら、**LGTM**や**ストック**していただけると励みになります！質問や改善アイデアがあれば、コメントで教えてください。次の記事でまたお会いしましょう！