늦은 프로그래밍 이야기
230124 TIL (Refresh Token, Redis) 본문
팀프로젝트
Refresh 토큰과 Redis 적용기
Refresh Token
저번 프로젝트에서도 리프레쉬 토큰을 구현 했었는데 다른 팀원이 맡아 작업을 하여 리프레쉬 토큰에 대해 잘 알지 못하였다. 혼자 개인과제에 적용해 보려고 했었으나 시간 관계상 구현해 보지 못했는데 이번 프로젝트에서 로그아웃 기능을 구현해야 해서 리프레쉬 토큰을 Redis에 저장하여 관리하는 방법을 구현해 보려고 한다.
리프레쉬 토큰에 대해 지식이 많이 부족했기에 리프레쉬 토큰을 적용하는 방법은 물론 왜 적용 하는지도 잘 알지 못해서 일단 왜 적용하는지 부터 알아봐야 했다. 찾아본 결과 개략적으로 엑세스 토큰으로 인가를 진행하였는데 엑세스 토큰의 만료 기간이 길면 탈취 당해서 탈취한 자가 엑세스 토큰의 만료 기간까지 마음대로 접근할 수 있어서 엑세스 토큰의 만료 기간을 짧게하고 리프레쉬 토큰의 만료 기간을 길게 하여 안전한 장소에 저장하고 엑세스 토큰이 만료되면 리프레쉬 토큰을 이용하여 엑세스 토큰을 재발급하는 방식으로 사용하는 것 같았다.
Redis를 적용하기 전에 리프레쉬 토큰을 엑세스 토큰과 같이 Response Header에 받아 각각의 요청에 Request Header에 넣어 주고 엑세스 토큰의 만료기간을 짧게 설정하여 엑세스 토큰이 만료되면 Request Header에 있는 리프레쉬 토큰을 사용하여 엑세스 토큰 재발급이 잘 되는지 확인해 보았다.
기존에 있던 JwtUtil 클래스에 리프레쉬 토큰의 헤더와 리프레쉬 토큰의 만료기간, 리프레쉬 토큰을 발급하는 메소드를 추가 해주었다.
public static final String REFRESH_HEADER = "Refresh";
private static final long REFRESH_TOKEN_TIME = 7 * 24 * 60 * 60 * 1000L;
public String createRefreshToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username)
.claim(AUTHORIZATION_KEY, role)
.setExpiration(new Date(date.getTime() + REFRESH_TOKEN_TIME))
.setIssuedAt(date)
.signWith(key, signatureAlgorithm)
.compact();
}
기존에 사용하던 validateToken 메소드를 boolean 타입에서 JwtEnum 타입으로 만들고 Enum 값 ACCESS, EXPIRED, DENIED로 구분하여 엑세스 토큰이 EXPIRED 되었을 때 리프레쉬 토큰의 정보를 가져와서 엑세스 토큰을 다시 발급하는 방법으로 JwtAuthFilter에 구현하였다.
protected void doFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainfilterChain) throws ServletException, IOException {
String token = jwtUtil.resolveToken(request, JwtUtil.AUTHORIZATION_HEADER);
if (token != null) {
if (jwtUtil.validateToken(token) == JwtEnum.DENIED){
jwtExceptionHandler(response, "Token Error", HttpStatus.UNAUTHORIZED.value());
return;
// Access Token 만료
} else if (jwtUtil.validateToken(token) == JwtEnum.EXPIRED) {
String refresh = jwtUtil.resolveToken(request, JwtUtil.REFRESH_HEADER);
if (jwtUtil.validateToken(refresh) == JwtEnum.ACCESS) {
token = jwtUtil.reissueAccessToken(refresh);
response.addHeader(JwtUtil.AUTHORIZATION_HEADER, token);
}
}
Claimsinfo = jwtUtil.getUserInfoFromToken(token);
setAuthentication(info.getSubject());
}
filterChain.doFilter(request, response);
}
로그인 할 때 엑세스 토큰과 리프레쉬 토큰을 같이 발급하여 일단은 테스트를 위해 헤더로 받아서 엑세스 토큰이 만료되면 리프레쉬 토큰을 사용하여 해당 사용자의 정보를 받아 엑세스 토큰을 필터에서 재발급 해주었다. 테스트를 해보니 정상적으로 발행이 되고 엑세스 토큰도 정상적으로 재발급 되는 것을 확인하였다.
리프레쉬 토큰을 검색하여 여러가지 방법을 찾아 보았지만, 필터에서 토큰을 재발급 해주는 것이 가장 좋은 방법 같다는 생각이 들어서 기존의 코드를 리팩토링 하며 리프레쉬 토큰을 발행하는 코드를 추가하였다.
Redis
리프레쉬 토큰을 헤더에 반환하는 것보다 DB에 저장하거나 Redis에 저장하여 리프레쉬 토큰이 필요할 때 가져와서 사용하는 것이 안전한 방법이라는 것을 들었고, 다수의 다른 팀들이 Redis를 구현한 것을 보고 새로운 기능을 사용해보고 싶은 마음에 Redis에 리프레쉬 토큰을 저장하는 방식을 사용하였다.
Redis를 구현하는 방식은 구글링을 통해 Config와 RedisDao 클래스를 작성하여 구현하였고, 구글링을 통해 Redis 설치, Redis 사용방법, 명령어 등을 검색해서 사용해 보았다.
@Component
public class RedisDao {
private final RedisTemplate<String, Object> redisTemplate;
public RedisDao(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void setValues(String key, Object data) {
ValueOperations<String, Object> values = redisTemplate.opsForValue();
values.set(key, data);
}
public void setValues(String key, Object data, Duration duration) {
ValueOperations<String, Object> values = redisTemplate.opsForValue();
values.set(key, data, duration);
}
public Object getValues(String key) {
ValueOperations<String, Object> values = redisTemplate.opsForValue();
return values.get(key);
}
public void deleteValues(String key) {
redisTemplate.delete(key);
}
}
setValue 메소드를 사용해 key 값에 userId를 넣어주고 value에 리프레쉬 토큰을 넣어주었고, getValue 메소드로 userId를 이용해 해당 사용자의 리프레쉬 토큰을 가져오고, deleteValue 메소드를 사용하여 해당 유저가 로그아웃 하면 리프레쉬 토큰을 삭제해주는 메소드들을 구현하였다.
Refresh Token을 Response Header로 받는 방법 → Redis에 저장하고 불러오는 방법으로 변경
리프레쉬 토큰을 헤더로 받아서 엑세스 토큰의 재발급을 하던 방식을 Redis에 저장하고 저장되어 있는 리프레쉬 토큰을 불러와서 엑세스 토큰을 재발급 하는 방식으로 변경하였다.
@Override
protected void doFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainfilterChain) throws ServletException, IOException {
String token = jwtUtil.resolveToken(request, JwtUtil.AUTHORIZATION_HEADER);
if (token != null) {
if (jwtUtil.validateToken(token) == JwtEnum.DENIED){
jwtExceptionHandler(response, "Token Error", HttpStatus.UNAUTHORIZED.value());
return;
// Access Token 만료
} else if (jwtUtil.validateToken(token) == JwtEnum.EXPIRED) {
// String refresh = jwtUtil.resolveToken(request, JwtUtil.REFRESH_HEADER);
String refresh = jwtUtil.getRefreshTokenFromRedis(token);
if (jwtUtil.validateToken(refresh) == JwtEnum.ACCESS) {
token = jwtUtil.reissueAccessToken(refresh);
response.addHeader(JwtUtil.AUTHORIZATION_HEADER, token);
}
}
Claimsinfo = jwtUtil.getUserInfoFromToken(token);
setAuthentication(info.getSubject());
}
filterChain.doFilter(request, response);
}
엑세스 토큰이 만료 되었을 경우 헤더에 있는 리프레쉬 토큰을 가져오는 코드를 만료된 엑세스 토큰을 사용하여 userId를 뽑아내서 key로 사용하여 Redis에 있는 리프레쉬 토큰을 가져오는 메소드로 바꿔 주었고, 간단하게 해결이 될 줄 알았다. 하지만 원래 작성한 코드에서는 작동을 하던 엑세스 토큰이 재발급 되는 기능이 제대로 작동하지 않았다.
리프레쉬 토큰을 가져오지 못하고 만료된 엑세스 토큰의 ExpiredJwtException 예외가 발생하는 문제
뭐가 문제일지 고민하다가 헤더에서 가져오는 것보다 Redis에서 리프레쉬 토큰을 가져오는 것이 느려서 리프레쉬 토큰을 가져오기 전에 다른 작업들이 진행이 되어서 엑세스 토큰을 재발급 해주는 조건문 안으로 들어가지 못해서 안되는 것인지도 고려해 보았는데, 결국 문제는 getRefreshTokenFromRedis의 메소드에 getUserInfoFromToken 메소드에서 만료된 토큰을 parsing 하는 과정에서 ExpiredJwtException이 발생하며 코드가 진행되지 않았던 문제였다.
혼자서는 도저히 해결할 수 없을 것 같았고 해결해도 오랜 시간이 소요될 것 같아서 연휴인데도 불구하고 공부하고 있는 동료 수강생들에게 도움을 요청하였다.
동료 수강생들이 제시한 해결책으로는 key를 userId로 사용하면서 만료된 토큰을 통하지 않고 어떻게 필터에서 userId를 가져와서 key 값으로 리프레쉬 토큰을 가져올 것인지였고, 차선책으로는 key 값을 다른 값으로 바꿔줘서 만료된 토큰을 parsing 하지 않고도 key를 사용하여 리프레쉬 토큰을 가져오는 방법이었다.
- Redis의 key 값으로 엑세스 토큰의 뒤 8자리를 사용하는 방법
엑세스 토큰의 뒤 8자리를 redis의 key로 저장하여 만료된 엑세스 토큰을 parsing 하지 않고 key로써 리프레쉬 토큰을 불러오는 방법으로 진행해 보았다.
String accessToken = jwtUtil.createToken(userInfoDto.getUsername(), userInfoDto.getRole());
redisDao.setValues(accessToken.substring(accessToken.length() - 8), refreshToken, Duration.ofMinutes(10));
로그인을 할 때에 substring을 사용하여 엑세스 토큰의 뒤 8자리를 잘라내어 key 값으로 저장하는 방법을 사용하였다. 문제없이 잘 되었지만, 엑세스 토큰을 재발행할 때마다 key 값을 바꿔주는 번거로움이 있었다. 근본적인 해결 방법이 아니고 key 값을 userId로 저장하는 것이 맞는 것 같아서 다음 방법을 모색해 보았다.
- 만료된 엑세스 토큰에서 강제로 UserId를 추출하는 방법
기존과 같이 Redis의 key 값으로써 UserId를 사용하고 만료된 엑세스 토큰에서 강제로 UserId를 추출하여 해당 UserId를 사용하여 리프레쉬 토큰을 가져오는 방법을 시도해 보았다.
public String getUserIdFromExpiredToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
} catch (ExpiredJwtException e) {
return e.getClaims().getSubject();
}
return null;
}
만료된 토큰을 parsing하여 ExpiredJwtException이 발생하면 해당 예외를 캐치하여 getClaims 메소드와 getSubject 메소드를 사용하여 예외 문구에서 강제로 UserId를 추출하는 메소드를 작성하였다.
'내일배움캠프 > TIL, WIL' 카테고리의 다른 글
| 230126 TIL (알고리즘, 테스트코드) (0) | 2023.01.26 |
|---|---|
| 230125 TIL (KPT회고, 알고리즘) (0) | 2023.01.25 |
| 12주차 WIL (0) | 2023.01.23 |
| 230120 TIL (팀프로젝트) (0) | 2023.01.22 |
| 230119 TIL (팀프로젝트) (0) | 2023.01.19 |