homeASCIIcasts

274: Récuérdame y recuperación de contraseña 

(view original Railscast)

Other translations: En Fr Ja

Other formats:

Written by Juan Lupión

Aunque existan muchas soluciones de autenticación para nuestras aplicaciones Rails, esto no quiere decir que debamos descartar escribir nuestra propia implementación. Veíamos cómo hacerlo en el episodio 250 [verlo, leerlo], mientras que en el episodio 270 [verlo, leerlo] vimos cómo con Rails 3.1 es todavía más fácil utilizando el método has_secure_password que genera los cifrados de las claves.

La autenticación desarrollada en estos episodios era muy básica, por lo que vamos a dedicar este episodio a añadir un par de mejoras. Primero añadiremos la posibilidad de recordar al usuario para aquellos que quieran iniciar la sesión automáticamente y luego añadiremos la funcionalidad de recuperación de contraseñas para los usuarios que las pierdan. Implementaremos estas funcionalidades extendiendo la aplicación del episodio 270, que funciona con Rails 3.1 pero los cambios que veremos aquí funcionan también en Rails 3.0.

Cómo recordar a los usuarios

Cuando los usuarios inician sesión en la aplicación su id se almacena en la sesión. Esto tiene lugar en la acción create del controlador SessionsController.

/app/controllers/sessions_controller.rb

def create
  user = User.find_by_email(params[:email])
  if user && user.authenticate(params[:password])
    session[:user_id] = user.id
    redirect_to root_url, :notice => "Logged in!"
  else
    flash.now.alert = "Invalid email or password"
    render "new"
  end
end

Cuando los usuarios con sesión iniciada cierran el navegador la cookie de sesión se borra, por lo que tienen que volver a introducir sus credenciales la próxima vez que visiten la aplicación. Vamos a cambiar la cookie de sesión por una de tipo permanente para que el identificador de usuario persista entre sesiones.

El problema más obvio con esto es que los códigos de id quedan almacenados como enteros secuenciales. Si el id queda almacenado en una cookie permanente sería muy fácil que un usuario malicioso cambiese el valor de la misma y viese los datos de otros usuarios. Para evitar esto generaremos un token único para cada usuario que sea imposible de adivinar y que será lo que guardaremos en la cookie.

Cada usuario tendrá su propio token que quedará almacenado en la base de datos así que tenemos que crear una migración para añadir un campo llamado auth_token a la tabla de usuarios y luego migrarla.

$ rails g migration add_auth_token_to_users auth_token:string

Tendremos que generar este token único cuando se crea el usuario, así que escribiremos un método llamado generate_token en el modelo User. Este método tendrá un argumento column por si más adelante necesitamos añadir otro tipo de tokens.

/app/models/user.rb

class User < ActiveRecord::Base
  attr_accessible :email, :password, :password_confirmation
  has_secure_password
  validates_presence_of :password, :on => :create
  before_create { generate_token(:auth_token) }
  
  def generate_token(column)
    begin
      self[column] = SecureRandom.urlsafe_base64
    end while User.exists?(column => self[column])
  end
end

La clase SecureRandom de ActiveSupport es la encargada de generar la cadena aleatoria. Hacemos que la cadena sea única generando tokens hasta dar con uno que haya sido asignado a ningún usuario. Llamamos a este método en un filtro before_create para generar el token cuando la instancia del usuario se guarda por primera vez. Si ya tuviésemos usuarios en la base de datos tendríamos que generarles sus tokens mediante una tarea rake, lo que no veremos hoy.

Modificaremos la acción create de SessionsController para almacenar el token en una cookie cuando el usuario inicia la sesión. Cambiaremos la acción destroy de forma que se elimine dicha cookie cuando el usuario cierra la sesión.

/app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  def new
  end

  def create
    user = User.find_by_email(params[:email])
    if user && user.authenticate(params[:password])
      cookies.permanent[:auth_token] = user.auth_token
      redirect_to root_url, :notice => "Logged in!"
    else
      flash.now.alert = "Invalid email or password"
      render "new"
    end
  end

  def destroy
    cookies.delete(:auth_token)
    redirect_to root_url, :notice => "Logged out!"
  end
end

Tal y como está, todos los usuarios quedarán con sesión iniciada permanentemente, lo que no tiene por qué ser lo que deseen, así que añadiremos una casilla de verificación para que puedan escoger si desean este comportamiento. Los cambios son muy sencillos, tan sólo tenemos que añadir la casilla junto con una etiqueta que explique su cometido.

/app/views/sessions/new.html.erb

<h1>Log in</h1>

<%= form_tag sessions_path do %>
  <div class="field">
    <%= label_tag :email %>
    <%= text_field_tag :email, params[:email] %>
  </div>
  <div class="field">
    <%= label_tag :password %>
    <%= password_field_tag :password %>
  </div>
  <div class="field">
    <%= label_tag :remember_me %>
    <%= check_box_tag :remember_me, 1, params[:remember_me] %>
  </div>  
  <div class="actions"><%= submit_tag "Log in" %></div>
<% end %>

Con esto ya podemos modificar SessionsController para que la cookie se establezca sólo si el usuario marca la casilla. De lo contrario, se utilizará una cookie de sesión.

/app/controllers/sessions_controller.rb

def create
  user = User.find_by_email(params[:email])
  if user && user.authenticate(params[:password])
    if params[:remember_me]
      cookies.permanent[:auth_token] = user.auth_token
    else
      cookies[:auth_token] = user.auth_token  
    end
    redirect_to root_url, :notice => "Logged in!"
  else
    flash.now.alert = "Invalid email or password"
    render "new"
  end
end

Todavía nos queda otro cambio por hacer. Hay que cambiar ApplicationController de forma que lea el token de autenticación a partir de la cookie en lugar del id de un usuario a partir de la sesión.

/app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  protect_from_forgery

  private
  def current_user
    @current_user ||= User.find_by_auth_token( ↵
      cookies[:auth_token]) if cookies[:auth_token]
  end
  helper_method :current_user
end

Ya podemos probar esto. Si iniciamos la sesión veremos la casilla para recordar la sesón, si la marcamos y luego cerramos y volvemos a abrir el navegador veremos que la sesión se inicia automáticamente.

El formulario con la casilla para recordar la sesión.

Recuperación de contraseñas

Veamos ahora cómo permitir a los usuarios cambiar una contraseña que hayan olvidado. Empezaremos añadiendo el enlace correspondiente en el formulario de inicio de sesión.

/app/views/sessions/new.html.erb

<h1>Log in</h1>

<%= form_tag sessions_path do %>
  <div class="field">
    <%= label_tag :email %>
    <%= text_field_tag :email, params[:email] %>
  </div>
  <div class="field">
    <%= label_tag :password %>
    <%= password_field_tag :password %>
  </div>
  <p><%= link_to "forgotten password?", ↵
    new_password_reset_path %></p>
  <div class="field">
    <%= label_tag :remember_me %>
    <%= check_box_tag :remember_me, 1, params[:remember_me] %>
  </div>  
  <div class="actions"><%= submit_tag "Log in" %></div>
<% end %>

El enlace apunta a new_password_reset_path que es parte de un recurso que todavía no hemos escrito. Creemos el controlador PasswordResets con la acción new.

$ rails g controller password_resets new

Queremos tratar este controlador como si fuese un recurso REST, por lo que modificaremos el fichero de rutas y cambiaremos la ruta generada por una llamada a resources.

/config/routes.rb

Auth::Application.routes.draw do

  get "logout" => "sessions#destroy", :as => "logout"
  get "login" => "sessions#new", :as => "login"
  get "signup" => "users#new", :as => "signup"
  root :to => "home#index"
  resources :users
  resources :sessions
  resources :password_resets
end

Esto nos servirá aunque no se trate de un recurso respaldado por un modelo.

