Skip to content

mata82/keycloak-spring-react-guide

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 

Repository files navigation

Guía de Integración Keycloak - Spring Boot 3.5 + React + Vite

Resumen General

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.

Arquitectura

  • 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)

Paso 1: Configuración de Docker para Keycloak y PostgreSQL

1.1 Crear docker-compose.yml

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: bridge

1.2 Iniciar los contenedores

docker-compose up -d

Paso 2: Configuración de Keycloak

2.1 Acceder a la Consola de Administración de Keycloak

2.2 Crear Reino (Realm)

  1. Hacer clic en "Create Realm"
  2. Nombre del reino: mi-app-realm
  3. Hacer clic en "Create"

2.3 Crear Cliente para Backend (Spring Boot)

  1. Ir a Clients → Create Client
  2. Client ID: spring-boot-app
  3. Client type: OpenID Connect
  4. Next → Next
  5. Configurar:
    • Valid redirect URIs: http://localhost:8081/*
    • Web origins: http://localhost:8081
    • Access Type: confidential
  6. Guardar y anotar el Client Secret de la pestaña Credentials

2.4 Crear Cliente para Frontend (React)

  1. Ir a Clients → Create Client
  2. Client ID: react-app
  3. Client type: OpenID Connect
  4. Next → Next
  5. Configurar:
    • Valid redirect URIs: http://localhost:3000/*
    • Web origins: http://localhost:3000
    • Access Type: public
  6. Guardar

2.5 Crear Roles

  1. Ir a Realm roles → Create role
  2. Crear los siguientes roles:
    • ADMIN
    • USER
    • MANAGER

2.6 Crear Usuarios y Asignar Roles

  1. Ir a Users → Add user
  2. Crear usuarios con diferentes roles para pruebas
  3. Establecer contraseñas en la pestaña Credentials (temporary: off)
  4. Asignar roles en la pestaña Role mapping

Paso 3: Configuración del Backend Spring Boot

3.1 Agregar Dependencias (pom.xml)

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

3.2 Propiedades de la Aplicación

# 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

3.3 Configuración de Seguridad

@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;
    }
}

3.4 Controladores de Ejemplo

@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"));
    }
}

Paso 4: Configuración del Frontend React

4.1 Instalar el Adaptador JS de Keycloak

npm install keycloak-js

4.2 Configuración de Keycloak (src/keycloak.js)

import Keycloak from 'keycloak-js';

const keycloak = new Keycloak({
  url: 'http://localhost:8080',
  realm: 'mi-app-realm',
  clientId: 'react-app',
});

export default keycloak;

4.3 Contexto de Autenticación (src/contexts/AuthContext.jsx)

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>
  );
};

4.4 Cliente HTTP con Autenticación (src/utils/api.js)

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;

4.5 Componente de Ruta Protegida (src/components/ProtectedRoute.jsx)

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;

4.6 Componente Principal de la App (src/App.jsx)

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;

4.7 HTML para Verificación Silenciosa de SSO (public/silent-check-sso.html)

<!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>

Paso 5: Configuración de Entornos

5.1 Entorno de Desarrollo (.env.development)

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/api

5.2 Configuración del Entorno de Producción

Para 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-frontend

React (.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/api

Paso 6: Probando la Implementación

6.1 Iniciar todos los servicios:

  1. docker-compose up -d (Keycloak + PostgreSQL)
  2. ./mvnw spring-boot:run (Aplicación Spring Boot en puerto 8081)
  3. npm run dev (Aplicación React en puerto 3000)

6.2 Escenarios de prueba:

  1. Acceder a endpoints públicos sin autenticación
  2. Iniciar sesión con diferentes usuarios que tengan diferentes roles
  3. Probar acceso basado en roles a endpoints protegidos
  4. Verificar funcionalidad de renovación de tokens
  5. Probar funcionalidad de cierre de sesión

Paso 7: Consideraciones Adicionales de Seguridad

7.1 Mejora en la Validación de Tokens JWT

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);
        }
    }
}

7.2 Seguridad a Nivel de Método

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
}

Paso 8: Monitoreo y Logging

8.1 Agregar configuración de logging para eventos de seguridad:

logging.level.org.springframework.security=INFO
logging.level.org.springframework.security.oauth2=DEBUG
logging.level.org.springframework.web.cors=DEBUG

8.2 Monitorear métricas y logs de Keycloak para eventos de seguridad

Conclusión

Esta 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

About

Keycloak Integration Guide - Spring Boot 3.5 + React + Vite

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published