Files
factorio-clusterio/plugins/player_auth/controller.ts
T

219 lines
6.1 KiB
TypeScript

import crypto from "crypto";
import express, { type Request, type Response } from "express";
import util from "util";
import jwt from "jsonwebtoken";
import { Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller";
const { basicType } = lib;
import { FetchPlayerCodeRequest, PlayerAuthServer, SetVerifyCodeRequest } from "./messages";
async function generateCode(length: number): Promise<string> {
// 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("");
}
type PlayerCode = { playerCode: string, verifyCode: string | null, expiresMs: number };
export class ControllerPlugin extends BaseControllerPlugin {
players!: Map<string, PlayerCode>;
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.expiresMs < now) {
this.players.delete(player);
}
}
}, 60e3).unref();
this.controller.app.get("/api/player_auth/servers", (req: Request, res: Response) => {
const servers: Static<typeof PlayerAuthServer.jsonSchema>[] = [];
for (const instance of this.controller.instances.values()) {
const pluginLoaded = instance.config.get("player_auth.load_plugin");
const assignedHost = instance.config.get("instance.assigned_host");
if (instance.status !== "running" || !pluginLoaded || !assignedHost) {
continue;
}
const host = this.controller.hosts.get(assignedHost);
if (!host) {
continue;
}
const address = instance.gamePort !== undefined
? `${host.publicAddress}:${instance.gamePort}`
: host.publicAddress;
const settings = instance.config.get("factorio.settings");
const includeAddress = this.controller.config.get("player_auth.show_connect_address");
servers.push({
name: settings["name"] as string || "unnamed server",
factorioVersion: instance.factorioVersion,
address: includeAddress ? address : undefined,
});
}
res.send(servers);
});
this.controller.app.post(
"/api/player_auth/player_code",
express.json(),
(req: Request, res: Response, next: any) => {
this.handlePlayerCode(req, res).catch(next);
}
);
this.controller.app.post(
"/api/player_auth/verify",
express.json(),
(req: Request, res: Response, next: any) => {
this.handleVerify(req, res).catch(next);
}
);
this.controller.handle(FetchPlayerCodeRequest, this.handleFetchPlayerCodeRequest.bind(this));
this.controller.handle(SetVerifyCodeRequest, this.handleSetVerifyCodeRequest.bind(this));
}
async handlePlayerCode(req: Request, res: Response) {
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 entry of this.players.values()) {
if (entry.playerCode === playerCode && entry.expiresMs > Date.now()) {
let verifyCode = await generateCode(this.controller.config.get("player_auth.code_length"));
let verifyToken = jwt.sign(
{
aud: "player_auth.verify_code",
exp: Math.floor(entry.expiresMs / 1000),
verify_code: verifyCode,
player_code: playerCode,
},
this.controller.authSecret
);
res.send({ verify_code: verifyCode, verify_token: verifyToken });
return;
}
}
res.send({ error: true, message: "invalid player_code" });
}
async handleVerify(req: Request, res: Response) {
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;
}
try {
let payload = jwt.verify(
verifyToken, this.controller.authSecret, { audience: "player_auth.verify_code" }
) as jwt.JwtPayload;
if (payload.verify_code !== verifyCode) {
throw new Error("invalid verify_code");
}
if (payload.player_code !== playerCode) {
throw new Error("invalid player_code");
}
} catch (err: any) {
res.send({ error: true, message: err.message });
return;
}
for (let [player, entry] of this.players) {
if (entry.playerCode === playerCode && entry.expiresMs > Date.now()) {
if (entry.verifyCode === verifyCode) {
let user = this.controller.users.getByName(player);
if (!user) {
res.send({ error: true, message: "invalid user" });
return;
}
let token = this.controller.users.signUserToken(user);
res.send({ verified: true, token });
return;
}
res.send({ verified: false });
return;
}
}
res.send({ error: true, message: "invalid player_code" });
}
async handleFetchPlayerCodeRequest(request: FetchPlayerCodeRequest) {
let playerCode = await generateCode(this.controller.config.get("player_auth.code_length"));
let expiresMs = Date.now() + this.controller.config.get("player_auth.code_timeout") * 1000;
this.players.set(request.player, { playerCode, verifyCode: null, expiresMs });
return { playerCode, controllerUrl: this.controller.getControllerUrl() };
}
async handleSetVerifyCodeRequest(request: SetVerifyCodeRequest) {
let { player, verifyCode } = request;
let entry = this.players.get(player);
if (!entry || entry.expiresMs < Date.now()) {
throw new lib.RequestError("invalid player");
}
entry.verifyCode = verifyCode;
}
}
// For testing only
export const _generateCode = generateCode;