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.
| Archivo | Estado | Líneas |
|---|---|---|
cmd/facturacion/main.go | nuevo | +81 −0 |
infra/realm/tienda-realm.json | modificado | +52 −1 |
internal/api/api.go | modificado | +13 −0 |
internal/apiauth/apiauth.go | modificado | +12 −6 |
internal/apiauth/authz.go | modificado | +16 −0 |
internal/facturacion/facturas.go | nuevo | +67 −0 |
internal/facturacion/worker.go | nuevo | +83 −0 |
internal/pedidos/pedidos.go | modificado | +5 −0 |
cmd/facturacion/main.go
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.