# SpringBoot-Forum **Repository Path**: janoandbiscuityeah/SpringBoot-Forum ## Basic Information - **Project Name**: SpringBoot-Forum - **Description**: Spring-Boot JWT AOP log等技综合技术总结运用 - **Primary Language**: Unknown - **License**: MulanPSL-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2022-07-16 - **Last Updated**: 2023-07-11 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 项目简介 本项目起初的项目是做一个简单的论坛界面,运用一下SpringMVC + Spring + Mybatis,当我基本的功能增删改查都 完成后又继续加入了分页功能,虽然要求很简单但是我对整体框架的构建费了很大心思。我将从基本的需求开始不断的加入 所学的所有技术进行融合
权限认证
JWT会话:签发,刷新
log日志
aop日志
## 构建:druid数据源配置整合 > SpringBoot数据库连接池的配置,是自己容器中没有DataSource才自动配置的,那么由于SprigBoot有自动装配的机制 > 如果我们不给它指定数据源那么它底层默认的就是HikariDataSource,我想用阿里巴巴的druid数据源里面的监控网站 > 防火墙这些功能,所以我就进行了配置。 ```xml spring: datasource: url: jdbc:mysql://localhost:3306/springboot username: root password: 12345678 driver-class-name: com.mysql.cj.jdbc.Driver ``` ```java druid: aop-patterns:com.Jano.* #监控SpringBean filters:stat,wall # 底层开启功能,stat(sql监控),wall(防火墙) stat-view-servlet: # 配置监控页功能 enabled:true login-username:admin login-password:123 reset-enable:false web-stat-filter: # 监控web enabled:true url-pattern: /* exclusions: '*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*' filter: stat: # 对上面filters里面的stat的详细配置 slow-sql-millis: 1000 log-slow-sql: true enabled: true wall: enabled: true config: drop-table-allow: false ``` ## 构建:Mybatis整合 > 引入相关的依赖,引入成功后
● 全局配置文件
● SqlSessionFactory:自动配置好了
● SqlSession:自动配置了SqlSessionTemplate 组合了SqlSession
● @Import(AutoConfiguredMapperScannerRegistrar.class)
● Mapper: 只要我们写的操作MyBatis的接口标注了@Mapper就会被自动扫描进来
```xml org.mybatis.spring.boot mybatis-spring-boot-starter 2.2.2 com.github.pagehelper pagehelper-spring-boot-starter 1.4.3 ``` **底层会自动装配** ```java @EnableConfigurationProperties(MybatisProperties.class) : MyBatis配置项绑定类。 @AutoConfigureAfter({DataSourceAutoConfiguration.class, MybatisLanguageDriverAutoConfiguration.class}) public class MybatisAutoConfiguration { ... } @ConfigurationProperties(prefix = "mybatis") public class MybatisProperties { ... } ``` ```xml # 配置mybatis规则 mybatis: mapper-locations: classpath:mybatis/mapper/*.xml # 可以不写全局配置文件,所有全局配置文件的配置都放在configuration配置项中了。 # config-location: classpath:mybatis/mybatis-config.xml configuration: map-underscore-to-camel-case: true ``` ## 构建 注册配置类 ```java /** * 一定要加上这个注解,成为Springboot的配置类,不然不会生效 */ @Configuration public class MyInterceptors implements WebMvcConfigurer { @Autowired CrossDomainInterceptor crossDomainInterceptor; @Autowired JwtTokenInterceptor jwtTokenInterceptor; @Autowired PermissionInterceptor permissionInterceptor; /** * 这个方法用来注册拦截器,我们自己写好的拦截器需要通过这里添加注册才能生效 * * @param registry */ @Override public void addInterceptors(InterceptorRegistry registry) { //跨域 registry.addInterceptor(crossDomainInterceptor).addPathPatterns("/**"); //检查是否携带accessToken 现在是除了登陆注册页面 都需要被拦截 后期可以根据情况来改变 registry.addInterceptor(jwtTokenInterceptor) .addPathPatterns("/**") .excludePathPatterns("/user/login.do") .excludePathPatterns("/jwt/refreshToken"); //权限判断拦截器 registry.addInterceptor(permissionInterceptor) .addPathPatterns("/**") .excludePathPatterns("/user/login.do") .excludePathPatterns("/jwt/refreshToken");;; } } ``` ## 遇到抛出的异常 **1.java.lang.IllegalStateException: getWriter() has already been called for this response** > 原因:我在token拦截器使用了Response.getWriter(),未关闭,当通过token拦截器进入权限拦截器不通过时又需要调用Response.getWriter()就会报错 > 在权限拦截中使用完就关闭就可以解决这个问题 **2.java.lang.IllegalStateException: Cannot create a session after the response has been committed** > 原因:我在权限拦截器中出现了错误使用了Response.getWriter(),但是没有return fals导致j继续放行,在log Aop切面中又需要使用到session > 但是此时response已经被提交了就会出现这个错误 ## 重点一:跨域的解决问题 > 跨域算是一个老生畅谈的容易遇到的问题,从最开始的javaWeb开始到现在springBoot总结下来一共也就三种情况,对于最简单的get请求 > 不携带 cookie 自定义header 参数的情况下不会发生什么跨域问题,随着我们需要对会话信息的保存 客户端无状态的这种要求的到来 > 我们有时需要将信息存放在cookie 有时将token存放在自定义请求头,有时post请求的复杂请求会预校验等等这些情况的到来我们就需要 > 开始解决跨域的问题了,具体的配置如下所示,我到现在为止处理的跨域问题都能由下面的配置完成。 > > ⚠️注意 > > 在SpringBoot项目中不仅要继承HandlerInterceptor还需要在配置类中进行注册 > > 在SSM框架中同样的道理,但是可以在配置文件中注册 > > JavaWeb项目中由于基于的是Servlet技术只需要继承拦截器就可以了 > > 那么后端的解决方案就这样了,对于前端我们需要处理的情况如下允许携带操作携带凭证开启即可了,还有的话就是 > 请求头的携带可以直接看axios的整理笔记就可以了,到此跨域的问题就算解决了,多提一点后端给前端的token的保存 > 方法可以存放在本地那么语法如下所示 > > > ```java @Component public class CrossDomainInterceptor implements HandlerInterceptor { /** * 处理器方法执行之前的操作 * * @param request 请求对象 * @param response 响应对象 * @param handler 处理器及其方法对象 * @return 如果为true则继续执行后续的拦截器和控制器方法,否则不执行 */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { System.out.println("进入了拦截器"); //cookie携带的跨域解决 response.addHeader("Access-Control-Allow-Origin", request.getHeader("Origin")); response.setHeader("Access-Control-Allow-Credentials", "true"); //请求方法的跨域解决 response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE,PUT,PATCH"); response.setHeader("Access-Control-Max-Age", "0"); //请求头的跨域解决 (解决响应的自定义Header前端无法读取的情况,在后面解决) //暴露哪些头部信息(因为跨域访问默认不能获取全部头部信息) /**response.setHeader("Access-Control-Expose-Headers", "你自定义的响应头字段名");*/ response.setHeader("Access-Control-Allow-Headers", "*"); response.setHeader("XDomainRequestAllowed", "1"); //对预检请求的放行 if ("OPTIONS".equals(request.getMethod())) { response.setStatus(200); } return true; } } ``` ```html //1.前端要进行配置,允许ajax操作携带凭证 axios.defaults.withCredentials = true; ``` ```html window.localStorage.setItem(key,value);//设置指定key的数据(JSON格式) window.localStorage.getItem(key);//获取指定key的数据 window.localStorage.removeItem(key);//删除指定key的数据 window.localStorage.clear();//清空所有的存储数据 window.sessionStorage.setItem(key,value); window.sessionStorage.getItem(key); window.sessionStorage.removeItem(key); window.sessionStorage.clear(); ``` ## 重点二:CommonControllerAdvice类(封装) 整个系统的基本框架中所有的业务逻辑判 断都在Service层解决,并且只要有异常就想抛出 最终在`@ControllerAdvice`中进行统一的处理,所以这个类中我不仅可以处理一些常见的异常还可以处理自定义的异常以满足业务需要 注意⚠️:然后,我们来看一下此类(CommonControllerAdvice)的注释: > 这个类是为那些声明了(@ExceptionHandler、@InitBinder 或 @ModelAttribute注解修饰的)方法的类而提供的专业化的@Component , 以供多个 Controller类所共享。 说白了,就是aop思想的一种实现,你告诉我需要拦截规则,我帮你把他们拦下来,具体你想做更细致的拦截筛选和拦截之后的处理, 你自己通过@ExceptionHandler、@InitBinder 或 @ModelAttribute这三个注解以及被其注解的方法来自定义 同时CommonControllerAdvice这个类我还继承了ResponseBodyAdvice类 > `ResponseBodyAdvice`可以在注解@ResponseBody将返回值处理成相应格式之前操作返回值。实现这个接口即可完成相应操作。 > 可用于对response 数据的一些统一封装或者加密等操作 > `ResponseBodyAdvice接口`和之前记录的RequestBodyAdvice接口类似, RequestBodyAdvice是请求到Controller之前拦截, > 做相应的处理操作, ***而ResponseBodyAdvice是对Controller返回的{@code @ResponseBody}or a {@code ResponseEntity} 后, > {@code HttpMessageConverter} 类型转换之前拦截, 进行相应的处理操作后,再将结果返回给客户端.*** 总结:所以有了这个类可以对项目中Controller层的方法进行切面编程 * 1.在此处统一对Controller层出现的异常进行处理 * 2.因为在Controller中层中对于结果的封装步骤差不多,所以也可以在这里统一封装结果 * basePackages-这里指定只处理自定义的Controller包下的控制器 ```java @Component @ControllerAdvice(basePackages = "com.Jano.controller") @ResponseBody public class CommonControllerAdvice implements ResponseBodyAdvice { } ``` ## 重点三:Date类型数据转换 > 在处理Date类型的数据时不管是从数据库读取Date还是从前端接受数据Date类型都有可能会出现格式不匹配的情况,以前的解决 办法对于前端String传参转化为Date格式无法匹配我是利用重写转换器的方法来做的,但是现在有了更好的办法; 例如: 从数据库中读出"date": "2018-08-01T14:25:31.296+0000" 这个格式并不是我们想要的,那么如何将其进行格式化?这时就需要用到 jackson 的 @JsonFormat 注解,并且我们 可以指定时区 那么@DateTimeFormat适用于入参是进行String类型的格式转换,这个注解可以打在参数上的(@JsonFormat Date date) ```java public class DateVo { @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GTM+8") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date date; public void setDate(Date date) { this.date = date; } public Date getDate() { return date; } } ``` ## 重点四:使用StringUtils的误区 > 注意:StringUtils中的isBlank和isEmpty的区别 > 查看源码可以得知两者都可以对 null和“” 进行判空,但是**isEmpty**不会对 “空格” 进行判空。 ## 重点五:分页功能的小知识点 > 使用PageHelper来设置排序分页查询条件 ```java @PostMapping("/users") public ApiResponse getUsers(@RequestParam(value = "pageNum", defaultValue = "0") int pageNum, @RequestParam(value = "pageSize", defaultValue = "10") int pageSize, @RequestParam(value = "orderBy", defaultValue = "name") String orderBy, @RequestParam(value = "order", defaultValue = "asc") String order, @RequestBody User user){ // 设置pageSize最大值 if(pageSize>50){ pageSize=50; } // 只允许指定的排序字段和排序方式,防止SQL注入 String[]orderByArr={"name","email"}; String orderByStr=""; if(StringUtils.isNotEmpty(orderBy)&&Arrays.asList(orderByArr).contains(orderBy.toLowerCase())){ orderByStr=String.format("%s %s",orderBy.toLowerCase(),"asc".equalsIgnoreCase(order)?"asc":"desc"); }else{ // 默认排序 orderByStr="name asc"; } =======重点======== PageHelper.startPage(pageNum,pageSize,orderByStr); List users=userMapper.listUsers(user); PageInfo userPageInfo=new PageInfo<>(users); return ApiResponse.success(userPageInfo); } ``` ## 重点六:事务的问题 > 在业务层中我需要删除一个帖子,同时需要删除他的评论,这个必须同时成功和同时失败,那么在springBoot中 > 默认是开启食事务的所以我们可以很容易的使用注解来完成这个事@Transactional > > 在这里主要说一下@Transactional 注解的失效场景 ```markdown @Transactional 注解的看似简单易用,但如果对它的用法一知半解,还是会踩到很多坑的。 @Transactional 注解的失效场景,这个问题见过太多的人栽跟头,一篇刨根问底,让面试官都闭嘴 ● @Transactional 应用在非 public 修饰的方法上,不支持回滚; ● @Transactional 注解属性 propagation 设置错误 ● @Transactional 注解属性 rollbackFor 设置错误; ● 在同一个类中方法调用,导致 @Transactional 失效; ● 异常被你的 catch 处理了,导致 @Transactional 没办法回滚而失效; ● 数据库配置了不支持事务的引擎,或者数据库本身就不支持事务。 ``` ## 重点六:保持会话的方式(本框架采用JWT保持会话) > 在学习JWT会话技术之前我们常用的保持会话的方式大概以下几种
> (一)session机制保持会话
> (二)cookie机制保持会话
> (三)token机制保持会话:配合Redis
> (四)token机制保持会话:JWT
注意⚠️:
session机制保持会话
缺点:高并发情况下,会占用服务器大量内存
分布式(一个业务分成几个子业务,部署在多个服务器)或者集群(一个业务部署在多个服务器)的时候,session不能共享。
解决方案:
● 高并发的时候可以将session存储到redis,如果用户长时间没有访问,将session存储到redis,就减少了服务器的压力。
● 分布式或者集群的时候,先通过redis来判断用户状态也可以实现session共享.

cookie机制保持会话
缺点:
● 每次访问都提交cookie,增加请求量
● 其他访问可能需要cookie(比如说购物车的信息存放在cookie), 浏览器对每个域存储的cookie的大小有限制,那么需要控制加密后的凭证。
token机制保持会话
● cookie 和session依赖于浏览器,如果客户端不是浏览器,那么需要手动添加token(和cookie类似,也是登录凭证), 将token添加到http header浏览器获取后本地保存下次请求带上或者做为参数添加到url, ● token可以结合redis使用保存用户信息
● 前端保存操作: ```html let token = resp.headers.token; let result = resp.data; window.sessionStorage.setItem("token",token); window.sessionStorage.setItem("user",JSON.stringify(result.data)) ``` 缺点: ● 每次访问的时候手动添加token
● 和cookie 的方式一样增加了请求量 ### 总结 不同的方式适合不同的应用场景,视情况使用。 #### 相同点 ● 所有的方式目的都是为了验证用户状态。 ● 都需要在客户端存储凭证。 #### 不同点 ● 第一种是通过是通过空间换时间,消耗内存存储session对象,但是判断用户状态不用复杂的逻辑。
● 第二种第三种用时间换空间,在服务器端逻辑处理进行判断用户状态。 ## 重点七:刷新JWT > 首先在每次访问页面(不包含登陆注册页面或者不需要验证的页面)的时候都会校验token是否正确 > 在本项目中我是从两个方面来进行校验的
> ●从获取的非空token中结合自己的密钥看是否能还原JWT对象从而获取数据声明
> ●将现在时间和JWT载荷中定义好的过期时间进行对比看是否在现在时间的前面从而判断是否过期
> ⚠️:**在我写的拦截器中,我原来没有直接用validateToken(String token)这个方法因为底层封装返回我并不清楚到底是token不合法 > 还是token已经过期了,所以我想的是将它分开来进行判断方便抛出自定义的异常,如此可以更好的配合前端根据我的返回值判断决定是否进行 > token的刷新,‼️‼️但是这个想法是我想天真了, >Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(secretKey)).parseClaimsJws(token).getBody();这个无论是token > 不合法,还是token过期都会抛出异常所以我没有办法区分除非在捕获异常的时候分情况的来捕获但是要改动很多,所以我决定就直接统一的给token invalid!!就可以了 然后前端只要拿到500异常就刷新token把如果在刷新token的过程中还是报错那就重新登陆了** **官方解释**
根据JWT规范,日期将被转换为自纪元以来的*秒数*(而不是毫秒)并存储为`exp`JWT声明.解析器将查看该声明并确保JWT未过期. 请注意,到期检查基于解析时的系统时钟时间.如果生成令牌的计算机的时钟与解析令牌的计算机相比具有合理的漂移,则过期检查可能会失败.在这种情况下,您可以使用JJWT的 `setAllowedClockSkewSeconds`方法(在JwtParser/Builder上)允许在时钟之间的差异上有一些摆动空间(1到2分钟应该绰绰有余),例如: ``` Jwts.parser().setAllowedClockSkewSeconds(120)...etc... ``` 如果由于任何可能不够好的原因,您可以通过以下方式控制实际的解析时钟: ``` Jwts.parser().setClock(new MyClock())...etc... ``` `Clock`但是在大多数情况下设置a 不是必需的(通常在测试用例中最有用). **验证JWT(是否过期,是否存在)** ```java /** * 校验令牌 */ public static Boolean validateToken(String token){ Claims claimsFromToken=getClaimsFromToken(token); //获取数据是否为空&&token是否过期 return(null!=claimsFromToken&&!isTokenExpired(token)); } /** * 从令牌中获取数据声明 */ public static Claims getClaimsFromToken(String token){ Claims claims; try{ //原来在登陆签发token时我就进行了二次加密所以这里也需要 claims=Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(secretKey)).parseClaimsJws(token).getBody(); }catch(Exception e){ claims=null; } return claims; } /** * 验证token 是否过期 */ public static Boolean isTokenExpired(String token){ try{ Claims claims=getClaimsFromToken(token); Date expiration=claims.getExpiration(); return expiration.before(new Date()); }catch(Exception e){ //log.error("error={}",e); e.printStackTrace(); return true; } } ``` **token拦截器** ```java /** * token校验拦截 * * @author zoumaoji * @date 2022/07/15 15:12 **/ @Component public class JwtTokenInterceptor implements HandlerInterceptor { /** * @param request * @param response * @param handler * @return boolean * @author zoumaoji * @date 2022/07/15 15:13 */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { JsonResult jsonResult = new JsonResult(); //1.token是否未携带 //2.token是否有效 response.setContentType("text/html;charset=utf-8"); //ACCESS_TOKEN在jwt的常量包中 这里是获取JWT签发的token String token = request.getHeader(Constant.ACCESS_TOKEN); //token不存在的情况 if (StringUtils.isBlank(token)) { //未经授权的状态码 未携带token的情况 jsonResult.setCode(HttpStatus.INTERNAL_SERVER_ERROR.value()); jsonResult.setErrMsg("please login!!"); response.getWriter().write(JSON.toJSONString(jsonResult)); return false; } ; //检验token是否成功 1.是否合法 2。是否过期 这两个同时满足才可以 if (!JwtTokenUtil.validateToken(token)) { //未经授权的状态码 token非法的情况 jsonResult.setCode(HttpStatus.INTERNAL_SERVER_ERROR.value()); jsonResult.setErrMsg("token invalid!!"); response.getWriter().write(JSON.toJSONString(jsonResult)); return false; } /**为了专门抛出token过期的情况这里写出来,因为在上面的底层中如下 laims claimsFromToken = getClaimsFromToken(token); 获取数据是否为空&&token是否过期 return (null != claimsFromToken && !isTokenExpired(token)); getClaimsFromToken(token)底层也会校验时间是否过期 我在测试的时候将token的过期时间设置为10s 就希望抛出的异常是token expired!! 然而时间过期的异常被getClaimsFromToken(token)这个函数抛出了 如下: public static Claims getClaimsFromToken(String token) { Claims claims; try { claims = Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(secretKey)).parseClaimsJws(token).getBody(); } catch (Exception e) { claims = null; } return claims; } 此时抛出的异常被捕获变成null到了拦截器层就直接返回给前端显示我的异常消息,此处证明了 Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(secretKey)).parseClaimsJws(token).getBody(); 会检验时间是否过期,如果过期会抛出io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-07-16T23:33:28Z. Current time: 2022-07-16T23:33:37Z, a difference of 9200 milliseconds. Allowed clock skew: 0 milliseconds 那么如何将过期时间和非法token这两个异常分开情况来进行抛出?????? 暂时没有更好的办法了我即使写了下面的这段逻辑,就算满足条件也到不到这个方法中,所以我换一个思路,就直接统一的给token invalid!!就可以了 然后前端只要拿到500异常就刷新token把如果在刷新token的过程中还是报错那就重新登陆了这样也可以的 ‼️ 主要的无语点在于Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(secretKey)).parseClaimsJws(token).getBody(); 在执行的过程是会校验token是否过期的 if (JwtTokenUtil.isTokenExpired(token)) { jsonResult.setCode(HttpStatus.REQUEST_TIMEOUT.value()); jsonResult.setErrMsg("token expired!!"); response.getWriter().write(JSON.toJSONString(jsonResult)); return false; } */ return true; } ``` **刷新JWT思路**
***问题***
>前端登录后,后端返回token和token有效时间,当token过期时要求用旧token去获取新的token,前端需要做到无痛刷新token,即请求刷新token时要做到用户无感知。 ![image](src/main/resources/helpStatic/jwt刷新思路.png)
#### **思路一**
前端发起ajax请求 参考:https://segmentfault.com/a/1190000020210980?utm_source=sf-similar-article
=> 后端发现jwt已经过期,返回401状态码
=>前端拦截响应数据,并发起刷新token的请求
=>后端返回了410的状态码(这个时候代表refreshToken也过期了,需要进行重新登录了)
=> 真正的过期了,需要跳转到登录界面
**疑惑** ***: 为什么不能将 token 设置的有效期长一些,失效就直接重新登陆,而需要不停的刷新 token, 毕竟 refresh token 过期的时候,总还是要重新登陆的***
**解答** >token 翻译成中文就是令牌。因为 HTTP1.1 协议每次请求都是独立的,不能复用的连接。 > 所以,在每次请求中都需要带上令牌。令牌有效期越长,那么请求携带次数就越多,传输过程越容易暴露令牌,导致安全性下降。 > 总而言之,就是一个令牌使用时间和次数越多,那么在使用过程中越不安全。反之, >如果把令牌有效期设置的越短,那么就越安全。但如果这样子,将导致用户体验很差。 > 那么如何解决这个矛盾的?对于经典的 web 应用,令牌的有效时间是 30 分钟,在有效期内使用令牌请求, > 后端将刷新令牌的有效期。如果超过 30 分钟再发起请求,服务端会要求前端带上 refresh token , > 即刷新令牌。刷新令牌有效期可以设置很多天,比如设置一周。刷新令牌设置这么长时间那不是很不安全? > 其实不是的,刷新令牌相比前面每次请求使用的令牌来说,区别在于低频次使用。虽然有效期长得多, > 但使用次数非常低,有的方案只用一次。如前所述,使用刷新令牌换取普通令牌,并获取一个新的刷新令牌, > 原刷新令牌作废。如此,便实现了用户登录后,只要不超过一周内,访问服务, > 那便可以一直免登录(例如你手机上的微信)。最后关于令牌本身,可以是服务端随机生成的 session id , > 也可以是服务端不保存映射关系的 JWT ,主要看具体应用场景。 最后归纳一下,token 和 refresh token 的区别在于有效期一个短一个长。 使用上 token 用于每次请求,refresh token 用于 token 过期后去换取新的 token 和 refresh token 。 这样设计的目的,就是为了解决安全性和使用体验之间的矛盾。 >如果你的 AccessToken 不是 Stateless 的(意味着每次都要读取状态,校验 AccessToken 的合法性,判断这个 AccessToken 是否已经被撤销,或者是否已经被替换),那么 RefreshToken 就没有太大的意义。 如果你的 AccessToken 不需要读取状态(无论是数据库或者缓存),仅凭 Token 本身的签名信息就能确定它的合法性(如 JWT ),那么 RefreshToken 的存在就相当于有了一个检查点,可以在检查点确认是否还可以续签。 因此 AccessToken 的有效期应当尽量设置短一点,通过 AccessToken 访问,只要通过签名校验合法即可通行,无序读取额外的状态来进一步确认是否撤销,当 AccessToken 过期以后再通过 RefreshToken 读取额外的状态(数据库 /缓存)确认是否继续签发。 **Controller** ```java /** * 刷新Jwt令牌 * * @author zoumaoji * @date 2022/07/16 16:02 **/ @RestController @RequestMapping("/jwt") public class JwtController { @Autowired JwtService jwtService; @RequestMapping("/refreshToken") @LogAnnotation(title = "Jwt模块", action = "刷新令牌") public JsonResult refreshToken(String refreshToken) { JsonResult jsonResult = jwtService.refreshToken(refreshToken); return jsonResult; } } ``` **serviceImpl** ```java /** * 刷新令牌的实现类 * * @author zoumaoji * @date 2022/07/16 17:07 **/ public class JwtServiceImpl extends AbstractBaseServiceImpl implements JwtService { @Autowired UserService userService; /** * 刷新令牌的逻辑 * 刷新用户的信息、刷新令牌的过期时间 * @param refreshToken * @return com.Jano.dto.JsonResult * @author zoumaoji * @date 2022/07/16 17:08 */ @Override public JsonResult refreshToken(String refreshToken) { JsonResult jsonResult = new JsonResult(); //获取刷新令牌中用户的信息 // 1.判断refreshToken是否为空 if (StringUtils.isBlank(refreshToken)) { //未经授权的状态码 未携带refreshToken的情况 jsonResult.setCode(HttpStatus.INTERNAL_SERVER_ERROR.value()); jsonResult.setErrMsg("please login!!"); return jsonResult; } // 2.refreshToken看是否合法 if (Objects.isNull(JwtTokenUtil.getClaimsFromToken(refreshToken))) { //未经授权的状态码 token非法的情况 jsonResult.setCode(HttpStatus.INTERNAL_SERVER_ERROR.value()); jsonResult.setErrMsg("token invalid!!"); return jsonResult; } //3.判断刷新令牌是否过期了,在拦截器中我们已经对ACCESS_TOKEN进行了拦截判断过期情况校验 //对于刷新令牌的这个接口就不进行拦截了 if (JwtTokenUtil.isTokenExpired(refreshToken)) { jsonResult.setCode(HttpStatus.REQUEST_TIMEOUT.value()); jsonResult.setErrMsg("refreshToken expired!!"); return jsonResult; } //4.判断用户信息是否更新(我在这里的做法就不再比较了直接根据用户id查数据库用户信息) //⚠️:当我们为一个用户修改了用户名等信息token是需要刷新的,因为如果不刷新在拦截器进行 //权限判定的时候我们是根据用户名查找数据库获取当前用户的权限的那么修改之后以前的用户名 //就不生效了,所以这里刷新的时候一定要拿到最新的用户信息(‼️数据库表用户名我设计的是唯一的) String userId = JwtTokenUtil.getUserId(refreshToken); UserModel user = userService.findById(Integer.valueOf(userId)); Map claims =new HashMap<>(); claims.put(Constant.JWT_USER_NAME, user.getUsername()); //最终刷新后的结果 String refreshTokenResult = JwtTokenUtil.refreshToken(refreshToken, claims); jsonResult.setCode(HttpStatus.OK.value()); jsonResult.setData(refreshTokenResult); return jsonResult; } } ``` #### **思路二** > 登陆时就签发一个`token`,每次请求都刷新 带上登陆时签发的token,加长下他的时间 如果在指定时间没有被刷新,比如30分钟之内没有刷新 > 过一次,超过了30分钟再进行刷新的时候就显示超时,退回登陆界面。 ## 重点八 用户的权限验证 > 首先在建表的情况下使用的思想是RBAC即:基于角色的访问控制 ![image](src/main/resources/helpStatic/RBAC.png)
![image](src/main/resources/helpStatic/权限设计图.png) > 具体的验证思路就是:**使用拦截器在我们保存用户登录状态的前提下**
**1.获取当前用户拥有的权限资格**
`List hasPermissions = permissionService.findByUsername(redisUser.getUsername())`

**2.与当前访问的@Controller所需权限进行比较判断**
资源本身对应的权限获取方法如下:
`控制器--方法--找到自定义权限注解--value(自定义注解的值)`
在拦截器中的Object对应此处访问的控制器对象 HandlerMethod hm =(HandlerMethod) handler;获取其方法上的注解进行逻辑判断即可

**3.最后通过Stream流的方式判断当前用户所拥有的权限是否包含当前访问的@Controller层上@Auth注解中的value值即所需权限, > 如果包含那么就可以放行** ## 重点九:Log日志的问题 > SpringBoot:底层是`Spring框架`,`Spring框架`默认是JCL日志抽象层
SpringBoot:选用SLF4j日志抽象层和logbook底层日志实现 ```xml 1.SpringBoot底层也是使用slf4j+logback的方式来进行日志记录 2.SpringBoot也把其他的日志都替换成了slf4j 3.中间的替换包 ``` ![image](src/main/resources/helpStatic/log.png) > 所以只需要简单的配置就可以使用日志的框架了 ```xml #配置日志级别从哪个级别开始打印 logging.level.root=info #在当前项目下保存日志文件 logging.file.name=logs/error.log # 在控制台输出的日志的格式 logging.pattern.console=%d{yyyy/MM/dd-HH:mm:ss} [%thread] %-5level %logger- %msg%n # 指定文件中日志输出的格式 logging.pattern.file=%d{yyyy/MM/dd-HH:mm} [%thread] %-5level %logger- %msg%n # 对mapper进行debug 注意包名 logging.level.com.Jano.mapper=debug # 对mybatis进行debug logging.level.org.mybatis=debug ``` ## 关于Aop日志切面 > 如果只是使用日志框架进行管理将平时的日志存入text文件那么在查看的时候实在不太方便,所以对于一个 > 后台的管理系统来说需要将用户的一些日常操作进行监控和细化的存入数据库,方便查看。 ![image](src/main/resources/helpStatic/aopLog.png) ### 核心思想
> 1.定义日志注解,起到标识作用
2.把日志注解,加在对应的controller上
3.定义日志切面,实现记录用户的操作信息
4.定义切点,把所有标注日志注解的方法,都作为切点。
5.定义Around切入策略,这时切面会切入所有标注日志注解的方法上
6.在Around切入策略中,获取连接点对像,通过该对像,获取目标方法上的各种运行期参数,写入数据库即可!
### 难点 HttpServletRequest的获取 **RequestContextHolder是一个包含了request请求的容器,所以要获取请求中的信息,自然要从容器中获取。
然而RequestContextHolder只能获取RequestAttributes对象,要取得request,必须从ServletRequestAttributes获取**
> 通过查看底层的源码,可以发现ServletRequestAttributes是继承了AbstractRequestAttributes, 然后AbstractRequestAttributes继承了RequestAttributes

也就是说ServletRequestAttributes是RequestAttributes的子类,所以直接强转就可以了 通过强转后的ServletRequestAttributes直接getRequest,即可得到request对象。 ```java HttpServletRequest request=((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest(); ``` ##### 获取HttpServletRequest信息 ```java RequestAttributes requestAttributes=RequestContextHolder.getRequestAttributes(); if(requestAttributes!=null){ HttpServletRequest request=(HttpServletRequest) requestAttributes.resolveReference(RequestAttributes.REFERENCE_REQUEST); // Do something } ``` #### 获取HttpSession信息 ```java RequestAttributes requestAttributes=RequestContextHolder.getRequestAttributes(); if(requestAttributes!=null){ HttpSession session=(HttpSession) requestAttributes.resolveReference(RequestAttributes.REFERENCE_SESSION); // Do something } ```