Spring Security认证授权
一.基本概念
1.1 认证和授权
**认证(Authentication):**判断一个用户属于系统内用户,还是匿名访问用户,认证后可获得用户基本信息与访问权限
**授权(Authorization):**对系统已有资源划分不同权限,为系统内用户分配对应权限,只有拥有权限的用户才可以访问对应的资源
1.2 RBAC控制访问
RBAC(Role Based Access Control)基于角色的访问控制,通过抽象出 “用户、角色、权限“ 三个概念,实现用户分配角色,角色分配权限的权限管理方式,也是目前企业中权限管理主要实现方案。
二.RBAC项目回顾
2.1、登录控制流程
登录流程:
1. 用户访问登录页面
2. 提交用户名与密码
3. 后台基于用户名查询用户对象
1. 不存在:返回用户名或密码错误
2. 存在:继续校验密码
4. 校验密码是否正确
1. 错误:返回用户名或密码错误
2. 正确:登录成功
5. 将用户信息存入 session
6. 返回登录成功
2.2访问控制流程
访问控制流程:
1. 判断当前请求是否需要拦截
1. 否:放行
2. 是:继续拦截
2. 获取当前登录用户信息
3. 判断请求方法是否有权限注解
1. 无:放行
2. 有:继续拦截
4. 判断用户是否拥有该权限
1. 有:放行
2. 无:拦截用户,返回 403
三 权限框架
3.1 权限框架能做什么?
**用户/角色/权限管理:**通常需要业务系统自己实现,由于各个系统的用户/角色/权限的数据管理方式不同,因此一般权限框架只会提供基础的内存或配置文件等用户/权限管理方式
**认证:**权限框架实现,在权限框架内部实现对于认证流程的控制,以及用户访问需要认证资源时的拦截功能
**授权:**通常由业务系统自己实现,因为各个业务系统的数据模型可能不一致,权限框架往往只能提供最基础的权限配置
**鉴权:**权限框架实现,一般权限框架会提供多种鉴权的方式,例如页面/方法/资源等权限控制
**其他功能:**通常权限框架会封装大部分与认证/授权相关的功能,如会话管理/记住用户/密码加密/用户状态判断/跨站攻击等功能
3.2 常见的权限框架
目前市面上比较流行的权限框架主要实 Shiro 和 Spring Security,这两个框架各自侧重点不同,各有各的优劣
3.2.1 Apache Shiro
Apache Shiro(读作“sheeroh”,即日语“城”)是一个开源安全框架,提供身份验证、授权、密码学和会话管理。Shiro框架直观、易用,同时也能提供健壮的安全性。
特点:
- 易于理解的 Java Security API;
- 简单的身份认证(登录),支持多种数据源(LDAP,JDBC,Kerberos,ActiveDirectory 等);
- 对角色的简单的签权(访问控制),支持细粒度的签权;
- 支持一级缓存,以提升应用程序的性能;
- 内置的基于 POJO 企业会话管理,适用于 Web 以及非 Web 的环境;
- 异构客户端会话访问;
- 非常简单的加密 API;
- 不跟任何的框架或者容器捆绑,可以独立运行。
3.2.2 Spring Security
Spring Security 是一个能够为基于 Spring 的企业应用系统提供声明式的安全访问控制解决方案的安全框架。它提供了一组可以在 Spring 应用上下文中配置的 Bean,充分利用了 Spring IoC,DI(控制反转 Inversion of Control, DI:Dependency Injection 依赖注入)和 AOP(面向切面编程)功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。
特点:
- 与 Spring Boot 集成非常简单
- 功能强大,高度可定制化
- 支持 OAuth2.0
- 强大的加密 API
- 防止跨站请求伪造攻击(CSRF)
- 提供 Spring Cloud 分布式组件
3.3 权限框架的选择
Shiro 和 Spring Security 都是 Java 领域比较优秀的权限框架,从下图(百度指数)热度来看,Shiro 的热度是一直高于 Spring Security 的,但是随着 SpringBoot 的普及与 Spring Cloud 的兴起,Spring Security 也是在逐渐上涨的趋势,而 Shiro 也在缓缓下滑。
选择建议:
- 普通 Java WEB 项目,不使用任何框架:选择 Shiro
- SSM 框架且只需要简单的认证与鉴权:选择 Shiro
- 其他情况使用 Spring Security
四 Spring Security 快速入门
基于 Spring Boot 集成 Spring Security,关键依赖 spring-boot-starter-security
Spring Boot 版本:2.6.13
Spring Security 版本:`5.6.8
4.1 搭建项目
创建 SpringBoot 项目,选择 web/security/lombok 依赖即可,pom.xml 文件如下
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.13</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>cn.wolfcode</groupId>
<artifactId>spring-security-demo</artifactId>
<version>1.0.0</version>
<name>spring-security-demo</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
4.2 创建启动类
package cn.wolfcode;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SpringSecurityDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SpringSecurityDemoApplication.class, args);
}
}
4.3 创建相关资源接口
package cn.wolfcode.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class IndexController {
@GetMapping
public String hello() {
return "<h1>系统首页</h1>";
}
}
package cn.wolfcode.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/employees")
public class EmployeeController {
@GetMapping
public String list() {
return "<h1>员工管理列表</h1>";
}
@PostMapping
public String save() {
return "<h1>新增员工</h1>";
}
}
package cn.wolfcode.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/departments")
public class DepartmentController {
@GetMapping
public String list() {
return "<h1>部门管理列表</h1>";
}
@PostMapping
public String save() {
return "<h1>新增部门</h1>";
}
}
通过以上操作,我们就已经搭建好了一个基础的 Spring Security 项目,通过浏览器访问任意资源,可以发现都会自动跳转到登录页面
默认用户名为 user
,而密码则会通过 UUID 生成并打印在控制台,例如下方信息
Using generated security password: 21226ad3-84da-4f0b-9601-7b2e1639ca08
4.4 修改默认用户名密码
spring:
security:
user:
name: wolfcode
password: 1
五 Spring Security 核心功能详解
5.1 工作原理
Spring Security 框架的核心是对应用进行认证与访问控制,而在 web 下的访问控制实际就是针对请求的拦截,我们知道 SpringMVC 的核心是 DispatcherServlet
,那么如果想要对请求进入 Servlet 以前就进行控制的话,必然需要用到 Java WEB 三大组件之一的 Filter 过滤器了。
而 Spring Security 的核心实际就是 Filter,甚至你可以把 Spring Security 看做就是一系列的过滤器链条,只要你大概明白它的过滤器都帮我们做了些什么事情,那么要理解 Spring Security 的工作原理也就不难了。
org.springframework.security.web.FilterChainProxy,它实现了javax.servlet.Filter,因此外部的请求会经过此
类,下图是 Spring Security 过虑器链结构图:
FilterChainProxy 是一个代理,真正起作用的是 FilterChainProxy 中 SecurityFilterChain 所包含的各个 Filter,同时这些 Filter 作为 Bean 被 Spring 管理,它们是 Spring Security 核心,下列类图大概列出了比较重要的几个过滤器对象,也是我们需要重点了解的对象。
以上重点过滤器的作用:
-
SecurityContextPersistenceFilter:这个过滤器是整个过滤器链的入口和出口,负责将 SecurityContext 上下文对象关联到 SecurityContextHolder 对象,在请求进来时将对象设置进去,在请求结束后将上下文对象从 SecurityContextHolder 对象中清除。
-
**UsernamePasswordAuthenticationFilter:**用来处理表单提交过来的认证请求,获取到表单参数后会封装一个认证 token 对象,将其交由
AuthenticationManager
对象进行验证,认证后将结果交给AuthenticationSuccessHandler
或AuthenticationFailureHandler
处理器对象来处理成功或失败的结果。 -
**FilterSecurityInterceptor:**对于需要进行访问控制的 web 资源进行鉴权,最终会交由
AccessDecisionManager
对象来进行权限决策,通过则访问资源,若拒绝则会交由AccessDeniedHandler
来处理拒绝后的结果。 -
**ExceptionTranslationFilter:**FilterChain 中任意过滤器抛出的异常都会被其捕获,但是只会处理
AuthenticationException
和AccessDeniedException
两种类型的异常,其他的异常都会继续往外抛出。
5.2 自定义认证页面
通过上面的过滤器链,我们大概知道了 Spring Security 的工作原理,那么在快速入门程序中的登录/登出页面又是从哪里来的呢?答案依然过滤器,Spring Security 默认为我们提供了登录/登出页面生成过滤器 DefaultLoginPageGeneratingFilter
、DefaultLogoutPageGeneratingFilter
如果想要访问我们自己的登录页面,那么我们就需要告诉 Spring Security 我们的登录页面相关信息,创建一个配置类,继承 WebSecurityConfigurerAdapter
类,并实现其中方法名为 configure
参数类型为 HttpSecurity
的方法
添加 jsp 依赖
<!-- jsp 依赖 -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
</dependency>
package cn.wolfcode.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 关闭 CSRF
http.csrf().disable();
// 请求认证授权配置
http.authorizeRequests()
.antMatchers("/login.jsp").anonymous()
// 对所有以 /static 开头的资源放行
// 匹配规则的三种写法
// **: 表示任意层级 =》 /static/** : /static/a.js /static/abc/ddd/c.html
// *: 表示长度的任意字符 /*.html : /xxx.html /aa.html
// ?: 表示单个任意字符 /index?.html : /index1.html /index2.html
.antMatchers("/static/**").permitAll()
// 其他所有请求都需要认证
.anyRequest().authenticated();
// 配置表单登录
http.formLogin() // 默认的 /login 页面
.loginPage("/login.jsp")
.loginProcessingUrl("/login")
.defaultSuccessUrl("/")
.usernameParameter("name")
.passwordParameter("pass");
}
}
准备一个登录页面,放在 src/main/webapp/login.jsp
下面
<%@ page contentType="text/html; charset=UTF-8" %>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录页面</title>
</head>
<body>
<h1>自定义登录页</h1>
<form action="/login" method="post">
<span style="color: red;">${SPRING_SECURITY_LAST_EXCEPTION.message}</span> <br>
用户名:<input type="text" name="name"> <br>
密 码:<input type="password" name="pass"> <br>
<button type="submit">登录</button>
</form>
</body>
</html>
5.3 自定义获取用户认证/权限信息
5.3.1 UserDeatails对象
在 Spring Security 中,所有用户最终认证后都会被封装为 UserDetails
接口的实现类,该接口主要定义了如下几个方法:
public interface UserDetails extends Serializable {
/**
* 返回认证用户的所有权限
*/
Collection<? extends GrantedAuthority> getAuthorities();
/**
* 返回认证用户的密码
*/
String getPassword();
/**
* 返回认证用户的用户名
*/
String getUsername();
/**
* 账户是否未过期
*/
boolean isAccountNonExpired();
/**
* 账号是否为解锁状态
*/
boolean isAccountNonLocked();
/**
* 账号的凭证是否未过期
*/
boolean isCredentialsNonExpired();
/**
* 账户是否启用
*/
boolean isEnabled();
}
我们需要将数据库查询到的用户信息,封装为一个 UserDetails
对象并交给 Spring Security,因此我们还需要借助另外一个类:UserDetailsService
Spring Security 提供了 UserDetailsService 接口,该接口中仅有一个方法 loadUserByUsername
,专门用于基于用户名从数据库中查询用户信息,可以将此方法类比与我们平时自己写登录逻辑的基于用户名查找用户功能,它的主要继承体系如下(只挑了几个比较重要的类)
- InMemoryUserDetailsManager
- 在内存中管理用户信息,可以通过该类构建用户需要被认证的对象直接存入内存中
- JdbcDaoImpl
- 通过
jdbc
操作数据库中的用户与权限信息,相关数据表由 Spring Security 提供
- 通过
- 自定义 UserDetailsService
- 如果想要自己实现从数据库获取用户的功能,即可以自行实现该 Service 的
loadUserByUsername
方法即可
- 如果想要自己实现从数据库获取用户的功能,即可以自行实现该 Service 的
5.3.2 自定义UserDeatilsService
package cn.wolfcode.service.impl;
import cn.wolfcode.domain.LoginUser;
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.Collections;
@Service
public class UserServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 模拟从数据库中查询数据
String admin = "admin";
String wolfcode = "wolfcode";
UserDetails user = null;
if (username.equals(admin)) {
// 返回的对象必须是 UserDetails 接口的实现类
// 1. 通过 Spring Security 内置的 User 类构造用户对象,将数据库查询到的数据封装进去
// 2. 自己创建一个类,实现 UserDetails 接口,将数据库中查询出的用户封装到该对象上
return User.withUsername(admin).password("{noop}111111").authorities("admin").build();
} else if (username.equals(wolfcode)) {
// 数据库查询到的用户信息
// return new LoginUser(wolfcode, "{noop}111111", Collections.singletonList("boss"));
return User.withUsername(wolfcode).password("{noop}111111").authorities("boss").build();
}
// 如果查询不到,提示用户名或密码错误
throw new UsernameNotFoundException("该用户不存在!");
}
}
5.3.3自定义UserDetails对象
package cn.wolfcode.domain;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.CollectionUtils;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
public class LoginUser implements UserDetails {
private String username;
private String password;
private List<? extends GrantedAuthority> authorities;
public LoginUser(String username, String password, List<String> permissions) {
this.username = username;
this.password = password;
if (!CollectionUtils.isEmpty(permissions)) {
this.authorities = permissions.stream()
.map(SimpleGrantedAuthority::new) // 将字符串转换为 SimpleGrantedAuthority 对象
.collect(Collectors.toList());
}
}
/**
* 获取授权的信息
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
/**
* @return 账号是否未过期,过期的账号无法使用
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* @return 账号是否解锁,被锁定的账号无法登录
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* @return 凭证是否已没有过期,过期的账号无法通过认证
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* @return 是否可用,被禁用的用户无法登录
*/
@Override
public boolean isEnabled() {
return true;
}
}
5.4 数据库密码加密
过去的项目中,我们数据库中的密码一直是明文显示的,如果万一某天数据库不小心泄露,则意味着所有用户的密码都将被泄露,因此数据库存储密码加密是非常有必要的,在 Spring Security 中提供了许多种数据库加密方案供我们选择,只需要创建其中一个加密对象,且交给 Spring 容器管理即可。
5.4.1 Bcrypt加密算法
Spring Security 默认推荐的加密方式为 BCryptPasswordEncoder
,我们通过以下代码来进行加密测试
package cn.wolfcode;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class BCryptTest {
@Test
public void encryptTest() {
// BCrypt 加密编码器
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
// 前端输入的密码
String password = "111111";
// 加密
String encode = encoder.encode(password);
System.out.println("加密后的密码 = " + encode);
// 第一次加密:$2a$10$Pd7KXuXGuPwe8KCUZivVHe.oMSGWGZ73AoJdjJihiKNSI0QH8tLXO
// 第二次加密:$2a$10$cGuMM49hUxKu7Nn483.wdukYtsPZ7Tj2LMt2X9.oF70bk8vh4.JSS
// 第三次加密:$2a$10$hYL6sLdQpXfGlBOXU5CF6Ozi8mJE/OzWL6W6NQF3AiJACVoEM0agG
// 同一个密码,多次加密生成的值是不一样的,并且每次不一样的值比较的结果还都是 true
System.out.println(encoder.matches(password, "$2a$10$Pd7KXuXGuPwe8KCUZivVHe.oMSGWGZ73AoJdjJihiKNSI0QH8tLXO"));
System.out.println(encoder.matches(password, "$2a$10$cGuMM49hUxKu7Nn483.wdukYtsPZ7Tj2LMt2X9.oF70bk8vh4.JSS"));
System.out.println(encoder.matches(password, "$2a$10$hYL6sLdQpXfGlBOXU5CF6Ozi8mJE/OzWL6W6NQF3AiJACVoEM0agG"));
}
}
Bcrypt 加密算法的原理如上图所示,生成的加密字符串有四部分组成,分别是:
- 第一部分为 hash 算法唯一标识,这里的 2a 表示 Bcrypt
- 第二部分的 10 表示 hash 次数,默认为 2 的 10 次方(1024) 次 hash,该值越大加密效率就会越低
- 第三部分叫做盐,这是一个随机值,可以理解为炒菜时撒盐,盐就是真正导致每一次生成的密文都不同,且又能校验通过的原因
- 第四部分是由 “盐 + 密码” 进行 2 的 10 次方(1024) 次 hash 运算,最后经过 base64 编码得来的
5.4.2 数据库加密配置
创建 WebSecurityConfig
配置类,继承 WebSecurityConfigurerAdapter
,并添加密码加密配置
package cn.wolfcode.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
修改 UserServiceImpl
的代码
package cn.wolfcode.service.impl;
import cn.wolfcode.domain.LoginUser;
import org.springframework.beans.factory.annotation.Autowired;
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.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Collections;
@Service
public class UserServiceImpl implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 模拟从数据库中查询数据
String admin = "admin";
String wolfcode = "wolfcode";
// 模拟数据库查询出来的密码就是加密的(正常情况下应该在保存用户时,就对用户密码加密)
String password = "$2a$10$hYL6sLdQpXfGlBOXU5CF6Ozi8mJE/OzWL6W6NQF3AiJACVoEM0agG";
if (username.equals(admin)) {
// 返回的对象必须是 UserDetails 接口的实现类
// 1. 通过 Spring Security 内置的 User 类构造用户对象,将数据库查询到的数据封装进去
// 2. 自己创建一个类,实现 UserDetails 接口,将数据库中查询出的用户封装到该对象上
return User.withUsername(admin).password(password).authorities("admin").build();
} else if (username.equals(wolfcode)) {
// 数据库查询到的用户信息
return new LoginUser(wolfcode, password, Collections.singletonList("boss"));
}
// 如果查询不到,提示用户名或密码错误
throw new UsernameNotFoundException("该用户不存在!");
}
}
当配置了密码加密后,Spring Security 会自动将前端传入的密码加密,并与数据库加密的密码进行匹配,比较密码是否正确
5.5 记住我
Spring Security 为我们提供了记住账户功能,可以将当前用户存入 cookie,并且在指定时间内用户都不需要再重复登录系统,配置文件中增加 rememberMe
相关配置
package cn.wolfcode.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userService;
// 省略 passwordEncoder
@Override
protected void configure(HttpSecurity http) throws Exception {
// ...... 其他的如自定义页面相同配置,此处省略
// 开启记住我功能,即使浏览器关闭再次访问也不需要登录
http.rememberMe()
.tokenValiditySeconds(60 * 60 * 24) // 设置一天內有效
.userDetailsService(userService); // rememberMeService 会调用 userService.loadUserByUsername 获取用户信息存入 cookie
}
}
修改登录页面
<%@ page contentType="text/html; charset=UTF-8" %>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录页面</title>
</head>
<body>
<h1>自定义登录页</h1>
<form action="/login" method="post">
<span style="color: red;">${SPRING_SECURITY_LAST_EXCEPTION.message}</span> <br>
用户名:<input type="text" name="name"> <br>
密 码:<input type="password" name="pass"> <br>
记住我:<input type="checkbox" name="rememberMe"> <br>
<button type="submit">登录</button>
</form>
</body>
</html>
5.6 CSRF防护
CSRF(Cross-site request forgery)中文名称:跨站请求伪造
由于许多网站的用户信息是存储在 cookie 或者 session 中的,那么此时当你在 A 网站登录后,访问 B 网站时,如果 B 网站隐藏了一段恶意代码,该代码自动向 A 网站发起一个恶意请求,就会导致浏览器误以为该请求是用户自己发送的,那么浏览器会将 A 网站的 cookie 携带在请求中,发送到服务端,此时服务端无法确认请求是真实用户发的,还是攻击者发的,因此用户账户被攻击了。
解决方案:
- HTTP 协议头的 Referer 字段:每次浏览器发起请求时,会自动携带当前请求发起的来源网站域名,服务端在处理时只要验证 Referer 字段是否是服务端自己认可的域名即可。
- 令牌验证:访问关键页面的操作时,服务端响应一个 token 给客户端,客户端下一次提交时将该 token 携带过去,服务端处理业务前验证 token 是否正确
在 Spring Security 中采用了 token 的方式,利用 CsrfFilter 过滤器来返回和验证 token,只有在 token 有效时,才会进行后续处理,否则请求将会被直接拦截下来。
默认 CSRF 防护就是开启的,将配置类中 http.csrf().disable();
关闭 CSRF 的配置注释掉即可重新开启
修改前端页面
<%@ page contentType="text/html; charset=UTF-8" %>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录页面</title>
</head>
<body>
<h1>自定义登录页</h1>
<form action="/login" method="post">
<span style="color: red;">${SPRING_SECURITY_LAST_EXCEPTION.message}</span> <br>
<%-- 从 session 中的 _csrf 对象中获取服务端生成的 token,提交的时候 CsrfFilter 会进行校验 --%>
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}">
用户名:<input type="text" name="name"> <br>
密 码:<input type="password" name="pass"> <br>
记住我:<input type="checkbox" name="rememberMe"> <br>
<button type="submit">登录</button>
</form>
</body>
</html>
5.7 获取用户认证信息
登录成功后,有时候需要获取到认证后的用户信息,那么此时我们需要接触到两个新的对象 SecurityContextHolder
和 Authentication
,像 Servlet 中有 ServletContext 一样,Security 中也有一个 SecurityContext,该对象封装了 Security 应用的上下文数据,并且改对象可以通过工具类 SecurityContextHolder 访问到,因此让我们使用 Security 可以变得更简单一些。
Authentication 代表的是认证信息对象,当一个用户认证通过以后,就会被封装成该接口的实现类,存入 SecurityContext 中,该对象中包含用户认证后的所有信息,因此我们在开发中经常使用到它。
/**
*
*/
public interface Authentication extends Principal, Serializable {
/**
* 当前用户所拥有的权限列表,每一个权限对象都必须是 GrantedAuthority 的实现类,不过大部分情况下都只需要保存一个字符串
*/
Collection<? extends GrantedAuthority> getAuthorities();
/**
* 凭证信息,在用户名密码认证场景下,等同于用户密码,在用户认证成功后为了保障安全性,这个值会被删除
*/
Object getCredentials();
/**
* 认证用户的详细信息,通常为 WebAuthenticationDetails 接口的实现类,保存了用户的 ip、sessionId 等信息
*/
Object getDetails();
/**
* 主体身份信息,在认证通过后通常就是 UserDetails 接口的实现类对象,可以理解为 UserDetailsService 所返回的那个对象
*/
Object getPrincipal();
/**
* 是否已认证,只有返回 true 才表示当前用户是已经通过认证了的
*/
boolean isAuthenticated();
/**
* 设置是否已认证属性
*/
void setAuthenticated(boolean var1) throws IllegalArgumentException;
}
Spring Security 给我们提供了很多种获取用户信息的方式,可以在 Controller 的方法中直接注入 Authentication
对象,甚至可以通过请求对象获取用户凭证对象,但是我们推荐的还是通过 SecurityContextHolder
工具类来获取,将其进行再次封装为工具类,之后便可以直接使用
package cn.wolfcode.utils;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
abstract public class SecurityUtils {
/**
* 获取用户
**/
public static UserDetails getLoginUser() {
try {
return (UserDetails) getAuthentication().getPrincipal();
} catch (Exception e) {
throw new AccountExpiredException("获取用户信息异常");
}
}
/**
* 获取Authentication
*/
public static Authentication getAuthentication() {
return SecurityContextHolder.getContext().getAuthentication();
}
/**
* 生成BCryptPasswordEncoder密码
*
* @param password 密码
* @return 加密字符串
*/
public static String encryptPassword(String password) {
return new BCryptPasswordEncoder().encode(password);
}
}
修改首页 Controller
package cn.wolfcode.controller;
import cn.wolfcode.utils.SecurityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class IndexController {
@GetMapping
public String hello() {
UserDetails user = SecurityUtils.getLoginUser();
String username = user.getUsername();
return "<h1>欢迎登录系统:" + username + "</h1>";
}
}
5.8 基于RESTFul的认证
以上案例中,只能用于前后端不分离的场景,而当我们开发前后端分离项目,需要后端返回的是数据而不是页面是就会存在一定问题了,其实我们可以通过自己提供登录接口,以及自定义登录失败处理器的方案来实现返回数据的方式
5.8.1封装工具类
增加 JsonUtil
和 ServletUtils
两个工具类,便于后续返回 json 数据的操作
package cn.wolfcode.utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class ServletUtils {
private static final Logger log = LoggerFactory.getLogger(ServletUtils.class);
public static void renderString(HttpServletResponse response, Object obj) throws IOException {
renderString(response, JsonUtils.toJson(obj));
}
public static void renderString(HttpServletResponse response, String json) throws IOException {
try {
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(json);
} catch (Exception e) {
log.error("响应 json 数据失败", e);
}
}
}
package cn.wolfcode.utils;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.List;
import java.util.Map;
/**
* Jackson工具类
*/
public final class JsonUtils {
private static final Logger log = LoggerFactory.getLogger(JsonUtils.class);
private static ObjectMapper MAPPER = new ObjectMapper();
// 日起格式化
private static final String STANDARD_FORMAT = "yyyy-MM-dd HH:mm:ss";
static {
//对象的所有字段全部列入
MAPPER.setSerializationInclusion(JsonInclude.Include.ALWAYS);
//取消默认转换timestamps形式
MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
//忽略空Bean转json的错误
MAPPER.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
//所有的日期格式都统一为以下的样式,即yyyy-MM-dd HH:mm:ss
MAPPER.setDateFormat(new SimpleDateFormat(STANDARD_FORMAT));
//忽略 在json字符串中存在,但是在java对象中不存在对应属性的情况。防止错误
MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
private JsonUtils() {
}
/**
* 对象转Json格式字符串
*
* @param object 对象
* @return Json格式字符串
*/
public static String toJson(Object object) {
if (object == null) {
return null;
}
try {
return object instanceof String ? (String) object : MAPPER.writeValueAsString(object);
} catch (Exception e) {
log.error("method=toJson() is convert error, errorMsg:{}", e.getMessage(), e);
return null;
}
}
/**
* Object TO Json String 字符串输出(输出空字符)
*
* @param object 对象
* @return Json格式字符串
*/
public static String toJsonEmpty(Object object) {
if (object == null) {
return null;
}
try {
MAPPER.getSerializerProvider().setNullValueSerializer(new JsonSerializer<Object>() {
@Override
public void serialize(Object param, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
//设置返回null转为 空字符串""
jsonGenerator.writeString("");
}
});
return MAPPER.writeValueAsString(object);
} catch (Exception e) {
log.error("method=toJsonEmpty() is convert error, errorMsg:{}", e.getMessage(), e);
}
return null;
}
/**
* Json 转为 Jave Bean
*
* @param text json字符串
* @param clazz 对象类型class
* @param <T> 对象类型
* @return 对象类型
*/
public static <T> T fromJSON(String text, Class<T> clazz) {
if (!StringUtils.hasLength(text) || clazz == null) {
return null;
}
try {
return MAPPER.readValue(text, clazz);
} catch (Exception e) {
log.error("method=toBean() is convert error, errorMsg:{}", e.getMessage(), e);
}
return null;
}
/**
* Json 转为 Map
*
* @param text json
* @param <K> key
* @param <V> value
* @return map
*/
public static <K, V> Map<K, V> toMap(String text) {
try {
if (StringUtils.hasText(text)) {
return null;
}
return toObject(text, new TypeReference<Map<K, V>>() {
});
} catch (Exception e) {
log.error("method=toMap() is convert error, errorMsg:{}", e.getMessage(), e);
}
return null;
}
/**
* Json 转 List, Class 集合中泛型的类型,非集合本身
*
* @param text json
* @param <T> 对象类型
* @return List
*/
public static <T> List<T> toList(String text) {
if (StringUtils.hasText(text)) {
return null;
}
try {
return toObject(text, new TypeReference<List<T>>() {
});
} catch (Exception e) {
log.error("method=toList() is convert error, errorMsg:{}", e.getMessage(), e);
}
return null;
}
/**
* Json 转 Object
*/
/**
* @param text json
* @param typeReference TypeReference
* @param <T> 类型
* @return T
*/
public static <T> T toObject(String text, TypeReference<T> typeReference) {
try {
if (StringUtils.hasText(text) || typeReference == null) {
return null;
}
return (T) (typeReference.getType().equals(String.class) ? text : MAPPER.readValue(text, typeReference));
} catch (Exception e) {
log.error("method=toObject() is convert error, errorMsg:{}", e.getMessage(), e);
}
return null;
}
}
5.8.2 添加登出/认证异常处理器
添加 security.handler
包,在里面加入下面两个类
package cn.wolfcode.security.handler;
import cn.wolfcode.utils.JsonUtils;
import cn.wolfcode.utils.ServletUtils;
import cn.wolfcode.vo.AjaxResult;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 自定义登出成功返回 json
*/
@Configuration
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler {
/**
* 退出处理
*/
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {
// 响应 json
ServletUtils.renderString(response, JsonUtils.toJson(AjaxResult.error(HttpStatus.OK.value(), "退出成功")));
}
}
package cn.wolfcode.security.handler;
import cn.wolfcode.utils.JsonUtils;
import cn.wolfcode.utils.ServletUtils;
import cn.wolfcode.vo.AjaxResult;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.Serializable;
/**
* 认证失败处理类 返回未授权
*/
@Component
public class UnauthenticatedEntryPointImpl implements AuthenticationEntryPoint, Serializable {
private static final long serialVersionUID = 1L;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException {
int code = HttpStatus.UNAUTHORIZED.value();
String msg = String.format("请求访问:%s,认证失败,无法访问系统资源", request.getRequestURI());
ServletUtils.renderString(response, JsonUtils.toJson(AjaxResult.error(code, msg)));
}
}
5.8.3 实现TokenUtils工具类
增加 TokenUtils 工具类, 以及一个内部 Map 用于维护当前已经登录的用户对象
package cn.wolfcode.utils;
import cn.wolfcode.domain.LoginUser;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
abstract public class TokenUtils {
/**
* 用于存储当期登录成功地用户信息
*/
private static final Map<String, LoginUser> MOCK_DB = new HashMap<>();
public static String randomToken() {
return UUID.randomUUID().toString().replaceAll("-", "");
}
public static void setUser(String token, LoginUser user) {
MOCK_DB.put(token, user);
}
public static LoginUser getUser(String token) {
return MOCK_DB.get(token);
}
public static void remove(String token) {
MOCK_DB.remove(token);
}
}
5.8.4、实现登录接口
package cn.wolfcode.controller;
import cn.wolfcode.domain.LoginUser;
import cn.wolfcode.utils.SecurityUtils;
import cn.wolfcode.utils.TokenUtils;
import cn.wolfcode.vo.JsonResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
public class LoginController {
@Autowired
private AuthenticationManager authenticationManager;
@PostMapping("/login")
public JsonResult<?> login(String username, String password) {
try {
// 调用 security 进行登录校验
// AuthenticationManager => 认证管理器, Security 认证流程的管理类
Authentication authentication = new UsernamePasswordAuthenticationToken(username, password);
// 进行真正的认证逻辑, 其中有一个步骤会调用 UserDetailsService.loadUserByUsername 的方法, 获取用户对象
// 最终判断密码是否正确, 返回成功认证信息
Authentication authenticate = authenticationManager.authenticate(authentication);
// 将当前登录成功地用户信息保存起来
SecurityUtils.setAuthentication(authenticate);
// 获取认证后的对象
LoginUser user = (LoginUser) authenticate.getPrincipal();
// 生成并返回 token
String token = TokenUtils.randomToken();
user.setToken(token);
// 将登录成功地用户存入 缓存
TokenUtils.setUser(token, user);
// 认证通过
return JsonResult.success(token);
} catch (BadCredentialsException e) {
log.error("[用户登录] 用户密码错误", e);
} catch (Exception e) {
log.error("[用户登录] 登录失败", e);
}
return JsonResult.failed("用户名或密码错误");
}
}
5.8.5 增加token验证过滤器
由于 Security 默认的用户认证拦截, 是从 SecurityContext 中获取用户的认证信息, 判断是否已认证, 而 SecurityContext 又与 Session 强绑定的, 因此一旦 sessionid 丢失以后, 就无法再判断用户是否已登录, 统一都当做未登录处理.
自定义过滤器, 在过滤器中获取到请求头中的 token 数据, 再基于 token 从 map 中获取用户数据, 并且将获取到的数据直接重新存入 SecurityContext 中, 之后进行用户认证拦截时, 即使之前的 session 访问不到了, 也是有认证对象的.
package cn.wolfcode.filter;
import cn.wolfcode.domain.LoginUser;
import cn.wolfcode.utils.JsonUtils;
import cn.wolfcode.utils.SecurityUtils;
import cn.wolfcode.utils.ServletUtils;
import cn.wolfcode.utils.TokenUtils;
import cn.wolfcode.vo.JsonResult;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class VerifyTokenFilter extends HttpFilter {
@Override
protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// 0. 判断是否是登录请求, 如果是登录请求, 直接放行
String uri = request.getRequestURI();
if ("/login".equals(uri) || "/logout".equals(uri)) {
// 如果是登录, 就直接放行
chain.doFilter(request, response);
return;
}
// 1. 获取到 token
String token = request.getHeader("X-Token");
if (StringUtils.hasText(token)) {
// 2. 验证 token 是否正确
LoginUser user = TokenUtils.getUser(token);
if (user != null) {
// 3. 告诉 Security 用户是有登录的
Authentication authentication = new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities());
SecurityUtils.setAuthentication(authentication);
// 4. 如果正确, 就放行
chain.doFilter(request, response);
return;
}
}
// 5. 如果错误, 就拦截并响应认证失败
JsonResult<?> result = JsonResult.failed(HttpStatus.UNAUTHORIZED.value(), "请登录后再访问");
String json = JsonUtils.toJson(result);
ServletUtils.renderString(response, json);
}
}
5.8.6 修改配置
最后,修改配置将上面的内容配置给 Security 即可
package cn.wolfcode.config;
import cn.wolfcode.filter.VerifyTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
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.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
/**
* Security 的配置类
* 1. 实现 WebSecurityConfigurerAdapter 适配器
* 2. 重写 configure(HttpSecurity http) 来对 Security 进行配置
*/
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userService;
@Autowired
private LogoutSuccessHandler logoutSuccessHandler;
@Autowired
private AuthenticationEntryPoint unauthenticatedEntryPoint;
@Autowired
private VerifyTokenFilter verifyTokenFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// HttpSecurity: Security 过滤器链的构造对象, 几乎所有配置都在这里面完成
// 关闭 CSRF 防护
http.csrf().disable();
// 配置某些 url 忽略 csrf
// http.csrf().ignoringAntMatchers("/login.jsp");
// 0. 访问控制
http.authorizeRequests()
.antMatchers("/login.jsp", "/login").anonymous() // 匿名时访问, 已经登录后不可访问
.antMatchers("/static/**").permitAll() // 对匹配的资源直接放行, 无论是否认证
.anyRequest().authenticated(); // 任意请求都需要认证
// 1. 配置登录页面 => 表单请求
/*http.formLogin()
.loginPage("/login.jsp") // 配置自己的登录页面
.loginProcessingUrl("/login") // 提交登录表单时, 去到 UsernamePasswordAuthenticationFilter 处理
// 判断用户名密码是否正确
.defaultSuccessUrl("/") // 默认登录成功以后去的页面
.usernameParameter("name") // 提交表单时的用户名名称
.passwordParameter("pass"); // 提交表单时的密码名称
*/
// 2. 配置记住我的功能
http.rememberMe() // 开启记住用户功能
.rememberMeParameter("rememberMe") // 配置提交表单时的属性名
.tokenValiditySeconds(60 * 60 * 24) // 设置存活时间
.userDetailsService(userService); // 在用户第一次登录时会调用该 service 的查询方法, 获取用户对象并进行编码, 存储到 cookie 中
// 3. 登出配置
http.logout().logoutUrl("/logout") // 访问哪个路径进行注销
.logoutSuccessHandler(logoutSuccessHandler); // 注销成功后如何处理
// 4. 异常过滤器配置
http.exceptionHandling()
.authenticationEntryPoint(unauthenticatedEntryPoint);
// 5. 添加自定义的过滤器
// 将自定义的过滤器添加到 用户认证过滤器之前
http.addFilterBefore(verifyTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
}
5.9 权限匹配规则
通过上面的案例,我们对于在 Spring Security 中实现认证已经没有太大问题了,那么接下来我们便来学习一下授权,基础的授权配置与认证类似,也是通过配置中的 antMatchers
方法来为指定资源配置所需要的权限,简单的配置如下
5.9.1 简单授权配置
@Override
protected void configure(HttpSecurity http) throws Exception {
// ......
http.authorizeRequests()
.antMatchers("/departments/**").hasAuthority("admin") // 配置所有以指定开头的路径需要拥有指定权限
.antMatchers("/employees/**").hasAnyAuthority("admin", "boss") // 有 admin | boss 任意一个权限
......;
}
加上配置并重启后已经可以看到权限拦截的效果了,但是返回的请求是 403 的异常页面,而不是符合我们需要的标准 json 数据,因此可以再增加一个权限拒绝的处理器,来处理权限拒绝后的请求返回 json 数据
5.9.2 访问拒绝处理器
增加访问拒绝处理,返回json字符串
package cn.wolfcode.security.handler;
import cn.wolfcode.utils.JsonUtils;
import cn.wolfcode.utils.SecurityUtils;
import cn.wolfcode.utils.ServletUtils;
import cn.wolfcode.vo.JsonResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 访问拒绝拦截器, 当没有权限时, 返回 json 字符串
*/
@Slf4j
@Component
public class UnauthorizedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 获取当前用户
UserDetails user = SecurityUtils.getLoginUser();
log.warn("[访问拒绝] 用户{}想要访问{}资源, 鉴权失败, 拦截该请求....", user.getUsername(), request.getRequestURI());
// 状态码 403
int status = HttpStatus.FORBIDDEN.value();
String json = JsonUtils.toJson(JsonResult.failed(status, "没有权限访问"));
ServletUtils.renderString(response, json);
}
}
修改配置,增加访问拒绝处理器配置
package cn.wolfcode.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.access.AccessDeniedHandler;
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// ......
@Autowired
private AccessDeniedHandler accessDeniedHandler;
// ......
@Override
protected void configure(HttpSecurity http) throws Exception {
// ......
http.exceptionHandling()
// 鉴权失败异常处理,返回 json 字符串
.accessDeniedHandler(accessDeniedHandler);
// ......
}
}
5.9.3 权限配置规则详解
路径拦截匹配:与认证配置一样,可以通过 antMachers()
方法进行规则匹配,为对应匹配到的资源赋予对应的访问规则
匹配通配符:
**:两颗星代表匹配任意层级,例如 /static/** 规则会匹配到任意以 /static 开头的资源
*:一颗星代表任意长度的任意字符串,通常用于资源名称匹配或后缀名匹配
?:问号匹配单个长度的任意字符,通常用于匹配只有一个字符差别的资源
需要注意的是,antMachers()
方法是存在先后顺序的,配置在前面的如果先匹配上了,后面的规则就不会再匹配了,因此一定要记得将更精确的匹配放在更前面。
安全策略:
-
permitAll:匹配到的所有资源都可以随意访问,无论有没有认证或权限
-
denyAll:匹配到的资源全都无法访问
-
anonymous:只有未登录时才可访问,已登录则无法访问
-
hasAuthority:是否拥有指定权限
-
hasRole:是否拥有对应的角色,比较时默认会追加 ROLE_ 作为前缀,因此后台查询角色时需要注意
-
access:支持 Spring EL 表达式,可以通过表达式访问上述所有方法
5.9.4 方法权限拦截
以上的配置都是针对 web 资源进行手动的配置,存在许多的不便利性,那么接下来我们就来了解下 Spring Security 给我们提供的基于注解的方法访问控制,想要实现方法访问控制,我们需要在配置类上面贴上 @EnableGlobalMethodSecurity
注解
@EnableGlobalMethodSecurity 注解的三个属性:
prePostEnabled
-
开启此属性,将支持下列注解,使用起来类似 access 方法,支持 Sp EL 表达式
- @PreAuthorize:前置拦截,在方法执行前执行
- @PostAuthorize:后置拦截,在方法执行后执行,可以通过
returnObject
变量拿到方法执行后的返回值 - @PreFilter:对方法入参进行过滤,满足条件的参数才能传入方法
- @PostFilter:对返回值进行过滤
-
securedEnabled
- 开启此属性,将支持 @Secured(“ROLE_admin”) 注解
- 不支持 Sp EL 表达式,默认只支持基于角色的拦截,且角色名必须加上前缀 ROLE_,不可取消
-
jsr250Enabled
- 开启此属性,将支持以下注解
- @RolesAllowed(“admin”):判断是否拥有某一个角色(只支持角色),可以省略 ROLE_ 前缀
- @DenyAll:拒绝所有请求
- @PermitAll:允许所有访问
先把原先配置类中的权限拦截配置删除,再更新一下员工 Controller
package cn.wolfcode.controller;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.security.RolesAllowed;
@RestController
@RequestMapping("/employees")
public class EmployeeController {
@PreAuthorize("hasAuthority('admin')")
@GetMapping
public String list() {
return "<h1>员工管理列表</h1>";
}
@PreAuthorize("hasRole('boss')")
@GetMapping("/save")
public String save() {
return "<h1>新增员工</h1>";
}
@Secured("ROLE_boss")
@GetMapping("/update")
public String update() {
return "<h1>更新员工</h1>";
}
@RolesAllowed({"admin", "other"})
@GetMapping("/delete")
public String delete() {
return "<h1>删除员工</h1>";
}
}
修改一下 UserServiceImpl 中的用户角色配置
if (username.equals(admin)) {
// 返回的对象必须是 UserDetails 接口的实现类
// 1. 通过 Spring Security 内置的 User 类构造用户对象,将数据库查询到的数据封装进去
// 2. 自己创建一个类,实现 UserDetails 接口,将数据库中查询出的用户封装到该对象上
return User.withUsername(admin).password(password).authorities("admin", "ROLE_other").build();
} else if (username.equals(wolfcode)) {
// 数据库查询到的用户信息
return new LoginUser(wolfcode, password, Collections.singletonList("ROLE_boss"));
}
六 附录:HttpSecurity 配置项
方法 | 说明 |
---|---|
openidLogin() | 用于基于 OpenId 的验证 |
headers() | 将安全标头添加到响应 |
cors() | 配置跨域资源共享( CORS ) |
sessionManagement() | 允许配置会话管理 |
portMapper() | 向到 HTTPS 或者从 HTTPS 重定向到 HTTP。默认情况下,Spring Security 使用一个 PortMapperImpl 映射 HTTP 端口8080到 HTTPS 端口8443,HTTP 端口80到 HTTPS 端口 443 |
jee() | 配置基于容器的预认证。 在这种情况下,认证由 Servlet 容器管理 |
x509() | 配置基于 x509 的认证 |
rememberMe() | 允许配置“记住我”的验证 |
authorizeRequests() | 允许基于使用 HttpServletRequest 限制访问 |
requestCache() | 允许配置请求缓存 |
exceptionHandling() | 允许配置错误处理 |
securityContext() | 在 HttpServletRequests 之间的 SecurityContextHolder 上设置 SecurityContext 的管理。 当使用 WebSecurityConfifigurerAdapter 时,这将 |
servletApi() | 将 HttpServletRequest 方法与在其上找到的值集成到 SecurityContext 中。 当使用 WebSecurityConfifigurerAdapter 时,这将自动应用 |
csrf() | 添加 CSRF 支持,使用 WebSecurityConfifigurerAdapter 时,默认启用 |
logout() | 添加退出登录支持。当使用 WebSecurityConfifigurerAdapter 时,这将自动应用。默认情况是,访问 URL ”/logout”,使 HTTP Session 无效来 |
anonymous() | 允许配置匿名用户的表示方法。 当与 WebSecurityConfifigurerAdapter 结合使用时,这将自动应用。 默认情况下,匿名用户将使用 |
formLogin() | 指定支持基于表单的身份验证。如果未指定 FormLoginConfifigurer#loginPage(String),则将生成默认登录页面 |
oauth2Login() | 根据外部 OAuth 2.0 或 OpenID Connect 1.0 提供程序配置身份验证 |
requiresChannel() | 配置通道安全。为了使该配置有用,必须提供至少一个到所需信道的映射 |
httpBasic() | 配置 Http Basic 验证 |
addFilterAt() | 允许配置错误处理 |
exceptionHandling() | 在指定的 Filter 类的位置添加过滤器 |
RBAC 改造
集成 Spring Security
1 导入依赖
将 SpringBoot 版本更新为 2.6.13
<!-- Spring Security 的依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
2 创建配置
创建 Security
配置类实现 WebSecurityConfigurerAdapter
类
重写 configure(HttpSecurity http)
方法
package cn.wolfcode.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 1. 关闭 CSRF
http.csrf().disable();
// 2. 权限配置
http.authorizeRequests()
.antMatchers("/api/code", "/api/login").permitAll() // 不需要权限的资源
.anyRequest().authenticated(); // 其他接口都需要认证
}
}
认证 Authentication
1 创建EmployeeUserDetails 对象
package cn.wolfcode.vo;
import cn.wolfcode.domain.Employee;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.CollectionUtils;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
public class EmployeeUserDetails implements UserDetails {
private Employee employee;
private List<GrantedAuthority> authorities; // 给 Security 使用的
private List<String> expressions; // 给我们自己用的,用于保存到 redis
public EmployeeUserDetails(Employee employee, List<String> expressions) {
this.employee = employee;
if (!CollectionUtils.isEmpty(expressions)) {
this.expressions = expressions;
this.authorities = expressions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
}
}
public Employee getEmployee() {
return employee;
}
public List<String> getExpressions() {
return expressions;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return employee.getPassword();
}
@Override
public String getUsername() {
return employee.getName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
2 实现UserDetailsService
员工 Service
实现类实现 UserDetailsService
接口
实现基于用户名查询用户与权限信息
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Employee employee = employeeMapper.selectByUsername(username);
if (employee == null) {
throw new UsernameNotFoundException("用户名或密码错误");
}
// 查询用户拥有的权限
List<String> expressions = permissionMapper.selectExpressionsByEmployeeId(employee.getId());
return new EmployeeUserDetails(employee, expressions);
}
3 实现认证接口
配置认证管理器对象和密码加密对象
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
// Spring Security 默认的密码加密器,当前使用加密的算法为 bcrypt
return new BCryptPasswordEncoder();
}
讲数据库的密码修改为加密后的密文
对之前登录接口改造
@Override
public Employee login(LoginInfoVO loginInfoVO) {
// 校验参数
if (!StringUtils.hasLength(loginInfoVO.getUsername())) {
throw new BusinessException("用户名不能为空");
}
if (!StringUtils.hasLength(loginInfoVO.getPassword())) {
throw new BusinessException("密码不能为空");
}
if (!StringUtils.hasLength(loginInfoVO.getUuid())) {
throw new BusinessException("非法操作");
}
if (!StringUtils.hasLength(loginInfoVO.getCode())) {
throw new BusinessException("验证码不能为空");
}
// 比对验证码
// 获取用户传递过来验证码
String code = loginInfoVO.getCode();
// 获取之前 Redis 存入生成验证码
String codeInRedis = redisService.get(loginInfoVO.getUuid());
if (!VerifyCodeUtil.verification(code, codeInRedis, true)) {
throw new BusinessException("验证码错误");
}
// 基于 Security 进行认证
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginInfoVO.getUsername(), loginInfoVO.getPassword());
Authentication authenticate = authenticationManager.authenticate(token);
// 将认证成功的对象存入上下文
SecurityUtils.setAuthentication(authenticate);
// 获取认证成功的员工对象
EmployeeUserDetails details = (EmployeeUserDetails) authenticate.getPrincipal();
Employee employee = details.getEmployee();
// 登录成功
// 往 Redis 存入身份数据 Map<String, String>
// key 随机的目的是为了安全
String randomKey = KeyUtil.getRandomKey();
redisService.set(randomKey, JSON.toJSONString(employee), RedisService.THIRTY_MINUTES);
employee.setPassword(randomKey);
// 往 Redis 存权限数据
if (!employee.isAdmin()) {
List<String> expressions = details.getExpressions();
redisService.set(RedisService.getEmployeePermissionExpressionKey(employee.getId()),
JSON.toJSONString(expressions),
RedisService.THIRTY_MINUTES);
}
return employee;
}
4 实现Token检查过滤器
package cn.wolfcode.security.filter;
import cn.wolfcode.domain.Employee;
import cn.wolfcode.redis.RedisService;
import cn.wolfcode.utils.SecurityUtils;
import cn.wolfcode.utils.ServletUtils;
import cn.wolfcode.vo.EmployeeUserDetails;
import cn.wolfcode.vo.Result;
import com.alibaba.fastjson.JSON;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
@Component
public class VerifyTokenFilter extends HttpFilter {
@Autowired
private RedisService redisService;
@Override
protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// 对预请求放行
String method = request.getMethod();
String uri = request.getRequestURI();
if ("OPTIONS".equals(method)) {
// 为预请求设置登录成功用户
Employee employee = new Employee();
employee.setAdmin(true);
SecurityUtils.setAuthentication(new UsernamePasswordAuthenticationToken(new EmployeeUserDetails(employee, Collections.emptyList()), method, Collections.emptyList()));
chain.doFilter(request, response);
return;
}
if ("/api/login".equals(uri) || "/api/code".equals(uri)) {
chain.doFilter(request, response);
return;
}
// 1. 获取 token,判断是否为空,如果为空返回认证失败
String token = request.getHeader("token");
Result result = Result.error("用户认证失败,请登录后再访问");
if (!StringUtils.hasLength(token)) {
ServletUtils.renderString(response, result);
return;
}
// 2. 从 redis 中获取用户数据,如果查询不到,返回认证失败
String json = redisService.get(token);
if (!StringUtils.hasLength(json)) {
ServletUtils.renderString(response, result);
return;
}
Employee employee = JSON.parseObject(json, Employee.class);
// 3. 从 redis 中查询权限信息
String jsonArray = redisService.get(RedisService.getEmployeePermissionExpressionKey(employee.getId()));
List<String> expressions = JSON.parseArray(jsonArray, String.class);
// 4. 重新封装认证对象,将认证对象绑定到上下文
EmployeeUserDetails details = new EmployeeUserDetails(employee, expressions);
SecurityUtils.setAuthentication(new UsernamePasswordAuthenticationToken(details, employee.getPassword(), details.getAuthorities()));
// 5. 放行
chain.doFilter(request, response);
}
}
配置过滤器到 UsernamePasswordAuthenticationFilter
之前
// 3. 添加过滤器配置
http.addFilterBefore(verifyTokenFilter, UsernamePasswordAuthenticationFilter.class);
5 登出&认证失败处理器
登出成功处理器
package cn.wolfcode.security.handle;
import cn.wolfcode.redis.RedisService;
import cn.wolfcode.utils.SecurityUtils;
import cn.wolfcode.utils.ServletUtils;
import cn.wolfcode.vo.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class RbacLogoutSuccessHandler implements LogoutSuccessHandler {
@Autowired
private RedisService redisService;
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 从请求中获取 token
String token = request.getHeader("token");
// 从 redis 中删除 token
if (StringUtils.hasLength(token)) {
redisService.del(token);
}
// 清空上下文
SecurityUtils.clearContext();
// 响应登出成功 json
ServletUtils.renderString(response, Result.ok());
}
}
认证失败处理器
package cn.wolfcode.security.handle;
import cn.wolfcode.utils.ServletUtils;
import cn.wolfcode.vo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 认证失败处理器
*/
@Slf4j
@Component
public class RbacAuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
log.warn("[认证失败处理器] 用户认证失败:", authException);
ServletUtils.renderString(response, Result.noAuth("用户认证失败"));
}
}
配置
// 4. 添加登出配置
http.logout().logoutSuccessHandler(rbacLogoutSuccessHandler);
// 5. 添加异常处理器
http.exceptionHandling().authenticationEntryPoint(rbacAuthenticationEntryPoint);
鉴权 Authorization
1.未授权异常处理器
处理器实现
package cn.wolfcode.security.handle;
import cn.wolfcode.utils.ServletUtils;
import cn.wolfcode.vo.Result;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 访问拒绝的处理器 => 没有权限时执行
*/
@Component
public class RbacAccessDeniedHandler implements AccessDeniedHandler {
private static final Logger log = LoggerFactory.getLogger(RbacAccessDeniedHandler.class);
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.warn("[权限拒绝] 用户没有访问权限:", accessDeniedException);
ServletUtils.renderString(response, Result.noPermission("没有访问权限"));
}
}
配置
// 5. 添加异常处理器
http.exceptionHandling()
.authenticationEntryPoint(rbacAuthenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
2.注解改造
@PreAuthorize("@ss.hasAuthority('department:saveOrUpdate')")
3.自定义权限判断方法
@Override
public boolean hasAuthority(String authority) {
EmployeeUserDetails details = (EmployeeUserDetails) SecurityUtils.getLoginUser();
if (details == null) {
return false;
}
Employee employee = details.getEmployee();
if (employee.isAdmin()) {
return true;
}
if (CollectionUtils.isEmpty(details.getExpressions())) {
return false;
}
return details.getExpressions().contains(authority);
}
4.更新统一异常处理器
package cn.wolfcode.web.advice;
import cn.wolfcode.exception.BusinessException;
import cn.wolfcode.exception.NoAuthException;
import cn.wolfcode.exception.NoPermissionException;
import cn.wolfcode.vo.Result;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ExceptionControllerAdvice {
@ExceptionHandler(RuntimeException.class)
public Result handleRuntimeException(RuntimeException e) {
e.printStackTrace();
return Result.error("系统繁忙,稍后重试");
}
@ExceptionHandler(BusinessException.class)
public Result handleRuntimeException(BusinessException e) {
e.printStackTrace();
return Result.error(e.getMessage());
}
@ExceptionHandler({NoAuthException.class, BadCredentialsException.class})
public Result handleAuthException(Exception e) {
e.printStackTrace();
return Result.noAuth(e.getMessage());
}
@ExceptionHandler({NoPermissionException.class, AccessDeniedException.class})
public Result handlePermissionException(Exception e) {
e.printStackTrace();
return Result.noPermission(e.getMessage());
}
}
cationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
### 2.注解改造
```java
@PreAuthorize("@ss.hasAuthority('department:saveOrUpdate')")
3.自定义权限判断方法
@Override
public boolean hasAuthority(String authority) {
EmployeeUserDetails details = (EmployeeUserDetails) SecurityUtils.getLoginUser();
if (details == null) {
return false;
}
Employee employee = details.getEmployee();
if (employee.isAdmin()) {
return true;
}
if (CollectionUtils.isEmpty(details.getExpressions())) {
return false;
}
return details.getExpressions().contains(authority);
}
4.更新统一异常处理器
package cn.wolfcode.web.advice;
import cn.wolfcode.exception.BusinessException;
import cn.wolfcode.exception.NoAuthException;
import cn.wolfcode.exception.NoPermissionException;
import cn.wolfcode.vo.Result;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ExceptionControllerAdvice {
@ExceptionHandler(RuntimeException.class)
public Result handleRuntimeException(RuntimeException e) {
e.printStackTrace();
return Result.error("系统繁忙,稍后重试");
}
@ExceptionHandler(BusinessException.class)
public Result handleRuntimeException(BusinessException e) {
e.printStackTrace();
return Result.error(e.getMessage());
}
@ExceptionHandler({NoAuthException.class, BadCredentialsException.class})
public Result handleAuthException(Exception e) {
e.printStackTrace();
return Result.noAuth(e.getMessage());
}
@ExceptionHandler({NoPermissionException.class, AccessDeniedException.class})
public Result handlePermissionException(Exception e) {
e.printStackTrace();
return Result.noPermission(e.getMessage());
}
}