Skip to content

Commit c178a41

Browse files
feat: Add authentication to chat messages (#879)
* Add authentication to chat messages * refactor: Improve typing of chat message methods in transports * feat: Only allow players to post chat messages * refactor: Always await metadata in `Master#onChatMessage` Metadata is only used for authentication, which always runs asynchronously in any case. If the storage API actually is synchronous, `await` will have no effect. Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
1 parent 40f4ed5 commit c178a41

7 files changed

Lines changed: 135 additions & 26 deletions

File tree

src/client/transport/local.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { InitializeGame } from '../../core/initialize';
1515
import { Client } from '../client';
1616
import { RandomBot } from '../../ai/random-bot';
1717
import { Stage } from '../../core/turn-order';
18-
import type { State, Store, SyncInfo } from '../../types';
18+
import type { ChatMessage, State, Store, SyncInfo } from '../../types';
1919

2020
jest.useFakeTimers();
2121

@@ -361,10 +361,13 @@ describe('LocalTransport', () => {
361361
});
362362

363363
test('send chat-message', () => {
364-
m.onChatMessage('matchID', { message: 'foo' });
365-
expect(m.master.onChatMessage).lastCalledWith('matchID', {
366-
message: 'foo',
367-
});
364+
const msg: ChatMessage = {
365+
id: '0',
366+
sender: '0',
367+
payload: { message: 'foo' },
368+
};
369+
m.onChatMessage('matchID', msg);
370+
expect(m.master.onChatMessage).lastCalledWith('matchID', msg, undefined);
368371
});
369372
});
370373
});

src/client/transport/local.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { TransportAPI, TransportData } from '../../master/master';
1414
import { Transport } from './transport';
1515
import type { TransportOpts, ChatCallback } from './transport';
1616
import type {
17+
ChatMessage,
1718
CredentialedActionShape,
1819
Game,
1920
LogEntry,
@@ -161,8 +162,18 @@ export class LocalTransport extends Transport {
161162
this.isConnected = true;
162163
}
163164

164-
onChatMessage(matchID, chatMessage) {
165-
this.master.onChatMessage(matchID, chatMessage);
165+
/**
166+
* Called when any player sends a chat message and the
167+
* master broadcasts the update to other clients (including
168+
* this one).
169+
*/
170+
onChatMessage(matchID: string, chatMessage: ChatMessage) {
171+
const args: Parameters<Master['onChatMessage']> = [
172+
matchID,
173+
chatMessage,
174+
this.credentials,
175+
];
176+
this.master.onChatMessage(...args);
166177
}
167178

168179
/**

src/client/transport/socketio.test.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { CreateGameReducer } from '../../core/reducer';
1313
import { InitializeGame } from '../../core/initialize';
1414
import * as Actions from '../../core/action-types';
1515
import type { Master } from '../../master/master';
16-
import type { State, Store } from '../../types';
16+
import type { ChatMessage, State, Store } from '../../types';
1717

1818
type UpdateArgs = Parameters<Master['onUpdate']>;
1919
type SyncArgs = Parameters<Master['onSync']>;
@@ -204,10 +204,18 @@ describe('multiplayer', () => {
204204
});
205205

206206
test('send chat-message', () => {
207-
m.onChatMessage('matchID', { message: 'foo' });
208-
expect(mockSocket.emit).lastCalledWith('chat', 'matchID', {
209-
message: 'foo',
210-
});
207+
const message: ChatMessage = {
208+
id: '0',
209+
sender: '0',
210+
payload: { message: 'foo' },
211+
};
212+
m.onChatMessage('matchID', message);
213+
expect(mockSocket.emit).lastCalledWith(
214+
'chat',
215+
'matchID',
216+
message,
217+
m.getCredentials()
218+
);
211219
});
212220
});
213221

src/client/transport/socketio.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,13 @@ export class SocketIOTransport extends Transport {
9696
this.socket.emit('update', ...args);
9797
}
9898

99-
onChatMessage(matchID, chatMessage) {
100-
this.socket.emit('chat', matchID, chatMessage);
99+
onChatMessage(matchID: string, chatMessage: ChatMessage) {
100+
const args: Parameters<Master['onChatMessage']> = [
101+
matchID,
102+
chatMessage,
103+
this.credentials,
104+
];
105+
this.socket.emit('chat', ...args);
101106
}
102107

103108
/**

src/master/master.test.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,54 @@ describe('authentication', () => {
808808
expect(sendAll).not.toHaveBeenCalled();
809809
});
810810
});
811+
812+
describe('onChatMessage', () => {
813+
const chatMessage = {
814+
id: 'uuid',
815+
payload: { message: 'foo' },
816+
sender: '0',
817+
};
818+
819+
beforeEach(resetTestEnvironment);
820+
821+
test('auth failure', async () => {
822+
const authenticateCredentials = () => false;
823+
const master = new Master(
824+
game,
825+
storage,
826+
TransportAPI(send, sendAll),
827+
new Auth({ authenticateCredentials })
828+
);
829+
const ret = await master.onChatMessage(matchID, chatMessage, undefined);
830+
expect(ret && ret.error).toBe('unauthorized');
831+
expect(sendAll).not.toHaveBeenCalled();
832+
});
833+
834+
test('auth success', async () => {
835+
const authenticateCredentials = () => true;
836+
const master = new Master(
837+
game,
838+
storage,
839+
TransportAPI(send, sendAll),
840+
new Auth({ authenticateCredentials })
841+
);
842+
const ret = await master.onChatMessage(matchID, chatMessage, undefined);
843+
expect(ret).toBeUndefined();
844+
expect(sendAll).toHaveBeenCalled();
845+
});
846+
847+
test('default', async () => {
848+
const master = new Master(
849+
game,
850+
storage,
851+
TransportAPI(send, sendAll),
852+
new Auth()
853+
);
854+
const ret = await master.onChatMessage(matchID, chatMessage, undefined);
855+
expect(ret).toBeUndefined();
856+
expect(sendAll).toHaveBeenCalled();
857+
});
858+
});
811859
});
812860

813861
describe('redactLog', () => {
@@ -982,10 +1030,17 @@ describe('chat', () => {
9821030
});
9831031

9841032
test('Sends chat messages to all', async () => {
985-
master.onChatMessage('matchID', { message: 'foo' });
1033+
master.onChatMessage(
1034+
'matchID',
1035+
{ id: 'uuid', sender: '0', payload: { message: 'foo' } },
1036+
undefined
1037+
);
9861038
expect(sendAllReturn('0')).toEqual({
9871039
type: 'chat',
988-
args: ['matchID', { message: 'foo' }],
1040+
args: [
1041+
'matchID',
1042+
{ id: 'uuid', sender: '0', payload: { message: 'foo' } },
1043+
],
9891044
});
9901045
});
9911046
});

src/master/master.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,30 @@ export class Master {
459459
}
460460
}
461461

462-
async onChatMessage(matchID, chatMessage) {
462+
async onChatMessage(
463+
matchID: string,
464+
chatMessage: ChatMessage,
465+
credentials: string | undefined
466+
): Promise<void | { error: string }> {
467+
const key = matchID;
468+
469+
if (this.auth) {
470+
const { metadata } = await (this.storageAPI as StorageAPI.Async).fetch(
471+
key,
472+
{
473+
metadata: true,
474+
}
475+
);
476+
const isAuthentic = await this.auth.authenticateCredentials({
477+
playerID: chatMessage.sender,
478+
credentials,
479+
metadata,
480+
});
481+
if (!isAuthentic) {
482+
return { error: 'unauthorized' };
483+
}
484+
}
485+
463486
this.transportAPI.sendAll(() => ({
464487
type: 'chat',
465488
args: [matchID, chatMessage],

src/server/transport/socketio.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -185,15 +185,19 @@ export class SocketIO {
185185
);
186186
}
187187
});
188-
socket.on('chat', async (matchID, chatMessage) => {
189-
const master = new Master(
190-
game,
191-
app.context.db,
192-
TransportAPI(matchID, socket, this.clientInfo, this.roomInfo),
193-
app.context.auth
194-
);
195-
master.onChatMessage(matchID, chatMessage);
196-
});
188+
socket.on(
189+
'chat',
190+
async (...args: Parameters<Master['onChatMessage']>) => {
191+
const [matchID] = args;
192+
const master = new Master(
193+
game,
194+
app.context.db,
195+
TransportAPI(matchID, socket, this.clientInfo, this.roomInfo),
196+
app.context.auth
197+
);
198+
master.onChatMessage(...args);
199+
}
200+
);
197201
});
198202
}
199203
}

0 commit comments

Comments
 (0)