Project/Airbnb Clone

[Airbnb 클론코딩] Register & Login functionality

hu6r1s 2023. 6. 25. 12:06

2023.06.17 - [Project/Airbnb Clone] - [Airbnb 클론코딩] Auth UI

 

[Airbnb 클론코딩] Auth UI

2023.06.11 - [Project/Airbnb Clone] - [Airbnb 클론코딩] 환경설정 및 Navbar UI(With. Youtube) [Airbnb 클론코딩] 환경설정 및 Navbar UI(With. Youtube) 유튜브를 보던 중에 Airbnb 클론코딩을 하는 것을 보았다. 그래서 Next.

hu-bris.tistory.com

Prisma setup

기능 구현에 들어가기 앞서 Prisma를 설치해야 한다.

Prisma자바스크립트와 타입스크립트 커뮤니티에서 주목받고 있는 차세대 ORM(Object Relational Mapping) 프레임워크이다.

npm install -D prisma
npx prisma init

해당 커맨드로 prisma를 설치하고 초기화하면 관련 파일(prisma 폴더,.env)이 생길 것이다.

초기 파일을 보면 postgresql로 설정되어 있는데 우리는 MongoDB를 사용할 것이다.

datasource db {
  provider = "mongodb"
  url      = env("DATABASE_URL")
}

provider를 mongodb로 변경하고 .env 파일에 url를 mongodb 연결 url로 변경한다.

mongodb의 url은 구글링하면 나오는 부분이기 때문에 따로 언급하지는 않겠다.

이제 prisma를 이용해 모델링을 해보자.

User model

먼저 User 모델을 만들어보자.

model User {
  id             String    @id @default(auto()) @map("_id") @db.ObjectId
  name           String?
  email          String?   @unique
  emailVerified  DateTime?
  image          String?
  hashedPassword String?
  createdAt      DateTime  @default(now())
  updatedAt      DateTime  @updatedAt
  favoriteIds    String[]  @db.ObjectId

  accounts     Account[]
  listings     Listing[]
  reservations Reservation[]
}

각 필드의 중요한 속성들을 설명하자면

@id 주요 식별자(primary key) 역할
@default(auto()) auto-increment 기능
@map("_id") 해당 속성을 MongoDB의 기본적인 식별자 필드명인 "_id"필드와 매핑함
@db.ObjectId MongoDB에서 사용되는 ObjectId 타입으로 저장되어야 한다는 것을 나타냄
@unique 고유한 값이어야 함을 나타냄. 즉, 같은 이메일 주소를 가진 user를 생성할 수 없음
@defualt(now()) 현재 시간을 자동으로 할당
@updatedAt User가 업데이트될 때마다 자동으로 갱신

Account model

다음은 계정 모델이다.

model Account {
  id                String  @id @default(auto()) @map("_id") @db.ObjectId
  userId            String  @db.ObjectId
  tyep              String
  provider          String
  providerAccountId String
  refesh_token      String? @db.ObjectId
  access_token      String? @db.ObjectId
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.ObjectId
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}
@relation 각각의 인수들은 userId 필드로 User 모델의 id를 참조 onDelete: Cascade를 사용하여 User가 삭제될 때 연결된 Account도 함께 삭제
@@unique provider와 providerAccountId의 조합이 고유해야 함을 나타냄. 즉, 같은 provider와 providerAccountId를 가진 Account를 생성할 수 없음

Listing model

model Listing {
  id            String   @id @default(auto()) @map("_id") @db.ObjectId
  title         String
  description   String
  imageSrc      String
  createdAt     DateTime @default(now())
  category      String
  roomCount     Int
  bathroomCount Int
  guestCount    Int
  locationValue String
  userId        String   @db.ObjectId
  price         Int

  user         User          @relation(fields: [userId], references: [id], onDelete: Cascade)
  reservations Reservation[]
}

Reservation model

model Reservation {
  id         String   @id @default(auto()) @map("_id") @db.ObjectId
  userId     String   @db.ObjectId
  listingId  String   @db.ObjectId
  startDate  DateTime
  endDate    DateTime
  totalPrice Int
  createdAt  DateTime @default(now())

  user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)
  listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
}

Listing 모델과 Reservation 모델은 다 설명한 속성이기 때문에 따로 설명하지는 않겠다.

 

이제 모델링이 끝났으므로 Prisma 스키마 파일의 상태를 데이터베이스로 푸시해보자.

npx prisma db push

해당 커맨드를 통해 연결된 MongoDB에 컬렉션이 생기게 된다.

PrismaClient

