Módulo 8 · Lección 15

Segundo factor solo para lo delicado (step-up)

Pedir un código a cada cliente para comprar una taza es excesivo; no pedirlo para gestionar todos los pedidos, imprudente. Con step-up, carlos entra con su contraseña y solo cuando abre Admin se le pide el código de su móvil. La API decide cuándo hace falta; Keycloak, cómo se consigue.

≈ 60 min Carpeta: tienda/pasos/paso-15 Realm nuevo: hay que reimportar

En este capítulo

Trabajas en
Keycloak (un login por niveles), api-pedidos (exige el segundo factor para gestionar) y tienda-web (lo pide cuando la API lo reclama).
Carpeta
tienda/pasos/paso-15 · realm nuevo: docker compose down -v en el paso anterior
Archivos
Dominio: actor.go, pedido.go, servicio.go; adaptadores: keycloak.go, rest.go; tienda-web: auth.go, pedidosclient/client.go, web/compras.go, cmd/web/main.go; apiauth.go (claims acr y auth_time); el realm; los tests · ver todos los cambios del paso.
En marcha
Keycloak y el AD del paso 15, api, web y facturacion. Opcional: una app de autenticación en el móvil (Google Authenticator, Microsoft Authenticator, FreeOTP…).
Comprueba
go -C tools/comprobar run . -paso 15 -servicios (desde course/): calcula el código de carlos por ti.

Al terminar sabrás

  • Qué son los niveles de autenticación (acr) y cómo se configuran en Keycloak.
  • Responder desde la API con el reto estándar de step-up (RFC 9470) sin meter Keycloak en el dominio.
  • Pedir un nivel desde Go con acr_values y comprobar después que se alcanzó.
  • Por qué acr no basta y hace falta auth_time (lo descubrimos probando).

1. El recorrido

carlos abre Admin; api-pedidos responde 401 insufficient_user_authentication; tienda-web vuelve a Keycloak con acr_values=reforzado; Keycloak pide solo el código; con el token nuevo, la API responde 200 carlos tienda-web api-pedidos Keycloak 1. GET /admin 2. token acr=basico 3. 401 insufficient_user_authentication 4. /auth?acr_values=reforzado (por el navegador) 5. solo el código: la contraseña ya está en la sesión 6. tokens con acr=reforzado (tienda-web lo comprueba) 7. GET /admin/pedidos → 200
La API pide el nivel con un estándar (RFC 9470); tienda-web sabe pedírselo a Keycloak; Keycloak sabe cómo verificarlo.

2. Niveles de autenticación en Keycloak

cd tienda/pasos/paso-14/infra && docker compose down -v
cd ../../paso-15/infra && docker compose up -d

Un nivel (Level of Authentication, LoA) es un número; el claim acr del token lo nombra. El realm del paso define dos, con el atributo acr.loa.map: basico = 1 (contraseña) y reforzado = 2 (contraseña + código). El flujo de login del navegador se sustituye por uno con un subflujo por nivel:

Flujo navegador con niveles: Cookie, Identity Provider Redirector, formularios por nivel con nivel 1 - contraseña y nivel 2 - código
Authentication → navegador con niveles. Debajo de «nivel 2 - código» va el paso «OTP 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"
    }
  ]
}
{
  "alias": "nivel 2",
  "config": {
    "loa-condition-level": "2",
    "loa-max-age": "300"
  }
}
PiezaQué hace
Cookie · Identity Provider RedirectorIgual que el flujo por defecto: sesión existente y kc_idp_hint (lección 12).
nivel 1 - contraseñaCondición «LoA 1» + usuario y contraseña. Vale 10 h (loa-max-age 36000).
nivel 2 - códigoCondición «LoA 2» + OTP Form. Vale 5 min (loa-max-age 300): pasado ese tiempo, un login que pida reforzado vuelve a pedir el código.
Sin nivel pedidoSolo se ejecuta el nivel 1: el login de siempre, con acr=basico.

Para que carlos tenga ya un código, el realm le importa una credencial OTP (solo para el curso: en un sistema real cada persona la configura escaneando un QR). Puedes añadirla a tu app de autenticación como clave manual, de tipo «basada en tiempo»: MNQXE3DPOMWW65DQFVRXK4TTN4WTEMBSGY.

3. La API decide cuándo hace falta

«Gestionar pedidos de otros exige verificar la identidad hace poco» es una regla de la tienda, así que va en el dominio, y sin mencionar Keycloak: el Actor trae StrongAuth y el servicio devuelve un error nuevo, ErrStepUp.

// 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{})
}

Qué es «reforzado» lo decide el adaptador de Keycloak, y cómo se pide al cliente también: un 401 con error="insufficient_user_authentication" y acr_values, el reto de la RFC 9470 (OAuth 2.0 Step-Up Authentication Challenge).

// Package keycloak es el adaptador de identidad de api-pedidos: valida el
// access token (con apiauth) y traduce lo que dice Keycloak (roles de realm,
// roles de client, scopes) a lo que entiende el dominio (pedidos.Actor).
//
// Es el único sitio de api-pedidos que conoce los nombres de los roles. Si
// mañana el rol «admin» se llama «tienda-admin», o los permisos vienen de
// otro proveedor, solo cambia este archivo.
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 {
	Verifier *apiauth.Verifier
}

// Middleware exige un access token válido (401 si no lo hay).
func (i Identity) Middleware(next http.Handler) http.Handler { return i.Verifier.Middleware(next) }

// RequireScope exige un scope en el token (403 insufficient_scope si falta).
func (Identity) RequireScope(scope string) func(http.Handler) http.Handler {
	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())
	if p == nil {
		return pedidos.Actor{}
	}
	return ActorFrom(p)
}

// 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,
		// 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)
	}
	if p.HasRole("admin") {
		a.Permissions = append(a.Permissions, pedidos.PermManage)
	}
	for _, r := range p.APIRoles { // roles de client de api-pedidos (servicios)
		if r == "facturar" {
			a.Permissions = append(a.Permissions, pedidos.PermInvoice)
		}
	}
	return a
}
Por qué acr no basta (lo descubrimos probando)

Bajamos el Max Age del nivel 2 a 20 segundos y renovamos el token de carlos 25 segundos después del código: el token nuevo seguía diciendo acr: "reforzado". El Max Age decide cuándo Keycloak vuelve a pedir el código en un login nuevo, pero no rebaja los tokens de una sesión abierta. Si la API se fiara solo de acr, un código valdría toda la sesión (hasta 10 h). El access token trae auth_time, que el step-up actualiza y el refresh conserva; por eso el adaptador exige además que sea de hace menos de StrongMaxAge, los mismos 5 minutos del realm.

REST solo añade una línea a su traducción de errores: ErrStepUp → h.id.StepUp(w). El puerto Identity gana el método StepUp, y el doble de los tests de la lección 14 también.

// writeError traduce los errores del dominio a respuestas HTTP.
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):
		jsonhttp.Error(w, http.StatusForbidden, "forbidden", "no tienes permiso para esto")
	case errors.Is(err, pedidos.ErrInvalid):
		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", err.Error())
	default:
		jsonhttp.Error(w, http.StatusInternalServerError, "server_error", "error interno")
	}
}

4. tienda-web reacciona

El cliente de la API reconoce el reto y lo convierte en un error con nombre:

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)

Y la página Admin, al recibirlo, manda a carlos a pedir ese nivel:

// admin lista todos los pedidos. No comprobamos aquí el rol: si el usuario no
// es admin, api-pedidos responde 403 y lo mostramos. El enlace del menú solo
// se ve para admins, pero quien decide de verdad es la API.
func (h *handlers) admin(w http.ResponseWriter, r *http.Request) {
	sess, tok, ok := h.apiToken(w, r, "%2Fadmin")
	if !ok {
		return
	}
	data := pageData{Active: "admin", Session: sess, Statuses: pedidos.Statuses}
	if id := r.URL.Query().Get("ok"); id != "" {
		data.Flash = "Pedido #" + id + " actualizado."
	}
	data.Error = errorMessage(r.URL.Query().Get("error"))
	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 {
		data.Orders = orders
	}
	h.render(w, "admin.html", data)
}

/login?acr=reforzado añade acr_values a la petición de autorización (solo valores de una lista permitida, OIDC_ACR_VALUES, como en la lección 12). La parte importante está en el callback: acr_values viaja por el navegador y cualquiera puede quitarlo de la URL. La documentación de Keycloak recomienda comprobar el resultado, y lo hacemos:

// 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
	}
}

Probado: seguimos la redirección de tienda-web a Keycloak quitando acr_values. Como carlos ya tenía sesión, Keycloak devolvió un código al momento… y tienda-web respondió 401 el login no alcanzó el nivel de autenticación pedido. Aunque se colara, la API seguiría diciendo que no: comprobar en los dos sitios no sobra.

5. Probarlo

Arranca api, web y facturacion del paso 15. Entra como carlos (carlos123) y abre Admin:

Keycloak pide el One-time code a carlos, con su usuario ya puesto
Keycloak solo pide el código: la contraseña ya está en la sesión.
Todos los pedidos tras el segundo factor
Con el código, de vuelta en Admin.

Durante los 5 minutos siguientes, Admin funciona sin más preguntas; después, la API vuelve a lanzar el reto y Keycloak vuelve a pedir el código. Comprar sigue como siempre: el dominio no pide segundo factor para los pedidos propios.

6. Lo que vimos probando

CasoResultado
Login normal de carlosUsuario y contraseña; acr: "basico".
Con sesión, pide reforzadoSolo el código; acr: "reforzado".
Sin sesión, pide reforzadoContraseña y después código.
Repetir un código ya usadoRechazado (invalid_user_credentials): cada código vale una vez, aunque siga dentro de sus 30 s.
ana (sin OTP) pide reforzadoKeycloak la obliga a configurar el autenticador («Mobile Authenticator Setup») antes de seguir.
jorge (entra por la empresa, lección 12)No puede: Keycloak le muestra el formulario de usuario y contraseña de la tienda, que no tiene. Con cuentas de la empresa, el segundo factor debería ponerlo la empresa (ver ejercicio 3).

Ejercicios

1. Tu móvil como segundo factor · fácil

Añade la clave de carlos a una app de autenticación y entra en Admin con el código de tu móvil.

Ver solución

En la app: añadir cuenta → introducir clave manualmente → nombre «carlos (tienda)», clave MNQXE3DPOMWW65DQFVRXK4TTN4WTEMBSGY, basada en tiempo. Es la codificación Base32 del secreto del realm (carlos-otp-curso-2026), lo que usan las apps; el algoritmo es el estándar (TOTP, SHA-1, 6 cifras, 30 s), el mismo que calcula tools/comprobar. Si el código no se acepta, revisa la hora del móvil y del equipo: TOTP depende del reloj.

2. Un test para la nueva regla · fácil

Escribe un test de dominio que compruebe que cambiar el estado de un pedido sin segundo factor devuelve ErrStepUp.

Ver solución
func TestCambiarEstadoExigeSegundoFactor(t *testing.T) {
	sinFactor := carlos
	sinFactor.StrongAuth = false
	_, err := newService().SetStatus(context.Background(), sinFactor, 1002, "Enviado")
	if !errors.Is(err, pedidos.ErrStepUp) {
		t.Fatalf("err = %v, want ErrStepUp", err)
	}
}

Probado: pasa. En los tests del paso, carlos ya tiene StrongAuth: true: el resto de reglas se prueban como si hubiera usado su código.

3. ¿Y las cuentas de la empresa? · media

jorge es admin por su grupo de la empresa, pero no puede subir a reforzado. ¿Qué opciones hay?

Ver solución
  • Que la empresa ponga el segundo factor. Lo natural con Entra ID es el acceso condicional de Microsoft: la empresa exige MFA para esa aplicación y la tienda confía en ella. Faltaría que Keycloak lo trasladara al acr de sus tokens, algo que no hemos probado en este curso.
  • Un segundo factor propio de la tienda para esas cuentas: darles una credencial local (OTP o passkey) además del login por la empresa. Más fricción y dos sitios que gestionar.
  • Que la administración no se haga con cuentas de la empresa: el rol admin solo para cuentas locales con segundo factor.

No hay respuesta universal: depende de quién responde de la seguridad de esas cuentas.

Errores comunes

bucle de login Admin vuelve a pedir el código una y otra vez

El acr que pide la API no coincide con el que emite el realm (acr.loa.map), o el StrongMaxAge de la API es menor que el tiempo que tarda el usuario. Comprueba con el perfil de tienda-web qué acr trae el ID token.

Invalid authenticator code. con un código que parece correcto

El reloj del móvil o del servidor va desfasado (TOTP depende de la hora), o el código ya se usó: Keycloak no acepta el mismo código dos veces.

kc_idp_hint dejó de funcionar tras cambiar el flujo

Un flujo de navegador propio sustituye al de Keycloak por completo: si no incluye Identity Provider Redirector, kc_idp_hint se ignora. Por eso el flujo del paso lo lleva.

el segundo factor dura toda la sesión

La API comprueba solo acr. Los tokens renovados lo conservan; compara también auth_time (sección 3).

Resumen

El dominio dice qué es delicado. El adaptador de Keycloak traduce «delicado» a «acr reforzado y reciente» y responde con el reto estándar. tienda-web pide el nivel con acr_values y comprueba el resultado. Keycloak, con un flujo por niveles, solo pregunta lo que falta.