Skip to content

Commit bfaa693

Browse files
committed
fix: optimize session lookups and add geolocation on-demand
Performance improvements: - Replace O(n) token decryption loops with O(1) tokenHash lookups in logout handler - Optimize refresh token validation using refreshTokenHash - Optimize refresh token update handler using refreshTokenHash Bug fixes: - Fix session termination not blocking requests (middleware timing issue) - Add on-demand geolocation lookup for sessions without stored geo data Code quality: - Remove emojis from log messages (destroy.js) - Add lazy cache cleanup to prevent memory leaks in lastTouchCache - Add skipPaths for auth routes in last-seen middleware All changes follow Strapi v5 Document Service API best practices.
1 parent d88f4ec commit bfaa693

File tree

6 files changed

+235
-60
lines changed

6 files changed

+235
-60
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "4.2.12",
2+
"version": "4.2.13",
33
"keywords": [
44
"strapi",
55
"strapi-plugin",

server/src/bootstrap.js

Lines changed: 12 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -113,25 +113,15 @@ module.exports = async ({ strapi }) => {
113113
return;
114114
}
115115

116-
// Find session by decrypting tokens and matching
117-
// Since tokens are encrypted, we need to get all active sessions and check each one
118-
const allSessions = await strapi.documents(SESSION_UID).findMany( {
116+
// Find session by tokenHash - O(1) DB lookup instead of O(n) decrypt loop!
117+
const tokenHashValue = hashToken(token);
118+
const matchingSession = await strapi.documents(SESSION_UID).findFirst({
119119
filters: {
120+
tokenHash: tokenHashValue,
120121
isActive: true,
121122
},
122123
});
123124

124-
// Find matching session by decrypting and comparing tokens
125-
const matchingSession = allSessions.find(session => {
126-
if (!session.token) return false;
127-
try {
128-
const decrypted = decryptToken(session.token);
129-
return decrypted === token;
130-
} catch (err) {
131-
return false;
132-
}
133-
});
134-
135125
if (matchingSession) {
136126
await sessionService.terminateSession({ sessionId: matchingSession.documentId });
137127
log.info(`[LOGOUT] Logout via /api/auth/logout - Session ${matchingSession.documentId} terminated`);
@@ -324,24 +314,15 @@ module.exports = async ({ strapi }) => {
324314
const refreshToken = ctx.request.body?.refreshToken;
325315

326316
if (refreshToken) {
327-
// Find session with this refresh token
328-
const allSessions = await strapi.documents(SESSION_UID).findMany( {
317+
// Find session by refreshTokenHash - O(1) DB lookup instead of O(n) decrypt loop!
318+
const refreshTokenHashValue = hashToken(refreshToken);
319+
const matchingSession = await strapi.documents(SESSION_UID).findFirst({
329320
filters: {
321+
refreshTokenHash: refreshTokenHashValue,
330322
isActive: true,
331323
},
332324
});
333325

334-
// Find matching session by decrypting and comparing refresh tokens
335-
const matchingSession = allSessions.find(session => {
336-
if (!session.refreshToken) return false;
337-
try {
338-
const decrypted = decryptToken(session.refreshToken);
339-
return decrypted === refreshToken;
340-
} catch (err) {
341-
return false;
342-
}
343-
});
344-
345326
if (!matchingSession) {
346327
// No active session with this refresh token - Block!
347328
log.warn('[BLOCKED] Blocked refresh token request - no active session');
@@ -375,23 +356,15 @@ module.exports = async ({ strapi }) => {
375356
const newRefreshToken = ctx.body.refreshToken;
376357

377358
if (oldRefreshToken) {
378-
// Find session and update with new tokens
379-
const allSessions = await strapi.documents(SESSION_UID).findMany( {
359+
// Find session by refreshTokenHash - O(1) DB lookup instead of O(n) decrypt loop!
360+
const oldRefreshTokenHash = hashToken(oldRefreshToken);
361+
const matchingSession = await strapi.documents(SESSION_UID).findFirst({
380362
filters: {
363+
refreshTokenHash: oldRefreshTokenHash,
381364
isActive: true,
382365
},
383366
});
384367

385-
const matchingSession = allSessions.find(session => {
386-
if (!session.refreshToken) return false;
387-
try {
388-
const decrypted = decryptToken(session.refreshToken);
389-
return decrypted === oldRefreshToken;
390-
} catch (err) {
391-
return false;
392-
}
393-
});
394-
395368
if (matchingSession) {
396369
const encryptedToken = newAccessToken ? encryptToken(newAccessToken) : matchingSession.token;
397370
const encryptedRefreshToken = newRefreshToken ? encryptToken(newRefreshToken) : matchingSession.refreshToken;

server/src/controllers/session.js

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ module.exports = {
6464
* GET /api/magic-sessionmanager/my-sessions
6565
* Automatically uses the authenticated user's documentId
6666
* Marks which session is the current one (based on JWT token hash)
67+
* Fetches geolocation data on-demand if not already stored
6768
*/
6869
async getOwnSessions(ctx) {
6970
try {
@@ -86,9 +87,12 @@ module.exports = {
8687
const config = strapi.config.get('plugin::magic-sessionmanager') || {};
8788
const inactivityTimeout = config.inactivityTimeout || 15 * 60 * 1000;
8889
const now = new Date();
90+
91+
// Get geolocation service for on-demand lookups
92+
const geolocationService = strapi.plugin('magic-sessionmanager').service('geolocation');
8993

9094
// Enhance sessions with isCurrentSession flag and parsed device info
91-
const sessionsWithCurrent = allSessions.map(session => {
95+
const sessionsWithCurrent = await Promise.all(allSessions.map(async (session) => {
9296
const lastActiveTime = session.lastActive ? new Date(session.lastActive) : new Date(session.loginTime);
9397
const timeSinceActive = now - lastActiveTime;
9498
const isTrulyActive = session.isActive && (timeSinceActive < inactivityTimeout);
@@ -115,6 +119,34 @@ module.exports = {
115119
geoLocation = null;
116120
}
117121
}
122+
123+
// On-demand geolocation lookup if not already stored
124+
if (!geoLocation && session.ipAddress) {
125+
try {
126+
const geoData = await geolocationService.getIpInfo(session.ipAddress);
127+
if (geoData && geoData.country !== 'Unknown') {
128+
geoLocation = {
129+
country: geoData.country,
130+
country_code: geoData.country_code,
131+
country_flag: geoData.country_flag,
132+
city: geoData.city,
133+
region: geoData.region,
134+
timezone: geoData.timezone,
135+
};
136+
137+
// Persist to database for future requests (fire-and-forget)
138+
strapi.documents(SESSION_UID).update({
139+
documentId: session.documentId,
140+
data: {
141+
geoLocation: JSON.stringify(geoLocation),
142+
securityScore: geoData.securityScore || null,
143+
},
144+
}).catch(() => {}); // Ignore update errors
145+
}
146+
} catch (geoErr) {
147+
strapi.log.debug('[magic-sessionmanager] Geolocation lookup failed:', geoErr.message);
148+
}
149+
}
118150

119151
// Remove sensitive token fields and internal fields
120152
const {
@@ -134,7 +166,7 @@ module.exports = {
134166
isTrulyActive,
135167
minutesSinceActive: Math.floor(timeSinceActive / 1000 / 60),
136168
};
137-
});
169+
}));
138170

139171
// Sort: current session first, then by loginTime
140172
sessionsWithCurrent.sort((a, b) => {
@@ -273,6 +305,7 @@ module.exports = {
273305
* Get current session info based on JWT token hash
274306
* GET /api/magic-sessionmanager/current-session
275307
* Returns the session associated with the current JWT token
308+
* Fetches geolocation on-demand if not already stored
276309
*/
277310
async getCurrentSession(ctx) {
278311
try {
@@ -323,6 +356,35 @@ module.exports = {
323356
geoLocation = null;
324357
}
325358
}
359+
360+
// On-demand geolocation lookup if not already stored
361+
if (!geoLocation && currentSession.ipAddress) {
362+
try {
363+
const geolocationService = strapi.plugin('magic-sessionmanager').service('geolocation');
364+
const geoData = await geolocationService.getIpInfo(currentSession.ipAddress);
365+
if (geoData && geoData.country !== 'Unknown') {
366+
geoLocation = {
367+
country: geoData.country,
368+
country_code: geoData.country_code,
369+
country_flag: geoData.country_flag,
370+
city: geoData.city,
371+
region: geoData.region,
372+
timezone: geoData.timezone,
373+
};
374+
375+
// Persist to database for future requests (fire-and-forget)
376+
strapi.documents(SESSION_UID).update({
377+
documentId: currentSession.documentId,
378+
data: {
379+
geoLocation: JSON.stringify(geoLocation),
380+
securityScore: geoData.securityScore || null,
381+
},
382+
}).catch(() => {}); // Ignore update errors
383+
}
384+
} catch (geoErr) {
385+
strapi.log.debug('[magic-sessionmanager] Geolocation lookup failed:', geoErr.message);
386+
}
387+
}
326388

327389
// Remove sensitive token fields and internal fields
328390
const {

server/src/destroy.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ module.exports = async ({ strapi }) => {
88
// Stop license pinging
99
if (strapi.licenseGuard && strapi.licenseGuard.pingInterval) {
1010
clearInterval(strapi.licenseGuard.pingInterval);
11-
log.info('🛑 License pinging stopped');
11+
log.info('[STOP] License pinging stopped');
1212
}
1313

1414
// Stop cleanup interval
1515
if (strapi.sessionManagerIntervals && strapi.sessionManagerIntervals.cleanup) {
1616
clearInterval(strapi.sessionManagerIntervals.cleanup);
17-
log.info('🛑 Session cleanup interval stopped');
17+
log.info('[STOP] Session cleanup interval stopped');
1818
}
1919

2020
log.info('[SUCCESS] Plugin cleanup completed');

server/src/middlewares/last-seen.js

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,27 @@ const { hashToken } = require('../utils/encryption');
1919
// In-memory cache for rate limiting (per session documentId)
2020
const lastTouchCache = new Map();
2121

22+
// Cache cleanup settings
23+
const CACHE_MAX_SIZE = 10000; // Max entries before cleanup
24+
const CACHE_CLEANUP_AGE = 60 * 60 * 1000; // Remove entries older than 1 hour
25+
26+
/**
27+
* Periodically clean up old cache entries to prevent memory leaks
28+
* Called lazily when cache grows too large
29+
*/
30+
function cleanupOldCacheEntries() {
31+
if (lastTouchCache.size < CACHE_MAX_SIZE) return;
32+
33+
const now = Date.now();
34+
const cutoff = now - CACHE_CLEANUP_AGE;
35+
36+
for (const [key, timestamp] of lastTouchCache.entries()) {
37+
if (timestamp < cutoff) {
38+
lastTouchCache.delete(key);
39+
}
40+
}
41+
}
42+
2243
module.exports = ({ strapi }) => {
2344
return async (ctx, next) => {
2445
// Get JWT token from Authorization header
@@ -30,8 +51,20 @@ module.exports = ({ strapi }) => {
3051
return;
3152
}
3253

33-
// Skip internal/admin routes that don't need session tracking
34-
const skipPaths = ['/admin', '/_health', '/favicon.ico'];
54+
// Skip routes that don't need session validation
55+
const skipPaths = [
56+
'/admin', // Admin panel routes (have their own auth)
57+
'/_health', // Health check
58+
'/favicon.ico', // Static assets
59+
'/api/auth/local', // Login endpoint
60+
'/api/auth/register', // Registration endpoint
61+
'/api/auth/forgot-password', // Password reset
62+
'/api/auth/reset-password', // Password reset
63+
'/api/auth/logout', // Logout endpoint (handled separately)
64+
'/api/auth/refresh', // Refresh token (has own validation in bootstrap.js)
65+
'/api/connect', // OAuth providers
66+
'/api/magic-link', // Magic link auth (if using magic-link plugin)
67+
];
3568
if (skipPaths.some(p => ctx.path.startsWith(p))) {
3669
await next();
3770
return;
@@ -63,14 +96,18 @@ module.exports = ({ strapi }) => {
6396
ctx.state.sessionUserId = matchingSession.user.documentId;
6497
}
6598
} else {
66-
// Token exists but no active session found - check if user is authenticated
67-
// Only block if we know this is an authenticated request
68-
if (ctx.state.user && ctx.state.user.documentId) {
69-
strapi.log.info(`[magic-sessionmanager] [BLOCKED] Session terminated for user ${ctx.state.user.documentId}`);
70-
return ctx.unauthorized('This session has been terminated. Please login again.');
71-
}
72-
// If ctx.state.user not set, this might be a public route or JWT validation hasn't run yet
73-
// Let the request continue - Strapi's auth will handle it
99+
// Token exists but no active session found
100+
// CRITICAL: We have a valid-looking JWT token but NO active session
101+
// This means the session was terminated - MUST block the request!
102+
//
103+
// Note: We cannot rely on ctx.state.user here because Strapi's JWT
104+
// middleware runs AFTER our plugin middleware. So ctx.state.user
105+
// is not yet set at this point.
106+
//
107+
// Since we have a Bearer token but no matching active session,
108+
// this is definitely a terminated session - block it!
109+
strapi.log.info(`[magic-sessionmanager] [BLOCKED] Request blocked - session terminated or invalid (token hash: ${currentTokenHash.substring(0, 8)}...)`);
110+
return ctx.unauthorized('This session has been terminated. Please login again.');
74111
}
75112

76113
} catch (err) {
@@ -94,6 +131,9 @@ module.exports = ({ strapi }) => {
94131
// Update cache
95132
lastTouchCache.set(matchingSession.documentId, now);
96133

134+
// Lazy cleanup: Remove old entries if cache grows too large
135+
cleanupOldCacheEntries();
136+
97137
// Update database
98138
await strapi.documents(SESSION_UID).update({
99139
documentId: matchingSession.documentId,

0 commit comments

Comments
 (0)