Spring Security ‐ Authorize HttpServletRequests - taeyun-ham/andalos GitHub Wiki

Authorize HttpServletRequests

스프링 시큐리티를 사용하면 요청 수준에서 권한을 모델링할 수 있습니다. 예를 들어, 스프링 시큐리티를 사용하면 /admin 아래의 모든 페이지가 하나의 권한을 요구하는 반면 다른 모든 페이지는 단순히 인증을 요구하도록 설정할 수 있습니다.

기본적으로 스프링 시큐리티는 모든 요청이 인증되어야 합니다. 그렇기 때문에 HttpSecurity 인스턴스를 사용할 때마다 권한 규칙을 선언해야 합니다.

HttpSecurity 인스턴스가 있을 때는 최소한 다음을 수행해야 합니다:

http
    .authorizeHttpRequests((authorize) -> authorize
        .anyRequest().authenticated()
    )

이는 스프링 시큐리티에 어플리케이션의 모든 엔드포인트가 최소한 인증된 보안 컨텍스트를 요구한다고 알려주는 것입니다.

많은 경우에, 귀하의 권한 규칙은 이보다 더 복잡할 것이므로, 다음 사용 사례를 고려해 보시기 바랍니다:

  • authorizeRequests를 사용하는 앱이 있고 authorizeHttpRequests로 마이그레이션하고 싶습니다.
  • AuthorizationFilter 컴포넌트가 어떻게 작동하는지 이해하고 싶습니다.
  • 특정 정규 표현식을 기반으로 요청을 매치하고 싶습니다.
  • 요청을 매치하고 싶고, Spring MVC를 기본 서블릿이 아닌 다른 것에 매핑하고 싶습니다.
  • 요청을 승인하고 싶습니다.
  • 프로그래매틱하게 요청을 매치하고 싶습니다.
  • 프로그래매틱하게 요청을 승인하고 싶습니다.
  • 요청 승인을 정책 에이전트에 위임하고 싶습니다.

이러한 사례들을 처리하기 위해 스프링 시큐리티는 다양한 구성과 메소드를 제공합니다. 각 사용 사례에 적합한 설정을 적용함으로써 보다 세밀하게 보안 정책을 관리할 수 있습니다.

Understanding How Request Authorization Components Work

이 섹션은 서블릿 기반 애플리케이션에서 요청 수준에서 권한 부여가 어떻게 작동하는지에 대해 더 깊이 파고들면서 서블릿 아키텍처 및 구현에 대해 확장합니다.

authorizationfilter

  1. 먼저, AuthorizationFilter는 SecurityContextHolder에서 인증을 검색하는 Supplier를 구성합니다.
  2. 그 다음, Supplier와 HttpServletRequest를 AuthorizationManager에 전달합니다. AuthorizationManager는 요청을 authorizeHttpRequests의 패턴과 일치시키고 해당 규칙을 실행합니다.
  3. 권한 부여가 거부되면 AuthorizationDeniedEvent가 발행되고 AccessDeniedException이 발생합니다. 이 경우 ExceptionTranslationFilter가 AccessDeniedException을 처리합니다.
  4. 접근이 허용되면 AuthorizationGrantedEvent가 발행되고 AuthorizationFilter는 애플리케이션이 정상적으로 처리될 수 있도록 FilterChain과 함께 계속 진행됩니다.

AuthorizationFilter Is Last By Default

AuthorizationFilter는 기본적으로 스프링 시큐리티 필터 체인의 마지막에 위치합니다. 이는 스프링 시큐리티의 인증 필터, 취약점 보호 및 기타 필터 통합이 권한 부여를 요구하지 않음을 의미합니다. 만약 당신이 AuthorizationFilter 이전에 자체 필터를 추가한다면, 이들 역시 권한 부여를 요구하지 않게 됩니다; 그렇지 않다면 요구하게 됩니다.

이것이 특히 중요해지는 경우는 스프링 MVC 엔드포인트를 추가할 때입니다. 이들은 DispatcherServlet에 의해 실행되며 이것은 AuthorizationFilter 이후에 오기 때문에, 당신의 엔드포인트는 허용되기 위해 authorizeHttpRequests에 포함되어야 합니다.

All Dispatches Are Authorized

AuthorizationFilter는 모든 요청뿐만 아니라 모든 디스패치에 대해서도 실행됩니다. 이는 REQUEST 디스패치뿐만 아니라 FORWARD, ERROR, INCLUDE에도 권한 부여가 필요함을 의미합니다.

예를 들어, 스프링 MVC는 요청을 Thymeleaf 템플릿을 렌더링하는 뷰 리졸버로 FORWARD할 수 있습니다. 다음과 같은 예시입니다:

@Controller
public class MyController {
    @GetMapping("/endpoint")
    public String endpoint() {
        return "endpoint";
    }
}

이 경우, 권한 부여가 두 번 발생합니다; 한 번은 /endpoint를 인증하기 위해, 그리고 다른 한 번은 "endpoint" 템플릿을 렌더링하기 위해 Thymeleaf로 포워딩을 인증하기 위해입니다.

이러한 이유로 모든 FORWARD 디스패치를 허용하고자 할 수 있습니다.

이 원칙의 또 다른 예는 스프링 부트가 에러를 처리하는 방식입니다. 컨테이너가 예외를 잡는 경우, 다음과 같은 상황에서:

@Controller
public class MyController {
    @GetMapping("/endpoint")
    public String endpoint() {
        throw new UnsupportedOperationException("unsupported");
    }
}

그러면 Boot는 ERROR 디스패치로 해당 사항을 전달합니다.

이 경우에도 권한 부여가 두 번 발생합니다; 한 번은 /endpoint를 인증하기 위해, 그리고 다른 한 번은 에러를 디스패치하기 위해입니다.

이러한 이유로 모든 ERROR 디스패치를 허용하고자 할 수 있습니다.

Authentication Lookup is Deferred

AuthorizationManager API는 Supplier을 사용한다는 것을 기억하세요.

이는 요청이 항상 허용되거나 항상 거부될 때 authorizeHttpRequests에 중요합니다. 이러한 경우에는 인증이 조회되지 않아 요청이 더 빠르게 처리됩니다.

Authorizing an Endpoint

우선 순위에 따라 더 많은 규칙을 추가함으로써 다른 규칙을 가진 스프링 시큐리티를 구성할 수 있습니다.

/endpoin만 USER 권한을 가진 최종 사용자에 의해 접근 가능하도록 요구하고 싶다면 다음과 같이 할 수 있습니다:

@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
	http
		.authorizeHttpRequests((authorize) -> authorize
			.requestMatchers("/endpoint").hasAuthority("USER")
			.anyRequest().authenticated()
		)
        // ...

	return http.build();
}

보시다시피, 선언은 패턴/규칙 쌍으로 나눌 수 있습니다.

AuthorizationFilter는 나열된 순서대로 이러한 쌍을 처리하며 요청에 첫 번째 일치하는 항목만 적용합니다. 이는 /** 도 /endpoint에 일치할 수 있지만 위의 규칙이 문제가 되지 않는다는 것을 의미합니다. 위의 규칙을 읽는 방법은 "요청이 /endpoint인 경우 USER 권한을 요구하고, 그렇지 않으면 인증만 요구한다"입니다.

스프링 시큐리티는 여러 패턴과 여러 규칙을 지원하며, 각각을 프로그래밍 방식으로 직접 생성할 수도 있습니다.

인증이 완료되면 다음과 같은 방법으로 시큐리티의 테스트 지원을 사용하여 테스트할 수 있습니다:

@WithMockUser(authorities="USER")
@Test
void endpointWhenUserAuthorityThenAuthorized() {
    this.mvc.perform(get("/endpoint"))
        .andExpect(status().isOk());
}

@WithMockUser
@Test
void endpointWhenNotUserAuthorityThenForbidden() {
    this.mvc.perform(get("/endpoint"))
        .andExpect(status().isForbidden());
}

@Test
void anyWhenUnauthenticatedThenUnauthorized() {
    this.mvc.perform(get("/any"))
        .andExpect(status().isUnauthorized());
}

Matching Requests

위에서 요청을 일치시키는 두 가지 방법을 이미 보셨습니다.

첫 번째는 가장 간단하며 모든 요청에 일치합니다.

두 번째는 URI 패턴을 사용하여 일치시키는 것입니다. 스프링 시큐리티는 URI 패턴 매칭을 위해 두 가지 언어를 지원합니다: Ant(위에서 본 것처럼) 및 정규 표현식.

Matching Using Ant

Ant는 스프링 시큐리티가 요청을 매칭하기 위해 사용하는 기본 언어입니다.

단일 엔드포인트 또는 디렉토리와 일치시킬 수 있으며, 나중에 사용할 수 있는 자리 표시자를 캡처할 수도 있습니다. 특정 HTTP 메소드 세트와 일치하도록 세부 조정할 수도 있습니다.

예를 들어, /endpoint 엔드포인트와 일치시키는 것이 아니라 /resource 디렉토리 아래의 모든 엔드포인트와 일치시키고 싶다고 가정해 보겠습니다. 그 경우 다음과 같이 할 수 있습니다:

http
    .authorizeHttpRequests((authorize) -> authorize
        .requestMatchers("/resource/**").hasAuthority("USER")
        .anyRequest().authenticated()
    )

이것을 해석하는 방법은 "요청이 /resource 또는 그 하위 디렉토리인 경우 USER 권한을 요구하고, 그렇지 않으면 인증만 요구한다"입니다.

아래에서 볼 수 있듯이 요청에서 경로 값을 추출할 수도 있습니다:

http
    .authorizeHttpRequests((authorize) -> authorize
        .requestMatchers("/resource/{name}").access(new WebExpressionAuthorizationManager("#name == authentication.name"))
        .anyRequest().authenticated()
    )

인증이 완료되면, 다음과 같은 방법으로 보안의 테스트 지원을 사용하여 테스트할 수 있습니다:

@WithMockUser(authorities="USER")
@Test
void endpointWhenUserAuthorityThenAuthorized() {
    this.mvc.perform(get("/endpoint/jon"))
        .andExpect(status().isOk());
}

@WithMockUser
@Test
void endpointWhenNotUserAuthorityThenForbidden() {
    this.mvc.perform(get("/endpoint/jon"))
        .andExpect(status().isForbidden());
}

@Test
void anyWhenUnauthenticatedThenUnauthorized() {
    this.mvc.perform(get("/any"))
        .andExpect(status().isUnauthorized());
}

스프링 시큐리티는 경로만을 일치시킵니다. 쿼리 매개변수와 일치시키고 싶다면 사용자 정의 요청 매처가 필요합니다.

Matching Using Regular Expressions

스프링 시큐리티는 정규 표현식을 사용하여 요청과 일치시킬 수 있습니다. 이는 하위 디렉토리에 ** 보다 더 엄격한 일치 기준을 적용하고 싶을 때 유용할 수 있습니다.

예를 들어, 사용자 이름을 포함하는 경로가 있고 모든 사용자 이름이 알파벳과 숫자로만 구성되어야 한다는 규칙을 고려해 보세요. 이 규칙을 준수하기 위해 RegexRequestMatcher를 다음과 같이 사용할 수 있습니다:

http
    .authorizeHttpRequests((authorize) -> authorize
        .requestMatchers(RegexRequestMatcher.regexMatcher("/resource/[A-Za-z0-9]+")).hasAuthority("USER")
        .anyRequest().denyAll()
    )

Matching By Http Method

또한 HTTP 메소드별로 규칙을 일치시킬 수 있습니다. 이는 읽기 또는 쓰기 권한과 같은 특정 권한이 부여된 경우 권한 부여에 유용합니다.

모든 GET 요청에 읽기 권한이 있어야 하고 모든 POST 요청에 쓰기 권한이 있어야 한다면 다음과 같이 설정할 수 있습니다:

http
    .authorizeHttpRequests((authorize) -> authorize
        .requestMatchers(HttpMethod.GET).hasAuthority("read")
        .requestMatchers(HttpMethod.POST).hasAuthority("write")
        .anyRequest().denyAll()
    )

이러한 권한 부여 규칙은 다음과 같이 해석됩니다: "요청이 GET인 경우, 읽기 권한을 요구하고; 요청이 POST인 경우, 쓰기 권한을 요구하며; 그 외의 경우 요청을 거부합니다."

기본적으로 요청을 거부하는 것은 보안 규칙을 허용 목록으로 전환시키기 때문에 건전한 보안 관행입니다.

인증이 완료되면, 다음과 같은 방법으로 보안의 테스트 지원을 사용하여 테스트할 수 있습니다:

@WithMockUser(authorities="read")
@Test
void getWhenReadAuthorityThenAuthorized() {
    this.mvc.perform(get("/any"))
        .andExpect(status().isOk());
}

@WithMockUser
@Test
void getWhenNoReadAuthorityThenForbidden() {
    this.mvc.perform(get("/any"))
        .andExpect(status().isForbidden());
}

@WithMockUser(authorities="write")
@Test
void postWhenWriteAuthorityThenAuthorized() {
    this.mvc.perform(post("/any").with(csrf()))
        .andExpect(status().isOk());
}

@WithMockUser(authorities="read")
@Test
void postWhenNoWriteAuthorityThenForbidden() {
    this.mvc.perform(get("/any").with(csrf()))
        .andExpect(status().isForbidden());
}

Matching By Dispatcher Type

앞서 언급했듯이, 스프링 시큐리티는 기본적으로 모든 디스패처 타입에 대해 권한을 부여합니다. 그리고 REQUEST 디스패치에서 설정된 보안 컨텍스트는 후속 디스패치로 이어지지만, 때로는 미묘한 불일치로 인해 예상치 못한 AccessDeniedException이 발생할 수 있습니다.

이를 해결하기 위해, FORWARD와 ERROR와 같은 디스패처 타입을 허용하도록 스프링 시큐리티 자바 설정을 구성할 수 있습니다. 다음과 같이 설정할 수 있습니다:

http {
    authorizeHttpRequests {
        authorize(DispatcherTypeRequestMatcher(DispatcherType.FORWARD), permitAll)
        authorize(DispatcherTypeRequestMatcher(DispatcherType.ERROR), permitAll)
        authorize("/endpoint", permitAll)
        authorize(anyRequest, denyAll)
    }
}

Using an MvcRequestMatcher

일반적으로 위에서 보여준 것처럼 requestMatchers(String)를 사용할 수 있습니다.

그러나 Spring MVC를 다른 서블릿 경로에 매핑하는 경우, 보안 설정에서 이를 고려해야 합니다.

예를 들어, Spring MVC가 기본값인 / 대신 /spring-mvc에 매핑된 경우, 권한을 부여하고 싶은 /spring-mvc/my/controller와 같은 엔드포인트가 있을 수 있습니다.

이 경우, 서블릿 경로와 컨트롤러 경로를 구성에서 분리하기 위해 MvcRequestMatcher를 사용해야 합니다. 다음과 같이 설정할 수 있습니다:

@Bean
MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) {
	return new MvcRequestMatcher.Builder(introspector).servletPath("/spring-mvc");
}

@Bean
SecurityFilterChain appEndpoints(HttpSecurity http, MvcRequestMatcher.Builder mvc) {
	http
        .authorizeHttpRequests((authorize) -> authorize
            .requestMatchers(mvc.pattern("/my/controller/**")).hasAuthority("controller")
            .anyRequest().authenticated()
        );

	return http.build();
}

이러한 필요성은 적어도 두 가지 방식으로 발생할 수 있습니다:

  • spring.mvc.servlet.path 부트 프로퍼티를 사용하여 기본 경로(/)를 다른 것으로 변경하는 경우
  • 두 개 이상의 스프링 MVC DispatcherServlet을 등록하는 경우 (이 중 하나가 기본 경로가 아닌 경우 필요)

Authorizing Requests

요청이 일치하면 permitAll, denyAll, hasAuthority와 같이 이미 본 여러 방법으로 권한을 부여할 수 있습니다.

간단히 요약하자면, DSL에 내장된 권한 부여 규칙은 다음과 같습니다:

  • permitAll - 요청은 권한 부여가 필요 없으며 공개 엔드포인트입니다; 이 경우, 인증은 세션에서 결코 검색되지 않습니다.
  • denyAll - 어떠한 경우에도 요청이 허용되지 않습니다; 이 경우, 인증은 세션에서 결코 검색되지 않습니다.
  • hasAuthority - 요청은 인증이 주어진 값과 일치하는 GrantedAuthority를 가지고 있어야 합니다.
  • hasRole - hasAuthority의 단축형으로 ROLE_ 또는 기본적으로 구성된 접두어를 추가합니다.
  • hasAnyAuthority - 요청은 인증이 주어진 값 중 하나와 일치하는 GrantedAuthority를 가지고 있어야 합니다.
  • hasAnyRole - hasAnyAuthority의 단축형으로 ROLE_ 또는 기본적으로 구성된 접두어를 추가합니다.
  • access - 요청은 이 사용자 정의 AuthorizationManager를 사용하여 접근을 결정합니다.

이제 패턴, 규칙, 그리고 그들이 어떻게 함께 짝지어질 수 있는지를 배웠으므로, 이보다 더 복잡한 예제에서 무슨 일이 일어나고 있는지 이해할 수 있어야 합니다:

@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
	http
		// ...
		.authorizeHttpRequests(authorize -> authorize                                  
            .dispatcherTypeMatchers(FORWARD, ERROR).permitAll() 
			.requestMatchers("/static/**", "/signup", "/about").permitAll()         
			.requestMatchers("/admin/**").hasRole("ADMIN")                             
			.requestMatchers("/db/**").access(allOf(hasAuthority("db"), hasRole("ADMIN")))   
			.anyRequest().denyAll()                                                
		);

	return http.build();
}
  1. 여러 권한 부여 규칙이 지정되어 있습니다. 각 규칙은 선언된 순서대로 고려됩니다.
  2. FORWARD와 ERROR 디스패치는 스프링 MVC가 뷰를 렌더링하고 스프링 부트가 오류를 렌더링할 수 있도록 허용됩니다.
  3. 모든 사용자가 접근할 수 있는 여러 URL 패턴을 지정했습니다. 특히, URL이 "/static/", "/signup", "/about"과 일치하는 경우 모든 사용자가 요청에 접근할 수 있습니다.
  4. "/admin/"으로 시작하는 모든 URL은 "ROLE_ADMIN" 역할을 가진 사용자에게 제한됩니다. hasRole 메소드를 호출하기 때문에 "ROLE_" 접두어를 지정할 필요가 없음을 알 수 있습니다.
  5. "/db/"로 시작하는 모든 URL은 사용자가 "db" 권한을 부여받았을 뿐만 아니라 "ROLE_ADMIN"이어야 합니다. hasRole 표현을 사용하기 때문에 "ROLE_" 접두어를 지정할 필요가 없음을 알 수 있습니다.
  6. 아직 일치하지 않은 모든 URL은 접근이 거부됩니다. 이는 권한 부여 규칙을 업데이트하는 것을 우연히 잊어버리고 싶지 않은 경우 좋은 전략입니다.