From 8e2f8c402747e80edcf6397045bca391aa335bec Mon Sep 17 00:00:00 2001 From: Sean Scally Date: Tue, 29 May 2018 02:45:46 -0700 Subject: [PATCH] lobby API support (#189) * Create API support for authenticated games * package-lock.json * Change game creation param to `name` * Test that joining a game that doesnt exist returns a failure * Add superagent, move supertest to dev dependency * WIP Setup authenticated game during dev server startup * Clean up authenticated game setup * Create API support for authenticated games * package-lock.json * Change game creation param to `name` * Test that joining a game that doesnt exist returns a failure * Add superagent, move supertest to dev dependency * WIP Setup authenticated game during dev server startup * Clean up authenticated game setup * Throw a 404 when the game to join is not found * standardize spacing * add missing headers * rename api-server.js to api.js * Move authenticated game setup to authenticated game example * enable CORS on API server * disallow changing the gameID in the example * fix documentation --- docs/api/Server.md | 42 + .../tic-tac-toe/components/authenticated.js | 151 +++ examples/react/modules/tic-tac-toe/routes.js | 6 + examples/react/server.js | 1 + examples/react/webpack.dev.js | 6 +- package-lock.json | 914 ++++++++---------- package.json | 7 +- src/client/client.js | 31 +- src/client/multiplayer/multiplayer.js | 8 + src/client/multiplayer/multiplayer.test.js | 36 + src/client/react-native.js | 8 + src/client/react-native.test.js | 11 +- src/client/react.js | 8 + src/client/react.test.js | 11 +- src/core/action-creators.js | 10 +- src/server/api.js | 126 +++ src/server/api.test.js | 303 ++++++ src/server/index.js | 19 +- src/server/index.test.js | 22 + 19 files changed, 1180 insertions(+), 540 deletions(-) create mode 100644 examples/react/modules/tic-tac-toe/components/authenticated.js create mode 100644 src/server/api.js create mode 100644 src/server/api.test.js diff --git a/docs/api/Server.md b/docs/api/Server.md index 83a801a87..0c33d02b4 100644 --- a/docs/api/Server.md +++ b/docs/api/Server.md @@ -42,3 +42,45 @@ const server = Server({ server.run(8000); ``` + +# Authentication + +You can optionally choose to require clients to use credential tokens to prove they have the right to send actions on behalf of a player. + +Authenticated games are created with server-side tokens for each player. You can create a game with the `games/create` API call, and join a player to a game with the `gameInstances/join` API call. + +A game that is authenticated will not accept moves from a client on behalf of a player without the appropriate credential token. + +Use the create API call to create a game that requires credential tokens. When you call the join API, you can retrieve the credential token for a particular player. + +Authentication APIs are available by default on `WebSocket port` + 1. + +### Creating a game + +#### `/games/:name/create` + +Creates a new authenticated game for a game named `name`. + +Accepts one parameter: `numPlayers`, which is required & indicates how many credentials to create. + +Returns `gameID`, which is the ID of the newly created game instance. + +### Joining a game + +#### `/game_instances/:id/join` + +Allows a player to join the given game instance `id`. + +Accepts three parameters, all required: + +`gameName`: the name of the game being joined + +`playerID`: the ordinal player in the game that is being joined (0, 1...) + +`playerName`: the display name of the player joining the game. + +Returns `playerCredentials` which is the token this player will require to authenticate their actions in the future. + +### Client Authentication + +All actions for an authenticated game require an additional payload field: `credentials`, which must be the given secret associated to the player. diff --git a/examples/react/modules/tic-tac-toe/components/authenticated.js b/examples/react/modules/tic-tac-toe/components/authenticated.js new file mode 100644 index 000000000..b5886849b --- /dev/null +++ b/examples/react/modules/tic-tac-toe/components/authenticated.js @@ -0,0 +1,151 @@ +/* + * Copyright 2018 The boardgame.io Authors + * + * Use of this source code is governed by a MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ + +import React from 'react'; +import { Client } from 'boardgame.io/react'; +import TicTacToe from '../game'; +import Board from './board'; +import PropTypes from 'prop-types'; +import request from 'superagent'; + +const App = Client({ + game: TicTacToe, + board: Board, + debug: false, + multiplayer: true, +}); + +class AuthenticatedClient extends React.Component { + constructor(props) { + super(props); + this.state = { + gameID: 'gameID', + players: { + '0': { + credentials: 'credentials', + }, + '1': { + credentials: 'credentials', + }, + }, + }; + } + + async componentDidMount() { + const gameName = 'tic-tac-toe'; + const PORT = 8000; + + const newGame = await request + .post(`http://localhost:${PORT + 1}/games/${gameName}/create`) + .send({ numPlayers: 2 }); + + const gameID = newGame.body.gameID; + + let playerCredentials = []; + + for (let playerID of [0, 1]) { + const player = await request + .patch(`http://localhost:${PORT + 1}/game_instances/${gameID}/join`) + .send({ + gameName, + playerID, + playerName: playerID.toString(), + }); + + playerCredentials.push(player.body.playerCredentials); + } + + this.setState({ + gameID, + players: { + '0': { + credentials: playerCredentials[0], + }, + '1': { + credentials: playerCredentials[1], + }, + }, + }); + } + + onPlayerCredentialsChange(playerID, credentials) { + this.setState({ + gameID: this.state.gameID, + players: { + ...this.state.players, + [playerID]: { + credentials, + }, + }, + }); + } + + render() { + return ( + + ); + } +} + +class AuthenticatedExample extends React.Component { + static propTypes = { + gameID: PropTypes.string, + players: PropTypes.any, + onPlayerCredentialsChange: PropTypes.func, + }; + + render() { + return ( +
+

Authenticated

+ +

+ Change the credentials of a player, and you will notice that the + server no longer accepts moves from that client. +

+ +
+
+ + + this.props.onPlayerCredentialsChange('0', event.target.value) + } + /> +
+
+ + + this.props.onPlayerCredentialsChange('1', event.target.value) + } + /> +
+
+
+ ); + } +} + +export default AuthenticatedClient; diff --git a/examples/react/modules/tic-tac-toe/routes.js b/examples/react/modules/tic-tac-toe/routes.js index 61028fa61..050820b26 100644 --- a/examples/react/modules/tic-tac-toe/routes.js +++ b/examples/react/modules/tic-tac-toe/routes.js @@ -9,6 +9,7 @@ import Singleplayer from './components/singleplayer'; import Multiplayer from './components/multiplayer'; import Spectator from './components/spectator'; +import Authenticated from './components/authenticated'; const routes = [ { @@ -21,6 +22,11 @@ const routes = [ text: 'Multiplayer', component: Multiplayer, }, + { + path: '/authenticated', + text: 'Authenticated', + component: Authenticated, + }, { path: '/spectator', text: 'Spectator', diff --git a/examples/react/server.js b/examples/react/server.js index c0b1649d2..b8fd89bb1 100644 --- a/examples/react/server.js +++ b/examples/react/server.js @@ -10,6 +10,7 @@ import path from 'path'; import KoaStatic from 'koa-static'; import KoaHelmet from 'koa-helmet'; import KoaWebpack from 'koa-webpack'; + import WebpackConfig from './webpack.dev.js'; import { Server } from 'boardgame.io/server'; import TicTacToe from './modules/tic-tac-toe/game'; diff --git a/examples/react/webpack.dev.js b/examples/react/webpack.dev.js index 9405630ec..9a1d16805 100644 --- a/examples/react/webpack.dev.js +++ b/examples/react/webpack.dev.js @@ -14,7 +14,11 @@ const OpenBrowserPlugin = require('open-browser-webpack-plugin'); const port = process.env.PORT || 8000; module.exports = { - entry: ['webpack-hot-middleware/client', path.resolve(__dirname, 'index.js')], + entry: [ + 'babel-polyfill', + 'webpack-hot-middleware/client', + path.resolve(__dirname, 'index.js'), + ], output: { publicPath: '/', diff --git a/package-lock.json b/package-lock.json index 25cdb649b..1e2a1a45d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,11 @@ "integrity": "sha1-6pn2EhtKjwZbTHH4VZXbJxRJiAc=", "dev": true }, + "@koa/cors": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@koa/cors/-/cors-2.2.1.tgz", + "integrity": "sha512-jy8eFnMm3EMkAsCd7B7Csz8AW2TmV3zapXbJB6Z8Pr8AWNaudm+MdBCfoUStE1i/PcpdkutnwZqmr12LJbbVdg==" + }, "@storybook/addon-actions": { "version": "3.2.16", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-3.2.16.tgz", @@ -21,7 +26,7 @@ "json-stringify-safe": "5.0.1", "prop-types": "15.6.0", "react-inspector": "2.2.1", - "uuid": "3.1.0" + "uuid": "3.2.1" }, "dependencies": { "fbjs": { @@ -252,7 +257,7 @@ "style-loader": "0.18.2", "url-loader": "0.6.2", "util-deprecate": "1.0.2", - "uuid": "3.1.0", + "uuid": "3.2.1", "webpack": "3.8.1", "webpack-dev-middleware": "1.12.0", "webpack-hot-middleware": "2.21.0" @@ -501,7 +506,7 @@ "stringstream": "0.0.5", "tough-cookie": "2.3.3", "tunnel-agent": "0.6.0", - "uuid": "3.1.0" + "uuid": "3.2.1" } }, "sntp": { @@ -3099,8 +3104,7 @@ "bytes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=", - "dev": true + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" }, "caller-path": { "version": "0.1.0", @@ -3307,7 +3311,7 @@ "requires": { "anymatch": "1.3.2", "async-each": "1.0.1", - "fsevents": "1.1.3", + "fsevents": "1.2.4", "glob-parent": "2.0.0", "inherits": "2.0.3", "is-binary-path": "1.0.1", @@ -3446,6 +3450,17 @@ "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" }, + "co-body": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/co-body/-/co-body-5.2.0.tgz", + "integrity": "sha512-sX/LQ7LqUhgyaxzbe7IqwPeTr2yfpfUIQ/dgpKo6ZI4y4lpQA0YxAomWIY+7I7rHWcG02PG+OuPREzMW/5tszQ==", + "requires": { + "inflation": "2.0.0", + "qs": "6.4.0", + "raw-body": "2.3.2", + "type-is": "1.6.15" + } + }, "coa": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/coa/-/coa-1.0.4.tgz", @@ -3722,6 +3737,12 @@ "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=", "dev": true }, + "cookiejar": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.1.tgz", + "integrity": "sha1-Qa1XsbVVlR7BcUEqgZQrHoIA00o=", + "dev": true + }, "cookies": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.7.1.tgz", @@ -5783,6 +5804,11 @@ "mime-types": "2.1.17" } }, + "formidable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", + "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==" + }, "forwarded": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", @@ -5823,39 +5849,29 @@ "dev": true }, "fsevents": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.1.3.tgz", - "integrity": "sha512-WIr7iDkdmdbxu/Gh6eKEZJL6KPE74/5MEsf2whTOFNxbIoIixogroLdKYqB6FDav4Wavh/lZdzzd3b2KxIXC5Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz", + "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==", "dev": true, "optional": true, "requires": { - "nan": "2.7.0", - "node-pre-gyp": "0.6.39" + "nan": "2.10.0", + "node-pre-gyp": "0.10.0" }, "dependencies": { "abbrev": { - "version": "1.1.0", + "version": "1.1.1", "bundled": true, "dev": true, "optional": true }, - "ajv": { - "version": "4.11.8", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "co": "4.6.0", - "json-stable-stringify": "1.0.1" - } - }, "ansi-regex": { "version": "2.1.1", "bundled": true, "dev": true }, "aproba": { - "version": "1.1.1", + "version": "1.2.0", "bundled": true, "dev": true, "optional": true @@ -5867,91 +5883,25 @@ "optional": true, "requires": { "delegates": "1.0.0", - "readable-stream": "2.2.9" + "readable-stream": "2.3.6" } }, - "asn1": { - "version": "0.2.3", - "bundled": true, - "dev": true, - "optional": true - }, - "assert-plus": { - "version": "0.2.0", - "bundled": true, - "dev": true, - "optional": true - }, - "asynckit": { - "version": "0.4.0", - "bundled": true, - "dev": true, - "optional": true - }, - "aws-sign2": { - "version": "0.6.0", - "bundled": true, - "dev": true, - "optional": true - }, - "aws4": { - "version": "1.6.0", - "bundled": true, - "dev": true, - "optional": true - }, "balanced-match": { - "version": "0.4.2", + "version": "1.0.0", "bundled": true, "dev": true }, - "bcrypt-pbkdf": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "tweetnacl": "0.14.5" - } - }, - "block-stream": { - "version": "0.0.9", - "bundled": true, - "dev": true, - "requires": { - "inherits": "2.0.3" - } - }, - "boom": { - "version": "2.10.1", - "bundled": true, - "dev": true, - "requires": { - "hoek": "2.16.3" - } - }, "brace-expansion": { - "version": "1.1.7", + "version": "1.1.11", "bundled": true, "dev": true, "requires": { - "balanced-match": "0.4.2", + "balanced-match": "1.0.0", "concat-map": "0.0.1" } }, - "buffer-shims": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "caseless": { - "version": "0.12.0", - "bundled": true, - "dev": true, - "optional": true - }, - "co": { - "version": "4.6.0", + "chownr": { + "version": "1.0.1", "bundled": true, "dev": true, "optional": true @@ -5961,14 +5911,6 @@ "bundled": true, "dev": true }, - "combined-stream": { - "version": "1.0.5", - "bundled": true, - "dev": true, - "requires": { - "delayed-stream": "1.0.0" - } - }, "concat-map": { "version": "0.0.1", "bundled": true, @@ -5982,35 +5924,11 @@ "core-util-is": { "version": "1.0.2", "bundled": true, - "dev": true - }, - "cryptiles": { - "version": "2.0.5", - "bundled": true, - "dev": true, - "requires": { - "boom": "2.10.1" - } - }, - "dashdash": { - "version": "1.14.1", - "bundled": true, "dev": true, - "optional": true, - "requires": { - "assert-plus": "1.0.0" - }, - "dependencies": { - "assert-plus": { - "version": "1.0.0", - "bundled": true, - "dev": true, - "optional": true - } - } + "optional": true }, "debug": { - "version": "2.6.8", + "version": "2.6.9", "bundled": true, "dev": true, "optional": true, @@ -6019,16 +5937,11 @@ } }, "deep-extend": { - "version": "0.4.2", + "version": "0.5.1", "bundled": true, "dev": true, "optional": true }, - "delayed-stream": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, "delegates": { "version": "1.0.0", "bundled": true, @@ -6036,74 +5949,25 @@ "optional": true }, "detect-libc": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "optional": true - }, - "ecc-jsbn": { - "version": "0.1.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "jsbn": "0.1.1" - } - }, - "extend": { - "version": "3.0.1", - "bundled": true, - "dev": true, - "optional": true - }, - "extsprintf": { - "version": "1.0.2", - "bundled": true, - "dev": true - }, - "forever-agent": { - "version": "0.6.1", + "version": "1.0.3", "bundled": true, "dev": true, "optional": true }, - "form-data": { - "version": "2.1.4", + "fs-minipass": { + "version": "1.2.5", "bundled": true, "dev": true, "optional": true, "requires": { - "asynckit": "0.4.0", - "combined-stream": "1.0.5", - "mime-types": "2.1.15" + "minipass": "2.2.4" } }, "fs.realpath": { "version": "1.0.0", "bundled": true, - "dev": true - }, - "fstream": { - "version": "1.0.11", - "bundled": true, "dev": true, - "requires": { - "graceful-fs": "4.1.11", - "inherits": "2.0.3", - "mkdirp": "0.5.1", - "rimraf": "2.6.1" - } - }, - "fstream-ignore": { - "version": "1.0.5", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "fstream": "1.0.11", - "inherits": "2.0.3", - "minimatch": "3.0.4" - } + "optional": true }, "gauge": { "version": "2.7.4", @@ -6111,7 +5975,7 @@ "dev": true, "optional": true, "requires": { - "aproba": "1.1.1", + "aproba": "1.2.0", "console-control-strings": "1.1.0", "has-unicode": "2.0.1", "object-assign": "4.1.1", @@ -6121,27 +5985,11 @@ "wide-align": "1.1.2" } }, - "getpass": { - "version": "0.1.7", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "assert-plus": "1.0.0" - }, - "dependencies": { - "assert-plus": { - "version": "1.0.0", - "bundled": true, - "dev": true, - "optional": true - } - } - }, "glob": { "version": "7.1.2", "bundled": true, "dev": true, + "optional": true, "requires": { "fs.realpath": "1.0.0", "inflight": "1.0.6", @@ -6151,64 +5999,35 @@ "path-is-absolute": "1.0.1" } }, - "graceful-fs": { - "version": "4.1.11", - "bundled": true, - "dev": true - }, - "har-schema": { - "version": "1.0.5", - "bundled": true, - "dev": true, - "optional": true - }, - "har-validator": { - "version": "4.2.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "ajv": "4.11.8", - "har-schema": "1.0.5" - } - }, "has-unicode": { "version": "2.0.1", "bundled": true, "dev": true, "optional": true }, - "hawk": { - "version": "3.1.3", + "iconv-lite": { + "version": "0.4.21", "bundled": true, "dev": true, + "optional": true, "requires": { - "boom": "2.10.1", - "cryptiles": "2.0.5", - "hoek": "2.16.3", - "sntp": "1.0.9" + "safer-buffer": "2.1.2" } }, - "hoek": { - "version": "2.16.3", - "bundled": true, - "dev": true - }, - "http-signature": { - "version": "1.1.1", + "ignore-walk": { + "version": "3.0.1", "bundled": true, "dev": true, "optional": true, "requires": { - "assert-plus": "0.2.0", - "jsprim": "1.4.0", - "sshpk": "1.13.0" + "minimatch": "3.0.4" } }, "inflight": { "version": "1.0.6", "bundled": true, "dev": true, + "optional": true, "requires": { "once": "1.4.0", "wrappy": "1.0.2" @@ -6220,7 +6039,7 @@ "dev": true }, "ini": { - "version": "1.3.4", + "version": "1.3.5", "bundled": true, "dev": true, "optional": true @@ -6233,111 +6052,43 @@ "number-is-nan": "1.0.1" } }, - "is-typedarray": { - "version": "1.0.0", - "bundled": true, - "dev": true, - "optional": true - }, "isarray": { "version": "1.0.0", "bundled": true, - "dev": true - }, - "isstream": { - "version": "0.1.2", - "bundled": true, - "dev": true, - "optional": true - }, - "jodid25519": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "jsbn": "0.1.1" - } - }, - "jsbn": { - "version": "0.1.1", - "bundled": true, - "dev": true, - "optional": true - }, - "json-schema": { - "version": "0.2.3", - "bundled": true, - "dev": true, - "optional": true - }, - "json-stable-stringify": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "jsonify": "0.0.0" - } - }, - "json-stringify-safe": { - "version": "5.0.1", - "bundled": true, "dev": true, "optional": true }, - "jsonify": { - "version": "0.0.0", - "bundled": true, - "dev": true, - "optional": true - }, - "jsprim": { - "version": "1.4.0", + "minimatch": { + "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { - "assert-plus": "1.0.0", - "extsprintf": "1.0.2", - "json-schema": "0.2.3", - "verror": "1.3.6" - }, - "dependencies": { - "assert-plus": { - "version": "1.0.0", - "bundled": true, - "dev": true, - "optional": true - } + "brace-expansion": "1.1.11" } }, - "mime-db": { - "version": "1.27.0", + "minimist": { + "version": "0.0.8", "bundled": true, "dev": true }, - "mime-types": { - "version": "2.1.15", + "minipass": { + "version": "2.2.4", "bundled": true, "dev": true, "requires": { - "mime-db": "1.27.0" + "safe-buffer": "5.1.1", + "yallist": "3.0.2" } }, - "minimatch": { - "version": "3.0.4", + "minizlib": { + "version": "1.1.0", "bundled": true, "dev": true, + "optional": true, "requires": { - "brace-expansion": "1.1.7" + "minipass": "2.2.4" } }, - "minimist": { - "version": "0.0.8", - "bundled": true, - "dev": true - }, "mkdirp": { "version": "0.5.1", "bundled": true, @@ -6352,23 +6103,33 @@ "dev": true, "optional": true }, + "needle": { + "version": "2.2.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "2.6.9", + "iconv-lite": "0.4.21", + "sax": "1.2.4" + } + }, "node-pre-gyp": { - "version": "0.6.39", + "version": "0.10.0", "bundled": true, "dev": true, "optional": true, "requires": { - "detect-libc": "1.0.2", - "hawk": "3.1.3", + "detect-libc": "1.0.3", "mkdirp": "0.5.1", + "needle": "2.2.0", "nopt": "4.0.1", - "npmlog": "4.1.0", - "rc": "1.2.1", - "request": "2.81.0", - "rimraf": "2.6.1", - "semver": "5.3.0", - "tar": "2.2.1", - "tar-pack": "3.4.0" + "npm-packlist": "1.1.10", + "npmlog": "4.1.2", + "rc": "1.2.7", + "rimraf": "2.6.2", + "semver": "5.5.0", + "tar": "4.4.1" } }, "nopt": { @@ -6377,12 +6138,28 @@ "dev": true, "optional": true, "requires": { - "abbrev": "1.1.0", - "osenv": "0.1.4" + "abbrev": "1.1.1", + "osenv": "0.1.5" + } + }, + "npm-bundled": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.1.10", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "3.0.1", + "npm-bundled": "1.0.3" } }, "npmlog": { - "version": "4.1.0", + "version": "4.1.2", "bundled": true, "dev": true, "optional": true, @@ -6398,12 +6175,6 @@ "bundled": true, "dev": true }, - "oauth-sign": { - "version": "0.8.2", - "bundled": true, - "dev": true, - "optional": true - }, "object-assign": { "version": "4.1.1", "bundled": true, @@ -6431,7 +6202,7 @@ "optional": true }, "osenv": { - "version": "0.1.4", + "version": "0.1.5", "bundled": true, "dev": true, "optional": true, @@ -6443,39 +6214,23 @@ "path-is-absolute": { "version": "1.0.1", "bundled": true, - "dev": true - }, - "performance-now": { - "version": "0.2.0", - "bundled": true, "dev": true, "optional": true }, "process-nextick-args": { - "version": "1.0.7", - "bundled": true, - "dev": true - }, - "punycode": { - "version": "1.4.1", - "bundled": true, - "dev": true, - "optional": true - }, - "qs": { - "version": "6.4.0", + "version": "2.0.0", "bundled": true, "dev": true, "optional": true }, "rc": { - "version": "1.2.1", + "version": "1.2.7", "bundled": true, "dev": true, "optional": true, "requires": { - "deep-extend": "0.4.2", - "ini": "1.3.4", + "deep-extend": "0.5.1", + "ini": "1.3.5", "minimist": "1.2.0", "strip-json-comments": "2.0.1" }, @@ -6489,112 +6244,63 @@ } }, "readable-stream": { - "version": "2.2.9", - "bundled": true, - "dev": true, - "requires": { - "buffer-shims": "1.0.0", - "core-util-is": "1.0.2", - "inherits": "2.0.3", - "isarray": "1.0.0", - "process-nextick-args": "1.0.7", - "string_decoder": "1.0.1", - "util-deprecate": "1.0.2" - } - }, - "request": { - "version": "2.81.0", + "version": "2.3.6", "bundled": true, "dev": true, "optional": true, "requires": { - "aws-sign2": "0.6.0", - "aws4": "1.6.0", - "caseless": "0.12.0", - "combined-stream": "1.0.5", - "extend": "3.0.1", - "forever-agent": "0.6.1", - "form-data": "2.1.4", - "har-validator": "4.2.1", - "hawk": "3.1.3", - "http-signature": "1.1.1", - "is-typedarray": "1.0.0", - "isstream": "0.1.2", - "json-stringify-safe": "5.0.1", - "mime-types": "2.1.15", - "oauth-sign": "0.8.2", - "performance-now": "0.2.0", - "qs": "6.4.0", - "safe-buffer": "5.0.1", - "stringstream": "0.0.5", - "tough-cookie": "2.3.2", - "tunnel-agent": "0.6.0", - "uuid": "3.0.1" + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "2.0.0", + "safe-buffer": "5.1.1", + "string_decoder": "1.1.1", + "util-deprecate": "1.0.2" } }, "rimraf": { - "version": "2.6.1", + "version": "2.6.2", "bundled": true, "dev": true, + "optional": true, "requires": { "glob": "7.1.2" } }, "safe-buffer": { - "version": "5.0.1", + "version": "5.1.1", "bundled": true, "dev": true }, - "semver": { - "version": "5.3.0", + "safer-buffer": { + "version": "2.1.2", "bundled": true, "dev": true, "optional": true }, - "set-blocking": { - "version": "2.0.0", + "sax": { + "version": "1.2.4", "bundled": true, "dev": true, "optional": true }, - "signal-exit": { - "version": "3.0.2", + "semver": { + "version": "5.5.0", "bundled": true, "dev": true, "optional": true }, - "sntp": { - "version": "1.0.9", + "set-blocking": { + "version": "2.0.0", "bundled": true, "dev": true, - "requires": { - "hoek": "2.16.3" - } + "optional": true }, - "sshpk": { - "version": "1.13.0", + "signal-exit": { + "version": "3.0.2", "bundled": true, "dev": true, - "optional": true, - "requires": { - "asn1": "0.2.3", - "assert-plus": "1.0.0", - "bcrypt-pbkdf": "1.0.1", - "dashdash": "1.14.1", - "ecc-jsbn": "0.1.1", - "getpass": "0.1.7", - "jodid25519": "1.0.2", - "jsbn": "0.1.1", - "tweetnacl": "0.14.5" - }, - "dependencies": { - "assert-plus": { - "version": "1.0.0", - "bundled": true, - "dev": true, - "optional": true - } - } + "optional": true }, "string-width": { "version": "1.0.2", @@ -6607,19 +6313,14 @@ } }, "string_decoder": { - "version": "1.0.1", + "version": "1.1.1", "bundled": true, "dev": true, + "optional": true, "requires": { - "safe-buffer": "5.0.1" + "safe-buffer": "5.1.1" } }, - "stringstream": { - "version": "0.0.5", - "bundled": true, - "dev": true, - "optional": true - }, "strip-ansi": { "version": "3.0.1", "bundled": true, @@ -6635,81 +6336,26 @@ "optional": true }, "tar": { - "version": "2.2.1", - "bundled": true, - "dev": true, - "requires": { - "block-stream": "0.0.9", - "fstream": "1.0.11", - "inherits": "2.0.3" - } - }, - "tar-pack": { - "version": "3.4.0", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "debug": "2.6.8", - "fstream": "1.0.11", - "fstream-ignore": "1.0.5", - "once": "1.4.0", - "readable-stream": "2.2.9", - "rimraf": "2.6.1", - "tar": "2.2.1", - "uid-number": "0.0.6" - } - }, - "tough-cookie": { - "version": "2.3.2", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "punycode": "1.4.1" - } - }, - "tunnel-agent": { - "version": "0.6.0", + "version": "4.4.1", "bundled": true, "dev": true, "optional": true, "requires": { - "safe-buffer": "5.0.1" + "chownr": "1.0.1", + "fs-minipass": "1.2.5", + "minipass": "2.2.4", + "minizlib": "1.1.0", + "mkdirp": "0.5.1", + "safe-buffer": "5.1.1", + "yallist": "3.0.2" } }, - "tweetnacl": { - "version": "0.14.5", - "bundled": true, - "dev": true, - "optional": true - }, - "uid-number": { - "version": "0.0.6", - "bundled": true, - "dev": true, - "optional": true - }, "util-deprecate": { "version": "1.0.2", "bundled": true, - "dev": true - }, - "uuid": { - "version": "3.0.1", - "bundled": true, "dev": true, "optional": true }, - "verror": { - "version": "1.3.6", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "extsprintf": "1.0.2" - } - }, "wide-align": { "version": "1.1.2", "bundled": true, @@ -6723,6 +6369,11 @@ "version": "1.0.2", "bundled": true, "dev": true + }, + "yallist": { + "version": "3.0.2", + "bundled": true, + "dev": true } } }, @@ -7648,6 +7299,11 @@ "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=" }, + "inflation": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/inflation/-/inflation-2.0.0.tgz", + "integrity": "sha1-i0F+R8KPklpFEz2RTKH9OJEH8w8=" + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -9376,6 +9032,15 @@ "vary": "1.1.1" } }, + "koa-body": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/koa-body/-/koa-body-2.6.0.tgz", + "integrity": "sha512-8i9ti3TRxelsnPUct0xY8toTFj5gTzGWW45ePBkT8fnzZP75y5woisVpziIdqcnqtt1lMNBD30p+tkiSC+NfjQ==", + "requires": { + "co-body": "5.2.0", + "formidable": "1.2.1" + } + }, "koa-compose": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.0.0.tgz", @@ -10750,9 +10415,9 @@ } }, "nan": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.7.0.tgz", - "integrity": "sha1-2Vv3IeyHfgjbJ27T/G63j5CDrUY=", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz", + "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==", "dev": true, "optional": true }, @@ -12434,8 +12099,7 @@ "qs": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.4.0.tgz", - "integrity": "sha1-E+JtKK1rD/qpExLNO/cI7TUecjM=", - "dev": true + "integrity": "sha1-E+JtKK1rD/qpExLNO/cI7TUecjM=" }, "query-string": { "version": "4.3.4", @@ -12576,7 +12240,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz", "integrity": "sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=", - "dev": true, "requires": { "bytes": "3.0.0", "http-errors": "1.6.2", @@ -12587,8 +12250,7 @@ "iconv-lite": { "version": "0.4.19", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", - "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==", - "dev": true + "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" } } }, @@ -13275,7 +12937,7 @@ "stringstream": "0.0.5", "tough-cookie": "2.3.2", "tunnel-agent": "0.6.0", - "uuid": "3.1.0" + "uuid": "3.2.1" } }, "require-directory": { @@ -13725,7 +13387,7 @@ "anymatch": "1.3.2", "exec-sh": "0.2.1", "fb-watchman": "2.0.0", - "fsevents": "1.1.3", + "fsevents": "1.2.4", "minimatch": "3.0.4", "minimist": "1.2.0", "walker": "1.0.7", @@ -14398,6 +14060,210 @@ "schema-utils": "0.3.0" } }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "dev": true, + "requires": { + "component-emitter": "1.2.1", + "cookiejar": "2.1.1", + "debug": "3.1.0", + "extend": "3.0.1", + "form-data": "2.3.2", + "formidable": "1.2.1", + "methods": "1.1.2", + "mime": "1.6.0", + "qs": "6.5.2", + "readable-stream": "2.3.6" + }, + "dependencies": { + "combined-stream": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "dev": true, + "requires": { + "delayed-stream": "1.0.0" + } + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "form-data": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", + "dev": true, + "requires": { + "asynckit": "0.4.0", + "combined-stream": "1.0.6", + "mime-types": "2.1.17" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "dev": true + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "dev": true + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "2.0.0", + "safe-buffer": "5.1.1", + "string_decoder": "1.1.1", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "5.1.1" + } + } + } + }, + "supertest": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-3.1.0.tgz", + "integrity": "sha512-O44AMnmJqx294uJQjfUmEyYOg7d9mylNFsMw/Wkz4evKd1njyPrtCN+U6ZIC7sKtfEVQhfTqFFijlXx8KP/Czw==", + "dev": true, + "requires": { + "methods": "1.1.2", + "superagent": "3.8.2" + }, + "dependencies": { + "combined-stream": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "dev": true, + "requires": { + "delayed-stream": "1.0.0" + } + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "form-data": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", + "dev": true, + "requires": { + "asynckit": "0.4.0", + "combined-stream": "1.0.6", + "mime-types": "2.1.17" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "dev": true + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "dev": true + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "2.0.0", + "safe-buffer": "5.1.1", + "string_decoder": "1.1.1", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "5.1.1" + } + }, + "superagent": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.2.tgz", + "integrity": "sha512-gVH4QfYHcY3P0f/BZzavLreHW3T1v7hG9B+hpMQotGQqurOvhv87GcMCd6LWySmBuf+BDR44TQd0aISjVHLeNQ==", + "dev": true, + "requires": { + "component-emitter": "1.2.1", + "cookiejar": "2.1.1", + "debug": "3.1.0", + "extend": "3.0.1", + "form-data": "2.3.2", + "formidable": "1.2.1", + "methods": "1.1.2", + "mime": "1.6.0", + "qs": "6.5.2", + "readable-stream": "2.3.6" + } + } + } + }, "supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", @@ -14880,8 +14746,7 @@ "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", - "dev": true + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" }, "unzip-response": { "version": "2.0.1", @@ -15042,10 +14907,9 @@ "dev": true }, "uuid": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", - "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==", - "dev": true + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.2.1.tgz", + "integrity": "sha512-jZnMwlb9Iku/O3smGWvZhauCf6cvvpKi4BKRiliS3cxnI+Gz9j5MEpTz2UFuXiKPJocb7gnsLHwiS05ige5BEA==" }, "uws": { "version": "0.14.5", diff --git a/package.json b/package.json index c89055ac2..8f4ce8b34 100644 --- a/package.json +++ b/package.json @@ -108,11 +108,15 @@ "rollup-plugin-uglify": "^2.0.1", "shelljs": "^0.7.8", "style-loader": "^0.18.2", + "superagent": "^3.8.3", + "supertest": "^3.1.0", "uglify-es": "^3.1.3", "webpack": "^3.5.5" }, "dependencies": { + "@koa/cors": "^2.2.1", "koa": "^2.3.0", + "koa-body": "^2.5.0", "koa-router": "^7.2.1", "koa-socket": "^4.4.0", "koa-static": "^4.0.1", @@ -121,7 +125,8 @@ "mousetrap": "^1.6.1", "prop-types": "^15.5.10", "redux": "^3.7.2", - "socket.io": "^2.0.3" + "socket.io": "^2.0.3", + "uuid": "3.2.1" }, "peerDependencies": { "react": "^16.0.0" diff --git a/src/client/client.js b/src/client/client.js index 75264ce6b..66d753b34 100644 --- a/src/client/client.js +++ b/src/client/client.js @@ -19,12 +19,19 @@ import { createGameReducer } from '../core/reducer'; * @param {object} store - The Redux store to create dispatchers for. * @param {string} playerID - The ID of the player dispatching these events. */ -export function createEventDispatchers(eventNames, store, playerID) { +export function createEventDispatchers( + eventNames, + store, + playerID, + credentials +) { let dispatchers = {}; for (let i = 0; i < eventNames.length; i++) { const name = eventNames[i]; dispatchers[name] = function(...args) { - store.dispatch(ActionCreators.gameEvent(name, args, playerID)); + store.dispatch( + ActionCreators.gameEvent(name, args, playerID, credentials) + ); }; } return dispatchers; @@ -37,12 +44,14 @@ export function createEventDispatchers(eventNames, store, playerID) { * @param {Array} moveNames - A list of move names. * @param {object} store - The Redux store to create dispatchers for. */ -export function createMoveDispatchers(moveNames, store, playerID) { +export function createMoveDispatchers(moveNames, store, playerID, credentials) { let dispatchers = {}; for (let i = 0; i < moveNames.length; i++) { const name = moveNames[i]; dispatchers[name] = function(...args) { - store.dispatch(ActionCreators.makeMove(name, args, playerID)); + store.dispatch( + ActionCreators.makeMove(name, args, playerID, credentials) + ); }; } return dispatchers; @@ -59,11 +68,13 @@ class _ClientImpl { socketOpts, gameID, playerID, + credentials, enhancer, }) { this.game = game; this.playerID = playerID; this.gameID = gameID; + this.credentials = credentials; let server = undefined; if (multiplayer instanceof Object && 'server' in multiplayer) { @@ -173,13 +184,15 @@ class _ClientImpl { this.moves = createMoveDispatchers( this.game.moveNames, this.store, - this.playerID + this.playerID, + this.credentials ); this.events = createEventDispatchers( this.game.flow.eventNames, this.store, - this.playerID + this.playerID, + this.credentials ); } @@ -200,6 +213,11 @@ class _ClientImpl { this.multiplayerClient.updateGameID(gameID); } } + + updateCredentials(credentials) { + this.credentials = credentials; + this.createDispatchers(); + } } /** @@ -215,6 +233,7 @@ class _ClientImpl { * @param {...object} socketOpts - Options to pass to socket.io. * @param {...object} gameID - The gameID that you want to connect to. * @param {...object} playerID - The playerID associated with this client. + * @param {...string} credentials - The authentication credentials associated with this client. * * Returns: * A JS object that provides an API to interact with the diff --git a/src/client/multiplayer/multiplayer.js b/src/client/multiplayer/multiplayer.js index ca3bc2c9c..4fcfc3c40 100644 --- a/src/client/multiplayer/multiplayer.js +++ b/src/client/multiplayer/multiplayer.js @@ -139,6 +139,10 @@ export class Multiplayer { updateGameID(id) { this.gameID = this.gameName + ':' + id; + const action = ActionCreators.reset(); + action._remote = true; + this.store.dispatch(action); + if (this.socket) { this.socket.emit('sync', this.gameID, this.playerID, this.numPlayers); } @@ -151,6 +155,10 @@ export class Multiplayer { updatePlayerID(id) { this.playerID = id; + const action = ActionCreators.reset(); + action._remote = true; + this.store.dispatch(action); + if (this.socket) { this.socket.emit('sync', this.gameID, this.playerID, this.numPlayers); } diff --git a/src/client/multiplayer/multiplayer.test.js b/src/client/multiplayer/multiplayer.test.js index 7ed1e708c..1f84f1603 100644 --- a/src/client/multiplayer/multiplayer.test.js +++ b/src/client/multiplayer/multiplayer.test.js @@ -11,6 +11,7 @@ import Game from '../../core/game'; import { makeMove } from '../../core/action-creators'; import { createGameReducer } from '../../core/reducer'; import * as ActionCreators from '../../core/action-creators'; +import * as Actions from '../../core/action-types'; class MockSocket { constructor() { @@ -35,6 +36,9 @@ test('Multiplayer defaults', () => { test('update gameID / playerID', () => { const m = new Multiplayer(); + const game = Game({}); + m.createStore(createGameReducer({ game })); + m.updateGameID('test'); m.updatePlayerID('player'); expect(m.gameID).toBe('default:test'); @@ -168,3 +172,35 @@ test('game server accepts enhanced store', () => { store.dispatch(makeMove('A', {})); expect(spyDispatcher.mock.calls.length).toBe(1); }); + +test('changing a gameID resets the state before resync', () => { + const m = new Multiplayer(); + const game = Game({}); + const store = m.createStore(createGameReducer({ game })); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + m.updateGameID('foo'); + + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: Actions.RESET, + _remote: true, + }) + ); +}); + +test('changing a playerID resets the state before resync', () => { + const m = new Multiplayer(); + const game = Game({}); + const store = m.createStore(createGameReducer({ game })); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + m.updatePlayerID('foo'); + + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: Actions.RESET, + _remote: true, + }) + ); +}); diff --git a/src/client/react-native.js b/src/client/react-native.js index 6dbcb1321..3baef94b5 100644 --- a/src/client/react-native.js +++ b/src/client/react-native.js @@ -43,11 +43,15 @@ export function Client({ game, numPlayers, board, multiplayer, enhancer }) { // The ID of the player associated with this client. // Only relevant in multiplayer. playerID: PropTypes.string, + // This client's authentication credentials. + // Only relevant in multiplayer. + credentials: PropTypes.string, }; static defaultProps = { gameID: 'default', playerID: null, + credentials: null, }; constructor(props) { @@ -59,6 +63,7 @@ export function Client({ game, numPlayers, board, multiplayer, enhancer }) { multiplayer, gameID: props.gameID, playerID: props.playerID, + credentials: props.credentials, socketOpts: { transports: ['websocket'], }, @@ -75,6 +80,9 @@ export function Client({ game, numPlayers, board, multiplayer, enhancer }) { if (nextProps.playerID != this.props.playerID) { this.client.updatePlayerID(nextProps.playerID); } + if (nextProps.credentials != this.props.credentials) { + this.client.updateCredentials(nextProps.credentials); + } } componentWillMount() { diff --git a/src/client/react-native.test.js b/src/client/react-native.test.js index a3570fbdd..213d9772e 100644 --- a/src/client/react-native.test.js +++ b/src/client/react-native.test.js @@ -145,27 +145,36 @@ test('update gameID / playerID', () => { board: TestBoard, multiplayer: true, }); - game = Enzyme.mount(); + game = Enzyme.mount(); const m = game.instance().client.multiplayerClient; + const g = game.instance().client; const spy1 = jest.spyOn(m, 'updateGameID'); const spy2 = jest.spyOn(m, 'updatePlayerID'); + const spy3 = jest.spyOn(g, 'updateCredentials'); expect(m.gameID).toBe('default:a'); expect(m.playerID).toBe('1'); + game.setProps({ gameID: 'a' }); game.setProps({ playerID: '1' }); + game.setProps({ credentials: 'foo' }); + expect(m.gameID).toBe('default:a'); expect(m.playerID).toBe('1'); expect(spy1).not.toHaveBeenCalled(); expect(spy2).not.toHaveBeenCalled(); + expect(spy3).not.toHaveBeenCalled(); game.setProps({ gameID: 'next' }); game.setProps({ playerID: 'next' }); + game.setProps({ credentials: 'bar' }); + expect(m.gameID).toBe('default:next'); expect(m.playerID).toBe('next'); expect(spy1).toHaveBeenCalled(); expect(spy2).toHaveBeenCalled(); + expect(spy3).toHaveBeenCalled(); }); test('local playerView', () => { diff --git a/src/client/react.js b/src/client/react.js index 78b53a8da..62c3bff6a 100644 --- a/src/client/react.js +++ b/src/client/react.js @@ -55,6 +55,9 @@ export function Client({ // The ID of the player associated with this client. // Only relevant in multiplayer. playerID: PropTypes.string, + // This client's authentication credentials. + // Only relevant in multiplayer. + credentials: PropTypes.string, // Enable / disable the Debug UI. debug: PropTypes.bool, }; @@ -62,6 +65,7 @@ export function Client({ static defaultProps = { gameID: 'default', playerID: null, + credentials: null, debug: true, }; @@ -78,6 +82,7 @@ export function Client({ multiplayer, gameID: props.gameID, playerID: props.playerID, + credentials: props.credentials, enhancer, }); @@ -91,6 +96,9 @@ export function Client({ if (nextProps.playerID != this.props.playerID) { this.client.updatePlayerID(nextProps.playerID); } + if (nextProps.credentials != this.props.credentials) { + this.client.updateCredentials(nextProps.credentials); + } } componentDidMount() { diff --git a/src/client/react.test.js b/src/client/react.test.js index 3f91dbcc3..64798f0e0 100644 --- a/src/client/react.test.js +++ b/src/client/react.test.js @@ -157,27 +157,36 @@ test('update gameID / playerID', () => { board: TestBoard, multiplayer: true, }); - game = Enzyme.mount(); + game = Enzyme.mount(); const m = game.instance().client.multiplayerClient; + const g = game.instance().client; const spy1 = jest.spyOn(m, 'updateGameID'); const spy2 = jest.spyOn(m, 'updatePlayerID'); + const spy3 = jest.spyOn(g, 'updateCredentials'); expect(m.gameID).toBe('default:a'); expect(m.playerID).toBe('1'); + game.setProps({ gameID: 'a' }); game.setProps({ playerID: '1' }); + game.setProps({ credentials: 'foo' }); + expect(m.gameID).toBe('default:a'); expect(m.playerID).toBe('1'); expect(spy1).not.toHaveBeenCalled(); expect(spy2).not.toHaveBeenCalled(); + expect(spy3).not.toHaveBeenCalled(); game.setProps({ gameID: 'next' }); game.setProps({ playerID: 'next' }); + game.setProps({ credentials: 'bar' }); + expect(m.gameID).toBe('default:next'); expect(m.playerID).toBe('next'); expect(spy1).toHaveBeenCalled(); expect(spy2).toHaveBeenCalled(); + expect(spy3).toHaveBeenCalled(); }); test('local playerView', () => { diff --git a/src/core/action-creators.js b/src/core/action-creators.js index 0ae1a6bb5..473bfe694 100644 --- a/src/core/action-creators.js +++ b/src/core/action-creators.js @@ -14,10 +14,11 @@ import * as Actions from './action-types'; * @param {string} type - The move type. * @param {Array} args - Additional arguments. * @param {string} playerID - The ID of the player making this action. + * @param {string} credentials - (optional) The credentials for the player making this action. */ -export const makeMove = (type, args, playerID) => ({ +export const makeMove = (type, args, playerID, credentials) => ({ type: Actions.MAKE_MOVE, - payload: { type, args, playerID }, + payload: { type, args, playerID, credentials }, }); /** @@ -26,10 +27,11 @@ export const makeMove = (type, args, playerID) => ({ * @param {string} type - The event type. * @param {Array} args - Additional arguments. * @param {string} playerID - The ID of the player making this action. + * @param {string} credentials - (optional) The credentials for the player making this action. */ -export const gameEvent = (type, args, playerID) => ({ +export const gameEvent = (type, args, playerID, credentials) => ({ type: Actions.GAME_EVENT, - payload: { type, args, playerID }, + payload: { type, args, playerID, credentials }, }); /** diff --git a/src/server/api.js b/src/server/api.js new file mode 100644 index 000000000..bdfdfdb54 --- /dev/null +++ b/src/server/api.js @@ -0,0 +1,126 @@ +/* + * Copyright 2018 The boardgame.io Authors + * + * Use of this source code is governed by a MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ + +const Koa = require('koa'); +const Router = require('koa-router'); +const koaBody = require('koa-body'); +const uuid = require('uuid/v4'); +const cors = require('@koa/cors'); +const Redux = require('redux'); + +import { createGameReducer } from '../core/reducer'; + +const createCredentials = () => uuid(); +const getGameMetadataKey = gameID => `${gameID}:metadata`; +const getNewGameInstanceID = () => uuid(); +const createGameMetadata = () => ({ + players: {}, +}); + +export const isActionFromAuthenticPlayer = async ({ + action, + db, + gameID, + playerID, +}) => { + const gameMetadata = await db.get(getGameMetadataKey(gameID)); + if (!gameMetadata) { + return true; + } + + if (!action.payload) { + return true; + } + + const hasCredentials = Object.keys(gameMetadata.players).some(key => { + return !!( + gameMetadata.players[key] && gameMetadata.players[key].credentials + ); + }); + if (!hasCredentials) { + return true; + } + + if (!action.payload.credentials) { + return false; + } + + if ( + action.payload.credentials !== gameMetadata.players[playerID].credentials + ) { + return false; + } + + return true; +}; + +export const createApiServer = ({ db, games }) => { + const app = new Koa(); + const router = new Router(); + + router.post('/games/:name/create', koaBody(), async ctx => { + const gameName = ctx.params.name; + let numPlayers = parseInt(ctx.request.body.numPlayers); + if (!numPlayers) { + numPlayers = 2; + } + + const gameMetadata = createGameMetadata(); + + const game = games.find(g => g.name === gameName); + const reducer = createGameReducer({ + game, + numPlayers, + }); + const store = Redux.createStore(reducer); + const state = store.getState(); + + for (let playerIndex = 0; playerIndex < numPlayers; playerIndex++) { + const credentials = createCredentials(); + gameMetadata.players[playerIndex] = { id: playerIndex, credentials }; + } + + const gameID = getNewGameInstanceID(); + const namespacedGameID = `${gameName}:${gameID}`; + + await db.set(getGameMetadataKey(namespacedGameID), gameMetadata); + await db.set(namespacedGameID, state); + + ctx.body = { + gameID, + }; + }); + + router.patch('/game_instances/:id/join', koaBody(), async ctx => { + const gameID = ctx.params.id; + const gameName = ctx.request.body.gameName; + const playerID = ctx.request.body.playerID; + const playerName = ctx.request.body.playerName; + + const namespacedGameID = `${gameName}:${gameID}`; + const gameMetadata = await db.get(getGameMetadataKey(namespacedGameID)); + + if (gameMetadata === null) { + ctx.throw(404, 'Game not found'); + } + + gameMetadata.players[playerID].name = playerName; + const playerCredentials = gameMetadata.players[playerID].credentials; + + await db.set(getGameMetadataKey(namespacedGameID), gameMetadata); + + ctx.body = { + playerCredentials, + }; + }); + + app.use(cors()); + app.use(router.routes()).use(router.allowedMethods()); + + return app; +}; diff --git a/src/server/api.test.js b/src/server/api.test.js new file mode 100644 index 000000000..d892c370e --- /dev/null +++ b/src/server/api.test.js @@ -0,0 +1,303 @@ +/* + * Copyright 2018 The boardgame.io Authors + * + * Use of this source code is governed by a MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ + +import request from 'supertest'; + +import { isActionFromAuthenticPlayer, createApiServer } from './api'; +import Game from '../core/game'; + +describe('.isActionFromAuthenticPlayer', () => { + let action; + let db; + let gameID; + let playerID; + let gameMetadata; + + beforeEach(() => { + gameID = 'some-game'; + playerID = '0'; + + action = { + payload: { + credentials: 'SECRET', + }, + }; + + gameMetadata = { + players: { + '0': { + credentials: 'SECRET', + }, + }, + }; + + db = { + get() { + return Promise.resolve(gameMetadata); + }, + }; + }); + + describe('when game metadata is not found', () => { + beforeEach(() => { + gameMetadata = null; + }); + + test('the action is authentic', async () => { + const result = await isActionFromAuthenticPlayer({ + action, + db, + gameID, + playerID, + }); + + expect(result).toBeTruthy(); + }); + }); + + describe('when action contains no payload', () => { + beforeEach(() => { + action = {}; + }); + + test('the action is authentic', async () => { + const result = await isActionFromAuthenticPlayer({ + action, + db, + gameID, + playerID, + }); + + expect(result).toBeTruthy(); + }); + }); + + describe('when game has no credentials', () => { + beforeEach(() => { + gameMetadata = { + players: { + '0': {}, + }, + }; + }); + + test('then action is authentic', async () => { + const result = await isActionFromAuthenticPlayer({ + action, + db, + gameID, + playerID, + }); + + expect(result).toBeTruthy(); + }); + }); + + describe('when game has credentials', () => { + describe('when action contains no credentials', () => { + beforeEach(() => { + action = { + payload: { + someStuff: 'foo', + }, + }; + }); + + test('then action is not authentic', async () => { + const result = await isActionFromAuthenticPlayer({ + action, + db, + gameID, + playerID, + }); + + expect(result).toBeFalsy(); + }); + }); + + describe('when action credentials do not match game credentials', () => { + beforeEach(() => { + action = { + payload: { + credentials: 'WRONG', + }, + }; + }); + test('then action is not authentic', async () => { + const result = await isActionFromAuthenticPlayer({ + action, + db, + gameID, + playerID, + }); + + expect(result).toBeFalsy(); + }); + }); + + describe('when action credentials do match game credentials', () => { + test('then action is authentic', async () => { + const result = await isActionFromAuthenticPlayer({ + action, + db, + gameID, + playerID, + }); + + expect(result).toBeTruthy(); + }); + }); + }); +}); + +describe('.createApiServer', () => { + describe('creating a game', () => { + let response; + let setSpy; + let app; + + beforeEach(async () => { + setSpy = jest.fn(); + const db = { + set: async (id, state) => setSpy(id, state), + }; + const games = [Game({ name: 'foo' })]; + + app = createApiServer({ db, games }); + + response = await request(app.callback()) + .post('/games/foo/create') + .send('numPlayers=3'); + }); + + test('is successful', () => { + expect(response.status).toEqual(200); + }); + + test('creates game data', () => { + expect(setSpy).toHaveBeenCalledWith( + expect.stringMatching('foo:'), + expect.objectContaining({ + ctx: expect.objectContaining({ + numPlayers: 3, + }), + }) + ); + }); + + test('creates game metadata', () => { + expect(setSpy).toHaveBeenCalledWith( + expect.stringMatching(':metadata'), + expect.objectContaining({ + players: expect.objectContaining({ + '0': expect.objectContaining({}), + '1': expect.objectContaining({}), + }), + }) + ); + }); + + test('returns game id', () => { + expect(response.body.gameID).not.toBeNull(); + }); + + describe('without numPlayers', () => { + beforeEach(async () => { + response = await request(app.callback()).post('/games/foo/create'); + }); + + test('uses default numPlayers', () => { + expect(setSpy).toHaveBeenCalledWith( + expect.stringMatching('foo:'), + expect.objectContaining({ + ctx: expect.objectContaining({ + numPlayers: 2, + }), + }) + ); + }); + }); + }); + + describe('joining a game', () => { + let response; + let db; + let games; + let credentials; + + beforeEach(() => { + credentials = 'SECRET'; + games = [Game({ name: 'foo' })]; + }); + + describe('when the game does not exist', () => { + beforeEach(async () => { + db = { + get: async () => null, + }; + + const app = createApiServer({ db, games }); + + response = await request(app.callback()) + .patch('/game_instances/1/join') + .send('gameName=foo&playerID=0&playerName=alice'); + }); + + test('throws a "not found" error', async () => { + expect(response.status).toEqual(404); + }); + }); + + describe('when the game does exist', () => { + let setSpy; + + beforeEach(async () => { + setSpy = jest.fn(); + db = { + get: async () => { + return { + players: { + '0': { + credentials, + }, + }, + }; + }, + set: async (id, state) => setSpy(id, state), + }; + + const app = createApiServer({ db, games }); + + response = await request(app.callback()) + .patch('/game_instances/1/join') + .send('gameName=foo&playerID=0&playerName=alice'); + }); + + test('is successful', async () => { + expect(response.status).toEqual(200); + }); + + test('returns the player credentials', async () => { + expect(response.body.playerCredentials).toEqual(credentials); + }); + + test('updates the player name', async () => { + expect(setSpy).toHaveBeenCalledWith( + expect.stringMatching(':metadata'), + expect.objectContaining({ + players: expect.objectContaining({ + '0': expect.objectContaining({ + name: 'alice', + }), + }), + }) + ); + }); + }); + }); +}); diff --git a/src/server/index.js b/src/server/index.js index 6a147fba2..10ae7a8d5 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -9,8 +9,11 @@ const Koa = require('koa'); const IO = require('koa-socket'); const Redux = require('redux'); + import { InMemory, Mongo } from './db'; import { createGameReducer } from '../core/reducer'; +import { createApiServer, isActionFromAuthenticPlayer } from './api'; + const PING_TIMEOUT = 20 * 1e3; const PING_INTERVAL = 10 * 1e3; @@ -33,6 +36,8 @@ export function Server({ games, db, _clientInfo, _roomInfo }) { } } + const api = createApiServer({ db, games }); + const clientInfo = _clientInfo || new Map(); const roomInfo = _roomInfo || new Map(); @@ -53,6 +58,16 @@ export function Server({ games, db, _clientInfo, _roomInfo }) { }); const store = Redux.createStore(reducer, state); + const isActionAuthentic = await isActionFromAuthenticPlayer({ + action, + db, + gameID, + playerID, + }); + if (!isActionAuthentic) { + return { error: 'unauthorized action' }; + } + // Check whether the player is allowed to make the move if (!game.flow.canPlayerMakeMove(state.G, state.ctx, playerID)) { return; @@ -129,10 +144,12 @@ export function Server({ games, db, _clientInfo, _roomInfo }) { return { app, + api, db, run: async (port, callback) => { await db.connect(); - app.listen(port, callback); + await api.listen(port + 1); + await app.listen(port, callback); }, }; } diff --git a/src/server/index.test.js b/src/server/index.test.js index 7d49972b6..75782e4d0 100644 --- a/src/server/index.test.js +++ b/src/server/index.test.js @@ -6,13 +6,21 @@ * https://opensource.org/licenses/MIT. */ +jest.mock('./api'); + import { Server } from './index'; import Game from '../core/game'; import * as ActionCreators from '../core/action-creators'; import * as Redux from 'redux'; +import { createApiServer, isActionFromAuthenticPlayer } from './api'; beforeEach(() => { jest.resetModules(); + jest.resetAllMocks(); + createApiServer.mockReturnValue({ + listen() {}, + }); + isActionFromAuthenticPlayer.mockReturnValue(true); }); jest.mock('koa-socket', () => { @@ -328,3 +336,17 @@ test('MONGO_URI', () => { expect(server.db.url).toBe('test'); delete process.env.MONGO_URI; }); + +test('auth failure', async () => { + isActionFromAuthenticPlayer.mockReturnValue(false); + + const server = Server({ games: [game] }); + const io = server.app.context.io; + const action = ActionCreators.gameEvent('endTurn'); + + await io.socket.receive('sync', 'gameID'); + io.socket.emit.mockReset(); + + await io.socket.receive('action', action, 0, 'gameID', '0'); + expect(io.socket.emit).toHaveBeenCalledTimes(0); +});