【初心者向け】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
🧩 各モジュールの概要
index.ts
1. 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}`);
});
router/index.ts
2. 全ルートをまとめてエクスポートするエントリーポイント。
機能別のルーターを設定しています。
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;
router/format.ts
3. 当該アプリの各種フォーマットを行う処理を載せています。
今回は、シークレットキーの付与を行う処理のみを記載しています。
シークレットキーの作成はランダム関数を使って出力しています。
上記既述のとおりですが、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;
router/chkLogin.ts
4. ログイン情報( 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;
router/tokenVerification.ts
5. 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;
helper/jwtHelper.ts
6. 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"));
}
}
app/sk.ts
7. 各種シークレットキーに関連する処理を格納しています。
共通暗号化キーと、ログイン時に設定されるシークレットキーをそれぞれ出力する機能や、シークレットキーを使って 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);
}
}
app/crypt.ts
8. 引数で受け取った文字列とシークレットキーを使って、暗号化、あるいはデコードを行う処理を記載しています。
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