Construí un formulario de autoservicio para cambiar la contraseña, lo probé como lo haría un usuario normal, y me cerró la sesión exacta en la que estaba sentado mientras escribía la contraseña nueva. No otro dispositivo, no una sesión vieja por ahí. Yo, ahora mismo, en la pestaña que estaba usando activamente para cambiar mi propia contraseña.
lo que se supone que hace la función
Los usuarios con sesión iniciada pueden cambiar su propia contraseña desde una página /profile con pestañas, bajo Seguridad. Escribe tu contraseña actual para demostrar que eres tú, escribe una nueva, listo. El requisito de seguridad obvio detrás de ese formulario es: si la sesión o el token de otra persona todavía andan flotando por ahí, cambiar tu contraseña debería matarlos. Eso no es opcional, es todo el sentido de permitir que ocurra un cambio de contraseña en primer lugar, si no invalidara las sesiones antiguas, un token robado simplemente sobreviviría directamente al "arreglo."
el diseño que parecía razonable
Mi primer instinto fue "excepto la actual." Revocar todos los refresh tokens de esta cuenta salvo el que pertenece a la sesión que hace esta petición exacta, ya que presumiblemente es la legítima, la persona está demostrando después de todo que conoce la contraseña actual. Para hacer eso, el handler necesita saber qué refresh token pertenece a esta sesión, para poder saltárselo. El sitio obvio donde buscarlo es la cookie refresh_token que viaja con la petición:
// first attempt: read the current session's own refresh cookie so we
// know which one to spare
currentToken := r.CookieValue("refresh_token")
if err := qtx.RevokeAllUserRefreshTokensExcept(ctx, userID, currentToken); err != nil {
writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to revoke sessions")
return
}Salvo que la cookie no tiene el alcance que yo había asumido a medias. Las cookies de refresh aquí se establecen con Path=/auth:
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()),
}
}Path=/auth significa que el navegador solo adjunta esa cookie a peticiones cuya ruta realmente empieza por /auth. Un envío de formulario desde /profile/security no es una de ellas. Así que en el momento en que mi lógica de excepto-la-actual intentaba leer refresh_token de la petición entrante para averiguar qué sesión perdonar, no obtenía nada, cada vez, para cada usuario legítimo, porque el navegador correctamente nunca la enviaba ahí en primer lugar. Mi salvaguarda para "no se pudo identificar la sesión actual" era caer de vuelta a revocar todo, que es una salvaguarda razonable en aislamiento. Solo que significaba que el respaldo se disparaba literalmente en cada cambio de contraseña real, ya que el camino feliz del que se suponía que era una excepción nunca ocurría en realidad.
Encontré esto probando la función de extremo a extremo en vez de solo probar el handler aisladamente con pruebas unitarias, lo cual le habría dado una cookie que un navegador real jamás habría enviado.
el arreglo real
Abandonar la idea de perdonar una sesión leyendo una cookie que estructuralmente no puede estar ahí. Revocar todo, sin excepciones, y luego acuñar de inmediato un refresh token completamente nuevo para la sesión que hace la petición y devolverlo en la misma respuesta vía Set-Cookie. El usuario se queda con la sesión iniciada, solo que con un token recién acuñado en vez del antiguo, y cada otra sesión, las que podrían pertenecer a alguien que robó un token, se elimina junto con él:
if err := qtx.RevokeAllUserRefreshTokens(ctx, userID); err != nil {
writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to revoke sessions")
return
}
rawRefreshToken, _, err := s.issueRefreshTokenWith(ctx, qtx, userID)
if err != nil {
writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to issue refresh token")
return
}
if err := tx.Commit(ctx); err != nil {
writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to commit transaction")
return
}
http.SetCookie(w, s.newRefreshCookie(rawRefreshToken, s.RefreshTTL))
w.WriteHeader(http.StatusNoContent)Revocar todo, emitir un token fresco, ponerlo al salir, todo dentro de una sola transacción para que no haya ninguna ventana donde todas las sesiones estén muertas y ninguna nueva exista todavía. Más simple que la versión de excepto-la-actual y no depende de una cookie que el navegador nunca iba a enviar a esa ruta de todos modos. A veces el arreglo para "mi lógica de excepción no funciona" es borrar la excepción, no depurarla.
demostrándolo con una segunda sesión, no solo leyendo el código
No confiaba en mí mismo lo suficiente como para simplemente leer la versión nueva y darla por arreglada, dado lo confiadamente equivocada que se había sentido la primera versión mientras la escribía. La prueba real abre dos sesiones contra el stack real en ejecución, registra un usuario, inicia sesión "actual" y obtiene su cookie de refresh, luego inicia una segunda sesión separada para la misma cuenta y obtiene esa cookie también. Cambia la contraseña a través de la sesión actual, y luego verifica dos cosas directamente contra los endpoints reales: la cookie recién rotada de la sesión actual todavía se intercambia con éxito por un access token fresco en /auth/refresh, y la cookie antigua de la segunda sesión es rechazada de plano. Ambas afirmaciones tienen que cumplirse a la vez para que el arreglo sea realmente correcto, una versión que por accidente revocara todo incluido el token nuevo fallaría la primera, y una versión que se olvidara de revocar las demás sesiones fallaría la segunda en silencio, viéndose bien desde el punto de vista de la sesión actual mientras deja cada otra sesión completamente abierta.
el arreglo más pequeño que vino de propina
La misma pasada de revisión que atrapó esto también atrapó un bug relacionado y más silencioso en el limitador de tasa de inicio de sesión y cambio de contraseña, que había estado usando como clave la dirección propia del contenedor del frontend en vez de la del visitante real, exactamente con la misma forma de error que un limitador de tasa un salto más allá en el servicio de blog (merece su propia entrada, ya que el arreglo terminó viviendo en el proxy inverso para ambos). Ninguno de los dos era el bug principal con el que empecé. Ambos salieron de la misma disciplina: rastrear lo que una petición realmente lleva en cada salto, ruta de cookie incluida, en vez de confiar en que un código que compila y pasa una prueba unitaria estrecha está haciendo lo que parece que hace una vez que hay navegadores y proxies reales de por medio.
la parte que hizo que encajara todo
El lado del frontend de esto se quedó casi aburrido en comparación, la lógica silenciosa de refresco de sesión en hooks.server.ts ya lee la cookie refresh_token que exista en ese momento y la intercambia por un access token fresco cuando el de corta duración expira:
const refreshToken = event.cookies.get('refresh_token');
if (refreshToken) {
const response = await event.fetch('/auth/refresh', { method: 'POST' });
...
}Como la nueva cookie de refresh se establece en la misma respuesta exacta que el propio cambio de contraseña, para cuando esta ruta de código se ejecuta de nuevo el navegador ya tiene la nueva. Sin paso separado de volver a iniciar sesión, sin mensaje de "se ha cerrado tu sesión, vuelve a iniciarla," la sesión simplemente continúa en silencio sobre un token nuevo debajo del usuario sin que note que algo cambió. Lo verifiqué correctamente también, no solo leyendo el código: cambié la contraseña a través del formulario real detrás de Caddy, confirmé que la cookie rotada de la sesión actual seguía funcionando en la siguiente petición, y confirmé que el refresh token antiguo de una segunda sesión separada era rechazado. La versión en palabras sencillas de la lección: el Path de una cookie no es una formalidad, es un límite de acceso real, y un respaldo de seguridad que se dispara en silencio en cada petición en vez de en la rara para la que está pensado no es un respaldo, es el comportamiento real disfrazado.