homeASCIIcasts

236: OmniAuth (y II) 

(view original Railscast)

Other translations: En

Written by Juan Lupión

En el episodio anterior [verlo, leerlo] vimos cómo en una aplicación Rails se pueden añadir diferentes servicios de autenticación a una cuenta de usuario usando OmniAuth. Al terminar el episodio, los usuarios que ya se habían registrado en nuestra aplicación con un nombre y una clave ya se podían autenticar a través de terceros servicios como Twitter. En este episodio seguiremos estudiando OmniAuth y extenderemos la aplicación para que pueda aceptar usuarios sin sesión iniciada e incluso usuarios que nunca se hayan registrado.

Actualización de OmniAuth

Tras el último episodio se ha publicado una nueva versión de OmniAuth que incluye importantes correcciones. Para actualizar la aplicación y que utilice esta nueva versión tan sólo tenemos que ejecutar

$ bundle update

desde el directorio de la aplicación. En esta nueva versión de OmniAuth cambia el nombre del entorno de la petición, que pasa de llamarse rack.auth a omniauth.auth, por lo que también tendremos que cambiar la aplicación para reflejar esto:

/app/controllers/authentications_controller.rb

def create
  auth = request.env["ominauth.auth"]
  current_user.authentications.find_or_create_by_provder_and_uid↵ 
    (auth['provider'], auth['uid'])
  flash[:notice] = "Authentication successful."
  redirect_to authentications_url
end

Inicio de sesión con OmniAuth

El primer cambio que vamos a hacer en la aplicación servirá para permitir que los usuarios ya existentes puedan iniciar la sesión a través de Twitter. Tal y como está ahora si intentamos hacerlo iremos a Twitter pero cuando nos autentiquemos allí seremos redirigidos de vuelta a nuestra aplicación, que nos recibirá con un mensaje de error.

Este es el error que aparece cuando un usuario ya existente intenta iniciar sesión vía Twitter.

El motivo por el que ocurre este error es que la acción create de AuthenticationController espera que ya exista un usuario con sesión iniciada e intenta buscar o crear una nueva autenticación para dicho usuario. Dado que estamos intentando autenticar a un usuario que todavía no ha iniciado la sesión con su nombre y clave, la variable current_user valdrá nil.

/app/controllers/authentications_controller.rb

def create
  auth = request.env["ominauth.auth"]
  current_user.authentications.find_or_create_by_provder_and_uid↵ 
    (auth['provider'], auth['uid'])
  flash[:notice] = "Authentication successful."
  redirect_to authentications_url
end

Tenemos que hacer un cambio para que intente localizar la autenticación apropiada y su usuario asociado. Si la encuentra, iniciará la sesión para dicho usuario y de lo contrario creará una. Todavía tenemos que controlar el escenario en el que no existe ninguna autenticación para un usuario nuevo, pero eso lo dejamos para más adelante.

/app/controllers/authentications_controller.rb

def create
  omniauth = request.env["omniauth.auth"]
  authentication = Authentication.find_by_provder_and_uid ↵
    (omniauth['provider'], omniauth['uid'])
  if authentication
    flash[:notice] = "Signed in successfully."
    sign_in_and_redirect(:user, authentication.user)
  else
    current_user.authentications.create(:provider => omniauth ↵
      ['provider'], :uid => omniauth['uid'])
    flash[:notice] = "Authentication successful."
    redirect_to authentications_url
  end
end

El método create busca ahora una Authentication basándose en los parámetros uid y provider de la variable de entorno omniauth.auth. Si encuentra uno, entonces queremos que se establezca la sesión para dicho usuario, para lo cual usaremos el método sign_in_and_redirect de Devise para iniciar la sesión del usuario asociado a dicha autenticacón. Si no se encuentra ninguna autenticación, crearemos una nueva instancia de autenticación para el usuario que tenga la sesión iniciada.

Esto se puede comprobar visitando la página de autenticaciones sin haber iniciado la sesión, y logándonos a través de Twitter. Deberíamos terminar con la sesión iniciada en la aplicación tras ser redirigidos a la página principal.

Sesión iniciada a través de Twitter.

Gestión de altas de usuarios

A continuación vamos a modificar nuestra aplicación para que gestione las altas de usuarios que llegan a nuestro sitio y quieran autenticarse con sus credenciales de Twitter. Tal y como está ahora nuestra aplicación no permitirá el registro de usuarios a través de Twitter porque el código de la acción create sigue esperando un current_user cuando no encuentra la autenticación correspondiente.

Podemos ver lo que ocurre en este caso saliendo tanto de Twitter como de nuestra aplicación y luego iniciando la sesión con una cuenta distinta de Twitter. Twitter nos redirige de vuelta a la aplicación pero vemos un mensaje de error porque el código en la acción create intenta recuperar las autenticaciones asociadas a un current_user que no existe.

El alta de usuario a través de Twitter devuelve un error.

Para corregirlo añadiremos otra condición en la acción create para que pueda controlar esta situación.

/app/controllers/authentications_controller.rb

def create
  omniauth = request.env["omniauth.auth"]
  authentication = Authentication.find_by_provider_and_uid ↵
    (omniauth['provider'], omniauth['uid'])
  if authentication
    flash[:notice] = "Signed in successfully."
    sign_in_and_redirect(:user, authentication.user)
  elsif current_user
    current_user.authentications.create(:provider => omniauth  ↵
      ['provider'], :uid => omniauth['uid'])
    flash[:notice] = "Authentication successful."
    redirect_to authentications_url
  else
    user = User.new
    user.authentications.build(:provider => omniauth ↵
      ['provider'], :uid => omniauth['uid'])
    user.save!
    flash[:notice] = "Signed in successfully."
    sign_in_and_redirect(:user, user)
  end
end

Este método gestiona ya tres condiciones distintas. Si se encuentra una autenticación inicia la sesión para el usuario asociado. Si no es así pero ya existe un usuario con sesión iniciada, entonces asigna una nueva autenticación para este usuario. Por último, si no hay un usuario con sesión iniciada se creará un nuevo usuario, se iniciará su sesión y por último se le asignará la autenticación recibida.

Si ahora intentamos iniciar la sesión como un nuevo usuario a través de Twitter veremos un mensaje de error diferente cuando Twitter nos redirija de vuelta

Hay errores de validación cuando registramos al usuario a través de Twitter.

La aplicación anuncia que ha fallado una validación que comprueba que el email y la clave de un usuario no pueden ser vacíos. Devise devuelve estos errores de validación sobre el modelo User porque asume que estos dos campos deben existir en el modelo.

En el modelo User hemos incluido :validatable en la lista de opciones que hemos pasado al método devise, y es ahí donde se producen las validaciones de correo y clave.

/app/models/user.rb

class User < ActiveRecord::Base
  has_many :authentications
  # Include default devise modules. Others available are:
  # :token_authenticatable, :lockable, :timeoutable 
  # :confirmable and :activatable
  devise :database_authenticatable, :registerable, 
         :recoverable, :rememberable, :trackable, :validatable

  # Setup accessible (or protected) attributes for your model
  attr_accessible :email, :password, :password_confirmation
end

La forma más sencilla de corregir esto sería eliminar la opción :validatable, lo que nos serviría si sólo quisiéramos implementar la autenticación vía Twitter pero no podemos tomar esta opción porque seguimos queriendo tener la posibiliad de que la gente se registre de la forma habitual con un nombre de usuario y una clave. Alternativamente, podríamos guardar al usuario sin validarlo cambiando

user.save!

tal y como figura en la acción create por

user.save(:validate => false)

Esto puentearía la validación cuando guardemos el usuario en la base de datos pero por supuesto significaría que terminaríamos con datos no válidos en la base de datos. El modelo User tiene un campo username que queremos que sea único, y si aquí nos saltamos la validación podríamos facilmente tener usuarios con nombres duplicados.

Por desgracia no hay una forma fácil de corregir esto. Lo que haremos será mantener la validación y redirigir al usuario a un formulario donde puedan corregir cualquier problema si la validación falla cuando se intente guardar al usuario. Cambiaremos el código de la acción create de forma que si la validación falla cuando se intenta guardar un usuario se les redirija a la página de registro de nuevo usuario. No queremos perder la información recibida de OmniAuth, por lo que la guardaremos en la sesión. El hash de OmniAuth a veces puede contener demasiados datos como para caber en la sesión basada en cookies por lo que eliminaremos la clave extra, que almacena gran cantidad de información que no nos es necesaria para registrar al usuario, así que podemos obviarla.

/app/controllers/authentications_controller.rb

user = User.new
user.authentications.build(:provider => omniauth['provider'], ↵
  :uid => omniauth['uid'])
if user.save
  flash[:notice] = "Signed in successfully."
  sign_in_and_redirect(:user, user)
else
  session[:omniauth] = omniauth.except('extra')
  redirect_to new_user_registration_url
end

A continuación vamos a modificar el comportamiento del controlador de registro, redefiniéndolo mediante la creación de un nuevo controlador de registro.

$ rails g controller registrations

Nos harán falta vistas para este controlador, por lo que podemos copiar las que ya usa Devise con el generador devise:views.

$ rails g devise:views

Esto copia las vistas desde Devise al directorio /app/views/devise de nuestra aplicación. En este directorio existe un directorio registrations que contine dos archivos erb. Vamos a mover ambos archivos a /app/views/registrations para que funcionen con nuestro nuevo controlador.

A continuación cambiaremos el archivo de rutas para decirle a Devise que utilice nuestro controlador para los registros en lugar del controlador por defecto.

/config/routes.rb

ProjectManage::Application.routes.draw do |map|
  match '/auth/:provider/callback' => 'authentications#create'
  devise_for :users, :controllers => { :registrations => ↵ 
    'registrations' }
  resources :projects
  resources :tasks
  resources :authentications
  root :to => 'projects#index'
end

En este nuevo controlador que acabamos de generar queremos redefinir parte de la funcionalidad del controlador de registros de Devise, por lo que lo modificaremos para que herede de Devise::RegistrationsController en lugar de ApplicationController. La clase RegistrationsController de Devise tiene un método llamado build_resource que construye un modelo User en las aciones new y create. Si redefinimos este método podremos personalizar el comportamiento del modelo de usuario que se crea y añadir una autenticación asociada basándonos en la información almacenada en la variable de sesión con datos de OmniAuth. Lo que haremos aquí será muy parecido a lo que hacemos en la acción create del controlador AuthenticationsController, así que extraeremos esta funcionalidad a un nuevo método del modelo User al que llamaremos apply_omniauth.

/app/models/user.rb

def apply_omniauth(omniauth)
  authentications.build(:provider => omniauth['provider'], ↵
    :uid => omniauth['uid'])
end

Ya podemos usar este método en la accion create de AutenthicationController.

/app/controllers/authentications_controller.rb

user = User.new
user.apply_omniauth(omniauth)
if user.save
  flash[:notice] = "Signed in successfully."
  sign_in_and_redirect(:user, user)
else
  session[:omniauth] = omniauth.except('extra')
  redirect_to new_user_registration_url
end

Y también en nuestro flamante RegistrationsControler.

/app/controllers/registrations_controller.rb

class RegistrationsController < Devise::RegistrationsController
  private
  def build_resource(*args)
    super
    if session[:omniauth]
      @user.apply_omniauth(session[:omniauth])
      @user.valid?
    end
  end
end

Esto sólo lo queremos hacer si ya existe la variable de sesión por lo que primero comprobaremos su existencia. También validaremos el usuario para que los errores de validacion aparezcan en la acción new a la que se redirigirá al usuario.

Veamos si funciona este nuevo código. Si visitamos la aplicación sin tener sesión iniciada y nos autenticamos vía Twitter volveremos a la página de registro porque aún no tenemos una cuenta de usuario y veremos los errores de validación.

Ahora en el formulario de registor aparecen los errores de validación.

No queremos validar la clave cuando el usuario ya tiene su propia forma de autenticación por lo que en el modelo User desactivaremos las validaciones sobre el campo password. Devise proporciona una forma de hacerlo redefiniendo el método password_required?. Sólo queremos validar la clave cuando el usuario no tenga otra autenticación o cuando esté intentado cambiar su clave. También queremos delegar a super para que se aplique también el comportamiento que estamos tratando de ampliar.

/app/models/user.rb

def password_required?
  (authentications.empty? || !password.blank?) && super
end

Queremos que los campos de clave del formulario de alta no aparezca cuando no sea estrictamente necesario, para lo que podemos usar password_required? en el código de la vista.

/app/views/registrations/new.html.erb

<h2>Sign up</h2>

<%= form_for(resource, :as => resource_name, :url => registration_path(resource_name)) do |f| %>
  <%= devise_error_messages! %>

  <p><%= f.label :email %><br />
  <%= f.text_field :email %></p>

<% if @user.password_required? %>
  <p><%= f.label :password %><br />
  <%= f.password_field :password %></p>

  <p><%= f.label :password_confirmation %><br />
  <%= f.password_field :password_confirmation %></p>
<% end %>
  <p><%= f.submit "Sign up" %></p>
<% end %>

<%= render :partial => "devise/shared/links" %>

Si ahora recargamos el formulario veremos que sólo aparece un error de validación, y que no se muestran los campos de clave.

