feat(webui): handle auth via websocket (#2226)

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.

Closes #2223

Reviewed-on: OpenWF/SpaceNinjaServer#2226
Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com>
Co-committed-by: Sainan <63328889+Sainan@users.noreply.github.com>
This commit is contained in:
Sainan 2025-06-21 07:26:43 -07:00 committed by Sainan
parent 93ef9a5348
commit 2fa6dcc7ed
8 changed files with 236 additions and 120 deletions

View File

@ -4,16 +4,16 @@ import { config } from "@/src/services/configService";
import { buildConfig } from "@/src/services/buildConfigService"; import { buildConfig } from "@/src/services/buildConfigService";
import { Account } from "@/src/models/loginModel"; 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 { IDatabaseAccountJson, ILoginRequest, ILoginResponse } from "@/src/types/loginTypes";
import { logger } from "@/src/utils/logger"; import { logger } from "@/src/utils/logger";
import { version_compare } from "@/src/helpers/inventoryHelpers"; import { version_compare } from "@/src/helpers/inventoryHelpers";
import { sendWsBroadcastTo } from "@/src/services/webService";
export const loginController: RequestHandler = async (request, response) => { 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 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 account = await Account.findOne({ email: loginRequest.email });
const nonce = Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
const buildLabel: string = const buildLabel: string =
typeof request.query.buildLabel == "string" typeof request.query.buildLabel == "string"
@ -42,26 +42,14 @@ export const loginController: RequestHandler = async (request, response) => {
loginRequest.ClientType == "webui-register") loginRequest.ClientType == "webui-register")
) { ) {
try { try {
const nameFromEmail = loginRequest.email.substring(0, loginRequest.email.indexOf("@")); const name = await getUsernameFromEmail(loginRequest.email);
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 newAccount = await createAccount({ const newAccount = await createAccount({
email: loginRequest.email, email: loginRequest.email,
password: loginRequest.password, password: loginRequest.password,
DisplayName: name, DisplayName: name,
CountryCode: loginRequest.lang?.toUpperCase() ?? "EN", CountryCode: loginRequest.lang?.toUpperCase() ?? "EN",
ClientType: loginRequest.ClientType == "webui-register" ? "webui" : loginRequest.ClientType, ClientType: loginRequest.ClientType,
CrossPlatformAllowed: true, Nonce: createNonce(),
ForceLogoutVersion: 0,
ConsentNeeded: false,
TrackedSettings: [],
Nonce: nonce,
BuildLabel: buildLabel, BuildLabel: buildLabel,
LastLogin: new Date() LastLogin: new Date()
}); });
@ -80,38 +68,29 @@ export const loginController: RequestHandler = async (request, response) => {
return; return;
} }
if (loginRequest.ClientType == "webui-register") {
response.status(400).json({ error: "account already exists" });
return;
}
if (!isCorrectPassword(loginRequest.password, account.password)) { if (!isCorrectPassword(loginRequest.password, account.password)) {
response.status(400).json({ error: "incorrect login data" }); response.status(400).json({ error: "incorrect login data" });
return; return;
} }
if (loginRequest.ClientType == "webui") { if (account.Nonce && account.ClientType != "webui" && !account.Dropped && !loginRequest.kick) {
if (!account.Nonce) { // U17 seems to handle "nonce still set" like a login failure.
account.ClientType = "webui"; if (version_compare(buildLabel, "2015.12.05.18.07") >= 0) {
account.Nonce = nonce; 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(); 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)); response.json(createLoginResponse(myAddress, myUrlBase, account.toJSON(), buildLabel));
}; };

View File

@ -1,5 +1,6 @@
import { RequestHandler } from "express"; import { RequestHandler } from "express";
import { Account } from "@/src/models/loginModel"; import { Account } from "@/src/models/loginModel";
import { sendWsBroadcastTo } from "@/src/services/webService";
export const logoutController: RequestHandler = async (req, res) => { export const logoutController: RequestHandler = async (req, res) => {
if (!req.query.accountId) { if (!req.query.accountId) {
@ -10,7 +11,7 @@ export const logoutController: RequestHandler = async (req, res) => {
throw new Error("Request is missing nonce parameter"); throw new Error("Request is missing nonce parameter");
} }
await Account.updateOne( const stat = await Account.updateOne(
{ {
_id: req.query.accountId, _id: req.query.accountId,
Nonce: nonce Nonce: nonce
@ -19,6 +20,10 @@ export const logoutController: RequestHandler = async (req, res) => {
Nonce: 0 Nonce: 0
} }
); );
if (stat.modifiedCount) {
// Tell WebUI its nonce has been invalidated
sendWsBroadcastTo(req.query.accountId as string, { logged_out: true });
}
res.writeHead(200, { res.writeHead(200, {
"Content-Type": "text/html", "Content-Type": "text/html",

View File

@ -11,13 +11,13 @@ const databaseAccountSchema = new Schema<IDatabaseAccountJson>(
email: { type: String, required: true, unique: true }, email: { type: String, required: true, unique: true },
password: { type: String, required: true }, password: { type: String, required: true },
DisplayName: { type: String, required: true, unique: true }, DisplayName: { type: String, required: true, unique: true },
CountryCode: { type: String, required: true }, CountryCode: { type: String, default: "" },
ClientType: { type: String }, ClientType: { type: String },
CrossPlatformAllowed: { type: Boolean, required: true }, CrossPlatformAllowed: { type: Boolean, default: true },
ForceLogoutVersion: { type: Number, required: true }, ForceLogoutVersion: { type: Number, default: 0 },
AmazonAuthToken: { type: String }, AmazonAuthToken: { type: String },
AmazonRefreshToken: { type: String }, AmazonRefreshToken: { type: String },
ConsentNeeded: { type: Boolean, required: true }, ConsentNeeded: { type: Boolean, default: false },
TrackedSettings: { type: [String], default: [] }, TrackedSettings: { type: [String], default: [] },
Nonce: { type: Number, default: 0 }, Nonce: { type: Number, default: 0 },
BuildLabel: String, BuildLabel: String,

View File

@ -18,6 +18,23 @@ export const isNameTaken = async (name: string): Promise<boolean> => {
return !!(await Account.findOne({ DisplayName: name })); 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<string> => {
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<IDatabaseAccountJson> => { export const createAccount = async (accountData: IDatabaseAccountRequiredFields): Promise<IDatabaseAccountJson> => {
const account = new Account(accountData); const account = new Account(accountData);
try { try {

View File

@ -6,6 +6,10 @@ import { logger } from "../utils/logger";
import { app } from "../app"; import { app } from "../app";
import { AddressInfo } from "node:net"; import { AddressInfo } from "node:net";
import ws from "ws"; 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 httpServer: http.Server | undefined;
let httpsServer: https.Server | undefined; let httpsServer: https.Server | undefined;
@ -25,7 +29,7 @@ export const startWebServer = (): void => {
httpServer = http.createServer(app); httpServer = http.createServer(app);
httpServer.listen(httpPort, () => { httpServer.listen(httpPort, () => {
wsServer = new ws.Server({ server: httpServer }); wsServer = new ws.Server({ server: httpServer });
//wsServer.on("connection", wsOnConnect); wsServer.on("connection", wsOnConnect);
logger.info("HTTP server started on port " + httpPort); logger.info("HTTP server started on port " + httpPort);
@ -33,7 +37,7 @@ export const startWebServer = (): void => {
httpsServer = https.createServer(tlsOptions, app); httpsServer = https.createServer(tlsOptions, app);
httpsServer.listen(httpsPort, () => { httpsServer.listen(httpsPort, () => {
wssServer = new ws.Server({ server: httpsServer }); wssServer = new ws.Server({ server: httpsServer });
//wssServer.on("connection", wsOnConnect); wssServer.on("connection", wsOnConnect);
logger.info("HTTPS server started on port " + httpsPort); logger.info("HTTPS server started on port " + httpsPort);
@ -92,11 +96,91 @@ export const stopWebServer = async (): Promise<void> => {
await Promise.all(promises); await Promise.all(promises);
}; };
/*const wsOnConnect = (ws: ws, _req: http.IncomingMessage): void => { interface IWsCustomData extends ws {
ws.on("message", console.log); accountId?: string;
};*/ }
export const sendWsBroadcast = <T>(data: T): void => { interface IWsMsgFromClient {
auth?: {
email: string;
password: string;
isRegister: boolean;
};
logout?: boolean;
}
interface IWsMsgToClient {
ports?: {
http: number | undefined;
https: number | undefined;
};
config_reloaded?: boolean;
auth_succ?: {
id: string;
DisplayName: string;
Nonce: number;
};
auth_fail?: {
isRegister: boolean;
};
logged_out?: 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<IDatabaseAccountJson>).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
}
} satisfies IWsMsgToClient)
);
} else {
ws.send(
JSON.stringify({
auth_fail: {
isRegister: data.auth.isRegister
}
} satisfies IWsMsgToClient)
);
}
}
if (data.logout) {
(ws as IWsCustomData).accountId = undefined;
}
});
};
export const sendWsBroadcast = (data: IWsMsgToClient): void => {
const msg = JSON.stringify(data); const msg = JSON.stringify(data);
if (wsServer) { if (wsServer) {
for (const client of wsServer.clients) { for (const client of wsServer.clients) {
@ -109,3 +193,21 @@ export const sendWsBroadcast = <T>(data: T): void => {
} }
} }
}; };
export const sendWsBroadcastTo = (accountId: string, data: IWsMsgToClient): 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);
}
}
}
};

View File

@ -2,7 +2,7 @@ import { Types } from "mongoose";
export interface IAccountAndLoginResponseCommons { export interface IAccountAndLoginResponseCommons {
DisplayName: string; DisplayName: string;
CountryCode: string; CountryCode?: string;
ClientType?: string; ClientType?: string;
CrossPlatformAllowed?: boolean; CrossPlatformAllowed?: boolean;
ForceLogoutVersion?: number; ForceLogoutVersion?: number;

View File

@ -37,7 +37,7 @@
<li class="nav-item dropdown user-dropdown"> <li class="nav-item dropdown user-dropdown">
<button class="nav-link dropdown-toggle displayname" data-bs-toggle="dropdown" aria-expanded="false"></button> <button class="nav-link dropdown-toggle displayname" data-bs-toggle="dropdown" aria-expanded="false"></button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/webui/" onclick="logout();" data-loc="navbar_logout"></a></li> <li><a class="dropdown-item" href="/webui/" onclick="doLogout();" data-loc="navbar_logout"></a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="event.preventDefault();renameAccount();" data-loc="navbar_renameAccount"></a></li> <li><a class="dropdown-item" href="#" onclick="event.preventDefault();renameAccount();" data-loc="navbar_renameAccount"></a></li>
<li><a class="dropdown-item" href="#" onclick="event.preventDefault();deleteAccount();" data-loc="navbar_deleteAccount"></a></li> <li><a class="dropdown-item" href="#" onclick="event.preventDefault();deleteAccount();" data-loc="navbar_deleteAccount"></a></li>

View File

@ -8,8 +8,28 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/explicit-function-return-type */
let auth_pending = false,
did_initial_auth = false;
const sendAuth = isRegister => {
if (localStorage.getItem("email") && localStorage.getItem("password")) {
auth_pending = true;
window.ws.send(
JSON.stringify({
auth: {
email: localStorage.getItem("email"),
password: wp.encSync(localStorage.getItem("password")),
isRegister
}
})
);
}
};
function openWebSocket() { function openWebSocket() {
window.ws = new WebSocket("/custom/ws"); window.ws = new WebSocket("/custom/ws");
window.ws.onopen = () => {
sendAuth(false);
};
window.ws.onmessage = e => { window.ws.onmessage = e => {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if ("ports" in msg) { if ("ports" in msg) {
@ -21,31 +41,9 @@ function openWebSocket() {
single.loadRoute("/webui/cheats"); single.loadRoute("/webui/cheats");
} }
} }
}; if ("auth_succ" in msg) {
window.ws.onclose = function () { auth_pending = false;
setTimeout(openWebSocket, 3000); const data = msg.auth_succ;
};
}
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 (single.getCurrentPath() == "/webui/") { if (single.getCurrentPath() == "/webui/") {
single.loadRoute("/webui/inventory"); single.loadRoute("/webui/inventory");
} }
@ -55,55 +53,74 @@ function loginFromLocalStorage() {
if (window.dict) { if (window.dict) {
updateLocElements(); updateLocElements();
} }
updateInventory(); if (!did_initial_auth) {
}, did_initial_auth = true;
() => { updateInventory();
logout(); }
alert(loc(isRegister ? "code_regFail" : "code_loginFail"));
} }
); if ("auth_fail" in msg) {
auth_pending = false;
logout();
if (single.getCurrentPath() == "/webui/") {
alert(loc(msg.auth_fail.isRegister ? "code_regFail" : "code_loginFail"));
} else {
single.loadRoute("/webui/");
}
}
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) { window.registerSubmit = false;
const req = $.post({
url: "/api/login.php", function doLogin() {
contentType: "text/plain", if (auth_pending) {
data: JSON.stringify({ return;
email: localStorage.getItem("email").toLowerCase(), }
password: wp.encSync(localStorage.getItem("password"), "hex"), localStorage.setItem("email", $("#email").val());
time: parseInt(new Date() / 1000), localStorage.setItem("password", $("#password").val());
s: "W0RFXVN0ZXZlIGxpa2VzIGJpZyBidXR0cw==", // signature of some kind sendAuth(registerSubmit);
lang: "en", window.registerSubmit = false;
// 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;
});
} }
function revalidateAuthz(succ_cb) { function revalidateAuthz(succ_cb) {
return doLoginRequest( getWebSocket().then(() => {
data => { // We have a websocket connection, so authz should be good.
window.authz = "accountId=" + data.id + "&nonce=" + data.Nonce; succ_cb();
succ_cb(); });
},
() => {
logout();
alert(loc("code_nonValidAuthz"));
single.loadRoute("/webui/"); // Show login screen
}
);
} }
function logout() { function logout() {
localStorage.removeItem("email"); localStorage.removeItem("email");
localStorage.removeItem("password"); localStorage.removeItem("password");
did_initial_auth = false;
}
function doLogout() {
logout();
if (window.ws) {
// Unsubscribe from notifications about nonce invalidation
window.ws.send(JSON.stringify({ logout: true }));
}
} }
function renameAccount() { function renameAccount() {
@ -129,10 +146,6 @@ function deleteAccount() {
} }
} }
if (localStorage.getItem("email") && localStorage.getItem("password")) {
loginFromLocalStorage();
}
single.on("route_load", function (event) { single.on("route_load", function (event) {
if (event.route.paths[0] != "/webui/") { if (event.route.paths[0] != "/webui/") {
// Authorised route? // Authorised route?