코딩/React

NextAuth.js로 NextJS앱에 로그인 로직 만들기 3편

드리프트 2021. 12. 28. 16:21
728x170

 

안녕하세요?

 

지난 시간에 이어 NextAuth.js로 NextJS앱에 로그인 로직 만들기 3편을 시작해 보도록 하겠습니다.

 

2편 링크입니다.

 

https://cpro95.tistory.com/612

 

NextAuth.js로 NextJS앱에 로그인 로직 만들기 2편

안녕하세요? 지난 시간에 이어 NextAuth로 NextJS앱에 로그인 로직 만들기 2편을 시작해 보겠습니다. 1편을 못 보신 분들을 위해 링크 걸어 놓겠습니다. https://cpro95.tistory.com/611 NextAuth.js로 NextJS앱에..

cpro95.tistory.com

 

2편에서는 signup 로직 관련 하드코딩 방식을 사용해서 테스트해봤는데요.

 

3편에서는 본격적으로 Prisma Client를 이용해서 DB에 유저 정보를 입력하고 불러들이는 로직을 알아볼 예정입니다.

 

먼저, 지난 시간에 임시로 만든 /pages/api/auth/signup.ts 파일을 다시 손봐서 완벽하게 만들어 볼까요?

 

/pages/api/auth/signup.ts

import { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
import { hashPassword } from '../../../lib/auth';

async function handler(req: NextApiRequest, res: NextApiResponse) {

    // Loading prisma client
    let prisma = new PrismaClient();

    if (req.method !== 'POST') {
        return;
    }

    const data = req.body;

    const { name, email, password } = data;

    if (
        !name ||
        !email ||
        !email.includes('@') ||
        !password ||
        password.trim().length < 7
    ) {
        res.status(422).json({
            message:
                'password should also be at least 7 characters long.',
            error: true,
        });
        return;
    }

    const existingUser = await prisma.user.findUnique({
        where: {
            email: email,
        },
        select: {
            email: true, name: true,
        }
    }
    );

    if (existingUser) {
        res.status(422).json({ message: 'User Email already exists!', error: true });
        return;
    }

    const hashedPassword = await hashPassword(password);

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

    if (result) {
        res.status(201).json({ message: 'Created user!', error: false });
    } else {
        res.status(422).json({ message: 'Prisma error occured', error: true })
    }
}

export default handler;

 

뭔가 지난 시간보다 더 복잡해진 거 같은데요.

 

천천히 하나하나 살펴보도록 하겠습니다.

 

먼저 password을 해싱해서 저장할 수 있도록 하기 위해 bcryptjs 패키지를 설치하도록 합시다.

 

npm i bcryptjs

 

그리고 최상위 폴더에서 lib 폴더를 만듭시다.

 

그리고 lib 폴더에 auth.ts 파일을 만들도록 합시다.

 

/lib/auth.ts

import { hash, compare } from 'bcryptjs';

export async function hashPassword(password: string): Promise<string> {
    const hashedPassword = await hash(password, 12);
    return hashedPassword;
}

export async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
    const isValid = await compare(password, hashedPassword);
    return isValid;
}

 

auth.ts 파일에는 두 개의 helper 함수가 있는데요.

 

하나는 hashPassword 함수입니다.

 

유저가 로그인할 때 입력한 비밀번호는 문자열 그대로 저장하는 방식은 보안에 심각한 위험을 초래할 수 있습니다.

 

그래서 모든 웹 앱에서는 crypt 방식을 이용하는데요.

 

NodeJS나 Javascript 쪽에서는 bcryptjs 패키지가 가장 많이 쓰입니다.

 

먼저 hashPassword 함수는 password라는 문자열을 받아서 hash 함수를 통해 사람이 알아볼 수 없는 문자열로 만들고 리턴해 줍니다.

 

그리고 verifyPassword 함수는 반대로 hash 된 문자열을 유저가 입력한 password 문자열을 hash 한 값과 비교하는 함수입니다.

 

login 할 때 유용하게 쓰이는 게 바로 verifyPassword 함수입니다.

 

이 방식 때문에 개발자라고 하더라도 유저의 password가 무엇인지 알 수 없게 되는 거죠.

 

일단 2개의 helper 유틸 함수를 만들었고 다시 signup.ts 파일을 살펴보도록 하겠습니다.

 

// Loading prisma client
    let prisma = new PrismaClient();

 

위 코드는 바로 Prisma 클라이언트를 초기화하는 함수입니다.

 

이렇게 new PrismaClient()라고 Prisma 클라이언트를 초기화해서 생성해서 prisma 변수에 저장하고 나중에 prisma 변수를 이용해서 DB 작업을 하게 됩니다.

 

const data = req.body;
    const { name, email, password } = data;
    if (
        !name ||
        !email ||
        !email.includes('@') ||
        !password ||
        password.trim().length < 7
    ) {
        res.status(422).json({
            message:
                'password should also be at least 7 characters long.',
            error: true,
        });
        return;
    }

위 코드는 유저가 입력한 내용이 최소한의 요건을 갖췄는지 체크하는 if 문입니다.

 

email에는 '@'문자가 포함되어야 하고 password는 최소 7자 이상이어야 합니다.

 

만약 위 요건에 충족하지 못할 시에는 422 코드로 response를 내보냅니다.

 

422번 코드는 아래와 같습니다.

 

const existingUser = await prisma.user.findUnique({
        where: {
            email: email,
        },
        select: {
            email: true, name: true,
        }
    }
    );

    if (existingUser) {
        res.status(422).json({ message: 'User Email already exists!', error: true });
        return;
    }

 

이제 prisma 코드가 나오는데요.

 

prisma.user.findUnique 함수가 그것입니다.

 

prisma는 prisma Client이고요.

 

그다음 user는 우리가 schema.prisma 에서 만든 모델 User를 나타냅니다.

 

모델 User는 대문자로 시작하지만 코드에서는 소문자로 이용해야 합니다.

 

그리고 prisma db관련 함수 중에 가장 많이 쓰이는 findUnique 함수인데요.

 

User 모델에서 특별한 걸 찾으라는 함수입니다.

 

그리고 findUnique에는 객체를 전달하는데요.

 

where 구문이 바로 찾는 조건이 됩니다.

 

where에서 email : email이라고 썼는데요.

 

앞에 email은 user 모델의 email이고 두 번째 email은 request body에서 유저가 보낸 email입니다.

 

즉, findUnique함수는 where 조건에서 email이 유저가 보낸 email과 같은걸 찾으라는 뜻입니다.

 

그리고 두번째 select문인데요.

 

SQL query 중에 "select * from"에서 * 에 해당되는 구문입니다.

 

즉, "select name, email from user"와 같은 경우죠.

 

즉, findUnique 함수는 리턴되는 객체가 바로 email, name을 리턴한다는 뜻이죠.

 

그리고 그 리턴되는 객체를 existingUser에 저장하고 만약 existingUser가 있으면,

 

422 코드로 에러를 전송합니다.

 

즉, 같은 이메일로 등록한 사람이 있다는 뜻이죠.

 

왜냐하면, 우리는 지금 가입하기 로직을 만들고 있고, email이 누군가 쓰고 있다면 똑같은 이메일로 새로 가입할 수 없기 때문입니다.

 

왜냐하면 schema.prisma 파일에서 model User를 설정할 때 email을 @unique로 설정했기 때문입니다.

 

model User {
id Int @id @default(autoincrement())
email String @unique
name String?
password String
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
}

 

이제 email도 등록된 거 없다고 하면 실제 user를 DB에 저장해서 가입하기 로직을 끝마쳐야 합니다.

 

다음 코드를 보시죠.

 

const hashedPassword = await hashPassword(password);

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

 

먼저, hashPassword함수를 이용해서 유저가 입력한 password값을 hash 합니다.

 

그걸 hashedPassword 변수에 저장하고,

 

그다음으로 prisma.user.create 함수를 통해 DB에 저장하게 됩니다.

 

정확히 말하면 SQL 입장에서는 user 테이블에 레코드를 만든다고 할 수 있습니다.

 

