Привет Хабр!

Как известно, 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 дает нам больше гибкости в настройке и больше выбора в реализациях. Но вместе с тем заставляет нас писать больше кода и конфигурации, хотя, на мой взгляд, не настолько уж много. В любом случае, при выборе, как подключать сервис авторизации к своему приложению, мы должны исходить из множества параметров, главным из которых является здравый смысл.

Спасибо за внимание!

UPD: По созданию расширений в keycloak, можно почитать в статье у моего коллеги