우리가 사용하는 보통의 어플리케이션은 인증이 필요하다.
사용자가 로그인을 시도하면 서버와 통신으로 우리 서비스에 "인증된 사용자"인지 구분한다.
말로만 보면 진짜 별거 없다.
하지만, 이 단순해 보이는 인증도 구현하는 개발자에게는 가깝고도 멀게만 느껴질 수 있다.
그래서 이번에는 필자가 최근 프로젝트를 하면서 어떤 기술들을 활용해서 인증의 취약점을 보완하고 구조적으로 보안했는 지에 대해 조금 살펴보는 시간을 갖고자 한다.
먼저, 간단하게 보통의 인증 프로세스를 톺아보자.
위의 그림처럼, 우리가 어플리케이션에서 로그인 정보를 입력한다.
그리고 이 정보가 authorization server라고 부르는 인증 서버에서 검증을 하고(얘가 우리 회원이 맞나? 회원 정보는 일치하게 입력했는가?) 통과의 표식으로 액세스 토큰과 리프레시 토큰을 발급 받는다.
이때, 액세스토큰을 담아 서버 요청을 보낸다.
그렇다면 여기서 리프레시는 왜 필요한가?
리프레시는 액세스토큰이 만료되었을 때, 말 그대로 재갱신해주기 위한 예비 토큰이다.
이 재갱신 토큰으로
"아 내 인증 토큰 만료되었는데, 리프레시 보내줄테니까. 새 토큰 다오!"
그렇게 새로운 인증토큰을 받는다. 이를 기반으로 새로운 토큰으로 클라이언트는 다시 리소스 요청을 한다.
만약 여기서도 401이 나거나, 혹은 재갱신 과정에서 401 등이 나면 그건 인증 서버에서 토큰을 만료시킨 경우이므로 클라이언트 단에서 재로그인 화면으로 리다이렉트 시킨다.
그래서 이 두개를 가지고 기본적으로, 로그인 유지 및 인증 처리를 하는 것이 jwt 토큰 방식이다. (여기서는 다른 기타의 세션 방식 등도 있지만 논외로 한다)
이쯤 되면, 이런 고민이 생기게 된다.
그렇다면, 우리는 이 토큰을 어디에 저장하는 게 좋을까?
보통은 간단히 생각하면 어 그냥 로컬 스토리지나 세션 스토리지 등과 같은 웹 스토리지에 저장하면 되는 거 아닌가?
할 수 있다...
그러나
히 생각해 보자.
그게 과연 맞을ㄲ...ㅏ?
물론, 단순한 개인 사이드 프로젝트 정도나 아주 간단한 어플리케이션면 그럴 수도 있다.
하지만, 회사라면. 그게 수백 수천명이 이용하는 가입자가 있는 서비스라면, 얘기는 달라진다.
그래서 필자도 이번에는 웹 스토리지에 저장은 하지만, 어떻게 하는 게 좋을 지 고민을 한참 했다.
그 결과, 클라이언트에서 접근할 수 없게 만드는 구조로 설계하는 것이 맞겠다고 생각했다.
사실 이 고민은...
원래는 위의 구조처럼 인증만 처리하는 별도의 서버가 우리 팀에게도 있었다면...
하지 않았을 것이다.
그런데, 우리는 그런 서버도 서비스 구축자도 없었다.
그래서 우리는 별도로 서버를 구축하기보다는 일단 nextjs의 웹서버를 활용하기로 했다.
각설하고, nextjs의 route api를 활용한 코드 구조를 살펴보자.
// 포스트 방식으로 클라이언트는 웹서버로 요청.
export async function POST(request: NextRequest) {
const { username, password } = await request.json();
try {
const res = await axios.post('리소스 서버 통신 주소', {
username,
password,
});
const { accessToken, refreshToken } = res.data;
// 리소스 서버에서 받은 토큰 정보를 웹 서버 단에서 브라우저 쿠키로 저장.
cookies().set('rtk', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 15,
path: '/',
sameSite: 'strict',
});
cookies().set('atk', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 14,
path: '/',
sameSite: 'strict',
});
const response = NextResponse.json({ success: true });
response.headers.set('Authorization', `Bearer ${accessToken}`);
return response;
} catch {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
}
// 클라이언트 코드
// 로그인 버튼 눌렀다는 가정하에 작성.
const login = async({userName, password})=>{
await axios.post('/api/auth', {
userName,
password
});
}
간단하게 코드를 정리해 보면, 클라이언트 측은 로그인 화면에서 로그인 요청을 (실제 백엔드 리소스 서버가 아닌) 웹 서버로 날린다.
이때, 요청을 받은 웹 서버는 클라이언트 측에서 받은 유저 정보를 기반으로 실제 리소스 백엔드 서버와 통신한다.
이후, 응답 받은 토큰 값을 브라우저의 쿠키에 저장한다. 다만, 여기서 주의할 점이 있다.
이때 쿠키의 옵션은 오직 서버에서만 설정할 수 있는 서버 전용 쿠키다.
아래는 서버 측에서 미리 셋팅한 옵션들이다.
• 목적: 쿠키가 클라이언트 측 JavaScript에서 접근할 수 없도록 설정.
• 이유: 이 옵션은 보안상의 이유로 사용되며, XSS(크로스 사이트 스크립팅) 공격을 방지. httpOnly를 true로 설정하면 오직 서버에서만 쿠키에 접근할 수 있어서, 리프레시 토큰이나 액세스 토큰과 같은 중요한 정보를 보호하는 데 도움이 된다.
secure: process.env.NODE_ENV === 'production'
• 목적: 프로덕션 환경에서는 쿠키가 HTTPS 연결에서만 전송되도록 설정.
• 이유: secure 옵션은 쿠키가 오직 HTTPS 연결을 통해서만 전송되도록 하여, 전송 중에 쿠키가 가로채이지 않도록 한다. 이 설정은 process.env.NODE_ENV === 'production' 조건을 통해 프로덕션 환경에서만 활성화되므로, 로컬 개발 중에는 HTTP로도 접근할 수 있어 개발 편의성을 높인다.
maxAge: 60 60 24 * 14
• 목적: 쿠키의 만료 시간을 2주로 설정 (초 단위).
• 이유: 이 옵션은 쿠키가 유효한 기간을 설정. 리프레시 토큰의 경우, 2주 동안 유효하도록 설정하여 사용자가 오랫동안 로그인 상태를 유지할 수 있게 하고, 이 기간이 지나면 다시 인증을 요구하게 해서 보안과 사용자 편의성을 균형 있게 유지.
path: '/'
• 목적: 쿠키가 전체 애플리케이션 경로에서 유효하도록 설정.
• 이유: path를 '/'로 설정하면 애플리케이션의 모든 라우트에서 쿠키에 접근할 수 있다. 이로 인해 인증 토큰을 애플리케이션 전체에서 공유할 수 있으며, 특정 경로에 제한되지 않도록 설정.
sameSite: 'strict'
• 목적: 브라우저가 교차 사이트 요청 시 쿠키를 전송하지 않도록 설정.
• 이유: sameSite 옵션은 CSRF(크로스 사이트 요청 위조) 공격을 방지하는 데 도움. 'strict'로 설정하면 쿠키를 설정한 동일한 사이트 내 요청에서만 쿠키가 전송되며, 다른 사이트에서의 요청에는 전송되지 않기 때문에 민감한 정보 보호 강화.
이 옵션들을 토대로, 보다 강화된 서버 쿠키를 구울 수 있다.
만약, 이 옵션들을 클라이언트에서 한다면??
가능할까?
"NO"
우리 팀은 nextjs라는 웹서버가 존재하는 프레임워크를 이용하기에 이런 간편한 설계가 가능했다.(물론, vite 등도 ssr이 가능한 구조가 있는 것으로는 안다)
다음은, 인증이 필요한 페이지들에 대한 접근을 제한하는 인증 guard를 만들 차례다.
처음에는 필자도 미들웨어에서 가딩처리하는 것을 생각했었다.
아래 nextjs 공식 예제처럼, config에 path에 따른 처리도 물론 가능하다.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// This function can be marked `async` if using `await` inside
export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/home', request.url))
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
{
source:
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
{
source:
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
has: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
{
source:
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
has: [{ type: 'header', key: 'x-present' }],
missing: [{ type: 'header', key: 'x-missing', value: 'prefetch' }],
},
],
}
다만 이렇게 되면, 문제가 2개가 생긴다.
- 새로운 페이지가 추가될 때 마다 path를 추가해 줘야하는 번거로움
- 서버에서 저장해 놓은 인증 토큰을 결국은 클라이언트 쪽에 내려줘야 함.
그래서 필자는 이 두가지 문제를 적절히 해결한 부분으로 app router부터 생겨난 layout.tsx를 활용해서 랩핑해 주는 일종의 가딩 페이지에서 처리해 주기로 결정했다.
그래서 폴더 구조부터 살펴보면,
app
└── (verify-guard)
├── 인증필요페이지1
├── 인증필요페이지2
├── 인증필요페이지3
├── 인증필요페이지4
└── layout.tsx
app 단 안에 (verify-guard)로 한데 묶었고, layout.tsx 아래 인증이 필요한 페이지들을 두게 되었다.
다음으로,layout.tsx 코드 구조를 대략적으로 보면 이렇다.
const reissueToken = async () => {
try {
await axios.put('웹 서버 refresh 토큰 요청 주소');
} catch (error) {
redirect('/login');
}
};
const checkToken = async (accessToken: string | undefined, refreshToken: string | undefined) => {
try {
await axios('내 정보 확인용 api 주소', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
} catch (error) {
console.log(error);
if (isAxiosError(error) && error.response?.status === 401) {
reissueToken();
} else {
redirect('/login');
}
}
};
const VerifyPageLayout: FC<PropsWithChildren> = ({ children }) => {
const accessToken = cookies().get('인증 토큰 키 값')?.value;
const refreshToken = cookies().get('리프레쉬 토큰 키 값')?.value;
checkToken(accessToken, refreshToken); // 토큰 체크
return (
<>
<AuthProvider accessToken={accessToken} />
{children}
</>
);
};
export default VerifyPageLayout;
간단히 살펴보면, 인증이 필요한 페이지에 들어오는 순간, 기존 가지고 있던 토큰으로 내 정보를 읽을 수 있는 지 확인한다.
그 이후에, 만약 실패한다고 하더라도 바로 재로그인 시키지는 않는다.
리프레쉬 토큰을 통해 웹 서버(nextjs auth api)에 토큰 재발급 요청을 한다.
이때도 실패하면 그때는 재로그인 시킨다.
성공하면 새로운 토큰으로 다시 셋팅하고, 이 토큰을 바탕으로 새롭게 클라이언트 단에 내려줄 작업을 한다.
이전에 설명했듯, 이 토큰을 클라이언트 단에서 검증하고 셋팅을 해줘야 한다. 이게 바로 authProvider다.
authProvider는 zustand로 설정된 전역 상태 값에 토큰을 최신화 시켜주는 역할이다.
이후에 이 토큰을 다시 axios interceptor에서 쓸 예정이다.
'use client';
import { FC, useEffect } from 'react';
import { useAuthStore } from '@src/stores/useAuthStore';
interface IAuthProviderProps {
accessToken?: string;
}
export const AuthProvider: FC<IAuthProviderProps> = ({ accessToken }) => {
const setToken = useAuthStore((state) => state.setAccessToken);
useEffect(() => {
if (accessToken) {
setToken(accessToken);
}
}, [accessToken, setToken]);
return <></>;
};
아래는 웹 인증 서버에서 리프레시 토큰 요청 시 처리하는 로직이다.
export async function PUT(request: NextRequest) {
const refreshToken = cookies().get('rtk')?.value;
const accessToken = cookies().get('atk')?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
try {
const res = await axios.post(
'토큰 재발급 백엔드 api 주소',
{
refreshToken,
accessToken,
}
);
const newResponse = res;
cookies().set('atk', newResponse.data.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 15,
path: '/',
sameSite: 'strict',
});
cookies().set('rtk', newResponse.data.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 14, // 2주
path: '/',
sameSite: 'strict',
});
// 새로운 Access Token을 응답 헤더로 클라이언트에 전달
const response = NextResponse.json({ success: true });
response.headers.set('Authorization', `Bearer ${newResponse.data.accessToken}`);
return response;
} catch (error) {
console.log(error, '리이슈');
return NextResponse.json({ error: 'Failed to refresh token' }, { status: 401 });
}
}
필자는 axios로 api 요청을 주로 하는데, 헤더 및 공통화 된 부분은 interceptor화해서 사용하고 있다.
바로 authProvider에서 사용한 토큰을 여기에 주입해서 사용하고 있다.
아래는 토큰 주입후 사용하는 것과 더불어, 클라이언트 단 요청에서 인증 오류가 날 경우, 예를 들어 서버 리소스 시점에서 클라이언트 사이드 측면에서 401이 나면 처리해주는 로직도 추가해 줬다.
const defaultRequest = axios.create({
baseURL: '서버 url',
headers: {
'Content-Type': 'application/json',
},
});
defaultRequest.interceptors.request.use((config) => {
// 전역 상태인 zustand에서 가져온 토큰 사용.
const accessToken = useAuthStore.getState().accessToken; // 미리 상태를 가져옴
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
defaultRequest.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
console.log(error, 'error');
if (error.response && error.response.status === 401 && !originalRequest._retry) {
console.log(401);
originalRequest._retry = true;
try {
const response = await axios.put('/api/auth'); // 토큰 갱신 요청
// 헤더에 담겨진 토큰을 꺼내서 써야 함.
const newAccessToken = response.headers.authorization.split(' ')[1];
useAuthStore.getState().setAccessToken(newAccessToken); // 상태 업데이트
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return defaultRequest(originalRequest); // 요청을 재시도
} catch (refreshError) {
useAuthStore.getState().clearAccessToken(); // 토큰 클리어
window.location.href = '/login'; // 로그인 페이지로 리다이렉트
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default defaultRequest;
여기까지 핵심적인 인증 처리 및 설계 구조를 살펴봤다. 이 구조를 토대로 인증을 처리하고 새로고침 혹은 재접속 시에도 자동 로그인 등이 가능하다.
다음은, api route를 활용한 로그 아웃, 회원 탈퇴와 더불어 서버 액션을 활용한 revalidatePath 처리를 소개하며 마무리 하겠다.
사실 이 두 개 역시 마찬가지다.
로그 아웃과 회원 탈퇴 모두 이제 더는 사용자 인증 정보가 없기 때문에, 토큰을 날려줘야 한다.
그래서 이것도, 우리는 별도의 인증 서버가 없기에...
api route 단을 활용해서 처리해 줬다.
export async function POST() {
const refreshToken = cookies().get('rtk')?.value;
const accessToken = cookies().get('atk')?.value;
if (!refreshToken) {
console.log('Not authenticated');
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
try {
await axios.post(
'로그아웃 서버 요청 url | 회원 탈퇴 서버 요청 url',
{},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
// 쿠키 삭제.
cookies().delete('rtk');
cookies().delete('atk');
return NextResponse.json({ success: true });
} catch (error) {
console.log(error);
return NextResponse.json({ error: 'Failed to logout' }, { status: 401 });
}
}
종종 회원 탈퇴 하거나, 로그인 처리를 하고, 난 뒤에 이 정보를 바탕으로 새로운 ui를 조건부 랜더링을 해줘야 할 때가 있다.
이때는 server 단의 cache를 초기화 해줘야 한다.
보통 필자는 클라이언트 단에서 server action과 revalidatePath로 서버 캐시를 초기화시켰다.
// server actions
'use server';
import { revalidatePath } from 'next/cache';
export async function revalidateVerify() {
return revalidatePath('/', 'page');
}
// client 단 로직
'use client'
const onClickLogout = ()=>{
//... 기타 로그아웃 로직...//
revalidateVerify();
}
이 글을 토대로, 인증 처리를 이런 상황(인증 서버가 없고 백엔드 서버 리소스에서 별도의 처리가 없을 때)에 직면했을 때, 이렇게도 처리할 수 있구나라고 이해한다면 그걸로 만족이다.
필자 역시, 이번 경험을 토대로 토큰에 대한 고민과 인증 처리 방식에 대한 설계 구조를 고민해 봐서 너무 좋았다.
또 이걸 내가 현재 쓸 수 있는 가용한 기술 자원(nextjs route api, server component, app router, layout 등)과도 결합해서 적절히 사용할 지에 대한 고민도 너무 재밌는 경험이었다.
긴 글 읽어주셔서 감사하다.
(최근 일이 바빠서 조금 글이 미뤄졌는데 이제 앞으로도 계속 꾸준히 올리고 정리해 봐야겠다. 그럼 이만 오늘도 즐코~)