Recuperación de contraseñas desde Rails API

En el anterior post explique como se realiza el envío de correos desde nuestro API Rails, ahora el paso a seguir es crear la funcionalidad necesaria para que nuestros usuarios puedan recuperar su contraseña.

Antes de empezar resumiré un poco la lógica del reinicio de contraseña.

A continuación encontramos dos diagramas de secuencia, en el primero podemos ver el flujo básico para la solicitud de reinicio de contraseña desde una app o un cliente web.

request password reset

En el segundo identificamos el flujo que debe realizar nuestro API cuando se procesa la solicitud.
process password reset

Continuaremos con el proyecto anterior como base así es que puedes descargarlo desde mi cuenta en GitHub

Necesitaremos lo siguiente:

  • Una endpoint dentro de UsersController llamado password_reset que se encargará de recibir la solicitud de cambio de contraseña.

  • Dos nuevas columnas para el modelo User, que serán password_revcovery_token de tipo String y password_revocery_expiration de tipo Timestamp. Estas propiedades nos ayudarán a controlar la vigencia del token que sera usado para validar que el cambio de contraseña sea haga de forma segura.

  • Un nuevo Controller para el reinicio de la contraseña, nos permitirá controlar el formulario de reinicio de contraseña y el proceso de cambio de la misma.

  • Dos Views, uno para cargar el formulario de cambio de contraseña y otro para mostrar la respuesta de este proceso.

Modificando el modelo

Ya con todo un poco más claro empezaremos con la implementación.

Comenzaremos agregando las dos columnas a nuestro modelo User, para esto crearemos una migración.

rails g migration AddTokenResetToUser password_recovery_token:string password_recovery_expiration:timestamp  

con esto rails nos generará la migración con las dos nuevas columnas para nuestra tabla users

Running via Spring preloader in process 46527  
      invoke  active_record
      create    db/migrate/20170313044809_add_token_reset_to_user.rb

Una vez generada nuestra migración debemos ejecutarla para que realice los cambios necesarios en nuestra base de datos.

rake db:migrate  
== 20170313044809 AddTokenResetToUser: migrating ==============================
-- add_column(:users, :password_recovery_token, :string)
   -> 0.0076s
-- add_column(:users, :password_recovery_expiration, :timestamp)
   -> 0.0004s
== 20170313044809 AddTokenResetToUser: migrated (0.0082s) =====================

Creando el endpoint para solicitud de cambio de contraseña

Perfecto, ya tenemos listo nuestro modelo. Ahora vamos a definir la nueva función dentro de UsersController, pero antes debemos hacer un pequeño ajuste a la función ExampleMailer.sample_email(user) para esto editamos el archivo app/mailers/example_mailer.rb y reemplazamos la función

def sample_email(user)  
    @user = user
    puts "Sending mail"
    mail(to: @user.email, subject: 'Sample Email')
end  

por

def sample_email(user, base_url)  
    @user = user
    @recovery_url = "#{base_url}/password_reset/#{@user.password_recovery_token}"
    mail(to: @user.email, subject: 'Solicitud de cambio de contraseña')
end  

y en la en el body del correo enviaremos el link para el cambio de contraseña así es que editamos el archivo app/views/example_mailer/sample_email.html.erb y reemplazamos todo el contenido por el siguiente código:

<ht>Hola <%= @user.email %></ht>  
<p>  
    Te enviamos este correo para que realices el cambio de tu contraseña entrando a la siguiente dirección <%= link_to "Recuperar contraseña", @recovery_url %>
</p>  

si te fijas bien estamos usando la variable @recovery_url la cual definimos dentro de app/mailers/example_mailer.rb

Con este ajuste lo que queremos hacer es pasar como parámetro la url base de nuestra app para construir la url de recuperación de contraseña que enviearemos por email.

Ahora si continuamos con nuestro UserController, editamos el archivo app/controller/UsersController.rb y creamos la función password_reset con el siguiente código.

def password_reset  
    user_email = params['email']
    @user = User.find_by(email: user_email)
    if @user
      password_recovery_token = SecureRandom.hex(32)
      @user.password_recovery_token = password_recovery_token
      @user.password_recovery_expiration = Time.now
      begin
        @user.save!
        base_url = request.protocol + request.host_with_port
        ExampleMailer.sample_email(@user, base_url).deliver
        render json: {success: 'request reset password successful'}, status: :ok 
      rescue Exception => e
        render json: {error: 'Fail save user with temporal password recovery token'}, status: :internal_server_error
      end
    else
      render json: {error: 'User not found'}, status: :not_found
    end
  end

En el código anterior generamos un random por medio de SecureRandom.hex(32) el cual sera nuestro token temporal junto con un
Time.now el cual nos data el tiempo de expiración del token generado, esto con el fin de dar caducidad al link que enviaremos por email.

Para terminar este paso es necesario agregar a nuestro archivo de rutas la nueva función, editamos el archivo config/routes.rb y agregamos la siguiente ruta:

post '/users/password_reset', to: 'users#password_reset'  

listo con esto terminamos el endpoint que usaremos para solicitar el cambio de contraseña, ahora podemos probarlo.

Iniciamos nuestro servidor rails, entramos en el directorio de nuestra aplicación y ejecutamos

$ rails s

Consumimos el endpoint http://localhost:3000/users/password_reset en mi caso desde Postman y enviamos como payload el email del usuario que solicitará el cambio de contraseña.

POST /users/password_reset HTTP/1.1  
Host: localhost:3000  
Content-Type: application/json  
{
  "email":"[email protected]"
}

Nos deberá llegar un correo con la el mensaje que indicamos en el archivo app/view/example_mail/sample_email.html.erb que contendrá el link para recuperar la contraseña y lucirá como este:

http://localhost:3000/password_reset/17ca49bb8d13c33c9c95cd3f420abd331b154c54dc45975cc373d828d12e81df  

Creando el formulario

Crearemos un nuevo controller llamado PasswordResetController con dos funciones llamadas index y reset.

rails g controller PasswordReset index reset  
Running via Spring preloader in process 31321  
      create  app/controllers/password_reset_controller.rb
       route  get 'password_reset/reset'
       route  get 'password_reset/index'
      invoke  test_unit
      create    test/controllers/password_reset_controller_test.rb

Una vez que rails nos genera nuestro controller debemos cambiar la clase de donde extiende editando el archivo app/controllers/password_reset_controller.rb y cambiando ApplicationController por ActionController::Base ya que al crear nuestro proyecto como api por defecto los controllers extenderán de ActionController::API.

class PasswordResetController < ActionController::Base  
  def index
  end

  def reset
  end
end  

También es necesario que ajustemos un poco el archivo de rutas para especificar el parámetro que contendrá el token de validación, editamos el archivo config/routes.rb y cambiamos las siguientes lineas

get 'password_reset/index'  
get 'password_reset/reset'  

por

get '/password_reset/:tmp_token', to: 'password_reset#index'  
post '/password_reset/reset', to: 'password_reset#reset'  

Ahora explicare el fin de las dos funciones que creamos.
La función index será la encargada de verificar si nuestro token es valido o si aun no se ha vencido, también nos ayudará con el render del formulario y será llamada cuando el usuario haga clic sobre el link que le enviamos por email.
La función reset estará encargada de realizar el cambio de contraseña e indicar el mensaje correspondiente si el proceso fue exitoso o no y sera ejecutada cuando el usuario haga submit del formulario de cambio de contraseña.

Antes de continuar vamos a agregar un pequeño middleware llamado Flash el cual nos servirá para renderizar mensajes en nuestras vistas, para esto editamos el archivo config/application.rb y agregamos la linea config.middleware.use ActionDispatch::Flash después de config.api_only = true quedando de la siguiente forma:

...
module ApiMail  
  class Application < Rails::Application
    # Settings in config/environments/* take precedence over those specified here.
    # Application configuration should go into files in config/initializers
    # -- all .rb files in that directory are automatically loaded.

    # Only loads a smaller set of middleware suitable for API only apps.
    # Middleware like session, flash, cookies can be added back manually.
    # Skip views, helpers and assets when generating a new resource.
    config.api_only = true
    config.middleware.use ActionDispatch::Flash
  end

Listo, empezamos editando el archivo app/controllers/password_reset_controller.rb y definimos una constante que indicará el tiempo máximo de vigencia de los token generados, en este caso lo dejaremos de 10 minutos.

@@EXPIRATION_TIME_MINUTES = 10

Implementamos la función index con el siguiente código:

def index  
    password_recovery_token = params[:tmp_token]
    @user = User.find_by(password_recovery_token: password_recovery_token)
    if @user
        time_difference_minutes = (Time.now - @user.password_recovery_expiration) / 60
        if time_difference_minutes < @@EXPIRATION_TIME_MINUTES
            @status = 1
            flash[:info] = "Puedes cambiar tu contraseña"
        else
            @status = 2
            flash[:danger] = "Ha expirado el tiempo para cambiar tu contraseña"
        end
    else
        @status = 3
        flash[:danger] = "Solicitud invalida"
    end
end  

Explicare un poco la lógica de esta función, lo primero que hacemos es obtener el token que llegara por medio del link enviado al correo

password_recovery_token = params[:tmp_token]  

luego buscaremos un usuario que coincida con este token

@user = User.find_by(password_recovery_token: password_recovery_token)

si el usuario existe validamos que el token aun sea vigente y mostramos el mensaje correspondiente.

time_difference_minutes = (Time.now - @user.password_recovery_expiration) / 60  
if time_difference_minutes < @@EXPIRATION_TIME_MINUTES  
  @status = 1
  flash[:info] = "Puedes cambiar tu contraseña"
else  
  @status = 2
  flash[:danger] = "Ha expirado el tiempo para cambiar tu contraseña"
end  

Ten en cuenta que si alguna validación falla se mostrará un mensaje correspondiente al problema y se asignará un valor a la variable @status el cual podrá tener 3 posibles valores 1,2 o 3 de los cuales 2 y 3 corresponden a errores y 1 indica que el proceso puede continuar y deberemos mostrar el formulario de cambio de contraseña.

Ahora crearemos la vista donde mostraremos el formulario, para esto crearemos una carpeta llamada password_reset (debe ser el mismo nombre que nuestro controller) dentro de app/views/ y luego creamos un archivo llamado index.html.erb (que corresponde al nombre de la función que definimos anteriormente) es decir que tendremos el archivo en la siguiente ruta app/views/password_reset/index.html.erb, por ultimo agregamos el siguiente código:

<% flash.each do |key, message| %>  
    <div class="alert alert-<%= key %>" role="alert"><p style="text-align:center"><%= message %></p></div>
<% end %>

<% if @status == 1 %>  
    <div class="container">
        <div class="Absolute-Center is-Responsive">
            <div class="col-sm-12 col-md-6 col-md-offset-3">
            <%= form_for @user, url: {action: "reset"} do |f| %>
              <%= hidden_field_tag :email, @user.email %>
              <div id="password" class="form-group">
                <%= f.password_field :password, class: 'form-control', placeholder: 'contraseña'%>
              </div>
              <div id="password_confirmation" class="form-group">
                <%= f.password_field :password_confirmation, class: 'form-control', placeholder: 'Confirma contraseña' %>
              </div>
              <%= f.submit "Cambiar contraseña", disabled: true, class: 'btn btn-primary btn-lg btn-block' %>
            <% end %>
        </div>
      </div>
  </div>
<% end %>  

Antes de continuar vamos a incluir Bootstrap para que nuestra página sea responsive con una buena apariencia y jQuery para hacer las validaciones del formulario, así es que agregamos al inicio del archivo app/views/password_reset/index.html.erb el siguiente código:

<head>  
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity=" sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
</head>  

Para validar el formulario definimos dos funciones javascript formValid(valid) y validatePassword(). La primera función nos servirá para resaltar las cajas de texto si que hay un error o no y activar o desactivar el botón de submit. La segunda hará uso de esta y será la encargada de validar las condiciones de aceptación de la contraseña, que para este caso debe tener una longitud de 8 caracteres como mínimo y debe ser igual a la contraseña de confirmación.

Ahora después del <head> que agregamos anteriormente vamos a incluir las siguientes funciones dentro de un tag <script>

// detectamos cuando el usuario deja de presionar una tecla
$(document).ready(function() {
    $("#user_password").keyup(validatePassword);
    $("#user_password_confirmation").keyup(validatePassword);
});
//hacemos la validación de la contraseña.
function validatePassword() {  
  var password = $("#user_password").val();
  var password_confirmation = $("#user_password_confirmation").val();

  if (password.length < 8) {
      formValid(false)
    $('input:submit').attr("value", "Minimo 8 carácteres");
  }
  else {
      if(password == password_confirmation) {
     formValid(true)
     $('input:submit').attr("value", "Cambiar contraseña");
       }
    else {
        formValid(false)
        $('input:submit').attr("value", "contraseñas no coinciden");
    }
  }
}
// Cambia el estilo del formulario para resaltar las cajas
// de texto, cuando hay un error agrega un border rojo y 
// cuando esta correcto agrega uno verde 
function formValid(valid) {  
  if (valid) {
      $("#password").removeClass("has-error");
      $("#password").addClass("has-success");
      $("#password_confirmation").removeClass("has-error");
      $("#password_confirmation").addClass("has-success");
      $('input:submit').attr("disabled", false);
  }
  else {
      $("#password").removeClass("has-success");
      $("#password").addClass("has-error");
      $("#password_confirmation").removeClass("has-success");
      $("#password_confirmation").addClass("has-error");
      $('input:submit').attr("disabled", true);
  } 
}

Estas validaciones se ejecutaran cada ves que el usuario deje de presionar una tecla al momento de ingresar la contraseña.

Bien ahora podemos hacer la prueba ejecutando el link que anteriormente nos llego al correo en nuestro navegador. Si han pasado mas de 10 minutos la pagina nos mostrará el siguiente mensaje "Ha expirado el tiempo para cambiar tu contraseña". Si por el contrario estamos dentro del tiempo que configuramos nos mostrará el formulario y deberá lucir como este:

change password form

Si intentas escribir te darás cuenta que entra en ejecución las funciones javascript que creamos para validar el formulario.

Contraseña invalida
change password form error

Contraseña valida
change password form ok

Ya con esto tenemos finalizada la parte de la construcción y validación del formulario para el cambio de contraseña.

Cambiando la contraseña

Ya tendiendo listo nuestro formulario, vamos a editar el archivo app/controller/password_reset.rb e implementamos la función reset usando el siguiente código:

def reset  
    email = params[:email]
    @user = User.find_by(email: email)
    time_difference_minutes = (Time.now - @user.password_recovery_expiration) / 60
    if time_difference_minutes < @@EXPIRATION_TIME_MINUTES
        new_password = params['user']['password']
        @user.password = new_password
        @user.password_recovery_token = nil
        @user.password_recovery_expiration = nil
        if @user.save!
            flash[:success] = "Contraseña modificada correctamente."
        else
            flash[:danger] = "Ocurrió un problema al cambiar la contraseña intenta de nuevo."
        end
    else
        flash[:danger] = "Ha expirado el tiempo para cambiar tu contraseña"
    end
end  

explicaré un poco el código anterior.

Lo primero que hacemos es obtener el parámetro email que llega del formulario y luego buscar un usuario por email.

email = params[:email]  
@user = User.find_by(email: email)

Luego de esto calculamos si la vigencia del token en minutos

time_difference_minutes = (Time.now - @user.password_recovery_expiration) / 60  

Si la vigencia del token esta en el rango que definimos el cual es de 10 minutos obtenemos la contraseña nueva y la asignamos a nuestro usuario, ademas debemos borrar el token y la vigencia que definimos ya que una vez cambiada la contraseña no serán necesarios.

new_password = params['user']['password']  
@user.password = new_password
@user.password_recovery_token = nil
@user.password_recovery_expiration = nil

Actualizamos la información de nuestro usuario y mostramos los mensajes correspondientes según sea el caso, exitoso o fallido.

if @user.save!  
  flash[:success] = "Contraseña modificada correctamente."
else  
  flash[:danger] = "Ocurrió un problema al cambiar la contraseña intenta de nuevo."
end  

Por ultimo creamos un nuevo archivo llamado reset.html.erb dentro de la carpeta app/views/password_reset/ con el siguiente código:

<head>  
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity=" sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
</head>

<% flash.each do |key, message| %>  
    <div class="alert alert-<%= key %>" role="alert"><p style="text-align:center"><%= message %></p></div>
<% end %>  

Este ultimo archivo permite mostrar el mensaje resultante del proceso de cambio de contraseña y sera llamado en el momento de hacer clic sobre el botón cambiar contraseña del formulario.

Listo! hemos terminado nuestra implementación ahora podemos hacer una prueba completa y verificar que la contraseña se ha cambiado exitosamente.

Puedes descargar el código fuente del proyecto desde GitHub

Jose Aponte

Desarrollador full-stack apasionado por las tecnologías de información y los lenguajes de programación. Me gustan divertirme con mi familia, mi lema es "Nunca paras de Aprender"

Bogota

Subscribe to Jappsku Engineering Blog

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!