[Spring security + JWT + Redis] #6 logout

devwuu·2023년 8월 18일

security

목록 보기
6/6

이 게시글은 강의를 듣고 사이드 프로젝트에 적용한 내용이 주를 이루고 있습니다
수강 강의 : (인프런) 스프링부트 시큐리티 & JWT 강의
🏠 Basic Exam : https://github.com/devwuu/spring-security-exam
🏠 사이드 프로젝트 : https://github.com/devwuu/todaktodak


1. logout을 하는 방법

일반적으로, 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 자체가 유효한지 검증하는 로직과 별개)


2. RedisHash, Repository 추가

우선은 최소한의 정보만 추가하고 추후에 추가로 필요한 정보가 있다면 하나씩 늘려보기로 했다.

@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> {

}

3. logout 로직 구현

(1) JwtUtil에 destroyToken() 구현

로그아웃 시, (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);
    }

(2) LogoutHandler 구현

SecurityFilterChain에 등록할 LogoutHandlerLogoutSuccessHandler를 구현한다. 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!!!");
    }
}

(3) SecurityFilterChain에 LogoutHandler 등록

구현된 LogoutHandlerLogoutSuccessHandlerSecurityFilterChain에 등록하고 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();
    }

4. Access Token 검증 로직 추가

(1) JwtUtil에 verifyAccessToken() 구현

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));
    }

(2) AuthorizationFilter 수정

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);
    }

5. 마치며

로그아웃까지 구현하고 나니 큰 기능은 대략적으로라도 모두 구현해본 것 같은 느낌이 든다. 포스팅을 하는 동안에도 조금씩 리팩토링을 해서 제일 처음에 짠 코드보다는 조금씩 나아지고 있는 게 눈에 보이니 뿌듯하다(잘한다는 뜻은 아니다...). 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

profile
일단 한다

1개의 댓글

comment-user-thumbnail
2023년 8월 18일

정보 감사합니다.

답글 달기