diff --git a/config.example.toml b/config.example.toml index 1284df5..c37c86d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -13,6 +13,15 @@ originAllowedDomains = ["computernewb.com"] # Maximum amount of active connections allowed from the same IP. maxConnections = 3 +[auth] +enabled = false +apiEndpoint = "" +secretKey = "hunter2" + +[auth.guestPermissions] +chat = true +turn = false + [vm] qemuArgs = "qemu-system-x86_64" vncPort = 5900 diff --git a/src/AuthManager.ts b/src/AuthManager.ts new file mode 100644 index 0000000..69fc01e --- /dev/null +++ b/src/AuthManager.ts @@ -0,0 +1,41 @@ +import { Rank, User } from "./User.js"; +import log from "./log.js"; + +export default class AuthManager { + apiEndpoint : string; + secretKey : string; + constructor(apiEndpoint : string, secretKey : string) { + this.apiEndpoint = apiEndpoint; + this.secretKey = secretKey; + } + + Authenticate(token : string, user : User) { + return new Promise(async res => { + var response = await fetch(this.apiEndpoint + "/api/v1/join", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + secretKey: this.secretKey, + sessionToken: token, + ip: user.IP.address + }) + }); + var json = await response.json() as JoinResponse; + if (!json.success) { + log("FATAL", `Failed to query auth server: ${json.error}`); + process.exit(1); + } + res(json); + }); + } +} + +interface JoinResponse { + success : boolean; + clientSuccess : boolean; + error : string | undefined; + username : string | undefined; + rank : Rank; +} \ No newline at end of file diff --git a/src/IConfig.ts b/src/IConfig.ts index 6552948..2d465b4 100644 --- a/src/IConfig.ts +++ b/src/IConfig.ts @@ -8,6 +8,15 @@ export default interface IConfig { originAllowedDomains : string[]; maxConnections: number; }; + auth : { + enabled : boolean; + apiEndpoint : string; + secretKey : string; + guestPermissions : { + chat : boolean; + turn : boolean; + } + } vm : { qemuArgs : string; vncPort : number; diff --git a/src/User.ts b/src/User.ts index b9e9ad6..3741088 100644 --- a/src/User.ts +++ b/src/User.ts @@ -141,6 +141,8 @@ export class User { export enum Rank { Unregistered = 0, + // After all these years + Registered = 1, Admin = 2, Moderator = 3, // Giving a good gap between server only internal ranks just in case diff --git a/src/WSServer.ts b/src/WSServer.ts index 5189fba..8479e1b 100644 --- a/src/WSServer.ts +++ b/src/WSServer.ts @@ -18,6 +18,7 @@ import log from './log.js'; import VM from './VM.js'; import { fileURLToPath } from 'url'; import path from 'path'; +import AuthManager from './AuthManager.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -54,7 +55,11 @@ export default class WSServer { private indefiniteTurn : User | null; private ModPerms : number; private VM : VM; - constructor(config : IConfig, vm : VM) { + + // Authentication manager + private auth : AuthManager | null; + + constructor(config : IConfig, vm : VM, auth : AuthManager | null) { this.Config = config; this.ChatHistory = new CircularBuffer<{user:string,msg:string}>(Array, this.Config.collabvm.maxChatHistoryLength); this.TurnQueue = new Queue(); @@ -84,6 +89,8 @@ export default class WSServer { this.VM = vm; this.VM.on("dirtyrect", (j, x, y) => this.newrect(j, x, y)); this.VM.on("size", (s) => this.newsize(s)); + // authentication manager + this.auth = auth; } listen() { @@ -216,6 +223,7 @@ export default class WSServer { }; private connectionClosed(user : User) { + if (this.clients.indexOf(user) === -1) return; if(user.IP.vote != null) { user.IP.vote = null; this.sendVoteUpdate(); @@ -236,6 +244,36 @@ export default class WSServer { var msgArr = guacutils.decode(message); if (msgArr.length < 1) return; switch (msgArr[0]) { + case "login": + if (msgArr.length !== 2 || !this.Config.auth.enabled) return; + var res = await this.auth!.Authenticate(msgArr[1], client); + if (res.clientSuccess) { + log("INFO", `${client.IP.address} logged in as ${res.username}`); + client.sendMsg(guacutils.encode("login", "1")); + var old = this.clients.find(c=>c.username === res.username); + if (old) { + // kick() doesnt wait until the user is actually removed from the list and itd be anal to make it do that + // so we call connectionClosed manually here. When it gets called on kick(), it will return because the user isn't in the list + this.connectionClosed(old); + await old.kick(); + } + // Set username + this.renameUser(client, res.username); + // Set rank + client.rank = res.rank; + if (client.rank === Rank.Admin) { + client.sendMsg(guacutils.encode("admin", "0", "1")); + } else if (client.rank === Rank.Moderator) { + client.sendMsg(guacutils.encode("admin", "0", "3", this.ModPerms.toString())); + } + this.clients.forEach((c) => c.sendMsg(guacutils.encode("adduser", "1", client.username!, client.rank.toString()))); + } else { + client.sendMsg(guacutils.encode("login", "0", res.error!)); + if (res.error === "You are banned") { + client.kick(); + } + } + break; case "list": client.sendMsg(guacutils.encode("list", this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail())); break; @@ -246,6 +284,9 @@ export default class WSServer { } client.connectedToNode = true; client.sendMsg(guacutils.encode("connect", "1", "1", this.Config.vm.snapshots ? "1" : "0", "0")); + if (this.Config.auth.enabled) { + client.sendMsg(guacutils.encode("auth", this.Config.auth.apiEndpoint)); + } if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode("chat", "", this.Config.collabvm.motd)); if (this.screenHidden) { @@ -304,12 +345,26 @@ export default class WSServer { case "rename": if (!client.RenameRateLimit.request()) return; if (client.connectedToNode && client.IP.muted) return; + if (this.Config.auth.enabled && client.rank !== Rank.Unregistered) { + client.sendMsg(guacutils.encode("chat", "", "Go to your account settings to change your username.")); + return; + } + if (this.Config.auth.enabled && msgArr[1] !== undefined) { + client.sendMsg(guacutils.encode("chat", "", "You need to log in to do that.")); + if (client.rank !== Rank.Unregistered) return; + this.renameUser(client, undefined); + return; + } this.renameUser(client, msgArr[1]); break; case "chat": if (!client.username) return; if (client.IP.muted) return; if (msgArr.length !== 2) return; + if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.chat) { + client.sendMsg(guacutils.encode("chat", "", "You need to login to do that.")); + return; + } var msg = Utilities.HTMLSanitize(msgArr[1]); // One of the things I hated most about the old server is it completely discarded your message if it was too long if (msg.length > this.Config.collabvm.maxChatLength) msg = msg.substring(0, this.Config.collabvm.maxChatLength); @@ -321,6 +376,10 @@ export default class WSServer { break; case "turn": if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return; + if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.turn) { + client.sendMsg(guacutils.encode("chat", "", "You need to login to do that.")); + return; + } if (!client.TurnRateLimit.request()) return; if (!client.connectedToNode) return; if (msgArr.length > 2) return; @@ -413,6 +472,10 @@ export default class WSServer { switch (msgArr[1]) { case "2": // Login + if (this.Config.auth.enabled) { + client.sendMsg(guacutils.encode("chat", "", "This server does not support staff passwords. Please log in to become staff.")); + return; + } if (!client.LoginRateLimit.request() || !client.username) return; if (msgArr.length !== 3) return; var sha256 = createHash("sha256"); @@ -527,6 +590,9 @@ export default class WSServer { case "18": // Rename user if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.rename)) return; + if (this.Config.auth.enabled) { + client.sendMsg(guacutils.encode("chat", "", "Cannot rename users on a server that uses authentication.")); + } if (msgArr.length !== 4) return; var user = this.clients.find(c => c.username === msgArr[2]); if (!user) return; diff --git a/src/index.ts b/src/index.ts index d11b272..b2d0f71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import * as fs from "fs"; import WSServer from './WSServer.js'; import QEMUVM from './QEMUVM.js'; import log from './log.js'; +import AuthManager from './AuthManager.js'; log("INFO", "CollabVM Server starting up"); @@ -34,12 +35,15 @@ async function start() { log("WARN", "To remove this warning, use the qmpHost and qmpPort options instead."); } + // Init the auth manager if enabled + var auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null; + // Fire up the VM var VM = new QEMUVM(Config); await VM.Start(); // Start up the websocket server - var WS = new WSServer(Config, VM); + var WS = new WSServer(Config, VM, auth); WS.listen(); } start(); \ No newline at end of file