Authentication + Twilio + SendGrid

hwisaac·2023년 2월 17일
0

당근마켓클론코딩

목록 보기
2/2

Authentication

  1. 유저가 폰번호를 전송하면 DB에서 검색해서 존재하는지를 판별한다.
  2. 존재하지 않으면 회원가입하고, 존재하면 정보를 DB에서 가져오자.
  3. 그리고 유저를 위한 토큰(랜덤넘버)을 발급한다
  4. 유저의 폰에 랜덤넘버를 보낸다
  5. 유저의 프론트엔드에서는 토큰을 받을 수 있는 화면으로 변경된다.
  6. 유저가 토큰을 입력하면 백엔드에서 토큰을 검색한다.
  7. 토큰을 찾으면 유저 정보를 가져오고, 로그인 하게 한다.
  8. 로그인 상태일 때만 보이는 화면을 구현해야 한다.
  9. 어떤 유저가 API 요청을 보냈는지도 알아야 한다.

    토큰 model 필요.

유저를 검색하고 존재하면 데이터를 가져오고 없으면 생성하기

// api/users/enter.tsx
if (email) {
  // 클라이언트를 사용해서 DB에서 email 에 해당하는 user를 검색
  user = await client.user.findUnique({
    where: {
      email,
    },
  });
  if (user) console.log("found it.");
  if (!user) {
    // 유저가 없으면 생성한다.
    console.log("Did not find. Will create.");
    user = await client.user.create({
      data: {
        name: "Anonymous",
        email,
      },
    });
  }
  console.log(user);
}
if (phone) {
  // 클라이언트를 사용해서 DB에서 phone 에 해당하는 user를 검색
  user = await client.user.findUnique({
    where: {
      phone: +phone,
    },
  });
  if (user) console.log("found it.");
  if (!user) {
    console.log("Did not find. Will create.");
    user = await client.user.create({
      data: {
        name: "Anonymous",
        phone: +phone,
      },
    });
  }
  console.log(user);
}

이렇게 DB를체크해서 있으면 가져오고 없으면 생성하는 로직은 빈번하기 때문에, 위 코드를 간단히 하는 기능이 이미 갖춰져 있다.

upsert : create하거나 update하거나 insert할 때 사용

const user = await client.user.upsert({
  where: {
    ...payload,
  },
  create: {
    // 없으면 생성하기
    name: "Anonymous",
    ...payload,
  },
  update: {}, // 해당 유저를 찾을 경우 어떤 업데이트를 할 것인지
});
console.log(user);

Token 로직

  1. schema.prisma 에 Token 모델추가
// schema.prisma
model User {
  id Int @id @default(autoincrement())
  phone Int? @unique
  email String? @unique
  name String
  avatar String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  tokens    Token[]
}
model Token {
  id        Int      @id @default(autoincrement())
  payload   String   @unique // 확인해야 하는 값. 필수이자 유일한 값
  user      User     @relation(fields: [userId], references: [id]) // userId 는 User모델의 id 임을 명시함
  userId    Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
  1. 코드 작성 후 npx prisma db push
  2. 서버도 재시작 해주자

    planetscale 의 해당 브랜치의 schema 에 업데이트 됨

// api/users/enter.tsx
const user = phone ? { phone: +phone } : { email };
const payload = Math.floor(100000 + Math.random() * 900000) + "";
const token = await client.token.create({
  // 토큰을 생성해서 db에 올림
  data: {
    payload,
    user: {
      connectOrCreate: {
        // 생성한 유저와 토큰(payload)를 연결한다.
        where: {
          ...user,
        },
        create: {
          name: "Anonymous",
          ...user,
        },
      },
    },
  },
});

(npx prisma studio)

connect : 새로운 토큰을 이미 존재하는 유저와 연결

create: 새로운 토큰과 유저를 만듬

createOrCreate : 유저를 찾고 찾으면 토큰과 connect 하고 찾지 못하면 생성해준다.

Twilio

Twilio 는 사람들한테 문자메세지 보내기, robocall, 폰번호 숨기기, 이메일기능 등 기능이 있다.

시작하기

  1. twilio 사이트에 가입한다
  2. ACCOUNT SID 코드를 복사해서, .env 파일에 TWILIO_SID=코드문자열 을 넣고 저장한다.
  3. AUTH TOKEN 코드도 복사해서, .env 파일에 TWILIO_SID=코드문자열 을 넣고 저장한다.

메세지 서비스

  1. [Messaging] - [services] - [create messaging service]

  2. 서비스 이름을 적고 [Notify my users] 선택 - [create messaging service] 클릭

  3. Sender Pool 화면이 나오는데, 우선 [Try it out]메뉴-[Get Set Up] 으로 가본다. 메세지 서비스를 시작할수 있다.

  4. [Start setup] - [select messaging service] 하면 기본적으로 월 1달러에 폰번호를 하나 제공해준다. (trial 로 15달러가 있으니까 1년은 사용한다)

  5. [Provision and add this number] 클릭

  6. [Try SMS] 버튼 클릭하면 메세지를 보내기 위해 필요한 폰번호, api요청을 확인할 수 있는 페이지가 나온다.

  • 양식을 채운 후
  • [Send Test SMS] 를 누르면 핸드폰으로 메세지가 온다.

Twilio SDK : 실제로 메세지 보내기

필수: 대시보드에 있는ACCOUNT SIDAUTH TOKEN 코드를 .env 파일에 제대로 저장했는지 확인한다.
> [Messaging]-[Service] 에서 Sid 코드도 .env 에 복사해야 한다. (test로 나 자신에게 보내야 하므로)
폰번호도 .env 파일에 추가하면 편리함

  1. npm i twilio 설치
  2. const twilioClient = twilio(process.env.TWILIO_SID, process.env.TWILIO_TOKEN); :twilio 를 import 한 뒤 twilio 클라이언트를 생성한다.
  3. twilio 클라이언트를 생성하고 문자를 보내는 코드를 작성한다.
import twilio from "twilio";
import { NextApiRequest, NextApiResponse } from "next";
import withHandler, { ResponseType } from "@libs/server/withHandler";
import client from "@libs/server/client";

// twilio 클라이언트를 생성
const twilioClient = twilio(process.env.TWILIO_SID, process.env.TWILIO_TOKEN);

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 });
  const payload = Math.floor(100000 + Math.random() * 900000) + "";
  const token = await client.token.create({
    data: {
      payload,
      user: {
        connectOrCreate: {
          where: {
            ...user,
          },
          create: {
            name: "Anonymous",
            ...user,
          },
        },
      },
    },
  });
  if (phone) {
    // 메세지를 생성하고 보낸다.
    const message = await twilioClient.messages.create({
      messagingServiceSid: process.env.TWILIO_MSID,
      to: process.env.MY_PHONE!, // !는 확실히 존재하는 변수임을 Typescript에게 알려준다.
      body: `Your login token is ${payload}.`,
    });
    console.log(message);
  }
  return res.json({
    ok: true,
  });
}

export default withHandler("POST", handler);

console.log(message) 로 출력된 값

Sand Grid이메일 보내기

  1. 대시보드 - [Explore Products] 메뉴 - [Email] 을 클릭하면 SendGrid 사이트로 이동한다.
  2. [Try for Free] 버튼을 누르고 회원가입을 한다.
  3. [Create a Single Sender] 버튼을 누른다.

4. 이메일, 닉네임 등 필수입력사항을 기입한 뒤 입력한 메일주소로 verify 하면 된다. 5. sendgrid에서 [Email API] - [Integration Guide] - [Web API]

  1. 언어선택: Node.js
  2. api 키의 이름을 기입한뒤 [Create Key] 를 눌러서 api 를 생성한다.
  3. 해당 api key 를 프로젝트의 .env 파일에 넣는다.
  4. npm install --save @sendgrid/mail
  5. 백엔드 코드에 import mail from "@sendgrid/mail" 을 적고 이메일을 보내는 코드를 작성한다.
