ch11 Implement authorization at the method level

2024. 3. 3. 10:31Spring Security

이 장에서 다룰 내용은 다음과 같습니다.

  • 스프링 애플리케이션에서의 메소드 보안
  • Authorities, roles 및 permissions 에 기반한 메소드 사전 승인
  • Authorities, roles 및 permissions 에 기반한 메소드 사후 승인

지금까지 우리는 인증을 구성하는 다양한 방법에 대해 논의했습니다. 우리는 2장에서 가장 간단한 접근 방식인 HTTP Basic 인증으로 시작했고, 그 다음 6장에서 Form 로그인을 설정하는 방법을 보여주었습니다. 하지만 권한 부여 측면에서는 엔드포인트 레벨에서의 구성만 논의했습니다. 만약 여러분의 앱이 웹 애플리케이션이 아니라면, 스프링 시큐리티를 인증과 권한 부여에도 사용할 수 없을까요? 스프링 시큐리티는 앱이 HTTP 엔드포인트를 통해 사용되지 않는 시나리오에서도 잘 맞습니다. 이 장에서는 메소드 레벨에서 권한 부여를 구성하는 방법을 배우게 됩니다. 우리는 이 접근 방식을 웹 및 비웹 애플리케이션 모두에서 권한 부여를 구성하는 데 사용하며, 이를 메소드 보안(그림 11.1)이라고 부릅니다.

 

그림 11.1 메소드 보안을 통해 애플리케이션의 어떤 계층에서든 권한 부여 규칙을 적용할 수 있습니다. 이 접근 방식을 통해 더 세밀하게, 그리고 특별히 선택한 수준에서 권한 부여 규칙을 적용할 수 있습니다.

 

웹이 아닌 애플리케이션의 경우, 메소드 보안은 엔드포인트가 없더라도 권한 부여 규칙을 구현할 기회를 제공합니다. 웹 애플리케이션에서는 이 접근 방식이 우리 앱의 다양한 계층에 권한 부여 규칙을 적용할 수 있는 유연성을 제공합니다, 엔드포인트 레벨에서만 적용되는 것이 아닙니다. 이 장으로 들어가서 메소드 보안을 사용하여 메소드 레벨에서 권한 부여를 적용하는 방법을 배워봅시다.

 

참고

 

11.1 Enabling method security

이 섹션에서는 메소드 레벨에서 권한 부여를 활성화하는 방법과 스프링 시큐리티가 제공하는 다양한 권한 부여 규칙을 적용할 수 있는 다양한 옵션에 대해 배웁니다. 이 접근 방식은 권한 부여를 적용하는 데 있어 더 큰 유연성을 제공합니다. 이것은 단순히 엔드포인트 레벨에서만 구성될 수 없는 상황에서 권한 부여 문제를 해결할 수 있게 해주는 필수적인 기술입니다.
디폴트로 메소드 보안은 비활성화되어 있으므로, 이 기능을 사용하고 싶다면 먼저 활성화해야 합니다. 또한, 메소드 보안은 권한 부여를 적용하기 위한 여러 접근 방식을 제공합니다. 이러한 접근 방식을 논의한 다음 이 장의 다음 섹션과 12장에서 예제를 구현합니다. 간략히 말하자면, 전역 메소드 보안으로 두 가지 주요한 작업을 할 수 있습니다:

  • Call authorization(호출 권한 부여) — 누군가가 구현된 권한 규칙에 따라 메소드를 호출할 수 있는지 결정하는 것(사전 권한 부여[preauthorization]) 또는 메소드 실행 후 메소드가 리턴하는 것에 접근할 수 있는지 결정하는 것(사후 권한 부여[postauthorization]).
  • Filtering(필터링) — 메소드가 파라미터를 통해 받을 수 있는 것을 결정하는 것(사전 필터링)과 메소드 실행 후 호출자가 메소드로부터 받을 수 있는 것을 결정하는 것(사후 필터링)입니다. 필터링에 대해 12장에서 논의하고 구현할 예정입니다.

11.1.1 Understanding call authorization

메소드 보안을 사용하여 권한 부여 규칙을 구성하는 접근 방법 중 하나는 Call authorizaiton(호출 권한 부여)입니다. 호출 권한 부여 접근 방식은 메소드가 호출될 수 있는지, 또는 메소드가 호출되고 나서 메소드의 리턴값에 호출자가 접근할 수 있는지를 결정하는 권한 규칙을 적용하는 것을 의미합니다. 우리는 종종 제공된 파라미터나 그 결과에 따라 누군가가 특정 로직에 접근할 수 있는지를 결정해야 합니다. 그러므로 호출 권한 부여에 대해 논의한 다음 몇 가지 예제에 적용해 보겠습니다.

 

메소드 보안은 어떻게 작동하나요? 권한 부여 규칙을 적용하는 메커니즘은 무엇인가요? 우리가 애플리케이션에서 메소드 보안을 활성화할 때, 실제로는 스프링 Aspect를 활성화합니다. 이 aspect는 권한 부여 규칙을 적용하는 메소드에 대한 호출을 가로채고, 이러한 권한 부여 규칙에 기반하여 가로챈 메소드로의 호출을 전달할지를 결정합니다(그림 11.2).

 

그림 11.2 전역 메소드 보안을 활성화하면, 어스펙트가 보호된 메소드로의 호출을 가로챕니다. 주어진 권한 부여 규칙이 존중되지 않으면, 어스펙트는 보호된 메소드로의 호출을 위임하지 않습니다.

 

