이 게시글은 강의를 듣고 사이드 프로젝트에 적용한 내용이 주를 이루고 있습니다
수강 강의 : (인프런) 스프링부트 시큐리티 & JWT 강의
🏠 Basic Exam : https://github.com/devwuu/spring-security-exam
🏠 사이드 프로젝트 : https://github.com/devwuu/todaktodak
일반적으로, session을 사용하는 경우라면 로그아웃을 하면 세션을 끊어버리는 방식으로 로그아웃을 구현한다. 그렇다면 token은 어떻게 해야하지? 검색을 해보니 발급된 JWT token은 설정한 유효기간 동안 살아 있으며 삭제 처리(발급 취소) 할 수 없는 듯 했다. 한 번 발급하면 유효 시간 동안은 어쨌든 살아 있는 token인 셈이다. 그렇기 때문에 blacklist를 만들어서 해당 리스트에 사용 하지 않을 token을 저장하고 해당 리스트에 있는 token으로 인증/인가 요청이 오면 invalid token 등의 exception으로 처리한다고 한다. 내 프로젝트 같은 경우엔 이미 Refresh Token 관리를 위해 Redis를 사용 하고 있었기 때문에 blacklist 역시 redis를 사용하면 되겠다고 생각했다.
내가 구현해야 하는 로그아웃 로직은 대략 세가지로 정리할 수 있다. (1) Refresh Token은 redis에 저장된 token 정보와 비교 검증하고 있으니 logout시에 redis에서 삭제만 해주면 된다. 그렇게 하면 비교 검증할 대상 token이 없으니 검증 과정에서 자연스레 Invalid token이 될 것이다. (2) 그렇다면 Blacklist에는 access token만 저장하면 된다. 더불어 (3) client가 보내온 access token이 로그인 된 token이 맞는지 검증하는 로직이 추가로 필요하다.(token 자체가 유효한지 검증하는 로직과 별개)
우선은 최소한의 정보만 추가하고 추후에 추가로 필요한 정보가 있다면 하나씩 늘려보기로 했다.
@RedisHash("black")
@Getter @Setter
@Accessors(chain = true, fluent = true)
@NoArgsConstructor
public class Blacklist {
@Id
private String id; //username
private String accessToken;
@TimeToLive(unit = TimeUnit.MINUTES)
private Long expiration;
}
@Repository
public interface BlacklistRepository extends CrudRepository<Blacklist, String> {
}
로그아웃 시, (1) redis에 저장되어있는 refresh token을 삭제하는 로직과 (2) redis에 blacklist(access token)을 저장하는 로직을 구현한다. blacklist를 저장할 때 유효 시간은 access token의 유효 시간에서 현재 시간을 뺀, 남은 유효 시간으로 설정해주면 된다. token의 유효 시간이 끝나면 token을 검증 할 때 TokenExpiredException이 발생하기 때문에 blacklist에 등록되어 있지 않아도 사용 불가한 token이 된다.
@Transactional
public void destroyToken(String header){
String accessToken = removePrefix(header);
DecodedJWT decodedJWT = decodeToken(accessToken);
String username = decodedJWT.getClaim("id").asString();
long expiration = Duration.between(Instant.now(), decodedJWT.getExpiresAtAsInstant()).toMinutes();
tokenRepository.deleteById(username);
Blacklist blacklist = new Blacklist()
.id(username)
.accessToken(accessToken)
.expiration(expiration);
blacklistRepository.save(blacklist);
}
SecurityFilterChain에 등록할 LogoutHandler와 LogoutSuccessHandler를 구현한다. client로부터 logout 요청이 오면 실행되어야 하는 로직과 로그아웃이 정상적으로 완료되고 난 다음에 실행되어야 하는 로직을 구현해주면 되는데, 두 로직이 특별히 다른 클래스로 분리되어야 할 이유를 (아직까진...) 못 찾아서 한 클래스에 구현했다. client에서 로그아웃 요청 시, 직전에 구현한 destroyToken이 실행되게 하고, 처리가 완료 되고 나면 성공적으로 로그아웃되었다는 응답을 보내주도록 구현했다.
public class UserLogoutHandler implements LogoutHandler, LogoutSuccessHandler {
...
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
String header = request.getHeader("Authorization");
if(StringUtil.isEmpty(header) || !jwtUtil.isStartWithPrefix(header)){
throw new InvalidTokenException("INVALID TOKEN");
}
jwtUtil.destroyToken(header);
}
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_OK);
JsonUtil.writeValue(response.getOutputStream(), "successfully logout!!!");
}
}
구현된 LogoutHandler와 LogoutSuccessHandler를 SecurityFilterChain에 등록하고 logout 요청 url도 커스텀했다.
@Bean
public SecurityFilterChain adminFilterChain(HttpSecurity http,
@Qualifier("adminAuthenticationFilter") UserAuthenticationFilter authenticationFilter,
AdminDetailService detailService,
JwtUtil jwtUtil) throws Exception {
http
...
.logout(logoutConfigurer ->
logoutConfigurer
.addLogoutHandler(new UserLogoutHandler(jwtUtil))
.logoutSuccessHandler(new UserLogoutHandler(jwtUtil))
.logoutUrl("/admin/logout")
.invalidateHttpSession(true)
.permitAll()
);
return http.build();
}
username으로 blacklist에 등록된 token을 찾고, 해당 token이 사용자가 보내온 token과 일치한다면 그 token은 사용할 수 없는 것으로 보고 빈 Optinal을 return 했다(Optinal.empty()). 반대로 일치하는 token이 아니면 사용할 수 있는 token으로 보고 사용자가 보내온 token을 decode하여 return 해줬다.
이 메서드를 사용하는 AuthorizationFilter에서는 return 받은 decode token에서 username을 가져와 이후에 필요한 인증 절차를 진행할 것이기 때문에 decode된 token이 없으면 invalid exception을 발생시킬 수 밖에 없게 된다.
@Transactional(readOnly = true)
public Optional<DecodedJWT> verifyAccessToken(String header){
String token = removePrefix(header);
DecodedJWT decodedJWT = decodeToken(token);
Optional<Blacklist> blacklist = blacklistRepository.findById(decodedJWT.getClaim("id").asString());
return blacklist.filter(black -> black.accessToken().equals(token)).map(black -> Optional.<DecodedJWT>empty()).orElse(Optional.of(decodedJWT));
}
AuthorizationFilter에서는 access token을 검증하고 사용이 불가능한 token일 경우 invalid token exception을 발생시켰다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if(StringUtil.isEmpty(header) || !jwtUtil.isStartWithPrefix(header)){
filterChain.doFilter(request, response);
return;
}
Optional<DecodedJWT> decodedJWT = jwtUtil.verifyAccessToken(header);
if(decodedJWT.isEmpty() ||!jwtUtil.isAccessToken(header)){
// logout된 토큰이거나 access token이 아닌 경우
throw new InvalidTokenException("INVALID ACCESS TOKEN");
}
String id = decodedJWT.get().getClaim("id").asString();
UserDetails details = userDetailsService.loadUserByUsername(id);
UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.authenticated(details, null, details.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(token);
filterChain.doFilter(request, response);
}
로그아웃까지 구현하고 나니 큰 기능은 대략적으로라도 모두 구현해본 것 같은 느낌이 든다. 포스팅을 하는 동안에도 조금씩 리팩토링을 해서 제일 처음에 짠 코드보다는 조금씩 나아지고 있는 게 눈에 보이니 뿌듯하다(잘한다는 뜻은 아니다...). security를 적용하면서 작은 우여곡절도 좀 겪었는데 나중에 정리해서 포스팅할 기회가 있었으면 좋겠다.
https://stackoverflow.com/questions/37959945/how-to-destroy-jwt-tokens-on-logout
https://velog.io/@joonghyun/SpringBoot-Jwt를-이용한-로그아웃
https://www.inflearn.com/questions/882630/jwt활용한-로그아웃
https://docs.spring.io/spring-security/reference/servlet/authentication/logout.html
정보 감사합니다.