JWT单双token实现机制记录
一、JWT单token实现机制记录
- token校验抛了过期以外的其它异常,重定向登录页面
- token校验抛了过期异常
- redis无此token,重定向登录页面
- 此种情况可能存在不符合预期地重定向登录页面的情况:联系2.2/4.2中所述-->若30s过渡期后客户端仍未成功设置新token,redis旧token键由于30s过渡期已过也已被删除,此时客户端再次拿过期旧token/有效期小于三分之一的旧token访问-->导致走到2.1/4.1这一步,造成不符预期地重定向登录页面(出现概率小且出现后问题不大,可以忽略)
- 或者放弃过渡期策略,转而采取新token到达服务端后再直接删除redis旧token键的策略:这需在redis维护新旧token过渡期间新旧token的关系。
- 若客户端一直未将新token设置成功,一直使用过期旧token/有效期小于三分之一的旧token访问,会造成两个问题:
- redis旧token过期,重定向登录页面(可见也会造成不符合预期地重定向登录页面的情况)
- redis旧token未过期,重走[生成新token-->在redis维护新旧token过渡期间新旧token的关系-->返回新token给客户端-->新token到达服务端后再直接删除redis旧token键]的逻辑(造成redis产生冗余键的问题)
- 若客户端一直未将新token设置成功,一直使用过期旧token/有效期小于三分之一的旧token访问,会造成两个问题:
- 综上所述,建议采用设置过渡期的原策略
- redis有此token
- token键的值无"old:true"属性,生成新token(有效期1小时)返回给客户端并设置新token到redis(有效期15天),并且设置redis旧token键30s过期(客户端切换新旧token的过渡期),且旧token键的值添加"old:true"属性作为标记
- token键的值有"old:true"属性,说明新token已经生成只是客户端未来得及设置并且旧token尚在30s有效期,不用再生成新token,认证通过不做处理
- redis无此token,重定向登录页面
- token有效期大于三分之一(或者1/4,1/5,1/6)且校验通过
- redis有此token,认证通过不做处理
- redis无此token,重定向登录页面
- token有效期小于三分之一(或者1/4,1/5,1/6)且校验通过
- redis无此token,重定向登录页面
- 此种情况可能存在不符合预期地重定向登录页面的情况:联系2.2/4.2中所述-->若30s过渡期后客户端仍未成功设置新token,redis旧token键由于30s过渡期已过也已被删除,此时客户端再次拿过期旧token/有效期小于三分之一的旧token访问-->导致走到2.1/4.1这一步,造成不符预期地重定向登录页面(出现概率小且出现后问题不大,可以忽略)
- 或者放弃过渡期策略,转而采取新token到达服务端后再直接删除redis旧token键的策略:这需在redis维护新旧token过渡期间新旧token的关系。
- 若客户端一直未将新token设置成功,一直使用过期旧token/有效期小于三分之一的旧token访问,会造成两个问题:
- redis旧token过期,重定向登录页面(可见也会造成不符合预期地重定向登录页面的情况)
- redis旧token未过期,重走[生成新token-->在redis维护新旧token过渡期间新旧token的关系-->返回新token给客户端-->新token到达服务端后再直接删除redis旧token键]的逻辑(造成redis产生冗余键的问题)
- 若客户端一直未将新token设置成功,一直使用过期旧token/有效期小于三分之一的旧token访问,会造成两个问题:
- 综上所述,建议采用设置过渡期的原策略
- redis有此token
- token键的值无"old:true"属性,生成新token(有效期1小时)返回给客户端并设置新token到redis(有效期15天),并且设置redis旧token键30s过期(客户端切换新旧token的过渡期),且旧token键的值添加"old:true"属性作为标记
- token键的值有"old:true"属性,说明新token已经生成只是客户端未来得及设置并且旧token尚在30s有效期,不用再生成新token,认证通过不做处理
- redis无此token,重定向登录页面
二、JWT双token实现机制记录
1.机制
- 其它前置处理:
-
是否登出token请求,处理token的登出即删除token
-
放行登录白名单请求,不用做token认证
-
access token传输格式不正确/未携带access token,重定向登录页面
-
log.error("access token传输格式不正确/未携带access token:{},重定向到移动端登录页面", tokenWithSchema); return redirectMobileEndLoginPage(exchange);
-
-
- access token校验抛了过期以外的其它异常,重定向登录页面
- access token过期,若校验access token时抛出ExpiredJwtException(不同jwt依赖包该异常名称可能不同)说明token过期
- 设置401-UNAUTHORIZED状态码并以报文提示客户端以客户端本地保存的refresh token请求"刷新(即重新生成)access token的/refreshToken接口(后面7.会介绍)",同时构建客户端请求refreshToken接口时需要携带的service参数(目的:在refreshToken接口中若校验出refreshToken过期/refreshToken无效/refreshToken在redis不存在需要重定向到登录页时提供登录接口要求携带的service参数)并把service(service即登录成功后要重定向到的原访问地址)传递给客户端。//【构建出来的serviceUrl类似: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/index】
-
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);//【这个buildCasServiceUrl(request)方法的具体实现参考意义不大,略】// 构建客户端请求refreshToken接口时需要携带的service参数(目的:在refreshToken接口中若校验出 refreshToken过期/refreshToken无效/refreshToken在redis不存在需要重定向到登录页时提供登录接口要求携带的service参数)String serviceUrl = GatewayCommonUtils.buildCasServiceUrl(request);//【构建出来的serviceUrl类似: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/index】
-
- 设置401-UNAUTHORIZED状态码并以报文提示客户端以客户端本地保存的refresh token请求"刷新(即重新生成)access token的/refreshToken接口(后面7.会介绍)",同时构建客户端请求refreshToken接口时需要携带的service参数(目的:在refreshToken接口中若校验出refreshToken过期/refreshToken无效/refreshToken在redis不存在需要重定向到登录页时提供登录接口要求携带的service参数)并把service(service即登录成功后要重定向到的原访问地址)传递给客户端。//【构建出来的serviceUrl类似: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/index】
- access token无效,即校验access token抛出了官方提供的除ExpiredJwtException(不同jwt依赖包该异常名称可能不同)以外的其它官方异常,如// throws UnsupportedJwtException, MalformedJwtException, SignatureException, IllegalArgumentException,重定向登录页面(需根据请求路径以及请求头的referer即请求来源页面等信息构建登录成功后需跳转的原访问地址,并拼接到登录页面地址后,后续6.中会讲到)
-
log.error("无效access token:{},重定向到移动端登录页面,无效原因:", accessToken, e); return redirectMobileEndLoginPage(exchange);
-
-
access token校验通过,还要确保此token在redis存在,双重校验 UserBO redis2User = redisLoginUserService.getLoginUserInfoForToken(accessToken, claims.get("userId").toString());
- redis不存在,重定向登录页面(需根据请求路径以及请求头的referer即请求来源页面等信息构建登录成功后需跳转的原访问地址,并拼接到登录页面地址后,后续6.中会讲到)
-
if (redis2User == null) { //当accessToken有效但Redis无记录时,表明该Token已被服务端主动失效(如用户主动登出、账号异常或管理员强制下线),应强制用户重新登录 log.error("access token:{}有效,但redis无对应存在,重定向到移动端登录页面", accessToken); return redirectMobileEndLoginPage(exchange); }
-
- redis存在,双重校验通过,处理其它业务逻辑
-
// 双重校验通过,开始鉴权 return CasFilter.doAuthorization(redis2User, exchange, chain);
-
- redis不存在,重定向登录页面(需根据请求路径以及请求头的referer即请求来源页面等信息构建登录成功后需跳转的原访问地址,并拼接到登录页面地址后,后续6.中会讲到)
- 补充说明重定向登录页面的大致代码逻辑(基于Cloud gateway)(重定向时对是否Ajax请求做了区分,因为Ajax请求不能自动重定向,即时是设置了重定向状态码和Location响应头)(根据请求路径以及请求头的referer即请求来源页面等信息构建service参数并拼接在登录页面地址后回传给登录页面/直接重定向,后续service将作为登录接口的入参值,表示登录成功后需跳转的原始访问地址)//【构建出来的serviceUrl类似: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/index】
-
@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);}}//【这个buildCasServiceUrl(request)方法的具体实现参考意义不大,略】// 根据URI构建出访问移动端登录页面地址要携带的service(service即登录成功后要重定向到的原访问地址)String serviceUrl = GatewayCommonUtils.buildCasServiceUrl(request);//【构建出来的serviceUrl类似: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/index】public static String constructRedirectUrl(String casLoginUrl, String serviceParameterName, String serviceUrl) {return casLoginUrl + (casLoginUrl.contains("?") ? "&" : "?") + serviceParameterName + "=" + serviceUrl;}/*** 重定向** @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();}/*** 只设置状态码和响应内容,未设置Location*(意味着即时是303状态码,由于未设置Location,浏览器不会自动重定向,响应内容也不会被丢弃)** @param exchange web交换* @param urlToRedirectTo 重定向地址* @return*/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));}
-
- /refreshToken接口(根据refreshToken重新生成accessToken和refresh token的接口)
- 接口入参servicec从何而来?/refreshToken接口<--客户端响应拦截器<--服务端检测到access token过期时构建并传递给客户端的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/index】
- 成功刷新access token,返回新access token和新refresh token的场景:
- refresh token校验通过,且redis有对应存在。
- 生成逻辑如下:(生成新refreshToken后,redis旧的refreshToken键不要立马删除,设置一个短有效期作为过渡期,比如30秒:避免旧的refreshToken键立马删除后又来一个携带旧refreshToken的请求,造成不符预期的重登录)(客户端在响应拦截器中可以获取原请求地址重试原请求,服务端无须传递原请求地址)
String newAccessToken = jwtUtil.generateAccessToken(claims); String newRefreshToken = jwtUtil.generateRefreshToken(claims); // 存储accessToken和用户信息到redis redisLoginUserService.setLoginUserInfoForToken(redis2user, newAccessToken, service, accessExpiration, redis2user.getId()); // 存储refreshToken和用户信息到redis redisLoginUserService.setLoginUserInfoForToken(redis2user, newRefreshToken, service, refreshExpiration, redis2user.getId()); // 生成新refreshToken后,redis旧的键不要立马删除,设置一个短有效期作为过渡期,比如30秒 redisLoginUserService.setLoginUserInfoForToken(redis2user, refreshToken, service, 30, 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);
- 刷新失败,重定向登录页面(将入参service拼接在登录页面地址后回传给登录页面/直接重定向,后续7.4.1中会给出具体代码)的场景:
- refreshToken过期或者无效,即jwtUtil.verifyRefreshToken(refreshToken);抛出异常。
- refreshToken未过期,但是redis无对应存在。
- 接口代码如下(基于Spring boot)(重定向时同样对是否Ajax请求做了区分因为Ajax请求不能自动重定向,即时是设置了重定向状态码和Location响应头)(将入参service拼接在登录页面地址后回传给登录页面/直接重定向,后续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/index】
-
@Value("${plm.module.auth.udf.jwt.access.expiration}") private long accessExpiration; @Value("${plm.module.auth.udf.jwt.refresh.expiration}") private long refreshExpiration; @Value("${plm.module.auth.udf.mobile-end-login-url}") private String mobileEndLoginUrl; @Autowired private JwtUtil jwtUtil; @Autowired private RedisLoginUserService redisLoginUserService;@PostMapping("/refreshToken") @ResponseBody public 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());// 生成新refreshToken后,redis旧的键不要立马删除,可以设置一个短有效期作为过渡期,比如30秒redisLoginUserService.setLoginUserInfoForToken(redis2user, refreshToken, service, 30, 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); }/*** 重定向到移动端登录页面(并在登录页面url后拼接service参数)* @param request* @param response* @param 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/index*/ public Result redirectMobileEndLoginPage(HttpServletRequest request, HttpServletResponse response, String serviceUrl) {// 构建拼接service参数的移动端登录页面地址String urlToRedirectTo = mobileEndLoginUrl + (mobileEndLoginUrl.contains("?") ? "&" : "?") + "service=" + serviceUrl;// 判断请求类型String xRequested = request.getHeader("x-requested-with");if ("XMLHttpRequest".equals(xRequested)) {// AJAX请求,返回携带service的登录地址(在前端通过window.document.location手动重定向,以解决<ajax请求不能自动重定向>)// vue-resource、axios、Ajax、jQuery的$.get/$.post都是对xhr(XMLHttpRequest)的封装Map<String, Object> resultBody = new HashMap<>(1);resultBody.put("redirect", urlToRedirectTo);// 响应状态码同样返回303 保证部分前端框架能从json获取异常状态 只设置状态码不设置Location头(避免丢弃原始响应体自动重定向)response.setStatus(HttpStatus.SEE_OTHER.value());return Result.makeErrRsp("刷新失败,请重定向到报文中'redirect'参数指定的URL,即登录页面", resultBody);} else {// 浏览器请求,直接重定向response.setStatus(HttpStatus.SEE_OTHER.value());response.setHeader("Location", urlToRedirectTo);// 实际上makeErrRsp()中的消息会被丢弃,浏览器控制台也看不到return Result.makeErrRsp("刷新失败,非AJAX请求,设置自动重定向<注:ajax请求不能自动重定向>");} }
-
- 登录接口
-
//返回值e.s.: // { // "msg": "success", // "code": 0, // "data": { // "redirect": "https://192.168.3.214/plm-system/portal/index", // "token": { // "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl9jb2RlIjoiaGFoYWhoYTIiLCJpZCI6ImhhaGFoaGExIiwic3ViIjoi56e75Yqo56uv55m75b2V5Yib5bu6QWNjZXNzVG9rZW4iLCJpc3MiOiJhdXRoLW1vZHVsZSIsImlhdCI6MTc0NzY0NTUyMiwiZXhwIjoxNzQ3NjQ5MTIyLCJqdGkiOiJlYzZlMTA3Ni1lNDMzLTRhZDUtOGY4Yy0xYmVlYTdhY2JmMmUifQ.W1b8ddWnvqkXI9E9IdJGxiGxbhKmp0C852eFkkLKW_lZoiyDneQaoQQsjOVBOW4Wc20O6luLM5ihqf28F2X9JQ", // "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl9jb2RlIjoiaGFoYWhoYTIiLCJpZCI6ImhhaGFoaGExIiwic3ViIjoi56e75Yqo56uv55m75b2V5Yib5bu6UmVmcmVzaFRva2VuIiwiaXNzIjoiYXV0aC1tb2R1bGUiLCJpYXQiOjE3NDc2NDU1MjIsImV4cCI6MTc0ODk0MTUyMiwianRpIjoiZDk2ZWFjMDMtZmUxYy00M2NiLTgwZDUtYzg4NDM4MWViYTE3In0.o1RJoRG_8xkQmlTWUq0NUl1oLkwnbRQi4cjDYnxUv-sJNN2c-DOEGUcy9whjpZhLCgOuDygrthecnpPihfbU9A" // } // } // }@PostMapping("/doMobileLogin") @ResponseBody public Result doMobileLogin(@RequestBody Map<String, String> param, HttpServletRequest request, HttpServletResponse response, HttpSession session) {String username = param.get("username");String password = param.get("password");String service = param.get("service");Assert.notNull(username, "username不能为空");Assert.notNull(password, "password不能为空");Assert.notNull(service, "service不能为空");log.info("/auth/login/doMobileLogin 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 {Result result = loginService.handleLogin(username, password, request, session, true, service);// 登录出错if(result.getCode() == 1){return result;} else {// 登录成功// 解析出登录成功后将要重定向到的原始访问地址返回给客户端,让客户端发起重定向String redirectUrl = loginService.getRedirectUrl(service);Map dataMap = new HashMap(2);dataMap.put("token", result.getData());dataMap.put("redirect", redirectUrl);result.setData(dataMap);// 只设置状态码 不设置Location头(避免丢弃原始响应体自动重定向)response.setStatus(HttpStatus.SEE_OTHER.value());/*// 当服务器返回HTTP 303 See Other状态码并设置Location头时,浏览器接收到303响应后,会丢弃原始响应体,// 直接向Location指向的URL发起GET请求,导致客户端JavaScript无法直接获取原始响应体中的token参数response.setHeader("Location", redirectUrl);*/return result;}} catch (Exception e) {log.error("/auth/login/doMobileLogin System Exception:", e);return Result.makeErrRsp("System Exception");} }
-
登录接口中Result result = loginService.handleLogin(username, password, request, session, true, service);这一行的代码实现,重点关注尾部:
redisLoginUserService.setLoginUserInfoForToken(user2Redis, accessToken, service, accessExpiration, user.getId());时传递service的目的仅仅是为了在设置token到redis时从service中解析出来k8s的namespace,作为键名的一部分;user.getId()也是拼接到键名中,accessToken也作为键名一部分,user2Redis作为键的值。
public Result handleLogin(String username, String password, HttpServletRequest request, HttpSession session, boolean isMobile, String service) {// 校验账号是否锁定String redisKey = IS_LOCK + username;Object object = redisTools.get(redisKey);JSONObject val = null;if (object != null) {val = JSONObject.parseObject(object.toString());Boolean isLock = val.getBoolean("isLock");if (isLock != null && isLock) {// data参数设为true,提示前端进行滑块验证return Result.makeErrRsp("您已连续输错密码" + allowNumOfLogin + "次,账号已锁定请稍后再试",true);}}String clientIp = IpUtil.getIpAddr(request);// 私钥解密try {password = decrypt(password, clientIp, username);} catch (Exception e) {return Result.makeErrRsp(e.getMessage());}//region 校验用户名密码是否匹配User user = userAuthController.verifyUserByPwd(username, password);if (user == null) {int failedLoginNum = 1;if (val == null) {val = new JSONObject();val.put("failedLoginNum", failedLoginNum);} else {failedLoginNum = val.getInteger("failedLoginNum");failedLoginNum++;val.put("failedLoginNum", failedLoginNum);}if (failedLoginNum >= allowNumOfLogin) {// 试错机会用光val.put("isLock", true);redisTools.set(redisKey, JSONObject.toJSONString(val), accountLockDuration * 60);log.warn("Account [" + username + "] has entered the wrong password [" + allowNumOfLogin + "] times in a row, and it has been locked. Please try again later", clientIp, username, "0");// data参数设为true,提示前端进行滑块验证return Result.makeErrRsp("您已连续输错密码" + allowNumOfLogin + "次,账号已锁定请稍后再试", true);} else {// 还有试错机会val.put("isLock", false);redisTools.set(redisKey, JSONObject.toJSONString(val), accountLockDuration * 60);int i = allowNumOfLogin - failedLoginNum;log.warn("The username [" + username + "] and password [" + password + "] do not match, you can retry [" + i + "] more times", clientIp, username, "0");// 连续两次登录失败,data参数设为true,提示前端后续登录需要进行滑块验证if (failedLoginNum >= 2) {return Result.makeErrRsp("用户名和密码不匹配,还可以重试" + i + "次", true);} // 前两次登录失败,前端不需要进行滑块验证else {return Result.makeErrRsp("用户名和密码不匹配,还可以重试" + i + "次");}}}//endregion 校验用户名密码是否匹配// 校验账号是否禁用 0未禁用1禁用int status = user.getStatus() == null ? 1 : user.getStatus();if (status == 0) {log.warn("Account [" + username + "] has been disabled, please contact the system administrator to regain access permissions", clientIp, username, "0");return Result.makeErrRsp("账号已禁用,请联系系统管理员重新获得访问权限");}// 校验通过-删除redis中的IS_LOCK键(如果存在)if (redisTools.hasKey(redisKey)) {redisTools.del(redisKey);}// 校验通过-更新db登录时间登录ip等用户信息 todo// RPC查询用户角色、关于服务的权限信息Map<String, Object> servicePermissions = new HashMap<>();User user2Redis = saveUserRoleResourcePermission(user, servicePermissions);if (!isMobile) { // 处理设备端登录// 保存Jsessionid和用户信息到redis,过期(不要大于cas的timeToKillInSeconds配置项)String authId = session.getId();redisLoginUserService.setLoginUserInfoForAuthId(user2Redis, authId);// 存储用户的服务权限列表信息,过期redisLoginUserService.hmset(authId, servicePermissions);return Result.makeOKRsp();} else { // 处理移动端登录// 生成access token 和 refresh tokenMap<String, Object> claimsMap = new HashMap<>(3);claimsMap.put("userId", user.getId());claimsMap.put("userName", user.getLoginCode());claimsMap.put("loginIp", clientIp); //loginIp将来对强制登出或有用处String accessToken = jwtUtil.generateAccessToken(claimsMap);String refreshToken = jwtUtil.generateRefreshToken(claimsMap);// 存储accessToken和用户信息到redisredisLoginUserService.setLoginUserInfoForToken(user2Redis, accessToken, service, accessExpiration, user.getId());// 存储refreshToken和用户信息到redisredisLoginUserService.setLoginUserInfoForToken(user2Redis, refreshToken, service, refreshExpiration, user.getId());// 返回access token 和 refresh token到客户端Map<String, String> map = new HashMap<>(2);map.put("accessToken", accessToken);map.put("refreshToken", refreshToken);return Result.makeOKDataRsp("登录成功,请将报文中的双token保存到本地并在后续请求中携带access token,然后重定向到报文中'redirect'参数指定的URL,即登录前的原始访问页面", map);} }
- 登录接口中// 解析出登录成功后将要重定向到的原始访问地址返回给客户端,让客户端发起重定向String redirectUrl = loginService.getRedirectUrl(service);这一行的代码实现(无需关注代码细节,每个项目的拼接结构都不一样导致解析方式也不一样):
(为什么要解析? 网关传递来的service参数即登录接口接收的servcice参数,e.s. 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/index,并非就是最终版本的登录成功后需要跳转的原始访问页面的地址: ① 若service参数值拼接有back_to和,说明是ajax请求,因为https://192.168.3.214/api/plm-system/aia-portal-svc/user/site可能是接口地址而非页面地址,所以需要基于service参数值后面拼接的back_to值即记录的ajax请求来源页面的地址,作为最终版本的登录成功后要重定向的地址。 ② 若无back_to,因为https://192.168.3.214/api/plm-system/aia-portal-svc/user/site中的192.168.3.214可能是本地调试时被Vue webPack devServer代理后的地址(移动端JWT登录中该service始终作为参数传递未被作为url被请求,所以不会被代理,所以实际不用担心这一点,而CAS登录成功后验证ST时service会被作为url请求导致又被代理一次需要特殊处理:用service参数值后面拼接的dev_server_host值即真实的被代理前的原访问host(取自referer)来代替192.168.3.214,(由于本例的dev_server_host值和192.168.3.214同,说明未被代理过,不用替换)。 )
/*** 获取登录成功后需要跳转到的地址** @param 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/index*/ public String getRedirectUrl(String service) {Map<String, String> serviceAsUrlParamMap = new LinkedHashMap<>();try {serviceAsUrlParamMap = extractGetParams(service);} catch (Exception e) {log.error("/login method >> [extractGetParams(service)] execute exception, service is {}.", service, e);}// 有back_to是ajax请求,back_to的值是发起ajax请求的所在页面地址String back_to = serviceAsUrlParamMap.get("back_to");if (StringUtils.hasLength(back_to)) {return back_to;}// 前端开发人员通过webpack的devServer代理到达网关的服务时String dev_server_host = serviceAsUrlParamMap.get("dev_server_host");if (StringUtils.hasLength(dev_server_host)) {//替换URL前缀try {// path = /api/plm-system/aia-portal-svc/user/siteString path = extractPath(service);service = dev_server_host + path + "?";} catch (Exception e) {log.error("/login method >> [extractPath(service)] execute exception, service is {}.", service, e);}}String urlWithoutParams = "";int index = service.indexOf("?");if (index == -1) {// 如果url只带了service http://127.0.0.1:32410/login?service=https://192.168.3.214/api/plm-system/aia-portal-svc/user/site// index 将为 -1service = service + "?";urlWithoutParams = service;} else {urlWithoutParams = service.substring(0, index + 1);}/*** https://192.168.3.214/api/plm-system/aia-portal-svc/user/site?*/StringBuilder stringBuilder = new StringBuilder(urlWithoutParams);// 拼接原始参数Set<String> paramsSet = serviceAsUrlParamMap.keySet();for (String key : paramsSet) {stringBuilder.append(key).append("=").append(serviceAsUrlParamMap.get(key)).append("&");}return stringBuilder.substring(0, stringBuilder.length() - 1); }
-
- 用到的JwtUtil工具类(可自行改造)
/*** @author: huo.qw* @createTime: 2025/05/15 下午 03:13* @description:*/import io.jsonwebtoken.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component;import java.util.Base64; import java.util.Date; import java.util.Map; import java.util.UUID;@Component public class JwtUtil {//access token的秘钥和过期时间@Value("${plm.module.auth.udf.jwt.access.secret}")private String accessSecret; // 确保长度至少是64字节(512 bits)@Value("${plm.module.auth.udf.jwt.access.expiration}")private long accessExpiration;//refresh token的秘钥和过期时间@Value("${plm.module.auth.udf.jwt.refresh.secret}")private String refreshSecret; // 确保长度至少是64字节(512 bits)@Value("${plm.module.auth.udf.jwt.refresh.expiration}")private long refreshExpiration;/*** 生成access token** 官方预定义了以下几个payload字段(供选用):* iss (issuer):签发人* exp (expiration time):过期时间* sub (subject):主题* aud (audience):受众* nbf (Not Before):生效时间* iat (Issued At):签发时间* jti (JWT ID):编号* @param claims 自定义字段 //注意!JWT默认不加密,Base64解码后可读,秘密信息不要放在这个部分;但也是可以加密的,生成原始token以后,可以用密钥再加密一次。* @return*/public String generateAccessToken(Map<String, Object> claims) {byte[] encodedKey = Base64.getEncoder().encode(accessSecret.getBytes());String base64EncodedSecretKey = new String(encodedKey); // 将密钥编码为Base64字符串return Jwts.builder().setClaims(claims) // 自定义字段.setSubject("移动端登录创建AccessToken") // 主题.setIssuer("auth-module") // 签发人.setIssuedAt(new Date()) // 签发时间.setExpiration(new Date(System.currentTimeMillis() + accessExpiration * 1000)) //过期时间.setId(UUID.randomUUID().toString()) // 编号;通常是一个随机字符串,可以不设置,但建议设置以增强安全性//.setAudience() // 受众;可以是字符串或字符串集合,可以不设置,但建议设置以限制使用范围:.setAudience("mobile-app") // 或 Arrays.asList("web","mobile")//.setNotBefore() // 生效时间;可以不设置,默认立即生效;如需设置:.setNotBefore(new Date(System.currentTimeMillis() + 1000)) // 1秒后生效.signWith(SignatureAlgorithm.HS512, base64EncodedSecretKey) // 算法和密钥.compact();}/*** 生成refresh token* @param claims* @return*/public String generateRefreshToken(Map<String, Object> claims) {byte[] encodedKey = Base64.getEncoder().encode(refreshSecret.getBytes());String base64EncodedSecretKey = new String(encodedKey); // 将密钥编码为Base64字符串return Jwts.builder().setClaims(claims) // 自定义字段.setSubject("移动端登录创建RefreshToken") // 主题.setIssuer("auth-module") // 签发人.setIssuedAt(new Date()) // 签发时间.setExpiration(new Date(System.currentTimeMillis() + refreshExpiration * 1000)) //过期时间.setId(UUID.randomUUID().toString()) // 编号;通常是一个随机字符串,可以不设置,但建议设置以增强安全性//.setAudience() // 受众;可以是字符串或字符串集合,可以不设置,但建议设置以限制使用范围:.setAudience("mobile-app") // 或 Arrays.asList("web","mobile")//.setNotBefore() // 生效时间;可以不设置,默认立即生效;如需设置:.setNotBefore(new Date(System.currentTimeMillis() + 1000)) // 1秒后生效.signWith(SignatureAlgorithm.HS512, base64EncodedSecretKey) // 算法和密钥(HS512算法相同秘钥不同数据时加密结果必然不同).compact();}/*** 校验access token:校验成功返回Claims即payload部分(包括设置的官方字段和自定义字段),校验失败(如token过期等..)会抛出异常* @param token* @return*/public Claims verifyRefreshToken(String token) throws Exception {byte[] encodedKey = Base64.getEncoder().encode(accessSecret.getBytes());String base64EncodedSecretKey = new String(encodedKey); // 将密钥编码为Base64字符串return Jwts.parser().setSigningKey(base64EncodedSecretKey).parseClaimsJws(token) // throws ExpiredJwtException, UnsupportedJwtException, MalformedJwtException, SignatureException, IllegalArgumentException.getBody();}/*** 校验refresh token:校验成功返回Claims即payload部分(包括设置的官方字段和自定义字段),校验失败(如token过期等..)会抛出异常* @param token* @return*/public Claims verifyAccessToken(String token) throws Exception {byte[] encodedKey = Base64.getEncoder().encode(refreshSecret.getBytes());String base64EncodedSecretKey = new String(encodedKey); // 将密钥编码为Base64字符串return Jwts.parser().setSigningKey(base64EncodedSecretKey).parseClaimsJws(token) // throws ExpiredJwtException, UnsupportedJwtException, MalformedJwtException, SignatureException, IllegalArgumentException.getBody();}/*** access token是否过期* @param token* @return*/public boolean isAccessTokenExpired(String token) {try {// 实际verifyAccessToken(token)方法也可以校验access token是否过期return verifyAccessToken(token).getExpiration().before(new Date());} catch (ExpiredJwtException e) {return true;} catch (Exception e) {// 如果是其他异常,非过期异常,暂时按未过期处理return false;}}/*** refresh token是否过期* @param token* @return*/public boolean isRefreshTokenExpired(String token) {try {// 实际verifyRefreshToken(token)方法也可以校验refresh token是否过期return verifyRefreshToken(token).getExpiration().before(new Date());} catch (ExpiredJwtException e) {return true;} catch (Exception e) {// 如果是其他异常,非过期异常,暂时按未过期处理return false;}}/*** 获取配置的access token时效* @return 单位毫秒*/public Long getAccessTokenMillisExpiration() {return this.accessExpiration * 1000;}/*** 获取配置的refresh token时效* @return 单位毫秒*/public Long getRefreshTokenMillisExpiration() {return this.refreshExpiration * 1000;} }
2.补充说明
- 相比单token机制,双token机制处理流程没有那么繁琐。
- 单token机制不会出现近期有访问,短期未访问,又需要重新登录的情况;双token机制也不会出现近期有访问但是由于短期refresh token过期了,导致又需要重新登录的情况。因为每次重新生成access token(1小时)时也会同步重新生成refresh token(15天),只有access token连续15天未访问过,refresh token才过期,才需要重新登录。
- 单双token机制都是双层校验(token本身的合法校验和redis中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();}/*** 只设置状态码和响应内容,未设置Location(意味着即时是303状态码,由于未设置Location,浏览器不会自动重定向,响应内容也不会被丢弃)** @param exchange web交换* @param urlToRedirectTo 重定向地址* @return*/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是登录成功后保存到客户端的refresh token。
-
其它略,不想写了,重要的基本都记录完了。
-