Qué cambió — paso 07
Qué cambió · paso-06 → paso-07

Cambios de la lección 07

Todo lo que cambia en tienda/pasos/paso-07 respecto al paso anterior. Vuelve a la lección: 07. Client Credentials.

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

ArchivoEstadoLíneas
cmd/facturacion/main.gonuevo+81 −0
infra/realm/tienda-realm.jsonmodificado+52 −1
internal/api/api.gomodificado+13 −0
internal/apiauth/apiauth.gomodificado+12 −6
internal/apiauth/authz.gomodificado+16 −0
internal/facturacion/facturas.gonuevo+67 −0
internal/facturacion/worker.gonuevo+83 −0
internal/pedidos/pedidos.gomodificado+5 −0
cmd/facturacion/main.go nuevo · +81 −0
@@ -0,0 +1,81 @@
+// Command facturacion es el servicio de facturación: un proceso interno que,
+// sin ningún usuario delante, factura los pedidos entregados. Se autentica
+// ante Keycloak como client confidencial (Client Credentials).
+//
+// Uso (desde tienda/pasos/paso-07):
+//
+//	go run ./cmd/facturacion
+package main
+
+import (
+	"context"
+	"log"
+	"os"
+	"os/signal"
+	"time"
+
+	"github.com/coreos/go-oidc/v3/oidc"
+	"golang.org/x/oauth2"
+	"golang.org/x/oauth2/clientcredentials"
+
+	"tienda/internal/facturacion"
+)
+
+func main() {
+	issuer := env("OIDC_ISSUER", "http://localhost:8080/realms/tienda")
+	apiURL := env("API_URL", "http://localhost:8081")
+	interval, err := time.ParseDuration(env("INTERVALO", "30s"))
+	if err != nil {
+		log.Fatalf("INTERVALO: %v", err)
+	}
+
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
+	defer stop()
+
+	provider, err := oidc.NewProvider(ctx, issuer)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	// Client Credentials: el servicio se identifica con su client ID y su
+	// secreto. No hay usuario, ni navegador, ni refresh token.
+	cfg := clientcredentials.Config{
+		ClientID:     env("OIDC_CLIENT_ID", "facturacion"),
+		ClientSecret: env("OIDC_CLIENT_SECRET", "facturacion-secret"), // solo para desarrollo
+		TokenURL:     provider.Endpoint().TokenURL,
+	}
+
+	// ReuseTokenSource guarda el token y solo pide otro cuando caduca.
+	// loggingSource nos deja ver en el log cuándo ocurre eso.
+	ts := oauth2.ReuseTokenSource(nil, loggingSource{cfg.TokenSource(ctx)})
+	client := oauth2.NewClient(ctx, ts) // añade «Authorization: Bearer» a cada petición
+	client.Timeout = 5 * time.Second
+
+	w := &facturacion.Worker{
+		API:      apiURL,
+		HTTP:     client,
+		Store:    facturacion.NewStore(),
+		Interval: interval,
+	}
+	log.Printf("facturacion: facturando cada %s contra %s", interval, apiURL)
+	w.Run(ctx)
+}
+
+// loggingSource registra cada vez que hace falta un token nuevo de Keycloak.
+type loggingSource struct{ src oauth2.TokenSource }
+
+func (l loggingSource) Token() (*oauth2.Token, error) {
+	tok, err := l.src.Token()
+	if err != nil {
+		return nil, err
+	}
+	log.Printf("token de servicio nuevo (caduca a las %s)", tok.Expiry.Format("15:04:05"))
+	return tok, nil
+}
+
+func env(key, def string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return def
+}
infra/realm/tienda-realm.json modificado · +52 −1 · generado, plegado
@@ -42,7 +42,15 @@
           }
         }
       }
-    ]
+    ],
+    "client": {
+      "api-pedidos": [
+        {
+          "name": "facturar",
+          "description": "Leer los pedidos de todos los clientes para facturarlos (para servicios)"
+        }
+      ]
+    }
   },
   "defaultRole": {
     "name": "default-roles-tienda",
@@ -90,6 +98,19 @@
         "cliente",
         "admin"
       ]
+    },
+    {
+      "username": "service-account-facturacion",
+      "enabled": true,
+      "serviceAccountClientId": "facturacion",
+      "realmRoles": [
+        "default-roles-tienda"
+      ],
+      "clientRoles": {
+        "api-pedidos": [
+          "facturar"
+        ]
+      }
     }
   ],
   "clients": [
@@ -213,6 +234,36 @@
         "offline_access",
         "microprofile-jwt"
       ]
+    },
+    {
+      "clientId": "facturacion",
+      "name": "Servicio de facturación",
+      "description": "Servicio interno: Client Credentials (lección 7)",
+      "enabled": true,
+      "protocol": "openid-connect",
+      "publicClient": false,
+      "clientAuthenticatorType": "client-secret",
+      "secret": "facturacion-secret",
+      "standardFlowEnabled": false,
+      "implicitFlowEnabled": false,
+      "directAccessGrantsEnabled": false,
+      "serviceAccountsEnabled": true,
+      "defaultClientScopes": [
+        "web-origins",
+        "acr",
+        "profile",
+        "roles",
+        "basic",
+        "email",
+        "api-pedidos"
+      ],
+      "optionalClientScopes": [
+        "address",
+        "phone",
+        "organization",
+        "offline_access",
+        "microprofile-jwt"
+      ]
     }
   ],
   "clientScopes": [
internal/api/api.go modificado · +13 −0
@@ -29,6 +29,9 @@
 	mux.Handle("POST /pedidos", protect(v, h.create, cliente, escribir))
 	mux.Handle("GET /admin/pedidos", protect(v, h.listAll, admin))
 	mux.Handle("PATCH /admin/pedidos/{id}", protect(v, h.setStatus, admin, escribir))
+
+	// Para servicios (Client Credentials): rol de client de api-pedidos.
+	mux.Handle("GET /facturacion/pedidos", protect(v, h.listByStatus, apiauth.RequireAPIRole("facturar")))
 }
 
 // protect encadena: token válido (v.Middleware) → cada comprobación → handler.
@@ -87,6 +90,16 @@
 	jsonhttp.Write(w, http.StatusOK, map[string]any{"pedidos": h.store.All()})
 }
 
+// listByStatus devuelve los pedidos de todos los clientes en un estado
+// (?status=Entregado por defecto). Lo usa el servicio de facturación.
+func (h *handlers) listByStatus(w http.ResponseWriter, r *http.Request) {
+	status := r.URL.Query().Get("status")
+	if status == "" {
+		status = "Entregado"
+	}
+	jsonhttp.Write(w, http.StatusOK, map[string]any{"pedidos": h.store.ByStatus(status)})
+}
+
 // setStatus cambia el estado de un pedido (solo admin con pedidos:escribir).
 func (h *handlers) setStatus(w http.ResponseWriter, r *http.Request) {
 	id, ok := pathID(w, r)
internal/apiauth/apiauth.go modificado · +12 −6
@@ -23,11 +23,13 @@
 	ClientID string   // azp: la aplicación que pidió el token
 	Scopes   []string // scope, separado por espacios
 	Roles    []string // realm_access.roles
+	APIRoles []string // resource_access.<audiencia>.roles: roles de client de esta API
 }
 
 // Verifier valida access tokens emitidos por un realm para una audiencia.
 type Verifier struct {
 	verifier *oidc.IDTokenVerifier
+	audience string
 }
 
 // NewVerifier lee el descubrimiento del issuer y prepara la validación.
@@ -40,7 +42,7 @@
 	// go-oidc se diseñó para ID tokens, pero las comprobaciones son las mismas
 	// que necesita un access token JWT: firma (JWKS, con caché y rotación de
 	// claves), iss exacto, aud contiene ClientID y exp.
-	return &Verifier{verifier: provider.Verifier(&oidc.Config{ClientID: audience})}, nil
+	return &Verifier{verifier: provider.Verifier(&oidc.Config{ClientID: audience}), audience: audience}, nil
 }
 
 // Verify comprueba un access token y devuelve a quién representa.
@@ -57,6 +59,9 @@
 		RealmAccess struct {
 			Roles []string `json:"roles"`
 		} `json:"realm_access"`
+		ResourceAccess map[string]struct {
+			Roles []string `json:"roles"`
+		} `json:"resource_access"`
 	}
 	if err := tok.Claims(&c); err != nil {
 		return nil, err
@@ -72,6 +77,7 @@
 		ClientID: c.AZP,
 		Scopes:   strings.Fields(c.Scope),
 		Roles:    c.RealmAccess.Roles,
+		APIRoles: c.ResourceAccess[v.audience].Roles,
 	}, nil
 }
 
@@ -83,7 +89,7 @@
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		raw, ok := bearerToken(r)
 		if !ok {
-			unauthorized(w, "", "falta la cabecera Authorization: Bearer <access token>")
+			v.unauthorized(w, "", "falta la cabecera Authorization: Bearer <access token>")
 			return
 		}
 		p, err := v.Verify(r.Context(), raw)
@@ -91,10 +97,10 @@
 			log.Printf("token rechazado: %v", err)
 			var expired *oidc.TokenExpiredError
 			if errors.As(err, &expired) {
-				unauthorized(w, "invalid_token", "el access token ha caducado")
+				v.unauthorized(w, "invalid_token", "el access token ha caducado")
 				return
 			}
-			unauthorized(w, "invalid_token", "access token inválido")
+			v.unauthorized(w, "invalid_token", "access token inválido")
 			return
 		}
 		ctx := context.WithValue(r.Context(), principalKey{}, p)
@@ -119,8 +125,8 @@
 
 // unauthorized responde 401 con la cabecera WWW-Authenticate de RFC 6750.
 // Sin token no se indica código de error; con un token malo, invalid_token.
-func unauthorized(w http.ResponseWriter, code, description string) {
-	h := `Bearer realm="api-pedidos"`
+func (v *Verifier) unauthorized(w http.ResponseWriter, code, description string) {
+	h := fmt.Sprintf(`Bearer realm=%q`, v.audience)
 	if code != "" {
 		h += fmt.Sprintf(`, error=%q, error_description=%q`, code, description)
 	} else {
internal/apiauth/authz.go modificado · +16 −0
@@ -30,6 +30,22 @@
 	}
 }
 
+// RequireAPIRole deja pasar solo si el token trae el rol de client de esta API
+// (resource_access.<audiencia>.roles). Es lo habitual para servicios: sus
+// permisos se asignan a su service account como roles de client de la API.
+func RequireAPIRole(role string) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			p := FromContext(r.Context())
+			if p == nil || !slices.Contains(p.APIRoles, role) {
+				jsonhttp.Error(w, http.StatusForbidden, "forbidden", "hace falta el rol de API "+role)
+				return
+			}
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
 // RequireScope deja pasar solo si el token trae el scope. Si no, responde
 // 403 insufficient_scope (RFC 6750) indicando qué scope pedir.
 func RequireScope(scope string) func(http.Handler) http.Handler {
internal/facturacion/facturas.go nuevo · +67 −0
@@ -0,0 +1,67 @@
+// Package facturacion es el dominio del servicio de facturación: las
+// facturas y el proceso que factura automáticamente los pedidos entregados.
+package facturacion
+
+import (
+	"fmt"
+	"sort"
+	"sync"
+	"time"
+)
+
+// Invoice es una factura de un pedido.
+type Invoice struct {
+	Number   string    `json:"numero"`  // F-0001, F-0002…
+	OrderID  int       `json:"pedido"`  // un pedido tiene como mucho una factura
+	Customer string    `json:"cliente"` // sub del cliente
+	Total    float64   `json:"total"`
+	Origin   string    `json:"origen"` // "automática" o "solicitada"
+	IssuedAt time.Time `json:"emitida"`
+}
+
+// Store guarda las facturas en memoria, seguro para uso concurrente.
+type Store struct {
+	mu      sync.Mutex
+	byOrder map[int]Invoice
+	next    int
+}
+
+// NewStore crea un almacén vacío.
+func NewStore() *Store {
+	return &Store{byOrder: make(map[int]Invoice), next: 1}
+}
+
+// Issue emite la factura de un pedido. Si el pedido ya tenía factura, la
+// devuelve con created=false: facturar dos veces lo mismo no crea otra.
+func (s *Store) Issue(orderID int, customer string, total float64, origin string) (inv Invoice, created bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if inv, ok := s.byOrder[orderID]; ok {
+		return inv, false
+	}
+	inv = Invoice{
+		Number:   fmt.Sprintf("F-%04d", s.next),
+		OrderID:  orderID,
+		Customer: customer,
+		Total:    total,
+		Origin:   origin,
+		IssuedAt: time.Now().UTC(),
+	}
+	s.byOrder[orderID] = inv
+	s.next++
+	return inv, true
+}
+
+// ByCustomer devuelve las facturas de un cliente, ordenadas por número.
+func (s *Store) ByCustomer(sub string) []Invoice {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	out := []Invoice{}
+	for _, inv := range s.byOrder {
+		if inv.Customer == sub {
+			out = append(out, inv)
+		}
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].Number < out[j].Number })
+	return out
+}
internal/facturacion/worker.go nuevo · +83 −0
@@ -0,0 +1,83 @@
+package facturacion
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log"
+	"net/http"
+	"time"
+)
+
+// Order es lo que el servicio necesita saber de un pedido de api-pedidos.
+type Order struct {
+	ID     int     `json:"id"`
+	Owner  string  `json:"owner"`
+	Total  float64 `json:"total"`
+	Status string  `json:"status"`
+}
+
+// Worker factura automáticamente los pedidos entregados. No actúa en nombre
+// de ningún usuario: llama a api-pedidos con el token de su propia service
+// account, que va incluido en HTTP (ver cmd/facturacion).
+type Worker struct {
+	API      string       // URL base de api-pedidos
+	HTTP     *http.Client // añade «Authorization: Bearer» con el token del servicio
+	Store    *Store
+	Interval time.Duration
+}
+
+// Run factura una vez al arrancar y después cada Interval, hasta que ctx termine.
+func (w *Worker) Run(ctx context.Context) {
+	t := time.NewTicker(w.Interval)
+	defer t.Stop()
+	for {
+		w.runOnce(ctx)
+		select {
+		case <-ctx.Done():
+			return
+		case <-t.C:
+		}
+	}
+}
+
+func (w *Worker) runOnce(ctx context.Context) {
+	orders, err := w.deliveredOrders(ctx)
+	if err != nil {
+		log.Printf("facturación automática: %v", err)
+		return
+	}
+	nuevas := 0
+	for _, o := range orders {
+		if inv, created := w.Store.Issue(o.ID, o.Owner, o.Total, "automática"); created {
+			nuevas++
+			log.Printf("  %s → pedido #%d por $ %.2f", inv.Number, inv.OrderID, inv.Total)
+		}
+	}
+	log.Printf("facturación automática: %d pedidos entregados, %d facturas nuevas", len(orders), nuevas)
+}
+
+// deliveredOrders pide a api-pedidos los pedidos entregados de todos los clientes.
+func (w *Worker) deliveredOrders(ctx context.Context) ([]Order, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, w.API+"/facturacion/pedidos?status=Entregado", nil)
+	if err != nil {
+		return nil, err
+	}
+	resp, err := w.HTTP.Do(req)
+	if err != nil {
+		return nil, err // incluye los fallos al pedir el token a Keycloak
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		var e struct {
+			Description string `json:"error_description"`
+		}
+		_ = json.NewDecoder(resp.Body).Decode(&e)
+		return nil, fmt.Errorf("api-pedidos respondió %s: %s", resp.Status, e.Description)
+	}
+	var out struct {
+		Pedidos []Order `json:"pedidos"`
+	}
+	err = json.NewDecoder(resp.Body).Decode(&out)
+	return out.Pedidos, err
+}
internal/pedidos/pedidos.go modificado · +5 −0
@@ -58,6 +58,11 @@
 // ByOwner devuelve los pedidos de un usuario, ordenados por número.
 func (s *Store) ByOwner(sub string) []Order {
 	return s.filter(func(o Order) bool { return o.Owner == sub })
+}
+
+// ByStatus devuelve los pedidos en un estado, ordenados por número.
+func (s *Store) ByStatus(status string) []Order {
+	return s.filter(func(o Order) bool { return o.Status == status })
 }
 
 // All devuelve todos los pedidos, ordenados por número.

← Volver a la lección 07