Autenticación y manejo de roles con RubyOnRails
— ruby, ruby on rails, jwt, programación — 6 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 -v2> ruby 2.6.1p33 (2019-01-30 revision 66950) [x86_64-linux]3$ rails -v4> 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:string2 rails g model Usuario correo:string password_digest:string rol:references3 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 < ApplicationRecord2 has_secure_password3 belongs_to :rol4end
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: &default2 adapter: mysql23 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>4 timeout: 50005
6development:7 <<: *default8 database: authTutorial9 username: tutorial10 password: JoralmoPro11 12test:13 <<: *default14 #database: db/test.sqlite315
16production:17 <<: *default18 #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 do8 usuario = Usuario.create(correo: Faker::Internet.email, password: "cliente", rol: cliente)9 Post.create(titulo: Faker::Book.title, contenido: Faker::Lorem.paragraph, usuario: usuario)10end11
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.2598s5== 20190411210050 CreateRols: migrated (0.2599s) ==============================6
7== 20190411210141 CreateUsuarios: migrating ===================================8-- create_table(:usuarios)9 -> 0.3437s10== 20190411210141 CreateUsuarios: migrated (0.3438s) ==========================11
12== 20190411210248 CreatePosts: migrating ======================================13-- create_table(:posts)14 -> 0.3559s15== 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:
gem 'jwt'
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 JsonWebToken2 JWT_SECRET = ENV["JWT_SECRET"]3 def self.encode(payload, exp = 24.hours.from_now)4 payload[:exp] = exp.to_i5 JWT.encode(payload, JWT_SECRET)6 end7 def self.decode(token)8 body = JWT.decode(token, JWT_SECRET)[0]9 HashWithIndifferentAccess.new body10 rescue JWT::ExpiredSignature, JWT::VerificationError => e11 raise ManejadorDeExcepciones::TokenExpirado, e.message12 rescue JWT::DecodeError, JWT::VerificationError => e13 raise ManejadorDeExcepciones::TokenNoValido, e.message14 end15end
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::Concern2 class TokenNoValido < StandardError; end3 class TokenExpirado < StandardError; end4 included do5 rescue_from ManejadorDeExcepciones::TokenNoValido do |_error|6 render json: {mensaje: "¡Acceso denegado!. Token inválido suministrado."}, status: :unauthorized7 end8 rescue_from ManejadorDeExcepciones::TokenExpirado do |_error|9 render json: {mensaje: "¡Acceso denegado!. Token expirado"}, status: :unauthorized10 end11 end12end
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::Base2 include ManejadorDeExcepciones3end
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 Authentication2 def initialize(usuario)3 @correo = usuario[:correo]4 @password = usuario[:password]5 @usuario = Usuario.find_by(correo: @correo)6 end7
8 def autenticar9 @usuario && @usuario.authenticate(@password)10 end11
12 def generar_token13 JsonWebToken.encode({usuario_id: @usuario.id, correo: @usuario.correo})14 end15end
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 do2 namespace :api do3 namespace :v1 do4 post 'login', to: 'session#login'5 end6 end7end
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)
y en session_controller
1class Api::V1::SessionController < ApplicationController2 def login3 auth = Authentication.new(login_params)4 if(auth.autenticar)5 render json: {mensaje: "¡Inicio de sesión correcto!", token: auth.generar_token}, status: :ok6 else7 render json: {mensaje: "Correo o Contraseña incorrectos"}, status: :unauthorized8 end9 end10 private11 def login_params12 params.permit(:correo, :password)13 end14end
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)
¡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 Ability2 include CanCan::Ability3
4 def initialize(usuario)5 send("#{usuario.rol.nombre}_permisos", usuario)6 end7
8 def administrador_permisos(usuario)9 can :manage, :all10 end11 12 def cliente_permisos(usuario)13 can :read, Post, :all14 can :manage, Post, { usuario_id: usuario.id}15 can [:read, :update], Usuario, { id: usuario.id }16 end17
18 def visitante_permisos(usuario)19 can :read, Post, :all20 end21
22 def lista_de_permisos23 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_behavior27 object28 end29 end30end
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 < ApplicationController2 def index3 usuarios = Usuario.all4 render json: usuarios, status: :ok5 end6
7 def create8 usuario = Usuario.new(usuario_params)9 if usuario.save10 render json: usuario, status: :ok11 else12 render json: "error", status: :unprocessable_entity13 end14 end15
16 def show17 usuario = Usuario.find(params[:id])18 permisos = Ability.new(usuario).lista_de_permisos19 render json: {usuario: usuario, permisos: permisos}, status: :ok20 end21
22 private23 def usuario_params24 params.permit(:correo, :password, :rol_id)25 end26end
Y desde postman probamos con el usuario 1 que es el admin
Con el usuario 2 que es un usuario normal (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::Base2 protect_from_forgery with: :null_session3 include ManejadorDeExcepciones4
5 rescue_from CanCan::AccessDenied do |exception|6 render json: { mensaje: exception.message }, status: 4037 end8
9 def current_user10 if token11 @usuario_actual ||= Usuario.find(token[:usuario_id])12 else13 @usuario_actual ||= Usuario.new(rol_id: 3)14 end15 end16
17 private18 def token19 valor = request.headers[:Authorization]20 return if valor.blank?21 @token ||= JsonWebToken.decode(valor.split(" ").last)22 end23end
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 < ApplicationController2 load_and_authorize_resource class: "Usuario"3
4 def index5 usuarios = Usuario.all6 render json: usuarios, status: :ok7 end8
9 def create10 usuario = Usuario.new(usuario_params)11 if usuario.save12 render json: usuario, status: :ok13 else14 render json: "error", status: :unprocessable_entity15 end16 end17
18 def show19 usuario = Usuario.find(params[:id])20 render json: usuario, status: :ok21 end22
23 private24 def usuario_params25 params.permit(:correo, :password, :rol_id)26 end27end
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
pero si lo hacemos con el token de un usuario normal
igual sucede con el usuario visitante
El enviroment en postman contiene los tokens (el del visitante simplemente no es nada)
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::Base2 before_action :set_locale3 protect_from_forgery with: :null_session4 include ManejadorDeExcepciones5
6 rescue_from CanCan::AccessDenied do |exception|7 render json: { mensaje: exception.message }, status: 4038 end9
10 def current_user11 if token12 @usuario_actual ||= Usuario.find(token[:usuario_id])13 else14 @usuario_actual ||= Usuario.new(rol_id: 3)15 end16 end17
18 private19 def token20 valor = request.headers[:Authorization]21 return if valor.blank?22 @token ||= JsonWebToken.decode(valor.split(" ").last)23 end24 def set_locale25 I18n.locale = "es"26 end27end
Haremos otra prueba y veremos los errores, en este caso trataremos de crear un nuevo usuario
Token de visitante
Token usuario
Y por ultimo con el administrador
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í.
¡Nos vemos el linea!