16

Whenever I updated my ASP.NET Core RC2 website running on as an Azure Web App, it logs out all users. It seems to be related to swapping a staging deployment slot to production (I use web deploy from VS to staging, and have it set to auto-swap to production). If I do a direct update of the production slot it's fine, but I don't want to do that. I am at a loss as to how to configure this, help would be appreciated!

Here is how I have it configured right now, my site only allows logging in directly (no facebook login etc.):

In ConfigureServices in Startup

// found some post that said this would help... doesn't seem to work...
services.AddDataProtection()
        .SetApplicationName("myweb");

services.AddIdentity<MyUser, MyRole>(options =>
{
    options.Cookies.ApplicationCookie.CookieDomain = settings.CookieDomain; // cookie domain lets us share cookies across subdomains
    options.Cookies.ApplicationCookie.LoginPath = new PathString("/account/login");
    options.Cookies.ApplicationCookie.ReturnUrlParameter = "ret";
    options.Cookies.ApplicationCookie.CookieSecure = CookieSecureOption.Never; // TODO: revisit site-wide https

    // allow login cookies to last for 30 days from last use
    options.Cookies.ApplicationCookie.ExpireTimeSpan = TimeSpan.FromDays(60);
    options.Cookies.ApplicationCookie.SlidingExpiration = true;

    // I think this needs to at least be longer than cookie expiration to prevent security stamp from becoming invalid before the cookie?
    options.SecurityStampValidationInterval = TimeSpan.FromDays(90);
})
.AddUserStore<MyUserStore>() // custom stores to hook up our old databases to new identity system
.AddRoleStore<MyRoleStore>()
.AddDefaultTokenProviders();

And in Configure in Startup

app.UseIdentity();
abatishchev
  • 98,240
  • 88
  • 296
  • 433
Yellowfive
  • 643
  • 6
  • 12
  • Are your staging and production slots using the same database? The login sessions are stored in the database, so if you are using 2 databases for 2 slots and do not keep those database settings while swapping, those login sessions are swapped too. – Jack Zeng Jun 17 '16 at 01:38
  • Both slots are using the same database, but I don't think that this is relevant in this case... our site doesn't use any session information or persist any session information to the database. What is happening probably is that the login cookie is becoming invalid on swap... my guess is that something is causing the cookies to be encrypted differently on the different slots... – Yellowfive Jun 17 '16 at 03:17
  • A little more detail: when I swap the deployment slots, the login cookies are still there in the browser... the website just doesn't think they are valid and ignores them. – Yellowfive Jun 19 '16 at 17:04

5 Answers5

21

After much research... I think that I have this working.

So for anyone who wants an ASP.NET Core RC2 website that uses the Identity stuff for login, and wants to host it on an Azure Web App, and wants to use the Deployment Slots to do updates via swapping, and doesn't want every user to get logged out every time the website is updated... read on!

** Usually, Azure gives you some magical default configuration that makes all of the instances in a single Web App work together. The issue with deployment slots is that it essentially acts like two completely separate Web Apps, so all the magic is gone.

You need to configure Data Protection correctly to make this work. It is a bit confusing because the documentation for .NET Core Identity makes no explicit mention of depending on or requiring that you configure Data Protection correctly, but it does. Data Protection is what it uses under the hood to encrypt the application login cookie.

The following code is needed in ConfigureServices:

services.AddDataProtection()
    .SetApplicationName("myweb")
    .ProtectKeysWithCertificate("thumbprint");

services.AddSingleton<IXmlRepository, CustomDataProtectionRepository>();

Explanation of each piece:

  1. Setting the application name lets you share the protected data across multiple applications that use this same application name. May not be required for all scenarios, but doesn't hurt for ours.
  2. You need to use a custom key encryption method that is consistent across both deployment slots. The default is specific to each deployment slot and can only be used within that slot. If you look at key encryption at rest, Azure uses Windows DPAPI by default. Not gonna work for our purposes. So I chose to use a certificate, just enter the thumbprint as seen in the Azure portal. NOTE: for those who hate certificates and all the jargon around it, the .NET Core documentation says you need a X.509 certificate that supports CAPI private keys or it won't work. blah blah blah blah use the SSL certificate you got for your website, it should work just fine.
  3. An aside: you have to do some extra googling to actually make using the certificate work. The Data Protection documentation kind of leaves you hanging in the case of Azure... just using the code above, you will likely get a 500 error when you deploy to Azure. Firstly, make sure you have uploaded your certificate in the "Custom domains and SSL" section of your Web App. Secondly, you need to add the WEBSITE_LOAD_CERTIFICATES Application Setting and add the thumbprint of your certificate to that in order to use it. See using certificates in azure websites.
  4. Once you set a certificate to encrypt the data... it blows away any default configuration about where to store the data -- Azure stores it in a shared folder that all of your instances can access by default (defaults described here data protection defaults). But different deployment slots are separate... so the built-in file system and registry options are no help. You have to write a custom implementation as described here: key storage providers. But oh wait... the section at the bottom on custom key repositories is a 1-liner with no link or explanation about how to hook it up... you really need to read here: key management, go to the IXmlRepository section. Unfortunately the IDataProtectionBuilder has handy extensions for everything except what you need to do here, thus the line where we register this custom IXmlRepository with the service provider. Despite the alarmingly generic name of that interface, it only impacts Data Protection and won't mess with your other stuff.
  5. Not shown is the implementation of CustomDataProtectionRepository. I used Azure blob storage. It is a pretty simple interface, make a comment if you need help with that though.

And OMG finally we have it working. Enjoy the 500% decrease in lost password customer service requests ;)

Yellowfive
  • 643
  • 6
  • 12
  • Will this work for ASP.NET 5 applications that currently havnt migrated to Core? – gorillapower Sep 28 '16 at 16:11
  • This is only really applicable to asp.net core. – Yellowfive Sep 29 '16 at 23:00
  • I just wanted to let you know that there is an (alpha) official azure storage extension from Microsoft https://www.nuget.org/packages/Microsoft.AspNetCore.DataProtection.Azure.Storage/ – Aaron Oct 26 '16 at 19:03
  • Do you know if you if there is any reason you should not be able to use a self-signed certificate (or the azure default https certificate on the azurewebsites.net subdomain) in step 3 with the WEBSITE_LOAD_CERTIFICATES setting? I just can not get past my dev website throwing a startup error saying that 'A certificate with the thumbprint 'xxx' could not be found', although this works perfectly fine locally against IIS Express and it's certificate. I was hoping to test it on a dev website before moving over to production and using my real SSL cert, but I am almost out of ideas at this point – Aaron Oct 27 '16 at 16:22
  • @Aaron Sounds like maybe you didn't upload your certificate to your azure account maybe? That's the only thing that springs to mind that would cause the error that you describe. – Yellowfive Dec 04 '16 at 09:17
  • I definitely did upload it. For some reason, the generic Azure certificate thumbprint, as well as my self signed one did not work. Thankfully however, it it did work just fine in production using my actual certificate, so all is good. No idea why it was not working in development. – Aaron Dec 05 '16 at 22:07
  • 1
    Hey @Yellowfive, cheers for this - just wondering if you know what happens when your SSL cert expires/is renewed - would all existing auth cookies become invalid at this point? – Matt Roberts Oct 23 '17 at 14:39
  • As long as you don't need to rekey the certificate, I don't think the cookies would become invalid. I'd have to try it though to be certain... don't think my current certificate has come up for renewal since implementing this. – Yellowfive Oct 24 '17 at 17:23
10

I tried to piece together a number of articles include the one here into a complete solution. Here is what I came up with. Original blog post: http://intellitect.com/staying-logged-across-azure-app-service-swap/

// Add Data Protection so that cookies don't get invalidated when swapping slots.
string storageUrl = Configuration.GetValue<string>("DataProtection:StorageUrl");
string sasToken = Configuration.GetValue<string>("DataProtection:SasToken");
string containerName = Configuration.GetValue<string>("DataProtection:ContainerName");
string applicationName = Configuration.GetValue<string>("DataProtection:ApplicationName");
string blobName = Configuration.GetValue<string>("DataProtection:BlobName");

// If we have values for all these things set up the data protection store in Azure.
if (storageUrl != null && sasToken != null && containerName != null && applicationName != null && blobName != null)
{
    // Create the new Storage URI
    Uri storageUri = new Uri($"{storageUrl}{sasToken}");

    //Create the blob client object.
    CloudBlobClient blobClient = new CloudBlobClient(storageUri);

    //Get a reference to a container to use for the sample code, and create it if it does not exist.
    CloudBlobContainer container = blobClient.GetContainerReference(containerName);
    container.CreateIfNotExists();

    services.AddDataProtection()
        .SetApplicationName(applicationName)
        .PersistKeysToAzureBlobStorage(container, blobName);
}

Here is a sample appsettings.json if they are stored that way.

{
  "DataProtection": {
    "ApplicationName": "AppName",
    "StorageUrl": "https://BlobName.blob.core.windows.net",
    "SasToken": "?sv=YYYY-MM-DD&ss=x&srt=xxx&sp=xxxxxx&se=YYYY-MM-DDTHH:MM:SSZ&st=YYYY-MM-DDTHH:MM:SSZ&sip=a.b.c.d-w.x.y.z&spr=https&sig=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "ContainerName": "data-protection-container-name", // All lower case with dashes and numbers.
    "BlobName": "data-protection-blob-name"
  }
}
EricksonG
  • 472
  • 4
  • 10
  • 1
    Since the key that mints user sessions is rather sensitive, as an additional layer of security to mitigate blob storage compromise, I recommend using `ProtectKeysWithAzureKeyVault`, as in: `services.AddDataProtection().PersistKeysToAzureBlobStorage(...).ProtectKeysWithAzureKeyVault(...)` – Michael Kropat Jul 17 '18 at 19:25
2

I updated EricksonG's answer to include some new libraries. This solves the problem and it's quick to implement.

You will need these packages:

 Microsoft.AspNetCore.DataProtection
 Azure.Extensions.AspNetCore.DataProtection.Blobs
 Azure.Extensions.AspNetCore.DataProtection.Keys

Here's my version of the code.

var connectionString = configuration["DataProtection:ConnectionString"];
var containerName = configuration["DataProtection:ContainerName"];
var applicationName = configuration["DataProtection:ApplicationName"];
var blobName = configuration["DataProtection:BlobName"];

services.AddDataProtection()
        .SetApplicationName(applicationName)  // This is optional. See below.
        .PersistKeysToAzureBlobStorage(connectionString, containerName, blobName);

You might need to make sure the container exists, however this will definitely handle creating blob and the keys automatically. You never even have to see them.

Also: docs suggests this should go in your config before anything else that might be doing auth, because those tools sometimes have their own take on Data Protection.

AppSettings:

{
  "DataProtection": {
    "ApplicationName": "AppName",  // Can technically be anything. See below for details on this.
    "ConnectionString": "<Your key from azure>",
    "ContainerName": "data-protection", // All lower case with dashes and numbers. There's no need to change this, but you can.
    "BlobName": "data-protection-keys" // Same. 
  }
}

Protection: As-is, this is not all that secure. Get it working, and then if you need more security, take this further by doing Key Vault protection via .ProtectKeysWithAzureKeyVault(), or one of the other extension methods. It would also be a good idea to use KeyVault for the connection string, of course.

Application Name: The .SetApplicationName() line is optional; it helps isolate or share data between apps. From the docs: by default, the Data Protection system isolates apps from one another based on their content root paths, even if they're sharing the same physical key repository. This prevents the apps from understanding each other's protected payloads.

More: https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview?view=aspnetcore-5.0

Brian MacKay
  • 31,133
  • 17
  • 86
  • 125
  • I implemented this and I am still getting logged out whenever I swap staging to prod. Is the somewhere in the middleware order that it needs to go? I put it near the top before the adddbcontext and adddefaultidentity services. My app is .Net Core 3.1 in case that matters. – Energy CJ Oct 22 '21 at 16:43
  • @EnergyCJ Make sure you have deployed it to both slots, of course. I would experiment with putting it in there as early as possible to see if that makes a difference. – Brian MacKay Oct 22 '21 at 20:39
  • I had to add the container manually; otherwise an exception was thrown indicating that it was not found. Blob and keys were created automatically as noted. – mcb2k3 Jan 27 '22 at 03:53
0

Thanks Brian. I have this working now. I think it required both this and the machine key in the web configuration file. I say I think because after I swap slots and then click the browse button I still see my register and login buttons instead of the profile info but as soon as I navigate to another page I see the profile info. I appreciate the response.

Energy CJ
  • 125
  • 2
  • 11
0

I tried this, but it still makes a new login when I swap slots. And user will potentially loose what they were doing.

I can see the blob data-protection-key is made fine in the storage account.

I'm using openidconnect and have a setup like this for authentication (just a PoC made from newest asp.net core web app template):

var initialScopes = builder.Configuration["DownstreamApi:Scopes"]?.Split(' ') ?? builder.Configuration["MicrosoftGraph:Scopes"]?.Split(' ');

var aadSection = builder.Configuration.GetSection("AzureAd");

 var connectionString = builder.Configuration["DataProtection:ConnectionString"];
    var containerName = builder.Configuration["DataProtection:ContainerName"];
    var applicationName = builder.Configuration["DataProtection:ApplicationName"];
    var blobName = builder.Configuration["DataProtection:BlobName"];

    builder.Services.AddDataProtection()
        .SetApplicationName(applicationName)  // This is optional. See below.
        .PersistKeysToAzureBlobStorage(connectionString, containerName, blobName);

    // Add services to the container.
    builder.Services.AddAuthentication(options =>
           {
               options.DefaultAuthenticateScheme = OpenIdConnectDefaults.AuthenticationScheme;
               options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
               options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
           })
           .AddMicrosoftIdentityWebApp(identityOptions =>
                                       {
                                           identityOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                                           identityOptions.Domain = aadSection.GetValue<string>("Domain");
                                           identityOptions.TenantId = aadSection.GetValue<string>("TenantId");
                                           identityOptions.ClientId = aadSection.GetValue<string>("ClientId");
                                           identityOptions.CallbackPath = aadSection.GetValue<string>("CallbackPath");
                                           identityOptions.Instance = aadSection.GetValue<string>("Instance");
                                           identityOptions.ClientSecret = aadSection.GetValue<string>("ClientSecret");

                                       },
                                       options =>
                                       {
                                           options.Cookie.Name = ".AspNet.SharedCookie";
                                           options.DataProtectionProvider = builder.Services.BuildServiceProvider().GetRequiredService<IDataProtectionProvider>(); ;
                                           // Additional cookie options if needed
                                       }
                                      )

           .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
           .AddMicrosoftGraph(builder.Configuration.GetSection("MicrosoftGraph"))
           .AddInMemoryTokenCaches();

Am I missing something?

Morten_564834
  • 1,479
  • 3
  • 15
  • 26