【JWT入門】Next.jsで認証機能を実装してみた

## はじめに
今回は、初めてJWTを使った認証機能(ログイン・新規登録機能)を実装してみたので、備忘録として残しておきます。APIもNext.js内で完結しており、バックエンド用の別言語は使用していません。


## 前提条件
この記事では、PrismaとNext.jsの接続が完了している状態から始めます。データベースはPostgreSQLを使用していますので、Prismaとの接続が完了していることを前提にお読みください。

## 完成イメージ
<iframe width="560" height="315" src="https://www.youtube.com/embed/EuJVzRQxXoQ?si=-tTUK6yh5Qas8VQi" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

## 目次
①フロント側の実装
②バックエンド側の実装

## フロント側の実装
まずは、全体のディレクトリ構成です。

```

├── app
│   ├── api
│   │   └── auth
│   │       ├── signin
│   │       │   └── route.ts
│   │       └── signup
│   │           └── route.ts
│   ├── globals.css
│   ├── layout.tsx
│   ├── page.tsx
│   └── user
│       ├── dashboard
│       │   └── page.tsx
│       ├── sign_in
│       │   └── page.tsx
│       └── sign_up
│           └── page.tsx
├── components
│   └── buttons
│       ├── PrimaryButton.tsx
│       └── SkeltonButton.tsx
├── types
│   └── user.ts
└── utils
    ├── api
    │   └── auth.ts
    └── toast.ts


```

以下は、page.tsx のコードです。
```typescript:src/app/page.tsx
"use client";
import { PrimaryButton } from "@/components/buttons/PrimaryButton";
import { SkeltonButton } from "@/components/buttons/SkeltonButton";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";

export default function Home() {
  const router = useRouter();
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  useEffect(() => {
    const token = localStorage.getItem("access_token");
    setIsLoggedIn(!!token);
  }, []);

  const onClickLogout = () => {
    localStorage.removeItem("access_token");
    console.log("ログアウトしました");
    setIsLoggedIn(false);
    router.push("/");
  };

  return (
    <>
      <div className="flex flex-col items-center gap-10 mt-12">
        <p className="text-3xl font-semibold text-[rgba(0,164,150,1)]">認証アプリ</p>
        <div className="flex justify-center items-center gap-5">
          {!isLoggedIn && (
            <>
              <SkeltonButton href="/user/sign_in">ログイン</SkeltonButton>
              <SkeltonButton href="/user/sign_up">新規登録</SkeltonButton>
            </>
          )}
          {isLoggedIn && (
            <>
              <PrimaryButton onClick={onClickLogout}>ログアウト</PrimaryButton>
              <SkeltonButton href="/user/dashboard">DashBoard</SkeltonButton>
            </>
          )}
        </div>
      </div>
    </>
  );
}
```
サインアップフォームは次のようになります。
Next.jsバージョン14からpageのファイル名はpage.tsxにしないとエラーが出るので気をつけてください。

```typescript:src/app/user/sign_up/page.tsx
"use client";
import { PrimaryButton } from "@/components/buttons/PrimaryButton";
import { SkeltonButton } from "@/components/buttons/SkeltonButton";
import { signUp } from "@/utils/api/auth";
import { errorToast, successToast } from "@/utils/toast";
import { useRouter } from "next/navigation";
import { useState } from "react";

export default function Sign_up() {
  const router = useRouter();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const onChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => setEmail(event.target.value);
  const onChangePassword = (event: React.ChangeEvent<HTMLInputElement>) => setPassword(event.target.value);

  const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    submit();
  };

  const submit = async () => {
    const res = await signUp({ email, password });
    const token = res?.token;

    if (token) {
      localStorage.setItem("access_token", token);
      successToast("新規登録に成功しました。");
      router.push("/user/dashboard");
    } else {
      errorToast("そのメールアドレスは既に使用されています。");
    }
  };

  return (
    <div className="flex flex-col items-center mt-12">
      <div className="mx-auto w-[300px] text-center">
        <div className="text-3xl font-semibold text-[rgba(0,164,150,1)]">サインアップ画面</div>
        <form onSubmit={onSubmit} className="mt-8 w-full flex flex-col items-center mb-5 gap-4">
          <input onChange={onChangeEmail} type="email" placeholder="メールアドレス" className="mb-3 rounded-[4px]" />
          <input onChange={onChangePassword} type="password" placeholder="パスワード" className="mb-3 rounded-[4px]" />
          <PrimaryButton disabled={!email || !password}>新規登録</PrimaryButton>
        </form>
      </div>
      <SkeltonButton href="/">ホームへ</SkeltonButton>
    </div>
  );
}
```

