2024. 3. 2. 13:08ㆍSpring Security
이 장에서 다루는 내용:
- matcher 메소드를 사용하여 제한을 적용할 request 선택
- 각 matcher 메소드에 대한 최적의 시나리오 학습
7장에서는 권한과 Role을 기반으로 접근을 구성하는 방법을 배웠습니다. 하지만 모든 엔드포인트에 대한 구성만 적용했습니다. 이 장에서는 특정 그룹의 요청에 대한 권한 부여[인가] 제약 조건을 적용하는 방법을 배울 것입니다. 프로덕션 애플리케이션에서는 모든 요청에 동일한 규칙을 적용할 가능성은 낮습니다. 일부 특정 사용자만 호출할 수 있는 엔드포인트가 있는 반면, 다른 엔드포인트는 모든 사람이 접근할 수 있습니다. 각 애플리케이션은 비즈니스 요구 사항에 따라 자체적인 커스텀 인증 구성을 가지고 있습니다. 액세스 구성을 작성할 때 다양한 요청을 참조하는 옵션에 대해 논의해 보겠습니다.
우리가 주목하지 않았지만, 여러분이 사용한 첫 번째 matcher 메소드는 anyRequest() 메소드였습니다. 이전 장에서 사용했듯이, 이것은 path나 HTTP method에 관계없이 모든 요청을 의미합니다. 이것은 "어떤 요청이든" 또는 가끔 "다른 어떤 요청이든"이라고 말하는 방법입니다.
먼저 path 별로 요청을 선택하는 것에 대해 이야기해 보겠습니다; 그런 다음 시나리오에 HTTP method를 추가할 수 있습니다. 권한 부여 구성을 적용할 요청을 선택하기 위해 requestMatchers() 메소드를 사용합니다.
8.1 Using the requestMatchers() method to select endpoints
이 섹션에서는 requestMatchers() 메소드를 일반적으로 사용하는 방법을 배워서, 8.2부터 8.4 섹션까지 다양한 접근 방식을 설명할 수 있게 됩니다. 여러분은 이 장을 마칠 때까지 여러분의 애플리케이션 요구 사항에 맞는 인증 설정에 requestMatchers() 메소드를 적용할 수 있게 됩니다. 간단한 예제로 시작해 봅시다. 우리는 /hello 와 /ciao 두 개의 엔드포인트를 노출하는 애플리케이션을 만듭니다. ADMIN 역할을 가진 사용자만 /hello 엔드포인트를 호출할 수 있도록 하고 싶습니다. 마찬가지로, MANAGER 역할을 가진 사용자만 /ciao 엔드포인트를 호출할 수 있도록 하고 싶습니다. 이 예제는 ssia-ch8-ex1 프로젝트에서 찾을 수 있습니다. 다음 리스팅은 컨트롤러 클래스의 정의를 제공합니다.
Listing 8.1 The definition of the controller class
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello!";
}
@GetMapping("/ciao")
public String ciao() {
return "Ciao!";
}
}
구성 클래스에서, 우리는 InMemoryUserDetailsManager를 우리의 UserDetailsService 인스턴스로 선언하고 다른 역할을 가진 두 명의 사용자를 추가합니다. 사용자 John은 ADMIN 역할을 가지고 있으며, Jane은 MANAGER 역할을 가집니다. ADMIN 역할을 가진 사용자만 /hello 엔드포인트를 호출할 수 있도록 요청을 인증할 때, 우리는 requestMatchers() 메소드를 사용합니다. 다음 목록은 구성 클래스의 정의를 제시합니다.
Listing 8.2 The definition of the configuration class
@Configuration
public class ProjectConfig {
@Bean
public UserDetailsService userDetailsService() {
var manager = new InMemoryUserDetailsManager();
var user1 = User.withUsername("john")
.password("12345")
.roles("ADMIN")
.build();
var user2 = User.withUsername("jane")
.password("12345")
.roles("MANAGER")
.build();
manager.createUser(user1);
manager.createUser(user2);
return manager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.httpBasic(Customizer.withDefaults());
http.authorizeHttpRequests(
c -> c.requestMatchers("/hello").hasRole("ADMIN") # A
.requestMatchers("/ciao").hasRole("MANAGER") # B
.anyRequest().permitAll()
//.anyRequest().denyAll()
//.anyRequest().authenticated()
);
return http.build();
}
}
#A Only calls the path /hello if the user has the ADMIN role
#B Only calls the path /ciao if the user has the MANAGER role
이 애플리케이션을 실행하고 테스트할 수 있습니다. 사용자 John으로 엔드포인트 /hello를 호출하면, 성공적인 응답을 받습니다. 하지만 동일한 엔드포인트를 사용자 Jane으로 호출하면, 응답 상태가 HTTP 403 Forbidden으로 반환됩니다. 마찬가지로, 엔드포인트 /ciao에 대해서는 Jane을 사용해서만 성공적인 결과를 얻을 수 있습니다. 사용자 John에 대해서는 응답 상태가 HTTP 403 Forbidden으로 반환됩니다. 다음 코드 스니펫에서 cURL을 사용한 예제 호출을 볼 수 있습니다. 사용자 John을 위한 엔드포인트 /hello를 호출하려면, 사용하세요
이제 아래 코드처럼 애플리케이션에 다른 엔드포인트(/hola)를 추가하면, 기본적으로 누구나 접근할 수 있게 되며, 인증되지 않은 사용자도 포함됩니다.(현재 /hola 엔드포인트는 requestMatchers로 설정하지 않았으므로)
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello!";
}
@GetMapping("/ciao")
public String ciao() {
return "Ciao!";
}
@GetMapping("/hola")
public String hola() {
return "Hola!";
}
}
이제 이 새로운 엔드포인트[/hola]에 접근하면, 유효한 사용자가 있든 없든 접근할 수 있음을 알 수 있습니다. 다음 코드 스니펫은 이러한 동작을 보여줍니다. 인증 없이 엔드포인트 /hola를 호출하려면, cURL로 테스트해 보세요
원하는 경우 permitAll() 메서드를 사용하여 이 동작[ 인증없이 /hola 엔드포인트 액세스하는 것 ]을 더 눈에 띄게 만들 수 있습니다.
리스팅 8.4에 제시된 대로 요청 인가를 위한 구성 체인 끝에 있는 anyRequest() 매처 메소드를 사용하여 이[눈에 띄게 만드는]를 수행합니다.
참고: 모든 규칙을 명시적으로 만드는 것이 좋은 관행입니다. 리스팅 목록은 /hello와 /ciao 엔드포인트를 제외하고 모든 User에게 엔드포인트에 대한 요청을 허용하려는 의도를 모호하지 않게 명확하게 나타냅니다.
Listing 8.4 Marking additional requests explicitly as accessible without authentication
@Configuration
public class ProjectConfig {
// Omitted code
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.httpBasic(Customizer.withDefaults());
http.authorizeHttpRequests(
c -> c.requestMatchers("/hello").hasRole("ADMIN")
.requestMatchers("/ciao").hasRole("MANAGER")
.anyRequest().permitAll() # A
);
return http.build();
}
}
#A permitAll() 메소드는 인증 없이 모든 다른 요청이 허용된다고 명시합니다.
참고 matchers를 사용하여 요청을 참조하는 경우 규칙의 순서는 특정 항목에서 일반 항목으로 이루어져야 합니다. 이것이 바로 anyRequest() 메서드가 더 구체적인 requestMatchers() 메서드보다 먼저 호출될 수 없는 이유입니다.
위 참고에서 언급한 "참조한다"는 용어는 특정 요청을 식별하고 선택하여 그 요청에 권한 부여 구성을 적용하는 과정을 의미합니다. 이 맥락에서, 경로 표현식 문법을 사용한다는 것은 애플리케이션의 보안 설정 내에서 특정 HTTP 요청 경로(예: URL 패턴)를 정의하고, 그러한 경로에 대해 특정 권한 부여 규칙(예: 접근 허용, 접근 거부)을 적용하는 방식을 말합니다. 이는 개발자가 애플리케이션의 보안 요구 사항에 맞게 특정 엔드포인트에 대한 접근 제어를 세밀하게 설정할 수 있게 해줍니다.
UNAUTHENTICATED VS. FAILED AUTHENTICATION
누구나 접근할 수 있도록 엔드포인트를 설계했다면, 인증을 위한 사용자 이름과 비밀번호를 제공하지 않고도 호출할 수 있습니다. 이 경우, Spring Security는 인증을 수행하지 않습니다. 하지만, 이러한 엔드포인트에 대한 액세스 요청에 사용자 이름과 비밀번호를 제공한다면, Spring Security는 인증 과정에서 이를 평가합니다. 만약 제공한 credential들이 잘못되었다면(시스템에 알려지지 않은 경우), 인증에 실패하고 응답 상태는 401 Unauthorized가 됩니다. 더 정확히 말하자면, 리스팅 8.4에 제시된 구성으로 /hola 엔드포인트를 호출하면, 앱은 예상대로 본문 "Hola!"를 반환하고 응답 상태는 200 OK입니다.
하지만 유효하지 않은 자격 증명으로 이 엔드포인트를 호출하면, 응답의 상태는 401 Unauthorized가 됩니다.
이 프레임워크의 동작은 이상해 보일 수 있지만, 요청에 사용자 이름과 비밀번호를 제공[예를 들어 Http Basic 인증 방식]한다면 프레임워크가 이를 평가하기 때문에 의미가 있습니다. 7장에서 배운 것처럼, 이 그림이 보여주듯이 애플리케이션은 항상 인증을 권한 부여 전에 수행합니다.
현재 권한 부여[Authorization] 필터는 /hola 경로로의 모든 요청을 허용합니다. 하지만 애플리케이션은 먼저 인증 로직을 실행하기 때문에, 요청은 권한 부여 필터로 전달되지 않습니다. 대신, 인증 필터는 HTTP 401 Unauthorized로 응답합니다.
결론적으로, 인증이 실패하는 모든 상황은 401 Unauthorized 상태를 가진 응답을 생성하며, 애플리케이션은 엔드포인트로의 호출을 전달하지 않습니다. permitAll() 메소드는 오직 권한 부여 구성에만 관련되며, 인증이 실패하면 호출은 더 이상 허용되지 않습니다.
물론, 다른 모든 엔드포인트를 인증된 사용자만 접근할 수 있도록 결정할 수 있습니다. 이를 위해, permitAll() 메소드를 authenticated()로 변경하면 됩니다. 이는 다음 목록에 제시되어 있습니다. 마찬가지로, denyAll() 메소드를 사용하여 다른 모든 요청을 거부할 수도 있습니다.
Listing 8.5 Making other requests accessible for all authenticated users
@Configuration
public class ProjectConfig {
// Omitted code
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.httpBasic(Customizer.withDefaults());
http.authorizeHttpRequests(
c -> c.requestMatchers("/hello").hasRole("ADMIN")
.requestMatchers("/ciao").hasRole("MANAGER")
//.anyRequest().denyAll()
.anyRequest().authenticated() # A
);
return http.build();
}
}
#A All other requests are accessible only by authenticated users.
이 섹션의 마지막에서, 권한 부여 제한을 구성하고자 하는 요청을 참조하기 위해 matchers 메소드를 어떻게 사용해야 하는지에 대해 익숙해졌습니다. 이제 사용할 수 있는 문법에 대해 더 심도 있게 다뤄야 합니다. 대부분의 실용적인 시나리오에서, 여러 엔드포인트가 동일한 권한 부여 규칙을 가질 수 있으므로, 엔드포인트별로 설정할 필요가 없습니다. 또한, 지금까지 해왔던 것처럼 경로뿐만 아니라 HTTP method를 명시할 필요가 있을 때도 있습니다. 때때로, 엔드포인트의 경로가 HTTP GET으로 호출될 때만 규칙을 구성해야 할 필요가 있습니다. 이 경우, HTTP POST와 HTTP DELETE에 대해 다른 규칙을 정의해야 합니다. 다음 섹션에서, 각 유형의 매처 메소드를 살펴보고 이러한 측면에 대해 자세히 논의합니다.
8.2 Selecting requests to apply authorization restrictions
이 섹션에서는 request matchers를 구성하는 방법에 대해 깊이 다룹니다. requestMatchers() 메소드를 사용하는 것은 권한 부여 구성을 적용하기 위해 요청을 참조하는 일반적인 접근 방식입니다. 따라서 개발하는 애플리케이션에서 이 메소드를 요청을 참조하기 위해 많은 기회를 가질 것으로 기대합니다.
이 matchers는 경로를 참조하기 위한 표준 ANT 문법을 사용합니다. 이 문법은 @RequestMapping, @GetMapping, @PostMapping 등과 같은 어노테이션으로 엔드포인트 매핑을 작성할 때 사용하는 것과 동일합니다. MVC matchers를 선언하기 위해 사용할 수 있는 두 가지 방법은 다음과 같습니다:
- requestMatchers(HttpMethod method, String... patterns) - 제한을 적용할 HTTP method와 경로를 모두 지정할 수 있게 해줍니다. 이 메소드는 같은 경로에 대해 다른 HTTP method별로 다른 제한을 적용하고 싶을 때 유용합니다.
- requestMatchers(String... patterns) - path를 기반으로 권한 부여 제한을 적용할 필요가 있을 때 사용하기 더 간단하고 쉽습니다. 이 제한은 path와 함께 사용되는 모든 HTTP method에 자동으로 적용될 수 있습니다.
이 섹션에서는 requestMatchers() 메소드를 사용하는 여러 방법을 다룹니다. 이를 보여주기 위해, 여러 엔드포인트를 노출하는 애플리케이션을 작성하기로 시작합니다. 처음으로, GET 이외의 다른 HTTP method로 호출할 수 있는 엔드포인트를 작성합니다. 지금까지 다른 HTTP method를 사용하는 것을 피한 것을 관찰했을 수 있습니다. 이유는 Spring Security가 기본적으로 크로스 사이트 요청 위조(CSRF)에 대한 보호를 적용하기 때문입니다. 9장에서는 Spring Security가 CSRF 토큰을 사용하여 이 취약점을 어떻게 완화하는지 논의할 것입니다. 하지만 현재 예제를 간단하게 하고 POST, PUT, 또는 DELETE로 노출된 모든 엔드포인트를 호출할 수 있도록 하기 위해, 우리는 configure() 메소드에서 CSRF 보호를 비활성화할 필요가 있습니다:
http.csrf(
c -> c.disable()
);
주의: 우리는 현재 CSRF 보호를 비활성화하여 당신이 현재 논의되고 있는 주제인 matcher 메소드에 집중할 수 있도록 합니다. 하지만 이를 좋은 접근법으로 서두르게 생각하지 마십시오. 9장에서는 Spring Security에 의해 제공되는 CSRF 보호에 대해 자세히 논의할 것입니다.
우리의 테스트에 사용할 네 개의 엔드포인트를 정의하기로 시작합니다:
- HTTP 메소드 GET을 사용하는 /a
- HTTP 메소드 POST를 사용하는 /a
- HTTP 메소드 GET을 사용하는 /a/b
- HTTP 메소드 GET을 사용하는 /a/b/c
Listing 8.6 Definition of the four endpoints for which we configure authorization
@RestController
public class TestController {
@PostMapping("/a")
public String postEndpointA() {
return "Works!";
}
@GetMapping("/a")
public String getEndpointA() {
return "Works!";
}
@GetMapping("/a/b")
public String getEnpointB() {
return "Works!";
}
@GetMapping("/a/b/c")
public String getEnpointC() {
return "Works!";
}
}
다른 Role을 가진 몇 명의 사용자도 필요합니다. 일을 간단하게 유지하기 위해, 우리는 InMemoryUserDetailsManager 사용을 계속합니다. 다음 리스팅에서는 구성 클래스에서 UserDetailsService의 정의를 볼 수 있습니다.
Listing 8.7 The definition of the UserDetailsService
@Configuration
public class ProjectConfig {
@Bean
public UserDetailsService userDetailsService() {
var manager = new InMemoryUserDetailsManager();
var user1 = User.withUsername("john")
.password("12345")
.roles("ADMIN")
.build();
var user2 = User.withUsername("bill")
.password("12345")
.roles("MANAGER")
.build();
manager.createUser(user1);
manager.createUser(user2);
return manager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
첫 번째 시나리오부터 시작해봅시다. /a 경로에 대해 HTTP GET 메소드로 수행된 요청의 경우, 애플리케이션은 사용자를 인증해야 합니다. 같은 경로에 대해 HTTP POST 메소드를 사용하는 요청은 인증을 요구하지 않습니다. 애플리케이션은 다른 모든 요청을 거부합니다. 다음 리스팅은 이 설정을 달성하기 위해 작성해야 할 구성을 보여줍니다.
Listing 8.8 Authorization configuration for the first scenario, /a
@Configuration
public class ProjectConfig {
// Omitted code
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.httpBasic(Customizer.withDefaults());
http.authorizeHttpRequests(
c -> c.requestMatchers(HttpMethod.GET, "/a")
.authenticated() # A
.requestMatchers(HttpMethod.POST, "/a")
.permitAll() # B
.anyRequest().
denyAll() # C
);
http.csrf(
c -> c.disable() # D
);
return http.build();
}
}
#A /a 경로에 대한 HTTP GET 메소드로 호출된 요청의 경우, 앱은 사용자를 인증해야 합니다.
#B HTTP POST 메소드로 호출된 /a 경로의 요청을 누구에게나 허용합니다.
#C 다른 모든 경로로의 다른 모든 요청을 거부합니다.
#D HTTP POST 메소드를 사용하여 /a 경로로의 호출을 가능하게 하기 위해 CSRF를 비활성화합니다.
다음 코드 스니펫에서, 우리는 리스팅 8.8에 제시된 구성에 대한 엔드포인트로의 호출 결과를 분석합니다. 인증 없이 HTTP 메소드 POST를 사용하여 경로 /a로의 호출을 위해 다음 cURL 명령어를 사용하세요:
curl -XPOST http://localhost:8080/a
The response body is
Works!
When calling path /a using HTTP GET without authenticating, use
curl -XGET http://localhost:8080/a
The response is
{
"status":401,
"error":"Unauthorized",
"message":"Unauthorized",
"path":"/a"
}
If you want to change the response to a successful one, you need to authenticate with a valid user. For the following call
curl -u john:12345 -XGET http://localhost:8080/a
the response body is
Works!
하지만 사용자 John은 /a/b 경로를 호출할 수 없으므로, 이 호출을 위해 그의 자격 증명으로 인증하는 것은 403 Forbidden을 생성합니다:
curl -u john:12345 -XGET http://localhost:8080/a/b
The response is
{
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/a/b"
}
이 예제를 통해, HTTP method를 기반으로 요청을 구별하는 방법을 이제 알게 되었습니다. 하지만, 여러 경로가 동일한 권한 부여 규칙을 가진 경우는 어떨까요? 물론, 우리는 권한 부여 규칙을 적용하는 모든 경로를 열거할 수 있지만, 너무 많은 경로가 있을 경우 코드 읽기가 불편해집니다. 또한, 동일한 접두사를 가진 경로 그룹이 항상 동일한 권한 부여 규칙을 가진다는 것을 처음부터 알 수 있습니다. 개발자가 동일한 그룹에 새로운 경로를 추가할 때 권한 부여 구성도 변경되지 않도록 하고 싶습니다. 이러한 경우를 관리하기 위해, 우리는 경로 표현식[path expression]을 사용합니다. 이를 예제로 증명해 보겠습니다.
현재 프로젝트에서, 우리는 /a/b로 시작하는 모든 경로에 대한 요청에 동일한 규칙이 적용되도록 하고 싶습니다. 이 경우의 경로는 /a/b와 /a/b/c입니다. 이를 달성하기 위해, 우리는 ** 연산자를 사용합니다. 이 예제는 프로젝트 ssia-ch8-ex3에서 찾을 수 있습니다.
Listing 8.9 Changes in the configuration class for multiple paths
@Configuration
public class ProjectConfig {
// Omitted code
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
http.httpBasic(Customizer.withDefaults());
http.authorizeHttpRequests(
c -> c.requestMatchers( "/a/b/**").authenticated() # A
.anyRequest().permitAll()
);
http.csrf(
c -> c.disable()
);
return http.build();
}
}
#A The /a/b/** expression refers to all paths prefixed with /a/b.
리스팅 8.9에서 제공된 구성을 사용하면, 인증 없이 /a 경로를 호출할 수 있지만, /a/b로 시작하는 모든 경로에 대해서는 애플리케이션이 사용자를 인증해야 합니다. 다음 코드 스니펫은 /a, /a/b, /a/b/c 엔드포인트를 호출한 결과를 제시합니다. 먼저, 인증 없이 /a 경로를 호출하려면 사용하세요
curl http://localhost:8080/a
The response body is
Works!
To call the /a/b path without authenticating, use
curl http://localhost:8080/a/b
The response is
{
"status":401,
"error":"Unauthorized",
"message":"Unauthorized",
"path":"/a/b"
}
To call the /a/b/c path without authenticating, use
curl http://localhost:8080/a/b/c
The response is
{
"status":401,
"error":"Unauthorized",
"message":"Unauthorized",
"path":"/a/b/c"
}
이전 예에서 제시된 것처럼 ** 연산자는 여러 경로 이름을 나타냅니다. 마지막 예에서 했던 것처럼 이를 사용하면 알려진 접두사가 있는 경로와 요청을 일치시킬 수 있습니다. 경로 중간에 사용하여 여러 경로 이름을 참조하거나 /a/**/c와 같은 특정 패턴으로 끝나는 경로를 참조할 수도 있습니다. 따라서 /a/**/c는 /a/b/c뿐만 아니라 /a/b/d/c 및 a/b/c/d/e/c 등과도 일치합니다. 하나의 경로 이름만 일치시키려면 단일 *를 사용할 수 있습니다. 예를 들어, a/*/c는 a/b/c 및 a/d/c와 일치하지만 a/b/d/c와 일치하지 않습니다.
일반적으로 경로 변수[path variable]를 사용하므로 이러한 요청에 권한 부여 규칙을 적용하는 것이 유용할 수 있습니다. 경로 변수 값을 참조하는 규칙을 적용할 수도 있습니다. denyAll() 메서드와 모든 요청 제한에 대한 섹션 8.1의 논의를 기억하시나요?
이제 이 섹션에서 배운 내용에 대한 보다 적합한 예를 살펴보겠습니다.
경로 변수가 있는 엔드포인트가 있고 숫자 이외의 다른 값이 있는 경로 변수의 값을 사용하는 모든 요청을 거부하려고 합니다. 이 예제는 ssia-ch8-ex4 프로젝트에서 찾을 수 있습니다. 다음 목록은 컨트롤러를 나타냅니다.
Listing 8.10 The definition of an endpoint with a path variable in a controller class
@RestController
public class ProductController {
@GetMapping("/product/{code}")
public String productCode(@PathVariable String code) {
return code;
}
}
다음 리스팅은 값이 숫자만 포함하는 호출만 항상 허용되고, 다른 모든 호출이 거부되도록 권한 부여를 구성하는 방법을 보여줍니다.
@Configuration
public class ProjectConfig {
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
http.httpBasic(Customizer.withDefaults());
http.authorizeHttpRequests(
c -> c.requestMatchers
( "/product/{code:^[0-9]*$}") # A
.permitAll()
.anyRequest().denyAll()
);
return http.build();
}
}
#A 이 정규식은 모든 숫자를 포함하는 모든 길이의 문자열을 나타냅니다.
참고 정규식과 함께 파라미터 표현식을 사용하는 경우 리스팅에 표시된 대로 파라미터 이름, 콜론(:) 및 정규식 사이에 공백이 없어야 합니다.
이 예제를 실행하면, 다음 코드 스니펫에서 제시된 결과를 볼 수 있습니다. 애플리케이션은 경로 변수 값이 숫자만 있을 때만 호출을 허용합니다. 값 1234a를 사용하여 엔드포인트를 호출하려면, 사용하세요
curl http://localhost:8080/product/1234a
The response is
{
"status":401,
"error":"Unauthorized",
"message":"Unauthorized",
"path":"/product/1234a"
}
To call the endpoint using the value 12345, use
curl http://localhost:8080/product/12345
The response is
12345
우리는 requestMatchers() 메소드를 사용하여 요청을 참조하는 방법에 대해 많은 논의를 하고 많은 예제를 포함했습니다. 표 8.1은 이 섹션에서 사용한 경로 표현식에 대한 복습입니다. 나중에 이들 중 어느 것이든 기억하고 싶을 때 간단히 참조할 수 있습니다.
Table 8.1 Common expressions used for path matching with MVC matchers
Expression | Description |
/a | Only path /a. |
/a/* | * 연산자는 하나의 경로 이름을 대체합니다. 이 경우 /a/b 또는 /a/c와 일치하지만 /a/b/c와 일치하지 않습니다. |
/a/** | ** 연산자는 여러 경로 이름을 대체합니다. 이 경우 /a, /a/b 및 /a/b/c는 이 표현식과 일치합니다. |
/a/{param} | 이 표현식은 주어진 경로 파라미터를 사용하여 경로 /a에 적용됩니다. |
/a/{param:regex} | 이 표현식은 파라미터 값이 주어진 정규식과 일치하는 경우에만 주어진 경로 파라미터가 있는 경로 /a에 적용됩니다. |
8.2.1 Using regular expressions with request matchers
이 섹션에서는 정규 표현식(regex)에 대해 논의합니다. 정규 표현식이 무엇인지 이미 알고 있어야 하지만, 그 주제에 대한 전문가일 필요는 없습니다. https://www.regular-expressions.info/books.html에서 추천하는 책들 중 어느 것이든 주제를 더 깊이 배울 수 있는 훌륭한 자료입니다. 정규 표현식을 작성할 때, 저도 종종 온라인 생성기와 같은 도구를 자주 사용합니다(그림 8.1 참조).
그림 8.1 키보드 위에서 고양이가 놀게 하는 것은 정규 표현식(regex)을 생성하는 최선의 방법이 아닙니다. 정규 표현식을 생성하는 방법을 배우려면 https://regexr.com/과 같은 온라인 생성기를 사용할 수 있습니다.
8.2 및 8.3 섹션에서, 대부분의 경우 경로 표현식 문법[/a, /a/*, /a/**]을 사용하여 권한 부여 구성을 적용할 요청을 참조할 수 있다는 것을 배웠습니다. 그러나 일부 경우에는 더 특별한 요구 사항이 있을 수 있으며, 이러한 요구 사항은 경로 표현식으로 해결할 수 없습니다. 이러한 요구 사항의 예는 다음과 같습니다: "경로에 특정 심볼이나 문자가 포함된 모든 요청을 거부한다." 이러한 시나리오에서는 정규 표현식과 같은 더 강력한 표현을 사용해야 합니다.
정규 표현식을 사용하여 문자열의 어떤 형식이든 표현할 수 있으므로, 이 문제에 대해 무한한 가능성을 제공합니다. 하지만 간단한 시나리오에 적용되었을 때조차 읽기 어렵다는 단점이 있습니다. 이러한 이유로, 경로 표현식을 사용하고 다른 선택지가 없을 때만 정규 표현식으로 돌아가는 것을 선호할 수 있습니다. 정규 표현식 요청 매처를 구현하기 위해, 파라미터로 RegexRequestMatcher 구현체와 함께 requestMatchers() 메소드를 사용할 수 있습니다.
정규 표현식[regex matcher]가 어떻게 작동하는지 증명하기 위해, 예제를 통해 실제로 적용해 보겠습니다: 사용자에게 비디오 콘텐츠를 제공하는 애플리케이션을 구축합니다. 비디오 콘텐츠를 제시하는 애플리케이션은, 사용자가 /video/{country}/{language} 엔드포인트를 호출하여 콘텐츠를 제공받을 수 있도록 합니다. 예를 들어, 애플리케이션은 사용자가 요청을 하는 곳에서 두 개의 경로 변수로 국가와 언어를 받습니다. 미국, 캐나다, 또는 영국에서 요청이 오거나 영어를 사용하는 경우, 어떤 인증된 사용자든 비디오 콘텐츠를 볼 수 있다고 간주합니다.
이 예제는 프로젝트 ssia-ch8-ex5에서 구현된 것을 찾을 수 있습니다. 보안이 필요한 엔드포인트에는 다음 리스팅에서 보여주는 것처럼 두 개의 경로 변수가 있습니다. 이것은 request matcher를 사용하여 구현하기 복잡한 요구 사항을 만듭니다.
Listing 8.12 The definition of the endpoint for the controller class
@RestController
public class VideoController {
@GetMapping("/video/{country}/{language}")
public String video(@PathVariable String country,
@PathVariable String language) {
return "Video allowed for " + country + " " + language;
}
}
단일 경로 변수에 대한 조건의 경우, 경로 표현식에서 직접 정규 표현식을 작성할 수 있습니다. 우리는 섹션 8.2에서 이러한 예를 언급했지만, 그 당시에는 정규 표현식에 대해 깊이 논의하지 않았기 때문에 자세히 다루지 않았습니다.
엔드포인트 /email/{email}이 있고, 이메일 파라미터의 값으로 .com으로 끝나는 주소를 보내는 요청에만 matcher를 사용하여 규칙을 적용하고 싶다고 가정해 봅시다. 그 경우, 다음 코드 스니펫에 제시된 대로 요청 매처를 작성합니다. 이에 대한 전체 예제는 프로젝트 ssia-ch8-ex6에서 찾을 수 있습니다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.httpBasic(Customizer.withDefaults());
http.authorizeHttpRequests(
c -> c.requestMatchers("/email/{email:.*(?:.+@.+\\.com)}")
.permitAll()
.anyRequest().denyAll()
);
return http.build();
}
이러한 제한을 테스트하면, 애플리케이션이 .com으로 끝나는 이메일만을 허용하는 것을 확인할 수 있습니다. 예를 들어, jane@example.com으로 엔드포인트를 호출하려면 다음 명령을 사용할 수 있습니다:
And to call the endpoint to jane@example.net, you use this command:
curl http://localhost:8080/email/jane@example.net
The response body is
{
"status":401,
"error":"Unauthorized",
"message":"Unauthorized",
"path":/email/jane@example.net
}
이는 상당히 쉽고, 왜 우리가 정규 표현식 매처를 덜 자주 접하는지 더 명확하게 이해할 수 있게 해줍니다. 하지만, 앞서 말했듯이, 요구 사항이 복잡한 경우도 있습니다. 다음과 같은 상황을 발견했을 때 정규 표현식 매처를 사용하는 것이 더 편리하다는 것을 알게 될 것입니다:
- 전화번호나 이메일 주소를 포함하는 모든 경로에 대한 특정 구성
- 모든 경로 변수를 통해 전송되는 것을 포함하여 특정 형식을 가진 모든 경로에 대한 특정 구성
우리의 정규 표현식 matcher 예제(ssia-ch8-ex6)로 돌아가보겠습니다. 더 복잡한 규칙을 작성해야 하고, 결국에는 더 많은 경로 패턴과 여러 경로 변수 값에 대한 참조가 필요할 때는, 정규 표현식 매처를 작성하는 것이 더 쉽습니다. 목록 8.13에서는 /video/{country}/{language} 경로에 대한 요구 사항을 해결하기 위해 정규 표현식 matcher를 사용하는 설정 클래스의 정의를 찾을 수 있습니다. 또한, 구현을 테스트하기 위해 서로 다른 권한을 가진 두 개의 사용자를 추가합니다.
Listing 8.13 The configuration class using a regex matcher
@Configuration
public class ProjectConfig {
@Bean
public UserDetailsService userDetailsService() {
var uds = new InMemoryUserDetailsManager();
var u1 = User.withUsername("john")
.password("12345")
.authorities("read")
.build();
var u2 = User.withUsername("jane")
.password("12345")
.authorities("read", "premium")
.build();
uds.createUser(u1);
uds.createUser(u2);
return uds;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.httpBasic(Customizer.withDefaults());
http.authorizeHttpRequests(
c -> c.requestMatchers(
new RegexRequestMatcher(".*/[us|uk|ca]+/[en|fr].*", #A
HttpMethod.GET.name()))
.authenticated()
.anyRequest()
.hasAuthority("premium") #B
);
return http.build();
}
}
#A 사용자가 인증되어야 하는 경로를 일치시키기 위해 정규 표현식을 사용합니다.
#B 사용자가 프리미엄 액세스를 갖고 있어야 하는 다른 경로를 구성합니다.
엔드포인트를 실행하고 테스트하면, 애플리케이션이 권한 부여 구성을 올바르게 적용했음을 확인할 수 있습니다. 사용자 John은 나라 코드가 US이고 언어가 en인 엔드포인트를 호출할 수 있지만, 우리가 구성한 제한 때문에 나라 코드가 FR이고 언어가 fr인 엔드포인트를 호출할 수 없습니다. /video 엔드포인트를 호출하고 사용자 John을 미국 지역과 영어로 인증하는 모습은 다음과 같습니다:
/fr/fr 로 /video 엔드포인트를 호출하고 사용자 John을 프랑스 지역과 프랑스어로 인증하는 모습은 다음과 같습니다:
premium 권한을 가진 사용자 Jane은 양쪽 호출 모두를 성공적으로 수행합니다. 첫 번째 호출의 경우,
그리고 두 번째 호출도
정규 표현식은 강력한 도구입니다. 정규 표현식을 사용하여 어떠한 요구 사항에 대한 경로를 참조할 수 있습니다. 그러나 정규 표현식은 읽기 어렵고 매우 길어질 수 있기 때문에, 마지막 선택으로 남겨두어야 합니다. 경로 표현식이 문제에 대한 해결책을 제공하지 않는 경우에만 사용하세요.
이 섹션에서는 필요한 정규 표현식이 짧도록 가장 간단한 예제를 사용했습니다. 그러나 더 복잡한 시나리오에서는 정규 표현식이 훨씬 길어질 수 있습니다. 물론, 어떤 정규 표현식이든 쉽게 읽을 수 있다고 말하는 전문가들도 있을 것입니다. 예를 들어, 이메일 주소와 일치하는 정규 표현식은 다음 코드 스니펫과 같을 수 있습니다. 이것을 쉽게 읽고 이해할 수 있나요?
(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:
[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-
\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-
9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.)
{3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-
\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-
\x7f])+)\])
8.3 Summary
- 실제 시나리오에서는 다양한 요청에 대해 서로 다른 권한 부여 규칙을 적용하는 경우가 많습니다.
- Path 및 HTTP method를 기반으로 권한 부여 규칙이 구성되는 요청을 지정합니다. 이렇게 하려면 requestMatchers() 메서드를 사용합니다.
- 요구 사항이 너무 복잡하여 경로 표현식으로 해결할 수 없는 경우 더 강력한 정규식을 사용하여 구현할 수 있습니다.
'Spring Security' 카테고리의 다른 글
ch10 Configuring Cross-Origin Resource Sharing(CORS) (0) | 2024.03.03 |
---|---|
ch09 Configuring Cross-Site Request Forgery(CSRF) protection (0) | 2024.03.03 |
ch07 Configuring endpoint-level authorization: Restricting access (0) | 2024.03.01 |
ch06 Implementing authenticaiton (0) | 2024.02.26 |
ch05 A web app's security begins with filters (0) | 2024.02.25 |