SSO для красавицы и чудовища
На картинке — глупенькая красавица отдаёт чудовищу учётную запись пользователя.
В одной стране жила была старая некрасивая система администрирования для школ, написанная на Classic ASP. Ею пользовались все учителя, ученики, а так же их родители. И в один прекрасный солнечный день её решили модернизировать. На смену уже устаревшей технологии должна придти современная ASP.Net MVC 5 с новым дизайном.
Однако в одночасье переписать все 6000 asp файлов не представляется возможным, какое-то время старая и новая система должны существовать параллельно.
И вот спустя полгода новая сверкающая (хотя кое-где всё таки проглядывает ржавчина и заплатки, ибо сроки) система приняла первых пользователей.
Следующим шагом необходимо было применить технологию единого входа (SSO) для пользователей, чтобы каждый мог свободно перемещаться между пока ещё глупенькой красавицей и полнофункциональным чудовищем.
Кроме того, одна из крупнейших систем управления обучением (LMS) захотела иметь тесную интеграцию с нашей системой администрирования, включая в себя SSO.
Постановка задачи и проблемы
Итак, в качестве входных данных имеем: 2500 школ, 1.5 млн активных пользователей. В новой система каждая школа имеет свой домен третьего уровня, в старой — произвольный домен.
Необходимо создать поставщик учётных записей (IdP) и использовать его в обеих системах, чтобы обеспечить переключение между ними без необходимости перелогиниваться.
Первая проблема — мультиарендная (multi-tenant) архитектура приложения. База данных школы отделена от остальных, соответственно нет единого списка пользователей.
Вторая проблема — устаревшая технология Classic ASP, для которой сложно найти уже существующие решения для использования какого либо IdP.
Третья проблема — в качестве поставщика учётных записей используются уже существующие IdP, с которыми нужно продолжать работать. Получается цепочка: сторонний IdP → наш IdP → конечный потребитель (Service Provider).
Выбор технологии SSO
Задача не нова и существует несколько путей реализации SSO. Самые популярные протоколы:
- SAML
- OpenID
OAuth в нашем случае не подходит, так как по сути является протоколом авторизации, а не аутентификации. Можно его расширить, но это не вписывается в прочие требования заказчика.
Глобальным стандартом пока ещё является SAML 2.0, хоть он и старенький. Часть уже существующих IdP, упомянутых выше, используют этот протокол в той или иной степени. Кроме того, LMS понимает SAML, но не использует OpenID. Поэтому было решено применять SAML 2.0.
Следующим шагом был выбор конкретной реализации SAML. Есть решения из коробки и библиотеки, список которых легко найти на Википедии — SAML-based products and services. Можно было использовать что-то готовое и простое вроде SimpleSAMLphp, но, во-первых, это php, с которым опыта мало, во-вторых, его нужно отдельно хостить, поддерживать и мониторить. Прошерстив реализации на .Net и не найдя решения из коробки, выбрали библиотеки от ComponentSpace, на фоне остальных выглядящие более взрослыми. В целом это решение оправдало себя, хотя обнаружились некоторые неприятные особенности использования, о которых будет сказано позднее.
Multi-tenant архитектура приложения выявила ещё одну задачу — что должно быть IdP и SP в случае каждой конкретной школы? Два варианта:
- Каждая школа — отдельный IdP. Легко реализовать, потому что все пользователи IdP хранятся в одной базе. Вполне достаточно для того, чтобы обеспечить SSO между старой и новой системами. Очень неудобно для SSO с LMS или ещё какой-нибудь системой — по сути надо зарегистрировать 2500 разных IdP, чтобы иметь возможность войти в любую школу.
- IdP централизованный, каждая школа — отдельный SP. Реализация уже не так проста, пользователи размазаны по 2500 баз. Зато интеграция с другими системами упрощаются и появляются интересные возможности в будущем — сделать единый логин для всех школ и для всех ролей. В этом случае у многодетного родителя с детьми в разных школах будет только один логин, а не множество, как сейчас.
Обсудив варианты с заказчиком, остановились на втором.
Для Classic ASP реализаций мы не нашли, а самим писать и не хотелось, и времени не было. Пришлось остановиться на том, что новая система будет иметь прокси, которую будет использовать старая. Не очень красиво, зато будет работать.
Оставалось получить подтверждение от заказчика и платную версию ComponentSpace SAML 2.0 for .Net. Теперь можно, наконец, и код начать писать.
Процесс аутентификации
Пока ещё не решена проблема размазанности базы пользователей. Так как по сути приложение одно и все школы имеют один субдомен (скажем, newsystem.localhost), можно не выделять IdP в отдельное приложение, а вписать его внутрь уже существующего. Он будет псевдо-школой со своим доменом третьего уровня «idp».
По факту будем иметь 2 разных идентификатора пользователя, это с OWIN можно сделать следующим образом:
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = "SSO.ApplicationCookie",
CookieDomain = ".newsystem.localhost",
ExpireTimeSpan = new TimeSpan(6, 0, 0),
SlidingExpiration = true
});
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
LoginPath = new PathString("/Account/Login")
});
Первая кука для пользователя IdP с субдоменом второго уровня, общим для всех школ. Срок жизни — 6 часов с автоматическим продлением при использовании. Вторая — обычная кука для пользователя школы.
Тогда каждая школа в нужный момент будет выступать как IdP.
Если пользователь ещё не аутентифицирован в конкретной школе, «Account/Login» создаст SAML запрос и отправит на «SsoService». IdP обрабатывает этот запрос и, если пользователь ещё не аутентифицирован, отправляет обратно в школу, но на специальную страницу «Account/IdPLogin». Школа сама обработает логин и пароль, аутентифицирует пользователя, но не у себя, а в IdP. Завершить процесс может и сам IdP — «Sso/SsoCompete» отвечает на запрос. Далее школа выступает уже как SP и обрабатывает ответ в своём «Sso/AssertionConsumerService».
Есть небольшая загвоздка — иногда мы не знаем, в какой школе пользователь имеет логин и пароль. Так получается, если запрос пришёл от сторонней системы. В этом случае можно предоставить пользователю самому выбрать школу. Выбирать из 2500 возможных вариантов неудобно, но можно оптимизировать этот процесс несколькими способами — запоминать последнюю выбранную школу и дать SP ограничить список для выбора, добавляя кастомный SAML атрибут в запрос.
Ещё одна проблема — что, если текущий пользователь IdP отсутствует в выбранной школе? Тогда покажем специальную страницу с возможностью выхода текущего пользователя и инициализации новой аутентификации.
school.newsystem.localhost выступает одновременно и SP (слева), и IdP (справа).
Аутентифицировать пользователя на Account/IdPLogin можно используя логин и пароль для школы, либо обратиться к стороннему IdP.
Далее идут подробности реализации, их можно пропустить, если нет желания сильно углубляться в детали. Но почитать результат в конце всё же рекомендую в любом случае.
Конфигурация
Начинаем с самого сложного — настройки IdP и SP. Мы решили, что IdP будет один, а вот SP будет уйма. 2500 для новой системы, 2500 для старой системы и ещё как минимум 1 для LMS. ComponentSpace позволяет загружать настройки из файла и программно. Так давайте делать и то, и другое!
Конфигурационный файл называется saml.config, туда положим общие настройки:
Здесь мы регистрируем
- себя как IdentityProvider с именем (фактически это глобально уникальный идентификатор) «urn: example: SAML:2.0: idp.newsystem.localhost» и неким сертификатом;
- себя как ServiceProvider с тем же именем и определяем путь до нашего AssertionConsumerService и сертификат;
- сторонние IdentityProvider (в нашем случае это просто пример IdP от ComponentSpace), для которых мы являемся ServiceProvider, с их сертификатами, путями и настройками;
- сторонние ServiceProvider (в нашем случае это LMS), для которых мы являемся IdP, с их сертификатами, путями и настройкам.
В последний список входят только сторонние системы, наши школы мы будем регистрировать программно. Сертификаты нужны для возможности подписывать и шифровать запросы и ответы. При появлении новых сторонних систем просто добавим их в этот файл, и всё будет работать без изменения кода.
Теперь на старте приложения нужно настроить все остальные SP. Есть одна особенность — наше приложение в разные моменты времени выступает и как IdP, и как один из 5000 SP. Почему 5000? Потому что прокси для старой системы является по факту отдельным набором из 2500 SP. ComponentSpace позволяет иметь произвольное количество конфигураций, чем мы и воспользуемся.
Сначала загрузим конфигурацию из файла:
SAMLConfiguration.Load();
ComponentSpace создаст сам конфигурацию с предопределённым названием «default». После создания она является текущей, так что к ней можно обращаться через SAMLConfiguration.Current.
Затем создадим конфигурацию IdP, по сути скопировав всё из текущей:
var identityProviderConfigurationId = SAMLConfiguration.Current.LocalIdentityProviderConfiguration.Name;
var identityProviderConfiguration = new SAMLConfiguration
{
LocalIdentityProviderConfiguration = SAMLConfiguration.Current.LocalIdentityProviderConfiguration,
PartnerServiceProviderConfigurations = SAMLConfiguration.Current.PartnerServiceProviderConfigurations,
LocalServiceProviderConfiguration = SAMLConfiguration.Current.LocalServiceProviderConfiguration,
PartnerIdentityProviderConfigurations = SAMLConfiguration.Current.PartnerIdentityProviderConfigurations,
ReloadOnConfigurationChange = SAMLConfiguration.Current.ReloadOnConfigurationChange,
CertificateManager = SAMLConfiguration.Current.CertificateManager,
TraceLevel = SAMLConfiguration.Current.TraceLevel
};
SAMLConfiguration.Configurations.Add(identityProviderConfigurationId, identityProviderConfiguration);
Для остальных это будет PartnerIdentityProviderConfiguration:
var partnerIdentityProviderConfigurations = new Dictionary
{
{
identityProviderConfigurationId,
new PartnerIdentityProviderConfiguration
{
Name = identityProviderConfigurationId,
SignAuthnRequest = true,
WantSAMLResponseSigned = false,
WantAssertionSigned = false,
WantAssertionEncrypted = false,
SingleSignOnServiceUrl = string.Format("https://{0}/sso/ssoservice", identityProviderHost),
SingleLogoutServiceUrl = string.Format("https://{0}/sso/sloidpservice", identityProviderHost),
PartnerCertificateSerialNumber = identityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateSerialNumber,
PartnerCertificateFile = identityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateFile,
PartnerCertificateSubject = identityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateSubject,
PartnerCertificateThumbprint = identityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateThumbprint
}
}
};
Создаём, используя домены школ, конфигурации SP и дополняем IdP списком нашим собственных SP для новой системы:
var spConfigurationId = string.Format("urn:example:saml:2.0:{0}", domain);
SAMLConfiguration.Configurations.Add(spConfigurationId, new SAMLConfiguration
{
LocalServiceProviderConfiguration = new LocalServiceProviderConfiguration
{
Name = spConfigurationId,
AssertionConsumerServiceUrl = string.Format("https://{0}/sso/assertionconsumerservice", domain),
LocalCertificateSerialNumber = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateSerialNumber,
LocalCertificateFile = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateFile,
LocalCertificatePassword = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificatePassword,
LocalCertificatePasswordKey = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificatePasswordKey,
LocalCertificateSubject = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateSubject,
LocalCertificateThumbprint = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateThumbprint
},
PartnerIdentityProviderConfigurations = partnerIdentityProviderConfigurations
});
}
identityProviderConfiguration
.PartnerServiceProviderConfigurations
.Add(spConfigurationId, new PartnerServiceProviderConfiguration
{
Name = spConfigurationId,
WantAuthnRequestSigned = false,
SignSAMLResponse = true,
SignAssertion = false,
EncryptAssertion = false,
AssertionConsumerServiceUrl = string.Format("https://{0}/sso/assertionconsumerservice", domain),
SingleLogoutServiceUrl = string.Format("https://{0}/sso/slospservice", domain),
PartnerCertificateSerialNumber = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateSerialNumber,
PartnerCertificateFile = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateFile,
PartnerCertificateSubject = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateSubject,
PartnerCertificateThumbprint = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateThumbprint
});
То же самое для прокси, отличие будет только в путях:
AssertionConsumerServiceUrl = string.Format("https://{0}/proxy/assertionconsumerservice", domain),
SingleLogoutServiceUrl = string.Format("https://{0}/proxy/sloservice", domain),
В процессе работы обнаружилась проблема — ComponentSpace нужна сессия для хранения своей внутренней информации, а наше распределённое ASP.Net MVC приложение сессию не использует. Только ради SSO включать её не очень правильно, потом у кого-нибудь обязательно появится соблазн туда складывать ещё что-то, и пошло поехало, прощай sessionless. Есть выход — в качестве SessionStore использовать, например, Redis:
public class SessionStore : AbstractSSOSessionStore
{
public override object Load(Type type)
{
var sessionObject = RedisSsoSessionComponent.Load(GetDatabaseSessionId(type));
return sessionObject != null && sessionObject.Length > 0 ? Deserialize(sessionObject) : null;
}
public override void Save(object ssoSession)
{
RedisSsoSessionComponent.Save(Serialize(ssoSession), GetDatabaseSessionId(ssoSession.GetType()));
}
public override string SessionID
{
get { CookieFacade.SsoSessionId; }
}
private string GetDatabaseSessionId(Type type)
{
return string.Format("{0}:{1}", SessionID, type.Name);
}
}
Здесь RedisSsoSessionComponent занимается непосредственным общением с Redis. Есть загвоздка с идентификатором сессии, можно его хранить в куках — в CookieFacade имеем свойство SsoSessionId:
string cookieName = "SsoSessionId";
var cookie = HttpContext.Current.Request.Cookies[cookieName];
if (cookie != null && !string.IsNullOrEmpty(cookie.Value))
{
return cookie.Value;
}
cookie = HttpContext.Current.Response.Cookies[cookieName];
if (cookie != null && !string.IsNullOrEmpty(cookie.Value))
{
return cookie.Value;
}
var sessionId = Guid.NewGuid().ToString();
cookie = new HttpCookie(cookieName, sessionId);
HttpContext.Current.Response.Cookies.Remove(cookie.Name);
HttpContext.Current.Response.AppendCookie(cookie);
return sessionId;
Если нужной куки не нашлось, создадим новый идентификатор.
Осталось подключить наше самописное хранилище:
SAMLConfiguration.SSOSessionStore = new SessionStore();
Конфигурация наконец-то завершена.
Необходимая инфраструктура
В процессе работы нужно различать, какую конфигурацию использовать для каждого запроса. Сделать это можно меняя SAMLConfiguration.ConfigurationID на лету.
Создадим атрибуты, которыми будем помечать необходимые действия в контроллерах:
public class SamlIdentityProviderAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
if (string.IsNullOrWhiteSpace(SAMLConfiguration.ConfigurationID) ||
!SAMLConfiguration.ConfigurationID.Equals(SamlConfig.IdentityProviderConfigurationId,
StringComparison.InvariantCultureIgnoreCase))
{
SAMLConfiguration.ConfigurationID = SamlConfig.IdentityProviderConfigurationId;
}
base.OnActionExecuting(filterContext);
}
}
public class SamlServiceProviderAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
string spConfigurationId = string.Format("urn:example:saml:2.0:{0}", domain);
if (string.IsNullOrWhiteSpace(SAMLConfiguration.ConfigurationID) ||
!SAMLConfiguration.ConfigurationID.Equals(spConfigurationId, StringComparison.InvariantCultureIgnoreCase))
{
SAMLConfiguration.ConfigurationID = spConfigurationId;
}
base.OnActionExecuting(filterContext);
}
}
Логика IdP
// обрабатывает входящие запросы
SAMLIdentityProvider.ReceiveSSO(Request, out partnerSp);
// разбирает запросы на наличие списка школ, используя уже Low level API
HTTPRedirectBinding.ReceiveRequest(HttpContext.Request, out authnRequestElement, out relayState, out signatureAlgorithm, out signature);
domains = _ssoComponent.GetSchoolDomains(authnRequestElement);
// проверяет аутентифицированность пользователя и сразу отправляет на завершение процесса
if (HttpContext.User.Identity.IsAuthenticated)
{
return RedirectToAction(MVC.SamlIdentityProvider.SsoComplete());
}
// перенаправляет пользователя на IdPLogin конкретной школы, если только одна указана в запросе
return Redirect(GetDomainLoginUrl(domain));
// перенаправляет пользователя на страницу выбора школы, если в запросе их несколько
return RedirectToAction(MVC.SamlIdentityProvider.SchoolSelect());
SsoComplete
// создаёт SAML атрибуты учётной записи
var attributes = new Dictionary
{
{
Saml2Helper.Attributes.UserRoleKey, userIdentity.UserRole.ToString()
},
{
Saml2Helper.Attributes.UserFirstNameKey, userIdentity.FirstName
},
{
Saml2Helper.Attributes.UserLastNameKey, userIdentity.LastName
}
};
// отвечает на запрос
SAMLIdentityProvider.SendSSO(Response, userIdentity.UserIdentifier, attributes);
SloService
// обрабатывает входящие запросы
SAMLIdentityProvider.ReceiveSLO(Request, Response, out isRequest, out hasCompleted, out logoutReason, out partnerServiceProvider);
// осуществляет выход пользователя из IdP
HttpContext.GetOwinContext().Authentication.SignOut();
// отвечает на запрос SP, если он пришёл со стороны SP
SAMLIdentityProvider.SendSLO(Response, null);
// либо отвечает на запрос стороннего IdP
SAMLServiceProvider.SendSLO(Response, null);
SchoolSelect
// показывает список доступных школ и перенаправляет на IdPLogin выбранной школы
InitSso
// инициирует новый SSO до стороннего IdP
SAMLServiceProvider.InitiateSSO(Response, null, partnerIdP);
AssertionConsumerService
// обрабатывает ответы от стороннего IdP
SAMLServiceProvider.ReceiveSSO(Request, out isInResponseTo, out partnerIdP, out userName, out attributes, out relayState);
// ищет пользователя по атрибутам
var user = await userManager.FindAsync(new UserLoginInfo("IdP”, userId));
// в случае успеха логинит пользователя в IdP и завершает процесс аутентификации, отправляя ответ SP
await _nativeLoginProcessor.SignInAsync(user);
return Redirect(MVC.SamlIdentityProvider.SsoComplete());
// в противном случае показывает страницу "Пользователь не найден”
IdpSpSloService
// обрабатывает запросы от стороннего IdP на выход
SAMLServiceProvider.ReceiveSLO(Request, out isRequest, out logoutReason, out partnerIdP);
// выходит из учётной записи текущего пользователя
HttpContext.GetOwinContext().Authentication.SignOut("SSO.ApplicationCookie");
// инициирует выход из своих SP
SAMLIdentityProvider.InitiateSLO(Response, null);
// либо отправляет ответ своему SP о завершении процесса
SAMLIdentityProvider.SendSLO(Response, null);
// обычная страница входа с полями для ввода логина и пароля, которая логинит пользователя в IdP
await _nativeLoginProcessor.SignInAsync(user);
// и перенаправляет на завершение процесса
return RedirectToAction(MVC.SamlIdentityProvider.SsoComplete());
// при возможности выбрать сторонний IdP инициирует новый SSO
return RedirectToAction(MVC.SamlIdentityProvider.InitSso(partnerIdP));
Логика SP
// обрабатывает ответы от IdP
SAMLServiceProvider.ReceiveSSO(Request, out isInResponseTo, out partnerIdP, out userName, out attributes, out relayState);
// ищет пользователя по атрибутам
var user = await _samlServiceProviderComponent.FindUserAsync(attributes);
// в случае успеха логинит пользователя в школе и перенаправляет на страницу школы, которую мы передали в relayState
await _nativeLoginProcessor.LocalSignInAsync(user);
return RedirectToLocal(relayState);
// в противном случае перенаправляет на страницу выхода из текущей учётной записи
return RedirectToAction(MVC.SamlServiceProvider.LogOut());
SloService
// обрабатывает входящие запросы
SAMLServiceProvider.ReceiveSLO(Request, out isRequest, out logoutReason, out partnerIdP);
// осуществляет выход пользователя из школы
HttpContext.GetOwinContext().Authentication.SignOut(DefaultAuthenticationTypes.ApplicationCookie);
// отвечает на запрос, если он пришёл от IdP
SAMLServiceProvider.SendSLO(Response, null);
// либо перенаправляет на логинку, если сам инициировал процесс выхода
return RedirectToAction(MVC.Account.Login());
LogOut
// показывает страницу с текстом вида "В данный момент вы пользователь Б в школе А, нажмите сюда, чтобы зайти под пользователем в школе С”
// отправляет запрос в IdP
SAMLServiceProvider.InitiateSSO(Response, returnUrl, SamlConfig.IdentityProviderConfigurationId);
LogOff
// осуществляет выход пользователя из школы
HttpContext.GetOwinContext().Authentication.SignOut(DefaultAuthenticationTypes.ApplicationCookie)
// инициирует выход из IdP
SAMLServiceProvider.InitiateSLO(Response, null);
Логика прокси для старой системы
//инициирует процесс
SAMLServiceProvider.InitiateSSO(Response, relayState, SamlConfig.IdentityProviderConfigurationId);
AssertionConsumerService
// обрабатывает ответы от IdP
SAMLServiceProvider.ReceiveSSO(Request, out isInResponseTo, out partnerIdP, out userName, out attributes, out relayState);
// ищет пользователя по атрибутам
var user = await _samlServiceProviderComponent.FindUserAsync(attributes);
// в случае успеха перенаправляет на страницу старой системы, передавая параметры пользователя и подписывая их, вроде такого
string timeStamp = DateTime.UtcNow.ToString("yyyyMMddHHmm");
string queryParams = string.Format("userId={0}&userLoginProvider={1}×tamp={2}&auth={3}",
userId,
userLoginProviderKey,
timeStamp,
MD5Helper.ComputeHash(string.Format("{0}{1}{2}{3}", userId, userLoginProviderKey, timeStamp, "Secret")));
// в противном случае перенаправляет на страницу выхода из текущей учётной записи
return RedirectToAction(MVC.Proxy.LogOut());
InitiateSlo
// инициирует процесс выхода
SAMLServiceProvider.InitiateSLO(Response, null);
SloService
// обрабатывает входящие запросы
SAMLServiceProvider.ReceiveSLO(Request, out isRequest, out logoutReason, out partnerIdP);
// перенаправляет на страницу выхода из старой системы, которая должна после выхода перенаправить обратно на ProcessSlo
ProcessSlo
// завершает процесс выхода
SAMLServiceProvider.SendSLO(HttpContext.Response, null);
LogOut
// показывает страницу с текстом вида "В данный момент вы пользователь Б в школе А, нажмите сюда, чтобы зайти под пользователем в школе С”
Это только верхушка процесса, внутри есть ещё много мелочей, о которых слишком долго и неинтересно писать.
Результат и выводы
Переход на SSO дело сложное и медленное. Владельцы старой системы очень скептически и осторожно относились к самой идее и к её реализации. Переход происходил постепенно, пачками по 100–200 школ в неделю.
На третьей пачке начались проблемы со скоростью ответа от IdP. Оказалось, что под него был выделен только один веб сервер, хотя мы просили выделить минимум 3 и контролировать количество запросов, добавляя больше серверов при необходимости. Заказчик понял ошибку и исправил положение. Сейчас 6 серверов обслуживают 1.5 млн пользователей, которые имеют привычку все заходить утром почти одновременно.
Далее на 1000 школ внезапно всё умерло. Логи показали, что Redis не отвечает. Тут надо упомянуть, что заказчик сам вызвался обеспечить нас необходимой инфраструктурой и даже купил Redis Labs Enterprise Cluster (RLEC). И по какой-то причине кластер они нам настроили не на запрашиваемые 100 ГБ, а всего на 5. Когда данных стало больше этого ограничения, RLEC просто встал… Никакого предупреждения, никакого вытеснения старых данных, просто перестал отвечать на любые запросы. Техподдержка Redis Labs оказалась очень слабой и медленной — им потребовалась неделя, чтобы найти причину, их рекомендации они сами позже называли ошибочными, и вообще пришлось пересоздавать несколько раз весь кластер. Сейчас кластер работает без сбоев, но осадок неприятный остался.
Библиотеки от ComponentSpace можно успешно использовать, хотя есть неудобства в их структуре — почти всё статическое и при этом не потоко-безопасное. Приходилось самим осторожно всё проверять, копаться в исходниках и при необходимости оборачивать своими потоко-безопасными компонентами. Это в большей степени относится к High level API, которого оказалось недостаточно, в некоторых местах использовали Low level. Кроме того, классы, хранящиеся в сессии, внутренние (internal). Поэтому, к примеру, узнать имя SP, запрос которого в данный момент мы обрабатываем, не так просто, несмотря на то, что доступ к SSOSessionStore у нас есть. Приходилось получать нужные данные рефлексией.
В целом всё работает прекрасно, остался огромный потенциал для улучшения и оптимизации, который в настоящее время постепенно реализуется.