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テーブルを、passwordHashとrememberTokenカラムを持つものに置き換え(引き続き SQLite)、対応するマイグレーションを生成します。src/app.ts—AuthProviderを登録し、createApp()にauth: {}を追加します。これによりセッションと CSRF のミドルウェアが有効になります。routes/web.ts— ルート registrar の先頭でregisterAuthRoutes(router)をインポートして呼び出すようにします。
Warning
add auth は db/schema.ts の users テーブル定義を書き換えます。users にカスタムカラムを追加していた場合は、コマンド実行後に再追加してください。
続いて、users のマイグレーションを適用し、デモアカウントをシードし、生成済みの型を更新します(雛形が新しいページとルートを追加したためです)。
bun run db:migrate
bun run db:seed
bun run codegen
2. チェックポイント: サインインする
開発サーバーを起動し(bun run dev)、http://localhost:3333/login を開きます。
- demo@example.com / secret でサインインします —
/dashboardに着地し、名前入りの挨拶が表示されます。 - 間違ったパスワードを試します — フォームに "Invalid credentials." が表示されます。
- プライベートブラウジングのウィンドウで
/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/create と POST /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.ts の posts テーブルを編集し、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()により、PostとUserの循環インポートを回避しています。relationTypesは、eager load されたデータの形を TypeScript に伝えます —post.authorはPostAuthor | nullと型付けされます。authorIdをfillableに加えることで、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.tsx の Props とヘッダー部分を更新します。
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 codegen(bun run dev が監視中なら自動で実行されます)。
5. チェックポイント: デモユーザーとして投稿する
- サインアウトした状態で
/postsの New post をクリックします —/loginにリダイレクトされます。 - demo@example.com / secret でサインインし、投稿を作成します。
- 投稿を開きます — 署名欄に "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.Login や pages.dashboard.Index が見つからない。
codegen が新しいページをまだ認識していません。bun run codegen を実行するか、bun run dev を再起動してください。
サインインしたのに /posts/create を開くたびに /login に戻される。
src/app.ts の createApp() に auth: {} が、providers に AuthProvider が追加されているか確認してください — add auth が自動でパッチしますが、ファイルをカスタマイズしていた場合は要確認です。
Post.create の呼び出しで TypeScript が authorId の欠落を指摘する。
スキーマだけ更新してモデルを更新していません: fillable に authorId を追加し、store アクションがそれを渡しているか確認してください。
次へ
投稿に著者が付きました — 次は読者に声を届けてもらいましょう。Part 3: リレーションシップ: コメント に進んでください。