Prisma ORM의 PrismaClient를 전역으로 사용하기 위한 설정을 해야합니다.

PrismaClient는 prisma를 통한 DB 수정작업을 할 수 있도록 해주는 도구이다.

npm install @prisma/client

해당 커맨드로 패키지를 받습니다.

import { PrismaClient } from "@prisma/client";

declare global {
    var prisma: PrismaClient | undefined
}

const client = globalThis.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalThis.prisma = client

export default client;

간단하게 코드 리뷰를 하자면 globalThis.prisma 변수가 이미 정의되어 있다면 해당 변수를 client에 할당하고 그렇지 않은 경우 new PrismaClient()를 사용하여 새로운 PrismaClient 인스턴스를 생성하여 client에 할당한다.

NODE_ENV가 'production'이 아닌 경우, globalThis.prisma에 client를 할당한다. 이는 개발 환경에서만 PrismaClient 인스턴스를 재사용하고, 프로덕션 환경에서는 매번 새로운 인스턴스를 생성하는 것을 방지하기 위한 조건이다.

NextAuth

NextAuth는 Next.js의 로그인 등 인증 기능을 제공하며, Oauth를 통한 인증 기능 또한 제공하여 간단하게 구현할 수 있는 라이브러리이다.

npm install next-auth @next-auth/prisma-adapter
npm install bcrypt @types/bcrypt

해당 커맨드로 NextAuth를 설치받고 그 외 필요한 패키지도 받는다.

PrismaAdapter

next-auth는 모든 데이터베이스에서 사용할 수 있다. 어댑터를 사용하여 모든 데이터베이스 서비스 또는 여러 서비스에 동시에 연결할 수 있다.

공식문서에 따르면 파일 구조는 이렇다.

📦pages
 ┗ 📂api
 ┃ ┗ 📂auth
 ┃ ┃ ┗ 📜[...nextauth].ts

Next13부터 루트 디렉터리에 app 디렉터리 안에 api 디렉터리가 있는데 지금까지는 pages와 app 디렉터리를 혼용하여 사용한다.

import bcrypt from "bcrypt"
import NextAuth, { AuthOptions } from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import GithubProvider from "next-auth/providers/github"
import GoogleProvider from "next-auth/providers/google"
import { PrismaAdapter } from "@next-auth/prisma-adapter"

import prisma from "@/app/libs/prismadb"

export const authOptions: AuthOptions = {
  adapter: PrismaAdapter(prisma),
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_ID as string,
      clientSecret: process.env.GITHUB_SECRET as string,
    }),
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    }),
    CredentialsProvider({
      name: 'credentials',
      credentials: {
        email: { label: 'email', type: 'text' },
        password: { label: 'password', type: 'password' }
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          throw new Error('Invalid credentials');
        }

        const user = await prisma.user.findUnique({
          where: {
            email: credentials.email
          }
        });

        if (!user || !user?.hashedPassword) {
          throw new Error('Invalid credentials');
        }

        const isCorrectPassword = await bcrypt.compare(
          credentials.password,
          user.hashedPassword
        );

        if (!isCorrectPassword) {
          throw new Error('Invalid credentials');
        }

        return user;
      }
    })
  ],
  pages: {
    signIn: '/',
  },
  debug: process.env.NODE_ENV === 'development',
  session: {
    strategy: "jwt",
  },
  secret: process.env.NEXTAUTH_SECRET,
}

export default NextAuth(authOptions);

간단하게 리뷰하자면 Provider를 통해 Oauth 기능을 제공받고 이메일과 비밀번호를 통한 인증도 제공받을 수 있다.

bcrypt를 통해 입력한 비밀번호와 해시화된 비밀번호를 비교한다.

Register functionality

📦app
 ┣ 📂actions
 ┃ ┗ 📜getCurrentUser.ts
 ┣ 📂api
 ┃ ┗ 📂register
 ┃ ┃ ┗ 📜route.ts

이번에는 app 디렉터리르 사용해서 회원가입 api를 구현한다.

import { NextResponse } from "next/server";
import bcrypt from "bcrypt";

import prisma from "@/app/libs/prismadb";

export async function POST(
  request: Request,
) {
  const body = await request.json();
  const {
    email,
    name,
    password,
  } = body;

  const hashedPassword = await bcrypt.hash(password, 12);

  const user = await prisma.user.create({
    data: {
      email,
      name,
      hashedPassword,
    }
  });

  return NextResponse.json(user);
}

요청한 데이터를 User 컬렉션에 넣어주는 것이다. 넣어주기 전에 비밀번호를 해시화시킨다.

