fix(websockets): send cookies manually for session authentication

This commit is contained in:
Alice Gaudon 2022-02-18 22:52:59 +01:00
parent dad4ff62f1
commit 535c8afdb1
7 changed files with 70 additions and 51 deletions

View 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>;
}

View File

@ -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>;
} }

View File

@ -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);

View File

@ -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', () => {

View File

@ -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,
})); }));

View File

@ -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,37 +42,11 @@ 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); logger.error(err, 'Error in websocket listener.');
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.');
});
}); });
}); });
} }

View File

@ -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')