From e151f973804855038b789cb2ec2fb4f7509368a7 Mon Sep 17 00:00:00 2001 From: Sainan <63328889+Sainan@users.noreply.github.com> Date: Sat, 21 Jun 2025 03:35:16 +0200 Subject: [PATCH] feat(webui): handle auth via websocket Now when logging in and out of the game, the webui is notified so it can refresh the nonce, removing the need for constant login requests to revalidate it. --- src/controllers/api/loginController.ts | 59 ++++------- src/controllers/api/logoutController.ts | 7 +- src/models/loginModel.ts | 8 +- src/services/loginService.ts | 17 +++ src/services/webService.ts | 91 +++++++++++++++- src/types/loginTypes.ts | 2 +- static/webui/script.js | 135 ++++++++++++------------ 7 files changed, 201 insertions(+), 118 deletions(-) diff --git a/src/controllers/api/loginController.ts b/src/controllers/api/loginController.ts index 3d510f26..c7b5c16d 100644 --- a/src/controllers/api/loginController.ts +++ b/src/controllers/api/loginController.ts @@ -4,16 +4,16 @@ import { config } from "@/src/services/configService"; import { buildConfig } from "@/src/services/buildConfigService"; import { Account } from "@/src/models/loginModel"; -import { createAccount, isCorrectPassword, isNameTaken } from "@/src/services/loginService"; +import { createAccount, createNonce, getUsernameFromEmail, isCorrectPassword } from "@/src/services/loginService"; import { IDatabaseAccountJson, ILoginRequest, ILoginResponse } from "@/src/types/loginTypes"; import { logger } from "@/src/utils/logger"; import { version_compare } from "@/src/helpers/inventoryHelpers"; +import { sendWsBroadcastTo } from "@/src/services/webService"; export const loginController: RequestHandler = async (request, response) => { const loginRequest = JSON.parse(String(request.body)) as ILoginRequest; // parse octet stream of json data to json object const account = await Account.findOne({ email: loginRequest.email }); - const nonce = Math.round(Math.random() * Number.MAX_SAFE_INTEGER); const buildLabel: string = typeof request.query.buildLabel == "string" @@ -42,26 +42,14 @@ export const loginController: RequestHandler = async (request, response) => { loginRequest.ClientType == "webui-register") ) { try { - const nameFromEmail = loginRequest.email.substring(0, loginRequest.email.indexOf("@")); - let name = nameFromEmail || loginRequest.email.substring(1) || "SpaceNinja"; - if (await isNameTaken(name)) { - let suffix = 0; - do { - ++suffix; - name = nameFromEmail + suffix; - } while (await isNameTaken(name)); - } + const name = await getUsernameFromEmail(loginRequest.email); const newAccount = await createAccount({ email: loginRequest.email, password: loginRequest.password, DisplayName: name, CountryCode: loginRequest.lang?.toUpperCase() ?? "EN", - ClientType: loginRequest.ClientType == "webui-register" ? "webui" : loginRequest.ClientType, - CrossPlatformAllowed: true, - ForceLogoutVersion: 0, - ConsentNeeded: false, - TrackedSettings: [], - Nonce: nonce, + ClientType: loginRequest.ClientType, + Nonce: createNonce(), BuildLabel: buildLabel, LastLogin: new Date() }); @@ -80,38 +68,29 @@ export const loginController: RequestHandler = async (request, response) => { return; } - if (loginRequest.ClientType == "webui-register") { - response.status(400).json({ error: "account already exists" }); - return; - } - if (!isCorrectPassword(loginRequest.password, account.password)) { response.status(400).json({ error: "incorrect login data" }); return; } - if (loginRequest.ClientType == "webui") { - if (!account.Nonce) { - account.ClientType = "webui"; - account.Nonce = nonce; + if (account.Nonce && account.ClientType != "webui" && !account.Dropped && !loginRequest.kick) { + // U17 seems to handle "nonce still set" like a login failure. + if (version_compare(buildLabel, "2015.12.05.18.07") >= 0) { + response.status(400).send({ error: "nonce still set" }); + return; } - } else { - if (account.Nonce && account.ClientType != "webui" && !account.Dropped && !loginRequest.kick) { - // U17 seems to handle "nonce still set" like a login failure. - if (version_compare(buildLabel, "2015.12.05.18.07") >= 0) { - response.status(400).send({ error: "nonce still set" }); - return; - } - } - - account.ClientType = loginRequest.ClientType; - account.Nonce = nonce; - account.CountryCode = loginRequest.lang?.toUpperCase() ?? "EN"; - account.BuildLabel = buildLabel; - account.LastLogin = new Date(); } + + account.ClientType = loginRequest.ClientType; + account.Nonce = createNonce(); + account.CountryCode = loginRequest.lang?.toUpperCase() ?? "EN"; + account.BuildLabel = buildLabel; + account.LastLogin = new Date(); await account.save(); + // Tell WebUI its nonce has been invalidated + sendWsBroadcastTo(account._id.toString(), { logged_out: true }); + response.json(createLoginResponse(myAddress, myUrlBase, account.toJSON(), buildLabel)); }; diff --git a/src/controllers/api/logoutController.ts b/src/controllers/api/logoutController.ts index 889e7d78..e2074f76 100644 --- a/src/controllers/api/logoutController.ts +++ b/src/controllers/api/logoutController.ts @@ -1,5 +1,6 @@ import { RequestHandler } from "express"; import { Account } from "@/src/models/loginModel"; +import { sendWsBroadcastTo } from "@/src/services/webService"; export const logoutController: RequestHandler = async (req, res) => { if (!req.query.accountId) { @@ -10,7 +11,7 @@ export const logoutController: RequestHandler = async (req, res) => { throw new Error("Request is missing nonce parameter"); } - await Account.updateOne( + const stat = await Account.updateOne( { _id: req.query.accountId, Nonce: nonce @@ -19,6 +20,10 @@ export const logoutController: RequestHandler = async (req, res) => { Nonce: 0 } ); + if (stat.modifiedCount) { + // Tell WebUI its nonce has been invalidated + sendWsBroadcastTo(req.query.accountId as string, { logged_out: true }); + } res.writeHead(200, { "Content-Type": "text/html", diff --git a/src/models/loginModel.ts b/src/models/loginModel.ts index 0f83c0cb..44dab113 100644 --- a/src/models/loginModel.ts +++ b/src/models/loginModel.ts @@ -11,13 +11,13 @@ const databaseAccountSchema = new Schema( email: { type: String, required: true, unique: true }, password: { type: String, required: true }, DisplayName: { type: String, required: true, unique: true }, - CountryCode: { type: String, required: true }, + CountryCode: { type: String, default: "" }, ClientType: { type: String }, - CrossPlatformAllowed: { type: Boolean, required: true }, - ForceLogoutVersion: { type: Number, required: true }, + CrossPlatformAllowed: { type: Boolean, default: true }, + ForceLogoutVersion: { type: Number, default: 0 }, AmazonAuthToken: { type: String }, AmazonRefreshToken: { type: String }, - ConsentNeeded: { type: Boolean, required: true }, + ConsentNeeded: { type: Boolean, default: false }, TrackedSettings: { type: [String], default: [] }, Nonce: { type: Number, default: 0 }, BuildLabel: String, diff --git a/src/services/loginService.ts b/src/services/loginService.ts index ccf5b958..44366f5c 100644 --- a/src/services/loginService.ts +++ b/src/services/loginService.ts @@ -18,6 +18,23 @@ export const isNameTaken = async (name: string): Promise => { return !!(await Account.findOne({ DisplayName: name })); }; +export const createNonce = (): number => { + return Math.round(Math.random() * Number.MAX_SAFE_INTEGER); +}; + +export const getUsernameFromEmail = async (email: string): Promise => { + const nameFromEmail = email.substring(0, email.indexOf("@")); + let name = nameFromEmail || email.substring(1) || "SpaceNinja"; + if (await isNameTaken(name)) { + let suffix = 0; + do { + ++suffix; + name = nameFromEmail + suffix; + } while (await isNameTaken(name)); + } + return nameFromEmail; +}; + export const createAccount = async (accountData: IDatabaseAccountRequiredFields): Promise => { const account = new Account(accountData); try { diff --git a/src/services/webService.ts b/src/services/webService.ts index d4d1227c..9c38dc15 100644 --- a/src/services/webService.ts +++ b/src/services/webService.ts @@ -6,6 +6,10 @@ import { logger } from "../utils/logger"; import { app } from "../app"; import { AddressInfo } from "node:net"; import ws from "ws"; +import { Account } from "../models/loginModel"; +import { createAccount, createNonce, getUsernameFromEmail, isCorrectPassword } from "./loginService"; +import { IDatabaseAccountJson } from "../types/loginTypes"; +import { HydratedDocument } from "mongoose"; let httpServer: http.Server | undefined; let httpsServer: https.Server | undefined; @@ -25,7 +29,7 @@ export const startWebServer = (): void => { httpServer = http.createServer(app); httpServer.listen(httpPort, () => { wsServer = new ws.Server({ server: httpServer }); - //wsServer.on("connection", wsOnConnect); + wsServer.on("connection", wsOnConnect); logger.info("HTTP server started on port " + httpPort); @@ -33,7 +37,7 @@ export const startWebServer = (): void => { httpsServer = https.createServer(tlsOptions, app); httpsServer.listen(httpsPort, () => { wssServer = new ws.Server({ server: httpsServer }); - //wssServer.on("connection", wsOnConnect); + wssServer.on("connection", wsOnConnect); logger.info("HTTPS server started on port " + httpsPort); @@ -92,9 +96,68 @@ export const stopWebServer = async (): Promise => { await Promise.all(promises); }; -/*const wsOnConnect = (ws: ws, _req: http.IncomingMessage): void => { - ws.on("message", console.log); -};*/ +interface IWsCustomData extends ws { + accountId: string; +} + +interface IWsMsgFromClient { + auth?: { + email: string; + password: string; + isRegister: boolean; + }; +} + +const wsOnConnect = (ws: ws, _req: http.IncomingMessage): void => { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + ws.on("message", async msg => { + const data = JSON.parse(String(msg)) as IWsMsgFromClient; + if (data.auth) { + let account: IDatabaseAccountJson | null = await Account.findOne({ email: data.auth.email }); + if (account) { + if (isCorrectPassword(data.auth.password, account.password)) { + if (!account.Nonce) { + account.ClientType = "webui"; + account.Nonce = createNonce(); + await (account as HydratedDocument).save(); + } + } else { + account = null; + } + } else if (data.auth.isRegister) { + const name = await getUsernameFromEmail(data.auth.email); + account = await createAccount({ + email: data.auth.email, + password: data.auth.password, + ClientType: "webui", + LastLogin: new Date(), + DisplayName: name, + Nonce: createNonce() + }); + } + if (account) { + (ws as IWsCustomData).accountId = account.id; + ws.send( + JSON.stringify({ + auth_succ: { + id: account.id, + DisplayName: account.DisplayName, + Nonce: account.Nonce + } + }) + ); + } else { + ws.send( + JSON.stringify({ + auth_fail: { + isRegister: data.auth.isRegister + } + }) + ); + } + } + }); +}; export const sendWsBroadcast = (data: T): void => { const msg = JSON.stringify(data); @@ -109,3 +172,21 @@ export const sendWsBroadcast = (data: T): void => { } } }; + +export const sendWsBroadcastTo = (accountId: string, data: T): void => { + const msg = JSON.stringify(data); + if (wsServer) { + for (const client of wsServer.clients) { + if ((client as IWsCustomData).accountId == accountId) { + client.send(msg); + } + } + } + if (wssServer) { + for (const client of wssServer.clients) { + if ((client as IWsCustomData).accountId == accountId) { + client.send(msg); + } + } + } +}; diff --git a/src/types/loginTypes.ts b/src/types/loginTypes.ts index 159d39e9..ef280188 100644 --- a/src/types/loginTypes.ts +++ b/src/types/loginTypes.ts @@ -2,7 +2,7 @@ import { Types } from "mongoose"; export interface IAccountAndLoginResponseCommons { DisplayName: string; - CountryCode: string; + CountryCode?: string; ClientType?: string; CrossPlatformAllowed?: boolean; ForceLogoutVersion?: number; diff --git a/static/webui/script.js b/static/webui/script.js index 6f2731c3..5f693041 100644 --- a/static/webui/script.js +++ b/static/webui/script.js @@ -8,8 +8,28 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ +let auth_pending = false, + did_initial_auth = false; +const sendAuth = isRegister => { + auth_pending = true; + window.ws.send( + JSON.stringify({ + auth: { + email: localStorage.getItem("email"), + password: wp.encSync(localStorage.getItem("password")), + isRegister + } + }) + ); +}; + function openWebSocket() { window.ws = new WebSocket("/custom/ws"); + window.ws.onopen = () => { + if (localStorage.getItem("email") && localStorage.getItem("password")) { + sendAuth(false); + } + }; window.ws.onmessage = e => { const msg = JSON.parse(e.data); if ("ports" in msg) { @@ -21,31 +41,9 @@ function openWebSocket() { single.loadRoute("/webui/cheats"); } } - }; - window.ws.onclose = function () { - setTimeout(openWebSocket, 3000); - }; -} -openWebSocket(); - -let loginOrRegisterPending = false; -window.registerSubmit = false; - -function doLogin() { - if (loginOrRegisterPending) { - return; - } - loginOrRegisterPending = true; - localStorage.setItem("email", $("#email").val()); - localStorage.setItem("password", $("#password").val()); - loginFromLocalStorage(); - registerSubmit = false; -} - -function loginFromLocalStorage() { - const isRegister = registerSubmit; - doLoginRequest( - data => { + if ("auth_succ" in msg) { + auth_pending = false; + const data = msg.auth_succ; if (single.getCurrentPath() == "/webui/") { single.loadRoute("/webui/inventory"); } @@ -55,55 +53,62 @@ function loginFromLocalStorage() { if (window.dict) { updateLocElements(); } - updateInventory(); - }, - () => { - logout(); - alert(loc(isRegister ? "code_regFail" : "code_loginFail")); + if (!did_initial_auth) { + did_initial_auth = true; + updateInventory(); + } } - ); + if ("auth_fail" in msg) { + auth_pending = false; + logout(); + alert(loc(msg.auth_fail.isRegister ? "code_regFail" : "code_loginFail")); + } + if ("logged_out" in msg) { + sendAuth(); + } + }; + window.ws.onclose = function () { + window.ws = undefined; + setTimeout(openWebSocket, 3000); + }; +} +openWebSocket(); + +function getWebSocket() { + return new Promise(resolve => { + let interval; + interval = setInterval(() => { + if (window.ws) { + clearInterval(interval); + resolve(window.ws); + } + }, 10); + }); } -function doLoginRequest(succ_cb, fail_cb) { - const req = $.post({ - url: "/api/login.php", - contentType: "text/plain", - data: JSON.stringify({ - email: localStorage.getItem("email").toLowerCase(), - password: wp.encSync(localStorage.getItem("password"), "hex"), - time: parseInt(new Date() / 1000), - s: "W0RFXVN0ZXZlIGxpa2VzIGJpZyBidXR0cw==", // signature of some kind - lang: "en", - // eslint-disable-next-line no-loss-of-precision - date: 1501230947855458660, // ??? - ClientType: registerSubmit ? "webui-register" : "webui", - PS: "W0RFXVN0ZXZlIGxpa2VzIGJpZyBidXR0cw==" // anti-cheat data - }) - }); - req.done(succ_cb); - req.fail(fail_cb); - req.always(() => { - loginOrRegisterPending = false; - }); +window.registerSubmit = false; + +function doLogin() { + if (auth_pending) { + return; + } + localStorage.setItem("email", $("#email").val()); + localStorage.setItem("password", $("#password").val()); + sendAuth(registerSubmit); + window.registerSubmit = false; } function revalidateAuthz(succ_cb) { - return doLoginRequest( - data => { - window.authz = "accountId=" + data.id + "&nonce=" + data.Nonce; - succ_cb(); - }, - () => { - logout(); - alert(loc("code_nonValidAuthz")); - single.loadRoute("/webui/"); // Show login screen - } - ); + getWebSocket().then(() => { + // We have a websocket connection, so authz should be good. + succ_cb(); + }); } function logout() { localStorage.removeItem("email"); localStorage.removeItem("password"); + did_initial_auth = false; } function renameAccount() { @@ -129,10 +134,6 @@ function deleteAccount() { } } -if (localStorage.getItem("email") && localStorage.getItem("password")) { - loginFromLocalStorage(); -} - single.on("route_load", function (event) { if (event.route.paths[0] != "/webui/") { // Authorised route?