Qué cambió — paso 09
Qué cambió · paso-08 → paso-09

Cambios de la lección 09

Todo lo que cambia en tienda/pasos/paso-09 respecto al paso anterior. Vuelve a la lección: 09. gocloak: usuarios, roles y grupos.

4 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.gonuevo+149 −0
go.modmodificado+11 −1
infra/realm/tienda-realm.jsonmodificado+46 −0
internal/admin/admin.gonuevo+273 −0
cmd/admin/main.go nuevo · +149 −0
@@ -0,0 +1,149 @@
+// Command admin es una CLI de administración del realm tienda, escrita con
+// gocloak sobre la Admin REST API de Keycloak.
+//
+// Uso (desde tienda/pasos/paso-09):
+//
+//	go run ./cmd/admin usuarios
+//	go run ./cmd/admin alta -usuario lucia -email lucia@tienda.test -nombre Lucía -apellido Cliente -password Temporal123
+//	go run ./cmd/admin rol -usuario lucia -rol admin            (añade; con -quitar lo quita)
+//	go run ./cmd/admin grupo -nombre personal -rol admin        (crea el grupo o le añade el rol)
+//	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)
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"log"
+	"os"
+	"strings"
+	"text/tabwriter"
+	"time"
+
+	"tienda/internal/admin"
+)
+
+func main() {
+	log.SetFlags(0)
+	if len(os.Args) < 2 {
+		usage()
+	}
+	cmd, args := os.Args[1], os.Args[2:]
+
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	a, err := admin.New(ctx, admin.Config{
+		URL:          env("KEYCLOAK_URL", "http://localhost:8080"),
+		Realm:        env("KEYCLOAK_REALM", "tienda"),
+		ClientID:     env("ADMIN_CLIENT_ID", "admin-tool"),
+		ClientSecret: env("ADMIN_CLIENT_SECRET", "admin-tool-secret"), // solo para desarrollo
+	})
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	fs := flag.NewFlagSet(cmd, flag.ExitOnError)
+	usuario := fs.String("usuario", "", "nombre de usuario")
+	switch cmd {
+	case "usuarios":
+		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")
+		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,
+				strings.Join(u.Roles, ","), strings.Join(u.Groups, ","))
+		}
+		tw.Flush()
+
+	case "alta":
+		email := fs.String("email", "", "email")
+		nombre := fs.String("nombre", "", "nombre")
+		apellido := fs.String("apellido", "", "apellido")
+		password := fs.String("password", "", "contraseña temporal")
+		parse(fs, args, func() bool { return *usuario != "" && *email != "" && *password != "" })
+		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)
+
+	case "rol":
+		rol := fs.String("rol", "", "rol de realm")
+		quitar := fs.Bool("quitar", false, "quitar el rol en vez de añadirlo")
+		parse(fs, args, func() bool { return *usuario != "" && *rol != "" })
+		check(a.SetRole(ctx, *usuario, *rol, !*quitar))
+		fmt.Printf("rol %s %s a %s\n", *rol, map[bool]string{true: "quitado", false: "asignado"}[*quitar], *usuario)
+
+	case "grupo":
+		nombre := fs.String("nombre", "", "nombre del grupo")
+		rol := fs.String("rol", "", "rol de realm para el grupo (opcional)")
+		parse(fs, args, func() bool { return *nombre != "" })
+		var roles []string
+		if *rol != "" {
+			roles = append(roles, *rol)
+		}
+		id, created, err := a.EnsureGroup(ctx, *nombre, roles...)
+		check(err)
+		fmt.Printf("grupo %s (id %s) %s\n", *nombre, id, map[bool]string{true: "creado", false: "ya existía"}[created])
+
+	case "miembro":
+		grupo := fs.String("grupo", "", "nombre del grupo")
+		parse(fs, args, func() bool { return *usuario != "" && *grupo != "" })
+		check(a.AddToGroup(ctx, *usuario, *grupo))
+		fmt.Printf("%s ahora es miembro de %s\n", *usuario, *grupo)
+
+	case "sesiones":
+		parse(fs, args, func() bool { return *usuario != "" })
+		ss, err := a.Sessions(ctx, *usuario)
+		check(err)
+		tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+		fmt.Fprintln(tw, "INICIO\tÚLTIMO ACCESO\tIP\tCLIENTS")
+		for _, s := range ss {
+			fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Started.Format("15:04:05"), s.LastAccess.Format("15:04:05"), s.IP, strings.Join(s.Clients, ","))
+		}
+		tw.Flush()
+
+	case "expulsar":
+		parse(fs, args, func() bool { return *usuario != "" })
+		check(a.LogoutAll(ctx, *usuario))
+		fmt.Printf("sesiones de %s cerradas\n", *usuario)
+
+	case "baja":
+		parse(fs, args, func() bool { return *usuario != "" })
+		check(a.Disable(ctx, *usuario))
+		fmt.Printf("%s deshabilitado\n", *usuario)
+
+	default:
+		usage()
+	}
+}
+
+// parse lee los flags y comprueba los obligatorios. ok es una función porque
+// hay que evaluarla DESPUÉS de fs.Parse, cuando los flags ya tienen valor.
+func parse(fs *flag.FlagSet, args []string, ok func() bool) {
+	_ = fs.Parse(args)
+	if !ok() {
+		fs.Usage()
+		os.Exit(2)
+	}
+}
+
+func check(err error) {
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+func usage() {
+	log.Fatal("uso: admin usuarios | alta | rol | grupo | miembro | sesiones | expulsar | baja  (-h en cada uno)")
+}
+
+func env(key, def string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return def
+}
go.mod modificado · +11 −1
@@ -3,8 +3,18 @@
 go 1.26.0
 
 require (
+	github.com/Nerzal/gocloak/v14 v14.0.4
 	github.com/coreos/go-oidc/v3 v3.21.0
 	golang.org/x/oauth2 v0.37.0
 )
 
-require github.com/go-jose/go-jose/v4 v4.1.4 // indirect
+require (
+	github.com/go-jose/go-jose/v4 v4.1.4 // indirect
+	github.com/go-resty/resty/v2 v2.17.2 // indirect
+	github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+	github.com/opentracing/opentracing-go v1.2.0 // indirect
+	github.com/pkg/errors v0.9.1 // indirect
+	github.com/segmentio/ksuid v1.0.4 // indirect
+	golang.org/x/mod v0.35.0 // indirect
+	golang.org/x/net v0.55.0 // indirect
+)
infra/realm/tienda-realm.json modificado · +46 −0 · generado, plegado
@@ -111,6 +111,23 @@
           "facturar"
         ]
       }
