Пользовательская авторизация на основе аннотаций и распространение заголовков в микросервисах Spring Boot

Пользовательская авторизация на основе аннотаций и распространение заголовков в микросервисах Spring Boot

4 января 2024 г.

Микросервисы (или архитектура микросервисов) — это архитектурный стиль разработки приложений. Когда приложение на основе микрослужб отвечает на рабочий процесс или запрос пользователя, оно может вызывать несколько внешних микрослужб (доступных через Интернет), которые могут вызывать слишком мало внутренних микрослужб (не доступных через Интернет). Каждый микросервис имеет определенную задачу, что позволяет разделить обширное приложение на более мелкие части.

Аутентификация и авторизация в микросервисах

При переходе на микросервисы необходимо обеспечивать безопасность микросервисов иначе, чем монолитное приложение. Монолитное приложение имеет контекст однопользовательского сеанса, общий для всех внутренних компонентов. Архитектура, основанная на микросервисах, не разделяет пользовательский контекст между собой, поэтому для его совместного использования требуется явная связь между микросервисами.

Для защиты приложений обычно используется комбинация аутентификации (AuthN) и авторизации (AutZ).

AuthN — подтверждает, что вы тот, за кого себя выдаете.

AuthZ — может ли пользователь получить доступ к информации или выполнить операцию.

Недавно я разрабатывал рабочий процесс авторизации для набора микросервисов, и задача заключалась в следующем: -

1. Как включить авторизацию на основе ролей для каждого API?

2. Как распространить роли, полученные от внешнего микросервиса, на внутренний микросервис?

В этой статье мы увидим, как я решил эти две проблемы, разработав: -

1. Пользовательская аннотация с использованием AspectJ

2. Пользовательский фильтр запросов и перехватчик клиентов с использованием среды Spring

Давайте начнем!

В чем наша проблема?

Я создал диаграмму потока данных (рис. 1), чтобы объяснить проблему. Существует одно приложение пользовательского интерфейса, осуществляющее вызовы к внешним микрослужбам, и эти вызовы также могут обслуживаться другими внутренними микрослужбами. В обязанности службы диспетчера сеансов входит вход в систему пользователя, сохранение сеанса и вызов службы AuthZ. Служба AuthZ вызывает службу подписки пользователей для получения ролей вошедших в систему пользователей. Служба AuthZ возвращает роль службе диспетчера сеансов, и эти роли передаются (в заголовке в виде массива JSON) всем внутренним внешним микрослужбам при каждом вызове API.

Служба диспетчера сеансов присоединяет один HTTP-заголовок запроса «X-Application-Roles» ко всем запросам API.

Пример: X-Application-Roles: ["GLOBAL_ADMIN", "TENANT_ADMIN", "APPLICATION_USER"]

Когда запрос API имеет этот заголовок с заданными значениями, он говорит, что идентификатору вошедшего в систему пользователя эти роли назначены для каждой подписки на приложение.

Figure1: High-level design explaining flow of communication between microservices

Каково наше решение?

Перейдем к первоначальным формулировкам задач:-

Как включить авторизацию на основе ролей для каждого API? Я попробовал следующие подходы для решения этой проблемы: -

* Создайте отдельный микросервис, чтобы проверить, имеют ли роли, входящие в заголовок X-Application-Roles, роль, необходимую для выполнения этого вызова API, и используйте этот вызов микросервиса в каждом запросе API. * Создайте аннотацию настраиваемых ролей и используйте ее во всех серверных API.

Я решил использовать второй подход, поскольку мне нужно было разрешить роли для более чем 30 серверных API, а первый подход заключался в добавлении еще одного перехода с каждым запросом API.

Как распространить роли, полученные от внешнего микросервиса, на внутренний микросервис? Решение первой проблемы хорошо подходит для внешних микросервисов, поскольку они напрямую получали заголовок X-Application-Roles от запросов, поступающих от пользовательского интерфейса, но проблема заключалась в том, чтобы распространить этот заголовок дальше на внутренние микросервисы и использовать ту же аннотацию пользовательской роли. Я попробовал следующие подходы для решения этой проблемы: -

