[nodeJS] Cafe Stamp 프로젝트 - JWT 인증과 nodeMailer

RedPanda·2022년 10월 31일
0

웬만한 사이트들은 회원을 관리하기 위해 회원가입 및 로그인 기능이 있다.
이번 포스팅에서는 우리가 이용한 회원 관리와 인증을 설명하고자 한다.

JWT로 로그인하기

왜 JWT를 사용하는가?

앞전에 배웠던 로그인 기능으로 세가지가 있다.
1. passportJS로 로그인을 한다.
2. JWT를 발급하여 로그인한다.
3. Session DB를 만들어 로그인을 관리한다.

세가지 방법은 모두 장단점을 가지고 있다.

  • Passport 같은 경우, 대부분을 passport의 메소드에서 관리하며 쿠키를 사용하여 로그인 여부를 관리한다. 회원 인증에 특화된 모듈이기에 직접 만드는 코드들보다 정교하고 편하다. 따라서 수정이 필요없는 경우에 편리하다. 또한, 다른 SNS 로그인을 지원하여 비교적 인증된 로그인 기능을 사용할 수 있다.
    그러나 제공하는 메소드들을 수정해야할 때에 복잡한 코드를 이해해야 하며, 기능적으로 제한되어있을 수 밖에 없다.

  • Session DB를 사용하는 경우, 로그인하는 세션들을 DB에서 모두 관리하기 때문에 로그인 정보가 가장 안전하게 저장된다.
    그러나 많은 사람이 로그인할 경우 또는 많은 사람이 로그인한 상태일 경우에 DB에 요청량이 많아지게 되므로 무리가 갈 수 있다.

  • JWT의 경우, 암호화된 토큰을 사용하며 기한을 직접 정할 수 있다. 또한, 세 방법 중에서 필자가 사용하기에 가장 편리한 사용방법이라 생각했다.
    그러나 이번 프로젝트의 특성상, 테블릿을 오래 로그인시켜야 하는 특성 때문에 JWT의 기한을 늘려야한다는 단점이 있었다.

세 경우를 모두 따져보았을 때, 나는 가장최근에 배웠기도 하고, 사용법이 비교적 간단한 JWT를 사용했다.

어떻게 사용하였는가?

앞서 말했듯이 사용법은 그렇게 어렵지 않다. 우선 JWT를 npm 해온 후에, 미들웨어에 토큰을 verify 해주는 함수를 정의해준다. 그 후에 로그인에 성공했을 때 토큰을 생성해주면 끝이다.

// middleware.js
const jwt = require("jsonwebtoken");
exports.verifyToken = (req, res, next) => {
  try {
    const token = req.headers.authorization.split(" ")[1]; 
    // jwt는 헤더에 'Bearer tokenText'와 같은 형식으로 담긴다.
    // tokenText만 사용하므로 이렇게 나눈다.
    req.decoded = jwt.verify(token, process.env.JWT_SECRET);
    return next();
  } catch (error) {
    if (error.name === "TokenExpiredError") {
      // 유효기간 초과
      const errResponse = resCode.TOKEN_EXPIRED_ERROR;
      console.log(errResponse);
      return res.status(errResponse.code).json(errResponse);
    }
    console.log(error);
    const errResponse = JSON.parse(JSON.stringify(resCode.UNAUTHORIZED_ERROR));
    console.log(errResponse);
    return res.status(errResponse.code).json(errResponse);
  }
};

// login.js
exports.signin = async (req, res, next) => {
  try {
    const { userId, password } = req.body;
    // 아이디, 비밀번호가 들어왔는지 확인
    if (!userId || !password) {
      const error = resCode.BAD_REQUEST_LACK_DATA;
      console.error(error);
      return res.status(error.code).json(error);
    }
    // 회원가입 정보를 확인
    const owner = await Owner.findOne({ where: { userId } });
    if (!owner) {
      const error = JSON.parse(JSON.stringify(resCode.BAD_REQUEST_NO_USER));
      error.message = "User is not Joinned";
      return res.status(error.code).json(error);
    } else if (!(await bcrypt.compare(password, owner.password))) {
      const error = JSON.parse(JSON.stringify(resCode.BAD_REQUEST_WRONG_DATA));
      error.message = "Wrong Password";
      return res.status(error.code).json(error);
    } else {
      // 토큰을 가지고 있는 사용자인지 확인
      if (req.headers.authorization) {
        const error = JSON.parse(
          JSON.stringify(resCode.BAD_REQUEST_WRONG_DATA)
        );
        error.message = "You are already Logged In!";
        error.token = req.headers.authorization.split(" ")[1];
        return res.status(error.code).json(error);
      }
      // 토큰 지급
      const accessToken = jwt.sign(
        {
          id: owner.id,
          isManager: owner.isManager,
        },
        process.env.JWT_SECRET,
        {
          expiresIn: "1440m", // 리프레시가 없을때 일단 이렇게 사용
          issuer: "Cafe Managers",
        }
      );
      // refreshToken 생성 필요
      req.session.jwt = accessToken;
      const response = JSON.parse(JSON.stringify(resCode.REQUEST_SUCCESS));
      response.token = accessToken;
      return res.status(response.code).json(response);
    }
  } catch (error) {
    console.log("ERROR RESPONSE -", error);
    error.statusCode = 500;
    next(error);
  }
};

