본 게시글은
next.js
,next-auth
,prisma
,tailwindcss
를 사용하는 것을 기반으로 작성됩니다.
회원가입은 브라우저에서 id
, password
, email
, name
, phone
, photo
를 입력받아서 prisma
를 이용해서 DB에 추가하는 비교적 단순한 부분이기 때문에 설명 대신 깃헙 링크로 대체하겠습니다.
/src/pages/signup.tsx
/src/pages/api/signup.tsx
로그인 로직을 구현하기 위해서 next-auth
를 사용했습니다.
next-auth
는 보다 쉽게 인증 로직을 구현하기 위한 Next.js
전용 라이브러리입니다.
google
, kakao
등이 OAuth
도 쉽게 구현할 수 있지만, id
와 password
를 이용한 로그인을 구현하는 것이 목표이기 때문에 Credentials
방식을 선택했습니다.
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]
);
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;
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;
// ...
}
}
}
로그인과 회원가입 페이지에 접근할 때만 이미 로그인했는지 여부를 판단하고 접근을 제한하는 코드입니다.
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
를 고려하지 않았기 때문에 유저 모델의 재정의가 필요합니다.
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")
와 같은 형태로 호출하기만 하면 완성입니다.