Подключение Keycloak к Spring Boot приложению

    Привет Хабр!

    Как известно, 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, можно почитать в статье у моего коллеги

    Reksoft
    Компания

    Комментарии 10

      0
      Мне вот интересно. Это обычная практика вставлять аутентификацию в само spring приложение?

      А то я решил в проекте использовать Apache модуль для аутентификации пользователя. Мне показалось так правильнее отделить бизнесприложение от наносного. И вроде как к примеру mod_auth_openidc сертифицирован, но почему то очень мало по нему находится примеров использования в интернете. Практически только документация на гитхабе и пара комментариев на в issues на том же гитхабе.

      Так то оно все прекрасно работает, но что то меня грызут червяки сомнений.
        0
        Мне вот интересно. Это обычная практика вставлять аутентификацию в само spring приложение?
        Здесь аутентификация на стороне Keycloak происходит
          0

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

            0
            Если большая система, то обязанность проверки и переадресации обычно возлагают на API Gateway. Но часто оставляют проверку на наличие токена и валидацию его подписи в самих сервисах, чтобы предотвратить, на всякий случай, несанкционированный доступ.
          +1
          Поддержу Вас в вопросе выноса аутентификации из приложения. Конечно нужно рассматривать каждый случай отдельно и если в принципе только одно приложение, то почему бы и да.

          Другое дело авторизация. Проверка разрешений бывает вшита в сервисы и по этому все равно придется получать эти разрешения для текущего пользователя. Это может не сильно отличаться от процесса аутентификации.
            0

            У меня такой случай. Gateway есть…
            Вот думаю может там токены в едином месте и проверять. А сервисам за Gateway слать клеймы в headerах. Обычно нужен только ID пользователя…
            Мне кажется преимуществом была бы единая точка проверки. Все сервисы из одной домены и в кругу одной команды и одном уровне доверия.
            Или есть в этой практике прям очевидные минусы?

              0
              Такой подход тоже часто используют. Если у вас сервисы хорошо защищены от запросов извне, то можно убрать security совсем из сервисов, так мы уберем дополнительную обязанность с микросервисов. Но в этом случае у вас Gateway становится бутылочным горлышком.

              А как вы планируете разделять API по ролям по url запроса на gateway или в header класть информацию о роли пользователя?
              Или различные точки входа(возможно разные API Gateway) для разных ролей пользователя?
                0

                У нас пока онда роль и одна гейтвей. Разделять особо нечего. Бутылочное горлышко — это гейтвей по определению ;)

          0

          Спасибо, очень полезная статья

            +1
            Добрый день,
            Важно в сервисах авторизации не забывать об использовании SSL…

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

            Самое читаемое