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.
| Archivo | Estado | Líneas |
|---|---|---|
cmd/admin/main.go | nuevo | +149 −0 |
go.mod | modificado | +11 −1 |
infra/realm/tienda-realm.json | modificado | +46 −0 |
internal/admin/admin.go | nuevo | +273 −0 |
cmd/admin/main.go
@@ -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
@@ -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
@@ -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
@@ -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
+}