로그인 시 JWT refresh token 추가

joona95·2024년 8월 20일

문제 상황

기존에는 로그인 시 JWT 를 하나만 생성해서 API 요청 시 인증을 위해 사용하고 있었다.

토큰이 만료되는 경우 재로그인이 필요하므로 만료 기간을 짧게 잡을 수는 없었다.

이 토큰이 탈취되는 경우에는 아무래도 보안적으로 문제가 될 수 있었다.

기존에는 기능적으로 동작하는 게 우선이었기 때문에 간단하게 작업을 했지만 보안을 조금 더 강화하기 위해서 access token, refresh token 을 나누어서 작업해보는 건 어떨까 싶었다.

API 요청 시 전달하는 access token은 만료 기간을 짧게 하여 탈취되더라도 큰 문제가 없도록 하고, refresh token 만료 기간은 길게한 후 access token 만료 시 refresh token 으로 재발급 받을 수 있도록 처리한다.

해결 방안

1. 로그인 시 access token과 함께 refresh token 을 발급하도록 함

@Service
public class JwtUtil {

	...

    private final static String TOKEN_KEY = "userId";
    private final static String REFRESH_TOKEN_KEY_PREFIX = "refresh_token_user_id_";
    @Value("${jwt.secret}")
    private String secretKey;
    @Value("${jwt.access-token-validity-in-ms}")
    private long accessTokenValidMillisecond;
    @Value("${jwt.refresh-token-validity-in-ms}")
    private long refreshTokenValidMillisecond;

	...

    public String createAccessToken(Long userId) {

        Date now = new Date();
        Key key = new SecretKeySpec(Base64.getDecoder().decode(this.secretKey), SignatureAlgorithm.HS256.getJcaName());

        return Jwts.builder()
                .claim(TOKEN_KEY, userId)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + accessTokenValidMillisecond))
                .signWith(key)
                .compact();
    }

    public String createRefreshToken(Long userId) {

        Date now = new Date();
        Key key = new SecretKeySpec(Base64.getDecoder().decode(this.secretKey), SignatureAlgorithm.HS256.getJcaName());

        String token = Jwts.builder()
                .claim(TOKEN_KEY, userId)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + refreshTokenValidMillisecond))
                .signWith(key)
                .compact();

        redisTemplate.opsForValue().set(REFRESH_TOKEN_KEY_PREFIX + userId, token, Duration.ofMillis(refreshTokenValidMillisecond));

        return token;
    }
    
    ...
}
String accessToken = jwtUtil.createToken(user.getUserId());
String refreshToken = jwtUtil.createRefreshToken(user.getUserId());
  • access token 은 만료기간을 1시간 정도로 짧게 설정한다.
  • refresh token 은 일주일 ~ 1달 정도로 길게 설정한다.

2. refresh token 저장 시 Redis 활용

@Service
public class JwtUtil {
	
    ...

    private final RedisTemplate<String, String> redisTemplate;
    private final static String TOKEN_KEY = "userId";
    private final static String REFRESH_TOKEN_KEY_PREFIX = "refresh_token_user_id_";
    @Value("${jwt.secret}")
    private String secretKey;
    @Value("${jwt.access-token-validity-in-ms}")
    private long accessTokenValidMillisecond;
    @Value("${jwt.refresh-token-validity-in-ms}")
    private long refreshTokenValidMillisecond;

    public JwtUtil(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    
    ...

    public String createRefreshToken(Long userId) {

        Date now = new Date();
        Key key = new SecretKeySpec(Base64.getDecoder().decode(this.secretKey), SignatureAlgorithm.HS256.getJcaName());

        String token = Jwts.builder()
                .claim(TOKEN_KEY, userId)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + refreshTokenValidMillisecond))
                .signWith(key)
                .compact();

        redisTemplate.opsForValue().set(REFRESH_TOKEN_KEY_PREFIX + userId, token, Duration.ofMillis(refreshTokenValidMillisecond));

        return token;
    }

    public long getUserId(String token) {

        return Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .get(TOKEN_KEY, Long.class);
    }

    public boolean isValidRefreshToken(String refreshToken) {

        if (isValidToken(refreshToken)) {

            long userId = getUserId(refreshToken);
            String foundRefreshToken = redisTemplate.opsForValue().get(REFRESH_TOKEN_KEY_PREFIX + userId);

            return refreshToken.equals(foundRefreshToken);
        }

        return false;
    }

    private boolean isValidToken(String token) {

        try {
            logger.debug(this.secretKey);
            Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(this.secretKey).build().parseClaimsJws(token);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (SecurityException | MalformedJwtException | IllegalArgumentException | SignatureException exception) {
            logger.info("잘못된 Jwt 토큰입니다");
        } catch (ExpiredJwtException exception) {
            logger.info("만료된 Jwt 토큰입니다");
        } catch (UnsupportedJwtException exception) {
            logger.info("지원하지 않는 Jwt 토큰입니다");
        }

        return false;
    }

    @Transactional
    public void removeRefreshToken(Long userId) {

        redisTemplate.delete(REFRESH_TOKEN_KEY_PREFIX + userId);
    }
    
    ...
}
  • JWT 는 stateless 한 HTTP 특징으로 인해 한 번 발급되면 토큰의 상태를 관리할 수 없어서 이를 위해 access token 과 refresh token 을 분리한다.
  • refresh token 은 access token이 만료되었을 때 재발급을 위해 사용하는 토큰이다.
  • refresh token 은 데이터베이스에 정보를 저장하여 토큰을 관리할 수 있게 한다.
  • 데이터베이스를 Redis 로 사용하게 된 이유
    • Redis 는 유효기간 설정이 가능하여 특정 유효시간 후에 데이터베이스에서 자동으로 제거할 수 있다.
    • Redis 는 Key-Value 형태의 데이터베이스로 조회 시 빠른 성능을 가진다.
    • 추후에 서버가 늘어나더라도 토큰 값을 글로벌하게 사용할 수 있다.

3. access token 만료 시 refresh token 으로 토큰 재발급

@RestController
@RequestMapping("/users")
public class UserController {
	
    ...

	@PostMapping("/token-reissue")
    public UserTokenRefreshResponse reissueToken(@RequestBody UserTokenRefreshRequest request) {

        return userService.reissueToken(request);
    }
}
@Service
public class UserService {

	...
    
    @Transactional(readOnly = true)
    public UserTokenRefreshResponse reissueToken(UserTokenRefreshRequest request) {

        if (!jwtUtil.isValidRefreshToken(request.getRefreshToken())) {
            throw new UserTokenNotExistException();
        }

        return UserTokenRefreshResponse.builder()
                .userId(request.getUserId())
                .accessToken(jwtUtil.createAccessToken(request.getUserId()))
                .refreshToken(jwtUtil.createRefreshToken(request.getUserId()))
                .build();
    }

}
  • refresh token 재발급 시 데이터베이스에 해당 값을 가지고 있는지 확인하고 토큰 재발급한다.
  • access token 과 함께 refresh token 도 재발급하여 refresh token 이 1회용으로 사용되도록 하여 탈취되더라도 유저가 먼저 토큰 갱신하면 사용할 수 없게 한다.

4. Authorization Bearer 인증 사용

@Service
public class JwtUtil {

	...

    private final static String TOKEN_HEADER = "Authorization";
    
	...
    
    public String resolveAccessToken(HttpServletRequest request) {

        String header = request.getHeader(TOKEN_HEADER);

        return header != null ? header.substring(7) : null;
    }

}
  • 기존엔 헤더로 'X-ACCESS-TOKEN' 값을 사용했다면 표준으로 사용되는 'Authorization' Bearer 인증을 사용하고자 한다.
  • 표준을 사용하는 것이 다른 사람이 확인할 때 가독성 측면에서도 좋다고 생각한다.

5. JWT 블랙리스트를 DB 에서 Redis 활용하도록 수정

@Service
public class JwtUtil {

	...

    private final RedisTemplate<String, String> redisTemplate;
    private final static String ACCESS_TOKEN_BLACKLIST_VALUE = "access_token_blacklist";

    public JwtUtil(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

	...

    public long getUserId(String token) {

        return Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .get(TOKEN_KEY, Long.class);
    }

    @Transactional(readOnly = true)
    public boolean isValidAccessToken(String accessToken) {

        if (StringUtils.hasText(redisTemplate.opsForValue().get(accessToken))) {
            return false;
        }

        return isValidToken(accessToken);
    }
    
    private boolean isValidToken(String token) {

        try {
            logger.debug(this.secretKey);
            Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(this.secretKey).build().parseClaimsJws(token);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (SecurityException | MalformedJwtException | IllegalArgumentException | SignatureException exception) {
            logger.info("잘못된 Jwt 토큰입니다");
        } catch (ExpiredJwtException exception) {
            logger.info("만료된 Jwt 토큰입니다");
        } catch (UnsupportedJwtException exception) {
            logger.info("지원하지 않는 Jwt 토큰입니다");
        }

        return false;
    }

    @Transactional
    public void setAccessTokenBlacklist(String accessToken) {

        redisTemplate.opsForValue().set(accessToken, ACCESS_TOKEN_BLACKLIST_VALUE, Duration.ofMillis(accessTokenValidMillisecond));
    }
}
  • 로그아웃이나 회원탈퇴를 했을 때 이미 발급된 access token이 만료될 때까지 관리할 수 없는 문제가 발생할 수 있다.
  • 위 문제를 방지하기 위해 JWT 블랙리스트에 access token 을 등록하여 API 요청 시 블랙리스트에 등록되어 있는지 여부를 확인한다.
  • JWT 블랙리스트를 저장하는 데이터베이스로 Redis를 활용하도록 한다.
    • 기존에는 RDB에 저장하고 있었는데 요청이 들어올 때마다 조회가 일어나는 문제가 있었다.
    • Key-Value 값으로 저장 가능하여 읽기 성능이 좋다.

0개의 댓글