Spring Security without WebSecurityConfigurerAdapter. Part 1

Spring Security without WebSecurityConfigurerAdapter. Part 1

In Spring Security, we've witnessed significant changes, notably the deprecation of the trusted WebSecurityConfigurerAdapter is in favor of a more streamlined and powerful approach to securing your web applications. This article serves as a comprehensive resource for developers seeking to grasp the latest techniques and best practices in securing their Spring-based web applications.

Here you will find all examples with code: https://github.com/BykaWF/SpringSecurityGuide


Basic Authentication

Code source:https://github.com/BykaWF/SpringSecurityGuide/tree/

Basic Auth

When you send a request to a secure server and receive a 401 (Unauthorized) response, it's the server's way of asking for your username and password. It checks these credentials to determine if you're authorized to access the requested resource. If your credentials are valid, you'll be granted access to the resource.

If you just add Spring Security dependency we will get this page and generated password in the console:

Before

If you want to add Basic Auth to your application:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    /**
     *  @Explanation: anyRequest() should be authenticated() and for getting pop up Basic Auth you need add httpBasic()
     *
     *  @Note: You can't log out after you have authenticated
     */
    @Bean
    public SecurityFilterChain formLogin(HttpSecurity http) throws Exception{
        http.
                authorizeHttpRequests(authorize -> authorize
                        .anyRequest()
                        .authenticated()
                )
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

And magic happened:

After


ParmitAll()

If you want to add an endpoint where every single user gets access without authorization.

To show how it works we need to add HomeController(unsecured) and UserController(secured):

@RestController
public class HomeController {
    @GetMapping("/")
    public String homeGreeting(){
        return "Hello, I'm not secured";
    }
}
@RestController
@RequestMapping("/api/user")
public class UserController {
    @GetMapping("/greeting")
    public String userGreeting(){
        return "I'm secured";
    }

And a little bit edit our SecurityConfig:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    /**
     *  @Explanation: if your request is matching ("/") to give permit it without authentication. If request is not matched it should be authenticated by Basic Auth
     *  @Note: You can't log out after you have authenticated
     */
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth -> {
                            auth.requestMatchers("/").permitAll();
                            auth.anyRequest().authenticated();
                        }
                )
                .httpBasic(withDefaults())
                .build();
    }
}

Without permitAll() we should be authenticated for http:8080/ or http:8080/api/user/greeting:

With permitAll() we can get access to http:8080/ without permission, but http:8080/API/user/greeting still secured:


Multiple Security Configuration

Do you want to use different types of authentication? We have two controllers: HomeController and UserController. Let's consider a situation where you want to have HTTP basic() auth for HomeContorller and formLogin() for UserContorller.

There is SecurityMatcher() which will help us to achieve it. We will make some changes in HomeController that help to be more clear:

@RestController
@RequestMapping("api/home/")
public class HomeController {
    @GetMapping("/greeting")
    public String homeGreeting(){
        return "Hello, I'm secured with httpBasic()";
    }
}

And of course, we should make some changes in SecurityConfig:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception {
        return http
                .securityMatcher("/api/user/**")
                .authorizeHttpRequests(auth -> {
                            auth.anyRequest().authenticated();
                        }
                )
                .httpBasic(withDefaults())
                .build();
    }
    @Bean
    SecurityFilterChain homeFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth -> {
                            auth.requestMatchers("/").permitAll();
                            auth.requestMatchers("/error").permitAll();
                            auth.anyRequest().authenticated();
                        }
                )
                .formLogin(withDefaults())
                .build();
    }
}

As we can see for UserController we want to use pop-up auth and for HomeController login form.


Users' Roles and Authorities

Code source:https://github.com/BykaWF/SpringSecurityGuide/tree/Role_Auth

Previously to get access to any resource we should use a generated password in our console and in the field username pass user. But if you want to create your User with username and password, role and so forth. You can store your User in memory or your database.

InMemoryUserDetailsManager

We can create our user InMemory by UserDetailsService - a core interface that loads user-specific data.

@Bean
    public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
        UserDetails user = User.builder()
                .username("Bogdan")
                .password(passwordEncoder.encode("password"))
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }

And we need to provide PasswordEncoder with technology BCryptPasswordEncoder.

@Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

Let's test it, could we get access to our resources with a new user:

Because our user with username: "Bogdan" and password: "password" exists we can reach the resource. But you have access to any other resource.


Roles and Permission (Authority)

Let's consider a basic situation when you are building a Library Management System and you have :

  • Seller (library stuff)

  • Customer

In this case, our Seller has the role of Admin and permission to write and read a book. Our Customer has the role of User and permission only read a book. We need APIs that are accessible only for admin or only users. And of course, you can assign multiple roles to your user.

Role-based Authentication

Let's add a new User with a new role in our SecurityConfig and create enums for roles and permissions.

   @Bean
    public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
        UserDetails bogdan = User.builder()
                .username("Bogdan")
                .password(passwordEncoder.encode("password"))
                .roles(USER.name())
                .build();
        UserDetails admin = User.builder() 
                .username("John")
                .password(passwordEncoder.encode("password"))
                .roles(ADMIN.name())
                .build();
        return new InMemoryUserDetailsManager(bogdan,admin);
    }
@RequiredArgsConstructor
@Getter
public enum Role {
    ADMIN(
            Set.of(
                    ADMIN_UPDATE,
                    ADMIN_DELETE,
                    ADMIN_CREATE,
                    ADMIN_READ,
                    USER_UPDATE,
                    USER_DELETE,
                    USER_CREATE,
                    USER_READ
            )
    ),
    USER(
            Set.of(
                    USER_UPDATE,
                    USER_DELETE,
                    USER_CREATE,
                    USER_READ
            )
    ),
    GEUST(Collections.emptySet());


    //Roles will have Set of permissions, because we don't want to have duplicates
    private final Set<Permission> permissions;

}
package Guide.SecuritySpring.model;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public enum Permission {
    ADMIN_READ("admin:read"),
    ADMIN_UPDATE("admin:update"),
    ADMIN_CREATE("admin:create"),
    ADMIN_DELETE("admin:delete"),
    USER_READ("user:read"),
    USER_UPDATE("user:update"),
    USER_CREATE("user:create"),
    USER_DELETE("user:delete");

    private final String permission;
}

We have created roles and enums. Let's add some new future to our Security Config.

@Bean
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
                .securityMatcher("/api/user/**") // <--- it is not necessary 
                .authorizeHttpRequests(auth -> {
                            auth.requestMatchers("/").permitAll();
                            auth.requestMatchers("/api/**").hasRole(USER.name());
                            auth.anyRequest().authenticated();
                        }
                )
                .httpBasic(withDefaults())
                .build();
    }

We have added auth.requestMatchers("/api/**").hasRole(USER.name()); that tells us to get access to our user endpoints can only who hasRole(User.name())

Permission-Based Authentication

Code source: https://github.com/BykaWF/SpringSecurityGuide/tree/Permission_Based_Authentication

For this section, I rebuilt the project, I think It will help get a better experience for testing any type of permission.

Added two models: Customer and Address

@Entity
@Transactional
@Data
@NoArgsConstructor
@Table(name = "address")
@AllArgsConstructor
@Builder
public class Address {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "add_id")
    private Long addressId;
    private String city;
    private String addressType;
}
@Entity
@Transactional
@Data
@NoArgsConstructor
@Table(name = "customer_details")
@AllArgsConstructor
@Builder
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "csr_id")
    private Long customerId;
    private String username;
    private String email;

    @OneToOne(cascade = CascadeType.ALL)
    @JoinColumn(name = "fk_add_id")
    private Address address;
}

Also provided Repositories for both entity and CustomerService. Role and Permission were edited:

@RequiredArgsConstructor
@Getter
public enum Permission {
    ADMIN_READ("admin:read"),
    ADMIN_UPDATE("admin:update"),
    ADMIN_CREATE("admin:create"),
    ADMIN_DELETE("admin:delete"),

    USER_READ("user:read"),

    MANAGER_READ("manager:read");

    private final String permission;
}

To our Role we added method getGrantedAuthorities(). GrantedAuthority - represents an authority granted (permissions) to an Authentication object(Our user). Here we want to map our permission to Set<SimpleGrantedAuthority>. SimpleGrantedAuthority is a basic concrete implementation of a GrantedAuthority.

@RequiredArgsConstructor
@Getter
public enum Role {
    ADMIN(
            Set.of(
                    ADMIN_UPDATE,
                    ADMIN_DELETE,
                    ADMIN_CREATE,
                    ADMIN_READ
            )
    ),
    USER(
            Set.of(
                    USER_READ
            )
    ),
    MANAGER_TRAINEE(
            Set.of(
                    MANAGER_READ
            )
    );


    //Roles will have Set of permissions, because we don't want to have duplicates
    private final Set<Permission> permissions;

    public Set<SimpleGrantedAuthority> getGrantedAuthorities(){
            Set<SimpleGrantedAuthority> permissions = getPermissions()
                    .stream()
                    .map(permission -> new SimpleGrantedAuthority(permission.name()))
                    .collect(Collectors.toSet());
            permissions.add(new SimpleGrantedAuthority("ROLE_" + this.name()));
            return permissions;
     }

And we want to use this method in our SecurityConfig. Let's look at our UserDetailsService.


    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
        UserDetails customer = User.builder()
                .username("Bogdan")
                .password(passwordEncoder.encode("password"))
//                .roles(USER.name())
                .build();

        UserDetails admin = User.builder()
                .username("John")
                .password(passwordEncoder.encode("password"))
//                .roles(ADMIN.name())
                .authorities(ADMIN.getGrantedAuthorities())
                .build();
        UserDetails manager = User.builder()
                .username("Yaroslav")
                .password(passwordEncoder.encode("password123"))
//                .roles(MANAGER_TRAINEE.name())
                .authorities(MANAGER_TRAINEE.getGrantedAuthorities())
                .build();
        return new InMemoryUserDetailsManager(customer, admin, manager);

    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(10);
    }
}

