Add Player Auth plugin (#368)

Add authentication plugin that does a simple challenge response protocol
to verify that the player trying to log in is who they claim to be.  The
process goes as follows:

1. A player code is generated for a player that wants to log in and is
   displayed in-game.
2. The player code is input into the web interface and the master
   generates a verification code as a challenge back along with a token.
3. The player inputs the verification code in-game and is logged in to
   the web interface in doing so.

To do the actual login the web interface polls a verify endpoint every 2
seconds until the code has either been verified or expired.  The
challenge comes from being unable to control the verification code and
having to input it as a player logging into one of the servers in order
to login.

Some additional steps are taken in order to increase the difficulty for
an attacker to subvert the challenge:

- The login process must be started from in-game.
- Generated codes are only valid for a limited timespan which is
  configurable and 2 minutes by default.
- The verification code comes with a token that only works with one
  player code and verification code combo and must be provided to pass
  validation.
This commit is contained in:
Hornwitser authored and GitHub committed 2021-03-17 18:48:22 +01:00
1 parent f07082b52d
commit 72d0994ca6
17 files changed
+1346 -3

No files matched your search

+2
View File
@@ -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
+2
View File
@@ -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:
+3 -1
View File
@@ -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",
+10 -1
View File
@@ -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);
+42
View File
@@ -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`.
+57
View File
@@ -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" },
},
}),
},
};
+55
View File
@@ -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,
};
+192
View File
@@ -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,
};
+210
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
{
"name": "player_auth",
"load": ["auth.lua"]
}
+34
View File
@@ -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 <github@hornwitser.no>",
"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"
}
}
+439
View File
@@ -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");
});
});
});
});
});
});
+9
View File
@@ -0,0 +1,9 @@
.factorio-login-steps {
padding-left: 1em;
}
.factorio-login-steps ul {
padding-left: 1em;
margin-bottom: 1em;
list-style-type: disc;
}
+158
View File
@@ -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 <Spin size="large" />;
}
if (servers.length === 0) {
return <Paragraph>
There are no servers in the cluster currently running that can complete
the login via Factorio. Please try again later.
</Paragraph>;
}
return <>
<Paragraph>Login using your Factorio account is a 3 step process:</Paragraph>
<style>
</style>
<ol className="factorio-login-steps">
<li>
<Paragraph>
Start Factorio, join one of the following multiplayer servers
and type <Text keyboard>/web-login</Text> into the chat:
</Paragraph>
<ul>
{servers.map(text => <li key={text}>{text}</li>)}
</ul>
</li>
<li>
<Paragraph>Enter the code displayed in the in-game dialog here:</Paragraph>
<Paragraph>
<Form
layout="inline"
onFinish={values => {
(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"));
}}
>
<Form.Item
name="player_code"
rules={[{ required: true, message: "Field is required" }]}
help={playerCodeError}
validateStatus={playerCodeError ? "error" : undefined}
>
<Input style={{ width: "10em" }} />
</Form.Item>
<Button type="primary" htmlType="submit">Verify</Button>
</Form>
</Paragraph>
</li>
<li>
<Paragraph>Enter this code into the in-game dialog:</Paragraph>
<Paragraph>
<Input disabled={verifyCode === null} style={{ width: "10em" }} value={verifyCode} />
</Paragraph>
</li>
</ol>
</>;
}
export class WebPlugin extends libPlugin.BaseWebPlugin {
async init() {
this.logger.info("Player Auth init");
this.loginForms = [{
name: "player_auth.factorio",
title: "Factorio",
Component: LoginForm,
}];
}
}
+37
View File
@@ -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 },
},
}),
],
});
+1
View File
@@ -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");
+91 -1
View File
@@ -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,
};