스프링 프레임워크의 많은 구현체들이 관점 지향 프로그래밍(AOP)에 의존합니다. 메소드 보안은 스프링 애플리케이션 내에서 aspect에 의존하는 수많은 컴포넌트 중 하나일 뿐입니다. aspect와 AOP에 대한 복습이 필요하다면, 제가 쓴 또 다른 책인 "Spring Start Here"(Manning, 2021)의 6장을 읽어보시길 권장합니다. 간략히 말해서, 우리는 호출 권한 부여를 다음과 같이 분류합니다.

  • 사전 권한 부여 — 프레임워크가 메소드 호출 전에 권한 부여 규칙을 확인합니다.
  • 사후 권한 부여 — 프레임워크가 메소드 실행 후에 권한 부여 규칙을 확인합니다.

이 두 접근 방식을 자세히 살펴보고 몇 가지 예제와 함께 구현해 보겠습니다.

 

 

USING PREAUTHORIZATION TO SECURE ACCESS TO METHODS

우리가 findDocumentsByUser(String username)라는 메소드를 가지고 있다고 가정해봅시다. 이 메소드는 특정 사용자를 위한 문서를 호출자에게 반환합니다. 호출자는 메소드의 파라미터에게 메소드가 문서를 검색할 사용자의 이름을 전달합니다. 인증된 사용자가 자신의 문서만 얻을 수 있도록 해야 한다고 가정해 봅시다. 우리는 인증된 사용자의 사용자명을 파라미터로 받는 메소드 호출만을 허용하도록 이 메소드에 규칙을 적용할 수 있을까요? 네! 이것이 바로 우리가 사전 권한 부여로 할 수 있는 것입니다.
우리가 특정 상황에서 누구든지 특정 메소드를 호출하는 것을 완전히 금지하는 권한 부여 규칙을 적용할 때, 이를 사전 권한 부여(그림 11.3)라고 합니다. 이 접근 방식은 프레임워크가 메소드를 실행하기전에 권한 조건을 검증한다는 것을 의미합니다. 만약 호출자가 우리가 정의한 권한 부여 규칙에 따른 권한이 없다면, 프레임워크는 메소드에 호출을 위임하지 않습니다. 대신, 프레임워크는 AccessDeniedException이라는 예외를 던집니다. 이는 글로벌 메소드 보안에 있어 가장 자주 사용되는 접근 방식입니다.

그림 11.3 사전 승인을 사용하면 메서드 호출을 추가로 위임하기 전에 권한 부여 규칙이 확인됩니다. 인증 규칙이 준수되지 않으면 프레임워크는 호출을 위임하지 않고 대신 메서드 호출자에게 예외를 발생시킵니다.

 

보통, 일부 조건이 충족되지 않는 경우에는 기능이 전혀 실행되지 않기를 원합니다. 인증된 사용자를 기반으로 조건을 적용할 수 있으며, 메소드가 파라미를 통해 받은 값들을 참조할 수도 있습니다.

 

USING POSTAUTHORIZATION TO SECURE A METHOD CALL

우리가 누군가가 메소드를 호출할 수는 있지만 반드시 메소드가 리턴하는 결과를 얻을 수 있는 것은 아닌 권한 부여 규칙을 적용할 때, 우리는 사후 권한 부여(그림 11.4)를 사용하고 있습니다. 사후 권한 부여를 사용할 때, 스프링 시큐리티는 메소드가 실행된 후에 권한 부여 규칙을 확인합니다. 이러한 종류의 권한 부여를 사용하여 특정 조건에서 메소드 리턴 값에 대한 접근을 제한할 수 있습니다. 사후 권한 부여는 메소드 실행 후에 일어나기 때문에, 메소드가 리턴하는 결과에 대해 권한 부여 규칙을 적용할 수 있습니다.

그림 11.4 사후 권한 부여를 사용하면, 어스펙트는 호출을 보호된 메소드로 위임합니다. 보호된 메소드가 실행을 마친 후, 어스펙트는 권한 부여 규칙을 확인합니다. 규칙이 존중되지 않는 경우, 결과를 호출자에게 리턴하는 대신 어스펙트는 예외를 던집니다.

 

일반적으로, 우리는 실행 후 메소드가 리턴하는 것을 기반으로 권한 부여 규칙을 적용하기 위해 사후 권한 부여를 사용합니다. 하지만 사후 권한 부여를 사용할 때는 주의해야 합니다! 만약 메소드가 실행 중에 어떤 것을 변경한다면, 그 변경은 권한 부여가 결국 성공하든 그렇지 않든 발생합니다.

 

참고로, @Transactional 애노테이션을 사용하더라도 사후 권한 부여가 실패하면 변경 사항은 롤백되지 않습니다. 사후 권한 부여 기능에 의해 발생하는 예외는 트랜잭션 매니저가 트랜잭션을 커밋한 후에 발생합니다.

 

11.1.2 Enabling method security in your project

