/Vamos a construirlo: invalidación de sesión en tiempo real
Hackernoon logo

Vamos a construirlo: invalidación de sesión en tiempo real


Foto de perfil del autor

@ @robzhuRobert Zhu

Campeón mundial actual @ AWS, ex Facebook, ex Microsoft

¿Cómo construiríamos una experiencia como la anterior?

Repo de demostración

Algunas aplicaciones necesitan limitar a los usuarios a un solo cliente o instancia de navegador. Esta publicación cubre cómo construir, mejorar y escalar esta característica. Comenzamos con una aplicación web simple con dos puntos finales API:

Los usuarios inician sesión enviando su ID de usuario en el encabezado de solicitud HTTP del usuario a la ruta / login. Aquí hay un ejemplo de solicitud / respuesta:

curl -H "user:user123" localhost:9000/login
"sessionId":"364rl8"
El usuario agrega

sessionid=364rl8

como un encabezado HTTP para la ruta

/api

. Si la ID de sesión es válida, el servidor devuelve “autenticado”; de lo contrario, el servidor devuelve un error:

curl -H "sessionid=364rl8" localhost:9000/api
authenticated

curl -H "sessionid=badSession" localhost:9000/api
error: invalid session
Nota: nuestro ejemplo devuelve la ID de sesión en el cuerpo de respuesta HTTP, pero en la práctica es más común almacenar la ID de sesión como una cookie, donde el servidor devuelve el

Set-Cookie: sessionid=364rl8

Encabezado HTTP

Esto hace que el navegador incluya automáticamente la ID de sesión en todas las solicitudes posteriores al mismo dominio.

1. La solución más simple

La solución más simple es utilizar un caché de sesión del lado del servidor que genera y almacena una ID de sesión para cada ID de usuario.

const  generateSessionId  = require("./utils");
const cors = require("cors");
const app = require("express")().use(cors());
 
const PORT = 9000;
// this will totally scale, trust me
const sessions = ;
 
app.get("/login", (req, res) => 
  const  user  = req.headers;
 
  if (!user) 
    res.status(400).send("error: request must include the 'user' HTTP header");
   else 
    const sessionId = generateSessionId();
    sessions[user] = sessionId;
 
    res.send( sessionId );
  
);
 
app.get("/api", (req, res) => 
  const  sessionid  = req.headers;
 
  if (!sessionid) 
    res.status(401).send("error: no sessionId. Log in at /login");
   else 
    if (Object.values(sessions).includes(sessionid)) 
      res.send("authenticated");
     else 
      res.status(401).send("error: invalid session.");
    
  
);
 
app.listen(PORT, () => 
  console.log(`server started on http://localhost:$PORT`);
);

Cada vez que un usuario inicie sesión correctamente, se anulará la ID de sesión. Las solicitudes que incluyen ID de sesión desactualizadas fallarán en la validación, causando que el servidor devuelva un error. Sin embargo, si el cliente no realiza una solicitud de API, el usuario no sabrá que la sesión fue invalidada. Idealmente, queremos una función del lado del cliente que nos pueda decir cuándo la sesión ya no es válida:

async function logIn(userId, onSessionInvalidated)
los

logIn

La función toma una función de devolución de llamada (como segundo argumento) que se invocará cada vez que detectemos que la sesión ya no es válida. Podemos implementar esta API de dos maneras: sondeo y envío de servidor.

2. Encuesta

La solución más simple es utilizar un caché de sesión del lado del servidor que genera y almacena una ID de sesión para cada ID de usuario.

async function logIn(userId, onSessionInvalidated) 
  const response = await fetch("http://localhost:9000/login", 
    headers: 
      user: userId,
    ,
  );
  const  sessionId  = await response.json();
 
  const POLLING_INTERVAL = 200;
  const poll = setInterval(async () => 
    const response = await fetch("http://localhost:9000/api", 
      headers: 
        sessionId,
      ,
    );
 
    if (response.status !== 200) 
      // non-200 status code means the token is invalid
      clearTimeout(poll);
      onSessionInvalidated();
    
  , POLLING_INTERVAL);
 
  return sessionId;

Sin embargo, las encuestas nos obligan a hacer una compensación entre latencia y eficiencia. Cuanto más corto sea el intervalo de sondeo, más rápido podremos detectar una mala sesión a costa de más encuestas desperdiciadas.

3. Servidor Push

Si encontramos cuellos de botella con la solución de sondeo, entonces nuestra solución final es mantener un canal persistente y bidireccional en el que el servidor pueda informar a los clientes conectados cuando sus sesiones se invaliden. Para esta demostración, usaremos Tomas web. Para alojar un servidor Web Socket, utilizamos el paquete ws.
const wss = new WebSocket.Server( port: 9001 );

wss.on("connection", (ws) => {
  ws.on("message", (data) => {
    const request = JSON.parse(data);
    if (request.action === "subscribeToSessionInvalidation") 
      const  sessionId  = request.args;
      subscribeToSessionInvalidation(sessionId, () => 
        ws.send(
          JSON.stringify(
            event: "sessionInvalidated",
            args: 
              sessionId,
            ,
          )
        );
      );
    
  });
});

