1. Introduction
We have spent a significant amount of time to date making sure we are identifying the caller, how to identify the caller, restricting access based on being properly authenticated, and the management of multiple users. In this lecture we are going to focus on expanding authorization constraints to both roles and permission-based authorities.
1.1. Goals
You will learn:
-
the purpose of authorities, roles, and permissions
-
how to express authorization constraints using URI-based and annotation-based constraints
-
how the enforcement of the constraints is accomplished
-
how to potentially customize the enforcement of constraints
1.2. Objectives
At the conclusion of this lecture and related exercises, you will be able to:
-
define the purpose of a role-based and permission-based authority
-
identify how to construct an
AccessDecisionManager
and supply customizedAccessDecisionVoter
classes -
implement URI Path-based authorization constraints
-
implement annotation-based authorization constraints
-
implement role inheritance
-
implement an
AccessDeniedException
controller advice to hide necessary stack trace information and provide useful error information to the caller -
identify the detailed capability of expression-based constraints to be able to handle very intricate situations
2. Authorities, Roles, Permissions
An authority is a general term used for a value that is granular enough to determine whether a user will be granted access to a resource. There are different techniques for slicing up authorities to match the security requirements of the application. Spring uses roles and permissions as types of authorities.
A role is a course-grain authority assigned to the type of user accessing the system and the prototypical uses that they perform. For example ROLE_ADMIN, ROLE_CLERK, or ROLE_CUSTOMER are relative to the roles in a business application.
A permission is a more fine-grain authority that describes the action being performed versus the role of the user. For example "PRICE_CHECK", "PRICE_MODIFY", "HOURS_GET", and "HOURS_MODIFY" are relative to the actions in a business application.
No matter which is being represented by the authority value, Spring Security looks to grant or deny access to a user based on their assigned authorities and the rules expressed to protect the resources accessed.
Spring represents both roles and permissions using a GrantedAuthority
class with an authority
string carrying the value. Role authority values have, by default, a "ROLE_" prefix, which
is a configurable value. Permissions/generic authorities do not have a prefix value.
Aside from that, they look very similar but are not always treated equally.
Spring refers to authorities with ROLE_ prefix as "roles" when the prefix is
stripped away and anything with the raw value as "authorities". ROLE_ADMIN authority
represents an ADMIN role. PRICE_CHECK permission is a PRICE_CHECK authority.
|
3. Authorization Constraint Types
There are two primary ways we can express authorization constraints within Spring: path-based and annotation-based.
3.1. Path-based Constraints
Path-based constraints are specific to web applications and controller operations since the constraint is expressed against a URI pattern.
We define path-based authorizations using the same HttpSecurity
builder we used with authentication.
We can use either the WebSecurityConfigurerAdapter (deprecated) or Component-based approaches — but not both.
-
WebSecurityConfigurer Approach
Authn and Authz HttpSecurity Configuration@Configuration @Order(0) @RequiredArgsConstructor public static class APIConfiguration extends WebSecurityConfigurerAdapter { private final UserDetailsService jdbcUserDetailsService; @Override public void configure(WebSecurity web) throws Exception { ...} @Override protected void configure(HttpSecurity http) throws Exception { http.requestMatchers(cfg->cfg.antMatchers("/api/**")); //... http.httpBasic(); //remaining authn and upcoming authz goes here
-
Component-based Approach
Authn and Authz HttpSecurity Configuration@Bean public WebSecurityCustomizer authzStaticResources() { return (web) -> web.ignoring().antMatchers("/content/**"); } @Bean @Order(0) public SecurityFilterChain authzSecurityFilters(HttpSecurity http) throws Exception { http.requestMatchers(cfg->cfg.antMatchers("/api/**")); //... http.httpBasic(); //remaining authn and upcoming authz goes here return http.build(); }
The first example below shows a URI path restricted to the ADMIN
role. The second example shows a URI path restricted to the ROLE_ADMIN
, or ROLE_CLERK
, or PRICE_CHECK
authorities.
It is worth saying multiple times.
Pay attention to the use of the terms "role" and "authority" within Spring security.
ROLE_X is a "ROLE_X" authority and a "X" role.
|
http.authorizeRequests(cfg->cfg.antMatchers(
"/api/authorities/paths/admin/**")
.hasRole("ADMIN")); (1)
http.authorizeRequests(cfg->cfg.antMatchers(HttpMethod.GET,
"/api/authorities/paths/price")
.hasAnyAuthority("PRICE_CHECK", "ROLE_ADMIN", "ROLE_CLERK")); (2)
1 | ROLE_ prefix automatically added to role authorities |
2 | ROLE_ prefix must be manually added when expressed as a generic authority |
Out-of-the-box, path-based annotations support role inheritance, roles, and permission-based constraints. Path-based constraints also support Spring Expression Language (SpEL).
3.2. Annotation-based Constraints
Annotation-based constraints are not directly related to web applications and not associated with URIs. Annotations are placed on the classes and/or methods they are meant to impact. The processing of those annotations has default, built-in behavior that we can augment and modify. The descriptions here are constrained to out-of-the-box capability before trying to adjust anything.
There are three annotation options in Spring:
-
@Secured — this was the original, basic annotation Spring used to annotate access controls for classes and/or methods. Out-of-the-box, this annotation only supports roles and does not support role inheritance.
@Secured("ROLE_ADMIN") (1) @GetMapping(path = "admin", produces = {MediaType.TEXT_PLAIN_VALUE}) public ResponseEntity<String> doAdmin(
1 ROLE_
prefix must be included in string -
JSR 250 — this is an industry standard API for expressing access controls using annotations for classes and/or methods. This is also adopted by JakartaEE. Out-of-the-box, this too only supports roles and does not support role inheritance.
@RolesAllowed("ROLE_ADMIN") (1) @GetMapping(path = "admin", produces = {MediaType.TEXT_PLAIN_VALUE}) public ResponseEntity<String> doAdmin(
1 ROLE_
prefix must be included in string -
expressions — this annotation capability is based on the powerful Spring Expression Language (SpEL) that allows for ANDing and ORing of multiple values and includes inspection of parameters and current context. It does provide support for role inheritance.
@PreAuthorize("hasRole('ADMIN')") (1) @GetMapping(path = "admin", produces = {MediaType.TEXT_PLAIN_VALUE}) public ResponseEntity<String> doAdmin( ... @PreAuthorize("hasAnyRole('ADMIN','CLERK') or hasAuthority('PRICE_CHECK')") (2) @GetMapping(path = "price", produces = {MediaType.TEXT_PLAIN_VALUE}) public ResponseEntity<String> checkPrice(
1 ROLE_
prefix automatically added to role authorities2 ROLE_
prefix not added to generic authority references
4. Setup
The bulk of this lecture will be demonstrating the different techniques for
expressing authorization constraints. To do this, I have created four controllers — configured using each technique and an additional whoAmI
controller to return
a string indicating the name of the caller and their authorities.
4.1. Who Am I Controller
To help us demonstrate authorities, I have added a controller to the application that will accept an injected user and return a string that describes who called.
@RestController
@RequestMapping("/api/whoAmI")
public class WhoAmIController {
@GetMapping(produces={MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<String> getCallerInfo(
@AuthenticationPrincipal UserDetails user) { (1)
List<?> values = (user!=null) ?
List.of(user.getUsername(), user.getAuthorities()) :
List.of("null");
String text = StringUtils.join(values);
ResponseEntity<String> response = ResponseEntity.ok(text);
return response;
}
}
1 | UserDetails of authenticated caller injected into method call |
The controller will return the following when called without credentials.
$ curl http://localhost:8080/api/whoAmI
[null]
The controller will return the following when called with credentials
$ curl http://localhost:8080/api/whoAmI -u frasier:password
[frasier, [PRICE_CHECK, ROLE_CUSTOMER]]
4.2. Demonstration Users
Our user database has been populated with the following users. All have an
assigned role (Roles all start with ROLE_
prefix).
One (frasier) has an assigned permission.
insert into authorities(username, authority) values('sam','ROLE_ADMIN'); insert into authorities(username, authority) values('rebecca','ROLE_ADMIN'); insert into authorities(username, authority) values('woody','ROLE_CLERK'); insert into authorities(username, authority) values('carla','ROLE_CLERK'); insert into authorities(username, authority) values('norm','ROLE_CUSTOMER'); insert into authorities(username, authority) values('cliff','ROLE_CUSTOMER'); insert into authorities(username, authority) values('frasier','ROLE_CUSTOMER'); insert into authorities(username, authority) values('frasier','PRICE_CHECK'); (1)
1 | frasier is assigned a (non-role) permission |
4.3. Core Security FilterChain Setup
The following shows the initial/core SecurityFilterChain setup carried over from earlier examples. We will add to this in a moment.
//HttpSecurity http
http.httpBasic(cfg->cfg.realmName("AuthzExample"));
http.formLogin(cfg->cfg.disable());
http.headers(cfg->{
cfg.xssProtection().disable();
cfg.frameOptions().disable();
});
http.csrf(cfg->cfg.disable());
http.cors(cfg->new AuthzCorsConfigurationSource());
http.sessionManagement(cfg->cfg
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.authorizeRequests(cfg->cfg.antMatchers(
"/api/whoami",
"/api/authorities/paths/anonymous/**")
.permitAll());
//more ...
4.4. Controller Operations
The controllers in this overall example will accept API requests and delegate the call to the WhoAmIController. Many of the operations look like the snippet example below — but with a different URI.
@RestController
@RequestMapping("/api/authorities/paths")
@RequiredArgsConstructor
public class PathAuthoritiesController {
private final WhoAmIController whoAmI; (1)
@GetMapping(path = "admin", produces = {MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<String> doAdmin(
@AuthenticationPrincipal UserDetails user) {
return whoAmI.getCallerInfo(user); (2)
}
1 | whoAmI controller injected into each controller to provide consistent response with username and authorities |
2 | API-invoked controller delegates to whoAmI controller along with injected UserDetails |
5. Path-based Authorizations
In this example, I will demonstrate how to apply security constraints on controller methods based on the URI used to invoke them. This is very similar to the security constraints of legacy servlet applications.
The following snippet shows a summary of the URIs in the controller we will be implementing.
@RequestMapping("/api/authorities/paths")
@GetMapping(path = "admin", produces = {MediaType.TEXT_PLAIN_VALUE})
@GetMapping(path = "clerk", produces = {MediaType.TEXT_PLAIN_VALUE})
@GetMapping(path = "customer", produces = {MediaType.TEXT_PLAIN_VALUE})
@GetMapping(path = "price", produces = {MediaType.TEXT_PLAIN_VALUE})
@GetMapping(path = "authn", produces = {MediaType.TEXT_PLAIN_VALUE})
@GetMapping(path = "anonymous", produces = {MediaType.TEXT_PLAIN_VALUE})
@GetMapping(path = "nobody", produces = {MediaType.TEXT_PLAIN_VALUE})
5.1. Path-based Role Authorization Constraints
We have the option to apply path-based authorization constraints using roles. The following example locks down three URIs to one or more roles each.
http.authorizeRequests(cfg->cfg.antMatchers(
"/api/authorities/paths/admin/**")
.hasRole("ADMIN")); (1)
http.authorizeRequests(cfg->cfg.antMatchers(
"/api/authorities/paths/clerk/**")
.hasAnyRole("ADMIN", "CLERK")); (2)
http.authorizeRequests(cfg->cfg.antMatchers(
"/api/authorities/paths/customer/**")
.hasAnyRole("CUSTOMER")); (3)
1 | admin URI may only be called by callers having role ADMIN
(or ROLE_ADMIN authority) |
2 | clerk URI may only be called by callers having either the
ADMIN or CLERK roles (or ROLE_ADMIN or ROLE_CLERK authorities) |
3 | customer URI may only be called by callers having the role CUSTOMER
(or ROLE_CUSTOMER authority) |
5.2. Example Path-based Role Authorization (Sam)
The following is an example set of calls for sam
, one
of our users with role ADMIN
. Remember that role ADMIN
is basically the same as saying authority ROLE_ADMIN
.
$ curl http://localhost:8080/api/authorities/paths/admin -u sam:password (1) [sam, [ROLE_ADMIN]] $ curl http://localhost:8080/api/authorities/paths/clerk -u sam:password (2) [sam, [ROLE_ADMIN]] $ curl http://localhost:8080/api/authorities/paths/customer -u sam:password (3) {"timestamp":"2020-07-14T15:12:25.927+00:00","status":403,"error":"Forbidden", "message":"Forbidden","path":"/api/authorities/paths/customer"}
1 | sam has ROLE_ADMIN authority, so admin URI can be called |
2 | sam has ROLE_ADMIN authority and clerk URI allows both
roles ADMIN and CLERK |
3 | sam lacks role CUSTOMER required to call customer URI
and is rejected with 403/Forbidden error |
5.3. Example Path-based Role Authorization (Woody)
The following is an example set of calls for woody
, one
of our users with role CLERK
.
$ curl http://localhost:8080/api/authorities/paths/admin -u woody:password (1) {"timestamp":"2020-07-14T15:12:46.808+00:00","status":403,"error":"Forbidden", "message":"Forbidden","path":"/api/authorities/paths/admin"} $ curl http://localhost:8080/api/authorities/paths/clerk -u woody:password (2) [woody, [ROLE_CLERK]] $ curl http://localhost:8080/api/authorities/paths/customer -u woody:password (3) {"timestamp":"2020-07-14T15:13:04.158+00:00","status":403,"error":"Forbidden", "message":"Forbidden","path":"/api/authorities/paths/customer"}
1 | woody lacks role ADMIN required to call admin URI
and is rejected with 403/Forbidden |
2 | woody has ROLE_CLERK authority, so clerk URI can be called |
3 | woody lacks role CUSTOMER required to call customer URI
and is rejected with 403/Forbidden |
6. Path-based Authority Permission Constraints
The following example shows how we can assign permission authority constraints. It is also an example of being granular with the HTTP method in addition to the URI expressed.
http.authorizeRequests(cfg->cfg.antMatchers(HttpMethod.GET, (1)
"/api/authorities/paths/price")
.hasAnyAuthority("PRICE_CHECK", "ROLE_ADMIN", "ROLE_CLERK")); (2)
1 | definition is limited to GET method for URI price URI |
2 | must have permission PRICE_CHECK or roles ADMIN or CLERK |
6.1. Path-based Authority Permission (Norm)
The following example shows one of our users with the CUSTOMER
role being rejected from calling the GET price
URI.
$ curl http://localhost:8080/api/authorities/paths/customer -u norm:password (1) [norm, [ROLE_CUSTOMER]] $ curl http://localhost:8080/api/authorities/paths/price -u norm:password (2) {"timestamp":"2020-07-14T15:13:38.598+00:00","status":403,"error":"Forbidden", "message":"Forbidden","path":"/api/authorities/paths/price"}
1 | norm has role CUSTOMER required to call customer URI |
2 | norm lacks the ROLE_ADMIN , ROLE_CLERK , and PRICE_CHECK authorities
required to invoke the GET price URI |
6.2. Path-based Authority Permission (Frasier)
The following example shows one of our users with the CUSTOMER
role and PRICE_CHECK
permission. This user can call both the
customer
and GET price
URIs.
$ curl http://localhost:8080/api/authorities/paths/customer -u frasier:password (1) [frasier, [PRICE_CHECK, ROLE_CUSTOMER]] $ curl http://localhost:8080/api/authorities/paths/price -u frasier:password (2) [frasier, [PRICE_CHECK, ROLE_CUSTOMER]]
1 | frazier has the CUSTOMER role assigned required to call customer URI |
2 | frazier has the PRICE_CHECK permission assigned required to call GET price URI |
6.3. Path-based Authority Permission (Sam and Woody)
The following example shows that users with the ADMIN
and CLERK
roles are able to call the GET price
URI.
$ curl http://localhost:8080/api/authorities/paths/price -u sam:password (1) [sam, [ROLE_ADMIN]] $ curl http://localhost:8080/api/authorities/paths/price -u woody:password (2) [woody, [ROLE_CLERK]]
1 | sam is assigned the ADMIN role required to call the GET price URI |
2 | woody is assigned the CLERK role required to call the GET price URI |
6.4. Other Path Constraints
We can add a few other path constraints that do not directly relate to roles. For example, we can exclude or enable a URI for all callers.
http.authorizeRequests(cfg->cfg.antMatchers(
"/api/authorities/paths/nobody/**")
.denyAll()); (1)
http.authorizeRequests(cfg->cfg.antMatchers(
"/api/authorities/paths/authn/**")
.authenticated()); (2)
1 | all callers of the nobody URI will be denied |
2 | all authenticated callers of the authn URI will be accepted |
6.5. Other Path Constraints Usage
The following example shows a caller attempting to access the URIs that either deny all callers or accept all authenticated callers
$ curl http://localhost:8080/api/authorities/paths/authn -u frasier:password (1)
[frasier, [PRICE_CHECK, ROLE_CUSTOMER]]
$ curl http://localhost:8080/api/authorities/paths/nobody -u frasier:password (2)
{"timestamp":"2020-07-14T18:09:38.669+00:00","status":403,
"error":"Forbidden","message":"Forbidden","path":"/api/authorities/paths/nobody"}
$ curl http://localhost:8080/api/authorities/paths/authn (3)
{"timestamp":"2020-07-14T18:15:24.945+00:00","status":401,
"error":"Unauthorized","message":"Unauthorized","path":"/api/authorities/paths/authn"}
1 | frazier was able to access the authn URI because he was authenticated |
2 | frazier was not able to access the nobody URI because all have been denied for that URI |
3 | anonymous user was not able to access the authn URI because they were not authenticated |
7. Authorization
With that example in place, we can look behind the scenes to see how this occurred.
7.1. Review: FilterSecurityInterceptor At End of Chain
If you remember when we inspected the filter chain setup for our API during the breakpoint in FilterChainProxy.doFilterInternal() — there was a FilterSecurityInterceptor
at the end of the chain.
This is where our path-based authorization constraints get carried out.
7.2. Attempt Authorization Call
We can set a breakpoint int the AbstractSecurityInterceptor.attemptAuthorization()
method to observe the authorization process.
7.3. FilterSecurityInterceptor Calls
-
the
FilterSecurityInterceptor
is at the end of the Security FilterChain and calls theAccessDecisionManager
to decide whether the authenticated caller has access to the target object. The call quietly returns without an exception if accepted and throws anAccessDeniedException
if denied. -
the assigned
AccessDecisionManager
is pre-populated with a set ofAccessDecisionVoters
(e.g.,WebExpressionVoter
) based on security definitions and passed the authenticated user, a reference to the target object, and the relevant rules associated with that target to potentially be used by the voters. -
the
AccessDecisionVoter
returns an answer that is eitherACCESS_GRANTED
,ACCESS_ABSTAIN
, orACCESS_DENIED
.
The overall evaluation depends on the responses from the voters and the aggregate answer setting (e.g., affirmative, consensus, unanimous) of the manager.
7.4. AccessDecisionManager
The AccessDecisionManager
comes in three flavors and we can also create
our own.
The three flavors provided by Spring are
-
AffirmativeBased
- returns positive if any voter returns affirmative -
ConsensusBase
- returns positive if majority of voters return affirmative -
UnanimousBased
- returns positive if all voters return affirmative or abstain
Denial is signaled with a thrown AccessDeniedException
exception.
AffirmativeBased
is the default.
There is a setting in each for how to handle 100% abstain results — the default is access denied.
7.5. Assigning Custom AccessDecisionManager
The following code snippet
shows an example of creating a UnanimousBased
AccessDecisionManager
and populating it with a custom list of voters.
@Bean
public AccessDecisionManager accessDecisionManager() {
return new UnanimousBased(List.of(
new WebExpressionVoter(),
new RoleVoter(),
new AuthenticatedVoter()));
}
A custom AccessDecisionManager
can be assigned to the builder returned
from the access restrictions call.
http.authorizeRequests(cfg->cfg.antMatchers(
"/api/authorities/paths/admin/**")
.hasRole("ADMIN").accessDecisionManager(/* custom ADM here*/));
7.6. AccessDecisionVoter
There are several AccessDecisionVoter
classes that take care of determining
whether the specific constraints are satisfied, violated, or no determination.
We can also create our own by extending or re-implementing any of the existing
implementations and register using the technique shown in the snippets above.
In our first case, Spring converted our rules to be resolved to the
WebExpressionVoter
. Because of that — we will see many similarities to
the constraint behavior of URI-based constraints and expression-based
constraints covered towards the end of this lecture.
8. Role Inheritance
Role inheritance provides an alternative to listing individual roles per
URI constraint. Lets take our case of sam
with the ADMIN
role. He is
forbidden from calling the customer
URI.
$ curl http://localhost:8080/api/authorities/paths/customer -u sam:password
{"timestamp":"2020-07-14T20:15:19.931+00:00","status":403,"error":"Forbidden",
"message":"Forbidden","path":"/api/authorities/paths/customer"}
8.1. Role Inheritance Definition
We can define a @Bean
that provides a RoleHierarchy
expressing
which roles inherit from other roles. The syntax to this constructor
is a String — based on the legacy XML definition interface.
@Bean
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
roleHierarchy.setHierarchy(StringUtils.join(List.of(
"ROLE_ADMIN > ROLE_CLERK", (1)
"ROLE_CLERK > ROLE_CUSTOMER"), (2)
System.lineSeparator())); (3)
return roleHierarchy;
}
1 | role ADMIN will inherit all accessed applied to role CLERK |
2 | role CLERK will inherit all accessed applied to role CUSTOMER |
3 | String expression built using new lines |
With the above @Bean
in place and restarting our application, users
with role ADMIN
or CLERK
are able to invoke the customer
URI.
$ curl http://localhost:8080/api/authorities/paths/customer -u sam:password
[sam, [ROLE_ADMIN]]
9. @Secured
As stated before, URIs are one way to identify a target meant for access control. However, it is not always the case that we are protecting a controller or that we want to express security constraints so far from the lower-level component method calls needing protection.
We have at least three options when implementing component method-level access control:
-
@Secured
-
JSR-250
-
expressions
I will cover @Secured and JSR-250 first — since they have a similar, basic constraint capability and save expressions to the end.
9.1. Enabling @Secured Annotations
@Secured annotations are disabled by default. We can enable them
be supplying a @EnableGlobalMethodSecurity
annotation with
securedEnabled
set to true.
@Configuration
@EnableGlobalMethodSecurity(
securedEnabled = true //@Secured({"ROLE_MEMBER"})
)
@RequiredArgsConstructor
public class SecurityConfiguration {
9.2. @Secured Annotation
We can add the @Secured
annotation to the class and method level of the targets we
want protected. Values are expressed in authority value. Therefore, since the following
example requires the ADMIN
role, we must express it as ROLE_ADMIN
authority.
@RestController
@RequestMapping("/api/authorities/secured")
@RequiredArgsConstructor
public class SecuredAuthoritiesController {
private final WhoAmIController whoAmI;
@Secured("ROLE_ADMIN") (1)
@GetMapping(path = "admin", produces = {MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<String> doAdmin(
@AuthenticationPrincipal UserDetails user) {
return whoAmI.getCallerInfo(user);
}
1 | caller checked for ROLE_ADMIN authority when calling doAdmin method |
9.3. @Secured Annotation Checks
@Secured
annotation supports requiring one or more authorities in order to invoke
a particular method.
$ curl http://localhost:8080/api/authorities/secured/admin -u sam:password (1)
[sam, [ROLE_ADMIN]]
$ curl http://localhost:8080/api/authorities/secured/admin -u woody:password (2)
{"timestamp":"2020-07-14T21:11:00.395+00:00","status":403,
"error":"Forbidden","trace":"org.springframework.security.access.AccessDeniedException: ...(lots!!!)
1 | sam has the required ROLE_ADMIN authority required to invoke doAdmin |
2 | woody lacks required ROLE_ADMIN authority needed to invoke doAdmin and is rejected
with an AccessDeniedException and a very large stack trace |
9.4. @Secured Many Roles
@Secured will support many roles ORed together.
@Secured({"ROLE_ADMIN", "ROLE_CLERK"})
@GetMapping(path = "price", produces = {MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<String> checkPrice(
A user with either ADMIN
or CLERK
role will be given access to checkPrice()
.
$ curl http://localhost:8080/api/authorities/secured/price -u woody:password
[woody, [ROLE_CLERK]]
$ curl http://localhost:8080/api/authorities/secured/price -u sam:password
[sam, [ROLE_ADMIN]]
9.5. @Secured Only Processing Roles
However, @Secured
evaluates using a RoleVoter
, which only processes roles.
@Secured({"ROLE_ADMIN", "ROLE_CLERK", "PRICE_CHECK"}) (1)
@GetMapping(path = "price", produces = {MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<String> checkPrice(
1 | PRICE_CHECK permission will be ignored |
Therefore, we cannot assign a @Secured
to allow a permission
like we did with the URI constraint.
$ curl http://localhost:8080/api/authorities/paths/price -u frasier:password (1)
[frasier, [PRICE_CHECK, ROLE_CUSTOMER]]
$ curl http://localhost:8080/api/authorities/secured/price -u frasier:password (2)
{"timestamp":"2020-07-14T21:24:20.665+00:00","status":403,
"error":"Forbidden","trace":"org.springframework.security.access.AccessDeniedException ...(lots!!!)
1 | frasier can call URI GET paths/price because he has permission PRICE_CHECK and URI-based
constraints honor non-role authorities (i.e., permissions) |
2 | frasier cannot call URI GET secured/price because checkPrice() is constrained
by @Secured and that only supports roles |
9.6. @Secured Does Not Support Role Inheritance
@Secured annotation does not appear to support role inheritance we put in place when securing URIs.
$ curl http://localhost:8080/api/authorities/paths/clerk -u sam:password (1)
[sam, [ROLE_ADMIN]]
$ curl http://localhost:8080/api/authorities/secured/clerk -u sam:password (2)
{"timestamp":"2020-07-14T21:48:40.063+00:00","status":403,
"error":"Forbidden","trace":"org.springframework.security.access.AccessDeniedException ...(lots!!!)
1 | sam is able to call paths/clerk URI because of ADMIN role inherits access from CLERK role |
2 | sam is unable to call doClerk() method because @Secured does not honor role inheritance |
10. Controller Advice
When using URI-based constraints, 403/Forbidden checks were done before calling the controller and
is handled by a default exception advice that limits the amount of data emitted in the response.
When using annotation-based constraints, an AccessDeniedException
is thrown during the call to the
controller and is currently missing a exception advice. That causes a very large stack trace to be
returned to the caller (abbreviated here with "…(lots!!!)").
$ curl http://localhost:8080/api/authorities/secured/clerk -u sam:password (2)
{"timestamp":"2020-07-14T21:48:40.063+00:00","status":403,
"error":"Forbidden","trace":"org.springframework.security.access.AccessDeniedException ...(lots!!!)
10.1. AccessDeniedException Exception Handler
We can correct that information bleed by adding an @ExceptionHandler
to address AccessDeniedException
.
In the example below I am building a string with the caller’s identity and filling in the
standard fields for the returned MessageDTO used in the error reporting in my BaseExceptionAdvice
.
...
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ExceptionAdvice extends info.ejava.examples.common.web.BaseExceptionAdvice { (1)
@ExceptionHandler({AccessDeniedException.class}) (2)
public ResponseEntity<MessageDTO> handle(AccessDeniedException ex) {
String text=String.format("caller[%s] is forbidden from making this request",
getPrincipal());
return this.buildResponse(HttpStatus.FORBIDDEN, null, text, (Instant)null);
}
protected String getPrincipal() {
try { (3)
return SecurityContextHolder.getContext().getAuthentication().getName();
} catch (NullPointerException ex) {
return "null";
}
}
}
1 | extending base class with helper methods and core set of exception handlers |
2 | adding an exception handler to intelligently handle access denial exceptions |
3 | SecurityContextHolder provides Authentication object for current caller |
10.2. AccessDeniedException Exception Result
With the above @ExceptionAdvice
in place, the stack trace from the AccessDeniedException
has been reduced to the following useful information returned to the caller.
The caller is told, what they called and who the caller identity was when they called.
$ curl http://localhost:8080/api/authorities/secured/clerk -u sam:password
{"url":"http://localhost:8080/api/authorities/secured/clerk","message":"Forbidden",
"description":"caller[sam] is forbidden from making this request",
"timestamp":"2020-07-14T21:56:32.743996Z"}
11. JSR-250
JSR-250 is an industry Java standard — also adopted by JakartaEE — for expressing
common aspects (including authorization constraints) using annotations. It has the
ability to express the same things as @Secured
and a bit more. @Secured
lacks
the ability to express "permit all" and "deny all". We can do that with JSR-250
annotations.
11.1. Enabling JSR-250
JSR-250 authorization annotations are also disabled by default. We can enable them
the same as @Secured by setting the @EnableGlobalMethodSecurity.jsr250Enabled
value to true.
@Configuration
@EnableGlobalMethodSecurity(
jsr250Enabled = true //@RolesAllowed({"ROLE_MANAGER"})
)
@RequiredArgsConstructor
public class SecurityConfiguration {
11.2. @RolesAllowed Annotation
JSR-250 has a few annotations, but its core @RolesAllowed
is a 1:1 match for what
we can do with @Secured
. The following example shows the doAdmin()
method
restricted to callers with the admin role, expressed as its ROLE_ADMIN
authority expression.
@RestController
@RequestMapping("/api/authorities/jsr250")
@RequiredArgsConstructor
public class Jsr250AuthoritiesController {
private final WhoAmIController whoAmI;
@RolesAllowed("ROLE_ADMIN") (1)
@GetMapping(path = "admin", produces = {MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<String> doAdmin(
@AuthenticationPrincipal UserDetails user) {
return whoAmI.getCallerInfo(user);
}
1 | role is expressed with ROLE_ prefix |
11.3. @RolesAllowed Annotation Checks
The @RollsAllowed annotation is restricting callers of doAdmin()
to have
authority ROLE_ADMIN
.
$ curl http://localhost:8080/api/authorities/jsr250/admin -u sam:password (1)
[sam, [ROLE_ADMIN]]
$ curl http://localhost:8080/api/authorities/jsr250/admin -u woody:password (2)
{"url":"http://localhost:8080/api/authorities/jsr250/admin","message":"Forbidden",
"description":"caller[woody] is forbidden from making this request",
"timestamp":"2020-07-14T22:10:31.177471Z"}
1 | sam can invoke doAdmin() because he has the ROLE_ADMIN authority |
2 | woody cannot invoke doAdmin() because he does not have the ROLE_ADMIN authority |
11.4. Multiple Roles
The @RollsAllowed annotation can express multiple authorities the caller may have.
@RolesAllowed({"ROLE_ADMIN", "ROLE_CLERK", "PRICE_CHECK"})
@GetMapping(path = "price", produces = {MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<String> checkPrice(
11.5. Multiple Role Check
The following shows were both sam
and woody
are able to invoke
checkPrice()
because they have one of the required authorities.
$ curl http://localhost:8080/api/authorities/jsr250/price -u sam:password (1)
[sam, [ROLE_ADMIN]]
$ curl http://localhost:8080/api/authorities/jsr250/price -u woody:password (2)
[woody, [ROLE_CLERK]]
1 | sam can invoke checkPrice() because he has the ROLE_ADMIN authority |
2 | woody can invoke checkPrice() because he has the ROLE_ADMIN authority |
11.6. JSR-250 Does not Support Non-Role Authorities
Out-of-the-box, JSR-250 authorization annotation processing does not support
non-Role authorizations. The following example shows where frazer
is able to
call URI GET paths/price
but unable to call checkPrice()
of the JSR-250 controller
even though it was annotated with one of his authorities.
$ curl http://localhost:8080/api/authorities/paths/price -u frasier:password (1)
[frasier, [PRICE_CHECK, ROLE_CUSTOMER]]
$ curl http://localhost:8080/api/authorities/jsr250/price -u frasier:password (2)
{"url":"http://localhost:8080/api/authorities/jsr250/price","message":"Forbidden",
"description":"caller[frasier] is forbidden from making this request",
"timestamp":"2020-07-14T22:13:26.247328Z"}
1 | frasier can invoke URI GET paths/price because he has the PRICE_CHECK authority
and URI-based constraints support non-role authorities |
2 | frazier cannot invoke JSR-250 constrained checkPrice() even though he has
PRICE_CHECK permission because JSR-250 does not support non-role authorities |
12. Expressions
As demonstrated, @Secured and JSR-250-based constraints are functional but very basic.
If we need more robust handling of constraints we can use Spring Expression Language and Pre/Post Constraints.
Expression support is enabled by adding the following setting to the @EnableGlobalMethodSecurity
annotation.
@EnableGlobalMethodSecurity(
prePostEnabled = true //@PreAuthorize("hasAuthority('ROLE_ADMIN')"), @PreAuthorize("hasRole('ADMIN')")
)
12.1. Expression Role Constraint
Expressions support many callable features and I am only going to scratch the
surface here. The primary annotation is @PreAuthorize
and whatever the constraint is — it is checked prior to calling the method. There are also features to
filter inputs and outputs based on flexible configurations. I will be sticking
to the authorization basics and not be demonstrating the other features here.
Notice that the contents of the string
looks like a function call — and it is. The following example constrains the doAdmin()
method to users with the role ADMIN
.
@RestController
@RequestMapping("/api/authorities/expressions")
@RequiredArgsConstructor
public class ExpressionsAuthoritiesController {
private final WhoAmIController whoAmI;
@PreAuthorize("hasRole('ADMIN')") (1)
@GetMapping(path = "admin", produces = {MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<String> doAdmin(
@AuthenticationPrincipal UserDetails user) {
return whoAmI.getCallerInfo(user);
}
1 | hasRole automatically adds the ROLE prefix |
12.2. Expression Role Constraint Checks
Much like @Secured and JSR-250, the following shows the caller being checked by expression
whether they have the ADMIN
role. The ROLE_
prefix is automatically applied.
$ curl http://localhost:8080/api/authorities/expressions/admin -u sam:password (1)
[sam, [ROLE_ADMIN]]
$ curl http://localhost:8080/api/authorities/expressions/admin -u woody:password (2)
{"url":"http://localhost:8080/api/authorities/expressions/admin","message":"Forbidden",
"description":"caller[woody] is forbidden from making this request",
"timestamp":"2020-07-14T22:31:07.669546Z"}
1 | sam can invoke doAdmin() because he has the ADMIN role |
2 | woody cannot invoke doAdmin() because he does not have the ADMIN role |
12.3. Expressions Support Permissions and Role Inheritance
As noted earlier with URI-based constraints, expressions support non-role authorities and role inheritance.
The following example checks whether the caller has an authority and chooses to manually supply the ROLE_
prefix.
@PreAuthorize("hasAuthority('ROLE_CLERK')")
@GetMapping(path = "clerk", produces = {MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<String> doClerk(
The following execution demonstrates that a caller with ADMIN
role will
be able to call a method that requires the CLERK
role because we earlier
configured ADMIN
role to inherit all CLERK
role accesses.
$ curl http://localhost:8080/api/authorities/expressions/clerk -u sam:password
[sam, [ROLE_ADMIN]]
12.4. Supports Permissions and Boolean Logic
Expressions can get very detailed. The following shows two evaluations being called and their result ORed together. The first evaluation checks whether the caller has certain roles. The second checks whether the caller has a certain permission.
@PreAuthorize("hasAnyRole('ADMIN','CLERK') or hasAuthority('PRICE_CHECK')")
@GetMapping(path = "price", produces = {MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<String> checkPrice(
$ curl http://localhost:8080/api/authorities/expressions/price -u sam:password (1)
[sam, [ROLE_ADMIN]]
$ curl http://localhost:8080/api/authorities/expressions/price -u woody:password (2)
[woody, [ROLE_CLERK]]
1 | sam can call checkPrice() because he satisfied the hasAnyRole() check by having the ADMIN role |
2 | woody can call checkPrice() because he satisfied the hasAnyRole() check by having the CLERK role |
$ curl http://localhost:8080/api/authorities/expressions/price -u frasier:password (1)
[frasier, [PRICE_CHECK, ROLE_CUSTOMER]]
1 | frazier can call checkPrice() because he satisfied the hasAuthority() check by having the
PRICE_CHECK permission |
$ curl http://localhost:8080/api/authorities/expressions/customer -u norm:password (1)
[norm, [ROLE_CUSTOMER]]
$ curl http://localhost:8080/api/authorities/expressions/price -u norm:password (2)
{"url":"http://localhost:8080/api/authorities/expressions/price","message":"Forbidden",
"description":"caller[norm] is forbidden from making this request",
"timestamp":"2020-07-14T22:48:04.771588Z"}
1 | norm can call doCustomer() because he satisfied the hasRole() check by having the
CUSTOMER role |
2 | norm cannot call checkPrice() because failed both the hasAnyRole() and hasAuthority()
checks by not having any of the looked for authorities. |
13. Summary
In this module we learned:
-
the purpose of authorities, roles, and permissions
-
how to express authorization constraints using URI-based and annotation-based constraints
-
how to enforcement of the constraints is accomplished
-
how the access control framework centers around an
AccessDecisionManager
andAccessDecisionVoter
classes -
how to implement role inheritance for URI and expression-based constraints
-
to implement an
AccessDeniedException
controller advice to hide necessary stack trace information and provide useful error information to the caller -
expression-based constraints are limitless in what they can express