SingleA: доменный SSO своими руками
TL;DR
SingleA — это набор Symfony бандлов, которые позволяют развернуть свой PHP’шный SSO, реализующий фреймворк SingleAuth. Тот, в свою очередь, позволяет пользователям веб-приложений, живущих на общем домене (2 уровня и выше) перестать повторно ходить на SSO после того, как они один раз уже залогинились (даже ради простого редиректа).
Всё это призвано кардинально изменить подход к разработке проектов на базе SOA и микросервисов, упростив получение пользовательских данных на стороне приложений без утомительных интеграций.
Пара disclaimer’ов.
Я не профессионал в академической речи, и местами данная статья может уходить в storytelling. Если вам хочется более-менее сухой информации, вы можете обратиться к официальной проекта (она написана тоже мной, но там я честно старался обойтись без лишнего словоблудства).
Недавно в Telegram-канале Beer::PHP вышла заметка о шифровании. И так получилось, что в комментариях к ней один из членов сообщества разошёлся сокрушительными комментариями о том, что автор местами “сжал тему до размеров монетки”.
Эта статья не раскрывает ни одной криптографической темы. Я бы даже не взялся сходу сказать, какую тему она раскрывает в достаточной мере. Она больше выступает в качестве “неуглублённого” обзора проекта SingleA и возбудителя интереса к тем технологиям, которые будут упомянуты в ходе этого обзора.
В этой статье я постараюсь рассказать о проекте SingleA, его особенностях, кому и чем он может быть полезен, а также про подходы, использованные в процессе работы над проектом. Начать хотелось бы с истории того, как он в принципе появился на свет.
Точка невозврата
Пару лет назад я столкнулся с очередной задачей по интеграции веб-приложения с корпоративной SSO’шкой. Привычное дело, но мне оно показалось интересным, потому что front и back приложения, как оно нынче водится, были разделены и жили на разных доменах. Единого подхода в компании ещё не было, и я позволил себе поиграть в изобретателя велосипеда.
Многие веб-приложения, с которыми мне доводилось иметь дело, работали следующим образом. Ты заходишь на них не залогиненным, идёшь на некий централизованный сервис аутентификации SSO, логинишься. Делаешь в приложении всё, что тебе нужно, уходишь. Потом заходишь на соседнее веб-приложение, живущее на “братском” домене, но там ты не залогинен. Тебя направляют на тот же SSO, через который ты проходишь транзитом (само собой), потому что ты уже залогинился и сессия жива. В результате пары редиректов ты снова в приложении и уже залогинен, супер. Это можно условно изобразить следующей диаграммой последовательности:
Через обозначенные на диаграмме POST-запросы SSO передаёт в приложение данные пользователя. Это не стандарт или жёсткое правило, но на своём пути я встречал только такой подход. В этом POST-запросе таится огромный подводный камень, как вы увидите в одном из примеров ниже.
Вопрос: если я уже залогинился на SSO, зачем меня туда посылать, даже ради простого редиректа обратно? С инженерной точки зрения, конечно, я знал ответ, но мой “внутренний пользователь” был озадачен. Когда же я столкнулся с этой ситуацией в работе, я разглядел в ней проблему, которая коснулась меня уже как инженера.
Дело в том, что залогинившись на условном front.domain.org через sso.domain.org, я не могу просто взять и начать слать запросы на api.domain.org, потому что последний обо мне ничего не знает. Тут впору вспомнить про JWT, но встаёт вопрос, кто должен его генерировать. Не все SSO это умеют, а генерация JWT на стороне front.domain.org не всегда является простым делом: API’шек может быть много, свойств пользователя тоже, а вдруг там конфиденциальные данные… Похоже на источник головной боли. Но обозначенный выше вопрос никак не выходил у меня из головы, и я решил поискать на него ответ, взглянув на проблему с другой стороны. Что изменится, если позволить следующее допущение: все веб-приложения, завязанные на один SSO (да и сам SSO), работают с общим доменом (как domain.org из примера выше). Очевидно, что в этом случае они будут иметь доступ к кукам, выставленным на этот домен.
С этой мысли началась работа над проектом, сменившим несколько имён и архитектур и ставшим “швейцарским ножом” при разработке проектов на базе сервис-ориентированной архитектуры или микросервисов. Но в начале была лишь абсолютная уверенность, что это единственное допущение про домен способно дать “зелёный свет” в реализации инструмента, который качественно изменит DX (developer experience) для меня и моей команды. Оставалось только собраться с мыслями. (И, как выяснилось впоследствии, с силами.)
Эволюция проекта
Здесь я несколько забегу вперёд и расскажу о том, как менялся облик проекта, не вдаваясь в технические подробности.
Первая версия
Первый виток истории проекта можно назвать классическим: обычное приложение на базе Symfony Framework. Из необычного были только попытки осмыслить подход Package by Feature, о котором достаточно хорошо позже рассказал Валентин Удальцов на митапе в Нижнем Новгороде. По замыслу, пользователи должны были ходить в это приложение, выступающее в роли SSO, проходить аутентификацию, а кука сессии ставилась бы не на домен данного приложения, а на общий домен. Затем на стороне клиентского веб-приложения происходило бы следующее: используя данную куку, выполнялся бы запрос (под капотом) к SSO на генерацию токена пользователя (JWT), который добавлялся бы в исходный запрос. Если быть точным, это делало не само клиентское приложение, а специальный клиент к SSO, представляющий собой скрипт на Lua для веб-сервера nginx, обрабатывающий запрос до передачи его в клиентское приложение. Благодаря такому подходу получилось избежать утомительной процедуры интеграции на уровне приложения. Об этом Lua-скрипте я расскажу ближе к концу. Здесь лишь отмечу, что это, пожалуй, единственная часть, которая не претерпела значительных изменений с начала работы над проектом.
Справедливым будет сказать, что утверждение об отсутствии “утомительной процедуры интеграции” — предельно субъективно и многими может быть оспорено. Читайте дальше, чтобы составить своё собственное мнение.
Сегодня первая версия не вызывает ничего, кроме сожаления. Но тогда осознание ошибок, заложенных в подходе с поддельными запросами пользователя, было ещё впереди. Да и неумелые заигрывания с Package by Feature ничего, кроме сумбура в коде, не породили.
Feature-based архитектура и появление Контрактов
Спустя несколько месяцев, я вернулся к проекту. Так вышло, что я уже не работал на прежнем месте, но мне предстояло выбрать тему для диплома в ВУЗе. К тому времени я успел осознать ряд своих ошибок и решил провести время с пользой: и диплом написать, и довести приложение до ума.
На данном этапе, как мне кажется, я смог довести архитектуру, ориентированную на “фичи”, до более-менее зрелого вида. Но что гораздо важнее, на этом этапе была сформирована концепция Контрактов, которая впоследствии обернётся набором пакетов интерфейсов SingleA Contracts, напоминающих компонент Symfony Contracts. Благодаря контрактам стало возможным без лишних усилий заменять реализацию тех или иных частей (компонентов) сервиса. В основном, код тех компонентов сервиса, который был описан в получившихся интерфейсах, был вынесен в отдельные Symfony бандлы: что-то отвечало за генерацию JWT, что-то за хранение данных пользователей и данных, относящихся к клиентским приложениям (их конфигурации на стороне SSO, о которых я не раз скажу дальше).
Диплом был написан и успешно защищён, настал период заслуженного отдыха. Я смотрел на проект, и мне хотелось донести его до более широких масс, чем дипломная комиссия и группа сокурсников. Но он для этого совершенно не годился.
Это было самостоятельное приложение, корректное развёртывание которого — тот ещё квест, пусть и снабжённый документацией, во всём говорящий о корпоративной природе приложения. Такой подход может быть удобен внутри организации, но если ты хочешь нести проект в мир open-source’а, это не лучший вариант.
Реализация генератора JWT не позволяла создавать токены с поддержкой шифрования (JWE). Библиотека с поддержкой JWT существовала, но нужно было побороть нежелание переписывать большой пласт кода.
Манипуляции с сессией пользователя казались откровенно дурно пахнущей практикой, но толковая замена этому решению никак не приходила на ум.
Этих причин хватило, чтобы провести последующий месяц в крепких раздумьях и планировании дальнейшей работы.
Symfony Bundle
Затем последовал самый сложный и, в то же время, самый многообещающий этап. Пришло окончательное осознание, что в мире open-source’а SingleA сможет выжить только как бандл для приложения Symfony, расширяющий его функциональность, а не как самостоятельное приложение. Это решение обернулось масштабным рефакторингом, в рамках которого были введены ключевые концепции проекта, навеянные смежными технологиями; например, пользовательские Ticket’ы многим могут отдалённо напомнить похожий механизм в Kerberos. Кроме того, был изменён способ хранения данных пользователей: вместо того, чтобы самостоятельно заботится об их хранении в отдельном хранилище, данные были перенесены в кэш приложения. Чтобы понять (и принять) это решение, достаточно осознать, что сервис SSO не должен выступать постоянным хранилищем пользовательских данных, а должен лишь быть “кэширующим слоем”. Компонент Symfony Cache позволяет использовать разные хранилища, что даёт необходимую гибкость в настройке конечного сервиса SSO.
И всё бы хорошо, но вопрос с JWT с поддержкой шифрования всё ещё не был решён, и к тому же меня уже буравил червь сомнения относительно вопросов надёжности SingleA в условиях “кровавого enterprise’а” и более-менее серьёзных нагрузок. Я решил, что лучшее, что можно сделать в такой ситуации — это обратиться к опыту старших товарищей, и отправился на встречу с коллегой из CDNnow. Уж кто, если не они, сможет критически отнестись к идее сервиса SSO, выступающего единой точкой отказа, да ещё и собранного на PHP. И я ни разу не пожалел о той встрече. (Дима, спасибо огромное!)
Качественный переход
На данном (пока финальном) этапе произошли следующие изменения.
Объединение разрозненных бандлов в монорепозиторий.
Symfony 6 и PHP 8.1 (хотя вот это “.1”, признаться, не входило в мои планы и пришло уже на финальной стадии — “спасибо” Николасу и co. за нетипичный для Symfony Core-team шаг по увеличению минимальной версии PHP в требованиях минорного релиза).
Переход от библиотеке lcobucci/jwt Луиса Кобуччи к web-token/jwt-framework Флорента Морселли (aka Spomky), обеспечивающей поддержку JWE. Забегая вперёд, хочу сказать, что восхищаюсь Флорентом и бесконечно благодарен ему за вклад, который он вносит в дело open-source’а и развитие JOSE в частности.
Был переработан компонент безопасности — механизм цифровой подписи запросов. Изначально цифровая подпись распространялась только на адрес, куда нужно было возвращать пользователя после успешной аутентификации. Она никак не была привязана ко времени запроса, что заслуженно было подвергнуто критике коллегой из CDNnow, ведь в таком случае можно уложить сервис валидными запросами, посылаемыми до бесконечности.
Теперь механизм цифровых подписей используется для всех запросов к SSO и привязан ко времени отправки запроса. Дальше расскажу подробнее.Данные пользователей и данные, относящиеся к клиентским приложениям, теперь хранятся на стороне SSO в зашифрованном виде, чтобы затруднить их компрометацию со стороны недобросовестных администраторов системы. Теме “тотального шифрования” ниже будет посвящён отдельный раздел. Здесь лишь скажу, что это был важный шаг на пути борьбы с собственной ленью, которая долгое время говорила “и так сойдёт”.
Защита данных пользователей и клиентов SSO — не блажь и признак паранойи, это обязанность любого, кто не хочет попасть в новостные сводки (а то и на скамью подсудимых).
Наверное, было что-то ещё, но сейчас уже и не вспомнить. На текущий момент за спиной 4 этапа эволюции SingleA, и у меня есть ощущение, что я начал понимать Intel с их стратегией разработки микропроцессоров Tick-tock. Сначала ты рушишь “барьеры прошлого”, чтобы увидеть доступные решения (и проблемы, которых раньше не замечал), а потом, осознав, что пути назад нет, реализуешь их.
Одним из важнейших продуктов эволюции проекта стало выделение фреймворка для аутентификации SIngleAuth (впоследствии давшего имя и всему проекту).
SingleAuth — фреймворк для аутентификации
Я могу ошибаться в терминологии, но протоколом называть SingleAuth было бы ошибкой, потому что он не накладывает никаких требований на реализацию, кроме требования к HTTP-методу (POST) и формату обмена данными (JSON) при регистрации клиентских приложений на стороне сервиса SSO, а также ключевой “фишки” — требования устанавливать куку с “сессией” пользователя в ответах на запросы к системе аутентификации — login и logout. Данная “сессия” именуется в терминах проекта ticket’ом, как было упомянуто выше, и не имеет ничего общего с PHP сессией.
В SingleA функция выхода из системы (logout) отнесена к системе аутентификации просто как обратная по отношению к входу в систему (login). Я понимаю, что это может сбивать с толку, и заранее прошу прощения, если вам это кажется странным.
С более полным описанием фреймворка вы можете ознакомиться в документации проекта (на разные разделы которой я буду ссылать по тексту не раз). Здесь я подсвечу только ключевые моменты.
Реализация сервиса SSO на базе SingleAuth должна включать методы регистрации клиентского приложения (клиента), login’а и logout’а пользователя, валидации его сессии и генерации токена. Под токеном понимается строка, позволяющая на стороне клиента преобразовать её в данные пользователя любым доступным способом. Например, токеном может быть JWT, в котором закодированы все эти данные, а может быть уникальный ключ, с помощью которого клиент извлечёт все нужные данные из некоего хранилища, в которое SSO предварительно эти данные положит — это отдаётся на откуп реализации.
Отдельное место в фреймворке отведено роли “фичей”. Feature — это функциональная возможность сервиса, обладающая собственной конфигурацией, задаваемой для каждого клиента при регистрации. SingleAuth говорит только о 3 фичах: системе аутентификации, валидации сессии пользователя и генерации токена пользователя, — но оставляет за реализацией право на расширение этого списка (включая предоставление возможности добавлять кастомные фичи). Как будет показано дальше, в SingleA всё именно так и сделано.
Ticket — уникальный идентификатор “сессии” пользователя (в понимании SingleAuth), позволяющий осуществлять взаимодействие между сервисом SSO и клиентом без непосредственного участия пользователя. Передаваться от SSO к пользователю ticket должен посредством куки, выставленной на общий для всех “заинтересованных” веб-приложений домен 2 (или более верхнего) уровня.
Регистрация клиента должна выполняться через POST-запрос с передачей в формате JSON необходимых для регистрации параметров (зависящих от реализации). Ответ должен быть представлен в формате JSON и содержать всю значимую информацию в теле ответа. Каждый клиент должен иметь уникальные идентификатор и секрет, которые затем должны указываться во всех запросах к SSO (пользовательских запросах на login/logout и внутренних запросах от клиента на валидацию сессии пользователя и генерацию его токена).
Остаётся отметить, что SingleAuth был спроектирован под влиянием таких протоколов, как Kerberos, OAuth 2.0 и OpenID Connect, и в особенности CAS.
Всё вышесказанное было долгой, но необходимой преамбулой к основной части повествования — описанию набора бандлов для Symfony, собранному в проект SinlgeA и позволяющему реализовать сервис Single Sign-On на базе фреймворка для аутентификации SingleAuth.
SingleA: конструктор SSO на базе PHP + Symfony
Слово “конструктор” призвано подчеркнуть тот факт, что готовая SSO получится только после сложения воедино необходимых компонентов. Ядром проекта является одноимённый бандл — nbgrp/singlea-bundle, который, используя систему контрактов, выступает в роли “оркестратора” фичей, обрабатывая входящие запросы.
Чтобы не заниматься пересказом документации проекта, расскажу о SingleA своими словами. Начнём с обзора его возможностей.
Концептуальный взгляд
Опираясь на систему контрактов, SingleA позволяет осуществить следующие действия:
Регистрация клиентов. Клиентские приложения можно регистрировать, передавая в запросе параметры для конфигурирования фич, нужных данному клиенту. По умолчанию, все фичи SingleA считаются необязательными для конфигурирования, но позже будет показано, как настроить сервис аутентификации с обязательными фичами. Важно помнить, если какая-то фича не была сконфигурирована при регистрации клиента, впоследствии он не сможет ей пользоваться; например, не задав параметры для генерации токена пользователя, клиент не сможет делать запросы на их получение (получит в ответ ошибку).
В ответ на запрос регистрации клиента возвращается его идентификатор и секрет, а также выходные параметры некоторых фичей (если нужно). Например, ключ для проверки подписи в JWT на стороне клиента (я скажу о нём подробнее ниже).
Секрет в данном случае играет особую роль. Дело в том, что конфигурации фичей, используемых клиентом, хранятся на стороне SingleA в зашифрованном виде, и секрет — один из двух компонентов шифрования, известный только клиенту. (Вторым компонентом являются ротируемые ключи на стороне сервиса.)Методы SinlgeAuth. SingleA позволяет выполнять основные действия, указанные во фреймворке SingleAuth: проводить аутентификацию и завершение сессии пользователя, валидировать сессию пользователя и генерировать токен пользователя. Предполагается, что первые 2 запроса выполняет пользователь (браузерные запросы), а остальные являются внутренними запросами от клиента к SSO и выполняются на самом деле модулем/скриптом, именуемым ниже “клиентом SingleA для nginx”, о котором подробнее будет рассказано ниже. (Вы, конечно, можете реализовать и использовать собственный клиент, который будет делать то же самое, что и мой Lua’шный.)
Атрибуты пользователя. После успешной аутентификации пользователя, для него собирается специальный набор параметров, называемый в терминах SingleA атрибутами пользователя (User Attributes). Этот набор хранится в кэше, ключ к элементу которого составлен из уникального ticket’а пользователя и имени firewall’а, через который пользователь был залогинен. Про этот firewall и его роль в работе всего SSO я скажу в следующем пункте, а здесь лишь добавлю 2 момента. Во-первых, данные пользователя, как и конфигурации фич клиента, хранятся в зашифрованном виде, где ticket выступает одним из компонентов шифрования. Во-вторых, элемент кэша, куда записывается набор пользовательских атрибутов, ограничен по времени жизни и будет удалён по истечении заданного срока.
Realm. В терминах SingleA, Realm — это имя firewall’а, обрабатывающего запрос. Система realm’ов (вернее, механизм фиксации сессии в Symfony) позволяет одному пользователю быть залогиненым через SSO сразу в нескольких независимых firewall’ах, и, что ещё важнее, с помощью разных User Provider’ов. Другими словами, это позволяет одному пользователю использовать разные Identity Provider’ы при аутентификации в разных приложениях, но через один и тот же SSO. (Пример из жизни с использованием нескольких IdP будет приведён ниже.)
ЭЦП запросов. Рассказывая про эволюцию проекта, я уже упоминал механизм цифровой подписи запросов. Он предполагает, что при регистрации клиента будут переданы открытый ключ и алгоритм хэширования, с помощью которых впоследствии нужно будет верифицировать подпись GET-параметров запроса, пришедшего на SSO. В числе прочего GET-параметры должны включать время отправки запроса, которое также защищено подписью, что гарантирует ограниченность срока годности запроса.
Функции ExpressionLanguage. Также стоит отметить, что забота о безопасности при обработке запросов — дело добровольное. Проверка наличия во внутренних запросах ticket’а и валидность цифровой подписи (для тех клиентов, кто активизировал данную фичу) — результат использования специальных функций, относящихся к компоненту Symfony ExpressionLanguage. Сюда же можно добавить, что SingleA предоставляет ещё несколько функций: для ограничения клиентских запросов по IP или подсети (предполагается использовать эту функцию для внутренних запросов на валидацию сессии пользователя и генерацию токена), а также для ограничения запросов на регистрацию клиентов по IP или подсети, или с помощью специального регистрационного ticket’а.
Подробности данного механизма можно найти в документации проекта.Наполнение payload’а из внешнего источника. Ещё одной фичей, дополняющей фреймворк SingleAuth, стал механизм Payload Fetcher.
Зачем он нужен? Например, вам может потребоваться получить в составе токена пользователя его роли в клиентском приложении. Но SSO ничего не знает (и знать не может) о пользовательских ролях на стороне клиента. Но он может выполнить дополнительный HTTP-запрос на заранее заданный веб-сервис, передав туда набор известных ему атрибутов пользователя (а вернее только те из них, названия которых были указаны при регистрации клиента в соответствующем параметре). Предполагается, что на стороне веб-сервиса, куда уйдёт запрос, знают, как преобразовать эти данные в роли (или любую другую необходимую информацию). Полученные в ответ данные дописываются в состав payload’а (откуда и название фичи).Пользовательские РНР-сессии и сессии SingleA. Проблема в том, что PHP и Symfony так устроены, что если вы будете каждые 5 минут обновлять страницу вашего приложения, по умолчанию ваша сессия никогда не протухнет (потому что срок её жизни будет постоянно увеличиваться). Для многих корпоративных приложений (с которых и началась история проекта SingleA) это плохой вариант. Они хотят, чтобы пользователи повторно проходили процедуру аутентификации каждые N часов. В основном это связано с регламентом безопасности. Ради соблюдения этого условия в SingleA реализовано разделение PHP сессии пользователя и “сессии” с точки зрения SingleA, которая длится столько, сколько существуют атрибуты пользователя в кэше. Жизнь элемента кэша фиксирована и истекает спустя заданный промежуток времени. Но если вам нужно сохранить поведение по умолчанию, при котором жизнь сессии пользователя продлевается при контактах с сервисом, этого можно достичь благодаря механизму Sticky Sessions, который будет пересохранять атрибуты снова и снова при выполнении пользователем аутентификации (на других запросах этого происходить не будет, как и не будет происходить пересохранения атрибутов для уже залогиненного пользователя с отключённым механизмом Sticky Sessions). При пересохранении атрибутов пользователя, их срок жизни будет продлеваться.
Symfony Bundle, а не приложение. Благодаря использованию SingleA как бандла, а не самостоятельного приложения, он может быть встроен в существующие решения в качестве реализации сервиса аутентификации. Контракты SingleA позволяют достаточно просто заменять базовые реализации тех или иных фич и разрабатывать собственные решения в соответствии со следующим набором нехитрых правил:
Создать отдельный интерфейс конфигурации фичи, унаследованный от общего интерфейса для всех фич SingleA\Contracts\FeatureConfig\FeatureConfigInterface (который содержит методы сериализации/десериализации). Это нужно сделать, даже если ваш интерфейс не будет содержать методов (что довольно странно, но всё бывает).
Создать фабрику конфигов вашей фичи, реализовав в ней общий интерфейс для всех фабрик конфигов фичей — SingleA\Contracts\FeatureConfig\FeatureConfigFactoryInterface. Фабрика обрабатывает данные из запроса на регистрацию клиента и генерирует экземпляр конфига фичи, или возвращает возникшие ошибки.
Следующий пункт зависит от того, как вы храните конфиги фич клиентов. Здесь будет приведён пример для бандла nbgrp/singlea-redis-bundle, входящего в состав проекта. Этот бандл предоставляет механизм хранения конфигов и метаданных клиентов (включающих время последнего обращения к SSO с идентификатором клиента) в Redis’е с помощью ещё одного удобного бандла — SncRedisBundle.В контейнере зависимостей вам нужно будет настроить сервис маршализации вашего конфига (пример конфигурации сервиса можно найти в описании бандла nbgrp/singlea-redis-bundle). Идентификатор данного сервиса нужно будет указать в конфигурации бандла, в параметре singlea_redis.config_managers (что также показано в описании). Там же можно задать, является ли та или иная фича обязательной при регистрации клиента.
Пожалуй, с обзором SingleA можно закончить и рассмотреть несколько вопросов, связанных с безопасностью и надёжностью собранного с его помощью сервиса аутентификации.
Хранение данных пользователя
Как было сказано выше, атрибуты пользователя хранятся в кэше. Используется для этого компонент Symfony Cache. С первого дня работы над SingleA была необходимость дать администратору SSO возможность принудительно разлогинить пользователя. К сегодняшнему дню эта опция реализована через элемент кэша, содержащий пользовательские атрибуты, который тегируется идентификатором пользователя. Инвалидация кэша по идентификатору пользователя приводит к удалению его атрибутов из кэша, и “сессия” пользователя считается завершённой. Но вынужденным следствием этого решения является требование использовать для кэш пула адаптер, поддерживающий работу с тегами, то есть реализующий интерфейс Symfony\Contracts\Cache\TagAwareCacheInterface.
Там, где необходимо быстрое хранилище типа ключ-значение, я предпочитаю использовать Redis. И для хранения тегированного кэша он подходит как нельзя лучше, если не считать омрачающего всё обстоятельства: записи тегов, содержащих ссылки на элементы кэша, живут вечно и не очищаются от элементов, удалённых по истечении срока жизни. Этой теме даже был посвящён один из вопросов в дискуссиях Symfony, но желаемой реакции от Symfony Core team на него уже не последует. Остаётся только собраться с силами и запилить решение своими руками.
Несмотря на трудности с тегами и Redis’ом, выбор кэша как места для временного хранения атрибутов пользователя остаётся дешёвым и в то же время практичным решением. Оно позволяет использовать собственный адаптер, если стандартные вас не устраивают по каким-то причинам. В случае таких хранилищ как Redis, можно гарантировать, что данные не задержатся в хранилище дольше, чем нужно, а при необходимости можно ограничить объём занимаемой памяти с вытеснением лишних значений по той или иной стратегии.
Выше я уже говорил, что ключ кэша состоит из ticket’а пользователя и realm’а. Это позволяет иметь независимые наборы атрибутов пользователя, составленных при успешной аутентификации через разные firewall’ы, с использованием разных user provider’ов. Но это же приводит к тому, что эти независимые наборы хранятся в разных элементах кэша и имеют разные сроки жизни. Следствием может стать ситуация, когда в рамках одного realm’а сессия пользователя будет считаться оконченной (атрибуты были удалены из кэша), а в рамках другого — нет. Кроме того, для каждого realm’а можно сконфигурировать кэш пул индивидуально, задав своё время хранения элементов кэша.
Удаление данных пользователей по окончании срока жизни — это лишь полдела. Следующая цель — обеспечить их конфиденциальность на этапе хранения в кэше, чтобы любопытный администратор не мог “случайно” скомпрометировать их.
Тотальное шифрование
Атрибуты пользователя, как и конфиги фичей клиентов, шифруются силами библиотеки Sodium. Для шифрования используются, с одной стороны, хранящиеся на стороне SSO ключи (в том смысле, что они указываются в параметрах конфигурации бандла SingleA), а с другой стороны, ticket пользователя или секрет клиента, передаваемые в запросе. Если злоумышленник сможет добыть зашифрованные значения атрибутов или конфигов, ему ещё предстоит постараться добыть ключи и ticket/секрет. Я не утверждаю, что это остановит злоумышленника или создаст непреодолимое препятствие на его пути. Но то, что это создаст определённые сложности и исключит беспрепятственный доступ к конфиденциальной информации — это точно. При грамотном подходе к безопасности этого может хватить, чтобы обеспечить надёжное хранение данных пользователей и не допустить утечку конфигов фич.
Ключи, о которых идёт речь, задаются в виде массива, что позволяет периодически выполнять их ротацию. Первый из ключей используется для шифрования данных, а остальные, последовательно, для расшифровки. Логика ротации описана в документации проекта, здесь просто скажу, что и для ключей атрибутов пользователя, и для ключей конфигов фич клиентов можно выбрать свой период ротации.
Слепая зона
В предыдущем абзаце я дал ссылку на раздел документации, имевший в начальной версии название “Ахиллесова пята безопасности SingleA”. Позже я сместил акцент с описываемой в нём проблемы на сильные стороны SingleA, подчёркивающие его надёжность, но сама проблема от этого никуда не делась.
Даже “обмазавшись” с ног до головы шифрованием и цифровыми подписями, мы не можем исключить атаки типа Man-in-the-middle на этапе регистрации клиента, если выполняем её через незащищённую сеть. Учитывая вновь обострившуюся тему “Чебурнета”, эта фобия кому-то может показаться вполне оправданной.
В то же время я глубоко убеждён, что столь тяжёлую артиллерию против сервиса аутентификации станут использовать только в качестве поистине крайней меры и в ответ на что-то очень нехорошее. А люди, занимающиеся подобными делами, могут не рассчитывать на поддержку и понимание с моей стороны. SingleA создан во благо и никогда не будет на их стороне.
Один SSO — много Identity Provider’ов
Перед тем, как перейти от темы сервера SingleA к теме клиента для интеграции с ним, приведу обещанный пример ситуации, когда вам может потребоваться несколько realm’ов — firewall’ов с разными user provider’ами.
Представим, что у вас есть корпоративное приложение, закрытое от посторонних с помощью нашего сервиса SSO, который, в свою очередь, использует корпоративный ADFS в качестве Identity Provider’а. И вот в один прекрасный день к вам приходят с задачей пустить в это приложение сотрудников другой организации (допустим, бизнес-партнёров). У них есть свой ADFS, учётная политика, служба безопасности, графики отпусков и прочие особенности, о которых вам не хотелось бы не только знать, но даже думать. Чтобы не заниматься дублированием аккаунтов чужих сотрудников в свой ADFS (со всеми вытекающими рисками), вы можете просто настроить дополнительный realm и заводить на него пользователей из сети вашего бизнес-партнёра. Настройка realm’а включает в себя следующие шаги.
Создать отдельный firewall в config/packages/security.yaml.
Настроить для него request matcher’а, который направит входящий запрос на обработку именно в этот firewall (пример такой настройки есть в документации).
Настроить необходимый аутентификатор для созданного firewall’а.
Настроить для этого firewall’а свой user provider, который будет брать пользователя из нужного вам источника.
Создать обработчик события SingleA\Bundles\Singlea\Event\UserAttributesEvent, который будет срабатывать при аутентификации именно в этом realm’е, и сформировать набор атрибутов пользователя. Здесь же вы можете добавить в атрибуты пользователя флаг (или любое другое значение), который в дальнейшем поможет вам отличить пользователя из сторонней организации от пользователей из вашей организации.
Для определения realm’а на основе запроса можно воспользоваться сервисом SingleA\Bundles\Singlea\Service\Realm\RealmResolver.
Таким образом можно дать пользователям доступ к приложениям через любой Identity Provider, лишь бы он интегрировался с системой аутентификации Symfony Security.
Осталось за кадром
Что ещё полезного есть в SingleA?
Консольные команды, позволяющие: зарегистрировать клиента из терминала, удалить выбранного клиента или всех, старше заданного количества дней, показать старейшего зарегистрированного клиента, а также принудительно разлогинить пользователя.
Идентификатор клиента — это base58-кодированный UUIDv6, что позволяет немного сэкономить на количестве символов и при этом получить информацию о дате и времени регистрации клиента прямо из его идентификатора.
Регистрационные ticket’ы требуют реализации интерфейса SingleA\Bundles\Singlea\Service\Client\RegistrationTicketManagerInterface, который позволяет создать механизм выдачи и отзыва этих ticket’ов.
Система событий SingleA (в дополнение к кастомизации через переопределение и декорирование сервисов).
Для получения дополнительного payload’а есть 2 бандла — nbgrp/singlea-jwt-fetcher-bundle (JWT Fetcher, обмен данными с помощью JWT, он будет приведён в примере ниже) и nbgrp/singlea-json-fetcher-bundle (JSON Fetcher, обмен через обычный JSON).
Подробности для каждого пункта можно найти в документации.
Клиент SingleA для nginx: Lua
Простота использования SingleA основана на том, что токен пользователя должен добавляться в исходный запрос на этапе его обработки веб-сервером, и в клиентском приложении уже останется только извлечь и преобразовать этот токен в объект пользователя. Для реализации этого был создан скрипт на Lua, исполняемый компилятором LuaJIT на стороне веб-сервера nginx. Подробно об этом скрипте, гордо названном мной “клиентом для SingleA”, можно прочитать всё в той же документации (чтобы не рассказывать долго и нудно о его конфигурировании). Здесь лишь подсвечу основные идеи.
С помощью методов клиента можно проверить наличие в запросе куки с ticket’ом, и либо перенаправить пользователя на SSO (с последующим возвращением по исходному URI), либо возвратить ему 401 Unauthorized.
Можно перенаправить пользователя на разлогинивание на SSO, с последующим возвращением на тот же URI, откуда был выполнен редирект. Соответственно, вам нужно будет самостоятельно решать, куда дальше направлять разлогиненного пользователя.
Пожалуй, самым важным является метод, запрашивающий с SSO токен пользователя и добавляющий его в исходный запрос. Чтобы не делать лишней работы, клиент анализирует HTTP-заголовок “Cache-Control” и кэширует полученный токен в общем для всех worker’ов nginx’а участке памяти (см. директиву lua_shared_dict) на указанное в инструкции max-age время. В последующих запросах клиент берёт значение из кэша, а не запрашивает его повторно с сервера.
Раз есть кэш, должен быть и механизм его сброса. Он есть сразу в 3х видах:
Можно прислать запрос со специальным HTTP-заголовком, в результате чего, вместо поиска токена в кэше, клиент удалит его оттуда и пойдёт за ним на сервер.
Можно выставить такой заголовок в ответе клиентского приложения, что приведёт к удалению токена из кэша уже после обработки запроса. Как следствие, на следующем запросе клиент получит свежий токен с сервера.
Наконец, всегда можно принудительно удалить токен из кэша без дополнительных условий и проверок. Актуально делать это в контексте отдельного location’а.
В документации можно найти более подробное описание клиентских методов (и аргументов для некоторых из них), а также примеры их использования. Тут лишь добавлю, что получение токена (и добавление его в исходный запрос) может быть опциональным (не приводящим к ошибке, если пользователь не залогинен). Чтобы избежать ситуации, когда токен был запрошен для пользователя, у которого непосредственно перед этим завершилась только что проверенная сессия, и в результате была возвращена ошибка, можно использовать цепочку вызовов:
require("singlea-client").new { ... }
:login()
:token(false)
:login()
В результате:
Первый вызов метода login() проверяет сессию до запроса токена (и выполняет редирект на аутентификацию, если нужно).
Метод token() получает токен (если пользователь залогинен) и добавляет его в запрос.
Второй вызов метода login() повторяет первую проверку (которая выполнит редирект, если пользователь был разлогинен за прошедшее с первой проверки время).
Красивый пример “на максималках”
Опишу пример конфигурации SSO и клиентских приложений, при котором достигается максимальный эффект от использования SingleA. Со стороны SingleA возьмём следующие бандлы:
nbgrp/singlea-bundle — то, чему посвящена большая часть текста выше.
nbgrp/singlea-redis-bundle — хранение клиентских метаданных и конфигов фич в Redis’е (и там же будем заодно хранить кэш и PHP’шные сессии).
nbgrp/singlea-jwt-bundle — генерация токенов пользователей в формате JWT.
nbgrp/singlea-jwt-fetcher-bundle — получение дополнительного payload’а для токена через обмен данными в составе JWT.
На стороне клиентского приложения будем использовать Symfony Framework в связке со следующими пакетами:
lexik/jwt-authentication-bundle — бандл, позволяющий аутентифицировать пользователя с помощью JWT и инстанцировать объект пользователя с помощью данных из payload’а JWT. Другими словами, превратить JWT в пользователя.
spomky-labs/lexik-jose-bridge — бандл, дополняющий предыдущий и позволяющий ему иметь дело с зашифрованными токенами (чего тот сам не умеет, так как по умолчанию использует библиотеку lcobucci/jwt).
web-token/signature-pack и web-token/encryption-pack — эти 2 pack’а добавляют в зависимости весь набор доступных алгоритмов для цифровой подписи и шифрования JWT из проекта web-token/jwt-framework (который используется на стороне SingleA). В своём реальном приложении вы, конечно, можете подключать только те алгоритмы, которые будете использовать.
Признаюсь, что конфигурирование связки данных бандлов, самого SingleA и, наконец, Symfony — занятие несколько утомительное, но необходимое для понимания того, что на чём стоит. Расписывать это во всех подробностях в данной статье было бы неверно, как мне кажется, поэтому предлагаю заинтересовавшимся изучить этот вопрос самостоятельно. Кроме того, в тестовом примере не демонстрируется никакой автоматизации при развёртывании сервиса SSO или клиентского приложения. Клиент регистрируется на стороне SSO простым POST-запросом с любого HTTP-клиента, и полученные данные используются для конфигурирования как самого клиентского приложения, так и Lua-клиента SingleA для nginx.
Для полноты картины представим, что, кроме SSO и клиентского приложения, где-то развёрнут веб-сервис, принимающий запросы от SSO на генерацию дополнительного payload’а для JWT. Это может делать и само клиентское приложение (лишь бы nginx был правильно сконфигурирован и не заворачивал такие запросы в Lua-клиент SingleA). Для нашего примера ограничимся утверждением, что данный сервис сконфигурирован корректно, умеет принимать зашифрованные JWT и отправлять в ответ данные, упакованные в шифрованный JWT.
Если у вас возникнет интерес к теме SingleA, можно будет рассмотреть вопрос конфигурирования связки “SingleA — клиентское приложение — Lua-клиент SingleA — веб-сервис генерации дополнительного payload’а“ в production-среде с развёртыванием в виде контейнеров.
Чтобы мои слова об утомительности конфигурирования не остались голословным утверждением, опишу некоторые данные, которые необходимо будет передать при регистрации клиента:
ключ в формате PEM, с помощью которого SingleA будет проверять цифровую подпись запросов от этого клиента;
ключ в формате JWK, которым нужно будет шифровать JWT, отправляемый в веб-сервис генерации дополнительного payload’а;
ключ (JWK) для проверки подписи в JWT, который вернёт этот веб-сервис;
ключ (JWK) для шифрования JWT, генерируемого при запросе токена пользователя.
В ответ на регистрационный запрос придёт:
идентификатор и секрет клиента;
ключ (JWK) для шифрования ответного JWT от веб-сервиса генерации дополнительного payload’а;
ключ (JWK) для проверки подписи JWT в запросе на дополнительный payload;
ключ (JWK) для проверки подписи JWT, генерируемого при запросе токена пользователя.
Чтобы не погрязнуть в рассказе о том, как и куда нужно разложить эти 7 ключей, условимся, что они попали туда, где должны быть.
У нас есть настроенный SSO (на базе Symfony и бандлов SingleA), пара клиентских приложений (на базе Symfony и упомянутых выше бандлов для работы с JWT), успешно зарегистрированных на стороне SSO, а также веб-сервис, генерирующий дополнительный payload для пользовательских JWT. Переходим к самому лакомому куску рассказа — разбору шагов, обеспечивающих отсутствие лишних редиректов для пользователя и получение всей необходимой информации на стороне клиентского приложения без лишних шаманских танцев (знаю, после всего вышесказанного это весьма спорное утверждение).
Будем считать, что запросы к клиентскому приложению заворачиваются в Lua-клиент SingleA, который выполняет вызов цепочки методов:
require("singlea-client").new { ... }
:login()
:token()
То есть сначала проверяет сессию пользователя (с возможным редиректом на SSO), а затем добавляет токен в исходный запрос.
Пользователь без куки с ticket’ом делает запрос к клиентскому приложению. Так как ticket’а нет, Lua-клиент SingleA редиректит его на SSO, с указанием текущего URI в качестве адреса, куда нужно вернуть пользователя после успешной аутентификации. В этом и всех остальных запросах к SSO, в числе прочего, указывается идентификатор и секрет клиента, а также цифровая подпись GET-параметров запроса и время его отправки.
SSO ловит запрос, проверяет наличие клиента, извлекает его конфиг фичи цифровой подписи из хранилища, расшифровывает его с помощью указанного секрета, проверяет, что время на обработку запроса не истекло и GET-параметры соответствуют цифровой подписи, указанной в запросе. Если всё хорошо, а пользователь не аутентифицирован, SSO отправляет его логиниться (на своей стороне или, например, на внешнем сервисе аутентификации), запоминая время прихода запроса, чтобы запрос не считался протухшим, когда пользователь вернётся (см. следующий пункт).
После успешной аутентификации и повторной проверки цифровой подписи, SSO формирует набор атрибутов пользователя, генерирует ticket и кладёт атрибуты в кэш.
Наконец, формируется редирект, где, в числе прочего, в куки кладётся ticket, а самой куке ставится домен, общий для всех клиентов и самой SSO. Со всем этим пользователь отправляется обратно в клиентское приложение по адресу из п. 1.
Вернувшись снова в клиентское приложение, Lua-клиент SingleA на этапе обработки запроса видит, что ticket есть, и выполняет внутренний запрос на валидацию сессии пользователя, а затем и на получение токена пользователя (с последующим добавлением его в заголовок исходного запроса).
Получая запрос на валидацию сессии пользователя, SSO выполняет необходимые проверки: существования клиента, валидности IP / подсети клиента (если настроено), цифровой подпись запроса, наличия ticket’а в правильном формате в запросе. Если всё в порядке, SSO проверяет, что атрибуты пользователя для данного ticket’а (и realm’а, в рамках которого происходит обработка запроса) существуют. Если так — возвращает 200, иначе — 403.
При запросе токена пользователя выполняются те же проверки + извлекаются конфиги генератора токенов (tokenizer’а) и фичи для запроса дополнительного payload’а. После этого выполняется последовательное формирование payload’а токена:
сначала из атрибутов пользователя в массив извлекаются поля, указанные в конфиге tokenizer’а (они задаются при регистрации клиента);
затем аналогичным образом формируется массив для запроса дополнительного payload’а;
с помощью этого массива формируется JWT для внешнего веб-сервиса (JWT подписывается и шифруется) и отправляется туда средствами HTTP-клиента Symfony (или иного, реализующего его интерфейс из контрактов symfony/http-client-contracts);
полученный в ответ JWT парсится (включая расшифровку и проверку подписи), и данные из его payload’а сливаются (с заменой) с массивом, который был сформирован в начале.
После создания payload’a для токена пользователя, на его основе формируется JWT (с подписью и шифрованием), который возвращается в теле ответа на запрос генерации токена пользователя. Срок годности токена, и без того заданный в стандартных полях payload’а JWT, дублируется в заголовке “Cache-Control”, в инструкции max-age, для последующего кэширования Lua-клиентом SingleA.
Получив ответ от SSO, Lua-клиент SingleA извлекает токен из тела ответа, кэширует его и добавляет в виде заголовка в исходный запрос, после чего передаёт запрос для дальнейшей обработки в клиентское приложение.
В последующих запросах, если токен для этого клиентского приложения и ticket’а есть в кэше, он берётся оттуда, а не запрашивается у SSO.
Если необходимо, Lua-клиент удаляет из кэша токен пользователя до вставки его в исходный запрос или после полной обработки запроса клиентским приложением.
Наконец, клиентское приложение получает запрос и формирует объект пользователя из данных, извлечённых из токена силами упомянутых бандлов для работы с JWT.
Благодаря этой последовательности шагов, в составе JWT можно передать любую, даже самую конфиденциальную информацию, и ей ничего не будет угрожать при передаче даже по самым открытым каналам.
Если теперь пользователь сделает запрос ко второму клиентскому приложению (зарегистрированному на том же SSO и работающему с тем же общим доменом), он не будет никуда перенаправлен для транзитного редиректа, потому что Lua-клиент SingleA, обрабатывающий запросы к этому приложению, увидит ticket в куках и самостоятельно проделает всю работу, начиная с п. 5. При этом состав токена пользователя для второго приложения будет отличаться от первого, потому что у этих клиентов разные конфиги tokenizer’а на стороне SSO.
Таким образом мы получили механизм, позволяющий без излишней кастомной логики, силами нескольких бандлов и набора настроек, а также nginx’а с Lua’шным скриптом, получить интеграцию с централизованным SSO, способным освободить ваших пользователей от избыточных редиректов, и предоставить вам их данные через JWT (или любой другой формат токена). И если всё это звучит интересно, но неубедительно, вот вам ещё один пример, показывающий эффективность использования SingleA при разработке приложений на базе микросервисной архитектуры и подчёркивающий его влияние на DX.
Дашборд
Здесь под дашбордом имеется в виду страница, на которой собрана информация из различных источников. Лучшим примером может служить главная страница условного Яндекса: на ней выводятся новости, погода, объявления, количество непрочитанных писем в почте, информация о пробках на дорогах и многое другое. Все эти данные, скорее всего, живут в разных сервисах, для которых есть API. При создании дашборда, отображающего данные из разных систем, одним из естественных желаний может быть отправка асинхронных запросов в API этих систем со страницы дашборда. Сложность этого решения в том, что если в приложении, к которому относится дашборд, вы залогинены, то вот веб-сервисы, предоставляющие доступ к API необходимых систем, ничего о вас не знают.
Тут стоит вспомнить диаграмму, приведённую в самом начале. POST-запросы между SSO и приложением не позволяют выполнить XHR-запросы, которые в другом случае могли бы пройти транзитом (если бы вместо POST-запросов были простые GET’ы).
Если представления пользователя у всех сервисов одинаковые (один и тот же набор свойств), то ещё можно посмотреть в сторону рассылки всем одного и того же JWT. Но как быть в случае, если набор свойств отличается? А если данные нужно шифровать, так как они содержат конфиденциальные сведения? В этом случае JWT для каждого API должен быть свой.
Вот тут-то SingleA и пригодится. Зайдя на страницу с дашбордом, пользователь пройдёт аутентификацию, общую для всех приложений и сервисов. После этого все асинхронные запросы к API будут дополнены токенами пользователя на этапе прохождения через nginx. И токен для каждого API будет содержать только те данные пользователя, которые нужны этому сервису. Обрабатывая запрос, каждый сервис будет знать о пользователе всё, что ему нужно, но не более.
В виде диаграммы последовательности сказанное можно изобразить следующим образом:
В случае, если срок сессии пользователя истечёт в процессе работы с приложением и Lua-клиент SingleA вернёт 403, достаточно будет перезагрузить страницу, что приведёт к редиректу пользователя на SSO с последующим возвращением на текущую страницу. Альтернативой может быть открытие отдельного фрейма с выполнением аутентификации в нём. Стоит обратить внимание: если на стороне SSO будет жива PHP сессия, пользователь будет автоматом перенаправлен обратно, то есть не будет никакого интерактива.
Бонус
Случалось ли вам сталкиваться со следующей ситуацией? После заполнения какой-нибудь формы (долгого и утомительного), вы отправляли её, и вместо сообщения об успехе, видели, как вас сначала редиректит на сервис аутентификации, откуда транзитом перекидывает обратно на ту же форму, но снова пустую. И вы понимали, что отправленные данные никуда по факту не отправились, а были потеряны из-за редиректа, и форму нужно заполнять заново. Кто сталкивался с этим хоть раз, наверное согласится, что допускать подобное поведение в своих приложениях — плохая идея.
В приложениях, использующих SingleA в качестве сервиса аутентификации, такое случится только в случае окончания срока жизни сессии на стороне SSO. Пока есть кука с ticket’ом и сессия жива, пользовательские запросы, включая POST, не пострадают, и пользователь не будет перенаправлен на SSO в процессе их обработки. По сути, ticket выступает аналогом Refresh Token’а из OAuth 2.0.
Немного про статический анализ
Если всё вышесказанное описывало SingleA, то здесь я хотел бы сказать пару слов о процессе работы над этим проектом. За годы работы с кодом на PHP, если я что-то и усвоил, то это правило, что код и дизайн приложения нужно держать в чистоте и порядке. Для этого есть немало удобных инструментов, из которых для себя я выбрал: PHP_CodeSniffer, PHP-CS-Fixer, PHP Mess Detector, PHP Magic Number Detector, Deptrac, Phan, Psalm и PHPStan. Когда этих инструментов стало слишком много для последовательно запуска, я открыл для себя ещё одну утилиту, позволяющую запускать весь этот зоопарк на машине разработчика параллельно — GrumPHP. Тогда мне только предстояло столкнуться с конфликтом зависимостей между разными утилитами анализа кода.
Когда эта участь настигла и меня, я посмотрел в сторону контейнерной изоляции утилит (как впоследствии выяснилось, я оказался не первым — в jakzal/phpqa уже эта идея была взята на вооружение). Мне захотелось совместить изоляцию утилит с удобством их конфигурирования и запуска через GrumPHP. Следствием стал инструмент nbgrp/auditor, выполняющий все необходимые проверки в контейнере и имеющий приятный и удобный вывод в консоль результатов проверки. Он используется и локально, и в рамках GitHub Action’а, проверяющего каждый push.
Настройки непосредственно статических анализаторов (Phan, Psalm, PHPStan) я вывернул на максимум (с единичными исключениями), чтобы обезопасить себя и проект от как можно большего числа потенциальных ошибок. Трудно сказать, насколько усложняют работу над проектом инструменты, которые иногда требуют от тебя дополнительных проверок типов данных, рефакторинга избыточных или перегруженных методов и прочих изменений, от которых в ином случае можно было бы отмахнуться при “белковом” code review. Уверенно могу сказать одно: за время работы над SingleA “бездушная машина” уберегла меня от десятков ошибок в дизайне как отдельных классов, так и всего проекта. Это бесценно.
Вместо итога
Когда после окончания университета я решил продолжить работу над SingleA и перевести его в разряд open-source’ных проектов, мои согруппники с удивлением спрашивали, зачем это мне. Ответ прост: задачи, которые решает SingleA, не решает ни один из известных мне продуктов, ни открытый, ни проприетарный (возможно такой есть, но мы не нашли друг друга). Если в результате всех моих усилий у разработчиков появится средство создания SSO’шного сервиса аутентификации, лишённого упомянутых в статье проблем, всё не зря.
С момента, когда я говорил эти слова, прошёл почти год. В моей позиции ничего не изменилось. Если SingleA может сделать вашу жизнь проще, а создаваемые вами продукты — надёжнее и функционально богаче, значит, я был прав и оно того стоило.
Не знаю, хорошо это или плохо, но статья вышла больше прозаической, чем технической. В ней нет красивых графиков и результатов многочисленных тестов (к сожалению, конечно), но есть история создания проекта и описание его работы с позиции создателя. Надеюсь, вы нашли такую точку зрения и сам проект интересными.
Благодарности
Я бы хотел выразить свою огромную благодарность и признание людям, которые поддерживали меня и помогали советами, критикой и рекомендациями на всём пути создания SingleA. Некоторых из них хочу назвать поимённо.
Дмитрий Письман, CDNnow
Олег Суханов, Softline Group
Но самый главный вклад в работу над проектом внесла моя жена. Оля, спасибо за всю ту поддержку, которую я получал все эти без малого 2 года. Без тебя ничего этого не было бы!
Хабровчане, спасибо за внимание. Буду рад конструктивной критике в комментариях и постараюсь ответить на ваши вопросы. Всем добра!