🔒

jwtを用いたシンプルな認証機能をもつエンドポイントを作ってjwt認証の理解を深めよう

2024/12/19に公開

この記事は、前回の記事「ボーッと生きてんじゃねえよ!!!JWT ってどうやって認証・認可してるの〜?」の続きの記事です!
長いですが、今回書いた内容の原理にあたる部分が書かれているので、よければそちらも読んでみてください。

本記事は、クロスマートアドベントカレンダー 2024 の 19 日目の記事として執筆しています!
https://qiita.com/advent-calendar/2024/xmart

本記事ざっくりと

  • jwt を使用した認証機能を持ったエンドポイントを作成します
  • curl でリクエストを送って、実際に認証をすることができることを確認します

今回のコードは、以下のサンプルコードとして配布しています。よろしければ参照ください。

https://github.com/3l4l5/jwt-test

jwt の原理(おさらい)

前回の記事のおさらいです。
jwt(HS256)を用いた認証フローは、以下の通りでした。

image
jwt の生成方法

image
jwt の検査方法

今回はこの原理をもとに、認証機能を有した web api の実装をしていきます。

jwt 公式からは様々な言語の library が準備されおり、今回はこのライブラリを活用しながら認証機能を作っていきます。

https://jwt.io/libraries

今回は様々な言語がある中で、 TypeScript を用いて書いていこうと思います。

実際に書いてみる(実処理部分)

サンプルとして、以下のような仕様を持つアプリケーションを作ってみましょう。

  • ユーザー ID とパスワードでログインできる
  • ユーザーには Admin と非 Admin の二種類のユーザーが存在する
  • 一般ユーザーが見ることができるコンテンツと、ログインユーザーのみ見ることができるコンテンツ、Admin ユーザーのみ見られるコンテンツの三種類のコンテンツがある。

これから、実際に jwt をゴニョゴニョする部分を jwt 公式さんに提供していただいているライブラリを活用させていただきながら書いていこうと思います。

今回、事前に用意してあるユーザーは以下の通りです

userId password isAdmin
user1 user1Password true
user2 user2Password false

jwt を生成する部分

jwt を作る部分です。
jwt には、任意のペイロードを入れることができます。
今回は、1 時間有効の jwt に、userId と isAdmin の情報を入れてみましょう。

コードは以下のようになります。

src/jwt/tokenHandler.ts
export function makeJwtToken(userId: string, isAdmin: boolean) {
  const payload = {
    userId: userId,
    isAdmin: isAdmin,
  };

  const token: string = jwt.sign(
    payload, // jwtに込めたい情報を付与
    jwtSecret, // サーバー側のみ知っている秘密の文字列を付与
    {
      expiresIn: "1h", // 1時間有効
    }
  );
  return token;
}

受け取った情報を object にして、jwt token を作成しています。
やってることはシンプルですね!

生成された token を抜き出して、jwt.ioで確認してみると、以下の結果を確認できます。

alt text

payload に userId と isAdmin が入っていることを確認できます。
いい感じですね!

jwt を確認する部分

では、次に、リクエストされた jwt を判定する部分です。
以下のようになります。

src/jwt/tokenHandler.ts
export function verifyToken(token: string): {
  userId: string;
  isAdmin: boolean;
} {
  const jwtPayload = jwt.verify(token, jwtSecret, {
    complete: false,
  }) as jwt.JwtPayload;
  return {
    userId: jwtPayload.userId,
    isAdmin: jwtPayload.isAdmin,
  };
}

こちらも至ってシンプルで、string で受け取った token を jwt のライブラリを用いて判定をしています。

もし、ここで、問題のある jwt が入ってきた場合は、jwt.verifyが例外を出します。
今回はやっていませんが適切にエラーハンドリングすることで、token が不正なのか、token が期限切れなのかを判定することもできます。

このように、実際に jwt を作成、判定する部分を作成することができました。
次に、これらの処理を Web API に組み込んでみましょう。

実際にエンドポイントに組み込んでみる

