Las dos lecciones anteriores han dejado a Kilómetro Cero con tokens RS256 que todos los servicios verifican y con canales cifrados por los que viajan. Pero han dado por resuelto algo que no lo está: de dónde salen las identidades. servicios/identidad/tokens.py firma con una clave cuyo .pub se copió a mano a cada servicio; el login es un formulario propio con Argon2; los empleados de Kilómetro Cero tienen cuentas corporativas en otro sistema; los productores preferirían entrar con su cuenta de Google; la app de repartidores no debería mostrar nunca una contraseña de larga duración; y pedidos necesita un token de máquina para llamar a inventario cuando actúa por su cuenta (una saga de compensación de 03-05, por ejemplo) sin ningún usuario detrás. Resolver cada caso en cada servicio es inviable y, peor, inseguro: cada implementación propia de login es una oportunidad de error.
Esta lección centraliza la identidad en un proveedor de identidad (IdP) y explica los protocolos que permiten que una identidad establecida allí valga en toda la plataforma: LDAP como directorio de empleados, SAML 2.0 para el inicio de sesión único empresarial, y OAuth 2.0 con OpenID Connect para aplicaciones web, móviles y máquina-a-máquina. Veremos la federación con identidades sociales, el ciclo de vida de una identidad (alta, cambios de rol, baja), la autenticación multifactor y la recuperación de cuentas. Y lo pondremos en marcha: Keycloak como IdP en docker-compose.yml con un realm km0, tres clientes, roles y grupos por productor, y OpenLDAP para los empleados; un cliente OIDC en Python con PKCE, la obtención de un token de máquina para pedidos, y una búsqueda LDAP. La identidad de los servicios por certificado (mTLS) y la custodia de secretos son 06-04; el borde que valida todo esto de forma centralizada, 06-05.
Contenido
- Por qué centralizar la identidad
- Proveedor de identidad frente a aplicaciones
- Directorios LDAP: DIT,
dn,ou, búsquedas y bind - SAML 2.0: inicio de sesión único empresarial
- OAuth 2.0: delegación de acceso
- OpenID Connect: identidad sobre OAuth 2.0
- LDAP, SAML y OAuth2/OIDC comparados
- Federación e identidades sociales
- Ciclo de vida, MFA y recuperación
- Kilómetro Cero: Keycloak como IdP y OpenLDAP para empleados
- Código: cliente OIDC con PKCE, client credentials y búsqueda LDAP
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Por qué centralizar la identidad
Kilómetro Cero tiene cuatro poblaciones con necesidades distintas:
| Población | Ejemplos | Cómo se autentica hoy | Qué necesita |
|---|---|---|---|
| Clientes | Ana, Marc, Lucía | Formulario propio, Argon2 | Registro fácil, login con Google/Apple, recuperación de contraseña, MFA opcional |
| Productores | Huerta La Vega, Quesería Montblanc, Bodega Roble Alto | Mismo formulario | Varias personas por productor con permisos distintos (ABAC de 06-01) |
| Empleados y operadores | Atención al cliente, operaciones, finanzas | Cuentas corporativas (correo, wiki, nóminas) | Una sola cuenta para todo (SSO), baja inmediata al dejar la empresa, MFA obligatorio |
| Servicios y trabajos | pedidos, analitica, el DAG km0_ventas_diarias, el consumidor de Flink |
Nada, o una contraseña en variable de entorno | Credenciales de máquina, de corta vida, sin humano de por medio |
Si cada servicio gestiona sus usuarios, aparecen los problemas conocidos: contraseñas distintas en cada sitio (y reutilizadas), un empleado que deja la empresa sigue teniendo acceso a analitica porque nadie lo borró de allí, MFA en la web pero no en el panel de operadores, y cuatro implementaciones de recuperación de contraseña con cuatro conjuntos de errores. La centralización resuelve: una fuente de verdad de quién existe y qué atributos tiene, un punto donde aplicar MFA y políticas de contraseña, una baja que cierra todas las puertas, y aplicaciones que dejan de tocar credenciales.
- Proveedor de identidad frente a aplicaciones
El proveedor de identidad (IdP) es el sistema que autentica a los sujetos y emite pruebas de identidad (assertions SAML, tokens OIDC) que las aplicaciones consumen. Las aplicaciones pasan a ser partes confiantes (relying parties, o service providers en SAML): nunca ven la contraseña, solo la prueba firmada.
flowchart LR
subgraph IdP["Proveedor de identidad (Keycloak, realm km0)"]
U[(Usuarios, roles, grupos)]
L[(OpenLDAP: empleados)]
G[Google / Apple]
U --- L
U --- G
end
W[web-km0] -->|OIDC| IdP
R[app-repartidores] -->|OIDC + PKCE| IdP
P[pedidos] -->|client credentials| IdP
O[Panel operadores] -->|OIDC| IdP
IdP -->|tokens RS256 + JWKS| S[Servicios: verifican como en 06-01]
Keycloak, el IdP que usaremos, cubre todo el cuadro: base de usuarios propia, federación con LDAP y con proveedores sociales, emisión de tokens OIDC y assertions SAML, MFA, y una consola de administración. Alternativas con el mismo papel: Auth0/Okta, Azure AD (Entra ID), AWS Cognito, Ory, Authentik. Los protocolos son estándar; cambiar de IdP no debería cambiar el código de las aplicaciones.
- Directorios LDAP: DIT,
dn, ou, búsquedas y bind
dn, ou, búsquedas y bindLDAP (Lightweight Directory Access Protocol) es el protocolo de consulta de directorios: bases de datos jerárquicas optimizadas para lecturas, que almacenan personas, grupos, equipos y sus atributos. Active Directory de Microsoft es la implementación más extendida en empresas (con Kerberos para la autenticación en Windows); OpenLDAP y 389 Directory Server son las de código abierto. En Kilómetro Cero el directorio guarda a los empleados, que es lo que suele haber ya en cualquier empresa antes de que exista la plataforma.
El directorio es un árbol, el DIT (Directory Information Tree). Cada entrada tiene un DN (distinguished name) único, que es la ruta desde la raíz:
dc=km0,dc=internal ← raíz (domain components) ├── ou=personas ← organizational unit │ ├── uid=jsala,ou=personas,dc=km0,dc=internal │ │ cn: Jordi Sala · mail: [email protected] · title: Operaciones │ │ memberOf: cn=operadores,ou=grupos,dc=km0,dc=internal │ └── uid=mpuig,ou=personas,dc=km0,dc=internal │ cn: Marta Puig · mail: [email protected] · title: Atención al cliente ├── ou=grupos │ ├── cn=operadores,ou=grupos,... member: uid=jsala,... member: uid=mpuig,... │ └── cn=finanzas,ou=grupos,... member: uid=jsala,... └── cn=keycloak-bind,dc=km0,dc=internal ← cuenta técnica de solo lectura
Conceptos:
- Entrada y atributos: cada nodo tiene atributos con tipos definidos por clases de objeto (
inetOrgPersonaportacn,sn,mail,uid;groupOfNamesaportamember). El esquema es estricto: no se puede inventar un atributo sin declararlo. dn,ou,cn,uid,dc: distinguished name, organizational unit, common name, user id, domain component. Eldnes la clave primaria; el resto son atributos con convenios.- Búsqueda: se indica una base (
ou=personas,dc=km0,dc=internal), un alcance (base,one,subtree) y un filtro con sintaxis de prefijo:(&(objectClass=inetOrgPerson)(memberOf=cn=operadores,ou=grupos,dc=km0,dc=internal))devuelve los operadores;(|(mail=j*)(uid=j*))los que empiezan por j. - Bind: la autenticación LDAP. Un simple bind envía
dny contraseña; el servidor los verifica contra el hash almacenado (SSHA, o mejor, Argon2 en OpenLDAP moderno). Autenticar a un usuario "contra LDAP" es: (1) con la cuenta técnica, buscar eldncuyouidomailcoincide; (2) intentar un bind con esedny la contraseña que el usuario tecleó. Si el bind tiene éxito, la contraseña es correcta. - LDAPS / StartTLS: LDAP en claro envía contraseñas en claro; siempre por TLS (puerto 636) o con StartTLS sobre el 389.
LDAP no es un protocolo de SSO: cada aplicación que autentica contra LDAP ve la contraseña del usuario. Por eso el papel de LDAP en una plataforma moderna es ser la fuente de usuarios y grupos que el IdP consulta, y no algo que las aplicaciones toquen directamente. Solo el IdP hace bind.
- SAML 2.0: inicio de sesión único empresarial
SAML (Security Assertion Markup Language, 2005) es el protocolo clásico de SSO entre empresas: un empleado entra una vez en el IdP corporativo y accede a decenas de aplicaciones de terceros (correo, nóminas, CRM) sin volver a autenticarse. Es XML firmado, transportado por redirecciones del navegador.
Actores y piezas:
- IdP: autentica y emite assertions: documentos XML firmados que afirman "el sujeto
[email protected]se autenticó a las 10:02 con contraseña y MFA, y tiene estos atributos (grupos, departamento)". - SP (service provider): la aplicación, que confía en el IdP porque tiene su certificado de firma, y que consume la assertion.
- Metadatos: cada parte publica un XML con sus URLs y certificados; se intercambian una vez al configurar la confianza.
- Bindings: cómo viaja el mensaje: HTTP-Redirect (en la URL), HTTP-POST (formulario autoenviado). Todo pasa por el navegador; IdP y SP no se hablan directamente.
El flujo SP-initiated (el usuario empieza en la aplicación):
sequenceDiagram
participant N as Navegador (Jordi)
participant SP as Panel de operadores (SP)
participant IdP as IdP corporativo (SAML)
N->>SP: GET /panel (sin sesión)
SP-->>N: 302 al IdP con AuthnRequest (firmado, comprimido en la URL)
N->>IdP: GET /sso?SAMLRequest=...
IdP->>N: Formulario de login (+ MFA)
N->>IdP: Credenciales
IdP-->>N: Página con formulario oculto: SAMLResponse (assertion XML firmada)
N->>SP: POST /saml/acs (Assertion Consumer Service) con SAMLResponse
SP->>SP: Verifica firma con el certificado del IdP, audiencia, caducidad, InResponseTo
SP-->>N: Cookie de sesión + 302 /panel
El flujo IdP-initiated empieza en un portal del IdP con iconos de aplicaciones; es menos seguro (no hay InResponseTo que ate la respuesta a una petición) y se desaconseja salvo necesidad. SAML sigue siendo obligatorio cuando el IdP de un cliente corporativo solo habla SAML; para aplicaciones nuevas, OIDC es más simple y mejor adaptado a APIs y móviles. Keycloak habla ambos, así que el panel de operadores de Kilómetro Cero podría usar SAML si mañana se integra con un IdP corporativo externo, y OIDC hoy.
- OAuth 2.0: delegación de acceso
OAuth 2.0 (RFC 6749, 2012) nació para un problema distinto de la autenticación: delegar acceso. Ana quiere que una app de terceros lea sus pedidos de Kilómetro Cero sin darle su contraseña. OAuth define cómo esa app obtiene un token de acceso limitado (a ciertos scopes, durante cierto tiempo) con el consentimiento de Ana. Que después se use como base de la autenticación es obra de OpenID Connect (apartado 6).
Roles:
| Rol | Quién es | En Kilómetro Cero |
|---|---|---|
| Resource owner | El dueño de los datos | Ana |
| Client | La aplicación que quiere acceder | web-km0, app-repartidores, pedidos |
| Authorization server | Emite tokens tras autenticar y pedir consentimiento | Keycloak, realm km0 |
| Resource server | La API que acepta tokens | pedidos, inventario, catalogo (verifican como en 06-01) |
Los scopes son cadenas que acotan el permiso: pedidos:leer, pedidos:crear, reparto:posicion. El token lleva los scopes concedidos y el resource server los comprueba. Los tipos de cliente: confidencial (puede guardar un secreto: un servicio de backend) y público (no puede: una SPA en el navegador, una app móvil; cualquier secreto embebido es extraíble).
Los flujos (grants)
| Flujo | Para quién | Cómo | Estado |
|---|---|---|---|
| Authorization code | Aplicaciones web con backend | El navegador va al IdP, vuelve con un código de un solo uso; el backend lo canjea por tokens con su secreto | Recomendado |
| Authorization code + PKCE | Clientes públicos (SPA, móvil) | Igual, pero sin secreto: el cliente genera un code_verifier aleatorio, envía su hash (code_challenge) al empezar, y el verificador al canjear; quien intercepte el código no puede canjearlo |
Recomendado; obligatorio en OAuth 2.1 para todos |
| Client credentials | Máquina a máquina, sin usuario | El cliente presenta su client_id y client_secret (o un certificado/JWT firmado) y recibe un token con sub = el propio cliente |
Recomendado |
| Refresh token | Cualquier cliente con sesión larga | Canjea un refresh token por un access token nuevo (06-01, apartado 6); con rotación en clientes públicos | Recomendado |
| Device code | Dispositivos sin teclado (TV, terminal) | El dispositivo muestra un código; el usuario lo introduce en otro dispositivo | Nicho |
| Implicit | SPAs (histórico) | El token volvía directamente en el fragmento de la URL | Desaconsejado: token en historial, sin PKCE, sin refresh |
| Resource owner password | Apps "de confianza" | La app recoge usuario y contraseña y los envía al IdP | Desaconsejado: anula el propósito de OAuth, sin MFA, entrena a los usuarios a dar la contraseña a apps |
La razón de PKCE merece detenerse. En un móvil, el IdP devuelve el código a la app mediante una URL personalizada (km0://callback?code=...), y otra app maliciosa podría registrar el mismo esquema y capturarlo. Con PKCE, el código solo sirve junto al code_verifier original, que nunca salió de la app legítima. En una SPA, protege igual contra un código robado por un script o por los logs de un proxy.
- OpenID Connect: identidad sobre OAuth 2.0
OpenID Connect (OIDC, 2014) añade a OAuth 2.0 lo que le faltaba para autenticar: un ID token (un JWT, exactamente los de 06-01, firmado por el IdP) con claims sobre quién es el usuario y cómo se autenticó, un endpoint userinfo para obtener más atributos, y descubrimiento automático de la configuración.
- ID token: claims
iss,sub,aud(=client_id),exp,iat, másauth_time,nonce(anti-repetición, generado por el cliente),amr(métodos:pwd,otp), y de perfil (email,name,preferred_username) según los scopesopenid profile email. Va dirigido al cliente: le dice quién ha entrado. - Access token: sigue siendo el de OAuth, dirigido a los resource servers (
aud: [pedidos, inventario]). Keycloak lo emite también como JWT RS256 con los roles del realm, que es lo queAuthInterceptorde 06-01 verifica. No se debe usar el ID token para llamar a las APIs, ni el access token para saber quién está en la web. - Discovery:
GET https://identidad.km0/realms/km0/.well-known/openid-configurationdevuelve un JSON con todos los endpoints (authorization_endpoint,token_endpoint,userinfo_endpoint,jwks_uri,end_session_endpoint) y los algoritmos soportados. - JWKS (JSON Web Key Set):
jwks_uripublica las claves públicas de firma del IdP, cada una con sukid. Esto sustituye al fichero.pubcopiado a mano de 06-01: los servicios descargan las claves, las cachean, y cuando ven unkiddesconocido las refrescan. La rotación de claves de firma pasa a ser automática.
El flujo completo con authorization code + PKCE, en Kilómetro Cero:
sequenceDiagram
participant N as Navegador (Ana)
participant W as web-km0 (backend)
participant K as Keycloak (realm km0)
participant P as pedidos
N->>W: GET /login
W->>W: genera state, nonce, code_verifier; challenge = SHA256(verifier)
W-->>N: 302 K/authorize?client_id=web-km0&response_type=code&scope=openid profile<br/>&redirect_uri=...&state&nonce&code_challenge&code_challenge_method=S256
N->>K: GET /authorize
K->>N: Login (contraseña + OTP si está activado)
N->>K: credenciales
K-->>N: 302 redirect_uri?code=abc&state=...
N->>W: GET /callback?code=abc&state=...
W->>W: comprueba state
W->>K: POST /token (code, code_verifier, client_id, client_secret)
K-->>W: {id_token, access_token, refresh_token, expires_in}
W->>K: GET /jwks (cacheado)
W->>W: verifica id_token: firma (kid), iss, aud=web-km0, exp, nonce
W-->>N: cookie de sesión HttpOnly
N->>W: GET /mis-pedidos
W->>P: GET /pedidos Authorization: Bearer access_token
P->>P: verifica con JWKS, aud=pedidos, roles (06-01)
P-->>W: pedidos de u-ana
Fíjate en que web-km0 es un cliente confidencial (tiene backend y secreto) y aun así usa PKCE: no cuesta nada y OAuth 2.1 lo exige. app-repartidores es público y usa solo PKCE.
- LDAP, SAML y OAuth2/OIDC comparados
| LDAP | SAML 2.0 | OAuth 2.0 / OIDC | |
|---|---|---|---|
| Qué es | Protocolo de directorio (consulta y bind) | Protocolo de SSO con assertions XML | Marco de delegación (OAuth) + capa de identidad (OIDC) con JWT |
| Año | 1993 (v3: 1997) | 2005 | 2012 / 2014 |
| Formato | Binario (ASN.1/BER) | XML firmado (XML-DSig) | JSON, JWT |
| Transporte | TCP 389/636, conexión directa app → directorio | Navegador (redirecciones, POST) | HTTPS, navegador para los flujos de usuario, directo para máquina |
| ¿La app ve la contraseña? | Sí | No | No |
| Móviles y APIs | Mal | Mal (XML pesado, sin tokens para APIs) | Diseñado para ello |
| Máquina a máquina | Bind de cuenta técnica | No | Client credentials |
| Atributos del usuario | Todos los del directorio | En la assertion | Claims del ID token y userinfo |
| Papel en Kilómetro Cero | Fuente de empleados y grupos para el IdP | Integración con IdPs corporativos externos | Todo lo demás: web, app, servicios |
No compiten: el patrón habitual es LDAP detrás del IdP, SAML u OIDC delante, y OIDC para lo nuevo.
- Federación e identidades sociales
Federar es aceptar identidades de otro IdP. Keycloak actúa como broker: la Quesería Montblanc pulsa "Entrar con Google", Keycloak redirige a Google (que es a su vez un IdP OIDC), recibe el ID token de Google, lo verifica, y crea o enlaza una cuenta local en el realm km0 con sub propio. Las aplicaciones de Kilómetro Cero solo ven tokens de Keycloak: no saben ni les importa si el usuario entró con contraseña, con Google o con Apple.
Ventajas: menos contraseñas que gestionar, registro con un clic, MFA delegada en el proveedor social. Precauciones: el email no verificado de algunos proveedores no debe usarse para enlazar automáticamente con una cuenta existente (un atacante registra en el proveedor X un email ajeno y hereda la cuenta); Keycloak lo obliga a verificar. Y las cuentas de empleados nunca se federan con proveedores sociales: vienen del LDAP corporativo, con MFA obligatorio.
- Ciclo de vida, MFA y recuperación
Una identidad no es un registro estático:
- Alta (onboarding): los clientes se autorregistran (con verificación de email); los productores son aprobados por un operador (rol
productory grupo del productor, apartado 10); los empleados aparecen en LDAP desde RR. HH. y Keycloak los sincroniza. SCIM (System for Cross-domain Identity Management) es el estándar REST para que un sistema de RR. HH. o un IdP cree, modifique y elimine usuarios en otras aplicaciones automáticamente; es lo que evita el "alguien tiene que darlo de alta a mano en cinco sitios". - Cambios de rol: Marta pasa de atención al cliente a operaciones: cambia su grupo en LDAP, Keycloak lo refleja en la siguiente sincronización o en el siguiente login, y su próximo token lleva los roles nuevos. Con tokens de 15 minutos (06-01), la latencia máxima es esa.
- Baja (offboarding): desactivar en LDAP o en Keycloak, revocar sesiones y refresh tokens. Los access tokens vivos caducan en minutos. Es la razón por la que las bajas se hacen en el IdP y nunca aplicación por aplicación.
- Revisión periódica (access review): listar quién tiene
operadory confirmar que sigue siendo cierto. Los permisos solo crecen si nadie los revisa. - MFA: obligatoria para operadores y productores (TOTP con una app, o WebAuthn/passkeys); opcional pero fomentada para clientes. Se configura una vez en el IdP y aplica a todo. Keycloak la impone por flujo de autenticación y por rol.
- Recuperación: el punto más atacado. Enlace de un solo uso por email con caducidad corta, nunca preguntas de seguridad, verificación adicional para cuentas con MFA (códigos de respaldo), y registro de cada recuperación en la auditoría de 06-05. Un flujo de recuperación débil anula la mejor MFA.
- Kilómetro Cero: Keycloak como IdP y OpenLDAP para empleados
Añadimos dos servicios a docker-compose.yml. Keycloak necesita su propia base de datos; OpenLDAP se inicializa con un LDIF con los empleados de ejemplo:
# km0/docker-compose.yml (fragmento). Contraseñas en claro solo para desarrollo: 06-04.
services:
postgres-keycloak:
image: postgres:16
environment: { POSTGRES_USER: keycloak, POSTGRES_PASSWORD: keycloak, POSTGRES_DB: keycloak }
volumes: [ "pg_keycloak:/var/lib/postgresql/data" ]
keycloak:
image: quay.io/keycloak/keycloak:26.0
command: ["start-dev", "--import-realm", "--hostname=http://localhost:8080"]
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres-keycloak:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
volumes:
- ./identidad/realm-km0.json:/opt/keycloak/data/import/realm-km0.json:ro
ports: [ "8080:8080" ]
depends_on: [ postgres-keycloak, openldap ]
openldap:
image: osixia/openldap:1.5.0
environment:
LDAP_ORGANISATION: "Kilometro Cero"
LDAP_DOMAIN: "km0.internal"
LDAP_ADMIN_PASSWORD: "admin"
LDAP_READONLY_USER: "true"
LDAP_READONLY_USER_USERNAME: "keycloak-bind"
LDAP_READONLY_USER_PASSWORD: "solo-lectura"
LDAP_TLS: "true"
volumes:
- ./identidad/empleados.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/50-empleados.ldif:ro
ports: [ "636:636" ]
volumes:
pg_keycloak:start-dev y --hostname=http://localhost:8080 son de desarrollo: en producción, Keycloak va detrás de TLS con start y un hostname real. El fichero identidad/empleados.ldif crea el árbol del apartado 3:
dn: ou=personas,dc=km0,dc=internal
objectClass: organizationalUnit
ou: personas
dn: ou=grupos,dc=km0,dc=internal
objectClass: organizationalUnit
ou: grupos
dn: uid=jsala,ou=personas,dc=km0,dc=internal
objectClass: inetOrgPerson
uid: jsala
cn: Jordi Sala
sn: Sala
mail: [email protected]
title: Operaciones
userPassword: {SSHA}... # generado con slappasswd; solo desarrollo
dn: uid=mpuig,ou=personas,dc=km0,dc=internal
objectClass: inetOrgPerson
uid: mpuig
cn: Marta Puig
sn: Puig
mail: [email protected]
title: Atencion al cliente
userPassword: {SSHA}...
dn: cn=operadores,ou=grupos,dc=km0,dc=internal
objectClass: groupOfNames
cn: operadores
member: uid=jsala,ou=personas,dc=km0,dc=internal
member: uid=mpuig,ou=personas,dc=km0,dc=internalY el realm, exportado a identidad/realm-km0.json (Keycloak lo importa al arrancar). Lo esencial, en forma de tabla porque el JSON completo ocupa cientos de líneas:
| Elemento | Configuración |
|---|---|
| Realm | km0; tokens de acceso 15 min; refresh 30 días con rotación (revoke refresh token: ON); firma RS256 con rotación de claves gestionada por Keycloak (kid en el JWKS) |
| Roles de realm | cliente, productor, repartidor, operador (los de 06-01) |
| Grupos | /productores/huerta-la-vega, /productores/queseria-montblanc, /productores/bodega-roble-alto: cada grupo asigna el rol productor y el atributo productor_id con el slug; /repartidores/furgoneta-3 asigna repartidor |
Cliente web-km0 |
Confidencial; Standard flow (authorization code) con PKCE S256 obligatorio; redirect_uris: ["https://km0.example/callback"]; scopes openid profile email + pedidos catalogo |
Cliente app-repartidores |
Público; authorization code + PKCE; redirect_uris: ["km0://callback"]; scope reparto |
Cliente pedidos-servicio |
Confidencial; solo service accounts (client credentials), sin flujos de usuario; rol de servicio svc-pedidos; audiencia inventario |
| Mappers | roles del realm → claim roles (lista plana); atributo de grupo productor_id → claim productor_id; scope pedidos → añade pedidos a aud; scope catalogo → catalogo a aud |
| Federación de usuarios | OpenLDAP: ldaps://openldap:636, bind dn: cn=keycloak-bind,dc=km0,dc=internal, users dn: ou=personas,dc=km0,dc=internal, modo READ_ONLY, sincronización periódica; group mapper ou=grupos → grupos de Keycloak; el grupo operadores asigna el rol operador |
| Proveedores de identidad | Google (OIDC), solo para usuarios de la base local; "confiar en email" desactivado |
| Autenticación | Flujo browser con OTP obligatorio para los roles operador y productor, opcional para cliente; recuperación por email con enlace de 15 min |
Con docker compose up -d keycloak openldap, la consola en http://localhost:8080 muestra el realm km0, y curl http://localhost:8080/realms/km0/.well-known/openid-configuration devuelve el documento de discovery. Jordi y Marta aparecen como usuarios federados desde LDAP, con el rol operador heredado del grupo; ninguna contraseña suya está en Keycloak.
El servicios/identidad/tokens.py de 06-01 queda retirado: Keycloak emite. Lo que sobrevive es servicios/comun/auth.py, con una única modificación: en lugar del .pub en fichero, usa el JWKS.
- Código: cliente OIDC con PKCE, client credentials y búsqueda LDAP
Verificación con JWKS (actualiza servicios/comun/auth.py)
# km0/servicios/comun/auth.py (fragmento actualizado)
import jwt
from jwt import PyJWKClient
ISS = "http://localhost:8080/realms/km0" # en producción: https://identidad.km0/realms/km0
_jwks = PyJWKClient(f"{ISS}/protocol/openid-connect/certs", cache_keys=True,
lifespan=3600) # cachea las claves; refresca si ve un kid nuevo
def verificar(token: str, audiencia: str) -> dict:
try:
clave = _jwks.get_signing_key_from_jwt(token).key # elige por 'kid' del header
return jwt.decode(token, clave, algorithms=["RS256"], audience=audiencia,
issuer=ISS, options={"require": ["exp", "iat", "sub"]}, leeway=30)
except jwt.PyJWTError as e:
raise TokenInvalido(str(e))El AuthInterceptor de inventario y el decorador requiere_rol no cambian una línea: los tokens de Keycloak llevan roles y productor_id gracias a los mappers.
servicios/identidad/oidc_cliente.py: authorization code + PKCE
Este módulo es el que usa el backend de web-km0 (y, con las diferencias comentadas, la app de repartidores). Se implementa con requests para que se vea cada paso; authlib lo envuelve en tres llamadas y es lo que se usaría en producción.
# km0/servicios/identidad/oidc_cliente.py
# pip install requests "PyJWT[crypto]"
import base64, hashlib, secrets, urllib.parse
import requests, jwt
from jwt import PyJWKClient
ISSUER = "http://localhost:8080/realms/km0"
CLIENT_ID = "web-km0"
CLIENT_SECRET = "..." # confidencial: llega desde el gestor de secretos (06-04)
REDIRECT_URI = "http://localhost:5000/callback"
_conf = requests.get(f"{ISSUER}/.well-known/openid-configuration", timeout=5).json()
_jwks = PyJWKClient(_conf["jwks_uri"], cache_keys=True)
def _b64url(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def iniciar_login() -> tuple[str, dict]:
"""Paso 1: construye la URL de autorización. Devuelve la URL y lo que hay que
guardar en la sesión del navegador (state, nonce, verifier) para el paso 2."""
state = _b64url(secrets.token_bytes(24)) # anti-CSRF: debe volver igual
nonce = _b64url(secrets.token_bytes(24)) # anti-repetición: irá dentro del id_token
verifier = _b64url(secrets.token_bytes(32)) # PKCE: secreto de un solo uso
challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
params = {
"client_id": CLIENT_ID, "response_type": "code",
"scope": "openid profile email pedidos catalogo",
"redirect_uri": REDIRECT_URI, "state": state, "nonce": nonce,
"code_challenge": challenge, "code_challenge_method": "S256",
}
url = _conf["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
return url, {"state": state, "nonce": nonce, "verifier": verifier}
def completar_login(query: dict, pendiente: dict) -> dict:
"""Paso 2: en /callback. Comprueba state, canjea el código y valida el ID token."""
if not secrets.compare_digest(query.get("state", ""), pendiente["state"]):
raise ValueError("state no coincide: posible CSRF")
resp = requests.post(_conf["token_endpoint"], data={
"grant_type": "authorization_code", "code": query["code"],
"redirect_uri": REDIRECT_URI, "client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET, # la app de repartidores no lo envía
"code_verifier": pendiente["verifier"], # PKCE: demuestra que somos quien empezó
}, timeout=5)
resp.raise_for_status()
tokens = resp.json()
clave = _jwks.get_signing_key_from_jwt(tokens["id_token"]).key
id_claims = jwt.decode(tokens["id_token"], clave, algorithms=["RS256"],
audience=CLIENT_ID, issuer=ISSUER, leeway=30)
if id_claims.get("nonce") != pendiente["nonce"]:
raise ValueError("nonce no coincide: id_token reutilizado")
return {"sub": id_claims["sub"], "email": id_claims.get("email"),
"nombre": id_claims.get("name"),
"access_token": tokens["access_token"], # para llamar a pedidos/catalogo
"refresh_token": tokens["refresh_token"], # guardar en servidor, nunca en el navegador
"expira_en": tokens["expires_in"]}
def refrescar(refresh_token: str) -> dict:
resp = requests.post(_conf["token_endpoint"], data={
"grant_type": "refresh_token", "refresh_token": refresh_token,
"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, timeout=5)
resp.raise_for_status()
return resp.json() # trae refresh_token nuevo: rotación (guardar, descartar el viejo)Integrado en Flask, /login llama a iniciar_login, guarda pendiente en la sesión y redirige; /callback llama a completar_login, guarda sub, access_token y refresh_token en la sesión de servidor (Redis, 06-01) y redirige a la página. El navegador solo tiene la cookie HttpOnly de la sesión.
Comprobación rápida desde la terminal, con un usuario ana creado en el realm:
python -c "from servicios.identidad.oidc_cliente import iniciar_login; print(iniciar_login()[0])"
# Abre la URL en el navegador, entra como ana, y copia el ?code=...&state=... de la redirecciónClient credentials: pedidos obtiene su token de máquina
Cuando pedidos actúa por sí mismo (compensación de una saga, reconciliación nocturna), no hay token de usuario que reenviar. Pide el suyo:
# km0/servicios/pedidos/token_servicio.py
import time, requests
TOKEN_URL = "http://keycloak:8080/realms/km0/protocol/openid-connect/token"
CLIENT_ID, CLIENT_SECRET = "pedidos-servicio", "..." # secreto: 06-04
_cache = {"token": None, "exp": 0}
def token_de_servicio() -> str:
"""Devuelve un access token para pedidos-servicio, cacheado hasta 60 s antes de caducar."""
if time.time() < _cache["exp"] - 60:
return _cache["token"]
r = requests.post(TOKEN_URL, data={"grant_type": "client_credentials",
"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,
"scope": "inventario"}, timeout=5)
r.raise_for_status()
t = r.json()
_cache.update(token=t["access_token"], exp=time.time() + t["expires_in"])
return _cache["token"]
# Uso en la saga de compensación (03-05), sin usuario detrás:
# stub.LiberarReserva(req, metadata=(("authorization", f"Bearer {token_de_servicio()}"),), timeout=2)El token resultante tiene sub: service-account-pedidos-servicio, roles: [svc-pedidos], aud: inventario. En ROLES_POR_METODO de inventario (06-01) se añade svc-pedidos a ReservarStock y LiberarReserva. Es la primera identidad de máquina real de la plataforma; 06-04 la reforzará con un certificado en lugar de un client_secret.
Búsqueda LDAP con ldap3
Los servicios no deberían hablar con LDAP (lo hace Keycloak), pero un operador sí necesita a veces consultar el directorio: por ejemplo, un script interno que lista quién está en operadores para la revisión de accesos del apartado 9.
# km0/servicios/identidad/ldap_consulta.py
# pip install ldap3
from ldap3 import Server, Connection, Tls, SUBTREE, ALL
import ssl
tls = Tls(ca_certs_file="certs/km0-ca.crt", validate=ssl.CERT_REQUIRED) # LDAPS con la CA de 06-02
servidor = Server("openldap", port=636, use_ssl=True, tls=tls, get_info=ALL)
BASE = "dc=km0,dc=internal"
def miembros_del_grupo(grupo: str) -> list[dict]:
with Connection(servidor, user=f"cn=keycloak-bind,{BASE}", password="solo-lectura",
auto_bind=True) as c: # bind con la cuenta técnica
c.search(search_base=f"ou=grupos,{BASE}", search_scope=SUBTREE,
search_filter=f"(&(objectClass=groupOfNames)(cn={grupo}))",
attributes=["member"])
if not c.entries:
return []
miembros = []
for dn in c.entries[0].member.values:
c.search(search_base=dn, search_scope="BASE", search_filter="(objectClass=*)",
attributes=["uid", "cn", "mail", "title"])
e = c.entries[0]
miembros.append({"uid": str(e.uid), "nombre": str(e.cn),
"mail": str(e.mail), "puesto": str(e.title)})
return miembros
def autenticar_empleado(uid: str, contrasena: str) -> bool:
"""Solo para ilustrar el bind de usuario: en Kilómetro Cero lo hace Keycloak, nunca un servicio."""
with Connection(servidor, user=f"cn=keycloak-bind,{BASE}", password="solo-lectura",
auto_bind=True) as c:
c.search(f"ou=personas,{BASE}", f"(uid={uid})", attributes=["dn"])
if not c.entries:
return False
dn = c.entries[0].entry_dn
try:
Connection(servidor, user=dn, password=contrasena, auto_bind=True).unbind()
return True
except Exception:
return False
if __name__ == "__main__":
for m in miembros_del_grupo("operadores"):
print(m)
# {'uid': 'jsala', 'nombre': 'Jordi Sala', 'mail': '[email protected]', 'puesto': 'Operaciones'}
# {'uid': 'mpuig', 'nombre': 'Marta Puig', 'mail': '[email protected]', 'puesto': 'Atencion al cliente'}Dos detalles de seguridad en el código: el filtro de búsqueda se construye con valores controlados (grupo viene de un argumento del script); si viniera de una entrada de usuario habría que escaparlo (ldap3.utils.conv.escape_filter_chars) para evitar inyección LDAP, el equivalente de la inyección SQL. Y la cuenta técnica es de solo lectura: si alguien roba solo-lectura, puede listar empleados pero no modificar grupos.
Errores Comunes y Consejos
- Usar el ID token para llamar a las APIs (o el access token para identificar al usuario en la web). El ID token tiene
aud: web-km0ypedidosdebe rechazarlo; el access token puede ser opaco en otros IdPs. Cada token para su destinatario. - Omitir
stateononce"porque ya hay PKCE". Protegen contra ataques distintos:statecontra CSRF en el callback (que te inicien sesión en la cuenta del atacante),noncecontra la reutilización de un ID token, PKCE contra la interceptación del código. redirect_uricon comodines. El IdP debe aceptar solo URIs exactas; un comodín permite que el código acabe en un dominio del atacante.- Flujo password "porque es una app propia". Anula MFA, el consentimiento y la federación, y acostumbra al usuario a teclear su contraseña en apps. Authorization code + PKCE sirve también para apps nativas.
- Secretos de cliente en apps móviles o SPAs. Son públicos por definición; regístralos como públicos con PKCE.
- Aplicaciones que hacen bind contra LDAP directamente. Cada una ve contraseñas y ninguna aplica MFA. LDAP detrás del IdP.
- Enlazar cuentas sociales por email no verificado. Es la vía clásica de apropiación de cuentas.
- Bajas aplicación por aplicación. Una baja es desactivar en el IdP (o en LDAP) y revocar sesiones; si necesitas tocar cinco sistemas, el diseño está mal (SCIM existe para eso).
- Copiar el JWKS a mano o fijar una sola clave. El IdP rota claves; usa
jwks_uricon caché y refresco porkiddesconocido. - Consejo: exporta el realm de Keycloak (
kc.sh export) y versiónalo enidentidad/realm-km0.json: la configuración de identidad es código, se revisa y se despliega igual que el resto. - Consejo: prueba el flujo con un IdP real en desarrollo (Keycloak en Compose), no con un "modo sin autenticación". Las diferencias entre entornos en autenticación son la fuente clásica de sorpresas en producción.
- Advertencia de cumplimiento. Los datos de identidad (emails, nombres, grupos de empleados, proveedores sociales enlazados) son datos personales; su tratamiento, retención y la política de MFA y recuperación deben revisarse con un profesional de seguridad y protección de datos.
Ejercicios
Ejercicio 1: El código interceptado
Un repartidor tiene instalada una app maliciosa que registra el esquema km0:// y captura la redirección km0://callback?code=XYZ&state=... antes que app-repartidores. Explica qué puede hacer la app maliciosa con ese código (a) si app-repartidores fuera un cliente confidencial con secreto embebido en el APK, (b) si fuera público sin PKCE, (c) con la configuración del apartado 10. Indica en cada caso qué comprobación de Keycloak falla o no falla.
Ejercicio 2: Marta deja la empresa
Marta Puig (mpuig) deja Kilómetro Cero un viernes a las 17:00. Describe qué acciones realiza RR. HH./IT, en qué sistema, y qué ocurre con: su sesión abierta en el panel de operadores, su refresh token, un access token emitido a las 16:55, su cuenta en la wiki interna (que usa SAML contra Keycloak) y sus permisos en analitica. ¿Qué tendría que existir para que todo esto fuera una única acción? ¿Y qué falla si analitica mantuviera su propia tabla de usuarios?
Ejercicio 3: El DAG de Airflow y Flink piden identidad
km0_ventas_diarias (05-05) y el consumidor de Flink de pedidos.eventos (05-04) no tienen usuario. Diseña para cada uno: el cliente en el realm km0 (nombre, tipo, flujo, scopes/audiencia), los roles de servicio, cómo obtienen y renuevan el token, y qué comprobación añade inventario o el broker de Kafka. Señala qué parte de este diseño 06-04 va a cambiar y por qué.
Soluciones
Ejercicio 1.
(a) La app maliciosa extrae el secreto del APK (es trivial), canjea el código con client_id + client_secret y obtiene los tokens del repartidor: Keycloak no puede distinguirla de la app legítima; ninguna comprobación falla. (b) Sin PKCE, un cliente público canjea el código solo con client_id y redirect_uri; la app maliciosa lo hace antes que la legítima y obtiene los tokens; la legítima recibe un error al intentar canjear un código ya usado, que es la única señal, tardía. (c) Con PKCE S256 obligatorio, el canje exige el code_verifier cuyo SHA-256 se envió como code_challenge en la petición de autorización; la app maliciosa tiene el código pero no el verificador (nunca salió de app-repartidores); Keycloak calcula el hash del verificador que envíe (o rechaza su ausencia) y responde invalid_grant. El código queda inservible; la app legítima, si es la primera en canjear, entra con normalidad. state no interviene en este ataque (protege el callback contra CSRF, no contra interceptación).
Ejercicio 2.
IT desactiva uid=mpuig en OpenLDAP (o RR. HH. lo hace vía SCIM/sincronización desde su sistema). Keycloak, en modo READ_ONLY con sincronización periódica, ve la cuenta desactivada en el siguiente ciclo (o de inmediato si IT también la desactiva en Keycloak y revoca sus sesiones desde la consola, que es lo recomendable). La sesión del panel de operadores: si el panel guarda una sesión de servidor con el access token, sigue funcionando hasta que el token caduque (16:55 + 15 min = 17:10 como máximo) o hasta que intente refrescar; el refresh token fue revocado al cerrar sus sesiones en Keycloak, así que el refresh falla y el panel la expulsa. La wiki con SAML: la próxima vez que necesite reautenticarse (o de inmediato si la wiki recibe single logout), el IdP no la autentica; su sesión local en la wiki dura lo que dure su cookie, y por eso las sesiones de aplicaciones SAML se configuran cortas. analitica: si valida tokens de Keycloak, no hay nada que hacer allí. Para que todo sea una acción única hace falta que la baja se haga solo en la fuente (LDAP/RR. HH.) y que fluya por sincronización o SCIM hasta el IdP, con revocación automática de sesiones. Si analitica mantuviera su propia tabla de usuarios con contraseña, la baja no llegaría: Marta seguiría entrando en analitica el lunes, que es exactamente el motivo del apartado 1.
Ejercicio 3.
Airflow: cliente airflow-servicio, confidencial, solo service accounts (client credentials), scope/audiencia analitica y hdfs (si el lago se protege con tokens; en la práctica HDFS usa Kerberos, fuera del curso), rol de servicio svc-analitica-escritura; el DAG obtiene el token en un PythonOperator inicial (o un hook que lo cachee como token_de_servicio) y, como las tareas duran más de 15 minutos, lo renueva por client credentials cuando caduca (sin refresh token: client credentials se repite). Flink: cliente flink-pedidos-consumidor, confidencial, client credentials, audiencia kafka, rol svc-consumidor-pedidos; el broker de Kafka se configura con SASL_SSL y el mecanismo OAUTHBEARER, que valida el JWT contra el JWKS de Keycloak y aplica ACLs: ese principal solo puede leer pedidos.eventos con su grupo de consumidores, y no escribir en nada. inventario añade svc-analitica-... a los métodos de solo lectura si Airflow lo consulta. Lo que 06-04 cambia: el client_secret de ambos vive hoy en la configuración de Airflow y del job de Flink, es decir, en un fichero o variable de entorno; se sustituirá por autenticación con certificado de cliente (mTLS) o por un secreto inyectado y rotado por Vault, y las credenciales de PostgreSQL del DAG pasarán a ser dinámicas.
Conclusión
Centralizar la identidad significa que existe un único lugar donde se decide quién existe, cómo se autentica y qué atributos tiene, y que las aplicaciones consumen pruebas firmadas en lugar de contraseñas. LDAP es el directorio: un árbol de entradas con dn, unidades organizativas, grupos y bind, ideal como fuente de empleados detrás del IdP y pésimo como protocolo de login de aplicaciones. SAML 2.0 es el SSO empresarial por assertions XML, todavía necesario para integrar IdPs corporativos. OAuth 2.0 delega acceso con scopes y flujos, de los que solo tres importan hoy (authorization code con PKCE, client credentials y refresh), y OpenID Connect le añade la identidad: un ID token para la aplicación, un access token para las APIs, discovery para configurarse solo y JWKS para que la verificación RS256 de 06-01 rote claves sin intervención. La federación con identidades sociales, el ciclo de vida con SCIM, la MFA por rol y una recuperación cuidadosa completan el cuadro. Kilómetro Cero tiene ahora Keycloak con el realm km0, los clientes web-km0, app-repartidores y pedidos-servicio, roles y grupos por productor, OpenLDAP con Jordi y Marta, oidc_cliente.py con PKCE y validación contra JWKS, token_de_servicio() para pedidos, y una consulta LDAP para la revisión de accesos.
Y sin embargo, dos cosas siguen siendo frágiles. pedidos-servicio se identifica con un client_secret, que es una contraseña de máquina guardada en algún sitio; inventario acepta esa identidad porque el token es válido, pero no tiene forma de saber si la conexión TLS que le llega viene de verdad del contenedor de pedidos o de cualquier proceso que haya robado el secreto. Y la contraseña de km0_analitica sigue en una variable de entorno del DAG, como la dejó 05-05. La siguiente lección abandona la idea de perímetro: cada servicio tendrá su propio certificado, cada conexión se autenticará en ambos sentidos (mTLS), la autorización se hará también por identidad de servicio, y los secretos dejarán de escribirse en ficheros para vivir en Vault, con credenciales dinámicas que caducan solas.
Curso de Arquitecturas Distribuidas
Módulo 1: Introducción a los Sistemas Distribuidos
- Conceptos Básicos de Sistemas Distribuidos
- Modelos de Sistemas Distribuidos
- Ventajas y Desafíos de los Sistemas Distribuidos
- Las Falacias de la Computación Distribuida
- Tiempo, Relojes y Ordenación de Eventos
- Del Monolito a la Plataforma Distribuida: el Caso Kilómetro Cero
Módulo 2: Comunicación en Sistemas Distribuidos
- Protocolos de Comunicación
- RPC y RMI
- gRPC y Serialización de Datos
- Mensajería y Colas de Mensajes
- Patrones de Comunicación Asíncrona
Módulo 3: Consistencia y Replicación
- Modelos de Consistencia
- El Teorema CAP y PACELC
- Algoritmos de Consenso
- Replicación de Datos
- Transacciones Distribuidas y Sagas
Módulo 4: Almacenamiento Distribuido
- Particionado de Datos y Hashing Consistente
- Sistemas de Archivos Distribuidos
- Almacenamiento de Objetos
- Bases de Datos Distribuidas
- Cachés Distribuidos
Módulo 5: Computación Distribuida
- Modelos de Computación Distribuida
- MapReduce y Hadoop
- Spark y Computación en Memoria
- Procesamiento de Flujos de Datos
- Planificación de Trabajos y Pipelines de Datos
Módulo 6: Seguridad en Sistemas Distribuidos
- Autenticación y Autorización
- Cifrado y Protección de Datos
- Gestión de Identidades
- Seguridad entre Servicios: mTLS y Gestión de Secretos
- Puertas de Enlace, Limitación de Tasa y Auditoría
Módulo 7: Monitoreo y Mantenimiento
- Monitoreo de Sistemas Distribuidos
- Logs Centralizados y Trazabilidad Distribuida
- Gestión de Fallos y Recuperación
- Patrones de Resiliencia: Timeouts, Reintentos y Circuit Breaker
- Automatización y Orquestación
- Pruebas en Sistemas Distribuidos e Ingeniería del Caos
