[Node-Express] Email 인증 구현 - Final Project

조상래·2021년 4월 26일
1

코드스테이츠

목록 보기
64/73

(파이널 프로잭트 때 사용한 인증 폼)

웹앱을 이용함에 있어 가장 기본이 되는 건 바로 회원가입, 로그인이다. 요즘은 로그인이 점점 간소화 되는 추세인 것 같다. 지금 내가 쓰는 벨로그를 예를 들면 비밀 번호를 입력할 필요도 없이 이메일 인증으로만 회원가입, 로그인을 진행한다. 이로인해 유저들의 편의성을 높이고 이것 또한 우리 페이지를 다시 찾아주는 데 영향을 주지 않을까 싶었다.

그래서 우린 이 방식을 도입하기로 결정하였다.

1. 구현 방식에 대한 고민

정말 매력적인 인증 방식이었지만, 처음엔 도대체 어떻게 해야할지 감이 오지 않았다. 혼자 백앤드를 맡고있는 상황이라 오롯이 혼자서 이 방법을 생각하고 구현해야 한다는게 힘들었다.

1) 벨로그 인증 메커니즘 분석

먼저 벨로그 로그인시 오는 이메일을 분석했다. 이메일 안 링크에 파라미터로 authCode가 담겨져 있었고 링크를 따라 들어가면 그대로 바로 로그인이 되었다. 과거 소셜로그인 스프린트를 할 때의 그 메커니즘과 비슷한것 같았다. 서버에 그랜트코드를 주면 토큰을 내어주는!그래서 정리를 해보기를

이메일을 입력하고 인증 버튼을 누른다 -> 서버 이메일 인증 API로 이메일을 전송 -> 서버에서 그랜트코드 생성 후 사용자 이메일로 그랜트 코드를 담은 리다이렉트 링크 전송 -> 클라이언트에서 코드를 받은 후 서버의 회원가입 또는 로그인 API 전송 -> 코드 비교 후 토큰 전달

2) 여러가지 변수 생각하기

위 방식대로 하면 될거라 단순하게 생각했는데 여러가지 변수들이 존재했다.

  • 리다이렉트 되어 들어오는 유저 정보를 어떻게 받을 것인가?
    이 부분에서 굉장히 고민했는데, 만약 회원가입이라면 이메일 인증시 오는 이메일을 먼저 DB에 올리는 것이다. 그리고 그때 생성된 그랜트코드를 같이 DB에 저장을 한 후 그랜트코드로 유저를 구분하여 토큰을 주는 것. 로그인 시에는 이미 저장된 이메일에 그랜트코드만 저장하여 비교하는 방식으로 지정.
  • 그랜트코드는 어떻게 관리할 것인가?
    로그인/회원가입시 사용했던 그랜트코드는 인증이 완료되는 순간 DB에서 날려버린다. 그리고 setTimeout을 이용하여 그 코드로 인증이 되지 않은지 1시간이 지나면 삭제해 버린다. 이렇게 되면 보안성이 조금 더 높아 질 것이라 생각했다.
  • 만약 회원가입을 두번 한다면?
    회원가입을 눌러서 인증 이메일을 받고 링크를 눌러 회원가입을 완료하지 않고 또 회원가입을 눌러 회원가입 이메일을 보내게 된다면 어떻게 할 것인가라는 생각을 했다. 간단한 방법은 이거다. 각 유저마다 스테이터스 코드를 둔다. 0 이면 비회원, 1 이면 유저 등등 회원가입을 완료하지 않을 시 0으로 두고 만약 한시간이 지나도 완료하지 않았다면 setTimeout을 이용하여 정보 자체를 날려버리는 것.

2. 구현하기

1) 필요한 패키지 선정

  • NodeMailer
    일단 이메일을 보내기 위해 NodeMailer를 선택했다.

  • ejs
    인증 이메일 폼을 만들기 위해 ejs를 선택했고 변수를 주어 회원가입/ 로그인 폼이 다르게 가도록 설정하였다.

2) 노드메일러로 이메일을 보낼 호스트 설정

구글을 선택했다. 이번에 구현할 때 사용한 방식은 보안수준이 낮은 앱의 액세스를 허용하는 방식으로 만약 보안이 신경쓰인다면 OAuth 방식을 추천한다.