이 섹션에서는 메소드 보안에 제공되는 사전 인가(preauthorization) 및 사후 인가(postauthorization) 기능을 적용하는 프로젝트를 진행합니다. 메소드 보안은 기본적으로 Spring Security 프로젝트에서 활성화되어 있지 않습니다. 이 기능을 사용하려면 먼저 활성화해야 합니다. 하지만, 이 기능을 활성화하는 것은 간단합니다. 설정 클래스에 @EnableMethodSecurity 어노테이션을 사용하기만 하면 됩니다. 이 예제에서, 저는 ssia-ch11-ex1이라는 새 프로젝트를 만들었습니다. 이 프로젝트를 위해, 저는 listing 11.1에 제시된 ProjectConfig 설정 클래스를 작성했습니다. 설정 클래스에 @EnableMethodSecurity 어노테이션을 추가합니다. 메소드 보안은 우리에게 인가 규칙을 정의할 수 있는 세 가지 접근 방식을 제공합니다. 이 장에서는 다음과 같은 접근 방식을 논의합니다:

  • pre-/postauthorization 어노테이션 (기본적으로 활성화됨)
  • JSR 250 어노테이션, @RolesAllowed
  • @Secured 어노테이션

거의 모든 경우에 pre-/postauthorization 어노테이션이 유일하게 사용되는 접근 방식이기 때문에, 이 장에서는 이 접근 방식을 논의합니다. 이 접근 방식은 @EnableMethodSecurity 어노테이션을 추가하면 사전에 활성화됩니다. 이 장의 끝에서 앞서 언급한 다른 두 옵션에 대한 간략한 개요를 제시합니다.

 

Listing 11.1 Enabling method security

@Configuration
@EnableMethodSecurity
public class ProjectConfig {
}

 

글로벌 메소드 보안은 HTTP Basic 인증에서 OAuth 2 (이 책의 3부에서 배울 것입니다)에 이르기까지 모든 인증 접근 방식과 함께 사용할 수 있습니다. 새로운 세부 사항에 집중할 수 있도록 간단히 유지하기 위해, 우리는 HTTP Basic 인증을 사용한 메소드 보안을 제공합니다. 이러한 이유로, 이 장의 프로젝트들을 위한 pom.xml 파일은 다음 코드 스니펫이 제시하는 것처럼 웹과 Spring Security 의존성만 필요합니다.

<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-web</artifactId>
</dependency>

 

참고: 이전 Spring Security 버전에서는 @EnableGlobalMethodSecurity 어노테이션을 사용했으며, pre-/postauthorization가 디폴트로 활성화되어 있지 않았습니다. 이전 Spring Security 버전(6보다 오래된 버전)을 사용하여 메소드 authorization를 작업해야 할 경우, Spring Security in Action 1st 에디션의 16장을 읽는 것이 유용할 것입니다.

 

11.2 Applying preauthorization rules

이 섹션에서는 preauthorization의 예를 구현합니다. 우리의 예제를 위해, 우리는 11.1절에서 시작한 ssia-ch11-ex1 프로젝트를 계속 진행합니다. 11.1절에서 논의한 바와 같이, preauthorization는 Spring Security가 특정 메소드를 호출하기 전에 적용하는 authorization 규칙을 정의하는 것을 의미합니다. 규칙이 존중되지 않으면, 프레임워크는 메소드를 호출하지 않습니다.

이 섹션에서 구현하는 애플리케이션은 간단한 시나리오를 가지고 있습니다. 이는 "Hello, " 뒤에 이름을 붙여 반환하는 엔드포인트, /hello를 노출합니다. 이름을 얻기 위해, 컨트롤러는 서비스 메소드를 호출합니다(그림 11.5 참조). 이 메소드는 사용자가 write 권한을 가지고 있는지 확인하기 위해 preauthorization 규칙을 적용합니다.

그림 11.5 NameService의 getName() 메소드를 호출하기 위해서는 인증된 사용자가 쓰기 권한을 가져야 합니다. 사용자가 이 권한을 가지고 있지 않다면, 프레임워크는 호출을 허용하지 않고 예외를 발생시킵니다.

 

인증할 사용자가 있는지 확인하기 위해 UserDetailsService와 PasswordEncoder를 추가했습니다. 우리의 솔루션을 검증하기 위해, 우리는 두 명의 사용자가 필요합니다: 하나는 쓰기 권한을 가진 사용자이고 다른 하나는 쓰기 권한이 없는 사용자입니다. 첫 번째 사용자가 엔드포인트를 성공적으로 호출할 수 있는 반면, 두 번째 사용자의 경우 앱이 메소드를 호출하려고 할 때 authorization 예외를 발생시킵니다. 다음 목록은 UserDetailsService와 PasswordEncoder를 정의하는 구성 클래스의 완전한 정의를 보여줍니다.

 

Listing 11.2 The configuration class for UserDetailsService and PasswordEncoder

@Configuration
@EnableMethodSecurity #A
public class ProjectConfig {
	@Bean #B
	public UserDetailsService userDetailsService() {
		var service = new InMemoryUserDetailsManager();
		var u1 = User.withUsername("natalie") 
					.password("12345")
					.authorities("read")
					.build();
		var u2 = User.withUsername("emma")
					.password("12345")
					.authorities("write")
					.build();
		service.createUser(u1);
		service.createUser(u2);
		return service;
	}
	@Bean #C
	public PasswordEncoder passwordEncoder() {
		return NoOpPasswordEncoder.getInstance();
	}
}

#A pre-/postauthorization를 위한 메소드 보안을 활성화합니다
#B 테스트를 위한 두 명의 사용자와 함께 UserDetailsService를 Spring 컨텍스트에 추가합니다
#C Spring 컨텍스트에 PasswordEncoder를 추가합니다

 

이 메소드에 대한 authorization 규칙을 정의하기 위해, 우리는 @PreAuthorize 어노테이션을 사용합니다. @PreAuthorize 어노테이션은 권한 부여 규칙을 기술하는 Spring 표현 언어(SpEL) 표현식을 값으로 받습니다. 이 예제에서, 우리는 간단한 규칙을 적용합니다.

사용자의 authorities에 기반한 제한을 hasAuthority() 메소드를 사용하여 정의할 수 있습니다. hasAuthority() 메소드에 대해서는 7장에서 배웠으며, 여기서 우리는 엔드포인트 레벨에서 authorization를 적용하는 방법에 대해 논의했습니다. 다음 리스팅은 이름의 값을 제공하는 서비스 클래스를 정의합니다.

 

Listing 11.3 The service class defines the preauthorization rule on the method

@Service
public class NameService {
 	@PreAuthorize("hasAuthority('write')") #A
 	public String getName() {
 		return "Fantastico";
 	}
}

#A 권한 부여 규칙을 정의합니다. 쓰기 권한을 가진 사용자만이 메소드를 호출할 수 있습니다.

 

다음 리스팅에서는 NameService를 의존하는 컨트롤러 클래스를 정의합니다.

 

Listing 11.4 The controller class implementing the endpoint and using the service

@RestController
public class HelloController {
 	private final NameService nameService; #A
 	// omitted constructor
 	@GetMapping("/hello")
 	public String hello() {
 		return "Hello, " + nameService.getName(); #B
 	}
}

#A 컨텍스트에서 서비스를 주입합니다
#B preauthorization 규칙을 적용한 메소드를 호출합니다

 

이제 애플리케이션을 시작하고 그 동작을 테스트할 수 있습니다. 우리는 쓰기 authorization를 가진 Emma만이 엔드포인트를 호출할 수 있을 것으로 예상합니다. 다음 코드 스니펫은 우리의 두 사용자, Emma와 Natalie로 엔드포인트를 호출합니다. /hello 엔드포인트를 호출하고 사용자 Emma로 인증하려면, 이 cURL 명령을 사용하세요:

curl -u emma:12345 http://localhost:8080/hello

 

The response body is

Hello, Fantastico

 

To call the /hello endpoint and authenticate with user Natalie, use this cURL command:

curl -u natalie:12345 http://localhost:8080/hello

 

The response body is

{
 "status":403,
 "error":"Forbidden",
 "message":"Forbidden",
 "path":"/hello"
}

 

마찬가지로, 7장에서 논의한 엔드포인트 인증을 위한 다른 표현식들을 사용할 수 있습니다. 여기에 그것들에 대한 짧은 요약이 있습니다:

  • hasAnyAuthority() — 여러 권한을 지정합니다. 사용자는 이들 권한 중 최소한 하나를 가지고 있어야 메소드를 호출할 수 있습니다.
  • hasRole() — 사용자가 메소드를 호출하기 위해 가져야 하는 역할을 지정합니다.
  • hasAnyRole() — 여러 역할을 지정합니다. 사용자는 이들 중 최소한 하나를 가지고 있어야 메소드를 호출할 수 있습니다.

우리의 예제를 확장하여 메소드 파라미터의 값을 사용하여 권한 부여 규칙을 정의하는 방법을 증명해 보겠습니다(그림 11.6 참조). 이 예제는 ssia-ch11-ex2라는 프로젝트에서 찾을 수 있습니다.

그림 11.6 preauthorization를 구현할 때, 메소드 파라미터의 값을 권한 부여 규칙에 사용할 수 있습니다. 우리 예제에서, 인증된 사용자만 자신의 비밀 이름에 대한 정보를 검색할 수 있습니다.

 

  1.  HelloController는 엔드포인트를 구현하고 NameService를 사용하여 사용자의 비밀 이름 리스트를 가져옵니다. 엔드포인트를 호출하려면 사용자가 먼저 인증해야 합니다.
  2. security aspect 에서는 파라미터로 제공된 이름이 인증된 사용자의 이름과 동일한지를 검증합니다.
  3. 파라미터로 제공된 이름이 인증된 사용자의 이름과 다른 경우, 이 aspect는 서비스 클래스에 호출을 위임하지 않고 예외를 발생시킵니다.

이 프로젝트에서는 첫 번째 예제와 동일한 ProjectConfig 클래스를 정의하여 우리가 Emma와 Natalie라는 두 명의 사용자와 계속 작업할 수 있도록 했습니다. 이제 엔드포인트는 path variable를 통해 값을 받고 주어진 사용자 이름에 대한 "secret name"을 얻기 위해 서비스 클래스를 호출합니다. 물론 이 경우, 비밀 이름은 단지 제가 정한 것으로 사용자의 특성을 가리키는 것이며, 모든 사람이 볼 수 있는 것은 아닙니다. 다음 리스팅에 제시된 대로 컨트롤러 클래스를 정의합니다.

 

Listing 11.5 The controller class defining an endpoint for testing

@RestController
public class HelloController {
 
     private final NameService nameService; #A
     // omitted constructor
     @GetMapping("/secret/names/{name}") #B
     public List<String> names(@PathVariable String name) {
     	return nameService.getSecretNames(name); #C
     }
}

#A 컨텍스트에서 보호된 메소드를 정의하는 서비스 클래스의 인스턴스를 주입합니다
#B path variable에서 값을 받는 엔드포인트를 정의합니다
#C 사용자의 비밀 이름을 얻기 위해 보호된 메소드를 호출합니다

 

이제 listing 11.6에서 NameService 클래스를 구현하는 방법을 살펴보겠습니다. 이제 우리가 인증에 사용하는 표현식은 #name == authentication.principal.username입니다. 이 표현식에서, 우리는 #name을 getSecretNames() 메소드 파라미터로 전달된 name의 값에 대한 참조로 사용하며, 현재 인증된 사용자를 참조하는 데 사용할 수 있는 인증 객체에 직접 접근할 수 있습니다. 우리가 사용하는 이 표현식은 인증된 사용자의 사용자 이름이 메소드 파라미터를 통해 전송된 값과 같은 경우에만 이 메소드를 호출할 수 있음을 나타냅니다. 다시 말해, 사용자는 자신의 비밀 이름만 검색할 수 있습니다.

 

Listing 11.6 The NameService class defines the protected method

@Service
public class NameService {
     private Map<String, List<String>> secretNames = 
         Map.of(
         "natalie", List.of("Energico", "Perfecto"),
         "emma", List.of("Fantastico"));
     
     @PreAuthorize #A
     ("#name == authentication.principal.username")
     public List<String> getSecretNames(String name) {
     	return secretNames.get(name);
     }
}

#A authorization 표현식에서 메소드 파라미터의 값을 나타내기 위해 #name을 사용합니다

 

애플리케이션을 시작하고 원하는 대로 작동하는지 증명하기 위해 테스트합니다. 다음 코드 스니펫은 사용자의 이름과 동일한 path variable의 값을 제공하여 엔드포인트를 호출할 때 애플리케이션의 동작을 보여줍니다.

curl -u emma:12345 http://localhost:8080/secret/names/emma

The response body is

["Fantastico"]

 

Emma 사용자로 인증했을 때, Natalie의 비밀 이름을 얻으려고 시도합니다. 이 호출은 작동하지 않습니다:

curl -u emma:12345 http://localhost:8080/secret/names/natalie

 

그러나 사용자 Natalie는 자신의 비밀 이름을 얻을 수 있습니다. 다음 코드 스니펫이 이를 증명합니다:

curl -u natalie:12345 http://localhost:8080/secret/names/natalie

 

참고: 애플리케이션의 어떤 계층에든 메소드 보안을 적용할 수 있음을 기억하세요. 이 장에서 제시된 예시에서는 서비스 클래스의 메소드에 적용된 authorizaton 규칙을 찾을 수 있습니다. 하지만, 애플리케이션의 어떤 부분에든 메소드 보안으로 authorization 규칙을 적용할 수 있습니다: 컨트롤러, 리포지토리, 매니저, 프록시 등등.

 

11.3 Applying postauthorization rules

어떤 메소드에 대한 호출을 허용하고 싶지만, 특정 상황에서는 호출자가 리턴 값을 받지 못하게 하고 싶을 수 있습니다. 메소드 호출 후 검증되는 권한 부여 규칙을 적용하고 싶을 때, 우리는 사후 인가(postauthorization)를 사용합니다. 처음에는 조금 어색하게 들릴 수 있습니다: 왜 누군가는 코드를 실행할 수 있지만 결과를 받을 수 없는 걸까요? 글쎄요, 이것은 메소드 자체에 관한 것이 아닙니다, 하지만 이 메소드가 데이터 소스, 예를 들어 웹 서비스나 데이터베이스에서 일부 데이터를 검색한다고 상상해 보세요. 권한 부여를 위해 추가해야 할 조건들은 받은 데이터에 따라 달라집니다. 그래서 메소드의 실행을 허용하지만, 그것이 리턴하는 것을 검증하고, 만약 그것이 기준을 충족하지 않는다면, 호출자가 리턴 값을 접근하는 것을 허용하지 않습니다.

Spring Security로 postauthorization 규칙을 적용하기 위해, 우리는 @PostAuthorize 어노테이션을 사용합니다, 이는 11.2절에서 논의된 @PreAuthorize와 유사합니다. 어노테이션은 authorization 규칙을 정의하는 SpEL을 값으로 받습니다. 우리는 @PostAuthorize 어노테이션을 사용하는 방법과 메소드에 대한 postauthorization 규칙을 정의하는 예제를 계속해서 배웁니다(그림 11.7).

그림 11.7 우리는 메소드가 호출되는 것을 방지하지 않지만, 정의된 권한 부여 규칙이 존중되지 않을 경우 리턴 값이 노출되는 것을 방지합니다.

 

1. BookController는 직원이 읽은 책에 대한 details를 클라이언트가 검색할 수 있는 엔드포인트를 구현합니다.
2. 클라이언트는 직원이 "reader" role을 가질 경우에만 그 직원이 읽은 책에 대한 details를 얻을 수 있습니다. 서비스 메소드를 호출하기 전에 aspect는 이 정보를 가지고 있지 않습니다. aspect는 메소드 호출을 위임하고 호출 후 authorization 규칙을 적용합니다.
3. getBookDetails() 메소드가 직원 "natalie"의 details를 리턴할 때, aspect는 이 직원이 "reader" role을 가지고 있지 않다는 것을 관찰합니다.
4. 요청이 authorization 기준을 충족하지 않기 때문에, 값은 컨트롤러에 리턴되지 않습니다. 대신, aspect는 예외를 발생시킵니다.

 

우리 예제의 시나리오에서, ssia-ch11-ex3이라는 프로젝트를 생성했으며, 이는 Employee 객체를 정의합니다. 우리의 Employee에는 이름, 책 목록, 권한 목록이 있습니다. 우리는 각 Employee를 애플리케이션 사용자와 연결합니다. 이 장의 다른 예제들과 일관성을 유지하기 위해, 우리는 같은 사용자인 Emma와 Natalie를 정의합니다. 우리는 메소드의 호출자가 직원의 details를 직원이 읽기 권한을 가지고 있을 경우에만 얻도록 하고 싶습니다. 직원 레코드와 연결된 권한을 레코드를 검색할 때까지 알 수 없기 때문에, 우리는 메소드 실행 후에 권한 규칙을 적용할 필요가 있습니다. 이러한 이유로, 우리는 @PostAuthorize 어노테이션을 사용합니다.
구성 클래스는 이전 예제에서 사용한 것과 동일합니다. 하지만, 여러분의 편의를 위해 다음 리스트에 그것을 반복해서 제공합니다.

 

Listing 11.7 Enabling method security and defining users

@Configuration
@EnableMethodSecurity
public class ProjectConfig {

    @Bean
    public UserDetailsService userDetailsService() {
        var service = new InMemoryUserDetailsManager();

        var u1 = User.withUsername("natalie")
                    .password("12345")
                    .authorities("read")
                    .build();

        var u2 = User.withUsername("emma")
                    .password("12345")
                    .authorities("write")
                    .build();

        service.createUser(u1);
        service.createUser(u2);

        return service;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
}

 

또한 이름, 책 목록, role 목록을 가진 Employee 객체를 표현할 클래스를 선언할 필요가 있습니다. 다음 리스트는 Employee 클래스를 정의합니다.

 

Listing 11.8 The definition of the Employee class

public class Employee {

    private String name;
    private List<String> books;
    private List<String> roles;

    public Employee(String name, List<String> books, List<String> roles) {
        this.name = name;
        this.books = books;
        this.roles = roles;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<String> getBooks() {
        return books;
    }

    public void setBooks(List<String> books) {
        this.books = books;
    }

    public List<String> getRoles() {
        return roles;
    }

    public void setRoles(List<String> roles) {
        this.roles = roles;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Employee employee = (Employee) o;
        return Objects.equals(name, employee.name) &&
                Objects.equals(books, employee.books) &&
                Objects.equals(roles, employee.roles);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, books, roles);
    }
}

 

우리는 아마도 직원의 details를 데이터베이스에서 얻을 것입니다. 우리 예제를 짧게 유지하기 위해, 우리는 우리의 데이터 소스로 간주되는 몇 개의 레코드가 있는 Map을 사용합니다. 리스트 11.9에서는 BookService 클래스의 정의를 찾을 수 있습니다. BookService 클래스에는 우리가 권한 규칙을 적용하는 메소드도 포함되어 있습니다. @PostAuthorize 어노테이션을 사용할 때 사용하는 표현식이 메소드에 의해 리턴된 값 returnObject를 참조한다는 것을 관찰하세요. postauthorization 표현식은 메서드가 리턴한 값을 사용할 수 있으며, 이 값은 메서드가 실행된 후에 사용할 수 있습니다.

 

Listing 11.9 The BookService class defining the authorized method

@Service
public class BookService {

    private Map<String, Employee> records =
            Map.of("emma",
                   new Employee(
                           "Emma Thompson",
                           List.of("Karamazov Brothers"),
                           List.of("accountant", "reader")),
                   "natalie",
                   new Employee(
                           "Natalie Parker",
                           List.of("Beautiful Paris"),
                           List.of("researcher"))
                  );

    @PostAuthorize("returnObject.roles.contains('reader')")			#A
    public Employee getBookDetails(String name) {
        return records.get(name);
    }
}

#A Defines the expression for postauthorization

 

또한 우리가 권한 규칙을 적용한 메소드를 호출하기 위한 엔드포인트를 구현하는 컨트롤러를 작성합시다. 다음 리스트는 이 컨트롤러 클래스를 제시합니다.

 

Listing 11.10 The controller class implementing the endpoint

@RestController
public class BookController {

    private final BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    @GetMapping("/book/details/{name}")
    public Employee getDetails(@PathVariable String name) {
        return bookService.getBookDetails(name);
    }
}

 

이제 애플리케이션을 시작하고 엔드포인트를 호출하여 앱의 동작을 관찰할 수 있습니다. 다음 코드 스니펫에서는 엔드포인트를 호출하는 예제를 찾을 수 있습니다. 리턴된 Role 리스트에 "reader" 문자열이 포함되어 있기 때문에 모든 사용자가 Emma의 details에 접근할 수 있지만, 어떤 사용자도 Natalie의 details를 얻을 수 없습니다. Emma의 details를 얻기 위해 엔드포인트를 호출하고 사용자 Emma로 인증할 때, 우리는 이 명령을 사용합니다:

curl -u emma:12345 http://localhost:8080/book/details/emma

 

The response body is

{
 "name":"Emma Thompson",
 "books":["Karamazov Brothers"],
 "roles":["accountant","reader"]
}

 

{
    "name": "Emma Thompson",
    "books": [
        "Karamazov Brothers"
    ],
    "roles": [
        "accountant",
        "reader"
    ]
}
스프링 프레임워크의 Spring Web MVC에서 RESTful API를 구축할 때, 컨트롤러 핸들러 메서드의 리턴 값은 HttpMessageConverter에 의해 자동으로 클라이언트에게 보내기 적합한 형식으로 변환됩니다. 디폴트 설정에서, 스프링은 MappingJackson2HttpMessageConverter를 사용하여 리턴 객체를 JSON 형식으로 직렬화합니다. 이는 클라이언트에게 보내지는 응답 데이터 형식이 JSON이 되도록 합니다.
따라서, 위에서 Map의 특정 엔트리 value를 클라이언트에게 리턴하는 경우, 스프링은 이 value를 JSON 형식으로 자동 변환하여 응답으로 보냅니다. 이 과정은 추가적인 설정 없이 스프링의 디폴트 동작으로 이루어집니다.

 

Emma의 details를 얻기 위해 엔드포인트를 호출하고 사용자 Natalie로 인증할 때, 우리는 이 명령을 사용합니다:

curl -u natalie:12345 http://localhost:8080/book/details/emma

 

The response body is

{
 "name":"Emma Thompson",
 "books":["Karamazov Brothers"],
 "roles":["accountant","reader"]
}

 

Calling the endpoint to get the details for Natalie and authenticating with user Emma, we use this command:

엔드포인트를 호출하여 Natalie의 세부 정보를 얻고 사용자 Emma로 인증하려면 다음 커맨드를 사용합니다:

curl -u emma:12345 http://localhost:8080/book/details/natalie

 

The response body is

{
 "status":403,
 "error":"Forbidden",
 "message":"Forbidden",
 "path":"/book/details/natalie"
}

 

Calling the endpoint to get the details for Natalie and authenticating with user Natalie, we use this command:

curl -u natalie:12345 http://localhost:8080/book/details/natalie

 

The response body is

{
 "status":403,
 "error":"Forbidden",
 "message":"Forbidden",
 "path":"/book/details/natalie"
}

 

참고로, 만약 요구사항이 pre-/postauthorization 둘 다 필요로 한다면, 같은 메소드에 @PreAuthorize와 @PostAuthorize를 모두 사용할 수 있습니다.

 

11.4 Implementing permissions for methods

AuthorizationPermission은 보안과 액세스 제어 컨텍스트에서 자주 사용되는 용어입니다. 이 두 용어는 밀접하게 관련되어 있지만, 그 사용법과 적용 범위에서 차이가 있습니다.

Authorization (인가)
 정의: Authorization은 특정 리소스나 시스템에 대한 접근 권한을 부여하는 과정입니다. 이는 사용자의 신원이 확인된 후에 이루어지며, 사용자가 수행할 수 있는 작업과 접근할 수 있는 데이터를 결정합니다.
 적용 범위: 시스템 전체에 걸쳐 사용자 또는 그룹에 대한 접근 권한을 관리하고 제어하는 넓은 개념입니다. 예를 들어, 웹 애플리케이션에서 특정 페이지에 대한 접근을 제어할 수 있습니다.
 표현 방식: 역할 기반 접근 제어(RBAC), 속성 기반 접근 제어(ABAC) 등 다양한 모델을 사용하여 구현될 수 있습니다.

Permission (권한)
 정의: Permission은 특정 작업을 수행할 수 있는 권한을 의미합니다. 이는 일반적으로 시스템 내에서의 개별적인 액션(읽기, 쓰기, 실행 등)에 대한 접근 권한을 나타냅니다.
 적용 범위: 보다 세밀한 수준에서, 특정 리소스에 대한 구체적인 작업을 할 수 있는 권한을 지정합니다. 예를 들어, 파일 시스템에서 파일이나 디렉토리에 대한 읽기/쓰기 권한을 관리하는 것이 이에 해당합니다.
 표현 방식: 보통 더 세부적인 작업 단위로 권한이 정의되며, 특정 리소스나 객체에 대한 구체적인 액션을 허용하거나 거부하는 규칙으로 표현됩니다.

차이점 요약
 범위와 적용 단계: Authorization은 사용자가 시스템 내에서 할 수 있는 일의 전반적인 권한을 관리하는 반면, Permission은 보다 구체적인 액션에 대한 권한을 나타냅니다.
 세부적인 제어: Authorization은 넓은 범위의 접근 권한을 제어하는 반면, Permission은 개별 리소스나 액션에 대한 접근 권한을 더 세밀하게 제어합니다.

일반적으로, "Permission"은 "Authorization" 프로세스의 일부로 간주될 수 있으며, 사용자가 시스템에서 할 수 있는 특정 행동을 제어하는 데 사용됩니다.

지금까지, 사전 인증(preauthorization)과 사후 인증(postauthorization)을 위한 간단한 표현식으로 규칙을 정의하는 방법을 배웠습니다. 이제, 권한 부여 로직이 더 복잡하고 한 줄로 작성할 수 없다고 가정해 봅시다. 큰 SpEL 표현식을 작성하는 것은 분명히 불편합니다. 저는 어떤 상황에서든 긴 SpEL 표현식을 사용하는 것을 추천하지 않습니다, 인증 규칙과 관련되었든 아니든 간에. 이는 단순히 읽기 어려운 코드를 생성하며, 이는 앱의 유지보수성에 영향을 미칩니다. 복잡한 권한 부여 규칙을 구현할 필요가 있을 때, 긴 SpEL 표현식을 작성하는 대신, 로직을 별도의 클래스로 빼내는 것이 좋습니다. 스프링 시큐리티는 권한(permission) 개념을 제공하여, 애플리케이션이 더 읽기 쉽고 이해하기 쉽도록 별도의 클래스에서 권한 부여 규칙을 작성할 수 있게 해줍니다.
이 섹션에서는 프로젝트 내에서 권한을 사용한 권한 부여 규칙을 적용합니다. 이 프로젝트를 ssia-ch11-ex4라고 명명했습니다. 이 시나리오에서, 문서를 관리하는 애플리케이션이 있습니다. 모든 문서에는 문서를 생성한 사용자인 소유자가 있습니다. 기존 문서의 세부 정보를 얻으려면 사용자는 관리자이거나 문서의 소유자여야 합니다. 이 요구 사항을 해결하기 위해 권한 평가자(permission evaluator)를 구현합니다. 다음 리스트는 단순한 자바 객체로서 문서를 정의합니다.

 

Listing 11.11 The Document class

public class Document {

    private String owner;

    public Document(String owner) {
        this.owner = owner;
    }

    public String getOwner() {
        return owner;
    }

    public void setOwner(String owner) {
        this.owner = owner;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Document document = (Document) o;
        return Objects.equals(owner, document.owner);
    }

    @Override
    public int hashCode() {
        return Objects.hash(owner);
    }
}

 

데이터베이스를 모의하고 예제를 더 짧게 만들어 여러분이 편하게 이해할 수 있도록, 저는 몇 개의 문서 인스턴스를 Map에서 관리하는 리포지토리 클래스를 생성했습니다. 이 클래스는 다음 리스트에서 찾을 수 있습니다.

 

Listing 11.12 The DocumentRepository class managing a few Document instances

@Repository
public class DocumentRepository {

    private Map<String, Document> documents =				#A
            Map.of("abc123", new Document("natalie"),
                    "qwe123", new Document("natalie"),
                    "asd555", new Document("emma"));


    public Document findDocument(String code) {
        return documents.get(code);							#B
    }
}

#A Identifies each document by a unique code and names the owner

#B Obtains a document by using its unique identification code

 

서비스 클래스는 코드로 문서를 얻기 위해 리포지토리를 사용하는 메소드를 정의합니다. 서비스 클래스 내의 메소드는 우리가 권한 부여 규칙을 적용하는 그 메소드입니다. 클래스의 로직은 간단합니다. 고유 코드로 Document를 리턴하는 메소드를 정의합니다. 우리는 이 메소드에 @PostAuthorize를 어노테이션을 적용하고, hasPermission() SpEL 표현식을 사용합니다. 이 메소드는 우리가 이 예제에서 추가로 구현하는 외부 권한 부여 표현식을 참조할 수 있게 합니다. 한편, hasPermission() 메소드에 제공하는 파라미터는 메소드에 의해 리턴된 값을 나타내는 returnObject와, 우리가 접근을 허용하는 역할의 이름인 ROLE_admin입니다. 이 클래스의 정의는 다음 리스트에서 찾을 수 있습니다.

 

Listing 11.13 The DocumentService class implementing the protected method

@Service
public class DocumentService {

    private final DocumentRepository documentRepository;

    public DocumentService(DocumentRepository documentRepository) {
        this.documentRepository = documentRepository;
    }

    @PostAuthorize("hasPermission(returnObject, 'ROLE_admin')")				#A
    public Document getDocument(String code) {
        return documentRepository.findDocument(code);
    }
}

#A Uses the hasPermission() expression to refer to an authorization expression

 

권한 로직을 구현하는 것은 우리의 임무입니다. 우리는 PermissionEvaluator 계약을 구현하는 객체를 작성함으로써 이를 수행합니다. PermissionEvaluator 계약은 권한 로직을 구현하는 두 가지 방법을 제공합니다:

  • object와 permission에 의해 - 현재 예제에서 사용되며, PermissionEvaluator가 두 object들을 받는다고 가정합니다: 하나는 권한 부여 규칙의 대상이 되는 객체와 permission 로직을 구현하는 데 필요한 추가 세부 사항을 제공하는 객체입니다.
  • object ID, object type, 그리고 permission에 의해 - permission evaluator가 object ID를 받아들이며, 필요한 object를 검색하는 데 사용할 수 있다고 가정합니다. 또한, object의 유형을 받게 되는데, 같은 permission
    evaluator가 여러 object 유형에 적용되고, permission을 평가하기 위해 추가적인 세부 사항을 제공하는 object가 필요한 경우에 사용될 수 있습니다.

In the next listing, you find the PermissionEvaluator contract with two methods.

 

Listing 11.14 The PermissionEvaluator contract definition

public interface PermissionEvaluator {
     boolean hasPermission(
     			Authentication a, 
     			Object subject,
     			Object permission);
                
     boolean hasPermission(
     			Authentication a, 
     			Serializable id, 
     			String type, 
     			Object permission);
}

 

현재 예제의 경우, 첫 번째 방법을 사용하는 것만으로 충분합니다. 우리는 이미 대상을 가지고 있는데, 이 경우에는 메소드에 의해 반환된 값입니다. 또한, 예제의 시나리오에 따라 어떤 문서에도 접근할 수 있는 ROLE_admin이라는 역할 이름을 보냅니다. 물론, 우리의 예제에서는 권한 평가자 클래스에서 역할의 이름을 직접 사용하고 hasPermission() 객체의 값으로 보내는 것을 피할 수 있었을 것입니다. 여기서, 우리는 예제를 위해서만 전자를 수행합니다. 실제 세계의 시나리오에서는, 더 복잡할 수 있으며, 여러 메소드가 있고, 인증 과정에서 필요한 세부 사항이 각각 다를 수 있습니다. 이러한 이유로, 메소드 레벨에서 인증 로직에 사용할 필요한 세부 사항을 보낼 수 있는 매개변수를 가집니다.