JWT 토큰은 사용자의 신원이나 권한을 결정하는 정보를 담고 있는 데이터 조각이다.
이를 통해 클라이언트와 서버는 안전하게 통신한다. 그렇다보니 탈취 당했을 때 문제가 생기는데, JWT를 탈취한 사람은 마치 신뢰할만한 사람인 것처럼 인증을 통과할 수 있기 때문이다.
따라서 JWT 토큰에는 유효기간이 필요하다. 그러나 유효기간을 짧게 두면 로그인을 자주 해야하는 불편함이 생기고, 그렇다고 길게 하자니 탈취 위험에서 벗어날 수 없다.
이런 문제점에 대한 해결법은 유효기간이 다른 토큰을 2개 두는 것이다. 이를 각각 Access 토큰, Refresh 토큰이라고 한다.
Access 토큰
사용자의 인증이 완료된 후, 해당 사용자를 인증하는 용도로 발급하는 토큰이다. 주로 JWT 형식으로 만들어지며, 이전에 구현했던 쿠키(Cookie)에 JWT를 설정하고, 지정된 만료 시간이 지나면 인증이 만료되는 구조 또한 Access 토큰이라고 할 수 있다.
인증 요청 시 Access 토큰을 사용하면, 토큰을 생성할 때 사용한 비밀키로 인증을 처리하게 되고, 이로 인해 여러 분기 처리 없이 코드를 구현할 수 있다는 장점이 있다.
특징으로는 무상태(Stateless)라는 게 있는데, 무상태란, 서버가 사용자의 상태(로그인 여부 등)를 기억하지 않고, Access 토큰 자페에 인증 정보를 저장해 필요할 때마다 이를 확인할 수 있는 구조를 의미한다.
즉, 서버가 재시작되거나 종료되어도 JWT에 포함된 정보를 바탕으로 계속 인증을 처리할 수 있다.
그러나 Access 토큰 자체가 사용자 인증에 필요한 모든 정보를 갖고 있다는 뜻이라서, 토큰을 가지고 있는 시간이 늘어날 수록 탈취 되었을 때 피해 규모 또한 커지게 된다. 또한 토큰이 탈취되어도 서버에서는 해당 토큰이 탈취된 토큰인지 아닌지 확인할 수 없으며, 강제로 토큰을 만료할 수도 없기 때문에 Refresh 토큰이라는 개념이 나오게 되었다.
Refresh 토큰
사용자의 모든 인증 정보를 담고 있는 Access 토큰과 달리, 특정 사용자가 Access 토큰을 발급받기 위한 목적으로 사용된다. 예를 들어 토큰이 만료되었을 때, 로그인을 한 번 더 수행하지 않고 Refresh 토큰을 이용해서 Access 토큰을 재발급 받을 수 있다.
좀 더 쉽게 말하면 사용자의 인증 정보를 검증하는데 사용되며, 이를 서버에서 관리한다.
서버는 Refresh 토큰을 디코딩하여 사용자의 정보를 확인하게 된다. 이 방식은 필요한 경우 서버에서 강제로 토큰을 만료시킬 수 있으며, 사용자의 인증 상태를 언제든 서버에서 제어할 수 있다.
즉, Access 토큰이 만료된 후 사용자가 다시 Refresh 토큰을 사용해 Access 토큰을 발급 받으려고 할 때, 처음 Refresh 토큰을 발급 받을 때와 두번째 Access 토큰을 재발급 받을 때의 상태가 동일해야지만, Access 토큰을 재발급 받을 수 있다.
따라서 보통 Access 토큰은 유효기간이 짧고, Refresh 토큰은 유효기간이 길다.
1. 로그인에 성공하면, 클라이언트는 Access 토큰과 Refresh 토큰을 발급 받는다.
2. 일정 시간이 지나 Access 토큰이 만료된다.
3. Refresh 토큰을 이용해 인증 서버에 다시금 Access 토큰을 재요청한다.
4. Refresh 토큰으로 사용자의 권한을 확힌한 서버는 새로운 Access 토큰을 발급한다.
Refresh 토큰 API 명세서
기능 | Method | URL | Request Header | Request | Response | Response Header |
Refresh/Access Token 발급 API | POST | localhost:3019/tokens | { "id": "64c47134ad123e335d76fff5" } |
{ "id": "64c47134ad123e335d76fff5" } |
{ ”accessToken”: “eyJhbGciOiJIUzI….”, ”refreshToken”: “eyJhbGciOiJIUzI…” } |
|
AccessToken 검증 API | GET | localhost:3019/tokens/validate | { ”accessToken”: “eyJhbGciOiJIUzI….” } |
{} | { "message": "64c47134ad123e335d76fff5의 Payload를 가진 Token이 성공적으로 인증되었습니다." } |
|
Refresh Token을 이용해 엑세스 토큰 재발급 API | POST | localhost:3019/tokens/refresh | { ”refreshToken”: “eyJhbGciOiJIUzI…” } |
{} | { "message": "Access Token을 새롭게 발급하였습니다." } |
{ ”accessToken”: “eyJhbGciOiJIUzI….” } |
환경 설정
# yarn을 이용해 프로젝트를 초기화합니다.
yarn init -y
# express, jsonwebtoken, cookie-parser 패키지를 설치합니다.
yarn add express jsonwebtoken cookie-parser
// app.js
import express from 'express';
import jwt from 'jsonwebtoken';
import cookieParser from 'cookie-parser';
const app = express();
const PORT = 3019;
// 비밀 키는 외부에 노출되면 안된다.
// 그렇기 때문에, 보통 .env 파일을 이용해 비밀 키를 관리해야한다.
const ACCESS_TOKEN_SECRET_KEY = `HangHae99`; // Access Token의 비밀 키를 정의합니다.
const REFRESH_TOKEN_SECRET_KEY = `Sparta`; // Refresh Token의 비밀 키를 정의합니다.
app.use(express.json());
app.use(cookieParser());
app.get('/', (req, res) => {
return res.status(200).send('Hello Token!');
});
app.listen(PORT, () => {
console.log(PORT, '포트로 서버가 열렸어요!');
});
Access 토큰과 Refresh 토큰을 다르게 설정한 이유는, 만약 Access 토큰이 탈취되어도, 해당 정보를 바탕으로 악의적인 해커가 다시금 그걸 해석해 Refresh 토큰에 사용하는 비밀키를 알아내지 못하게 하기 위해서다.
토큰 발급 API
// Refresh 토큰을 관리할 객체
const tokenStorages = {}
// 토큰 발급 API
app.post('/tokens', async(req, res) => {
// id 발급
const {id} = req.body;
// 토큰 발급
// jwt 발급 => id를 바탕으로 생성
// ACCESS_TOKEN_SECRET_KEY로 비밀키 지정
// Access 토큰을 사용할 수 있는 시간: 10초
const accessToken = jwt.sign({id: id}, ACCESS_TOKEN_SECRET_KEY, {expiresIn: '10s'});
// REFRESH_TOKEN_SECRET_KEY 비밀키 지정
// Refresh 토큰을 사용할 수 있는 기간: 7일
const refreshToken = jwt.sign({id: id}, REFRESH_TOKEN_SECRET_KEY, {expiresIn: '7d'});
// 세션과 비슷...
// Refresh 토큰을 가지고 해당 유저의 정보를 서버에 저장
tokenStorages[refreshToken] = {
id: id, // 사용자에게 전달받은 ID를 저장
ip: req.ip, // 사용자의 IP 정보를 저장
userAgent: req.headers['user-agent'], // 특정 클라이언트가 어떤 방식으로 서버에 요청했는가
}
// 클라이언트에게 쿠키(토큰)를 할당
res.cookie('accessToken', accessToken);
res.cookie('refreshToken', refreshToken);
return res.status(201).json({message: 'Token이 정상적으로 발급되었다.'});
});
Refresh 토큰을 발급하고 저장까지 완료했다.
tokenStorages을 실제로 console.log를 통해 읽으면 아래 사진과 같다.
JWT가 키로 사용됐고, 안에는 클라이언트가 저장한 정보들이 들어있는 걸 확인할 수 있다. id는 입력한 그대로 들어가 있고, ip는 로컬 호스트라 127.0.0.1이 나왔다.(왼쪽은 IPv6 주소)
!!!! 주의 !!!!
이번 예제에서 Refresh 토큰은 tokenStorages라는 객체 형태로 관리했지만, 이는 인 메모리(In-Memory) 방식을 사용하기 때문에 서버가 재시작/종료될 경우, 모든 정보가 사라진다.
따라서 실제 서비스에서는 별도의 테이블에서 Refresh 토큰을 저장하고 관리한다.
이렇게 할 경우, Access 토큰이 만료되고 Refresh 토큰 검증 작업을 수행할 때, MySQL과 같은 DB를 조회함과 동시에 함께 처리할 수 있는 장점을 가지게 된다.
Access 토큰 검증 API
이전에는 Access 토큰을 검증하기 위해 사용자 인증 미들웨어를 사용했다.(링크)
이번에는 Access 토큰으로 구현할 것이다.
// Access 토큰 검증 API
app.get('/tokens/validate', async(req, res) => {
// 쿠키 정보를 객체 구조 분해로 가져온다
const accessToken = req.cookies.accessToken;
// Access 토큰 존재여부 확인
if(!accessToken)
return res.status(400).json({message: 'Access 토큰이 존재하지 않는다'});
const payload = validateToken(accessToken, ACCESS_TOKEN_SECRET_KEY);
if(!payload) // Access 토큰 기간이 만료되도 이곳으로 온다
return res.status(401).json({errorMessage: 'Access 토큰이 정상적이지 않다.'});
const {id} = payload;
return res.status(200).json({message: `${id}의 Payload를 가진 토큰이 정상적으로 인증 됐다`});
});
// 토큰 검증 & payload를 조회하기 위한 함수
// 검증에도 사용하고, 토큰 안에 있는 정보를 확인하기 위해서도 사용
// 1. 어떤 토큰을 가져와 해당하는 payload를 가져올 것인지.
// 2. 토큰을 사용하기 위한 비밀키
function validateToken(token, secretKey) {
// 인증에 성공: payload 반환
// 인증에 실패: 에러 발생
try{
return jwt.verify(token, secretKey);
} catch(error) {
return null;
}
}
다시 토큰을 재발급하고, 해당 코드를 실행하면 다음과 같은 결과를 볼 수 있다.
- validateToken()
ValidateToken()는 제공된 토큰이 유효한지 여부를 검증하는 역할을 담당한다
- secretKey를 전달받아, 서버에서 검증할 비밀 키를 설정한다.
- Access Token 이나 Refresh Token이 저희가 발급한 것인지 검증한다.
- Access Token 이나 Refresh Token의 만료 여부를 검증한다.
Access 토큰 재발급 API
// Access 토큰 재발급 API
app.post('/tokens/refresh', async(req, res) => {
// Refresh 토큰을 객체 구조 분해 할당으로 가져온다.
const {refreshToken} = req.cookies;
if(!refreshToken)
return res.status(400).json({errorMessage: 'Refresh 토큰이 존재하지 않는다.'});
const payload = validateToken(refreshToken, REFRESH_TOKEN_SECRET_KEY);
if(!payload)
return res.status(401).json({errorMessage: 'Refresh 토큰이 정상적이지 않다.'});
const userInfo = tokenStorages[refreshToken];
if(!userInfo) // 서버 재시작/종료로 해당 정보가 날아갔다.
return res.status(419).json({errorMessage: 'Refresh 토큰의 정보가 서버에 존재하지 않는다.'});
// 새로운 Access 토큰 발급
const newAccessToken = jwt.sign(userInfo.id, ACCESS_TOKEN_SECRET_KEY);
// 클라이언트에게 전달
res.cookie('accessToken', newAccessToken);
return res.status(201).json({message: 'Access 토큰을 정상적으로 새롭게 발급했다.'});
});
여기서 잠깐!!!
해당 코드에서, Access 토큰을 재발급 받을 때, 맨 처음 발급 받을 때와 다른 점이 하나 있다.
// 맨 처음 발급 받을 때
const accessToken = jwt.sign({id: id}, ACCESS_TOKEN_SECRET_KEY, {expiresIn: '10s'});
// 재 발급 받을 때
const newAccessToken = jwt.sign(userInfo.id, ACCESS_TOKEN_SECRET_KEY);
그렇다. 맨 처음 발급 받을 때는 유효기간을 10초로 설정했는데, 재 발급 받을 때는 유효기간 설정을 하지 않았다!
이렇듯 코드를 작성할 때, 일관성이 있어야 하는 부분에서 사람들은 실수를 하기 마련이다.
이런 실수를 줄이기 위해 우리는 Access 토큰을 일정하게 발급할 새로운 함수를 사용할 예정이다.
function createAccessToken(id) {
return jwt.sign({id}, ACCESS_TOKEN_SECRET_KEY, {expiresIn: '10s'});
}
해당 함수를 사용해 Access 토큰을 발급 받는 방식은 다음과 같다.
// 맨 처음 발급 받을 때
const accessToken = createAccessToken(id);
// 재 발급 받을 때
const newAccessToken = createAccessToken(userInfo.id);
이후 토큰을 새로 발급 받은 후 재 발급 받는 API를 실행하면 다음과 같은 결과가 나온다.
프로젝트를 신속하게 구현해야 하거나 사용자의 요청에 대한 인증을 최소화하려는 목표가 있다면 Access Token만 사용해도 괜찮다. 반면에 보안성을 중요하게 여기고 서버를 더욱 견고하게 구성 해야한다면 Refresh Token의 사용을 고려해야 한다.
'Node.js' 카테고리의 다른 글
24/09/19 - Node.js 숙련(2 - 3): Express Session (0) | 2024.09.19 |
---|---|
24/09/18 - Node.js 숙련(2 - 2): 트랜잭션(Transaction) (0) | 2024.09.18 |
24/09/11 - Node.js 숙련(1 - 8): JWT(Json Web Token) (0) | 2024.09.11 |
24/09/11 - Node.js 숙련(1 - 7): 쿠키와 세션 (0) | 2024.09.11 |
24/09/10 - Node.js 숙련(1 - 6): Prisma 시작 (0) | 2024.09.10 |