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.
🚀 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.
🛠️ 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.');
}
}
};
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.
🛡️ 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;
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
}
}
},
Diagrama de Flujo de la Navegación con RBAC
🔄 Pruebas y Consideraciones Adicionales
Escenario de Pruebas
Para verificar que tu implementación de RBAC funciona correctamente, realiza los siguientes pasos:
- No logeado: Intenta acceder a
/admin,/editor,/profile. Deberías ser redirigido a/login. - Login como Viewer:
- Navega a
/adminy/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 conv-has-role="'admin'"ov-has-role="'editor'"están ocultos.
- Navega a
- Login como Editor:
- Navega a
/admin. Deberías ser bloqueado. - Navega a
/editory/profile. Deberían funcionar. Observa los elementos de UI.
- Navega a
- Login como Admin:
- Navega a
/admin,/editor,/profile. Todas deberían ser accesibles y todos los elementos de UI conv-has-roledeberían mostrarse si el rol 'admin' está incluido en los requisitos.
- Navega a
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,sessionStorageo un sistema de gestión de estado que se hidrate). Nuestroauth.jses 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 composableuseAuth()que exponga funciones comocanAccess(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
- Optimización del Bundle en Vue 3: Reduciendo el Tamaño de tu Aplicación Web para Mayor Velocidadintermediate18 min
- Migrando de Options API a Composition API en Vue 3: Una Guía Prácticaintermediate15 min
- Gestión de Estado Centralizada con Pinia en Vue 3: Guía Completaintermediate18 min
- Controlando la Visibilidad: Directivas v-if, v-show y v-for en Vue 3 para Renderizado Condicional y Listasintermediate15 min
- Domina la Reactividad: Explorando Refs y Reactive en Vue 3 para una Gestión de Estado Eficienteintermediate18 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!