1.什么是JWT?
1.官网地址
官网地址: https://jwt.io/introduction/
2.官网译文及白话解释
-
译文:
JSON Web Token
(JWT)是一个开放标准(rfc7519);
它定义了一种紧凑的、自包含的方式,用于在各方之间以JSON对象安全地传输信息。此信息可以验证和信任,因为它是数字签名的。jwt可以使用秘密(使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对进行签名 -
白话解释:JWT是
JSON Web Token
的简称,是通过JSON形式作为Web应用中的令牌,用于在各方之间安全地将信息作为JSON对象传输。在数据传输过程中还可以完成对数据加密、签名等相关处理。
2.JWT能做什么
1.授权
- 是jwt在项目中使用最广的功能,例如我们在用户登录成功后,生成一个该用户的jwt令牌返回前端,前端对用户的后续请求中携带JWT,根据业务内容进行判断用户是否有访问服务和资源的权限。并且当今广泛使用单点登录功能也是要基于JWT的,因为它的开销很小并且可以在不同的域中轻松使用。
2.信息交换
JSON Web Token
是在各方之间安全地传输信息的好方法。因为可以对JWT进行签名(例如,使用公钥/私钥对),所以您可以确保请求发起人是否合法;并且由于签名是使用标头和有效负载计算的,甚至能够验证您的请求内容是否被第三方拦截后进行了篡改
3.为什么要使用JWT?
一说到jwt,那么咱们就不得不提session
、redis
1.传统session 方式面临的问题
我们最早在做项目的时候,由于功能单一或者用户数量较低,通常是将用户登录后的认证信息存于服务器的session的,每次请求时候呢,都会根据客户端请求携带的cookie(sessionId)去服务器校验比对,这种方式呢,当cookie被截获,用户就会很容易受到跨站请求伪造的攻击,且当用户数量上来的时候,存储用户信息的session会增大服务器的开销!前后端分离,服务器集群都是单session面临的窘境!
2.redis 面临的问题
由于服务器集群需要解决session共享问题,所以呢,后续随着发展,慢慢的将用户信息存于了redis中,但redis也是一个纯吃内存的服务,大用户量下对redis内存扩展的需要又要不断增大!
总结:
session: 消耗服务器资源 安全问题 需要解决分布式session
redis:内存资源消耗问题
3.使用JWT的优势
-
简洁: 可以通过URL将JWT携带在HTTP请求参数 (param)或者在HTTP 请求头(header)进行发送,数据量小,传输速度快
-
可自包含,jwt中可自定义存储用户一些信息,适用于大多数业务逻辑场景,避免频繁查库
-
jwt 支持任何web形式请求
-
保存在客户端,无需在服务端保存用户会话信息,特别适用于分布式微服务
4.JWT认证流程分析
1.文字说明
第一步:前端通过Web表单将自己的用户名和密码发送到后端的接口
第二步:后端根据用户密码查询数据库校验账户合法性,验证成功后将一些用户信息构造进jwt中,此时jwt会进行加密成为一串字符串,前端将jwt生成的字符串返回给前端
第三步:前端后续操作请求将必须携带后端返回的jwt字符串 可存于Header或参数中(根据前后端协商结果,但一般都是header 且使用Authorization位,这样可以防止请求伪造),后端则检查前端是否传递了jwt或者传递过来的jwt是否合法是否过期等等
第四步:如后端接口需要验证才可访问,则对请求校验jwt,验证通过则进行逻辑操作,否则让用户登录
第五步:客户端用户退出时前端可选择删除jwt(也可不删(jwt一般都会设置有效时间))
2.图片说明
整个链路图示如下:
5.JWT结构详解
前边说了一些jwt的各种好各种优点,但仅仅初略提了一嘴jwt就是一个加密的后的字符串…
那么,其具体结构属性是怎样的呢?
令牌组成
JWT(JSON WEB TOKEN)共有三部分组成:标头.负载.签名
jwt: String ====> header.payload.singnature
- 1.标头(Header)
- 2.有效负载(Payload)
- 3.签名(Signature)
- JWT通常如下所示:xxxxx.yyyyy.zzzzz 三部分,即Header.Payload.Signature
例如这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
1.标头
JWT的第一部分是标头(Header),标头通常由两部分组成:令牌的类型(即JWT)和所使用的签名算法,例如HMAC SHA256或RSA。它会使用 Base64 编码组成 JWT 结构的第一部分。
一般我们都会使用默认标头:
{
"alg": "HS256",
"typ": "JWT"
}
2.负载
令牌的第二部分是有效负载(Payload),其中包含声明。声明是有关实体(通常是用户)和其他数据的声明。同样的,它会使用 Base64 编码组成 JWT 结构的第二部分
这一部分,我们通常会存储一些项目业务所需要的使用的普遍属性
{
"user_id": "1",
"name": "tom",
"organization_id": 1
}
3.签名
令牌的第三部是签名(Signature)签名 需要使用编码后的 header 和 payload 以及我们提供的一个密钥,然后使用 header 中指定的签名算法(HS256)进行签名。签名的作用是防止JWT篡改
例如这样:HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret);
公式:加密算法(标头base64编码+“.”+负载base64编码,密钥)
签名目的:
最后一步签名的过程,实际上是对头部以及负载内容进行签名,防止内容被窜改。如果有人对头部以及负载的内容解码之后进行修改,再进行编码,最后加上之前的签名组合形成新的JWT的话,那么服务器端会判断出新的头部和负载形成的签名和JWT附带上的签名是不一样的。如果要对新的头部和负载进行签名,在不知道服务器加密时用的密钥的话,得出来的签名也是不一样的。
4.JWT使用注意事项
前边也说过 标头(Header)和负载(Payload)是用base64进行编码的,而Base64是可逆的,这就意味着,其可以被外部解码!!!!那么我的信息不是暴露了??这不是很沙雕吗??
确实,JWT中的Header和Payload 可被反编,我不往其中存敏感信息不好了吗,这不是虎吗?我只存个user_id或者user_nick_name,就算被反编码知道了,那又怎样呢???他又能从这无关紧要的属性中推测出啥呢???就像你知道我叫神威马超
或者我叫玉麒麟卢俊义
,我不违法乱纪你能咋地!
官网也警示了!不要在JWT中填充敏感信息!!!!
请一定要注意:不要在JWT中填入敏感信息!!!
请一定要注意:不要在JWT中填入敏感信息!!!
请一定要注意:不要在JWT中填入敏感信息!!!
强调+∞∞∞∞∞
6.springboot整合JWT
1.依赖引入
我们要使用jwt呢,肯定需要引入其相应的依赖
<!--引入jwt-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.5.0</version>
</dependency>
2.小试牛刀
接下来,咱们就先来简单使用一下jwt
1.构建JWT
首先,咱们来编写一个demo
我们构建jwt 只需使用其JWT.create()
方法,一直Build我们的参数即可
header
:可不写,其有一个默认参数,当然您也可自行更改,详见其JWT.create()
方法
withClaim
:方法即为我们的负载(payload)可多次build
withSubject
: 也是填充负载的一种方式
sign
:即我们的签名方式,起中班包含加密密钥,sign一定是要在最后一个build的
@Test
void contextLoads() {
String jwt = JWT.create()
.withClaim("username", "王二麻子")
.sign(Algorithm.HMAC256("secret"));
System.out.println(jwt);
}
执行后呢,拿到了一个jwt字符串
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6IjExMSJ9.2g5robitasDRAv4RKEOwn6O7VGbpARjT06zistUGfCI
我们使用在线jwt解析,将我们jwtcopy进去看看
成功获取到了jwt的header以及 payload,这呢,也再次证明和警示了我们,不要将用户敏感信息存入JWT负载中…
2.JWT校验
我们服务器呢,亦可对JWT进行校验
我们需要根据JWT构建时的密钥进行生成一个解析对象JWTVerifier
然后将调用解析对象的verify
方法,将我们的JWT传入进去,生成一个JWT解码对象
JWT解码对象调用不同的方法拿到对应的值…
例如调用getClaim("负载中某一字段名")
可拿到某具体字段的值
也可调用getClaims()
拿到所有负载内容,返回值为map
@Test
void verifyJWT() {
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("secret")).build();
DecodedJWT decodedJWT = jwtVerifier.verify("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6IueOi-S6jOm6u-WtkCJ9.Q71pvsTR5TYFePJFIMLvCbO8MGnQ0oiSJNCuSs1JiM4");
String payload = decodedJWT.getPayload();
System.out.println("负载:" + payload);
System.out.println("标头:" + decodedJWT.getHeader());
System.out.println("签名:" + decodedJWT.getSignature());
System.out.println("------");
Map<String, Claim> payloads = decodedJWT.getClaims();
payloads.forEach((k,v)-> System.out.println( k + ":" + v.asString()));
}
注意的点:拿到负载字段后,其值时需要强转类型的,比如你原来某一键值对是"username":“zs”,那么你需要as为String… 如果你有键值对为"age": 1,那么你需要对其值asInt…以此类推…需要严格的对应字段值才能成功正确的获取到…否则拿不到真正的值
例如,我之前的是"“username”, "王二麻子”, 那么我拿到值需要再asString…如果类型不对,是无法获取到正确的值…
如此,校验这一步也基本走通了
3.JWT过期时间制定
事实上,我们会对服务器token做一个过期限制,比如十二小时自动过期或者每天凌晨自动过期等等…
JWT呢,其构造方法默认即可设置构造的时间
@Test
void contextLoads() {
String jwt = JWT.create()
.withClaim("username", "admin")
.withExpiresAt(new Date(System.currentTimeMillis()+60000))
.sign(Algorithm.HMAC256("secret"));
System.out.println(jwt);
}
我这里呢,是设置了构造后的一分钟的过期,那么此token仅仅只有一分钟的有效时间
我们来测试一下
丢到JWT解析器中,发现其负载中新增了一个exp字段,其值便是我们这个TOKEN过期时间点的时间戳(秒单位)…
时隔一会,代码解析JWT 报错JWT超时异常
3.正式整合
1.连接数据库
用的是mysql数据库,mybatisplus ORM框架
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/test?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: root
用户表实体
@Data
@TableName("user")
public class User {
private Integer id;
private String username;
private String password;
}
@Data
public class UserSub {
private Integer id;
private String username;
}
2.JWT封装工具类
我们不可能每次都对jWT做如此复杂的操作,为了高效开发以及复用,我们需要对JWT的使用做一定的代码封装
注意:这里的Jwt构建与解析与上方展示方式做了一些微调,主要是针对于payLoad,一般生产情况我们会直接将一个json格式的数据(注意不要填充敏感感信息)直接填充至payload,解析时拿到payLoad反序列化为对象
package com.leilei.jwt;
import com.alibaba.fastjson.JSON;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.leilei.entity.UserSub;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Date;
/**
* @author lei
* @version 1.0
* @date 2020/11/28 17:43
* @desc
*/
@Service
public class JwtSupport {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expireTime}")
private Long expireTime;
/**
* 生成token
*
* @param payload 载荷
* @return 返回token
*/
public String buildToken(UserSub payload) {
JWTCreator.Builder jwt = JWT.create();
jwt.withSubject(JSON.toJSONString(payload));
jwt.withExpiresAt(new Date(System.currentTimeMillis() + expireTime * 60 * 60 * 1000));
return jwt.sign(Algorithm.HMAC256(secret));
}
/**
* 验证token
*
* @param token
* @return
*/
public void verify(String token) {
JWT.require(Algorithm.HMAC256(secret)).build().verify(token);
}
/**
* 获取token中payload
*
* @param jwt
* @return
*/
public UserSub parseJwt(String jwt) {
DecodedJWT decodedJwt = JWT.require(Algorithm.HMAC256(secret)).build().verify(jwt);
String subject = decodedJwt.getSubject();
return JSON.parseObject(subject, UserSub.class);
}
}
yml新增配置
jwt:
# 密钥 后续对密码配置需要加密
secret: 'lei#ae86..'
#token过期时间 单位小时
expireTime: 12
这里也要注意:我们的密钥后续需要做一些安全配置,例如加密,使用启动参数覆盖等等,一定要保护密钥的安全,不然JWT加密就成了空壳…
接下来我们简单的模拟一下登录
public AjaxResult login(User user) {
User existUser = userMapper.selectOne(new QueryWrapper<User>()
.lambda()
.eq(User::getUsername, user.getUsername())
.eq(User::getPassword, user.getPassword()));
if (existUser == null) {
return AjaxResult.error("账户或密码错误");
}
UserSub userSub = new UserSub();
BeanUtils.copyProperties(existUser, userSub);
return AjaxResult.success(jwtSupport.buildToken(userSub));
}
3.自定义校验注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AccPermission {
/**
* jwt校验,如果设置为false则表示接口不需要校验token合法性
* @return
*/
boolean jwt() default true;
}
4.自定义校验拦截器
package com.leilei.jwt;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.leilei.common.AjaxResult;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @author lei
* @version 1.0
* @date 2020/11/28 18:35
* @desc
*/
@Component
@Log4j2
public class JwtInterceptor implements HandlerInterceptor {
@Autowired
private JwtSupport jwtSupport;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
String url = request.getRequestURI();
HandlerMethod handlerMethod = (HandlerMethod) handler;
AccPermission annotation = handlerMethod.getMethod().getAnnotation(AccPermission.class);
if (annotation == null) {
annotation = handlerMethod.getMethodAnnotation(AccPermission.class);
//无校验注解,则仍默认校验JWT合法性
if (annotation == null) {
return true;
// return checkJWT(request,response,url);
}
}
//如果设置了jwt=false,则直接放行
if (!annotation.jwt()) {
return true;
}
//jwt=true,则开始校验
return checkJwt(request, response, url);
}
return true;
}
/**
* 校验JWT
*
* @param request
* @param response
* @param url
* @return
* @throws Exception
*/
public boolean checkJwt(HttpServletRequest request, HttpServletResponse response, String url) throws Exception {
String authToken = request.getHeader("Authorization");
if (StringUtils.isBlank(authToken)) {
return checkError(response, url);
}
jwtSupport.verify(authToken);
return true;
}
/**
* 校验失败
*
* @param response
* @param url
* @return
* @throws Exception
*/
public boolean checkError(HttpServletResponse response, String url) throws Exception {
response.setHeader("Content-Type", "application/json");
response.setCharacterEncoding("UTF-8");
log.error("当前接口-{}需要登录!", url);
response.getWriter().print(JSON.toJSONString(AjaxResult.error("Token校验失败,当前请求需要登录!!!", 401)));
return false;
}
}
5.web配置,定义放开拦截路径以及配置拦截器生效
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
/**
* 添加;拦截器 以及拦截 或者放行规则
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
//将自定义的拦截器添加进去 必须是使用方法获取,不可直接New 否则该拦截器中中无法注入Bean
registry.addInterceptor(getJwtInterceptor())
//拦截所有
.addPathPatterns("/**")
//排除路径 排除中的路径 不需要进行拦截
.excludePathPatterns("/login/**");
}
/**
* 定义方法获取自定义的 JWTInterceptor
* @return
*/
@Bean
public JWTInterceptor getJwtInterceptor() {
return new JWTInterceptor();
}
}
6.全局异常拦截
package com.leilei.config;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.leilei.common.AjaxResult;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* @author lei
* @create 2022-10-19 14:59
* @desc 全局异常拦截
**/
@RestControllerAdvice
public class ExceptionAdvice {
@ExceptionHandler(TokenExpiredException.class)
public AjaxResult tokenExpiredException() {
return AjaxResult.error("token已过期", -2);
}
@ExceptionHandler(JWTVerificationException.class)
public AjaxResult jwtVerificationException() {
return AjaxResult.error("token解析异常", -3);
}
}
7.controller 接口
现有接口如下,一个登录,一个测试,我们测试接口打上了自定的的注解AccPermission
,且对该url进行了拦截,因此,当前/test
url 需要携带登录后获取的token令牌
7.测试
1.未携带token 访问需要校验token的接口
…抛出需要登录信息
2.未携带token访问无需校验token的接口
…可正常访问
3.输入正确账户密码,拿到token
…成功拿到token信息,这里前端需要保存起来
4.用拿到的token访问需要验证token的接口
…可正常访问接口
5.更改token后访问需要校验token的接口
…抛出token错误,需要登录信息
6.重启项目后,用之前正确token访问需要校验token的接口
…token仍然有效,无需再次登录服务器,直到token过期为止
…
7.结语
从以上案例中,我们已然看出了JWT的强大之处…例如轻量级,无需每次查询数据库,凭证由客户端存储,项目重起服务端无需从新颁发凭证等等