fix(realtime): Allow connections for guest users

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-10-03 18:39:44 +02:00
parent 4ab66dfe2d
commit a97f7e8fd1
8 changed files with 139 additions and 59 deletions

View file

@ -52,7 +52,7 @@ export class RealtimeNote extends (EventEmitter as TypedEventEmitterConstructor<
*/ */
public addClient(client: WebsocketConnection): void { public addClient(client: WebsocketConnection): void {
this.clients.add(client); this.clients.add(client);
this.logger.debug(`User '${client.getUser().username}' connected`); this.logger.debug(`User '${client.getUsername()}' connected`);
} }
/** /**
@ -63,7 +63,7 @@ export class RealtimeNote extends (EventEmitter as TypedEventEmitterConstructor<
public removeClient(client: WebsocketConnection): void { public removeClient(client: WebsocketConnection): void {
this.clients.delete(client); this.clients.delete(client);
this.logger.debug( this.logger.debug(
`User '${client.getUser().username}' disconnected. ${ `User '${client.getUsername()}' disconnected. ${
this.clients.size this.clients.size
} clients left.`, } clients left.`,
); );

View file

@ -18,5 +18,6 @@ export function mockConnection(synced: boolean): WebsocketConnection {
isSynced: jest.fn(() => synced), isSynced: jest.fn(() => synced),
send: jest.fn(), send: jest.fn(),
getUser: jest.fn(() => Mock.of<User>({ username: 'mockedUser' })), getUser: jest.fn(() => Mock.of<User>({ username: 'mockedUser' })),
getUsername: jest.fn(() => 'mocked user'),
}); });
} }

View file

@ -192,4 +192,26 @@ describe('websocket connection', () => {
expect(sut.getUser()).toBe(mockedUser); expect(sut.getUser()).toBe(mockedUser);
}); });
it('returns the correct username', () => {
const mockedUserWithUsername = Mock.of<User>({ username: 'MockUser' });
const sut = new WebsocketConnection(
mockedWebsocket,
mockedUserWithUsername,
mockedRealtimeNote,
);
expect(sut.getUsername()).toBe('MockUser');
});
it('returns a fallback if no username has been set', () => {
const sut = new WebsocketConnection(
mockedWebsocket,
mockedUser,
mockedRealtimeNote,
);
expect(sut.getUsername()).toBe('Guest');
});
}); });

View file

@ -30,7 +30,7 @@ export class WebsocketConnection {
*/ */
constructor( constructor(
websocket: WebSocket, websocket: WebSocket,
private user: User, private user: User | null,
realtimeNote: RealtimeNote, realtimeNote: RealtimeNote,
) { ) {
const awareness = realtimeNote.getAwareness(); const awareness = realtimeNote.getAwareness();
@ -94,7 +94,11 @@ export class WebsocketConnection {
return this.controlledAwarenessIds; return this.controlledAwarenessIds;
} }
public getUser(): User { public getUser(): User | null {
return this.user; return this.user;
} }
public getUsername(): string {
return this.getUser()?.username ?? 'Guest';
}
} }

View file

@ -3,6 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Optional } from '@mrdrogdrog/optional';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
@ -64,7 +65,9 @@ describe('Websocket gateway', () => {
const mockedValidSessionCookie = 'mockedValidSessionCookie'; const mockedValidSessionCookie = 'mockedValidSessionCookie';
const mockedSessionIdWithUser = 'mockedSessionIdWithUser'; const mockedSessionIdWithUser = 'mockedSessionIdWithUser';
const mockedValidUrl = 'mockedValidUrl'; const mockedValidUrl = 'mockedValidUrl';
const mockedValidGuestUrl = 'mockedValidGuestUrl';
const mockedValidNoteId = 'mockedValidNoteId'; const mockedValidNoteId = 'mockedValidNoteId';
const mockedValidGuestNoteId = 'mockedValidGuestNoteId';
let sessionExistsForUser = true; let sessionExistsForUser = true;
let noteExistsForNoteId = true; let noteExistsForNoteId = true;
@ -151,14 +154,15 @@ describe('Websocket gateway', () => {
permissionsService = module.get<PermissionsService>(PermissionsService); permissionsService = module.get<PermissionsService>(PermissionsService);
jest jest
.spyOn(sessionService, 'extractVerifiedSessionIdFromRequest') .spyOn(sessionService, 'extractSessionIdFromRequest')
.mockImplementation((request: IncomingMessage): string => { .mockImplementation(
if (request.headers.cookie === mockedValidSessionCookie) { (request: IncomingMessage): Optional<string> =>
return mockedSessionIdWithUser; Optional.ofNullable(
} else { request.headers?.cookie === mockedValidSessionCookie
throw new Error('no valid session cookie found'); ? mockedSessionIdWithUser
} : null,
}); ),
);
const mockUsername = 'mockUsername'; const mockUsername = 'mockUsername';
jest jest
@ -184,26 +188,37 @@ describe('Websocket gateway', () => {
.mockImplementation((request: IncomingMessage): string => { .mockImplementation((request: IncomingMessage): string => {
if (request.url === mockedValidUrl) { if (request.url === mockedValidUrl) {
return mockedValidNoteId; return mockedValidNoteId;
} else if (request.url === mockedValidGuestUrl) {
return mockedValidGuestNoteId;
} else { } else {
throw new Error('no valid note id found'); throw new Error('no valid note id found');
} }
}); });
const mockedNote = Mock.of<Note>({ id: 4711 }); const mockedNote = Mock.of<Note>({ id: 4711 });
const mockedGuestNote = Mock.of<Note>({ id: 1235 });
jest jest
.spyOn(notesService, 'getNoteByIdOrAlias') .spyOn(notesService, 'getNoteByIdOrAlias')
.mockImplementation((noteId: string) => .mockImplementation((noteId: string) => {
noteExistsForNoteId && noteId === mockedValidNoteId if (noteExistsForNoteId && noteId === mockedValidNoteId) {
? Promise.resolve(mockedNote) return Promise.resolve(mockedNote);
: Promise.reject('no note found'), }
); if (noteId === mockedValidGuestNoteId) {
return Promise.resolve(mockedGuestNote);
} else {
return Promise.reject('no note found');
}
});
jest jest
.spyOn(permissionsService, 'mayRead') .spyOn(permissionsService, 'mayRead')
.mockImplementation( .mockImplementation(
(user: User | null, note: Note): Promise<boolean> => (user: User | null, note: Note): Promise<boolean> =>
Promise.resolve( Promise.resolve(
user === mockUser && note === mockedNote && userHasReadPermissions, (user === mockUser &&
note === mockedNote &&
userHasReadPermissions) ||
(user === null && note === mockedGuestNote),
), ),
); );
@ -231,6 +246,22 @@ describe('Websocket gateway', () => {
addClientSpy = jest.spyOn(mockedRealtimeNote, 'addClient'); addClientSpy = jest.spyOn(mockedRealtimeNote, 'addClient');
}); });
it('adds a valid connection request without a session', async () => {
const request = Mock.of<IncomingMessage>({
socket: {
remoteAddress: 'mockHost',
},
url: mockedValidGuestUrl,
headers: {},
});
await expect(
gateway.handleConnection(mockedWebsocket, request),
).resolves.not.toThrow();
expect(addClientSpy).toHaveBeenCalledWith(mockedWebsocketConnection);
expect(mockedWebsocketCloseSpy).not.toHaveBeenCalled();
});
it('adds a valid connection request', async () => { it('adds a valid connection request', async () => {
const request = Mock.of<IncomingMessage>({ const request = Mock.of<IncomingMessage>({
socket: { socket: {

View file

@ -52,10 +52,12 @@ export class WebsocketGateway implements OnGatewayConnection {
extractNoteIdFromRequestUrl(request), extractNoteIdFromRequestUrl(request),
); );
const username = user?.username ?? 'guest';
if (!(await this.permissionsService.mayRead(user, note))) { if (!(await this.permissionsService.mayRead(user, note))) {
//TODO: [mrdrogdrog] inform client about reason of disconnect. //TODO: [mrdrogdrog] inform client about reason of disconnect.
this.logger.log( this.logger.log(
`Access denied to note '${note.id}' for user '${user.username}'`, `Access denied to note '${note.id}' for user '${username}'`,
'handleConnection', 'handleConnection',
); );
clientSocket.close(); clientSocket.close();
@ -65,7 +67,7 @@ export class WebsocketGateway implements OnGatewayConnection {
this.logger.debug( this.logger.debug(
`New realtime connection to note '${note.id}' (${ `New realtime connection to note '${note.id}' (${
note.publicId note.publicId
}) by user '${user.username}' from ${ }) by user '${username}' from ${
request.socket.remoteAddress ?? 'unknown' request.socket.remoteAddress ?? 'unknown'
}`, }`,
); );
@ -98,11 +100,15 @@ export class WebsocketGateway implements OnGatewayConnection {
*/ */
private async findUserByRequestSession( private async findUserByRequestSession(
request: IncomingMessage, request: IncomingMessage,
): Promise<User> { ): Promise<User | null> {
const sessionId = const sessionId = this.sessionService.extractSessionIdFromRequest(request);
this.sessionService.extractVerifiedSessionIdFromRequest(request);
if (sessionId.isEmpty()) {
return null;
}
const username = await this.sessionService.fetchUsernameForSessionId( const username = await this.sessionService.fetchUsernameForSessionId(
sessionId, sessionId.get(),
); );
return await this.userService.getUserByUsername(username); return await this.userService.getUserByUsername(username);
} }

View file

@ -129,9 +129,9 @@ describe('SessionService', () => {
const mockedRequest = Mock.of<IncomingMessage>({ const mockedRequest = Mock.of<IncomingMessage>({
headers: {}, headers: {},
}); });
expect(() => expect(
sessionService.extractVerifiedSessionIdFromRequest(mockedRequest), sessionService.extractSessionIdFromRequest(mockedRequest).isEmpty(),
).toThrow('No hedgedoc-session cookie found'); ).toBeTruthy();
}); });
it("fails if the cookie header isn't valid", () => { it("fails if the cookie header isn't valid", () => {
@ -139,9 +139,9 @@ describe('SessionService', () => {
headers: { cookie: 'no' }, headers: { cookie: 'no' },
}); });
mockParseCookieModule(`s:anyValidSessionId.validSignature`); mockParseCookieModule(`s:anyValidSessionId.validSignature`);
expect(() => expect(
sessionService.extractVerifiedSessionIdFromRequest(mockedRequest), sessionService.extractSessionIdFromRequest(mockedRequest).isEmpty(),
).toThrow('No hedgedoc-session cookie found'); ).toBeTruthy();
}); });
it("fails if the hedgedoc session cookie isn't marked as signed", () => { it("fails if the hedgedoc session cookie isn't marked as signed", () => {
@ -150,8 +150,10 @@ describe('SessionService', () => {
}); });
mockParseCookieModule('sessionId.validSignature'); mockParseCookieModule('sessionId.validSignature');
expect(() => expect(() =>
sessionService.extractVerifiedSessionIdFromRequest(mockedRequest), sessionService.extractSessionIdFromRequest(mockedRequest),
).toThrow("cookie doesn't look like a signed cookie"); ).toThrow(
'cookie "hedgedoc-session" doesn\'t look like a signed session cookie',
);
}); });
it("fails if the hedgedoc session cookie doesn't contain a session id", () => { it("fails if the hedgedoc session cookie doesn't contain a session id", () => {
@ -160,8 +162,10 @@ describe('SessionService', () => {
}); });
mockParseCookieModule('s:.validSignature'); mockParseCookieModule('s:.validSignature');
expect(() => expect(() =>
sessionService.extractVerifiedSessionIdFromRequest(mockedRequest), sessionService.extractSessionIdFromRequest(mockedRequest),
).toThrow("cookie doesn't look like a signed cookie"); ).toThrow(
'cookie "hedgedoc-session" doesn\'t look like a signed session cookie',
);
}); });
it("fails if the hedgedoc session cookie doesn't contain a signature", () => { it("fails if the hedgedoc session cookie doesn't contain a signature", () => {
@ -170,8 +174,10 @@ describe('SessionService', () => {
}); });
mockParseCookieModule('s:sessionId.'); mockParseCookieModule('s:sessionId.');
expect(() => expect(() =>
sessionService.extractVerifiedSessionIdFromRequest(mockedRequest), sessionService.extractSessionIdFromRequest(mockedRequest),
).toThrow("cookie doesn't look like a signed cookie"); ).toThrow(
'cookie "hedgedoc-session" doesn\'t look like a signed session cookie',
);
}); });
it("fails if the hedgedoc session cookie isn't signed correctly", () => { it("fails if the hedgedoc session cookie isn't signed correctly", () => {
@ -180,8 +186,8 @@ describe('SessionService', () => {
}); });
mockParseCookieModule('s:sessionId.invalidSignature'); mockParseCookieModule('s:sessionId.invalidSignature');
expect(() => expect(() =>
sessionService.extractVerifiedSessionIdFromRequest(mockedRequest), sessionService.extractSessionIdFromRequest(mockedRequest),
).toThrow("Signature of hedgedoc-session cookie isn't valid."); ).toThrow('signature of cookie "hedgedoc-session" isn\'t valid.');
}); });
it('can extract a session id from a valid request', () => { it('can extract a session id from a valid request', () => {
@ -190,7 +196,7 @@ describe('SessionService', () => {
}); });
mockParseCookieModule(`s:${validSessionId}.validSignature`); mockParseCookieModule(`s:${validSessionId}.validSignature`);
expect( expect(
sessionService.extractVerifiedSessionIdFromRequest(mockedRequest), sessionService.extractSessionIdFromRequest(mockedRequest).get(),
).toBe(validSessionId); ).toBe(validSessionId);
}); });
}); });

View file

@ -70,27 +70,37 @@ export class SessionService {
* Extracts the hedgedoc session cookie from the given {@link IncomingMessage request} and checks if the signature is correct. * Extracts the hedgedoc session cookie from the given {@link IncomingMessage request} and checks if the signature is correct.
* *
* @param request The http request that contains a session cookie * @param request The http request that contains a session cookie
* @return The extracted session id * @return An {@link Optional optional} that either contains the extracted session id or is empty if no session cookie has been found
* @throws Error if no session cookie was found * @throws Error if the cookie has been found but the content is malformed
* @throws Error if the cookie content is malformed * @throws Error if the cookie has been found but the content isn't signed
* @throws Error if the cookie content isn't signed
*/ */
extractVerifiedSessionIdFromRequest(request: IncomingMessage): string { extractSessionIdFromRequest(request: IncomingMessage): Optional<string> {
return Optional.ofNullable(request.headers.cookie) return Optional.ofNullable(request.headers?.cookie)
.map((cookieHeader) => parseCookie(cookieHeader)[HEDGEDOC_SESSION]) .map((cookieHeader) => parseCookie(cookieHeader)[HEDGEDOC_SESSION])
.orThrow(() => new Error(`No ${HEDGEDOC_SESSION} cookie found`)) .map((rawCookie) =>
.map((cookie) => SessionService.sessionCookieContentRegex.exec(cookie)) this.extractVerifiedSessionIdFromCookieContent(rawCookie),
.orThrow( );
() => }
new Error(
`${HEDGEDOC_SESSION} cookie doesn't look like a signed cookie`, /**
), * Parses the given session cookie content and extracts the session id.
) *
.guard( * @param rawCookie The cookie to parse
(cookie) => unsign(cookie[1], this.authConfig.session.secret) !== false, * @return The extracted session id
() => new Error(`Signature of ${HEDGEDOC_SESSION} cookie isn't valid.`), * @throws Error if the cookie has been found but the content is malformed
) * @throws Error if the cookie has been found but the content isn't signed
.map((cookie) => cookie[2]) */
.get(); private extractVerifiedSessionIdFromCookieContent(rawCookie: string): string {
const parsedCookie =
SessionService.sessionCookieContentRegex.exec(rawCookie);
if (parsedCookie === null) {
throw new Error(
`cookie "${HEDGEDOC_SESSION}" doesn't look like a signed session cookie`,
);
}
if (unsign(parsedCookie[1], this.authConfig.session.secret) === false) {
throw new Error(`signature of cookie "${HEDGEDOC_SESSION}" isn't valid.`);
}
return parsedCookie[2];
} }
} }