1、项目需求和设置
1.1 项目需求
这里将实现一个小型Web应用程序,在该应用程序中,用户在成功进行身份验证之后,可以在主页上看到产品列表。
就这个项目而言,数据库将存储此项目的产品和用户。每个用户的密码会用bcrypt或scrypt进行哈希化。这里选择了两种哈希算法,以便给出一个理由来自定义示例中的身份验证逻辑。users表中的一个列会存储加密类型。还有第三个表会存储用户权限。
下图描述了此应用程序的身份验证流程。这里已经将要以不同方式进行自定义的组件设置了阴影。对于其他组件,将使用SpringSecurity提供的默认值。请求将遵循标准的身份验证流程。这里用箭头表示图中的请求,箭头上有一条连续的线。AuthenticationFilter会拦截请求,然后将身份验证责任委托给AuthenticationManager,后者会使用AuthenticationProvider对请求进行身份验证。它将返回成功通过身份验证的调用的详细信息,以便AuthenticationFilter可以将这些信息存储在SpringContext中。
本示例中实现的是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进行哈希化处理。可以使用这些凭据进行登录。
真实场景中,绝不允许用户定义如此简单的密码,存在安全风险。
登录后,应用程序会将访问者重定向到主页。在这里,从安全上下文获取的用户名将出现在页面上,同时显示来自数据库的产品列表。
当单击Sign out here链接时,应用程序会重定向到标准的注销确认页面,这是Sping Security预先定义好的,因为我们使用的是formLogin身份验证方法。
单击Log Out后,访问者将被重定向回登录页面。
至此,一个小型且安全的Web应用程序就搭建好了,这个太简单了,真实场景中都是前后端分离的项目,关于SpringSecurity如何在前后端分离的项目中使用我会单独发一篇博客。
原文始发于微信公众号(全栈开发那些事):SpringSecurity实践:小型且安全的Web应用程序
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/91781.html