#236 OmniAuth Part 2
- Download:
- source codeProject Files in Zip (143 KB)
- mp4Full Size H.264 Video (26.3 MB)
- m4vSmaller H.264 Video (17.4 MB)
- webmFull Size VP8 Video (43.6 MB)
- ogvFull Size Theora Video (39.6 MB)
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:
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.
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
.
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.
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.
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.
Para corregirlo añadiremos otra condición en la acción create
para que pueda controlar esta situación.
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
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.
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.
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.
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
.
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
.
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
.
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.
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.
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.
<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 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
.
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.
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.
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.
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.
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.
Si miramos en la página de autenticaciones veremos la autenticación 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:
<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.
<div class="provider"> <%= authentication.provider_name %> </div>
El método provider_name
es inmediato:
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.