fix(websockets): send cookies manually for session authentication
This commit is contained in:
parent
dad4ff62f1
commit
535c8afdb1
51
src/SessionWebSocketListener.ts
Normal file
51
src/SessionWebSocketListener.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import config from "config";
|
||||||
|
import cookie from "cookie";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import {Request} from "express";
|
||||||
|
import {Session} from "express-session";
|
||||||
|
import {IncomingMessage} from "http";
|
||||||
|
import {WebSocket} from "ws";
|
||||||
|
|
||||||
|
import Application from "./Application.js";
|
||||||
|
import RedisComponent from "./components/RedisComponent.js";
|
||||||
|
import {logger} from "./Logger.js";
|
||||||
|
import WebSocketListener from "./WebSocketListener.js";
|
||||||
|
|
||||||
|
export default abstract class SessionWebSocketListener<A extends Application> extends WebSocketListener<A> {
|
||||||
|
|
||||||
|
public async handle(socket: WebSocket, request: IncomingMessage): Promise<void> {
|
||||||
|
socket.once('message', (data, isBinary) => {
|
||||||
|
if (isBinary) return socket.close(1003);
|
||||||
|
|
||||||
|
const cookies = cookie.parse(data.toString());
|
||||||
|
const sid = cookieParser.signedCookie(cookies['connect.sid'], config.get('session.secret'));
|
||||||
|
|
||||||
|
if (!sid) {
|
||||||
|
socket.close(1002, 'Could not decrypt provided session cookie.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = this.getApp().as(RedisComponent).getStore();
|
||||||
|
store.get(sid, (err, session) => {
|
||||||
|
if (err || !session) {
|
||||||
|
logger.error(err, 'Error while initializing session in websocket for sid ' + sid);
|
||||||
|
socket.close(1011);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.id = sid;
|
||||||
|
|
||||||
|
store.createSession(<Request>request, session);
|
||||||
|
this.handleSessionSocket(socket, request, session as Session).catch(err => {
|
||||||
|
logger.error(err, 'Error in websocket listener.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract handleSessionSocket(
|
||||||
|
socket: WebSocket,
|
||||||
|
request: IncomingMessage,
|
||||||
|
session: Session,
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
@ -1,4 +1,3 @@
|
|||||||
import {Session} from "express-session";
|
|
||||||
import {IncomingMessage} from "http";
|
import {IncomingMessage} from "http";
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
|
|
||||||
@ -20,6 +19,5 @@ export default abstract class WebSocketListener<T extends Application> {
|
|||||||
public abstract handle(
|
public abstract handle(
|
||||||
socket: WebSocket,
|
socket: WebSocket,
|
||||||
request: IncomingMessage,
|
request: IncomingMessage,
|
||||||
session: Session | null,
|
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,7 @@ export default class WebsocketClient {
|
|||||||
const websocket = new WebSocket(this.websocketUrl);
|
const websocket = new WebSocket(this.websocketUrl);
|
||||||
websocket.onopen = () => {
|
websocket.onopen = () => {
|
||||||
console.debug('Websocket connected');
|
console.debug('Websocket connected');
|
||||||
|
websocket.send(document.cookie);
|
||||||
};
|
};
|
||||||
websocket.onmessage = (e) => {
|
websocket.onmessage = (e) => {
|
||||||
this.listener(websocket, e);
|
this.listener(websocket, e);
|
||||||
|
@ -3,10 +3,10 @@ import {IncomingMessage} from "http";
|
|||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
|
|
||||||
import Application from "../../Application.js";
|
import Application from "../../Application.js";
|
||||||
import WebSocketListener from "../../WebSocketListener.js";
|
import SessionWebSocketListener from "../../SessionWebSocketListener.js";
|
||||||
import MagicLink from "../models/MagicLink.js";
|
import MagicLink from "../models/MagicLink.js";
|
||||||
|
|
||||||
export default class MagicLinkWebSocketListener<A extends Application> extends WebSocketListener<A> {
|
export default class MagicLinkWebSocketListener<A extends Application> extends SessionWebSocketListener<A> {
|
||||||
private readonly connections: { [p: string]: (() => void)[] | undefined } = {};
|
private readonly connections: { [p: string]: (() => void)[] | undefined } = {};
|
||||||
|
|
||||||
public refreshMagicLink(sessionId: string): void {
|
public refreshMagicLink(sessionId: string): void {
|
||||||
@ -16,13 +16,7 @@ export default class MagicLinkWebSocketListener<A extends Application> extends W
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async handle(socket: WebSocket, request: IncomingMessage, session: Session | null): Promise<void> {
|
public async handleSessionSocket(socket: WebSocket, request: IncomingMessage, session: Session): Promise<void> {
|
||||||
// Drop if requested without session
|
|
||||||
if (!session) {
|
|
||||||
socket.close(1002, 'Session is required for this request.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refuse any incoming data
|
// Refuse any incoming data
|
||||||
socket.on('message', () => {
|
socket.on('message', () => {
|
||||||
socket.close(1003);
|
socket.close(1003);
|
||||||
@ -37,19 +31,22 @@ export default class MagicLinkWebSocketListener<A extends Application> extends W
|
|||||||
// Refresh if immediately applicable
|
// Refresh if immediately applicable
|
||||||
if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) {
|
if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) {
|
||||||
socket.send('refresh');
|
socket.send('refresh');
|
||||||
socket.close(1000);
|
const reason = magicLink ?
|
||||||
|
'Magic link state changed.' :
|
||||||
|
'Magic link not found for session ' + session.id;
|
||||||
|
socket.close(1000, reason);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validityTimeout = setTimeout(() => {
|
const validityTimeout = setTimeout(() => {
|
||||||
socket.send('refresh');
|
socket.send('refresh');
|
||||||
socket.close(1000);
|
socket.close(1000, 'Timed out');
|
||||||
}, magicLink.getExpirationDate().getTime() - new Date().getTime());
|
}, magicLink.getExpirationDate().getTime() - new Date().getTime());
|
||||||
|
|
||||||
const f = () => {
|
const f = () => {
|
||||||
clearTimeout(validityTimeout);
|
clearTimeout(validityTimeout);
|
||||||
socket.send('refresh');
|
socket.send('refresh');
|
||||||
socket.close(1000);
|
socket.close(1000, 'Closed by server');
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
|
@ -29,8 +29,9 @@ export default class SessionComponent extends ApplicationComponent {
|
|||||||
store: this.storeComponent.getStore(),
|
store: this.storeComponent.getStore(),
|
||||||
resave: false,
|
resave: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
httpOnly: true,
|
httpOnly: false,
|
||||||
secure: config.get('session.cookie.secure'),
|
secure: config.get('session.cookie.secure'),
|
||||||
|
sameSite: 'strict',
|
||||||
},
|
},
|
||||||
rolling: true,
|
rolling: true,
|
||||||
}));
|
}));
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
import config from "config";
|
import config from "config";
|
||||||
import cookie from "cookie";
|
import {Express, Router} from "express";
|
||||||
import cookieParser from "cookie-parser";
|
|
||||||
import {Express, Request, Router} from "express";
|
|
||||||
import {Session} from "express-session";
|
|
||||||
import {WebSocketServer} from "ws";
|
import {WebSocketServer} from "ws";
|
||||||
|
|
||||||
import Application from "../Application.js";
|
import Application from "../Application.js";
|
||||||
@ -45,39 +42,13 @@ export default class WebSocketServerComponent extends ApplicationComponent {
|
|||||||
if (!listener) {
|
if (!listener) {
|
||||||
socket.close(1002, `Path not found ${request.url}`);
|
socket.close(1002, `Path not found ${request.url}`);
|
||||||
return;
|
return;
|
||||||
} else if (!request.headers.cookie) {
|
|
||||||
listener.handle(socket, request, null).catch(err => {
|
|
||||||
logger.error(err, 'Error in websocket listener.');
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Websocket on ${request.url}`);
|
logger.debug(`Websocket on ${request.url}`);
|
||||||
|
listener.handle(socket, request).catch(err => {
|
||||||
const cookies = cookie.parse(request.headers.cookie);
|
|
||||||
const sid = cookieParser.signedCookie(cookies['connect.sid'], config.get('session.secret'));
|
|
||||||
|
|
||||||
if (!sid) {
|
|
||||||
socket.close(1002);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = app.as(RedisComponent).getStore();
|
|
||||||
store.get(sid, (err, session) => {
|
|
||||||
if (err || !session) {
|
|
||||||
logger.error(err, 'Error while initializing session in websocket.');
|
|
||||||
socket.close(1011);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.id = sid;
|
|
||||||
|
|
||||||
store.createSession(<Request>request, session);
|
|
||||||
listener.handle(socket, request, session as Session).catch(err => {
|
|
||||||
logger.error(err, 'Error in websocket listener.');
|
logger.error(err, 'Error in websocket listener.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop(): Promise<void> {
|
public async stop(): Promise<void> {
|
||||||
|
@ -1185,12 +1185,12 @@ describe('Session persistence', () => {
|
|||||||
|
|
||||||
await followMagicLinkFromMail(agent, cookies);
|
await followMagicLinkFromMail(agent, cookies);
|
||||||
|
|
||||||
expect(cookies[0]).toMatch(/^connect\.sid=.+; Path=\/; HttpOnly$/);
|
expect(cookies[0]).toMatch(/^connect\.sid=.+; Path=\/; SameSite=Strict$/);
|
||||||
|
|
||||||
res = await agent.get('/csrf')
|
res = await agent.get('/csrf')
|
||||||
.set('Cookie', cookies)
|
.set('Cookie', cookies)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; HttpOnly$/);
|
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; SameSite=Strict$/);
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
await agent.post('/auth/logout')
|
await agent.post('/auth/logout')
|
||||||
@ -1217,7 +1217,7 @@ describe('Session persistence', () => {
|
|||||||
const res = await agent.get('/csrf')
|
const res = await agent.get('/csrf')
|
||||||
.set('Cookie', cookies)
|
.set('Cookie', cookies)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; HttpOnly$/);
|
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; SameSite=Strict$/);
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
await agent.post('/auth/logout')
|
await agent.post('/auth/logout')
|
||||||
@ -1244,7 +1244,7 @@ describe('Session persistence', () => {
|
|||||||
const res = await agent.get('/csrf')
|
const res = await agent.get('/csrf')
|
||||||
.set('Cookie', cookies)
|
.set('Cookie', cookies)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; HttpOnly$/);
|
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; SameSite=Strict$/);
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
await agent.post('/auth/logout')
|
await agent.post('/auth/logout')
|
||||||
|
Loading…
Reference in New Issue
Block a user