В этой статье мы хотим поделиться нашим опытом интеграции Sitecore 9.3 с системой управления пользователями Azure AD B2C. Расскажем о том, как нам удалось построить на базе этих технологий личный кабинет, с какими трудностями мы при этом столкнулись и как их в итоге решили. Но сначала — немного вводных данных.
Вводные
Sitecore CMS — одна из лидирующих систем управления контентом на .NET Framework. По данным Built With, на Sitecore построено больше 17000 сайтов, среди которых сайты компаний Microsoft, United Airlines, PUMA, L’Oreal и т.д.
В России Sitecore не особо популярна из-за своей дороговизны. Согласно информации все тех же Built With, в русскоязычном интернете на этой CMS разработано около 60 сайтов.
Мы во fuse8 много работаем с зарубежными клиентами и диджитал-агентствами. Поэтому Sitecore — одна из основных CMS в нашем стеке технологий. Вместе с нашими партнёрами UNRVLD (бывшие Delete Agency), мы разработали на Sitecore сайты для таких компаний и брендов, как Biffa, Leeds Beckett University, The Open, Southampton FC, Royal Canin и других.
Azure Active Directory B2C — это облачная служба управления корпоративными удостоверениями пользователей. С её помощью пользователи могут войти в приложения, используя аккаунты социальных сетей, предприятий и локальных учётных записей.
Задача
На одном из проектов перед нами стояла задача создать личный кабинет для клиентов крупной организации. В этом личном кабинете пользователи должны были иметь возможность просматривать текущие операции, выставлять счета на оплату, общаться с технической поддержкой, управлять личной информацией и т.д. При этом все данные в ЛК должны были подтягиваться из корпоративной системы нашего заказчика через защищенный API. Точкой входа пользователей заказчик выбрал Azure Active Directory B2C.
Итак, немного декомпозируем задачу. Нам нужно было:
- интегрироваться с защищенным API, предоставленным заказчиком;
- реализовать регистрацию пользователя;
- реализовать вход/выход пользователя в ЛК и сброс пароля;
- реализовать имперсонализацию — сделать так, чтобы администраторы и специалисты технической службы заказчика могли попасть в ЛК пользователя, не зная его логина и пароля.
И в процессе выполнения всего этого мы столкнулись с некоторыми сложностями и проблемами:
- как реализовать поддержку нескольких пользовательских политик (входа, имперсонализации, регистрации, сброса пароля) в пределах одного сайта;
- как организовать одновременный выход из Azure AD B2C и личного кабинета Sitecore;
- как при входе в ЛК переадресовать пользователя на страницу входа Azure AD B2C;
- как избежать роста количества аутентификационных кук, приводящего к превышению допустимой длины запроса.
В интернете полно документации, статей и гайдов по Sitecore CMS и Azure Active Directory B2C. Но решений именно наших проблем и трудностей, собранных в одной статье, мы среди них не нашли. Пришлось справляться местами самим, местами с помощью официальной техподдержки Sitecore. Полученной информацией мы и поделимся с вами ниже. Как говорится, мы через эти сложности уже прошли, так что вам не придется :)
Создаём клиент Azure AD B2C
Чтобы воссоздать нашу работу над интеграцией Sitecore с Azure AD B2C, понадобится базовый клиент Azure AD B2C — со стандартными пользовательскими потоками Sign up and sign in и Password reset. Чтобы создать его, воспользуйтесь официальным гайдом — нужно просто повторить шаги с 1 по 3 и у вас уже будет рабочий клиент с нужными настройками пользовательских потоков.
Рисунок 1 — потоки пользователей
Также нам необходимо выбрать утверждения Claims, которые будут возвращаться с ID-токеном. Помимо стандартных утверждений, вроде имени, email и т.д., мы добавим ещё и утверждение Roles. Для этого нажимаем на Manage user attributes → Add. Теперь при создании пользователя вы сможете добавить ему роль с определенным уровнем прав доступа — например, администратор, клиент или гость. Отметим, что в реальном приложении для этого пришлось бы настраивать индивидуальные пользовательские политики. Но для демонстрации и нашего метода будет достаточно.
Рисунок 2 — утверждения Claims
Теперь при входе/регистрации мы получим ID-токен, в котором будут прописаны данные о пользователе и предоставленных ему ролях.
{
"typ": "JWT",
"alg": "RS256",
"kid": "X5eXk4xyojNFum1kl2Ytv8dlNP4-c57dO6QGTVBwaNk"
}.{
"exp": 1646850084,
"nbf": 1646846484,
"ver": "1.0",
"iss": "https://azureb2cmydemo.b2clogin.com/d227dc46-1f9c-4a46-97a2-d3b83ed89a3a/v2.0/",
"sub": "b5bb6799-77f9-43a6-b6bf-15eaff761600",
"aud": "d1400d7d-a389-4c38-a36f-327e8e949017",
"nonce": "defaultNonce",
"iat": 1646846484,
"auth_time": 1646846484,
"oid": "b5bb6799-77f9-43a6-b6bf-15eaff761600",
"name": "Test user",
"country": "Russia",
"given_name": "Dmitry",
"family_name": "Ivanov",
"extension_Roles": "Administrator, Developer",
"emails": [
"kataconov@mail.ru"
],
"tfp": "B2C_1_SignUpAndSignIn1"
}.[Signature]
Последний шаг для настройки клиента Azure B2C — интеграция с защищенным API. Процесс настройки подробно описан в шаге 2 руководства.
В итоге наше веб-приложение должно получить доступ к защищённому API, который затем будет использоваться при получении Access- и Refresh-токенов.
Рисунок 3 — API permissions
Интегрируемся с Sitecore
Согласно официальной документации, федеративная аутентификация требует настройки Sitecore определенным образом. Дополнительно к этому, с учётом специфики нашей задачи, мы еще разберёмся, как нам организовать конвейер для сброса пароля и реализовать получение Access- и Refresh-токенов.
Итак, вот, что нам предстоит реализовать:
- конфигурацию поставщика удостоверений (Identity Provider);
- получение Access- и Refresh-токенов;
- преобразование утверждений (Claims) и свойств профиля пользователя (Properties);
- создание виртуального пользователя;
- генерацию URL для доступа в Azure B2C;
- дополнительный конвейер для сброса пароля.
Помимо этого, стоит уделить внимание кукам, процессу выхода из системы и окончанию сессии.
Все взаимодействие с Azure AD B2C должно происходить через Identity Provider и генерацию ссылки через конвейер (pipeline) Sitecore. Даже если кажется, что у нас есть всё необходимое для прямого запроса. Таким образом мы задействуем Sitecore Identity Server, где генерируется объект State, и все внутренние процессы работают так, как должны.
Чтобы поддерживать сразу несколько пользовательских потоков, нужно создать отдельный провайдер под каждый пользовательский поток или пользовательскую политику. К этому мы пришли после обсуждения этого вопроса с официальной поддержкой Sitecore.
А теперь — к настройке!
Конфигурация Identity provider
Чтобы сконфигурировать поставщика удостоверений (Identity Provider), нам нужно:
- создать конвейер, наследуемый от IdentityProvidersProcessor;
- переопределить метод ProcessCore;
- переопределить IdentityProviderName;
- зарегистрировать конвейер в конфигурации Sitecore.
Ниже приведен код класса, реализующего конвейер, предназначенный для регистрации и входа (аутентификации) пользователей:
namespace SitecoreAzureB2CDemo.Pipelines
{
public class SignUpAndSignInPipeline : IdentityProvidersProcessor
{
private readonly string _tenant = "azureb2cmydemo.onmicrosoft.com";
private readonly string _aadInstance;
private readonly string _metaAddress;
private readonly string _redirectUri = "https://sitecoreazureb2cdemosc.dev.local/azureb2c";
private readonly string _postLogoutRedirectUri = "https://sitecoreazureb2cdemosc.dev.local/azureb2c";
private readonly string _clientId = "d1400d7d-a389-4c38-a36f-327e8e949017";
private readonly string _clientSecret = "aF67Q~rO4IoyoisargoDvtSkq2K4RF-u5Jyut";
private readonly string _scope = "https://azureb2cmydemo.onmicrosoft.com/tasks-api/tasks.read";
protected override string IdentityProviderName => IdentityProviderNames.SignUpAndSignIn;
protected virtual string Policy => "B2C_1_SignUpAndSignIn1";
private readonly HttpClient _client = new HttpClient();
private IdentityProvider IdentityProvider => GetIdentityProvider();
public SignUpAndSignInPipeline(FederatedAuthenticationConfiguration federatedAuthenticationConfiguration,
ICookieManager cookieManager, BaseSettings settings) : base(federatedAuthenticationConfiguration,
cookieManager, settings)
{
var aadInstanceTemplate = "https://azureb2cmydemo.b2clogin.com/{0}/{1}";
_aadInstance = string.Format(aadInstanceTemplate, _tenant, Policy);
_metaAddress = $"{_aadInstance}/v2.0/.well-known/openid-configuration";
}
protected override void ProcessCore(IdentityProvidersArgs args)
{
Assert.ArgumentNotNull(args, nameof(args));
var authenticationType = GetAuthenticationType();
var options = new OpenIdConnectAuthenticationOptions(authenticationType)
{
Caption = IdentityProvider.Caption,
AuthenticationMode = AuthenticationMode.Passive,
RedirectUri = _redirectUri,
PostLogoutRedirectUri = _postLogoutRedirectUri,
ClientId = _clientId,
Authority = _aadInstance,
MetadataAddress = _metaAddress,
UseTokenLifetime = true,
TokenValidationParameters = new TokenValidationParameters { NameClaimType = Claims.Name },
CookieManager = CookieManager,
Notifications = new OpenIdConnectAuthenticationNotifications
{
RedirectToIdentityProvider = OnRedirectToIdentityProvider,
SecurityTokenValidated = OnSecurityTokenValidated,
}
};
args.App.UseOpenIdConnectAuthentication(options);
}
private async Task OnSecurityTokenValidated(
SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> arg)
{
var identity = arg.AuthenticationTicket.Identity;
identity.AddClaim(new Claim(Claims.IdToken, arg.ProtocolMessage.IdToken));
//apply Sitecore claims tranformations
arg.AuthenticationTicket.Identity.ApplyClaimsTransformations(
new TransformationContext(FederatedAuthenticationConfiguration, IdentityProvider));
arg.AuthenticationTicket = new AuthenticationTicket(identity, arg.AuthenticationTicket.Properties);
}
private Task OnRedirectToIdentityProvider(
RedirectToIdentityProviderNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> arg)
{
var owinContext = arg.OwinContext;
var protocolMessage = arg.ProtocolMessage;
if (protocolMessage.RequestType == OpenIdConnectRequestType.Authentication)
{
protocolMessage.Prompt = "login";
}
else if (protocolMessage.RequestType == OpenIdConnectRequestType.Logout)
{
protocolMessage.PostLogoutRedirectUri = BuildPostLogoutRedirectUri(owinContext);
}
return Task.CompletedTask;
}
}
}
Теперь, чтобы зарегистрировать наш конвейер в конфигурации Sitecore, мы должны сделать следующее:
- создать новую секцию identityProvider под /sitecore/federatedAuthentication/identityProviders;
- создать новую секцию mapEntry под configuration/sitecore/federatedAuthentication/identityProvidersPerSites.
Итоговый patch-file Sitecore конфигурации выглядит следующим образом:
<?xml version="1.0" encoding="utf-8"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/"
xmlns:role="http://www.sitecore.net/xmlconfig/role/">
<sitecore>
<pipelines>
<owin.identityProviders>
<processor type="SitecoreAzureB2CDemo.Pipelines.SignUpAndSignInPipeline, SitecoreAzureB2CDemo" resolve="true" />
</owin.identityProviders>
</pipelines>
<federatedAuthentication type="Sitecore.Owin.Authentication.Configuration.FederatedAuthenticationConfiguration, Sitecore.Owin.Authentication">
<identityProvidersPerSites hint="list:AddIdentityProvidersPerSites">
<mapEntry name="Azure AD B2C for website" type="Sitecore.Owin.Authentication.Collections.IdentityProvidersPerSitesMapEntry, Sitecore.Owin.Authentication" resolve="true">
<sites hint="list">
<site>website</site>
</sites>
<identityProviders hint="list:AddIdentityProvider">
<identityProvider ref="federatedAuthentication/identityProviders/identityProvider[@id='SignUpAndSignIn']" />
</identityProviders>
<externalUserBuilder type="SitecoreAzureB2CDemo.Helpers.ExternalDomainUserBuilder, SitecoreAzureB2CDemo" resolve="true">
<IsPersistentUser>false</IsPersistentUser>
</externalUserBuilder>
</mapEntry>
</identityProvidersPerSites>
<identityProviders hint="list:AddIdentityProvider">
<identityProvider id="SignUpAndSignIn" type="Sitecore.Owin.Authentication.Configuration.DefaultIdentityProvider, Sitecore.Owin.Authentication">
<param desc="name">SignUpAndSignIn</param>
<param desc="domainManager" type="Sitecore.Abstractions.BaseDomainManager" resolve="true" />
<caption>SignUpAndSignIn</caption>
<domain>extranet</domain>
<enabled>true</enabled>
<triggerExternalSignOut>true</triggerExternalSignOut>
<transformations hint="list:AddTransformation">
</transformations>
</identityProvider>
</identityProviders>
</federatedAuthentication>
</sitecore>
</configuration>
Параметры конфигурации Azure B2C поместим в секцию appSettings в файле Web.config:
<appSettings configBuilders="SitecoreAppSettingsBuilder">
<add key="AzureB2C.Tenant" value="azureb2cmydemo.onmicrosoft.com"/>
<add key="AzureB2C.SignUpAndSignInPolicy" value="B2C_1_SignUpAndSignIn1"/>
<add key="AzureB2C.PasswordResetPolicy" value="B2C_1_PasswordReset1"/>
<add key="AzureB2C.ProfileEditingPolicy" value="B2C_1_ProfileEditing1"/>
<add key="AzureB2C.ClientId" value="xxx"/>
<add key="AzureB2C.ClientSecret" value="xxx"/>
<add key="AzureB2C.RedirectUri" value="https://sitecoreazureb2cdemosc.dev.local/azureb2c"/>
<add key="AzureB2C.PostLogoutRedirectUri" value="https://sitecoreazureb2cdemosc.dev.local/azureb2c"/>
<add key="AzureB2C.AzureADInstance" value="https://azureb2cmydemo.b2clogin.com/{0}/{1}"/>
<add key="AzureB2C.AccessTokenUri" value="https://azureb2cmydemo.b2clogin.com/azureb2cmydemo.onmicrosoft.com/{0}/oauth2/v2.0/token"/>
<add key="AzureB2C.Scope" value="https://azureb2cmydemo.onmicrosoft.com/tasks-api/tasks.read" />
</appSettings>
На что ещё стоит обратить внимание: OnRedirectToIdentityProvider
- OnRedirectToIdentityProvider — необходимо сгенерировать ссылку PostLogoutRedirectUri во избежание бесконечного редиректа при завершении сеанса.
- OnSecurityTokenValidated — здесь мы можем добавить дополнительную информацию в утверждения (Claims), например, ID-токен. Позднее мы получим и добавим в утверждения Access- и Refresh-токены.
- Вызов метода ApplyClaimsTransformations необходим, даже если не определено ни одной трансформации утверждений в конфигурации — без него мы получали бы exception “Ids claim is missing”.
- Важно получать CookieManager через DI и явно передавать его в OpenIdConnectAuthenticationOptions. Это один из шагов для решения проблем с куками — когда рост количества кук приводит к превышению максимально допустимого размера запроса и приводит к ошибке и некорректной работе с SameSite cookies. Вот тут — хотфикс с пошаговым решением этой проблемы.
Получение Access- и Refresh-токенов
Итак, вся информация, предоставляемая пользователю в личном кабинете, подтягивается из защищенного API на стороне клиента. Чтобы связаться с этим API, нам нужны Access- и Refresh-токены.
Получение токенов для доступа к защищенному API мы реализуем в обработчике события об успешной валидации токена безопасности, полученного от Azure B2C (OnSecurityTokenValidated). Далее нам нужно сохранить полученные токены в утверждения (Claims) и, таким образом, сделать их доступными в любом месте проекта.
Сейчас для большей наглядности мы использовали обычный API запрос без применения сторонних библиотек. В реальном проекте нужно будет создать сервис для отслеживания времени жизни токена и его обновления по мере необходимости. Или же воспользоваться сторонней библиотекой, в которой реализована валидация и обновление токена.
private async Task OnSecurityTokenValidated(
SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> arg)
{
var identity = arg.AuthenticationTicket.Identity;
var result = await GetToken(arg.ProtocolMessage.Code);
identity.AddClaim(new Claim(Claims.IdToken, arg.ProtocolMessage.IdToken));
identity.AddClaim(new Claim(Claims.AccessToken, result.AccessToken));
identity.AddClaim(new Claim(Claims.RefreshToken, result.RefreshToken));
//apply Sitecore claims tranformations
arg.AuthenticationTicket.Identity.ApplyClaimsTransformations(
new TransformationContext(FederatedAuthenticationConfiguration, IdentityProvider));
arg.AuthenticationTicket = new AuthenticationTicket(identity, arg.AuthenticationTicket.Properties);
}
private async Task GetToken(string code)
{
var getTokenUrl = string.Format(AzureB2CConfiguration.AccessTokenUri, Policy);
var dict = new Dictionary<string, string>
{
{ "grant_type", "authorization_code" },
{ "client_id", _clientId },
{ "client_secret", _clientSecret },
{ "scope", $"{_scope} offline_access" },
{ "code", code },
{ "redirect_uri", AzureB2CConfiguration.RedirectUri},
};
var requestBody = new FormUrlEncodedContent(dict);
var response = await _client.PostAsync(getTokenUrl, requestBody);
response.EnsureSuccessStatusCode();
var responseBody = await response.Content.ReadAsStringAsync();
var responseDto = JsonConvert.DeserializeObject(responseBody);
return responseDto;
}
Преобразование утверждений (Claims) и свойств профиля пользователя (Properties)
Для различных сценариев работы нам может понадобиться преобразование (маппинг) одних утверждений в другие или в свойства пользователя Sitecore. У Sitecore для этого есть готовый механизм. Подробнее о нём — в разделе Configure federated authentication официальной документации.
Преобразования можно настроить двумя способами:
- на уровне конкретного провайдера: секция identityProvider → transformations — будет применяться только к одному identity-провайдеру;
- на уровне секции identityProviders → sharedTransformations — будет применяться ко всем identity-провайдерам, зарегистрированным в конфигурации приложения.
Таким образом можно единожды определить общие для всех провайдеров трансформации в одной секции sharedTransformations, а специфические — определить для конкретных провайдеров, если это понадобится.
Рассмотрим несколько примеров:
Преобразование утверждения “Emails” в “Email”. “Emails” — это стандартное утверждение, которое Sitecore понимает и автоматически сопоставляет с соответствующим свойством пользователя. Чтобы преобразовать его в “Email”, добавим трансформацию в identityProviders → sharedTransformations:
<sharedTransformations>
<transformation type="Sitecore.Owin.Authentication.Services.DefaultTransformation, Sitecore.Owin.Authentication">
<sources hint="raw:AddSource">
<claim name="emails" />
</sources>
<targets hint="raw:AddTarget">
<claim name="email" />
</targets>
<keepSource>false</keepSource>
</transformation>
</sharedTransformations>
Преобразования утверждения “Roles” в отдельные роли пользователя. Тут потребуется более сложный маппинг. Сопоставление утверждений с ролями позволяет системе авторизовать пользователя на основе ролей и управлять правами доступа. Мы также добавили его в sharedTransformations:
<sharedTransformations>
<transformation name="map roles from Sitecore"
type="SitecoreAzureB2CDemo.Transformations.ClaimsToRolesTransformation, SitecoreAzureB2CDemo"
patch:after="transformation[@type='Sitecore.Owin.Authentication.IdentityServer.Transformations.ApplyAdditionalClaims, Sitecore.Owin.Authentication.IdentityServer']"
resolve="true" />
</sharedTransformations>
Для этого мы создали класс, наследуемый от класса Transformation, и реализовали в нём требуемую логику:
public class ClaimsToRolesTransformation : Transformation
{
public override void Transform(ClaimsIdentity identity, TransformationContext context)
{
var claimValue = identity.Claims.GetClaimValue(Claims.Permissions);
if (string.IsNullOrEmpty(claimValue))
{
return;
}
var userPermissions = claimValue.Split(',');
foreach (var userPermission in userPermissions)
{
identity.AddClaim(new Claim(Claims.Role, userPermission));
}
}
}
А вот, как реализовать маппинг утверждений в свойства профиля, которые сохраняются в профиле пользователя:
<propertyInitializer
type="Sitecore.Owin.Authentication.Services.PropertyInitializer, Sitecore.Owin.Authentication">
<maps hint="list">
<map name="emailClaim"
type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication"
resolve="true">
<data hint="raw:AddData">
<source name="email" />
<target name="Email" />
</data>
</map>
</propertyInitializer>
Создание виртуального пользователя
При работе федеративной аутентификацией нам нужно решить, будем ли мы использовать виртуального пользователя на время жизни сессии или же создадим постоянного (persisted). Поскольку в нашей системе управление пользователями полностью вынесено в систему клиента, мы решили остановиться на варианте с виртуальным пользователем.
Для начала давайте создадим класс, наследуемый от DefaultExternalUserBuilder. Одной из основных его задач будет генерация имени пользователя.
public class ExternalDomainUserBuilder : DefaultExternalUserBuilder
{
public ExternalDomainUserBuilder(ApplicationUserFactory applicationUserFactory, IHashEncryption hashEncryption)
: base(applicationUserFactory, hashEncryption)
{
}
public override ApplicationUser BuildUser(UserManager userManager,
ExternalLoginInfo externalLoginInfo)
{
var appUser = base.BuildUser(userManager, externalLoginInfo);
appUser.InnerUser.Profile.Save();
return appUser;
}
private static void MapClaimToCustomProperty(ExternalLoginInfo source, ApplicationUser target, string claim, string propertyName)
{
var property = source.GetClaimValue(claim);
if (!string.IsNullOrEmpty(property))
{
target.InnerUser.Profile.SetCustomProperty(propertyName, property);
}
}
protected override string CreateUniqueUserName(UserManager userManager,
ExternalLoginInfo externalLoginInfo)
{
if (userManager == null)
{
throw new ArgumentNullException(nameof(userManager));
}
if (externalLoginInfo == null)
{
throw new ArgumentNullException(nameof(externalLoginInfo));
}
var identityProvider =
this.FederatedAuthenticationConfiguration.GetIdentityProvider(externalLoginInfo.ExternalIdentity);
if (identityProvider == null)
{
throw new InvalidOperationException("Unable to retrieve identity provider for given identity");
}
var domain = identityProvider.Domain;
var name = externalLoginInfo.GetClaimValue(Claims.Email);
if (string.IsNullOrEmpty(name))
{
return GetDomainUserName(domain, externalLoginInfo.DefaultUserName);
}
return GetDomainUserName(domain, name.Replace(",", ""));
}
private string GetDomainUserName(string domain, string userName)
{
Sitecore.Diagnostics.Log.Info("Azure login user " + userName, this);
return $"{domain}\\{userName}";
}
}
Затем нам нужно зарегистрировать этот класс в конфигурации Sitecore. Для этого добавим секцию externalUserBuilder в federatedAuthentication → identityProvidersPerSites → mapEntry. При этом свойство IsPersistentUser для виртуальных пользователей должно быть выставлено в false.
<externalUserBuilder
type="SitecoreAzureB2CDemo.Helpers.ExternalDomainUserBuilder, SitecoreAzureB2CDemo" resolve="true">
<IsPersistentUser>false</IsPersistentUser>
</externalUserBuilder>
Генерация URL для доступа в Azure B2C
Настроив один или более внешних identity provider-ов, мы сможем генерировать ссылки для доступа к ним путем вызова конвейера Sitecore GetSignInUrlInfo. Конвейер вернет коллекцию ссылок для входа — по одной на каждый identity provider. Нам нужно будет выбрать нужную ссылку по имени провайдера.
private string GetSignInUrl(string identityProviderName, string returnUrl)
{
if (string.IsNullOrEmpty(identityProviderName))
{
throw new ArgumentNullException(nameof(identityProviderName));
}
var pipelineManager = (BaseCorePipelineManager)ServiceLocator.ServiceProvider.GetService(typeof(BaseCorePipelineManager));
var args = new Sitecore.Pipelines.GetSignInUrlInfo.GetSignInUrlInfoArgs(Sitecore.Context.Site.Name, returnUrl);
Sitecore.Pipelines.GetSignInUrlInfo.GetSignInUrlInfoPipeline.Run(pipelineManager, args);
var url = args.Result.FirstOrDefault(x => x.IdentityProvider == identityProviderName);
return url?.Href;
}
Тут важно понимать, что ссылка будет вести не на Azure AD B2C, а на Sitecore Identity Server:
Чтобы вызвать identity-сервер, нужно отправить POST-запрос. XHR-запрос в этом случае не подойдет, поскольку в ответ мы ожидаем получить редирект с кодом 302, ведущий на Azure AD B2C.
Тут нам поможет форма с автоматической отправкой:
<html>
<body onload='sessionStorage.clear(); document.forms[""form""].submit()'>
<form name='form' action='@Model.SignInUrl' method='post'>
<input type="submit" value="Login">
</form>
</body>
</html>
Из кода выше видно, что форма содержит кусочек JavaScript, который будет выполнен браузером автоматически по завершении загрузки HTML.
Теперь identity-сервер перенаправит нас на Azure AD B2C, добавив параметры, необходимые для внутренней работы:
Дополнительный конвейер для сброса пароля
Напомним, ранее представители техподдержки Sitecore рекомендовали нам использовать отдельные классы Identity Provider для каждого потока/политики Azure B2C. Поэтому для сброса пароля нам понадобится дополнительный Identity Provider.
Создадим новый конвейер, наследуемый от SignUpAndSignInPipeline:
public class PasswordResetPipeline : SignUpAndSignInPipeline
{
public PasswordResetPipeline(FederatedAuthenticationConfiguration federatedAuthenticationConfiguration,
ICookieManager cookieManager, BaseSettings settings) : base(federatedAuthenticationConfiguration,
cookieManager, settings)
{
}
protected override string IdentityProviderName => IdentityProviderNames.PasswordReset;
protected override string Policy => "B2C_1_PasswordReset1";
}
И зарегистрируем его в конфигурации, как было показано ранее в разделе «Конфигурация Identity provider» для SignUpAndSignInPipeline.
<pipelines>
<owin.identityProviders>
<processor type="SitecoreAzureB2CDemo.Pipelines.SignUpAndSignInPipeline, SitecoreAzureB2CDemo" resolve="true" />
<processor type="SitecoreAzureB2CDemo.Pipelines.PasswordResetPipeline, SitecoreAzureB2CDemo" resolve="true" />
</owin.identityProviders>
</pipelines>
<federatedAuthentication
type="Sitecore.Owin.Authentication.Configuration.FederatedAuthenticationConfiguration, Sitecore.Owin.Authentication">
<identityProvidersPerSites hint="list:AddIdentityProvidersPerSites">
<mapEntry name="Azure AD B2C for website"
type="Sitecore.Owin.Authentication.Collections.IdentityProvidersPerSitesMapEntry, Sitecore.Owin.Authentication" resolve="true">
<sites hint="list">
<site>website</site>
</sites>
<identityProviders hint="list:AddIdentityProvider">
<identityProvider ref="federatedAuthentication/identityProviders/identityProvider[@id='SignUpAndSignIn']" />
<identityProvider ref="federatedAuthentication/identityProviders/identityProvider[@id='PasswordReset']" />
</identityProviders>
<externalUserBuilder
type="SitecoreAzureB2CDemo.Helpers.ExternalDomainUserBuilder, SitecoreAzureB2CDemo" resolve="true">
<IsPersistentUser>false</IsPersistentUser>
</externalUserBuilder>
</mapEntry>
</identityProvidersPerSites>
<identityProviders hint="list:AddIdentityProvider">
<identityProvider id="SignUpAndSignIn" type="Sitecore.Owin.Authentication.Configuration.DefaultIdentityProvider, Sitecore.Owin.Authentication">
<param desc="name">SignUpAndSignIn</param>
<param desc="domainManager" type="Sitecore.Abstractions.BaseDomainManager" resolve="true" />
<caption>SignUpAndSignIn</caption>
<domain>extranet</domain>
<enabled>true</enabled>
<triggerExternalSignOut>true</triggerExternalSignOut>
<transformations hint="list:AddTransformation">
</transformations>
</identityProvider>
<identityProvider id="PasswordReset" type="Sitecore.Owin.Authentication.Configuration.DefaultIdentityProvider, Sitecore.Owin.Authentication">
<param desc="name">PasswordReset</param>
<param desc="domainManager" type="Sitecore.Abstractions.BaseDomainManager" resolve="true" />
<caption>PasswordReset</caption>
<domain>extranet</domain>
<enabled>true</enabled>
<triggerExternalSignOut>true</triggerExternalSignOut>
<transformations hint="list:AddTransformation">
</transformations>
</identityProvider>
</identityProviders>
Теперь мы можем работать с несколькими пользовательскими политиками, а при вызове GetSignInUrlInfoPipeline получим коллекцию ссылок и по имени Identity-провайдера сможем выбрать нужную.
private string GetSignInUrl(string identityProviderName, string returnUrl)
{
if (string.IsNullOrEmpty(identityProviderName))
{
throw new ArgumentNullException(nameof(identityProviderName));
}
var pipelineManager = (BaseCorePipelineManager)ServiceLocator.ServiceProvider.GetService(typeof(BaseCorePipelineManager));
var args = new Sitecore.Pipelines.GetSignInUrlInfo.GetSignInUrlInfoArgs(Sitecore.Context.Site.Name, returnUrl);
Sitecore.Pipelines.GetSignInUrlInfo.GetSignInUrlInfoPipeline.Run(pipelineManager, args);
var url = args.Result.FirstOrDefault(x => x.IdentityProvider == identityProviderName);
return url?.Href;
}
Что у нас получилось
Настало время демонстрации работы! Итак, попробуем войти в систему. Для начала заходим на тестовую страницу. Как мы видим, Is Authenticated — false, то есть, у нас пока нет аутентифицированного пользователя, утверждения отсутствуют, свойства профиля пользователя не заполнены.
Рисунок 4 — Azure B2C тестовая страница (неавторизованный)
Нажмем кнопку Login, и браузер перенаправляет нас на страницу входа Azure AD B2C:
Рисунок 5 — Azure Login page
Вводим логин и пароль, нажимаем кнопку Sign in и снова возвращаемся на тестовую страницу нашего приложения. Теперь, как видно на рисунке ниже, аутентификация прошла успешно — пользователь авторизован, и мы видим заполненные свойства профиля, утверждения и присвоенные роли.
Рисунок 6 — Azure AD B2C тестовая страница (авторизованный)
Проверяем реализацию сброса пароля. Нажимаем на кнопку Password reset и попадем на страницу сброса пароля:
Рисунок 7 — Cброс пароля
Рисунок 8 — Cброс пароля
Готово! Всё, что от нас требовалось, сделано и работает корректно! Мы реализовали процесс входа/выхода в личный кабинет на Sitecore через Azure AD B2C. Научились работать с несколькими пользовательскими политиками. Дали пользователям возможность сбросить и изменить пароль, а также обеспечили корректную обработку кук.
Материалы, которые мы использовали в работе:
Документация Sitecore 9.3 Federated authentication
Руководство по созданию клиента Azure Active Directory B2C
Configure federated authentication
Запрос маркера доступа в Azure Active Directory B2C
Отдельно хочется отметить готовность официальной техподдержки Sitecore помогать разработчиком — часть решений по текущей задаче (проблема с бесконечным редиректом при Logout, использование отдельных identity-провайдеров для каждой пользовательской политики) мы нашли именно благодаря им!
Фото в заголовке: by Fotis Fotopoulos on Unsplash
Автор статьи: Дмитрий Катасонов
Тимлид и эксперт: Александр Береговой
Редактура текста: Марина Медведева