생각보다 로그인에 필요한 조건이 많아 코드가 길어졌다. 프론트에서 미리 처리해주는 것도 있지만 서버에서 한번 더 처리해주는 것이 안전하다고 판단했다.

보완해야할 점

1. 탈취냐 짧은 생명 주기냐

JWT 토큰의 특성상 탈취당할 위험이 쉽다고 한다. 서버의 비밀키만 알아내면 누구나 토큰을 해석할 가능성이 있다. 따라서 탈취해도 사용하지 못하도록 생명 주기를 짧게 설정해야 하는데, 이 경우에 우리 프로젝트에서는 사용하지 못한다.

테블릿은 계속 켜져있어햐 하기 때문에 로그인이 최소 반나절에서 하루는 유지되어야 한다. 그렇기에 이번 프로젝트에서 고민한 것이 리프레쉬 토큰(refresh token) 이였다.

2. 리프레쉬 토큰이란?

리프레쉬 토큰은 JWT의 짧은 생명주기를 보완하기 위해 발급해주는 토큰이다. 짧은 기한의 토큰을 액세스 토큰이라 하는데, 액세스 토큰이 만료되면 리프레쉬 토큰에서 액세스 토큰을 찾아 재발급해주는 방법을 사용한다. 이러한 방법을 사용하면 액세스 토큰의 단점을 보완하면서 안전하게 로그인 기능을 구현할 수 있다.

3. 리프레쉬의 저장공간은 어디에?

리프레쉬 토큰은 DB에 새로운 테이블을 생성하여 저장한다고 한다. 루틴은 이렇게 된다.

  • 액세스 토큰 유효 : 바로 로그인
  • 액세스 토큰 만료 : 리프레쉬 토큰 테이블 검색 -> 리프레쉬 토큰 유효 -> 액세스 토큰 발급
  • 리프레쉬 토큰 만료 : 토큰 테이블에서 삭제 -> 로그아웃

나름대로 구현을 생각해보았을 때, 액세스 토큰의 payload에 리프레쉬 토큰의 id를 추가하면 좋겠다고 판단했다.

4. 리프레쉬 토큰 구현(예비)

따라서 예상해본 구현은 이러하다.

const Token = require("../models/token");
// login.js
.
.
.
// 로그인 조건 부합 시에
// 리프레쉬 토큰 생성 및 DB 추가
	const refreshToken = jwt.sign(
        {
          id: owner.id, // 필요없는 값 추가
        },
        process.env.JWT_SECRET,
        {
          expiresIn: "1440m", // 토큰의 생명 주기는 하루
          issuer: "Cafe Managers",
        }
      );
	const createRefresh = await Token.create({token: refreshToken});

      // 액세스 토큰 지급
      const accessToken = jwt.sign(
        {
          refresh: createRefresh.id, // 리프레쉬 토큰을 찾을 id값을 넣어둠
          id: owner.id,
          isManager: owner.isManager,
        },
        process.env.JWT_SECRET,
        {
          expiresIn: "5m", // 짧은 주기로 보안 강화
          issuer: "Cafe Managers",
        }
      );
.
.
.

