🌼 해당 글은 다음 스펙을 기준으로 작성되었습니다.
새로 참여한 프로젝트에서 맡게 된 파트 중에 "회원가입 / 로그인"이 있었다. 우리는 구글 로그인만 사용하기로 했고, 구글 소셜 로그인 구현은 백엔드에서 진행하기로 했다. 프론트엔드에선 그저 로그인 성공 시 넘겨주는 accessToken
과 refreshToken
을 가지고 있다가, API 요청을 보낼 때 headers
에 accessToken
을 넘겨주면 된다고 했다. 사실 무슨 말인지 이해는 잘 못했지만 어렵게 느껴지진 않았다. ㅎㅎ 할만할 듯?
그러나 막상 작업에 들어가자 이 파트를 맡은 걸 매우 후회했다 ··· (생략)
accessToken
, refreshToken
, hasInfo
(회원가입 / 로그인을 구분하기 위한 boolean 값)을 받는다.accessToken
과 refreshToken
을 쿠키로 관리한다.accessToken
이 만료되면 refreshToken
을 가지고 새로운 accessToken을 발급 받는다.accessToken
과 refreshToken
을 쿠키에서 제거한다.(프론트엔드에서 로그인을 처리하기 전에, 꼭 이 글을 보는 걸 추천한다. 프론트엔드에서의 로그인, JWT 관리 등을 검색해봤다면 무조건 봤을 글이라고 생각하지만 꼭 읽어보길 추천한다. )
JWT
는 유저의 신원이나 권한을 결정하는 정보를 담고 있는 데이터 조각이다. 유저가 로그인을 할 때 서버가 인증 정보를 보내주는데, 이때 암호화나 시그니처 추가가 가능한 데이터 패키지 안에 인증 정보를 담아 준다. 이 패키지가 JSON Web Token
즉, JWT
이다. 이 안에 담기는 정보 중 accessToken
과 referhsToken
이 유저 인증에 사용되는데, 이 정보를 클라이언트에 저장한다.
이런 JWT
인증방식은 비밀키로 암호화를 하기 때문에 클라이언트와 서버는 안전하게 통신할 수 있다.
다만 이런 JWT
가 탈취 당했을 때 발생할 수 있는 여러 보안상 문제가 존재한다. 이때문에 유효 기간을 두는 것이다. 다만 유효기간을 짧게 두면 사용자가 로그인을 자주 해야하므로 사용자 경험적으로 좋지 않고, 길게 두면 보안상 탈취 위험이 높기 때문에 유효 기간이 다른 2개의 JWT
(accessToken
, refreshToken
)을 사용하는 것이다.
accessToken
을 사용하고, refreshToken
은 이 accessToken
이 만료됐을 때 사용한다.accessToken
의 만료 기간은 짧게 두고 refreshToken
으로 주기적으로 새로운 accessToken
을 재발급한다.accessToken
을 탈취하더라도 짧은 유효 기간이 지나면 사용할 수 없다.refreshToken
을 사용하여 새로운 accessSToken
재발급이 가능하다.결론적으로, 짧은 시간 동안에만 사용 가능하며 주기적으로 재발급 받을 수 있게 하여 accessToken
이 유출되더라도 그 피해를 최소화하는 방식이다.
refreshToken
과 accessToken
을 받는다.refreshToken
과 accessToken
을 로컬에 저장한다.accessToken
을 넣어 통신을 한다.accessToken
의 유효 기간이 만료되었다.accessToken
을 헤더에 넣어 API 통신을 시도한다.accessToken
을 받은 서버는 401(Unauthorized)에러 코드로 응답한다.accessToken
의 유효기간이 만료되었음을 알 수 있다.AccessToken
이 아닌 refreshToken
을 넣어 accessToken
재발급 API를 요청한다.rerfreshToken
으로 사용자의 권한을 확인한 서버는 새로운 accessToken
을 발급하여 응답한다.이제 accessToken
과 refreshToken
의 사용 이유와 프로세스에 대해 얼추 이해했다. 그러면 이제 이 JWT
를 로컬 어디에 저장해야할 지 고민이 된다. 기본적으로 3 가지 방법이 있다.
localStorage
/ sessionStorage
저장secure
httpOnly
쿠키 저장1번의 경우 Javascript로 접근이 가능하며 보안상 취약점이 많아 제외했다. 2번의 경우 역시 Javascript 내 접근이 가능하며, XSS 취약점이나 CSRF 취약점이 있을 때 API 요청 시 공격을 수행하거나 유저 권한으로 정보를 가져올 수 있다.
3번 방식은 브라우저에 쿠키로 저장되는 건 2번과 같지만 httyOnly
쿠키의 경우 Javascript 내에서 접근이 불가능하며, secure
을 적용하면 https 접속에서만 동작한다.
(3번의 경우에도 취약점이 존재하나, 자세한 내용은 적지 않겠다.)
로컬 스토리지나 세션 스토리지는 Javascript로 접근이 가능한 문제도 있었으나 서버 사이드에선 접근할 수 없다는 것도 가장 큰 문제였다. 우리 프로젝트는 NextJS 프로젝트였고, 서버사이드에서 API 요청이나 로그인 여부 확인이 필요했기 때문에 1번은 일찌감치 탈락했다.
여기까지 찾아봤을 때 생각했던 건 "refreshToken은 httpOnly 쿠키에 저장하고 accessToken은 recoil에 저장하자!"였다. 다만 recoil
과 Nextjs가 상성이 좋지 않다는 게 가장 큰 문제였다. 앞서 다른 작업을 할 때 recoil
로 관리되던 데이터가 새로 고침 시 날라가는 걸 확인할 수 있었다. 그래서 recoil
을 사용하여 어떤 값을 전역적으로 관리하려면 recoil-persist
를 사용해서 로컬 스토리지에 저장하는 작업을 추가로 진행했어야 했다.
만약 recoil
로 accessToken
을 관리한다고 해도, 똑같이 새로고침 시 날라가버린다면 recoil-persist
로 브라우저 저장소에 저장해야 하는데 이건 로컬 스토리지에 accessToken
을 저장하는 것과 같지 않을까,, 라는 고민이 들었다. 더 이상적인 JWT 핸들링 방법을 고민했으나, 작업 일정이 촉박하였고 JWT 공부에 더 시간을 투자할 수 없는 상황이었다. 그래서 우선 accessToken
역시 쿠키에 저장하기로 하였다.😔
다만 SSR(서버 사이드)에선 Next 서버를 통해 httpOnly
쿠키에 접근할 수 있었으나 CSR(클라이언트 사이드)의 경우 httpOnly
쿠키에 접근할 방법이 없어 accessToken
은 일반 쿠키에 저장하였다. 보안상 아쉬움이 정말 많았지만 우선은 이렇게 진행할 수 밖에 없었다. ㅠㅠ (최대한 쿠키의 SameSite, httpOnly, secure 속성 등을 확인하여 보안 이슈를 방어하고자 하였다. 그리고 accessToken
의 만료 시간을 10분으로 짧게 제한 두었다.)
+글을 작성하는 시점에서 생각했을 때 가장 이상적인 핸들링은 프론트엔드 도메인과 백엔드 도메인을 일치시키고 accessToken
과 refreshToken
을 모두 secure
httpOnly
쿠키에 저장하는 것이다. 도메인이 같을 때 클라이언트에서 HTTP 요청을 보낼 때 자동으로 쿠키가 서버에 전송되기 때문이다. (우리 프로젝트에서 백엔드와 프론트엔드의 도메인을 일치 시키고자 시도하였으나 실패하였다,,) 이 역시 XSS 취약점이 있다면 보안 이슈가 존재하기 때문에 클라이언트와 서버에서 별도의 XSS 방어 처리를 해줘야 한다.
우리 프로젝트는 서버 사이드와 클라이언트 사이드에서 API 요청을 보냈고 요청을 보낼 때마다 accessToken
을 헤더에 담아서 보내줘야 했다. 또한 로그인 여부는 accessToken
의 존재 여부로 확인하였고 이 역시 서버 사이드와 클라이언트 사이드 모두에서 accessToken
을 접근하였다. 그리고 refreshToken
은 httpOnly
쿠키에 저장되었으므로 무조건 서버 사이드에서만 접근할 수 있었다. 요약하자면 다음과 같다.
accessToken
접근refreshToken
접근 가능로그인 성공 후 리디렉션 되는 프론트엔드 URL은 "{CLIENT_BASE_URL}/oauth"이다. 이때 파라미터로 accessToken
과 refreshToken
, hasInfo
(회원가입 / 로그인 구분하는 boolean) 값을 받는다.
다음 코드는 pages/oauth/index.tsx
의 일부이다.
export default function Oauth() {
const [accessToken, setAccessToken] = useState<string | null>('');
const [refreshToken, setRefreshToken] = useState<string | null>('');
const [hasInfo, setHasInfo] = useState<boolean | null>(null);
useEffect(() => {
const url = window.location.search;
const urlParams = new URLSearchParams(url);
// url 파라미터에서 값 가져오기
const accessTokenParam = urlParams.get('accessToken');
const refreshTokenParam = urlParams.get('refreshToken');
const hasInfoParam = urlParams.get('hasInfo');
// 해당 값들을 state로 저장하기
if (accessTokenParam !== null) {
setAccessToken(accessTokenParam);
}
if (refreshTokenParam !== null) {
setRefreshToken(refreshTokenParam);
}
if (hasInfoParam !== null) {
setHasInfo(hasInfoBoolean);
}
}, [])
return <LoadingSpinner />;
}
oauth 페이지가 마운트될 때, url 파라미터에서 accessToken
, refreshToken
, hasInfo
값을 받아 온다. 각각의 값들은 useState
를 통해 저장된다. (eslint
경고 방어로 조건문을 추가했다.)
그리고 accessToken
과 refreshToken
이 state에 저장되면 그 값들을 가지고 Next API Routes
로 만든 session
API로 전달한다. 이 session API에서 JWT를 쿠키에 저장하는 처리를 한다.
useEffect(() => {
if (accessToken && refreshToken) {
axios
.post('/api/auth/session', {
accessToken,
refreshToken,
})
.then((res) => {
if (res.status === 200) {
setIsTokenPosted(true);
}
})
.catch((error) => {
console.error('로그인 성공 후 토큰 저장 실패', error);
});
}
}, [accessToken, refreshToken]);
NextJS API Routes는 쉽게 말해선 Next 서버에서 사용할 수 있는 API를 만드는 기능이라고 보면 된다. pages/api
안에 만든 파일이 rest api path
가 된다.
참고 포스팅 - Next API Routes란?
pages/api/auth/session.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { setCookie } from 'cookies-next';
import IAuth from '@/types/auth';
export default function Session(req: NextApiRequest, res: NextApiResponse) {
// API 요청이 POST일 때만
if (req.method === 'POST') {
try {
const { accessToken, refreshToken } = req.body as IAuth;
if (accessToken && refreshToken) {
setCookie('accessToken', accessToken, {
req,
res,
path: '/',
maxAge: 60 * 60 * 24,
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
setCookie('refreshToken', refreshToken, {
req,
res,
path: '/',
maxAge: 60 * 60 * 24,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
res.status(200).json({ message: '토큰 저장 성공' });
} else {
res
.status(400)
.json({ message: '토큰 저장 실패 | 토큰이 존재하지 않습니다.' });
}
} catch (error) {
res.status(500).json({ message: '토큰 저장에 실패하였습니다.' });
}
} else {
res.setHeader('Allow', ['POST']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
Set-Cookie
를 사용하여 쿠키에 저장할 수 있었으나, 좀 더 쉽게 쿠키를 관리하기 위해 cookies-next
라이브러리를 사용하였다. 꼭 사용할 필요는 없다. 개발 환경(npm run dev
) 일 땐 http
로 접근하므로 배포환경일 때만 secure
속성을 켜도록 설정하였다. 또한 refreshToken
은 서버에서만 접근할 수 있도록 httpOnly
속성을 켜두었다. 둘 다 만료기간은 하루이지만, 실제 JWT의 만료기간과는 상이하다. (우리 프로젝트에서 accessToken
은 10분, refreshToken
은 2주동안 유효하다.)
클라이언트 사이드에선refreshToken
을 httpOnly
쿠키에 저장할 수 없으므로 서버가 res, req 객체를 통해 쿠키를 저장할 수 있도록 session API Routes를 생성하였다. 또한 성공 시 200
코드와 message
를 전달하였고 실패 시 경우에 따라 다른 에러 코드를 응답하도록 하였다.
session API에서 200
상태 코드를 응답하면 isTokenPosted
를 true로 변경한다. isTokenPosted
이 true이거나 getCookie('accessToken')
값이 존재할 때 hasInfo
에 따라 "/register"이나 "/home"으로 라우팅 처리를 하였다.
로그아웃 시 쿠키에 저장된 accessToken
과 refreshToken
을 제거해야 한다. 로그인일 때(session)와 마찬가지로 httpOnly 쿠키에 접근하기 위해서 logout API Route를 만들어 쿠키를 제거하였다.
import { NextApiRequest, NextApiResponse } from 'next';
import { deleteCookie } from 'cookies-next';
export default function logout(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
deleteCookie('accessToken', { req, res, path: '/' });
deleteCookie('refreshToken', { req, res, path: '/' });
res.status(200).json({ message: '로그아웃 성공 | 토큰 삭제' });
} else {
res.setHeader('Allow', ['POST']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
Next에서 JWT 핸들링에 대해 고민한 과정과 최종 코드를 정리하였다. 위에서 말했던 것처럼 보안상의 아쉬운 점이 존재하는 코드이다. 더 좋은 방법을 찾기 위해서 많이 알아보았으나 현재 코드 이상으로 좋은 방법(가능한 방법)을 찾지 못하였다. NextJS도 JWT도 이번 프로젝트에서 처음 제대로 사용해보는 초보자라 더 어렵게 느껴졌던 것 같다. 😥
그리고 사실 가장 어려웠던 부분은 accessToken
만료 시 refreshToken
을 재발급 받는 기능을 구현하는 것이었다. 해당 내용은 다음 포스팅에서 서술하겠다.
만약 포스팅에서 이해가 되지 않는 부분이나 혹은 잘못 설명한 부분이 있다면 댓글로 알려주세요 ! 🙇🏻♀️🙇🏻♂️
전체 코드는 여기에서 확인할 수 있습니다.