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

June 27, 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.csDelete Views/AccountIn 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:[sourcecode language="xml"]<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>[/sourcecode]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.comModify 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:[sourcecode language="csharp"]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; } }}[/sourcecode]Add a Login view and replace it with code:[sourcecode language="csharp"]@{ ViewBag.Title = "Facebook Login Error";}@if (!String.IsNullOrEmpty(ViewBag.ErrorMessage)){Error: @ViewBag.ErrorMessage}else{Facebook login encountered an error without a description.}[/sourcecode]Replace the contents of _LogOnPartial.cshtml with this code:[sourcecode language="csharp"]@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") ]}[/sourcecode]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 instead2) You can use a session provider that relies on a database or a separate app server3) You can get rid of that code if you don't care about CSRFIf there is interest, I will post a working project example.