目录
一、 OAuth2Token - AuthenticationToken
二、OAuth2Realm - AuthorizingRealm
四、OAuth2Filter - AuthenticatingFilter
JWT
JWT的 Token 要经过加密才能返回给客户端,包括客户端上传的 Token ,后端项目需要验证核实。于是我们需要一个JWT工具类,用来 加密Token 和 验证Token 的有效性。
一、引入依赖
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
<version>1.5.3</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.5.3</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.11</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>4.4.13</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
二、定义密钥和过期时间
我建议大家把密钥和过期时间定义到SpringBoot配置文件中,然后再值注入到JavaBean中,这样维护起来比较方便。
emos:
jwt:
#密钥
secret: abc123456
#令牌过期时间(天)
expire: 5
#令牌缓存时间(天数)
cache-expire: 10
三、创建JWT工具类
package com.example.emos.wx.config.shiro;
import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DateUtil;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.example.emos.wx.exception.EmosException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* JWT 工具类,用于生成、解析和验证 JSON Web Token
*/
@Component
@Slf4j
public class JwtUtil {
// 从配置文件中读取密钥,用于签名和验证
@Value("${emos.jwt.secret}")
private String secret;
// 从配置文件中读取 Token 过期时间(单位:天)
@Value("${emos.jwt.expire}")
private int expire;
/**
* 生成 JWT Token
* @param userId 用户 ID,用于标识令牌所属用户
* @return 生成的 Token 字符串
*/
public String createToken(int userId) {
// 设置过期时间:当前时间 + 配置的天数
Date date = DateUtil.offset(new Date(), DateField.DAY_OF_YEAR, expire).toJdkDate();
// 使用 HMAC256 算法和密钥签名
Algorithm algorithm = Algorithm.HMAC256(secret);
// 构建 JWT,添加自定义 Claim 和过期时间
JWTCreator.Builder builder = JWT.create();
String token = builder
.withClaim("userId", userId) // 添加 userId 到 Token 中
.withExpiresAt(date) // 设置过期时间
.sign(algorithm); // 签名生成 Token
return token;
}
/**
* 从 Token 中解析用户 ID
* @param token 令牌字符串
* @return 用户 ID
*/
public int getUserId(String token) {
try {
// 解码 Token 并提取自定义 Claim
DecodedJWT decode = JWT.decode(token);
return decode.getClaim("userId").asInt();
} catch (JWTDecodeException e) {
// 解析失败,抛出自定义异常
throw new EmosException("令牌无效");
}
}
/**
* 校验 Token 的合法性
* @param token 令牌字符串
*/
public void verifyToken(String token) {
// 使用密钥创建校验器
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm).build();
// 验证 Token 的有效性
verifier.verify(token);
}
}
把令牌封装成认证对象
接下来我们要把 JWT 和 shiro框架 对接起来,这样 shiro框架就会拦截所有的Http请求,然后验证请求提交的 Token是否有效。
一、 OAuth2Token - AuthenticationToken
客户端提交的Token不能直接交给Shiro框架,需要先封装成 AuthenticationToken 类型的对象,所以我们我们需要先创建 AuthenticationToken 的实现类。
作用:
-
封装 JWT 令牌:
- 将客户端传递的 JWT 令牌封装成一个
AuthenticationToken
对象,供 Shiro 的认证逻辑使用。
- 将客户端传递的 JWT 令牌封装成一个
-
配合
Realm
进行认证:- 在
OAuth2Realm
的doGetAuthenticationInfo
方法中,会接收这个OAuth2Token
对象,从中提取令牌,进行身份验证。
- 在
-
作为 Shiro 的认证流程输入:
- 在 Shiro 的认证流程中,
AuthenticationToken
是一个标准接口,Shiro 会调用其方法来获取认证相关的信息。
- 在 Shiro 的认证流程中,
package com.example.emos.wx.config.shiro;
import org.apache.shiro.authc.AuthenticationToken;
public class OAuth2Token implements AuthenticationToken {
private String token;
public OAuth2Token(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return null;
}
@Override
public Object getCredentials() {
return null;
}
}
二、OAuth2Realm - AuthorizingRealm
OAuth2Realm 类是 AuthorizingRealm的实现类,我们要在这个实现类中定义认证和授权的方法。我们这里定义空的认证去授权方法,把Shiro和JWT整合起来,当我们在实际生产中,更具项目编写补充代码。
作用:
- 认证(Authentication):
- 验证用户身份(例如检查令牌的合法性)。
- 通常在用户登录时执行。
- 授权(Authorization):
- 判断用户是否具有某些权限。
- 通常在用户访问受权限保护的资源时执行。
package com.example.emos.wx.config.shiro;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class OAuth2Realm extends AuthorizingRealm {
@Autowired
private JwtUtil jwtUtil;
//判断当前 Realm 是否支持特定类型的 AuthenticationToken
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof OAuth2Token;
}
/*
* 授权,验证权限时调用
* */
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
//TODO 查询用户权限列表
//TODO 把用户权限加入到info当中
return info;
}
/*
* 认证,登录时调用
* */
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
//TODO 从令牌中获取用户userId,检测用户是否被冻结
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo();
//TODO 将token信息,用户信息加入到info当中
return info;
}
}
三、刷新令牌设计
①、为什么要刷新Token的过期时间?
我们在定义JwtUtil工具类的时候,生成的 Token 都有过期时间。那么问题来了,假设 Token 过期时间为15天,用户在第14天的时候,还可以免登录正常访问系统。但是到了第15天,用户的Token过期于是用户需要重新登录系统。
实现这种效果的方案有两种:双Token 和 Token缓存 ,这里重点讲一下 Token 缓存方案。
Token缓存方案是把Token 缓存到Redis,然后设置Redis里面缓存的 Token 过期时间为正常 Token 的1倍,然后根据情况刷新 Token 的过期时间。
Token失效,缓存也不存在的情况
当第15天,用户的 Token 失效以后,我们让Shiro程序到Redis查看是否存在缓存的 Token ,如果这个Token 不存在于Redis里面,就说明用户的操作间隔了15天,需要重新登录。
Token失效,但是缓存还存在的情况
如果Redis中存在缓存的 Token ,说明当前 Token 失效后,间隔时间还没有超过15天,不应该让用户重新登录。所以要生成新的 Token 返回给客户端,并且把这个 Token 缓存到Redis里面,这种操作成为刷新 Token 过期时间。
②、客户端更新令牌
只要用户成功登陆系统,当后端服务器更新 Token 的时候,就在响应中添加 Token 。客户端那边判断每次Ajax响应里面是否包含 Token ,如果包含,就把 Token 保存起来就可以了。
③、在响应中添加令牌
我们定义 OAuth2Filter 类拦截所有的HTTP请求,一方面它会把请求中的 Token 字符串提取出来,封装成对象交给Shiro框架;另一方面,它会检査 Token 的有效性。如果 Token 过期,那么会生成新的 Token ,分别存储在 ThreadLocalToken 和 Redis 中。
之所以要把,新令牌 保存到 ThreadlocalToken 里面,是因为要向 AOP切面类传递这个新令牌。我们定义了 A0P切面类,拦截所有Web方法返回的R对象,然后在 R对象 里面添加 新令牌。但是 OAuth2Filter 和 AOP 切面类之间没有调用关系,所以我们很难把 新令牌 传给 AOP切面类 。
这里我想到了 ThreadLocal,只要是同一个线程,往 ThreadLocal 里面写入数据和读取数据是完全相同的。在Web项目中,从OAuth2Filter 到AOP切面类,都是由同一个线程来执行的,中途不会更换线程。所以我们可以放心的把新令牌保存都在 ThreadLocal 里面,AOP切面类 可以成功的取出新令牌,然后往 R对象 里面添加新令牌即可。
创建ThreadLocalToken类:
package com.example.emos.wx.config.shiro;
import org.springframework.stereotype.Component;
@Component
public class ThreadLocalToken {
private ThreadLocal<String> local = new ThreadLocal<>();
public void setToken(String token) {
local.set(token);
}
public String getToken() {
return local.get();
}
public void clear() {
local.remove();
}
}
四、OAuth2Filter - AuthenticatingFilter
作用:
-
令牌验证和用户认证:
- 拦截用户的请求,提取并验证请求中包含的 JWT 令牌。
- 如果令牌有效,允许访问资源;如果令牌无效或过期,拒绝访问或发放新的令牌。
-
支持跨域和特殊请求:
- 对于跨域预检请求(
OPTIONS
请求),直接放行,不经过 Shiro 处理。
- 对于跨域预检请求(
-
处理认证异常:
- 根据令牌状态(如过期、无效),返回不同的 HTTP 状态码和错误信息给客户端。
-
Redis 缓存令牌管理:
- 检查 Redis 是否有备份令牌,用于在令牌过期时发放新的令牌。
@Component // 将该类声明为 Spring 管理的组件,方便依赖注入
@Scope("prototype") // 每次请求该 Bean 时都会创建一个新的实例
public class OAuth2Filter extends AuthenticatingFilter {
@Autowired
private JwtUtil jwtUtil; // 用于处理 JWT 令牌的工具类
@Value("${emos.jwt.cache-expire}") // 从配置文件中读取令牌过期时间
private int cacheExpire;
@Autowired
private ThreadLocalToken threadLocalToken; // 用于线程安全地保存令牌
@Autowired
private RedisTemplate redisTemplate; // 操作 Redis 数据库的工具
/**
* 拦截请求后,把令牌字符串封装成令牌对象
* 如果请求中没有 token,返回 null
*/
@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest) request;
String requestToken = getRequestToken(req);
if (StringUtils.isBlank(requestToken)) {
return null; // 没有令牌,不创建 AuthenticationToken
}
return new OAuth2Token(requestToken); // 使用自定义的 OAuth2Token 封装令牌
}
/**
* 判断请求是否需要被 Shiro 处理
* 放行 OPTIONS 请求,因为前端在跨域时会发送此类型的预检请求
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
HttpServletRequest req = (HttpServletRequest) request;
if (req.getMethod().equals(RequestMethod.OPTIONS.name())) {
return true; // OPTIONS 请求直接放行
}
return false; // 其他请求需要被 Shiro 处理
}
/**
* 处理所有需要 Shiro 验证的请求
* 验证令牌的有效性,处理令牌过期逻辑,以及交给 Shiro 执行登录
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
// 设置响应头信息,允许跨域
resp.setHeader("Content-type", "text/html;charset=UTF-8");
resp.setHeader("Access-Control-Allow-Origin", resp.getHeader("Origin"));
resp.setHeader("Access-Control-Allow-Credentials", "true");
// 获取请求中的 token
String token = getRequestToken(req);
try {
jwtUtil.verifyToken(token); // 验证令牌是否有效
} catch (TokenExpiredException e) { // 如果令牌过期,检查 Redis 中是否有存储的令牌
if (redisTemplate.hasKey("token")) { // 如果 Redis 中有令牌
redisTemplate.delete(token); // 删除旧令牌
int userId = jwtUtil.getUserId(token); // 从旧令牌中解析用户 ID
String newToken = jwtUtil.createToken(userId); // 生成新令牌
redisTemplate.opsForValue().set("token", newToken); // 将新令牌保存到 Redis
threadLocalToken.setToken(newToken); // 保存新令牌到 ThreadLocal
} else {
// 如果 Redis 中也没有令牌,直接返回未授权状态
resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
resp.getWriter().print("令牌已过期");
return false;
}
} catch (JWTDecodeException e) { // 处理无效的令牌
resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
resp.getWriter().print("无效令牌");
return false;
}
// 执行 Shiro 的登录逻辑
boolean bool = executeLogin(request, response);
return bool;
}
/**
* 登录失败的处理逻辑
* 返回 401 状态码,并输出错误信息
*/
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
resp.setHeader("Content-type", "text/html;charset=UTF-8");
resp.setHeader("Access-Control-Allow-Origin", resp.getHeader("Origin"));
resp.setHeader("Access-Control-Allow-Credentials", "true");
try {
resp.getWriter().print(e.getMessage()); // 输出错误信息
} catch (IOException ex) {
// 忽略异常
}
return false;
}
/**
* 从请求头或请求参数中获取 token
* 如果没有找到,返回 null
*/
private String getRequestToken(HttpServletRequest request) {
String token = request.getHeader("token"); // 从请求头中获取
if (StringUtils.isBlank(token)) {
token = request.getParameter("token"); // 如果请求头中没有,从参数中获取
}
return token;
}
/**
* 重写 doFilterInternal 方法,确保 Shiro 的过滤逻辑被调用
*/
@Override
public void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException {
super.doFilterInternal(request, response, chain);
}
}
五、创建的 shiroconfig类
我们要创建的 shiroconfig类,是用来把 oAuth2Filter 和 oAuth2Realm配置到Shiro框架,这样我们辛苦搭建的Shiro+JWT才算生效。
package com.example.emos.wx.config.shiro;
// 导入相关的类
import org.apache.shiro.mgt.SecurityManager; // Shiro的核心安全管理器接口
import org.apache.shiro.spring.LifecycleBeanPostProcessor; // 管理Shiro生命周期的工具类
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor; // 用于注解权限验证的Advisor
import org.apache.shiro.spring.web.ShiroFilterFactoryBean; // Shiro的核心过滤器工厂
import org.apache.shiro.web.mgt.DefaultWebSecurityManager; // Shiro默认的Web安全管理器
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter; // Servlet的过滤器接口
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
@Configuration // 表示这是一个配置类,Spring会自动加载
public class ShiroConfig {
// 配置 Shiro 的核心安全管理器 SecurityManager
@Bean("securityManager")
public SecurityManager securityManager(OAuth2Realm oAuth2Realm) {
// 使用默认的 Web 安全管理器
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置自定义的 Realm,用于身份验证和授权
securityManager.setRealm(oAuth2Realm);
// 禁用 RememberMe 功能
securityManager.setRememberMeManager(null);
return securityManager; // 返回安全管理器
}
// 配置 Shiro 的核心过滤器工厂,用于定义过滤规则
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager, OAuth2Filter oAuth2Filter) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
// 设置安全管理器
shiroFilter.setSecurityManager(securityManager);
// 配置自定义的 OAuth2 过滤器
Map<String, Filter> filters = new HashMap<>();
filters.put("oauth2", oAuth2Filter); // 定义 oauth2 过滤器
shiroFilter.setFilters(filters);
// 配置过滤链,定义访问权限
Map<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/webjars/**", "anon");
filterMap.put("/druid/**", "anon");
filterMap.put("/app/**", "anon");
filterMap.put("/sys/login", "anon");
filterMap.put("/swagger/**", "anon");
filterMap.put("/v2/api-docs", "anon");
filterMap.put("/swagger-ui.html", "anon");
filterMap.put("/swagger-resources/**", "anon");
filterMap.put("/captcha.jpg", "anon");
filterMap.put("/user/register", "anon");
filterMap.put("/user/login", "anon");
filterMap.put("/test/**", "anon");
filterMap.put("/**", "oauth2");
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter; // 返回配置好的过滤器工厂
}
// 管理 Shiro 生命周期的工具类
@Bean("lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor(); // 返回生命周期管理器
}
// 配置权限注解支持
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
// 设置安全管理器,用于注解支持
advisor.setSecurityManager(securityManager);
return advisor; // 返回配置好的 Advisor
}
}
六、利用AOP把更新的令牌返回给客户端
我们在写OAuth2Filter
的时候,把更新后的令牌写到ThreadLocalToken
里面的ThreadLocal
。那么这个小节,我们要创建AOP切面类
,拦截所有Web方法的返回值,在返回的R对象
中添加更新后的令牌。
package com.example.emos.wx.aop;
import com.example.emos.wx.common.util.R;
import com.example.emos.wx.config.shiro.ThreadLocalToken;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Aspect // 声明为切面类
@Component // 声明为 Spring Bean
public class TokenAspect {
@Autowired
private ThreadLocalToken threadLocalToken;
@Around("execution(public * com.example.emos.wx..*.*(..))") // 定义切点
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
// 执行目标方法,并获取其返回值
R r = (R) joinPoint.proceed();
// 从 ThreadLocalToken 中获取 token
String token = threadLocalToken.getToken();
if (token != null) { // 如果 token 存在
r.put("token", token); // 将 token 添加到响应结果中
threadLocalToken.clear(); // 清除线程本地的 token
}
// 返回修改后的响应结果
return r;
}
}
七、创建ExceptionAdvice
类
对返回的异常内容做一下精简
package com.example.emos.wx.config;
import com.example.emos.wx.exception.EmosException;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.UnauthorizedException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理类,用于捕获控制器中未处理的异常并返回统一的错误信息。
*/
@Slf4j
@RestControllerAdvice // 声明为全局异常处理类
public class ExceptionAdvice {
/**
* 处理所有异常的统一方法。
*
* @param e 捕获的异常对象
* @return 错误信息,返回给前端
*/
@ResponseBody // 将返回结果序列化为 JSON 格式
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // 设置 HTTP 状态码为 500(内部服务器错误)
@ExceptionHandler(Exception.class) // 捕获所有类型为 Exception 的异常
public String validException(Exception e) {
// 将异常信息记录到日志中
log.error("执行错误",e);
// 如果异常是方法参数校验失败的异常(如 @Valid 或 @Validated 校验失败)
if (e instanceof MethodArgumentNotValidException) {
MethodArgumentNotValidException exception = (MethodArgumentNotValidException) e;
// 获取校验失败的错误信息(仅取第一个错误信息)
return exception.getBindingResult().getAllErrors().get(0).getDefaultMessage();
}
// 如果异常是自定义的 EmosException
else if (e instanceof EmosException) {
EmosException exception = (EmosException) e;
// 返回自定义异常的错误信息
return exception.getMessage();
}
// 如果异常是权限不足异常(Shiro 框架)
else if (e instanceof UnauthorizedException) {
// 返回固定的错误信息,提示用户无权限
return "你没有权限";
}
else {
// 返回通用错误信息
return "后端处理异常";
}
}
}
效果演示:
结果: