🏗️(y-provider) manage auth in y-provider app

The way to connect to the hocuspocus server needs to be proxified in
nginx to query a dedicated route in the django application and then
follow the request to the express server with the additionnal headers.
The auth can be done in the express server by querying the backend on
the document retrieve endpoint. If the response status code is 200, the
user has access to the document, otherwise it is not the case. Then we
can check the abilities to determine what the user can do or not.
This commit is contained in:
Manuel Raynaud
2025-03-19 15:41:05 +01:00
parent a5b9169eb6
commit e86919fb9a
14 changed files with 358 additions and 128 deletions

View File

@@ -6,7 +6,7 @@ import { createDoc, verifyDocName } from './common';
const config = {
CRISP_WEBSITE_ID: null,
COLLABORATION_WS_URL: 'ws://localhost:8083/collaboration/ws/',
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
ENVIRONMENT: 'development',
FRONTEND_THEME: 'default',
MEDIA_BASE_URL: 'http://localhost:8083',
@@ -99,7 +99,7 @@ test.describe('Config', () => {
browserName,
}) => {
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:8083/collaboration/ws/');
return webSocket.url().includes('ws://localhost:4444/collaboration/ws/');
});
await page.goto('/');
@@ -114,7 +114,7 @@ test.describe('Config', () => {
await verifyDocName(page, randomDoc[0]);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:8083/collaboration/ws/');
expect(webSocket.url()).toContain('ws://localhost:4444/collaboration/ws/');
});
test('it checks that Crisp is trying to init from config endpoint', async ({

View File

@@ -65,7 +65,7 @@ test.describe('Doc Editor', () => {
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:8083/collaboration/ws/?room=');
.includes('ws://localhost:4444/collaboration/ws/?room=');
});
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
@@ -73,7 +73,7 @@ test.describe('Doc Editor', () => {
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
'ws://localhost:8083/collaboration/ws/?room=',
'ws://localhost:4444/collaboration/ws/?room=',
);
// Is connected
@@ -103,7 +103,7 @@ test.describe('Doc Editor', () => {
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:8083/collaboration/ws/?room=');
.includes('ws://localhost:4444/collaboration/ws/?room=');
});
webSocket = await webSocketPromise;

View File

@@ -13,11 +13,22 @@ jest.mock('../src/env', () => {
PORT: port,
COLLABORATION_SERVER_ORIGIN: origin,
COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
COLLABORATION_BACKEND_BASE_URL: 'http://app-dev:8000',
};
});
console.error = jest.fn();
const mockDocFetch = jest.fn();
jest.mock('@/api/getDoc', () => ({
fetchDocument: mockDocFetch,
}));
const mockGetMe = jest.fn();
jest.mock('@/api/getMe', () => ({
getMe: mockGetMe,
}));
import { hocusPocusServer } from '@/servers/hocusPocusServer';
import { promiseDone } from '../src/helpers';
@@ -30,40 +41,15 @@ describe('Server Tests', () => {
await hocusPocusServer.configure({ port: portWS }).listen();
});
afterEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
server.close();
void hocusPocusServer.destroy();
});
test('WebSocket connection with correct API key can connect', () => {
const { promise, done } = promiseDone();
// eslint-disable-next-line jest/unbound-method
const { handleConnection } = hocusPocusServer;
const mockHandleConnection = jest.fn();
(hocusPocusServer.handleConnection as jest.Mock) = mockHandleConnection;
const clientWS = new WebSocket(
`ws://localhost:${port}/collaboration/ws/?room=test-room`,
{
headers: {
authorization: 'test-secret-api-key',
Origin: origin,
},
},
);
clientWS.on('open', () => {
expect(mockHandleConnection).toHaveBeenCalled();
clientWS.close();
mockHandleConnection.mockClear();
hocusPocusServer.handleConnection = handleConnection;
done();
});
return promise;
});
test('WebSocket connection with bad origin should be closed', () => {
const { promise, done } = promiseDone();
@@ -84,13 +70,13 @@ describe('Server Tests', () => {
return promise;
});
test('WebSocket connection with incorrect API key should be closed', () => {
test('WebSocket connection without cookies header should be closed', () => {
const { promise, done } = promiseDone();
const ws = new WebSocket(
`ws://localhost:${port}/collaboration/ws/?room=test-room`,
{
headers: {
Authorization: 'wrong-api-key',
Origin: origin,
},
},
@@ -121,6 +107,12 @@ describe('Server Tests', () => {
quiet: true,
preserveConnection: false,
onClose: (data) => {
expect(console.error).toHaveBeenCalledWith(
'Invalid room name - Probable hacking attempt:',
'hocuspocus-test',
'my-test',
);
wsHocus.stopConnectionAttempt();
expect(data.event.reason).toBe('Forbidden');
wsHocus.webSocket?.close();
@@ -134,9 +126,142 @@ describe('Server Tests', () => {
return promise;
});
test('WebSocket connection read-only', () => {
test('WebSocket connection fails if user can not access document', () => {
const { promise, done } = promiseDone();
mockDocFetch.mockRejectedValue('');
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=my-test`,
WebSocketPolyfill: WebSocket,
maxAttempts: 1,
quiet: true,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: 'my-test',
broadcast: false,
quiet: true,
preserveConnection: false,
onClose: (data) => {
expect(console.error).toHaveBeenCalledWith(
'[onConnect]',
'Backend error: Unauthorized',
);
wsHocus.stopConnectionAttempt();
expect(data.event.reason).toBe('Forbidden');
expect(mockDocFetch).toHaveBeenCalledTimes(1);
wsHocus.webSocket?.close();
wsHocus.disconnect();
provider.destroy();
wsHocus.destroy();
done();
},
});
return promise;
});
test('WebSocket connection fails if user do not have correct retrieve ability', () => {
const { promise, done } = promiseDone();
mockDocFetch.mockResolvedValue({
abilities: {
retrieve: false,
},
});
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=my-test`,
WebSocketPolyfill: WebSocket,
maxAttempts: 1,
quiet: true,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: 'my-test',
broadcast: false,
quiet: true,
preserveConnection: false,
onClose: (data) => {
expect(console.error).toHaveBeenCalledWith(
'onConnect: Unauthorized to retrieve this document',
'my-test',
);
wsHocus.stopConnectionAttempt();
expect(data.event.reason).toBe('Forbidden');
expect(mockDocFetch).toHaveBeenCalledTimes(1);
wsHocus.webSocket?.close();
wsHocus.disconnect();
provider.destroy();
wsHocus.destroy();
done();
},
});
return promise;
});
[true, false].forEach((canEdit) => {
test(`WebSocket connection ${canEdit ? 'can' : 'can not'} edit document`, () => {
const { promise, done } = promiseDone();
mockDocFetch.mockResolvedValue({
abilities: {
retrieve: true,
update: canEdit,
},
});
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=hocuspocus-test`,
WebSocketPolyfill: WebSocket,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: 'hocuspocus-test',
broadcast: false,
quiet: true,
onConnect: () => {
void hocusPocusServer
.openDirectConnection('hocuspocus-test')
.then((connection) => {
connection.document?.getConnections().forEach((connection) => {
expect(connection.readOnly).toBe(!canEdit);
});
void connection.disconnect();
provider.destroy();
wsHocus.destroy();
done();
});
},
});
return promise;
});
});
test('Add request header x-user-id if found', () => {
const { promise, done } = promiseDone();
mockDocFetch.mockResolvedValue({
abilities: {
retrieve: true,
update: true,
},
});
mockGetMe.mockResolvedValue({
id: 'test-user-id',
});
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=hocuspocus-test`,
WebSocketPolyfill: WebSocket,
@@ -152,15 +277,14 @@ describe('Server Tests', () => {
.openDirectConnection('hocuspocus-test')
.then((connection) => {
connection.document?.getConnections().forEach((connection) => {
expect(connection.readOnly).toBe(true);
expect(connection.context.userId).toBe('test-user-id');
});
void connection.disconnect();
provider.destroy();
wsHocus.destroy();
done();
});
provider.destroy();
wsHocus.destroy();
done();
},
});

View File

@@ -20,6 +20,7 @@
"@hocuspocus/server": "2.15.2",
"@sentry/node": "9.3.0",
"@sentry/profiling-node": "9.3.0",
"axios": "1.8.2",
"cors": "2.8.5",
"express": "4.21.2",
"express-ws": "5.0.2",

View File

@@ -0,0 +1,76 @@
import { IncomingHttpHeaders } from 'http';
import axios from 'axios';
import { COLLABORATION_BACKEND_BASE_URL } from '@/env';
enum LinkReach {
RESTRICTED = 'restricted',
PUBLIC = 'public',
AUTHENTICATED = 'authenticated',
}
enum LinkRole {
READER = 'reader',
EDITOR = 'editor',
}
type Base64 = string;
interface Doc {
id: string;
title?: string;
content: Base64;
creator: string;
is_favorite: boolean;
link_reach: LinkReach;
link_role: LinkRole;
nb_accesses_ancestors: number;
nb_accesses_direct: number;
created_at: string;
updated_at: string;
abilities: {
accesses_manage: boolean;
accesses_view: boolean;
ai_transform: boolean;
ai_translate: boolean;
attachment_upload: boolean;
children_create: boolean;
children_list: boolean;
collaboration_auth: boolean;
destroy: boolean;
favorite: boolean;
invite_owner: boolean;
link_configuration: boolean;
media_auth: boolean;
move: boolean;
partial_update: boolean;
restore: boolean;
retrieve: boolean;
update: boolean;
versions_destroy: boolean;
versions_list: boolean;
versions_retrieve: boolean;
};
}
export const fetchDocument = async (
documentName: string,
requestHeaders: IncomingHttpHeaders,
) => {
const response = await axios.get<Doc>(
`${COLLABORATION_BACKEND_BASE_URL}/api/v1.0/documents/${documentName}/`,
{
headers: {
Cookie: requestHeaders['cookie'],
Origin: requestHeaders['origin'],
},
},
);
if (response.status !== 200) {
throw new Error(`Failed to fetch document: ${response.statusText}`);
}
return response.data;
};

View File

@@ -0,0 +1,31 @@
import { IncomingHttpHeaders } from 'http';
import axios from 'axios';
import { COLLABORATION_BACKEND_BASE_URL } from '@/env';
export interface User {
id: string;
email: string;
full_name: string;
short_name: string;
language: string;
}
export const getMe = async (requestHeaders: IncomingHttpHeaders) => {
const response = await axios.get<User>(
`${COLLABORATION_BACKEND_BASE_URL}/api/v1.0/users/me/`,
{
headers: {
Cookie: requestHeaders['cookie'],
Origin: requestHeaders['origin'],
},
},
);
if (response.status !== 200) {
throw new Error(`Failed to fetch user: ${response.statusText}`);
}
return response.data;
};

View File

@@ -8,3 +8,5 @@ export const Y_PROVIDER_API_KEY =
process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key';
export const PORT = Number(process.env.PORT || 4444);
export const SENTRY_DSN = process.env.SENTRY_DSN || '';
export const COLLABORATION_BACKEND_BASE_URL =
process.env.COLLABORATION_BACKEND_BASE_URL || 'http://app-dev:8000';

View File

@@ -14,14 +14,7 @@ export const collaborationResetConnectionsHandler = (
const room = req.query.room;
const userId = req.headers['x-user-id'];
logger(
'Resetting connections in room:',
room,
'for user:',
userId,
'room:',
room,
);
logger('Resetting connections in room:', room, 'for user:', userId);
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
@@ -43,8 +36,8 @@ export const collaborationResetConnectionsHandler = (
}
doc.getConnections().forEach((connection) => {
const connectionUserId = connection.request.headers['x-user-id'];
if (connectionUserId === userId) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (connection.context.userId === userId) {
connection.close();
}
});

View File

@@ -40,17 +40,16 @@ export const wsSecurity = (
): void => {
// Origin check
const origin = req.headers['origin'];
if (origin && !allowedOrigins.includes(origin)) {
if (!origin || !allowedOrigins.includes(origin)) {
ws.close(4001, 'Origin not allowed');
console.error('CORS policy violation: Invalid Origin', origin);
return;
}
// Secret API Key check
const apiKey = req.headers['authorization'];
if (apiKey !== COLLABORATION_SERVER_SECRET) {
console.error('Forbidden: Invalid API Key');
ws.close();
const cookies = req.headers['cookie'];
if (!cookies) {
ws.close(4001, 'No cookies');
console.error('CORS policy violation: No cookies');
return;
}

View File

@@ -1,41 +1,74 @@
import { Server } from '@hocuspocus/server';
import { fetchDocument } from '@/api/getDoc';
import { getMe } from '@/api/getMe';
import { logger } from '@/utils';
export const hocusPocusServer = Server.configure({
name: 'docs-collaboration',
timeout: 30000,
quiet: true,
onConnect({ requestHeaders, connection, documentName, requestParameters }) {
async onConnect({
requestHeaders,
connection,
documentName,
requestParameters,
context,
}) {
const roomParam = requestParameters.get('room');
const canEdit = requestHeaders['x-can-edit'] === 'True';
if (!canEdit) {
connection.readOnly = true;
}
logger(
'Connection established:',
documentName,
'userId:',
requestHeaders['x-user-id'],
'canEdit:',
canEdit,
'room:',
requestParameters.get('room'),
);
if (documentName !== roomParam) {
console.error(
'Invalid room name - Probable hacking attempt:',
documentName,
requestParameters.get('room'),
requestHeaders['x-user-id'],
);
return Promise.reject(new Error('Unauthorized'));
return Promise.reject(new Error('Wrong room name: Unauthorized'));
}
let can_edit = false;
try {
const document = await fetchDocument(documentName, requestHeaders);
if (!document.abilities.retrieve) {
console.error(
'onConnect: Unauthorized to retrieve this document',
roomParam,
);
return Promise.reject(new Error('Wrong abilities:Unauthorized'));
}
can_edit = document.abilities.update;
} catch (error: unknown) {
if (error instanceof Error) {
console.error('onConnect: backend error', error.message);
}
return Promise.reject(new Error('Backend error: Unauthorized'));
}
connection.readOnly = !can_edit;
/*
* Unauthenticated users can be allowed to connect
* so we flag only authenticated users
*/
try {
const user = await getMe(requestHeaders);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
context.userId = user.id;
} catch {}
logger(
'Connection established:',
documentName,
'canEdit:',
can_edit,
'room:',
requestParameters.get('room'),
);
return Promise.resolve();
},
});

View File

@@ -7107,6 +7107,15 @@ axe-core@^4.10.0:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df"
integrity sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==
axios@1.8.2:
version "1.8.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979"
integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==
dependencies:
follow-redirects "^1.15.6"
form-data "^4.0.0"
proxy-from-env "^1.1.0"
axobject-query@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee"
@@ -9180,6 +9189,11 @@ flatted@^3.3.3:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358"
integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==
follow-redirects@^1.15.6:
version "1.15.9"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1"
integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==
fontkit@^2.0.2:
version "2.0.4"
resolved "https://registry.yarnpkg.com/fontkit/-/fontkit-2.0.4.tgz#4765d664c68b49b5d6feb6bd1051ee49d8ec5ab0"