🗝

【初心者向け】JWTを使ったNode.js認証機能の実装メモ

に公開

🔰 はじめに

TypeScript + Node.js を使って JWTによる認証機能 を初めて実装してみました。
この記事はその備忘録であり、同じようにこれからJWT認証を学びたい方に向けたまとめです。


👤 対象読者

  • Node.js でログイン機能の作成を学びたい方
  • JWT 認証の流れをコードレベルで理解したい方
  • TypeScript+MySQL環境で認証機構を構築したい方

⚙️ 使用技術スタック

  • Node.js
  • TypeScript
  • Express
  • JWT(jsonwebtoken パッケージ)
  • MySQL(DB接続用)
  • その他パッケージ(dotenv, bcrypt, cors, mysql2 など)

🗂 構成前提

  • バックエンドプロジェクトは作成済み(Express + TypeScript)
  • 上記パッケージはすでにインストール済み

🗺 全体構成と認証フロー図

【認証チェック】
[クライアント]
    ↓  GET /tokenVerification
[バックエンド]
    ↓  cookie に JWT ない → 結果:認証NGを返却
    ↓  cookie に JWT ある
    ↓      → JWT 検証 し問題あり → 結果:認証未済を返却
    ↓      → JWT 検証 し問題なし → JWT 再作成して cookie に設定 & 結果:認証OKを返却
[クライアント]
    ↓  認証結果を受取

 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
【ログインチェック】
[クライアント]
    ↓  POST /format/makeSK
[バックエンド]
    ↓  cookie に ID ない → ID / SecretKey を作成しDBに登録、SecretKey を返却
    ↓  cookie に ID ある
    ↓      → ID が DB になし → ID / SecretKey を作成しDBに登録、SecretKey を返却
    ↓      → ID が DB にあり → ID をもとに SecretKey をDBから取得、SecretKey を返却
[クライアント]
    ↓  SecretKey を格納
    ↓  UserName、Password を SecretKey でエンコード
    ↓  POST /chkLogin (encUserName、encPassword)
[バックエンド]
    ↓  encUserName、encPassword を SecretKey でデコード
    ↓  対象の UserName を DB からユーザ情報を取得
    ↓   → 該当するユーザ情報がない → 1:ユーザ情報なし を返却
    ↓   → Password と 該当するユーザ情報のパスワードが一致しない
    ↓       → パスワードを間違えた回数の上限を超えていない → 2:パスワード不正 を返却
    ↓       → パスワードを間違えた回数の上限を超えている  → 3:パスワード不正&回数超過 を返却
    ↓   → Password と 該当するユーザ情報のパスワードが一致
    ↓       → パスワード変更期限を超えている    → 4:パスワード変更期限超過 を返却
    ↓       → 仮パスワード発行時のログインである → 5:仮パスワード変更時ログイン を返却
    ↓       → 上記以外              → 0:ログイン成功 を返却
    ↓     → パスワード一致時、JWT 作成し、cookie に設定。
    ↓       SecretKey 情報に情報付加。
    ↓  
[クライアント]
    ↓  ログインチェック結果を受取


🔧 ディレクトリ構成

.
├── index.ts
├── src/
│   └── router/
│       ├── index.ts
│       ├── format.ts
│       ├── chkLogin.ts
│       └── tokenVerification.ts
├── helper/
│   └── jwtHelper.ts
├── app/
│   ├── sk.ts
│   └── crypt.ts

🧩 各モジュールの概要

1. index.ts

Express アプリ起動ポイント。ルーターの設定などを行います。

import express, { Express } from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import router from "./src/router";

const app: Express = express();
const port = process.env.PORT;
const frontPass = process.env.FRONTPASS;

const corsOption: cors.CorsOptions = {
  //フロントエンド側のポート番号を設定する
  origin: frontPass,
  //認証情報の通信をするために true に設定。
  credentials: true,
}

//app.use(bodyParser.json());

app.use(cors(corsOption));

// URLの中でエンコードされた文字を読み取れるようにする
app.use(express.urlencoded({ extended: true}));
// リクエストされたJSONオブジェクトを読み取れるようにする。
app.use(express.json());
// リクエストされた cookie を読み取れるようにする。
app.use(cookieParser());

app.use("/", router);

app.listen(port, () => {
  console.log(`listening on port ${port}`);
});

2. router/index.ts

全ルートをまとめてエクスポートするエントリーポイント。
機能別のルーターを設定しています。

import * as express from "express";
import chkLogin from "./chkLogin";
:
import tokenVenification from './tokenVerification';
import com from './common';

const router = express.Router();

router.use("/chkLogin",chkLogin);
:
router.use("/tokenVerification", tokenVenification);
:

export default router;

3. router/format.ts

当該アプリの各種フォーマットを行う処理を載せています。
今回は、シークレットキーの付与を行う処理のみを記載しています。
シークレットキーの作成はランダム関数を使って出力しています。
上記既述のとおりですが、cookie に ID(シークレットキーに紐づくID)が設定されていない場合、また、該当の ID が DB に設定されていない場合は、再度IDを作成します。DB に ID が設定されていれば、DB からシークレットキーを取得します。その後、作成 / 取得 したシークレットキーを返却します。
当該機能は、ログインした時に都度実行される前提となっており、このシークレットキーを使ってクライアント側とやり取りする情報のうち、ユーザ情報等、秘匿する必要がある情報を、クライアント側で暗号化してもらいます。
クライアント側では、このシークレットキーを別のキーを使って暗号化して、静的な属性に保持してもらう必要があります。

import express, { Request, Response } from "express";
import { connectionDB, deleteDB, disconnectFromMySQL, DBBeginTrans, DBCommit, DBRollback, selectDBSec, insertDBSec} from '../../app/connDB';
import mysql from 'mysql2';
import { randomBytes } from 'crypto'
import ms from "ms";
import { encrypt,decrypt } from '../../app/crypt';
import { getComSKEnv } from "../../app/sk";

const router = express.Router();

function makeRandom( minDigit: number, maxDigit: number) : string{
  const S="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
  const numDigit = Math.random() * ( maxDigit + 1 - minDigit )  + minDigit;

  return randomBytes(numDigit).reduce((p, i) => p + S[(i % S.length)], '')
}

router.post("/makeSK", async (req: Request, res: Response) => {

    const aryInputValue: string[] =[];
    let connect: mysql.Connection | undefined;
    let sqlFormatTxt: string;
    let setID :string = '';
    let setKey :string = '';
    let data1 :mysql.RowDataPacket[];
    let makeSKFlg :boolean = false;

    const comSK = await getComSKEnv();

    try {
      connect = connectionDB();

      // IDがセットされていない場合、ID を作成して返信
      await DBBeginTrans(connect);

        // cookie.ID があるか確認
        if (req.cookies.ID) {

          // cookie.ID がDBに設定されているか確認
          setID = decrypt(req.cookies.ID, comSK);
          sqlFormatTxt = mysql.format(
            "select * from T_SKEYS where ID = ? ", [setID]);
          data1 = await selectDBSec(connect, sqlFormatTxt);

          // cookie.ID がDBに設定されていない場合、再作成
          if (data1.length === 0) {
            makeSKFlg = true;
          // cookie.ID がDBに設定されている場合、そのままSKを返却
          } else {
            makeSKFlg = false;
            setKey = decrypt(data1[0].SECRETKEY,(await getComSKEnv()));
          }
      // cookie.ID が作成されていない場合、作成
      } else {
        makeSKFlg = true;
      }

      if ( makeSKFlg ) {

        while (true) {
          setID = makeRandom(25, 255);
          sqlFormatTxt = mysql.format(
            "select * from T_SKEYS where ID = ? ", [setID]);
          data1 = await selectDBSec(connect, sqlFormatTxt);
          if (data1.length === 0 ) break;
        }

        // ID, SK を T_SKEYS に登録
        setKey = makeRandom(75, 255);
        sqlFormatTxt = mysql.format(
          "insert into T_SKEYS values ( ?, ?, NULL, sysdate() )");
        aryInputValue.push(setID);
        aryInputValue.push(encrypt(setKey, comSK));
        await insertDBSec(connect, sqlFormatTxt,aryInputValue);

        // Cookie に ID(暗号化)を設定
        res.cookie("ID", encrypt(setID, comSK), {
          httpOnly: true,
          expires: new Date(Date.now() + ms("1d")),
          path: '/',
        });
      }

      await DBCommit(connect);
      res.status(200).json({KEY:setKey});

    } catch(error) {
  
      if (connect)  await DBRollback(connect);
      console.error('Error:', error);
      res.status(500).send('An error occured');

    } finally {
  
      if ( connect) disconnectFromMySQL(connect);
    }
  
  });export default router;

