Skip to content

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でアプリケーション登録が必要です。

  1. Google Cloud Platformにアクセス
  2. プロジェクトを作成またはプロジェクトを選択
  3. 「認証情報を作成」→「OAuthクライアントID」を選択
  4. アプリケーションタイプで「ウェブアプリケーション」を選択
  5. 名前を入力(例:「アンケートアプリ」)
  6. 「認証済みリダイレクトURI」に http://localhost:3000/api/auth/callback/google を追加
  7. 「作成」ボタンをクリック
  8. 表示される「クライアントID」と「クライアントシークレット」をメモ(他の人に見られないようにしてください)

Google Cloud Platform

環境変数の設定#

プロジェクトのルートディレクトリに.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>
  );
}

動作確認#

開発サーバーを起動して、以下の動作を確認してみましょう:

  1. ホームページからログインボタンを押して、Googleアカウントでログイン
  2. ログインに成功したら、アンケート一覧ページにアクセスできるようになる(管理者メールアドレスの場合)
  3. 管理者以外のアカウントでログインした場合は、アンケート一覧ページにアクセスできない

ログイン画面

まとめ#

NextAuth.jsを使うことで、従来のReact+Nestアプリケーションよりも簡単かつ安全に認証機能を実装できました。主な利点は:

  1. 簡単な設定: わずかなコードでOAuth認証を実装できる
  2. セキュリティ: セキュリティのベストプラクティスが組み込まれている
  3. 複数の認証プロバイダー: Google以外にもGitHub、Apple、Twitterなど多数のプロバイダーをサポート
  4. セッション管理: セッション管理が自動的に行われる
  5. TypeScript対応: 型安全なコードが書ける
  6. ミドルウェア対応: Next.jsのミドルウェアと連携したページレベルの保護が簡単

これで、Next.jsとPrismaを使ったアンケートアプリケーションが完成しました。このアプリケーションは、従来のReact+Nest+TypeORMの構成よりもシンプルかつ機能的で、開発効率も向上しています。