Привет, Хабр!
Я, начинающий Java-разработчик, студент 3 курса, и это — моя первая статья здесь. Я не буду заострять внимание на теории, так как в интернете достаточно статей на эту тему, а сосредоточусь на практике и предложу свое решение. В процессе мы создадим несколько служб, а именно:
-
Config server (с помощью Spring Cloud Config Server)
-
Сервис обнаружения служб (с помощью Eureka server)
-
API-gateway (с помощью Spring Cloud Gateway)
-
Resource server (наш защищенный ресурс)
На кого нацелена эта статья?
В первую очередь, данная статья для таких же начинающих разработчиков, как и я, которые только пытаются освоить технологии Spring Cloud и KeyCloak, но уже имеют базовое представление о них.
Итак, приступим!
Содержание
-
Настройка Keycloak
-
Создание службы конфигурации (Config server)
-
Создание и конфигурация сервиса обнаружения служб (Eureka server)
-
Создание и конфигурация API-шлюза (Gateway server)
-
Создание и конфигурация нашего защищенного ресурса (Resource Server)
-
Запуск и тестирование служб
Настройка 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, ответ будет таким:


На данном этапе моя статья подходит к концу, надеюсь, что данная работа принесет пользу. Также жду каких-либо замечаний от опытных разработчиков.
Статьи и ресурсы, которые использовались мной:
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/735076/
Добавить комментарий