Spring Security + JWT / Refresh Token을 통한 토큰 재발급 방식
spring security + jwt를 사용한 인증 방식을 구현할 때, 토큰 재발급은 어떤 방식으로 이루어지는 게 좋을지에 대해 생각해 보며 정리한 내용입니다.
* 토큰 재발급의 경우 다양한 방식으로 구현될 수 있으며, 해당 포스팅에서 구현된 방식은 여러 방식 중 하나라는 점 참고 부탁드립니다.
Refresh Token을 사용하는 이유
access token은 발급된 이후, 서버에 저장되지 않고 토큰 자체로 검정을 하며 사용자 권한을 인증한다는 stateless(무상태)라는 특징이 있는데요.
때문에 access token이 탈취되면 토큰이 만료되기 전까지 토큰을 가진 사람은 누구나 권한 인증이 가능해진다는 문제점이 발생할 수 있으며, 이러한 문자점을 보완하기 위해서 액세스 토큰의 만료 기간을 짧게 주는 방식이 적용되고 있습니다.
설정하기에 따라 다르지만 일반적으로 access token의 유효 기간은 30분에서 1시간 정도로 발급되며, 유효 기간이 짧은 만큼 사용자의 측면에서는 토큰이 만료될 때마다 다시 로그인을 하여 액세스 토큰을 발급받아야 하는 불편함이 생기게 됩니다.
이러한 이유 때문에 refresh token이 사용되게 되었는데요.
refresh token은 access token에 비해 훨씬 더 긴 유효 기간으로 발급되며, 리프레시 토큰의 경우 접근에 대한 권한을 가진 것이 아니라 액세스 토큰 재발급에만 사용된다는 특징이 있습니다.
/*
정리하자면, access token의 안전성을 확보하기 위해 유효 기간을 짧게 잡았고, 이때 생기는 사용자의 편의성 감소로 보완하기 위해 refresh token이 사용되게 되었습니다.
액세스 토큰과 리프레시 토큰은 보안성과 성능 그리고 사용 편의성 등을 적절하게 타협한 결과로 볼 수 있습니다.
*/
Refresh Token의 문제점과 추가적인 보안 방안
하지만 리프레시 토큰에도 문제점은 있는데요. stateless라는 특징으로 인해 액세스 토큰과 마찬가지로 탈취 당할 위험이 있으며, 탈취 되었을 때 refresh token을 통해 access token을 재발급 받을 수 있게 됩니다.
때문에 최초 로그인 시, 로그인 요청 ip를 저장하고 재발급 요청이 왔을 때, 요청이 온 ip와 저장된 ip를 비교하여 다른 경우 토큰을 재발급하지 않거나 알림을 보내는 등의 조치를 취할 수 있는데요.
아래 예시 내용은 로그인 시 redis를 사용하여 리프레시 토큰을 저장하고, 이때 요청이 들어온 ip 주소도 함께 저장하여 추후 토큰 재발급 요청이 왔을 때 ip를 비교하는 방식을 사용하였습니다.
구현 코드
해당 내용은 security + jwt 로그인에 대한 기본적인 구현 이후의 내용으로 로그인 방식에 대한 전반적인 이해가 필요한 내용입니다.
security + jwt 로그인 방식에 대한 자세한 내용이 필요하시다면 아래 포스팅을 먼저 참고하시면 좋을 것 같습니다.
2021.09.22 - [Programming/Spring Boot] - spring security + JWT 로그인 기능 파헤치기 - 1
/*
전체 코드 및 부분적으로 사용된 method를 모두 설명하기에는 내용이 너무 길어지기 때문에 전체적인 로직을 위주로 설명되었으며, 부족한 부분은 포스팅 맨 하단 참고 자료 및 github의 소스 코드를 함께 보시면 좋을 것 같습니다.
*/
1. RefreshToken Class
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
@RedisHash(value = "refresh", timeToLive = 604800)
public class RefreshToken {
private String id;
private String ip;
private Collection<? extends GrantedAuthority> authorities;
@Indexed
private String refreshToken;
}
redis에 저장될 RefreshToken 클래스입니다.
저장되는 클래스의 내용을 살펴보면, access token 재발급에 사용될 id 및 authorities 정보 및 refresh token에 대한 정보가 저장되고 있으며, ip 정보도 함께 저장되는 것을 볼 수 있는데요.
최초 로그인 시 refresh token과 함께 ip 정보를 저장하여 토큰 재발급 요청 시, 최초 로그인 된 ip 주소와 비교를 통해 재발급 여부를 결정하는 방식을 적용하기 위함입니다.
(해당 객체가 redis에 저장되는 방식은 RedisRepository 방식을 활용하였으며, 해당 방식에 대한 자세한 내용 역시 아래 포스팅을 참고해 주시면 좋을 것 같습니다.)
2. Service 단의 signin method
public ResponseEntity<?> signin(HttpServletRequest request, UserRequestDto.SignIn signIn) {
// 1, 2. email, pw 기반 Authentication 객체 생성 및 검증 부분 생략
...
// 3. 인증 정보를 기반으로 JWT Token 생성
UserResponseDto.TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication);
// 4. Redis에 RefreshToken 저장
refreshTokenRedisRepository.save(RefreshToken.builder()
.id(authentication.getName())
.ip(Helper.getClientIp(request))
.authorities(authentication.getAuthorities())
.refreshToken(tokenInfo.getRefreshToken())
.build());
return response.success(tokenInfo);
}
이어서 로그인이 될 때 service 단에서 동작하는 signin() method입니다.
email 또는 id와 password를 기반으로 한 검증 방식 이후, generateToken() 메서드를 통해 access token과 refresh token을 생성하는데요. 이때 중요한 부분은 4번 과정을 통해 redis에 위에서 본 refreshToken 객체를 저장하는 것입니다.
(Helper 클래스의 getClientIp() 메서드는 직접 구현한 메서드로, 해당 내용 역시 포스팅 하단 참고 자료를 통해 확인할 수 있으며, 로직과는 직접적인 연관이 없다고 생각된 부분이라 따로 뺀 점 양해 부탁드립니다.)
3. JwtAuthenticationFilter
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
//1. Request Header 에서 JWT Token 추출
String token = jwtTokenProvider.resolveToken((HttpServletRequest) servletRequest);
//2. validateToken 메서드로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
if (!((HttpServletRequest) servletRequest).getRequestURI().equals("/v1/user/reissue")) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(servletRequest, servletResponse);
}
jwt 토큰 검증을 위해 적용된 custom filter입니다.
security 설정을 적용하는 WebSecurityConfigure 클래스의 SecurityFilterChain 부분에서 토큰 재발급 요청 path에 대한 권한을 permitAll()으로 풀어주더라도 filter는 동작을 하는데요.
만약 토큰 재발급 요청 시 refresh token을 요청 headers에 Authorization 키로 받는다면, 해당 필터에서 token이 존재하기 때문에 2번 부분의 로직을 타게 되며, getAuthentication 메서드가 동작할 때 내부적으로 권한에 대한 정보가 없기 때문에 exception이 발생하게 되어있습니다.
때문에 그 부분을 처리하기 위해 토큰 재발급 요청 path인 경우 getAuthentication 및 setAuthentication이 동작하지 않도록 하였습니다. 하지만 이 과정에서 validateToken() 메서드를 통한 토큰 유효성 검사는 실행되기 때문에 토큰이 유효하지 않은 경우를 걸러낼 수 있습니다.
4. Service 단의 토큰 재발급 reissue method
public ResponseEntity<?> reissue(HttpServletRequest request) {
//1. Request Header 에서 JWT Token 추출
String token = jwtTokenProvider.resolveToken(request);
//2. validateToken 메서드로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
//3. 저장된 refresh token 찾기
RefreshToken refreshToken = refreshTokenRedisRepository.findByRefreshToken(token);
if (refreshToken != null) {
//4. 최초 로그인한 ip 와 같은지 확인 (처리 방식에 따라 재발급을 하지 않거나 메일 등의 알림을 주는 방법이 있음)
String currentIpAddress = Helper.getClientIp(request);
if (refreshToken.getIp().equals(currentIpAddress)) {
// 5. Redis 에 저장된 RefreshToken 정보를 기반으로 JWT Token 생성
UserResponseDto.TokenInfo tokenInfo = jwtTokenProvider.generateToken(refreshToken.getId(), refreshToken.getAuthorities());
// 6. Redis RefreshToken update
refreshTokenRedisRepository.save(RefreshToken.builder()
.id(refreshToken.getId())
.ip(currentIpAddress)
.authorities(refreshToken.getAuthorities())
.refreshToken(tokenInfo.getRefreshToken())
.build());
return response.success(tokenInfo);
}
}
}
return response.fail("토큰 갱신에 실패했습니다.");
}
이어서 토큰 재발급이 실행되는 부분입니다.
findByRefreshToken() 메서드를 통해 redis에 저장된 refreshToken 객체를 찾고, 해당 객체에 저장되는 ip를 비교하는 로직을 확인할 수 있는데요.
여기서는 ip가 같은 경우에만 재발급이 진행되도록 하였지만, 경우에 따라서는 알림 메일을 보내는 등의 방법이 적용될 수도 있습니다.
이후 내용은 저장된 정보를 기반으로 jwt token 생성, 그리고 새로 발급된 refresh token을 redis에 다시 갱신하는 코드입니다.
(지금 살펴보니 1, 2. 부분은 jwt filter와 중복되는 부분이기 때문에 필터에서 검증 이후 다른 key로 refresh token 값을 전달해 주고 서비스 단에서 받는 방법도 적용해 볼 수 있을 것 같습니다.)
적용해 볼 수 있는 다른 토큰 재발급 방식
재발급 요청 시 access token과 refresh token 둘 다 받는 방법도 적용해 볼 수 있을 것 같은데요.
액세스 토큰, 리프레시 토큰을 둘 다 받아 access token의 유효시간이 지났더라도 해당 토큰을 파싱 하여 나오는 사용자 정보를 통해 redis에 저장된 refresh token과 요청 시 들어온 refresh token이 일치하는지 확인하는 방법도 있을 것 같습니다.
이 방식의 경우 리프레시 토큰은 단순하게 값을 비교하기 위해서만 사용되기 때문에 굳이 비용이 드는 jwt로 만들지 않고, 단순하게 일정한 길이를 가진 hex string으로 만들어 redis 저장 시 유효 기간(ttl)을 부여하는 방법을 적용할 수 있을 것 같습니다.
< Redis Repository를 통한 Redis 사용 방법 >
2023.03.07 - [Programming/Web] - (spring boot) RedisRepository 사용하는 방법, @RedisHash
< 요청 클라이언트의 ip를 가져오는 방법 >
2022.06.07 - [Programming/Java] - Java 클라이언트 요청 IP 가져오는 방법(HttpServletRequest)
< 참고 자료 >
https://okky.kr/articles/1007579
https://betterprogramming.pub/should-we-store-tokens-in-db-af30212b7f22
< github >
https://github.com/JianChoi-Kor/ttotw/tree/master/client-module/src/main/java/com/project/ttotw
'Programming > Spring Boot' 카테고리의 다른 글
Spring Boot OAuth2-Client 내부적인 동작 과정 (1) | 2023.03.25 |
---|---|
Spring Event, @TransactionalEventListener 사용하기 (6) | 2023.03.16 |
Spring Boot 초기 데이터 설정 방법 정리(data.sql, schema.sql) (2) | 2023.01.26 |
@Scheduled 동작 시 timezone 설정 관련하여 발생한 이슈 정리 (0) | 2023.01.06 |
@Transactional 상태에서 Exception이 발생했을 때 Rollback 동작 과정 (0) | 2022.12.27 |