OAuth 2 Сlient Credentials Flow. Пример на Spring Boot

В этой статье рассмотрим пример Client Credentials flow с новым экспериментальным сервером авторизации на Spring Boot.  Новый сервер был анонсирован буквально недавно и еще не полностью готов (текущая версия 0.0.2). Но Client Credentials flow (и Authorization Code flow) он уже поддерживает.

Пример взят из официального репозитория, но переделан под Maven (вместо Gradle). Еще в нем включено логирование запросов. Так что в консоли мы увидим запросы – их отправляет ядро клиента и ядро сервера ресурсов с помощью RestTemplate и Webclient. Этих скрытых запросов много – все три сервера активно взаимодействуют.

Пример, как и положено, состоит из трёх компонентов. Все они являются Spring Boot приложениями и запускаются на разных серверах:

  • сервер авторизации. Здесь есть:
      •  конечная точка http://auth-server:9000/oauth2/token. Здесь происходит аутентификация клиента по Client Credentials и ему выдается access-token (он же JWT-токен)
      • конечная точка http://auth-server:9000/oauth2/jwks.  Здесь происходит проверка этого JWT-токена. Вообще этот токен отправляет клиент на сервер ресурсов с каждым запросом. Но сервер ресурсов проверяет его не сам, а обращается на эту точку, чтобы проверить токен.

     

  • сервер ресурсов. Он получает запрос от клиента с JWT-токеном и отправляет этот токен серверу авторизации на проверку. Если все ок, то выдает ресурс.
  • клиент. Он должен быть заранее зарегистрирован на сервере авторизации со своими Client Credentials. Клиент отправляет эти Client Credentials серверу авторизации, чтобы получить JWT-токен, а затем шлет запросы с приложенным JWT-токеном серверу ресурсов.

Вообще Client Credentials flow используется, если клиент – не человек, а машина. То есть перенаправления на страницу сервера авторизации и получения согласия живого пользователя не требуется, что упрощает flow. Наш клиент – это микросервис, который предоставляет API веб-интерфейсу. Но важно понимать, что несмотря на наличие веб-интерфейса, клиентом является именно приложение на сервере, а не в браузере. Приложение в браузере задействовало бы уже Implicit Flow, что не наш случай. (А наш случай – Client Credentials flow).

Перед запуском примера в файл /etc/hosts нужно добавить строку 127.0.0.1 auth-server

Client Credentials flow

Flow уже был изложен выше при перечислении трех серверов, но теперь то же самое на картинке, а затем по пунктам:

Выглядит так:

Client Credentials Flow
Client Credentials Flow

Предварительно мы регистрируем клиент на сервере авторизации с client_id и client_secret. У нас значениями будут messaging-client и secret.

  1. Клиент (серверное приложение) отправляет Client Credentials (под которыми он зарегистрирован) на сервер авторизации.
  2. В ответ получает access token (он же JWT-токен).
  3. С access token клиент обращается на сервер ресурсов.
  4. Сервер ресурсов не может сам проверить access token, он обращается за проверкой на сервер авторизации.
  5. Если проверка прошла успешно, сервер ресурсов возвращает клиенту ресурс.

Сервер авторизации и регистрация на нем клиента

Чтобы сделать приложение сервером авторизации, добавим в него зависимость:

<dependency>
    <groupId>org.springframework.security.experimental</groupId>
    <artifactId>spring-security-oauth2-authorization-server</artifactId>
    <version>0.0.2</version>
</dependency>

Также понадобится spring-boot-starter-web и spring-boot-starter-security.

Клиента можно зарегистрировать In-Memory:

@EnableWebSecurity
@Import(OAuth2AuthorizationServerConfiguration.class)
public class AuthorizationServerConfig {

    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("messaging-client")
                .clientSecret("secret")
                .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
                //.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                //.redirectUri("http://localhost:8080/authorized")
                .scope("message.read")
                .scope("message.write")
                .clientSettings(clientSettings -> clientSettings.requireUserConsent(true))
                .build();
        return new InMemoryRegisteredClientRepository(registeredClient);
    }
    // @formatter:on

    @Bean
    public KeyManager keyManager() {
        return new StaticKeyGeneratingKeyManager();
    }

}

