JWT를 이용한 로그인 기능은 Access 토큰과 Refresh 토큰을 이용하여 구현에 성공했다.
그런데 이 JWT를 삭제할 수 있는 방식이 없는 것 같다...(로그아웃이 생각보다 어려워..) 그렇다면 JWT를 이용하여 로그아웃을 하려면 어떻게 해야 할까 고민을 하다가 토큰을 만료시키면 로그인이 안 되는거니까 토큰을 만료시켜야겠다는 생각을 했다.
그러면 가능한 시나리오들이 어떻게 될까? 구글링을 하면서 2가지 방법들을 찾아봤다
(내가 모르는 방법이 더 있을 수 있음)
클라이언트에 저장이 된 JWT 데이터를 제거하기
JWT 데이터는 클라이언트의 storage에 저장이 된다. 따라서 프론트엔드 단에서 storage에 있는 JWT를 clear 하면 된다. 하지만, 이 방법의 단점은 만약 유저가 토큰을 미리 카피를 했다면 계속해서 서버에 요청을 보낼 수 있다.
블랙리스트 생성
로그아웃하고 싶은 토큰들을 블랙리스트에 모은다. 그리고 블랙리스트에 토큰이 들어오면 해당 토큰을 무효화하는 작업을 진행하는 방법이다. JWT의 가장 큰 특징은 무상태성이고 클라이언트에 저장이 된다는 점이다. 그래서 데이터베이스를 사용할 필요가 전혀 없다.
signature가 일치하고 토큰이 만료되지 않았다는 가정하에 유저가 언제든지 request에 접근이 가능하다. 하지만 이러한 점 때문에 이 토큰을 없애는 것은 매우매우 어렵게 된다.
그렇다면 블랙리스트를 써야하는 이유가 뭘까 ?
로그아웃을 할 때 토큰을 무효화해야 하는 이유는 토큰이 authentication에 사용되기 때문일 것이다. JWT는 클라이언트 사이드에서 삭제가 되더라도 만료시간이 지나지 않았다면 서버에서 여전히 사용이 가능하다(1번 경우). 따라서 Access Token을 블랙리스트로 저장하여 만료시키는 기능이 필요하다.
Redis를 JWT에서 사용해야 하는 이유?
JWT를 사용하여 유저 인증을 구현할 때 내가 사용하는 방식은 Access Token과 Refresh Token 이다. 이 두 토큰은 각각 인증과 재발급에 사용된다. Refresh Token은 Access Token이 만료되면 다시 Access Token을 발급해주는 토큰으로 계속적으로 Access Token을 업데이트하여 어플리케이션의 보안 성능을 향상시켜준다. 나의 경우 Access Token의 만료시간은 30분, Refresh Token의 만료시간은 14일로 설정했다. 그렇다면 Refresh Token은 어딘가에 저장을 해둬야 할텐데 데이터베이스에 저장을 하면 문제점이 발생한다. Refresh Token에도 유효기간이 존재하기 때문에 RDBMS에 저장을 하면 배치를 이용하여 주기적으로 삭제를 해줘야 하는 번거로움이 생긴다. 그래서 적절한 방법을 찾던 중에 Redis(인 메모리 데이터 저장소)를 찾았다. 추가적으로 Redis를 JWT 로그아웃에도 사용할 수 있으니까 일석이조다.
두 줄 요약
그래서 어떻게 로그아웃 할건데?
클라이언트로부터 액세스 토큰과 리프레시 토큰을 둘 다 받아서 로그아웃 요청된 Access Token을 블랙리스트로 저장하는 기능이 추가된다. AccessToken을 Redis에 블랙리스트로 등록할 때 필요한 부분은 요청 들어온 AccessToken의 남은 유효시간이다. 로그아웃 성공된 회원의 AccessToken을 블랙리스트로 Redis에 등록할 때 토큰 값 자체를 키로 두고 value로 logout이라는 값을 준다.
이때 블랙리스트로 등록하는 액세스 토큰에 유효시간을 요청시 받은 액세스토큰의 남은 유효시간만큼 준다. 이렇게 되면 로그아웃된 액세스 토큰으로 요청이 들어왔을 때 해당 토큰의 유효성이 남아있는 동안 Redis에 해당 토큰 값이 key로 블랙리스트 등록이 되어 있기 때문에 로그인을 할 수 없다.
그리고 요청하는 AccessToken의 유효시간이 만료되었을 때 당연히 요청할 수 없고, 이 때 블랙리스트에 등록된 AccessToken 값도 자동으로 삭제된다.
그러면 내가 해야 할 일은?
1. redis 설정하기
2. 로그인 할 때 redis에 토큰 저장하기
3. 로그아웃 할 때 redis의 토큰 삭제하고 내가 로그아웃 하고 싶은 토큰을 만료시간 설정하여 블랙리스트에 추가
-> 여기서 블랙리스트에 등록한다는 것은 key를 Access Token value를 logout으로 등록한다는 것과 동일, 실질적인 블랙리스트는 존재하지 않는다^^. 그냥 편의상 그렇게 부르는 듯하다
레디스를 설치하기 (생략)
build.gradle에 해당 코드를 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
spring:
redis:
host: localhost
port: 6379
package com.example.concalendar.user.config;
@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisRepositoryConfig {
private final RedisProperties redisProperties;
// lettuce
// RedisConnectionFactory 인터페이스를 통해 LettuceConnectionFactory를 생성하여 반환한다.
// RedisProperties로 yaml에 저장한 host, post를 가지고 와서 연결한다.
@Bean
public RedisConnectionFactory redisConnectionFactory(){
return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
}
// setKeySerializer, setValueSerializer 설정으로 redis-cli를 통해 직접 데이터를 보는게 가능하다.
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 헤더에서 JWT 를 받아옵니다.
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
// log를 이용한 확인
log.info("[This is Verifying token!!]"); // 그냥 스트링 출력해주는 로
log.info(((HttpServletRequest) request).getRequestURL().toString());
// 유효한 토큰인지 확인합니다. -> validation 진행
if (token != null && jwtTokenProvider.validateToken(token)) {
// Redis에 해당 accessToken logout 여부를 확인
String isLogout = (String) redisTemplate.opsForValue().get(token);
// 로그아웃이 없는(되어 있지 않은) 경우 해당 토큰은 정상적으로 작동하기
if (ObjectUtils.isEmpty(isLogout)) {
// 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext 에 Authentication 객체를 저장합니다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
그 후에 로그인 service를 수정해주자.
jwtTokenProvider의 createToken(유저이메일, 유저역할) 메서드를 통해 AccessToken과 RefreshToken을 발급받는다
그리고 redisTemplate 빈(bean) 객체를 가져와서 opsForValue().set()메서드로 해당 토큰을 redis에 저장한다
UserService
@Transactional
public TokenDto login(UserDto userDto) {
// 로그인 시 Email이 일치하면 유저 정보 가져오기
User user = userRepository.findByUserEmail(userDto.getUserEmail())
.orElseThrow(()->new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
// 로그인 시 패스워드가 불일치하면 에러 발생
if (!passwordEncoder.matches(userDto.getPassword(), user.getPassword())){
throw new IllegalArgumentException("잘못된 비밀번호입니다.");
}
// AccessToken, Refresh Token 발급하기
TokenDto tokenDto = jwtTokenProvider.createToken(user.getUsername(), user.getRoles());
//redis에 RT:13@gmail.com(key) / 23jijiofj2io3hi32hiongiodsninioda(value) 형태로 리프레시 토큰 저장하기
redisTemplate.opsForValue().set("RT:"+user.getUserEmail(),tokenDto.getRefreshToken(),tokenDto.getRefreshTokenExpiresTime(), TimeUnit.MILLISECONDS);
return tokenDto;
}
이후에 UserService 클래스의 logout 메서드를 추가해준다. 매개변수는 accessToken과 refreshToken으로 구성된 TokenRequestDto 객체를 넣어준다.
먼저, 로그아웃하고 싶은 토큰이 유효한지 검증하고 유효하다면 AccessToken으로 Authentication -> User값을 가져온다
그리고 로그인 할 때 저장했던 redis refresh 토큰을 redisTemplate을 이용하여 삭제한다.
마지막으로 해당 Access 토큰을 다시 redis에 저장한다. 단, 저장할 때 value 값을 "logout"으로 설정하고 accessToken의 만료시간을 추가해서 넣어준다.
@Transactional
public void logout(TokenRequestDto tokenRequestDto){
// 로그아웃 하고 싶은 토큰이 유효한 지 먼저 검증하기
if (!jwtTokenProvider.validateToken(tokenRequestDto.getAccessToken())){
throw new IllegalArgumentException("로그아웃 : 유효하지 않은 토큰입니다.");
}
// Access Token에서 User email을 가져온다
Authentication authentication = jwtTokenProvider.getAuthentication(tokenRequestDto.getAccessToken());
// Redis에서 해당 User email로 저장된 Refresh Token 이 있는지 여부를 확인 후에 있을 경우 삭제를 한다.
if (redisTemplate.opsForValue().get("RT:"+authentication.getName())!=null){
// Refresh Token을 삭제
redisTemplate.delete("RT:"+authentication.getName());
}
// 해당 Access Token 유효시간을 가지고 와서 BlackList에 저장하기
Long expiration = jwtTokenProvider.getExpiration(tokenRequestDto.getAccessToken());
redisTemplate.opsForValue().set(tokenRequestDto.getAccessToken(),"logout",expiration,TimeUnit.MILLISECONDS);
}