7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

jsonwebtokenを使ってシンプルな認証機能を作成

Last updated at Posted at 2023-07-14

はじめに

こちらはNode.jsで使用できるライブラリjsonwebtokenを使ってシンプルな認証機能を作成する記事です。

先日私が書いたJWTトークンに関する以下の記事を実際にコードで具体化したものになります。
もしよろしければ下記の記事も読んでいただけると幸いです:bow:

では環境構築から始めていきます。

環境構築

今回はNext.js(13.4.10)環境で認証機能を作成していきます。

  • Windows11
  • Next.js(13.4.10)
terminal
npx create-next-app jsonwebtoken-sample

スクリーンショット (280).png
Next.jsの環境が構築できたらjsonwebtokenと型定義の@types/jsonwebtokenをインストールします。

terminal
npm install jsonwebtoken @types/jsonwebtoken

ログインページとAPIを作成

次にログインページにログイン認証API、ユーザー情報をクレームに含んだJWTを検証するAPIを作成します。

ディレクトリ構成

スクリーンショット (281).png

ログインページ

本来はロジックやJSXをファイル分割すべきだと思うのですが、今回はデフォルトで設置されているsrc/app/page.tsxファイルに全て記述しました。

src/app/page.tsx
"use client";

import { FormEvent, MouseEvent, useState } from "react";

type Result = {
  user: {
    userId: number;
    username: string;
  };
  message: string;
};

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

  // 1⃣ デフォルト値としてuserId: 9999とusername: ゲストユーザー を設定。
  const [user, setUser] = useState({
    userId: 9999,
    username: "ゲストユーザー",
  });

  // 2⃣ emailとpasswordを使ったログイン認証API
  const login = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const payload = {
      email,
      password,
    };

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

      if (response.status === 401) {
        window.alert(message);
      } else if (response.status === 200) {
        setUser(user);
        window.alert(message);
      }
    } catch (err) {
      window.alert(`エラーが発生しました。: ${err}`);
    }

    setEmail("");
    setPassword("");
  };

  // 3⃣ JWTトークンを検証するAPIにGETリクエストを送信する関数
  const verifyJwt = async (
    e: MouseEvent<HTMLButtonElement, globalThis.MouseEvent>
  ) => {
    e.preventDefault();

    try {
      const response = await fetch("/api/verify-jwt");
      const { user, message }: Result = await response.json();

      if (response.status === 401) {
        window.alert(message);
      } else if (response.status === 200) {
        setUser(user);
        window.alert(message);
      }
    } catch (err) {
      window.alert(`エラーが発生しました。: ${err}`);
    }
  };

  // 4⃣ ログインページ
  return (
    <main>
      <div className="flex flex-col justify-around items-center h-screen">
        <div>
          <p className="mb-4">ユーザーID: {user.userId}</p>
          <p>ユーザー名: {user.username}</p>
          <button
            onClick={(e) => verifyJwt(e)}
            className="px-4 py-2 mt-4 bg-indigo-400 rounded text-white"
          >
            ユーザー情報確認
          </button>
        </div>
        <div className="flex justify-center items-center h-96 w-96 bg-gray-100">
          <form onSubmit={(e) => login(e)}>
            <div className="flex flex-col justify-center items-center">
              <label htmlFor="email">email</label>
              <input
                type="email"
                id="email"
                placeholder="input email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                className="mb-8 py-2 px-4"
              />
              <label htmlFor="password">password</label>
              <input
                type="password"
                id="password"
                placeholder="input password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                className="mb-8 py-2 px-4"
              />
              <button
                type="submit"
                className="bg-indigo-500 py-2 px-4 rounded text-white"
              >
                送信
              </button>
            </div>
          </form>
        </div>
      </div>
    </main>
  );
}
  • ①はログイン認証を行っていないユーザーを想定してユーザーIDを9999、ユーザー名をゲストユーザーとしてデフォルト値を設定しています。
  • ②はフォーム画面で入力したemailとpasswordをsrc/app/api/loginにログイン認証APIにPOSTで送信する関数を定義しています。
  • ③はJWTの妥当性を検証するAPI src/app/api/verify-jwtに対して、cookieに保存されているユーザー情報を含んだJWTトークンをGETで送信する関数を定義しています。
  • ④はフォーム入力欄や、現在のユーザー情報を表示する要素を含んだログインページです。

ログイン認証API

