九保すこひ@フリーランスエンジニア|累計300万PVのブログ運営中
さてさて、前回記事「Expressでユーザー登録機能」でも少し触れましたが、ユーザー登録機能をつくる際に「パスワード再発行機能」、いわゆる「パスワード・リマインダー」も同時に開発しようと考えていました。
ただ、ユーザー登録機能が予想を上回るコード量になってしまい、あえなく別記事にするべきと判断しました。
そのため、今回はそれを受けて、
パスワードの再発行機能をExpressでつくる
をテーマにしてお届けします。
ぜひみなさんのお役に立てると嬉しいです😊✨
開発環境: Node 8、Express 4.1
目次
手順
今回開発する「パスワード再発行機能」は以下の手順で実行する機能になります。
- ユーザーがメールアドレスを送信
- パスワード再発行メールを送信
- メールに書かれているURLをクリック
- 表示されたフォームから新しいパスワードを送信
- 新パスワードを保存する
なお、前回もそうですがこの流れに加えてコードはLaravel
を参考したものです。Laravel
さん、いつもありがとうございます。m_ _m
パッケージをインストールする
必要になるパッケージですが、ほぼ前回記事と同じになりますので、「前提として」を参考にして以下のパッケージをインストールしておいてください。
- Sequelize
- nodemailer
- express-validator
- bcrypt
なお、テンプレート・エンジンにはmustache-express
を使っています。
準備する
では、パスワード再発行に必要なDBテーブル「PasswordResets」をつくります。以下のコマンドを実行してください。
npx sequelize-cli model:generate --name PasswordReset --attributes email:string,token:string
すると、以下の2ファイルが作成されます。
- /models/passwordreset.js
- /migrations/************-create-password-reset.js
マイグレーションには、不要な項目が含まれているので以下のように変更してください。
'use strict'; module.exports = { up: (queryInterface, Sequelize) => { return queryInterface.createTable('PasswordResets', { email: { type: Sequelize.STRING }, token: { type: Sequelize.STRING }, createdAt: { allowNull: false, type: Sequelize.DATE } }); }, down: (queryInterface, Sequelize) => { return queryInterface.dropTable('PasswordResets'); } };
また、モデルも同様に変更してください。(プライマリーキーの変更や、リレーションシップを追加しています)
'use strict'; module.exports = (sequelize, DataTypes) => { const PasswordReset = sequelize.define('PasswordReset', { email: { type: DataTypes.STRING, primaryKey: true }, token: DataTypes.STRING, createdAt: DataTypes.DATE }, { timestamps: false, }); PasswordReset.associate = function(models) { PasswordReset.hasOne(models.User, { foreignKey: 'email', sourceKey: 'email' }); }; return PasswordReset; };
では、マイグレーションを実行してDBテーブルを作成します。
npx sequelize-cli db:migrate
すると、テーブルはこのようになります。
必要なパッケージ・モデルを読み込む
app.js
に、以下のパッケージ&モデルを読み込むコードを追加してください。(なお、crypto
はインストール不要で使えるパッケージです)
const { check, validationResult } = require('express-validator'); const nodemailer = require('nodemailer'); const bcrypt = require('bcrypt'); const crypto = require('crypto'); const PasswordReset = require('./models').PasswordReset; const User = require('./models').User;
ミドルウェアを設定する
同じくapp.js
に、以下のコードを追加してPOSTデータを取得できるようにしておいてください。
app.use(express.json()); app.use(express.urlencoded({ extended: true }));
必要な情報を追加する
ここは前回と重複しますが、暗号化につかうAPP_KEY
、トップURLのAPP_URL
、そして、メール送信情報のtransporter
をapp.js
に追加しておきます。(本来は.env
を使うべきですが今回は割愛します)
// 暗号化につかうキー const APP_KEY = 'YOUR-SECRET-KEY'; // トップURL const APP_URL = 'http://express41.test'; // メール送信設定 const transporter = nodemailer.createTransport({ host: '127.0.0.1', port: 1025, secure: '', auth: { user: '', pass: '' } });
メールアドレスを送信するフォームをつくる
では、パスワードを忘れたユーザーが自分のメールアドレスを送信するフォームをつくっていきます。
以下のルートをapp.js
に追加してください。
app.get('/password/reset', (req, res) => { res.render('auth/passwords/email'); });
次に「views/auth/passwords/email.mst」というファイルをつくり中身を以下のようにします。
<html> <head> <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <div id="app"> <div class="card-body"> <div class="form-group row"> <label for="email" class="col-md-4 col-form-label text-md-right">メールアドレス</label> <div class="col-md-6"> <input type="email" class="form-control" v-model="email"> <div class="alert alert-danger" v-if="error" v-text="error"></div> </div> </div> <div class="form-group row mb-0"> <div class="col-md-6 offset-md-4"> <button type="submit" class="btn btn-primary" @click="onSubmit">パスワード再発行リンクを送信する</button> </div> </div> </div> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.2/axios.min.js"></script> <script> new Vue({ el: '#app', data: { email: '', error: '' }, methods: { onSubmit() { this.error = ''; axios.post('/password/email', { email: this.email }) .then(response => { if(response.data.result) { alert('入力されたメールアドレスにメッセージ送信しました。'); } }) .catch(error => { const errors = error.response.data.errors; errors.every(error => { this.error = error.msg; return false; }); }); } } }); </script> </body> </html>
中身としては、Vue
とaxios
を使ってメールアドレスをAjax
送信しているだけです。
実際にブラウザで表示するとこのようになります。
パスワード再発行メールを送信する部分をつくる
続いて、送信されたメールアドレスに「パスワード再発行メール」を送信する部分をつくっていきます。
app.js
に以下のバリデーション・ルールとルートを追加してください。
// バリデーション・ルール const passwordEmailValidationRules = [ check('email') .not().isEmpty().withMessage('この項目は必須入力です。') .isEmail().withMessage('有効なメールアドレス形式で指定してください。') .custom((value, { req }) => { return User.findOne({ where: { email: req.body.email } }).then(user => { if(!user) { throw new Error('このメールアドレスに一致するユーザーを見つけることが出来ませんでした。'); } }); }) ]; app.post('/password/email', [passwordEmailValidationRules], (req, res) => { const errors = validationResult(req); if(!errors.isEmpty()) { // バリデーション失敗 return res.status(422).json({ errors: errors.array() }); } const email = req.body.email; const randomStr = Math.random().toFixed(36).substring(2, 38); const token = crypto.createHmac('sha256', APP_KEY) .update(randomStr) .digest('hex'); const passwordResetUrl = APP_URL +'/password/reset/'+ token +'?email='+ encodeURIComponent(email); PasswordReset.findOrCreate({ where: { email: email }, defaults: { email: email, token: token, createdAt: new Date() } }).then(([passwordReset, created]) => { if(!created) { passwordReset.token = token; passwordReset.createdAt = new Date(); passwordReset.save(); } // メール送信 transporter.sendMail({ from: 'from@example.com', to: email, text: "以下のURLをクリックしてパスワードを再発行してください。\n\n"+ passwordResetUrl, subject: 'パスワード再発行メール', }); res.json({ result: true }); }); });
この中でやっていることは、以下のとおりです。
- 送信されたメールアドレスを持つユーザーがいるかチェック
- ユーザーが見つかったらトークンをつくって PasswordReset へ保存
- トークンとメールアドレスを含んだ「パスワード再発行URL」をつくる
- そのURLをメールで送信
新パスワードを設定するフォームをつくる
では次に、「パスワード再発行URL」をクリックしたときに表示されるフォームをつくっていきましょう。
app.js
に以下のルートを追加してください。
app.get('/password/reset/:token', (req, res) => { res.render('auth/passwords/reset', { token: req.params.token, email: req.query.email }); });
そして、「views/auth/passwords/reset.mst」というファイルをつくって、中身を以下のようにします。
<html> <head> <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <div id="app"> <div class="card-body"> <div class="alert alert-danger" v-if="errors.token" v-text="errors.token"></div> <div class="form-group row"> <label for="email" class="col-md-4 col-form-label text-md-right">メールアドレス</label> <div class="col-md-6"> <input type="email" class="form-control" v-model="params.email"> <div class="alert alert-danger" v-if="errors.email" v-text="errors.email"></div> </div> </div> <div class="form-group row"> <label for="password" class="col-md-4 col-form-label text-md-right">パスワード</label> <div class="col-md-6"> <input type="password" class="form-control" v-model="params.password"> <div class="alert alert-danger" v-if="errors.password" v-text="errors.password"></div> </div> </div> <div class="form-group row"> <label for="password-confirm" class="col-md-4 col-form-label text-md-right">パスワード(確認)</label> <div class="col-md-6"> <input type="password" class="form-control" v-model="params.passwordConfirmation"> <div class="alert alert-danger" v-if="errors.passwordConfirmation" v-text="errors.passwordConfirmation"></div> </div> </div> <div class="form-group row mb-0"> <div class="col-md-6 offset-md-4"> <button type="submit" class="btn btn-primary" @click="onSubmit">登録する</button> </div> </div> </div> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.2/axios.min.js"></script> <script> new Vue({ el: '#app', data: { params: { token: '{{ token }}', email: '{{ email }}', password: '', passwordConfirmation: '' }, errors: {} }, methods: { onSubmit() { this.errors = { token: '', email: '', password: '', passwordConfirmation: '' }; axios.post('/password/reset', this.params) .then(response => { if(response.data.result) { alert('パスワードのリセットが完了しました。'); } }) .catch(error => { const errors = error.response.data.errors; errors.forEach(error => { const key = error.param; if(this.errors[key] === '') { this.errors[key] = error.msg; } }); }); } } }); </script> </body> </html>
この中では、「メールアドレス」「パスワード」「パスワード(確認)」の3種類を入力してもらい、最終的にtoken
も自動追加して送信されるようになっています。(Vue
のdata
に直接メールアドレス、トークンを渡していることに注目してください)
新パスワードを設定する部分をつくる
では、最後に送信された新パスワードを設定する部分をつくっていきましょう。
ここでもapp.js
にバリデーション・ルールとルートを追加します。
// バリデーション・ルール const passwordResetValidationRules = [ check('email') .not().isEmpty().withMessage('この項目は必須入力です。') .isEmail().withMessage('有効なメールアドレス形式で指定してください。') .custom((value, { req }) => { return User.findOne({ where: { email: req.body.email } }).then(user => { if(!user) { throw new Error('このメールアドレスに一致するユーザーを見つけることが出来ませんでした。'); } }); }), check('password') .not().isEmpty().withMessage('この項目は必須入力です。') .isLength({ min:8, max:25 }).withMessage('8文字から25文字にしてください。') .custom((value, { req }) => { if(req.body.password !== req.body.passwordConfirmation) { throw new Error('パスワード(確認)と一致しません。'); } return true; }) ]; app.post('/password/reset', [passwordResetValidationRules], (req, res) => { const errors = validationResult(req); if(!errors.isEmpty()) { // バリデーション失敗 return res.status(422).json({ errors: errors.array() }); } const email = req.body.email; const password = req.body.password; const token = req.body.token; PasswordReset.findOne({ where: { email: email }, include: [ { model: User } ] }).then(passwordReset => { if(passwordReset && passwordReset.token === token && passwordReset.User) { const user = passwordReset.User; user.password = bcrypt.hashSync(password, bcrypt.genSaltSync(8)); user.save(); passwordReset.destroy(); res.json({ result: true }); } else { return res.status(422).json({ errors: [ { value: '', msg: 'このパスワードリセットトークンは無効です。', param: 'token', location: 'body' } ] }); } }); });
この中での流れは以下のとおりです。
- メールアドレス、パスワードが正しいかチェック
- メールアドレスからパスワード再発行情報を取得
- もしデータが存在していれば、新しいパスワードを暗号化(ハッシュ化)。新パスワードとして保存
テストしてみる
では、実際にテストしてみましょう!
まず/password/reset
にアクセスして、メールアドレスを送信します。
すると以下のアップが表示されます。
メールをチェックします。
メールに書かれているURLをクリックすると、以下のフォームが表示されるので「パスワード」「パスワード(確認)」を入力します。(メールアドレスは自動入力されます)
送信ボタンをクリックすると・・・
はい!
全てうまくいきました😊✨
ダウンロードする
今回実際に開発したソースコード一式を以下からダウンロードできます。
※ただし、パッケージのインストールやマイグレーション、テンプレート・エンジンの設定などはご自身で行っていただく必要があります。
Expressでパスワード再発行機能をつくるおわりに
ということで、今回は前回に引き続きExpress
でパスワードの再発行機能をつくってみました。
結果として想像よりもたくさんやることがあったので、正直毎回こんなコードを直に書いていたらめんどうだなー💦 なんて思ってしまいました。
もういっそのこと、Express
の方で「ログイン」「ユーザー登録」「パスワード再発行」はパッケージ化しておいて、Laravel
のようにインストールすればすぐ使えるようにすればいいのにな・・・なんて勝手に思ってしまいました。
(自分でやるべき??・・・うーん、、、検討してみます😅)
ではでは〜!