Использование Spring Cloud Gateway в качестве OAuth2 клиента  и KeyCloak для защиты служб

от автора

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

Я, начинающий 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/


ссылка на оригинал статьи https://habr.com/ru/articles/735076/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *