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);
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 @@
<MessageDisplay header={ view.header } content={ view.content } ></MessageDisplay>
{: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." />
{: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}
</main>

View file

@ -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": {

View file

@ -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 = /^\[(?:.*?)\]: (?<username>[a-zA-Z0-9]+) joined th
const leaveNotificationRegex = /^\[(?:.*?)\]: (?<username>[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);
}
}
});
}

View file

@ -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;

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 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.");
}

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 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/"));

View file

@ -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;

View file

@ -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" });

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"
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"