【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)


1、功能简述

这里将设计一个由3个参与者组成的系统:客户端、身份验证服务器和业务逻辑服务器。从这3个参与者中,我们将实现身份验证服务器的后端部分和业务逻辑服务器。

实现的内容如下:

  • 实现并使用令牌

  • 使用JSON Web Tokens

  • 在多个应用程序中分离身份验证和授权职责

  • 实现多重身份验证场景

  • 使用多个自定义过滤器和多个AuthenticationProvider对象

1.1 组件介绍

  • 客户端:这是使用后端服务为的应用程序。它可以是一个移动应用程序,也可以是使用Vue.js等框架开卡的Web应用程序的前端

  • 身份验证服务器:这是一个具有用户凭据数据库的应用程序。此应用程序的目的是根据用户的凭据(用户名和密码)对用户进行身份验证,并通过短信(SMS)向用户发送一次性密码(OTP)。本示例中不会发送SMS,所以我们将直接从数据库中读取OTP的值。

  • 业务逻辑服务器:这是暴露客户端所使用的端点的应用程序。我们希望对这些端点的访问行为进行安全保护。在调用端点之前,用户必须使用用户名和密码进行身份验证,然后发送OTP。用户通过SMS短信接收OTP。因为这个应用程序是目标应用程序,所以要使用Spring Security保护它。

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

1.2 调用过程

要调用业务逻辑服务器上的任何端点,客户端必须遵循以下3个步骤:

(1)通过调用业务逻辑服务器上的/login端点验证用户名和密码,以获得随机生成的OTP。

(2)使用用户名和OTP调用/login端点。

(3)通过将步骤(2)中接收到的令牌添加到HTTP请求的Authorization头信息来调用任何端点。

当客户端对用户名和密码进行身份验证时,业务逻辑服务器会向身份验证服务器发送一个获取OTP的请求。身份验证成功之后,身份验证服务器会通过SMS将随机生成的OTP发送给客户端。这种识别用户的方法被称为多重身份验证(Multi-Factor Authentication,MFA)。

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

在第二个身份验证步骤中,一旦客户端获得了来自接收到的SMS短信的验证码,用户就可以再次使用用户名和密码调用/login端点。业务逻辑服务器会使用身份验证服务器验证该验证码。如果验证码有效,则客户端将接收到一个令牌,它可以使用该令牌调用业务逻辑服务器上的任何端点。

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

在第三个身份验证步骤中,客户端现在可以通过第(2)步中接收到的令牌添加到HTTP请求的Authorization头信息来调用任何端点。如下图。

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

这里有一点点缺陷,其实客户端应该只与身份验证服务器共享密码,而不与业务逻辑服务器共享密码,这里是为了简化。

JWT的介绍请参考这篇博文:https://blog.csdn.net/qq_43753724/article/details/122370738?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522164588854116781683955552%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=164588854116781683955552&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_ecpm_v1~rank_v31_ecpm-1-122370738.nonecase&utm_term=jwt&spm=1018.2226.3001.4450

2、实现身份验证服务器

在这里的场景中,身份验证服务器要连接到一个数据库,其中存储了在请求身份验证事件期间生成的用户凭据和OTP。这个应用程序需要暴露3个端点。(见下图)

  • /user/add:添加稍后用于测试实现的用户。

  • /user/auth:通过用户的凭据对用户进行身份验证,并发送带有OTP的SMS短信。这里去掉了发送SMS短信的实现部分。

  • /otp/check:验证OTP值是否是身份验证服务器之前为特定用户生成的值。

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

2.1 项目依赖

 <dependencies>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-data-jpa</artifactId>
         </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-security</artifactId>
         </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-web</artifactId>
         </dependency>
 
         <dependency>
             <groupId>mysql</groupId>
             <artifactId>mysql-connector-java</artifactId>
             <scope>runtime</scope>
         </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-test</artifactId>
             <scope>test</scope>
             <exclusions>
                 <exclusion>
                     <groupId>org.junit.vintage</groupId>
                     <artifactId>junit-vintage-engine</artifactId>
                 </exclusion>
             </exclusions>
         </dependency>
         <dependency>
             <groupId>org.springframework.security</groupId>
             <artifactId>spring-security-test</artifactId>
             <scope>test</scope>
         </dependency>
     </dependencies>

