XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

SpaceNinjaServer

A simple server for a small space ninja game

公开
关注 0 Fork 1 Star 0
返回提交历史

XFEstudio/SpaceNinjaServer

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: https://onlyg.it/OpenWF/SpaceNinjaServer/pulls/2226 Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com> Co-committed-by: Sainan <63328889+Sainan@users.noreply.github.com>

2fa6dcc7
Sainan <63328889+Sainan@users.noreply.github.com>
提交于

代码差异

8 个文件 +234 -118
Modified src/controllers/api/loginController.ts +19 -40
@@ -4,16 +4,16 @@ import { config } from "@/src/services/configService";
4 4 import { buildConfig } from "@/src/services/buildConfigService";
5 5
6 6 import { Account } from "@/src/models/loginModel";
7 import { createAccount, isCorrectPassword, isNameTaken } from "@/src/services/loginService";
7 import { createAccount, createNonce, getUsernameFromEmail, isCorrectPassword } from "@/src/services/loginService";
8 8 import { IDatabaseAccountJson, ILoginRequest, ILoginResponse } from "@/src/types/loginTypes";
9 9 import { logger } from "@/src/utils/logger";
10 10 import { version_compare } from "@/src/helpers/inventoryHelpers";
11 import { sendWsBroadcastTo } from "@/src/services/webService";
11 12
12 13 export const loginController: RequestHandler = async (request, response) => {
13 14 const loginRequest = JSON.parse(String(request.body)) as ILoginRequest; // parse octet stream of json data to json object
14 15
15 16 const account = await Account.findOne({ email: loginRequest.email });
16 const nonce = Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
17 17
18 18 const buildLabel: string =
19 19 typeof request.query.buildLabel == "string"
@@ -42,26 +42,14 @@ export const loginController: RequestHandler = async (request, response) => {
42 42 loginRequest.ClientType == "webui-register")
43 43 ) {
44 44 try {
45 const nameFromEmail = loginRequest.email.substring(0, loginRequest.email.indexOf("@"));
46 let name = nameFromEmail || loginRequest.email.substring(1) || "SpaceNinja";
47 if (await isNameTaken(name)) {
48 let suffix = 0;
49 do {
50 ++suffix;
51 name = nameFromEmail + suffix;
52 } while (await isNameTaken(name));
53 }
45 const name = await getUsernameFromEmail(loginRequest.email);
54 46 const newAccount = await createAccount({
55 47 email: loginRequest.email,
56 48 password: loginRequest.password,
57 49 DisplayName: name,
58 50 CountryCode: loginRequest.lang?.toUpperCase() ?? "EN",
59 ClientType: loginRequest.ClientType == "webui-register" ? "webui" : loginRequest.ClientType,
60 CrossPlatformAllowed: true,
61 ForceLogoutVersion: 0,
62 ConsentNeeded: false,
63 TrackedSettings: [],
64 Nonce: nonce,
51 ClientType: loginRequest.ClientType,
52 Nonce: createNonce(),
65 53 BuildLabel: buildLabel,
66 54 LastLogin: new Date()
67 55 });
@@ -80,38 +68,29 @@ export const loginController: RequestHandler = async (request, response) => {
80 68 return;
81 69 }
82 70
83 if (loginRequest.ClientType == "webui-register") {
84 response.status(400).json({ error: "account already exists" });
85 return;
86 }
87
88 71 if (!isCorrectPassword(loginRequest.password, account.password)) {
89 72 response.status(400).json({ error: "incorrect login data" });
90 73 return;
91 74 }
92 75
93 if (loginRequest.ClientType == "webui") {
94 if (!account.Nonce) {
95 account.ClientType = "webui";
96 account.Nonce = nonce;
97 }
98 } else {
99 if (account.Nonce && account.ClientType != "webui" && !account.Dropped && !loginRequest.kick) {
100 // U17 seems to handle "nonce still set" like a login failure.
101 if (version_compare(buildLabel, "2015.12.05.18.07") >= 0) {
102 response.status(400).send({ error: "nonce still set" });
103 return;
104 }
76 if (account.Nonce && account.ClientType != "webui" && !account.Dropped && !loginRequest.kick) {
77 // U17 seems to handle "nonce still set" like a login failure.
78 if (version_compare(buildLabel, "2015.12.05.18.07") >= 0) {
79 response.status(400).send({ error: "nonce still set" });
80 return;
105 81 }
106
107 account.ClientType = loginRequest.ClientType;
108 account.Nonce = nonce;
109 account.CountryCode = loginRequest.lang?.toUpperCase() ?? "EN";
110 account.BuildLabel = buildLabel;
111 account.LastLogin = new Date();
112 82 }
83
84 account.ClientType = loginRequest.ClientType;
85 account.Nonce = createNonce();
86 account.CountryCode = loginRequest.lang?.toUpperCase() ?? "EN";
87 account.BuildLabel = buildLabel;
88 account.LastLogin = new Date();
113 89 await account.save();
114 90
91 // Tell WebUI its nonce has been invalidated
92 sendWsBroadcastTo(account._id.toString(), { logged_out: true });
93
115 94 response.json(createLoginResponse(myAddress, myUrlBase, account.toJSON(), buildLabel));
116 95 };
117 96
Modified src/controllers/api/logoutController.ts +6 -1
@@ -1,5 +1,6 @@
1 1 import { RequestHandler } from "express";
2 2 import { Account } from "@/src/models/loginModel";
3 import { sendWsBroadcastTo } from "@/src/services/webService";
3 4
4 5 export const logoutController: RequestHandler = async (req, res) => {
5 6 if (!req.query.accountId) {
@@ -10,7 +11,7 @@ export const logoutController: RequestHandler = async (req, res) => {
10 11 throw new Error("Request is missing nonce parameter");
11 12 }
12 13
13 await Account.updateOne(
14 const stat = await Account.updateOne(
14 15 {
15 16 _id: req.query.accountId,
16 17 Nonce: nonce
@@ -19,6 +20,10 @@ export const logoutController: RequestHandler = async (req, res) => {
19 20 Nonce: 0
20 21 }
21 22 );
23 if (stat.modifiedCount) {
24 // Tell WebUI its nonce has been invalidated
25 sendWsBroadcastTo(req.query.accountId as string, { logged_out: true });
26 }
22 27
23 28 res.writeHead(200, {
24 29 "Content-Type": "text/html",
Modified src/models/loginModel.ts +4 -4
@@ -11,13 +11,13 @@ const databaseAccountSchema = new Schema<IDatabaseAccountJson>(
11 11 email: { type: String, required: true, unique: true },
12 12 password: { type: String, required: true },
13 13 DisplayName: { type: String, required: true, unique: true },
14 CountryCode: { type: String, required: true },
14 CountryCode: { type: String, default: "" },
15 15 ClientType: { type: String },
16 CrossPlatformAllowed: { type: Boolean, required: true },
17 ForceLogoutVersion: { type: Number, required: true },
16 CrossPlatformAllowed: { type: Boolean, default: true },
17 ForceLogoutVersion: { type: Number, default: 0 },
18 18 AmazonAuthToken: { type: String },
19 19 AmazonRefreshToken: { type: String },
20 ConsentNeeded: { type: Boolean, required: true },
20 ConsentNeeded: { type: Boolean, default: false },
21 21 TrackedSettings: { type: [String], default: [] },
22 22 Nonce: { type: Number, default: 0 },
23 23 BuildLabel: String,
Modified src/services/loginService.ts +17 -0
@@ -18,6 +18,23 @@ export const isNameTaken = async (name: string): Promise<boolean> => {
18 18 return !!(await Account.findOne({ DisplayName: name }));
19 19 };
20 20
21 export const createNonce = (): number => {
22 return Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
23 };
24
25 export const getUsernameFromEmail = async (email: string): Promise<string> => {
26 const nameFromEmail = email.substring(0, email.indexOf("@"));
27 let name = nameFromEmail || email.substring(1) || "SpaceNinja";
28 if (await isNameTaken(name)) {
29 let suffix = 0;
30 do {
31 ++suffix;
32 name = nameFromEmail + suffix;
33 } while (await isNameTaken(name));
34 }
35 return nameFromEmail;
36 };
37
21 38 export const createAccount = async (accountData: IDatabaseAccountRequiredFields): Promise<IDatabaseAccountJson> => {
22 39 const account = new Account(accountData);
23 40 try {
Modified src/services/webService.ts +108 -6
@@ -6,6 +6,10 @@ import { logger } from "../utils/logger";
6 6 import { app } from "../app";
7 7 import { AddressInfo } from "node:net";
8 8 import ws from "ws";
9 import { Account } from "../models/loginModel";
10 import { createAccount, createNonce, getUsernameFromEmail, isCorrectPassword } from "./loginService";
11 import { IDatabaseAccountJson } from "../types/loginTypes";
12 import { HydratedDocument } from "mongoose";
9 13
10 14 let httpServer: http.Server | undefined;
11 15 let httpsServer: https.Server | undefined;
@@ -25,7 +29,7 @@ export const startWebServer = (): void => {
25 29 httpServer = http.createServer(app);
26 30 httpServer.listen(httpPort, () => {
27 31 wsServer = new ws.Server({ server: httpServer });
28 //wsServer.on("connection", wsOnConnect);
32 wsServer.on("connection", wsOnConnect);
29 33
30 34 logger.info("HTTP server started on port " + httpPort);
31 35
@@ -33,7 +37,7 @@ export const startWebServer = (): void => {
33 37 httpsServer = https.createServer(tlsOptions, app);
34 38 httpsServer.listen(httpsPort, () => {
35 39 wssServer = new ws.Server({ server: httpsServer });
36 //wssServer.on("connection", wsOnConnect);
40 wssServer.on("connection", wsOnConnect);
37 41
38 42 logger.info("HTTPS server started on port " + httpsPort);
39 43
@@ -92,11 +96,91 @@ export const stopWebServer = async (): Promise<void> => {
92 96 await Promise.all(promises);
93 97 };
94 98
95 /*const wsOnConnect = (ws: ws, _req: http.IncomingMessage): void => {
96 ws.on("message", console.log);
97 };*/
99 interface IWsCustomData extends ws {
100 accountId?: string;
101 }
98 102
99 export const sendWsBroadcast = <T>(data: T): void => {
103 interface IWsMsgFromClient {
104 auth?: {
105 email: string;
106 password: string;
107 isRegister: boolean;
108 };
109 logout?: boolean;
110 }
111
112 interface IWsMsgToClient {
113 ports?: {
114 http: number | undefined;
115 https: number | undefined;
116 };
117 config_reloaded?: boolean;
118 auth_succ?: {
119 id: string;
120 DisplayName: string;
121 Nonce: number;
122 };
123 auth_fail?: {
124 isRegister: boolean;
125 };
126 logged_out?: boolean;
127 }
128
129 const wsOnConnect = (ws: ws, _req: http.IncomingMessage): void => {
130 // eslint-disable-next-line @typescript-eslint/no-misused-promises
131 ws.on("message", async msg => {
132 const data = JSON.parse(String(msg)) as IWsMsgFromClient;
133 if (data.auth) {
134 let account: IDatabaseAccountJson | null = await Account.findOne({ email: data.auth.email });
135 if (account) {
136 if (isCorrectPassword(data.auth.password, account.password)) {
137 if (!account.Nonce) {
138 account.ClientType = "webui";
139 account.Nonce = createNonce();
140 await (account as HydratedDocument<IDatabaseAccountJson>).save();
141 }
142 } else {
143 account = null;
144 }
145 } else if (data.auth.isRegister) {
146 const name = await getUsernameFromEmail(data.auth.email);
147 account = await createAccount({
148 email: data.auth.email,
149 password: data.auth.password,
150 ClientType: "webui",
151 LastLogin: new Date(),
152 DisplayName: name,
153 Nonce: createNonce()
154 });
155 }
156 if (account) {
157 (ws as IWsCustomData).accountId = account.id;
158 ws.send(
159 JSON.stringify({
160 auth_succ: {
161 id: account.id,
162 DisplayName: account.DisplayName,
163 Nonce: account.Nonce
164 }
165 } satisfies IWsMsgToClient)
166 );
167 } else {
168 ws.send(
169 JSON.stringify({
170 auth_fail: {
171 isRegister: data.auth.isRegister
172 }
173 } satisfies IWsMsgToClient)
174 );
175 }
176 }
177 if (data.logout) {
178 (ws as IWsCustomData).accountId = undefined;
179 }
180 });
181 };
182
183 export const sendWsBroadcast = (data: IWsMsgToClient): void => {
100 184 const msg = JSON.stringify(data);
101 185 if (wsServer) {
102 186 for (const client of wsServer.clients) {
@@ -109,3 +193,21 @@ export const sendWsBroadcast = <T>(data: T): void => {
109 193 }
110 194 }
111 195 };
196
197 export const sendWsBroadcastTo = (accountId: string, data: IWsMsgToClient): void => {
198 const msg = JSON.stringify(data);
199 if (wsServer) {
200 for (const client of wsServer.clients) {
201 if ((client as IWsCustomData).accountId == accountId) {
202 client.send(msg);
203 }
204 }
205 }
206 if (wssServer) {
207 for (const client of wssServer.clients) {
208 if ((client as IWsCustomData).accountId == accountId) {
209 client.send(msg);
210 }
211 }
212 }
213 };
Modified src/types/loginTypes.ts +1 -1
@@ -2,7 +2,7 @@ import { Types } from "mongoose";
2 2
3 3 export interface IAccountAndLoginResponseCommons {
4 4 DisplayName: string;
5 CountryCode: string;
5 CountryCode?: string;
6 6 ClientType?: string;
7 7 CrossPlatformAllowed?: boolean;
8 8 ForceLogoutVersion?: number;
Modified static/webui/index.html +1 -1
@@ -37,7 +37,7 @@
37 37 <li class="nav-item dropdown user-dropdown">
38 38 <button class="nav-link dropdown-toggle displayname" data-bs-toggle="dropdown" aria-expanded="false"></button>
39 39 <ul class="dropdown-menu dropdown-menu-end">
40 <li><a class="dropdown-item" href="/webui/" onclick="logout();" data-loc="navbar_logout"></a></li>
40 <li><a class="dropdown-item" href="/webui/" onclick="doLogout();" data-loc="navbar_logout"></a></li>
41 41 <li><hr class="dropdown-divider"></li>
42 42 <li><a class="dropdown-item" href="#" onclick="event.preventDefault();renameAccount();" data-loc="navbar_renameAccount"></a></li>
43 43 <li><a class="dropdown-item" href="#" onclick="event.preventDefault();deleteAccount();" data-loc="navbar_deleteAccount"></a></li>
Modified static/webui/script.js +78 -65
@@ -8,8 +8,28 @@
8 8 /* eslint-disable @typescript-eslint/no-unsafe-member-access */
9 9 /* eslint-disable @typescript-eslint/explicit-function-return-type */
10 10
11 let auth_pending = false,
12 did_initial_auth = false;
13 const sendAuth = isRegister => {
14 if (localStorage.getItem("email") && localStorage.getItem("password")) {
15 auth_pending = true;
16 window.ws.send(
17 JSON.stringify({
18 auth: {
19 email: localStorage.getItem("email"),
20 password: wp.encSync(localStorage.getItem("password")),
21 isRegister
22 }
23 })
24 );
25 }
26 };
27
11 28 function openWebSocket() {
12 29 window.ws = new WebSocket("/custom/ws");
30 window.ws.onopen = () => {
31 sendAuth(false);
32 };
13 33 window.ws.onmessage = e => {
14 34 const msg = JSON.parse(e.data);
15 35 if ("ports" in msg) {
@@ -21,31 +41,9 @@ function openWebSocket() {
21 41 single.loadRoute("/webui/cheats");
22 42 }
23 43 }
24 };
25 window.ws.onclose = function () {
26 setTimeout(openWebSocket, 3000);
27 };
28 }
29 openWebSocket();
30
31 let loginOrRegisterPending = false;
32 window.registerSubmit = false;
33
34 function doLogin() {
35 if (loginOrRegisterPending) {
36 return;
37 }
38 loginOrRegisterPending = true;
39 localStorage.setItem("email", $("#email").val());
40 localStorage.setItem("password", $("#password").val());
41 loginFromLocalStorage();
42 registerSubmit = false;
43 }
44
45 function loginFromLocalStorage() {
46 const isRegister = registerSubmit;
47 doLoginRequest(
48 data => {
44 if ("auth_succ" in msg) {
45 auth_pending = false;
46 const data = msg.auth_succ;
49 47 if (single.getCurrentPath() == "/webui/") {
50 48 single.loadRoute("/webui/inventory");
51 49 }
@@ -55,55 +53,74 @@ function loginFromLocalStorage() {
55 53 if (window.dict) {
56 54 updateLocElements();
57 55 }
58 updateInventory();
59 },
60 () => {
56 if (!did_initial_auth) {
57 did_initial_auth = true;
58 updateInventory();
59 }
60 }
61 if ("auth_fail" in msg) {
62 auth_pending = false;
61 63 logout();
62 alert(loc(isRegister ? "code_regFail" : "code_loginFail"));
64 if (single.getCurrentPath() == "/webui/") {
65 alert(loc(msg.auth_fail.isRegister ? "code_regFail" : "code_loginFail"));
66 } else {
67 single.loadRoute("/webui/");
68 }
63 69 }
64 );
70 if ("logged_out" in msg) {
71 sendAuth();
72 }
73 };
74 window.ws.onclose = function () {
75 window.ws = undefined;
76 setTimeout(openWebSocket, 3000);
77 };
65 78 }
79 openWebSocket();
66 80
67 function doLoginRequest(succ_cb, fail_cb) {
68 const req = $.post({
69 url: "/api/login.php",
70 contentType: "text/plain",
71 data: JSON.stringify({
72 email: localStorage.getItem("email").toLowerCase(),
73 password: wp.encSync(localStorage.getItem("password"), "hex"),
74 time: parseInt(new Date() / 1000),
75 s: "W0RFXVN0ZXZlIGxpa2VzIGJpZyBidXR0cw==", // signature of some kind
76 lang: "en",
77 // eslint-disable-next-line no-loss-of-precision
78 date: 1501230947855458660, // ???
79 ClientType: registerSubmit ? "webui-register" : "webui",
80 PS: "W0RFXVN0ZXZlIGxpa2VzIGJpZyBidXR0cw==" // anti-cheat data
81 })
82 });
83 req.done(succ_cb);
84 req.fail(fail_cb);
85 req.always(() => {
86 loginOrRegisterPending = false;
81 function getWebSocket() {
82 return new Promise(resolve => {
83 let interval;
84 interval = setInterval(() => {
85 if (window.ws) {
86 clearInterval(interval);
87 resolve(window.ws);
88 }
89 }, 10);
87 90 });
88 91 }
89 92
93 window.registerSubmit = false;
94
95 function doLogin() {
96 if (auth_pending) {
97 return;
98 }
99 localStorage.setItem("email", $("#email").val());
100 localStorage.setItem("password", $("#password").val());
101 sendAuth(registerSubmit);
102 window.registerSubmit = false;
103 }
104
90 105 function revalidateAuthz(succ_cb) {
91 return doLoginRequest(
92 data => {
93 window.authz = "accountId=" + data.id + "&nonce=" + data.Nonce;
94 succ_cb();
95 },
96 () => {
97 logout();
98 alert(loc("code_nonValidAuthz"));
99 single.loadRoute("/webui/"); // Show login screen
100 }
101 );
106 getWebSocket().then(() => {
107 // We have a websocket connection, so authz should be good.
108 succ_cb();
109 });
102 110 }
103 111
104 112 function logout() {
105 113 localStorage.removeItem("email");
106 114 localStorage.removeItem("password");
115 did_initial_auth = false;
116 }
117
118 function doLogout() {
119 logout();
120 if (window.ws) {
121 // Unsubscribe from notifications about nonce invalidation
122 window.ws.send(JSON.stringify({ logout: true }));
123 }
107 124 }
108 125
109 126 function renameAccount() {
@@ -129,10 +146,6 @@ function deleteAccount() {
129 146 }
130 147 }
131 148
132 if (localStorage.getItem("email") && localStorage.getItem("password")) {
133 loginFromLocalStorage();
134 }
135
136 149 single.on("route_load", function (event) {
137 150 if (event.route.paths[0] != "/webui/") {
138 151 // Authorised route?