+    },
+    {
+      "username": "service-account-admin-tool",
+      "enabled": true,
+      "serviceAccountClientId": "admin-tool",
+      "realmRoles": [
+        "default-roles-tienda"
+      ],
+      "clientRoles": {
+        "realm-management": [
+          "view-users",
+          "query-users",
+          "manage-users",
+          "query-groups",
+          "view-realm"
+        ]
+      }
     }
   ],
   "clients": [
@@ -261,6 +278,35 @@
         "basic",
         "email",
         "api-pedidos"
+      ],
+      "optionalClientScopes": [
+        "address",
+        "phone",
+        "organization",
+        "offline_access",
+        "microprofile-jwt"
+      ]
+    },
+    {
+      "clientId": "admin-tool",
+      "name": "Herramienta de administración",
+      "description": "CLI de administración con gocloak (módulo 5)",
+      "enabled": true,
+      "protocol": "openid-connect",
+      "publicClient": false,
+      "clientAuthenticatorType": "client-secret",
+      "secret": "admin-tool-secret",
+      "standardFlowEnabled": false,
+      "implicitFlowEnabled": false,
+      "directAccessGrantsEnabled": false,
+      "serviceAccountsEnabled": true,
+      "defaultClientScopes": [
+        "web-origins",
+        "acr",
+        "profile",
+        "roles",
+        "basic",
+        "email"
       ],
       "optionalClientScopes": [
         "address",
internal/admin/admin.go nuevo · +273 −0
@@ -0,0 +1,273 @@
+// Package admin gestiona usuarios, roles, grupos y sesiones del realm tienda
+// a través de la Admin REST API de Keycloak, con la librería gocloak.
+//
+// Se autentica como el client confidencial admin-tool (Client Credentials).
+// Lo que puede hacer lo deciden los roles del client «realm-management»
+// asignados a su service account (manage-users, view-users…).
+package admin
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"sort"
+	"time"
+
+	"github.com/Nerzal/gocloak/v14"
+)
+
+// 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
+}
+
+// Config indica dónde está Keycloak y con qué client entrar.
+type Config struct {
+	URL          string // http://localhost:8080 (sin /realms/…)
+	Realm        string // realm a administrar (y en el que vive el client)
+	ClientID     string
+	ClientSecret string
+}
+
+// ErrNotFound indica que el usuario o el grupo no existe.
+var ErrNotFound = errors.New("no existe")
+
+// New obtiene un token de la service account. Una CLI vive menos que el
+// token (5 min), así que basta con pedirlo una vez.
+func New(ctx context.Context, cfg Config) (*Admin, error) {
+	kc := gocloak.NewClient(cfg.URL)
+	jwt, err := kc.LoginClient(ctx, cfg.ClientID, cfg.ClientSecret, cfg.Realm)
+	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
+}
+
+// UserInfo resume un usuario para listarlo.
+type UserInfo struct {
+	ID, Username, Email, Name string
+	Enabled                   bool
+	Roles                     []string // roles de realm asignados directamente
+	Groups                    []string
+}
+
+// Users lista los usuarios del realm (sin las service accounts).
+func (a *Admin) Users(ctx context.Context) ([]UserInfo, error) {
+	users, err := a.kc.GetUsers(ctx, a.token, a.realm, gocloak.GetUsersParams{Max: gocloak.IntP(100)})
+	if err != nil {
+		return nil, explain(err)
+	}
+	var out []UserInfo
+	for _, u := range users {
+		if u.ServiceAccountClientID != nil {
+			continue
+		}
+		info := UserInfo{
+			ID:       gocloak.PString(u.ID),
+			Username: gocloak.PString(u.Username),
+			Email:    gocloak.PString(u.Email),
+			Name:     gocloak.PString(u.FirstName) + " " + gocloak.PString(u.LastName),
+			Enabled:  gocloak.PBool(u.Enabled),
+		}
+		roles, err := a.kc.GetRealmRolesByUserID(ctx, a.token, a.realm, info.ID)
+		if err != nil {
+			return nil, explain(err)
+		}
+		for _, r := range roles {
+			if name := gocloak.PString(r.Name); name != "default-roles-"+a.realm {
+				info.Roles = append(info.Roles, name)
+			}
+		}
+		groups, err := a.kc.GetUserGroups(ctx, a.token, a.realm, info.ID, gocloak.GetGroupsParams{})
+		if err != nil {
+			return nil, explain(err)
+		}
+		for _, g := range groups {
+			info.Groups = append(info.Groups, gocloak.PString(g.Name))
+		}
+		sort.Strings(info.Roles)
+		out = append(out, info)
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
+	return out, nil
+}
+
+// NewCustomer da de alta a un cliente: usuario habilitado, rol «cliente» y
+// una contraseña temporal que tendrá que cambiar en su primer login.
+func (a *Admin) NewCustomer(ctx context.Context, username, email, first, last, tempPassword string) (string, error) {
+	id, err := a.kc.CreateUser(ctx, a.token, a.realm, gocloak.User{
+		Username:      gocloak.StringP(username),
+		Email:         gocloak.StringP(email),
+		FirstName:     gocloak.StringP(first),
+		LastName:      gocloak.StringP(last),
+		Enabled:       gocloak.BoolP(true),
+		EmailVerified: gocloak.BoolP(true),
+	})
+	if err != nil {
+		return "", explain(err)
+	}
+	// temporary=true: Keycloak le pedirá una contraseña nueva al entrar.
+	if err := a.kc.SetPassword(ctx, a.token, id, a.realm, tempPassword, true); err != nil {
+		return id, explain(err)
+	}
+	return id, a.SetRole(ctx, username, "cliente", true)
+}
+
+// SetRole asigna (add=true) o quita un rol de realm a un usuario.
+func (a *Admin) SetRole(ctx context.Context, username, role string, add bool) error {
+	id, err := a.userID(ctx, username)
+	if err != nil {
+		return err
+	}
+	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}))
+}
+
+// EnsureGroup crea el grupo si no existe y le asigna los roles de realm.
+// Es idempotente: ejecutarlo dos veces deja el mismo resultado.
+func (a *Admin) EnsureGroup(ctx context.Context, name string, roles ...string) (id string, created bool, err error) {
+	id, err = a.groupID(ctx, name)
+	switch {
+	case errors.Is(err, ErrNotFound):
+		if id, err = a.kc.CreateGroup(ctx, a.token, a.realm, gocloak.Group{Name: gocloak.StringP(name)}); err != nil {
+			return "", false, explain(err)
+		}
+		created = true
+	case err != nil:
+		return "", false, err
+	}
+	var rs []gocloak.Role
+	for _, role := range roles {
+		r, err := a.kc.GetRealmRole(ctx, a.token, a.realm, role)
+		if err != nil {
+			return id, created, explain(err)
+		}
+		rs = append(rs, *r)
+	}
+	if len(rs) > 0 {
+		err = explain(a.kc.AddRealmRoleToGroup(ctx, a.token, a.realm, id, rs))
+	}
+	return id, created, err
+}
+
+// AddToGroup mete a un usuario en un grupo: hereda sus roles.
+func (a *Admin) AddToGroup(ctx context.Context, username, group string) error {
+	uid, err := a.userID(ctx, username)
+	if err != nil {
+		return err
+	}
+	gid, err := a.groupID(ctx, group)
+	if err != nil {
+		return err
+	}
+	return explain(a.kc.AddUserToGroup(ctx, a.token, a.realm, uid, gid))
+}
+
+// Session es una sesión SSO de un usuario.
+type Session struct {
+	Started, LastAccess time.Time
+	IP                  string
+	Clients             []string
+}
+
+// Sessions lista las sesiones SSO activas de un usuario.
+func (a *Admin) Sessions(ctx context.Context, username string) ([]Session, error) {
+	id, err := a.userID(ctx, username)
+	if err != nil {
+		return nil, err
+	}
+	ss, err := a.kc.GetUserSessions(ctx, a.token, a.realm, id)
+	if err != nil {
+		return nil, explain(err)
+	}
+	var out []Session
+	for _, s := range ss {
+		sess := Session{
+			Started:    time.UnixMilli(gocloak.PInt64(s.Start)),
+			LastAccess: time.UnixMilli(gocloak.PInt64(s.LastAccess)),
+			IP:         gocloak.PString(s.IPAddress),
+		}
+		for _, c := range s.Clients {
+			sess.Clients = append(sess.Clients, c)
+		}
+		sort.Strings(sess.Clients)
+		out = append(out, sess)
+	}
+	return out, nil
+}
+
+// LogoutAll cierra todas las sesiones de un usuario (y avisa por back-channel
+// a los clients que lo tengan configurado).
+func (a *Admin) LogoutAll(ctx context.Context, username string) error {
+	id, err := a.userID(ctx, username)
+	if err != nil {
+		return err
+	}
+	return explain(a.kc.LogoutAllSessions(ctx, a.token, a.realm, id))
+}
+
+// Disable deshabilita a un usuario: no podrá iniciar sesión ni renovar tokens.
+// Es preferible a borrarlo: se conserva su historial (y su «sub»).
+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)}))
+}
+
+// userID busca el ID de un usuario por su nombre exacto.
+func (a *Admin) userID(ctx context.Context, username string) (string, 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)
+	}
+	if len(users) == 0 {
+		return "", fmt.Errorf("usuario %q: %w", username, ErrNotFound)
+	}
+	return gocloak.PString(users[0].ID), nil
+}
+
+// groupID busca el ID de un grupo de primer nivel por su nombre exacto.
+func (a *Admin) groupID(ctx context.Context, name string) (string, error) {
+	groups, err := a.kc.GetGroups(ctx, a.token, a.realm, gocloak.GetGroupsParams{
+		Search: gocloak.StringP(name),
+		Exact:  gocloak.BoolP(true),
+	})
+	if err != nil {
+		return "", explain(err)
+	}
+	for _, g := range groups {
+		if gocloak.PString(g.Name) == name {
+			return gocloak.PString(g.ID), nil
+		}
+	}
+	return "", fmt.Errorf("grupo %q: %w", name, ErrNotFound)
+}
+
+// explain añade una pista a los errores típicos de la Admin API.
+func explain(err error) error {
+	var apiErr *gocloak.APIError
+	if !errors.As(err, &apiErr) {
+		return err
+	}
+	switch apiErr.Code {
+	case http.StatusForbidden:
+		return fmt.Errorf("%w (¿le falta un rol de realm-management a la service account de admin-tool?)", err)
+	case http.StatusConflict:
+		return fmt.Errorf("%w (ya existe)", err)
+	}
+	return err
+}

← Volver a la lección 09