2.2 数据库脚本

由于还需要确保为应用程序创建了数据库,因为要存储用户凭据(用户名和密码),所以需要一个表。还需要第二张表来存储与经过身份验证的用户相关联的OTP值。

将下面的脚本放到项目resources文件夹中,因为Spring Boot会从这里提取脚本并执行它。

 CREATE TABLE IF NOT EXISTS `spring`.`user` (
     `username` VARCHAR(45) NOT NULL,
     `password` TEXT NULL,
     PRIMARY KEY (`username`)
 );
 
 CREATE TABLE IF NOT EXISTS `spring`.`otp` (
     `username` VARCHAR(45) NOT NULL,
     `code` VARCHAR(45) NULL,
     PRIMARY KEY (`username`)
 );

2.3 application.properties

 spring.datasource.url=jdbc:mysql://localhost:3306/spring?useLegacyDatetimeCode=false&serverTimezone=UTC
 spring.datasource.username=root
 spring.datasource.password=123456
 spring.datasource.initialization-mode=always

这个应用程序的依赖项还添加了Spring Security。为身份验证服务器这样做的唯一原因是我打算使用BCryptPasswordEncoder。在将密码存储在数据库中时,使用这个BCryptPasswordEncoder来哈希化用户的密码。为了使得示例简短,这里没有在业务逻辑服务器和身份验证服务器之间实现身份验证。

2.4 身份验证服务器的配置类

 @Configuration
 public class ProjectConfig extends WebSecurityConfigurerAdapter {
 
     //定义一个密码编辑器来哈希化存储在数据库中的密码
     @Bean
     public PasswordEncoder passwordEncoder() {
         return new BCryptPasswordEncoder();
    }
 
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.csrf().disable();  //禁用CSRF。这样就可以直接调用应用程序的所有端点
         http.authorizeRequests()
                .anyRequest().permitAll();  //允许所有不需要身份验证的调用
    }
 }

2.5 两个JPA实体

 @Entity
 public class User {
 
     @Id
     private String username;
     private String password;
 
     public String getUsername() {
         return username;
    }
 
     public void setUsername(String username) {
         this.username = username;
    }
 
     public String getPassword() {
         return password;
    }
 
     public void setPassword(String password) {
         this.password = password;
    }
 }
 @Entity
 public class Otp {
 
     @Id
     private String username;
     private String code;
 
     public String getUsername() {
         return username;
    }
 
     public void setUsername(String username) {
         this.username = username;
    }
 
     public String getCode() {
         return code;
    }
 
     public void setCode(String code) {
         this.code = code;
    }
 }

2.6 两个Repository接口

 public interface UserRepository extends JpaRepository<User, String> {
 
     Optional<User> findUserByUsername(String username);
 }
 public interface OtpRepository extends JpaRepository<Otp, String> {
 
     Optional<Otp> findOtpByUsername(String username);
 }

2.7 自动装配UserService类中的依赖项

 @Service
 @Transactional
 public class UserService {
 
     @Autowired
     private PasswordEncoder passwordEncoder;
 
     @Autowired
     private UserRepository userRepository;
 
     @Autowired
     private OtpRepository otpRepository;
 }

接下来需要定义一个方法来添加用户

2.8 addUser()方法的定义

 @Service
 @Transactional
 public class UserService {
 
     @Autowired
     private PasswordEncoder passwordEncoder;
 
     @Autowired
     private UserRepository userRepository;
 
     @Autowired
     private OtpRepository otpRepository;
 
     public void addUser(User user) {
         user.setPassword(passwordEncoder.encode(user.getPassword()));
         userRepository.save(user);
    }
 }

业务逻辑服务器需要什么?它需要一种发送用户名和密码以进行身份验证的方法。