prisma 클라이언트의 create 함수는 객체의 data 값을 저장하는데요.

 

그래서 data: {} 식으로 작성해야 합니다.

 

data에 들어갈 값은 그냥 나열하면 됩니다.

 

우리가 user sign up form에서 얻은 name, email, password 값을 그대로 적으면 됩니다.

 

name: name, email : email 이 되고, password는 hashedPassword가 되는 거죠.

 

마지막으로 result 변수에 prisma.user.create 결과값을 저장하는데요.

 

result 값에 따라 마지막으로 리턴하는 response 코드값이 201이냐, 422냐 정해집니다.

 

if (result) {
        res.status(201).json({ message: 'Created user!', error: false });
    } else {
        res.status(422).json({ message: 'Prisma error occured', error: true })
    }
}

 

이제 signup.ts 파일을 만들었으니까 테스트해볼까요?

 

yarn dev 명령어로 개발 서버를 돌리고요.

 

결과는 대 성공입니다.

 

리턴되는 메시지가 "Sign up Success: Created user!"라고 나왔습니다.

 

그리고 브라우저의 콘솔 창에서도 message가 잘 나오고 있습니다.

 

그럼, DB에 제대로 저장되어 있는지 확인을 어떻게 할까요?

 

이 시점에서 Prisma 가 아주 중요한 역할을 하는데요.

 

Prisma는 GUI 방식으로 DB 값을 보여주고 수정, 삭제까지 할 수 있게 제공해 줍니다.

 

터미널 창을 하나 열어서 아래와 같이 실행해 봅시다.

 

npx prisma studio

 

실행하면 개발서버를 5555 포트에서 열었다고 하고 브라우저가 자동으로 열립니다.

 

우리가 만들었던 모델이 나오고 있네요.

 

User 모델을 클릭해 볼까요?

 

우리가 만들었던 user가 DB에 저장되어 있는 모습을 볼 수 있습니다.

 

이걸로 signup 관련 로직은 제대로 작성한 거 같네요.

 

그럼 마지막으로 pages/signup.tsx 파일에서 아래 코드처럼 router.replace("/login") 부분을 주석처리를 없애 주십시오.

 

try {
      const result = await createUser(
        enteredName,
        enteredEmail,
        enteredPassword
      );
      console.log(result);
      setFormStatus(`Sign up Success: ${result.message}`);
      router.replace("/login");
    } catch (error) {
      console.log(error);
      setFormStatus(`Error Occured: ${error.message}`);
    }

 

왜냐하면 가입이 끝났으면 로그인 페이지로 이동하라는 뜻입니다.

 

 

 

로그인 화면 만들기

 

아까 가입이 끝났을 때 router.replace로 "/login" 주소로 이동하라고 했는데요.

 

이번 강좌 1편에서도 그랬지만 NextAuth의 signin 주소는 "/api/auth/signin" 주소였는데요.

 

왜 "/login" 주소로 보내라고 했을까요?

 

왜냐하면 NextAuth가 만든 로그인 화면 말고 직접 만들고 싶어서 그랬습니다.

 

일단 /pages 폴더에 login.tsx 파일을 만들도록 합시다.

 

/pages/login.tsx

import React, { useState, useRef } from "react";
import { useSession, signIn } from "next-auth/react";
import { useRouter } from "next/router";

const Login: React.FC = (props) => {
  const [formStatus, setFormStatus] = useState<string>(null);

  const emailInputRef = useRef<HTMLInputElement>(null);
  const passwordInputRef = useRef<HTMLInputElement>(null);

  async function submitHandler(event: React.SyntheticEvent) {
    event.preventDefault();

    const enteredEmail = emailInputRef.current?.value;
    const enteredPassword = passwordInputRef.current?.value;

    const result = await signIn("credentials", {
      redirect: false,
      email: enteredEmail,
      password: enteredPassword,
    });

    if (!result.error) {
      setFormStatus(`Log in Success!`);
      router.replace("/");
    } else {
      setFormStatus(`Error Occured : ${result.error}`);
    }
  } // end of submitHandler function

  const { data: session, status } = useSession();
  const router = useRouter();

  if (status === "authenticated") {
    router.replace("/");
    return (
      <div>
        <h1>Log in</h1>
        <div>You are already logged in.</div>
        <div>Now redirect to main page.</div>
      </div>
    );
  }

  return (
    <div className="container px-5 py-10 mx-auto w-2/3">
      <div className="text-center mb-12">
        <h1 className="text-4xl md:text-4xl text-gray-700 font-semibold">
          Log In
        </h1>
      </div>
      <form
        onSubmit={submitHandler}
        className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"
      >
        <div className="mb-4">
          <label
            className="block text-gray-700 text-sm font-bold mb-2"
            htmlFor="email"
          >
            Email
          </label>
          <input
            className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
            id="email"
            type="text"
            placeholder="Email"
            required
            ref={emailInputRef}
          />
        </div>
        <div className="mb-6">
          <label
            className="block text-gray-700 text-sm font-bold mb-2"
            htmlFor="password"
          >
            Password
          </label>
          <input
            className="shadow appearance-none border border-red-500 rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
            id="password"
            type="password"
            required
            ref={passwordInputRef}
          />
          <p className="text-red-500 text-xs italic">
            {/* Please choose a password. */}
            {formStatus}
          </p>
        </div>
        <div className="flex items-center justify-between">
          <button
            className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
            type="submit"
          >
            Log In
          </button>
        </div>
      </form>
    </div>
  );
};

export default Login;

 

참고로 Navbar.tsx 파일에서 Login 부분의 주소를 "/api/auth/signin"에서 "/login"으로 바꾸도록 합시다.

 

그래야 Navbar의 Log In 링크가 제대로 작동하기 때문입니다.

 

그럼 코드를 살펴볼까요?

 

UI 부분은 Sign Up UI 코드에서 name 부분만 삭제한 겁니다.

 

UI 디자인을 제대로 재활용한 겁니다.

 

그리고 useRef의 변수 값과 useState 변수 이름도 똑같기 때문에 딱히 어려운 건 없습니다.

 

중요한 건 submitHandler 함수인데요.

 

여기서 어떻게 로그인 로직을 구현했는지 살펴보도록 하겠습니다.

 

const enteredEmail = emailInputRef.current?.value;
    const enteredPassword = passwordInputRef.current?.value;

    const result = await signIn("credentials", {
      redirect: false,
      email: enteredEmail,
      password: enteredPassword,
    });

 

위 코드를 보시면 email과 password값을 signIn 함수를 이용해서 result 값을 얻어 내는데요.

 

signIn 함수는 뭘까요?

 

바로 "next-auth/react" 패키지에서 제공하는 signIn 함수입니다.

 

수동으로 로그인할 수 있게 해주는 함수입니다.

 

signIn 함수의 첫 번째 인자는 바로 로그인 방식인데요.

 

구글 로그인 방식도 있을 거고, 카카오 로그인, 네이버 로그인도 있을 수 있는데, 우리는 유저 이메일과 패스워드 방식입니다.

 

NextAuth에서는 이 방식을 credentials이라고 부릅니다.

 

그래서 signIn 함수의 첫 번째 인자 값이 바로 "credentials"입니다.

 

즉, signIn 함수로 로그인할 건데 바로 useremail / password 방식으로 로그인할 거다라고 NextAuth에 알려주는 형식입니다.

 

그리고 두 번째 인자는 객체인데요.

 

여기에 필요한 정보를 넣습니다.

 

redirect : false는 signIn 과정에서 에러가 발생했을 때 화면을 새로고침 하지 않고 그 자리에 있으라는 뜻입니다.

 

그리고 email에는 enteredEmail, password에는 enteredPassword값을 넣어줬습니다.

 

그리고 최종적으로 signIn 함수가 실행되면 result값이 나오는데요.

 

result 값에 에러가 없으면 다시 router.replace 함수로 인해 "/"로 이동하게 됩니다.

 

한번 테스트해볼까요?

 

 

뭔가 에러가 났는데요.

 

