mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Reject WebSocket tokens minted before a password change
Stamp the user's password_version onto the ephemeral ws token and verify it on connect, closing the socket (4001) when it no longer matches, so a token issued before a password reset can't be replayed. Tokens minted without a version are treated as version 0, matching the JWT pv-claim semantics.
This commit is contained in:
@@ -1335,7 +1335,10 @@ export function deleteMcpToken(userId: number, tokenId: string): { error?: strin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createWsToken(userId: number): { error?: string; status?: number; token?: string } {
|
||||
const token = createEphemeralToken(userId, 'ws');
|
||||
// Bind the ws-token to the user's current password_version so a token minted
|
||||
// before a password reset is rejected on connect (defence-in-depth session gate).
|
||||
const pv = (db.prepare('SELECT password_version FROM users WHERE id = ?').get(userId) as { password_version?: number } | undefined)?.password_version ?? 0;
|
||||
const token = createEphemeralToken(userId, 'ws', { pv });
|
||||
if (!token) return { error: 'Service unavailable', status: 503 };
|
||||
return { token };
|
||||
}
|
||||
|
||||
@@ -11,15 +11,31 @@ interface TokenEntry {
|
||||
userId: number;
|
||||
purpose: string;
|
||||
expiresAt: number;
|
||||
/**
|
||||
* Snapshot of the user's `password_version` at mint time, used for the
|
||||
* defence-in-depth session gate on WebSocket connects. `undefined` for
|
||||
* tokens minted without a version (legacy/other purposes), which callers
|
||||
* treat as version 0 — mirroring the JWT `pv` claim semantics.
|
||||
*/
|
||||
pv?: number;
|
||||
}
|
||||
|
||||
export interface EphemeralTokenMeta {
|
||||
/** Bind the token to the user's current password_version (session gate). */
|
||||
pv?: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, TokenEntry>();
|
||||
|
||||
export function createEphemeralToken(userId: number, purpose: string): string | null {
|
||||
export function createEphemeralToken(
|
||||
userId: number,
|
||||
purpose: string,
|
||||
meta?: EphemeralTokenMeta,
|
||||
): string | null {
|
||||
if (store.size >= MAX_STORE_SIZE) return null;
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const ttl = TTL[purpose] ?? 60_000;
|
||||
store.set(token, { userId, purpose, expiresAt: Date.now() + ttl });
|
||||
store.set(token, { userId, purpose, expiresAt: Date.now() + ttl, pv: meta?.pv });
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -31,6 +47,22 @@ export function consumeEphemeralToken(token: string, purpose: string): number |
|
||||
return entry.userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `consumeEphemeralToken`, but also returns the `password_version` the
|
||||
* token was minted with. Used by the WebSocket handshake so a token issued
|
||||
* before a password change can be rejected even within its short TTL.
|
||||
*/
|
||||
export function consumeEphemeralTokenWithMeta(
|
||||
token: string,
|
||||
purpose: string,
|
||||
): { userId: number; pv?: number } | null {
|
||||
const entry = store.get(token);
|
||||
if (!entry) return null;
|
||||
store.delete(token);
|
||||
if (entry.purpose !== purpose || Date.now() > entry.expiresAt) return null;
|
||||
return { userId: entry.userId, pv: entry.pv };
|
||||
}
|
||||
|
||||
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function startTokenCleanup(): void {
|
||||
|
||||
+21
-8
@@ -1,6 +1,6 @@
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { db, canAccessTrip } from './db/database';
|
||||
import { consumeEphemeralToken } from './services/ephemeralTokens';
|
||||
import { consumeEphemeralTokenWithMeta } from './services/ephemeralTokens';
|
||||
import { User } from './types';
|
||||
import http from 'node:http';
|
||||
|
||||
@@ -69,20 +69,33 @@ function setupWebSocket(server: http.Server): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = consumeEphemeralToken(token, 'ws');
|
||||
if (!userId) {
|
||||
const consumed = consumeEphemeralTokenWithMeta(token, 'ws');
|
||||
if (!consumed) {
|
||||
nws.close(4001, 'Invalid or expired token');
|
||||
return;
|
||||
}
|
||||
const { userId } = consumed;
|
||||
|
||||
let user: User | undefined;
|
||||
user = db.prepare(
|
||||
'SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?'
|
||||
).get(userId) as User | undefined;
|
||||
if (!user) {
|
||||
let row: (User & { password_version?: number }) | undefined;
|
||||
row = db.prepare(
|
||||
'SELECT id, username, email, role, mfa_enabled, password_version FROM users WHERE id = ?'
|
||||
).get(userId) as (User & { password_version?: number }) | undefined;
|
||||
if (!row) {
|
||||
nws.close(4001, 'User not found');
|
||||
return;
|
||||
}
|
||||
// Session gate (defence-in-depth): reject a ws-token minted before a
|
||||
// password change. Tokens carry the pv they were issued with; tokens
|
||||
// minted without a pv (legacy) are treated as version 0, matching the
|
||||
// JWT `pv` claim semantics in verifyJwtAndLoadUser.
|
||||
const tokenPv = typeof consumed.pv === 'number' ? consumed.pv : 0;
|
||||
const currentPv = typeof row.password_version === 'number' ? row.password_version : 0;
|
||||
if (tokenPv !== currentPv) {
|
||||
nws.close(4001, 'Invalid or expired token');
|
||||
return;
|
||||
}
|
||||
// Don't leak password_version beyond the handshake.
|
||||
const { password_version: _pv, ...user } = row;
|
||||
const requireMfa = (db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined)?.value === 'true';
|
||||
const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true;
|
||||
if (requireMfa && !mfaOk) {
|
||||
|
||||
@@ -51,6 +51,7 @@ import { createUser, createTrip } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { setupWebSocket } from '../../src/websocket';
|
||||
import { createEphemeralToken } from '../../src/services/ephemeralTokens';
|
||||
import { createWsToken } from '../../src/services/authService';
|
||||
|
||||
let server: http.Server;
|
||||
let wsUrl: string;
|
||||
@@ -429,6 +430,51 @@ describe('WS auth edge cases', () => {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('WS-027 — ws-token minted before a password change is rejected (session gate)', async () => {
|
||||
// createWsToken stamps the user's current password_version (0) into the token.
|
||||
const { user } = createUser(testDb);
|
||||
const result = createWsToken(user.id);
|
||||
const token = result.token!;
|
||||
|
||||
// Simulate a password reset bumping the version AFTER the token was issued.
|
||||
testDb.prepare('UPDATE users SET password_version = password_version + 1 WHERE id = ?').run(user.id);
|
||||
|
||||
const closeCode = await new Promise<number>((resolve) => {
|
||||
const ws = new WebSocket(`${wsUrl}?token=${encodeURIComponent(token)}`);
|
||||
ws.once('close', (code) => resolve(code));
|
||||
ws.once('error', () => resolve(4001));
|
||||
});
|
||||
expect(closeCode).toBe(4001);
|
||||
});
|
||||
|
||||
it('WS-028 — ws-token whose password_version still matches connects successfully', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Bump the version first, THEN mint — the token captures the current pv.
|
||||
testDb.prepare('UPDATE users SET password_version = 3 WHERE id = ?').run(user.id);
|
||||
const result = createWsToken(user.id);
|
||||
const client = await connectWs(result.token!);
|
||||
try {
|
||||
const msg = await client.next();
|
||||
expect(msg.type).toBe('welcome');
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('WS-029 — legacy token without a pv is rejected once the user resets their password', async () => {
|
||||
// Tokens minted via createEphemeralToken carry no pv (treated as version 0).
|
||||
const { user } = createUser(testDb);
|
||||
const token = createEphemeralToken(user.id, 'ws')!;
|
||||
testDb.prepare('UPDATE users SET password_version = 1 WHERE id = ?').run(user.id);
|
||||
|
||||
const closeCode = await new Promise<number>((resolve) => {
|
||||
const ws = new WebSocket(`${wsUrl}?token=${encodeURIComponent(token)}`);
|
||||
ws.once('close', (code) => resolve(code));
|
||||
ws.once('error', () => resolve(4001));
|
||||
});
|
||||
expect(closeCode).toBe(4001);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user