Встроенные в 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, @PreAuthirize, @PostAuthorize и т.д.
Аспекты.
ДТО потребителя однотипна, и при желании можно написать аспекты для работы с данными потребителя. Например, если в сигнатуре метода есть ДТО потребителя, выполнять проверку, логировать и т.д. Или при наличии в сигнатуре метода ДТО потребителя и модели пользователя, заполнять модель пользователя данными из ДТО потребителя. Вариантов много, все зависит от потребностей и бизнес-задач.
Приземляемся, оцениваем обстановку
Представленный выше подход хорошо применим для части сервисов в рамках небольшой системы.
Плюсы:
Общий концепт для работы с потребителем во всех сервисах.
Простое взаимодействие между сервисами без передачи дополнительной мета-информации, токенов и т. д.
Просто писать и поддерживать сложные правила проверки безопасности.
Возможность вызова сервиса от лица пользователя или системы.
Удобно редактировать одну библиотеку и переиспользовать ее.
Минусы:
Вероятность получить ошибку во время генерации потребителя. Например, не задав ему необходимые права или, наоборот, назначив ему дополнительные права.
Для критических секций необходимо дополнительно проверять по идентификатору пользователя или систему, вызывая сервисы авторизаций, сессий или пр.
Вместо заключения
Лучшее — это враг хорошего!
Описанный выше способ не является универсальным и подходящим ко всем системам, зато с его помощью можно быстро и просто реализовать сложные кейсы для проверки прав пользователя, и с минимальными усилиями добавить в проект универсальные инструменты для работы с такими кейсами.
BugM
Вы не Гугл. Вам не нужен grpc.
Экономия от бинарного и довольно сложного внутри протокола в обмен на усложнение всего в вашем проекте не окупится на ваших объемах. HTTP, json, gzip достаточно оптимальны и просты для вашего проекта.
Как нормально сделать аутентификацию на вашем языке и вашем фрейворке можно прочитать в любой инструкции для новичков. Это давно решенная проблема. И это решение достаточно хорошо для вашего проекта.