这是《冷饭新炒》系列的第五篇文章。html
本文会翻炒一个用以产生访问令牌的开源标准JWT
,介绍JWT
的规范、底层实现原理、基本使用和应用场景。java
很惋惜维基百科上没有搜索到JWT
的条目,可是从jwt.io
的首页展现图中,能够看到描述:git
JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two partiesgithub
从这段文字中能够提取到JWT
的规范文件RFC 7519
,里面有详细地介绍JWT
的基本概念,Claims
的含义、布局和算法实现等,下面逐个展开击破。web
JWT
全称是JSON Web Token
,若是从字面上理解感受是基于JSON
格式用于网络传输的令牌。实际上,JWT
是一种紧凑的Claims
声明格式,旨在用于空间受限的环境进行传输,常见的场景如HTTP
受权请求头参数和URI
查询参数。JWT
会把Claims
转换成JSON
格式,而这个JSON
内容将会应用为JWS
结构的有效载荷或者应用为JWE
结构的(加密处理后的)原始字符串,经过消息认证码(Message Authentication Code
或者简称MAC
)和/或者加密操做对Claims
进行数字签名或者完整性保护。redis
这里有三个概念在其余规范文件中,简单提一下:算法
JWE
(规范文件RFC 7516
):JSON Web Encryption
,表示基于JSON
数据结构的加密内容,加密机制对任意八位字节序列进行加密、提供完整性保护和提升破解难度,JWE
中的紧凑序列化布局以下BASE64URL(UTF8(JWE Protected Header)) || '.' || BASE64URL(JWE Encrypted Key) || '.' || BASE64URL(JWE Initialization Vector) || '.' || BASE64URL(JWE Ciphertext) || '.' || BASE64URL(JWE Authentication Tag)
JWS
(规范文件RFC 7515
):JSON Web Signature
,表示使用JSON
数据结构和BASE64URL
编码表示通过数字签名或消息认证码(MAC
)认证的内容,数字签名或者MAC
可以提供完整性保护,JWS
中的紧凑序列化布局以下:ASCII(BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)) || '.' || BASE64URL(JWS Signature)
JWA
(规范文件RFC 7518
):JSON Web Algorithm
,JSON Web
算法,数字签名或者MAC
算法,应用于JWS
的可用算法列表以下:总的来讲,JWT
其实有两种实现,基于JWE
实现的依赖于加解密算法、BASE64URL
编码和身份认证等手段提升传输的Claims
的被破解难度,而基于JWS
的实现使用了BASE64URL
编码和数字签名的方式对传输的Claims
提供了完整性保护,也就是仅仅保证传输的Claims
内容不被篡改,可是会暴露明文。目前主流的JWT
框架中大部分都没有实现JWE
,因此下文主要经过JWS
的实现方式进行深刻探讨。spring
Claim
有索赔、声称、要求或者权利要求的含义,可是笔者以为任一个翻译都不怎么合乎语义,这里保留Claim
关键字直接做为命名。JWT
的核心做用就是保护Claims
的完整性(或者数据加密),保证JWT
传输的过程当中Claims
不被篡改(或者不被破解)。Claims
在JWT
原始内容中是一个JSON
格式的字符串,其中单个Claim
是K-V
结构,做为JsonNode
中的一个field-value
,这里列出经常使用的规范中预约义好的Claim
:shell
简称 | 全称 | 含义 |
---|---|---|
iss | Issuer | 发行方 |
sub | Subject | 主体 |
aud | Audience | (接收)目标方 |
exp | Expiration Time | 过时时间 |
nbf | Not Before | 早于该定义的时间的JWT 不能被接受处理 |
iat | Issued At | JWT 发行时的时间戳 |
jti | JWT ID | JWT 的惟一标识 |
这些预约义的Claim
并不要求强制使用,什么时候选用何种Claim
彻底由使用者决定,而为了使JWT
更加紧凑,这些Claim
都使用了简短的命名方式去定义。在不和内建的Claim
冲突的前提下,使用者能够自定义新的公共Claim
,如:数据库
简称 | 全称 | 含义 |
---|---|---|
cid | Customer ID | 客户ID |
rid | Role ID | 角色ID |
必定要注意,在JWS
实现中,Claims
会做为payload
部分进行BASE64
编码,明文会直接暴露,敏感信息通常不该该设计为一个自定义Claim
。
在JWT
规范文件中称这些Header
为JOSE Header
,JOSE
的全称为Javascript Object Signature Encryption
,也就是Javascript
对象签名和加密框架,JOSE Header
其实就是Javascript
对象签名和加密的头部参数。下面列举一下JWS
中经常使用的Header
:
简称 | 全称 | 含义 |
---|---|---|
alg | Algorithm | 用于保护JWS 的加解密算法 |
jku | JWK Set URL | 一组JSON 编码的公共密钥的URL ,其中一个是用于对JWS 进行数字签名的密钥 |
jwk | JSON Web Key | 用于对JWS 进行数字签名的密钥相对应的公共密钥 |
kid | Key ID | 用于保护JWS 进的密钥 |
x5u | X.509 URL | X.509 相关 |
x5c | X.509 Certificate Chain | X.509 相关 |
x5t | X.509 Certificate SHA-1 Thumbprin | X.509 相关 |
x5t#S256 | X.509 Certificate SHA-256 Thumbprint | X.509 相关 |
typ | Type | 类型,例如JWT 、JWS 或者JWE 等等 |
cty | Content Type | 内容类型,决定payload 部分的MediaType |
最多见的两个Header
就是alg
和typ
,例如:
{ "alg": "HS256", "typ": "JWT" }
主要介绍JWS
的布局,前面已经提到过,JWS
的紧凑布局以下:
ASCII(BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)) || '.' || BASE64URL(JWS Signature)
其实还有非紧凑布局,会经过一个JSON
结构完整地展现Header
参数、Claims
和分组签名:
{ "payload":"<payload contents>", "signatures":[ {"protected":"<integrity-protected header 1 contents>", "header":<non-integrity-protected header 1 contents>, "signature":"<signature 1 contents>"}, ... {"protected":"<integrity-protected header N contents>", "header":<non-integrity-protected header N contents>, "signature":"<signature N contents>"}] }
非紧凑布局还有一个扁平化的表示形式:
{ "payload":"<payload contents>", "protected":"<integrity-protected header contents>", "header":<non-integrity-protected header contents>, "signature":"<signature contents>" }
其中Header
参数部分能够参看上一小节,而签名部分能够参看下一小节,剩下简单提一下payload
部分,payload
(有效载荷)其实就是完整的Claims
,假设Claims
的JSON
形式是:
{ "iss": "throwx", "jid": 1 }
那么扁平化非紧凑格式下的payload
节点就是:
{ ...... "payload": { "iss": "throwx", "jid": 1 } ...... }
JWS
签名生成依赖于散列或者加解密算法,可使用的算法见前面贴出的图,例如HS256
,具体是HMAC SHA-256
,也就是经过散列算法SHA-256
对于编码后的Header
和Claims
字符串进行一次散列计算,签名生成的伪代码以下:
## 不进行编码 HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), 256 bit secret key ) ## 进行编码 base64UrlEncode( HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload) [256 bit secret key]) )
其余算法的操做基本类似,生成好的签名直接加上一个前置的.
拼接在base64UrlEncode(header).base64UrlEncode(payload)
以后就生成完整的JWS
。
前面已经分析过JWT
的一些基本概念、布局和签名算法,这里根据前面的理论进行JWT
的生成、解析和校验操做。先引入common-codec
库简化一些编码和加解密操做,引入一个主流的JSON
框架作序列化和反序列化:
<dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> <version>1.15</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.11.0</version> </dependency>
为了简单起见,Header
参数写死为:
{ "alg": "HS256", "typ": "JWT" }
使用的签名算法是HMAC SHA-256
,输入的加密密钥长度必须为256 bit
(若是单纯用英文和数字组成的字符,要32
个字符),这里为了简单起见,用00000000111111112222222233333333
做为KEY
。定义Claims
部分以下:
{ "iss": "throwx", "jid": 10087, # <---- 这里有个笔误,原本打算写成jti,后来发现写错了,不打算改 "exp": 1613227468168 # 20210213 }
生成JWT
的代码以下:
@Slf4j public class JsonWebToken { private static final String KEY = "00000000111111112222222233333333"; private static final String DOT = "."; private static final Map<String, String> HEADERS = new HashMap<>(8); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); static { HEADERS.put("alg", "HS256"); HEADERS.put("typ", "JWT"); } String generateHeaderPart() throws JsonProcessingException { byte[] headerBytes = OBJECT_MAPPER.writeValueAsBytes(HEADERS); String headerPart = new String(Base64.encodeBase64(headerBytes,false ,true), StandardCharsets.US_ASCII); log.info("生成的Header部分为:{}", headerPart); return headerPart; } String generatePayloadPart(Map<String, Object> claims) throws JsonProcessingException { byte[] payloadBytes = OBJECT_MAPPER.writeValueAsBytes(claims); String payloadPart = new String(Base64.encodeBase64(payloadBytes,false ,true), StandardCharsets.UTF_8); log.info("生成的Payload部分为:{}", payloadPart); return payloadPart; } String generateSignaturePart(String headerPart, String payloadPart) { String content = headerPart + DOT + payloadPart; Mac mac = HmacUtils.getInitializedMac(HmacAlgorithms.HMAC_SHA_256, KEY.getBytes(StandardCharsets.UTF_8)); byte[] output = mac.doFinal(content.getBytes(StandardCharsets.UTF_8)); String signaturePart = new String(Base64.encodeBase64(output, false ,true), StandardCharsets.UTF_8); log.info("生成的Signature部分为:{}", signaturePart); return signaturePart; } public String generate(Map<String, Object> claims) throws Exception { String headerPart = generateHeaderPart(); String payloadPart = generatePayloadPart(claims); String signaturePart = generateSignaturePart(headerPart, payloadPart); String jws = headerPart + DOT + payloadPart + DOT + signaturePart; log.info("生成的JWT为:{}", jws); return jws; } public static void main(String[] args) throws Exception { Map<String, Object> claims = new HashMap<>(8); claims.put("iss", "throwx"); claims.put("jid", 10087L); claims.put("exp", 1613227468168L); JsonWebToken jsonWebToken = new JsonWebToken(); System.out.println("自行生成的JWT:" + jsonWebToken.generate(claims)); } }
执行输出日志以下:
23:37:48.743 [main] INFO club.throwable.jwt.JsonWebToken - 生成的Header部分为:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 23:37:48.747 [main] INFO club.throwable.jwt.JsonWebToken - 生成的Payload部分为:eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9 23:37:48.748 [main] INFO club.throwable.jwt.JsonWebToken - 生成的Signature部分为:7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs 23:37:48.749 [main] INFO club.throwable.jwt.JsonWebToken - 生成的JWT为:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs 自行生成的JWT:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs
能够在jwt.io
上验证一下:
解析JWT
的过程是构造JWT
的逆向过程,首先基于点号.
分三段,而后分别进行BASE64
解码,而后获得三部分的明文,头部参数和有效载荷须要作一次JSON
反序列化便可还原各个部分的JSON
结构:
public Map<Part, PartContent> parse(String jwt) throws Exception { System.out.println("当前解析的JWT:" + jwt); Map<Part, PartContent> result = new HashMap<>(8); // 这里暂且认为全部的输入JWT的格式都是合法的 StringTokenizer tokenizer = new StringTokenizer(jwt, DOT); String[] jwtParts = new String[3]; int idx = 0; while (tokenizer.hasMoreElements()) { jwtParts[idx] = tokenizer.nextToken(); idx++; } String headerPart = jwtParts[0]; PartContent headerContent = new PartContent(); headerContent.setRawContent(headerPart); headerContent.setPart(Part.HEADER); headerPart = new String(Base64.decodeBase64(headerPart), StandardCharsets.UTF_8); headerContent.setPairs(OBJECT_MAPPER.readValue(headerPart, new TypeReference<Map<String, Object>>() { })); result.put(Part.HEADER, headerContent); String payloadPart = jwtParts[1]; PartContent payloadContent = new PartContent(); payloadContent.setRawContent(payloadPart); payloadContent.setPart(Part.PAYLOAD); payloadPart = new String(Base64.decodeBase64(payloadPart), StandardCharsets.UTF_8); payloadContent.setPairs(OBJECT_MAPPER.readValue(payloadPart, new TypeReference<Map<String, Object>>() { })); result.put(Part.PAYLOAD, payloadContent); String signaturePart = jwtParts[2]; PartContent signatureContent = new PartContent(); signatureContent.setRawContent(signaturePart); signatureContent.setPart(Part.SIGNATURE); result.put(Part.SIGNATURE, signatureContent); return result; } enum Part { HEADER, PAYLOAD, SIGNATURE } @Data public static class PartContent { private Part part; private String rawContent; private Map<String, Object> pairs; }
这里尝试用以前生产的JWT
进行解析:
public static void main(String[] args) throws Exception { JsonWebToken jsonWebToken = new JsonWebToken(); String jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs"; Map<Part, PartContent> parseResult = jsonWebToken.parse(jwt); System.out.printf("解析结果以下:\nHEADER:%s\nPAYLOAD:%s\nSIGNATURE:%s%n", parseResult.get(Part.HEADER), parseResult.get(Part.PAYLOAD), parseResult.get(Part.SIGNATURE) ); }
解析结果以下:
当前解析的JWT:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs 解析结果以下: HEADER:PartContent(part=HEADER, rawContent=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9, pairs={typ=JWT, alg=HS256}) PAYLOAD:PartContent(part=PAYLOAD, rawContent=eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9, pairs={iss=throwx, jid=10087, exp=1613227468168}) SIGNATURE:PartContent(part=SIGNATURE, rawContent=7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs, pairs=null)
验证JWT
创建在解析JWT
完成的基础之上,须要对解析出来的头部参数和有效载作一次MAC
签名,与解析出来的签名作校对。另外,能够自定义校验具体的Claim
项,如过时时间和发行者等。通常校验失败会针对不一样的状况定制不一样的运行时异常便于区分场景,这里为了方便统一抛出IllegalStateException
:
public void verify(String jwt) throws Exception { System.out.println("当前校验的JWT:" + jwt); Map<Part, PartContent> parseResult = parse(jwt); PartContent headerContent = parseResult.get(Part.HEADER); PartContent payloadContent = parseResult.get(Part.PAYLOAD); PartContent signatureContent = parseResult.get(Part.SIGNATURE); String signature = generateSignaturePart(headerContent.getRawContent(), payloadContent.getRawContent()); if (!Objects.equals(signature, signatureContent.getRawContent())) { throw new IllegalStateException("签名校验异常"); } String iss = payloadContent.getPairs().get("iss").toString(); // iss校验 if (!Objects.equals(iss, "throwx")) { throw new IllegalStateException("ISS校验异常"); } long exp = Long.parseLong(payloadContent.getPairs().get("exp").toString()); // exp校验,有效期14天 if (System.currentTimeMillis() - exp > 24 * 3600 * 1000 * 14) { throw new IllegalStateException("exp校验异常,JWT已通过期"); } // 省略其余校验项 System.out.println("JWT校验经过"); }
相似地,用上面生成过的JWT
进行验证,结果以下:
当前校验的JWT:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs 当前解析的JWT:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs 23:33:00.174 [main] INFO club.throwable.jwt.JsonWebToken - 生成的Signature部分为:7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs JWT校验经过
上面的代码存在硬编码问题,只是为了用最简单的JWS
实现方式从新实现了JWT
的生成、解析和校验过程,算法也使用了复杂程度和安全性极低的HS256
,因此在生产中并不推荐花大量时间去实现JWS
,能够选用现成的JWT
类库,如auth0
和jjwt
。
JWT
本质是一个令牌,更多场景下是做为会话ID
(session_id
)使用,做用是'维持会话的粘性'
和携带认证信息(若是用JWT
术语,应该是安全地传递Claims
)。笔者记得好久之前使用的一种Session ID
解决方案是由服务端生成和持久化Session ID
,返回的Session ID
须要写入用户的Cookie
,而后用户每次请求必须携带Cookie
,Session ID
会映射用户的一些认证信息,这一切都是由服务端管理,一个很常见的例子就是Tomcat
容器中出现的J(ava)SESSIONID
。与以前的方案不一样,JWT
是一种无状态的令牌,它并不须要由服务端保存,携带的数据或者会话的数据都不须要持久化,使用JWT
只须要关注Claims
的完整性和合法性便可,生成JWT
时候全部有效数据已经经过编码存储在JWT
字符串中。正因JWT
是无状态的,一旦颁发后获得JWT
的客户端均可以经过它与服务端交互,JWT
一旦泄露有可能形成严重安全问题,所以实践的时候通常须要作几点:
JWT
须要设置有效期,也就是exp
这个Claim
必须启用和校验JWT
须要创建黑名单,通常使用jti
这个Claim
便可,技术上可使用布隆过滤器加数据库的组合(数量少的状况下简单操做甚至能够用Redis
的SET
数据类型)JWS
的签名算法尽量使用安全性高的算法,如RSXXX
Claims
尽量不要写入敏感信息JWT
认证,须要进行短信、指纹等二次认证PS:身边有很多同事所在的项目会把JWT持久化,其实这违背了JWT的设计理念,把JWT当成传统的会话ID使用了
JWT
通常用于认证场景,搭配API
网关使用效果甚佳。多数状况下,API
网关会存在一些通用不须要认证的接口,其余则是须要认证JWT
合法性而且提取JWT
中的消息载荷内容进行调用,针对这个场景:
JWT
认证,这个场景在Spring Cloud Gateway
中须要自定义实现一个JWT
认证的WebFilter
URI
白名单集合,命中白名单则不须要进行JWT
认证,这个场景在Spring Cloud Gateway
中须要自定义实现一个JWT
认证的GlobalFilter
下面就Spring Cloud Gateway
和jjwt
,贴一些骨干代码,限于篇幅不进行细节展开。引入依赖:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Hoxton.SR10</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.2</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.2</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.2</version> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.18</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> </dependencies>
而后编写JwtSpi
和对应的实现HMAC256JwtSpiImpl
:
@Data public class CreateJwtDto { private Long customerId; private String customerName; private String customerPhone; } @Data public class JwtCacheContent { private Long customerId; private String customerName; private String customerPhone; } @Data public class VerifyJwtResultDto { private Boolean valid; private Throwable throwable; private long jwtId; private JwtCacheContent content; } public interface JwtSpi { /** * 生成JWT * * @param dto dto * @return String */ String generate(CreateJwtDto dto); /** * 校验JWT * * @param jwt jwt * @return VerifyJwtResultDto */ VerifyJwtResultDto verify(String jwt); /** * 把JWT添加到封禁名单中 * * @param jwtId jwtId */ void blockJwt(long jwtId); /** * 判断JWT是否在封禁名单中 * * @param jwtId jwtId * @return boolean */ boolean isInBlockList(long jwtId); } @Component public class HMAC256JwtSpiImpl implements JwtSpi, InitializingBean, EnvironmentAware { private SecretKey secretKey; private Environment environment; private int minSeed; private String issuer; private int seed; private Random random; @Override public void afterPropertiesSet() throws Exception { String secretKey = Objects.requireNonNull(environment.getProperty("jwt.hmac.secretKey")); this.minSeed = Objects.requireNonNull(environment.getProperty("jwt.exp.seed.min", Integer.class)); int maxSeed = Objects.requireNonNull(environment.getProperty("jwt.exp.seed.max", Integer.class)); this.issuer = Objects.requireNonNull(environment.getProperty("jwt.issuer")); this.random = new Random(); this.seed = (maxSeed - minSeed); this.secretKey = new SecretKeySpec(secretKey.getBytes(), "HmacSHA256"); } @Override public void setEnvironment(Environment environment) { this.environment = environment; } @Override public String generate(CreateJwtDto dto) { long duration = this.random.nextInt(this.seed) + minSeed; Map<String, Object> claims = new HashMap<>(8); claims.put("iss", issuer); // 这里的jti最好用相似雪花算法之类的序列算法生成,确保惟一性 claims.put("jti", dto.getCustomerId()); claims.put("uid", dto.getCustomerId()); claims.put("exp", TimeUnit.NANOSECONDS.toMillis(System.nanoTime()) + duration); String jwt = Jwts.builder() .setHeaderParam("typ", "JWT") .signWith(this.secretKey, SignatureAlgorithm.HS256) .addClaims(claims) .compact(); // 这里须要缓存uid->JwtCacheContent的信息 JwtCacheContent content = new JwtCacheContent(); // redis.set(KEY[uid],toJson(content),expSeconds); return jwt; } @Override public VerifyJwtResultDto verify(String jwt) { JwtParser parser = Jwts.parserBuilder() .requireIssuer(this.issuer) .setSigningKey(this.secretKey) .build(); VerifyJwtResultDto resultDto = new VerifyJwtResultDto(); try { Jws<Claims> parseResult = parser.parseClaimsJws(jwt); Claims claims = parseResult.getBody(); long jti = Long.parseLong(claims.getId()); if (isInBlockList(jti)) { throw new IllegalArgumentException(String.format("jti is in block list,[i:%d]", jti)); } long uid = claims.get("uid", Long.class); // JwtCacheContent content = JSON.parse(redis.get(KEY[uid]),JwtCacheContent.class); // resultDto.setContent(content); resultDto.setValid(Boolean.TRUE); } catch (Exception e) { resultDto.setValid(Boolean.FALSE); resultDto.setThrowable(e); } return resultDto; } @Override public void blockJwt(long jwtId) { } @Override public boolean isInBlockList(long jwtId) { return false; } }
而后是JwtGlobalFilter
和JwtWebFilter
的非彻底实现:
@Component public class JwtGlobalFilter implements GlobalFilter, Ordered, EnvironmentAware { private final AntPathMatcher pathMatcher = new AntPathMatcher(); private List<String> accessUriList; @Autowired private JwtSpi jwtSpi; private static final String JSON_WEB_TOKEN_KEY = "X-TOKEN"; private static final String UID_KEY = "X-UID"; private static final String JWT_ID_KEY = "X-JTI"; @Override public void setEnvironment(Environment environment) { accessUriList = Arrays.asList(Objects.requireNonNull(environment.getProperty("jwt.access.uris")) .split(",")); } @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); // OPTIONS 请求直接放行 HttpMethod method = request.getMethod(); if (Objects.nonNull(method) && Objects.equals(method, HttpMethod.OPTIONS)) { return chain.filter(exchange); } // 获取请求路径 String requestPath = request.getPath().value(); // 命中请求路径白名单 boolean matchWhiteRequestPathList = Optional.ofNullable(accessUriList) .map(paths -> paths.stream().anyMatch(path -> pathMatcher.match(path, requestPath))) .orElse(false); if (matchWhiteRequestPathList) { return chain.filter(exchange); } HttpHeaders headers = request.getHeaders(); String token = headers.getFirst(JSON_WEB_TOKEN_KEY); if (!StringUtils.hasLength(token)) { throw new BusinessException(BusinessErrorCode.TOKEN_ERROR.getCode(), "token is null"); } VerifyJwtResultDto resultDto = jwtSpi.verify(token); if (Objects.equals(resultDto.getValid(), Boolean.FALSE)) { throw new BusinessException(BusinessErrorCode.TOKEN_ERROR.getCode(), resultDto.getThrowable()); } headers.set(JWT_ID_KEY, String.valueOf(resultDto.getJwtId())); headers.set(UID_KEY, String.valueOf(resultDto.getContent().getCustomerId())); return chain.filter(exchange); } @Override public int getOrder() { return 1; } } @Component public class JwtWebFilter implements WebFilter { @Autowired private RequestMappingHandlerMapping requestMappingHandlerMapping; @Autowired private JwtSpi jwtSpi; private static final String JSON_WEB_TOKEN_KEY = "X-TOKEN"; private static final String UID_KEY = "X-UID"; private static final String JWT_ID_KEY = "X-JTI"; @Override public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) { // OPTIONS 请求直接放行 HttpMethod method = exchange.getRequest().getMethod(); if (Objects.nonNull(method) && Objects.equals(method, HttpMethod.OPTIONS)) { return chain.filter(exchange); } HandlerMethod handlerMethod = requestMappingHandlerMapping.getHandlerInternal(exchange).block(); if (Objects.isNull(handlerMethod)) { return chain.filter(exchange); } RequireJWT typeAnnotation = handlerMethod.getBeanType().getAnnotation(RequireJWT.class); RequireJWT methodAnnotation = handlerMethod.getMethod().getAnnotation(RequireJWT.class); if (Objects.isNull(typeAnnotation) && Objects.isNull(methodAnnotation)) { return chain.filter(exchange); } HttpHeaders headers = exchange.getRequest().getHeaders(); String token = headers.getFirst(JSON_WEB_TOKEN_KEY); if (!StringUtils.hasLength(token)) { throw new BusinessException(BusinessErrorCode.TOKEN_ERROR.getCode(), "token is null"); } VerifyJwtResultDto resultDto = jwtSpi.verify(token); if (Objects.equals(resultDto.getValid(), Boolean.FALSE)) { throw new BusinessException(BusinessErrorCode.TOKEN_ERROR.getCode(), resultDto.getThrowable()); } headers.set(JWT_ID_KEY, String.valueOf(resultDto.getJwtId())); headers.set(UID_KEY, String.valueOf(resultDto.getContent().getCustomerId())); return chain.filter(exchange); } }
最后是一些配置属性:
jwt.hmac.secretKey='00000000111111112222222233333333' jwt.exp.seed.min=360000 jwt.exp.seed.max=8640000 jwt.issuer='throwx' jwt.access.uris=/index,/actuator/*
笔者负责的API
网关使用了JWT
应用于认证场景,算法上使用了安全性稍高的RS256
,使用RSA
算法进行签名生成。项目上线初期,JWT
的过时时间都固定设置为7
天,生产日志发现该API
网关周期性发生"假死"现象,具体表现为:
Nginx
自检周期性出现自检接口调用超时,提示部分或者所有API
网关节点宕机API
网关所在机器的CPU
周期性飙高,在用户访问量低的时候表现平稳ELK
进行日志排查,发现故障出现时段有JWT
集中性过时和从新生成的日志痕迹排查结果代表JWT
集中过时和从新生成时候使用RSA
算法进行签名是CPU
密集型操做,同时从新生成大量JWT
会致使服务所在机器的CPU
超负载工做。初步的解决方案是:
JWT
生成的时候,过时时间添加一个随机数,例如360000(1小时的毫秒数) ~ 8640000(24小时的毫秒数)
之间取一个随机值添加到当前时间戳加7
天获得exp
值这个方法,对于一些老用户营销场景(老用户长时间没有登陆,他们客户端缓存的JWT
通常都已通过期)没有效果。有时候运营会经过营销活动唤醒老用户,大量老用户从新登陆有可能出现爆发性大批量从新生成JWT
的状况,对于这个场景提出两个解决思路:
JWT
时候,考虑延长过时时间,可是时间越长,风险越大API
网关所在机器的硬件配置,特别是CPU
配置,如今不少云厂商都有弹性扩容方案,能够很好应对这类突发流量场景主流的JWT
方案是JWS
,此方案是只编码和签名,不加密,务必注意这一点,JWS
方案是无状态而且不安全的,关键操做应该作多重认证,也要作好黑名单机制防止JWT
泄漏后形成安全性问题。JWT
不存储在服务端,这既是它的优点,同时也是它的劣势。不少软件架构都没法作到尽善尽美,这个时候只能权衡利弊。
参考资料:
(本文完 c-3-w e-a-20210219)