JWT技术专题
一、简介
1.1 背景
Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准.该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
1.2 定义
JWT(JSON WEB TOKEN):JSON网络令牌,JWT是一个轻便的安全跨平台传输格式,定义了一个紧凑的自包含的方式在不同实体之间安全传输信息(JSON格式)。它是在Web环境下两个实体之间传输数据的一项标准。实际上传输的就是一个字符串。广义上讲JWT是一个标准的名称;狭义上JWT指的就是用来传递的那个token字符串。
1.3 不得不说的那些事
标准定义方为ISOC(Internet Society 国际互联网协会),标准文件为 RFC7519,目前绝大多数的网络标准都是由ISOC定义的。
RFC(Request For Comments),意即“请求评议”,包含了关于Internet的几乎所有重要的文字资料和标准,也称为“网络知识圣经”
JWT用来做什么?怎么来的?
由于http协议是无状态的,所以客户端每次访问都是新的请求。这样每次请求都需要验证身份,传统方式是用session+cookie来记录/传输用户信息,而JWT就是更安全方便的方式。它的特点就是简洁,紧凑和自包含,而且不占空间,传输速度快,而且有利于多端分离,接口的交互等等。
JWT是一种Token规范,主要面向的还是登录、验证和授权方向,当然也可以用只来传递信息。一般都是存在header里,也可以存在cookie里。
紧凑: 意味着这个字符串很小,甚至可以通过URL参数,POSTparameter中以Http Header的方式传输。
自包含:传输的字符串包含很多信息,别人拿到以后就不需要再多次访问数据库获取信息,而且通过其中的信息就可以知道加密类型和方式,当然解密需要公钥和密钥。
应用场景:认证和授权,认证解决[你是谁]的问题,授权解决了[你能做什么]的问题 一次性验证,邮件验证,注册激活,登录验证,长期未登录激活,应用授权,单点登录
1.3 什么是TOKEN
Token是服务器签发的一串加密字符串,是为了给客户端重复访问的一个令牌,作用是为了证明请求者(客户端)的身份,保持用户长期保持登录状态。
JWT和TOKEN的关系
JWT就是TOKEN的一种载体,或者说JWT是一种标准,而Token是一个概念,而JWT就是这个概念执行的一种规范,通俗点说token就是一串字符串,jwt就是加上类型,信息等数据再加密包装一下成为一个新的token。
最直观的区别就是不使用JWT的token需要查询数据库来验证,而使用JWT可以不用对数据库进行操作,因为它本身包含了可自定义的信息
JWT是所有语言通用的,其实sessionID储存也算是一种token
二、基于token认证和基于Session认证
在上一篇《Spring Security十万字详解》中对两种认证方式做了简要和概述。
三、JWT结构
JWT是由三部分构成,将这三段信息文本用链接构成了JWT字符串。就像这样
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJVc2VySWQiOjEyMywiVXNlck5hbWUiOiJhZG1pbiJ9.Qjw1epD5P6p4Yy2yju3-fkq28PddznqRj3ESfALQy_U
第一部分我们称它为头部(header)第二部分我们称其为载荷(payload,类似于飞机上承载的物品),第三部分是签证(signature)
header
JWT的头部承载的两部分信息:
声明类型,这里是jwt 声明加密的算法,通常直接使用HMAC SHA256 完整的头部就像下面这样的JSON
{ 'typ':'JWT', 'alg':'HS256' }
然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
plyload
载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分
标准中注册的声明
- 公共的声明
- 私有的声明
- 标注中注册的声明(建议不强制使用)
- iss:jwt签发者
- sub:jwt所面向的用户
- aud:接收jwt的一方
- exp:jwt的过期时间,这个过期时间必须大于签发时间
- nbf:定义在什么时间之前,该jwt都是不可用的
- iat:jwt的签发时间
- jti:jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击
公共的声明:
公共的声明可以添加任何的信息,一般添加用户的相关信息或其它业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密;
私有的声明
私有的声明是提供者和消费者功能定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为名文信息。
定义一个payload
{ "sub": "1234567890", "name": "John Doe", "admin": true }
然后将其base64加密,得到jwt的一部分
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
Signature
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:
- header(base64后的)
- payload(base64后的)
- secred
这个部分需要base64加密后的header和base64加密后的payload使用“.”连接组成的字符串,然后通过header中声明的加密方式进行加secret组合加密,然后就构成了jwt的第三部分
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload); var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
将这三部分用“.”连接成一个完整的字符串,构成了最终的jwt:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
注意:secret是保存在服务器端的,jwt的签发也是在服务端的,secret就是用来进行jwt的签发和jwt的验证,所以它就是你服务端的私钥,在任何场景都不应该流露出去,一旦客户端得知这个secret,那就意味着客户端可以自我签发jwt了
应用
一般是在请求头里加入Authorization,并加上Bearer标注:
fetch('api/user/1', {
headers: { 'Authorization': 'Bearer ' + token
}
})
四、有关JWT值得讨论的一些事
JWT有什么不好的地方?
不好的地方就是JWT等于说是泼出去的水,发出去的令牌,不可更改,只要有效期内就可以永使用
JWT安全性如何?
这也是JWT最重要的一点,它的出现就是为了免登陆或者权限控制,所以它的安全性至关重要。所以它虽然是一直加密的,但是安全性总是建立在相对性之上的。所以只能说一定程度上是安全的。
JWT如何存放?
JWT不需要存放,或者说存放在客户端那里。服务端只需要有解密算法就可以判断当前的JWT是不是有效的。
JWT如何验证?
当接收方接收到一个JWT的时候,首先要对这个JWT的完整性进行验证,这个就是签名认证。它验证的方法其实很简单,只要把header使用base64url解码以后就知道签发者用什么算法做的签名,然后用这个算法对header和payload加密一下,再比较这个签名是否和JWT本身包含的signature是否完全相同,只要不同就可以认为这个JWT是被篡改过的。所以接收方生成签名的时候必须使用跟JWT发送方相同的密钥。
再次就是验证payload里的部分,比如是否过期,还有自定义的信息是否相同,加上已经前面验证的signature,这样就保证了这个签名的有效性
互斥登录/修改密码后登录的问题
场景:用户在多个设备或者其它浏览器/IP登录以后或者修改密码以后,那其它的设备就需要自动退出登录
- 用户修改密码之后签发一个新的token来覆盖之前的。但是之前的token只要在失效之前仍然是可以验证并且登录的
- 用户手动退出的时候也是需要token失效的,这就需要退出的时候直接清除或者覆盖客户端保存的token。同上,之前的token仍然有效,所以token的时间不能太长
- 设置黑名单,在签发新token的时候把之前未过期的token加入黑名单使之失效。但是这样就需要储存token,而jwt的优点就是不需要储存空间。
续签
场景:需要用户长期登录的应用,比如用你开发需要用户保持登录的应用,在使用token的时候保证用户体验不需要频繁登录操作,又要保证用户在一段时间之后登录过期重新登录,主要是一个用户体验和用户信息安全的问题。这个时候又要求token不能过长,只能通过续签来保持登录状态,又或者说用户登录的时候你需要获取用户在社交网站的信息,就需要获取社交网站授权的token,这也需要续签,不过这是向社交网站续签。
何时续签:
1.每次请求刷新 每次请求直接覆盖签发新的token
2.记录时间定时刷新 设置一个定时器,记录签发时间,然后到过期的时候签发新的
3.临期时刷新 访问的时候对比时间,如果即将过期就立即刷新。
4.过期后续签,这样设置过期时间的意义就在于如果token被盗取后黑客登录也需要续签,这样用户再次登录客户端就会发现用户登录异常。而且每次获取的token都是新的,旧的只能>获取一次续签的机会,也就说如果用户续签以后之前的token就不能再续签了。
5.jwt-autorefresh(JWT自动刷新)(有限制条件,在客户端定时刷新)。
JWT的优点
- 因为json的通用性,所以JWT是可以跨语言支持的,像C#,JavaScript,NodeJS,PHP等许多语言都可以使用
- 因为由了payload部分,所以JWT可以在自身存储一些其它业务逻辑所必要的非敏感信息
- 便于传输,jwt的构成非常简单,字节占用很小,所以它是非常便于传输的
- 它不需要在服务端保存会话信息,所以它易于应用的扩展
JWT 安全相关
- 不应该在jwt的payload部分存储敏感信息,因为该部分是客户端可解密的部分
- 保护好secret私钥。该私钥非常重要
- 如果可以,请使用https协议
JWT生成token的安全性分析
从JWT生成的token组成上来看,要想避免token被伪造,主要就得看签名部分了,而签名部分又有三部分组成,其中头部和载荷的base64编码,几乎是透明的,毫无安全性可言,那么最终守护token安全的重担就落在了加入的盐上面了!试想:如果生成token所用的盐与解析token时加入的盐是一样的。岂不是类似于中国人民银行把人民币防伪技术公开了?大家可以用这个盐来解析token,就能用来伪造token。这时,我们就需要对盐采用非对称加密的方式进行加密,以达到生成token与校验token方所用的盐不一致的安全效果!
非对称加密RSA介绍
基本原理:同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端私钥加密,持有私钥或公钥才可以解密公钥加密,持有私钥才可解密 优点:安全,难以破解 缺点:算法比较耗时,为了安全,可以接受 历史:三位数学家Rivest、Shamir 和 Adleman 设计了一种算法,可以实现非对称加密。这种算法用他们三个人的名字缩写:RSA。
JWT流程图
五、JWT认证的代码实现
5.1 创建工程
环境:idea、maven、jdk8、springboot2.x
maven结构图
pom依赖
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.5.3</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.uncle</groupId> <artifactId>jwt-demo</artifactId> <version>0.0.1-SNAPSHOT</version> <name>jwt-demo</name> <description>Demo project for Spring Boot</description> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.4.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.48</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> </project>
5.2 自定义注解
package com.uncle.jwtdemo.annotations; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @program: jwt-demo * @description: 跳过验证 * @author: 步尔斯特 * @create: 2021-08-07 16:18 */ @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface PassToken { boolean required() default true; }
package com.uncle.jwtdemo.annotations; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @program: jwt-demo * @description: 需要登录才可以进行操作 * @author: 步尔斯特 * @create: 2021-08-07 16:19 */ @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface UserLoginToken { boolean required() default true; }
5.3 用户实体类
package com.uncle.jwtdemo.bean; import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @program: jwt-demo * @description: * @author: 步尔斯特 * @create: 2021-08-07 16:21 */ @Data @AllArgsConstructor @NoArgsConstructor public class User { String Id; String username; String password; //获取token的方法 public String getToken(User user) { String token=""; token= JWT.create().withAudience(user.getId()) .sign(Algorithm.HMAC256(user.getPassword())); return token; } }
5.4 创建拦截器
package com.uncle.jwtdemo.config; import com.uncle.jwtdemo.interceptor.AuthenticationInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** * @program: jwt-demo * @description: * @author: 步尔斯特 * @create: 2021-08-07 16:25 */ @Configuration public class InterceptorConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authenticationInterceptor()) .addPathPatterns("/**"); } @Bean public AuthenticationInterceptor authenticationInterceptor() { return new AuthenticationInterceptor(); } }
5.5 认证的拦截器
package com.uncle.jwtdemo.interceptor; import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.exceptions.JWTVerificationException; import com.uncle.jwtdemo.annotations.PassToken; import com.uncle.jwtdemo.annotations.UserLoginToken; import com.uncle.jwtdemo.bean.User; import com.uncle.jwtdemo.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.lang.reflect.Method; /** * @program: jwt-demo * @description: * @author: 步尔斯特 * @create: 2021-08-07 16:22 */ public class AuthenticationInterceptor implements HandlerInterceptor { @Autowired UserService userService; @Override public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object object) throws Exception { String token = httpServletRequest.getHeader("token");// 从 http 请求头中取出 token // 如果不是映射到方法直接通过 if (!(object instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) object; Method method = handlerMethod.getMethod(); //检查是否有passtoken注释,有则跳过认证 if (method.isAnnotationPresent(PassToken.class)) { PassToken passToken = method.getAnnotation(PassToken.class); if (passToken.required()) { return true; } } //检查有没有需要用户权限的注解 if (method.isAnnotationPresent(UserLoginToken.class)) { UserLoginToken userLoginToken = method.getAnnotation(UserLoginToken.class); if (userLoginToken.required()) { // 执行认证 if (token == null) { throw new RuntimeException("无token,请重新登录"); } // 获取 token 中的 user id String userId; try { userId = JWT.decode(token).getAudience().get(0); } catch (JWTDecodeException j) { throw new RuntimeException("401"); } User user = userService.findUserById(userId); if (user == null) { throw new RuntimeException("用户不存在,请重新登录"); } // 验证 token JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(user.getPassword())).build(); try { jwtVerifier.verify(token); } catch (JWTVerificationException e) { throw new RuntimeException("401"); } return true; } } return true; } @Override public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception { } }
5.6 用户业务
package com.uncle.jwtdemo.service; import com.uncle.jwtdemo.bean.User; /** * @program: jwt-demo * @description: * @author: 步尔斯特 * @create: 2021-08-07 16:30 */ public interface UserService { User findByUsername(User user); User findUserById(String id); }
package com.uncle.jwtdemo.service.impl; import com.uncle.jwtdemo.bean.User; import com.uncle.jwtdemo.service.UserService; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map; /** * @program: jwt-demo * @description: * @author: 步尔斯特 * @create: 2021-08-07 16:48 */ @Service public class UserServiceImpl implements UserService { private static Map<String, User> userMap = new HashMap<>(); @Override public User findByUsername(User user) { return userMap.get(user.getUsername()); } @Override public User findUserById(String id) { return userMap.get(id); } static { userMap.put("1", new User("1", "zhangsan", "123")); userMap.put("zhangsan",new User("1","zhangsan","123")); } }
5.7 token业务
package com.uncle.jwtdemo.service; import com.uncle.jwtdemo.bean.User; /** * @program: jwt-demo * @description: * @author: 步尔斯特 * @create: 2021-08-07 16:37 */ public interface TokenService { String getToken(User userForBase); }
package com.uncle.jwtdemo.service.impl; import com.uncle.jwtdemo.bean.User; import com.uncle.jwtdemo.service.TokenService; import org.springframework.stereotype.Service; /** * @program: jwt-demo * @description: * @author: 步尔斯特 * @create: 2021-08-07 17:26 */ @Service public class TokenServiceImpl implements TokenService { @Override public String getToken(User userForBase) { return userForBase.getToken(userForBase); } }
5.8 测试接口
package com.uncle.jwtdemo.controller; import com.alibaba.fastjson.JSONException; import com.alibaba.fastjson.JSONObject; import com.uncle.jwtdemo.annotations.UserLoginToken; import com.uncle.jwtdemo.bean.User; import com.uncle.jwtdemo.service.TokenService; import com.uncle.jwtdemo.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; /** * @program: jwt-demo * @description: * @author: 步尔斯特 * @create: 2021-08-07 16:27 */ @RestController @RequestMapping("api") public class UserApi { @Autowired UserService userService; @Autowired TokenService tokenService; //登录 @PostMapping("/login") public Object login(@RequestBody User user) { JSONObject jsonObject=new JSONObject(); User userForBase=userService.findByUsername(user); if(userForBase==null){ try { jsonObject.put("message","登录失败,用户不存在"); } catch (JSONException e) { e.printStackTrace(); } return jsonObject; }else { if(!userForBase.getPassword().equals(user.getPassword())){ try { jsonObject.put("message","登录失败,密码错误"); } catch (JSONException e) { e.printStackTrace(); } return jsonObject; }else { String token = tokenService.getToken(userForBase); try { jsonObject.put("token", token); } catch (JSONException e) { e.printStackTrace(); } try { jsonObject.put("user", userForBase); } catch (JSONException e) { e.printStackTrace(); } System.out.println(jsonObject); return jsonObject; } } } @UserLoginToken @GetMapping("/getMessage") public String getMessage(){ return "你已通过验证"; } }
5.x 测试结果
附文
@Target:注解的作用目标 @Target(ElementType.TYPE)——接口、类、枚举、注解 @Target(ElementType.FIELD)——字段、枚举的常量 @Target(ElementType.METHOD)——方法 @Target(ElementType.PARAMETER)——方法参数 @Target(ElementType.CONSTRUCTOR) ——构造函数 @Target(ElementType.LOCAL_VARIABLE)——局部变量 @Target(ElementType.ANNOTATION_TYPE)——注解 @Target(ElementType.PACKAGE)——包 @Retention:注解的保留位置 RetentionPolicy.SOURCE:这种类型的Annotations只在源代码级别保留,编译时就会被忽略,在class字节码文件中不包含。 RetentionPolicy.CLASS:这种类型的Annotations编译时被保留,默认的保留策略,在class文件中存在,但JVM将会忽略,运行时无法获得。 RetentionPolicy.RUNTIME:这种类型的Annotations将被JVM保留,所以他们能在运行时被JVM或其他使用反射机制的代码所读取和使用。 @Document:说明该注解将被包含在javadoc中 @Inherited:说明子类可以继承父类中的该注解
Algorithm.HMAC256():使用HS256生成token,密钥则是用户的密码,唯一密钥的话可以保存在服务端。
withAudience()存入需要保存在token的信息,这里我把用户ID存入token中
实现一个拦截器就需要实现HandlerInterceptor接口
HandlerInterceptor接口主要定义了三个方法
1.boolean preHandle (): 预处理回调方法,实现处理器的预处理,第三个参数为响应的处理器,自定义Controller,返回值为true表示继续流程(如调用下一个拦截器或处理器)或者接着执行postHandle()和afterCompletion();false表示流程中断,不会继续调用其他的拦截器或处理器,中断执行。
2.void postHandle(): 后处理回调方法,实现处理器的后处理(DispatcherServlet进行视图返回渲染之前进行调用),此时我们可以通过modelAndView(模型和视图对象)对模型数据进行处理或对视图进行处理,modelAndView也可能为null。
3.void afterCompletion(): 整个请求处理完毕回调方法,该方法也是需要当前对应的Interceptor的preHandle()的返回值为true时才会执行,也就是在DispatcherServlet渲染了对应的视图之后执行。用于进行资源清理。整个请求处理完毕回调方法。如性能监控中我们可以在此记录结束时间并输出消耗时间,还可以进行一些资源清理,类似于try-catch-finally中的finally,但仅调用处理器执行链中
主要流程:
1.从 http 请求头中取出 token,
2.判断是否映射到方法
3.检查是否有passtoken注释,有则跳过认证
4.检查有没有需要用户登录的注解,有则需要取出并验证
5.认证通过则可以访问,不通过会报相关错误信息
WebMvcConfigurerAdapter该抽象类其实里面没有任何的方法实现,只是空实现了接口
WebMvcConfigurer内的全部方法,并没有给出任何的业务逻辑处理,这一点设计恰到好处的让我们不必去实现那些我们不用的方法,都交由WebMvcConfigurerAdapter抽象类空实现,如果我们需要针对具体的某一个方法做出逻辑处理,仅仅需要在WebMvcConfigurerAdapter子类中@Override对应方法就可以了。
注:
在SpringBoot2.0及Spring 5.0中WebMvcConfigurerAdapter已被废弃
网上有说改为继承WebMvcConfigurationSupport(),不过试了下,还是过期的
解决方法:
直接实现WebMvcConfigurer (官方推荐)