2.9 实现身份验证的第一个步骤

 @Service
 @Transactional
 public class UserService {
 
     @Autowired
     private PasswordEncoder passwordEncoder;
 
     @Autowired
     private UserRepository userRepository;
 
     @Autowired
     private OtpRepository otpRepository;
 
     public void addUser(User user) {
         user.setPassword(passwordEncoder.encode(user.getPassword()));
         userRepository.save(user);
    }
 
     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())) {
                 renewOtp(u);    //如果密码正确,则生成一个新的OTP
            } else {
                 throw new BadCredentialsException("Bad credentials.");
            }
        } else {
             throw new BadCredentialsException("Bad credentials.");
        }
    }
     
      private void renewOtp(User u) {
         String code = GenerateCodeUtil.generateCode();
  //根据用户名搜索OTP
         Optional<Otp> userOtp = otpRepository.findOtpByUsername(u.getUsername());
         if (userOtp.isPresent()) {  //如果这个用户名的OTP存在,则更新其值
             Otp otp = userOtp.get();
             otp.setCode(code);
        } else {    //如果不存在,则用生成的值创建一条新记录
             Otp otp = new Otp();
             otp.setUsername(u.getUsername());
             otp.setCode(code);
             otpRepository.save(otp);
        }
    }
 }

2.10 生成OTP

 public final class GenerateCodeUtil {
 
     private GenerateCodeUtil() {}
 
     public static String generateCode() {
         String code;
 
         try {
             //创建一个SecureRandom的实例,该实例会生成一个随机的int值。
             SecureRandom random = SecureRandom.getInstanceStrong();
             //生成一个0~8999的值。加上1000之后编程1000~9999(4位随机码)的值
             //并将int值转换成String并返回
             code = String.valueOf(random.nextInt(9000) + 1000);
        } catch (NoSuchAlgorithmException e) {
             throw new RuntimeException("Problem when generating the random code.");
        }
 
         return code;
    }
 }

2.11 验证OTP

 @Service
 @Transactional
 public class UserService {
 
     @Autowired
     private PasswordEncoder passwordEncoder;
 
     @Autowired
     private UserRepository userRepository;
 
     @Autowired
     private OtpRepository otpRepository;
 
     public void addUser(User user) {
         user.setPassword(passwordEncoder.encode(user.getPassword()));
         userRepository.save(user);
    }
 
     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())) {
                 renewOtp(u);    //如果密码正确,则生成一个新的OTP
            } else {
                 throw new BadCredentialsException("Bad credentials.");
            }
        } else {
             throw new BadCredentialsException("Bad credentials.");
        }
    }
     //验证OTP
     public boolean check(Otp otpToValidate) {
         Optional<Otp> userOtp = otpRepository.findOtpByUsername(otpToValidate.getUsername());
         if (userOtp.isPresent()) {
             Otp otp = userOtp.get();
             if (otpToValidate.getCode().equals(otp.getCode())) {
                 return true;
            }
        }
 
         return false;
    }
 
     private void renewOtp(User u) {
         String code = GenerateCodeUtil.generateCode();
 
         Optional<Otp> userOtp = otpRepository.findOtpByUsername(u.getUsername());
         if (userOtp.isPresent()) {  //如果这个用户名的OTP存在,则更新其值
             Otp otp = userOtp.get();
             otp.setCode(code);
        } else {    //如果不存在,则用生成的值创建一条新记录
             Otp otp = new Otp();
             otp.setUsername(u.getUsername());
             otp.setCode(code);
             otpRepository.save(otp);
        }
    }
 
 }

2.12 AuthController定义

 @RestController
 public class AuthController {
 
     @Autowired
     private UserService userService;
 
     @PostMapping("/user/add")
     public void addUser(@RequestBody User user) {
         userService.addUser(user);
    }
 
     @PostMapping("/user/auth")
     public void auth(@RequestBody User user) {
         userService.auth(user);
    }
 
     @PostMapping("/otp/check")
     public void check(@RequestBody Otp otp, HttpServletResponse response) {
         if (userService.check(otp)) {
             response.setStatus(HttpServletResponse.SC_OK);
        } else {
             response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        }
    }
 }

2.13 测试

