tutoriales.com

Control de Acceso Basado en Roles (RBAC) en Vue 3 con Directivas Personalizadas y Vue Router

Este tutorial te guiará paso a paso en la implementación de un sistema de Control de Acceso Basado en Roles (RBAC) en tus aplicaciones Vue 3. Aprenderás a usar directivas personalizadas para proteger elementos de la interfaz de usuario y guardianes de ruta de Vue Router para controlar el acceso a páginas enteras, mejorando significativamente la seguridad y la experiencia del usuario.

Intermedio20 min de lectura11 views
Reportar error

🚀 Introducción al RBAC en Vue 3

El Control de Acceso Basado en Roles (RBAC) es un método esencial para gestionar qué usuarios pueden realizar determinadas acciones o acceder a ciertos recursos dentro de una aplicación. En el desarrollo web moderno, especialmente con frameworks de frontend como Vue.js, implementar RBAC de manera efectiva es crucial para la seguridad y la usabilidad.

Este tutorial se centrará en cómo puedes integrar RBAC en tu aplicación Vue 3 utilizando directivas personalizadas para la visibilidad de elementos de la UI y guardianes de ruta (route guards) de Vue Router para proteger vistas completas. ¡Prepárate para llevar la seguridad de tu aplicación al siguiente nivel! 🔒

¿Por qué RBAC en el Frontend?

Aunque la seguridad real siempre debe residir en el backend, implementar controles de acceso en el frontend mejora la experiencia del usuario al ocultar funcionalidades no disponibles y evita peticiones innecesarias a recursos prohibidos. Además, ofrece una capa visual inmediata sobre los permisos del usuario.

🔥 Importante: La seguridad del frontend es solo una medida de conveniencia y UX. Nunca confíes únicamente en ella para la validación de permisos críticos; siempre valida los permisos también en el backend.

🛠️ Configuración Inicial del Proyecto Vue 3

Para empezar, asumiremos que ya tienes un proyecto Vue 3 configurado. Si no es así, puedes crear uno rápidamente:

npm init vue@latest

Durante la configuración, asegúrate de añadir Vue Router a tu proyecto. Lo necesitaremos para los guardianes de ruta.

Estructura de Roles y Usuarios

Para simular nuestro sistema de RBAC, crearemos un pequeño módulo de auth que gestionará los roles del usuario actual. En una aplicación real, esta información vendría del backend tras la autenticación.

Crearemos un archivo src/stores/auth.js (o src/utils/auth.js si no usas Pinia/Vuex):

// src/stores/auth.js (ejemplo simple sin Pinia/Vuex)

import { ref } from 'vue';

const currentUser = ref(null); // Podría ser un objeto con { id, name, roles }

const auth = {
  login: (user, roles) => {
    // En un escenario real, aquí harías una petición al backend
    // y recibirías el usuario y sus roles.
    currentUser.value = { ...user, roles };
    console.log(`Usuario logeado: ${user.name}, Roles: ${roles.join(', ')}`);
  },
  logout: () => {
    currentUser.value = null;
    console.log('Usuario deslogeado.');
  },
  hasRole: (role) => {
    if (!currentUser.value) return false;
    if (typeof role === 'string') {
      return currentUser.value.roles.includes(role);
    }
    if (Array.isArray(role)) {
      return role.some(r => currentUser.value.roles.includes(r));
    }
    return false;
  },
  hasAnyRole: (roles) => {
    return auth.hasRole(roles);
  },
  hasAllRoles: (roles) => {
    if (!currentUser.value) return false;
    if (!Array.isArray(roles)) return false;
    return roles.every(r => currentUser.value.roles.includes(r));
  },
  isAuthenticated: () => {
    return !!currentUser.value;
  },
  getUser: () => {
    return currentUser.value;
  }
};

export default auth;

Para facilitar las pruebas, vamos a añadir un par de botones en App.vue para cambiar de usuario:

