Skip to content

Commit

Permalink
feat(server): Lobby server improvements (#532)
Browse files Browse the repository at this point in the history
* fix(server): Assign seat credentials when a player joins a room

Addresses potential for leaking credentials identified in #429. 
Credentials for each seat are now set when a player joins a room and 
then deleted when the player leaves.

* feat(server): Allow customised `generateCredentials` lobby config

Setting a `generateCredentials` method in the lobbyConfig customises the 
credentials stored in gameMetadata and returned to players when the join 
a room. `generateCredentials` is called with Koa’s `ctx` object to allow 
credentials to depend on request content/headers, and can be 
asynchronous to permit calling third-party services.

* feat(master): Allow asynchronous authentication checks

* feat(server): Use options object to configure socket.io server transport

* feat(server): Allow master’s auth option to be set via SocketIO

* feat(server): Add `authenticateCredentials` option to server

* refactor(server): Move `generateCredentials` option to Server factory

* docs(api): Document custom authentication approaches

* docs(api): Remove “custom” from Server API docs

Co-authored-by: Nicolo John Davis <nicolodavis@gmail.com>
  • Loading branch information
delucis and nicolodavis committed Jan 14, 2020
1 parent b3e0bdc commit 4d33faa
Show file tree
Hide file tree
Showing 8 changed files with 143 additions and 53 deletions.
2 changes: 1 addition & 1 deletion docs/documentation/api/Lobby.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Options are:

- `apiPort`: If specified, it runs the Lobby API in a separate Koa server on this port. Otherwise, it shares the same Koa server runnning on the default boardgame.io `port`.
- `apiCallback`: Called when the Koa server is ready. Only applicable if `apiPort` is specified.
- `shortid`: Function that returns an unique identifier, needed for creating new match codes and user's credentials in matches. If not specified, uses [shortid](https://www.npmjs.com/package/shortid).
- `uuid`: Function that returns an unique identifier, needed for creating new game ID codes. If not specified, uses [shortid](https://www.npmjs.com/package/shortid).

#### Creating a room

Expand Down
52 changes: 51 additions & 1 deletion docs/documentation/api/Server.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ A config object with the following options:

3. `transport` (_object_): the transport implementation.
If not provided, socket.io is used.

4. `generateCredentials` (_function_): an optional function that returns player credentials to store in the game metadata and validate against. If not specified, the Lobby’s `uuid` implementation will be used.

5. `authenticateCredentials` (_function_): an optional function that tests if a player’s move is made with the correct credentials when using the default socket.io transport implementation.

### Returns

Expand Down Expand Up @@ -54,6 +58,52 @@ server.run(8000);

##### With callback

```
```js
server.run(8000, () => console.log("server running..."));
```

##### With custom authentication

`generateCredentials` is called when a player joins a game with:

- `ctx`: The Koa context object, which can be used to generate tailored credentials from request headers etc.

`authenticateCredentials` is called when a player makes a move with:

- `credentials`: The credentials sent from the player’s client
- `playerMetadata`: The metadata object for the `playerID` making a move

Below is an example of how you might implement custom authentication with a hypothetical `authService` library.

The `generateCredentials` method checks for the Authorization header on incoming requests and tries to use it to decode a token. It returns an ID from the result, storing a public user ID as “credentials” in the game metadata.

The `authenticateCredentials` method passed to the `Server` also expects a similar token, which when decoded matches the ID stored in game metadata.


```js
const { Server } = require('boardgame.io/server');

const generateCredentials = async ctx => {
const authHeader = ctx.request.headers['authorization'];
const token = await authService.decodeToken(authHeader);
return token.uid;
}

const authenticateCredentials = async (credentials, playerMetadata) => {
if (credentials) {
const token = await authService.decodeToken(credentials);
if (token.uid === playerMetadata.credentials) return true;
}
return false;
}

const server = Server({
games: [game1, game2, ...],
generateCredentials,
authenticateCredentials,
});

server.run(8000);
```

!> N.B. This approach is not currently compatible with how the React `<Lobby>` provides credentials.
2 changes: 1 addition & 1 deletion src/master/master.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export class Master {
});
} else {
const gameMetadata = await this.storageAPI.get(GameMetadataKey(gameID));
isActionAuthentic = this.auth({
isActionAuthentic = await this.auth({
action,
gameMetadata,
gameID,
Expand Down
14 changes: 7 additions & 7 deletions src/server/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,7 @@ describe('.createApiServer', () => {
get: async () => {
return {
players: {
'0': {
credentials,
},
'0': {},
},
};
},
Expand All @@ -206,7 +204,11 @@ describe('.createApiServer', () => {

describe('when the playerID is available', () => {
beforeEach(async () => {
const app = createApiServer({ db, games });
const app = createApiServer({
db,
games,
lobbyConfig: { uuid: () => credentials },
});
response = await request(app.callback())
.post('/games/foo/1/join')
.send('playerID=0&playerName=alice');
Expand Down Expand Up @@ -488,9 +490,7 @@ describe('.createApiServer', () => {
expect.stringMatching(':metadata'),
expect.objectContaining({
players: expect.objectContaining({
'0': expect.objectContaining({
credentials: 'SECRET1',
}),
'0': expect.objectContaining({}),
'1': expect.objectContaining({
name: 'bob',
credentials: 'SECRET2',
Expand Down
36 changes: 24 additions & 12 deletions src/server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@ export const CreateGame = async (
});

for (let playerIndex = 0; playerIndex < numPlayers; playerIndex++) {
const credentials = lobbyConfig.uuid();
gameMetadata.players[playerIndex] = { id: playerIndex, credentials };
gameMetadata.players[playerIndex] = { id: playerIndex };
}

const gameID = lobbyConfig.uuid();
Expand All @@ -62,18 +61,29 @@ export const CreateGame = async (
return gameID;
};

export const createApiServer = ({ db, games, lobbyConfig }) => {
export const createApiServer = ({
db,
games,
lobbyConfig,
generateCredentials,
}) => {
const app = new Koa();
return addApiToServer({ app, db, games, lobbyConfig });
return addApiToServer({ app, db, games, lobbyConfig, generateCredentials });
};

export const addApiToServer = ({ app, db, games, lobbyConfig }) => {
if (!lobbyConfig) {
lobbyConfig = {};
}
if (!lobbyConfig.uuid) {
lobbyConfig = { ...lobbyConfig, uuid };
}
export const addApiToServer = ({
app,
db,
games,
lobbyConfig,
generateCredentials,
}) => {
if (!lobbyConfig) lobbyConfig = {};
lobbyConfig = {
...lobbyConfig,
uuid: lobbyConfig.uuid || uuid,
generateCredentials: generateCredentials || lobbyConfig.uuid || uuid,
};
const router = new Router();

router.get('/games', async ctx => {
Expand Down Expand Up @@ -170,7 +180,8 @@ export const addApiToServer = ({ app, db, games, lobbyConfig }) => {
}

gameMetadata.players[playerID].name = playerName;
const playerCredentials = gameMetadata.players[playerID].credentials;
const playerCredentials = await lobbyConfig.generateCredentials(ctx);
gameMetadata.players[playerID].credentials = playerCredentials;

await db.set(GameMetadataKey(namespacedGameID), gameMetadata);

Expand Down Expand Up @@ -201,6 +212,7 @@ export const addApiToServer = ({ app, db, games, lobbyConfig }) => {
}

delete gameMetadata.players[playerID].name;
delete gameMetadata.players[playerID].credentials;
if (Object.values(gameMetadata.players).some((val: any) => val.name)) {
await db.set(GameMetadataKey(namespacedGameID), gameMetadata);
} else {
Expand Down
35 changes: 31 additions & 4 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,32 @@ export const createServerRunConfig = (portOrConfig?: any, callback?: any) => {
return config;
};

/**
* Wrap a user-provided auth function to simplify external API
* @param {function} fn The authentication function to wrap
* @return {function} Wrapped function for use by master
*/
const wrapAuthFn = fn => ({ action, gameMetadata, playerID }) =>
fn(action.payload.credentials, gameMetadata[playerID]);

/**
* Instantiate a game server.
*
* @param {Array} games - The games that this server will handle.
* @param {object} db - The interface with the database.
* @param {object} transport - The interface with the clients.
* @param {function} authenticateCredentials - Function to test player
* credentials. Optional.
* @param {function} generateCredentials - Method for API to generate player
* credentials. Optional.
*/
export function Server({ games, db, transport }: any) {
export function Server({
games,
db,
transport,
authenticateCredentials,
generateCredentials,
}: any) {
const app = new Koa();

games = games.map(Game);
Expand All @@ -51,7 +69,11 @@ export function Server({ games, db, transport }: any) {
app.context.db = db;

if (transport === undefined) {
transport = SocketIO();
const auth =
typeof authenticateCredentials === 'function'
? wrapAuthFn(authenticateCredentials)
: true;
transport = SocketIO({ auth });
}
transport.init(app, games);

Expand All @@ -69,10 +91,15 @@ export function Server({ games, db, transport }: any) {
const lobbyConfig = serverRunConfig.lobbyConfig;
let apiServer;
if (!lobbyConfig || !lobbyConfig.apiPort) {
addApiToServer({ app, db, games, lobbyConfig });
addApiToServer({ app, db, games, lobbyConfig, generateCredentials });
} else {
// Run API in a separate Koa app.
const api = createApiServer({ db, games, lobbyConfig });
const api = createApiServer({
db,
games,
lobbyConfig,
generateCredentials,
});
apiServer = await api.listen(
lobbyConfig.apiPort,
lobbyConfig.apiCallback
Expand Down
13 changes: 7 additions & 6 deletions src/server/transport/socketio.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@ export function TransportAPI(gameID, socket, clientInfo, roomInfo) {
/**
* Transport interface that uses socket.io
*/
export function SocketIO(_clientInfo, _roomInfo) {
const clientInfo = _clientInfo || new Map();
const roomInfo = _roomInfo || new Map();

export function SocketIO({
clientInfo = new Map(),
roomInfo = new Map(),
auth = true,
} = {}) {
return {
init: (app, games) => {
const io = new IO({
Expand All @@ -83,7 +84,7 @@ export function SocketIO(_clientInfo, _roomInfo) {
game,
app.context.db,
TransportAPI(gameID, socket, clientInfo, roomInfo),
true
auth
);
await master.onUpdate(action, stateID, gameID, playerID);
});
Expand All @@ -110,7 +111,7 @@ export function SocketIO(_clientInfo, _roomInfo) {
game,
app.context.db,
TransportAPI(gameID, socket, clientInfo, roomInfo),
true
auth
);
await master.onSync(gameID, playerID, numPlayers);
});
Expand Down
42 changes: 21 additions & 21 deletions src/server/transport/socketio.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,13 @@ jest.mock('koa-socket-2', () => {
describe('basic', () => {
const app = { context: {} };
const games = [Game({ seed: 0 })];
let _clientInfo;
let _roomInfo;
let clientInfo;
let roomInfo;

beforeEach(() => {
_clientInfo = new Map();
_roomInfo = new Map();
const transport = SocketIO(_clientInfo, _roomInfo);
clientInfo = new Map();
roomInfo = new Map();
const transport = SocketIO({ clientInfo, roomInfo });
transport.init(app, games);
});

Expand All @@ -93,7 +93,7 @@ describe('TransportAPI', () => {
const games = [Game({ seed: 0 })];
const clientInfo = new Map();
const roomInfo = new Map();
const transport = SocketIO(clientInfo, roomInfo);
const transport = SocketIO({ clientInfo, roomInfo });
transport.init(app, games);
io = app.context.io;
api = TransportAPI('gameID', io.socket, clientInfo, roomInfo);
Expand Down Expand Up @@ -150,8 +150,8 @@ describe('sync / update', () => {
describe('connect / disconnect', () => {
const app = { context: {} };
const games = [Game({ seed: 0 })];
let _clientInfo;
let _roomInfo;
let clientInfo;
let roomInfo;
let io;

const toObj = m => {
Expand All @@ -163,9 +163,9 @@ describe('connect / disconnect', () => {
};

beforeAll(() => {
_clientInfo = new Map();
_roomInfo = new Map();
const transport = SocketIO(_clientInfo, _roomInfo);
clientInfo = new Map();
roomInfo = new Map();
const transport = SocketIO({ clientInfo, roomInfo });
transport.init(app, games);
io = app.context.io;
});
Expand All @@ -176,11 +176,11 @@ describe('connect / disconnect', () => {
io.socket.id = '1';
await io.socket.receive('sync', 'gameID', '1', 2);

expect(toObj(_clientInfo)['0']).toMatchObject({
expect(toObj(clientInfo)['0']).toMatchObject({
gameID: 'gameID',
playerID: '0',
});
expect(toObj(_clientInfo)['1']).toMatchObject({
expect(toObj(clientInfo)['1']).toMatchObject({
gameID: 'gameID',
playerID: '1',
});
Expand All @@ -190,30 +190,30 @@ describe('connect / disconnect', () => {
io.socket.id = '0';
await io.socket.receive('disconnect');

expect(toObj(_clientInfo)['0']).toBeUndefined();
expect(toObj(_clientInfo)['1']).toMatchObject({
expect(toObj(clientInfo)['0']).toBeUndefined();
expect(toObj(clientInfo)['1']).toMatchObject({
gameID: 'gameID',
playerID: '1',
});
expect(toObj(_roomInfo.get('gameID'))).toEqual({ '1': '1' });
expect(toObj(roomInfo.get('gameID'))).toEqual({ '1': '1' });
});

test('unknown player disconnects', async () => {
io.socket.id = 'unknown';
await io.socket.receive('disconnect');

expect(toObj(_clientInfo)['0']).toBeUndefined();
expect(toObj(_clientInfo)['1']).toMatchObject({
expect(toObj(clientInfo)['0']).toBeUndefined();
expect(toObj(clientInfo)['1']).toMatchObject({
gameID: 'gameID',
playerID: '1',
});
expect(toObj(_roomInfo.get('gameID'))).toEqual({ '1': '1' });
expect(toObj(roomInfo.get('gameID'))).toEqual({ '1': '1' });
});

test('1 disconnects', async () => {
io.socket.id = '1';
await io.socket.receive('disconnect');
expect(toObj(_clientInfo)).toEqual({});
expect(toObj(_roomInfo.get('gameID'))).toEqual({});
expect(toObj(clientInfo)).toEqual({});
expect(toObj(roomInfo.get('gameID'))).toEqual({});
});
});

0 comments on commit 4d33faa

Please sign in to comment.