diff --git a/CHANGELOG.md b/CHANGELOG.md index c5827ecb..506b8412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,8 @@ Version 2.0.0 - Added option to set address to bind HTTP(S) port to. - Added an overview of the installed plugins to the web interface. - Added clusterioctl command to list plugins on the master server. +- Added Player Auth plugin for logging in to the web interface by proving + the ability to log in to a server as a given user. ### Changes diff --git a/README.md b/README.md index 20127ac7..b7b0f481 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ supported and maintained by the Clusterio developers: - [Subspace Storage](https://github.com/clusterio/factorioClusterioMod): Provide shared storage that can transport items between servers via teleport chests. +- [Player Auth](/plugins/player_auth/README.md): Provides authentication + to the cluster via logging into a Factorio server. There's also plugins developed and maintained by the community: diff --git a/package.json b/package.json index fadd435c..091a03b4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "root", "private": true, "scripts": { - "test": "mocha --check-leaks -R spec --recursive test plugins/global_chat/test plugins/research_sync/test plugins/statistics_exporter/test plugins/subspace_storage/test", + "test": "mocha --check-leaks -R spec --recursive test plugins/global_chat/test plugins/research_sync/test plugins/statistics_exporter/test plugins/subspace_storage/test plugins/player_auth/test", "fast-test": "FAST_TEST=y npm test", "cover": "nyc npm test", "fast-cover": "FAST_TEST=y nyc npm test", @@ -25,7 +25,9 @@ "@clusterio/lib": "file:./packages/lib", "@clusterio/master": "file:packages/master", "@clusterio/slave": "file:./packages/slave", + "body-parser": "^1.18.2", "eslint": "^7.2.0", + "express": "^4.16.2", "fs-extra": "^8.1.0", "jsdoc": "^3.6.3", "jsonwebtoken": "^8.2.1", diff --git a/packages/master/master.js b/packages/master/master.js index fa65b2f4..aec2aa05 100755 --- a/packages/master/master.js +++ b/packages/master/master.js @@ -1726,7 +1726,16 @@ async function loadPlugins() { } let masterPlugin = new MasterPluginClass( - pluginInfo, { app, config: masterConfig, db, slaveConnections }, { endpointHitCounter }, logger + pluginInfo, + { + app, + config: masterConfig, + db, + getMasterUrl, + slaveConnections, + }, + { endpointHitCounter }, + logger ); await masterPlugin.init(); masterPlugins.set(pluginInfo.name, masterPlugin); diff --git a/plugins/player_auth/README.md b/plugins/player_auth/README.md new file mode 100644 index 00000000..dde707dc --- /dev/null +++ b/plugins/player_auth/README.md @@ -0,0 +1,42 @@ +Clusterio Player Auth Plugin +============================ + +Plugin authenticating logged in players to the web interface. +Authentication is achieved by a simple challenge response mechanism +where players have to log into one of the Factorio server in the cluster +and open a dialog that presents a code, this code is input into the +login form on the web interface which gives back a second code. Once +the player enters the second code into the dialog in-game the web +interface is authenticated. + + +Installation +------------ + +Run the following commands in the folder Clusterio is installed to: + + npm install @clusterio/plugin-player_auth + npx clusteriomaster plugin add @clusterio/plugin-player_auth + +Substitute clusteriomaster with clusterioslave or clusterioctl if this a +dedicate slave or ctl installation respectively. + + +Master Configuration +-------------------- + +#### player_auth.code_length + +Length in character of the generated challenge codes that need to be +input between the web interface login form and the in-game Factorio +login dialog. + +Defaults to `6`. + +#### player_auth.code_timeout + +Time in seconds the first code generated stays valid. The login must be +completed in less than this time starting from when the login dialog is +opened in-game. + +Defaults to `120`. diff --git a/plugins/player_auth/info.js b/plugins/player_auth/info.js new file mode 100644 index 00000000..0991b0d5 --- /dev/null +++ b/plugins/player_auth/info.js @@ -0,0 +1,57 @@ +"use strict"; +let libLink = require("@clusterio/lib/link"); +let libConfig = require("@clusterio/lib/config"); + + +class MasterConfigGroup extends libConfig.PluginConfigGroup {} +MasterConfigGroup.defaultAccess = ["master", "slave", "control"]; +MasterConfigGroup.groupName = "player_auth"; +MasterConfigGroup.define({ + name: "code_length", + title: "Code Length", + description: "Length in characters of the generated codes.", + type: "number", + initial_value: 6, +}); +MasterConfigGroup.define({ + name: "code_timeout", + title: "Code Timeout", + description: "Time in seconds for the generated codes to stay valid.", + type: "number", + initial_value: 120, +}); +MasterConfigGroup.finalize(); + +module.exports = { + name: "player_auth", + title: "Player Auth", + description: "Provides authentication to the cluster via logging into a Factorio server.", + masterEntrypoint: "master", + instanceEntrypoint: "instance", + webEntrypoint: "./web", + MasterConfigGroup, + + messages: { + fetchPlayerCode: new libLink.Request({ + type: "player_auth:fetch_player_code", + links: ["instance-slave", "slave-master"], + forwardTo: "master", + requestProperties: { + "player": { type: "string" }, + }, + responseProperties: { + "player_code": { type: "string" }, + "master_url": { type: "string" }, + }, + }), + setVerifyCode: new libLink.Request({ + type: "player_auth:set_verify_code", + links: ["instance-slave", "slave-master"], + forwardTo: "master", + requestProperties: { + "player": { type: "string" }, + "verify_code": { type: "string" }, + }, + }), + }, +}; diff --git a/plugins/player_auth/instance.js b/plugins/player_auth/instance.js new file mode 100644 index 00000000..931f722a --- /dev/null +++ b/plugins/player_auth/instance.js @@ -0,0 +1,55 @@ +/** + * @module + */ +"use strict"; +const libPlugin = require("@clusterio/lib/plugin"); +const libLuaTools = require("@clusterio/lib/lua_tools"); + + +class InstancePlugin extends libPlugin.BaseInstancePlugin { + async init() { + if (!this.instance.config.get("factorio.enable_save_patching")) { + throw new Error("player_auth plugin requires save patching."); + } + + this.instance.server.on("ipc-player_auth", event => this.handleEvent(event).catch( + err => this.logger.error(`Error handling event:\n${err.stack}`) + )); + } + + async handleEvent(event) { + if (event.type === "open_dialog") { + if (!this.slave.connector.connected) { + await this.sendRcon(`/web-login error ${event.player} login is temporarily unavailable`); + return; + } + + let response; + try { + response = await this.info.messages.fetchPlayerCode.send(this.instance, { player: event.player }); + } catch (err) { + await this.sendRcon(`/web-login error ${event.player} ${err.message}`); + return; + } + await this.sendRcon(`/web-login open ${event.player} ${response.master_url} ${response.player_code}`); + + } else if (event.type === "set_verify_code") { + try { + await this.info.messages.setVerifyCode.send(this.instance, { + player: event.player, + verify_code: event.verify_code, + }); + + } catch (err) { + await this.sendRcon(`/web-login error ${event.player} ${err.message}`); + return; + } + + await this.sendRcon(`/web-login code_set ${event.player}`); + } + } +} + +module.exports = { + InstancePlugin, +}; diff --git a/plugins/player_auth/master.js b/plugins/player_auth/master.js new file mode 100644 index 00000000..5c491014 --- /dev/null +++ b/plugins/player_auth/master.js @@ -0,0 +1,192 @@ +/** + * @module + */ +"use strict"; +const crypto = require("crypto"); +const util = require("util"); +const jwt = require("jsonwebtoken"); + +const libPlugin = require("@clusterio/lib/plugin"); +const libErrors = require("@clusterio/lib/errors"); +const { basicType } = require("@clusterio/lib/helpers"); + + +async function generateCode(length) { + // ji1lI, 0oOQ, and 2Z are not present to ease reading. + let letters = "abcdefghkmnpqrstuvwxyzABCDEFGHJKLMNPRSTUVWXY3456789"; + let asyncRandomBytes = util.promisify(crypto.randomBytes); + + let code = []; + for (let byte of await asyncRandomBytes(length)) { + // Due to the 51 characters in the letters not fitting perfectly in + // 256 there's an ever so slight bias towards a with this algorithm. + code.push(letters[byte % letters.length]); + } + + return code.join(""); +} + + +class MasterPlugin extends libPlugin.BaseMasterPlugin { + async init() { + // Store of validation attempts by players + this.players = new Map(); + + // Periodically remove expired entries + setInterval(() => { + let now = Date.now(); + for (let [player, entry] of this.players) { + if (entry.expires < now) { + this.players.delete(player); + } + } + }, 60e3).unref(); + + this.master.app.get("/api/player_auth/servers", (req, res) => { + this.metrics.endpointHitCounter.labels(req.route.path).inc(); + + let servers = []; + for (let instance of this.master.db.instances.values()) { + if (instance.status === "running" && instance.config.get("player_auth.load_plugin")) { + servers.push(instance.config.get("factorio.settings")["name"] || "unnamed server"); + } + } + res.send(servers); + }); + + this.master.app.post("/api/player_auth/player_code", (req, res, next) => { + this.metrics.endpointHitCounter.labels(req.route.path).inc(); + this.handlePlayerCode(req, res).catch(next); + }); + + this.master.app.post("/api/player_auth/verify", (req, res, next) => { + this.metrics.endpointHitCounter.labels(req.route.path).inc(); + this.handleVerify(req, res).catch(next); + }); + } + + async handlePlayerCode(req, res) { + if (basicType(req.body) !== "object") { + res.sendStatus(400); + return; + } + + let playerCode = req.body.player_code; + if (typeof playerCode !== "string") { + res.sendStatus(400); + return; + } + + for (let [player, entry] of this.players) { + if (entry.playerCode === playerCode && entry.expires > Date.now()) { + let verifyCode = await generateCode(this.master.config.get("player_auth.code_length")); + let secret = this.master.config.get("master.auth_secret"); + let verifyToken = jwt.sign( + { + aud: "player_auth.verify_code", + exp: Math.floor(entry.expires / 1000), + verify_code: verifyCode, + player_code: playerCode, + }, + secret + ); + + res.send({ verify_code: verifyCode, verify_token: verifyToken }); + return; + } + } + + res.send({ error: true, message: "invalid player_code" }); + } + + async handleVerify(req, res) { + if (basicType(req.body) !== "object") { + res.sendStatus(400); + return; + } + + let playerCode = req.body.player_code; + if (typeof playerCode !== "string") { + res.sendStatus(400); + return; + } + + let verifyCode = req.body.verify_code; + if (typeof verifyCode !== "string") { + res.sendStatus(400); + return; + } + + let verifyToken = req.body.verify_token; + if (typeof verifyToken !== "string") { + res.sendStatus(400); + return; + } + + let secret = this.master.config.get("master.auth_secret"); + try { + let payload = jwt.verify(verifyToken, secret, { audience: "player_auth.verify_code" }); + if (payload.verify_code !== verifyCode) { + throw new Error("invalid verify_code"); + } + + if (payload.player_code !== playerCode) { + throw new Error("invalid player_code"); + } + + } catch (err) { + res.send({ error: true, message: err.message }); + return; + } + + for (let [player, entry] of this.players) { + if (entry.playerCode === playerCode && entry.expires > Date.now()) { + if (entry.verifyCode === verifyCode) { + let user = this.master.db.users.get(player); + if (!user) { + res.send({ error: true, message: "invalid user" }); + return; + } + + let token = user.createToken(secret); + res.send({ verified: true, token }); + return; + + } + res.send({ verified: false }); + return; + + } + } + + res.send({ error: true, message: "invalid player_code" }); + } + + async fetchPlayerCodeRequestHandler(message) { + let playerCode = await generateCode(this.master.config.get("player_auth.code_length")); + let expires = Date.now() + this.master.config.get("player_auth.code_timeout") * 1000; + this.players.set(message.data.player, { playerCode, verifyCode: null, expires }); + return { player_code: playerCode, master_url: this.master.getMasterUrl() }; + } + + async setVerifyCodeRequestHandler(message) { + let { player, verify_code } = message.data; + if (!this.players.has(player)) { + throw new libErrors.RequestError("invalid player"); + } + + let entry = this.players.get(player); + if (entry.expires < Date.now()) { + throw new libErrors.RequestError("invalid player"); + } + + entry.verifyCode = verify_code; + } +} + +module.exports = { + MasterPlugin, + + // For testing only + _generateCode: generateCode, +}; diff --git a/plugins/player_auth/module/auth.lua b/plugins/player_auth/module/auth.lua new file mode 100644 index 00000000..d6fbe249 --- /dev/null +++ b/plugins/player_auth/module/auth.lua @@ -0,0 +1,210 @@ +local clusterio_api = require("modules/clusterio/api") +local auth = {} + +function open_dialog(player, url, code) + if player.gui.screen.player_auth_dialog then + player.gui.screen.player_auth_dialog.destroy() + end + + local frame = player.gui.screen.add { + name = "player_auth_dialog", + type = "frame", + direction = "vertical", + } + + local titlebar = frame.add { + type = "flow", + } + titlebar.drag_target = frame + + local title = titlebar.add { + type = "label", + style = "frame_title", + caption = "Web Interface Login", + } + title.drag_target = frame + + local filler = titlebar.add { + type = "empty-widget", + style = "draggable_space_header", + } + filler.style.horizontally_stretchable = "on" + filler.style.right_margin = 4 + filler.style.height = 24 + filler.drag_target = frame + + titlebar.add { + name = "player_auth_dialog_close_button", + type = "sprite-button", + sprite = "utility/close_white", + hovered_sprite = "utility/close_black", + clicked_sprite = "utility/close_black", + style = "frame_action_button", + } + + local content = frame.add { + name = "player_auth_dialog_content", + type = "frame", + direction = "vertical", + style = "inside_shallow_frame_with_padding", + } + content.style.width = 300 + + local p1 = content.add { + type = "label", + caption = "Login to the web interface is a 3 step process:", + } + p1.style.single_line = false + p1.style.bottom_margin = 8 + + local p2 = content.add { + type = "label", + caption = "1. Open the web interface in a browser, the url to it is shown below:", + } + p2.style.single_line = false + p2.style.bottom_margin = 8 + + local url_textfield = content.add { + type = "textfield", + text = url, + } + url_textfield.style.bottom_margin = 8 + url_textfield.style.width = 300 - 24 + + local p3 = content.add { + type = "label", + caption = "2. Enter the following code into step 2 of the Factorio login:", + } + p3.style.single_line = false + p3.style.bottom_margin = 8 + + local player_code_textfield = content.add { + type = "textfield", + text = code, + } + player_code_textfield.style.bottom_margin = 8 + player_code_textfield.style.width = 180 + + local p4 = content.add { + type = "label", + caption = "3. Enter the code in step 3 of the Factorio login here:" + } + p4.style.single_line = false + p4.style.bottom_margin = 8 + + local verify_code_row = content.add { + type = "flow", + } + local verify_code_input = verify_code_row.add { + name = "player_auth_verify_code_input", + type = "textfield", + } + verify_code_input.style.bottom_margin = 8 + verify_code_input.style.width = 180 + local verify_code_button = verify_code_row.add { + name = "player_auth_verify_code_button", + type = "button", + style = "green_button", + sprite = "utility/check_mark", + caption = "Log in", + } + verify_code_button.style.minimal_width = 80 + + frame.force_auto_center() + player.opened = frame +end + +function auth.add_commands() + commands.add_command("web-login", "Login to the web interface", function(event) + if event.player_index then + local player = game.players[event.player_index] + clusterio_api.send_json("player_auth", { + type = "open_dialog", + player = player.name, + }) + return + end + + if event.parameter then + local args = {} + for w in string.gmatch(event.parameter, "[^ ]+") do + args[#args + 1] = w + end + if #args < 1 then + rcon.print("Incorrect number of parameters") + return + end + + local command = table.remove(args, 1) + if command == "open" then + if #args ~= 3 then + rcon.print("Incorrect number of parameters") + return + end + + local player_name, url, code = table.unpack(args) + local player = game.players[player_name] + if not player then + rcon.print("Player " .. player_name .. " does not exist") + return + end + + open_dialog(player, url, code) + + elseif command == "code_set" then + local player_name = args[1] + local player = game.players[player_name] + if not player then + rcon.print("Player " .. player_name .. " does not exist") + return + end + + if player.gui.screen.player_auth_dialog then + player.gui.screen.player_auth_dialog.destroy() + end + + elseif command == "error" then + local player_name = table.remove(args, 1) + local player = game.players[player_name] + if not player then + rcon.print("Player " .. player_name .. " does not exist") + return + end + + player.print("Error: " .. table.concat(args, " ")) + end + end + end) +end + +auth.events = {} +auth.events[defines.events.on_gui_click] = function(event) + if not event.element or not event.element.valid then + return + end + + if event.element.name == "player_auth_dialog_close_button" then + event.element.parent.parent.destroy() + + elseif event.element.name == "player_auth_verify_code_button" then + local verify_code = event.element.parent.player_auth_verify_code_input.text + local player_name = game.players[event.player_index].name + clusterio_api.send_json("player_auth", { + type = "set_verify_code", + player = player_name, + verify_code = verify_code, + }) + end +end + +auth.events[defines.events.on_gui_closed] = function(event) + if + event.element + and event.element.valid + and event.element.name == "player_auth_dialog" + then + event.element.destroy() + end +end + +return auth diff --git a/plugins/player_auth/module/module.json b/plugins/player_auth/module/module.json new file mode 100644 index 00000000..782aad51 --- /dev/null +++ b/plugins/player_auth/module/module.json @@ -0,0 +1,4 @@ +{ + "name": "player_auth", + "load": ["auth.lua"] +} diff --git a/plugins/player_auth/package.json b/plugins/player_auth/package.json new file mode 100644 index 00000000..0c5534a9 --- /dev/null +++ b/plugins/player_auth/package.json @@ -0,0 +1,34 @@ +{ + "name": "@clusterio/plugin-player_auth", + "version": "2.0.0-alpha.5", + "description": "Clusterio plugin authenticating logged in players to the web interface", + "author": "Hornwitser ", + "homepage": "https://github.com/clusterio/factorioClusterio#readme", + "license": "MIT", + "repository": "clusterio/factorioClusterio", + "scripts": { + "build": "webpack-cli --env.production", + "test": "echo \"Error: run tests from root\" && exit 1" + }, + "bugs": { + "url": "https://github.com/clusterio/factorioClusterio/issues" + }, + "peerDependencies": { + "@clusterio/lib": "^2.0.0-alpha.0" + }, + "dependencies": { + "jsonwebtoken": "^8.2.1" + }, + "devDependencies": { + "@clusterio/web_ui": "^2.0.0-alpha.4", + "antd": "^4.6.5", + "phin": "^3.4.1", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "webpack-cli": "^3.3.12", + "webpack-merge": "^5.2.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/plugins/player_auth/test/plugin.js b/plugins/player_auth/test/plugin.js new file mode 100644 index 00000000..9fd42a73 --- /dev/null +++ b/plugins/player_auth/test/plugin.js @@ -0,0 +1,439 @@ +"use strict"; +const assert = require("assert").strict; +const phin = require("phin"); +const jwt = require("jsonwebtoken"); + +const mock = require("../../../test/mock"); + +const master = require("../master"); +const instance = require("../instance"); +const info = require("../info"); +const libErrors = require("@clusterio/lib/errors"); + + +describe("player_auth", function() { + describe("master.js", function() { + describe("generateCode()", function() { + it("should generate a code of the given length", async function() { + let code = await master._generateCode(20); + assert.equal(typeof code, "string"); + assert.equal(code.length, 20, "incorrect length"); + }); + it("should only use the easy to recognize letters in the alphabet", async function() { + let chars = new Set(); + for (let i = 0; i < 20; i++) { + let code = await master._generateCode(15); + for (let char of code) { + chars.add(char); + } + } + + for (let char of chars) { + assert(/[A-Za-z0-9]/.test(char), `Contained non-alphanumeric letter ${char}`); + assert(/[^ji1lI0oOQ2Z]/.test(char), `Contained banned letter ${char}`); + } + }); + }); + + describe("MasterPlugin", function() { + let masterPlugin; + let masterUrl; + before(async function() { + masterPlugin = await mock.createMasterPlugin(master.MasterPlugin, info); + masterPlugin.master.mockConfigEntries.set("player_auth.code_length", 10); + masterPlugin.master.mockConfigEntries.set("player_auth.code_timeout", 1); + masterUrl = await masterPlugin.master.startServer(); + }); + after(async function() { + if (masterPlugin) { + await masterPlugin.master.stopServer(); + } + }); + + describe("/api/player_auth/servers", function() { + it("should return a list of running servers with player_auth loaded", async function() { + function addInstance(id, status, load, name) { + masterPlugin.master.db.instances.set(id, { + config: { + get(field) { + if (field === "player_auth.load_plugin") { + return load; + } else if (field === "factorio.settings") { + return { name }; + } + throw new Error(`field ${field} not implemented`); + }, + }, + status, + }); + } + addInstance(1, "running", true, "running loaded"); + addInstance(2, "stopped", true, "stopped loaded"); + addInstance(3, "running", false, "running unloaded"); + addInstance(4, "stopped", false, "stopped unloaded"); + addInstance(5, "running", true, undefined); + let result = await phin({ + url: `${masterUrl}/api/player_auth/servers`, + parse: "json", + }); + assert.deepEqual(result.body, ["running loaded", "unnamed server"]); + for (let id of [1, 2, 3, 4, 5]) { + masterPlugin.master.db.instances.delete(id); + } + }); + }); + + describe("/api/player_auth/player_code", function() { + it("should return 400 on invalid json", async function() { + let result = await phin({ + url: `${masterUrl}/api/player_auth/player_code`, + method: "POST", + headers: { + "Content-Type": "application/json", + }, + data: "invalid", + }); + assert.equal(result.statusCode, 400); + }); + it("should return 400 on valid json which is not an object", async function() { + for (let obj of [true, false, null, "string", 123, ["an", "array"]]) { + let result = await phin({ + url: `${masterUrl}/api/player_auth/player_code`, + method: "POST", + headers: { + "Content-Type": "application/json", + }, + data: JSON.stringify(obj), + }); + assert.equal(result.statusCode, 400); + } + }); + it("should return 400 if player_code is not a string", async function() { + for (let obj of [true, false, null, 123, [], {}]) { + let result = await phin({ + url: `${masterUrl}/api/player_auth/player_code`, + method: "POST", + headers: { + "Content-Type": "application/json", + }, + data: JSON.stringify({ player_code: obj }), + }); + assert.equal(result.statusCode, 400); + } + }); + it("should return invalid player_code if the code is expired", async function() { + let expires = Date.now() - 1000; + masterPlugin.players.set("expired", { playerCode: "expried", verifyCode: null, expires }); + let result = await phin({ + url: `${masterUrl}/api/player_auth/player_code`, + method: "POST", + data: { player_code: "expired" }, + parse: "json", + }); + assert.deepEqual(result.body, { error: true, message: "invalid player_code" }); + }); + it("should return a verify code and token if code is valid", async function() { + let expires = Date.now() + 1000; + masterPlugin.players.set("valid", { playerCode: "valid", verifyCode: null, expires }); + let result = await phin({ + url: `${masterUrl}/api/player_auth/player_code`, + method: "POST", + data: { player_code: "valid" }, + parse: "json", + }); + assert.equal(typeof result.body.verify_code, "string"); + assert.equal(typeof result.body.verify_token, "string"); + }); + }); + + describe("/api/player_auth/verify", function() { + it("should return 400 on invalid json", async function() { + let result = await phin({ + url: `${masterUrl}/api/player_auth/verify`, + method: "POST", + headers: { + "Content-Type": "application/json", + }, + data: "invalid", + }); + assert.equal(result.statusCode, 400); + }); + it("should return 400 on valid json which is not an object", async function() { + for (let obj of [true, false, null, "string", 123, ["an", "array"]]) { + let result = await phin({ + url: `${masterUrl}/api/player_auth/verify`, + method: "POST", + headers: { + "Content-Type": "application/json", + }, + data: JSON.stringify(obj), + }); + assert.equal(result.statusCode, 400); + } + }); + it("should return 400 if player_code, verify_code or verify_token are not strings", async function() { + for (let obj of [true, false, null, 123, [], {}]) { + for (let field of ["player_code", "verify_code", "verify_token"]) { + let result = await phin({ + url: `${masterUrl}/api/player_auth/verify`, + method: "POST", + data: { + player_code: "str", + verify_code: "str", + verify_token: "str", + [field]: obj, + }, + }); + assert.equal(result.statusCode, 400); + } + } + }); + it("should return error if verify_token is not valid", async function() { + async function verify(tokenParams, error) { + let result = await phin({ + url: `${masterUrl}/api/player_auth/verify`, + method: "POST", + data: { + player_code: "player", + verify_code: "verify", + verify_token: jwt.sign({ + aud: "player_auth.verify_code", + player_code: "player", + verify_code: "verify", + ...tokenParams, + }, tokenParams.secret || "TestSecretDoNotUse"), + }, + parse: "json", + }); + assert.deepEqual(result.body.error, true); + } + await verify({ secret: "InvalidSecret" }); + await verify({ verify_code: "bad" }); + await verify({ player_code: "bad" }); + await verify({ aud: "not_player_auth" }); + }); + it("should return invalid player_code if the code is expired", async function() { + let expires = Date.now() - 1000; + masterPlugin.players.set("expired", { playerCode: "expried", verifyCode: "verify", expires }); + let result = await phin({ + url: `${masterUrl}/api/player_auth/verify`, + method: "POST", + data: { + player_code: "expired", + verify_code: "verify", + verify_token: jwt.sign({ + aud: "player_auth.verify_code", + player_code: "expired", + verify_code: "verify", + }, "TestSecretDoNotUse"), + }, + parse: "json", + }); + assert.deepEqual(result.body, { error: true, message: "invalid player_code" }); + }); + it("should return verified false if verify code has not yet been set", async function() { + let expires = Date.now() + 1000; + masterPlugin.players.set("unverified", { playerCode: "unverified", verifyCode: null, expires }); + let result = await phin({ + url: `${masterUrl}/api/player_auth/verify`, + method: "POST", + data: { + player_code: "unverified", + verify_code: "verify", + verify_token: jwt.sign({ + aud: "player_auth.verify_code", + player_code: "unverified", + verify_code: "verify", + }, "TestSecretDoNotUse"), + }, + parse: "json", + }); + assert.deepEqual(result.body, { verified: false }); + }); + it("should return error if user is missing", async function() { + let expires = Date.now() + 1000; + masterPlugin.players.set("missing", { playerCode: "missing", verifyCode: "verify", expires }); + let result = await phin({ + url: `${masterUrl}/api/player_auth/verify`, + method: "POST", + data: { + player_code: "missing", + verify_code: "verify", + verify_token: jwt.sign({ + aud: "player_auth.verify_code", + player_code: "missing", + verify_code: "verify", + }, "TestSecretDoNotUse"), + }, + parse: "json", + }); + assert.deepEqual(result.body, { error: true, message: "invalid user" }); + }); + it("should return verified true with token if valid verification", async function() { + let expires = Date.now() + 1000; + masterPlugin.players.set("player", { playerCode: "player", verifyCode: "verify", expires }); + let result = await phin({ + url: `${masterUrl}/api/player_auth/verify`, + method: "POST", + data: { + player_code: "player", + verify_code: "verify", + verify_token: jwt.sign({ + aud: "player_auth.verify_code", + player_code: "player", + verify_code: "verify", + }, "TestSecretDoNotUse"), + }, + parse: "json", + }); + assert.equal(result.body.verified, true); + assert.deepEqual(typeof result.body.token, "string", "missing token"); + }); + }); + + describe(".fetchPlayerCodeRequestHandler()", function() { + it("should return a code", async function() { + let result = await masterPlugin.fetchPlayerCodeRequestHandler({ data: { player: "test" }}); + assert(typeof result.player_code === "string", "no code returned"); + assert(result.player_code.length === 10, "incorrect code length returned"); + let expires = masterPlugin.players.get("test").expires; + let msFromExpected = Math.abs(expires - Date.now() - 1000); + assert(msFromExpected < 100, `expiry time expected outside window (${msFromExpected}ms)`); + }); + }); + + describe(".setVerifyCodeRequestHandler()", function() { + it("should throw if player does not exist", async function() { + await assert.rejects( + masterPlugin.setVerifyCodeRequestHandler( + { data: { player: "invalid", verify_code: "invalid" }} + ), + new libErrors.RequestError("invalid player") + ); + }); + it("should throw if player code has expired", async function() { + let expires = Date.now() - 1000; + masterPlugin.players.set("expired", { playerCode: "expried", verifyCode: null, expires }); + await assert.rejects( + masterPlugin.setVerifyCodeRequestHandler( + { data: { player: "expired", verify_code: "expired" }} + ), + new libErrors.RequestError("invalid player") + ); + }); + }); + + describe("integration", function() { + it("should verify a full login flow", async function() { + let app = masterPlugin.master.app; + let { player_code } = await masterPlugin.fetchPlayerCodeRequestHandler({ data: { player: "test" }}); + + let playerCodeResult = await phin({ + url: `${masterUrl}/api/player_auth/player_code`, + method: "POST", + data: { player_code }, + parse: "json", + }); + + let { verify_code, verify_token } = playerCodeResult.body; + await masterPlugin.setVerifyCodeRequestHandler({ data: { player: "test", verify_code }}); + + let verifyResult = await phin({ + url: `${masterUrl}/api/player_auth/verify`, + method: "POST", + data: { player_code, verify_code, verify_token }, + parse: "json", + }); + assert.equal(verifyResult.body.verified, true); + }); + }); + }); + }); + + describe("instance.js", function() { + describe("class InstancePlugin", function() { + let instancePlugin; + before(async function() { + instancePlugin = await mock.createInstancePlugin(instance.InstancePlugin, info); + }); + + describe(".handleEvent()", async function() { + describe("open_dialog", async function() { + it("should call /web-login error if not connected to master", async function() { + instancePlugin.instance.server.reset(); + instancePlugin.slave.connector.connected = false; + await instancePlugin.handleEvent({ type: "open_dialog", player: "test" }); + let command = instancePlugin.instance.server.rconCommands[0]; + instancePlugin.slave.connector.connected = true; + assert.equal(command, "/web-login error test login is temporarily unavailable"); + }); + it("should call /web-login error after error from the master", async function() { + instancePlugin.instance.server.reset(); + instancePlugin.instance.connector.once("send", message => { + instancePlugin.instance.connector.emit("message", { + seq: 1, + type: "player_auth:fetch_player_code_response", + data: { + seq: message.seq, + error: "master error", + }, + }); + }); + await instancePlugin.handleEvent({ type: "open_dialog", player: "test" }); + let command = instancePlugin.instance.server.rconCommands[0]; + assert.equal(command, "/web-login error test master error"); + }); + it("should call /web-login open after a valid response from the master", async function() { + instancePlugin.instance.server.reset(); + instancePlugin.instance.connector.once("send", message => { + instancePlugin.instance.connector.emit("message", { + seq: 1, + type: "player_auth:fetch_player_code_response", + data: { + seq: message.seq, + player_code: "code", + master_url: "master-url", + }, + }); + }); + await instancePlugin.handleEvent({ type: "open_dialog", player: "test" }); + let command = instancePlugin.instance.server.rconCommands[0]; + assert.equal(command, "/web-login open test master-url code"); + }); + }); + describe("open_dialog", async function() { + it("should call /web-login code_set after a valid response from the master", async function() { + instancePlugin.instance.server.reset(); + instancePlugin.instance.connector.once("send", message => { + instancePlugin.instance.connector.emit("message", { + seq: 1, + type: "player_auth:set_verify_code_response", + data: { seq: message.seq }, + }); + }); + await instancePlugin.handleEvent( + { type: "set_verify_code", player: "test", verify_code: "verify" } + ); + let command = instancePlugin.instance.server.rconCommands[0]; + assert.equal(command, "/web-login code_set test"); + }); + it("should call /web-login error after error from the master", async function() { + instancePlugin.instance.server.reset(); + instancePlugin.instance.connector.once("send", message => { + instancePlugin.instance.connector.emit("message", { + seq: 1, + type: "player_auth:set_verify_code_response", + data: { seq: message.seq, error: "master error" }, + }); + }); + await instancePlugin.handleEvent( + { type: "set_verify_code", player: "test", verify_code: "verify" } + ); + let command = instancePlugin.instance.server.rconCommands[0]; + assert.equal(command, "/web-login error test master error"); + }); + }); + }); + }); + }); +}); diff --git a/plugins/player_auth/web/index.css b/plugins/player_auth/web/index.css new file mode 100644 index 00000000..eb29b47a --- /dev/null +++ b/plugins/player_auth/web/index.css @@ -0,0 +1,9 @@ +.factorio-login-steps { + padding-left: 1em; +} + +.factorio-login-steps ul { + padding-left: 1em; + margin-bottom: 1em; + list-style-type: disc; +} diff --git a/plugins/player_auth/web/index.jsx b/plugins/player_auth/web/index.jsx new file mode 100644 index 00000000..bb91a5ad --- /dev/null +++ b/plugins/player_auth/web/index.jsx @@ -0,0 +1,158 @@ +import React, { useEffect, useState } from "react"; + +import libPlugin from "@clusterio/lib/plugin"; +import { notify, notifyErrorHandler } from "@clusterio/web_ui"; +import { Button, Form, Input, Spin, Typography } from "antd"; + +import "./index.css"; + +const { Paragraph, Text } = Typography; + + +function LoginForm(props) { + let [servers, setServers] = useState(null); + let [playerCode, setPlayerCode] = useState(null); + let [playerCodeError, setPlayerCodeError] = useState(null); + let [verifyCode, setVerifyCode] = useState(null); + let [verifyToken, setVerifyToken] = useState(null); + + useEffect(() => { + (async () => { + let response = await fetch(`${webRoot}api/player_auth/servers`); + if (response.ok) { + setServers(await response.json()); + } else { + setServers([]); + } + })(); + }, []); + + useEffect(() => { + if (verifyToken) { + let checkLoop = setInterval(() => { + (async () => { + let response = await fetch(`${webRoot}api/player_auth/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + verify_token: verifyToken, + verify_code: verifyCode, + player_code: playerCode, + }), + }); + if (!response.ok) { + throw new Error(`Bad server response: ${response.status}`); + } + let json = await response.json(); + if (json.error) { + // While there are other possibilites the most likely + // cause of an error response here is that the code expired. + setPlayerCodeError("Code expired"); + setVerifyToken(null); + clearInterval(checkLoop); + return; + } + if (json.verified) { + props.setToken(json.token); + } + })().catch(err => { + clearInterval(checkLoop); + notifyErrorHandler("Error verifying code")(err); + }); + }, 2000); + return () => { clearInterval(checkLoop); }; + } + + return undefined; + }, [verifyToken]); + + if (!servers) { + return ; + } + + if (servers.length === 0) { + return + There are no servers in the cluster currently running that can complete + the login via Factorio. Please try again later. + ; + } + + return <> + Login using your Factorio account is a 3 step process: + +
    +
  1. + + Start Factorio, join one of the following multiplayer servers + and type /web-login into the chat: + +
      + {servers.map(text =>
    • {text}
    • )} +
    +
  2. +
  3. + Enter the code displayed in the in-game dialog here: + +
    { + (async () => { + let response = await fetch(`${webRoot}api/player_auth/player_code`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + player_code: values.player_code, + }), + }); + if (!response.ok) { + throw new Error(`Bad server response: ${response.status}`); + } + + let json = await response.json(); + if (json.error) { + setPlayerCodeError("Invalid Code"); + setVerifyToken(null); + return; + } + + setPlayerCodeError(null); + setPlayerCode(values.player_code); + setVerifyCode(json.verify_code); + setVerifyToken(json.verify_token); + })().catch(notifyErrorHandler("Error verifying code")); + }} + > + + + + +
    +
    +
  4. +
  5. + Enter this code into the in-game dialog: + + + +
  6. +
+ ; +} + + +export class WebPlugin extends libPlugin.BaseWebPlugin { + async init() { + this.logger.info("Player Auth init"); + this.loginForms = [{ + name: "player_auth.factorio", + title: "Factorio", + Component: LoginForm, + }]; + } +} diff --git a/plugins/player_auth/webpack.config.js b/plugins/player_auth/webpack.config.js new file mode 100644 index 00000000..fc82c3f4 --- /dev/null +++ b/plugins/player_auth/webpack.config.js @@ -0,0 +1,37 @@ +"use strict"; +const path = require("path"); +const webpack = require("webpack"); +const { merge } = require("webpack-merge"); + +const common = require("@clusterio/web_ui/webpack.common"); + +module.exports = (env = {}) => merge(common(env), { + context: __dirname, + entry: "./web/index.jsx", + output: { + publicPath: "auto", + filename: "bundle.js", + path: path.resolve(__dirname, "dist", "web"), + }, + plugins: [ + new webpack.container.ModuleFederationPlugin({ + name: "player_auth", + library: { type: "var", name: "plugin_player_auth" }, + filename: "remoteEntry.js", + exposes: { + "./info": "./info.js", + "./package.json": "./package.json", + "./web": "./web/index.jsx", + }, + shared: { + "@clusterio/lib/config": { import: false }, + "@clusterio/lib/link": { import: false }, + "@clusterio/lib/plugin": { import: false }, + "@clusterio/web_ui": { import: false }, + "antd": { import: false }, + "react": { import: false }, + "react-dom": { import: false }, + }, + }), + ], +}); diff --git a/test/integration/index.js b/test/integration/index.js index f3ab1e76..be6eb761 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -143,6 +143,7 @@ before(async function() { await exec("node ../../packages/ctl plugin add ../../plugins/research_sync"); await exec("node ../../packages/ctl plugin add ../../plugins/statistics_exporter"); await exec("node ../../packages/ctl plugin add ../../plugins/subspace_storage"); + await exec("node ../../packages/ctl plugin add ../../plugins/player_auth"); await exec("node ../../packages/master bootstrap create-admin test"); await exec("node ../../packages/master bootstrap create-ctl-config test"); diff --git a/test/mock.js b/test/mock.js index 6ca83e96..7536771c 100644 --- a/test/mock.js +++ b/test/mock.js @@ -1,7 +1,13 @@ "use strict"; const events = require("events"); +const http = require("http"); +const express = require("express"); +const bodyParser = require("body-parser"); const libLink = require("@clusterio/lib/link"); +const libPlugin = require("@clusterio/lib/plugin"); +const libUsers = require("@clusterio/lib/users"); +const libPrometheus = require("@clusterio/lib/prometheus"); class MockLogger { @@ -85,8 +91,13 @@ class MockConnector extends events.EventEmitter { } } -class MockServer { +class MockServer extends events.EventEmitter { constructor() { + super(); + this.reset(); + } + + reset() { this.rconCommands = []; this.rconCommandResults = new Map(); } @@ -135,6 +146,82 @@ class MockControl extends libLink.Link { } } +class MockMaster { + constructor() { + this.app = express(); + this.app.use(bodyParser.json({ limit: "10mb" })); + this.mockConfigEntries = new Map([ + ["master.external_address", "test"], + ["master.auth_secret", "TestSecretDoNotUse"], + ]); + this.config = { + get: (name) => { + if (this.mockConfigEntries.has(name)) { + return this.mockConfigEntries.get(name); + } + throw Error(`mock for field ${name} is not implemented`); + }, + }; + + let roles = new Map([ + [0, new libUsers.Role({ id: 0, name: "Admin", description: "admin", permissions: ["core.admin"] })], + [1, new libUsers.Role({ id: 1, name: "Player", description: "player", permissions: [] })], + ]); + roles.get(1).grantDefaultPermissions(); + + let users = new Map([ + ["test", new libUsers.User({ name: "test", roles: [0, 1] }, roles)], + ["player", new libUsers.User({ name: "player", roles: [1] }, roles)], + ]); + this.db = { + instances: new Map(), + slaves: new Map(), + roles, + users, + }; + } + + getMasterUrl() { + return "http://master.example/"; + } + + async startServer() { + this.server = http.createServer(this.app); + await new Promise(resolve => { + this.server.listen(0, "127.0.0.1", resolve); + }); + let address = this.server.address(); + return `http://127.0.0.1:${address.port}`; + } + + async stopServer() { + if (this.server) { + this.server.close(); + this.server.unref(); + } + } +} + +async function createMasterPlugin(MasterPluginClass, info) { + let master = new MockMaster(); + let metrics = { + endpointHitCounter: new libPrometheus.Counter("hit_counter", "Hit Counter", { labels: ["route"] }), + }; + let logger = new MockLogger(); + let plugin = new MasterPluginClass(info, master, metrics, logger); + await plugin.init(); + return plugin; +} + +async function createInstancePlugin(InstancePluginClass, info) { + let instance = new MockInstance(); + let slave = new MockSlave(); + let plugin = new InstancePluginClass(info, instance, slave); + libPlugin.attachPluginMessages(instance, plugin); + await plugin.init(); + return plugin; +} + module.exports = { MockLogger, @@ -143,4 +230,7 @@ module.exports = { MockInstance, MockSlave, MockControl, + + createMasterPlugin, + createInstancePlugin, };