Sólo se muestra el campo de la dirección de correo y un error de validación.

Sólo nos queda una cosa pendiente en el controlador RegistrationsController, que es eliminar los datos de OmniAuth de la sesión una vez que el usuario ha sido dado de alta correctamente. Lo haremos redefiniendo la acción create.

/app/controllers/registration_controllers.rb

def create
  super
  session[:omniauth] = nil unless @user.new_record? 
end

A partir de ahora se borrarán los datos de OmniAuth de la sesión cuando el nuevo usuario sea creado. Si rellenamos una dirección de correo en el formulario podremos registrarnos y si volvemos a la página de autenticaciones veremos que aparece nuestra autenticación de Twitter.

El nuevo usuario tiene disponible la autenticación de Twitter.

Añadir un servicio a OmniAuth

Terminaremos viendo lo fácil que es añadir un nuevo servicio de autenticación a OmniAuth, en este caso OpenID. Nuestra aplicación está usando WEBrick en modo de desarrollo y sabemos que las URLs largas que utiliza OpenID pueden provocar problemas, por lo que pasaremos a usar Mongrel.

Para ello tan sólo tenemos que poner una referencia a Mongrel en el fichero Gemfile de nuestra aplicación. Especificaremos la versión 1.2.0.pre, que es la que funciona con Rails.

/Gemfile

gem 'mongrel', '1.2.0.pre2'

A continuación tenemos que modificar nuestro fichero de configuración de OmniAuth y añadir OpenID a la lista de proveedores.

/config/initializers/omniauth.rb

require 'openid/store/filesystem'
Rails.application.config.middleware.use OmniAuth::Builder do
  provider :twitter, 's3dXXXXXXXX', 'lR23XXXXXXXXXXXXXXXXXX'
  provider :open_id, OpenID::Store::Filesystem.new('/tmp')
end

Vamos a usar almacenamiento en el sistema de archivos con OpenID. Si nuestro alojamiento no proporciona esta posibilidad, podemos utilizar el almacenamiento basado en memcached o ActiveRecord, pero en todo caso OpenID exige el uso de un almacén de persistencia. Nótese que OpenID no incluye automáticamente la persistencia basada en archivos por lo que tendremos que hacer el require nosotros.

Por último tenemos que ir al modelo User y cambiar el funcionamiento del método apply_omniauth. OpenID proporciona un parámetro de email y lo usaremos en el modelo User salvo que el usuario ya una dirección de correo.

/app/models/user.rb

def apply_omniauth(omniauth)
  self.email = omniauth['user_info']['email'] if email.blank?
  authentications.build(:provider => omniauth['provider'], ↵
    :uid => omniauth['uid'])
end

Podemos probar esto iniciando la sesión con una cuenta de OpenID. Cuando seamos redirigidos a la web de nuestro proveedor de OpenID, podemos autenticarnos allí y luego seremos redirigidos de vuelta a la aplicación, con la sesión iniciada y con la dirección de correo de nuestra cuenta de OpenID automáticamente asignada a nuestra cuenta en la aplicación.

Registro usando OpenID.

Si miramos en la página de autenticaciones veremos la autenticación OpenID.

El usuario tiene asignada la auteticación de OpenID.

El título de la autenticación OpenID de la página anterior es incorrecto, así que lo arreglaremos de inmediato. El título de cada autenticación se muestra utilizando el siguiente código:

/app/views/authentications/index.html.erb

<div class="provider">
  <%= authentication.provider.titleize %>
</div>

El método titleize no funciona en este caso, por lo que utilizaremos un nuevo método en la clase Authentication, que será el que utilizaremos.

/app/views/authentications/index.html.erb

<div class="provider">
  <%= authentication.provider_name %>
</div>

El método provider_name es inmediato:

/app/models/authentication.rb

class Authentication < ActiveRecord::Base
  belongs_to :user
  
  def provider_name
    if provider == 'open_id'
      "OpenID"
    else
      provider.titleize
    end
  end
end

Esto es todo lo que nos quedaba por hacer en este episodio. OmniAuth es una gema impresioante, especialmente si tenemos en cuenta el número de servicios a los que se puede conectar. Una vez que tengamos establecidas las bases es muy fácil añadir nuevos servicios según nos haga falta. Añadir este tipo de comportamiento sobre un sistema de autenticación ya existente es un poco complejo, y de hecho no hemos cubierto todos los detalles, pero con esto debería ser necesario para empezar a utilizar OmniAuth con Devise.