4. router/chkLogin.ts

ログイン情報( UserName/Passwod (enc済み))の受信と検証、および JWT の発行を行う処理になります。
クライアント側において、上記 format.ts / makeSK メソッドで返却したシークレットキーを使って UserName と Password を暗号化して送付してもらいます。(その前提で作成しています)
それをデコードしたうえで、ログイン情報の確認を行い、その結果をコード値で返すと同時に、ログインが成功とした場合には JWT を cookie に設定します。
(中にはシークレットキーを保持するDB内のテーブルにユーザ名を設定していたりしますが、論点とは余り関係ないので割愛します)

import express, { Request, Response } from "express";
import { connectionDB, selectDBSec,updateDBSec, disconnectFromMySQL, DBBeginTrans, DBCommit, DBRollback} from '../../app/connDB';
import mysql from 'mysql2';
import { decrypt } from '../../app/crypt';
import { jwtHelper } from "../helper/jwtHelper";
import { getComSKEnv, getSK } from "../../app/sk";
import { logState, inputLog } from "../../app/inputLog";

const router = express.Router();

router.post("/", async (req: Request, res: Response) => {

    const SK = await getSK(req.cookies.ID);

    const tarID = await decrypt(req.cookies.ID, (await getComSKEnv()));

    const userName = decrypt(req.body.userName, SK);
    const pass = decrypt(req.body.pass, SK);
    const aryInputValue: string[] =[];

    let connect: mysql.Connection | undefined;
  
    try {

      connect = connectionDB();

      await DBBeginTrans(connect);

      // --------------------------------------------------------------------------

      let sqlFormatTxt: string = mysql.format("select * from T_USERS where USERNAME = ? ", [userName]);
      let data1 = await selectDBSec(connect, sqlFormatTxt);
      let returnCd = '0';

      if (data1.length === 0) {

        returnCd = '1';                    // 対象ユーザなし

      } else if ( decrypt(data1[0].PASSWORD, (await getComSKEnv())) !== pass ) {

        const failCheckID : string  =  'L0001'

        // ログイン失敗回数 インクリメント
        sqlFormatTxt = mysql.format("update T_USERS set LGINFAIL = LGINFAIL + 1 where USERNAME = ? ", [userName])
        await updateDBSec(connect, sqlFormatTxt, aryInputValue);
        await DBCommit(connect);

        sqlFormatTxt = mysql.format(
          "select T1.LGINFAIL from T_USERS T1 " + 
          "where T1.USERNAME = ? " +
          "and T1.LGINFAIL >= " + 
          " ( select T2.PARAM1 from T_PARAMETER T2 where PARAMID = ? ) "
          , [userName, failCheckID]);
        data1 = await selectDBSec(connect, sqlFormatTxt);
  
        if (data1.length === 0) returnCd = '2'; // パスワード不正
        else returnCd = '3';       // パスワード不正 かつ 回数超過

      } else {

        const passChgID = 'L0002';

        //ログイン変更期限チェック
        sqlFormatTxt = mysql.format(
          "select 1 from T_USERS T1 " + 
          "where T1.USERNAME = ? " +
          "and  DATEDIFF( sysdate(), T1.LGINFAIL) > " + 
          " ( select T2.PARAM1 from T_PARAMETER T2 where PARAMID = ? ) "
          , [userName, passChgID]);
        data1 = await selectDBSec(connect, sqlFormatTxt);

        if( data1.length !== 0 ) {
          returnCd =  '4'; // 変更期限超過判定
        } 

        //仮パスワードでのログイン(リセットフラグ)
        sqlFormatTxt = mysql.format(
          "select 1 from T_USERS T1 " + 
          "where T1.USERNAME = ? " +
          "and   T1.RESETFLG = 1 "
          , [userName, passChgID]);
        data1 = await selectDBSec(connect, sqlFormatTxt);

        if( data1.length !== 0 ) {
          returnCd =  '5'; // パスワードリセット
        } 

        // ログイン失敗回数 クリア / ログイン時間更新
        sqlFormatTxt = mysql.format(
          "update T_USERS set LGINFAIL = 0, LGINTIME = sysdate() where USERNAME = ? ", 
          [userName])
        await updateDBSec(connect, sqlFormatTxt, aryInputValue);

        // T_SKEY.USERNAME_E に ユーザ名(暗号化)を設定
        sqlFormatTxt = mysql.format(
          "update T_SKEYS set USERNAME_E = ? where ID = ? ", 
          [req.body.userName, tarID])
        await updateDBSec(connect, sqlFormatTxt, aryInputValue);

        // jwtToken トークンを生成
        res.cookie("jwtToken", jwtHelper.createToken(), {
            httpOnly: true,
            expires: jwtHelper.setTokenDate(),
            path: '/',
        });

        await DBCommit(connect);

      }

      switch(returnCd) {
        case '0' :
          inputLog(userName, req.body.ScreenID, req.body.ACT, 
            logState.SUCCESS,'LOGIN SUCCESS');
          break;
        case '3' :
          inputLog(userName, req.body.ScreenID, req.body.ACT, 
            logState.FAIL,'LOCKOUT');
          break;
        default :
          break;
      }

      res.send(returnCd);

      // --------------------------------------------------------------------------
  
    } catch(error) {
  
      if (connect) await DBRollback(connect);
      inputLog(userName, req.body.ScreenID, req.body.ACT, 
        logState.FAIL,String(error));
      console.error('Error:', error);
      res.status(500).send('An error occured');
    } finally {
  
      if ( connect) disconnectFromMySQL(connect);
    }
  
  });
  
  export default router;

5. router/tokenVerification.ts

JWT を検証した結果を返却する処理になります。JWT は cookie に設定する前提で作成しています。
ログイン成功時に設定された JWT の検証を行い、問題なければ JWT を再作成して cookie に設定、認証済みである結果を返します。
JWT が設定されてなかったり、検証した結果、不正なものである場合は認証されていないと返します。

import * as express from "express";
import { jwtHelper } from "../helper/jwtHelper";

const router = express.Router();

//jwtトークンの検証
router.get("/", (req, res) => {

    if (!req.cookies.jwtToken) {
        //cookieにjwtトークンがない場合は、認証不可
        return res.status(200).json({ isAuthenticated: false });
    }

    //  リクエストされたjwtトークンを検証
    const decode = jwtHelper.verifyToken(req.cookies.jwtToken);

    // 確認結果に問題がなければ認証可 / 問題あれば不可
    if (decode) {

        // token の作成
        try {
            //検証がOKであれば、jwtトークンを再作成
            res.cookie("jwtToken", jwtHelper.createToken(), {
                httpOnly: true,
                expires: jwtHelper.setTokenDate(),
                path: '/',
            });

            // 認証可
            return res.status(200).json({ isAuthenticated: true });

        } catch(error) {

            return res.status(500).send('An error occured');
        } 
    } else {

        // 認証不可
        return res.status(200).json({ isAuthenticated: false });
    }


});

export default router;

6. helper/jwtHelper.ts

JWT の作成・検証ロジックを記載しているファイルです。
JWT 検証用の暗号キーを別途環境変数として設定する前提で、同キーを使って JWT を検証します。

import { Response } from "express";
import * as jwt from "jsonwebtoken";
import ms from "ms";

const def_sek_key = 'D5tTKBRSFJ8LXY3u6jPQhxJNqvyq24fZ';

export class jwtHelper {
    // 秘密鍵
    static jweSecret = process.env.JWT_SECRET || def_sek_key;

    // トークン作成
    static createToken () {
        const token = jwt.sign({ foo: "bar"}, this.jweSecret, {
            expiresIn: "30d",
        });
        return token;
    }

    // トークン確認
    static verifyToken( token: string) {
        try {
            const decoded = jwt.verify(token, this.jweSecret);
            return decoded;
        } catch (err){
            console.error(err);
        }
    }

    // トークン生存期間設定
    static setTokenDate() {
        return new Date(Date.now() + ms("1d"));
    }
}

7. app/sk.ts

各種シークレットキーに関連する処理を格納しています。
共通暗号化キーと、ログイン時に設定されるシークレットキーをそれぞれ出力する機能や、シークレットキーを使って UserName をデコードする機能が記載されています。
UserName はやり取りすることが頻繁にあったので、共通関数化しました。

import { connectionDB, selectDB, selectDBSec, disconnectFromMySQL } from './connDB';
import * as mysql from 'mysql2';
import { decrypt } from './crypt';
import * as dotenv from 'dotenv';

dotenv.config();

// 後日、作成した共通鍵を作成し、JSONファイルに書き込むファンクションを作成
//  (当該ファンクションを bat から実行する形にする)

export async function getComSKEnv() : Promise<string> {

    const comSKID = process.env.ENC_ID;
    const encKey = process.env.ENC_KEY;

    let connect: mysql.Connection | undefined;
    let sqlFormatTxt: string;

    try {

        connect = connectionDB();

        // 開錠前の USERNAME で T_SKEYS から SK 取得
        sqlFormatTxt = mysql.format(
            "select T1.PARAM1 from T_PARAMETER T1 " +
            " where T1.PARAMID = ?  ", [comSKID]);
        let data1 = await selectDBSec(connect, sqlFormatTxt);

        const comSK = data1[0].PARAM1;

        if ( !comSK || typeof comSK !== "string" ) {
            console.error( 'ENCRIPTION KEY is not defined');
            return '';
        } else if ( !encKey || typeof encKey !== "string" ) {
            console.error( 'ENV KEY is not defined');
            return '';
        }

        return decrypt(comSK, encKey);

    } catch(error) {

        console.error('Error:', error);
        throw error;
        
    } finally {

        if ( connect) disconnectFromMySQL(connect);
    }

}


export async function getSK(ID: string) {

    let connect: mysql.Connection | undefined;
    let sqlFormatTxt: string;

    const comSK = await getComSKEnv();
    const decID : string = decrypt(ID, comSK);

    try {

        connect = connectionDB();

        // 開錠前の USERNAME で T_SKEYS から SK 取得
        sqlFormatTxt = mysql.format(
            "select T1.SECRETKEY from T_SKEYS T1 " +
            " where T1.ID = ?  ", [decID]);
        let data1 = await selectDBSec(connect, sqlFormatTxt);

        const SK = decrypt(data1[0].SECRETKEY, comSK);
        if ( typeof SK !== "string" ) throw new Error("認証キーが不正です");
        return SK;

    } catch(error) {

        console.error('Error:', error);
        throw error;
    } finally {

        if ( connect) disconnectFromMySQL(connect);
    }

};

export async function decUserName(ID: string) {

    let connect: mysql.Connection | undefined;
    let sqlFormatTxt: string;

    if (!ID) return '';

    const comSK = await getComSKEnv();
    const decID : string = decrypt(ID, comSK);

    try {

        connect = connectionDB();

        // 開錠前の USERNAME で T_SKEYS から SK 取得
        sqlFormatTxt = mysql.format(
            "select T1.USERNAME_E, T1.SECRETKEY from T_SKEYS T1 " +
            " where T1.ID = ?  ", [decID]);
        let data1 = await selectDBSec(connect, sqlFormatTxt);

        const SK = decrypt(data1[0].SECRETKEY,comSK);
        const encUSERNAME = data1[0].USERNAME_E;
        let rslt = '';
        if ( typeof SK !== "string" ) throw new Error("認証キーが不正です");

        if ( typeof encUSERNAME === "string" 
            && encUSERNAME.length !== 0 ) rslt = decrypt(encUSERNAME, SK)
        
        return rslt;

    } catch(error) {

        console.error('Error:', error);
        throw error;
    } finally {

        if ( connect) disconnectFromMySQL(connect);
    }
}