С помощью комментариев из исходного примера удалены строки, необходимые для Authorization Code flow, поскольку в статье мы его опускаем.

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

http://auth-server:9000/oauth2/token

мы получим JWT-токен в ответ на Client Credentials.

Запрос access токена

Давайте протестируем. В соответствии со спецификацией запрос следующий.

POST /oauth2/token HTTP/1.1
Host: auth-server:9000
Content-Type: application/x-www-form-urlencoded
Authorization: Basic bWVzc2FnaW5nLWNsaWVudDpzZWNyZXQ=
grant_type=client_credentials&scope=message.read message.write

Выше в заголовке Authorization помощью Base64 зашифрованы значения через двоеточие messaging-client:secret. Это стандартная Basic-аутентификация.

Создадим этот запрос в Postman:

Ответ с access токеном

Ответ такой:

{
    "access_token": "eyJraWQiOiI5ZGM3MDA3Mi0xMDk2LTRkYjctOTRkYi1kY2RmMzE0OTJjZjIiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJtZXNzYWdpbmctY2xpZW50IiwiYXVkIjoibWVzc2FnaW5nLWNsaWVudCIsIm5iZiI6MTYwMzcwOTg4NSwic2NvcGUiOlsibWVzc2FnZS5yZWFkIiwibWVzc2FnZS53cml0ZSJdLCJpc3MiOiJodHRwczpcL1wvb2F1dGgyLnByb3ZpZGVyLmNvbSIsImV4cCI6MTYwMzcxMzQ4NSwiaWF0IjoxNjAzNzA5ODg1LCJqdGkiOiI1N2RmY2UxNi1lOGRiLTRlY2MtYTdhOC0zZjc5MjRmOTJjMmUifQ.RBSysR0WVSQBXsFPfFg_u2QNxlE7HANdWnUuROxxzJbvTfapQ3T8WB0QS8V46f4yGaWymNzLAz8e_rdBsY3AKXMlZv7H0bDwifqJBQde2bpYPUIdLdzO3lJkWqMkUGY32tM1hN9ZOOrk9sQ9fW5PuKgZGXZpsKMc8DLZrzuhavc7lM94ft5EJOuEBwlOcBUOTx0qOrL6BkLGm1dcmVmTP983vgd7A9pSwY9ZHlKLfdVjQK3iFyrGA0WF7NEQQ8soWqW_otHapUQ08XVRW4vcsUSyH1QAHqonGk2R7T6dv18Vk_9nGjUfbmS4pGswgLRE0JnhSXZoTWsKHh7i-ixXpA",
    "scope": "message.read message.write",
    "token_type": "Bearer",
    "expires_in": "3599"
}

С этим токеном (access_token) можно будет обращаться на сервер ресурсов. Если расшифровать токен на сайте jwt.io, мы увидим, что в нем есть идентификатор клиента, его права, издатель токена, время жизни токена:

{
  "sub": "messaging-client",
  "aud": "messaging-client",
  "nbf": 1603709885,
  "scope": [
    "message.read",
    "message.write"
  ],
  "iss": "https://oauth2.provider.com",
  "exp": 1603713485,
  "iat": 1603709885,
  "jti": "57dfce16-e8db-4ecc-a7a8-3f7924f92c2e"
}

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

Сервер ресурсов

На сервер ресурсов будем обращаться на /messages за данными:

@RestController
public class MessagesController {

    @GetMapping("/messages")
    public String[] getMessages() {
        return new String[] {"Message 1", "Message 2", "Message 3"};
    }
}

То, что /messages – защищенный url, указываем в конфигурации:

