📝

Next.jsでログインの際にJWTを使ってみる

2024/01/31に公開

以前、prismaとsupabaseを利用した記事を書きました。
https://zenn.dev/kiriyama/articles/89bac9034bbe7a

またNext.js14になってからAPI側の書き方やJSON Web Token(JWT)も利用方法が変わったっぽいのでせっかくなのでログイン時にトークンの発行・保存をやってみる。

prismaやsupabaseの設定は以前書いた記事と同じなので省略。

https://zenn.dev/kiriyama/articles/89bac9034bbe7a

また今回はあくまでjsonwebtokenを使いたいので、ユーザーの新規登録の処理は省く。

JWT とは?

JSON Web Token(JWT) は、JSON ベースのデータを暗号化してつくられる文字列で、
認証や認可のための仕組みとして Web アプリケーションなどで用いられる技術で以下引用です。

通常のトークン形式の認証では、トークンの正当性を確認するためにサーバへの問い合わせが必要です。JWT では 公開鍵を利用してクライアント側で トークンの正当性を確認できるという特徴があります。

https://zenn.dev/mikakane/articles/tutorial_for_jwt

構成

新規登録とログイン、更新なども記事としてい追加するかもしれませんので一旦すべてのディレクトリ構成を載せておきます。

ディレクトリ構成

app/
├── api/user
│       └── login
│           └── route.ts
├── user/
│       └── login
│           └── page.tsx
├── utils
│     └── useAuth.ts

ユーザーの情報のスキーマ

prismaをNext.jsにインストール際にprismaのフォルダが作成されて、shema.prismaというファイルがある。そこにマイグレーションしてsupabaseにテーブルを作成する際のスキーマの設定を以下のようにした。

model User {
  id Int @id @default(autoincrement())
  email String @unique
  name String?
  password String
}

IDはデフォルトで自動的に付与される。あとはユーザー名とメールアドレスとパスワードを保存することにした。

JsonWebTokenをインストール

まずはJsonWebTokenのパッケージをインストールする。

npm install jose

トークンをチェックするには

実際にトークンの有効かどうか?どんなデータを保存しているかを確認するには以下のサイトから確認ができる。

https://jwt.io/

ログインの処理

まずはログイン側のコードを掲載。

フロントエンド側

フォームに関してはServer Actionsを利用しないでよくある処理をしているため、use clientの指定でクライアントコンポーネントとして、処理している。

user/login/page.tsx
"use client";
import React, { useState, FormEvent } from "react";

const Register: React.FC = () => {
  const [formData, setFormData] = useState({
    email: "",
    password: "",
  });

  let flg = false;
  let msg = "";

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData((prevData) => ({
      ...prevData,
      [name]: value,
    }));
  };

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();

    try {
      const response = await fetch("/api/user/login", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify(formData),
      });

      const jsondata = await response.json();

      flg = jsondata.flg;
      msg = jsondata.message;

      if (flg) {
        //成功したら、トークンを保存
        if ("token" in jsondata) {
          localStorage.setItem("token", jsondata.token);
          alert(msg);
        }
      } else {
        alert(msg);
      }
    } catch (error) {
      alert("ログイン失敗");
    }
  };

  return (
    <div className="container">
      <h1>ログイン</h1>
      <form onSubmit={handleSubmit}>
        <div className="mb-4">
          <label htmlFor="email>Email</label>
          <input
            type="email"
            id="email"
            name="email"
            value={formData.email}
            onChange={handleChange}
          />
        </div>
        <div className="mb-4">
          <label htmlFor="password">Password</label>
          <input
            type="password"
            id="password"
            name="password"
            value={formData.password}
            onChange={handleChange}
           />
        </div>
        <div>
          <button type="submit">ログイン</button>
        </div>
      </form>
    </div>
  );
};

export default Register;

フロントエンド側をざっくり説明。
useStateでフォームの値をformDataというオブジェクト型で管理している。
handleSubmitで後術するAPI/api/user/loginにメールアドレスとパスワードを送信している。

APIの側の処理で、トークンを発行したらフロントエンド側にレスポンスしてlocalStrageにトークンを保存している。この方法が良いのかわからないが、リロードするとデータは消えてしまうので、localStorageを利用。

API側