* Используйте библиотеку Spring для распространения пользовательских заголовков, например, spring cloud sleuth. * Используйте запрос фильтр и клиент перехватчик

Я решил применить второй подход, поскольку не хочу вводить еще одну зависимость от библиотеки и дополнительную настройку.

Аннотация пользовательских ролей для аннотирования серверных API

Шаг 1. Создайте перечисление для хранения ролей и имени заголовка: -

public enum ApplicationRoles { 
  GLOBAL_ADMIN,
  TENANT_ADMIN,
  APPLICATION_USER;

  public static final String X_APPLICATION_ROLES_HEADER = "X-Application-Roles";
}

Шаг 2. Создайте интерфейс, который мы будем использовать в качестве аннотации на уровне метода для API-интерфейсов контроллера загрузки Spring: -

@Target(ElementType.METHOD) 
@Retention(RetentionPolicy.RUNTIME) 
public @interface RolesAllowed { 
  ApplicationRoles[] value();
}

Шаг 3. Создайте собственный аспект с помощью библиотеки AspectJ, чтобы проверить, имеют ли роли, указанные в заголовке X-Application-Roles, роль, необходимую для выполнения этого вызова API. Эта аннотация будет обслуживать запрос API, когда роли, представленные в заголовке запроса, применяются через API (показано на следующем шаге), в противном случае будет выдано исключение ЗАПРЕЩЕНО.

@Aspect
public class RolesCheckAspect {

    // User your package name where RolesAllowed interface exists
    @Before("@annotation(com.adp.security.auth.RolesAllowed)")
    public void before(JoinPoint joinPoint) throws JsonProcessingException {

        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        // Get expected Roles from annotation
        Set<ApplicationRoles> expectedRoles = Arrays.stream(methodSignature.getMethod().getAnnotation(RolesAllowed.class).value()).collect(Collectors.toSet());
        // Get all annotations defined in method signature
        Annotation[][] parameterAnnotations = methodSignature.getMethod().getParameterAnnotations();
        // Get actual arguments from the method
        Object[] methodArguments = joinPoint.getArgs();
        // Get actual roles from request
        Set<ApplicationRoles> actualRoles = getActualRoles(parameterAnnotations, methodArguments);
        // Check whether actual roles have expected API role
        if (expectedRoles.stream().noneMatch(actualRoles::contains))
            throw new ForbiddenException(String.format("Required roles are missing in '%s' header", ApplicationRoles.X_APPLICATION_ROLES_HEADER));
    }

    private static Set<ApplicationRoles> getActualRoles(Annotation[][] parameterAnnotations, Object[] methodArguments) throws JsonProcessingException {
        // Get roles header index in annotations
        int rolesHeaderIndex = getRolesHeaderIndex(parameterAnnotations);

        ObjectMapper objectMapper = JsonMapper.builder()
                .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS)
                .build();
        return objectMapper.readValue(String.valueOf(methodArguments[rolesHeaderIndex]), new TypeReference<>() {});
    }

    private static int getRolesHeaderIndex(Annotation[][] parameterAnnotations) {
        record HeaderWithIndex(int index, Optional<RequestHeader> requestHeader){}

        HeaderWithIndex rolesHeader = IntStream.range(0, parameterAnnotations.length)
                .mapToObj(argIndex -> {
                    Optional<RequestHeader> optionalRequestHeader = Arrays.stream(parameterAnnotations[argIndex])
                            .filter(RequestHeader.class::isInstance)
                            .map(RequestHeader.class::cast)
                            .filter(requestHeader -> requestHeader.value().equalsIgnoreCase(ApplicationRoles.X_APPLICATION_ROLES_HEADER))
                            .findFirst();
                    return new HeaderWithIndex(argIndex, optionalRequestHeader);
                })
                .filter(headerWithIndex -> headerWithIndex.requestHeader().isPresent())
                .findFirst()
                .orElseThrow(() -> new BadRequestException(String.format("Required header '%s' is not present", ApplicationRoles.X_APPLICATION_ROLES_HEADER)));

        return rolesHeader.index();
    }
}

