11 Case Practice Using Spring Security Advanced Topics to Protect Web Applications

11 Case Practice Using Spring Security Advanced Topics to Protect Web Applications #

In the previous lectures, we introduced some advanced features provided by Spring Security, including filters, CSRF protection, CORS, and global methods. These are all very practical and useful features. As a summary of this phase, today’s content will use these features to build a typical authentication mechanism in the security field, namely Multi-Factor Authentication (MFA).

Case Study Design and Initialization #

In today’s case study, instead of using a mature third-party solution, we will design and implement a simple and complete authentication mechanism based on the features provided by Spring Security.

Multi-Factor Authentication Design #

Multi-Factor Authentication is a method of secure access control, where users need to pass at least two or more authentication mechanisms to access the final resource.

So how do we implement multiple authentication mechanisms? One common approach is to divide it into two steps. The first step is to obtain an authentication code (Authentication Code) through the username and password. The second step is to perform secure access based on the username and this authentication code. The basic execution flow of this multi-factor authentication is illustrated in the following diagram:

Drawing 0.png

Illustration of the implementation of multi-factor authentication

System Initialization #

To implement multi-factor authentication, we need to build an independent authentication service, Auth-Service, which provides authentication forms based on both username+password and username+authentication code. Of course, building an authentication system requires providing the User entity class as shown below:

@Entity

public class User {

    @Id

    @GeneratedValue(strategy = GenerationType.IDENTITY)

    private Integer id;

    private String username;

    private String password;

}

As you can see, the User object contains the definitions for the username (Username) and password (Password). Similarly, the AuthCode object represents the authentication code and includes the username (Username) and the specific authentication code (Code) as shown below:

@Entity

public class AuthCode {

    @Id

    @GeneratedValue(strategy = GenerationType.IDENTITY)

    private Integer id;

    private String username;

    private String code;

}

Based on the User and AuthCode entity objects, we provide the corresponding SQL definitions for creating database tables as shown below:

CREATE TABLE IF NOT EXISTS `spring_security_demo`.`user` (
    `id` INT NOT NULL AUTO_INCREMENT,
    `username` VARCHAR(45) NULL,
    `password` TEXT NULL,
    PRIMARY KEY (`id`)
);

CREATE TABLE IF NOT EXISTS `spring_security_demo`.`auth_code` (
    `id` INT NOT NULL AUTO_INCREMENT,
    `username` VARCHAR(45) NOT NULL,
    `code` VARCHAR(45) NULL,
    PRIMARY KEY (`id`)
);

With the authentication service in place, we need to build a business service, Business-Service, which completes the specific authentication operations by integrating the authentication service and returns an access token (Token) to the client system. Therefore, Business-Service will call Auth-Service as shown in the following diagram:

Drawing 2.png

Relationship diagram of Business-Service calling Auth-Service

Next, we will start implementing the multi-factor authentication mechanism from these two services.

Implementing the Multi-Factor Authentication Mechanism #

For the multi-factor authentication mechanism, the implementation of the authentication service is the foundation, but it is not difficult. Let’s take a closer look.

Implementing the Authentication Service #

In terms of presentation, the authentication service is also a web service, so internally it needs to expose HTTP endpoints by building Controller layer components. For this purpose, we have created the AuthController as shown below:

@RestController

public class AuthController {

    @Autowired

    private UserService userService;

    // Add User

    @PostMapping("/user/add")

    public void addUser(@RequestBody User user) {

        userService.addUser(user);

    }

    // Authenticate the user based on username and password

    @PostMapping("/user/auth")

    public void auth(@RequestBody User user) {

        userService.auth(user);

    }

    // Authenticate based on username and authentication code

    @PostMapping("/authcode/check")

    public void check(@RequestBody AuthCode authCode, HttpServletResponse response) {

        if (userService.check(authCode)) {

            response.setStatus(HttpServletResponse.SC_OK);

        } else {

            response.setStatus(HttpServletResponse.SC_FORBIDDEN);

        }

    }

}

可以看到,除了一个添加用户信息的 HTTP 端点外,我们还分别实现了通过用户名和密码对用户进行首次认证的 /user/auth 端点,以及通过用户名和认证码进行二次认证的 /authcode/check 端点。

这两个核心端点的实现逻辑都位于 UserService 中。我们先来看其中的 auth() 方法:

public void auth(User user) {
    Optional<User> o = userRepository.findUserByUsername(user.getUsername());
    
    if (o.isPresent()) {
        User u = o.get();
        
        if (passwordEncoder.matches(user.getPassword(), u.getPassword())) {
            // 生成或刷新认证码
            generateOrRenewAutoCode(u);
        } else {
            throw new BadCredentialsException("Bad credentials.");
        }
    } else {
        throw new BadCredentialsException("Bad credentials.");
    }
}

上述代码中的关键流程是在完成用户密码匹配之后进行刷新认证码的流程。负责实现该流程的 generateOrRenewAutoCode() 方法如下所示:

private void generateOrRenewAutoCode(User u) {
    String generatedCode = GenerateCodeUtil.generateCode();
    
    Optional<AuthCode> authCode = autoCodeRepository.findAuthCodeByUsername(u.getUsername());
    
    if (authCode.isPresent()) { // 如果存在认证码,则刷新该认证码
        AuthCode code = authCode.get();
        code.setCode(generatedCode);
    } else { // 如果没有找到认证码,则生成并保存一个新的认证码
        AuthCode code = new AuthCode();
        code.setUsername(u.getUsername());
        code.setCode(generatedCode);
        autoCodeRepository.save(code);
    }
}

上述方法的流程很明确,首先调用 GenerateCodeUtil 工具类的 generateCode() 方法生成一个认证码,然后根据当前数据库中的状态决定是否刷新已有的认证码,或者直接生成一个新的认证码并保存。因此,每次调用 UserServiceauth() 方法就相当于对用户的认证码进行了动态重置。

一旦用户获取了认证码,并通过该认证码访问系统,认证服务就可以对该认证码进行校验,从而确定其是否有效。对认证码进行验证的方法如下所示:

public boolean check(AuthCode authCodeToValidate) {
    Optional<AuthCode> authCode = autoCodeRepository.findAuthCodeByUsername(authCodeToValidate.getUsername());
    
    if (authCode.isPresent()) {
        AuthCode authCodeInStore = authCode.get();
        
        if (authCodeToValidate.getCode().equals(authCodeInStore.getCode())) {
            return true;
        }
    }
    
    return false;
}

这里的逻辑也很简单,就是将从数据库中获取的认证码与用户传入的认证码进行比对。

至此,认证服务的核心功能已经构建完成。下面我们来看业务服务的实现过程。

实现业务服务 #

在业务服务中,我们需要调用认证服务提供的 HTTP 端点,完成用户认证和认证码认证这两个核心操作。因此,我们需要构建一个认证服务的客户端组件来完成远程调用。在本案例中,我们参考设计模式中的门面(Facade)模式的设计理念,将这个组件命名为 AuthenticationServerFacade,即认证服务的一种门面组件。定义如下:

@Component
public class AuthenticationServerFacade {
    @Autowired
    private RestTemplate rest;
    
    @Value("${auth.server.base.url}")
    private String baseUrl;
    
    public void checkPassword(String username, String password) {
        String url = baseUrl + "/user/auth";
        
        User body = new User();
        body.setUsername(username);
        body.setPassword(password);
        
        HttpEntity<User> request = new HttpEntity<User>(body);
        
        rest.postForEntity(url, request, Void.class);
    }
    
    public boolean checkAuthCode(String username, String code) {
        String url = baseUrl + "/authcode/check";
        
        User body = new User();
        body.setUsername(username);
        body.setCode(code);
        
        HttpEntity<User> request = new HttpEntity<User>(body);
        
        ...
            String username = request.getHeader("username");
    
            String password = request.getHeader("password");
    
            String code = request.getHeader("code");
    
     
    
            //使用 AuthenticationManager 处理认证过程
    
        }
    
    }

The first thing to pay attention to in the above code is the base class OncePerRequestFilter that CustomAuthenticationFilter extends. As the name suggests, OncePerRequestFilter ensures that the filter logic is executed only once per request and avoids multiple repetitions. Here, we retrieve the username, password, and code parameters from the HTTP request headers, and attempt to authenticate using the AuthenticationManager. As discussed in the “Authentication System: How to Understand the User Authentication Mechanism of Spring Security in Depth?” lecture, the AuthenticationManager uses the AuthenticationProvider to perform the actual authentication process.

Let’s recall the two authentication operations provided by the authentication service: one is user authentication based on username and password, and the other is authentication based on username and code. Therefore, we need to implement different AuthenticationProviders for these two operations. For example, the UsernamePasswordAuthenticationProvider below implements authentication for usernames and passwords:

    @Component
    public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
    
        @Autowired
        private AuthenticationServerFacade authServer;
    
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            String username = authentication.getName();
            String password = String.valueOf(authentication.getCredentials());
    
            // Call the authentication service to complete authentication
            authServer.checkPassword(username, password);
    
            return new UsernamePasswordAuthenticationToken(username, password);
        }
    
        public boolean supports(Class<?> aClass) {
            return UsernamePasswordAuthentication.class.isAssignableFrom(aClass);
        }
    }