ログインフォームは次のようになります。
```typescript:src/app/user/sign_in/page.tsx
"use client";
import { PrimaryButton } from "@/components/buttons/PrimaryButton";
import { SkeltonButton } from "@/components/buttons/SkeltonButton";
import { signIn } from "@/utils/api/auth";
import { errorToast, successToast } from "@/utils/toast";
import { useRouter } from "next/navigation";
import { useState } from "react";

export default function Sign_in() {
  const router = useRouter();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const onChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => setEmail(event.target.value);
  const onChangePassword = (event: React.ChangeEvent<HTMLInputElement>) => setPassword(event.target.value);

  const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    submit();
  };

  const submit = async () => {
    const res = await signIn({ email, password });
    const token = res?.token;

    if (token) {
      localStorage.setItem("access_token", token);
      successToast("ログインに成功しました。");
      router.push("/user/dashboard");
    } else {
      errorToast("メールアドレスまたはパスワードが間違っています。");
    }
  };

  return (
    <div className="flex flex-col items-center mt-12">
      <div className="mx-auto w-[300px] text-center">
        <div className="text-3xl font-semibold text-[rgba(0,164,150,1)]">ログイン画面</div>
        <form onSubmit={onSubmit} className="mt-8 w-full flex flex-col items-center mb-5 gap-4">
          <input onChange={onChangeEmail} type="email" placeholder="メールアドレス" className="mb-3 rounded-[4px]" />
          <input onChange={onChangePassword} type="password" placeholder="パスワード" className="mb-3 rounded-[4px]" />
          <PrimaryButton disabled={!email || !password}>ログイン</PrimaryButton>
        </form>
      </div>
      <SkeltonButton href="/">ホームへ</SkeltonButton>
    </div>
  );
}
```
以下のコードは、ユーザーが認証後にアクセスできるDashboardページの実装です。
```typescript:src/app/user/dashboard/page.tsx
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { jwtDecode } from "jwt-decode";
import { SkeltonButton } from "@/components/buttons/SkeltonButton";

type DecodedUser = {
  email: string;
  iat: number;
  exp: number;
};

export default function dashboard() {
  const router = useRouter();
  const [email, setEmail] = useState<string>();

  useEffect(() => {
    const token = localStorage.getItem("access_token");
    if (!token) {
      router.push("/");
    } else if (token.split(".").length === 3) {
      const decodedUser = jwtDecode<DecodedUser>(token);
      setEmail(decodedUser.email);
    }
  }, [router]);

  return (
    <div className="flex flex-col items-center mt-10 gap-5">
      <h1 className="text-3xl font-semibold text-[rgba(0,164,150,1)]">Dashboard</h1>
      <div>
        <p>メールアドレス:{email}</p>
      </div>
      <SkeltonButton href="/">ホームへ</SkeltonButton>
    </div>
  );
}


```

次に、サインアップとサインインのためのAPIを呼び出すコードです。もし、コンポーネントや型関連のエラーが発生する場合は、それぞれ必要なファイルを作成して対処してください。
 
 ```typescript:app/utils/api/auth.ts
 export const signUp = async (body: { email: string; password: string }) => {
  const url = "http://localhost:3000/api/auth/signup";

  const data = await fetch(url, {
    method: "POST",
    body: JSON.stringify(body),
    headers: {
      "Content-Type": "application/json",
    },
  });

  return data.json();
};

export const signIn = async (body: { email: string; password: string }) => {
  const url = "http://localhost:3000/api/auth/signin";

  const data = await fetch(url, {
    method: "POST",
    body: JSON.stringify(body),
    headers: {
      "Content-Type": "application/json",
    },
  });

  return data.json();
};

 ```

ログイン成功時やエラー時には、react-toastify ライブラリを用いて通知を表示しています。以下のコードを参考にしてください。ライブラリがインストールされていない場合は、npm install react-toastify を実行してインストールしてください。

 ```typescript:app/utils/toast.ts
 import { toast } from 'react-toastify';

export const errorToast = (message: string) => {
  return toast.error(message, {
    autoClose: 1500,
    closeButton: false,
    hideProgressBar: true,
    pauseOnHover: false,
  });
};

export const successToast = (message: string) => {
  return toast.success(message, {
    autoClose: 1500,
    closeButton: false,
    hideProgressBar: true,
    pauseOnHover: false,
  });
};

 ```

 これでフロント側の実装が完了です。
 
 ## バックエンド側の実装
 次はバックエンド側の実装になります。
 src/app/api/auth 内に signup および signin ディレクトリを作成し、それぞれに route.ts ファイルを配置します。ファイルの実装は以下の通りです。apiのファイル名は必ずroute.tsにしてください。ここで自分も沼りました。
 ```typescript:src/app/api/auth/signup/route.ts
 import { NextResponse } from "next/server";
import prisma from "../../../../../prisma/prisma";
import jwt from "jsonwebtoken";

const JWT_SECRET = process.env.JWT_SECRET;

export async function POST(req: Request) {
  const { email, password } = await req.json();
  try {
    const user = await prisma.user.create({
      data: {
        email,
        password,
      },
    });
    if (JWT_SECRET) {
      const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: "1h" });
      return NextResponse.json({ message: "サインアップ成功", user, token }, { status: 200 });
    }
    return NextResponse.json(user);
  } catch (e) {
    return NextResponse.json(e);
  }
}
 ```
 次はログインのapiになります。
 ```typescript:src/app/api/auth/signup/route.ts
 import { NextRequest, NextResponse } from "next/server";
import prisma from "../../../../../prisma/prisma";
import jwt from "jsonwebtoken";

const JWT_SECRET = process.env.JWT_SECRET;

export async function POST(req: NextRequest) {
  const { email, password } = await req.json();

  try {
    const user = await prisma.user.findUnique({
      where: { email },
    });
    if (!user) {
      return NextResponse.json({ error: "ユーザーが見つかりません" }, { status: 404 });
    }
    if (user.password !== password) {
      return NextResponse.json({ error: "パスワードが間違っています" }, { status: 401 });
    }
    if (JWT_SECRET) {
      const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: "1h" });
      return NextResponse.json({ message: "ログイン成功", user, token }, { status: 200 });
    }
  } catch (e) {
    console.error("ログインエラー:", e);
    return NextResponse.json({ error: "ログイン処理中にエラーが発生しました" }, { status: 500 });
  }
}
 ```

認証に必要なJWTのシークレットキーを設定します。まず、ターミナルで以下のコマンドを実行して、ランダムな32桁の文字列を生成してください。

```
openssl rand -hex 16
```
生成された文字列を、.env ファイルに以下のように貼り付けて設定します。
```
JWT_SECRET="32桁文字列"
```


これで、JWTのシークレットキーが設定され、アプリケーションが問題なく動作するはずです。
 雑記事でしたがここまで見てくださった方ありがとうございました(^^)