1. 认证 vs. 令牌认证
认证是应用确认用户身份的协议集合。传统应用通过会话cookie持久化身份,这依赖服务端存储会话ID,迫使开发者创建特定于服务器的会话存储或完全独立的会话存储层。
令牌认证解决了服务端会话ID无法解决的问题。与传统认证类似,用户提交可验证凭证,但收到的是令牌集而非会话ID。初始凭证可以是用户名/密码、API密钥,甚至来自其他服务的令牌。
1.1. 为什么选择令牌?
使用令牌替代会话ID能:
- 降低服务器负载
- 简化权限管理
- 为分布式/云基础设施提供更好工具
JWT主要通过无状态特性实现这些优势(下文详述)。令牌应用广泛,包括CSRF防护、OAuth2交互、会话ID等。多数标准未指定令牌格式,以下是典型的Spring Security CSRF令牌示例:
<input name="_csrf" type="hidden"
value="f3f42ea9-3104-4d13-84c0-7bcb68202f16"/>
提交表单时若缺少正确令牌,将收到错误响应。这是令牌的实用性体现,但上述示例是"哑令牌"——无法从令牌本身获取固有含义。而JWT正是解决这个问题的关键。
2. JWT包含什么?
JWT(发音"jot")是URL安全、编码、加密签名(有时加密)的字符串,可作为多种应用的令牌。以下是JWT作为CSRF令牌的示例:
<input name="_csrf" type="hidden"
value="eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJlNjc4ZjIzMzQ3ZTM0MTBkYjdlNjg3Njc4MjNiMmQ3MCIsImlhdCI6MTQ2NjYzMzMxNywibmJmIjoxNDY2NjMzMzE3LCJleHAiOjE0NjY2MzY5MTd9.rgx_o8VQGuDa2AqCHSgVOD5G68Ld_YYM7N7THmvLIKc"/>
此令牌明显更长。若提交表单时缺少令牌,同样会收到错误响应。为什么选择JWT?因为该令牌经过加密签名,可验证未被篡改,且编码了额外信息。
JWT由三个用点号(.
)分隔的部分组成:
- Header(头部)
- Payload(载荷)
- Signature(签名)
每部分都经过Base64 URL编码,确保URL安全使用。我们逐一分析:
2.1. 头部
解码头部得到JSON字符串:
{"alg":"HS256"}
表明JWT使用HMAC SHA-256算法签名。
2.2. 载荷
解码载荷得到JSON字符串(格式化后):
{
"jti": "e678f23347e3410db7e68767823b2d70",
"iat": 1466633317,
"nbf": 1466633317,
"exp": 1466636917
}
载荷中的键值对称为"声明",JWT规范定义了七个"注册声明":
iss
:签发者sub
:主题aud
:接收方exp
:过期时间nbf
:生效时间iat
:签发时间jti
:JWT ID
构建JWT时可添加自定义声明。示例中的CSRF令牌包含JWT ID、签发时间、生效时间和过期时间(比签发时间晚1分钟)。
2.3. 签名
签名通过以下伪代码生成:
computeHMACSHA256(
header + "." + payload,
base64DecodeToByteArray("4pE8z3PBoHjnV1AhvGk+e8h2p+ShZpOnpr8cwHmMh1w=")
)
只要知道密钥,就能生成签名并与JWT的签名部分对比验证完整性。技术上,加密签名的JWT称为JWS,加密的JWT称为JWE,但通常统称为JWT。
这解释了JWT作为CSRF令牌的优势:可验证签名并检查exp
声明确认有效性,无需服务端维护额外状态。
3. JJWT教程环境搭建
JJWT是端到端JWT创建和验证的Java库(Apache 2.0许可)。其构建器接口隐藏了大部分复杂性,主要操作包括构建和解析JWT。
本文代码示例可在GitHub仓库获取。使用Spring Boot简化API交互:
mvn clean spring-boot:run
应用暴露十个接口(使用httpie交互):
http localhost:8080
Available commands (assumes httpie - https://github.com/jkbrzt/httpie):
http http://localhost:8080/
本使用说明
http http://localhost:8080/static-builder
使用硬编码声明构建JWT
http POST http://localhost:8080/dynamic-builder-general claim-1=value-1 ... [claim-n=value-n]
使用传入声明构建JWT(通用声明映射)
http POST http://localhost:8080/dynamic-builder-specific claim-1=value-1 ... [claim-n=value-n]
使用传入声明构建JWT(特定声明方法)
http POST http://localhost:8080/dynamic-builder-compress claim-1=value-1 ... [claim-n=value-n]
使用传入声明构建DEFLATE压缩JWT
http http://localhost:8080/parser?jwt=<jwt>
解析传入JWT
http http://localhost:8080/parser-enforce?jwt=<jwt>
解析传入JWT并强制验证'iss'注册声明和'hasMotorcycle'自定义声明
http http://localhost:8080/get-secrets
显示当前使用的签名密钥
http http://localhost:8080/refresh-secrets
生成新签名密钥并显示
http POST http://localhost:8080/set-secrets
HS256=base64-encoded-value HS384=base64-encoded-value HS512=base64-encoded-value
显式设置应用使用的密钥
后续章节将分析这些接口及其JJWT代码实现。
4. 使用JJWT构建JWT
JJWT的流式接口使JWT创建分为三步:
- 定义令牌内部声明(签发者、主题、过期时间等)
- 加密签名JWT(生成JWS)
- 按JWT紧凑序列化规则压缩为URL安全字符串
最终JWT是三部分Base64编码字符串,使用指定算法和密钥签名。此时可与其他方共享令牌。
JJWT使用示例:
String jws = Jwts.builder()
.setIssuer("Stormpath")
.setSubject("msilverman")
.claim("name", "Micah Silverman")
.claim("scope", "admins")
// Fri Jun 24 2016 15:33:42 GMT-0400 (EDT)
.setIssuedAt(Date.from(Instant.ofEpochSecond(1466796822L)))
// Sat Jun 24 2116 15:33:42 GMT-0400 (EDT)
.setExpiration(Date.from(Instant.ofEpochSecond(4622470422L)))
.signWith(
SignatureAlgorithm.HS256,
TextCodec.BASE64.decode("Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E=")
)
.compact();
常见签名反模式
以下三种签名方式都是反模式:
.signWith(SignatureAlgorithm.HS256, "secret".getBytes("UTF-8"))
问题:密钥过短且非原生字节数组
.signWith(SignatureAlgorithm.HS256, "Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E=".getBytes("UTF-8"))
问题:直接转换Base64字符串而非解码
.signWith(SignatureAlgorithm.HS512, TextCodec.BASE64.decode("Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E="))
问题:HS512算法下密钥长度不足
示例代码中的SecretService
类确保使用算法强度的密钥。运行以下命令设置密钥:
http POST localhost:8080/set-secrets \
HS256="Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E=" \
HS384="VW96zL+tYlrJLNCQ0j6QPTp+d1q75n/Wa8LVvpWyG8pPZOP6AA5X7XOIlI90sDwx" \
HS512="cd+Pr1js+w2qfT2BoCD+tPcYp9LbjpmhSMEJqUob1mcxZ7+Wmik4AYdjX+DlDjmE4yporzQ9tm7v3z/j+QbdYg=="
访问/static-builder
接口:
http http://localhost:8080/static-builder
返回JWT:
eyJhbGciOiJIUzI1NiJ9.
eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIiwibmFtZSI6Ik1pY2FoIFNpbHZlcm1hbiIsInNjb3BlIjoiYWRtaW5zIiwiaWF0IjoxNDY2Nzk2ODIyLCJleHAiOjQ2MjI0NzA0MjJ9.
kP0i_RvTAmI8mgpIkDFhRX3XthSdP-eqqFKGcU92ZIQ
动态构建JWT
访问/dynamic-builder-general
接口:
http -v POST localhost:8080/dynamic-builder-general iss=Stormpath sub=msilverman hasMotorcycle:=true
注意:hasMotorcycle
使用:=
传递原始JSON值而非字符串。
返回JWT:
{
"jwt":
"eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIiwiaGFzTW90b3JjeWNsZSI6dHJ1ZX0.OnyDs-zoL3-rw1GaSl_KzZzHK9GoiNocu-YwZ_nQNZU",
"status": "SUCCESS"
}
后端代码:
@RequestMapping(value = "/dynamic-builder-general", method = POST)
public JwtResponse dynamicBuilderGeneric(@RequestBody Map<String, Object> claims)
throws UnsupportedEncodingException {
String jws = Jwts.builder()
.setClaims(claims)
.signWith(
SignatureAlgorithm.HS256,
secretService.getHS256SecretBytes()
)
.compact();
return new JwtResponse(jws);
}
类型安全验证
访问/dynamic-builder-specific
接口时传入错误类型:
http -v POST localhost:8080/dynamic-builder-specific iss=Stormpath sub:=5 hasMotorcycle:=true
返回错误:
{
"exceptionType": "java.lang.ClassCastException",
"message": "java.lang.Integer cannot be cast to java.lang.String",
"status": "ERROR"
}
增强版验证代码:
private void ensureType(String registeredClaim, Object value, Class expectedType) {
boolean isCorrectType =
expectedType.isInstance(value) ||
expectedType == Long.class && value instanceof Integer;
if (!isCorrectType) {
String msg = "Expected type: " + expectedType.getCanonicalName() +
" for registered claim: '" + registeredClaim + "', but got value: " +
value + " of type: " + value.getClass().getCanonicalName();
throw new JwtException(msg);
}
}
再次调用相同接口:
http -v POST localhost:8080/dynamic-builder-specific iss=Stormpath sub:=5 hasMotorcycle:=true
返回更明确的错误:
{
"exceptionType": "io.jsonwebtoken.JwtException",
"message":
"Expected type: java.lang.String for registered claim: 'sub', but got value: 5 of type: java.lang.Integer",
"status": "ERROR"
}
5. 使用JJWT解析JWT
访问/parser
接口:
http http://localhost:8080/parser?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIiwibmFtZSI6Ik1pY2FoIFNpbHZlcm1hbiIsInNjb3BlIjoiYWRtaW5zIiwiaWF0IjoxNDY2Nzk2ODIyLCJleHAiOjQ2MjI0NzA0MjJ9.kP0i_RvTAmI8mgpIkDFhRX3XthSdP-eqqFKGcU92ZIQ
返回解析结果:
{
"claims": {
"body": {
"exp": 4622470422,
"iat": 1466796822,
"iss": "Stormpath",
"name": "Micah Silverman",
"scope": "admins",
"sub": "msilverman"
},
"header": {
"alg": "HS256"
},
"signature": "kP0i_RvTAmI8mgpIkDFhRX3XthSdP-eqqFKGcU92ZIQ"
},
"status": "SUCCESS"
}
后端解析代码:
@RequestMapping(value = "/parser", method = GET)
public JwtResponse parser(@RequestParam String jwt) throws UnsupportedEncodingException {
Jws<Claims> jws = Jwts.parser()
.setSigningKeyResolver(secretService.getSigningKeyResolver())
.parseClaimsJws(jwt);
return new JwtResponse(jws);
}
密钥解析器
SecretService
中的密钥解析器:
private SigningKeyResolver signingKeyResolver = new SigningKeyResolverAdapter() {
@Override
public byte[] resolveSigningKeyBytes(JwsHeader header, Claims claims) {
return TextCodec.BASE64.decode(secrets.get(header.getAlgorithm()));
}
};
若篡改JWT(如删除签名最后一个字符),将返回:
{
"exceptionType": "io.jsonwebtoken.SignatureException",
"message":
"JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.",
"status": "ERROR"
}
6. 实战:Spring Security CSRF令牌
跨站请求伪造(CSRF)是恶意网站诱骗用户向已信任网站提交请求的漏洞。常见解决方案是同步令牌模式:在Web表单中插入令牌,服务器验证其有效性。
Spring Security内置同步令牌模式,配合Thymeleaf模板会自动插入令牌。默认令牌是"哑令牌",本节将其升级为JWT,增加防篡改和过期验证。
Spring Security配置
@Configuration
public class WebSecurityConfig {
private String[] ignoreCsrfAntMatchers = {
"/dynamic-builder-compress",
"/dynamic-builder-general",
"/dynamic-builder-specific",
"/set-secrets"
};
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf()
.ignoringAntMatchers(ignoreCsrfAntMatchers)
.and().authorizeRequests()
.antMatchers("/**")
.permitAll();
return http.build();
}
}
访问/jwt-csrf-form
查看表单源码:
<input type="hidden" name="_csrf" value="5f375db2-4f40-4e72-9907-a290507cb25e" />
自定义CSRF令牌仓库
修改Spring Security配置:
@Configuration
public class WebSecurityConfig {
@Autowired
CsrfTokenRepository jwtCsrfTokenRepository;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf()
.csrfTokenRepository(jwtCsrfTokenRepository)
.ignoringAntMatchers(ignoreCsrfAntMatchers)
.and().authorizeRequests()
.antMatchers("/**")
.permitAll();
return http.build();
}
}
配置仓库Bean:
@Configuration
public class CSRFConfig {
@Autowired
SecretService secretService;
@Bean
@ConditionalOnMissingBean
public CsrfTokenRepository jwtCsrfTokenRepository() {
return new JWTCsrfTokenRepository(secretService.getHS256SecretBytes());
}
}
自定义仓库实现:
public class JWTCsrfTokenRepository implements CsrfTokenRepository {
private byte[] secret;
public JWTCsrfTokenRepository(byte[] secret) {
this.secret = secret;
}
@Override
public CsrfToken generateToken(HttpServletRequest request) {
String id = UUID.randomUUID().toString().replace("-", "");
Date now = new Date();
Date exp = new Date(System.currentTimeMillis() + (1000*30)); // 30秒过期
String token;
try {
token = Jwts.builder()
.setId(id)
.setIssuedAt(now)
.setNotBefore(now)
.setExpiration(exp)
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
} catch (UnsupportedEncodingException e) {
token = id; // 降级处理
}
return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", token);
}
@Override
public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
// 实现省略
}
@Override
public CsrfToken loadToken(HttpServletRequest request) {
// 实现省略
}
}
添加JWT验证过滤器
在Spring Security配置中添加过滤器:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.addFilterAfter(new JwtCsrfValidatorFilter(), CsrfFilter.class)
.csrf()
.csrfTokenRepository(jwtCsrfTokenRepository)
.ignoringAntMatchers(ignoreCsrfAntMatchers)
.and().authorizeRequests()
.antMatchers("/**")
.permitAll();
return http.build();
}
验证过滤器实现:
private class JwtCsrfValidatorFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
CsrfToken token = (CsrfToken) request.getAttribute("_csrf");
if (
"POST".equals(request.getMethod()) &&
Arrays.binarySearch(ignoreCsrfAntMatchers, request.getServletPath()) < 0 &&
token != null
) {
try {
Jwts.parser()
.setSigningKey(secret.getBytes("UTF-8"))
.parseClaimsJws(token.getToken());
} catch (JwtException e) {
request.setAttribute("exception", e);
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
RequestDispatcher dispatcher = request.getRequestDispatcher("expired-jwt");
dispatcher.forward(request, response);
return; // 提前终止
}
}
filterChain.doFilter(request, response);
}
}
启动应用后访问/jwt-csrf-form
,等待30秒后提交表单,将显示过期错误页面。
7. JJWT扩展特性
7.1. 强制声明验证
JJWT允许在解析时指定必需声明及其值,避免手动验证分支逻辑。/parser-enforce
接口代码:
@RequestMapping(value = "/parser-enforce", method = GET)
public JwtResponse parserEnforce(@RequestParam String jwt)
throws UnsupportedEncodingException {
Jws<Claims> jws = Jwts.parser()
.requireIssuer("Stormpath")
.require("hasMotorcycle", true)
.setSigningKeyResolver(secretService.getSigningKeyResolver()).build()
.parseClaimsJws(jwt);
return new JwtResponse(jws);
}
创建符合要求的JWT:
http -v POST localhost:8080/dynamic-builder-specific \
iss=Stormpath hasMotorcycle:=true sub=msilverman
验证成功:
{
"jws": {
"body": {
"hasMotorcycle": true,
"iss": "Stormpath",
"sub": "msilverman"
},
"header": {
"alg": "HS256"
},
"signature": "qrH-U6TLSVlHkZdYuqPRDtgKNr1RilFYQJtJbcgwhR0"
},
"status": "SUCCESS"
}
缺少hasMotorcycle
声明时:
{
"exceptionType": "io.jsonwebtoken.MissingClaimException",
"message":
"Expected hasMotorcycle claim to be: true, but was not present in the JWT claims.",
"status": "ERROR"
}
声明值错误时:
{
"exceptionType": "io.jsonwebtoken.IncorrectClaimException",
"message": "Expected hasMotorcycle claim to be: true, but was: false.",
"status": "ERROR"
}
7.2. JWT压缩
当JWT声明过多时,令牌可能过大。JJWT支持压缩,示例:
http -v POST localhost:8080/dynamic-builder-compress \
iss=Stormpath hasMotorcycle:=true sub=msilverman the=quick brown=fox jumped=over lazy=dog \
somewhere=over rainbow=way up=high and=the dreams=you dreamed=of
返回压缩JWT(比未压缩短62字符):
eyJhbGciOiJIUzI1NiIsImNhbGciOiJERUYifQ.eNpEzkESwjAIBdC7sO4JegdXnoC2tIk2oZLEGB3v7s84jjse_AFe5FOikc5ZLRycHQ3kOJ0Untu8C43ZigyUyoRYSH6_iwWOyGWHKd2Kn6_QZFojvOoDupRwyAIq4vDOzwYtugFJg1QnJv-5sY-TVjQqN7gcKJ3f-j8c-6J-baDFhEN_uGn58XtnpfcHAAD__w.3_wc-2skFBbInk0YAQ96yGWwr8r1xVdbHn-uGPTFuFE
后端压缩代码:
@RequestMapping(value = "/dynamic-builder-compress", method = POST)
public JwtResponse dynamicBuildercompress(@RequestBody Map<String, Object> claims)
throws UnsupportedEncodingException {
String jws = Jwts.builder()
.setClaims(claims)
.compressWith(new DeflateCompressionAlgorithm())
.signWith(
SignatureAlgorithm.HS256,
secretService.getHS256SecretBytes()
)
.compact();
return new JwtResponse(jws);
}
JJWT自动检测并解压压缩的JWT,头部包含calg
声明提示解压算法。
8. Java开发者令牌工具
8.1. JJWT库
JJWT是Java中创建和验证JWT的易用工具,完全免费开源(Apache 2.0许可)。欢迎报告问题、提出改进建议甚至提交代码!
8.2. 在线工具
- jsonwebtoken.io:解码JWT的在线工具,支持多种语言代码生成,由Node.js的nJWT库驱动
- java.jsonwebtoken.io:JJWT专用在线工具,可实时编辑头部/载荷并查看生成的Java代码
8.3. JWT Inspector
JWT Inspector是开源Chrome扩展,可直接在浏览器中检查和调试JWT,自动发现页面中的JWT(cookie、存储、请求头)。
9. 总结
JWT为普通令牌增加了智能特性:加密签名验证、内置过期时间和信息编码能力,为真正的无状态会话管理奠定基础,极大提升应用可扩展性。
在Stormpath,我们将JWT用于OAuth2令牌、CSRF令牌和微服务断言等场景。一旦开始使用JWT,你可能再也不会回到过去的"哑令牌"。
有问题?在Twitter联系我:@afitnerd