title: JWT原理与基本使用
date: 2022-03-27 23:16:39
tags:
- jwt
前提
写这篇文章的原因是因为前段时间接手了一个项目,它并没有使用公司的统一的单点登录平台sso
,而是通过调用uim
接口来验证用户信息,并以JWT
的实现方式生成一段token存到客户端的localStorage
中,登录后的客户端每次携带该token
去请求后端,经由后端服务器自定义的filter
解析校验token
完成登录。
究其原因,可能目前sso
的实现方案符合cas
标准,需要利用浏览器的重定向功能,而app
全是接口交互,所以在系统需要兼容web
端与移动端的情况下,采用了JWT
来实现登录认证流程。之前我对于JWT
是一种半了解的状态,于是乎趁此机会浅学了一波JWT
的基本使用。
概念
JSON Web Token
简称JWT
,在https://jwt.io/
中,有一段对于JWT
的描述,大体意思为JWT
拥有一套开放的行业标准(RFC 7519
),里面详细介绍了JWT
的基本概念,Claims
的含义、布局和算法实现等。
JWT
是一种紧凑的Claims
声明格式,适用于空间受限的网络环境中传输,例如 HTTP
授权标头和 URI
查询参数。JWT
对Claims
编码并转化为 JSON [RFC7159]
对象传输,它被用作JWS[RFC 7515]
的有效载荷或者JWE[RFC 7516]
(加密后)的字符串。JWT
使用 JWS/JWE Compact
序列化,使用Message Authentication Code (MAC)
and/or 加密手段对claims签名并提供完整性保护。
以上概念摘自规范性文件RFC7519,对于文中提到了JWS
、JWE
简单描述如下:
JWS
(祥见RFC 7515):JSON Web Signature
,使用基于JSON
的数据结构的数字签名或消息身份验证码(MAC),对传输的Claims提供了完整性保护(Claims
内容不被篡改,但会暴露明文)。
JWE
(详见RFC 7516):JSON Web Encryption
,使用基于 JSON
的数据结构的加密内容、这使得Claims
在传输过程中被破解的难度提高。
目前已知的主流框架大都未实现JWE
,例如JJWT、auth0
,故下文主要以JWS
实现方式展开讨论。
Currently Unsupported Features(当前不支持的功能)
Non-compact serialization and parsing.
JWE (Encryption for JWT)
JWT布局
JWS
的紧凑布局定义为:
BASE64URL(UTF8(JWS Protected Header)) || '.' ||
BASE64URL(JWS Payload) || '.' ||
BASE64URL(JWS Signature)
可以看出JWT
由三个部分组成,并且各个部分分别使用Base64url编码,然后以句点连接,他的表现形式如下:
const token = base64urlEncoding(header) + '.' + base64urlEncoding(payload) + '.' + base64urlEncoding(signature)
此外还有非紧凑布局,将header
、payload
、signature
组合成一个json形式展示,此次不展开讨论(有关紧凑布局、非紧凑布局的概念详见JWS JSON
序列化概述)
header
JWT
中的header又称Jose header
,包含描述加密操作和对象签名的参数。下方是JWT
中常用的header
字段:
code(简拼) | name(全拼) | description(描述) |
---|---|---|
typ | Token type | token类型(JWT、JWS、JWE) |
cty | Content type | payload部分的MediaType(使用嵌套签名或加密,建议将其设置为JWT) |
alg | Message authentication code algorithm | 加解密算法 |
kid | Key ID | 算法密钥 |
x5c | x.509 Certificate Chain | x509证书链(用于服务器验证签名是否有效以及令牌是否真实) |
x5u | x.509 Certificate Chain URL | x509证书链的URL(服务器将检索并使用此信息来验证签名是否真实) |
crit | Critical | 用作实现定义的扩展,以便接受有效的令牌 |
payload
payload
部分其实就是一个完整的Claims
,而Claims
本质上是一个JSON
字符串,我们会以k-v
的形式去定义它。JWT
规范中定义了内置的一些Claims
属性,我们可以选用或者自定义一些业务特定的Claims
(当然不能和内置的Claims
发生冲突),由于payload部分在JWS
中仅作base64编码,即明文是直接暴露在外面的,所以自定义Claims
的内容不能涉敏。
JWT
中预定义的Claims
:
code(简拼) | name(全拼) | description(描述) |
---|---|---|
iss | Issuer | 确定发布JWT的负责人 |
sub | Subject | 确定JWT的主体 |
aud | Audience | 标识JWT的目标收件人 |
exp | Expiration Time | 过期时间 |
nbf | Not Before | 确定JWT开始接受处理的时间。该值必须是NumericDate |
iat | Issued at | 确定发布JWT的时间。该值必须是NumericDate |
jti | JWT ID | 令牌的唯一标识符(区分大小写) |
Signature
JWS
生成签名依赖特定的签名算法将header、payload
部分进行一次签名加密,比较常见的如HS256(HMAC-SHA256)、RS256(RSA-SHA256)
。在base64UrlEncode(header).base64UrlEncode(payload).
之后拼上此次计算的签名(base64编码后)即为一个完整的java web token
。
以简单的HMAC-SHA256
为例(伪)
// 定义32位密钥
String secretKey = "11111111111111111111111111111111";
// header payload 为base64编码后的值
String content = header + "." + payload;
Mac mac = HmacUtils.getInitializedMac(HmacAlgorithms.HMAC_SHA_256,secretKey.getBytes(StandardCharsets.UTF_8));
// 签名
byte[] output = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
// base64得到最终签名结果
String signaturePart = new String(Base64.encodeBase64URLSafe(output), StandardCharsets.UTF_8);
JWT认证流程
由上图可知,整个JWT
的认证流程分为6步:
- 用户从浏览器携带用户名、密码等身份信息进行登录
- 服务端确认身份信息后,通过指定的签名算法生成
token
(不包含敏感信息) - 服务器将
JWT
返回给浏览器端 - 浏览器之后的请求中会把
token
携带在Authorization Header
中一起发给服务端 - 服务器验证
JWT
(包含签名以及特定的Claims
属性) - 服务器将验证结果返回给浏览器
JWT生成解析流程
基于前面对JWT
的认知,我们可以通过硬编码的方式,实现一套JWT(JWS)
的生成、解析、校验的流程。
引入common、json
相关包:
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>${latest-version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${latest-version}</version>
</dependency>
生成JWT
示例代码:
/**
* JWT签名采用HMAC SHA-256散列算法
* 为了简化开发 固定header内容
* 为例简化开发 固定claims
*
* @since 1.8
*/
public class JsonGenerator {
// 256bit 密钥
private static final String KEY = "11111111111111111111111111111111";
// 初始化序列化对象
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
// header
public static final Map<String,String> HEADER = new HashMap<>(4);
// payload
public static final Map<String,Object> CLAIMS = new HashMap<>(4);
static {
// 定义Token类型
HEADER.put("typ","JWT");
// 定义签名算法
HEADER.put("alg","HS256");
// 定义发行方
CLAIMS.put("iss", "iss");
// uuid
CLAIMS.put("jti", 1234567890L);
// 过期时间
CLAIMS.put("exp", 1648652266914L);
}
/**
* 编码header
*
* @return
* @throws JsonProcessingException
*/
String generateHeader() throws JsonProcessingException {
byte[] headerBytes = OBJECT_MAPPER.writeValueAsBytes(HEADER);
return new String(Base64.encodeBase64URLSafe(headerBytes), StandardCharsets.US_ASCII);
}
/**
* 编码payload
*
* @return
* @throws JsonProcessingException
*/
String generatePayload() throws JsonProcessingException {
byte[] payloadBytes = OBJECT_MAPPER.writeValueAsBytes(CLAIMS);
return new String(Base64.encodeBase64URLSafe(payloadBytes), StandardCharsets.UTF_8);
}
/**
* 对header、payload签名
*
* @param header
* @param payload
* @return
*/
String generateSignature(String header, String payload) {
String msg = header + "." + payload;
Mac mac = HmacUtils.getInitializedMac(HmacAlgorithms.HMAC_SHA_256, KEY.getBytes(StandardCharsets.UTF_8));
byte[] output = mac.doFinal(msg.getBytes(StandardCharsets.UTF_8));
return new String(Base64.encodeBase64URLSafe(output), StandardCharsets.UTF_8);
}
public static void main(String[] args) throws Exception {
JsonGenerator jsonGenerator = new JsonGenerator();
String header = jsonGenerator.generateHeader();
System.out.println("生成header部分:"+header);
String payload = jsonGenerator.generatePayload();
System.out.println("生成payload部分"+payload);
String signature = jsonGenerator.generateSignature(header, payload);
System.out.println("最终生成的token:"+ Stream.of(header,payload,signature).collect(Collectors.joining(".")));
}
}
运行main函数可以看到控制台输出:
生成header部分:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
生成payload部分eyJpc3MiOiJpc3MiLCJleHAiOjE2NDg2NTIyNjY5MTQsImp0aSI6MTIzNDU2Nzg5MH0
最终生成的token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpc3MiLCJleHAiOjE2NDg2NTIyNjY5MTQsImp0aSI6MTIzNDU2Nzg5MH0.ZbddEf9xTWJJwnCiDNIWs4t1QUgYsIo7cg1hH4SfM1U
可以将生成token
放到jwt.io解析验证
其实这个逆向过程很简单,只需要按分隔符"."取出编码后的header、payload
以及Signature
,在对header、payload
做base64解码即可,简单的代码实现如下:
public void parse(){
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpc3MiLCJleHAiOjE2NDg2NTIyNjY5MTQsImp0aSI6MTIzNDU2Nzg5MH0.ZbddEf9xTWJJwnCiDNIWs4t1QUgYsIo7cg1hH4SfM1U";
StringTokenizer tokenizer = new StringTokenizer(token, ".");
// 我们知道输入格式 所以这里简单写
String[] result = new String[]{tokenizer.nextToken(),tokenizer.nextToken(),tokenizer.nextToken()};
String header = new String(Base64.decodeBase64(result[0]), StandardCharsets.UTF_8);
String payload = new String(Base64.decodeBase64(result[1]), StandardCharsets.UTF_8);
System.out.println("解析后的header:"+header);
System.out.println("解析后的payload:"+payload);
System.out.println("签名部分:"+result[2]);
}
// 控制台输出
解析后的header:{"alg":"HS256","typ":"JWT"}
解析后的payload:{"iss":"iss","exp":1648652266914,"jti":1234567890}
签名部分:ZbddEf9xTWJJwnCiDNIWs4t1QUgYsIo7cg1hH4SfM1U
最后非常重要的一步就是对token
的完整与合法性校验。其一是验证上文解析出来的签名,其二是对Claims
的重要属性做验证(例如过期时间),伪代码实现如下:
public void verify() throws Exception{
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpc3MiLCJleHAiOjE2NDg2NTIyNjY5MTQsImp0aSI6MTIzNDU2Nzg5MH0.ZbddEf9xTWJJwnCiDNIWs4t1QUgYsIo7cg1hH4SfM1U";
StringTokenizer tokenizer = new StringTokenizer(token, ".");
// 我们知道输入格式 所以这里简单写
String[] result = new String[]{tokenizer.nextToken(),tokenizer.nextToken(),tokenizer.nextToken()};
String header = new String(Base64.decodeBase64(result[0]), StandardCharsets.UTF_8);
String payload = new String(Base64.decodeBase64(result[1]), StandardCharsets.UTF_8);
System.out.println("解析后的header:"+header);
System.out.println("解析后的payload:"+payload);
System.out.println("签名部分:"+result[2]);
// 校验签名合法性
String signature = generateSignature(result[0], result[1]);
if (!Objects.equals(signature,result[2])){
// 签名校验不通过 自定义异常处理
}
Map<String, Object> payloadMap = OBJECT_MAPPER.readValue(payload, new TypeReference<Map<String, Object>>() {});
long exp = Long.parseLong(Objects.toString(payloadMap.get("exp")));
// claims校验 过期时间有效期为1小时
if (System.currentTimeMillis() - exp > 60 * 60 * 1000) {
// 签名已过期 自定义异常处理
}
}
JJWT实现
上节的代码实现仅是简单的描述了JWT
从生成、解析再到校验的过程,使用的签名算法的安全性较低且方式过于粗暴。在实际应用中还是需要采用主流的JWT
框架,避免重复造轮子的同时,活跃的社区也能让问题快速的得到响应。
以开源项目JJWT
为例,生成JWT
private String createJWT(String id, String issuer, String subject, long ttlMillis) {
// 签名算法
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 定义密钥
String secretKey = "11111111111111111111111111111111";
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
// 设置Claims
JwtBuilder builder = Jwts.builder().setId(id)
.setIssuedAt(now)
.setSubject(subject)
.setIssuer(issuer)
.signWith(new SecretKeySpec(secretKey.getBytes(), "HmacSHA256"), signatureAlgorithm);
// 设置过期时间
if (ttlMillis >= 0) {
long expMillis = nowMillis + ttlMillis;
Date exp = new Date(expMillis);
builder.setExpiration(exp);
}
// 构建 JWT 并将其序列化为紧凑的 URL 安全字符串
return builder.compact();
}
解析校验token
private void parseJWT(String jwt) {
// 定义密钥
String secretKey = "11111111111111111111111111111111";
// 解析token
Claims claims = Jwts.parserBuilder()
.setSigningKey(new SecretKeySpec(secretKey.getBytes(), "HmacSHA256"))
.build()
// 解析payload部分
// 进行签名校验,失败会抛出SignatureException异常
// token失效过期会抛出ExpiredJwtException异常
.parseClaimsJws(jwt)
.getBody();
System.out.println("ID: " + claims.getId());
System.out.println("Subject: " + claims.getSubject());
System.out.println("Issuer: " + claims.getIssuer());
System.out.println("Expiration: " + claims.getExpiration());
}
总结
JWT
本质上是一种无状态的token
令牌,它设计的初衷就是更关注Claims
的完整性,任何拿到JWT
的客户端都可以无障碍的和服务器进行交互。这既是它的优势(支持跨域验证,可以应用于单点登录),同时也是它的劣势(JWS
只签名不加密,token
泄漏后会有安全问题),所以我们需要在算法选型时尽量选择复杂算法,严格校验Claims
中的属性并且Claims
中不能有敏感字段。
JWT
与CAS
不同的是,它的用户信息是以token
的形式存储在客户端,而非存储在服务端。对于过期刷新的问题,其实可以参考OAuth2
中refresh token
的概念。
参考