NodeJSとMySQLを使用した安全なパスワードフローの作成
公開: 2022-03-10私のような人なら、特にしばらくアクセスしていないサイトでは、パスワードを何度も忘れてしまいます。 プレーンテキストでパスワードが含まれているパスワードのリセットメールも見たことがあるか、またはそれによって悔やまれている可能性があります。
残念ながら、パスワードのリセットワークフローは、アプリケーション開発中に短期間で注意が必要になります。 これは、ユーザーエクスペリエンスを苛立たせるだけでなく、アプリケーションにギャップのあるセキュリティホールを残す可能性があります。
安全なパスワードリセットワークフローを構築する方法について説明します。 基本コンポーネントとしてNodeJSとMySQLを使用します。 別の言語、フレームワーク、またはデータベースを使用して作成している場合でも、各セクションで概説されている一般的な「セキュリティのヒント」に従うことでメリットを得ることができます。
パスワードのリセットフローは、次のコンポーネントで構成されています。
- ユーザーをワークフローの開始に送るためのリンク。
- ユーザーがメールを送信できるフォーム。
- 電子メールを検証し、そのアドレスに電子メールを送信するルックアップ。
- ユーザーがパスワードをリセットできるようにする有効期限のあるリセットトークンを含む電子メール。
- ユーザーが新しいパスワードを生成できるようにするフォーム。
- 新しいパスワードを保存し、ユーザーが新しいパスワードで再度ログインできるようにします。
Node、Express、MySQLの他に、次のライブラリを使用します。
- ORMを続編する
- Nodemailer
SequelizeはNodeJSデータベースORMであり、データベースの移行とセキュリティ作成クエリの実行を容易にします。 Nodemailerは、パスワードリセットメールの送信に使用する人気のあるNodeJSメールライブラリです。
セキュリティのヒント#1
一部の記事では、JSON Web Token(JWT)を使用して安全なパスワードフローを設計できることを示唆しています。これにより、データベースストレージが不要になります(したがって、実装が簡単になります)。 JWTトークンシークレットは通常コードに直接保存されるため、このアプローチはサイトでは使用しません。 すべてを支配するために「1つの秘密」を持たないようにしたいので(同じ理由で、同じ値のパスワードをソルトしない)、したがって、この情報をデータベースに移動する必要があります。
インストール
まず、Sequelize、Nodemailer、およびその他の関連ライブラリをインストールします。
$ npm install --save sequelize sequelize-cli mysql crypto nodemailer
リセットワークフローを含めるルートに、必要なモジュールを追加します。 Expressとルートの復習が必要な場合は、ガイドを確認してください。
const nodemailer = require('nodemailer');
そして、電子メールのSMTPクレデンシャルを使用して構成します。
const transport = nodemailer.createTransport({ host: process.env.EMAIL_HOST, port: process.env.EMAIL_PORT, secure: true, auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS } });
私が使用しているメールソリューションはAWSのSimpleEmail Serviceですが、何でも使用できます(Mailgunなど)。
メール送信サービスを初めて設定する場合は、適切なドメインキーの設定と認証の設定に時間をかける必要があります。 SESと一緒にRoute53を使用する場合、これは非常にシンプルで、事実上自動的に実行されるため、私はそれを選びました。 AWSには、SESがRoute53とどのように連携するかについてのチュートリアルがいくつかあります。
セキュリティのヒント#2
コードから離れた場所にクレデンシャルを保存するために、dotenvを使用します。これにより、環境変数を使用してローカルの.envファイルを作成できます。 そうすれば、本番環境にデプロイするときに、コードに表示されないさまざまな本番環境キーを使用できるため、構成の権限をチームの特定のメンバーのみに制限できます。
データベースの設定
リセットトークンをユーザーに送信するので、それらのトークンをデータベースに保存する必要があります。
データベースに機能しているusersテーブルがあると想定しています。 すでにSequelizeを使用している場合は、すばらしいです。 そうでない場合は、SequelizeとSequelizeCLIをブラッシュアップすることをお勧めします。
アプリでSequelizeをまだ使用していない場合は、アプリのルートフォルダーで以下のコマンドを実行して設定できます。
$ sequelize init
これにより、移行やモデルなど、セットアップに多数の新しいフォルダーが作成されます。
これにより、構成ファイルも作成されます。 構成ファイルで、ローカルmysqlデータベースサーバーへの資格情報を使用してdevelopment
ブロックを更新します。
SequelizeのCLIツールを使用して、データベーステーブルを生成してみましょう。
$ sequelize model:create --name ResetToken --attributes email:string,token:string,expiration:date,used:integer $ sequelize db:migrate
このテーブルには次の列があります。
- ユーザーのメールアドレス、
- 生成されたトークン、
- そのトークンの有効期限、
- トークンが使用されたかどうか。
バックグラウンドで、sequelize-cliは次のSQLクエリを実行しています。
CREATE TABLE `ResetTokens` ( `id` int(11) NOT NULL AUTO_INCREMENT, `email` varchar(255) DEFAULT NULL, `token` varchar(255) DEFAULT NULL, `expiration` datetime DEFAULT NULL, `createdAt` datetime NOT NULL, `updatedAt` datetime NOT NULL, `used` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
SQLクライアントまたはコマンドラインを使用して、これが正しく機能したことを確認します。
mysql> describe ResetTokens; +------------+--------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +------------+--------------+------+-----+---------+----------------+ | id | int(11) | NO | PRI | NULL | auto_increment | | email | varchar(255) | YES | | NULL | | | token | varchar(255) | YES | | NULL | | | expiration | datetime | YES | | NULL | | | createdAt | datetime | NO | | NULL | | | updatedAt | datetime | NO | | NULL | | | used | int(11) | NO | | 0 | | +------------+--------------+------+-----+---------+----------------+ 7 rows in set (0.00 sec)
セキュリティのヒント#3
現在ORMを使用していない場合は、使用を検討する必要があります。 ORMは、SQLクエリの記述と適切なエスケープを自動化し、デフォルトでコードをより読みやすく、より安全にします。 SQLクエリを適切にエスケープすることで、SQLインジェクション攻撃を回避するのに役立ちます。
パスワードリセットルートの設定
user.jsでgetルートを作成します。
router.get('/forgot-password', function(req, res, next) { res.render('user/forgot-password', { }); });
次に、POSTルートを作成します。これは、パスワードのリセットフォームが投稿されたときにヒットするルートです。 以下のコードには、いくつかの重要なセキュリティ機能が含まれています。
セキュリティのヒント#4-6
- メールアドレスが見つからない場合でも、ステータスとして「ok」を返します。 私たちのデータベースでは、どの電子メールが本物であるか、本物ではないかをボットが把握することは望ましくありません。
- トークンで使用するランダムなバイトが多いほど、ハッキングされる可能性は低くなります。 トークンジェネレーターではランダムに64バイトを使用しています(8未満は使用しないでください)。
- トークンは1時間で期限切れになります。 これにより、リセットトークンが機能する時間枠が制限されます。
router.post('/forgot-password', async function(req, res, next) { //ensure that you have a user with this email var email = await User.findOne({where: { email: req.body.email }}); if (email == null) { /** * we don't want to tell attackers that an * email doesn't exist, because that will let * them use this form to find ones that do * exist. **/ return res.json({status: 'ok'}); } /** * Expire any tokens that were previously * set for this user. That prevents old tokens * from being used. **/ await ResetToken.update({ used: 1 }, { where: { email: req.body.email } }); //Create a random reset token var fpSalt = crypto.randomBytes(64).toString('base64'); //token expires after one hour var expireDate = new Date(new Date().getTime() + (60 * 60 * 1000)) //insert token data into DB await ResetToken.create({ email: req.body.email, expiration: expireDate, token: fpSalt, used: 0 }); //create email const message = { from: process.env.SENDER_ADDRESS, to: req.body.email, replyTo: process.env.REPLYTO_ADDRESS, subject: process.env.FORGOT_PASS_SUBJECT_LINE, text: 'To reset your password, please click the link below.\n\nhttps://'+process.env.DOMAIN+'/user/reset-password?token='+encodeURIComponent(token)+'&email='+req.body.email }; //send email transport.sendMail(message, function (err, info) { if(err) { console.log(err)} else { console.log(info); } }); return res.json({status: 'ok'}); });
上で参照されているUser変数が表示されます—これは何ですか? このチュートリアルでは、データベースに接続して値を取得するユーザーモデルがあることを前提としています。 上記のコードはSequelizeに基づいていますが、データベースに直接クエリを実行する場合は、必要に応じて変更できます(ただし、Sequelizeをお勧めします)。
次に、ビューを生成する必要があります。 Bootstrap CSS、jQuery、およびNode Expressフレームワークに組み込まれているpugフレームワークを使用すると、ビューは次のようになります。
extends ../layout block content div.container div.row div.col h1 Forgot password p Enter your email address below. If we have it on file, we will send you a reset email. div.forgot-message.alert.alert-success() Email address received. If you have an email on file we will send you a reset email. Please wait a few minutes and check your spam folder if you don't see it. form#forgotPasswordForm.form-inline(onsubmit="return false;") div.form-group label.sr-only(for="email") Email address: input.form-control.mr-2#emailFp(type='email', name='email', placeholder="Email address") div.form-group.mt-1.text-center button#fpButton.btn.btn-success.mb-2(type='submit') Send email script. $('#fpButton').on('click', function() { $.post('/user/forgot-password', { email: $('#emailFp').val(), }, function(resp) { $('.forgot-message').show(); $('#forgotPasswordForm').remove(); }); });
このページのフォームは次のとおりです。
この時点で、データベースにある電子メールアドレスをフォームに入力し、そのアドレスでパスワードのリセット電子メールを受信できるはずです。 リセットリンクをクリックしても、まだ何も実行されません。
「パスワードのリセット」ルートを設定する
それでは、先に進んで残りのワークフローを設定しましょう。
Sequelize.Opモジュールをルートに追加します。
const Sequelize = require('sequelize'); const Op = Sequelize.Op;
次に、パスワードのリセットリンクをクリックしたユーザーのGETルートを作成しましょう。 以下に示すように、リセットトークンを適切に検証していることを確認する必要があります。
セキュリティのヒント#7:
有効期限が切れておらず、使用されていないリセットトークンのみを検索していることを確認してください。
デモンストレーションの目的で、テーブルを小さく保つために、ここでロード時に期限切れのトークンをすべてクリアします。 大規模なWebサイトがある場合は、これをcronjobに移動します。
router.get('/reset-password', async function(req, res, next) { /** * This code clears all expired tokens. You * should move this to a cronjob if you have a * big site. We just include this in here as a * demonstration. **/ await ResetToken.destroy({ where: { expiration: { [Op.lt]: Sequelize.fn('CURDATE')}, } }); //find the token var record = await ResetToken.findOne({ where: { email: req.query.email, expiration: { [Op.gt]: Sequelize.fn('CURDATE')}, token: req.query.token, used: 0 } }); if (record == null) { return res.render('user/reset-password', { message: 'Token has expired. Please try password reset again.', showForm: false }); } res.render('user/reset-password', { showForm: true, record: record }); });
次に、ユーザーが新しいパスワードの詳細を入力するとヒットするPOSTルートを作成しましょう。
セキュリティのヒント#8から11:
- パスワードが一致し、最小要件を満たしていることを確認してください。
- リセットトークンをもう一度チェックして、使用されておらず、有効期限が切れていないことを確認します。 トークンはフォームを介してユーザーによって送信されているため、もう一度確認する必要があります。
- パスワードをリセットする前に、トークンに使用済みのマークを付けてください。 そうすれば、予期しないことが起こった場合(サーバーのクラッシュなど)、トークンがまだ有効である間はパスワードがリセットされません。
- 暗号的に安全なランダムソルトを使用します(この場合、64のランダムバイトを使用します)。
router.post('/reset-password', async function(req, res, next) { //compare passwords if (req.body.password1 !== req.body.password2) { return res.json({status: 'error', message: 'Passwords do not match. Please try again.'}); } /** * Ensure password is valid (isValidPassword * function checks if password is >= 8 chars, alphanumeric, * has special chars, etc) **/ if (!isValidPassword(req.body.password1)) { return res.json({status: 'error', message: 'Password does not meet minimum requirements. Please try again.'}); } var record = await ResetToken.findOne({ where: { email: req.body.email, expiration: { [Op.gt]: Sequelize.fn('CURDATE')}, token: req.body.token, used: 0 } }); if (record == null) { return res.json({status: 'error', message: 'Token not found. Please try the reset password process again.'}); } var upd = await ResetToken.update({ used: 1 }, { where: { email: req.body.email } }); var newSalt = crypto.randomBytes(64).toString('hex'); var newPassword = crypto.pbkdf2Sync(req.body.password1, newSalt, 10000, 64, 'sha512').toString('base64'); await User.update({ password: newPassword, salt: newSalt }, { where: { email: req.body.email } }); return res.json({status: 'ok', message: 'Password reset. Please login with your new password.'}); }); And again, the view: extends ../layout block content div.container div.row div.col h1 Reset password p Enter your new password below. if message div.reset-message.alert.alert-warning #{message} else div.reset-message.alert() if showForm form#resetPasswordForm(onsubmit="return false;") div.form-group label(for="password1") New password: input.form-control#password1(type='password', name='password1') small.form-text.text-muted Password must be 8 characters or more. div.form-group label(for="password2") Confirm new password input.form-control#password2(type='password', name='password2') small.form-text.text-muted Both passwords must match. input#emailRp(type='hidden', name='email', value=record.email) input#tokenRp(type='hidden', name='token', value=record.token) div.form-group button#rpButton.btn.btn-success(type='submit') Reset password script. $('#rpButton').on('click', function() { $.post('/user/reset-password', { password1: $('#password1').val(), password2: $('#password2').val(), email: $('#emailRp').val(), token: $('#tokenRp').val() }, function(resp) { if (resp.status == 'ok') { $('.reset-message').removeClass('alert-danger').addClass('alert-success').show().text(resp.message); $('#resetPasswordForm').remove(); } else { $('.reset-message').removeClass('alert-success').addClass('alert-danger').show().text(resp.message); } }); });
これはどのように見えるべきかです:
ログインページへのリンクを追加する
最後に、ログインページからこのフローへのリンクを追加することを忘れないでください! これを行うと、パスワードのリセットフローが機能するはずです。 プロセスの各段階で徹底的にテストして、すべてが機能し、トークンの有効期限が短く、ワークフローの進行に合わせて正しいステータスがマークされていることを確認してください。
次のステップ
これが、安全でユーザーフレンドリーなパスワードリセット機能のコーディングに役立つことを願っています。
- 暗号化セキュリティについて詳しく知りたい場合は、ウィキペディアの要約をお勧めします(警告、密集しています!)。
- アプリの認証にさらにセキュリティを追加したい場合は、2FAを調べてください。 そこにはたくさんの異なるオプションがあります。
- 独自のパスワードリセットフローを作成することを恐れている場合は、GoogleやFacebookなどのサードパーティのログインシステムを利用できます。 PassportJSは、これらの戦略を実装するNodeJSに使用できるミドルウェアです。