refactor code and add token handoff

This commit is contained in:
hippoz 2022-02-25 19:56:10 +02:00
parent 00a90e9d5a
commit e18b6ee11d
Signed by: hippoz
GPG key ID: 7C52899193467641
13 changed files with 216 additions and 25 deletions

View file

@ -21,7 +21,7 @@
hash = hash.substring(1, hash.length); hash = hash.substring(1, hash.length);
if (hash !== "") { if (hash !== "") {
routeInfo = hash.split("||"); routeInfo = hash.split(",,");
} }
function doChatLogin() { 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) { if (routeInfo.length >= 2) {
switch (routeInfo[0]) { switch (routeInfo[0]) {
@ -192,6 +212,10 @@
view = { type: "REDEEM_TOKEN_CONFIRM_PROMPT", token: routeInfo[1] }; view = { type: "REDEEM_TOKEN_CONFIRM_PROMPT", token: routeInfo[1] };
break; break;
} }
case "token_handoff": {
view = { type: "HANDOFF_TOKEN_CONFIRM_PROMPT", handoffToken: routeInfo[1] };
break;
}
} }
} else { } else {
// no special route, continue normal execution // no special route, continue normal execution
@ -230,5 +254,7 @@
<MessageDisplay header={ view.header } content={ view.content } ></MessageDisplay> <MessageDisplay header={ view.header } content={ view.content } ></MessageDisplay>
{:else if view.type === "REDEEM_TOKEN_CONFIRM_PROMPT"} {:else if view.type === "REDEEM_TOKEN_CONFIRM_PROMPT"}
<FuzzyView on:selected={fuzzyConfirmToken} elements={[{name: "Yes", id: "Yes"}, {name: "No", id: "No"}]} title="Would you like to import this token? It might've been created for you by your bridge administrator, or a bridge application. Only import tokens you own and trust." /> <FuzzyView on:selected={fuzzyConfirmToken} elements={[{name: "Yes", id: "Yes"}, {name: "No", id: "No"}]} title="Would you like to import this token? It might've been created for you by your bridge administrator, or a bridge application. Only import tokens you own and trust." />
{:else if view.type === "HANDOFF_TOKEN_CONFIRM_PROMPT"}
<FuzzyView on:selected={fuzzyConfirmHandoff} elements={[{name: "Yes", id: "Yes"}, {name: "No", id: "No"}]} title="An application would like to have access to your account. This means it will be able to read and send messages on your behalf. Once you give an application access, you will not be able to revoke it. Only allow account access to applications you trust. Would you like to give this application account access?" />
{/if} {/if}
</main> </main>

View file

@ -1,13 +1,14 @@
{ {
"name": "discordbridge", "name": "discordbridge",
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "src/index.js",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"express": "^4.17.2", "express": "^4.17.2",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"node-fetch": "^3.2.0", "node-fetch": "^3.2.0",
"uuid": "^8.3.2",
"ws": "^8.4.2" "ws": "^8.4.2"
}, },
"optionalDependencies": { "optionalDependencies": {

View file

@ -14,6 +14,7 @@ const TARGET_GUILD_ID = "_";
const TARGET_CHANNEL_ID = "_"; const TARGET_CHANNEL_ID = "_";
const ORIGIN = "http://localhost:4050"; const ORIGIN = "http://localhost:4050";
const GATEWAY_ORIGIN = "ws://localhost:4050/gateway"; const GATEWAY_ORIGIN = "ws://localhost:4050/gateway";
const FRONTEND_ORIGIN = "http://localhost:4050/";
const messageSchema = { t: "number", d: "object" }; const messageSchema = { t: "number", d: "object" };
const messageTypes = { const messageTypes = {
HELLO: 0, HELLO: 0,
@ -27,6 +28,9 @@ const joinNotificationRegex = /^\[(?:.*?)\]: (?<username>[a-zA-Z0-9]+) joined th
const leaveNotificationRegex = /^\[(?:.*?)\]: (?<username>[a-zA-Z0-9]+) left the game/; const leaveNotificationRegex = /^\[(?:.*?)\]: (?<username>[a-zA-Z0-9]+) left the game/;
const rconConnection = new Rcon("localhost", "25575", process.env.RCON_PASSWORD); const rconConnection = new Rcon("localhost", "25575", process.env.RCON_PASSWORD);
const pendingHandoffs = new Map();
const usernameToToken = new Map();
export default class GatewayClient { export default class GatewayClient {
constructor(gatewayPath) { constructor(gatewayPath) {
this.gatewayPath = 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 = [ const tellrawPayload = [
{ text: "[" }, { text: "[" },
{ text: `${username}`, color: "gray" }, { text: `${username}`, color: "gray" },
@ -188,6 +196,26 @@ async function sendMinecraftMessageAs(rcon, username, content, attachments=[], r
rcon.send(`tellraw @a ${JSON.stringify(tellrawPayload)}`); 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() { async function main() {
rconConnection.on("error", (e) => { rconConnection.on("error", (e) => {
console.error("rcon: got error", e); console.error("rcon: got error", e);
@ -198,9 +226,18 @@ async function main() {
}, 5000); }, 5000);
} }
}); });
const gateway = new GatewayClient(GATEWAY_ORIGIN); const gateway = new GatewayClient(GATEWAY_ORIGIN);
gateway.onEvent = (e) => { 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); 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); const messageResult = chatMessageRegex.exec(stringData);
if (messageResult) { if (messageResult) {
await sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, messageResult.groups.message, messageResult.groups.username, null); if (messageResult.groups.message === "!:minecraft-link") {
return; 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);
}
} }
}); });
} }

View file

@ -1,4 +1,4 @@
import { WebSocketServer } from "ws"; import { WebSocket, WebSocketServer } from "ws";
import { guildMap } from "./common.js"; import { guildMap } from "./common.js";
import { decodeToken } from "./tokens.js"; import { decodeToken } from "./tokens.js";
@ -12,7 +12,9 @@ const messageTypes = {
}; };
class GatewayServer { class GatewayServer {
constructor(server, extraWebsocketServerConfig={}) { constructor() {}
start(server, extraWebsocketServerConfig={}) {
this.wss = new WebSocketServer({ this.wss = new WebSocketServer({
server, server,
...extraWebsocketServerConfig ...extraWebsocketServerConfig
@ -99,7 +101,7 @@ class GatewayServer {
try { try {
message = JSON.parse(message.toString()); message = JSON.parse(message.toString());
} catch (e) { } catch (e) {
return ws.close(4000, "Payload error."); return ws.close(4000, "Payload JSON parse error.");
} }
if (!this._checkMessageSchema(message)) if (!this._checkMessageSchema(message))
@ -123,7 +125,7 @@ class GatewayServer {
this._clientDispatch(ws, { this._clientDispatch(ws, {
type: "SET_TOKEN", type: "SET_TOKEN",
token: message.token token: message.d.token
}); });
this._clientDispatch(ws, { this._clientDispatch(ws, {
type: "AUTHENTICATE_AS", type: "AUTHENTICATE_AS",
@ -205,6 +207,30 @@ class GatewayServer {
ws.ping(); 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; export default GatewayServer;

3
src/commonservers.js Normal file
View file

@ -0,0 +1,3 @@
import GatewayServer from "./GatewayServer.js";
export const gatewayServer = new GatewayServer();

View file

@ -1,6 +1,7 @@
export const mainHttpListenPort = 4050; export const mainHttpListenPort = 4050;
export const watchedGuildIds = ["822089558886842418", "736292509134749807"]; export const watchedGuildIds = ["822089558886842418", "736292509134749807"];
export const jwtSecret = process.env.JWT_SECRET; export const jwtSecret = process.env.JWT_SECRET;
export const jwtHandoffSecret = process.env.JWT_HANDOFF_SECRET;
export const discordToken = process.env.DISCORD_TOKEN; export const discordToken = process.env.DISCORD_TOKEN;
export const dangerousAdminMode = true; export const dangerousAdminMode = true;
export const logContextMap = { export const logContextMap = {
@ -20,3 +21,15 @@ export const logContextMap = {
error: true, 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.");
}

View file

@ -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 express from "express";
import apiRoute from "./routes/api.js"; import apiRoute from "./routes/api.js";
import { mainHttpListenPort } from "./config.js"; import { gatewayServer } from "./commonservers.js";
import { bot, logger } from "./common.js";
import GatewayServer from "./GatewayServer.js";
const log = logger("log", "ServerMain"); // (probably) a bad idea
// might introduce bugs and probably a bad idea
Object.freeze(Object.prototype); Object.freeze(Object.prototype);
Object.freeze(Object); Object.freeze(Object);
const app = express(); const log = logger("log", "main");
const httpServer = http.createServer(app);
const gatewayServer = new GatewayServer(httpServer, { export const app = express();
path: "/gateway" export const httpServer = createServer(app);
});
gatewayServer.start(httpServer);
app.use(express.json()); app.use(express.json());
app.use("/", express.static("frontend/public/")); app.use("/", express.static("frontend/public/"));
@ -25,4 +24,4 @@ app.use("/api/v1", apiRoute);
httpServer.listen(mainHttpListenPort, () => { httpServer.listen(mainHttpListenPort, () => {
log(`http listen on ${mainHttpListenPort}`); log(`http listen on ${mainHttpListenPort}`);
bot.connect(); bot.connect();
}); });

View file

@ -1,7 +1,9 @@
import express from "express"; import express from "express";
import { guildMap, logger } from "../common.js"; import { guildMap, logger } from "../common.js";
import { dangerousAdminMode } from "../config.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"); 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; export default router;

View file

@ -1,5 +1,5 @@
import jsonwebtoken from "jsonwebtoken"; import jsonwebtoken from "jsonwebtoken";
import { jwtSecret } from "./config.js"; import { jwtHandoffSecret, jwtSecret } from "./config.js";
export function createToken({ username, avatarURL, discordID, guildAccess, isSuperToken=false }) { export function createToken({ username, avatarURL, discordID, guildAccess, isSuperToken=false }) {
return new Promise((resolve, reject) => { 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) { export function decodeToken(token) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
jsonwebtoken.verify(token, jwtSecret, (err, token) => { 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) { export function checkAuth(callback) {
return async (req, res) => { return async (req, res) => {
const token = req.get("authorization"); const token = req.get("authorization");
@ -37,6 +59,7 @@ export function checkAuth(callback) {
if (user) { if (user) {
req.user = user; req.user = user;
req.authenticated = true; req.authenticated = true;
req.token = token;
return await callback(req, res); return await callback(req, res);
} else { } else {
res.status(401).send({ error: true, message: "ERROR_UNAUTHORIZED" }); res.status(401).send({ error: true, message: "ERROR_UNAUTHORIZED" });

View file

@ -470,6 +470,11 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= 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: vary@~1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"