// middleware.js
try {
const token = req.headers.authorization.split(" ")[1];
    req.decoded = jwt.verify(token, process.env.JWT_SECRET);
    return next();
  } catch (error) {
    if (error.name === "TokenExpiredError") {
      // 유효기간 초과 시에 리프레쉬 토큰을 찾아본다.
      const refreshToken = await Token.findOne({where: {refresh: req.decoded.token}});
      // 리프레쉬 토큰이 만료되었는지 확인
      if(jwt.verify(refreshToken, process.env.JWT_SECRET){
         // 성공 시에 새로운 액세스 토큰을 부여하고 다음으로 넘어감
        const accessToken = jwt.sign(
          {
            refresh: refreshToken.id,
            id: owner.id,
            isManager: owner.isManager,
          },
          process.env.JWT_SECRET,
          {
            expiresIn: "5m", // 짧은 주기로 보안 강화
            issuer: "Cafe Managers",
          }
        );
      req.newToken = accessToken;
      req.decoded = {id: owner.id, isManager: owner.isManager};
      return next();
      // API를 호출할 때마다 req.token을 확인해봐야함.
      // 있으면 프론트엔드에 토큰을 수정하라는 요청을 해야함.
    }
.
.
.

구현을 하면서 드는 문제점이 하나 있었다. 리프레쉬 토큰으로 액세스 토큰을 생성하는 미들웨어에서 많은 처리를 해야 한다는 점이었다.

토큰 확인 시에 생성을 하면서 프론트에 토큰 수정 요청을 보내야 하며 decoded에 새로운 토큰 정보를 담아야 한다. 이 모든 수행을 위해 위와 같은 방식을 사용했으며, 미들웨어 처리가 끝난 후에 방문하는 메소드는 토큰 수정 여부를 req.newToken에서 판단한 후에 response에 실어 보낸다. 없다면 undefined를 넣어 보내는 것도 방법이라 생각한다.어찌되었든 수정할 부분이 하나 생겼다...

NodeMailer로 이메일 전송

Nodemailer JS란?

nodemailer는 js로 간단하게 메일을 보낼 수 있는 모듈이다. nodeMailer는 메일을 보낼 수 있는 이메일 계정이 필요하며, 그 계정으로 원하는 사용자에게 텍스트 또는 html을 보낼 수 있다.

NodeMailer의 사용법

이번 프로젝트에서는 총 두가지의 기능에서 이 모듈을 사용하였다. 첫째로는 회원가입 전에 이메일 인증을 하도록 할때, 둘째는 아이디 및 비밀번호 찾기를 할 때 사용하였다. 다음은 생성한 코드이다.

// util.js
const nodmailer = require("nodemailer");

exports.makeEmail = (html, subject, email) => {
  let transporter = nodemailer.createTransport({
    service: "Naver",
    host: "smtp.naver.com",
    port: 465,
    auth: {
      user: process.env.EMAIL, // generated ethereal user
      pass: process.env.PASSWORD, // generated ethereal password
    },
    tls: {
      rejectUnauthorized: false,
      minVersion: "TLSv1.2",
    },
  });

  let mailOption = {
    from: process.env.EMAIL,
    to: email, // list of receivers
    subject, // Subject line
    html, // html body
  };
  // LocalStorage에 저장하되, 암호화하여 저장 -> 코드 인증 시에 복호화하여 비교
  return [transporter, mailOption];
};

// email.js
const { getAuthCode, makeEmail } = require("../libs/util");
const { Owner } = require("../models");
const resCode = require("../libs/error");
const bcrypt = require("bcrypt");

// 이메일 보내는 메소드
exports.emailsender = async (req, res, next) => {
  try {
    const { email } = req.params;
    const owner = await Owner.findOne({ where: { email } });
    // 유저 메일 중복 예외 처리
    if (owner) {
      const error = resCode.BAD_REQUEST_EXIESTED;
      return res.status(error.code).json(error);
    }
    const code = getAuthCode();
    if (code && email) {
      const html = `<h1>이메일 인증 코드입니다.</h1>
      <h2>${code}</h2>
      <h3>코드를 입력해주세요!</h3>
      `;
      const subject = "이메일 인증 요청 - 마이스탬프";
      // email과 code가 유효하면 메일을 보냄
      const emailMaker = makeEmail(html, subject, email);

      // LocalStorage에 저장하되, 암호화하여 저장 -> 코드 인증 시에 복호화하여 비교
      const hashCode = await bcrypt.hash(code, 12);
      console.log(hashCode);
      const info = await emailMaker[0].sendMail(emailMaker[1]);
      console.log("메일 정보: ", info);
      const response = JSON.parse(JSON.stringify(resCode.REQUEST_SUCCESS));
      response.user = {
        hash: hashCode,
        email,
      };
      return res.status(response.code).json(response);
    }
  } catch (error) {
    console.error("ERROR :", error);
    error.statusCode = 500;
    next(error);
  }
};

다음은 이메일 인증에 대한 코드이다. 실제로 사용에 필요한 코드는 makeEmail.js의 일부와 info에서 이루어지는 메소드 뿐이다.
사용하는 방법은 어렵지 않으나 메일에 담을수 있는 값이 한정적일 수 있다는 생각을 해보았다.

다음에 사용하게 되면 사진도 넣어보고 버튼으로 서버에 API를 호출하는 기능도 넣어보도록 할 예정이다.

여담

response 값에 대해 우선적으로 설명했어야 했는데 아직 못했다. 이번에 서버를 구현하고 가장 많이 한 고민이 이 부분이였는데, 후반에 구현해서 후반에 설명해도 되겠다는 착각을 한 것 같다. 따라서 다음 포스팅은 response에 대해 설명할 예정이다.
JWT 인증 또한 매우 한정적이고 구글 로그인도 구현해보고 싶은 바람이기에 프로젝트 수정 또는 다음 프로젝트에서 꼭 passport로 로그인을 구현해보도록 해야겠다. 정확히는 JWT 토큰을 다른 곳에 사용하는 방법을 생각해보도록 해야겠다.

profile
끄적끄적 코딩일기

0개의 댓글