// api/users/enter.tsx
import mail from "@sendgrid/mail";

const email = await mail.send({
  from: "abcd@naver.com",
  to: "abcd@naver.com",
  subject: "Your Carrot Market Verification Email",
  text: `Your token is ${payload}`,
  html: `<strong>Your token is ${payload}</strong>`,
});
console.log(email);

console.log(email); 가 찍힌 모습

Token UI

로그인할 경우에만 UI 를 보이도록 구현하자

  • DB 에서 User 가 삭제 될 경우, Token 도 같이 삭제되어야 한다. (Token 의 모델이 user 와 연결돼 있으므로 )
  • @relation 에 onDelete: Cascade : 부모 레코드가 삭제되면 자식 레코드도 같이 삭제됨
  • @relation 에 onDelete: SetNull : 삭제시 user값을 null로 바꾸고 token 은 내버려둠
// @relation 에 onDelete: Cascade 를 추가한다.
model Token {
  id        Int      @id @default(autoincrement())
  payload   String   @unique
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId    Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

저장후 npx prisma db push

  1. token 을 서버로 보내는 코드를 작성

backend

// 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;
  console.log(token);
  res.status(200).end();
}

export default withHandler("POST", handler);

frontend

// pages/enter.tsx
const Enter: NextPage = () => {
  const [confirmToken, { loading: tokenLoading, data: tokenData }] =
    useMutation<MutationResult>("/api/users/confirm");
  const { register: tokenRegister, handleSubmit: tokenHandleSubmit } =
    useForm<TokenForm>();
  const onTokenValid = (validForm: TokenForm) => {
    if (tokenLoading) return;
    confirmToken(validForm);
  };

  return <>{/*보여지는 코드*/}</>;
};

serverless 세션

백엔드에서 iron session 을 이용하여, 유저에게 쿠키를 주고 유저가 요청할 때 누구인지 알 수 있게 하자

iron session : 서명/암호화된 쿠키를 사용하는 NodeJs 의 stateless 세션 툴이다.

JWT(Json Web Token) 와 다른점

  • JWT 는 암호화되지 않고 서명을 하고, 서명과 함께 토큰을 보낸다.
  • JWT 에서는 서명을 확인하고 신뢰하게 된다.
  • iron session 은 쿠키를 암호화 하고 복호화 하는 과정이 있다.

사용법

  1. npm i iron-session 로 iron-session 설치
  2. withIronSessionApiRoute이 함수로 핸들러(또다른 함수를 리턴하는 함수)를 감싸주면 iron session 이 req 객체 안에 요청하는 세션 유저들을 담아 보내준다.

적용 예시

// pages/api/login.ts

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

export default withIronSessionApiRoute(
  async function loginRoute(req, res) {
    // 핸들러
    // get user from database then:
    req.session.user = {
      // req.session.user 생성
      id: 230,
      admin: true,
    };
    await req.session.save(); // 세션을 저장
    res.send({ ok: true });
  },
  {
    cookieName: "myapp_cookiename",
    password: "complex_password_at_least_32_characters_long",
    // secure: true should be used in production (HTTPS) but can't be used in development (HTTP)
    cookieOptions: {
      secure: process.env.NODE_ENV === "production",
    },
  }
);
  1. 적용하기
// api/users/confirm.tsx
// 주어지는 토큰에 해당하는 유저를 db에서 찾고 유저id를 세션에 저장한다.
import { withIronSessionApiRoute } from "iron-session/next";
import { NextApiRequest, NextApiResponse } from "next";
import withHandler, { ResponseType } from "@libs/server/withHandler";
import client from "@libs/server/client";