@EnableWebSecurity
public class ResourceServerConfig extends WebSecurityConfigurerAdapter {
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .mvcMatcher("/messages/**")
                .authorizeRequests()
                    .mvcMatchers("/messages/**").access("hasAuthority('SCOPE_message.read')")
                    .and()
                .oauth2ResourceServer()
                    .jwt();
    }

}

В вышеприведенном коде мы сказали, что авторизация осуществляется через OAuth 2, то есть для доступа необходимо иметь в запросе заголовок Authorization с JWT-токеном (он же access token).

Но как сервер ресурсов поймет, корректен ли пришедший в заголовке JWT-токен? Для этого в настройках application.yml указываем, куда сервер ресурсов должен обращаться за проверкой корректности:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: http://auth-server:9000/oauth2/jwks

То есть обращаться он должен на сервер авторизации http://auth-server:9000/oauth2/jwks. Сервер авторизации выдавал токен, он его и проверит.

Конечно, в POM-файле сервера ресурсов необходима зависимость:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

Теперь можно обращаться на сервер ресурсов с полученным выше токеном и получить данные.

Запрос на сервер ресурсов и ответ

Составим запрос с полученным токеном. Токен необходимо помещать в заголовок Authorization с префиксом Bearer:

GET /messages HTTP/1.1
Host: localhost:8090
Authorization: Bearer eyJraWQiOiI5ZGM3MDA3Mi0xMDk2LTRkYjctOTRkYi1kY2RmMzE0OTJjZjIiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJtZXNzYWdpbmctY2xpZW50IiwiYXVkIjoibWVzc2FnaW5nLWNsaWVudCIsIm5iZiI6MTYwMzcwOTg4NSwic2NvcGUiOlsibWVzc2FnZS5yZWFkIiwibWVzc2FnZS53cml0ZSJdLCJpc3MiOiJodHRwczpcL1wvb2F1dGgyLnByb3ZpZGVyLmNvbSIsImV4cCI6MTYwMzcxMzQ4NSwiaWF0IjoxNjAzNzA5ODg1LCJqdGkiOiI1N2RmY2UxNi1lOGRiLTRlY2MtYTdhOC0zZjc5MjRmOTJjMmUifQ.RBSysR0WVSQBXsFPfFg_u2QNxlE7HANdWnUuROxxzJbvTfapQ3T8WB0QS8V46f4yGaWymNzLAz8e_rdBsY3AKXMlZv7H0bDwifqJBQde2bpYPUIdLdzO3lJkWqMkUGY32tM1hN9ZOOrk9sQ9fW5PuKgZGXZpsKMc8DLZrzuhavc7lM94ft5EJOuEBwlOcBUOTx0qOrL6BkLGm1dcmVmTP983vgd7A9pSwY9ZHlKLfdVjQK3iFyrGA0WF7NEQQ8soWqW_otHapUQ08XVRW4vcsUSyH1QAHqonGk2R7T6dv18Vk_9nGjUfbmS4pGswgLRE0JnhSXZoTWsKHh7i-ixXpA

Этот же запрос в Postman с ответом:

Если же токен не прикладывать, вернется 401 с заголовком:

WWW-Authenticate: Bearer

Однако, в реальности при запросе ресурса выполняется н один запрос, а два – есть еще дополнительный запрос от сервера ресурсов к серверу авторизации для проверки токена. И это можно увидеть в консоли в IntelliJ IDEA.

Проверка access токена

В консоли запущенного сервера ресурсов мы увидим, что он обращается на сервер авторизации по адресу

http://auth-server:9000/oauth2/jwks

для проверки access токена:

2020-10-26 13:32:37.387 DEBUG 13124 --- [nio-8090-exec-2] o.s.web.client.RestTemplate              : HTTP GET http://auth-server:9000/oauth2/jwks
2020-10-26 13:32:37.390 DEBUG 13124 --- [nio-8090-exec-2] o.s.web.client.RestTemplate              : Accept=[text/plain, application/json, application/*+json, */*]
2020-10-26 13:32:37.408 DEBUG 13124 --- [nio-8090-exec-2] o.s.web.client.RestTemplate              : Response 200 OK