먼저 내 구글 계정에 대한 액세스 허용을 해준다.

구글 계정이 만약 2단계 인증을 하고 있는 경우라면 내 구글 계정 -> 보안 탭으로 들어간다

앱 비밀번호를 눌러 생성해주고 나오는 비밀번호는 나중에 노드메일러에서 사용 될 예정이다

3) 코드 작성하기

모두 연결되는 코드이지만 설명을 위해 떼어 놓겠다.

간단한 유효성 검사

import nodemailer from 'nodemailer';
import ejs from 'ejs';
import dotenv from 'dotenv';
import { Users } from '../../models/user';

dotenv.config();

const authEmail = async (req, res) => {
  const { email } = req.body;
  const vaildCheck = email.indexOf('@');
  if (!email || email.length === 0 || vaildCheck === -1) {
    return res.status(400).json({message: 'Need accurate informations'})
  };
  

이메일 형식으로 보내지 않았을 때 400 응답을 보내 주었다.

그리고 앞서 얘기한 변수들을 고려하여 그에 알맞는 코드 작성.

  let authCode = String(Math.random().toString(36).slice(2)) //? 랜덤 문자열 생성
  let action = ''; //? 회원가입/ 로그인을 구분하기위한 변수
  let endPoint = ''; //? 상황에 따른 리다이렉트 엔드포인트
  let display = ''; //? 상황에 따른 이메일 인증 폼

  //? 만약 이미 존재하는 유저라면 로그인 폼으로 아니라면 회원가입 폼으로.
  const isUser = await Users.findOne({where:{ email }}).then(async (data) => {
    if (data) {
      //? 존재하지만 회원가입이 완료 되지 않았을 떄 status code는 0
      const status = Number(data.getDataValue('status'));
      //? 0일 때 다시한번 authCode를 갱신하여 회원가입 이메일을 보내고
      if (status === 0) {
        await Users.update({ authCode }, {where: { email }});
        //? 1시간이 지나도 회원가입 완료하지 않을 시 자동으로 데이터 파괴
        setTimeout(async () => {
          await Users.findOne({where: { authCode }}).then( async (data) => {
            if (data) {
              const status = Number(data.getDataValue('status'));
              const email = String(data.getDataValue('email'));
              if (status === 0) {
                await Users.destroy({where: { email }});
              }
            }
          });
        }, 60 * 60 * 1000);
        action = '회원가입';
        endPoint = 'signup';
        return false;
      } else {
        await Users.update({ authCode }, {where: {email}});
        //? 로그인 으로 진행할 때 1시간 후 자동으로 authCode -> null.
        setTimeout(async () => {
          await Users.update({ authCode: null }, {where: { email }})
        }, 60 * 60 * 1000);
        action = '로그인';
        endPoint = 'login';
        display= 'none'
        return true;
      }
    } else {
      //? 데이터베이스에 정보가 없을 때
      const nickName = '시인' + Math.random().toString(36).slice(2);
      //? 회원가입 전 임시 데이터를 만들어 준다. 
      //? 만약 링크를 누른다면 signUp 메소드에서 status -> 1(회원).
      await Users.create({ email, nickName, introduction: null, authCode, status: 0, avatarUrl: null });
      //? 1시간 안에 완료하지 않을 시 데이터 자체를 파괴.
      setTimeout(async () => {
        await Users.findOne({where: { authCode }}).then( async (data) => {
          if (data) {
            const status = Number(data.getDataValue('status'));
            const email = String(data.getDataValue('email'));
            if (status === 0) {
              await Users.destroy({where: { email }});
            }
          }
        });
      }, 60 * 60 * 1000);
      action = '회원가입';
      endPoint = 'signup';
      return false;
    }
  });

첫 구현이라 겹치는 코드가 많아서 복잡해 보인다. 다음은 인증 이메일을 보내는 코드이다.

  //? ejs를 이용한 인증이메일 폼.
  let authEmailForm;
  //? 리다이렉선을 위한 코드
  //?리다이렉선을 하고싶다면 .env 에서 수정
  const clientAddr = process.env.CLIENT_ADDR || 'https://localhost:3000'
  //? ejs 모듈을 이용해 ejs 파일을 불러온다.
  //? ejs 에 담기는 변수들은 위 코드에서 경우에 따라 설정 된 상태로 올 것이다.
  ejs.renderFile(__dirname + '/authForm/authMail.ejs', { clientAddr, authCode, action, endPoint, display }, (err, data) => {
    if (err) console.log(err);
    authEmailForm = data;
  })

위에서 보내줄 폼을 설정 ejs에 대한 간략한 설명은 나중에.

  //? 메일을 보내는 코드. 사용할 플렛폼에서 권한 설정 부터!
  const transporter = nodemailer.createTransport({
    //? 아래와같이 설정해준다
    service: 'gmail',
    host: 'smtp.gmail.com',
    port: 587,
    secure: false,
    //? 여기엔 아까 생성한 앱 비밀번호와 이메일을 입력해준다.
    auth: {
      //? dotenv 환경변수를 이용하는 편이 보안에도 좋다
        user: process.env.NODEMAILER_USER,
        pass: process.env.NODEMAILER_PASSWD,
    },
  });

  await transporter.sendMail({
    from: `BBBA <tkdfo93@gmail.com>`, //? 보내는 사람 이메일 정보
    to: email, //? 받는 사람 이메일 역시 변수로 설정 해둔 상태
    //? 경우에 따른 메시지
    subject: isUser ? 'NHB에 로그인을 완료해주세요!' : 'NHB의 회원이 되어주세요!',
    html: authEmailForm,
  }, (error, info) => {
    if (error) {
      console.log(error);
    }
    res.status(200).json({"message": action});
    //? 전송을 끝내는 메소드
    transporter.close();
  });
};

