Skip to content
JoralmoPro
TwitterHomepage

Autenticación y manejo de roles con RubyOnRails

ruby, ruby on rails, jwt, programación6 min read

Como dice en el titulo del post trataré de hacer un tutorial de como implementar un sistema autenticación y de roles utilizando ROR, para esto crearé un proyecto en el que hay usuarios de 3 tipos (administrador, usuario, visitante) y cada usuario tiene posts y será una pequeña API por lo que no tendrá nada en el front, para la autenticación utilizaré el estandar JWT (Json Web Token) con la gema ruby-jwt, y para el manejo de roles una librería de autorización que es la gema cancancan.

Y bueno empecemos, asumo que tienes rails y ruby instalado en tu pc, para este tutorial he utilizado las versiones:

1$ ruby -v
2> ruby 2.6.1p33 (2019-01-30 revision 66950) [x86_64-linux]
3$ rails -v
4> Rails 5.2.3

Lo primero que debemos hacer es crear el proyecto con rails new authTutorial

Siguiente a esto creamos los modelos

1rails g model Rol nombre:string
2 rails g model Usuario correo:string password_digest:string rol:references
3 rails g model Post titulo:string contenido:string usuario:references

Importante el atributo password_digest que es el que le indica a rails que debe encriptar la contraseña al momento del registro.

Importante crearlos en este orden para que no haya problemas al momento de migrar la base de datos.

Los anteriores comandos nos generarán tres modelos y tres migraciones.

Ahora debemos ir al modelo de usuarios en app/models/usuario.rb y decirle a rails que es un modelo al cual le debe encriptar la contraseña agregandole has_secure_password

1class Usuario < ApplicationRecord
2 has_secure_password
3 belongs_to :rol
4end

El belogns_to se agrega automaticamente al momento de crear el modelo con el generador

Lo que haremos ahora será configurar nuestra base de datos en /config/database.yml en el cual para este tutorial yo utilizaré la gema mysql2, por lo tanto en el Gemfile reemplazaré sqlite3 por mysql2

database.yml

1default: &default
2 adapter: mysql2
3 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
4 timeout: 5000
5
6development:
7 <<: *default
8 database: authTutorial
9 username: tutorial
10 password: JoralmoPro
11
12test:
13 <<: *default
14 #database: db/test.sqlite3
15
16production:
17 <<: *default
18 #database: db/production.sqlite3

Una vez terminado con este archivo, pasamos al seed (ahora explico porque he comentado las bases de datos de los otros dos enviroments).

En el seed vamos a crear unos cuantos datos para la base de datos, utilizaré faker para estó, por lo tanto en el Gemfile toca agregar gem'faker' por lo menos yo lo he agregado solamente para el grupo de development y test, adicionalmente descomentar gem 'bcrypt' para la contraseña del usuario, luego hacemos bundle install

Una vez agregada la gema vamos al archivo db/seeds.rb

1administrador = Rol.create(nombre: "administrador")
2cliente = Rol.create(nombre: "cliente")
3visitante = Rol.create(nombre: "visitante")
4
5Usuario.create(correo: "joralmopro@gmail.com", password: "123456", rol: administrador)
6
75.times do
8 usuario = Usuario.create(correo: Faker::Internet.email, password: "cliente", rol: cliente)
9 Post.create(titulo: Faker::Book.title, contenido: Faker::Lorem.paragraph, usuario: usuario)
10end
11
12p "#{Usuario.count} usuarios creados"
13p "#{Rol.count} roles creados"
14p "#{Post.count} post creados"

Esto creará los tres roles mencionados al principio, un usuario administrador y 5 usuarios normales y al final nos mostrará en consola cuantas entidades a creado de cada modelo.

Teniendo ya esto listo procedemos a ejecutar en la consola

rails db:create; and rails db:migrate; and rails db:seed (Yo uso fish en ubuntu, si usas bash normal reemplazar los ; and por &&)

El anterior comando es el motivo por el que he comentado las bases de datos de los otros dos enviromentes pues al hacer db:create intentará crear las bases de datos y dará error pues no tengo sqlite3, en caso de necesitarlas solo escribes en consola rails db:environment:set RAILS_ENV=development para que siempre utilice el enviroment development.

Despues de ejecutar el comando anterior deberas ver algo como esto en la consola

1Created database 'authTutorial'
2== 20190411210050 CreateRols: migrating =======================================
3-- create_table(:rols)
4 -> 0.2598s
5== 20190411210050 CreateRols: migrated (0.2599s) ==============================
6
7== 20190411210141 CreateUsuarios: migrating ===================================
8-- create_table(:usuarios)
9 -> 0.3437s
10== 20190411210141 CreateUsuarios: migrated (0.3438s) ==========================
11
12== 20190411210248 CreatePosts: migrating ======================================
13-- create_table(:posts)
14 -> 0.3559s
15== 20190411210248 CreatePosts: migrated (0.3560s) =============================
16
17"6 usuarios creados"
18"3 roles creados"
19"5 post creados"

Y hasta ahora todo bien, todo correcto :v

Pero empecemos con lo más interesante, vamos con la autenticación, para esto es necesario primero que todo incluir en el Gemfile dos gemas:

  1. gem 'jwt'
  2. gem 'dotenv-rails'

la primera es para jwt y la segunda es para poder utilizar el archivo .env que es donde colocaremos la key necesaria para el jwt, no olvidar el bundle install y luego en la raiz del proyecto crear el archivo .env y agregar JWT_SECRET='l4m3j0rc14v3d31mund0' donde la clave puede ser lo que quieras.

Continuamos ahora creando en /app una carpeta llamada jwt y dentro el archivo json_web_token.rb que es donde tendremos la clase JsonWebToken, el archivo contendra lo siguiente

1class JsonWebToken
2 JWT_SECRET = ENV["JWT_SECRET"]
3 def self.encode(payload, exp = 24.hours.from_now)
4 payload[:exp] = exp.to_i
5 JWT.encode(payload, JWT_SECRET)
6 end
7 def self.decode(token)
8 body = JWT.decode(token, JWT_SECRET)[0]
9 HashWithIndifferentAccess.new body
10 rescue JWT::ExpiredSignature, JWT::VerificationError => e
11 raise ManejadorDeExcepciones::TokenExpirado, e.message
12 rescue JWT::DecodeError, JWT::VerificationError => e
13 raise ManejadorDeExcepciones::TokenNoValido, e.message
14 end
15end

Dos funciones, encode y decode.

La primera recibe dos parametros (aunque el segundo puede ser vacio y por defecto le pondrá 24 horas), y crea el token.

La segunda solo recibe como parametro un token y lo decodifica, luego se hace un rescue para manejar los errores en caso de que el token haya vencido o no sea valido, este manejador de errores lo crearemos a continuación, por lo tanto procedemos a crear un archivo en app/controllers/concerns/manejador_de_excepciones.rb

1module ManejadorDeExcepciones extend ActiveSupport::Concern
2 class TokenNoValido < StandardError; end
3 class TokenExpirado < StandardError; end
4 included do
5 rescue_from ManejadorDeExcepciones::TokenNoValido do |_error|
6 render json: {mensaje: "¡Acceso denegado!. Token inválido suministrado."}, status: :unauthorized
7 end
8 rescue_from ManejadorDeExcepciones::TokenExpirado do |_error|
9 render json: {mensaje: "¡Acceso denegado!. Token expirado"}, status: :unauthorized
10 end
11 end
12end

Esté archivo recibira los errores de la clase JWT y los manejará para devolver el mensaje de error, ahora tenemos que incluirlo en el appliation_controller para poder utilizarlo en las demás clases

1class ApplicationController < ActionController::Base
2 include ManejadorDeExcepciones
3end

Ahora procedemos a crear una carpeta llamada auth (o como quieran) dentro de app y dentro un archivo authentication.rb que es donde estará la clase Authentication que será la encargada de autenticar al usuario y generar el token, esperemos que se entienda con solo verla.

1class Authentication
2 def initialize(usuario)
3 @correo = usuario[:correo]
4 @password = usuario[:password]
5 @usuario = Usuario.find_by(correo: @correo)
6 end
7
8 def autenticar
9 @usuario && @usuario.authenticate(@password)
10 end
11
12 def generar_token
13 JsonWebToken.encode({usuario_id: @usuario.id, correo: @usuario.correo})
14 end
15end

teniendo esto, esperariamos que nuestra autenticación ya funcionara, entonces la probamos creando una ruta al controlador de sesión en config/routes.rb (como estoy manejando las carpetas ap1 y v1 en mi proyecto colocaré los namespace, si quieres los puedes obviar)

1Rails.application.routes.draw do
2 namespace :api do
3 namespace :v1 do
4 post 'login', to: 'session#login'
5 end
6 end
7end

Ahora creamos los controladores para ir probando que todo funciona hasta aquí, quedaría algo así (obviar las carpetas api y v1 si lo deseas)

controladores

y en session_controller

1class Api::V1::SessionController < ApplicationController
2 def login
3 auth = Authentication.new(login_params)
4 if(auth.autenticar)
5 render json: {mensaje: "¡Inicio de sesión correcto!", token: auth.generar_token}, status: :ok
6 else
7 render json: {mensaje: "Correo o Contraseña incorrectos"}, status: :unauthorized
8 end
9 end
10 private
11 def login_params
12 params.permit(:correo, :password)
13 end
14end

y luego para poder probar, ruby espera un csrf_token en cada solicitud pero aquí no lo utilizaremos por lo que nos toca deshabilitarlo agregando en application_controller protect_from_forgery with: :null_session y ahora si podremos probar (en mi caso lo haré con postman y enviando el usuario que he creado en el seed como admin)

primerPruebaLogin

¡Y hasta aquí todo está funcionando correctamente!

pero hasta este punto todo el mundo puede hacer lo que le de la gana despues de haber obtenido el token, hasta un visitante podría eliminar o actualizar algo (en caso de tener creados los metodos en los controladores) y es cuando entra en juego la gema cancancan que es la que nos permitirá gestionar que permisos tiene cada usuario dependiendo de su rol, y lo hace de una manera muy elegante, lo primero entonces es instalarla agregando gem 'cancancan' en el Gemfile y luego como no ejecutar bundle install, a continuación despues de instalada debemos ejecutar en consola rails g cancan:ability para que se cree el archivo ability.rb dentro de la carpeta app/models/, si da error pues lo crear manual xD.

Una vez ubicado el archivo vemos que tiene la clase Ability creada y el inicializador de está misma, como nuestro proyecto tiene 3 roles lo que haremos es que cada vez que soliciten autorización les devolveremos una función dependiendo del rol del usuario para saber que puede hacer y que no, así:

1class Ability
2 include CanCan::Ability
3
4 def initialize(usuario)
5 send("#{usuario.rol.nombre}_permisos", usuario)
6 end
7
8 def administrador_permisos(usuario)
9 can :manage, :all
10 end
11
12 def cliente_permisos(usuario)
13 can :read, Post, :all
14 can :manage, Post, { usuario_id: usuario.id}
15 can [:read, :update], Usuario, { id: usuario.id }
16 end
17
18 def visitante_permisos(usuario)
19 can :read, Post, :all
20 end
21
22 def lista_de_permisos
23 rules.map do |rule|
24 object = { acciones: rule.actions, sobre: rule.subjects.map{ |s| s.is_a?(Symbol) ? s : s.name } }
25 object[:condiciones] = rule.conditions unless rule.conditions.blank?
26 object[:inverted] = true unless rule.base_behavior
27 object
28 end
29 end
30end

Vemos tres funciones una por cada rol, y lo que dice dentro de cada una es que el administrador puede hacer de todo, el cliente puede leer todos los post pero solo puede actualizar, crear, eliminar y borrar post que le pertenezcan a el y además solo podra ver y actualizar un usuario si es el mismo, en cuando al visitante solamente puede leer todos los post, esto es maravilloso nos ahorramos hacer un monton de if (que yo habría hecho si esta gema no existiera xD) en cada controlador dependiendo del rol del usuario; tambien vemos un metodo llamado lista_de_permisos que este lo que nos retorna es que puede hacer cada rol, ahora lo vemos.

Para probarlo en el controlador de usuarios escribiremos los siguientes metodos

1class Api::V1::UsuariosController < ApplicationController
2 def index
3 usuarios = Usuario.all
4 render json: usuarios, status: :ok
5 end
6
7 def create
8 usuario = Usuario.new(usuario_params)
9 if usuario.save
10 render json: usuario, status: :ok
11 else
12 render json: "error", status: :unprocessable_entity
13 end
14 end
15
16 def show
17 usuario = Usuario.find(params[:id])
18 permisos = Ability.new(usuario).lista_de_permisos
19 render json: {usuario: usuario, permisos: permisos}, status: :ok
20 end
21
22 private
23 def usuario_params
24 params.permit(:correo, :password, :rol_id)
25 end
26end

Y desde postman probamos con el usuario 1 que es el admin

lista de permisos admin

Con el usuario 2 que es un usuario normal (cliente)

lista de permisos cliente

y vemos que por cada petición nos retorna los permisos del usuario, las acciones que puede hacer, sobre que modelo y si tiene alguna condición para poder hacer esa acción, quizá para algo puede servir esté metodo en algun momento.

Pero continuemos con el proceso, ahora para que cancancan pueda saber que hacer al momento de hacer una petición a algún controlador donde definamos la autorización el asume que tenemos la clase ability (que ya la tenemos en la carpeta app/models) y tambien un metodo llamado current_user y por lo tanto este metodo es el que procederemos a escribir ahora, entonces nos vamos a app/controllers/application_controller.rb y es aquí donde escribiremos el metodo para poder tenerlo disponible en toda la aplicación quedando así

1class ApplicationController < ActionController::Base
2 protect_from_forgery with: :null_session
3 include ManejadorDeExcepciones
4
5 rescue_from CanCan::AccessDenied do |exception|
6 render json: { mensaje: exception.message }, status: 403
7 end
8
9 def current_user
10 if token
11 @usuario_actual ||= Usuario.find(token[:usuario_id])
12 else
13 @usuario_actual ||= Usuario.new(rol_id: 3)
14 end
15 end
16
17 private
18 def token
19 valor = request.headers[:Authorization]
20 return if valor.blank?
21 @token ||= JsonWebToken.decode(valor.split(" ").last)
22 end
23end

Lo que agregamos ahora es un rescue para manejar el error cuando el acceso sea denegado y devolver un error de no autorizado, el metodo current user que recupera el usuario actual dependiendo del token que venga en la solicitud y en caso de no haber ningun token se tomará como usuario visitante por eso se le asigna el rol tres (asumo que el rol con id tres es el visitante, lo pueden hacer dinamico con una consulta a la tabla rols).

Luego de esto volvemos el metodo show el usuarios_controller normal y le agregamos la directiva para que cancancan aplique la autorización en esa clase, así

1class Api::V1::UsuariosController < ApplicationController
2 load_and_authorize_resource class: "Usuario"
3
4 def index
5 usuarios = Usuario.all
6 render json: usuarios, status: :ok
7 end
8
9 def create
10 usuario = Usuario.new(usuario_params)
11 if usuario.save
12 render json: usuario, status: :ok
13 else
14 render json: "error", status: :unprocessable_entity
15 end
16 end
17
18 def show
19 usuario = Usuario.find(params[:id])
20 render json: usuario, status: :ok
21 end
22
23 private
24 def usuario_params
25 params.permit(:correo, :password, :rol_id)
26 end
27end

Y probamos!

Pedimos el usuario con id 3 y enviamos el token del administrador, y lo devolverá sin problemas pues el puede realizar está acción

autorizacion admin

pero si lo hacemos con el token de un usuario normal

autorizacion usuario normal

igual sucede con el usuario visitante

autorizacion visitante

El enviroment en postman contiene los tokens (el del visitante simplemente no es nada)

enviroment postman

Y con estás pruebas podemos ver que todo está funcionando correctamente!, ahora simplemente por deseo personal me gustaría ver el mensaje de error en español y de forma personalizada, por lo que toca hacer lo siguiente, en config/locales/ crearé el archivo es.yml y tendrá lo siguiente

1es:
2 unauthorized:
3 manage:
4 all: "No estás autorizado para: [%{action}] => [%{subject}]."

además de esto en application_controller agregamos un before_action y le pasamos una función para cambiar el locale de rails, quedaría así

1class ApplicationController < ActionController::Base
2 before_action :set_locale
3 protect_from_forgery with: :null_session
4 include ManejadorDeExcepciones
5
6 rescue_from CanCan::AccessDenied do |exception|
7 render json: { mensaje: exception.message }, status: 403
8 end
9
10 def current_user
11 if token
12 @usuario_actual ||= Usuario.find(token[:usuario_id])
13 else
14 @usuario_actual ||= Usuario.new(rol_id: 3)
15 end
16 end
17
18 private
19 def token
20 valor = request.headers[:Authorization]
21 return if valor.blank?
22 @token ||= JsonWebToken.decode(valor.split(" ").last)
23 end
24 def set_locale
25 I18n.locale = "es"
26 end
27end

Haremos otra prueba y veremos los errores, en este caso trataremos de crear un nuevo usuario

Token de visitante

crear usuario visitante

Token usuario

crear usuario usuario

Y por ultimo con el administrador

crear usuario admin

Y así mismo pasaría con los demás metodos, nuestra aplicación tendrá un manejo de autenticación un autorización un poco más organizado y mejor elaborado graciás a estás gemas.

Si algó no está bien escrito o hay algun error pido disculpas, para dudas o comentarios estoy en las redes como @JoralmoPro.

Para escribir este tutorial me he guidao y tal vez copiado algun trozo de código de aquí, aquí, aquí, aquí y aquí.

Repositorio

¡Nos vemos el linea!