Menu

認証

アプリケーションのデータを保護するために、認証の理解は非常に重要です。このページでは、認証を実装するために使用するReactとNext.jsの機能について説明します。

始める前に、このプロセスを3つの概念に分解すると理解しやすくなります:

  1. 認証:ユーザーが本人であることを確認します。ユーザー名とパスワードなど、ユーザーが持っているものによってアイデンティティを証明する必要があります。
  2. セッション管理:リクエスト間でユーザーの認証状態を追跡します。
  3. 認可:ユーザーがアクセスできるルートとデータを決定します。

この図は、ReactとNext.jsの機能を使用した認証フローを示しています:

ReactとNext.jsの機能を使用した認証フローを示す図

このページの例では、教育目的でベーシックなユーザー名とパスワードによる認証を説明します。カスタム認証ソリューションを実装することもできますが、セキュリティと簡便性を高めるために、認証ライブラリの使用を推奨します。これらのライブラリは、認証、セッション管理、認可のためのビルトインソリューションに加えて、ソーシャルログイン、多要素認証、ロールベースのアクセス制御などの追加機能を提供します。認証ライブラリセクションでリストを確認できます。

認証

サインアップとログイン機能

Reactの<form>要素とServer Actions、そしてuseFormStateを使用して、ユーザーの認証情報を取得し、フォームフィールドを検証し、認証プロバイダーのAPIまたはデータベースを呼び出すことができます。

Server Actionsは常にサーバー上で実行されるため、認証ロジックを処理するための安全な環境を提供します。

サインアップ/ログイン機能を実装するためのステップは以下の通りです:

1. ユーザーの認証情報を取得

ユーザーの認証情報を取得するために、送信時にServer Actionを呼び出すフォームを作成します。例えば、ユーザーの名前、メール、パスワードを受け付けるサインアップフォーム:

app/ui/signup-form.tsx
TypeScript
import { signup } from '@/app/actions/auth'
 
export function SignupForm() {
  return (
    <form action={signup}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" placeholder="Email" />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      <button type="submit">Sign Up</button>
    </form>
  )
}
app/actions/auth.tsx
TypeScript
export async function signup(formData: FormData) {}

2. サーバー側でフォームフィールドを検証

Server Actionを使用して、サーバー側でフォームフィールドを検証します。認証プロバイダーがフォームバリデーションを提供していない場合は、ZodYupなどのスキーマバリデーションライブラリを使用できます。

Zodを例として使用すると、適切なエラーメッセージを含むフォームスキーマを定義できます:

app/lib/definitions.ts
import { z } from 'zod'
 
export const SignupFormSchema = z.object({
  name: z
    .string()
    .min(2, { message: '名前は2文字以上である必要があります。' })
    .trim(),
  email: z.string().email({ message: '有効なメールアドレスを入力してください。' }).trim(),
  password: z
    .string()
    .min(8, { message: '8文字以上である必要があります' })
    .regex(/[a-zA-Z]/, { message: '最低1文字のアルファベットを含む必要があります。' })
    .regex(/[0-9]/, { message: '最低1文字の数字を含む必要があります。' })
    .regex(/[^a-zA-Z0-9]/, {
      message: '最低1文字の特殊文字を含む必要があります。',
    })
    .trim(),
})
 
export type FormState =
  | {
      errors?: {
        name?: string[]
        email?: string[]
        password?: string[]
      }
      message?: string
    }
  | undefined
app/lib/definitions.js
import { z } from 'zod'
 
export const SignupFormSchema = z.object({
  name: z
    .string()
    .min(2, { message: '名前は2文字以上である必要があります。' })
    .trim(),
  email: z.string().email({ message: '有効なメールアドレスを入力してください。' }).trim(),
  password: z
    .string()
    .min(8, { message: '8文字以上である必要があります' })
    .regex(/[a-zA-Z]/, { message: '最低1文字のアルファベットを含む必要があります。' })
    .regex(/[0-9]/, { message: '最低1文字の数字を含む必要があります。' })
    .regex(/[^a-zA-Z0-9]/, {
      message: '最低1文字の特殊文字を含む必要があります。',
    })
    .trim(),
})

認証プロバイダーのAPIやデータベースへの不要な呼び出しを防ぐために、フォームフィールドが定義されたスキーマと一致しない場合は、Server Actionで早期にreturnすることができます。

app/actions/auth.ts
import { SignupFormSchema, FormState } from '@/app/lib/definitions'
 
export async function signup(state: FormState, formData: FormData) {
  // フォームフィールドを検証
  const validatedFields = SignupFormSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  })
 
  // フォームフィールドが無効な場合は早期に返す
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }
 
  // ユーザーを作成するためにプロバイダーまたはdbを呼び出す...
}
app/actions/auth.js
import { SignupFormSchema } from '@/app/lib/definitions'
 
export async function signup(state, formData) {
  // フォームフィールドを検証
  const validatedFields = SignupFormSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  })
 
  // フォームフィールドが無効な場合は早期に返す
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }
 
  // ユーザーを作成するためにプロバイダーまたはdbを呼び出す...
}

<SignupForm />に戻り、ReactのuseFormStateフックを使用して、フォーム送信中にバリデーションエラーを表示できます:

app/ui/signup-form.tsx
TypeScript
'use client'
 
import { useFormState, useFormStatus } from 'react-dom'
import { signup } from '@/app/actions/auth'
 
export function SignupForm() {
  const [state, action] = useFormState(signup, undefined)
 
  return (
    <form action={action}>
      <div>
        <label htmlFor="name">名前</label>
        <input id="name" name="name" placeholder="名前" />
      </div>
      {state?.errors?.name && <p>{state.errors.name}</p>}
 
      <div>
        <label htmlFor="email">メールアドレス</label>
        <input id="email" name="email" placeholder="メールアドレス" />
      </div>
      {state?.errors?.email && <p>{state.errors.email}</p>}
 
      <div>
        <label htmlFor="password">パスワード</label>
        <input id="password" name="password" type="password" />
      </div>
      {state?.errors?.password && (
        <div>
          <p>パスワードは以下の条件を満たす必要があります:</p>
          <ul>
            {state.errors.password.map((error) => (
              <li key={error}>- {error}</li>
            ))}
          </ul>
        </div>
      )}
      <SubmitButton />
    </form>
  )
}
 
function SubmitButton() {
  const { pending } = useFormStatus()
 
  return (
    <button disabled={pending} type="submit">
      サインアップ
    </button>
  )
}

補足:

  • これらの例では、Next.js App RouterにバンドルされているreactのuseFormStateフックを使用しています。React 19を使用している場合は、代わりにuseActionStateを使用してください。詳細についてはReactのドキュメントを参照してください。
  • React 19では、useFormStatusは返されるオブジェクトにdata、method、actionなどの追加のキーを含みます。React 19を使用していない場合は、pendingキーのみが利用可能です。
  • React 19では、useActionStateも返されるstateにpendingキーを含みます。
  • データを変更する前に、ユーザーがそのアクションを実行する権限を持っているかを必ず確認する必要があります。認証と認可を参照してください。

3. ユーザーを作成またはユーザー認証情報を確認

フォームフィールドを検証した後、認証プロバイダーのAPIまたはデータベースを呼び出して、新しいユーザーアカウントを作成するか、ユーザーが存在するかを確認できます。

前の例から続けて:

app/actions/auth.tsx
TypeScript
export async function signup(state: FormState, formData: FormData) {
  // 1. フォームフィールドを検証
  // ...
 
  // 2. データベースに挿入するためのデータを準備
  const { name, email, password } = validatedFields.data
  // 例:ユーザーのパスワードを保存する前にハッシュ化
  const hashedPassword = await bcrypt.hash(password, 10)
 
  // 3. ユーザーをデータベースに挿入または認証ライブラリのAPIを呼び出す
  const data = await db
    .insert(users)
    .values({
      name,
      email,
      password: hashedPassword,
    })
    .returning({ id: users.id })
 
  const user = data[0]
 
  if (!user) {
    return {
      message: 'アカウントの作成中にエラーが発生しました。',
    }
  }
 
  // TODO:
  // 4. ユーザーセッションを作成
  // 5. ユーザーをリダイレクト
}

ユーザーアカウントの作成または認証情報の確認に成功した後、セッションを作成してユーザーの認証状態を管理できます。セッション管理戦略に応じて、セッションをクッキーやデータベース、または両方に保存できます。詳細についてはセッション管理セクションを参照してください。

ヒント:

  • 上記の例は教育目的で認証のステップを詳しく説明しているため冗長になっています。これは、独自の安全なソリューションの実装がいかに複雑になりうるかを示しています。プロセスを簡略化するために認証ライブラリの使用を検討してください。
  • ユーザー体験を向上させるため、登録フロー中の早い段階で重複するメールアドレスやユーザー名をチェックすることをお勧めします。例えば、ユーザーが入力中やインプットフィールドがフォーカスを失った時などです。これにより不要なフォーム送信を防ぎ、ユーザーにすぐにフィードバックを提供できます。これらのチェックの頻度を管理するために、use-debounceなどのライブラリでデバウンスできます。

セッション管理

セッション管理は、リクエスト間でユーザーの認証状態が保持されることを保証します。セッションまたはトークンの作成、保存、更新、削除を含みます。

セッションには2つのタイプがあります:

  1. ステートレス:セッションデータ(またはトークン)はブラウザのクッキーに保存されます。クッキーは各リクエストと共に送信され、サーバー上でセッションを検証できます。このメソッドはより簡単ですが、正しく実装されない場合はセキュリティが低下する可能性があります。
  2. データベース:セッションデータはデータベースに保存され、ユーザーのブラウザは暗号化されたセッションIDのみを受け取ります。このメソッドはより安全ですが、複雑でサーバーリソースを多く使用する可能性があります。

補足: どちらのメソッドも使用できますし、両方を組み合わせることもできますが、iron-sessionJoseなどのセッション管理ライブラリの使用を推奨します。

ステートレスセッション

ステートレスセッションを作成および管理するには、以下のステップを実行する必要があります:

  1. セッションの署名に使用するシークレットキーを生成し、環境変数として保存します。
  2. セッション管理ライブラリを使用してセッションデータを暗号化/復号化するロジックを記述します。
  3. Next.jsのcookies APIを使用してクッキーを管理します。

上記に加えて、ユーザーがアプリケーションに戻ってきたときにセッションを更新(またはリフレッシュ)し、ログアウト時にセッションを削除する機能を追加することを検討してください。

補足: 認証ライブラリにセッション管理が含まれているかどうかを確認してください。

1. シークレットキーの生成

セッションに署名するためのシークレットキーを生成する方法はいくつかあります。例えば、ターミナルでopensslコマンドを使用することもできます:

terminal
openssl rand -base64 32

このコマンドは32文字のランダムな文字列を生成し、それをシークレットキーとして環境変数ファイルに保存できます:

.env
SESSION_SECRET=your_secret_key

そして、このキーをセッション管理ロジックで参照できます:

app/lib/session.js
const secretKey = process.env.SESSION_SECRET

2. セッションの暗号化と復号化

次に、好みのセッション管理ライブラリを使用してセッションを暗号化および復号化できます。前の例から続けて、Edge Runtimeと互換性のあるJoseとReactのserver-onlyパッケージを使用して、セッション管理ロジックがサーバーでのみ実行されることを保証します。

app/lib/session.ts
TypeScript
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { SessionPayload } from '@/app/lib/definitions'
 
const secretKey = process.env.SESSION_SECRET
const encodedKey = new TextEncoder().encode(secretKey)
 
export async function encrypt(payload: SessionPayload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(encodedKey)
}
 
export async function decrypt(session: string | undefined = '') {
  try {
    const { payload } = await jwtVerify(session, encodedKey, {
      algorithms: ['HS256'],
    })
    return payload
  } catch (error) {
    console.log('セッションの検証に失敗しました')
  }
}

ヒント

  • ペイロードには、後続のリクエストで使用される最小限のユニークなユーザーデータ(ユーザーIDやロールなど)のみを含める必要があります。電話番号、メールアドレス、クレジットカード情報などの個人を特定できる情報や、パスワードなどの機密データを含めるべきではありません。

3. クッキーの設定(推奨オプション)

セッションをクッキーに保存するには、Next.jsのcookies APIを使用します。クッキーはサーバーで設定され、推奨オプションを含める必要があります:

  • HttpOnly: クライアントサイドのJavaScriptがクッキーにアクセスするのを防ぎます。
  • Secure: クッキーの送信にhttpsを使用します。
  • SameSite: クロスサイトリクエストでクッキーを送信できるかどうかを指定します。
  • Max-Age または Expires: 一定期間後にクッキーを削除します。
  • Path: クッキーのURLパスを定義します。

これらのオプションの詳細についてはMDNを参照してください。

app/lib/session.ts
import 'server-only'
import { cookies } from 'next/headers'
 
export async function createSession(userId: string) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const session = await encrypt({ userId, expiresAt })
  const cookieStore = await cookies()
 
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}
app/lib/session.js
import 'server-only'
import { cookies } from 'next/headers'
 
export async function createSession(userId) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const session = await encrypt({ userId, expiresAt })
  const cookieStore = await cookies()
 
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}

Server Actionに戻り、createSession()関数を呼び出し、redirect() APIを使用してユーザーを適切なページにリダイレクトできます:

app/actions/auth.ts
import { createSession } from '@/app/lib/session'
 
export async function signup(state: FormState, formData: FormData) {
  // 前のステップ:
  // 1. フォームフィールドを検証
  // 2. データベースに挿入するためのデータを準備
  // 3. ユーザーをデータベースに挿入または認証ライブラリのAPIを呼び出す
 
  // 現在のステップ:
  // 4. ユーザーセッションを作成
  await createSession(user.id)
  // 5. ユーザーをリダイレクト
  redirect('/profile')
}
app/actions/auth.js
import { createSession } from '@/app/lib/session'
 
export async function signup(state, formData) {
  // 前のステップ:
  // 1. フォームフィールドを検証
  // 2. データベースに挿入するためのデータを準備
  // 3. ユーザーをデータベースに挿入または認証ライブラリのAPIを呼び出す
 
  // 現在のステップ:
  // 4. ユーザーセッションを作成
  await createSession(user.id)
  // 5. ユーザーをリダイレクト
  redirect('/profile')
}

ヒント

  • クッキーはサーバーで設定される必要があります。クライアントサイドでの改ざんを防ぐためです。
  • 🎥 Next.jsでのステートレスセッションと認証について詳しく学ぶ → YouTube (11分)

セッションの更新(またはリフレッシュ)

セッションの有効期限を延長することもできます。これはユーザーがアプリケーションに再度アクセスした後もログイン状態を維持するのに役立ちます。例えば:

app/lib/session.ts
import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'
 
export async function updateSession() {
  const session = (await cookies()).get('session')?.value
  const payload = await decrypt(session)
 
  if (!session || !payload) {
    return null
  }
 
  const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
 
  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expires,
    sameSite: 'lax',
    path: '/',
  })
}
app/lib/session.js
import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'
 
export async function updateSession() {
  const session = (await cookies()).get('session')?.value
  const payload = await decrypt(session)
 
  if (!session || !payload) {
    return null
  }
 
  const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const cookieStore = await cookies()
 
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expires,
    sameSite: 'lax',
    path: '/',
  })
}

ヒント: 認証ライブラリがリフレッシュトークンをサポートしているかどうかを確認してください。リフレッシュトークンを使用してユーザーのセッションを延長できます。

セッションの削除

セッションを削除するには、クッキーを削除できます:

app/lib/session.ts
import 'server-only'
import { cookies } from 'next/headers'
 
export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}
app/lib/session.js
import 'server-only'
import { cookies } from 'next/headers'
 
export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}

そしてdeleteSession()関数をアプリケーションで再利用できます。例えば、ログアウト時:

app/actions/auth.ts
import { cookies } from 'next/headers'
import { deleteSession } from '@/app/lib/session'
 
export async function logout() {
  deleteSession()
  redirect('/login')
}
app/actions/auth.js
import { cookies } from 'next/headers'
import { deleteSession } from '@/app/lib/session'
 
export async function logout() {
  deleteSession()
  redirect('/login')
}

データベースセッション

データベースセッションを作成および管理するには、以下のステップを実行する必要があります:

  1. セッションとデータを保存するためのテーブルをデータベースに作成します(または認証ライブラリがこれを処理するかどうかを確認します)。
  2. セッションの挿入、更新、削除の機能を実装します。
  3. セッションIDをユーザーのブラウザに保存する前に暗号化し、データベースとクッキーが同期していることを確認します(これはオプションですが、Middlewareでの楽観的な認証チェックのために推奨されます)。

例えば:

app/lib/session.ts
import cookies from 'next/headers'
import { db } from '@/app/lib/db'
import { encrypt } from '@/app/lib/session'
 
