2
Help us understand the problem. What are the problem?

posted at

ExpressでJWT認証機能付きのAPIを作成する(その4. サインアップ時のメール認証・パスワードリセットの実装)

はじめに

この記事は「その3. 認可機能とユーザ操作の実装」に続けて書いています。

目的

自社のFileMaker CloudデータベースにWebからアクセスするためのAPIを作成する必要があり,そのために勉強したことの備忘録として step by step で作成の過程を書いていきます。想定する読者はjavascriptを触ったことはあるけれども,本格的なバックエンド開発の経験は無い,というような方です。

この記事では不特定多数のユーザを対象とするサービスを構築する際に必須となるサインアップ時のメール認証やパスワードリセットの機能を,Node.js用のメーラーモジュールであるnodemailerを用いて実装していきます。

開発環境

node v14.17.4
npm 6.14.14

ローカル開発環境には仮想SMTPサーバであるMailCatcherがインストールされている前提です。

Step 1. Nodemailerのセットアップ

zsh
$ npm install nodemailer --save

SMTPサーバの設定は.envに記述しておき,それを読み込む。ついでに,後々使うであろうメール内容で必要になるサーバURLや署名なんぞも書き込んでおこう。

.env
SERVER_URL=http://localhost:3000

# Nodemailer Settings
MAIL_HOST=127.0.0.1
MAIL_PORT=1025
MAIL_USER=
MAIL_PASS=
MAIL_FROM=noreply@hoge.com
MAIL_FOOTER=ほげほげ株式会社(このメールには返信できません)

ユーザ管理関係のルーティングファイルusers.jsnodemailerを読み込み,メール送信に必要なtransporterオブジェクトを作成しておこう。

/routes/users.js(一部)
const nodemailer = require('nodemailer')

const mailConfig = {
    host: process.env.MAIL_HOST,
    port: process.env.MAIL_PORT,
    auth: {
        user: process.env.MAIL_USER,
        pass: process.env.MAIL_PASS
    }
}
const transporter = nodemailer.createTransport(mailConfig)

Step 2. ユーザテーブルの変更

メールによるサインアップ時の認証やパスワードリセットの機能を実装するため,既存のユーザテーブルに次のカラムを追加しよう。

  • emailVerifiedAt (date): メールによる認証が終了した日時を格納する。ここが空欄であれば,メールによる認証が済んでいないと見なしログイン認証を拒否する。
  • needPasswordReset (boolean): パスワードリセットが必要かどうかの論理値。ここが1であれば,パスワードリセットが済んでいないと見なしログイン認証を拒否する。

カラムの追加はsequelize-cliを用いて行う。

zsh
$ npx sequelize-cli migration:create --name modify-user-for-using-mail

作成された空のマイグレーションファイルを編集する。

/migrations/yyyymmddhhssmm-modify-user-for-using-mail.js
'use strict';

  module.exports = {
    up: async (queryInterface, Sequelize) => {
+     await queryInterface.addColumn(
+       'Users',
+       'emailVerifiedAt',
+       {
+         type: Sequelize.DATE,
+         allowNull: true
+       }
+     )
+     await queryInterface.addColumn(
+       'Users',
+       'needPasswordReset',
+       {
+         type: Sequelize.BOOLEAN,
+         allowNull: true
+       }
+     )
    },

    down: async (queryInterface, Sequelize) => {
+     await queryInterface.removeColumn('Users', 'emailVerifiedAt'),
+     await queryInterface.removeColumn('Users', 'needPasswordReset')
    }
  };

カラムの追加はqueryInterface.addColumn()で行える。引数にはMySQLのテーブル名,カラム名とオプションを指定する。down:(マイグレーションを取り消す際の処理)にqueryInterface.removeColumn()を記述することを忘れずに。

さらに,モデルファイルの方も書き換えておこう。

/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,
+   emailVerifiedAt: DataTypes.DATE,
+   needPasswordReset: DataTypes.BOOLEAN
  }, {
    sequelize,
    modelName: 'User',
  })

編集が終わったらマイグレーションを実行してデータベースに反映させる。

zsh
$ npx sequelize-cli db:migrate

これで準備完了♪

Step 3. 機能実装

それではエンドポイントを実装していこう。

3-1. サインアップ(修正)

既に作成してあるサインアップのコードを修正し,ユーザに認証メールを送信するようにする。アドミニストレータが直接メールアドレスを指定してサインアップする時などは,リクエストヘッダに有効なAccess Tokenを送信し,skipVerificationtrueにすればメール送信を回避するよう設定する。

