la parte del blog que nunca ves

22 de junio de 2026 meta devloggo

la parte que nadie mira

Todo el que ha visto este blog alguna vez ha visto el header, la montaña en pixel art, los pájaros. Nadie ha mirado nunca la página de login y ha pensado "bonita." Es un campo de usuario, un campo de contraseña, y un botón. Está bien, se supone que tiene que ser aburrida. Lo que no es aburrido, al menos para mí, es todo lo que hay detrás de ese botón.

Construí el sistema de cuentas como su propio servicio en Go separado, auth, que no sabe nada de posts ni comentarios, solo usuarios, contraseñas, y tokens. El servicio de blog le pregunta "esta persona tiene permiso para hacer esto" y esa es toda la relación entre los dos. Separarlo así se sentía exagerado para un blog de una sola persona con prácticamente ningún usuario, pero quería entender de verdad cómo se arma un sistema de auth real en vez de importar un paquete y confiar en él a ciegas.

la ruta de la petición hacia el servicio de auth

contraseñas: qué es lo que realmente te da el hashing

Primera decisión real: cómo guardas una contraseña. Mi primer instinto, el vergonzoso, fue algo así:

// what I almost did, first instinct
func hash(password string) string {
	sum := sha256.Sum256([]byte(password))
	return hex.EncodeToString(sum[:])
}

Parece razonable si nadie te lo ha explicado nunca. SHA-256 es una función hash de verdad, rápida, con salida de tamaño fijo. El problema es exactamente que es rápida. Un hash SHA-256 plano se rompe por fuerza bruta a miles de millones de intentos por segundo en una GPU decente, porque nada frena al atacante, y si dos usuarios eligen la misma contraseña obtienen exactamente el mismo hash, lo que le filtra ese hecho a quien sea que vea la base de datos alguna vez.

Lo que realmente quieres es algo deliberadamente lento y hambriento de memoria, lo bastante lento como para que los logins legítimos apenas lo noten pero la fuerza bruta quede aplastada. Eso es Argon2. Esto es lo que hace auth en realidad:

const (
	argonMemory      = 64 * 1024
	argonIterations  = 3
	argonParallelism = 2
	saltLength       = 16
	keyLength        = 32
)

func Hash(plain string) (string, error) {
	salt := make([]byte, saltLength)
	if _, err := rand.Read(salt); err != nil {
		return "", err
	}
	key := argon2.IDKey([]byte(plain), salt, argonIterations, argonMemory, argonParallelism, keyLength)
	encoded := fmt.Sprintf(
		"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
		argon2.Version, argonMemory, argonIterations, argonParallelism,
		base64.RawStdEncoding.EncodeToString(salt),
		base64.RawStdEncoding.EncodeToString(key),
	)
	return encoded, nil
}

Cada contraseña recibe su propia sal aleatoria para que contraseñas idénticas nunca produzcan hashes idénticos, y los números de memoria e iteraciones ajustan cuánto cuesta cada intento, a propósito. No escribí yo mismo la implementación de Argon2, esa viene directo de golang.org/x/crypto, y eso también es deliberado. Los primitivos criptográficos son exactamente el tipo de cosa que no te construyes a mano ni siquiera cuando ya entiendes la teoría, porque la teoría nunca fue donde se esconden los bugs, la implementación sí lo es.

La parte que realmente me sorprendió fue el truco del hash falso en el login:

user, err := s.Queries.GetUserByUsername(ctx, req.Username)
if err != nil {
	if errors.Is(err, pgx.ErrNoRows) {
		// Verify password against dummy hash to prevent timing-based username enumeration.
		_, _ = password.Verify(req.Password, dummyHash)
		writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
		return
	}
	...
}

Uno pensaría que un usuario que no existe simplemente puede cortar de inmediato. Pero el hashing tarda una cantidad de tiempo medible, y si un usuario incorrecto responde al instante mientras que una contraseña incorrecta tarda 80ms porque corrió la comprobación real de Argon2, un atacante puede medir el tiempo de tus respuestas y averiguar qué usuarios existen de verdad en el sitio. Así que hasta un login para un usuario que no es real igual quema CPU comprobando contra un hash falso, solo para que el timing se vea idéntico en ambos casos. Jamás se me habría ocurrido eso solo.

tokens y cookies, que me confundieron durante un tiempo

Esta fue la parte donde seguía mezclando las cosas. Sabía que los JWT eran "un token con cosas codificadas dentro" pero no entendía por qué hacían falta dos tipos distintos, ni por qué uno vive en una cookie manejada de forma diferente al otro.

Lo que auth realmente emite en el login son dos cosas: un access token de corta duración (15 minutos, firmado con una clave EdDSA para que cualquier servicio pueda verificarlo sin tener que llamar de vuelta a auth por la red) y un refresh token de larga duración (30 días) que solo el propio auth comprueba contra la base de datos. Ambos viajan como cookies httponly, lo que significa que el JavaScript del navegador no puede leer ninguno de los dos, solo el navegador los reenvía automáticamente.

func (s *Server) newRefreshCookie(rawToken string, maxAge time.Duration) *http.Cookie {
	return &http.Cookie{
		Name:     "refresh_token",
		Value:    rawToken,
		Path:     "/auth",
		HttpOnly: true,
		Secure:   s.CookieSecure,
		SameSite: http.SameSiteStrictMode,
		MaxAge:   int(maxAge.Seconds()),
	}
}

Dos tokens en vez de uno tiene que ver enteramente con el radio de la explosión. El access token se envía en cada petición, así que tiene que ser algo que cualquier servicio pueda comprobar barato: verificar una firma contra una clave pública, sin necesidad de ida y vuelta a la base de datos. Pero si esa misma credencial de larga duración se filtrara, importaría muchísimo más. Así que lo que realmente dura un mes nunca sale de la comprobación propia de la base de datos de auth, y lo que vuela en cada petición solo vive 15 minutos.

los refresh tokens rotan, y reutilizarlos significa que algo va mal

Cada vez que el frontend llama a /auth/refresh, el refresh token viejo se marca como usado y se emite uno completamente nuevo en su lugar, una cadena. Esta es la parte que me hizo entender de verdad por qué importa la rotación en vez de solo conocer la palabra: si alguien roba una cookie de refresh token e intenta usarla después de que el dueño real ya haya rotado más allá de ella, eso no es solo un token expirado, es evidencia de que el token se robó.

if row.RevokedAt.Valid {
	if row.ReplacedBy.Valid {
		// This token was rotated away by a normal refresh, and someone
		// is now replaying that old link in the chain. Assume the
		// chain is compromised and kill every active refresh token for
		// this user.
		if err := s.Queries.RevokeAllUserRefreshTokens(ctx, row.UserID); err != nil {
			...
		}
		http.SetCookie(w, s.clearRefreshCookie())
		writeError(w, r, http.StatusUnauthorized, "invalid_refresh_token", "refresh token reuse detected")
		return
	}
	...
}

Si un token revocado todavía tiene un ReplacedBy, alguien está presentando un eslabón más atrás en la cadena que aquel por el que la sesión real ya avanzó, y eso no puede pasar con un único usuario legítimo. Así que la respuesta no es "vuelve a iniciar sesión," es "mata cada sesión que tenga esta cuenta," dejando fuera también al dueño real como daño colateral. Se sintió duro escribir eso la primera vez, luego lo pensé de verdad y ese es exactamente el punto.

rate limiting, la parte aburrida pero necesaria

Última pieza: login y registro están ambos detrás de un simple token bucket en memoria, 10 peticiones por minuto por dirección IP.

func New(perMinute int) *Limiter {
	return &Limiter{
		buckets: make(map[string]*bucket),
		rate:    float64(perMinute) / 60.0,
		burst:   float64(perMinute),
	}
}

func (l *Limiter) Allow(key string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()

	now := time.Now()
	b, ok := l.buckets[key]
	if !ok {
		l.buckets[key] = &bucket{tokens: l.burst - 1, lastSeen: now}
		return true
	}

	elapsed := now.Sub(b.lastSeen).Seconds()
	b.tokens += elapsed * l.rate
	if b.tokens > l.burst {
		b.tokens = l.burst
	}
	b.lastSeen = now

	if b.tokens < 1 {
		return false
	}
	b.tokens--
	return true
}

Nada elaborado, un map en memoria que se resetea si el proceso se reinicia, lo cual está completamente bien para lo que realmente necesita frenar: alguien machacando el endpoint de login probando contraseñas. La clave del bucket es la IP de quien llama, que solo funciona bien porque el frontend resuelve la IP real del cliente y la pasa limpiamente en vez de que cada visitante aparezca como la dirección propia del contenedor del frontend. Me llevó un tiempo molestamente largo siquiera notar que ese bug existía, todo el mundo compartiendo un solo bucket porque todos parecían la misma IP para auth, pero eso en realidad es un problema del proxy del frontend y pertenece a otro post.

Eso es todo el botón de login. Aburrido a propósito.

0 comentarios

Inicia sesión para comentar.

Iniciar sesión

¿No tienes cuenta?