Decoupling OWIN external authentication from ASP.NET Identity

posted by Jeff | Thursday, October 3, 2013, 10:46 PM | comments: 0

One of the nicest features of the forthcoming release for the ASP.NET Web stack is the inclusion of bits around external authentication. It makes it stupid easy to add login capability through Google, Facebook and such. Coupled to this in the default project templates is a tie to the new ASP.NET Identity, which is a replacement for the old (and frankly crappy) Membership API. This new thing uses Entity Framework and is extensible and neat-o.

However, if you're like me, you probably have plenty of projects that already have their own login and user management schemes, so retrofitting Identity into your app is not something you really want to do. This is also true for POP Forums, the open source forum app that I use at the core of my own communities. The real magic happens through OWIN middlewear, and with help from a great StackOverflow answer, I figured out how to avoid creating any dependencies on Microsoft.AspNet.Identity.

(Note: I'm able to get the stuff I checked in on CodePlex to work on one machine, but not on another machine. The AuthenticationResult coming back from the AuthenticationManager is null on the non-working box. Haven't figured that out yet. If anything below is suspect, please let me know.)

First, you might need some context. What is OWIN? It stands for "Open Web Interface for .NET," and is essentially an abstraction of stuff that happens between your app and an HTTP server. There's a great read if you want to get into the weeds, but let me distill it down to something smaller. OWIN is a spec that allows you to run .NET-based Web apps without depending specifically on IIS. It lets you get deep into the raw request/response lifecycle. Middlewear components are added to a collection, and they act on the requests to the Web server (IIS, self-hosted, whatever) and give something back.

The external auth stuff lives as a bunch of these OWIN components. If you look at the magic created for you in a new project in Visual Studio 2013, you'll see a class file under /App_Start called Startup.Auth.cs. It's actually a partial class, tied to one in Startup.cs in the root. That class fires off the registration of components. You'll see a series of commented out extension methods that register the various types of external auth.

The general workflow goes like this: Display external auth buttons on the login page, submit those to an MVC method that handles the forwarding to the appropriate auth provider, take the result that comes back and either login the user or save the auth provider data (the issuer and provider key) into the ASP.NET Identity data store.

My goal was to get the result of the provider and handle the persistence and association with forum accounts myself. My implementation might have something that isn't correct, so feel free to let me know if something ain't right. First I registered my own OWIN startup class. As best I can tell, you can don one of these per assembly. In this case, I'm using the settings already found in the forums to make decisions about what to enable and what values to use:

using System;
using Microsoft.Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Ninject;
using Owin;
using PopForums.Configuration;
using PopForums.ExternalLogin;
using PopForums.Web;

[assembly: OwinStartup(typeof (PopForumsOwinStartup))]
namespace PopForums.Configuration
{
	public class PopForumsOwinStartup
	{
		public void Configuration(IAppBuilder app)
		{
			var settings = PopForumsActivation.Kernel.Get().Current;

			app.SetDefaultSignInAsAuthenticationType(ExternalAuthentication.ExternalCookieName);

			app.UseCookieAuthentication(new CookieAuthenticationOptions
			{
				AuthenticationType = ExternalAuthentication.ExternalCookieName,
				AuthenticationMode = AuthenticationMode.Passive,
				CookieName = CookieAuthenticationDefaults.CookiePrefix + ExternalAuthentication.ExternalCookieName,
				ExpireTimeSpan = TimeSpan.FromMinutes(5),
			});

			if (settings.UseTwitterLogin)
				app.UseTwitterAuthentication(
				   consumerKey: settings.TwitterConsumerKey,
				   consumerSecret: settings.TwitterConsumerSecret);

			if (settings.UseMicrosoftLogin)
				app.UseMicrosoftAccountAuthentication(
					clientId: settings.MicrosoftClientID,
					clientSecret: settings.MicrosoftClientSecret);

			if (settings.UseFacebookLogin)
				app.UseFacebookAuthentication(
				   appId: settings.FacebookAppID,
				   appSecret: settings.FacebookAppSecret);

			if (settings.UseGoogleLogin)
				app.UseGoogleAuthentication();

			app.MapHubs();
		}
	}
}

The most important thing here is that the order of the cookie setup matters. After that, I register the various providers.

Moving on to the login page, my revised controller action looks like this:

public ViewResult Login()
{
	// not relevant stuff

	var externalLoginList = new List(HttpContext.GetOwinContext().Authentication.GetAuthenticationTypes((Func<AuthenticationDescription, bool>) (d =>
		{
			if (d.Properties != null)
			return d.Properties.ContainsKey("Caption");
			return false;
		})));

	return View(externalLoginList);
}

I'm not positive, but I think I actually took this code from an extension method in Microsoft.AspNet.Identity, which seemed like a weird place for it. Since I don't want dependencies on that, I did a copy-paste. It gets the collection of registered auth providers so you can enumerate through them and make buttons. Make buttons I did, again borrowing from the default templates in the tooling.

using (Html.BeginForm("ExternalLogin", "Account", new { ReturnUrl = ViewBag.Referrer }))
{
	@Html.AntiForgeryToken()
	<fieldset id="socialLoginList">
		<legend>Use another service to log in.</legend>
			<p>
			@foreach (AuthenticationDescription p in Model){
				<button type="submit" class="btn" id="@p.AuthenticationType" name="provider" value="@p.AuthenticationType" title="Log in using your @p.Caption account">@p.AuthenticationType</button>
			}
			</p>
	</fieldset>
}

The controller actions are where I started to diverge. Again, this involves some copy-paste from the templates, starting with the ChallengeResult used in the action methods. I won't bore you with those details, though it doesn't hurt to learn more about them, even if you're using the templates in VS2013 with the Identity stuff.

public class ChallengeResult : HttpUnauthorizedResult
{
	public ChallengeResult(string provider, string redirectUrl)
	{
		LoginProvider = provider;
		RedirectUrl = redirectUrl;
	}

	public string LoginProvider { get; set; }
	public string RedirectUrl { get; set; }

	public override void ExecuteResult(ControllerContext context)
	{
		context.HttpContext.GetOwinContext().Authentication.Challenge(new AuthenticationProperties{ RedirectUrl = RedirectUrl }, LoginProvider);
	}
}

// from the AccountController:

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult ExternalLogin(string provider, string returnUrl)
{
	return new ChallengeResult(provider, Url.Action("ExternalLoginCallback", "Account", new { loginProvider = provider, ReturnUrl = returnUrl }));
}

public async Task ExternalLoginCallback(string loginProvider, string returnUrl)
{
	var authentication = OwinContext.Authentication;
	var authResult = await ExternalAuthentication.GetAuthenticationResult(authentication);
	var matchResult = UserAssociationManager.ExternalUserAssociationCheck(authResult);
	if (matchResult.Successful)
	{
		UserService.Login(matchResult.User, HttpContext);
		return Redirect(returnUrl);
	}

	// TODO: offer standard login to associate, or go to create

	return RedirectToAction("Create");
}

There's a point of magic to point out here. The OwinContext mentioned above is an IOwinContext property of the controller, and it's injected in via the constructor. I'm using Ninject (for now... I'm really considering a switch to StructureMap), so my mapping looks like this:

Bind().ToMethod(x => HttpContext.Current.GetOwinContext());

Extension methods are cool, but they make unit testing kind of a pain, so this helps reduce some of that pain.

The callback sends the Authentication property of the IOwinContext to fetch the auth result, and that's passed to an association manager, which essentially checks to see if the Issuer and ProviderKey coming back match any records in the association table that stores those values with user ID's. If yes, it does a login, and if not, it sends you off to the page to create an account. At some point I'll get something in there to offer the user a chance to associate the external login with the forum's native login.

If the rest of this was TL;DR here's the important part. This is the part where you can see what the external provider has, so you can associate that stuff with whatever your user management system uses.

using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.Owin.Security;
using PopForums.Extensions;

namespace PopForums.ExternalLogin
{
	public class ExternalAuthentication : IExternalAuthentication
	{
		public async Task GetAuthenticationResult(IAuthenticationManager authenticationManager)
		{
			var authResult = await authenticationManager.AuthenticateAsync(ExternalCookieName);
			if (!authResult.Identity.IsAuthenticated)
				return null;
			var externalIdentity = authResult.Identity;
			var providerKeyClaim = externalIdentity.FindFirst(ClaimTypes.NameIdentifier);
			var issuer = providerKeyClaim.Issuer;
			var providerKey = providerKeyClaim.Value;
			var name = externalIdentity.FindFirstValue(ClaimTypes.Name);
			var email = externalIdentity.FindFirstValue(ClaimTypes.Email);
			if (String.IsNullOrEmpty(issuer))
				throw new NullReferenceException("The identity claims contain no issuer.");
			if (String.IsNullOrEmpty(providerKey))
				throw new NullReferenceException("The identity claims contain no provider key");
			var result = new ExternalAuthenticationResult
			             {
				             Issuer = issuer,
				             ProviderKey = providerKey,
				             Name = name,
				             Email = email
			             };
			return result;
		}

		public const string ExternalCookieName = "External";
	}
}

The first line is really where we get the data we're looking for. The magic in the OWIN middlewear uses its context to figure out what the heck you just scored in terms of claims and credentials from the external provider. From there you can see how I'm grabbing the Issuer and ProviderKey, the two things you'll want to compare against when doing your own, non Identity framework user management.


Comments

No comments yet.


Post your comment: