JWT, Redis를 이용한 로그아웃

구본식·2023년 1월 8일
2
post-thumbnail

이번 프로젝트에서 사용하던 JWT관련 라이브러리가 java-jwt였지만 이번 로그아웃 구현시에는 jjwt관련 라이브러리를 사용하였다.

	//jwt 사용
	implementation group: 'com.auth0', name: 'java-jwt', version: '3.10.2'
	//새로추가(jjwt library)
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5', 'io.jsonwebtoken:jjwt-jackson:0.11.5'

jjwt를 사용한 이유는 기능구현에 있어 좀더 많은 편의메서드를 제공하고 이미 많은 사람들이 사용하고 있어 레퍼런스들이 많기 때문이다.

1. 전체적인 흐름

결론적으로는 이번 프로젝트에서 사용하던 Spring Security filter 중 인증 필터 기능에
JWT토큰(엑세스 토큰) 요청시 이미 로그아웃 처리된 블랙 리스트JWT 토큰인지를 검증하는 기능을 추가하면 된다.

또한 로그아웃 api호출시 사용하던 JWT토큰Redis에 저장하고 Redis의 ExpirationJWT 토큰의 남은시간으로 세팅한다.

[로그아웃 api 호출시]
1. 엑세스(JWT)토큰의 유효기간을 알아낸다.(이미 유효기간이 끝날경우 굳이 Redis에 블랙리스트 토큰으로 지정할 필요가 없으니)
2. Redis Cache에 (key:"엑세스토큰", value:"logout", expiration:엑세스토큰 남은 기간)으로 넣는다.
(어떠한 사람들은 key로 "userId", value로 "엑세스토큰"으로 구현하였는데 지금 굳이 "userId"를 넣을 필요가 없다고 생각해서 위와 같은 방식으로 하였다. 움....맞나?🤣)

[인증 필터]
1. 기존 JWT 토큰 검증 전에 요청한 엑세스토큰블랙리스트에 있는지를 확인한다.
RedisKey값으로 있는지
2. 블랙 리스트에 속한 토큰이라면 인증 실패처리를 한다.


2. JwtProvider

 	//JWT 토큰의 만료시간
    public Long getExpiration(String accessToken){

        Date expiration = Jwts.parserBuilder().setSigningKey(JwtProperties.SECRET.getBytes())
                .build().parseClaimsJws(accessToken).getBody().getExpiration();

        long now = new Date().getTime();
        return expiration.getTime() - now;
    }

JWT토큰의 남은 유효시간을 얻어오는 메서드이다.


3. service

	//로그아웃
    @Transactional
    public void logout(String accessToken){

        Long findUserId = jwtProvider.getUserIdToToken(accessToken);

        //엑세스 토큰 남은 유효시간
        Long expiration = jwtProvider.getExpiration(accessToken);

        //Redis Cache에 저장
        redisTemplate.opsForValue().set(accessToken, "logout", expiration, TimeUnit.MILLISECONDS);

        //리프레쉬 토큰 삭제
        refreshTokenRepository.delete(findUserId);
    }

4. JwtAuthenticationFilter

기존 프로젝트에 정리해둔 "BasicAuthenticationFilter"을 커스텀한 필터이다.

public class JwtAuthenticationFilter extends BasicAuthenticationFilter {

    private JwtProvider jwtProvider;
    private ObjectMapper objectMapper;
    private UserService userService;
    private RedisTemplate<String,String> redisTemplate;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtProvider jwtProvider, ObjectMapper objectMapper,
                                   UserService userService, RedisTemplate<String,String> redisTemplate) {
        super(authenticationManager);
        this.jwtProvider = jwtProvider;
        this.objectMapper = objectMapper;
        this.userService = userService;
        this.redisTemplate = redisTemplate;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {

        String token = jwtProvider.validAccessTokenHeader(request);

        try {
            //header 에서 JWT 토큰이 있는지 검사
            if(!StringUtils.hasText(token))  //토큰이 없는 경우
                throw new NotExistingToken("토큰이 없습니다.");

            //로그아웃된 토큰인지 검사
            validBlackToken(token);

            //JWT 토큰 만료기간 검증
            jwtProvider.validTokenExpired(token);
            
            if(!jwtProvider.validTokenHeaderUser(token))
                throw new NotValidToken("정상적이지 않은 토큰입니다.");

            Long userId = jwtProvider.getUserIdToToken(token);
            User findUser = userService.findUserByUserId(userId);
            PrincipalDetails principalDetails = new PrincipalDetails(findUser);
            Authentication authentication = new UsernamePasswordAuthenticationToken(
                   principalDetails, // 나중에 컨트롤러에서 DI해서 쓸 때 사용하기 편함.
                   null, // 패스워드는 모르니까 null 처리, 어차피 지금 로그인 인증하는게 아니니까!!(로그인 필터를 사용하는게 아니니깐 지금)
                   principalDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication); //추가로 controller 단에서 해당 객체를 꺼낼수 있다.!

            chain.doFilter(request,response);

        }catch (ExpireTokenException e) { //기한만료된 토큰-201
            sendResponse(response, e.getMessage(),
                    HttpStatus.CREATED.value(), HttpStatus.CREATED.getReasonPhrase());
            return;
        }catch (BlackToken e) { //로그아웃된 토큰-401
            sendResponse(response, e.getMessage(),
                    HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
            return;
        } catch (NotExistingToken e){ //헤더에 토큰이 없는경우-412
            sendResponse(response, e.getMessage(),
                    HttpStatus.PRECONDITION_FAILED.value(),HttpStatus.PRECONDITION_FAILED.getReasonPhrase() );
            return;
        }catch (NotValidToken e) { //정상적이지 않은 토큰-401
            sendResponse(response, e.getMessage(),
                    HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
            return;
        }
        catch (Exception e) { //나머지 서버 에러-500
            sendResponse(response, e.getMessage(),
                    HttpStatus.INTERNAL_SERVER_ERROR.value(), HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
            return;
        }
    }

    private void validBlackToken(String accessToken) {

        //Redis에 있는 엑세스 토큰인 경우 로그아웃 처리된 엑세스 토큰임.
        String blackToken = redisTemplate.opsForValue().get(accessToken);
        if(StringUtils.hasText(blackToken))
            throw new BlackToken("로그아웃 처리된 엑세스 토큰입니다.");
    }

    private void sendResponse(HttpServletResponse response, String message, int code, String status ) throws IOException {

        BaseErrorResult result = new BaseErrorResult(message, String.valueOf(code), status);

        String res = objectMapper.writeValueAsString(result);
        response.setStatus(code);
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(res);
    }
}

프로젝트에서 구현하였던 스프링 시큐리티 인증 필터블랙리스트 토큰인지를 검증하기 위해 validBlackToken(token) 메서드를 정의하여 검증하였다.

메서드의 기능은 앞서 흐름도에서 설명했듯이 Redis에 해당 엑세스토큰Key가 있는지를 확인하게 된다.

참고자료 : https://monynony0203.tistory.com/m/105, https://sepang2.tistory.com/84

profile
백엔드 개발자를 꿈꾸며 기록중💻

0개의 댓글