<!-- src/App.vue (fragmento) -->
<template>
  <header>
    <div class="wrapper">
      <nav>
        <RouterLink to="/">Inicio</RouterLink>
        <RouterLink to="/admin">Admin</RouterLink>
        <RouterLink to="/editor">Editor</RouterLink>
        <RouterLink to="/profile">Perfil</RouterLink>
      </nav>
      <div style="margin-top: 10px;">
        <button @click="loginAs('admin')">Login Admin</button>
        <button @click="loginAs('editor')">Login Editor</button>
        <button @click="loginAs('viewer')">Login Viewer</button>
        <button @click="logout">Logout</button>
        <span v-if="auth.isAuthenticated()" style="margin-left: 10px;">
          Hola, {{ auth.getUser().name }} ({{ auth.getUser().roles.join(', ') }})
        </span>
        <span v-else style="margin-left: 10px;">No logeado</span>
      </div>
    </div>
  </header>

  <RouterView />
</template>

<script setup>
import { RouterLink, RouterView } from 'vue-router';
import auth from '@/stores/auth'; // Asegúrate de que la ruta sea correcta
import { onMounted } from 'vue';

const loginAs = (roleType) => {
  let user;
  if (roleType === 'admin') {
    user = { id: 1, name: 'Admin User' };
    auth.login(user, ['admin', 'editor', 'viewer']);
  } else if (roleType === 'editor') {
    user = { id: 2, name: 'Editor User' };
    auth.login(user, ['editor', 'viewer']);
  } else {
    user = { id: 3, name: 'Viewer User' };
    auth.login(user, ['viewer']);
  }
};

const logout = () => {
  auth.logout();
};

// Para ver el estado inicial
onMounted(() => {
  console.log('Estado inicial de autenticación:', auth.getUser());
});
</script>

<style scoped>
/* Estilos básicos para el navbar y botones */
nav {
  width: 100%;
  font-size: 12px;
  text-align: center;
  margin-top: 2rem;
}

nav a.router-link-exact-active {
  color: var(--color-text);
}

nav a.router-link-exact-active:hover {
  background-color: transparent;
}

nav a {
  display: inline-block;
  padding: 0 1rem;
  border-left: 1px solid var(--color-border);
}

nav a:first-of-type {
  border: 0;
}

button {
  margin-right: 10px;
  padding: 8px 15px;
  cursor: pointer;
  border: 1px solid #ccc;
  border-radius: 5px;
  background-color: #f0f0f0;
}
button:hover {
  background-color: #e0e0e0;
}
</style>

✨ Directivas Personalizadas para Control de UI

Las directivas personalizadas en Vue 3 son una herramienta poderosa para manipular el DOM directamente, y son perfectas para controlar la visibilidad de elementos basados en roles. Crearemos una directiva v-has-role.

Creando la Directiva v-has-role

En src/directives/hasRole.js:

// src/directives/hasRole.js
import auth from '@/stores/auth';

export default {
  mounted(el, binding) {
    const requiredRoles = binding.value; // Puede ser un string o un array

    if (!auth.isAuthenticated()) {
      el.style.display = 'none';
      return;
    }

    if (typeof requiredRoles === 'string') {
      if (!auth.hasRole(requiredRoles)) {
        el.style.display = 'none';
      }
    } else if (Array.isArray(requiredRoles)) {
      const hasAnyRole = requiredRoles.some(role => auth.hasRole(role));
      if (!hasAnyRole) {
        el.style.display = 'none';
      }
    } else {
      console.warn('v-has-role: El valor de la directiva debe ser una cadena o un array de cadenas.');
    }
  },
  updated(el, binding) {
    // La lógica de actualización es similar a la de montaje en este caso simple
    // para manejar cambios dinámicos en los roles del usuario o el valor de la directiva
    const requiredRoles = binding.value;

    // Restablecer el estilo por defecto antes de aplicar la lógica
    el.style.display = ''; 

    if (!auth.isAuthenticated()) {
      el.style.display = 'none';
      return;
    }

    if (typeof requiredRoles === 'string') {
      if (!auth.hasRole(requiredRoles)) {
        el.style.display = 'none';
      }
    } else if (Array.isArray(requiredRoles)) {
      const hasAnyRole = requiredRoles.some(role => auth.hasRole(role));
      if (!hasAnyRole) {
        el.style.display = 'none';
      }
    } else {
      console.warn('v-has-role: El valor de la directiva debe ser una cadena o un array de cadenas.');
    }
  }
};
💡 Consejo: `v-has-role` oculta elementos cambiando su `display` CSS a `none`. Si quieres removerlos completamente del DOM, deberías usar `v-if` con una función de verificación de roles o una directiva personalizada que manipule el DOM de forma más agresiva.

Registrando la Directiva Globalmente

Para poder usar v-has-role en cualquier componente, debemos registrarla globalmente en src/main.js:

// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import hasRole from './directives/hasRole'; // Importa tu directiva

const app = createApp(App);

app.use(router);
app.directive('has-role', hasRole); // Registra la directiva

app.mount('#app');

Uso de v-has-role en Componentes

Ahora podemos usar v-has-role en cualquier componente para mostrar u ocultar elementos. Vamos a modificar un componente de ejemplo, src/components/TheWelcome.vue, y crear algunas vistas de prueba (AdminView.vue, EditorView.vue, ProfileView.vue).

Primero, las vistas de prueba:

<!-- src/views/AdminView.vue -->
<template>
  <main>
    <h1>Panel de Administración</h1>
    <p>Solo visible para administradores.</p>
    <div v-has-role="'admin'">
      <p>Este texto solo lo ve un **admin**.</p>
      <button>Gestionar Usuarios</button>
      <button v-has-role="['admin', 'super-admin']">Configuración Avanzada</button>
    </div>
    <p v-has-role="'editor'">Este párrafo solo lo ven los **editores** (y admins si tienen el rol).</p>
  </main>
</template>

<script setup>
// No necesita lógica específica aquí para la directiva
</script>

<!-- src/views/EditorView.vue -->
<template>
  <main>
    <h1>Panel de Edición</h1>
    <p>Solo visible para editores o administradores.</p>
    <div v-has-role="'editor'">
      <p>Puedes editar contenido aquí.</p>
      <button>Publicar Artículo</button>
    </div>
    <div v-has-role="'admin'">
      <p>Como **admin**, también puedes ver esto en la vista de editor.</p>
      <button>Revisar Estadísticas</button>
    </div>
  </main>
</template>

<script setup>
// ...
</script>
<!-- src/views/ProfileView.vue -->
<template>
  <main>
    <h1>Perfil de Usuario</h1>
    <p>Información del perfil, visible para todos los usuarios logeados.</p>
    <div v-if="auth.isAuthenticated()">
      <p>Bienvenido, {{ auth.getUser().name }}</p>
      <p>Tus roles: {{ auth.getUser().roles.join(', ') }}</p>
      <button v-has-role="'admin'">Cambiar permisos de otros</button>
      <button v-has-role="['editor', 'admin']">Acceder a herramientas premium</button>
      <button v-has-role="'viewer'">Ver solo mi perfil</button>
    </div>
    <div v-else>
      <p>Por favor, inicia sesión para ver tu perfil.</p>
    </div>
  </main>
</template>

<script setup>
import auth from '@/stores/auth';
</nscript>

Actualiza tu src/router/index.js para incluir estas nuevas rutas:

// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import auth from '@/stores/auth'; // Importa tu módulo de auth

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/admin',
      name: 'admin',
      component: () => import('../views/AdminView.vue')
    },
    {
      path: '/editor',
      name: 'editor',
      component: () => import('../views/EditorView.vue')
    },
    {
      path: '/profile',
      name: 'profile',
      component: () => import('../views/ProfileView.vue')
    }
  ]
});

// ... Aquí es donde añadirás los guardianes de ruta más adelante

export default router;

Ahora, cuando cambies de usuario en App.vue, verás cómo los botones y párrafos se muestran u ocultan dinámicamente según el rol del usuario logeado. La directiva v-has-role se encarga de la lógica de visibilidad en el DOM.

50% Completado

🛡️ Guardianes de Ruta para Control de Acceso a Vistas

Mientras que las directivas personalizadas son excelentes para controlar elementos dentro de una vista, los guardianes de ruta (Route Guards) de Vue Router son la herramienta ideal para controlar el acceso a vistas completas antes de que se carguen. Vue Router ofrece tres tipos de guardianes: globales, por ruta y por componente.

Para RBAC, los guardianes globales y por ruta son los más útiles.

Guardianes Globales: beforeEach

Un guardián global se ejecuta antes de cada navegación. Es perfecto para verificar si un usuario está autenticado y tiene los roles necesarios para acceder a cualquier ruta que lo requiera.

Modifica src/router/index.js para añadir un guardián beforeEach:

// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import auth from '@/stores/auth'; // Importa tu módulo de auth

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView,
      meta: { requiresAuth: false } // No requiere autenticación
    },
    {
      path: '/admin',
      name: 'admin',
      component: () => import('../views/AdminView.vue'),
      meta: { requiresAuth: true, roles: ['admin'] } // Requiere rol 'admin'
    },
    {
      path: '/editor',
      name: 'editor',
      component: () => import('../views/EditorView.vue'),
      meta: { requiresAuth: true, roles: ['editor', 'admin'] } // Requiere 'editor' o 'admin'
    },
    {
      path: '/profile',
      name: 'profile',
      component: () => import('../views/ProfileView.vue'),
      meta: { requiresAuth: true } // Solo requiere autenticación, cualquier rol
    },
    {
      path: '/login',
      name: 'login',
      component: () => import('../views/LoginView.vue'), // Crea esta vista si la necesitas
      meta: { requiresAuth: false } // Página de login
    },
    {
      path: '/:pathMatch(.*)*', // Catch-all 404
      name: 'NotFound',
      component: () => import('../views/NotFoundView.vue') // Crea esta vista
    }
  ]
});

router.beforeEach((to, from, next) => {
  const requiresAuth = to.meta.requiresAuth;
  const requiredRoles = to.meta.roles;

  if (requiresAuth && !auth.isAuthenticated()) {
    // Si la ruta requiere autenticación y el usuario no está logeado, redirige a login
    console.log('Redirigiendo a login: No autenticado.');
    next({ name: 'login' });
  } else if (requiresAuth && auth.isAuthenticated()) {
    // Si está autenticado y la ruta tiene roles requeridos
    if (requiredRoles) {
      const userRoles = auth.getUser().roles;
      const hasPermission = requiredRoles.some(role => userRoles.includes(role));

      if (!hasPermission) {
        // Si el usuario no tiene los roles necesarios, redirige a una página de 'acceso denegado'
        console.log(`Acceso Denegado a ${to.path}: Rol insuficiente.`);
        alert('Acceso Denegado: No tienes los permisos necesarios.'); // O redirige a una vista 403
        next(false); // Cancelar la navegación
      } else {
        // El usuario está autenticado y tiene los roles
        next();
      }
    } else {
      // El usuario está autenticado y la ruta no requiere roles específicos
      next();
    }
  } else if (!requiresAuth && auth.isAuthenticated() && to.name === 'login') {
    // Si el usuario ya está logeado y trata de ir a la página de login, redirige a home
    next({ name: 'home' });
  } else {
    // La ruta no requiere autenticación
    next();
  }
});

export default router;
📌 Nota: He añadido una meta `requiresAuth` y `roles` a las definiciones de ruta. Estas propiedades `meta` son accesibles dentro de los guardianes de ruta y nos permiten definir los requisitos de acceso para cada ruta.

Para que este guardián funcione, necesitarás crear una LoginView.vue y una NotFoundView.vue (o AccessDeniedView.vue) básicas:

