Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to use OpenID providers with unique identifier URLs in ASP.NET MVC4

The new SimpleMembershipProvider implemented in ASP.NET MVC4 allows easy, built-in support for two popular OpenID providers (Google and Yahoo) and three OAuth providers (Microsoft, Facebook, Twitter).

The providers implemented in DotNetOpenAuth.AspNet.Clients for use with the SimpleMembershipProvider all use static URLs for their identity services --- that is, all users use the same well-known URL to access the provider. The users' OpenID identifiers are separate from the URL used to access the identity service.

For example, Google's OpenID service URL is https://www.google.com/accounts/o8/id for all users.

This works with the SimpleMembershipProvider in MVC4, where the URL of the identity provider needs to be known, constant, and registered at the time your MVC app starts up.

The problem is, other OpenID providers commonly use the user's unique OpenID identifier as the URL to access the identity service.

For example, AOL and WordPress use https://openid.aol.com/{username} and https://{username}.wordpress.com, respectively.

If you replace the SimpleMembershipProvider with your own implementation of an ExtendedMembershipProvider, then you can roll your own provider implementations, but then it doesn’t work with the MVC4 Account controller out-of-the-box.

How does one implement a new OpenID Relying Party using the SimpleMembershipProvider, when the provider uses unique identifiers with the username in the URL?

like image 256
nekno Avatar asked Oct 14 '12 01:10

nekno


1 Answers

I’ve developed the following solution that works for me, and I’m sharing in case it will help others, but I would really like to see if there’s a more direct method or “best practice” that I’m missing.

Basically, you need to implement an OpenIdClient that is initialized with a ProviderIdentifier that has a URL containing the keyword __username__.

At runtime, the provider name and user name are passed to the Account controller, where the provider client is selected by name, and the user name is substituted for the __username__ keyword before the authentication request is sent to the provider.


The OpenID Client

The DotNetOpenAuth OpenID provider classes contributed by Microsoft inherit the base class DotNetOpenAuth.AspNet.Clients.OpenIdClient, which implements the IAuthenticationClient interface required for OpenID provider classes. Starting with the source for the Google provider because it has a straight-forward implementation, customize it to make a GenericOpenIdClient class that works with providers using custom URLs.

To create the custom URL at runtime, we’ll accept the OpenID user name as a URI fragment, and replace all instances of __username__ in the URL with the user name submitted by the user. Providers need to be registered with URLs during application startup, so we can't just register a provider URL at runtime when the user name is known.

We'll use OpenID Selector to submit a form to our Account controller’s ExternalLogin action with the provider form value set to the provider name and username in the format provider;{username}. OpenId Selector has logic built in to substitute all instances of {username} with input from a textbox presented to the user. On the server side, we’ll split the provider name from the username, look up the provider by name from those registered at application startup, and set the GenericOpenIdClient.UserName property to the username submitted by the user.

When the authentication request is created to send to the OpenID provider, we’ll check the GenericOpenIdClient.UserName property, and if set, we’ll recreate the provider URL using the username before sending the request. In order to do so, we need to override the RequestAuthentication() method to create the authentication request with our custom URL. __username__ is used instead of {username} here because { and } aren't valid characters for a hostname, so creating URLs including them becomes problematic when we need to register them as generic provider identifiers.

/GenericOpenIdClient.cs

namespace DotNetOpenAuth.AspNet.Clients
{
    using System;
    using System.Collections.Generic;
    using System.Web;
    using System.Xml.Linq;
    using DotNetOpenAuth.OpenId;
    using DotNetOpenAuth.OpenId.Extensions.AttributeExchange;
    using DotNetOpenAuth.OpenId.RelyingParty;

    public class GenericOpenIdClient : OpenIdClient
    {
        #region Constants and Fields

        /// <summary>
        /// The openid relying party.
        /// </summary>
        /// <remarks>
        /// Pass null as applicationStore to specify dumb mode. Create a protected field to use internally; we can't access the private base class field.
        /// </remarks>
        protected static readonly OpenIdRelyingParty RelyingParty = new OpenIdRelyingParty(applicationStore: null);

        /// <summary>
        /// The provider identifier.
        /// </summary>
        /// <remarks>
        /// Create a protected field to use internally; we can't access the private base class field.
        /// </remarks>
        protected readonly Identifier providerIdentifier;

        #endregion

        #region Constructors and Destructors

        public GenericOpenIdClient(string providerName, Identifier providerIdentifier)
            : base(providerName, providerIdentifier) 
        {
            this.providerIdentifier = providerIdentifier; // initialize our internal field as well
        }

        #endregion

        #region Public Properties

        public String UserName { get; set; }

        #endregion

        #region Protected Properties

        /// <summary>
        /// The provider Identifier with the "__username__" keyword replaced with the value of the UserName property.
        /// </summary>
        protected Identifier ProviderIdentifier
        {
            get
            {
                var customIdentifier = String.IsNullOrWhiteSpace(this.UserName) ?
                    this.providerIdentifier :
                    Identifier.Parse(HttpUtility.UrlDecode(this.providerIdentifier).Replace("__username__", this.UserName));
                return customIdentifier;
            }
        }

        #endregion

        #region Methods

        /// <summary>
        /// Gets the extra data obtained from the response message when authentication is successful.
        /// </summary>
        /// <param name="response">
        /// The response message. 
        /// </param>
        /// <returns>A dictionary of profile data; or null if no data is available.</returns>
        protected override Dictionary<string, string> GetExtraData(IAuthenticationResponse response)
        {
            FetchResponse fetchResponse = response.GetExtension<FetchResponse>();
            if (fetchResponse != null)
            {
                var extraData = new Dictionary<string, string>();
                extraData.AddItemIfNotEmpty("email", fetchResponse.GetAttributeValue(WellKnownAttributes.Contact.Email));
                extraData.AddItemIfNotEmpty("country", fetchResponse.GetAttributeValue(WellKnownAttributes.Contact.HomeAddress.Country));
                extraData.AddItemIfNotEmpty("firstName", fetchResponse.GetAttributeValue(WellKnownAttributes.Name.First));
                extraData.AddItemIfNotEmpty("lastName", fetchResponse.GetAttributeValue(WellKnownAttributes.Name.Last));

                return extraData;
            }

            return null;
        }

        public override void RequestAuthentication(HttpContextBase context, Uri returnUrl)
        {
            var realm = new Realm(returnUrl.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped));
            IAuthenticationRequest request = RelyingParty.CreateRequest(ProviderIdentifier, realm, returnUrl);

            // give subclasses a chance to modify request message, e.g. add extension attributes, etc.
            this.OnBeforeSendingAuthenticationRequest(request);

            request.RedirectToProvider();
        }

        /// <summary>
        /// Called just before the authentication request is sent to service provider.
        /// </summary>
        /// <param name="request">
        /// The request. 
        /// </param>
        protected override void OnBeforeSendingAuthenticationRequest(IAuthenticationRequest request)
        {
            // Attribute Exchange extensions
            var fetchRequest = new FetchRequest();
            fetchRequest.Attributes.AddRequired(WellKnownAttributes.Contact.Email);
            fetchRequest.Attributes.AddOptional(WellKnownAttributes.Contact.HomeAddress.Country);
            fetchRequest.Attributes.AddRequired(WellKnownAttributes.Name.First);
            fetchRequest.Attributes.AddRequired(WellKnownAttributes.Name.Last);

            request.AddExtension(fetchRequest);
        }

        #endregion
    }

    /// <summary>
    /// The dictionary extensions.
    /// </summary>
    internal static class DictionaryExtensions
    {
        /// <summary>
        /// Adds the value from an XDocument with the specified element name if it's not empty.
        /// </summary>
        /// <param name="dictionary">
        /// The dictionary. 
        /// </param>
        /// <param name="document">
        /// The document. 
        /// </param>
        /// <param name="elementName">
        /// Name of the element. 
        /// </param>
        public static void AddDataIfNotEmpty(
            this Dictionary<string, string> dictionary, XDocument document, string elementName)
        {
            var element = document.Root.Element(elementName);
            if (element != null)
            {
                dictionary.AddItemIfNotEmpty(elementName, element.Value);
            }
        }

        /// <summary>
        /// Adds a key/value pair to the specified dictionary if the value is not null or empty.
        /// </summary>
        /// <param name="dictionary">
        /// The dictionary. 
        /// </param>
        /// <param name="key">
        /// The key. 
        /// </param>
        /// <param name="value">
        /// The value. 
        /// </param>
        public static void AddItemIfNotEmpty(this IDictionary<string, string> dictionary, string key, string value)
        {
            if (key == null)
            {
                throw new ArgumentNullException("key");
            }

            if (!string.IsNullOrEmpty(value))
            {
                dictionary[key] = value;
            }
        }
    }
}

To register the providers built into the new DotNetOpenAuth classes contributed by Microsoft, uncomment the existing Microsoft, Facebook, Twitter, and Google providers, and add a call to register the built-in Yahoo provider. The OpenID providers we’re about to implement don’t need keys, but you’ll need to obtain keys from the OAuth providers (Microsoft, Facebook, and Twitter) if you want to use them. The rest of the providers available in the OpenID Selector package can be added to your liking.

/App_Start/AuthConfig.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using DotNetOpenAuth.AspNet.Clients;
using DotNetOpenAuth.OpenId.RelyingParty;
using Microsoft.Web.WebPages.OAuth;
using Mvc4ApplicationOpenAuth.Models;

namespace Mvc4ApplicationOpenAuth
{
    public static class AuthConfig
    {
        public static void RegisterAuth()
        {
            // To let users of this site log in using their accounts from other sites such as Microsoft, Facebook, and Twitter,
            // you must update this site. For more information visit http://go.microsoft.com/fwlink/?LinkID=252166

            //OAuthWebSecurity.RegisterMicrosoftClient(
            //    clientId: "",
            //    clientSecret: "");

            //OAuthWebSecurity.RegisterTwitterClient(
            //    consumerKey: "",
            //    consumerSecret: "");

            //OAuthWebSecurity.RegisterFacebookClient(
            //    appId: "",
            //    appSecret: "");

            OAuthWebSecurity.RegisterGoogleClient();
            OAuthWebSecurity.RegisterYahooClient();
            OAuthWebSecurity.RegisterClient(new GenericOpenIdClient("Aol", "https://openid.aol.com/__username__"), "Aol", new Dictionary());
            OAuthWebSecurity.RegisterClient(new GenericOpenIdClient("LiveJournal", "https://__username__.livejournal.com/"), "LiveJournal", new Dictionary());
            OAuthWebSecurity.RegisterClient(new GenericOpenIdClient("WordPress", "https://__username__.wordpress.com/"), "WordPress", new Dictionary());
            OAuthWebSecurity.RegisterClient(new GenericOpenIdClient("Blogger", "https://__username__.blogspot.com/"), "Blogger", new Dictionary());
            OAuthWebSecurity.RegisterClient(new GenericOpenIdClient("VeriSign", "https://__username__.pip.verisignlabs.com/"), "VeriSign", new Dictionary());
            OAuthWebSecurity.RegisterClient(new GenericOpenIdClient("ClaimID", "https://claimid.com/__username__"), "ClaimID", new Dictionary());
            OAuthWebSecurity.RegisterClient(new GenericOpenIdClient("ClickPass", "https://clickpass.com/public/__username__"), "ClickPass", new Dictionary());
            OAuthWebSecurity.RegisterClient(new GenericOpenIdClient("Google Profile", "https://www.google.com/profiles/__username__"), "Google Profile", new Dictionary());
            OAuthWebSecurity.RegisterClient(new GenericOpenIdClient("MyOpenID", "https://__username__.myopenid.com/"), "MyOpenID", new Dictionary());
        }
    }
}

Last, we need parse the provider form value submitted to the Account controller’s ExternalLogin action by OpenID Selector to check for the “;” delimiter indicating a username is present. If so, then we parse out the provider name and username.

