Привет Хабр!
Как известно, spring OAuth2.0.x переведен в режим поддержки уже почти как 2 года назад , а большая часть его функциональности теперь доступна в spring-security (матрица сопоставления). В spring-security отказались переносить Authorization service (roadmap) и предлагают использовать вместо него свободные или платные аналоги, в частности keycloak. В этом посте мы хотели бы поделится различными вариантами подключения keycloak к приложениям spring-boot.
Содержание
Немного о Keycloak
Это реализация SSO (Single sign on) с открытым исходным кодом для управления идентификацией и доступом пользователей.
Основной функционал, поддерживаемый в Keycloak:
-
Single-Sign On and Single-Sign Out.
-
OpenID/OAuth 2.0/SAML.
-
Identity Brokering – аутентификация с помощью внешних OpenID Connect или SAML.
-
Social Login – поддержка Google, GitHub, Facebook, Twitter.
-
User Federation – синхронизация пользователей из LDAP и Active Directory серверов.
-
Kerberos bridge – использование Kerberos сервера для автоматической аутентификации пользователей.
-
Гибкое управление политиками через realm.
-
Адаптеры для JavaScript, WildFly, JBoss EAP, Fuse, Tomcat, Jetty, Spring.
-
Возможность расширения с использованием плагинов.
-
И многое-многое другое…
Запускаем и настраиваем keycloak
Для запуска keycloak на машине разработчика удобно использовать docker-compose. В этом случае мы можем в разное время для разных приложений запускать свой сервис авторизации, тем самым избавляя себя от кучи проблем, связанных с конфигурацией под различные приложения. Ниже приведен один из вариантов конфигурации docker-compose для запуска standalone сервера с базой данных postgres:
docker-compose.yml
version: "3.8" services: postgres: container_name: postgres image: library/postgres environment: POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: keycloak_db ports: - "5432:5432" restart: unless-stopped keycloak: image: jboss/keycloak container_name: keycloak environment: DB_VENDOR: POSTGRES DB_ADDR: postgres DB_DATABASE: keycloak_db DB_USER: ${POSTGRES_USER:-postgres} DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} KEYCLOAK_USER: admin KEYCLOAK_PASSWORD: admin_password ports: - "8484:8080" depends_on: - postgres links: - "postgres:postgres"
После успешного запуска необходимо произвести настройки realm, клиентов, ролей и пользователей.
Произведем некоторые первоначальные настройки. Создадим realm «my_realm»:
После этого создадим клиент "my_client"
, через который будем производить авторизацию пользователей (оставим все настройки по-умолчанию):
Не забываем указывать redirect_url
. В нашем случае он будет равен: http://localhost:8080/*
Создадим роли для пользователей нашей системы — "ADMIN", "USER"
:
Добавляем пользователей "admin"
с ролью "ADMIN"
:
И пользователя "user"
с ролью "USER"
. Не забываем устанавливать пароли на вкладке "Credentials"
:
Основная настройка закончена, теперь можно приступить к подключению spring boot приложений.
Подключаем Keycloak при помощи адаптера
В официальной документации к keycloak для использования в приложениях рекомендуют использовать готовые библиотеки — адаптеры, которые дают возможность избавиться от boilerplate кода и излишнего конфигурирования. Есть реализация для большинства популярных языков и фреймворков (supported-platforms). Мы будем использовать Spring Boot Adapter.
Создадим небольшое демонстрационное, приложение на spring-boot (исходники можно найти здесь) и подключим к нему Keycloak Spring Boot адаптер. Конфигурационный файл maven будет выглядеть так:
pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="<http://maven.apache.org/POM/4.0.0>" xmlns:xsi="<http://www.w3.org/2001/XMLSchema-instance>" xsi:schemaLocation="<http://maven.apache.org/POM/4.0.0> <https://maven.apache.org/xsd/maven-4.0.0.xsd>"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.9.RELEASE</version> <relativePath /> <!-- lookup parent from repository --> </parent> <groupId>org.akazakov.keycloak</groupId> <artifactId>demo-keycloak-adapter</artifactId> <version>0.0.1-SNAPSHOT</version> <name>Demo Keycloak Adapter</name> <description>Demo project for Spring Boot and Keycloak</description> <properties> <java.version>11</java.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.keycloak.bom</groupId> <artifactId>keycloak-adapter-bom</artifactId> <version>12.0.3</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.keycloak</groupId> <artifactId>keycloak-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> </dependencies> </project>
Для целей проверки добавим контроллер, который будет выставлять методы для различных ролей пользователей и информацию о текущем пользователе (этот же контроллер мы будем использовать в других примерах ниже):
@RestController @RequestMapping("/api") public class SampleController { @GetMapping("/anonymous") public String getAnonymousInfo() { return "Anonymous"; } @GetMapping("/user") @PreAuthorize("hasRole('USER')") public String getUserInfo() { return "user info"; } @GetMapping("/admin") @PreAuthorize("hasRole('ADMIN')") public String getAdminInfo() { return "admin info"; } @GetMapping("/service") @PreAuthorize("hasRole('SERVICE')") public String getServiceInfo() { return "service info"; } @GetMapping("/me") public Object getMe() { final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); return authentication.getName(); } }
Чтобы наше приложение успешно запустилось и подключилось к keycloak, нам необходимо добавить соответствующую конфигурацию. Первое, что мы сделаем, это в application.yml добавим настройки клиента и подключения к серверу авторизации:
server: port: ${SERVER_PORT:8080} spring: application.name: ${APPLICATION_NAME:spring-security-keycloak} keycloak: auth-server-url: http://localhost:8484/auth realm: my_realm resource: my_client public-client: true
После этого добавим конфигурацию spring-security, переопределим KeycloakWebSecurityConfigurerAdapter
, поставляемый вместе с адаптером:
@KeycloakConfiguration @EnableGlobalMethodSecurity(prePostEnabled = true) public class WebSecurityConfig extends KeycloakWebSecurityConfigurerAdapter { @Override protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { return new NullAuthenticatedSessionStrategy(); } @Autowired public void configureGlobal(AuthenticationManagerBuilder authManagerBuilder) { KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider(); keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper()); authManagerBuilder.authenticationProvider(keycloakAuthenticationProvider); } @Bean public KeycloakConfigResolver keycloakConfigResolver() { return new KeycloakSpringBootConfigResolver(); } @Override protected void configure(HttpSecurity http) throws Exception { super.configure(http); http .authorizeRequests() .antMatchers("/api/anonymous/**").permitAll() .anyRequest().fullyAuthenticated(); } }
Теперь проверим работу нашего приложения. Запустим приложение и попробуем зайти пользователем на соответствующий url. Например: http://localhost:8080/api/admin
. В результате, браузер перенаправит нас на окно логина пользователя:
После ввода корректного имени пользователя и пароля, браузер перенаправит нас на изначальный адрес. В результате получим страницу с некоторой информацией, доступной пользователю:
Если перейдем по адресу получения информации о текущем пользователе (http://localhost:8080/api/me
), то получим в результате uuid пользователя в keycloak:
Если нам нужно, чтобы сервис только проверял токен доступа и не инициализировал процедуру аутентификации пользователя, достаточно включить bearer-only: true
в конфигурацию приложения:
keycloak: auth-server-url: http://localhost:8484/auth realm: my_realm resource: my_client public-client: true bearer-only: true
Используем OAuth2 Client из spring-security
Использование keycloak адаптера избавляет нас от написания кучи boilerplate кода. Но в то же время наше приложение становится зависимым от реализации. В некоторых случаях не стоит завязываться на какой-то конкретный сервис авторизации, это даст нам больше гибкости в дальнейшей эксплуатации системы.
Одной из ключевых особенностей spring security 5 является поддержка протоколов OAuth2 и OIDC. Мы можем использовать OAuth2 клиент из пакета spring-security для интеграции с сервером keycloak.
Итак, для использования клиента подключим соответствующую библиотеку в зависимости от проекта (исходный код примера). Полный текст pom.xml
:
pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="<http://maven.apache.org/POM/4.0.0>" xmlns:xsi="<http://www.w3.org/2001/XMLSchema-instance>" xsi:schemaLocation="<http://maven.apache.org/POM/4.0.0> <https://maven.apache.org/xsd/maven-4.0.0.xsd>"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.9.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>org.akazakov.keycloak</groupId> <artifactId>demo-keycloak-oauth</artifactId> <version>0.0.1-SNAPSHOT</version> <name>demo-keycloak-oauth</name> <description>Demo project for Spring Boot OAuth and Keycloak</description> <properties> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
Далее в application.yaml
необходимо указать параметры подключения к сервису авторизации:
server: port: ${SERVER_PORT:8080} spring: application.name: ${APPLICATION_NAME:spring-security-keycloak-oauth} security: oauth2: client: provider: keycloak: issuer-uri: http://localhost:8484/auth/realms/my_realm registration: keycloak: client-id: my_client
По умолчанию роли пользователей будут вычисляться на основе значения "scope"
в access token, и к ним прибавляется "ROLE_USER"
для всех авторизованных пользователей системы. Можно оставить как есть и перейти на модель scope. Но в нашем примере мы будем использовать роли пользователей в рамках realm’а. Все, что нам нужно, это переопределить oidcUserService
и задать свой маппинг ролей для пользователя. Нужные роли приходят в разделе "groups"
токена доступа, его мы и будем использовать для определения ролей пользователя. В результате, наша конфигурация для spring security с переопределенным oidcUserService
будет выглядеть так:
@Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests(authorizeRequests -> authorizeRequests .antMatchers("/api/anonymous/**").permitAll() .anyRequest().authenticated()) .oauth2Login(oauth2Login -> oauth2Login .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint .oidcUserService(this.oidcUserService()) ) ); } @Bean public OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() { final OidcUserService delegate = new OidcUserService(); return (userRequest) -> { OidcUser oidcUser = delegate.loadUser(userRequest); final Map<String, Object> claims = oidcUser.getClaims(); final JSONArray groups = (JSONArray) claims.get("groups"); final Set<GrantedAuthority> mappedAuthorities = groups.stream() .map(role -> new SimpleGrantedAuthority(("ROLE_" + role))) .collect(Collectors.toSet()); return new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo()); }; } }
В данном случае работа приложения будет практически аналогична работе с использованием keycloak адаптера.
Подключаем приложение как ResourceService
Довольно часто не нужно, чтобы наше приложение инициировало аутентификацию пользователя. Достаточно лишь проверки авторизации пользователя по предоставляемому токену доступа. Вариантом подключения авторизации с keycloak без использования адаптера является настройка приложения как resource server. В этом случае приложение не может инициировать аутентификацию пользователя, а только авторизует пользователя и проверяет подпись токена доступа. Подключим соответствующие библиотеки: spring-security-oauth2-resource-server и spring-security-oauth2-jose (исходный код). Полный файл pom.xml
будет выглядеть так:
pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="<http://maven.apache.org/POM/4.0.0>" xmlns:xsi="<http://www.w3.org/2001/XMLSchema-instance>" xsi:schemaLocation="<http://maven.apache.org/POM/4.0.0> <https://maven.apache.org/xsd/maven-4.0.0.xsd>"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.9.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>org.akazakov.keycloak</groupId> <artifactId>demo-keycloak-resource</artifactId> <version>0.0.1-SNAPSHOT</version> <name>demo-keycloak-resource</name> <description>Demo project for Spring Boot and Spring security and Keycloak</description> <properties> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-resource-server</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-jose</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
Далее нам необходимо указать путь к JWK (JSON Web Key) набору ключей, с помощью которых наше приложение будет проверять токены доступа. В keycloak они доступны по адресу: http://${host}/auth/realms/${realm)/protocol/openid-connect/certs
. В итоге application.yml
будет выгдядеть следующим образом:
server: port: ${SERVER_PORT:8080} spring: application.name: ${APPLICATION_NAME:spring-security-keycloak-resource} security: oauth2: resourceserver: jwt: jwk-set-uri: ${KEYCLOAK_REALM_CERT_URL:http://localhost:8484/auth/realms/my_realm/protocol/openid-connect/certs}
Как и в случае с OAuth2 Client нам также необходимо переопределить конвертер ролей пользователя. В данном случае мы можем переопределить jwtAuthenticationConverter
.
Полный текст WebSecurityConfiguration
:
@Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests(authorizeRequests -> authorizeRequests .antMatchers("/api/anonymous/**").permitAll() .anyRequest().authenticated()) .oauth2ResourceServer(resourceServerConfigurer -> resourceServerConfigurer .jwt(jwtConfigurer -> jwtConfigurer .jwtAuthenticationConverter(jwtAuthenticationConverter())) ); } @Bean public Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter() { JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter()); return jwtAuthenticationConverter; } @Bean public Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter() { JwtGrantedAuthoritiesConverter delegate = new JwtGrantedAuthoritiesConverter(); return new Converter<>() { @Override public Collection<GrantedAuthority> convert(Jwt jwt) { Collection<GrantedAuthority> grantedAuthorities = delegate.convert(jwt); if (jwt.getClaim("realm_access") == null) { return grantedAuthorities; } JSONObject realmAccess = jwt.getClaim("realm_access"); if (realmAccess.get("roles") == null) { return grantedAuthorities; } JSONArray roles = (JSONArray) realmAccess.get("roles"); final List<SimpleGrantedAuthority> keycloakAuthorities = roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role)).collect(Collectors.toList()); grantedAuthorities.addAll(keycloakAuthorities); return grantedAuthorities; } }; } }
Здесь мы создаем конвертер (jwtGrantedAuthoritiesConverter
), который принимает токен и извлекает из секции "realm_access"
роли пользователя. Далее мы можем либо сразу вернуть их, либо, как в данном случае, расширить список, который извлекается конвертером по умолчанию.
Проверим работу. Воспользуемся встроенным в Intellij idea http клиентом, либо плагином к VSCode — Rest Client. В начале получим токен пользователя, произведем запрос к keycloak, используя логин и пароль зарегистрированного пользователя:
### POST <http://localhost:8484/auth/realms/my_realm/protocol/openid-connect/token> Content-Type: application/x-www-form-urlencoded client_id=my_client&grant_type=password&scope=openid&username=admin&password=admin > {% client.global.set("auth_token", response.body.access_token); %}
Ответ будет примерно следующего содержания:
Ответ
POST <http://localhost:8484/auth/realms/my_realm/protocol/openid-connect/token> HTTP/1.1 200 OK ... Content-Type: application/json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlb21qWFY2d3dNek8xVS0tYUdhVllpSHM3eURaZVM1aU96bl9JR3RlS1ZzIn0.eyJleHAiOjE2MTY2NTQzNjEsImlhdCI6MTYxNjY1NDA2MSwianRpIjoiMGQwMjg2YWUtYTlmYy00MzcxLWFmM2ItZjJlNTM5N2I4NzViIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4NDg0L2F1dGgvcmVhbG1zL215X3JlYWxtIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjkzMGIxMTNmLWI0NzUtNDhkMC05NTQxLWMyYzI2MWZlYmRmZCIsInR5cCI6IkJlYXJlciIsImF6cCI6Im15X2NsaWVudCIsInNlc3Npb25fc3RhdGUiOiI1ZDI5ZDQ2ZS1iOTI2LTRkNTktODlmOC0yNDM2ZWRjYWU0ZjAiLCJhY3IiOiIxIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwiQURNSU4iLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6ImFkbWluIn0.dvGvYhhhfH8r6EP8k_spFwBS35ulYMTWNL4lcz9PR2e-p4FU-ehre1EQA8xpbkYzYEWRB_elzTya5IhbYR8KArrujplIDNAOlqJ9W6a4Tx-r44QCteM0DW4BNzbZAH2L0Bg7aSstRKUuULceRNYQcdCvSFjEU5DsHk26a6TM5KCrkv0ryGo11pam-pnbs2Z2jOSfSHvOAfMNL9OVJYRBjlTmsEzzgH9dHSa_pT2Q-SvgvfCcwfY0XkgUZkMPUtz85-lqchROb4XpHOiy3Cfn8MgrGNwhf-MsmN5wiAGe0DI_LW2Jxr3boZMLS4AuuNQ7agr65g-JuO9-LhlgndxN8g", "expires_in": 300, "refresh_expires_in": 1800, "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJmNGEwNWQxNy0yNWU4LTRjMjEtOTMyMC0zMzcwODlhNTg5MjQifQ.eyJleHAiOjE2MTY2NTU4NjEsImlhdCI6MTYxNjY1NDA2MSwianRpIjoiMjNmNDBiZWUtNmQ3Ny00ZTIxLTg0NTItNDg1NDc2OTk1ZDUyIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4NDg0L2F1dGgvcmVhbG1zL215X3JlYWxtIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo4NDg0L2F1dGgvcmVhbG1zL215X3JlYWxtIiwic3ViIjoiOTMwYjExM2YtYjQ3NS00OGQwLTk1NDEtYzJjMjYxZmViZGZkIiwidHlwIjoiUmVmcmVzaCIsImF6cCI6Im15X2NsaWVudCIsInNlc3Npb25fc3RhdGUiOiI1ZDI5ZDQ2ZS1iOTI2LTRkNTktODlmOC0yNDM2ZWRjYWU0ZjAiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIn0.r4BrjwfavKFF8dst3AyRi0LTfymbSVfDKDT9KyMpmzk", "token_type": "bearer", "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlb21qWFY2d3dNek8xVS0tYUdhVllpSHM3eURaZVM1aU96bl9JR3RlS1ZzIn0.eyJleHAiOjE2MTY2NTQzNjEsImlhdCI6MTYxNjY1NDA2MSwiYXV0aF90aW1lIjowLCJqdGkiOiJiN2UwNDhmZS01ZTRjLTQxMWYtYTBjMC0xNGExYzhlOGJhYWEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Ojg0ODQvYXV0aC9yZWFsbXMvbXlfcmVhbG0iLCJhdWQiOiJteV9jbGllbnQiLCJzdWIiOiI5MzBiMTEzZi1iNDc1LTQ4ZDAtOTU0MS1jMmMyNjFmZWJkZmQiLCJ0eXAiOiJJRCIsImF6cCI6Im15X2NsaWVudCIsInNlc3Npb25fc3RhdGUiOiI1ZDI5ZDQ2ZS1iOTI2LTRkNTktODlmOC0yNDM2ZWRjYWU0ZjAiLCJhdF9oYXNoIjoiRlh2VzB2Z3pwd3R6N1FabEZtTFhJdyIsImFjciI6IjEiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6ImFkbWluIn0.ZDeZg4Z-PPmn2fVm7opGLRutzDh6l8uRYqZzbqIX7wk0GhgtMHV1CW8RvDd51AuYw81WyoMyRAD_-T6ne58Rt9f5XNZZfS8xoXzTFV1xH6XigOVQH2jIHN-2VIM1IgJnteo7nuTz9zo4OXIFvEjaFHq4AXDkiq6jhThv0qPS3WrAA-MutyW8G37GM0fsCgANvlGKoWm1_1wKyeTZ0Gfug32Vf6gUikfxA9bmaS4oGYGc6lqFE6EHgtjIn0q9gNUfpEXaqpiL3mCBu9V6sJG5Rp_MOqp-aXrM9NbLTz2JTXevtClHI6qVUIoh8OXXXT98QmKrVr9Cyr9BRUrQyt0Zzg", "not-before-policy": 0, "session_state": "5d29d46e-b926-4d59-89f8-2436edcae4f0", "scope": "openid profile email" } Response code: 200 (OK); Time: 114ms; Content length: 2987 bytes
Теперь проверим, что методы доступны пользователю с соответствующими правами:
GET <http://localhost:8080/api/admin> Authorization: Bearer {{auth_token}} Content-Type: application/json
В ответ получим:
GET <http://localhost:8080/api/admin> HTTP/1.1 200 ... admin info Response code: 200; Time: 34ms; Content length: 10 bytes
Авторизация вызовов сервисов с использованием keycloak
При работе с микросервисной архитектурой иногда возникают требования авторизованных вызовов между сервисами. В случаях, когда инициатором взаимодействия является какой-то внутренний процесс или служба, нам где-то нужно брать токен доступа. В качестве решения данного вопроса мы можем использовать Client Credentials Flow, чтобы получить токен из keycloak (исходный код примера доступен по ссылке).
Для начала создадим нового клиента, под которым будут авторизоваться наши сервисы:
Для возможности авторизации сервиса нам нужно изменить тип доступа ("Access Type"
) на "confidential"
и включить флаг "Service accounts Enabled"
. В остальном конфигурация не отличается от конфигурации по умолчанию:
Если нам необходимо, чтобы у сервисов, авторизованных под данным клиентом, была своя роль, добавим ее в роли:
Далее эту роль необходимо добавить клиенту. На вкладке "Service Account Roles"
выбираем необходимую роль — в нашем случае роль "SERVICE"
:
Сохраняем client_id и client_secret для дальнейшего использования в сервисах для авторизации:
Для демонстрации создадим небольшое приложение, которое будет получать информацию доступную по адресу http://localhost:8080/api/service
из предыдущих примеров.
Для начала создадим компонент, который будет авторизовывать наш сервис в keycloak:
@Component public class KeycloakAuthClient { private static final Logger log = LoggerFactory .getLogger(KeycloakAuthClient.class); private static final String TOKEN_PATH = "/token"; private static final String GRANT_TYPE = "grant_type"; private static final String CLIENT_ID = "client_id"; private static final String CLIENT_SECRET = "client_secret"; public static final String CLIENT_CREDENTIALS = "client_credentials"; @Value("${app.keycloak.auth-url:http://localhost:8484/auth/realms/my_realm/protocol/openid-connect}") private String authUrl; @Value("${app.keycloak.client-id:service_client}") private String clientId; @Value("${app.keycloak.client-secret:acb719cf-4afd-42d3-91f2-93a60b3f2023}") private String clientSecret; private final RestTemplate restTemplate; public KeycloakAuthClient(RestTemplate restTemplate) { this.restTemplate = restTemplate; } public KeycloakAuthResponse authenticate() { MultiValueMap<String, String> paramMap = new LinkedMultiValueMap<>(); paramMap.add(CLIENT_ID, clientId); paramMap.add(CLIENT_SECRET, clientSecret); paramMap.add(GRANT_TYPE, CLIENT_CREDENTIALS); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); String url = authUrl + TOKEN_PATH; HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(paramMap, headers); log.info("Try to authenticate"); ResponseEntity<KeycloakAuthResponse> response = restTemplate.exchange(url, HttpMethod.POST, entity, KeycloakAuthResponse.class); if (!response.getStatusCode().is2xxSuccessful()) { log.error("Failed to authenticate"); throw new RuntimeException("Failed to authenticate"); } log.info("Authentication success"); return response.getBody(); } }
Метод authenticate
производит вызов к keycloak и в случае успешного ответа возвращает объект KeycloakAuthResponse
:
public class KeycloakAuthResponse { @JsonProperty("access_token") private String accessToken; @JsonProperty("expires_in") private Integer expiresIn; @JsonProperty("refresh_expires_in") private Integer refreshExpiresIn; @JsonProperty("refresh_token") private String refreshToken; @JsonProperty("token_type") private String tokenType; @JsonProperty("id_token") private String idToken; @JsonProperty("session_state") private String sessionState; @JsonProperty("scope") private String scope; // Getters and setters or lombok ... }
Далее мы берем access_token
из ответа для того, чтобы использовать в дальнейших вызовах к защищенным методам. Ниже пример вызова к защищенному методу:
@SpringBootApplication public class DemoServiceAuthApplication implements CommandLineRunner { private static final String BEARER = "Bearer "; private static final String SERVICE_INFO_URL = "http://localhost:8080/api/service"; private final KeycloakAuthClient keycloakAuthClient; private final RestTemplate restTemplate; private static final Logger log = LoggerFactory .getLogger(DemoServiceAuthApplication.class); public DemoServiceAuthApplication(KeycloakAuthClient keycloakAuthClient, RestTemplate restTemplate) { this.keycloakAuthClient = keycloakAuthClient; this.restTemplate = restTemplate; } public static void main(String[] args) { SpringApplication.run(DemoServiceAuthApplication.class, args); } @Override public void run(String... args) { final KeycloakAuthResponse authenticate = keycloakAuthClient.authenticate(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(authenticate.getAccessToken()); log.info("Make request to resource server"); final ResponseEntity<String> responseEntity = restTemplate.exchange(SERVICE_INFO_URL, HttpMethod.GET, new HttpEntity(headers), String.class); if (!responseEntity.getStatusCode().is2xxSuccessful()) { log.error("Failed to request"); throw new RuntimeException("Failed to request"); } log.info("Response data: {}", responseEntity.getBody()); } }
Сначала мы авторизуем наш сервис через keycloak, потом производим запрос к защищенному ресурсу, добавив в HTTP Headers параметр Authorization: Bearer ...
В результате выполнения программы мы получим содержимое защищенного метода:
. ____ _ __ _ _ /\\\\ / ___'_ __ _ _(_)_ __ __ _ \\ \\ \\ \\ ( ( )\\___ | '_ | '_| | '_ \\/ _` | \\ \\ \\ \\ \\\\/ ___)| |_)| | | | | || (_| | ) ) ) ) ' |____| .__|_| |_|_| |_\\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot :: (v2.4.4) 2021-04-13 16:04:36.672 INFO 19240 --- [ main] o.a.keycloak.DemoServiceAuthApplication : Starting DemoServiceAuthApplication using Java 14.0.1 on MacBook-Pro.local with PID 19240 (/Users/akazakov/Projects/spring-boot-keycloak/demo-service-auth/target/classes started by akazakov in /Users/akazakov/Projects/spring-boot-keycloak) 2021-04-13 16:04:36.674 INFO 19240 --- [ main] o.a.keycloak.DemoServiceAuthApplication : No active profile set, falling back to default profiles: default 2021-04-13 16:04:37.199 INFO 19240 --- [ main] o.a.keycloak.DemoServiceAuthApplication : Started DemoServiceAuthApplication in 0.814 seconds (JVM running for 6.425) 2021-04-13 16:04:37.203 INFO 19240 --- [ main] o.akazakov.keycloak.KeycloakAuthClient : Try to authenticate 2021-04-13 16:04:53.697 INFO 19240 --- [ main] o.akazakov.keycloak.KeycloakAuthClient : Authentication success 2021-04-13 16:04:53.697 INFO 19240 --- [ main] o.a.keycloak.DemoServiceAuthApplication : Make request to resource server 2021-04-13 16:04:54.088 INFO 19240 --- [ main] o.a.keycloak.DemoServiceAuthApplication : Response data: service info Disconnected from the target VM, address: '127.0.0.1:57479', transport: 'socket' Process finished with exit code 0
Конечно, представленный в примере выше строго в ознакомительных целях KeycloakAuthClient
нельзя использовать в продуктовой среде, как минимум нужно добавить поддержку сохранения токена доступа на некоторое время, а еще лучше поддержку механизма обновления токена доступа при истечении его срока действия.
Выводы
Подключение keycloak с помощью поставляемого адаптера, конечно, избавляет нас от написания большого количества кода и конфигураций. Но тогда наше приложение будет завязано на конкретную реализацию сервиса авторизации. Подключение же с использованием только возможностей фреймворка spring дает нам больше гибкости в настройке и больше выбора в реализациях. Но вместе с тем заставляет нас писать больше кода и конфигурации, хотя, на мой взгляд, не настолько уж много. В любом случае, при выборе, как подключать сервис авторизации к своему приложению, мы должны исходить из множества параметров, главным из которых является здравый смысл.
Спасибо за внимание!
ссылка на оригинал статьи https://habr.com/ru/company/reksoft/blog/552346/
Добавить комментарий