Pull to refresh
0
True Engineering
Лаборатория технологических инноваций

Битва за ADFS (Active Directory Federation Services)

Reading time 12 min
Views 17K

Предыстория


Проект начинался как портал на основе SP 2007, а позже на основе 2010 SP. Изначально все пользователи были в Active Directory. Был только один тип пользователей. Связи между ними были достаточно простыми. Появлялись новые типы пользователей, которые сложным образом становились связаны друг с другом. Также постепенно проект обрастал различными связанными подсистемами, часть из которых работала внутри портала, часть вне его. И это все усложняло схему авторизации.



Какие проблемы?


До какого-то момента использование NTLM удовлетворяло все потребности. Единственная проблема была в том, что столкнулись с тем, что при переходе на связанный сервис, в том случае если url-адресс отличался от адреса портала, приходилось заново вводить логин и пароль. В принципе такая задача могла бы быть решена при помощи продукта web application proxy. Однако позже появились модули на Java и таким образом, среда стала в значительной степени гетерогенной. Также на горизонте замаячила необходимость предоставления доступа пользователям из сторонних доменов. Для решения этих задач стала ясна необходимость Single Sign On (SSO). Было принято решение о внедрении ADFS и переводе портала и всех служб на эту технологию.

Мы обсудили наше видение с заказчиком и наметили День X, когда все должно заработать на ADFS.

Ход событий:



День X – 1 год

Первым делом мы решили переключить на ADFS один из модулей.
Идея была в том, чтобы включив ADFS на портале, набить все шишки, которые можно набить, обжечься там, где можно обжечься и т.п. Поимев определенное количество проблем, мы успешно провели это переключение, о чем уже писали habrahabr.ru/company/eastbanctech/blog/209834.

День X – 3 месяца

Первое, с чего начали — рефакторинг кода, чтобы он работал корректно под Claims Authentication. Пара примеров из того, что мы изменили:

1. Раздача разрешений на элементы списков SharePoint для пользователей и групп Active Directory. Так как разрешения должны были теперь раздаваться на клэймы, то пришлось переписывать все эти места. Хорошо, что почти все такие места использовали одну нашу библиотеку по работе с AD (а те, что не использовали, мы изменили таким образом, чтобы они тоже работали с нашей библиотекой).

Вместо раздачи разрешений пользователям вида “domain\user”, разрешения стали выдаваться клэймам «i:0e.t|ADFS|user@domain». Для «domain\group» — на клэймы вида «c:0-.t|ADFS|group» соответственно. Нам также пришлось отделить случаи, когда разрешения выдаются пользователю, от случаев, когда они выдаются группе, поскольку без использования claims доменные группы выглядят в MS SharePoint одинаково. Таким образом метод GetPrincipalName, определяющий, полное имя principal я превратился в 2:

public static string GetGroupPrincipalName(string group)
{
    if (string.IsNullOrEmpty(TrustedIdentityProviderName))
    {
        return string.Format(CultureInfo.InvariantCulture, "{0}\\{1}", CurrentDomain, GetPrincipalNameWithoutDomain(group));
    }
    return string.Concat("c:0-.t|", TrustedIdentityProviderName, "|", GetPrincipalNameWithoutDomain(@group));
}

public static string GetUserPrincipalName(string user)
{
    if (string.IsNullOrEmpty(TrustedIdentityProviderName))
    {
        return string.Format(CultureInfo.InvariantCulture, "{0}\\{1}", CurrentDomain, GetPrincipalNameWithoutDomain(user.ToLower(CultureInfo.InvariantCulture)));
    } 
    return string.Concat("i:0e.t|", TrustedIdentityProviderName, "|", GetPrincipalNameWithoutDomain(user), TrustedIdentityProviderDomain);
}


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

public static string GetPrincipalNameWithoutDomain(string principal)
{
    if (string.IsNullOrEmpty(principal))
        return string.Empty;
    return
        Regex.Match(principal.Trim(),
                @"(i:)?0e.t.*\|(?<userName>[\d\w_&\.\s-]+)@[\w\d\._]|(c:)?0-.t.*\|(?<userName>[\d\w_&\.\s-]+)$|^(?<userName>[\d\w_&\.\s-]+)@[\w\d\._]|^(?<userName>[\d\w_&\.\s-]+)$|[\w]+\\(?<userName>[\d\w_&\.\s-]+)$")
            .Groups["userName"].Value;
}


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

День X – 2 месяца

Через месяц-другой портал ожил, заработали базовые вещи, модуль новостей, бизнес-процессы на основе Sharepoint Workflows и многое другое.

После этого мигрировали портал на тестовом окружении, составили чек-лист по нашим бескрайним модулям, назначили ответственных за каждый модуль и полетели по схеме:

• модуль сделали
• выкатили на тестирование
• нашли кучу багов, поправили
• модуль готов

По ходу дела нам встретилось пара интересных проблем

1. Первая проблема, с которой мы столкнулись, – это вызов SOAP сервисов

Большинство wcf-сервисов, которые “живут” в папке _vti_bin и используются скриптами из браузера и через webHttpBinding. Из оставшихся часть сервисов используется для межмодульного взаимодействия, а остальные нужны другим системам клиента, с которыми настроена интеграция. Естественно, большинство этих взаимодействий сделаны на основе SOAP (так удобнее) и завязаны на NTLM-аутентификацию. Для начала мы попытались понять, насколько проблемно будет перевести всех клиентов (хотя бы наших, работающих на WCF) на ADFS. Попробовали, ужаснулись количеству необходимых телодвижений и сложности конфигурации клиента и забыли эту мысль. Потратили немного времени на попытку заставить SharePoint работать одновременно с двумя схемами аутентификации для сервисов (да так, чтобы пользователи не заметили). Не вышло, NTLM упорно отказывался работать (про причины чуть ниже).

Таким образом, нам было необходимо срочно вернуть «настоящий» NTLM для сервисов. Поэтому обратились к такой возможности SharePoint, как расширение (extend) web-приложений. Рассудили так, что на основном адресе останутся REST-сервисы для браузера, аутентификацией которых займется браузер пользователя, а все служебные сервисы переедут на адрес расширенного сайта, который будет работать с NTLM. Казалось бы, все просто… Начали.

Мы расширили портал, уже переведённый на ADFS, с помощью стандартных средств SharePoint (Extend Web application), выбрав в качестве Authentication Provider для расширенного сайта NTLM. Ожидаемый результат: все пользователи WCF-сервисов из служебной директории «ISAPI» в SharePoint работают с ними, как и раньше, максимум — правят адрес вызываемого сервиса в секции client в файле конфигурации «Web.config». Расширение основного сайта, уже переведённого на ADFS, сразу не решило проблем вызовов WCF-сервисов для пользователей через аутентификацию NTLM — мы неумолимо получали ответ на всякий вызов WCF-сервиса: «The HTTP request is unauthorized with client authentication scheme 'Ntlm'. The authentication header received from the server was 'Negotiate,NTLM'». Истинной причиной проблемы явилось то, что при расширении главного сайта в основной файл конфигурации «Web.config» Sharepoint скопировал неправильные аутентификационные модули в секции «modules»:

<add name="FederatedAuthentication" type="Microsoft.SharePoint.IdentityModel.SPFederationAuthenticationModule …" />
<add name="SessionAuthentication" type="Microsoft.SharePoint.IdentityModel.SPSessionAuthenticationModule …" />
<add name="SPWindowsClaimsAuthentication" type="Microsoft.SharePoint.IdentityModel.SPWindowsClaimsAuthenticationHttpModule …" />

Тогда как правильным модулем для NTLM-сайта является:

<add name="Session" type="System.Web.SessionState.SessionStateModule" />

После долгих обсуждение решили действовать в следующей последовательности:
— создать расширение главного сайта до его перевода на ADFS, тогда останется прежний правильный NTLM-модуль аутентификации;
— перевести главный сайт портала в Sharepoint на ADFS
В случае если у вас появятся одни и те же WCF-сервисы в ISAPI, которые имеют одновременно endpoint’ы как для доступа через NTLM, так и через ADFS, то они будут требовать одновременной поддержки сайтом IIS как «Forms Authentication», так и «Windows authentication». В нашем случае мы имеем главный сайт Sharepoint и его расширение, которые намеренно одновременно не поддерживают оба способа аутентификации. Для решения этой проблемы использовали:

— Создать две подпапки в ISAPI «ModuleServiceAdfs», «ModuleServiceNtlm»

— скопировать в обе папки svc-файл сервиса WCF

— создать в каждой из папок свой файл конфигурации «Web.config» — в первой для ADFS, во второй — для NTLM.

2. Вторая проблема – устаревание saml-токена


Большинство запросов, которые выполнялись к серверу, работали через ajax посредством jquery. При этом периодически возникают ситуации, когда saml-токен становится невалидным (когда токен устарел, когда был перезапущен пул sharepoint, когда был перезапущен пул ADFS). Стандартный механизм перенаправления на страницу аутентификации с автообновлением токена или вводом логина/пароля и дальнейшего возврата на исходную страницу в случае с jquery.ajax не работает. Да и сама ситуация, когда пользователя приходится отправлять на страницу аутентификации только за тем, чтобы автоматически вернуть его на исходную страницу, но с потерянными результатами работы, энтузиазма не внушала. Беглый поиск на просторах интернета привел нас вот к этому решению. Для тех, кому лень ходить по ссылке, – суть решения в заведении preauth-страницы и оборачивании всех ajax-запросов обработчиком, который, на случай ответа сервера 401, загружает данную страницу через iFrame, после чего повторяет исходный запрос. Создание единой точки выполнения ajax-запросов на наш случай проблемой не было, поскольку приложение и было написано в такой манере (Мы уже рассказывали про наш подход в этой и этой статьях).

Мы развили найденное решение решения, сделав некоторые дополнения:


1. На случай, если было сделано несколько запросов подряд, то, вместо того, чтобы создавать для каждого по iFrame-у при получении 401, мы для второго и последующих запросов возвращаем тот же deferred, что был создан для первого запроса.

2. Данный подход работает на случай, когда токен устарел или когда его “забыл” sharepoint. Но он не работал на случай, когда нас “забыл” ADFS, – в этом случае требуется повторный ввод логина/пароля. Подход, описанный в статье, на такие случаи приводил к бесконечному циклу загрузки preauth-страницы через iFrame без какого-либо результата. Прямой редирект на страницу аутентификации тоже был не очень приятен, поскольку это означало потерю результатов работы для пользователя. Решением стало отображение модальника для ввода логина/пароля на случай, если загрузка через iFrame не помогла, и мы снова получили 401. Модальник, в свою очередь, делает вызов кастомного сервиса, который уже выполняет аутентификацию в ADFS. После выполнения аутентификации – дублируем исходный ajax-запрос/запросы.
Дополненный код выглядит следующим образом:

refreshToken: function ()
        {
            if (wcfDispatcherDef.frameLoadPromise === undefined)
            {
                return jquery.Deferred(function (d)
                {
                    wcfDispatcherDef.frameLoadPromise = d;
                    var iFrame = jquery('<iframe></iframe>');
                    iFrame.hide();
                    iFrame.appendTo('body');
                    iFrame.attr('src', wcfDispatcherDef.PreauthUrl);
                    iFrame.load(function ()
                    {
                        setTimeout(function ()
                        {
                            wcfDispatcherDef.frameLoadPromise = undefined;
                            d.resolve();
                            iFrame.remove();
                        }, 100);
                    });
                });
            } else
            {
                return wcfDispatcherDef.frameLoadPromise;
            }
        },
        makeServiceCall: function (settings, initialPromise)
        {
            var self = this;
            var d = initialPromise || jquery.Deferred();
            var promise = jquery.ajax(settings)
                        .done(function ()
                        {
                            d.resolveWith(self.requestContext || self, jquery.makeArray(arguments));
                        }).fail(function (error)
                        {
                            if (error.status * 1 === ETR.HttpStatusCode.Unauthorized && wcfDispatcherDef.HandleUnauthorizedError === true)
                            {
                                if (initialPromise)
                                {
                                    wcfDispatcherDef.AuthDialog.show().done(function (result)
                                    {
                                        if (result === true)
                                        {
                                            self.makeServiceCall.call(self, settings, d).done(function ()
                                            {
                                                d.resolveWith(self.requestContext || self, jquery.makeArray(arguments));
                                            });
                                        } else
                                        {
                                            router.navigate('#forbidden');
                                        }
                                    });
                                } else
                                {
                                    self.refreshToken().then(function ()
                                    {
                                        self.makeServiceCall.call(self, settings, d).done(function ()
                                        {
                                            d.resolveWith(self.requestContext || self, jquery.makeArray(arguments));
                                        });
                                    });
                                }
                            } else
                            {
                                d.rejectWith(self.requestContext || self, jquery.makeArray(arguments));
                            }
                        });

            return d;
        },


Помимо этого, мы решили множество интересных задач, которые сюда не включаем, так как их описание тянет на целую книгу.

День X – 1 месяц


Когда закончили на тестовом окружении, доработали инструкцию, собрали огромный пакет для обновления и провели тестовую миграцию на предрелизном сервере с реальными данными. Три дня воевали с возникающими мелочами (как без них, тестовое окружение не идеально), и зарядили тестирование с самого начала, после чего назначили День X.

День X


День Х был назначен на субботу, мы собираемся в офисе в 9-00. В офисе клиента – их администратор, который собственно и производит развертывание. Когда деплоишь не сам, а кто-то другой по твоей инструкции, всегда страшно, поэтому весь день следили за каждым его шагом через расшаренный экран в Lync. В 15-00 миграция закончена. Все проверяем, находим, что не взлетело, допиливаем напильником. В 18-00 все остальное заработало, расходимся довольные.

Первый рабочий день после Дня X


Наступает понедельник, первый рабочий день. Для нас начинается АД. Из основного, выясняется, что:
a. Токен протухает гораздо чаще, чем мы думали;
b. Портал тормозит сильнее обычного;
c. Пользователей постоянно выкидывает на страницу логина, что очень-очень мешает работать;
d. Есть проблема не только с ajax запросами. Если пользователь заполняет стандартную форму нового элемента списка полчаса, то с вероятностью 90% он свои изменения при сохранении теряет.

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

Первый” сюрприз”. У нас есть кастомный uploader файлов с превьюшками, который сохраняет временные файлы на диске (для случаев, когда объект и файлы создаются в одной форме и аттачить файлы в момент пока некуда, а показать в preview надо). Так вот uploader сохраняет временные файлы в поддиректории приложения, типа C:\inetpub\Sharepoint\Files при этом создавая там свои поддиректории, а потом их удаляя. Удаления эти приводили к периодическому recycle пула приложения. А так как Sharepoint Logon Token Cache живет просто в памяти, то со всеми сессиями можно было попрощаться. Uploader этот, надо признаться, живет у нас уже год, и пул скорее всего он перегружал и раньше, но до этого никто особо не замечал, с Windows аутентификацией это не приводило к повторному запросу учетных данных:). В итоге оперативно сменили целевую папку uploader и вакханалия стала меньше.

Второй” сюрприз”. Иногда процесс начинал съедать всю память, что заставляло пул перегружаться. Отловить, что конкретно грузит портал, когда на нем работают сотни человек, задача не простая и не быстрая. Анализируем запросы к базе, логи, находим те самые критичные места, которые все периодически заваливают, понимаем, как их сделать по-другому, переписываем, накатываем… Дышать становиться легче. Опять же, проблема этой неоптимальности была всегда, но на периодические тормоза раньше закрывали глаза. Мало ли что тормозит, а с отсутствием прямого доступа к прод серверу оперативная диагностика осложняется намного, посему и игнорировали эту проблему.

Портал работает шустрее, каждые 5 минут уже не выкидывает, но раз в полчаса-час все равно происходит перелогин. И тут мы открываем для себя базовые вещи, которые должны были для себя открыть в самом начале, — управление временем жизни Logon Token для тех, кому интересны подробности msdn.microsoft.com/ru-ru/library/office/hh147183(v=office.14).aspx (тему эту пропустили, так как при тестировании это особо не возникало. Проверка отдельного тест-кейса легко укладывается в 5 минут, а от кейса к кейсу тестировщик меняет пользователей, и сессия обновляется. Длительную работу с 9 до 6 не эмулировали. Настроили длительности сессии 1 день и вроде все должно было стать хорошо, но…

Очередной сюрприз. Пользователей продолжает выкидывать. Уже не так регулярно, но случаи есть, причем массовые. Судя по логам, пул не перегружается в это время. В чем же дело? Читаем, разбираемся, как оно все работает, находим полезную статью про кэши blogs.msdn.com/b/besidethepoint/archive/2013/03/27/appfabric-caching-and-sharepoint-1.aspx Понимаем, что по умолчанию размер кеша — только для 250 токенов, а когда кеш переполняется, — всем привет :) Увеличиваем размер кеша — наступает эйфория…Поток негатива и гневных писем спадает.

Что дальше


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

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

<add type="Microsoft.SharePoint.IdentityModel.SPTokenCache, Microsoft.SharePoint.IdentityModel, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />

А в нем SPSecurityTokenCache private KeyValuePair<string, SecurityToken>[] m_StrongCache; — вот как реализован внутри тот самый StrongCache.

Ради интереса решили попробовать написать свой TokenCache, однако попытка сделать это за 5 минут успехом не увенчались. Покопавшись поглубже во всем SP-конвеере, обнаружили, что связность компонентов там довольно высока. В итоге все же написали в тестовых целях свою версию TokenCache, хотя кое-где пришлось все-таки использовать Reflection.

Заключение


Какие же выводы можно сделать, оглядываясь назад. Подобные изменения сходны с заменой кирпича в основании пирамиды, который удерживает на себе всю конструкцию. Конечно, можно говорить от том, что так делать не нужно, что систему нужно надстраивать, а не перестраивать. Однако в реальной жизни обязательно настанет момент, когда фундаментальные изменения станут необходимы. И тогда, по нашему разумению, нужно:
a. Досконально изучить внутренние механизмы того, что подвергнется изменениям, сколько бы поставщики не говорили про” хорошо документированную черную коробку”;
b. Как можно лучше смоделировать работу системы, включая сценарии продолжительного использования, высокой нагрузки и т.п.;
c. Попытаться спрогнозировать последствия. Проработать сценарии отката, если изменения придется все-таки убирать;
d. Договориться с клиентом, о том, что возможны сбои;
e. И приготовиться…
Tags:
Hubs:
+8
Comments 0
Comments Leave a comment

Articles

Information

Website
www.trueengineering.ru
Registered
Founded
Employees
101–200 employees
Location
Россия