/Controllers/AccountController.cs

[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public ActionResult ExternalLogin(string provider, string returnUrl)
{
    if (provider.Contains(';'))
    {
        string[] providerParts = provider.Split(';');
        if (providerParts.Length == 2)
        {
            AuthenticationClientData clientData;
            if (OAuthWebSecurity.TryGetOAuthClientData(providerParts[0], out clientData))
            {
                var genericClient = clientData.AuthenticationClient as GenericOpenIdClient;
                if (genericClient != null)
                {
                    provider = providerParts[0];
                    genericClient.UserName = providerParts[1];
                }
            }
        }
    }

    return new ExternalLoginResult(provider, Url.Action("ExternalLoginCallback", new { ReturnUrl = returnUrl }));
}

The UI

The UI implementation is made much easier with the open-source OpenID Selector. Download OpenID Selector and customize it for use with the OAuthWebSecurity classes.

  1. Create a new openid folder in your web app at: /Content/openid
  2. Copy the css, images, images.large, and images.small folders from the openid-selector download to the /Content/openid folder, then include the files in your project.
  3. From the openid-selector download's js folder, copy openid-jquery.js and openid-en.js to your web app's /Scripts folder, then include the files your project.
  4. Open the openid-en.js file and customize it so the provider URLs are the provider names you will add in your AuthConfig.cs file. For providers with custom URLs, use the format Provider;{username}:

/Scripts/openid-en.js

var providers_large = {
    google : {
        name : 'Google',
        url : 'Google'
    },
    facebook : {
        name : 'Facebook',
        url : 'Facebook',
    },
    twitter: {
        name: 'Twitter',
        url: 'Twitter'
    },
    microsoft : {
        name : 'Microsoft',
        url : 'Microsoft'
    },
    yahoo : {
        name : 'Yahoo',
        url : 'Yahoo'
    },
    aol : {
        name : 'Aol',
        label : 'Enter your Aol screenname.',
        url : 'Aol;{username}'
    }
};

var providers_small = {
    livejournal: {
        name : 'LiveJournal',
        label : 'Enter your Livejournal username.',
        url: 'LiveJournal;{username}'
    },
    wordpress : {
        name : 'WordPress',
        label : 'Enter your WordPress.com username.',
        url: 'WordPress;{username}'
    },
    blogger : {
        name : 'Blogger',
        label : 'Your Blogger account',
        url: 'Blogger;{username}'
    },
    verisign : {
        name : 'VeriSign',
        label : 'Your VeriSign username',
        url: 'VeriSign;{username}'
    },
    claimid : {
        name : 'ClaimID',
        label : 'Your ClaimID username',
        url: 'ClaimID;{username}'
    },
    clickpass : {
        name : 'ClickPass',
        label : 'Enter your ClickPass username',
        url: 'ClickPass;{username}'
    },
    google_profile : {
        name : 'Google Profile',
        label : 'Enter your Google Profile username',
        url: 'Google Profile;{username}'
    },
    myopenid: {
        name: 'MyOpenID',
        label: 'Enter your MyOpenID username.',
        url: 'MyOpenID;{username}'
    }
};

openid.locale = 'en';
openid.sprite = 'en'; // reused in german& japan localization
openid.demo_text = 'In client demo mode. Normally would have submitted OpenID:';
openid.signin_text = 'Log in';
openid.image_title = 'Log in with {provider}';
openid.no_sprite = true;
openid.img_path = '/Content/openid/images/';

OpenID Selector doesn’t come with images for Microsoft or Twitter, so download your favorite Microsoft and Twitter (blue on white) logos, convert them to GIFs at 100x60 pixels, then drop them in the /Content/openid/images.large folder. Read the instructions in the OpenID Selector README.txt file if you want to use a single sprite image instead if separate images. Set openid.no_sprite = false; in openid-en.js if you use the sprite.

Register the JS and CSS files as a new bundle. Open /App_Start/BundleConfig.cs and add the following script and style bundles in the RegisterBundles() method.

/App_Start/BundleConfig.cs

bundles.Add(new ScriptBundle("~/bundles/openid").Include(
    "~/Scripts/openid-jquery.js",
    "~/Scripts/openid-en.js"));

bundles.Add(new StyleBundle("~/Content/css/openid").Include("~/Content/openid/css/openid-shadow.css"));

I prefer the OpenID Selector's "shadow" style, so I elected use just the openid-shadow.css CSS file and customized the following classes to work in the MVC4 Login template.

/Content/css/openid/openid-shadow.css

/*#openid_form {
    width: 590px;
}*/

#openid_highlight {
    padding: 0px;
    background-color: #FFFCC9;
    float: left;
    border-radius: 5px; 
    -moz-border-radius: 5px;
    -webkit-border-radius: 5px;
}

.openid_large_btn {
    width: 100px;
    height: 60px;
/* fix for IE 6 only: http://en.wikipedia.org/wiki/CSS_filter#Underscore_hack */
    _width: 104px;
    _height: 64px;

    border: 2px solid #DDD;
    border-right: 2px solid #ccc;
    border-bottom: 2px solid #ccc;
    margin: 3px;
    padding: 3px;
    float: left;
    border-radius: 5px; 
    -moz-border-radius: 5px;
    -webkit-border-radius: 5px;
    box-shadow: 2px 2px 4px #ddd;
    -moz-box-shadow: 2px 2px 4px #ddd;
    -webkit-box-shadow: 2px 2px 4px #ddd;
}

.openid_large_btn:hover {
    margin: 4px 3px 3px 6px;
    padding: 2px 3px 3px 0px;
    border: 2px solid #999;
    box-shadow: none;
    -moz-box-shadow: none;
    -webkit-box-shadow: none;
}

To create a generic place to add CSS scripts to the page's <head> tag, add a head section at the bottom of the <head> tag.

/Views/Shared/_Layout.cshtml

<head>
    <meta charset="utf-8" />
    <title>@ViewBag.Title - My ASP.NET MVC Application</title>
    <link href="~/favicon.ico" rel="shortcut icon" type="image/x-icon" />
    <meta name="viewport" content="width=device-width" />
    @Styles.Render("~/Content/css")
    @Scripts.Render("~/bundles/modernizr")
    @RenderSection("head", false)
</head>

Then, in the /Views/Account/Login.cshtml file, customize the Login view by adding the OpenID bundles we registered previously to the appropriate sections at the bottom of the page.

/Views/Account/Login.cshtml

<section class="social" id="socialLoginForm">
    @Html.Action("ExternalLoginsList", new { ReturnUrl = ViewBag.ReturnUrl })
</section>

@section Head {        
    @Styles.Render("~/Content/css/openid")
}

@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")
    @Scripts.Render("~/bundles/openid")
    <script type="text/javascript">
        $(function () {
            openid.init('provider');
        });
    </script>
}

The last element of the UI involves replacing the default ExternalLogin form with the OpenID Selector form.

/Views/Account/_ExternalLoginsListPartial.cshtml

using (Html.BeginForm("ExternalLogin", "Account", new { ReturnUrl = ViewBag.ReturnUrl }, FormMethod.Post, new { id = "openid_form" }))
{
    @Html.AntiForgeryToken()
    <input type="hidden" name="action" value="verify" />

    <h2>Use another service to log in.</h2>
    <br />
    <fieldset id="socialLoginList">
        <legend></legend>

        <div id="openid_choice">
            <div id="openid_btns"></div>
        </div>
        <div id="openid_input_area">
            <input id="provider" name="provider" type="text" value="" />
            <input id="openid_submit" type="submit" value="Log in"/>
        </div>
        <noscript>
            <p>OpenID is service that allows you to log-on to many different websites using a single indentity. Find out <a href="http://openid.net/what/">more about OpenID</a> and <a href="http://openid.net/get/">how to get an OpenID enabled account</a>.</p>
        </noscript>
    </fieldset>
}
like image 61
nekno Avatar answered Oct 31 '22 08:10

nekno