I've been working with ASP.NET 4.x and ASP.NET Core for a while, and I've always noticed some unusual differences between them.
The migration process from ASP.NET 4.x to ASP.NET Core can be challenging, depending on how the source code is designed or organized, the size of the application, and the packages used.
I'm going to present a technique for implementing a side-by-side migration process that ensures both applications continue to run.
Let's begin with a simple ASP.NET MVC 4.8 application configured for forms authentication.
Normally, the authentication cookie is created by calling FormsAuthentication.SetAuthCookie(username, rememberMe); after a successful login, and it is destroyed by calling FormsAuthentication.SignOut() upon logout. This setup is supported by a small configuration in the web.config file using the authentication tag.
<system.web>
<compilation debug="true" targetFramework="4.8" />
<httpRuntime targetFramework="4.8" />
<authentication mode="Forms">
<forms loginUrl="~/Account/Login" timeout="2880" requireSSL="true" path="/" />
</authentication>
</system.web>
using Mvc4FormAuthentication.Models;
using System.Web.Mvc;
using System.Web.Security;
namespace Mvc4FormAuthentication.Controllers
{
public class AccountController : Controller
{
public ActionResult Login()
{
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Login(LoginViewModel model, string returnUrl)
{
if (!ModelState.IsValid)
{
return View(model);
}
var isAuthenticated = IsAuthenticateUser(model.Username, model.Password);
if (isAuthenticated)
{
// the AUTH cookie is set here
FormsAuthentication.SetAuthCookie(model.Username, model.RememberMe);
return Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
}
ModelState.AddModelError("", "Invalid username or password.");
return View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Logout()
{
// the AUTH cookie is removed here
FormsAuthentication.SignOut();
return RedirectToAction("Index", "Home");
}
private bool IsAuthenticateUser(string username, string password)
{
return true;
}
}
}
Using browser DevTools, you can locate the ASP.NET authentication cookie generated during the forms authentication login process.
Easy!
However, to share the authentication cookie between the applications, we need to change the way this cookie is generated.
First, remove the web.config configuration. Let's go back to the original code without the authentication tag.
<system.web>
<compilation debug="true" targetFramework="4.8" />
<httpRuntime targetFramework="4.8" />
</system.web>
Now, let's install some OWIN packages.
- Microsoft.Owin.Host.SystemWeb
- Microsoft.Owin.Security.Interop
- Microsoft.Owin.Security.Cookies
Next, create a startup class.
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Owin;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.Interop;
using Owin;
using System;
using System.IO;
[assembly: OwinStartup(typeof(Mvc4OwinAuthentication.Startup))]
namespace Mvc4OwinAuthentication
{
public class Startup
{
const string APPLICATION_NAME = "SharedAuthCookieApp";
const string COOKIE_NAME = ".AspNet.ApplicationAuthCookie";
const string COOKIE_PATH = "/";
const string SHARING_FOLDER_PATH = @"C:\Sources\Tests\DotNet\SharedAuthCookie";
public void Configuration(IAppBuilder app)
{
var loginPath = new PathString("/Account/Login");
var chunkingCookieManager = new ChunkingCookieManager();
var dataProtectionProvider = DataProtectionProvider.Create(
new DirectoryInfo(SHARING_FOLDER_PATH),
builder =>
{
builder.SetApplicationName(APPLICATION_NAME);
}
).CreateProtector(
"Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationMiddleware",
"Cookies",
"v2"
);
var dataProtectorShim = new DataProtectorShim(dataProtectionProvider);
var aspNetTicketDataFormat = new AspNetTicketDataFormat(dataProtectorShim);
var cookieAuthenticationOptions = new CookieAuthenticationOptions
{
// This should match the AuthenticationType you use when creating the ClaimsIdentity ("Cookies")
AuthenticationType = CookieAuthenticationDefaults.AuthenticationType,
CookieHttpOnly = true,
CookieManager = chunkingCookieManager,
CookieName = COOKIE_NAME,
CookiePath = COOKIE_PATH,
CookieSameSite = SameSiteMode.Lax,
ExpireTimeSpan = TimeSpan.FromMinutes(20),
LoginPath = loginPath,
SlidingExpiration = true,
TicketDataFormat = aspNetTicketDataFormat
};
app.UseCookieAuthentication(cookieAuthenticationOptions);
}
}
}
Add a configuration to AntiForgeryToken on Global.asax in the Application_Start.
protected void Application_Start()
{
AntiForgeryConfig.UniqueClaimTypeIdentifier = ClaimTypes.NameIdentifier;
AreaRegistration.RegisterAllAreas();
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
}
And finally, the AccountController should create the auth cookie.
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Mvc4OwinAuthentication.Models;
using System.Collections.Generic;
using System.Security.Claims;
using System.Web;
using System.Web.Mvc;
namespace Mvc4OwinAuthentication.Controllers
{
public class AccountController : Controller
{
public ActionResult Login()
{
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Login(LoginViewModel model, string returnUrl)
{
if (!ModelState.IsValid)
{
return View(model);
}
var user = AuthenticateUser(model.Username, model.Password);
if (user != null)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
};
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationType);
var authManager = HttpContext.GetOwinContext().Authentication;
var authProperties = new AuthenticationProperties
{
IsPersistent = model.RememberMe
};
authManager.SignIn(authProperties, claimsIdentity);
return Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
}
ModelState.AddModelError("", "Invalid username or password.");
return View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Logout()
{
HttpContext.GetOwinContext().Authentication.SignOut(CookieAuthenticationDefaults.AuthenticationType);
return RedirectToAction("Index", "Home");
}
private User AuthenticateUser(string username, string password)
{
return new User(username);
}
}
}
Here's our new cookie.
😎
Let's dive into the ASP.NET Core 9 part.
In Program.cs, configure the authentication to use cookies.
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
namespace MvcCoreAuthentication
{
public class Program
{
const string APPLICATION_NAME = "SharedAuthCookieApp";
const string COOKIE_NAME = ".AspNet.ApplicationAuthCookie";
const string COOKIE_PATH = "/";
const string LOGIN_PATH = "/Account/Login";
const string SHARING_FOLDER_PATH = @"C:\Sources\Tests\DotNet\SharedAuthCookie";
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
#region Cookie Configuration
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(SHARING_FOLDER_PATH))
.SetApplicationName(APPLICATION_NAME);
var chunkingCookieManager = new ChunkingCookieManager();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.Name = COOKIE_NAME;
options.Cookie.Path = COOKIE_PATH;
options.Cookie.SameSite = SameSiteMode.Lax;
options.CookieManager = chunkingCookieManager;
options.ExpireTimeSpan = TimeSpan.FromMinutes(20);
options.LoginPath = LOGIN_PATH;
options.SlidingExpiration = true;
});
#endregion
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication(); // Cookie Configuration - Add this line
app.UseAuthorization();
app.MapStaticAssets();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}")
.WithStaticAssets();
app.Run();
}
}
}
Here's our ASP.NET Core cookie 😉
It's time to see if everything worked.
Create a new controller with an action decorated with the [Authorize] attribute (do this in both applications).
public class PrivateController : Controller
{
[Authorize]
public ActionResult Index()
{
return View();
}
}
Update the _Layout.cshtml file to link the routes in both apps.
ASP.NET 4.8
<ul class="navbar-nav flex-grow-1">
@if (Request.IsAuthenticated)
{
<li>
<a href="#">Hello, @User.Identity.Name!</a>
</li>
<li>
@using (Html.BeginForm("Logout", "Account", FormMethod.Post, new { id = "logoutForm", @class = "navbar-form" }))
{
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-link navbar-btn">Logout</button>
}
</li>
}
else
{
<li>
@Html.ActionLink("Login", "Login", "Account", new { area = "" }, new { @class = "nav-link" })
</li>
}
<li>@Html.ActionLink("Home", "Index", "Home", new { area = "" }, new { @class = "nav-link" })</li>
<li>@Html.ActionLink("Local Private", "Index", "Private", new { area = "" }, new { @class = "nav-link" })</li>
<li>
<a class="nav-link" href="https://localhost:7102/Private">External Private</a>
</li>
</ul>
ASP.NET Core
<ul class="navbar-nav flex-grow-1">
@if (User.Identity.IsAuthenticated)
{
<li class="nav-item">
<span class="navbar-text">Hello, @User.Identity.Name!</span>
</li>
<li class="nav-item">
<form id="logoutForm" asp-controller="Account" asp-action="Logout" method="post" class="form-inline">
@Html.AntiForgeryToken()
<button type="submit" class="nav-link btn btn-link" style="display:inline; padding:0;">Logout</button>
</form>
</li>
}
else
{
<li class="nav-item">
<a class="nav-link" asp-controller="Account" asp-action="Login">Login</a>
</li>
}
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Private" asp-action="Index">Local Private</a>
</li>
<li>
<a class="nav-link text-dark" href="https://localhost:44301/Private">External Private</a>
</li>
</ul>
We're done! ⭐
Bonus
Of course, sharing the DataProtectionKey via the local file system isn't ideal.
There are many remote and distributed alternatives available, but for demonstration purposes, we'll use Valkey/Redis.
Now, let's create and run a container with Valkey to store the data protection key.
docker run -d --rm -p "6379:6379" --name share-valkey valkey/valkey:alpine
(it could be any Valkey/Redis server that you have access)
Back to ASP.NET 4.8 application
Install the package Microsoft.AspNetCore.DataProtection.StackExchangeRedis
And change the Startup class
public class Startup
{
const string APPLICATION_NAME = "SharedAuthCookieApp";
const string COOKIE_NAME = ".AspNet.ApplicationAuthCookie";
const string COOKIE_PATH = "/";
const string LOGIN_PATH = "/Account/Login";
const string SHARING_FOLDER_PATH = @"C:\Sources\Tests\DotNet\SharedAuthCookie";
const string DATA_PROTECTION_KEY_CACHE_NAME = "MY-SHARED-DATA-PROTECTION-KEY";
public void Configuration(IAppBuilder app)
{
var loginPath = new PathString(LOGIN_PATH);
var chunkingCookieManager = new ChunkingCookieManager();
var dataProtectionProvider = DataProtectionProvider.Create(
new DirectoryInfo(SHARING_FOLDER_PATH),
builder =>
{
builder.SetApplicationName(APPLICATION_NAME);
builder.PersistKeysToStackExchangeRedis(ConnectionMultiplexer.Connect("localhost:6379"), DATA_PROTECTION_KEY_CACHE_NAME);
}
).CreateProtector(
"Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationMiddleware",
"Cookies",
"v2"
);
Don't worry about maintaining the DirectoryInfo because the builder won't generate the DataProtectionKey file anymore.
And in the ASP.NET Core application, configure Data Protection to use the same remote key storage.
Install the package Microsoft.AspNetCore.DataProtection.StackExchangeRedis
Change the Program class
public class Program
{
const string APPLICATION_NAME = "SharedAuthCookieApp";
const string COOKIE_NAME = ".AspNet.ApplicationAuthCookie";
const string COOKIE_PATH = "/";
const string LOGIN_PATH = "/Account/Login";
const string DATA_PROTECTION_KEY_CACHE_NAME = "MY-SHARED-DATA-PROTECTION-KEY";
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
#region Cookie Configuration
builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(ConnectionMultiplexer.Connect("localhost:6379"), DATA_PROTECTION_KEY_CACHE_NAME)
.SetApplicationName(APPLICATION_NAME);
Running the applications, we can see that the DataProtectionKey is being used from Valkey/Redis.
😍
That's it!
I know there's a lot of code, but in the end, it's pretty straightforward once you understand how to configure the OWIN cookie.
Let's review some key points.
In ASP.NET 4.8
- Install the following packages:
- Microsoft.Owin.Host.SystemWeb
- Microsoft.Owin.Security.Cookies
- Microsoft.Owin.Security.Interop
- Microsoft.AspNetCore.DataProtection.StackExchangeRedis
- Create the Startup class to configure the cookie settings
- Update the AccountController to properly create and destroy the authentication cookie
- Configure AntiForgeryToken in the Application_Start method of Global.asax
In ASP.NET Core
- Install the package:
- Microsoft.AspNetCore.DataProtection.StackExchangeRedis
- Configure Cookie Authentication:
- Add cookie authentication to the WebApplicationBuilder.
- Call
app.UseAuthentication()
in the pipeline.
- Modify how the Account controller creates and destroys the authentication cookie
Important to note
- The cookie parameters must be the same in both applications.
- Do not expose the DataProtectionKey 🔥
The source code can be found here
Thanks, and keep making awesome code!
Top comments (0)