Как мы реализовали аутентификацию трафика для MSA на базе монолита

Привет, Хабр! Меня зовут Дмитрий Салахутдинов, я принципал инженер в СберМаркете. Занимаюсь развитием Ruby-платформы и масштабированием системы через декомпозицию монолита на сервисы.

В статье хочу поделится опытом внедрения аутентификации на базе монолита. На мой взгляд, это первый необходимый подготовительный шаг к реализации фич в автономных сервисах рядом с монолитной системой. В каком-то смысле, это эволюционный способ перехода из монолитной в микро-сервисную архитектуру.

7b2e31555e8a74f057f4c48c1e341ca1.jpg

Постановка задачи

Задачей было запустить автономный сервис «пользовательских сердечек ❤️», доступный аутентифицированному пользователю мобильного приложения. 

Для аутентификации используется HTTP-заголовок  Authorization, содержащий токен клиентской сессии.

Клиентская витрина СберМаркета на тот момент была реализована в монолитном исполнении (Ruby/Rails, но в рамках этой статьи стек не принципиален). Аутентификация традиционно вплетена в монолит и «размазана» в разных частях проекта.

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

Задача: реализовать автономный сервис сердечек (для понравившихся товаров)

Задача: реализовать автономный сервис сердечек (для понравившихся товаров)

Реализовать бизнес-логику сердечек в отдельном сервисе не представляет особой сложности, если нам удастся понять их принадлежность. Но эта логика аутентификации исторически находится в монолите.

В контексте такой задачи аутентификация — это процесс понимания кто владелец сердечек.

Аутентификация (в контексте задачи) — это процесс понимания кто владелец

Аутентификация (в контексте задачи) — это процесс понимания кто владелец «сердечек».

Проксирование трафика через монолит

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

Пустить трафик через монолит (не подходит)

Пустить трафик через монолит (не подходит)

Хотя рациональное зерно в таком варианте есть. Стоит выделить в процессе обработки запроса этап аутентификации и базироваться на монолите, поскольку логика аутентификации сосредоточена в нем, а моменте отказаться от него невозможно. Так мы приходим к следующему логичному варианту.

Аутентификация «на базе монолита»

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

Схема аутентификации: 1. запрос на аутентификацию в монолит 2. возврат результата. 3. использование результата для обработки исходного запроса в новом сервисе

Схема аутентификации: 1. запрос на аутентификацию в монолит 2. возврат результата. 3. использование результата для обработки исходного запроса в новом сервисе

В схеме можно выделить 3 ключевых этапа:

  1. Аутентифицировать каждый входящий запрос о специальный эндпоинт /authn стандартной логикой монолита.

  2. Результат аутентификации упаковать в специальный HTTP-заголовок ответа X-Auth-Identity.Если значение есть — пользователь известен; значения нет — трафик не аутентифицирован.

  3. Снабдить оригинальный запрос результатом аутентификации, HTTP-заголовком X-Auth-Identity с идентификатором пользователя.

Получим X-Auth-Identity заголовок в сервисе мы поймем, с каким пользователем ассоциировать сердечки.

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

При этом подход формально отвязывает реализацию бизнес-логики в сервисе от аутентификации, предоставляя новому сервису абстрагированный результат.

Берем его в разработку!

Реализация на API-гейтвее

Чтобы схема завелась нужна единая точка входа, централизованный способ управлять трафиком. Эту задачу решает API-гейтвей (про API-gateway есть хорошая статья на Хабре).

В легаси-системах все может быть сложней: со временем количество точек входа разрастается, появляются различные способы приема входящего трафика от разных типов клиентов. Могут использоваться разные доменные имена, разные технологии и т.д. Бессмысленно внедрять аутентификацию, не разобравшись с трафиком.

Собрать все воедино — это отдельная большая история (мы ее пропустим).

Технологический разброс реализации API-гейтвея широк (от Nginx и Envoy-proxy до самописного решения). Мы рассмотрим реализацию на базе Envoy-proxy, расширив его функциональность специфической логикой предобработки запросов с помощью фильтра (встроенного в Envoy механизма кастомизации).

Фильтр может быть исполнен в двух вариантах: скриптом на Lua либо подключаемой сборкой Web-Assembly. У нас в приоритете была скорость внедрения, поэтому выбор пал на Lua.

Ниже представлен пример кода Envoy-фильтра. Суть можно кратко описать одним предложением: «Размениваем входящий HTTP-заголовок Authorization на внутренний X-Auth-Identity с идентификатором пользователя».

function envoy_on_request(request_handle)
  request_handle:headers():remove("X-Auth-Identity")
  
  local token = request_handle:headers():get("Authorization")
  if token == nil then return end

  local cluster_name = "outbound|80||api.monolith.svc.cluster.local"
  local authentication_request = {  
    [":method"] = "POST", [":path"] = "/authn",  
    [":authority"] = cluster_name,  
    ["authorization"] = token  
  }  
  
  local response_headers, response_body = request_handle:httpCall(  
    cluster_name,  
    authentication_request,  
    "",
    100  
  )  
  
  local identity = response_headers["X-Auth-identity"]  
  if identity == nil then return end  
  
  request_handle:headers():remove("Authorization")
  request_handle:headers():add("X-Auth-Identity", identity)
end

 Кратко разберем четыре интересных момента в работе скрипта:

  • Если заголовка Authorization нет — трафик аутентифицировать не надо, он и так анонимный

  • Если значение есть, отправляем его в эндпойнт аутентификации /authn монолита.

  • Если аутентификация удалась в заголовках ответа получаем X-Auth-Identity, содержащий идентификатор пользователя. Если значения нет — аутентификация не прошла (токен не валидный, устаревший и т.д.)

  • Вырезаем из запроса заголовок Authorization, если нам удалось обменять его на X-Auth-Identity

Важно: Не забыть удалять X-Auth-Identity на случай, если кто-то передал его значение извне, решив заполучить чужие сердечки!

Поддержка тестового окружения (стейджинги)

Новая схема требует адаптации тестового окружения, особенно если в нем используется несколько наборов стендов. В таком случае необходимо проводить аутентификацию с использованием определенного инстанса монолита, потому что данные о сессиях пользователей могут разниться от одного стенда к другому. Нам важно дать возможность «собирать» стенд, тестируя функциональность с ожидаемым набором данных, в т.ч. аутентификационных.

Решение простое — ввести для тестовой среды специальный «инфраструктурный заголовок» X-Auth-Namespace, значение которого будет указывать на неймспейс в Kubernetes, куда направлять аутентификационный запрос.

Если заголовок не передан — используем неймспейс дефолтного стейджа. Если передан — аутентификация производится на указанном стенде монолита. Код на API-гейтвее для стейджинг-окружения приобретает вариативность:

 {{ if eq .Values.global.env "prod" -}}  
    local cluster_name = "outbound|80||api.monolith.svc.cluster.local"  
  {{ else -}}  
    local cluster_name = "outbound|80||api..svc.cluster.local" 

    local request_namespace = request_handle:headers():get("X-Auth-Namespace")  
    if request_namespace == nil  
    then  
      namespace = "default-monolith-namespace"  
    else  
      namespace = request_namespace  
    end  
    cluster_name = cluster_name:gsub("", namespace)    
  {{- end }}

Для удобства ручного тестирования можно использовать Chrome-плагин ModHeader, он позволяет снабжать запросы дополнительным заголовком.

Реализация эндпоинта аутентификации

В примере ниже приведена реализация на Ruby. Ничего необычного, кроме того, что это отдельный эндпойнт, даже не рубист легко разберется в сути:

  1. Находим пользователя по токену сессии.

  2. Если аутентификация прошла — возвращаем 200 и uuid пользователя.

  3. Если не прошла — возвращаем 403.

class AuthController < ApplicationController
  def authn
    user = authenticate_user
    if user
      response.headers['X-AUTH-IDENTITY'] = user.uuid 
      render status: 200
    else
      render status: 403
    end
  end

  def authenticate_user
    session = Session.active.find_by(access_token: access_token)
    User.find_by(:id, session.user_id) if session
  end
end

Полезно отдавать разные статусы ответов в зависимости от успешности аутентификации. В моем примере статус ни на что не влияет и на гейтвее никак не учитывается (но мог бы). Но так в будущем проще оформить метрики для мониторинга работы аутентификации, особенно если они идут из коробки.

Важный момент: эндпоинт предназначен для «внутреннего использования», и не должен быть доступен извне, поэтому закрываем к нему доступ. В противном случае, он может выдать нюансы внутренней реализации.

Специфика Ruby/Rails

Уделим немного внимания реализации на Ruby. Если вы не рубист — можете смело пропустить этот блок.

Rails-контроллер, Rails-middleware, Rack.

В исполнении Rails-контроллера выглядит громоздко и неэффективно. Нет нужны проходить весь Rails-стек, чтобы вычитать заголовок, и сходить БД. В качестве оптимизации возможно реализовать логику через Rails-middleware или перенести на уровень Rack. Чем дальше в Rack-стек, тем больше придется делать руками, но работать будет быстрей.

Мы ограничились выносом в Rails-middleware, это позволило максимально срезать стек вызовов без очень сложного рефакторинга кода.

Рефакторинг аутентификации

Большинство монолитов на Rails неявно используют warden — универсальный Rack-based фреймворк аутентификации. Обратимся к двум его важным фичам, позволяющим стройно отрефакторить многогранную логику аутентификации:

  • Стратегии аутентификации: архитектура гема позволяет стройно упорядочивать в цепочки разные способы аутентификации. К примеру, клиента мы можем аутентифицировать по токену мобильного приложения, а если его нет, то через Cookie.

  • Скоупы аутентификации — это некоторая абстракция, которая позволяет задать различные сценарии аутентификации из разных видов цепочек.

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

Больше подробностей по Ruby-части можно найти в докладе на руби-митапе (вот ссылка на обзор в моём телеграм-канале)

Запуск в продакшен

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

  • появляется дополнительная инфраструктурная сложность;

  • будет сопровождаться рефакторингом логики аутентификации в монолите.

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

Этап полноценного тестирования немаловажен! Мне хочется уделить больше внимания техническим деталям плавного выпуска в продакшен, поэтому тестирование мы пропустим.

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

«Тестирование на продакшене»

Звучит странно, но идея очень простая. Включить фичу на API-гейтвее — только при наличие переданного HTTP-заголовка X-AUTH-Enabled: true. При этом даже необязательно иметь ответную часть в монолите (заодно проверим как схема ведет себя, если ответной части нет)

function envoy_on_request(request_handle)
  local enabled = request_handle:headers():get("X-Auth-Enabled")
     if enabled = "true" then
       # принудительное включение
  end
end

Минус: не объясняет, как безопасно выкатить Envoy-фильтр на гейтвее.

Плюс: позволяет обкатать функционал в продакшен-среде без аффекта на реальных пользователей.

«Фича-флаг» на гейтвее

Поддержать плавную выпуск функциональности на гейтвее возможно через имитацию фича-флага в Lua. Это позволит постепенно переключать пользователей на новую схему, предполагающую дополнительный поход в монолит за аутентификацией.

function envoy_on_request(request_handle)
  local number = math.random(1,1000)
  if number < 100 then # включено на 10%
    # включаем всю схему
  end
end

Минус: требуется деплой Envoy-фильтра на любое изменение процента

Плюс: можно плавно подключать пользователей на любой процент

Как расширение первого варианта такой фича-флаг можно реализовать на клиенте, если это будет проще — передавать с клиента X-AUTH-Enabled в определенном проценте пользователей.

Фича флаг в эндпоинте в аутентификации

Идея запустить эндпоинт аутентификации как пустышку (отдает 200 ОК), а дальше постепенно и плавно включать логику аутентификации на определенный процент пользователей.

def authn
    # Flipper.enable_percentage_of_actors :authn, 10
    return [200, {}, ''] unless Flipper.enabled?(:authn)
    
    # тут логика аутентификации
    ...
  end

Плюсы:

  • Удобно и привычно использовать фича-флаги в коде приложения (для Ruby популярный инструмент — Flipper).

  • Гибко настраивается, например для определенного пользователя, или по времени.

  • Динамически управляется (настоящий фича-флаг) из админ-панели.

Минусы:

  • Никак не уберегает от опасности накосячить на гейтвее.

  • Удалять оригинальный заголовок Authorization для этого варианта на API-гейтвее не нужно.

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

В нашем случае пользовались в основном флагами со стороны Ruby-монолита. А изменения на API-гейтвее выкатывали в технологическое окно в ночную смену.

Сохранение совместимости

Финальная схема предполагает получение сервисами результата аутентификации в виде стандартизованного заголовка X-Auth-Identity. Монолит в этом случае не должен быть исключением, и его постепенно стоит привести к такой же схеме: обрабатывать X-Auth-Identity, и не аутентифицировать запрос в случае наличия значения. Это избавит от повторной аутентификации в монолите (которая изначально там реализована), а так же выровняет «клиентскую» часть бизнес-логики монолита с другими сервисами.

Но в процессе плавного выпуска (и для возможности отката на старую схему) все же придется сохранять совместимость: монолит должен продолжать уметь аутентифицировать трафик и «как раньше», и «по-новому».

На времи миграции требуется одновременная поддержка старой и новой схемы аутентификации при обработке запросов в монолите

На времи миграции требуется одновременная поддержка старой и новой схемы аутентификации при обработке запросов в монолите

Мониторинг

Для наблюдения за процессом миграции на новую схему и для дальнейшего обслуживания позаботимся о метриках. Реализация зависит от стека разработки. Даже для Ruby/Rails объем работ варьируется от способа реализации (для Rails-контроллеров есть метрики «из-коробки», а для middleware нужно писать самостоятельно).

Приведу топ-список востребованных метрик, которыми мы пользовались в процессе:

  • Успешность/не успешность аутентификации. Это можно сделать через стандартные метрики web-приложения вашего фреймворка, в которые, скорее всего, уже включено распределение по статусом ответов (200 — аутентификация прошла, 403 — не прошла). Если упал рейт успеха — намудрили с пробросом заголовков.

  • Рейт запросов на эндпоинт аутентификации, гистограмма времени обработки запросов — помогают валидировать плавность раскатки функционала в период запуска, и аномалии в последующей работе (если упал рейт запросов — намудрили с трафиком).

  • Длительность запросов в БД (поиск сессии по токену). Это опционально, но потребуется для дальнейшей эксплуатации (тем более что базой для решения все равно остается монолит).

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

Стандартизация

Решение «на базе монолита» временное. Чтобы в дальнейшем упростить переход на что-то более надежное и системное, важно заранее заложить стандарт, абстрагирующий потребителей результата аутентификации от самой реализации.

Такой «протокол» в перспективе позволит подменить текущий монолитный эндпойнт на сервис аутентификации, не внося изменения в многочисленные сервисы, уже реализованные на базе стандарта.

Переход от монолитной реализации к сервисной упростится за счет стандартизации

Переход от монолитной реализации к сервисной упростится за счет стандартизации

Базовые рекомендации к разработке стандарта:

  1. Нейминг: стандартизировать и зарезервировать http-заголовки, которые будут использоваться «внутри» для обогащения запроса результатами аутентификации. Дать им соответствующие названия, начинающиеся с X- (в соответствие с конвенцией X- означает extended и для специфичных заголовков стоит использовать этот префикс).

  2. Жестко зафиксировать набор заголовков и их значение, к примеру:

    • X-Auth-Identity — сквозной идентификатор пользователя (объекта аутентификации).

    • X-Auth-Type — использованный способ аутентификации (в том числе определяет тип объекта, например, пользователь, или интегрированный с нами партнер).

    •  X-Auth-Namespace — специальный инфраструктурный заголовок для роутинга в нужный экземпляр «монолита» (сервис аутентификации) на стейджинге.

  3. Предусмотреть сквозные идентификаторы для пользователей. В монолите вероятней всего используются числовые идентификаторы, которые генерируются автоматически БД при вставке. Вынос аутентификации — подходящий момент продумать процесс отказа от них в пользу более универсальных. К примеру, перейти на UUID. Это поможет, когда источником данных о пользователя станет другой сервис. Тем не менее, часть бизнес-логики, базирующейся на монолите может по-прежнему оперировать старым идентификатором, если выделить специальный legacy-заголовок под него: X-Legacy-ID: 123. Слово Legacy поможет уберечь потребителей от сильной завязки на старый идентификатор.

Системное решение

Текущее решение позиционирую исключительно как временное. Коснемся вкратце перехода на один из вариантов системного решения — автономный сервис аутентификации, назовем его auth-agent. Именно по такому пути монолитное решение трансформировалось в постоянное в Сбермаркете.

Вариант миграции на сервис аутентификации

Вариант миграции на сервис аутентификации

Хотя тема его разработки — это отдельная большая история, отмечу основные моменты, чтобы проиллюстрировать мысль о пользе унификации из предыдущего раздела:

  • Запускаем отдельный сервис (1), который будет обслуживать эндпоинт аутентификации. Чтобы он смог работать, ему нужны данные о пользователях (и их токенах), которые по началу нужно стримить из монолита, например, при помощи Kafka. На случай отставания данных, монолитный эндпоинт используется в качестве фолбека (3).

  • Дальше переносим в сервис логику «выдачи аутентификационных токенов» (2), в том числе куки. После этой операции сервис полноценно управляет аутентификацией. И фолбек на монолит становится не нужен (3)

Авторизация

В случае с сервисом «сердечек» бизнес-логика простая и не предполагает авторизацию. Но рано или поздно потребуется авторизация, к примеру, для интерфейса администратора или другого случая разделения прав пользователей.

Если в монолитном приложении действует ролевая модель (ассоциированный с пользователем набор ролей), то аналогичное временное решение на базе монолита можно переиспользовать и для передачи данных о ролях пользователя. Вводим дополнительный заголовок X-Auth-Roles, содержащий информацию о наборе ролей пользователя.

Вариант использования схемы для ролевой авторизации (роли пользователя передаются в заголовке)

Вариант использования схемы для ролевой авторизации (роли пользователя передаются в заголовке)

В этом случае сервис на основании списка ролей самостоятельно авторизует пользователя, принимая решения о доступности функциональности для текущего пользователя.

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

Выводы

Аутентификация на базе монолита — это жизнеспособное быстрое решение, позволяющее на время закрыть вопрос с аутентификацией и не блокировать запуск новых автономных сервисов. Я предлагаю его на рассмотрение, если перед вами стоит аналогичная задача — плавно переходить в микро-сервисную архитектуру.

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

Буду рад обсудить решение в комментариях!

Tech-команда СберМаркета ведет соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и на YouTube. А также слушай подкаст «Для tech и этих» от наших it-менеджеров.

Habrahabr.ru прочитано 2723 раза