仕様

Method/end-point
  POST /users
Request Headers
  Content-type : application/JSON
  X-Acces-Token : Access Token(メール認証なしでサインアップを行う時のみ,有効なアドミニストレータ権限の記載されたAccess Tokenが必要)
Request Body
  username (string) : ユーザ名(必須)
  email (string) : メールアドレス(必須)
  password (string) : パスワード(必須)
  privilege (integer) : 権限(0:一般ユーザ,1:管理ユーザ,2:アドミニストレータ。1, 2以外を指定すると0となる。)
  skipVerification (boolean) : trueに設定された場合,メールによる認証処理を行わない。アドミニストレータ権限の記載されたAccess Tokenがヘッダに無い場合には無視される。
Status Code
  200 OK : 正常に登録(認証メールの送信を含む)された
  400 Bad Request : リクエスト不正
  409 Conflict : ユーザ名が既に使用されている
  500 Internal Server Error : サーバ処理異常
Response JSON object
  id (string) : 主キー
  email (string) : メールアドレス
  privilege (integer) : 権限
  username (string) : ユーザ名
  emailVerifiedAt (string) : メール認証済みの場合,メール認証日時が返る
  needPasswordReset (boolean) : パスワードリセットを要する場合,trueが返る
  updatedAt (string) : ユーザ更新日時
  createdAt (string) : ユーザ作成日時
  verificationLimit (string) : 認証メールが送信された場合,認証期限が返る

コード

まずUserモデルのインスタンスを引数として認証メールを送信する関数をPromiseベースで作成しておく。

/routes/users.js
const sendVerificationMail = (user) => {
  return new Promise((resolve, reject) => {
    // メール認証用のJWTを作成
    const jwtPayload = {
      id: userInst.id
    }
    const mailToken = jwt.sign(jwtPayload, jwtSecret, jwtOptions)
    const exp = JSON.parse(Buffer.from(mailToken.split('.')[1], 'base64').toString()).exp
    const mailTokenExpiryDate = new Date(exp * 1000)
    // メール内容を作成して送信
    const verificationURL = process.env.SERVER_URL + '/users/verify/' + mailToken
    const message = {
      from: process.env.MAIL_FROM,
      to: userInst.email,
      subject: 'ほげほげシステム 本登録メール',
      html: '<p>' + user.username + 'さんの本登録を行います。</p>'
          + '<p>以下のURLから本登録を完了してください。</p>'
          + '<p><a href=' + verificationURL + '>' + verificationURL + '</a></p>'
          + '<p>URL有効期限:' + mailTokenExpiryDate.toLocaleString('ja-JP', {timeZone: 'JST'}) + '(日本時間)</p>'
          + '<hr>'
          + '<p>' + process.env.MAIL_FOOTER + '</p>'
    }
    try {
      transporter.sendMail(message)
      resolve(mailTokenExpiryDate)
    } catch (err) {
      reject(err)
    }
  })
}

まず認証用のトークンをJWT形式で生成する。Access Tokenとは違い,ペイロードはユーザのidのみとしている。生成されたトークンから有効期限を取り出すところは一緒。

const jwtPayload = {
  id: userInst.id
}
const mailToken = jwt.sign(jwtPayload, jwtSecret, jwtOptions)
const exp = JSON.parse(Buffer.from(mailToken.split('.')[1], 'base64').toString()).exp
const mailTokenExpiryDate = new Date(exp * 1000)

トークンが生成されれば次にメール内容を作成し,送信する。

const verificationURL = process.env.SERVER_URL + '/users/verify/' + mailToken
const message = {
  from: process.env.MAIL_FROM,
  to: userInst.email,
  subject: 'ほげほげシステム 本登録メール',
  html: '<p>' + user.username + 'さんの本登録を行います。</p>'
      + '<p>以下のURLから本登録を完了してください。</p>'
      + '<p><a href=' + verificationURL + '>' + verificationURL + '</a></p>'
      + '<p>URL有効期限:' + mailTokenExpiryDate.toLocaleString('ja-JP', {timeZone: 'JST'}) + '(日本時間)</p>'
      + '<hr>'
      + '<p>' + process.env.MAIL_FOOTER + '</p>'
}
try {
  transporter.sendMail(message)
  resolve(mailTokenExpiryDate)
} catch (err) {
  reject(err)
}

