JWT 플로우를 작성하면서 access 토큰과 refresh 토큰에 대한 구현에 대한 많은 고민이 있었다. 좋은 글들이 많았고 이런 저런 방법을 고민하던 중, 내가 구현한 코드를 정리하고자 포스팅하게 되었다.
계속되던 삽질 끝에 깨닫게 된 것은 "JWT Access Token과 Refresh Token을 구현하는 방식은 너무나도 많다."라는 점이었다. 따라서 내가 포스팅하는 구현 방식이 절대 정답이 아니고 여러 방법 중 하나일 뿐임을 명심하길 바란다.
이번 포스팅은 간단한 JWT 소개와 시나리오, 그리고 구현으로 이어진다. 구현에 있어 세부적인 많이 생략했음을 고려하여 흐름만 이해하도록 하자. 글에 부족한 부분이 많이 있는데, 주기적으로 수정하여 살을 덧붙일 예정이다.
1. JWT란?
JWT는 JSON Web Token의 약어로, JSON 형식의 데이터를 저장하는 토큰이며 다음과 같이 세 부분으로 구성된다.
- 헤더(header): 토큰 종류와 해시 알고리즘 정보
- 페이로드(payload): 토큰의 내용물이 인코딩된 부분
- 시그니처(signature): 일련의 문자열, 시그니처를 통해 토큰이 변조되었는지 여부를 확인
JWT에 대한 더욱 자세한 정보는 여기에서 확인할 수 있다.
2. Access Token & Refresh Token란?
간단히 말해서 Access Token은 인증을 위한 JWT이면서, 동시에 보안을 위해 유효기간이 매우 짧다. 반면, Refresh Token은 유효기간이 짧은 Access Token을 보완하기 위한 JWT로서, Access Token에 비해 유효기간이 길다. Access Token과 Refresh Token, 그리고 기타 인증방식에 대해서 너무나도 깔끔하고 자세하게 포스팅한 글이 있어 여기에 링크를 남긴다.
3. 시나리오
1. 로그인을 하면 Access Token과 Refresh Token을 모두 발급한다.
이때, Refresh Token만 서버측의 DB에 저장하며 Refresh Token과 Access Token을 쿠키에 저장한다.
2. 사용자가 인증이 필요한 API에 접근하고자 하면, 가장 먼저 토큰을 검사하는 미들웨어를 검사한다.
이때, 토큰을 검사함과 동시에 각 경우에 대해서 토큰의 유효기간을 확인하여 재발급 여부를 결정한다.
- case1: access token과 refresh token 모두가 만료된 경우 -> 에러 발생
- case2: access token은 만료됐지만, refresh token은 유효한 경우 -> access token 재발급
- case3: access token은 유효하지만, refresh token은 만료된 경우 -> refresh token 재발급
- case4: accesss token과 refresh token 모두가 유효한 경우 -> 다음 미들웨어로
3. 로그아웃을 하면 Access Token과 Refresh Token을 모두 만료시킨다.
4. 실제 구현
앞서 말했듯이, 기본적인 부분과 민감한 부분은 많이 생략됐다.
구현하기에 어려운 부분은 아니니 흐름만 이해하면서 읽으면 될 듯하다.
프로젝트 구조
예제를 위한 샘플 코드라 이름 중복이 많고 필요없는 코드들은 지운 상태이다.
express, jsonwebtoken, cookie-parser 등의 필요한 모듈은 모두 설치되어 있다고 가정한다.
라우터
../routes/user.js
먼저 토큰을 검사하는 미들웨어(checkTokens)를 거쳐 유효한 사용자가 존재하는지 확인하는 미들웨어(checkUser)를 거쳐 마지막 콜백함수가 실행된다.
const user = require('../controllers/user');
const { checkTokens, checkUser } = require('../middlewares/user');
const router = express.Router();
...
router.post('/login', user.login);
router.get('/read', checkTokens, checkUser, user.read);
유틸
../utils/jwt.js
JWT에 관련되어서 토큰을 처리하는 과정에서 커스텀이 필요한 부분들을 유틸에 정의한다.
아래 코드에선 유효기간이 만료된 코드에 대해서 null을 리턴한다.
const jwt = require('jsonwebtoken');
//...
module.exports = {
verifyToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch (e) {
/**
* 다음과 같은 형태로 특정 에러에 대해서 핸들링해줄 수 있다.
if (e.name === 'TokenExpiredError') {
return null
}
*
*/
return null
},
...
}
}
시나리오1: 로그인
../controllers/user.js
Refresh Token과 Access Token을 모두 발급한 후, Refresh Token만 DB에 저장한다.
이때 토큰이 저장되는 DB에 유저 테이블의 PK값이 FK값으로 저장되기 때문에 Refresh Token의 payload엔 빈 객체를 할당한다. 이는 JWT 특성과도 연관되는데, JWT의 payload가 늘어날수록 오버헤드가 크기 때문이다.
Refresh Token은 2주(14d), Access Token은 1시간(1h)의 유효기간을 가진다.
당연한 말이지만, 만약 Access Token의 유효기간이 Refresh Token보다 길다면 굳이 토큰을 분리하여 플로우를 유지하는 이유가 사라지게 된다.
...
const controller = {
async login (req, res, next) {
/**
* POST요청을 통해 req.body에 담긴 사용자의 id와 name을 추출하는 로직
*/
...
const refreshToken = jwt.sign({},
process.env.JWT_SECRET, {
expiresIn: '14d',
issuer: 'cotak'
});
const connection = await pool.getConnection(async conn => await conn);
try {
// DB에 refresh 토큰 삽입
await connection.beginTransaction();
await connection.query(`
INSERT INTO
tokens(content, user_no)
VALUE
(?, ?);
`, [refreshToken, user_no]);
await connection.commit();
// 토큰 세팅
const accessToken = jwt.sign({ userId, userName },
process.env.JWT_SECRET, {
expiresIn: '1h',
issuer: 'cotak'
});
// 웹 브라우저(클라이언트)에 토큰 세팅
res.cookie('accessToken', accessToken);
res.cookie('refreshToken', refreshToken);
next();
} catch (e) {
await connection.rollback();
next(e);
} finally {
connection.release();
}
},
...
}
module.exports = controller;
시나리오2: 토큰 검사 및 재발급
../middlewares/user.js
여기가 살짝 복잡하다. 앞서 말한 4가지 케이스에 대해서 모두 검사를 해줘야 한다. 아래의 예제에선 생략했지만, 실제론 refresh token을 DB조회를 통해 검사해줘야 한다. (관련 링크)
- case1: access token과 refresh token 모두가 만료된 경우 -> 에러 발생
- case2: access token은 만료됐지만, refresh token은 유효한 경우 -> access token 재발급
- case3: access token은 유효하지만, refresh token은 만료된 경우 -> refresh token 재발급
- case4: accesss token과 refresh token 모두가 유효한 경우 -> 바로 다음 미들웨어로 넘긴다.
...
const { verifyToken } = require("../utils/jwt");
module.exports = {
async checkTokens(req, res, next) {
/**
* access token 자체가 없는 경우엔 에러(401)를 반환
* 클라이언트측에선 401을 응답받으면 로그인 페이지로 이동시킴
*/
if (req.cookies.access === undefined) throw Error('API 사용 권한이 없습니다.');
const accessToken = verifyToken(req.cookies.access);
const refreshToken = verifyToken(req.cookies.refresh); // *실제로는 DB 조회
if (accessToken === null) {
if (refreshToken === undefined) { // case1: access token과 refresh token 모두가 만료된 경우
throw Error('API 사용 권한이 없습니다.');
} else { // case2: access token은 만료됐지만, refresh token은 유효한 경우
/**
* DB를 조회해서 payload에 담을 값들을 가져오는 로직
*/
const newAccessToken = jwt.sign({ userId, userName },
process.env.JWT_SECRET, {
expiresIn: '1h',
issuer: 'cotak'
});
res.cookie('access', newAccessToken);
req.cookies.access = newAccessToken;
next();
}
} else {
if (refreshToken === undefined) { // case3: access token은 유효하지만, refresh token은 만료된 경우
const newRefreshToken = jwt.sign({},
process.env.JWT_SECRET, {
expiresIn: '14d',
issuer: 'cotak'
});
/**
* DB에 새로 발급된 refresh token 삽입하는 로직 (login과 유사)
*/
res.cookie('refresh', newRefreshToken);
req.cookies.refresh = newRefreshToken;
next();
} else { // case4: accesss token과 refresh token 모두가 유효한 경우
next();
}
}
},
...
}
...
시나리오3: 로그아웃 후 Access Token, Refresh Token 만료
이 부분은 따로 코드로 작성하지 않았다. 사용자 정보를 확인해서 Refresh Token은 DB에서 지우고, Access Token은 res.clearCookie()를 이용해서 쿠키에서 지우면 된다.
'웹 > Node.js' 카테고리의 다른 글
[Node.js] 비동기식 mysql을 사용하는 이유 (async/await) (3) | 2021.04.14 |
---|---|
[Node.js] 모듈화에 사용되는 module.exports와 exports의 차이 (0) | 2021.04.13 |
[Node.js] MongoDB: 개념 및 기본 쿼리문 (0) | 2021.03.14 |
[Node.js] Express 6: 쿠키와 세션 (Cookie & Session) (2) | 2021.02.22 |
[Node.js] Express 5: 에러 처리(Error Handling) (0) | 2021.02.22 |