How Does Spring Security Work: Granular Security with Custom Security Expressions

What is spring security, and how does it work?
Spring Security is an application security framework from the Spring ecosystem. It supports several different authentication and authorization schemes compatible with the latest industry standards regarding this sensitive topic. It is built on a robust architecture that also allows easy customizations and extensibility of the framework’s core functionality.
In this blog post, we will explore several approaches in which Spring Security can be customized:
- through the means of custom Spring EL security expressions and specialized beans,
- to achieve a more complex, and
- granular security implementation model in cases when the simple role-based access control model (RBAC) does not suffice.
Basic Spring Security expressions and how to use them
Spring Security implements some basic authorization expressions by default. If you’ve ever used it, some of these should already be familiar.
- permitAll, denyAll
- hasRole, hasAnyRole
- hasAuthority, hasAnyAuthority
- isAnonymous, isAuthenticated, isRememberMe, isFullyAuthenticated
- hasPermission
You can find more information on these default expressions in the official docs: https://docs.spring.io/spring-security/site/docs/current/reference/html5/#el-access.
Now, given a simple spring web security configuration, these could be utilized in the following way.
package com.agency04.blog.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
public class BasicExpressionsWebSecurityDemoConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(final AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("admin").password("{noop}admin").roles("ADMIN")
.and()
.withUser("basic").password("{noop}basic").roles("BASIC");
}
@Override
protected void configure(final HttpSecurity http) throws Exception {
http
.authorizeRequests()
.mvcMatchers("/admin-resource").hasRole("ADMIN")
.mvcMatchers("/basic-resource").hasAnyRole("BASIC", "ADMIN")
.mvcMatchers("/authenticated-resource").authenticated()
.mvcMatchers("/**").permitAll()
.and()
.httpBasic();
}
}
Here, the first configure method is a simple authentication configuration that is just an in-memory store of users. For demonstration purposes, there are two users:
- admin user with ADMIN role and no password encryption rules for simplicity
- basic user with BASIC role and no password encryption rules for simplicity
Furthermore, in the second configure method httpBasic() is being used. There is no particular reason rather than its simplicity and convenience for demo purposes. This means that we opted in for HTTP basic authentication, and Spring Security will configure everything to support this auth scheme (more on HTTP basic auth https://datatracker.ietf.org/doc/html/rfc7617 ). If we tried to access any of the protected controller endpoints without the authorization header containing the credentials, we would get 401 unauthorized responses.
Now for the main topic of this article, the second configure method also demonstrates the usage of the aforementioned default Spring Security authorization expressions. For example, /admin-resource URI path can be accessed only by a user containing role ADMIN, while /basic-resource may be accessed by either admin or basic user. Moreover, /authenticated-resource assumes that the user must be authenticated, regardless of the role, to access this endpoint.
The code for our simple secured demo controller is provided below.
package com.agency04.blog.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class SecuredExampleController {
@GetMapping("/admin-resource")
public String getAdminResource() {
return "Only admin can see this";
}
@GetMapping("/basic-resource")
public String getBasicResource() {
return "Only basic user can see this";
}
@GetMapping("/authenticated-resource")
public String getAuthenticatedResource() {
return "Everyone who is authenticated can see this";
}
@GetMapping("/public-resource")
public String getPublicResource() {
return "This resource is public";
}
}
Granular authorization rules with global method security
Besides declaring all the route guards in one central place, one could use something spring calls method security for more control and better granularity. This allows access control on a Java method level by using PreAuthorize, PostAuthorize, and Secured annotations and providing them with the security expression.
Below is an example of the same secured demo controller. This time it’s not relying on a central configuration for the authorization definitions. Instead, we used method security with just mentioned annotations to apply the security expressions.
@RestController
public class SecuredExampleController {
@GetMapping("/admin-resource")
@PreAuthorize("hasRole('ADMIN')")
public String getAdminResource() {
return "Only admin can see this";
}
@GetMapping("/basic-resource")
@PreAuthorize("hasAnyRole('BASIC', 'ADMIN')")
public String getBasicResource() {
return "Only basic user can see this";
}
@GetMapping("/authenticated-resource")
@PreAuthorize("isAuthenticated()")
public String getAuthenticatedResource() {
return "Everyone who is authenticated can see this";
}
@GetMapping("/public-resource")
public String getPublicResource() {
return "This resource is public";
}
}
Additionally, for this to work, security configuration needs to be annotated with @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) so that the Spring is aware PreAuthorize, PostAuthorize, and Secured annotations will be used and that it can set up the proxies behind the scenes which will handle this boilerplate method access control logic.
A few more words on the Spring Security concepts
All authorization schemes in Spring Security have in common that they rely on a central Spring Security Context object which holds the security-specific information about the user that is authenticated by the system. Such custom-logged user representation can be provided by implementing a class that either extends org.springframework.security.core.userdetails.User or implements the org.springframework.security.core.userdetails.UserDetails interface and making the common UserDetailsService, which is used as an entry point for fetching the security-specific user data at the time of authentication, return this object. This is important because the security expressions we are writing about rely on this central Spring Security context, and so will the custom expressions when we implement them.
How to create a custom security expression method in Spring Security?
To be able to write custom security expressions, the default Spring Security configuration regarding method security needs to be tweaked a little bit. First, write a custom expressions class that will implement the custom expression. Expressions are implemented as public class methods.
package com.agency04.blog.expression;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import java.util.Optional;
public class CustomSecurityExpression {
public boolean isUsernameEqualToBasic() {
final String expectedUsername = "basic";
final Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (isAuthenticated(auth)) {
final User authUser = (User) auth.getPrincipal();
return expectedUsername.equals(authUser.getUsername());
}
return false;
}
private static boolean isAuthenticated(final Authentication authentication) {
return authentication != null
&& authentication.isAuthenticated()
&& !(authentication instanceof AnonymousAuthenticationToken);
}
}
Here, a custom expression is implemented that checks if a user’s username is “basic”. Later, this can be used to limit access to backend methods where the premise is that only the user with this username may access the resource.
For now, this class has nothing to do with the existing Spring Security infrastructure, so let’s start connecting the dots. Next, an extension of a SecurityExpressionRoot class needs to be implemented, which holds the definitions for default Spring Security expressions.
MethodSecurityExpressionsOperations
This class also needs to implement MethodSecurityExpressionsOperations class so that it is compatible with the next step and which is creating an extension of the DefaultMethodSecurityExpressionHandler, which relies on the SecurityExpressionRoot to expose those default security expressions on a method level for use.
public class CustomMethodSecurityExpressionRoot extends SecurityExpressionRoot implements MethodSecurityExpressionOperations {
private Object filterObject;
private Object returnObject;
private final CustomSecurityExpression expression = new CustomSecurityExpression();
public CustomMethodSecurityExpressionRoot(final Authentication authentication) {
super(authentication);
}
public boolean isUsernameEqualToBasic() {
return expression.isUsernameEqualToBasic();
}
@Override
public Object getFilterObject() {
return this.filterObject;
}
@Override
public Object getReturnObject() {
return this.returnObject;
}
@Override
public Object getThis() {
return this;
}
@Override
public void setFilterObject(final Object obj) {
this.filterObject = obj;
}
@Override
public void setReturnObject(final Object obj) {
this.returnObject = obj;
}
}
Here, the method isUserNameEqualToBasic() is defined as a new expression within the root of the existing expression. For its implementation, it just calls CustomSecurityExpression. Here we have the actual implementation for the new expressions for better code reuse later.
You may also notice the additional filterObject and returnObject properties and the corresponding getters and setters enforced by the MethodSecurityExpressionOperations interface. This code is actually just copied from the default MethodSecurityExpressionRoot class, which is Spring Security’s internal implementation that is used by DefaultMethodSecurityExpressionHandler by default.
The code was copied and not simply extended because this class has a package-private modifier. It is entirely inaccessible to us. The goal is to add the custom expressions and still retain the default behavior of the existing Spring Security infrastructure.
Extension code for the DefaultMethodSecurityExpressionHandler
package com.agency04.blog.expressionhandler;
import com.agency04.blog.expression.CustomMethodSecurityExpressionRoot;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionOperations;
import org.springframework.security.core.Authentication;
public class CustomMethodSecurityExpressionHandler extends DefaultMethodSecurityExpressionHandler {
@Override
protected MethodSecurityExpressionOperations createSecurityExpressionRoot(
final Authentication authentication,
final MethodInvocation invocation
) {
final CustomMethodSecurityExpressionRoot root = new CustomMethodSecurityExpressionRoot(authentication);
root.setPermissionEvaluator(getPermissionEvaluator());
root.setTrustResolver(getTrustResolver());
root.setRoleHierarchy(getRoleHierarchy());
return root;
}
}
This extension is used to override the creation of the security expression root to be used when evaluating the security expressions. It uses the CustomMethodSecurityExpressionRoot class where the new expression is defined.
To wrap up this method of security configuration, there is one more step. And it’s providing an additional Spring configuration class that extends GlobalMethodSecurityConfiguration to register the new expression handler with the existing Spring Security infrastructure.
package com.agency04.blog.config;
import com.agency04.blog.expressionhandler.CustomMethodSecurityExpressionHandler;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.access.expression.DenyAllPermissionEvaluator;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class CustomSecurityExpressionsAuthorizationConfig extends GlobalMethodSecurityConfiguration {
private final ApplicationContext applicationContext;
public CustomSecurityExpressionsAuthorizationConfig(final ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
final PermissionEvaluator permissionEvaluator = new DenyAllPermissionEvaluator();
final CustomMethodSecurityExpressionHandler expressionHandler = new CustomMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(permissionEvaluator);
expressionHandler.setApplicationContext(applicationContext);
return expressionHandler;
}
}
Here, @EnableWebSecurity annotation is used to avoid conflicting bean definitions with default Spring Security autoconfiguration. We also instantiated permissionEvaluator as DenyAllPermissionEvaluator, the default implementation used in standard Spring Security configuration.
Now it is time to include and test one additional method in the endpoints controller using the new security expression.
@GetMapping("/basic-username-allowed-resource")
@PreAuthorize("isUsernameEqualToBasic()")
public String getBasicUsernameResource() {
return "Only user that is authenticated and has username equal to 'basic' can see this";
}
If you have tried it out, it should be working now and allowing access for the basic user and not for the admin user because it strictly expects the username to match the value “basic”.
Using custom security expressions in the server-side rendered views
If you have ever worked with a server-side rendering engine, thymeleaf, which is very popular in the Spring ecosystem, you may have also stumbled upon its security dialect. This allows you to use the default Spring Security expressions in thymeleaf HTML code. One of such usages may look something like this. <div sec:authorize="hasRole('ADMIN')"> This content will be visible only if user has role admin. </div>
What if we want to use the custom method security expressions like this, to have a common library of expressions that can be used on the frontend as well as in the backend? If using a server-side rendering technology, it would be nice to have.
To achieve this, the Spring Security configuration needs to be expanded a bit more further, but nothing too much. Similarly to how method security expressions are defined, the same can be achieved for web security expressions. These are the terminology differences that Spring uses.
The same way the CustomMethodSecurityExpressionRoot is defined, a CustomWebSecurityExpressionRoot needs to be defined which extends Spring’s WebSecurityExpressionRoot that also relies on the base SecurityExpressionRoot that was seen earlier.
package com.agency04.blog.expression;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.expression.WebSecurityExpressionRoot;
public class CustomWebSecurityExpressionRoot extends WebSecurityExpressionRoot {
private final CustomSecurityExpression expression = new CustomSecurityExpression();
public CustomWebSecurityExpressionRoot(final Authentication a, final FilterInvocation fi) {
super(a, fi);
}
public boolean isUsernameEqualToBasic() {
return expression.isUsernameEqualToBasic();
}
}
Next, an extension of DefaultWebSecurityExpressionHandler needs to be defined, similarly as was done earlier for the DefaultMethodSecurityExpressionHandler.
package com.agency04.blog.expressionhandler;
import com.agency04.blog.expression.CustomWebSecurityExpressionRoot;
import org.springframework.security.access.expression.SecurityExpressionOperations;
import org.springframework.security.authentication.AuthenticationTrustResolver;
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler;
import org.springframework.security.web.access.expression.WebSecurityExpressionRoot;
public class CustomWebSecurityExpressionHandler extends DefaultWebSecurityExpressionHandler {
private final AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();
@Override
protected SecurityExpressionOperations createSecurityExpressionRoot(
final Authentication authentication,
final FilterInvocation fi) {
WebSecurityExpressionRoot webSecurityExpressionRoot = new CustomWebSecurityExpressionRoot(authentication, fi);
webSecurityExpressionRoot.setPermissionEvaluator(getPermissionEvaluator());
webSecurityExpressionRoot.setRoleHierarchy(getRoleHierarchy());
webSecurityExpressionRoot.setTrustResolver(this.trustResolver);
return webSecurityExpressionRoot;
}
}
Finally, this custom expression handler needs to be declared as a Spring bean and included in the existing WebSecurityConfigurerAdapter configuration by using expressionHandler property in the HttpSecurity builder.
package com.agency04.blog.config;
import com.agency04.blog.expressionhandler.CustomWebSecurityExpressionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
public class BasicExpressionsWebSecurityDemoConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(final AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("admin").password("{noop}admin").roles("ADMIN")
.and()
.withUser("basic").password("{noop}basic").roles("BASIC");
}
@Override
protected void configure(final HttpSecurity http) throws Exception {
http
.authorizeRequests()
.mvcMatchers("/**").permitAll() // authorization is achieved on a controller method level
.expressionHandler(customWebSecurityExpressionHandler())
.and()
.formLogin();
}
@Bean
public CustomWebSecurityExpressionHandler customWebSecurityExpressionHandler() {
return new CustomWebSecurityExpressionHandler();
}
}
Notice that formLogin() is now being used as the authentication scheme instead of httpBasic(). HTTP basic is more convenient for web services and APIs. Form login is most typically used when working with server-side rendered client views.
Achieving the similar functionality using custom spring beans
We looked into how to create a custom Spring Security extension to allow for new expression implementation. This could then be used for granular backend method security and on the server-side rendered web views.
In this part, we have an example of how similar functionality could be provided by using plain Spring beans. In most cases, it is much simpler and maybe just enough for most use cases.
The configuration extensions above are beneficial when there are complicated Spring Security Context principal objects that may need additional rules to perform authorization. Still, the Spring beans way has few benefits. It avoids relatively complicated configurations and security expressions that behave and work on the same principles as all other Spring beans in the application.
The idea relies on a simple principle that expressions fed into the @PreAuthorize annotation. For example, we could also call Spring beans directly by using @beanName.methodName() syntax. Here’s a simple example to achieve the same thing as in the above custom security expression example.
package com.agency04.blog.component;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
public class CustomSecurityExpression {
public boolean isUsernameEqualToBasic() {
final String expectedUsername = "basic";
final Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (isAuthenticated(auth)) {
final User authUser = (User) auth.getPrincipal();
return expectedUsername.equals(authUser.getUsername());
}
return false;
}
private static boolean isAuthenticated(final Authentication authentication) {
return authentication != null
&& authentication.isAuthenticated()
&& !(authentication instanceof AnonymousAuthenticationToken);
}
}
And then, the controller method looks something like this.
@GetMapping("/basic-username-allowed-resource-provided-through-spring-bean")
@PreAuthorize("@customSecurityExpression.isUsernameEqualToBasic()")
public String getBasicUsernameResourceProvidedBySpringBean() {
return "Only user that is authenticated and has username equal to 'basic' can see this";
}
Conclusion
In this blog post, we had a quick peek into how we can leverage the modularity of the Spring Security architecture to enhance the default functionality and tailor it to our specific needs.
We also provided an example of achieving similar functionality by relying on existing powerful concepts that Spring offers. The examples were pretty simple for the sake of the demonstration. Still, the concept holds regardless of how complex the security logic may be.
Hope you enjoyed it and learned something new. Until next time, cheers!