처음으로 백엔드분과 디자이너분이랑 협업하면서 만든 프로젝트 YeoGi에서 나는 회원가입 쪽을 맡게되었다.
사용기술 : Typescript , NextJS
NextJs를 처음 써봐서 처음 로그인, 회원 가입 로직은 클라이언트 컴포넌트에서 진행했었다.
나의 로그인/회원가입 로직의 발전 과정
자체회원가입/로그인
->OAuth2소셜회원가입/로그인
->NextAuth를 이용한 OAuth 회원가입/로그인
가장 큰 이유는 , NextJS를 잘 활용하고 있지않다고 생각 이 들어서있다. 시간에 급급해 열심히 달려가다보니, 어느새 우리 프로젝트는 그냥 리액트 프로젝트에 NextJS 한방울이 섞인 그런 프로젝트가 되어버렸다.
NextJS를 사용하면서 토큰관리를 어떻게 해야하나 의 고민 끝에 NextAuth 라이브러리에서 소셜 로그인 기능을 편리하게 제공해주고, 서버컴포넌트와의 콜라보도 잘 된다는걸 알게되었다.
두번째,자체 로그인/회원가입으로 관리하는것 보다 보안성이 높아질것 이라고 생각했다.
사실 우리프로젝트가 그만큼 필요한건 아니지만..ㅎ
OAuth 나 NextAuth를 통한 OAuth 로그인은 애플리케이션 자체에서 사용자의 비밀번호를 직접 다루지 않기 때문에 보안 위험이 줄어든다.
뿐만아니라 NextAuth.js는 세션을 안전하게 관리하고, 세션 데이터와 쿠키를 암호화하여 저장한다.
NextAuth 는 NextJs프로젝트
의 사용자 인증 및 세션 관리
를 위한 라이브러리이다.
다양한 소셜 로그인을 지원하며, 서버와 클라이언트 측에서 모두 인증 및 세션(회원관리? 회원인증관리 라고 생각하면된다.) 관리를 보다 손쉽게 할수 있게끔 해준다.
NextAuth의 로그인 흐름은 간단히 아래와 같다.
[로그인 흐름]
1. 사용자 요청: 사용자가 애플리케이션에서 로그인 버튼을 클릭할때 NextAuth.js의 /api/auth/signin
API 엔드포인트로 요청이 전송된다.
2. 인증 제공자 선택: 사용자는 NextAuth.js가 제공하는 로그인 화면에서 사용하고자 하는 인증 제공자
를 선택한다.
3. OAuth2 인증 절차: 선택된 제공자에 따라 OAuth2 인증 절차가 시작되고, 제공자의 로그인 페이지로 리디렉션되어 인증을 완료한다.
4. 토큰 발급 및 저장: 제공자가 인증을 성공적으로 마치면, NextAuth.js는 액세스 토큰과 리프레시 토큰을 발급받고 이를 관리한다.
[세션관리]
세션 생성: 사용자가 성공적으로 로그인하면 NextAuth.js는 사용자 정보를 세션에 저장. 이 세션은 서버측에서 관리
되며, 클라이언트는 쿠키를 통해 세션을 유지
함.
세션 조회: NextAuth.js는 useSession() 훅
을 제공하여 클라이언트 측에서 현재 로그인된 사용자의 세션 정보를 쉽게 가져올 수 있습니다.
NextAuth는 기본적으로 NextJs의 동적 API route를 이용해 로그인을 구현한다.
🤚🏻 동적 API route를 까먹은 분들을 위한 간단 설명
: NextJs 에서 URL 경로의 일부를 동적으로 변경할 수 있는 API 라우트를 의미!
-> NextJs에서 파일의 이름이 곧 URL 경로가 되며, 해당 경로로 들어오는 요청을 처리할 수 있음!
ex) 파일 이름에 대괄호([])를 사용하여 URL의 특정 부분을 동적으로 처리
ex) pages/api/user/[id]
따라서 이 라이브러리를 사용하려면 아래 경로로 파일을 생성해야한다.
app/api/auth/[...nextauth]/route.js
혹은 pages/api/auth/[...nextauth].js
로 파일을 생성해야한다.
이 파일에는 각각 소셜 provider들에 따른 handler 함수를 작성할 것이다.
그전에..!
✅ 각각 소셜 로그인에 대한 CLIENT_ID 와 CLIENT_SECRET 값을 env 파일에서 관리하고 있는지..!
✅ RedirectURI 등록시 , 주소가 api/auth/callback/kakao
이런식으로 되어있는지...!
(뒤에 kakao 나 naver등 provider이름만 바꾸면된다)
확인해보길 바란다.
NextAuth를 사용하기위해서는 SessionProvider 라는걸 설정해야한다.
(NextAuth에서는 기본적으로 세션을 전역상태로 관리할 수 있다.)
이 SessionProvider 가 무엇인고 하고 SessionProvider 를 타고 들어갔다.
아래와 같은 설명이 나온다.
이 SessionProvider 안에는 context가 있어 로그인 정보를 전체 컴포넌트들이 사용할 수 있게 해준다.
"use client"
import { SessionProvider } from "next-auth/react"
import { ReactNode } from "react"
export const NextAuthSession = ({ children }: { children: ReactNode }) => {
return <SessionProvider>{children}</SessionProvider>
}
app 폴더 아래 (layout.tsx 와 같은위치) SessionProvider를 사용한 NextAuthSession
컴포넌트를 생성했다. 해당 컴포넌트를 보면 props
로 children
을 전달하는걸 볼 수 있을것이다.
🤚🏻 여기서 체크해야할점,
context 자체가클라이언트 컴포넌트에서만 사용가능
하기 때문에 해당 컴포넌트도 클라이언트 컴포넌트여야 한다.
그런데 저 컴포넌트는 layout.tsx에서 전역으로 사용할수 있기위해<body>
태그 아래 묶어야하는데, 보통 NextJs에서layout.tsx
에서 metadata를 설정하기 때문에 layout.tsx는서버컴포넌트
여야한다.
따라서 이 문제를 해결하기 위해 Props 로 children을 전달했다.
props로 children을 전달하면, 클라이언트 컴포넌트가 그 안에 포함된 모든 요소를 클라이언트에서 독립적으로 렌더링하고 처리할 수 있다. 이 방법을 통해 클라이언트 컴포넌트가 서버 컴포넌트의 일부로 포함되어도 제대로 동작할 수 있게 되는것이다.
//layout.tsx
const myeongjo = Nanum_Myeongjo({ weight: ["400", "700"], subsets: ["latin"], variable: "--font-myeongjo" })
const pretendard = localFont({
//...생략
variable: "--font-pretendard",
})
export const metadata: Metadata = {
title: "Record Your Trip",
description: "여기에 여행을 기록하세요",
icons: {
icon: "/icons/logo_img.svg",
},
other: {
'link rel="preload" as="image" href="/images/main-02.webp"': "",
'link rel="preload" as="image" href="/images/main-03.webp"': "",
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<head>
</head>
<body className={`${myeongjo.className} ${pretendard.className}`}>
<NextAuthSession>
<ReactQueryProvider>
<ClientLayout>
<Layout>{children}</Layout>
</ClientLayout>
</ReactQueryProvider>
</NextAuthSession>
</body>
</html>
)
}
이제 다시 아까 만들어둔 파일 pages/api/auth/[...nextauth].js
로 돌아와서 handler를 만들것이다.
(나는 NextAuth 를 이용한 OAuth 로그인 기능이였기때문에 해당 기능만 작성하겠다. 그외 자체로그인/회원가입은 Credentials
라는걸 쓸수있다.)
import NextAuth from "next-auth/next"
import GoogleProvider from "next-auth/providers/google"
import Kakao from "next-auth/providers/kakao"
import Naver from "next-auth/providers/naver"
const G_REST_API_KEY = process.env.GOOGLE_CLIENT_ID
const G_CLIENT_SECRET_KEY = process.env.GOOGLE_CLIENT_SECRET
const K_REST_API_KEY = process.env.KAKAO_CLIENT_ID
const K_CLIENT_SECRET_KEY = process.env.KAKAO_CLIENT_SECRET
const N_REST_API_KEY = process.env.NAVER_CLIENT_ID
const N_CLIENT_SECRET_KEY = process.env.NAVER_CLIENT_SECRET
const AUTH_API_URL = process.env.NEXT_PUBLIC_BASE_URL //백엔드URL
// NextAuth 타입 정의 확장-세션과 JWT에 추가 속성 정의
declare module "next-auth" {
interface Session {
accessToken?: string
provider?: string
}
}
declare module "next-auth/jwt" {
interface JWT {
accessToken?: string
provider?: string
}
}
const handler = NextAuth({
debug: true, //콘솔에 디버그 정보 출력 활성화
secret: process.env.NEXTAUTH_SECRET,
//OAuth 제공자 설정
providers: [
GoogleProvider({
clientId: G_REST_API_KEY || "",
clientSecret: G_CLIENT_SECRET_KEY || "",
}),
Kakao({
clientId: K_REST_API_KEY || "",
clientSecret: K_CLIENT_SECRET_KEY || "",
}),
Naver({
clientId: N_REST_API_KEY || "",
clientSecret: N_CLIENT_SECRET_KEY || "",
}),
],
session: {
strategy: "jwt",// 세션을 JWT 방식으로 관리
maxAge: 60 * 60 * 24 * 30,//세션만료기간 설정
},
callbacks: {
// JWT콜백 함수 - 토큰 생성 및 업데이트
async jwt({ token, account, trigger, session }) {
if (account) {
// 토큰에 제공자 정보와 액세스 토큰 저장
token.accessToken = account.access_token
token.provider = account.provider
//백엔드에게 줄 토큰 encoded 실행
const encodedToken = encodeURIComponent(account.access_token)
const url = `${AUTH_API_URL}백엔드에연결주소/${account.provider}?token=${encodedToken}`
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
})
if (response.ok) {
const data = await response.json()
token.accessToken = data.token.accessToken // 백엔드에서 받은 토큰 저장
token.refreshToken = data.token.refreshToken // 리프레시 토큰이 있다면 저장
}
}
return token
},
async session({ session, token }) {
console.log(token, "token")
session.accessToken = token.accessToken
console.log(session, "session")
return session
},
},
//쿠키 설정
cookies: {
sessionToken: {
name: `session-token`,// 세션토큰 쿠키이름설정
options: {
httpOnly: true, //자바스크립트를 통한 쿠키 접근을 방지하여 XSS 공격으로부터 보호
sameSite: "lax", //CSRF 공격을 방지하고 일부 크로스 사이트 요청을 허용
path: "/", //쿠키가 전체 사이트에서 유효하도록 설정
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 30, //30일
},
},
},
})
export { handler as GET, handler as POST }
NextAuth 를 사용하면서 확실히 백엔드와 프론트엔드 간의 모호한 역할분담을 느꼈다. 기존 했던 방식은 주소창에 띄워주는 인가코드를 받아와서 백엔드에게 전해주고 백엔드로부터 발급된 토큰을 이용했다.
하지만, NextAuth를 사용하니까 그 인가코드 처리를 해줘서 자동으로 쿠키에 NextAuth관련 것들이 저장되었다.
TMI!
(여기서 CSRF token이라는것이 있는데, 이는 Cross-Site-Request-Forgery 토큰이라고 해서 사용자가 의도하지않은 요청을 방어하기위해 만들어진 일회용토큰이다. 즉, 서버에 Post요청 보낼때마다 함께 전송되어 서버는 이토큰을 검증하여 유효성을 판단한다고 한다)
회원정보도 자동으로 useSession()훅 함수를 통해서 전역 상태관리를 해주는데..!! 백엔드에게 다시 토큰을 재발급하여 사용할 이유가 있나 많이 고민했다. 실제로 "그래서 ! 백엔드와의 연결은 어떻게 해야하는데?!" 라는 의문이 들어 여러 블로그를 찾아봤다.
그래서 나는 우리프로젝트 웹사이트의 목적 및 사용방법에 초점을 맞췄다.
우리프로젝트는 단순히 토큰을 사용해서 인증된 회원임을 구분하는것이 아니라 사용자 마다 쓴 게시글과 댓글 등을 달리 보여줘야했다.
NextAuth에서 자동으로 주는 sessionToken은 DB에 저장되는것이 아니기때문에 단순히 회원정보를 update하는 수정기능만 있는게 아닌이상, 백엔드의 재발급된 토큰이 필요하다고 판단.
이 생각으로 작성된 코드가 위 코드중 callbacks
함수이다.
NextAuth에서 주는 accessToken
값을 백엔드에게 전달
해줬고, 백엔드는 이 토큰을 가공하여 회원정보와 함께 프론트엔드에게 넘겨준다.
이때 주는
access_Token
은 데브툴-애플리케이션 에 뜨는session-token
이 아니라 VS코드 터미널에 뜨는 토큰값이다.
(아마 우리가 흔히 보는 토큰 형태가 아닐것이다.)
실제로 나는 애플리케이션에 뜨는 session-token을 줘야하는건줄알고 전달해줬는데, 백엔드 쪽에서는 해당 토큰으로 인증된 회원인지 판별할수 없다고 한다.
잘 모르겠으면 한번 위 코드에서 account.access_token
이랑 이것저것 다 콘솔로그를 찍어보고 확인해보길.. (실제로 콘솔만 10개 찍어서 터미널에서 확인했다..)
🧐 그렇다면,, 기존에 자동으로 저장되는 next-auth.session-token은요??
위에 저러한 이유들로 결국 백엔드에게 토큰을 재발급 받았다. 그러면 기존에 NextAuth에서 쿠키에 자동으로 저장되는 next-auth.session-token은..?
어차피 백엔드에게 토큰을 재발급 받을거라면 굳이 NextAuth에서 기본적으로 쿠키에 저장되는 session토큰은 필요없다.
그래서 백엔드로부터 토큰을 session토큰에 저장하고 쿠키를 설정했다.
//세션에 백엔드 토큰 저장한후 쿠키이름 설정하기
async session({ session, token }) {
session.accessToken = token.accessToken
console.log(session, "session")//백엔드에서 재발급된 토큰으로 저장됨
return session
},
},
cookies: {
sessionToken: {
name: `session-token`,//임의로 이름 변경가능
options: {
httpOnly: true, //자바스크립트를 통한 쿠키 접근을 방지하여 XSS 공격으로부터 보호
sameSite: "lax", //CSRF 공격을 방지하고 일부 크로스 사이트 요청을 허용
path: "/", //쿠키가 전체 사이트에서 유효하도록 설정
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 30, //30일
},
},
},
위에 방법들로 로컬 환경에서 로그인기능이 제대로 잘 작동하는것을 파악했다.
근데..... 버셀로 배포한 페이지에서 에러가 떴다..!!!
해당 부분을 알아보기위해 엄청나게 많은 벨로그를 찾아본 결과,
각각의 provider에 대한 clientId, clientSecret 외에도
NEXTAUTH_SECRET
값과 NEXTAUTH_URL
값도 확인해줘야한다.
openssl rand -base64 32
를 치면 값이 나온다. 이부분을 복사 해서 맞춰주고📚참고문헌
NextAuth핵심정리
NextAuth공식문서
NextAuth를 이용한 로그인 구현
NextAuth 커스텀로그인 만들기