로그인 구현: #2. 식별자 검증

bien·2023년 9월 13일
0

프로젝트

목록 보기
2/5
post-thumbnail

저번 게시글에서 다양한 방법으로 식별자를 제공해보았다. 이번엔 필터와 인터셉터를 활용하여 식별자를 검증해보자!

필터와 인터셉터 관련 지식은 아래 게시글을 참조
[Spring] 로그인 처리2 - 필터, 인터셉터 (by.inflearn 스프링 강의)


스프링 인터셉터 (with session)

session 방식으로 부여된 식별자를 인터셉터를 통해 검증해보자!

스프링 인터셉터의 흐름

HTTP 요청 > WAS > 필터 > 서블릿 > 스프링 인터셉터 > 컨트롤러

📖 LoginCheckInterceptor.java

package com.study.admin.interceptor;

import com.study.admin.enums.SessionConst;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        String requestURI = request.getRequestURI();

        HttpSession session = request.getSession();

        if(session == null || session.getAttribute(SessionConst.LOGIN_ADMIN) == null) {
            // 로그인으로 redirect
            response.sendRedirect("/admin/login?redirectURL=" + requestURI);
            return false;
        }

        return true;
    }
}

📖 WebConfig

package com.study.admin;

import com.study.admin.argumentresolver.LoginAdminArgumentResolver;
import com.study.admin.interceptor.LogInterceptor;
import com.study.admin.interceptor.LoginCheckInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginAdminArgumentResolver());
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        registry.addInterceptor(new LoginCheckInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns(
                        "/", "/admin/add", "/admin/login",
                        "/admin/logout", "/css/**", "/*.ico",
                        "/error"
                );
    }

}

정말 간단히 세션과 관련된 로그인 로직을 구현할 수 있다. url 별로 인터셉터 적용 여부를 간단히 설정 가능하다는 점, preHandler, postHandler, afterCompletion으로 나뉘어 있는것이 정말 큰 장점이었다.


서블릿 필터 (with JWT)

처음에는 access token만 사용해서 구현하다가, refresh token도 함께 구현해보고 싶어서 추가해 구현했다. Spring Security 없이 jwt를 통한 인증 & 인가를 구현해 보았다.

📁 Jwt 관련 Exception

  • MalformedJwtException: 올바르지 않은 형식을 가진 토큰일 때 발생
  • UnsupportedJwtException: 지원되지 않는 클레임 유형 포함 시
  • IllegalArgumentException: 잘못된 인수 포함 시
  • SignatureException: 토큰의 서명 검증 과정 실패 시
  • ExpiredJwtException: 만료 시간(exp 클레임)이 지난 경우

나는 만료 시간이 지난 것에 대한 ExpiredJwtException은 Jwt가 기능적으로 가지고 있어 발생을 예측하고 대응해야 하는 예외이고, 그 외 Exception은 토큰 탈취의 위험성이 있는 예외로 느껴졌다. 따라서 ExpiredJwtException는 로직을 따라 처리하고, 그 외 로직은 로그아웃을 하는 방향으로 설계했다.

📁 Servlet Filter

서블릿 필터에는 몇가지 주의해야 하는 부분이 있다.

필터 흐름

HTTP 요청 > WAS(서버) > 필터 > 서블릿 > 컨트롤러

Spring MVC의 life cycle에 관한 그림이다. 보면 Filter는 Dispatcher Servlet의 바깥에 있다. 따라서 일반적으로 스프링 부트에서 제공하는 기능을 사용할 수 없다.

필터 예외 처리

Restful API로 프로젝트를 진행하면서, 발생할 수 있는 예외들에 대해 사용자 정의 예외를 추가하고 @ExceptionHandler를 이용해 예외를 처리했다. 그러나 filter는 당연히 스프링이 제공하는 해당 기능을 사용할 수 없다.