// TS에게 req.session 의 모습을 알려준다
declare module "iron-session" {
  interface IronSessionData {
    user?: {
      id: number;
    };
  }
}

async function handler(
  req: NextApiRequest,
  res: NextApiResponse<ResponseType>
) {
  const { token } = req.body;
  // Prisma로 토큰과 일치 하는 데이터를 찾는다.
  const exists = await client.token.findUnique({
    where: {
      payload: token,
    },
  });
  if (!exists) return res.status(404).end();
  req.session.user = { // 세션객체에에 user 객체를 만들어서 넣는다.
    id: exists.userId,
  };
  await req.session.save(); // 세션을 저장한다.
  res.status(200).end();
}

export default withIronSessionApiRoute(withHandler("POST", handler), {
  cookieName: "carrotsession",
  password:// password 는 .env 파일에 넣어주자
});

세션이 쿠키에 저장된 모습

// 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,
  });
}
// "GET" 메소드를 쓴다
export default withIronSessionApiRoute(withHandler("GET", handler), {
  cookieName: "carrotsession",
  password: //  ,
});

/api/users/me url 로 접근시, console.log(req.session.user) 에 의해 다음 내용이 출력된다.

// pages.enter.tsx
// 토큰 데이터가 ok 되면 '/' 으로 라우팅
const router = useRouter();
useEffect(() => {
  if (tokenData?.ok) {
    router.push("/");
  }
}, [tokenData, router]);

NextAuth.js

NextAuth 는 Next.js 에서 authentication 구현을 도와주는 패키지이다.

간단한 설정만으로 인증 기능을 처리해준다. (해당 프로젝트에서 사용하진 않을 것임)

Authorization (FE)

인증되지 않은 유저로부터 페이지를 보호하는 훅을 만들어보자

  • 로그인하지 않은 채로 핸들러를 요청하면, id값이 없기 때문에 에러가 난다. 이런 에러에 대한 처리도 해줘야 한다.
  1. 유저가 로그인 상태인지 아닌지 체크하자
// lib/server/withHandler.ts
export interface ResponseType {
  ok: boolean;
  [key: string]: any;
}
interface ConfigType {
  method: "GET" | "POST" | "DELETE";
  handler: (req: NextApiRequest, res: NextApiResponse) => void;
  isPrivate?: boolean; // 이 값으로 로그인 여부 체크
}
export default function withHandler({
  method,
  isPrivate = true,
  handler,
}: ConfigType) {
  return async function (
    req: NextApiRequest,
    res: NextApiResponse
  ): Promise<any> {
    if (req.method !== method) {
      return res.status(405).end();
    }
    // private 이고 user 가 없으면, 에러를 발생시킨다.
    if (isPrivate && !req.session.user) {
      return res.status(401).json({ ok: false, error: "Plz log in." });
    }
    try {
      await handler(req, res);
    } catch (error) {
      console.log(error);
      return res.status(500).json({ error });
    }
  };
}

로그인하지 않은 채로 api 요청시

useUser

  • 유저데이터에 접근할 수 있는 훅을 만들고 각 페이지에서 데이터를 불러오자 (페이지를 개별적인 관점으로 관리)
// libs/client/useUser.ts
// 유저 정보를 가져오는 훅
import { useRouter } from "next/router";
import { useEffect, useState } from "react";

export default function useUser() {
  const [user, setUser] = useState();
  const router = useRouter();
  useEffect(() => {
    fetch("/api/users/me")
      .then((response) => response.json())
      .then((data) => {
        if (!data.ok) {
          return router.replace("/enter"); // 히스토리에 남지 않게 그냥 페이지를 교체할때 replace 쓴다
        }
        setUser(data.profile);
      });
  }, [router]);
  return user;
}

개선할 점: api 요청으로 데이터를 가져올 때 , 캐싱해서 쓰는 것이 네트워크 자원을 절약하는 데 좋다.

SWR 을 사용해 보자!

0개의 댓글