Привет, Хабр!

Я, начинающий Java-разработчик, студент 3 курса, и это - моя первая статья здесь. Я не буду заострять внимание на теории, так как в интернете достаточно статей на эту тему, а сосредоточусь на практике и предложу свое решение. В процессе мы создадим несколько служб, а именно:

  • Config server (с помощью Spring Cloud Config Server)

  • Сервис обнаружения служб (с помощью Eureka server)

  • API-gateway (с помощью Spring Cloud Gateway)

  • Resource server (наш защищенный ресурс)

На кого нацелена эта статья?

В первую очередь, данная статья для таких же начинающих разработчиков, как и я, которые только пытаются освоить технологии Spring Cloud и KeyCloak, но уже имеют базовое представление о них.

Итак, приступим!

Содержание

  1. Настройка Keycloak

  2. Создание службы конфигурации (Config server)

  3. Создание и конфигурация сервиса обнаружения служб (Eureka server)

  4. Создание и конфигурация API-шлюза (Gateway server)

  5. Создание и конфигурация нашего защищенного ресурса (Resource Server)

  6. Запуск и тестирование служб

Настройка Keycloak

Для начала подтянем образ KeyCloak из Docker и запустим контейнер с помощью команды:

docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:21.1.1 start-dev 

где вместо KEYCLOAK_ADMIN и  KEYCLOAK_ADMIN_PASSWORD    указываем желаемый логин и пароль для использования интерфейса админа.

После создадим пространство для нашего приложения:

Далее создадим нашего клиента. Напомню, что в качестве клиента будет выступать наш API-шлюз:

Создадим две роли для нашего клиента - админ и пользователь:

И создадим  двух пользователей и присвоим им соответствующие роли:

Установим пароли для наших пользователей:

На этом настройка KeyCloak подошла к концу и мы можем перейти к созданию службы конфигурации.

Создание службы конфигурации (Config server)

Я буду использовать для конфигурации локальное хранилище (в конце статьи добавлю ссылки на интересные статьи, посвященные другим видам конфигурации). Зависимости в нашем pom.xml будут выглядеть так:

   <dependency>
       <groupId>org.springframework.cloud</groupId>
       <artifactId>spring-cloud-config-server</artifactId>
   </dependency>
   <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-devtools</artifactId>
       <scope>runtime</scope>
       <optional>true</optional>
   </dependency>
   <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-configuration-processor</artifactId>
       <optional>true</optional>
   </dependency>
   <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-test</artifactId>
       <scope>test</scope>
   </dependency>

Наш основной и единственный класс будет выглядеть так:

ConfigServerApplication.java


import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.config.server.EnableConfigServer;

@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {

   public static void main(String[] args) {
       SpringApplication.run(ConfigServerApplication.class, args);
   }

}

Аннотация @EnableConfigServer говорит о том, что наш сервис будет сервером конфигурации, и при запуске каждая служба в нашем приложении будет обращаться к нему, чтобы получить свою конфигурацию.

application.properties

spring.application.name=config-server
spring.profiles.active=native
server.port=8071
spring.cloud.config.server.native.search-locations=
  • spring.application.name - название нашей службы

  • spring.profiles.active - данное свойство говорит о том, что наша служба конфигурации будет использовать локальное хранилище для конфигурации

  • spring.cloud.config.server.native.search-locations - здесь мы должны указать путь в нашем локальном хранилище, где будут лежать properties файлы для каждой службы

На данном этапе создание службы конфигураций закончено.

Создание и конфигурация сервиса обнаружения служб (Eureka server)

Перейдем к созданию сервера обнаружения служб. Зависимости pom.xml:

   <dependency>
       <groupId>org.springframework.cloud</groupId>
       <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
   </dependency>
   <dependency>
       <groupId>org.springframework.cloud</groupId>
       <artifactId>spring-cloud-starter-config</artifactId>
   </dependency>
   <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-devtools</artifactId>
       <scope>runtime</scope>
       <optional>true</optional>
   </dependency>
   <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-configuration-processor</artifactId>
       <optional>true</optional>
   </dependency>
   <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-test</artifactId>
       <scope>test</scope>
   </dependency>

EurekaServerApplication класс будет выглядеть так:

EurekaServerApplication.java

@SpringBootApplication
@EnableEurekaServer
@RefreshScope
public class EurekaServerApplication {

   public static void main(String[] args) {
       SpringApplication.run(EurekaServerApplication.class, args);
   }

}

application.properties

spring.application.name=eserver
spring.profiles.active=dev
spring.config.import=optional:configserver:http://localhost:8071

Здесь мы указываем название службы, окружение и ссылку на сервер конфигурации. Таким образом, конфигурация службы будет доступна по ссылке:

http://localhost:8071/eserver/dev

В локальном хранилище создадим файл eserver-dev.properties в соответствии с названием службы и его окружением и добавим в него следующие свойства:

eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false
eureka.instance.hostname=localhost
server.port=8070
  • eureka.client.register-with-eureka — определяет, регистрируется ли сервис как клиент на Eureka Server.

  • eureka.client.fetch-registry — получать или нет информацию о зарегистрированных клиентах.

Перейдем к настройке API - шлюза.

Создание и конфигурация API-шлюза (Spring Cloud Gateway)

Зависимости pom.xml:

<dependency>
   <groupId>org.springframework.cloud</groupId>
   <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
   <groupId>org.springframework.cloud</groupId>
   <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
   <groupId>org.springframework.cloud</groupId>
   <artifactId>spring-cloud-starter-config</artifactId>
   <version>4.0.1</version>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-devtools</artifactId>
   <scope>runtime</scope>
   <optional>true</optional>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-configuration-processor</artifactId>
   <optional>true</optional>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-test</artifactId>
   <scope>test</scope>
</dependency>

application.properties

spring.application.name=gateway
spring.profiles.active=dev
spring.config.import=optional:configserver:http://localhost:8071

gateway-dev.properties

eureka.client.service-url.defaultZone=http://localhost:8070/eureka
eureka.client.register-with-eureka=true
eureka.instance.prefer-ip-address=true
eureka.client.fetch-registry=true
eureka.instance.hostname=localhost
spring.cloud.gateway.discovery.locator.enabled=true
spring.cloud.gateway.discovery.locator.lower-case-service-id=true
server.port=8081
spring.cloud.gateway.default-filters=TokenRelay=
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8080/realms/habr
spring.security.oauth2.client.registration.keycloak.provider=keycloak
spring.security.oauth2.client.registration.keycloak.client-id=client-id
spring.security.oauth2.client.registration.keycloak.client-secret=client-secret
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.keycloak.scope=openid
  • eureka.client.register-with-eureka - служба должна регистрироваться в Eureka server

  • eureka.client.service-url.defaultZone - ссылка, по которой сервис будет регистрироваться в службе обнаружения

  • spring.cloud.gateway.discovery.locator.enabled - настройка для создания маршрутов на основе служб, зарегистрированных в Eureka

  • spring.cloud.gateway.default-filters=TokenRelay= - пересылка токена будет происходить между службами

  • spring.security.oauth2.client.provider.keycloak.issuer-uri - ссылка на сервер, который аутентифицирует пользователей и выдает токен доступа

  • spring.security.oauth2.client.registration.keycloak.client-id - здесь необходимо указать id клиента, который мы создали в KeyCloak

  • spring.security.oauth2.client.registration.keycloak.client-secret - Client Secret созданного клиента

Более подробно про все настройки можно прочитать в данной статье:

https://habr.com/ru/articles/701912/

Реализация Security Config для API-шлюза, выступающим клиентом:

SecurityConfig.java

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
   @Autowired
   private ReactiveClientRegistrationRepository registrationRepository;
   @Bean
   public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
       http
               .authorizeExchange()
               .anyExchange()
               .authenticated()
               .and()
               .oauth2Login()
               .and()
               .logout()
               .logoutSuccessHandler(oidcLogoutSuccessHandler())
       ;

       return http.build();
   }
   @Bean
   public ServerLogoutSuccessHandler oidcLogoutSuccessHandler() {
       OidcClientInitiatedServerLogoutSuccessHandler successHandler = new OidcClientInitiatedServerLogoutSuccessHandler(registrationRepository);
       successHandler.setPostLogoutRedirectUri(url);
       return successHandler;
   }
}

Создание и конфигурация нашего защищенного ресурса (Resource server)

Зависимости pom.xml:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
   <groupId>org.springframework.cloud</groupId>
   <artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<dependency>
   <groupId>org.springframework.cloud</groupId>
   <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
   <groupId>org.projectlombok</groupId>
   <artifactId>lombok</artifactId>
   <optional>true</optional>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-devtools</artifactId>
   <scope>runtime</scope>
   <optional>true</optional>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-configuration-processor</artifactId>
   <optional>true</optional>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-test</artifactId>
   <scope>test</scope>
</dependency>

resource-dev.properties

server.port=0
eureka.client.service-url.defaultZone=http://localhost:8070/eureka
eureka.client.register-with-eureka=true
eureka.instance.prefer-ip-address=true
eureka.client.fetch-registry=true
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8080/realms/habr
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=${spring.security.oauth2.resourceserver.jwt.issuer-uri}/protocol/openid-connect/certs
jwt.auth.converter.resource-id=habr-client
jwt.auth.converter.principal-attribute=preferred_username

JWT включают всю информацию в токене, поэтому серверу ресурсов необходимо проверить подпись токена, чтобы убедиться, что данные не были изменены. Свойство jwk-set-uri содержит открытый ключ , который сервер может использовать для этой цели. Данные настройки мы будем использовать для проверки пользователя:

  • jwt.auth.converter.resource-id - id нашего клиента KeyCloak

  • jwt.auth.converter.principal-attribute - значение этого поля JWT мы будем извлекать, чтобы аутентифицировать пользователя.

Далее необходимо написать мапперы для извлечения из JWT необходимой информации о пользователе:

JwtAuthConverterProperties.java

@Data
@Validated
@Configuration
@ConfigurationProperties(prefix = "jwt.auth.converter")
public class JwtAuthConverterProperties {

   private String resourceId;
   private String principalAttribute;
}

Этот класс будет извлекать приведенные выше настройки, которые будут использоваться в классе JwtAuthConverter:

JwtAuthConverter.java

@Component
public class JwtAuthConverter implements Converter<Jwt, AbstractAuthenticationToken> {

   private final JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();

   private final JwtAuthConverterProperties properties;

   public JwtAuthConverter(JwtAuthConverterProperties properties) {
       this.properties = properties;
   }

   @Override
   public AbstractAuthenticationToken convert(Jwt jwt) {
       Collection<GrantedAuthority> authorities = Stream.concat(
               jwtGrantedAuthoritiesConverter.convert(jwt).stream(),
               extractResourceRoles(jwt).stream()).collect(Collectors.toSet());
       return new JwtAuthenticationToken(jwt, authorities, getPrincipalClaimName(jwt));
   }

   private String getPrincipalClaimName(Jwt jwt) {
       String claimName = JwtClaimNames.SUB;
       if (properties.getPrincipalAttribute() != null) {
           claimName = properties.getPrincipalAttribute();
       }
       return jwt.getClaim(claimName);
   }

   private Collection<? extends GrantedAuthority> extractResourceRoles(Jwt jwt) {
       Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
       Map<String, Object> resource;
       Collection<String> resourceRoles;
       if (resourceAccess == null
               || (resource = (Map<String, Object>) resourceAccess.get(properties.getResourceId())) == null
               || (resourceRoles = (Collection<String>) resource.get("roles")) == null) {
           return Set.of();
       }
       return resourceRoles.stream()
               .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
               .collect(Collectors.toSet());
   }
}

Здесь мы извлекаем информацию о пользователе и его ролях. Реализации данных классов были взяты из статьи: https://medium.com/geekculture/using-keycloak-with-spring-boot-3-0-376fa9f60e0b

В качестве ресурса мы будем возвращать класс Message.

Message.java

@Getter
@Setter
@NoArgsConstructor

public class Message {
   private boolean status;

   @JsonInclude(JsonInclude.Include.NON_NULL)
   private String msg;

   @JsonInclude(JsonInclude.Include.NON_NULL)
   private Error error;

   public Message(boolean status, Error error) {
       this.status = status;
       this.error = error;
   }
   public Message(boolean status, String message) {
       this.status = status;
       this.msg=message;
   }
}

Error.java

@Getter
@Setter
@NoArgsConstructor
public class Error {
   public Error(String msg, int code) {
       this.msg = msg;
       this.code = code;
   }
   private String msg;
   private int code;

}

Наш RestController будет содержать две конечные точки и выглядеть так:

@RestController
public class ResourceController {
   @GetMapping(value = "/admin")
   public ResponseEntity<Message> helloAdmin(){
       return new ResponseEntity<>(new Message(true, "Hello from Admin"), HttpStatusCode.valueOf(HttpStatus.OK.value()));
   }

   @GetMapping(value = "/user")
   public ResponseEntity<Message> helloUser(){
       return new ResponseEntity<>(new Message(true, "Hello from User"), HttpStatusCode.valueOf(HttpStatus.OK.value()));
   }

}

SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig {
   @Autowired
   private  JwtAuthConverter jwtAuthConverter;
   @Bean
   public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
       http
               .authorizeHttpRequests((authz) -> {
                           try {
                               authz
                                       .requestMatchers("/admin").hasRole("ADMIN")
                                       .requestMatchers("/user").hasRole("USER")
                                       .anyRequest().authenticated()
                                       .and()
                                       .exceptionHandling().accessDeniedHandler(accessDeniedHandler())
                                       .and()
                                       .oauth2ResourceServer()
                                       .jwt()
                                       .jwtAuthenticationConverter(jwtAuthConverter);
                           } catch (Exception e) {
                               e.printStackTrace();
                           }
                       }
               );
       return http.build();
   }
   @Bean
   public AccessDeniedHandler accessDeniedHandler(){
       return new CustomAccessDeniedHandler();
   }
}

Здесь мы добавляем наш JwtAuthConverter и кастомный AccessDeniedHandler, который возвращает Message с кодом 403, если для запрашиваемого ресурса нет прав:

{
  "status" : false,
  "error" : {
    "msg" : "Forbidden",
    "code" : 403
  }
}

CustomAccessDeniedHandler.java

public class CustomAccessDeniedHandler implements AccessDeniedHandler {

   private static final ObjectMapper objectMapper = new ObjectMapper();
   @Override
   public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
       response.setContentType("application/json;charset=utf-8");
       response.setStatus(HttpStatus.FORBIDDEN.value());
       response.getOutputStream().println(objectMapper.writerWithDefaultPrettyPrinter().
               writeValueAsString(
                       new Message(false,
                               new Error("Forbidden", 403))));
   }
}

Запуск и тестирование служб

Для начала запустим Config Server. Перейдя, например, по http://localhost:8071/resource/dev мы можем получить конфигурацию для Resource server:

Далее запустим Eureka server. Перейдя по http://localhost:8070 мы перейдем на стартовую страничку Eureka:

После этого запускаем наш шлюз и resource server. Перейдем еще раз на страничку Eureka и убедимся, что наши сервисы успешно зарегистрировались:

Как видим, наши службы работают, перейдем по http://localhost:8081/resource/admin (наш API-шлюз сконфигурирован таким образом, что сначала нужно прописать название службы, а после - адрес конечной точки)  и нас должно редиректнуть на страничку KeyCloak. Введем данные, которые задавали в самом начале для админа и получим доступ к ресурсу.

После этого, по http://localhost:8081/resource/admin мы получим такой ответ:

Если же мы попробуем перейти по  http://localhost:8081/resource/user, ответ будет таким:

На данном этапе моя статья подходит к концу, надеюсь, что данная работа принесет пользу. Также жду каких-либо замечаний от опытных разработчиков.

Ссылка на проект GitHub

Статьи и ресурсы, которые использовались мной:

https://medium.com/geekculture/using-keycloak-with-spring-boot-3-0-376fa9f60e0b

https://habr.com/ru/articles/701912/

https://habr.com/ru/companies/otus/articles/590761/

https://habr.com/ru/articles/701912/

https://habr.com/ru/companies/otus/articles/539348/

Комментарии (2)


  1. 18741878
    15.05.2023 12:38

    Вот тут обо всем этом и о многом другом: https://dmkpress.com/catalog/computer/programming/java/978-5-97060-971-2/


    1. timofeyreedtz Автор
      15.05.2023 12:38

      Согласен с вами, как раз недавно прочитал её)