《Spring实战》(第6版) 保护Spring

发布于:2025-02-24 ⋅ 阅读:(15) ⋅ 点赞:(0)

第1部分 Spring基础

第4章 使用非关系型数据

关系型数据库一直是首选,近年来"NoSQL"数据库提供了数据存储的不同概念和结构。

SpringData为很多NoSQL数据库提供了支持,包括MongoDB、Cassandra、Couchbase、Neo4j、Redis等,无论选择哪种,编程模型几乎是相同的。

看两个最流行的NoSQL数据库:Cassandra和MongoDB。

4.1 使用Cassandra存储库

Cassandra是一个分布式、高性能、始终可用、最终一致、列分区存储的NoSQL数据库。

Cassandra处理的是要写入表中的数据行,这些数据会被分区到一对多的分布式节点上。

没有任何一个节点会持有所有的数据,任何给定的数据行都会跨多个节点保存副本,从而消除单点故障。

Spring Data Cassandra为Cassandra数据库提供了自动化存储库的支持,与Spring Data JPA对关系型数据库的支持非常类似,还提供了用于将应用的领域模型映射为后端数据库结构的注解。

要完整了解它的特定,建议去阅读Cassandra的官方文档。

4.1.1 启动Spring Data Cassandra

其他略,用到时再说。

4.2 编写MongoDB存储库

自己查吧,用Docker容器来启动。

第5章 保护Spring

5.1 启用Spring Security

自动或手动添加Spring Boot security starter依赖到pom.xml文件中。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

启动,访问主页,http://localhost:8080/login

用户名:user,密码在启动时的控制台:1a6d18d5-a6b9-4aa1-bcbe-08fef7cd5c17

登录进去。

5.2 配置Spring Security

Spring Security有多种配置方式,冗长的基于XML配置,现在都支持基于Java的配置,更加容易编写和阅读。

Spring Security的基础配置类
package tacos.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig {

	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
}

基础的安全配置主要工作声明 PasswordEncoder bean,创建新用户和登录时对用户认证都会用到它,本例使用BCryptPasswordEncoder。

Spring Security提供的密码转换器:
  • BCryptPasswordEncoder:使用bcrypt强哈希加密。
  • NoOpPasswordEncoder:不使用任何转码,没有任何加密技术,不适合生产环境使用。
  • Pbkdf2PasswordEncoder:使用PBKDF2加密。
  • SCryptPasswordEncoder:使用Scrypt哈希加密。
  • StandardPasswordEncoder:使用SHA-256哈希加密,被认为加密不够安全,已被废弃。

无论哪种密码转换器,数据库中的密码永远不会被解码,用户登录时加密后与数据库对比。

是PasswordEncoder的match()方法中进行的。

Spring Security提供了多个内置的UserDetailsService实现,包括:

  • 内存用户存储
  • JDBC用户存储
  • LDAP用户存储
5.2.1 基于内存的用户详情服务

用户信息可以存在内存之中,假设我们有有限的用户,而且这几个用户几乎不会发生变化,这种情况可以将用户定义成安全配置的一部分是非常简单的。