export async function createSession(id: number) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
 
  // 1. データベースにセッションを作成
  const data = await db
    .insert(sessions)
    .values({
      userId: id,
      expiresAt,
    })
    // セッションIDを返す
    .returning({ id: sessions.id })
 
  const sessionId = data[0].id
 
  // 2. セッションIDを暗号化
  const session = await encrypt({ sessionId, expiresAt })
 
  // 3. 楽観的な認証チェックのためにセッションをクッキーに保存
  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}
app/lib/session.js
import cookies from 'next/headers'
import { db } from '@/app/lib/db'
import { encrypt } from '@/app/lib/session'
 
export async function createSession(id) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
 
  // 1. データベースにセッションを作成
  const data = await db
    .insert(sessions)
    .values({
      userId: id,
      expiresAt,
    })
    // セッションIDを返す
    .returning({ id: sessions.id })
 
  const sessionId = data[0].id
 
  // 2. セッションIDを暗号化
  const session = await encrypt({ sessionId, expiresAt })
 
  // 3. 楽観的な認証チェックのためにセッションをクッキーに保存
  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}

ヒント

  • より高速なデータ取得のために、Vercel Redisのようなデータベースの使用を検討してください。ただし、セッションデータをプライマリデータベースに保持し、クエリ数を減らすためにデータリクエストを組み合わせることもできます。
  • ユーザーの最終ログイン時刻の追跡や、アクティブなデバイス数の追跡、すべてのデバイスからログアウトする機能をユーザーに提供するなど、より高度なユースケースにはデータベースセッションを使用することを選択できます。

セッション管理を実装した後、ユーザーがアプリケーション内で何にアクセスし、何ができるかを制御する認可ロジックを追加する必要があります。詳細については認可セクションを参照してください。

認可

ユーザーが認証され、セッションが作成されると、ユーザーがアプリケーション内で何にアクセスし、何ができるかを制御する認可を実装できます。

認可チェックには2つの主要なタイプがあります:

  1. 楽観的: クッキーに保存されたセッションデータを使用して、ユーザーがルートにアクセスしたりアクションを実行する権限があるかどうかをチェックします。これらのチェックは、UIコンポーネントの表示/非表示や、権限やロールに基づいたユーザーのリダイレクトなどの迅速な操作に役立ちます。
  2. 安全: データベースに保存されたセッションデータを使用して、ユーザーがルートにアクセスしたりアクションを実行する権限があるかどうかをチェックします。これらのチェックはより安全で、機密データやアクションへのアクセスが必要な操作に使用されます。

どちらの場合も、以下を推奨します:

Middlewareを使用した楽観的チェック(オプション)

Middlewareを使用して権限に基づいてユーザーをリダイレクトしたい場合があります:

  • 楽観的チェックを実行するため。Middlewareはすべてのルートで実行されるため、リダイレクトロジックを一元化し、権限のないユーザーを事前にフィルタリングするのに適しています。
  • ユーザー間で共有される静的ルート(例:有料コンテンツ)を保護するため。

しかし、Middlewareはプリフェッチされたルートを含むすべてのルートで実行されるため、パフォーマンスの問題を防ぐために、クッキーからセッションを読み取る(楽観的チェック)のみを行い、データベースチェックを避けることが重要です。

例えば:

middleware.ts
TypeScript
import { NextRequest, NextResponse } from 'next/server'
import { decrypt } from '@/app/lib/session'
import { cookies } from 'next/headers'
 
// 1. 保護されたルートとパブリックルートを指定
const protectedRoutes = ['/dashboard']
const publicRoutes = ['/login', '/signup', '/']
 
export default async function middleware(req: NextRequest) {
  // 2. 現在のルートが保護されているかパブリックかをチェック
  const path = req.nextUrl.pathname
  const isProtectedRoute = protectedRoutes.includes(path)
  const isPublicRoute = publicRoutes.includes(path)
 
  // 3. クッキーからセッションを復号化
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)
 
  // 4. ユーザーが認証されていない場合は/loginにリダイレクト
  if (isProtectedRoute && !session?.userId) {
    return NextResponse.redirect(new URL('/login', req.nextUrl))
  }
 
  // 5. ユーザーが認証済みの場合は/dashboardにリダイレクト
  if (
    isPublicRoute &&
    session?.userId &&
    !req.nextUrl.pathname.startsWith('/dashboard')
  ) {
    return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
  }
 
  return NextResponse.next()
}
 
// Middlewareを実行しないルート
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}
middleware.js
import { NextResponse } from 'next/server'
import { decrypt } from '@/app/lib/session'
import { cookies } from 'next/headers'
 
// 1. 保護されたルートとパブリックルートを指定
const protectedRoutes = ['/dashboard']
const publicRoutes = ['/login', '/signup', '/']
 
export default async function middleware(req) {
  // 2. 現在のルートが保護されているかパブリックかをチェック
  const path = req.nextUrl.pathname
  const isProtectedRoute = protectedRoutes.includes(path)
  const isPublicRoute = publicRoutes.includes(path)
 
  // 3. クッキーからセッションを復号化
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)
 
  // 5. ユーザーが認証されていない場合は/loginにリダイレクト
  if (isProtectedRoute && !session?.userId) {
    return NextResponse.redirect(new URL('/login', req.nextUrl))
  }
 
  // 6. ユーザーが認証済みの場合は/dashboardにリダイレクト
  if (
    isPublicRoute &&
    session?.userId &&
    !req.nextUrl.pathname.startsWith('/dashboard')
  ) {
    return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
  }
 
  return NextResponse.next()
}
 
// Middlewareを実行しないルート
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}

Middlewareは初期チェックに役立ちますが、データを保護する唯一の防衛線であってはなりません。セキュリティチェックの大部分は、データソースにできるだけ近い場所で実行される必要があります。詳細についてはデータアクセスレイヤーを参照してください。

ヒント

  • Middlewareでは、req.cookies.get('session').valueを使用してクッキーを読み取ることもできます。
  • MiddlewareはEdge Runtimeを使用します。認証ライブラリとセッション管理ライブラリが互換性があるかどうかを確認してください。
  • Middlewareのmatcherプロパティを使用して、Middlewareを実行するルートを指定できます。ただし、認証の場合は、Middlewareがすべてのルートで実行されることを推奨します。

データアクセスレイヤー(DAL)の作成

データリクエストと認可ロジックを一元化するためにDALを作成することを推奨します。

DALには、ユーザーがアプリケーションを操作する際にユーザーのセッションを検証する関数を含める必要があります。最低でも、その関数はセッションが有効かどうかをチェックし、その後のリクエストに必要なユーザー情報を返すか、ユーザーをリダイレクトする必要があります。

例えば、verifySession()関数を含むDALのための別ファイルを作成します。そしてReactのcache APIを使用して、React のレンダリングパス中に関数の戻り値をメモ化します:

app/lib/dal.ts
TypeScript
import 'server-only'
 
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'
 
export const verifySession = cache(async () => {
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)
 
  if (!session?.userId) {
    redirect('/login')
  }
 
  return { isAuth: true, userId: session.userId }
})
app/lib/dal.js
import 'server-only'
 
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'
 
export const verifySession = cache(async () => {
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)
 
  if (!session?.userId) {
    redirect('/login')
  }
 
  return { isAuth: true, userId: session.userId }
})

そして、データリクエスト、Server Actions、Route HandlersでverifySession()関数を呼び出すことができます:

app/lib/dal.ts
TypeScript
export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null
 
  try {
    const data = await db.query.users.findMany({
      where: eq(users.id, session.userId),
      // 必要なカラムのみを明示的に返す(ユーザーオブジェクト全体ではなく)
      columns: {
        id: true,
        name: true,
        email: true,
      },
    })
 
    const user = data[0]
 
    return user
  } catch (error) {
    console.log('ユーザーの取得に失敗しました')
    return null
  }
})

ヒント:

  • DALはリクエスト時に取得されるデータを保護するために使用できます。ただし、ユーザー間で共有されるデータを持つ静的ルートの場合、データはビルド時に取得され、リクエスト時ではありません。静的ルートを保護するにはMiddlewareを使用してください。
  • 安全なチェックのために、セッションIDをデータベースと比較してセッションが有効かどうかを確認できます。レンダリングパス中に不要な重複したデータベースリクエストを避けるために、Reactのcache関数を使用してください。
  • 関連するデータリクエストを、verifySession()を実行してから任意のメソッドを実行するJavaScriptクラスにまとめることもできます。

データ転送オブジェクト(DTO)の使用

データを取得する際は、アプリケーションで使用される必要なデータのみを返し、オブジェクト全体は返さないことを推奨します。例えば、ユーザーデータを取得する場合、パスワードや電話番号などを含むユーザーオブジェクト全体ではなく、ユーザーのIDと名前のみを返すことができます。