今回は、Express を使用していこうと思います。どのフレームワークでも似たような実装になるかと思います。
全ては紙面の問題で上げることはできませんので、主要な部分のみ抜き出します。

今回は以下の部分について紹介します。

  • ログインユーザーの判定を行い、token を発行する部分
  • jwt の判定を行う Middleware 部分
  • Admin 限定で確認できるコンテンツを返却する部分

ログインユーザーの判定を行い、token を発行する部分

まず ログイン機能の部分です。以下のコードになります。

src/index.ts
// ログイン
app.post("/users/login", (req: Request, res: Response) => {
  // request bodyでリクエストされた userId と password を取得
  const { userId, password } = req.body;
  // 保存されてる user の情報を userId からとってくる
  const user = findUser(userId);

  if (user === undefined) {
    res.status(404).json({
      message: "cannot find user",
    });
    return;
  }
  // 注意!!! ほんとは平文でパスワード保存しちゃダメだよ!!!
  if (user.password == password) {
    // 情報が詰め込まれたtokenを生成
    const token = makeJwtToken(userId, user.isAdmin);
    res.cookie("jwt", token);
    res.send("ok");
    return;
  } else {
    res.status(404).json({
      message: "invalid password",
    });
    return;
  }
});

正常系の主な処理は以下のとおりです。

  1. POST された request body を展開して userId と password を抜き出す
  2. 抜き出した userId, password の組み合わせが正しいかどうかを判別する。(本来の web サービスではパスワードをそのままの文字列で保存するのはいけません。)
  3. password が正しかった場合、前章で作成した makeJwtToken 関数を用いて token を作成する
  4. 作成した token を cookie に入れて、response を返す

これによって、クライアント側では cookie に jwt がセットされ、ログイン状態になります。

実際にこのエンドポイントにリクエストを行うと以下のレスポンスが得られます

HTTP/1.1 200 OK
X-Powered-By: Express
Set-Cookie: jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ1c2VyMSIsImlzQWRtaW4iOnRydWUsImlhdCI6MTczNDU2NjczMywiZXhwIjoxNzM0NTY2NzkzfQ.r2GqMNLlWHHfjy8Gpi1jaxva-T-Sq5Tbwp_Jwo84bCY; Path=/
Content-Type: text/html; charset=utf-8
Content-Length: 2
ETag: W/"2-eoX0dku9ba8cNUXvu/DyeabcC+s"
Date: Thu, 19 Dec 2024 00:05:33 GMT
Connection: keep-alive
keep-alive: timeout=5

ok

Header の Set-Cookie に jwt が含まれていることがわかりますね!意図通りです!

jwt の判定を行う Middleware 部分

Middleware とは Express にある機能で、リクエストを受けたのち、実際の処理を始める前に実行する処理を指します。

詳しくは Express の公式ドキュメントをご覧ください

https://expressjs.com/ja/guide/using-middleware.html

alt text
middleware のイメージ

今回は、認証が必要な path のみに、jwt の判定を行う処理を middleware として登録して、認証が行われるようにします。

実際のコードは以下のような形です。

src/middleware/token.ts
export function verifyTokenMiddleware(
  req: RequestWithAuth,
  res: Response,
  next: NextFunction
) {
  const key = req.cookies?.jwt; // cookieに含まれているkeyを取得
  if (key === undefined) {
    res.status(403).json({ message: "token is empty" });
    return;
  }
  try {
    const userInfo = verifyToken(key); // keyから情報を取り出す
    req.authData = userInfo;
    next(); // 問題なくここまで来たら、middlewareの次の処理へ
    return;
  } catch (e) {
    console.error(e);
    res.status(403).json({ message: "token is invalid" });
    return;
  }
}

主に、前章で作成した jwt の判定部分を利用する形で、この関数の中ではリクエストの処理と、例外として返すか、後続処理に続けるかのハンドリングをしています。

  1. cookie から key を取得
  2. verifyToken 関数に key を渡す
  3. 問題なければリクエストにユーザー情報を付与して後続処理に渡す
  4. token に問題があれば、403 エラーを返して処理を終える

登場人物が多くなってきましたが、一つひとつのやっていることはシンプルです。

Admin 限定で確認できるコンテンツを返却する部分

最後に Admin 限定で、スペシャルなコンテンツを返すエンドポイントを作成します。

コードは以下の通りです。

src/index.ts
app.get(
  "/contents/special/admin",
  verifyTokenMiddleware, // jwt tokenで認証を行うミドルウェア
  (req: RequestWithAuth, res: Response) => {
    if (req.authData?.isAdmin) { // userがAdminかどうかを判定
      res.json({
        contents: "super special mokemoke",
      });
    } else {
      res.status(403).json({
        message: "you are not admin user",
      });
    }
  }
);

正常系の処理は以下の通りです

  1. 先ほど作成した verifyTokenMiddleware を、middleware として登録しています。
  2. 受けとった request から、Admin かどうかを判定して、Admin だった場合は、スペシャルなコンテンツを返却します

middleware の中で cookie に含まれている token を解析してユーザーの情報を request に入れ込んでくれているので、実際の処理の中で userId や isAdmin などを使用することができるようになるわけですね!

今回はやっていませんが、この「Admin かどうかを判定する部分」を、繰り返し使用するようであれば、middleware として抜き出して作成してもいいでしょう。

実際に curl でリクエストを投げてみる

最後に、curl を用いて実際にリクエストを投げてみましょう!

ログイン

curl -c cookie.txt -X POST http://localhost:3000/users/login \
     -H "Content-Type: application/json" \
     -d '{
           "userId": "user1",
           "password": "user1Password"
         }'

補足
-c: レスポンスの set-cookie を用いて cookie.txt に保存します

レスポンスは以下です。

>>> ok

cookie.txt の内容も確認してみましょう

cookie.txt
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

localhost	FALSE	/	FALSE	0	jwt	eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ1c2VyMSIsImlzQWRtaW4iOnRydWUsImlhdCI6MTczNDU2OTQ1MCwiZXhwIjoxNzM0NTY5NTEwfQ.8bnf4vqAyOTAigV_M1CBWlfHGpvBaMp6M0IN__XMOmw

ちゃんと jwt のキーに token が保存されていますね!

ログインユーザーのみ確認できるエンドポイントにリクエストしてみる

先ほど取得した cookie を用いて、ログインユーザー限定のコンテンツにアクセスしてみましょう!
以下のようになります。

curl -b cookie.txt -X GET http://localhost:3000/contents/special

>>> {"contents":"special mokemoke"}

見事、special mokemoke を取得することができました!

Admin ユーザーのみ確認できるエンドポイントにリクエストしてみる

先ほど取得した cookie を用いて、Admin ユーザー限定のコンテンツにアクセスしてみましょう!
以下のようになります。

curl -b cookie.txt -X GET http://localhost:3000/contents/special/admin
>>> {"contents":"super special mokemoke"}

見事、admin ユーザー限定の super special mokemoke を取得することができました!

終わりに

この記事では、実際に jwt 認証を用いたエンドポイントの作成を通して jwt 認証の理解を深めました。
今回作成したサンプルコードは、かなり最小限のものです。本番環境で使用するのは控えて欲しいですが、jwt を用いた認証の理解の助けになれば幸いです。

今回使用したサンプルコードはこちらです。今回省略した部分なども含まれています。実際に動かすこともできますので、よろしければ参照ください。
https://github.com/3l4l5/jwt-test

ところで、今回実装した方法では、cookie に token を入れる形で key を運用しましたが、key の管理方法はこれで安全なのでしょうか?
cookie での運用は安全なのか、レスポンスで token を返して localstorage に保存を促すほうがいいのか。Authorization ヘッダーに token を指定してもらったほうがいいのか。など、token 管理のプラクティスはいくつかあります。次の記事では、token の管理方法にまとめようと思います!

参考にさせていただいた記事 🙇‍♂️

https://qiita.com/beckyJPN/items/e85d40459e7e535dae73

GitHubで編集を提案

Discussion