はじめに
この記事は「その1. XserverにExpressサーバを立ててgitで更新出来るようにする」に続けて書いています。
目的
自社のFileMaker CloudデータベースにWebからアクセスするためのAPIを作成する必要があり,そのために勉強したことの備忘録として step by step で作成の過程を書いていきます。想定する読者はjavascriptを触ったことはあるけれども,本格的なバックエンド開発の経験は無い,というような方です。
この記事では
- MySQLでのデータベース・ユーザ作成
- Node.js用のORMであるsequelizeを用いたデータベースアクセスの確立
- JSON Web Tokenを用いたAccess Tokenの生成機能の実装
- Refresh Tokenを用いたAccess Token更新機能の実装
- 上記を用いたサインアップおよびログイン機能の実装
までを行います。
開発環境
node v14.17.4
npm 6.14.14
Step 1. MySQLと連携できるようにする。
さすがに認証無しでFileMaker Cloudにアクセスさせる訳にはいかないので,認証機能を作りたい。認証データはFileMaker側にユーザテーブルを作成してそちらを参照させても良いけど,今回はAPI側データベースを組んで独自に認証をすることにした。XserverではMySQLが利用できるので,ローカルにもMySQL serverを立ち上げ,それを利用して開発を進めていく。
NodeからMySQLへアクセスする場合,アクセス用のモジュールから直接アクセスしてもよいが,最近ではORM(オブジェクト関係マッピング)と呼ばれる手法でデータベースの内容をあたかもオブジェクトのように操作するのが主流のようだ。Node用のORMモジュールとしてはsequelizeが有名で,MySQLにも対応しているためこれを利用して連携させることにした。
なお,Sequelizeについては以下のページを非常に参考にさせて頂いた:
以下,ローカル開発環境にもMySQLがインストールされている前提で話を進める。
1-1. MySQLでデータベースを作成する
文字セットを4バイト対応utf8にするのを忘れずに。
CREATE DATABASE IF NOT EXISTS fmproxy CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
1-2. MySQLにアクセスするためのユーザを作成する
アクセスは上で作成したデータベースのみに限定しておく。
CREATE USER fmproxy@localhost IDENTIFIED BY 'hogehoge';
GRANT ALL PRIVILEGES ON fmproxy.* TO fmproxy@localhost;
1-3. Sequelizeをインストール
npm install sequelize --save
npm install mysql2 --save
sequelizeには,プロジェクトの初期構築やモデル作成,マイグレーション(変更内容のデータベース側への反映)などを効率化するためのツールであるsequelize-cliが用意されている。以前にsequelize-cliを利用したことがあれば基本的にはグローバルインストールされているはずだが,以前インストールしたことがなければ,これもインストールしておこう。
sudo npm install sequelize-cli -g
1-4. 初期構築
プロジェクトのトップディレクトリで以下を実行することで,sequelize-cliを利用した初期構築を行える。
% npx sequelize-cli init
Sequelize CLI [Node: 16.13.0, CLI: 6.3.0, ORM: 6.17.0]
Created "config/config.json"
Successfully created models folder at ".../fmproxy/models".
Successfully created migrations folder at ".../fmproxy/migrations".
Successfully created seeders folder at ".../fmproxy/seeders".
これにより,config
models
migrations
seeders
の4つのフォルダと関連ファイルが自動的に作成される。
/config/config.json
に先ほどMySQL側で設定したデータベース・ユーザ情報を書きこんでおこう。
"development": {
"username": "fmproxy",
"password": "hogehoge",
"database": "fmproxy",
"host": "localhost",
"dialect": "mysql"
}
Step 2. エラー処理の作成
今後色々と機能を実装していくにあたり,当然ながらエラー処理が必要となってくる。Expressにはデフォルトでエラーハンドラが用意されており,next()
にエラーオブジェクトなどを入れて実行すると自動的にエラーハンドラに処理が移るようになっている。
ただ,発生したエラーの種類によってステータスコード(404 Not Found
とか500 Internal Server Error
とか)を変える必要があるけど,デフォルトのError
クラスには当然ながらその情報はない。よってステータスコードの情報を持つ子クラスを作成し,それをエラーハンドラに渡すことでエラー処理を簡便化する。以下を参考にさせて頂いた。
2-1. エラー処理用モジュールを作成する
まず,/my_modules
ディレクトリを作成し,customerror.js
という名称で以下の内容のファイルを作成する。
module.exports = class CustomError extends Error {
constructor(message, statusCode) {
super(message)
this.statusCode = statusCode
}
}
これによりnew CustomError( メッセージ , ステータスコード )
という形でステータスコード情報を持つエラーオブジェクトを生成できる。
2-2. エラーハンドラの実装
server.js
側にエラーハンドラを記述する。同時に,存在しないエンドポイントへアクセスがあった場合に返す404
エラーの処理も作成しておこう。
require('dotenv').config()
const express = require('express')
const app = express()
+ const CustomError = require('./my_modules/customerror')
const port = 3000
app.get('/', (req, res, next) => {
models.sequelize.authenticate()
.then(() => {
res.json({
status: "OK",
mode: process.env.NODE_ENV,
database: "OK"
})
})
- .catch(next)
+ .catch((err) => {
+ return next(new CustomError(err.message, 500))
+ })
})
// 404 Error
app.use((req, res, next) => {
next(new CustomError('Endpoint not found.', 404))
})
// Error handler
app.use((err, req, res, next) => {
res.status(err.statusCode || 500).json({message: err.message})
})
app.listen(port, () => {
console.log(`App listening on port ${port}`)
})
const CustomError = require('./my_modules/customerror')
で先ほど作成したオリジナルのエラークラスを使えるようにしている。
404エラーは全てのルーティング処理の最後に記述する。要求されたエンドポイントに合致する記述があるか,上から順番に処理されていくので,どこにもなければ最後に処理されるイメージ。new CustomError('Endpoint not found', 404)
で,メッセージとステータスコードを持ったエラーオブジェクトを生成し,next( ... )
でエラーハンドラに渡している。
エラーハンドラはさらにその後に記述する。エラーハンドラは通常のreq
res
next
にerr
を加えた4つの引数を持つのが特徴。このerr
にnext( ... )
で渡されたデータが入っている。先ほどのように,作成したオリジナルのエラーオブジェクトが渡されればstatusCode
にコードが入っているはずだが,他のエラーオブジェクトが渡されてきた場合にはstatusCode
は設定されていないので,その場合500
エラーを返すようにしている。
試しにcurl
から存在しないエンドポイントに要求を送ってみる。
% curl -w'\n' localhost:3000/hoge -i
HTTP/1.1 404 Not Found
X-Powered-By: Express
Access-Control-Allow-Origin: localhost
Vary: Origin
Content-Type: application/json; charset=utf-8
Content-Length: 33
ETag: W/"21-nmb0LeFULIVnnqpLjeS8DvrEM9U"
Date: Thu, 31 Mar 2022 07:53:55 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"message":"Endpoint not found."}
しっかり404
のステータスコードと,設定したエラーメッセージも返ってきていることがわかる。
Step 3. Sequelizeを用いたデータベース接続の下準備
squelizeを用いてMySQLにアクセスしていくための準備を行っていこう。
3-1. データベースへのテスト接続
SequelizeのAPI referenceにある接続テスト用のスニペットを流用して,エンドポイント/
にアクセスした際にデータベースへの接続をテストして結果を返すようにしてみる。
// 省略
const models = require('./models')
// 省略
app.get('/', (req, res, next) => {
models.sequelize.authenticate()
.then(() => {
res.json({
status: "OK",
mode: process.env.NODE_ENV,
database: "OK"
})
})
.catch((err) => {
return next(new CustomError(err.message, 500))
})
})
// 省略
まずconst models = require('./models')
でsequelizeのモデルファイルを読み込んでいる。
models.sequelize.authenticate()
ではデータベースへのアクセスを試みる。アクセスが成功するとpromiseがresolveされるので.then
以降に処理が移る。一方,アクセスが失敗するとpromiseがrejectされエラーオブジェクトが返ってくるため,.catch()
以降に処理が飛ぶ。
.catch()
の中では,sequelizeから返ってきたエラーのメッセージとステータスコード500
を持ったオリジナルのエラーオブジェクトを作成し,next()
でエラーハンドラに渡している。
では実際にアクセスしてみよう。
% curl -w'\n' localhost:3000 -i
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: localhost
Vary: Origin
Content-Type: application/json; charset=utf-8
Content-Length: 52
ETag: W/"34-2bTadweWNuVflDGQThwOzfGqvwU"
Date: Thu, 31 Mar 2022 05:56:27 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"status":"OK","mode":"development","database":"OK"}
きちんとデータベースの状態が返ってきている。
試しにMySQLを終了させてから同様にアクセスしてみる。
% mysql.server stop
Shutting down MySQL
.... SUCCESS!
% curl -w'\n' localhost:3000 -i
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Access-Control-Allow-Origin: localhost
Vary: Origin
Content-Type: application/json; charset=utf-8
Content-Length: 49
ETag: W/"31-5CVYqfnjJAdE4clShc2M9RTb25E"
Date: Thu, 31 Mar 2022 08:17:25 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"message":"connect ECONNREFUSED 127.0.0.1:3306"}
ちゃんとエラーハンドリングされている。
3-2. モデルを作成してマイグレーションする
MySQLに作成したデータベースには,認証システムで使用するユーザ情報を保存するためのテーブルを作成する必要がある。通常であればMySQL側でテーブルを作成していくが,sequelizeではまずオブジェクト操作とデータベースへの接続を変換するためのモデルファイルを作成する。さらにモデルに応じてデータベース側の構成を変更するためのマイグレーションファイルを作成し,これを元にマイグレーションを実行することでデータベース側を操作する。
まずsequelize-cliを利用してモデルを作成しよう。モデルには,そのテーブルがどんな項目を持つかを指定しなければならない。今回は
- username : ログイン用のユーザー名
- email : メールアドレス
- password : パスワード(ハッシュで登録)
- privilege : ユーザ権限(0:一般ユーザ,1:管理ユーザ,2:アドミニストレータ)
とした。(項目は後から追加や変更が可能)
% npx sequelize-cli model:generate --name User --attributes username:string,email:string,password:string,privilege:integer
Sequelize CLI [Node: 16.13.0, CLI: 6.3.0, ORM: 6.17.0]
New model was created at /Users/.../fmproxy/models/user.js .
New migration was created at /Users/.../fmproxy/migrations/yyyymmddhhmmss-create-user.js .
これにより,models
ディレクトリにuser.js
が,migrations
ディレクトリにマイグレーションファイルが自動的に作成された。
sequelize-cliでモデルを作成すると,作成されるテーブルには引数で指定した項目以外にid
(主キー),createdAt
(作成日),updatedAt
(変更日)が自動で追加される。しかしデフォルトで作成されるidは整数値になっているので,セキュリティの観点からUUIDに変更しておく。参考:
まずモデルファイルだが,デフォルトではid
の項目は記述されていない(ユーザがアクセスすることを想定していないため?)ので,記載を追加しておく。
// 省略
User.init({
+ id: {
+ primaryKey: true,
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4
+ },
username: DataTypes.STRING,
email: DataTypes.STRING,
password: DataTypes.STRING,
privilege: DataTypes.INTEGER
}, {
sequelize,
modelName: 'User',
});
// 省略
primaryKey: true
でこの項目が主キーであることを宣言し,type: DataTypes.UUID
でデータタイプを,defaultValue: DataTypes.UUIDV4
で初期値としてuuidを生成するよう指定している。
次にマイグレーションファイルも変更しておこう。
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('Users', {
id: {
allowNull: false,
- autoIncrement: true,
primaryKey: true,
- type: Sequelize.INTEGER
+ type: Sequelize.UUID,
+ defaultValue: sequelize.UUIDV4
},
// 省略
}
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('Users');
}
};
こちらには元々id
の項目が記述されているので,これを変更していく。
ファイルを保存したらターミナルからマイグレーションを行おう。
% npx sequelize-cli db:migrate
これにより,MySQL側にテーブルが作成される。ビューワで覗いてみると
ちゃんと出来てる♪ なお細かいことだが,上で作成したsequelizeのモデル名はUser
であったのに,MySQLに作成されたテーブル名はUsers
と複数形になっている。どうやらsequelizeが自動的にお節介を焼いてくれるようだ。
ともあれ,これでUser
モデルを経由してデータベースにアクセスするための準備が整った。
3-3. ルータを使用できるようにしておく
認証システムを作成してゆく前に,今後エンドポイントを多数作るのでexpress.Routerを利用してモジュール形式でルーティング処理をできるようにしておく。
まずroutes
ディレクトリを作成し,users.js
ファイルを作成する。
require('dotenv').config()
const express = require('express')
const router = express.Router()
const CustomError = require('../my_modules/customerror')
const models = require('../models')
module.exports = router
モジュールファイルには,環境変数や上で作成したエラー処理用カスタムクラスのモジュール,sequelizeアクセス用のモジュールなどを読み込んでおく。ルートディレクトリより1つ深いディレクトリにファイルを置いているので,参照が./hogehoge
でなく../hogehoge
になることに注意。
次にserver.jsに以下の記述を追加して,/users
以下への要求は上で作成したusers.js
で処理するようにする。
require('dotenv').config()
const models = require('./models')
const express = require('express')
const app = express()
const port = 3000
+ const users = require('./routes/users')
app.get('/', (req, res, next) => {
models.sequelize.authenticate()
.then(() => {
res.json({
status: "OK",
mode: process.env.NODE_ENV,
database: "OK"
})
})
.catch(next)
})
+ app.use('/users', users)
app.use((req, res, next) => {
next(new CustomError('Endpoint not found.', 404))
})
app.use((err, req, res, next) => {
res.status(err.statusCode || 500).json({ message: err.message })
})
app.listen(port, () => {
console.log(`App listening on port ${port}`)
})
app.use('/users', users)
は404エラーハンドラの前に記述すること。
以後,/users
以下のエンドポイントに対する処理は全てusers.js
に記述していく。
3-4. その他
自サーバ以外からのアクセスを遮断するよう,CORSミドルウェアを設定しておこう。また今後JSON形式のリクエストを処理してゆくので,JSON処理用のミドルウェアも使用するよう設定する。これらは全てのアクセスに共通する部分なので,メインファイルであるserver.js
に記述する。
% npm install cors --save
require('dotenv').config()
const models = require('./models')
const express = require('express')
+ const CustomError = require('../my_modules/customerror')
+ const cors = require('cors')
const app = express()
const port = 3000
+ const corsOptions = {
+ origin: 'localhost',
+ optionsSuccessStatus: 200
+ }
const users = require('./routes/users')
+ app.use(cors(corsOptions))
+ app.use(express.json())
// 省略
Step.4 サインアップとログインの作成
それでは準備が整ったので,早速エンドポイントを作成していこう。まずはサインアップとログインから。
4-1. サインアップ
送信されたユーザー名,メールアドレス,パスワード,権限を元に,データベースにデータを追加する。
仕様
Method/end-point
POST /users
Request Headers
Content-type : application/JSON
Request Body
username (string) : ユーザ名(必須)
email (string) : メールアドレス(必須)
password (string) : パスワード(必須)
privilege (integer) : 権限(0:一般ユーザ,1:管理ユーザ,2:アドミニストレータ。1, 2以外を指定すると0となる。)
Status Code
200 OK : 正常に登録された
400 Bad Request : リクエスト不正
409 Conflict : ユーザ名が既に使用されている
500 Internal Server Error : サーバ処理異常
Response JSON object
id (string) : 主キー
email (string) : メールアドレス
privilege (integer) : 権限
username (string) : ユーザ名
updatedAt (string) : ユーザ更新日時
createdAt (string) : ユーザ作成日時
考え方と下準備
基本的にはPOSTメソッドで渡されたユーザ名,メールアドレスの重複チェックを行い,問題なければそれらとハッシュ化したパスワードをsequelizeで書き込めばOK。
ハッシュ化はNode.jsのデフォルトモジュールであるcrypto
を用いても良いが,セキュリティ的に耐性が低いということなのでbcrypt
モジュールを使用する。
% npm install bcrypt --save
コード
/* 追加モジュール */
const bcrypt = require('bcrypt')
/* 定数設定 */
const salt = bcrypt.genSaltSync(10)
/* メイン処理 */
router.post('/', (req, res, next) => {
// リクエストボディの検証
if (!Object.keys(req.body).length) return next(new CustomError('Request is invalid.', 400))
if (!req.body.username || !req.body.email || !req.body.password) return next(new CustomError('Username, email and password are mandatory.', 400))
// データの準備
if (req.body.privilege !== 1 && req.body.privilege !== 2) req.body.privilege = 0
const newPasswordHash = bcrypt.hashSync(req.body.password, salt)
// データベースへ登録
models.User.findOrCreate({
where: { username: req.body.username },
defaults: { email: req.body.email, password: newPasswordHash, privilege: req.body.privilege }
}).then(([userInst, created]) => {
if (!created) return next(new CustomError('The username already exists.', 409))
let resJSON = { ...userInst.dataValues }
delete resJSON.password
res.json(resJSON)
}).catch((err) => {
return next(new CustomError(err.message, 500))
})
})
まずルート処理の外であらかじめbcrypt
モジュールを読み込み,ソルトを作成している。
const bcrypt = require('bcrypt')
const salt = bcrypt.genSaltSync(10)
メインのルート処理を解説していく。
まずはじめにリクエストボディを検証する。
if (!Object.keys(req.body).length) return next(new CustomError('Request is invalid.', 400))
if (!req.body.username || !req.body.email || !req.body.password) return next(new CustomError('Username, email and password are mandatory.', 400))
1行目。上の「下準備」でexpress.json()
を使用するように設定したので,ユーザから送信されたリクエストボディは自動的にパースされてreq.body
に格納される。しかしリクエストボディが渡されなかったり,内容がJSONとして不正だったりすると,req.body
は{}
という,空のオブジェクトになる。
オブジェクトが空かどうかの判定は{}との比較では行えないので,キー配列の個数が0(=キーが存在しない)であることで判定する。Object.keys(req.body).length
でキーの個数が返ってくるので,これを!
で論理反転することで「オブジェクトが空である」という判定ができる。
オブジェクトが空の場合,next(new CustomError( ... ))
でエラーハンドラに処理を渡す。ただしこれだけでは以下に書かれたこのエンドポイントの処理を全て実行してからエラーハンドラに移ることになってしまうため,必ずreturn
を付けておく。
結局,処理の中でエラーを返す場合には
if ( エラー判定 ) return next(new CustomError('エラーメッセージ', ステータスコード))
という文で行えば良いことになる。一行で書けてラクチン♪
2行目ではリクエストボディのうち必須項目であるusername
email
password
が揃っているかを判定している。
リクエストボディの形式が問題なければ,次にデータの下準備を行う。
if (req.body.privilege !== 1 && req.body.privilege !== 2) req.body.privilege = 0
const newPasswordHash = bcrypt.hashSync(req.body.password, salt)
1行目では,ユーザ権限の正規化を行っている。privilege
は整数,かつ「0, 1, 2」のいずれかでなければならない。従って,整数値の「1」「2」以外(整数値の「0」も含む)が指定された場合には強制的に「0」にしている。
2行目ではbcrypt.hashSync(req.body.password, salt)
でパスワードからハッシュを作成し,定数に格納している。
データの準備が終われば,いよいよsequelizeを用いてデータを登録する。sequelizeのクエリのうち,findOrCreate
を用いて,ユーザ名の重複チェックと作成を行う。
models.User.findOrCreate({
where: { username: req.body.username },
defaults: { email: req.body.email, password: passwordHash, privilege: req.body.privilege }
})
where: { username: req.body.username }
で送信されたusername
に一致するものがあるか検索し,なければdefaults
に設定した内容でユーザを作成する。
sequelizeでの処理が正常に終了するとthen
以下のPromiseチェーンに処理が移る。
.then(([userInst, created]) => {
if (!created) return next(new CustomError('The username already exists.', 409))
let resJSON = { ...userInst.dataValues }
delete resJSON.password
res.json(resJSON)
})
findOrCreate
からは作成された(もしくは既に存在した)ユーザデータのインスタンスと,新規に作成されたか否かの論理値の2つの戻り値が返る。ここではそれぞれuserInst
created
とし,まずcreated
が偽(=既に存在していた)場合には409
エラーを返している。
重複するユーザがなく新規に作成された場合には,作成されたユーザの情報を返す。res.json(userInst)
とすれば簡単だが,これだとパスワードハッシュまで返してしまうので,別個にパスワード抜きのオブジェクトを作成する。普通にオブジェクトをコピーすると参照のコピーとなってしまうので,スプレッド構文を使って新たなオブジェクトresJSON
を作成する。
その際,{ ...userInst }
とするとインスタンスに含まれる余計なデータまでコピーされてしまうので,{ ...userInst.dataValues }
とすることでデータのみをコピーしている。その上でdelete resJSON.password
でパスワードデータのみ削除し,これをres.json(resJSON)
で返している。
最後に,sequelizeが何らかのエラーを返した場合(MySQLがダウンしているなど)のために.catch(err)
以下でエラー処理を行っている。
.catch((err) => {
next(new CustomError(err.message, 500))
})
早速postmanを使って検証してみよう。
しっかり200 OK
のステータスと作成されたユーザ情報が返っている。
試しにユーザ名やパスワードなどを空欄にしたり,JSONの形式をおかしくして送信すると,ちゃんとエラーが返ってくる
4-2. ログイン作るその前に(Access Token / Refresh Token発行機能の実装)
サインアップを作ったなら次は当然ログイン機能の実装となるのだが,今回,認証・認可には **JWT(JSON Web Token)**ベースのAccess Token / Refresh Tokenを利用することにしているので,先にその発行機能を実装しておこう。
JWTって?
JWTによる認証・認可処理やAccess Token,Refresh Tokenの考え方については以下のページが詳しい。
要は正しくログインできたユーザに「通行手形」となるAccess Tokenを発行するのだが,Access Tokenには暗号化された情報が含まれており,この暗号化情報を含むトークンの一形式がJWT。ユーザにはアクセスするたびにAccess Tokenをヘッダに入れて送信してもらう。サーバ側では送信されたAccess Tokenの暗号化情報を検証し,改竄がなければ認証されたユーザからのアクセスと判断する。ただしこれではもしAccess Tokenが第三者に漏洩した場合そのまま悪用されてしまうので,Access Tokenの有効期限は分単位などの短時間にしておき,万が一漏洩してもすぐ無効になるようにしてある。
Access Tokenが無効になると当然サーバにアクセスしても認証ではねられてしまうので再度のログインが必要になるが,あまり頻繁にログイン操作を要するのではUX上都合が悪い。そこで,Access Tokenが失効した場合にこれを再発行するための「合い言葉」を別に用意しておく。これがRefresh Tokenで,有効期限はAccess Tokenと比べて長い(1日から長い場合には1年など)。Refresh Tokenが漏洩したらどうすんのさ? といえばそれはヤバいよねということになるのだが,前述の通りRefresh TokenはAccess Tokenの更新にしか使われないため,Access Tokenと比べれば圧倒的にネットワークを流れる頻度が低い。よって漏洩のリスクも低いよね!……という発想。
たぶんこの認識はすっっっっっっっっごくアバウトでこのあたりに詳しい人からしたら目眩がするのかもしれないけど,とりあえず実装していこう。今回はAccess Tokenの生成とRefresh Tokenの生成をそれぞれPromiseベースの関数として定義しておき,ログインおよびトークンリフレッシュでそれらを利用する形とする。
Access Tokenの作成
まず,JWTの生成に必要なjsonwebtoken
モジュールをインストールしておく。
$ npm install jsonwebtoken --save
JWTを利用したAccess Tokenの生成に当たってはJWTの署名アルゴリズムと有効時間を設定しなければならない。署名アルゴリズムは公開鍵・秘密鍵方式のRSAや,共通鍵方式のHMACなどから選べるが,今回は暗号化・復号化とも同じサーバで行うので,SHA-256ベースのHMACであるHS256を使用する。HS256で使用する共通鍵としてはハッシュと同じ長さ(=256bit)以上の文字列の使用が推奨されているので,256 ÷ 8 = 32文字以上の英数文字列を決めておかなければならない。Web上には生成ツールが色々公開されているので,そうしたツールを使って適当に生成しておこう。
また,Access Tokenの有効時間は前述したように,もし漏洩したとしてもリスクが少なくなるよう短めに設定する必要がある。これを何分にするかは取り扱うデータの秘匿性などにもよるだろうけど,とりあえず今回は30分としてみる。
これらは環境変数に記述しておき,そこから読み取る。
JWT_SECRET=HCujmweMScV3DwZKtrE7RG7sE8bXDjVy
ACCESS_TOKEN_DURATION_MINUTE=30
ではコード。
/* 追加モジュール */
const jwt = require('jsonwebtoken')
/* 定数設定 */
const jwtSecret = process.env.JWT_SECRET
const jwtOptions = {
algorithm: 'HS256',
expiresIn: process.env.ACCESS_TOKEN_DURATION_MINUTE + 'm'
}
/* メイン処理 */
const generateAccessToken = (user) => {
return new Promise((resolve, reject) => {
const jwtPayload = {
id: user.id,
privilege: user.privilege
}
try {
const accessToken = jwt.sign(jwtPayload, jwtSecret, jwtOptions)
const exp = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64').toString()).exp
const accessTokenExpiryDate = new Date(exp * 1000)
resolve({
accessToken: accessToken,
accessTokenExpiryDate: accessTokenExpiryDate.toISOString()
})
} catch (err) {
reject(err)
}
})
}
まずjsonwebtoken
モジュールを読み込んでおく。
続いてJWT生成に必要な情報を定数に読み込んでいる。jwtSecret
は.env
に記述した共通鍵文字列をそのまま代入。jwtOptions
はJWT生成時に使用するオプションをJSON形式で指定する。algorithm
は指定しなければ自動的にHS256
になるようだけど,一応明示的に指定。expiresIn
は有効時間で,数値で指定すると秒単位となり,文字列であれば30分ならば'30m'
のように単位とともに指定する。.env
から読み込んだ有効時間は文字列として渡されるので,今回はそのまま'm'
を付けて分指定とした。
実際に呼び出されるgenerateAccessToken
関数は,引数としてsequelizeが返してくるユーザモデルのインスタンス(user
)が渡されることを想定している。新規にPromiseを生成し,JWTのペイロードとしてユーザのid
情報とprivilege
情報を設定する。
jwt.sign( ペイロード, 鍵文字列, オプション )
でJWTが生成される。生成されたJWTのペイロードには生成時刻(iat
)と有効期限(exp
)が自動的に書き込まれるが,形式がUNIX時間なので可読性が悪い。そこで,トークンとは別に有効期限情報を渡すことにする。
const exp = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64').toString()).exp
const accessTokenExpiryDate = new Date(exp * 1000)
JWTは.
で区切られた文字列で,ペイロードはその2番目の要素。そこでaccessToken.split('.')[1]
でペイロードのみを取り出し,Buffer.from( ペイロード, 'base64').toString()
で文字列に変換。これをJSONとしてパースした上でexp
要素を取り出している。取得したexp
は前述のようにUNIX時間だが,単位が秒でありJavaScriptでDate
オブジェクトを扱う際のミリ秒と異なるので,1000倍してDate
オブジェクトに変換している。
最後にresolve()
で生成が成功した際の結果を返している。
resolve({
accessToken: accessToken,
accessTokenExpiryDate: accessTokenExpiryDate.toISOString()
})
上記の生成過程はtry { ... }
節に入れてあるので,エラーが発生した場合にはcatch
節で拾い,reject(err)
として結果を返している。
catch (err) {
reject(err)
}
Refresh Tokenの生成
Refesh Tokenは上の説明でも書いたように,Access Tokenが失効した際に再発行するためのもの。アクセスのたびにネットワークを流れるAccess Tokenと違い,一定時間ごとにしかネットワークを流れないため漏洩リスクは低い。とはいえ,Refresh Tokenの有効期限はAccess Tokenに比べ長時間に設定されるので,いざRefresh Tokenが漏洩するとそのセキュリティ上の脅威はAccess Tokenの漏洩よりも高いと考えたほうが良い。そのため,Refresh Tokenの運用ポリシについてはいろいろと検討する必要がある。
- Refresh Tokenの形式 単なるUUID文字列にするのか? 暗号化署名付きのトークンにするか?
- リフレッシュ実行時の挙動
- Refresh Tokenは再発行するのか? それとも既存のものを再利用させるのか?
- 有効期限はリセットする(延ばす)のか? それとも元々の期限を保持するのか?
- 漏洩が疑われるRefresh Tokenをブラックリストで管理するか?
このあたりは構築するサービスのセキュリティポリシに関わるところなので何が正解ということはないが,しっかり考えておく必要があるようだ。ひとまず今回は形式についてはUUIDとし,リフレッシュ実行時の挙動は環境変数から後で変更可能とすることにした。
まずUUIDを扱うため,uuid
モジュールをインストールしておく。
$ npm install uuid --save
.env
ファイルに以下の内容を追加しておく。
REFRESH_TOKEN_DURATION_MINUTE=1440
REFRESH_TOKEN_NEW_TOKEN=true
REFRESH_TOKEN_RESET_EXP=false
REFRESH_TOKEN_DURATION_MINUTE
は分単位の有効時間。今回は1440分 = 1日とした。
REFRESH_TOKEN_NEW_TOKEN
はAccess Tokenの再発行要求があった際,使用したRefresh Tokenを無効化して新たなRefresh Tokenを発行し直すかどうか。再発行せずに以前のものを有効としておく場合にはfalse
にする。
REFRESH_TOKEN_RESET_EXP
はAccess Tokenの再発行要求があった際,それまでのRefresh Tokenの有効期限をリセットし,再発行時点を起点として新たに有効期限を延長するかどうか。延長せずに以前の有効期限で期限切れにさせる場合にはfalse
にする。
さて,Refresh Tokenを単純なUUIDにするということは,サーバ側でユーザ情報と紐づけてRefresh Tokenの情報を保存しておかなければならない。そこで,まずsequelizeで新しくRefreshToken
モデルを作成し,それをUser
モデルと関連させる。
$ npx sequelize-cli model:generate --name RefreshToken --attributes userId:uuid,token:uuid,exiryDate:date
RefreshToken
の情報はUser
の情報に1対1で対応するリレーションになるが,sequelizeではモデルにこうしたリレーションの情報を持たせることができる。リレーションにはには1対1 One-to-One,1対多 One-to-Many,多対多 Many-to-Manyがあるが,sequelizeはその全てに対応するためHasOne
BelongsTo
HasMany
BelongsToMany
の4種類の関連 Assocciation情報をモデルに設定することができる。
今回であればユーザ1人が持つRefresh Tokenは1つだけのはずなので,1対1のリレーションとなる。したがって,参照元(ソース source モデルと呼ばれる)であるUser
モデルと,参照先(ターゲット target モデルと呼ばれる)であるRefreshToken
モデルとの間に,以下のような関係を設定する必要がある。
User.hasOne(RefreshToken)
RefreshToken.belongsTo(User)
2モデル間を繋ぐため,RefreshToken
側にはUser
モデルの主キーを外部キーとして持っておかなければならない。実は上でsequelize-cliでモデルを作成した際に作成しておいたuserId
という項目はこの外部キーを保存する想定で作成していたのだが,作成しただけではsequelize側が「これは外部キーの項目だな」と分かろうはずもない。なので,モデルファイルをいじくって「userId
に外部キーが保存されていますよ」という情報も教える必要がある。
では,これを踏まえて元々あったUser
モデルの定義ファイル(/models/user.js
)と,今回新たに作成したRefreshToken
モデルの定義ファイル(/models/refreshtoken.js
),マイグレーションファイル(/migrations/yyyymmddhhmmss-create-refresh-token.js
)を修正していこう。
static associate(models) {
// define association here
+ User.hasOne(models.RefreshToken, {
+ foreignKey: 'userId'
+ })
}
static associate(models) {
// define association here
+ RefreshToken.belongsTo(models.User, {
+ foreignKey: 'userId',
+ onDelete: 'CASCADE'
+ / })
}
userId: {
type: Sequelize.UUID,
+ onDelete: 'CASCADE',
+ references: {
+ model: 'Users',
+ key: 'id'
+ }
},
モデルファイルには先ほど示したhasOne
belongsTo
と,foreignKey
の設定をそれぞれ記載している。加えて,ターゲットモデルであるRefreshToken
モデルの方には,ソースモデルのUser
側でデータが削除された際,関連するRefreshToken
側のデータをどう処理するかの設定をonDelete: 'CASCADE'
として設定している。これはMySQLの設定と同じで,ソースモデルのデータが削除された場合,ターゲットモデル側のデータも削除する,ということ。onDelete: 'RESTRICT'
ならエラーが返るし,onDelete: 'SET NULL'
ならRefreshToken
側のuserId
がnull
になる。
マイグレーションファイルでは,外部キーとして使用するuserId
の設定にonDelete
の内容とreferences
として連携先のモデル名および主キー列名を追加している。
修正を終えたらマイグレーションを実行しておこう。
$ npx sequelize-cli db:migrate
ではこれで準備が整ったので,実際のコードを書いてみる。
/* 追加モジュール */
const { v4: uuidv4 } = require('uuid')
/* 定数設定 */
const refreshTokenDurationMinute = parseInt(process.env.REFRESH_TOKEN_DURATION_MINUTE)
const newToken = !!(process.env.REFRESH_TOKEN_NEW_TOKEN.toLowerCase() === 'true')
const resetExp = !!(process.env.REFRESH_TOKEN_RESET_EXP.toLowerCase() === 'true')
/*メイン処理*/
const generateRefreshToken = (user, newToken, resetExp) => {
return new Promise((resolve, reject) => {
models.RefreshToken.findOrCreate({
where: { userId: user.id }
}).then(([refreshTokenInst, created]) => {
// 新規トークン発行?
if (newToken) refreshTokenInst.set({ token: uuidv4() })
// 有効期限リセット?
if (resetExp) {
refreshTokenExpiryDate = new Date()
refreshTokenExpiryDate.setSeconds(refreshTokenExpiryDate.getSeconds() + 60 * refreshTokenDurationMinute)
refreshTokenInst.set({ expiryDate: refreshTokenExpiryDate})
}
return refreshTokenInst.save()
}).then((updatedInstance) => {
resolve({
refreshToken: updatedInstance.token,
refreshTokenExpiryDate: updatedInstance.expiryDate.toISOString()
})
}).catch((err) => {
reject(err)
})
})
}
順に見ていこう。まず処理に使用するuuid
モジュールを読み込む。
const { v4: uuidv4 } = require('uuid')
次に定数設定。
const refreshTokenDurationMinute = parseInt(process.env.REFRESH_TOKEN_DURATION_MINUTE)
const newToken = !!(process.env.REFRESH_TOKEN_NEW_TOKEN.toLowerCase() === 'true')
const resetExp = !!(process.env.REFRESH_TOKEN_RESET_EXP.toLowerCase() === 'true')
1行目,.env
に記述したREFRESH_TOKEN_DURATION_MINUTE
はAccess Tokenの際にも指摘したように文字列として渡される。Access TokenのときはJWTオプションとしてそのまま文字列で利用するので良かったが,今回は後で見るように整数値として計算に使用しなければならない。そこでparseInt()
で整数リテラルに変換した上で定数に代入している。
2行目・3行目は「Refresh Tokenを再発行するか」(REFRESH_TOKEN_NEW_TOKEN
)と「有効期限をリセットするか」(REFRESH_TOKEN_RESET_EXP
)のフラグに関する処理だが,これらもやはり文字列として渡されるので,論理値に変換して定数代入する必要がある。今回は「TRUE」「True」「true」などであればtrue
,それ以外はfalse
に解釈するようにすべく,toLowerCase()
で小文字変換した文字列がtrue
と一致するかを見ている。このままでは論理値として解釈されないので,二重否定!!
で論理型に変換してから定数代入している。
ではメインの関数。
const generateRefreshToken = (user, newToken, resetExp) => {
return new Promise((resolve, reject) => {
models.RefreshToken.findOrCreate({
where: { userId: user.id }
}).then(([refreshTokenInst, created]) => {
// 新規トークン発行?
if (newToken) refreshTokenInst.set({ token: uuidv4() })
// 有効期限リセット?
if (resetExp) {
refreshTokenExpiryDate = new Date()
refreshTokenExpiryDate.setSeconds(refreshTokenExpiryDate.getSeconds() + 60 * refreshTokenDurationMinute)
refreshTokenInst.set({ expiryDate: refreshTokenExpiryDate})
}
return refreshTokenInst.save()
}).then((updatedInstance) => {
resolve({
refreshToken: updatedInstance.token,
refreshTokenExpiryDate: updatedInstance.expiryDate.toISOString()
})
}).catch((err) => {
reject(err)
})
})
}
まず関数はユーザモデルのインスタンスuser
に加え,Token発行に関する2つの設定値newToken
resetExp
を論理値で引数にとる形にしている。
Access Tokenと同様Promiseを返す関数を定義し,まずmodels.RefreshToken.findOrCreate({ where: { userId: user.id } })
で,引数に渡されたユーザインスタンスのid
カラムを元に,RefreshToken
の該当するデータが無いか探す。見つかったか,もしくは該当が無く新規に作成されたRefreshToken
モデルのインスタンスが返ってくるので,.then()
節の関数にrefreshTokenInst
として渡している。
.then()
節では,発行に関する設定に応じてインスタンスのデータを更新する。データの更新は「インスタンスに新しいデータをセット(set
)」→「インスタンスを保存(save
)」という2段階で行う方法と,一気にアップデートする(update
)方法の2つがある。どちらでやっても構わないけど,今回は設定に応じて書き換えるデータ,書き換えないデータがあるので,2段階で行う方法をとった。
まずnewToken
がtrue
であれば,token
に新しいUUID文字列をセットする。データのセットはset({ データ列名 : 新しいデータ })
の形で行う。
次にresetExp
がtrue
であれば,exipryDate
に新しい有効期限をセットする。Access Tokenの際は生成したトークンに自動的に有効期限の情報が記載されたのでそれを読み込んでいたが,今回のRefresh Tokenは単なるUUID文字列なので,自分で有効期限を計算しなければならない。このため,new Date()
で現在時刻のDateオブジェクトを作成し,setSeconds
で現在のUNIX時間(秒単位)に有効時間(分単位)× 60を足した数値を設定することで,有効期限を表すDateオブジェクトにしている。
データのセットが終わっただけでは「これからデータをこう変更しますよ」というだけで実際のデータベースには反映されないので,refreshTokenInst.save()
でデータベースへの反映を行う。これにより保存された(=アップデートされた)インスタンスが返ってくるので,これを次の.then()
節に回している。
最後にアップデートされたインスタンスからRefresh Tokenと有効期限の情報を読み取ってPromiseの成功結果としてresolve
する。一方,ここまでのPromiseチェーンの中でエラーが発生した場合には.catch(err)
節にerr
としてエラーが返ってくるので,その場合には失敗結果としてそのままreject
している。
4−3. ログイン
さてAccess Token / Refresh Tokenの生成を実装したので,ようやくログイン機能を実装できる。
仕様
Method/end-point
POST /users/login
Request Headers
Content-type : application/JSON
Request Body
username (string) : ユーザ名(必須)
password (string) : パスワード(必須)
Status Code
200 OK : 正常にログインされた
400 Bad Request : リクエスト不正
401 Unauthorized : パスワードが一致しない
404 Not Found : ユーザが存在しない
500 Internal Server Error : サーバ処理異常
Response JSON object
id (string) : 主キー
email (string) : メールアドレス
privilege (integer) : 権限
username (string) : ユーザ名
updatedAt (string) : ユーザ更新日時
createdAt (string) : ユーザ作成日時
accessToken (string) : Access Token
accessTokenExpiryDate (string) : Access Tokenの有効期限
refreshToken (string) : Refresh Token
refreshTokenExpiryDate (string) : Refresh Tokenの有効期限
コード
router.post('/login/', (req, res, next) => {
// リクエスト検証
if (!Object.keys(req.body).length) return next(new CustomError('Request is invalid.', 400))
if (!req.body.username || !req.body.password) return next(new CustomError('Username and password are mandatory.', 400))
// ユーザ検索
models.User.findOne({
where: { username: req.body.username }
}).then(async (userInst) => {
// 認証処理
if (!userInst) return next(new CustomError('User not found.', 404))
const passwordValid = bcrypt.compareSync(req.body.password, userInst.password)
if (!passwordValid) return next(new CustomError('Password is invalid.', 401)) // Password is incorrect.
// トークン生成
const tokens = await Promise.all([
generateAccessToken(userInst),
generateRefreshToken(userInst, true, true) // generate new token and set duration.
])
// 応答処理
let resJSON = { ...userInst.dataValues, ...tokens[0], ...tokens[1] }
delete resJSON.password
res.json(resJSON)
}).catch((err) => {
return next(new CustomError(err.message, 500))
})
})
順に解説。
まずリクエストの検証,ここはサインアップの時とほぼ同じである。
if (!Object.keys(req.body).length) return next(new CustomError('Request is invalid.', 400))
if (!req.body.username || !req.body.password) return next(new CustomError('Username and password are mandatory.', 400))
リクエスト検証をパスしたら,送信されたユーザ名でUser
モデルからデータを検索する。
models.User.findOne({
where: { username: req.body.username }
})
これにより,User
モデルのインスタンスが返ってくるので,これを次の.then()
節で処理していく。
.then(async (userInst) => {
// 認証処理
if (!userInst) return next(new CustomError('User not found.', 404))
const passwordValid = bcrypt.compareSync(req.body.password, userInst.password)
if (!passwordValid) return next(new CustomError('Password is invalid.', 401)) // Password is incorrect.
// トークン生成
const tokens = await Promise.all([
generateAccessToken(userInst),
generateRefreshToken(userInst, true, true) // generate new token and set duration.
])
// 応答処理
let resJSON = { ...userInst.dataValues, ...tokens[0], ...tokens[1] }
delete resJSON.password
res.json(resJSON)
})
まず,合致するユーザ名を持つユーザのデータが無かった場合,userInst
がnull
で返ってくるので404
エラーを返している。
ユーザが存在した場合,次にパスワードチェックを行う。ユーザが送信したパスワード(平文)と,データベースに保存されているハッシュとの比較は,bcrypt.compareSync( 平文 , ハッシュ )
で行えるので,その結果を一度定数passwordValid
に保存し,これがfalse
であれば401
エラーを返している。
パスワードチェックも通過すれば認証されたということで,トークンの生成に入る。Access TokenとRefresh Tokenを生成する関数はいずれもPromiseを返す関数として定義しておいたので,Promise.all
で並列処理させ,その結果を定数tokens
に代入させる。なおログイン時にはRefresh Tokenは必ず新規に生成されるので,設定は両方ともtrue
にしておく。
ところでRefresh Tokenの生成はデータベースへのアクセスを伴うのでやや時間がかかる。.then()
節の中は非同期で処理が進んでいくため,Promise.all()
が解決するまで待たないと,token
にはpending
状態のPromiseが返ってくることになってしまう。そのため,この.then()
節を処理する関数はasync
関数とし,await
でPromise.all()
の処理が終了してからtoken
への代入を行っている。
最後に応答処理。サインインと同様,スプレッド構文を用いて応答のためのJSONデータを新たに作っていく。tokens
にはPromise.all()
の結果として,generateAccessToken()
の結果とgenerateRefreshToken()
の結果が配列として入っている。そこで...tokens[0]
でgenerateAccessToken()
の結果を,...tokens[1]
でgenerateRefreshToken()
の結果がそれぞれresJSON
にコピーされる。パスワードハッシュの情報を削除してからres.json()
で最終的に応答。
最後の.catch()
節はサインインと同様なので省略する。
では,Postmanで動作を確認してみる。
無事ログインに成功し,Access TokenとRefresh Tokenが返ってきているようだ
4-4. トークンリフレッシュ
ログインが出来れば,ほぼそれを流用する形でトークンリフレッシュのエンドポイントも作成できる。違うのはユーザが送信してくるのがパスワードではなくRefresh Tokenであることと,判定がパスワードハッシュの検証ではなくRefresh Tokenが有効なものかの検証になることだけだ。
仕様
Method/end-point
POST /users/refresh
Request Headers
Content-type : application/JSON
Request Body
id (string) : ユーザid(必須)
refreshToken (string) : Refresh Token(必須)
Status Code
200 OK : 正常に再発行されたされた
400 Bad Request : リクエスト不正
401 Unauthorized : Refresh Tokenが一致しないか,失効している
404 Not Found : ユーザが存在しない
500 Internal Server Error : サーバ処理異常
Response JSON object
id (string) : 主キー
email (string) : メールアドレス
privilege (integer) : 権限
username (string) : ユーザ名
updatedAt (string) : ユーザ更新日時
createdAt (string) : ユーザ作成日時
accessToken (string) : Access Token
accessTokenExpiryDate (string) : Access Tokenの有効期限
refreshToken (string) : Refresh Token
refreshTokenExpiryDate (string) : Refresh Tokenの有効期限
コード
router.post('/refresh', (req, res, next) => {
// リクエスト検証
if (!Object.keys(req.body).length) return next(new CustomError('Request is invalid.', 400))
if (!req.body.id || !req.body.refreshToken) return next(new CustomError('Id and refreshToken are mandatory.', 400))
// ユーザ検索
models.User.findByPk(req.body.id, {
include: 'RefreshToken'
}).then(async (userInst) => {
// トークン検証
if (!userInst) return next(new CustomError('User not found.', 404))
if (userInst.RefreshToken.token !== req.body.refreshToken) return next(new CustomError('Refresh token is invalid.', 401))
if (new Date() > userInst.RefreshToken.expiryDate) return next(new CustomError('Refresh token is expired.', 401))
// トークン生成
const tokens = await Promise.all([
generateAccessToken(userInst),
generateRefreshToken(userInst, newToken, resetExp)
])
// 応答処理
let resJSON = { ...userInst.dataValues, ...tokens[0], ...tokens[1] }
delete resJSON.password
delete resJSON.RefreshToken
res.json(resJSON)
}).catch((err) => {
next(new CustomError(err.message, 500))
})
})
ユーザ検索の部分だが,username
を元にUser
モデルからデータを検索するところはログイン処理と同じだが,これだとRefreshToken
モデルのデータは返ってこない。そこでinclude: 'RefreshToken'
として,関連させてあるRefreshToken
モデルのデータも一緒に返すよう指定している。これにより,戻ってきたUser
モデルのインスタンスにはUser.RefreshToken
以下にRefreshToken
モデルのデータが含まれることになる。Refresh Tokenの検証はこれを利用して行っている。
if (userInst.RefreshToken.token !== req.body.refreshToken) return next(new CustomError('Refresh token is invalid.', 401))
userInst.RefreshToken.token
で関連するRefreshToken
モデルのtoken
データを取り出し,これをリクエストとして送信されたRefresh Tokenと比較している。合致しなければ401
エラーを返す。
if (new Date() > userInst.RefreshToken.expiryDate) return next(new CustomError('Refresh token is expired.', 401))
有効期限の検証も同様。new Date()
で新たに生成したDate
オブジェクトとデータベースに保存していた有効期限userInst.RefreshToken.expiryDate
とを比較し,過ぎていればやはり401
エラーを返している。
トークン検証が終了すれば新たなAccess Token / Refresh Tokenの生成に移るが,ログインの際と違い,Refresh Token生成のトークン再生成・有効期限リセットのオプションは.env
の内容に応じた定数で指定している。
Postmanで確認すると,正しいRefresh Tokenの送信に対し,Access TokenとRefresh Tokenがしっかり再発行されているのがわかる。
まとめ
ここまででsequelizeを利用したユーザ情報データベースの立ち上げ,およびそれを用いた認証処理,特にJWTを使用したAccess Tokenの発行と,Refresh Tokenを用いたトークン再発行を実装してきました。内容がだいぶ長くなったので,Access Tokenを利用した認可処理については次の記事で扱いたいと思います。