jsonwebtokenでトークンを発行するのは、サーバサイド側なのでAPI側の処理から記載。
ちなみにフロントエンド側からはフォームでメールアドレス、パスワードが送信されてきたものとする。

api/user/login
import { NextRequest, NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
import { SignJWT } from "jose";

const prisma = new PrismaClient();

export async function POST(request:NextRequest){
    const body = await request.json();
  
    try {
        //接続
        prisma.$connect();
	
        const user = await prisma.user.findUnique({
            where:{
                email:body.email
            }
        });

        if(!user){
            return NextResponse.json({message:"ユーザーが存在しません",flg:false})
        }

        if(user.password !== body.password){
            return NextResponse.json({message:"パスワードが間違っています",flg:false})
        }

        //1:JWT用のシークレットキーを作成
        const secretKey = new TextEncoder().encode("prisma-supabase");

        //2:JWTのペイロードを作成
        const payload = {
            email:body.email,
            username:user.name,
        }

        //3:JWTでトークンを発行
        const token = await new SignJWT(payload).setProtectedHeader({alg:"HS256"})
        .setExpirationTime("2h") //有効期限 2hは2時間 1dは1日
        .sign(secretKey);
    
        return NextResponse.json({message:"ログイン成功",flg:true,token:token})

    } catch (error) {

        return NextResponse.json({message:"ログイン失敗",flg:false})

    } finally {
        await prisma.$disconnect();
    }
    
}

JsonWebTokenの処理の前に簡単に説明すると、prismaのfindUniqueでメールアドレスをチェックする。メールアドレスが存在しない場合は、「ユーザーが存在しません」というメッセージをjsonデータとしてフロント側に返している。

存在する場合は、パスワードをチェック。
フロント側から受け取ったパスワード(body.password)とSupabaseに登録されているパスワード(user.password)が一致しているかチェック。一致しなければ、フロント側に「パスワードが間違っています。」とjsonデータとして返しています。

成功した場合は、以下のようにNextResponseにトークンを指定して返す。

return NextResponse.json({message:"ログイン成功",flg:true,token:token})

JWT用のシークレットキーを作成

ユーザーが存在していて、パスワードも一致したらログイン成功となるのだが、この段階でJsonWebTokenを発行する。

まずはシークレットキーが必要になるので、今回はprisma-supabaseをシークレットキーとして使用する。ただし、TextEncoderencode()メソッドでUint8Arrayに変換している。

 const secretKey = new TextEncoder().encode("prisma-supabase");

https://developer.mozilla.org/ja/docs/Web/API/TextEncoder/encode

JWTのペイロードを作成

ペイロードは JWT の本体ですが、任意の値を埋め込むことができます。
今回はユーザー名とパスワードを埋め込んでおきます。

 const payload = {
       email:body.email,
       username:user.name,
   }

以下の2つのキーは JWT の ペイロードでキーとして用いられることの多い項目でクレームと呼ばれてるようです。
また、issやsub、expはJWTで予約されているクレーム名で『予約クレーム』と呼ばれてます。

  • sub : 認証の対象となるユーザの識別子で 通常 URI 形式で提供されます (subject)
  • iat : トークンの発行日時を表す timestamp (issued at)
  • aud : トークンが利用されるべきクライアント(受信者)識別子で 通常 URI 形式で提供されます (audience)
  • iss : トークンの発行者を表す識別子 (issuer)
  • exp : トークンの有効期限を表す timestamp (expiration)
  • nbf : exp とは逆に、トークンが有効となる日時を表す timestamp (not before)
  • jti : JWT の一意の ID

JWTでトークンを発行

上記のシークレットキーとペイロードをもとにトークンを発行する。

const token = await new SignJWT(payload).setProtectedHeader({alg:"HS256"})
        .setExpirationTime("2h") //有効期限 2hは2時間 1dは1日
        .sign(secretKey);
    
 return NextResponse.json({message:"ログイン成功",flg:true,token:token})

トークンを発行するにはSignJWTでトークンに必要な以下を設定する。

  • アルゴリズムの種類
  • 有効期限
  • ペイロード
  • シークレットキー

まずSignJWTの引数にペイロードを指定する。その後、アルゴリズムをHS256を指定するために、setProtectedHeaderで指定する。
有効期限はsetExpirationTimeで指定する。「2h」は2時間となり1日は「1d」となる。
最後にsignでシークレットキーを指定する。

これでトークンが発行される。実際に有効になってるかは以下で確認。
https://jwt.io/

いったんこれでJWTによるトークンは完了。

トークンが有効かを検証するには?

次はトークンが実際に有効かどうかをチェックする。
そのため、マイページを作成して以下のように流れにした。

  • マイページへ飛ぶ
  • トークンを調べて有効であれば、名前とアドレスを表示。
  • トークンが有効でなければログインページへリダイレクトさせる。

カスタムフック useAuthを作成

トークンをチェックするために別ファイルにしたいので、カスタムフックとして作成する。

/utils/useAuth.ts
"use client"
import { jwtVerify } from "jose";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";

const useAuth = () => {
  const [loginUser, setLoginUser] = useState({
    email: "",
    exp: 0,
    username: "",
  });

  const router = useRouter();

  useEffect(() => {
    const checkToken = async () => {

      //1:トークを取得する
      const token = localStorage.getItem("token");

      //2:トークンがあるかどうか
      if (!token) {
        router.push("/user/login");
      }

      //3:トークンがある場合は有効性をチェック
      try {
        const secretKey = new TextEncoder().encode("prisma-supabase");
        const decodedJWT = await jwtVerify(token, secretKey);

        //ログインユーザーをセット
        setLoginUser(decodedJWT.payload);
        
      } catch (error) {
        //トークンが不正な場合はログイン画面に遷移
        router.push("/user/login");
      }
    };

    checkToken();
  }, [router]);

  return loginUser;
};

export default useAuth;

処理の流れ

まずは、トークンから取り出したユーザー名とメールアドレスと有効期限の値をuseStateで管理。
このloginUserreturnすることで使用するページでユーザー名とメールアドレスと表示させている。

実際にトークンを検証するには、useEffectで処理。
トークンをバリデーションする為にawaitで処理したいので、checkTokenという関数をasyncで対応する。

checkToken関数の処理は以下のようにしている。

1:トークンがあるかどうかチェックするためにlocalStrageからトークンを取得
2:トークンがなかったらそもそも発行されていないのでログインへリダイレクトさせる。
3:バリデーションする際は有効であった場合とそうでなかった場合の処理をtry ~ catch()文として処理する
4:シークレットキーをTextEncoderencodeでエンコードする。ここで有効かどうかを確認する為にjwtVerifyを利用してトークン(token)とシークレットキー(secretkey)を渡してバリデーションをする。
5:戻り値のdecodedJWTpayloadプロパティにemailusernameexpがオブジェクトとして含まれているのでそのままsetLoginUserに渡している。
個別にユーザー名など取り出したい場合は、decodedJWT.payload.usernameとすればいい。

もしdecodedJWTで有効期限が切れていた場合は、catch(error)の処理になるため、userRouterrouterオブジェクトのpushメソッドでログインページへリダイレクトさせている。

ちなみにusernameやemailなどのプロパティ名はAPIのログイン処理の際にJWTのペイロードを作成で決めた名前で任意の名前で大丈夫です。

マイページを作成

次は検証するためにuser/mypageというパスでマイページを作成する。

/user/mypage/page.tsx
"use client"
import useAuth from '@/app/utils/useAuth';
import Link from 'next/link';
import React from 'react'

const page = () => {

  const loginUser = useAuth();

  return (
    <div>
        <h1>マイページ</h1>
        <p>ユーザー名:{ loginUser.username && loginUser.username }</p>
        <p>メールアドレス:{ loginUser.email && loginUser.email }</p>
    </div>
  )
}

export default page

user clientでクライアントコンポーネントにしているには訳があり、カスタムフックであるuseAuthuseEffectなどReact Hooksを利用しているから。

useAuthを呼び出して、ここでトークンをバリデーションしてチェックして成功しいていれば、loginUserにユーザー名とメールアドレスと入っている。
もし有効期限が切れていたり、そもそもトークンが入っていないのであれば、ログインページにリダイレクトする。

まとめ

これでいったんは、JWTでログイン時にトークンを発行して、ページでトークンをチェックという事はできた。

あとはAPIでこのトークンをチェックするにはMiddlewareの処理となるのでそれはまた今度。

Discussion