개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

공부 › Spring › 그 밖의 기능

시큐리티 필터 체인

401과 403은 어디에서 갈리나

스프링 시큐리티는 컨트롤러 앞에 놓인 필터들의 줄입니다. 앞쪽 필터가 "누구인지"를 확인해 기록해 두고, 뒤쪽 필터가 "이 경로에 들어와도 되는지"를 판단합니다. 누구인지 확인되지 않았으면 401, 누구인지는 알지만 권한이 없으면 403으로 응답합니다. 토큰과 경로에 따라 요청이 어디에서 막히고 어떤 응답으로 끝나는지 봅니다.

실행으로 대조 · 스프링 부트 4.1.1과 시큐리티 7.1.1 서버에 열두 가지 요청을 보내 대조

이 실습에 나오는 개념

인증과 인가
인증(Authentication)은 사용자가 누구인지 확인하는 것이고, 인가(Authorization)는 확인된 사용자가 특정 자원이나 기능을 사용할 권한이 있는지 판단하는 것이다. 인증이 먼저 이뤄지고 그 결과를 바탕으로 인가를 판단한다. HTTP 상태 코드로는 인증 실패가 401, 인가 실패가 403에 해당한다.
Spring Security와 SecurityFilterChain
Spring Security는 인증과 인가를 처리하는 스프링의 보안 프레임워크로, 서블릿 필터들의 묶음으로 동작한다. SecurityFilterChain은 어떤 요청에 어떤 보안 필터들을 어떤 순서로 적용할지 정의한 것이다. 요청이 컨트롤러에 도달하기 전에 필터들이 차례로 인증 정보 확인, 권한 검사 등을 수행한다.
SecurityContext
현재 요청을 보낸 사용자의 인증 정보(누구인지, 어떤 권한이 있는지)를 담아 두는 Spring Security의 저장소다. 인증에 성공하면 이곳에 정보가 저장되고, 이후 컨트롤러나 서비스에서 현재 사용자를 조회할 때 여기서 꺼낸다. 기본적으로 스레드별로 보관된다.
필터(Filter)
서블릿 컨테이너 수준에서 요청과 응답을 가로채 처리하는 구성 요소로 서블릿 표준에 속한다. 요청이 DispatcherServlet에 도달하기 전과 응답이 돌아가는 길에 동작하며, 인코딩 설정, CORS 처리, 인증 같은 모든 요청에 걸친 작업에 쓴다. Spring Security도 필터들의 묶음으로 동작한다.
HTTP 상태 코드
서버가 요청을 처리한 결과를 나타내는 세 자리 숫자다. 2xx는 성공, 3xx는 다른 위치로의 이동(리다이렉션), 4xx는 클라이언트의 요청에 문제가 있음, 5xx는 서버 쪽 오류를 뜻한다. 200(성공), 201(생성됨), 400(잘못된 요청), 401(인증 필요), 403(권한 없음), 404(없음), 500(서버 오류)이 자주 쓰인다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. GET /orders 요청이 들어와 시큐리티 필터 체인에 진입합니다. 컨트롤러보다 앞, 서블릿 필터 단계입니다.
  2. JwtAuthenticationFilter: Authorization 헤더에 토큰이 없습니다. 아무것도 하지 않고 다음 필터로 넘깁니다. 여기서 바로 거절하지 않는 이유는, 인증이 필요 없는 경로일 수도 있기 때문입니다.
  3. AnonymousAuthenticationFilter: 여기까지 인증 정보가 없으면 "익명 사용자"로 표시해 둡니다.
  4. AuthorizationFilter: 이 경로의 규칙은 authenticated (인증된 사용자만)입니다. 현재 사용자는 익명이라 조건에 맞지 않습니다. 접근 거부 예외를 던집니다.
  5. ExceptionTranslationFilter: 예외를 받아 사용자를 확인합니다. 익명 사용자이므로 "권한이 없다"가 아니라 "누구인지부터 밝혀라"로 판단하고, AuthenticationEntryPoint를 호출해 401로 응답합니다.
  6. 응답이 나가고, 이 요청에 쓰인 SecurityContext는 비워집니다. 스레드에 묶어 두는 정보라서, 스레드가 다음 요청에 재사용되기 전에 지워야 합니다.

실행 결과

코드

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/public/**").permitAll()
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated())
        .addFilterBefore(jwtAuthenticationFilter,
                         UsernamePasswordAuthenticationFilter.class)
        .exceptionHandling(e -> e
            .authenticationEntryPoint((req, res, ex) -> res.setStatus(401))   // 인증 안 됨
            .accessDeniedHandler((req, res, ex) -> res.setStatus(403)))       // 권한 없음
        .build();
}

// JwtAuthenticationFilter: Authorization 헤더의 토큰을 검증하고
// 유효하면 SecurityContext에 인증 정보를 넣는다

이 모형의 전제

세션을 쓰지 않는 JWT 방식의 일반적인 구성을 가정했습니다. 직접 만든 JwtAuthenticationFilter는 토큰이 없거나 잘못된 경우 거절하지 않고 넘긴다고 보았습니다(잘못된 토큰이면 이 필터에서 바로 401을 내도록 구현하기도 합니다). 열두 가지 조합 모두를 실제 스프링 부트 4.1.1과 스프링 시큐리티 7.1.1 서버에 HTTP 요청을 보내, 응답 코드와 응답을 만든 곳(AuthenticationEntryPoint, AccessDeniedHandler), 컨트롤러 실행 여부가 화면과 일치하는 것을 확인했습니다. 이때 두 가지를 알게 됐습니다. 첫째, 인증 실패 때의 응답을 정하지 않으면(exceptionHandling 설정이 없으면) 토큰 없는 요청에 401이 아니라 403이 돌아옵니다. 그래서 화면의 코드에 exceptionHandling 설정을 넣었습니다. 둘째, 권한 부족을 스프링 시큐리티의 기본 처리기에 맡기면 USER가 관리자 경로를 요청했을 때 403이 아니라 401이 돌아왔습니다. 기본 처리기가 오류 페이지(/error)로 요청을 다시 넘기는데, 그 요청에는 인증 정보가 없어 다시 인증 실패로 처리되기 때문입니다. 화면처럼 403이 나오게 하려면 처리기에서 상태 코드를 직접 응답하거나 /error 경로를 허용해야 합니다. 실제 체인에는 여기 적지 않은 필터가 더 있습니다.

앞 실습: 캐시 추상화

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요