Esta guía te lleva paso a paso para implementar autenticación y autorización con Keycloak usando seguridad basada en roles para un backend Spring Boot 3.5 y frontend React + Vite.
- Backend: Spring Boot 3.5 + Java 21 + Spring Security + Keycloak
- Frontend: React + Vite + Adaptador JS de Keycloak
- Servidor de Autenticación: Keycloak (contenedor Docker)
- Base de Datos: PostgreSQL (contenedor Docker)
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: keycloak-postgres
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: password
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- keycloak-network
keycloak:
image: quay.io/keycloak/keycloak:23.0.3
container_name: keycloak
command: start-dev
environment:
KC_HOSTNAME: localhost
KC_HOSTNAME_PORT: 8080
KC_HOSTNAME_STRICT_BACKCHANNEL: false
KC_HTTP_ENABLED: true
KC_HOSTNAME_STRICT_HTTPS: false
KC_HEALTH_ENABLED: true
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: password
ports:
- "8080:8080"
restart: always
depends_on:
- postgres
networks:
- keycloak-network
volumes:
postgres_data:
driver: local
networks:
keycloak-network:
driver: bridgedocker-compose up -d- URL: http://localhost:8080
- Usuario: admin
- Contraseña: admin
- Hacer clic en "Create Realm"
- Nombre del reino:
mi-app-realm - Hacer clic en "Create"
- Ir a Clients → Create Client
- Client ID:
spring-boot-app - Client type:
OpenID Connect - Next → Next
- Configurar:
- Valid redirect URIs:
http://localhost:8081/* - Web origins:
http://localhost:8081 - Access Type:
confidential
- Valid redirect URIs:
- Guardar y anotar el Client Secret de la pestaña Credentials
- Ir a Clients → Create Client
- Client ID:
react-app - Client type:
OpenID Connect - Next → Next
- Configurar:
- Valid redirect URIs:
http://localhost:3000/* - Web origins:
http://localhost:3000 - Access Type:
public
- Valid redirect URIs:
- Guardar
- Ir a Realm roles → Create role
- Crear los siguientes roles:
ADMINUSERMANAGER
- Ir a Users → Add user
- Crear usuarios con diferentes roles para pruebas
- Establecer contraseñas en la pestaña Credentials (temporary: off)
- Asignar roles en la pestaña Role mapping
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Starter Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OAuth2 Resource Server -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- Spring Boot Starter OAuth2 Client -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
</dependencies># Configuración del Servidor
server.port=8081
# Configuración de Keycloak
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8080/realms/mi-app-realm
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:8080/realms/mi-app-realm/protocol/openid-connect/certs
# Configuración CORS
app.cors.allowed-origins=http://localhost:3000
# Logging
logging.level.org.springframework.security=DEBUG
logging.level.org.springframework.web.cors=DEBUG@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Value("${app.cors.allowed-origins}")
private String allowedOrigins;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/manager/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers("/api/user/**").hasAnyRole("ADMIN", "MANAGER", "USER")
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
grantedAuthoritiesConverter.setAuthoritiesClaimName("realm_access.roles");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(
jwt -> {
Collection<GrantedAuthority> authorities = grantedAuthoritiesConverter.convert(jwt);
// Extraer roles del reino desde el token JWT
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess != null && realmAccess.containsKey("roles")) {
List<String> roles = (List<String>) realmAccess.get("roles");
List<GrantedAuthority> realmAuthorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
Set<GrantedAuthority> allAuthorities = new HashSet<>(authorities);
allAuthorities.addAll(realmAuthorities);
return allAuthorities;
}
return authorities;
}
);
return jwtAuthenticationConverter;
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList(allowedOrigins.split(",")));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "${app.cors.allowed-origins}")
public class TestController {
@GetMapping("/public/info")
public ResponseEntity<String> endpointPublico() {
return ResponseEntity.ok("Este es un endpoint público");
}
@GetMapping("/user/profile")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<String> endpointUsuario(Authentication authentication) {
return ResponseEntity.ok("Endpoint de usuario - Hola " + authentication.getName());
}
@GetMapping("/manager/dashboard")
@PreAuthorize("hasRole('MANAGER')")
public ResponseEntity<String> endpointManager() {
return ResponseEntity.ok("Panel de administración del manager");
}
@GetMapping("/admin/settings")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<String> endpointAdmin() {
return ResponseEntity.ok("Configuraciones de administrador");
}
@GetMapping("/user/info")
public ResponseEntity<Map<String, Object>> obtenerInfoUsuario(Authentication authentication) {
if (authentication instanceof JwtAuthenticationToken jwtAuth) {
Jwt jwt = jwtAuth.getToken();
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("username", jwt.getClaimAsString("preferred_username"));
userInfo.put("email", jwt.getClaimAsString("email"));
userInfo.put("roles", authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
return ResponseEntity.ok(userInfo);
}
return ResponseEntity.ok(Map.of("message", "No hay información de usuario disponible"));
}
}npm install keycloak-jsimport Keycloak from 'keycloak-js';
const keycloak = new Keycloak({
url: 'http://localhost:8080',
realm: 'mi-app-realm',
clientId: 'react-app',
});
export default keycloak;import React, { createContext, useContext, useEffect, useState } from 'react';
import keycloak from '../keycloak';
const AuthContext = createContext();
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth debe ser usado dentro de un AuthProvider');
}
return context;
};
export const AuthProvider = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [token, setToken] = useState(null);
const [userInfo, setUserInfo] = useState(null);
useEffect(() => {
const initKeycloak = async () => {
try {
const authenticated = await keycloak.init({
onLoad: 'check-sso',
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html'
});
setIsAuthenticated(authenticated);
if (authenticated) {
setToken(keycloak.token);
setUserInfo({
username: keycloak.tokenParsed?.preferred_username,
email: keycloak.tokenParsed?.email,
roles: keycloak.tokenParsed?.realm_access?.roles || []
});
}
} catch (error) {
console.error('Error al inicializar Keycloak:', error);
} finally {
setIsLoading(false);
}
};
initKeycloak();
// Renovación de token
const interval = setInterval(() => {
if (keycloak.isTokenExpired(30)) {
keycloak.updateToken(30)
.then((refreshed) => {
if (refreshed) {
setToken(keycloak.token);
}
})
.catch(() => {
console.error('Error al renovar el token');
logout();
});
}
}, 10000);
return () => clearInterval(interval);
}, []);
const login = () => {
keycloak.login();
};
const logout = () => {
keycloak.logout();
setIsAuthenticated(false);
setToken(null);
setUserInfo(null);
};
const hasRole = (role) => {
return userInfo?.roles?.includes(role) || false;
};
const value = {
isAuthenticated,
isLoading,
token,
userInfo,
login,
logout,
hasRole
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};import axios from 'axios';
import keycloak from '../keycloak';
const API_BASE_URL = 'http://localhost:8081/api';
const api = axios.create({
baseURL: API_BASE_URL,
});
// Interceptor de petición para agregar token de autenticación
api.interceptors.request.use(
(config) => {
if (keycloak.token) {
config.headers.Authorization = `Bearer ${keycloak.token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Interceptor de respuesta para manejar expiración de token
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
try {
await keycloak.updateToken(30);
// Reintentar la petición original
error.config.headers.Authorization = `Bearer ${keycloak.token}`;
return api.request(error.config);
} catch (refreshError) {
keycloak.login();
}
}
return Promise.reject(error);
}
);
export default api;import React from 'react';
import { useAuth } from '../contexts/AuthContext';
const ProtectedRoute = ({ children, roles = [] }) => {
const { isAuthenticated, isLoading, hasRole, login } = useAuth();
if (isLoading) {
return <div>Cargando...</div>;
}
if (!isAuthenticated) {
return (
<div>
<h2>Acceso Denegado</h2>
<p>Necesitas estar autenticado para acceder a esta página.</p>
<button onClick={login}>Iniciar Sesión</button>
</div>
);
}
if (roles.length > 0 && !roles.some(role => hasRole(role))) {
return (
<div>
<h2>Permisos Insuficientes</h2>
<p>No tienes el rol requerido para acceder a esta página.</p>
<p>Roles requeridos: {roles.join(', ')}</p>
</div>
);
}
return children;
};
export default ProtectedRoute;import React from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
const Navigation = () => {
const { isAuthenticated, userInfo, login, logout, hasRole } = useAuth();
return (
<nav style={{ padding: '1rem', borderBottom: '1px solid #ccc' }}>
<Link to="/" style={{ marginRight: '1rem' }}>Inicio</Link>
{isAuthenticated && (
<>
<Link to="/user" style={{ marginRight: '1rem' }}>Área Usuario</Link>
{hasRole('MANAGER') && (
<Link to="/manager" style={{ marginRight: '1rem' }}>Manager</Link>
)}
{hasRole('ADMIN') && (
<Link to="/admin" style={{ marginRight: '1rem' }}>Admin</Link>
)}
</>
)}
<div style={{ float: 'right' }}>
{isAuthenticated ? (
<div>
Bienvenido, {userInfo?.username}!
<button onClick={logout} style={{ marginLeft: '1rem' }}>Cerrar Sesión</button>
</div>
) : (
<button onClick={login}>Iniciar Sesión</button>
)}
</div>
</nav>
);
};
const Home = () => (
<div>
<h1>Página de Inicio</h1>
<p>Esta es la página de inicio pública.</p>
</div>
);
const UserPage = () => (
<ProtectedRoute roles={['USER']}>
<div>
<h1>Página de Usuario</h1>
<p>Esta página requiere el rol USER.</p>
</div>
</ProtectedRoute>
);
const ManagerPage = () => (
<ProtectedRoute roles={['MANAGER']}>
<div>
<h1>Página de Manager</h1>
<p>Esta página requiere el rol MANAGER.</p>
</div>
</ProtectedRoute>
);
const AdminPage = () => (
<ProtectedRoute roles={['ADMIN']}>
<div>
<h1>Página de Administrador</h1>
<p>Esta página requiere el rol ADMIN.</p>
</div>
</ProtectedRoute>
);
function App() {
return (
<AuthProvider>
<Router>
<div className="App">
<Navigation />
<div style={{ padding: '2rem' }}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/user" element={<UserPage />} />
<Route path="/manager" element={<ManagerPage />} />
<Route path="/admin" element={<AdminPage />} />
</Routes>
</div>
</div>
</Router>
</AuthProvider>
);
}
export default App;<!DOCTYPE html>
<html>
<head>
<title>Verificación Silenciosa SSO</title>
</head>
<body>
<script src="http://localhost:8080/js/keycloak.js"></script>
<script>
const keycloak = new Keycloak({
url: 'http://localhost:8080',
realm: 'mi-app-realm',
clientId: 'react-app',
});
keycloak.init({
onLoad: 'check-sso',
flow: 'standard'
}).then(() => {
parent.postMessage(location.href, location.origin);
});
</script>
</body>
</html>VITE_KEYCLOAK_URL=http://localhost:8080
VITE_KEYCLOAK_REALM=mi-app-realm
VITE_KEYCLOAK_CLIENT_ID=react-app
VITE_API_BASE_URL=http://localhost:8081/apiPara producción, actualiza tus configuraciones:
Spring Boot (application-prod.properties):
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://tu-dominio-keycloak/realms/mi-app-realm
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://tu-dominio-keycloak/realms/mi-app-realm/protocol/openid-connect/certs
app.cors.allowed-origins=https://tu-dominio-frontendReact (.env.production):
VITE_KEYCLOAK_URL=https://tu-dominio-keycloak
VITE_KEYCLOAK_REALM=mi-app-realm
VITE_KEYCLOAK_CLIENT_ID=react-app
VITE_API_BASE_URL=https://tu-dominio-api/apidocker-compose up -d(Keycloak + PostgreSQL)./mvnw spring-boot:run(Aplicación Spring Boot en puerto 8081)npm run dev(Aplicación React en puerto 3000)
- Acceder a endpoints públicos sin autenticación
- Iniciar sesión con diferentes usuarios que tengan diferentes roles
- Probar acceso basado en roles a endpoints protegidos
- Verificar funcionalidad de renovación de tokens
- Probar funcionalidad de cierre de sesión
Agregar validación personalizada de JWT en Spring Boot:
@Component
public class CustomJwtValidator implements OAuth2TokenValidator<Jwt> {
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
List<OAuth2Error> errors = new ArrayList<>();
// Agregar lógica de validación personalizada
if (!jwt.getClaimAsString("iss").equals("http://localhost:8080/realms/mi-app-realm")) {
errors.add(new OAuth2Error("invalid_issuer", "Emisor inválido", null));
}
if (errors.isEmpty()) {
return OAuth2TokenValidatorResult.success();
} else {
return OAuth2TokenValidatorResult.failure(errors);
}
}
}Usar anotaciones para seguridad de grano fino:
@PreAuthorize("hasRole('ADMIN') and #userId == authentication.name")
@GetMapping("/users/{userId}")
public ResponseEntity<User> obtenerUsuario(@PathVariable String userId) {
// Implementación
}
@PreAuthorize("@securityService.canAccessResource(authentication, #resourceId)")
@GetMapping("/resources/{resourceId}")
public ResponseEntity<Resource> obtenerRecurso(@PathVariable String resourceId) {
// Implementación
}logging.level.org.springframework.security=INFO
logging.level.org.springframework.security.oauth2=DEBUG
logging.level.org.springframework.web.cors=DEBUGEsta configuración proporciona una integración completa de Keycloak con seguridad basada en roles para tu aplicación Spring Boot y React. La configuración soporta múltiples entornos y sigue las mejores prácticas de seguridad.
Beneficios clave:
- Autenticación y autorización centralizadas
- Control de acceso basado en roles
- Autenticación sin estado basada en tokens
- Configuración CORS para integración frontend
- Configuraciones específicas por entorno
- Configuración lista para producción con PostgreSQL
Recuerda:
- Usar HTTPS en producción
- Actualizar regularmente Keycloak y dependencias
- Monitorear logs de seguridad
- Implementar manejo de errores apropiado
- Agregar limitación de velocidad para endpoints de API
- Considerar implementar rotación de tokens de renovación