최근에 nextjs 프레임워크를 이용하여 쇼핑몰 웹앱 프로젝트를 새로 시작하면서 가장 먼저 고민하게된 문제는 회원가입 및 로그인이였다. 백엔드 서버를 따로 두지 않는 서버리스
구조로 프로젝트를 계획하던 중이라 빠르고 간편하게 인증과 인가 기능을 구현할 수 있는 next-auth
라이브러리를 도입해 보았다.
next-auth
의 기본 개념과 환경 세팅은 이미 자세하게 잘 설명해놓은 글이 많기 때문에 생략하도록 하고 내가 참고 했던 글 몇개만 첨부하고 넘어가도록 하겠다.
https://next-auth.js.org/getting-started/introduction
https://velog.io/@dosomething/Next-auth-를-이용한-로그인-구현#-토큰-유효성-검사
쇼핑몰 사용자 연령대를 4, 50대로 잡았고 일단은 국내 사용자로 한정하여 네이버
와 카카오
로그인이 가장 효과적일 것으로 생각되어 해당 provider
을 추가하였다.
// src/pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth";
import type { NextAuthOptions } from "next-auth";
import NaverProvider from "next-auth/providers/naver";
import KakaoProvider from "next-auth/providers/kakao";
export const authOptions: NextAuthOptions = {
providers: [
NaverProvider({
clientId: process.env.NAVER_ID!,
clientSecret: process.env.NAVER_SECRET!,
}),
KakaoProvider({
clientId: process.env.KAKAO_ID!,
clientSecret: process.env.KAKAO_SECRET!,
}),
],
};
export default NextAuth(authOptions);
위에서 적은 코드가 잘 작동하나 싶어 확인해 보았다.
로그인을 했을 때 쿠키에 정보가 잘 저장되고 로그아웃 하면 사라진다. 잘 동작한다!
그럼 세션값에 정보가 잘 들어있는지 확인하기 위해 아무 페이지에서 useSession()
을 사용해서 세션을 불러왔는데 왠걸 카카오에서는 잘찍히는 name
프로퍼티가 네이버에서는 undefined
가 떳다...
왜 이런 문제가 발생하는지 살펴 보기위해 로그인 이후 해당 정보를 포함하는 signIn()
콜백 함수안에서 user
객체 콘솔을 찍어보았다. 역시나 이름이 들어 있어야 할 name
에 undefined
값이 들어있었고 profile
객체 안에 이름값이 들어 있었다.
이런 일이 발생하는 이유는 next-auth
에서 워낙 다양한 플랫폼의 oauth 를 지원하다 보니 플랫폼마다 주는 데이터의 형식 차이로 발생하는 문제가 아닌가 라고 유추해 보았다.
// 네이버 로그인 user 객체
{
id: 'qp47I3BLNuXxweV56HTk...',
name: undefined,
email: 'example1234@naver.com',
image: undefined
}
// 네이버 로그인 profile 객체
{
resultcode: '00',
message: 'success',
response: {
id: 'qp47I3BLNuXxweV56HTk...',
email: 'example1234@naver.com',
name: '공기밥'
}
}
이유가 어찌되었든 두 플랫폼 모두 빈 값없이 사용자 정보를 세션에 담도록 다음과 같이 코드를 변경해 주었고 이후 세션에 네이버 로그인 역시 이름값이 잘 담겨서 전달되는 것을 확인했다.
export const authOptions: NextAuthOptions = {
providers: [
NaverProvider({
clientId: process.env.NAVER_ID!,
clientSecret: process.env.NAVER_SECRET!,
}),
KakaoProvider({
clientId: process.env.KAKAO_ID!,
clientSecret: process.env.KAKAO_SECRET!,
}),
],
callbacks: {
async signIn({ user, profile }) {
// profile 객체에 이름이나 이메일 값이 있으면 해당 값을 user 객체에 저장
if (profile) {
user.name = profile.response?.name || user.name;
user.email = profile.response?.email || user.email;
}
return true;
},
},
};
export default NextAuth(authOptions);
이번 프로젝트에서 회원가입은 별도의 추가 정보없이 간단히 이름과 이메일 정보만 필요로 했다. 따라서 oauth 로그인 할 때 DB에 사용자 정보가 없는 사람은 바로 회원가입이 이루어지는 방식으로 구현했다.
next-auth
에서는 자동으로 데이터베이스와 연결해주는 adapter 라는 기능을 지원한다. 이 프로젝트는 MongoDB
를 사용하고 있었고 @auth/mongodb-adapter 도 지원하고 있어서 프로젝트에 적용해 보았지만 여러 에러들을 마주했다. 또한 DB에 생성되는 모델의 형태도 정해져 있어 내가 원하는 DB 구조가 아니었기 때문에 adapter
를 사용하지 않기로 결정했다.
사용자가 로그인 하면 호출되는 signIn
콜백 안에서 DB를 체크하고 새로운 유저를 등록하는 로직을 집어 넣어서 간단하게 연동시켰다. prisma
ORM 을 사용해서 데이터베이스 모델을 관리했고 DB에 생성된 유저 id
와 role
정보를 세션에 추가해서 클라이언트에서 나중에 접근할 수 있도록 했다.
// src/pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth";
import type { NextAuthOptions } from "next-auth";
import NaverProvider from "next-auth/providers/naver";
import KakaoProvider from "next-auth/providers/kakao";
import prisma from "common/lib/prisma";
export const authOptions: NextAuthOptions = {
providers: [
NaverProvider({
clientId: process.env.NAVER_ID!,
clientSecret: process.env.NAVER_SECRET!,
}),
KakaoProvider({
clientId: process.env.KAKAO_ID!,
clientSecret: process.env.KAKAO_SECRET!,
}),
],
callbacks: {
async signIn({ user, profile }) {
if (profile) {
user.name = profile.response?.name || user.name;
user.email = profile.response?.email || user.email;
}
try {
// 데이터베이스에 유저가 있는지 확인
let db_user = await prisma.user.findUnique({
where: { email: user.email! },
});
// 없으면 데이터베이스에 유저 추가
if (!db_user) {
db_user = await prisma.user.create({
data: {
name: user.name!,
email: user.email!,
cart: {
create: {},
},
},
});
}
// 유저 정보에 데이터베이스 아이디, 역할 연결
user.id = db_user.id;
user.role = db_user.role;
return true;
} catch (error) {
console.log("로그인 도중 에러가 발생했습니다. " + error);
return false;
}
},
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
// 세션에 유저 정보 저장
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
secret: process.env.NEXTAUTH_SECRET,
};
export default NextAuth(authOptions);
next-auth
에서 제공하는 getToken()
함수를 이용해서 사용자 로그인 토큰에 접근할 수 있다는 점을 이용하여 페이지별 접근 권한 설정을 middleware.ts
파일을 통해 해주었다. 앞서 설정해 주었던 NEXTAUTH_SECRET
환경변수를 사용해서 토큰을 복호화하고 사용자 역할 정보를 가져와서 사용했다.
// src/middleare.ts
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
export async function middleware(req: NextRequest) {
const token = await getToken({
req,
secret: process.env.NEXTAUTH_SECRET,
});
const { pathname } = req.nextUrl;
if (pathname.startsWith("/login")) {
if (token) {
return NextResponse.redirect(new URL("/", req.url));
}
}
if (pathname.startsWith("/my")) {
if (!token) {
return NextResponse.redirect(new URL("/login", req.url));
}
}
if (pathname.startsWith("/admin")) {
if (token?.role !== "ADMIN") {
return NextResponse.redirect(new URL("/", req.url));
}
}
}
export const config = {
matcher: ["/login", "/my", "/admin"],
};
Vercel
을 사용해서 배포 테스트를 하던 도중 NEXTAUTH_SECRET
환경 변수를 추가해 주지 않아서 에러가 발생했다. next-auth
를 사용하기 위해 두가지 환경 변수가 필요한데, 토큰 암호화 복호화를 위한 NEXTAUTH_SECRET
, 그리고 서비스 도메인 정보를 담은 NEXTAUTH_URL
이 필요하다.
NEXTAUTH_SECRET
를 얻는 방법은 공식 홈페이지에 잘 나와있는데 아래 openssl
커맨드를 사용하면 된다.
$ openssl rand -base64 32
vscode
커맨드 창이나 맥 터미널 어디에서 실행해도 상관없고 실행후 아래에 출력되는 랜덤한 문자열 값을 복사해서 사용하면 된다.