왜 이럴까요?

 

바로 [... nextauth]. ts 파일을 수정하지 않아서 그렇습니다.

 

CredentialsProvider({
            // The name to display on the sign in form (e.g. "Sign in with...")
            name: "유저 이메일,페스워드 방식",
            // The credentials is used to generate a suitable form on the sign in page.
            // You can specify whatever fields you are expecting to be submitted.
            // e.g. domain, username, password, 2FA token, etc.
            // You can pass any HTML attribute to the <input> tag through the object.
            credentials: {
                email: { label: "유저 이메일", type: "email", placeholder: "user@email.com" },
                password: { label: "패스워드", type: "password" }
            },
            async authorize(credentials, req) {
                // Add logic here to look up the user from the credentials supplied
                if (
                    credentials.email === "testuser@email.com" &&
                    credentials.password === "test"
                ) {
                    const user = { id: 1, name: "test user", email: "testuser@email.com" }
                    return user;
                }

                return null
            }
        })

위의 코드가 바로 지금까지 있었던 [...nextauth].ts 파일의 내용인데요.

 

1편에서 이용한 하드 코딩한 이메일과 패스워드 값이 있었네요.

 

이제 이 authorize 함수를 우리가 원하는 Prisma DB 방식으로 바꿔줘야 합니다.

 

어떻게 할까요?

 

일단 prismaClient를 불러오고 /lib/auth.ts 파일에서 만든 verifyPassword 함수를 불러옵시다.

 

import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { PrismaClient } from "@prisma/client";
import { verifyPassword } from "../../../lib/auth";

let prisma = new PrismaClient();

export default NextAuth({
....
....
....
....
});

 

이제 authorize 함수를 바꿔줘야 하는데 다음과 같이 바꿉시다.

 

prisma DB에서 유저 정보를 비교하는 루틴입니다.

 

async authorize(credentials) {
                const user = await prisma.user.findUnique({
                    where: {
                        email: String(credentials.email),
                    },
                    select: {
                        name: true, email: true, password: true
                    },
                });

                if (!user) {
                    throw new Error('No user found!');
                }

                const isValid = await verifyPassword(
                    credentials.password,
                    user.password
                );

                if (!isValid) {
                    throw new Error('Could not log you in!');
                }
                return { name: user.name, email: user.email };
            }

새로 만든 authorize 함수 안에는 먼저 user를 찾는데요.

 

prisma.user.findUnique 함수를 이용합니다.

 

앞에서도 잠깐 설명한 함수입니다.

 

where 부분에서 email이 credentials 변수의 email과 같은걸 찾는 겁니다.

 

credentials 변수는 NextAuth의 Credentials 방식으로 전달된 유저 정보입니다.

 

보통은 string 타입이지만 에러 체크를 위해 String(credentials.email) 식으로 강제로 string 타입화 했습니다.

 

그리고, select 문에서 만약 찾았다면 리턴할 항목을 골라주고 있는데요.

 

name, email, password를 리턴하라고 했습니다.

 

그러면 user 변수에 바로 이 3가지가 저장되게 됩니다.

 

그리고 그다음 코드는 user가 없을 때 즉, 에러가 났을 때 "No user found"라고 에러를 뿜어줍니다.

 

만약 user가 있다면 verifyPassword 함수로 credentials.password 값과 user.password 값을 비교하고

 

만약 isValid가 true일 때 최종적으로 객체를 리턴하게 됩니다.

 

그 객체는 바로 name, email을 포함하는 객체인 거죠.

 

이제 다시 실행해 볼까요?

 

 

코드를 고치고 실행했을 때 아주 잘 실행되었네요.

 

이걸로 유저 가입과 로그인 로직을 구현했는데요.

 

Navbar 부분에 Log out 앞에 user.name이나 user.email 부분을 첨가하도록 하겠습니다.

 

이 부분은 여러분께서 직접 해 보도록 하시죠.

 

저는 email을 보여주도록 했습니다.

 

이상 3편입니다.

 

다음 편에 뵙도록 하겠습니다.

 

그리드형