Мы для этого просто указали в настройках сервера ресурсов конечную точку для проверки.

Давайте теперь настроим и запустим последнюю часть примера – клиент, который будет действовать за нас вместо POSTMAN.

OAuth 2 Клиент

Чтобы сделать Spring Boot приложение OAuth 2- клиентом, добавим в POM зависимость:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

(и не только ее, там много dependency, см. исходники).

В настройках пропишем тип гранта client_credentials, сами значения Client Credentials, права, а также куда обращаться за JWT-токеном:

spring:
  security:
    oauth2:
      client:
        registration:
          messaging-client-client-credentials:
            provider: spring
            client-id: messaging-client
            client-secret: secret
            authorization-grant-type: client_credentials
            scope: message.read,message.write
        provider:
          spring:
            token-uri: http://auth-server:9000/oauth2/token

Настройки в Java:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    
    @Override
    public void configure(WebSecurity web) {
        web
            .ignoring()
                .antMatchers("/webjars/**");
    }
  
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .anyRequest().permitAll()
                .and()
            .logout()
                .disable()
            .oauth2Client();
    }

}
@Configuration
public class WebClientConfig {

    @Bean
    WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
                new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        return WebClient.builder()
                .apply(oauth2Client.oauth2Configuration())
                .exchangeStrategies(ExchangeStrategies.builder().codecs(c ->
                        c.defaultCodecs().enableLoggingRequestDetails(true)).build()
                )
                .build();
    }

    @Bean
    OAuth2AuthorizedClientManager authorizedClientManager(
            ClientRegistrationRepository clientRegistrationRepository,
            OAuth2AuthorizedClientRepository authorizedClientRepository) {

        OAuth2AuthorizedClientProvider authorizedClientProvider =
                OAuth2AuthorizedClientProviderBuilder.builder()
                        //.authorizationCode()
                        .clientCredentials()
                        .build();
        DefaultOAuth2AuthorizedClientManager authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(
                clientRegistrationRepository, authorizedClientRepository);
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

        return authorizedClientManager;
    }
}

Теперь конечная точка /authorize, которая инициирует обращение к ресурсам:

@Controller
public class AuthorizationController {
    private final WebClient webClient;
    private final String messagesBaseUri;

    public AuthorizationController(WebClient webClient,
            @Value("${messages.base-uri}") String messagesBaseUri) {
        this.webClient = webClient;
        this.messagesBaseUri = messagesBaseUri;
    }


    @GetMapping(value = "/authorize", params = "grant_type=client_credentials")
    public String clientCredentialsGrant(Model model) {
        System.out.println("before");
        String[] messages = this.webClient
                .get()
                .uri(this.messagesBaseUri)
                .attributes(clientRegistrationId("messaging-client-client-credentials"))
                .retrieve()
                .bodyToMono(String[].class)
                .block();
        model.addAttribute("messages", messages);

        return "index";
    }
}

Клиент у нас веб-приложение:

Главная страница клиента
Главная страница клиента

и когда мы щелкаем по ссылке Client Credentials (http://localhost:8080/authorize?grant_type=client_credentials), мы попадаем в контроллер выше. В контроллере WebClient обращается к серверу ресурсов по адресу http://localhost:8090/messages  (messagesBaseUri), но под капотом WebClient сначала сначала обращается за токеном на сервер авторизации.

То есть в консоли мы увидим два запроса вместо одного: сначала за токеном на сервер авторизации, а затем с токеном в заголовке на сервер ресурсов:

2020-10-26 20:06:52.714 DEBUG 5748 --- [oundedElastic-1] o.s.web.client.RestTemplate              : HTTP POST http://auth-server:9000/oauth2/token
2020-10-26 20:06:52.716 DEBUG 5748 --- [oundedElastic-1] o.s.web.client.RestTemplate              : Accept=[application/json, application/*+json]
2020-10-26 20:06:52.716 DEBUG 5748 --- [oundedElastic-1] o.s.web.client.RestTemplate              : Writing [{grant_type=[client_credentials], scope=[message.read message.write]}] as "application/x-www-form-urlencoded;charset=UTF-8"
2020-10-26 20:06:53.111 DEBUG 5748 --- [oundedElastic-1] o.s.web.client.RestTemplate              : Response 200 OK
2020-10-26 20:06:53.112 DEBUG 5748 --- [oundedElastic-1] o.s.web.client.RestTemplate              : Reading to [org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse] as "application/json;charset=UTF-8"
2020-10-26 20:06:53.386  WARN 5748 --- [oundedElastic-1] o.a.c.util.SessionIdGeneratorBase        : Creation of SecureRandom instance for session ID generation using [SHA1PRNG] took [247] milliseconds.
2020-10-26 20:06:53.471 TRACE 5748 --- [oundedElastic-1] o.s.w.r.f.client.ExchangeFunctions       : HTTP GET http://localhost:8090/messages, headers=[Authorization:"Bearer eyJraWQiOiI0NzQyZDFhYy1kOWQ2LTQzYTQtYTM3OS1kZjdiZDExOGVmMWIiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJtZXNzYWdpbmctY2xpZW50IiwiYXVkIjoibWVzc2FnaW5nLWNsaWVudCIsIm5iZiI6MTYwMzcyNDgxMywic2NvcGUiOlsibWVzc2FnZS5yZWFkIiwibWVzc2FnZS53cml0ZSJdLCJpc3MiOiJodHRwczpcL1wvb2F1dGgyLnByb3ZpZGVyLmNvbSIsImV4cCI6MTYwMzcyODQxMywiaWF0IjoxNjAzNzI0ODEzLCJqdGkiOiI3OGMxZWNhMS1mMzE5LTQ1YTEtODAzZC02NzE4MDZjNTgxMzcifQ.Rh7Dsyfz7icMdliElh0A_8NkDTD_uAHD5KWkMwEXbydPLebw3VK-uXMKi8WP10DPoSWWADe-YatCWdpUnLS83OHKOa2qfCBIinaZPEpw6QtPUKuiS_8zM9JJmFJPEo2mqvzhm3RqUG1LGDk9oYHSOhk5AbastWNIP4Gw1yC4DnKGsyhbGzX4fD1AhkjggHvODSddfRIwLd1akfpC5VFtHty86DcYk-YBqh8kTsJ0AXIHRGh3tP-LBQVA5Dpe_6sF6wh5bpWfifeoCzxsaQieHj-QZ53OHHDcSRqJGqE30Pp9fiTF9077pT4i--yrjGDGsUUJmZSGWspZYLAwlPWgdw"]
2020-10-26 20:06:53.971 TRACE 5748 --- [ctor-http-nio-1] o.s.w.r.f.client.ExchangeFunctions       : Response 200 OK, headers=[X-Content-Type-Options:"nosniff", ...]
Для просмотра полного текста фрагмента выше наведите курсор и щелкните иконку с горизонтальными линиями – Open code in new window – откроется блокнот с настоящим текстом.

То есть ровно то, что мы делали в Postman двумя запросами, клиент делает сам.

Итоги

Исходный код на Maven с включенными логами и закомментированным Authorization Code Flow есть на GitHub.

OAuth 2 Сlient Credentials Flow. Пример на Spring Boot: 4 комментария

  1. Опечатка:
    “Пример, как и положено, состоит их трех компонентов. ”
    “…из трёх…”

  2. Уважаемвй автор. Не могли бы вы сделать туториал на тему, как авторизоваться с помощью spring boot и oauth2 на сторонних серверах (не google, facebook и пр. которые входят в коробку), а напримере vk.com?
    Очень хочется понять, как сделать кастомный вариант.
    В интернете почти нет на эту тему информации.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *