Qué cambió — paso 11
Qué cambió · paso-10 → paso-11

Cambios de la lección 11

Todo lo que cambia en tienda/pasos/paso-11 respecto al paso anterior. Vuelve a la lección: 11. Federación con Active Directory.

8 archivos cambian. En verde lo que se añade; en rojo lo que se quita. go.sum no se muestra.

ArchivoEstadoLíneas
cmd/admin/main.gomodificado+16 −4
infra/ad/0globals.confnuevo+4 −0
infra/ad/admin-passwordnuevo+1 −0
infra/ad/seed.shnuevo+33 −0
infra/docker-compose.ldaps.ymlnuevo+21 −0
infra/docker-compose.ymlmodificado+45 −5
infra/realm/tienda-realm.jsonmodificado+268 −0
internal/admin/admin.gomodificado+101 −22
cmd/admin/main.go modificado · +16 −4
@@ -10,7 +10,8 @@
 //	go run ./cmd/admin miembro -usuario lucia -grupo personal
 //	go run ./cmd/admin sesiones -usuario ana
 //	go run ./cmd/admin expulsar -usuario ana                    (cierra todas sus sesiones)
-//	go run ./cmd/admin baja -usuario lucia                      (deshabilita)
+//	go run ./cmd/admin baja -usuario lucia                      (deshabilita; solo usuarios locales)
+//	go run ./cmd/admin sincronizar                              (importa/actualiza usuarios de AD)
 package main
 
 import (
@@ -53,9 +54,9 @@
 		users, err := a.Users(ctx)
 		check(err)
 		tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
-		fmt.Fprintln(tw, "USUARIO\tNOMBRE\tEMAIL\tACTIVO\tROLES\tGRUPOS")
+		fmt.Fprintln(tw, "USUARIO\tORIGEN\tNOMBRE\tEMAIL\tACTIVO\tROLES\tGRUPOS")
 		for _, u := range users {
-			fmt.Fprintf(tw, "%s\t%s\t%s\t%v\t%s\t%s\n", u.Username, u.Name, u.Email, u.Enabled,
+			fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%v\t%s\t%s\n", u.Username, u.Origin, u.Name, u.Email, u.Enabled,
 				strings.Join(u.Roles, ","), strings.Join(u.Groups, ","))
 		}
 		tw.Flush()
@@ -69,6 +70,7 @@
 		id, err := a.NewCustomer(ctx, *usuario, *email, *nombre, *apellido, *password)
 		check(err)
 		fmt.Printf("alta de %s (id %s) con rol cliente; deberá cambiar la contraseña al entrar\n", *usuario, id)
+		fmt.Println("(es un usuario local de Keycloak: los usuarios de AD se crean en AD)")
 
 	case "rol":
 		rol := fs.String("rol", "", "rol de realm")
@@ -116,6 +118,16 @@
 		check(a.Disable(ctx, *usuario))
 		fmt.Printf("%s deshabilitado\n", *usuario)
 
+	case "sincronizar":
+		res, err := a.Sync(ctx)
+		check(err)
+		if len(res) == 0 {
+			fmt.Println("el realm no tiene directorios federados")
+		}
+		for _, r := range res {
+			fmt.Printf("%s: %d nuevos, %d actualizados, %d eliminados, %d fallidos\n", r.Provider, r.Added, r.Updated, r.Removed, r.Failed)
+		}
+
 	default:
 		usage()
 	}
@@ -138,7 +150,7 @@
 }
 
 func usage() {
-	log.Fatal("uso: admin usuarios | alta | rol | grupo | miembro | sesiones | expulsar | baja  (-h en cada uno)")
+	log.Fatal("uso: admin usuarios | alta | rol | grupo | miembro | sesiones | expulsar | baja | sincronizar  (-h en cada uno)")
 }
 
 func env(key, def string) string {
infra/ad/0globals.conf nuevo · +4 −0
@@ -0,0 +1,4 @@
+# Solo para desarrollo: permite que Keycloak se autentique en el puerto 389 con
+# un bind simple, sin cifrar. Un AD real exige LDAPS (puerto 636) o firma; la
+# lección 11 explica cómo pasar a LDAPS.
+ldap server require strong auth = no
infra/ad/admin-password nuevo · +1 −0
@@ -0,0 +1 @@
+Admin-Tienda-2026!
infra/ad/seed.sh nuevo · +33 −0
@@ -0,0 +1,33 @@
+#!/bin/sh
+# Llena el «Active Directory» de prueba (Samba AD DC) con lo que usa la
+# tienda. Es idempotente: si algo ya existe, lo deja como está.
+set -e
+AD_ADMIN_PASSWORD=$(cat "$AD_ADMIN_PASSWORD_FILE")
+AUTH="-H ldap://ad -U TIENDA\\Administrator%${AD_ADMIN_PASSWORD}"
+
+echo "esperando al controlador de dominio..."
+until samba-tool ou list $AUTH >/dev/null 2>&1; do sleep 3; done
+
+ou()     { samba-tool ou list $AUTH | grep -qx "$1" || samba-tool ou create "$1,DC=tienda,DC=local" $AUTH; }
+group()  { samba-tool group show "$1" $AUTH >/dev/null 2>&1 || samba-tool group add "$1" --groupou="OU=Tienda" $AUTH; }
+# user <login> <contraseña> <nombre> <apellido> <ou>
+user() {
+  if ! samba-tool user show "$1" $AUTH >/dev/null 2>&1; then
+    samba-tool user create "$1" "$2" --use-username-as-cn --given-name="$3" --surname="$4" \
+      --mail-address="$1@tienda.local" --userou="$5" $AUTH
+  fi
+  samba-tool user setexpiry "$1" --noexpiry $AUTH >/dev/null
+}
+member() { samba-tool group addmembers "$1" "$2" $AUTH 2>/dev/null || true; }
+
+ou "OU=Tienda"
+# Cuenta de servicio con la que Keycloak lee el directorio (solo lectura).
+user svc-keycloak "Keycloak-Lectura-2026!" Keycloak Servicio "CN=Users"
+# Grupos con el mismo nombre que los roles de realm de Keycloak.
+group cliente
+group admin
+user sofia "Sofia-Tienda-2026!" Sofia Ventas "OU=Tienda"
+user diego "Diego-Tienda-2026!" Diego Soporte "OU=Tienda"
+member cliente sofia,diego
+member admin diego
+echo "AD de prueba listo: sofia (cliente), diego (cliente, admin)"
infra/docker-compose.ldaps.yml nuevo · +21 −0
@@ -0,0 +1,21 @@
+# LDAPS (opcional, lección 11): Keycloak habla con el AD cifrado, en el puerto 636.
+#
+#   1) docker compose cp ad:/var/lib/samba/private/tls/ca.pem ad/ad-ca.pem
+#   2) docker compose -f docker-compose.yml -f docker-compose.ldaps.yml up -d
+#   3) En la consola: User federation → active-directory → Connection URL:
+#      ldaps://dc1.tienda.local:636 y vuelve a escribir Bind credentials
+#      (Keycloak lo exige al cambiar la URL): Keycloak-Lectura-2026!
+#
+# La CA cambia cada vez que se crea el dominio (docker compose down -v):
+# entonces repite el paso 1.
+services:
+  ad:
+    networks:
+      default:
+        aliases: [dc1.tienda.local]   # el nombre que figura en el certificado del AD
+  keycloak:
+    environment:
+      # Confía en la CA que firmó el certificado del AD (archivo PEM).
+      KC_TRUSTSTORE_PATHS: /opt/keycloak/conf/truststores/ad-ca.pem
+    volumes:
+      - ./ad/ad-ca.pem:/opt/keycloak/conf/truststores/ad-ca.pem:ro
infra/docker-compose.yml modificado · +45 −5
@@ -1,11 +1,43 @@
-# Keycloak + PostgreSQL para el curso "Go + Keycloak".
-# Solo para desarrollo: usa start-dev (HTTP, cachés locales, sin hostname fijo).
+# Keycloak + PostgreSQL + un «Active Directory» de prueba (Samba AD DC) para
+# el curso "Go + Keycloak". Solo para desarrollo.
 #
-#   docker compose up -d          # arrancar
-#   docker compose logs -f keycloak
-#   docker compose down -v        # parar y BORRAR datos (fuerza reimportar el realm)
+#   docker compose up -d          # arrancar (la primera vez tarda: crea el dominio)
+#   docker compose logs -f ad-seed
+#   docker compose down -v        # parar y BORRAR datos (dominio y realm incluidos)
 
 services:
+  # Controlador de dominio tienda.local (TIENDA\…). Samba implementa el LDAP
+  # de Active Directory: grupos, sAMAccountName, memberOf, userAccountControl…
+  ad:
+    image: instantlinux/samba-dc:4.23.10-r0
+    hostname: dc1
+    cap_add: [SYS_ADMIN]            # Samba guarda atributos extendidos del sistema de archivos
+    environment:
+      REALM: tienda.local
+      WORKGROUP: TIENDA
+      NETBIOS_NAME: DC1
+      DOMAIN_ACTION: provision      # crea el dominio la primera vez
+      BIND_INTERFACES_ONLY: "no"
+    secrets:
+      - samba-admin-password
+    volumes:
+      - ad-etc:/etc/samba
+      - ad-lib:/var/lib/samba
+      - ./ad/0globals.conf:/etc/samba/conf.d/0globals.conf:ro
+
+  # Llena el AD (unidad Tienda, grupos, usuarios) y termina. Es idempotente.
+  ad-seed:
+    image: instantlinux/samba-dc:4.23.10-r0
+    entrypoint: ["sh", "/seed.sh"]
+    environment:
+      AD_ADMIN_PASSWORD_FILE: /run/secrets/samba-admin-password
+    secrets:
+      - samba-admin-password
+    volumes:
+      - ./ad/seed.sh:/seed.sh:ro
+    depends_on:
+      - ad
+
   postgres:
     image: postgres:17
     environment:
@@ -46,6 +78,14 @@
     depends_on:
       postgres:
         condition: service_healthy
+      ad-seed:
+        condition: service_completed_successfully
+
+secrets:
+  samba-admin-password:
+    file: ./ad/admin-password      # contraseña del administrador del dominio (solo desarrollo)
 
 volumes:
   pgdata:
+  ad-etc:
+  ad-lib:
infra/realm/tienda-realm.json modificado · +268 −0 · generado, plegado
@@ -316,6 +316,274 @@
       ]
     }
   ],
