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?