Next.jsでのOAuth認証#
Next.jsとPrismaでWebアプリケーションを作る最終回です。 最初からやりたい場合はナビゲーションから初回を開いてください。
今回は、Googleアカウントでログインできるようにして、ログインした管理者だけがアンケートの一覧を表示できるようにします。認証には、Next.jsでよく使われているNextAuth.js(Auth.js)というライブラリを利用します。
NextAuth.jsのセットアップ#
まず、NextAuth.jsをインストールします:
npm install next-auth
Google OAuthの設定#
Google Cloud Platformでアプリケーション登録が必要です。
- Google Cloud Platformにアクセス
- プロジェクトを作成またはプロジェクトを選択
- 「認証情報を作成」→「OAuthクライアントID」を選択
- アプリケーションタイプで「ウェブアプリケーション」を選択
- 名前を入力(例:「アンケートアプリ」)
- 「認証済みリダイレクトURI」に
http://localhost:3000/api/auth/callback/google
を追加 - 「作成」ボタンをクリック
- 表示される「クライアントID」と「クライアントシークレット」をメモ(他の人に見られないようにしてください)
環境変数の設定#
プロジェクトのルートディレクトリに.env.local
ファイルを作成し、以下の内容を追加します:
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=ここには長くて複雑なランダムな文字列を入れる(例:openssl rand -base64 32で生成可能)
GOOGLE_CLIENT_ID=GoogleのクライアントID
GOOGLE_CLIENT_SECRET=Googleのクライアントシークレット
ADMIN_EMAIL=管理者のメールアドレス(アンケート一覧を見られる人)
NextAuth.jsの基本設定#
app/api/auth/[...nextauth]/route.ts
ファイルを作成してNextAuth.jsのAPIルートを設定します:
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
export const authOptions = {
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
}),
],
callbacks: {
async jwt({ token, account }) {
// アクセストークンを保存
if (account) {
token.accessToken = account.access_token;
}
return token;
},
async session({ session, token }) {
// セッションにアクセストークンを追加
session.accessToken = token.accessToken;
return session;
},
async signIn({ user }) {
// 特定のメールアドレスだけを許可する場合はここでチェック
// この例では環境変数で設定した管理者メールアドレス以外の全てのログインを許可
return true;
},
},
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
セッション型の拡張#
TypeScriptの型定義を拡張して、セッションにアクセストークンを含めるようにします。
types/next-auth.d.ts
というファイルを作成します:
import 'next-auth';
declare module 'next-auth' {
interface Session {
accessToken?: string;
user: {
name?: string | null;
email?: string | null;
image?: string | null;
};
}
}
セッションプロバイダーの設定#
アプリケーション全体でセッションを利用できるようにします。app/providers.tsx
ファイルを作成します:
'use client';
import { SessionProvider } from 'next-auth/react';
import { ReactNode } from 'react';
export function Providers({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
次に、このプロバイダーをルートレイアウトに追加します。app/layout.tsx
を編集します:
import './globals.css';
import type { Metadata } from 'next';
import Navbar from '@/components/Navbar';
import { Providers } from './providers';
export const metadata: Metadata = {
title: 'アンケートアプリ',
description: 'Next.jsとPrismaで作成したアンケートアプリ',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>
<Providers>
<Navbar />
<main className="py-8">
{children}
</main>
</Providers>
</body>
</html>
);
}
ログイン/ログアウトコンポーネントの作成#
ナビゲーションバーにログイン/ログアウト機能を追加します。src/components/Navbar.tsx
を編集します:
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useSession, signIn, signOut } from 'next-auth/react';
export default function Navbar() {
const pathname = usePathname();
const { data: session, status } = useSession();
const loading = status === 'loading';
return (
<nav className="bg-blue-600 text-white p-4">
<div className="container mx-auto flex justify-between items-center">
<Link href="/" className="text-xl font-bold">
アンケートアプリ
</Link>
<div className="flex items-center">
<ul className="flex space-x-4 mr-4">
<li>
<Link
href="/"
className={pathname === '/' ? 'underline font-bold' : 'hover:underline'}
>
ホーム
</Link>
</li>
<li>
<Link
href="/enquete/new"
className={pathname === '/enquete/new' ? 'underline font-bold' : 'hover:underline'}
>
新規作成
</Link>
</li>
{session && (
<li>
<Link
href="/enquete/list"
className={pathname === '/enquete/list' ? 'underline font-bold' : 'hover:underline'}
>
一覧表示
</Link>
</li>
)}
</ul>
{!loading && !session && (
<button
onClick={() => signIn('google')}
className="bg-white text-blue-600 px-3 py-1 rounded hover:bg-gray-100"
>
ログイン
</button>
)}
{session && (
<div className="flex items-center">
{session.user?.image && (
<img
src={session.user.image}
alt={session.user.name || ''}
className="w-8 h-8 rounded-full mr-2"
/>
)}
<span className="mr-2">{session.user?.name}</span>
<button
onClick={() => signOut()}
className="bg-white text-blue-600 px-3 py-1 rounded hover:bg-gray-100"
>
ログアウト
</button>
</div>
)}
</div>
</div>
</nav>
);
}
アンケート一覧ページの保護#
アンケート一覧ページにアクセス制限をかけます。まず、アクセスチェックのためのAPIルートを作成します。
app/api/enquetes/route.ts
を編集します:
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getToken } from 'next-auth/jwt';
// POSTリクエスト処理(アンケート投稿用)
export async function POST(request: Request) {
try {
const body = await request.json();
// バリデーション
if (!body.point || !body.message || !body.mail) {
return NextResponse.json(
{ error: '必須フィールドが不足しています' },
{ status: 400 }
);
}
// データベースに保存
const enquete = await prisma.enquete.create({
data: {
point: body.point,
message: body.message,
mail: body.mail,
},
});
return NextResponse.json(enquete);
} catch (error) {
console.error('アンケート保存エラー:', error);
return NextResponse.json(
{ error: 'アンケートの保存に失敗しました' },
{ status: 500 }
);
}
}
// GETリクエスト処理(アンケート一覧取得用、認証が必要)
export async function GET(request: NextRequest) {
try {
// セッショントークンを取得
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
});
// 未ログインの場合はエラー
if (!token) {
return NextResponse.json(
{ error: '認証が必要です' },
{ status: 401 }
);
}
// 管理者のみアクセス可能
const adminEmail = process.env.ADMIN_EMAIL;
if (token.email !== adminEmail) {
return NextResponse.json(
{ error: 'アクセス権限がありません' },
{ status: 403 }
);
}
// 全てのアンケートを取得
const enquetes = await prisma.enquete.findMany({
orderBy: {
id: 'desc',
},
});
return NextResponse.json(enquetes);
} catch (error) {
console.error('アンケート取得エラー:', error);
return NextResponse.json(
{ error: 'アンケートの取得に失敗しました' },
{ status: 500 }
);
}
}
次に、アンケート一覧ページを更新して、認証状態を確認し、管理者のみにアクセスを許可します。
app/enquete/list/page.tsx
を編集します:
'use client';
import { useSession } from 'next-auth/react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
export default function EnqueteListPage() {
const { data: session, status } = useSession();
const [enquetes, setEnquetes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchEnquetes() {
try {
const response = await fetch('/api/enquetes');
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'アンケートの取得に失敗しました');
}
const data = await response.json();
setEnquetes(data);
} catch (error) {
setError(error instanceof Error ? error.message : '不明なエラーが発生しました');
} finally {
setLoading(false);
}
}
if (session) {
fetchEnquetes();
} else if (status !== 'loading') {
setLoading(false);
}
}, [session, status]);
if (status === 'loading') {
return (
<div className="container mx-auto p-4 text-center">
<p>ログイン状態を確認中...</p>
</div>
);
}
if (!session) {
return (
<div className="container mx-auto p-4">
<div className="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 mb-4">
<p>このページを閲覧するにはログインが必要です。</p>
</div>
<Link
href="/"
className="text-blue-500 hover:underline"
>
ホームに戻る
</Link>
</div>
);
}
if (error) {
return (
<div className="container mx-auto p-4">
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-4">
<p>エラー: {error}</p>
<p>管理者権限がない可能性があります。</p>
</div>
<Link
href="/"
className="text-blue-500 hover:underline"
>
ホームに戻る
</Link>
</div>
);
}
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">アンケート一覧</h1>
{loading ? (
<p>アンケートを読み込み中...</p>
) : enquetes.length === 0 ? (
<p>アンケートがまだありません。</p>
) : (
<div className="bg-white shadow-md rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">評価</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">メッセージ</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">メールアドレス</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{enquetes.map((enquete) => (
<tr key={enquete.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{enquete.id}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{enquete.point}点</td>
<td className="px-6 py-4 text-sm text-gray-500">{enquete.message}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{enquete.mail}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="mt-6">
<Link
href="/enquete/new"
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
新規アンケート作成
</Link>
</div>
</div>
);
}
ミドルウェアによるページレベルの保護#
特定のページやルートを包括的に保護するために、Next.jsのミドルウェアを使用できます。プロジェクトのルートディレクトリにmiddleware.ts
というファイルを作成します:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
// 保護したいパス
const protectedPaths = ['/enquete/list'];
export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
// 保護されたパスかチェック
if (protectedPaths.some(prefix => path.startsWith(prefix))) {
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
});
// ログインしていない場合はログインページへリダイレクト
if (!token) {
const url = new URL('/api/auth/signin', request.url);
url.searchParams.set('callbackUrl', encodeURI(request.url));
return NextResponse.redirect(url);
}
// 管理者のみアクセス可能なパスの場合
if (path.startsWith('/enquete/list')) {
const adminEmail = process.env.ADMIN_EMAIL;
if (token.email !== adminEmail) {
// 権限がない場合はホームページへリダイレクト
return NextResponse.redirect(new URL('/', request.url));
}
}
}
return NextResponse.next();
}
export const config = {
// ミドルウェアを適用するパス
matcher: ['/enquete/:path*'],
};
サインインページのカスタマイズ#
NextAuth.jsのデフォルトのサインインページをカスタマイズするには、app/auth/signin/page.tsx
ファイルを作成します:
'use client';
import { signIn } from 'next-auth/react';
import { useSearchParams } from 'next/navigation';
export default function SignIn() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl') || '/';
return (
<div className="flex items-center justify-center min-h-[70vh]">
<div className="w-full max-w-md p-8 space-y-8 bg-white rounded-lg shadow-md">
<div className="text-center">
<h1 className="text-3xl font-extrabold text-gray-900">アンケートアプリにログイン</h1>
<p className="mt-2 text-gray-600">
Google アカウントでログインしてアンケートの管理を行うことができます
</p>
</div>
<div className="mt-8 space-y-6">
<button
onClick={() => signIn('google', { callbackUrl })}
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<span className="absolute left-0 inset-y-0 flex items-center pl-3">
<svg className="h-5 w-5 text-blue-500 group-hover:text-blue-400" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.477 0 10s4.477 10 10 10 10-4.477 10-10S15.523 0 10 0zm5.594 10.114l-1.884.325C13.247 12.667 11.708 14 10 14c-2.206 0-4-1.794-4-4s1.794-4 4-4c1.823 0 3.353 1.209 3.845 2.866l1.88-.324C15.065 5.75 12.772 4 10 4c-3.314 0-6 2.686-6 6s2.686 6 6 6c2.772 0 5.065-1.75 5.721-4.543l-.127-.343z" clipRule="evenodd" />
</svg>
</span>
Google でログイン
</button>
</div>
</div>
</div>
);
}
動作確認#
開発サーバーを起動して、以下の動作を確認してみましょう:
- ホームページからログインボタンを押して、Googleアカウントでログイン
- ログインに成功したら、アンケート一覧ページにアクセスできるようになる(管理者メールアドレスの場合)
- 管理者以外のアカウントでログインした場合は、アンケート一覧ページにアクセスできない
まとめ#
NextAuth.jsを使うことで、従来のReact+Nestアプリケーションよりも簡単かつ安全に認証機能を実装できました。主な利点は:
- 簡単な設定: わずかなコードでOAuth認証を実装できる
- セキュリティ: セキュリティのベストプラクティスが組み込まれている
- 複数の認証プロバイダー: Google以外にもGitHub、Apple、Twitterなど多数のプロバイダーをサポート
- セッション管理: セッション管理が自動的に行われる
- TypeScript対応: 型安全なコードが書ける
- ミドルウェア対応: Next.jsのミドルウェアと連携したページレベルの保護が簡単
これで、Next.jsとPrismaを使ったアンケートアプリケーションが完成しました。このアプリケーションは、従来のReact+Nest+TypeORMの構成よりもシンプルかつ機能的で、開発効率も向上しています。