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由三个用点号(.)分隔的部分组成:

  1. Header(头部)
  2. Payload(载荷)
  3. 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创建分为三步:

  1. 定义令牌内部声明(签发者、主题、过期时间等)
  2. 加密签名JWT(生成JWS)
  3. 按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();

常见签名反模式

以下三种签名方式都是反模式:

  1. .signWith(SignatureAlgorithm.HS256, "secret".getBytes("UTF-8"))
    

    问题:密钥过短且非原生字节数组

  2. .signWith(SignatureAlgorithm.HS256, "Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E=".getBytes("UTF-8"))
    

    问题:直接转换Base64字符串而非解码

  3. .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


原始标题:Supercharge Java Auth with JSON Web Tokens (JWTs) | Baeldung