Published on

如何用 Vault 保护和管理 Spring Authorization Server JWT 密钥

Authors
  • avatar
    Name
    ReLive27
    Twitter

简介

在现代应用中,安全性是首要考虑因素,特别是在涉及用户身份认证和授权的服务中。JWT(JSON Web Token)在 OAuth 2.0 和 OpenID Connect 标准中扮演了重要角色。作为一种加密令牌,JWT 通常用于标识和验证用户或客户端的身份,不管是使用对称加密还是非对称加密算法生成JWT,对于密钥或私钥的安全存储始终是最关键的问题。本文将介绍如何使用 HashiCorp Vault 来保护和管理 Spring Authorization Server 构建的授权服务的 JWT 密钥,通过 Vault 的密钥管理功能,确保 JWT 密钥的安全性。

Vault 简介

Vault 是一个专为密钥和机密数据管理而设计的工具,支持集中化的密钥存储和动态凭据生成功能。Vault 的主要功能包括:

  • 密钥存储:使用加密方法保护敏感数据和密钥。
  • 动态凭据:能够为不同的应用生成和轮转独立的密钥。
  • 访问控制:基于策略管理,确保只有授权应用可以访问密钥。

通过 Spring Vault 将HashiCorp Vault无缝集成到 Spring 应用中,用于管理 Spring Authorization Server 授权服务的 JWT 密钥。

安装 Vault

本示例介绍使用docker安装Vault,所以需要你准备docker环境或者通过Vault官网介绍进行二进制安装。

请勿在生产环境使用下述简单的安装步骤,你应该设置访问控制策略。

1. 拉取 Vault 镜像

首先,拉取指定版本的 Vault 镜像(1.13.3):

docker pull vault:1.13.3

2. 运行 Vault 容器

以开发模式运行 Vault 容器:

docker run --cap-add=IPC_LOCK -d --name=dev-vault vault:1.13.3

3.查看 Vault 启动日志

查看Vault启动日志,获取Root Token:

docker logs -f dev-vault

日志输出示例:

                 Api Address: http://127.0.0.1:8200
                         Cgo: disabled
             Cluster Address: https://127.0.0.1:8201
                  Listener 1: tcp (addr: "127.0.0.1:8200", cluster address: "127.0.0.1:8201", max_request_duration: "1m30s", max_request_size: "33554432", tls: "disabled")
                   Log Level: info
                       Mlock: supported: false, enabled: false
               Recovery Mode: false
                     Storage: inmem
                     Version: Vault v1.13.3

    WARNING! dev mode is enabled! In this mode, Vault runs entirely in-memory
    and starts unsealed with a single unseal key. The root token is already
    authenticated to the CLI, so you can immediately begin using Vault.

    You may need to set the following environment variable:

        $ export VAULT_ADDR='http://127.0.0.1:8200'

    The unseal key and root token are displayed below in case you want to
    seal/unseal the Vault or re-authenticate.

    Unseal Key: 1+yv+v5mz+aSCK67X6slL3ECxb4UDL8ujWZU/ONBpn0=
    Root Token: s.XmpNPoi9sRhYtdKHaQhkHP6x

    Development mode should NOT be used in production installations!

4.配置 Vault 客户端

启动一个新的终端会话并进入容器:

docker exec -it dev-vault /bin/sh

设置 Vault 地址:

export VAULT_ADDR='http://127.0.0.1:8200'

将 Root Token 设置为环境变量:

export VAULT_TOKEN="s.XmpNPoi9sRhYtdKHaQhkHP6x"

5.验证 Vault 服务器是否正在运行 在容器内运行以下命令检查服务器状态:

vault status

如果您遇到如下错误,请检查VAULT_ADDR环境变量配置正确。

    Error checking seal status: Get "https://127.0.0.1:8200/v1/sys/seal-status": http: server gave HTTP response to HTTPS client

6. 启用 Transit 引擎

vault secrets enable transit

7. 创建支持签名的密钥

使用 rsa-2048 类型的密钥,以支持签名操作:

vault write -f transit/keys/oauth2 type="rsa-2048"

将 Spring Authorization Server 配置为使用 Vault 中的密钥

在 Spring Authorization Server 项目中,修改 application.yml 配置文件,配置Vault服务地址,注意token为Vault启动日志中的Root Token。

spring:
  application:
    name: authorization-service
  cloud:
    vault:
      scheme: http
      uri: http://127.0.0.1:8200
      authentication: token
      token: ${VAULT_TOKEN}
      fail-fast: true
      kv:
        enabled: true
        backend: transit

接下来我们需要自定义VaultJwtEncoder,使用Vault生成JWT签名。

public final class VaultJwtEncoder implements JwtEncoder {
    private final VaultOperations vaultOperations;
    private static final JwsHeader DEFAULT_JWS_HEADER = JwsHeader.with(SignatureAlgorithm.RS256).type(JOSEObjectType.JWT.getType()).build();
    private String key = "oauth2"; //key需要和vault创建签名密钥key保持一致

    public VaultJwtEncoder(VaultOperations vaultOperations) {
        Assert.notNull(vaultOperations, "vaultOperations cannot be null");
        this.vaultOperations = vaultOperations;
    }

    @SneakyThrows
    @Override
    public Jwt encode(JwtEncoderParameters parameters) throws JwtEncodingException {
        JwsHeader headers = parameters.getJwsHeader();
        if (headers == null) {
            headers = DEFAULT_JWS_HEADER;
        }
        JWSHeader jwsHeader = convert(headers);
        JwtClaimsSet claims = parameters.getClaims();
        JWTClaimsSet jwtClaimsSet = convert(claims);

        JWSObject jwsObject = new JWSObject(jwsHeader, new Payload(jwtClaimsSet.toJSONObject()));

        // Sign the JWS object
        String signingInput = new String(jwsObject.getSigningInput());
        Plaintext plaintext = Plaintext.of(signingInput).with(VaultTransitContext.builder().build());
        String signature = vaultOperations.opsForTransit().sign(key, plaintext).getSignature();

        // Attach the signature to the JWS object
        Base64URL signatureBase64URL = Base64URL.from(signature.startsWith("vault:v1:") ? signature.substring(9) : signature);
        jwsObject = new JWSObject(jwsHeader.toBase64URL(), new Payload(jwtClaimsSet.toJSONObject()), signatureBase64URL);

        // Serialize JWS to compact format
        String jws = jwsObject.serialize();
        return new Jwt(jws, claims.getIssuedAt(), claims.getExpiresAt(), headers.getHeaders(), claims.getClaims());

    }
...
}

其他Spring Authorization Server授权服务配置和之前文章一致,这里不再赘述,具体配置可以通过文末链接查看源码,这里我们只讲述与文章相关的核心代码逻辑。

资源服务配置Vault进行验证JWT签名

在我们使用Vault在授权服务生成JWT后,资源服务作为JWT的使用者,同样需要验签逻辑进行验证签名,允许客户端请求通过。

通过自定义VaultJwtDecoder解析JWT的签名字符串,由Vault验证签名是否有效,当签名有效解析JWT其他部分,当签名被篡改,抛出异常终止操作。

public class VaultJwtDecoder implements JwtDecoder {
    private String key = "oauth2";
    private OAuth2TokenValidator<Jwt> jwtValidator = JwtValidators.createDefault();
    private Converter<Map<String, Object>, Map<String, Object>> claimSetConverter = MappedJwtClaimSetConverter.withDefaults(Collections.emptyMap());
    private final VaultOperations vaultOperations;


    public VaultJwtDecoder(VaultOperations vaultOperations) {
        Assert.notNull(vaultOperations, "vaultOperations cannot be null");
        this.vaultOperations = vaultOperations;
    }

    public void setJwtValidator(OAuth2TokenValidator<Jwt> jwtValidator) {
        Assert.notNull(jwtValidator, "jwtValidator cannot be null");
        this.jwtValidator = jwtValidator;
    }

    @SneakyThrows
    @Override
    public Jwt decode(String token) throws JwtException {
        SignedJWT jwt = this.parse(token);
        Jwt createdJwt = this.createJwt(token, jwt);
        return this.validateJwt(createdJwt);
    }

    private SignedJWT parse(String token) {
        try {
            return SignedJWT.parse(token);
        } catch (Exception e) {
            log.trace("Failed to parse token", e);
            throw new BadJwtException(String.format("An error occurred while attempting to decode the Jwt: %s", e.getMessage()), e);
        }
    }

    private Jwt createJwt(String token, SignedJWT parsedJwt) {
        try {
            // Verify signature using Vault
            String signingInput = new String(parsedJwt.getSigningInput());
            String signature = parsedJwt.getSignature().toString();

            Plaintext plaintext = Plaintext.of(signingInput).with(VaultTransitContext.builder().build());
            boolean isValid = vaultOperations.opsForTransit().verify(key, plaintext, Signature.of("vault:v1:" + signature));

            if (!isValid) {
                throw new JOSEException("Token signature is not valid");
            }
            JWTClaimsSet jwtClaimsSet = parsedJwt.getJWTClaimsSet();
            Map<String, Object> headers = new LinkedHashMap<>(parsedJwt.getHeader().toJSONObject());
            Map<String, Object> claims = this.claimSetConverter.convert(jwtClaimsSet.getClaims());
            return Jwt.withTokenValue(token).headers((h) -> {
                h.putAll(headers);
            }).claims((c) -> {
                c.putAll(claims);
            }).build();
        } catch (JOSEException e) {
            log.trace("Failed to process JWT", e);
            throw new JwtException(String.format("An error occurred while attempting to decode the Jwt: %s", e.getMessage()), e);
        } catch (Exception e) {
            log.trace("Failed to process JWT", e);
            if (e.getCause() instanceof ParseException) {
                throw new BadJwtException(String.format("An error occurred while attempting to decode the Jwt: %s", "Malformed payload"), e);
            } else {
                throw new BadJwtException(String.format("An error occurred while attempting to decode the Jwt: %s", e.getMessage()), e);
            }
        }
    }
...
}

验证 JWT 签名和解密过程

通过 Vault 的密钥进行JWT签名和验证,确保所有的 JWT 都是通过 Vault 保护的密钥生成的。我们通过完整的OAuth2.0授权码流程来验证这一过程。

浏览器访问:http://127.0.0.1:8070/client/test

结论

通过将 Vault 集成到 Spring Authorization Server 中,我们可以安全地管理 JWT 密钥,实现了密钥的集中存储和访问控制。通过本文的介绍,希望你可以在自己的应用中尝试这一方法,为用户提供更安全的身份认证服务。

与往常一样,本文中使用的源代码可在 GitHub 上获得。