API-Шлюз или опять тестировать
При разработке микросервисов рано или поздно возникает вопрос о специализированном микросервисе, через который проходят запросы и маршрутизируются в нужный. Это необходимо не только с точки зрения безопасности (в одной точке аудита можно увидеть все нужные события), но так же с точки зрения производительности. Ваш микросервис выполняющий важную функцию наверняка использует массу ресурсов, например подключения к БД — даже если обработка плохого запроса не требует подключения к БД, то она гарантированно ворует ресурс ЦП. Разумеется можно покупать все более производительные сервера, но такой путь ни к чему не приведет.
Очевидно, что делать очередной дубль проекта spring-cloud идея не самая лучшая. Именно поэтому в этой статье автор попытается застрелить сразу несколько зайчиков одним выстрелом, а именно:
Применим теорию систем авторизации на практике, вы же не забыли про статью?
Попытаемся снизить трудозатраты на разработку дополнительного сервиса и увеличения связности, api-шлюз работает сразу со всеми сервисами!
Попытаемся сделать обнаружение ошибок на этапе компиляции, а не запуска.
1 Существующие решения и почему они не подходят для проекта Mireapay
Во-первых, стоит второй раз сказать про https://spring.io/projects/spring-cloud-gateway скорее всего вам нужен именно он и его возможностей вам хватит, можете закрывать статью ;).
Однако у него есть серьезная проблема в виде увеличения трудозатрат, отсутствия необходимых фичей (прозрачная авторизация и валидация входных данных, вернее они есть, но писать надо все руками в специальных фильтрах — удачи!). Для проекта Mireapay важно не увеличивать сложность проекта, иначе его реализация перейдет из трудной в нереализуемую.
Все микросервисы так или иначе общаются друг с другом и передают информацию по REST API. Все эти запросы нужно авторизовывать, а ведь еще нужны клиенты. Для простых проектов, либо если у вас на каждом проекте по разработчику, то это не представляет сложности. Однако если нужно писать много клиентов и кода самого по себе более чем достаточно, что бы в нем потонуть, то необходимо использовать генератор.
Раз уже клиенты генерируются, то почему бы не сделать генерацию gateway и сразу клиентов, которые будут обращаться к бекенду? В качестве бонуса получим еще и генератор клиентов для внутренних взаимодействий между бекендами!
2 Еще немного теории
При реализации Zero-Trust микросервисной архитектуры всегда нужно иметь в виду следующую диаграмму:
Смысл ее в том, что каждое обращение к целевому «Бэкенду» должно проходить процедуру авторизации, вне зависимости от источника запроса. Однако способов реализации есть несколько:
Оригинальный jwt от пользователя проходит по всему стеку;
Jwt пользователя учитывается только на шлюзе, но дальше работает уже авторизация сервиса сервисом;
(Небезопасно) Jwt пользователя проверяется на шлюзе, но дальше все запросы проходят без авторизации. Любой сервис может вызвать любой метод у другого сервиса без возможности потом локализовать утечку.
Разберем только первые два случая, т.к. последний является серьезной угрозой безопасности системы.
2.1 Один jwt, что бы авторизовать их всех
Данный способ подходит для особо параноидальных случаев и наличия бесконечных вычислительных ресурсов. Запрос на каждом сервисе живет днем сурка и постоянно авторизуется и проверяется на возможность выполнения, как если бы сам пользователь его запустил изначально. Данный способ не является каким-то плохим или ненужным, однако необходимо учитывать, что он не защищает самого пользователя от угроз взлома, т.к. его токен мог быть похищен, и следовательно попытка предупредить атаку изнутри системы на саму систему обречена, т.к. взломанный сервис (или снабженный зловредом) сможет спокойно передавать нужные данные и команды.
Хотя такой вариант кажется наиболее безопасным, на деле он таковым не является, так как не спасает от внутренних атак.
2.2 Раньше был Jwt-серый, теперь Jwt-белый
Не только оптимальным с точки зрения производительности, но и безопасности является ситуация, когда на шлюзе пользовательский jwt проверяется, авторизуется согласно политикам безопасности, после чего из токена вытаскиваются необходимые для обработки запроса данные и передаются на бэкенд в виде обогащенного запроса. Авторизация при обращении к бэкенду проверяет не возможность пользователя выполнить запрос, а шлюза — обратиться к методу бэкенда.
В этом случае даже в случае кражи токена пользователя внутренняя атака значительно сокращается в масштабе из-за того, что максимальный вред будет ограничен разрешениями сервиса по выполнению запросов на целевом бэкенде.
3 Вариант API-шлюза с авторизацией
Следует сразу обратить внимание, что в проект Mireapay разрабатывается исходя из гео-распределения серверов по большим расстояниям, разные группы могут администрировать узлы платежной системы и авторизация является краеугольным камнем всей системы. Очевидно, что пользователи должны получать токены авторизации в одной точке, обладающей максимальной защитой и аудиторским контролем. Но вот обращение сервисов узлов к этой точке для получения их токенов доступа — является источником серьезных проблем не только производительности, но и стабильности системы. Brain split никто не отменял, для пользователя оба сервера могут выглядеть доступными, но между ними почему-то нет связи.
Поэтому для авторизации пользователей и сервисов используются разные реалмы авторизации, что позволит даже в случае кражи важных данных, позволяющих выполнить запрос внутри системы «как бы от имени пользователя» — пострадает только один узел. Внутренняя авторизация сервисов, недоступная извне — только повышает безопасность всей системы. Т.к. уменьшает масштаб угроз безопасности. Позволяет делегировать обеспечение безопасности нижестоящим командам.
Теперь можно приступить к описанию работы API-Шлюза в проекте Mireapay.
Идеальным вариантом, позволяющим максимально снизить трудозатраты является прямая генерация нужных классов из интерфейсов. Например, представим себе простой сервис по получению информации об аккаунте:
@RestController
@Tag(name = "Account api", description = "Account REST API endpoints")
@RequestMapping("/rest/v1/account")
public interface AccountV1RestAPI {
@GetMapping
@Operation(description = "Retrieve basic account info description")
@PreAuthorize("@function.anyOf(" +
"@accountAccess.hasRole('ROLE_USER'), " +
"@srvAccess.hasPermission('SRV_PERM_ACCOUNT_GET')" +
")")
Mono info(
@ApiClientHeader @AccountId UUID accountId
);
}
Пояснения к коду
ApiClientHeader — аннотация, позволяющая генератору отправить параметр в виде заголовка бэкенду;
AccountId — вытаскивает значение параметра из заголовка или jwt, в зависимости от режима работы;
PreAuthorize — реактивная авторизация запроса на основе разрешений и ролей:
function.anyOf — вызов метода бина, который объединит результаты выполнения параметров, необходимо по причине того, что SpEL не умеет работать с реактивными методами, хотя внешний слушатель может получить результат Mono
, но работает только если выражение приводится к этому типу; accountAccess.hasRole — на шлюзе проверит, что у пользователя есть роль;
srvAccess.hasPermission — на бэкенде проверит, что вызывающий сервис имеет нужное разрешение.
Т.к. у нас имеется всего 2 режима работы — шлюз и бэкенд, то можно даже отказаться от function.anyOf, которая выполняет логическое или, и заменить ее на свитч в завимости от режима работы — мы же его знаем изначально. В данном случае не важно, т.к. токены доступа пользователя и сервиса используют разные типы бинов (Principal).
Т.к. токены у пользователей и сервисов разные, и создаются разными реалмами, то для них специально сделаны отдельные бины: пользователь, сервис. Таким образом мы всегда знаем чей именно jwt обрабатываем. На самом деле все гораздо проще. На бэкенде мы никогда не получим пользовательский jwt, т.к. он будет игнорироваться, а на шлюзе — бэкендовский, т.к. ожидать будем именно пользовательский из cookie.
Очевидно, что из кода интерфейса, представленного выше, можно не только гарантировать, что при компиляции все ошибки будут обнаружены (например в следствие обновления версии библиотеки), но так же можно сгенерировать код шлюза и клиента.
Код шлюза для такого сервиса будет простым:
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
@RestController
@AllArgsConstructor
public class AccountV1RestAPIGateway implements AccountV1RestAPI {
AccountV1RestAPIClient client;
@Override
public Mono info(UUID accountId) {
return client.info(accountId);
}
}
Спринг по умолчанию смотрит аннотации интерфейсов, которые реализует наш контроллер и использует их так, будто они написаны в этом классе. С этим подходом есть свои проблемы, например аннотацию PreAuthorize нельзя переопределить, но это малые недостатки.
Шлюз использует сгенерированный клиент, который выглядит таким образом:
public class WebClientAccountV1RestAPIClient implements AccountV1RestAPIClient {
final private WebClient webClient;
public WebClientAccountV1RestAPIClient(WebClient webClient) {
this.webClient = webClient;
}
@Override
public Mono info(UUID accountId) {
return webClient.get()
.uri("/rest/v1/account")
.header("X-ACCOUNT-ID", String.valueOf(accountId))
.retrieve()
.bodyToMono(AccountDto.class);
}
}
Как было сказано выше, аннотация ApiClientHeader определила, что параметр accountId будет передан бэкенду в качестве заголовка с названием X-ACCOUNT-ID. Имя генерируется автоматически, но его можно задавать.
Теперь, для корректной работы шлюза осталось только внедрять заголовок авторизации в каждый запрос, это делается тут. А если точнее, то все генерируемые клиенты пытаются получить бин с типом WebClientFiltersCustomizer (при этом можно на каждый клиент сделать свою отдельную авторизацию, но тогда нужно будет создавать бины с нужным именем под каждый клиент), который добавит фильтры в список фильтров WebClient при его создании. Инъекция заголовка с токеном доступа тут.
4 А что на бэкенде?
Т.к. мы унифицировали генерацией клиентов работу авторизации, то вполне логичным будет унификация процесса авторизации на бэкенде, желательно в форме «подключил и забыл».
Для этого в проекте Mireapay созданы 2 библиотеки:
implementation "com.lastrix.mps.hq.api.rest:base-backend-security:${version_mps_hq_rest_api}"
testImplementation "com.lastrix.mps.hq.api.rest:base-backend-security-test:${version_mps_hq_rest_api}"
Первая позволит нам сразу настроить нужные резолверы аргументов и настроить spring-security. А вторая прозрачно запускать тесты для интеграционного тестирования.
Теперь все что остается в проекте — это реализовать интерфейс:
@RestController
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class AccountV1RestAPIController implements AccountV1RestAPI {
AccountService service;
AccountMapper mapper;
@Override
public Mono info(UUID accountId) {
return service.find(accountId).map(mapper::toDto);
}
}
Следует обратить внимание, что аннотация RestController обязательна, иначе бин не будет создан. Такая же аннотация на интерфейсе нужна для корректной работы плагина-генератора.
Теперь все что осталось — это написать тест успешной авторизации:
@Test
void test_ok() {
var accountId = Stubs.createFreshAccount(client);
var account = fetchFor(accountId, Set.of("SRV_PERM_ACCOUNT_GET"))
.expectStatus().isOk()
.expectBody(AccountDto.class)
.returnResult()
.getResponseBody();
assertNotNull(account);
assertEquals(accountId, account.getId());
assertMessageQueuesAreEmpty();
}
И вариант запрещенного доступа:
@Test
void test_forbidden() {
var accountId = Stubs.createFreshAccount(client);
fetchFor(accountId, Set.of())
.expectStatus().isForbidden();
assertMessageQueuesAreEmpty();
}
Т.к. тест — интеграционный, то Stubs.createFreshAccount (client) создает аккаунт в БД, как работает интеграционное тестирование БД описано в этой статье.
Для работодателей
Работодатель, поспеши завести себе Java-кота в команду! Мышей не ловит, но зато пишет Java-код, а еще проектирует немножко.
https://hh.ru/resume/b33504daff020c31070039ed1f77794a774336
И не забываем, дорогие мои HRы: