Archive for June, 2011

June 26, 2011

Facebook Connect Authentication injected into ASP.NET MVC 3 Forms Authentication

I was recently admiring the simplicity of the PHP example on how to implement Server-side Authentication using OAuth 2.0 on facebook.com. You can read the full document here: http://developers.facebook.com/docs/authentication/

The code below assumes that you’ve read the entire “Server-side Flow” section and have made some attempt to understand it.

I used Visual Studio 2010 and generated a new project of type “ASP.NET MVC 3 Web Application”, on the next step I choose Internet Application (once you’ve mastered the code below you’ll also be able to do this starting with Empty).

Next you can do some cleanup:

Delete Controllers/AccountController.cs

Delete Views/Account

In web.config you can delete the sections <membership>, <profile> and<roleManager>. You can also delete the <connectionStrings> section (or at least the one with the name “ApplicationServices”).

The reason for all this is that we won’t be using any of the built-in profile/membership/roles providers or the database connection string they rely upon.

Update the <authentication> node to look like this:

<authentication mode="Forms">
<!-- we don't want the expiration to slide, because we respect the Facebook token expiration settings -->
<forms loginUrl="~/Facebook/Login" slidingExpiration="false" />
</authentication>

Create a fake domain in your C:\Windows\System32\drivers\etc\hosts file (make sure you use this domain when you setup your Facebook application). For the sake of discussion I am going to use dev.somefakedomain.com. All you have to do is add this line to the file (you can edit it via Notepad – make sure you launch Notepad with Administrator privileges):

127.0.0.1 dev.somefakedomain.com

Modify your project settings to look like this image:

project settings

The port can be anything you like, as long as it’s not used by another application on your machine (I use whatever port Visual Studio assigned).

Add a new controller. I called it Facebook (if you don’t call it “Facebook”, don’t forget to change the loginUrl part of the <forms> tag in web.config).

Replace the entire body of your controller with this code:

using System;
using System.Collections.Generic;
using System.Web.Mvc;
using System.Security.Cryptography;
using System.Net;
using System.IO;
using System.Web.Script.Serialization;
using System.Web.Security;

namespace FacebookLogin.Controllers
{
    public class FacebookController : Controller
    {
        // these really should be in a config file:
        private string appId = "getyourown!";
        private string appSecret = "lookup your own!";
        /*
         * This is important. Using the default 'localhost' for debugging will fail,
         * because of problems with writing cookies and because Facebook requires a real
         * domain when you setup your application. What you have to do is create a 'fake'
         * domain by editing your C:\Windows\System32\drivers\etc\hosts file.
         * Google how to do this */
        private string authorizeUrl = "http://dev.somefakedomain.com:2064/Facebook/Login";

        public ActionResult Login()
        {
            try
            {
                string error = Request["error"];
                if (!String.IsNullOrEmpty(error))
                {
                    ViewBag.ErrorMessage = Request["error_description"];
                }
                else
                {
                    string code = Request["code"];
                    // when we get redirected here by Forms Authentication we'll
                    // have a ReturnUrl indicating the page we came from
                    string returnUrl = Request.QueryString["ReturnUrl"];

                    if (String.IsNullOrEmpty(code))
                    {
                        // CSRF protection
                        var hashBytes = new MD5CryptoServiceProvider().ComputeHash(Guid.NewGuid().ToByteArray());
                        string state = Convert.ToBase64String(hashBytes);
                        Session["state"] = state;

                        // add the return Url to the state parameter so Facebook can send it all back to us
                        if (!String.IsNullOrEmpty(returnUrl))
                        {
                            state += returnUrl;
                        }

                        // good programmers encode strings before passing them to a query string
                        state = Url.Encode(state);

                        // don't forget to change the "scope" values listed below
                        // to something appropriate for your facebook application
                        // facebook warns not to get greedy as users may deny access
                        string redirectUrl = "http://www.facebook.com/dialog/oauth?client_id=" + appId +
                                             "&redirect_uri=" +
                                             Url.Encode(authorizeUrl) +
                                             "&scope=publish_stream&state=" +
                                             state;
                        return Redirect(redirectUrl);
                    }

                    string sessionState = Convert.ToString(Session["state"]);
                    string requestState = Request.QueryString["state"];

                    if (!String.IsNullOrEmpty(requestState) &&
                        !String.IsNullOrEmpty(sessionState) &&
                        requestState.Length >= sessionState.Length &&
                        requestState.Substring(0, sessionState.Length) == sessionState)
                    {
                        string tokenUrl = "https://graph.facebook.com/oauth/access_token?client_id=" + appId +
                                          "&redirect_uri=" + Url.Encode(authorizeUrl) +
                                          "&client_secret=" + appSecret +
                                          "&code=" + code;
                        string response = GetPageContent(tokenUrl);

                        var responseDictionary = ParseQueryString(response);
                        if (responseDictionary.ContainsKey("access_token"))
                        {
                            // note: you don't HAVE to respect this, as long as you
                            // get a new token before trying to use the FB API
                            double facebookTokenExpirationSeconds =
                                Convert.ToDouble(responseDictionary["expires"]);

                            // Note: you may want to store responseDictionary["access_token"] and
                            // facebookTokenExpirationSeconds somewhere so you can use it for other FB API requests
                            string graphUrl = "https://graph.facebook.com/me?access_token=" +
                                              responseDictionary["access_token"];

                            var serializer = new JavaScriptSerializer();
                            dynamic facebookUser = serializer.DeserializeObject(GetPageContent(graphUrl));

                            // grab facebook name and Id to use as our forms authentication ticket
                            string facebookName = facebookUser["name"];
                            long facebookUserId = Convert.ToInt64(facebookUser["id"]);

                            // get the cookie the way forms authentication would put it together.
                            //Note: I am using the facebookUserId as the "username" as it's guaranteed to be unique
                            var authCookie = FormsAuthentication.GetAuthCookie(facebookUserId.ToString(), true);
                            var ticket = FormsAuthentication.Decrypt(authCookie.Value);
                            // we want to change the expiration of our forms authentication cookie
                            // to match the token expiration date, but you can also use your own expiration
                            DateTime expiration = ticket.IssueDate.AddSeconds(facebookTokenExpirationSeconds);
                            var newTicket = new FormsAuthenticationTicket(ticket.Version, ticket.Name,
                                ticket.IssueDate, expiration, ticket.IsPersistent, facebookName);

                            // encrypt the cookie again
                            authCookie.Value = FormsAuthentication.Encrypt(newTicket);

                            // manually set it (instead of calling FormsAuthentication.SetAuthCookie)
                            Response.Cookies.Add(authCookie);

                            // If we added the Redirect Url to our 'state', grab it and redirect
                            if (requestState.Length > sessionState.Length)
                            {
                                returnUrl = requestState.Substring(sessionState.Length);
                                if (!String.IsNullOrEmpty(returnUrl))
                                {
                                    return Redirect(returnUrl);
                                }
                            }

                            // otherwise redirect back to Home or whatever your default page is
                            return RedirectToAction("Index", "Home");
                        }
                        else
                        {
                            ViewBag.ErrorMessage = "Facebook authorization replied with this invalid response: " +
                                response;
                        }

                    }
                    else
                    {
                        ViewBag.ErrorMessage =
                            "There is a problem with the redirect from Facebook. You may be a victim of CSRF.";
                    }

                }
            }
            catch (Exception ex)
            {
                ViewBag.ErrorMessage = "Login failed with this exception: " + ex.Message;
            }

            return View();
        }

        public ActionResult Logout()
        {
            FormsAuthentication.SignOut();
            return RedirectToAction("Index", "Home");
        }

        private static string GetPageContent(string url)
        {
            var request = WebRequest.Create(url);
            var reader = new StreamReader(request.GetResponse().GetResponseStream());
            return reader.ReadToEnd();
        }

        private static IDictionary ParseQueryString(string query)
        {
            var result = new Dictionary();

            // if string is null, empty or whitespace
            if (string.IsNullOrEmpty(query) || query.Trim().Length == 0)
            {
                return result;
            }

            foreach (var pair in query.Split("&".ToCharArray()))
            {
                if (!String.IsNullOrEmpty(pair))
                {
                    var pairParts = pair.Split("=".ToCharArray());
                    if (pairParts.Length == 2)
                    {
                        result.Add(pairParts[0], pairParts[1]);
                    }
                }
            }

            return result;
        }
    }
}

Add a Login view and replace it with code:

@{
    ViewBag.Title = "Facebook Login Error";
}

@if (!String.IsNullOrEmpty(ViewBag.ErrorMessage))
{

Error: @ViewBag.ErrorMessage

}
else
{

Facebook login encountered an error without a description.

}

Replace the contents of _LogOnPartial.cshtml with this code:

@if (Request.IsAuthenticated)
{
    string facebookName = "BLANK";
    FormsIdentity ident = User.Identity as FormsIdentity;

    if (ident != null)
    {
        FormsAuthenticationTicket ticket = ident.Ticket;
        facebookName = ticket.UserData;
    }

    Welcome <strong>@facebookName</strong>!
    [ @Html.ActionLink("Logout", "Logout", "Facebook") ]
}
else
{
    @:[ @Html.ActionLink("Login", "Login", "Facebook") ]
}

Now that you’ve done this you can use the built-in [Authorize] tag on any controller method and the user will get sent to your Facebook/Login controller. The sky is the limit from here.

You could also use the Facebook SDK on codeplex, which provides a richer programming model and more functionality, but if you want to understand what’s happening behing the scenes, this gives you a good idea of the handshake required for successful Facebook authentication.

Note that this example uses the built-in Session object, which will fail on a web server farm. You have a few options:

1) You can change the code that uses Session to rely on cookies instead

2) You can use a session provider that relies on a database or a separate app server

3) You can get rid of that code if you don’t care about CSRF

If there is interest, I will post a working project example.