지난 포스팅까지 인증 및 인가 기능을 구현하였고, 이번 포스팅에서는 스프링 시큐리티로 로그아웃을 구현하며 Security 시리즈를 마무리하려 한다.
추후에 리팩토링 혹은 시큐어 코딩을 하게 된다면 따로 포스팅하도록 하겠다.
블랙 리스트 기능을 사용하지 않은 일반적인 로그아웃 흐름과 블랙 리스트 기능이 적용된 로그아웃의 흐름을 살펴보자.
서버 측에서는 로그아웃 시 처리를 하는 로직이 없다. 서버 측에서는 토큰에 대한 제어가 없어 JWT 를 무효화할 방법이 없기 때문이다. (JWT 특성 : Stateless)
일반적으로 서버는 사용자 관련 데이터를 정리하거나 세션 정보를 삭제하지만 JWT의 유효성에는 영향을 미치지 않는다.
클라이언트가 로그아웃을 하고 JWT를 제거했다 하더라도, 해당 JWT는 여전히 유효하다. 만약 JWT가 탈취되거나 유효 기간이 만료되지 않았다면, 해당 토큰을 사용하는 공격자가 서버에 유효한 요청을 보낼 수 있다.
로그아웃 후에도 유효한 토큰으로 서버에 유효한 요청을 보낼 수 있기 때문에 이를 방지하고자 블랙 리스트 기능을 도입하게 되었다.
보안 강화: 로그아웃 할 때 블랙리스트에 추가함으로써, 해당 토큰을 즉시 무효화할 수 있다. 이는 토큰이 탈취되었을 때 보안 리스크를 줄여준다.
분산 시스템에서 유용: 블랙리스트를 사용하면, 모든 인스턴스가 중앙의 Redis 블랙리스트를 참조하여 토큰의 유효성을 검증할 수 있다.
정교한 제어: JWT의 만료 기간 이외에도, 특정 이벤트(ex. 비밀번호 변경, 계정 비활성화) 시에도 블랙 리스트 기능을 사용하여 토큰을 무효화할 수 있다.
요청(request)에서 Access Token과 Refresh Token을 꺼내서 authService로 넘겨준다.
@Tag(name = "사용자 로그아웃 API", description = "사용자 로그아웃 및 토큰 재발급과 관련된 API")
@RestController
@RequestMapping("/api/v1/logout")
@RequiredArgsConstructor
@CrossOrigin("*")
public class LogoutController {
private final AuthService authService;
private final ResponseService responseService;
private final JwtTokenProvider jwtTokenProvider;
@Operation(summary = "로그아웃", description = "사용자(임대인, 임차인 공통)의 로그아웃과 관련된 API")
@DeleteMapping
public CommonResult logout(HttpServletRequest request) {
String accessToken = jwtTokenProvider.resolveToken(request);
String refreshToken = jwtTokenProvider.resolveRefreshToken(request);
authService.logout(accessToken, refreshToken);
return responseService.getSuccessfulResult();
}
AuthService 인터페이스를 작성한다.
public interface AuthService {
void logout(String accessToken, String refreshToken);
}
AccessToken과 RefreshToken을 검증하고 토큰의 남은 유효시간을 가지고 와서 블랙리스트에 추가한다.
@Override
public void logout(String accessToken, String refreshToken) {
//access token 검증
if (!jwtTokenProvider.validateToken(accessToken)) {
throw new UnauthorizedAccessException(ExceptionList.SIGNATURE_JWT);
}
//access token에서 인증정보 조회
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
String username = authentication.getName(); //사용자 아이디
//아이디로 조회되는 refresh token 이 있으면 삭제
if(redisServiceImpl.getValues(username)!=null){
redisServiceImpl.deleteValues(username);
}
//Access Token 남은 유효시간 가지고 와서 blacklist에 추가
Long expiration = jwtTokenProvider.getAccessTokenExpiration(accessToken);
redisServiceImpl.setBlackList(accessToken, "access_token", expiration);
// Refresh Token 남은 유효시간 가지고 와서 blacklist에 추가
if (refreshToken != null && jwtTokenProvider.validateToken(refreshToken)) {
Long refreshTokenExpiration = jwtTokenProvider.getRefreshTokenExpiration(refreshToken);
redisServiceImpl.setBlackList(refreshToken, "refresh_token", refreshTokenExpiration);
}
}
리프레쉬 토큰도 탈취될 가능성이 있기 때문에 로그아웃할 때 리프레시 토큰도 무효화해야 한다. 리프레쉬 토큰도 블랙리스트에 추가하여 새로운 액세스 토큰을 발급받지 못하도록 구현하였다.
토큰의 유효성을 검증하는 JwtTokenProvider에 블랙리스트를 검증하는 로직을 구현하였다. Redis의 키가 토큰이기 때문에 리프레시 토큰과 액세스 토큰을 동시에 검증할 수 있다.
// 토큰의 유효성 검증
public boolean validateToken(String jwtToken) {
try {
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
//블랙리스트 검증
if(redisServiceImpl.hasKeyBlackList(jwtToken)) {
return false;
}
return true;
} catch (SecurityException e) {
throw new JwtException("잘못된 JWT 서명입니다.");
} catch (MalformedJwtException e) {
throw new JwtException("잘못된 JWT 토큰입니다.");
} catch (ExpiredJwtException e) {
throw new JwtException("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
throw new JwtException("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
throw new JwtException("JWT 토큰의 구조가 유효하지 않습니다.");
}
}
}
리프레쉬 토큰을 저장하는 Redis에 블랙리스트 Template을 추가하여 블랙리스트 저장 로직을 구현하였다.
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisServiceImpl implements RedisService{
...
private final RedisTemplate<String, Object> redisBlackListTemplate;
public boolean checkExistsValue(String value) {
return !value.equals("false");
}
public void setBlackList(String key, Object o, Long milliSeconds) {
redisBlackListTemplate.setValueSerializer(new Jackson2JsonRedisSerializer(o.getClass()));
redisBlackListTemplate.opsForValue().set(key, o, milliSeconds, TimeUnit.MILLISECONDS);
}
public Object getBlackList(String key) {
return redisBlackListTemplate.opsForValue().get(key);
}
public boolean deleteBlackList(String key) {
return redisBlackListTemplate.delete(key);
}
public boolean hasKeyBlackList(String key) {
return redisBlackListTemplate.hasKey(key);
}
}
이렇게 블랙리스트를 활용한 로그아웃 기능이 완성 되었다. 내가 취약하다고 생각했던 부분을 직접 보완을 해보니 좀 더 뿌듯하고 시큐어 코딩이 왜 필요한지 알게 되었다. 이를 계기로 시큐어 코딩에 대해 자세히 공부할 예정이다.
로그인부터 로그아웃까지 구현을 하고 블로그로 정리를 해보니 완성이 아닌 내가 놓친 부분이 많다는 것을 알게 되었다. 이해가 더 잘되는 부분이 있었고 왜 코드를 이렇게 짰지 하면서 바꿨던 부분도 있었다. 그래서 중간중간에 코드를 수정하고 추가하면서 블로그를 작성하였고 아직도 부족하다고 느낀다.
(수정하거나 추가했으면 좋겠다는 기능이 있으시면 댓글 부탁드립니다:))
그리고 시큐리티 시리즈로 처음으로 글을 작성하면서 왜 개발 블로그를 써야 하는지 이해가 갔다. 이렇게 바꿔봐야겠다 한 부분이 많았기 때문에 이 부분은 추후에 블로그를 통해 업로드 할 것이다. 앞으로 블로그를 적극 활용하여 내가 새롭게 알게 된 부분, 문제 해결 방법을 공유하는 개발자가 될 것이다.
다음 시리즈는 KEYCLOAK 입니다!