ただし、返されるデータ構造を制御できない場合や、クライアントに全体のオブジェクトが渡されることを避けたいチームで作業している場合は、クライアントに公開しても安全なフィールドを指定するなどの戦略を使用できます。

app/lib/dto.ts
TypeScript
import 'server-only'
import { getUser } from '@/app/lib/dal'
 
function canSeeUsername(viewer: User) {
  return true
}
 
function canSeePhoneNumber(viewer: User, team: string) {
  return viewer.isAdmin || team === viewer.team
}
 
export async function getProfileDTO(slug: string) {
  const data = await db.query.users.findMany({
    where: eq(users.slug, slug),
    // ここで特定のカラムを返す
  })
  const user = data[0]
 
  const currentUser = await getUser(user.id)
 
  // またはクエリに特有の内容のみをここで返す
  return {
    username: canSeeUsername(currentUser) ? user.username : null,
    phonenumber: canSeePhoneNumber(currentUser, user.team)
      ? user.phonenumber
      : null,
  }
}
app/lib/dto.js
import 'server-only'
import { getUser } from '@/app/lib/dal'
 
function canSeeUsername(viewer) {
  return true
}
 
function canSeePhoneNumber(viewer, team) {
  return viewer.isAdmin || team === viewer.team
}
 
export async function getProfileDTO(slug) {
  const data = await db.query.users.findMany({
    where: eq(users.slug, slug),
    // ここで特定のカラムを返す
  })
  const user = data[0]
 
  const currentUser = await getUser(user.id)
 
  // またはクエリに特有の内容のみをここで返す
  return {
    username: canSeeUsername(currentUser) ? user.username : null,
    phonenumber: canSeePhoneNumber(currentUser, user.team)
      ? user.phonenumber
      : null,
  }
}

DALでデータリクエストと認可ロジックを一元化し、DTOを使用することで、すべてのデータリクエストが安全で一貫していることを保証でき、アプリケーションの規模が大きくなるにつれて保守、監査、デバッグがより容易になります。

補足:

  • DTOを定義する方法はいくつかあります。上記の例のような個別の関数から、toJSON()の使用、JavaScriptクラスまで様々です。これらはJavaScriptのパターンであり、ReactやNext.jsの機能ではないため、アプリケーションに最適なパターンを見つけるための調査を推奨します。
  • セキュリティのベストプラクティスについては、Next.jsのセキュリティに関する記事で詳しく学べます。

Server Components

Server Componentsでの認証チェックは、ロールベースのアクセスに役立ちます。例えば、ユーザーのロールに基づいてコンポーネントを条件付きでレンダリングする場合:

app/dashboard/page.tsx
TypeScript
import { verifySession } from '@/app/lib/dal'
 
export default function Dashboard() {
  const session = await verifySession()
  const userRole = session?.user?.role // セッションオブジェクトに'role'が含まれていると仮定
 
  if (userRole === 'admin') {
    return <AdminDashboard />
  } else if (userRole === 'user') {
    return <UserDashboard />
  } else {
    redirect('/login')
  }
}

この例では、DALのverifySession()関数を使用して'admin'、'user'、および未認証のロールをチェックしています。このパターンにより、各ユーザーが自分のロールに適したコンポーネントとのみやり取りできることが保証されます。

レイアウトと認証チェック

部分レンダリングにより、レイアウトでのチェックには注意が必要です。これらはナビゲーション時に再レンダリングされないため、ユーザーセッションはルート変更のたびにチェックされません。

代わりに、データソースや条件付きでレンダリングされるコンポーネントの近くでチェックを行う必要があります。

例えば、ユーザーデータを取得し、ナビゲーションにユーザー画像を表示する共有レイアウトを考えてみましょう。レイアウトで認証チェックを行うのではなく、レイアウトでユーザーデータ(getUser())を取得し、DALで認証チェックを行うべきです。

これにより、アプリケーション内でgetUser()が呼び出されるたびに認証チェックが実行されることが保証され、開発者がデータへのアクセス権限をチェックするのを忘れることを防ぎます。

app/layout.tsx
TypeScript
export default async function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getUser();
 
  return (
    // ...
  )
}
app/lib/dal.ts
export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null
 
  // セッションからユーザーIDを取得しデータを取得
})
app/lib/dal.js
export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null
 
  // セッションからユーザーIDを取得しデータを取得
})

