본문 바로가기
📕 Spring Framework/Spring Project

리팩터링 「Authentication(인증)」

by GroovyArea 2022. 7. 11.
태어나서 처음 회사에 지원했다. 서류를 여러 군데 넣었다. 벌써 서류 탈락만 3번째이다. 
서류 탈락이 이런 기분이구나...ㅎㅎ
회사 기준에 부합하지 않는 내 실력과 결과물 탓이지 뭐.
더 열심히 다듬어야겠다.

서류를 다듬다가 인증과 인가 구현 중 이슈를 작성했던 부분이 눈에 띄었다.
인증과 인가... 음...
인가는 애노테이션을 이용해 인터셉터로 구현한 명확한 근거가 있었다.
인증은 좀 애매했다. JWT 토큰을 인증하는 부분은 굳이 인터셉터까지 도달할 필요가 없다. 

그래서 인증 작업을 인터셉터에서 필터로 리팩토링을 진행했다.

필터(Filter)란?

HTTP 요청과 응답을 거른 뒤 정제할 수 있는 기능이다. Servlet Container 단에서 동작한다.

스프링 범위 밖에서 처리된다.

Dispathcer Servlet에 요청이 전달되기 전 / 후에 url 패턴에 맞는 모든 요청에 대해서 부가적인 작업과 기능을 추가하거나 처리할 수 있다.

 

필터를 사용해야 할 때

- 보안 관련 공통 작업을 진행

> 웹 컨테이너상에서 동작하므로 보안 검사를 통해 요청을 차단할 수 있어 안정성을 높인다.

 

- 로깅 작업

- 인코딩 작업

- request 커스터마이징

 

필터 메서드

 

  • init 메소드
    • 필터 객체를 초기화하고 서비스에 추가하기 위한 메서드이다. 웹 컨테이너가 1회 init 메소드를 호출하여 필터 객체를 초기화하면, 이후 요청들은 doFilter() 메소드를 통해 처리된다.
  • doFilter 메소드
    • url-pattern에 맞는 모든 HTTP 요청이 디스패처 서블릿으로 전달되기 전에 웹 컨테이너에 의해 실행되고, 디스패처 서블릿에서 클라이언트에게 HTTP 응답이 가기 전에 웹 컨테이너에 의해 실행되는 메소드이다.
      doFilter()의 파라미터로는 FilterChain이 있는데, FilterChain의 doFilter() 를 통해 다음 대상으로 요청을 전달하게 된다. chain.doFilter() 전/후에 우리가 필요한 처리 과정을 넣어줌으로써 원하는 처리를 진행할 수 있다.
  • destory 메서드
    • 필터 객체를 서비스에서 제거하고 사용하는 자원을 반환하기 위한 메서드이다. 이는 웹 컨테이너에 의해 1번 호출되며, 이후에는 이제 doFilter() 에 의해 처리되지 않는다.

 

 

필터의 구현

일반적으로 Filter 인터페이스를 상속받아 필터를 구현할 수 있지만, 

일반적인 상황인 요청이 들어와 서블릿을 실행할 때 또 다른 요청이 들어오게 되면 동일한 필터를 다시 실행하게 된다. 

이러할 경우 불필요한 리소스가 낭비된다.

 

그래서~~! 사용한 것은 OnePerRequestFilter

이 클래스를 상속받게 되면 요청당 필터로 동일 필터의 동작을 방지하며 한 번의 요청당 한 번의 필터만 실행되어 보안 인증을 구현하기에 매우 유용하다.

 

구현 필터 코드

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {

    private static final String BEARER_TOKEN = "Bearer";
    private static final String NULL_TOKEN = "DB에 토큰이 존재하지 않습니다. 로그인이 필요합니다.";
    private static final String INVALID_TOKEN = "토큰이 일치하지 않습니다. 잘못된 접근입니다.";
    private static final String INVALID_SIGNATURE = "토큰 형식이 잘못 되었습니다.";
    //private static final String EMPTY_TOKEN = "헤더에 저장된 토큰이 필요합니다.";
    private static final String CLASS_CAST_FAIL = "토큰 유효성 검사가 실패하였습니다. 확인 후 재요청 바랍니다.";
    private static final String MALFORMED_TOKEN = "유효하지 않은 토큰입니다.";
    private static final String EXPIRED_TOKEN = "토큰 유효기간이 만료되었습니다. 재로그인 바랍니다.";
    private static final String UNSUPPORTED_TOKEN = "지원하지 않는 토큰입니다.";


    private final AuthorizationExtractor authorizationExtractor;

    private final JwtTokenProvider jwtTokenProvider;

    private final RedisService redisService;

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

        if (!requestURI.startsWith("/api")) {
            filterChain.doFilter(request, response);
        }

        try {
            String requestToken = authorizationExtractor.extract(request, BEARER_TOKEN);
            jwtTokenProvider.validateToken(requestToken);

            final String tokenUserId = jwtTokenProvider.getUserId(requestToken);

            /* request에 토큰 유저 권한 및 아이디 추가 */
            request.setAttribute("tokenUserRole", jwtTokenProvider.getUserGrade(requestToken));
            request.setAttribute("tokenUserId", jwtTokenProvider.getUserId(requestToken));
            request.setAttribute("token", requestToken);

            /* Redis DB에 저장된 토큰 추출 */
            final String redisToken = redisService.getData(tokenUserId);

            /* DB에 토큰이 존재하지 않을 경우 */
            if (redisToken == null) {
                throw new RedisNullTokenException(NULL_TOKEN);
            }

            /* DB 토큰과 로그인 유저 토큰 정보가 일치하지 않을 경우 */
            if (!redisToken.equals(requestToken)) {
                throw new TokenMismatchException(INVALID_TOKEN);
            }

            filterChain.doFilter(request, response);

        } /*catch (TokenEmptyException e) {
            setErrorResponse(response, EMPTY_TOKEN, e);
        }*/ catch (SignatureException e) {
            setErrorResponse(response, INVALID_SIGNATURE, e);
        } catch (RedisNullTokenException e) {
            setErrorResponse(response, NULL_TOKEN, e);
        } catch (TokenMismatchException e) {
            setErrorResponse(response, INVALID_TOKEN, e);
        } catch (ClassCastException e) {
            setErrorResponse(response, CLASS_CAST_FAIL, e);
        } catch (MalformedJwtException e) {
            setErrorResponse(response, MALFORMED_TOKEN, e);
        } catch (ExpiredJwtException e) {
            setErrorResponse(response, EXPIRED_TOKEN, e);
        } catch (UnsupportedJwtException e) {
            setErrorResponse(response, UNSUPPORTED_TOKEN, e);
        }
    }

    private void setErrorResponse(HttpServletResponse response, String message, Exception e) throws IOException {
        log.error(e.getMessage());
        response.setCharacterEncoding("utf-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.getWriter().write(message);
    }
}

@Component를 사용해 스프링 빈으로 등록할 수 있다.

 

필터를 등록하는 방식은 두 가지인데,

 

@Component를 이용 

-> 요청 URL 매핑 불가, @Order로 순서 지정 가능

 

@ServletComponentScan(Application 클래스에 지정) + @WebFilter

-> URL 매핑 가능, 순서 지정 불가

 

나는 첫 번째 방식을 따르기로 했다. 필터가 하나이지만, 애노테이션을 하나만 붙이는 게 더 깔끔하다고 생각이 들었기 때문이다.

 

예외 처리는 스프링 시큐리티를 사용하지 않고 있기 때문에, 예외 처리 필터를 사용하지 못했다.

직접 Try - Catch 문으로 예외를 잡아 처리해 응답 객체로 응답했다.

 

 

동일하게 예외 처리되어 잘 응답이 되는 것을 확인 가능하다.

 

후기

처음에 인증을 구현할 때 확실히 서블릿 필터에 대한 개념이 부족했던 것 같다. 

인터셉터만 사용해봤기 때문에 익숙한 인터셉터로 인증을 구현하지 않았나 싶다.

그 당시에는 낯선 개념에 막혀 안된다고 판단했었지만, 계속 공부하다 보니 익숙해져 한번 해볼 수 있겠다는 생각이 들었다. 막상 다시 해보니 개념이 확실히 정립된 상태여서 비교적 쉽게 구현이 됐다.

이게 발전인가..

그래도 안주하지 말고 흠 없게 하자!

 

반응형