2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

こんにちは!「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と関連パッケージをインストールします。以下のコマンドを実行:

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

2. データベーススキーマの拡張

ユーザー情報を保存するため、usersテーブルとsessionsテーブルを追加します。src/lib/db/schema.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回で作成したテーブル(変更なし)。

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

npx drizzle-kit generate
npx drizzle-kit migrate

3. Luciaの初期化

Luciaをセットアップします。src/lib/auth.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を作成:

<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を作成:

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

<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に実装:

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で同様の認証を実装する場合、以下のような設定が必要です:

// 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

<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

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を作成:

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.sessionlocals.userがすべてのサーバー処理で利用可能になります。


動作確認

プロジェクトを起動:

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を作成:

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

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

<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ストックしていただけると励みになります!質問や改善アイデアがあれば、コメントで教えてください。次の記事でまたお会いしましょう!

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?