通过这个设置,我们现在有了身份验证服务器。接下来启动它并确保缎带你按照期望的方式工作。要测试身份验证服务器,需要:

(1)通过调用/user/add端点向数据库添加一个新用户。

(2)通过检查数据库中的users表来验证用户是否被正确添加。

(3)调用步骤(1)中添加的用户的/user/身份验证端点。

(4)验证应用程序是否生成了OTP并在otp表中存储了OTP。

(5)使用步骤(3)中生成的OTP验证/otp/check端点是否按预期工作。

首先将一个用户添加到身份验证服务器的数据库中。至少需要一个用户进行身份验证。可以通过调用在身份验证服务器中创建的/user/add端点来添加用户。因为没有在身份验证服务器中配置端口,所以要使用默认端口,即8080。以下是其调用:

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

查看下数据库中用户是否被添加成功

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

注意:这里我们在将密码存储到数据库之前会对其进行哈希化处理,这是预期的行为。

现在有了一个用户,所以要通过调用/user/auth端点来为该用户生成一个OTP。

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

在数据库的otp表中,应用程序会生成并存储一个随机的四位数验证码。

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

测试身份验证服务器的最后一步是调用/otp/check端点,并且验证当OTP正确时在响应中返回HTTP 200 OK状态码,而在OTP错误时则返回403 Forbidden状态码。

调用成功时的状态码如下:

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

调用失败时的状态码如下:(故意写错OTP,让它调用失败)

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

3、实现业务逻辑服务器

我们要在业务逻辑服务器和身份验证服务器之间实现通信并且实现和使用JWT进行身份验证和授权,以便在应用程序中简历MFA。要完成此任务,我们需要:

(1)创建一个表示打算保护的资源的端点。

(2)实现第一个身份验证步骤。在该步骤中,客户端要将用户凭据(用户名和密码)发送到业务逻辑服务器以进行登录。

(3)实现第二个身份验证步骤,在这个步骤中,客户端要将用户从身份验证服务器接收到的OTP发送到业务逻辑服务器。通过OTP进行身份验证之后,客户端将获得一个JWT,它可以使用该JWT访问用户的资源。

(4)基于JWT实现授权。业务逻辑服务器会验证从客户端接受的JWT,如果有效,则允许客户端访问资源。

从技术上讲,要实现这4个概括要点,我们需要:

  • (1)创建业务逻辑服务器项目。

  • (2)实现Authentication对象,其中包含两个身份验证步骤的角色。

  • (3)实现一个代理,以便在身份验证服务器和业务逻辑服务器之间建立通信。

  • (4)定义AuthenticationProvider对象,这些对象使用步骤(2)中定义的Authentication对象来实现两个身份验证步骤中的身份验证逻辑。

  • (5)定义拦截HTTP请求的自定义过滤器对象,并应用由AuthenticationProvider对象实现的身份验证逻辑。

  • (6)编写授权配置。

3.1 项目依赖

 <dependencies>
     <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-security</artifactId>
     </dependency>
     <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-web</artifactId>
     </dependency>
     <dependency>
         <groupId>io.jsonwebtoken</groupId>
         <artifactId>jjwt-api</artifactId>
         <version>0.11.1</version>
     </dependency>
     <dependency>
         <groupId>io.jsonwebtoken</groupId>
         <artifactId>jjwt-impl</artifactId>
         <version>0.11.1</version>
         <scope>runtime</scope>
     </dependency>
     <dependency>
         <groupId>io.jsonwebtoken</groupId>
         <artifactId>jjwt-jackson</artifactId>
         <version>0.11.1</version>
         <scope>runtime</scope>
     </dependency>
     <dependency>
         <groupId>jakarta.xml.bind</groupId>
         <artifactId>jakarta.xml.bind-api</artifactId>
     </dependency>
 
     <dependency>
         <groupId>org.glassfish.jaxb</groupId>
         <artifactId>jaxb-runtime</artifactId>
     </dependency>
 
     <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-test</artifactId>
         <scope>test</scope>
         <exclusions>
             <exclusion>
                 <groupId>org.junit.vintage</groupId>
                 <artifactId>junit-vintage-engine</artifactId>
             </exclusion>
         </exclusions>
     </dependency>
     <dependency>
         <groupId>org.springframework.security</groupId>
         <artifactId>spring-security-test</artifactId>
         <scope>test</scope>
     </dependency>
 </dependencies>

3.2 TestController类

 @RestController
 public class TestController {
 
     @GetMapping("/test")
     public String test() {
         return "Test";
    }
 }

现在,为了确保应用程序的安全,必须确保3个层面的身份验证。

  • 使用用户名和密码进行身份验证,以接收OTP。

  • 使用OTP进行身份验证以接收令牌。

  • 使用令牌进行身份验证以访问端点。

3.3 设计方案

本方案拥有两个自定义Authentication对象和两个自定义AuthenticationProvider对象。这些对象可有助于应用于/login端点相关的逻辑。

这些逻辑将:

  • 使用用户名和密码对用户进行身份验证。

  • 使用OTP对用户进行身份验证。

然后要用第二个过滤器实现对令牌的验证。如下图:

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

3.4 实现Authentication对象

我们需要两种Authentication对象,一种标识用户名和密码的身份验证,另一种表示OTP的身份验证。Authentication接口表示请求的身份验证过程。它可以是一个正在进行的处理,也可以是一个已经完成的处理。我们需要为应用程序使用用户名和密码对用户进行身份验证以及OTP这两种情况实现Authentication接口。

如下代码提供了UsernamePasswordAuthentication类,它使用用户名和密码实现身份验证。为了简短一些,这里继承了UsernamePasswordAuthenticationToken类,因而也就间接地扩展了Authentication接口。

3.4.1 UsernamePasswordAuthentication类

 public class UsernamePasswordAuthentication extends UsernamePasswordAuthenticationToken {
 
     public UsernamePasswordAuthentication(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
         super(principal, credentials, authorities);
    }
 
     public UsernamePasswordAuthentication(Object principal, Object credentials) {
         super(principal, credentials);
    }
 }

这个类中同时定义了两个构造函数。当调用两个参数地构造函数时,身份验证实例仍然没有经过身份验证,而具有3个参数的构造函数会将Authentication对象设置为已通过身份验证。第三个参数是已授予的权限集合,对于已结束的身份验证过程,这是必须的。

当Authentication实例被验证时,意味着身份验证过程结束。如果未将Authentication对象设置为authenticated,并且在此过程中没有抛出异常,则AuthenticationManager将尝试查找正确的AuthenticationProvider对象对请求进行身份验证。

3.4.2 OtpAuthentication类

 public class OtpAuthentication extends UsernamePasswordAuthenticationToken {
 
     public OtpAuthentication(Object principal, Object credentials) {
         super(principal, credentials);
    }
 }

我们使用OTP为第二个身份验证步骤实现第二个Authentication对象。这个类继承了UsernamePasswordAuthenticationToken。这里可以使用相同的类,因为我们将OTP视为一种密码。

3.5 实现身份验证服务器的代理

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

对于这个实现,我们需要:

(1)定义一个模型类User,需要使用它调用身份验证服务器暴露的REST服务。

(2)声明一个RestTemplate类型的bean,需要使用它调用由身份验证服务器暴露的其他端点。

(3)实现代理类,它定义了两种方法:一种用于用户名/密码身份验证,另一种用于用户名/otp身份验证。

3.5.1 User模型类

 public class User {
 
     private String username;
     private String password;
     private String code;
 
     public String getUsername() {
         return username;
    }
 
     public void setUsername(String username) {
         this.username = username;
    }
 
     public String getPassword() {
         return password;
    }
 
     public void setPassword(String password) {
         this.password = password;
    }
 
     public String getCode() {
         return code;
    }
 
     public void setCode(String code) {
         this.code = code;
    }
 }

3.5.2 ProjectConfig类

 @Configuration
 public class ProjectConfig {
 
     @Bean
     public RestTemplate restTemplate() {
         return new RestTemplate();
    }
 }

定义一个RestTemplate bean。

