Refresh Token 탈취 대응
AccessToken과 RefreshToken을 같이 쿠키에 저장하고 있기 떄문에 둘이 같이 탈취될 가능성이 있습니다.
RefreshToken은 유효기간이 길어서 만료되기 전까지 해커가 계속해서 사용할 수 있습니다.
대응 방법으로 RefreshToken을 발급할 때 서버 저장소에 따로 저장하고 RefreshToken을 1회성으로 사용하는 RTR (Refresh Token Rotation)을 도입해보겠습니다.
RefreshToken을 저장할 서버 저장소로 Redis를 사용해보겠습니다.
Redis를 사용하는 이유
RefreshToken을 서버 저장소에 따로 저장해서 사용한다는 것은 추가적인 I/O 작업이 필요하다는 의미입니다.
JWT의 장점인 빠른 인증 처리를 살리기 위해 DB보다는 보다 속도가 빠르고 가벼운 In-Memory 기반의 Redis에 저장합니다.
또한, RefreshToken은 간단하게 (사용자_id, RefreshToken) 정도로 저장할 것이기 떄문에 Key-Value 기반의 Redis에 저장합니다.
구현할 로직
Redis 설정
Redis는 Redis 클라우드 서비스인 redisLabs
을 이용하겠습니다.
config 폴더에 Redis설정을 해두고 import해 사용하겠습니다.
redisLabs에 생성한 redis-DB와 연동한 후 연결해줍니다. (사용법은 생략하겠습니다.)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// config/redis.js
import redis from "redis";
const redisClient = redis.createClient({
url: `redis://${process.env.REDIS_USERNAME}:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}/${process.env.REDIS_DB_NUM}`,
});
redisClient.on("error", (err) => {
console.error("Redis Client Error", err);
});
redisClient.on("connect", () => {
console.info("Redis connected!");
});
await redisClient.connect();
export default redisClient;
jwt_util.js 수정
RefreshToken 생성 함수에서는 Redis에 값이 존재한다면 삭제한 후 생성한 RefreshToken을 저장합니다.
RefreshToken 검증 함수에서는 요청과 함께 보낸 RefreshToken으로 key값을 얻어낸후 Redis를 조회합니다. 두 값이 같은 경우엔 사용자 정보를 넘겨주고 다른 경우 토큰 탈취 가능성이 있으므로 토큰을 삭제합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import jwt from "jsonwebtoken";
import redisClient from "../config/redis.js";
const accessTokenSecret = process.env.ACCESS_TOKEN_SECRET;
const refreshTokenSecret = process.env.REFRESH_TOKEN_SECRET;
export default {
// AccessToken 생성 함수
accessSign: (userId) => {
try {
return jwt.sign({ id: userId }, accessTokenSecret, {
expiresIn: "2h", // 유효기간
algorithm: "HS256", // 암호화 알고리즘
});
} catch (err) {
throw err;
}
},
accessVerify: (accessToken) => {
try {
return jwt.verify(accessToken, accessTokenSecret);
} catch (err) {
throw err;
}
},
refreshSign: async (userId, exp) => {
try {
const refreshToken = jwt.sign(
{
id: userId,
exp: exp ? exp : Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 14,
// 현재시각 + 60초 * 60분 * 24시간 * 14일
},
refreshTokenSecret,
{
algorithm: "HS256",
}
);
if (await redisClient.get(userId)) await redisClient.del(userId);
await redisClient.set(userId, refreshToken);
return refreshToken;
} catch (err) {
throw err;
}
},
refreshVerify: async (refreshToken) => {
try {
const decode = jwt.verify(refreshToken, refreshTokenSecret);
const confirmRefresh = await redisClient.get(decode.id);
// 요청 시 보낸 토큰과 Redis에 저장된 토큰 비교 검증
if (refreshToken == confirmRefresh) return decode;
throw "RefreshToken 탈취 가능성 포착";
} catch (err) {
throw err;
}
},
};
auth_jwt.js 수정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import jwtUtil from "../utils/jwt_util.js";
import UserStorage from "../models/model_user.js";
const authJwt = async (req, res, next) => {
if (!req.cookies.accessToken && !req.cookies.refreshToken) {
// case 1 : Token이 없을 경우
return next();
}
try {
const accessToken = req.cookies.accessToken;
// AccessToken 검증
const accessDecode = await jwtUtil.accessVerify(accessToken);
// case 2 : AccessToken이 유효안 경우
req.user = await UserStorage.getUserInfo(accessDecode.id);
return next();
} catch (err) {
// AccessToken 만료 시, 초기화
res.clearCookie("accessToken");
const refreshToken = req.cookies.refreshToken;
try {
// RefreshToken 검증
const refreshDecode = await jwtUtil.refreshVerify(refreshToken);
try {
// case 3 : AccessToken은 만료, RefreshToken은 유효인 경우
const user = await UserStorage.getUserInfo(refreshDecode.id);
// 새로운 AccessToken 생성
let newAccessToken = await jwtUtil.accessSign(user.id);
// 새로운 RefreshToken 생성
let newRefreshToken = await jwtUtil.refreshSign(
user.id,
refreshDecode.exp
);
// AccessToken 재발급
res.cookie("accessToken", newAccessToken);
// RefreshToken 재발급
res.cookie("refreshToken", newRefreshToken);
req.user = user;
return next();
} catch (err) {
return next(err);
}
} catch (err) {
// case 4 : AccessToken 만료, RefreshToken 만료인 경우
console.log(err);
// RefreshToken 만료 시, 초기화
res.clearCookie("refreshToken");
return next();
}
}
};
export default authJwt;
정리
Redis를 활용해 RTR을 구현하므로써 해커가 RefreshToken을 탈취해도 지속적으로 활용하지 못하도록 막았으며 탈취 가능성이 있으면 토큰을 삭제하는 로직을 구현했습니다.
하지만 지금의 대응은 만약 실제 사용자가 서비스를 사용하지 않으면 서버가 알 수 없어 RefreshToken의 만료시간동안 해커가 이용할 수도 있습니다.
이는 Stateless한 JWT가 갖는 문제라고 생각되며 완벽하게 막는 것은 불가능해보입니다.
다음 포스팅은 처음부터 탈취를 당하지 않도록 막는 방법에 대해 알아보겠습니다.