赞
踩
TIP:更多文章笔者考虑同步到 javgo.cn,目前内容较少,后续会陆续更新。
官方网站:https://jwt.io/introduction/
官方介绍如下:
JSON Web Token(JWT)是一个定义在 RFC 7519 开放标准下的技术,提供了一种紧凑且自包含的方式用于在各方之间安全地传输信息。JWT 使用 JSON 对象作为载体,同时通过数字签名来验证和确保信息的可信度。数字签名可以通过秘密密钥(HMAC 算法)或是公钥/私钥对(使用 RSA 或 ECDSA)生成。
说简单点就是:JWT 是一种通过 JSON 形式作为 Web 应用中的令牌(Token),能够在各方之间安全地将信息作为 JSON 对象传输,并可以在传输过程中完成数据加密、签名等相关处理。
HTTP 协议本身是无状态的,这意味着用户每次发出请求时都必须进行身份验证。为了使应用能识别是哪个用户发出的请求,我们只能在服务器端的 Session 域中存储一份用户登录的信息。这份登录信息会以 SessionID 的形式在响应时传递给浏览器进行缓存,并告诉其保存为 Cookie,以便下次请求时发送给服务端进行身份验证。
然而,这种方法有一些不可避免的问题:
用户首次登录成功后,服务器会返回一个令牌(Token)。用户随后每次请求受保护资源时,都需要在 HTTP 请求头(Request Header)中添加一个 Authorization 字段,字段值就是 Bearer 加上此令牌(Token)。服务器通过对 Authorization 字段值信息(也就是 Token)的解码及数字签名校验来获取其中的用户信息,从而实现认证和授权。(“Bearer” 中文翻译可以理解为 “携带” 的意思)
下面是一个请求头中的 Authorization 字段携带 Token 的示例:
Authorization: Bearer <token>
下面是 JWT 官方给的工作原理图:
看起来过于草率了,下面是一个扩展后的原理图:
基于 JWT 的认证避免了传统 Session 认证机制存在的问题:
[!WARNING]
对于已签名的令牌(Token),即使其他人无法更改令牌,但令牌中的所有信息都是公开的。因此,敏感信息(如密码)不应放入令牌中,应该只存放不影响隐私的信息,如用户 ID 等。同时,如果通过 HTTP 请求头发送 JWT,要尽量保证令牌的大小不要过大,因为某些服务器可能不接受超过 8 KB 的标头。
JWT由三个以 “·
” 为分隔符的部分组成:标头(Header)、载荷(Payload)以及签名(Signature)。header.payload.signature
标头(Header)主要包含两个信息:令牌类型(typ)和所使用的加密算法(alg),例如 HS256 或者 RSA。将这部分信息采用 JSON 格式存储,然后通过 Base64 编码处理,就构成了 JWT 的第一部分。例如:
{
"alg": "HS256",
"typ": "JWT"
}
[!WARNING]
Base64 仅仅是一种编码方式,而非加密方式,其内容可以很容易地解码出来。
载荷(Payload)部分包含了所要传递的数据,通常这些数据都是一些声明(claims),例如用户身份信息、token 的生成时间、过期时间等。载荷也需要进行 Base64 编码,形成 JWT 的第二部分。例如:
{
"sub": "1234567890",
"name": "John Doe",
"created": 1489079981393,
"exp": 1489684781
"admin": true
}
[!WARNING]
虽然经过签名的令牌能防止数据被篡改,但任何人都可以读取其中的信息。因此,敏感信息应该避免存储在 JWT 的有效载荷或标头中,除非它们被加密。
签名(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/
RBAC,即基于角色的访问控制,是一种常用的企业安全策略。在 RBAC 中,权限和角色相关联,用户通过成为对应角色的成员而获得权限。
RBAC 的核心概念包括:
用户和角色、角色和权限、用户和权限之间都可以是多对多的关系,所以 RBAC 可以实现非常细致和灵活的权限管理。
RBAC 有许多优点:
用户可以拥有一个或多个角色,角色可以包含一个或多个权限。当用户尝试访问系统资源时,系统将根据用户的角色和角色所拥有的权限来判断用户是否有权限进行操作。
在项目的 pom.xml 文件中添加相关依赖:
<!-- JWT 支持 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
@Test
void testGetToken() {
// 创建 Token 过期时间(7 天)
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DATE, 7);
// 创建有效载荷中的声明
Map<String,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 解析结果如下:
@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 异常
在日常项目开发中我们的一个项目是由多个独立的模块构成,需要使用另一个模块时引入对应的模块依赖(坐标)即可。这里为了更好的代码复用(安全模块复用),我们也将安全模块进行单独开发。
在项目的 pom.xml 文件中添加如下依赖:
<!-- Web 安全支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT 支持 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- Swagger API -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-boot-starter</artifactId>
<version>3.0.0</version>
</dependency>
<!-- HuTool 工具包 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.9</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
编写一个 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(Map<String,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){
// 创建负载
Map<String,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);
}
}
}
上面 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 异常。这是因为你的密钥太短,不足以安全地应用于对应的签名算法,你可能需要换一个更为复杂的。
先补充一些前导知识:
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 就不会再次被调用。
具体步骤如下:
通过这种方式,OncePerRequestFilter 确保了 doFilterInternal 方法在一次请求中只会被调用一次。
因此,当我们继承 OncePerRequestFilter 并创建自己的过滤器时,需要重写这个方法,而不是 doFilter 方法。这是因为 OncePerRequestFilter 的 doFilter 方法已经被实现了,确保 doFilterInternal 只在一次请求中被调用一次。
当使用 Spring Security 整合 JWT 时,通常需要一个过滤器(我们称之为登录授权过滤器)来处理每个进入应用的请求,检查请求头中是否有 JWT 令牌,然后验证这个令牌是否有效。如果令牌有效,过滤器会设置安全上下文(SecurityContextHolder),使得后续的请求处理可以知道当前的用户是谁。
因此,我们得出以下为什么使用 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 方法的处理逻辑总结如下:
[!NOTE]
总结:JwtAuthenticationTokenFilter 的主要任务是从 HTTP 请求中提取 JWT 令牌,验证该令牌,然后根据令牌中的信息设置当前的用户上下文。这确保了只有持有有效 JWT 令牌的用户才能访问受保护的资源。
先补充一些前导知识:
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 无效或过期时,我们不希望重定向用户到登录页面,而是希望返回一个明确的错误消息,例如 “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();
}
}
处理逻辑总结如下:
[!TIP]
为什么设置特定的响应头:
Access-Control-Allow-Origin=*
:在前后端分离的应用中,前端和后端可能运行在不同的域上。为了允许前端从不同的域请求后端资源,我们需要设置此响应头。但在生产环境中,通常建议设置具体的域名而不是使用通配符*
,以增加安全性。Cache-Control=no-cache
:为了确保客户端总是接收到最新的认证失败消息,而不是使用可能已经过时的缓存数据,我们需要设置此响应头。这对于安全相关的响应尤为重要,因为我们不希望旧的或不准确的安全消息被缓存并显示给用户。
先补充一些前导知识:
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 List<String> 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 进行了直接放行,具体可根据实际情况调整。
接着我们就需要将上面自定义的 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 的授权规则
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.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 认证成功,就不需要进入后续的基于表单的认证过程了。
上面我们已经准备好了一个基于 JWT 认证的安全模块了,那么其他模块应该如何使用呢?这便是本节需要讨论的问题。
当其他模块想要使用 Security 模块时,只需要在其 pom.xml 文件中引入 Security 模块的坐标即可。例如:
<dependency>
<groupId>cn.javgo</groupId>
<artifactId>my-security</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
一般情况下我们并不会使用 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 List<UmsResource> resourceList;
// 构造注入
public AdminUserDetails(UmsAdmin umsAdmin, List<UmsResource> 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 接口的一个简单实现。它主要用于表示授予用户的权限或角色。
其特点如下:
在上面的代码实现中,我们将每个 UmsRole 对象被转换为一个 SimpleGrantedAuthority 对象。同时选择 resource.getId() + ":" + resource.getName()
作为权限字符串有以下原因:
封装好用户信息(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 资源列表
*/
List<UmsResource> 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 继续查询用户的对应的资源
List<UmsResource> resourceList = getResourceList(admin.getId());
// 封装为自定义的用户详细信息类
return new AdminUserDetails(admin,resourceList);
}
throw new UsernameNotFoundException("用户名或密码错误");
}
// 根据用户 id 获取用户被授权的资源(重要)
@Override
public List<UmsResource> getResourceList(Long adminId) {
// 从缓存中获取资源列表(可暂时忽略)
List<UmsResource> 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 资源列表
*/
List<UmsResource> 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>
查询逻辑如下:
[!NOTE]
小结:这个 SQL 查询的目的是找出与给定用户 ID (adminId) 相关的所有资源。它首先查找与该用户关联的所有角色,然后查找与这些角色关联的所有资源。查询的结果是一个资源列表,其中每个资源与给定的用户 ID 直接或间接(通过角色)关联。
[!TIP]
自定义授权逻辑不是本文重点,大致处理逻辑就是逐一比对被访问资源应该具备权限与当前认证通过的用户所具备的权限,如果比对成功则处理对应的业务逻辑,否则抛出 AccessDeniedException 异常。
如何让 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 List<SecurityScheme> securitySchemes(){
List<SecurityScheme> list = new ArrayList<>();
// 创建一个 ApiKey 对象,表示在请求头中的 "Authorization" 字段中携带 token
ApiKey apiKey = new ApiKey("Authorization","Authorization","header");
list.add(apiKey);
return list;
}
/**
* 创建安全上下文的配置
* @param path 路径匹配模式
* @return 安全上下文的配置列表
*/
private List<SecurityContext> securityContexts(String path){
List<SecurityContext> result = new ArrayList<>();
SecurityContext securityContext = SecurityContext.builder()
.securityReferences(defaultAuth()) // 设置默认的安全引用
.operationSelector(o -> o.requestMappingPattern().matches(path)) // 设置路径匹配模式(匹配所有路径)
.build();
result.add(securityContext);
return result;
}
/**
* 创建默认的安全引用
* @return 安全引用列表
*/
private List<SecurityReference> defaultAuth(){
List<SecurityReference> 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。
下面是一些关键方法的解释:
因此,当我们在 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。
由于 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
Map<String, String> tokenMap = new HashMap<>();
tokenMap.put("token", token);
tokenMap.put("tokenHead", tokenHead);
return CommonResult.success(tokenMap);
}
// ...
}
OK,分享结束,希望能帮助你梳理清楚相关知识。
文末,感谢 Github 开源项目 mall 作者 macrozheng 提供了全面的代码支撑,感兴趣的可以自己盘一盘该项目,其中有很多值得学习的业务解决方案
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。