SpringSecurity实践:小型且安全的Web应用程序


1、项目需求和设置

1.1 项目需求

这里将实现一个小型Web应用程序,在该应用程序中,用户在成功进行身份验证之后,可以在主页上看到产品列表。

就这个项目而言,数据库将存储此项目的产品和用户。每个用户的密码会用bcrypt或scrypt进行哈希化。这里选择了两种哈希算法,以便给出一个理由来自定义示例中的身份验证逻辑。users表中的一个列会存储加密类型。还有第三个表会存储用户权限。

下图描述了此应用程序的身份验证流程。这里已经将要以不同方式进行自定义的组件设置了阴影。对于其他组件,将使用SpringSecurity提供的默认值。请求将遵循标准的身份验证流程。这里用箭头表示图中的请求,箭头上有一条连续的线。AuthenticationFilter会拦截请求,然后将身份验证责任委托给AuthenticationManager,后者会使用AuthenticationProvider对请求进行身份验证。它将返回成功通过身份验证的调用的详细信息,以便AuthenticationFilter可以将这些信息存储在SpringContext中。

SpringSecurity实践:小型且安全的Web应用程序

本示例中实现的是AuthenticationProvider以及与身份验证逻辑相关的所有内容。如上图所示,其中创建了AuthenticationProviderService类,它实现了AuthenticationProvider接口。这个接口实现了身份验证逻辑,其中需要调用UserDetailsService从数据库中查找用户详细信息,并通过PasswordEncoder验证密码是否正确。对于这个应用程序,我们创建了一个JpaUserDetailsService,它使用Spring Data JPA与数据库进行交互。由于这个原因,它依赖于Spring Data JpaRepository,这个示例中将其命名为UserRepository。

这里需要两个密码编码器,因为应用程序需要验证用bcrypt哈希化的密码和用scrypt哈希化的密码。作为一个简单的web应用程序,它需要一个标准的登陆表单来允许用户进行身份验证。为此,需要配置formLogin作为身份验证方法。


1.2 开发前的准备

项目实现的主要步骤:

  • 设置数据库

  • 定义用户管理

  • 实现身份验证逻辑

  • 实现主页面

  • 运行并测试应用程序


数据库包含3张表:user、authority和product。

建表脚本如下:

 CREATE TABLE IF NOT EXISTS `spring`.`user` (
   `id` INT NOT NULL AUTO_INCREMENT,
   `username` VARCHAR(45) NOT NULL,
   `password` TEXT NOT NULL,
   `algorithm` VARCHAR(45) NOT NULL,
   PRIMARY KEY (`id`));
 
 CREATE TABLE IF NOT EXISTS `spring`.`authority` (
   `id` INT NOT NULL AUTO_INCREMENT,
   `name` VARCHAR(45) NOT NULL,
   `user` INT NOT NULL,
   PRIMARY KEY (`id`));
 
 CREATE TABLE IF NOT EXISTS `spring`.`product` (
   `id` INT NOT NULL AUTO_INCREMENT,
   `name` VARCHAR(45) NOT NULL,
   `price` VARCHAR(45) NOT NULL,
   `currency` VARCHAR(45) NOT NULL,
   PRIMARY KEY (`id`));

建议在权限和用户之间建立多对多的关系。为了从持久层的角度使示例更简单,并将重点放在Spring Security的基本要素方面,这里决定使用一对多的关系。

测试数据脚本:

 INSERT IGNORE INTO `spring`.`user` (`id`, `username`, `password`, `algorithm`) VALUES ('1', 'john', '$2a$10$xn3LI/AjqicFYZFruSwve.681477XaVNaUQbr1gioaWPn4t1KsnmG', 'BCRYPT');
 
 INSERT IGNORE INTO `spring`.`authority` (`id`, `name`, `user`) VALUES ('1', 'READ', '1');
 INSERT IGNORE INTO `spring`.`authority` (`id`, `name`, `user`) VALUES ('2', 'WRITE', '1');
 
 INSERT IGNORE INTO `spring`.`product` (`id`, `name`, `price`, `currency`) VALUES ('1', 'Chocolate', '10', 'USD');
 

在这段代码中,对于用户John,使用了bcrypt对密码进行哈希。其原始密码是12345.

项目开发所需依赖项:

 <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-thymeleaf</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>

application.properties:

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

这里只是为了测试,在真实场景中,绝对不该在application.properties中写入像凭据或私钥这样的敏感数据。相反,应该使用一个私密资料库达到这个目的。

2、实现用户管理

2.1 实现步骤

  • (1)为两种哈希算法定义密码编码器对象

  • (2)定义JPA实体来表示存储身份验证过程中所需的详细信息的user表和authority表。

  • (3)为Spring Data声明JpaRepository接口。这里只需直接引用用户即可,因此声明了一个名为UserRepository的存储库。

  • (4)创建一个在User JPA实体上实现UserDetails接口的装饰器。主要是为了分离职责。

  • (5)实现UserDetailsService接口。为此,要创建一个名为JpaUserDetailsService的类。这个类使用步骤(3)中创建的UserReposiroty从数据库中获取关于用户的详细信息。如果JpaUserDetailsService找到了用户,它会将这些用户作为步骤(4)中所定义的装饰器的实现返回。

2.2 为每个PasswordEncoder注册一个bean

 @Configuration
 public class ProjectConfig {
 
     @Bean
     public BCryptPasswordEncoder bCryptPasswordEncoder() {
         return new BCryptPasswordEncoder();
    }
 
     @Bean
     public SCryptPasswordEncoder sCryptPasswordEncoder() {
         return new SCryptPasswordEncoder();
    }
 
   
 }

对于用户管理,需要声明一个UserDetailsService实现,该实现会通过用户名从数据库中检索用户。它需要返回用户作为UserDetails接口的实现,需要实现两个JPA实体来进行身份验证:User和Authority。

2.3 User实体类

 @Entity
 public class User {
 
     @Id
     @GeneratedValue(strategy = GenerationType.IDENTITY)
     private Integer id;
 
     private String username;
     private String password;
 
     @Enumerated(EnumType.STRING)
     private EncryptionAlgorithm algorithm;
 
     @OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
     private List<Authority> authorities;
 
     public Integer getId() {
         return id;
    }
 
     public void setId(Integer id) {
         this.id = id;
    }
 
     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 EncryptionAlgorithm getAlgorithm() {
         return algorithm;
    }
 
     public void setAlgorithm(EncryptionAlgorithm algorithm) {
         this.algorithm = algorithm;
    }
 
     public List<Authority> getAuthorities() {
         return authorities;
    }
 
     public void setAuthorities(List<Authority> authorities) {
         this.authorities = authorities;
    }
 }

EncryptionAlgorithm是一个枚举,它定义了在请求中指定的两种受支持的哈希算法。

 public enum EncryptionAlgorithm {
     BCRYPT, SCRYPT
 }

2.4 Authority实体

 @Entity
 public class Authority {
 
     @Id
     @GeneratedValue(strategy = GenerationType.IDENTITY)
     private Integer id;
 
     private String name;
 
     @JoinColumn(name = "user")
     @ManyToOne
     private User user;
 
     public Integer getId() {
         return id;
    }
 
     public void setId(Integer id) {
         this.id = id;
    }
 
     public String getName() {
         return name;
    }
 
     public void setName(String name) {
         this.name = name;
    }
 
     public User getUser() {
         return user;
    }
 
     public void setUser(User user) {
         this.user = user;
    }
 }

必须声明存储库,以便能够通过用户名从数据库中检索用户,代码如下:

2.5 User实体的Spring Data存储库定义

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

这里使用了Spring Data JPA存储库。然后Spring Data会实现接口中声明的方法,并根据其名称执行查询。该方法会返回一个Optional实例,该实例包含User实体,并且其名称会作为参数提供。如果数据库中不存在这样的用户,则该方法将返回一个空的Optional实例。

要从UserDetailsService返回用户,需要将其表示为UserDetails。在如下代码中,CustomUserDetails类实现了UserDetails接口并包装了User实体。

2.6 UserDetails接口的实现

 public class CustomUserDetails implements UserDetails {
 
     private final User user;
 
     public CustomUserDetails(User user) {
         this.user = user;
    }
 
     @Override
     public Collection<? extends GrantedAuthority> getAuthorities() {
         return user.getAuthorities().stream()
            //将在数据库中找到的该用户的每个权限名称映射到一个SimpleGrantedAuthority
                    .map(a -> new SimpleGrantedAuthority(a.getName()))
            //以列表形式手机并返回SimpleGrantedAuthority的所有实例
                    .collect(Collectors.toList());
    }
 
     @Override
     public String getPassword() {
         return user.getPassword();
    }
 
     @Override
     public String getUsername() {
         return user.getUsername();
    }
 
     @Override
     public boolean isAccountNonExpired() {
         return true;
    }
 
     @Override
     public boolean isAccountNonLocked() {
         return true;
    }
 
     @Override
     public boolean isCredentialsNonExpired() {
         return true;
    }
 
     @Override
     public boolean isEnabled() {
         return true;
    }
 
     public final User getUser() {
         return user;
    }
 }

SImpleGrantedAuthority是GrantedAuthority接口的简单实现。Spring Security提供了这种实现。

2.7 UserDetailsService接口的实现

 @Service
 public class JpaUserDetailsService implements UserDetailsService {
 
    @Autowired
    private UserRepository userRepository;
 
    @Override
    public CustomUserDetails loadUserByUsername(String username) {
    //声明一个Supplier来创建异常实例
        Supplier<UsernameNotFoundException> s =
                () -> new UsernameNotFoundException("Problem during authentication!");
 
        User u = userRepository
        //返回包含用户的Optional实例。如果用户不存在,则返回空的Optional实例。
        .findUserByUsername(username)
        //如果Optional实例为空,则抛出所定义的Suppier创建的异常;否则,它将返回User实例
        .orElseThrow(s);
 
  //使用CustomUserDetails装饰器包装User实例并返回它
        return new CustomUserDetails(u);
    }
 }

3、实现自定义身份验证逻辑

完成用户和密码管理之后,接下来可以开始编写自定义身份验证逻辑了。为此,必须实现一个AuthenticationProvider并将其注册到Spring Security身份验证架构中。编写身份验证逻辑所需的依赖项是UserDetailsService实现和两个密码编码器。除了自动装配这些依赖项,我们还重写了authenticate()和supports()方法。需要实现supports()方法将受支持的Authentication实现类型指定为UsernamePasswordAuthenticationToken。

3.1 实现AuthenticationProvider

 @Service
 public class AuthenticationProviderService implements AuthenticationProvider {
 
     @Autowired
     private JpaUserDetailsService userDetailsService;
 
     @Autowired
     private BCryptPasswordEncoder bCryptPasswordEncoder;
 
     @Autowired
     private SCryptPasswordEncoder sCryptPasswordEncoder;
 
     //重写authenticate()方法来定义身份验证逻辑
     @Override
     public Authentication authenticate(Authentication authentication) throws AuthenticationException {
         String username = authentication.getName();
         String password = authentication.getCredentials().toString();
  //使用UserDetailsService从数据库中查找用户详细信息
         CustomUserDetails user = userDetailsService.loadUserByUsername(username);
  //根据特定于用户的哈希算法来验证密码
         switch (user.getUser().getAlgorithm()) {
             case BCRYPT:
                 return checkPassword(user, password, bCryptPasswordEncoder);
             case SCRYPT:
                 return checkPassword(user, password, sCryptPasswordEncoder);
        }
 
         throw new  BadCredentialsException("Bad credentials");
    }
 
     @Override
     public boolean supports(Class<?> aClass) {
         return UsernamePasswordAuthenticationToken.class.isAssignableFrom(aClass);
    }
 
     private Authentication checkPassword(CustomUserDetails user, String rawPassword, PasswordEncoder encoder) {
         if (encoder.matches(rawPassword, user.getPassword())) {
             return new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), user.getAuthorities());
        } else {
             throw new BadCredentialsException("Bad credentials");
        }
    }
 }

authenticate()方法首先会根据用户的用户名加载用户,然后会验证密码是否与数据库中存储的哈希值相匹配。验证过程摇依赖于哈希化用户密码的算法。

3.2 在配置类中注册AuthenticationProvider

 @Configuration
 public class ProjectConfig extends WebSecurityConfigurerAdapter {
 
     //从上下文中获取AuthenticationProviderService的实例。
     @Autowired
     private AuthenticationProviderService authenticationProvider;
 
     @Bean
     public BCryptPasswordEncoder bCryptPasswordEncoder() {
         return new BCryptPasswordEncoder();
    }
 
     @Bean
     public SCryptPasswordEncoder sCryptPasswordEncoder() {
         return new SCryptPasswordEncoder();
    }
 
     @Override
     protected void configure(AuthenticationManagerBuilder auth) {
         //通过重写configure()方法,为Spring Security注册身份验证提供程序
         auth.authenticationProvider(authenticationProvider);
    }
 
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.formLogin()
            .defaultSuccessUrl("/main", true);
         http.authorizeRequests().anyRequest().authenticated();
    }
 }

3.3 将formLogin配置为身份验证方法

  @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.formLogin()
            .defaultSuccessUrl("/main", true);
         http.authorizeRequests().anyRequest().authenticated();
    }

4、实现主页面

最后,既然安全部分已经就绪,就可以实现应用程序的主页了。它是一个简单的页面,其中会显示product表的所有记录。此页面仅在用户登录后才可访问。为了从数据库获取产品记录,必须向项目中添加一个Product实体类和一个ProductRepository接口

4.1 定义Product JPA实体

 @Entity
 public class Product {
 
     @Id
     @GeneratedValue(strategy = GenerationType.IDENTITY)
     private Integer id;
 
     private String name;
     private double price;
 
     @Enumerated(EnumType.STRING)
     private Currency currency;
 
     public Integer getId() {
         return id;
    }
 
     public void setId(Integer id) {
         this.id = id;
    }
 
     public String getName() {
         return name;
    }
 
     public void setName(String name) {
         this.name = name;
    }
 
     public double getPrice() {
         return price;
    }
 
     public void setPrice(double price) {
         this.price = price;
    }
 
     public Currency getCurrency() {
         return currency;
    }
 
     public void setCurrency(Currency currency) {
         this.currency = currency;
    }
 }

Currency枚举声明了应用程序中允许作为货币的类型。

 public enum Currency {
     USD, GBP, EUR
 }

4.2 ProductRepository接口的定义

 public interface ProductRepository extends JpaRepository<Product, Integer> {
 }

4.3 ProductService类的实现

 @Service
 public class ProductService {
 
     @Autowired
     private ProductRepository productRepository;
 
     //从数据库中检索所有产品
     public List<Product> findAll() {
         return productRepository.findAll();
    }
 }

4.4 控制器类的定义

 @Controller
 public class MainPageController {
 
     @Autowired
     private ProductService productService;
 
     @GetMapping("/main")
     public String main(Authentication a, Model model) {
         model.addAttribute("username", a.getName());
         model.addAttribute("products", productService.findAll());
         return "main.html";
    }
 }

main.html页面存储在resources/templates文件夹中,并且会显示产品和登录用户的名称

4.5 主页面的定义

 <!DOCTYPE html>
 <html lang="en" xmlns:th="http://www.thymeleaf.org">
     <head>
         <meta charset="UTF-8">
         <title>Products</title>
     </head>
     <body>
         <h2 th:text="'Hello, ' + ${username} + '!'" />
         <p><a href="/logout">Sign out here</a></p>
 
         <h2>These are all the products:</h2>
         <table>
             <thead>
             <tr>
                 <th> Name </th>
                 <th> Price </th>
             </tr>
             </thead>
             <tbody>
             <tr th:if="${products.empty}">
                 <td colspan="2"> No Products Available </td>
             </tr>
             <tr th:each="book : ${products}">
                 <td><span th:text="${book.name}"> Name </span></td>
                 <td><span th:text="${book.price}"> Price </span></td>
             </tr>
             </tbody>
         </table>
     </body>
 </html>

5、运行和测试应用程序

浏览器访问http://localhost:8080访问。

标准的登陆表单如下图所示,存储在数据库中的用户是john,其密码是12345,该密码使用了bcrypt进行哈希化处理。可以使用这些凭据进行登录。

SpringSecurity实践:小型且安全的Web应用程序

真实场景中,绝不允许用户定义如此简单的密码,存在安全风险。

登录后,应用程序会将访问者重定向到主页。在这里,从安全上下文获取的用户名将出现在页面上,同时显示来自数据库的产品列表。

SpringSecurity实践:小型且安全的Web应用程序

当单击Sign out here链接时,应用程序会重定向到标准的注销确认页面,这是Sping Security预先定义好的,因为我们使用的是formLogin身份验证方法。

SpringSecurity实践:小型且安全的Web应用程序

单击Log Out后,访问者将被重定向回登录页面。

至此,一个小型且安全的Web应用程序就搭建好了,这个太简单了,真实场景中都是前后端分离的项目,关于SpringSecurity如何在前后端分离的项目中使用我会单独发一篇博客。


原文始发于微信公众号(全栈开发那些事):SpringSecurity实践:小型且安全的Web应用程序

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

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

(0)
小半的头像小半

相关推荐

发表回复

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