Qué cambió — paso 15
Qué cambió · paso-14 → paso-15

Cambios de la lección 15

Todo lo que cambia en tienda/pasos/paso-15 respecto al paso anterior. Vuelve a la lección: 15. Segundo factor solo para lo delicado.

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

ArchivoEstadoLíneas
cmd/web/main.gomodificado+2 −0
infra/realm/tienda-realm.jsonmodificado+129 −0
internal/adaptadores/keycloak/keycloak.gomodificado+28 −1
internal/adaptadores/keycloak/keycloak_test.gomodificado+22 −0
internal/adaptadores/rest/rest.gomodificado+11 −7
internal/adaptadores/rest/rest_test.gomodificado+12 −1
internal/apiauth/apiauth.gomodificado+14 −7
internal/auth/auth.gomodificado+29 −0
internal/pedidos/actor.gomodificado+3 −0
internal/pedidos/pedido.gomodificado+3 −0
internal/pedidos/servicio.gomodificado+15 −3
internal/pedidos/servicio_test.gomodificado+17 −1
internal/pedidosclient/client.gomodificado+7 −0
internal/web/compras.gomodificado+13 −2
cmd/web/main.go modificado · +2 −0
@@ -32,6 +32,8 @@
 
 		// Proveedores de identidad a los que se puede entrar directamente (lección 12).
 		IdentityProviders: strings.Fields(env("OIDC_IDP_HINTS", "corporativo")),
+		// Niveles de autenticación que se pueden pedir (lección 15).
+		ACRValues: strings.Fields(env("OIDC_ACR_VALUES", "reforzado")),
 	}
 	addr := env("ADDR", ":3000")
 	apiURL := env("API_URL", "http://localhost:8081")              // api-pedidos
infra/realm/tienda-realm.json modificado · +129 −0 · generado, plegado
@@ -91,6 +91,12 @@
           "type": "password",
           "value": "carlos123",
           "temporary": false
+        },
+        {
+          "type": "otp",
+          "userLabel": "móvil de carlos",
+          "secretData": "{\"value\": \"carlos-otp-curso-2026\"}",
+          "credentialData": "{\"subType\": \"totp\", \"digits\": 6, \"counter\": 0, \"period\": 30, \"algorithm\": \"HmacSHA1\"}"
         }
       ],
       "realmRoles": [
@@ -646,6 +652,129 @@
       }
     }
   ],