Here, we use the AuthenticationServerFacade facade class to make a remote call to the authentication service. Similarly, we can also build an AuthenticationProvider for the code-based authentication, which is shown below:

    @Component
    public class AuthCodeAuthenticationProvider implements AuthenticationProvider {
    
        @Autowired
        private AuthenticationServerFacade authServer;
    
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            String username = authentication.getName();
            String code = String.valueOf(authentication.getCredentials());
    
            // Call the authentication service to complete authentication
            boolean result = authServer.checkAuthCode(username, code);
    
            if (result) {
                return new AuthCodeAuthentication(username, code);
            } else {
                throw new BadCredentialsException("Bad credentials.");
            }
        }
    
        public boolean supports(Class<?> aClass) {
            return AuthCodeAuthentication.class.isAssignableFrom(aClass);
        }
    }

Note that both UsernamePasswordAuthenticationProvider and AuthCodeAuthenticationProvider return custom authentication information classes, which inherit from the UsernamePasswordAuthenticationToken provided by Spring Security.

Now, let’s return to the CustomAuthenticationFilter filter component and provide its complete implementation, as shown below:

    @Component
    public class CustomAuthenticationFilter extends OncePerRequestFilter {
    
        @Autowired
        private AuthenticationManager manager;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String username = request.getHeader("username");

String password = request.getHeader("password");

String code = request.getHeader("code");

// If the code is empty, it means that username/password authentication needs to be performed first

if (code == null) {

    Authentication a = new UsernamePasswordAuthentication(username, password);

    manager.authenticate(a);

} else {

    // If the code is not empty, perform code authentication

    Authentication a = new AuthCodeAuthentication(username, code);

    manager.authenticate(a);

    

    // If code authentication is successful, generate a token using UUID and add it to the response header

    String token = UUID.randomUUID().toString();

    response.setHeader("Authorization", token);

}



}

@Override

protected boolean shouldNotFilter(HttpServletRequest request) {

    return !request.getServletPath().equals("/login");

}

The implementation process of CustomAuthenticationFilter is relatively simple, and the code is self-explanatory. The only thing to note is that after the authentication based on the authentication code, we will add an “Authorization” header to the response and return a token using the UUID value.

For the above code, we can summarize it with the following class diagram:

Drawing 4.png

Class diagram of multi-factor authentication process

Finally, we need to ensure the effective collaboration between classes through the configuration system in Spring Security. For this, we build the SecurityConfig class as shown below:

@Configuration

public class SecurityConfig extends WebSecurityConfigurerAdapter {

 

    @Autowired

    private CustomAuthenticationFilter customAuthenticationFilter;

 

    @Autowired

    private AuthCodeAuthenticationProvider authCodeAuthenticationProvider;

 

    @Autowired

    private UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider;

 

    @Override

    protected void configure(AuthenticationManagerBuilder auth) {

        auth.authenticationProvider(authCodeAuthenticationProvider)

            .authenticationProvider(usernamePasswordAuthenticationProvider);

    }

 

    @Override

    protected void configure(HttpSecurity http) throws Exception {

        http.csrf().disable();

 

        http.addFilterAt(

                customAuthenticationFilter,

                BasicAuthenticationFilter.class);

 

        http.authorizeRequests()

                .anyRequest().authenticated();

    }

 

    @Override

    @Bean

    protected AuthenticationManager authenticationManager() throws Exception {

        return super.authenticationManager();

    }

}

In the above configuration, we can see that we can add a custom filter using the addFilterAt() method. You can also refer to the content of the Chapter 08 “Pipeline Filters: How to Extend Security with Spring Security Filters?” for more information on filter usage.

You can download the complete code for this case here.

Case Demonstration #

Now, let’s start the authentication service and business service locally. Note that the authentication service runs on port 8080, and the business service runs on port 9090. Then open Postman and enter the relevant parameters for simulating the HTTP request, as shown below:

Drawing 5.png

Illustration of the first step of multi-factor authentication: username + password-based

Obviously, this request only provides the username and password, so it will perform authentication based on UsernamePasswordAuthenticationProvider to generate an authentication code for the user “jianxiang”. The authentication code is dynamically generated, so the result of each request is different. I have queried the database and the authentication code is “9750”. You can also try it yourself.

With the authentication code, we have completed the first step of the multi-factor authentication process. Next, let’s build the request based on this authentication code and get the response result, as shown below:

Drawing 7.png

Illustration of the second step of multi-factor authentication: username + authentication code-based

As you can see, by passing the correct authentication code, we have completed the second step of the multi-factor authentication process based on AuthCodeAuthenticationProvider, and finally generated an “Authorization” header in the HTTP response.

Summary and Preview #

In this lesson, we demonstrated how to use some advanced topics in Spring Security to protect web applications. The implementation of the multi-factor authentication process requires building multiple custom AuthenticationProviders and handling requests through interceptors. I believe that the development techniques demonstrated in the case will help you in your daily work.

The summary of this lesson’s content is as follows:

Drawing 9.png

Here’s a question for you to think about: How can you use filters in Spring Security to implement customized authentication for user requests?