필터에서의 예외 처리는 어떻게 해야할까? 필터 체인으로 해결해야 한다.
예외를 발생시키고 던질 필터를, 예외를 처리할 필터로 감싸서 (더 앞에 둬서) 던져진 예외를 처리하도록 구현해야 한다.

필터 흐름

HTTP 요청 > WAS(서버) > ExceptionHandlerFilter(예외 처리) > JwtFilter(예외 발생 및 던지기) > 서블릿 > 컨트롤러

filter의 chain은 java에서 method call stack과 유사하다. chain.doFilter() 부분을 try-catch로 감싸주어 이후 진행되는 필터에서 던져지는 예외를 처리할 수 있다.

preflight, CORS

SpringBoot-Vue 프로젝트를 진행하면서 CORS와 관련해서 많은 오류를 경험했다. 배경지식이 전혀 없는 상태에 맨땅에 헤딩하듯이 구현을 해서 preflgiht의 존재를 눈치채는데에만 이틀 정도를 썼다. 여기에 적기에는 양이 많아 이후 따로 포스팅 하려고 한다.

CORS 대응책으로 Controller에서 @CrossOrigin(origins = "http://localhost:3030")를 통해 외부 도메인 접근을 허용해준다. filter에서 이게 될까? 안된다. vue에서 끝없는 CORS 지옥을 경험할 수 있다.

대신 filter에서 해당 코드들을 추가하면 된다.

response.setHeader("Access-Control-Allow-Origin", "http://localhost:3030");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With, ..."); // 필요한 헤더를 추가
response.setHeader("Access-Control-Allow-Credentials", "true"); // 필요에 따라 설정

참조) 교차 출처 리소스 공유 (CORS)

📜 시나리오

  1. 유효기간 이내의 Access Token을 서버에 보내면 필터를 통과한다.
  2. 만료된 Access Token을 서버에 보내면, 서버에서 만료 에러를 반환한다.
  3. 클라이언트에서 Refresh Token을 전송한다.
  4. 서버는 Refresh Token을 DB와 일치 여부를 확인하고, 이상이 없는 경우 Access Token을 재발급 한다.
  5. Refresh Token 검증에 실패한 경우, 로그아웃을 실행한다.

🔎 내가 설계한 구체적인 구현 방안

분명 더 좋은 방법이 많겠지만, 나는 이런식으로 설계를 해봤다.

Exception (사용자 정의 예외)

  • ExpiredAccessTokenException (ErrorCode 403)
    • access 토큰을 파싱하는 과정에 ExpiredJwtException 발생 시(access token이 만료된 경우) 해당 예외를 발생시킨다.
    • 이 예외를 response로 받는 경우, 갱신을 위해 클라이언트 측에서 refresh token을 전달한다.
  • InvalidTokenException (ErrorCode 401)
    • access 토큰, refresh 토큰을 파싱하는 과정에 Jwt 관련 예외 발생 시 해당 예외를 발생 시킨다.
    • 이 예외를 response로 받는 경우, 프론트 측에서 로그아웃을 실행한다.

Class

  • JwtFilter (access token 관련)
    • access 토큰이 유효한 경우, 이후 필터 체인을 진행시킨다.
    • access 토큰이 만료된 경우, ExpiredAccessTokenException(403)을 발생시킨다.
    • access 토큰이 유효하지 않은 경우, InvalidTokenException(401)을 발생시킨다.
  • ExceptionHandlerFilter ( 토큰 관련 에러 처리 )
    • filter.doChain을 try-catch문으로 감싸 두 종류의 error를 각각 처리한다.
  • RefreshController (refresh token 관련)
    • refresh 토큰이 유효하고, 만료되지 않았으며, db의 uuid와 일치할 경우 access 토큰을 갱신한다.
    • 그 외, InvalidTokenException(401)을 발생시킨다.

