null

Кэширование пользовательских ролей из стороннего сервиса при Stateless-аутентификации (Spring)

Представим не столь редкую ситуацию, когда у нас есть некоторый сервис на базе Spring, к которому шлет запросы клиентское приложение. Фронтэнд взаимодействует с Keycloak (или другим сервером аутентификации, не столь важно), определяет пользователя, делающего запросы, и при обращении к нашему сервису подкладывает в заголовок "Authorization" Bearer-токен в виде JWT, на основании которого в сервисе должны происходить идентификация пользователя и определение прав доступа.

Схема обычная и соответствует стандарту авторизации OAuth 2.0, однако проблемы возникают, если нам необходимо непосредственно в процессе авторизации выдать те или иные права, основываясь на данных из другого сервиса, либо в целом приходится использовать какие-либо данные извне. Несмотря на все преимущества stateless-аутентификации, среди которых масштабируемость и производительность, в данном случае она будет проблемой, потому что при данном подходе сервер не использует сессии и не хранит никакой информации о клиенте между запросами. Таким образом, информация из стороннего сервиса будет запрашиваться заново при каждом запросе, что может сильно ударить по производительности системы. Конечно подобные проблемы лучше решать еще на этапе проектирования архитектуры всей системы, но если ситуация уже возникла, то решение есть.

Чтобы не запрашивать данные из стороннего сервиса каждый раз, можно прибегнуть к кэшированию данных, сохраняя их по идентификатору пользователя. Для этого есть несколько решений, но мы рассмотрим вариант, оптимальный  по сложности реализации и скорости выполнения. Это будет стандартная поддержка кэширования Spring и библиотека Caffeine, которая де-факто уже также стала частью стандарта кэширования в Spring. Это будет быстрее, чем читать и обновлять значения из Redis, поднимать который имеет смысл лишь в случае множества распределенных сервисов, поскольку в данном случае кэш будет храниться прямо в памяти приложения. В дополнение к этому мы также напишем конвертер для аутентификации, который при входе будет объединять роли из Keycloak и роли, приходящие из стороннего сервиса, чтобы в нашем сервисе можно было единообразно их использовать.  

Шаг 1. Зависимости и конфигурация приложения 

  • Добавить зависимости:
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
    implementation 'org.springframework.boot:spring-boot-starter-cache'
    implementation 'com.github.ben-manes.caffeine:caffeine'
}
  • В главный класс надо добавить аннотацию @EnableCaching, которая включит кэширвание в Spring
  • Добавить в application.yml нужные настройки:
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://your-keycloak.com/realms/test
  cache:
    cache-names: infoFromOtherService
    caffeine:
      spec: expireAfterWrite=60s,maximumSize=1000

Здесь под issuer-uri мы указываем адрес Keycloak для проверки валидности токена, который пришел с клиентского приложения. В разделе cache указываем название для кэша, где мы будем хранить информацию о пользователе, приходящую из стороннего сервиса. В spec мы указываем настройки caffeine, которые помогут не очищать кэш самостоятельно, а задать ему срок жизни (в данном случае 60 секунд), после которого данные будут запрашиваться снова. В maximumSize пишется максимальный размер кэша, чтобы он не мог занять слишком много места.

Шаг 2. Сервис, который запрашивает данные о пользователе из стороннего сервиса 


import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

​​​​​​​@Service
public class ExternalSourceService {

    // sync = true защищает от повторной отправки застрявших в очереди запросов при истечении кэша
    @Cacheable(value = "infoFromOtherService", key = "#username", sync = true)
    public List<String> getInfoFromExternalSource(String username) {
        // Вызов API
        return externalSourceClient.findExternalInfoByUsername(username); 
    }
}

Это самый обычный сервис, который делает необходимый запрос по указанному API. Не будем приводить его реализацию, поскольку это сильно зависит от конкретного случая, отметим только необходимость добавить аннотацию Cacheable, value которой совпадает с названием кэша из настроек. С помощью этой аннотации мы отмечаем, что результаты именно этого метода необходимо кэшировать.

Шаг 3. Конвертер, преобразующий JWT-токен​​​​​​​

import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
​​​​​​​
@Component
@RequiredArgsConstructor
public class AuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    @Autowired
    private final ExternalSourceService externalSourceService;

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {
        UUID userId = UUID.fromString(jwt.getSubject()); // UUID пользователя
        String username = jwt.getClaimAsString("preferred_username"); // имя пользователя
        Collection<GrantedAuthority> roles = extractKeycloakRoles(jwt); // получаем роли из Keycloak
        // получаем информацию о пользователе из стороннего источника (что кэшируется)
        String externalSourceUserInfo = externalSourceService.getInfoFromExternalSource(username);
        try {
            // пытаемся добавить роли пользователя из стороннего источника
            Collection<GrantedAuthority> rolesFromExternalSource = extractExternalRoles(externalSourceUserInfo);
            roles.addAll(rolesFromExternalSource);
        } catch (JsonProcessingException e) {
            log.error("Cannot parse user info ", e);
        }
        return new JwtAuthenticationToken(jwt, roles, username);
    }

    private Collection<GrantedAuthority> extractKeycloakRoles(Jwt jwt) {
        Map<String, Object> realmAccess = jwt.getClaim("realm_access");
        if (realmAccess == null || realmAccess.isEmpty()) return Collections.emptyList();
        Collection<String> roles = (Collection<String>) realmAccess.get("roles");
        return roles.stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                .collect(Collectors.toList());
    }

    private Collection<GrantedAuthority> extractExternalRoles(String externalSourceUserInfo) throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode rootNode = objectMapper.readTree(externalSourceUserInfo);
​​​​​​​        // достаем нужный кусок JSON, но парсинг ответа от стороннего сервиса зависит от особенностей конкретной системы
        JsonNode globalRoles = rootNode.get("roles");
        return objectMapper.convertValue(globalRoles, new TypeReference<List<String>>() {
                }).stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                .collect(Collectors.toList());
    }

Шаг 4. Конфигурация Spring Security

import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthenticationConverter authenticationConverter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().authorizeRequests()
                    .antMatchers("/api/v1/example/**").authenticated()
                    .anyRequest().hasAnyRole("EXTERNAL_SOURCE_ADMIN")
                .and().oauth2ResourceServer().jwt().jwtAuthenticationConverter(authenticationConverter);

    }
}

Здесь мы уже можем в правилах проверки прав использовать те роли, которые мы получили из стороннего сервиса (вместе с кейклоковскими). И главное, что получение этих ролей не будет отрабатывать при каждом запросе к нашему сервису, а будет происходить лишь по истечении кэша, настройки которого, с использованием Caffeine, можно регулировать в application.yml. 

Вперед