8. app/crypt.ts

引数で受け取った文字列とシークレットキーを使って、暗号化、あるいはデコードを行う処理を記載しています。

import * as AES from 'crypto-js/aes';
import * as Utf8 from 'crypto-js/enc-utf8';
import SHA256 from 'crypto-js/sha256'; 

export const encrypt = ( plainText: string, SECRET_KEY: string ): string => {

    try {
        const key = SHA256(SECRET_KEY);
        const enprypted = AES.encrypt(plainText, key.toString());
        return enprypted.toString();
    } catch(error) {
        console.error("Decryption error: ", error);
        throw new Error("Failed to decrypt data.");
    }
}

export const decrypt = ( cipherText: string, SECRET_KEY: string ): string => {
    try {
        const key = SHA256(SECRET_KEY);
        const decrypted = AES.decrypt(cipherText, key.toString());
        return decrypted.toString(Utf8);
    } catch(error) {
        console.error("Decryption error: ", error);
        throw new Error("Failed to decrypt data.");
    }
}

9. 認証機能関連テーブル構成

テーブル名:T_USERS

ユーザ情報を格納するテーブル

項目 属性 概要
USERID int / PK ユーザID
USERNAME varchar ユーザ名
PASSWORD varchar パスワード
MAILADD varchar メールアドレス
LGINFAIL int ログイン失敗回数
LGINTIME datetime 直近のログイン日時
PASSSETDATE datetime パスワードが設定された日時
RESETFLG tinyint パスワードリセットボタンをされているフラグ
テーブル名:T_SKEYS

シークレットキーを格納するテーブル

項目 属性 概要
ID varchar / PK シークレットキーを管理するID
SECRETKEY varchar シークレットキー
USERNAME_E varchar シークレットキーを使っているUSERNAME(Enc済み)
MAKETIME datetime シークレットキーが作成された日時
テーブル名:T_PARAMETER

パラメータを格納するテーブル

項目 属性 概要
PARAMID varchar / PK パラメータを管理するID
PARAMNAME varchar パラメータ名
PARAM1 varchar パラメータ設定項目1
PARAM2 varchar パラメータ設定項目2
PARAM3 varchar パラメータ設定項目3
PARAM4 varchar パラメータ設定項目4
PARAM5 varchar パラメータ設定項目5
DESCNT varchar 備考欄

💡 実装上の工夫点

  • dotenv を使って、共通の秘密鍵は .env から読み込みのと同時に、セッションごとにシークレットキーを渡すことで、第三者からの情報の読み取りをより難しくするようにしました。
  • 設定した文字列(UserName や Passwod など)は AES でハッシュ化して、送付やDBに保存などを行います。特にパスワードについては暗号化したものを保存しています。
  • Express ミドルウェアで認証済みAPIを簡潔に実装しています。

📝 実装しながら得られた学び

  • 「トークンの漏洩=全アクセス許可」になるため、扱いには注意が必要です。ただし、今回作成したものでは「シークレットキー」も持たないと情報の引き出しはできないので「トークンの漏洩≠機密性の崩壊」とはならないようにしています。
  • なお、上記のバックエンドでは共通の暗号化キーは2重にしています。つまり、.envに設定している共通鍵(1)を使って、DB内に各種処理で使用する共通鍵(2)を暗号化しています。これにより、環境の再起動を行わずに、DB内の共通鍵(2)を定期的に変更できるようにしています。
  • 「JWTはどうやって成否を判断しているのか」が良くわかっていませんでしたが、結局、デコードするだけだったんだというのに最近気づきました。。。

📁 GitHub リポジトリ

実装コードはこちらに公開しています👇
🔗 https://github.com/tomox2x2/01_portal.git


🔗 参考記事


✏️ 最後に

本記事は、自分が「初めて」JWTを実装した際のメモとしてまとめたものです。
まだ改善の余地があるかもしれませんので、脆弱性や改善点などのご指摘があればぜひお願いします! 🙇‍♂️


Discussion