認証
認証を理解することは、アプリケーションのデータを保護するために重要です。この記事では、認証を実装するためにReactとNext.jsの機能をどのように使用するかについて説明します。
始める前に、プロセスを次の3つの概念に分解すると役立ちます:
- 認証:ユーザーが主張する人物であるかどうかを検証します。ユーザーがユーザー名やパスワードなどのもので自分の身元を証明することが必要です。
- セッション管理:リクエスト間のユーザーの認証状態を追跡します。
- 認可:ユーザーがどのルートとデータにアクセスできるかを決定します。
次の図は、ReactおよびNext.jsの機能を使用した認証フローを示しています:
この記事の例では、教育的目的で基本的なユーザー名とパスワードの認証を説明しています。カスタムの認証ソリューションを実装することもできますが、セキュリティの向上とシンプルさのために、認証ライブラリの使用をお勧めします。これらは、認証、セッション管理、および認可のための組み込みソリューションに加えて、ソーシャルログイン、多要素認証、役割ベースのアクセス制御などの追加機能を提供します。Auth Librariesセクションでリストを見ることができます。
Authentication
新規登録とログイン機能
ReactのServer ActionsおよびuseFormState
と共に<form>
要素を使用して、ユーザーの資格情報を取得し、フォームフィールドを検証し、認証プロバイダーのAPIまたはデータベースを呼び出すことができます。
Server Actionsは常にサーバー上で実行されるため、認証ロジックを処理するための安全な環境を提供します。
新規登録/ログイン機能を実装するための手順は次のとおりです:
1. ユーザーの資格情報を取得する
ユーザーの資格情報を取得するには、送信時にServer Actionを呼び出すフォームを作成します。例えば、ユーザーの名前、メール、およびパスワードを受け入れる登録フォーム:
- TypeScript
- JavaScript
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>
)
}
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>
)
}
- TypeScript
- JavaScript
export async function signup(formData: FormData) {}
export async function signup(formData) {}
2. フォームフィールドをサーバー側で検証する
Server Actionを使用して、フォームフィールドをサーバー上で検証します。認証プロバイダーがフォームの検証を提供していない場合は、ZodやYupのようなスキーマバリデーションライブラリを使用できます。
Zodを例にとると、適切なエラーメッセージを含むフォームスキーマを定義できます:
- TypeScript
- JavaScript
import { z } from 'zod'
export const SignupFormSchema = z.object({
name: z
.string()
.min(2, { message: 'Name must be at least 2 characters long.' })
.trim(),
email: z.string().email({ message: 'Please enter a valid email.' }).trim(),
password: z
.string()
.min(8, { message: 'Be at least 8 characters long' })
.regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
.regex(/[0-9]/, { message: 'Contain at least one number.' })
.regex(/[^a-zA-Z0-9]/, {
message: 'Contain at least one special character.',
})
.trim(),
})
export type FormState =
| {
errors?: {
name?: string[]
email?: string[]
password?: string[]
}
message?: string
}
| undefined
import { z } from 'zod'
export const SignupFormSchema = z.object({
name: z
.string()
.min(2, { message: 'Name must be at least 2 characters long.' })
.trim(),
email: z.string().email({ message: 'Please enter a valid email.' }).trim(),
password: z
.string()
.min(8, { message: 'Be at least 8 characters long' })
.regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
.regex(/[0-9]/, { message: 'Contain at least one number.' })
.regex(/[^a-zA-Z0-9]/, {
message: 'Contain at least one special character.',
})
.trim(),
})
不要な認証プロバイダーのAPIやデータベースへの呼び出しを防ぐために、定義されたスキーマに一致しないフォームフィールドがある場合、Server Actionで早期にreturn
することができます。
- TypeScript
- JavaScript
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,
}
}
// プロバイダーまたはデータベースを呼び出してユーザーを作成...
}
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,
}
}
// プロバイダーまたはデータベースを呼び出してユーザーを作成...
}
<SignupForm />
内に戻り、フォームが送信されている間に検証エラーを表示するために、ReactのuseFormState
フックを使用できます:
- TypeScript
- JavaScript
highlight={7,15,21,27-39}
'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">Name</label>
<input id="name" name="name" placeholder="Name" />
</div>
{state?.errors?.name && <p>{state.errors.name}</p>}
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" placeholder="Email" />
</div>
{state?.errors?.email && <p>{state.errors.email}</p>}
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" />
</div>
{state?.errors?.password && (
<div>
<p>Password must:</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">
Sign Up
</button>
)
}
highlight={7,15,21,27-39}
'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">Name</label>
<input id="name" name="name" placeholder="John Doe" />
</div>
{state?.errors?.name && <p>{state.errors.name}</p>}
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" placeholder="john@example.com" />
</div>
{state?.errors?.email && <p>{state.errors.email}</p>}
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" />
</div>
{state?.errors?.password && (
<div>
<p>Password must:</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">
Sign Up
</button>
)
}
Good to know:
- これらの例では、Reactの
useFormState
フックを使用しています。これはNext.js App Routerにバンドルされています。React 19を使用している場合は、useActionState
を代わりに使用してください。React docsで詳しい情報を参照してください。- React 19では、
useFormStatus
は追加されたオブジェクトのキーであるデータ、メソッド、およびアクションを含んでいます。React 19を使用していない場合は、pending
キーのみが利用可能です。- React 19では、
useActionState
も返された状態にpending
キーを含んでいます。- データを変更する前に、ユーザーがそのアクションを実行する許可を持っていることを確認してください。Authentication and Authorizationを参照してください。
3.ユーザーを作成またはユーザーの資格情報を確認する
フォームフィールドを検証した後、認証プロバイダーのAPIまたはデータベースを呼び出して、新しいユーザーアカウントを作成するか、ユーザーが存在するか確認できます。
前の例から続けて:
- TypeScript
- JavaScript
export async function signup(state: FormState, formData: FormData) {
// 1. フォームフィールドを検証
// ...
// 2. データベースへの挿入のためのデータを準備
const { name, email, password } = validatedFields.data
// 例:ユーザーのパスワードを保存する前にハッシュ化する
const hashedPassword = await bcrypt.hash(password, 10)
// 3. データベースにユーザーを挿入するまたはAuth LibraryのAPIを呼び出す
const data = await db
.insert(users)
.values({
name,
email,
password: hashedPassword,
})
.returning({ id: users.id })
const user = data[0]
if (!user) {
return {
message: 'An error occurred while creating your account.',
}
}
// TODO:
// 4. ユーザーセッションを作成
// 5. ユーザーをリダイレクト
}
export async function signup(state, formData) {
// 1. フォームフィールドを検証
// ...
// 2. データベースへの挿入のためのデータを準備
const { name, email, password } = validatedFields.data
// 例:ユーザーのパスワードを保存する前にハッシュ化する
const hashedPassword = await bcrypt.hash(password, 10)
// 3. データベースにユーザーを挿入するまたはLibrary APIを呼び出す
const data = await db
.insert(users)
.values({
name,
email,
password: hashedPassword,
})
.returning({ id: users.id })
const user = data[0]
if (!user) {
return {
message: 'An error occurred while creating your account.',
}
}
// TODO:
// 4. ユーザーセッションを作成
// 5. ユーザーをリダイレクト
}
ユーザーアカウントを正常に作成するか、ユーザーの資格情報を確認した後、ユーザーの認証状態を管理するためのセッションを作成できます。セッション管理戦略によっては、セッションをcookie、データベース、またはその両方に保存することができます。セッション管理セクションを続けて学びましょう。
Tips:
- 上記の例は、教育目的で手順をはっきりと示しているため冗長です。これは、セキュアなソリューションを独自に実装するのがすぐに複雑になることを示しています。プロセスを簡素化するために、Auth Libraryを使用することを検討してください。
- ユーザーエクスペリエンスを向上させるために、登録フローの早い段階で重複したメールやユーザー名を確認しようとするかもしれません。例として、ユーザーが入力するユーザー名や入力フィールドから焦点を外す際に確認することができます。これにより、不要なフォーム送信を防ぎ、ユーザーに即時のフィードバックを提供することができます。use-debounceなどのライブラリを使用してリクエストをデバウンスし、チェックの頻度を管理することができます。
セッション管理
セッション管理は、リクエスト間でユーザーの認証された状態を保持することを保証します。セッションやトークンの作成、保存、リフレッシュ、削除を伴います。
セッションには2つのタイプがあります:
- ステートレス:セッションデータ(またはトークン)はブラウザのcookieに保存されます。各リクエストとともにcookieが送信され、サーバー上でセッションが検証されます。この方法はシンプルですが、正しく実装されていないとセキュリティが低くなります。
- データベース:セッションデータはデータベースに保存され、ユーザーのブラウザには暗号化されたセッションIDのみが送信されます。この方法はよりセキュアですが、より複雑でサーバーリソースを多く使用する可能性があります。
Good to know: どちらの方法も、どちらも使用することができますが、iron-sessionやJoseのようなセッション管理ライブラリを使用することをお勧めします。
ステートレスセッション
ステートレスセッションを作成して管理するために、いくつかの手順を以下に示します:
- セッションに署名するための秘密鍵を生成し、環境変数として保存します。
- セッション管理ライブラリを使用して、セッションデータを暗号化/復号化するロジックを書きます。
- Next.jsの
cookies
APIを使用してcookieを管理します。
上記に加えて、更新(またはリフレッシュ)する機能を追加し、ユーザーがアプリケーションに戻った際にセッションを更新することや、ユーザーがログアウトしたときにセッションを削除することを考慮に入れてください。
Good to know: authライブラリがセッション管理を含んでいるかどうかを確認してください。
1. 秘密鍵を生成する
セッションに署名するための秘密鍵を生成する方法はいくつかあります。たとえば、ターミナルでopenssl
コマンドを使用することを選択することができます:
openssl rand -base64 32
このコマンドは、秘密鍵として使用し、環境変数ファイルに保存できる32文字のランダムな文字列を生成します:
SESSION_SECRET=your_secret_key
次に、セッション管理ロジック内でこの鍵を参照できます:
const secretKey = process.env.SESSION_SECRET
2. セッションを暗号化および復号する
次に、セッションを暗号化および復号化するためのセッション管理ライブラリを使用できます。前の例から引き続き、Jose(Edge Runtimeとの互換性あり)およびReactのserver-only
パッケージを使用して、セッション管理ロジックがサーバーでのみ実行されることを保証します。
- TypeScript
- JavaScript
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('Failed to verify session')
}
}
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
const secretKey = process.env.SESSION_SECRET
const encodedKey = new TextEncoder().encode(secretKey)
export async function encrypt(payload) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(encodedKey)
}
export async function decrypt(session) {
try {
const { payload } = await jwtVerify(session, encodedKey, {
algorithms: ['HS256'],
})
return payload
} catch (error) {
console.log('Failed to verify session')
}
}
Tips:
- ペイロードには、今後のリクエストで使用される最小限のユニークなユーザーデータ、たとえばユーザーのIDや役割などを含めるべきです。電話番号やメールアドレス、クレジットカード情報などの個人を特定できる情報や、パスワードなどの機密データを含めないようにしてください。
3. Cookieの設定(推奨オプション)
セッションをcookieに保存するには、Next.jsのcookies
APIを使用します。Cookieはサーバー上で設定され、次の推奨オプションを含む必要があります:
- HttpOnly: クライアントサイドのJavaScriptからCookieをアクセスできないようにします。
- Secure: httpsを使用してCookieを送信します。
- SameSite: クロスサイトリクエストでCookieを送信できるかどうかを指定します。
- Max-AgeまたはExpires: 一定期間後にCookieを削除します。
- Path: CookieのURLパスを定義します。
それぞれのオプションについてはMDNで詳しい情報を参照してください。
- TypeScript
- JavaScript
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 })(await cookies()).set(
'session',
session,
{
httpOnly: true,
secure: true,
expires: expiresAt,
sameSite: 'lax',
path: '/',
}
)
}
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: '/',
})
}
サーバーアクション内でcreateSession()
関数を呼び出し、ユーザーを適切なページにリダイレクトするためにredirect()
APIを使用します:
- TypeScript
- JavaScript
import { createSession } from '@/app/lib/session'
export async function signup(state: FormState, formData: FormData) {
// 前のステップ:
// 1. フォームフィールドを検証
// 2. データベースへの挿入のためのデータを準備
// 3. データベースにユーザーを挿入するまたはLibrary APIを呼び出す
// 現在のステップ:
// 4. ユーザーセッションを作成
await createSession(user.id)
// 5. ユーザーをリダイレクト
redirect('/profile')
}
import { createSession } from '@/app/lib/session'
export async function signup(state, formData) {
// 前のステップ:
// 1. フォームフィールドを検証
// 2. データベースへの挿入のためのデータを準備
// 3. データベースにユーザーを挿入するまたはLibrary APIを呼び出す
// 現在のステップ:
// 4. ユーザーセッションを作成
await createSession(user.id)
// 5. ユーザーをリダイレクト
redirect('/profile')
}
Tips:
- Cookieはサーバー側で設定されるべきであり、クライアント側での改ざんを防ぐためです。
- 🎥 視聴:Next.jsを用いたステートレスセッションと認証についてさらに学びたい場合は → YouTube (11分)。
セッションの更新(またはリフレッシュ)
セッションの有効期限を延長することもできます。これは、ユーザーがアプリケーションに再度アクセスした際にログイン状態を保持するために役立ちます。例えば:
- TypeScript
- JavaScript
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: '/',
})
}
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)(
await cookies()
).set('session', session, {
httpOnly: true,
secure: true,
expires: expires,
sameSite: 'lax',
path: '/',
})
}
Tip: 認証ライブラリがリフレッシュトークンをサポートしているか確認してください。これはユーザーのセッションを延長するために使用できます。
セッションの削除
セッションを削除するには、cookieを削除します:
- TypeScript
- JavaScript
import 'server-only'
import { cookies } from 'next/headers'
export async function deleteSession() {
const cookieStore = await cookies()
cookieStore.delete('session')
}
import 'server-only'
import { cookies } from 'next/headers'
export async function deleteSession() {
const cookieStore = await cookies()
cookieStore.delete('session')
}
次に、アプリケーション内でdeleteSession()
関数を再利用できます。たとえば、ログアウト時に:
- TypeScript
- JavaScript
import { cookies } from 'next/headers'
import { deleteSession } from '@/app/lib/session'
export async function logout() {
deleteSession()
redirect('/login')
}
import { cookies } from 'next/headers'
import { deleteSession } from '@/app/lib/session'
export async function logout() {
deleteSession()
redirect('/login')
}
データベースセッション
データベースセッションを作成して管理するために、次のステップに従う必要があります:
- セッションとデータを保存するためのデータベースにテーブルを作成します(またはAuth Libraryがこれを処理しているか確認します)。
- セッションを挿入、更新、削除する機能を実装します
- セッションIDをユーザーのブラウザに保存する前に暗号化し、データベースとcookieが同期していることを確認します(これはオプションですが、Middlewareでの楽観的な確認のために推奨されます)。
たとえば:
- TypeScript
- JavaScript
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: '/',
})
}
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: '/',
})
}
Tips:
- より高速なデータ取得のために、Vercel Redisのようなデータベースを使用することを検討してください。ただし、セッションデータをプライマリデータベースに保持し、データリクエストを組み合わせてクエリの数を減らすこともできます。
- ユーザーが最後にログインした時間やアクティブデバイスの数を追跡する、またはユーザーがすべてのデバイスからログアウトできるようにするなど、より高度なユースケースにデータベースセッションを使用することを選択するかもしれません。
セッション管理を実装した後、アプリケーション内でユーザーがアクセスおよび実行できる内容を制御するための認証ロジックを追加する必要があります。Authorizationセクションに進んでさらに学びましょう。
認可
ユーザーが認証され、セッションが作成された後、アプリケーション内でユーザーがアクセスおよび実行できる内容を制御するための認証を実装できます。
認証チェックには2つの主なタイプがあります:
- 楽観的: Cookieに保存されたセッションデータを使用して、ユーザーがルートにアクセスしたり、アクションを実行する権限があるかどうかをチェックします。これらのチェックは、UI要素を表示/非表示にする、または権限や役割に基づいてユーザーをリダイレクトするなど、クイック操作に役立ちます。
- セキュア: データベースに保存されたセッションデータを使用して、ユーザーがルートにアクセスしたり、アクションを実行する権限があるかどうかをチェックします。これらのチェックはよりセキュアであり、機密データまたはアクションにアクセスする必要がある操作に使用されます。
両方のケースでお勧めすることは:
- 認証ロジックの集中化のためにデータアクセス層(DAL)を作成する。
- 必要なデータのみを返すためにData Transfer Objects(DTO)を使用する。
- Middlewareを使用して楽観的なチェックを行うことをオプションで行う。
ミドルウェアによる楽観的なチェック(オプション)
いくつかの場合、Middlewareを使用してでユーザーの権限に基づくリダイレクトを行いたい場合があります:
- 楽観的なチェックを行うため。Middlewareはすべてのルートで実行されるため、リダイレクトロジックの集中化と未承認ユーザーの事前フィルタリングに便利な方法です。
- ユーザー間でデータを共有する静的ルート(例:ペイウォールの背後にあるコンテンツ)を保護するため。
ただし、Middlewareがすべてのルートで、prefetchedされたルートを含むため、ミドルウェアではcookieからセッションを読み取るだけ(楽観的チェック)にし、パフォーマンス問題を防ぐためにデータベースチェックを避けることが重要です。
例として:
- TypeScript
- JavaScript
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. Cookieからセッションを復号
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$).*)'],
}
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. Cookieからセッションを復号
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は初期チェックに役立ちますが、それをデータ保護における唯一の防御手段にするべきではありません。ほとんどのセキュリティチェックはデータソースに可能な限り近い箇所で実行されるべきです。詳細については、Data Access Layerを参照してください。
Tips:
- ミドルウェアでは、
req.cookies.get('session').value
を使用してCookieを読み取ることもできます。- MiddlewareはEdge Runtimeを使用します。認証ライブラリとセッション管理ライブラリが互換性があるか確認してください。
- Middleware内の
matcher
プロパティを使用して、Middlewareが実行されるルートを指定できます。ただし、認証の場合、Middlewareはすべてのルートで実行されることが推奨されます。
データアクセス層(DAL)の作成
データ要求と認証ロジックを集中化するために、DALの作成をお勧めします。
DALには、アプリケーションとやり取りする際にユーザーのセッションを確認する関数を含めるべきです。少なくとも、関数はセッションが有効であるかどうかを確認し、ユーザー情報をリダイレクトまたは返す必要があります。
たとえば、DALのための別のファイルを作成し、verifySession()
関数を含めます。次に、Reactのcache APIを使用してReactのレンダリングパス中に関数の戻り値をメモ化します:
- TypeScript
- JavaScript
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 }
})
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 }
})
次に、データリクエスト、サーバーアクション、ルートハンドラーでverifySession()
関数を呼び出すことができます:
- TypeScript
- JavaScript
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('Failed to fetch user')
return null
}
})
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('Failed to fetch user')
return null
}
})
Tip:
- DALはリクエスト時にフェッチされるデータを保護するために使用される可能性があります。ただし、ユーザー間でデータを共有する静的ルートに対しては、データはビルド時にフェッチされ、リクエスト時には取得されません。静的ルートを保護するにはMiddlewareを使用してください。
- セキュアなチェックのために、データベースとセッションIDを比較することで、セッションが有効であるか示すことができます。レンダリングパス中にデータベースへの不要な重複したリクエストを避けるために、Reactのcache関数を使用してください。
- 関連するデータ要求をセッション確認前に実行するJavaScriptクラスを作成することを検討するかもしれません。
Data Transfer Objects(DTO)の使用
データを取得する際には、アプリケーションで使用される必要なデータのみを返し、オブジェクト全体を返さないことをお勧めします。たとえば、ユーザーデータをフェッチしている場合、ユーザーのIDと名前だけを返すかもしれません、そしてユーザーオブジェクト全体を渡すことは好ましくありません。これには、パスワード、電話番号などが含まれる可能性があります。
ただし、返されるデータの構造を制御できない場合や、開発チームでオブジェクト全体がクライアントに渡されることを避けたい場合は、どのフィールドがクライアントに公開するのが安全かを決定する戦略を使用することができます。
- TypeScript
- JavaScript
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,
}
}
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を使用することで、すべてのデータ要求がセキュアで一貫性を持ち、アプリケーションがスケールするにつれて維持、監査、およびデバッグが容易になります。
Good to know:
- DTOを定義する方法はいくつかあります。たとえば、
toJSON()
を使用する、個々の関数を使用する、またはJSクラスを使用するなどです。これらはJavaScriptのパターンであり、ReactまたはNext.jsの機能ではないため、アプリケーションの最適なパターンを見つけるためにリサーチをすることをお勧めします。- Next.jsの記事でのセキュリティについてのセキュリティベストプラクティスについて学びましょう。
サーバーコンポーネント
サーバーコンポーネントにおけるAuthチェックは、役割ベースのアクセスに役立ちます。たとえば、ユーザーの役割に基づいてコンポーネントを条件付きでレンダリングします:
- TypeScript
- JavaScript
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')
}
}
import { verifySession } from '@/app/lib/dal'
export default function Dashboard() {
const session = await verifySession()
const userRole = session.role // セッションオブジェクトの一部である「role」を仮定しています
if (userRole === 'admin') {
return <AdminDashboard />
} else if (userRole === 'user') {
return <UserDashboard />
} else {
redirect('/login')
}
}
この例では、'admin'、'user'、および未認証の役割を確認するためにDALのverifySession()
関数を使用しています。このパターンは、各ユーザーが自分の役割に応じたコンポーネントと対話することを保証します。
Layoutsとauthチェック
Partial Renderingにより、Layoutsでチェックを行う際は注意が必要です。これらはナビゲーション時に再レンダリングされません。つまり、ユーザーのセッションはルート変更時にすべてチェックされないということです。
代わりに、データソースに近い場所や条件付きでレンダリングされるコンポーネントの近くでチェックを行う必要があります。
たとえば、ユーザーデータをフェッチし、ナビゲーションでユーザー画像を表示する共有レイアウトを考えます。レイアウト内で認証チェックを行う代わりに、レイアウトでユーザーデータ(getUser()
)をフェッチしDALで認証チェックを行います。
これにより、アプリケーション内でgetUser()
が呼び出される場所で認証チェックが行われ、開発者がデータへのアクセスが許可されていることを忘れるのを防ぎます。
- TypeScript
- JavaScript
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getUser();
return (
// ...
)
}
export default async function Layout({ children }) {
const user = await getUser();
return (
// ...
)
}
- TypeScript
- JavaScript
export const getUser = cache(async () => {
const session = await verifySession()
if (!session) return null
// セッションからユーザーIDを取得しデータをフェッチ
})
export const getUser = cache(async () => {
const session = await verifySession()
if (!session) return null
// セッションからユーザーIDを取得しデータをフェッチ
})
Good to know:
- SPAsで一般的なパターンは、ユーザーが未認証の場合、レイアウトやトップレベルコンポーネント内で
return null
を行うことです。これをすることはおすすめできません。なぜなら、Next.jsアプリケーションには複数のエントリポイントがあるため、ネストしたルートセグメントやサーバーアクションをアクセスするのを防ぐことはできないためです。
サーバーアクション
サーバーアクションを公開APIエンドポイントと同じセキュリティ考慮で扱い、ユーザーがその動作を行う権限を持っているか確認します。
以下の例では、アクションを続行する前にユーザーの役割を確認しています:
- TypeScript
- JavaScript
'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
}
// 権限のあるユーザーのためにアクションを続行
}
'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
}
// 権限のあるユーザーのためにアクションを続行
}
ルートハンドラー
ルートハンドラーを公開APIエンドポイントと同じセキュリティ考慮で扱い、ユーザーがルートハンドラーにアクセスする権限を持っているか確認します。
たとえば:
- TypeScript
- JavaScript
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 })
}
// 権限のあるユーザーのために継続
}
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段階のセキュリティチェックを持つルートハンドラーを示しています。まず、アクティブなセッションを確認し、次にログインしたユーザーが「admin」であるかを確認します。
コンテキストプロバイダー
認証にコンテキストプロバイダーを使用することは、interleavingにより機能します。ただし、React context
はサーバーコンポーネントでサポートされていないため、クライアントコンポーネントのみに適用可能です。
これは機能しますが、子サーバーコンポーネントは最初にサーバー上でレンダリングされ、コンテキストプロバイダーのセッションデータにアクセスできません:
- TypeScript
import { ContextProvider } from 'auth-lib'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<ContextProvider>{children}</ContextProvider>
</body>
</html>
)
}
"use client";
import { useSession } from "auth-lib";
export default function Profile() {
const { userId } = useSession();
const { data } = useSWR(`/api/user/${userId}`, fetcher)
return (
// ...
);
}
"use client";
import { useSession } from "auth-lib";
export default function Profile() {
const { userId } = useSession();
const { data } = useSWR(`/api/user/${userId}`, fetcher)
return (
// ...
);
}
クライアントコンポーネントでセッションデータが必要な場合(たとえば、クライアント側のデータフェッチに)、クライアントに機密のセッションデータが露出しないように、ReactのtaintUniqueValue
APIを使用してください。
リソース
Next.jsでの認証について学んだ後、セキュアな認証とセッション管理を実装するのに役立つNext.js互換のライブラリとリソースを以下に示します:
認証ライブラリ
セッション管理ライブラリ
詳細な読み物
認証とセキュリティについて学び続けるために、以下のリソースを確認してください: