관련 포스트

2023.07.13 - [Spring/Security] - 스프링) Spring Security Authentication/Authorization (23-06-29)

 

스프링) Spring Security Authentication/Authorization (23-06-29)

velog에서 이전한 글 입니다. Filter Spring의 life cycle이다. Security Spring Security도 많은 filter로 구성 돼있다. Security의 목적은 controller에서 인증/인가를 분리하고 filter단에서 손쉽게 처리하기 위함이다.

cornpip.tistory.com

Spring Security 예외처리

인증/인가 흐름

현재 구성한 인증/인가 흐름이다. 프론트에서 error msg를 요청했고 현재 흐름에서 핸들링 할 수 있는 부분을 찾고 처리했다.

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    ....
        http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        http.exceptionHandling((exceptionHandling) ->
                exceptionHandling
                        .authenticationEntryPoint(myAuthenticationEntryPoint())
                        .accessDeniedHandler(myAccessDeniedHandler())
        );
    ...
    	return http.build();
    }

 

버전은 boot-start-security:3.1.2 이고 필터와 에러 핸들링 설정은 위와 같다. 예외 처리를 하나씩 살펴보자

(a)

    switch (jwtUtil.validateToken(tokenValue)) {
        case "fail" -> {
            log.info("case fail");
            responseUtil.responseToExceptionResponseDto(response, HttpStatus.FORBIDDEN, "토큰 유효성 검증에 실패했습니다.");
        }
        ...
    }

유효성 검증에서 fail을 던지면 HttpServletResponse를 처리하고 filterChain.doFilter(request, response) 로 필터를 이어가지 않고 끝났다. 즉 에러 핸들링이 Security가 아닌 JwtAuthorizationFilter에서 진행된다.

(b)

@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
	...
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        log.info(failed.getMessage());
        responseUtil.responseToExceptionResponseDto(response, HttpStatus.UNAUTHORIZED, failed.getMessage());
    }
    ...
}

로그인 라우트로 들어온 경우 예외는 UsernamePasswordAuthenticationFilter 의 unsuccessfulAuthentication에서 핸들링한다. 이 경우 AuthenticationEntryPoint로 이어지지 않고 만약 unsuccessfulAuthentication을 오버라이딩하지 않았다면 AuthenticationEntryPoint에서 처리된다.

(c)

public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
    private final ResponseUtil responseUtil;

    @Autowired
    public MyAuthenticationEntryPoint(ResponseUtil responseUtil) {
        this.responseUtil = responseUtil;
    }

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        //인증 실패, 없는 라우트 등 잘못된 요청일 경우
        responseUtil.responseToExceptionResponseDto(response, HttpStatus.FORBIDDEN, "잘못된 요청입니다.");
    }
}

AuthenticationEntryPoint는.anyRequest().authenticated() 을 통과하지 못했을 때 최종 핸들링이라고 볼 수 있다.

통과 못 할 케이스는

  • 인가에서 setContext가 문제가 있을 때
  • permitAll이 아닌데 Context에 인증 정보가 비어있을 때
  • 없는 라우트이거나 요청 형식이 올바르지 않을 때

정도를 예상해 볼 수 있다.

(d)

public class MyAccessDeniedHandler implements AccessDeniedHandler {
    private final ResponseUtil responseUtil;

    @Autowired
    public MyAccessDeniedHandler(ResponseUtil responseUtil) {
        this.responseUtil = responseUtil;
    }

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        responseUtil.responseToExceptionResponseDto(response, HttpStatus.FORBIDDEN, "인가 실패");
    }
}

setContext가 정상적인 상태에서 인가가 허락되지 않는다면 처리하는 핸들링으로 ROLE_USER가 ROLE_ADMIN 라우트에 접근하는 케이스이다.


문제

현재 구성에서 문제는 (a)이다. 토큰이 있으면 permitAll이던 /authenticated route이던 인가 필터에서 에러핸들링을 하고 끝난다. 토큰이 있어도 permitAll과 login은 토큰 유효성과 무관하게 진행했으면 한다.

해결

    public static final String[] permitAllRouteArray = {
            "/api/user/signup", "/api/user/cors", "/api/user/kakao/login", "/swagger-ui/**", "/webjars/**",
            "/v3/api-docs/**", "/swagger-resources/**", "/api/file/**"
    };
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    	....
        http.authorizeHttpRequests((authorizeHttpRequest) ->
                        authorizeHttpRequest
                                .requestMatchers(permitAllRouteArray).permitAll()
                                .anyRequest().authenticated()
        );
        ....
	}

Security에 permitAll한 라우트를 담고있는 무언가가 있을 것 같지만 일단 Security에 위와 같이 static 으로 permitAll할 라우트를 선언 할당하고 requestMatchers를 설정했다.

@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    public static final String loginUrl = "/api/user/login";
    ...

    @Autowired
    public JwtAuthenticationFilter(JwtUtil jwtUtil, ResponseUtil responseUtil) {
    	...
        setFilterProcessesUrl(JwtAuthenticationFilter.loginUrl);
    }
    ...
}

login route도 static 변수를 선언 할당했다.

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String tokenValue = jwtUtil.getTokenFromRequest(request);
        boolean loginPass = JwtAuthenticationFilter.loginUrl.equals(request.getServletPath());
        boolean permitPass = Arrays.stream(WebSecurityConfig.permitAllRouteArray)
                .anyMatch(str -> str.equals(request.getServletPath()));

        if (loginPass || permitPass || !StringUtils.hasText(tokenValue)) {
            filterChain.doFilter(request, response);
        } else if (StringUtils.hasText(tokenValue)) {
        ...
    	}
    }

OncePerRequestFilter를 상속받은 JwtAuthorizationFilter 에서 위와같이 요청 라우트(request.getServletPath)가 loginUrl이나 permitAllRouteArray에 있는 라우트 중 하나와 같은지 확인하고 같은 경우 JwtAuthorizationFilter에서 유효성을 검증하고 setContext 하는 작업을 건너뛴다.