Next.js 와 Express로 이메일 인증 로그인 구현하기

버건디·2023년 7월 17일
0

Next.js

목록 보기
43/52
post-thumbnail

프로젝트를 만들어보며 velog 같이 이메일로만으로도 로그인과 회원가입 둘 다 할 수 있도록 하고 싶었다.


- 흐름 구상도

  1. 사용자가 이메일을 입력하면 서버쪽에서 이메일을 받는다.
  2. 서버에서 암호화된 문자열 코드를 생성한다.
  3. 이메일과 코드를 DB에 저장한다.
  4. 코드를 포함하는 링크를 만들고 이를 이메일로 보낸다.
  5. 사용자가 링크를 클릭하면, 서버에서 문자열을 데이터베이스에서 찾고 이에 해당하는 이메일을 식별한다.
  6. 식별된 이메일을 이용하여 사용자가 다른 정보들을 입력 받을수 있도록한다.

1. 프론트 쪽에서 이메일 보내는 함수 작성

  const formSubmitHandler = async (e: React.MouseEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (emailref.current) {
      const res = await fetch("http://localhost:3002/api/users/signUp", {
        method: "POST",
        credentials: "include",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          email: emailref.current.value,
        }),
      });

      const data = await res.json();

      if (!res.ok) {
        throw new Error("에러가 발생했습니다!");
      } else {
        console.log(data);
        emailref.current.value = "";
      }
    }
  };

2. 백엔드에서 해당 이메일을 암호화 해주는 함수

import * as crypto from "crypto";

export const encryptEmail = async (email: string) => {
  const key = crypto.randomBytes(24);
  const iv = crypto.randomBytes(16);

  const derivedKey = crypto.scryptSync(key.toString("hex"), "salt", 24);

  const cipher = crypto.createCipheriv("aes-192-cbc", derivedKey, iv);
  const encryptedBuffer = Buffer.concat([
    cipher.update(email, "utf8"),
    cipher.final(),
  ]);

  const encrypted = encryptedBuffer.toString("base64");

  return encrypted;
};

3. 해당 암호화 된 코드를 포함한 이메일 보내주기

const sendMail = async (email: string, login: string) => {
  try {
    // 만약에 이미 인증 링크를 받은 사용자가 또 이메일 인증 메일을 받는다면
    // db에서 삭제 후 갱신
    await EmailVerify.destroy({ where: { email: email } });

    const encryptedCode = await encryptEmail(email);
    await EmailVerify.create({ email: email, encryptedCode: encryptedCode });

    let loginState = login === "login" ? "로그인" : "회원가입";
    const html = renderHtml(loginState, encryptedCode);

    let mailOptions = {
      from: process.env.GMAIL_USER,
      to: email,
      subject: `제목 ${loginState}`,
      html: html,
    };

    await transporter.sendMail(mailOptions);
  } catch (err) {
    console.error(err);
    throw new HttpError("이메일을 보내는데 실패 했어요!", 503);
  }
};

여기서 중요한건, 사용자가 이메일을 처음에 받았다가 다시 이메일을 요청했을때, db에서 원래 저장되어있던 코드를 지워주고 갱신시켜주어야한다는 점이다.

3-1. 해당 이메일과 이메일을 암호화한 코드를 저장할 테이블 생성

import {
  DataTypes,
  Model,
  Sequelize,
  InferAttributes,
  InferCreationAttributes,
  CreationOptional,
} from "sequelize";

export class EmailVerify extends Model<
  InferAttributes<EmailVerify>,
  InferCreationAttributes<EmailVerify>
> {
  declare id: CreationOptional<number>;
  declare email: string;
  declare encryptedCode: string;
  declare createdAt: CreationOptional<Date>;
  declare updatedAt: CreationOptional<Date>;
}

export function initEmail(sequelize: Sequelize): void {
  EmailVerify.init(
    {
      id: {
        type: DataTypes.INTEGER,
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
      },
      email: {
        type: DataTypes.STRING(45),
        allowNull: false,
      },
      encryptedCode: {
        type: DataTypes.STRING,
        allowNull: false,
      },
      createdAt: DataTypes.DATE,
      updatedAt: DataTypes.DATE,
    },
    {
      modelName: "EmailVerifies",
      tableName: "EmailVerifies",
      timestamps: true,
      sequelize,
    }
  );
}

전송 받은 링크를 클릭했을때, 암호화된 코드가 쿼리로 나온다.

4. 프론트 측에서 searchParams 를 사용하여 쿼리 받아오기

  • page.tsx
import Register from "@/components/Register/Register";
import { getUserEmail } from "@/utils/getUserEmail";

type SearchParams = {
  searchParams: {
    code: string;
  };
};

export default async function Page({ searchParams }: SearchParams) {
  let encryptedCode = searchParams.code;
  
  // + 같은 문자열을 searchParams에서 공백으로 인식하므로, 저 공백을 다시 "+"로 변환시켜주기
  encryptedCode = encryptedCode.replace(/ /g, "+");

  let userEmail: string | undefined;
  const emailObj = await getUserEmail(encryptedCode);
  userEmail = emailObj.email;

  return (
    <>
      <Register userEmail={userEmail} />
    </>
  );
}

5. 백엔드 측에서 해당 코드를 받아서 맞는 이메일 반환해주기

const registerUser = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const { encryptedCode } = req.body;
  let email;

  try {
    const emailData = await EmailVerify.findOne({
      where: { encryptedCode: encryptedCode },
    });

    // console.log(emailData);

    // 해당 코드에 맞는 이메일이 존재하지 않는다면
    if (!emailData) {
      return res.json({ encryptedCode: undefined });
    }

    // 이메일 발견, 날짜 확인
    email = emailData.email;

    const createdAt = emailData.createdAt.getTime();

    // 현재 시간과 createdAt 시간의 차이 계산
    const timeDifference = Date.now() - createdAt;

    // 시간 차이가 24시간(86400000밀리초)이 넘었다면
    if (timeDifference > 86400000) {
      res.json({ encryptedCode: null });
    }
  } catch (err) {
    console.error(err);
    const error = new HttpError("이메일을 전송하는데 실패했습니다.", 503);
    return next(error);
  }

  return res.json({ email: email });
};

해당 이메일을 정상적으로 받아올 수 있었다.


- 더 생각해보아야할점

  1. 현재는 이메일을 암호화한것이기때문에, 암호화 코드 자체의 길이가 엄청 길수밖에 없다. velog 같은 경우는 코드가 9-10자 정도인데 이 길이를 어떻게 줄일 수 있을까?
profile
https://brgndy.me/ 로 옮기는 중입니다 :)

0개의 댓글