2022.11.24 TIL

SUADI·2022년 11월 24일
0

오늘의 커밋

  • jwtfilter 앞 단에 filter 하나 더 둬서 jwtException 처리

스프링 시큐리티와 jwt만 생각하면 숙련주차 때 고생하던 생각이 난다. CRUD도 이제 막 배우기 시작했는데 그 어려운 보안 관련 프레임워크를 배우다니..! 어우.. 그 당시엔 열심히 삽질해도 콘크리트 바닥을 삽질하는 기분이였는데 요즘엔 그 단단하던 바닥에 작은 흠집 정도는 나는 정도의 이해도가 생기긴 했다. 여전히 많은 공부가 필요하지만 현재로서는 이 정도의 이해도를 가진 것만으로도 대견스럽다. TIL 시작.

[1] Spring Security

스프링 시큐리티에 대해 다시 한 번 공부해 보고, 숙련주차에는 이해하지 못했던 스프링 시큐리티의 내부 구조에 대해 아주 조금만 더 깊이 들어가 본 후에 프로젝트에서 jwt 관련 이슈에 대해 이야기 해보는 순서로 진행해 보려고 한다.

{1} Spring Security란?

스프링 시큐리티는 인증, 인가와 같은 보안을 담당하는 스프링 하위 프레임워크이다. Spring Security는 보안과 관련해서 체계적으로 많은 옵션을 제공해주기 때문에 개발자 입장에서는 일일이 보안관련 로직을 작성하지 않아도 된다는 장점이 있다. 스프링 시큐리티를 사용하지 않는다면 세션으로 자동적으로 체크해준다던가 로그인 후 리다이렉트해준다던가 하는 당연스러웠던 것들을 자체적으로 구현해야만 한다.

{2} Spring Security 구조

(1) 스프링 영역

스프링 시큐리티 구조에 대해 이야기하기 앞서 스프링 MVC 앞 단의 구조에 대해 먼저 조금 알아보려고 한다. 위 그림을 살펴보면 스프링 영역의 맨 앞 단에 내가 완전 모르는 Dispatcher Servlet과 Interceptor가 있고, 그나마 조금 공부한 AOP, Controller 등이 있다. 이 이후에는 Service, Respository, DB가 차례로 있을 것이다. 스프링 영역에 대해 키워드 위주로만 공부하고 지나가고 다음에 제대로 다뤄보자.

Dispatcher Servlet - Distpatch는 '보내다'라는 의미이고, Servlet은 '클라이언트의 요청에 대해 동적으로 작동하는 웹 어플리케이션 컴포넌트'이다. 디스패처 서블랫은 HTTP 프로토콜로 들어오는 모든 요청을 가장 먼저 받아 적합한 컨트롤러에게 위임해 주는 Front Controller 역할을 한다.

Interceptor - 인터셉터는 디스패처 서블릿이 컨트롤러를 호출하기 전과 후에 요청과 응답을 참조하거나 가공할 수 있는 기능을 제공한다. 세부적으로 적용해야 하는 인증이나 인가와 같이 클라이언트 요청과 관련된 작업 등이 있다. 예를 들어 특정 그룹의 사용자는 어떤 기능을 사용하지 못하는 경우가 있는데, 이러한 작업들은 컨트롤러로 넘어가기 전에 검사해야 하므로 인터셉터가 처리하기에 적합하다.

Filter - 스프링 영역 바깥에 있는 영역으로 애플리케이션의 HTTP 요청 및 응답을 가로채는 데 사용되는 개체이다. doFilter라는 메서드가 필터체인으로 가로채 주는 역할을 한다. 인터셉터와 AOP도 비슷한 역할을 하지만 실행되는 시점이 다르므로 각 상황에 맞춰 구현해야만 한다.

스프링 시큐리티는 디스패처 서블릿보다 앞 단인 Filter 기반으로 동작하기 때문에 Spring MVC와 분리되어 관리 및 동작한다. 인터셉터에서도 보안 관련 작업을 할 수 있지만 인터셉터 이전 단계인 필터에서 구현하는 것이 일반적이고 효율적이다.

(2) 스프링 시큐리티 구조

위의 이미지와 함께 사용자를 인증하는 과정을 알아보면,

  1. 사용자가 로그인을 요청한다.(HTTP Request)
  2. HTTP 요청이 Authentication Filter를 제일 처음 거친다.
  3. UsernamePasswordAuthenticationToken을 거쳐서 username와 password 데이터를 가져온 후 인증을 위한 토큰을 생성 후 인증을 다른 쪽에 위임한다.
  4. AuthenticationManager, AuthenticationProvider, UserDetailService를 거쳐 DB에 접근한다.
  5. DB에 존재하는 유저라면 USerDetails로 꺼내 session을 생성한다.
  6. 스프링 시큐리티의 인메모리 세션 저장소인 SercurityContextHolder에 세션을 저장한다.
  7. 사용자에게 Session ID와 함께 응답을 내려준다.
  8. 이후 요청에서는 요청 쿠키에서 SessionId를 보고 검증 후 유효하면 요청에 대한 응답을 한다.

