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.
| Archivo | Estado | Líneas |
|---|---|---|
cmd/web/main.go | nuevo | +76 −0 |
go.mod | modificado | +8 −1 |
internal/auth/auth.go | nuevo | +256 −0 |
internal/session/session.go | nuevo | +79 −0 |
internal/web/templates/home.html | nuevo | +15 −0 |
internal/web/templates/layout.html | nuevo | +57 −0 |
internal/web/templates/pedidos.html | nuevo | +16 −0 |
internal/web/templates/perfil.html | nuevo | +12 −0 |
internal/web/web.go | nuevo | +114 −0 |
cmd/web/main.go
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
+ }
+}