strapi-plugin-magic-sessionmanager 3.2.0 → 3.3.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 +66 -44
- package/dist/server/index.js +97 -8
- package/dist/server/index.mjs +97 -8
- package/package.json +1 -1
- package/server/src/bootstrap.js +103 -1
- package/server/src/content-types/session/schema.json +4 -0
- package/server/src/controllers/session.js +9 -4
- package/server/src/services/session.js +7 -5
package/README.md
CHANGED
|
@@ -349,7 +349,7 @@ Log: "Cleaned up X inactive sessions"
|
|
|
349
349
|
2. Inactivity timeout triggers cleanup
|
|
350
350
|
3. Admin terminates the session
|
|
351
351
|
|
|
352
|
-
#### Refresh Tokens
|
|
352
|
+
#### Refresh Tokens ✅ **SOLVED!**
|
|
353
353
|
|
|
354
354
|
**What are Refresh Tokens?**
|
|
355
355
|
Refresh tokens allow users to get new Access Tokens (JWTs) without re-entering credentials. This enables longer sessions:
|
|
@@ -367,78 +367,100 @@ Strapi issues new JWT
|
|
|
367
367
|
User continues without re-login
|
|
368
368
|
```
|
|
369
369
|
|
|
370
|
-
**The
|
|
371
|
-
- **Stored:**
|
|
372
|
-
- **
|
|
373
|
-
- **
|
|
370
|
+
**The Solution (v3.2+):**
|
|
371
|
+
- **Stored:** YES - Refresh tokens are encrypted and stored with sessions ✅
|
|
372
|
+
- **Tracked:** YES - Middleware intercepts `/api/auth/refresh-token` requests ✅
|
|
373
|
+
- **Validated:** YES - Checks if session is still active before issuing new tokens ✅
|
|
374
|
+
|
|
375
|
+
**How It Works:**
|
|
374
376
|
|
|
375
|
-
**Scenario:**
|
|
376
377
|
```
|
|
377
|
-
|
|
378
|
+
Login: User gets JWT + Refresh Token
|
|
379
|
+
↓
|
|
380
|
+
Both tokens encrypted and stored in session
|
|
381
|
+
↓
|
|
382
|
+
Admin terminates session
|
|
383
|
+
↓
|
|
384
|
+
Session: isActive = false ❌
|
|
385
|
+
↓
|
|
386
|
+
User tries to refresh token:
|
|
387
|
+
POST /api/auth/refresh-token
|
|
388
|
+
{ refreshToken: "..." }
|
|
378
389
|
↓
|
|
379
|
-
|
|
390
|
+
[Refresh Token Middleware]
|
|
380
391
|
↓
|
|
381
|
-
|
|
382
|
-
User has refresh token
|
|
392
|
+
Decrypt all active session refresh tokens
|
|
383
393
|
↓
|
|
384
|
-
|
|
394
|
+
Find matching session
|
|
385
395
|
↓
|
|
386
|
-
|
|
396
|
+
Session found but isActive = false?
|
|
397
|
+
→ BLOCK! Return 401 Unauthorized ❌
|
|
398
|
+
→ Message: "Session terminated. Please login again."
|
|
387
399
|
↓
|
|
388
|
-
|
|
400
|
+
Session found and isActive = true?
|
|
401
|
+
→ ALLOW! ✅
|
|
402
|
+
→ Strapi issues new tokens
|
|
403
|
+
→ Session updated with new encrypted tokens
|
|
389
404
|
```
|
|
390
405
|
|
|
391
|
-
**
|
|
392
|
-
This plugin **cannot prevent** users with valid refresh tokens from getting new JWTs. The session termination only affects the current JWT token.
|
|
406
|
+
**Security Benefits:**
|
|
393
407
|
|
|
394
|
-
**
|
|
408
|
+
✅ **Session termination is FINAL** - User cannot get new tokens
|
|
409
|
+
✅ **Refresh tokens tracked** - Encrypted & stored securely
|
|
410
|
+
✅ **Token rotation** - New tokens automatically updated in session
|
|
411
|
+
✅ **Admin control** - Force logout works even with refresh tokens
|
|
412
|
+
|
|
413
|
+
**Configuration:**
|
|
414
|
+
|
|
415
|
+
Enable refresh tokens in Strapi:
|
|
395
416
|
|
|
396
|
-
**Option 1: Disable Refresh Tokens (Strict Control)**
|
|
397
417
|
```typescript
|
|
398
418
|
// src/config/plugins.ts
|
|
399
419
|
export default () => ({
|
|
400
420
|
'users-permissions': {
|
|
401
421
|
config: {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
//
|
|
422
|
+
jwtManagement: 'refresh', // Enable refresh tokens
|
|
423
|
+
sessions: {
|
|
424
|
+
accessTokenLifespan: 3600, // 1 hour (in seconds)
|
|
425
|
+
maxRefreshTokenLifespan: 2592000, // 30 days
|
|
426
|
+
idleRefreshTokenLifespan: 604800, // 7 days idle
|
|
405
427
|
},
|
|
406
428
|
},
|
|
407
429
|
},
|
|
408
430
|
'magic-sessionmanager': {
|
|
431
|
+
enabled: true,
|
|
409
432
|
config: {
|
|
410
|
-
inactivityTimeout: 15 * 60 * 1000, //
|
|
433
|
+
inactivityTimeout: 15 * 60 * 1000, // 15 minutes
|
|
411
434
|
},
|
|
412
435
|
},
|
|
413
436
|
});
|
|
414
437
|
```
|
|
415
438
|
|
|
416
|
-
**
|
|
417
|
-
```typescript
|
|
418
|
-
'users-permissions': {
|
|
419
|
-
config: {
|
|
420
|
-
jwt: {
|
|
421
|
-
expiresIn: '15m', // Short Access Token
|
|
422
|
-
refreshExpiresIn: '30m', // Short Refresh Token
|
|
423
|
-
},
|
|
424
|
-
},
|
|
425
|
-
}
|
|
426
|
-
```
|
|
439
|
+
**Testing Refresh Token Blocking:**
|
|
427
440
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
-
|
|
431
|
-
-
|
|
432
|
-
-
|
|
433
|
-
- Use session analytics to detect unusual patterns
|
|
441
|
+
```bash
|
|
442
|
+
# 1. Login and get tokens
|
|
443
|
+
curl -X POST http://localhost:1337/api/auth/local \
|
|
444
|
+
-H "Content-Type: application/json" \
|
|
445
|
+
-d '{"identifier":"user@example.com","password":"pass"}'
|
|
434
446
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
447
|
+
# Save both tokens:
|
|
448
|
+
ACCESS_TOKEN="eyJhbGci..."
|
|
449
|
+
REFRESH_TOKEN="abc123..."
|
|
450
|
+
|
|
451
|
+
# 2. Admin terminates session
|
|
452
|
+
# Go to Admin → Sessions → Find session → Terminate
|
|
453
|
+
|
|
454
|
+
# 3. Try to refresh token
|
|
455
|
+
curl -X POST http://localhost:1337/api/auth/refresh-token \
|
|
456
|
+
-H "Content-Type: application/json" \
|
|
457
|
+
-d "{\"refreshToken\":\"$REFRESH_TOKEN\"}"
|
|
458
|
+
|
|
459
|
+
# Expected: 401 Unauthorized
|
|
460
|
+
# "Session terminated. Please login again."
|
|
461
|
+
```
|
|
440
462
|
|
|
441
|
-
This
|
|
463
|
+
**This completely solves the refresh token security gap!** 🔒
|
|
442
464
|
|
|
443
465
|
### Multi-Login Behavior
|
|
444
466
|
|
package/dist/server/index.js
CHANGED
|
@@ -350,8 +350,10 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
|
|
|
350
350
|
userId: user.id,
|
|
351
351
|
ip,
|
|
352
352
|
userAgent,
|
|
353
|
-
token: ctx.body.jwt
|
|
354
|
-
// Store
|
|
353
|
+
token: ctx.body.jwt,
|
|
354
|
+
// Store Access Token (encrypted)
|
|
355
|
+
refreshToken: ctx.body.refreshToken
|
|
356
|
+
// Store Refresh Token (encrypted) if exists
|
|
355
357
|
});
|
|
356
358
|
strapi2.log.info(`[magic-sessionmanager] ✅ Session created for user ${user.id} (IP: ${ip})`);
|
|
357
359
|
if (geoData && (config2.enableEmailAlerts || config2.enableWebhooks)) {
|
|
@@ -396,6 +398,84 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
|
|
|
396
398
|
}
|
|
397
399
|
});
|
|
398
400
|
strapi2.log.info("[magic-sessionmanager] ✅ Login/Logout interceptor middleware mounted");
|
|
401
|
+
strapi2.server.use(async (ctx, next) => {
|
|
402
|
+
const isRefreshToken = ctx.path === "/api/auth/refresh-token" && ctx.method === "POST";
|
|
403
|
+
if (isRefreshToken) {
|
|
404
|
+
try {
|
|
405
|
+
const refreshToken = ctx.request.body?.refreshToken;
|
|
406
|
+
if (refreshToken) {
|
|
407
|
+
const allSessions = await strapi2.entityService.findMany("plugin::magic-sessionmanager.session", {
|
|
408
|
+
filters: {
|
|
409
|
+
isActive: true
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
const matchingSession = allSessions.find((session2) => {
|
|
413
|
+
if (!session2.refreshToken) return false;
|
|
414
|
+
try {
|
|
415
|
+
const decrypted = decryptToken$2(session2.refreshToken);
|
|
416
|
+
return decrypted === refreshToken;
|
|
417
|
+
} catch (err) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
if (!matchingSession) {
|
|
422
|
+
strapi2.log.warn("[magic-sessionmanager] 🚫 Blocked refresh token request - no active session");
|
|
423
|
+
ctx.status = 401;
|
|
424
|
+
ctx.body = {
|
|
425
|
+
error: {
|
|
426
|
+
status: 401,
|
|
427
|
+
message: "Session terminated. Please login again.",
|
|
428
|
+
name: "UnauthorizedError"
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
strapi2.log.info(`[magic-sessionmanager] ✅ Refresh token allowed for session ${matchingSession.id}`);
|
|
434
|
+
}
|
|
435
|
+
} catch (err) {
|
|
436
|
+
strapi2.log.error("[magic-sessionmanager] Error checking refresh token:", err);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
await next();
|
|
440
|
+
if (isRefreshToken && ctx.status === 200 && ctx.body && ctx.body.jwt) {
|
|
441
|
+
try {
|
|
442
|
+
const oldRefreshToken = ctx.request.body?.refreshToken;
|
|
443
|
+
const newAccessToken = ctx.body.jwt;
|
|
444
|
+
const newRefreshToken = ctx.body.refreshToken;
|
|
445
|
+
if (oldRefreshToken) {
|
|
446
|
+
const allSessions = await strapi2.entityService.findMany("plugin::magic-sessionmanager.session", {
|
|
447
|
+
filters: {
|
|
448
|
+
isActive: true
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
const matchingSession = allSessions.find((session2) => {
|
|
452
|
+
if (!session2.refreshToken) return false;
|
|
453
|
+
try {
|
|
454
|
+
const decrypted = decryptToken$2(session2.refreshToken);
|
|
455
|
+
return decrypted === oldRefreshToken;
|
|
456
|
+
} catch (err) {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
if (matchingSession) {
|
|
461
|
+
const encryptedToken = newAccessToken ? encryptToken$1(newAccessToken) : matchingSession.token;
|
|
462
|
+
const encryptedRefreshToken = newRefreshToken ? encryptToken$1(newRefreshToken) : matchingSession.refreshToken;
|
|
463
|
+
await strapi2.entityService.update("plugin::magic-sessionmanager.session", matchingSession.id, {
|
|
464
|
+
data: {
|
|
465
|
+
token: encryptedToken,
|
|
466
|
+
refreshToken: encryptedRefreshToken,
|
|
467
|
+
lastActive: /* @__PURE__ */ new Date()
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
strapi2.log.info(`[magic-sessionmanager] 🔄 Tokens refreshed for session ${matchingSession.id}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
} catch (err) {
|
|
474
|
+
strapi2.log.error("[magic-sessionmanager] Error updating refreshed tokens:", err);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
strapi2.log.info("[magic-sessionmanager] ✅ Refresh Token interceptor middleware mounted");
|
|
399
479
|
strapi2.server.use(
|
|
400
480
|
lastSeen({ strapi: strapi2, sessionService })
|
|
401
481
|
);
|
|
@@ -480,6 +560,10 @@ const attributes = {
|
|
|
480
560
|
type: "text",
|
|
481
561
|
"private": true
|
|
482
562
|
},
|
|
563
|
+
refreshToken: {
|
|
564
|
+
type: "text",
|
|
565
|
+
"private": true
|
|
566
|
+
},
|
|
483
567
|
loginTime: {
|
|
484
568
|
type: "datetime",
|
|
485
569
|
required: true
|
|
@@ -760,14 +844,16 @@ var session$3 = {
|
|
|
760
844
|
},
|
|
761
845
|
/**
|
|
762
846
|
* Get user's sessions
|
|
763
|
-
* GET /magic-sessionmanager/user/:userId/sessions
|
|
764
|
-
*
|
|
847
|
+
* GET /magic-sessionmanager/user/:userId/sessions (Admin API)
|
|
848
|
+
* GET /api/magic-sessionmanager/user/:userId/sessions (Content API)
|
|
849
|
+
* SECURITY: Admins can view any user, Content API users only their own
|
|
765
850
|
*/
|
|
766
851
|
async getUserSessions(ctx) {
|
|
767
852
|
try {
|
|
768
853
|
const { userId } = ctx.params;
|
|
854
|
+
const isAdminRequest = ctx.state.userAbility || ctx.state.admin;
|
|
769
855
|
const requestingUserId = ctx.state.user?.id;
|
|
770
|
-
if (requestingUserId && String(requestingUserId) !== String(userId)) {
|
|
856
|
+
if (!isAdminRequest && requestingUserId && String(requestingUserId) !== String(userId)) {
|
|
771
857
|
strapi.log.warn(`[magic-sessionmanager] Security: User ${requestingUserId} tried to access sessions of user ${userId}`);
|
|
772
858
|
return ctx.forbidden("You can only access your own sessions");
|
|
773
859
|
}
|
|
@@ -1315,14 +1401,15 @@ const { encryptToken, decryptToken, generateSessionId } = encryption;
|
|
|
1315
1401
|
var session$1 = ({ strapi: strapi2 }) => ({
|
|
1316
1402
|
/**
|
|
1317
1403
|
* Create a new session record
|
|
1318
|
-
* @param {Object} params - { userId, ip, userAgent, token }
|
|
1404
|
+
* @param {Object} params - { userId, ip, userAgent, token, refreshToken }
|
|
1319
1405
|
* @returns {Promise<Object>} Created session
|
|
1320
1406
|
*/
|
|
1321
|
-
async createSession({ userId, ip = "unknown", userAgent = "unknown", token }) {
|
|
1407
|
+
async createSession({ userId, ip = "unknown", userAgent = "unknown", token, refreshToken }) {
|
|
1322
1408
|
try {
|
|
1323
1409
|
const now = /* @__PURE__ */ new Date();
|
|
1324
1410
|
const sessionId = generateSessionId(userId);
|
|
1325
1411
|
const encryptedToken = token ? encryptToken(token) : null;
|
|
1412
|
+
const encryptedRefreshToken = refreshToken ? encryptToken(refreshToken) : null;
|
|
1326
1413
|
const session2 = await strapi2.entityService.create("plugin::magic-sessionmanager.session", {
|
|
1327
1414
|
data: {
|
|
1328
1415
|
user: userId,
|
|
@@ -1332,7 +1419,9 @@ var session$1 = ({ strapi: strapi2 }) => ({
|
|
|
1332
1419
|
lastActive: now,
|
|
1333
1420
|
isActive: true,
|
|
1334
1421
|
token: encryptedToken,
|
|
1335
|
-
// ✅ Encrypted
|
|
1422
|
+
// ✅ Encrypted Access Token
|
|
1423
|
+
refreshToken: encryptedRefreshToken,
|
|
1424
|
+
// ✅ Encrypted Refresh Token
|
|
1336
1425
|
sessionId
|
|
1337
1426
|
// ✅ Unique identifier
|
|
1338
1427
|
}
|
package/dist/server/index.mjs
CHANGED
|
@@ -346,8 +346,10 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
|
|
|
346
346
|
userId: user.id,
|
|
347
347
|
ip,
|
|
348
348
|
userAgent,
|
|
349
|
-
token: ctx.body.jwt
|
|
350
|
-
// Store
|
|
349
|
+
token: ctx.body.jwt,
|
|
350
|
+
// Store Access Token (encrypted)
|
|
351
|
+
refreshToken: ctx.body.refreshToken
|
|
352
|
+
// Store Refresh Token (encrypted) if exists
|
|
351
353
|
});
|
|
352
354
|
strapi2.log.info(`[magic-sessionmanager] ✅ Session created for user ${user.id} (IP: ${ip})`);
|
|
353
355
|
if (geoData && (config2.enableEmailAlerts || config2.enableWebhooks)) {
|
|
@@ -392,6 +394,84 @@ var bootstrap$1 = async ({ strapi: strapi2 }) => {
|
|
|
392
394
|
}
|
|
393
395
|
});
|
|
394
396
|
strapi2.log.info("[magic-sessionmanager] ✅ Login/Logout interceptor middleware mounted");
|
|
397
|
+
strapi2.server.use(async (ctx, next) => {
|
|
398
|
+
const isRefreshToken = ctx.path === "/api/auth/refresh-token" && ctx.method === "POST";
|
|
399
|
+
if (isRefreshToken) {
|
|
400
|
+
try {
|
|
401
|
+
const refreshToken = ctx.request.body?.refreshToken;
|
|
402
|
+
if (refreshToken) {
|
|
403
|
+
const allSessions = await strapi2.entityService.findMany("plugin::magic-sessionmanager.session", {
|
|
404
|
+
filters: {
|
|
405
|
+
isActive: true
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
const matchingSession = allSessions.find((session2) => {
|
|
409
|
+
if (!session2.refreshToken) return false;
|
|
410
|
+
try {
|
|
411
|
+
const decrypted = decryptToken$2(session2.refreshToken);
|
|
412
|
+
return decrypted === refreshToken;
|
|
413
|
+
} catch (err) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
if (!matchingSession) {
|
|
418
|
+
strapi2.log.warn("[magic-sessionmanager] 🚫 Blocked refresh token request - no active session");
|
|
419
|
+
ctx.status = 401;
|
|
420
|
+
ctx.body = {
|
|
421
|
+
error: {
|
|
422
|
+
status: 401,
|
|
423
|
+
message: "Session terminated. Please login again.",
|
|
424
|
+
name: "UnauthorizedError"
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
strapi2.log.info(`[magic-sessionmanager] ✅ Refresh token allowed for session ${matchingSession.id}`);
|
|
430
|
+
}
|
|
431
|
+
} catch (err) {
|
|
432
|
+
strapi2.log.error("[magic-sessionmanager] Error checking refresh token:", err);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
await next();
|
|
436
|
+
if (isRefreshToken && ctx.status === 200 && ctx.body && ctx.body.jwt) {
|
|
437
|
+
try {
|
|
438
|
+
const oldRefreshToken = ctx.request.body?.refreshToken;
|
|
439
|
+
const newAccessToken = ctx.body.jwt;
|
|
440
|
+
const newRefreshToken = ctx.body.refreshToken;
|
|
441
|
+
if (oldRefreshToken) {
|
|
442
|
+
const allSessions = await strapi2.entityService.findMany("plugin::magic-sessionmanager.session", {
|
|
443
|
+
filters: {
|
|
444
|
+
isActive: true
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
const matchingSession = allSessions.find((session2) => {
|
|
448
|
+
if (!session2.refreshToken) return false;
|
|
449
|
+
try {
|
|
450
|
+
const decrypted = decryptToken$2(session2.refreshToken);
|
|
451
|
+
return decrypted === oldRefreshToken;
|
|
452
|
+
} catch (err) {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
if (matchingSession) {
|
|
457
|
+
const encryptedToken = newAccessToken ? encryptToken$1(newAccessToken) : matchingSession.token;
|
|
458
|
+
const encryptedRefreshToken = newRefreshToken ? encryptToken$1(newRefreshToken) : matchingSession.refreshToken;
|
|
459
|
+
await strapi2.entityService.update("plugin::magic-sessionmanager.session", matchingSession.id, {
|
|
460
|
+
data: {
|
|
461
|
+
token: encryptedToken,
|
|
462
|
+
refreshToken: encryptedRefreshToken,
|
|
463
|
+
lastActive: /* @__PURE__ */ new Date()
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
strapi2.log.info(`[magic-sessionmanager] 🔄 Tokens refreshed for session ${matchingSession.id}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
} catch (err) {
|
|
470
|
+
strapi2.log.error("[magic-sessionmanager] Error updating refreshed tokens:", err);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
strapi2.log.info("[magic-sessionmanager] ✅ Refresh Token interceptor middleware mounted");
|
|
395
475
|
strapi2.server.use(
|
|
396
476
|
lastSeen({ strapi: strapi2, sessionService })
|
|
397
477
|
);
|
|
@@ -476,6 +556,10 @@ const attributes = {
|
|
|
476
556
|
type: "text",
|
|
477
557
|
"private": true
|
|
478
558
|
},
|
|
559
|
+
refreshToken: {
|
|
560
|
+
type: "text",
|
|
561
|
+
"private": true
|
|
562
|
+
},
|
|
479
563
|
loginTime: {
|
|
480
564
|
type: "datetime",
|
|
481
565
|
required: true
|
|
@@ -756,14 +840,16 @@ var session$3 = {
|
|
|
756
840
|
},
|
|
757
841
|
/**
|
|
758
842
|
* Get user's sessions
|
|
759
|
-
* GET /magic-sessionmanager/user/:userId/sessions
|
|
760
|
-
*
|
|
843
|
+
* GET /magic-sessionmanager/user/:userId/sessions (Admin API)
|
|
844
|
+
* GET /api/magic-sessionmanager/user/:userId/sessions (Content API)
|
|
845
|
+
* SECURITY: Admins can view any user, Content API users only their own
|
|
761
846
|
*/
|
|
762
847
|
async getUserSessions(ctx) {
|
|
763
848
|
try {
|
|
764
849
|
const { userId } = ctx.params;
|
|
850
|
+
const isAdminRequest = ctx.state.userAbility || ctx.state.admin;
|
|
765
851
|
const requestingUserId = ctx.state.user?.id;
|
|
766
|
-
if (requestingUserId && String(requestingUserId) !== String(userId)) {
|
|
852
|
+
if (!isAdminRequest && requestingUserId && String(requestingUserId) !== String(userId)) {
|
|
767
853
|
strapi.log.warn(`[magic-sessionmanager] Security: User ${requestingUserId} tried to access sessions of user ${userId}`);
|
|
768
854
|
return ctx.forbidden("You can only access your own sessions");
|
|
769
855
|
}
|
|
@@ -1311,14 +1397,15 @@ const { encryptToken, decryptToken, generateSessionId } = encryption;
|
|
|
1311
1397
|
var session$1 = ({ strapi: strapi2 }) => ({
|
|
1312
1398
|
/**
|
|
1313
1399
|
* Create a new session record
|
|
1314
|
-
* @param {Object} params - { userId, ip, userAgent, token }
|
|
1400
|
+
* @param {Object} params - { userId, ip, userAgent, token, refreshToken }
|
|
1315
1401
|
* @returns {Promise<Object>} Created session
|
|
1316
1402
|
*/
|
|
1317
|
-
async createSession({ userId, ip = "unknown", userAgent = "unknown", token }) {
|
|
1403
|
+
async createSession({ userId, ip = "unknown", userAgent = "unknown", token, refreshToken }) {
|
|
1318
1404
|
try {
|
|
1319
1405
|
const now = /* @__PURE__ */ new Date();
|
|
1320
1406
|
const sessionId = generateSessionId(userId);
|
|
1321
1407
|
const encryptedToken = token ? encryptToken(token) : null;
|
|
1408
|
+
const encryptedRefreshToken = refreshToken ? encryptToken(refreshToken) : null;
|
|
1322
1409
|
const session2 = await strapi2.entityService.create("plugin::magic-sessionmanager.session", {
|
|
1323
1410
|
data: {
|
|
1324
1411
|
user: userId,
|
|
@@ -1328,7 +1415,9 @@ var session$1 = ({ strapi: strapi2 }) => ({
|
|
|
1328
1415
|
lastActive: now,
|
|
1329
1416
|
isActive: true,
|
|
1330
1417
|
token: encryptedToken,
|
|
1331
|
-
// ✅ Encrypted
|
|
1418
|
+
// ✅ Encrypted Access Token
|
|
1419
|
+
refreshToken: encryptedRefreshToken,
|
|
1420
|
+
// ✅ Encrypted Refresh Token
|
|
1332
1421
|
sessionId
|
|
1333
1422
|
// ✅ Unique identifier
|
|
1334
1423
|
}
|
package/package.json
CHANGED
package/server/src/bootstrap.js
CHANGED
|
@@ -235,7 +235,8 @@ module.exports = async ({ strapi }) => {
|
|
|
235
235
|
userId: user.id,
|
|
236
236
|
ip,
|
|
237
237
|
userAgent,
|
|
238
|
-
token: ctx.body.jwt,
|
|
238
|
+
token: ctx.body.jwt, // Store Access Token (encrypted)
|
|
239
|
+
refreshToken: ctx.body.refreshToken, // Store Refresh Token (encrypted) if exists
|
|
239
240
|
});
|
|
240
241
|
|
|
241
242
|
strapi.log.info(`[magic-sessionmanager] ✅ Session created for user ${user.id} (IP: ${ip})`);
|
|
@@ -293,6 +294,107 @@ module.exports = async ({ strapi }) => {
|
|
|
293
294
|
|
|
294
295
|
strapi.log.info('[magic-sessionmanager] ✅ Login/Logout interceptor middleware mounted');
|
|
295
296
|
|
|
297
|
+
// Middleware to block refresh token requests for terminated sessions
|
|
298
|
+
strapi.server.use(async (ctx, next) => {
|
|
299
|
+
// Check if this is a refresh token request
|
|
300
|
+
const isRefreshToken = ctx.path === '/api/auth/refresh-token' && ctx.method === 'POST';
|
|
301
|
+
|
|
302
|
+
if (isRefreshToken) {
|
|
303
|
+
try {
|
|
304
|
+
const refreshToken = ctx.request.body?.refreshToken;
|
|
305
|
+
|
|
306
|
+
if (refreshToken) {
|
|
307
|
+
// Find session with this refresh token
|
|
308
|
+
const allSessions = await strapi.entityService.findMany('plugin::magic-sessionmanager.session', {
|
|
309
|
+
filters: {
|
|
310
|
+
isActive: true,
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Find matching session by decrypting and comparing refresh tokens
|
|
315
|
+
const matchingSession = allSessions.find(session => {
|
|
316
|
+
if (!session.refreshToken) return false;
|
|
317
|
+
try {
|
|
318
|
+
const decrypted = decryptToken(session.refreshToken);
|
|
319
|
+
return decrypted === refreshToken;
|
|
320
|
+
} catch (err) {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
if (!matchingSession) {
|
|
326
|
+
// No active session with this refresh token → Block!
|
|
327
|
+
strapi.log.warn('[magic-sessionmanager] 🚫 Blocked refresh token request - no active session');
|
|
328
|
+
ctx.status = 401;
|
|
329
|
+
ctx.body = {
|
|
330
|
+
error: {
|
|
331
|
+
status: 401,
|
|
332
|
+
message: 'Session terminated. Please login again.',
|
|
333
|
+
name: 'UnauthorizedError'
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
return; // Don't continue
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
strapi.log.info(`[magic-sessionmanager] ✅ Refresh token allowed for session ${matchingSession.id}`);
|
|
340
|
+
}
|
|
341
|
+
} catch (err) {
|
|
342
|
+
strapi.log.error('[magic-sessionmanager] Error checking refresh token:', err);
|
|
343
|
+
// On error, allow request to continue (fail-open for availability)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Continue with request
|
|
348
|
+
await next();
|
|
349
|
+
|
|
350
|
+
// AFTER: If refresh token response was successful, update session with new tokens
|
|
351
|
+
if (isRefreshToken && ctx.status === 200 && ctx.body && ctx.body.jwt) {
|
|
352
|
+
try {
|
|
353
|
+
const oldRefreshToken = ctx.request.body?.refreshToken;
|
|
354
|
+
const newAccessToken = ctx.body.jwt;
|
|
355
|
+
const newRefreshToken = ctx.body.refreshToken;
|
|
356
|
+
|
|
357
|
+
if (oldRefreshToken) {
|
|
358
|
+
// Find session and update with new tokens
|
|
359
|
+
const allSessions = await strapi.entityService.findMany('plugin::magic-sessionmanager.session', {
|
|
360
|
+
filters: {
|
|
361
|
+
isActive: true,
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const matchingSession = allSessions.find(session => {
|
|
366
|
+
if (!session.refreshToken) return false;
|
|
367
|
+
try {
|
|
368
|
+
const decrypted = decryptToken(session.refreshToken);
|
|
369
|
+
return decrypted === oldRefreshToken;
|
|
370
|
+
} catch (err) {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
if (matchingSession) {
|
|
376
|
+
const encryptedToken = newAccessToken ? encryptToken(newAccessToken) : matchingSession.token;
|
|
377
|
+
const encryptedRefreshToken = newRefreshToken ? encryptToken(newRefreshToken) : matchingSession.refreshToken;
|
|
378
|
+
|
|
379
|
+
await strapi.entityService.update('plugin::magic-sessionmanager.session', matchingSession.id, {
|
|
380
|
+
data: {
|
|
381
|
+
token: encryptedToken,
|
|
382
|
+
refreshToken: encryptedRefreshToken,
|
|
383
|
+
lastActive: new Date(),
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
strapi.log.info(`[magic-sessionmanager] 🔄 Tokens refreshed for session ${matchingSession.id}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
} catch (err) {
|
|
391
|
+
strapi.log.error('[magic-sessionmanager] Error updating refreshed tokens:', err);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
strapi.log.info('[magic-sessionmanager] ✅ Refresh Token interceptor middleware mounted');
|
|
397
|
+
|
|
296
398
|
// Mount lastSeen update middleware
|
|
297
399
|
strapi.server.use(
|
|
298
400
|
require('./middlewares/last-seen')({ strapi, sessionService })
|
|
@@ -57,16 +57,21 @@ module.exports = {
|
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Get user's sessions
|
|
60
|
-
* GET /magic-sessionmanager/user/:userId/sessions
|
|
61
|
-
*
|
|
60
|
+
* GET /magic-sessionmanager/user/:userId/sessions (Admin API)
|
|
61
|
+
* GET /api/magic-sessionmanager/user/:userId/sessions (Content API)
|
|
62
|
+
* SECURITY: Admins can view any user, Content API users only their own
|
|
62
63
|
*/
|
|
63
64
|
async getUserSessions(ctx) {
|
|
64
65
|
try {
|
|
65
66
|
const { userId } = ctx.params;
|
|
67
|
+
|
|
68
|
+
// Check if this is an admin request
|
|
69
|
+
const isAdminRequest = ctx.state.userAbility || ctx.state.admin;
|
|
66
70
|
const requestingUserId = ctx.state.user?.id;
|
|
67
71
|
|
|
68
|
-
// SECURITY CHECK:
|
|
69
|
-
|
|
72
|
+
// SECURITY CHECK: Content API users can only see their own sessions
|
|
73
|
+
// Admins can see any user's sessions
|
|
74
|
+
if (!isAdminRequest && requestingUserId && String(requestingUserId) !== String(userId)) {
|
|
70
75
|
strapi.log.warn(`[magic-sessionmanager] Security: User ${requestingUserId} tried to access sessions of user ${userId}`);
|
|
71
76
|
return ctx.forbidden('You can only access your own sessions');
|
|
72
77
|
}
|
|
@@ -17,18 +17,19 @@ const { encryptToken, decryptToken, generateSessionId } = require('../utils/encr
|
|
|
17
17
|
module.exports = ({ strapi }) => ({
|
|
18
18
|
/**
|
|
19
19
|
* Create a new session record
|
|
20
|
-
* @param {Object} params - { userId, ip, userAgent, token }
|
|
20
|
+
* @param {Object} params - { userId, ip, userAgent, token, refreshToken }
|
|
21
21
|
* @returns {Promise<Object>} Created session
|
|
22
22
|
*/
|
|
23
|
-
async createSession({ userId, ip = 'unknown', userAgent = 'unknown', token }) {
|
|
23
|
+
async createSession({ userId, ip = 'unknown', userAgent = 'unknown', token, refreshToken }) {
|
|
24
24
|
try {
|
|
25
25
|
const now = new Date();
|
|
26
26
|
|
|
27
27
|
// Generate unique session ID
|
|
28
28
|
const sessionId = generateSessionId(userId);
|
|
29
29
|
|
|
30
|
-
// Encrypt JWT
|
|
30
|
+
// Encrypt JWT tokens before storing (both access and refresh)
|
|
31
31
|
const encryptedToken = token ? encryptToken(token) : null;
|
|
32
|
+
const encryptedRefreshToken = refreshToken ? encryptToken(refreshToken) : null;
|
|
32
33
|
|
|
33
34
|
const session = await strapi.entityService.create('plugin::magic-sessionmanager.session', {
|
|
34
35
|
data: {
|
|
@@ -38,8 +39,9 @@ module.exports = ({ strapi }) => ({
|
|
|
38
39
|
loginTime: now,
|
|
39
40
|
lastActive: now,
|
|
40
41
|
isActive: true,
|
|
41
|
-
token: encryptedToken,
|
|
42
|
-
|
|
42
|
+
token: encryptedToken, // ✅ Encrypted Access Token
|
|
43
|
+
refreshToken: encryptedRefreshToken, // ✅ Encrypted Refresh Token
|
|
44
|
+
sessionId: sessionId, // ✅ Unique identifier
|
|
43
45
|
},
|
|
44
46
|
});
|
|
45
47
|
|