이제 마지막 편인, 로그아웃 서비스를 구현해보도록 하겠다. 저번 편에서 로그인을 할 때 redis를 사용해 RefreshToken을 저장해두었던 이유는 결국 로그아웃 처리를 위해 했다고 생각하면 된다. JWT 특성상 한번 발급된 토큰은 삭제/수정이 불가능하기 때문에 인메모리 데이터를 이용해 빠르고 효율적으로 로그아웃 처리를 할 수 있게 만들었다고 생각하면 된다. 이번 편에서 해볼 거는 로그아웃을 했을 때 동작되는 로직과 Filter에서 어떻게 처리되는지 확인해볼 것이다.
로그아웃 로직
1. 헤더에서 발급되어있는 JWT 토큰을 가져옴
2. 발급되어있는 JWT 토큰의 시간을 가져오기
3. SecurityContextHolder에 등록되어있는 정보에서 email을 가져온다.
4. 로그인할 때 key(email) : value(RefreshToken) 형식으로 저장했기 때문에 가져온 email(key)로 redis에 value가 존재하는지 체크
5. 존재한다면 redis에 저장되어있는 RefreshToken 삭제
6. [ 블랙리스트 생성 단계 ] redis에 가져온 key(JWT 토큰) : value("logout")으로 저장
7. Filter에서 redis에 요청받은 토큰이 존재하는지 체크 -> 있다면 Exception 발생
위 로직을 토대로 로그아웃 기능을 구현해보자.
Service에서는 크게 어려울 건 없다. 헤더를 통해 auth에 토큰 정보를 담아서 불필요한 값을 지운 후 atk에 담아주었다. expiration에 atk의 남은 유효시간을 가져오기 위해 메서드를 TokenProvider에 만들어주었는데 다음과 같다.
public Long getExpiration(String accessToken) {
// accessToken 남은 유효시간
Date expiration = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody()
.getExpiration();
// 현재 시간
Long now = new Date().getTime();
return (expiration.getTime() - now);
}
그 후 SecurityContextHolder에 등록되어있는 유저 정보를 가져와 이메일을 담아주고 redis에 해당 이메일로 키값이 설정된 value값 RefreshToken이 존재하는지 확인해주고 있다면 삭제해준다.
그리고 현재 가져온 AccessToken(key)을 redis에 "logout"(value)로 설정해주고 , 남은 유효시간만큼 저장해주면서 일종의 블랙리스트를 작성해주었다.
그러면 Filter에서 로그아웃이 된 사용자들을 어떻게 검증되는지 확인해보자.
GenericFilterBean을 상속 받은 클래스에서 Override 받은 doFilter에서 추가적으로 구현해주었다. jwt가 정상적으로 헤더에 들어가 있을 경우에 redis에서 해당 토큰이 getValues를 통해 블랙리스트에 등록되어있는지 확인해주는 코드가 추가되었다. 토큰이 redis에 저장되어있는 경우는 블랙리스트이기 때문에 존재하지 않아야 정상적으로 로그인 정보를 등록할 것 이기 때문에 null 인 경우에만 정상적으로 SecurityContextHolder에 authentication을 등록해줄 것이다.
만약 redis에 token값이 존재한다면(블랙리스트에 토큰이 저장되어있다면) 인증되지 않은 사용자가 인증이 필요한 리소스에 액세스 하려고 하여 예외가 throw 되기 때문에 AuthenticationEntryPoint를 불러온 JwtAuthenticationEntryPoint 클래스에서 commence 메서드를 호출하게된다. 해당 메소드를 커스텀해서 클라이언트에게 Jwt가 만료되었다는 메시지와 상태 코드를 보내주도록 하자.
회원가입부터 로그인, 로그아웃까지 전부 살펴보았다. 이 3가지 서비스만 잘 만들어보다 보안과 핵심적인 로직들을 잘 만들었다고 볼 수 있기 때문에 어느 프로젝트를 개발할 때 가장 까다로워질 수 있는 부분이다. 열심히 공부해서 더 보안성을 높이고 좋은 회원관리 시스템을 만들어보자 🥸