認証用URLは.envから取得したサーバURLに続けて/users/verify/トークンという形式にしている。認証用URLを生成したら,送信するメール内容を記述したmessageオブジェクトを作成する。最後にtransporter.sendMail(message)で送信。送信処理はtry {}節に入れてあるので,送信が成功すればresolve(mailTokenExpiryDate)で認証期限とともにresolveを返し,SMTPサーバのダウン等でエラーが返ってくればreject(err)でエラーとともにrejectを返す。

それではメインの処理を修正していこう。

/routes/users.js
  router.post('/', (req, res, next) => {
+   // トークンがあれば検証する
+   if (req.headers['x-access-token']) {
+     const token = req.headers['x-access-token']
+     jwt.verify(token, jwtSecret, (err, decoded) => {
+       if (err) return new CustomError('Unauthorized: ' + err.message + '.', 400)
+       req.privilege = decoded.privilege
+     })
+   }
    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]) => {
+   }).then(async ([userInst, created]) => {
      if (!created) return next(new CustomError('The username already exists.', 409))
      let resJSON = { ...userInst.dataValues }
+     // アドミニストレータユーザがメール認証スキップを指定した場合には,emailVerifiedAtに現在時刻を設定する
+     if (req.privilege === 2 && req.body.skipVerification === true) {
+       await userInst.update({ emailVerifiedAt: new Date })
+     } else {
+       const verificationLimit = await sendVerificationMail(userInst)
+       resJSON.verificationLimit = verificationLimit
+     }
      delete resJSON.password
      res.json(resJSON)
    }).catch((err) => {
      return next(new CustomError(err.message, 500))
    })
  })

まずアドミニストレータがメール認証を回避してユーザ追加を行いたい場合にはAccess Tokenを送ってくるので,Access Tokenの検証を最初に行っている。ここは認証ミドルウェアとやっていることは同じ。

ユーザをfindOrCreateで検索/作成して,ユーザインスタンスが返ってきてからの.then()節は元々同期処理だったが,この後でawaitを使うのでasyncを追加している。

if (req.privilege === 2 && req.body.skipVerification === true) {
  await userInst.update({ emailVerifiedAt: new Date })
} else {
  const verificationLimit = await sendVerificationMail(userInst)
  resJSON.verificationLimit = verificationLimit
}

ユーザがアドミニストレータであればAccess Tokenを検証した際にreq.privilege2が設定されているはず。そのうえでリクエストボディskipVerificationtrueに設定されていれば,ユーザインスタンスのemailVerifiedAtに現在時刻を設定してupdateでデータを保存する。

もしそうでなければsendVerificationMail(userInst)を呼び出して認証メールを送信する。問題なく認証メールが送信されれば認証期限が返ってくるはずなので,これを応答JSONに追加している。

実装が終わったら試しにサインアップしてみる。MailCatcherで確認してみると
スクリーンショット 2022-04-07 18.36.15.png
しっかりメールが送信されていることがわかる。

3-2. 認証メールの再送

送信された認証URLに含まれるトークンはAccess Tokenと同じ有効時間を持っている(上記のsendVerificationMail関数の中で使用しているjwtOptionsを変更すれば,別の有効時間を持たせることは可能)。したがって,期限が過ぎてしまうともうその認証URLは使用できなくなってしまう。

実運用では「メールが送られたのに開いてクリックするのを忘れた」というユーザが出現することは当然予想されることなので,認証メールの再送機能は絶対に必要。usernameを送信することで認証メールを再送信するエンドポイントを作成しておこう。

仕様

Method/end-point
  POST /users/verify
Request Headers
  Content-type : application/JSON
Request Body
  username (string) : ユーザ名(必須)
Status Code
  200 OK : 正常に認証メールが送信された
  400 Bad Request : リクエスト不正
  404 Not Found : ユーザが存在しない
  409 Conflict : 既にメール認証が行われている
  500 Internal Server Error : サーバ処理異常
Response JSON object
  id (string) : 主キー
  email (string) : メールアドレス
  privilege (integer) : 権限
  username (string) : ユーザ名
  updatedAt (string) : ユーザ更新日時
  createdAt (string) : ユーザ作成日時
  verificationLimit (string) : 認証メールの認証期限が返る

コード

/routes/users.js
router.post('/verify', (req, res, next) => {
    // リクエスト検証
    if (!Object.keys(req.body).length) return next(new CustomError('Request is invalid.', 400))
    if (!req.body.username) return next(new CustomError('Username is required.', 400))
    // ユーザ検索
    models.User.findOne({ where: { username: req.body.username } })
        .then(async (userInst) => {
            if (!userInst) return next(new CustomError('User not found.', 404))
            if (userInst.emailVerifiedAt) return next(new CustomError('Email already verified.', 409))
            let resJSON = { ...userInst.dataValues }
            const verificationLimit = await sendVerificationMail(userInst)
            resJSON.verificationLimit = verificationLimit
            res.json(resJSON)
        }).catch((err) => {
            return next(new CustomError(err.message, 500))
        })
})

リクエストにusernameが含まれるか検証し,含まれていればそれを元にユーザを検索。返ってきたユーザインスタンスから重複をチェックし,認証メールを送信するところはサインアップとほぼ同じだ。

3-3. 認証用URL

認証用URLを受け取るエンドポイント。トークンがURLに含まれるので,req.paramsで取得して処理を行っていく。通常のAPIエンドポイントと違い,ユーザのメールソフトから直接クリックされるので,応答はJSONではなく,一般ユーザが見て内容を理解できる形で返す必要がある。

仕様

Method/end-point
  GET /users/verify/(token)
Status Code
  200 OK : 正常に認証メールが送信された
  400 Bad Request : リクエスト不正
  404 Not Found : ユーザが存在しない
  409 Conflict : 既にメール認証が行われている
  500 Internal Server Error : サーバ処理異常
Response JSON object
  id (string) : 主キー
  email (string) : メールアドレス
  privilege (integer) : 権限
  username (string) : ユーザ名
  updatedAt (string) : ユーザ更新日時
  createdAt (string) : ユーザ作成日時
  verificationLimit (string) : 認証メールの認証期限が返る

コード

まず,認証が正常に終了した際にユーザに遷移してもらうフロントエンドのトップURLを.envに記述しておく。

.env
TOP_URL=http://localhost:8080

その上で,エンドポイントのコードは以下のようにした。

/routes/users.js
router.get('/verify/:token', (req, res, next) => {
    const token = req.params.token
    jwt.verify(token, jwtSecret, (err, decoded) => {
        if (err) {
            if (err.message = 'jwt expired') return res.status(401).send('認証URLが失効しています。ログイン画面から「認証URLの再送」へ進んで下さい。<br><a href=' + process.env.TOP_URL + '>ログイン画面へ</a>')
            return res.status(401).send('認証URLが不正です。')
        }
        models.User.findByPk(decoded.id)
            .then(async (userInst) => {
                if (!userInst) return res.status(404).send('ユーザが見つかりません。')
                if (userInst.emailVerifiedAt) return res.status(409).send('ユーザは既に認証されています。<br><a href=' + process.env.TOP_URL + '>ログイン画面へ</a>')
                await userInst.update({ emailVerifiedAt: new Date })
                res.send(userInst.username + 'さんのメール認証が正常に行われました。<br><a href=' + process.env.TOP_URL + '>ログイン画面へ</a>')
            })
    })
})

URLから取り出したトークンはJWT形式なので,認証処理と同じくjwt.verifyで検証を行っている。エラーのうち,認証期限切れの場合にはerr.message = 'jwt expired'で検出できるため,特別に期限切れであることを示すメッセージを返している。

トークンの検証が通ればペイロードに含まれるidを元にユーザを検索する。返ってきたインスタンスでemailVerifiedAtに値があれば既に認証されているということなので409エラーを返している。値がなければ現在時刻を入力してアップデートし,正常に処理が終了した旨のメッセージを返して終了。

実際に送信されたメールのURLをクリックしてみると
スクリーンショット 2022-04-07 19.57.33.png
正常に処理が行われている。

3-4. パスワードリセット

ユーザがパスワードを忘れた時のために,ユーザネームを元に登録されたメールアドレスへ新しいパスワードを送信するエンドポイントを作成する。

仕様

Method/end-point
  POST /users/password-reset
Request Headers
  Content-type : application/JSON
Request Body
  username (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) : ユーザ作成日時

下準備

パスワードリセットの際は,新たにランダムな文字列のパスワードを作成し,メールでユーザに送付するとともにそのハッシュをデータベースに書き込む。ランダムな文字列の生成はネットでググれば色々出てくるけど,今回はここを参考にして,再利用しやすいようにランダム文字列生成用のクラスを作成することにした。

/routes/users.js
class RandomPass {
    constructor() {
        this.chars = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
    }
    generate(length) {
        return Array.from(Array(length)).map(() => this.chars[Math.floor(Math.random() * this.chars.length)]).join('')
    }
}
const randomPass = new RandomPass

