mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 15:24:52 +09:00
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:
1 parent
f07082b52d
commit
72d0994ca6
17 files changed
+1346
-3
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`.
|
||||
@@ -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" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "player_auth",
|
||||
"load": ["auth.lua"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
.factorio-login-steps {
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
.factorio-login-steps ul {
|
||||
padding-left: 1em;
|
||||
margin-bottom: 1em;
|
||||
list-style-type: disc;
|
||||
}
|
||||
@@ -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,
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user