В этой статье рассмотрим пример Client Credentials flow с новым экспериментальным сервером авторизации на Spring Boot. Новый сервер был анонсирован буквально недавно и еще не полностью готов (текущая версия 0.0.2). Но Client Credentials flow (и Authorization Code flow) он уже поддерживает.
Пример взят из официального репозитория, но переделан под Maven (вместо Gradle). Еще в нем включено логирование запросов. Так что в консоли мы увидим запросы — их отправляет backend клиента и backend сервера ресурсов с помощью 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).
Client Credentials flow
Flow уже был изложен выше при перечислении трех серверов, но теперь то же самое на картинке, а затем по пунктам:
Выглядит так:
Предварительно мы регистрируем клиент на сервере авторизации с client_id и client_secret. У нас значениями будут messaging-client и secret.
- Клиент (серверное приложение) отправляет Client Credentials (под которыми он зарегистрирован) на сервер авторизации.
- В ответ получает access token (он же JWT-токен).
- С access token клиент обращается на сервер ресурсов.
- Сервер ресурсов не может сам проверить access token, он обращается за проверкой на сервер авторизации.
- Если проверка прошла успешно, сервер ресурсов возвращает клиенту ресурс.
Сервер авторизации и регистрация на нем клиента
Чтобы сделать приложение сервером авторизации, добавим в него зависимость:
<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(); } }
Настройка WebClient и OAuth2AuthorizedClientManager (который используется в настройке WebClient):
@Configuration public class WebClientConfig { @Bean WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) { ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client = new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager); return WebClient.builder() .apply(oauth2Client.oauth2Configuration()) .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"; } }
Клиент у нас веб-приложение. Инициировать нужный flow можно, щелкнув по ссылке:
Щелкнув 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", ...]
То есть ровно то, что мы делали в Postman двумя запросами, клиент делает сам.
Итоги
Исходный код на Maven с включенными логами и закомментированным Authorization Code Flow есть на GitHub.
Authorization Code Flow рассмотрен тут.
Опечатка:
«Пример, как и положено, состоит их трех компонентов. »
«…из трёх…»
Уважаемвй автор. Не могли бы вы сделать туториал на тему, как авторизоваться с помощью spring boot и oauth2 на сторонних серверах (не google, facebook и пр. которые входят в коробку), а напримере vk.com?
Очень хочется понять, как сделать кастомный вариант.
В интернете почти нет на эту тему информации.
Постараюсь
Спасибо. С нетерпением ждём!! 🙂
побыстрее))
К сожалению, VK ответы выдает нестандартные (не полностью соответствующие протоколу OAuth 2). Так что это целая история — поддержать VK, и чтоб остальные провайдеры (которые поддерживаются в Spring Security по умолчанию) при этом не слетели.
Пример SSO с VK (+github и Google) https://sysout.ru/single-sign-on-s-postavshhikom-vk/
ребята а есть пример сервиса авторизации с подключённой бд в которой хранятся пользователи для входа в систему?
В Client Credentials Flow пользователи не фигурируют, они есть в Authorization Code Flow, пример тут
Разница с этим примером только в части AuthorizationServer:
— раскомментируем две строки, которые как раз отвечают за Authorization Code Flow:
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) и
.redirectUri
Так Authorization Code Flow стал доступен.
— вместо @Bean UserDetails с InMemory-пользователем задаем свой CustomUserDetailsService, в котором прописано, каким образом брать пользователей из базы (подробнее тут). В примере база h2 с тремя пользователями (и Hibernate), но можно подключить любую базу.
Спасибо.
А ваши примеры подойдут для реализации Single Sign-On?
Ну oauth 2 (authorization code flow) и есть реализация sso. Несколько клиентов используют единый Authorization Server для входа пользователей (перенаправляют их туда для логина и получения разрешений). Другое дело, что есть ещё стандарт openid (похож на oauth2, но он исключительно для входа, без выдачи разрешений). Так вот не знаю, поддерживает ли данный Authorization Server от Spring стандарт openid. Потому что он еще в процессе разработки, но подозреваю, что да. Пример почти без изменений взят из официального репозитория, там же и список issues есть, можно поискать или спросить. А можно взять keyloak — сторонний сервер авторизации, он точно давно готов полностью (но у меня с ним примера нет)
Не работает ваш authorization server. Запрос с postman не проходит
Почему? все сработало, внимательнее смотрите (только что перепроверено).
Да, действительно. хотел удалить комментарий, но не получилось. не обратил внимания на заметку в начале, о длобавлении «127.0.0.1 auth-server» (вчера там было применительно к Linux). Только зачем так сделали? Не проще ли было использовать localhost в адресе…
Мне вот интересно, а если стоит задача настроить SSO с уже существующим authentication server-ом. Например, есть корпоративный аккаунт на Keycloak, мы пишем корпоративный же веб-вервис который должен аутертифицировать пользователей с помощью Keycloak. Что в этом случае измениться? Как я понимаю, это уже будет Authorization Code flow, а Client будет являтся и Resource server-ом?
1) без псевдонима не работает (из-за перенаправления в браузере между одним доменом с разными портами, но это неточно)
2) Да, если это именно люди, которые в браузере перенаправляются на сервер авторизации, входят там в систему, дают согласие на доступ клиента к ресурсам, а потом перебрасываются обратно, то это Authorization Code flow.
Веб-сервис — это наверно Resource-сервер, а клиент отдельно, иначе зачем вообще OAuth2. Но вам виднее. (Resource-сервер может одновременно быть и Authorization сервер, если это соцсеть GitHub или Google — там и вход в систему, и ресурсы-фотографии)
пример SSO с Keyloak https://sysout.ru/single-sign-on-s-postavshhikom-keyloak/
Можно узнать практическое применение данного примера?
В этом примере клиент — это микросервис (приложение на сервере), , который предоставляет API веб-интерфейсу.
Например, есть web-приложение (на Angular, Vue или чистый JS) + приложение Java на микросервисах.
Web-приложение обращается к клиенту за доступом к ресурсам (чтение-запись в БД), клиент определяет уровень доступа пользователя, и в зависимости от этого отклоняет запрос или перенаправляет в другой микросервис для получения запрошенного ресурса.
Возможно такое применение вашего примера?
Или он совсем о другом?
И для моего случая достаточно просто REST API авторизации?