Lsiron

HELLO FOLIO(express, mongoose)- 7(Nodemailer 를 이용한 비밀번호 재설정 구현) 본문

개발일지/HELLO FOLIO

HELLO FOLIO(express, mongoose)- 7(Nodemailer 를 이용한 비밀번호 재설정 구현)

Lsiron 2024. 7. 8. 06:13

현재 폴더 구조

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",
    "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('/my-page', authMiddleware ,myPageRouter); 

module.exports = { app };

.env

# DB 접속 URL
DB_URL="디비주소"
# DB 프로젝트 이름
DB_NAME="디비이름"
# 서버 포트 번호
PORT=3000
# JWT토큰 발급 키
SECRET="secret"
# REFRESH 토큰 발급 키
REFRESH_SECRET="toosecret"

 

로그인과 회원가입 구현을 완료하였으니, 이젠 비밀번호를 기억하지 못할 때 비밀번호를 다시 설정 할 수 있도록 하는 비밀번호 재생성 기능을 만들어보자.

(구 비밀번호 찾기 기능. 현재는 보안관계상 설정해놓았던 비밀번호 찾기가 아니라 비밀번호를 새로이 설정 할 수 있도록 한다.)

 

먼저 나는 비밀번호를 기억하지 못할 때, 재설정 버튼을 누르면 사용자가 가입한 이메일을 기입하도록 한 뒤, 그 이메일로 비밀번호를 재 설정 할 수있는 링크를 보내줄 것 이다.

 

그렇다면 내가 해야 할 것은? 

 

1. 이메일을 기입하는 라우터와 페이지 생성

2. 비밀번호를 재설정하는 라우터와 페이지 생성

3. 클라이언트로부터 이메일을 받으면 서버는 해당 이메일로 비밀번호를 재설정하는 페이지 링크를 보내기

4. 클라이언트로부터 새로운 비밀번호를 받으면 암호화 하여, 데이터베이스에 저장하기

 

먼저 routes 폴더로 가서 resetPwRouter.js 파일을 하나 만들어주고, 아래와 같이 파일을 작성해주자.

const { Router } = require('express');
const resetPwRouter = Router();

// 비밀번호 재설정 요청 페이지
resetPwRouter.get('/forgot-password', (req, res) => {
    res.render(forgot-pw.ejs)
});

// 비밀번호 재설정 요청 처리
resetPwRouter.post('/forgot-password', (req, res) => {
    res.send(forgot-pw)
});

// 비밀번호 재설정 페이지
resetPwRouter.get('/reset-password', (req, res) => {
    res.render(reset-pw.ejs)
});

// 비밀번호 재설정 처리
resetPwRouter.post('/reset-password', (req, res) => {
    res.send(reset-pw)
});

module.exports = resetPwRouter;

 

초석만 작성한 뒤에, module.exports로 resetPwRouter를 내보내주자.

 

이제 routes 폴더의 최 상단에 있는 index.js로 가서 resetPwRouter를 받고 다시 내보내주자.

const userRouter = require("./userRouter");
const myPageRouter = require("./myPageRouter")
const resetPwRouter = require("./resetPwRouter")

module.exports = {
    userRouter,
    myPageRouter,
    resetPwRouter,
};

 

index.js로 내보내 주었다면 app.js로 가서 resetPwRouter를 받아오자.

const express = require('express');                     
const app = express();
const bcrypt = require('bcrypt');
const passport = require('passport');				

const { userRouter, resetPwRouter, 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);				// resetPwRouter 작동
app.use('/my-page', authMiddleware ,myPageRouter); 

module.exports = { app };

 

Router를 만들어 주었으니 간단하게 ejs 파일을 만들어야겠다.

 

먼저 views 폴더로 가서 forgot-pw.ejs와 reset-pw.ejs를 만들어주고, 아래와 같이 작성해보자.

 

이번에도 form태그를 사용하여 요청을 처리할 것이다.

 

