Home 로그인 페이지 만들기 - 10 (Redis)
Post
X

로그인 페이지 만들기 - 10 (Redis)

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에 저장합니다.


구현할 로직

access-refresh-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가 갖는 문제라고 생각되며 완벽하게 막는 것은 불가능해보입니다.

다음 포스팅은 처음부터 탈취를 당하지 않도록 막는 방법에 대해 알아보겠습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.