32.1 JWT的基本原理和特点

32.1.1 JWT原理概述

Json Web Token (JWT)是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准(RFC 7519)。

这里的Token 也称作令牌,通常被认为是访问资服务器源或应用程序接口(API)时需要持有的凭证。它类似于临时的证书签名,一般被用来在身份提供者和服务提供者间传递用户身份信息,以便于从资源服务器获取资源,也可以增加一些其它业务逻辑所必须的声明信息。Token既可直接用于认证,也可被加密。

基于Token的鉴权机制类似于HTPP协议,它也是无状态的,即不需要在服务端保留用户的认证信息或者会话信息。这就意味着基于Token认证机制的应用不需要去考虑用户是在哪一台服务器登录。这为应用的扩展提供了便利,非常适合于使用 REST API 的场景(这些场景往往需要实现分布式站点的SSO单点登录)。

下图是Token的认证流程

image.png
1. 客户端使用某种手段请求登录,如用户名密码、手机短信验证等
1. 服务端验证成功后,签发 Token 自己保存并发送给客户端
1. 客户端存储收到的 Token
1. 客户端每次请求时将 Token 放入 Headers 中
1. 服务端校验 Token,成功则返回请求数据,失败则返回错误信息

在项目实践中,通常客户端(前端)代码会把 Token 显式存放于 Local Storage、Session Storage、或 Cookie中。服务器端不需要保存Token据数据,也可以根据业务逻辑选择普通关系型数据库、分布式键值数据库(如 Redis)等系统保存 Token。

客户端必须要在每次请求时传递这个Token给服务端,它应该保存在请求头里, 另外,服务端要支持CORS(跨来源资源共享)策略(比如在服务端设置Access-Control-Allow-Origin: *)。

为了安全,必须采用 HTTPS 协议传递Token,并且一定要小心防止服务器端加密密钥泄露。

32.1.2 JWT的结构

JWT是由三段信息构成的,将这三段信息文本用.链接一起就构成了JWT字符串。就像这样:

  1. eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

JWT第一部分我们称它为头部(Header),第二部分我们称其为载荷(Payload),第三部分是签名(Signature)。

Header两部分信息:类型声明和加密算法声明。例如

{
  'typ': 'JWT',
  'alg': 'HS256'
}

将头部进行Base64编码后构成了JWT的第一部分:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Playload是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息统称为“声明(Claims)”包括标准中注册的声明、公共的声明、私有的声明三种类型。

标准中注册的声明是建议但不强制使用,可以选择的项目包括 :

  • iss JWT签发者
  • sub JWT所面向的用户
  • aud JWT的接收方
  • exp JWT的过期时间,这个过期时间必须要大于签发时间
  • nbf JWT在这个时间之前都是不可用的
  • iat JWT的签发时间
  • jti JWT的唯一身份标识,主要用来作为一次性Token,从而回避重放攻击。

公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可以解密。

私有声明是提供者和消费者所共同定义的声明,也不建议存放敏感信息,因为默认情况下改部分信息归类为明文信息。

下面是一个Payload的例子

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

将其进行base64加密,得到Jwt的第二部分。

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9

JWT的第三部分是一个签名信息,这个部分需要Base64加密后的Header和Base64加密后的Payload使用 . 连接组成的字符串,然后通过Header中声明的加密方式进行签名JWT的第三部分。

将这三部分用 . 连接成一个完整的字符串后成为最终的JWT.

可见JWT的构成非常简单,字节占用很小,所以它非常便于传输。而JSon的通用性,使得JWT是跨语言支持的,像Java、JavaScript、PHP等很多语言都可以使用。

32.1.3 JWT的特点

Token 和 Session 相比有很大的优势:基于 Token 的验证是无状态的,这是它与 Cookie 相比的最大优点。服务器唯一的工作是在成功的登陆请求上签署 Token,然后在后续收到的访问中验证传入的 Token 是否有效。Token 完全由应用系统管理,所以它可以很好的应用于分布式系统和复杂均衡的服务器环境,能够避开同源策略在多个站点中使用,并且可以抵抗跨站请求伪造(CSRF)。Token同时支持PC浏览器和移动平台,并且效率更高。

当然 Session 和 Token 并不矛盾。Token 的安全性比 Session 好,因为每一个请求都有签名还能防止监听以及重复攻击,而 Session 必须依赖链路层来保障通讯安全。如果你需要实现有状态的会话,仍然可以增加 Session 在服务器端保存一些状态。

采用 JWT 的认证方式下,服务端不存储用户状态信息,有效期内无法废弃(除非辅助以其他状态记录手段),有效期到期后,需要重新创建一个新的来替换。 所以它并不适合长期保持状态,不适合需要踢用户下线的场景,不适合需要频繁修改用户信息的场景。因为要解决这些问题,总是需要额外查询数据库或者缓存,或者反复加密解密,这些场景不如直接使用 Session。

32.2 教程目标功能点

下面是本章教程预期实现的功能目标:

  • 通过填写用户名和密码登录。
  • 验证成功后, 服务端生成 JWT 认证 Token并返回给客户端。
  • 客户端在每次请求中携带 JWT 来访问权限内的接口。
  • 每次请求都验证 Token 的有效性和权限,在 Token无有效时抛出 401 未授权异常。

    32.3 实现 JWT 认证的过程

    32.3.1 选择和添加依赖

常见的操作JWT的Java库有如下这些:

  • Auth0实现 的 java-jwt
  • Brian Campbell实现的 jose4j
  • connect2id实现的 nimbus-jose-jwt
  • Les Haziewood实现的 jjwt
  • Inversoft实现的prime-jwt
  • Vertx实现的vertx-auth-jwt.

网络上较多的文章或教程选择使用jjwt,阿里云API网关Token认证采用的是jose4j,Spring Security则选择内置nimbus-jose-jwt并适度扩展后实现OAuth 2.0的相关功能。这里我们决定依循Spring Security的选择。

在验证JWT方面,有人选择在自定义Spring Security的Filter中完成。而我们同样倾向选择Spring Security自己的功能,也就是关于OAuth 2.0功能。

这里需要说明的是,独立的Spring Security OAuth 项目已经被Spring废止,取而代之的是内嵌在Spring Security中的OAuth,我们选择认证JWT的也是这个内嵌的OAuth 2.0 Resource Server。千万不要搞串了。具体的区别可以看官方文档 OAuth 2.0 Migration Guide

下面是正确的依赖内容

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-resource-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-jose</artifactId>
        </dependency>

32.3.2 创建 JWT 工具类

为了让应用程序代码更加直观简洁,我们设计了2个工具类,把nimbusds生成和解析JWT的常见代码都封装起来。

首先是JWT的构造器类JwtBuilder

package com.longser.security.jose;

import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.ECDSASigner;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import org.springframework.util.Assert;

import java.net.URL;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.RSAPrivateKey;
import java.util.Collections;
import java.util.Date;
import java.util.List;

@SuppressWarnings("unused")
public class JwtBuilder {

    JWTClaimsSet.Builder claimsSetBuilder;
    JWSHeader.Builder headerBuilder;
    JWSSigner signer;

    public JwtBuilder() {
        this.claimsSetBuilder = new JWTClaimsSet.Builder();
    }

    public JwtBuilder issuer(URL iss) {
        this.claimsSetBuilder.issuer(iss.toString());
        return this;
    }

    public JwtBuilder subject(String sub) {
        this.claimsSetBuilder.subject(sub);
        return this;
    }

    public JwtBuilder audience(List<String> aud) {
        this.claimsSetBuilder.audience(aud);
        return this;
    }

    public JwtBuilder audience(String aud) {
        if (aud == null) {
            this.claimsSetBuilder.audience((List<String>)null);
        } else {
            this.claimsSetBuilder.audience(Collections.singletonList(aud));
        }
        return this;
    }

    public JwtBuilder expirationTime(Date exp) {
        this.claimsSetBuilder.expirationTime(exp);
        return this;
    }

    public JwtBuilder notBeforeTime(Date nbf) {
        this.claimsSetBuilder.notBeforeTime(nbf);
        return this;
    }

    public JwtBuilder issueTime(Date iat) {
        this.claimsSetBuilder.issueTime(iat);
        return this;
    }

    public JwtBuilder jwtID(String jti) {
        this.claimsSetBuilder.jwtID(jti);
        return this;
    }

    public JwtBuilder claim(String name, Object value) {
        this.claimsSetBuilder.claim(name, value);
        return this;
    }

    public JwtBuilder header(JWSAlgorithm alg) {
        this.headerBuilder = new JWSHeader.Builder(alg).type(JOSEObjectType.JWT);
        return this;
    }

    public JwtBuilder signer(RSAPrivateKey privateKey) {
        this.signer = new RSASSASigner(privateKey);
        return this;
    }

    public JwtBuilder signer(ECPrivateKey privateKey) throws JOSEException {
        this.signer = new ECDSASigner(privateKey);
        return this;
    }

    public String build() {
        Assert.notNull(this.signer,"You set the signer before building JWT!");

        JWTClaimsSet claims = this.claimsSetBuilder.build();
        JWSHeader header = this.headerBuilder != null? this.headerBuilder.build() :
                new JWSHeader.Builder(JWSAlgorithm.RS256).build();
        SignedJWT signedJWT = new SignedJWT(header, claims);

        return sign(signedJWT).serialize();
    }

