JWT 적용한 로그인은 구현 했는데 말입니다 ... 로그아웃은 어떻게 하지?
JWT를 적용한 로그인 기능은 Access Token(AT), Refresh Token(RT)를 이용하여 구현해냈다.
참고: https://un-lazy-midnight.tistory.com/159
그런데 로그아웃은.. 어떻게 하지?
클라이언트에 넘긴 토큰을 어떻게 삭제해야 하는지 고민이 되었다.
1. 클라이언트에서 토큰을 삭제
프론트엔드 단에서 storage에 있는 토큰을 삭제하면 된다. 하지만, 만약 해당 토큰을 미리 복사해두거나 탈취해서 요청을 시도한다면 정상적인 로그아웃이라고 볼 수 없으면서 보안상으로도 좋지 않다. 보안을 위해 세션을 통한 로그인이 아닌 JWT를 적용했는데... 이 방법은 아닌 것 같다!
2. 블랙리스트 생성
로그아웃하고 싶은 토큰들을 '블랙리스트'에 모으는 방법이다. 일명 '블랙리스트'에 토큰이 들어오면 해당 토큰을 무효화 한다. JWT의 가장 큰 특징은 무상태성이고 클라이언트에 저장이 된다는 점이다. 그래서 데이터베이스를 사용할 필요가 전혀 없다. 토큰이 만료되지 않았다는 가정하에 유저가 언제든지 request에 접근이 가능하다. 하지만 이러한 점 때문에 이 토큰을 삭제하는 것이 어렵다.
블랙리스트를 사용하는 이유는?
로그아웃을 할 때 토큰을 무효화해야 하는 이유는 토큰이 authentication에 사용되기 때문이다. JWT는 클라이언트 단에서 삭제가 되더라도 유효시간이 만료되지 않았다면 서버에서 여전히 사용이 가능하다. 따라서 Access Token을 블랙리스트로 저장하여 만료시키는 기능이 필요하다.
Redis를 JWT에서 사용해야 하는 이유?
앞서 JWT를 사용하여 사용자 인증을 Access Token(AT), Refresh Token(RT)을 통해 구현했다. 이 두 토큰은 각각 인증과 재발급에 사용된다. RT는 AT이 만료되면 다시 AT를 발급해주는 토큰으로 계속적으로 AT를 업데이트하여 서비스의 보안 성능을 향상시켜준다.
현재 프로젝트에서는 Access Token의 유효시간은 30분, Refresh Token의 유효시간은 7일로 설정했다. 그렇다면 Refresh Token를 7일 동안 어딘가에 저장을 해둬야 할텐데 기존 RDB(나의 경우 Mysql)에 저장을 하면 문제점이 발생한다. 유효한 시간이 존재하기 때문에 RDBMS에 저장을 하면 배치를 이용하여 주기적으로 삭제를 해줘야 하는 번거로움이 생긴다.
그래서 Redis(인 메모리 데이터 저장소)가 적합하다고 느꼈다. key-value 형태로 저장을 할 수 있고, 유효시간도 설정할 수 있다. 무엇보다 주기적으로 삭제해줘도 괜찮고, 만약 데이터가 날아간다고 해도 기껏해야 사용자가 로그아웃 되는 정도의 사이드 이펙트가 발생하여 안전한 편이라고 생각했다.
그래서 로그아웃은 어떻게 하나요?
로그아웃 성공한 회원의 Access Token을 Redis에 등록할 때 토큰 값을 key로 두고, value로 logout이라는 값을 부여한다. 이때 Access Token의 유효시간을 요청시 받은 Access Token의 남은 유효시간만큼 설정 한다. 이렇게 되면 로그아웃 된 Access Token으로 요청이 들어왔을 때 해당 토큰의 유효시간이 남아있는 동안 Redis에 등록이 되어 있기 때문에 제대로 된 요청을 할 수 없다.
이제부터 구현해야 할 것들을 간단히 세 줄 요약하자면 다음과 같다.
1. Redis 설정하기
2. 로그인시 Refresh Token(RT)은 Redis에 저장
3. 로그아웃시 Redis에 저장되어 있는 RT 삭제하고 로그아웃 처리한 AT 저장(= 블랙리스트에 등록)
Redis 설정하기
- Redis 설치(생략)
- build.gradle
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'
- application.yml
spring:
redis:
host: {host}
port: 6379
password: {password}
- RedisTemplateConfig 클래스를 생성해서 Redis를 사용하기 위한 설정들을 입력
@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisRepositoryConfig {
private final RedisProperties redisProperties;
// lettuce
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(redisProperties.getHost());
redisStandaloneConfiguration.setPort(redisProperties.getPort());
redisStandaloneConfiguration.setPassword(redisProperties.getPassword());
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
// redis-cli 사용을 위한 설정
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
- 기존의 jwtAuthenticationFilter 클래스에 정상 토큰인지 확인하는 코드를 추가해준다.
- jwtProvider의 validateAccessToken()에서 AT의 value를 Redis에서 가져왔을때 "logout"인지 검증하는 코드를 추가해준다
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// Access Token 추출
String accessToken = resolveToken(request);
try { // 정상 토큰인지 검사
if (accessToken != null && jwtProvider.validateAccessToken(accessToken)) {
Authentication authentication = jwtProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("Save authentication in SecurityContextHolder.");
}
} catch (InvalidTokenException e) { // 잘못된 토큰일 경우
SecurityContextHolder.clearContext();
} catch (InvalidMemberException e) { // 회원을 찾을 수 없을 경우
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
}
@Component
@Transactional(readOnly = true)
public class JwtProvider {
...
// Filter에서 사용
public boolean validateAccessToken(String accessToken) {
String redisServiceValues = redisService.getValues(accessToken);
try {
if (redisServiceValues != null // NPE 방지
&& redisServiceValues.equals("logout")) { // 로그아웃 했을 경우
return false;
}
Jwts.parserBuilder()
.setSigningKey(signingKey)
.build()
.parseClaimsJws(accessToken);
return true;
} catch (ExpiredJwtException e) {
return true;
} catch (Exception e) {
return false;
}
}
...
}
로그인시 Refresh Token(RT)은 Redis에 저장
- AuthService에 RT를 Redis에 저장하는 코드 추가
// 토큰 발급
@Transactional
public TokenDto generateToken(String provider, String email, String authorities) {
// RT가 이미 있을 경우
if (redisService.getValues("RT(" + provider + "):" + email) != null) {
redisService.deleteValues("RT(" + provider + "):" + email); // 삭제
}
// AT, RT 생성 및 Redis에 RT 저장
TokenDto tokenDto = jwtProvider.createToken(email, authorities);
saveRefreshToken(provider, email, tokenDto.getRefreshToken());
return tokenDto;
}
// RT를 Redis에 저장
@Transactional
public void saveRefreshToken(String provider, String principal, String refreshToken) {
redisService.setValuesWithTimeout("RT(" + provider + "):" + principal, // key
refreshToken, // value
jwtProvider.getTokenExpirationTime(refreshToken)); // timeout(milliseconds)
}
로그아웃시 Redis에 저장되어 있는 RT 삭제하고 로그아웃 처리한 AT 저장(= 블랙리스트에 등록)
- AuthService에 logout() 로직 구현
// 로그아웃
@Transactional
public void logout(String requestAccessTokenInHeader) {
String requestAccessToken = resolveToken(requestAccessTokenInHeader);
String principal = getPrincipal(requestAccessToken);
// Redis에 저장되어 있는 RT 삭제
String refreshTokenInRedis = redisService.getValues("RT(" + SERVER + "):" + principal);
if (refreshTokenInRedis != null) {
redisService.deleteValues("RT(" + SERVER + "):" + principal);
}
// Redis에 로그아웃 처리한 AT 저장
long expiration = jwtProvider.getTokenExpirationTime(requestAccessToken) - new Date().getTime();
redisService.setValuesWithTimeout(requestAccessToken,
"logout",
expiration);
}
// "Bearer {AT}"에서 {AT} 추출
public String resolveToken(String requestAccessTokenInHeader) {
if (requestAccessTokenInHeader != null && requestAccessTokenInHeader.startsWith("Bearer ")) {
return requestAccessTokenInHeader.substring(7);
}
return null;
}
참고
https://velog.io/@joonghyun/SpringBoot-Jwt를-이용한-로그아웃
'💻dev > 🕹️Project' 카테고리의 다른 글
대동덕지도 | Spring Security + JWT 토큰 유효성 검증과 재발급 (Access Token, Refresh Token) (0) | 2023.08.13 |
---|---|
대동덕지도 | 로그인 기능 구현에서 Refresh Token을 선택한 이유 (0) | 2023.08.13 |
대동덕지도 | Spring Boot에서 비밀번호 재설정 링크 이메일 전송 기능 구현하기 (0) | 2023.08.11 |
대동덕지도 | Spring Boot에서 Spring Security + JWT 로그인을 구현하자! (Access Token, Refresh Token 발급) (0) | 2023.08.11 |
Toy Project | 깃허브 README에 매일 최신 포스트 자동 업데이트 하기 (0) | 2023.04.06 |