丢失或无效的会话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 });
}
});
subscribeToSessionInvalidation
和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,
};
现在,我们准备更新客户端以使用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.缩放
我敢打赌,您注意到此解决方案无法扩展。 我们最好在风险投资获得资金之前尽快解决该问题! 要创建可伸缩的版本,我们需要进行以下更改:
- 将会话缓存移至可扩展的分布式缓存
- 从事件发射器转移到可扩展的分布式pubsub系统
- 更新客户端以在断开连接时添加重试逻辑
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命令HMGET和HMSET (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模式。
在构建此演示的过程中,人们提出了其他几种解决方案:
- SWR ( 感谢@pacocoursey )
- Pubsub即服务: pusher和pubnub
- 具有API网关的Web套接字
- GraphQL订阅
您想看一下这些解决方案的演示吗?
我希望本文能为您提供一些构建实时会话无效的想法。 我敢肯定,我还没有考虑过很好的解决方案,请在评论中将其保留在下面。
先前发布在 https://updateloop.dev/lets-build-you-have-been-logged-out/
翻译自: https://hackernoon.com/lets-build-it-real-time-session-invalidation-wnaa3vmg
丢失或无效的会话id