📖 ExceptionHandlerFilter

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

    private final ObjectMapper objectMapper;

    /**
     * 토큰 관련 에러 핸들링
     * - JwtFilter에서 발생하는 에러를 핸들링합니다.
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;

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

            log.error("ExceptionHandlerFilter: 정상적이지 않은 토큰입니다.");

            ErrorDTO errorDTO = new ErrorDTO(ErrorCode.INVALID_TOKEN);

            response.setStatus(HttpStatus.UNAUTHORIZED.value()); //401
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            objectMapper.writeValue(response.getWriter(), errorDTO);

        } catch (ExpiredAccessTokenException e) {

            log.error("ExceptionHandlerFilter: 만료된 access 토큰 입니다.");

            ErrorDTO errorDTO = new ErrorDTO(ErrorCode.EXPIRED_ACCESS_TOKEN);

            response.setStatus(HttpStatus.FORBIDDEN.value()); //403
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            objectMapper.writeValue(response.getWriter(), errorDTO);

        }
    }
}

두 개의 Error를 각각 처리하고 있다. (ErrorDTO에 직접 설정한 ErrorCode와 함께 HttpStatus도 변경해서 보냈다.)

📖 JwtFilter

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    /**
     * Jwt 관련 Utility
     */
    private final JwtUtil jwtUtil;

    /**
     * 비로그인 사용자도 접근 가능한 경로 리스트
     */
    private static final String[] whiteList = {"/", "/api/member/login", "/api/member/signup", "/api/member/checkId", "/api/refresh"};

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        log.info("JWT Filter 적용====================================================");

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        String jwt = jwtUtil.resolveToken(httpRequest);
        log.info("jwt = {}", jwt);

        String requestURI = httpRequest.getRequestURI();
        log.info("requestUri ={}", requestURI);
        log.info("requestMethod = {}", httpRequest.getMethod());

        response.setHeader("Access-Control-Allow-Origin", "http://localhost:3030");
        response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
        response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, UseRefreshToken");
        response.setHeader("Access-Control-Allow-Credentials", "true"); // 필요에 따라 설정


        // preflight(Option method)요청이거나 Get 요청인 경우, jwt 필터링을 생략합니다.
        if ("OPTIONS".equalsIgnoreCase(httpRequest.getMethod()) ||
                "GET".equalsIgnoreCase(httpRequest.getMethod())) {
            log.info("요청 메서드 = {}", httpRequest.getMethod());

            chain.doFilter(request, response);
            return;
        }

        if (isLoginCheckPath(requestURI)) {
            // 비로그인 사용자가 접근 불가능한 경로인 경우, jwt 검증을 시행합니다.

            try{
                jwtUtil.validateToken(jwt);

            } catch(ExpiredJwtException ex) {

                log.info("토큰 유효기간 만료");
                throw new ExpiredAccessTokenException();

            } catch (InvalidTokenException ex) {

                log.info("부적절한 토큰");
                throw ex;
            }

            // 검증에 성공한 경우 chain을 진행합니다.
            chain.doFilter(request, response);
            return;
        }

        // 비로그인 사용자가 접근 가능한 경로인 경우, 필터를 진행합니다.
        chain.doFilter(request, response);
        log.info("JwtFilter chain 밑");

    }

    /**
     * 화이트 리스트에 해당하는 경로인지 판별합니다.
     *
     * @param requestURL
     * @return 화이트 리스트에 포함 X : 인증체크 필요 = true
     * @return 화이트 리스트에 포함 O : 인증체크 불필요 = false
     */
    private boolean isLoginCheckPath(String requestURL) {
        return !PatternMatchUtils.simpleMatch(whiteList, requestURL);
    }
}

Preflight의 Option method 요청이거나 인증이 필요없는 Get 요청인 경우 jwt를 생략하고, Post와 Delete 요청에만 filter가 적용되도록 설정했다.

앞서 언급한 response.setHeader로 관련 설정을 넣어줘야 CORS 관련 에러를 피할 수 있다.

📖 WebConfig

@Configuration
public class WebConfig {

    @Bean
    public FilterRegistrationBean ExceptionHandlerFilter(ObjectMapper objectMapper) {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new ExceptionHandlerFilter(objectMapper));
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/*");
        return filterRegistrationBean;
    }

    @Bean
    public FilterRegistrationBean jwtFilter(JwtUtil jwtUtil) {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new JwtFilter(jwtUtil));
        filterRegistrationBean.setOrder(2);
        filterRegistrationBean.addUrlPatterns("/*");
        return filterRegistrationBean;
    }

}

에러를 감쌀 필터가 더 우선으로 실행되어야 한다.

📖 RefreshController

@Slf4j
@RestController
@RequiredArgsConstructor
@CrossOrigin(origins = "http://localhost:3030/", allowedHeaders = "UseRefreshToken")
@RestControllerAdvice
@RequestMapping("/api")
public class RefreshController {

   /**
    * Refresh 토큰 CRUD 관련 서비스
    */
   private final RefreshService refreshService;

   /**
    * jwt Utility
    */
   private final JwtUtil jwtUtil;

   @PostMapping("/refresh")
   public ResponseEntity<APIResult> validateRefreshToken(@RequestBody String id,
                                                         HttpServletRequest request) throws InvalidTokenException {
       log.info("id={}", id);

       String refreshToken = jwtUtil.resolveToken(request);
       String accessToken = null;

       try{
           // 토큰 유효성 검증
           jwtUtil.validateToken(refreshToken);
           // DB 데이터와 일치 여부 확인
           refreshService.validateRefreshTokenAgainstDb(id, refreshToken);
       } catch(InvalidTokenException | ExpiredJwtException ex) {
           log.info("refresh 토큰 검증 실패");
           ex.printStackTrace();
           refreshService.deleteByMemberId(id);
           throw ex;
       }

       // 토큰 유효 & db와 일치
       log.info("refresh 토큰 정보 디비와 일치");

       accessToken = jwtUtil.createAccessToken(id);
       log.info("생성한 accessToken = {}", accessToken);

       APIResult apiResult = new APIResult();
       apiResult.putResult("accessToken", accessToken);
       apiResult.setState(State.SUCCESS);

       return ResponseEntity.ok(apiResult);

   }

refresh 토큰 검증 및 갱신과 관련해서 하나의 Controller를 더 준비해 담당하도록 구현했다.

📖 GlobalExceptionHandler

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({InvalidTokenException.class, ExpiredJwtException.class})
    public ResponseEntity handleInvalidTokenException(InvalidTokenException ex) {
        log.info("handleInvalidTokenException 적용");

        ErrorDTO errorDTO = new ErrorDTO(ErrorCode.INVALID_TOKEN);

        APIResult apiResult = new APIResult();
        apiResult.setState(State.FAILURE);
        apiResult.putResult("errorDTO", errorDTO);
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(apiResult);
    }
}

RefreshController에서 던진 exception을 핸들링하는 코드를 구현했다.

⛑️ 개선사항

HTTP 요청 > WAS(서버) > 필터 > 서블릿 > 컨트롤러

요청의 흐름이 위와 같다고 배웠고, 예외 발생시 컨트롤러에서 WAS까지 던진다고 배웠다. 그래서 RefreshController에서 던진 예외를 ExceptionHandelrFilter에서 try-catch문을 통해 처리해줄것이라고 기대했다.(그래서 따로 예외를 handling하는 코드를 작성하지 않았었다.) 그러나 예상과 다르게 catch문으로 빠져 원하는 response를 반환하지 않고 500대 internal exception으로 응답했다. 이유를 찾아보려고 나름대로 오래 고민해봤는데 원인을 모르겠다. 일단 ExceptionHandler를 통해서 컨트롤러의 예외를 다뤄 프로그램자체는 잘 작동하지만, 원인을 알수 있었으면 좋겠다.

왜 controller에서 던지는 예외를 filter에서 try-catch문으로 처리할 수 없는걸까?


Reference

profile
Good Luck!

0개의 댓글