src/app/api/login
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import jwt from "jsonwebtoken";

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

  /* 1⃣ 本来はDBにemailでユーザーが存在するかを確認して、ハッシュ化されたパスワードと比較を行うと思うのですが、
        今回はシンプルなログイン認証としたいので、emailとpasswordをハードコーディングしています。
  */
  if (email === "hirohana@co.jp" && password === "1234") {
    const user = {
      userId: 1,
      username: "hirohana",
    };

    // 2⃣ cookieにJWTを保存する。
    const secretKey = process.env.SECRET_KEY as string;
    const token = jwt.sign(user, secretKey, {
      expiresIn: 120,
      algorithm: "HS256",
    });
    cookies().set({
      name: "token",
      value: token,
      httpOnly: true,
      secure: true,
      sameSite: "strict",
      path: "/",
    });

    // 3⃣ ユーザーにuserとmessageを格納したオブジェクトを返却する
    const message = "ログイン認証に成功しました。";
    return NextResponse.json({ user, message }, { status: 200 });
  } else {
    // 4⃣ ログイン認証に失敗した場合は下記のmessageを格納したオブジェクトを返却する。
    const message = "emailまたはpasswordが違っています。";
    return NextResponse.json({ message }, { status: 401 });
  }
}
  • ①は本来、フォームから送信されたemailとpasswordを使ってデータベースに問い合わせを行い、妥当性を検証する必要があるのですが、今回はシンプルなログイン認証機能という事でemailはhirohana@co.jp、passwordは1234とハードコーディングをしています。
  • ②はjwt.sign関数を呼び出し、引数にユーザー情報と.envファイルに格納されているシークレットキーを渡してJWTトークンを作成しcookieに保存しています。
  • ③ですが、ログイン認証に成功した場合はユーザー情報(userId、username)を格納したuser変数と、ログイン認証に成功したというメッセージを格納したmessage変数をjsonとして返却しています。
  • ④はログイン認証に失敗した場合にemailまたはpasswordが違っていますという文言を格納したmessage変数をjsonとして返却しています。

JWT検証API

src/app/api/verify-jwt
import { cookies } from "next/headers";
import jwt from "jsonwebtoken";
import { NextResponse } from "next/server";

export async function GET() {

  // 1⃣ クッキーに保存されたtokenという名前のJWTを取得。
  const object = cookies().get("token") ?? { value: "" };
  const token = object.value;
  const secretKey = process.env.SECRET_KEY as string;

  try {
    // 2⃣ cookieから取得したtokenと、環境変数に格納しているsecretKeyを使い
    // tokenが改ざんされていないか、有効期限は切れていないかをverify関数を使って検証している。
    const user = jwt.verify(token, secretKey);

    // 3⃣ tokenに問題が無ければtokenから取得したuser情報とmessageをレスポンスとして返却。
    const message = "ユーザー情報を取得できました。";
    return NextResponse.json({ user, message }, { status: 200 });
  } catch (err) {
    // 4⃣ tokenが改ざんされていたり、有効期限が切れていたら下記のmessageをレスポンスとして返却。
    const message = `ユーザー情報を取得できませんでした。 ${err}`;
    return NextResponse.json({ message }, { status: 401 });
  }
  • ①はクッキーに保存されているtokenという名称のユーザー情報(userId、username)が格納されたJWTトークンを取得しています。
  • ②はクッキーから取得したJWTトークンと環境変数に格納しているシークレットキーを使い、トークンが改ざんされていないか、有効期限は切れていないかをjwt.verify関数を使って検証しています。
  • ③はJWTトークンの検証に問題が無ければトークンから取得したユーザー情報(userId、username)とユーザー情報を取得できたというメッセージをjsonとして返却しています。
  • ④はJWTトークンが改ざんされていたり有効期限が切れていた場合はエラーハンドリングされcatch句に飛び、ユーザー情報が取得できなかったというメッセージをjsonとして返却しています。

シークレットキーを.envファイルに記述

ログイン認証API、JWT検証APIで使用するシークレットキーを.envファイルに記述します。

.env
SECRET_KEY=secretKey

ログイン認証APIの確認

ログイン認証APIを確認していきます。

emailをhirohana@co.jp、passwordを1234で入力し、ログイン認証APIに送信するとページ上部のユーザーIDユーザー名が更新されていることが分かります。

またChormeの開発者ツールのApplicationのタブのcookieを見ると、JWTトークンが格納されていることが確認できます。

スクリーンショット (285).png

念のためJWTトークンとして認識されているか https://jwt.io/ のサイトにて確認を行ったところ、上図を見たら分かるようにデコードされたuserIdとusernameが表示されていました。

JWT検証APIの確認

次にcookieに保存されているJWTトークンを検証するためJWT検証APIにGETリクエストを送信し、ペイロードからuserIdとusernameがレスポンスとして渡ってきて表示されるか確認を行います。

一度画面をリロードするとログインページ上部のユーザーIDとユーザー名は、デフォルト値に戻るのが確認できると思います。その後でユーザー情報確認のボタンを押せばJWTトークン検証APIにリクエストが送信され、再度ユーザーID及びユーザー名が更新されます。

もしJWTトークンの有効期限が切れた場合は、window.alertにて期限切れエラーメッセージが下図のように表示されます。今回のコードではjwt.sign関数の中でexpiresIn: 120で設定していますので、ログイン認証の2分後にユーザー情報確認ボタンを押せばJWTトークンの期限が切れることになります。

ペイロードに個人情報を格納してはいけない

JWTトークンのヘッダーとペイロードはBase64URLエンコードされているだけで、暗号化されているわけではありません。したがって万が一JWTトークンが盗まれた際に、Base64URLデコードされてしまうと容易に個人情報が盗まれますので、ペイロードに含める情報は仮に流出しても問題ない情報ユーザーIDユーザー名ユーザーのプロフィール写真URLなどしか格納してはいけないことに注意が必要です。

おわりに

最後まで記事をご覧いただきありがとうございました。

間違い等ありましたらご指摘いただけると幸いです:bow:

参考

7
3
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?