NodeJS 및 MySQL을 사용하여 보안 암호 흐름 만들기

게시 됨: 2022-03-10
빠른 요약 ↬ 비밀번호 재설정 기능은 사용자 친화적인 애플리케이션을 위한 테이블 스테이크입니다. 보안의 악몽이 되기도 합니다. Darshan은 NodeJS 및 MySQL을 사용하여 이러한 함정을 피할 수 있도록 안전한 비밀번호 재설정 흐름을 성공적으로 생성하는 방법을 보여줍니다.

저와 같은 사람이라면 특히 한동안 방문하지 않은 사이트에서 비밀번호를 두 번 이상 잊어버렸을 것입니다. 또한 일반 텍스트로 된 비밀번호가 포함된 비밀번호 재설정 이메일을 보았거나 이에 대해 속상해 했을 것입니다.

불행히도 비밀번호 재설정 워크플로는 응용 프로그램 개발 중에 시간이 단축되고 주의가 제한됩니다. 이는 사용자 경험을 좌절시킬 뿐만 아니라 애플리케이션에 보안 허점을 남길 수 있습니다.

안전한 비밀번호 재설정 워크플로를 구축하는 방법을 다룰 것입니다. 우리는 NodeJS와 MySQL을 기본 구성 요소로 사용할 것입니다. 다른 언어, 프레임워크 또는 데이터베이스를 사용하여 작성하는 경우에도 각 섹션에 설명된 일반 "보안 팁"을 따르는 것이 좋습니다.

재설정 암호 흐름은 다음 구성 요소로 구성됩니다.

  • 사용자를 워크플로 시작으로 보내는 링크입니다.
  • 사용자가 이메일을 제출할 수 있는 양식입니다.
  • 이메일의 유효성을 검사하고 해당 주소로 이메일을 보내는 조회입니다.
  • 만료된 재설정 토큰이 포함된 이메일로 사용자가 비밀번호를 재설정할 수 있습니다.
  • 사용자가 새 암호를 생성할 수 있는 양식입니다.
  • 새 비밀번호를 저장하고 사용자가 새 비밀번호로 다시 로그인하도록 합니다.

Node, Express 및 MySQL 외에도 다음 라이브러리를 사용할 것입니다.

  • ORM의 후속작
  • 노드메일러

Sequelize는 NodeJS 데이터베이스 ORM으로 데이터베이스 마이그레이션과 보안 생성 쿼리를 보다 쉽게 ​​실행할 수 있습니다. Nodemailer는 비밀번호 재설정 이메일을 보내는 데 사용할 인기 있는 NodeJS 이메일 라이브러리입니다.

보안 팁 #1

일부 기사에서는 JWT(JSON Web Tokens)를 사용하여 보안 암호 흐름을 설계할 수 있다고 제안합니다. 그러면 데이터베이스 저장소가 필요하지 않으므로 구현이 더 쉽습니다. JWT 토큰 비밀은 일반적으로 코드에 바로 저장되기 때문에 우리 사이트에서는 이 접근 방식을 사용하지 않습니다. 우리는 그것들을 모두 지배하는 '하나의 비밀'(같은 값으로 암호를 소금에 절이지 않는 것과 같은 이유로)을 피하고 싶고, 따라서 이 정보를 데이터베이스로 옮겨야 합니다.

점프 후 더! 아래에서 계속 읽기 ↓

설치

먼저 Sequelize, Nodemailer 및 기타 관련 라이브러리를 설치합니다.

 $ npm install --save sequelize sequelize-cli mysql crypto nodemailer

재설정 워크플로를 포함하려는 경로에서 필수 모듈을 추가합니다. Express 및 Routes에 대한 재충전이 필요한 경우 해당 가이드를 확인하십시오.

 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의 Simple Email Service이지만, 무엇이든(Mailgun 등) 사용할 수 있습니다.

이메일 전송 서비스를 처음 설정하는 경우 적절한 도메인 키를 구성하고 인증을 설정하는 데 시간을 할애해야 합니다. Route 53을 SES와 함께 사용하는 경우 이는 매우 간단하고 거의 자동으로 수행되므로 제가 선택했습니다. AWS에는 SES가 Route53에서 작동하는 방식에 대한 몇 가지 자습서가 있습니다.

보안 팁 #2

내 코드에서 자격 증명을 저장하기 위해 dotenv를 사용하여 내 환경 변수로 로컬 .env 파일을 생성할 수 있습니다. 이렇게 하면 프로덕션에 배포할 때 코드에 표시되지 않는 다른 프로덕션 키를 사용할 수 있으므로 구성 권한을 팀의 특정 구성원으로만 제한할 수 있습니다.

데이터베이스 설정

재설정 토큰을 사용자에게 보낼 것이기 때문에 해당 토큰을 데이터베이스에 저장해야 합니다.

데이터베이스에 작동하는 사용자 테이블이 있다고 가정합니다. Sequelize를 이미 사용하고 있다면 좋습니다! 그렇지 않은 경우 Sequelize 및 Sequelize CLI에 대해 정리할 수 있습니다.

앱에서 아직 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

  1. 이메일 주소를 찾지 못하더라도 상태로 'ok'를 반환합니다. 우리는 봇이 우리 데이터베이스에서 어떤 이메일이 진짜인지 아닌지 알아내는 것을 원하지 않습니다.
  2. 토큰에서 사용하는 임의의 바이트가 많을수록 해킹될 가능성이 줄어듭니다. 토큰 생성기에서 64개의 임의 바이트를 사용하고 있습니다(8개 미만은 사용하지 마십시오).
  3. 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'}); });

위에서 참조한 사용자 변수가 표시됩니다. 이것은 무엇입니까? 이 자습서의 목적을 위해 데이터베이스에 연결하여 값을 검색하는 사용자 모델이 있다고 가정합니다. 위의 코드는 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:

만료되지 않고 사용되지 않은 재설정 토큰만 찾고 있는지 확인하십시오.

데모 목적으로 테이블을 작게 유지하기 위해 여기에서 로드 시 만료된 모든 토큰도 지웁니다. 큰 웹 사이트가 있는 경우 이를 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); } }); });

다음과 같이 표시되어야 합니다.

안전한 비밀번호 재설정 워크플로를 위한 비밀번호 재설정 양식
비밀번호 재설정 양식입니다. (큰 미리보기)

로그인 페이지에 링크 추가

마지막으로 로그인 페이지에서 이 흐름에 대한 링크를 추가하는 것을 잊지 마십시오! 이렇게 하면 암호 재설정 흐름이 작동해야 합니다. 프로세스의 각 단계에서 철저하게 테스트하여 모든 것이 작동하는지 확인하고 토큰의 만료 기간이 짧고 워크플로가 진행됨에 따라 올바른 상태로 표시됩니다.

다음 단계

이 정보가 안전하고 사용자 친화적인 비밀번호 재설정 기능을 코딩하는 데 도움이 되었기를 바랍니다.

  • 암호화 보안에 대해 더 알고 싶다면 Wikipedia의 요약을 추천합니다(경고, 밀집되어 있습니다!).
  • 앱 인증에 더 많은 보안을 추가하려면 2FA를 살펴보십시오. 거기에는 다양한 옵션이 있습니다.
  • 나만의 재설정 비밀번호 흐름을 구축하는 것이 두렵다면 Google 및 Facebook과 같은 타사 로그인 시스템에 의존할 수 있습니다. PassportJS는 이러한 전략을 구현하는 NodeJS에 사용할 수 있는 미들웨어입니다.