Yandex.Metrika Counter

В этой статье мы хотим поделиться нашим опытом интеграции 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;
  • дополнительный конвейер для сброса пароля.

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

Парочка важных выводов, которые мы сделали в процессе настройки Sitecore и его интеграции с Azure AD 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 запрос без применения сторонних библиотек. В реальном проекте нужно будет создать сервис для отслеживания времени жизни токена и его обновления по мере необходимости. Или же воспользоваться сторонней библиотекой, в которой реализована валидация и обновление токена.

Подробно процесс получения токенов расписан в в разделе «Запрос маркера доступа в Azure Active Directory B2C» официальной документации Microsoft.

 

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/externallogin?authenticationType=SignUpAndSignIn&ReturnUrl=%2fidentity%2fexternallogincallback%3fReturnUrl%3d%252fazureb2c%26sc_site%3dwebsite%26authenticationSource%3dDefault&sc_site=website

Чтобы вызвать 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, добавив параметры, необходимые для внутренней работы:

https://azureb2cmydemo.b2clogin.com/azureb2cmydemo.onmicrosoft.com/b2c_1_signupandsignin1/oauth2/v2.0/authorize?client_id=d1400d7d-a389-4c38-a36f-327e8e949017&redirect_uri=https%3A%2F%2Fsitecoreazureb2cdemosc.dev.local%2Fazureb2c&response_type=code%20id_token&scope=openid%20profile&state=OpenIdConnect.AuthenticationProperties%3Dhwe7m0qKgXQKiMza5ViYcrjAehjrhE-vO2CklGHCDJ4_N2iEbt5leLP0oRG2Y1LujmtZsgHeZ1zzoIHebutJdAUxXr0ZZ9BuQaSLycs-2Eb_nsN2BkVV_qDDJHtJJZsBqbc6v6R1cAeC-WLBJr84nRGrqhTt1BGbAwNPPPv4JfHkUl8d9PjQZf_oXMZHPnGfSLt0J0h1bYYkvpM2k6hk725wnByHDQmWYzlnaCzdzaV_iPisnPuennulGPllC5Vd2OQ4JogZ6M_A_I2uWj5y351rOxv0tmqiRbQpOnUkfAX1JUtnAFxoYAqlS2ij84TeMstnD3MFGpTmAppwFgatiw&response_mode=form_post&nonce=637825709211329023.ZDA2Y2E4MTgtNjdkMi00YTFmLWEyNDYtMzU1OWUyMzMzYzkzODNjODcyZWQtMWFiOS00MmE3LTk3N2EtNGU0NzhhMmE1OGI0&prompt=login&x-client-SKU=ID_NET461&x-client-ver=5.3.0.0

Дополнительный конвейер для сброса пароля

Напомним, ранее представители техподдержки 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. Научились работать с несколькими пользовательскими политиками. Дали пользователям возможность сбросить и изменить пароль, а также обеспечили корректную обработку кук.

Материалы, которые мы использовали в работе:

Документация Azure B2C

Документация Sitecore 9.3 Federated authentication

Руководство по созданию клиента Azure Active Directory B2C

Настройка проверки подлинности в примере веб-приложения, которое вызывает веб-API при помощи Azure AD B2C

Configure federated authentication

SameSite support

Запрос маркера доступа в Azure Active Directory B2C

Map claims and roles

Отдельно хочется отметить готовность официальной техподдержки Sitecore помогать разработчиком — часть решений по текущей задаче (проблема с бесконечным редиректом при Logout, использование отдельных identity-провайдеров для каждой пользовательской политики) мы нашли именно благодаря им!


Фото в заголовке: by Fotis Fotopoulos on Unsplash

Автор статьи: Дмитрий Катасонов

Тимлид и эксперт: Александр Береговой

Редактура текста: Марина Медведева