开放标准(RFC 7519):JSON Web Token (JWT)
开放标准:JSON Web Token
- 前言
- 基本使用
- 整合Shiro
- 登录
- 自定义JWT认证过滤器
- 配置Config
- 自定义凭证匹配规则
- 接口验证
- 权限控制
- 禁用session
- 缓存的使用
- 登录退出
- 单用户登录
- Token刷新
- 双Token方案
- 单Token方案
前言
JSON Web Token (JWT) 是一种开放标准 (RFC 7519),它定义了一种紧凑且自包含的方式,用于将信息作为 JSON 对象在各方之间安全地传输。此信息是经过数字签名的,因此可以验证和信任。可以使用密钥(使用 HMAC 算法)或使用 RSA 或 ECDSA 的公钥/私钥对对 JWT 进行签名。
以下是 JSON Web Token 有用的一些情况:
- 授权:这是使用 JWT 的最常见场景。用户登录后,每个后续请求都将包含 JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是当今广泛使用 JWT 的一项功能,因为它的开销小,并且能够轻松地跨不同域使用。
- 信息交换:JSON Web Token 是在各方之间安全地传输信息的好方法。由于 JWT 可以签名(例如,使用公钥/私钥对),因此您可以确保发件人是他们所声称的身份。此外,由于签名是使用标头和有效负载计算的,因此您还可以验证内容是否未被篡改。
JSON Web Token由三个部分组成,由点 ( .
) 分隔,它们是:
- Header 页眉:标头通常由两部分组成:令牌的类型(JWT)和签名算法(HMAC SHA256 或 RSA)。
{"alg": "HS256","typ": "JWT"
}
用Base64对这个JSON编码就得到JWT的第一部分。
-
Payload 有效载荷:令牌的第二部分是有效负载,其中包含声明。声明是关于实体(通常是用户)和其他数据的声明。 有三种类型的声明:registered, public 和 private。
-
Registered claims:这些是一组预定义的声明,不是强制性的,但建议使用,以提供一组有用的、可互操作的声明。比如:
iss
(签发者)、exp
(过期时间)、sub
(主题)、aud
(接收者)、iat
(签发时间)、nbf
(在此之前不可用)等。 -
Public claims:这些声明可以由用户随意定义。但不建议添加敏感信息,因为该部分在客户端可解密。
-
Private claims:提供者和消费者所共同定义的声明,一般不建议存放敏感信息
-
一个示例有效Payload如下(并不需要三个声明都设置):
{"sub": "1234567890","name": "John Doe","admin": true
}
对Payload进行Base64编码就得到JWT的第二部分。
不要将机密信息放在 JWT 的 payload 或 header 元素中,除非它们是加密的。
- Signature 签名:要创建签名部分,您必须获取编码的Header、编码的Payload、密钥、标头中指定的算法,并对其进行签名。
例如,如果您想使用 HMAC SHA256 算法,将按以下方式创建签名:
HMACSHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)
- Header页眉:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
- Payload 有效载荷:eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
- 通过HMAC SHA256 算法得到:SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
- 最后,我们将上述的 3 个部分的字符串通过
“.”
进行拼接得到完整JWT。
每当用户想要访问受保护的路由时,它都应该发送 JWT,通常在 Authorization 标头中使用 Bearer 模式。因此,标头的内容应如下所示。
Authorization: Bearer < token >
在某些情况下,这可以是无状态授权机制。服务器的受保护路由将检查 Authorization 标头中的有效 JWT,如果存在,将允许用户访问受保护的资源。跨域资源共享 (CORS) 不会成为问题,因为它不使用 Cookie。
请注意,如果您通过 HTTP 标头发送 JWT 令牌,则应尽量防止它们变得太大。某些服务器不接受超过 8 KB 的标头。
下图显示了如何获取 JWT 并用于访问 API 或资源:
- 应用程序或客户端向授权服务器请求授权。这是通过不同的授权流之一执行的。例如,典型的符合 OpenID Connect 的 Web 应用程序将使用授权代码流通过
/oauth/authorize
终端节点。 - 授予授权后,授权服务器将向应用程序返回访问令牌。
- 应用程序使用访问令牌访问受保护的资源(如 API)。
JSON 解析器在大多数编程语言中都很常见,因为它们直接映射到对象。相反,XML 没有自然的文档到对象的映射。这使得使用 JWT 比使用 SAML 断言更容易。
关于使用情况,JWT 用于 Internet 规模。这突出了在多个平台(尤其是移动平台)上对 JSON Web Token进行客户端处理的便利性。
基本使用
引入依赖的方式有很多种:
- 使用jjwt库
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.12.6</version>
</dependency>
该依赖包含了 jjwt-api
、jjwt-impl
和 jjwt-jackson
三个模块的所有功能。
- 使用java-jwt库
<dependency><groupId>com.auth0</groupId><artifactId>java-jwt</artifactId><version>4.4.0</version>
</dependency>
根据具体的项目需求和偏好进行选择(官网上好像是auth0)。
- 创建 JWT
要创建一个 JWT,你需要使用 JWT.create()
方法,然后添加必要的声明(claims),最后使用你的密钥和算法进行签名。示例代码如下:
public class Test {public static void main(String[] args) {// 创建 JWTString token = JWT.create()
// .withHeader(map) 自定义Header,可以传map或json.withIssuer("auth0") // 发行人.withSubject("1234567890") // 主题.withAudience("app_audience") // 观众.withIssuedAt(new Date()) // 发行时间.withExpiresAt(new Date(System.currentTimeMillis() + 3600 * 1000)) // 过期时间(1小时后)
// .withPayload(map) 自定义payload,可以传map或json
// .withClaim("test", "test") 自定义payload,指定name和value.sign(Algorithm.HMAC256("123345")); // 使用 HMAC256 算法和密钥进行签名,默认用该参数的加密类型当作HeaderSystem.out.println(token);/** Output* eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9* .eyJpc3MiOiJhdXRoMCIsInN1YiI6IjEyMzQ1Njc4OTAiLCJhdWQiOiJhcHBfYXVkaWVuY2UiLCJpYXQiOjE3MzYxMjc0ODksImV4cCI6MTczNjEzMTA4OX0* .O4VD5DOn77tzPUZabPhPhkFKW9vS31dTpTAOPmkRv2o*/}
}
然后去官网可以查看解密的数据,如图所示:
- 验证 JWT
验证一个 JWT,我们需要创建一个 JWTVerifier 实例,该实例定义了验证 JWT 时所需的条件(如算法和密钥、发行人、观众等)。然后,我们使用 verify()
方法对 JWT 进行验证,并返回一个 DecodedJWT 实例,该实例包含了 JWT 中的所有声明。
public class Test {public static void main(String[] args) {// 验证 JWTJWTVerifier verifier = JWT.require(Algorithm.HMAC256("123456")).build(); // 可重用验证器实例DecodedJWT jwt = verifier.verify("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" +".eyJpc3MiOiJhdXRoMCIsInN1YiI6IjEyMzQ1Njc4OTAiLCJhdWQiOiJhcHBfYXVkaWVuY2UiLCJpYXQiOjE3MzYxMjc0ODksImV4cCI6MTczNjEzMTA4OX0" +".O4VD5DOn77tzPUZabPhPhkFKW9vS31dTpTAOPmkRv2o");System.out.println("Verified Token: " + jwt);System.out.println("Verified Token: " + jwt.getId());System.out.println("Verified Token: " + jwt.getIssuer());/** Output* Verified Token: com.auth0.jwt.JWTDecoder@2aece37d* Verified Token: null* Verified Token: auth0*/}
}
如果验证过程中出现密钥不匹配或者token过期都会抛出异常,如图所示:
然后整理成Util工具类,方便调用,示例代码如下:
public class JWTUtil {// 过期时间一天private static final long EXPIRE_TIME = 24*60*60*1000;/*** 生成token签名** @param username 用户名* @param secret 密码* @return token字符串*/public static String sign(String username, String secret) {String token = JWT.create().withClaim("username", username).withIssuedAt(new Date()) // 发行时间.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRE_TIME)).sign(Algorithm.HMAC256(secret));return token;}/*** 校验token是否有效** @param token 生成token* @param username 用户名* @param secret 密码* @return*/public static boolean verify(String token, String username, String secret) {try {// 保证荷载参数一致JWTVerifier build = JWT.require(Algorithm.HMAC256(secret)).withClaim("username", username).build();DecodedJWT decodedJWT = build.verify(token);return true;} catch (Exception e) {e.printStackTrace(); // 方便查看错误信息return false;}}/*** 获取token种的用户名,过期了也可以获取* @param token* @return*/public static String getUsername(String token){try {return JWT.decode(token).getClaim("username").asString();} catch (Exception e) {return null;}}public static void main(String[] args) {String admin = sign("admin", "123345");System.out.println(verify(admin,"admin","123345"));System.out.println(getUsername(admin));}
}
有很多的案例在生成Token时,secret
参数是固定密钥。
整合Shiro
将 Shiro 与 JWT 整合可以实现无状态认证和授权,适用于分布式系统和微服务架构。
登录
以登录为切入点,创建一个登录接口,用于生成JWT并返回给客户端。
一般使用UsernamePasswordToken类作为登录参数,我们需要创建一个类似的JWT类(实现AuthenticationToken接口即可),示例代码如下:
public class JWTToken implements AuthenticationToken {private String token;public JWTToken(String token) {this.token = token;}@Overridepublic Object getPrincipal() {return token;}@Overridepublic Object getCredentials() {return token;}
}
建立一个用于授权时保存信息类,方便访问当前登录的用户信息,示例代码如下:
public class UserPrincipal implements Serializable {private User user;private String token;public UserPrincipal(User user, String token) {this.user = user;this.token = token;}// getter and setter ......
}
然后在登陆时,校验用户名和密码后生成对应的Token,使用JWT Token作为登录参数,示例代码如下:
@Controller
@RequestMapping(value = "/user")
public class UserController {@Autowiredprivate UserService userService;@PostMapping("/login")public ResponseEntity login(@RequestParam("username") String username, @RequestParam("password") String password) {// 校验用户User user = userService.getOne(new QueryWrapper<User>().ge("username", username));if (user == null) {return ResponseEntity.ok("用户不存在");}SimpleHash hash = new SimpleHash("MD5", password, username);if (!hash.toHex().equals(user.getPassword())) {return ResponseEntity.ok("账号或密码错误");}// 生成tokenString sign = JWTUtil.sign(username, hash.toHex());JWTToken jwtToken = new JWTToken(sign);// 对用户信息进行身份认证Subject subject = SecurityUtils.getSubject();try {subject.login(jwtToken);} catch (AuthenticationException e) {e.printStackTrace();}// 返回响应参数response.setHeader("Authorization", sign);return ResponseEntity.ok(sign);}@GetMapping("/main")public ResponseEntity main() {UserPrincipal userPrincipal = (UserPrincipal) SecurityUtils.getSubject().getPrincipal();User user = userPrincipal.getUser();return ResponseEntity.ok("成功");}
}
Realm中进行授权操作(授权时需要在查询一次用户信息进行保存),示例代码如下:
@Component
public class UserRealm extends AuthorizingRealm {@Autowiredprivate UserService userService;@Autowiredprivate RoleService roleService;@Autowiredprivate PermissionService permissionService;@Overridepublic boolean supports(AuthenticationToken token) {return token instanceof JWTToken;}@Overrideprotected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {JWTToken token = (JWTToken) authenticationToken;String username = JWTUtil.getUsername((String) token.getPrincipal());if (username == null) {throw new UnknownAccountException("账号不存在");}User user = userService.getOne(new QueryWrapper<User>().ge("username", username));if (user == null) {throw new UnknownAccountException("账号不存在");}// 密码验证使用tokenSimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(new UserPrincipal(user, (String) token.getPrincipal()), user.getPassword(), ByteSource.Util.bytes(username), getName());return simpleAuthenticationInfo;}@Overrideprotected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {UserPrincipal userPrincipal = (UserPrincipal) principalCollection.getPrimaryPrincipal();User user = userPrincipal.getUser();List<Role> roleList = roleService.getByUserId(user.getId());SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();roleList.forEach(item ->{simpleAuthorizationInfo.addRole(item.getName());});List<Integer> roleIds = roleList.stream().map(Role::getId).collect(Collectors.toList());List<Permission> permissions = permissionService.listByIds(roleIds);permissions.forEach(item->{simpleAuthorizationInfo.addStringPermission(item.getName());});return simpleAuthorizationInfo;}
}
(1)你会遇到第一个错误,类型转换错误JWTToken无法匹配AuthenticationToken类型,这是因为默认匹配为UsernamePasswordToken,错误如图所示:
解决该问题的方法有两种,第一种就是Config文件中调用setAuthenticationTokenClass()
方法指定匹配类,示例代码如下:
@Configuration
@Component
public class ShiroConfig {@Beanpublic Realm realm() {UserRealm userRealm = new UserRealm();userRealm.setCredentialsMatcher(hashedCredentialsMatcher()); // 为realm设置指定算法userRealm.setAuthenticationTokenClass(JWTToken.class);return userRealm;}
}
第二种方法就是再Realm中重写匹配规则,示例代码如下:
public class UserRealm extends AuthorizingRealm {@Overridepublic boolean supports(AuthenticationToken token) {return token instanceof JWTToken;}// 省略部分代码... ...
}
(2)第二个错误,如果指定了加密方式就会报 Odd number of characters.
错误,如图所示:
因为你的密码使用Token传输,通过指定的加密方式再给密码加密时不符合规则,比如:加密方式为SHA-256,如图所示:
(如果设置了指定密码加密)解决办法把Config中setCredentialsMatcher()
方法设置的加密匹配规则删除即可,示例代码如下:
@Configuration
@Component
public class ShiroConfig {@Beanpublic Realm realm() {UserRealm userRealm = new UserRealm();//userRealm.setCredentialsMatcher(hashedCredentialsMatcher()); // 为realm设置指定算法userRealm.setAuthenticationTokenClass(JWTToken.class);return userRealm;}/*** 指定密码加密算法类型*/@Beanpublic HashedCredentialsMatcher hashedCredentialsMatcher() {HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();hashedCredentialsMatcher.setHashAlgorithmName("SHA-256"); // 设置哈希算法return hashedCredentialsMatcher;}
}
另外,有思考过用UsernamePasswordToken类作为登录参数,统一再自定义Realm中处理,但在其他接口进入自定义过滤器的时候发现要传入用户名和密码,但是你只能获取Header中的Token,处理起来很繁琐,所以不考虑该思路。
自定义JWT认证过滤器
Shiro和JWT在登录验证机制上存在根本的差异:
- 在Shiro中,登录成功后,Shiro会创建一个会话(Session),并在这个会话期间维护用户的认证状态。因此,在用户的整个会话期间,只要会话没有过期或被显式销毁,Shiro就不会再次调用
login()
方法(如图所示,认证时所经过的几个过滤器,当未登录访问时就会进FormAuthenticationFilter,跳转至登录页面)。
- 对于 JWT 这种无状态认证机制,它并不依赖于 Session,因此,每次客户端发送请求时,服务端都需要验证JWT令牌的有效性,通过自定义过滤器中需要手动调用
login()
方法。
接下来,我们需要创建一个自定义的 Shiro 过滤器,负责处理请求中的 JWT Token。
public class JWTFilter extends BasicHttpAuthenticationFilter {/*** 执行登录,你可以直接将该段代码写入isAccessAllowed()中*/@Overrideprotected boolean executeLogin(ServletRequest request, ServletResponse response) {try {String headerToken = getHeaderToken(request);JWTToken jwtToken = new JWTToken(headerToken);getSubject(request, response).login(jwtToken);} catch (Exception e) {return false;}return true;}/*** 是否允许访问*/@Overrideprotected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) {if (getHeaderToken(servletRequest) != null) {return executeLogin(servletRequest, servletResponse);}return false;}/*** 访问被拒绝*/@Overrideprotected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {HttpServletResponse httpServletResponse = WebUtils.toHttp(servletResponse);httpServletResponse.setCharacterEncoding("UTF-8");httpServletResponse.setContentType("application/json");servletResponse.getWriter().println(JSONObject.toJSONString(ResponseEntity.ok("认证失败")));return false;}/*** 对进入自定义过滤器的接口跨域提供支持,非全局过滤器*/@Overrideprotected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {HttpServletRequest httpServletRequest = WebUtils.toHttp(request);HttpServletResponse httpServletResponse = WebUtils.toHttp(response);httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));// 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value());return false;}return super.preHandle(request, response);}/*** 多种方式获取token*/private String getHeaderToken(ServletRequest servletRequest) {HttpServletRequest request = WebUtils.toHttp(servletRequest);// 获取请求头的tokenString jwtToken = getAuthzHeader(servletRequest);// 获取表单参数、地址栏中的tokenif (jwtToken == null) {jwtToken = request.getParameter(AUTHORIZATION_HEADER);}// 获取cookie中的参数Cookie[] cookies = request.getCookies();if (jwtToken == null && cookies != null && cookies.length > 0) {for (Cookie cookie : cookies) {if (cookie.getName() != null && AUTHORIZATION_HEADER.equals(cookie.getName())) {jwtToken = cookie.getValue();}}}return jwtToken;}
}
上述示例中,跨域方法注释了,自定义过滤器配置的跨域方案只针对于被拦截的接口,具有一定局限性,当然可以处理成全局过滤器(使用@Component
注解,然后注入配置文件中),但是违背了隔离原则。
配置Config
创建一个Shiro配置类,并配置JWT过滤器。
@Configuration
public class ShiroConfig {/*** 核心安全过滤器对进入应用的请求进行拦截和过滤,从而实现认证、授权、会话管理等安全功能。*/@Beanpublic ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();shiroFilterFactoryBean.setSecurityManager(securityManager);// 自定义过滤器Map<String, Filter> filters = new HashMap<>();filters.put("jwt", new JWTFilter());shiroFilterFactoryBean.setFilters(filters);// 配置拦截器链,指定了哪些路径需要认证、哪些路径允许匿名访问Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();filterChainDefinitionMap.put("/user/login", "anon");filterChainDefinitionMap.put("/user/logout", "logout");filterChainDefinitionMap.put("/**", "jwt");shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);return shiroFilterFactoryBean;}/*** 创建Shiro Web应用的整体安全管理*/@Beanpublic DefaultWebSecurityManager securityManager() {DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();defaultWebSecurityManager.setRealm(realm());// 可以添加其他配置,如缓存管理器、会话管理器等return defaultWebSecurityManager;}/*** 注册Realm的对象,用于执行安全相关的操作,如用户认证、权限查询*/@Beanpublic Realm realm() {UserRealm userRealm = new UserRealm();return userRealm;}
}
自定义凭证匹配规则
再默认情况下进入SimpleCredentialsMatcher.doCredentialsMatch()
方法,通过比较两个值是否相等,如图所示:
所以前面自定义Realm中有一个错误会导致密码不匹配的问题,如图所示:
因为登录传的Token,自定义Realm中传的是用户密码,示例代码如下:
// 密码验证使用token
SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(new UserPrincipal(user, (String) token.getPrincipal())
, user.getPassword(), ByteSource.Util.bytes(username), getName());
需要将它修改为Token,两边保持一致,就能解决密码不匹配的问题,示例代码如下:
SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(new UserPrincipal(user,(String) token.getPrincipal()),
token.getCredentials(), ByteSource.Util.bytes(username), getName());
但是这种规则具有局限性,无法校验Token的有效时间(除非你的系统不需要设置有效时间,就可以使用上述方式),所以需要使用JWT的校验方式来验证Token是否有效,示例代码如下:
public class JWTCredentialsMatcher implements CredentialsMatcher {@Overridepublic boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) {UserPrincipal userPrincipal = (UserPrincipal) authenticationInfo.getPrincipals().getPrimaryPrincipal();User user = userPrincipal.getUser();return JWTUtil.verify((String) authenticationToken.getPrincipal(), user.getUsername(), user.getPassword());}
}
然后再Config中指定自定义Realm的密码匹配规则,示例代码如下:
@Configuration
@Component
public class ShiroConfig {// 省略部分代码... ...@Beanpublic Realm realm() {UserRealm userRealm = new UserRealm();userRealm.setCredentialsMatcher(new JWTCredentialsMatcher()); // 为realm指定凭证匹配规则userRealm.setAuthenticationTokenClass(JWTToken.class);return userRealm;}
}
接口验证
一切准备就绪后,我们就来验证一下登录和接口访问,我们先来访问示例代码中的/user/main
接口,传入错误的Token和不传Token,如图所示:
可以看到能够正常的拦截。然后再来访问/user/login
登录接口,传入不存在用户名或密码,以及正确账号获取Token,如图所示:
三种不同的测试方式都能正确执行,然后我们用返回的Token,访问/user/main
接口,看看能否正确返回数据,如图所示:
访问后正常返回数据,基本的JWT整合就算完成。
权限控制
Shiro通过角色和权限进行授权,以确定哪些用户可以访问资源。根据代码追踪,登录后不会进入自定义Realm中的doGetAuthorizationInfo()
授权方法,要想实现访问控制,有几种方法可以提供:
- 可以使用Shiro提供的注解,比如:
@RequiresPermissions
、@RequiresRoles
等注解。 - 调用
subject.isPermitted()
、subject.checkPermission()
等判断角色权限方法。 - 配置拦截器链指定访问权限,比如:
filterChainDefinitionMap.put("/user/list", "roles[root]");
以上三种方式再访问时触发操作,个人比较偏向第二种方式,注解的方式不够灵活需要每个接口都加上注解,拦截器链配置的方式也不够灵活,而且是再登陆前进行的校验,导致返回的状态码是401未授权,而不是500,所以第二种方式相比之下,更加灵活且友好返回结果。
我们只需要再自定义过滤器中判断即可,示例代码如下:
public class JWTFilter extends BasicHttpAuthenticationFilter {/*** 是否允许访问*/@Overrideprotected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) {HttpServletRequest request = WebUtils.toHttp(servletRequest);String requestURI = request.getRequestURI();Subject subject = getSubject(servletRequest, servletResponse);// 当header不为空且if (getHeaderToken(servletRequest) == null || !executeLogin(servletRequest, servletResponse)) {return false;}// 是否拥有访问权限if (!subject.isPermitted(requestURI)) {return false;}return true;}// 省略部分代码... ...
}
假如说现在该用户只有/user/main
的权限,当他访问/user/list
接口时,就会被拒绝,如图所示:
当没有权限时,为了更加友好的提示,我们稍作修改,示例代码如下:
public class JWTFilter extends BasicHttpAuthenticationFilter {/*** 访问被拒绝*/@Overrideprotected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {HttpServletResponse httpServletResponse = WebUtils.toHttp(servletResponse);httpServletResponse.setCharacterEncoding("UTF-8");httpServletResponse.setContentType("application/json");PrintWriter writer = servletResponse.getWriter();Object principal = getSubject(servletRequest, servletResponse).getPrincipal();if (principal == null) {writer.println(JSONObject.toJSONString(ResponseEntity.ok("没有登录")));} else {writer.println(JSONObject.toJSONString(ResponseEntity.ok("没有权限")));}return false;}// 省略部分代码... ...
}
禁用session
在前面的介绍中虽然使用的Token认证,但还是会生成Session,如图所示:
Shiro的session管理通常用于跟踪用户的登录状态和会话信息,但在某些情况下,你可能希望禁用它,例如当你使用基于token的认证(如JWT)时。
官方文档,如图所示:
DefaultWebSecurityManager中设置禁用代码,示例代码如下:
public class StatelessDefaultSubjectFactory extends DefaultWebSubjectFactory {@Overridepublic Subject createSubject(SubjectContext context) {context.setSessionCreationEnabled(false);return super.createSubject(context);}
}
@Configuration
public class ShiroConfig {@Beanpublic DefaultWebSecurityManager securityManager() {DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();defaultWebSecurityManager.setRealm(realm());// 每次请求不创建sessionStatelessDefaultSubjectFactory statelessDefaultSubjectFactory = new StatelessDefaultSubjectFactory();defaultWebSecurityManager.setSubjectFactory(statelessDefaultSubjectFactory);// 登录不创建sessionDefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();defaultSessionStorageEvaluator.setSessionStorageEnabled(false);subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);defaultWebSecurityManager.setSubjectDAO(subjectDAO);// 可以添加其他配置,如缓存管理器、会话管理器等return defaultWebSecurityManager;}
}
然后重新登录或每次请求就不会创建Token了,如图所示:
缓存的使用
Shiro 提供了缓存机制,用于提高性能,对应前后分离项目频繁的调用登录,减少自定义Realm的验证、授权对底层数据源(如数据库)的频繁访问。
实现Shiro提供的Cache类,示例代码如下:
public class RedisCacheManage implements CacheManager {private final RedisTemplate<String, Object> redisTemplate;public RedisCacheManage(RedisTemplate<String, Object> redisTemplate) {this.redisTemplate = redisTemplate;}@Overridepublic <K, V> Cache<K, V> getCache(String s) throws CacheException {return new RedisCache<>(s, redisTemplate);}
}
public class RedisCache<K, V> implements Cache<K, V> {private final HashOperations<String, K, V> hashOperations;private final String name;public RedisCache(String name, RedisTemplate<String, Object> redisTemplate) {this.name = name;this.hashOperations = redisTemplate.opsForHash();}@Overridepublic V get(K k) throws CacheException {return hashOperations.get(name, k);}@Overridepublic V put(K k, V v) throws CacheException {hashOperations.put(name, k, v);return v;}@Overridepublic V remove(K k) throws CacheException {V v = hashOperations.get(name, k);hashOperations.delete(name, k);return v;}@Overridepublic void clear() throws CacheException {hashOperations.delete(name);}@Overridepublic int size() {return hashOperations.size(name).intValue();}@Overridepublic Set<K> keys() {return hashOperations.keys(name);}@Overridepublic Collection<V> values() {return hashOperations.values(name);}
}
然后配置Redis并再自定义Realm中配置使用,示例代码如下:
@Configuration
public class ShiroConfig {// 省略部分代码... .../*** 创建Shiro Web应用的整体安全管理*/@Beanpublic DefaultWebSecurityManager securityManager() {DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();defaultWebSecurityManager.setRealm(realm());// 可以添加其他配置,如缓存管理器、会话管理器等return defaultWebSecurityManager;}/*** 注册Realm的对象,用于执行安全相关的操作,如用户认证、权限查询*/@Beanpublic Realm realm() {UserRealm userRealm = new UserRealm();userRealm.setCredentialsMatcher(new JWTCredentialsMatcher()); // 为realm设置指定算法userRealm.setCachingEnabled(true); // 启动全局缓存userRealm.setAuthenticationCachingEnabled(true); // 启动验证缓存userRealm.setAuthorizationCachingEnabled(true);userRealm.setAuthenticationCacheName("Authentication"); // 定义授权缓存名userRealm.setAuthorizationCacheName("Authorization"); // 定义认证缓存名userRealm.setCacheManager(cacheManager());userRealm.setAuthenticationTokenClass(JWTToken.class);return userRealm;}/*** redis配置*/@Autowiredprivate RedisConnectionFactory redisConnectionFactory;@Beanpublic RedisTemplate<String, Object> redisTemplate() {RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();redisTemplate.setConnectionFactory(redisConnectionFactory);Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);ObjectMapper objectMapper = new ObjectMapper();//设置了 ObjectMapper 的可见性规则。通过该设置,所有字段(包括 private、protected 和 package-visible 等)都将被序列化和反序列化,无论它们的可见性如何。objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);//启用了默认的类型信息 NON_FINAL 参数表示只有非 final 类型的对象才包含类型信息,这可以帮助在反序列化时正确地将 JSON 字符串转换回对象。objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);jackson2JsonRedisSerializer.setObjectMapper(objectMapper);StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();//key采用String的序列化方式redisTemplate.setKeySerializer(stringRedisSerializer);//hash的key也采用String的序列化方式redisTemplate.setHashKeySerializer(stringRedisSerializer);return redisTemplate;}
}
执行登录请求后、正常访问接口,可以看到第二次请求进入自定义过滤器登录就会直接从缓存获取数据(源码不做介绍),如图所示:
查看Redis中保存了验证、授权的信息,如图所示:
但是还会带来一个问题,如果当用户退出后,原有的Token如果没到过期时间,依然可以使用进行接口访问,理想状态下退出后不可再继续使用的,如图所示:
原因:Redis缓存的校验、授权数据清空后,再次请求判断缓存中没有数据,进入自定义Realm中重新查询数据库进行保存
解决这个问题,可以再登录时,生成一个Key存入Redis缓存中;请求其他接口时,再自定义过滤器中判断缓存是否存在;退出时,删除该缓存。示例代码如下:
@Controller
@RequestMapping(value = "/user")
public class UserController {@Autowiredprivate UserService userService;@Autowiredprivate RedisTemplate<String,Object> redisTemplate;@PostMapping("/login")public ResponseEntity login(@RequestParam("username") String username, @RequestParam("password") String password, HttpServletResponse response) {// 校验用户User user = userService.getOne(new QueryWrapper<User>().ge("username", username));if (user == null) {return ResponseEntity.ok("用户不存在");}SimpleHash hash = new SimpleHash("MD5", password, username);if (!hash.toHex().equals(user.getPassword())) {return ResponseEntity.ok("账号或密码错误");}// 生成tokenString sign = JWTUtil.sign(username, hash.toHex());JWTToken jwtToken = new JWTToken(sign);// 保存用户tokenString key = "login_user_token_"+username;redisTemplate.opsForValue().set(key, sign);try {// 对用户信息进行身份认证Subject subject = SecurityUtils.getSubject();subject.login(jwtToken);} catch (IncorrectCredentialsException e) {return ResponseEntity.ok("密码错误");}// 返回响应参数response.setHeader("Authorization", sign);return ResponseEntity.ok(jwtToken.getPrincipal());}@PostMapping("/logout")public ResponseEntity logout() {UserPrincipal userPrincipal = (UserPrincipal) SecurityUtils.getSubject().getPrincipal();// 删除用户tokenString key = "login_user_token_"+userPrincipal.getUser().getUsername();redisTemplate.delete(key);// 同时清空验证和授权缓存SecurityUtils.getSubject().logout();return ResponseEntity.ok("成功");}
}
@Configuration
public class ShiroConfig {/*** 核心安全过滤器对进入应用的请求进行拦截和过滤,从而实现认证、授权、会话管理等安全功能。*/@Beanpublic ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();shiroFilterFactoryBean.setSecurityManager(securityManager);// 配置拦截器链,指定了哪些路径需要认证、哪些路径允许匿名访问Map<String, Filter> filters = new HashMap<>();filters.put("jwt", new JWTFilter(redisTemplate()));shiroFilterFactoryBean.setFilters(filters);Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();filterChainDefinitionMap.put("/user/login", "anon");filterChainDefinitionMap.put("/**", "jwt");shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);return shiroFilterFactoryBean;}// 省略部分代码... ...
}
public class JWTFilter extends BasicHttpAuthenticationFilter {private RedisTemplate<String,Object> redisTemplate;public JWTFilter(RedisTemplate<String,Object> redisTemplate){this.redisTemplate = redisTemplate;}/*** 执行登录*/@Overrideprotected boolean executeLogin(ServletRequest request, ServletResponse response) {try {String headerToken = getHeaderToken(request);JWTToken jwtToken = new JWTToken(headerToken);// 获取tokenString key = "login_user_token_"+JWTUtil.getUsername(headerToken);String redisToken = (String) redisTemplate.opsForValue().get(key);if (redisToken == null) { // 缓存为空,可能有效期过期,同时删除验证和授权缓存getSubject(request, response).logout();return false;}getSubject(request, response).login(jwtToken);} catch (Exception e) {e.printStackTrace();return false;}return true;}// 省略部分代码... ...
}
下次访问当缓存中的数据不存在时(比如缓存过期导致),同时需要将Redis缓存中验证、授权时保存的数据一并清除避免占用资源,也解决了失效Token继续请求的问题(但是还有另一个问题,如果缓存过期后直接请求登录接口,导致验证、授权保存的数据未清除,所以这种情况下可能还需要监听Redis过期进行处理),执行结果如图所示:
如果不将验证、授权进行缓存,那么处理起来就非常的简单,直接在自定义Realm中判断Redis的Key过期没有即可(如果启用缓存,在自定义Realm中判断会导致下次请求直接从缓存获取数据不进入自定义Realm中,所以再过滤器中处理),不需要考虑验证、授权的缓存清除问题(建议这样,不然你要考虑很多缓存过期数据清理的问题,比如缓存过期对应数据验证、授权需要清除)。
除此之外,你还可以给这个Key设置过期时间,每次请求当缓存未删除时需要给该缓存的过期时间进行延长,过期删除后下次请求不允许访问,需要重新登录(后续讲解)。
另外如果只允许一个用户同时登录,你还需要判断当前的用户Token与缓存中的是否一致。将另外一个用户进行下线,保证只有一个用户可以操作。
更多的处理方式,取决于你的业务深度,具体问题具体分析。
登录退出
与前后不分离项目有所区别,如果登录退出使用默认logout
过滤器,示例代码如下:
filterChainDefinitionMap.put("/user/logout", "logout");
会导致退出成功后无法重定向到指定页面,导致报错,如图所示:
我们可以看下源码,如图所示:
当登录退出后,默认重定向到/
根目录,因为是前后分离页面导致报错。
解决这个问题,在过滤器链中不定义/user/logout
接口,然后就会先进入自定义过滤器中,然后执行登录接口逻辑,再执行具体接口业务,如果Token不存在,则在过滤器中被拦截(不建议设置为anon
匿名过滤器,如果先用失效的Token再用未失效的Token会导致获取不到当前登录用户的情况,导致无法清除,一定是登录成功的用户才可以登录退出)。
示例代码如下:
@Configuration
public class ShiroConfig {/*** 核心安全过滤器对进入应用的请求进行拦截和过滤,从而实现认证、授权、会话管理等安全功能。*/@Beanpublic ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();shiroFilterFactoryBean.setSecurityManager(securityManager);// 配置拦截器链,指定了哪些路径需要认证、哪些路径允许匿名访问Map<String, Filter> filters = new HashMap<>();filters.put("jwt", new JWTFilter(redisTemplate()));shiroFilterFactoryBean.setFilters(filters);Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();filterChainDefinitionMap.put("/user/login", "anon");filterChainDefinitionMap.put("/**", "jwt");shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);return shiroFilterFactoryBean;}// 省略部分代码... ...
}
public class JWTFilter extends BasicHttpAuthenticationFilter {private RedisTemplate<String,Object> redisTemplate;public JWTFilter(RedisTemplate<String,Object> redisTemplate){this.redisTemplate = redisTemplate;}/*** 执行登录*/@Overrideprotected boolean executeLogin(ServletRequest request, ServletResponse response) {try {String headerToken = getHeaderToken(request);JWTToken jwtToken = new JWTToken(headerToken);String key = "login_user_token_"+JWTUtil.getUsername(headerToken);String redisToken = (String) redisTemplate.opsForValue().get(key);if (redisToken == null) { // 只有Token一致才能登录,其他全部拦截getSubject(request, response).logout();return false;}getSubject(request, response).login(jwtToken);} catch (Exception e) {e.printStackTrace();return false;}return true;}// 省略部分代码... ...
}
@Controller
@RequestMapping(value = "/user")
public class UserController {@PostMapping("/logout")public ResponseEntity logout() {UserPrincipal userPrincipal = (UserPrincipal) SecurityUtils.getSubject().getPrincipal();if (userPrincipal != null) {// 删除用户tokenString key = "login_user_token_" + userPrincipal.getUser().getUsername();redisTemplate.delete(key);}SecurityUtils.getSubject().logout();return ResponseEntity.ok("成功");}
}
因为我们使用缓存解决登录退出后Token继续使用的问题,所以登录退出时,还需要将该缓存从数据清空(如果开启了验证、授权缓存登录退出时logout()
方法自动清除),为了防止过期的Token请求登录退出接口导致Null指针错误,需进行非空判断。执行结果如图:
单用户登录
在实际应用中,有时需要限制一个账号只能在一处登录,即实现单用户登录功能。
在前面的缓存章节中有介绍过Token失效后继续使用的问题,我们使用另一个缓存解决Token失效的问题,当然该解决方案稍作修改也解决的单用户登录的问题,示例代码如下:
public class JWTFilter extends BasicHttpAuthenticationFilter {private RedisTemplate<String,Object> redisTemplate;public JWTFilter(RedisTemplate<String,Object> redisTemplate){this.redisTemplate = redisTemplate;}/*** 执行登录*/@Overrideprotected boolean executeLogin(ServletRequest request, ServletResponse response) {try {String headerToken = getHeaderToken(request);JWTToken jwtToken = new JWTToken(headerToken);String key = "login_user_token_"+JWTUtil.getUsername(headerToken);String redisToken = (String) redisTemplate.opsForValue().get(key);if (redisToken == null || !redisToken.equals(headerToken)) { // 只有Token一致才能登录,其他全部拦截getSubject(request, response).logout();return false;}getSubject(request, response).login(jwtToken);} catch (Exception e) {e.printStackTrace();return false;}return true;}// 省略部分代码... ...
}
执行结果如图所示:
其原理就是相同Key对应的Value被替换,当Token不一致时,不允许访问接口,从而实现了单用户登录功能,这样就确保了一个账号只能在一处登录。
如果想要实现同一账号多用户登录,就不能公用一个Key,每个用户登录生成不同Token,当作Key可以解决一账号多用户登录也可以阻止Token失效继续访问的问题,如图所示:
如果验证、授权使用缓存,实现单(多)用户登录,最简单的办法就是缓存的Key进行处理保持一致,实现逻辑和上面基本一致,这样三个缓存(验证、授权、解决Token失效的缓存)都要保持一致。
Token刷新
以页面的形式进行讲解,请求登录接口,遇到CORS跨域问题,因为登录使用匿名过滤器,自定义过滤器的跨域处理不会生效,如图所示:
所有这里定义一个全局的跨域配置(有很多种方案),示例代码如下:
@Configuration
public class MyCorsConfig {@Beanpublic CorsFilter corsFilter() {CorsConfiguration config = new CorsConfiguration();config.addAllowedOriginPattern("*"); // 支持域config.setAllowCredentials(true); // 是否发送Cookieconfig.addAllowedMethod("*"); // 支持请求方式config.addAllowedHeader("*"); // 允许的原始请求头部信息config.addExposedHeader("*"); // 暴露的头部信息UrlBasedCorsConfigurationSource corsConfigurationSource = new UrlBasedCorsConfigurationSource();corsConfigurationSource.registerCorsConfiguration("/**", config);return new CorsFilter(corsConfigurationSource);}
}
在 Web 应用程序中,Token(令牌)通常用于身份验证和授权。为了保证安全性和用户体验, Token 通常会设置一个较短的有效期。当 Token 即将过期或已经过期时,需要进行刷新操作以获取新的 Token。
双Token方案
客户端在初次认证时,服务器会返回一个短期有效的访问Token和一个长期有效的刷新 Token。客户端在访问 Token过期时,可以使用刷新Token向服务器申请新的访问Token。
登录时生成访问令牌accessToken和刷新令牌refreshToken,前端隔段时间通过refreshToken调用/user/refreshToken
接口获取新的accessToken,示例代码如下:
@Controller
@RequestMapping(value = "/user")
public class UserController {@Autowiredprivate UserService userService;@Autowiredprivate RedisTemplate<String,Object> redisTemplate;@PostMapping("/login")public ResponseEntity login(@RequestParam("username") String username, @RequestParam("password") String password, HttpServletResponse response) {User user = userService.getOne(new QueryWrapper<User>().ge("username", username));if (user == null) {return ResponseEntity.ok("用户不存在");}Sha256Hash hash = new Sha256Hash(password, username);if (!hash.toHex().equals(user.getPassword())) {return ResponseEntity.ok("账号或密码错误");}// 生成tokenString sign = JWTUtil.sign(username, hash.toHex());// 生成token 假设AccessToken为30分钟有效期String generateAccessToken = JWTUtil.sign(username, hash.toHex());// RefreshToken7天有效期String generateRefreshToken = JWTUtil.sign(username, hash.toHex());// 保存用户tokenString accessToken = "access_token_"+username;redisTemplate.opsForValue().set(accessToken, generateAccessToken);String refreshToken = "refresh_token_"+username;redisTemplate.opsForValue().set(refreshToken, generateRefreshToken);try {// 对用户信息进行身份认证JWTToken jwtToken = new JWTToken(generateAccessToken);Subject subject = SecurityUtils.getSubject();subject.login(jwtToken);} catch (IncorrectCredentialsException e) {return ResponseEntity.ok("密码错误");}// 返回响应参数response.setHeader("accessToken", generateAccessToken);response.setHeader("refreshToken", generateRefreshToken);Map<String, String> tokenResp= new HashMap<>();tokenResp.put("accessToken", accessToken);tokenResp.put("refreshToken", refreshToken);return ResponseEntity.ok(tokenResp);}@PostMapping("/refreshToken")public ResponseEntity<Map<String, String>> refreshToken(@RequestBody Map<String, String> request) {UserPrincipal userPrincipal = (UserPrincipal) SecurityUtils.getSubject().getPrincipal();User user = userPrincipal.getUser();String refreshToken = request.get("refreshToken");// 校验token是否过期if (JWTUtil.verify(refreshToken, user.getUsername(), user.getPassword())) {return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();}String newAccessToken = JWTUtil.sign(user.getUsername(), user.getPassword());Map<String, String> response = new HashMap<>();response.put("accessToken", newAccessToken);return ResponseEntity.ok(response);}
}
双 Token 方案需要额外管理刷新令牌,包括生成、存储、验证和更新等操作,增加了系统的复杂度和开发成本。
单Token方案
- 访问 Token本身携带所有用户认证信息。当Token过期时,客户端需要重新进行登录获取新的Token。这种方案非常的简单粗暴,一般很少使用这种方案。
登录时保存Token并设置缓存过期时间,示例代码如下:
@Controller
@RequestMapping(value = "/user")
public class UserController {@Autowiredprivate UserService userService;@Autowiredprivate RedisTemplate<String,Object> redisTemplate;@PostMapping("/login")public ResponseEntity login(@RequestParam("username") String username, @RequestParam("password") String password, HttpServletResponse response) {User user = userService.getOne(new QueryWrapper<User>().ge("username", username));if (user == null) {return ResponseEntity.ok("用户不存在");}Sha256Hash hash = new Sha256Hash(password, username);if (!hash.toHex().equals(user.getPassword())) {return ResponseEntity.ok("账号或密码错误");}// 生成tokenString sign = JWTUtil.sign(username, hash.toHex());JWTToken jwtToken = new JWTToken(sign);// 保存用户token,设置token有效期30分钟String key = "login_user_token_"+username;redisTemplate.opsForValue().set(key, sign, 30, TimeUnit.HOURS);try {// 对用户信息进行身份认证Subject subject = SecurityUtils.getSubject();subject.login(jwtToken);} catch (IncorrectCredentialsException e) {return ResponseEntity.ok("密码错误");}// 返回响应参数response.setHeader("Authorization", sign);// 返回cookieCookie cookie = new Cookie("Authorization", sign);response.addCookie(cookie);return ResponseEntity.ok(jwtToken.getPrincipal());}
}
用户登录后,访问其他接口,进入自定义过滤器,如果缓存中的Token过期被清除了,要求重新登录,示例代码如下:
public class JWTFilter extends BasicHttpAuthenticationFilter {private RedisTemplate<String,Object> redisTemplate;public JWTFilter(RedisTemplate<String,Object> redisTemplate){this.redisTemplate = redisTemplate;}/*** 执行登录*/@Overrideprotected boolean executeLogin(ServletRequest request, ServletResponse response) {try {String headerToken = getHeaderToken(request);JWTToken jwtToken = new JWTToken(headerToken);// 获取tokenString key = "login_user_token_"+JWTUtil.getUsername(headerToken);String redisToken = (String) redisTemplate.opsForValue().get(key);// 判断Token是否存在if (redisToken == null || !redisToken.equals(headerToken)) {getSubject(request, response).logout();return false;}getSubject(request, response).login(jwtToken);} catch (Exception e) {e.printStackTrace();return false;}return true;}/*** 是否允许访问*/@Overrideprotected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) {HttpServletRequest http = WebUtils.toHttp(servletRequest);String requestURI = http.getRequestURI();System.out.println(requestURI);Subject subject = getSubject(servletRequest, servletResponse);boolean isAccessAllowed = false;if (getHeaderToken(servletRequest) != null) {isAccessAllowed = executeLogin(servletRequest, servletResponse);}System.out.println(subject.getPrincipal());if (isAccessAllowed && !subject.isPermitted(requestURI)) {return false;}return isAccessAllowed;}/*** 访问被拒绝*/@Overrideprotected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {HttpServletResponse httpServletResponse = WebUtils.toHttp(servletResponse);httpServletResponse.setCharacterEncoding("UTF-8");httpServletResponse.setContentType("application/json");Object subject = getSubject(servletRequest, servletResponse).getPrincipal();PrintWriter writer = servletResponse.getWriter();if (subject == null) {writer.println(JSONObject.toJSONString(ResponseEntity.ok("没有登录")));} else {writer.println(JSONObject.toJSONString(ResponseEntity.ok("没有权限")));}return false;}// 省略部分代码... ...
}
不需要额外实现复杂的 Token 刷新机制,系统只需要关注 Token 的有效期和登录验证逻辑,降低了开发和维护的难度。
用户在使用过程中,一旦 Token 过期就需要重新输入用户名和密码进行登录,尤其是在频繁操作或者长时间使用的场景下,这会给用户带来极大的不便,降低用户对系统的满意度。
- 访问Token有一个固定的过期时间,然而每次使用Token时,过期时间会重新刷新,延长到固定的时间窗口。这种方式通常与刷新 Token方案结合使用。
登录时设置Token存放Redis中的有效时间(Token不设置过期时间),示例代码如下:
public class JWTUtil {/*** 生成token签名** @param username 用户名* @param secret 密码* @return token字符串*/public static String sign(String username, String secret) {String token = JWT.create().withClaim("username", username).withIssuedAt(new Date()) // 发行时间.sign(Algorithm.HMAC256(secret));return token;}// 省略部分代码... ...
}
@Controller
@RequestMapping(value = "/user")
public class UserController {@Autowiredprivate UserService userService;@Autowiredprivate RedisTemplate<String,Object> redisTemplate;@PostMapping("/login")public ResponseEntity login(@RequestParam("username") String username, @RequestParam("password") String password, HttpServletResponse response) {// 校验用户User user = userService.getOne(new QueryWrapper<User>().ge("username", username));if (user == null) {return ResponseEntity.ok("用户不存在");}SimpleHash hash = new SimpleHash("MD5", password, username);if (!hash.toHex().equals(user.getPassword())) {return ResponseEntity.ok("账号或密码错误");}// 生成tokenString sign = JWTUtil.sign(username, hash.toHex());JWTToken jwtToken = new JWTToken(sign);// 保存用户token,设置token有效期30分钟String key = "login_user_token_"+username;redisTemplate.opsForValue().set(key, sign, 30, TimeUnit.MINUTES);try {// 对用户信息进行身份认证Subject subject = SecurityUtils.getSubject();subject.login(jwtToken);} catch (IncorrectCredentialsException e) {return ResponseEntity.ok("密码错误");}// 返回响应参数response.setHeader("Authorization", sign);// 返回cookieCookie cookie = new Cookie("Authorization", sign);response.addCookie(cookie);return ResponseEntity.ok(jwtToken.getPrincipal());}
}
访问其他接口时,进入自定义过滤器中,如果Redis中的Token不存在(过期删除)就要求重新登陆,否则每次请求就将Redis过期时间刷新,示例代码如下:
public class JWTFilter extends BasicHttpAuthenticationFilter {private RedisTemplate<String,Object> redisTemplate;public JWTFilter(){}public JWTFilter(RedisTemplate<String,Object> redisTemplate){this.redisTemplate = redisTemplate;}/*** 执行登录*/@Overrideprotected boolean executeLogin(ServletRequest request, ServletResponse response) {try {String headerToken = getHeaderToken(request);JWTToken jwtToken = new JWTToken(headerToken);// 获取tokenString key = "login_user_token_"+JWTUtil.getUsername(headerToken);String redisToken = (String) redisTemplate.opsForValue().get(key);if (redisToken == null || !redisToken.equals(headerToken)) {getSubject(request, response).logout();return false;} else { // 重置redis过期时间System.out.println("redis-expire:"+redisTemplate.getExpire(key, TimeUnit.MINUTES));redisTemplate.expire(key, 30, TimeUnit.MINUTES);}getSubject(request, response).login(jwtToken);} catch (Exception e) {e.printStackTrace();return false;}return true;}// 省略部分代码... ...
}
每次请求刷新对应Redis有效期,若长时间未操作,到期自动清除,重新登陆。执行结果如图所示:
如果你觉得Token续期的方案,安全性太低了,当令牌已过期,服务器返回特定的错误码(如 401 Unauthorized),客户端捕获到该错误后,自动向服务器发送刷新令牌的请求。有三种方案:定时刷新、请求拦截、响应拦截,下面针对这几种方案进行详细讲解。
- 定时刷新
在用户登录成功后,与后端确认Token过期时间(一般来说应小于服务器的过期时间),使用 JavaScript 的 setInterval
函数来定时触发刷新 Token的请求。
后端需要提供Token刷新接口,通过当前Token的有效期防止频繁刷新Token,缓存Token中不存在再拦截器中处理,示例代码如下:
@Controller
@RequestMapping(value = "/user")
public class UserController {@PostMapping("/refreshToken")public ResponseEntity refreshToken(HttpServletResponse response) {UserPrincipal userPrincipal = (UserPrincipal) SecurityUtils.getSubject().getPrincipal();User user = userPrincipal.getUser();// 获取Token有效期Date expiry = JWTUtil.getExpiry(userPrincipal.getToken());// 正确将 Date 转换为 LocalDateTimeInstant instant = expiry.toInstant();LocalDateTime dateAsLocalDateTime = instant.atZone(ZoneId.systemDefault()).toLocalDateTime();LocalDateTime now = LocalDateTime.now();Duration between = Duration.between(now, dateAsLocalDateTime);// 间隔时间大于5分钟不生成新tokenif (between.toMinutes() > 5) {// 返回原tokenresponse.setHeader("Authorization", userPrincipal.getToken());return ResponseEntity.ok(userPrincipal.getToken());}String sign = JWTUtil.sign(user.getUsername(), user.getPassword());// token重新赋值String key = "login_user_token_" + userPrincipal.getUser().getUsername();redisTemplate.opsForValue().set(key, sign);// 返回tokenresponse.setHeader("Authorization", sign);return ResponseEntity.ok(sign);}
}
登录时生成Token并设置JWT有效期(短Token)和Redis有效期(长Token)存入缓存,示例代码如下:
public class JWTUtil {// 过期30分钟private static final long EXPIRE_TIME = 30 * 60 * 1000;/*** 生成token签名** @param username 用户名* @param secret 密码* @return token字符串*/public static String sign(String username, String secret) {String token = JWT.create().withClaim("username", username).withIssuedAt(new Date()) // 发行时间.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRE_TIME)) // 过期时间.sign(Algorithm.HMAC256(secret));return token;}/*** 获取token有效期* @param token* @return */public static Date getExpiry(String token){try {return JWT.decode(token).getExpiresAt();} catch (Exception e) {return null;}}
}
@Controller
@RequestMapping(value = "/user")
public class UserController {@PostMapping("/login")public ResponseEntity login(@RequestParam("username") String username, @RequestParam("password") String password, HttpServletResponse response) {User user = userService.getOne(new QueryWrapper<User>().ge("username", username));if (user == null) {return ResponseEntity.ok("用户不存在");}Sha256Hash hash = new Sha256Hash(password, username);if (!hash.toHex().equals(user.getPassword())) {return ResponseEntity.ok("账号或密码错误");}// 生成tokenString sign = JWTUtil.sign(username, hash.toHex());JWTToken jwtToken = new JWTToken(sign);// 保存用户token,设置token有效期7天String key = "login_user_token_"+username;redisTemplate.opsForValue().set(key, sign, 7, TimeUnit.DAY);try {// 对用户信息进行身份认证Subject subject = SecurityUtils.getSubject();subject.login(jwtToken);} catch (IncorrectCredentialsException e) {return ResponseEntity.ok("密码错误");}// 返回响应参数response.setHeader("Authorization", sign);// 返回cookieCookie cookie = new Cookie("Authorization", sign);response.addCookie(cookie);return ResponseEntity.ok(jwtToken.getPrincipal());}// 模拟首页多个请求情况@PostMapping("/main")public ResponseEntity main() {Subject subject = SecurityUtils.getSubject();System.out.println("===============main==========");System.out.println(subject.getPrincipal());return ResponseEntity.ok("success");}@PostMapping("/main2")public ResponseEntity main2() {return ResponseEntity.ok("success");}@PostMapping("/main3")public ResponseEntity main3() {try {Thread.sleep(3000);} catch (InterruptedException e) {throw new RuntimeException(e);}return ResponseEntity.ok("success");}@PostMapping("/main4")public ResponseEntity main4() {try {Thread.sleep(4000);} catch (InterruptedException e) {throw new RuntimeException(e);}return ResponseEntity.ok("success");}
}
如果用过期的Token访问(JWT过期和Redis过期删除)接口,过滤器拦截,后端直接认定为没有登陆,JWT过期再登录时参考自定义匹配凭证章节;Redis过期删除判断主要再拦截器中处理,因为每隔段时间会更换Token所以不需要缓存延期,示例代码如下:
public class JWTFilter extends BasicHttpAuthenticationFilter {@Overrideprotected boolean executeLogin(ServletRequest request, ServletResponse response) {try {String headerToken = getHeaderToken(request);JWTToken jwtToken = new JWTToken(headerToken);// 获取tokenString key = "login_user_token_"+JWTUtil.getUsername(headerToken);String redisToken = (String) redisTemplate.opsForValue().get(key);if (redisToken == null || !redisToken.equals(headerToken)) {// 通过状态码跳转登录httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);getSubject(request, response).logout();return false;}getSubject(request, response).login(jwtToken);} catch (Exception e) {e.printStackTrace();return false;}return true;}// 省略部分代码... ...
}
前端以JavaScript方式的 setInterval
函数来定时触发刷新 Token的请求。以登录成功后(将Token存入localStorage)进入首页为例,示例代码如下:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>首页页面</title>
</head>
<body>
<div class="login-container"><h2>首页</h2><button id="fetchDataButton">发起请求</button><p id="responseMessage"></p> <!-- 添加了这个元素 --><p><a href="/user/logout">退出登录</a></p>
</div>
<script type="module">import { makeRequest } from './tokenHandler.js';// 触发点击事件document.getElementById('fetchDataButton').addEventListener('click', async () => {try {await makeRequest('http://localhost:8080/user/main');await makeRequest('http://localhost:8080/user/main2');await makeRequest('http://localhost:8080/user/main3');await makeRequest('http://localhost:8080/user/main4');// console.log('请求结果:', result);// document.getElementById('responseMessage').textContent = result;} catch (error) {console.error('Error:', error);}});
</script>
</body>
</html>
封装成公用js代码方便调用,定时任务每隔段时间请求刷新Token接口,如果此时用户正在请求就会放入队列中进行等待,示例代码如下:
// 标记 Token 是否正在刷新
let isRefreshingToken = false;
// 请求队列
let requestQueue = [];// 模拟后端接口:刷新 Token
async function refreshTokenApi() {isRefreshingToken = true;try {// 这里模拟向服务器发送刷新 token 的请求const response = await fetch('http://localhost:8080/user/refreshToken', {method: 'POST',headers: {'Content-Type': 'application/json','Authorization': `${localStorage.getItem('token')}`},// body: JSON.stringify({// // 可以传递一些必要的参数// })});const data = await response.text();// 假设返回的新 token 字段名为 newToken,根据实际情况修改localStorage.setItem('token', data);console.log('Token 刷新成功'+data);// 执行队列中的请求requestQueue.forEach(request => request(localStorage.getItem('token')));requestQueue = [];return data;} catch (error) {console.error('Token 刷新失败', error);requestQueue = [];throw error;} finally {isRefreshingToken = false;}
}// 定时刷新 Token
const refreshInterval = 5000; // 每 5 秒尝试刷新 Token
setInterval(() => {refreshTokenApi();
}, refreshInterval);// 发起请求的函数
// 封装通用的请求函数
const makeRequest = async (url, options = {}) => {return new Promise(async (resolve, reject) => {const originalRequest = async (token) => {try {const newOptions = {...options,headers: {...options.headers,'Authorization': `${token}`},method: 'GET'};const response = await fetch(url, newOptions);const data = await response.text();resolve(data);} catch (error) {reject(error);}};console.log(isRefreshingToken)if (isRefreshingToken) {console.log("当前正再刷新token,请求放入队列中")// 如果 Token 正在刷新,将请求加入队列requestQueue.push(originalRequest);console.log(requestQueue)} else {// 检查 Token 是否过期,这里简单假设响应状态码为 401 表示 Token 过期const response = await fetch(url, {...options,headers: {...options.headers,'Authorization': `${localStorage.getItem('token')}`}});if (response.status === 401) {// Token 过期,开始刷新requestQueue.push(originalRequest);refreshTokenApi().catch(reject);} else {const data = await response;resolve(data);}}});
};
export { makeRequest };
其实前端放入队列的请求只有一个,Token刷新后继续执行队列中的请求,后续的请求等待第一个执行完毕后才会执行,类似于同步操作。
我们需要考虑第一个问题,如果请求接口(模拟某个接口请求慢的情况)途中触发Token刷新,那么后面接口就应该等待Token刷新后使用新的Token进行请求,执行结果如图所示:
第二个问题,如果Token刷新的途中(模拟Token刷新慢),发起请求应该等待Token刷新完毕后再发起请求,执行结果如图所示:
按照固定的时间间隔向服务器发送刷新请求,会显著增加服务器的处理压力,频繁的刷新操作会消耗额外的网络带宽。另一方面,攻击者可以通过截获 Token 刷新请求,分析其中的加密算法和数据格式,从而尝试破解用户的 Token。
- 请求拦截
在获取 Token 时,服务器通常会同时返回 Token 的过期时间(也可以前端解析JWT获取)。在请求拦截时,通过比较当前时间和过期时间来判断 Token 是否过期,若已过期,则将请求挂起,先刷新Token后再继续请求。
我们需要再登录接口和刷新Token接口返回参数加上Token有效期,示例代码如下:
@Controller
@RequestMapping(value = "/user")
public class UserController {@PostMapping("/login")public ResponseEntity login(@RequestParam("username") String username, @RequestParam("password") String password, HttpServletResponse response) {// 省略部分代码... ...// 返回参数Map<String,Object> map = new HashMap<>();map.put("Authorization", sign);map.put("ExpireTime", JWTUtil.getExpiry(sign).getTime());return ResponseEntity.ok(jwtToken.getPrincipal());}@PostMapping("/refreshToken")public ResponseEntity refreshToken() throws InterruptedException {// 省略部分代码... ...// 返回参数Map<String,Object> map = new HashMap<>();map.put("Authorization", sign);map.put("ExpireTime", JWTUtil.getExpiry(sign).getTime());return ResponseEntity.ok(sign);}
}
登录成功后(将Token和ExpireTime存入localStorage)进入首页为例,主要介绍js部分的处理(其他代码参考之前案例),用户发起请求前先校验Token的过期时间是否达到范围,如果没有则正常请求,否则将请求放入队列,先执行Token刷新操作,示例代码如下:
// 标记 Token 是否正在刷新
let isRefreshingToken = false;
// 请求队列
let requestQueue = [];// 模拟后端接口:刷新 Token
async function refreshTokenApi() {isRefreshingToken = true;try {// 这里模拟向服务器发送刷新 token 的请求const response = await fetch('http://localhost:8080/user/refreshToken', {method: 'POST',headers: {'Content-Type': 'application/json','Authorization': `${localStorage.getItem('token')}`},// body: JSON.stringify({// // 可以传递一些必要的参数// })});const data = await response.json();// 假设返回的新 token 字段名为 newToken,根据实际情况修改localStorage.setItem('token', data.Authorization);localStorage.setItem('expireTime', data.ExpireTime);console.log('Token 刷新成功'+data);// 执行队列中的请求requestQueue.forEach(request => request(localStorage.getItem('token')));requestQueue = [];return data;} catch (error) {console.error('Token 刷新失败', error);requestQueue = [];throw error;} finally {isRefreshingToken = false;}
}
// 封装通用的请求函数
const makeRequest = async (url, options = {}) => {return new Promise(async (resolve, reject) => {const originalRequest = async (token) => {try {const newOptions = {...options,headers: {...options.headers,'Authorization': `${token}`},method: 'GET'};const response = await fetch(url, newOptions);if (!response.ok) {if (response.status === 401) { // 再次检查,以防本地判断失误if (!isRefreshing) {requestQueue.push((newToken) => originalRequest(newToken));await refreshToken();} else {requestQueue.push((newToken) => originalRequest(newToken));}return;}throw new Error(`Request failed with status ${response.status}`);}const data = await response.text();resolve(data);} catch (error) {reject(error);}};// 请求拦截:判断 Token 是否过期const currentTime = Date.now();const expireTime = localStorage.getItem("expireTime");if (expireTime && isFiveMinutesApart(expireTime, currentTime)) {console.log(isRefreshingToken)if (isRefreshingToken) {console.log("当前正再刷新token,请求放入队列中")// 如果 Token 正在刷新,将请求加入队列requestQueue.push(originalRequest);console.log(requestQueue)} else {requestQueue.push(originalRequest);await refreshTokenApi();}} else {// 正常发送请求await originalRequest(localStorage.getItem("token"));}});
};function isFiveMinutesApart(timestamp1, timestamp2) {// 计算两个时间戳的差值(毫秒)const difference = timestamp1 - timestamp2;console.log(difference)// 5 分钟对应的毫秒数const fiveMinutesInMs = 5 * 60 * 1000;// 判断差值是否等于 5 分钟对应的毫秒数return difference < fiveMinutesInMs;
}
export { makeRequest };
当Token过期时间达到范围值先触发Token刷新,再将其他请求放入队列,等Token刷新后再发起,否则正常请求接口,执行结果如图所示:
仔细思考还有一个问题未解决?如果用户长时间未操作,此时Token已经过期了,如果再次请求刷新Token接口,再拦截器拦下返回没有登录,该如何处理?如果直接跳转登录页面根本不需要刷新Token方案。经过思考,我们将刷新Token接口设置为anon
匿名访问,示例代码如下:
@Configuration
public class ShiroConfig {/*** 核心安全过滤器对进入应用的请求进行拦截和过滤,从而实现认证、授权、会话管理等安全功能。*/@Beanpublic ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();shiroFilterFactoryBean.setSecurityManager(securityManager);// 配置拦截器链,指定了哪些路径需要认证、哪些路径允许匿名访问Map<String, Filter> filters = new HashMap<>();filters.put("jwt", new JWTFilter(redisTemplate()));shiroFilterFactoryBean.setFilters(filters);Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();filterChainDefinitionMap.put("/user/login", "anon");filterChainDefinitionMap.put("/user/refreshToken", "anon");filterChainDefinitionMap.put("/**", "jwt");shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);return shiroFilterFactoryBean;}// 省略部分代码...
}
然后修改Token刷新接口,只要Redis中Token没有过期清除,就可以重新生成Token,增加一些限制判断逻辑,示例代码如下:
@Controller
@RequestMapping(value = "/user")
public class UserController {@PostMapping("/refreshToken")public ResponseEntity refreshToken(HttpServletRequest request) {String token = request.getHeader("authorization");Date expiry = JWTUtil.getExpiry(token);String key = "login_user_token_" + JWTUtil.getUsername(token);// 拦截token不存在或者不匹配if (!redisTemplate.hasKey(key) || !redisTemplate.opsForValue().get(key).equals(token)) {return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("没有登陆");}// 正确将 Date 转换为 LocalDateTimeInstant instant = expiry.toInstant();LocalDateTime dateAsLocalDateTime = instant.atZone(ZoneId.systemDefault()).toLocalDateTime();LocalDateTime now = LocalDateTime.now();Duration between = Duration.between(now, dateAsLocalDateTime);// 间隔时间大于5分钟不生成新tokenif (between.toMinutes() > 5) {// 返回原tokenMap<String,Object> map = new HashMap<>();map.put("Authorization", token);map.put("ExpireTime", expiry.getTime());return ResponseEntity.ok(map);}User user = userService.getOne(new QueryWrapper<User>().ge("username", JWTUtil.getUsername(token)));String sign = JWTUtil.sign(user.getUsername(), user.getPassword());redisTemplate.opsForValue().set(key, sign);// 返回tokenMap<String,Object> map = new HashMap<>();map.put("Authorization", sign);map.put("ExpireTime", JWTUtil.getExpiry(sign).getTime());return ResponseEntity.ok(map);}// 省略部分代码...
}
这样即使用户长时间未操作,再次请求触发刷新Token也能正常更新,不会影响用户的体验。
需要注意设置为匿名访问,会增加一定的安全风险,尤其是可能导致恶意用户滥用该接口,在实现匿名刷新时加上适当的限制,如请求频率、IP 限制等。
相对于前端定时刷新Token,请求拦截可以避免频繁的Token刷新请求,减少不必要的网络流量。但是该方法需要后端提供Token过期字段,使用本地时间判断,容易被篡改,增加后端维护成本;增加额外的计算开销,在请求非常频繁时,这种开销会导致一定的延迟。
- 响应拦截
先发起请求,当接口返回过期后,先刷新Token再重新发送原始请求。
后端只需要把前面案例的有效期字段去除即可,代码上没什么变化,主要介绍前端js的变化,当请求第一个接口返回登录过期,先进行Token刷新并将请求放入队列中,等待刷新完毕后再次发起请求,示例代码如下:
// 标记 Token 是否正在刷新
let isRefreshingToken = false;
// 请求队列
let requestQueue = [];// 模拟后端接口:刷新 Token
async function refreshTokenApi() {isRefreshingToken = true;try {// 这里模拟向服务器发送刷新 token 的请求const response = await fetch('http://localhost:8080/user/refreshToken', {method: 'POST',headers: {'Content-Type': 'application/json','Authorization': `${localStorage.getItem('token')}`},// body: JSON.stringify({// // 可以传递一些必要的参数// })});const data = await response.json();// 假设返回的新 token 字段名为 newToken,根据实际情况修改localStorage.setItem('token', data.Authorization);localStorage.setItem('expireTime', data.ExpireTime);console.log('Token 刷新成功'+data);// 执行队列中的请求requestQueue.forEach(request => request(localStorage.getItem('token')));requestQueue = [];return data;} catch (error) {console.error('Token 刷新失败', error);requestQueue = [];throw error;} finally {isRefreshingToken = false;}
}// 发起请求的函数
// 封装通用的请求函数
const makeRequest = async (url, options = {}) => {return new Promise(async (resolve, reject) => {const originalRequest = async (token) => {try {const newOptions = {...options,headers: {...options.headers,'Authorization': `${token}`},method: 'GET'};const response = await fetch(url, newOptions);// 响应拦截const data = await toData(response);if (response.ok) {if (data.body === "没有登录") {if (!isRefreshingToken) {requestQueue.push((newToken) => originalRequest(newToken));await refreshTokenApi();} else {requestQueue.push((newToken) => originalRequest(newToken));}}} else if (!response.ok) {if (response.status === 401) { // 如果返回401则直接跳转登录页面window.location.href = "./login.html"}throw new Error(`Request failed with status ${response.status}`);}resolve(data);} catch (error) {reject(error);}};// 正常发送请求await originalRequest(localStorage.getItem("token"));});
};function toData(data) {const contentType = data.headers.get('Content-Type');if (contentType && contentType.includes('application/json')) {return data.json();} else {return data.text();}
}
export { makeRequest };
执行结果如图所示:
似乎还有更好的处理方案,既然接口响应Token失效了,是否可以直接将新的Token生成后返回。
因为生成Token逻辑再自定义过滤器中处理,为了保证过滤器的单一职责,我们先修改生成Token方式,使用固定密钥,示例代码如下:
public class JWTUtil {// 过期30分钟private static final long EXPIRE_TIME = 30 * 60 * 1000;private static final String secret = "Zt]q5V5*MZ.WfHknK)b_";/*** 生成token签名** @param username 用户名* @return token字符串*/public static String sign(String username) {String token = JWT.create().withClaim("username", username).withIssuedAt(new Date()) // 发行时间.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRE_TIME)).sign(Algorithm.HMAC256(secret));return token;}/*** 校验token是否有效** @param token 生成token* @param username 用户名* @return*/public static boolean verify(String token, String username) {try {// 保证荷载参数一致JWTVerifier build = JWT.require(Algorithm.HMAC256(secret)).withClaim("username", username).build();DecodedJWT decodedJWT = build.verify(token);return true;} catch (Exception e) {e.printStackTrace();return false;}}// 省略部分代码...
}
在过滤器中处理Token过期后重新生成Token,放入响应头中返回给前端(需要设置Access-Control-Expose-Headers
前端才能使用),示例代码如下:
public class JWTFilter extends BasicHttpAuthenticationFilter {private RedisTemplate<String,Object> redisTemplate;public JWTFilter(){}public JWTFilter(RedisTemplate<String,Object> redisTemplate){this.redisTemplate = redisTemplate;}/*** 执行登录*/@Overrideprotected boolean executeLogin(ServletRequest request, ServletResponse response) {String headerToken = getHeaderToken(request);JWTToken jwtToken = new JWTToken(headerToken);// 获取tokenString key = "login_user_token_"+JWTUtil.getUsername(headerToken);try {String redisToken = (String) redisTemplate.opsForValue().get(key);if (redisToken == null || !redisToken.equals(headerToken)) {getSubject(request, response).logout();// 通过状态码跳转登录httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);return false;}getSubject(request, response).login(jwtToken);} catch (IncorrectCredentialsException e) {// 生成Token,并且更新RedisHttpServletResponse httpServletResponse = WebUtils.toHttp(response);String newToken = createToken(headerToken);httpServletResponse.setHeader("Authorization", newToken);redisTemplate.opsForValue().set(key, newToken);e.printStackTrace();return false;}return true;}/*** 对跨域提供支持*/@Overrideprotected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {HttpServletRequest httpServletRequest = WebUtils.toHttp(request);HttpServletResponse httpServletResponse = WebUtils.toHttp(response);try {httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");// 确保前端能获取响应头中的字段httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));httpServletResponse.setHeader("Access-Control-Expose-Headers","Authorization");// 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value());return false;}}catch (Exception e) {e.printStackTrace();}return super.preHandle(request, response);}private String createToken(String headerToken) {String username = JWTUtil.getUsername(headerToken);return JWTUtil.sign(username);}
}
然后前端稍作修改,直接从第一个请求中获取新Token存入localStorage,然后重新请求,示例代码如下:
// 标记 Token 是否正在刷新
let isRefreshingToken = false;
// 请求队列
let requestQueue = [];// 模拟后端接口:刷新 api
async function refreshApi() {isRefreshingToken = true;try {// 执行队列中的请求requestQueue.forEach(request => request(localStorage.getItem('token')));requestQueue = [];} catch (error) {console.error('Token 刷新失败', error);requestQueue = [];throw error;} finally {isRefreshingToken = false;}
}// 发起请求的函数
// 封装通用的请求函数
const makeRequest = async (url, options = {}) => {return new Promise(async (resolve, reject) => {const originalRequest = async (token) => {try {const newOptions = {...options,headers: {...options.headers,'Authorization': `${token}`},method: 'GET'};const response = await fetch(url, newOptions);// 响应拦截const data = await toData(response);if (response.ok) {if (data.body === "没有登录") {if (!isRefreshingToken) {console.log(response.headers.get("authorization"))const authorization = response.headers.get("authorization");localStorage.setItem("token", authorization);// 更新tokenrequestQueue.push((newToken) => originalRequest(newToken));await refreshApi();} else {requestQueue.push((newToken) => originalRequest(newToken));}}} else if (!response.ok) {if (response.status === 401) { // 如果返回401则直接跳转登录页面window.location.href = "./login.html"}throw new Error(`Request failed with status ${response.status}`);}resolve(data);} catch (error) {reject(error);}};// 正常发送请求await originalRequest(localStorage.getItem("token"));});
};function toData(data) {const contentType = data.headers.get('Content-Type');if (contentType && contentType.includes('application/json')) {return data.json();} else {return data.text();}
}
export { makeRequest };
使用第一个接口返回的新Token重新发起请求,执行结果如图所示:
相比较于前两种方式,响应拦截不需要复杂的处理,没有额外字段,避免不必要的判断和请求,虽然会多发送一次请求,但是没有单独提供Token接口,并且新Token的随机性给系统的安全带来了极大的保障。
相关文章:
开放标准(RFC 7519):JSON Web Token (JWT)
开放标准:JSON Web Token 前言基本使用整合Shiro登录自定义JWT认证过滤器配置Config自定义凭证匹配规则接口验证权限控制禁用session缓存的使用登录退出单用户登录Token刷新双Token方案单Token方案 前言 JSON Web Token (JWT) 是一种开放标准…...
Linux上用C++和GCC开发程序实现不同MySQL实例下单个Schema之间的稳定高效的数据迁移
设计一个在Linux上运行的GCC C程序,同时连接两个不同的MySQL实例,两个实例中分别有两个Schema的表结构完全相同,复制一个实例中一个Schema里的所有表的数据到另一个实例中一个Schema里,使用以下快速高效的方法,加入异常…...
【Windows】Windows常用命令
目录 文件和目录相关命令系统信息查看命令网络相关命令进程管理命令磁盘管理命令用户和权限管理命令计划任务和脚本命令其他常用命令1. 文件和目录相关命令 命令作用示例cd切换目录cd C:\Usersdir列出目录内容dirmkdir创建新目录mkdir NewFolderrmdir删除空目录rmdir OldFolder…...
趣讲TCP三次握手
一、TCP三次握手简介 TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。在TCP连接中,只有两方进行通信,它使用校验和、确认和重传机制来保证数据的可靠传输。…...
vue3中的标签属性中的Ref
用在普通 DOM 标签上,获取的是 DOM 节点: 当你在一个普通的 HTML 标签(例如 <div>、<input> 等)上使用 ref 属性时,ref 会返回该 DOM 元素的直接引用。这使得你可以在 JavaScript 代码中方便地访问和操作…...
vue3.2 + vxe-table4.x 实现多层级结构的 合并、 展开、收起 功能
<template><div style"padding: 20px"><vxe-table border :data"list" :height"800" :span-method"rowspanMethod"><vxe-column title"一级类目" field"category1"><template #defaul…...
DeepSeek R1 + 飞书机器人实现AI智能助手
效果 TFChat项目地址 https://github.com/fish2018/TFChat 腾讯大模型知识引擎用的是DeepSeek R1,项目为sanic和redis实现,利用httpx异步处理流式响应,同时使用buffer来避免频繁调用飞书接口更新卡片的网络耗时。为了进一步减少网络IO消耗&…...
Java虚拟机监控工具
在Java应用高频出现的OOM、卡顿、线程阻塞等问题背后,往往隐藏着复杂的JVM运行机制异常。本文将通过真实案例场景,演示6款主流工具的组合使用技巧,助你快速定位90%以上的线上故障。 一、基础监控三板斧 1. jstat:GC性能透视仪 …...
利用python和gpt写一个conda环境可视化管理工具
最近在学习python,由于不同的版本之间的差距较大,如果是用环境变量来配置python的话,会需要来回改,于是请教得知可以用conda来管理,但是conda在管理的时候老是要输入命令,感觉也很烦,于是让gpt帮…...
软件工程----统一过程模型RUP
统一过程RUP是一种以用例驱动、以体系结构为核心、迭代和增量的软件开发过程,由UML方法和工具支持,广泛应用于各类面向对象项目。 RUP本身支持可裁剪性,可应付给类领域软件和不同的项目规模 RUP蕴含了大量优秀的实践方法,如&…...
Spring的MutipartFile 会直接将流转成文件存放在临时目录嘛?
Spring 的 MultipartFile 默认会将上传的文件存储到临时目录。具体行为取决于底层的 MultipartResolver 实现。常见的实现包括: 1. StandardServletMultipartResolver(默认实现) 如果使用的是 StandardServletMultipartResolver(…...
基于大数据的空气质量数据可视化分析系统
【大数据】基于大数据的空气质量数据可视化分析系统(完整系统源码开发笔记详细部署教程)✅ 目录 一、项目简介二、项目界面展示三、项目视频展示 一、项目简介 本系统的实践价值在于将大数据技术与空气质量监测相结合,为公众、研究机构和政府…...
一文了解:部署 Deepseek 各版本的硬件要求
很多朋友在咨询关于 DeepSeek 模型部署所需硬件资源的需求,最近自己实践了一部分,部分信息是通过各渠道收集整理,so 仅供参考。 言归正转,大家都知道,DeepSeek 模型的性能在很大程度上取决于它运行的硬件。我们先看一下…...
国内访问Github的四种方法(2025版)
声明:以下内容,仅供学习使用,不得他用。如有他用,与本文作者无关。 国内访问GitHub及下载文件的解决方案整理如下,结合最新技术方案和实测有效方法: 一、网络层解决方案 Hosts文件修改法 通过DNS查询工具…...
企业级AI办公落地实践:基于钉钉/飞书的标准产品解决方案
一、平台化AI的崛起:开箱即用的智能革命 2024年企业AI应用调研数据显示: 73%的中型企业选择平台标准产品而非自研头部SaaS平台AI功能渗透率达89%典型ROI周期从18个月缩短至3-6个月 核心优势对比: 维度自研方案平台标准产品部署周期6-12个…...
金融行业专题|某基金公司基于超融合信创平台支持人大金仓数据库的性能评测
随着“自主可控”在 IT 基础设施领域不断深化,数据库的国产化替代也被很多金融机构提上日程。为了保证性能,大部分国产数据库都基于信创架构的裸金属服务器部署。在国产虚拟化/超融合平台上,国产数据库性能表现如何?尤其是搭配信创…...
Python异常处理面试题及参考答案
目录 什么是 Python 中的异常?程序为什么需要异常处理机制? 解释 BaseException 和 Exception 的区别 Python 的异常处理与传统的错误代码返回机制相比有哪些优势? 列出至少 5 个 Python 内置异常类型并说明触发场景 语法错误 (SyntaxError) 与运行时异常 (Runtime Erro…...
Java 实现快速排序算法:一条快速通道,分而治之
大家好,今天我们来聊聊快速排序(QuickSort)算法,这个经典的排序算法被广泛应用于各种需要高效排序的场景。作为一种分治法(Divide and Conquer)算法,快速排序的效率在平均情况下非常高ÿ…...
【JavaSE-1】初识Java
1、Java 是什么? Java 是一种优秀的程序设计语言,人类和计算机之间的交流可以借助 Java 这种语言来进行交流,就像人与人之间可以用中文、英语,日语等进行交流一样。 Java 和 JavaScript 两者有关系吗? 一点都没有关系!!! 前端内容:HTML CSS JS,称为网页三剑客 2、JDK 下…...
JavaScript将:;隔开的字符串转换为json格式。使用正则表达式匹配键值对,并构建对象。多用于解析cssText为style Object对象
// 使用正则表达式匹配键值对,并构建对象 let string2Json(s)>{const r {};s.replace(/;/g, ;).replace(/\;/g, \n).replace(/:/g, :).replace(/\n/g, \n)//合并多个换行符.split(\n).forEach(item > {const [k, v] item.split(:);(k…...
lvgl运行机制分析
lv_timer_handler() 是 LVGL 的“心脏”:这个函数会依次做以下事情: 处理定时器(如动画、延迟回调)。 读取输入设备(如触摸屏、按键的状态)。 刷新脏区域(仅重绘屏幕上发生变化的区域…...
紧跟潮流,将 DeepSeek 集成到 VSCode
Visual Studio Code(简称 VSCode)是一款由微软开发的免费开源代码编辑器,自 2015 年发布以来,凭借其轻便、强大、且拥有丰富扩展生态的特点,迅速成为了全球开发者的首选工具。VSCode 支持多平台操作系统,包…...
Apache Tomcat RCE 稳定复现 保姆级!(CVE-2024-50379)附视频+POC
原文链接 Apache Tomcat 最新RCE 稳定复现分析 保姆级!!!附复现视频POC 前言 最近爆出 Apache Tomcat条件竞争导致的RCE,影响范围当然是巨大的,公司也及时收到了相关情报,于是老大让我复现,以…...
【文献阅读】A Survey on Model Compression for Large Language Models
大语言模型模型压缩综述 摘要 大语言模型(LLMs)已成功变革了自然语言处理任务。然而,其庞大的规模和高昂的计算需求给实际应用带来了挑战,尤其是在资源受限的环境中。模型压缩已成为应对这些挑战的关键研究领域。本文对大语言模…...
利用shardingsphere-proxy对mysql分片
本文介绍利用shardingsphere-proxy分库分表的配置过程。shardingsphere-proxy是一个中间件,启动后会模拟成一个实际的mysql服务,我们可以通过可视化工具或jdbc操作,实际执行的sql会通过shardingsphere-proxy转换,进而在具体的mysq…...
AI智能体与大语言模型:重塑SaaS系统的未来航向
在数字化转型的浪潮中,软件即服务(SaaS)系统一直是企业提升效率、优化业务流程的重要工具。随着AI智能体和大语言模型(LLMs)的迅速发展,SaaS系统正迎来前所未有的变革契机。本文将从AI智能体和大语言模型对…...
mapbox基础,使用geojson加载heatmap热力图层
👨⚕️ 主页: gis分享者 👨⚕️ 感谢各位大佬 点赞👍 收藏⭐ 留言📝 加关注✅! 👨⚕️ 收录于专栏:mapbox 从入门到精通 文章目录 一、🍀前言1.1 ☘️mapboxgl.Map 地图对象1.2 ☘️mapboxgl.Map style属性1.3 ☘️heatmap热力图层样式二、🍀使用geojs…...
python环境打包2 pytorch和cuda的安装逻辑
基本逻辑 理一理安装pytorch的一列逻辑,以及他的依赖。(看完这小节再实践) 配置pytorch,安装步骤为:显卡驱动-->python-->cuda--->pytorch。 pytorch是依赖conda的,conda是依赖python的。 &am…...
hot100-矩阵
240.搜索二维矩阵② 编写一个高效的算法来搜索 mxn 矩阵 matrix 中的一个目标值 target 。该矩阵具有以下特性: 每行的元素从左到右升序排列。 每列的元素从上到下升序排列。 思路: 输入矩阵: 从标准输入读取矩阵的行数 n 和列数 m。 按…...
扩散模型基本概念
1. 核心思想 从最原始的DDPM来讲,扩散模型是用变分估计训练的马尔可夫链,相当于VAE+流模型。与标准化流相比,扩散模型的正向过程为预先定义的加噪过程,负责将图像 x ∼ p ( x ) x\sim{p(x)} x∼...
【Python 入门基础】—— 人工智能“超级引擎”,AI界的“瑞士军刀”,
欢迎来到ZyyOvO的博客✨,一个关于探索技术的角落,记录学习的点滴📖,分享实用的技巧🛠️,偶尔还有一些奇思妙想💡 本文由ZyyOvO原创✍️,感谢支持❤️!请尊重原创…...
网络协议 HTTP、HTTPS、HTTP/1.1、HTTP/2 对比分析
1. 基本定义 HTTP(HyperText Transfer Protocol) 应用层协议,用于客户端与服务器之间的数据传输(默认端口 80)。 HTTP/1.0:早期版本,每个请求需单独建立 TCP 连接,效率低。HTTP/1.1&…...
Mysql COUNT() 函数详解
简介 COUNT()函数定义 COUNT()函数是SQL中常用的 聚合函数 ,用于统计满足特定条件的记录数。它可以灵活地应用于各种查询场景,帮助用户快速获取所需的数据统计信息。该函数不仅能够计算所有行的数量,还能针对特定列进行计数,并支…...
Redis缓存一致性难题:如何让数据库和缓存不“打架”?
标题:Redis缓存一致性难题:如何让数据库和缓存不“打架”?(附程序员脱发指南) 导言:当数据库和缓存成了“异地恋” 想象一下:你刚在美团下单了一份麻辣小龙虾,付款后刷新页面&#…...
WIn32 笔记:本专栏课件
专栏导航 上一篇:在VS2019里面,调整代码字体大小 回到目录 下一篇:计算机基础:二进制基础01,比特与字节 本节前言 在之前的讲解里面,我讲解了 Visual Studio 软件的一些个基础操作步骤。从本节开始&am…...
设置同一个局域网内远程桌面Ubuntu
1、安装xrdp: 打开终端,运行以下命令来安装xrdp: sudo apt update sudo apt install xrdp 2、启动 XRDP 并设置开机自启 sudo systemctl start xrdp sudo systemctl enable xrdp 3、验证 XRDP 运行状态 sudo systemctl status xrdp 如果显示 active (ru…...
Spring Boot 自定义 Starter 完整实战手册
Spring Boot 自定义 Starter 完整实战手册 一、核心概念与项目结构 1. 什么是 Starter? 本质:预配置模块 依赖集合 自动配置类 默认实现核心价值: 统一技术栈:团队快速复用标准组件简化配置:隐藏复杂实现细节&…...
C++ 红黑树万字详解(含模拟实现(两种版本))
目录 红黑树的概念 红黑树的性质 红黑树的删除 红黑树与AVL树的比较 红黑树的应用 红黑树的模拟实现 红黑树的概念 红黑树,是一种二叉搜索树,但在每个结点上增加一个存储位表示结点的颜色,可以是Red或Black。 通过对任何一条从根到叶…...
使用 Spring Boot 和 Keycloak 的 OAuth2 快速指南
1. 概述 本教程是关于使用 Spring Boot 和 Keycloak 通过 OAuth2 配置后端的。 我们将使用 Keycloak 作为 OpenID 提供程序。我们可以将其视为负责身份验证和用户数据(角色、配置文件、联系信息等)的用户服务。它是最完整的 OpenID Connect ࿰…...
4个小时开发DeepSeek+baiduNaotu一键生成思维导图
一、引言 最近发现AI生成思维导图的解决方案普遍存在两个断层:用户需手动复制模型输出的JSON数据到脑图软件,且缺乏实时可视化反馈。基于日常使用的BaiduNaotu框架(其轻量级架构与简洁的UI设计已满足基础需求),我决定…...
DeepSeek 开源狂欢周(一)FlashMLA:高效推理加速新时代
上周末,DeepSeek在X平台(Twitter)宣布将开启连续一周的开源,整个开源社区为之沸腾,全球AI爱好者纷纷为关注。没错,这是一场由DeepSeek引领的开源盛宴,推翻了传统推理加速的种种限制。这周一&…...
视频批量分段工具
参考原文:视频批量分段工具 选择视频文件 当您启动这款视频批量分段工具程序后,有两种便捷的方式来选择要处理的视频文件。其一,您可以点击程序界面中的 “文件” 菜单,在下拉选项里找到 “选择视频文件” 按钮并点击;…...
【OMCI实践】ONT上线过程的omci消息(五)
引言 在前四篇文章中,主要介绍了ONT上线过程的OMCI交互的第一、二、三个阶段omci消息,本篇介绍第四个阶段,OLT下发配置到ONT。前三个阶段,每个厂商OLT和ONT都遵循相同标准,OMCI的交换过程大同小异。但第四个阶段&…...
git从零学起
从事了多年java开发,一直在用svn进行版本控制,如今更换了公司,使用的是git进行版本控制,所以打算记录一下git学习的点滴,和大家一起分享。 百度百科: Git(读音为/gɪt/)是一个开源…...
服务器间迁移conda环境
注意:可使用迁移miniconda文件 or 迁移yaml文件两种方式,推荐前者,基本无bug! 一、迁移miniconda文件: 拷贝旧机器的miniconda文件文件到新机器: 内网拷贝:scp -r mazhf192.168.1.233:~/miniconda3 ~/ 外…...
计算机毕业设计SpringBoot+Vue.js精准扶贫管理系统(源码+文档+PPT+讲解)
温馨提示:文末有 CSDN 平台官方提供的学长联系方式的名片! 温馨提示:文末有 CSDN 平台官方提供的学长联系方式的名片! 温馨提示:文末有 CSDN 平台官方提供的学长联系方式的名片! 作者简介:Java领…...
[RH342]tcpdump
[RH342]tcpdump 1. 题目2. 解题 1. 题目 服务器serverc 和 servera 之间有进程定期发送一个明文密码,找出它2. 解题 找出通信端口 抓包分析 tcpdump -X -vv port 6644红框中就是密码,所以密码是root123...
LeetCode-Hot100-001两数之和
给出个人解答,不懂的可以在评论区问 代码 使用的手写的hash函数 class Hash{ public:static const int MAXN 10007;int num;struct Data{int key;int v; int nxt;};vector<Data> data;vector<int> head;Hash(): num(0), data(3*MAXN), head(3*MAXN)…...
(2.26 “详细分析示例“ 暴力+位运算 最长优雅子数组)leetcode 2401
a&b0说明a和b的每一位都是一个0和一个1 不存在两个均为1的位次 a|0a 0与任何数|都等于它本身 (mask)的作用: 担心两数的1在用一位导致mask覆盖了? 答:出现这种情况说明mask与nums j后就直接break 由:…...
【Go】十六、protobuf构建基础服务信息、grpc服务启动的基础信息
商品服务 服务结构 创建 goods 服务,将之前 user 服务的基本结构迁移到 goods 服务上,完整目录是: mxshop_srvs user_srv … tmp … goods_srv config config.go 配置的读取表 global global.go 数据库、日志初始化、全局变量定义 handler …...