인증 로직 구현 ( next-auth )

박상은·2022년 8월 13일
16

🧺 bleshop 🧺

목록 보기
3/10

본 게시글은 next.js, next-auth, prisma, tailwindcss를 사용하는 것을 기반으로 작성됩니다.

회원가입

회원가입은 브라우저에서 id, password, email, name, phone, photo를 입력받아서 prisma를 이용해서 DB에 추가하는 비교적 단순한 부분이기 때문에 설명 대신 깃헙 링크로 대체하겠습니다.

로그인

로그인 로직을 구현하기 위해서 next-auth를 사용했습니다.
next-auth는 보다 쉽게 인증 로직을 구현하기 위한 Next.js 전용 라이브러리입니다.
google, kakao 등이 OAuth도 쉽게 구현할 수 있지만, idpassword를 이용한 로그인을 구현하는 것이 목표이기 때문에 Credentials 방식을 선택했습니다.

1. 세팅 코드 예시

import prisma from "@src/prisma";
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcrypt";

export default NextAuth({
  providers: [
    // 인증 방식 선택 ( 현재는 "id" + "password" )
    CredentialsProvider({
      // 여기서 입력한 이름을 "signIn(이름)" 형태로 사용
      name: "Credentials",
      // 여기서 작성한 타입 그대로 아래 "authorize()"의 "credentials"의 타입 적용
      // 또한 "next-auth"에서 생성해주는 로그인창에서 사용 ( http://localhost:3000/api/auth/signin )
      credentials: {
        id: {
          label: "아이디",
          type: "text",
          placeholder: "아이디를 입력하세요.",
        },
        password: {
          label: "비밀번호",
          type: "password",
          placeholder: "비밀번호를 입력하세요.",
        },
      },

      // 로그인 유효성 검사
      // 로그인 요청인 "signIn("credentials", { id, password })"에서 넣어준 "id", "password"값이 그대로 들어옴
      async authorize(credentials, req) {
        if (!credentials)
          throw new Error("잘못된 입력값으로 인한 오류가 발생했습니다.");

        const { id, password } = credentials;

        const exUser = await prisma.user.findUnique({
          where: { id },
          include: { photo: true },
        });
        if (!exUser) throw new Error("존재하지 않는 아이디입니다.");

        const result = await bcrypt.compare(password, exUser.password);
        if (!result) throw new Error("비밀번호가 불일치합니다.");

        // 반환하는 값중에 name, email, image만 살려서 "session.user"로 들어감
        return exUser;
      },
    }),
  ],
  callbacks: {
    async jwt({ token }) {
      return token;
    },
    // 세션에 로그인한 유저 데이터 입력
    async session({ session }) {
      const exUser = await prisma.user.findUnique({
        where: { name: session.user?.name },
        select: {
          idx: true,
          id: true,
          name: true,
          email: true,
          phone: true,
          address: true,
          photo: {
            select: {
              path: true,
            },
          },
        },
      });

      // 로그인한 유저 데이터 재정의
      // 단, 기존에 "user"의 형태가 정해져있기 때문에 변경하기 위해서는 타입 재정의가 필요함
      session.user = exUser;

      // 여기서 반환한 session값이 "useSession()"의 "data"값이 됨
      return session;
    },
  },
  secret: process.env.SECRET,
});
// 다른 부분은 전부 생략하고 로그인 요청 부분만 살림

// 2022/08/12 - 로그인 요청 - by 1-blue
const onSubmit = useCallback(
  async (body: ApiLogInBody) => {
    try {
      // body에 로그인을 위해 입력한 id와 password가 들어있음
      const result = await signIn("credentials", {
        // 로그인 실패 시 새로고침 여부
        redirect: false,
        id: body.id,
        password: body.password,
        // ...body
      });

      // "authorize()"에서 날린 "throw new Error("")"가 "result.error"로 들어옴
      if (result?.error) return toast.error(result.error);

      // 만약 에러가 없다면 로그인 성공
      // 세션 쿠키가 생성됨
      toast.success("로그인 성공. 메인 페이지로 이동합니다.");
      router.push("/");
    } catch (error) {
      console.error("error >> ", error);

      toast.error(
        "알 수 없는 에러로 로그인에 실패했습니다. 잠시후에 다시 시도해주세요!"
      );
    }
  },
  [router]
);

2. 프론트 사용 예시

import type { NextPage } from "next";
import { useSession } from "next-auth/react";

const Home: NextPage = () => {
  const { data, status } = useSession();
  
  // status는 "authenticated" | "loading" | "unauthenticated"를 가짐 ( 로그인 여부 판단에 사용 )
  // data는 "expires"와 "user"( "callbacks"의 "session()"에서 반환한 값 )를 가짐

  return (
    <>
      <h1>Home!!!</h1>
    </>
  );
};