여러 경우를 따지니 길고 복잡해지긴 했다.

다음은 ejs 폼을 설정해 볼 차례이다. ejs는 html에 <% %>등의 키워드를 사용하여 변수를 줄 수 있는게 장점이다. 이해가 어렵다면 위의 코드와 차근차근 비교하면서 보는게 좋다.

<html>
<body style="padding: 0; margin: 0; box-sizing: border-box; width: 920px;">
  <div style="font-size: 50px; font-weight: bold; margin: 10px 10px;">NHB</div>
  <div style="width: 920px;">
    <% if(display !== 'none') {%>
      <div style="overflow:scroll; width:auto; height:600px; padding:10px; border: 1px solid black; border-radius: 10px; margin: 10px 10px;">
        서비스 이용약관
        <br>
        <br>
        제 1 조 (목적)
        <br>
        이 약관은 BBBA(이하 '회사')이 제공하는 서비스의 이용과 관련하여 '회사'와 회원과의 권리, 의무 및 책임사항, 기타 필요한 사항을 규정함을 목적으로 합니다.
        <br><br>
        
        생략
        
        (시행일) 이 약관은 서비스 화면에 게재한 후 즉시 시행합니다.<br>
      </div>
      <div style="margin: 5px 10px; font-size: 20px; font-weight: bold;">회원가입을 완료하면 자동으로 동의가 됩니다!</div>
    <% } %>
    <a href="<%= clientAddr %>/<%= endPoint %>?authCode=<%= authCode %>"" style="margin: 5px 10px; font-size: 16px;">이곳을 눌러 <%= action %>을 완료해주세요</a>
    <p style="color:gray; margin: 5px 10px">위 링크는 1시간 후에 만료됩니다.</p>
  </div>
</body>
</html>

먼저 if를 이용하여 회원가입시 동의서를 보이도록 하였다. 그리고 변수를 주어 리다이렉트 링크를 설정해주었다. 다른 건 그냥 html을 작성하는 것과 같다.

3. 마치며

이런저런 사이트를 참고하면서 하니 생각보다 어렵진 않았다. 그러나 확실히 벨로그 처럼 링크를 줘서 리다이렉트 시키는 방식의 포스팅은 나오지 않았다. (내가 못 찾았을 수도) 그래서 조금 걱정되는 것은 '이 방식을 선호하지 않는 것인가?' 의문점을 가졌다. 자료가 없다면 그럴 만한 이유가 있을 텐데 말이다. 벨로그의 로그인 방식엔 내가 놓치고 있는 조금 더 특별한 무엇이 있나 싶기도 하다. 끊임 없이 고민을 해봐야 할 것 같다.

profile
Codestates Full IM26기 수료

0개의 댓글