+  "attributes": {
+    "acr.loa.map": "{\"basico\": 1, \"reforzado\": 2}"
+  },
+  "authenticationFlows": [
+    {
+      "alias": "navegador con niveles",
+      "description": "Login del navegador con dos niveles: contraseña (basico) y contraseña + código (reforzado)",
+      "providerId": "basic-flow",
+      "topLevel": true,
+      "builtIn": false,
+      "authenticationExecutions": [
+        {
+          "requirement": "ALTERNATIVE",
+          "priority": 10,
+          "autheticatorFlow": false,
+          "userSetupAllowed": false,
+          "authenticator": "auth-cookie"
+        },
+        {
+          "requirement": "ALTERNATIVE",
+          "priority": 20,
+          "autheticatorFlow": false,
+          "userSetupAllowed": false,
+          "authenticator": "identity-provider-redirector"
+        },
+        {
+          "requirement": "ALTERNATIVE",
+          "priority": 30,
+          "autheticatorFlow": true,
+          "userSetupAllowed": false,
+          "flowAlias": "formularios por nivel"
+        }
+      ]
+    },
+    {
+      "alias": "formularios por nivel",
+      "description": "Un subflujo por nivel, del más bajo al más alto",
+      "providerId": "basic-flow",
+      "topLevel": false,
+      "builtIn": false,
+      "authenticationExecutions": [
+        {
+          "requirement": "CONDITIONAL",
+          "priority": 10,
+          "autheticatorFlow": true,
+          "userSetupAllowed": false,
+          "flowAlias": "nivel 1 - contraseña"
+        },
+        {
+          "requirement": "CONDITIONAL",
+          "priority": 20,
+          "autheticatorFlow": true,
+          "userSetupAllowed": false,
+          "flowAlias": "nivel 2 - código"
+        }
+      ]
+    },
+    {
+      "alias": "nivel 1 - contraseña",
+      "description": "basico (LoA 1): usuario y contraseña",
+      "providerId": "basic-flow",
+      "topLevel": false,
+      "builtIn": false,
+      "authenticationExecutions": [
+        {
+          "requirement": "REQUIRED",
+          "priority": 10,
+          "autheticatorFlow": false,
+          "userSetupAllowed": false,
+          "authenticator": "conditional-level-of-authentication",
+          "authenticatorConfig": "nivel 1"
+        },
+        {
+          "requirement": "REQUIRED",
+          "priority": 20,
+          "autheticatorFlow": false,
+          "userSetupAllowed": false,
+          "authenticator": "auth-username-password-form"
+        }
+      ]
+    },
+    {
+      "alias": "nivel 2 - código",
+      "description": "reforzado (LoA 2): además, un código de un solo uso (TOTP)",
+      "providerId": "basic-flow",
+      "topLevel": false,
+      "builtIn": false,
+      "authenticationExecutions": [
+        {
+          "requirement": "REQUIRED",
+          "priority": 10,
+          "autheticatorFlow": false,
+          "userSetupAllowed": false,
+          "authenticator": "conditional-level-of-authentication",
+          "authenticatorConfig": "nivel 2"
+        },
+        {
+          "requirement": "REQUIRED",
+          "priority": 20,
+          "autheticatorFlow": false,
+          "userSetupAllowed": false,
+          "authenticator": "auth-otp-form"
+        }
+      ]
+    }
+  ],
+  "authenticatorConfig": [
+    {
+      "alias": "nivel 1",
+      "config": {
+        "loa-condition-level": "1",
+        "loa-max-age": "36000"
+      }
+    },
+    {
+      "alias": "nivel 2",
+      "config": {
+        "loa-condition-level": "2",
+        "loa-max-age": "300"
+      }
+    }
+  ],
+  "browserFlow": "navegador con niveles",
   "clientScopes": [
     {
       "name": "api-pedidos",
internal/adaptadores/keycloak/keycloak.go modificado · +28 −1
@@ -8,11 +8,22 @@
 package keycloak
 
 import (
+	"fmt"
 	"net/http"
+	"time"
 
 	"tienda/internal/apiauth"
 	"tienda/internal/pedidos"
 )
+
+// StrongACR es el nivel de autenticación (claim acr) que el realm emite tras
+// contraseña + código (acr.loa.map: "reforzado" = nivel 2, lección 15).
+const StrongACR = "reforzado"
+
+// StrongMaxAge es cuánto vale el segundo factor: igual que el Max Age del
+// nivel 2 en el realm. Hace falta comprobarlo aquí porque los tokens
+// renovados (refresh) conservan acr="reforzado" toda la sesión.
+const StrongMaxAge = 5 * time.Minute
 
 // Identity implementa rest.Identity con access tokens de Keycloak.
 type Identity struct {
@@ -27,6 +38,17 @@
 	return apiauth.RequireScope(scope)
 }
 
+// StepUp responde 401 con el reto de RFC 9470: el token es válido, pero la
+// operación necesita un login más fuerte; acr_values dice cuál pedir.
+func (Identity) StepUp(w http.ResponseWriter) {
+	w.Header().Set("WWW-Authenticate", fmt.Sprintf(
+		`Bearer realm="api-pedidos", error="insufficient_user_authentication", `+
+			`error_description="hace falta un segundo factor", acr_values=%q`, StrongACR))
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusUnauthorized)
+	fmt.Fprintf(w, `{"error":"insufficient_user_authentication","error_description":"hace falta un segundo factor","acr_values":%q}`+"\n", StrongACR)
+}
+
 // Actor devuelve quién llama, ya en términos del dominio.
 func (Identity) Actor(r *http.Request) pedidos.Actor {
 	p := apiauth.FromContext(r.Context())
@@ -38,7 +60,12 @@
 
 // ActorFrom traduce un token verificado a un Actor del dominio.
 func ActorFrom(p *apiauth.Principal) pedidos.Actor {
-	a := pedidos.Actor{ID: p.Subject, Name: p.Username}
+	a := pedidos.Actor{
+		ID:   p.Subject,
+		Name: p.Username,
+		// Reforzado = login con segundo factor y además reciente.
+		StrongAuth: p.ACR == StrongACR && time.Since(p.AuthTime) <= StrongMaxAge,
+	}
 	if p.HasRole("cliente") || p.HasRole("admin") {
 		a.Permissions = append(a.Permissions, pedidos.PermBuy)
 	}
internal/adaptadores/keycloak/keycloak_test.go modificado · +22 −0
@@ -3,6 +3,7 @@
 import (
 	"slices"
 	"testing"
+	"time"
 
 	"tienda/internal/adaptadores/keycloak"
 	"tienda/internal/apiauth"
@@ -31,3 +32,24 @@
 		})
 	}
 }
+
+func TestStrongAuth(t *testing.T) {
+	ahora := time.Now()
+	tests := []struct {
+		name     string
+		acr      string
+		hace     time.Duration
+		reforzad bool
+	}{
+		{"solo contraseña", "basico", time.Minute, false},
+		{"código hace 1 minuto", "reforzado", time.Minute, true},
+		{"código hace 6 minutos (token renovado)", "reforzado", 6 * time.Minute, false},
+		{"sin acr", "", time.Minute, false},
+	}
+	for _, tt := range tests {
+		p := &apiauth.Principal{ACR: tt.acr, AuthTime: ahora.Add(-tt.hace)}
+		if got := keycloak.ActorFrom(p).StrongAuth; got != tt.reforzad {
+			t.Errorf("%s: StrongAuth = %v, want %v", tt.name, got, tt.reforzad)
+		}
+	}
+}
internal/adaptadores/rest/rest.go modificado · +11 −7
@@ -23,6 +23,8 @@
 	RequireScope(scope string) func(http.Handler) http.Handler
 	// Actor devuelve quién llama, en términos del dominio.
 	Actor(r *http.Request) pedidos.Actor
+	// StepUp pide al cliente un login más fuerte (lección 15).
+	StepUp(w http.ResponseWriter)
 }
 
 type handlers struct {
@@ -72,7 +74,7 @@
 func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
 	os, err := h.svc.Mine(r.Context(), h.id.Actor(r))
 	if err != nil {
-		writeError(w, err)
+		h.writeError(w, err)
 		return
 	}
 	jsonhttp.Write(w, http.StatusOK, listJSON(os))
@@ -85,7 +87,7 @@
 	}
 	o, err := h.svc.Get(r.Context(), h.id.Actor(r), id)
 	if err != nil {
-		writeError(w, err)
+		h.writeError(w, err)
 		return
 	}
 	jsonhttp.Write(w, http.StatusOK, toJSON(o))
@@ -102,7 +104,7 @@
 	}
 	o, err := h.svc.Create(r.Context(), h.id.Actor(r), body.Producto, body.Cantidad)
 	if err != nil {
-		writeError(w, err)
+		h.writeError(w, err)
 		return
 	}
 	jsonhttp.Write(w, http.StatusCreated, toJSON(o))
@@ -111,7 +113,7 @@
 func (h *handlers) listAll(w http.ResponseWriter, r *http.Request) {
 	os, err := h.svc.All(r.Context(), h.id.Actor(r))
 	if err != nil {
-		writeError(w, err)
+		h.writeError(w, err)
 		return
 	}
 	jsonhttp.Write(w, http.StatusOK, listJSON(os))
@@ -125,7 +127,7 @@
 	}
 	os, err := h.svc.ToInvoice(r.Context(), h.id.Actor(r), status)
 	if err != nil {
-		writeError(w, err)
+		h.writeError(w, err)
 		return
 	}
 	jsonhttp.Write(w, http.StatusOK, listJSON(os))
@@ -145,15 +147,17 @@
 	}
 	o, err := h.svc.SetStatus(r.Context(), h.id.Actor(r), id, body.Status)
 	if err != nil {
-		writeError(w, err)
+		h.writeError(w, err)
 		return
 	}
 	jsonhttp.Write(w, http.StatusOK, toJSON(o))
 }
 
 // writeError traduce los errores del dominio a respuestas HTTP.
-func writeError(w http.ResponseWriter, err error) {
+func (h *handlers) writeError(w http.ResponseWriter, err error) {
 	switch {
+	case errors.Is(err, pedidos.ErrStepUp):
+		h.id.StepUp(w) // 401 con el reto: cómo se pide lo sabe el adaptador de identidad
 	case errors.Is(err, pedidos.ErrNotFound):
 		jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
 	case errors.Is(err, pedidos.ErrForbidden):
internal/adaptadores/rest/rest_test.go modificado · +12 −1
@@ -44,7 +44,16 @@
 
 func (f fakeIdentity) Actor(*http.Request) pedidos.Actor { return *f.actor }
 
-var ana = &pedidos.Actor{ID: memoria.AnaID, Permissions: []pedidos.Permission{pedidos.PermBuy}}
+func (fakeIdentity) StepUp(w http.ResponseWriter) {
+	w.Header().Set("WWW-Authenticate", `Bearer error="insufficient_user_authentication"`)
+	w.WriteHeader(http.StatusUnauthorized)
+}
+
+var (
+	ana             = &pedidos.Actor{ID: memoria.AnaID, Permissions: []pedidos.Permission{pedidos.PermBuy}}
+	carlos          = &pedidos.Actor{ID: memoria.CarlosID, Permissions: []pedidos.Permission{pedidos.PermBuy, pedidos.PermManage}}
+	carlosReforzado = &pedidos.Actor{ID: memoria.CarlosID, Permissions: carlos.Permissions, StrongAuth: true}
+)
 
 // do monta la API con esa identidad y hace una petición.
 func do(id fakeIdentity, method, path, body string) *httptest.ResponseRecorder {
@@ -71,6 +80,8 @@
 		{"crear con scope", fakeIdentity{actor: ana, scopes: []string{"pedidos:escribir"}}, "POST", "/pedidos", `{"producto":"taza","cantidad":1}`, 201},
 		{"crear con datos inválidos", fakeIdentity{actor: ana, scopes: []string{"pedidos:escribir"}}, "POST", "/pedidos", `{"producto":"yate","cantidad":1}`, 400},
 		{"admin sin permiso de dominio", fakeIdentity{actor: ana}, "GET", "/admin/pedidos", "", 403},
+		{"gestionar sin segundo factor", fakeIdentity{actor: carlos}, "GET", "/admin/pedidos", "", 401},
+		{"gestionar con segundo factor", fakeIdentity{actor: carlosReforzado}, "GET", "/admin/pedidos", "", 200},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
internal/apiauth/apiauth.go modificado · +14 −7
@@ -10,6 +10,7 @@
 	"log"
 	"net/http"
 	"strings"
+	"time"
 
 	"github.com/coreos/go-oidc/v3/oidc"
 
@@ -18,13 +19,15 @@
 
 // Principal es quien hace la petición, según el access token verificado.
 type Principal struct {
-	Subject  string   // sub: el usuario (o la service account de un client)
-	Username string   // preferred_username
-	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
-	Token    string   // el access token en bruto, por si hay que intercambiarlo (lección 8)
+	Subject  string    // sub: el usuario (o la service account de un client)
+	Username string    // preferred_username
+	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
+	ACR      string    // acr: cómo de fuerte fue el login (lección 15: "basico" o "reforzado")
+	AuthTime time.Time // auth_time: cuándo se autenticó por última vez (también el step-up)
+	Token    string    // el access token en bruto, por si hay que intercambiarlo (lección 8)
 }
 
 // Verifier valida access tokens emitidos por un realm para una audiencia.
@@ -54,6 +57,8 @@
 	}
 	var c struct {
 		Typ         string `json:"typ"`
+		ACR         string `json:"acr"`
+		AuthTime    int64  `json:"auth_time"`
 		Username    string `json:"preferred_username"`
 		AZP         string `json:"azp"`
 		Scope       string `json:"scope"`
@@ -79,6 +84,8 @@
 		Scopes:   strings.Fields(c.Scope),
 		Roles:    c.RealmAccess.Roles,
 		APIRoles: c.ResourceAccess[v.audience].Roles,
+		ACR:      c.ACR,
+		AuthTime: time.Unix(c.AuthTime, 0),
 		Token:    raw,
 	}, nil
 }
internal/auth/auth.go modificado · +29 −0
@@ -45,6 +45,10 @@
 	// IdentityProviders son los alias de proveedores de identidad de Keycloak
 	// a los que /login?idp=… puede saltar directamente (kc_idp_hint).
 	IdentityProviders []string
+
+	// ACRValues son los niveles de autenticación que /login?acr=… puede pedir
+	// (acr_values), p. ej. "reforzado" para un segundo factor (lección 15).
+	ACRValues []string
 }
 
 // pendingLogin es lo que recordamos entre /login y /callback.
@@ -52,6 +56,7 @@
 	nonce     string
 	verifier  string // code_verifier de PKCE: nunca sale del servidor
 	returnTo  string // adónde volver tras el login
+	acr       string // nivel de autenticación pedido (acr_values), si se pidió
 	expiresAt time.Time
 }
 
@@ -63,6 +68,7 @@
 	sessions      *session.Store
 	endSessionURL string // end_session_endpoint del descubrimiento
 	idps          []string
+	acrs          []string
 	postLogoutURL string
 
 	mu      sync.Mutex
@@ -101,6 +107,7 @@
 		endSessionURL: meta.EndSessionEndpoint,
 		postLogoutURL: cfg.PostLogoutRedirectURL,
 		idps:          cfg.IdentityProviders,
+		acrs:          cfg.ACRValues,
 		pending:       make(map[string]pendingLogin),
 	}, nil
 }
@@ -123,10 +130,15 @@
 
 	a.mu.Lock()
 	a.dropExpiredLocked()
+	acr := r.URL.Query().Get("acr")
+	if !slices.Contains(a.acrs, acr) {
+		acr = ""
+	}
 	a.pending[state] = pendingLogin{
 		nonce:     nonce,
 		verifier:  verifier,
 		returnTo:  safeReturnTo(r.URL.Query().Get("next")),
+		acr:       acr,
 		expiresAt: time.Now().Add(pendingTTL),
 	}
 	a.mu.Unlock()
@@ -146,6 +158,11 @@
 	if idp := r.URL.Query().Get("idp"); idp != "" && slices.Contains(a.idps, idp) {
 		opts = append(opts, oauth2.SetAuthURLParam("kc_idp_hint", idp))
 	}
+	if acr != "" {
+		// Step-up: pedimos a Keycloak un login más fuerte. Si ya hay sesión,
+		// solo pedirá lo que falte (el código, no la contraseña).
+		opts = append(opts, oauth2.SetAuthURLParam("acr_values", acr))
+	}
 	authURL := a.oauth.AuthCodeURL(state, opts...)
 	http.Redirect(w, r, authURL, http.StatusFound)
 }
@@ -204,6 +221,18 @@
 	if idToken.Nonce != p.nonce {
 		http.Error(w, "nonce inválido", http.StatusUnauthorized)
 		return
+	}
+	// acr_values viaja por el navegador y alguien podría quitarlo de la URL:
+	// si pedimos un nivel, el ID token tiene que confirmarlo.
+	if p.acr != "" {
+		var c struct {
+			ACR string `json:"acr"`
+		}
+		if err := idToken.Claims(&c); err != nil || c.ACR != p.acr {
+			log.Printf("login sin el nivel pedido: acr=%q, se esperaba %q", c.ACR, p.acr)
+			http.Error(w, "el login no alcanzó el nivel de autenticación pedido", http.StatusUnauthorized)
+			return
+		}
 	}
 
 	// 6. Leemos los claims que nos interesan y creamos la sesión, ahora con los tokens.
internal/pedidos/actor.go modificado · +3 −0
@@ -18,6 +18,9 @@
 	ID          string // identificador estable (el «sub»)
 	Name        string
 	Permissions []Permission
+	// StrongAuth indica que la persona acaba de verificar su identidad con
+	// un segundo factor. El dominio no sabe cómo: lo decide el adaptador.
+	StrongAuth bool
 }
 
 // Can indica si el actor tiene el permiso p.
internal/pedidos/pedido.go modificado · +3 −0
@@ -27,4 +27,7 @@
 	ErrNotFound  = errors.New("pedido no encontrado")
 	ErrForbidden = errors.New("no tienes permiso")
 	ErrInvalid   = errors.New("datos inválidos")
+	// ErrStepUp: la operación exige verificar la identidad de nuevo, con
+	// un segundo factor (lección 15). No es un «no»: es un «todavía no».
+	ErrStepUp = errors.New("hace falta verificar tu identidad con un segundo factor")
 )
internal/pedidos/servicio.go modificado · +15 −3
@@ -38,8 +38,13 @@
 	if err != nil {
 		return Order{}, err
 	}
-	if o.Owner != a.ID && !a.Can(PermManage) {
-		return Order{}, ErrNotFound
+	if o.Owner != a.ID {
+		if !a.Can(PermManage) {
+			return Order{}, ErrNotFound
+		}
+		if !a.StrongAuth {
+			return Order{}, ErrStepUp
+		}
 	}
 	return o, nil
 }
@@ -66,10 +71,14 @@
 	})
 }
 
-// All devuelve todos los pedidos.
+// All devuelve todos los pedidos. Gestionar pedidos de otros exige una
+// autenticación reforzada.
 func (s *Service) All(ctx context.Context, a Actor) ([]Order, error) {
 	if !a.Can(PermManage) {
 		return nil, ErrForbidden
+	}
+	if !a.StrongAuth {
+		return nil, ErrStepUp
 	}
 	return s.repo.List(ctx, Filter{})
 }
@@ -78,6 +87,9 @@
 func (s *Service) SetStatus(ctx context.Context, a Actor, id int, status string) (Order, error) {
 	if !a.Can(PermManage) {
 		return Order{}, ErrForbidden
+	}
+	if !a.StrongAuth {
+		return Order{}, ErrStepUp
 	}
 	if !slices.Contains(Statuses, status) {
 		return Order{}, fmt.Errorf("%w: estado inválido %q", ErrInvalid, status)
internal/pedidos/servicio_test.go modificado · +17 −1
@@ -12,7 +12,7 @@
 // Actores de prueba: el dominio no sabe de tokens, así que basta con crearlos.
 var (
 	ana      = pedidos.Actor{ID: memoria.AnaID, Name: "ana", Permissions: []pedidos.Permission{pedidos.PermBuy}}
-	carlos   = pedidos.Actor{ID: memoria.CarlosID, Name: "carlos", Permissions: []pedidos.Permission{pedidos.PermBuy, pedidos.PermManage}}
+	carlos   = pedidos.Actor{ID: memoria.CarlosID, Name: "carlos", Permissions: []pedidos.Permission{pedidos.PermBuy, pedidos.PermManage}, StrongAuth: true}
 	factura  = pedidos.Actor{ID: "service-account-facturacion", Permissions: []pedidos.Permission{pedidos.PermInvoice}}
 	anonimo  = pedidos.Actor{ID: "x"}
 	pedidoDe = map[string]int{"ana": 1001, "carlos": 1003} // de memoria.NewWithSamples
@@ -119,3 +119,19 @@
 
 // second devuelve el error de una llamada que devuelve (valor, error).
 func second[T any](_ T, err error) error { return err }
+
+func TestGestionarExigeSegundoFactor(t *testing.T) {
+	ctx := context.Background()
+	s := newService()
+	sinFactor := carlos
+	sinFactor.StrongAuth = false
+	if _, err := s.All(ctx, sinFactor); !errors.Is(err, pedidos.ErrStepUp) {
+		t.Errorf("All sin segundo factor: err = %v, want ErrStepUp", err)
+	}
+	if _, err := s.Get(ctx, sinFactor, 1001); !errors.Is(err, pedidos.ErrStepUp) {
+		t.Errorf("pedido de otro sin segundo factor: err = %v, want ErrStepUp", err)
+	}
+	if _, err := s.Get(ctx, sinFactor, 1003); err != nil {
+		t.Errorf("su propio pedido no exige segundo factor: %v", err)
+	}
+}
internal/pedidosclient/client.go modificado · +7 −0
@@ -31,6 +31,9 @@
 	ErrUnauthorized = errors.New("api-pedidos rechazó el access token")
 	// ErrForbidden indica que el token es válido pero no basta (403).
 	ErrForbidden = errors.New("api-pedidos denegó la operación")
+	// ErrStepUpRequired indica que la API pide un login más fuerte (401 con
+	// error="insufficient_user_authentication", RFC 9470; lección 15).
+	ErrStepUpRequired = errors.New("api-pedidos pide verificar la identidad con un segundo factor")
 )
 
 // Client llama a api-pedidos.
@@ -106,11 +109,15 @@
 		var e struct {
 			Code        string `json:"error"`
 			Description string `json:"error_description"`
+			ACR         string `json:"acr_values"`
 		}
 		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
 		_ = json.Unmarshal(raw, &e)
 		switch resp.StatusCode {
 		case http.StatusUnauthorized:
+			if e.Code == "insufficient_user_authentication" {
+				return fmt.Errorf("%w (acr_values=%s)", ErrStepUpRequired, e.ACR)
+			}
 			return fmt.Errorf("%w: %s", ErrUnauthorized, e.Description)
 		case http.StatusForbidden:
 			return fmt.Errorf("%w: %s", ErrForbidden, e.Description)
internal/web/compras.go modificado · +13 −2
@@ -59,7 +59,13 @@
 		data.Flash = "Pedido #" + id + " actualizado."
 	}
 	data.Error = errorMessage(r.URL.Query().Get("error"))
-	if orders, err := h.api.AllOrders(r.Context(), tok); err != nil {
+	orders, err := h.api.AllOrders(r.Context(), tok)
+	if errors.Is(err, pedidosclient.ErrStepUpRequired) {
+		// La API quiere un segundo factor: volvemos a Keycloak pidiéndolo.
+		http.Redirect(w, r, "/login?acr=reforzado&next=%2Fadmin", http.StatusSeeOther)
+		return
+	}
+	if err != nil {
 		log.Printf("admin: %v", err)
 		data.Error = errorMessage(errorCode(err))
 	} else {
@@ -79,7 +85,12 @@
 		http.Error(w, "id inválido", http.StatusBadRequest)
 		return
 	}
-	if _, err := h.api.SetStatus(r.Context(), tok, id, r.PostFormValue("status")); err != nil {
+	_, err = h.api.SetStatus(r.Context(), tok, id, r.PostFormValue("status"))
+	if errors.Is(err, pedidosclient.ErrStepUpRequired) {
+		http.Redirect(w, r, "/login?acr=reforzado&next=%2Fadmin", http.StatusSeeOther)
+		return
+	}
+	if err != nil {
 		log.Printf("cambiar estado: %v", err)
 		http.Redirect(w, r, "/admin?error="+errorCode(err), http.StatusSeeOther)
 		return

← Volver a la lección 15