12
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ExpressでJWT認証機能付きのAPIを作成する(その2. Access Token / Refresh Tokenによる認証機能の実装)

Last updated at Posted at 2022-04-03

はじめに

この記事は「その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にするのを忘れずに。

MySQL
CREATE DATABASE IF NOT EXISTS fmproxy CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

1-2. MySQLにアクセスするためのユーザを作成する

アクセスは上で作成したデータベースのみに限定しておく。

MySQL
CREATE USER fmproxy@localhost IDENTIFIED BY 'hogehoge';
GRANT ALL PRIVILEGES ON fmproxy.* TO fmproxy@localhost;

1-3. Sequelizeをインストール

zsh
npm install sequelize --save
npm install mysql2 --save

sequelizeには,プロジェクトの初期構築やモデル作成,マイグレーション(変更内容のデータベース側への反映)などを効率化するためのツールであるsequelize-cliが用意されている。以前にsequelize-cliを利用したことがあれば基本的にはグローバルインストールされているはずだが,以前インストールしたことがなければ,これもインストールしておこう。

zsh
sudo npm install sequelize-cli -g

1-4. 初期構築

プロジェクトのトップディレクトリで以下を実行することで,sequelize-cliを利用した初期構築を行える。

zsh
% 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側で設定したデータベース・ユーザ情報を書きこんでおこう。

/config/config.json(該当部分のみ)
  "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という名称で以下の内容のファイルを作成する。

/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エラーの処理も作成しておこう。

/server.js
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 nexterrを加えた4つの引数を持つのが特徴。このerrnext( ... )で渡されたデータが入っている。先ほどのように,作成したオリジナルのエラーオブジェクトが渡されればstatusCodeにコードが入っているはずだが,他のエラーオブジェクトが渡されてきた場合にはstatusCodeは設定されていないので,その場合500エラーを返すようにしている。

試しにcurlから存在しないエンドポイントに要求を送ってみる。

zsh
% 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にある接続テスト用のスニペットを流用して,エンドポイント/にアクセスした際にデータベースへの接続をテストして結果を返すようにしてみる。

/server.js
// 省略
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()でエラーハンドラに渡している。

では実際にアクセスしてみよう。

zsh
% 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を終了させてから同様にアクセスしてみる。

zsh
% 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:アドミニストレータ)

とした。(項目は後から追加や変更が可能)

zsh
% 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の項目は記述されていない(ユーザがアクセスすることを想定していないため?)ので,記載を追加しておく。

/models/user.js
// 省略
  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を生成するよう指定している。

次にマイグレーションファイルも変更しておこう。

/migrations/yyyymmddhhmmss-create-user.js
'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の項目が記述されているので,これを変更していく。

ファイルを保存したらターミナルからマイグレーションを行おう。

zsh
% npx sequelize-cli db:migrate

これにより,MySQL側にテーブルが作成される。ビューワで覗いてみると
スクリーンショット 2022-03-24 15.17.06.png
ちゃんと出来てる♪ なお細かいことだが,上で作成したsequelizeのモデル名はUserであったのに,MySQLに作成されたテーブル名はUsersと複数形になっている。どうやらsequelizeが自動的にお節介を焼いてくれるようだ。

ともあれ,これでUserモデルを経由してデータベースにアクセスするための準備が整った。

3-3. ルータを使用できるようにしておく

認証システムを作成してゆく前に,今後エンドポイントを多数作るのでexpress.Routerを利用してモジュール形式でルーティング処理をできるようにしておく。

まずroutesディレクトリを作成し,users.jsファイルを作成する。

/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で処理するようにする。

/server.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に記述する。

zsh
% npm install cors --save
/server.js
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モジュールを使用する。

zsh
% npm install bcrypt --save

コード

/routes/users.js
/* 追加モジュール */
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を使って検証してみよう。
スクリーンショット 2022-03-31 21.04.56.png
しっかり200 OKのステータスと作成されたユーザ情報が返っている。

試しにユーザ名やパスワードなどを空欄にしたり,JSONの形式をおかしくして送信すると,ちゃんとエラーが返ってくる:smiley:

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が漏洩したらどうすんのさ? といえばそれはヤバいよね:scream:ということになるのだが,前述の通りRefresh TokenはAccess Tokenの更新にしか使われないため,Access Tokenと比べれば圧倒的にネットワークを流れる頻度が低い。よって漏洩のリスクも低いよね!……という発想。