Шаг 4. Настройте компонент RolesCheckAspect. Этот шаг необходим, если ваш аспектный класс ролей существует в разных проектах библиотеки.

@Configuration
public class RestTemplateConfig {
    @Bean
    public RolesCheckAspect rolesCheckAspect() {
           return new RolesCheckAspect();
    }
}

Шаг 5. Используйте аннотацию ролей через API

@GetMapping("/api/v1/hierarchy")
@RolesAllowed(value={ApplicationRoles.APPLICATION_USER})
public ResponseEntity<String> getHierarchy(
                     @RequestHeader(TENANT) String tenant,
                     @RequestHeader(ApplicationRoles.X_APPLICATION_ROLES_HEADER) String roles
) { 
       return ResponseEntity.ok(hierarchyService.getHierarchy(tenant));
}

Распространение заголовка ролей

Распространение заголовка роли из внешних API микросервисов во внутренние API микросервисов

Теперь у нас включена авторизация для внешнего микросервиса; Оставшаяся проблема — распространение заголовка X-Application-Roles во внутренний микросервис.

Шаг 1. Создайте класс для хранения ролей из входящих запросов API. Этот класс будет настроен как запросить bean-компонент с областью действия.

public class RolesInfo { 
  private String roles;

  public String getRoles() {
    return roles;
  }

  public void setRoles(String roles) {
    this.roles = roles;
  }
}

Шаг 2. Создайте собственный фильтр запросов для хранения ролей из заголовка в объекте RolesInfo.

public class RolesFilter implements Filter {

    private final RolesInfo rolesInfo;

    public RolesFilter(RolesInfo rolesInfo) {
        this.rolesInfo = rolesInfo;
    }

    @Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
    String roles = httpServletRequest.getHeader(ApplicationRoles.X_APPLICATION_ROLES_HEADER);
    // Setting roles in request scoped bean object
    rolesInfo.setRoles(roles);
    filterChain.doFilter(servletRequest, servletResponse);
  }
}

Шаг 3. Создайте перехватчик клиента для извлечения ролей из bean-компонента области запроса и распространения на внутренние запросы API микросервиса.

public class RolesInterceptor implements ClientHttpRequestInterceptor { 

  private final RolesInfo rolesInfo;

  public RolesInterceptor(RolesInfo rolesInfo) {
      this.rolesInfo = rolesInfo;
  }

  @Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
    // Header propagation : Get roles from request scoped bean object and add with header
    String roles = rolesInfo.getRoles();
    request.getHeaders().add(ApplicationRoles.X_APPLICATION_ROLES_HEADER, roles);
    return execution.execute(request, body);
  }
} 

Шаг 4. Настройте bean-компонент с областью запроса и добавьте клиентский перехватчик в RestTemplate.

@Configuration 
public class RestTemplateConfig { 

  @Bean
  public RestTemplate restTemplate() {
      RestTemplate restTemplate = new RestTemplate();
      List<ClientHttpRequestInterceptor> interceptors = restTemplate.getInterceptors();
      if (CollectionUtils.isEmpty(interceptors)) {
          interceptors = new ArrayList<>();
      }
      interceptors.add(new RolesInterceptor(rolesInfo()));
      restTemplate.setInterceptors(interceptors);
      return restTemplate;
  }

  @Bean
  @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
  public RolesInfo rolesInfo () {
      return new RolesInfo();
  }
}

Лучше создать отдельный проект библиотеки с аспектом ролей, фильтром запросов и перехватчиком клиента и использовать его в разных репозиториях кода микросервиса, чтобы избежать повторения одних и тех же шагов и дублирования кода.

Надеюсь, вам понравилось читать эту статью!


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE