Tutorial/tutorials

Part 2: 認証を追加する

Part 1 で作ったブログは、誰でも投稿を公開できてしまいます。このパートでは、コマンド 1 つでユーザーアカウントを追加し、投稿作成をログイン必須にして、すべての投稿に著者を紐づけます。

Part 2: 認証を追加する

Part 1 で作ったブログは、誰でも投稿を公開できてしまいます。このパートでは、コマンド 1 つでユーザーアカウントを追加し、投稿作成をログイン必須にして、すべての投稿に著者を紐づけます。

このパートで学ぶこと:

  • bunx guren add auth が何を生成し、何を配線してくれるのか
  • ミドルウェアエイリアスとルートグループでルートを保護する方法
  • コントローラー内で this.auth.userOrFail() を使ってサインイン中のユーザーを取得する方法
  • belongsTo リレーションシップを宣言し、findWithOrFail で eager load する方法

1. 認証の雛形をインストールする

プロジェクトルートで実行します。

bunx guren add auth

このコマンド 1 つで、セッションベースのログインスタック一式が生成されます。

ファイル 役割
app/Http/Controllers/Auth/LoginController.ts ログインフォーム、サインイン(auth.attempt)、ログアウト
app/Http/Controllers/DashboardController.ts 保護されたページの例
app/Http/Controllers/ProfileController.ts 名前 / メールアドレス / パスワードの編集
app/Models/User.ts AuthenticatableModel を継承した User モデル
app/Providers/AuthProvider.ts 認証マネージャーに User で認証するよう指示
app/Http/Validators/LoginValidator.ts ログインフォーム用の Zod スキーマ
app/Http/Validators/ProfileValidator.ts プロフィール更新用の Zod スキーマ
resources/js/components/Layout.tsx サインイン / ログアウトのナビゲーション付き共有レイアウト
resources/js/pages/auth/Login.tsx ログインページ
resources/js/pages/dashboard/Index.tsx ダッシュボードページ
resources/js/pages/profile/Edit.tsx プロフィールページ
routes/auth.ts registerAuthRoutes()/login/logout/dashboard/profile
db/seeders/UsersSeeder.ts デモユーザー(demo@example.com / secret)をシード

さらに 既存ファイルの編集 も行います。

  • db/schema.ts — 初期状態の users テーブルを、passwordHashrememberToken カラムを持つものに置き換え(引き続き SQLite)、対応するマイグレーションを生成します。
  • src/app.tsAuthProvider を登録し、createApp()auth: {} を追加します。これによりセッションと CSRF のミドルウェアが有効になります。
  • routes/web.ts — ルート registrar の先頭で registerAuthRoutes(router) をインポートして呼び出すようにします。

Warning

add authdb/schema.tsusers テーブル定義を書き換えます。users にカスタムカラムを追加していた場合は、コマンド実行後に再追加してください。

続いて、users のマイグレーションを適用し、デモアカウントをシードし、生成済みの型を更新します(雛形が新しいページとルートを追加したためです)。

bun run db:migrate
bun run db:seed
bun run codegen

2. チェックポイント: サインインする

開発サーバーを起動し(bun run dev)、http://localhost:3333/login を開きます。

  1. demo@example.com / secret でサインインします — /dashboard に着地し、名前入りの挨拶が表示されます。
  2. 間違ったパスワードを試します — フォームに "Invalid credentials." が表示されます。
  3. プライベートブラウジングのウィンドウで /dashboard を開きます — /login にリダイレクトされます。保護されたルートは本当に保護されています。

Note

雛形が提供するのはログインフローであり、一般公開のセルフ登録機能ではありません。開発中にユーザーを増やしたい場合は、db/seeders/UsersSeeder.ts にエントリーを追加して bun run db:seed を再実行してください。

3. 投稿作成を保護する

auth ミドルウェアエイリアスを登録し、投稿を変更する 2 つのルートに付与します。

routes/web.ts を編集します。

import { Router, requireAuthenticated } from '@guren/core'
import { registerAuthRoutes } from './auth.js'
import HomeController from '../app/Http/Controllers/HomeController.js'
import PostController from '../app/Http/Controllers/PostController.js'
import { PostPayloadSchema } from '../app/Http/Validators/PostValidator.js'

export function registerWebRoutes(router: Router): void {
  registerAuthRoutes(router)

  router.aliasMiddleware('auth', requireAuthenticated({ redirectTo: '/login' }))

  router.get('/', [HomeController, 'index'])

  // Health check endpoint for load balancers and uptime monitors
  router.get('/health', (c) => c.json({ status: 'ok' }))

  router.group('/posts', (posts) => {
    posts.get('/', [PostController, 'index']).name('posts.index')
    posts.middleware('auth').get('/create', [PostController, 'create']).name('posts.create')
    posts.get('/:id', [PostController, 'show']).name('posts.show')
    posts.middleware('auth').post('/', { name: 'posts.store', body: PostPayloadSchema }, [PostController, 'store'])
  })
}

aliasMiddleware はミドルウェアに一度だけ名前を付け、ルートからは 'auth' として参照できるようにします。投稿の一覧と閲覧は公開のまま、/posts/createPOST /posts の送信は、未ログインの訪問者を /login にリダイレクトするようになりました。保護したいルートが複数あるなら、1 つずつタグ付けする代わりにグループでまとめられます。

posts.middleware('auth').group((authed) => {
  authed.get('/create', [PostController, 'create']).name('posts.create')
  authed.post('/', { name: 'posts.store', body: PostPayloadSchema }, [PostController, 'store'])
})

4. すべての投稿に著者を持たせる

