Carrot market 정복 노트 [8] - "Authentication page"

Jay·2022년 3월 16일
1

이 페이지는 유저 Authentication 에 관한 모든 기능들을 구현 하는 내용이며, 로그인의 경우 일회용 비밀번호를 제공하여 로그인 하는 방식이 될것이다. 그래서, token 활용, Twillo, Sendgrid, 등에 대한 내용이 포함되어 있다.


1. Accounts logic

phoe number 또는 email adress로 계정을 만드는 logic 을 구현.

pages/api/users/enter.tsx

import { NextApiRequest, NextApiResponse } from "next";
import withHandler from "@libs/server/withHandler";
import client from "@libs/server/client";

async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { phone, email } = req.body; // user 가 입렵후 submit 한 phone 또는 email 정보를 req.body 로 가져옴.
  const inputInfo = phone ? { phone: +phone } : { email };  //es6 삼항 연산 !!
  const user = await client.user.upsert({  
    where: {
      ...inputInfo,
    },
    create: {
      name: "Anonymous", 	   // name is required in user table so, init as Anonymous
      ...inputInfo,
    },
    update: {},
  });
  console.log(user);
  
  return res.status(200).end();
}

export default withHandler("POST", handler);

※ Point

  • 일반적으로는 원하는 data 하나당 findUnique 쿼리로찾고 없다면 create 쿼리로 만들어주는 코드를 짜게되어 긴 코드가 되지만, 여기서 사용된 upsert 쿼리는 MySQL 에서 UNIQUE KEY 값이 중복 되는 값이 없다면 Updata를 , 중복이 된다면 Insert를 할수 있게 해주는 쿼리 인데, 이를 이용해 코드를 간결 하게 할수 있다. 사용 할때에는 3가지 옵션(create, where, update)을 반드시 명시 해주어야 한다. 또한, const payload = phone ? { phone: +phone } : { email }; 이처럼 삼항 연산으로 원하는 다중의 값을 조건적으로 넣어주어 사용할수 있다(코드 간결화를 위한).

2. Token Logic

prisma/schema.prisma

model User {
	// ...
  tokens    Token[]      // 여러개[]의 Token 을 User 에 담음.
}

