Situation
When developing web applications, it is often useful to have sane security defaults. In the case of my web API, I want to shut down access to all resources by default. No one should be able to access endpoints.
However, I want to explicitly override these rules on the controllers and/or on the endpoint methods themselves, using annotations like @Secured
, @RolesAllowed
, @PermitAll
or @PreAuthorize
.
Furthermore, I want the /login
and /register
paths to be public.
For now, I have created following security configuration (I have stripped it down a bit to the most important parts):
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain =
http
.csrf { it.disable() }
.authorizeHttpRequests {
it
.requestMatchers(HttpMethod.POST, "/auth/register", "/auth/login")
.permitAll()
.anyRequest()
.denyAll()
}
// ... further config
.build()
As you can see, I have taken down access to all paths except the ones mentioned above.
Problem
The problem with my configuration is that denyAll()
cancels the filter chain prematurely. That is, the framework does not evaluate my controller/method level annotations at all. A 403
forbidden is returned automatically.
What I want
Example 1
Now I want to do the following in my controllers:
@RestController
class TodoController(private val todoService: TodoService) {
// ONLY users with role admin
@RolesAllowed(Role.ADMIN_VALUE)
// @<Some HTTP mapping>
fun method0()
// ONLY users with roles admin and user
@RolesAllowed(Role.ADMIN_VALUE, Role.USER_VALUE)
// @<Some HTTP mapping>
fun method1()
// ANY "authn" user, independent of their "auhtz" status
@PreAuthorize("isAuthenticated()")
fun method2()
// Anyone, independent of their "authn" / "authz" status
@PermitAll
// @<Some HTTP mapping>
fun method3()
// UUUUPPPPPPSSS..... Fot to annotate? No problem, NO ONE can call this!
// @<Some HTTP mapping>
fun method4()
}
As you can see above, the annotations enable granular control over the endpoints. If a developer fets to annotate the endpoint methods properly, no damage is done, because the system rejects everything by default.
Example 2
Suppose we have a controller in which all endpoint methods require the same settings. In these cases, it would be helpful to just annotate the class itself and the methods inherit this setting.
// Endpoint methods in this controller can be called by anyone, regardless of "authn" / "authz" status
@PermitAll
@RestController
class TodoController
// Endpoint methods in this controller can ONLY be called by admins
@RolesAllowed(Role.ADMIN_VALUE)
@RestController
class TodoController
// Endpoint methods in this controller can ONLY be called admins and regular users
@RolesAllowed(Role.ADMIN_VALUE, Role.USER_VALUE)
@RestController
class TodoController
// Endpoint methods in this controller can ONLY be called by logged-in users (regardless of their roles)
@PreAuthorize("isAuthenticated()")
@RestController
class TodoController
/*
Endpoint methods in this controller cannot be called by anyone,
because neither the controller, nor the methods have security annotations.
The default configuration of denying everything automatically kicks in.
*/
@RestController
class TodoController
Example 3
As you might have imagined, in the case that the controller and the methods are both annotated, I want the method annotations to over rule the controller annotations:
@PreAuthorize("isAuthenticated()")
@RestController
class TodoController {
// Overrides "isAuthenticated": Now anyone can call this
@PermitAll
fun foo()
// Has inherited "isAuthenticated" from the controller
fun bar()
// Overrides "isAuthenticated": Only users with role admin can access
@Secured(Role.ADMIN_VALUE)
fun baz()
}
Question
How does one accomplish this? I know for a fact that this is somehow possible in Spring Boot, because the Vaadin framework follows a similar approach. However, since this is a regular Spring Boot application without dependencies to Vaadin, I am clueless on how to do this. I could not find a satisfying answer either.
Lastly... Yes, I know, I can just set the path configurations in my SecurityConfig
for every endpoint method. However, that's not what I want. I want explicit control on the controllers/endpoint methods.
Situation
When developing web applications, it is often useful to have sane security defaults. In the case of my web API, I want to shut down access to all resources by default. No one should be able to access endpoints.
However, I want to explicitly override these rules on the controllers and/or on the endpoint methods themselves, using annotations like @Secured
, @RolesAllowed
, @PermitAll
or @PreAuthorize
.
Furthermore, I want the /login
and /register
paths to be public.
For now, I have created following security configuration (I have stripped it down a bit to the most important parts):
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain =
http
.csrf { it.disable() }
.authorizeHttpRequests {
it
.requestMatchers(HttpMethod.POST, "/auth/register", "/auth/login")
.permitAll()
.anyRequest()
.denyAll()
}
// ... further config
.build()
As you can see, I have taken down access to all paths except the ones mentioned above.
Problem
The problem with my configuration is that denyAll()
cancels the filter chain prematurely. That is, the framework does not evaluate my controller/method level annotations at all. A 403
forbidden is returned automatically.
What I want
Example 1
Now I want to do the following in my controllers:
@RestController
class TodoController(private val todoService: TodoService) {
// ONLY users with role admin
@RolesAllowed(Role.ADMIN_VALUE)
// @<Some HTTP mapping>
fun method0()
// ONLY users with roles admin and user
@RolesAllowed(Role.ADMIN_VALUE, Role.USER_VALUE)
// @<Some HTTP mapping>
fun method1()
// ANY "authn" user, independent of their "auhtz" status
@PreAuthorize("isAuthenticated()")
fun method2()
// Anyone, independent of their "authn" / "authz" status
@PermitAll
// @<Some HTTP mapping>
fun method3()
// UUUUPPPPPPSSS..... Fot to annotate? No problem, NO ONE can call this!
// @<Some HTTP mapping>
fun method4()
}
As you can see above, the annotations enable granular control over the endpoints. If a developer fets to annotate the endpoint methods properly, no damage is done, because the system rejects everything by default.
Example 2
Suppose we have a controller in which all endpoint methods require the same settings. In these cases, it would be helpful to just annotate the class itself and the methods inherit this setting.
// Endpoint methods in this controller can be called by anyone, regardless of "authn" / "authz" status
@PermitAll
@RestController
class TodoController
// Endpoint methods in this controller can ONLY be called by admins
@RolesAllowed(Role.ADMIN_VALUE)
@RestController
class TodoController
// Endpoint methods in this controller can ONLY be called admins and regular users
@RolesAllowed(Role.ADMIN_VALUE, Role.USER_VALUE)
@RestController
class TodoController
// Endpoint methods in this controller can ONLY be called by logged-in users (regardless of their roles)
@PreAuthorize("isAuthenticated()")
@RestController
class TodoController
/*
Endpoint methods in this controller cannot be called by anyone,
because neither the controller, nor the methods have security annotations.
The default configuration of denying everything automatically kicks in.
*/
@RestController
class TodoController
Example 3
As you might have imagined, in the case that the controller and the methods are both annotated, I want the method annotations to over rule the controller annotations:
@PreAuthorize("isAuthenticated()")
@RestController
class TodoController {
// Overrides "isAuthenticated": Now anyone can call this
@PermitAll
fun foo()
// Has inherited "isAuthenticated" from the controller
fun bar()
// Overrides "isAuthenticated": Only users with role admin can access
@Secured(Role.ADMIN_VALUE)
fun baz()
}
Question
How does one accomplish this? I know for a fact that this is somehow possible in Spring Boot, because the Vaadin framework follows a similar approach. However, since this is a regular Spring Boot application without dependencies to Vaadin, I am clueless on how to do this. I could not find a satisfying answer either.
Lastly... Yes, I know, I can just set the path configurations in my SecurityConfig
for every endpoint method. However, that's not what I want. I want explicit control on the controllers/endpoint methods.
- Can't be done in a tidy way. Rather add a an ArchUnit annotation check (archunit./use-cases) to prevent any unannotated controller methods being released. – John Williams Commented Feb 16 at 13:42
2 Answers
Reset to default 2Recently I faced this same scenario that I needed to implement for a Java application. After walking-through the Spring Security docs and code I decided to implement custom AuthorizationManager that replaces the default PreAuthorizeAuthorizationManager with my own authorization decision.
This custom PreAuthorizeAuthorizationManager
only delegates the decision to the common Spring Security implementation, and if it doesn't have a decision (the method called does not have an JSR250/PreAuthorize annotation) return a AuthorizationDecision
that denies the request.
@Component
public class CustomDelegatingPreAuthorizeAuthorizationManager implements AuthorizationManager<MethodInvocation> {
private static final AuthorizationDecision DENIED_DECISION = new AuthorizationDecision(false);
private final AuthorizationManager<MethodInvocation> preAuthorizeDelegate = new PreAuthorizeAuthorizationManager();
private final AuthorizationManager<MethodInvocation> jsr250Delegate = new Jsr250AuthorizationManager();
@Override
public AuthorizationDecision check(final Supplier<Authentication> authentication, final MethodInvocation object) {
return Optional.ofNullable(preAuthorizeDelegate.authorize(authentication, object))
.or(() -> Optional.ofNullable(jsr250Delegate.authorize(authentication, object)))
.filter(AuthorizationDecision.class::isInstance)
.map(AuthorizationDecision.class::cast)
.orElse(DENIED_DECISION);
}
}
Then, we need to publish the method interceptor with a pointcut to when this custom AuthorizationManager
should run. The example below was inspired in the Spring's PreAuthorize method interceptor implementation, which the pointcut is for methods annotated with the @PreAuthorize
, and I replaced to run for all methods annotated with @RequestMapping
, which would be all controller endpoints. (You can add any pointcut you want here like @RestController
for example)
Note: If you observe I'm delegating the jsr250 AuthorizationManager instead of enabling in the @EnableMethodSecurity(jsr250Enabled = true)
. The reason is in the CustomAuthorizationManager, always returns an AuthorizationDecision
and if the mehtod does not have an annotation will be a denied decision, making the spring not call the next AuthorizationInterception
since the JSR250 order is after PRE_AUTHORIZE
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public Advisor preAuthorize(final CustomDelegatingPreAuthorizeAuthorizationManager manager) {
final Pointcut pointcut = Pointcuts.union(
new AnnotationMatchingPointcut(null, RequestMapping.class, true),
new AnnotationMatchingPointcut(RequestMapping.class, true)
);
final AuthorizationManagerBeforeMethodInterceptor interceptor = new AuthorizationManagerBeforeMethodInterceptor(
pointcut,
manager
);
interceptor.setOrder(AuthorizationInterceptorsOrder.PRE_AUTHORIZE.getOrder());
return interceptor;
}
}
The last but not least, we need to change in the SecurityFilterChain
to permit all requests, which will make the Spring always call the PreAuthorizedMethodInterceptor
in the filter chain, and doing the decision in the CustomAuthorizationManager
.
@EnableWebSecurity
public class WebSecurityConfiguration {
@Bean
public SecurityFilterChain defaultSecurityFilterChain(
final HttpSecurity http
) throws Exception {
return http
.authorizeHttpRequests(authorize ->
authorize
.anyRequest().permitAll()
)
.build();
}
}
Unfortunately, I don't think this approach will cover all examples you gave, especially of the example 3, that you have a @PreAuthorize
in the class level and a @PermitAll
to override in method level. As I mentioned before, the CustomAuthorizationManager
will always return a AuthorizationDecision
, then when find a @PreAuthorize
in the controller level does not executes a JSR250 for the method.
For my scenarios that was OK, once I always use the @PreAuthorize
, and this should work.
@PreAuthorize("isAuthenticated()")
@RestController
@RequestMapping("todo")
public class TodoController {
// Overrides "isAuthenticated": Now anyone can call this
@PreAuthorize("permitAll()")
public void foo() {}
}
I'm not sure if this is the right approach, but was what worked to me and hope that can help you.
EDIT:
As asked about the 403 in the Unauthorized response, Spring removed a time ago the Http401AuthenticationEntryPoint, and as suggested in that same thread GitHub thread I added manually an AuthenticationEntryPoint
for my scenario.
http.exceptionHandling(exceptions ->
exceptions
.accessDeniedHandler(default403AccessDeniedHandler)
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
)
The default403AccessDeniedHandler
is just a implementation from AccessDeniedHandler
to make sure for every request denied with respond with 403
@Component
public class Default403AccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(
final HttpServletRequest request,
final HttpServletResponse response,
final AccessDeniedException accessDeniedException
) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
}
}
Instead of using denyAll()
you should use authenticated()
that way spring security will try to authenticate all the paths except /login
and /register
.
If you want to use jsr-250 based annotations then annotate your security config with @EnableMethodSecurity(jsr250Enabled = true)
this will enable the @RolesAllowed
and @PermitAll
annotations.
I would suggest you to use spring security method level annotations exclusively and not mix jsr-250 annotations, as spring security can handle all cases of jsr-250 annotations like
@PreAuthorize("permitAll")
and@PreAuthorize("hasRole('USER')")
etc .
For problem 1, you can try @PreAuthorize("denyAll")
at class level. This will block any method which is not annotated by defualt.
Not sure but this might override your security config level request matcher based configurations.
For problem 2, you can use spring security annotations at class level which will automatically apply them at method level.
For problem 3, this will also work by default with spring security annotations.