Spring Boot安全管理
实际开发中,一些应用通常要考虑到安全性问题。例如,对于一些重要的操作,有些请求需要用户验明身份后才可以执行,还有一些请求需要用户具有特定权限才可以执行。这样做的意义,不仅可以用来保护项目安全,还可以控制项目访问效果。
Spring Security介绍
针对项目的安全管理,Spring家族提供了安全框架Spring Security,它是一个基于Spring生态圈的,用于提供安全访问控制解决方案的框架。为了方便Spring Boot项目的安全管理,Spring Boot对Spring Security安全框架进行了整合支持,并提供了通用的自动化配置,从而实现了Spring Security安全框架进行了整合支持,并提供了通用的自动化配置,从而实现了Spring Security安全框架中包含的多数安全管理功能
1)MVC Security是Spring Boot整合Spring MVC搭建Web应用的安全管理框架,也是开发中使用最多的一款安全功能。
2)WebFlux Security是Spring Boot整合Spring WebFlux搭建Web应用的安全管理。虽然Spring WebFlux框架刚出现不久、文档不够健全,但是它继承了其他安全功能的优点,后续有可能在Web开发中越来越流行。
3)OAuth2是大型项目的安全管理框架,可以实现第三方认证、单点登录等功能,但是目前Spring Boot版本还不支持OAuth2安全管理框架。
4)Actuator Security用于对项目的一些运行环境提供安全监控,例如Health健康信息、Info运行信息等,它主要作为系统指标供运维人员查看管理系统的运行情况
Spring Security快速入门
Spring Security的安全管理有两个重要概念,分别是Authentication(认证)和Authorization(授权)。其中,认证即确认用户是否登录,并对用户登录进行管控;授权即确定用户所拥有的功能权限,并对用户权限进行管控。
基础环境搭建
为了更换地使用Spring Boot整合实现MVC Security安全管理功能,实现Authentication(认证)和Authorization(授权)的功能,后续我们将会结合一个访问电影列表和详情的案例进行演示说明,这里先对案例的基础环境进行搭建。
1)创建Spring Boot项目。使用Spring Initializr方式创建一个名为chapter07的Spring Boot项目,在Dependencies依赖选择中选择Web模块中Web依赖以及Template Engines模块中的Thymeleaf依赖,然后根据提示完成羡慕创建。
2)引入页面html资源文件。在项目的resources下templates目录中,引入案例所需的资源文件。
index.html是项目首页面,common和vip文件夹中分别是普通用户和vip用户可访问的页面。
index.html
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta http-equiv="Content-Type" content="text/html" charset="UTF-8">
<title>影视直播厅</title>
</head>
<body>
<h1 align="center">欢迎进入电影网站首页</h1>
<hr/>
<h3>普通电影</h3>
<ul>
<li><a th:href="@{/detail/common/1}">飞驰人生</a></li>
<li><a th:href="@{/detail/common/2}">夏洛特烦恼</a></li>
</ul>
<ul>
<li><a th:href="@{/detail/vip/1}">速度与激情</a></li>
<li><a th:href="@{/detail/vip/2}">星球崛起</a></li>
</ul>
</body>
</html>
index.html首页面中通过标签分类展示了一些普通电影和VIP电影,并且这些电影都通过<a>
标签连接到了具体的影片详细路径。
在templates文件夹下,common和vip文件夹中引入的HTML文件就是对应电影的简介信息。
common/1.html
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>影片详情</title>
</head>
<body>
<a th:href="@{/}">返回</a>
<h1>飞驰人生</h1>
</body>
</html>
3)编写Web控制层。在chapter07项目中创建名为com.example.chapter07.controller的包,并在该包下创建一个用于页面请求处理的控制类
FileController.java
package com.example.chapter07.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@Controller
public class FileController {
@GetMapping("/detail/{type}/{path}")
public String toDetail(@PathVariable("type")String type,
@PathVariable("path")String path){
return "detail/"+type+"/"+path;
}
}
开启安全管理效果测试
在Spring Boot项目中开启Spring Security的方式非常简单,只需要引入spring-boot-statrter-security启动器即可。
添加spring-boot-starter-security启动器
在项目的pom.xml中引入Spring Security安全框架的依赖启动器spring-boot-starter-security
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
上述引入的依赖spring-boot-starter-security就是Spring Boot整合Spring Security安全框架而提供的依赖启动器,其版本号由Spring Boot进行统一管理。当在当前Spring Boot版本下,对应的Spring Security框架版本号为5.1.4。
需要说明的是,一旦项目引入spring-boot-start-security启动器,MVC Security和WebFlux Security负责的安全功能都会立即生效;对应OAuth2安全管理功能来说,则还需要额外引入一些其他安全依赖。
项目启动测试
项目启动时会在控制台自动生成一个安全密码(这个密码在每次启动项目时都是随机生成的)。
通过浏览器查看项目首页,
自动跳转到了一个新的登录链接页面"http:/localhost:8080/login",这说明在项目中添加spring-boot-starter-security依赖启动器后,项目实现了Spring Security的自动化配置,并且具有了一些默认的安全管理功能。另外,项目中没有手动创建用户登录页面,而添加了Security依赖后,SPring Boot会自带一个默认的登录页面。
当在Spring Security提供的默认登录页面"/login"中输入错误登录信息后,会重定向到"/login?error"页面并显示出错误信息。
需要说明的是,在Spring Boot项目中加入安全依赖启动器后,Security会默认提供一个可登录的用户信息,其中用户名为user,密码随机生成,这个密码会随着项目的每次启动随机生成并打印在控制台上。
可以发现这种默认安全管理方式存在诸多问题,例如,只有唯一的默认登录用户user、密码随机生成且过于暴露、登录页面及错误提示页面不是我们想要的等。
MVC Security安全配置介绍
使用Spring Boot与Spring MVC进行Web开发时,如果项目引入安全依赖启动器,MVC Security安全管理功能就会自动生效,其默认的安全配置是在SecurityAutoConfigration和UserDetailsServiceAutoConfiguration中实现的。其中,Security AutoConfiguration会导入并自动化配置SpringBootWebSecurityConfiguration用于启动Web安全安全管理,UserDetailsServiceAutoConfigration则用于配置用户身份信息。
通过自定义WebSecurityConfigurerAdapter类型的Bean组件,可以完全关闭Security提供的Web应用默认安全配置,但是不会关闭UserDetailsService用户信息自动配置类。如果要关闭UserDetailsService默认用户信息配置,可以之定义UserDetailsService、AuthenticationProvider或AuthenticationManager类型的Bean组件。另外,可以通过自定义WebSecurityConfigurerAdapter类型的Bean组件覆盖默认访问规则。Spring Boot提供了非常多方便的方法,可用于覆盖请求映射和静态资源的访问规则。
WebSecurityConfigurerAdapter类的主要方法与说明
方法 | 描述 |
---|---|
configure(AuthenticationManagerBuilder auth) | 定制用户认证管理器来实现用户认证 |
configure(HttpSecurity http) | 定制基于Http请求的用户访问控制 |
自定义用户认证
通过自定义WebSecurityConfigurerAdapter类型的Bean组件,并重写configure(AuthenticationManagerBuilder auth)方法,可以自定义用户认证。针对自定义用户认证,Spring Security提供了多种自定义认证方法,包括有:In-Memory Authentication(内存身份认证)、Authentication Provider(身份认证提供商)和UserDetailsService(身份详情服务)。
内存身份认证
In-Memory Authentication(内存身份认证)是最简单的身份认证方式,主要用于Security安全认证体验和测试。自定义内存身份认证时,只需要在重写的configure(Authentication ManagerBuilder auth)方法中定义测试用户即可。下面通过Spring Boot整合Spring Security实现内存身份认证。
自定义WebSecurityConfigurerAdapter配置类
在chapter07项目中创建名为com.example.chapter07.config的包,并在该包下创建一个配置类SecurityConfig
SecurityConfig.java
package com.example.chapter07.config;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}
自定义了一个继承自WebSecurityConfigurerAdapter的SecurityConfig配置类,用于进行MVC Security自定义配置,该类上方的@EnableWebSecurity注解是一个组合注解,其效果等同于@Configuration、@Import、@EnableGlobalAuthentication的组合用法,关于这些注解的介绍具体如下:
1)@Configuration注解的作用是将当前自定义的SecurityConfig类作为Spring Boot的配置类。
2)@Import注解的作用是根据pom.xml中导入的Web模块和Security模块进行自动化配置
3)@EnableGlobalAuthentication注解则用于开启自定义的全局认证。
需要说明的是,如果是针对Spring WebFlux框架的安全支持,需要在项目中导入Reactive Web模块和Security模块,并使用@EnableWebFluxSecurity注解开启基于WebFlux Security的安全支持。
使用内存进行身份认证
在自定义的SecurityConfig类中重写configure(AuthenticationManagerBuilder auth)方法,并在该方法中使用内存身份认证的方式进行自定义用户认证
SecurityConfig.java
package com.example.chapter07.config;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
auth.inMemoryAuthentication().passwordEncoder(encoder)
.withUser("shitou").password(encoder.encode("123456")).roles("common")
.and()
.withUser("李四").password(encoder.encode("123456")).roles("vip");
}
}
重写了WebSecurityConfigurerAdapter类的configure(AuthenticationManagerBuilder auth)方法,并在该方法中使用内存身份认证的方式自定义了认证用户信息。定义用户认证信息时,设置了两个用户,包括用户名、密码和角色。
1)从Spring Security 5开始,自定义用户认证必须设置密码编码器用于保护密码,否则控制台会出现异常错误。
2)Spring Security提供了多种密码编码器,包括BcryptPasswordEncoder、Pbkdf2PasswordEncoder、ScryptPasswordEncoder等,密码设置不限于本例中的BcryptPasswordEncoder密码编码器。
3)自定义用户认证时,可以定义用户角色roles,也可以定义用户权限authorities。在进行赋值时,权限通常是在角色值得基础上添加"ROLE_"前缀。例如,authorities(“ROLE_common”)和roles(“common”)是等效的。
4)自定义用户认证时,可以为某个用户一次指定多个角色或权限,例如roles(“common”,“vip”)或者authorities(“ROLE_common”,“ROLE_vip”)。
效果测试
重启chapter07项目进行效果测试,项目启动成功后,仔细查看控制台打印信息,发现没有默认安全管理时随机生成的密码了。通过浏览器访问"http://localhost:8080"查看首页
实际开发中,用户都是在页面注册和登录时进行认证管理的,而非在程序内部使用内存管理的方式手动控制注册用户,所以上述使用内存身份认证的方式无法用于实际生产,只可以作为初学者的测试使用。
JDBC身份认证
JDBC Authentication(JDBC 身份认证)是通过JDBC连接数据库对已有用户身份进行认证。
数据准备
JDBC身份认证的本质是使用数据库中已有的用户信息在项目中实现用户认证服务,所以需要提前准备好相关数据。这里我们使用之前创建的名为springbootdata的数据库,在该数据库中创建3个表t_customer、t_authority和t_customer_authority,并预先插入几条测试数据。
security.sql
use springbootdata;
drop table if exists `t_customer`;
create table `t_customer`(
`id` int(20) not null auto_increment,
`username` varchar(200) default null,
`password` varchar(200) default null,
`valid` tinyint(1) not null default '1',
primary key (`id`)
) engine=InnoDB auto_increment=4 default charset=utf8;
insert into `t_customer` values('1','shitou','$2a$10$BxzPg9I0VAKfDy6F5SRYhepktsvpfhbdz/iecePudLmMCcOdlK0n6','1');
insert into `t_customer` values('2','李四','$2a$10$BxzPg9I0VAKfDy6F5SRYhepktsvpfhbdz/iecePudLmMCcOdlK0n6','1');
drop table if exists t_authority;
create table `t_authority`(
`id` int(20) not null auto_increment,
`authority` varchar(20) default null,
primary key (`id`)
) engine=InnoDB auto_increment=3 default charset=utf8;
insert into `t_authority` values ('1','ROLE_common');
insert into `t_authority` values ('2','ROLE_vip');
drop table if exists `t_customer_authority`;
create table `t_customer_authority`(
`id` int(20) not null auto_increment,
`customer_id` varchar(20) default null,
`authority_id` varchar(20) default null,
primary key (`id`)
) engine=InnoDB auto_increment=5 default charset=utf8;
insert into `t_customer_authority` values ('1','1','1');
insert into `t_customer_authority` values ('2','2','2');
1)创建用户表t_customer时,用户名username必须唯一,因为Security在进行用户查询时是通过username定位是否存在唯一用户的。
2)创建用户表t_customer时,必须额外定义一个tinyinit类型的字段(对应boolean类型的属性,例如示例中的valid),用于校验用户身份是否合法(默认都是合法的)。
3)初始化用户表t_customer数据时,插入的用户密码password必须是对应编码器编码后的密码,例如示例中的密码就是加密后的形式(对应的原始密码为123456)。因此,在自定义配置类中进行用户密码查询时,必须使用与数据库密码统一的密码编码器进行编码。
4)初始化权限表t_authority数据时,权限authority值必须带有"ROLE_"前缀,而默认的用户角色值则是对应权限值去掉"ROLE_"前缀。
添加JDBC连接数据库的依赖驱动启动器
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
进行数据库连接配置
在项目的全局配置文件application.properties中编写对应的数据库连接配置。
application.properties
spring.datasource.url=jdbc:mysq://localhost:3306/springbootdata?serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=123456
使用JDBC进行身份认证
SecurityConfig.java
package com.example.chapter07.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import javax.sql.DataSource;
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private DataSource dataSource;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String userSQL="select username,password,valid from t_customer" +
"where username = ?";
String authoritySQL="select c.username,a.authority from t_customer c," +
"t_authority a t_customer_authority ca where" +
"ca.customer_id=c.id and cd.authority_id=a.id and c.username = ?";
auth.jdbcAuthentication().passwordEncoder(encoder)
.dataSource(dataSource)
.usersByUsernameQuery(userSQL)
.authoritiesByUsernameQuery(authoritySQL);
}
}
重写的configure方法中使用JDBC身份认证的方式进行身份认证。使用JDBC身份认证时,首先需要对密码进行编码设置(必须与数据库中用户密码加密方式一致);然后需要加载JDBC进行认证连接的数据源DataSource;最后,执行SQL语句,实现通过用户名username查询用户信息和用户权限。
需要注意的是,定义用户查询的SQL语句时,必须返回用户名username、密码password是否为有效用户valid3个字段信息;定义权限查询的SQL语句时,必须返回用户名username权限authority两个字段信息。否则,登录时输入正确的用户信息会出现PreparedStatement Callback的SQL异常错误信息。
效果测试
重启chapter07项目进行效果测试,项目启动成功后,通过浏览器访问。
UserDetailsService身份认证
对于用户流量较大的项目来说,频繁地使用JDBC进行数据库查询不仅麻烦,而且会降低网站响应速度。对于一个完善地项目来说,如果某些业务已经实现了用户信息查询地服务,就没必要使用JDBC进行身份认证了。
创建数据库实体类
新建包com.example.chapter07.domain
Customer.java
package com.example.chapter07.domain;
import javax.persistence.*;
import java.io.Serializable;
@Entity(name = "t_customer")
public class Customer implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String username;
private String password;
private Integer valid;
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 Integer getValid() {
return valid;
}
public void setValid(Integer valid) {
this.valid = valid;
}
@Override
public String toString() {
return "Customer{" +
"id=" + id +
", username='" + username + '\'' +
", password='" + password + '\'' +
", valid=" + valid +
'}';
}
}
package com.example.chapter07.domain;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.io.Serializable;
@Entity(name = "t_authority")
public class Authority implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String authority;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getAuthority() {
return authority;
}
public void setAuthority(String authority) {
this.authority = authority;
}
@Override
public String toString() {
return "Authority{" +
"id=" + id +
", authority='" + authority + '\'' +
'}';
}
}
创建Repository类
CustomerRepository.java
package com.example.chapter07.repository;
import com.example.chapter07.domain.Customer;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CustomerRepository extends JpaRepository<Customer, Integer> {
public Customer findByUsername(String username);
}
AuthorityRepository.java
package com.example.chapter07.repository;
import com.example.chapter07.domain.Authority;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface AuthorityRepository extends JpaRepository<Authority,Integer> {
@Query(value = "select a.id,a.authority from t_customer_authority ca,t_authority a, t_customer c where ca.customer_id=c.id and ca.authority_id=a.id and c.username=?1",nativeQuery = true)
public List<Authority> getAuthoritiesByUsername(String username);
}
定义查询用户及角色信息地服务接口
CustomerService.java
package com.example.chapter07.service;
import com.example.chapter07.domain.Authority;
import com.example.chapter07.domain.Customer;
import com.example.chapter07.repository.AuthorityRepository;
import com.example.chapter07.repository.CustomerRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CustomerService {
private final CustomerRepository customerRepository;
private final AuthorityRepository authorityRepository;
private final RedisTemplate redisTemplate;
@Autowired
public CustomerService(CustomerRepository customerRepository, AuthorityRepository authorityRepository, RedisTemplate redisTemplate) {
this.customerRepository = customerRepository;
this.authorityRepository = authorityRepository;
this.redisTemplate = redisTemplate;
}
public Customer getCustomer(String username) {
Customer customer = null;
Object o = redisTemplate.opsForValue().get("customer_" + username);
if (o != null) {
customer = (Customer) o;
} else {
customer = customerRepository.findByUsername(username);
if (customer != null) {
redisTemplate.opsForValue().set("customer_" + username, customer);
}
}
return customer;
}
public List<Authority> getCustomerAuthority(String username) {
List<Authority> authorities = null;
Object o = redisTemplate.opsForValue().get("authorities_" + username);
if (o != null) {
authorities=(List<Authority>)o;
}else{
authorities = authorityRepository.getAuthoritiesByUsername(username);
if (authorities.size() > 0) {
redisTemplate.opsForValue().set("authorities_" + username, authorities);
}
}
return authorities;
}
}
定义UserDetailsService用于封装认证用户信息
UserDetailService是Security提供的用于封装认证用户信息的接口,该接口提供的loadUserByUsername(String s)方法用于通过用户名加载用户信息。使用UserDetailsService进行身份认证时,自定义一个UserDetailsService接口的实现类,通过loadUserByUsername(String s)方法封装用户详情信息并返回UserDetails对象供Security认证使用。
UserDetailsServiceImpl.java
package com.example.chapter07.Impl;
import com.example.chapter07.domain.Authority;
import com.example.chapter07.domain.Customer;
import com.example.chapter07.service.CustomerService;
import org.springframework.beans.factory.annotation.Autowired;
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.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private final CustomerService customerService;
public UserDetailsServiceImpl(CustomerService customerService) {
this.customerService = customerService;
}
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
Customer customer = customerService.getCustomer(s);
List<Authority> authorities = customerService.getCustomerAuthority(s);
//对用户权限进行封装
List<SimpleGrantedAuthority> list=authorities.stream()
.map(authority -> new SimpleGrantedAuthority(authority.getAuthority()))
.collect(Collectors.toList());
if(customer!=null){
UserDetails userDetails=new User(customer.getUsername(),customer.getPassword(),list);
return userDetails;
}else{
throw new UsernameNotFoundException("当前用户不存在!");
}
}
}
重写了UserDetailsService接口的loadUserByUsername(String s)方法用于借助CustomerService业务处理类获取用户信息和权限信息,并通过UserDetails进行认证用户信息封装。
需要注意的是,CustomerService业务处理类获取User实体类时,必须对当前用户进行非空判断,这里使用throw进行异常处理,如果查询的用户为空,throw会抛出UsernameNotFoundException的异常,如果没有使用throw异常处理,Security将无法识别,导致程序整体报错。
使用UserDetailService进行身份认证
接下来我们在configure(AuthenticationManagerBuilder auth)方法中使用UserDetailsService身份认证的方法进行自定义用户认证。
SecurityConfig.java
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
BCryptPasswordEncoder encoder=new BCryptPasswordEncoder();
auth.userDetailsService(userDetailsService).passwordEncoder(encoder);
}
在重写的configure方法中使用UserDetailsService身份认证的方式自定义了认证用户信息。在使用UserDetailsService身份认证时,可直接调用userDetailsService(T userDetailsService)对UserDetailsService实现类进行认证,认证过程中需要对密码进行编码设置。
效果测试
浏览器访问查看效果。
登录成功。
自定义用户授权管理
当一个系统建立之后,通常需要适当地做一些权限控制,使得不同用户具有不同的权限操作系统。
自定义用户访问控制
实际生产中,网站访问多是基于HTTP请求的,我们已经分析出通过重写WebSecurityConfigurerAdapter类的config(HttpSecurity http)方法可以对基于Http的请求访问进行控制。下面我们通过对configure(HttpSecurity http)方法剖析,分析自定义用户访问控制的实现过程。
configure(HttpSecurity http)方法的参数类型是HttpSecurity类,HttpSecurity类提供了Http请求的限制以及权限、Session管理配置、CSRF跨站请求问题等方法
方法 | 描述 |
---|---|
authorizeRequests() | 开启基于HttpServietRequest请求访问的限制 |
formLogin() | 开启基于表单的用户登录 |
httpBasic() | 开启基于HTTP请求的Basic认证登录 |
logout() | 开启退出登录的支持 |
sessionManagement() | 开启Session管理配置 |
rememberMe() | 开启记住我功能 |
csrf() | 配置CSRF跨站请求伪造防护功能 |
此处重点讲解用户访问控制,指令先对authorizeRequests()方法的返回值做进一步查看。
方法 | 描述 |
---|---|
antMatchers(String… antPatterns) | 开启Ant风格的路径匹配 |
mvcMatchers(String… antPatterns) | 开启MVC风格的路径匹配 |
regexMatchers(String… regexPatterns) | 开启正则表达式的路径匹配 |
and() | 功能连接符 |
anyRequest() | 匹配任何请求 |
rememberMe() | 开启记住我功能 |
hasAnyRole(String…roles) | 匹配给定的SpEL表达式计算结果是否为true |
hasRole(String roles) | 匹配用户是否有某一个角色 |
hasAnyAuthority(String…authorities) | 匹配用户是否有参数中的任意权限 |
hasAuthority(String authority) | 匹配用户是否有某一个权限 |
authenticated() | 匹配已经登录认证的用户 |
fullyAuthenticated() | 匹配完整登录认证的用户(非rememberMe登录用户) |
haslpAddress(String ipaddressExpression) | 匹配某IP地址的访问请求 |
permitAll() | 无条件对请求进行放行 |
自定义用户访问控制
打开之前创建的MVC Security自定义配置类SecurityConfig,重写configure(HttpSecurity http)方法进行用户访问控制。
@Autowired
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/").permitAll()
.antMatchers("/detail/common/**").hasRole("common")
.antMatchers("/detail/vip/**").hasRole("vip")
.anyRequest().authenticated()
.and()
.formLogin();
}
configure()方法设置了用户访问权限,其中,路径为"/“的请求直接放行;路径为”/detail/common/**“的请求,只有用户角色为common(即ROLE_common权限)才允许访问;路径为”/detail/vip/**“的请求,只有用户角色是vip(即ROLE_vip权限)才允许访问;其他请求则要求用户必须先进行登录认证。
效果测试
浏览器访问
直接访问localhost:8080可以进入项目首页,这是因为自定义的用户访问控制中,对”/“的请求是直接放行的,说明自定义用户访问控制生效。
在项目首页单击普通电影或VIP专享电影名称查询电影详情。
在项目首页访问影片详情(实质是请求URL跳转,如”/detail/common/1"),会直接被自定义的访问控制拦截并跳转到默认用户登录界面。在此登录界面输入正确的用户名称和密码信息。
访问common电影
访问vip电影
页面会出现403Forbidden(禁止访问)的错误信息,而控制台不会报错。
注:当前实例没有配置完善的用户注销功能,所以登录一个用户后要切换其他用户的话将浏览器重启,再次使用新账号登录。
自定义用户登录
实际开发中,通常要求定制更美观的用户登录页面,并配置有更好的错误提示信息,此时需要自定义用户登录控制。
formLogin()用户登录方法中涉及用户登录的主要方法及说明
方法 | 描述 |
---|---|
loginPage(String loginPage) | 用户登录页面跳转路径,默认为get请求的/login |
sucessForwardUrl(String forwardUrl) | 用户登录成功后的重定向地址 |
sucessHandler(AuthenticationSuccessHandler successHandler) | 用户登录成功后的处理 |
defaultSuccessUrl(String defaultSuccessUrl) | 用户直接登录后默认跳转地址 |
failureForwardUrl(String forwardUrl) | 用户登录失败后的重定向地址 |
failureUrl(String authenticationFailureUrl) | 用户登录失败后的跳转地址,默认为/login?error |
failureHandler(AuthenticationFailureHandler authenticationFailureHandler) | 用户登录失败后的错误处理 |
usernameParameter(String usernameParameter) | 登录用户的用户名参数,默认为username |
passwordParameter(String passwordParameter) | 登录用户的密码参数,默认为password |
loginPrecessingUrl(String loginProcessingUrl) | 登录表单提交的路径,默认为post请求的/login |
permitAll() | 无条件对请求进行放行 |
自定义用户登录页面
login.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta http-equiv="content-type" content="text/html;charset=UTF-8"/>
<title>title</title>
<link th:href="@{/login/css/bootstrap.min.css}" rel="stylesheet"/>
<link th:href="@{/login/css/signin.css}" rel="stylesheet"/>
</head>
<body class="text-center">
<form class="form-signin" th:action="@{/userLogin}" th:method="post">
<img class="mb-4" th:src="@{/login/img/login.jpg}" width="72px" height="72px"/>
<h1 class="h3 mb-3 font-weight-normal">请登录</h1>
<div th:if="${param.error}" style="color: red;height: 40px;text-align: left;font-size: 1.lem">
<img th:src="@{/login/img/loginError.jpg}" width="20px">用户名或密码错误,请重新登录!
</div>
<input type="text" name="name" class="form-control" placeholder="用户名" required="" autofocus="">
<input type="password" name="pwd" class="form-control" placeholder="密码" required="">
<button class="btn btn-lg btn-primary btn-block" type="submit">登录</button>
<p class="mt-5 mb-3 text-muted">Copyright 2019-202</p>
</form>
</body>
</html>
通过<form>
标签定义一个用户登录功能,且登录数据以POST方式通过/userLogin
路径进行提交。其中,登录表单中的用户名参数和密码参数可以自行定义;登录数据提交方式必须为post,提交的路径也可以自行定义。同时有一个专门用来存储登录错误后返回错误信息的<div>
块,在该<div>
块中使用th:if="${param.error}"
来判断请求中是否带有一个error参数,从而判断是否登录成功,该参数是Security默认的,用户可以自行定义。
还引入了两个css样式文件和两个IMG图片文件、用来渲染用户登录页面,它们都存在与/login/**
目录下,需要提前引入这些静态资源文件到chapter07项目resources下的static目录中。
自定义用户登录跳转
在之前创建的FileController类中添加一个跳转到登录页面login.html的方法。
@GetMapping("/userLogin")
public String toLoginPage(){
return "login/login";
}
在上述添加的toLoginPage()方法中,配置了请求路径为"/userLogin"的Get请求,并向静态资源根目录下的login文件夹下的login.html页面跳转。
Spring Security默认采用Get方式的"login"请求用于向登录页面跳转,使用Post方式的/login
请求用于对登录后的数据处理。
Spring Security默认向登录页面跳转时,采用的请求方式是GET,请求路径是"/login";如果要处理登录后的数据,默认采用的请求方式是POST,请求路径是"login"。
自定义用户登录控制
完成上面的准备工作后,打开SecurityConfig类,重写configure(HttpSecurity http)方法,实现用户登录控制。
@Autowired
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/").permitAll()
.antMatchers("/login/**").permitAll()
.antMatchers("/detail/common/**").hasRole("common")
.antMatchers("/detail/vip/**").hasRole("vip")
.anyRequest().authenticated();
http.formLogin()
.loginPage("/userLogin").permitAll()
.usernameParameter("name").passwordParameter("pwd")
.defaultSuccessUrl("/")
.failureUrl("/userLogin?error");
}
1)loginPage("/userLogin")方法指定了向自定义登录页跳转的请求路径(前面定义的toLoginPage()方法的请求映射路径),并使用permitAll()方法对进行登录跳转的请求进行放行。
2)usernameParameter(“name”)和passwordParameter(“pwd”)方法用来接收登录时提交的用户名和密码。这里的参数name和pwd必须和login.html中用户名、密码中的name属性值保持一致,如果login.html中的name属性值是默认值是默认的username和password,这两个方法可以省略。
3)defaultSuccessUrl("/")方法指定了用户登录成功后默认跳转到项目首页。
4)failureUrl("/userLogin?error")方法用来控制用户登录认证失败后的跳转路径,该方法默认参数为"/login?error"。其中,参数中的"/userLogin"为向登录页面跳转的映射,error是一个错误标识,作用是登录失败后在登录页面进行接收判断,例如login.html实例中的${param.error}
,这两者必须保持一致。
5)antMatchers("/login/**").permitAll()方法的作用是对项目static文件夹下login包及其子包中的静态资源文件进行统一放行处理。如果没有对静态资源放行,未登录的用户访问项目首页时就无法加载页面关联的静态资源文件。
效果测试
自定义用户退出
自定义用户推出主要考虑退出后的会话如何管理已经跳转到哪个页面。HttpSecurity类的logout()方法用来处理用户退出,它默认处理路径为/logout
的Post类型请求,同时也会清除Session和Remember Me等任何默认用户配置。
logout()方法中涉及用户退出的主要方法及说明
方法 | 说明 |
---|---|
logoutUrl(String logoutUrl) | 用户退出处理控制URL,默认为post请求的/logout |
logoutSuccessUrl(String logoutSuccessUrl) | 用户推出后的重定向地址 |
logoutSuccessHandler(LogoutSuccessHandler logoutSuccessHandler) | 用户退出成功后的处理器 |
deleteCookies(String…cookieNamesToClear) | 用户退出后删除指定Cookie |
invalidateHttpSession(boolean invlidateHttpSession) | 用户退出后是否立即清除Session(默认true) |
clearAuthentication(boolean clearAuthentication) | 用户退出后是否立即清除Authentication用户认证信息(默认为true) |
添加自定义用户退出链接
在之前创建的项目首页index.html上方新增一个用户退出链接
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta http-equiv="Content-Type" content="text/html" charset="UTF-8">
<title>影视直播厅</title>
</head>
<body>
<h1 align="center">欢迎进入电影网站首页</h1>
<form th:action="@{/mylogout}" method="post">
<input th:type="submit" th:value="注销"/>
</form>
<hr/>
<h3>普通电影</h3>
<ul>
<li><a th:href="@{/detail/common/1}">飞驰人生</a></li>
<li><a th:href="@{/detail/common/2}">夏洛特烦恼</a></li>
</ul>
<ul>
<li><a th:href="@{/detail/vip/1}">速度与激情</a></li>
<li><a th:href="@{/detail/vip/2}">星球崛起</a></li>
</ul>
</body>
</html>
新增一个<form>标签进行注销控制,且定义的退出表单action为/mylogout
(默认为/logout
),方法为post。
需要说明的是,Spring Boot项目中引入Spring Security框架后会自动开启CSRF防护功能(跨站请求伪造防护),用户退出时必须使用POST请求,如果关闭了CSRF防护功能,那么可以使用任意方式的HTTP请求进行用户注销。
自定义用户退出控制
在页面中定义好用户退出链接后,不需要再Controller控制层中额外定义用户退出方法,可以直接在Security中定制logout()方法实现用户退出。打开SecurityConfig类,重写config(HttpSecurity http)方法进行用户退出控制。
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/").permitAll()
.antMatchers("/login/**").permitAll()
.antMatchers("/detail/common/**").hasRole("common")
.antMatchers("/detail/vip/**").hasRole("vip")
.anyRequest().authenticated();
http.formLogin()
.loginPage("/userLogin").permitAll()
.usernameParameter("name").passwordParameter("pwd")
.defaultSuccessUrl("/")
.failureUrl("/userLogin?error");
http.logout()
.logoutUrl("/mylogout")
.logoutSuccessUrl("/");
}
上述代码中,在configure(HttpSecurity http)方法中使用logout()及其相关方法实现了用户退出功能。其中,logoutUrl("/mylogout")方法指定了用户退出的请求路径,这个路径与index.html页面退出表单中action的值必须保持一致,如果退出表单使用了默认的"/logout"请求,则此方法可以省略;logoutSuccessUrl("/")方法指定了用户退出成功后重定向到"/"地址(即再次重定向到项目首页)。在用户退出后,用会话信息则默认清除,此情况下无须手动配置。
效果测试
登录后可以正常访问资源,在注销后访问资源需要重新登录。
登录用户信息获取
在传统项目中进行用户登录处理时,通常会查询用户是否存在,如果存在则登录成功,同时将当前用户放在Session中。
使用HttpSession获取用户信息
在之前创建的FilmeController控制类中新增一个用于获取当前会话用户信息的getUser()方法
@GetMapping("/getuserBySession")
@ResponseBody
public void getUser(HttpSession session){
Enumeration<String> names=session.getAttributeNames();
while (names.hasMoreElements()){
String element = names.nextElement();
SecurityContextImpl attribute = (SecurityContextImpl)session.getAttribute(element);
System.out.println("element"+element);
System.out.println("attribute"+attribute);
Authentication authentication=attribute.getAuthentication();
UserDetails principal=(UserDetails)authentication.getPrincipal();
System.out.println(principal);
System.out.println("username:"+principal.getUsername());
}
}
上述代码中,在getUser(HttpSession session)方法中通过获取当前HttpSession的相关方法遍历并获取了会话中的用户信息。其中,通过getAttribute(element)获取会话对象时,默认返回的是一个Object对象,其本质是一个SecurityContextImpl类,为了方便擦好看对象数据,所以强制转换为SecurityContextImpl;在获取认证用户信息时,使用了Authentication的getPrincipal()方法,其返回值也是一个Object对象,其本质是封装用户信息的UserDetails封装类,其中包括有姓名、密码、权限、是否过期等。
当前HttpSession会话中只有一个key为"SPRING_SECURITY_CONTRXT"的用户信息,并且用户信息被封装在SecurityContextImpl类对象中。
使用SecurityContextHolder获取用户信息
Spring Security针对拦截的登录用户专门提供了一个SecurityContextHolder类,来获取Spring Security的应用上下文SecurityContext,进而获取封装的用户信息。
在FileController控制类中新增一个获取当前会话用户信息的getUser2()方法
@GetMapping("/getuserByContext")
@ResponseBody
public void getUser2(){
SecurityContext context= SecurityContextHolder.getContext();
System.out.println("userDetails:"+context);
Authentication authentication=context.getAuthentication();
UserDetails principal=(UserDetails)authentication.getPrincipal();
System.out.println(principal);
System.out.println("username:"+principal.getUsername());
}
上述代码中,通过Security提供的SecurityContextHolder类先获取了应用上下文对象SecurityContext,并通过其相关方法获取了当前登录用户信息。通过与HttpSession方式获取用户信息的实例对比可以发现,这两种方法的区别就是获取SecurityContext的不同,他后续方法基本一致。
至此,关于Spring Boot整合Spring Security拦截后的登录用户信息获取就已经讲解完毕这里介绍的两种方法中,HttpSession的方式获取用户信息相比比较传统,而且必须引入HtppSession对象;而Security提供的SecurityContextHolder则相对简便,也是在Security项目中相对推荐的使用方式。
记住我功能
在实际开发中,有些项目为了用户登录方便还会提供记住我功能。如果用户登录时勾选了记住我选项,那么在一段有效时间内,会默认自动登录,并允许访问相关页面,这就免去了重复登录操作的麻烦。Spring Security提供了用户登录控制的同时,当然也提供了对应记住我功能,前面介绍的HttpSecurity类的主要方法rememberMe()就是Spring Security用来处理记住我功能。
主要方法
方法 | 说明 |
---|---|
rememberMeParameter(String rememberMeParameter) | 指示在登陆时记住用户的Http参数 |
key(String key) | 记住我认证生成的Token令牌标识 |
tokenValiditySeconds(int tokenValiditySeconds) | 记住我Token令牌有效期,单位为s(秒) |
tokenRepository(PersistentTokenRepository tokenRepository) | 指定要使用的PersistentTokenRepository,用来配置持久化Token令牌 |
alwaysRemember(boolean alwaysRemember) | 是否应该始终创建记住我Cookie,默认为false |
clearAuthentication(boolean clearAuthentication) | 是否设置Cookie为安全的,如果设置为true,则必须通过HTTPS进行连接请求。 |
需要说明的是,Spring Security针对记住我功能提供了两种实现;一种是简单使用加密来保证基于Cookie中Token的安全;另一种是通过数据或其他持久化机制来保持生成的Token。
基于加密简单Token的方式
基于简单加密Token的方式实现记住我功能非常简单,当用户选择记住我并成功登录后,Spring Security将会生成一个Cookie并发送给客户端浏览器。其中Cookie值由下列方式组合加密而成。
base64(username+":"+expirationTime+":"+md5Hex(username+":"+expirationTime+":"+password+":"+key))
上述Cookie值得生成方式中,username代表登录的用户名;password代表用户密码;expirationTime标识记住我中Token的失效日期,以毫秒为单位;key表示防止修改Token的标识。
基于简单加密Token的方式中的Token在指定的时间内有效,且必须保证Token中所包含的username、password和key没有改变。需要注意的是,这种加密方式其实是存在安全隐患的,任何人获取到该记住我功能的Token后,都可以在该Token过期之前进行自动登录,只有当用户觉察到Token被盗用后,才会对自己的登录密码进行修改来立即使其原有的记住我Token失效。
为了简化操作,我们在以前创建的项目用户登录页login.html中新增一个记住我勾选框
login.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta http-equiv="content-type" content="text/html;charset=UTF-8"/>
<title>title</title>
<link th:href="@{/login/css/bootstrap.min.css}" rel="stylesheet"/>
<link th:href="@{/login/css/signin.css}" rel="stylesheet"/>
</head>
<body class="text-center">
<form class="form-signin" th:action="@{/userLogin}" th:method="post">
<img class="mb-4" th:src="@{/login/img/login.jpg}" width="72px" height="72px"/>
<h1 class="h3 mb-3 font-weight-normal">请登录</h1>
<div th:if="${param.error}" style="color: red;height: 40px;text-align: left;font-size: 1.lem">
<img th:src="@{/login/img/loginError.jpg}" width="20px">用户名或密码错误,请重新登录!
</div>
<input type="text" name="name" class="form-control" placeholder="用户名" required="" autofocus="">
<input type="password" name="pwd" class="form-control" placeholder="密码" required="">
<div class="checkbox mb-3">
<label>
<input type="checkbox" name="rememberme">记住我
</label>
</div>
<button class="btn btn-lg btn-primary btn-block" type="submit">登录</button>
<p class="mt-5 mb-3 text-muted">Copyright 2019-202</p>
</form>
</body>
</html>
上述修改的用户登录页login.html中,在用户登录的<form>表单中新增一个checkbox多选框为用户提供记住我选项。其中记住我勾选框的name属性值设为了"rememberme",而Security提供的记住我功能的name属性值默认为“remember-me”
打开SecurityConfig类,重写config(HttpSecurity http)方法进行记住我功能配置
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/").permitAll()
.antMatchers("/login/**").permitAll()
.antMatchers("/detail/common/**").hasRole("common")
.antMatchers("/detail/vip/**").hasRole("vip")
.anyRequest().authenticated();
http.formLogin()
.loginPage("/userLogin").permitAll()
.usernameParameter("name").passwordParameter("pwd")
.defaultSuccessUrl("/")
.failureUrl("/userLogin?error");
http.logout()
.logoutUrl("/mylogout")
.logoutSuccessUrl("/");
http.rememberMe()
.rememberMeParameter("rememberme")
.tokenValiditySeconds(200);
}
上述代码中,在之前实现的configure(HttpSecurity http)方法中使用rememberMe()及相关方法实现了记住我功能。其中,rememberMeParameter(“rememberme”)方法指定了记住我勾选框的name属性值,如果页面中使用了默认的"remember-me",则该方法可以省略;tokenValiditySeconds(200)方法设置了记住我功能中Token的有效期为200s。
重启chapter07项目进行效果测试。
重新打开浏览器,直接查看影片详情,发现已经记住了用户。
基于持久化Token的方式
持久化Token的方式与简单加密Token的方式在实现Remember-Me功能上大体相同,都是在用户选择记住我并成功登录后,将生成的Token存入Cookie中并发送到客户端浏览器,在下次用户通过同一客户端访问系统时,系统将直接从客户端Cookie中读取Token进行认证。两者的主要区别在于:基于简单加密Token的方式,生成的Token将在客户端保存一段时间,如果用户不登出,或者不修改密码,那么在Cookie实现之前,任何人都可以无限制地使用该Token进行自动登录;而基于持久化Token地方式采用如下实现逻辑。
- 用户选择记住我成功登录后,Security会把username、随机产生地序列号、生成的Token进行持久化存储(例如一个数据表中),同时将他们的组合生成一个Cookie发送给客户端浏览器。
- 当用户再次访问系统时,首先检查客户端携带的Cookie,如果对应Cookie中包含的username、序列号和Token与数据库中保存的一致,则通过验证并自动登录,同时系统将重新生成一个新的Token替换数据库中旧地Token,并将的Cookie再次发送给客户端。
- 如果Cookie中的Token不匹配,则很有可能是用户的Cookie被盗用了。由于盗用者使用初次生成的Token进行登陆时会生成一个新的Token,所以当用户在不知情时再次登录就会出现Token不匹配的情况,这时就需要重新登录,并生成新的Token和Cookie同时Spring Security就可以发现Cookie可能被盗用的情况,它将删除数据库中与当前用户相关的所有Token记录,这样盗用者使用原有的Cookie将不能再次登录。
- 如果用户访问系统时没有携带Cookie,或者包含的username和序列号与数据库中保存的不一致,那么将会引导用户到登录页面。
综上,对应安全性要求很高的应用,不推荐使用Remember-Me功能。
create table persistent_logins (username varchar(64) not null,
series varchar(64) primary key,
token varchar(64) not null,
last_used timestamp not null);
创建一个名为persistent_logins的数据表,其中username存储用户名,series存储随机生成的序列号,token存储每次访问更新的Token,last_used表示最近登录日期。需要说明的是,在默认情况下基于持久化Token的方式会使用上述官方提供的用户表persistent_logins进行持久化Token的管理,读者不需要自定义存储Cookie信息的用户表。
在完成存储Cookie信号的用户表创建以及页面记住我功能勾选框设置后,打开SecurityConfig类,重写configure(HttpSecurity http)方法进行记住我功能配置
@Autowired
private DataSource dataSource;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/").permitAll()
.antMatchers("/login/**").permitAll()
.antMatchers("/detail/common/**").hasRole("common")
.antMatchers("/detail/vip/**").hasRole("vip")
.anyRequest().authenticated();
http.formLogin()
.loginPage("/userLogin").permitAll()
.usernameParameter("name").passwordParameter("pwd")
.defaultSuccessUrl("/")
.failureUrl("/userLogin?error");
http.logout()
.logoutUrl("/mylogout")
.logoutSuccessUrl("/");
http.rememberMe()
.rememberMeParameter("rememberme")
.tokenValiditySeconds(200)
.tokenRepository(tokenRepository());
}
@Bean
public JdbcTokenRepositoryImpl tokenRepository(){
JdbcTokenRepositoryImpl jr=new JdbcTokenRepositoryImpl();
jr.setDataSource(dataSource);
return jr;
}
上述代码中,与基于简单加密的Token方式相比,在持久化Token方式的rememberMe()实例中加入了tokenRepository(tokenRepository())方式对Cookie信息进行持久化管理。其中的tokenRepository()参数会返回一个设置dataSource数据源的JdbcTokenRepositoryImpl实现类对象,该对象包含操作Token的各种方法。
效果测试
CSRF防护功能
CSRF(Cross-site request forgery,跨站请求伪造),也被称为"One Click Attack"(一键攻击)或者"Session Riding"(会话控制),通常缩写为CSRF或者XSRF,是一种对网站的恶意利用。与传统的XSS攻击(Cross-site Scripting,跨站脚本攻击)相比,CSRF攻击更加难以防范,被认为比XSS更具危险性。CSRF攻击可以在受害者毫不知情的情况下以受害者的名义伪造请求发送给攻击页面,从而在用户未授权的情况下执行在权限保护之下的操作。
例如,一个用户Tom登录银行站点服务器准备进行转账操作,在此用户信息有效期内,Tom被诱导查看了一个黑客恶意网站,该网站就会获取到Tom登录后的浏览器与银行网站之间尚未过期的Session信息,而Tom浏览器的Cookie中含有Tom银行的认证信息,此时黑客会伪装成Tom认证后的合法用户对银行账户进行非法操作。
在讨论如何抵御CSRF攻击之前,先要明确CSRF攻击的对象,也就是要保护的对象。从上面的例子可知,CSRF攻击是黑客借助受害者的Cookie骗取服务器的信任,但是黑客并不能获取Cookie,也看不到Cookie的具体内容。另外,对于服务器返回的结果,由于浏览器同源策略的限制,黑客无法进行解析。黑客所能做的就是伪造正常用户给服务器发送请求,以执行请求中所描述的命令,在服务器端直接改变数据的值,而非窃取服务器中的数据。因此,针对CSRF攻击要保护的对象是那些可以直接产生数据变化的服务,而对于读取数据的服务,可以不进行CSRF保护。例如,银行转账操作会改变账号金额,需要进行CSRF保护。获取银行卡等级信息是读取操作,不会改变数据,可以不需要保护。
在业界目前防御CSRF攻击主要有以下3种策略
1)验证HTTP Referer字段
2)在请求地址中添加Token并验证
3)在HTTP头中自定义属性并验证
方法 | 描述 |
---|---|
disable() | 关闭Security默认开启的CSRT防御功能 |
csrfTokenRepository(CsrfTokenRepositor csrfTokenRepository) | 指定要使用的CsrfTokenRepositoryRepository(Token令牌持久化仓库)。默认是由LazyCsrfTokenRepository包装的HttpSessionCsrfTokenRepository |
requireCsrfProtectionMatcher(RequesMatcher requireCsrfProtectionMatcher) | 指定针对什么类型的请求应用CSRF防护功能。默认设置是忽略GET、HEAD、TRACE和OPTIONS请求,而处理并防御其他所有请求。 |
CSRF防护功能关闭
Spring Boot整合Spring Security默认开启了CSRF防御功能,并要求数据修改的请求方法(例如PATCH、POST、PUT和DELETE)都需要经过Security配置的安全认证后方可正常访问,否则无法正常发送请求。
1)创建数据修改页面。打开项目resource/templates目录,在该目录下创建一个名为csrf的文件夹,在该文件夹中编写一个模拟修改用户账号信息的Thymeleaf页面csrfTest.html用来进行CSRF测试。
csrfTest.html
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
<title>用户修改</title>
</head>
<body>
<div align="center">
<form method="post" action="/updateUser">
用户名:<input type="text" name="username"/><br/>
密码:<input type="password" name="password"/><br/>
<button type="submit">修改</button>
</form>
</div>
</body>
</html>
2)编写后台控制台方法。在chapter07项目的com.example.chapter07.controller的包下,创建一个用于CSRF页面请求测试的控制类CSRFController。
package com.example.chapter07.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
@Controller
public class CSRFController {
@GetMapping("/toUpdate")
public String toUpdate(){
return "csrf/csrfTest";
}
@PostMapping("/updateUser")
public String updateUser(@RequestParam String username, @RequestParam String password, HttpServletRequest request){
System.out.println(username);
System.out.println(password);
String csrf_token=request.getParameter("_csrf");
System.out.println(csrf_token);
return "index";
}
}
3)CSRF默认防护效果测试。
重启chapter07项目,通过浏览器访问"http://localhost:8080/toUpdate"用户修改页面,由于前面配置了请求拦截,会先自动跳转到用户修改页面。
随意输入修改后的用户名和密码,单击[修改]按钮进行数据提交。
在代码业务逻辑没有错误的情况下,表单中正确提交POST的请求数据被拦截,出现了403和Forbidden(禁止)的错误提示信息,而后台也没有任何响应。这说明整合使用的Spring Security安全框架默认启动了CSRF安全防护功能,而上述示例被拦截的本质原因就是数据修改请求中没有携带CSRF Token(CSRF令牌)相关的参数信息,所以被认为是不安全的请求。
通过上述示例可以看出,在整合Spring Security安全框架后,项目默认启用了CSRF安全防护功能,项目中所有涉及数据修改方式的请求都会被拦截。针对这种情况,可以有两种处理方式:一种方式是直接关闭Security默认开启的CSRF防御功能;另一种方式就是配置Security需要的CSRF Token。
如果选择关闭Security默认开启的CSRF防御功能的话,配置非常简单。打开配置类SecurityConfig,在重写的configure(HttpSecurity http)方法中进行关闭配置即可
protected void configure(HttpSecurity http) throws Exception {
//可以关闭Spring Security默认开启的CSRF防护功能
http.csrf().disable();
}
注:这种直接关闭CSRF防御的方式简单粗暴,不太推荐使用,如果强行关闭后网站可能会面临CSRF攻击的危险。
Spring Security针对不同类型的数据修改请求提供了不同方式的CSRFToken配置,主要包括:针对Form表单数据修改的CSRF Token配置和针对Ajax数据修改请求的CSRF Token配置。
针对Form表单数据修改的CSRF Token配置
针对Form表单类型的数据修改请求,Security支持在Form表单中提供一个携带CSRF Token信息的隐藏域,与其他修改数据一起提交,这样后台就可以获取并验证该请求是否为安全的。
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
<title>用户修改</title>
</head>
<body>
<div align="center">
<form method="post" action="/updateUser">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
用户名:<input type="text" name="username"/><br/>
密码:<input type="password" name="password"/><br/>
<button type="submit">修改</button>
</form>
</div>
</body>
</html>
上述代码中,Form表单中的<input>隐藏标签携带了Security提供的CSRT Token信息。其中,th:name="
c
s
r
f
.
p
a
r
a
m
e
t
e
r
N
a
m
e
"
会
获
取
S
e
c
u
r
i
t
y
默
认
提
供
的
C
S
R
F
T
o
k
e
n
对
于
的
k
e
y
值
‘
c
s
r
f
‘
,
t
h
:
v
a
l
u
e
=
"
{_csrf.parameterName}"会获取Security默认提供的CSRF Token对于的key值`_csrf`,th:value="
csrf.parameterName"会获取Security默认提供的CSRFToken对于的key值‘csrf‘,th:value="{_csrf.token}"会获取Security默认随机生成的CSRF Token对应的value值。在Form表单中添加上述CSRF配置后,无须其他配置就可以正常实现数据修改请求,后台配置的Security会自动获取并识别请求中的CSRF Token信息并进行用户信息验证,从而判断是否安全。
需要说明的是,针对Thymeleaf模板页面中的Form表单数据修改请求,除了可以使用上述示例方式显示配置CSRF Token信息提交数据修改请求外,还可以使用Thymeleaf模板th:action属性配置CSRF Token信息
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
<title>用户修改</title>
</head>
<body>
<div align="center">
<form method="post" th:action="@{/updateUser}">
<!--<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>-->
用户名:<input type="text" name="username"/><br/>
密码:<input type="password" name="password"/><br/>
<button type="submit">修改</button>
</form>
</div>
</body>
</html>
使用了Thymeleaf模板的th:action属性配置类Form表单数据修改后的请求路径,而在表单中并没有提供携带CSRF Token信息的隐藏域,但仍然可以正常执行数据修改请求。这是因为使用Thymeleaf模板的th:action属性的配置请求时,会默认携带CSRF Token信息,无须开发者手动添加,这也解释了在前面编写的login.html页面进行用户登陆时为何可以正常执行的原因。
针对Ajax数据修改请求的CSRF Token配置
对于Ajax类型的数据修改请求来说,Security提供了通过添加HTTP header头信息的方式携带CSRF Token信息进行请求验证。
首先,在页面<head>标签中添加<meta>子标签,并配置CSRF Token信息。
<head>
<!--获取CSRF Token-->
<meta name="_csrf" th:content="${_csrf.token}"/>
<!--获取CSRF头,默认为X-CSRF-Token-->
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
</head>
然后,在具体的Ajax请求中获取<meta>子标签中设置的CSRF Token信息并绑定在HTTP请求头中进行请求验证
$(function(){
//获取<meta>标签中封装的CSRF Token信息
var token=$("meta[name='_csrf']").attr("content");
var hear=$("meta[name='_csrf_header']").attr("content");
//将头中的CSRF Token信息进行发送
$(document).ajaxSend(function(e,xhr,options){
xhr.setRequestHeader(header,token);
})
});
Security管理前端页面
我们只是通过Spring Security对后台增加了权限控制,前端页面并没有做任何处理,前端页面显示的还是对应的链接等内容,用户体验较差。
添加thymeleaf-extras-springsecurity5依赖启动器
在项目pom.xml中添加
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
需要注意的是,上述添加的thymeleaf-extras-springsecurity5依赖启动器中,其版本号同样是由Spring Boot统一整合并管理的。如果引用thymeleaf-extras-springsecurity4依赖启动器,那么还需要添加<version>标签手动进行版本管理。
修改前端页面,使用Security相关标签进行页面控制
打开chapter07项目首页index.html,引入Security安全标签,并在页面中根据需要使用Security标签进行显示控制
index.html
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
<meta http-equiv="Content-Type" content="text/html" charset="UTF-8">
<title>影视直播厅</title>
</head>
<body>
<h1 align="center">欢迎进入电影网站首页</h1>
<div sec:authorize="isAnonymous()">
<h2 align="center">游客您好,如果想查看电影<a th:href="@{/userLogin}">请登录</a> </h2>
</div>
<div sec:authorize="isAuthenticated()">
<h2 align="center">
<span sec:authentication="name" style="color:#007bff"></span>
您好,您的用户权限为<span sec:authentication="principal.authorities" style="color: darkkhaki"></span>,您有权观看以下电影
</h2>
<form th:action="@{/mylogout}" method="post">
<input th:type="submit" th:value="注销"/>
</form>
</div>
<hr/>
<div sec:authorize="hasRole('common')">
<h3>普通电影</h3>
<ul>
<li><a th:href="@{/detail/common/1}">飞驰人生</a></li>
<li><a th:href="@{/detail/common/2}">夏洛特烦恼</a></li>
</ul>
</div>
<div sec:authorize="hasAuthority('ROLE_vip')">
<h3>vip电影</h3>
<ul>
<li><a th:href="@{/detail/vip/1}">速度与激情</a></li>
<li><a th:href="@{/detail/vip/2}">星球崛起</a></li>
</ul>
</div>
</body>
</html>
页面顶部通过"xmlns:sec"引入了Security安全标签,页面中根据需要编写了4个<div>模块。下面我们对这4个<div>模块的作用及内部属性进行详细说明
sec:authorize="isAnonymous()"
属性判断用户是否未登录,只有匿名用户(未登录用户)才会显示请登录链接提示。
sec:authorize="isAuthenticated()"
属性来判断用户是否已登录,只有认证用户才会显示登录用户信息和注销链接等提示。
sec:authorize="hasRole('common')"
属性,定义了只有角色为common(对应权限Authority为ROLE_common)且登录的用户才会显示普通电影列表信息。
sec:authorize="hasAuthority('ROLE_vip')"
属性,定义了只有权限为ROLE_vip(对应角色Role为vip)且登录的用户才会显示VIP电影列表信息。
sec:authentication="name" sec:authentication="principal.authorities"
两个属性分别显示了登录用户名name和权限authority。
测试效果
重启chapter07项目进行效果测试。