たぶんこの認識はすっっっっっっっっごくアバウトでこのあたりに詳しい人からしたら目眩がするのかもしれないけど,とりあえず実装していこう。今回はAccess Tokenの生成とRefresh Tokenの生成をそれぞれPromiseベースの関数として定義しておき,ログインおよびトークンリフレッシュでそれらを利用する形とする。

Access Tokenの作成

まず,JWTの生成に必要なjsonwebtokenモジュールをインストールしておく。

zsh
$ npm install jsonwebtoken --save

JWTを利用したAccess Tokenの生成に当たってはJWTの署名アルゴリズム有効時間を設定しなければならない。署名アルゴリズムは公開鍵・秘密鍵方式のRSAや,共通鍵方式のHMACなどから選べるが,今回は暗号化・復号化とも同じサーバで行うので,SHA-256ベースのHMACであるHS256を使用する。HS256で使用する共通鍵としてはハッシュと同じ長さ(=256bit)以上の文字列の使用が推奨されているので,256 ÷ 8 = 32文字以上の英数文字列を決めておかなければならない。Web上には生成ツールが色々公開されているので,そうしたツールを使って適当に生成しておこう。

また,Access Tokenの有効時間は前述したように,もし漏洩したとしてもリスクが少なくなるよう短めに設定する必要がある。これを何分にするかは取り扱うデータの秘匿性などにもよるだろうけど,とりあえず今回は30分としてみる。

これらは環境変数に記述しておき,そこから読み取る。

.env
JWT_SECRET=HCujmweMScV3DwZKtrE7RG7sE8bXDjVy
ACCESS_TOKEN_DURATION_MINUTE=30

ではコード。

/routes/users.js
/* 追加モジュール */
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モジュールをインストールしておく。

zsh
$ npm install uuid --save

.envファイルに以下の内容を追加しておく。

.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モデルと関連させる。

zsh
$ npx sequelize-cli model:generate --name RefreshToken --attributes userId:uuid,token:uuid,exiryDate:date

RefreshTokenの情報はUserの情報に1対1で対応するリレーションになるが,sequelizeではモデルにこうしたリレーションの情報を持たせることができる。リレーションにはには1対1 One-to-One1対多 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)を修正していこう。

/models/user.js
static associate(models) {
  // define association here
+ User.hasOne(models.RefreshToken, {
+   foreignKey: 'userId'
+ })
}
/models/refreshtoken.js
static associate(models) {
  // define association here
+  RefreshToken.belongsTo(models.User, {
+   foreignKey: 'userId',
+   onDelete: 'CASCADE'
+ / })
}
/migrations/yyyymmddhhmmss-create-refresh-token.js
      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側のuserIdnullになる。

マイグレーションファイルでは,外部キーとして使用するuserIdの設定にonDeleteの内容とreferencesとして連携先のモデル名および主キー列名を追加している。

修正を終えたらマイグレーションを実行しておこう。

zsh
$ npx sequelize-cli db:migrate

ではこれで準備が整ったので,実際のコードを書いてみる。

/routes/users.js
/* 追加モジュール */
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段階で行う方法をとった。

まずnewTokentrueであれば,tokenに新しいUUID文字列をセットする。データのセットはset({ データ列名 : 新しいデータ })の形で行う。

次にresetExptrueであれば,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の有効期限

コード

server.js
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)
})

まず,合致するユーザ名を持つユーザのデータが無かった場合,userInstnullで返ってくるので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関数とし,awaitPromise.all()の処理が終了してからtokenへの代入を行っている。

最後に応答処理。サインインと同様,スプレッド構文を用いて応答のためのJSONデータを新たに作っていく。tokensにはPromise.all()の結果として,generateAccessToken()の結果とgenerateRefreshToken()の結果が配列として入っている。そこで...tokens[0]generateAccessToken()の結果を,...tokens[1]generateRefreshToken()の結果がそれぞれresJSONにコピーされる。パスワードハッシュの情報を削除してからres.json()で最終的に応答。

最後の.catch()節はサインインと同様なので省略する。

では,Postmanで動作を確認してみる。
スクリーンショット 2022-04-03 13.57.49.png
無事ログインに成功し,Access TokenとRefresh Tokenが返ってきているようだ:laughing:

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の有効期限

コード

/routes/users.js
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を利用した認可処理については次の記事で扱いたいと思います。

12
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?