이번 글에서는 NextAuth.js 버전 4.22.4(현재 사내에서 사용중인 버전)을 기준으로, NEXTAUTH_SECRET 환경 변수와 HTTP Only 쿠키를 활용한 NextAuth의 보안 메커니즘을 살펴보고, API 요청시 NextAuth를 사용해서 토큰을 검증하는 방법에 관해 알아보려한다. NextAuth를 세팅하고, 인증(로그인)을 구현하는 방법을 설명한 글이 아닌 점 미리 참고바란다.
1. NextAuth.js 소개
NextAuth.js는 Next.js 애플리케이션을 위한 인증 솔루션으로, 다양한 인증 제공자와 쉽게 통합할 수 있다. 특히, JWT 기반 세션 관리를 기본으로 지원하여 인증 관련 로직을 간소화할 수 있다. NextAuth.js의 가장 큰 특징은 다양한 Provider(Google, Naver, Kakao, GitHub 등)를 손쉽게 연동할 수 있는 점이며, 커스텀 백엔드 인증을 구현해야 하는 경우에도 유연한 설정을 제공한다.
NextAuth.js는 간단한 설정, 강력한 보안, 다양한 제공자 지원을 필요로 하는 대부분의 Next.js 프로젝트에 적합한 솔루션이다.
2. "NextAuth.js는 Next.js를 위한 인증솔루션이다.", 무슨의미일까?
NextAuth.js가 Next.js를 위한 인증 솔루션이라고 하는 이유는 Next.js의 철학과 아키텍처에 특화된 설계와 통합 기능을 제공하기 때문이다. 이를 구체적으로 살펴보면, NextAuth.js는 Next.js의 기능과 한계를 고려하여 다음과 같은 이유로 최적화되어 있다.
1) API Routes와의 통합
Next.js API Routes란?
- Next.js는 서버리스 함수(Serverless Functions)를 쉽게 작성할 수 있도록 API Routes를 제공한다.
- 이를 통해 개발자는 클라이언트와 서버 간의 통신을 위한 엔드포인트를 정의할 수 있다.
NextAuth.js의 통합 방식
- NextAuth.js는 Next.js의 API Routes에 pages/api/auth/[...nextauth].ts 파일로 라우트를 생성하여 동작한다.
- 이 라우트는 인증 흐름을 처리하는 핵심 역할을 하며, OAuth 로그인, 콜백 처리, 토큰 발급 및 갱신 등의 작업을 수행한다.
2) 클라이언트와 서버에서의 인증 상태 공유
Next.js의 SSR(서버사이드 렌더링) 특성
- Next.js는 SSR(서버사이드 렌더링)과 CSR(클라이언트사이드 렌더링)을 모두 지원한다.
- 서버에서 인증 정보를 처리하고, 클라이언트에서 이를 안전하게 활용하는 구조가 가능하다.
NextAuth.js의 지원 방식
- getServerSession: SSR에서 인증된 사용자 정보를 쉽게 가져올 수 있도록 도와준다.
- useSession: 클라이언트에서 세션 상태를 실시간으로 확인하고 관리.
- React 훅을 통해 클라이언트에서 손쉽게 사용자 정보를 활용 가능.
- 예: 페이지 렌더링 전에 사용자 인증 상태를 확인하고, 인증되지 않은 경우 리다이렉트 처리.
import { getServerSession } from "next-auth";
import { authOptions } from "./api/auth/[...nextauth]";
export async function getServerSideProps(context) {
const session = await getServerSession(context.req, context.res, authOptions);
if (!session) {
return { redirect: { destination: "/login", permanent: false } };
}
return { props: { user: session.user } };
}
• 클라이언트와 서버에서의 인증 상태가 일관되게 유지된다.
• SSR 페이지에서 사용자별 데이터를 안전하게 제공할 수 있다.
3) 서버리스 환경에 최적화
Next.js의 서버리스 배포 지원
- Next.js는 Vercel, AWS Lambda 등 서버리스 플랫폼에서 동작하도록 설계되었다.
- API Routes는 요청이 있을 때만 실행되므로, 서버리스 환경에서 비용 효율적으로 작동한다.
NextAuth.js의 서버리스 지원
- NextAuth.js는 인증 요청마다 pages/api/auth/[...nextauth] 라우트를 통해 서버리스 함수가 실행되도록 설계되었다.
- 상태 비저장 방식(Stateless)으로 동작하며, JWT를 사용해 세션 관리를 처리하여 서버리스 환경에서의 효율성을 극대화한다.
4) Next.js의 라우팅 시스템 활용
Next.js 라우팅 특징
- Next.js는 파일 기반 라우팅 시스템을 제공한다.
- pages 디렉토리 아래의 파일 이름에 따라 자동으로 라우트가 생성된다.
NextAuth.js의 활용
- NextAuth.js는 pages/api/auth/[...nextauth].ts 파일 하나로 인증과 관련된 모든 엔드포인트를 처리한다.
- /api/auth/signin: 로그인 엔드포인트.
- /api/auth/signout: 로그아웃 엔드포인트.
- /api/auth/callback: 인증 제공자의 콜백 처리.
- /api/auth/session: 세션 상태 확인.
5) 쿠키 및 세션 관리의 자동화
Next.js와 쿠키
- Next.js는 SSR이나 API Routes에서 요청(Request) 객체를 통해 쿠키에 접근할 수 있다.
NextAuth.js의 처리 방식
- NextAuth.js는 HTTP Only 쿠키를 기본적으로 사용하여 인증 정보를 관리한다.
- 서버에서 쿠키를 자동으로 읽고, 세션 상태를 유지하거나 갱신한다.
• Next.js의 요청 객체와 완벽히 호환되며, 개발자가 쿠키 처리 로직을 직접 작성할 필요가 없다.
• 보안이 강화된 기본 설정(HTTP Only, Secure, SameSite)을 제공한다.
6) React와의 시너지
Next.js는 React 기반
- Next.js는 React를 기반으로 하며, 클라이언트와 서버 간의 상호작용을 쉽게 만들어 준다.
NextAuth.js의 React 훅
- useSession 훅을 제공하여, React 컴포넌트에서 세션 상태를 손쉽게 가져올 수 있다.
- 실시간으로 세션 상태를 확인하고, 인증 여부에 따라 컴포넌트 렌더링을 제어할 수 있다.
import { useSession } from "next-auth/react";
export default function Profile() {
const { data: session, status } = useSession();
if (status === "loading") {
return <p>Loading...</p>;
}
if (!session) {
return <p>You need to log in</p>;
}
return <p>Welcome, {session.user.name}</p>;
}
7) 개발자 경험(DX)에 최적화
- 간단한 설정: 최소한의 설정으로 인증 구현이 가능.
- 확장성: 다양한 인증 제공자(Google, GitHub, Twitter 등)와의 통합이 간단.
- TypeScript 지원: 타입 안전성을 제공하여 Next.js 애플리케이션과 잘 맞는다.
3. NextAuth.js의 동작방식
1. 인증 과정: JWT 생성 및 HttpOnly 쿠키 저장
- 사용자가 로그인 버튼을 클릭하면 NextAuth.js는 프로바이더(예: Google, Kakao 등)와 OAuth 인증 과정을 진행한다.
- 프로바이더가 사용자 정보를 확인하고 성공적인 인증 응답을 반환하면, NextAuth.js는 JWT를 생성한다.
- JWT는 사용자 정보(예: 이메일, 사용자 ID), 토큰 만료 시간(expires_at), 프로바이더 정보 등을 포함한다.
- JWT는 NextAuth.js의 NEXTAUTH_SECRET 환경 변수를 사용해 서명(Signing)되어 변조를 방지한다.
- 생성된 JWT는 HttpOnly 속성이 설정된 쿠키에 저장되어 클라이언트로 전송된다.
2. 쿠키 저장: 브라우저에 저장된 HttpOnly 쿠키
- 생성된 JWT는 브라우저에 HttpOnly 쿠키 형태로 저장된다.
- 쿠키 이름은 일반적으로 next-auth.session-token이며, 보안 환경(HTTPS)에서는 __Secure-next-auth.session-token이 사용된다.
- 추가로, 쿠키에는 Secure 속성이 설정되며, 이는 쿠키가 HTTPS 연결을 통해서만 전송되도록 보장한다.
- 쿠키의 기본 만료 시간은 NextAuth.js 설정(session.maxAge)에 따라 설정되며, 기본값은 30일이다.
3. 요청 처리: 브라우저의 자동 쿠키 전송
- 사용자가 인증이 필요한 리소스에 접근하거나 API 요청을 보낼 때, 브라우저는 자동으로 쿠키를 포함하여 요청을 전송한다.
- 예를 들어, 브라우저는 다음과 같은 요청을 생성한다:
4. 서버 검증: 쿠키 기반 세션 확인
- 서버는 클라이언트로부터 수신한 요청에서 HttpOnly 쿠키에 저장된 JWT를 추출하고 이를 검증한다.
- 쿠키 추출: 서버는 요청 헤더에서 Cookie를 읽어 JWT 값을 가져온다.
- JWT 디코딩 및 검증: NextAuth.js의 NEXTAUTH_SECRET을 사용하여 JWT 서명을 검증한고, JWT의 만료 시간(exp)을 확인하여, 토큰이 유효한지 확인한다.
- 사용자 정보 확인: JWT의 페이로드(payload)에 포함된 사용자 정보를 기반으로, 사용자가 인증된 상태인지 확인하고, 검증이 성공하면, 서버는 요청을 승인, 사용자 정보를 활용한 추가 작업을 수행한다.
이 과정은 JWT 기반 인증과 HttpOnly 쿠키의 장점을 결합하여 보안성과 편리성을 모두 제공한다. 브라우저가 자동으로 쿠키를 전송하므로 개발자는 클라이언트와 서버 간의 인증 정보를 수동으로 처리할 필요가 없다.
4. NextAuth.js의 보안 메커니즘
NextAuth.js는 보안적인 측면에서 여러 가지 중요한 메커니즘을 제공한다. 그 중에서도 NEXTAUTH_SECRET 환경 변수와 HTTP Only 쿠키를 활용한 토큰 저장 방식은 핵심적인 보안 기능이다.
1) NEXTAUTH_SECRET의 역할
NEXTAUTH_SECRET은 NextAuth.js에서 세션과 JWT(JSON Web Token)를 안전하게 관리하기 위해 사용되는 비밀 키다. 이 비밀 키는 다음과 같은 역할을 수행한다:
- 토큰 서명(Signing): JWT를 생성할 때, NEXTAUTH_SECRET을 사용하여 토큰에 서명을 추가한다. 이 서명은 토큰의 무결성을 보장하며, 서버는 서명을 검증하여 토큰이 변조되지 않았는지 확인할 수 있다.
- 토큰 암호화(Encryption): 필요에 따라, 토큰의 내용을 암호화하여 민감한 정보가 노출되지 않도록 보호할 수 있다. 암호화된 토큰은 NEXTAUTH_SECRET을 사용하여 인코딩 및 디코딩된다.
중요점: NEXTAUTH_SECRET은 외부에 노출되지 않도록 안전하게 관리해야 한다. 이 비밀 키가 유출되면, 공격자가 토큰을 위조하거나 기존 토큰을 변조할 수 있어 보안에 심각한 문제가 발생할 수 있다.
2) HTTP Only 쿠키를 이용한 토큰 저장
HTTP Only 쿠키는 웹 브라우저가 클라이언트 사이드 스크립트(예: JavaScript)에서 접근할 수 없도록 설정된 쿠키다. 이 속성을 통해 쿠키의 보안을 강화하고, 민감한 정보를 클라이언트 측에서 노출되지 않도록 보호할 수 있다.
NextAuth.js는 인증 정보를 클라이언트와 서버 간에 안전하게 전달하고 저장하기 위해 HTTP Only 쿠키를 사용한다. HTTP Only 쿠키는 다음과 같은 특징을 가진다:
- 클라이언트 사이드 스크립트 접근 불가: HttpOnly 속성이 설정된 쿠키는 JavaScript를 통해 접근할 수 없다. 따라서 XSS(Cross-Site Scripting) 공격을 통해 클라이언트 사이드에서 쿠키에 저장된 토큰을 탈취하는 것을 방지할 수 있다.
- 자동 전송: 브라우저는 같은 도메인에 대한 요청 시 자동으로 HTTP Only 쿠키를 서버로 전송한다. 이는 개발자가 별도로 토큰을 헤더에 포함시키지 않아도 되므로 편리하다.
- 서버 전용 접근: 브라우저가 HTTP 요청을 보낼 때, 해당 요청의 도메인과 경로에 맞는 쿠키를 자동으로 포함시켜 서버로 전송하지만, 클라이언트 측 스크립트에서는 접근할 수 없다.
- 토큰 관리 불필요: 개발자는 클라이언트 측에서 토큰을 저장하고 관리할 필요가 없다.
- 보안 설정 자동 적용: 쿠키의 보안 속성이 자동으로 적용되므로, 보안 설정을 실수할 가능성이 줄어든다.
- 코드 간소화: 인증 토큰을 요청마다 수동으로 첨부하는 코드가 필요 없으므로, 코드가 간결해진다.
3) HTTP Only 쿠키 vs 로컬스토리지 토큰 관리 방법 비교
NextAuth.js는 인증 정보를 안전하게 관리하기 위해 HTTP Only 쿠키를 사용한다. 이는 클라이언트와 서버 간의 인증 데이터를 주고받는 데 있어 보안성과 편의성을 동시에 제공하는 방식이다. 이를 일반적인 토큰 관리 방법(로컬 스토리지)과 비교해보자.
1. HTTP Only 쿠키 기반 토큰 저장 (NextAuth.js 방식)
장점:
1. 보안 강화:
- JavaScript 접근 불가: HttpOnly 속성이 설정된 쿠키는 클라이언트 사이드 스크립트(JavaScript)를 통해 접근할 수 없어서ㅜ XSS(Cross-Site Scripting) 공격으로부터 토큰이 노출되는 것을 방지한다.
- Secure 속성: HTTPS 연결에서만 전송되도록 설정해, 네트워크에서의 도청을 방지한다.
2. 자동 전송:
- 브라우저가 HTTP 요청 시 자동으로 쿠키를 포함하므로, 클라이언트 코드에서 인증 토큰을 명시적으로 추가할 필요가 없다.
- 요청마다 개발자가 헤더에 토큰을 설정하거나 관리하지 않아도 된다.
3. CSRF 방지:
- 쿠키의 SameSite 속성을 사용하면, 악성 사이트에서 요청을 보내는 것을 방지할 수 있다.
단점:
1. CORS 제한: 다른 도메인에서 쿠키를 전송하려면 추가적인 설정(CORS 및 SameSite=None)이 필요하다.
2. 스토리지 크기 제한: 쿠키 크기 제한(4KB)이 있어 매우 큰 데이터를 저장하기 어렵다.
2. 로컬 스토리지(LocalStorage) 기반 토큰 저장
작동 방식:
• JWT 생성: 서버에서 생성된 JWT를 클라이언트에 반환한다.
• 로컬 스토리지 저장: 클라이언트 측의 localStorage에 JWT를 저장한다.
• 헤더 포함 요청: 클라이언트가 API 요청을 보낼 때, 저장된 토큰을 읽어 HTTP 헤더에 추가한다.
장점:
1. 간단한 사용:
- localStorage는 브라우저의 내장 기능이므로 사용이 간편하다.
- 다양한 브라우저에서 동일하게 동작한다.
2. CORS 문제 없음:
- 서버 간(Cross-Origin) 요청에서도 헤더로 토큰을 전송할 수 있어, 도메인 간 통신이 자유롭다.
단점:
1. 보안 취약점:
- JavaScript 접근 가능: XSS 공격이 발생할 경우, 악성 스크립트가 localStorage에 저장된 JWT를 쉽게 탈취할 수 있다.
- 명시적 헤더 추가 필요: API 요청마다 토큰을 HTTP 헤더에 포함시키는 로직을 구현해야 한다.
2. 세션 관리 번거로움:
- 토큰이 만료되거나 로그아웃 시 localStorage에서 직접 토큰을 삭제해야 한다.
5. 구현
1) API 핸들러 작성: 북마크 생성하는 POST 핸들러
Next.js에서 제공하는 API 라우트를 사용하여 북마크를 생성하는 핸들러를 작성했다. 이 핸들러는 사용자가 인증되었는지 확인한 후, 북마크를 생성하는 역할을 한다.
- 세션 확인: 사용자의 세션이 존재하는지 확인한다.
- 토큰 검증 및 갱신: 토큰이 만료되었는지 확인하고, 만료된 경우 새로 갱신한다.
- 핸들러 실행: 유효한 세션과 토큰이 확인되면 요청에 따라 작업을 수행한다.
export async function POST(request: NextRequest) {
const { values } = await request.json();
if (!values) {
return new Response("Bad Request", { status: 400 });
}
return isSessionExist(request, async (user, token) => {
return createBookmark(token, values)
.then((res) => NextResponse.json(res))
.catch((error) => new Response(JSON.stringify(error), { status: 500 }));
});
}
2) JWT 검증 및 사용자 정보 활용 : isSessionExist
API 핸들러에서 토큰 검증 및 갱신을 담당하는 isSessionExist 함수의 구현이다.
// 세션 확인 및 토큰 갱신 처리
export async function isSessionExist(
request: NextRequest,
handler: (authUser: AuthUser, token: string) => Promise<Response>,
): Promise<Response> {
try {
// 1. 유저 세션 가져오기 & 에러처리
const { user } = await getSessionOrThrow();
// 2. 겟 토큰 & 에러처리
let token:DefaultToken = await getTokenOrThrow(request);
// 3. 토큰 만료 체크 및 갱신 처리
token = await checkAndRefreshToken(token);
// 4. 토큰 암호화
const encryptedToken:string = getEncryptedIdToken(token);
// 5. 세션이 유효하면 핸들러 처리
return await handler(user, encryptedToken);
} catch (error) {
// 6. 에러 처리
return handleError(error);
}
}
1. 세션 가져오기: getSessionOrThrow
getSessionOrThrow 함수는 NextAuth.js의 getServerSession을 사용해 세션을 가져오며, 세션이 없을 경우 에러를 발생시킨다.
async function getSessionOrThrow() {
const session = await getServerSession(authOptions);
if (session === null) {
throw new Error("Authentication Error");
}
return session;
}
2. 토큰 가져오기: getTokenOrThrow
getTokenOrThrow 함수는 next-auth/jwt의 getToken을 사용해 토큰을 가져오며, 토큰이 없을 경우 에러를 발생시킨다.
async function getTokenOrThrow(request: NextRequest): Promise<DefaultTokenType> {
const token = (await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET || "",
})) as DefaultTokenType;
if (!token) {
throw new Error("Token not found");
}
return token;
}
추가설명: getToken
getToken 함수는 NextAuth.js에서 제공하는 유틸리티 함수로, HTTP 요청(req)에서 JWT 토큰을 추출하고, 이를 디코딩하여 반환한다. 이 함수는 클라이언트와 서버 간의 인증 상태를 확인하기 위해 사용된다.
export async function getToken<R extends boolean = false>(
params: GetTokenParams<R>
): Promise<R extends true ? string : JWT | null> {
const {
req,
secureCookie = process.env.NEXTAUTH_URL?.startsWith("https://") ??
!!process.env.VERCEL,
cookieName = secureCookie
? "__Secure-next-auth.session-token"
: "next-auth.session-token",
raw,
decode: _decode = decode,
logger = console,
secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET,
} = params
if (!req) throw new Error("Must pass `req` to JWT getToken()")
const sessionStore = new SessionStore(
{ name: cookieName, options: { secure: secureCookie } },
{ cookies: req.cookies, headers: req.headers },
logger
)
let token = sessionStore.value
const authorizationHeader =
req.headers instanceof Headers
? req.headers.get("authorization")
: req.headers?.authorization
if (!token && authorizationHeader?.split(" ")[0] === "Bearer") {
const urlEncodedToken = authorizationHeader.split(" ")[1]
token = decodeURIComponent(urlEncodedToken)
}
// @ts-expect-error
if (!token) return null
// @ts-expect-error
if (raw) return token
try {
// @ts-expect-error
return await _decode({ token, secret })
} catch {
// @ts-expect-error
return null
}
}
1) 요청 객체(req)로부터 쿠키와 헤더에서 토큰 검색
- req.cookies와 req.headers.authorization을 이용해 세션 토큰을 찾는다.
- 기본적으로 next-auth.session-token 또는 __Secure-next-auth.session-token 쿠키에서 토큰을 검색한다.
- Authorization 헤더가 포함된 경우, Bearer 타입의 토큰도 지원한다.
2) 환경 변수 및 설정값을 기반으로 동작
- cookieName: 사용할 쿠키 이름을 지정한다.
- HTTPS 환경에서는 __Secure-next-auth.session-token이 기본값으로 사용된다.
- HTTP 환경에서는 next-auth.session-token이 기본값이다.
- secureCookie: 환경 변수를 기반으로 보안 쿠키(HTTPS 전용) 설정 여부를 결정한다.
- secret: 토큰을 디코딩할 때 사용할 비밀 키를 환경 변수에서 가져온다.
3) 토큰 디코딩
- 토큰이 발견되면, decode 함수를 호출하여 디코딩한다.
- 디코딩 과정에서 NEXTAUTH_SECRET 환경 변수를 사용하여 토큰의 무결성을 검증한다.
3. 토큰 갱신 처리: checkAndRefreshToken
checkAndRefreshToken 함수는 토큰의 만료 시간을 확인한 뒤, 만료되었으면 갱신을 시도한다. 갱신 실패 시 에러를 발생시킨다.
async function checkAndRefreshToken(token: DefaultTokenType): Promise<DefaultTokenType> {
let expiresAt = getExpiresAt(token.expires_at);
if (isNaN(expiresAt) || expiresAt <= Date.now()) {
token = await tokenRefresher.updateUserToken(token);
if (token.error) {
throw new Error(token.error);
}
}
return token;
}
4. 토큰 암호화: getEncryptedIdToken
getEncryptedIdToken 함수는, getToken을 이용해서 NextRequest의 요청 헤더에서 꺼내온 토큰으로, 우리의 aws 람다 서버에서 유저를 검증하기 위한 정보를 담아, 약속한 방식으로 다시 암호화 하여 최종 토큰을 반드는 함수다.
export const getEncryptedIdToken = (token: JWT): string => {
const idToken = `${token.provider} ${token.providerAccountId} ${token.email} ${token.expires_at}`;
return encryptString(idToken);
};
6. 최종 요청: createBookmark
createBookmark 함수는, Next.js의 API router에서 isSessionExist 함수로 검증된 토큰을 받아서, 북마크 API를 요청한다.
export const createBookmark = async (
token: string,
values: { campId: string },
) => {
const config: ConfigType = {
endpoint: "bookmarks",
method: "POST",
token,
body: { values },
};
return userFetcher.customFetch(config);
};
'개발 일기' 카테고리의 다른 글
[이벤트 로깅을 위한 EventTracker] 파트 2. 리팩토링 과정 기록 (0) | 2024.11.22 |
---|---|
[이벤트 로깅을 위한 EventTracker] 파트 1. 요구사항 분석과 설계 및 구현 과정 기록 (1) | 2024.11.22 |
Dropdown 컴포넌트 개선: useImperativeHandle 훅으로 외부에서 드롭다운 상태 제어하기 (0) | 2024.11.18 |
내 성장은 내가 만들어가는거다. (0) | 2024.11.17 |
따듯한 가치와 경험을 만드는 개발자를 꿈꾼다. (0) | 2024.11.17 |