Control de acceso basado en funciones

Añade a la aplicación el control de acceso basado en roles mediante guardias.
Introduce guardias de acceso, administración, grupo y rol para la protección de rutas.
Actualiza la barra lateral para incluir elementos de menú basados en roles.
Configura el cliente Keycloak para incluir información de grupo en el token de acceso, habilitando la autorización basada en grupos.
Añade la página de acceso denegado.
This commit is contained in:
luis cespedes 2025-05-20 10:42:24 -04:00
parent 55fc1f6278
commit 6b351ff5b3
24 changed files with 1175 additions and 829 deletions

View File

@ -1,59 +1,94 @@
import { Routes, PreloadAllModules } from '@angular/router';
import { Routes } from '@angular/router';
import { LoginComponent } from './pages/login/login.component';
import { LayoutComponent } from './components/layout/layout.component';
import { authGuard } from './guards/auth.guard';
import { groupGuard } from './guards/group.guard';
import { NotFoundComponent } from './pages/not-found/not-found.component';
import { AccessDeniedComponent } from './pages/access-denied/access-denied.component';
export const routes: Routes = [
// Public routes that don't require authentication
{
path: 'login',
component: LoginComponent,
data: { title: 'Login' }
},
// Protected routes that require authentication
{
path: '',
component: LayoutComponent,
canActivate: [authGuard],
children: [
{ path: '', redirectTo: 'inicio', pathMatch: 'full' },
{
path: 'inicio',
{
path: 'inicio',
loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent),
data: { title: 'Inicio' }
data: { title: 'Inicio' }
},
{
path: 'unidad-concesiones',
{
path: 'unidad-concesiones',
loadComponent: () => import('./pages/concesiones/concesiones.component').then(m => m.ConcesionesComponent),
data: { title: 'Unidad de Concesiones' }
data: {
title: 'Unidad de Concesiones',
}
},
{
path: 'ct-actualizacion',
{
path: 'ct-actualizacion',
loadComponent: () => import('./pages/actualizacion-pd/actualizacion-pd.component').then(m => m.ActualizacionPdComponent),
data: { title: 'Cronograma temporal por actualización de PD' }
data: {
title: 'Cronograma temporal por actualización de PD',
}
},
{
path: 'ct-ajuste',
{
path: 'ct-ajuste',
loadComponent: () => import('./pages/ajuste-pd/ajuste-pd.component').then(m => m.AjustePdComponent),
data: { title: 'Cronograma temporal por ajuste de PD' }
data: {
title: 'Cronograma temporal por ajuste de PD',
}
},
{
path: 'resumen',
{
path: 'resumen',
loadComponent: () => import('./pages/resumen/resumen.component').then(m => m.ResumenComponent),
data: { title: 'Resumen' }
data: {
title: 'Resumen',
}
},
{
path: 'unidad-informacion',
// Rutas específicas por grupos
{
path: 'grupos/admin',
loadComponent: () => import('./pages/admin-area/admin-area.component').then(m => m.AdminAreaComponent),
canActivate: [groupGuard],
data: {
title: 'Área de Administrador',
groups: ['administradores'] // Solo administradores pueden acceder
}
},
{
path: 'grupos/user',
loadComponent: () => import('./pages/user-area/user-area.component').then(m => m.UserAreaComponent),
canActivate: [groupGuard],
data: {
title: 'Área de Usuario',
groups: ['administradores', 'usuarios'] // Administradores y usuarios pueden acceder
}
},
{
path: 'unidad-informacion',
loadComponent: () => import('./pages/unidad-informacion/unidad-informacion.component').then(m => m.UnidadInformacionComponent),
data: { title: 'Unidad de Información' }
data: {
title: 'Unidad de Información',
}
},
{
path: '404',
{
path: '404',
component: NotFoundComponent,
data: { title: 'Error 404' }
data: { title: 'Error 404' }
},
{
path: 'access-denied',
component: AccessDeniedComponent,
data: { title: 'Acceso Denegado' }
},
]
},
{ path: '**', redirectTo: '404' }
];
];

View File

@ -7,12 +7,9 @@
<div class="version-badge">1.0</div>
</div>
<!-- Subtítulo del logo -->
</div>
<!-- Separador -->
<div class="separator"></div>
<!-- Navigation Menu -->
<div class="menu-container">
<ul class="sidebar-menu">
@ -23,7 +20,6 @@
<span class="active-indicator"></span>
</a>
</li>
<li class="menu-item" routerLinkActive="active">
<a routerLink="/unidad-concesiones" class="menu-link">
<i class="menu-icon pi pi-building"></i>
@ -31,7 +27,6 @@
<span class="active-indicator"></span>
</a>
</li>
<li class="menu-item" routerLinkActive="active">
<a routerLink="/ct-actualizacion" class="menu-link">
<i class="menu-icon pi pi-flag"></i>
@ -39,7 +34,6 @@
<span class="active-indicator"></span>
</a>
</li>
<li class="menu-item" routerLinkActive="active">
<a routerLink="/ct-ajuste" class="menu-link">
<i class="menu-icon pi pi-sliders-h"></i>
@ -47,7 +41,6 @@
<span class="active-indicator"></span>
</a>
</li>
<li class="menu-item" routerLinkActive="active">
<a routerLink="/resumen" class="menu-link">
<i class="menu-icon pi pi-list"></i>
@ -55,7 +48,6 @@
<span class="active-indicator"></span>
</a>
</li>
<li class="menu-item" routerLinkActive="active">
<a routerLink="/unidad-informacion" class="menu-link">
<i class="menu-icon pi pi-box"></i>
@ -63,6 +55,40 @@
<span class="active-indicator"></span>
</a>
</li>
<!-- Menú desplegable para Prueba de Roles -->
<li class="menu-item" [class.active]="isRolesMenuOpen">
<div class="menu-link" (click)="toggleRolesMenu()">
<i class="menu-icon pi pi-shield"></i>
<span class="menu-text">Prueba de Roles</span>
<i class="pi" [class.pi-chevron-down]="isRolesMenuOpen" [class.pi-chevron-right]="!isRolesMenuOpen"
style="margin-left: auto;"></i>
<span class="active-indicator"></span>
</div>
</li>
<!-- Submenú de Roles (visible cuando isRolesMenuOpen es true) -->
<div *ngIf="isRolesMenuOpen" class="submenu">
<li class="menu-item" routerLinkActive="active">
<a routerLink="/grupos/admin" class="menu-link" style="padding-left: 2.5rem;">
<i class="menu-icon pi pi-user-plus"></i>
<span class="menu-text">Solo Admin</span>
<span class="active-indicator"></span>
</a>
</li>
<li class="menu-item" routerLinkActive="active">
<a routerLink="/grupos/user" class="menu-link" style="padding-left: 2.5rem;">
<i class="menu-icon pi pi-user"></i>
<span class="menu-text">Solo Usuario</span>
<span class="active-indicator"></span>
</a>
</li>
<li class="menu-item" routerLinkActive="active">
<a routerLink="/roles/public" class="menu-link" style="padding-left: 2.5rem;">
<i class="menu-icon pi pi-users"></i>
<span class="menu-text">Acceso Público</span>
<span class="active-indicator"></span>
</a>
</li>
</div>
</ul>
</div>
</div>

View File

@ -215,4 +215,115 @@
100% {
opacity: 0.6;
}
}
// Estilos adicionales para el submenu de roles
/* Estilos para el submenú */
.submenu {
margin-left: 0.5rem;
animation: slideDown 0.3s ease-in-out;
overflow: hidden;
border-left: 1px dashed rgba(163, 197, 230, 0.7);
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
@keyframes slideDown {
from {
max-height: 0;
opacity: 0;
transform: translateY(-10px);
}
to {
max-height: 200px;
opacity: 1;
transform: translateY(0);
}
}
/* Para hacer el elemento del menú principal como un botón */
.menu-item .menu-link {
cursor: pointer;
}
/* Estilo especial para el elemento del menú desplegable */
.menu-item .menu-link:has(i.pi-chevron-right),
.menu-item .menu-link:has(i.pi-chevron-down) {
position: relative;
overflow: hidden;
}
/* Efecto de onda al hacer clic en el menú desplegable */
.menu-item .menu-link:has(i.pi-chevron-right)::after,
.menu-item .menu-link:has(i.pi-chevron-down)::after {
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
pointer-events: none;
background-image: radial-gradient(circle, rgba(255,255,255,0.4) 0%, transparent 10.5%);
background-repeat: no-repeat;
background-position: 50%;
transform: scale(10,10);
opacity: 0;
transition: transform .3s, opacity 0.8s;
}
.menu-item .menu-link:active:has(i.pi-chevron-right)::after,
.menu-item .menu-link:active:has(i.pi-chevron-down)::after {
transform: scale(0,0);
opacity: .3;
transition: 0s;
}
/* Estilos para elementos del submenú */
.submenu .menu-item .menu-link {
position: relative;
transition: all 0.3s ease;
}
.submenu .menu-item .menu-link::before {
content: '';
position: absolute;
top: 50%;
left: 1rem;
width: 5px;
height: 5px;
background-color: #a3c5e6;
border-radius: 50%;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.3s ease;
}
.submenu .menu-item .menu-link:hover::before {
opacity: 1;
}
.submenu .menu-item.active .menu-link::before {
opacity: 1;
background-color: white;
box-shadow: 0 0 4px rgba(255,255,255,0.7);
}
/* Estilos especiales para el submenú activo */
.menu-item.active + .submenu {
border-left: 1px solid rgba(255,255,255,0.5);
}
/* Animación para el icono de chevron */
.menu-item .pi-chevron-right,
.menu-item .pi-chevron-down {
transition: transform 0.3s ease;
}
.menu-item .pi-chevron-down {
transform: rotate(0deg);
}
.menu-item .pi-chevron-right {
transform: rotate(-90deg);
}

View File

@ -1,14 +1,25 @@
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { PrimeIcons } from 'primeng/api';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { DirectAuthService } from '../../services/direct-auth.service';
@Component({
selector: 'app-sidebar',
imports: [RouterLink, RouterLinkActive],
standalone: true,
imports: [CommonModule, RouterModule],
templateUrl: './sidebar.component.html',
styleUrl: './sidebar.component.scss',
standalone: true
styleUrls: ['./sidebar.component.scss']
})
export class SidebarComponent {
}
isRolesMenuOpen = false;
constructor(private authService: DirectAuthService) {}
toggleRolesMenu() {
this.isRolesMenuOpen = !this.isRolesMenuOpen;
}
getCurrentUser() {
return this.authService.getCurrentUser();
}
}

View File

@ -0,0 +1,65 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { DirectAuthService } from '../services/direct-auth.service';
/**
* Guard combinado que verifica si el usuario tiene los roles y/o pertenece a los grupos requeridos
* Espera un array de roles en route.data['roles'] y/o un array de grupos en route.data['groups']
* El parámetro route.data['requireAll'] determina si se requieren todos los roles/grupos o solo alguno
* Si no cumple con los requisitos, redirige a la página de acceso denegado
*/
export const accessGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean | UrlTree => {
const authService = inject(DirectAuthService);
const router = inject(Router);
// Primero verificar si está autenticado
if (!authService.isAuthenticated()) {
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
}
// Obtener los roles y grupos requeridos del data de la ruta
const requiredRoles = route.data['roles'] as Array<string> || [];
const requiredGroups = route.data['groups'] as Array<string> || [];
const requireAll = route.data['requireAll'] as boolean || false;
// Si no hay roles ni grupos requeridos, permitir acceso
if (requiredRoles.length === 0 && requiredGroups.length === 0) {
return true;
}
// Verificar acceso según la configuración
let hasAccess = false;
if (requireAll) {
// Necesita cumplir TODOS los requisitos
let hasRequiredRoles = true;
let hasRequiredGroups = true;
if (requiredRoles.length > 0) {
hasRequiredRoles = authService.hasAllRoles(requiredRoles);
}
if (requiredGroups.length > 0) {
hasRequiredGroups = authService.inAllGroups(requiredGroups);
}
hasAccess = hasRequiredRoles && hasRequiredGroups;
} else {
// Necesita cumplir ALGUNO de los requisitos
hasAccess = (requiredRoles.length > 0 && authService.hasAnyRole(requiredRoles)) ||
(requiredGroups.length > 0 && authService.inAnyGroup(requiredGroups));
}
if (hasAccess) {
return true;
}
// Si no cumple los requisitos, redirigir a página de acceso denegado
return router.createUrlTree(['/access-denied']);
};

View File

@ -0,0 +1,31 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { DirectAuthService } from '../services/direct-auth.service';
/**
* Guard que verifica si el usuario es administrador
* Si no es administrador, redirige a la página de acceso denegado
*/
export const adminGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean | UrlTree => {
const authService = inject(DirectAuthService);
const router = inject(Router);
// Primero verificar si está autenticado
if (!authService.isAuthenticated()) {
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
}
// Verificar si es administrador
if (authService.isAdmin()) {
return true;
}
// Si no es administrador, redirigir a página de acceso denegado
return router.createUrlTree(['/access-denied']);
};

View File

@ -3,21 +3,23 @@ import { CanActivateFn, Router } from '@angular/router';
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { DirectAuthService } from '../services/direct-auth.service';
// Simple implementation for authentication guard using DirectAuthService
/**
* Guard que verifica si el usuario está autenticado
* Si el usuario no está autenticado, redirige al login con la URL de retorno
*/
export const authGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean | UrlTree => {
const authService = inject(DirectAuthService);
const router = inject(Router);
// Check if user is authenticated
if (authService.isAuthenticated()) {
return true;
}
// If not authenticated, redirect to login
// Si no está autenticado, redirigir a login con la URL de retorno
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url !== '/' ? state.url : '/inicio' }
queryParams: { returnUrl: state.url }
});
};

View File

@ -0,0 +1,40 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { DirectAuthService } from '../services/direct-auth.service';
/**
* Guard que verifica si el usuario pertenece a los grupos requeridos
* Espera un array de grupos en route.data['groups']
* Si no pertenece a los grupos necesarios, redirige a la página de acceso denegado
*/
export const groupGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean | UrlTree => {
const authService = inject(DirectAuthService);
const router = inject(Router);
// Primero verificar si está autenticado
if (!authService.isAuthenticated()) {
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
}
// Obtener los grupos requeridos del data de la ruta
const requiredGroups = route.data['groups'] as Array<string>;
// Si no hay grupos requeridos, permitir acceso
if (!requiredGroups || requiredGroups.length === 0) {
return true;
}
// Usar la función de servicio para verificar grupos
if (authService.inAnyGroup(requiredGroups)) {
return true;
}
// Si no pertenece a los grupos necesarios, redirigir a página de acceso denegado
return router.createUrlTree(['/access-denied']);
};

9
src/app/guards/index.ts Normal file
View File

@ -0,0 +1,9 @@
// Archivo de barril (index.ts) para exportar todos los guards
export * from './auth.guard';
export * from './role.guard';
export * from './group.guard';
export * from './access.guard';
export * from './admin.guard';
// Esto permite importar todos los guards de una vez:
// import { authGuard, roleGuard, groupGuard, accessGuard, adminGuard } from './guards';

View File

@ -0,0 +1,40 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { DirectAuthService } from '../services/direct-auth.service';
/**
* Guard que verifica si el usuario tiene los roles requeridos
* Espera un array de roles en route.data['roles']
* Si no tiene los roles necesarios, redirige a la página de acceso denegado
*/
export const roleGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean | UrlTree => {
const authService = inject(DirectAuthService);
const router = inject(Router);
// Primero verificar si está autenticado
if (!authService.isAuthenticated()) {
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
}
// Obtener los roles requeridos del data de la ruta
const requiredRoles = route.data['roles'] as Array<string>;
// Si no hay roles requeridos, permitir acceso
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
// Usar la función de servicio para verificar roles
if (authService.hasAnyRole(requiredRoles)) {
return true;
}
// Si no tiene los roles necesarios, redirigir a página de acceso denegado
return router.createUrlTree(['/access-denied']);
};

View File

@ -0,0 +1,12 @@
<div class="p-4 flex justify-content-center align-items-center" style="min-height: 70vh;">
<div class="p-card p-shadow-4 p-4 text-center" style="max-width: 500px;">
<i class="pi pi-lock text-danger" style="font-size: 4rem;"></i>
<h2 class="mt-3 text-2xl font-bold">Acceso Denegado</h2>
<p class="mt-2 mb-4">No tienes los permisos necesarios para acceder a esta página.</p>
<p class="mb-4 text-sm">Tu rol actual no te permite ver este contenido.</p>
<button class="p-button p-button-primary mt-3" (click)="goHome()">
<i class="pi pi-home mr-2"></i>
Volver al inicio
</button>
</div>
</div>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AccessDeniedComponent } from './access-denied.component';
describe('AccessDeniedComponent', () => {
let component: AccessDeniedComponent;
let fixture: ComponentFixture<AccessDeniedComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AccessDeniedComponent]
})
.compileComponents();
fixture = TestBed.createComponent(AccessDeniedComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,17 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
@Component({
selector: 'app-access-denied',
imports: [CommonModule],
templateUrl: './access-denied.component.html',
styleUrl: './access-denied.component.scss'
})
export class AccessDeniedComponent {
constructor(private router: Router) {}
goHome() {
this.router.navigate(['/inicio']);
}
}

View File

@ -0,0 +1,15 @@
<div class="p-4">
<div class="p-card p-shadow-4 p-4">
<h2 class="text-2xl font-bold mb-4">Área de Administrador</h2>
<div class="bg-blue-50 p-3 rounded border-left-3 border-blue-500 mb-3">
<i class="pi pi-info-circle text-blue-500 mr-2"></i>
Esta página solo es accesible para usuarios con rol de administrador.
</div>
<div class="mt-4">
<h3 class="text-xl font-medium mb-2">Información del usuario:</h3>
<pre class="p-2 border-1 border-gray-300 rounded bg-gray-100 overflow-auto">{{ userInfo | json }}</pre>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminAreaComponent } from './admin-area.component';
describe('AdminAreaComponent', () => {
let component: AdminAreaComponent;
let fixture: ComponentFixture<AdminAreaComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdminAreaComponent]
})
.compileComponents();
fixture = TestBed.createComponent(AdminAreaComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,20 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DirectAuthService } from '../../services/direct-auth.service';
@Component({
selector: 'app-admin-area',
imports: [CommonModule],
templateUrl: './admin-area.component.html',
styleUrl: './admin-area.component.scss'
})
export class AdminAreaComponent implements OnInit {
userInfo: any;
constructor(private authService: DirectAuthService) {}
ngOnInit() {
this.userInfo = this.authService.getCurrentUser();
}
}

