diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 0dd0f6c..def1694 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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' } -]; +]; \ No newline at end of file diff --git a/src/app/components/sidebar/sidebar.component.html b/src/app/components/sidebar/sidebar.component.html index 8811b3e..16f18a6 100644 --- a/src/app/components/sidebar/sidebar.component.html +++ b/src/app/components/sidebar/sidebar.component.html @@ -7,12 +7,9 @@
1.0
- -
- \ No newline at end of file diff --git a/src/app/components/sidebar/sidebar.component.scss b/src/app/components/sidebar/sidebar.component.scss index cd8a21a..6e33a5b 100644 --- a/src/app/components/sidebar/sidebar.component.scss +++ b/src/app/components/sidebar/sidebar.component.scss @@ -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); } \ No newline at end of file diff --git a/src/app/components/sidebar/sidebar.component.ts b/src/app/components/sidebar/sidebar.component.ts index de38495..fa49e7a 100644 --- a/src/app/components/sidebar/sidebar.component.ts +++ b/src/app/components/sidebar/sidebar.component.ts @@ -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(); + } +} \ No newline at end of file diff --git a/src/app/guards/access.guard.ts b/src/app/guards/access.guard.ts new file mode 100644 index 0000000..170e7c1 --- /dev/null +++ b/src/app/guards/access.guard.ts @@ -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 || []; + const requiredGroups = route.data['groups'] as Array || []; + 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']); +}; \ No newline at end of file diff --git a/src/app/guards/admin.guard.ts b/src/app/guards/admin.guard.ts new file mode 100644 index 0000000..4d30112 --- /dev/null +++ b/src/app/guards/admin.guard.ts @@ -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']); +}; \ No newline at end of file diff --git a/src/app/guards/auth.guard.ts b/src/app/guards/auth.guard.ts index 9843946..574b71d 100644 --- a/src/app/guards/auth.guard.ts +++ b/src/app/guards/auth.guard.ts @@ -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 } }); }; \ No newline at end of file diff --git a/src/app/guards/group.guard.ts b/src/app/guards/group.guard.ts new file mode 100644 index 0000000..2439cbe --- /dev/null +++ b/src/app/guards/group.guard.ts @@ -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; + + // 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']); +}; \ No newline at end of file diff --git a/src/app/guards/index.ts b/src/app/guards/index.ts new file mode 100644 index 0000000..2dc5142 --- /dev/null +++ b/src/app/guards/index.ts @@ -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'; \ No newline at end of file diff --git a/src/app/guards/role.guard.ts b/src/app/guards/role.guard.ts new file mode 100644 index 0000000..a11a4ca --- /dev/null +++ b/src/app/guards/role.guard.ts @@ -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; + + // 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']); +}; \ No newline at end of file diff --git a/src/app/pages/access-denied/access-denied.component.html b/src/app/pages/access-denied/access-denied.component.html new file mode 100644 index 0000000..c9479ef --- /dev/null +++ b/src/app/pages/access-denied/access-denied.component.html @@ -0,0 +1,12 @@ +
+
+ +

Acceso Denegado

+

No tienes los permisos necesarios para acceder a esta página.

+

Tu rol actual no te permite ver este contenido.

+ +
+
\ No newline at end of file diff --git a/src/app/pages/access-denied/access-denied.component.scss b/src/app/pages/access-denied/access-denied.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/access-denied/access-denied.component.spec.ts b/src/app/pages/access-denied/access-denied.component.spec.ts new file mode 100644 index 0000000..fc50ec6 --- /dev/null +++ b/src/app/pages/access-denied/access-denied.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AccessDeniedComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AccessDeniedComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/access-denied/access-denied.component.ts b/src/app/pages/access-denied/access-denied.component.ts new file mode 100644 index 0000000..2f0bdf5 --- /dev/null +++ b/src/app/pages/access-denied/access-denied.component.ts @@ -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']); + } +} diff --git a/src/app/pages/admin-area/admin-area.component.html b/src/app/pages/admin-area/admin-area.component.html new file mode 100644 index 0000000..4f792b2 --- /dev/null +++ b/src/app/pages/admin-area/admin-area.component.html @@ -0,0 +1,15 @@ +
+
+

Área de Administrador

+
+ + Esta página solo es accesible para usuarios con rol de administrador. +
+ +
+

Información del usuario:

+
{{ userInfo | json }}
+
+ +
+
\ No newline at end of file diff --git a/src/app/pages/admin-area/admin-area.component.scss b/src/app/pages/admin-area/admin-area.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/admin-area/admin-area.component.spec.ts b/src/app/pages/admin-area/admin-area.component.spec.ts new file mode 100644 index 0000000..7d89a84 --- /dev/null +++ b/src/app/pages/admin-area/admin-area.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AdminAreaComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminAreaComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/admin-area/admin-area.component.ts b/src/app/pages/admin-area/admin-area.component.ts new file mode 100644 index 0000000..495c53a --- /dev/null +++ b/src/app/pages/admin-area/admin-area.component.ts @@ -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(); + } +} \ No newline at end of file diff --git a/src/app/pages/user-area/user-area.component.html b/src/app/pages/user-area/user-area.component.html new file mode 100644 index 0000000..8fa6c02 --- /dev/null +++ b/src/app/pages/user-area/user-area.component.html @@ -0,0 +1,14 @@ +
+
+

Área de Usuario

+
+ + Esta página es accesible para usuarios con roles estándar. +
+ +
+

Información del usuario:

+
{{ userInfo | json }}
+
+
+
\ No newline at end of file diff --git a/src/app/pages/user-area/user-area.component.scss b/src/app/pages/user-area/user-area.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/user-area/user-area.component.spec.ts b/src/app/pages/user-area/user-area.component.spec.ts new file mode 100644 index 0000000..859eaba --- /dev/null +++ b/src/app/pages/user-area/user-area.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UserAreaComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(UserAreaComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/user-area/user-area.component.ts b/src/app/pages/user-area/user-area.component.ts new file mode 100644 index 0000000..6646e96 --- /dev/null +++ b/src/app/pages/user-area/user-area.component.ts @@ -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(); + } +} \ No newline at end of file diff --git a/src/app/services/direct-auth.service.ts b/src/app/services/direct-auth.service.ts index 76d1e3d..07ae4c3 100644 --- a/src/app/services/direct-auth.service.ts +++ b/src/app/services/direct-auth.service.ts @@ -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(null); + private userRoles = signal([]); + private userGroups = signal([]); + private userPermissions = signal([]); // 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(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 { + 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 { + 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 { + 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 { + 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 { + if (!this.isAuthenticated()) { + return of(false); + } + + return of(this.isAdmin()); + } + + // Guard para comprobar si el usuario es desarrollador + public isDeveloperGuard(): Observable { + 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 { + 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 { + 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; + } } \ No newline at end of file diff --git a/tutorial-keycloak-completo.md b/tutorial-keycloak-completo.md index 93c0422..a0af57f 100644 --- a/tutorial-keycloak-completo.md +++ b/tutorial-keycloak-completo.md @@ -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 - - - - - -``` -### 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 { - return this.keycloak.loadUserProfile(); - } - - public login(): void { - this.keycloak.login(); - } - - public logout(): void { - this.keycloak.logout(); - } - - public isLoggedIn(): Promise { - 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 { - - // 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({ - urlPattern: /^(http:\/\/localhost)(\/.*)?$/i, // URLs que comienzan con http://localhost - bearerPrefix: 'Bearer' -}); - -// Condición para APIs -const apiCondition = createInterceptorCondition({ - 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(null); - public user$ = this.userSubject.asObservable(); - - // Estado de autenticación como signal - public isAuthenticated = signal(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 { - 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 { - 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 { - return this.keycloak.login({ - redirectUri: redirectUri || window.location.origin - }); - } - - logout(): Promise { - return this.keycloak.logout({ - redirectUri: window.location.origin - }); - } - - isLoggedIn(): Observable { - try { - return from(Promise.resolve(this.keycloak.authenticated || false)); - } catch (error) { - console.error('Error checking authentication:', error); - return from(Promise.resolve(false)); - } - } - - getToken(): Promise { - try { - return Promise.resolve(this.keycloak.token || ''); - } catch (error) { - console.error('Error getting token:', error); - return Promise.resolve(''); - } - } - - async updateToken(minValidity = 30): Promise { - 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 => { - 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 => { - 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, - next: HttpHandlerFn -): Observable => { - 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 - -``` - -### 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 -
-
-
-
-
-

Demo Keycloak con LDAP - Correos.com

-
-
-
-

Bienvenido, {{ userProfile?.firstName }} {{ userProfile?.lastName }}

-

Email: {{ userProfile?.email }}

-

Nombre de Usuario: {{ userProfile?.username }}

- -

Tus Roles:

-
    -
  • {{ role }}
  • -
- - -
- - -

Por favor inicia sesión para acceder al sistema

- -
-
-
-
-
-
-``` ## 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