Migrating from Providers to CMS 12 ASP.NET Identity with cookie tweaks
Some notes after upgrading a multi-site solution to .NET 6.0 including changing cookie name and setting domain value dynamically with a custom ICookieManager.
Looking around before starting this answer on sub domain cookies on Stack Overflow seemed like the way to go to be able to affect the domain value of the identity authentication cookie in modern dotnet.
I needed this to allow logged in users to move between sites that share apex name but have different sub domains.
Without the leading dot added (or nowadays it also works widely to just strip the first part before the apex name), cookie can only be read by the site on the full sub domain name that set the cookie originally.
In the CMS 11 version I had put together a quite messy IIS rewriteRule
that fixed this, here it is for reference.
<outboundRules rewriteBeforeCache="true">
<rule name="Add Cookie Domain" preCondition="No Cookie Domain">
<match serverVariable="RESPONSE_Set_Cookie" pattern=".*" negate="false" />
<action type="Rewrite" value="{R:0}; domain={C:1}" />
<conditions>
<!--This will put .domain.com in C:1 *.*.*)-->
<add input="{HTTP_HOST}" pattern="^[a-z0-9-]{0,61}(\.[a-z0-9-]{0,61}\.[a-z0-9-]{0,61})" />
</conditions>
</rule>
<preConditions>
<preCondition name="No Cookie Domain">
<!--Check that a Set-Cookie header exists, and that the domain flag is missing-->
<add input="{RESPONSE_Set_Cookie}" pattern="." />
<add input="{RESPONSE_Set_Cookie}" pattern="; domain=.*" negate="true" />
</preCondition>
</preConditions>
</outboundRules>
This is now handled by the custom manager class from the Stack Overflow answer from Michael above. Just a few small changes.
public class SiteCookieManager : ICookieManager
{
private readonly ICookieManager concreteManager;
public SiteCookieManager()
{
// The default manager from Microsoft
this.concreteManager = new ChunkingCookieManager();
}
public void AppendResponseCookie(HttpContext context, string key, string value, CookieOptions options)
{
var threadSafeOptions = options.DeepClone();
threadSafeOptions.Domain = GetDomainValue(context.Request.Host.Host);
this.concreteManager.AppendResponseCookie(context, key, value, threadSafeOptions);
}
public void DeleteCookie(HttpContext context, string key, CookieOptions options)
{
var threadSafeOptions = options.DeepClone();
threadSafeOptions.Domain = GetDomainValue(context.Request.Host.Host);
this.concreteManager.DeleteCookie(context, key, threadSafeOptions);
}
public string GetRequestCookie(HttpContext context, string key)
{
return this.concreteManager.GetRequestCookie(context, key);
}
private static string GetDomainValue(string host)
{
var splitHostname = host.Split('.');
if (splitHostname.Length > 2)
{
return string.Join(".", splitHostname.Skip(1));
}
else if(splitHostname.Length == 2) {
return "." + host;
}
else
{
return host;
}
}
}
I also wanted to relax the password requirements and third, I wanted to isolate the identity authentication cookie name between DXP environments since they otherwise (with the domain fix above) pick up each others cookies (sites are named inte.domain.com and prep.domain.com and www.domain.com) and effectively signs out the user that is signing in to a different environment.
To activate my customizations it seemed risky to unwrap AddCmsAspNetIdentity()
. Instead, to re-configure just a little bit "on top" this seems to work.
services
.AddCmsAspNetIdentity<ApplicationUser>()
.AddCms()
.AddEmbeddedLocalization<Startup>();
services.Configure<IdentityOptions>(o =>
{
o.Password.RequireNonAlphanumeric = false;
o.Password.RequireDigit = false;
o.Password.RequireLowercase = false;
o.Password.RequireUppercase = false;
});
services.PostConfigure<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme, o => {
o.Cookie.Name = $".Identity{this.webHostingEnvironment.EnvironmentName}";
o.CookieManager = new SiteCookieManager();
});
Signing in through the regular /util/login
page we now get .IdentityDevelopment
as name for the authentication cookie and since my local site is using site.local
the custom manager sets .site.local
for the cookie's domain value.
For copying users and their roles we wanted to do a little restart in general and not keep passwords.
Using SQL directly against the migrated database is the easy starting point, something like this normally works since you probably still have the tables and data in the database.
SELECT aspnet_Users.UserId, aspnet_Users.UserName, aspnet_UsersInRoles.RoleId, aspnet_Roles.RoleName, aspnet_Membership.Email
FROM aspnet_Users
LEFT JOIN aspnet_UsersInRoles ON aspnet_UsersInRoles.UserId = aspnet_Users.UserId
LEFT JOIN aspnet_Roles ON aspnet_UsersInRoles.RoleId = aspnet_Roles.RoleId
LEFT JOIN aspnet_Membership ON aspnet_Membership.ApplicationId = aspnet_Users.ApplicationId AND aspnet_Users.UserId = aspnet_Membership.UserId
ORDER BY aspnet_Users.UserName
Do a basic SqlConnection
, iterate and process. For convenience; add roles and users to the CMS using the implementations of UIRoleProvider
and UIUserProvider
under EPiServer.Shell.Security
.
Our solution was to have this in a one time secret page and create a report that the admin can use to get an overview and distribute the individual credential from.
Another option we thought about but didn't do was to send the initial new credential directly to that user's e-mail.
After creating all the users you want to keep in it's probably a good idea to delete data from the old tables.
But before doing anything in this blog post I would check if it's possible to instead switch to some OpenID Connect setup to get (single) sign-on with multi-factor authentication there. Things like connecting other catalogs and managed monitoring of malicious or synthetic login attempts are usually more developed there than what you can get in place with local ASP.NET Identity.
Comments?
Published and tagged with these categories: Optimizely, CMS, ASP.NET