From e18b6ee11d2ac7990f8fd1b714949bbde373dedf Mon Sep 17 00:00:00 2001 From: hippoz <10706925-hippoz@users.noreply.gitlab.com> Date: Fri, 25 Feb 2022 19:56:10 +0200 Subject: [PATCH] refactor code and add token handoff --- frontend/src/components/App.svelte | 28 ++++++++++- package.json | 3 +- scripts/minecraft.js | 64 ++++++++++++++++++++++-- DiscordClient.js => src/DiscordClient.js | 0 GatewayServer.js => src/GatewayServer.js | 34 +++++++++++-- WatchedGuild.js => src/WatchedGuild.js | 0 common.js => src/common.js | 0 src/commonservers.js | 3 ++ config.js => src/config.js | 13 +++++ index.js => src/index.js | 25 +++++---- {routes => src/routes}/api.js | 41 ++++++++++++++- tokens.js => src/tokens.js | 25 ++++++++- yarn.lock | 5 ++ 13 files changed, 216 insertions(+), 25 deletions(-) rename DiscordClient.js => src/DiscordClient.js (100%) rename GatewayServer.js => src/GatewayServer.js (87%) rename WatchedGuild.js => src/WatchedGuild.js (100%) rename common.js => src/common.js (100%) create mode 100644 src/commonservers.js rename config.js => src/config.js (52%) rename index.js => src/index.js (59%) rename {routes => src/routes}/api.js (77%) rename tokens.js => src/tokens.js (67%) diff --git a/frontend/src/components/App.svelte b/frontend/src/components/App.svelte index 34c8bec..905bf96 100644 --- a/frontend/src/components/App.svelte +++ b/frontend/src/components/App.svelte @@ -21,7 +21,7 @@ hash = hash.substring(1, hash.length); if (hash !== "") { - routeInfo = hash.split("||"); + routeInfo = hash.split(",,"); } function doChatLogin() { @@ -185,6 +185,26 @@ } } + async function fuzzyConfirmHandoff({ detail: yn }) { + if (yn === "Yes") { + if (!apiClient.token) { + view = { type: "MESSAGE_DISPLAY", header: "Authorization failed", content: "You need to be logged in to give applications account access." }; + return; + } + apiClient.postRequest("/tokens/handoff/fulfill", { + handoffToken: view.handoffToken + }).then(() => { + view = { type: "MESSAGE_DISPLAY", header: "Application authorized", content: "You can now close this tab." }; + }).catch(() => { + view = { type: "MESSAGE_DISPLAY", header: "Authorization failed", content: "We couldn't authorize this application." }; + }); + } else if (yn === "No") { + view = { type: "MESSAGE_DISPLAY", header: "Got it!", content: "We won't give this application account access right now." }; + } else { + view = { type: "MESSAGE_DISPLAY", header: "Authorization failed", content: "We can't give this application account access right now." }; + } + } + if (routeInfo.length >= 2) { switch (routeInfo[0]) { @@ -192,6 +212,10 @@ view = { type: "REDEEM_TOKEN_CONFIRM_PROMPT", token: routeInfo[1] }; break; } + case "token_handoff": { + view = { type: "HANDOFF_TOKEN_CONFIRM_PROMPT", handoffToken: routeInfo[1] }; + break; + } } } else { // no special route, continue normal execution @@ -230,5 +254,7 @@ {:else if view.type === "REDEEM_TOKEN_CONFIRM_PROMPT"} + {:else if view.type === "HANDOFF_TOKEN_CONFIRM_PROMPT"} + {/if} diff --git a/package.json b/package.json index e4e72a0..5b63e95 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "discordbridge", "version": "1.0.0", - "main": "index.js", + "main": "src/index.js", "license": "MIT", "type": "module", "dependencies": { "express": "^4.17.2", "jsonwebtoken": "^8.5.1", "node-fetch": "^3.2.0", + "uuid": "^8.3.2", "ws": "^8.4.2" }, "optionalDependencies": { diff --git a/scripts/minecraft.js b/scripts/minecraft.js index 69b5119..ab769fa 100644 --- a/scripts/minecraft.js +++ b/scripts/minecraft.js @@ -14,6 +14,7 @@ const TARGET_GUILD_ID = "_"; const TARGET_CHANNEL_ID = "_"; const ORIGIN = "http://localhost:4050"; const GATEWAY_ORIGIN = "ws://localhost:4050/gateway"; +const FRONTEND_ORIGIN = "http://localhost:4050/"; const messageSchema = { t: "number", d: "object" }; const messageTypes = { HELLO: 0, @@ -27,6 +28,9 @@ const joinNotificationRegex = /^\[(?:.*?)\]: (?[a-zA-Z0-9]+) joined th const leaveNotificationRegex = /^\[(?:.*?)\]: (?[a-zA-Z0-9]+) left the game/; const rconConnection = new Rcon("localhost", "25575", process.env.RCON_PASSWORD); +const pendingHandoffs = new Map(); +const usernameToToken = new Map(); + export default class GatewayClient { constructor(gatewayPath) { this.gatewayPath = gatewayPath; @@ -148,7 +152,11 @@ async function sendBridgeMessageAs(guildId, channelId, content, username=undefin }); } -async function sendMinecraftMessageAs(rcon, username, content, attachments=[], referencedMessage=null) { +async function sendMinecraftMessageAs(rcon, username, content, attachments, referencedMessage=null) { + if (!attachments) { + attachments = []; + } + const tellrawPayload = [ { text: "[" }, { text: `${username}`, color: "gray" }, @@ -188,6 +196,26 @@ async function sendMinecraftMessageAs(rcon, username, content, attachments=[], r rcon.send(`tellraw @a ${JSON.stringify(tellrawPayload)}`); } +async function startHandoffForUser(username) { + const handoffRes = await fetch(`${ORIGIN}/api/v1/tokens/handoff/create`, { + method: "POST", + body: JSON.stringify({}), + headers: { + "content-type": "application/json", + "authorization": TOKEN + } + }); + const { token: handoffToken, dispatchUUID } = (await handoffRes.json()); + + pendingHandoffs.set(dispatchUUID, username); + // TODO: probably not a good idea + setTimeout(() => { + pendingHandoffs.delete(dispatchUUID); + }, 40000); + + return handoffToken; +} + async function main() { rconConnection.on("error", (e) => { console.error("rcon: got error", e); @@ -198,9 +226,18 @@ async function main() { }, 5000); } }); + const gateway = new GatewayClient(GATEWAY_ORIGIN); gateway.onEvent = (e) => { - if (e.eventType === "MESSAGE_CREATE" && e.message.channel_id === TARGET_CHANNEL_ID && e.message.guild_id === TARGET_GUILD_ID && !e.message.webhook_id) { + if (e.eventType === "$BRIDGE_HANDOFF_FULFILLMENT") { + const username = pendingHandoffs.get(e.dispatchUUID); + if (!username) + return; + + usernameToToken.set(username, e.token); + + sendMinecraftMessageAs(rconConnection, "Minecraft Bridge System", `${username} has been successfully linked to a bridge token.`, null, null); + } else if (e.eventType === "MESSAGE_CREATE" && e.message.channel_id === TARGET_CHANNEL_ID && e.message.guild_id === TARGET_GUILD_ID && !e.message.webhook_id) { sendMinecraftMessageAs(rconConnection, e.message.author.username, e.message.content, e.message.attachments, e.message.referenced_message); } }; @@ -226,8 +263,27 @@ async function main() { const messageResult = chatMessageRegex.exec(stringData); if (messageResult) { - await sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, messageResult.groups.message, messageResult.groups.username, null); - return; + if (messageResult.groups.message === "!:minecraft-link") { + try { + const handoffToken = await startHandoffForUser(messageResult.groups.username); + const handoffLink = `${FRONTEND_ORIGIN}#token_handoff,,${handoffToken}`; + sendMinecraftMessageAs( + rconConnection, + "Minecraft Bridge System", + `${messageResult.groups.username}: Click on the attached link to finish linking your account. You have 40 seconds.`, + [ + { + filename: `link account to ${messageResult.groups.username}`, + proxy_url: handoffLink + } + ] + ); + } catch(e) { + sendMinecraftMessageAs(rconConnection, "Minecraft Bridge System", `Failed to create link for ${messageResult.groups.username}.`, null, null); + } + } else { + await sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, messageResult.groups.message, messageResult.groups.username, null); + } } }); } diff --git a/DiscordClient.js b/src/DiscordClient.js similarity index 100% rename from DiscordClient.js rename to src/DiscordClient.js diff --git a/GatewayServer.js b/src/GatewayServer.js similarity index 87% rename from GatewayServer.js rename to src/GatewayServer.js index 8c0458b..dd31ead 100644 --- a/GatewayServer.js +++ b/src/GatewayServer.js @@ -1,4 +1,4 @@ -import { WebSocketServer } from "ws"; +import { WebSocket, WebSocketServer } from "ws"; import { guildMap } from "./common.js"; import { decodeToken } from "./tokens.js"; @@ -12,7 +12,9 @@ const messageTypes = { }; class GatewayServer { - constructor(server, extraWebsocketServerConfig={}) { + constructor() {} + + start(server, extraWebsocketServerConfig={}) { this.wss = new WebSocketServer({ server, ...extraWebsocketServerConfig @@ -99,7 +101,7 @@ class GatewayServer { try { message = JSON.parse(message.toString()); } catch (e) { - return ws.close(4000, "Payload error."); + return ws.close(4000, "Payload JSON parse error."); } if (!this._checkMessageSchema(message)) @@ -123,7 +125,7 @@ class GatewayServer { this._clientDispatch(ws, { type: "SET_TOKEN", - token: message.token + token: message.d.token }); this._clientDispatch(ws, { type: "AUTHENTICATE_AS", @@ -205,6 +207,30 @@ class GatewayServer { ws.ping(); }); } + + findClientByToken(token) { + for (const [client, _] of this.wss.clients.entries()) { + if (client.readyState === WebSocket.OPEN && client.state && client.state.token === token) { + return client; + } + } + } + + dispatchHandoffFulfillment(targetToken, dispatchUUID, token) { + const targetClient = this.findClientByToken(targetToken); + if (!targetClient) + return false; + + targetClient.send(JSON.stringify({ + t: messageTypes.EVENT, + d: { + eventType: "$BRIDGE_HANDOFF_FULFILLMENT", + dispatchUUID, + token + } + })); + return true; + } } export default GatewayServer; diff --git a/WatchedGuild.js b/src/WatchedGuild.js similarity index 100% rename from WatchedGuild.js rename to src/WatchedGuild.js diff --git a/common.js b/src/common.js similarity index 100% rename from common.js rename to src/common.js diff --git a/src/commonservers.js b/src/commonservers.js new file mode 100644 index 0000000..2b97e3a --- /dev/null +++ b/src/commonservers.js @@ -0,0 +1,3 @@ +import GatewayServer from "./GatewayServer.js"; + +export const gatewayServer = new GatewayServer(); diff --git a/config.js b/src/config.js similarity index 52% rename from config.js rename to src/config.js index 52ec2e2..e3e95fb 100644 --- a/config.js +++ b/src/config.js @@ -1,6 +1,7 @@ export const mainHttpListenPort = 4050; export const watchedGuildIds = ["822089558886842418", "736292509134749807"]; export const jwtSecret = process.env.JWT_SECRET; +export const jwtHandoffSecret = process.env.JWT_HANDOFF_SECRET; export const discordToken = process.env.DISCORD_TOKEN; export const dangerousAdminMode = true; export const logContextMap = { @@ -20,3 +21,15 @@ export const logContextMap = { error: true, } }; + +if (!jwtSecret) { + console.error("Missing jwtSecret. Please make sure it's set through the environment variable JWT_SECRET."); +} + +if (!jwtHandoffSecret) { + console.error("Missing jwtHandoffSecret. Please make sure it's set through the environment variable JWT_SECRET."); +} + +if (jwtSecret === jwtHandoffSecret) { + console.error("jwtHandoffSecret and jwtSecret are both equal. This is a stability and security risk."); +} diff --git a/index.js b/src/index.js similarity index 59% rename from index.js rename to src/index.js index cdfc8d4..bd5d90b 100644 --- a/index.js +++ b/src/index.js @@ -1,22 +1,21 @@ -import http from "node:http"; +import { createServer } from "node:http"; +import { bot, logger } from "./common.js"; +import { mainHttpListenPort } from "./config.js"; import express from "express"; import apiRoute from "./routes/api.js"; -import { mainHttpListenPort } from "./config.js"; -import { bot, logger } from "./common.js"; -import GatewayServer from "./GatewayServer.js"; +import { gatewayServer } from "./commonservers.js"; -const log = logger("log", "ServerMain"); - -// might introduce bugs and probably a bad idea +// (probably) a bad idea Object.freeze(Object.prototype); Object.freeze(Object); -const app = express(); -const httpServer = http.createServer(app); -const gatewayServer = new GatewayServer(httpServer, { - path: "/gateway" -}); +const log = logger("log", "main"); + +export const app = express(); +export const httpServer = createServer(app); + +gatewayServer.start(httpServer); app.use(express.json()); app.use("/", express.static("frontend/public/")); @@ -25,4 +24,4 @@ app.use("/api/v1", apiRoute); httpServer.listen(mainHttpListenPort, () => { log(`http listen on ${mainHttpListenPort}`); bot.connect(); -}); \ No newline at end of file +}); diff --git a/routes/api.js b/src/routes/api.js similarity index 77% rename from routes/api.js rename to src/routes/api.js index 3bc5003..05bc2d8 100644 --- a/routes/api.js +++ b/src/routes/api.js @@ -1,7 +1,9 @@ import express from "express"; import { guildMap, logger } from "../common.js"; import { dangerousAdminMode } from "../config.js"; -import { checkAuth, createToken } from "../tokens.js"; +import { checkAuth, createHandoffToken, createToken, decodeHandoffToken } from "../tokens.js"; +import { v4 } from "uuid"; +import { gatewayServer } from "../commonservers.js"; const error = logger("error", "API"); @@ -164,4 +166,41 @@ router.get("/events/poll", checkAuth(async (req, res) => { } })); +router.post("/tokens/handoff/create", checkAuth(async (req, res) => { + const { username, isSuperToken=false } = req.user; + if (!isSuperToken) + return res.status(403).send({ error: true, message: "ERROR_NO_SUPERTOKEN" }); + + try { + const dispatchUUID = v4(); + const token = await createHandoffToken({ displayName: username, dispatchUUID, consumerToken: req.token }); + res.status(200).send({ error: false, message: "SUCCESS_HANDOFF_CREATED", token, dispatchUUID }); + } catch(e) { + res.status(500).send({ error: true, message: "ERROR_TOKEN_CREATE_FAILURE" }); + } +})); + +router.post("/tokens/handoff/fulfill", checkAuth(async (req, res) => { + const { isSuperToken=false } = req.user; + if (isSuperToken) + return res.status(403).send({ error: true, message: "ERROR_SUPERTOKEN_SECURITY" }); + + const handoffToken = req.body.handoffToken; + if (!handoffToken) { + return res.status(400).send({ error: true, message: "ERROR_BAD_HANDOFF_TOKEN" }); + } + + try { + const { consumerToken, dispatchUUID } = await decodeHandoffToken(handoffToken); + if (gatewayServer.dispatchHandoffFulfillment(consumerToken, dispatchUUID, req.token)) { + res.status(200).send({ error: false, message: "SUCCESS_HANDOFF_FULFILLED" }); + } else { + res.status(400).send({ error: true, message: "ERROR_NO_HANDOFF_CONSUMERS" }); + } + } catch(e) { + console.error(e); + res.status(400).send({ error: true, message: "ERROR_BAD_HANDOFF_TOKEN" }); + } +})); + export default router; diff --git a/tokens.js b/src/tokens.js similarity index 67% rename from tokens.js rename to src/tokens.js index 7cf0705..9192ded 100644 --- a/tokens.js +++ b/src/tokens.js @@ -1,5 +1,5 @@ import jsonwebtoken from "jsonwebtoken"; -import { jwtSecret } from "./config.js"; +import { jwtHandoffSecret, jwtSecret } from "./config.js"; export function createToken({ username, avatarURL, discordID, guildAccess, isSuperToken=false }) { return new Promise((resolve, reject) => { @@ -12,6 +12,17 @@ export function createToken({ username, avatarURL, discordID, guildAccess, isSup }); } +export function createHandoffToken({ dispatchUUID, displayName, consumerToken }) { + return new Promise((resolve, reject) => { + jsonwebtoken.sign({ dispatchUUID, displayName, consumerToken }, jwtHandoffSecret, (err, token) => { + if (err) + return reject(err); + + resolve(token); + }); + }); +} + export function decodeToken(token) { return new Promise((resolve, reject) => { jsonwebtoken.verify(token, jwtSecret, (err, token) => { @@ -23,6 +34,17 @@ export function decodeToken(token) { }); } +export function decodeHandoffToken(token) { + return new Promise((resolve, reject) => { + jsonwebtoken.verify(token, jwtHandoffSecret, (err, token) => { + if (err) + return reject(err); + + resolve(token); + }); + }); +} + export function checkAuth(callback) { return async (req, res) => { const token = req.get("authorization"); @@ -37,6 +59,7 @@ export function checkAuth(callback) { if (user) { req.user = user; req.authenticated = true; + req.token = token; return await callback(req, res); } else { res.status(401).send({ error: true, message: "ERROR_UNAUTHORIZED" }); diff --git a/yarn.lock b/yarn.lock index 91276e2..794ca88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -470,6 +470,11 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"