啥是JWT,怎么玩?
什么是 JWT
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于在通信双方传递 以 json 对象的形式传递信息,信息经过数字签名,因此它是可以被验证和信任。JWT 可以使用密(通过 HMAC 算法生成)或使用 RSA 或 ECDSA 生成的公钥/私钥对来签名。
官网: https://JWT.io/
标准: https://tools.ietf.org/html/rfc7519
JWT令牌的优点:
- JWT 基于 json,非常方便解析。
- 可以在令牌中自定义丰富的内容,易扩展。
- 通过非对称加密算法及数字签名技术,JWT 防止篡改,安全性高。
- 资源服务使用 JWT 可不依赖认证服务即可完成授权。
缺点:JWT 令牌较长,占存储空间比较
JWT 能做什么
授权
这是使用JWT的最常见方案。一旦用户登录,每个后续请求将包括JWT,从而允许用户访问该令牌允许的路由,服务和资源。单点登陆是当今广泛使用JWT的一项功能,因为它的开销很小并且可以在不同的域中轻松使用。
信息交换
JSON Web Token是在各方之间安全地传输信息的好方法。因为可以对JWT进行签名(例如,使用公钥/私钥对),所以您可以确保发件人是他们所说的人。此外,由于签名是使用标头和有效负载计算的,因此您还可以验证内容是否遭到篡改。
为什么选择 JWT
基于传统的Session认证
我们知道,http 协议本身是一种无状态的协议,而这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,那么下一次请求时,用户还要再一次进行用户认证才行,因为根据 http 协议,我们并不能知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,告诉其保存为cookie,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这就是传统的基于 session 认证。
Session 认证存在的问题:
- 每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大
- 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。
- 因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。
- 在前后端分离系统中就更加痛苦,也就是说前后端分离在应用解耦后增加了部署的复杂性。
通常用户一次请求就要转发多次。如果用 session 每次携带 sessionid 到服务器,服务器还要查询用户信息。同时如果用户很多。这些信息存储在服务器内存中,给服务器增加负担。还有就是CSRF(跨站伪造请求攻 击)攻击,session 是基于 cookie 进行用户识别的,cookie 如果被截获,用户就会很容易受到跨站请求伪造的攻击。还有就是 sessionid 就是一个特征值,表达的信息不够丰富。不容易扩展。而且如果你后端应用是多节点部署。那么就需要实现 session 共享机制。不方便集群应用。
基于JWT认证
认证流程
- 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口。这一过程一般是一个HTTP POST 请求。建议的方式是通过 SSL 加密的传输(https协议),从而避免敏感信息被嗅探。
- 后端核对用户名和密码成功后,将用户的id等其他信息作为 JWT Payload(负载),将其与头部分别进行Base64编码拼接后签名,形成一个 JWT(Token)。形成的 JWT 就是一个形同 lll.zzz.xxx 的字符串。
- 后端将 JWT 字符串作为登录成功的返回结果返回给前端。前端可以将返回的结果保存在 localStorage 或 sessionStorage 上,退出登录时前端删除保存的 JWT 即可。
- 前端在每次请求时将 JWT 放入 HTTP Header 中的 Authorization 位。(解决XSS和XSRF问题) HEADER
- 后端检查是否存在,如存在验证 JWT 的有效性。例如,检查签名是否正确;检查 Token 是否过期;检查 Token 的接收方是否是自己(可选)。
- 验证通过后后端使用 JWT 中包含的用户信息进行其他逻辑操作,返回相应结果。
JWT优势
-
简洁(Compact): 可以通过 URL,POST 参数或者在 HTTP header 发送,因为数据量小,传输速度也很快
-
自包含(Self-contained):负载中包含了所有用户所需要的信息,避免了多次查询数据库
-
因为 Token 是以 JSON 加密的形式保存在客户端的,所以 JWT 是跨语言的,原则上任何 web 形式都支持。
-
不需要在服务端保存会话信息,特别适用于分布式微服务。
JWT 组成
JWT 本质上就是一个字符串它由三部分组成:
- 1.标头(Header)
- 2.有效载荷(Payload)
- 3.签名(Signature)
因此,JWT通常如下所示:xxxxx.yyyyy.zzzzz Header.Payload.Signature
头部(Header)
令牌头部通常由两部分组成:令牌的类型(即JWT)和所使用的签名算法,例如HMAC SHA256或RSA。它会使用 Base64 编码组成 JWT 的第一部分:
{
"alg": "HS256",
"typ": "JWT"
}
Base64 是一种基于 64 个可打印字符来表示二进制数据的表示方法。由于 2 的 6 次方等于 64,所以每 6 个比特为一个单元,对应某个可打印字符。三个字节有 24 个比特,对应于 4 个 Base64 单元,即 3 个字节需要用 4 个可打印字符来表示。JDK 中提供了非常方便的 BASE64Encoder 和 BASE64Decoder ,用它们可以非常方便的完成基于 BASE64 的编码和解码。
头部的 json 字符串进行 BASE64 编码,编码后的字符串如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
负载(Payload)
令牌的第二部分是负载,就是存放有效信息的地方,这些有效信息包含三个部分:
-
JWT标准声明(建议但不强制使用)
- iss: JWT 签发者
- sub: JWT 所面向的用户
- aud: 接收 JWT 的一方
- exp: JWT 的过期时间,这个过期时间必须要大于签发时间
- nbf: 定义在什么时间之前,该 JWT 都是不可用的.
- iat: JWT 的签发时间
- jti: JWT 的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
-
公共的声明
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密 -
私有的声明
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为 Base64 是对称解密的,意味着该部分信息可以解码为明文信息
这个指的就是自定义的声明。比如下面那个举例中的 name 都属于自定的声明。这些声明跟 JWT 标准规定的声明区别在于:- JWT 标准声明,接收方在拿到 JWT 之后,知道怎么对这些标准的声明进行验证
- 而私有的声明不会验证,除非明确告诉接收方要对这些声明进行验证以及规则才行
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
其中 sub、iat 是标准的声明, name 是自定义的声明(公共的或私有的)然后将其进行 Base64 编码,得到 JWT 的第二部分:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbWVzIiwiYWRtaW4iOnRydWV9
签名(signature)
JWT 的第三部分是一个签证信息,这个签证信息由三部分组成:
- header (Base64后的)
- payload (Base64后的)
- secret(盐,一定要保密)
这个部分需要 Base64 加密后的 header 和 Base64 加密后的 payload 使用 $.$ 连接组成的字符串,然后通过 header 中声明的加密方式进行加盐 secret 组合加密,然后就构成了 JWT 的第三部分:
8HI-Lod0ncfVDnbKIPJJqLH998duF9DSDGkx3gRPNVI
最后一步签名的过程,实际上是对头部以及负载内容进行签名,防止内容被窜改。如果有人对头部以及负载的内容解码之后进行修改,再进行编码,最后加上之前的签名组合形成新的 JWT 的话,那么服务器端会判断出新的头部和负载形成的签名和 JWT 附带上的签名是不一样的。如果要对新的头部和负载进行签名,在不知道服务器加密时用的密钥的话,得出来的签名也是不一样的。
将这三部分用.连接成一个完整的字符串,构成了最终的JWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.8HI-Lod0ncfVDnbKIPJJqLH998duF9DSDGkx3gRPNVI
注意: secret 是保存在服务器端的, JWT 的签发生成也是在服务器端的, secret 就是用来进行 JWT 的签发和 JWT 的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个 secret , 那就意味着客户端是可以自我签发 JWT 了。
使用 JWT
项目中引入 java 工具包 java-jwt 或 jjwt,这里我们使用 java-jwt。
引入依赖
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.11.0</version>
</dependency>
测试功能
随便在哪个工程中先测试一下能否正常使用 jwt 功能
生成 token
@Test
public void getToken() {
Calendar instance = Calendar.getInstance();
instance.add(Calendar.SECOND, 60);
// 生成令牌
String token = JWT.create()
// 自定义的声明
.withClaim("username", "张三")
.withClaim("userId", 1001)
// 有效时间
.withExpiresAt(instance.getTime())
// 签名算法
.sign(Algorithm.HMAC256("*%$(4541dsd"));
System.out.println(token);
}
根据令牌和签名解析数据
@Test
public void getInfo(){
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("*%$(4541dsd")).build();
DecodedJWT verify = jwtVerifier.verify("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDQ5MzA2NTAsInVzZXJJZCI6MTAwMSwidXNlcm5hbWUiOiLlvKDkuIkifQ.8fEeFx_zD2be-t7gGSLPF4GxgHGfhxkPr7tjqhqCaqo");
System.out.println(verify.getHeader());
System.out.println(verify.getPayload());
System.out.println(verify.getSignature());
System.out.println(verify.getClaim("username").asString());
System.out.println(verify.getClaim("userId").asInt());
}
封装 JWT 工具类
public class JWTUtils {
private static String SING = "token!Q@W3e4r";
/**
* 生成token
* @param map 传入payload
* @return 返回token
*/
public static String getToken(Map<String,String> map){
Calendar instance = Calendar.getInstance();
// 一周有效期
instance.add(Calendar.DAY_OF_WEEK,1);
// 创建 JWT builder
JWTCreator.Builder builder = JWT.create();
// payload
map.forEach((k,v)->{
builder.withClaim(k,v);
});
// 设置过期时间
builder.withExpiresAt(instance.getTime());
// 设置签名加密
String token = builder.sign(Algorithm.HMAC256(SING));
return token;
}
/**
* 验证token
* @param token
* @return
*/
public static void verify(String token){
JWT.require(Algorithm.HMAC256(TOKEN)).build().verify(token);
}
/**
* 获取token中payload
* @param token
* @return
*/
public static DecodedJWT getToken(String token){
return JWT.require(Algorithm.HMAC256(TOKEN)).build().verify(token);
}
}
SpringBoot 整合 JWT
创建 Spring Boot 工程
。。。
引入依赖
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MP -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.0</version>
</dependency>
<!-- mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.11.0</version>
</dependency>
修改工程配置
server.port=8989
spring.application.name=jwt
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.log4j2.Log4j2Impl
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.password=#&7415zgh
spring.datasource.url=jdbc:mysql://localhost:3306/security?serverTimezone=GMT%2B8
spring.datasource.username=root
数据库相关
pojo 层
@Data
public class Users {
private String id;
private String username;
private String password;
}
mapper 层
@Mapper
public interface UsersMapper extends BaseMapper<Users> {
}
后端向前端返回 token
result 是自定义的返回数据的数据结构,这里选择了返回 map。
用户登陆后,完成认证,服务器向前端返回 token:
@RestController
@Slf4j
public class JwtController {
@Resource
private UsersMapper mapper;
// 认证
@PostMapping("/user/login")
public Map<String,Object> login(Users user) {
System.out.println("前端传来的用户数据:"+user);
// 返回结果
Map<String,Object> result = new HashMap<>();
try {
// 查询数据库
QueryWrapper<Users> queryWrapper = new QueryWrapper<>();
HashMap<String, Object> queryMap = new HashMap<>();
queryMap.put("username", user.getUsername());
queryMap.put("password", user.getPassword());
queryWrapper.allEq(queryMap);
Users userDB = mapper.selectOne(queryWrapper);
// 认证失败
if(userDB == null)
throw new RuntimeException("没有此用户。请重新登录");
// 认证成功
Map<String, String> tokenClaimsMap = new HashMap<>(); //用来存放payload
tokenClaimsMap.put("id",userDB.getId());
tokenClaimsMap.put("username", userDB.getUsername());
String token = JWTUtils.getToken(tokenClaimsMap);
// 返回的数据
result.put("state",true);
result.put("msg","登录成功!");
result.put("token",token);
} catch (Exception e) {
// 认证失败,返回的数据
e.printStackTrace();
result.put("state","false");
result.put("msg",e.getMessage());
}
return result;
}
}
前端将 token 存储在 localStorage 或 sessionStorage
前端请求头中携带 token
字段名为:token
拦截请求验证 token
创建拦截器,拦截所有需要认证才能访问的资源:
public class JwtInterceptor implements HandlerInterceptor {
// 请求前到达之前拦截
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从请求头获取 token
String token = request.getHeader("token");
// token 验证失败后的返回信息
Map<String,Object> errResult = new HashMap<>();
try {
// JWT 工具包验证 token
JWTUtils.verify(token);
return true;
} catch (TokenExpiredException e) {
errResult.put("state", false);
errResult.put("msg", "Token已经过期!!!");
} catch (SignatureVerificationException e){
errResult.put("state", false);
errResult.put("msg", "签名错误!!!");
} catch (AlgorithmMismatchException e){
errResult.put("state", false);
errResult.put("msg", "加密算法不匹配!!!");
} catch (Exception e) {
e.printStackTrace();
errResult.put("state", false);
errResult.put("msg", "无效token~~");
}
// 认证失败,返回 json 提示信息
String json = new ObjectMapper().writeValueAsString(errResult);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
return false;
}
}
@Component
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JwtInterceptor()).
// 不拦截 /user/login 下的请求,因为它要完成认证返回token
excludePathPatterns("/user/login")
// 拦截其他所有请求
.addPathPatterns("/**");
}
}
访问受认证保护的资源
@GetMapping("/private/info")
public String info(HttpServletRequest request){
String token = request.getHeader("token");
DecodedJWT decodedJWT = JWTUtils.getToken(token);
System.out.println(decodedJWT.getClaim("username").asString());
System.out.println(decodedJWT.getClaim("userId").asInt());
return "这是一段私人信息。只有登录才能显示";
}