constructor()でこのクラスを元にインスタンスが生成される際,charsにパスワードに使用する文字列を格納している。なお,間違えやすいl I 1o O 0は抜いている。

generateメソッドはパスワードの文字数lengthを引数にとり,そのサイズの配列を生成した上で,各文字位置に.mapcharsからランダムな文字を充当させている。

クラス定義が終われば,randomPassをインスタンスとして生成し,以下の処理で利用する。

コード

/routes/users.js
router.post('/password-reset', (req, res, next) => {
  // リクエスト検証
  if (!Object.keys(req.body).length) return next(new CustomError('Request is invalid.', 400))
  if (!req.body.username) return next(new CustomError('Username is required.', 400))
  // ユーザ検索
  models.User.findOne({ where: { username: req.body.username } })
    .then(async (userInst) => {
      // エラー処理
      if (!userInst) return next(new CustomError('User not found.', 404))
      if (!userInst.emailVerifiedAt) return next(new CustomError('Mail address is not verified.', 401))
      // 新規をパスワード発行してデータベースへ反映
      const newPassword = randomPass.generate(8)
      const newPasswordHash = bcrypt.hashSync(newPassword, salt)
      await userInst.update({
        password: newPasswordHash,
        needPasswordReset: true
      })
      // メール送信
      const message = {
        from: process.env.MAIL_FROM,
        to: userInst.email,
        subject: 'ほげほげシステム パスワード再設定メール',
        html: '<p>' + userInst.username + 'さんのパスワードをリセットしました。</p>'
          + '<p>パスワード:' + newPassword + '</p>'
          + '<p>ログイン後,パスワードを変更して下さい。<a href=' + process.env.TOP_URL + '>ログイン画面へ</a></p>'
          + '<hr>'
          + '<p>' + process.env.MAIL_FOOTER + '</p>'
      }
      transporter.sendMail(message)
      let resJSON = { ...userInst.dataValues }
      res.json(resJSON)
    }).catch((err) => {
      return next(new CustomError(err.message, 500))
    })
})

リクエストを検証した後,ユーザ名を元にユーザを検索するところまでは認証メール再送信と一緒。エラー処理では,ユーザが見つからなかった時に加え,ユーザのメールアドレスがまだ検証されていない場合にもエラーを返している。(ユーザ本人のものか検証されていないアドレスにパスワードを送付するわけにはいかないもんね)

問題なければ新規パスワードをrandomPass.generate()で生成し,ハッシュをデータベースへ登録,メール送信。

3-5. ログイン(修正)

ログインのエンドポイントは,ユーザインスタンスでemailVerifiedAtに値が入っているかどうかを見れば良い。入っていなければまだメール認証がされていないということなので,401エラーを返す。

コード

/routes/users.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))
+     if (!userInst.emailVerifiedAt) return next(new CustomError('Mail address is not verified', 401))
      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))
    })
  })

3-6. ユーザ情報編集(修正)

パスワードリセット後のユーザはneedPasswordResettrueとなるので,これをもとにフロントエンド側でパスワードの変更を促す。パスワードが変更されればneedPasswordResetフラグを解除しなければならないので,その機能を実装する。

コード

長いので,ユーザ検索後の.then()節のみ。

/routes/users.js
  models.User.findByPk(req.params.id)
    .then(async (userInst) => {
      if (!userInst) return next(new CustomError('User not found.', 404))
      if (req.body.username && (req.body.username !== userInst.username)) {
        const userCount = await models.User.count({ where: { username: req.body.username } })
        if (userCount) return next(new CustomError('This username is already used.', 409))
      }
      if (req.body.username) userInst.username = req.body.username
      if (req.body.email) userInst.email = req.body.email
-     if (req.body.password) userInst.password = bcrypt.hashSync(req.body.password, salt)
+     if (req.body.password) {
+       userInst.password = bcrypt.hashSync(req.body.password, salt)
+       if (userInst.needPasswordReset) userInst.needPasswordReset = null
+     }
      if (!(req.body.privilege === undefined)) userInst.privilege = req.body.privilege
      // Save data and return response
      const updatedUser = await userInst.save()
      let resJSON = { ...updatedUser.dataValues }
      delete resJSON.password
      res.json(resJSON)
    })

パスワードの変更があった場合,needPasswordResetnullに戻している。

まとめ

ここまでで,ユーザ管理関係の基本的なCRUDに加え,メールによる認証やパスワードリセットのバックエンド機能を実装してきました。ここらで記事をあらため,いよいよフロントエンドの開発に移りたいと思います。

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
2
Help us understand the problem. What are the problem?