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.
| Archivo | Estado | Líneas |
|---|---|---|
cmd/web/main.go | modificado | +2 −0 |
infra/realm/tienda-realm.json | modificado | +129 −0 |
internal/adaptadores/keycloak/keycloak.go | modificado | +28 −1 |
internal/adaptadores/keycloak/keycloak_test.go | modificado | +22 −0 |
internal/adaptadores/rest/rest.go | modificado | +11 −7 |
internal/adaptadores/rest/rest_test.go | modificado | +12 −1 |
internal/apiauth/apiauth.go | modificado | +14 −7 |
internal/auth/auth.go | modificado | +29 −0 |
internal/pedidos/actor.go | modificado | +3 −0 |
internal/pedidos/pedido.go | modificado | +3 −0 |
internal/pedidos/servicio.go | modificado | +15 −3 |
internal/pedidos/servicio_test.go | modificado | +17 −1 |
internal/pedidosclient/client.go | modificado | +7 −0 |
internal/web/compras.go | modificado | +13 −2 |
cmd/web/main.go
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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