En la acción new crearemos un formulario para que el usuario pueda introducir su dirección de correo y solicitar el restablecimiento de su clave. El formulario queda así:

/app/views/password_resets/new.html.erb

<h1>Reset Password</h1>

<%= form_tag password_resets_path, :method => :post do %>
  <div class="field">
    <%= label_tag :email %>
    <%= text_field_tag :email, params[:email] %>
  </div>
  <div class="actions"><%= submit_tag "Reset Password" %></div>
<% end %>

En este formulario tenemos que usar form_tag porque no se trata de un recurso ActiveRecord. El formulario hace un POST a la acción create del controlador PasswordResets, que escribiremos a continuación. Buscará al usuario por la dirección de correo y luego le enviará las instrucciones para restablecer su contraseña, lo que se hará en el método send_password_reset del modelo User.

/app/controllers/password_resets.rb

def create
  user = User.find_by_email(params[:email])
  user.send_password_reset if user
  redirect_to root_url, :notice => "Email sent with ↵
    password reset instructions."
end

El mensaje aparece tanto si el usuario se encuentra como si no, para complicarle un poco las cosas a los usuarios maliciosos que traten de saber si un usuario determinado existe en la base de datos.

A continuación escribiremos el método send_password_reset, en el que enviaremos un correo que contendrá el token para la petición de restablecimiento de contraseña. Queremos que el token expire después de un tiempo determinado, por ejemplo un par de horas, de forma que el enlace sea válido sólo durante un breve tiempo tras el envío del correo. Para esto nos harán falta un par de campos más en la tabla de usuarios. Escribamos la migración correspondiente.

$ rails g migration add_password_reset_to_users password_reset_token:string password_reset_sent_at:datetime

En send_password_reset utilizaremos el metodo generate_token de antes para crear un token de restablecimiento de clave. Igualmente estableceremos también el campo password_reset_sent_at para saber cuándo debería expirar el token. Después de guardar los cambios en el modelo del usuario se lo pasaremos al UserMailer de forma que envíe el correo.

/app/models/user.rb

def send_password_reset
  generate_token(:password_reset_token)
  self.password_reset_sent_at = Time.zone.now
  save!
  UserMailer.password_reset(self).deliver
end

Todavía no tenemos el UserMailer por lo que lo escribiremos a continuación.

$ rails g mailer user_mailer password_reset

En el mailer asignaremos el usuario a una variable de instancia para poder acceder a ella desde la plantilla y poder establecer el asunto y el destinatario.

/app/mailers/user_mailer.rb

class UserMailer < ActionMailer::Base
  default from: "from@example.com"

  def password_reset(user)
    @user = user
    mail :to => user.email, :subject => "Password Reset"
  end
end

La plantilla contendrá las instrucciones necesarias para restablecer la contraseña, así como un enlace.

/app/views/user_mailer/password_reset_text.erb

To reset your password click the URL below.

<%= edit_password_reset_url(@user.password_reset_token) %>

If you did not request your password to be reset please ignore this email and your password will stay as it is.

El enlace en el correo lleva al usuario a la acción edit de PasswordResetsController. No se trata del enfoque REST más ortodoxo pero en este caso nos servirá. Para que las URLs funcionen en los correos tenemos que modificar la configuración de nuestro entorno para añadir la siguiente linea en development.rb.

/config/environments/development.rb

Auth::Application.configure do
  # Other config items omitted.
  
  config.action_mailer.default_url_options = { :host => ↵ 
    "localhost:3000" }
end

Añadiremos también una línea similar en production.rb con el nombre del dominio de producción.

Hagamos una prueba. Si visitamos la página de restablecimiento de contraseña e introducimos nuestra dirección de correo deberíamos leer un correo con las instrucciones para restablecer la contraseña.

La página principal mostrando el envío del mensaje de correo.

En el log de desarrollo veremos los detalles del correo.

Sent mail to eifion@asciicasts.com (65ms)
Date: Thu, 14 Jul 2011 20:18:48 +0100
From: from@example.com
To: eifion@asciicasts.com
Message-ID: <4e1f4118af661_31a81639e544652a@noonoo.home.mail>
Subject: Password Reset
Mime-Version: 1.0
Content-Type: text/plain;
 charset=UTF-8
Content-Transfer-Encoding: 7bit

To reset your password click the URL below.

http://localhost:3000/password_resets/DeStUAsv2QTX_SR3ub_N0g/edit

If you did not request your password to be reset please ignore this email and your password will stay as it is.
Redirected to http://localhost:3000/
Completed 302 Found in 1889ms

El correo incluye un enlace a la URL de restablecimiento de contraseña, que lleva el token y el parámetro id.

A continuación debemos escribir la acción edit, en ella recuperaremos al usuario por su token único. Nótese que utilizaremos el método con exclamación para emitir un error 404 en caso de que el usuario no sea encontrado.

/app/controllers/password_resets_controller.rb

def edit
  @user = User.find_by_password_reset_token!(params[:id])
end

Crearemos también un formulario en la vista correspondiente para que el usuario pueda volver a introducir su contraseña.

/app/views/password_resets/edit.html.erb

<h1>Reset Password</h1>

<%= form_for @user, :url => password_reset_path(params[:id]) ↵
  do |f| %>
  <% if @user.errors.any? %>
    <div class="error_messages">
      <h2>Form is invalid</h2>
      <ul>
        <% for message in @user.errors.full_messages %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>
  <div class="field">
    <%= f.label :password %>
    <%= f.password_field :password %>
  </div>
  <div class="field">
    <%= f.label :password_confirmation %>
    <%= f.password_field :password_confirmation %>
  </div>
  <div class="actions"><%= f.submit "Update Password" %></div>
<% end %>

Utilizamos form_for porque en este caso estamos modificando un recurso. Debido a ello tenemos que establecer el parámetro :url para que el formulario no sea enviado como POST a UsersController. En su lugar se envía a la acción update de PasswordResetsController pasándo el testigo de reinicio del clave como id. El formulario contiene una sección para mostrar los mensajes de error y los campos para introducir y confirmar la nueva contraseña.

A continuación escribiremos la acción update, que primero comprueba que el token de restauración de contraseña tiene menos de dos horas y si no es así, se redirige al usuario otra vez al formulario. Si el token es lo suficientemente reciente tratamos de actualizar al usuario. Si esto tiene éxito redirigiremos a la página principal y mostraremos un mensaje, si la operación no tiene éxito es que debe haber ocurrido un error en el formulario por lo que lo volvemos a mostrar.

/app/controllers/password_resets_controller.rb

def update
  @user = User.find_by_password_reset_token!(params[:id])
  if @user.password_reset_sent_at < 2.hours.ago
    redirect_to new_password_reset_path, :alert => "Password ↵ 
      reset has expired."
  elsif @user.update_attributes(params[:user])
    redirect_to root_url, :notice => "Password has been reset."
  else
    render :edit
  end
end

Podemos hacer la prueba pegando la URL del correo electrónico en la ventana del navegador.

Cambio de contraseña.

Si introducimos una clave y una confirmación incorrectas veremos un mensaje de error, y si enviamos correctamente el formulario la contraseña cambiará.

La contraseña queda cambiada.

Esta técnica de restablecimiento de contraseña se puede usar para desarrollar otras funcionalidades, por ejemplo para confirmar las nuevas cuentas, que consistiría en algo similar al restablecimiento de contraseña pero cuando se hace clic en el enlace del correo lo que hacemos es apuntar en la base de datos que la cuenta está confirmada.

Con esto cerramos este episodio en el que hemos visto el inicio de sesión automático y el restablecimiento de claves. Algunas herramientas, como Devise, proporcionan esta funcionalidad de manera automática pero puede ser interesante desarrollarla desde cero.