Backend/Spring

Spring boot에서 JWT 구현(3)

나는 유찌 2022. 2. 20. 16:59

이전 글

2022.02.19 - [Backend/Spring] - Spring boot에서 JWT 구현(1)

2022.02.20 - [Backend/Spring] - Spring boot에서 JWT 구현(2)

 

 

바로 전 글에서 Access token을 발급하고 Refresh token을 Redis에 저장하는 과정까지 진행해보았습니다❗

이번 포스팅에서는 Access token이 만료되었을 때 재발급하는 과정을 프론트엔드 코드와 같이 조금 살펴보겠습니다.

 

 

 

Access Token 만료


@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    try {
        String path = request.getServletPath();

        if(path.startsWith("/api/auth/reissue")) {
            filterChain.doFilter(request, response);
        } else {
            String accessToken = tokenProvider.resolveAccessToken(request);
            boolean isTokenValid = tokenProvider.validateToken(accessToken, request);

            if (StringUtils.hasText(accessToken) && isTokenValid) {
                this.setAuthentication(accessToken);
            }

            filterChain.doFilter(request, response);
        }

    } catch (ExpiredJwtException e) {
        ResponseVO responseVO = ResponseVO.builder()
                .status(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getStatus())
                .message(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getMessage())
                .code(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getCode())
                .build();

        response.setStatus(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getStatus().value());
        response.getWriter().write(new ObjectMapper().writeValueAsString(responseVO));
        response.getWriter().flush();
    }
}
  • 전에 작성했던 JwtAuthenticationFilter 코드의 일부입니다.
  • 보시면 ExpiredJwtException 처리를 하고 있는데 토큰 만료 시에 나오는 Exception으로 저의 경우 JWT_ACCESS_TOKEN_EXPIRED라는 이름으로 Custom 401 에러를 내려주었습니다

 

{
    "status":"UNAUTHORIZED",
    "message":"Access token has expired",
    "response":null,
    "code":"TOKEN-0001"
}
  • 만료된 Access 토큰을 가지고 아무 API를 호출하고 받은 return 값입니다.
  • 저는 Access 토큰 만료 에러와 Refresh 토큰 만료 에러를 구분하여 내려주었습니다.
  • 재발급 API 요청을 어떤 경우에 보내야 하는지 구분하기 위함입니다.

 

 

Axios Interceptor 사용


🛠 React.js 17.0.1, Axios 0.24.0을 사용하였습니다 🛠

 

axios.interceptors.request.use(function(config) {
    const token = localStorage.getItem('token')
    if(token) {
        config.headers.Authorization = process.env.REACT_APP_TOKEN_PREFIX + ' ' + token
    }
    return config;
})

axios.interceptors.response.use(
    success => success,
    async(error) => {
        const errorCode = error.response.data.code

        if(errorCode === 'TOKEN-0001') {
            const originRequest = error.config

            await axios.post('/api/auth/reissue')
            .then(result => {
                localStorage.setItem('token', result.data.response.accessToken)
                window.location.reload()
            })
            .catch(error => {
                localStorage.removeItem('token')
            })
            return Promise.reject(error)
        }
    }
)
  • axios interceptor란 axios를 이용한 API를 요청할 때 모두 가로채 와서 해당 코드를 실행하는 코드입니다.
  • axios.interceptors.response는 모든 API가 응답이 내려올 때, axios.interceptors.request는 모든 API를 보낼 때 실행되는 코드를 의미합니다😀
  • 위의 코드를 설명하자면 request는 모든 API를 보내기 전에 Header 값으로 Authorization이라는 이름으로 'Bearer <token>'을 넣어줍니다.
  • 모든 API의 응답에서 에러가 발생하는 경우 ErrorCode가 'TOKEN-0001'일 때 즉, Access token이 만료가 되었다는 에러일 때 토큰을 재발급하는 API를 호출합니다.
  • 성공적으로 API가 요청이 되면 localstorage에 저장을 하고 reload를 통해서 원래 실행하려던 API를 다시 요청합니다.
  • 만일 여기서 에러가 나는 경우 Refresh Token이 만료가 되었다고 생각하고 storage의 토큰 값을 삭제해 아예 로그아웃을 시켜버립니다.

 

 

Access Token 재발급 API


