<font size=3 face="华文楷体"> 阿浪独白 </font>:<br>
<font size=2> 失踪人口回归,阿浪终于想起了账号密码。失踪的这段时间,潜心闭关,近日受欧阳飞 (<font color="green"> 不要管他是谁,他只为爱掉眼泪 </font>) 影响,偶得灵感,得此一作。</font>
<center size=2> 西门老仙,法力无边!!!</center>
# 前言
日常开发中,客户端与服务器通常采用 HTTP 协议进行通信,但 HTTP 是没有状态的,无法记录用户的身份信息和行为。
<font size=3 face="华文楷体"> 会话机制:</font></br>
<font size=2> 每次请求响应完成之后连接就断开了,下一次的请求需要重新连接,会话机制可以保存用户的身份信息和行为信息。</font></br>
<font size=2> 一个用户的所有请求操作都应该属于同一个会话,而另一个用户的所有请求操作则应该属于另一个会话。</font>
会话跟踪技术是一种在客户端与服务器间保持 HTTP 状态的解决方案,我们所熟知的有 Cookie + Session、URL 重写、Token 等。
Cookie 在浏览器保存 SessionID、Session 实际内容保存在服务端,目前的项目都是前后端分离 + 微服务,所以会面临 Session 共享问题,随着用户量的增多,开销就会越大。URL 重写又是通过明文传输,不安全容易被劫持。
<font> 想了解 Cookie 和 Sesson 可以看我这篇文章:《Web 开发两把刀:Cookie & Session 的社会主义兄弟情》</font>
Token 的优势:
- Token 支持跨域访问,Cookie 不可以跨域访问。
- Token 支持多平台,Cookie 只支持部分 web 端。
# What is JWT ?
JWT 的全称是 Json Web Token,是一种基于 JSON 的、用于在网络上声明某种主张的令牌(token)规范。
官方解释:</br>
JWT 由三部分组成:hand、payload、signature,各部分通过 ‘ . ’ 连接
<center>xxxx . yyyy . zzzz</center>
# 1、HEAD
头部是一个 JSON 对象,存储描述数据类型(JWT)和签名算法(HSA256、RSA256),通过 Base64UrlEncode 编码后生成 head 。
编码:
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9
解码:
{
"alg": "RS256",
"typ": "JWT"
}
# 2、PAYLOAD
负载存放一些传输的有效声明,可以使用官方提供的声明,也可以自定义声明。同样通过 Base64UrlEncode 编码后生成 payload。声明可以分为三种类型:
- Registered claims:</br>
官方预定义的、非强制性的但是推荐使用的、有助于交互的声明 (注意使用这些声明只能是三个字符)。
名称 | 作用 |
---|---|
iss (issuer) | 签发人 |
sub (subject) | 主题 |
aud (audience) | 受众 |
exp (expiration time) | 过期时间 |
nbf (Not Before) | 生效时间 |
iat (Issued At) | 签发时间 |
jti (JWT ID) | 编号 |
Public claims: </br>
保留给 JWT 的使用者自定义。但是需要注意避免使用 IANA JSON Web Token Registry 中定义的关键字。Private claims: </br>
保留给 JWT 的使用者自定义,用来传送传输双方约定好的消息。((—_—)是不是没搞懂 public claims 和 private claims 的区别,阿浪也不知道)
编码:
eyJhdWQiOiLopb_pl6jpmL_mtaoiLCJkYXRhIjoiXCLopb_pl6jpmL_mtapcIiIsImlzcyI6IkhBTkdIVUFfQURNSU4iLCJleHAiOjE2MjIzNjA4NDEsImlhdCI6MTYyMjM2MDg0MX0
解码:
{
"aud": "西门阿浪",
"data": "\"西门阿浪\"",
"iss": "HANGHUA_ADMIN",
"exp": 1622360841,
"iat": 1622360841
}
# 3、SIGNATURE
数据签名是 JWT 的核心部分,构成较为复杂,且无法被反编码。
HS256加密: | |
signature = HMACSHA256( base64UrlEncode(header) + "." +base64UrlEncode(payload), secret ); | |
RS256加密: | |
signature = RSASHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload), publicKey, privateKey) |
signature 可以选择对称加密算法或者非对称加密算法,常用的就是 HS256、RS256。
对称加密: 加密方和解密方利用同一个秘钥对数据进行加密和解密。
非对称加密: 加密方用私钥加密,并把公钥告诉解密方用于解密。
<font size=3 face="华文楷体">Base64Encode 和 Base64URLEncode 的区别 </font></br>
1、Base64 是一种用 64 个字符 [AZ,az,0~9,+,/] 来表示任意二进制数据的方法。
因为二进制文件包含很多无法显示和打印的字符,base64 编码后的文本数据可以在邮件正文、网页等直接显示。</br>
</br>
2、Token 有些场合可能会放到 URL (比如 api.example.com/?token=xxx)。Base64 有三个字符 +、/ 和 =, 在 URL 里面有特殊含义,所以要被替换掉:= 被省略、+ 替换成 -,/ 替换成_ ,因此 JWT 采用 Base64URLEncode。
# 4、JWT 执行逻辑
逻辑清晰明了,用户首次登陆时,通过传输账号密码验证身份,验证成功后,服务器生成 Token 响应用户。用户后续请求只需要传送 Token,服务器只需对 Token 进行校验来确认身份。
# 双 Token 保证 活跃用户
# 活跃用户
Token 用于身份认证时,如果有效期设置太长,泄露了会不安全。如果设置太短,用户频繁的重新登陆,程序员的祖坟有可能不保。那如何界定有效时间呢?这就要引入一个概念:用户的活跃性。
系统把用户分为活跃用户和不活跃用户,对于不活跃用户,token 过期后需要重新登陆,因为使用频率较低,token 失活后重新登陆,他的感受没有那么强烈。
活跃用户在 token 过期后,不应该直接登陆,而是要根据他的活跃时间来判定是否重新激活 token,当符合条件时,直接激活 Token,带给用户最好的体验。
若 token 有效期时长为 at,活跃用户时长计为 rt,且用户每次操作客户端后活跃时间都与之同步刷新。
- 当 rt == at 时
这种情况,当然可以确定属于活跃用户。在整个 token 的有效期用户都在操作,如果这时 token 失效让重新登陆,用户体验确实不好。
- 假设存在 rt > at 的情况
既然 rt 与 at 相等时,属于活跃用户,这种可以算是激进分子了。
- 当 rt < at 时
这种情况比较复杂,我们无法界定 rt 在 at 中所占比例为多少时属于活跃用户,而且我们也无法推测 token 失效后,用户啥时候再次请求,因此定义为不活跃用户。
# accessToken、refreshToken 两兄弟
用户首次登陆后,获得 accessToken (时长较短) 和 refreshToken (时间较长),每次请求判断 accessToken 是否过期。
当 access_token 过期后,判断 refreshToken 是否过期,若没过期,则通过 refreshToken 刷新获取新的 access_token,如果都过期,就需要重新登录了。
由活跃用户分析可知,当 rt >= at,用户在 at 时间内都是活跃的。设 accessToken 的有效期为用户的活跃时间 rt,当 rt <= refresh_Time,直接刷新 rt。所以可以认为 [accessToken 创建开始时间点 ,2 * accessToken 有效时长] 时间内用户是活跃的
建议:refreshToken 时间 >= 2 * accessToken 时间。
# auth0 大法
JWT 只是规范,就像 Java 中的接口,无法直接使用,需要一个实现规范的具体实现库。平时开发中较多使用 jjwt,据传 auth0 的底层实现效率更高。注意,auth0 不是 OAuth2,不要搞混了。
首先,加入 maven 依赖,最新版本就是 3.16.0。
<dependency> | |
<groupId>com.auth0</groupId> | |
<artifactId>java-jwt</artifactId> | |
<version>3.16.0</version> | |
</dependency> |
# HS256 算法
HS256 是对称加密算法,相对来说比较简单易上手,网上例子也很详尽,感兴趣可以自己查找资料。我们主要来看看非对称加密算法。
# RS256 算法
- 1、生成密钥对
想签发 Token,首先要生成 PublicKey 和 PrivateKey。JDK 的 java.security. interfaces 包提供了 RS 算法的密钥对类型。我们直接构建一个存方密钥对的 POJO 类。
public class RSA256Key {
private RSAPublicKey publicKey;
private RSAPrivateKey privateKey;
public RSA256Key() {
}
public RSA256Key(RSAPublicKey publicKey, RSAPrivateKey privateKey) {
this.publicKey = publicKey;
this.privateKey = privateKey;
}
}
***省略getter和setter***
然后写一个密钥生成的工具类,通过官方信息可知,密钥对的实例生成后可重复使用。因此,我打算采用单例的双重校验锁来控制密钥对象的生成。
如果并发量过大的话,自己可以加一个自定义线程池去生成。
//数字签名
public static final String KEY_ALGORITHM = "RSA";
//RSA密钥长度
public static final int KEY_SIZE = 1024;
//唯一的密钥实例
private static volatile RSA256Key rsa256Key;
/**
* 生成 公钥/私钥
*
* 由双重校验锁保证创建唯一的密钥实例,因此创建完成后仅有唯一实例。
* 当被JVM回收后,才会创建新的实例
* @return
* @throws NoSuchAlgorithmException
*/
public static RSA256Key generateRSA256Key() throws NoSuchAlgorithmException {
//第一次校验:单例模式只需要创建一次实例,若存在实例,不需要继续竞争锁,
if (rsa256Key == null) {
//RSA256Key单例的双重校验锁
synchronized(RSA256Key.class) {
//第二次校验:防止锁竞争中自旋的线程,拿到系统资源时,重复创建实例
if (rsa256Key == null) {
//密钥生成所需的随机数源
KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance(KEY_ALGORITHM);
keyPairGen.initialize(KEY_SIZE);
//通过KeyPairGenerator生成密匙对KeyPair
KeyPair keyPair = keyPairGen.generateKeyPair();
//获取公钥和私钥
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
rsa256Key = new RSA256Key();
rsa256Key.setPublicKey(publicKey);
rsa256Key.setPrivateKey(privateKey);
}
}
}
return rsa256Key;
}
单例的双重校验锁能够严格保证 RSAPublicKey 对象生成的唯一性,当线程们进入 generateRSA256Key () 方法验证实例对象为空时,最快的线程拿到锁资源,并阻塞后续线程。
KeyPairGenerator 是密钥生成的核心类,根据我们自定义的密钥长度 KEY_SIZE 来生成密钥。<font color="red"> 密钥生成创建 RSA256Key 实例对象时,此处有个坑 (当然是并发量足够大时),希望有大佬指点:虽然 synchronized 阻塞住了部分线程,但当 RSA256Key 实例化后还未赋值前,正巧有新线程刚检测 rsa256Key,直接跳到后续逻辑,因为密钥实例值为空报出异常 </font>
- 2、签发 Token
Token 的签发逻辑很简单,auth0 为我们封装的很好,只需要向 Algorithm 的静态方法 RSA256 传递私钥,通过 JWT 类内的 withXXX () 方法传参即可。
/**
* 签发Token
*
* withIssuer()给PAYLOAD添加一跳数据 => token发布者
* withClaim()给PAYLOAD添加一跳数据 => 自定义声明 (key,value)
* withIssuedAt() 给PAYLOAD添加一条数据 => 生成时间
* withExpiresAt()给PAYLOAD添加一条数据 => 保质期
*
* @param data
* @return
* @throws NoSuchAlgorithmException
*/
public static String creatTokenByRS256(Object data) throws NoSuchAlgorithmException {
//初始化 公钥/私钥
RSA256Key rsa256Key = SecretKeyUtil.generateRSA256Key();
//加密时,使用私钥生成RS算法对象
Algorithm algorithm = Algorithm.RSA256(rsa256Key.getPrivateKey());
return JWT.create()
//签发人
.withIssuer(ISSUER)
//接收者
.withAudience(data.toString())
//签发时间
.withIssuedAt(new Date())
//过期时间
.withExpiresAt(DateUtil.addHours(2))
//相关信息
.withClaim("data", JsonUtil.toJsonString(data))
//签入
.sign(algorithm);
}
</br>
- <font color="red"> 有个值得吐槽的一点(下面是对 auth0 的源码分析,不感兴趣的可以跳过)</font>
</br>
/**
* Creates a new Algorithm instance using SHA256withRSA. Tokens specify this as "RS256".
*
* @param key the key to use in the verify or signing instance.
* @return a valid RSA256 Algorithm.
* @throws IllegalArgumentException if the Key Provider is null.
* @deprecated use {@link #RSA256(RSAPublicKey, RSAPrivateKey)} or {@link #RSA256(RSAKeyProvider)}
*/
@Deprecated
public static Algorithm RSA256(RSAKey key) throws IllegalArgumentException {
RSAPublicKey publicKey = key instanceof RSAPublicKey ? (RSAPublicKey) key : null;
RSAPrivateKey privateKey = key instanceof RSAPrivateKey ? (RSAPrivateKey) key : null;
return RSA256(publicKey, privateKey);
}
/**
* Creates a new Algorithm instance using SHA256withRSA. Tokens specify this as "RS256".
*
* @param publicKey the key to use in the verify instance.
* @param privateKey the key to use in the signing instance.
* @return a valid RSA256 Algorithm.
* @throws IllegalArgumentException if both provided Keys are null.
*/
public static Algorithm RSA256(RSAPublicKey publicKey, RSAPrivateKey privateKey) throws IllegalArgumentException {
return RSA256(RSAAlgorithm.providerForKeys(publicKey, privateKey));
}
因为我们是使用的 RSAPublicKey 和 RSAPrivateKey 存储的密钥,而且两种类型都继承自 RSAKey,所以我们可以直接调用 RSA256 (RSAKey key),只需传入私钥,逻辑会自动为公钥赋 null,顺序调用第二个方法。
但是该方法标记了 @Deprecated,说明官方废除了这方法。我们只能直接调用第二个方法,所以传参需要我们自己指定 null 值,而且有些不了解 RS256 算法的人, 会同时传入公钥与私钥。
/**
* Creates a new Algorithm instance using SHA256withRSA. Tokens specify this as "RS256".
*
* @param keyProvider the provider of the Public Key and Private Key for the verify and signing instance.
* @return a valid RSA256 Algorithm.
* @throws IllegalArgumentException if the provided Key is null.
*/
public static Algorithm RSA256(RSAKeyProvider keyProvider) throws IllegalArgumentException {
return new RSAAlgorithm("RS256", "SHA256withRSA", keyProvider);
}
通过调用上面两个方法生成 RSAKeyProvider, 调入该方法,最终生成 Algorithm 对象。
- 3、校验 Token
校验与签发同样简单,只是通过 PublicKey 生成 Algorithm,因为我把加密解密都放在了服务端,省去了很多不必要的麻烦。
public static boolean verifierToken(String token) throws NoSuchAlgorithmException {
//获取公钥/私钥
RSA256Key rsa256Key = SecretKeyUtil.generateRSA256Key();
//根据密钥对生成RS256算法对象
Algorithm algorithm = Algorithm.RSA256(rsa256Key.getPublicKey());
System.out.println("PublicKey: " + rsa256Key.getPublicKey().getPublicExponent());
//解密时,使用gong钥生成算法对象
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer(ISSUER)
.build();
try {
//验证Token,verifier自动验证
DecodedJWT jwt = verifier.verify(token);
return true;
}catch (JWTVerificationException e){
log.error("Token无法通过验证! " + e.getMessage());
return false;
}
通过 JWTVerifier 对象可生成 DecodedJWT,如果想获取具体的 TOken 信息,可通过 DecodedJWT 获取。
对 auth0 底层实现感兴趣的同学,可以从 gitHub 上 clone 下来自己跑起来看一看。学一学别人源码中的设计模式和逻辑处理。
源码地址:github.com/auth0/java-jwt