@strapi-community/plugin-io 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -770,6 +770,130 @@ Configure permissions in the Strapi admin panel:
770
770
 
771
771
  ---
772
772
 
773
+ ## Security
774
+
775
+ The plugin implements multiple security layers to protect your real-time connections.
776
+
777
+ ### Admin Session Tokens
778
+
779
+ For admin panel connections (Live Presence), the plugin uses secure session tokens:
780
+
781
+ ```
782
+ +------------------+ +------------------+ +------------------+
783
+ | Admin Browser | ---> | Session Endpoint| ---> | Socket.IO |
784
+ | (Strapi Admin) | | /io/presence/ | | Server |
785
+ +------------------+ +------------------+ +------------------+
786
+ | | |
787
+ | 1. Request session | |
788
+ | (Admin JWT in header) | |
789
+ +------------------------>| |
790
+ | | |
791
+ | 2. Return session token | |
792
+ | (UUID, 10 min TTL) | |
793
+ |<------------------------+ |
794
+ | | |
795
+ | 3. Connect Socket.IO | |
796
+ | (Session token in auth) | |
797
+ +-------------------------------------------------->|
798
+ | | |
799
+ | 4. Validate & connect | |
800
+ |<--------------------------------------------------+
801
+ ```
802
+
803
+ **Security Features:**
804
+ - **Token Hashing**: Tokens stored as SHA-256 hashes (plaintext never persisted)
805
+ - **Short TTL**: 10-minute expiration with automatic refresh at 70%
806
+ - **Usage Limits**: Max 10 reconnects per token to prevent replay attacks
807
+ - **Rate Limiting**: 30-second cooldown between token requests
808
+ - **Minimal Data**: Only essential user info stored (ID, firstname, lastname)
809
+
810
+ ### Rate Limiting
811
+
812
+ Prevent abuse with configurable rate limits:
813
+
814
+ ```javascript
815
+ // In config/plugins.js
816
+ module.exports = {
817
+ io: {
818
+ enabled: true,
819
+ config: {
820
+ security: {
821
+ rateLimit: {
822
+ enabled: true,
823
+ maxEventsPerSecond: 10, // Max events per socket per second
824
+ maxConnectionsPerIp: 50 // Max connections from single IP
825
+ }
826
+ }
827
+ }
828
+ }
829
+ };
830
+ ```
831
+
832
+ ### IP Whitelisting/Blacklisting
833
+
834
+ Restrict access by IP address:
835
+
836
+ ```javascript
837
+ // In config/plugins.js
838
+ module.exports = {
839
+ io: {
840
+ enabled: true,
841
+ config: {
842
+ security: {
843
+ ipWhitelist: ['192.168.1.0/24', '10.0.0.1'], // Only these IPs allowed
844
+ ipBlacklist: ['203.0.113.50'], // These IPs blocked
845
+ requireAuthentication: true // Require JWT/API token
846
+ }
847
+ }
848
+ }
849
+ };
850
+ ```
851
+
852
+ ### Security Monitoring API
853
+
854
+ Monitor active sessions via admin API:
855
+
856
+ ```bash
857
+ # Get session statistics
858
+ GET /io/security/sessions
859
+ Authorization: Bearer <admin-jwt>
860
+
861
+ # Response:
862
+ {
863
+ "data": {
864
+ "activeSessions": 5,
865
+ "expiringSoon": 1,
866
+ "activeSocketConnections": 3,
867
+ "sessionTTL": 600000,
868
+ "refreshCooldown": 30000
869
+ }
870
+ }
871
+
872
+ # Force logout a user (invalidate all their sessions)
873
+ POST /io/security/invalidate/:userId
874
+ Authorization: Bearer <admin-jwt>
875
+
876
+ # Response:
877
+ {
878
+ "data": {
879
+ "userId": 1,
880
+ "invalidatedSessions": 2,
881
+ "message": "Successfully invalidated 2 session(s)"
882
+ }
883
+ }
884
+ ```
885
+
886
+ ### Best Practices
887
+
888
+ 1. **Always use HTTPS** in production for encrypted WebSocket connections
889
+ 2. **Enable authentication** for sensitive content types
890
+ 3. **Configure CORS** to only allow your frontend domains
891
+ 4. **Monitor connections** via the admin dashboard
892
+ 5. **Set reasonable rate limits** based on your use case
893
+ 6. **Review access logs** periodically for suspicious activity
894
+
895
+ ---
896
+
773
897
  ## Admin Panel
774
898
 
775
899
  The plugin provides a full admin interface for configuration and monitoring.
@@ -1327,6 +1451,9 @@ Copyright (c) 2024 Strapi Community
1327
1451
  - **Admin Panel Sidebar** - Live presence panel integrated into edit view
1328
1452
  - **Admin Session Authentication** - Secure session tokens for Socket.IO
1329
1453
  - **Admin JWT Strategy** - New authentication strategy for admin users
1454
+ - **Enhanced Security** - Token hashing (SHA-256), usage limits, rate limiting
1455
+ - **Automatic Token Refresh** - Tokens auto-refresh at 70% of TTL
1456
+ - **Security Monitoring API** - Session stats and force-logout endpoints
1330
1457
 
1331
1458
  ### v5.0.0
1332
1459
  - Strapi v5 support
@@ -7,7 +7,7 @@ const designSystem = require("@strapi/design-system");
7
7
  const admin = require("@strapi/strapi/admin");
8
8
  const styled = require("styled-components");
9
9
  const socket_ioClient = require("socket.io-client");
10
- const index$1 = require("./index--2NeIKGR.js");
10
+ const index$1 = require("./index-BEZDDgvZ.js");
11
11
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
12
12
  const styled__default = /* @__PURE__ */ _interopDefault(styled);
