mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
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.
440 lines
16 KiB
JavaScript
440 lines
16 KiB
JavaScript
"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");
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|