- Published on
Redis를 사용하여 기존의 JWT방식의 로그아웃 구현
- Authors
- Name
- ywj9811
JWT + Redis
이전에 OAuth2 Login + JWT 를 작성했었는데, 해당 프로젝트에는 로그아웃 기능이 구현이 되어있지 않다.
즉, 로그아웃을 하더라도 AccessToken의 기간이 끝나지 않는다면 다시 접근할 수 있고 RefreshToken을 알고 있다면 언제든지 접근할 수 있는 것이다.
⚠️물론 RefreshToken의 경우 로그아웃 하면 DB에서 삭제해주고 AccessToken 또한 따로 처리하면 할 수 있을 것이다. 하지만 불필요한 디스크 접근을 막기 위해서 Redis를 사용해볼까 한다.
설정 추가
// redis 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
build.gradle에 해당 코드를 추가해서 의존 관계를 추가하여 Redis를 간편하게 이용할 수 있도록 하자.
spring:
redis: localhost
port: 6379
application.yml에도 위의 코드를 추가하도록 하자.
port는 기본이 6379를 사용하도록 되어 있다.
⚠️물론 이전에 Redis를 다운받고 사용하도록 하자 (구글에 검색하면 많이 나온다.)
참고로 Redis를 사용하기 때문에 이전의 User 엔티티에서 refreshToken은 삭제한다.
RedisRepository 작성
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Repository;
import java.time.Duration;
import java.util.Optional;
@RequiredArgsConstructor
@Repository
public class RedisRepo {
private final RedisTemplate<String, String> redisTemplate;
public void setValues(String key, String data) {
ValueOperations<String, String> values = redisTemplate.opsForValue();
values.set(key, data);
}
public void setValues(String key, String data, Duration duration) {
ValueOperations<String, String> values = redisTemplate.opsForValue();
values.set(key, data, duration);
}
public Optional<String> getValues(String key) {
ValueOperations<String, String> values = redisTemplate.opsForValue();
return Optional.ofNullable(values.get(key));
}
public void deleteValues(String key) {
redisTemplate.delete(key);
}
}
RedisTemplate<String, String>
을 자동 주입 받아서 사용하도록 할 것이다.
코드의 경우는 간단하기 때문에 바로 이해가 될 것이다.
이제 본격적으로 수정을 해볼 것인데, 다음과 같은 순서로 작성할 것이다.
1. RefreshToken 발급 시 Redis에 저장 및 업데이트
2. 로그아웃 할 경우 RefreshToken 삭제 및 AccessToken 블랙리스트 등록
RefreshToken 발급 시 Redis에 저장 및 업데이트
이전에 처음 구현을 할 때는 RereshToken을 데이터 베이스에 각 유저별로 등록을 하면서 사용했었다.
하지만 Redis에 대해 설명할 때 다룬 것과 같이 데이터 베이스는 디스크에 직접 접근해야 하기 때문에 서버에 부하가 걸릴 수 있다.
따라서 데이터 베이스에 저장하는 것이 아닌 Redis에 저장을 하고 사용하려고 한다.
JwtAuthenticationProcessingFilter와 LoginSucessHandler 그리고 Oauth2LoginSuccess에서 수정을 하도록 할 것이다.
- LoginSuccessHandler
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String username = extractUsername(authentication); //인증 정보에서 username 가져옴
String accessToken = jwtService.createAccessToken(username); //JwtService에서 AccessToken 발급
String refreshToken = jwtService.createRepublic void updateRefreshToken(String username, String refreshToken) {
Optional<User> byUsername = userRepository.findByUsername(username);
if (byUsername.isEmpty()) {
new Exception("일치하는 회원이 없습니다.");
}
log.info("RefreshToken 업데이트");
// User user = byUsername.get();
// user.updateRefreshToken(refreshToken);
// userRepository.saveAndFlush(user);
/**
* Redis 사용
*/
redisRepo.setValues(username, refreshToken, Duration.ofMillis(refreshTokenExpirationPeriod));
}freshToken(username); //JwtService에서 RefreshToken 발급
jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken);
//응답 헤더에 accessToken, refreshToken 장착
Optional<User> byUsername = userRepo.findByUsername(username);
if (byUsername.isPresent()) {
/**
* Redis 사용 수정
*/
redisRepo.setValues(username, refreshToken, Duration.ofMillis(refreshTokenExpiration));
}
log.info("로그인 성공 username : {}", username);
log.info("로그인 성공 AccessToken : {}", accessToken);
log.info("토큰 만료 기간 : {}", accessTokenExpiration);
}
LoginSuccessHandler의 onAuthenticationSuccess()
에서 로그인 진행 시 Redis에 refreshToken을 저장하도록 할 것이다.
redisRepo.setValues(username, refreshToken, Duration.ofMillis(refreshTokenExpiration));
에서 username을 Key로 refreshToken을 Value로 하여 저장하고 있는데, refreshToken의 만료 시간을 넣어줘서 해당 기간이 지나면 자동으로 삭제되도록 설정을 한다.
- OAuth2LoginSuccessHandler
private void loginSuccess(HttpServletResponse response, PrincipalDetails principalDetails) throws IOException {
String accessToken = jwtService.createAccessToken(principalDetails.getUsername());
String refreshToken = jwtService.createRefreshToken(principalDetails.getUsername());
jwtService.updateRefreshToken(principalDetails.getUsername(), refreshToken);
jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken);
}
@Service
@RequiredArgsConstructor
@Getter
@Slf4j
public class JwtService {
...
public void updateRefreshToken(String username, String refreshToken) {
Optional<User> byUsername = userRepository.findByUsername(username);
if (byUsername.isEmpty()) {
new Exception("일치하는 회원이 없습니다.");
}
log.info("RefreshToken 업데이트");
/**
* Redis 사용
*/
redisRepo.setValues(username, refreshToken, Duration.ofMillis(refreshTokenExpirationPeriod));
}
...
}
이렇게 이전과 마찬가지로 redisRepo에 저장하고 있다.
- JwtAuthenticationProcessingFilter
public void checkRefreshTokenAndReIssueAccessToken(HttpServletRequest request, HttpServletResponse response, String refreshToken) throws IOException, AccessTokenValidationException {
log.info("refreshToken 검사");
Optional<String> username = jwtService.extractUsername(refreshToken);
if (username.isPresent()) {
Optional<User> byUsername = userRepo.findByUsername(username.get());
if (byUsername.isPresent()) {
if (!redisRepo.getValues(byUsername.get().getUsername()).isEmpty()) {
log.info("refreshToken 업데이트 및 AccessToken 재발급 ");
String reIssuedRefreshToken = reIssueRefreshToken(byUsername.get());
jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(username.get()), reIssuedRefreshToken);
//AccessToken 재발급 요청시 어떤 경로로 요청했는지 함께 보내줌 (헤더에 담아서)
String requestURI = request.getRequestURI();
log.info("requestURI : {}", requestURI);
response.setHeader("requestUrl", requestURI);
return;
}
}
}
log.error("refreshToken값이 잘못되었습니다. 요청 확인 바람");
throw new AccessTokenValidationException("RefreshToken 값 불일치");
}
private String reIssueRefreshToken(User user) {
String reIssuedRefreshToken = jwtService.createRefreshToken(user.getUsername());
/**
* Redis 사용 수정
*/
redisRepo.setValues(user.getUsername(), reIssuedRefreshToken, Duration.ofMillis(refreshTokenExpiration));
return reIssuedRefreshToken;
이렇게 AuthenticationProcessingFilter에서 AccessToken 재발급이 왔을 때 RefreshToken이 현재 Redis에 존재하는지 확인 후 AccessToken을 재발급 해줌과 동시에 Redis에 RefreshToken을 다시 저장하고 있다.
💡이 때 Key 값은 해당 username으로 등록하고 있다.
로그아웃 할 경우 RefreshToken 삭제 및 AccessToken 블랙리스트 등록
RefreshToken 발급할 때 Redis에 저장을 하였으니, 로그아웃을 할 때면 Redis에서 해당 RefreshToken을 삭제해줘야 한다.
그리고 로그아웃을 하면 기존의 AccessToken으로 접근할 수 없어야 하니 AccessToken은 블랙리스트로 등록을 해줘야 한다.
즉, 로그아웃할 때 위의 과정에 대한 로직을 추가해줘야 하며 권한이 필요한 페이지로 이동할 때 AccessToken이 블랙리스트로 등록되어있는지 확인할 수 있도록 수정을 해줘야 한다.
- 로그아웃 로직 추가
UserController
@PostMapping("/out")
@ResponseBody
public String logout(HttpServletRequest request, Long userIdx) {
log.info("accessToken = {}", request.getHeader("Authorization"));
boolean logout = jwtService.logout(request, userIdx);
if (logout)
return "로그아웃";
return "오류 발생";
}
우선 간단하게 컨트롤러에서는 로그아웃 성공 여부를 확인할 수 있게 반환을 해주도록 하고 작성을 하도록 하자.
JwtService
public boolean logout(HttpServletRequest request, Long userIdx) {
Optional<String> optionalAccessToken = extractToken(request);
if (optionalAccessToken.isEmpty())
throw new JwtException("JWT 예외 발생");
String accessToken = optionalAccessToken.get();
try {
JWT.require(Algorithm.HMAC512(secretKey)).build().verify(accessToken);
Long expiration = getExpiration(accessToken);
log.info("AccessToken 블랙리스트 등록 {}", accessToken);
redisRepo.setValues(accessToken, "logout", Duration.ofMillis(expiration));
} catch (TokenExpiredException e) {
log.info("토큰 기한이 만료되었습니다 {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.error("JWT 토큰이 잘못되었습니다. {}", e.getMessage());
throw new JwtException("JWT 토큰이 잘못되었습니다.");
} catch (Exception e) {
log.error("유효하지 않은 토큰입니다. {}", e.getMessage());
e.printStackTrace();
throw new JwtException("JWT 예외 발생");
}
log.info("userIdx = {}", userIdx);
User user = userRepository.findById(userIdx).get();
Optional<String> refreshToken = redisRepo.getValues(username);
if (!refreshToken.isEmpty()) {
redisRepo.deleteValues(username);
}
return true;
}
public Optional<String> extractToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(accessHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
//accessToken 남은 시간 계산
public Long getExpiration(String accessToken) {
Date expiration = JWT.decode(accessToken).getExpiresAt();
Long now = new Date().getTime();
return (expiration.getTime() - now);
}
위의 코드를 살펴보면 AccessToken을 전달 받으면 해당 토큰의 남은 유효기간 만큼 블랙리스트로 저장 한다.
💡블랙리스트 등록 → “AccessToken”을 Key 값으로 “logout”이라는 Value를 저장한 것이다.
그리고 username을 통해서 Redis에서 RefreshToken을 삭제해준다.
이렇게 로그아웃 처리를 할 수 있다.
- 권한이 필요한 페이지로 이동할 때 AccessToken이 블랙리스트로 등록되어있는지 확인
로그아웃을 할 경우 AccessToken은 블랙리스트로 그리고 RefreshToken은 삭제했으니 이제 해당 토큰으로 접근하는 경우 막아야 한다.
public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException, AccessTokenValidationException {
log.info("checkAccessTokenAndAuthentication() 호출");
Optional<String> accessToken = jwtService.extractAccessToken(request)
.filter(jwtService::isTokenValid);
if (accessToken.isPresent()) {
log.info("----------------------------------------------------");
log.info("AccessToken.value = {}", redisRepo.getValues(accessToken.get()));
log.info("----------------------------------------------------");
if (!redisRepo.getValues(accessToken.get()).isPresent()) {
Optional<String> username = jwtService.extractUsername(accessToken.get());
if (username.isPresent()) {
Optional<User> user = userRepo.findByUsername(username.get());
if (user.isPresent()) {
saveAuthentication(user.get());
filterChain.doFilter(request, response);
return;
}
}
log.error("username이 없음");
}
log.error("블랙리스트 등록 accessToken 접근");
}
log.error("AccessToken 비정상");
throw new AccessTokenValidationException("AccessToken 비정상");
}
이렇게 만약 Redis에 해당 토큰을 Key값으로 저장되어 있다면 해당 토큰은 블랙리스트로 저장되어 있는 것이기 때문에 통과할 수 없도록 막는 것이다.
현재는 임시로 예외를 발생 시키도록 막아 두었다.
이렇게 Redis를 추가하여 기존의 JWT + Spring Security 에서 로그아웃 기능을 더 효율적으로 추가할 수 있다.