Lsiron
HELLO FOLIO(express, mongoose)- 8(Nodemailer 를 이용한 비밀번호 재설정 구현 2) 본문
HELLO FOLIO(express, mongoose)- 8(Nodemailer 를 이용한 비밀번호 재설정 구현 2)
Lsiron 2024. 7. 13. 16:17현재 폴더 구조
web_project/
├── db/
│ ├── model # 각종 모델 폴더
│ ├── schemas # 각종 스키마 폴더
│ └── index.js # db 연결파일
├── middleware/ # 각종 middleware 폴더
├── passport/ # 로그인 관련 passport 폴더
│ ├── strategies # 전략 구성
│ └── index.js # 전략 exports 파일
├── routes/ # 라우트 관련 폴더
│ └── index.js # 라우트 exports 파일
├── services/ # 유저 관련 service 폴더
├── views/ # EJS 템플릿 폴더
├── public/ # static 파일 폴더
│ ├── css/ # CSS 파일들
│ ├── js/ # JS 파일들
│ └── images/ # 이미지 파일들
├── .env # 환경 변수 파일
├── .gitignore # Git 무시 파일
├── package.json # 프로젝트 메타데이터 및 종속성 목록
├── app.js # 애플리케이션 진입점
├── index.js # 서버 실행 파일
└── README.md # 프로젝트 설명 파일
현재 설치한 모듈
"dependencies": {
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"ejs": "^3.1.10",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"mongodb": "^6.7.0",
"mongoose": "^8.4.3",
"nodemailer": "^6.9.13",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0"
},
app.js
const express = require('express');
const app = express();
const bcrypt = require('bcrypt');
const passport = require('passport');
const { userRouter, myPageRouter } = require("./routes");
require('./passport')();
const authMiddleware = require('./middleware/authMiddleware');
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(passport.initialize());
app.use('/', userRouter);
app.use('/', resetPwRouter);
app.use('/my-page', authMiddleware ,myPageRouter);
module.exports = { app };
.env
# DB 접속 URL
DB_URL="디비주소"
# DB 프로젝트 이름
DB_NAME="디비이름"
# 서버 포트 번호
PORT=3000
# JWT토큰 발급 키
SECRET="secret"
# REFRESH 토큰 발급 키
REFRESH_SECRET="toosecret"
# 비밀번호 찾기 이메일 발송메일
EMAIL_USER=내이메일
# 비밀번호 찾기 이메일 비밀번호
EMAIL_PASS=내비번
1. 이메일을 기입하는 라우터와 페이지 생성
2. 비밀번호를 재설정하는 라우터와 페이지 생성
3. 클라이언트로부터 이메일을 받으면 서버는 해당 이메일로 비밀번호를 재설정하는 페이지 링크를 보내기
4. 클라이언트로부터 새로운 비밀번호를 받으면 암호화 하여, 데이터베이스에 저장하기
남은건 클라이언트로부터 새로운 비밀번호를 받으면 암호화 한 뒤, 데이터베이스에 저장을 해야한다.
먼저 resetPwRouter.js로 가서 비밀번호 재설정에 대한 get요청과 post요청을 다뤄야겠다.
const { Router } = require('express');
const resetPwRouter = Router();
const UserModel = require('../db/model/userModel');
const crypto = require('crypto');
const nodemailer = require("nodemailer");
resetPwRouter.get('/forgot-password', (req, res) => {
res.render(forgot-pw.ejs)
});
resetPwRouter.post('/forgot-password', async (req, res) => {
try {
const { email } = req.body;
const user = await UserModel.findOne({ email });
if (!user) {
return res.status(400).send("등록된 사용자가 아닙니다.");
}
const token = crypto.randomBytes(20).toString('hex');
user.resetPwToken = token;
user.resetPwExpires = Date.now() + 3600000;
await user.save();
const resetLink = `http://${req.headers.host}/reset-password/${token}`;
const transporter = nodemailer.createTransport({
service: 'Gmail',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});
async function sendResetEmail() {
await transporter.sendMail({
from: process.env.EMAIL_USER,
to: email,
subject: '비밀번호 재설정',
text: `다음 링크에 접속하여 비밀번호를 재설정하세요. ${resetLink}`
});
}
await sendResetEmail();
res.send('비밀번호 재설정 이메일 전송')
} catch (error) {
res.status(500).send("서버 오류");
}
});
resetPwRouter.get('/reset-password/:token', (req, res) => {
res.render(reset-pw.ejs)
});
resetPwRouter.post('/reset-password/:token', (req, res) => {
res.send(reset-pw)
});
module.exports = resetPwRouter;
유저는 우리가 토큰을 삽입한 링크를 타고 들어올 것이다.
URL 파라미터 방식으로 설정하여, 토큰을 삽입한 링크를 타고 들어왔으니 URL에 있는 정보인 token을 params로 받아주자.
params로 받은 토큰으로 db에 저장된 유저의 resetPwtoken과 일치하는지 확인해주고, 이에 더해서 resetPwExpires 필드를 이용하여 Date.now() + 360000 으로 미리 넣어주었으니까 Date.now()보다 큰 값인지 확인하여( 현재 유효한 토큰인지 확인하여 ) 만료된 토큰 허용 방지를 해주자.
동시에 유저를 찾지 못할 경우를 삽입해주고, 마지막으로 reset-pw.ejs를 띄우도록 렌더 해 주면서, 비밀번호 재 생성 post 요청을 보낼 수 있도록 해주자. 단, token을 reset-pw.ejs에 넘겨주어야 한다. 이유는 후에 설명하도록 하겠다.
const { Router } = require('express');
const resetPwRouter = Router();
const UserModel = require('../db/model/userModel');
const crypto = require('crypto');
const nodemailer = require("nodemailer");
resetPwRouter.get('/forgot-password', (req, res) => {
res.render(forgot-pw.ejs)
});
resetPwRouter.post('/forgot-password', async (req, res) => {
try {
const { email } = req.body;
const user = await UserModel.findOne({ email });
if (!user) {
return res.status(400).send("등록된 사용자가 아닙니다.");
}
const token = crypto.randomBytes(20).toString('hex');
user.resetPwToken = token;
user.resetPwExpires = Date.now() + 3600000;
await user.save();
const resetLink = `http://${req.headers.host}/reset-password/${token}`;
const transporter = nodemailer.createTransport({
service: 'Gmail',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});
async function sendResetEmail() {
await transporter.sendMail({
from: process.env.EMAIL_USER,
to: email,
subject: '비밀번호 재설정',
text: `다음 링크에 접속하여 비밀번호를 재설정하세요. ${resetLink}`
});
}
await sendResetEmail();
res.send('비밀번호 재설정 이메일 전송')
} catch (error) {
res.status(500).send("서버 오류");
}
});
resetPwRouter.get('/reset-password/:token', (req, res) => {
//URL파라미터를 통해 token을 params로 받아옴
const { token } = req.params;
//resetPwToken과 resetPwExpires 필드를 이용하여 유저 찾기
const user = await UserModel.findOne({
resetPwToken: token,
resetPwExpires: { $gt: Date.now() }
});
// 유저를 못 찾을 경우
if (!user) {
return res.status(400).send("유효하지 않거나 만료된 토큰입니다.");
}
// reset-pw.ejs와 token 변수를 함께 렌더 시킴.
res.render('reset-pw.ejs', { token });
});
resetPwRouter.post('/reset-password/:token', (req, res) => {
res.send(reset-pw)
});
module.exports = resetPwRouter;
이제 정당하고 유효한 토큰을 가지고있는 유저는 새 비밀번호 페이지를 들어갈 수 있게 되었다.
그럼 이제 reset-pw.ejs로 가서 왜 token 변수를 넘겨주는지 확인해보자.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HELLO FOLIO</title>
</head>
<body>
<form action="/reset-password" method="POST">
<input type="password" name="password" placeholder=" new password">
<input type="password" name="confirmPassword" placeholder=" confirm new password">
<button type="submit">비밀번호 재설정</button>
</form>
</body>
</html>
현재 reset-pw.ejs는 별 문제가 없어 보인다. 허나 우리는 링크를 무엇으로 바꿔주었는가 ?!
정당하고 유효한 토큰을 가진자만 페이지를 들어오고 새 비밀번호를 설정할 수 있도록 링크를 설정 해 주었다.
서버에서 넘겨준 token 변수를 가져와서 링크를 바꿔보자.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HELLO FOLIO</title>
</head>
<body>
<form action="/reset-password/<%= token %>" method="POST">
<input type="password" name="password" placeholder=" new password">
<input type="password" name="confirmPassword" placeholder=" confirm new password">
<button type="submit">비밀번호 재설정</button>
</form>
<script>
const token = '<%= token %>';
</script>
</body>
</html>
script를 이용해, 넘겨받은 token을 reset-pw.ejs에서 다시 변수로 선언 해 준 뒤 ejs에서 사용할 수 있도록 변경 해 주었다.
참고로 ejs에서 html 코드를 작성할 때, script를 바로 적용 하려면 <%%> 처럼 꺽쇄를 사용한다.
이 꺽쇄에는 종류가 세 가지가 있다.
1. <%script%> : 자바스크립트 문법을 생으로 사용할 때 쓴다. 즉 <% for (let i= 0 ; ) ....%> 처럼 반복문등을 쓸 때
2. <%- script%> : include('nav.ejs') 처럼 특수한 문법을 쓸 때 사용한다. 이 문법은 안에있는 데이터가 html 형식일 경우에 실제 html 처럼 인식한다. 즉, <p>nav 입니다</p> 코드가 nav입니다 로 화면에 나타난다. 이 처럼 html 코드가 실제 html 처럼 인식돼서 나온다.
3. <%= script%> : 변수를 출력하거나, 자바스크립트 표현식을 나타낼 때 사용한다. 이 문법은 <%- script%> 와 기능이 거의 유사하다. 허나 안에있는 데이터가 html 형식일 경우에 전부 문자로 인식한다. 예를 들어, <%= include('nav.ejs')%> 이렇게 적용할 경우, nav의 html 요소가 실제 html로 적용 되는것이 아닌 전부 문자로 인식된다. 즉, <p>nav 입니다</p> 코드가 <p>nav 입니다</p> 로 생으로 화면에 나타난다.
다시 resetPwRouter.js로 가서 새 비밀번호 요청을 받아주자.
새 비밀번호를 만드는 것은 회원가입을 구현 할 때, 새 유저의 비밀번호 정보를 담을 때와 똑같다. form태그를 이용하여 body로 넘겨받은 비밀번호 정보를 해쉬로 암호화 시킨 뒤, db에 저장만 시키면 된다.
다만 회원가입과 달리 추가할 점은 token을 다뤄야한다는 점.
똑같이 token을 params에 담아서 보내준 후, resetPwtoken과 resetPwExpires 필드를 통해 유저를 확인 해 주고, 유저를 찾지 못 할 경우를 넣어주자.
그리곤 비밀번호를 새로 저장하면 undefined로 token을 초기화 시켜주자.
마지막으로 유저를 로그인 화면으로 이동시켜주자.
const { Router } = require('express');
const resetPwRouter = Router();
const UserModel = require('../db/model/userModel');
const crypto = require('crypto');
const nodemailer = require("nodemailer");
const bcrypt = require('bcrypt');
resetPwRouter.get('/forgot-password', (req, res) => {
res.render(forgot-pw.ejs)
});
resetPwRouter.post('/forgot-password', async (req, res) => {
try {
const { email } = req.body;
const user = await UserModel.findOne({ email });
if (!user) {
return res.status(400).send("등록된 사용자가 아닙니다.");
}
const token = crypto.randomBytes(20).toString('hex');
user.resetPwToken = token;
user.resetPwExpires = Date.now() + 3600000;
await user.save();
const resetLink = `http://${req.headers.host}/reset-password/${token}`;
const transporter = nodemailer.createTransport({
service: 'Gmail',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});
async function sendResetEmail() {
await transporter.sendMail({
from: process.env.EMAIL_USER,
to: email,
subject: '비밀번호 재설정',
text: `다음 링크에 접속하여 비밀번호를 재설정하세요. ${resetLink}`
});
}
await sendResetEmail();
res.send('비밀번호 재설정 이메일 전송')
} catch (error) {
res.status(500).send("서버 오류");
}
});
resetPwRouter.get('/reset-password/:token', (req, res) => {
const { token } = req.params;
const user = await UserModel.findOne({
resetPwToken: token,
resetPwExpires: { $gt: Date.now() }
});
if (!user) {
return res.status(400).send("유효하지 않거나 만료된 토큰입니다.");
}
res.render('reset-pw.ejs', { token });
});
resetPwRouter.post('/reset-password/:token', (req, res) => {
//URL파라미터를 통해 token을 params로 받아옴
const { token } = req.params;
//ejs에서 form태그를 통해 password를 body로 받아옴
const { password } = req.body;
//resetPwToken과 resetPwExpires 필드를 통해 유저를 확인
const user = await UserModel.findOne({
resetPwToken: token,
resetPwExpires: { $gt: Date.now() }
});
//유저를 찾지 못 할경우
if (!user) {
return res.status(400).send("유효하지 않거나 만료된 토큰입니다.");
}
// 비밀번호를 bcrypt로 해싱하여 저장
const hashedPassword = await bcrypt.hash(password, 10);
user.password = hashedPassword;
// 토큰 관련 필드 초기화
user.resetPwToken = undefined;
user.resetPwExpires = undefined;
// 변경한 유저정보 저장
await user.save();
// 로그인 화면으로 이동
res.redirect('/login');
});
module.exports = resetPwRouter;
이제 비밀번호 재설정 페이지와 요청을 모두 끝냈다. 마지막으로 예외처리만 해주자.
const { Router } = require('express');
const resetPwRouter = Router();
const UserModel = require('../db/model/userModel');
const crypto = require('crypto');
const nodemailer = require("nodemailer");
const bcrypt = require('bcrypt');
resetPwRouter.get('/forgot-password', (req, res) => {
res.render(forgot-pw.ejs)
});
resetPwRouter.post('/forgot-password', async (req, res) => {
try {
const { email } = req.body;
const user = await UserModel.findOne({ email });
if (!user) {
return res.status(400).send("등록된 사용자가 아닙니다.");
}
const token = crypto.randomBytes(20).toString('hex');
user.resetPwToken = token;
user.resetPwExpires = Date.now() + 3600000;
await user.save();
const resetLink = `http://${req.headers.host}/reset-password/${token}`;
const transporter = nodemailer.createTransport({
service: 'Gmail',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});
async function sendResetEmail() {
await transporter.sendMail({
from: process.env.EMAIL_USER,
to: email,
subject: '비밀번호 재설정',
text: `다음 링크에 접속하여 비밀번호를 재설정하세요. ${resetLink}`
});
}
await sendResetEmail();
res.send('비밀번호 재설정 이메일 전송')
} catch (error) {
res.status(500).send("서버 오류");
}
});
resetPwRouter.get('/reset-password/:token', (req, res) => {
const { token } = req.params;
const user = await UserModel.findOne({
resetPwToken: token,
resetPwExpires: { $gt: Date.now() }
});
if (!user) {
return res.status(400).send("유효하지 않거나 만료된 토큰입니다.");
}
res.render('reset-pw.ejs', { token });
});
resetPwRouter.post('/reset-password/:token', async (req, res) => {
try {
const { token } = req.params;
const { password } = req.body;
const user = await UserModel.findOne({
resetPwToken: token,
resetPwExpires: { $gt: Date.now() }
});
if (!user) {
return res.status(400).send("유효하지 않거나 만료된 토큰입니다.");
}
const hashedPassword = await bcrypt.hash(password, 10);
user.password = hashedPassword;
user.resetPwToken = undefined;
user.resetPwExpires = undefined;
await user.save();
res.redirect('/login');
} catch (error) {
res.status(500).send("서버 오류");
}
});
module.exports = resetPwRouter;
이로써 비밀번호 재생성 (구 비밀번호 찾기) 기능을 구현 하였다.