mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-25 00:13:00 +00:00
fix(sessions): keep an ended session's row until it expires
A session renewed from a token issued before sessions were tracked has an id derived from that token, and the renewal inserts it only when no row by that id exists. The nightly purge deleted revoked rows straight away, so the next request with the still valid old token inserted the session again as an active one and got a fresh tracked token with a new lifetime. A logout, an ended session or a sign out of the others held only until the purge ran. The purge now deletes expired rows only, and a revoked row stays until its own expiry. A derived row is recorded with an expiry no earlier than the old token's, so by the time it goes the old token is refused by its signature check anyway.
This commit is contained in:
@@ -217,11 +217,16 @@ export class UserSessionsRepository extends TrekRepository<UserSessions> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The nightly purge: `DELETE FROM user_sessions WHERE expires_at <= ? OR revoked_at IS NOT NULL`.
|
||||
* A row that is gone refuses its token just as a revoked one does, so
|
||||
* nothing depends on keeping them.
|
||||
* The nightly purge: `DELETE FROM user_sessions WHERE expires_at <= ?`.
|
||||
*
|
||||
* A revoked row stays until its own expiry. A session renewed from a token
|
||||
* issued before sessions were tracked has an id derived from that token,
|
||||
* and only its revoked row stops the still valid old token from inserting
|
||||
* it again as a fresh, active session. Its `expires_at` is never earlier
|
||||
* than the old token's expiry, so once it has passed, the old token is dead
|
||||
* too and the row can go.
|
||||
*/
|
||||
async deleteInactive(now: string): Promise<number> {
|
||||
return await this.nativeDelete({ $or: [{ expires_at: { $lte: now } }, { revoked_at: { $ne: null } }] });
|
||||
return await this.nativeDelete({ expires_at: { $lte: now } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import { CronRegistrarService } from '../scheduling/cron-registrar.service';
|
||||
import { SessionsService } from './sessions.service';
|
||||
|
||||
/**
|
||||
* Nightly removal of the `user_sessions` rows that no longer let anyone in:
|
||||
* expired ones, and revoked ones. Every sign-in writes a row, so without this
|
||||
* the table would grow by one row per login for as long as the install runs.
|
||||
* Deleting a revoked row changes nothing for its token, which a missing row
|
||||
* refuses just the same.
|
||||
* Nightly removal of the expired `user_sessions` rows. Every sign-in writes a
|
||||
* row, so without this the table would grow by one row per login for as long
|
||||
* as the install runs. A revoked row waits for its own expiry: for a session
|
||||
* renewed from a token from before sessions were tracked, that row is what
|
||||
* keeps the old token from bringing the session back.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SessionPurgeJob implements OnApplicationBootstrap {
|
||||
@@ -25,7 +25,7 @@ export class SessionPurgeJob implements OnApplicationBootstrap {
|
||||
async tick(now: Date = new Date()): Promise<void> {
|
||||
try {
|
||||
const removed = await this.sessions.purgeInactive(now);
|
||||
if (removed > 0) logInfo(`Session purge: removed ${removed} expired or revoked session(s)`);
|
||||
if (removed > 0) logInfo(`Session purge: removed ${removed} expired session(s)`);
|
||||
} catch (err: unknown) {
|
||||
logError(`Session purge: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
@@ -119,6 +119,12 @@ export class SessionsService {
|
||||
* only if it is not there yet, so all of them land on one session. If that
|
||||
* session was ended in the meantime, nothing is renewed.
|
||||
*
|
||||
* The old token stays valid until its own expiry whatever happens to the
|
||||
* session, so the row is what refuses it after an end. Its `expires_at` is
|
||||
* therefore never earlier than the old token's, and the nightly purge
|
||||
* keeps a revoked row until that has passed: a purged row would let the
|
||||
* old token insert the session again as an active one.
|
||||
*
|
||||
* @txStandalone one statement, standing on its own like `renew`.
|
||||
*/
|
||||
private async renewUntracked(claims: RenewableSessionClaims, client: SessionClient): Promise<string | null> {
|
||||
@@ -127,11 +133,12 @@ export class SessionsService {
|
||||
const jti = legacySessionId(claims.token);
|
||||
const token = this.sign(user, claims.remember, jti);
|
||||
const { iat, exp } = this.lifetimeOf(token);
|
||||
const keepUntil = Math.max(exp, expiryOf(claims.token) ?? exp);
|
||||
await this.sessions.insertSessionIfAbsent({
|
||||
id: jti,
|
||||
user_id: user.id,
|
||||
created_at: dbNow(new Date(iat * 1000)),
|
||||
expires_at: dbNow(new Date(exp * 1000)),
|
||||
expires_at: dbNow(new Date(keepUntil * 1000)),
|
||||
user_agent: clipUserAgent(client.userAgent),
|
||||
});
|
||||
return (await this.sessions.findActive(jti, user.id, dbNow())) ? token : null;
|
||||
@@ -185,7 +192,11 @@ export class SessionsService {
|
||||
return this.sessions.revokeAllForUser(userId, dbNow(), exceptId);
|
||||
}
|
||||
|
||||
/** Remove the rows that refuse their token anyway: expired or revoked. */
|
||||
/**
|
||||
* Remove the expired rows. A revoked row is kept until it expires too, so
|
||||
* a session derived from a token from before tracking cannot come back
|
||||
* (see `renewUntracked`).
|
||||
*/
|
||||
async purgeInactive(now: Date): Promise<number> {
|
||||
return this.sessions.deleteInactive(dbNow(now));
|
||||
}
|
||||
@@ -208,6 +219,13 @@ export class SessionsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** The `exp` claim of a token, when it has a numeric one. */
|
||||
function expiryOf(token: string): number | undefined {
|
||||
const decoded = jwt.decode(token);
|
||||
const exp = decoded !== null && typeof decoded === 'object' ? (decoded as { exp?: unknown }).exp : undefined;
|
||||
return typeof exp === 'number' ? exp : undefined;
|
||||
}
|
||||
|
||||
function clipUserAgent(agent: string | null | undefined): string | null {
|
||||
return agent ? agent.slice(0, USER_AGENT_MAX_LENGTH) : null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user