miércoles, 16 de marzo de 2011

Autenticación de usuarios contra Facebook y OpenId

Para los usuarios resulta cómodo poder acceder a nuestro sitio web con las mismas credenciales que usan para entrar a sus sitios habituales, es por eso que en nuestro equipo nos planteamos implementar un mecanismo que permitiera entrar en nuestro sitio con su cuenta de Google, Yahoo! o Facebook.
Para Google y Yahoo!  es posible usar OpenId pero con Facebook la técnica cambia. Suena interesante, veamos qué podemos hacer.

Lo primero que haremos es definir un esquema común de autenticación, para esto definimos la interface IAuthenticationProvider como se muestra en esta figura:

IAuthenticationProvider

La interface consta del método Authenticate que es el encargado de gestionar el permiso contra el proveedor (Facebook, Google o Yahoo!)  y de la propiedad ProviderId que nos puede servir para identificar el IAuthenticationProvider contra el que se autentica el usuario en algún repositorio. Las clases FacebookAuthenticationProvider y OpenIdAuthenticationProvider implementan la inerface IAuthenticationProvider para autenticar el usuario contra Facebook y contra Google y Yahoo! respectivamente.

La primera vez que se hace una llamada al método Authenticate de IAuthenticationProvider se retorna un objeto AuthenticationResult con el estado “Initializing” y  la URL a la cual debemos redireccionar al usuario en la propiedad InitializingUrl. En dicha URL el proveedor mostrará una interfaz donde el usuario introducirá sus credenciales, la imagen que sigue es la interfaz de autenticación de Facebook.

FacebookLogin

Una vez que el usuario ha sido autenticado, el proveedor (en este caso Facebook) nos debe redireccionar nuevamente a nuestra aplicación. Esta vez se invoca nuevamente el método Authenticate y ahora debe retornar un objeto de tipo AuthenticationResult con un estado que indique que el usuario ha sido autenticado con éxito (Authenticated) así como los datos del usuario autenticado en la propiedad UserData. Con estos datos podemos registrar al usuario en nuestra aplicación o hacer cualquier otra cosa que deseemos.


La clase AuthenticationResult puede definirse de la siguiente manera:
   1: public class AuthenticationResult
   2: {
   3:     public AuthenticationResultStatus Status { get; set; }
   4:  
   5:     public string ErrorMessage { get; set; }
   6:  
   7:     public string InitializingUrl { get; set; }
   8:  
   9:     public dynamic UserData { get; set; }
  10: }

Toda la lógica descrita anteriormente se puede implementar de manera elegante en un controlador de una aplicación ASP.NET. Veamos el código del método del controlador que implementa este proceso y así los amantes del código podrán entender mejor cómo funciona todo:
   1: public ActionResult Authenticate(string providerName, string returnUrl)
   2: {
   3:     var provider = ServiceLocator
   4:         .GetInstance<IAuthenticationProvider>(providerName); 
   5:     var result = provider.Authenticate();
   6:  
   7:     if (result == null)
   8:         return new EmptyResult();
   9:  
  10:     switch (result.Status)
  11:     {
  12:         case AuthenticationResultStatus.Initializing:
  13:             return Redirect(result.InitializingUrl);
  14:         
  15:         case AuthenticationResultStatus.AccessDinied:
  16:             return new AccessDiniedViewResult
  17:             { 
  18:                 Message = result.ErrorMessage 
  19:             };
  20:  
  21:         case AuthenticationResultStatus.Authenticated:
  22:             IDynamicAdapter<User> adapter = ServiceLocator
  23:                 .GetInstance<IDynamicAdapter<User>>(providerName);
  24:             User possibleUser = adapter.Convert(result.UserData);
  25:             var user = userService.FindByEmail(possibleUser.Email);
  26:             if (user == null)
  27:             {
  28:                 //If no user is found, send to a registration form...
  29:                 var registerModel = new RegisterModel();
  30:                 registerModel.ProviderId = provider.ProviderId;
  31:                 return View("RegisterFromProvider", registerModel.FromUser(possibleUser)); 
  32:             }
  33:  
  34:             formsAuthenticationService.SignIn(user.Email, false);
  35:             if (Url.IsLocalUrl(returnUrl))
  36:             {
  37:                 return Redirect(returnUrl);
  38:             }
  39:             else
  40:             {
  41:                 return RedirectToAction("Index", "Home");
  42:             } 
  43:     }
  44:  
  45:     return new EmptyResult(); 
  46: }

En el método anterior notaremos que es necesario hacer una conversión de los datos retornados en la propiedad UserData de AuthenticationResult. A primera vista esto puede resultar chocante pues se pudo retornar directamente una instancia de la clase User en la propiedad UserData de AuthenticationResult. Sin embargo, nuestra idea es mantener IAuthenticationProvider y AuthenticationResult lo más aislados posible de cualquier implementación que podamos hacer de la clase User.

En este punto, la solución que encontramos fue crear un adaptador capaz de convertir datos de un tipo a otro, por ejemplo, en este caso, convertir los datos devueltos en la propiedad UserData de AuthenticationResult a una instancia de la clase User, es por eso que surge la interface IDynamicAdapter que se muestra a continuación:
   1: public interface IDynamicAdapter<T>
   2: {
   3:     T Convert(dynamic obj);
   4: }
Una posible implementación para los datos retornados por el proveedor de Facebook es la siguiente:
   1: public class FacebookUserToChilUserAdapter : IDynamicAdapter<User>
   2: {
   3:     public User Convert(dynamic obj)
   4:     {
   5:         return new User
   6:             {
   7:                 Email = obj.email,
   8:                 Name = obj.first_name,
   9:                 Gender = obj.gender == "male" ? Gender.Male : Gender.Female,
  10:                 LastName = (obj.middle_name + " " + obj.last_name).Trim(),
  11:                 ImageUrl = obj.image
  12:             };   
  13:     }
  14: }
Ahora que se han explicado las decisiones de diseño solo falta aclarar como se conjuga todo. Es aquí donde aparece el ServiceLocator. El ServiceLocator actúa como una fábrica de objetos, fabricando instancias de IAuthenticationProvider y IDynamicAdapter . El ServiceLocator se encuentra definido en la biblioteca Common Service Locator de Microsoft, su función fundamental es proveer una abstracción común para contenedores IoC. En nuestro caso, el ServiceLocator trabaja con Unity Application Block, en el siguiente código se puede ver cómo se ha configurado el contenedor IoC:
   1: <register type="IAuthenticationProvider" mapTo="OpenIdAuthenticationProvider" name="google">
   2:   <property name="ProviderId" value="1" />
   3: </register>
   4: <register type="IAuthenticationProvider" mapTo="OpenIdAuthenticationProvider" name="yahoo">
   5:   <property name="ProviderId" value="2" />
   6: </register>
   7: <register type="IAuthenticationProvider" mapTo="FacebookAuthenticationProvider" name="facebook">
   8:   <property name="ProviderId" value="3" />
   9: </register>
  10: <register type="IDynamicAdapter`1[User]" mapTo="OpenIdUserToChilUserAdapter" name="google" />
  11: <register type="IDynamicAdapter`1[User]" mapTo="OpenIdUserToChilUserAdapter" name="yahoo" />
  12: <register type="IDynamicAdapter`1[User]" mapTo="FacebookUserToChilUserAdapter" name="facebook" />
Cuando el providerName del método del controlador es “facebook” el ServiceLocator  devolverá una instancia del tipo FacebookAuthenticationProvider para el proveedor y una instancia del tipo FacebookUserToChilUserAdapter para la conversión de datos.

Ya solo nos queda implementar el IAuthenticationProvider para Facebook (FacebookAuthenticationProvider) y para Yahoo! y Google (OpenIdAuthenticationProvider).

En el caso de OpenId necesitamos la biblioteca DotNetOpenAuth que encapsula toda la lógica de OAuth 2.0 y OpenID. En concreto, el método que nos interesa en  OpenIdAuthenticationProvider es el Authenticate que mostramos a continuación:

   1: public AuthenticationResult Authenticate()
   2: {
   3:     var response = openid.GetResponse();
   4:     var ctx = System.Web.HttpContext.Current;
   5:     var request = ctx.Request;
   6:  
   7:     if (response == null)
   8:     {
   9:         Identifier id;
  10:         if (Identifier.TryParse(request["openid_identifier"], out id))
  11:         {
  12:             try
  13:             {
  14:                 var authenticationRequest = openid.CreateRequest(request["openid_identifier"]);
  15:                 AddRequestExtensions(authenticationRequest);
  16:                 return new AuthenticationResult 
  17:                 { 
  18:                     Status = AuthenticationResultStatus.Initializing, 
  19:                     InitializingUrl = authenticationRequest.RedirectingResponse.Headers["location"] 
  20:                 }; 
  21:             }
  22:             catch (ProtocolException ex)
  23:             {
  24:                 return new AuthenticationResult 
  25:                 { 
  26:                     Status = AuthenticationResultStatus.ProviderError, 
  27:                     ErrorMessage = ex.Message 
  28:                 };
  29:             }
  30:         }
  31:         else
  32:         {
  33:             throw new Exception("Invalid identifier");
  34:         }
  35:     }
  36:     else
  37:     {
  38:         switch (response.Status)
  39:         {
  40:             case AuthenticationStatus.Authenticated:
  41:                 return new AuthenticationResult 
  42:                 { 
  43:                     Status = AuthenticationResultStatus.Authenticated, 
  44:                     UserData = BuildAuthenticationDataFromFetchResponse(response) 
  45:                 };
  46:  
  47:             case AuthenticationStatus.Canceled:
  48:             case AuthenticationStatus.Failed:
  49:                 return new AuthenticationResult 
  50:                 { 
  51:                     Status = AuthenticationResultStatus.AccessDinied 
  52:                 };
  53:         }
  54:     }
  55:  
  56:     return null;
  57: }
Veamos cómo funciona:

Si el usuario está autenticado, digamos que antes de entrar en nuestra aplicación estaba leyendo atenta y detenidamente su correo, el método directamente retorna una instancia de AuthenticationResult con el estado Authenticated y con los datos del usuario en UserData. Para solicitarle al proveedor que nos devuelva los datos que nos interesan, se usa el método AddRequestExtensions que se ha definido con el modificador virtual para que pueda ser redefinido con facilidad, el código de este método es el siguiente:
   1: protected virtual void AddRequestExtensions(IAuthenticationRequest request)
   2: {
   3:     var fetch = new FetchRequest();
   4:     
   5:     fetch.Attributes.AddRequired(WellKnownAttributes.Contact.Email);
   6:     fetch.Attributes.AddRequired(WellKnownAttributes.Name.First);
   7:     fetch.Attributes.AddRequired(WellKnownAttributes.Name.Last);
   8:     fetch.Attributes.AddRequired(WellKnownAttributes.Name.FullName);
   9:     fetch.Attributes.AddRequired(WellKnownAttributes.BirthDate.WholeBirthDate);
  10:     fetch.Attributes.AddRequired(WellKnownAttributes.Person.Gender);
  11:     fetch.Attributes.AddRequired(WellKnownAttributes.Media.Images.Default);
  12:     fetch.Attributes.AddRequired(WellKnownAttributes.Preferences.Language);
  13:     fetch.Attributes.AddRequired(WellKnownAttributes.Preferences.TimeZone);
  14:  
  15:     request.AddExtension(fetch);
  16: }
En caso de que el usuario no esté autenticado se devuelve un objeto de tipo AuthenticationResult con el estado Initializing y con el valor de la URL a la cual se debe redireccionar el usuario, en la siguiente imagen se muestra la interfaz de autenticación de Google:

GoogleAccount

Una vez que Google nos ha autenticado, nos redirecciona nuevamente a nuestra aplicación y en este caso el proceso sigue como se describió anteriormente para el usuario autenticado.

Para recuperar los datos que Google nos devuelve se usa el siguiente método, que también se ha definido como virtual:
   1: protected virtual dynamic BuildAuthenticationDataFromFetchResponse(IAuthenticationResponse response)
   2: {
   3:     var fetch = response.GetExtension<FetchResponse>();
   4:     dynamic user = new ExpandoObject();
   5:     user.Email = fetch.GetAttributeValue(WellKnownAttributes.Contact.Email); 
   6:     user.Name = fetch.GetAttributeValue(WellKnownAttributes.Name.First);
   7:     user.LastName = fetch.GetAttributeValue(WellKnownAttributes.Name.Last);
   8:     user.ImageUrl = fetch.GetAttributeValue(WellKnownAttributes.Media.Images.Default);
   9:  
  10:     if (string.IsNullOrEmpty(user.Name) && !string.IsNullOrEmpty(fetch.GetAttributeValue(WellKnownAttributes.Name.FullName)))
  11:         user.Name = fetch.GetAttributeValue(WellKnownAttributes.Name.FullName);
  12:  
  13:     return user;
  14: }
Por último, mencionar que una vez que Google nos ha autenticado, antes de redireccionarnos nuevamente a nuestra aplicación, nos pregunta si deseamos compartir nuestros datos, esto se muestra en la siguiente figura:

GoogleAccount1

En caso de que el usuario decida rechazar este pedido el OpenIdAuthenticationProvider devolverá un objeto de tipo AuthenticationResult con el estado AccessDinied.

Facebook por su parte, utiliza OAuth 2.0 para la autenticación de usuarios. Lo primero que haremos es descargarnos la biblioteca Facebook C# SDK, lo sorprendente de esta biblioteca es lo simple que resulta su uso.

A diferencia de Google y Yahoo!, Facebook requiere que tengamos una aplicación registrada. El proceso de registrar una aplicación en Facebook es bastante sencillo y se puede hacer aquí. Es necesario aclarar que para que el mecanismo de autenticación funcione el dominio del valor que se establezca en el campo “URL del sitio” debe coincidir con el dominio del valor de la URL al que deseamos que Facebook nos redireccione cuando se ha autenticado el usuario. Por ejemplo, en mi caso el valor de la URL del sitio es http://localhost:53291/ y el valor de la URL al que quiero que Facebook me redireccione una vez autenticado el usuario es http://localhost:53291/logon/facebook, este último valor se establece en el web.config de la aplicación ASP.NET. Los detalles del mecanismo de autenticación de Facebook están descritos en este artículo de una manera bastante fácil.

El método Authenticate de la clase FacebookAuthenticationProvider está codificado de la siguiente forma:
   1: public AuthenticationResult Authenticate()
   2: {
   3:     var ctx = System.Web.HttpContext.Current;
   4:     var req = ctx.Request;
   5:     var code = req["code"];
   6:  
   7:     if (!string.IsNullOrEmpty(req["error_reason"]) && 
   8:         !string.IsNullOrEmpty(req["error"]) && 
   9:         !string.IsNullOrEmpty(req["error_description"]))
  10:         return new AuthenticationResult 
  11:         { 
  12:             Status = AuthenticationResultStatus.AccessDinied, 
  13:             ErrorMessage = req["error_description"] 
  14:         };
  15:  
  16:     if (string.IsNullOrEmpty(code))
  17:     {
  18:         var paramaters = new Dictionary<string, object>
  19:                         {
  20:                             { "display", "popup" },
  21:                             { "scope", "email" }
  22:                         };
  23:  
  24:         var loginUri = OAuthClient.GetLoginUrl(paramaters);
  25:         return new AuthenticationResult 
  26:         { 
  27:             Status = AuthenticationResultStatus.Initializing, 
  28:             InitializingUrl = loginUri.AbsoluteUri 
  29:         };
  30:     }
  31:  
  32:     var tokenParams = OAuthClient.ExchangeCodeForAccessToken(code, null) as IDictionary<string, object>;
  33:     var token = tokenParams["access_token"] as string;
  34:  
  35:     FacebookClient fb = new FacebookClient(token);
  36:     dynamic obj = fb.Get("me");
  37:     obj.image = "http://graph.facebook.com/" + obj.id + "/picture";
  38:  
  39:     return new AuthenticationResult 
  40:     { 
  41:         Status = AuthenticationResultStatus.Authenticated, 
  42:         UserData = obj  
  43:     };
  44: }
El código anterior es bastante fácil de entender, de cualquier forma vamos a ver cómo ocurre el proceso.
En caso de que no estemos autenticados en Facebook, debemos redireccionar al usuario a la interfaz de autenticación de usuario donde se nos pide que introduzcamos nuestras credenciales. Aquí se le pasa en el parámetro scope los permisos que deseamos que tenga nuestra aplicación. La instancia de  AuthenticationResult devuelto tendrá el estado Initializing y en la propiedad InitializingUrl la URL a la que debemos redireccionar el usuario.

Una vez autenticados, Facebook nos informa de los datos que está solicitando la aplicación como se muestra en la siguiente figura:
FacebokPermission  
Acto seguido, en caso de que otorguemos los permisos solicitados, Facebook nos redirecciona nuevamente a nuestra aplicación y envía un parámetro code. Con este code, es necesario solicitar un “AccessToken” que nos permitirá hacer llamadas al Facebook Graph API.

Luego de obtener los datos del usuario se retorna un objeto de tipo AuthenticationResult con el estado Authenticated y en UserData los datos que nos ha devuelto Facebook.

Si el usuario no autoriza nuestra aplicación, se retornan tres parámetros: error con el valor access_dinied, error_description con el valor The+user+denied+your+request y un parámetro con nombre user_denied sin valor. Aquí el método Authenticate de nuestra clase retorna una instancia de AuthenticationResult con el estado AccessDinied.

Por último mencionar que se debe establecer en el fichero web.config de nuestra aplicación, los parámetros que necesitamos proporcionar a Facebook en las diferentes llamadas. Los valores de estos parámetros fueron generados cuando registramos nuestra aplicación en Facebook.
Aquí muestro un ejemplo:
   1: <facebookSettings
   2:     appId = "APP_ID"
   3:     appSecret = "APP_SECRET"
   4:     siteUrl = "http://localhost:53291/logon/facebook" />
Con lo explicado hasta aquí es suficiente, al menos eso creo, para dar solución al reto que nos trazamos al inicio, ahora solo queda codificarlo todo (un poco de copy+paste no viene mal) y hacer que funcione todo (nada fácil).

1 comentario: