
Встроенные в gRPC способы проверки прав справляются со своими задачами, но накладывают ряд ограничений и не дают возможность писать сложные варианты проверок без «оригинальных» инженерных решений. А тот, кто хоть раз грешил обходом ограничений, знает, чем это чревато.
В одном из проектов мы решили попробовать упростить процесс валидации данных при внешней интеграции, соблюдая все правила безопасности. Шалость удалась:)
Наш backend-разработчик — Александр — нашел-таки то самое «оригинальное» инженерное решение. Решили поделиться с вами, чтобы и вам страдать не приходилось.
Александр, backend-разработчик
Люблю кодить и шкодить под веселую музыку и чашкой кофе:)
Содержание
То, что нужно обязательно изучить начинающим разрабам
-
Открываем коробочку — немного о gRPC.
-
Что в коробочке? — механизмы аутентификации в gRPC.
То, что будет полезно даже опытным бэкендерам
-
Разделяй и властвуй! — немного о нашем «оригинальное» инженерном решении для упрощения аутентификации удаленного вызова.
-
Шаблон gRPC и реализация на Java — блок под копипаст. Тут то, ради чего мы собрались.
Открываем коробочку
gRPC — это современный высокопроизводительный фреймворк с открытым исходным кодом для удаленного вызова процедур.
Компания Google выпустила фреймворк gRPC в 2015 и вдохнула новую жизнь в популярную технологию RPC.
Фреймворк обладает рядом преимуществ:
-
поддержка большого количества языков и генераторов для сервера/клиента;
-
очень быстрый;
-
описание сервисов и сообщений в виде контракта proto-файлов без привязки к языку;
-
двунаправленная потоковая передача данных через HTTP/2;
-
блокирующие и неблокирующие вызовы;
-
проверка работоспособности;
-
аутентификация.
Описанные выше преимущества дают разработчикам много свободы, привели к возрастающей популярности gRPC. В результате все больше систем начинают использовать gRPC вместо привычного REST.

gRPC чаще используют для внутреннего взаимодействия между сервисами, но в последних версиях была доработана аутентификация и появилась возможность использовать gRPC для внешних интеграций.
Что в коробочке
В gRPC между клиентом и сервером встроены следующие механизмы идентификации.
-
SSL/TLS. В gRPC встроена поддержка шифрования SSL/TLS для обмена данными между клиентом и сервером. Настройка проходит достаточно просто — в официальной документации есть подробно описанная инструкция.
-
ALTS. В gRPC встроен механизм защиты данных ALTS, который используется в облачных решениях Google (GCP). Google хорошо описывает использование ALTS в gRPC.
-
Аутентификация на основе токенов. В gRPC встроена поддержка механизма передачи метаданных авторизации в запросе/ответе.
Условно, все взаимодействия между сервисами можно разделить на две категории — внутреннее взаимодействие между сервисами системы и внешнее АПИ для взаимодействия с системой. Все механизмы подходят для защиты внутренних и внешних взаимодействий. Выбор зависит от особенностей системы и требований безопасности.
Взаимодействие между сервисами внутри системы
Сервисы небольших систем без жестких требований к безопасности работают внутри одного контура или в облаке, поэтому, им вполне можно доверять и не усложнять защиту каналов.

Взаимодействие в больших системах устроено интереснее, и для транспортных каналов часто настраиваются ограничения. Например, в сервисы добавляются сертификаты, которые определяют ограничение доступа. Или же используется единый сервис авторизации, который раздает и проверяет авторизационные токены на наличие прав и видов полномочий.
Внешнее API для взаимодействия с системой
Внешние интеграции с системой разнообразны и зависят от требований архитектуры, безопасности и др. Чаще всего под внешними интеграциями понимается публичный API для взаимодействия с системой, например, REST или gRPC. Очевидно, что публичные каналы связи необходимо защищать. Хорошая практика защиты публичных каналов — это использование OAuth2 и JWT-токенов.

Разделяй и властвуй!
Сервисы внутри системы могут быть написаны на разных языках и иметь различные способы взаимодействия — синхронные, асинхронные, сообщения и т. д. Задача публичного API — это скрыть внутреннюю кухню системы и предоставить удобный API для взаимодействия с системой. Хорошая практика — это использование шлюза BFF. В таком случае, проводить проверку внешних токенов или получать внутренние токены удобнее всего внутри шлюза.

Стоит отметить важный момент при работе с внешними JWT-токенами
Внешний JWT-токен может содержать только ID пользователя и ключи для проверки подлинности, а может содержать информацию о пользователе, например, логин, почту, роли и т. д.
-
В первом случае, мы проверяем корректность токена и обмениваем его на внутренний токен с информацией о правах доступа.
-
Во втором случае, не всегда есть необходимость обменивать токен, достаточно проверить его корректность и извлечь из него информацию о пользователе.
Улучшаем коробочку удобной системой хранения
Все описанное ниже хорошо применимо для небольших систем, работающих в одном контуре или облаке, где есть доверие к вызовам внутренних сервисов. Такой подход невозможно применить ко всем системам, особенно крупным.
Протокол gPRC отличается от привычного нам REST тем, что не накладывает жестких ограничений к формату сообщений, поэтому есть возможность не паковать в JWT-токен информацию о потребителе* сервиса, а передавать ее во вложенной структуре.
Почему это важно?
Сервисы не всегда вызываются пользователями! В случае внешнего вызова API через REST или gRPC, мы получаем JWT-токен, из которого извлекаем информацию о пользователе. Существуют бизнес-задачи, которые подразумевают вызов одного сервиса из другого, например, во время работы планировщика. В этом случае ID системы и присвоенные ей роли определяются в момент вызова.
Как реализовать?

Шаблон gRPC и реализация на Java
3..2..1..полетели!
-
Создаем общую библиотеку с описанием информации о потребителе в proto-файле
ConsumerSecurity.proto syntax = "proto3"; package ru.myapp.grpc; option java_package = "ru.myapp.grpc"; option java_outer_classname = "GrpcConsumerProto"; option java_multiple_files = true; /* *Потребитель сервиса - пользователь или система */ message GrpcConsumer { oneof consumer { GrpcUser user = 1; GrpcSystem system = 2; } } /* *Данные пользователя / message GrpcUser { string id = 1; repeated GrpcRole roles = 2; repeated GrpcOrganisation organisations = 3; string firstName = 4; string lastName = 5; string middleName = 6; string email = 7; string phone = 8; } / *Данные системы */ message GrpcSystem { string id = 1; repeated GrpcRole roles = 2; } /* *Роль */ message GrpcRole { string roleName = 1; } /* *Организация */ message GrpcOrganisation { string orgId = 1; string orgName = 2; }
-
Включаем описание потребителя в описание сервисов сервера
TestGrpcService.proto syntax = "proto3"; package ru.myapp.grpc.orguser; import "ru/myapp/grpc/ConsumerSecurity.proto"; option java_multiple_files = true; service TestGrpcService { rpc TestOperation (ReqMessage) returns (RespMessage) { } } message ReqMessage { ru.myapp.grpc.GrpcConsumer consumer = 1; string message = 2; } message RespMessage { string message = 2; }
-
Теперь во время вызова удаленной процедуры передаем информацию о потребителе сервиса, а во время обработки удаленной процедуры на сервере анализируем информацию о потребителе

Какие плюшки получаем
-
Появляется возможность создавать потребителя — пользователя или систему. Вызывающая сторона определяет информацию о потребителе, но в критических секциях можно вызвать сервис авторизации и выполнить дополнительную проверку прав пользователя.
-
Пользователь и система содержат все необходимые данные для работы процедуры. Например, уникальный идентификатор, роль, имя и т.д.
-
При неправильном заполнении потребителя можно получить некорректный результат выполнения удаленной процедуры. Поэтому следует более внимательно относится к написанию клиентской части.
Сценарии использования
-
Шлюз извлекает информацию из токена. В шлюз приходит внешний запрос с JWT-токеном, из которого извлекается информация о потребителе. Затем формируется DTO с потребителем, заполняется нужными данными и отправляется при вызове во внутренние сервисы.
-
Шлюз извлекает ID из токена. В шлюз приходит внешний запрос с JWT-токеном (или без), который содержит уникальный идентификатор пользователя. Из сервиса авторизации по этому идентификатору извлекается информация о пользователе, а затем формируется DTO с потребителем, заполняется нужными данными и отправляется при вызове во внутренние сервисы.
-
Вызов между сервисами от пользователя. gRPC-сервер обрабатывает запрос, содержащий данные потребителя, и ему нужно взывать другой сервис от имени этого пользователя. В этом случае DTO «пробрасывается» в другой сервис.
-
Вызов между сервисами от системы. Сервис выполняет запрос от лица системы с расширенными правами или выполняется работа по расписанию и все запросы выполняется от системы. В этом случае, формируется DTO потребителя-системы и передается во время вызова.
Делаем еще проще
Структура потребителя одинакова во всех сервисах, и задачи для работы с ней примерно похожи. Поэтому можно сделать стартер с описанием сервиса по работе с DTO потребителя.
ConsumerDetailService.java — Пример интерфейса для сервиса проверки потребителя.public interface ConsumerDetailService<C> { List<String> getRoles(C consumer); boolean hasRole(C consumer, String role); void hasRoleOrElseThrow(C consumer, String role); List<UUID> getOrganisations(C consumer); boolean hasOrganisationId(C consumer, UUID orgId); Optional<UUID> getUserId(C consumer); boolean hasUserId(C consumer, UUID userId); Optional<UUID> getSystemId(C consumer); boolean hasSystemId(C consumer, UUID sysId); boolean isUser(C consumer); boolean isSystem(C consumer); } GrpcConsumerDetailServiceImpl.java — пример реализации трех методов для работы с ролями потребителя public class GrpcConsumerDetailServiceImpl implements ConsumerDetailService<GrpcConsumer> { @Override public List<String> getRoles(GrpcConsumer consumer) { return Optional.ofNullable(consumer) .map(it -> switch (it.getConsumerCase()) { case USER -> it.getUser().getRolesList(); case SYSTEM -> it.getSystem().getRolesList(); default -> List.of(GrpcRole.newBuilder().setRoleName("ROLE_ANONYMOUS").build()); }) .orElseGet(ArrayList::new) .stream() .map(GrpcRole::getRoleName) .collect(Collectors.toList()); } @Override public boolean hasRole(GrpcConsumer consumer, String role) { if (!StringUtils.hasText(role)) { return false; } return Optional.ofNullable(consumer) .map(it -> switch (it.getConsumerCase()) { case USER -> it.getUser().getRolesList(); case SYSTEM -> it.getSystem().getRolesList(); default -> List.of(GrpcRole.newBuilder().setRoleName("ROLE_ANONYMOUS").build()); }) .orElseGet(ArrayList::new) .stream() .map(GrpcRole::getRoleName) .anyMatch(role::equalsIgnoreCase); } @Override public void hasRoleOrElseThrow(GrpcConsumer consumer, String role) { if (!hasRole(consumer, role)) { throw new SecurityException(String.format("Consumer not contain %s role", role)); } } } ConsumerSecurityAutoConfiguration.java авто-конфигурация @AllArgsConstructor @Configuration @EnableConfigurationProperties(SecurityConsumerProperties.class) public class ConsumerSecurityAutoConfiguration { @Bean @ConditionalOnMissingBean ConsumerDetailService<GrpcConsumer> sckGrpcConsumerDetailService() { return new GrpcConsumerDetailServiceImpl(); } } spring.factories — добавление авто-конфигурации org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ru.myapp.security.autoconfigure.ConsumerSecurityAutoConfiguration
Использование сервиса
-
Подключаем стартер, который добавляет в проект сервис для работы с потребителем.
implementation "ru.myapp:myapp-security-starter:v1"
-
Импортируем бин сервиса для работы с потребителем.
-
Через конструктор
private final ConsumerDetailService<GrpcConsumer> consumerDetailService;
-
Через аннотацию@Autowired
@Autowired private ConsumerDetailService<GrpcConsumer> consumerDetailService;
-
Используем методы сервиса для работы с потребителем.
Получаем DTO потребителя из запроса и отправляем в сервис, например:
consumerDetailService.hasRoleOrElseThrow(organisation.getConsumer(), "ROLE_ADMIN");
Но и это еще не все!
Бонус!
Важно отметить, что есть возможность создания более «продвинутых» способов работы с данными потребителя.
-
Перехватчик gRPC.
В реализацию gRPC под Spring встроен механизм добавления перехватчиков. «Из коробки» уже есть несколько реализаций, например, для извлечения информации из JWT-токена.
При необходимости можно разработать свой перехватчик, который, например, извлекает ДТО с данными потребителя и заполняет по ним контекст безопасности Spring. В итоге появляется возможность использовать стандартные аннотации Spring для проверки прав: @Secured, @PreAutirise, @PostAutorise и т.д.
-
Аспекты.
ДТО потребителя однотипна, и при желании можно написать аспекты для работы с данными потребителя. Например, если в сигнатуре метода есть ДТО потребителя, выполнять проверку, логировать и т.д. Или при наличии в сигнатуре метода ДТО потребителя и модели пользователя, заполнять модель пользователя данными из ДТО потребителя. Вариантов много, все зависит от потребностей и бизнес-задач.
Приземляемся, оцениваем обстановку
Представленный выше подход хорошо применим для части сервисов в рамках небольшой системы.
Плюсы:
-
Общий концепт для работы с потребителем во всех сервисах.
-
Простое взаимодействие между сервисами без передачи дополнительной мета-информации, токенов и т. д.
-
Просто писать и поддерживать сложные правила проверки безопасности.
-
Возможность вызова сервиса от лица пользователя или системы.
-
Удобно редактировать одну библиотеку и переиспользовать ее.
Минусы:
-
Вероятность получить ошибку во время генерации потребителя. Например, не задав ему необходимые права или, наоборот, назначив ему дополнительные права.
-
Для критических секций необходимо дополнительно проверять по идентификатору пользователя или систему, вызывая сервисы авторизаций, сессий или пр.
Вместо заключения
Лучшее — это враг хорошего!
Описанный выше способ не является универсальным и подходящим ко всем системам, зато с его помощью можно быстро и просто реализовать сложные кейсы для проверки прав пользователя, и с минимальными усилиями добавить в проект универсальные инструменты для работы с такими кейсами.

ссылка на оригинал статьи https://habr.com/ru/post/667616/
Добавить комментарий