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

举报
别团等shy哥发育 发表于 2023/01/09 18:52:38 2023/01/09
【摘要】 @toc 1、项目需求和设置 1.1 项目需求  这里将实现一个小型Web应用程序,在该应用程序中,用户在成功进行身份验证之后,可以在主页上看到产品列表。  就这个项目而言,数据库将存储此项目的产品和用户。每个用户的密码会用bcrypt或scrypt进行哈希化。这里选择了两种哈希算法,以便给出一个理由来自定义示例中的身份验证逻辑。users表中的一个列会存储加密类型。还有第三个表会存储用户权...

@toc

1、项目需求和设置

1.1 项目需求

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

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

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

image-20220218185517798

  本示例中实现的是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进行哈希化处理。可以使用这些凭据进行登录。

image-20220218194930651

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

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

image-20220218195117530

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

image-20220218195241121

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

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

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。