간단하게 form태그를 사용하여 요청을 보내는 상황과 fetch를 사용하여 ajax요청을 보내는 상황을 정리하자면 다음과 같다.

 

1. form 태그를 사용하여 요청 보내기

  • 사용자 로그인/회원가입 폼.
  • 간단한 문의나 연락 폼.
  • 파일 업로드 폼.

2. fetch를 사용하여 ajax 요청 보내기

  • 댓글 작성 후 페이지 일부 업데이트.
  • 실시간 검색 자동 완성.
  • 비동기 폼 검증 및 제출.
  • AJAX 기반 CRUD(생성, 읽기, 업데이트, 삭제) 작업.

먼저 forgot-pw.ejs 이다.

<!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="/forgot-password" method="POST">
            <input type="email" name="email">
            <button type="submit">비밀번호 재설정 링크 보내기</button>
        </form>
</body>
</html>

 

다음은 reset-pw.ejs 이다.

<!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>

 

1. 이메일을 기입하는 라우터와 페이지 생성

2. 비밀번호를 재설정하는 라우터와 페이지 생성

3. 클라이언트로부터 이메일을 받으면 서버는 해당 이메일로 비밀번호를 재설정하는 페이지 링크를 보내기

4. 클라이언트로부터 새로운 비밀번호를 받으면 암호화 하여, 데이터베이스에 저장하기

 

이제 3번을 구현해야한다. 이메일을 넘겨받았을 때 서버는 재설정 할 수 있는 링크를 주어야한다.

 

허나, 재설정 링크로 아무나 접속을 해서는 안되며 오로지 해당 유저만 들어올 수 있는 페이지 링크여야 한다.

 

그러면 해당 유저만 들어올 수 있도록 하는 방법에는 무엇이 있을까?

 

유저가 고유하게 가지고있는 번호이면서 동적으로 변해야한다.

 

바로 토큰과 같이 유저에게 입장권을 주면 된다. 즉, DB의 필드에 키를 설정 해두고 유효기간이 끝나면 사라지도록 하는 것.

 

입장권의 개념은 토큰과 유사하기에 이름을 토큰이라고 하겠다.

 

이 토큰을 생성할 때는 node.js 내장 모듈인 crypto를 사용해야한다. 즉 토큰을 암호화 시키는 것.

 

그리곤 crypto를 통해 생성된 암호문자를 포함한 링크를 이메일로 보내도록 해야한다.

 

먼저 입장권을 가지고 있을 수 있도록 UserSchema.js 로 가서 resetPwToken 필드와 resetPwExpires 필를  추가해준다.

const mongoose = require('mongoose');

const UserSchema = new mongoose.Schema({
    email: {
        type: String,
        required: true,
        unique: true,
    },
    password: {
        type: String,
        required: true,
    },
    name: {
        type: String,
        required: true,
    },
    refreshToken: {
        type: String,
    },
// 비밀번호 찾기 토큰
    resetPwToken: {
        type: String,
    },
    resetPwExpires: {
        type: Date,
    },
{
    collection: "users",
    timestamps: true,
});

module.exports = UserSchema;

 

이제 resetPwRouter.js로 다시 가보자.


먼저 userModel을 불러오고, node.js 내장 모듈인 crypto 를 불러오자.  

 

클라이언트로 부터 받은 email로 user를 찾고 암호화 시킨 토큰을 만든 다음에 DB에 토큰과 토큰의 유효기간을 함께 저장한다.  마지막으로 email 링크를 생성해보자.

const { Router } = require('express');
const resetPwRouter = Router();
const UserModel = require('../db/model/userModel');
const crypto = require('crypto');

resetPwRouter.get('/forgot-password', (req, res) => {
    res.render(forgot-pw.ejs)
});

resetPwRouter.post('/forgot-password', async (req, res) => {
        const { email }  = req.body;
        const user = await UserModel.findOne({ email });

        const token = crypto.randomBytes(20).toString('hex');
        user.resetPwToken = token;
        user.resetPwExpires = Date.now() + 3600000; // 만료기간 1시간으로 설정
        await user.save();

        const resetLink = `http://${req.headers.host}/reset-password/${token}`;
        
        res.send(forget-pw)
});

resetPwRouter.get('/reset-password', (req, res) => {
    res.render(reset-pw.ejs)
});

resetPwRouter.post('/reset-password', (req, res) => {
    res.send(reset-pw)
});

module.exports = resetPwRouter;

 

 거듭 말하지만 const { email } = req.body 는 const email = req.body.email 과 같다. 즉, 구조분해할당을 한 것이다.

 

req.headers.host 는 HTTP 요청 객체(req)의 헤더(headers)에서 host 필드를 의미한다.

 

보통 웹 애플리케이션에서 HTTP 요청이 발생하면, 클라이언트가 요청한 URL의 호스트 정보는 HTTP 헤더에 포함되어 전달된다.

 

즉, 우리가 지정한 포트 8000을 포함한 localhost:8000 이 된다.

 

그러면 reset-password 링크는 token값에 따라 변동한다. 그러면 각 라우터들의 URL 경로도 바꿔주어야한다.

 

URL의 특정 부분에 데이터를 포함시키는 행위를 했기 때문에 URL Parameter 방식을 사용해서 URL을 만들어주자.

const { Router } = require('express');
const resetPwRouter = Router();
const UserModel = require('../db/model/userModel');
const crypto = require('crypto');

resetPwRouter.get('/forgot-password', (req, res) => {
    res.render(forgot-pw.ejs)
});

resetPwRouter.post('/forgot-password', async (req, res) => {
        const { email }  = req.body;
        const user = await UserModel.findOne({ email });

        const token = crypto.randomBytes(20).toString('hex');
        user.resetPwToken = token;
        user.resetPwExpires = Date.now() + 3600000; // 만료기간 1시간으로 설정
        await user.save();

        const resetLink = `http://${req.headers.host}/reset-password/${token}`;
        
        res.send(forget-pw)
});

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;

 

특별한 것은 없다 그냥 reset-password/ 뒤에 :token만 붙여주었다. 참고로 세미콜론(:)을 안쓰고 그냥 token만 쓰면 그냥 token 사이트 하나를 만든셈이다. ( 정적인 사이트 )

 

이제 이메일 링크도 만들었고 동적인 비밀번호 재 설정 사이트도 만들어주었다. 

 

1. 이메일을 기입하는 라우터와 페이지 생성

2. 비밀번호를 재설정하는 라우터와 페이지 생성

3. 클라이언트로부터 이메일을 받으면 서버는 해당 이메일로 비밀번호를 재설정하는 페이지 링크를 보내기

4. 클라이언트로부터 새로운 비밀번호를 받으면 암호화 하여, 데이터베이스에 저장하기

 

이제 본격적으로 3번을 해보자.

 

일단 이메일 전송 시스템을 사용하려면 Nodemailer 라는 모듈을 사용해야한다.

터미널에 아래와 같이 입력한 후, nodemailer를 설치해주자.

$ npm install nodemailer

 

자 이제, nodemailer가 설치되었다. 그러면 라이브러리를 불러와주고 기본코드를 입력해준다.

 

참고로 기본 코드는 nodemailer 공식 사이트에 나와있다. https://nodemailer.com/about/

 

Nodemailer :: Nodemailer

Nodemailer Nodemailer is a module for Node.js applications to allow easy as cake email sending. The project got started back in 2010 when there was no sane option to send email messages, today it is the solution most Node.js users turn to by default. npm i

nodemailer.com

 

const { Router } = require('express');
const resetPwRouter = Router();
const UserModel = require('../db/model/userModel');
const crypto = require('crypto');
const nodemailer = require("nodemailer");	//nodemailer 라이브러리 불러오기

resetPwRouter.get('/forgot-password', (req, res) => {
    res.render(forgot-pw.ejs)
});

resetPwRouter.post('/forgot-password', async (req, res) => {
        const { email }  = req.body;
        const user = await UserModel.findOne({ email });

        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}`;
        // nodemaile 기본코드
        const transporter = nodemailer.createTransport({
        host: "smtp.ethereal.email",
        port: 587,
        secure: false, 
        auth: {
            user: "maddison53@ethereal.email",
            pass: "jn7jnAPss4f63QBp6D",
        },
        });

        async function main() {
        const info = await transporter.sendMail({
            from: '"Maddison Foo Koch" <maddison53@ethereal.email>', 
            to: "bar@example.com, baz@example.com",
            subject: "Hello ✔", 
            text: "Hello world?", 
            html: "<b>Hello world?</b>", 
        });

        res.send(forget-pw)
});

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;

 

불필요한 주석과 console.log() 는 삭제 해 주었다.

 

이제 우리의 입맛대로 한번 바꿔보자. 나는 Gmail을 통해 이메일을 보낼 것 이다.

참고로 transporter 변수에 host, port, secure 키와 값들은 service : 'Gmail' 로 바꿀 수 있다.

이는 nodemailer가 내부적으로 Gmail SMTP 서버의 설정을 미리 정의해 두었기 때문에 가능하다.  

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) => {
        const { email }  = req.body;
        const user = await UserModel.findOne({ email });
        
        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: 내이메일,
                pass: 내비번
            }
        });

        async function sendResetEmail() {
          await transporter.sendMail({
            from: 내이메일, 
            to: email,
            subject: '비밀번호 재설정',
            text: `다음 링크에 접속하여 비밀번호를 재설정하세요. ${resetLink}`
        });
        }

        await sendResetEmail();

        res.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;

 

transporter 변수에 있는 host, port, secure 키와 값들을 service : 'Gmail' 로 바꿔주었고, 기본 함수로 되어있던 main 함수의 이름을 sendResetEmail로 바꾼 뒤 비동기 방식으로 호출 해 주었다.

 

여기서 주의할 점은 pass 에서 이메일의 비밀번호는 실제 비밀번호가 아니라, 해당 이메일 사이트에서 어플리케이션 비밀번호를 설정한 뒤에 그 비밀번호를 넣어주어야한다.

 

또한 기본 함수로 있던 변수 info 또한 제거 하였으며, to는 클라이언트로부터 받은 이메일 변수를 넣어주었다.

 

허나 내이메일과 내비번은 개인정보이기 때문에 .env에 넣어주고 dotenv 문법을 사용하여 채워주자. 

 

.env로 가서 정보를 입력해준다.

# DB 접속 URL
DB_URL="디비주소"
# DB 프로젝트 이름
DB_NAME="디비이름"
# 서버 포트 번호
PORT=3000
# JWT토큰 발급 키
SECRET="secret"
# REFRESH 토큰 발급 키
REFRESH_SECRET="toosecret"
# 비밀번호 찾기 이메일 발송메일
EMAIL_USER=내이메일
# 비밀번호 찾기 이메일 비밀번호
EMAIL_PASS=내비번

 

다시 resetPwRouter.js로 가서 환경변수 문법을 넣어주고 예외처리도 해주자.

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;

 

이제 3번인 비밀번호 재생성 링크를 보내는 작업을 끝냈다. 다음은 비밀번호 재설정 처리와 요청을 할 차례이다.

 

1. 이메일을 기입하는 라우터와 페이지 생성

2. 비밀번호를 재설정하는 라우터와 페이지 생성

3. 클라이언트로부터 이메일을 받으면 서버는 해당 이메일로 비밀번호를 재설정하는 페이지 링크를 보내기

4. 클라이언트로부터 새로운 비밀번호를 받으면 암호화 하여, 데이터베이스에 저장하기