返回提交历史
Modified
src/controllers/api/loginController.ts
+19
-40
Modified
src/controllers/api/logoutController.ts
+6
-1
Modified
src/models/loginModel.ts
+4
-4
Modified
src/services/loginService.ts
+17
-0
Modified
src/services/webService.ts
+108
-6
Modified
src/types/loginTypes.ts
+1
-1
Modified
static/webui/index.html
+1
-1
Modified
static/webui/script.js
+78
-65
XFEstudio/XFESpaceNinjaServer
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
代码差异
8 个文件
+234
-118
@@ -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
@@ -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",
@@ -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,
@@ -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 {
@@ -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
};
@@ -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;
@@ -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>
@@ -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?