カラムを追加する

db/schema.tsposts テーブルを編集し、users を参照させます。

export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  title: text('title').notNull(),
  body: text('body').notNull(),
  authorId: integer('author_id').notNull().references(() => users.id),
  createdAt: text('created_at').notNull().$defaultFn(() => new Date().toISOString()),
})

マイグレーションを生成し、開発用データベースを作り直します。

bun run db:make add_author_to_posts
bun run db:reset --seed

Warning

SQLite では、すでに行が入っているテーブルに、デフォルト値のない NOT NULL カラムを追加できません — Part 1 で作った投稿がマイグレーションを妨げてしまいます。db:reset --seed はすべてのテーブルを削除し、全マイグレーションを最初から再実行して、デモユーザーを再シードします。開発データは使い捨てで構いませんが、本番データベースに対しては絶対に db:reset を実行しないでください。

リレーションシップを宣言する

app/Models/Post.ts を更新し、投稿が著者に属することを宣言します。

import { defineModel, type BelongsToRecord } from '@guren/core'
import { posts } from '../../db/schema.js'
import type { UserRecord } from './User.js'

export type PostRecord = typeof posts.$inferSelect
export type NewPostRecord = typeof posts.$inferInsert
export type PostAuthor = Pick<UserRecord, 'id' | 'name'>

export class Post extends defineModel(posts) {
  static fillable = ['title', 'body', 'authorId']

  static override relationTypes: { author: BelongsToRecord<PostAuthor> } = {
    author: null,
  }
}

Post.belongsTo('author', () => import('./User.js').then((m) => m.User), 'authorId', 'id')

ここでは 3 つの要素が連携しています。

  • Post.belongsTo('author', ...) がリレーションを登録します: posts.authorId をたどって users.id に到達する、という意味です。遅延 import() により、PostUser の循環インポートを回避しています。
  • relationTypes は、eager load されたデータの形を TypeScript に伝えます — post.authorPostAuthor | null と型付けされます。
  • authorIdfillable に加えることで、Post.create() がこのフィールドを設定できるようになります。

store で著者を設定し、show で読み込む

app/Http/Controllers/PostController.ts の 2 つのアクションを更新します。

import type { UserRecord } from '../../Models/User.js'

// inside PostController:

  async show(): Promise<Response> {
    const { id } = this.validateParams(PostIdParamSchema)
    const post = await Post.findWithOrFail(id, 'author')

    return this.inertia(pages.posts.Show, {
      post: {
        id: post.id,
        title: post.title,
        body: post.body,
        createdAt: post.createdAt,
        author: post.author ? { id: post.author.id, name: post.author.name } : null,
      },
    })
  }

  async store(): Promise<Response> {
    const data = await this.validateBody(PostPayloadSchema)
    const user = await this.auth.userOrFail<UserRecord>()
    const post = await Post.create({ ...data, authorId: user.id })

    return this.redirect(post ? `/posts/${post.id}` : '/posts')
  }
  • this.auth.userOrFail() はサインイン中のユーザーを返すか、401 で応答します — ルートミドルウェアの背後に置く第 2 の防衛線です。
  • findWithOrFail(id, 'author') は、投稿が見つからなければ 404 を返すその同じ呼び出しの中で、リレーションを eager load します。
  • show アクションは、生のレコードをそのまま渡すのではなく、明示的なペイロードを組み立てています。これは重要です: 読み込んだ author の行には passwordHash が含まれており、this.inertia() に渡したものはすべてページにシリアライズされます。送るフィールドは自分で選んでください。ユーザーレコードを丸ごとブラウザに転送してはいけません。

著者を表示する

resources/js/pages/posts/Show.tsxProps とヘッダー部分を更新します。

interface Props {
  post: {
    id: number
    title: string
    body: string
    createdAt: string
    author: { id: number; name: string } | null
  }
}
      <p className="text-sm text-zinc-500">
        {new Date(post.createdAt).toLocaleDateString()} · {post.author?.name ?? 'Unknown author'}
      </p>

Props の形が変わったので、マニフェストを更新します: bun run codegenbun run dev が監視中なら自動で実行されます)。

5. チェックポイント: デモユーザーとして投稿する

  1. サインアウトした状態で /postsNew post をクリックします — /login にリダイレクトされます。
  2. demo@example.com / secret でサインインし、投稿を作成します。
  3. 投稿を開きます — 署名欄に "Demo User" と表示されます。

よくあるつまずき

デモアカウントで "Invalid credentials." になる。 シーダーが一度も実行されておらず、users テーブルが空です。bun run db:seed を実行してください。

add_author_to_posts マイグレーションが失敗する(NOT NULL constraint / カラムを追加できない)。 posts の既存行が新しい NOT NULL カラムを満たせません。bun run db:reset --seed で開発用データベースを作り直してください。

add auth のあと pages.auth.Loginpages.dashboard.Index が見つからない。 codegen が新しいページをまだ認識していません。bun run codegen を実行するか、bun run dev を再起動してください。

サインインしたのに /posts/create を開くたびに /login に戻される。 src/app.tscreateApp()auth: {} が、providersAuthProvider が追加されているか確認してください — add auth が自動でパッチしますが、ファイルをカスタマイズしていた場合は要確認です。

Post.create の呼び出しで TypeScript が authorId の欠落を指摘する。 スキーマだけ更新してモデルを更新していません: fillableauthorId を追加し、store アクションがそれを渡しているか確認してください。

次へ

投稿に著者が付きました — 次は読者に声を届けてもらいましょう。Part 3: リレーションシップ: コメント に進んでください。