@PostMapping("/reissue")
public ResponseEntity<ResponseVO> reissue(HttpServletRequest request, HttpServletResponse response) throws IOException {
    MemberDTO memberDTO = authService.reissue(request, response);
    return ResponseEntity
            .status(HttpStatus.OK)
            .body(ResponseVO.builder()
                .status(HttpStatus.OK)
                .response(memberDTO)
                .build());
}

 

@Override
public MemberDTO reissue(HttpServletRequest request, HttpServletResponse response) throws IOException {
    try {
        String token = tokenProvider.resolveAccessToken(request);
        // 만료된 Access Token을 디코딩하여 Payload 값을 가져옴
        HashMap<String, String> payloadMap = JwtUtil.getPayloadByToken(token);
        String email = payloadMap.get("sub");

        // Redis에 저장된 Refresh Token을 찾고 만일 없다면 401 에러를 내려줍니다
        Optional<Token> refreshToken = redisRepository.findById(email);
        refreshToken.orElseThrow(
            () -> new CustomException(ErrorCode.UNAUTHORIZED)
        );

        // Refresh Token이 만료가 된 토큰인지 확인합니다
        boolean isTokenValid = tokenProvider.validateToken(refreshToken.get().getValue(), request);

        // Refresh Token이 만료가 되지 않은 경우
        if(isTokenValid) {
            Optional<Member> member = userRepository.findById(email);

            if(member.isPresent()) {
                // Access Token과 Refresh Token을 둘 다 새로 발급하여 Refresh Token은 새로 Redis에 저장
                Token newAccessToken = tokenProvider.createAccessToken(member.get());
                Token newRefreshToken = tokenProvider.createRefreshToken(member.get());

                tokenProvider.setHeaderAccessToken(response, newAccessToken.getValue());

                redisRepository.save(newRefreshToken);

                return MemberDTO.builder()
                        .accessToken(newAccessToken.getValue())
                        .member(member.get())
                        .build();
            }
        }
    } catch(ExpiredJwtException e) {
        // Refresh Token 만료 Exception
        throw new CustomException(ErrorCode.JWT_REFRESH_TOKEN_EXPIRED);
    }
    return null;
}
  • 토큰을 디코딩하여 Payload를 가져오는 JwtUtil의 getPayloadByToken을 구현하였습니다.
  • 아래와 같은 Jwt에 내장된 parser 기능이 있지만 굳이 Decode 코드를 추가한 이유는 이미 만료된 토큰이라 ExpiredJwtException이 발생하기 때문에 따로 Decode 하는 코드를 추가하였습니다. (만료되었지만 Payload 값은 가져오고 싶기 때문에...❗)
  • Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
     
  • Redis에 저장된 Refresh Token 만료까지 체크한 후  Access Token을 새로 만들어주고 보안을 위하여 새로운 Refresh Token을 발급해 새로 Redis에 저장함으로써 기존의 Refresh Token은 폐기합니다.
  • 위의 코드는 Filter가 아닌 Service단에서 이루어지는 코드라 Exception을 Custom 처리가 가능합니다. 따라서 Refresh Token이 만료된 경우 'JWT_REFRESH_TOKEN_EXPIRED' Custom 401 에러를 내려줍니다.

 

 

public static HashMap<String, String> getPayloadByToken(String token) {
    try {
        String[] splitJwt = token.split("\\.");

        Base64.Decoder decoder = Base64.getDecoder();
        String payload = new String(decoder.decode(splitJwt[1] .getBytes()));

        return new ObjectMapper().readValue(payload, HashMap.class);
    } catch (JsonProcessingException e) {
        log.error(e.getMessage());
        return null;
    }
}
  • 제가 구현한 Token을 Decode 하는 코드입니다😀😀

 

 

마치며


블로그에 글을 정리하면서 보니 구현하면서도 보안적으로 좀 다듬어야 할 부분들이 있다고 생각했는데 정말 크게 와닿네요😂😂

Github에 나중에 JWT 레포지터리를 따로 만들 생각인데 그때 보완해야 될 부분을 보완해서 올려야겠습니다ㅎㅎㅠ

 

 

 

GitHub - yujin-Kim-98/side-project-blog: Spring boot + React + MongoDB

Spring boot + React + MongoDB. Contribute to yujin-Kim-98/side-project-blog development by creating an account on GitHub.

github.com