<!-- src/views/LoginView.vue -->
<template>
  <main>
    <h1>Iniciar Sesión</h1>
    <p>Por favor, inicia sesión para acceder a las funcionalidades.</p>
    <button @click="loginAs('viewer')">Iniciar sesión como Viewer</button>
    <button @click="loginAs('editor')">Iniciar sesión como Editor</button>
    <button @click="loginAs('admin')">Iniciar sesión como Admin</button>
  </main>
</template>

<script setup>
import auth from '@/stores/auth';
import { useRouter } from 'vue-router';

const router = useRouter();

const loginAs = (roleType) => {
  let user;
  if (roleType === 'admin') {
    user = { id: 1, name: 'Admin User' };
    auth.login(user, ['admin', 'editor', 'viewer']);
  } else if (roleType === 'editor') {
    user = { id: 2, name: 'Editor User' };
    auth.login(user, ['editor', 'viewer']);
  } else {
    user = { id: 3, name: 'Viewer User' };
    auth.login(user, ['viewer']);
  }
  router.push('/'); // Redirige al inicio después de login
};
</script>
<!-- src/views/NotFoundView.vue o AccessDeniedView.vue -->
<template>
  <main>
    <h1>404 - Página no encontrada o Acceso Denegado</h1>
    <p>Lo sentimos, la página que buscas no existe o no tienes permiso para verla.</p>
    <RouterLink to="/">Ir a la página de inicio</RouterLink>
  </main>
</template>

<script setup>
import { RouterLink } from 'vue-router';
</script>

Ahora, intenta navegar a /admin sin ser admin, o a /editor sin ser editor. El guardián global beforeEach interceptará la navegación y actuará en consecuencia.

Guardianes por Ruta: beforeEnter

Los guardianes por ruta son definidos directamente en la configuración de una ruta, lo que los hace ideales para lógica específica de esa ruta sin afectar el flujo global. Aunque beforeEach es muy potente para RBAC general, beforeEnter puede ser útil para excepciones o lógicas más granulares.

Por ejemplo, si quisieras una comprobación adicional solo para la ruta /admin que dependiera de un estado específico del administrador:

// src/router/index.js (fragmento dentro de 'routes')
{
  path: '/admin',
  name: 'admin',
  component: () => import('../views/AdminView.vue'),
  meta: { requiresAuth: true, roles: ['admin'] },
  beforeEnter: (to, from, next) => {
    // Lógica adicional para el admin, por ejemplo, verificar si el admin está 'activo'
    // Esto se ejecutará *después* del beforeEach global, si ese permite la navegación.
    console.log('Ejecutando beforeEnter para /admin');
    // Supongamos que necesitamos un token especial para ciertos admins
    const hasSpecialToken = localStorage.getItem('admin_token_active') === 'true';

    if (!hasSpecialToken && auth.hasRole('admin')) {
      alert('Necesitas activar tu token de administrador para esta sección.');
      next(false); // Bloquea la navegación
    } else {
      next(); // Continúa la navegación
    }
  }
},
⚠️ Advertencia: Los guardianes `beforeEnter` se ejecutan después de los guardianes globales `beforeEach`. Ten en cuenta el orden de ejecución y la posible redundancia de comprobaciones.

Diagrama de Flujo de la Navegación con RBAC

Inicio ¿Ruta requiere autenticación? No Si ¿Usuario autenticado? No Si Redirigir a Login ¿Ruta requiere roles específicos? No Si ¿Usuario tiene roles requeridos? No Si Acceso Denegado Ir a ruta

🔄 Pruebas y Consideraciones Adicionales

Escenario de Pruebas

Para verificar que tu implementación de RBAC funciona correctamente, realiza los siguientes pasos:

  1. No logeado: Intenta acceder a /admin, /editor, /profile. Deberías ser redirigido a /login.
  2. Login como Viewer:
    • Navega a /admin y /editor. Deberías recibir una alerta de 'Acceso Denegado' o ser redirigido a una página de error.
    • Navega a /profile. Debería funcionar. Observa que los elementos con v-has-role="'admin'" o v-has-role="'editor'" están ocultos.
  3. Login como Editor:
    • Navega a /admin. Deberías ser bloqueado.
    • Navega a /editor y /profile. Deberían funcionar. Observa los elementos de UI.
  4. Login como Admin:
    • Navega a /admin, /editor, /profile. Todas deberían ser accesibles y todos los elementos de UI con v-has-role deberían mostrarse si el rol 'admin' está incluido en los requisitos.
💡 Consejo: Abre la consola del navegador para ver los mensajes de `console.log` que hemos añadido en `auth.js` y `router/index.js`, te ayudarán a depurar.

Mejora de la Experiencia de Usuario

  • Página de Acceso Denegado (403): En lugar de un alert(), es mejor redirigir al usuario a una página dedicada que explique por qué no puede acceder y le ofrezca opciones (ej. contactar al soporte, ir a la página de inicio).
  • Indicadores visuales: Puedes usar deshabilitar en lugar de ocultar elementos para roles sin permiso, para que el usuario sepa que la función existe pero no está disponible para él.
  • Feedback al usuario: Mensajes claros al usuario cuando se le deniega el acceso, tanto en la interfaz como en el enrutamiento.

Consideraciones Avanzadas

  • Estado persistente: En una aplicación real, los roles del usuario deben persistir a través de recargas de página (usando localStorage, sessionStorage o un sistema de gestión de estado que se hidrate). Nuestro auth.js es un ejemplo simplificado que se reinicia con cada recarga.
  • Composables para permisos: Para una lógica más compleja o para usar permisos en la <script setup>, podrías crear un composable useAuth() que exponga funciones como canAccess(role).
  • Tipado con TypeScript: Si usas TypeScript, definir interfaces para los roles y las metas de ruta mejorará la robustez de tu código.
  • Servicio de Permisos: Para aplicaciones grandes, centralizar la lógica de permisos en un servicio o módulo dedicado que las directivas y guardianes consuman. Esto facilita la gestión de permisos complejos (ej. can('edit-post', postId)).
Ejemplo de Composable `useAuth`
// src/composables/useAuth.js
import { computed } from 'vue';
import auth from '@/stores/auth';

export function useAuth() {
  const user = computed(() => auth.getUser());
  const isAuthenticated = computed(() => auth.isAuthenticated());

  const hasRole = (role) => auth.hasRole(role);
  const hasAnyRole = (roles) => auth.hasAnyRole(roles);
  const hasAllRoles = (roles) => auth.hasAllRoles(roles);

  return {
    user,
    isAuthenticated,
    hasRole,
    hasAnyRole,
    hasAllRoles,
    login: auth.login,
    logout: auth.logout
  };
}

Luego, en un componente:

<script setup>
import { useAuth } from '@/composables/useAuth';

const { user, isAuthenticated, hasRole } = useAuth();
</script>

<template>
  <div v-if="isAuthenticated.value">
    <p>Hola, {{ user.value.name }}</p>
    <button v-if="hasRole('admin')">Panel de Admin</button>
  </div>
</template>

✅ Conclusión

Has aprendido a implementar un sistema de Control de Acceso Basado en Roles (RBAC) en tu aplicación Vue 3, utilizando dos mecanismos clave:

  • Directivas Personalizadas (v-has-role): Para controlar la visibilidad de elementos de la UI de forma declarativa y sencilla.
  • Guardianes de Ruta de Vue Router (beforeEach): Para proteger rutas enteras y asegurar que solo los usuarios autorizados puedan acceder a ciertas vistas.

Combinando estas técnicas, puedes construir una aplicación Vue.js más segura y con una mejor experiencia de usuario, adaptando la interfaz y la navegación a los permisos específicos de cada rol. Recuerda siempre que la validación de seguridad final debe realizarse en el backend. ¡Ahora puedes proteger tus rutas y componentes con confianza! 🎉

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!