
九保すこひです(フリーランスのITコンサルタント、エンジニア)
さてさて、前回記事「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
のようにインストールすればすぐ使えるようにすればいいのにな・・・なんて勝手に思ってしまいました。
(自分でやるべき??・・・うーん、、、検討してみます)
ではでは〜!