export default Home;

3. user 타입 재정의

import NextAuth from "next-auth";

// type
import type { UserWithPhoto } from "@src/types";

// 여기서 재정의한 타입이 "session.user"의 타입으로 정의됨
declare module "next-auth" {
  interface Session {
    user: {
      idx: number;
      id: string;
      name: string;
      // ...
    }
  }
}

4. 접근 제한 ( middleware )

로그인과 회원가입 페이지에 접근할 때만 이미 로그인했는지 여부를 판단하고 접근을 제한하는 코드입니다.

import type { NextRequest, NextFetchEvent } from "next/server";
import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";

const secret = process.env.SECRET;

export async function middleware(req: NextRequest, event: NextFetchEvent) {
  // 로그인 했을 경우에만 존재함 ( "next-auth.session-token" 쿠키가 존재할 때 )
  const session = await getToken({ req, secret, raw: true });
  const { pathname } = req.nextUrl;

  // 2022/08/13 - 로그인/회원가입 접근 제한 - by 1-blue
  if (pathname.startsWith("/login") || pathname.startsWith("/signup")) {
    if (session) {
      return NextResponse.redirect(new URL("/", req.url));
    }
  }
}

export const config = {
  matcher: ["/login", "/signup"],
};

(추가) OAuth 로그인 ( kakao, google )

처음에는 OAuth를 고려하지 않았기 때문에 유저 모델의 재정의가 필요합니다.
Credentials 방식의 경우에는 반드시 id, password 등이 필요하지만 OAuth의 경우에는 다른 페이지에서 인증을 대신 처리해주기 때문에 필요치 않습니다. 따라서 아래와 같이 유저 모델을 변경했습니다.

enum Provider {
  Credentials
  KAKAO
  GOOGLE
}

// 유저
model User {
  idx      Int       @id @default(autoincrement())
  name     String
  email    String    @unique
  id       String?   @unique
  password String?
  phone    String?   @unique
  photo    String?
  role     Role?     @default(USER)
  provider Provider? @default(Credentials)
  
  // ... 나머지 생략
}

로그인해서 받는 필수 정보인 name, email만 필수로 만들고 나머지는 옵셔널로 변경했습니다.
또한 어떤 방식의 로그인인지 구분하기 위해서 provider 컬럼을 추가했습니다.
그리고 아래와 같이 사용할 로그인 방식을 추가하고 callbacks에서 유저를 등록하도록 코드를 작성합니다.

export default NextAuth({
  providers: [
    // ... Credentials 생략
  
    // 카카오 로그인
    KakaoProvider({
      clientId: process.env.KAKAO_ID,
      clientSecret: process.env.KAKAO_SECRET,
    }),

    // 구글 로그인
    googleProvider({
      clientId: process.env.GOOGLE_ID,
      clientSecret: process.env.GOOGLE_SECRET,
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      // "account.provider"는 로그인을 요청할 때만 값이 존재하기 때문에
      // 로그인 요청을 하는 경우에 체크해서 첫 로그인이라면 등록합니다.
      
      // 카카오 로그인일 경우
      if (account?.provider === "kakao") {
        const exUser = await prisma.user.findFirst({
          where: { provider: "KAKAO", name: token.name!, email: token.email! },
        });

        // 등록된 유저가 아니라면 회원가입
        if (!exUser) {
          await prisma.user.create({
            data: {
              name: token.name!,
              email: token.email!,
              photo: token.picture,
              provider: "KAKAO",
            },
          });
        }
      }
      // 구글 로그인일 경우
      if (account?.provider === "google") {
        const exUser = await prisma.user.findFirst({
          where: { provider: "GOOGLE", name: token.name!, email: token.email! },
        });

        // 등록된 유저가 아니라면 회원가입
        if (!exUser) {
          await prisma.user.create({
            data: {
              name: token.name!,
              email: token.email!,
              photo: token.picture,
              provider: "GOOGLE",
            },
          });
        }
      }

      return token;
    },
    // 세션에 로그인한 유저 데이터 입력
    async session({ session }) {
      const exUser = await prisma.user.findFirst({
        where: { email: session.user.email },
        select: {
          idx: true,
          id: true,
          name: true,
          email: true,
          phone: true,
          role: true,
          photo: true,
          provider: true,
        },
      });

      session.user = exUser!;

      return session;
    },
  },
    
  // ... 나머지 생략
});

위 로직을 작성하고 로그인 페이지에서 버튼을 만들고 클릭 시 signIn("kakao")와 같은 형태로 호출하기만 하면 완성입니다.

0개의 댓글