13
13
  const pulse = styled.keyframes`
@@ -181,19 +181,46 @@ const LivePresencePanel = ({ documentId, model, document }) => {
181
181
  return;
182
182
  }
183
183
  let cancelled = false;
184
- const getSession = async () => {
184
+ let refreshTimeoutId = null;
185
+ const getSession = async (isRefresh = false) => {
185
186
  try {
186
- setPresenceState((prev) => ({ ...prev, status: "requesting" }));
187
+ if (!isRefresh) {
188
+ setPresenceState((prev) => ({ ...prev, status: "requesting" }));
189
+ }
187
190
  const { data } = await post(`/${index$1.PLUGIN_ID}/presence/session`, {});
188
191
  if (cancelled) return;
189
192
  if (!data || !data.token) {
190
193
  throw new Error("Invalid session response");
191
194
  }
192
- console.log(`[${index$1.PLUGIN_ID}] Presence session obtained for:`, data.user?.email);
195
+ console.log(`[${index$1.PLUGIN_ID}] Session ${isRefresh ? "refreshed" : "obtained"}:`, {
196
+ expiresIn: Math.round((data.expiresAt - Date.now()) / 1e3) + "s",
197
+ refreshAfter: Math.round((data.refreshAfter - Date.now()) / 1e3) + "s"
198
+ });
193
199
  setSessionData(data);
194
- setPresenceState((prev) => ({ ...prev, status: "connecting" }));
200
+ if (!isRefresh) {
201
+ setPresenceState((prev) => ({ ...prev, status: "connecting" }));
202
+ }
203
+ if (data.refreshAfter) {
204
+ const refreshIn = data.refreshAfter - Date.now();
205
+ if (refreshIn > 0) {
206
+ console.log(`[${index$1.PLUGIN_ID}] Token refresh scheduled in ${Math.round(refreshIn / 1e3)}s`);
207
+ refreshTimeoutId = setTimeout(() => {
208
+ if (!cancelled) {
209
+ console.log(`[${index$1.PLUGIN_ID}] Refreshing session token...`);
210
+ getSession(true);
211
+ }
212
+ }, refreshIn);
213
+ }
214
+ }
195
215
  } catch (error2) {
196
216
  if (cancelled) return;
217
+ if (error2.response?.status === 429) {
218
+ console.warn(`[${index$1.PLUGIN_ID}] Rate limited, retrying in 30s...`);
219
+ refreshTimeoutId = setTimeout(() => {
220
+ if (!cancelled) getSession(isRefresh);
221
+ }, 3e4);
222
+ return;
223
+ }
197
224
  console.error(`[${index$1.PLUGIN_ID}] Failed to get presence session:`, error2);
198
225
  setPresenceState((prev) => ({
199
226
  ...prev,
@@ -205,6 +232,9 @@ const LivePresencePanel = ({ documentId, model, document }) => {
205
232
  getSession();
206
233
  return () => {
207
234
  cancelled = true;
235
+ if (refreshTimeoutId) {
236
+ clearTimeout(refreshTimeoutId);
237
+ }
208
238
  };
209
239
  }, [uid, documentId, post]);
210
240
  React.useEffect(() => {
@@ -5,7 +5,7 @@ import { Flex } from "@strapi/design-system";
5
5
  import { useFetchClient } from "@strapi/strapi/admin";
6
6
  import styled, { css, keyframes } from "styled-components";
7
7
  import { io } from "socket.io-client";
8
- import { P as PLUGIN_ID } from "./index-CzvX8YTe.mjs";
8
+ import { P as PLUGIN_ID } from "./index-Dof_eA3e.mjs";
9
9
  const pulse = keyframes`
10
10
  0%, 100% {
11
11
  box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2);
@@ -177,19 +177,46 @@ const LivePresencePanel = ({ documentId, model, document }) => {
177
177
  return;
178
178
  }
179
179
  let cancelled = false;
180
- const getSession = async () => {
180
+ let refreshTimeoutId = null;
181
+ const getSession = async (isRefresh = false) => {
181
182
  try {
182
- setPresenceState((prev) => ({ ...prev, status: "requesting" }));
183
+ if (!isRefresh) {
184
+ setPresenceState((prev) => ({ ...prev, status: "requesting" }));
185
+ }
183
186
  const { data } = await post(`/${PLUGIN_ID}/presence/session`, {});
184
187
  if (cancelled) return;
185
188
  if (!data || !data.token) {
186
189
  throw new Error("Invalid session response");
187
190
  }
188
- console.log(`[${PLUGIN_ID}] Presence session obtained for:`, data.user?.email);
191
+ console.log(`[${PLUGIN_ID}] Session ${isRefresh ? "refreshed" : "obtained"}:`, {
192
+ expiresIn: Math.round((data.expiresAt - Date.now()) / 1e3) + "s",
193
+ refreshAfter: Math.round((data.refreshAfter - Date.now()) / 1e3) + "s"
194
+ });
189
195
  setSessionData(data);
190
- setPresenceState((prev) => ({ ...prev, status: "connecting" }));
196
+ if (!isRefresh) {
197
+ setPresenceState((prev) => ({ ...prev, status: "connecting" }));
198
+ }
199
+ if (data.refreshAfter) {
200
+ const refreshIn = data.refreshAfter - Date.now();
201
+ if (refreshIn > 0) {
202
+ console.log(`[${PLUGIN_ID}] Token refresh scheduled in ${Math.round(refreshIn / 1e3)}s`);
203
+ refreshTimeoutId = setTimeout(() => {
204
+ if (!cancelled) {
205
+ console.log(`[${PLUGIN_ID}] Refreshing session token...`);
206
+ getSession(true);
207
+ }
208
+ }, refreshIn);
209
+ }
210
+ }
191
211
  } catch (error2) {
192
212
  if (cancelled) return;
213
+ if (error2.response?.status === 429) {
214
+ console.warn(`[${PLUGIN_ID}] Rate limited, retrying in 30s...`);
215
+ refreshTimeoutId = setTimeout(() => {
216
+ if (!cancelled) getSession(isRefresh);
217
+ }, 3e4);
218
+ return;
219
+ }
193
220
  console.error(`[${PLUGIN_ID}] Failed to get presence session:`, error2);
194
221
  setPresenceState((prev) => ({
195
222
  ...prev,
@@ -201,6 +228,9 @@ const LivePresencePanel = ({ documentId, model, document }) => {
201
228
  getSession();
202
229
  return () => {
203
230
  cancelled = true;
231
+ if (refreshTimeoutId) {
232
+ clearTimeout(refreshTimeoutId);
233
+ }
204
234
  };
205
235
  }, [uid, documentId, post]);
206
236
  useEffect(() => {
@@ -6,7 +6,7 @@ const admin = require("@strapi/strapi/admin");
6
6
  const styled = require("styled-components");
7
7
  const designSystem = require("@strapi/design-system");
8
8
  const index = require("./index-DkTxsEqL.js");
9
- const index$1 = require("./index--2NeIKGR.js");
9
+ const index$1 = require("./index-BEZDDgvZ.js");
10
10
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
11
11
  const styled__default = /* @__PURE__ */ _interopDefault(styled);
12
12
  const UsersIcon = () => /* @__PURE__ */ jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", children: /* @__PURE__ */ jsxRuntime.jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" }) });
@@ -4,7 +4,7 @@ import { useFetchClient, useNotification } from "@strapi/strapi/admin";
4
4
  import styled, { css, keyframes } from "styled-components";
5
5
  import { Box, Loader, Flex, Field, TextInput, Badge, Typography } from "@strapi/design-system";
6
6
  import { u as useIntl } from "./index-CEh8vkxY.mjs";
7
- import { P as PLUGIN_ID } from "./index-CzvX8YTe.mjs";
7
+ import { P as PLUGIN_ID } from "./index-Dof_eA3e.mjs";
8
8
  const UsersIcon = () => /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" }) });
9
9
  const BoltIcon = () => /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" }) });
10
10
  const ChartBarIcon = () => /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" }) });
@@ -5,7 +5,7 @@ import { Download, Upload, Check } from "@strapi/icons";
5
5
  import { useFetchClient, useNotification } from "@strapi/strapi/admin";
6
6
  import { u as useIntl } from "./index-CEh8vkxY.mjs";
7
7
  import styled from "styled-components";
8
- import { P as PLUGIN_ID } from "./index-CzvX8YTe.mjs";
8
+ import { P as PLUGIN_ID } from "./index-Dof_eA3e.mjs";
9
9
  const ResponsiveMain = styled(Main)`
10
10
  & > div {
11
11
  padding: 16px !important;
@@ -7,7 +7,7 @@ const icons = require("@strapi/icons");
7
7
  const admin = require("@strapi/strapi/admin");
8
8
  const index = require("./index-DkTxsEqL.js");
9
9
  const styled = require("styled-components");
10
- const index$1 = require("./index--2NeIKGR.js");
10
+ const index$1 = require("./index-BEZDDgvZ.js");
11
11
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
12
12
  const styled__default = /* @__PURE__ */ _interopDefault(styled);
13
13
  const ResponsiveMain = styled__default.default(designSystem.Main)`
@@ -51,7 +51,7 @@ const index = {
51
51
  },
52
52
  id: `${PLUGIN_ID}-settings`,
53
53
  to: `${PLUGIN_ID}/settings`,
54
- Component: () => Promise.resolve().then(() => require("./SettingsPage-4OkXJAjU.js")).then((mod) => ({ default: mod.SettingsPage }))
54
+ Component: () => Promise.resolve().then(() => require("./SettingsPage-CsRazf0j.js")).then((mod) => ({ default: mod.SettingsPage }))
55
55
  },
56
56
  {
57
57
  intlLabel: {
@@ -60,7 +60,7 @@ const index = {
60
60
  },
61
61
  id: `${PLUGIN_ID}-monitoring`,
62
62
  to: `${PLUGIN_ID}/monitoring`,
63
- Component: () => Promise.resolve().then(() => require("./MonitoringPage-K5Y3hhKF.js")).then((mod) => ({ default: mod.MonitoringPage }))
63
+ Component: () => Promise.resolve().then(() => require("./MonitoringPage-9f4Gzd2X.js")).then((mod) => ({ default: mod.MonitoringPage }))
64
64
  }
65
65
  ]
66
66
  );
@@ -84,7 +84,7 @@ const index = {
84
84
  async bootstrap(app) {
85
85
  console.log(`[${PLUGIN_ID}] [INFO] Bootstrapping plugin...`);
86
86
  try {
87
- const { default: LivePresencePanel } = await Promise.resolve().then(() => require("./LivePresencePanel-CNaEK-Gk.js"));
87
+ const { default: LivePresencePanel } = await Promise.resolve().then(() => require("./LivePresencePanel-2U3I3yL0.js"));
88
88
  const contentManagerPlugin = app.getPlugin("content-manager");
89
89
  if (contentManagerPlugin && contentManagerPlugin.apis) {
90
90
  contentManagerPlugin.apis.addEditViewSidePanel([LivePresencePanel]);
@@ -50,7 +50,7 @@ const index = {
50
50
  },
51
51
  id: `${PLUGIN_ID}-settings`,
52
52
  to: `${PLUGIN_ID}/settings`,
53
- Component: () => import("./SettingsPage-DMbMGU6J.mjs").then((mod) => ({ default: mod.SettingsPage }))
53
+ Component: () => import("./SettingsPage-Btz_5MuC.mjs").then((mod) => ({ default: mod.SettingsPage }))
54
54
  },
55
55
  {
56
56
  intlLabel: {
@@ -59,7 +59,7 @@ const index = {
59
59
  },
60
60
  id: `${PLUGIN_ID}-monitoring`,
61
61
  to: `${PLUGIN_ID}/monitoring`,
62
- Component: () => import("./MonitoringPage-Bn9XJSlg.mjs").then((mod) => ({ default: mod.MonitoringPage }))
62
+ Component: () => import("./MonitoringPage-Bbkoh6ih.mjs").then((mod) => ({ default: mod.MonitoringPage }))
63
63
  }
64
64
  ]
65
65
  );
@@ -83,7 +83,7 @@ const index = {
83
83
  async bootstrap(app) {
84
84
  console.log(`[${PLUGIN_ID}] [INFO] Bootstrapping plugin...`);
85
85
  try {
86
- const { default: LivePresencePanel } = await import("./LivePresencePanel-BeNq_EnQ.mjs");
86
+ const { default: LivePresencePanel } = await import("./LivePresencePanel-CIFG_05s.mjs");
87
87
  const contentManagerPlugin = app.getPlugin("content-manager");
88
88
  if (contentManagerPlugin && contentManagerPlugin.apis) {
89
89
  contentManagerPlugin.apis.addEditViewSidePanel([LivePresencePanel]);
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index--2NeIKGR.js");
2
+ const index = require("../_chunks/index-BEZDDgvZ.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-CzvX8YTe.mjs";
1
+ import { i } from "../_chunks/index-Dof_eA3e.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -350,31 +350,56 @@ async function bootstrapIO$1({ strapi: strapi2 }) {
350
350
  return next(new Error("Max connections reached"));
351
351
  }
352
352
  const token = socket.handshake.auth?.token || socket.handshake.query?.token;
353
+ const strategy2 = socket.handshake.auth?.strategy;
354
+ const isAdmin = socket.handshake.auth?.isAdmin === true;
353
355
  if (token) {
354
- try {
355
- const decoded = await strapi2.plugin("users-permissions").service("jwt").verify(token);
356
- strapi2.log.info(`socket.io: JWT decoded - user id: ${decoded.id}`);
357
- if (decoded.id) {
358
- const users = await strapi2.documents("plugin::users-permissions.user").findMany({
359
- filters: { id: decoded.id },
360
- populate: { role: true },
361
- limit: 1
362
- });
363
- const user = users.length > 0 ? users[0] : null;
364
- if (user) {
356
+ if (isAdmin || strategy2 === "admin-jwt") {
357
+ try {
358
+ const presenceController = strapi2.plugin(pluginId$6).controller("presence");
359
+ const session = presenceController.consumeSessionToken(token);
360
+ if (session) {
365
361
  socket.user = {
366
- id: user.id,
367
- username: user.username,
368
- email: user.email,
369
- role: user.role?.name || "authenticated"
362
+ id: session.userId,
363
+ username: `${session.user.firstname || ""} ${session.user.lastname || ""}`.trim() || `Admin ${session.userId}`,
364
+ email: session.user.email || `admin-${session.userId}`,
365
+ role: "strapi-super-admin",
366
+ isAdmin: true
370
367
  };
371
- strapi2.log.info(`socket.io: User authenticated - ${user.username} (${user.email})`);
368
+ socket.adminUser = session.user;
369
+ presenceController.registerSocket(socket.id, token);
370
+ strapi2.log.info(`socket.io: Admin authenticated - ${socket.user.username} (ID: ${session.userId})`);
372
371
  } else {
373
- strapi2.log.warn(`socket.io: User not found for id: ${decoded.id}`);
372
+ strapi2.log.warn(`socket.io: Admin session token invalid or expired`);
374
373
  }
374
+ } catch (err) {
375
+ strapi2.log.warn(`socket.io: Admin session verification failed: ${err.message}`);
376
+ }
377
+ } else {
378
+ try {
379
+ const decoded = await strapi2.plugin("users-permissions").service("jwt").verify(token);
380
+ strapi2.log.info(`socket.io: JWT decoded - user id: ${decoded.id}`);
381
+ if (decoded.id) {
382
+ const users = await strapi2.documents("plugin::users-permissions.user").findMany({
383
+ filters: { id: decoded.id },
384
+ populate: { role: true },
385
+ limit: 1
386
+ });
387
+ const user = users.length > 0 ? users[0] : null;
388
+ if (user) {
389
+ socket.user = {
390
+ id: user.id,
391
+ username: user.username,
392
+ email: user.email,
393
+ role: user.role?.name || "authenticated"
394
+ };
395
+ strapi2.log.info(`socket.io: User authenticated - ${user.username} (${user.email})`);
396
+ } else {
397
+ strapi2.log.warn(`socket.io: User not found for id: ${decoded.id}`);
398
+ }
399
+ }
400
+ } catch (err) {
401
+ strapi2.log.warn(`socket.io: JWT verification failed: ${err.message}`);
375
402
  }
376
- } catch (err) {
377
- strapi2.log.warn(`socket.io: JWT verification failed: ${err.message}`);
378
403
  }
379
404
  } else {
380
405
  strapi2.log.debug(`socket.io: No token provided, connecting as public`);
@@ -690,6 +715,13 @@ async function bootstrapIO$1({ strapi: strapi2 }) {
690
715
  if (settings2.livePreview?.enabled !== false) {
691
716
  previewService.cleanupSocket(socket.id);
692
717
  }
718
+ try {
719
+ const presenceController = strapi2.plugin(pluginId$6).controller("presence");
720
+ if (presenceController?.unregisterSocket) {
721
+ presenceController.unregisterSocket(socket.id);
722
+ }
723
+ } catch (e) {
724
+ }
693
725
  });
694
726
  socket.on("error", (error2) => {
695
727
  strapi2.log.error(`socket.io: Socket error (id: ${socket.id}): ${error2.message}`);
@@ -1311,19 +1343,38 @@ var settings$3 = ({ strapi: strapi2 }) => ({
1311
1343
  };
1312
1344
  }
1313
1345
  });
1314
- const { randomUUID } = require$$1__default.default;
1346
+ const { randomUUID, createHash } = require$$1__default.default;
1315
1347
  const sessionTokens = /* @__PURE__ */ new Map();
1348
+ const activeSockets = /* @__PURE__ */ new Map();
1349
+ const refreshThrottle = /* @__PURE__ */ new Map();
1350
+ const SESSION_TTL = 10 * 60 * 1e3;
1351
+ const REFRESH_COOLDOWN = 30 * 1e3;
1352
+ const CLEANUP_INTERVAL = 2 * 60 * 1e3;
1353
+ const hashToken = (token) => {
1354
+ return createHash("sha256").update(token).digest("hex");
1355
+ };
1316
1356
  setInterval(() => {
1317
1357
  const now = Date.now();
1318
- for (const [token, session] of sessionTokens.entries()) {
1358
+ let cleaned = 0;
1359
+ for (const [tokenHash, session] of sessionTokens.entries()) {
1319
1360
  if (session.expiresAt < now) {
1320
- sessionTokens.delete(token);
1361
+ sessionTokens.delete(tokenHash);
1362
+ cleaned++;
1321
1363
  }
1322
1364
  }
1323
- }, 5 * 60 * 1e3);
1365
+ for (const [userId, lastRefresh] of refreshThrottle.entries()) {
1366
+ if (now - lastRefresh > 60 * 60 * 1e3) {
1367
+ refreshThrottle.delete(userId);
1368
+ }
1369
+ }
1370
+ if (cleaned > 0) {
1371
+ console.log(`[plugin-io] [CLEANUP] Removed ${cleaned} expired session tokens`);
1372
+ }
1373
+ }, CLEANUP_INTERVAL);
1324
1374
  var presence$3 = ({ strapi: strapi2 }) => ({
1325
1375
  /**
1326
1376
  * Creates a session token for admin users to connect to Socket.IO
1377
+ * Implements rate limiting and secure token storage
1327
1378
  * @param {object} ctx - Koa context
1328
1379
  */
1329
1380
  async createSession(ctx) {
@@ -1332,28 +1383,40 @@ var presence$3 = ({ strapi: strapi2 }) => ({
1332
1383
  strapi2.log.warn("[plugin-io] Presence session requested without admin user");
1333
1384
  return ctx.unauthorized("Admin authentication required");
1334
1385
  }
1386
+ const lastRefresh = refreshThrottle.get(adminUser.id);
1387
+ const now = Date.now();
1388
+ if (lastRefresh && now - lastRefresh < REFRESH_COOLDOWN) {
1389
+ const waitTime = Math.ceil((REFRESH_COOLDOWN - (now - lastRefresh)) / 1e3);
1390
+ strapi2.log.warn(`[plugin-io] Rate limit: User ${adminUser.id} must wait ${waitTime}s`);
1391
+ return ctx.tooManyRequests(`Please wait ${waitTime} seconds before requesting a new session`);
1392
+ }
1335
1393
  try {
1336
1394
  const token = randomUUID();
1337
- const expiresAt = Date.now() + 2 * 60 * 1e3;
1338
- sessionTokens.set(token, {
1339
- token,
1395
+ const tokenHash = hashToken(token);
1396
+ const expiresAt = now + SESSION_TTL;
1397
+ sessionTokens.set(tokenHash, {
1398
+ tokenHash,
1399
+ userId: adminUser.id,
1340
1400
  user: {
1341
1401
  id: adminUser.id,
1342
- email: adminUser.email,
1402
+ // Only store minimal user data needed for display
1343
1403
  firstname: adminUser.firstname,
1344
1404
  lastname: adminUser.lastname
1345
1405
  },
1346
- expiresAt
1406
+ createdAt: now,
1407
+ expiresAt,
1408
+ usageCount: 0,
1409
+ maxUsage: 10
1410
+ // Max reconnects with same token
1347
1411
  });
1348
- strapi2.log.info(`[plugin-io] Presence session created for admin user: ${adminUser.email}`);
1412
+ refreshThrottle.set(adminUser.id, now);
1413
+ strapi2.log.info(`[plugin-io] Presence session created for admin user: ${adminUser.id}`);
1349
1414
  ctx.body = {
1350
1415
  token,
1351
- user: {
1352
- id: adminUser.id,
1353
- email: adminUser.email,
1354
- firstname: adminUser.firstname,
1355
- lastname: adminUser.lastname
1356
- },
1416
+ // Send plaintext token to client (only time it's exposed)
1417
+ expiresAt,
1418
+ refreshAfter: now + SESSION_TTL * 0.7,
1419
+ // Suggest refresh at 70% of TTL
1357
1420
  wsPath: "/socket.io",
1358
1421
  wsUrl: `${ctx.protocol}://${ctx.host}`
1359
1422
  };
@@ -1363,23 +1426,142 @@ var presence$3 = ({ strapi: strapi2 }) => ({
1363
1426
  }
1364
1427
  },
1365
1428
  /**
1366
- * Validates and consumes a session token (one-time use)
1429
+ * Validates a session token and tracks usage
1430
+ * Implements usage limits to prevent token abuse
1367
1431
  * @param {string} token - Session token to validate
1368
1432
  * @returns {object|null} Session data or null if invalid/expired
1369
1433
  */
1370
1434
  consumeSessionToken(token) {
1371
- if (!token) {
1435
+ if (!token || typeof token !== "string") {
1372
1436
  return null;
1373
1437
  }
1374
- const session = sessionTokens.get(token);
1438
+ const tokenHash = hashToken(token);
1439
+ const session = sessionTokens.get(tokenHash);
1375
1440
  if (!session) {
1441
+ strapi2.log.debug("[plugin-io] Token not found in session store");
1376
1442
  return null;
1377
1443
  }
1378
- if (session.expiresAt < Date.now()) {
1379
- sessionTokens.delete(token);
1444
+ const now = Date.now();
1445
+ if (session.expiresAt < now) {
1446
+ sessionTokens.delete(tokenHash);
1447
+ strapi2.log.debug("[plugin-io] Token expired, removed from store");
1380
1448
  return null;
1381
1449
  }
1450
+ if (session.usageCount >= session.maxUsage) {
1451
+ strapi2.log.warn(`[plugin-io] Token usage limit exceeded for user ${session.userId}`);
1452
+ sessionTokens.delete(tokenHash);
1453
+ return null;
1454
+ }
1455
+ session.usageCount++;
1456
+ session.lastUsed = now;
1382
1457
  return session;
1458
+ },
1459
+ /**
1460
+ * Registers a socket as using a specific token
1461
+ * @param {string} socketId - Socket ID
1462
+ * @param {string} token - The token being used
1463
+ */
1464
+ registerSocket(socketId, token) {
1465
+ if (!socketId || !token) return;
1466
+ const tokenHash = hashToken(token);
1467
+ activeSockets.set(socketId, tokenHash);
1468
+ },
1469
+ /**
1470
+ * Unregisters a socket when it disconnects
1471
+ * @param {string} socketId - Socket ID
1472
+ */
1473
+ unregisterSocket(socketId) {
1474
+ activeSockets.delete(socketId);
1475
+ },
1476
+ /**
1477
+ * Invalidates all sessions for a specific user (e.g., on logout)
1478
+ * @param {number} userId - User ID to invalidate
1479
+ * @returns {number} Number of sessions invalidated
1480
+ */
1481
+ invalidateUserSessions(userId) {
1482
+ let invalidated = 0;
1483
+ for (const [tokenHash, session] of sessionTokens.entries()) {
1484
+ if (session.userId === userId) {
1485
+ sessionTokens.delete(tokenHash);
1486
+ invalidated++;
1487
+ }
1488
+ }
1489
+ refreshThrottle.delete(userId);
1490
+ strapi2.log.info(`[plugin-io] Invalidated ${invalidated} sessions for user ${userId}`);
1491
+ return invalidated;
1492
+ },
1493
+ /**
1494
+ * Gets session statistics (for monitoring) - internal method
1495
+ * @returns {object} Session statistics
1496
+ */
1497
+ getSessionStatsInternal() {
1498
+ const now = Date.now();
1499
+ let active = 0;
1500
+ let expiringSoon = 0;
1501
+ for (const session of sessionTokens.values()) {
1502
+ if (session.expiresAt > now) {
1503
+ active++;
1504
+ if (session.expiresAt - now < 2 * 60 * 1e3) {
1505
+ expiringSoon++;
1506
+ }
1507
+ }
1508
+ }
1509
+ return {
1510
+ activeSessions: active,
1511
+ expiringSoon,
1512
+ activeSocketConnections: activeSockets.size,
1513
+ sessionTTL: SESSION_TTL,
1514
+ refreshCooldown: REFRESH_COOLDOWN
1515
+ };
1516
+ },
1517
+ /**
1518
+ * HTTP Handler: Gets session statistics for admin monitoring
1519
+ * @param {object} ctx - Koa context
1520
+ */
1521
+ async getSessionStats(ctx) {
1522
+ const adminUser = ctx.state.user;
1523
+ if (!adminUser) {
1524
+ return ctx.unauthorized("Admin authentication required");
1525
+ }
1526
+ try {
1527
+ const stats = this.getSessionStatsInternal();
1528
+ ctx.body = { data: stats };
1529
+ } catch (error2) {
1530
+ strapi2.log.error("[plugin-io] Failed to get session stats:", error2);
1531
+ return ctx.internalServerError("Failed to get session statistics");
1532
+ }
1533
+ },
1534
+ /**
1535
+ * HTTP Handler: Invalidates all sessions for a specific user
1536
+ * @param {object} ctx - Koa context
1537
+ */
1538
+ async invalidateUserSessionsHandler(ctx) {
1539
+ const adminUser = ctx.state.user;
1540
+ if (!adminUser) {
1541
+ return ctx.unauthorized("Admin authentication required");
1542
+ }
1543
+ const { userId } = ctx.params;
1544
+ if (!userId) {
1545
+ return ctx.badRequest("User ID is required");
1546
+ }
1547
+ try {
1548
+ const userIdNum = parseInt(userId, 10);
1549
+ if (isNaN(userIdNum)) {
1550
+ return ctx.badRequest("Invalid user ID");
1551
+ }
1552
+ const invalidated = this.invalidateUserSessions(userIdNum);
1553
+ strapi2.log.info(`[plugin-io] Admin ${adminUser.id} invalidated ${invalidated} sessions for user ${userIdNum}`);
1554
+ ctx.body = {
1555
+ data: {
1556
+ userId: userIdNum,
1557
+ invalidatedSessions: invalidated,
1558
+ message: `Successfully invalidated ${invalidated} session(s)`
1559
+ }
1560
+ };
1561
+ } catch (error2) {
1562
+ strapi2.log.error("[plugin-io] Failed to invalidate user sessions:", error2);
1563
+ return ctx.internalServerError("Failed to invalidate sessions");
1564
+ }
1383
1565
  }
1384
1566
  });
1385
1567
  const settings$2 = settings$3;
@@ -1471,6 +1653,24 @@ var admin$1 = {
1471
1653
  config: {
1472
1654
  policies: ["admin::isAuthenticatedAdmin"]
1473
1655
  }
1656
+ },
1657
+ // Security: Session statistics
1658
+ {
1659
+ method: "GET",
1660
+ path: "/security/sessions",
1661
+ handler: "presence.getSessionStats",
1662
+ config: {
1663
+ policies: ["admin::isAuthenticatedAdmin"]
1664
+ }
1665
+ },
1666
+ // Security: Invalidate user sessions (force logout)
1667
+ {
1668
+ method: "POST",
1669
+ path: "/security/invalidate/:userId",
1670
+ handler: "presence.invalidateUserSessionsHandler",
1671
+ config: {
1672
+ policies: ["admin::isAuthenticatedAdmin"]
1673
+ }
1474
1674
  }
1475
1675
  ]
1476
1676
  };
@@ -29677,21 +29877,55 @@ var strategies = ({ strapi: strapi2 }) => {
29677
29877
  credentials: function(user) {
29678
29878
  return `${this.name}-${user.id}`;
29679
29879
  },
29680
- authenticate: async function(auth) {
29880
+ /**
29881
+ * Authenticates admin user via session token
29882
+ * @param {object} auth - Auth object containing token
29883
+ * @param {object} socket - Socket instance for registration
29884
+ * @returns {object} User data if authenticated
29885
+ * @throws {UnauthorizedError} If authentication fails
29886
+ */
29887
+ authenticate: async function(auth, socket) {
29681
29888
  const token2 = auth.token;
29682
- if (!token2) {
29889
+ if (!token2 || typeof token2 !== "string") {
29890
+ strapi2.log.warn("[plugin-io] Admin auth failed: No token provided");
29683
29891
  throw new UnauthorizedError2("Invalid admin credentials");
29684
29892
  }
29893
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
29894
+ if (!uuidRegex.test(token2)) {
29895
+ strapi2.log.warn("[plugin-io] Admin auth failed: Invalid token format");
29896
+ throw new UnauthorizedError2("Invalid token format");
29897
+ }
29685
29898
  try {
29686
29899
  const presenceController = strapi2.plugin("io").controller("presence");
29687
29900
  const session = presenceController.consumeSessionToken(token2);
29688
29901
  if (!session) {
29902
+ strapi2.log.warn("[plugin-io] Admin auth failed: Token not valid or expired");
29689
29903
  throw new UnauthorizedError2("Invalid or expired session token");
29690
29904
  }
29691
- return session.user;
29905
+ if (socket?.id) {
29906
+ presenceController.registerSocket(socket.id, token2);
29907
+ }
29908
+ strapi2.log.info(`[plugin-io] Admin authenticated: User ID ${session.userId}`);
29909
+ return {
29910
+ id: session.userId,
29911
+ ...session.user
29912
+ };
29692
29913
  } catch (error2) {
29693
- strapi2.log.warn("[plugin-io] Admin session verification failed:", error2.message);
29694
- throw new UnauthorizedError2("Invalid admin credentials");
29914
+ if (error2 instanceof UnauthorizedError2) {
29915
+ throw error2;
29916
+ }
29917
+ strapi2.log.error("[plugin-io] Admin session verification error:", error2.message);
29918
+ throw new UnauthorizedError2("Authentication failed");
29919
+ }
29920
+ },
29921
+ /**
29922
+ * Cleanup when socket disconnects
29923
+ * @param {object} socket - Socket instance
29924
+ */
29925
+ onDisconnect: function(socket) {
29926
+ if (socket?.id) {
29927
+ const presenceController = strapi2.plugin("io").controller("presence");
29928
+ presenceController.unregisterSocket(socket.id);
29695
29929
  }
29696
29930
  },
29697
29931
  getRoomName: function(user) {
@@ -318,31 +318,56 @@ async function bootstrapIO$1({ strapi: strapi2 }) {
318
318
  return next(new Error("Max connections reached"));
319
319
  }
320
320
  const token = socket.handshake.auth?.token || socket.handshake.query?.token;
321
+ const strategy2 = socket.handshake.auth?.strategy;
322
+ const isAdmin = socket.handshake.auth?.isAdmin === true;
321
323
  if (token) {
322
- try {
323
- const decoded = await strapi2.plugin("users-permissions").service("jwt").verify(token);
324
- strapi2.log.info(`socket.io: JWT decoded - user id: ${decoded.id}`);
325
- if (decoded.id) {
326
- const users = await strapi2.documents("plugin::users-permissions.user").findMany({
327
- filters: { id: decoded.id },
328
- populate: { role: true },
329
- limit: 1
330
- });
331
- const user = users.length > 0 ? users[0] : null;
332
- if (user) {
324
+ if (isAdmin || strategy2 === "admin-jwt") {
325
+ try {
326
+ const presenceController = strapi2.plugin(pluginId$6).controller("presence");
327
+ const session = presenceController.consumeSessionToken(token);
328
+ if (session) {
333
329
  socket.user = {
334
- id: user.id,
335
- username: user.username,
336
- email: user.email,
337
- role: user.role?.name || "authenticated"
330
+ id: session.userId,
331
+ username: `${session.user.firstname || ""} ${session.user.lastname || ""}`.trim() || `Admin ${session.userId}`,
332
+ email: session.user.email || `admin-${session.userId}`,
333
+ role: "strapi-super-admin",
334
+ isAdmin: true
338
335
  };
339
- strapi2.log.info(`socket.io: User authenticated - ${user.username} (${user.email})`);
336
+ socket.adminUser = session.user;
337
+ presenceController.registerSocket(socket.id, token);
338
+ strapi2.log.info(`socket.io: Admin authenticated - ${socket.user.username} (ID: ${session.userId})`);
340
339
  } else {
341
- strapi2.log.warn(`socket.io: User not found for id: ${decoded.id}`);
340
+ strapi2.log.warn(`socket.io: Admin session token invalid or expired`);
342
341
  }
342
+ } catch (err) {
343
+ strapi2.log.warn(`socket.io: Admin session verification failed: ${err.message}`);
344
+ }
345
+ } else {
346
+ try {
347
+ const decoded = await strapi2.plugin("users-permissions").service("jwt").verify(token);
348
+ strapi2.log.info(`socket.io: JWT decoded - user id: ${decoded.id}`);
349
+ if (decoded.id) {
350
+ const users = await strapi2.documents("plugin::users-permissions.user").findMany({
351
+ filters: { id: decoded.id },
352
+ populate: { role: true },
353
+ limit: 1
354
+ });
355
+ const user = users.length > 0 ? users[0] : null;
356
+ if (user) {
357
+ socket.user = {
358
+ id: user.id,
359
+ username: user.username,
360
+ email: user.email,
361
+ role: user.role?.name || "authenticated"
362
+ };
363
+ strapi2.log.info(`socket.io: User authenticated - ${user.username} (${user.email})`);
364
+ } else {
365
+ strapi2.log.warn(`socket.io: User not found for id: ${decoded.id}`);
366
+ }
367
+ }
368
+ } catch (err) {
369
+ strapi2.log.warn(`socket.io: JWT verification failed: ${err.message}`);
343
370
  }
344
- } catch (err) {
345
- strapi2.log.warn(`socket.io: JWT verification failed: ${err.message}`);
346
371
  }
347
372
  } else {
348
373
  strapi2.log.debug(`socket.io: No token provided, connecting as public`);
@@ -658,6 +683,13 @@ async function bootstrapIO$1({ strapi: strapi2 }) {
658
683
  if (settings2.livePreview?.enabled !== false) {
659
684
  previewService.cleanupSocket(socket.id);
660
685
  }
686
+ try {
687
+ const presenceController = strapi2.plugin(pluginId$6).controller("presence");
688
+ if (presenceController?.unregisterSocket) {
689
+ presenceController.unregisterSocket(socket.id);
690
+ }
691
+ } catch (e) {
692
+ }
661
693
  });
662
694
  socket.on("error", (error2) => {
663
695
  strapi2.log.error(`socket.io: Socket error (id: ${socket.id}): ${error2.message}`);
@@ -1279,19 +1311,38 @@ var settings$3 = ({ strapi: strapi2 }) => ({
1279
1311
  };
1280
1312
  }
1281
1313
  });
1282
- const { randomUUID } = require$$1;
1314
+ const { randomUUID, createHash } = require$$1;
1283
1315
  const sessionTokens = /* @__PURE__ */ new Map();
1316
+ const activeSockets = /* @__PURE__ */ new Map();
1317
+ const refreshThrottle = /* @__PURE__ */ new Map();
1318
+ const SESSION_TTL = 10 * 60 * 1e3;
1319
+ const REFRESH_COOLDOWN = 30 * 1e3;
1320
+ const CLEANUP_INTERVAL = 2 * 60 * 1e3;
1321
+ const hashToken = (token) => {
1322
+ return createHash("sha256").update(token).digest("hex");
1323
+ };
1284
1324
  setInterval(() => {
1285
1325
  const now = Date.now();
1286
- for (const [token, session] of sessionTokens.entries()) {
1326
+ let cleaned = 0;
1327
+ for (const [tokenHash, session] of sessionTokens.entries()) {
1287
1328
  if (session.expiresAt < now) {
1288
- sessionTokens.delete(token);
1329
+ sessionTokens.delete(tokenHash);
1330
+ cleaned++;
1289
1331
  }
1290
1332
  }
1291
- }, 5 * 60 * 1e3);
1333
+ for (const [userId, lastRefresh] of refreshThrottle.entries()) {
1334
+ if (now - lastRefresh > 60 * 60 * 1e3) {
1335
+ refreshThrottle.delete(userId);
1336
+ }
1337
+ }
1338
+ if (cleaned > 0) {
1339
+ console.log(`[plugin-io] [CLEANUP] Removed ${cleaned} expired session tokens`);
1340
+ }
1341
+ }, CLEANUP_INTERVAL);
1292
1342
  var presence$3 = ({ strapi: strapi2 }) => ({
1293
1343
  /**
1294
1344
  * Creates a session token for admin users to connect to Socket.IO
1345
+ * Implements rate limiting and secure token storage
1295
1346
  * @param {object} ctx - Koa context
1296
1347
  */
1297
1348
  async createSession(ctx) {
@@ -1300,28 +1351,40 @@ var presence$3 = ({ strapi: strapi2 }) => ({
1300
1351
  strapi2.log.warn("[plugin-io] Presence session requested without admin user");
1301
1352
  return ctx.unauthorized("Admin authentication required");
1302
1353
  }
1354
+ const lastRefresh = refreshThrottle.get(adminUser.id);
1355
+ const now = Date.now();
1356
+ if (lastRefresh && now - lastRefresh < REFRESH_COOLDOWN) {
1357
+ const waitTime = Math.ceil((REFRESH_COOLDOWN - (now - lastRefresh)) / 1e3);
1358
+ strapi2.log.warn(`[plugin-io] Rate limit: User ${adminUser.id} must wait ${waitTime}s`);
1359
+ return ctx.tooManyRequests(`Please wait ${waitTime} seconds before requesting a new session`);
1360
+ }
1303
1361
  try {
1304
1362
  const token = randomUUID();
1305
- const expiresAt = Date.now() + 2 * 60 * 1e3;
1306
- sessionTokens.set(token, {
1307
- token,
1363
+ const tokenHash = hashToken(token);
1364
+ const expiresAt = now + SESSION_TTL;
1365
+ sessionTokens.set(tokenHash, {
1366
+ tokenHash,
1367
+ userId: adminUser.id,
1308
1368
  user: {
1309
1369
  id: adminUser.id,
1310
- email: adminUser.email,
1370
+ // Only store minimal user data needed for display
1311
1371
  firstname: adminUser.firstname,
1312
1372
  lastname: adminUser.lastname
1313
1373
  },
1314
- expiresAt
1374
+ createdAt: now,
1375
+ expiresAt,
1376
+ usageCount: 0,
1377
+ maxUsage: 10
1378
+ // Max reconnects with same token
1315
1379
  });
1316
- strapi2.log.info(`[plugin-io] Presence session created for admin user: ${adminUser.email}`);
1380
+ refreshThrottle.set(adminUser.id, now);
1381
+ strapi2.log.info(`[plugin-io] Presence session created for admin user: ${adminUser.id}`);
1317
1382
  ctx.body = {
1318
1383
  token,
1319
- user: {
1320
- id: adminUser.id,
1321
- email: adminUser.email,
1322
- firstname: adminUser.firstname,
1323
- lastname: adminUser.lastname
1324
- },
1384
+ // Send plaintext token to client (only time it's exposed)
1385
+ expiresAt,
1386
+ refreshAfter: now + SESSION_TTL * 0.7,
1387
+ // Suggest refresh at 70% of TTL
1325
1388
  wsPath: "/socket.io",
1326
1389
  wsUrl: `${ctx.protocol}://${ctx.host}`
1327
1390
  };
@@ -1331,23 +1394,142 @@ var presence$3 = ({ strapi: strapi2 }) => ({
1331
1394
  }
1332
1395
  },
1333
1396
  /**
1334
- * Validates and consumes a session token (one-time use)
1397
+ * Validates a session token and tracks usage
1398
+ * Implements usage limits to prevent token abuse
1335
1399
  * @param {string} token - Session token to validate
1336
1400
  * @returns {object|null} Session data or null if invalid/expired
1337
1401
  */
1338
1402
  consumeSessionToken(token) {
1339
- if (!token) {
1403
+ if (!token || typeof token !== "string") {
1340
1404
  return null;
1341
1405
  }
1342
- const session = sessionTokens.get(token);
1406
+ const tokenHash = hashToken(token);
1407
+ const session = sessionTokens.get(tokenHash);
1343
1408
  if (!session) {
1409
+ strapi2.log.debug("[plugin-io] Token not found in session store");
1344
1410
  return null;
1345
1411
  }
1346
- if (session.expiresAt < Date.now()) {
1347
- sessionTokens.delete(token);
1412
+ const now = Date.now();
1413
+ if (session.expiresAt < now) {
1414
+ sessionTokens.delete(tokenHash);
1415
+ strapi2.log.debug("[plugin-io] Token expired, removed from store");
1348
1416
  return null;
1349
1417
  }
1418
+ if (session.usageCount >= session.maxUsage) {
1419
+ strapi2.log.warn(`[plugin-io] Token usage limit exceeded for user ${session.userId}`);
1420
+ sessionTokens.delete(tokenHash);
1421
+ return null;
1422
+ }
1423
+ session.usageCount++;
1424
+ session.lastUsed = now;
1350
1425
  return session;
1426
+ },
1427
+ /**
1428
+ * Registers a socket as using a specific token
1429
+ * @param {string} socketId - Socket ID
1430
+ * @param {string} token - The token being used
1431
+ */
1432
+ registerSocket(socketId, token) {
1433
+ if (!socketId || !token) return;
1434
+ const tokenHash = hashToken(token);
1435
+ activeSockets.set(socketId, tokenHash);
1436
+ },
1437
+ /**
1438
+ * Unregisters a socket when it disconnects
1439
+ * @param {string} socketId - Socket ID
1440
+ */
1441
+ unregisterSocket(socketId) {
1442
+ activeSockets.delete(socketId);
1443
+ },
1444
+ /**
1445
+ * Invalidates all sessions for a specific user (e.g., on logout)
1446
+ * @param {number} userId - User ID to invalidate
1447
+ * @returns {number} Number of sessions invalidated
1448
+ */
1449
+ invalidateUserSessions(userId) {
1450
+ let invalidated = 0;
1451
+ for (const [tokenHash, session] of sessionTokens.entries()) {
1452
+ if (session.userId === userId) {
1453
+ sessionTokens.delete(tokenHash);
1454
+ invalidated++;
1455
+ }
1456
+ }
1457
+ refreshThrottle.delete(userId);
1458
+ strapi2.log.info(`[plugin-io] Invalidated ${invalidated} sessions for user ${userId}`);
1459
+ return invalidated;
1460
+ },
1461
+ /**
1462
+ * Gets session statistics (for monitoring) - internal method
1463
+ * @returns {object} Session statistics
1464
+ */
1465
+ getSessionStatsInternal() {
1466
+ const now = Date.now();
1467
+ let active = 0;
1468
+ let expiringSoon = 0;
1469
+ for (const session of sessionTokens.values()) {
1470
+ if (session.expiresAt > now) {
1471
+ active++;
1472
+ if (session.expiresAt - now < 2 * 60 * 1e3) {
1473
+ expiringSoon++;
1474
+ }
1475
+ }
1476
+ }
1477
+ return {
1478
+ activeSessions: active,
1479
+ expiringSoon,
1480
+ activeSocketConnections: activeSockets.size,
1481
+ sessionTTL: SESSION_TTL,
1482
+ refreshCooldown: REFRESH_COOLDOWN
1483
+ };
1484
+ },
1485
+ /**
1486
+ * HTTP Handler: Gets session statistics for admin monitoring
1487
+ * @param {object} ctx - Koa context
1488
+ */
1489
+ async getSessionStats(ctx) {
1490
+ const adminUser = ctx.state.user;
1491
+ if (!adminUser) {
1492
+ return ctx.unauthorized("Admin authentication required");
1493
+ }
1494
+ try {
1495
+ const stats = this.getSessionStatsInternal();
1496
+ ctx.body = { data: stats };
1497
+ } catch (error2) {
1498
+ strapi2.log.error("[plugin-io] Failed to get session stats:", error2);
1499
+ return ctx.internalServerError("Failed to get session statistics");
1500
+ }
1501
+ },
1502
+ /**
1503
+ * HTTP Handler: Invalidates all sessions for a specific user
1504
+ * @param {object} ctx - Koa context
1505
+ */
1506
+ async invalidateUserSessionsHandler(ctx) {
1507
+ const adminUser = ctx.state.user;
1508
+ if (!adminUser) {
1509
+ return ctx.unauthorized("Admin authentication required");
1510
+ }
1511
+ const { userId } = ctx.params;
1512
+ if (!userId) {
1513
+ return ctx.badRequest("User ID is required");
1514
+ }
1515
+ try {
1516
+ const userIdNum = parseInt(userId, 10);
1517
+ if (isNaN(userIdNum)) {
1518
+ return ctx.badRequest("Invalid user ID");
1519
+ }
1520
+ const invalidated = this.invalidateUserSessions(userIdNum);
1521
+ strapi2.log.info(`[plugin-io] Admin ${adminUser.id} invalidated ${invalidated} sessions for user ${userIdNum}`);
1522
+ ctx.body = {
1523
+ data: {
1524
+ userId: userIdNum,
1525
+ invalidatedSessions: invalidated,
1526
+ message: `Successfully invalidated ${invalidated} session(s)`
1527
+ }
1528
+ };
1529
+ } catch (error2) {
1530
+ strapi2.log.error("[plugin-io] Failed to invalidate user sessions:", error2);
1531
+ return ctx.internalServerError("Failed to invalidate sessions");
1532
+ }
1351
1533
  }
1352
1534
  });
1353
1535
  const settings$2 = settings$3;
@@ -1439,6 +1621,24 @@ var admin$1 = {
1439
1621
  config: {
1440
1622
  policies: ["admin::isAuthenticatedAdmin"]
1441
1623
  }
1624
+ },
1625
+ // Security: Session statistics
1626
+ {
1627
+ method: "GET",
1628
+ path: "/security/sessions",
1629
+ handler: "presence.getSessionStats",
1630
+ config: {
1631
+ policies: ["admin::isAuthenticatedAdmin"]
1632
+ }
1633
+ },
1634
+ // Security: Invalidate user sessions (force logout)
1635
+ {
1636
+ method: "POST",
1637
+ path: "/security/invalidate/:userId",
1638
+ handler: "presence.invalidateUserSessionsHandler",
1639
+ config: {
1640
+ policies: ["admin::isAuthenticatedAdmin"]
1641
+ }
1442
1642
  }
1443
1643
  ]
1444
1644
  };
@@ -29645,21 +29845,55 @@ var strategies = ({ strapi: strapi2 }) => {
29645
29845
  credentials: function(user) {
29646
29846
  return `${this.name}-${user.id}`;
29647
29847
  },
29648
- authenticate: async function(auth) {
29848
+ /**
29849
+ * Authenticates admin user via session token
29850
+ * @param {object} auth - Auth object containing token
29851
+ * @param {object} socket - Socket instance for registration
29852
+ * @returns {object} User data if authenticated
29853
+ * @throws {UnauthorizedError} If authentication fails
29854
+ */
29855
+ authenticate: async function(auth, socket) {
29649
29856
  const token2 = auth.token;
29650
- if (!token2) {
29857
+ if (!token2 || typeof token2 !== "string") {
29858
+ strapi2.log.warn("[plugin-io] Admin auth failed: No token provided");
29651
29859
  throw new UnauthorizedError2("Invalid admin credentials");
29652
29860
  }
29861
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
29862
+ if (!uuidRegex.test(token2)) {
29863
+ strapi2.log.warn("[plugin-io] Admin auth failed: Invalid token format");
29864
+ throw new UnauthorizedError2("Invalid token format");
29865
+ }
29653
29866
  try {
29654
29867
  const presenceController = strapi2.plugin("io").controller("presence");
29655
29868
  const session = presenceController.consumeSessionToken(token2);
29656
29869
  if (!session) {
29870
+ strapi2.log.warn("[plugin-io] Admin auth failed: Token not valid or expired");
29657
29871
  throw new UnauthorizedError2("Invalid or expired session token");
29658
29872
  }
29659
- return session.user;
29873
+ if (socket?.id) {
29874
+ presenceController.registerSocket(socket.id, token2);
29875
+ }
29876
+ strapi2.log.info(`[plugin-io] Admin authenticated: User ID ${session.userId}`);
29877
+ return {
29878
+ id: session.userId,
29879
+ ...session.user
29880
+ };
29660
29881
  } catch (error2) {
29661
- strapi2.log.warn("[plugin-io] Admin session verification failed:", error2.message);
29662
- throw new UnauthorizedError2("Invalid admin credentials");
29882
+ if (error2 instanceof UnauthorizedError2) {
29883
+ throw error2;
29884
+ }
29885
+ strapi2.log.error("[plugin-io] Admin session verification error:", error2.message);
29886
+ throw new UnauthorizedError2("Authentication failed");
29887
+ }
29888
+ },
29889
+ /**
29890
+ * Cleanup when socket disconnects
29891
+ * @param {object} socket - Socket instance
29892
+ */
29893
+ onDisconnect: function(socket) {
29894
+ if (socket?.id) {
29895
+ const presenceController = strapi2.plugin("io").controller("presence");
29896
+ presenceController.unregisterSocket(socket.id);
29663
29897
  }
29664
29898
  },
29665
29899
  getRoomName: function(user) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@strapi-community/plugin-io",
4
- "version": "5.1.0",
4
+ "version": "5.2.0",
5
5
  "description": "A plugin for Strapi CMS that provides the ability for Socket IO integration",
6
6
  "keywords": [
7
7
  "strapi",