Skip to content

Commit 702d3ad

Browse files
committed
fix: update session activity on ALL requests with valid JWT
Problem: Session was only updated when ctx.state.user was set, which only happens on /api/auth/ routes. After 15 minutes, the session appeared offline even though the user was still active. Solution: - Now tracks activity on ALL API requests with valid JWT token - Uses in-memory cache for rate limiting (faster than DB checks) - Falls back to finding session by token if ctx.state.user not set - Skips internal/admin routes for performance This ensures accurate 'Active now' status based on real API usage.
1 parent 9eb2249 commit 702d3ad

1 file changed

Lines changed: 76 additions & 24 deletions

File tree

server/src/middlewares/last-seen.js

Lines changed: 76 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,41 @@
1010
* even though JWT tokens are stateless and cannot be invalidated directly.
1111
*
1212
* [SUCCESS] Migrated to strapi.documents() API (Strapi v5 Best Practice)
13+
* [FIX] Now updates activity on ALL requests with valid JWT, not just when ctx.state.user is set
1314
*/
1415

1516
const SESSION_UID = 'plugin::magic-sessionmanager.session';
1617
const { decryptToken } = require('../utils/encryption');
1718

19+
// In-memory cache for rate limiting (per session)
20+
const lastTouchCache = new Map();
21+
1822
module.exports = ({ strapi, sessionService }) => {
1923
return async (ctx, next) => {
24+
// Get JWT token from Authorization header
25+
const currentToken = ctx.request.headers.authorization?.replace('Bearer ', '');
26+
27+
// Skip if no token provided
28+
if (!currentToken) {
29+
await next();
30+
return;
31+
}
32+
33+
// Skip internal/admin routes that don't need session tracking
34+
const skipPaths = ['/admin', '/_health', '/favicon.ico'];
35+
if (skipPaths.some(p => ctx.path.startsWith(p))) {
36+
await next();
37+
return;
38+
}
39+
40+
let matchingSession = null;
41+
let userId = null;
42+
2043
// BEFORE processing request: Validate the SPECIFIC session is still active
21-
// Strapi v5: Use documentId instead of numeric id for Document Service API
22-
if (ctx.state.user && ctx.state.user.documentId) {
23-
try {
24-
const userId = ctx.state.user.documentId;
25-
const currentToken = ctx.request.headers.authorization?.replace('Bearer ', '');
26-
27-
if (!currentToken) {
28-
// No token provided, let Strapi handle auth
29-
await next();
30-
return;
31-
}
44+
try {
45+
// Try to get userId from ctx.state.user first (if already authenticated)
46+
if (ctx.state.user && ctx.state.user.documentId) {
47+
userId = ctx.state.user.documentId;
3248

3349
// Get all active sessions for this user
3450
const activeSessions = await strapi.documents(SESSION_UID).findMany({
@@ -45,7 +61,6 @@ module.exports = ({ strapi, sessionService }) => {
4561
}
4662

4763
// Find the session that matches this specific JWT token
48-
let matchingSession = null;
4964
for (const session of activeSessions) {
5065
if (!session.token) continue;
5166
try {
@@ -64,28 +79,65 @@ module.exports = ({ strapi, sessionService }) => {
6479
strapi.log.info(`[magic-sessionmanager] [BLOCKED] Session for user ${userId} has been terminated`);
6580
return ctx.unauthorized('This session has been terminated. Please login again.');
6681
}
82+
} else {
83+
// User not yet authenticated by Strapi - find session directly by token
84+
// This handles cases where JWT is valid but ctx.state.user isn't set yet
85+
const allActiveSessions = await strapi.documents(SESSION_UID).findMany({
86+
filters: { isActive: true },
87+
populate: { user: { fields: ['documentId'] } },
88+
limit: 500, // Reasonable limit for performance
89+
});
6790

68-
// Store the matching session ID for later use (touch, etc.)
91+
for (const session of allActiveSessions) {
92+
if (!session.token) continue;
93+
try {
94+
const decrypted = decryptToken(session.token);
95+
if (decrypted === currentToken) {
96+
matchingSession = session;
97+
userId = session.user?.documentId;
98+
break;
99+
}
100+
} catch (err) {
101+
// Ignore decryption errors
102+
}
103+
}
104+
}
105+
106+
// Store the matching session for later use
107+
if (matchingSession) {
69108
ctx.state.sessionId = matchingSession.documentId;
70109
ctx.state.currentSession = matchingSession;
71-
72-
} catch (err) {
73-
strapi.log.debug('[magic-sessionmanager] Error checking session:', err.message);
74-
// On error, allow request to continue (fail-open for availability)
75110
}
111+
112+
} catch (err) {
113+
strapi.log.debug('[magic-sessionmanager] Error checking session:', err.message);
114+
// On error, allow request to continue (fail-open for availability)
76115
}
77116

78117
// Process request
79118
await next();
80119

81-
// AFTER response: Update activity timestamps if user is authenticated
82-
if (ctx.state.user && ctx.state.user.documentId && ctx.state.sessionId) {
120+
// AFTER response: Update activity timestamps if we found a valid session
121+
if (matchingSession) {
83122
try {
84-
// Call touch with rate limiting using the validated session ID
85-
await sessionService.touch({
86-
userId: ctx.state.user.documentId,
87-
sessionId: ctx.state.sessionId,
88-
});
123+
// Rate limiting: Check in-memory cache first (faster than DB)
124+
const config = strapi.config.get('plugin::magic-sessionmanager') || {};
125+
const rateLimit = config.lastSeenRateLimit || 30000; // 30 seconds default
126+
const now = Date.now();
127+
const lastTouch = lastTouchCache.get(matchingSession.documentId) || 0;
128+
129+
if (now - lastTouch > rateLimit) {
130+
// Update cache
131+
lastTouchCache.set(matchingSession.documentId, now);
132+
133+
// Update database
134+
await strapi.documents(SESSION_UID).update({
135+
documentId: matchingSession.documentId,
136+
data: { lastActive: new Date() },
137+
});
138+
139+
strapi.log.debug(`[magic-sessionmanager] [TOUCH] Session ${matchingSession.documentId} activity updated`);
140+
}
89141
} catch (err) {
90142
strapi.log.debug('[magic-sessionmanager] Error updating lastSeen:', err.message);
91143
}

0 commit comments

Comments
 (0)