はじめに
この記事は「その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のセットアップ
$ npm install nodemailer --save
SMTPサーバの設定は.env
に記述しておき,それを読み込む。ついでに,後々使うであろうメール内容で必要になるサーバURLや署名なんぞも書き込んでおこう。
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.js
にnodemailer
を読み込み,メール送信に必要なtransporter
オブジェクトを作成しておこう。
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
を用いて行う。
$ npx sequelize-cli migration:create --name modify-user-for-using-mail
作成された空のマイグレーションファイルを編集する。
'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()
を記述することを忘れずに。
さらに,モデルファイルの方も書き換えておこう。
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',
})
編集が終わったらマイグレーションを実行してデータベースに反映させる。
$ npx sequelize-cli db:migrate
これで準備完了♪
Step 3. 機能実装
それではエンドポイントを実装していこう。
3-1. サインアップ(修正)
既に作成してあるサインアップのコードを修正し,ユーザに認証メールを送信するようにする。アドミニストレータが直接メールアドレスを指定してサインアップする時などは,リクエストヘッダに有効なAccess Tokenを送信し,skipVerification
をtrue
にすればメール送信を回避するよう設定する。
仕様
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ベースで作成しておく。
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
を返す。
それではメインの処理を修正していこう。
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.privilege
に2
が設定されているはず。そのうえでリクエストボディskipVerification
がtrue
に設定されていれば,ユーザインスタンスのemailVerifiedAt
に現在時刻を設定してupdate
でデータを保存する。
もしそうでなければsendVerificationMail(userInst)
を呼び出して認証メールを送信する。問題なく認証メールが送信されれば認証期限が返ってくるはずなので,これを応答JSONに追加している。
実装が終わったら試しにサインアップしてみる。MailCatcherで確認してみると
しっかりメールが送信されていることがわかる。
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) : 認証メールの認証期限が返る
コード
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
に記述しておく。
TOP_URL=http://localhost:8080
その上で,エンドポイントのコードは以下のようにした。
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をクリックしてみると
正常に処理が行われている。
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) : ユーザ作成日時
下準備
パスワードリセットの際は,新たにランダムな文字列のパスワードを作成し,メールでユーザに送付するとともにそのハッシュをデータベースに書き込む。ランダムな文字列の生成はネットでググれば色々出てくるけど,今回はここを参考にして,再利用しやすいようにランダム文字列生成用のクラスを作成することにした。
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
1
とo
O
0
は抜いている。
generate
メソッドはパスワードの文字数length
を引数にとり,そのサイズの配列を生成した上で,各文字位置に.map
でchars
からランダムな文字を充当させている。
クラス定義が終われば,randomPass
をインスタンスとして生成し,以下の処理で利用する。
コード
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
エラーを返す。
コード
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. ユーザ情報編集(修正)
パスワードリセット後のユーザはneedPasswordReset
がtrue
となるので,これをもとにフロントエンド側でパスワードの変更を促す。パスワードが変更されればneedPasswordReset
フラグを解除しなければならないので,その機能を実装する。
コード
長いので,ユーザ検索後の.then()
節のみ。
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)
})
パスワードの変更があった場合,needPasswordReset
をnull
に戻している。
まとめ
ここまでで,ユーザ管理関係の基本的なCRUDに加え,メールによる認証やパスワードリセットのバックエンド機能を実装してきました。ここらで記事をあらため,いよいよフロントエンドの開発に移りたいと思います。