合家云社区物业管理平台
4.权限管理模块开发
4.1 权限管理概述
4.1.1 权限管理的意义
后台管理系统中,通常需要控制不同的登录用户可以操作的内容。权限管理用于管理系统资源,分配用户菜单、资源权限,以及验证用户是否有访问资源权限。
4.1.2 RBAC权限设计模型
ACL介绍
ACL(Access Control List):访问控制列表 用户 -> 权限
ACL 模型,权限能直接赋予用户,例如将查看订单列表(权限)赋予某位运营人员(用户)。但是这种模式的缺点在于,但用户量达到一定量级的时候,那么就需要对每个用户都进行一次授权操作,那么这个工作量就会相当大。
RBAC介绍 (Role-Based Access Control,基于角色的访问控制)
RBAC 模型,这是软件设计中最常用的权限管理模型,相比于 ACL 模型,RBAC 模型在用户与权限之间多了一个元素「角色」,通过权限关联角色、角色关联用户的方法来间接地赋予用户权限,从而实现用户与权限的解耦。
使用ACL模式时,当系统中存在多个用户拥有相同的权限,在分配的时候就要分别为这几个用户指定相同的权限,修改时也要为这几个用户的权限进行一一修改。
使用RBAC模式 有了角色后,我们只需要为该角色制定好权限后,将相同权限的用户都指定为同一个角色即可,便于权限管理。
4.1.3 权限管理相关概念
4.1.3.1 用户管理
用户管理中的用户主要是功能系统的使用者,这些用户是一个一个的员工个体,这些个体往往从两个维度来进行划分:行政关系(部门架构)、业务部门(业务架构)。
用户管理就是在此两个维度来给员工个体进行关联性的初步分群或者分组。按照行政部门或者按照业务线部门划分后,对应部门或者小组内的用户有着基本相似的系统功能使用需求和权限等级;
4.1.3.2 权限管理
1) 功能权限管理
功能权限管理:指的是用户可看到哪些模块,能操作哪些按钮,因为企业中的用户拥有不同的角色,拥有的职责也是不同的。
菜单级别的权限控制
操作按钮级别的权限控制
- 在菜单级权限控制的基础上,将页面上的操作动作也一并管理维护,即页面上的操作按钮,简单说就是增、删、改、查等操作的统一授权管理,此种管理模式,可将权限控制得更加细致、准确。
2) 数据权限管理
数据权限管理:指的是用户可看到哪些模块的哪些数据。
例如:一个系统中包含多个清单(清单 1、清单 2、清单 3),系统管理员能对整个系统操作维护,也就可以对系统中的所有清单进行操作(增、删、改、查);
假如分配给二级管理员的是清单 1,那么他将只能对清单 1 进行操作(增、改、查);
如果是普通用户也许只有查看数据的权限,没有数据维操作的权限(查),这里的操作是指系统中所有可点击的按钮权限操作,列举的增删改查只是最常见的几种操作而已。
4.1.3.3 角色管理
角色往往是基于业务管理需求而预先在系统中设定好的固定标签,每个角色对应明确的系统权限,其所拥有的系统权限一般不会随意更改,并且角色也不会随着用户的被添加和被移除而进行改变,相较于用户管理而言更加稳定;
1) 角色是资源或菜单权限的集合。通过角色对不同管理员分配不同的资源、菜单权限。拥有相同权限的用户可以访问相同的菜单和资源。可以理解为权限分组。
2) 通过对用户分配角色,来最终实现用户的访问权限控制。一个用户可分配多个角色,这些角色的资源、菜单的并集即是用户可访问的全部资源。
4.1.4 权限管理设计
4.1.4.1 权限关系图
权限管理包含用户、角色、菜单、三个个核心组件。通过建立这些组件之间的关联关系,来实现用户到菜单的权限控制。
权限管理组件的关系图:
4.1.4.2 权限数据库设计
4.2 Spring Security实战
4.2.1 Spring Security 简介
1) Spring Security 是 Spring 家族中的一个安全管理框架,可以轻松地与 Spring 应用程序集成, 它提供了一系列的安全服务和工具,用于保护企业应用程序的安全性。
Spring Security 提供了许多功能,包括身份验证(Authentication)、授权(Authorization)、访问控制、密码管理、单点登录(Single Sign-On)、攻击防范(如跨站点脚本攻击、SQL注入攻击)等。
2) 认证和授权也是SpringSecurity作为安全框架的核心功能
-
认证(Authentication):验证用户的身份以确保用户是合法的,可以访问系统中的资源。
这通常涉及用户身份验证,例如使用用户名和密码验证用户的身份,或者使用第三方身份验证提供程序进行身份验证,例如 OAuth2。
-
授权(Authorization):控制用户对系统中资源的访问权限。这涉及将用户分配到特定的角色或权限,以控制他们可以执行哪些操作和访问哪些资源。
例如,管理员可能有更多的权限,可以访问和修改系统的高级设置,而普通用户则只能访问他们自己的个人资料信息。
3) Spring Security 与 Shiro的区别?
- 领域不同:Spring Security 是 Spring 生态系统的一部分,而 Shiro 是一个独立的框架。
- 功能不同:Spring Security 提供了更完整的安全性解决方案,包括基于过滤器链的身份验证、授权、会话管理、密码管理、单点登录等功能。Shiro 更注重简化开发人员的安全需求,提供易于使用和灵活的 API。
- 灵活性不同:Shiro 在配置和使用方面更加简单,提供了灵活性和易于集成性。相比之下,Spring Security 提供了更高级的功能,但需要更多的配置和学习成本。
一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。
4.2.2 Spring Security 快速入门
4.2.2.1 创建SpringBoot工程
1) 创建工程
2) 引入依赖
<?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.7.8</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.mashibing</groupId>
<artifactId>springsecurity_example</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<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.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
4.2.2.2 入门案例编写
- 启动类
@SpringBootApplication
public class SpringsecurityExampleApplication {
public static void main(String[] args) {
SpringApplication.run(SpringsecurityExampleApplication.class, args);
}
}
- Controller
@RestController
public class HelloController {
@RequestMapping("/hello")
public String hello(){
return "hello";
}
}
- 引入SpringSecurity依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
- 登录页面
访问http://localhost:8080/hello 会跳转到 http://localhost:8080/login
用户名是 user, 密码会打印到控制台
Using generated security password: ac3218c5-3abe-4ac7-bade-3d6487b38bf3
退出登录
4.2.2.3 SpringSecurity工作流程分析
SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器。
图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。
- UsernamePasswordAuthenticationFilter: 负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责。
- ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。
- FilterSecurityInterceptor:负责权限校验的过滤器。
Debug分析
- 添加断点
- 获取容器中的Bean
4.2.3 认证
4.2.3.1 什么是认证(Authentication)
- 通俗地讲就是验证当前用户的身份,证明“你是你自己”(比如:你每天上下班打卡,都需要通过指纹打卡,当你的指纹和系统里录入的指纹相匹配时,就打卡成功)
- 互联网中的认证
- 用户名密码登录
- 邮箱发送登录链接
- 手机号接收验证码
- 只要你能收到邮箱/验证码,就默认你是账号的主人
4.2.3.2 两种认证方式
1) 基于Session的认证方式
session 认证流程:
- 用户第一次请求服务器的时候,服务器根据用户提交的相关信息,创建对应的 Session
- 请求返回时将此 Session 的唯一标识 SessionID 返回给浏览器
- 浏览器接收到服务器返回的 SessionID 后,会将此信息存入到 Cookie 中,同时 Cookie 记录此 SessionID 属于哪个域名
- 当用户第二次访问服务器的时候,请求会自动把此域名下的 Cookie 信息也发送给服务端,服务端会从 Cookie 中获取 SessionID,再根据 SessionID 查找对应的 Session 信息,如果没有找到说明用户没有登录或者登录失效,如果找到 Session 证明用户已经登录可执行后面操作。
根据以上流程可知,SessionID 是连接 Cookie 和 Session 的一道桥梁,大部分系统也是根据此原理来验证用户登录状态。
session 认证存在的问题
- 在分布式的环境下,基于session的认证会出现一个问题,每个应用服务都需要在session中存储用户身份信息,通过负载均衡将本地的请求分配到另一个应用服务需要将session信息带过去,否则会重新认证。我们可以使用Session共享、Session黏贴等方案。
2) 基于Token的认证方式
什么是Token? (令牌)
- 访问资源接口(API)时所需要的资源凭证
- 简单 token 的组成: uid(用户唯一的身份标识)、time(当前时间的时间戳)、sign(签名,token 的前几位以哈希算法压缩成的一定长度的十六进制字符串)
服务器对 Token 的存储方式:
- 存到数据库中,每次客户端请求的时候取出来验证(服务端有状态)
- 存到 redis 中,设置过期时间,每次客户端请求的时候取出来验证(服务端有状态)
- 不存,每次客户端请求的时候根据之前的生成方法再生成一次来验证(JWT,服务端无状态)
Token特点:
- 服务端无状态化、可扩展性好
- 支持移动端设备
- 安全
- 支持跨程序调用
token 的身份验证流程:
- 客户端使用用户名跟密码请求登录
- 服务端收到请求,去验证用户名与密码
- 验证成功后,服务端会签发一个 token 并把这个 token 发送给客户端
- 客户端收到 token 以后,会把它存储起来,比如放在 cookie 里或者 localStorage 里
- 客户端每次向服务端请求资源的时候需要带着服务端签发的 token
- 服务端收到请求,然后去验证客户端请求里面带着的 token ,如果验证成功,就向客户端返回请求的数据
每一次请求都需要携带 token,需要把 token 放到 HTTP 的 Header 里
注意:
登录时 token 不宜保存在 localStorage,被 XSS 攻击时容易泄露。所以比较好的方式是把 token 写在 cookie 里。为了保证 xss 攻击时 cookie 不被获取,还要设置 cookie 的 http-only。这样,我们就能确保 js 读取不到 cookie 的信息了。再加上 https,能让我们的请求更安全一些。
token认证方式的优缺点
- 优点: 基于token的认证方式,服务端不用存储认证数据,易维护扩展性强, 客户端可以把token 存在任意地方,并且可以实现web和app统一认证机制。
- 缺点: token由于自包含信息,因此一般数据量较大,而且每次请求都需要传递,因此比较占带宽。另外,token的签名验签操作也会给cpu带来额外的处理负担。
3) Token 和 Session 的区别
- Session 是一种记录服务器和客户端会话状态的机制,使服务端有状态化,可以记录会话信息。而 Token 是令牌,访问资源接口(API)时所需要的资源凭证。Token 使服务端无状态化,不会存储会话信息。
- Session 和 Token 并不矛盾,作为身份认证 Token 安全性比 Session 好,因为每一个请求都有签名还能防止监听以及重复攻击,而 Session 就必须依赖链路层来保障通讯安全了。如果你需要实现有状态的会话,仍然可以增加 Session 来在服务器端保存一些状态。
如果你的用户数据可能需要和第三方共享,或者允许第三方调用 API 接口,用 Token 。如果永远只是自己的网站,自己的 App,用什么就无所谓了。
4.2.3.3 JWT (JSON Web Token)
(1) JWT简介
什么是JWT
- JWT是一种基于 Token 的****认证授权机制.
- JWT (JSON Web Token) 是目前最流行的跨域认证解决方案,是一种基于 Token 的认证授权机制。从 JWT 的全称可以看出,JWT 本身也是 Token,一种规范化之后的 JSON 结构的 Token。
JWT有什么用
- JWT最常见的场景就是授权认证,用户登录之后,后续的每个请求都将包含JWT, 系统在每次处理用户请求之前,都要先进行JWT的安全校验,通过校验之后才能进行接下来的操作.
JWT认证方式
- JWT通过数字签名的方式,以JSON对象为载体,在用户和服务器之间传递安全可靠的信息.
- 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口。这一过程一般是一个HTTP POST请求。建议的方式是通过SSL加密的传输(https协议),从而避免敏感信息被嗅探。
- 后端核对用户名和密码成功后,将用户的id等其他信息作为JWT Payload(负载),将其与头部分别进行Base64编码拼接后签名,形成一个JWT(Token)。形成的JWT就是一个形同lll.zzz.xxx的字符串。 token head.payload.singurater
- 后端将JWT字符串作为登录成功的返回结果返回给前端。前端可以将返回的结果保存在localStorage或sessionStorage上,退出登录时前端删除保存的JWT即可。
- 前端在每次请求时将JWT放入HTTP Header中的Authorization位。(解决XSS和XSRF问题) HEADER
- 后端检查是否存在,如存在验证JWT的有效性。例如,检查签名是否正确;检查Token是否过期;检查Token的接收方是否是自己(可选)。
- 验证通过后后端使用JWT中包含的用户信息进行其他逻辑操作,返回相应结果。
(2) JWT的组成部分
头部(Header)
- 描述 JWT 的元数据,定义了生成签名的算法以及 Token 的类型。
{"typ":"JWT","alg":"HS256"}
//typ(Type):令牌类型,也就是 JWT。
//alg(Algorithm) :签名算法,比如 HS256。
JWT签名算法中,一般有两个选择,一个采用HS256,另外一个就是采用RS256
进行BASE64编码https://base64.us/,编码后的字符串如下:eyJhbGciOiJIUzI1NiJ9
载荷(payload)
载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分
{"sub":"1234567890","name":"John Doe","admin":true}
将上面的JSON数据进行base64编码,得到Jwt第二部分: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
字段说明,下面的字段都是由 JWT的标准所定义的
iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token。
签名(signature)
服务器通过 Payload、Header 和一个密钥(Secret) 使用 Header 里面指定的签名算法(默认是 HMAC SHA256)生成。
Signature 部分是对前两部分的签名,作用是防止 Token(主要是 payload) 被篡改。
这个签名的生成需要用到:
- Header + Payload。
- 存放在服务端的密钥(一定不要泄露出去)。
- 签名算法。
例如,如果要使用HMAC SHA256算法,则将通过以下方式创建签名:
String encodeString = base64UrlEncode(header) + "." + base64UrlEncode(payload);
String secret = HMACSHA256(encodeString,secret);
签名用于验证消息在此过程中没有更改,并且对于使用私钥进行签名的令牌,它还可以验证JWT的发送者是它所说的真实身份。
注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
(3) 签名的目的
最后一步签名的过程,实际上是对头部以及载荷内容进行签名。
一般而言,加密算法对于不同的输入产生的输出总是不一样的。对于两个不同的输入,产生同样的输出的概率极其地小(有可能比我成世界首富的概率还小)。所以,我们就把“不一样的输入产生不一样的输出”当做必然事件来看待吧。
所以,如果有人对头部以及载荷的内容解码之后进行修改,再进行编码的话,那么新的头部和载荷的签名和之前的签名就将是不一样的。而且,如果不知道服务器加密的时候用的密钥的话,得出来的签名也一定会是不一样的。
服务器应用在接受到JWT后,会首先对头部和载荷的内容用同一算法再次签名。那么服务器应用是怎么知道我们用的是哪一种算法呢?别忘了,我们在JWT的头部中已经用 alg
字段指明了我们的加密算法了。
如果服务器应用对头部和载荷再次以同样方法签名之后发现,自己计算出来的签名和接受到的签名不一样,那么就说明这个Token的内容被别人动过的,我们应该拒绝这个Token,返回一个HTTP 401 Unauthorized响应。
(4) JWT与Token的区别
Token 和 JWT (JSON Web Token) 都是用来在客户端和服务器之间传递身份验证信息的一种方式。但是它们之间有一些区别。
- Token 是一个通用术词,可以指代任何用来表示身份的字符串。它可以是任何形式的字符串,并不一定是 JWT。
- JWT 是一种特殊的 Token,它是一个 JSON 对象,被编码成字符串并使用秘密密钥进行签名。JWT 可以用来在身份提供者和服务提供者之间安全地传递身份信息,因为它可以被加密,并且只有拥有秘密密钥的方能解密。
总的来说,JWT 是一种特殊的 Token,它具有更强的安全性和可靠性。
(5) JWT的优势
- 简洁(Compact): 可以通过URL,POST参数或者在HTTP header发送,因为数据量小,传输速度也很快
- 自包含(Self-contained):负载中包含了所有用户所需要的信息,避免了多次查询数据库
- 因为Token是以JSON加密的形式保存在客户端的,所以JWT是跨语言的,原则上任何web形式都支持。
- 不需要在服务端保存会话信息,特别适用于分布式微服务。
4.2.3.4 JJWT签发与验证token
使用jjwt实现jwt的签发和解析获取payload中的数据.
JJWT是一个提供端到端的JWT创建和验证的Java库。永远免费和开源(Apache License,版本2.0)。
官方文档:https://github.com/jwtk/jjwt
(1) 引入依赖
<!--jwt依赖-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
(2) 创建 Token
@SpringBootTest
class SpringsecurityExampleApplicationTests {
@Test
void contextLoads() {
}
@Test
public void testJJWT(){
JwtBuilder builder = Jwts.builder()
.setId("9527") //设置唯一ID
.setSubject("hejiayun_community") //设置主体
.setIssuedAt(new Date()) //设置签约时间
.signWith(SignatureAlgorithm.HS256, "mashibing");//设置签名 使用HS256算法,并设置SecretKey
//压缩成String形式,签名的JWT称为JWS
String jws = builder.compact();
System.out.println(jws);
/**
* eyJhbGciOiJIUzI1NiJ9.
* eyJqdGkiOiI5NTI3Iiwic3ViIjoiaGVqaWF5dW5fY29tbXVuaXR5IiwiaWF0IjoxNjgxMTM1ODY2fQ.
* ybkDJLVj1Fsi8m3agyxtyd0wxv7lHDqCWNOLN-eOxC8
*/
}
}
运行打印结果:
eyJhbGciOiJIUzI1NiJ9.
eyJqdGkiOiI5NTI3Iiwic3ViIjoiaGVqaWF5dW5fY29tbXVuaXR5IiwiaWF0IjoxNjgxMTM1ODY2fQ.
ybkDJLVj1Fsi8m3agyxtyd0wxv7lHDqCWNOLN-eOxC
(3) 解析Token
我们刚才已经创建了token ,在web应用中这个操作是由服务端进行然后发给客户端,客户端在下次向服务端发送请求时需要携带这个token(这就好像是拿着一张门票一样),那服务端接到这个token 应该解析出token中的信息(例如用户id),根据这些信息查询数据库返回相应的结果。
解析JJWS的方法如下:
- 使用该
Jwts.parser()
方法创建JwtParserBuilder
实例。 setSigningKey()
与builder中签名方法signWith()对应,parser中的此方法拥有与signWith()方法相同的三种参数形式,用于设置JWT的签名key,用户后面对JWT进行解析。- 最后,
parseClaimsJws(String)
用您的jws调用该方法,生成原始的JWS。 - 如果解析或签名验证失败,则整个调用将包装在try / catch块中。
@Test
public void parserJWT(){
String JWS = "eyJhbGciOiJIUzI1NiJ9." +
"eyJqdGkiOiI5NTI3Iiwic3ViIjoiaGVqaWF5dW5fY29tbXVuaXR5IiwiaWF0IjoxNjgxMTM1ODY2fQ." +
"ybkDJLVj1Fsi8m3agyxtyd0wxv7lHDqCWNOLN-eOxC8";
//claims = 载荷 (payload)
try {
Claims claims = Jwts.parser().setSigningKey("mashibing")
.parseClaimsJws(JWS)
.getBody();
System.out.println(claims);
} catch (Exception e) {
System.out.println("Token验证失败! !");
e.printStackTrace();
}
}
运行打印结果:
{jti=9527, sub=hejiayun_community, iat=1681135866}
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token。
sub: jwt所面向的用户
(4) 设置过期时间
有很多时候,我们并不希望签发的token是永久生效的,所以我们可以为token添加一个过期时间。
- 创建token 并设置过期时间
@Test
public void testJJWT2(){
long currentTimeMillis = System.currentTimeMillis();
Date expTime = new Date(currentTimeMillis);
JwtBuilder builder = Jwts.builder()
.setId("9527") //设置唯一ID
.setSubject("hejiayun_community") //设置主体
.setIssuedAt(new Date()) //设置签约时间
.setExpiration(expTime) //设置过期时间
.signWith(SignatureAlgorithm.HS256, "mashibing");//设置签名 使用HS256算法,并设置SecretKey
//压缩成String形式,签名的JWT称为JWS
String jws = builder.compact();
System.out.println(jws);
/**
* eyJhbGciOiJIUzI1NiJ9.
* eyJqdGkiOiI5NTI3Iiwic3ViIjoiaGVqaWF5dW5fY29tbXVuaXR5IiwiaWF0IjoxNjgxMTM3MjI0LCJleHAiOjE2ODExMzcyMjR9.
* evc01MRxLjpbksbMLdVPM9sJGYGhpC3UYOfm4-0sMGE
*/
}
- 解析TOKEN
打印效果: 异常信息: JWT签名与本地计算的签名不匹配。JWT有效性不能断言,也不应该被信任
Token验证失败! !
io.jsonwebtoken.MalformedJwtException: JWT strings must contain exactly 2 period characters. Found:
(5) 自定义claims
我们刚才的例子只是存储了id和subject两个信息,如果你想存储更多的信息(例如角色)可以定义自定义claims。
创建测试类,并设置测试方法:
@Test
public void testJJWT3(){
long currentTimeMillis = System.currentTimeMillis()+100000000L;
Date expTime = new Date(currentTimeMillis);
JwtBuilder builder = Jwts.builder()
.setId("9527") //设置唯一ID
.setSubject("hejiayun_community") //设置主体
.setIssuedAt(new Date()) //设置签约时间
.setExpiration(expTime) //设置过期时间
.claim("roles","admin") //设置角色
.signWith(SignatureAlgorithm.HS256, "mashibing");//设置签名 使用HS256算法,并设置SecretKey
//压缩成String形式,签名的JWT称为JWS
String jws = builder.compact();
System.out.println(jws);
/**
* eyJhbGciOiJIUzI1NiJ9.
* eyJqdGkiOiI5NTI3Iiwic3ViIjoiaGVqaWF5dW5fY29tbXVuaXR5IiwiaWF0IjoxNjgxMTM3MjI0LCJleHAiOjE2ODExMzcyMjR9.
* evc01MRxLjpbksbMLdVPM9sJGYGhpC3UYOfm4-0sMGE
*/
}
解析TOKEN,打印结果
{jti=9527, sub=hejiayun_community, iat=1681137464, exp=1681237464, roles=admin}
4.2.3.5 入门案例认证流程分析
(1) 入门案例认证流程图
1) AbstractAuthenticationProcessingFilter
AbstractAuthenticationProcessingFilter
的职责也就非常明确: 处理所有HTTP Request和Response对象,并将其封装成AuthenticationMananger可以处理的Authentication。- 它的实现类 UsernamePasswordAuthenticationFilter 表示当前访问系统的用户,封装了用户相关信息。
2) AuthenticationManager
-
AuthenticationManager
定义了认证Authentication的方法 , 用来尝试对传入的Authentication对象进行认证。用于处理身份验证的核心逻辑;
ProviderManager
ProviderManager
是Authentication的一个实现,并将具体的认证操作委托给一系列的AuthenticationProvider来完成,从而可以实现支持多种认证方式。
3) AbstractUserDetailsAuthenticationProvider
-
ProviderManager 本身并不直接处理身份认证请求,它会委托给内部配置的Authentication Provider列表providers。该列表会进行循环遍历,依次对比匹配以查看它是否可以执行身份验证
-
providers集合的泛型是AuthenticationProvider接口,AuthenticationProvider接口有多个实现子类
4) DaoAuthenticationProvider
- AuthenticationProvider接口的一个直接子类是AbstractUserDetailsAuthenticationProvider,该类又有一个直接子类DaoAuthenticationProvider.
- Spring Security中默认就是使用Dao Authentication Provider来实现基于数据库模型认证授权工作的!
5) UserDetailsService
- DaoAuthenticationProvider 在进行认证的时候,需要调用 UserDetailsService 对象的loadUserByUsername() 方法来获取用户信息 UserDetails,其中包括用户名、密码和所拥有的权限等。
- 如果我们需要改变认证方式,可以实现自己的 AuthenticationProvider;
- 如果需要改变认证的用户信息来源,我们可以实现 UserDetailsService。
6) InMemoryUserDetailsManager
- 它是UserDetailsService接口的实现类, 在内存中维护用户信息。使用方便,但是数据只保存在内存中,重启后数据丢失.
(2) 认证流程中对象之间的关系
虽然 Spring Security 看似很复杂,但是其核心思想和以前那种简单的认证流程依然是一样的。只不过,Spring Security 将其中的关键部分抽象了处理,又提供了相应的扩展接口。
我们在使用时,便可以实现自己的 UserDetailsService 和 UserDetails 来获取保存用户信息,实现自己的 Authentication 来保存特定的用户认证信息, 实现自己的 AuthenticationProvider 使用自己的 UserDetailsService 和 Authentication 来对用户认证信息进行效验。
4.2.3.6 重构入门案例-准备工作
(1) 需求分析
登录操作
- 自定义登录接口
- 调用ProviderManager的方法进行认证 如果认证通过生成jwt
- 把用户信息存入redis中
- 自定义UserDetailsService
- 在这个实现类中去查询数据库
(2) 添加依赖
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--fastjson依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.74</version>
</dependency>
<!--jwt依赖-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>
<!-- Mysql驱动包 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.32</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-core</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
(3 )SpringBoot Redis缓存序列化处理
Spring Data Redis为我们封装了Redis客户端的各种操作,简化使用。
- 当Redis当做数据库或者消息队列来操作时,我们一般使用RedisTemplate来操作
- 当Redis作为缓存使用时,我们可以将它作为Spring Cache的实现,直接通过注解使用
SpringBoot RedisTemplate的序列化问题
- SpringBoot RedisTemplate用来操作Key-Value为对象类型,默认采用JDK序列化类型,JDK序列化性能差,而且存储到Redis服务端是二进制不便查询,JDK序列化要求实体实现
Serializable
接口.
① 添加序列化工具类,让Redis使用FastJson序列化,提高序列化效率, 将存储在Redis中的value值,序列化为JSON格式便于查看
/**
* Redis使用FastJson进行序列化
* @author spikeCong
* @date 2023/4/10
**/
public class FastJsonJsonRedisSerializer<T> implements RedisSerializer<T> {
@SuppressWarnings("unused")
private ObjectMapper objectMapper = new ObjectMapper();
public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
private Class<T> clazz;
static
{
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
}
public FastJsonJsonRedisSerializer(Class<T> clazz)
{
super();
this.clazz = clazz;
}
@Override
public byte[] serialize(T t) throws SerializationException
{
if (t == null)
{
return new byte[0];
}
return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
}
@Override
public T deserialize(byte[] bytes) throws SerializationException
{
if (bytes == null || bytes.length <= 0)
{
return null;
}
String str = new String(bytes, DEFAULT_CHARSET);
return JSON.parseObject(str, clazz);
}
public void setObjectMapper(ObjectMapper objectMapper)
{
Assert.notNull(objectMapper, "'objectMapper' must not be null");
this.objectMapper = objectMapper;
}
protected JavaType getJavaType(Class<?> clazz)
{
return TypeFactory.defaultInstance().constructType(clazz);
}
}
② 添加Redis配置类
@Configuration
public class RedisConfig {
@Bean
@SuppressWarnings(value = { "unchecked", "rawtypes" })
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
{
RedisTemplate<Object, Object> template = new RedisTemplate<>();
//配置连接工厂
template.setConnectionFactory(connectionFactory);
//使用FastJson2JsonRedisSerializer 来序列化和反序列化redis的value值
FastJsonJsonRedisSerializer serializer = new FastJsonJsonRedisSerializer(Object.class);
ObjectMapper mapper = new ObjectMapper();
//指定要序列化的域: field,get和set,以及修饰符范围,ANY表示包括private和public
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
//指定序列化输入的类型,类必须是非final修饰的, final修饰的类会报异常.
mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(mapper);
//redis中存储的value值,采用json序列化
template.setValueSerializer(serializer);
//redis中的key值,使用StringRedisSerializer来序列化和反序列化
template.setKeySerializer(new StringRedisSerializer());
//初始化RedisTemplate的一些参数设置
template.afterPropertiesSet();
return template;
}
}
(4) 导入工具类
- Redis工具类
/**
* spring redis 工具类
*/
@SuppressWarnings(value = { "unchecked", "rawtypes" })
@Component
public class RedisCache
{
@Autowired
public RedisTemplate redisTemplate;
/**
* 缓存基本的对象,Integer、String、实体类等
*
* @param key 缓存的键值
* @param value 缓存的值
*/
public <T> void setCacheObject(final String key, final T value)
{
redisTemplate.opsForValue().set(key, value);
}
/**
* 缓存基本的对象,Integer、String、实体类等
*
* @param key 缓存的键值
* @param value 缓存的值
* @param timeout 时间
* @param timeUnit 时间颗粒度
*/
public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit)
{
redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
}
/**
* 设置有效时间
*
* @param key Redis键
* @param timeout 超时时间
* @return true=设置成功;false=设置失败
*/
public boolean expire(final String key, final long timeout)
{
return expire(key, timeout, TimeUnit.SECONDS);
}
/**
* 设置有效时间
*
* @param key Redis键
* @param timeout 超时时间
* @param unit 时间单位
* @return true=设置成功;false=设置失败
*/
public boolean expire(final String key, final long timeout, final TimeUnit unit)
{
return redisTemplate.expire(key, timeout, unit);
}
/**
* 获得缓存的基本对象。
*
* @param key 缓存键值
* @return 缓存键值对应的数据
*/
public <T> T getCacheObject(final String key)
{
ValueOperations<String, T> operation = redisTemplate.opsForValue();
return operation.get(key);
}
/**
* 删除单个对象
*
* @param key
*/
public boolean deleteObject(final String key)
{
return redisTemplate.delete(key);
}
/**
* 删除集合对象
*
* @param collection 多个对象
* @return
*/
public long deleteObject(final Collection collection)
{
return redisTemplate.delete(collection);
}
/**
* 缓存List数据
*
* @param key 缓存的键值
* @param dataList 待缓存的List数据
* @return 缓存的对象
*/
public <T> long setCacheList(final String key, final List<T> dataList)
{
Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
return count == null ? 0 : count;
}
/**
* 获得缓存的list对象
*
* @param key 缓存的键值
* @return 缓存键值对应的数据
*/
public <T> List<T> getCacheList(final String key)
{
return redisTemplate.opsForList().range(key, 0, -1);
}
/**
* 缓存Set
*
* @param key 缓存键值
* @param dataSet 缓存的数据
* @return 缓存数据的对象
*/
public <T> long setCacheSet(final String key, final Set<T> dataSet)
{
Long count = redisTemplate.opsForSet().add(key, dataSet);
return count == null ? 0 : count;
}
/**
* 获得缓存的set
*
* @param key
* @return
*/
public <T> Set<T> getCacheSet(final String key)
{
return redisTemplate.opsForSet().members(key);
}
/**
* 缓存Map
*
* @param key
* @param dataMap
*/
public <T> void setCacheMap(final String key, final Map<String, T> dataMap)
{
if (dataMap != null) {
redisTemplate.opsForHash().putAll(key, dataMap);
}
}
/**
* 获得缓存的Map
*
* @param key
* @return
*/
public <T> Map<String, T> getCacheMap(final String key)
{
return redisTemplate.opsForHash().entries(key);
}
/**
* 往Hash中存入数据
*
* @param key Redis键
* @param hKey Hash键
* @param value 值
*/
public <T> void setCacheMapValue(final String key, final String hKey, final T value)
{
redisTemplate.opsForHash().put(key, hKey, value);
}
/**
* 获取Hash中的数据
*
* @param key Redis键
* @param hKey Hash键
* @return Hash中的对象
*/
public <T> T getCacheMapValue(final String key, final String hKey)
{
HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
return opsForHash.get(key, hKey);
}
/**
* 获取多个Hash中的数据
*
* @param key Redis键
* @param hKeys Hash键集合
* @return Hash对象集合
*/
public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys)
{
return redisTemplate.opsForHash().multiGet(key, hKeys);
}
/**
* 获得缓存的基本对象列表
*
* @param pattern 字符串前缀
* @return 对象列表
*/
public Collection<String> keys(final String pattern)
{
return redisTemplate.keys(pattern);
}
}
- JWT工具类
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;
/**
* JWT工具类
*/
public class JwtUtil {
//有效期为
public static final Long JWT_TTL = 60 * 60 *1000L;// 60 * 60 *1000 一个小时
//设置秘钥明文
public static final String JWT_KEY = "mashibing";
public static String getUUID(){
String token = UUID.randomUUID().toString().replaceAll("-", "");
return token;
}
/**
* 生成jtw
* @param subject token中要存放的数据(json格式)
* @return
*/
public static String createJWT(String subject) {
JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间
return builder.compact();
}
/**
* 生成jtw
* @param subject token中要存放的数据(json格式)
* @param ttlMillis token超时时间
* @return
*/
public static String createJWT(String subject, Long ttlMillis) {
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间
return builder.compact();
}
private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
SecretKey secretKey = generalKey();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if(ttlMillis==null){
ttlMillis=JwtUtil.JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.setId(uuid) //唯一的ID
.setSubject(subject) // 主题 可以是JSON数据
.setIssuer("sg") // 签发者
.setIssuedAt(now) // 签发时间
.signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
.setExpiration(expDate);
}
/**
* 创建token
* @param id
* @param subject
* @param ttlMillis
* @return
*/
public static String createJWT(String id, String subject, Long ttlMillis) {
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
return builder.compact();
}
public static void main(String[] args) throws Exception {
String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJjYWM2ZDVhZi1mNjVlLTQ0MDAtYjcxMi0zYWEwOGIyOTIwYjQiLCJzdWIiOiJzZyIsImlzcyI6InNnIiwiaWF0IjoxNjM4MTA2NzEyLCJleHAiOjE2MzgxMTAzMTJ9.JVsSbkP94wuczb4QryQbAke3ysBDIL5ou8fWsbt_ebg";
Claims claims = parseJWT(token);
System.out.println(claims);
}
/**
* 生成加密后的秘钥 secretKey
* @return
*/
public static SecretKey generalKey() {
byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}
/**
* 解析
*
* @param jwt
* @return
* @throws Exception
*/
public static Claims parseJWT(String jwt) throws Exception {
SecretKey secretKey = generalKey();
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
}
}
JWT工具类使用相关问题
-
秘钥长度不合理,将秘钥明文长度设置为 6位.
异常信息: Exception in thread "main" java.lang.IllegalArgumentException: Last unit does not have enough valid bits
//设置秘钥明文(长度为6位) public static final String JWT_KEY = "msbhjy";
-
1.8 以上版本,需要引入
JAXB API
相关依赖异常信息: java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter
<dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.0</version> </dependency> <dependency> <groupId>com.sun.xml.bind</groupId> <artifactId>jaxb-impl</artifactId> <version>2.3.0</version> </dependency> <dependency> <groupId>com.sun.xml.bind</groupId> <artifactId>jaxb-core</artifactId> <version>2.3.0</version> </dependency> <dependency> <groupId>javax.activation</groupId> <artifactId>activation</artifactId> <version>1.1.1</version> </dependency>
- 字符串渲染工具类
public class WebUtils{
/**
* 将字符串渲染到客户端
*
* @param response 渲染对象
* @param string 待渲染的字符串
* @return null
*/
public static String renderString(HttpServletResponse response, String string) {
try
{
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(string);
}
catch (IOException e)
{
e.printStackTrace();
}
return null;
}
}
4.2.3.7 重构入门案例-具体实现
(1) 通过数据库校验用户
通过前面的分析,我们得出结论:可以自定义一个UserDetailsService,并让Spring Security使用它。我们的UserDetailsService可以从数据库中获取用户名和密码。
- 创建数据库及用户表
CREATE TABLE `sys_user` (
`user_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`user_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
`nick_name` VARCHAR(30) NOT NULL COMMENT '用户昵称',
`password` VARCHAR(100) DEFAULT '' COMMENT '密码',
`phonenumber` VARCHAR(11) DEFAULT '' COMMENT '手机号码',
`sex` CHAR(1) DEFAULT '0' COMMENT '用户性别(0男 1女 2未知)',
`status` CHAR(1) DEFAULT '0' COMMENT '帐号状态(0正常 1停用)',
PRIMARY KEY (`user_id`) USING BTREE
) ENGINE=INNODB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='用户信息表'
- 引入MybatisPuls和mysql驱动的依赖
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>
<!-- Mysql驱动包 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.32</version>
</dependency>
- 配置数据库信息
spring:
datasource:
url: jdbc:mysql://localhost:3306/sg_security?characterEncoding=utf-8&serverTimezone=UTC
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
- 创建实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("sys_user")
public class SysUser implements Serializable {
/**
* 主键
*/
@TableId
private Long userId;
/**
* 用户名
*/
private String userName;
/**
* 昵称
*/
private String nickName;
/**
* 密码
*/
private String password;
/**
* 手机号
*/
private String phonenumber;
/**
* 用户性别(0男,1女,2未知)
*/
private String sex;
/**
* 账号状态(0正常 1停用)
*/
private String status;
}
- 定义Mapper接口
public interface UserMapper extends BaseMapper<User> {
}
- 配置Mapper扫描
@SpringBootApplication
@MapperScan("com.mashibing.springsecurity_example.mapper")
public class SpringsecurityExampleApplication {
public static void main(String[] args) {
ConfigurableApplicationContext run = SpringApplication.run(SpringsecurityExampleApplication.class, args);
System.out.println("123456");
}
}
- 测试
@SpringBootTest
public class MapperTest {
@Autowired
private UserMapper userMapper;
@Test
public void testUserMapper(){
List<User> users = userMapper.selectList(null);
System.out.println(users);
}
}
(2) 引入SpringSecurity
第一步: 编写一个类,实现UserDetailsService接口,并重写其中的loadUserByUsername方法。在该方法中,使用用户名从数据库中检索用户信息。
/**
* 根据用户名检索用户信息
* @author spikeCong
* @date 2023/4/14
**/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysUser::getUserName,username);
SysUser user = userMapper.selectOne(wrapper);
//如果查询不到数据,抛出异常 给出提示
if(Objects.isNull(user)){
throw new RuntimeException("用户名或密码错误");
}
//方法的返回值是 UserDetails接口类型,需要返回自定义的实现类
return new LoginUser(user);
}
}
第二步 为了将用户信息转换为UserDetails类型的对象,需要创建一个类来实现UserDetails接口,并将用户信息封装在其中。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {
private SysUser sysUser;
/**
* 用于获取用户被授予的权限,可以用于实现访问控制。
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
/**
* 用于获取用户的密码,一般用于进行密码验证。
*/
@Override
public String getPassword() {
return sysUser.getPassword();
}
/**
* 用于获取用户的用户名,一般用于进行身份验证。
*/
@Override
public String getUsername() {
return sysUser.getUserName();
}
/**
* 用于判断用户的账户是否未过期,可以用于实现账户有效期控制。
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 用于判断用户的账户是否未锁定,可以用于实现账户锁定功能。
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 用于判断用户的凭证(如密码)是否未过期,可以用于实现密码有效期控制。
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 用于判断用户是否已激活,可以用于实现账户激活功能。
*/
@Override
public boolean isEnabled() {
return true;
}
}
UserDetails接口定义了以下方法:
- getAuthorities(): 返回用户被授予的权限集合。这个方法返回的是一个集合类型,其中每个元素都是一个GrantedAuthority对象,表示用户被授予的权限。
- getPassword(): 返回用户的密码。这个方法返回的是一个字符串类型,表示用户的密码。
- getUsername(): 返回用户的用户名。这个方法返回的是一个字符串类型,表示用户的用户名。
- isAccountNonExpired(): 返回一个布尔值,表示用户的账户是否未过期。
- isAccountNonLocked(): 返回一个布尔值,表示用户的账户是否未锁定。
- isCredentialsNonExpired(): 返回一个布尔值,表示用户的凭证(如密码)是否未过期。
- isEnabled(): 返回一个布尔值,表示用户是否已激活。
第三步 测试
访问登录地址 http://localhost:8080/login ,输入用户名密码
登录失败,后台报错
java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
报错原因
- Spring Security中密码的存储格式是“{id}…………”.前面的id是加密方式,id可以是bcrypt、sha256等,后面跟着的是加密后的密码.也就是说,程序拿到传过来的密码的时候,会首先查找被“{”和“}”包括起来的id,来确定后面的密码是被怎么样加密的,如果找不到就认为id是null.
如果要测试,需要往用户表中写入用户数据,并且如果你想让用户的密码是明文存储,需要在密码前加{noop}。就可以正常登录了, 例如
(3) BCryptPasswordEncoder 密码加密存储
1. BCryptPasswordEncoder 介绍
在实际的项目中,为了保护密码的安全,我们通常不会将密码以明文的形式存储在数据库中。通常,我们使用SpringSecurity提供的BCryptPasswordEncoder来进行加密。
BCryptPasswordEncoder是Spring Security提供的一个PasswordEncoder实现类,它使用了bcrypt算法对密码进行加密和解密。
2. 常用方法测试
BCryptPasswordEncoder主要有以下方法:
encode(CharSequence rawPassword)
:对原始密码进行加密处理,并返回加密后的密码字符串。matches(CharSequence rawPassword, String encodedPassword)
:对比原始密码和加密后的密码是否匹配。rawPassword为原始密码,encodedPassword为从数据库或其他地方获取的已经加密的密码字符串,如果匹配则返回true,否则返回false。
@Autowired
private PasswordEncoder passwordEncoder;
@Test
public void testBcryp(){
String e1 = passwordEncoder.encode("123456");
String e2 = passwordEncoder.encode("123456");
System.out.println(e1);
System.out.println(e2);
System.out.println(e1.equals(e2));
//$2a$10$0CS95XYw7GyDQNXq6FO7FuWDHR4yLTVyFXgQICjgTddWIG9OJ6isy
boolean b = passwordEncoder.matches("123456",
"$2a$10$0CS95XYw7GyDQNXq6FO7FuWDHR4yLTVyFXgQICjgTddWIG9OJ6isy");
System.out.println("=============== " + b);
}
BCryptPasswordEncoder使用随机盐值对密码进行加密,每次加密的结果都不同,即使相同的原始密码,加密后得到的字符串也是不同的。这种随机性增加了密码的安全性,防止了攻击者通过破解一个用户密码的方式,来破解其他用户的密码。
3.引入 BCryptPasswordEncoder
我们只需要将BCryptPasswordEncoder对象注入到Spring容器中,SpringSecurity就会使用该PasswordEncoder来验证密码。
为了配置SpringSecurity,我们可以定义一个继承自WebSecurityConfigurerAdapter的配置类。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
修改数据库的明文密码为加密后的密码, 测试一下
(4) 自定义登录接口
我们需要自定义一个登陆接口,并让SpringSecurity不要对该接口进行登录验证,以允许未登录用户访问。
在该接口中,我们使用AuthenticationManager的authenticate方法进行用户认证,需要在SecurityConfig中配置将AuthenticationManager注入到容器中。
如果认证成功,则需要生成一个jwt并将其放入响应中返回。为了让用户在下次请求时能够通过jwt识别出具体的用户,我们需要将用户信息存储在redis中,可以将用户id作为key。
当需要自定义登录接口时,可以按照以下步骤进行:
- 创建一个新的登录接口,例如LoginController , 用于接收用户的登录信息。
@RestController
public class LoginController {
@Autowired
private LoginService loginService;
@PostMapping("/user/login")
public ResponseResult login(@RequestBody SysUser user){
//登录
return loginService.login(user);
}
}
- 创建LoginService和其实现类 LoginServiceImpl, 登录操作主要的实现逻辑都在实现类中
public interface LoginService {
ResponseResult login(SysUser sysUser);
}
@Service
public class LoginServiceImpl implements LoginService {
@Override
public ResponseResult login(SysUser sysUser) {
//1.调用AuthenticationManager的 authenticate方法,进行用户认证。
//2.如果认证没有通过,给出错误提示
//3.如果认证通过,使用userId生成一个JWT,并将其保存到 ResponseResult对象中返回
//4.将用户信息存储在Redis中,在下一次请求时能够识别出用户,userid作为key
return null;
}
}
- 配置SecurityConfig 在SecurityConfig中添加一个配置,将自定义登录接口添加到Spring Security中,并设置为放行。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
/**
* 注入 AuthenticationManager,供外部类使用
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
//该方法用于配置 HTTP 请求的安全处理
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不会创建会话,每个请求都将被视为独立的请求。
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
//定义请求授权规则
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
}
}
后面我们再去详细说明一下configure方法中的细节.
- 回到loginService的login方法,补全剩余步骤
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisCache redisCache;
@Override
public ResponseResult login(SysUser sysUser) {
//1.调用AuthenticationManager的 authenticate方法,进行用户认证。
//1.1 需要传入一个Authentication对象的实现,该对象包含用户信息
Authentication usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(sysUser.getUserName(),sysUser.getPassword());
Authentication authentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
//2.如果认证没有通过,给出错误提示
if(Objects.isNull(authentication)){
throw new RuntimeException("登录失败");
}
//3.如果认证通过,使用userId生成一个JWT,并将其保存到 ResponseResult对象中返回
//3.1 获取经过身份验证的用户的主体信息
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
//3.2 获取到userID 生成JWT
String userId = loginUser.getSysUser().getUserId().toString();
String jwt = JwtUtil.createJWT(userId);
//4.将用户信息存储在Redis中,在下一次请求时能够识别出用户,userid作为key
redisCache.setCacheObject("login:"+userId,loginUser);
//5.封装ResponseResult,并返回
Map<String,String> map = new HashMap<>();
map.put("token",jwt);
return new ResponseResult(200,"登录成功",map);
}
}
(5) 使用postman测试
(6) 实现认证过滤器
当用户再次发送请求的时候,要进行校验,用户会携带登录时生成的JWT,所以我们需要自定义一个Jwt认证过滤器
- 获取token
- 解析token获取其中的userid login:+userId
- 从redis中获取用户信息
- 存入SecurityContextHolder
SecurityContextHolder 记录如下信息:当前操作的用户是谁,该用户是否已经被认证,他拥有哪些角色或权限等等。
经过自定义认证过滤器过滤后的用户信息会被保存到SecurityContextHolder中,后面的过滤器会从SecurityContextHolder中获取用户信息.
操作步骤如下
- 自定义一个过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的userid
自定义过滤器要去继承OncePerRequestFilter,OncePerRequestFilter 旨在简化过滤器的编写,并确保每个请求只被过滤一次,避免多次过滤的问题。
/**
* 自定义认证过滤器,用来校验用户请求中携带的Token
* @author spikeCong
* @date 2023/4/25
**/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisCache redisCache;
/**
* 封装过滤器的执行逻辑
* @param request
* @param response
* @param filterChain
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//1.从请求头中获取token
String token = request.getHeader("token");
//2.判断token是否为空,为空直接放行
if(!StringUtils.hasText(token)){
//放行
filterChain.doFilter(request,response);
//return的作用是返回响应的时候,避免走下面的逻辑
return;
}
//3.解析Token
String userId;
try {
Claims claims = JwtUtil.parseJWT(token);
userId = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("非法token");
}
//4.从redis中获取用户信息
String redisKey = "login:" + userId;
LoginUser loginUser = redisCache.getCacheObject(redisKey);
if(Objects.isNull(loginUser)){
throw new RuntimeException("用户未登录");
}
//5.将用户新保存到SecurityContextHolder,以便后续的访问控制和授权操作使用。
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//6.放行
filterChain.doFilter(request,response);
}
}
UsernamePasswordAuthenticationToken 三个参数的构造方法:
- principal:表示认证请求的主体,通常是一个用户名或者其他识别主体的信息。
- credentials:表示认证请求的凭据,通常是密码或者其他证明主体身份的信息。
- authorities: 权限信息
将Token检验过滤器 添加到过滤器链中
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
/**
* 注入 AuthenticationManager,供外部类使用
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
//该方法用于配置 HTTP 请求的安全处理
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不会创建会话,每个请求都将被视为独立的请求。
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
//定义请求授权规则
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//将自定义认证过滤器,添加到过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
}
使用postman进行测试
(7) 实现退出功能
定义一个登出接口,删除redis中对应的用户数据即可。
为什么不需要清除SecurityContextHolder中的数据
在退出登录时,如果使用 JWT 进行认证,并将 JWT 保存在 Redis 中,需要清除 Redis 中的 JWT 数据。由于 JWT 是无状态的,它本身不会与 Spring Security 的认证信息产生关联,因此在退出登录时,不需要清除
SecurityContextHolder
中的认证信息。
@RestController
public class LoginController {
@GetMapping("/user/logout")
public ResponseResult logout(){
//登录
return loginService.logout();
}
}
public interface LoginService {
ResponseResult login(SysUser sysUser);
ResponseResult logout();
}
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
private RedisCache redisCache;
@Override
public ResponseResult logout() {
//获取当前用户的认证信息
UsernamePasswordAuthenticationToken authenticationToken =
(UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
if(Objects.isNull(authenticationToken)){
throw new RuntimeException("获取用户认证信息失败,请重新登录!");
}
LoginUser loginUser = (LoginUser) authenticationToken.getPrincipal();
Long userId = loginUser.getSysUser().getUserId();
//删除redis中的用户信息
redisCache.deleteObject("login:" + userId);
return new ResponseResult(200,"注销成功");
}
}
测试
4.2.4 授权
4.2.4.1 什么是授权
授权是指在认证通过之后,根据用户的身份和角色,确定用户是否有权执行某项操作或访问某个资源的过程。
在应用程序中,授权通常是通过访问控制机制来实现的,例如基于角色的访问控制(Role-Based Access Control,RBAC)
4.2.4.2 Spring Security 授权基本流程
Spring Security 的授权基本流程如下:
- 进行认证操作,会生成一个
Authentication
对象 - 确定了用户的身份和角色之后,可以通过 Spring Security 提供的注解进行授权操作。
- 如果授权通过,则可以执行相关操作。
其中第一步操作 将权限信息保存到Authentication,有两个地方与保存权限有关
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//TODO 查询用户权限信息
//方法的返回值是UserDetails类型,需要返回自定义的实现类,并且将user信息通过构造方法传入
return new LoginUser(sysUser);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//TODO 获取权限信息封装到 Authentication
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser,null,null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//6.放行
filterChain.doFilter(request,response);
}
4.2.4.2 SpringSecurity授权实现
(1) 设置资源访问所需要的权限
在security中添加注解 @EnableGlobalMethodSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@EnableGlobalMethodSecurity(prePostEnabled = true)
是 Spring Security 提供的一个注解,用于启用全局方法级别的安全控制,在使用 Spring Security 进行方法级别的授权控制时,需要使用该注解来启用相关功能。其中,
prePostEnabled = true
表示开启 Spring Security 的方法级别安全控制。pre
表示在方法执行前进行授权校验,post
表示在方法执行后进行授权校验。
在HelloController中添加 @PreAuthorize(“hasAuthority(‘test’)”) 注解
@RestController
public class HelloController {
@RequestMapping("/hello")
@PreAuthorize("hasAuthority('test')")
public String hello(){
return "hello";
}
}
@PreAuthorize("hasAuthority('test')")
是 Spring Security 提供的一个注解,用于在方法执行前进行权限校验。它的作用是检查当前登录用户是否具有指定的权限,如果有,则允许执行该方法,否则抛出AccessDeniedException
异常,阻止方法执行。
hasAuthority()
方法用于检查用户是否具有指定的权限
hasAuthority('test')
表示检查当前用户是否具有名为test
的权限
@PreAuthorize
注解是在方法执行前进行权限校验的,因此如果当前用户不具有指定的权限,该方法将不会被执行。如果需要在方法执行后进行权限校验,可以使用 @PostAuthorize
注解。
(2) 封装权限信息
第一步 在UserDetailsServiceImpl中 ,根据用户查询权限信息,添加到LoginUser中
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysUser::getUserName,username);
SysUser user = userMapper.selectOne(wrapper);
//如果查询不到数据,抛出异常 给出提示
if(Objects.isNull(user)){
throw new RuntimeException("用户名或密码错误");
}
//TODO 根据用户查询权限信息,添加到LoginUser中,这里的权限信息我们写死,封装到list集合
ArrayList<String> list = new ArrayList<>(Arrays.asList("test"));
//方法的返回值是 UserDetails接口类型,需要返回自定义的实现类
return new LoginUser(user,list);
}
}
第二步 由于LoginUser中还有这个构造函数,所以我们要修改一下LoginUser
/* LoginUser */
//存储权限信息集合
private List<String> permissions;
public LoginUser(SysUser user, ArrayList<String> permissions) {
this.sysUser = user;
this.permissions = permissions;
}
第三步 如果SpringSecurity想要获取用户权限信息,其实最终要调用 getAuthorities()方法,所以要在这个方法中将查询到的权限信息进行转换,转换另一个List集合,其中保存的数据类型是 GrantedAuthority 类型.这是一个接口,我们用它下面的这个实现
package com.mashibing.springsecurity_example.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
/**
* @author spikeCong
* @date 2023/4/24
**/
@Data
public class LoginUser implements UserDetails {
private SysUser sysUser;
//存储权限信息集合
private List<String> permissions;
public LoginUser(SysUser user, ArrayList<String> permissions) {
this.sysUser = user;
this.permissions = permissions;
}
/**
* 用于获取用户被授予的权限,可以用于实现访问控制。
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
//将permissions集合中的String类型权限信息,转换为SimpleGrantedAuthority类型
// List<SimpleGrantedAuthority> authorities = new ArrayList<>();
// for (String permission : permissions) {
// SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(permission);
// authorities.add(simpleGrantedAuthority);
// }
//1.8 语法
List<SimpleGrantedAuthority> authorities = permissions.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return authorities;
}
}
第四步 对上面的代码进行优化, 将权限的集合提取到方法外,除第一次调用需要正在查询以外,后面判断只要authorities集合不为空,就直接返回
@Data
public class LoginUser implements UserDetails {
private SysUser sysUser;
public LoginUser() {
}
public LoginUser(SysUser sysUser) {
this.sysUser = sysUser;
}
//存储权限信息集合
private List<String> permissions;
public LoginUser(SysUser user, ArrayList<String> permissions) {
this.sysUser = user;
this.permissions = permissions;
}
//authorities集合不需要序列化,只需要序列化permissions集合即可
@JSONField(serialize = false)
private List<SimpleGrantedAuthority> authorities;
/**
* 用于获取用户被授予的权限,可以用于实现访问控制。
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
//将permissions集合中的String类型权限信息,转换为SimpleGrantedAuthority类型
// List<SimpleGrantedAuthority> authorities = new ArrayList<>();
// for (String permission : permissions) {
// SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(permission);
// authorities.add(simpleGrantedAuthority);
// }
if(authorities != null){
return authorities;
}
//1.8 语法
authorities = permissions.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return authorities;
}
}
第五部分 在 JwtAuthenticationTokenFilter
认证过滤器中, 将权限信息保存到 SecurityContextHolder
//TODO 5.将用户保存到SecurityContextHolder,以便后续的访问控制和授权操作使用。
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
第六步 debug 测试一下
(3) 根据RBAC权限模型创建表
1. RBAC权限模型
- RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。
2. 创建RBAC模型所需的表
CREATE TABLE `sys_menu` (
`menu_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
`menu_name` VARCHAR(50) NOT NULL COMMENT '菜单名称',
`path` VARCHAR(200) DEFAULT '' COMMENT '路由地址',
`component` VARCHAR(255) DEFAULT NULL COMMENT '组件路径',
`visible` CHAR(1) DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)',
`status` CHAR(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
`perms` VARCHAR(100) DEFAULT NULL COMMENT '权限标识',
`icon` VARCHAR(100) DEFAULT '#' COMMENT '菜单图标',
`create_by` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT '' COMMENT '备注',
PRIMARY KEY (`menu_id`) USING BTREE
) ENGINE=INNODB AUTO_INCREMENT=2068 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='菜单权限表'
CREATE TABLE `sys_role` (
`role_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`role_name` VARCHAR(30) NOT NULL COMMENT '角色名称',
`role_key` VARCHAR(100) NOT NULL COMMENT '角色权限字符串',
`status` CHAR(1) NOT NULL COMMENT '角色状态(0正常 1停用)',
`del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
`create_by` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`role_id`) USING BTREE
) ENGINE=INNODB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='角色信息表'
CREATE TABLE `sys_role_menu` (
`role_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`menu_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '菜单id',
PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
CREATE TABLE `sys_user` (
`user_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`user_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
`nick_name` VARCHAR(30) NOT NULL COMMENT '用户昵称',
`password` VARCHAR(100) DEFAULT '' COMMENT '密码',
`phonenumber` VARCHAR(11) DEFAULT '' COMMENT '手机号码',
`sex` CHAR(1) DEFAULT '0' COMMENT '用户性别(0男 1女 2未知)',
`status` CHAR(1) DEFAULT '0' COMMENT '帐号状态(0正常 1停用)',
PRIMARY KEY (`user_id`) USING BTREE
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='用户信息表'
CREATE TABLE `sys_user_role` (
`user_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '用户id',
`role_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '角色id',
PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3. 查询当前有用户所拥有的菜单权限
SELECT
sm.perms
FROM sys_user su
LEFT JOIN sys_user_role sur ON su.user_id = sur.user_id
LEFT JOIN sys_role sr ON sur.role_id = sr.role_id
LEFT JOIN sys_role_menu srm ON sr.role_id = srm.role_id
LEFT JOIN sys_menu sm ON srm.menu_id = sm.menu_id
WHERE su.user_id = 2
4. 创建菜单实体
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@TableName(value = "sys_menu")
public class Menu implements Serializable {
@TableId
private Long id;
//菜单名
private String menuName;
//路由地址
private String path;
//组件路径
private String component;
//菜单状态 (0 显示, 1隐藏)
private String visible;
//菜单状态 (0 正常, 1 停用)
private String status;
//权限标识
private String perms;
//菜单图标
private String icon;
private String createBy;
private String updateBy;
private Date updateTime;
private Date createTime;
private String remark;
}
(4) 从数据库获取权限信息
我们要做的就是根据用户id去查询到其所对应的菜单权限信息即可
1.mapper编写
/**
* @author spikeCong
* @date 2023/4/26
**/
public interface MenuMapper extends BaseMapper<Menu> {
List<String> selectPermsByUserId(Long id);
}
SELECT
DISTINCT sm.perms
FROM sys_user_role sur
LEFT JOIN sys_role sr ON sur.role_id = sr.role_id
LEFT JOIN sys_role_menu srm ON sr.role_id = srm.role_id
LEFT JOIN sys_menu sm ON srm.menu_id = sm.menu_id
WHERE
user_id = #{userid}
AND sr.status = 0
AND sm.status = 0
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mashibing.springsecurity_example.mapper.MenuMapper">
<select id="selectPermsByUserId" resultType="java.lang.String">
SELECT
DISTINCT sm.perms
FROM sys_user_role sur
LEFT JOIN sys_role sr ON sur.role_id = sr.role_id
LEFT JOIN sys_role_menu srm ON sr.role_id = srm.role_id
LEFT JOIN sys_menu sm ON srm.menu_id = sm.menu_id
WHERE
user_id = #{userid}
AND sr.status = 0
AND sm.status = 0
</select>
</mapper>
在application.yml中配置mapperXML文件的位置
spring:
datasource:
url: jdbc:mysql://localhost:3306/test_security?characterEncoding=utf-8&serverTimezone=UTC
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
redis:
host: localhost
port: 6379
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
2.service编写
UserDetailsServiceImpl
中去调用mapper的方法查询权限信息, 然后封装到LoginUser对象中.
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Autowired
private MenuMapper menuMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysUser::getUserName,username);
SysUser user = userMapper.selectOne(wrapper);
//如果查询不到数据,抛出异常 给出提示
if(Objects.isNull(user)){
throw new RuntimeException("用户名或密码错误");
}
//TODO 根据用户查询权限信息,添加到LoginUser中,这里的权限信息我们写死,封装到list集合
// ArrayList<String> list = new ArrayList<>(Arrays.asList("test"));
List<String> list = menuMapper.selectPermsByUserId(user.getUserId());
//方法的返回值是 UserDetails接口类型,需要返回自定义的实现类
return new LoginUser(user,list);
}
}
测试,用普通用户去测试一下
@RestController
public class HelloController {
//拥有system:user:list权限才能访问
@RequestMapping("/hello")
@PreAuthorize("hasAuthority('system:user:list')")
public String hello(){
return "hello";
}
//拥有system:role:list 才能访问
@RequestMapping("/ok")
@PreAuthorize("hasAuthority('system:role:list')")
public String ok(){
return "ok";
}
}
4.4.5 SpringSecurity异常处理
除了保护应用程序中受保护资源的访问,我们还希望在认证失败或授权失败时,能够返回与应用程序其他接口相同的 JSON 格式响应,以便前端能够统一处理。
4.4.5.1 ExceptionTranslationFilter介绍
ExceptionTranslationFilter
是 Spring Security 框架中的一个关键过滤器,用于处理请求过程中抛出的异常,并将其转化为合适的响应。它的主要作用是保护应用程序中受保护资源的访问,并根据用户的身份进行适当的响应。
当 Spring Security 抛出异常时,ExceptionTranslationFilter
将会捕获该异常并根据异常类型去判断是认证失败还是授权失败出现的异常。然后根据 Spring Security 的配置进行处理。
- 如果是认证过程中出现的异常会被封装成
AuthenticationException
, 然后调用AuthenticationEntryPoint对象的方法去进行异常处理。 - 如果是授权过程中出现的异常会被封装成
AccessDeniedException
, 然后调用AccessDeniedHandler对象的方法去进行异常处理。
4.4.5.2 认证过程中的异常处理
AuthenticationEntryPoint
是 Spring Security 中用于处理未经身份验证的用户访问受保护资源时的异常的接口。
**通过实现 **AuthenticationEntryPoint
接口,我们可以自定义未经身份验证的用户访问需要认证的资源时应该返回的响应。
/**
* 自定义认证过程异常处理
* @author spikeCong
* @date 2023/4/26
**/
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "认证失败请重新登录");
String json = JSON.toJSONString(result);
WebUtils.renderString(response,json);
}
}
4.4.5.3 授权过程中的异常处理
**在 Spring Security 中,当用户请求某个受保护的资源,但是由于权限不足或其他原因被拒绝访问时,Spring Security 会调用 **AccessDeniedHandler
来处理这种情况。
**通过自定义实现 **AccessDeniedHandler
接口,并覆盖 handle
方法,我们可以自定义处理用户被拒绝访问时应该返回的响应。
/**
* 自定义处理授权过程中的异常
* @author spikeCong
* @date 2023/4/26
**/
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(),"权限不足,禁止访问");
String json = JSON.toJSONString(result);
WebUtils.renderString(response,json);
}
}
4.4.5.4 配置SpringSecurity
- 先注入对应的处理器
@Autowired
private AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
private AccessDeniedHandler accessDeniedHandler;
- 然后使用HttpSecurity对象的方法去进行配置
//配置异常处理器
http.exceptionHandling()
//配置认证失败处理器
.authenticationEntryPoint(authenticationEntryPoint)
//配置授权失败处理器
.accessDeniedHandler(accessDeniedHandler);
测试一下
4.4.6 跨域解决方案CORS
4.4.6.1 什么是跨域 ?
首先一个url是由:协议、域名、端口 三部分组成。(一般端口默认80)
如:https://mashibing.com:80
跨域是指通过JS在不同的域之间进行数据传输或通信,比如用ajax向一个不同的域请求数据,只要****协议、域名、端口有任何一个不同,都被当作是不同的域,浏览器就不允许跨域请求。
- 跨域的几种常见情
如果跨域调用,会出现如下错误:
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
翻译过来就是:已被CORS策略阻止:对请求的响应未通过访问控制检查
, 这就是没有配置相关的跨域参数,是不能访问这个接口的
由于我们采用的是前后端分离的编程方式,前端和后端必定存在跨域问题。解决跨域问 题可以采用CORS
4.4.6.2 跨域产生原因?
(1) 出于浏览器的同源策略限制
所谓同源(即在同一个域)就是两个页面具有相同的协议(protocol)、主机(host)和端口号(port)。才可以互相访问
否则只要有一个不同,是不能访问的。
同源策略(Same Orgin Policy)是一种约定,它是浏览器核心也最基本的安全功能,它会阻止一个域的js脚本和另外一个域的内容进行交互,如果缺少了同源策略,浏览器很容易受到XSS、CSFR等攻击。
(2) 跨站脚本攻击(XSS)
(3) 跨站请求伪造 (CSRF)
CSRF(Cross-site request forgery)跨站请求伪造:攻击者诱导受害者进入第三方网站,在第三方网站中,向被攻击网站发送跨站请求。利用受害者在被攻击网站已经获取的注册凭证,绕过后台的用户验证,达到冒充用户对被攻击的网站执行某项操作的目的。
总结: XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。
非同源会出现的限制
- 无法读取非同源网页的cookie、localstorage等
- 无法接触非同源网页的DOM和js对象
- 无法向非同源地址发送Ajax请求
4.4.6.3 如何解决跨域问题
为了安全起见,浏览器在使用XMLHttpRequest对象发起HTTP请求时必须遵守同源策略。否则,这将被视为跨域请求,并且默认情况下将被禁止。同源策略要求协议、域名和端口号必须完全相同,以便进行正常通信。
在前后端分离的项目中,前端项目和后端项目通常不属于同一源,因此必然存在跨域请求的问题。因此,我们需要对其进行处理,以便前端能够进行跨域请求。
(1) CORS介绍
CORS(Cross-Origin Resource Sharing)即跨域资源共享,是一种用于处理跨域请求的机制。它允许浏览器向跨域服务器发送XMLHttpRequest请求,以便在不违反同源策略的情况下获取服务器上的资源。
CORS的实现方式主要是通过HTTP头部来实现的,浏览器会在请求中添加一些自定义的HTTP头部,告诉服务器请求的来源、目标地址等信息。服务器在接收到请求后,会根据请求头中的信息来判断是否允许跨域请求,并在响应头中添加一些自定义的HTTP头部,告诉浏览器是否允许请求、允许哪些HTTP方法、允许哪些HTTP头部等信息。
在响应头中添加以下字段,可以解决跨域问题:
access-control-allow-origin
: 该字段是必须的。它的值要么是请求时Origin
字段的值,要么是一个*
,表示接受任意域名的请求。access-control-allow-credentials
: 该字段可选。它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。设为true
,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器。这个值也只能设为true
,如果服务器不要浏览器发送Cookie,删除该字段即可Access-Control-Allow-Methods
: 该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次"预检"请求。
其实最重要的就是 access-control-allow-origin
字段,添加一个 * ,允许所有的域都能访问
(2) 配置SpringBoot的允许跨域
在SpringBoot项目中只需要编写一个配置类使其实现WebMvcConfigurer接口并重写其addCorsMappings方法即可。
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
// 设置允许跨域的路径
registry.addMapping("/**")
// 设置允许跨域请求的域名
.allowedOriginPatterns("*")
// 是否允许cookie
.allowCredentials(true)
// 设置允许的请求方式
.allowedMethods("GET", "POST", "DELETE", "PUT")
// 设置允许的header属性
.allowedHeaders("*")
// 跨域允许时间
.maxAge(3600);
}
}
**你也可以通过使用 **@CrossOrigin
注解来解决跨域问题。例如:
@RestController
public class MyController {
@CrossOrigin(origins = "http://localhost:8080")
@GetMapping("/my-endpoint")
public String myEndpoint() {
// ...
}
}
**这里 **@CrossOrigin
注解的 origins
参数指定了允许访问该接口的域名。在上面的例子中,只有来自 http://localhost:8080
域名的请求才能访问 myEndpoint
接口。
(3) 配置SpringSecurity允许跨域
由于我们的资源都会受到SpringSecurity的保护,所以想要跨域访问还要让SpringSecurity运行跨域访问。
//该方法用于配置 HTTP 请求的安全处理
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不会创建会话,每个请求都将被视为独立的请求。
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
//定义请求授权规则
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//将自定义认证过滤器,添加到过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//配置异常处理器
http.exceptionHandling()
//配置认证失败处理器
.authenticationEntryPoint(authenticationEntryPoint)
//配置授权失败处理器
.accessDeniedHandler(accessDeniedHandler);
//允许跨域
http.cors();
}
(4) 前后端联调测试
**首先运行我在资料中给大家提供的前端项目, **注意前端环境要提前配置完成
然后运行后端的项目,进行访问测试即可. 在SpringSecurity中这两行代码注释掉,才能复现跨域请求问题
http.csrf().disable();
http.cors();
4.4.7 权限校验扩展
4.4.7.1 @PreAuthorize注解中的其他方法
hasAuthority
:检查调用者是否具有指定的权限;
@RequestMapping("/hello")
@PreAuthorize("hasAuthority('system:user:list')")
public String hello(){
return "hello Spring Security! !";
}
hasAnyAuthority
:检查调用者是否具有指定的任何一个权限;
@RequestMapping("/ok")
@PreAuthorize("hasAnyAuthority('system:user:list,system:role:list')")
public String ok(){
return "ok Spring Security! !";
}
hasRole
:检查调用者是否有指定的角色;
**hasRole要求有对应的角色才可以访问,但是它内部会把我们传入的参数拼接上 **ROLE_ 后再去比较。所以这种情况下要用户对应的权限也要有 ROLE_ 这个前缀才可以。
@RequestMapping("/level1")
@PreAuthorize("hasRole('admin')")
public String level1(){
return "level1 page";
}
hasAnyRole
:检查调用者是否具有指定的任何一个角色;
@RequestMapping("/level2")
@PreAuthorize("hasAnyRole('admin','common')")
public String level2(){
return "level2 page";
}
4.4.7.2 权限校验源码分析
- 详见视频
4.4.7.3 自定义权限校验
我们也可以定义自己的权限校验方法,在@PreAuthorize注解中使用我们的方法。
/**
* 自定义权限校验方法
* @author spikeCong
* @date 2023/4/27
**/
@Component("my_ex")
public class MyExpression {
/**
* 自定义 hasAuthority
* @param authority 接口指定的访问权限限制
* @return: boolean
*/
public boolean hasAuthority(String authority){
//获取当前用户的权限
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
List<String> permissions = loginUser.getPermissions();
//判断集合中是否有authority
return permissions.contains(authority);
}
}
使用SPEL表达式,引入自定义的权限校验
SPEL(Spring Expression Language)是 Spring 框架提供的一种表达式语言,用于在 Spring 应用程序中进行编程和配置时使用。
Spring Security 中的权限表达式:可以使用 SPEL 表达式定义在授权过程中使用的逻辑表达式
@RequestMapping("/ok")
@PreAuthorize("@my_ex.hasAuthority('system:role:list')")
public String ok(){
return "ok";
}
4.4.7.4 基于配置的权限控制
- 在security配置类中,通过配置的方式对资源进行权限控制
@RequestMapping("/yes")
public String yes(){
return "yes";
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//关闭csrf
http.csrf().disable();
//允许跨域
http.cors();
http //不会创建会话,每个请求都将被视为独立的请求。
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
//定义请求授权规则
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
//配置形式的权限控制
.antMatchers("/yes").hasAuthority("system/menu/index")
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//将自定义认证过滤器,添加到过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//配置异常处理器
http.exceptionHandling()
//配置认证失败处理器
.authenticationEntryPoint(authenticationEntryPoint)
//配置授权失败处理器
.accessDeniedHandler(accessDeniedHandler);
}
4.4.7.5 角色加权限校验方式解析
(1) Role 和 Authority 的区别
用户拥有的权限,有以下两种表示
roles("admin","common","test")
authorities("system:user:list","system:role:list","system:menu:list");
给资源授予权限(角色或权限)
@PreAuthorize("hasAuthority('system:user:list')")
@PreAuthorize("hasAnyAuthority('system:user:list,system:role:list')")
@PreAuthorize("hasRole('admin')")
@PreAuthorize("hasAnyRole('admin','common')")
用户权限的保存方式
-
roles("admin","common","test")
,增加”ROLE“前缀存放:- 【“ROLE_admin”,“ROLE_common”,"ROLE_test"】 表示拥有的权限。
- 一个角色表示的是多个权限,用户传入的角色不能以 ROLE开头,否则会报错。ROLE是自动加上的 如果我们保存的用户的角色:直接传入角色的名字,权限【
new SimpleGrantedAuthority(“ROLE“ + role)
】保存即可
-
authorities (“USER”,”MANAGER”)
,原样存放:【"system:user:list","system:role:list"】
表示拥有的权限。- 如果我们保存的是真正的权限;直接传入权限名字,权限【
new SimpleGrantedAuthority(permission)
】保存
**无论是 Role 还是 Authority 都保存在 **
List<GrantedAuthority>
,每个用户都拥有自己的权限集合
用户权限的验证方式
- 通过角色(权限)验证: 拥有任何一个角色都可以访问,验证时会自动增加”ROLE_“进行查找验证:【”ROLE_admin”,”ROLE_common”】
- **通过权限验证: ** 拥有任何一个权限都可以访问,验证时原样查找进行验证:【”system:role:list”】
(2) 结合角色进行权限控制
- 创建Role角色实体
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "sys_role")
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Role implements Serializable {
@TableId
private Long roleId;
/**
* 角色名
*/
private String roleName;
/**
* 角色权限字符串
*/
private String roleKey;
/**
* 角色状态 0正常,1停用
*/
private String status;
/**
* 删除标志 0存在,1删除
*/
private String delFlag;
private Long createBy;
private Date createTime;
private Long updateBy;
private Date updateTime;
private String remark;
}
- RoleMapper
public interface RoleMapper extends BaseMapper<Role> {
List<String> selectRolesByUserId(Long id);
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mashibing.springsecurity_example.mapper.RoleMapper">
<select id="selectRolesByUserId" resultType="java.lang.String">
SELECT
sr.role_key
FROM sys_user_role sur
LEFT JOIN sys_role sr ON sur.role_id = sr.role_id
WHERE sur.user_id = #{userid} AND sr.status = 0 AND sr.del_flag = 0
</select>
</mapper>
- UserServiceDetailsImpl
@Data
public class LoginUser implements UserDetails {
private SysUser sysUser;
public LoginUser() {
}
public LoginUser(SysUser sysUser) {
this.sysUser = sysUser;
}
//存储权限信息集合
private List<String> permissions;
//存储角色信息集合
private List<String> roles;
public LoginUser(SysUser user, List<String> permissions) {
this.sysUser = user;
this.permissions = permissions;
}
public LoginUser(SysUser user, List<String> permissions, List<String> roles) {
this.sysUser = user;
this.permissions = permissions;
this.roles = roles;
}
//避免出现异常
@JSONField(serialize = false)
private List<SimpleGrantedAuthority> authorities;
/**
* 用于获取用户被授予的权限,可以用于实现访问控制。
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if(authorities != null){
return authorities;
}
//1.8 语法
authorities = permissions.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
//处理角色信息
authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
return authorities;
}
}
- Controller
@RequestMapping("/level1")
//当前用户是common角色,并且具有system:role:list或者system:user:list
@PreAuthorize("hasRole('common') AND hasAnyAuthority('system:role:list','system:user:list')")
public String level1(){
return "level1 page";
}
@RequestMapping("/level2")
//当前用户拥有admin或者common角色,或者具有system:role:list权限
@PreAuthorize("hasAnyRole('admin','common') OR hasAuthority('system:role:list')")
public String level2(){
return "level2 page";
}
- 测试一下
@RequestMapping("/level1")
//当前用户是common角色,并且具有system:role:list或者system:user:list
@PreAuthorize("hasRole('admin') AND hasAnyAuthority('system:role:list','system:user:list')")
public String level1(){
return "level1 page";
}
@RequestMapping("/level2")
//当前用户拥有admin或者common角色,或者具有system:role:list权限
@PreAuthorize("hasAnyRole('admin','common') OR hasAuthority('system:role:list')")
public String level2(){
return "level2 page";
}
4.4.8 认证方案扩展
我们首先创建一个新的项目,来进行接下来的案例演示,配置文件
server:
#服务器的HTTP端口
port: 8888
spring:
datasource:
url: jdbc:mysql://localhost:3306/test_security?characterEncoding=utf-8&serverTimezone=UTC
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
thymeleaf:
prefix: classpath:/templates/
suffix: .html
encoding: UTF-8
mode: HTML
cache: false
security:
user:
name: test
password: 123456
roles: admin,user
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
4.4.8.1 自定义认证
(1) 自定义资源权限规则
- 引入模板依赖
<!--thymeleaf-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
- 在 templates 中定义登录界面 login.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录页面</title>
</head>
<body>
<h1>用户登录</h1>
<form method="post" th:action="@{/login}">
用户名:<input name="username" type="text"/><br>
密码:<input name="password" type="password"/><br>
<input type="submit" value="登录"/>
</form>
</body>
</html>
- 配置 Spring Security 配置类
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests() //开始配置授权,即允许哪些请求访问系统
.mvcMatchers("/login.html").permitAll() //指定哪些请求路径允许访问
.mvcMatchers("/index").permitAll() //指定哪些请求路径允许访问
.anyRequest().authenticated() //除上述以外,指定其他所有请求都需要经过身份验证
.and()
.formLogin() //配置表单登录
.loginPage("/login.html") //登录页面
.loginProcessingUrl("/login") //提交路径
.usernameParameter("username") //表单中用户名
.passwordParameter("password") //表单中密码
.successForwardUrl("/index") //指定登录成功后要跳转的路径为 /index
//.defaultSuccessUrl("/index") //redirect 重定向 注意:如果之前请求路径,会有优先跳转之前请求路径
.failureUrl("/login.html") //指定登录失败后要跳转的路径为 /login.htm
.and()
.csrf().disable();//这里先关闭 CSRF
}
}
说明
- permitAll() 代表放行该资源,该资源为公共资源 无需认证和授权可以直接访问
- anyRequest().authenticated() 代表所有请求,必须认证之后才能访问
- **formLogin() 代表开启表单认证 **
- successForwardUrl 、defaultSuccessUrl 这两个方法都可以实现成功之后跳转
- **successForwardUrl 默认使用 **
forward
跳转注意:不会跳转到之前请求路径
- **defaultSuccessUrl 默认使用 **
redirect
跳转注意:如果之前有请求路径,会优先跳转之前请求路径,可以传入第二个参数进行修改
- **successForwardUrl 默认使用 **
注意: 放行资源必须放在所有认证请求之前!
- 创建Controller
@Controller
public class LoginController {
@RequestMapping("/ok")
public String ok(){
return "ok";
}
@RequestMapping("/login.html")
public String login(){
return "login";
}
}
(2) 自定义认证成功处理器
- 有时候页面跳转并不能满足我们,特别是在前后端分离开发中就不需要成功之后跳转页面。只需要给前端返回一个 JSON 通知登录成功还是失败与否。这个时候可以通过自定义
AuthenticationSucccessHandler
实现
public interface AuthenticationSuccessHandler {
void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException;
}
根据接口的描述信息,也可以得知登录成功会自动回调这个方法,进一步查看它的默认实现,你会发现successForwardUrl、defaultSuccessUrl也是由它的子类实现的
- 自定义 AuthenticationSuccessHandler 实现
@Component
public class AuthenticationSuccessHandlerImpl implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
Map<String, Object> result = new HashMap<String, Object>();
result.put("msg", "登录成功");
result.put("status", 200);
response.setContentType("application/json;charset=UTF-8");
String s = new ObjectMapper().writeValueAsString(result);
response.getWriter().println(s);
}
}
- 配置 AuthenticationSuccessHandler
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler successHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
.and()
.formLogin() //配置表单登录
.successHandler(successHandler)
.failureUrl("/login.html") //指定登录失败后要跳转的路径为 /login.htm
.and()
.csrf().disable();//这里先关闭 CSRF
}
}
- 测试一下
(3) 自定义认证失败处理器
- 和自定义登录成功处理一样,Spring Security 同样为前后端分离开发提供了登录失败的处理,这个类就是 AuthenticationFailureHandler,源码为:
public interface AuthenticationFailureHandler {
void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException;
}
根据接口的描述信息,也可以得知登录失败会自动回调这个方法,进一步查看它的默认实现,你会发现failureUrl、failureForwardUrl也是由它的子类实现的。
- 自定义 AuthenticationFailureHandler 实现
@Component
public class AuthenticationFailureHandlerImpl implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
Map<String, Object> result = new HashMap<String, Object>();
result.put("msg", "登录失败: "+exception.getMessage());
result.put("status", 500);
response.setContentType("application/json;charset=UTF-8");
String s = new ObjectMapper().writeValueAsString(result);
response.getWriter().println(s);
}
}
- 配置 AuthenticationFailureHandler
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
//...
.and()
.formLogin()
//..
.failureHandler(new MyAuthenticationFailureHandler())
.and()
.csrf().disable();//这里先关闭 CSRF
}
}
- 测试一下
(4) 自定义注销登录处理器
Spring Security 中也提供了默认的注销登录配置,在开发时也可以按照自己需求对注销进行个性化定制。
-
开启注销登录
默认开启
@Configuration public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeHttpRequests() //... .and() .formLogin() //... .and() .logout() .logoutUrl("/logout") .invalidateHttpSession(true) .clearAuthentication(true) .logoutSuccessUrl("/login.html") .and() .csrf().disable();//这里先关闭 CSRF } }
- 通过 logout() 方法开启注销配置
- **logoutUrl 指定退出登录请求地址,默认是 GET 请求,路径为 **
/logout
- invalidateHttpSession 退出时是否是 session 失效,默认值为 true
- clearAuthentication 退出时是否清除认证信息,默认值为 true
- logoutSuccessUrl 退出登录时跳转地址
前后端分离注销登录配置
- 如果是前后端分离开发,注销成功之后就不需要页面跳转了,只需要将注销成功的信息返回前端即可,此时我们可以通过自定义 LogoutSuccessHandler 实现来返回注销之后信息:
@Component
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
Map<String, Object> result = new HashMap<String, Object>();
result.put("msg", "注销成功");
result.put("status", 200);
response.setContentType("application/json;charset=UTF-8");
String s = new ObjectMapper().writeValueAsString(result);
response.getWriter().println(s);
}
}
- 配置
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Autowired
private LogoutSuccessHandler logoutSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests() //开始配置授权,即允许哪些请求访问系统
.and()
.formLogin() //配置表单登录
//...
.and()
.logout()
// .logoutUrl("/logout")
.invalidateHttpSession(true)
.clearAuthentication(true)
// .logoutSuccessUrl("/login.html")
.logoutSuccessHandler(logoutSuccessHandler)
.and()
.csrf().disable();//这里先关闭 CSRF
}
}
- 测试
4.4.8.2 添加图形验证码
在用户登录时,一般通过表单的方式进行登录都会要求用户输入验证码,Spring Security
默认没有实现图形验证码的功能,所以需要我们自己实现。
图形验证码一般是在用户名、密码认证之前进行验证的,所以需要在 UsernamePasswordAuthenticationFilter
过滤器之前添加一个自定义过滤器 ImageCodeValidateFilter
,用来校验用户输入的图形验证码是否正确。
自定义的过滤器 ImageCodeValidateFilter 首先会判断请求是否为 POST 方式的登录表单提交请求,如果是就将其拦截进行图形验证码校验。如果验证错误,会抛出自定义异常类对象 ValidateCodeException,该异常类需要继承 AuthenticationException 类。在自定义过滤器中,我们需要手动捕获自定义异常类对象,并将捕获到自定义异常类对象交给自定义失败处理器进行处理。
(1) 传统web开发
Kaptcha 是谷歌提供的生成图形验证码的工具,参考地址为:https://github.com/penggle/kaptcha,依赖如下:
Kaptcha 是一个可高度配置的实用验证码生成工具,可自由配置的选项如:
- 验证码的字体
- 验证码字体的大小
- 验证码字体的字体颜色
- 验证码内容的范围(数字,字母,中文汉字!)
- 验证码图片的大小,边框,边框粗细,边框颜色
- 验证码的干扰线
- 验证码的样式(鱼眼样式、3D、普通模糊、…)
- 引入依赖
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
- 添加验证码配置类
@Configuration
public class KaptchaConfig {
@Bean
public Producer kaptcha() {
Properties properties = new Properties();
// 是否有边框
properties.setProperty(Constants.KAPTCHA_BORDER, "yes");
// 边框颜色
properties.setProperty(Constants.KAPTCHA_BORDER_COLOR, "192,192,192");
// 验证码图片的宽和高
properties.setProperty(Constants.KAPTCHA_IMAGE_WIDTH, "110");
properties.setProperty(Constants.KAPTCHA_IMAGE_HEIGHT, "40");
// 验证码颜色
properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_COLOR, "0,0,0");
// 验证码字体大小
properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_SIZE, "32");
// 验证码生成几个字符
properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, "4");
// 验证码随机字符库
properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_STRING, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYAZ");
// 验证码图片默认是有线条干扰的,我们设置成没有干扰
properties.setProperty(Constants.KAPTCHA_NOISE_IMPL, "com.google.code.kaptcha.impl.NoNoise");
Config config = new Config(properties);
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
- 创建验证码实体类
public class CheckCode implements Serializable {
private String code; //验证字符
private LocalDateTime expireTime; //过期时间
public CheckCode(String code, int expireTime) {
this.code = code;
//返回指定的过期时间
this.expireTime = LocalDateTime.now().plusSeconds(expireTime);
}
public CheckCode(String code) {
//默认验证码 60秒后过期
this(code,60);
}
//是否过期
public boolean isExpired(){
return this.expireTime.isBefore(LocalDateTime.now());
}
public String getCode() {
return code;
}
}
- 创建生成验证码Controller
@Controller
public class KaptchaController {
private final Producer producer;
@Autowired
public KaptchaController(Producer producer) {
this.producer = producer;
}
@GetMapping("/code/image")
public void getVerifyCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
//1.创建验证码文本
String capText = producer.createText();
//2.创建验证码图片
BufferedImage bufferedImage = producer.createImage(capText);
//3.将验证码文本放进 Session 中
CheckCode code = new CheckCode(capText);
request.getSession().setAttribute(Constants.KAPTCHA_SESSION_KEY, code);
//4.将验证码图片返回,禁止验证码图片缓存
response.setHeader("Cache-Control", "no-store");
response.setHeader("Pragma", "no-cache");
response.setDateHeader("Expires", 0);
//5.设置ContentType
response.setContentType("image/png");
ImageIO.write(bufferedImage,"jpg",response.getOutputStream());
}
}
- 在 login.html 中添加验证码功能
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<h3>表单登录</h3>
<form method="post" th:action="@{/login}">
<input type="text" name="username" placeholder="用户名"><br>
<input type="password" name="password" placeholder="密码"><br>
<input name="imageCode" type="text" placeholder="验证码"><br>
<img th:onclick="this.src='/code/image?'+Math.random()" th:src="@{/code/image}" alt="验证码"/><br>
<button type="submit">登录</button>
</form>
</body>
</html>
- 更改安全配置类 SpringSecurityConfig,设置访问
/code/image
不需要任何权限
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests() //开始配置授权,即允许哪些请求访问系统
.mvcMatchers("/login.html","/code/image").permitAll() //指定哪些请求路径允许访问
.anyRequest().authenticated() //除上述以外,指定其他所有请求都需要经过身份验证
.and()
.formLogin() //配置表单登录
//......
}
}
- 测试
访问 http://localhost:8888/login.html
,出现图形验证的信息
- 创建自定义异常类
/**
* 自定义验证码错误异常
* @author spikeCong
* @date 2023/4/29
**/
public class KaptchaNotMatchException extends AuthenticationException {
public KaptchaNotMatchException(String msg) {
super(msg);
}
public KaptchaNotMatchException(String msg, Throwable cause) {
super(msg, cause);
}
}
- 自定义图形验证码校验过滤器
@Component
public class KaptchaFilter extends OncePerRequestFilter {
//前端输入的图形验证码参数
private String codeParameter = "imageCode";
//自定义认证失败处理器
@Autowired
private AuthenticationFailureHandler failureHandler;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
//非post请求的表单提交不校验 图形验证码
if (request.getMethod().equals("POST")) {
try {
//校验图形验证码合法性
validate(request);
} catch (KaptchaNotMatchException e) {
failureHandler.onAuthenticationFailure(request,response,e);
return;
}
}
//放行进入下一个过滤器
filterChain.doFilter(request,response);
}
//判断验证码合法性
private void validate(HttpServletRequest request) throws KaptchaNotMatchException {
//1.获取用户传入的图形验证码值
String requestCode = request.getParameter(this.codeParameter);
if(requestCode == null){
requestCode = "";
}
requestCode = requestCode.trim();
//2.获取session中的验证码值
HttpSession session = request.getSession();
CheckCode checkCode =(CheckCode) session.getAttribute(Constants.KAPTCHA_SESSION_KEY);
if(checkCode != null){
//清除验证码,不管成功与否,客户端应该在登录失败后 刷新验证码
session.removeAttribute(Constants.KAPTCHA_SESSION_KEY);
}
// 校验出错,抛出异常
if (StringUtils.isBlank(requestCode)) {
throw new KaptchaNotMatchException("验证码的值不能为空");
}
if (checkCode == null) {
throw new KaptchaNotMatchException("验证码不存在");
}
if (checkCode.isExpired()) {
throw new KaptchaNotMatchException("验证码过期");
}
if (!requestCode.equalsIgnoreCase(checkCode.getCode())) {
throw new KaptchaNotMatchException("验证码输入错误");
}
}
}
- 更改安全配置类 SpringSecurityConfig,将自定义过滤器添加过滤器链中
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler successHandler;
@Autowired
private AuthenticationFailureHandler failureHandler;
@Autowired
private LogoutSuccessHandler logoutSuccessHandler;
@Autowired
private KaptchaFilter kaptchaFilter;
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//开启基于HTTP请求访问控制
http.authorizeHttpRequests()
//开始配置授权,即允许哪些请求访问系统
.mvcMatchers("/login.html","/code/image").permitAll()
//除上述以外,指定其他所有请求都需要经过身份验证
.anyRequest().authenticated();
//开启 form表单登录
http.formLogin()
.loginPage("/login.html") //登录页面(覆盖security的)
.loginProcessingUrl("/login") //提交路径
.usernameParameter("username") //表单中用户名
.passwordParameter("password") //表单中密码
// 使用自定义的认证成功和失败处理器
.successHandler(successHandler)
.failureHandler(failureHandler);
//开启登出配置
http.logout()
.invalidateHttpSession(true)
.clearAuthentication(true)
.logoutSuccessHandler(logoutSuccessHandler);
http.csrf().disable();//这里先关闭 CSRF
//将自定义图形验证码校验过滤器,添加到UsernamePasswordAuthenticationFilter之前
http.addFilterBefore(kaptchaFilter, UsernamePasswordAuthenticationFilter.class);
}
}
- 测试
访问 http://localhost:8888/login.html
,出现图形验证的信息,输入 用户名密码及 正确验证码
访问 localhost:8080/login/page
,等待 60 秒后,输入正确的用户名、密码和验证码:
(3) 前后端分离开发
图形验证码包含两部分:图片和文字验证码。
- 在JSP时代,图形验证码生成和验证是通过Session机制来实现的:后端生成图片和文字验证码,并将文字验证码放在session中,前端填写验证码后提交到后台,通过与session中的验证码比较来实现验证。
- 在前后端分离的项目中,登录使用的是Token验证,而不是Session。后台必须保证当前用户输入的验证码是用户开始请求页面时候的验证码,必须保证验证码的唯一性。
前后端分离开发方式保证验证码唯一性的解决思路
-
把生成的验证码放在全局的的缓存中,如redis,并设置一个过期时间。
-
前端验证时,需要把验证码的id也带上,供后端验证。
为每个验证码code分配一个主键codeId。前端请求验证码code时,将codeId在前端生成并发送给后端;后端生成code及图形验证码后,并与codeId关联保存;前端填写好code后,将code和codeId一并提交到后端,后端对code和codeId进行比较,完成验证。
-
后台在生成图片后使用Base64进行编码
Base64用于将二进制数据编码成ASCII字符 (图片、文件等都可转化为二进制数据)
1. 回到第一个 springsecurity
项目, 先创建一个 CaptchaController
- **导入easy-captcha **https://gitee.com/ele-admin/EasyCaptcha
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
<version>1.6.2</version>
</dependency>
@RestController
public class CaptchaController {
@Autowired
private RedisCache redisCache;
/**
* 生成验证码
* @param response
* @return: com.mashibing.springsecurity_example.common.ResponseResult
*/
@GetMapping("/captchaImage")
public ResponseResult getCode(HttpServletResponse response){
SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);
//生成验证码,及验证码唯一标识
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
String key = Constants.CAPTCHA_CODE_KEY + uuid;
String code = specCaptcha.text().toLowerCase();
//保存到redis
redisCache.setCacheObject(key,code,1000, TimeUnit.SECONDS);
//创建map
HashMap<String,Object> map = new HashMap<>();
map.put("uuid",uuid);
map.put("img",specCaptcha.toBase64());
return new ResponseResult(200,"验证码获取成功",map);
}
}
2. 创建用户登录对象
/**
* 用户登录对象
* @author spikeCong
* @date 2023/4/30
**/
public class LoginBody {
/**
* 用户名
*/
private String userName;
/**
* 用户密码
*/
private String password;
/**
* 验证码
*/
private String code;
/**
* 唯一标识
*/
private String uuid = "";
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getUuid() {
return uuid;
}
public void setUuid(String uuid) {
this.uuid = uuid;
}
}
3. LoginController 中创建处理验证码的登录方法
/**
* 登录方法
*
* @param loginBody 登录信息
* @return 结果
*/
@PostMapping("/user/login")
public ResponseResult login(@RequestBody LoginBody loginBody)
{
// 生成令牌
String token = loginService.login(loginBody.getUserName(), loginBody.getPassword(), loginBody.getCode(),
loginBody.getUuid());
Map<String,Object> map = new HashMap<>();
map.put("token",token);
return new ResponseResult(200,"登录成功",map);
}
4. LoginService中创建处理验证码的登录方法
public interface LoginService {
String login(String username, String password, String code, String uuid);
}
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisCache redisCache;
/**
* 带验证码登录
* @param username
* @param password
* @param code
* @param uuid
* @return: java.lang.String
*/
@Override
public String login(String username, String password, String code, String uuid) {
//从redis中获取验证码
String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
String captcha = redisCache.getCacheObject(verifyKey);
redisCache.deleteObject(captcha);
if (captcha == null || !code.equalsIgnoreCase(captcha)){
throw new CaptchaNotMatchException("验证码错误!");
}
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
Authentication authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
//3.如果认证通过,使用userId生成一个JWT,并将其保存到 ResponseResult对象中返回
//3.1 获取经过身份验证的用户的主体信息
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
//3.2 获取到userID 生成JWT
String userId = loginUser.getSysUser().getUserId().toString();
String jwt = JwtUtil.createJWT(userId);
//4.将用户信息存储在Redis中,在下一次请求时能够识别出用户,userid作为key
redisCache.setCacheObject("login:"+userId,loginUser);
//5.封装ResponseResult,并返回
return jwt;
}
}
5.添加自定义异常
public class CaptchaNotMatchException extends AuthenticationException {
public CaptchaNotMatchException(String msg) {
super(msg);
}
public CaptchaNotMatchException(String msg, Throwable cause) {
super(msg, cause);
}
}
6.配置类中添加配置
// 对于登录接口 允许匿名访问
.mvcMatchers("/user/login","/captchaImage").anonymous()
通常 mvcMatcher 比 antMatcher 更安全:
antMatchers(“/secured”) 仅仅匹配 /secured
mvcMatchers(“/secured”) 匹配 /secured 之余还匹配 /secured/, /secured.html, /secured.xyz
因此 mvcMatcher 更加通用且容错性更高。
7.前后端联调测试
- VSCode导入前端项目, 导入带有验证码 security_demo_captcha项目
注意
node_modules
我已经给大家下载好了, 就不需要执行npm install
- npm run serve 启动项目,即可看到生成的验证码
请求信息
输入正确的用户名密码,验证码 登录成功.