View File

@ -0,0 +1,14 @@
<div class="p-4">
<div class="p-card p-shadow-4 p-4">
<h2 class="text-2xl font-bold mb-4">Área de Usuario</h2>
<div class="bg-green-50 p-3 rounded border-left-3 border-green-500 mb-3">
<i class="pi pi-info-circle text-green-500 mr-2"></i>
Esta página es accesible para usuarios con roles estándar.
</div>
<div class="mt-4">
<h3 class="text-xl font-medium mb-2">Información del usuario:</h3>
<pre class="p-2 border-1 border-gray-300 rounded bg-gray-100 overflow-auto">{{ userInfo | json }}</pre>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserAreaComponent } from './user-area.component';
describe('UserAreaComponent', () => {
let component: UserAreaComponent;
let fixture: ComponentFixture<UserAreaComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserAreaComponent]
})
.compileComponents();
fixture = TestBed.createComponent(UserAreaComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,18 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DirectAuthService } from '../../services/direct-auth.service';
@Component({
selector: 'app-user-area',
imports: [CommonModule],
templateUrl: './user-area.component.html',
styleUrl: './user-area.component.scss'
})
export class UserAreaComponent implements OnInit {
userInfo: any;
constructor(private authService: DirectAuthService) {}
ngOnInit() {
this.userInfo = this.authService.getCurrentUser();
}
}

View File

@ -1,7 +1,7 @@
import { Injectable, signal, inject } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { Observable, throwError, of } from 'rxjs';
import { catchError, tap, map } from 'rxjs/operators';
import { Router } from '@angular/router';
import { MessageService } from 'primeng/api';
import { environment } from '../../environments/environment';
@ -15,13 +15,25 @@ export class DirectAuthService {
private realm = environment.keycloak.realm;
private clientId = environment.keycloak.clientId;
private tokenEndpoint = `${this.keycloakUrl}/realms/${this.realm}/protocol/openid-connect/token`;
private userEndpoint = `${this.keycloakUrl}/realms/${this.realm}/protocol/openid-connect/userinfo`;
// Storage keys
private readonly STORAGE_TOKEN_KEY = 'keycloak_token';
private readonly STORAGE_USER_KEY = 'keycloak_user';
private readonly STORAGE_ROLES_KEY = 'keycloak_roles';
private readonly STORAGE_GROUPS_KEY = 'keycloak_groups';
private readonly STORAGE_PERMISSIONS_KEY = 'keycloak_permissions';
private readonly STORAGE_LAST_ACTIVITY_KEY = 'keycloak_last_activity';
// Router y MessageService
private router = inject(Router);
private messageService = inject(MessageService);
// Estado de autenticación como signal
// Estado de autenticación como signals
private userInfo = signal<any>(null);
private userRoles = signal<string[]>([]);
private userGroups = signal<string[]>([]);
private userPermissions = signal<string[]>([]);
// Token y refresh token
private tokenInfo: any = null;
@ -44,13 +56,18 @@ export class DirectAuthService {
// Temporizador para logs de actividad
private activityLogTimer: any = null;
// Nombre de grupos específicos
private readonly GROUP_ADMINISTRADORES = 'administradores';
private readonly GROUP_DESARROLLADORES = 'desarrolladores';
private readonly GROUP_USUARIOS = 'usuarios';
constructor(private http: HttpClient) {
// Inicializar configuraciones desde environment
this.INACTIVITY_PERCENTAGE = environment.auth.inactivityPercentage;
this.MIN_INACTIVITY_TIME = environment.auth.minInactivityTime;
this.STRICT_TOKEN_RENEWAL = environment.auth.strictTokenRenewal;
this.ENABLE_ACTIVITY_LOGS = environment.auth.enableActivityLogs;
this.LOG_INTERVAL = environment.auth.logInterval;
this.INACTIVITY_PERCENTAGE = environment.auth.inactivityPercentage || 0.5;
this.MIN_INACTIVITY_TIME = environment.auth.minInactivityTime || 300000; // 5 minutos por defecto
this.STRICT_TOKEN_RENEWAL = environment.auth.strictTokenRenewal || false;
this.ENABLE_ACTIVITY_LOGS = environment.auth.enableActivityLogs || false;
this.LOG_INTERVAL = environment.auth.logInterval || 60000; // 1 minuto por defecto
// Intentar cargar el token del almacenamiento local al iniciar
this.loadTokenFromStorage();
@ -66,25 +83,68 @@ export class DirectAuthService {
// Cargar token del almacenamiento local
private loadTokenFromStorage(): void {
const tokenInfo = localStorage.getItem('keycloak_token');
const tokenInfo = localStorage.getItem(this.STORAGE_TOKEN_KEY);
const userInfo = localStorage.getItem(this.STORAGE_USER_KEY);
const rolesInfo = localStorage.getItem(this.STORAGE_ROLES_KEY);
const groupsInfo = localStorage.getItem(this.STORAGE_GROUPS_KEY);
const permissionsInfo = localStorage.getItem(this.STORAGE_PERMISSIONS_KEY);
const lastActivity = localStorage.getItem(this.STORAGE_LAST_ACTIVITY_KEY);
// Cargar última actividad si existe
if (lastActivity) {
this.lastActivityTime = parseInt(lastActivity, 10);
}
if (tokenInfo) {
try {
this.tokenInfo = JSON.parse(tokenInfo);
// Cargar información del usuario
if (userInfo) {
this.userInfo.set(JSON.parse(userInfo));
}
// Cargar roles
if (rolesInfo) {
this.userRoles.set(JSON.parse(rolesInfo));
}
// Cargar grupos
if (groupsInfo) {
this.userGroups.set(JSON.parse(groupsInfo));
}
// Cargar permisos
if (permissionsInfo) {
this.userPermissions.set(JSON.parse(permissionsInfo));
}
// Verificar si el token ha expirado
if (this.isTokenExpired()) {
// Token expirado al cargar
// Si tiene refresh token y (no está en modo estricto o el usuario ha estado activo), intentar renovar
if (this.tokenInfo.refresh_token && (!this.STRICT_TOKEN_RENEWAL || this.isUserActive())) {
// Usuario activo o no en modo estricto, intentando renovar token
this.refreshToken().subscribe();
this.refreshToken().subscribe({
next: () => {
console.log('[Auth] Token renovado con éxito al iniciar');
},
error: (err) => {
console.error('[Auth] Error al renovar token al iniciar:', err);
this.logout();
}
});
} else {
// Usuario inactivo o sin refresh token, cerrando sesión
this.logout();
}
} else {
// Decodificar info del usuario desde el token
this.setUserFromToken(this.tokenInfo.access_token);
// Decodificar info del usuario desde el token si no fue cargado del storage
if (!userInfo) {
this.setUserFromToken(this.tokenInfo.access_token);
// Obtener información adicional del usuario desde el endpoint userinfo
}
// Configurar temporizador para renovación de token
// Solo si el usuario está activo (en modo estricto) o siempre (en modo no estricto)
if (!this.STRICT_TOKEN_RENEWAL || this.isUserActive()) {
@ -99,11 +159,49 @@ export class DirectAuthService {
}
} catch (e) {
// Error al cargar token
console.error('[Auth] Error al cargar datos del storage:', e);
this.clearAllStorageData();
this.logout();
}
}
}
// Guardar toda la información en el localStorage
private saveAllToStorage(): void {
if (this.tokenInfo) {
localStorage.setItem(this.STORAGE_TOKEN_KEY, JSON.stringify(this.tokenInfo));
}
if (this.userInfo()) {
localStorage.setItem(this.STORAGE_USER_KEY, JSON.stringify(this.userInfo()));
}
if (this.userRoles()) {
localStorage.setItem(this.STORAGE_ROLES_KEY, JSON.stringify(this.userRoles()));
}
if (this.userGroups()) {
localStorage.setItem(this.STORAGE_GROUPS_KEY, JSON.stringify(this.userGroups()));
}
if (this.userPermissions()) {
localStorage.setItem(this.STORAGE_PERMISSIONS_KEY, JSON.stringify(this.userPermissions()));
}
// Guardar tiempo de última actividad
localStorage.setItem(this.STORAGE_LAST_ACTIVITY_KEY, this.lastActivityTime.toString());
}
// Limpiar todos los datos del storage
private clearAllStorageData(): void {
localStorage.removeItem(this.STORAGE_TOKEN_KEY);
localStorage.removeItem(this.STORAGE_USER_KEY);
localStorage.removeItem(this.STORAGE_ROLES_KEY);
localStorage.removeItem(this.STORAGE_GROUPS_KEY);
localStorage.removeItem(this.STORAGE_PERMISSIONS_KEY);
localStorage.removeItem(this.STORAGE_LAST_ACTIVITY_KEY);
}
// Comprueba si el usuario ha estado activo dentro del período de inactividad
private isUserActive(): boolean {
const currentTime = Date.now();
@ -131,11 +229,15 @@ export class DirectAuthService {
tap(tokenInfo => {
// Guardar información del token
this.tokenInfo = tokenInfo;
localStorage.setItem('keycloak_token', JSON.stringify(tokenInfo));
// Decodificar info del usuario
this.setUserFromToken(tokenInfo.access_token);
// Obtener información adicional del usuario desde el endpoint userinfo
// Guardar toda la información en localStorage
this.saveAllToStorage();
// Configurar temporizador para renovación de token
this.startRefreshTokenTimer();
@ -153,11 +255,18 @@ export class DirectAuthService {
}),
catchError(error => {
// Error de autenticación
this.messageService.add({
severity: 'error',
summary: 'Error de autenticación',
detail: 'Credenciales incorrectas o error de servidor'
});
return throwError(() => new Error('Credenciales incorrectas o error de servidor'));
})
);
}
// Cerrar sesión
public logout(): void {
// Detener temporizador de renovación
@ -170,9 +279,12 @@ export class DirectAuthService {
this.stopActivityLogging();
// Limpiar datos de sesión
localStorage.removeItem('keycloak_token');
this.clearAllStorageData();
this.tokenInfo = null;
this.userInfo.set(null);
this.userRoles.set([]);
this.userGroups.set([]);
this.userPermissions.set([]);
// Redirigir a la página de login
this.router.navigate(['/login']);
@ -228,19 +340,19 @@ export class DirectAuthService {
.set('grant_type', 'refresh_token')
.set('refresh_token', this.tokenInfo.refresh_token);
// Intentando renovar el token
return this.http.post<any>(this.tokenEndpoint, params.toString(), { headers })
.pipe(
tap(newTokenInfo => {
// Token renovado exitosamente
// Actualizar información del token
this.tokenInfo = newTokenInfo;
localStorage.setItem('keycloak_token', JSON.stringify(newTokenInfo));
// Actualizar información del usuario si es necesario
this.setUserFromToken(newTokenInfo.access_token);
// Guardar toda la información actualizada en localStorage
this.saveAllToStorage();
// Reiniciar temporizador para renovación de token
this.startRefreshTokenTimer();
@ -255,6 +367,7 @@ export class DirectAuthService {
}),
catchError(error => {
// Error al renovar token
console.error('[Auth] Error al renovar token:', error);
// Si falla la renovación, forzar cierre de sesión
this.logout();
return throwError(() => new Error('Error al renovar la sesión'));
@ -277,6 +390,98 @@ export class DirectAuthService {
return this.userInfo();
}
// Obtener el ID del usuario
public getUserId(): string | null {
return this.userInfo()?.id || null;
}
// Obtener username del usuario
public getUsername(): string | null {
return this.userInfo()?.username || null;
}
// Obtener email del usuario
public getUserEmail(): string | null {
return this.userInfo()?.email || null;
}
// Obtener nombre completo del usuario
public getUserFullName(): string | null {
return this.userInfo()?.name || null;
}
// Obtener todos los roles del usuario
public getUserRoles(): string[] {
return this.userRoles() || [];
}
// Obtener todos los grupos del usuario
public getUserGroups(): string[] {
return this.userGroups() || [];
}
// Obtener todos los permisos del usuario
public getUserPermissions(): string[] {
return this.userPermissions() || [];
}
// Verificar si el usuario tiene un rol específico
public hasRole(role: string): boolean {
return this.userRoles().includes(role);
}
// Verificar si el usuario tiene cualquiera de los roles especificados
public hasAnyRole(roles: string[]): boolean {
return roles.some(role => this.userRoles().includes(role));
}
// Verificar si el usuario tiene todos los roles especificados
public hasAllRoles(roles: string[]): boolean {
return roles.every(role => this.userRoles().includes(role));
}
// Verificar si el usuario pertenece a un grupo específico
public inGroup(group: string): boolean {
return this.userGroups().includes(group);
}
// Verificar si el usuario pertenece a cualquiera de los grupos especificados
public inAnyGroup(groups: string[]): boolean {
return groups.some(group => this.userGroups().includes(group));
}
// Verificar si el usuario pertenece a todos los grupos especificados
public inAllGroups(groups: string[]): boolean {
return groups.every(group => this.userGroups().includes(group));
}
// Verificar si el usuario es administrador (por rol o grupo)
public isAdmin(): boolean {
return this.hasRole('admin') ||
this.inGroup(this.GROUP_ADMINISTRADORES);
}
// Verificar si el usuario es desarrollador (por rol o grupo)
public isDeveloper(): boolean {
return this.hasRole('developer') ||
this.inGroup(this.GROUP_DESARROLLADORES);
}
// Verificar si el usuario tiene un permiso específico
public hasPermission(permission: string): boolean {
return this.userPermissions().includes(permission);
}
// Verificar si el usuario tiene cualquiera de los permisos especificados
public hasAnyPermission(permissions: string[]): boolean {
return permissions.some(perm => this.userPermissions().includes(perm));
}
// Verificar si el usuario tiene todos los permisos especificados
public hasAllPermissions(permissions: string[]): boolean {
return permissions.every(perm => this.userPermissions().includes(perm));
}
// Verificar si el token ha expirado
private isTokenExpired(): boolean {
if (!this.tokenInfo?.access_token) {
@ -300,6 +505,30 @@ export class DirectAuthService {
}
}
// Tiempo restante de validez del token en segundos
public getTokenRemainingTimeInSeconds(): number {
return Math.floor(this.getTokenExpirationTime() / 1000);
}
// Obtiene el payload completo del token decodificado
public getDecodedToken(): any {
if (!this.tokenInfo?.access_token) {
return null;
}
try {
const tokenParts = this.tokenInfo.access_token.split('.');
if (tokenParts.length !== 3) {
return null;
}
return JSON.parse(atob(tokenParts[1]));
} catch (e) {
console.error('[Auth] Error al decodificar token:', e);
return null;
}
}
// Decodificar token y extraer información del usuario
private setUserFromToken(token: string): void {
try {
@ -309,21 +538,71 @@ export class DirectAuthService {
}
const payload = JSON.parse(atob(tokenParts[1]));
console.log('[Auth] Token payload:', payload);
// Extraer información del usuario del payload
const user = {
id: payload.sub,
username: payload.preferred_username,
name: payload.name,
firstName: payload.given_name,
lastName: payload.family_name,
email: payload.email,
emailVerified: payload.email_verified,
locale: payload.locale,
roles: payload.realm_access?.roles || [],
isAdmin: (payload.realm_access?.roles || []).includes('admin')
clientRoles: payload.resource_access?.[this.clientId]?.roles || [],
isAdmin: (payload.realm_access?.roles || []).includes('admin'),
groups: payload.groups || [], // Puede estar vacío inicialmente
attributes: payload.attributes || {}
};
// Actualizar señales
this.userInfo.set(user);
this.userRoles.set([
...(payload.realm_access?.roles || []),
...(payload.resource_access?.[this.clientId]?.roles || [])
]);
// Los grupos podrían estar vacíos en el token, esperaremos a obtenerlos del userinfo
if (payload.groups && Array.isArray(payload.groups)) {
this.userGroups.set(payload.groups);
}
// Extraer permisos si están disponibles en el token
const permissions = [];
// Si hay scope, intentar extraer permisos
if (payload.scope) {
const scopes = payload.scope.split(' ');
scopes.forEach((scope: string) => {
if (scope.startsWith('permission:')) {
permissions.push(scope.substring(11));
}
});
}
// Si hay resource_access con permisos
if (payload.resource_access && payload.resource_access[this.clientId]) {
const clientPerms = payload.resource_access[this.clientId].permissions;
if (clientPerms && Array.isArray(clientPerms)) {
permissions.push(...clientPerms);
}
}
this.userPermissions.set(permissions);
// Loguear información para depuración
console.log('[Auth] Usuario decodificado:', user);
console.log('[Auth] Roles:', this.userRoles());
console.log('[Auth] Grupos iniciales:', this.userGroups());
} catch (e) {
// Error al decodificar token
console.error('[Auth] Error al decodificar token:', e);
this.userInfo.set(null);
this.userRoles.set([]);
this.userGroups.set([]);
this.userPermissions.set([]);
}
}
@ -347,19 +626,28 @@ export class DirectAuthService {
const timeToExpiry = expirationTime - currentTime;
const refreshTime = timeToExpiry * 0.7;
// Calcular tiempos para la renovación del token
console.log(`[Auth] Token configurado para renovarse en ${Math.round(refreshTime/1000)} segundos`);
this.refreshTokenTimeout = setTimeout(() => {
// Si STRICT_TOKEN_RENEWAL está activado, verificar la actividad del usuario
if (!this.STRICT_TOKEN_RENEWAL || this.isUserActive()) {
this.refreshToken().subscribe();
console.log('[Auth] Intentando renovar token automáticamente');
this.refreshToken().subscribe({
next: () => console.log('[Auth] Token renovado con éxito'),
error: (err) => {
console.error('[Auth] Error al renovar token:', err);
this.logout();
}
});
} else {
// En modo estricto, si el usuario está inactivo, cerrar sesión
console.log('[Auth] Usuario inactivo, cerrando sesión por inactividad');
this.logout();
}
}, refreshTime);
} catch (e) {
// Error al configurar renovación de token
console.error('[Auth] Error al configurar renovación de token:', e);
}
}
@ -404,6 +692,87 @@ export class DirectAuthService {
}
}
// Verificar si el token necesita ser renovado pronto
public tokenNeedsRenewal(): boolean {
const expirationTime = this.getTokenExpirationTime();
// Considerar renovación si queda menos del 30% del tiempo de vida del token
return expirationTime > 0 && expirationTime < this.getTokenLifespan() * 0.3;
}
// Obtener el tiempo de vida total del token en milisegundos
private getTokenLifespan(): number {
if (!this.tokenInfo?.access_token) {
return 0;
}
try {
const tokenParts = this.tokenInfo.access_token.split('.');
const payload = JSON.parse(atob(tokenParts[1]));
// Calcular tiempo total de vida (exp - iat)
if (payload.exp && payload.iat) {
return (payload.exp - payload.iat) * 1000;
}
return 0;
} catch (e) {
return 0;
}
}
// Verificar acceso por roles para usar en Guards
public checkRoleAccess(requiredRoles: string[]): Observable<boolean> {
if (!this.isAuthenticated()) {
return of(false);
}
// Si no hay roles requeridos, permitir acceso
if (!requiredRoles || requiredRoles.length === 0) {
return of(true);
}
// Verificar si el usuario tiene alguno de los roles requeridos
return of(this.hasAnyRole(requiredRoles));
}
// Verificar acceso por grupos para usar en Guards
public checkGroupAccess(requiredGroups: string[]): Observable<boolean> {
if (!this.isAuthenticated()) {
return of(false);
}
// Si no hay grupos requeridos, permitir acceso
if (!requiredGroups || requiredGroups.length === 0) {
return of(true);
}
// Verificar si el usuario pertenece a alguno de los grupos requeridos
return of(this.inAnyGroup(requiredGroups));
}
// Verificar acceso por permisos para usar en Guards
public checkPermissionAccess(requiredPermissions: string[]): Observable<boolean> {
if (!this.isAuthenticated()) {
return of(false);
}
// Si no hay permisos requeridos, permitir acceso
if (!requiredPermissions || requiredPermissions.length === 0) {
return of(true);
}
// Verificar si el usuario tiene todos los permisos requeridos
return of(this.hasAllPermissions(requiredPermissions));
}
// Forzar renovación del token manualmente
public forceTokenRenewal(): Observable<any> {
if (this.isAuthenticated() && this.tokenInfo?.refresh_token) {
return this.refreshToken();
}
return throwError(() => new Error('No hay sesión activa para renovar'));
}
// Configurar monitoreo de actividad del usuario
private setupActivityMonitoring(): void {
if (typeof window !== 'undefined') {
@ -435,6 +804,9 @@ export class DirectAuthService {
// Registrar que el usuario está activo
this.lastActivityTime = Date.now();
// Guardar tiempo de última actividad en localStorage
localStorage.setItem(this.STORAGE_LAST_ACTIVITY_KEY, this.lastActivityTime.toString());
// Marca el usuario como activo
this.userInactive.set(false);
@ -455,10 +827,195 @@ export class DirectAuthService {
);
// Configurar timer de inactividad
this.userActivity = setTimeout(() => {
// Umbral de inactividad alcanzado
this.userInactive.set(true);
}, inactivityTime);
}
// Obtener el estado de inactividad del usuario
public isUserInactive(): boolean {
return this.userInactive();
}
// Obtener tiempo de inactividad en segundos
public getInactivityTime(): number {
return Math.floor((Date.now() - this.lastActivityTime) / 1000);
}
// ======== FUNCIONES ESPECÍFICAS PARA LOS GUARDS ========
// Guard para comprobar si el usuario es administrador
public isAdminGuard(): Observable<boolean> {
if (!this.isAuthenticated()) {
return of(false);
}
return of(this.isAdmin());
}
// Guard para comprobar si el usuario es desarrollador
public isDeveloperGuard(): Observable<boolean> {
if (!this.isAuthenticated()) {
return of(false);
}
return of(this.isDeveloper());
}
// Guard para comprobar si el usuario pertenece al grupo de usuarios normales
public isRegularUserGuard(): Observable<boolean> {
if (!this.isAuthenticated()) {
return of(false);
}
return of(this.inGroup(this.GROUP_USUARIOS));
}
// Guard combinado para roles y grupos
public checkAccess(requiredRoles: string[] = [], requiredGroups: string[] = []): Observable<boolean> {
if (!this.isAuthenticated()) {
return of(false);
}
// Si no hay requisitos, permitir acceso
if ((!requiredRoles || requiredRoles.length === 0) &&
(!requiredGroups || requiredGroups.length === 0)) {
return of(true);
}
// Verificar roles si hay alguno especificado
const hasRequiredRole = requiredRoles.length === 0 || this.hasAnyRole(requiredRoles);
// Verificar grupos si hay alguno especificado
const hasRequiredGroup = requiredGroups.length === 0 || this.inAnyGroup(requiredGroups);
// Combinar verificaciones según si necesitamos ambas o cualquiera
const hasAccess = (requiredRoles.length > 0 && requiredGroups.length > 0)
? (hasRequiredRole && hasRequiredGroup) // Necesita ambos
: (hasRequiredRole || hasRequiredGroup); // Necesita cualquiera
return of(hasAccess);
}
// ======== FUNCIONES PARA DEPURACIÓN ========
// Obtener información completa del estado de autenticación para depuración
public getAuthDebugInfo(): any {
return {
isAuthenticated: this.isAuthenticated(),
tokenExpired: this.isTokenExpired(),
user: this.userInfo(),
roles: this.userRoles(),
groups: this.userGroups(),
permissions: this.userPermissions(),
tokenRemainingTime: this.getTokenRemainingTimeInSeconds(),
isAdmin: this.isAdmin(),
isDeveloper: this.isDeveloper(),
isInactivityTimerActive: !!this.userActivity,
inactivityTimeSeconds: this.getInactivityTime(),
isUserInactive: this.userInactive()
};
}
// Verificar si existe el token en localStorage (útil para depuración)
public hasLocalStorageToken(): boolean {
return !!localStorage.getItem(this.STORAGE_TOKEN_KEY);
}
// Imprimir información completa del token para depuración
public logTokenInfo(): void {
if (!this.isAuthenticated()) {
console.log('[Auth] No hay sesión activa');
return;
}
console.log('====== INFORMACIÓN DEL TOKEN ======');
console.log('Token:', this.getToken());
console.log('Token Decodificado:', this.getDecodedToken());
console.log('Tiempo restante:', this.getTokenRemainingTimeInSeconds(), 'segundos');
console.log('Usuario:', this.userInfo());
console.log('Roles:', this.userRoles());
console.log('Grupos:', this.userGroups());
console.log('Permisos:', this.userPermissions());
console.log('Es Admin:', this.isAdmin());
console.log('Es Developer:', this.isDeveloper());
console.log('================================');
}
// ======== FUNCIONES AUXILIARES PARA MAPEO DE RECURSOS ========
// Obtener recursos permitidos según roles y grupos
public getAuthorizedResources(resourceMap: any): string[] {
if (!this.isAuthenticated()) {
return [];
}
const authorizedResources: string[] = [];
// Si el usuario es admin, tiene acceso a todo
if (this.isAdmin()) {
return Object.keys(resourceMap);
}
// Recorrer el mapa de recursos
for (const resource in resourceMap) {
const access = resourceMap[resource];
// Verificar acceso por roles
if (access.roles && this.hasAnyRole(access.roles)) {
authorizedResources.push(resource);
continue;
}
// Verificar acceso por grupos
if (access.groups && this.inAnyGroup(access.groups)) {
authorizedResources.push(resource);
continue;
}
// Verificar acceso por permisos
if (access.permissions && this.hasAllPermissions(access.permissions)) {
authorizedResources.push(resource);
}
}
return authorizedResources;
}
// Verificar si el usuario puede acceder a un recurso específico
public canAccessResource(resource: string, resourceMap: any): boolean {
if (!this.isAuthenticated()) {
return false;
}
// Si el usuario es admin, tiene acceso a todo
if (this.isAdmin()) {
return true;
}
// Verificar si el recurso existe en el mapa
if (!resourceMap[resource]) {
return false;
}
const access = resourceMap[resource];
// Verificar acceso por roles
if (access.roles && this.hasAnyRole(access.roles)) {
return true;
}
// Verificar acceso por grupos
if (access.groups && this.inAnyGroup(access.groups)) {
return true;
}
// Verificar acceso por permisos
if (access.permissions && this.hasAllPermissions(access.permissions)) {
return true;
}
return false;
}
}

View File

@ -717,778 +717,34 @@ Ahora puedes acceder a la consola de administración de Keycloak en http://192.1
> **Nota**: Para aplicaciones que no son SPA, puedes habilitar "Client authentication" y obtener un client secret que deberás usar en la configuración.
> **Nota**: Puedes descargar un ejemplo de una maquina virtual de virtual box en el siguiente [enlace](https://valposystemscom-my.sharepoint.com/:u:/g/personal/luis_cespedes_valposystems_com/EepH0pHrk8ZEgeU-RmVkopgBiN6LWoPm6P8MJEzt-qdxXw?e=IZFMMg) con todo ya configurado , solo importar y usar
## 6. Integración con Angular: Enfoque básico
## Configurar cliente para incluir grupos
**Añadir un Protocol Mapper en el cliente de Keycloak:** a. Inicia sesión en la consola de administración de Keycloak b. Navega a "Clients" > selecciona tu cliente "angular-app" c. Ve a la pestaña "Client Scopes" > Selecciona el scope por defecto (normalmente es el nombre del cliente) d. Ve a la pestaña "Mappers" e. Haz clic en "Create" o "Add Mapper" y selecciona "Group Membership" f. Configura el mapper con los siguientes valores:
Para las versiones más antiguas de Angular, vamos a utilizar el enfoque tradicional de integración con Keycloak.
- Name: groups
- Token Claim Name: groups
- Add to ID token: ON
- Add to access token: ON
- Add to userinfo: ON
- Full group path: OFF (a menos que necesites la ruta completa) g. Guarda la configuración
### Crear una aplicación Angular
Alternativamente, puedes crear un mapper de tipo "User Attribute" si prefieres almacenar los grupos como atributos de usuario.
```bash
# Crear una nueva aplicación
ng new angular-keycloak-app
cd angular-keycloak-app
![](https://i.ibb.co/fG49Sb2m/imagen.png)
# Instalar la biblioteca para integrar Keycloak
npm install keycloak-angular keycloak-js
```
![client scope](https://i.ibb.co/wr2PXT6h/imagen.png)
### Configurar Keycloak en Angular
Crea un archivo `src/assets/silent-check-sso.html`:
![eleccion-mapper](https://i.ibb.co/6Rcq1TvG/imagen.png)
```html
<html>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>
```
### Configurar el módulo principal (Angular tradicional)
![eleccion grupo membership](https://i.ibb.co/ymBxwKGV/imagen.png)
Modifica el archivo `src/app/app.module.ts`:
![configuracion maper](https://i.ibb.co/TqWHYYjX/imagen.png)
```typescript
import { NgModule, APP_INITIALIZER } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { KeycloakAngularModule, KeycloakService } from 'keycloak-angular';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
Que logramos con esto ? facil , que en el accestoken aparezcan los grupos que pertenece el usuario , tal cual lo configuramos en ldap, esto puede ayudar para crear politicas de acceso a paginas o rutas protegidas para algun grupo en especifico mas adelante
![ejemplo json acces token ](https://i.ibb.co/jPrR0XH3/imagen.png)
function initializeKeycloak(keycloak: KeycloakService) {
return () =>
keycloak.init({
config: {
url: 'http://localhost:8080',
realm: 'angular-app',
clientId: 'angular-app'
},
initOptions: {
onLoad: 'check-sso',
silentCheckSsoRedirectUri:
window.location.origin + '/assets/silent-check-sso.html'
}
});
}
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
AppRoutingModule,
KeycloakAngularModule
],
providers: [
{
provide: APP_INITIALIZER,
useFactory: initializeKeycloak,
multi: true,
deps: [KeycloakService]
}
],
bootstrap: [AppComponent]
})
export class AppModule { }
```
### Crear un servicio de autenticación básico
```bash
ng generate service auth
```
Edita `src/app/auth.service.ts`:
```typescript
import { Injectable } from '@angular/core';
import { KeycloakService } from 'keycloak-angular';
import { KeycloakProfile } from 'keycloak-js';
@Injectable({
providedIn: 'root'
})
export class AuthService {
constructor(private keycloak: KeycloakService) { }
public getLoggedUser(): Promise<KeycloakProfile | null> {
return this.keycloak.loadUserProfile();
}
public login(): void {
this.keycloak.login();
}
public logout(): void {
this.keycloak.logout();
}
public isLoggedIn(): Promise<boolean> {
return this.keycloak.isLoggedIn();
}
public getRoles(): string[] {
return this.keycloak.getUserRoles();
}
}
```
### Crear un guardia para rutas protegidas (Angular tradicional)
```bash
ng generate guard auth
```
Edita `src/app/auth.guard.ts`:
```typescript
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot, Router, UrlTree } from '@angular/router';
import { KeycloakAuthGuard, KeycloakService } from 'keycloak-angular';
@Injectable({
providedIn: 'root'
})
export class AuthGuard extends KeycloakAuthGuard {
constructor(
protected override readonly router: Router,
protected readonly keycloak: KeycloakService
) {
super(router, keycloak);
}
public async isAccessAllowed(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Promise<boolean | UrlTree> {
// Verifica si el usuario está autenticado
if (!this.authenticated) {
await this.keycloak.login({
redirectUri: window.location.origin + state.url,
});
return false;
}
// Obtiene los roles requeridos desde la ruta
const requiredRoles = route.data['roles'];
// Permite el acceso si no hay roles requeridos
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
// Verifica si el usuario tiene al menos uno de los roles requeridos
return requiredRoles.some((role: string) => this.roles.includes(role));
}
}
```
### Configurar las rutas con protección
Modifica `src/app/app-routing.module.ts`:
```typescript
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from './auth.guard';
import { AppComponent } from './app.component';
const routes: Routes = [
{
path: 'admin',
component: AppComponent,
canActivate: [AuthGuard],
data: { roles: ['administradores'] }
},
{
path: 'developer',
component: AppComponent,
canActivate: [AuthGuard],
data: { roles: ['desarrolladores'] }
},
{
path: 'user',
component: AppComponent,
canActivate: [AuthGuard],
data: { roles: ['usuarios'] }
},
{
path: '',
component: AppComponent
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
```
## 7. Integración con Angular 19: Enfoque moderno
Para Angular 19 y versiones más recientes, utilizaremos el enfoque moderno que aprovecha características como componentes independientes y el sistema de inyección de dependencias mejorado.
### Configuración en app.config.ts (Angular 19)
Configura Keycloak en el archivo `src/app/app.config.ts`:
```typescript
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { provideAnimations } from '@angular/platform-browser/animations';
import {
provideKeycloak,
createInterceptorCondition,
IncludeBearerTokenCondition,
INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
includeBearerTokenInterceptor
} from 'keycloak-angular';
// Define condiciones para incluir el token en las peticiones
const localhostCondition = createInterceptorCondition<IncludeBearerTokenCondition>({
urlPattern: /^(http:\/\/localhost)(\/.*)?$/i, // URLs que comienzan con http://localhost
bearerPrefix: 'Bearer'
});
// Condición para APIs
const apiCondition = createInterceptorCondition<IncludeBearerTokenCondition>({
urlPattern: /^(\/api)(\/.*)?$/i, // URLs que comienzan con /api
bearerPrefix: 'Bearer'
});
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(
routes,
withPreloading(PreloadAllModules)
),
provideAnimations(),
// Usar el interceptor de Keycloak para adjuntar tokens de autenticación
provideHttpClient(
withFetch(),
withInterceptors([includeBearerTokenInterceptor])
),
// Configuración para Keycloak
provideKeycloak({
config: {
url: 'http://localhost:8080', // URL del servidor Keycloak
realm: 'angular-app', // Nombre del realm
clientId: 'angular-app' // ID del cliente
},
initOptions: {
onLoad: 'check-sso', // Opciones: 'login-required' o 'check-sso'
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
checkLoginIframe: false,
pkceMethod: 'S256' // Mejora la seguridad
},
// Configurar el interceptor especificando las URLs donde incluir el token
providers: [
{
provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
useValue: [localhostCondition, apiCondition]
}
]
})
]
};
```
## 8. Servicios de autenticación avanzados
Con Angular 19, podemos crear un servicio de autenticación más avanzado utilizando signals y effects.
```typescript
import { Injectable, inject, effect, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, from } from 'rxjs';
import { KEYCLOAK_EVENT_SIGNAL, KeycloakEventType } from 'keycloak-angular';
import { Router } from '@angular/router';
import Keycloak from 'keycloak-js';
@Injectable({
providedIn: 'root'
})
export class AuthService {
// Inyectar Keycloak directamente
private keycloak = inject(Keycloak);
private keycloakEvents = inject(KEYCLOAK_EVENT_SIGNAL);
private router = inject(Router);
// Estado del usuario
private userSubject = new BehaviorSubject<any>(null);
public user$ = this.userSubject.asObservable();
// Estado de autenticación como signal
public isAuthenticated = signal<boolean>(false);
constructor(private http: HttpClient) {
// Verificar estado inicial
this.checkInitialAuthState();
// Configurar manejadores de eventos usando Angular effects
effect(() => {
const event = this.keycloakEvents();
if (!event) return;
console.log('Keycloak event:', event.type);
// Autenticación exitosa
if (event.type === KeycloakEventType.AuthSuccess) {
this.isAuthenticated.set(true);
this.loadUserInfo();
}
// Cierre de sesión
if (event.type === KeycloakEventType.AuthLogout) {
this.isAuthenticated.set(false);
this.userSubject.next(null);
this.router.navigate(['/login']);
}
// Error de autenticación
if (event.type === KeycloakEventType.AuthError) {
console.error('Authentication error:', event);
this.isAuthenticated.set(false);
this.userSubject.next(null);
}
// Expiración del token
if (event.type === KeycloakEventType.TokenExpired) {
console.log('Token expired, refreshing...');
this.updateToken();
}
});
}
private async checkInitialAuthState(): Promise<void> {
try {
const isLoggedIn = await this.keycloak.authenticated;
this.isAuthenticated.set(isLoggedIn);
if (isLoggedIn) {
await this.loadUserInfo();
}
} catch (error) {
console.error('Error checking initial auth state:', error);
this.isAuthenticated.set(false);
}
}
private async loadUserInfo(): Promise<void> {
try {
const isLoggedIn = await this.keycloak.authenticated;
if (!isLoggedIn) {
this.userSubject.next(null);
return;
}
const userProfile = await this.keycloak.loadUserProfile();
const isAdmin = this.keycloak.hasRealmRole('admin');
// Obtener roles del usuario
const realmRoles = this.keycloak.realmAccess?.roles || [];
const resourceRoles = this.keycloak.resourceAccess || {};
const user = {
id: userProfile.id,
username: userProfile.username,
name: `${userProfile.firstName || ''} ${userProfile.lastName || ''}`.trim(),
email: userProfile.email,
role: isAdmin ? 'admin' : 'user',
roles: {
realm: realmRoles,
resource: resourceRoles
},
isAdmin: isAdmin
};
this.userSubject.next(user);
} catch (error) {
console.error('Error loading user profile:', error);
this.userSubject.next(null);
}
}
login(redirectUri?: string): Promise<void> {
return this.keycloak.login({
redirectUri: redirectUri || window.location.origin
});
}
logout(): Promise<void> {
return this.keycloak.logout({
redirectUri: window.location.origin
});
}
isLoggedIn(): Observable<boolean> {
try {
return from(Promise.resolve(this.keycloak.authenticated || false));
} catch (error) {
console.error('Error checking authentication:', error);
return from(Promise.resolve(false));
}
}
getToken(): Promise<string> {
try {
return Promise.resolve(this.keycloak.token || '');
} catch (error) {
console.error('Error getting token:', error);
return Promise.resolve('');
}
}
async updateToken(minValidity = 30): Promise<boolean> {
try {
return await this.keycloak.updateToken(minValidity);
} catch (error) {
console.error('Error refreshing token:', error);
await this.login();
return false;
}
}
getCurrentUser(): any {
return this.userSubject.value;
}
// Verificar si el usuario tiene un rol específico
hasRole(role: string): boolean {
return this.keycloak.hasRealmRole(role);
}
// Verificar si el usuario tiene alguno de los roles especificados
hasAnyRole(roles: string[]): boolean {
for (const role of roles) {
if (this.keycloak.hasRealmRole(role)) {
return true;
}
}
return false;
}
}
```
## 9. Guardias de ruta e interceptores HTTP
### Guardia de ruta funcional (Angular 19)
```typescript
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import Keycloak from 'keycloak-js';
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { createAuthGuard, AuthGuardData } from 'keycloak-angular';
// Implementación directa
export const authGuard: CanActivateFn = async (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Promise<boolean | UrlTree> => {
const keycloak = inject(Keycloak);
const router = inject(Router);
try {
// Verificar si el usuario está autenticado
const authenticated = await keycloak.authenticated;
if (authenticated) {
// Verificar roles si están especificados en la ruta
const requiredRoles = route.data['roles'] as string[];
if (requiredRoles && requiredRoles.length > 0) {
// Comprobar si el usuario tiene los roles requeridos
const hasRequiredRole = requiredRoles.some(role =>
keycloak.hasRealmRole(role)
);
if (!hasRequiredRole) {
console.log('Usuario no tiene los roles requeridos');
return router.createUrlTree(['/unauthorized']);
}
}
return true;
}
// Si no está autenticado, redirigir a la página de login
console.log('Usuario no autenticado, redirigiendo a login');
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url !== '/' ? state.url : '/inicio' }
});
} catch (error) {
console.error('Error al verificar autenticación:', error);
return router.createUrlTree(['/login']);
}
};
// Alternativa usando el helper de keycloak-angular
const isAccessAllowed = async (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
authData: AuthGuardData
): Promise<boolean | UrlTree> => {
const { authenticated, grantedRoles } = authData;
const router = inject(Router);
if (authenticated) {
const requiredRoles = route.data['roles'] as string[];
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const hasRequiredRole = requiredRoles.some(role =>
grantedRoles.realmRoles.includes(role)
);
if (hasRequiredRole) {
return true;
}
return router.createUrlTree(['/unauthorized']);
}
return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
};
// Helper para crear el guardia (opcional)
export const authGuardWithHelper = createAuthGuard(isAccessAllowed);
```
### Interceptor HTTP para manejo de errores
```typescript
import { inject } from '@angular/core';
import {
HttpRequest,
HttpHandlerFn,
HttpInterceptorFn,
HttpErrorResponse
} from '@angular/common/http';
import { Observable, throwError, from, switchMap } from 'rxjs';
import { catchError } from 'rxjs/operators';
import Keycloak from 'keycloak-js';
import { Router } from '@angular/router';
/**
* Este interceptor complementa al incluido en app.config.ts
* Proporciona funcionalidad adicional para manejo de errores
*/
export const authInterceptor: HttpInterceptorFn = (
request: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<any> => {
const keycloak = inject(Keycloak);
const router = inject(Router);
// Manejar la petición con gestión de errores de autenticación
return next(request).pipe(
catchError((error: HttpErrorResponse) => {
// Manejar errores 401 Unauthorized
if (error.status === 401) {
console.log('Error 401, refrescando token o redirigiendo a login');
// Intentar refrescar el token primero
return from(keycloak.updateToken(30)).pipe(
switchMap(refreshed => {
if (refreshed) {
// Token refrescado, reintentar la petición
return next(request);
} else {
// No se pudo refrescar el token, redirigir a login
keycloak.login();
return throwError(() => error);
}
}),
catchError(refreshError => {
console.error('Error al refrescar token:', refreshError);
// Redirigir a login en caso de error
router.navigate(['/login']);
return throwError(() => error);
})
);
}
// Para otros errores, simplemente pasarlos
return throwError(() => error);
})
);
};
```
## 10. Componentes de UI para login/logout
### Componente de login (Angular 19)
```typescript
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, ActivatedRoute } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { take } from 'rxjs/operators';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule],
templateUrl: './login.component.html',
styleUrl: './login.component.scss'
})
export class LoginComponent implements OnInit {
private authService = inject(AuthService);
private route = inject(ActivatedRoute);
private router = inject(Router);
loading: boolean = false;
returnUrl: string = '';
ngOnInit() {
// Obtener el returnUrl de los query params
this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/inicio';
// Verificar si ya está autenticado y redirigir
this.authService.isLoggedIn().subscribe({
next: (isLoggedIn) => {
if (isLoggedIn) {
console.log('Usuario ya autenticado, redirigiendo a:', this.returnUrl);
// Comprobar si la URL de retorno es válida
const effectiveReturnUrl = this.returnUrl === '/' ? '/inicio' : this.returnUrl;
// Redirigir
this.router.navigate([effectiveReturnUrl], { replaceUrl: true });
}
},
error: (error) => console.error('Error al verificar autenticación:', error)
});
}
async onLogin() {
this.loading = true;
try {
// Comprobar si la URL de retorno es válida
const effectiveReturnUrl = this.returnUrl === '/' ? '/inicio' : this.returnUrl;
// Construir la redirectUri
const redirectUri = window.location.origin + effectiveReturnUrl;
console.log('Iniciando login con redirectUri:', redirectUri);
// Iniciar el flujo de autenticación
await this.authService.login(redirectUri);
// Keycloak se encargará de la redirección
} catch (error) {
console.error('Error al iniciar sesión:', error);
this.loading = false;
}
}
}
```
### Plantilla HTML para el componente de login
```html
<div class="login-container">
<div class="login-card">
<h2>Iniciar Sesión</h2>
<p>Por favor, inicia sesión para acceder al sistema.</p>
<button
[disabled]="loading"
(click)="onLogin()"
class="login-button">
{{ loading ? 'Cargando...' : 'Iniciar Sesión con Keycloak' }}
</button>
</div>
</div>
```
### Componente principal con información de usuario (común para ambos enfoques)
Edita `src/app/app.component.ts`:
```typescript
import { Component, OnInit } from '@angular/core';
import { AuthService } from './auth.service';
import { KeycloakProfile } from 'keycloak-js';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
title = 'angular-keycloak-app';
isLoggedIn = false;
userProfile: KeycloakProfile | null = null;
userRoles: string[] = [];
constructor(private authService: AuthService) {}
async ngOnInit() {
this.isLoggedIn = await this.authService.isLoggedIn();
if (this.isLoggedIn) {
this.userProfile = await this.authService.getLoggedUser();
this.userRoles = this.authService.getRoles();
}
}
login() {
this.authService.login();
}
logout() {
this.authService.logout();
}
}
```
Edita `src/app/app.component.html`:
```html
<div class="container mt-5">
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h2>Demo Keycloak con LDAP - Correos.com</h2>
</div>
<div class="card-body">
<div *ngIf="isLoggedIn; else loginButton">
<h3>Bienvenido, {{ userProfile?.firstName }} {{ userProfile?.lastName }}</h3>
<p>Email: {{ userProfile?.email }}</p>
<p>Nombre de Usuario: {{ userProfile?.username }}</p>
<h4>Tus Roles:</h4>
<ul>
<li *ngFor="let role of userRoles">{{ role }}</li>
</ul>
<button class="btn btn-danger" (click)="logout()">Cerrar Sesión</button>
</div>
<ng-template #loginButton>
<p>Por favor inicia sesión para acceder al sistema</p>
<button class="btn btn-primary" (click)="login()">Iniciar Sesión</button>
</ng-template>
</div>
</div>
</div>
</div>
</div>
```
## 11. Arquitectura del sistema
@ -1523,8 +779,6 @@ El flujo de autenticación funciona de la siguiente manera:
6. Los interceptores HTTP añaden automáticamente el token a las peticiones API
7. Las rutas protegidas verifican los roles del usuario antes de permitir el acceso
![](https://i.ibb.co/R4GLcqms/imagen.png)
![](https://i.ibb.co/v62KxXkF/imagen.png)
## 12. Consideraciones de seguridad