Skip to content

Commit 5c6c4ae

Browse files
committed
fix: prevent old terminated sessions from blocking new magic-link logins
Two bugs caused new logins to be blocked after session termination: 1. Login interceptor only checked POST, but magic-link login is GET. Also missed OTP verify and MFA TOTP endpoints. Now intercepts all magic-link login methods (GET /login, POST /otp/verify, POST /verify-mfa-totp, POST /login-totp). 2. Fallback logic in JWT verify, last-seen middleware, and session-required policy blocked ALL JWTs when ANY old terminated session existed for the user. Now only the specific terminated token (matched by tokenHash) is blocked. Old terminated sessions no longer prevent re-login.
1 parent b334a23 commit 5c6c4ae

3 files changed

Lines changed: 74 additions & 65 deletions

File tree

server/src/bootstrap.js

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,16 @@ module.exports = async ({ strapi }) => {
170170

171171
// Check if this was a successful login request
172172
const isAuthLocal = ctx.path === '/api/auth/local' && ctx.method === 'POST';
173-
const isMagicLink = ctx.path.includes('/magic-link/login') && ctx.method === 'POST';
173+
// Magic-link login is GET (/api/magic-link/login?loginToken=XXX)
174+
// TOTP-as-primary is POST (/api/magic-link/login-totp)
175+
const isMagicLinkLogin = ctx.path.includes('/magic-link/login') && (ctx.method === 'GET' || ctx.method === 'POST');
176+
// MFA TOTP verification after magic link click
177+
const isMagicLinkMFA = ctx.path.includes('/magic-link/verify-mfa-totp') && ctx.method === 'POST';
178+
// OTP verification completes the login flow and returns a JWT
179+
const isMagicLinkOTP = ctx.path.includes('/magic-link/otp/verify') && ctx.method === 'POST';
180+
const isMagicLink = isMagicLinkLogin || isMagicLinkMFA || isMagicLinkOTP;
174181

175-
if ((isAuthLocal || isMagicLink) && ctx.status === 200 && ctx.body && ctx.body.user) {
182+
if ((isAuthLocal || isMagicLink) && ctx.status === 200 && ctx.body && ctx.body.jwt && ctx.body.user) {
176183
try {
177184
const user = ctx.body.user;
178185

@@ -766,23 +773,14 @@ async function registerSessionAwareAuthStrategy(strapi, log) {
766773
return decoded;
767774
}
768775

769-
// Check for manually terminated sessions
770-
const terminatedSessions = await strapi.documents(SESSION_UID).findMany({
771-
filters: { user: { documentId: userDocId }, terminatedManually: true },
772-
limit: 1,
773-
});
774-
775-
if (terminatedSessions && terminatedSessions.length > 0) {
776-
strapi.log.info(
777-
`[magic-sessionmanager] [JWT-BLOCKED] User ${userDocId.substring(0, 8)}... has terminated sessions`
778-
);
779-
return null;
780-
}
781-
782-
// No sessions at all
776+
// No active sessions exist for this user and no session matched this token.
777+
// Old terminated sessions from PREVIOUS logins must NOT block new tokens.
778+
// The token-specific terminated check (by tokenHash above) already handles
779+
// blocking the exact token that was terminated. Blocking here based on
780+
// unrelated old sessions would prevent re-login after session termination.
783781
if (strictMode) {
784782
strapi.log.info(
785-
`[magic-sessionmanager] [JWT-BLOCKED] No sessions exist for user ${userDocId.substring(0, 8)}... (strictMode)`
783+
`[magic-sessionmanager] [JWT-BLOCKED] No active sessions for user ${userDocId.substring(0, 8)}... (strictMode)`
786784
);
787785
return null;
788786
}

server/src/middlewares/last-seen.js

Lines changed: 35 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -141,39 +141,43 @@ module.exports = ({ strapi, sessionService }) => {
141141
});
142142

143143
if (inactiveSessions && inactiveSessions.length > 0) {
144-
// Check if ANY session was manually terminated
145-
const manuallyTerminated = inactiveSessions.find(s => s.terminatedManually === true);
144+
// Find a session that can be reactivated (timed out, NOT manually terminated)
145+
const reactivatable = inactiveSessions.find(s => s.terminatedManually !== true);
146146

147-
if (manuallyTerminated) {
148-
// User was explicitly logged out -> BLOCK
149-
strapi.log.info(`[magic-sessionmanager] [BLOCKED] User ${userDocId.substring(0, 8)}... was manually logged out`);
150-
return ctx.unauthorized('Session has been terminated. Please login again.');
147+
if (reactivatable) {
148+
// SECURITY: Check maxSessionAge to prevent indefinite reactivation
149+
const maxAgeDays = config.maxSessionAgeDays || 30;
150+
const loginTime = reactivatable.loginTime
151+
? new Date(reactivatable.loginTime).getTime()
152+
: (reactivatable.lastActive ? new Date(reactivatable.lastActive).getTime() : 0);
153+
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
154+
const isExpired = loginTime > 0 && (Date.now() - loginTime) > maxAgeMs;
155+
156+
if (isExpired) {
157+
strapi.log.info(`[magic-sessionmanager] [BLOCKED] Session exceeded max age of ${maxAgeDays} days (user: ${userDocId.substring(0, 8)}...)`);
158+
return ctx.unauthorized('Session expired. Please login again.');
159+
}
160+
161+
await strapi.documents(SESSION_UID).update({
162+
documentId: reactivatable.documentId,
163+
data: {
164+
isActive: true,
165+
lastActive: new Date(),
166+
},
167+
});
168+
strapi.log.info(`[magic-sessionmanager] [REACTIVATED] Session reactivated for user ${userDocId.substring(0, 8)}...`);
169+
// Continue - session is now active
170+
} else {
171+
// Only terminated sessions exist - do NOT block here.
172+
// Old terminated sessions from previous logins must not prevent
173+
// new logins (e.g., user got a new magic link after being terminated).
174+
// The JWT verify wrapper already blocks the specific terminated token.
175+
if (strictMode) {
176+
strapi.log.info(`[magic-sessionmanager] [BLOCKED] No active session for user ${userDocId.substring(0, 8)}... (strictMode)`);
177+
return ctx.unauthorized('No valid session. Please login again.');
178+
}
179+
strapi.log.warn(`[magic-sessionmanager] [WARN] No active session for user ${userDocId.substring(0, 8)}... (allowing)`);
151180
}
152-
153-
// Session was deactivated by timeout -> REACTIVATE most recent one
154-
// SECURITY: Check maxSessionAge to prevent indefinite reactivation
155-
const sessionToReactivate = inactiveSessions[0];
156-
const maxAgeDays = config.maxSessionAgeDays || 30;
157-
const loginTime = sessionToReactivate.loginTime
158-
? new Date(sessionToReactivate.loginTime).getTime()
159-
: (sessionToReactivate.lastActive ? new Date(sessionToReactivate.lastActive).getTime() : 0);
160-
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
161-
const isExpired = loginTime > 0 && (Date.now() - loginTime) > maxAgeMs;
162-
163-
if (isExpired) {
164-
strapi.log.info(`[magic-sessionmanager] [BLOCKED] Session exceeded max age of ${maxAgeDays} days (user: ${userDocId.substring(0, 8)}...)`);
165-
return ctx.unauthorized('Session expired. Please login again.');
166-
}
167-
168-
await strapi.documents(SESSION_UID).update({
169-
documentId: sessionToReactivate.documentId,
170-
data: {
171-
isActive: true,
172-
lastActive: new Date(),
173-
},
174-
});
175-
strapi.log.info(`[magic-sessionmanager] [REACTIVATED] Session reactivated for user ${userDocId.substring(0, 8)}...`);
176-
// Continue - session is now active
177181
} else {
178182
// No sessions exist at all - session was never created
179183
if (strictMode) {

server/src/policies/session-required.js

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -69,28 +69,35 @@ module.exports = async (policyContext, _policyConfig, { strapi }) => {
6969
});
7070

7171
if (inactiveSessions && inactiveSessions.length > 0) {
72-
// Check if ANY session was manually terminated
73-
const manuallyTerminated = inactiveSessions.find(s => s.terminatedManually === true);
72+
// Find a session that can be reactivated (timed out, NOT manually terminated)
73+
const reactivatable = inactiveSessions.find(s => s.terminatedManually !== true);
7474

75-
if (manuallyTerminated) {
76-
// User was explicitly logged out → BLOCK
75+
if (reactivatable) {
76+
await strapi.documents(SESSION_UID).update({
77+
documentId: reactivatable.documentId,
78+
data: {
79+
isActive: true,
80+
lastActive: new Date(),
81+
},
82+
});
7783
strapi.log.info(
78-
`[magic-sessionmanager] [POLICY-BLOCKED] User ${userDocId.substring(0, 8)}... was manually logged out`
84+
`[magic-sessionmanager] [POLICY-REACTIVATED] Session reactivated for user ${userDocId.substring(0, 8)}...`
7985
);
80-
throw new errors.UnauthorizedError('Session terminated. Please login again.');
86+
return true;
8187
}
8288

83-
// Session was deactivated by timeout → REACTIVATE most recent one
84-
const sessionToReactivate = inactiveSessions[0];
85-
await strapi.documents(SESSION_UID).update({
86-
documentId: sessionToReactivate.documentId,
87-
data: {
88-
isActive: true,
89-
lastActive: new Date(),
90-
},
91-
});
92-
strapi.log.info(
93-
`[magic-sessionmanager] [POLICY-REACTIVATED] Session reactivated for user ${userDocId.substring(0, 8)}...`
89+
// Only terminated sessions exist - do NOT block here.
90+
// Old terminated sessions from previous logins must not prevent
91+
// new logins. The JWT verify wrapper already blocks the specific terminated token.
92+
if (strictMode) {
93+
strapi.log.info(
94+
`[magic-sessionmanager] [POLICY-BLOCKED] No active session for user ${userDocId.substring(0, 8)}... (strictMode)`
95+
);
96+
throw new errors.UnauthorizedError('No valid session. Please login again.');
97+
}
98+
99+
strapi.log.warn(
100+
`[magic-sessionmanager] [POLICY-WARN] No active session for user ${userDocId.substring(0, 8)}... (allowing)`
94101
);
95102
return true;
96103
}

0 commit comments

Comments
 (0)