    private SignedJWT sign(SignedJWT jwt) {
        Assert.notNull(this.signer,"You tell me what singer you want to use !");

        try {
            jwt.sign(this.signer);
            return jwt;
        }
        catch (Exception ex) {
            throw new IllegalArgumentException(ex);
        }
    }
}

然后是进一步封装好用来创建和解析JWT的工具类JwtUtils

package com.longser.security.jose;

import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.crypto.ECDSAVerifier;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jwt.SignedJWT;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;

import java.net.MalformedURLException;
import java.net.URL;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.text.ParseException;
import java.time.Instant;
import java.util.Collection;
import java.util.Date;
import java.util.UUID;

@SuppressWarnings("unused")
public class JwtUtils {

    private static final JwtBuilder jwtBuilder = new JwtBuilder();

    private JwtUtils() {
        throw new IllegalStateException("Cannot create instance of static class");
    }

    public static SignedJWT parse(String jwtString, RSAPublicKey key) throws ParseException, JOSEException {

        SignedJWT signedJWT = SignedJWT.parse(jwtString);
        boolean checked = signedJWT.verify(new RSASSAVerifier(key));

        return checked? signedJWT : null;
    }

    public static SignedJWT parse(String jwtString, ECPublicKey key) throws ParseException, JOSEException {

        SignedJWT signedJWT = SignedJWT.parse(jwtString);
        boolean checked = signedJWT.verify(new ECDSAVerifier(key));

        return checked? signedJWT : null;
    }

    private static void setJwtData(String username, long expiry) throws MalformedURLException {
        Instant now = Instant.now();
        jwtBuilder
                .issuer(new URL("https://www.longser.com"))
                .issueTime(new Date(now.toEpochMilli()))
                .expirationTime(new Date(now.plusSeconds(expiry).toEpochMilli()))
                .subject(username)
                .jwtID(UUID.randomUUID().toString().replaceAll("-",""));
    }

    private static String getCope(Authentication authentication) {
        StringBuilder scope = new StringBuilder();
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

        for (GrantedAuthority authority : authorities) {
            scope.append(" ").append(authority);
        }

        return scope.toString().trim();
    }

    public static String create(String username,
                                long expiry,
                                RSAPrivateKey privateKey,
                                Authentication authentication) throws MalformedURLException {
        setJwtData(username, expiry);
        jwtBuilder.claim("scope", getCope(authentication));

        return jwtBuilder.signer(privateKey).build();
    }

    public static String create(String username, long expiry, RSAPrivateKey privateKey) throws MalformedURLException {
        setJwtData(username, expiry);

        return jwtBuilder.signer(privateKey).build();
    }

    public static String create(String username,
                                long expiry,
                                ECPrivateKey privateKey,
                                Authentication authentication) throws MalformedURLException, JOSEException {
        setJwtData(username, expiry);
        jwtBuilder.claim("scope", getCope(authentication));

        return jwtBuilder.signer(privateKey).build();
    }

    public static String create(String username, long expiry, ECPrivateKey privateKey) throws JOSEException, MalformedURLException {
        setJwtData(username, expiry);

        return jwtBuilder.signer(privateKey).build();
    }
}

从代码中可以看到我们目前主要支持RSASSAECDSA两种算法法来签名和验签。通常这两个算法就足够了,如果你希望使用其它算法,甚至于使用自定义的算法,可以自己扩展这两个类。

另外,我们在代码中强制 Issuer 比如符合 URL 的格式要求,这时为了让com.nimbusds.jwt.SignedJWT 能够兼容org.springframework.security.oauth2.jwt.Jwt。后者定义在返回Issuer时会把内容封装进对象。

32.3.3 定义RSA密钥对

我们把生成的RSA公钥和私钥后形成下面两个文件并保存在目录 src/main/resources/config

-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDcWWomvlNGyQhA
iB0TcN3sP2VuhZ1xNRPxr58lHswC9Cbtdc2hiSbe/sxAvU1i0O8vaXwICdzRZ1JM
g1TohG9zkqqjZDhyw1f1Ic6YR/OhE6NCpqERy97WMFeW6gJd1i5inHj/W19GAbqK
LhSHGHqIjyo0wlBf58t+qFt9h/EFBVE/LAGQBsg/jHUQCxsLoVI2aSELGIw2oSDF
oiljwLaQl0n9khX5ZbiegN3OkqodzCYHwWyu6aVVj8M1W9RIMiKmKr09s/gf31Nc
3WjvjqhFo1rTuurWGgKAxJLL7zlJqAKjGWbIT4P6h/1Kwxjw6X23St3OmhsG6HIn
+jl1++MrAgMBAAECggEBAMf820wop3pyUOwI3aLcaH7YFx5VZMzvqJdNlvpg1jbE
E2Sn66b1zPLNfOIxLcBG8x8r9Ody1Bi2Vsqc0/5o3KKfdgHvnxAB3Z3dPh2WCDek
lCOVClEVoLzziTuuTdGO5/CWJXdWHcVzIjPxmK34eJXioiLaTYqN3XKqKMdpD0ZG
mtNTGvGf+9fQ4i94t0WqIxpMpGt7NM4RHy3+Onggev0zLiDANC23mWrTsUgect/7
62TYg8g1bKwLAb9wCBT+BiOuCc2wrArRLOJgUkj/F4/gtrR9ima34SvWUyoUaKA0
bi4YBX9l8oJwFGHbU9uFGEMnH0T/V0KtIB7qetReywkCgYEA9cFyfBIQrYISV/OA
+Z0bo3vh2aL0QgKrSXZ924cLt7itQAHNZ2ya+e3JRlTczi5mnWfjPWZ6eJB/8MlH
Gpn12o/POEkU+XjZZSPe1RWGt5g0S3lWqyx9toCS9ACXcN9tGbaqcFSVI73zVTRA
8J9grR0fbGn7jaTlTX2tnlOTQ60CgYEA5YjYpEq4L8UUMFkuj+BsS3u0oEBnzuHd
I9LEHmN+CMPosvabQu5wkJXLuqo2TxRnAznsA8R3pCLkdPGoWMCiWRAsCn979TdY
QbqO2qvBAD2Q19GtY7lIu6C35/enQWzJUMQE3WW0OvjLzZ0l/9mA2FBRR+3F9A1d
rBdnmv0c3TcCgYEAi2i+ggVZcqPbtgrLOk5WVGo9F1GqUBvlgNn30WWNTx4zIaEk
HSxtyaOLTxtq2odV7Kr3LGiKxwPpn/T+Ief+oIp92YcTn+VfJVGw4Z3BezqbR8lA
Uf/+HF5ZfpMrVXtZD4Igs3I33Duv4sCuqhEvLWTc44pHifVloozNxYfRfU0CgYBN
HXa7a6cJ1Yp829l62QlJKtx6Ymj95oAnQu5Ez2ROiZMqXRO4nucOjGUP55Orac1a
FiGm+mC/skFS0MWgW8evaHGDbWU180wheQ35hW6oKAb7myRHtr4q20ouEtQMdQIF
snV39G1iyqeeAsf7dxWElydXpRi2b68i3BIgzhzebQKBgQCdUQuTsqV9y/JFpu6H
c5TVvhG/ubfBspI5DhQqIGijnVBzFT//UfIYMSKJo75qqBEyP2EJSmCsunWsAFsM
TszuiGTkrKcZy9G0wJqPztZZl2F2+bJgnA6nBEV7g5PA4Af+QSmaIhRwqGDAuROR
47jndeyIaMTNETEmOnms+as17g==
-----END PRIVATE KEY-----
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3FlqJr5TRskIQIgdE3Dd
7D9lboWdcTUT8a+fJR7MAvQm7XXNoYkm3v7MQL1NYtDvL2l8CAnc0WdSTINU6IRv
c5Kqo2Q4csNX9SHOmEfzoROjQqahEcve1jBXluoCXdYuYpx4/1tfRgG6ii4Uhxh6
iI8qNMJQX+fLfqhbfYfxBQVRPywBkAbIP4x1EAsbC6FSNmkhCxiMNqEgxaIpY8C2
kJdJ/ZIV+WW4noDdzpKqHcwmB8FsrumlVY/DNVvUSDIipiq9PbP4H99TXN1o746o
RaNa07rq1hoCgMSSy+85SagCoxlmyE+D+of9SsMY8Ol9t0rdzpobBuhyJ/o5dfvj
KwIDAQAB
-----END PUBLIC KEY-----

这里省略了的密钥对生成过程。实际项目中必须自己重新生成。

为便于在代码中访问这对密钥,把他们的位置信息配置在Application.yml中

application:
  authentication:
    jwt:
      time-to-live: 30
      private.key: classpath:config/app.key
      public.key: classpath:config/app.pub

在META-INF/additional-spring-configuration-metadata.json 中增加配置

    {
      "name": "application.authentication.jwt.time-to-live",
      "type": "java.lang.Long",
      "description": "JWT的有效期(秒)."
    },
    {
      "name": "application.authentication.jwt.private.key",
      "type": "java.security.interfaces.RSAPrivateKey",
      "description": "私钥文件路径."
    },
    {
      "name": "application.authentication.jwt.public.key",
      "type": "java.security.interfaces.RSAPublicKey",
      "description": "公钥文件路径."
    }

32.3.4 登录成功后生成Token

为使用密钥加密,需要在LoginController中读取Application.yml中指定的私钥文件

   @Value("${application.authentication.jwt.time-to-live:30}")
    Long jwtTimeToLive;

    @Value("${application.authentication.jwt.private.key}")
    RSAPrivateKey privateKey;

然后,我们只需要修改响应 /login 的方法,让它登录成功后把Token数据放在返回JSON的data部分:

-return "登录成功";
+return JwtUtils.create(username, jwtTimeToLive, privateKey)

这里我们把Token的有效期设置成30秒,这时为了方便进行JWT过期效果的测试。实际应用中应根据具体的业务逻辑选取合适的时间。

32.3.5 配置规则配置

为了配合解码验证JWT,我们需要在 SecurityConfig 中定义一个JwtDecoder 类型的Bean,他返是携带公钥的 NimbusJwtDecoder

    @Value("${application.authentication.jwt.public.key}")
    RSAPublicKey key;

    @Bean
    JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withPublicKey(this.key).build();
    }

然后在在configure(HttpSecurity http) 方法中增加如下配置启用能够一张JWT的OAuth Resource Server:


        // 这是配置的关键,决定哪些接口开启防护,哪些接口绕过防护
        // 自定义自己编写的登录页面
        http.logout().logoutUrl("/api/logout")
                .addLogoutHandler(logoutHandler())
                .logoutSuccessHandler(logoutSuccessHandler())
                .deleteCookies("JSESSIONID")
                .permitAll()
                .and()
+               .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
+               .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeRequests()

增加的第一条配置告知Spring Security启用OAuth 2.0的资源服务器,用来检查所有需认证请求是否携带JWT以及该JWT是否合法。第二条配置的目的是让认证变为无状态,也就是不在往Session中存储认证结果信息。

32.3.6 解码JWT信息内容

为了展示解码JWT的方法,现在给 TestController 增加一个新的接口

    @Value("${application.authentication.jwt.public.key}")
    RSAPublicKey publicKey;

    @GetMapping("/token")
    public void getParseToken(HttpServletRequest request) throws ParseException, JOSEException {

        String requestHeader = request.getHeader("Authorization");
        // +7 是因为 字符串"bearer" 和 Token内容之间有个空格
        String token = requestHeader.substring(requestHeader.lastIndexOf("bearer") + 7);

        SignedJWT signedJWT = JwtUtils.parse(token, publicKey);
        assert signedJWT != null;
        System.out.println(signedJWT.getJWTClaimsSet().getSubject());
        System.out.println(signedJWT.getJWTClaimsSet());

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        WebAuthenticationDetails details = (WebAuthenticationDetails) authentication.getDetails();
        System.out.println(details);
    }

在上面的代码中,我们直接从HttpServletRequest中读取JWT后再调用JwtUtils.parse解码。实际上完全可以不必这样做。因为OAuth Resource Server会把解码后的jwt对象作为控制器方法的参数自动注入(如同注入Request、Response、Session一样),所以可以用下面这样更简单的方法

    @GetMapping("/token2")
    public void getParseTokenSimple(@AuthenticationPrincipal Jwt jwt) {

        System.out.println(jwt.getClaims());
        System.out.println("Username: " + jwt.getSubject());
        System.out.println(("Issuer: " + jwt.getIssuer().toString()));
        System.out.println("IssueTime: " + jwt.getIssuedAt().toString());
        System.out.println("ExpirationTime:" + jwt.getExpiresAt().toString());

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        WebAuthenticationDetails details = (WebAuthenticationDetails) authentication.getDetails();
        System.out.println(details);
    }

这段代码和前一段代码还有一个比较重要的区别是这里注入的Jwt的类型是org.springframework.security.oauth2.jwt.Jwt而非com.nimbusds.jwt.SignedJWT。这也是我们在JwtUtils中强制Issuer符合URL格式以保持二者兼容的原因。

32.3.7 简单的功能测试

我们用Postman做一下简单的功能测试。

直接访问地址 /test/token ,得到如下的结果
image.png
user的身份登录,可以看到在 data 中获得了用字符串表示的JWT:
image.png
在访问 /first的页面的 Auth 项目下选择类型为 Bearer Token,然后把获得的JWT字符串复制到Token,点击发送后可以成功得到返回的数据:
image.png
下面是在控制台获得的输出信息:

user
{"sub":"user","iss":"https:\/\/www.longser.com","exp":1637237860,"iat":1637237830,"jti":"53dd59122dc64912b92d5d5e962df3a3"}
WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=0A9E571419CDE3422965046ED0C057FF]
{iss=https://www.longser.com, sub=user, exp=2021-11-18T12:17:40Z, iat=2021-11-18T12:17:10Z, jti=53dd59122dc64912b92d5d5e962df3a3}
Username: user
Issuer: https://www.longser.com
IssueTime: 2021-11-18T12:17:10Z
ExpirationTime:2021-11-18T12:17:40Z
WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=5E2171FE5E949E8B1A365A295F08BEF6]

在获得JWT 90秒以后(不是30秒,这个后面会解释原因)再次尝试访问,会发现控制台没有输出。查看返回的Hedaders,在WWW-Authenticate中可以看到服务器给出的信息——JWT expired
image.png
尽管在前端可以根据返回的状态(401)和HedadersWWW-Authenticate的内容来判断访问的状态,但这不符合我们一直以来对API接口的约定,因此这里也需要增加自定义的异常处理。

32.4 自定义OAuth认证异常处理器

在默认的配置中,Spring Security在过滤器BearerTokenAuthenticationFilter中使用org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint执行对认证异常的处理。它主要完成了下面几项工作:

  • 在Response的Headers总放置一项记录了出错信息的WWW-Authenticate
  • 把Response的状态设置为 401 Unauthorized
  • 返回空的Body

根据上面的分析,我们自定义的主要目标就是在现有逻辑的基础上按照既定的作法记录日志、返回固定格式的结果。由于BearerTokenAuthenticationEntryPoint被定义为final,我们无法在它的基础上继承,只好继承AuthenticationEntryPoint,然后把BearerTokenAuthenticationEntryPoint中的代码复制过来之后再增加新的内容。

下面是完整的代码,其中主体代码是从Spring Security 5.1(写作本教程时的最新版)中复制过来的。

package com.longser.security.oauth2;

import com.longser.restful.result.RestfulResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.server.resource.BearerTokenError;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * An AuthenticationEntryPoint implementation used to commence authentication of
 * protected resource requests using BearerTokenAuthenticationFilter.
 *
 * Uses information provided by BearerTokenError to set HTTP response status code
 * and populate WWW-Authenticate HTTP header.
 *
 * Copy from org.springframework.security.oauth2.server
 *                  .resource.web.BearerTokenAuthenticationEntryPoint
 * (Version 5.1)
 *
 * The changes are appending some codes to put QueryResult information in
 * HttpServletResponse and print logging information.
 */
public class BearerTokenUnauthorized implements AuthenticationEntryPoint {

    private String realmName;

    private static final Logger LOGGER =
            LoggerFactory.getLogger(BearerTokenUnauthorized.class);

    /**
     * Collect error details from the provided parameters and format according to RFC
     * 6750, specifically {@code error}, {@code error_description}, {@code error_uri}, and
     * {@code scope}.
     * @param request that resulted in an <code>AuthenticationException</code>
     * @param response so that the user agent can begin authentication
     * @param authException that caused the invocation
     */
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {

        HttpStatus status = HttpStatus.UNAUTHORIZED;
        Map<String, String> parameters = new LinkedHashMap<>();
        String message = "登录状态无效,请重新登录";

        if (this.realmName != null) {
            parameters.put("realm", this.realmName);
        }
        if (authException instanceof OAuth2AuthenticationException) {
            OAuth2Error error = ((OAuth2AuthenticationException) authException).getError();
            parameters.put("error", error.getErrorCode());
            if (StringUtils.hasText(error.getDescription())) {
                message = error.getDescription();
                parameters.put("error_description", error.getDescription());
            }
            if (StringUtils.hasText(error.getUri())) {
                parameters.put("error_uri", error.getUri());
            }
            if (error instanceof BearerTokenError) {
                BearerTokenError bearerTokenError = (BearerTokenError) error;
                if (StringUtils.hasText(bearerTokenError.getScope())) {
                    parameters.put("scope", bearerTokenError.getScope());
                }
                status = ((BearerTokenError) error).getHttpStatus();
            }
        }
        String wwwAuthenticate = computeWWWAuthenticateHeaderValue(parameters);
        response.addHeader(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate);
        response.setStatus(status.value());

        // Here is the newly added code

        String title = status.getReasonPhrase();
        message = message.toLowerCase();

        if(message.contains("expired at")) {
            message = "登录状态过期,请重新登录";
        } else if(message.contains("invalid token") || message.contains("invalid signature")) {
            message = "登录状态无效,请重新登录";
        } else if(message.contains("used before")) {
            message = "授权尚未生效,请在之后尝试";
        }

        LOGGER.warn("[Exception] 捕获到异常 {}: {}", authException.getClass().getSimpleName(),
                authException.getMessage());
        LOGGER.warn("[{}] {}", title, message);

        response.setContentType("application/json;charset=UTF-8");
        response.setCharacterEncoding("UTF-8");
        PrintWriter out = response.getWriter();
        out.println(RestfulResult.fail(status.value(), message));
    }

    /**
     * Set the default realm name to use in the bearer token error response
     */
    public void setRealmName(String realmName) {
        this.realmName = realmName;
    }

    private static String computeWWWAuthenticateHeaderValue(Map<String, String> parameters) {
        StringBuilder wwwAuthenticate = new StringBuilder();
        wwwAuthenticate.append("Bearer");
        if (!parameters.isEmpty()) {
            wwwAuthenticate.append(" ");
            int i = 0;
            for (Map.Entry<String, String> entry : parameters.entrySet()) {
                wwwAuthenticate.append(entry.getKey()).append("=\"").append(entry.getValue()).append("\"");
                if (i != parameters.size() - 1) {
                    wwwAuthenticate.append(", ");
                }
                i++;
            }
        }
        return wwwAuthenticate.toString();
    }
}

定义好处理类之后,需要在SecurityConfig中配置使用这个类,在方法configure(HttpSecurity http)的最后增加如下代码:

        http.getConfigurer(OAuth2ResourceServerConfigurer.class)
                .authenticationEntryPoint(new BearerTokenUnauthorized());

重启应用后,直接使用前次获得的JWT登录,可以看到能够得到期望的返回数据:
image.png
在后端日志中可以看到如下的信息

[Exception] 捕获到异常 InvalidBearerTokenException: An error occurred while attempting to decode the Jwt: Jwt expired at 2021-11-18T12:17:40Z
Unauthorized] 登录状态过期,请重新登录

现在我们把JWT的第一个字母修改其它字母,然后尝试访问,可以看到以下结果:
image.png
查看后端的日志,可以看到如下关于JWT Header格式无效的信息:
InvalidBearerTokenException: An error occurred while attempting to decode the Jwt: Invalid unsecured/JWS/JWE header: Invalid JSON: Unexpected token ”alg”:”RS256”} at position 15.

查看返回的Headers,可以看到如下关于JWT Header格式无效的信息:
Bearer error=”invalid_token”, error_description=”Invalid token”, error_uri=”https://tools.ietf.org/html/rfc6750#section-3.1

32.5 JWT认证中的权限处理

登录后我们用Postman尝试访问 /test/annotation/user/read(不要忘了设置Token),可以得到没有访问权限的反馈。

想一下这也是有道理的。因为此时的认证是无状态的,也就是服务器并不知道当初签发JWT时那个对应用户的权限信息。尽管你可以根据JWT携带的用户名去查询获取权限内容,但更正规的作法是在签发JWT时就把对应的权限一并记录下来。

我们定义的JwtUtils中有这样一个方法:

public static String create(String username,
                                long expiry,
                                RSAPrivateKey privateKey,
                                Authentication authentication)

他有一个Authentication类型的参数,此方法用这个参数使用下面的方法来获得当前签发对象用户的权限定义并记录在自定义的 scope 声明里面,Spring Security OAuth 2 Resource Server默认会从这个scope声明中读取用户的权限。

    private static String getCope(Authentication authentication) {
        String scope = "";
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

        for (GrantedAuthority authority : authorities) {
            scope = scope + " " + authority;
        }

        return scope.trim();
    }

为此,我们在 login 中换一个创建JWT的方法。

-return JwtUtils.create(username, jwtTimeToLive, privateKey);
+return JwtUtils.create(username, jwtTimeToLive, privateKey, authentication);

重启应用,登录获得JWT后再次尝试访问,仍旧没有访问权限

此时没有访问权限的问题是在JWT的默认解析过程中,所有的权限名称都被加上了 SCOPE_ 前缀,而我们之前在 hasAuthority 中声明的权限名称都没有这个前缀。

那是不是意味着我们只能把所有注解的权限名称都修改一遍?不是的。

尽管把所有权限注解中的权限名称都加上 SCOPE_ 前缀可以解决当前的问题,但这样不仅繁琐、工作量大,而且导致和其它认证方式不兼容的问题。

在Spring Security官方文档 12.3.9. Configuring Authorization 中的 Extracting Authorities Manually 部分指出我们可以自己定义修改这个固定前缀的内容,并且给出了例子 Example 115. Authorities Prefix Configuration。与这个例子一起,文档中还特别说明 you can remove the prefix altogether by calling JwtGrantedAuthoritiesConverter#setAuthorityPrefix(“”),也就是我们可以选择完全不加任何前缀。

具体的作法就是在 SecurityConfig 中增加如下的定义:

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        grantedAuthoritiesConverter.setAuthorityPrefix("");

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }

是的,只需要完成这个定义。重启之后,你应该发现现有的权限注解定义都可以继续发挥作用了。

看到这里,你应该也明白为什么我们在前一章中决定选择使用authorities() 而不是 roles() 定义用户的权限了——因为这样做可以保持不同认证方式之间的兼容

32.6 JWT常见问题讨论

1. 要不要在数据库中保存JWT ?

一般不要。使用JWT做登录验证的时候,你不需要把JWT记录在类似关系型数据库或者Redis这样的键值数据库中。JWT协议本身设计目的就是通过签名加密等密码技术让Token能够自我证明其有效性。把Token记录下来,不仅没有必要,更是破坏了JWT 无状态 的原则。

2. 如何防止JWT被中间人劫持后假冒?

规避这个风险的最主要方式就是使用HTTPS。绝对不要在正式运行的环境中使用 HTTP 来传递 JWT。

3. JWT携带用户名还是用户ID更好一些?

这个要看应用系统具体设计的逻辑。如果允许用户修改用户名,或者用户信息保存在关系型数据库中,确实携带用户ID会更好一些。但如果用户信息保存在Redis中,应该差别不大。而且,既然纠结这个事情,那干脆就决定携带ID或者全部携带好了。

4. 设置JWT有效期有什么规则?

这也是要看应用系统的具体设计逻辑。对于一次有效,或者期望短时间有效的场景,可以把有效期设置的短一些。对于希望能够记住用户的应用,可以适当把有效期设置的长一些。尽管这个回答相当于没说,但事实就是这样的。

5. JWT自己携带的ID有什么用?

通常的应用系统用不到这个ID。但如果业务逻辑设计为 JWT 只能使用一次,那么可以使用类似 UUID 的方式给 JWT 的 ID 赋值。然后在类似 Redis 中记录已经失效的ID值,并在验证的时候比对(可以自定义认证过程直接校验或者在认证后二次识别)。

6. 是否要设计自动刷新机制,如何设计?

对于 JWT 认证来说,自动刷新不是必须的。如果你希望用户一次登录后能够长期使用 JWT,则确实设计一个刷新机制要比签发超长有效期更好一些。你可以根据不同的需求设计逻辑规则。如快要到期时询问用户用户是否刷新,并且可以考虑是否让用户在刷新时做其它形式的安全验证。你也可以设计自动刷新的机制。比如你可以签发一个5天有效的JWT,只要在有效期内他使用了这个 JWT ,就给他签发一个新的5天有效的JWT,并且为了防止频繁签发,可以约定每天最多刷新一次。

7. 为了更加安全,可以整体加密JWT内容么?

尽管不是很必要,但确实可以这样做。严格说起来,我们在本节使用的已经不是原始的JWT,而是JWS(即携带了签名的 JWT)。如果你想对当前的 JWT 整体再次加密以保护携带的内容,那么其实就是 JWE 规范定义的内容。具体的可以去看官方示例

8. 如何强制废除部分或全部已经签发的JWT?

先说一下废除全部 JWT 的方法。通常来说有两种,一种是简单粗暴地更换密钥对后重启服务器。另外一种不必重启服务器,在软件里面设置一个标记,并且自定义验证过程,使得在某个日期前签发的 JWT 都逻辑失效。如果想要实现废除部分JWT的功能,恐怕就不得不建立某种“黑名单”机制了。

9. 完成本节的认证是否就是实现了OAuth2?

不是的。我们只是完成了自己签发JWT和使用OAuth 2 Resource Server来验证权限。建设完整的OAuth2系统还需要认证服务器(OAuth 2 Authorization Server)。并且还需要给出Client的范例。当然作为自己签发自己用的应用系统来说,本节的内容确实足够了。

下面是Oauth 2的完整认证流程(引自Oauth2.0协议rfc6749 https://tools.ietf.org/html/rfc6749):
image.png

10. 可以用Spring Security设计OAuth2 Client么?

没问题。自己去看文档吧。

11. 如何配置使用Spring Security的OAuth2 认证服务器?

这个你做不到。因为Spring Security还没有发布。具体看这边官方声明:Announcing the Spring Authorization Server

12. 本章的方法就是Spring Security OAuth的内容么?

不是。本节的介绍的方法是Spring Security中的组成部分。而Spring Security OAuth作是另外一个独立的项目,并且已经被废弃了。这个确实容易混淆,Spring Security中的OAuthSpring Security OAuth是两个完全不同的东西。详细内容可以看官方的弃用声明(Deprecation Notice)

版权说明:本文由北京朗思云网科技股份有限公司原创,向互联网开放全部内容但保留所有权力。