Login functionality

로그인 기능을 구현하기 전에 hook과 components에 Register와 관련된 파일들을 복사해 Login으로 바꿔주자.

거기에 로그인 컴포넌트에는 name 입력이 필요없기 때문에 지워주자.

const onSubmit: SubmitHandler<FieldValues> = (data) => {
    setIsLoading(true)

    signIn('credentials', {
      ...data,
      redirect: false,
    })
      .then((callback) => {
        setIsLoading(false)

        if (callback?.ok) {
          toast.success('Logged in')
          router.refresh()
          loginModal.onClose()
        }

        if (callback?.error) {
          toast.error(callback.error)
        }
      })
  }

이전에 회원가입 기능을 axios를 통해 사용했는데 next-auth의 signIn 기능을 사용하면 로그인 처리를 쉽게 할 수 있다.

로그인이 되었는지 확인하고 사용자의 메뉴탭을 변경해줘야 한다.

위의 파일구조처럼 getCurrentUser.ts를 생성한다.

import { getServerSession } from "next-auth/next";

import { authOptions } from "@/pages/api/auth/[...nextauth]";
import prisma from "@/app/libs/prismadb";

export async function getSession() {
  return await getServerSession(authOptions)
}

export default async function getCurrentUser() {
  try {
    const session = await getSession()

    if (!session?.user?.email) {
      return null
    }

    const currentUser = await prisma.user.findUnique({
      where: {
        email: session.user.email as string
      }
    })

    if (!currentUser) {
      return null
    }

    // return currentUser;
    return {
      ...currentUser,
      createdAt: currentUser.createdAt.toISOString(),
      updatedAt: currentUser.updatedAt.toISOString(),
      emailVerified: currentUser.emailVerified?.toISOString() || null
    }
  } catch (error: any) {
    return null
  }
}

현재 로그인되어 있는 유저의 세션을 가져와서 현재 유저를 확인하고 세션이 있다면 네이게이션바를 바꿔준다.

currentUser를 반환하는데 거기에 생성 날짜와 업데이트 날짜 등 Datetime을 string 형식으로 바꿔주고 있다.

이게 Warning 에러가 뜬다고 해서 문자열로 변환해준다고 하는데 나는 그런 Warning이 뜨지는 않았다.

{currentUser ? (
              <>
                <MenuItem
                  onClick={() => { }}
                  label="My trips"
                />
                <MenuItem
                  onClick={() => { }}
                  label="My favorites"
                />
                <MenuItem
                  onClick={() => { }}
                  label="My reservations"
                />
                <MenuItem
                  onClick={() => { }}
                  label="My properties"
                />
                <MenuItem
                  onClick={() => { }}
                  label="Airbnb my home"
                />
                <MenuItem
                  onClick={() => signOut()}
                  label="Logout"
                />
              </>
            ) : (
              <>
                <MenuItem
                  onClick={loginModal.onOpen}
                  label="Login"
                />
                <MenuItem
                  onClick={registerModal.onOpen}
                  label="Sign up"
                />
              </>
            )}

세션이 있으면 로그아웃을 포함한 메뉴가 생기고 없다면 로그인과 회원가입 메뉴가 나오도록 한다.

currentUser는 Navbar.tsx와 UserMenu.tsx 의 Props로 넣어준다.

Social Login

구글과 깃허브를 통해 로그인할 수 있도록 해주기 위해서 [...nextauth].ts 파일에서 Provider를 통해 기능을 넣어놨다.

깃허브는 Settings -> Developer Settings -> OAuth Apps를 통해서 클라우드 ID와 PW를 만들어 준다.

영상에서는 callback URL를 Homepage URL과 똑같이 했지만 에러가 계속 발생해서 구글 OAuth와 똑같이 콜백주소를 적었다. 

<Button
    outline
    label="Continue with Github"
    icon={AiFillGithub}
    onClick={() => signIn('github')}
/>

그리고 버튼을 클릭했을 때, signIn을 통해서 로그인 시켜주면 된다.

구글은 Google Cloud Console을 통해서 진행한다. 이는 따로 설명하지 않겠다.

마무리

OAuth 기능으로 로그인이 계속 되지 않았다. 데이터베이스에 유저 컬렉션에는 데이터가 들어가지만 계정 컬렉션에는 데이터가 들어가지 않았다. 계속 안되길래 혹시나 싶어 최신 버전으로 바꿔보았다.

npm update

해당 커맨드를 통해 업데이트를 해주니 정상적으로 작동했었다.

이제는 Category UI를 만들려고 한다.