跳到内容

Java的JSON Web令牌备忘单

简介

许多应用程序使用JSON Web令牌(JWT)来允许客户端在身份验证后指示其身份以进行后续交换。

摘自 JWT.IO

JSON Web令牌(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间以JSON对象的形式安全地传输信息。这些信息可以被验证和信任,因为它们经过数字签名。JWT可以使用密钥(通过HMAC算法)或使用RSA的公钥/私钥对进行签名。

JWT用于承载与客户端身份和特征(声明)相关的信息。这些信息由服务器签名,以确保在发送给客户端后未被篡改。这可以防止攻击者修改身份或特征——例如,将角色从普通用户更改为管理员或更改客户端的登录信息。

令牌在身份验证期间创建(成功身份验证后签发),并在任何处理之前由服务器验证。应用程序使用令牌允许客户端向服务器提供一个本质上是“身份证”的东西。然后,服务器可以安全地验证令牌的有效性和完整性。这种方法是无状态和可移植的,这意味着它适用于不同的客户端和服务器技术,以及各种传输通道——尽管HTTP是最常用的。

令牌结构

令牌结构示例摘自 JWT.IO

[Base64(HEADER)].[Base64(PAYLOAD)].[Base64(SIGNATURE)]

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.
TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

部分 1:头部

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

部分 2:载荷

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

部分 3:签名

HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), KEY )

目标

本备忘单提供了在使用Java的JSON Web令牌(JWT)时预防常见安全问题的提示。

本文中介绍的提示是一个Java项目的一部分,该项目旨在展示处理JSON Web令牌创建和验证的正确方法。

您可以在这里找到该Java项目,它使用了官方的JWT库

在本文的其余部分,术语令牌指代JSON Web令牌(JWT)。

使用JWT的考量

即使JWT“易于”使用并允许以无状态方式暴露服务(主要是REST风格),但它并非适用于所有应用程序的解决方案,因为它伴随一些注意事项,例如令牌的存储问题(本备忘单中讨论)等等……

如果您的应用程序不需要完全无状态,您可以考虑使用所有Web框架提供的传统会话系统,并遵循专门的会话管理备忘单中的建议。然而,对于无状态应用程序,如果实施得当,它是一个不错的选择。

问题

无哈希算法

症状

这种攻击(在此描述)发生在攻击者篡改令牌并更改哈希算法时,通过关键字none指示令牌的完整性已被验证。正如上面链接中解释的,一些库将使用none算法签名的令牌视为已验证签名的有效令牌,因此攻击者可以篡改令牌声明,而修改后的令牌仍将受到应用程序的信任。

如何预防

首先,使用不会暴露于此漏洞的JWT库。

最后,在令牌验证期间,明确要求使用了预期的算法。

实施示例

// HMAC key - Block serialization and storage as String in JVM memory
private transient byte[] keyHMAC = ...;

...

//Create a verification context for the token requesting
//explicitly the use of the HMAC-256 hashing algorithm
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(keyHMAC)).build();

//Verify the token, if the verification fail then a exception is thrown
DecodedJWT decodedToken = verifier.verify(token);

令牌侧劫

症状

这种攻击发生在攻击者拦截/窃取令牌后,利用其获取目标用户身份并访问系统。

如何预防

预防此问题的一种方法是向令牌添加“用户上下文”。用户上下文应包含以下内容:

  • 在身份验证阶段生成一个随机字符串。该字符串作为强化Cookie发送给客户端(具有以下标志:HttpOnly + SecureSameSiteMax-Agecookie前缀)。避免设置expires头,以便在浏览器关闭时清除Cookie。将Max-Age设置为小于或等于JWT过期时间的值——绝不能更大。
  • 随机字符串的SHA256哈希值将存储在令牌中(而不是原始值),以防止任何XSS问题导致攻击者读取随机字符串值并设置预期的Cookie。

避免使用IP地址作为上下文的一部分。IP地址在单个会话期间可能因合法原因而更改——例如,当用户在移动设备上访问应用程序并切换网络提供商时。此外,IP跟踪可能引起与欧盟GDPR合规性相关的担忧。

在令牌验证期间,如果收到的令牌不包含正确的上下文(例如,如果它正在被攻击者重放),则必须拒绝它。

实施示例

成功身份验证后创建令牌的代码。

// HMAC key - Block serialization and storage as String in JVM memory
private transient byte[] keyHMAC = ...;
// Random data generator
private SecureRandom secureRandom = new SecureRandom();

...

//Generate a random string that will constitute the fingerprint for this user
byte[] randomFgp = new byte[50];
secureRandom.nextBytes(randomFgp);
String userFingerprint = DatatypeConverter.printHexBinary(randomFgp);

//Add the fingerprint in a hardened cookie - Add cookie manually because
//SameSite attribute is not supported by javax.servlet.http.Cookie class
String fingerprintCookie = "__Secure-Fgp=" + userFingerprint
                           + "; SameSite=Strict; HttpOnly; Secure";
response.addHeader("Set-Cookie", fingerprintCookie);

//Compute a SHA256 hash of the fingerprint in order to store the
//fingerprint hash (instead of the raw value) in the token
//to prevent an XSS to be able to read the fingerprint and
//set the expected cookie itself
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] userFingerprintDigest = digest.digest(userFingerprint.getBytes("utf-8"));
String userFingerprintHash = DatatypeConverter.printHexBinary(userFingerprintDigest);

//Create the token with a validity of 15 minutes and client context (fingerprint) information
Calendar c = Calendar.getInstance();
Date now = c.getTime();
c.add(Calendar.MINUTE, 15);
Date expirationDate = c.getTime();
Map<String, Object> headerClaims = new HashMap<>();
headerClaims.put("typ", "JWT");
String token = JWT.create().withSubject(login)
   .withExpiresAt(expirationDate)
   .withIssuer(this.issuerID)
   .withIssuedAt(now)
   .withNotBefore(now)
   .withClaim("userFingerprint", userFingerprintHash)
   .withHeader(headerClaims)
   .sign(Algorithm.HMAC256(this.keyHMAC));

验证令牌的代码。

// HMAC key - Block serialization and storage as String in JVM memory
private transient byte[] keyHMAC = ...;

...

//Retrieve the user fingerprint from the dedicated cookie
String userFingerprint = null;
if (request.getCookies() != null && request.getCookies().length > 0) {
 List<Cookie> cookies = Arrays.stream(request.getCookies()).collect(Collectors.toList());
 Optional<Cookie> cookie = cookies.stream().filter(c -> "__Secure-Fgp"
                                            .equals(c.getName())).findFirst();
 if (cookie.isPresent()) {
   userFingerprint = cookie.get().getValue();
 }
}

//Compute a SHA256 hash of the received fingerprint in cookie in order to compare
//it to the fingerprint hash stored in the token
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] userFingerprintDigest = digest.digest(userFingerprint.getBytes("utf-8"));
String userFingerprintHash = DatatypeConverter.printHexBinary(userFingerprintDigest);

//Create a verification context for the token
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(keyHMAC))
                              .withIssuer(issuerID)
                              .withClaim("userFingerprint", userFingerprintHash)
                              .build();

//Verify the token, if the verification fail then an exception is thrown
DecodedJWT decodedToken = verifier.verify(token);

用户无法内置撤销令牌

症状

这个问题是JWT固有的,因为令牌仅在过期时才失效。用户没有内置功能来显式撤销令牌的有效性。这意味着如果令牌被盗,用户无法自行撤销令牌,从而阻止攻击者。

如何预防

由于JWT是无状态的,因此在服务客户端请求的服务器上不维护任何会话。因此,服务器端没有会话需要失效。一个良好实施的令牌侧劫解决方案(如上所述)应该减轻在服务器端维护拒绝列表的需要。这是因为在令牌侧劫中使用的强化cookie可以被认为与传统会话系统中使用的会话ID一样安全,并且除非cookie和JWT都被拦截/窃取,否则JWT是不可用的。因此,可以通过从会话存储中清除JWT来“模拟”注销。如果用户选择关闭浏览器,则cookie和sessionStorage都会自动清除。

另一种防止这种情况的方法是实现一个令牌拒绝列表,该列表将用于模仿传统会话管理系统中的“注销”功能。

拒绝列表将保留令牌的摘要(SHA-256十六进制编码)以及撤销日期。此条目必须至少持续到令牌过期。

当用户想要“注销”时,它会调用一个专门的服务,该服务会将提供的用户令牌添加到拒绝列表中,从而立即使该令牌在应用程序中的后续使用无效。

实施示例

黑名单存储

具有以下结构的数据库表将用作中央拒绝列表存储。

create table if not exists revoked_token(jwt_token_digest varchar(255) primary key,
revocation_date timestamp default now());
令牌撤销管理

负责将令牌添加到拒绝列表并检查令牌是否已撤销的代码。

/**
* Handle the revocation of the token (logout).
* Use a DB in order to allow multiple instances to check for revoked token
* and allow cleanup at centralized DB level.
*/
public class TokenRevoker {

 /** DB Connection */
 @Resource("jdbc/storeDS")
 private DataSource storeDS;

 /**
  * Verify if a digest encoded in HEX of the ciphered token is present
  * in the revocation table
  *
  * @param jwtInHex Token encoded in HEX
  * @return Presence flag
  * @throws Exception If any issue occur during communication with DB
  */
 public boolean isTokenRevoked(String jwtInHex) throws Exception {
     boolean tokenIsPresent = false;
     if (jwtInHex != null && !jwtInHex.trim().isEmpty()) {
         //Decode the ciphered token
         byte[] cipheredToken = DatatypeConverter.parseHexBinary(jwtInHex);

         //Compute a SHA256 of the ciphered token
         MessageDigest digest = MessageDigest.getInstance("SHA-256");
         byte[] cipheredTokenDigest = digest.digest(cipheredToken);
         String jwtTokenDigestInHex = DatatypeConverter.printHexBinary(cipheredTokenDigest);

         //Search token digest in HEX in DB
         try (Connection con = this.storeDS.getConnection()) {
             String query = "select jwt_token_digest from revoked_token where jwt_token_digest = ?";
             try (PreparedStatement pStatement = con.prepareStatement(query)) {
                 pStatement.setString(1, jwtTokenDigestInHex);
                 try (ResultSet rSet = pStatement.executeQuery()) {
                     tokenIsPresent = rSet.next();
                 }
             }
         }
     }

     return tokenIsPresent;
 }


 /**
  * Add a digest encoded in HEX of the ciphered token to the revocation token table
  *
  * @param jwtInHex Token encoded in HEX
  * @throws Exception If any issue occur during communication with DB
  */
 public void revokeToken(String jwtInHex) throws Exception {
     if (jwtInHex != null && !jwtInHex.trim().isEmpty()) {
         //Decode the ciphered token
         byte[] cipheredToken = DatatypeConverter.parseHexBinary(jwtInHex);

         //Compute a SHA256 of the ciphered token
         MessageDigest digest = MessageDigest.getInstance("SHA-256");
         byte[] cipheredTokenDigest = digest.digest(cipheredToken);
         String jwtTokenDigestInHex = DatatypeConverter.printHexBinary(cipheredTokenDigest);

         //Check if the token digest in HEX is already in the DB and add it if it is absent
         if (!this.isTokenRevoked(jwtInHex)) {
             try (Connection con = this.storeDS.getConnection()) {
                 String query = "insert into revoked_token(jwt_token_digest) values(?)";
                 int insertedRecordCount;
                 try (PreparedStatement pStatement = con.prepareStatement(query)) {
                     pStatement.setString(1, jwtTokenDigestInHex);
                     insertedRecordCount = pStatement.executeUpdate();
                 }
                 if (insertedRecordCount != 1) {
                     throw new IllegalStateException("Number of inserted record is invalid," +
                     " 1 expected but is " + insertedRecordCount);
                 }
             }
         }

     }
 }

令牌信息泄露

症状

这种攻击发生在攻击者访问令牌(或一组令牌)并从中提取存储的信息(JWT的内容是base64编码的,但默认情况下不加密),以获取有关系统的信息。信息可以例如是安全角色、登录格式等。

如何预防

防止这种攻击的一种方法是使用对称算法(例如)加密令牌。

保护加密数据免受诸如Padding Oracle或任何其他使用密码分析的攻击也很重要。

为了实现所有这些目标,使用了AES-GCM算法,它提供带有关联数据的认证加密

更多详情请见此处

AEAD primitive (Authenticated Encryption with Associated Data) provides functionality of symmetric
authenticated encryption.

Implementations of this primitive are secure against adaptive chosen ciphertext attacks.

When encrypting a plaintext one can optionally provide associated data that should be authenticated
but not encrypted.

That is, the encryption with associated data ensures authenticity (ie. who the sender is) and
integrity (ie. data has not been tampered with) of that data, but not its secrecy.

See RFC5116: https://tools.ietf.org/html/rfc5116

注意

这里添加加密主要是为了隐藏内部信息,但非常重要的是要记住,防止JWT篡改的首要保护是签名。因此,令牌签名及其验证必须始终存在。

实施示例

令牌加密

负责管理加密的代码。使用Google Tink专用加密库来处理加密操作,以便利用此库提供的内置最佳实践。

/**
 * Handle ciphering and deciphering of the token using AES-GCM.
 *
 * @see "https://github.com/google/tink/blob/master/docs/JAVA-HOWTO.md"
 */
public class TokenCipher {

    /**
     * Constructor - Register AEAD configuration
     *
     * @throws Exception If any issue occur during AEAD configuration registration
     */
    public TokenCipher() throws Exception {
        AeadConfig.register();
    }

    /**
     * Cipher a JWT
     *
     * @param jwt          Token to cipher
     * @param keysetHandle Pointer to the keyset handle
     * @return The ciphered version of the token encoded in HEX
     * @throws Exception If any issue occur during token ciphering operation
     */
    public String cipherToken(String jwt, KeysetHandle keysetHandle) throws Exception {
        //Verify parameters
        if (jwt == null || jwt.isEmpty() || keysetHandle == null) {
            throw new IllegalArgumentException("Both parameters must be specified!");
        }

        //Get the primitive
        Aead aead = AeadFactory.getPrimitive(keysetHandle);

        //Cipher the token
        byte[] cipheredToken = aead.encrypt(jwt.getBytes(), null);

        return DatatypeConverter.printHexBinary(cipheredToken);
    }

    /**
     * Decipher a JWT
     *
     * @param jwtInHex     Token to decipher encoded in HEX
     * @param keysetHandle Pointer to the keyset handle
     * @return The token in clear text
     * @throws Exception If any issue occur during token deciphering operation
     */
    public String decipherToken(String jwtInHex, KeysetHandle keysetHandle) throws Exception {
        //Verify parameters
        if (jwtInHex == null || jwtInHex.isEmpty() || keysetHandle == null) {
            throw new IllegalArgumentException("Both parameters must be specified !");
        }

        //Decode the ciphered token
        byte[] cipheredToken = DatatypeConverter.parseHexBinary(jwtInHex);

        //Get the primitive
        Aead aead = AeadFactory.getPrimitive(keysetHandle);

        //Decipher the token
        byte[] decipheredToken = aead.decrypt(cipheredToken, null);

        return new String(decipheredToken);
    }
}
令牌的创建/验证

在令牌创建和验证期间使用令牌加密处理器。

加载密钥(加密密钥是使用Google Tink生成和存储的)并设置加密器。

//Load keys from configuration text/json files in order to avoid to storing keys as a String in JVM memory
private transient byte[] keyHMAC = Files.readAllBytes(Paths.get("src", "main", "conf", "key-hmac.txt"));
private transient KeysetHandle keyCiphering = CleartextKeysetHandle.read(JsonKeysetReader.withFile(
Paths.get("src", "main", "conf", "key-ciphering.json").toFile()));

...

//Init token ciphering handler
TokenCipher tokenCipher = new TokenCipher();

令牌创建。

//Generate the JWT token using the JWT API...
//Cipher the token (String JSON representation)
String cipheredToken = tokenCipher.cipherToken(token, this.keyCiphering);
//Send the ciphered token encoded in HEX to the client in HTTP response...

令牌验证。

//Retrieve the ciphered token encoded in HEX from the HTTP request...
//Decipher the token
String token = tokenCipher.decipherToken(cipheredToken, this.keyCiphering);
//Verify the token using the JWT API...
//Verify access...

客户端令牌存储

症状

当应用程序以以下方式存储令牌时,会发生这种情况:

  • 浏览器自动发送(Cookie存储)。
  • 即使浏览器重启也能检索(使用浏览器localStorage容器)。
  • 在发生XSS问题时被检索(JavaScript代码可访问Cookie,或令牌存储在浏览器本地/会话存储中)。

如何预防

  1. 使用浏览器sessionStorage容器存储令牌,或使用JavaScript闭包私有变量。
  2. 在调用服务时,使用JavaScript将其作为Bearer HTTP Authentication 头添加。
  3. 指纹信息添加到令牌中。

将令牌存储在浏览器sessionStorage容器中会使令牌面临通过XSS攻击被窃取的风险。然而,添加到令牌中的指纹可以防止攻击者在其机器上重用被盗令牌。为了最大限度地缩小攻击者的可利用面,添加浏览器内容安全策略以强化执行上下文。

但是,我们知道sessionStorage由于其按标签页的范围,并非总是实用,令牌的存储方法应平衡安全性可用性

LocalStorage在可用性方面优于sessionStorage,因为它允许会话在浏览器重启和跨标签页之间持久化,但您必须使用严格的安全控制。

  • 存储在localStorage中的令牌应具有较短的过期时间(例如,15-30分钟的空闲超时,8小时的绝对超时)。
  • 实施诸如令牌轮换刷新令牌等机制以最小化风险。

如果需要跨标签页的会话持久性sessionStorage,请考虑使用BroadcastChannel API单点登录(SSO)在用户打开新标签页时自动重新验证身份。

存储令牌的另一种方法,除了浏览器sessionStoragelocalStorage之外,是使用JavaScript私有变量或闭包。在这种情况下,所有Web请求都通过一个JavaScript模块路由,该模块将令牌封装在一个私有变量中,除了该模块内部之外无法访问。

注意

  • 剩余的情况是当攻击者使用用户的浏览上下文作为代理,通过合法用户使用目标应用程序时,但内容安全策略可以阻止与非预期域的通信。
  • 还可以以强化cookie内签发令牌的方式实现认证服务,但在这种情况下,必须实现针对跨站请求伪造攻击的保护。

实施示例

身份验证后存储令牌的JavaScript代码。

/* Handle request for JWT token and local storage*/
function authenticate() {
    const login = $("#login").val();
    const postData = "login=" + encodeURIComponent(login) + "&password=test";

    $.post("/services/authenticate", postData, function (data) {
        if (data.status == "Authentication successful!") {
            ...
            sessionStorage.setItem("token", data.token);
        }
        else {
            ...
            sessionStorage.removeItem("token");
        }
    })
    .fail(function (jqXHR, textStatus, error) {
        ...
        sessionStorage.removeItem("token");
    });
}

调用服务时,将令牌作为Bearer HTTP Authentication头添加的JavaScript代码,例如此处的令牌验证服务。

/* Handle request for JWT token validation */
function validateToken() {
    var token = sessionStorage.getItem("token");

    if (token == undefined || token == "") {
        $("#infoZone").removeClass();
        $("#infoZone").addClass("alert alert-warning");
        $("#infoZone").text("Obtain a JWT token first :)");
        return;
    }

    $.ajax({
        url: "/services/validate",
        type: "POST",
        beforeSend: function (xhr) {
            xhr.setRequestHeader("Authorization", "bearer " + token);
        },
        success: function (data) {
            ...
        },
        error: function (jqXHR, textStatus, error) {
            ...
        },
    });
}

实现带私有变量的闭包的JavaScript代码

function myFetchModule() {
    // Protect the original 'fetch' from getting overwritten via XSS
    const fetch = window.fetch;

    const authOrigins = ["https://yourorigin", "https://"];
    let token = '';

    this.setToken = (value) => {
        token = value
    }

    this.fetch = (resource, options) => {
        let req = new Request(resource, options);
        destOrigin = new URL(req.url).origin;
        if (token && authOrigins.includes(destOrigin)) {
            req.headers.set('Authorization', token);
        }
        return fetch(req)
    }
}

...

// usage:
const myFetch = new myFetchModule()

function login() {
  fetch("/api/login")
      .then((res) => {
          if (res.status == 200) {
              return res.json()
          } else {
              throw Error(res.statusText)
          }
      })
      .then(data => {
          myFetch.setToken(data.token)
          console.log("Token received and stored.")
      })
      .catch(console.error)
}

...

// after login, subsequent api calls:
function makeRequest() {
    myFetch.fetch("/api/hello", {headers: {"MyHeader": "foobar"}})
        .then((res) => {
            if (res.status == 200) {
                return res.text()
            } else {
                throw Error(res.statusText)
            }
        }).then(responseText => console.log("helloResponse", responseText))
        .catch(console.error)
}

弱令牌密钥

症状

当使用基于HMAC的算法保护令牌时,令牌的安全性完全取决于HMAC所用密钥的强度。如果攻击者可以获得有效的JWT,他们就可以进行离线攻击,并尝试使用诸如John the RipperHashcat之类的工具来破解密钥。

如果他们成功了,他们就可以修改令牌并使用他们获得的密钥重新签名。这可能使他们提升权限、危及其他用户的账户,或根据JWT的内容执行其他操作。

有许多指南更详细地记录了此过程。

如何预防

防止这种攻击最简单的方法是确保用于签名JWT的密钥强大且唯一,以便攻击者更难破解。由于此密钥无需人工输入,因此它应该至少有64个字符,并使用安全的随机源生成。

或者,考虑使用RSA签名而不是HMAC和密钥签名的令牌。

延伸阅读