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: #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:
parent
93ef9a5348
commit
2fa6dcc7ed
@ -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,22 +68,11 @@ 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;
|
||||
}
|
||||
} 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) {
|
||||
@ -105,13 +82,15 @@ export const loginController: RequestHandler = async (request, response) => {
|
||||
}
|
||||
|
||||
account.ClientType = loginRequest.ClientType;
|
||||
account.Nonce = nonce;
|
||||
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));
|
||||
};
|
||||
|
||||
|
@ -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",
|
||||
|
@ -11,13 +11,13 @@ const databaseAccountSchema = new Schema<IDatabaseAccountJson>(
|
||||
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,
|
||||
|
@ -18,6 +18,23 @@ export const isNameTaken = async (name: string): Promise<boolean> => {
|
||||
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> => {
|
||||
const account = new Account(accountData);
|
||||
try {
|
||||
|
@ -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,11 +96,91 @@ export const stopWebServer = async (): Promise<void> => {
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
/*const wsOnConnect = (ws: ws, _req: http.IncomingMessage): void => {
|
||||
ws.on("message", console.log);
|
||||
};*/
|
||||
interface IWsCustomData extends ws {
|
||||
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);
|
||||
if (wsServer) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -2,7 +2,7 @@ import { Types } from "mongoose";
|
||||
|
||||
export interface IAccountAndLoginResponseCommons {
|
||||
DisplayName: string;
|
||||
CountryCode: string;
|
||||
CountryCode?: string;
|
||||
ClientType?: string;
|
||||
CrossPlatformAllowed?: boolean;
|
||||
ForceLogoutVersion?: number;
|
||||
|
@ -37,7 +37,7 @@
|
||||
<li class="nav-item dropdown user-dropdown">
|
||||
<button class="nav-link dropdown-toggle displayname" data-bs-toggle="dropdown" aria-expanded="false"></button>
|
||||
<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><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>
|
||||
|
@ -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 => {
|
||||
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() {
|
||||
window.ws = new WebSocket("/custom/ws");
|
||||
window.ws.onopen = () => {
|
||||
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,74 @@ function loginFromLocalStorage() {
|
||||
if (window.dict) {
|
||||
updateLocElements();
|
||||
}
|
||||
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) {
|
||||
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;
|
||||
getWebSocket().then(() => {
|
||||
// We have a websocket connection, so authz should be good.
|
||||
succ_cb();
|
||||
},
|
||||
() => {
|
||||
logout();
|
||||
alert(loc("code_nonValidAuthz"));
|
||||
single.loadRoute("/webui/"); // Show login screen
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem("email");
|
||||
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() {
|
||||
@ -129,10 +146,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?
|
||||
|
Loading…
x
Reference in New Issue
Block a user