model Token {
  id        Int      @id @default(autoincrement())
  payload   String   @unique
  user      User     @relation(fields: [userId], references: [id])
  // User 테이블에 Token 테이블에 관계성을 Token의 userId를 필드로 와 User의 id를 참조로 지정.  
  userId    Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

pages/api/users/enter.tsx

async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { phone, email } = req.body;
  const user = phone ? { phone: +phone } : { email };
  const payload = Math.floor(100000 + Math.random() * 900000) + "";
  // payload 는 User 모델 내에서 unique한 값이어야 되어, 6자리의 random 숫자를 생성하고 + "" 의 의미는 string 으로 변환.
  const token = await client.token.create({
    data: {
      payload,
      user: {
        connectOrCreate: { // command 키를 누르고 data 를 클릭해보면, user 필드를 꼭 넣어주야 한다는 것을 알수 있는데 한층 더 가보게되면 3가지 옵션이 나오는데 그중, connectOrCreate 사용 하여 modle 들을 연결하고 생성 하겠다는 의미
          where: {
            ...user,
          },
          create: {
            name: "Anonymous",
            ...user,
          },
        },
      },
    },

  });
  
  console.log(token);

※ Point

  • connectOrCreate를 사용 하면 새로운 token을 이미 존재하는 유저와 연결해주고, 유저가 없다면, 새로 user 계정 아이디와 token을 만들어 준다, 그리고 where 과 create 을 무조건 써주어야한다.

  • connectOrCreate 으로 token 연결 뿐만 아니라, user 계정 아이디 또한 찾고, 없으면 새로 생성 된다 (token과 함께).


3. req, res 를 boolean type화 시키기

libs/server/withHandler.ts

export interface ResponseType {
  ok: boolean;
  [key: string]: any;
}

pages/api/users/enter.tsx

import withHandler, { ResponseType } from "@libs/server/withHandler";

async function handler(
  req: NextApiRequest,
  res: NextApiResponse<ResponseType>
) {
const { phone, email } = req.body;
  const user = phone ? { phone: +phone } : email ? { email } : null;
  if (!user) return res.status(400).json({ ok: false });
  // 만약 phone 이나 email 정보 없이 user 가 form 을 제출 하게되면 400 에러 코드를 보낸다.
return res.json({
    ok: true,
  });
}

※ Point

pages/api/users/enter.tsx 에서, return res.status(200).end(); 이처럼 http 상태코드 리턴 대신 req, res 변수에 boolean type으로 지정해 위의 코드처럼 사용 가능하다.


4. Twilio

이 프로젝트에서는, 로그인을 유저가 입력한 Phone 또는 email로 일회용 비밀번호 제공과 함께 로그인 하는 logic이 들어가게 되는데, Twilio 는 SMS를 보내줄수 있게 제공하게 해준다. 이외에도 WebRTC , 영상 전화 등 많은것을 할수 있다(유료).

Setup

  1. twilio 계정을 만들고 Account SID 과 AUTH TOKEN을 .env 파일에 넣어 준다.
  2. Messaging - Service 탭에서 sender pool 단계에서 try it out - get setup 페이지로 이동후 setup 버튼을 누르면 미국 phone # 가 제공 되어진다.
  3. Trial account 인지라 보내는 번호는 바꿀수 없다.. trial fee는 1$ per month.
  4. Messaging - Service 를 다시 들어가 MSG_SID 또한 .env 파일에 저장 한다.

4-1 Twilio SMS 메세지 보내는 방법

pages/api/users/enter.tsx

import twilio from "twilio";

const twilioClient = twilio(process.env.TWILIO_SID, process.env.TWILIO_TOKEN);
// .env 파일에있는 Twilio SID 와 TOKEN 을 받아옴.
 if (phone) {
    const message = await twilioClient.messages.create({
      messagingServiceSid: process.env.TWILIO_MSID,
      to: process.env.MY_PHONE!, 
      body: `Your login token is ${payload}.`,
    });
    console.log(message);
  }

4-2 Twilio email 보내는 방법

이번엔 Sendgrid (by Twilio)를 통해 SMS이 아닌 사용자 Email 주소로 일회용 비밀번호인 token 을 보내는 방법의 내용이다.

Setup

  1. 회원가입 이후 API KEY 를 만들어 .env 파일에 넣는다

pages/api/users/enter.tsx

import mail from "@sendgrid/mail";

mail.setApiKey(process.env.SENDGRID_KEY!);

else if (email) {
   const email = await mail.send({
     from: "Email 주소 입력",
     to: "Email 주소 입력",
     subject: "Your Carrot Market Verification Email",
     text: `Your token is ${payload}`,
     html: `<strong>Your token is ${payload}</strong>`, //만약을 위해 html 도 같이 보내준다.
   });
   console.log(email);
 }

※ Point

SMS 든 email 이든 현재 trial 목적으로 하기에 to: 부분을 나의 폰번호, 이메일 주소로 해놓았지만, 나중에는 유저 정보를 받아 처리할것이다. 꼭 주의 해야될 점은, 이 부분을 잘 핸들링 하여 fee 비용을 꼭 최소화 시켜야 한다 money.... TEST 도중엔 주석 처리를 해놓자.


5 Token UI

5-1 TypeScript 제네릭(Generic) 사용하기.

우선, TypeScript에서 Generic이란, 재사용 가능한 컴포넌트를 생성할때 사용되며, 다양한 타입에 동작하는 컴포넌트를 작성할수 있다. 여기서 T는 제내릭을 선언할때 관용적으로 사용되는 식별자로 Type parameter 라고 한다.

libs/client/useMutation.tsx

import { useState } from "react";

interface UseMutationState<T> {
  loading: boolean;
  data?: T;  				// 이전엔 data의 type 은 object이였다.
  error?: object;
}
type UseMutationResult<T> = [(data: any) => void, UseMutationState<T>];
// 이 코드 또한 <T>를 선언해준다

export default function useMutation<T = any>(      //첫번째, <T> 선언 해주고 UseMutationState으로 보냄.
  url: string
): UseMutationResult<T> // 두번째, <T>를 다시 UseMutationResult 넣어주고 {
  const [state, setSate] = useState<UseMutationState<T>>({
    loading: false,
    data: undefined,
    error: undefined,
  });

※ Point 5-1

이 프로젝트에서 UseMutationState에 선언되어 있는 data의 타입을 object로 지정 했었으나, pages/enter.tsx 파일 내에서는 data에 대한 데이터 존재 여부를 boolean type으로도 사용 하고 싶기에 Generic 을 사용하게 된 것이다. 그리고, data가 쓰이는 모든 객체들 또한 T를 선언해 주어야 한다.


5-2 Token 입력 페이지 만들기.

Token 이 email 또는 phone#로 전송이 되었다면, UI에서는 one time password 인 token 을 입력후 로그인 하는 화면이 있어야 한다.

pages/enter.tsx

interface TokenForm {  // token을 위한 type 선언
 token: string;
}

interface MutationResult {
 ok: boolean;
}

const Enter: NextPage = () => {

const [enter, { loading, data, error }] =
   useMutation<MutationResult>("/api/users/enter");
   
 const [confirmToken, { loading: tokenLoading, data: tokenData }] =
   useMutation<MutationResult>("/api/users/confirm");
   // token을 위한 새로운 mutation hook. 이름을 바꿀쑤 있는것은 useMutation 이 배열을 리턴하기 때문이다.
 const { register, handleSubmit, reset } = useForm<EnterForm>();
 
 const { register: tokenRegister, handleSubmit: tokenHandleSubmit } =
   useForm<TokenForm>();
   // register과 handleSubmit은 이미 사용 되어 지고 있는 이름이기 때문에 새로운 type을 적용해 다른 변수로 취급하게 한다.
   
 const onTokenValid = (validForm: TokenForm) => {
   if (tokenLoading) return;
   confirmToken(validForm);
 };
 
return (
 {data?.ok ? (           // 이 부분에서 .ok? 부분을 boolean 으로 하고 싶기 때문에 boolean type 으로 선언되어 있는 MutationResult객체 타입을 useMutation 에 적용 한 것이다.
         <form
           onSubmit={tokenHandleSubmit(onTokenValid)}
           className="flex flex-col mt-8 space-y-4"
         >
          <Input
             register={tokenRegister("token", {
               required: true,
             })}
              name="token"
             label="Confirmation Token"
             type="number"
             required
           />
            <Button text={tokenLoading ? "Loading" : "Confirm Token"} />
         </form>
       ) : (
       <>
       // 기존 enter 페이지의 UI
       </>
         
      )

※ Point 5-2

Token을 위한useMutation hook 의 재사용과, 만약 변수명이 같은것을 또 같은 변수 명으로 쓰고싶다면, type명을 다르게 명시하여 다른 변수로 취급되어 질수있다.


Token 확인 만들기

pages/api/users/confirm.tsx

import { NextApiRequest, NextApiResponse } from "next";
import withHandler, { ResponseType } from "@libs/server/withHandler";
import client from "@libs/server/client";

async function handler(
  req: NextApiRequest,
  res: NextApiResponse<ResponseType>
) {
  const { token } = req.body; //req.body 에서 token 을 받아 온다.
  console.log(token);
  res.status(200).end(); // 전송 ok
}

export default withHandler("POST", handler);

6. Serverless Sessions

이번엔, iron session을 이용하여 user를 인증을 할것이다. serverless가 성립 될수 있는 이유는, 유저가 로그인할때 쓰이는 token이 암호화 되어 쿠키로 저장고 불러와지는 방식으로 이루어 지기 때문이다. 동작 되어지는 과정은, payload(토큰 번호)를 암호화 -> 암호화 된 payload를 쿠키로 user에게 전송 -> 받은 쿠키의 암호화 푼다 -> 현재 페이지에 접근하는 user의 id에 맞게 접근성을 부여해주는 방식으로 이루어 진다.

잠깐!
JWT(Json Web Token)는 유저의 id를 가진 객체에 서명하고 이서명과 함께 유저에게 토큰을 보내는 것인데 JWT는 토큰안에 있는 정보 확인이 가능 하여 보안에 조금 취약할수 있다. 또한, 세션을 위한 백엔드 구축도 해야한다. 하지만 iron session을 사용 하면 이 단점들을 보안 하여 인증 로직을 구현할 수 있다.
설치 커맨드는 다음과 같다. npm i iron-session

  • Iron Session사용 방법
  1. 사용 하고싶은 함수를 iron session helper 함수로 감싸준다.
  2. iron session에 암호화 해줄 비밀번호를 설정해준다 (아주 길고 복잡한 암호로).
  3. 이전에 받았던 Token을 session에 저장해준다.
  4. cookie에 암호화가 되어있는 token 이 유저 정보와 같다면, 로그인이 완료.

Token 데이터 session에 담기 (POST)

pages/api/users/confirm.tsx

import { withIronSessionApiRoute } from "iron-session/next";

declare module "iron-session" {       // 아래 res.session.user 에서 모듈의 정의를 찾을수 없다는 error가 생긴다. 
이런 경우엔, declare를 선언 하여 해당 변수가 존재 한다는것을 알려주어야 한다.
  interface IronSessionData {
    user?: {
      id: number;
    };
  }
}

async function handler(
  req: NextApiRequest,
  res: NextApiResponse<ResponseType>
) {
  const { token } = req.body;

const exists = await client.token.findUnique({
    where: {
      payload: token,       // token 모듈에 payload 필드 값을 저장 한다.
    },
  });         
  if (!exists) return res.status(404).end(); // Token 이 없다면 404 에러 코드
  req.session.user = {     // 토큰이 존재한다면, userId 를 세션에 id를 넣어 생성.
    id: exists?.userId,
  };
  await req.session.save(); // 암호화가 된 세션을 저장
  
  await client.token.deleteMany({  // 토큰의 userId를 찾아 deleteMany로 
    where: {
      userId: exists.userId,
    },
  });
  res.json({ ok: true });
}

export default withIronSessionApiRoute(withHandler("POST", handler), {
  cookieName: "carrotsession",
  password:
    "복잡한 구조의 아무런 번호 넣는곳",
}); 

Token 데이터(cookie) 정보 불러오기 (GET)

pages/api/users/me.tsx

import { withIronSessionApiRoute } from "iron-session/next";
import { NextApiRequest, NextApiResponse } from "next";
import withHandler, { ResponseType } from "@libs/server/withHandler";
import client from "@libs/server/client";

declare module "iron-session" {
  interface IronSessionData {
    user?: {
      id: number;
    };
  }
}

async function handler(
  req: NextApiRequest,
  res: NextApiResponse<ResponseType>
) {
  console.log(req.session.user);
  const profile = await client.user.findUnique({
    where: { id: req.session.user?.id },
  });
  res.json({
    ok: true,
    profile,
  });
}

export default withIronSessionApiRoute(withHandler("GET", handler), {
  cookieName: "carrotsession",
  password:
    "9845904809485098594385093840598df;slkgjfdl;gkfsdjg;ldfksjgdsflgjdfklgjdflgjflkgjdgd",
});

※ Point

  • withHandler 함수를 iron session인 withIronSessionApiRoute로 감싸주면, res.session 에 접근할수 있게된다. 이 프로젝트에서 모든 api들은 같은 서버에서 실행되는 것이 아닌, 특정 API 의 URL으로 개별적으로 실행 된다. 그래서 iron session 을 api 마다 감싸 주어야 한다.

  • iron session 설정을 위와 같이 꼭 추가해 주어야 한다. 그리고, withHandler에 promise 를 void또는 any로 return 해주어야 된다.

  • 추가적으로, withIronSessionApiRoute함수를 따로 컴포넌트화 시켜 놓아 가독성을 높일수 있다.

이외 알아 두어야 할것들

Prisma 관련 알아 두어야 할것들

  • token과 같이 관계성이 있는 model 들을 삭제해야 될때에는 아래의 코드 처럼 Cascade 를 넣어주면 된다. 이는 parent record 가 삭제되면 child record 도 삭제 시킨다는 뜻 이다. Setnull 을 사용한다면 token은 있지만 null 로 바꿔주는 역할을 한다.

prisma/schema.prisma

model Token {
 user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
 }
  • phone # 의 경우 국가 번호도 같이 입력되기에 User model 타입을 BigInt 또는 String 으로 처리 해주어야 된다.

  • npx prisma db push : 스키마 업데이트시 커맨드

  • NextAuth : Next에서는 유저 인증을 위한 기능 또한 제공 된다. 하지만, customize 하기엔 좀 제한적 일수 있다.

profile
React js 개발자

0개의 댓글