😀

【TypeScprit×Node.js(Express.js)】によるJWTトークンを復号しユーザーを取得する

2023/02/26に公開

はじめに

今回は、TypeScriptによるJWTトークンの複合処理と、復号したトークンでユーザーを取得する方法について、久方ぶりに手こずったので、備忘録として残します。

Userモデル

まず、前提としてユーザーのデータは以下のようになっています。

user.ts
import mongoose from "mongoose";

// userModel作成
const userSchema = new mongoose.Schema({
	username: {
		type: String,
		required: true,
		unique: true,
	},
	password: {
		type: String,
		required: true,
	},
});

module.exports = mongoose.model("User", userSchema);

ライブラリはmongooseを使用しています。
また、MongoDBでは以下の項目を持つようにしています。

  • _id
  • username
  • password
  • __v

以上が、ユーザーの持っている情報となります。

JWTトークンによるユーザー取得APIの実装

データの概要を見たところで、早速本題に入ります。

auth.ts
import express from "express";
import { verifyToken } from "../middleware/tokenHandler";

const router = express.Router();

// JWT認証APIを呼び出し
router.post(
	"/verify-token",
	verifyToken,
	(req: express.Request, res: express.Response) => {
		return res.status(200).json({ user: req.user });
	}
);

少し本筋とは外れますが、念のため解説すると、ExpressAppでは、routerという仕組みによりindex.tsのエンドポイントさえ指定していれば、API側でルーティングの設定ができる仕組みを提供しています。

具体的には、index.tsで以下のコードを書くだけで、エンドポイントをrouter.HTTPリクエスト()の引数で設定するだけで良くなり、一つ一つのAPIをindex.tsに記述する必要がなくなるという仕組みです。

index.ts
// エンドポイントからAPIを呼び出す
app.use("/api/v1", require("./src/v1/routes/auth"));

上記の場合は、/api/v1/各APIで設定したエンドポイントでAPIを呼び出すことができます。

本筋に戻りますと、第二引数のvefiryTokenがミドルウェアとなっており、ユーザーが認証済みかどうかをユーザーを取得することで確認する処理を記述しています。

このミドルウェアによる検証が正常終了した時に、第三引数の認証済みのユーザー情報が返却される仕組みになっています。

それでは、次にミドルウェアの実装を見てみましょう。

middleware

コードとしては、以下のような実装にしています。

tokenHandler.ts
import express, { NextFunction } from "express";
import jwt from "jsonwebtoken";

const User = require("../models/user");

// express.Requestに拡張でuser型を追加
declare global {
	namespace Express {
		interface Request {
			user?: { id: string };
		}
	}
}

// JWTトークンを復号する処理
const tokenDecode = (req: express.Request) => {
	// リクエストヘッダーの"authorization"を取得
	const bearerHeader = req.headers.authorization;

	// 認証情報が存在する場合
	if (bearerHeader) {
		// トークンを取得
		const bearer = bearerHeader.split(" ")[1];

		try {
			// トークンを復号
			const decodedToken = jwt.verify(
				bearer,
				process.env.TOKEN_SECRET_KEY as string
			);

			return decodedToken;
		} catch {
			return false;
		}
	} else {
		return false;
	}
};

// JWTを検証するためのミドルウェア
const verifyToken = async (
	req: express.Request,
	res: express.Response,
	next: NextFunction
) => {
	// 復号したトークンを取得
	const decodedToken = tokenDecode(req);

	// トークンが存在する場合
	if (decodedToken) {
		// ユーザーを取得(トークンはもともとユーザーのIDから生成したものであるため検索可能)
		const user = await User.findById((decodedToken as any).id);

		// ユーザーが存在しない場合
		if (!user) {
			return res.status(401).json("権限がありません");
		}

		// リクエスト情報を取得したユーザーで上書き
		req.user = user;
		next();
	} else {
		return res.status(401).json("権限がありません");
	}
};

export { verifyToken };

まず、疑問になるのは、declare global{}の部分だと思います。

// express.Requestに拡張でuser型を追加
declare global {
	namespace Express {
		interface Request {
			user?: { id: string };
		}
	}
}

これは、express.Request型userをプロパティとして追加するための記述をしています。

これがないと、req.userという記述で以下のエラーが出力されます。
プロパティ 'user' は型 'Request<ParamsDictionary, any, any, ParsedQs, Record<string, any>>' に存在しません。

ちなみに、このコードはスコープがグローバルで適用されますが、リクエストから取得したいプロパティを選択(ここではuser)しているファイルと同ファイルに書かないと、エディタ上のエラーはきているのに、上記のエラーが出続けるという事象が発生するので、注意が必要です。

// JWTトークンを復号する処理
const tokenDecode = (req: express.Request) => {
	// リクエストヘッダーの"authorization"を取得
	const bearerHeader = req.headers.authorization;

	// 認証情報が存在する場合
	if (bearerHeader) {
		// トークンを取得
		const bearer = bearerHeader.split(" ")[1];

		try {
			// トークンを復号
			const decodedToken = jwt.verify(
				bearer,
				process.env.TOKEN_SECRET_KEY as string
			);

			return decodedToken;
		} catch {
			return false;
		}
	} else {
		return false;
	}
};

次に、JWTトークンを復号する処理を行いますが、JWTトークンは、リクエストヘッダーのauthorizationに入っているため、そちらを取得し、条件分岐させハンドリングしています。

また、トークンの取得時は、splitで半角スペースで分割していますが、これは半角スペースの後ろ部分にトークンがあるため、このようにしています。

続いて、復号処理ですが、これはjsonwebtokenverify関数で行えます。
第一引数に、復号前のトークンを渡し、第二引数に環境変数としている秘密鍵を渡します。
環境変数ですが、as stirngをつけないと以下、エラーとなります。

型 'undefined' を型 'Secret | GetPublicKeyOrSecret' に割り当てることはできません。
(存在しないユーザーによるアクセスなどがあるため、undefinedの可能性があるので、上記エラーが出ます。)

最後に、認証ユーザーかどうか検証するミドルウェア関数を実装します。

// JWTを検証するためのミドルウェア
const verifyToken = async (
	req: express.Request,
	res: express.Response,
	next: NextFunction
) => {
	// 復号したトークンを取得
	const decodedToken = tokenDecode(req);

	// トークンが存在する場合
	if (decodedToken) {
		// ユーザーを取得(トークンはもともとユーザーのIDから生成したものであるため検索可能)
		const user = await User.findById((decodedToken as any).id);

		// ユーザーが存在しない場合
		if (!user) {
			return res.status(401).json("権限がありません");
		}

		// リクエスト情報を取得したユーザーで上書き
		req.user = user;
		next();
	} else {
		return res.status(401).json("権限がありません");
	}
};

ここでのポイントは複合化したトークンでユーザーの取得を行うという点です。

なぜかというと、トークン発行時に、ユーザーIDでトークンを発行しているためです。
以下が、当該コードとなります。

// JWTの発行
const token = jwt.sign(
    { id: user._id },
	process.env.TOKEN_SECRET_KEY as string,
	{
    	expiresIn: "24h",
	}
);

このため、ユーザーの取得は、findById()にて、復号化したトークンで取得しているわけです。

ちなみに、findById()(decodedToken as any).idとしていますが、これをしないと、これまた以下のようにエラーとなります。

プロパティ 'id' は型 'string | JwtPayload' に存在しません。 プロパティ 'id' は型 'string' に存在しません

これはdecodedTokenstringJwtPayloadの2つの型となるため、string型のユーザーIDの検索に使用できないためエラーとなります。
したがって、正常コードのようにas anyとすることで、ユーザーIDとしユーザーを取得するようにしています。

おわりに

JavaScriptと比べるとコードにさまざまな工夫が必要となるため、大変かと思いますが、どなたかの参考になれば幸いです。

Discussion