在分布式微服务架构中,特别是跨域访问的情况下,通常会使用 JWT 技术来实现安全认证。
JSON Web Tokens 是一种开放的、行业标准的 RFC7519 规范,用于安全地表示双方之间的声明。
JWT 官网,JWT Introduction,Auth0 > JWT。
JWT 介绍及结构
学一门技术,优先阅读官方文档,才能系统地了解并理解其概念和应用。
JWT 简介
JWT 是 Json Web Token 的简写,是一种开放标准(RFC 7519),它定义了一种简洁独立的数据规范,用于在各方之间作为 JSON 对象安全地传输信息,这些信息可通过数字签名进行验签和信任。
JWT 可以使用密钥(如,HMAC 算法)或使用 RSA 或 ECDSA 的公钥/私钥对进行签名。
虽然 JWT 可以加密以在各方之间提供保密,但我们将专注于签名令牌。签名令牌可以验证其中包含的声明的完整性,而加密令牌则隐藏其他方的声明。当使用公钥/私钥对签名令牌时,签名还证明只有持有私钥的一方是签署它的一方。
JWT 使用场景
- Authorization:授权,这是 JWT 最常见的方案。一旦用户登录,后续每个请求都会包含 JWT,允许用户访问授予该令牌的路由、服务和资源。SSO(Single Sign On - 单点登录)是 JWT 广泛应用的场景,开销小,且能在不同的域中轻松使用;其它一些一次性验证场景,如邮件激活用户等。
- Information Exchange:信息交换,JSON Web 令牌是在各方之间安全传输信息的好方法。因为 JWT 可以签名。例如,使用 公钥/私钥对,可以确信息是持有私钥的人发送的。另外,签名是对头和消息体(有效负载)计算得到的,可以验证内容是否被篡改。
JWT 数据结构
JWT 数据结构由三部分组成,分别是:Header、Payload、Signature,使用点号分隔(.
),所以 JWT 通常显示如下格式:xxxxx.yyyyy.zzzzz。
Header:消息头
Header 通常由两部分组成:Token 类型,即 JWT,以及使用的签名算法,如 HMAC、SHA256 或 RSA。如下示例:{ "alg": "HS256", "typ": "JWT" }
然后对这串 header json 使用 Base64Url 编码
Payload:消息体
Payload 指有效负载(消息体),包含关于实体(通常为用户)和其他数据的声明。声明有三种类型:注册声明,公开声明,私人声明。- Registered claims:注册声明,一组预先定义的声明,非强制的,但建议提供一组有用的,可互操作的声明。其中包括:ISS(发行人),EXP(过期时间),SUB(主题)、AUD(受众)等。
注意:声明名称只有三个字符,因为 JWT 意味着紧溱。 - Public claims:公开声明,由使用 JWT 的人随意定义。但为了避免冲突,最好使用 IANA JSON Web Token 注册表中的声明名称,或者将其定义为包含在一个防止冲突命名空间的 URI。
- Private claims:私人声明,这是为了使用各方之间共享信息而创建的自定义声明。
payload 示例如下:
{ "sub": "1234567890", "name": "John Doe", "admin": true }
然后对这串 payload json 使用 Base64Url 编码。
注意:对于已签名的令牌,这些信息虽然可以防止被篡改,但任何人都可以读取。除非 JWT 是加密的,否则不要将敏感信息放在 Header 或 Payload 中。- Registered claims:注册声明,一组预先定义的声明,非强制的,但建议提供一组有用的,可互操作的声明。其中包括:ISS(发行人),EXP(过期时间),SUB(主题)、AUD(受众)等。
Signature:签名
要创建签名,必须获取已编码的 Header、已编码的 Payload,一个密钥,和在 Header 中指定的算法,并对其进行签名。
例如,如果使用 HMAC SHA256 算法,将按以下方式创建签名:HMACSHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload), secret)
签名用于验证消息是否在传输过程中被篡改,对于使用私钥签名的令牌,还可以验证 JWT 的发送者是否可信息。
JWT 数据组合
JWT 最终输出的是三个由点(.
) 分隔的Base64-URL 字符串,这些点可以在 HTML 和 HTTP 环境中轻松传递。
下面显示了一个JWT,包含了 Header 和 Payload 编码,并使用密钥签名:
可以使用 JWT.IO 调试器来解码、验证和生成 JWT,如下示例:
JWT 工作原理
在身份验证中,当用户使用其凭据成功登录时,将返回 JSON Web Token。 由于 Token 就是凭证,因此必须非常小心以防止出现安全问题,通常会对令牌设置过期时间。
每当用户想要访问受保护的路由或资源时,用户代理应该使用 Bearer 模式发送 JWT,Token 通常在 Authorization 头中。 如下所示:
Authorization: Bearer <token>
在某些情况下,可以是无状态授权机制。 服务器的受保护路由将在 Authorization 头中检查有效的 JWT,如果存在,则允许用户访问受保护的资源。 如果 JWT 包含必要的数据,则可以减少查询数据库以进行某些操作的需要。
如果在 Authorization 头中发送 Token,则跨域资源共享(CORS)将不会成为问题,因为它不使用 cookies。
下图显示了如何获取 JWT 并用于访问 API 或资源:
- 应用或客户端向受权服务器请求受权。
- 授权服务器向应用程序返回访问令牌(Token)。
- 应用程序使用访问令牌访问资源服务器中受保护资源。
- 资源服务器对令牌进行核验,包括对令牌自身的核验和向授权服务器请求核验。
注意:对于签名的令牌,令牌中包含的所有信息都是对外公开的,即使外界无法修改它,所以不应该将秘密信息放在令牌中。
关于授权,JSON Web Token 允许粒度安全性,即能够在令牌中指定一组特定权限,从而提高可调试性。
JWT 优点
先谈谈 JSON Web Tokens(JWT)与 Simple Web Tokens(SWT)和 Security Assertion Markup Language Tokens(SAML)相比的好处。
由于 JSON 比 XML 更简洁,所以它被编码时,它的大小也更小,使得 JWT 比 SAML 更紧凑。这使得JWT 成为在 HTML 和 HTTP 环境中传递的一个很好的选择。
在安全方面,SWT 只能使用 HMAC 算法通过共享密钥对称签名。但是,JWT 和 SAML 令牌可以使用 X.509 证书形式的公钥/私钥对进行签名。与简单的 JSON 签名相比,使用 XML 数字签名来签名 XML 而不会引入模糊的安全漏洞非常困难。
JSON 解析器在大多数编程语言中很常见,因为它们直接映射到对象。相反,XML 没有一个自然的文档映射到对象。这使得使用 JWT 比使用 SAML 断言更容易。
关于使用,JWT 用于互联网规模。这突出了在多个平台(尤其是移动平台)上客户端处理 JSON Web Token 的便利性。
JWT 缺点
- JWT 生成严重依赖于密钥和生成算法,并且是硬编码存中,或存在外部配置文件中,这样密钥增加了泄漏的风险,威胁系统安全。
- JWT 使用 Base64 编码,并没有加密,不参存储敏感数据,而 Session 信息存服务器,相对更安全。
- JWT 是无状态一次性的,一旦签发,无法中途废弃,只能重新签发,但旧的未过期,仍可使用。
- 若不控制 JWT 签名的有效载荷(payload)中的数据量,则数据可能非常大,增加网络开销,远比只是很短的字符串 sessionId 开销大得多。
JWT 安全使用
清除已泄漏的 Token:将 JWT 在服务端(Redis)存储一份,若发现令牌异常,则从服务端将此 Token 清除,当用户发起请求时,强制用户重新进行身份验证。这种处理方式相对就比较重了,与 JWT 轻量级验证有些违背,但也不失为一种可行的选择。
敏感操作增加二次验证,如手机验证码,扫二维码等方式,确认操作者是用户本人,若验证不通过,则终止操作,同时要求重新验证用户身份信息。
地域检查:检查用户请求所在的地域,若短时间内在多个地域活动,则终止当前请求,强制用户重新进行身份认证,签发新的 Token,并提醒(或要求)用户重置密码。
监控请求频率:如果 JWT 密钥被盗,攻击者伪造用户身份,可能会高频次对系统发送请求,以窃取用户数据。
可以监控单位时间内的用户请求次数,若超出阀值则认为异常,服务端终止请求并清除该用户的 Token ,转到认证中心对用户身份进行验证。
客户端环境检测:可以 Token 与设备的机器码进行绑定,并存储在服务端中,当客户端发起请求时,可以先校验机器码,如果不匹配则视为非法,终止请求。
将废弃的 JWT 加入黑名单:重新签发 JWT 后,将旧的未过期的 Token 加入黑名单(Redis)避免再次使用。
JWT 续签:最简单的方式是在每次请求时刷新 JWT,或计算剩余有效时间小于某个值时就刷新 JWT,在HTTP 请求时返回一个新的 JWT。此方法暴力且不优雅。
另一种方式是在 Redis 中为每个 JWT 设置过期时间,每次访问时刷新 JWT 的过期时间。
JWT 使用建议
- JWT 是无状态的,只有过期时间,无法主动使其失效,因此 Payload 中的 exp 过期时间不要设的太长。
- JWT 是无状态的,适用于无状态的 Rest API,适用于移动端,前后端分离的 Web 端。
- JWT 的 Base64Url 编码是为了 token 存在于 url 中, Base64Url 解码后是明文,不可存放敏感数据。
- 在服务端开启 HttpOnly,预防 XSS 攻击。
- 若要预防重放攻击,可以增加 jti(JWT ID)来作为唯一验证。
- JWT 最好用于一次性授权 Token 的设计,时效短。
- 在实际应用中,强烈建议走 HTTPS 协议,对 JWT 串进行二次加密。
使用 KeyProvider
通过使用 KeyProvider,可以在运行时更改用于验证令牌签名或为 RSA 或 ECDSA 算法签署新令牌的密钥。 这是通过实现 RSAKeyProvider 或 ECDSAKeyProvider 方法达到的:
- getPublicKeyById(String kid):在令牌签名验证期间调用,返回用于验证令牌的密钥。如果使用钥匙旋转,例如 JWK,它可以使用ID获取正确的旋转钥匙(或始终返回相同的钥匙)。
- getPrivateKey():在令牌签名期间调用,返回将用于签名 JWT 的密钥。
- getPrivateKeyId():在令牌签名期间调用,返回 getPrivateKey() 方法返回的密钥的 ID。此值优先于 jwtcreator.builder withkeyid(string) 方法中的设置。如果不需要设置 kid 值,请避免使用 KeyProvider 实例化算法。
下面的示例展示了如何使用 JWkstore,一个虚构的 JWK 集实现。对于使用 JWKS 的简单密钥旋转,请尝试 JWKS RSA Java 库。
final JwkStore jwkStore = new JwkStore("{JWKS_FILE_HOST}");
final RSAPrivateKey privateKey = //Get the key instance
final String privateKeyId = //Create an Id for the above key
RSAKeyProvider keyProvider = new RSAKeyProvider() {
@Override
public RSAPublicKey getPublicKeyById(String kid) {
//Received 'kid' value might be null if it wasn't defined in the Token's header
RSAPublicKey publicKey = jwkStore.get(kid);
return (RSAPublicKey) publicKey;
}
@Override
public RSAPrivateKey getPrivateKey() {
return privateKey;
}
@Override
public String getPrivateKeyId() {
return privateKeyId;
}
};
Algorithm algorithm = Algorithm.RSA256(keyProvider);
//Use the Algorithm to create and verify JWTs.
JWT 实现 java-jwt
在 JWT 官网的 Libraries 里可以看到分别为不同语言提供了 JWT 标准的实现库,甚至一种语言有多个类似的库,只是支持的签名算法和检验略有区别。
JWT 的 Java 实现有:Auth0 > java-jwt,jjwt,jose4j,参考库官网或 GitHub Wiki 学习对其使用。
添加依赖
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.8.1</version>
</dependency>
选择算法
算法定义如何对签名和验证令牌。
当使用 RSA 或 ECDSA 算法并且只需要对 JWT 进行签名时,可以通过传递空值来避免指定公钥。当只需要验证 JWT 时,也可以对私钥进行同样的操作。
使用静态密码或密钥:
//HMAC
Algorithm algorithmHS = Algorithm.HMAC256("secret");
//RSA
RSAPublicKey publicKey = //Get the key instance
RSAPrivateKey privateKey = //Get the key instance
Algorithm algorithmRS = Algorithm.RSA256(publicKey, privateKey);
创建并签署令牌
首先要创建一个 JWTCreator 实例,调用 JWT.create() 并使用 Builder 模式自定义需要加入到 Token 中的声明,最后调用 sign() 入传入 Algorithm 实例。
HS256 示例:
try {
Algorithm algorithm = Algorithm.HMAC256("secret");
String token = JWT.create()
.withIssuer("auth0")
.sign(algorithm);
} catch (JWTCreationException exception){
//Invalid Signing configuration / Couldn't convert Claims.
}
RS256 示例:
RSAPublicKey publicKey = //Get the key instance
RSAPrivateKey privateKey = //Get the key instance
try {
Algorithm algorithm = Algorithm.RSA256(publicKey, privateKey);
String token = JWT.create()
.withIssuer("auth0")
.sign(algorithm);
} catch (JWTCreationException exception){
//Invalid Signing configuration / Couldn't convert Claims.
}
验证令牌
首先调用 JWT.require() 创建 JWTVerifier 实例,并且传入 Algorithm 实例。如果需要验证令牌具有特定声明值,可使用 Builder 来定义。最后调用 verifier.verify(token)。
HS256 示例:
String token = "";
try {
Algorithm algorithm = Algorithm.HMAC256("secret");
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer("auth0")
.build(); //Reusable verifier instance
DecodedJWT jwt = verifier.verify(token);
} catch (JWTVerificationException exception){
//Invalid signature/claims
}
RS256 示例:
String token = "";
RSAPublicKey publicKey = //Get the key instance
RSAPrivateKey privateKey = //Get the key instance
try {
Algorithm algorithm = Algorithm.RSA256(publicKey, privateKey);
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer("auth0")
.build(); //Reusable verifier instance
DecodedJWT jwt = verifier.verify(token);
} catch (JWTVerificationException exception){
//Invalid signature/claims
}
如果 Token 含有无效的签名,或未满足声明要求,则会抛出:JWTVerificationException 异常。
时间校验
JWT Token 可能包含 DateNumber 类型字段,可用于验证:
- iat(Issued At):toke 签发时间,必须小于当前时间。
- exp(Expires At):token 过期时间,必须大于当前时间。
- nbf(Not Before):token 开始生效日期,在此日期之前不可用。
验证令牌时,将自动对时间进行验证,从而在值无效时引发 JWTverificationException 。如果上面的任何字段不存在,则在验证中不检验这些字段。
给 Token 指定一个额外有效期的窗口期(相当延长有时间),使用 JWTVerifier 生成器中的 acceptLeeway() 方法并传递一个正秒值。适用于上面列出的每一项。
过期的窗口期是最终实现是将当前时间往回推 N 秒,相对延长过期时间。
JWTVerifier verifier = JWT.require(algorithm)
.acceptLeeway(1) // 1 sec for nbf, iat and exp
.build();
还可以为的日期声明指定自定义值,只覆盖该声明的默认值。
JWTVerifier verifier = JWT.require(algorithm)
.acceptLeeway(1) //1 sec for nbf and iat
.acceptExpiresAt(5) //5 secs for exp
.build();
如果您需要在 lib/app 中测试此行为,请将 Verification 实例强制转换为 BaseVerification,以获得接受自定义 Clock 的verification.build() 方法的可见性。如下示例:
BaseVerification verification = (BaseVerification) JWT.require(algorithm)
.acceptLeeway(1)
.acceptExpiresAt(5);
Clock clock = new CustomClock(); //Must implement Clock interface
JWTVerifier verifier = verification.build(clock);
解码令牌
String token = "";
try {
DecodedJWT jwt = JWT.decode(token);
} catch (JWTDecodeException exception){
//Invalid token
}
如果 Token 存无效的语法,或不是 JSON ,则抛出 JWTDecodeException 异常。
java-jwt 示例
示例中使用 FastJson 和 HuTools 库。
@Test
public void jwtTest() throws Exception {
//签名算法
Algorithm algorithm = Algorithm.HMAC256("123456");
//创建并签名 Token
JWTCreator.Builder builder = JWT.create();
//发布时间
DateTime issueDateTime = DateUtil.date();
builder.withClaim("sub", "1234567890")
.withClaim("name", "John Doe")
.withIssuedAt(issueDateTime)//发布时间
.withExpiresAt(DateUtil.offsetSecond(issueDateTime, 10))//2秒过期
.withIssuer("Rocky");
String token = builder.sign(algorithm);
System.out.println(token);
//Token 解码
DecodedJWT jwt = JWT.decode(token);
System.out.println(JSON.toJSONString(jwt));
//Token 验证
Verification verification = JWT.require(algorithm);
JWTVerifier jwtVerifier = verification
.withIssuer("Rocky")
.acceptLeeway(2)//2秒时间窗口
.build();
DecodedJWT verify = jwtVerifier.verify(token);
System.out.println(JSON.toJSONString(verify));
}
JWT 实现 jjwt
JJWT 旨在成为最容易使用和理解的库,用于在 JVM 和 Android 上创建和验证 JSON Web Token(JWT)。
JJWT 是一个开源的纯 Java 实现,完全基于 JWT,JWS,JWE,JWK 和 JWA RFC 规范,该库由 Okta 的高级架构师 Les Hazlewood 创建,JJWT 还添加了一些不属于规范的便利扩展,例如 JWT 压缩和声明实施。
添加依赖
<!--jjwt-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- 或者添加以下依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.10.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.10.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.10.5</version>
<scope>runtime</scope>
</dependency>
<!-- 如果使用 RSASSA-PSS (PS256, PS384, PS512) 算法,则取消此注释
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.60</version>
<scope>runtime</scope>
</dependency>
-->
快速开始
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
// We need a signing key, so we'll create one just for this example. Usually
// the key would be read from your application configuration instead.
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
// jwt token
String jws = Jwts.builder().setSubject("Joe").signWith(key).compact();
断言 jwt
assert Jwts.parser().setSigningKey(key).parseClaimsJws(jws).getBody().getSubject().equals("Joe");
//捕抓 jwt 校验异常
try {
Jwts.parser().setSigningKey(key).parseClaimsJws(compactJws);
//OK, we can trust this JWT
} catch (JwtException e) {
//don't trust the JWT!
}
创建 Keys
SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256); //or HS384 or HS512
byte[] keyBytes = getSigningKeyFromApplicationConfiguration();
SecretKey key = Keys.hmacShaKeyFor(keyBytes);
KeyPair keyPair = Keys.keyPairFor(SignatureAlgorithm.RS256); //or RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512
创建 JWS
使用 Jwts.builder() 创建 wtBuilder 实例,可以使用 Builder 模式设置消息头,消息体(声明),签名算法,最后调用 compact() 方法。
String jws = Jwts.builder() // (1) .setSubject("Bob") // (2) .signWith(key) // (3) .compact(); // (4)
设置 Header,jjwt 默认会设置两个头信息,分别是
alg
和zip
。//设置头 String jws = Jwts.builder() .setHeaderParam("kid", "myKeyId") // ... etc ... // Header 实例 Header header = Jwts.header(); populate(header); //implement me String jws = Jwts.builder() .setHeader(header) // ... etc ... // Header Map Map<String,Object> header = getMyHeaderMap(); //implement me String jws = Jwts.builder() .setHeader(header) // ... etc ...
设置 Payload
- setIssuer: sets the iss (Issuer) Claim
- setSubject: sets the sub (Subject) Claim
- setAudience: sets the aud (Audience) Claim
- setExpiration: sets the exp (Expiration Time) Claim
- setNotBefore: sets the nbf (Not Before) Claim
- setIssuedAt: sets the iat (Issued At) Claim
- setId: sets the jti (JWT ID) Claim
String jws = Jwts.builder() .setIssuer("me") .setSubject("Bob") .setAudience("you") .setExpiration(expiration) //a java.util.Date .setNotBefore(notBefore) //a java.util.Date .setIssuedAt(new Date()) // for example, now .setId(UUID.randomUUID()) //just an example id //自定义 Claims 声明 String jws = Jwts.builder() .claim("hello", "world") //创建 Claims 实例 Claims claims = Jwts.claims(); populate(claims); //implement me String jws = Jwts.builder() .setClaims(claims) //创建 Claims Map Map<String,Object> claims = getMyClaimsMap(); //implement me String jws = Jwts.builder() .setClaims(claims) //设置算法密钥 String jws = Jwts.builder() .signWith(key) .compact(); //覆盖默认算法,如果设置的 RSA PrivateKey,JJWT默认自动选择 RS256算法 .signWith(privateKey, SignatureAlgorithm.RS512) .compact();
读取 JWS
使用 Jwts.parser() 方法创建 JwtParser 实例,指定 SecretKey 或 非对称 PublicKey 用于验证 JWS 签名,最后调用 parseClaimsJws(String) 方法。
解析(验证) JWS
Jws<Claims> jws; try { jws = Jwts.parser() // (1) .setSigningKey(key) // (2) .parseClaimsJws(jwsString); // (3) // we can safely trust the JWT catch (JwtException ex) { // (4) // we *cannot* use the JWT as intended by its creator }
验证密钥 Key
//使用单个密钥 Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(jwsString); //非对称签名,私钥签名,公钥验证 Jwts.parser() .setSigningKey(publicKey) .parseClaimsJws(jwsString);
如果想要使用不同的 Keys,就不要调用 setSigningKey() 方法,而是实现 SigningKeyResolver 接口,传入实例到 JwtParser。
SigningKeyResolver signingKeyResolver = getMySigningKeyResolver(); Jwts.parser() .setSigningKeyResolver(signingKeyResolver) // <---- .parseClaimsJws(jwsString);
继承 SigningKeyResolverAdapter 适配器,实现 resolveSigningKey(JwsHeader, Claims) 方法:
public class MySigningKeyResolver extends SigningKeyResolverAdapter { @Override public Key resolveSigningKey(JwsHeader jwsHeader, Claims claims) { // implement me } }
自定义压缩
JJWT 默认对生成的 jws 进行了压缩,压缩后的 jws 是非标准的 JWT,因此,创建和解析都需要使用 JJWT 库。
压缩 JWT
Jwts.builder()
.compressWith(CompressionCodecs.DEFLATE) // or CompressionCodecs.GZIP
解析 JWT 设置压缩解析器
CompressionCodecResolver ccr = new MyCompressionCodecResolver();
Jwts.parser()
.setCompressionCodecResolver(ccr)
自定义压缩实现
public class MyCompressionCodecResolver implements CompressionCodecResolver {
@Override
public CompressionCodec resolveCompressionCodec(Header header) throws CompressionException {
String alg = header.getCompressionAlgorithm();
CompressionCodec codec = getCompressionCodec(alg); //implement me
return codec;
}
}
自定 Base64
//编码
Encoder<byte[], String> base64UrlEncoder = getMyBase64UrlEncoder(); //implement me
String jws = Jwts.builder()
.base64UrlEncodeWith(base64UrlEncoder)
//解码
Decoder<String, byte[]> base64UrlDecoder = getMyBase64UrlDecoder(); //implement me
Jwts.parser()
.base64UrlDecodeWith(base64UrlEncoder)
其它参考
注意:本文归作者所有,未经作者允许,不得转载