Este código le dice al servidor que escuche las conexiones entrantes de Web Socket en el puerto 9001. Para cada nueva conexión, escuche los mensajes y adopte el siguiente formato:


  action: "action ID",
  args: ...
Si el

action

el valor es

"subscribeToSessionInvalidation"

, notifique a ese cliente cada vez que se invalide la ID de sesión especificada.

Nota: esta solución requiere generar ID de sesión que sean difíciles de adivinar.

También necesitamos actualizar nuestro

logIn

controlador de ruta para detectar sesiones existentes y publicar el evento de invalidación:

app.get("/login", (req, res) => 
  const  user  = req.headers;

  if (!user) 
    res.status(400).send("error: request must include the 'user' HTTP header");
   else 
    const existingSession = sessions[user];
    if (existingSession) 
      publishSessionInvalidation(existingSession);
    
    const sessionId = generateSessionId();
    sessions[user] = sessionId;

    res.send( sessionId );
  
);
subscribeToSessionInvalidation

y

publishSessionInvalidation

:

const  EventEmitter  = require("events");
const sessionEvents = new EventEmitter();

const SESSION_INVALIDATED = "session_invalidated";

function publishSessionInvalidation(sessionId) 
  sessionEvents.emit(SESSION_INVALIDATED, sessionId);


function subscribeToSessionInvalidation(sessionId, callback) 
  const listener = (invalidatedSessionId) => 
    if (sessionId === invalidatedSessionId) 
      sessionEvents.removeListener(SESSION_INVALIDATED, listener);
      callback();
    
  ;

  sessionEvents.addListener(SESSION_INVALIDATED, listener);


module.exports = 
  publishSessionInvalidation,
  subscribeToSessionInvalidation,
;
Ahora estamos listos para actualizar el cliente para usar el WebSocket DOM API para reemplazar nuestra lógica de sondeo:
async function logIn(userId, onSessionInvalidated) 
  const response = await fetch("http://localhost:9000/login", 
    headers: 
      user: userId,
    ,
  );
  const  sessionId  = await response.json();

  const socket = new WebSocket("ws://localhost:9001");
  socket.addEventListener("open", () => 
    console.log("connected.");
    socket.addEventListener("message", ( data ) => 
      const  event, args  = JSON.parse(data);
      if (event === "sessionInvalidated") 
        // args.sessionId should equal sessionId
        onSessionInvalidated();
      
    );
    socket.send(
      JSON.stringify(
        action: "subscribeToSessionInvalidation",
        args: 
          sessionId,
        ,
      )
    );
  );

  socket.addEventListener("error", (error) => 
    console.error(error);
  );

  return sessionId;
Carga

/push/index.html

en tu navegador y pruébalo. Ahora debería ver alguna acción de invalidación de sesión en tiempo real.

4. Escalado

Apuesto a que te diste cuenta de que esta solución no escala. ¡Es mejor que lo arreglemos rápidamente antes de que nuestro VC retire sus fondos! Para crear una versión escalable, necesitamos hacer los siguientes cambios:

  1. Mueva el caché de sesión a un caché distribuido escalable
  2. Pasar del emisor de eventos a un sistema pubsub distribuido escalable
  3. Actualice el cliente para agregar lógica de reintento al desconectar
Redis cumple los requisitos n. ° 1 y n. ° 2. Si necesitamos escalar Redis, podemos implementar un Redis racimo o podemos usar una versión alojada de Redis, como Amazon ElastiCache.

Redis como caché de sesión remota

Primero, hagamos girar una instancia de redis. Si tienes un docker en alguna parte:

docker run -d -p 6739:6739 redis
Asegúrese de que el puerto 6739 esté abierto si está ejecutando esto en una VM en la nube. Si no tiene una VM en la nube, puede iniciar un t2.micro instancias en EC2 como parte del nivel gratuito de AWS. Una vez que se inicia su VM, puede instalar docker.
// remoteCache.js
const redis = require("redis");

const SessionCacheKey = "sessions";

client = redis.createClient(
  host: process.env.REDIS_HOST
);

async function getSession(userId) 
  return new Promise((resolve) => 
    return client.hmget(SessionCacheKey, userId, (err, res) => 
      resolve(res ? (Array.isArray(res) ? res[0] : res) : null);
    );
  );


async function putSession(userId, sessionId) 
  return new Promise((resolve) => 
    client.hmset(SessionCacheKey, userId, sessionId, (err, res) => 
      resolve(res ? (Array.isArray(res) ? res[0] : res) : null);
    );
  );
Usamos los comandos de Redis HMGET y HMSET (HM significa “mapa hash”) para leer y escribir la tupla respectivamente

[user ID, session ID]

. Eso se encarga del almacenamiento de la sesión, aún necesitamos reemplazar el emisor de eventos con Redis. los redis npm docs state:

Cuando un cliente emite SUBSCRIBE o PSUBSCRIBE, esa conexión se pone en modo “suscriptor”. En ese punto, los únicos comandos válidos son aquellos que modifican el conjunto de suscripción y se cierran (también hacen ping en algunas versiones de redis). Cuando el conjunto de suscripción está vacío, la conexión se vuelve a poner en modo normal.

Por lo tanto, necesitamos crear dos clientes Redis, uno para comandos generales y otro para comandos de suscriptor dedicados:

// remoteCache.js

const SessionInvalidationChannel = "sessionInvalidation";
const pendingCallbacks = ;

async function connect() {
  client = redis.createClient(
    host: process.env.REDIS_HOST
  );
  // the redis client we're using works in two modes "normal" and
  // "subscriber". So we duplicate a client here and use that
  // for our subscriptions.
  subscriber = client.duplicate();

  return Promise.all([
    new Promise((resolve) => 
      client.on("ready", () => resolve());
    ),
    new Promise((resolve) => 
      subscriber.on("ready", () => 
        subscriber.on("message", (channel, invalidatedSession) => 
          console.log(channel, invalidatedSession);
          if (Object.keys(pendingCallbacks).includes(invalidatedSession)) 
            pendingCallbacks[invalidatedSession]();
            delete pendingCallbacks[invalidatedSession];
          
        );

        subscriber.subscribe(SessionInvalidationChannel, () => 
          resolve();
        );
      );
    ),
  ]);
}

function publishSessionInvalidation(sessionId) 
  client.publish(SessionInvalidationChannel, sessionId);


function subscribeToSessionInvalidation(sessionId, callback) 
  pendingCallbacks[sessionId] = callback;
En el

connect

función, nos suscribimos a la

"sessionInvalidation" 

canal. Publicamos en este canal cuando otro módulo llama

publishSessionInvalidation

.

Puede ejecutar la demostración de esta manera:

git clone https://github.com/robzhu/logged-out 
cd logged-out/push-redis/server
npm i && node server.js
Siguiente, abierto

/push-redis/index.html

en dos pestañas del navegador y debería poder ver la demostración en funcionamiento.

5. Cliente nativo

Tomemos un momento para considerar aplicaciones de ejemplo que necesitan invalidación de sesión en tiempo real. Algunos que me vienen a la mente: juegos, clientes de transmisión de medios, aplicaciones financieras avanzadas (por ejemplo, terminal Bloomberg).

Dado que este tipo de aplicaciones a menudo se crean como clientes nativos, veamos cómo se ve un cliente .net:

using System;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Websocket.Client;

static class Program
{
  const string LoginEndpoint = "http://localhost:9000/login";
  const string UserID = "1234";
  static Uri WebSocketEndpoint = new Uri("ws://localhost:9001");

  static async Task Main(string[] args)
  
    HttpClient client = new HttpClient();

    client.DefaultRequestHeaders.Add("user", UserID);
    dynamic response = JsonConvert.DeserializeObject(await client.GetStringAsync(LoginEndpoint));
    string sessionId = response.sessionId;
    Console.WriteLine("Obtained session ID: " + sessionId);

    using (var socket = new WebsocketClient(WebSocketEndpoint))
    
      await socket.Start();

      socket.MessageReceived.Subscribe(msg =>
      
        dynamic payload = JsonConvert.DeserializeObject(msg.Text);
        if (payload["event"] == "sessionInvalidated")
        
          Console.WriteLine("You have logged in elsewhere. Exiting.");
          Environment.Exit(0);
        
      );

      socket.Send(JsonConvert.SerializeObject(new
      
        action = "subscribeToSessionInvalidation",
        args = new
        
          sessionId = sessionId
        
      ));

      Console.WriteLine("Press ENTER to exit.");
      Console.ReadLine();
    
  
}
Puede ejecutar el cliente .net y el cliente web uno al lado del otro y ver cómo se invalidan entre sí.

De las muchas asperezas en la demostración, me destaca la falta de seguridad de tipos alrededor de la API. Específicamente, los nombres de los temas y el esquema para la solicitud de suscripción y la respuesta. Escalar esta solución más allá de un desarrollador requeriría documentación completa o un sistema de tipo cliente-servidor, como un esquema GraphQL.

Durante el desarrollo de esta demostración, la gente ha sugerido varias otras soluciones:

  1. SWR (gracias @pacocoursey)
  2. Pubsub-as-a-service: arribista y pubnub
  3. Web Sockets con API Gateway
  4. Suscripciones GraphQL

¿Te gustaría ver una demostración de esas soluciones?

Espero que este artículo te haya dado algunas ideas para construir la invalidación de sesión en tiempo real. Estoy seguro de que hay excelentes soluciones que no he considerado, déjelas a continuación en los comentarios.

Comentarios

Etiquetas

La pancarta de noonificación

¡Suscríbase para obtener su resumen diario de las mejores historias tecnológicas!





Source link