+  "components": {
+    "org.keycloak.storage.UserStorageProvider": [
+      {
+        "name": "active-directory",
+        "providerId": "ldap",
+        "subComponents": {
+          "org.keycloak.storage.ldap.mappers.LDAPStorageMapper": [
+            {
+              "name": "username",
+              "providerId": "user-attribute-ldap-mapper",
+              "subComponents": {},
+              "config": {
+                "ldap.attribute": [
+                  "sAMAccountName"
+                ],
+                "is.mandatory.in.ldap": [
+                  "true"
+                ],
+                "always.read.value.from.ldap": [
+                  "false"
+                ],
+                "read.only": [
+                  "true"
+                ],
+                "user.model.attribute": [
+                  "username"
+                ]
+              }
+            },
+            {
+              "name": "first name",
+              "providerId": "user-attribute-ldap-mapper",
+              "subComponents": {},
+              "config": {
+                "ldap.attribute": [
+                  "givenName"
+                ],
+                "is.mandatory.in.ldap": [
+                  "false"
+                ],
+                "always.read.value.from.ldap": [
+                  "true"
+                ],
+                "read.only": [
+                  "true"
+                ],
+                "user.model.attribute": [
+                  "firstName"
+                ]
+              }
+            },
+            {
+              "name": "last name",
+              "providerId": "user-attribute-ldap-mapper",
+              "subComponents": {},
+              "config": {
+                "ldap.attribute": [
+                  "sn"
+                ],
+                "is.mandatory.in.ldap": [
+                  "true"
+                ],
+                "read.only": [
+                  "true"
+                ],
+                "always.read.value.from.ldap": [
+                  "true"
+                ],
+                "user.model.attribute": [
+                  "lastName"
+                ]
+              }
+            },
+            {
+              "name": "email",
+              "providerId": "user-attribute-ldap-mapper",
+              "subComponents": {},
+              "config": {
+                "ldap.attribute": [
+                  "mail"
+                ],
+                "is.mandatory.in.ldap": [
+                  "false"
+                ],
+                "always.read.value.from.ldap": [
+                  "false"
+                ],
+                "read.only": [
+                  "true"
+                ],
+                "user.model.attribute": [
+                  "email"
+                ]
+              }
+            },
+            {
+              "name": "MSAD account controls",
+              "providerId": "msad-user-account-control-mapper",
+              "subComponents": {},
+              "config": {
+                "always.read.enabled.value.from.ldap": [
+                  "true"
+                ]
+              }
+            },
+            {
+              "name": "roles desde grupos de AD",
+              "providerId": "role-ldap-mapper",
+              "subComponents": {},
+              "config": {
+                "mode": [
+                  "LDAP_ONLY"
+                ],
+                "membership.attribute.type": [
+                  "DN"
+                ],
+                "roles.dn": [
+                  "OU=Tienda,DC=tienda,DC=local"
+                ],
+                "user.roles.retrieve.strategy": [
+                  "GET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTE"
+                ],
+                "membership.ldap.attribute": [
+                  "member"
+                ],
+                "membership.user.ldap.attribute": [
+                  "sAMAccountName"
+                ],
+                "role.name.ldap.attribute": [
+                  "cn"
+                ],
+                "memberof.ldap.attribute": [
+                  "memberOf"
+                ],
+                "use.realm.roles.mapping": [
+                  "true"
+                ],
+                "role.object.classes": [
+                  "group"
+                ],
+                "roles.ldap.filter": [
+                  "(|(cn=cliente)(cn=admin))"
+                ]
+              }
+            },
+            {
+              "name": "creation date",
+              "providerId": "user-attribute-ldap-mapper",
+              "subComponents": {},
+              "config": {
+                "ldap.attribute": [
+                  "whenCreated"
+                ],
+                "is.mandatory.in.ldap": [
+                  "false"
+                ],
+                "always.read.value.from.ldap": [
+                  "true"
+                ],
+                "read.only": [
+                  "true"
+                ],
+                "user.model.attribute": [
+                  "createTimestamp"
+                ]
+              }
+            },
+            {
+              "name": "modify date",
+              "providerId": "user-attribute-ldap-mapper",
+              "subComponents": {},
+              "config": {
+                "ldap.attribute": [
+                  "whenChanged"
+                ],
+                "is.mandatory.in.ldap": [
+                  "false"
+                ],
+                "read.only": [
+                  "true"
+                ],
+                "always.read.value.from.ldap": [
+                  "true"
+                ],
+                "user.model.attribute": [
+                  "modifyTimestamp"
+                ]
+              }
+            },
+            {
+              "name": "Kerberos principal attribute mapper",
+              "providerId": "kerberos-principal-attribute-mapper",
+              "subComponents": {},
+              "config": {}
+            }
+          ]
+        },
+        "config": {
+          "authType": [
+            "simple"
+          ],
+          "bindCredential": [
+            "Keycloak-Lectura-2026!"
+          ],
+          "bindDn": [
+            "CN=svc-keycloak,CN=Users,DC=tienda,DC=local"
+          ],
+          "cachePolicy": [
+            "NO_CACHE"
+          ],
+          "changedSyncPeriod": [
+            "-1"
+          ],
+          "connectionUrl": [
+            "ldap://ad:389"
+          ],
+          "editMode": [
+            "READ_ONLY"
+          ],
+          "enabled": [
+            "true"
+          ],
+          "fullSyncPeriod": [
+            "-1"
+          ],
+          "importEnabled": [
+            "true"
+          ],
+          "krbPrincipalAttribute": [
+            "userPrincipalName"
+          ],
+          "pagination": [
+            "true"
+          ],
+          "priority": [
+            "0"
+          ],
+          "rdnLDAPAttribute": [
+            "cn"
+          ],
+          "searchScope": [
+            "2"
+          ],
+          "syncRegistrations": [
+            "false"
+          ],
+          "trustEmail": [
+            "true"
+          ],
+          "userObjectClasses": [
+            "person, organizationalPerson, user"
+          ],
+          "usernameLDAPAttribute": [
+            "sAMAccountName"
+          ],
+          "usersDn": [
+            "OU=Tienda,DC=tienda,DC=local"
+          ],
+          "uuidLDAPAttribute": [
+            "objectGUID"
+          ],
+          "vendor": [
+            "ad"
+          ]
+        }
+      }
+    ]
+  },
   "clientScopes": [
     {
       "name": "api-pedidos",
internal/admin/admin.go modificado · +101 −22
@@ -19,9 +19,10 @@
 
 // Admin es un cliente de administración ya autenticado.
 type Admin struct {
-	kc    *gocloak.GoCloak
-	realm string
-	token string // access token de la service account de admin-tool
+	kc      *gocloak.GoCloak
+	baseURL string
+	realm   string
+	token   string // access token de la service account de admin-tool
 }
 
 // Config indica dónde está Keycloak y con qué client entrar.
@@ -32,8 +33,13 @@
 	ClientSecret string
 }
 
-// ErrNotFound indica que el usuario o el grupo no existe.
-var ErrNotFound = errors.New("no existe")
+var (
+	// ErrNotFound indica que el usuario o el grupo no existe.
+	ErrNotFound = errors.New("no existe")
+	// ErrFederated indica que el usuario viene de Active Directory: sus datos
+	// se cambian en AD, no en Keycloak.
+	ErrFederated = errors.New("el usuario viene de Active Directory")
+)
 
 // New obtiene un token de la service account. Una CLI vive menos que el
 // token (5 min), así que basta con pedirlo una vez.
@@ -43,13 +49,14 @@
 	if err != nil {
 		return nil, fmt.Errorf("login de %s: %w", cfg.ClientID, err)
 	}
-	return &Admin{kc: kc, realm: cfg.Realm, token: jwt.AccessToken}, nil
+	return &Admin{kc: kc, baseURL: cfg.URL, realm: cfg.Realm, token: jwt.AccessToken}, nil
 }
 
 // UserInfo resume un usuario para listarlo.
 type UserInfo struct {
 	ID, Username, Email, Name string
 	Enabled                   bool
+	Origin                    string   // "AD" si viene de un directorio (federationLink), si no "local"
 	Roles                     []string // roles de realm asignados directamente
 	Groups                    []string
 }
@@ -71,6 +78,7 @@
 			Email:    gocloak.PString(u.Email),
 			Name:     gocloak.PString(u.FirstName) + " " + gocloak.PString(u.LastName),
 			Enabled:  gocloak.PBool(u.Enabled),
+			Origin:   origin(u),
 		}
 		roles, err := a.kc.GetRealmRolesByUserID(ctx, a.token, a.realm, info.ID)
 		if err != nil {
@@ -117,19 +125,28 @@
 }
 
 // SetRole asigna (add=true) o quita un rol de realm a un usuario.
+// En usuarios de AD, los roles que vienen de grupos de AD (mapper LDAP_ONLY)
+// no se pueden cambiar aquí: Keycloak responde 400 y lo explicamos.
 func (a *Admin) SetRole(ctx context.Context, username, role string, add bool) error {
-	id, err := a.userID(ctx, username)
-	if err != nil {
-		return err
-	}
+	u, err := a.findUser(ctx, username)
+	if err != nil {
+		return err
+	}
+	id := gocloak.PString(u.ID)
 	r, err := a.kc.GetRealmRole(ctx, a.token, a.realm, role)
 	if err != nil {
 		return explain(err)
 	}
 	if add {
-		return explain(a.kc.AddRealmRoleToUser(ctx, a.token, a.realm, id, []gocloak.Role{*r}))
-	}
-	return explain(a.kc.DeleteRealmRoleFromUser(ctx, a.token, a.realm, id, []gocloak.Role{*r}))
+		err = a.kc.AddRealmRoleToUser(ctx, a.token, a.realm, id, []gocloak.Role{*r})
+	} else {
+		err = a.kc.DeleteRealmRoleFromUser(ctx, a.token, a.realm, id, []gocloak.Role{*r})
+	}
+	var apiErr *gocloak.APIError
+	if errors.As(err, &apiErr) && apiErr.Code == http.StatusBadRequest && origin(u) == "AD" {
+		return fmt.Errorf("%w: el rol %s sale de los grupos de AD; cambia la pertenencia al grupo en AD (%v)", ErrFederated, role, err)
+	}
+	return explain(err)
 }
 
 // EnsureGroup crea el grupo si no existe y le asigna los roles de realm.
@@ -217,27 +234,89 @@
 
 // Disable deshabilita a un usuario: no podrá iniciar sesión ni renovar tokens.
 // Es preferible a borrarlo: se conserva su historial (y su «sub»).
+// Los usuarios de AD se deshabilitan en AD: con el proveedor en solo lectura,
+// Keycloak ignoraría el cambio sin dar error, así que ni lo intentamos.
 func (a *Admin) Disable(ctx context.Context, username string) error {
-	id, err := a.userID(ctx, username)
-	if err != nil {
-		return err
-	}
-	return explain(a.kc.UpdateUser(ctx, a.token, a.realm, gocloak.User{ID: &id, Enabled: gocloak.BoolP(false)}))
+	u, err := a.findUser(ctx, username)
+	if err != nil {
+		return err
+	}
+	if origin(u) == "AD" {
+		return fmt.Errorf("%w: deshabilita a %s en AD (Keycloak lo verá en su siguiente login)", ErrFederated, username)
+	}
+	return explain(a.kc.UpdateUser(ctx, a.token, a.realm, gocloak.User{ID: u.ID, Enabled: gocloak.BoolP(false)}))
+}
+
+// SyncResult es el resultado de sincronizar un directorio.
+type SyncResult struct {
+	Provider string `json:"-"`
+	Added    int    `json:"added"`
+	Updated  int    `json:"updated"`
+	Removed  int    `json:"removed"`
+	Failed   int    `json:"failed"`
+}
+
+// Sync lanza una sincronización completa de cada directorio (LDAP/AD)
+// federado en el realm: importa a Keycloak los usuarios nuevos de AD y
+// actualiza los existentes. gocloak no tiene una función para esto, así que
+// usamos su cliente HTTP autenticado para llamar al endpoint directamente.
+func (a *Admin) Sync(ctx context.Context) ([]SyncResult, error) {
+	providers, err := a.kc.GetComponentsWithParams(ctx, a.token, a.realm, gocloak.GetComponentsParams{
+		ProviderType: gocloak.StringP("org.keycloak.storage.UserStorageProvider"),
+	})
+	if err != nil {
+		return nil, explain(err)
+	}
+	var out []SyncResult
+	for _, p := range providers {
+		var res SyncResult
+		url := fmt.Sprintf("%s/admin/realms/%s/user-storage/%s/sync", a.baseURL, a.realm, gocloak.PString(p.ID))
+		resp, err := a.kc.GetRequestWithBearerAuth(ctx, a.token).
+			SetQueryParam("action", "triggerFullSync").
+			SetResult(&res).
+			Post(url)
+		if err != nil {
+			return out, err
+		}
+		if resp.IsError() {
+			return out, fmt.Errorf("sincronizar %s: %s", gocloak.PString(p.Name), resp.Status())
+		}
+		res.Provider = gocloak.PString(p.Name)
+		out = append(out, res)
+	}
+	return out, nil
+}
+
+// origin dice de dónde viene un usuario: "AD" si está federado, "local" si no.
+func origin(u *gocloak.User) string {
+	if gocloak.PString(u.FederationLink) != "" {
+		return "AD"
+	}
+	return "local"
 }
 
 // userID busca el ID de un usuario por su nombre exacto.
 func (a *Admin) userID(ctx context.Context, username string) (string, error) {
+	u, err := a.findUser(ctx, username)
+	if err != nil {
+		return "", err
+	}
+	return gocloak.PString(u.ID), nil
+}
+
+// findUser busca un usuario por su nombre exacto.
+func (a *Admin) findUser(ctx context.Context, username string) (*gocloak.User, error) {
 	users, err := a.kc.GetUsers(ctx, a.token, a.realm, gocloak.GetUsersParams{
 		Username: gocloak.StringP(username),
 		Exact:    gocloak.BoolP(true), // sin Exact, «ana» también encontraría a «mariana»
 	})
 	if err != nil {
-		return "", explain(err)
+		return nil, explain(err)
 	}
 	if len(users) == 0 {
-		return "", fmt.Errorf("usuario %q: %w", username, ErrNotFound)
-	}
-	return gocloak.PString(users[0].ID), nil
+		return nil, fmt.Errorf("usuario %q: %w", username, ErrNotFound)
+	}
+	return users[0], nil
 }
 
 // groupID busca el ID de un grupo de primer nivel por su nombre exacto.

← Volver a la lección 11