{3} 프로젝트에 유효하지 않은 토큰 관련 예외 처리 적용

프로젝트에서 유효하지 않은 토큰으로 Http 요청을 받았을 때 500에러가 터졌었는데 이 이슈를 언제 해결하나 프로젝트 초반부터 고민하고 있었는데 갑자기 무슨 바람이 들었는지 하던 작업을 다 멈춰 버리고 어제, 오늘 예외 처리하는데 투자했다. 결과적으로는 500에러가 아닌 401 Unauthorized 에러와 함께 에러 메세지를 응답하도록 처리했다. 효과적으로 처리를 했는지는 아직도 잘은 모르겠지만 원하는 결과대로 했다는 것에 만족하기로 했다.

(1) 첫번째 시도

프로젝트에서는 전역적인 예외처리를 AOP를 사용하여 GlobalExceptionHandler가 모든 예외를 잡도록 설정했었어서 토큰 관련 예외도 여기서 다 잡아낼줄 알았다.

하지만 JwtFilter는 AOP보다 앞 단에 위치해 있다는 사실을 알게 되고 나서부터 Filter에 대한 공부를 시작했고 Filter chain을 커스텀해서 사용할 수 있으며 JWT 토큰 관련 예외를 잡기 위해서는 JwtFilter보다 앞 단에 위치해 있어야만 예외를 잡을 수 있다는걸 알게 되었다.

지금까지 전역적인 예외처리를 할 때에도 Service 단보다 AOP 단이 앞 단에 있기 때문에 예외를 잡을 수 있었던 것처럼 어찌보면 당연한 이치다.

(2) 두번째 시도

@RequiredArgsConstructor
public class JwtSecurityConfiguration
        extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private final TokenProvider tokenProvider;
    private final JwtExceptionFilter jwtExceptionFilter;

    @Override
    public void configure(HttpSecurity httpSecurity) {
        JwtFilter customJwtFilter = new JwtFilter(tokenProvider);
        httpSecurity.addFilterBefore(customJwtFilter, UsernamePasswordAuthenticationFilter.class);

        // JwtFilter 앞단에 JwtExceptionFilter 를 위치시키겠다는 설정
        httpSecurity.addFilterBefore(jwtExceptionFilter, JwtFilter.class);
    }
}

먼저 Config 클래스에 JwtFilter보다 JwtExceptionFilter가 더 앞 단에 위치하도록 설정했다.

@Component
public class JwtExceptionFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        try {
            filterChain.doFilter(request, response);
        } catch (GlobalException e) {
            setErrorResponse(response, e);
        }
    }

    private void setErrorResponse(HttpServletResponse response, GlobalException e) throws IOException {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType("application/json; charset=UTF-8");

        ObjectMapper mapper = new ObjectMapper();

        response.getWriter().write(mapper.writeValueAsString(
                ResponseDto.fail(
                        e.getErrorCode().getHttpStatus(),
                        e.getErrorCode().getMessage(),
                        e.getErrorCode().getDetail()
                )));
    }
}

JwtExceptionFilter에서는 doFilter가 진행이 되다가 GlobalException이 터질 경우 401 코드와 함께 에러메세지를 응답하도록 했다.

여기서 처음 알게 된 사실도 있고, 비효율적(?)으로 코드를 짰다고 느꼈던 포인트가 몇가지 있다.

이 코드를 작성하면서 HttpServletResponse의 객체를 통해 상태 코드를 설정해줄 수 있다는 것도 처음 알게 되었다.

그리고 프로젝트에서 지금껏 썼던 response 양식인 ResponseDto의 fail 메서드를 이용하고 싶었지만 어떻게 하는지 잘 몰라서 객체를 json 형태로 바꿔주는 ObjectMapper를 이용해서 응답을 했는데 이렇게 하면 안될 것 같은 느낌이 강하게 들었지만 현재로선 이게 최선이였다. 나중에 더 공부해서 리펙터링해야겠다.

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
            throw new GlobalException(ErrorCode.WRONG_TOKEN);
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
            throw new GlobalException(ErrorCode.TOKEN_EXPIRED);
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
            throw new GlobalException(ErrorCode.UNSUPPORTED_TOKEN);
        }
    }

그리고 마지막으로 토큰 검증하는 메서드에 각 조건에 따라 GlobalException을 터뜨리도록 했다.

참고 자료

0개의 댓글

관련 채용 정보