文章目录
-
- 1.JWT 入门
-
- 1.1 JWT 概念
- 1.2 JWT 应用场景
- 1.3 为何选择 JWT
-
- 基于 Session 的传统认证
- 基于 JWT 的认证
- 1.4 JWT 的结构
-
- 标头(Header)
- 载荷(Payload)
- 签名(Signature)
- 1.5 RBAC (Role-Based Access Control)
- 1.6 JWT 基本使用
-
- 添加依赖
- 生成 Token
- 解析 Token
- 2.Security 整合 JWT
-
- 2.1 单独抽离 Security 模块
-
- 添加相关依赖
- JWT 工具类
- JWT 相关配置
- JWT 登录授权过滤器
- 自定义 AuthenticationEntryPoint
- 自定义 AccessDeniedHandler
- 注册自定义的组件
- 直接放行的白名单
- 配置 SecurityFilterChain
- 2.2 其他模块使用 Security 模块
-
- 添加 Security 模块依赖
- 自定义 UserDetails
- 自定义 UserDetailsService
- 自定义 AccessDecisionManager
- Swagger 适配
- 解觉跨域问题
- 登录并返回 Token
TIP:更多文章笔者考虑同步到 javgo.cn,目前内容较少,后续会陆续更新。
1.JWT 入门
1.1 JWT 概念
官方网站:https://jwt.io/introduction/
官方介绍如下:
JSON Web Token(JWT)是一个定义在 RFC 7519 开放标准下的技术,提供了一种紧凑且自包含的方式用于在各方之间安全地传输信息。JWT 使用 JSON 对象作为载体,同时通过数字签名来验证和确保信息的可信度。数字签名可以通过秘密密钥(HMAC 算法)或是公钥/私钥对(使用 RSA 或 ECDSA)生成。
说简单点就是:JWT 是一种通过 JSON 形式作为 Web 应用中的令牌(Token),能够在各方之间安全地将信息作为 JSON 对象传输,并可以在传输过程中完成数据加密、签名等相关处理。
1.2 JWT 应用场景
- 授权(Authorization):JWT 常用于用户授权。一旦用户登录成功就可以获得一个令牌(Token),后续的每个请求都将在其请求头中携带该令牌(Token),以允许用户访问令牌(Token)授权的路由、服务和资源等。由于 JWT 的小开销和跨域能力,JWT 被广泛应用于单点登录(Single Sign-On)。
- 信息交换(Information Exchange):JWT 提供了一种安全的方式在各方之间传输信息。JWT 可以通过数字签名来确定发送者的身份。同时,由于数字签名是根据标头和有效负载计算的,所以还可以验证内容是否被篡改过。
1.3 为何选择 JWT
基于 Session 的传统认证
HTTP 协议本身是无状态的,这意味着用户每次发出请求时都必须进行身份验证。为了使应用能识别是哪个用户发出的请求,我们只能在服务器端的 Session 域中存储一份用户登录的信息。这份登录信息会以 SessionID 的形式在响应时传递给浏览器进行缓存,并告诉其保存为 Cookie,以便下次请求时发送给服务端进行身份验证。
然而,这种方法有一些不可避免的问题:
- 每个经过身份认证的用户都会在服务器端 Session 域中存储一条记录。随着认证用户的增多,服务端的开销会显著增大;
- 认证记录通常保存在服务器内存中,这意味着用户下次请求必须发送到同一台服务器,这在分布式应用中限制了负载均衡器的能力;同时,如果后端应用是集群多节点部署,就需要实现 Session 共享机制,这给集群应用带来了不便。
- 携带 SessionID 的 Cookie 容易被截获,用户可能会受到跨站请求伪造(CSRF)攻击;
- 在前后端分离的系统中,用户的每次请求都需要通过代理(如 Nginx)转发多次,并在服务器上查询用户信息。这将给服务器带来额外的负担,并增加部署的复杂性。
基于 JWT 的认证
用户首次登录成功后,服务器会返回一个令牌(Token)。用户随后每次请求受保护资源时,都需要在 HTTP 请求头(Request Header)中添加一个 Authorization 字段,字段值就是 Bearer 加上此令牌(Token)。服务器通过对 Authorization 字段值信息(也就是 Token)的解码及数字签名校验来获取其中的用户信息,从而实现认证和授权。(“Bearer” 中文翻译可以理解为 “携带” 的意思)
下面是一个请求头中的 Authorization 字段携带 Token 的示例:
Authorization: Bearer
下面是 JWT 官方给的工作原理图:
看起来过于草率了,下面是一个扩展后的原理图:
基于 JWT 的认证避免了传统 Session 认证机制存在的问题:
- 客户端发起认证请求:用户通过登录表单将用户名和密码发送到后端的接口,这一过程通常是一个 HTTP POST 请求。为避免敏感信息被嗅探,建议使用 SSL 加密传输(HTTPS 协议)。
- 服务端生成令牌(Token):服务端在核对用户名和密码成功后,会将用户的 id 等其他信息作为 JWT Payload(负载),并签名生成一个 JWT (Token),形成的 JWT 是一个形如 xxx.yyy.zzz 的字符串。(下面马上会介绍)
- 前端保存令牌(Token):JWT 字符串作为登录成功的返回结果返回给前端,前端将返回的结果保存在本地浏览器的 localStorage 或 sessionStorage 中。
- 后续请求携带令牌(Token):后续用户每次请求服务端资源时,都需要将 JWT 放入 HTTP Header 的 Authorization 位(Bearer + Token),避免了 XSS(跨站脚本攻击)和 CSRF(跨站请求伪造)问题。
- 服务端拦截请求解析并验证令牌(Token):后端会拦截请求,检查请求头中是否携带令牌(Token),如果存在则进行解析并验证其有效性。例如,检查签名是否正确,检查 Token 是否过期,检查 Token 的接收方是否是自己等(可选)。
- 响应结果:验证通过后,后端使用 JWT 中包含的用户信息进行其他逻辑操作,返回相应结果。
[!WARNING]
对于已签名的令牌(Token),即使其他人无法更改令牌,但令牌中的所有信息都是公开的。因此,敏感信息(如密码)不应放入令牌中,应该只存放不影响隐私的信息,如用户 ID 等。同时,如果通过 HTTP 请求头发送 JWT,要尽量保证令牌的大小不要过大,因为某些服务器可能不接受超过 8 KB 的标头。
1.4 JWT 的结构
JWT由三个以 “·
” 为分隔符的部分组成:标头(Header)、载荷(Payload)以及签名(Signature)。header.payload.signature
标头(Header)
标头(Header)主要包含两个信息:令牌类型(typ)和所使用的加密算法(alg),例如 HS256 或者 RSA。将这部分信息采用 JSON 格式存储,然后通过 Base64 编码处理,就构成了 JWT 的第一部分。例如:
{
"alg": "HS256",
"typ": "JWT"
}
[!WARNING]
Base64 仅仅是一种编码方式,而非加密方式,其内容可以很容易地解码出来。
载荷(Payload)
载荷(Payload)部分包含了所要传递的数据,通常这些数据都是一些声明(claims),例如用户身份信息、token 的生成时间、过期时间等。载荷也需要进行 Base64 编码,形成 JWT 的第二部分。例如:
{
"sub": "1234567890",
"name": "John Doe",
"created": 1489079981393,
"exp": 1489684781
"admin": true
}
[!WARNING]
虽然经过签名的令牌能防止数据被篡改,但任何人都可以读取其中的信息。因此,敏感信息应该避免存储在 JWT 的有效载荷或标头中,除非它们被加密。
签名(Signature)
签名(Signature)部分是用于验证消息在传输过程中未被篡改,以及验证令牌发送者的身份。签名部分需要使用 header,payload,密钥,以及 header 中声明的加密方式(如 HS256)共同生成。例如:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
这样,JWT 的最终形式是三部分通过 “.” 连接的 Base64-URL 字符串。它不仅适用于在 HTML 和 HTTP 环境中传输,而且比基于 XML 的标准(如 SAML)更为简洁。
下面是一个最终的 JWT 字符串实例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
我们可以在该网站上获得解析结果:https://jwt.io/
1.5 RBAC (Role-Based Access Control)
RBAC,即基于角色的访问控制,是一种常用的企业安全策略。在 RBAC 中,权限和角色相关联,用户通过成为对应角色的成员而获得权限。
RBAC 的核心概念包括:
- 用户(User):系统的使用者。
- 角色(Role):系统中职责的抽象。
- 权限(Permission):对系统资源的访问能力。
用户和角色、角色和权限、用户和权限之间都可以是多对多的关系,所以 RBAC 可以实现非常细致和灵活的权限管理。
RBAC 有许多优点:
- 简化管理:只需定义角色和权限,然后为用户分配角色即可。
- 增强用户 –> 角色 –> 权限
用户可以拥有一个或多个角色,角色可以包含一个或多个权限。当用户尝试访问系统资源时,系统将根据用户的角色和角色所拥有的权限来判断用户是否有权限进行操作。
1.6 JWT 基本使用
添加依赖
在项目的 pom.xml 文件中添加相关依赖:
dependency>
groupId>io.jsonwebtokengroupId>
artifactId>jjwtartifactId>
version>0.9.1version>
dependency>
生成 Token
@Test
void testGetToken() {
// 创建 Token 过期时间(7 天)
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DATE, 7);
// 创建有效载荷中的声明
MapString,Object> claims = new HashMap>();
claims.put("sub", "javgo"); // 用户名
claims.put("created", new Date()); // 创建时间
claims.put("roles", "admin"); // 角色
claims.put("authorities", "admin"); // 权限
claims.put("id", 1); // 用户 ID
// 生成 Token
String token = Jwts.builder()
.setHeaderParam("typ", "JWT") // 设置 Token 类型(默认是 JWT)
.setHeaderParam("alg", "HS256") // 设置签名算法(默认是 HS256)
.setClaims(claims) // 设置有效载荷中的声明
.signWith(SignatureAlgorithm.HS256, "hags213#ad&*sdk".getBytes()) // 设置签名使用的密钥和签名算法
.setExpiration(calendar.getTime()) // 设置 Token 过期时间
.compact();
System.out.println(token);
}
执行结果如下:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqYXZnbyIsImNyZWF0ZWQiOjE2OTAwMDkyMjk3NzksInJvbGVzIjoiYWRtaW4iLCJpZCI6MSwiZXhwIjoxNjkwNjE0MDI5LCJhdXRob3JpdGllcyI6ImFkbWluIn0.-VYyJNemNB0XS2Qk3Ai77MirRPobyZ0EnQgoKiv9IXE
Base64 解析结果如下:
解析 Token
@Test
void analysisToken(){
Claims claims = Jwts.parser() // 解析
.setSigningKey("hags213#ad&*sdk".getBytes()) // 设置密钥(会自动推断算法)
.parseClaimsJws("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqYXZnbyIsImNyZWF0ZWQiOjE2OTAwMDkyMjk3NzksInJvbGVzIjoiYWRtaW4iLCJpZCI6MSwiZXhwIjoxNjkwNjE0MDI5LCJhdXRob3JpdGllcyI6ImFkbWluIn0.-VYyJNemNB0XS2Qk3Ai77MirRPobyZ0EnQgoKiv9IXE") // 设置要解析的 Token
.getBody();// 获取有效载荷中的声明
System.out.println("用户名:" + claims.get("sub"));
System.out.println("创建时间:" + claims.get("created"));
System.out.println("角色:" + claims.get("roles"));
System.out.println("权限:" + claims.get("authorities"));
System.out.println("用户 ID:" + claims.get("id"));
System.out.println("过期时间:" + claims.getExpiration());
}
执行结果如下:
用户名:javgo
创建时间:1690009229779
角色:admin
权限:admin
用户 ID:1
过期时间:Sat Jul 29 15:00:29 CST 2023
[!TIP]
常见异常信息:
- SignatureVerificationException:签名不一致异常
- TokenExpiredException:令牌过期异常
- AlgorithmMismatchException:签名算法不匹配异常
- InvalidClaimException:失效的 payload 异常
2.Security 整合 JWT
2.1 单独抽离 Security 模块
在日常项目开发中我们的一个项目是由多个独立的模块构成,需要使用另一个模块时引入对应的模块依赖(坐标)即可。这里为了更好的代码复用(安全模块复用),我们也将安全模块进行单独开发。
添加相关依赖
在项目的 pom.xml 文件中添加如下依赖:
dependency>
groupId>org.springframework.bootgroupId>
artifactId>spring-boot-starter-securityartifactId>
dependency>
dependency>
groupId>io.jsonwebtokengroupId>
artifactId>jjwtartifactId>
version>0.9.1version>
dependency>
dependency>
groupId>io.springfoxgroupId>
artifactId>springfox-boot-starterartifactId>
version>3.0.0version>
dependency>
dependency>
groupId>cn.hutoolgroupId>
artifactId>hutool-allartifactId>
version>5.8.9version>
dependency>
dependency>
groupId>org.projectlombokgroupId>
artifactId>lombokartifactId>
optional>trueoptional>
dependency>
JWT 工具类
编写一个 JWT 工具类 JwtTokenUtil 负责令 Token 的生成、解析、验证、刷新等功能。
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* JWT 工具类
*/
public class JwtTokenUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenUtil.class);
// Claim 中的用户名
private static final String CLAM_KEY_USERNAME = "sub";
// Claim 中的创建时间
private static final String CLAM_KEY_CREATED = "created";
// JWT 密钥
@Value("${jwt.secret}")
private String secret;
// JWT 过期时间
@Value("${jwt.expiration}")
private Long expiration;
// Authorization 请求头中的 token 字符串的开头部分(Bearer)
@Value("${jwt.tokenHead}")
private String tokenHead;
//================ private methods ==================
/**
* 根据负载生成 JWT 的 token
* @param claims 负载
* @return JWT 的 token
*/
private String generateToken(MapString,Object> claims){
return Jwts.builder()
.setClaims(claims) // 设置负载
.setExpiration(generateExpirationDate()) // 设置过期时间
.signWith(SignatureAlgorithm.HS512,secret) // 设置签名使用的签名算法和签名使用的秘钥
.compact();
}
/**
* 生成 token 的过期时间
* @return token 的过期时间
*/
private Date generateExpirationDate(){
/*
Date 构造器接受格林威治时间,推荐使用 System.currentTimeMillis() 获取当前时间距离 1970-01-01 00:00:00 的毫秒数
而我们在配置文件中配置的是秒数,所以需要乘以 1000。
一般而言 Token 的过期时间为 7 天,因此我们一般在 Spring Boot 的配置文件中将 jwt.expiration 设置为 604800,
即 7 * 24 * 60 * 60 = 604800 秒。
*/
return new Date(System.currentTimeMillis() + expiration * 1000);
}
/**
* 从 token 中获取 JWT 中的负载
* @param token JWT 的 token
* @return JWT 中的负载
*/
private Claims getClaimsFromToken(String token){
Claims claims = null;
try{
claims = Jwts.parser() // 解析 JWT 的 token
.setSigningKey(secret) // 指定签名使用的密钥(会自动推断签名的算法)
.parseClaimsJws(token) // 解析 JWT 的 token
.getBody(); // 获取 JWT 的负载(即要传输的数据)
}catch (Exception e){
LOGGER.info("JWT 格式验证失败:{}",token);
}
return claims;
}
/**
* 验证 token 是否过期
* @param token JWT 的 token
* @return token 是否过期 true:过期 false:未过期
*/
private boolean isTokenExpired(String token){
Date expiredDate = getExpiredDateFromToken(token);
return expiredDate.before(new Date());
}
/**
* 从 token 中获取过期时间
* @param token JWT 的 token
* @return 过期时间
*/
private Date getExpiredDateFromToken(String token){
return getClaimsFromToken(token).getExpiration();
}
/**
* 判断 token 是否可以被刷新
* @param token JWT 的 token
* @param time 指定时间段(单位:秒)
* @return token 是否可以被刷新 true:可以 false:不可以
*/
private boolean tokenRefreshJustBefore(String token,int time){
// 解析 JWT 的 token 拿到负载
Claims claims = getClaimsFromToken(token);
// 获取 token 的创建时间
Date tokenCreateDate = claims.get(CLAM_KEY_CREATED, Date.class);
// 获取当前时间
Date refreshDate = new Date();
// 条件1: 当前时间在 token 创建时间之后
// 条件2: 当前时间在(token 创建时间 + 指定时间段)之前(即指定时间段内可以刷新 token)
return refreshDate.after(tokenCreateDate) && refreshDate.before(DateUtil.offsetSecond(tokenCreateDate, time));
}
//================ public methods ==================
/**
* 从 token 中获取登录用户名
* @param token JWT 的 token
* @return 登录用户名
*/
public String getUserNameFromToken(String token){
String username;
try{
// 从 token 中获取 JWT 中的负载
Claims claims = getClaimsFromToken(token);
// 从负载中获取用户名
username = claims.getSubject();
}catch (Exception e){
username = null;
}
return username;
}
/**
* 验证 token 是否有效
* @param token JWT 的 token
* @param userDetails 从数据库中查询出来的用户信息(需要自定义 UserDetailsService 和 UserDetails)
* @return token 是否有效 true:有效 false:无效
*/
public boolean validateToken(String token, UserDetails userDetails){
// 从 token 中获取用户名
String username = getUserNameFromToken(token);
// 条件一:用户名不为 null
// 条件二:用户名和 UserDetails 中的用户名一致
// 条件三:token 未过期
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
/**
* 根据用户信息生成 token
* @param userDetails 用户信息(需要自定义 UserDetails)
* @return token 字符串
*/
public String generateToken(UserDetails userDetails){
// 创建负载
MapString,Object> claims = new HashMap>();
// 设置负载中的用户名
claims.put(CLAM_KEY_USERNAME,userDetails.getUsername());
// 设置负载中的创建时间
claims.put(CLAM_KEY_CREATED,new Date());
// 根据负载生成 token
return generateToken(claims);
}
/**
* 判断 token 是否可以被刷新
* @param oldToken JWT 的 token
* @return token 是否可以被刷新 true:可以 false:不可以
*/
public String refreshHeadToken(String oldToken){
// 失效条件1:token 为 null
if (StrUtil.isEmpty(oldToken)) return null;
// 失效条件2:token 格式错误(不包含 "Bearer ")
String token = oldToken.substring(tokenHead.length());
if (StrUtil.isEmpty(token)) return null;
// 失效条件3:token 中没有负载
Claims claims = getClaimsFromToken(oldToken);
if (claims == null) return null;
// 失效条件4:token 已过期
if (isTokenExpired(oldToken)) return null;
// 如果 token 在 30 分钟之内刚刷新过,返回原 token
if (tokenRefreshJustBefore(oldToken,30*60)){
return oldToken;
}else { // 否则,生成新的 token
// 设置负载中的创建时间
claims.put(CLAM_KEY_CREATED,new Date());
// 根据负载生成 token
return generateToken(claims);
}
}
}
JWT 相关配置
上面 JwtTokenUtil 工具类中的 JWT 密钥、过期时间和请求头中 Authorization 的 Bearer 都是通过 @Value 从 Spring 环境中获取的,因此我们需要在 application 配置文件中进行相应的配置。
jwt:
tokenHeader: Authorization # JWT 存储的请求头
secret: mySecret-admin-secret # JWT 加解密使用的密钥
expiration: 604800 # JWT 的过期时间,单位秒,604800 = 7天
tokenHead: 'Bearer ' # JWT 的开头
[!ATTENTION]
该配置严格来说应该配置在使用 Security 模块的模块的 application 之中,因为不同模块的密钥等信息可能会存在差异。
[!WARNING]
上面的 secret 密钥不建议直接明文配置,在项目中可以通过加密算法进行加密或者存放在安全的地方。同时如果你的密匙过于简短,可能会出现 WeakKeyException 异常。这是因为你的密钥太短,不足以安全地应用于对应的签名算法,你可能需要换一个更为复杂的。
JWT 登录授权过滤器
先补充一些前导知识:
org.springframework.web.filter.OncePerRequestFilter 是一个抽象类,继承自 GenericFilterBean:
public abstract class OncePerRequestFilter extends GenericFilterBean {...}
而 org.springframework.web.filter.GenericFilterBean 是一个实现了 javax.servlet.Filter 接口的抽象类,它提供了一些基本的生命周期方法,如 init(实现了 InitializingBean)和 destroy(实现了 DisposableBean),以及一些用于处理 FilterConfig(实现了 Filter)和 ServletContext(实现了 ServletContextAware)的便捷方法。
public abstract class GenericFilterBean implements Filter, BeanNameAware, EnvironmentAware,
EnvironmentCapable, ServletContextAware, InitializingBean, DisposableBean {
@Nullable
private ServletContext servletContext;
@Nullable
private FilterConfig filterConfig;
@Override
public final void init(FilterConfig filterConfig) throws ServletException {...}
@Override
public void destroy(){...}
// ...
}
OncePerRequestFilter 的核心是 doFilter 和 doFilterInternal 方法:
// 提供了默认实现
@Override
public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 检查请求和响应是否为 HTTP 请求和响应,如果不是,则抛出异常
if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
throw new ServletException("OncePerRequestFilter just supports HTTP requests");
}
// 强制转换为 HttpServletRequest 和 HttpServletResponse
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// 获取表示请求是否已被过滤的属性名
String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
// 检查请求是否已经有这个属性,如果有,说明请求已经被过滤过
boolean hasAlreadyFilteredAttribute = request.getAttribute(alreadyFilteredAttributeName) != null;
// 如果请求应该被跳过或者不应该被过滤
if (skipDispatch(httpRequest) || shouldNotFilter(httpRequest)) {
// 直接继续请求链,不调用此过滤器
filterChain.doFilter(request, response);
}
// 如果请求已经被过滤过
else if (hasAlreadyFilteredAttribute) {
// 如果请求的类型是 ERROR
if (DispatcherType.ERROR.equals(request.getDispatcherType())) {
// 对于嵌套的错误分派,调用特定的方法
doFilterNestedErrorDispatch(httpRequest, httpResponse, filterChain);
return;
}
// 直接继续请求链,不调用此过滤器
filterChain.doFilter(request, response);
}
else {
// 如果请求还没有被过滤过(重点)
// 设置请求已被过滤的属性
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
try {
// 调用 doFilterInternal 方法进行实际的过滤操作(重点)
doFilterInternal(httpRequest, httpResponse, filterChain);
}
finally {
// 在过滤操作完成后,移除请求已被过滤的属性
request.removeAttribute(alreadyFilteredAttributeName);
}
}
}
// 抽象方法
protected abstract void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException;
从上面的方法源码分析可以知道,doFilter 方法的主要逻辑是通过一个特定的请求属性(alreadyFilteredAttributeName)来判断一个请求是否已经被过滤过。如果一个请求已经被过滤过,那么 doFilterInternal 就不会再次被调用。
具体步骤如下:
- 首先,它检查传入的 ServletRequest 和 ServletResponse 是否为 HTTP 类型,如果不是,它会抛出异常。
- 然后,它检查请求是否已经被过滤过,这是通过查看请求是否有一个特定的属性(alreadyFilteredAttributeName)来完成的。
- 如果请求应该被跳过或者不应该被过滤,或者请求已经被过滤过,那么它会直接继续请求链,不会调用 doFilterInternal。
- 如果请求还没有被过滤过,它会设置一个属性表示请求已经被过滤,然后调用 doFilterInternal 方法进行实际的过滤操作。在过滤操作完成后,它会移除这个属性。
通过这种方式,OncePerRequestFilter 确保了 doFilterInternal 方法在一次请求中只会被调用一次。
因此,当我们继承 OncePerRequestFilter 并创建自己的过滤器时,需要重写这个方法,而不是 doFilter 方法。这是因为 OncePerRequestFilter 的 doFilter 方法已经被实现了,确保 doFilterInternal 只在一次请求中被调用一次。
当使用 Spring Security 整合 JWT 时,通常需要一个过滤器(我们称之为登录授权过滤器)来处理每个进入应用的请求,检查请求头中是否有 JWT 令牌,然后验证这个令牌是否有效。如果令牌有效,过滤器会设置安全上下文(SecurityContextHolder),使得后续的请求处理可以知道当前的用户是谁。
因此,我们得出以下为什么使用 OncePerRequestFilter 来实现 JWT 过滤器的结论:
- 确保只执行一次:在一次请求中,你不希望 JWT 的验证逻辑被执行多次。这不仅会浪费资源,还可能导致不可预测的行为。使用 OncePerRequestFilter 可以确保在一次请求中只验证一次 JWT。
- 集成与 Spring Security:Spring Security 的过滤器链中可能有多个过滤器。使用 OncePerRequestFilter 可以确保 JWT 过滤器与其他 Spring Security 过滤器良好地集成。
- 简化代码:由于 OncePerRequestFilter 已经处理了请求的基本流程,你只需要关注 JWT 的验证逻辑,而不是整个过滤过程。这使得代码更加简洁和易于维护。
[!NOTE]
总结:当使用 Spring Security 整合 JWT 时,OncePerRequestFilter 提供了一个简单、高效的方式来实现 JWT 的验证逻辑。你只需要继承这个类,然后重写 doFilterInternal 方法,实现你的 JWT 验证逻辑即可。
下面是我们应该实现的 JWT 登录授权过滤器 JwtAuthenticationTokenFilter:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* JWT 登录授权过滤器
*/
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private static final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
// 用户详细信息服务(用于从数据库中加载用户信息,需要自定义实现)
@Autowired
private UserDetailsService userDetailsService;
// JWT 工具类
@Autowired
private JwtTokenUtil jwtTokenUtil;
// JWT 令牌请求头(即:Authorization)
@Value("${jwt.tokenHeader}")
private String tokenHeader;
// JWT 令牌前缀(即:Bearer)
@Value("${jwt.tokenHead}")
private String tokenHead;
/**
* 从请求中获取 JWT 令牌,并根据令牌获取用户信息,最后将用户信息封装到 Authentication 中,方便后续校验(只会执行一次)
* @param request 请求
* @param response 响应
* @param filterChain 过滤器链
* @throws ServletException Servlet 异常
* @throws IOException IO 异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 从请求中获取 JWT 令牌的请求头(即:Authorization)
String authHeader = request.getHeader(this.tokenHeader);
// 如果请求头不为空,并且以 JWT 令牌前缀(即:Bearer)开头
if (authHeader != null && authHeader.startsWith(this.tokenHead)){
// 获取 JWT 令牌的内容(即:去掉 JWT 令牌前缀后的内容)
String authToken = authHeader.substring(this.tokenHead.length());
// 从 JWT 令牌中获取用户名
String username = jwtTokenUtil.getUserNameFromToken(authToken);
// 记录日志
LOGGER.info("checking username:{}", username);
// 如果用户名不为空,并且 SecurityContextHolder 中的 Authentication 为空(表示该用户未登录)
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null){
// 从数据库中加载用户信息
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 如果 JWT 令牌有效
if (jwtTokenUtil.validateToken(authToken,userDetails)){
// 将用户信息封装到 UsernamePasswordAuthenticationToken 对象中(即:Authentication)
// 参数:用户信息、密码(因为 JWT 令牌中没有密码,所以这里传 null)、用户权限
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails,null,userDetails.getAuthorities());
// 将请求中的详细信息(即:IP、SessionId 等)封装到 UsernamePasswordAuthenticationToken 对象中方便后续校验
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 记录日志
LOGGER.info("authenticated user:{}", username);
// 将 UsernamePasswordAuthenticationToken 对象封装到 SecurityContextHolder 中方便后续校验
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
// 放行,执行下一个过滤器
filterChain.doFilter(request,response);
}
}
上述 doFilterInternal 方法的处理逻辑总结如下:
- 从 HTTP 请求头中获取 JWT 令牌。
- 检查该令牌是否存在并且是否以指定的前缀(如 “Bearer “)开头。
- 如果满足上述条件,从令牌中提取用户名。
- 如果用户名存在,并且当前的 SecurityContextHolder 中没有 Authentication(表示用户尚未登录),则继续处理。
- 使用 UserDetailsService 从数据库中加载与该用户名对应的用户详细信息。
- 使用 JwtTokenUtil 验证 JWT 令牌是否有效。
- 如果令牌有效,创建一个 UsernamePasswordAuthenticationToken 对象,其中包含用户的详细信息、权限等,并将其设置到 SecurityContextHolder 中,这样后续的请求处理可以知道当前的用户是谁。
- 最后,放行请求,使其继续执行下一个过滤器或进入目标处理程序。
[!NOTE]
总结:JwtAuthenticationTokenFilter 的主要任务是从 HTTP 请求中提取 JWT 令牌,验证该令牌,然后根据令牌中的信息设置当前的用户上下文。这确保了只有持有有效 JWT 令牌的用户才能访问受保护的资源。
自定义 AuthenticationEntryPoint
先补充一些前导知识:
org.springframework.security.web.AuthenticationEntryPoint 是 Spring Security 中的一个函数式接口,它定义了一个方法 commence。这个接口主要用于处理认证失败的情况,例如当用户尝试访问一个受保护的资源但没有提供有效的凭证(一般密码,这里便是 Token)时。
public interface AuthenticationEntryPoint {
/**
* 处理认证失败的情况
* @param request 表示客户端请求的信息
* @param response 表示服务器对客户端请求的响应
* @param authException 表示身份验证过程中发生的异常
* @throws IOException IO 异常
* @throws ServletException Servlet 异常
*/
void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
throws IOException, ServletException;
}
此方法的任务是在认证失败时修改 HTTP 响应,其默认实现为 LoginUrlAuthenticationEntryPoint。默认情况下,Spring Security 会重定向用户到登录页面。
但是,在某些应用场景中,例如前后端分离的 RESTful web services,可能更希望返回一个错误代码和消息,而不是重定向到登录页面。
当使用 JWT 作为认证机制时,通常的流程是:
- 用户首先使用用户名和密码登录。
- 服务器验证用户名和密码,如果验证成功,则返回一个 JWT。
- 在后续的请求中,用户将 JWT 作为请求的一部分(通常是在请求头中)发送给服务器。
- 服务器验证 JWT,并根据 JWT 中的信息确定用户的身份。
在这种情境下,当 JWT 无效或过期时,我们不希望重定向用户到登录页面,而是希望返回一个明确的错误消息,例如 “Token is invalid” 或 “Token has expired”。
因此,我们需要实现 AuthenticationEntryPoint 并重写 commence 方法。在 commence 方法中,我们可以自定义响应,返回适当的 HTTP 状态码(如 401 Unauthorized)和一个明确的错误消息。
下面是我们需要自定义的认证失败返回逻辑 RestAuthenticationEntryPoint:
import cn.hutool.json.JSONUtil;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 自定义认证失败处理:没有登录或 token 过期时
*/
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
/**
* 当认证失败时,此方法会被调用
* @param request 请求对象
* @param response 响应对象
* @param authException 认证失败时抛出的异常
* @throws IOException IO 异常
* @throws ServletException Servlet 异常
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 设置响应头,允许任何域进行跨域请求
response.setHeader("Access-Control-Allow-Origin", "*");
// 设置响应头,指示响应不应被缓存
response.setHeader("Cache-Control","no-cache");
// 设置响应的字符编码为 UTF-8
response.setCharacterEncoding("UTF-8");
// 设置响应内容类型为 JSON
response.setContentType("application/json");
// 将认证失败的消息写入响应体
response.getWriter().println(JSONUtil.parse(CommonResult.unauthorized(authException.getMessage())));
// 刷新响应流,确保数据被发送
response.getWriter().flush();
}
}
处理逻辑总结如下:
- 当用户尝试访问受保护的资源但未提供有效的凭证或 JWT 令牌无效时,会抛出 AuthenticationException 异常而自动回调 AuthenticationEntryPoint 的 commence 方法进行处理。
- 为了确保响应可以在跨域的情况下被前端接收,设置了 “Access-Control-Allow-Origin” 为 “*”,这意味着任何域都可以接收此响应。
- 为了确保响应不被客户端缓存,设置了 “Cache-Control” 为 “no-cache”。这是为了确保客户端总是从服务器获取最新的响应,而不是使用旧的、可能已经过时的缓存数据。
- 响应的内容类型被设置为 JSON,字符编码被设置为 UTF-8。
- 使用 JSONUtil.parse(Hutool 工具包将对象转为 JSON 的工具类)和 CommonResult.unauthorized(自定义的通用返回对象)将认证失败的消息转换为 JSON 格式并写入响应体。
- 最后,刷新响应流,确保所有数据都被发送到客户端。
[!TIP]
为什么设置特定的响应头:
Access-Control-Allow-Origin=*
:在前后端分离的应用中,前端和后端可能运行在不同的域上。为了允许前端从不同的域请求后端资源,我们需要设置此响应头。但在生产环境中,通常建议设置具体的域名而不是使用通配符*
,以增加安全性。Cache-Control=no-cache
:为了确保客户端总是接收到最新的认证失败消息,而不是使用可能已经过时的缓存数据,我们需要设置此响应头。这对于安全相关的响应尤为重要,因为我们不希望旧的或不准确的安全消息被缓存并显示给用户。
自定义 AccessDeniedHandler
先补充一些前导知识:
org.springframework.security.web.access.AccessDeniedHandler 是 Spring Security 中的一个接口,它只有一个核心方法用于处理访问被拒绝的情况,即当一个已经认证的用户尝试访问他没有权限的资源时。
/**
* AccessDeniedHandler 接口用于处理访问被拒绝的情况。
*/
public interface AccessDeniedHandler {
/**
* 用于处理访问被拒绝的请求
* @param request 当前的请求对象
* @param response 当前的响应对象
* @param accessDeniedException 访问被拒绝的异常信息
* @throws IOException IO 异常
* @throws ServletException Servlet 异常
*/
void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
throws IOException, ServletException;
}
此方法的任务是在访问被拒绝时修改 HTTP 响应,默认实现为 AccessDeniedHandlerImpl。默认情况下,Spring Security 会重定向用户到一个错误页面。处理逻辑如下:
- 如果用户未经认证,将其重定向到登录页面。
- 如果用户已经认证但没有访问权限,将其重定向到一个配置的错误页面或者默认的错误页面。
- 在重定向之前,它会保存引发访问拒绝的请求,以便用户在获得适当的权限后可以继续该请求。
但在某些应用场景中,例如前后端分离的 RESTful web services,可能更希望返回一个错误代码和消息,而不是重定向到错误页面。
当使用 JWT 作为认证机制时,我们通常希望所有的响应,包括错误响应,都是 JSON 格式的。因此,当一个已经认证的用户尝试访问他没有权限的资源时,我们不希望重定向他到一个错误页面,而是希望返回一个明确的 JSON 格式的错误消息。
因此,需要实现 AccessDeniedHandler 并重写 handle 方法的。在 handle 方法中,我们可以自定义响应,返回适当的 HTTP 状态码(如 403 Forbidden)和一个明确的错误消息。
下面是我们需要自定义的权限不够返回逻辑 RestfulAccessDeniedHandler:
import cn.hutool.json.JSONUtil;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 自定义返回结果:访问权限不足时
*/
public class RestfulAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Cache-Control","no-cache");
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.parse(CommonResult.forbidden(accessDeniedException.getMessage())));
response.getWriter().flush();
}
}
注册自定义的组件
提供一个 Spring Security 通用组件配置类,将所有要用到的组件统一配置到这里,以免出现循环依赖等问题。
import cn.javgo.security.component.*;
import cn.javgo.security.util.JwtTokenUtil;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* Spring Security 配置类,将所有要用到的组件统一配置到这里,以免出现循环依赖等问题。
*/
@Configuration
public class CommonSecurityConfig {
// 注册白名单 Bean(稍后就会说)
@Bean
public IgnoreUrlsConfig ignoreUrlsConfig(){
return new IgnoreUrlsConfig();
}
// 注册 JWT 工具类 Bean
@Bean
public JwtTokenUtil jwtTokenUtil(){
return new JwtTokenUtil();
}
// 注册自定义认证失败逻辑 Bean
@Bean
public RestAuthenticationEntryPoint restAuthenticationEntryPoint(){
return new RestAuthenticationEntryPoint();
}
// 注册自定义权限不足处理 Bean
@Bean
public RestfulAccessDeniedHandler restfulAccessDeniedHandler(){
return new RestfulAccessDeniedHandler();
}
// 注册 JWT 登录授权过滤器 Bean
@Bean
public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter(){
return new JwtAuthenticationTokenFilter();
}
}
直接放行的白名单
项目中并不是所有的资源(API)都需要认证或者权限,总有一些是可以接受匿名访问的,对于这类资源我们称之为 “白名单”,因此可以充分利用 Spring Boot 的自动配置机制进行实现。结合 Spring 的条件注解 @ConfigurationProperties(prefix = "secure.ignored")
当 Spring 环境中存在以 secure.ignored
开头的配置属性时自动注入我们自定义的参数绑定类即可。
下面是我们需要提供的参数绑定类:
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
/**
* Spring Security 白名单资源路径配置
*/
@Getter
@Setter
@ConfigurationProperties(prefix = "secure.ignored")
public class IgnoreUrlsConfig {
private ListString> urls = new ArrayList>();
}
然后,其他模块在使用 Security 模块时只需要在其 application 配置文件中进行配置即可。下面是一个示例配置:
secure:
ignored:
urls:
- /swagger-ui/
- /swagger-resources/**
- /**/v2/api-docs
- /**/*.html
- /**/*.js
- /**/*.css
- /**/*.png
- /**/*.map
- /favicon.ico
- /actuator/**
- /druid/**
- /admin/login
- /admin/register
- /admin/info
- /admin/logout
- /minio/upload
上面主要对 Swagger、静态资源、MinIO 文件上传和后台权限模块的登录、注册、查询信息、注销等 URL 进行了直接放行,具体可根据实际情况调整。
配置 SecurityFilterChain
接着我们就需要将上面自定义的 JWT 登录授权过滤器、自定义认证失败处理、自定义权限异常处理组件和白名单处理逻辑在 Spring Security 的过滤器链 SecurityFilterChain 中进行配置了。
详细配置内容如下:
import cn.javgo.security.component.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Spring Security 配置类
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// Spring Security 白名单资源路径配置
private final IgnoreUrlsConfig ignoreUrlsConfig;
// 自定义返回结果:没有权限访问时
private final RestfulAccessDeniedHandler restfulAccessDeniedHandler;
// 自定义返回结果:没有登录或 token 过期时
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
// JWT 拦截器
private final JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
// 构造函数注入
public SecurityConfig(IgnoreUrlsConfig ignoreUrlsConfig, RestfulAccessDeniedHandler restfulAccessDeniedHandler, RestAuthenticationEntryPoint restAuthenticationEntryPoint, JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter) {
this.ignoreUrlsConfig = ignoreUrlsConfig;
this.restfulAccessDeniedHandler = restfulAccessDeniedHandler;
this.restAuthenticationEntryPoint = restAuthenticationEntryPoint;
this.jwtAuthenticationTokenFilter = jwtAuthenticationTokenFilter;
}
/**
* 配置 Spring Security 的过滤链
* @param httpSecurity HttpSecurity
* @return SecurityFilterChain
* @throws Exception 异常
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
// 开始配置 URL 的授权规则
ExpressionUrlAuthorizationConfigurerHttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
// 遍历白名单,将白名单中的 URL 配置为完全公开,不需要任何权限
for (String url : ignoreUrlsConfig.getUrls()) {
registry.antMatchers(url).permitAll();
}
// 允许所有 OPTIONS 请求(通常用于 CORS 预检请求)
registry.antMatchers(HttpMethod.OPTIONS).permitAll();
// 配置其他所有请求都需要认证
registry.and().authorizeRequests()
.anyRequest().authenticated()
.and()
// 禁用 CSRF 保护(在使用 token 时通常不需要)
.csrf().disable()
// 配置 session 策略为无状态,即不创建 session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 配置异常处理器
.exceptionHandling()
// 当访问被拒绝时使用自定义的处理器返回响应
.accessDeniedHandler(restfulAccessDeniedHandler)
// 当未认证或 token 过期时使用自定义的处理器返回响应
.authenticationEntryPoint(restAuthenticationEntryPoint)
.and()
// 在 UsernamePasswordAuthenticationFilter 之前添加 JWT 拦截器
.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
return httpSecurity.build();
}
}
上述配置代码注释比较详细,不再过多解释,下面挑出其中三个关键点进行进一步解释。
-
CSRF 是什么?为什么使用了 JWT 之后就可以禁用 CSRF?
CSRF(Cross-Site Request Forgery)即跨站请求伪造,是一种攻击手段。攻击者诱导受害者执行非本意的操作,例如在受害者不知情的情况下更改电子邮件地址或密码,或进行不想要的购买。这种攻击通常利用了用户已经登录的网站的身份验证状态。
传统的基于 session 的认证依赖于 cookie,这使得它容易受到 CSRF 攻击。但 JWT 通常不存储在 cookie 中,而是存储在前端的 localStorage 或 sessionStorage 中。当需要发送 JWT 时,它会被附加到请求头中,而不是自动通过 cookie 发送。由于 CSRF 攻击是利用服务器自动接收 cookie 的特性,所以使用 JWT 并将其存储在前端,而不是 cookie 中,可以有效地避免 CSRF 攻击。
-
为什么使用了 JWT 之后就可以关闭 Session 了?
JWT 是自包含的,意味着每个 token 都包含了用户的所有认证和授权信息。因此,服务器不需要再去 session 中查找用户的认证和授权信息。
在传统的基于 session 的认证中,服务器为每个已认证的用户创建一个 session,并将 session ID 存储在 cookie 中。但在基于 JWT 的认证中,由于 JWT 已经包含了所有必要的信息,服务器不再需要维护 session。这减少了服务器的存储需求并简化了扩展。因此,设置为 SessionCreationPolicy.STATELESS 确保 Spring Security 不会创建或使用 session。
-
为什么要在 UsernamePasswordAuthenticationFilter 之前添加自定义的 JWT 拦截器?
UsernamePasswordAuthenticationFilter 是 Spring Security 提供的一个默认注册的过滤器,用于处理基于表单的登录认证。当用户尝试登录时,这个过滤器会捕获登录请求,提取用户名和密码,并尝试使用这些凭证进行认证。
JWT 拦截器的目的是从请求头中提取 JWT,并基于该 JWT 进行认证。如果请求已经包含一个有效的 JWT,那么用户就已经被认证,不需要再进行基于表单的登录。因此,JWT 拦截器应该在 UsernamePasswordAuthenticationFilter 之前运行,这样如果 JWT 认证成功,就不需要进入后续的基于表单的认证过程了。
2.2 其他模块使用 Security 模块
上面我们已经准备好了一个基于 JWT 认证的安全模块了,那么其他模块应该如何使用呢?这便是本节需要讨论的问题。
添加 Security 模块依赖
当其他模块想要使用 Security 模块时,只需要在其 pom.xml 文件中引入 Security 模块的坐标即可。例如:
dependency>
groupId>cn.javgogroupId>
artifactId>my-securityartifactId>
version>1.0-SNAPSHOTversion>
dependency>
自定义 UserDetails
一般情况下我们并不会使用 Spring Security 默认为我们创建的基于内存的默认用户,而是将用户信息存储在数据库中,通过实现 UserDetails 来封装自己的用户信息。
因此,我们先回顾一下 UserDetails:
org.springframework.security.core.userdetails.UserDetails 是 Spring Security 中的一个核心接口,用于获取用户的认证和授权信息。它提供了关于用户的核心信息,如用户名、密码、权限等。
public interface UserDetails extends Serializable {
// 返回授予用户的权限集合
Collection? extends GrantedAuthority> getAuthorities();
// 返回用户的密码
String getPassword();
// 返回用户的用户名
String getUsername();
// 指示用户的帐户是否未过期
boolean isAccountNonExpired();
// 指示用户是否未被锁定或解锁
boolean isAccountNonLocked();
// 指示用户的凭据(密码)是否未过期
boolean isCredentialsNonExpired();
// 指示用户是否启用或禁用
boolean isEnabled();
}
Spring Security 提供了一个 org.springframework.security.core.userdetails.User 类,它是 UserDetails 接口的默认实现。这个类包含了上述方法的具体实现,以及一些辅助的构造函数,使得创建用户对象变得简单。
而上面的 getAuthorities() 返回的 GrantedAuthority 是 Spring Security 中的另一个核心接口,代表了授予认证主体的权限。在 Spring Security 中,权限通常是角色,如 “ROLE_USER”、“ROLE_ADMIN” 等以 “ROLE_” 开头。
[!TIP]
在创建用户的权限时不需要显示指定 “ROLE_” 前缀,因为 Spring Security 的默认实现中会帮我们加上。因此,假设你需要创建一个 “ADMIN” 角色,角色名就设置为 “ADMIN”,而不应该显示设置为 “ROLE_ADMIN”。
它是一个函数式接口,其中就只有一个 getAuthority() 方法,该方法返回一个字符串,代表了授予认证主体的权限。
public interface GrantedAuthority extends Serializable {
String getAuthority();
}
当我们在自定义的认证和授权逻辑中使用 Spring Security 时,我们通常需要提供用户的详细信息,如用户名、密码和权限,这些信息可能存储在数据库中(如 MySQL)。为了与 Spring Security 集成,我们需要提供一个实现了 UserDetails 接口的类来封装用户信息。
特别是 getAuthorities() 方法,它返回一个 GrantedAuthority 的集合,代表了用户的所有权限。这些权限在进行访问控制决策时是必要的,例如,当我们想要限制只有拥有 “ROLE_ADMIN” 角色的用户才能访问某个资源时。
在实际应用中,我们可能会有自己的用户和角色模型。通过实现 UserDetails 和重写 getAuthorities() 方法,我们可以将自己的用户和角色模型映射到 Spring Security 需要的格式,从而实现自定义的认证和授权逻辑。
下面是一个完整示例:
import cn.javgo.model.UmsAdmin;
import cn.javgo.model.UmsResource;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
/**
* 自定义用户详细信息类
*/
public class AdminUserDetails implements UserDetails {
// 用户信息实体类(对应 user 用户表)
private final UmsAdmin umsAdmin;
// 用户关联的资源列表(对应 resource 资源表)
private final ListUmsResource> resourceList;
// 构造注入
public AdminUserDetails(UmsAdmin umsAdmin, ListUmsResource> resourceList) {
this.umsAdmin = umsAdmin;
this.resourceList = resourceList;
}
/**
* 获取用户的权限集合
* @return 权限集合
*/
@Override
public Collection? extends GrantedAuthority> getAuthorities() {
// 将资源列表转换为权限集合
return resourceList.stream()
// 将每个资源对象转换为一个 SimpleGrantedAuthority 对象
.map(role -> new SimpleGrantedAuthority(resource.getId() + ":" + resource.getName()))
.collect(Collectors.toList());
}
/**
* 获取用户密码
* @return 密码
*/
@Override
public String getPassword() {
return umsAdmin.getPassword();
}
/**
* 获取用户名
* @return 用户名
*/
@Override
public String getUsername() {
return umsAdmin.getUsername();
}
/**
* 账户是否未过期
* @return true 表示未过期
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 账户是否未锁定
* @return true 表示未锁定
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 凭证(密码)是否未过期
* @return true 表示未过期
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 账户是否启用
* @return true 表示启用
*/
@Override
public boolean isEnabled() {
// 如果用户状态为 1,则表示启用,0 表示禁用
return umsAdmin.getStatus().equals(1);
}
}
重点分析一下下面的这段代码:
@Override
public Collection? extends GrantedAuthority> getAuthorities() {
// 将资源列表转换为权限集合
return resourceList.stream()
// 将每个资源对象转换为一个 SimpleGrantedAuthority 对象
.map(role -> new SimpleGrantedAuthority(resource.getId() + ":" + resource.getName()))
.collect(Collectors.toList());
}
其中的 org.springframework.security.core.authority.SimpleGrantedAuthority 是 GrantedAuthority 接口的一个简单实现。它主要用于表示授予用户的权限或角色。
其特点如下:
- 它只有一个构造函数 SimpleGrantedAuthority(String role),该构造函数接受一个字符串参数,表示权限或角色的名称。
- 它重写了 getAuthority() 方法,直接返回构造函数中传入的字符串。
- 它是不可变(final)的,一旦创建,就不能更改其权限字符串。
在上面的代码实现中,我们将每个 UmsRole 对象被转换为一个 SimpleGrantedAuthority 对象。同时选择 resource.getId() + ":" + resource.getName()
作为权限字符串有以下原因:
- 唯一性:resource.getId() 保证了每个角色都有一个唯一的标识符。这意味着每个 SimpleGrantedAuthority 对象都将具有唯一的权限字符串,这在权限检查时是很有用的。
- 可读性:通过添加 resource.getName(),权限字符串不仅是唯一的,而且是可读的。这使得在调试或查看日志时,可以轻松识别每个权限代表的角色。
- 灵活性:在某些情况下,可能只需要角色的 ID 进行权限检查,而在其他情况下,可能需要角色的名称。将两者都包含在权限字符串中为这种灵活性提供了可能性。
自定义 UserDetailsService
封装好用户信息(UserDetails)之后,下一步我们就该讨论如何获取到这些用户信息了。
org.springframework.security.core.userdetails.UserDetailsService 是 Spring Security 提供的一个核心接口,用于加载用户的详细信息。它通常用于从存储系统如 MySQL 数据库中加载用户的认证信息(UserDetails)。
它是一个函数式接口,只有一个核心方法 loadUserByUsername(String username),这个方法负责根据提供的用户名加载用户的详细信息。它返回一个 UserDetails 对象,该对象包含了用户的用户名、密码、权限等信息。如果没有找到指定的用户,这个方法应该抛出一个 UsernameNotFoundException。
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
在 Spring Security 中,当用户尝试登录时,认证管理器 (AuthenticationManager) 需要访问用户的详细信息(UserDetails)来验证用户的凭证(一般是密码)。它就是使用了 UserDetailsService 的 loadUserByUsername(String username) 来加载用户的详细信息的。
因此,我们需要在配置类中提供一个 UserDetailsService bean 是告诉 Spring Security 如何从你的存储系统(例如 MySQL 数据库)加载用户信息。这是桥接 Spring Security 和你的存储系统之间的关键部分。
下面是一个示例:
import cn.javgo.mall.service.UmsAdminService;
import cn.javgo.mall.service.UmsResourceService;
import org.springframework.context.annotation.Bean;
import org.springframework.security.core.userdetails.UserDetailsService;
/**
* Spring Security 相关配置
*/
@Configuration
public class MallSecurityConfig {
// 用户信息服务(对应 user 用户表)
private final UmsAdminService adminService;
// 构造注入
public MallSecurityConfig(UmsAdminService adminService) {
this.adminService = adminService;
this.resourceService = resourceService;
}
/**
* 获取用户信息
* @return 用户信息
*/
@Bean
public UserDetailsService userDetailsService() {
// 获取登录用户信息
return adminService::loadUserByUsername;
}
// ...
}
上面的 UmsAdminService 对应了用户表实体类 UmsAdmin,其中提供了 loadUserByUsername(String username) 方法的实现逻辑。
相关部分代码如下:
/**
* 后台用户管理 Service
*/
public interface UmsAdminService {
/**
* 获取用户信息
* @param username 用户名
* @return 用户信息
*/
UserDetails loadUserByUsername(String username);
/**
* 获取用户的资源列表
* @param adminId 用户 id
* @return 资源列表
*/
ListUmsResource> getResourceList(Long adminId);
/**
* 根据用户名获取后台用户
* @param username 用户名
* @return 后台用户
*/
UmsAdmin getAdminByUsername(String username);
// ...
}
对应的实现类如下:
/**
* 后台用户管理 Service 实现类
*/
@Service
public class UmsAdminServiceImpl implements UmsAdminService {
// 从数据库根据用户名获取用户信息
@Override
public UserDetails loadUserByUsername(String username) {
// 根据用户名查询对应的用户
UmsAdmin admin = getAdminByUsername(username);
// 如果用户存在
if (admin != null){
// 通过用户 id 继续查询用户的对应的资源
ListUmsResource> resourceList = getResourceList(admin.getId());
// 封装为自定义的用户详细信息类
return new AdminUserDetails(admin,resourceList);
}
throw new UsernameNotFoundException("用户名或密码错误");
}
// 根据用户 id 获取用户被授权的资源(重要)
@Override
public ListUmsResource> getResourceList(Long adminId) {
// 从缓存中获取资源列表(可暂时忽略)
ListUmsResource> resourceList = getCacheService().getResourceList(adminId);
if (CollUtil.isNotEmpty(resourceList)){
return resourceList;
}
// 从数据库中获取资源列表(重要)
resourceList = adminRoleRelationDao.getResourceList(adminId);
// 将资源列表添加到缓存中(可暂时忽略)
if (CollUtil.isNotEmpty(resourceList)){
getCacheService().setResourceList(adminId,resourceList);
}
return resourceList;
}
// ...
}
重点关注上面获取资源的逻辑,这可以解开你可能会发出的疑问:“为什么说好的 用户 - 角色 - 资源
,现在感觉就只剩下了 用户 - 资源
?”
对于资源的获取,我们扩展了 MBG 自动生成的 UmsAdminRoleRelationMapper,因为它仅仅针对单表查询,而一个用户所具备的资源权限同时涉及到了多张数据库表,显然此时 UmsAdminRoleRelationMapper 就有些无能为力了,因此我们选择自定义一个 UmsAdminRoleRelationDao 来实现我们想要的处理逻辑。
重点关注其中的 getResourceList(@Param(“adminId”) Long adminId) 方法:
@Mapper
public interface UmsAdminRoleRelationDao {
/**
* 获取用户所有可访问资源
* @param adminId 用户 id
* @return 资源列表
*/
ListUmsResource> getResourceList(@Param("adminId") Long adminId);
// ...
}
该方法对应的 Mapper XML 如下:
select id="getResourceList" resultType="cn.javgo.model.UmsResource">
SELECT
ur.id id, -- 选择资源的 ID
ur.create_time createTime, -- 选择资源的创建时间
ur.`name` `name`, -- 选择资源的名称
ur.url url, -- 选择资源的 URL
ur.description description, -- 选择资源的描述
ur.category_id categoryId -- 选择资源的分类 ID
FROM
ums_admin_role_relation ar -- 用户-角色关系表
LEFT JOIN ums_role r ON ar.role_id = r.id -- 左连接角色表
LEFT JOIN ums_role_resource_relation rrr ON r.id = rrr.role_id -- 左连接角色-资源关系表
LEFT JOIN ums_resource ur ON ur.id = rrr.resource_id -- 左连接资源表
WHERE
ar.admin_id = #{adminId} -- 筛选条件为用户id
AND ur.id IS NOT NULL -- 资源id不为空
GROUP BY
ur.id -- 根据资源id分组
/select>
查询逻辑如下:
- 主查询表:查询从 ums_admin_role_relation 表开始,这是一个用户-角色关系表,表示用户与其拥有的角色之间的关系。
- 连接角色表:通过左连接 ums_role 表(基于 role_id),我们可以获取与给定用户关联的所有角色。
- 连接角色-资源关系表:接着,查询左连接 ums_role_resource_relation 表(基于角色的 id),这是一个角色与其对应的资源之间的关系表。
- 连接资源表:最后,查询左连接 ums_resource 表(基于 resource_id),从而获取与给定角色关联的所有资源。
-
筛选条件:
- ar.admin_id = #{adminId}:这确保我们只获取与给定用户 ID (adminId) 相关的资源。
- ur.id IS NOT NULL:这确保我们只选择那些实际与角色关联的资源(即,存在于 ums_resource 表中的资源)。
- 分组:使用 GROUP BY ur.id 确保每个资源只被列出一次,即使它与多个角色关联。
- 选择的字段:查询选择了资源的 ID、创建时间、名称、URL、描述和分类 ID。
[!NOTE]
小结:这个 SQL 查询的目的是找出与给定用户 ID (adminId) 相关的所有资源。它首先查找与该用户关联的所有角色,然后查找与这些角色关联的所有资源。查询的结果是一个资源列表,其中每个资源与给定的用户 ID 直接或间接(通过角色)关联。
自定义 AccessDecisionManager
[!TIP]
自定义授权逻辑不是本文重点,大致处理逻辑就是逐一比对被访问资源应该具备权限与当前认证通过的用户所具备的权限,如果比对成功则处理对应的业务逻辑,否则抛出 AccessDeniedException 异常。
相关阅读:Security- 基于路径的动态权限控制.md
Swagger 适配
如何让 Swagger 发送认证请求头呢?原理很简单,那就是在每次发送请求时都在请求头中的 Authorization 携带上 Bearer Token 即可。
下面是实现逻辑:
import cn.javgo.mall.common.domain.SwaggerProperties;
import org.springframework.context.annotation.Bean;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.*;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spi.service.contexts.SecurityContext;
import springfox.documentation.spring.web.plugins.Docket;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* Swagger 基础配置类(抽象基类)
*/
public abstract class BaseSwaggerConfig {
/**
* 抽象方法,子类需要提供具体的 Swagger 属性实现
* @return SwaggerProperties 对象
*/
public abstract SwaggerProperties swaggerProperties();
/**
* 创建 Docket 对象,用于 Swagger 的主要配置
* @return Docket 对象
*/
@Bean
public Docket createRestApi() {
// 获取 Swagger 属性
SwaggerProperties swaggerProperties = swaggerProperties();
// 创建 Docket 对象并配置其属性
Docket docket = new Docket(DocumentationType.SWAGGER_2)
// 返回一个 ApiSelectorBuilder 实例,用于控制哪些接口暴露给 Swagger
.select()
// 设置扫描的 API 的包路径
.apis(RequestHandlerSelectors.basePackage(swaggerProperties.getApiBasePackage()))
// 扫描所有路径
.paths(PathSelectors.any())
.build()
// 设置 API 的基本信息
.apiInfo(apiInfo(swaggerProperties));
// 如果启用了安全验证,则需要配置以下内容(即是否启用了 JWT)
if (swaggerProperties.isEnableSecurity())
// 配置认证方式和认证场景
docket.securitySchemes(securitySchemes()).securityContexts(securityContexts("/*/.*"));
return docket;
}
/**
* 创建 API 的基本信息
* @param swaggerProperties Swagger 属性
* @return ApiInfo 对象
*/
private ApiInfo apiInfo(SwaggerProperties swaggerProperties) {
return new ApiInfoBuilder()
.title(swaggerProperties.getTitle()) // 设置标题
.description(swaggerProperties.getDescription()) // 设置描述
.contact(new Contact( // 设置联系方式
swaggerProperties.getContactName(),
swaggerProperties.getContactUrl(),
swaggerProperties.getContactEmail()))
.version(swaggerProperties.getVersion()) // 设置版本
.build();
}
/**
* 创建安全验证的配置
* @return 安全验证的配置列表
*/
private ListSecurityScheme> securitySchemes(){
ListSecurityScheme> list = new ArrayList>();
// 创建一个 ApiKey 对象,表示在请求头中的 "Authorization" 字段中携带 token
ApiKey apiKey = new ApiKey("Authorization","Authorization","header");
list.add(apiKey);
return list;
}
/**
* 创建安全上下文的配置
* @param path 路径匹配模式
* @return 安全上下文的配置列表
*/
private ListSecurityContext> securityContexts(String path){
ListSecurityContext> result = new ArrayList>();
SecurityContext securityContext = SecurityContext.builder()
.securityReferences(defaultAuth()) // 设置默认的安全引用
.operationSelector(o -> o.requestMappingPattern().matches(path)) // 设置路径匹配模式(匹配所有路径)
.build();
result.add(securityContext);
return result;
}
/**
* 创建默认的安全引用
* @return 安全引用列表
*/
private ListSecurityReference> defaultAuth(){
ListSecurityReference> result = new ArrayList>();
// 创建一个全局的授权范围
AuthorizationScope authorizationScope = new AuthorizationScope("global","accessEverything");
AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
authorizationScopes[0] = authorizationScope;
// 创建一个安全引用,表示需要在 "Authorization" 字段中携带 token
SecurityReference securityReference = new SecurityReference("Authorization",authorizationScopes);
result.add(securityReference);
return result;
}
}
这样一来,当 Spring Security 整合 JWT 后,就能在 Swagger 中测试受保护的 API 了,因为我们在每次发送请求时都在请求头中的 Authorization 携带上了 Bearer Token。
下面是一些关键方法的解释:
- securitySchemes():该方法定义了一个名为 “Authorization” 的 ApiKey。当我们在 Swagger UI 中测试 API 时,我们需要为这个 ApiKey 提供一个值,这个值就是我们的 JWT。
- securityContexts():该方法定义了一个安全上下文,它告诉 Swagger 哪些 API 需要使用上述定义的 ApiKey。在我们的配置中,所有匹配 “/*/.*” 模式的 API 都需要这个 ApiKey。
- defaultAuth():该方法定义了一个安全引用,它告诉 Swagger 使用哪个 ApiKey(在这个例子中是 “Authorization”)和哪个授权范围(在这个例子中是 “global”)。
因此,当我们在 Swagger UI 中测试受保护的 API 时,Swagger 会提示我们提供一个值给 “Authorization” ApiKey。我们需要为它提供一个有效的 JWT,然后 Swagger 会在请求头中携带这个 JWT 来调用 API。
解觉跨域问题
在前后端分离场景中跨域是一个必须考虑的问题,为此我们可以配置一个全局跨域处理方案,由于使用跨域注解 @CrossOrigin 的方式来处理并不够灵活,这里我们可以选择配置 Spring 提供的 CorsFilter 过滤器来允许跨域调用。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
/**
* 全局跨域配置
*/
@Configuration
public class GlobalCorsConfig {
/**
* 配置跨域访问的过滤器
* @return 返回配置好的跨域过滤器
*/
@Bean
public CorsFilter corsFilter(){
// 创建 CORS 配置对象
CorsConfiguration config = new CorsConfiguration();
// 允许任何域名使用
config.addAllowedOriginPattern("*");
// 允许客户端携带凭证
config.setAllowCredentials(true);
// 允许所有的请求头
config.addAllowedHeader("*");
// 允许所有的请求方法(主要是跨域的 OPTIONS 预检请求)
config.addAllowedMethod("*");
// 创建 CORS 配置源对象
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// 为所有的 URL 路径设置跨域配置
source.registerCorsConfiguration("/**",config);
// 返回新的 CORS 过滤器,使用上面的配置源
return new CorsFilter(source);
}
}
[!TIP]
为什么要配置 setAllowCredentials(true)?
setAllowCredentials(true) 表示允许客户端在跨域请求中携带凭证。在这里,凭证可以是 cookies、HTTP 认证或客户端 SSL 证书等。
当整合了 JWT 的情况下,通常 JWT 会被存储在客户端的 cookie 或 localStorage(一般是后者)中。当客户端向服务器发送请求时,它会从存储中获取 JWT,并将其放在请求头中,通常是 Authorization 头。这样,服务器可以验证此 JWT 来认证请求。
如果你的前端应用和后端 API 位于不同的域名或端口上,那么任何从前端到后端的请求都会被视为跨域请求。如果你希望在跨域请求中携带 JWT 或其他凭证,你需要设置 setAllowCredentials(true)。否则,浏览器会因为安全原因阻止这种行为。
例如,如果你的前端应用尝试发送一个带有 JWT 的跨域请求,但后端的 CORS 配置不允许携带凭证,那么这个请求将会失败。因此,为了确保跨域请求可以携带 JWT 或其他凭证,需要设置 setAllowCredentials(true)。
登录并返回 Token
最后让我们一起来看看在控制层应该如何处理登录请求并返回 Token。
由于 UmsAdmin 实体类里面封装了用户的所有基本信息,但是实际在进行认证时只需要用户名和密码两个字段,我们可以使用 DTO(数据传输对象)来重新封装一个登录请求的请求参数:
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotEmpty;
/**
* 用户登录参数
*/
@Data
@EqualsAndHashCode
public class UmsAdminLoginParam {
@NotEmpty
@ApiModelProperty(value = "用户名",required = true)
private String username;
@NotEmpty
@ApiModelProperty(value = "密码",required = true)
private String password;
}
登录请求的方法可以声明在 UmsAdminService 中:
/**
* 后台用户管理 Service
*/
public interface UmsAdminService {
/**
* 登录功能
* @param username 用户名
* @param password 密码
* @return 生成的 JWT 的 token
*/
String login(String username, String password);
// ...
}
对应的实现类如下:
/**
* 后台用户管理 Service 实现类
*/
@Service
public class UmsAdminServiceImpl implements UmsAdminService {
/**
* 用户登录
* @param username 用户名
* @param password 密码
* @return token
*/
@Override
public String login(String username, String password) {
String token = null;
// 密码需要客户端加密后传递
try{
// 根据用户名从数据库中获取用户信息
UserDetails userDetails = loadUserByUsername(username);
// 进行密码匹配
if (!passwordEncoder.matches(password,userDetails.getPassword())){
Asserts.fail("密码不正确");
}
// 检查用户是否被禁用
if (!userDetails.isEnabled()){
Asserts.fail("账号已被禁用");
}
// 封装用户信息(由于使用 JWT 进行验证,这里不需要凭证)
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails,null,userDetails.getAuthorities());
// 将用户信息存储到 Security 上下文中,以便于 Security 进行权限验证
SecurityContextHolder.getContext().setAuthentication(authentication);
// 生成 token
token = jwtTokenUtil.generateToken(userDetails);
// 添加登录记录
insertLoginLog(username);
}catch (AuthenticationException e){
LOGGER.warn("登录异常:{}",e.getMessage());
}
return token;
}
// ...
}
下面是控制层的对应登录 API:
/**
* 后台用户管理Controller
*/
@RestController
@Api(tags = "UmsAdminController")
@Tag(name = "UmsAdminController", description = "后台用户管理")
@RequestMapping("/admin")
public class UmsAdminController {
@ApiOperation(value = "登录以后返回token")
@PostMapping(value = "/login")
public CommonResult login(@Validated @RequestBody UmsAdminLoginParam umsAdminLoginParam) {
// 通过用户名和密码获取token
String token = adminService.login(umsAdminLoginParam.getUsername(), umsAdminLoginParam.getPassword());
// 如果token为空,返回错误信息
if (token == null) {
return CommonResult.validateFailed("用户名或密码错误");
}
// 如果token不为空,返回token
MapString, String> tokenMap = new HashMap>();
tokenMap.put("token", token);
tokenMap.put("tokenHead", tokenHead);
return CommonResult.success(tokenMap);
}
// ...
}
OK,分享结束,希望能帮助你梳理清楚相关知识。
文末,感谢 Github 开源项目 mall 作者 macrozheng 提供了全面的代码支撑,感兴趣的可以自己盘一盘该项目,其中有很多值得学习的业务解决方案 👍。
TIP:参考资料
- 上面涉及到的源码来自 Github 开源项目 mall:https://github.com/macrozheng/mall