mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 07:04:52 +09:00
The jsonwebtoken accept strings as input for secrets which is nonsensical from a cryptographic standpoint as the algorithms all operate on byte sequences. Worse still, strings are encoded into bytes using UTF-8, which disallows certain sequences of bytes from appearing and potentially weakening the cryptography. For legacy reasons the master secret was encoded into base64 _and passed as a string to jsonwebtoken_. This further weakened the crypto as the bytes that can appear in base 64 is only 1/4 of the 256 possible values for bytes. This could significantly weaken the cryptographic strength of the tokens. I have known of this issue for quite some time but has put off actually fixing it for too long now. Since the master secret is already documented as being in base 64, decode it as base 64 and pass it as a byte Buffer to jsonwebtoken. This will ensure every byte value is possible in the byte sequence passed to the jsonwebtoken crypto. The impact of this change is unfortunately that all tokens will become invalid and will have to be reissued.
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.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.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,
|
|
}, Buffer.from(tokenParams.secret || "TestSecretDoNotUse", "base64")),
|
|
},
|
|
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",
|
|
}, Buffer.from("TestSecretDoNotUse", "base64")),
|
|
},
|
|
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",
|
|
}, Buffer.from("TestSecretDoNotUse", "base64")),
|
|
},
|
|
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",
|
|
}, Buffer.from("TestSecretDoNotUse", "base64")),
|
|
},
|
|
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",
|
|
}, Buffer.from("TestSecretDoNotUse", "base64")),
|
|
},
|
|
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");
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|