Let's comment roles() in our user's details and use .authorities(ROLE.getGrantedAuthorities()). Here is we implemented method that we built in our Role that will load the permissions. If just add a debug pointer to the line where we are returning InMemoryUserDetailsManager(customer, admin, manager); we get:

As we can see our permissions were loaded from our method and also was added ROLE_.
In apiSecurityFilterChain we should add a few requestMatchers for each method and set authorities for each one.

 @Bean
    public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth -> {
                            auth.requestMatchers(GET, "management/api/**").hasAnyRole(ADMIN.name(), MANAGER_TRAINEE.name());
                            auth.requestMatchers(POST, "management/api/**").hasAuthority(ADMIN_CREATE.name());
                            auth.requestMatchers(DELETE, "management/api/**").hasAuthority(ADMIN_DELETE.name());
                            auth.requestMatchers(PUT, "management/api/**").hasAuthority(ADMIN_UPDATE.name());
                            auth.anyRequest().authenticated();
                        }
                )
                .httpBasic(withDefaults())
                .build();
    }

After all, let's consider our CustomerContoroller. As I mentioned we put a "lock" for anyone except the admin to send a POST method.

@RestController
@RequestMapping("management/api/")
public class CustomerController {

    private final CustomerService customerService;

    @Autowired
    public CustomerController(CustomerService customerService, CustomerRepository customerRepository) {
        this.customerService = customerService;
    }

    @PostMapping("/new")
    public ResponseEntity<Customer> createCustomer(@RequestBody CreateCustomerRequest request) {

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        System.out.println("Authentication: " + authentication.getName());
        System.out.println("Authorities: " + authentication.getAuthorities());

        return ResponseEntity.ok(customerService.createCustomer(request.toCustomer()));
    }

    @GetMapping("/getCustomers")
    public List<Customer> getAllUsers() {
        return customerService.getCustomers();
    }

}

Let's try to send a POST request to our server and we will start with our manager Yaroslav as we can see below he can't send a request. Because he doesn't have the authority to do it. (screenshot above with authorities all users).

-> However, our admin John can do it:

@PreAuthorize

Annotation for specifying a method access-control expression which will be evaluated to decide whether a method invocation is allowed or not.

The first thing you need to add to SecurityConfig new annotation - @EnableMethodSecurity

@Configuration
@EnableWebSecurity
@EnableMethodSecurity <------- 
public class SecurityConfig {

    @Bean
    public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
        // your code
    }

Then go to CustomerController and let's consider the POST request. We take 'ADMIN_CREATE' from our Permission.

    @PostMapping("/new")
    @PreAuthorize("hasAuthority('ADMIN_CREATE')") <------
    public ResponseEntity<Customer> createCustomer(@RequestBody CreateCustomerRequest request) {

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        System.out.println("Authentication: " + authentication.getName());
        System.out.println("Authorities: " + authentication.getAuthorities());

        return ResponseEntity.ok(customerService.createCustomer(request.toCustomer()));
    }
public enum Permission {
    ADMIN_READ("admin:read"),
    ADMIN_UPDATE("admin:update"),
    ADMIN_CREATE("admin:create"), <-------
    ADMIN_DELETE("admin:delete"),
    ..........
}

Also, you can use roles:

    @GetMapping("/getCustomers")
    @PreAuthorize("hasAnyRole('ADMIN','MANAGER_TRAINEE')")<---
    public List<Customer> getAllUsers() {
        return customerService.getCustomers();
    }

Conclusion

To wrap it up, in Spring Security, we've explored various ways to make our web applications secure. Here's a simplified summary:

  1. Basic Authentication: When you visit a secure website, it might ask for your username and password. If they're correct, you can access the website.

  2. permitAll(): Sometimes, we want to allow access to a part of our website without needing a username and password. Spring Security helps us do this for specific parts of our site.

  3. Multiple Security: Imagine having different locks for different doors in your house. Spring Security allows us to set up different security rules for different parts of our website.

  4. Users' Roles and Authorities: We can assign different roles to users. Some users might have more access (like administrators), while others have limited access (like regular users).

  5. InMemoryUserDetailsManager: This is like having a list of users and their credentials stored in memory. Spring Security helps us manage this list.

  6. Role-based Authentication: Think of it as giving different people different levels of access. Some have more privileges, while others have fewer.

  7. Permission-Based Authentication: We can specify who can access specific parts of the website based on their permissions.

  8. @PreAuthorize: It's a way to set rules for who can use certain features on the website. Think of it like a bouncer at a club checking if you're allowed to enter.

In the next parts, we will delve deeper into additional important security concepts, including:

  1. CSRF (Cross-Site Request Forgery): We'll learn how to protect our web applications from malicious requests that can trick users into performing actions without their consent.

  2. Form-Based Authentication: We'll explore how to create custom login forms and authenticate users using their credentials.

  3. Database Authentication: We'll dive into storing user information and credentials in a database, allowing for more flexibility in managing users.

  4. JSON Web Tokens (JWT): We'll discover how to implement authentication and authorization using JWTs, which are becoming increasingly popular for securing modern web applications.