JWT单双token实现机制记录
JWT单token实现机制记录
JWT双token实现机制记录
- 基于的jwt版本:
<!-- JWT依赖 --> <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>${jjwt.version}</version> </dependency>
- 网关(spring cloud gateway)某过滤器JwtAuthFilter(专门用于移动端请求的过滤)中过滤请求头是否含Authorization: Bearar token,无则放行(return chain.filter(exchange);)有则进入此过滤器,往exchange中添加一个标记exchange.getAttributes().put(SKIP_FILTER, true);,用于跳过后续的用于CAS登录过滤的CasFilter和用于CAS的ST认证的MyCas30ProxyTicketValidationFilter(因为移动端登录采用JWT实现不再是CAS)
-
public static final String SKIP_FILTER = "SKIP_CasFilter_MyCas30ProxyTicketValidationFilter";String tokenWithSchema = exchange.getRequest().getHeaders().getFirst(AuthConstants.HEADER_AUTHORIZATION); // 移动端请求 if (StringUtils.hasLength(tokenWithSchema)) {// 设置跳过CasFilter和MyCas30ProxyTicketValidationFilterexchange.getAttributes().put(SKIP_FILTER, true);ServerHttpRequest request = exchange.getRequest(); 。。。。。。 }
-
- 在CasFilter和MyCas30ProxyTicketValidationFilter的头部就检查是否跳过此过滤器
-
// 检查是否跳过当前过滤器 if (exchange.getAttribute(JwtAuthFilter.SKIP_FILTER) != null) { return chain.filter(exchange); // 直接跳过处理 }
-
- 回到JwtAuthFilter,紧接着在JwtAuthFilter中,根据URL校验是否是登出请求(携带双token,即access token 和 refresh token),删除redis中存储的双token,完成登出。
- 是否是登录白名单请求,是则放行,不做登录校验了。
-
// 放行登录白名单请求 if (isLoginWhiteListUrl(CasFilter.getRequestUri(request))) {return chain.filter(exchange); }public static String getRequestUri(ServerHttpRequest request) {StringBuffer urlBuffer = new StringBuffer(request.getURI().toString());if (request.getURI().getQuery() != null) {urlBuffer.append("?").append(request.getURI().getQuery());}return urlBuffer.toString(); }@Value("${cas.white-url}") private String loginWhiteList;/*** 是否登录白名单请求** @param url* @return*/ public boolean isLoginWhiteListUrl(String url) {if (StringUtils.hasLength(this.loginWhiteList)) {Pattern pattern = Pattern.compile(this.loginWhiteList);return pattern.matcher(url).matches();} else {return false;} }
-
- 从请求头获取access token,如果access token传输格式不正确/未携带access token,重定向到移动端登录页面。
-
log.error("access token传输格式不正确/未携带access token:{},重定向到移动端登录页面", tokenWithSchema); return redirectMobileEndLoginPage(exchange);@Value("${access-control.mobile-end-login-url}")private String mobileEndLoginUrl;//伪代码,也可以是别的名称,这里取的是和CAS一样的名称。Protocol.CAS3.getServiceParameterName() = "service"/*** 重定向到移动端登录页面(并在登录页面url后拼接service参数)*/public Mono<Void> redirectMobileEndLoginPage(ServerWebExchange exchange) {ServerHttpRequest request = exchange.getRequest();// 根据URI构建出访问移动端登录页面地址要携带的service(service即登录成功后要重定向到的原访问地址)String serviceUrl = GatewayCommonUtils.buildCasServiceUrl(request);String urlToRedirectTo = GatewayCommonUtils.constructRedirectUrl(this.mobileEndLoginUrl, Protocol.CAS3.getServiceParameterName() , serviceUrl);// 判断请求类型String xRequested = request.getHeaders().getFirst("x-requested-with");if ("XMLHttpRequest".equals(xRequested)) {// AJAX请求,返回携带service的CAS登录地址(在前端通过window.document.location手动重定向,以解决ajax请求不能自动重定向)// vue-resource、axios、Ajax、jQuery的$.get/$.post都是对xhr(XMLHttpRequest)的封装Map<String, Object> resultBody = new HashMap<>(2);resultBody.put("code", HttpStatus.SEE_OTHER.value());resultBody.put("url", urlToRedirectTo);// 响应状态代码同样返回303 保证部分前端框架能从json获取异常状态return GatewayCommonUtils.getVoidMono(exchange, HttpStatus.SEE_OTHER, resultBody);} else {// 浏览器请求,直接重定向return GatewayCommonUtils.redirect(exchange, urlToRedirectTo);}}/*** 重定向** @param exchange web交换* @param urlToRedirectTo 重定向地址* @return*/public static Mono<Void> redirect(ServerWebExchange exchange, String urlToRedirectTo) {return GatewayCommonUtils.redirect(exchange.getResponse(), urlToRedirectTo);}public static Mono<Void> redirect(ServerHttpResponse response, String urlToRedirectTo) {response.setStatusCode(HttpStatus.SEE_OTHER);response.getHeaders().set("Location", urlToRedirectTo);return response.setComplete();}public static Mono<Void> getVoidMono(ServerWebExchange serverWebExchange, HttpStatus status, Map<String, Object> body) {serverWebExchange.getResponse().setStatusCode(status);byte[] bytes = JSONObject.toJSONString(body).getBytes(StandardCharsets.UTF_8);DataBuffer buffer = serverWebExchange.getResponse().bufferFactory().wrap(bytes);return serverWebExchange.getResponse().writeWith(Flux.just(buffer));}
-
- 若access token正常传递,则验证token:Claims claims = jwtUtil.verifyAccessToken(accessToken);
- 若抛出ExpiredJwtException说明token过期,设置401状态码并以报文提示客户端以客户端本地保存的refresh token请求【刷新(即重新生成)access token的/refreshToken接口"】,同时构建客户端请求refreshToken接口时需要携带的service参数(目的:在refreshToken接口中若校验出refreshToken过期/refreshToken无效/refreshToken在redis不存在需要重定向到登录页时提供登录接口要求携带的service参数)并把service(service即登录成功后要重定向到的原访问地址)传递给客户端。
-
log.info("过期access token:{},提示客户端使用refresh token刷新(重新生成)access token", accessToken); Map<String, Object> resultBody = new HashMap<>(2); //返回json结构尽量和包装类Result保持一致 resultBody.put("code", ErrorCode.A0311.code()); resultBody.put("message", "The access token has expired. Please use the refresh token saved by the client and the service parameter in the message to refresh the access token."); //构建客户端请求refreshToken接口时需要携带的service参数(目的:在refreshToken接口中若校验出refreshToken过期/refreshToken无效/refreshToken在redis不存在需要重定向到登录页时提供登录接口要求携带的service参数) String serviceUrl = GatewayCommonUtils.buildCasServiceUrl(request); resultBody.put("service", serviceUrl); return GatewayCommonUtils.getVoidMono(exchange, HttpStatus.UNAUTHORIZED, resultBody);ErrorCode.A0311("A0311", "授权已过期")
- 客户端收到过期报文后在响应拦截器中以客户端本地保存的refresh token请求【刷新(即重新生成)access token的/refreshToken接口"】。逻辑类似如下:(下图未处理/refreshToken接口中由于refresh token过期/refresh token无效/redis无对应refresh token存在而要求客户端重定向到移动端登录页面的情况,实际开发中应按/refreshToken接口返回的提示报文补全相关代码)
- /refreshToken接口(重新生成access token和refresh token)逻辑:
@PostMapping("/refreshToken")@ResponseBodypublic Result refreshAccessTokenByRefreshToken(@RequestBody Map<String, String> param, HttpServletRequest request, HttpServletResponse response, HttpSession session) {String refreshToken = param.get("refreshToken");String service = param.get("service");Assert.notNull(refreshToken, "service不能为空");log.info("/auth/login/refreshToken service is {}", service);// service = https://192.168.3.214/api/plm-system/aia-portal-svc/user/site?dev_server_host=https://192.168.3.214&back_to=https://192.168.3.214/plm-system/portal/indexAssert.state(service.split("/").length > 4 && service.matches("^\\S+/api/[^\\s/]+/\\S+$"), "service值不符合接口预期");try {Claims claims = jwtUtil.verifyRefreshToken(refreshToken);UserBO redis2user = redisLoginUserService.getLoginUserInfoForToken(refreshToken, claims.get("userId").toString(), service);if(redis2user != null) {String newAccessToken = jwtUtil.generateAccessToken(claims);String newRefreshToken = jwtUtil.generateRefreshToken(claims);// 存储accessToken和用户信息到redisredisLoginUserService.setLoginUserInfoForToken(redis2user, newAccessToken, service, accessExpiration, redis2user.getId());// 存储refreshToken和用户信息到redisredisLoginUserService.setLoginUserInfoForToken(redis2user, newRefreshToken, service, refreshExpiration, redis2user.getId());// 刷新成功,返回新的access token 和 refresh token给客户端Map<String, String> map = new HashMap<>(2);map.put("newAccessToken", newAccessToken);map.put("newRefreshToken", newRefreshToken);// 后续客户端会在响应拦截器中携带新的access token重试原请求(客户端在响应拦截器中可以获取原请求地址,服务端无须传递)return Result.makeOKDataRsp("刷新成功,请将报文中的双token保存到本地并携带新的access token重试原请求,以实现无感刷新", map);}} catch (ExpiredJwtException e) {log.error("/auth/login/refreshToken refreshToken:{} expired", refreshToken);} catch (Exception e) {log.error("/auth/login/refreshToken refreshToken:{} invalid:", refreshToken, e);}// 刷新失败,通知客户端重定向移动端登录页面(接着传递service参数<--/refreshToken接口<--响应拦截器<--网关JwtAuthFilter检测到过期access token时传递来的service参数)return redirectMobileEndLoginPage(request, response, service);}
入参中的service取自网关检测到token过期后,返回的401状态码的报文中的service参数,入参中的refresh token是保存到
-