Spring Boot HandBook

    Introduction :#

    Security method annotations in Spring Security provide a way to apply security constraints directly to your methods, offering fine-grained control over who can access specific functionalities. Here’s a brief overview of some key annotations:

    @Secured#

    The @Secured annotation is used to specify roles required to access a particular method. It restricts access based on roles and is one of the simpler ways to manage method-level security.

    @Secured(”ROLE_USER"): Ensures the user has the ROLE_USER role.

    NOTE: Make sure secureEnabled is set to true @EnableMethodSecurity(securedEnabled = true)

     

    Code

    Here’s the configuration class.

    import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity //by adding this annotation we are telling springboot that we want to we want to configure this SpringSecurityFilterChain @RequiredArgsConstructor @EnableMethodSecurity(securedEnabled = true) public class WebSecurityConfig { private final JWTAuthFilter jwtAuthFilter; private final OAuth2SuccessHandler oAuth2SuccessHandler; private static final String[] publicRoutes = { "/error","/auth/**","/home.html" }; @Bean SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { httpSecurity .authorizeHttpRequests(auth-> auth .requestMatchers(publicRoutes).permitAll() // Public endpoints //.requestMatchers("/endpoint/**").hasRole(Roles.ADMIN.name()) // all the routes after endpoint will be allowed for "ADMIN" only without admin if any one is trying to send any request on this endpoint then it will show a forbidden error .requestMatchers(HttpMethod.GET,"/endpoint/**").permitAll()//anyone can access get method of this endpoint controller .requestMatchers(HttpMethod.POST,"/endpoint/**") .hasAnyRole(Roles.ADMIN.name(),Roles.USER.name()) //only Admin and User can access this post method of this endpoint controller .requestMatchers(HttpMethod.POST,"/endpoint/**") .hasAnyAuthority(Permissions.POST_CREATE.name()) .requestMatchers(HttpMethod.PUT,"/endpoint/**") .hasAuthority(Permissions.POST_UPDATE.name()) .requestMatchers(HttpMethod.DELETE,"/endpoint/**") .hasAnyAuthority(Permissions.POST_DELETE.name()) .anyRequest().authenticated()) // All other requests require authentication .csrf(csrfConfig-> // Disable CSRF for simplicity, not recommended for production csrfConfig .disable()) .sessionManagement(sessionConfig -> // Disable JSESSIONID for simplicity, not recommended for production sessionConfig .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .oauth2Login(oauth2Config->oauth2Config .failureUrl("/login?error=true") .successHandler(oAuth2SuccessHandler)); return httpSecurity.build(); //when you add build this throws an exception } @Bean AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } @Bean PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } }

    Here’s the controller class.

    import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.annotation.Secured; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequiredArgsConstructor @RequestMapping("/endpoint") public class DemoController { private final DemoService demoService; @PostMapping public ResponseEntity<DemoDto> createNewDemo(@RequestBody DemoDto demoDto){ return ResponseEntity.ok(demoService.createNewDemo(demoDto)); } @GetMapping @Secured("ROLE_USER") // user can only access getAll the demo public ResponseEntity<List<DemoDto>> getAllDemo(){ return ResponseEntity.ok(demoService.getAllDemo()); } @GetMapping("/{id}") public ResponseEntity<DemoDto> getDemoById(@PathVariable Long id){ return ResponseEntity.ok(demoService.getDemoById(id)); } }

    Here’s the Exception handler class.

    import io.jsonwebtoken.JwtException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity<ApiError> handleResourceNotFoundException(ResourceNotFoundException exception){ ApiError apiError = new ApiError(exception.getLocalizedMessage(), HttpStatus.NOT_FOUND); return new ResponseEntity<>(apiError,HttpStatus.NOT_FOUND); } @ExceptionHandler(AuthenticationException.class) public ResponseEntity<ApiError> handleAuthenticationException(AuthenticationException e){ ApiError apiError = new ApiError(e.getLocalizedMessage(), HttpStatus.UNAUTHORIZED); return new ResponseEntity<>(apiError,HttpStatus.UNAUTHORIZED); } @ExceptionHandler(JwtException.class) public ResponseEntity<ApiError> handleJwtException(JwtException e){ ApiError apiError = new ApiError(e.getLocalizedMessage(), HttpStatus.UNAUTHORIZED); return new ResponseEntity<>(apiError,HttpStatus.UNAUTHORIZED); } @ExceptionHandler(AccessDeniedException.class) public ResponseEntity<ApiError> handleAccessDeniedException(AccessDeniedException e){ ApiError apiError = new ApiError(e.getLocalizedMessage(), HttpStatus.FORBIDDEN); return new ResponseEntity<>(apiError,HttpStatus.FORBIDDEN); } }

    Output

    After creating some users, here's the User entity table in the database:

    After creating some users, here's the User - roles entity table in the database:

    Log in with the user ‘Raman,’ who has the role ‘USER.’

    Without authentication, an ‘Access Denied’ error with a status of ‘Forbidden’ is received. This error is handled by our GlobalExceptionHandler as an ‘Access Denied’ exception.

    With authentication, we can access the list on the demo GET mapping route, as it's authorized for the 'USER' role.

     

    @PreAuthorize#

    The @PreAuthorize annotation offers more flexibility compared to @Secured. It allows you to use Spring Expression Language (SpEL) to define complex security expressions and conditions for method access.

    @PreAuthorize("hasRole('ADMIN')"): Ensures the user has the ROLE_ADMIN role. @PreAuthorize("hasAuthority('READ_WALLET')"): Checks for specific authority. @PreAuthorize("@rideSecurity.isWalletOwner(#id)"): calls specific method of a bean

    Code

    Here is the WebConfig class.

    import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity //by adding this annotation we are telling springboot that we want to we want to configure this SpringSecurityFilterChain @RequiredArgsConstructor @EnableMethodSecurity(securedEnabled = true) public class WebSecurityConfig { private final JWTAuthFilter jwtAuthFilter; private final OAuth2SuccessHandler oAuth2SuccessHandler; private static final String[] publicRoutes = { "/error","/auth/**","/home.html" }; @Bean SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { httpSecurity .authorizeHttpRequests(auth-> auth .requestMatchers(publicRoutes).permitAll() // Public endpoints .requestMatchers("/endpoint/**").authenticated() //must be authenticated .anyRequest().authenticated()) // All other requests require authentication .csrf(csrfConfig-> // Disable CSRF for simplicity, not recommended for production csrfConfig .disable()) .sessionManagement(sessionConfig -> // Disable JSESSIONID for simplicity, not recommended for production sessionConfig .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .oauth2Login(oauth2Config->oauth2Config .failureUrl("/login?error=true") .successHandler(oAuth2SuccessHandler)); return httpSecurity.build(); //when you add build this throws an exception } @Bean AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } @Bean PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } }

    This is our Controller class where we used @PreAuthorize.

    import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.annotation.Secured; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequiredArgsConstructor @RequestMapping("/endpoint") public class DemoController { private final DemoService demoService; @PostMapping public ResponseEntity<DemoDto> createNewDemo(@RequestBody DemoDto demoDto){ return ResponseEntity.ok(demoService.createNewDemo(demoDto)); } @GetMapping @Secured({"ROLE_USER"}) // user can only access getAll the demo public ResponseEntity<List<DemoDto>> getAllDemo(){ return ResponseEntity.ok(demoService.getAllDemo()); } @GetMapping("/{id}") //@PreAuthorize("hasRole('USER') OR hasAuthority('POST_VIEW')") //or @PreAuthorize("@demoSecurity.isOwnerOfDemoId(#id)") //you can create a class and method where owning user only access public ResponseEntity<DemoDto> getDemoById(@PathVariable Long id){ return ResponseEntity.ok(demoService.getDemoById(id)); } }

    Here’s the Service Class.

    import lombok.RequiredArgsConstructor; import org.modelmapper.ModelMapper; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import java.util.List; import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class DemoService { private final DemoRepo demoRepo; private final ModelMapper modelMapper; public DemoDto createNewDemo(DemoDto demoDto) { UserEntity user = (UserEntity) SecurityContextHolder .getContext() .getAuthentication() .getPrincipal(); DemoEntity demo = modelMapper.map(demoDto,DemoEntity.class); demo.setAuthor(user); DemoEntity savedDemo = demoRepo.save(demo); return modelMapper.map(savedDemo,DemoDto.class); } public List<DemoDto> getAllDemo() { return demoRepo.findAll() .stream() .map(demoEntity -> modelMapper.map(demoEntity,DemoDto.class)) .collect(Collectors.toList()); } public DemoDto getDemoById(Long id) { DemoEntity demo = demoRepo.findById(id) .orElseThrow(()->new ResourceNotFoundException("Demo id not found: "+id)); return modelMapper.map(demo,DemoDto.class); } }

    Here’s the Dto class and entity class.

    import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor public class DemoDto { private Long id; private String name; private UserDto author; } import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Entity @Getter @Setter @AllArgsConstructor @NoArgsConstructor public class DemoEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @ManyToOne private UserEntity author; }

    That created class and method.

    import lombok.RequiredArgsConstructor; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class DemoSecurity { private final DemoService demoService; public boolean isOwnerOfDemoId(Long id){ UserEntity user = (UserEntity) SecurityContextHolder .getContext() .getAuthentication() .getPrincipal(); DemoDto demo = demoService.getDemoById(id); return demo .getAuthor() .getId() .equals(user.getId()); } }

    Output

    After creating some users, here's the User entity table in the database:

    After creating some users, here's the User - roles entity table in the database:

    Log in with the user ‘Raman,’ who has the role ‘USER.’

    When you use any user's login access token to create a demo using 'POST mapping,' that user is set as the owner of the newly created demo with a unique Demo-id.

    I've set Bob as the owner of demo-id 1 and Raman as the owner of demo-id 2, as shown in the image provided (by ‘Raman’).

    Using 'Raman's' login access token, we can view all items in the demo list through the' GET mapping'. Here, ‘Demo-id = 1’ has ‘Bob’ as the owner, and ‘Demo-id = 2’ has ‘Raman’ as the owner.

    If you use Bob's login access token to access the ‘Get by Demo-id’ route for demo-id=1, or Raman's login access token for demo-id=2, you will be able to access the route and see all the information regarding that specific demo-id. This is because the authentication and authorization checks confirm that Bob is the owner of demo-id=1 and Raman is the owner of demo-id=2, granting them the appropriate access rights.

    If Alice logs in 

    And tries to access demo-id=1 or demo-id=2 using her access token, she will encounter an ‘Access Denied’ error with a status of ‘Forbidden.’ This occurs because Alice is not the owner of either demo, and the access control in place ensures that only the owner (Bob for demo-id=1 and Raman for demo-id=2) can access their respective demo information. The error is handled by our GlobalExceptionHandler as an ‘Access Denied’ exception.

    Security Methods vs Request Matchers#

    1. Security Method Annotations:#

    These annotations are applied at the method level and provide fine-grained control over who can access a specific method within your application.

    Common Annotations:#

    • @Secured: Specifies that a method can only be accessed by users with specific roles. This annotation only checks for roles (not permissions or complex expressions).
    • @PreAuthorize: Allows more complex security expressions, such as checking roles, permissions, and conditions on method parameters.

    Code

    @GetMapping @Secured({"ROLE_USER"}) // user can only access getAll the demo public ResponseEntity<List<DemoDto>> getAllDemo(){ return ResponseEntity.ok(demoService.getAllDemo()); } @GetMapping("/{id}") //@PreAuthorize("hasRole('USER') OR hasAuthority('POST_VIEW')") //or @PreAuthorize("@demoSecurity.isOwnerOfDemoId(#id)") //you can create a class and method where owning user only access public ResponseEntity<DemoDto> getDemoById(@PathVariable Long id){ return ResponseEntity.ok(demoService.getDemoById(id)); }

    Advantages:#

    • Granular control: You can secure individual service or controller methods.
    • Complex conditions: With annotations like @PreAuthorize, you can define complex conditions using Spring Expression Language (SpEL), such as checking roles, permissions, or method arguments.
    • Business logic-level security: Method-level annotations are ideal for enforcing business rules, such as restricting certain actions (like viewing, editing, or deleting data) based on user roles/permissions.

    Limitations:#

    • Method-level security is tied to individual methods, so it might be harder to manage if the same rules need to be applied across multiple layers (such as controllers, services, or repositories).

    2. Request Matchers:#

    Request matchers apply security rules at the URL level and define who can access certain endpoints in your application. This is done within the security configuration (usually in a SecurityFilterChain bean).

    Code

    @Bean SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { private static final String[] publicRoutes = { "/error","/auth/**","/home.html" }; httpSecurity .authorizeHttpRequests(auth-> auth .requestMatchers(publicRoutes).permitAll() // Public endpoints // .requestMatchers("/endpoint/**").hasRole(Roles.ADMIN.name()) // all the routes after endpoint will be allowed for "ADMIN" only without admin if any one is trying to send any request on this endpoint then it will show a forbidden error .requestMatchers("/endpoint/**").authenticated() .requestMatchers(HttpMethod.GET,"/endpoint/**").permitAll()//anyone can access get method of this endpoint controller .requestMatchers(HttpMethod.POST,"/endpoint/**") .hasAnyRole(Roles.ADMIN.name(),Roles.USER.name()) //only Admin and Creator can access this post method of this endpoint controller .requestMatchers(HttpMethod.POST,"/endpoint/**") .hasAnyAuthority(Permissions.POST_CREATE.name()) .requestMatchers(HttpMethod.PUT,"/endpoint/**") .hasAuthority(Permissions.POST_UPDATE.name()) .requestMatchers(HttpMethod.DELETE,"/endpoint/**") .hasAnyAuthority(Permissions.POST_DELETE.name()) .anyRequest().authenticated()); // All other requests require authentication }

    Advantages:#

    • URL-based access control: Request matchers are great for securing entire URLs or sets of URLs (like a REST API endpoint).
    • Centralized configuration: Security rules are managed centrally in the security configuration, making it easier to see which endpoints are protected and by whom.
    • Endpoint-wide rules: Use request matchers to secure entire sets of endpoints based on HTTP methods (GET, POST, PUT, DELETE), roles, or permissions.

    Limitations:#

    • Less granular control: Request matchers secure based on URLs and HTTP methods but do not apply business logic-level conditions. You can’t easily use complex conditions like checking method arguments.
    • Less flexibility: If you need different security rules for different operations on the same endpoint (e.g., GET is open, but POST requires a specific role), it can become more cumbersome to manage.

     

    Last updated on Dec 09, 2024