Обеспечение безопасности Optimizely CMS всегда было ключевым моментом практически в каждом проекте. Хотя доступен встроенный поставщик удостоверений ASP.NET, многие организации предпочитают облачные решения для удостоверений, которые позволяют централизованно управлять пользователями и ролями.
В некоторых случаях эти два подхода должны работать вместе — скоординировано объединяя локальную идентификацию ASP.NET с внешним поставщиком. Хорошей новостью является то, что в мире .NET такие рабочие процессы можно настроить довольно легко. Однако эта гибкость также может привести к ненужным сложностям, если с ней не обращаться осторожно.
Ниже приведено пошаговое руководство по настройке одного из способов интеграции аутентификации (и обработки авторизации для ролей) в Optimizely CMS.
Поток
В этом сценарии пользователи внешнего интерфейса аутентифицируются через Okta и получают доступ к сайту с использованием токенов JWT, поскольку внешний интерфейс является «безголовым». Пользователи CMS также проходят аутентификацию через Okta, используя подход MVC. Кроме того, резервный вход в систему через ASP.NET Identity остается доступным для доступа к CMS.
Очень важно с самого начала определить имена схем аутентификации, используемые в приложении. В данном конкретном случае требовалось следующее.
internal static class AuthScheme
{
internal const string Cms = "CMS-Auth";
internal const string Api = "API-Auth";
internal const string OktaMvc = "Okta-MVC";
internal const string OktaJwt = "Okta-JWT";
internal const string ContentApi = OpenIDConnectOptionsDefaults.AuthenticationScheme;
internal static readonly string Identity = IdentityConstants.ApplicationScheme;
}
На высоком уровне определены две основные схемы аутентификации:
-
система управления контентом: для доступа пользователей в режиме редактирования CMS служит схемой по умолчанию.
-
API: для аутентификации запросов конечной точки API
Аутентификация Okta использует две схемы:
Дополнительно имеются:
-
API контента: поддерживает безголовую архитектуру, включив CMS в качестве сервера OpenIddict для предварительного просмотра контента.
-
Личность: представляет идентификатор базы данных ASP.NET по умолчанию.
Эта структура схемы улучшает организацию и упрощает управление политиками аутентификации в приложении.
services
.AddPolicyScheme(AuthScheme.Cms, null, o =>
{
o.ForwardDefaultSelector = httpContext =>
httpContext.ContainsIdentityAuthCookie()
? AuthScheme.Identity
: AuthScheme.OktaMvc;
})
.AddPolicyScheme(AuthScheme.Api, null, o =>
{
o.ForwardDefaultSelector = httpContext =>
httpContext.IsSwaggerContext()
? AuthScheme.Cms
: AuthScheme.OktaJwt;
});
АддПолициСхеме Метод позволяет условной логике определить, какую схему аутентификации использовать. Для схемы CMS по умолчанию решение зависит от того, есть ли у пользователя идентификационный файл cookie. Если присутствует, сайт позволяет вернуться к аутентификации личности ASP.NET; в противном случае запускается вызов Okta.
private static bool ContainsIdentityAuthCookie(this HttpContext context)
{
var config = context.RequestServices.GetRequiredService>();
var identityCookieName = config.Get(AuthScheme.Identity).Cookie.Name;
if (string.IsNullOrWhiteSpace(identityCookieName))
{
return false;
}
return context.Request.Cookies.ContainsKey(identityCookieName);
}
Схема API в основном использует токены Okta JWT и контролирует авторизацию для конечных точек API с помощью [Authorize(AuthScheme.API)] атрибут. Чтобы упростить тестирование разработчиков с помощью Swagger, условная политика обнаруживает этот сценарий и возвращается к схеме CMS. Это обеспечивает доступ к API через действительный сеанс браузера.
Конфигурация
Выше описано, как аутентификация должна функционировать в различных случаях. Остаётся подробная конфигурация для каждой схемы аутентификации.
Начнем с основ. Первым из них является ASP.NET Identity, который требует простой однострочной конфигурации.
services.AddCmsAspNetIdentity<ApplicationUser>();
Кроме того, в целях документации включена конфигурация потока аутентификации Content API, хотя она не будет описываться подробно. Это необходимо для функционирования безголовой архитектуры, но не участвует в основной бизнес-логике приложения.
internal static IServiceCollection AddContentApiAuth(this IServiceCollection services, ContentApiAuthOptions? options, bool isDevelopment)
{
services.AddOpenIDConnect(
useDevelopmentCertificate: true,
signingCertificate: null,
encryptionCertificate: null,
createSchema: true,
o =>
{
o.RequireHttps = !isDevelopment;
o.Applications.Add(new OpenIDConnectApplication
{
ClientId = options?.ClientId,
ClientSecret = options?.ClientSecret,
Scopes = { "openid", "offline_access", "profile", "email", "roles", ContentManagementApiOptionsDefaults.Scope },
});
}
);
services.AddOpenIDConnectUI();
return services;
}
internal static IServiceCollection AddContentApiAuth(this IServiceCollection services, ContentApiAuthOptions? options, bool isDevelopment)
{
services.AddOpenIDConnect(
useDevelopmentCertificate: true,
signingCertificate: null,
encryptionCertificate: null,
createSchema: true,
o =>
{
o.RequireHttps = !isDevelopment;
o.Applications.Add(new OpenIDConnectApplication
{
ClientId = options?.ClientId,
ClientSecret = options?.ClientSecret,
Scopes = { "openid", "offline_access", "profile", "email", "roles", ContentManagementApiOptionsDefaults.Scope },
});
}
);
services.AddOpenIDConnectUI();
return services;
} Окта МВЦ
Наконец, фокус смещается на настройку аутентификации Okta, начиная с настройки Okta MVC.
internal static AuthenticationBuilder AddOktaAuth(this AuthenticationBuilder authenticationBuilder, OktaAuthOptions? options)
{
ArgumentNullException.ThrowIfNull(options);
authenticationBuilder
.AddOktaMvc(AuthScheme.OktaMvc, new OktaMvcOptions
{
OktaDomain = options.Domain,
AuthorizationServerId = OktaWebOptions.DefaultAuthorizationServerId,
ClientId = options.ClientId,
ClientSecret = options.ClientSecret,
CallbackPath = options.CallbackPath,
PostLogoutRedirectUri = options.SignOutRedirectUrl,
Scope = new List<string> { "openid", "profile", "email" },
GetClaimsFromUserInfoEndpoint = true,
OpenIdConnectEvents = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = context => {
context.Response.Headers.CacheControl = "no-store, no-transform, no-cache";
context.Response.Headers["CDN-Cache-Control"] = "no-store, no-transform, no-cache";
if (context.Response.StatusCode == StatusCodes.Status401Unauthorized) {
context.HandleResponse();
}
if (context.Request.Path.StartsWithSegments($"/{ApiControllerBase.ApiPrefix}"))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.HandleResponse();
}
return Task.CompletedTask;
},
OnAuthenticationFailed = async context => {
context.HandleResponse();
var messageBytes = Encoding.ASCII.GetBytes(context.Exception.Message);
await context.Response.BodyWriter.WriteAsync(messageBytes);
},
OnTokenValidated = context =>
{
EnsureInternalRedirection(context);
return Task.CompletedTask;
},
OnTicketReceived = async context =>
{
OktaClaimsTransformer.Transform(context.Principal);
await SynchronizeRoles(context.HttpContext, context.Principal);
}
}
})
.AddApiAuth(options);
return authenticationBuilder;
}
Окта.AspNetCore Здесь используется пакет NuGet, предоставляющий АддОктаМвк метод расширения для настройки сведений о сервере авторизации вместе с необходимыми областями, которые как минимум включают в себя открытый идентификатор, профильи электронная почта. Кроме того, getClaimsForUserInfoEndpoint флаг гарантирует, что информация о пользователе Конечная точка вызывается для получения всех доступных утверждений.
Примечательно, что некоторые события аутентификации обрабатывают определенные модификации и крайние случаи. ОнРедиректтоидентитипровидер Событие предотвращает циклы кэширования и перенаправления и гарантирует возврат кода состояния 401 вместо перенаправления на страницу входа в контекст Swagger.
Другие обработчики событий управляют ошибками, возвращая соответствующие сообщения и избегая внутренних перенаправлений при успешной аутентификации.
private static void EnsureInternalRedirection(TokenValidatedContext context)
{
if (string.IsNullOrEmpty(context.Properties?.RedirectUri))
{
return;
}
try
{
var redirectUri = new Uri(context.Properties.RedirectUri, UriKind.RelativeOrAbsolute);
if (redirectUri.IsAbsoluteUri)
{
context.Properties.RedirectUri = redirectUri.PathAndQuery;
}
}
catch (UriFormatException)
{
context.Properties.RedirectUri = "/";
}
}
Последнее событие срабатывает после полного получения токена. Он используется для изменения претензий, поступающих от Okta, на основе конкретных бизнес-требований. Что еще более важно, этот шаг синхронизирует необходимые роли с Optimizely CMSчто крайне важно для того, чтобы пользователи, прошедшие аутентификацию через Okta, имели соответствующие права доступа к интерфейсу CMS.
private static async Task SynchronizeRoles(HttpContext httpContext, ClaimsPrincipal? principal)
{
if (principal?.Identity is not ClaimsIdentity claimsIdentity)
{
return;
}
var optiClaims = new List
{
new(ClaimTypes.Role, "WebAdmins", ClaimValueTypes.String),
};
claimsIdentity.AddClaims(optiClaims);
var syncService = httpContext
.RequestServices
.GetRequiredService();
await userSyncService.SynchronizeAsync(claimsIdentity);
}
В идеале все роли должны предоставляться напрямую из Окты. Однако это не всегда осуществимо. В таких случаях здесь можно предложить индивидуальное решение. пользовательсинксервице является примером ISynchronizingUserService от EPiServer.Security пространство имен. Его основная обязанность — добавлять новые записи в tblSyncchedUser, tblSyncedUserRelationsи tblSyncchedUserRole таблицы в базе данных, которые сопоставляют пользователей с конкретными ролями Optimizely.
Для Okta MVC требуется небольшая дополнительная настройка, которая не обрабатывается в АддОктаМвк метод. Эту настройку необходимо выполнить на этапе после настройки после регистрации служб.
internal static IServiceCollection ConfigureOktaTokenValidation(this IServiceCollection services, OktaAuthOptions? options)
{
ArgumentNullException.ThrowIfNull(options);
return services.PostConfigureAll(o =>
{
var validationParameters = GetTokenValidationParameters(AuthScheme.OktaMvc);
validationParameters.ValidIssuer = options.Domain;
validationParameters.ValidAudience = options.ClientId;
o.TokenValidationParameters = validationParameters;
});
}
Он определяет, как токен должен проверяться путем установки основных параметров. Он также настраивает окно допуска (ЧасыПерекос), что позволяет токену считаться действительным в течение короткого периода времени, несмотря на незначительные расхождения во времени на нескольких серверах.
private static TokenValidationParameters GetTokenValidationParameters(string authenticationType) =>
new()
{
RoleClaimType = SiteClaimTypes.Role,
NameClaimType = SiteClaimTypes.Username,
AuthenticationType = authenticationType,
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(5),
ValidateIssuerSigningKey = true
};
Окта JWT
После настройки Okta MVC следующим шагом будет настройка Okta JWT. Настройка очень похожа, с некоторыми небольшими отличиями, такими как создание URL-адреса эмитента.
Установка КартаВходящие претензии установите флажок false, чтобы отключить автоматическое сопоставление утверждений, предоставляя полный контроль над преобразованием утверждений. Это позволяет специальной логике обеспечивать согласованность утверждений для потоков аутентификации MVC и JWT. Сопоставление исходных утверждений выполняется вручную, а не на основе соглашений по умолчанию.
Здесь указан только один обработчик событий — для синхронизации ролей пользователей по той же логике, что и для MVC. Ключевое отличие заключается в использовании OnTokenValidated вместо OnTokenReceived. Этот выбор основан на тестировании, которое показало, что объединение этих событий дает полный набор претензий. С использованием OnTokenValidated в схеме MVC привело к отсутствию утверждений, которые присутствовали в OnTokenReceived.
private static AuthenticationBuilder AddApiAuth(this AuthenticationBuilder authenticationBuilder, OktaAuthOptions options) =>
authenticationBuilder
.AddJwtBearer(AuthScheme.OktaJwt, o =>
{
o.Authority = UrlHelper.CreateIssuerUrl(options.Domain, AuthorizationServerId);
o.Audience = options.ApiAudience;
o.RequireHttpsMetadata = true;
o.TokenValidationParameters = GetTokenValidationParameters(AuthScheme.OktaJwt);
o.MapInboundClaims = false;
o.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
OktaClaimsTransformer.Transform(context.Principal);
await SynchronizeRoles(context.HttpContext, context.Principal);
}
};
});
Склеиваем все детали вместе
После настройки всех схем последним шагом является сборка полной системы аутентификации.
private static IServiceCollection AddMixedAuth(this IServiceCollection services, AuthOptions? options, bool isDevelopment)
{
ArgumentNullException.ThrowIfNull(options);
services
.AddCmsAspNetIdentity<ApplicationUser>()
.ConfigureApplicationCookie(o =>
{
o.Cookie.HttpOnly = true;
o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
})
.AddSession(o =>
{
o.Cookie.HttpOnly = true;
o.Cookie.IsEssential = true;
})
.AddAuthentication(o =>
{
o.DefaultAuthenticateScheme = AuthScheme.Cms;
o.DefaultChallengeScheme = AuthScheme.Cms;
})
.AddOktaAuth(options.Okta)
.AddPolicyScheme(AuthScheme.Cms, null, o =>
{
o.ForwardDefaultSelector = httpContext =>
httpContext.ContainsIdentityAuthCookie()
? AuthScheme.Identity
: AuthScheme.OktaMvc;
})
.AddPolicyScheme(AuthScheme.Api, null, o =>
{
o.ForwardDefaultSelector = httpContext =>
httpContext.IsSwaggerContext()
? AuthScheme.Cms
: AuthScheme.OktaJwt;
});
services.ConfigureOktaTokenValidation(options.Okta);
services.AddContentApiAuth(options?.ContentApi, isDevelopment);
return services;
}
Аддмикседаутентификатор метод объединяет все конфигурации аутентификации. Он настраивает обработку файлов cookie и сеансов, определяет политики и устанавливает схему по умолчанию. Этот метод служит единой централизованной точкой, к которой подключена каждая часть установки. Затем его можно удобно использовать на более высоком уровне, например, в файле запуска, чтобы активировать полную конфигурацию смешанной аутентификации.
services.AddMixedAuth(options, isDevelopment);
Дополнительный полезный фрагмент кода — это КартаВыходМаршрут метод расширения, который выполняется внутри Настроить метод стартового файла. Его роль — определить текущий контекст схемы и выполнить соответствующую процедуру выхода.
internal static IApplicationBuilder MapLogoutRoute(this IApplicationBuilder app, AuthOptions? options)
{
app.MapWhen(context => context.Request.Path.StartsWithSegments("/Util/Logout"), appBuilder =>
{
appBuilder.Run(async httpContext =>
{
if (httpContext.ContainsIdentityAuthCookie())
{
var signInManager = httpContext.RequestServices.GetRequiredService();
await signInManager.SignOutAsync();
}
else
{
await httpContext.SignOutAsync();
}
httpContext.Response.Redirect(options?.Okta?.SignOutRedirectUrl ?? "/", false);
});
});
return app;
}
Заключительные слова
Описанный выше подход помог наглядно настроить достаточно сложную и интересную настройку аутентификации и авторизации в одном из проектов, над которыми я работал. Это результат многих часов исследований, чтения документации и разработки хорошо структурированной конфигурации. Эту структуру легко расширить, и ее можно плавно включать и отключать, что делает ее практичной для реальных сценариев.
Ещё по этой теме

