JWS + JWK em um aplicativo OAuth2 do Spring Security
1. Visão geral
Neste tutorial, aprenderemos sobre JSON Web Signature (JWS) e como ela pode ser implementada usando a especificação JSON Web Key (JWK) em aplicativos configurados com Spring Security OAuth2.
Devemos ter em mente que mesmo sendoSpring is working to migrate all the Spring Security OAuth features to the Spring Security framework, este guia ainda é um bom ponto de partida para entender os conceitos básicos dessas especificações e deve ser útil na hora de implementá-las em qualquer framework.
Primeiro, vamos tentar entender os conceitos básicos; como o que é JWS e JWK, sua finalidade e como podemos configurar facilmente um servidor de recursos para usar esta solução OAuth.
Em seguida, iremos mais fundo, analisaremos as especificações em detalhes, analisando o que a inicialização OAuth2 está fazendo nos bastidores e configurando um servidor de autorização para usar JWK.
2. Compreendendo o panorama geral do JWS e JWK
Antes de começar, é importante que entendamos corretamente alguns conceitos básicos. É aconselhável ler nossos artigosOAuth eJWT primeiro, pois esses tópicos não fazem parte do escopo deste tutorial.
JWS é uma especificação criada pela IETF quedescribes different cryptographic mechanisms to verify the integrity of data, ou seja, os dados em aJSON Web Token (JWT). Ele define uma estrutura JSON que contém as informações necessárias para isso.
É um aspecto importante na especificação JWT amplamente usada, uma vez que as reivindicações precisam ser assinadas ou criptografadas para serem consideradas efetivamente protegidas.
No primeiro caso, o JWT é representado como um JWS. Embora esteja criptografado, o JWT será codificado em uma estrutura JSON Web Encryption (JWE).
O cenário mais comum ao trabalhar com OAuth é ter assinado JWTs. Isso ocorre porque normalmente não precisamos “ocultar” informações, mas simplesmente verificar a integridade dos dados.
Obviamente, quer estejamos lidando com JWTs assinados ou criptografados, precisamos de diretrizes formais para poder transmitir as chaves públicas com eficiência
This is the purpose of JWK, uma estrutura JSON que representa uma chave criptográfica, definida também pela IETF.
Many Authentication providers offer a “JWK Set” endpoint, também definido nas especificações. Com ele, outros aplicativos podem encontrar informações sobre chaves públicas para processar JWTs.
Por exemplo, um servidor de recursos usa o campokid (ID da chave) presente no JWT para encontrar a chave correta no conjunto JWK.
2.1. Implementando uma solução usando JWK
Normalmente, se quisermos que nosso aplicativo forneça recursos de maneira segura, como usando um protocolo de segurança padrão, como OAuth 2.0, precisaremos seguir as próximas etapas:
-
Registre clientes em um servidor de autorização - em nosso próprio serviço ou em um provedor conhecido como Okta, Facebook ou Github
-
Esses clientes solicitarão um token de acesso do servidor de autorização, seguindo qualquer uma das estratégias OAuth que possamos ter configurado
-
Eles tentarão acessar o recurso que apresenta o token (neste caso, como um JWT) ao servidor de recursos
-
The Resource Server has to verify that the token hasn’t been manipulated by checking its signature, bem como validar suas reivindicações
-
E, finalmente, nosso servidor de recursos recupera o recurso, agora assegurando que o cliente tenha as permissões corretas
3. JWK e a configuração do servidor de recursos
Mais tarde, veremos como configurar nosso próprio servidor de autorização que atende JWTs e um ponto de extremidade ‘Conjunto de JWK’.
Neste ponto, porém, vamos nos concentrar no cenário mais simples - e provavelmente mais comum - em que estamos apontando para um servidor de autorização existente.
Tudo o que precisamos fazer é indicar como o serviço deve validar o token de acesso que recebe, por exemplo, qual chave pública deve usar para verificar a assinatura do JWT.
Usaremos os recursos doSpring Security OAuth’s Autoconfig para conseguir isso de uma forma simples e limpa, usando apenas as propriedades do aplicativo.
3.1. Dependência do Maven
Precisamos adicionar a dependência de configuração automática OAuth2 ao arquivo pom do nosso aplicativo Spring:
org.springframework.security.oauth.boot
spring-security-oauth2-autoconfigure
2.1.6.RELEASE
Como de costume, podemos verificar a versão mais recente do artefato emMaven Central.
Observe que essa dependência não é gerenciada pelo Spring Boot e, portanto, precisamos especificar sua versão.
Deve corresponder à versão do Spring Boot que estamos usando de qualquer maneira.
3.2. Configurando o servidor de recursos
Em seguida, teremos que habilitar os recursos do Resource Server em nosso aplicativo com a anotação@EnableResourceServer:
@SpringBootApplication
@EnableResourceServer
public class ResourceServerApplication {
public static void main(String[] args) {
SpringApplication.run(ResourceServerApplication.class, args);
}
}
Agora precisamos indicar como nosso aplicativo pode obter a chave pública necessária para validar a assinatura das JWTs que recebe como tokens de portador.
A inicialização do OAuth2 oferece estratégias diferentes para verificar o token.
Como dissemos antes,most Authorization servers expose a URI with a collection of keys that other services can use to validate the signature.
Configuraremos o endpoint de conjunto JWK de um servidor de autorização local no qual trabalharemos mais adiante.
Vamos adicionar o seguinte em nossoapplication.properties:
security.oauth2.resource.jwk.key-set-uri=
http://localhost:8081/sso-auth-server/.well-known/jwks.json
Vamos dar uma olhada em outras estratégias enquanto analisamos esse assunto em detalhes.
Note: o novo Spring Security 5.1 Resource Server só oferece suporte a JWTs assinados por JWK como autorização, e Spring Boot também oferece uma propriedade muito semelhante para configurar o endpoint de conjunto JWK:
spring.security.oauth2.resourceserver.jwk-set-uri=
http://localhost:8081/sso-auth-server/.well-known/jwks.json
3.3. Configurações de mola sob o capô
A propriedade que adicionamos anteriormente se traduz na criação de alguns beans Spring.
Mais precisamente, o OAuth2 Boot criará:
-
aJwkTokenStore com a única capacidade de decodificar um JWT e verificar sua assinatura
-
uma posiçãoDefaultTokenServices para usar o anteriorTokenStore
4. O ponto de extremidade definido do JWK no servidor de autorização
Agora vamos nos aprofundar neste assunto, analisando alguns aspectos principais de JWK e JWS à medida que configuramos um servidor de autorização que emite JWTs e atende seu endpoint de conjunto JWK.
Observe que, uma vez que o Spring Security ainda não oferece recursos para configurar um servidor de autorização, criar um usando os recursos OAuth do Spring Security é a única opção neste estágio. Porém, será compatível com o Spring Security Resource Server.
4.1. Habilitando os recursos do servidor de autorização
A primeira etapa é configurar nosso servidor de Autorização para emitir tokens de acesso quando necessário.
Também adicionaremosspring-security-oauth2-autoconfigure dependência como fizemos com o Resource Server.
Primeiro, usaremos a anotação@EnableAuthorizationServer para configurar os mecanismos do servidor de autorização OAuth2:
@Configuration
@EnableAuthorizationServer
public class JwkAuthorizationServerConfiguration {
// ...
}
E vamos registrar um cliente OAuth 2.0 usando propriedades:
security.oauth2.client.client-id=bael-client
security.oauth2.client.client-secret=bael-secret
Com isso, nosso aplicativo recuperará tokens aleatórios quando solicitado com as credenciais correspondentes:
curl bael-client:bael-secret\
@localhost:8081/sso-auth-server/oauth/token \
-d grant_type=client_credentials \
-d scope=any
Como podemos ver, Spring Security OAuthretrieves a random string value by default, not JWT-encoded:
"access_token": "af611028-643f-4477-9319-b5aa8dc9408f"
4.2. Emitindo JWTs
Podemos facilmente mudar isso criando um beanJwtAccessTokenConverter no contexto:
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
return new JwtAccessTokenConverter();
}
e usá-lo em uma instânciaJwtTokenStore:
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
Então, com essas mudanças, vamos solicitar um novo token de acesso e, desta vez, obteremos um JWT, codificado como JWS, para ser preciso.
Podemos identificar facilmente JWSs; sua estrutura consiste em três campos (cabeçalho, carga útil e assinatura) separados por um ponto:
"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzY29wZSI6WyJhbnkiXSwiZXhwIjoxNTYxOTcy...
.
XKH70VUHeafHLaUPVXZI9E9pbFxrJ35PqBvrymxtvGI"
Por padrão, o Spring assina o cabeçalho e a carga usando uma abordagem de Código de Autenticação de Mensagem (MAC).
Podemos verificar isso analisando o JWT em um dos muitosJWT decoder/verifier online tools que podemos encontrar lá.
Se decodificarmos o JWT que obtivemos, veremos que o valor do atributoalg éHS256, o que indica que um algoritmoHMAC-SHA256 foi usado para assinar o token.
Para entender por que não precisamos de JWKs com essa abordagem, temos que entender como funciona a função de hashing MAC.
4.3. A assinatura simétrica padrão
O hashing do MAC usa a mesma chave para assinar a mensagem e verificar sua integridade; é uma função de hashing simétrica.
Portanto, por motivos de segurança, o aplicativo não pode compartilhar publicamente sua chave de assinatura.
Apenas por motivos acadêmicos, tornaremos público o endpoint/oauth/token_key do Spring Security OAuth:
security.oauth2.authorization.token-key-access=permitAll()
E vamos personalizar o valor da chave de assinatura quando configurarmos oJwtAccessTokenConverter bean:
converter.setSigningKey("bael");
Para saber exatamente qual chave simétrica está sendo usada.
Observação: mesmo se não publicarmos a chave de assinatura, configurar uma chave de assinatura fraca é uma ameaça potencial aos ataques de dicionário.
Depois de conhecer a chave de assinatura, podemos verificar manualmente a integridade do token usando a ferramenta online mencionada anteriormente.
A biblioteca Spring Security OAuth também configura um endpoint/oauth/check_token que valida e recupera o JWT decodificado.
Este endpoint também é configurado com uma regra de acessodenyAll() e deve ser protegido conscientemente. Para este propósito, poderíamos usar a spropertysecurity.oauth2.authorization.check-token-access como fizemos para a chave de token antes.
4.4. Alternativas para a configuração do servidor de recursos
Dependendo de nossas necessidades de segurança, podemos considerar que proteger adequadamente um dos pontos de extremidade mencionados recentemente - enquanto os torna acessíveis aos Servidores de Recursos - é suficiente.
Se for esse o caso, podemos deixar o Authorization Server como está e escolher outra abordagem para o Resource Server.
O servidor de recursos espera que o servidor de autorização tenha pontos de extremidade protegidos, então, para começar, precisaremos fornecer as credenciais do cliente, com as mesmas propriedades que usamos no servidor de autorização:
security.oauth2.client.client-id=bael-client
security.oauth2.client.client-secret=bael-secret
Então, podemos escolher usar o endpoint/oauth/check_token (a.k.a. o ponto final de introspecção) ou obtenha uma única chave de/oauth/token_key:
## Single key URI:
security.oauth2.resource.jwt.key-uri=
http://localhost:8081/sso-auth-server/oauth/token_key
## Introspection endpoint:
security.oauth2.resource.token-info-uri=
http://localhost:8081/sso-auth-server/oauth/check_token
Como alternativa, podemos apenas configurar a chave que será usada para verificar o token no Serviço de Recurso:
## Verifier Key
security.oauth2.resource.jwt.key-value=bael
Com essa abordagem, não haverá interação com o Servidor de Autorização, mas é claro que isso significa menos flexibilidade nas alterações na configuração de assinatura do Token.
Como na estratégia principal de URI, essa última abordagem pode ser recomendada apenas para algoritmos de assinatura assimétrica.
4.5. Criando um arquivo de armazenamento de chaves
Não vamos esquecer nosso objetivo final. Queremos fornecer um terminal JWK Set, como os provedores mais conhecidos.
Se vamos compartilhar chaves, será melhor se usarmos criptografia assimétrica (particularmente, algoritmos de assinatura digital) para assinar os tokens.
O primeiro passo para isso é criar um arquivo keystore.
Uma maneira fácil de conseguir isso é:
-
abra a linha de comando no diretório/bin de qualquer JDK ou JRE que você tenha em mãos:
cd $JAVA_HOME/bin
-
execute o comandokeytool, com os parâmetros correspondentes:
./keytool -genkeypair \
-alias bael-oauth-jwt \
-keyalg RSA \
-keypass bael-pass \
-keystore bael-jwt.jks \
-storepass bael-pass
Observe que usamos um algoritmo RSA aqui, que é assimétrico.
-
responda às perguntas interativas e gere o arquivo keystore
4.6. Adicionando o arquivo Keystore ao nosso aplicativo
Temos que adicionar o keystore aos recursos do nosso projeto.
Essa é uma tarefa simples, mas lembre-se de que este é um arquivo binário. Isso significa que não pode serfiltered ou ficará corrompido.
Se estivermos usando o Maven, uma alternativa é colocar os arquivos de texto em uma pasta separada e configurarpom.xml adequadamente:
src/main/resources
false
src/main/resources/filtered
true
4.7. Configurando oTokenStore
A próxima etapa é configurar nossoTokenStore com o par de chaves; o privado para assinar os tokens e o público para validar a integridade.
Criaremos uma posiçãoKeyPair instance empregando o arquivo keystore no classpath e os parâmetros que usamos quando criamos o arquivo.jks:
ClassPathResource ksFile =
new ClassPathResource("bael-jwt.jks");
KeyStoreKeyFactory ksFactory =
new KeyStoreKeyFactory(ksFile, "bael-pass".toCharArray());
KeyPair keyPair = ksFactory.getKeyPair("bael-oauth-jwt");
E vamos configurá-lo em nosso beanJwtAccessTokenConverter, removendo qualquer outra configuração:
converter.setKeyPair(keyPair);
Podemos solicitar e decodificar um JWT novamente para verificar o parâmetroalg alterado.
Se dermos uma olhada no endpoint da chave token, veremos a chave pública obtida no armazenamento de chaves.
É facilmente identificável pelo cabeçalhoPEM “Encapsulation Boundary”; a string começando com “—–BEGIN PUBLIC KEY—–“.
4.8. As dependências do ponto de extremidade definido pelo JWK
A biblioteca Spring Security OAuth não oferece suporte a JWK fora da caixa.
Consequentemente, precisaremos adicionar outra dependência ao nosso projeto,nimbus-jose-jwt, que fornece algumas implementações básicas do JWK:
com.nimbusds
nimbus-jose-jwt
7.3
Lembre-se de que podemos verificar a versão mais recente da biblioteca usandothe Maven Central Repository Search Engine.
4.9. Criando o ponto de extremidade do conjunto JWK
Vamos começar criando um beanJWKSet usando a instânciaKeyPair que configuramos anteriormente:
@Bean
public JWKSet jwkSet() {
RSAKey.Builder builder = new RSAKey.Builder((RSAPublicKey) keyPair().getPublic())
.keyUse(KeyUse.SIGNATURE)
.algorithm(JWSAlgorithm.RS256)
.keyID("bael-key-id");
return new JWKSet(builder.build());
}
Agora, a criação do terminal é bastante simples:
@RestController
public class JwkSetRestController {
@Autowired
private JWKSet jwkSet;
@GetMapping("/.well-known/jwks.json")
public Map keys() {
return this.jwkSet.toJSONObject();
}
}
O campo Key Id que configuramos na instânciaJWKSet e traduz no parâmetrokid.
This kid is an arbitrary alias for the key, e é geralmente usado pelo servidor de recursos paraselect the correct entry from the collection, já que a mesma chave devebe included in the JWT Header.
Enfrentamos um novo problema agora; uma vez que Spring Security OAuth não oferece suporte a JWK, os JWTs emitidos não incluirão o cabeçalhokid.
Vamos encontrar uma solução alternativa para isso.
4.10. Adicionando o valorkid ao cabeçalho JWT
Criaremos um novoclass estendendo oJwtAccessTokenConverter que swe vem usando e que permite adicionar entradas de cabeçalho aos JWTs:
public class JwtCustomHeadersAccessTokenConverter
extends JwtAccessTokenConverter {
// ...
}
Em primeiro lugar, precisamos:
-
configurar a classe pai como temos feito, configurando oKeyPair que configuramos
-
obter um objetoSigner que usa a chave privada do armazenamento de chaves
-
é claro, uma coleção de cabeçalhos personalizados que queremos adicionar à estrutura
Vamos configurar o construtor com base nisso:
private Map customHeaders = new HashMap<>();
final RsaSigner signer;
public JwtCustomHeadersAccessTokenConverter(
Map customHeaders,
KeyPair keyPair) {
super();
super.setKeyPair(keyPair);
this.signer = new RsaSigner((RSAPrivateKey) keyPair.getPrivate());
this.customHeaders = customHeaders;
}
Agora vamos substituir o métodoencode . Nossa implementação será a mesma que a principal, com a única diferença de que também passaremos os cabeçalhos personalizados ao criar o tokenString:
private JsonParser objectMapper = JsonParserFactory.create();
@Override
protected String encode(OAuth2AccessToken accessToken,
OAuth2Authentication authentication) {
String content;
try {
content = this.objectMapper
.formatMap(getAccessTokenConverter()
.convertAccessToken(accessToken, authentication));
} catch (Exception ex) {
throw new IllegalStateException(
"Cannot convert access token to JSON", ex);
}
String token = JwtHelper.encode(
content,
this.signer,
this.customHeaders).getEncoded();
return token;
}
Vamos usar esta classe agora ao criar o beanJwtAccessTokenConverter:
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
Map customHeaders =
Collections.singletonMap("kid", "bael-key-id");
return new JwtCustomHeadersAccessTokenConverter(
customHeaders,
keyPair());
}
Estamos prontos para ir. Lembre-se de alterar as propriedades do servidor de recursos de volta. Precisamos usar apenas a propriedadekey-set-uri que configuramos no início do tutorial.
Podemos solicitar um token de acesso, verificar seu valorkid e usá-lo para solicitar um recurso.
Assim que a chave pública é recuperada, o Servidor de Recursos a armazena internamente, mapeando-a para a ID da Chave para solicitações futuras.
5. Conclusão
Aprendemos muito neste guia abrangente sobre JWT, JWS e JWK. Não apenas configurações específicas do Spring, mas também conceitos gerais de segurança, vendo-os em ação com um exemplo prático.
Vimos a configuração básica de um servidor de recursos que lida com JWTs usando um endpoint de conjunto JWK.
Por último, estendemos os recursos básicos do Spring Security OAuth, configurando um servidor de autorização expondo um endpoint de conjunto JWK de forma eficiente.
Podemos encontrar os dois serviços emour OAuth Github repo, como sempre.