이어서...
1. 로그아웃 처리에 대한 고민...
1-1. 이미 발급한 토큰의 상태 변화
우리는 로그아웃을 할 때 발급했던 AccesToken과 RefreshToken을 강제로 만료시켜야 한다.
참, Stateless한 토큰을 사용할 때 항상 고민되는 부분이다.
문제점 : 이미 발급한 토큰의 상태 변화에 대응하기 어렵다!
즉, stateless한 토큰에 대해서는 그 유효기간이 끝날 때까지 계속해서 유효하게 남아 있게 된다는 점...
그렇다면 몇 가지 대책이 있다!
1-2. 대안책
1. 프론트엔드에서 토큰 삭제
사실 가장 간단한 방법. 프런트와 로직의 협의하여, "로그아웃" 시에 아예 토큰을 삭제시켜버리는 것이다!
클라이언트 측에서 말 그대로 토큰을 삭제하여 '로그아웃'을 구현
2. 토큰 블랙리스트 관리
유효한 AccessToken을 들고 로그아웃 요청이 오는 경우, 서버 측에서 해당 토큰을 블랙리스트로 처리하는 방법이다!
인증 필터에서 매 요청마다 토큰이 블랙리스트에 해당하는 토큰인지를 확인해야 한다...
1-3. 장단점
1. 프론트엔드에서 토큰 삭제
- 장점: 구현이 간단하다!
- 단점: 서버 측에서는 여전히 토큰이 유효하다. (Stateless 하기 때문...)
만약 아직 유효한 토큰이 토큰이 타인에게 노출되면 보안 문제가 발생할 수 있다.
2. 토큰 블랙리스트 관리
- 장점: 서버 측에서 토큰을 더욱 효과적으로 관리할 수 있다! 노출된 토큰에 대한 보안 위험을 최소화할 수 있다.
- 단점: 매 요청마다 블랙리스트를 확인해야 하므로, 사실상 statless한 JWT의 장점을 상실한다...
1-4. ✅ 선택!
프론트엔드에서 토큰 삭제??
JWT의 주요 장점 중 하나는 stateless한 특성이다.
즉, 서버가 클라이언트의 상태를 유지할 필요가 없다는 것!
하지만 클라이언트 측에서 토큰을 삭제하는 것은 클라이언트 측에서 상태를 변경하는 것이므로 이러한 특성이 손실될 수 있다.
(백엔드 측면에서 보자면)
로그인을 통해 클라이언트로 발급한 토큰은 서버에서 통제할 수 없다.
사용자가 로그아웃을 하게되면 토큰을 사용하지 못하게 해야하는데, 클라이언트가 갖고 있는 토큰을 우리 측에서 컨트롤 할 수 없다...
토큰 블랙리스트 관리??
사실 이 또한, Stateful한 (상태 저장) 관리 기법을 일부 차용해야 하지만,
보안 강화, 보안 유지, 유연성(구현해둔 블랙리스트를 사용하여 특정 세션 또는 기기에서 로그아웃하는 등의 추가적인 제어도 가능)
등의 이유로
[블랙리스트 관리] 방법을 선택하도록 하였다!
블랙리스트에 추가된 토큰은 서버에 도착하는 즉시 무효화되므로 보안을 유지하는 데 더욱 효과적이라고 판단했다.
다시 말하지만, Access Token이 블랙리스트에 존재하는지 매번 DB를 조회하여 확인해야 하는 상황에 놓이게 된다.
이러한 상황에서 JWT 방식은 stateful한 방식으로 간주될 수 있으며,
이러한 점에서 JWT의 장점을 일부 상실한다고 볼 수도 있다.
또한 성능적인 측면에서 일부 손해를 본다...
우리는 차선책으로 인-메모리 방식인 Redis를 사용하도록 결정했다.
토큰의 장점이 일부 손해 보는 것은 감수하되, 성능 측면에서 최대한 손해가 없도록 했다.
2. 로그아웃 로직 구현
2-1. JwtLogoutFilter 작성
- AccessToken 을 헤더에서 가져온다.
- 해당 AccessToken을 블랙리스트 처리하여 redis에 저장한다.
- AccessToken에서 추출한 username을 key로 가지고 있는 value(RefreshToken)을 삭제한다.
@RequiredArgsConstructor
@Slf4j
public class JwtLogoutFilter implements LogoutHandler {
private final RedisUtil redisUtil;
private final JwtUtil jwtUtil;
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
try {
log.info("[*] Logout Filter");
String accessToken = jwtUtil.resolveAccessToken(request);
redisUtil.save(
accessToken,
"logout",
jwtUtil.getExpTime(accessToken),
TimeUnit.MILLISECONDS
);
String username = jwtUtil.getUsername(accessToken);
redisUtil.delete(username);
} catch (ExpiredJwtException e) {
log.warn("[*] case : accessToken expired");
try {
HttpResponseUtil.setErrorResponse(response, HttpStatus.UNAUTHORIZED, "세션이 만료되었습니다. 다시 로그인하세요");
} catch (IOException ex) {
log.error("IOException occurred while setting error response: {}", ex.getMessage());
}
}
}
}
- key : value 형태로 저장 되는 Redis!
- 우리는 accessToken : "logout" 형태로 블랙리스트 처리를 해줄 것이다!
2-2. SecurityConfig 필터에 추가!
// Logout Filter
http
.logout(logout -> logout
.logoutUrl("/logout")
.addLogoutHandler(new JwtLogoutFilter(redisUtil, jwtUtil))
.logoutSuccessHandler((request, response, authentication) ->
HttpResponseUtil.setSuccessResponse(
response,
HttpStatus.OK,
"로그아웃 성공"
)
)
);
2-3. JwtAuthorizationFilter 인가단계 수정
- 매번 logout 된 토큰인지 체크 해주는 로직을 추가한다!
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
log.info("[*] Jwt Filter");
try {
String accessToken = jwtUtil.resolveAccessToken(request);
// accessToken 없이 접근할 경우
if (accessToken == null) {
filterChain.doFilter(request, response);
return;
}
// logout 처리된 accessToken
if (redisUtil.get(accessToken) != null && redisUtil.get(accessToken).equals("logout")) {
logger.info("[*] Logout accessToken");
filterChain.doFilter(request, response);
return;
}
log.info("[*] Authorization with Token");
authenticateAccessToken(accessToken);
filterChain.doFilter(request, response);
} catch (ExpiredJwtException e) {
try {
HttpResponseUtil.setErrorResponse(response, HttpStatus.UNAUTHORIZED, "엑세스 토큰이 유효하지 않습니다.");
} catch (IOException ex) {
log.error("IOException occurred while setting error response: {}", ex.getMessage());
}
log.warn("[*] case : accessToken Expired");
}
}
자~~~~~~ 길고 길었던, JWT를 사용한 인증/인가 과정이 모두 끝났다!
다음 포스팅에서 모든 Case를 테스트 해보겠다!
참고 자료 :
https://russwest.tistory.com/40
https://upcurvewave.tistory.com/611