Перейти к основному содержимому

JWS + JWK в приложении Spring Security OAuth2

· 13 мин. чтения

1. Обзор

В этом руководстве мы узнаем о веб-подписи JSON (JWS) и о том, как ее можно реализовать с помощью спецификации веб-ключа JSON (JWK) в приложениях, настроенных с помощью Spring Security OAuth2.

Мы должны помнить, что даже несмотря на то, что Spring работает над переносом всех функций Spring Security OAuth на платформу Spring Security , это руководство по-прежнему является хорошей отправной точкой для понимания основных концепций этих спецификаций, и оно должно пригодиться в то время. реализации их на любом фреймворке.

Во-первых, мы попытаемся понять основные понятия; например, что такое JWS и JWK, их цель и как мы можем легко настроить сервер ресурсов для использования этого решения OAuth.

Затем мы пойдем глубже, мы подробно проанализируем спецификации, проанализировав, что OAuth2 Boot делает за кулисами, и настроим сервер авторизации для использования JWK.

2. Понимание общей картины JWS и JWK

./bfa2c42375fa9bd5e9731ebc8e0aaefc.png

Прежде чем начать, важно правильно понять некоторые основные понятия. Рекомендуется сначала ознакомиться с нашими статьями об OAuth и JWT , поскольку эти темы не являются частью этого руководства.

JWS — это спецификация, созданная IETF, которая описывает различные криптографические механизмы для проверки целостности данных , а именно данных в JSON Web Token (JWT) . Он определяет структуру JSON, содержащую необходимую для этого информацию.

Это ключевой аспект широко используемой спецификации JWT, поскольку утверждения должны быть либо подписаны, либо зашифрованы, чтобы считаться надежно защищенными.

В первом случае JWT представлен как JWS. Хотя, если он зашифрован, JWT будет закодирован в структуре JSON Web Encryption (JWE).

Наиболее распространенный сценарий при работе с OAuth — только что подписанные JWT. Это связано с тем, что нам обычно не нужно «скрывать» информацию, а просто проверять целостность данных.

Конечно, независимо от того, работаем ли мы с подписанными или зашифрованными JWT, нам нужны формальные рекомендации, чтобы иметь возможность эффективно передавать открытые ключи.

Это цель JWK , структуры JSON, представляющей криптографический ключ, также определенный IETF.

Многие поставщики аутентификации предлагают конечную точку «JWK Set» , также определенную в спецификациях. С его помощью другие приложения могут найти информацию об открытых ключах для обработки JWT.

Например, сервер ресурсов использует поле kid (идентификатор ключа), присутствующее в JWT, для поиска правильного ключа в наборе JWK.

2.1. Внедрение решения с помощью JWK

Как правило, если мы хотим, чтобы наше приложение обслуживало ресурсы безопасным образом, например, с использованием стандартного протокола безопасности, такого как OAuth 2.0, нам необходимо выполнить следующие шаги:

  1. Регистрировать клиентов на сервере авторизации — либо в нашем собственном сервисе, либо в известном провайдере, таком как Okta, Facebook или Github.
  2. Эти клиенты будут запрашивать токен доступа с сервера авторизации, следуя любой из стратегий OAuth, которые мы могли настроить.
  3. Затем они попытаются получить доступ к ресурсу, представляющему токен (в данном случае, как JWT) к серверу ресурсов.
  4. Сервер ресурсов должен убедиться, что токен не подвергался манипуляциям, проверив его подпись , а также подтвердить свои утверждения .
  5. И, наконец, наш сервер ресурсов извлекает ресурс, теперь будучи уверенным, что у клиента есть правильные разрешения.

3. JWK и конфигурация сервера ресурсов

Позже мы увидим, как настроить собственный сервер авторизации, который обслуживает JWT и конечную точку «JWK Set».

Однако на этом этапе мы сосредоточимся на самом простом и, вероятно, наиболее распространенном сценарии, в котором мы указываем на существующий сервер авторизации.

Все, что нам нужно сделать, это указать, как служба должна проверять полученный токен доступа, например, какой открытый ключ следует использовать для проверки подписи JWT.

Мы будем использовать функции Autoconfig Spring Security OAuth, чтобы добиться этого простым и понятным способом, используя только свойства приложения.

3.1. Зависимость от Maven

Нам нужно добавить зависимость автоматической настройки OAuth2 в файл pom нашего приложения Spring:

<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.1.6.RELEASE</version>
</dependency>

Как обычно, последнюю версию артефакта мы можем проверить в Maven Central .

Обратите внимание, что эта зависимость не управляется Spring Boot, и поэтому нам нужно указать ее версию.

В любом случае он должен соответствовать версии Spring Boot, которую мы используем.

3.2. Настройка сервера ресурсов

Затем нам нужно включить функции Resource Server в нашем приложении с помощью аннотации @EnableResourceServer :

@SpringBootApplication
@EnableResourceServer
public class ResourceServerApplication {

public static void main(String[] args) {
SpringApplication.run(ResourceServerApplication.class, args);
}
}

Теперь нам нужно указать, как наше приложение может получить открытый ключ, необходимый для проверки подписи JWT, которое оно получает в качестве токенов носителя.

OAuth2 Boot предлагает различные стратегии проверки токена.

Как мы уже говорили, большинство серверов авторизации предоставляют URI с набором ключей, которые другие службы могут использовать для проверки подписи.

Мы настроим конечную точку JWK Set локального сервера авторизации, над которой будем работать дальше.

Давайте добавим следующее в наш application.properties :

security.oauth2.resource.jwk.key-set-uri=
http://localhost:8081/sso-auth-server/.well-known/jwks.json

Мы рассмотрим другие стратегии по мере подробного анализа этой темы.

Примечание . Новый сервер ресурсов Spring Security 5.1 поддерживает только подписанные JWK JWT в качестве авторизации, и Spring Boot также предлагает очень похожее свойство для настройки конечной точки JWK Set:

spring.security.oauth2.resourceserver.jwk-set-uri=
http://localhost:8081/sso-auth-server/.well-known/jwks.json

3.3. Spring Конфигурации под капотом

Свойство, которое мы добавили ранее, преобразуется в создание пары компонентов Spring.

Точнее, загрузка OAuth2 создаст:

  • JwkTokenStore с единственной возможностью декодировать JWT и проверять его подпись
  • экземпляр DefaultTokenServices для использования бывшего TokenStore

4. Конечная точка JWK Set на сервере авторизации

Теперь мы углубимся в эту тему, проанализировав некоторые ключевые аспекты JWK и JWS при настройке сервера авторизации, который выдает JWT и обслуживает конечную точку JWK Set.

Обратите внимание, что, поскольку Spring Security еще не предлагает функции для настройки сервера авторизации, единственным вариантом на данном этапе является создание сервера с использованием возможностей Spring Security OAuth. Однако он будет совместим с Spring Security Resource Server.

4.1. Включение функций сервера авторизации

Первым шагом является настройка нашего сервера авторизации для выдачи токенов доступа при необходимости.

Мы также добавим зависимость spring-security-oauth2-autoconfigure , как мы это сделали с Resource Server.

Во-первых, мы будем использовать аннотацию @EnableAuthorizationServer для настройки механизмов сервера авторизации OAuth2:

@Configuration
@EnableAuthorizationServer
public class JwkAuthorizationServerConfiguration {

// ...

}

И мы зарегистрируем клиент OAuth 2.0, используя свойства:

security.oauth2.client.client-id=bael-client
security.oauth2.client.client-secret=bael-secret

При этом наше приложение будет извлекать случайные токены по запросу с соответствующими учетными данными:

curl bael-client:bael-secret\
@localhost:8081/sso-auth-server/oauth/token \
-d grant_type=client_credentials \
-d scope=any

Как мы видим, Spring Security OAuth по умолчанию извлекает случайное строковое значение, а не кодированное JWT:

"access_token": "af611028-643f-4477-9319-b5aa8dc9408f"

4.2. Выдача JWT

Мы можем легко изменить это, создав bean-компонент JwtAccessTokenConverter в контексте:

@Bean
public JwtAccessTokenConverter accessTokenConverter() {
return new JwtAccessTokenConverter();
}

и используя его в экземпляре JwtTokenStore :

@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}

Итак, с этими изменениями давайте запросим новый токен доступа, и на этот раз мы получим JWT, закодированный как JWS, если быть точным.

Мы можем легко идентифицировать JWS; их структура состоит из трех полей (заголовок, полезная нагрузка и подпись), разделенных точкой:

"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzY29wZSI6WyJhbnkiXSwiZXhwIjoxNTYxOTcy...
.
XKH70VUHeafHLaUPVXZI9E9pbFxrJ35PqBvrymxtvGI"

По умолчанию Spring подписывает заголовок и полезную нагрузку, используя подход кода аутентификации сообщения (MAC).

Мы можем убедиться в этом, проанализировав JWT в одном из множества онлайн-инструментов декодера/верификатора JWT, которые мы можем там найти.

Если мы расшифруем полученный JWT, то увидим, что значение атрибута alg равно HS256 , что указывает на то, что для подписи токена использовался алгоритм HMAC-SHA256 .

Чтобы понять, почему при таком подходе нам не нужны JWK, мы должны понять, как работает функция хеширования MAC.

4.3. Симметричная подпись по умолчанию

Хеширование MAC использует один и тот же ключ для подписи сообщения и проверки его целостности; это симметричная хэш-функция.

Поэтому в целях безопасности приложение не может публично делиться своим ключом подписи.

Только по академическим причинам мы опубликуем конечную точку Spring Security OAuth /oauth/token_key :

security.oauth2.authorization.token-key-access=permitAll()

И мы настроим значение ключа подписи при настройке bean-компонента JwtAccessTokenConverter :

converter.setSigningKey("bael");

Чтобы точно знать, какой симметричный ключ используется.

Примечание: даже если мы не опубликуем ключ подписи, установка слабого ключа подписи представляет собой потенциальную угрозу атак по словарю.

Как только мы узнаем ключ подписи, мы можем вручную проверить целостность токена с помощью онлайн-инструмента, о котором мы упоминали ранее.

Библиотека Spring Security OAuth также настраивает конечную точку /oauth/check_token , которая проверяет и извлекает декодированный JWT.

Эта конечная точка также настроена с помощью правила доступа denyAll() и должна быть защищена сознательно. Для этой цели мы могли бы использовать свойство security.oauth2.authorization.check-token-access , как мы делали ранее для ключа токена.

4.4. Альтернативы конфигурации сервера ресурсов

В зависимости от наших потребностей в безопасности мы могли бы подумать, что достаточно обеспечить надлежащую защиту одной из недавно упомянутых конечных точек, сделав их доступными для серверов ресурсов.

Если это так, то мы можем оставить сервер авторизации как есть и выбрать другой подход для сервера ресурсов.

Сервер ресурсов будет ожидать, что сервер авторизации будет иметь защищенные конечные точки, поэтому для начала нам нужно будет предоставить учетные данные клиента с теми же свойствами, которые мы использовали на сервере авторизации:

security.oauth2.client.client-id=bael-client
security.oauth2.client.client-secret=bael-secret

Затем мы можем использовать конечную точку /oauth/check_token (также известную как конечная точка самоанализа) или получить один ключ из /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

В качестве альтернативы мы можем просто настроить ключ, который будет использоваться для проверки токена в службе ресурсов:

## Verifier Key
security.oauth2.resource.jwt.key-value=bael

При таком подходе не будет взаимодействия с Сервером авторизации, но, конечно, это означает меньшую гибкость в отношении изменений с конфигурацией подписи токена.

Как и в случае со стратегией ключевого URI, этот последний подход можно рекомендовать только для алгоритмов асимметричной подписи.

4.5. Создание файла хранилища ключей

Давайте не будем забывать нашу конечную цель. Мы хотим предоставить конечную точку JWK Set, как это делают самые известные поставщики.

Если мы собираемся обмениваться ключами, будет лучше, если мы будем использовать асимметричную криптографию (в частности, алгоритмы цифровой подписи) для подписи токенов.

Первым шагом к этому является создание файла хранилища ключей.

Один простой способ добиться этого:

  1. откройте командную строку в каталоге /bin любого JDK или JRE, который у вас есть под рукой:
cd $JAVA_HOME/bin
  1. запустите команду keytool с соответствующими параметрами:
./keytool -genkeypair \
-alias bael-oauth-jwt \
-keyalg RSA \
-keypass bael-pass \
-keystore bael-jwt.jks \
-storepass bael-pass

Обратите внимание, что здесь мы использовали алгоритм RSA, который является асимметричным.

  1. ответить на интерактивные вопросы и создать файл хранилища ключей

4.6. Добавление файла хранилища ключей в наше приложение

Мы должны добавить хранилище ключей в ресурсы нашего проекта.

Это простая задача, но имейте в виду, что это двоичный файл. Это означает, что его нельзя отфильтровать , иначе он будет поврежден.

Если мы используем Maven, один из вариантов — поместить текстовые файлы в отдельную папку и соответствующим образом настроить pom.xml :

<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
</resource>
<resource>
<directory>src/main/resources/filtered</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>

4.7. Настройка хранилища токенов

Следующим шагом является настройка нашего TokenStore с помощью пары ключей; частный для подписи токенов и общедоступный для проверки целостности.

Мы создадим экземпляр KeyPair , используя файл хранилища ключей в пути к классам и параметры, которые мы использовали при создании файла .jks :

ClassPathResource ksFile =
new ClassPathResource("bael-jwt.jks");
KeyStoreKeyFactory ksFactory =
new KeyStoreKeyFactory(ksFile, "bael-pass".toCharArray());
KeyPair keyPair = ksFactory.getKeyPair("bael-oauth-jwt");

И мы настроим его в нашем bean-компоненте JwtAccessTokenConverter , удалив любую другую конфигурацию:

converter.setKeyPair(keyPair);

Мы можем снова запросить и декодировать JWT, чтобы проверить изменение параметра alg .

Если мы посмотрим на конечную точку Token Key, мы увидим открытый ключ, полученный из хранилища ключей.

Его легко узнать по заголовку PEM «Граница инкапсуляции»; строка, начинающаяся с « ——BEGIN PUBLIC KEY—— » .

4.8. Зависимости конечной точки набора JWK

Библиотека Spring Security OAuth не поддерживает JWK по умолчанию.

Следовательно, нам нужно добавить в наш проект еще одну зависимость, nimbus-jose-jwt , которая обеспечивает некоторые базовые реализации JWK:

<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>7.3</version>
</dependency>

Помните, что мы можем проверить последнюю версию библиотеки с помощью поисковой системы Maven Central Repository .

4.9. Создание конечной точки набора JWK

Давайте начнем с создания bean-компонента JWKSet с использованием экземпляра KeyPair , который мы настроили ранее:

@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());
}

Теперь создать конечную точку довольно просто:

@RestController
public class JwkSetRestController {

@Autowired
private JWKSet jwkSet;

@GetMapping("/.well-known/jwks.json")
public Map<String, Object> keys() {
return this.jwkSet.toJSONObject();
}
}

Поле идентификатора ключа, которое мы настроили в экземпляре JWKSet , преобразуется в параметр kid .

Этот дочерний элемент является произвольным псевдонимом для ключа, и он обычно используется сервером ресурсов для выбора правильной записи из коллекции, поскольку тот же ключ должен быть включен в заголовок JWT.

Теперь мы сталкиваемся с новой проблемой; поскольку Spring Security OAuth не поддерживает JWK, выпущенные JWT не будут включать детский заголовок.

Давайте найдем обходной путь, чтобы решить эту проблему.

4.10. Добавление значения kid в заголовок JWT

Мы создадим новый класс , расширяющий JwtAccessTokenConverter , который мы использовали, и который позволяет добавлять записи заголовков в JWT:

public class JwtCustomHeadersAccessTokenConverter
extends JwtAccessTokenConverter {

// ...

}

В первую очередь нам потребуется:

  • настройте родительский класс, как мы это делали, настроив KeyPair , который мы настроили
  • получить объект Signer , который использует закрытый ключ из хранилища ключей
  • конечно, набор пользовательских заголовков, которые мы хотим добавить в структуру

Давайте настроим конструктор на основе этого:

private Map<String, String> customHeaders = new HashMap<>();
final RsaSigner signer;

public JwtCustomHeadersAccessTokenConverter(
Map<String, String> customHeaders,
KeyPair keyPair) {
super();
super.setKeyPair(keyPair);
this.signer = new RsaSigner((RSAPrivateKey) keyPair.getPrivate());
this.customHeaders = customHeaders;
}

Теперь мы переопределим метод encode . Наша реализация будет такой же, как и родительская, с той лишь разницей, что мы также будем передавать пользовательские заголовки при создании токена String :

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;
}

Теперь воспользуемся этим классом при создании bean- компонента JwtAccessTokenConverter :

@Bean
public JwtAccessTokenConverter accessTokenConverter() {
Map<String, String> customHeaders =
Collections.singletonMap("kid", "bael-key-id");
return new JwtCustomHeadersAccessTokenConverter(
customHeaders,
keyPair());
}

Мы готовы идти. Не забудьте вернуть обратно свойства Resource Server. Нам нужно использовать только свойство key-set-uri , которое мы настроили в начале руководства.

Мы можем запросить токен доступа, проверить его значение для детей и использовать его для запроса ресурса.

После извлечения открытого ключа сервер ресурсов сохраняет его внутри, сопоставляя с идентификатором ключа для будущих запросов.

5. Вывод

Из этого всеобъемлющего руководства по JWT, JWS и JWK мы узнали довольно много. Не только специфичные для Spring конфигурации, но и общие концепции безопасности, демонстрируя их в действии на практическом примере.

Мы видели базовую конфигурацию сервера ресурсов, который обрабатывает JWT с использованием конечной точки JWK Set.

Наконец, мы расширили базовые функции Spring Security OAuth, настроив сервер авторизации, эффективно предоставляющий конечную точку JWK Set.

Мы , как всегда, можем найти оба сервиса в нашем репозитории OAuth Github .