補足:

  • SPAでよくみられるパターンは、ユーザーが認証されていない場合にレイアウトや最上位コンポーネントでreturn nullを返すことです。このパターンは推奨されません。Next.jsアプリケーションには複数のエントリーポイントがあり、ネストされたルートセグメントとServer Actionsへのアクセスを防ぐことはできないためです。

Server Actions

Server Actionsは公開されているAPIエンドポイントと同じセキュリティ上の考慮事項で扱い、ユーザーがミューテーションを実行する権限があるかどうかを確認する必要があります。

以下の例では、アクションを進める前にユーザーのロールをチェックしています:

app/lib/actions.ts
'use server'
import { verifySession } from '@/app/lib/dal'
 
export async function serverAction(formData: FormData) {
  const session = await verifySession()
  const userRole = session?.user?.role
 
  // ユーザーがアクションを実行する権限がない場合は早期に返す
  if (userRole !== 'admin') {
    return null
  }
 
  // 権限のあるユーザーに対してアクションを実行
}
app/lib/actions.js
'use server'
import { verifySession } from '@/app/lib/dal'
 
export async function serverAction() {
  const session = await verifySession()
  const userRole = session.user.role
 
  // ユーザーがアクションを実行する権限がない場合は早期に返す
  if (userRole !== 'admin') {
    return null
  }
 
  // 権限のあるユーザーに対してアクションを実行
}

Route Handlers

Route Handlersは公開されているAPIエンドポイントと同じセキュリティ上の考慮事項で扱い、ユーザーがRoute Handlerにアクセスする権限があるかどうかを確認する必要があります。

例えば:

app/api/route.ts
import { verifySession } from '@/app/lib/dal'
 
export async function GET() {
  // ユーザー認証とロール確認
  const session = await verifySession()
 
  // ユーザーが認証されているかチェック
  if (!session) {
    // ユーザーが認証されていない
    return new Response(null, { status: 401 })
  }
 
  // ユーザーが'admin'ロールを持っているかチェック
  if (session.user.role !== 'admin') {
    // ユーザーは認証されているが、適切な権限がない
    return new Response(null, { status: 403 })
  }
 
  // 権限のあるユーザーに対して処理を継続
}
app/api/route.js
import { verifySession } from '@/app/lib/dal'
 
export async function GET() {
  // ユーザー認証とロール確認
  const session = await verifySession()
 
  // ユーザーが認証されているかチェック
  if (!session) {
    // ユーザーが認証されていない
    return new Response(null, { status: 401 })
  }
 
  // ユーザーが'admin'ロールを持っているかチェック
  if (session.user.role !== 'admin') {
    // ユーザーは認証されているが、適切な権限がない
    return new Response(null, { status: 403 })
  }
 
  // 権限のあるユーザーに対して処理を継続
}

上記の例は、2段階のセキュリティチェックを持つRoute Handlerを示しています。まずアクティブなセッションをチェックし、その後ログインしているユーザーが'admin'であるかを確認します。

コンテキストプロバイダー

認証用のコンテキストプロバイダーはインターリービングのおかげで動作します。ただし、React contextはServer Componentsではサポートされておらず、Client Componentsでのみ適用可能です。

これは動作しますが、子のServer Componentsは最初にサーバーでレンダリングされ、コンテキストプロバイダーのセッションデータにアクセスできません:

app/layout.ts
TypeScript
import { ContextProvider } from 'auth-lib'
 
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <ContextProvider>{children}</ContextProvider>
      </body>
    </html>
  )
}
app/ui/profile.ts
TypeScript
"use client";
 
import { useSession } from "auth-lib";
 
export default function Profile() {
  const { userId } = useSession();
  const { data } = useSWR(`/api/user/${userId}`, fetcher)
 
  return (
    // ...
  );
}

セッションデータがClient Componentsで必要な場合(例:クライアントサイドのデータフェッチのため)、ReactのtaintUniqueValue APIを使用して機密性の高いセッションデータがクライアントに露出するのを防ぎます。

リソース

Next.jsでの認証について学んだので、安全な認証とセッション管理を実装するためのNext.js互換のライブラリとリソースを紹介します:

認証ライブラリ

セッション管理ライブラリ

さらなる学習

認証とセキュリティについて学び続けるために、以下のリソースをチェックしてください: