Computer scienceBackendSpring BootSpring Security

Method-level authorization

14 minutes read

Authorization is an essential aspect of any secure system. It decides what resources a user can access and what tasks they can perform. In terms of Spring Security, authorization usually happens at the endpoint level, where users get access to specific URLs based on their roles or permissions. However, endpoint authorization might not be sufficient, especially in complex applications that require fine-grained control. This is when method-level authorization becomes important.

Enabling method-level security

In its default setting, Spring Security features endpoint-level authorization, where specific access rules apply to endpoints matching certain patterns. Method-level authorization offers a way to safeguard individual methods by directly imposing security constraints. This is possible through the use of annotations in your Spring Boot application.

Let's create a demo application with different endpoints to see how method-level authorization can help secure them. After adding the spring-boot-starter-web and spring-boot-starter-security to the build file, let's insert a simple security configuration:

Java
@Configuration
@EnableMethodSecurity
class SecurityConfig {

    @Bean
    UserDetailsService userDetailsService() {
        return new InMemoryUserDetailsManager(
                User.withUsername("john")
                        .password("{noop}123")
                        .authorities("read")
                        .build(),
                User.withUsername("jane")
                        .password("{noop}123")
                        .authorities("write")
                        .build()
        );
    }
}
Kotlin
@Configuration
@EnableMethodSecurity
class SecurityConfig {

    @Bean
    fun userDetailsService(): UserDetailsService {
        return InMemoryUserDetailsManager(
            User.withUsername("john")
                .password("{noop}123")
                .authorities("read")
                .build(),
            User.withUsername("jane")
                .password("{noop}123")
                .authorities("write")
                .build()
        )
    }
}

We construct a custom UserDetailsService with two hardcoded users here. We keep the default security filter chain configuration that enables basic HTTP authentication and demands that each user authenticate to access any endpoint.

To enable method-level authorization, we included the @EnableMethodSecurity annotation to the security configuration class. When used with default parameters, this annotation allows for certain method annotations: @PreAuthorize, @PostAuthorize, @PreFilter, and @PostFilter. We will address these in the following section.

Method annotations

Let's discuss the four enabled method-level security annotations:

  • @PreAuthorize: You use this annotation to fix the access-control expressions that need evaluation before a method is called. If the evaluation result is true, the method invocation will be permitted; if it's false, an AccessDeniedException will be thrown, and the client will receive the 403 FORBIDDEN status code.

  • @PostAuthorize: This annotation allows for a check after a method has been invoked. This is useful when the security decision should come after the method execution but before its return. For example, when the method first needs to fetch or create an object, then a decision should be made if the client can access that object or not.

  • @PreFilter: We use this annotation to filter input arguments before invoking a method, using a given predicate within the annotation. It is useful when a method takes a collection, and you want to ensure that the collection only contains elements the user is authorized to act upon. You have to make sure the collection is mutable!

  • @PostFilter: This annotation is used to filter elements from a returned collection. The filtering happens after the method has finished execution. Again, make sure the collection is mutable!

Method security annotations support Spring Expression Language (SpEL) expressions to provide dynamic access control. SpEL expressions can access the current authentication, principal, and even method parameters, for example:

  • @PreAuthorize("hasRole('ADMIN')"): Only users with the ADMIN role can access the annotated method.

  • @PreAuthorize("#username == authentication.name"): The annotated method can be accessed only if its username parameter matches the current user's username.

We will see more examples in the next section.

@PreAuthorize annotation

Let's create a controller class with methods to apply security annotations:

Java
@RestController
class DemoController {

    @GetMapping("/hello")
    public String hello() {
        return "Hello!";
    }

    // other controller methods go here...
}
Kotlin
@RestController
class DemoController {

    @GetMapping("/hello")
    fun hello(): String = "Hello!"

    // other controller methods go here...
}

We haven't applied any method security annotation yet, but this method is only accessible to authenticated users. But why? Because the authentication and authorization filters will be applied before the request reaches the controller. Keep this in mind!

Let's change the access rule for the hello method and make it available only to users possessing the read authority:

Java
@GetMapping("/hello")
@PreAuthorize("hasAuthority('read')")
public String hello() {
    return "Hello!";
}
Kotlin
@GetMapping("/hello")
@PreAuthorize("hasAuthority('read')")
fun hello(): String = "Hello!"

Consider the scenario where we want to allow each user to access their personal profiles but not those of other users:

Java
@GetMapping("/profile/{name}")
@PreAuthorize("authentication.name == #name")
public String profile(@PathVariable String name) {
    return "You are " + name;
}
Kotlin
@GetMapping("/profile/{name}")
@PreAuthorize("authentication.name == #name")
fun profile(@PathVariable name: String): String = "You are $name"

So, if the user named john sends a request to the endpoint /profile/john, they will be granted access, but if the same user tries to access the /profile/jane endpoint, the request will be denied.

Remember, we are not restricted to securing only the methods of a controller. You can apply method-level security to any public method of any bean. Let's add a service class and see it in action with new examples.

@PreFilter annotation

Java
@Service
class DemoService {

    @PreFilter(filterTarget = "names", value = "filterObject != authentication.name")
    public List<String> process(List<String> names) {
        return names.stream()
                .map(String::toUpperCase)
                .toList();
    }

    // other service methods go here...
}
Kotlin
@Service
class DemoService {
    
    @PreFilter(filterTarget = "names", value = "filterObject != authentication.name")
    fun process(names: List<String>): List<String> {
        return names.map { it.uppercase() }.toList()
    }
    
    // other service methods go here...
}

This bean has a public method secured with the @PreFilter annotation. The filterTarget parameter indicates the collection to which the filter applies, and the value parameter specifies the SpEL expression to apply to the collection elements. In this example, any element in the collection that does not equal the current user's name will be filtered out. You must ensure that your collection is mutable, otherwise the UnsupportedOperationException will be thrown. Now, let's create another endpoint and use the DemoService class to process the requests:

Java
@RestController
class DemoController {
    private final DemoService service;

    DemoController(DemoService service) {
        this.service = service;
    }

    @GetMapping("/list")
    public List<String> list() {
        List<String> names = new ArrayList<>();
        names.add("john");
        names.add("jane");
        names.add("bob");
        return service.process(names);
    }

    // other controller methods go here...
}
Kotlin
@RestController
class DemoController(private val service: DemoService) {

    @GetMapping("/list")
    fun list(): List<String> {
        val names = mutableListOf(
            "john",
            "jane",
            "bob"
        )

        return service.process(names)
    }

    // other controller methods go here...
}

Now, if the user named john sends a request to the /list endpoint, they will receive the following response body:

[
  "JANE",
  "BOB"
]

@PostFilter annotation

Let's add one more method to the DemoService class:

Java
@PostFilter("filterObject == authentication.name")
public List<String> getNames() {
    return Stream.of("john", "jane", "bob")
            .collect(Collectors.toCollection(ArrayList::new));
}
Kotlin
@PostFilter("filterObject == authentication.name")
fun getNames(): List<String> = listOf("john", "jane", "bob").toList()

According to the expression, only elements that equal the current user's name will pass the filter. Next, we'll develop an endpoint that fetches names by delegating to the getNames method:

Java
@GetMapping("/service")
public List<String> service() {
    return service.getNames();
}
Kotlin
@GetMapping("/service")
fun service(): List<String> = service.getNames()

Now, when the user named john sends a request to the /service endpoint, a JSON array with a single element that matches the user's name will be returned:

[
  "john"
]

Similarly, when the user named jane requests the /service endpoint, a JSON array with one element matching the user's name will be returned:

[
  "jane"
]

@PostAuthorize annotation

Generally, it is not advisable to compose complex SpEL expressions. As an alternative, you can establish a custom method in a bean and reference it in your SpEL expression.

Imagine we have a DecisionMaker class (you can name it whatever you like) with a method that accepts a UserDetails object. If it meets certain conditions, it returns true; otherwise, false:

Java
@Component("dm")
class DecisionMaker {
    public boolean decide(UserDetails userDetails) {
        return userDetails != null && userDetails.getUsername().startsWith("jo");
    }
}
Kotlin
@Component("dm")
class DecisionMaker {
    fun decide(userDetails: UserDetails?): Boolean {
        return userDetails != null && userDetails.username.startsWith("jo")
    }
}

You can use this function in any security annotation like this:

Java
@GetMapping("/details")
@PostAuthorize("@dm.decide(returnObject)")
public UserDetails getName() {
    return (UserDetails) SecurityContextHolder
            .getContext()
            .getAuthentication()
            .getPrincipal();
}
Kotlin
@GetMapping("/details")
@PostAuthorize("@dm.decide(returnObject)")
fun getName(): UserDetails? {
    return SecurityContextHolder.getContext().authentication.principal as? UserDetails
}

In the previous example, the returnObject is a reference to the object sent back by the method, and @dm is a reference to the DecisionMaker bean. Only users whose usernames start with jo will be allowed access to their user details, while others will be denied.

Conclusion

Spring Security's method-level security is a versatile tool providing fine control over access to specific methods in your application. By using annotations such as @PreAuthorize, @PostAuthorize, @PreFilter, and @PostFilter, you can outline complex access-control rules that go beyond basic endpoint-level authorization. Together with the versatility of Spring Expression Language (SpEL), these annotations allow for dynamic and adaptable security configurations that can meet a wide range of application needs.

9 learners liked this piece of theory. 1 didn't like it. What about you?
Report a typo