Qué cambió — paso 03
Qué cambió · paso-02 → paso-03

Cambios de la lección 03

Todo lo que cambia en tienda/pasos/paso-03 respecto al paso anterior. Vuelve a la lección: 03. Login con Authorization Code + PKCE.

9 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.gonuevo+76 −0
go.modmodificado+8 −1
internal/auth/auth.gonuevo+256 −0
internal/session/session.gonuevo+79 −0
internal/web/templates/home.htmlnuevo+15 −0
internal/web/templates/layout.htmlnuevo+57 −0
internal/web/templates/pedidos.htmlnuevo+16 −0
internal/web/templates/perfil.htmlnuevo+12 −0
internal/web/web.gonuevo+114 −0
cmd/web/main.go nuevo · +76 −0
@@ -0,0 +1,76 @@
+// Command web es tienda-web: el catálogo público y la zona de cliente,
+// con login a través de Keycloak (OpenID Connect).
+//
+// Uso (desde tienda/pasos/paso-03):
+//
+//	go run ./cmd/web
+package main
+
+import (
+	"context"
+	"log"
+	"net/http"
+	"os"
+	"strings"
+	"time"
+
+	"tienda/internal/auth"
+	"tienda/internal/session"
+	"tienda/internal/web"
+)
+
+func main() {
+	cfg := auth.Config{
+		Issuer:       env("OIDC_ISSUER", "http://localhost:8080/realms/tienda"),
+		ClientID:     env("OIDC_CLIENT_ID", "tienda-web"),
+		ClientSecret: env("OIDC_CLIENT_SECRET", "tienda-web-secret"), // solo para desarrollo
+		RedirectURL:  env("OIDC_REDIRECT_URL", "http://localhost:3000/callback"),
+	}
+	addr := env("ADDR", ":3000")
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	sessions := session.NewStore(8 * time.Hour)
+	a, err := auth.New(ctx, cfg, sessions)
+	if err != nil {
+		log.Fatal(err) // ¿Keycloak está arrancado?
+	}
+
+	mux := http.NewServeMux()
+	a.Register(mux)
+	web.Register(mux, a)
+
+	srv := &http.Server{
+		Addr: addr,
+		// CrossOriginProtection (Go 1.25+) rechaza POST de otros orígenes: CSRF en /logout.
+		Handler:           logRequests(http.NewCrossOriginProtection().Handler(mux)),
+		ReadHeaderTimeout: 5 * time.Second,
+	}
+	log.Printf("tienda-web escuchando en http://%s (issuer %s)", listenHost(addr), cfg.Issuer)
+	log.Fatal(srv.ListenAndServe())
+}
+
+// logRequests escribe una línea por petición: útil para seguir el flujo.
+func logRequests(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		start := time.Now()
+		next.ServeHTTP(w, r)
+		log.Printf("%s %s (%s)", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
+	})
+}
+
+func env(key, def string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return def
+}
+
+// listenHost convierte ":3000" en "localhost:3000" para mostrar la URL.
+func listenHost(addr string) string {
+	if strings.HasPrefix(addr, ":") {
+		return "localhost" + addr
+	}
+	return addr
+}
go.mod modificado · +8 −1
@@ -1,3 +1,10 @@
 module tienda
 
-go 1.24
+go 1.26.0
+
+require (
+	github.com/coreos/go-oidc/v3 v3.21.0
+	golang.org/x/oauth2 v0.37.0
+)
+
+require github.com/go-jose/go-jose/v4 v4.1.4 // indirect
internal/auth/auth.go nuevo · +256 −0
@@ -0,0 +1,256 @@
+// Package auth implementa el login de tienda-web contra Keycloak con
+// OpenID Connect: Authorization Code + PKCE, usando go-oidc y x/oauth2.
+package auth
+
+import (
+	"context"
+	"crypto/rand"
+	"fmt"
+	"log"
+	"net/http"
+	"net/url"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/coreos/go-oidc/v3/oidc"
+	"golang.org/x/oauth2"
+
+	"tienda/internal/session"
+)
+
+const (
+	sessionCookie = "tienda_session" // ID de la sesión de la app
+	stateCookie   = "tienda_state"   // ata el login en curso a este navegador
+	pendingTTL    = 10 * time.Minute // tiempo máximo para completar el login
+)
+
+// Config es lo que tienda-web necesita saber de su client en Keycloak.
+type Config struct {
+	Issuer       string // URL del realm, p. ej. http://localhost:8080/realms/tienda
+	ClientID     string
+	ClientSecret string
+	RedirectURL  string // debe estar en «Valid redirect URIs» del client
+}
+
+// pendingLogin es lo que recordamos entre /login y /callback.
+type pendingLogin struct {
+	nonce     string
+	verifier  string // code_verifier de PKCE: nunca sale del servidor
+	returnTo  string // adónde volver tras el login
+	expiresAt time.Time
+}
+
+// Auth agrupa la configuración OIDC y los handlers de login.
+type Auth struct {
+	oauth    oauth2.Config
+	verifier *oidc.IDTokenVerifier
+	sessions *session.Store
+
+	mu      sync.Mutex
+	pending map[string]pendingLogin // clave: state
+}
+
+// New lee el documento de descubrimiento del issuer y prepara el cliente OIDC.
+func New(ctx context.Context, cfg Config, sessions *session.Store) (*Auth, error) {
+	provider, err := oidc.NewProvider(ctx, cfg.Issuer)
+	if err != nil {
+		return nil, fmt.Errorf("descubrimiento OIDC en %s: %w", cfg.Issuer, err)
+	}
+
+	return &Auth{
+		oauth: oauth2.Config{
+			ClientID:     cfg.ClientID,
+			ClientSecret: cfg.ClientSecret,
+			RedirectURL:  cfg.RedirectURL,
+			Endpoint:     provider.Endpoint(), // URLs de /auth y /token, sacadas del descubrimiento
+			Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
+		},
+		// El verificador comprueba firma (con el JWKS), iss, aud == ClientID y exp.
+		verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
+		sessions: sessions,
+		pending:  make(map[string]pendingLogin),
+	}, nil
+}
+
+// Register añade las rutas de autenticación al mux.
+func (a *Auth) Register(mux *http.ServeMux) {
+	mux.HandleFunc("GET /login", a.handleLogin)
+	mux.HandleFunc("GET /callback", a.handleCallback)
+	mux.HandleFunc("POST /logout", a.handleLogout)
+}
+
+// handleLogin inicia el flujo: genera state, nonce y code_verifier y
+// redirige el navegador a Keycloak.
+func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) {
+	state := rand.Text()
+	nonce := rand.Text()
+	verifier := oauth2.GenerateVerifier()
+
+	a.mu.Lock()
+	a.dropExpiredLocked()
+	a.pending[state] = pendingLogin{
+		nonce:     nonce,
+		verifier:  verifier,
+		returnTo:  safeReturnTo(r.URL.Query().Get("next")),
+		expiresAt: time.Now().Add(pendingTTL),
+	}
+	a.mu.Unlock()
+
+	// Guardamos el state también en una cookie: así solo ESTE navegador
+	// puede completar este login (protección contra login CSRF).
+	http.SetCookie(w, &http.Cookie{
+		Name:     stateCookie,
+		Value:    state,
+		Path:     "/callback",
+		MaxAge:   int(pendingTTL.Seconds()),
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+	})
+
+	authURL := a.oauth.AuthCodeURL(state,
+		oidc.Nonce(nonce),
+		oauth2.S256ChallengeOption(verifier),
+	)
+	http.Redirect(w, r, authURL, http.StatusFound)
+}
+
+// handleCallback recibe ?code=…&state=… de Keycloak, canjea el código por
+// tokens, verifica el ID token y crea la sesión.
+func (a *Auth) handleCallback(w http.ResponseWriter, r *http.Request) {
+	q := r.URL.Query()
+
+	// 1. ¿Keycloak devolvió un error? (p. ej. el usuario canceló)
+	if e := q.Get("error"); e != "" {
+		http.Error(w, "Keycloak devolvió un error: "+e+" — "+q.Get("error_description"), http.StatusBadRequest)
+		return
+	}
+
+	// 2. El state de la URL debe coincidir con el de la cookie de este navegador.
+	state := q.Get("state")
+	c, err := r.Cookie(stateCookie)
+	if err != nil || state == "" || c.Value != state {
+		http.Error(w, "state inválido: vuelve a iniciar sesión", http.StatusBadRequest)
+		return
+	}
+	http.SetCookie(w, &http.Cookie{Name: stateCookie, Path: "/callback", MaxAge: -1})
+
+	// 3. Recuperamos nonce y code_verifier (cada state sirve una sola vez).
+	a.mu.Lock()
+	p, ok := a.pending[state]
+	delete(a.pending, state)
+	a.mu.Unlock()
+	if !ok || time.Now().After(p.expiresAt) {
+		http.Error(w, "login caducado o desconocido: vuelve a iniciar sesión", http.StatusBadRequest)
+		return
+	}
+
+	// 4. Canal trasero: canjeamos el código por tokens enviando el code_verifier
+	//    (y el client_secret, que x/oauth2 añade a partir de la Config).
+	tok, err := a.oauth.Exchange(r.Context(), q.Get("code"), oauth2.VerifierOption(p.verifier))
+	if err != nil {
+		log.Printf("canje del código: %v", err)
+		http.Error(w, "no se pudo completar el login", http.StatusBadGateway)
+		return
+	}
+
+	// 5. Verificamos el ID token: firma, iss, aud, exp… y el nonce, que es cosa nuestra.
+	rawIDToken, ok := tok.Extra("id_token").(string)
+	if !ok {
+		http.Error(w, "la respuesta de Keycloak no trae id_token", http.StatusBadGateway)
+		return
+	}
+	idToken, err := a.verifier.Verify(r.Context(), rawIDToken)
+	if err != nil {
+		log.Printf("verificación del ID token: %v", err)
+		http.Error(w, "ID token inválido", http.StatusUnauthorized)
+		return
+	}
+	if idToken.Nonce != p.nonce {
+		http.Error(w, "nonce inválido", http.StatusUnauthorized)
+		return
+	}
+
+	// 6. Leemos los claims que nos interesan y creamos la sesión.
+	var claims struct {
+		Username string `json:"preferred_username"`
+		Name     string `json:"name"`
+		Email    string `json:"email"`
+	}
+	var all map[string]any
+	if err := idToken.Claims(&claims); err != nil {
+		http.Error(w, "claims ilegibles", http.StatusInternalServerError)
+		return
+	}
+	if err := idToken.Claims(&all); err != nil {
+		http.Error(w, "claims ilegibles", http.StatusInternalServerError)
+		return
+	}
+
+	sess := a.sessions.Create(session.User{
+		Subject:  idToken.Subject,
+		Username: claims.Username,
+		Name:     claims.Name,
+		Email:    claims.Email,
+	}, all, rawIDToken)
+
+	http.SetCookie(w, &http.Cookie{
+		Name:     sessionCookie,
+		Value:    sess.ID,
+		Path:     "/",
+		HttpOnly: true,                 // JavaScript no puede leerla
+		SameSite: http.SameSiteLaxMode, // no viaja en POST de otros sitios
+		// Secure: true,                // obligatorio en producción (HTTPS)
+	})
+	http.Redirect(w, r, p.returnTo, http.StatusFound)
+}
+
+// handleLogout cierra la sesión LOCAL de tienda-web.
+// Ojo: la sesión SSO en Keycloak sigue viva (lo arreglamos en la lección 4).
+func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) {
+	if c, err := r.Cookie(sessionCookie); err == nil {
+		a.sessions.Delete(c.Value)
+	}
+	http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1})
+	http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// CurrentSession devuelve la sesión del usuario de esta petición, si la hay.
+func (a *Auth) CurrentSession(r *http.Request) (*session.Session, bool) {
+	c, err := r.Cookie(sessionCookie)
+	if err != nil {
+		return nil, false
+	}
+	return a.sessions.Get(c.Value)
+}
+
+// RequireLogin protege un handler: sin sesión, manda a /login y vuelve después.
+func (a *Auth) RequireLogin(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if _, ok := a.CurrentSession(r); !ok {
+			http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
+			return
+		}
+		next.ServeHTTP(w, r)
+	})
+}
+
+// dropExpiredLocked borra logins a medio hacer que ya caducaron.
+// Debe llamarse con a.mu bloqueado.
+func (a *Auth) dropExpiredLocked() {
+	now := time.Now()
+	for k, p := range a.pending {
+		if now.After(p.expiresAt) {
+			delete(a.pending, k)
+		}
+	}
+}
+
+// safeReturnTo solo acepta rutas locales («/pedidos»), nunca URLs de otro
+// sitio («https://malo.example», «//malo.example»): evita redirecciones abiertas.
+func safeReturnTo(next string) string {
+	if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
+		return "/"
+	}
+	return next
+}
internal/session/session.go nuevo · +79 −0
@@ -0,0 +1,79 @@
+// Package session guarda en memoria las sesiones de los usuarios de tienda-web.
+//
+// El navegador solo recibe una cookie con un ID aleatorio; los datos del
+// usuario (y, en la lección 4, los tokens) se quedan en el servidor.
+// Al reiniciar el proceso se pierden todas las sesiones: suficiente para el
+// curso. En producción usarías Redis, una base de datos o similar.
+package session
+
+import (
+	"crypto/rand"
+	"sync"
+	"time"
+)
+
+// User es la identidad del usuario, sacada del ID token ya verificado.
+type User struct {
+	Subject  string // claim "sub": ID estable del usuario en Keycloak
+	Username string // claim "preferred_username"
+	Name     string
+	Email    string
+}
+
+// Session es lo que recordamos de un usuario que ha iniciado sesión.
+type Session struct {
+	ID        string
+	User      User
+	Claims    map[string]any // todos los claims del ID token, para la página /perfil
+	IDToken   string         // el ID token en bruto; lo usaremos para el logout (lección 4)
+	ExpiresAt time.Time
+}
+
+// Store es un almacén de sesiones en memoria, seguro para uso concurrente.
+type Store struct {
+	mu       sync.Mutex
+	ttl      time.Duration
+	sessions map[string]*Session
+}
+
+// NewStore crea un almacén cuyas sesiones caducan tras ttl.
+func NewStore(ttl time.Duration) *Store {
+	return &Store{ttl: ttl, sessions: make(map[string]*Session)}
+}
+
+// Create guarda una sesión nueva con un ID aleatorio y la devuelve.
+func (s *Store) Create(u User, claims map[string]any, rawIDToken string) *Session {
+	sess := &Session{
+		ID:        rand.Text(), // 128 bits aleatorios (Go 1.24+)
+		User:      u,
+		Claims:    claims,
+		IDToken:   rawIDToken,
+		ExpiresAt: time.Now().Add(s.ttl),
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.sessions[sess.ID] = sess
+	return sess
+}
+
+// Get devuelve la sesión si existe y no ha caducado.
+func (s *Store) Get(id string) (*Session, bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	sess, ok := s.sessions[id]
+	if !ok {
+		return nil, false
+	}
+	if time.Now().After(sess.ExpiresAt) {
+		delete(s.sessions, id)
+		return nil, false
+	}
+	return sess, true
+}
+
+// Delete elimina la sesión (logout).
+func (s *Store) Delete(id string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	delete(s.sessions, id)
+}
internal/web/templates/home.html nuevo · +15 −0
@@ -0,0 +1,15 @@
+{{define "content"}}
+<h1>Catálogo</h1>
+{{if not .Session}}
+<div class="note">El catálogo es público. Para ver tus pedidos, <a href="/login">inicia sesión</a>.</div>
+{{end}}
+<div class="grid">
+  {{range .Products}}
+  <div class="card">
+    <h3>{{.Name}}</h3>
+    <p>{{.Desc}}</p>
+    <span class="price">$ {{printf "%.2f" .Price}}</span>
+  </div>
+  {{end}}
+</div>
+{{end}}
internal/web/templates/layout.html nuevo · +57 −0
@@ -0,0 +1,57 @@
+{{define "layout"}}<!doctype html>
+<html lang="es">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Tienda Go</title>
+  <style>
+    :root { --bg:#f6f7f9; --card:#fff; --text:#1d2433; --muted:#667085; --line:#e4e7ec; --brand:#00758f; --brand-2:#e3f3f6; }
+    * { box-sizing: border-box; }
+    body { margin:0; font:16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background:var(--bg); color:var(--text); }
+    header { background:var(--card); border-bottom:1px solid var(--line); }
+    .bar { max-width:960px; margin:0 auto; padding:.8rem 1rem; display:flex; gap:1.2rem; align-items:center; flex-wrap:wrap; }
+    .logo { font-weight:800; color:var(--brand); text-decoration:none; font-size:1.15rem; }
+    nav a { color:var(--muted); text-decoration:none; padding:.3rem .2rem; }
+    nav a.on { color:var(--text); font-weight:600; border-bottom:2px solid var(--brand); }
+    .spacer { flex:1; }
+    .who { color:var(--muted); font-size:.92rem; }
+    .btn { display:inline-block; border:0; border-radius:8px; padding:.5rem .9rem; font:inherit; font-weight:600; cursor:pointer; text-decoration:none; background:var(--brand); color:#fff; }
+    .btn.ghost { background:transparent; color:var(--text); border:1px solid var(--line); }
+    main { max-width:960px; margin:0 auto; padding:1.5rem 1rem 3rem; }
+    h1 { margin:.2rem 0 1rem; }
+    .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:1rem; }
+    .card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:1rem; }
+    .card h3 { margin:0 0 .3rem; font-size:1.05rem; }
+    .card p { margin:0 0 .6rem; color:var(--muted); font-size:.92rem; }
+    .price { font-weight:700; }
+    table { width:100%; border-collapse:collapse; background:var(--card); border:1px solid var(--line); border-radius:12px; overflow:hidden; }
+    th, td { text-align:left; padding:.6rem .8rem; border-bottom:1px solid var(--line); vertical-align:top; }
+    th { background:#f0f2f5; font-size:.8rem; text-transform:uppercase; color:var(--muted); }
+    td code { font:.85rem ui-monospace, Consolas, monospace; word-break:break-all; }
+    .note { background:var(--brand-2); border-radius:10px; padding:.8rem 1rem; margin:1rem 0; }
+    .badge { font-size:.8rem; padding:.1rem .5rem; border-radius:99px; background:#eef2f6; }
+  </style>
+</head>
+<body>
+<header>
+  <div class="bar">
+    <a class="logo" href="/">🛒 Tienda Go</a>
+    <nav>
+      <a href="/" {{if eq .Active "catalogo"}}class="on"{{end}}>Catálogo</a>
+      <a href="/pedidos" {{if eq .Active "pedidos"}}class="on"{{end}}>Mis pedidos</a>
+      {{if .Session}}<a href="/perfil" {{if eq .Active "perfil"}}class="on"{{end}}>Perfil</a>{{end}}
+    </nav>
+    <span class="spacer"></span>
+    {{if .Session}}
+      <span class="who">Hola, <strong>{{.Session.User.Name}}</strong></span>
+      <form method="post" action="/logout"><button class="btn ghost" type="submit">Salir</button></form>
+    {{else}}
+      <a class="btn" href="/login">Entrar</a>
+    {{end}}
+  </div>
+</header>
+<main>
+{{template "content" .}}
+</main>
+</body>
+</html>{{end}}
internal/web/templates/pedidos.html nuevo · +16 −0
@@ -0,0 +1,16 @@
+{{define "content"}}
+<h1>Mis pedidos</h1>
+<p class="who">Pedidos de <strong>{{.Session.User.Username}}</strong> ({{.Session.User.Email}})</p>
+{{if .Orders}}
+<table>
+  <thead><tr><th>Nº</th><th>Artículos</th><th>Total</th><th>Estado</th></tr></thead>
+  <tbody>
+  {{range .Orders}}
+    <tr><td>#{{.ID}}</td><td>{{.Items}}</td><td>$ {{printf "%.2f" .Total}}</td><td><span class="badge">{{.Status}}</span></td></tr>
+  {{end}}
+  </tbody>
+</table>
+{{else}}
+<div class="note">Todavía no tienes pedidos.</div>
+{{end}}
+{{end}}
internal/web/templates/perfil.html nuevo · +12 −0
@@ -0,0 +1,12 @@
+{{define "content"}}
+<h1>Perfil</h1>
+<p>Estos son los claims del <strong>ID token</strong> verificado con el que se creó tu sesión.</p>
+<table>
+  <thead><tr><th>Claim</th><th>Valor</th></tr></thead>
+  <tbody>
+  {{range .Claims}}
+    <tr><td><code>{{.Name}}</code></td><td><code>{{.Value}}</code></td></tr>
+  {{end}}
+  </tbody>
+</table>
+{{end}}
internal/web/web.go nuevo · +114 −0
@@ -0,0 +1,114 @@
+// Package web contiene las páginas de tienda-web: catálogo, pedidos y perfil.
+package web
+
+import (
+	"embed"
+	"encoding/json"
+	"html/template"
+	"log"
+	"net/http"
+	"sort"
+
+	"tienda/internal/auth"
+	"tienda/internal/session"
+)
+
+//go:embed templates/*.html
+var templateFS embed.FS
+
+// Product es un artículo del catálogo (público).
+type Product struct {
+	Name  string
+	Desc  string
+	Price float64
+}
+
+// Order es un pedido de ejemplo. En el módulo 3 vendrán de api-pedidos.
+type Order struct {
+	ID     int
+	Items  string
+	Total  float64
+	Status string
+}
+
+var catalog = []Product{
+	{"Gopher de peluche", "El compañero ideal para depurar.", 19.90},
+	{"Taza «go fmt»", "Formatea tu café automáticamente.", 9.50},
+	{"Camiseta Keycloak", "Algodón 100 %, talla única de realm.", 15.00},
+	{"Pegatinas OIDC", "Pack de 10: iss, sub, aud, exp…", 4.99},
+}
+
+// ordersByUser simula una base de datos de pedidos, indexada por username.
+var ordersByUser = map[string][]Order{
+	"ana": {
+		{1001, "Gopher de peluche ×1, Pegatinas OIDC ×2", 29.88, "Enviado"},
+		{1002, "Taza «go fmt» ×1", 9.50, "Pendiente"},
+	},
+	"carlos": {
+		{1003, "Camiseta Keycloak ×2", 30.00, "Entregado"},
+	},
+}
+
+// claim es una fila de la tabla de /perfil.
+type claim struct {
+	Name  string
+	Value string
+}
+
+// pageData es lo que reciben todas las plantillas.
+type pageData struct {
+	Active   string // pestaña activa del menú
+	Session  *session.Session
+	Products []Product
+	Orders   []Order
+	Claims   []claim
+}
+
+type handlers struct {
+	auth  *auth.Auth
+	pages map[string]*template.Template
+}
+
+// Register añade las páginas al mux. /pedidos y /perfil exigen sesión.
+func Register(mux *http.ServeMux, a *auth.Auth) {
+	h := &handlers{auth: a, pages: make(map[string]*template.Template)}
+	for _, name := range []string{"home.html", "pedidos.html", "perfil.html"} {
+		h.pages[name] = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/"+name))
+	}
+
+	mux.HandleFunc("GET /{$}", h.home)
+	mux.Handle("GET /pedidos", a.RequireLogin(http.HandlerFunc(h.pedidos)))
+	mux.Handle("GET /perfil", a.RequireLogin(http.HandlerFunc(h.perfil)))
+}
+
+func (h *handlers) home(w http.ResponseWriter, r *http.Request) {
+	sess, _ := h.auth.CurrentSession(r)
+	h.render(w, "home.html", pageData{Active: "catalogo", Session: sess, Products: catalog})
+}
+
+func (h *handlers) pedidos(w http.ResponseWriter, r *http.Request) {
+	sess, _ := h.auth.CurrentSession(r)
+	h.render(w, "pedidos.html", pageData{
+		Active:  "pedidos",
+		Session: sess,
+		Orders:  ordersByUser[sess.User.Username],
+	})
+}
+
+func (h *handlers) perfil(w http.ResponseWriter, r *http.Request) {
+	sess, _ := h.auth.CurrentSession(r)
+	var claims []claim
+	for name, v := range sess.Claims {
+		b, _ := json.Marshal(v)
+		claims = append(claims, claim{name, string(b)})
+	}
+	sort.Slice(claims, func(i, j int) bool { return claims[i].Name < claims[j].Name })
+	h.render(w, "perfil.html", pageData{Active: "perfil", Session: sess, Claims: claims})
+}
+
+func (h *handlers) render(w http.ResponseWriter, name string, data pageData) {
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	if err := h.pages[name].ExecuteTemplate(w, "layout", data); err != nil {
+		log.Printf("plantilla %s: %v", name, err)
+	}
+}

← Volver a la lección 03