Привет, Хабр!
Я, начинающий 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/
18741878
Вот тут обо всем этом и о многом другом: https://dmkpress.com/catalog/computer/programming/java/978-5-97060-971-2/
timofeyreedtz Автор
Согласен с вами, как раз недавно прочитал её)