Appearance
Spring Security Part 9: Method Security with @PreAuthorize and @PostAuthorize
So far, authorization lived in the security filter chain:
java
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/users").hasRole("USER")
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());With hundreds of APIs, one long list of URL rules in the security configuration becomes hard to maintain. Annotation-based authorization puts the rule on the method itself, next to the code it protects.
Users with roles and permissions
A user gets one role (ROLE_USER, ROLE_ADMIN) and any number of fine-grained permissions (ORDER_READ, SALES_DELETE, ...). Both end up as authorities:
java
@Entity
@Table(name = "user_login")
public class UserLoginEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password;
private String role; // e.g. "ROLE_USER" or "ROLE_ADMIN"
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private List<UserPermissionEntity> permissions = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Set<GrantedAuthority> authorities = new HashSet<>();
authorities.add(new SimpleGrantedAuthority(role));
permissions.forEach(permission -> authorities.add(new SimpleGrantedAuthority(permission.getName())));
return authorities;
}
public Long getId() { return id; }
@Override public String getPassword() { return password; }
@Override public String getUsername() { return username; }
// setters
}java
@Entity
@Table(name = "user_permission")
public class UserPermissionEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name; // e.g. ORDER_READ, SALES_READ
// getters and setters
}The repository, the service implementing UserDetailsService, and a registration endpoint (POST /user-login, hashing the password) are as in part 2.
Enabling method security
java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // without it, @PreAuthorize and @PostAuthorize are ignored
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/user-login").permitAll()
.anyRequest().authenticated())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
.httpBasic(Customizer.withDefaults());
return http.build();
}
}@EnableMethodSecurity turns on @PreAuthorize and @PostAuthorize by default (prePostEnabled = true is the default, so writing it is optional). The filter chain still authenticates; the fine-grained checks move to the methods.
@PreAuthorize
Checked before the method runs:
java
@RestController
@RequestMapping("/api")
public class OrderController {
@GetMapping("/orders")
@PreAuthorize("hasRole('USER') and hasAuthority('ORDER_READ')")
public ResponseEntity<String> readOrders() {
return ResponseEntity.ok("ALL orders has been fetched successfully");
}
}java
@RestController
@RequestMapping("/api")
public class SalesController {
@GetMapping("/sales")
@PreAuthorize("hasAuthority('SALES_READ')")
public ResponseEntity<String> readSalesDetails() {
return ResponseEntity.ok("ALL Sales details has been fetched successfully");
}
}| User's authorities | /api/orders | /api/sales |
|---|---|---|
ROLE_USER, ORDER_READ | 200 | 403 |
ROLE_USER, ORDER_READ, SALES_READ | 200 | 200 |
ORDER_READ only (no role) | 403 | 403 |
hasRole vs hasAuthority
Both check the user's authorities. The only difference: hasRole adds the ROLE_ prefix.
java
// SecurityExpressionRoot, simplified
public final boolean hasAuthority(String authority) {
return hasAnyAuthorityName(null, authority); // no prefix
}
public final boolean hasRole(String role) {
return hasAnyAuthorityName(this.defaultRolePrefix, role); // "ROLE_" + role
}
private boolean hasAnyAuthorityName(String prefix, String... roles) {
Set<String> roleSet = getAuthoritySet();
for (String role : roles) {
String defaultedRole = getRoleWithDefaultPrefix(prefix, role);
if (roleSet.contains(defaultedRole)) {
return true;
}
}
return false;
}So hasRole('USER') is the same as hasAuthority('ROLE_USER').
How the check runs
The annotation is enforced with AOP: @EnableMethodSecurity wraps the bean in a proxy (the controller above is an AOP proxy), and AuthorizationManagerBeforeMethodInterceptor, an AOP method interceptor, evaluates the expression before calling the real method. A failed check throws AccessDeniedException, which becomes 403. These are method interceptors (the AOP chapter), not Spring MVC HandlerInterceptors, so @PreAuthorize works on any Spring bean, a service as well as a controller. The usual proxy rules apply: a call from inside the same class skips the check.
The expression is SpEL (Spring Expression Language), parsed by Spring's SpelExpressionParser.
Logical operators:
| Operator | Meaning | Example |
|---|---|---|
and | both | hasRole('ADMIN') and hasAuthority('READ') |
or | either | hasRole('ADMIN') or hasRole('USER') |
not | negation | not hasRole('ADMIN') |
! | negation | !hasAuthority('DELETE') |
Relational operators: ==, !=, <, >, <=, >=, as in #value >= 90.
Method parameters are available as #name, and the logged-in user as authentication:
java
@PreAuthorize("#id == authentication.principal.id")
@GetMapping("/users/{id}")
public User fetchUserDetails(@PathVariable Long id) {
return userServiceObject.fetchUserDetails(id);
}A user can fetch only their own details: user 11 gets 200 for /users/11 and 403 for /users/12. (authentication.principal is the UserDetails object, so it needs a getId().)
@PostAuthorize
Checked after the method has run, but before the response goes back. Handled by AuthorizationManagerAfterMethodInterceptor. The return value is available as returnObject:
java
@GetMapping("/orders")
@PreAuthorize("hasRole('USER') and hasAuthority('ORDER_READ')")
@PostAuthorize("returnObject.userID == authentication.principal.id")
public OrderDTO readOrders() {
OrderDTO orderDTO = new OrderDTO();
orderDTO.userID = 11; // hardcoded for testing: the id of "a_user"
orderDTO.orderID = 100001;
return orderDTO;
}a_user(id 11): 200 with the order.b_user(id 12): passes@PreAuthorize, the method runs, then@PostAuthorizefails: 403, and the order is not sent.
Because the check comes afterwards, the method has already run: in the test, its body executed for b_user too. Use @PostAuthorize for checks that depend on what is returned by a read; never on methods that change data, since the change would already be made.
Next: That completes security. The application is now feature-complete; the last module is about running it in production. Chapter 43 covers Actuator: endpoints that show whether the application is healthy, its metrics and thread dumps, and how to secure and extend them.
Check yourself
- Why move authorization from the filter chain onto methods?
- How do a user's role and permissions become authorities?
- What is needed for
@PreAuthorizeto take effect? - A user has
ROLE_USERandORDER_READ. Which of/api/ordersand/api/salessucceed? - What is the difference between
hasRole('USER')andhasAuthority('USER')? - Which component checks
@PreAuthorize, and what kind of interceptor is it? - What status does a failed check produce?
- How do you allow a user to read only their own record?
- When does
@PostAuthorizerun, and what can it access? - Why must
@PostAuthorizenot be used on a method that updates data?
Answers
- With many APIs, one central list of URL rules is hard to maintain; annotations keep each rule next to its method.
getAuthorities()returns both: the role (ROLE_USER) and each permission (ORDER_READ) asSimpleGrantedAuthority.@EnableMethodSecurityon a configuration class./api/orders(200);/api/salesgives 403.hasRoleadds theROLE_prefix, so it checksROLE_USER;hasAuthority('USER')checks exactlyUSER.AuthorizationManagerBeforeMethodInterceptor, an AOP method interceptor around a proxy (not an MVC interceptor).AccessDeniedException, returned as 403 Forbidden.@PreAuthorize("#id == authentication.principal.id").- After the method returns, before the response is sent; it can use
returnObjectandauthentication. - The method has already run, so the change is made even when the check fails.