在内存用户详情服务bean中声明用户
package tacos.security;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class SecurityConfig {

	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	@Bean
	public UserDetailsService userDetailsService(PasswordEncoder encoder) {
		List<UserDetails> usersList = new ArrayList<>();
		usersList.add(new User("buzz",encoder.encode("buzz"),
				Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"))));
		usersList.add(new User("woody",encoder.encode("woody"),
				Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"))));
		return new InMemoryUserDetailsManager(usersList);
	}
	
}

创建用户对象,都包含用户名,密码和权限列表。

用户名/密码:buzz/buzz woody/woody。可以登录。

UserDetails,User都是Spring Security提供的。

5.2.2 自定义用户认证

如需新增、移除或变更用户,我们用关系型数据库进行存储,对用户进行持久化处理。

定义用户实体
package tacos;

import java.util.Arrays;
import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class User implements UserDetails {
	
	private static final long serialVersionUID = 1L;
	
	@Id
	@GeneratedValue(strategy = GenerationType.AUTO)
	private Long id;
	
	private final String username;
	private final String password;
	private final String fullname;
	private final String street;
	private final String city;
	private final String state;
	private final String zip;
	private final String phoneNumber;

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));
	}

	public User(String username, String password, String fullname, String street, String city, String state,
			String zip, String phoneNumber) {
		super();
		this.username = username;
		this.password = password;
		this.fullname = fullname;
		this.street = street;
		this.city = city;
		this.state = state;
		this.zip = zip;
		this.phoneNumber = phoneNumber;
	}

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public String getFullname() {
		return fullname;
	}

	public String getStreet() {
		return street;
	}

	public String getCity() {
		return city;
	}

	public String getState() {
		return state;
	}

	public String getZip() {
		return zip;
	}

	public String getPhoneNumber() {
		return phoneNumber;
	}

	public String getUsername() {
		return username;
	}

	public String getPassword() {
		return password;
	}

}

getAuthorities()方法应该返回用户被授予权限的一个集合,各种以is开头的方法返回布尔值,表明用户账号的可用、锁定、过期状态。(这些方法在UserDetails接口中,都返回true)

定义用户存储库接口
package tacos.data;

import org.springframework.data.repository.CrudRepository;

import tacos.User;


public interface UserRepository extends CrudRepository<User, Long>{

	User findByUsername(String username);
	
}

除拓展所有CRUD操作外,还定义findByUsername()方法,在用户详情中会用到,以便根据用户名查找User。

SecurityConfig声明自定义用户详情服务Bean
@Bean
public UserDetailsService userDetailsService(UserRepository userRepo) {
    return username -> {
        User user = userRepo.findByUsername(username);
        if(user != null) { return user; }
        throw new UsernameNotFoundException("用户名:"+username+"找不到!");
    };
}

UserDetailsService接口中只有一个方法loadUserByUsername(),则视为函数式接口,则不必实现类,而直接用lambda表达式来简化,需要传入一个username参数。还有就是此方法不能返回null,若返回则抛出异常。

注册用户

用户注册流程需要借助SpringMVC来完成。

用户登录表单类
package tacos.security;

import org.springframework.security.crypto.password.PasswordEncoder;

import tacos.User;

public class RegistrationForm {

	private String username;
	private String password;
	private String fullname;
	private String street;
	private String city;
	private String state;
	private String zip;
	private String phone;
	
	public User toUser(PasswordEncoder passwordEncoder) {
		return new User(username, passwordEncoder.encode(password), 
			fullname, street, city, state, zip, phone);
	}
	
	public RegistrationForm(String username, String password, String fullname, String street, String city, String state,
			String zip, String phone) {
		super();
		this.username = username;
		this.password = password;
		this.fullname = fullname;
		this.street = street;
		this.city = city;
		this.state = state;
		this.zip = zip;
		this.phone = phone;
	}

	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 getFullname() {
		return fullname;
	}

	public void setFullname(String fullname) {
		this.fullname = fullname;
	}

	public String getStreet() {
		return street;
	}

	public void setStreet(String street) {
		this.street = street;
	}

	public String getCity() {
		return city;
	}

	public void setCity(String city) {
		this.city = city;
	}

	public String getState() {
		return state;
	}

	public void setState(String state) {
		this.state = state;
	}

	public String getZip() {
		return zip;
	}

	public void setZip(String zip) {
		this.zip = zip;
	}

	public String getPhone() {
		return phone;
	}

	public void setPhone(String phone) {
		this.phone = phone;
	}
	
	
	
}

用户注册的控制器RegistrationController
package tacos.security;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import tacos.data.UserRepository;

@Controller
@RequestMapping("/register")
public class RegistrationController {

	private UserRepository userRepo;

	private PasswordEncoder passwordEncoder;

	public RegistrationController(UserRepository userRepo, PasswordEncoder passwordEncoder) {
		super();
		this.userRepo = userRepo;
		this.passwordEncoder = passwordEncoder;
	}

	@GetMapping
	public String registerForm() {
		return "registration";
	}

	public String processRegistration(RegistrationForm form) {
		userRepo.save(form.toUser(passwordEncoder));
		return "redirct:/login";
	}
}

注册表单视图的Thymeleaf模版registration.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Taco Cloud</title>
</head>
<body>
	<h1>Register</h1>
	<img alt="" th:src="@{images/TacoCloud.png}">
	<form method="post" th:action="@{/register}" id="registerForm">
	
		<label for="username">Username:</label>
		<input type="text" name="username"><br>
		
		<label for="password">Password:</label>
		<input type="password" name="password"><br>
		
		<label for="confirm">Confirm password:</label>
		<input type="password" name="confirm"><br>
		
		<label for="fullname">Full name:</label>
		<input type="text" name="fullname"><br>
		
		<label for="street">Street:</label>
		<input type="text" name="street"><br>
		
		<label for="city">City:</label>
		<input type="text" name="city"><br>
		
		<label for="state">State:</label>
		<input type="text" name="state"><br>
		
		<label for="zip">Zip:</label>
		<input type="text" name="zip"><br>
		
		<label for="phone">Phone:</label>
		<input type="text" name="phone"><br>
		
		<input type="submit" value="Register">
	</form>
</body>
</html>

应用已经有了完整用户注册和认证功能,启动还是无法进入注册页面,因为所有请求都需要认证。我们看一下Web请求如何被拦截和保护的。

5.3 保护Web请求

应用的安全需求是:用户在设计taco和提交订单之前,必须要经过认证,但主页、登录页和注册页应该对未认证的用户开发。

HttpSecurity可以配置很多功能,其中包括:

  • 要求在某个请求提供服务之前,满足特定的安全条件。
  • 配置自定义的登录页。
  • 使用户能够退出应用。
  • 预防跨站请求伪造。

配置HttpSecurity最常见的需求就是拦截请求以确保用户具备适当的权限。

5.3.1 保护请求

确保只有认证过的用户才能发起对"/design"和"/orders"的请求,而其他请求对所有用户均可用,如下配置就能实现这一点:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http.cors(cors -> cors.configure(http))
               .authorizeHttpRequests(authorize -> authorize
                   .requestMatchers("/design","/orders").hasRole("USER")
                   .requestMatchers("/","/**").permitAll())
               .formLogin(form -> form
                   .loginPage("/login"))
               .logout(logout -> logout
                    .logoutUrl("/logout")
                    .permitAll())
               .build();
}

具备ROLE_USER权限的用户才能访问"/design"和"/orders"。

其他的所有请求允许所有用户访问。

规则顺序很重要,前面的比后面的优先级高。

我用的是spring-security-config-6.4.2,规则和版本5的不同,具体API自己查吧。

可以用SpEL表达式来声明更丰富的安全规则,了解即可。

5.3.2 创建自定义的登录页

formLogin()方法告诉自定义登录页的路径,若没有经过认证并且需要登录,就会将用户重定向到该路径。

登录页很简单,只有一个视图没有其他东西,可以不写Controller,只要在WebConfig中将其声明为一个视图控制器,增加登录页面的视图控制器。

package tacos.web;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

	@Override
	public void addViewControllers(ViewControllerRegistry registry) {
		registry.addViewController("/").setViewName("home");
		registry.addViewController("/login");
	}
	
}

新建login.html登录页的视图。

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Taco Cloud</title>
</head>
<body>
	<h1>Login</h1>
	<img th:src="@{/images/TacoCloud.png}">
	<div th:if=${error}>
		无法登录,请检查你的用户名和密码是否正确!
	</div>
	<p>
		还未注册,点击 <a th:href="@{/register}">这里</a>去注册!
	</p>
	<form action="post" th:action="@{/login}" id="loginForm">
		<label for="username">用户名:</label>
		<input type="text" name="username" id="username"><br>
		
		<label for="password">密码:</label>
		<input type="password" name="password" id=""password""><br>
		
		<input type="submit" value="登录">
	</form>
</body>
</html>

默认情况下,会监听"/login"路径登录请求,用户名密码分别为username和password,这也是可以配置的。

.and()
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/authenticate")
.usernameParameter("user")
.passwordParameter("pwd")

修改完就是,路径为"/authenticate",用户名密码为,user,pwd。

一般登录后导航到正在浏览的页面,如果直接访问登录页,导航至根路径,也就是主页,也可以修改默认的成功页,例如导航到"/design"。强制要求登录到这里的话,后面加true参数。

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http.cors(cors -> cors.configure(http))
               .authorizeHttpRequests(authorize -> authorize
                   .requestMatchers("/design","/orders").hasRole("USER")
                   .requestMatchers("/","/**").permitAll())
               .formLogin(form -> form
                   .loginPage("/login")
                   .defaultSuccessUrl("/design",true))
               .logout(logout -> logout
                    .logoutUrl("/logout")
                    .permitAll())
               .build();
}

上面的代码要和项目中的代码整体整理一下!!!

5.3.3 启用第三方认证

你可能在自己喜欢的Web站点上见过"使用微信登录",“使用QQ登录”,"使用支付宝登录"类似的内容。这种方式能让用户避免在Web站点特定的登录页上自己输入凭证信息。

这种认证基于OAuth2或OpenID Connect(OIDC)。

OAuth2是一个授权规范,用来通过第三方网站实现认证功能,OpenID Connect是另一个基于OAuth2的安全规范,用户规范化第三方认证过程中发生的交互。

自动或手动添加OAuth2客户端的starter依赖。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

配置文件中设置通用属性

# 配置 OAuth2 客户端相关信息
spring.security.oauth2.client.registration.wechat.client-id=your_appid
spring.security.oauth2.client.registration.wechat.client-secret=your_appsecret
spring.security.oauth2.client.registration.wechat.client-name=微信登录
spring.security.oauth2.client.registration.wechat.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.wechat.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.wechat.scope=snsapi_userinfo

# 配置 OAuth2 提供者相关信息
spring.security.oauth2.client.provider.wechat.authorization-uri=https://open.weixin.qq.com/connect/qrconnect
spring.security.oauth2.client.provider.wechat.token-uri=https://api.weixin.qq.com/sns/oauth2/access_token
spring.security.oauth2.client.provider.wechat.user-info-uri=https://api.weixin.qq.com/sns/userinfo
spring.security.oauth2.client.provider.wechat.user-name-attribute=unionid

客户端ID和secret是用来标识我们的应用在微信中的凭证。

可以在微信的开发者网站新建应用来获取客户端ID和secret。

scope属性可以用来指定应用的权限范围,案例中获取用户的基本信息。

用户尝试访问需要认证的页面时,他们的浏览器会被重定向到微信,若还没有登录微信,将会看到微信登录页面,他们会被要求根据请求的权限范围对我们的应用程序授权,最后,用户被重定向到我们的应用程序,此时,他们完成了认证。

通过SecurityFilterChain来定义安全配置,还需要启用OAuth2登录。

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http.cors(cors -> cors.configure(http))
    .authorizeHttpRequests(authorize -> authorize
                           .requestMatchers("/design","/orders").hasRole("USER")
                           .requestMatchers("/","/**").permitAll())
    .formLogin(form -> form
               .loginPage("/login")
               .defaultSuccessUrl("/design",true))
    .logout(logout -> logout
            .logoutUrl("/logout"))
    //.oauth2Login(t -> t.and())
    .build();
}

也可以配置传统的通过用户名密码登录,可以在配置中指定登录页,或提供一个微信登录页面链接a,退出也同样重要,在HttpSecurity对象上调用logout方法。

整体略。

5.3.4 防止跨站请求伪造

跨站请求伪造(Cross-Site Request Forgery,CSRF)是一种常见的安全攻击。

它会让用户在一个恶意的Web页面上填写信息,然后自动的将表单以攻击受害者的身份提交到另外一个应用上。

为了防止此类攻击,展现表单的时候生成一个CSRF令牌(token),放到隐藏域中临时存储起来,以便后续服务器上使用。

Spring Security提供了内置的CSRF保护,默认启用,唯一要做的就是每个表单有一个名为"_csrf"的字段,它会持有CSRF令牌。

模版中会:

5.4 实现方法级别的安全

比如删除所有订单操作,只有管理员才有权限删除。

可以在配置类中添加对应权限,但是其他控制类也需要这样的操作,为了方便,启用方法上的安全防护。

@PreAuthorize(“hasRole(‘ADMIN’)”)

安全配置类上添加@EnableGlobalMethodSecurity使上面注解生效。

假设根据ID获取订单方法,想限制这个方法,可以用@PostAuthorize注解。

5.5 了解用户是谁

@AuthenticationPrincipal

想详细了解去看下面的书:

  • 入门优先:从《Spring Security实战》或《Spring Boot Up and Running》开始,快速上手。
  • 深入协议:先读《OAuth 2 in Action》,再结合《Pro Spring Security》实践。
  • 全面掌握:《Spring Security in Action》是当前最系统的选择,覆盖现代安全场景(如微服务、响应式)