diff --git a/.gitignore b/.gitignore index b08ae3c..dbcbc51 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules .env package-lock.json -*.code-workspace \ No newline at end of file +*.code-workspace +build/ \ No newline at end of file diff --git a/data/accounts.json b/data/accounts.json index fca680c..bf8f441 100644 --- a/data/accounts.json +++ b/data/accounts.json @@ -1,10 +1,10 @@ [ { "username": "test", - "user_id": "123456" + "user_id": 123456 }, { "username": "Pieloaf", - "user_id": "293850" + "user_id": 293850 } ] \ No newline at end of file diff --git a/package.json b/package.json index c4bf8f2..f69c9f6 100644 --- a/package.json +++ b/package.json @@ -11,15 +11,20 @@ "license": "ISC", "dependencies": { "async-mqtt": "^2.6.3", - "dotenv": "^16.0.3", - "express": "^4.18.2" + "dotenv": "^16.3.1", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.0" }, "devDependencies": { "@types/express": "^4.17.14", + "@types/jsonwebtoken": "^9.0.2", "@types/node": "^18.11.0", "@types/ws": "^8.5.3", "ts-node-dev": "^2.0.0", "tsc": "^2.0.4", "typescript": "^4.8.4" + }, + "engines": { + "node": ">=18.18.2" } } diff --git a/src/auth/discord.ts b/src/auth/discord.ts new file mode 100644 index 0000000..abfe5fa --- /dev/null +++ b/src/auth/discord.ts @@ -0,0 +1,58 @@ +import { config } from "dotenv"; + +config(); + +const DISCORD_REDIRECT_URI = + "https://bsf.pieloaf.com/auth/discord-oauth-callback"; +const DISCORD_CLIENT_ID = "1122976027140956221"; +const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET as string; +const DISCORD_API_BASE_URL = "https://discord.com/api"; + +export const getDiscordOAuthURL = () => { + let url = new URL(`https://discord.com/api/oauth2/authorize`); + url.searchParams.set("client_id", DISCORD_CLIENT_ID); + url.searchParams.set("redirect_uri", DISCORD_REDIRECT_URI); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "identify guilds"); + return url.toString(); +}; + +export const getDiscorOauthToken = async (grant_code: string) => { + let url = new URL(`${DISCORD_API_BASE_URL}/oauth2/token`); + let body = new URLSearchParams({ + client_id: DISCORD_CLIENT_ID, + client_secret: DISCORD_CLIENT_SECRET, + grant_type: "authorization_code", + code: grant_code, + redirect_uri: DISCORD_REDIRECT_URI, + }); + let requestData = { + method: "POST", + body: body, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }; + + let response = await fetch(url.toString(), requestData); + if (response.status === 200) { + return await response.json(); + } else { + throw new Error( + `Error fetching OAuth tokens: [${response.status}] ${response.statusText}` + ); + } +}; + +export const getDiscordUser = async (access_token: string) => { + let url = new URL(`${DISCORD_API_BASE_URL}/users/@me`); + let requestData = { + method: "GET", + headers: { + Authorization: `Bearer ${access_token}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + }; + let response = await fetch(url.toString(), requestData); + return await response.json(); +}; diff --git a/src/index.ts b/src/index.ts index 3043ad8..da8e48d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,9 @@ import { AuthRouter, Session, sessionHandler } from "./sessions"; import { ChatRouter } from "./chat"; import { BattleRouter } from "./battle/Battle"; import { QueueRouter } from "./queue"; +import { config } from "dotenv"; + +config(); const app = express(); @@ -16,7 +19,7 @@ app.use(express.json()); // parse data as json unless otherwise specified * [with the exception of steam/overlay/{session_id}/{state}]. This middleware * extracts the session key and checks it is a valid session before continuing. **/ -app.use((req, res, next) => { +app.use("/services", (req, res, next) => { // steam overlay requests dont end with the session key but the server // doesnt seem to do anything with the request anyway so skip check if (req.path.startsWith("/services/session/steam/overlay/")) { @@ -33,33 +36,33 @@ app.use((req, res, next) => { // the login route ends with /11 so in that case the user has no session // and the extracted "key" will be 11, so in this case the request can continue if (!session && session_key !== "11") { - res.sendStatus(403); + res.status(403); return; } // adding the session object to the request object so // each module doesn't need to lookup the session again (req as any).session = session; - next(); + app._router.handle(req, res, next); }); -app.use("/services/auth", AuthRouter); +app.use("/auth", AuthRouter); -app.use("/services/chat", ChatRouter); +app.use("/chat", ChatRouter); -app.use("/services/vs", QueueRouter); +app.use("/vs", QueueRouter); -app.use("/services/battle", BattleRouter); +app.use("/battle", BattleRouter); // request leaderboard or update server of location -app.post("/services/game/leaderboards/:session_key", (req, res) => { +app.post("/game/leaderboards/:session_key", (req, res) => { // parse board_ids and tourney from body // and lookup database res.json(JSON.parse(readFileSync("./data/lboard.json", "utf-8"))); }); // poll for relevant data -app.get("/services/game/:session_key", (req, res) => { +app.get("/game/:session_key", (req, res) => { let session: Session = (req as any).session; // send buffered data and clear if (session.data.length > 0) { @@ -74,23 +77,37 @@ app.get("/services/game/:session_key", (req, res) => { * Random routes that either have temp data or idk what their purpose is */ +const getTokenFromHeader = (req: express.Request) => { + return req.headers.authorization?.match(/Bearer (.*)/)?.[1]; +}; // get account info -app.get("/services/account/info/:session_key", (req, res) => { +app.get("/account/info/:session_key?", (req, res) => { + let auth: string | undefined = + req.params.session_key || getTokenFromHeader(req); + + if (!auth) { + res.status(403); + return; + } // // look up user in database // return user data (will require some handlers for packing data) // TODO: implement handlers for packing acc data res.json(JSON.parse(readFileSync("./data/acc.json", "utf-8"))); }); -app.post("/services/game/location/:session_key", (req, res) => { +app.post("/game/location/:session_key", (req, res) => { // do something here with location info maybe? idk what + + // TODO: start worker for class linked with location eg meadhouse -> worker for roster res.send(); }); // notify server if steam overlay is enabled -app.post("/services/session/steam/overlay/:session_key/:state", (req, res) => { +app.post("/session/steam/overlay/:session_key/:state", (req, res) => { // idk what the server does with this info res.send(); }); -http.createServer(app).listen(3000); +http.createServer(app).listen(3000, () => { + console.log("Express server listening on port " + 3000); +}); diff --git a/src/sessions.ts b/src/sessions.ts index 50e15e8..9fd4b7d 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -1,13 +1,20 @@ - import crypto from "crypto"; -import { readFileSync } from 'fs'; +import { readFileSync } from "fs"; import { getQueue } from "./queue"; import { GameModes } from "./const"; import { Router } from "express"; +import jsonwebtoken from "jsonwebtoken"; +const { verify, sign } = jsonwebtoken; +import * as d_auth from "./auth/discord"; +import { config } from "dotenv"; + +config(); -const build_number = readFileSync("./data/build-number", 'utf-8'); +const build_number = readFileSync("./data/build-number", "utf-8"); export const AuthRouter = Router(); +const JWT_SECRET = process.env.JWT_SECRET as string; + var generateKey = () => { return crypto.randomBytes(8).toString("hex"); }; @@ -15,28 +22,27 @@ var generateKey = () => { const getInitialData = (): any[] => { // should take user_id arg to check currency data and friend data // return initial queue data [done], tournament data, currency data, friend data - let initialData: any[] = [] + let initialData: any[] = []; for (const type of Object.values(GameModes)) { - initialData.push(getQueue(type, 0)) + initialData.push(getQueue(type, 0)); } - initialData.concat(JSON.parse(readFileSync("./data/first.json", 'utf-8'))); - return initialData -} - + initialData.concat(JSON.parse(readFileSync("./data/first.json", "utf-8"))); + return initialData; +}; const getUser = (user_id: number) => { // look up user in database and return data // needs some form of authentication - // maybe a user_id stored in a jwt token + // maybe a user_id stored in a jwt token // which can be passed as the username to the game from an external client // anyway... on with the demo - return JSON.parse(readFileSync("./data/accounts.json", 'utf-8')).find( - (acc: any) => acc.user_id === user_id); + return JSON.parse(readFileSync("./data/accounts.json", "utf-8")).find( + (acc: any) => acc.user_id === user_id + ); }; export class Session { - display_name: string; user_id: number; session_key: string; @@ -50,7 +56,7 @@ export class Session { this.user_id = user_id; this.session_key = generateKey(); this.data = getInitialData(); - }; + } asJson() { return { @@ -59,18 +65,22 @@ export class Session { user_id: this.user_id, vbb_name: null, session_key: this.session_key, - } - }; + }; + } pushData(...data: any) { this.data.push(...data); - }; + } } -var sessions: any = {} +var sessions: any = {}; export const sessionHandler = { - getSessions: (filterFunc: (s:Session, index: number, array: Session[]) => void = _ => true): Session[] => { + getSessions: ( + filterFunc: (s: Session, index: number, array: Session[]) => void = ( + _ + ) => true + ): Session[] => { return (Object.values(sessions) as Session[]).filter(filterFunc); }, addSession: (user_id: number) => { @@ -79,17 +89,21 @@ export const sessionHandler = { return session.asJson(); }, getSession: (key: string, value: any): Session => { - if (key === "session_key") - return sessions[value]; - return Object.values(sessions).find(session => (session as any)[key] === value) as Session; + if (key === "session_key") return sessions[value]; + return Object.values(sessions).find( + (session) => (session as any)[key] === value + ) as Session; }, removeSession: (session_key: string) => { delete sessions[session_key]; - } + }, }; -AuthRouter.post('/login/:httpVersion', (req, res) => { - let userData = sessionHandler.addSession(req.body.steam_id); +AuthRouter.post("/login/:httpVersion", (req, res) => { + let data = verify(req.body.steam_id, process.env.JWT_SECRET as string); + console.log(data); // Temporary + // TODO: lookup user in database + let userData = sessionHandler.addSession(293850); res.json(userData); }); @@ -97,3 +111,31 @@ AuthRouter.post("/logout/:session_key", (req, res) => { sessionHandler.removeSession(req.params.session_key); res.send(); }); + +AuthRouter.get("/discord-login", (req, res) => { + res.redirect(d_auth.getDiscordOAuthURL()); +}); + +AuthRouter.get("/discord-oauth-callback", async (req, res) => { + let jwt_res: string | undefined; + let error: string | undefined; + + if (req.query?.error || !req.query?.code) { + error = req.query.error?.toString(); + } + try { + const tokens = await d_auth.getDiscorOauthToken( + req.query.code as string + ); + const d_user = await d_auth.getDiscordUser(tokens.access_token); + jwt_res = sign({ discord_id: d_user.id }, JWT_SECRET); + } catch (e) { + console.log(e); // TODO: Should probably log this somewhere persistent + error = "an_error_occurred_communicating_with_discord"; + } + res.status(301); + if (error) { + return res.redirect(`bsf://auth?error=${error}`); + } + return res.redirect(`bsf://auth?access_token=${jwt_res}`); +});