丢失或无效的会话id_让我们来构建它:实时会话无效

丢失或无效的会话id

我们将如何建立一种上述体验?

演示回购

某些应用程序需要将用户限制为单个客户端或浏览器实例。 这篇文章介绍了如何构建,改进和扩展此功能。 我们从具有两个API端点的简单Web应用开始:

用户通过将用户HTTP请求标头中的用户ID发送到/ login路由来登录。 这是一个示例请求/响应:

curl -H"user:user123" localhost:9000/login
{ "sessionId" : "364rl8" }

用户将sessionid=364rl8添加为路由/api的HTTP标头。 如果会话ID有效,则服务器返回“已认证”,否则,服务器返回错误:

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

curl -H "sessionid=badSession" localhost:9000/api
error: invalid session

注意:我们的示例在HTTP响应主体中返回会话ID,但是在实践中将会话ID存储为cookie更为常见,在该服务器中服务器返回Set-Cookie: sessionid=364rl8 HTTP标头。

这将导致浏览器自动将会话ID包含在对同一域的所有后续请求中。

1.最简单的解决方案

最简单的解决方案是使用服务器端会话缓存,该缓存为每个用户ID生成并存储会话ID。

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} ` );
});

每当用户成功登录时,会话ID都会被覆盖。 包含过期会话ID的请求将无法通过验证,从而导致服务器返回错误。 但是,如果客户端未发出API请求,则用户将不知道该会话无效。 理想情况下,我们需要一个客户端函数来告诉我们会话何时不再有效:

async function logIn ( userId, onSessionInvalidated )

logIn函数采用一个回调函数(作为第二个参数),只要我们检测到该会话不再有效,就会调用该函数。 我们可以通过两种方式实现此API:轮询和服务器推送。

2.轮询

最简单的解决方案是使用服务器端会话缓存,该缓存为每个用户ID生成并存储会话ID。

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;
}

但是,轮询迫使我们在延迟和效率之间进行权衡。 轮询间隔越短,我们可以更快地检测到不良会话,而浪费的轮询则更多。

3.服务器推送

如果我们在轮询解决方案中遇到瓶颈,那么我们的最终解决方案是维护一个持久的双向通道,服务器可以在该通道上告知连接的客户端会话无效。 对于此演示,我们将使用Web Sockets 。 要托管Web Socket服务器,我们使用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,
            },
          })
        );
      });
    }
  });
});

此代码告诉服务器在端口9001上侦听传入的Web套接字连接。对于每个新连接,侦听消息并采用以下格式:

{action : "action ID" ,
  args : {...}
}

如果action值为"subscribeToSessionInvalidation" ,则每当指定的会话ID无效时,通知该客户端。

注意:此解决方案需要生成难以猜测的会话ID。

我们还需要更新我们的logIn路由处理程序以检测现有会话并发布无效事件:

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 });
  }
});

subscribeToSessionInvalidationpublishSessionInvalidation

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,
};

现在,我们准备更新客户端以使用WebSocket DOM API替换我们的轮询逻辑:

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;
}

在浏览器中加载/push/index.html ,然后尝试一下。 现在,您应该看到一些实时会话无效操作。

4.缩放

我敢打赌,您注意到此解决方案无法扩展。 我们最好在风险投资获得资金之前尽快解决该问题! 要创建可伸缩的版本,我们需要进行以下更改:

  1. 将会话缓存移至可扩展的分布式缓存
  2. 从事件发射器转移到可扩展的分布式pubsub系统
  3. 更新客户端以在断开连接时添加重试逻辑

Redis满足要求#1和#2。 如果需要扩展Redis,我们可以部署Redis 集群 ,也可以使用Redis的托管版本,例如Amazon ElastiCache

Redis作为远程会话缓存

首先,让我们启动一个redis实例。 如果您在某处有码头工人:

docker run -d -p6739 : 6739 redis

如果要在云VM上运行,请确保端口6739是打开的。 如果没有云虚拟机,则可以在EC2上启动t2.micro实例作为AWS免费套餐的一部分。 启动虚拟机后,您可以安装docker

很多 文章讨论了Redis的会话缓存。 这是我的方法,使用redis npm包

// 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 );
    });
  });
}

我们使用Redis命令HMGETHMSET (HM代表“哈希映射”)分别读取和写入元组[user ID, session ID] 。 这样就可以处理会话存储,我们仍然需要用Redis替换事件发射器。 Redis NPM文档说明:

当客户端发出SUBSCRIBE或PSUBSCRIBE时,该连接将进入“订户”模式。 那时,唯一有效的命令是那些修改订阅集并退出的命令(也可以在某些redis版本上ping通)。 当订阅集为空时,连接将恢复为常规模式。

因此,我们需要创建两个Redis客户端,一个用于常规命令,另一个用于专用订户命令:

// 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;
}

connect函数中,我们订阅了"sessionInvalidation"通道。 当另一个模块调用publishSessionInvalidation时,我们将发布到此频道。

您可以像这样运行演示:

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

接下来,在两个浏览器选项卡中打开/push-redis/index.html ,您应该能够看到正在运行的演示。

5.本机客户端

让我们花点时间考虑需要实时会话失效的示例应用程序。 我想到的一些东西是:游戏,流媒体客户端,高级金融应用程序(例如Bloomberg Terminal)。

由于这类应用程序通常是作为本机客户端构建的,因此让我们看看.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();
    }
  }
}

您可以并行运行.net客户端和Web客户端,并观察它们彼此失效。

在该演示的许多粗糙之处中,API缺乏类型安全性对我来说很突出。 具体来说,订阅请求和响应的主题名称和架构。 将解决方案扩展到一个开发人员之外,将需要全面的文档或客户端-服务器类型的系统,例如GraphQL模式。

在构建此演示的过程中,人们提出了其他几种解决方案:

  1. SWR感谢@pacocoursey
  2. Pubsub即服务: pusherpubnub
  3. 具有API网关的Web套接字
  4. GraphQL订阅

您想看一下这些解决方案的演示吗?

我希望本文能为您提供一些构建实时会话无效的想法。 我敢肯定,我还没有考虑过很好的解决方案,请在评论中将其保留在下面。

先前发布在 https://updateloop.dev/lets-build-you-have-been-logged-out/

翻译自: https://hackernoon.com/lets-build-it-real-time-session-invalidation-wnaa3vmg

丢失或无效的会话id

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值