3.5.3 AuthenticationServerProxy类

 @Component
 public class AuthenticationServerProxy {
 
     @Autowired
     private RestTemplate rest;
  //从application.properties文件中获取基准URL
     @Value("${auth.server.base.url}")
     private String baseUrl;
 
     public void sendAuth(String username, String password) {
         String url = baseUrl + "/user/auth";
 
         var body = new User();
         body.setUsername(username);
         body.setPassword(password);
 
         var request = new HttpEntity<>(body);
 
         rest.postForEntity(url, request, Void.class);
    }
 
     public boolean sendOTP(String username, String code) {
         String url = baseUrl + "/otp/check";
 
         var body = new User();
         body.setUsername(username);
         body.setCode(code);
 
         var request = new HttpEntity<>(body);
 
         var response = rest.postForEntity(url, request, Void.class);
 
         return response.getStatusCode().equals(HttpStatus.OK);
    }
 }

要将身份验证服务器的基准URL添加到application.properties文件中。这里还更改了当前应用程序的端口,因为我们希望在同一个系统上运行两个服务器应用程序,以便进行测试。这里将身份验证服务器保持在默认端口上,即8080,并将当前应用程序(业务逻辑服务器)的端口更改为9090。下面是application.properties文件的内容

 server.port=9090
 
 auth.server.base.url=http://localhost:8080
 jwt.signing.key=ymLTU8rq83j4fmJZj60wh4OrMNuntIj4fmJ

3.6 实现AuthenticationProvider接口

这里要创建一个名为UsernamePasswordAuthenticationProvider的类来提供UsernamePasswordAuthentication类型的Authentication,如下代码所示。因为此处将流程设计为有两个身份验证步骤,并且有一个过滤器负责这两个步骤,所以我们知道身份验证不会在此提供程序处结束。其中使用了带有两个参数的构造函数来构建Authentication对象。

3.6.1 UsernamePasswordAuthenticationProvider类

 @Component
 public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
 
     @Autowired
     private AuthenticationServerProxy proxy;
 
     @Override
     public Authentication authenticate(Authentication authentication) throws AuthenticationException {
         String username = authentication.getName();
         String password = String.valueOf(authentication.getCredentials());
         //使用代理调用身份验证服务器。通过SMS短信将OTP发送到客户端。
         proxy.sendAuth(username, password);
         return new UsernamePasswordAuthenticationToken(username, password);
    }
 
     @Override
     public boolean supports(Class<?> aClass) {
         //为Authentication的UsernamePasswordAuthentication类型设计此AuthenticationProvider
         return UsernamePasswordAuthentication.class.isAssignableFrom(aClass);
    }
 }

3.6.2 OtpAuthenticationProvider类

下面代码给出了为OtpAuthentication类型的Authentication而设计的身份验证提供程序。这个AuthenticationProvider所实现的逻辑很简单。它会调用身份验证服务器来确定OTP是否有效。如果OTP正确且有效,他将返回一个Authentication实例。过滤器会在HTTP响应中返回令牌。如果OTP不正确,则身份验证提供程序会抛出一个异常。

 @Component
 public class OtpAuthenticationProvider implements AuthenticationProvider {
 
     @Autowired
     private AuthenticationServerProxy proxy;
 
     @Override
     public Authentication authenticate(Authentication authentication) throws AuthenticationException {
         String username = authentication.getName();
         String code = String.valueOf(authentication.getCredentials());
         boolean result = proxy.sendOTP(username, code);
 
         if (result) {
             return new OtpAuthentication(username, code);
        } else {
             throw new BadCredentialsException("Bad credentials.");
        }
    }
 
     @Override
     public boolean supports(Class<?> aClass) {
         return OtpAuthentication.class.isAssignableFrom(aClass);
    }
 }

3.7 实现过滤器

这里将实现要添加到过滤器链中的自定义过滤器。它们的目的是拦截请求并应用身份验证逻辑。这里实现了一个过滤器来处理由身份验证服务器完成的身份验证,并且实现由另一个过滤器用于基于JWT的身份验证。其中要实现一个InitialAuthenticationFilter类,它会处理使用身份验证服务器完成的第一个身份验证步骤。

在第一步中,用户要是用其用户名和密码进行身份验证,以接受OTP。

在第二步中,用户发送OTP来证明他们确实是他们所声称的身份,在成功身份验证之后,应用程序会为他们提供一个令牌来调用业务逻辑服务器暴露的任何端点。

3.7.1 InitialAuthenticationFilter类

 @Component
 public class InitialAuthenticationFilter extends OncePerRequestFilter {
 
     //自动装配AuthenticationManager,它将应用正确的身份验证逻辑
     @Autowired
     private AuthenticationManager manager;
 
     @Value("${jwt.signing.key}")
     private String signingKey;
 
     //重写doFilterInternal方法以根据请求要求正确的身份验证
     @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 (code == null) {//如果HTTP请求中不包含OTP,则会假定必须基于用户名和密码进行身份验证
             Authentication a = new UsernamePasswordAuthentication(username, password);
             manager.authenticate(a);
        } else {
             //对于第二个身份验证步骤,需要创建一个OtpAuthentication类型的实例,并将其发送到AuthenticationManager,后者会为其找到合适的提供程序
             Authentication a = new OtpAuthentication(username, code);
             manager.authenticate(a);
 
             SecretKey key = Keys.hmacShaKeyFor(signingKey.getBytes(StandardCharsets.UTF_8));
             //构建JWT并将已验证用户的用户名存储为其声明之一。这里使用密钥对令牌进行签名
             String jwt = Jwts.builder()
                    .setClaims(Map.of("username", username))
                    .signWith(key)
                    .compact();
             //将令牌添加到HTTP响应的Authorization头信息中
             response.setHeader("Authorization", jwt);
        }
 
    }
 
     @Override
     protected boolean shouldNotFilter(HttpServletRequest request) {
         //仅对/login路径应用此过滤器
         return !request.getServletPath().equals("/login");
    }
 }

首先注入了AuthenticationManager,并将身份验证职责委托给它,重写了doFilterInternal()方法,当请求到达过滤器链中的此过滤器时会调用该方法,其中还重写了shouldNotFilter()方法。shouldNotFilter()方法是我们选择扩展OncePerRequestFilter类而不是直接实现Filter接口的原因之一。在重写这个方法时,我们定义了过滤器执行时的一个特定条件。在本示例中,我们希望仅执行/login路径上的任何请求,并跳过其他请求。

在第一个身份验证步骤中,客户端会发送用户名和密码来获得OTP。我们假设,如果用户没有发送OTP(一个验证码),则必须根据用户名和密码进行身份验证。接下来期望从HTTP请求头信息中心获取所有值,而如果没有发送验证码,则要通过创建UsernamePasswordAuthentication实例并且将验证职责转发给AuthenticationManager来调用第一个身份验证步骤。

接下来,AuthenticationManager会尝试寻找合适的AuthenticationProvider。在本例中,就是UsernamePasswordAuthenticationProvider。它将会被触发,因为其supports()方法声明它接受UsernamePasswordAuthentication类型。

但是如果在请求中发送了验证码,则会假设这是第二个身份验证步骤。本示例将创建一个OtpAuthentication对象来调用AuthenticationManager。从OtpAuthenticationProvider类的实现中我们知道,如果身份验证失败,将抛出一个异常。这意味着只有在OTP有效时,才会生成JWT令牌并将其添加到HTTP响应头信息中。

接下来还需要添加过滤器来处理除/login之外的所有路径上的请求。这个过滤器被命名为JwtAuthenticationFilter。该过滤器将期望请求的授权HTTP头信息中存在JWT。这个过滤器会通过检查签名来验证JWT、创建一个经过身份验证的Authentication独享,并将其添加到SecurityContext中。

3.7.2 JwtAuthenticationFilter类

 /**
  * 处理除/login之外的所有路径上的请求
  */
 @Component
 public class JwtAuthenticationFilter extends OncePerRequestFilter {
 
     @Value("${jwt.signing.key}")
     private String signingKey;
 
     @Override
     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
         String jwt = request.getHeader("Authorization");
 
         SecretKey key = Keys.hmacShaKeyFor(signingKey.getBytes(StandardCharsets.UTF_8));
         //解析令牌以获取声明并验证签名。如果签名无效,则抛出异常
         Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(jwt)
                .getBody();
 
         String username = String.valueOf(claims.get("username"));
 
         GrantedAuthority a = new SimpleGrantedAuthority("user");
         //创建添加到SecurityContext的Authentication实例。
         var auth = new UsernamePasswordAuthentication(username, null, List.of(a));
         //将Authentication对象添加到SecurityContext中
         SecurityContextHolder.getContext().setAuthentication(auth);
 
         //调用过滤器链中的下一个过滤器
         filterChain.doFilter(request, response);
    }
 
     @Override
     protected boolean shouldNotFilter(HttpServletRequest request) {
         //将此过滤器配置为在请求/login路径时不触发
         return request.getServletPath().equals("/login");
    }
 }

3.8 编写安全性配置

  • 将过滤器添加到过滤器链中

  • 禁用CSRF防护。这里将使用JWT代替使用CSRF令牌进行的验证。

  • 添加AuthenticationProvider对象,以便AuthenticationManager获知它们。

  • 使用匹配其方法配置所有需要进行身份验证的请求。

  • 在Spring 上下文中添加AuthenticationManger bean,以便可以从InitialAuthenticationFilter类注入它。

SecurityConfig类

 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
 
     @Autowired
     private InitialAuthenticationFilter initialAuthenticationFilter;
 
     @Autowired
     private JwtAuthenticationFilter jwtAuthenticationFilter;
 
     @Autowired
     private OtpAuthenticationProvider otpAuthenticationProvider;
 
     @Autowired
     private UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider;
 
     @Override
     protected void configure(AuthenticationManagerBuilder auth) {
         //将两个身份验证提供程序配置到身份验证管理器
         auth.authenticationProvider(otpAuthenticationProvider)
            .authenticationProvider(usernamePasswordAuthenticationProvider);
    }
 
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.csrf().disable();//禁用CSRF防护
 
         //将两个自定义过滤器添加到过滤器链中
         http.addFilterAt(
                 initialAuthenticationFilter,
                 BasicAuthenticationFilter.class)
            .addFilterAfter(
                 jwtAuthenticationFilter,
                 BasicAuthenticationFilter.class
            );
 
         //确保所有请求都经过身份验证
         http.authorizeRequests()
                .anyRequest().authenticated();
    }
 
     //将AuthenticationManager添加到Spring上下文,以便可以从过滤器类自动装配它
     @Override
     @Bean
     protected AuthenticationManager authenticationManager() throws Exception {
         return super.authenticationManager();
    }
 }

4、测试整个系统

我们在2.13添加了一个用户并检查了身份验证服务器是否正常工作。现在可以尝试第一步,这需要在2.13中添加的用户来访问业务逻辑服务器暴露的端点。身份验证服务器将打开端口8080,业务逻辑服务器则使用端口9090,这是之前在业务逻辑服务器的application.properties文件中配置的端口。调用如下:

启动身份验证服务器

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

启动业务逻辑服务器

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

调用/login端点。

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

调用/login端点并提供正确的用户名和密码后,需要在数据库中检查所生成的OTP值,这应该是otp表中的一条记录,其中用户名字段的值是danielle。

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

假设这个OTP是通过SMS短信发送的,并且用户接收到了。需要将它用于第二个身份验证步骤。

下面调用/login端点来执行第二个身份验证步骤。

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

从响应中可看出,JWT就在预期的位置:在授权响应头信息中。

接下来,使用获得的令牌调用/test端点。

【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

请求成功了,至此,我们成功编写了整个后端系统,并通过编写自定义身份验证和授权来保护其资源。我们还使用了JWT,这将为我们后面的OAuth2做准备。



原文始发于微信公众号(全栈开发那些事):【SpringSecurity】职责分离(身份验证服务器+业务逻辑服务器+JWT)

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/91714.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!