«1. Обзор

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

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

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

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

2. Общее представление о JWS и JWK

Прежде чем начать, важно правильно понять некоторые основные понятия. Рекомендуется сначала ознакомиться с нашими статьями об 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 (ID ключа), присутствующее в JWT, для поиска правильного ключа в наборе JWK.

2.1. Реализация решения с использованием JWK

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

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

  1. Register Clients in an Authorization Server – either in our own service, or in a well-known provider like Okta, Facebook or Github
  2. These Clients will request an access token from the Authorization Server, following any of the OAuth strategies we might have configured
  3. They will then try to access the resource presenting the token (in this case, as a JWT) to the Resource Server
  4. The Resource Server has to verify that the token hasn’t been manipulated by checking its signature as well as validate its claims
  5. And finally, our Resource Server retrieves the resource, now being sure that the Client has the correct permissions

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

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

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

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

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

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

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

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

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

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

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

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

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

@SpringBootApplication
@EnableResourceServer
public class ResourceServerApplication {

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

«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:

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

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

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

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

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

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

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

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

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

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

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

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

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

@Configuration
@EnableAuthorizationServer
public class JwkAuthorizationServerConfiguration {

    // ...

}

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

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

4.2. Выпуск JWT

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

converter.setSigningKey("bael");

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

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

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

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

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

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

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

Затем мы можем выбрать использование конечной точки /oauth/check_token (также известной как конечная точка самоанализа) или получить один ключ из /oauth/token_key:

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

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

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

## 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

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

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

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

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

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

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

Один из простых способов добиться этого:

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

  1. open the command line in the /bin directory of any JDK or JRE you have in handy:
cd $JAVA_HOME/bin
  1. run the keytool command, with the corresponding parameters:
./keytool -genkeypair \
  -alias bael-oauth-jwt \
  -keyalg RSA \
  -keypass bael-pass \
  -keystore bael-jwt.jks \
  -storepass bael-pass

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

  1. answer the interactive questions and generate the keystore file

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

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

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

4.7. Настройка TokenStore

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

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

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

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

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

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

converter.setKeyPair(keyPair);

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

Его легко идентифицировать по заголовку PEM «Граница инкапсуляции»; строка, начинающаяся с «—— НАЧАТЬ ПУБЛИЧНЫЙ КЛЮЧ———.

4.8. JWK Set Endpoint Dependency

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

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

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

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

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

Давайте начнем с создания 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());
}

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

@RestController
public class JwkSetRestController {

    @Autowired
    private JWKSet jwkSet;

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

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

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

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

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

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

Прежде всего, мы необходимо:

public class JwtCustomHeadersAccessTokenConverter
  extends JwtAccessTokenConverter {

    // ...

}

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

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

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

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

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

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

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

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

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

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

5. Заключение

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

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

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

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

«