snakeia-server 1.2.7 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Copyright (C) 2020 Eliastik (eliastiksofts.com)
2
+ * Copyright (C) 2020-2026 Eliastik (eliastiksofts.com)
3
3
  *
4
4
  * This file is part of "SnakeIA Server".
5
5
  *
@@ -16,25 +16,32 @@
16
16
  * You should have received a copy of the GNU General Public License
17
17
  * along with "SnakeIA Server". If not, see <http://www.gnu.org/licenses/>.
18
18
  */
19
- const express = require("express");
20
- const app = express();
21
- const fs = require("fs");
22
- const httpLib = require("http");
23
- const httpsLib = require("https");
24
- let server = null;
25
- let io = null;
26
- const entities = require("html-entities");
27
- const ejs = require("ejs");
28
- const jwt = require("jsonwebtoken");
29
- const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
30
- const cookieParser = require("cookie-parser");
31
- const ioCookieParser = require("socket.io-cookie-parser");
32
- const i18n = require("i18n");
33
- const rateLimit = require("express-rate-limit");
34
- const winston = require("winston");
35
- const { doubleCsrf } = require("csrf-csrf");
36
- const bodyParser = require("body-parser");
37
- const node_config = require("config");
19
+ const express = require("express");
20
+ const app = express();
21
+ const fs = require("fs");
22
+ const httpLib = require("http");
23
+ const httpsLib = require("https");
24
+ let server = null;
25
+ let io = null;
26
+ const entities = require("html-entities");
27
+ const ejs = require("ejs");
28
+ const { SignJWT,
29
+ jwtVerify,
30
+ decodeJwt } = require("jose");
31
+ const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
32
+ const cookieParser = require("cookie-parser");
33
+ const ioCookieParser = require("socket.io-cookie-parser");
34
+ const i18n = require("i18n");
35
+ const rateLimit = require("express-rate-limit");
36
+ const winston = require("winston");
37
+ const { doubleCsrf } = require("csrf-csrf");
38
+ const bodyParser = require("body-parser");
39
+ const node_config = require("config");
40
+ const { randomUUID,
41
+ randomBytes,
42
+ createSecretKey,
43
+ createHash } = require("crypto");
44
+ const semver = require("semver");
38
45
 
39
46
  process.env["ALLOW_CONFIG_MUTATIONS"] = true;
40
47
  let config = node_config.get("ServerConfig"); // Server configuration (see default config file config.json)
@@ -44,15 +51,26 @@ const configSources = node_config.util.getConfigSources();
44
51
  const configFile = configSources[configSources.length - 1].name;
45
52
 
46
53
  config.port = process.env.PORT || config.port;
47
- const jsonWebTokenSecretKey = config.jsonWebTokenSecretKey && config.jsonWebTokenSecretKey.trim() != "" ? config.jsonWebTokenSecretKey : generateRandomJsonWebTokenSecretKey();
48
- const jsonWebTokenSecretKeyAdmin = config.jsonWebTokenSecretKeyAdmin && config.jsonWebTokenSecretKeyAdmin.trim() != "" ? config.jsonWebTokenSecretKeyAdmin : generateRandomJsonWebTokenSecretKey(jsonWebTokenSecretKey);
54
+
55
+ const jsonWebTokenSecretKey = createSecretKey(
56
+ Buffer.from(config.jsonWebTokenSecretKey?.trim() || randomBytes(32))
57
+ );
58
+
59
+ const jsonWebTokenSecretKeyAdmin = createSecretKey(
60
+ Buffer.from(config.jsonWebTokenSecretKeyAdmin?.trim() || randomBytes(32))
61
+ );
49
62
 
50
63
  const productionMode = process.env.NODE_ENV === "production";
51
64
 
52
65
  // Update config to file
53
66
  function updateConfigToFile() {
54
- fs.writeFileSync(configFile, JSON.stringify({ "ServerConfig": config }, null, 4), "UTF-8");
55
- config = node_config.get("ServerConfig");
67
+ try {
68
+ fs.writeFileSync(configFile, JSON.stringify({ "ServerConfig": config }, null, 4), "UTF-8");
69
+ config = node_config.get("ServerConfig");
70
+ logger.info("updated config file");
71
+ } catch(e) {
72
+ logger.error("failed to update config file", e);
73
+ }
56
74
  }
57
75
 
58
76
  // Logging
@@ -114,7 +132,6 @@ io = require("socket.io")(server, {
114
132
 
115
133
  // Game modules
116
134
  const snakeia = require("snakeia");
117
- const { randomUUID } = require("crypto");
118
135
  const Snake = snakeia.Snake;
119
136
  const Grid = snakeia.Grid;
120
137
  const GameConstants = snakeia.GameConstants;
@@ -122,9 +139,10 @@ const GameEngine = config.enableMultithreading ? require("./GameEngineMultit
122
139
  const Player = require("./Player");
123
140
 
124
141
  const games = {}; // Contains all the games processed by the server
125
- const tokens = []; // User tokens
126
- const invalidatedUserTokens = []; // Invalidated user tokens
127
- const invalidatedAdminTokens = []; // Invalidated admin tokens
142
+ const tokens = new Map(); // User tokens
143
+ const socketSessions = new Map();
144
+ const invalidatedUserTokens = new Set(); // Invalidated user tokens
145
+ const invalidatedAdminTokens = new Set(); // Invalidated admin tokens
128
146
 
129
147
  function getRoomsData() {
130
148
  const rooms = [];
@@ -164,20 +182,30 @@ function getRoomsData() {
164
182
  }
165
183
 
166
184
  function getRandomRoomKey() {
167
- let r;
185
+ let roomKey;
168
186
 
169
187
  do {
170
- r = Math.random().toString(36).substring(2, 10);
171
- } while(r in games);
188
+ const length = 8;
189
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
190
+ const charsLength = chars.length;
191
+
192
+ const bytes = randomBytes(length);
193
+
194
+ roomKey = "";
195
+
196
+ for(let i = 0; i < length; i++) {
197
+ roomKey += chars[bytes[i] % charsLength];
198
+ }
199
+ } while(roomKey in games);
172
200
 
173
- return r;
201
+ return roomKey;
174
202
  }
175
203
 
176
204
  function generateRandomJsonWebTokenSecretKey(precValue) {
177
205
  let key;
178
206
 
179
207
  do {
180
- key = require("crypto").randomBytes(256).toString("base64");
208
+ key = randomBytes(256).toString("base64");
181
209
  } while(precValue && precValue == key);
182
210
 
183
211
  return key;
@@ -206,8 +234,8 @@ function getMaxPlayers(code) {
206
234
  return Math.min(config.maxPlayers, Math.max(Math.round(numberEmptyCases / 5), 2));
207
235
  }
208
236
 
209
- function createRoom(data, socket) {
210
- if(Object.keys(games).filter(key => games[key] != null).length < config.maxRooms && !Player.containsTokenAllGames(socket?.handshake?.auth?.token || socket?.handshake?.query?.token || socket?.request?.cookies?.token, games) && !Player.containsIdAllGames(socket.id, games)) {
237
+ async function createRoom(data, socket) {
238
+ if(Object.keys(games).filter(key => games[key] != null).length < config.maxRooms && !Player.containsTokenAllGames(Player.getSocketToken(socket), games) && !Player.containsIdAllGames(socket.id, games)) {
211
239
  let heightGrid = 20;
212
240
  let widthGrid = 20;
213
241
  let borderWalls = false;
@@ -218,13 +246,13 @@ function createRoom(data, socket) {
218
246
  let validSettings = true;
219
247
  let privateGame = false;
220
248
 
221
- if(data.heightGrid == null || isNaN(data.heightGrid) || data.heightGrid < config.minGridSize || data.heightGrid > config.maxGridSize) {
249
+ if(data.heightGrid == null || isNaN(data.heightGrid) || !Number.isInteger(data.heightGrid) || data.heightGrid < config.minGridSize || data.heightGrid > config.maxGridSize) {
222
250
  validSettings = false;
223
251
  } else {
224
252
  heightGrid = data.heightGrid;
225
253
  }
226
254
 
227
- if(data.widthGrid == null || isNaN(data.widthGrid) || data.widthGrid < config.minGridSize || data.widthGrid > config.maxGridSize) {
255
+ if(data.widthGrid == null || isNaN(data.widthGrid) || !Number.isInteger(data.widthGrid) || data.widthGrid < config.minGridSize || data.widthGrid > config.maxGridSize) {
228
256
  validSettings = false;
229
257
  } else {
230
258
  widthGrid = data.widthGrid;
@@ -249,12 +277,12 @@ function createRoom(data, socket) {
249
277
  }
250
278
 
251
279
  if(data.speed == "custom") {
252
- if(data.customSpeed == null || isNaN(data.customSpeed) || data.customSpeed < config.minSpeed || data.customSpeed > config.maxSpeed) {
280
+ if(data.customSpeed == null || isNaN(data.customSpeed) || !Number.isInteger(data.customSpeed) || data.customSpeed < config.minSpeed || data.customSpeed > config.maxSpeed) {
253
281
  validSettings = false;
254
282
  } else {
255
283
  speed = data.customSpeed;
256
284
  }
257
- } else if(data.speed == null || isNaN(data.speed) || data.speed < config.minSpeed || data.speed > config.maxSpeed) {
285
+ } else if(data.speed == null || isNaN(data.speed) || !Number.isInteger(data.speed) || data.speed < config.minSpeed || data.speed > config.maxSpeed) {
258
286
  validSettings = false;
259
287
  } else {
260
288
  speed = data.speed;
@@ -294,13 +322,14 @@ function createRoom(data, socket) {
294
322
  started: false,
295
323
  alreadyInit: false,
296
324
  timeoutPlay: null,
325
+ timeoutMatchmaking: null,
297
326
  timeStart: null,
298
327
  timeoutMaxTimePlay: null
299
328
  };
300
329
 
301
330
  games[code].numberAIToAdd = enableAI ? Math.round(getMaxPlayers(code) / 2 - 1) : 0;
302
331
 
303
- logger.info("room creation (code: " + code + ") - username: " + (Player.getUsernameSocket(socket)) + " - ip: " + getIPSocketIO(socket.handshake) + " - socket: " + socket.id, {
332
+ logger.info("room creation (code: " + code + ") - username: " + (await Player.getUsernameSocket(socket, jsonWebTokenSecretKey)) + " - ip: " + getIPSocketIO(socket.handshake) + " - socket: " + socket.id, {
304
333
  "widthGrid": widthGrid,
305
334
  "heightGrid": heightGrid,
306
335
  "generateWalls": generateWalls,
@@ -326,7 +355,7 @@ function createRoom(data, socket) {
326
355
  });
327
356
  }
328
357
  } else if(socket != null) {
329
- if(Player.containsTokenAllGames(socket?.handshake?.auth?.token || socket?.handshake?.query?.token || socket?.request?.cookies?.token, games) || Player.containsIdAllGames(socket.id, games)) {
358
+ if(Player.containsTokenAllGames(Player.getSocketToken(socket), games) || Player.containsIdAllGames(socket.id, games)) {
330
359
  socket.emit("process", {
331
360
  success: false,
332
361
  code: null,
@@ -358,11 +387,11 @@ function copySnakes(snakes) {
358
387
  snakeCopy.aiLevel = snake.aiLevel;
359
388
 
360
389
  if(snake.lastTail) {
361
- snakeCopy.lastTail = JSON.parse(JSON.stringify(snake.lastTail));
390
+ snakeCopy.lastTail = structuredClone(snake.lastTail);
362
391
  }
363
392
 
364
393
  if(snake.lastHead) {
365
- snakeCopy.lastHead = JSON.parse(JSON.stringify(snake.lastHead));
394
+ snakeCopy.lastHead = structuredClone(snake.lastHead);
366
395
  }
367
396
 
368
397
  snakeCopy.lastTailMoved = snake.lastTailMoved;
@@ -371,7 +400,7 @@ function copySnakes(snakes) {
371
400
  snakeCopy.player = snake.player;
372
401
 
373
402
  if(snake.queue) {
374
- snakeCopy.queue = JSON.parse(JSON.stringify(snake.queue));
403
+ snakeCopy.queue = structuredClone(snake.queue);
375
404
  }
376
405
 
377
406
  snakeCopy.score = snake.score;
@@ -416,6 +445,8 @@ function setupRoom(code) {
416
445
  "confirmExit": false,
417
446
  "getInfos": false,
418
447
  "getInfosGame": false,
448
+ "getInfosControls": false,
449
+ "getInfosGoal": false,
419
450
  "errorOccurred": game.errorOccurred,
420
451
  "timerToDisplay": config.enableMaxTimeGame ? (config.maxTimeGame - (Date.now() - game.timeStart)) / 1000 : -1,
421
452
  "aiStuck": game.aiStuck,
@@ -435,6 +466,8 @@ function setupRoom(code) {
435
466
  "confirmExit": false,
436
467
  "getInfos": false,
437
468
  "getInfosGame": false,
469
+ "getInfosControls": false,
470
+ "getInfosGoal": false,
438
471
  "errorOccurred": game.errorOccurred,
439
472
  "searchingPlayers": false
440
473
  });
@@ -447,6 +480,8 @@ function setupRoom(code) {
447
480
  "confirmExit": false,
448
481
  "getInfos": false,
449
482
  "getInfosGame": false,
483
+ "getInfosControls": false,
484
+ "getInfosGoal": false,
450
485
  "errorOccurred": game.errorOccurred
451
486
  });
452
487
  });
@@ -457,6 +492,8 @@ function setupRoom(code) {
457
492
  "confirmExit": false,
458
493
  "getInfos": false,
459
494
  "getInfosGame": false,
495
+ "getInfosControls": false,
496
+ "getInfosGoal": false,
460
497
  "errorOccurred": game.errorOccurred
461
498
  });
462
499
  });
@@ -471,13 +508,22 @@ function setupRoom(code) {
471
508
  "confirmExit": false,
472
509
  "getInfos": false,
473
510
  "getInfosGame": false,
511
+ "getInfosControls": false,
512
+ "getInfosGoal": false,
474
513
  "errorOccurred": game.errorOccurred
475
514
  });
476
515
 
477
516
  if(games[code] != null) {
478
517
  games[code].started = false;
479
518
  games[code].searchingPlayers = true;
519
+
480
520
  clearTimeout(games[code].timeoutMaxTimePlay);
521
+ clearTimeout(games[code].timeoutMatchmaking);
522
+
523
+ games[code].timeoutMatchmaking = setTimeout(() => {
524
+ gameMatchmaking(games[code], code);
525
+ io.to("room-" + code).emit("reset", { "gameOver": false, "gameFinished": false, "scoreMax": false, "gameMazeWin": false });
526
+ }, config.matchmakingWaitTime);
481
527
  }
482
528
  });
483
529
 
@@ -491,6 +537,8 @@ function setupRoom(code) {
491
537
  "confirmExit": false,
492
538
  "getInfos": false,
493
539
  "getInfosGame": false,
540
+ "getInfosControls": false,
541
+ "getInfosGoal": false,
494
542
  "errorOccurred": game.errorOccurred
495
543
  });
496
544
  });
@@ -507,6 +555,8 @@ function setupRoom(code) {
507
555
  "confirmExit": false,
508
556
  "getInfos": false,
509
557
  "getInfosGame": false,
558
+ "getInfosControls": false,
559
+ "getInfosGoal": false,
510
560
  "errorOccurred": game.errorOccurred
511
561
  });
512
562
  });
@@ -577,10 +627,9 @@ function cleanRooms() {
577
627
  const game = games[keys[i]];
578
628
 
579
629
  if(game != null) {
580
- const players = Object.keys(game.players) + Object.keys(game.spectators);
581
- const nb = players.length;
630
+ const players = Object.keys(game.players).length + Object.keys(game.spectators).length;
582
631
 
583
- if(nb <= 0) {
632
+ if(players <= 0) {
584
633
  toRemove.push(keys[i]);
585
634
 
586
635
  if(game.gameEngine && game.gameEngine.kill) {
@@ -591,11 +640,16 @@ function cleanRooms() {
591
640
  }
592
641
 
593
642
  for(let i = 0; i < toRemove.length; i++) {
594
- games[toRemove[i]] = null;
643
+ delete games[toRemove[i]];
595
644
  }
596
645
  }
597
646
 
598
647
  function gameMatchmaking(game, code) {
648
+ if(game.timeoutMatchmaking != null) {
649
+ clearTimeout(game.timeoutMatchmaking);
650
+ game.timeoutMatchmaking = null;
651
+ }
652
+
599
653
  if(game != null && games[code] != null && games[code].searchingPlayers) {
600
654
  let numberPlayers = game.players.length + games[code].numberAIToAdd;
601
655
 
@@ -661,6 +715,11 @@ async function startGame(code) {
661
715
  game.timeoutPlay = null;
662
716
  }
663
717
 
718
+ if(game.timeoutMatchmaking != null) {
719
+ clearTimeout(game.timeoutMatchmaking);
720
+ game.timeoutMatchmaking = null;
721
+ }
722
+
664
723
  game.searchingPlayers = false;
665
724
  game.started = true;
666
725
  game.gameEngine.snakes = [];
@@ -745,9 +804,9 @@ function sendStatus(code) {
745
804
  }
746
805
  }
747
806
 
748
- function exitGame(game, socket, code) {
807
+ async function exitGame(game, socket, code) {
749
808
  if(game) {
750
- logger.info("exit game (code: " + code + ") - username: " + Player.getUsernameSocket(socket) + " - ip: " + getIPSocketIO(socket.handshake) + " - socket: " + socket.id);
809
+ logger.info("exit game (code: " + code + ") - username: " + (await Player.getUsernameSocket(socket, jsonWebTokenSecretKey)) + " - ip: " + getIPSocketIO(socket.handshake) + " - socket: " + socket.id);
751
810
 
752
811
  if(Player.containsId(game.players, socket.id) && Player.getPlayer(game.players, socket.id).snake != null) {
753
812
  Player.getPlayer(game.players, socket.id).snake.gameOver = true;
@@ -778,94 +837,92 @@ function ipBanned(ip) {
778
837
  ip = ip.substr(7, ip.length);
779
838
  }
780
839
 
781
- return new Promise((resolve, reject) => {
782
- config.ipBan.forEach(ipBanned => {
783
- if(ipBanned == ip) {
784
- resolve();
785
- }
786
- });
787
-
788
- reject();
789
- });
840
+ return config.ipBan.includes(ip);
790
841
  }
791
842
 
792
843
  function usernameBanned(username) {
793
- return new Promise((resolve, reject) => {
794
- config.usernameBan.forEach(usernameBanned => {
795
- if(username.toLowerCase().indexOf(usernameBanned.toLowerCase()) > -1) {
796
- resolve();
797
- }
798
- });
844
+ for(const usernameBanned of config.usernameBan) {
845
+ if(username.toLowerCase().indexOf(usernameBanned.toLowerCase()) > -1) {
846
+ return true;
847
+ }
848
+ }
799
849
 
800
- reject();
801
- });
850
+ return false;
802
851
  }
803
852
 
804
- function usernameAlreadyInUse(username) {
805
- return new Promise((resolve, reject) => {
806
- tokens.forEach(token => {
807
- try {
808
- const otherUsername = jwt.verify(token, jsonWebTokenSecretKey).username;
853
+ async function getUsernameToken(token, secretKey) {
854
+ try {
855
+ const { payload } = await jwtVerify(token, secretKey);
856
+ return payload.username ?? null;
857
+ } catch {
858
+ return null;
859
+ }
860
+ }
809
861
 
810
- if(otherUsername) {
811
- if(otherUsername.toLowerCase().indexOf(username.toLowerCase()) > -1) {
812
- resolve();
813
- }
814
- }
815
- } catch(e) {}
816
- });
862
+ async function usernameAlreadyInUse(username) {
863
+ if(!tokens.has(username.toLowerCase())) {
864
+ return false;
865
+ }
817
866
 
818
- reject();
819
- });
867
+ try {
868
+ const token = tokens.get(username.toLowerCase());
869
+ const result = await getUsernameToken(token, jsonWebTokenSecretKey);
870
+
871
+ return result != null;
872
+ } catch(e) {
873
+ logger.error(e);
874
+ return false;
875
+ }
820
876
  }
821
877
 
822
- function verifyRecaptcha(response) {
878
+ async function verifyRecaptcha(response) {
823
879
  if(config.enableRecaptcha && config.recaptchaPrivateKey && config.recaptchaPrivateKey.trim() != "" && config.recaptchaPublicKey && config.recaptchaPublicKey.trim() != "") {
824
880
  const params = new URLSearchParams();
881
+
825
882
  params.append("secret", config.recaptchaPrivateKey);
826
883
  params.append("response", response);
827
-
828
- return new Promise((resolve, reject) => {
829
- fetch(config.recaptchaApiUrl, {
884
+
885
+ try {
886
+ const fetchResponse = await fetch(config.recaptchaApiUrl, {
830
887
  method: "POST",
831
888
  body: params
832
- }).then(res => res.json()).then(json => {
833
- if(json && json.success) {
834
- resolve();
835
- } else {
836
- reject();
837
- }
838
889
  });
839
- });
840
- } else {
841
- return new Promise((resolve, reject) => {
842
- resolve();
843
- });
890
+
891
+ const responseBody = await fetchResponse.json();
892
+
893
+ if(responseBody && responseBody.success) {
894
+ return Promise.resolve();
895
+ }
896
+
897
+ return Promise.reject();
898
+ } catch(e) {
899
+ return Promise.reject();
900
+ }
844
901
  }
902
+
903
+ return Promise.resolve();
845
904
  }
846
905
 
847
- function verifyFormAuthentication(body) {
848
- return new Promise((resolve, reject) => {
849
- verifyRecaptcha(body["g-recaptcha-response"]).then(() => {
850
- const username = body["username"];
851
-
852
- if(username && username.trim() != "" && username.length >= config.minCharactersUsername && username.length <= config.maxCharactersUsername) {
853
- usernameBanned(username).then(() => {
854
- reject("BANNED_USERNAME");
855
- }, () => {
856
- usernameAlreadyInUse(username).then(() => {
857
- reject("USERNAME_ALREADY_IN_USE");
858
- }, () => {
859
- resolve();
860
- });
861
- });
862
- } else {
863
- reject("BAD_USERNAME");
864
- }
865
- }, () => {
866
- reject("INVALID_RECAPTCHA");
867
- });
868
- });
906
+ async function verifyFormAuthentication(body) {
907
+ try {
908
+ await verifyRecaptcha(body["g-recaptcha-response"]);
909
+ } catch {
910
+ throw "INVALID_RECAPTCHA";
911
+ }
912
+
913
+ const username = body["username"];
914
+
915
+ if(!username || username.trim() === "" || username.length < config.minCharactersUsername || username.length > config.maxCharactersUsername) {
916
+ throw "BAD_USERNAME";
917
+ }
918
+
919
+ if(usernameBanned(username)) {
920
+ throw "BANNED_USERNAME";
921
+ }
922
+
923
+ if(await usernameAlreadyInUse(username)) {
924
+ throw "USERNAME_ALREADY_IN_USE";
925
+ }
869
926
  }
870
927
 
871
928
  app.engine("html", ejs.renderFile);
@@ -878,6 +935,44 @@ app.use(express.urlencoded({ extended: true }));
878
935
  app.use(cookieParser());
879
936
  app.use(i18n.init);
880
937
 
938
+ const csrfSecretAdmin = generateRandomJsonWebTokenSecretKey(jsonWebTokenSecretKeyAdmin);
939
+ const { doubleCsrfProtection: doubleCsrfProtectionAdmin, generateCsrfToken: generateCsrfTokenAdmin } = doubleCsrf({
940
+ getSecret: () => csrfSecretAdmin,
941
+ getSessionIdentifier: (req) => req.cookies.tokenAdmin || randomUUID(),
942
+ getCsrfTokenFromRequest: (req) => {
943
+ return (
944
+ req.headers["x-csrf-token"] ||
945
+ req.body?._csrf ||
946
+ req.query?._csrf
947
+ );
948
+ },
949
+ cookieName: productionMode ? "__Host-snakeia-server.x-csrf-token-admin" : "snakeia-server.x-csrf-token-admin",
950
+ cookieOptions: {
951
+ sameSite: productionMode ? "strict" : "lax",
952
+ path: "/",
953
+ secure: productionMode
954
+ }
955
+ });
956
+
957
+ const csrfSecretUser = generateRandomJsonWebTokenSecretKey(jsonWebTokenSecretKeyAdmin);
958
+ const { doubleCsrfProtection: doubleCsrfProtectionUserAuthent, generateCsrfToken: generateCsrfTokenUserAuthent } = doubleCsrf({
959
+ getSecret: () => csrfSecretUser,
960
+ getSessionIdentifier: (req) => req.cookies.sessionId || randomUUID(),
961
+ getCsrfTokenFromRequest: (req) => {
962
+ return (
963
+ req.headers["x-csrf-token"] ||
964
+ req.body?._csrf ||
965
+ req.query?._csrf
966
+ );
967
+ },
968
+ cookieName: "snakeia-server.x-csrf-token-user",
969
+ cookieOptions: {
970
+ sameSite: "lax",
971
+ path: "/authentication",
972
+ secure: productionMode
973
+ }
974
+ });
975
+
881
976
  // Rate limiter
882
977
  app.use("/authentication", rateLimit({
883
978
  windowMs: config.authentWindowMs,
@@ -887,15 +982,16 @@ app.use("/authentication", rateLimit({
887
982
 
888
983
  // IP ban
889
984
  app.use(function(req, res, next) {
890
- ipBanned(req.ip).then(() => {
891
- res.render(__dirname + "/views/banned.html", {
985
+ if(ipBanned(req.ip)) {
986
+ res.status(403);
987
+
988
+ return res.render(__dirname + "/views/banned.html", {
892
989
  contact: config.contactBan,
893
990
  theme: req.query.theme
894
991
  });
895
- res.end();
896
- }, () => {
897
- next()
898
- });
992
+ }
993
+
994
+ next();
899
995
  });
900
996
 
901
997
  app.get("/", function(req, res) {
@@ -906,96 +1002,170 @@ app.get("/", function(req, res) {
906
1002
  });
907
1003
  });
908
1004
 
909
- app.get("/authentication", function(req, res) {
910
- if(req.cookies && config.enableAuthentication) {
911
- let err = false;
912
-
913
- checkAuthenticationExpress(req).catch(() => err = true).finally(() => {
914
- res.render(__dirname + "/views/authentication.html", {
915
- publicKey: config.recaptchaPublicKey,
916
- enableRecaptcha: config.enableRecaptcha,
917
- errorRecaptcha: false,
918
- errorUsername: false,
919
- errorUsernameBanned: false,
920
- errorUsernameAlreadyInUse: false,
921
- success: false,
922
- authent: !err,
923
- locale: i18n.getLocale(req),
924
- min: config.minCharactersUsername,
925
- max: config.maxCharactersUsername,
926
- enableMaxTimeGame: config.enableMaxTimeGame,
927
- maxTimeGame: config.maxTimeGame,
928
- theme: req.query.theme
929
- });
930
- });
931
- } else {
932
- res.end();
1005
+ app.get("/authentication", async (req, res) => {
1006
+ if(!req.cookies || !config.enableAuthentication) {
1007
+ res.status(400);
1008
+ return res.end();
1009
+ }
1010
+
1011
+ const authenticated = await checkAuthenticationExpress(req).then(() => true).catch(() => false);
1012
+
1013
+ setSessionCookie(req, res);
1014
+
1015
+ const clientCompatible = isClientCompatible(req.query.version, req.query.id);
1016
+
1017
+ if(!clientCompatible) {
1018
+ res.status(403);
1019
+ }
1020
+
1021
+ res.render(__dirname + "/views/authentication.html", {
1022
+ publicKey: config.recaptchaPublicKey,
1023
+ enableRecaptcha: config.enableRecaptcha,
1024
+ errorRecaptcha: false,
1025
+ errorUsername: false,
1026
+ errorUsernameBanned: false,
1027
+ errorUsernameAlreadyInUse: false,
1028
+ success: false,
1029
+ authent: authenticated,
1030
+ locale: i18n.getLocale(req),
1031
+ min: config.minCharactersUsername,
1032
+ max: config.maxCharactersUsername,
1033
+ enableMaxTimeGame: config.enableMaxTimeGame,
1034
+ maxTimeGame: config.maxTimeGame,
1035
+ theme: req.query.theme,
1036
+ clientCompatible,
1037
+ serverGameVersion: GameConstants.Setting.APP_VERSION,
1038
+ csrfToken: generateCsrfTokenUserAuthent(req, res, { overwrite: true, validateOnReuse: true }),
1039
+ });
1040
+
1041
+ if(authenticated && clientCompatible) {
1042
+ sendTokenToSocket(req, getExpressUserToken(req));
933
1043
  }
934
1044
  });
935
1045
 
936
- app.post("/authentication", function(req, res) {
937
- if(req.cookies && config.enableAuthentication) {
938
- let err = false;
939
-
940
- checkAuthenticationExpress(req).catch(() => err = true).finally(() => {
941
- if(err) {
942
- verifyFormAuthentication(req.body).then(() => {
943
- const username = req.body["username"];
944
- const id = req.query.id;
945
-
946
- const token = jwt.sign({
947
- username: username
948
- }, jsonWebTokenSecretKey, { expiresIn: config.authenticationTime / 1000 });
949
-
950
- res.cookie("token", token, { expires: new Date(Date.now() + config.authenticationTime), httpOnly: true, sameSite: "None", secure: (req.protocol == "https" ? true : false) });
951
-
952
- res.render(__dirname + "/views/authentication.html", {
953
- publicKey: config.recaptchaPublicKey,
954
- enableRecaptcha: config.enableRecaptcha,
955
- errorRecaptcha: false,
956
- errorUsername: false,
957
- errorUsernameBanned: false,
958
- errorUsernameAlreadyInUse: false,
959
- success: true,
960
- authent: false,
961
- locale: i18n.getLocale(req),
962
- min: config.minCharactersUsername,
963
- max: config.maxCharactersUsername,
964
- enableMaxTimeGame: config.enableMaxTimeGame,
965
- maxTimeGame: config.maxTimeGame,
966
- theme: req.query.theme
967
- });
1046
+ function isClientCompatible(version, hasIdQueryParam) {
1047
+ const hasNoVersion = !version || version.trim().length === 0;
968
1048
 
969
- logger.info("authentication - username: " + username + " - ip: " + req.ip);
1049
+ if((hasNoVersion && hasIdQueryParam) || hasNoVersion) {
1050
+ return false;
1051
+ }
970
1052
 
971
- if(id != null) {
972
- io.to("" + id).emit("token", token);
973
- }
974
- }, (err) => {
975
- res.render(__dirname + "/views/authentication.html", {
976
- publicKey: config.recaptchaPublicKey,
977
- enableRecaptcha: config.enableRecaptcha,
978
- errorRecaptcha: err == "INVALID_RECAPTCHA",
979
- errorUsername: err == "BAD_USERNAME",
980
- errorUsernameBanned: err == "BANNED_USERNAME",
981
- errorUsernameAlreadyInUse: err == "USERNAME_ALREADY_IN_USE",
982
- success: false,
983
- authent: false,
984
- locale: i18n.getLocale(req),
985
- min: config.minCharactersUsername,
986
- max: config.maxCharactersUsername,
987
- enableMaxTimeGame: config.enableMaxTimeGame,
988
- maxTimeGame: config.maxTimeGame,
989
- theme: req.query.theme
990
- });
991
- });
992
- }
1053
+ return semver.gte(version, GameConstants.Setting.APP_VERSION);
1054
+ }
1055
+
1056
+ function setSessionCookie(req, res) {
1057
+ let sessionId = req.cookies.sessionId;
1058
+
1059
+ if(!sessionId) {
1060
+ sessionId = randomUUID();
1061
+
1062
+ res.cookie("sessionId", sessionId, {
1063
+ httpOnly: true,
1064
+ sameSite: "lax",
1065
+ secure: req.protocol === "https"
993
1066
  });
994
- } else {
995
- res.end();
1067
+
1068
+ req.cookies.sessionId = sessionId;
1069
+ }
1070
+ }
1071
+
1072
+ app.post("/authentication", doubleCsrfProtectionUserAuthent, async (req, res) => {
1073
+ if(!req.cookies || !config.enableAuthentication) {
1074
+ res.status(400);
1075
+ return res.end();
996
1076
  }
1077
+
1078
+ const alreadyAuthenticated = await checkAuthenticationExpress(req).then(() => true).catch(() => false);
1079
+
1080
+ if(alreadyAuthenticated) {
1081
+ return res.end();
1082
+ }
1083
+
1084
+ const clientCompatible = isClientCompatible(req.query.version, req.query.id);
1085
+
1086
+ let formError = null;
1087
+
1088
+ if(clientCompatible) {
1089
+ await verifyFormAuthentication(req.body).catch(e => formError = e);
1090
+ }
1091
+
1092
+ if(formError || !clientCompatible) {
1093
+ res.status(403);
1094
+
1095
+ return res.render(__dirname + "/views/authentication.html", {
1096
+ publicKey: config.recaptchaPublicKey,
1097
+ enableRecaptcha: config.enableRecaptcha,
1098
+ errorRecaptcha: formError == "INVALID_RECAPTCHA",
1099
+ errorUsername: formError == "BAD_USERNAME",
1100
+ errorUsernameBanned: formError == "BANNED_USERNAME",
1101
+ errorUsernameAlreadyInUse: formError == "USERNAME_ALREADY_IN_USE",
1102
+ success: false,
1103
+ authent: false,
1104
+ locale: i18n.getLocale(req),
1105
+ min: config.minCharactersUsername,
1106
+ max: config.maxCharactersUsername,
1107
+ enableMaxTimeGame: config.enableMaxTimeGame,
1108
+ maxTimeGame: config.maxTimeGame,
1109
+ theme: req.query.theme,
1110
+ clientCompatible,
1111
+ serverGameVersion: GameConstants.Setting.APP_VERSION,
1112
+ csrfToken: generateCsrfTokenUserAuthent(req, res, { overwrite: true, validateOnReuse: true })
1113
+ });
1114
+ }
1115
+
1116
+ const username = req.body["username"];
1117
+
1118
+ const token = await new SignJWT({ username })
1119
+ .setProtectedHeader({ alg: "HS256" })
1120
+ .setExpirationTime(Math.floor(config.authenticationTime / 1000) + "s")
1121
+ .setIssuedAt()
1122
+ .sign(jsonWebTokenSecretKey);
1123
+
1124
+ generateTokenCookie(res, token, req);
1125
+
1126
+ res.render(__dirname + "/views/authentication.html", {
1127
+ publicKey: config.recaptchaPublicKey,
1128
+ enableRecaptcha: config.enableRecaptcha,
1129
+ errorRecaptcha: false,
1130
+ errorUsername: false,
1131
+ errorUsernameBanned: false,
1132
+ errorUsernameAlreadyInUse: false,
1133
+ success: true,
1134
+ authent: false,
1135
+ locale: i18n.getLocale(req),
1136
+ min: config.minCharactersUsername,
1137
+ max: config.maxCharactersUsername,
1138
+ enableMaxTimeGame: config.enableMaxTimeGame,
1139
+ maxTimeGame: config.maxTimeGame,
1140
+ theme: req.query.theme,
1141
+ clientCompatible,
1142
+ serverGameVersion: GameConstants.Setting.APP_VERSION,
1143
+ csrfToken: null
1144
+ });
1145
+
1146
+ logger.info("authentication - username: " + username + " - ip: " + req.ip);
1147
+
1148
+ sendTokenToSocket(req, token);
997
1149
  });
998
1150
 
1151
+ function generateTokenCookie(res, token, req) {
1152
+ res.cookie("token", token, {
1153
+ expires: new Date(Date.now() + config.authenticationTime),
1154
+ httpOnly: true,
1155
+ sameSite: "None",
1156
+ secure: req.protocol === "https"
1157
+ });
1158
+ }
1159
+
1160
+ function sendTokenToSocket(req, token) {
1161
+ const sessionId = req.cookies.sessionId;
1162
+ const socketId = socketSessions.get(sessionId);
1163
+
1164
+ if(socketId != null) {
1165
+ io.to("" + socketId).emit("token", token);
1166
+ }
1167
+ }
1168
+
999
1169
  app.get("/rooms", function(req, res) {
1000
1170
  const callbackName = req.query.callback;
1001
1171
  res.charset = "UTF-8";
@@ -1019,25 +1189,18 @@ function kickUser(socketId, token) {
1019
1189
  }
1020
1190
 
1021
1191
  function kickUsername(username) {
1022
- tokens.forEach(token => {
1023
- jwt.verify(token, jsonWebTokenSecretKey, function(err, data) {
1024
- if(!err && data && data.username && data.username == username) {
1025
- invalidateUserToken(token);
1026
- }
1027
- });
1028
- });
1029
- }
1192
+ const usernameToken = tokens.get(username.toLowerCase());
1030
1193
 
1031
- function invalidateUserToken(token) {
1032
- invalidatedUserTokens.push(token);
1033
-
1034
- for(let i = tokens.length - 1; i >= 0; i--) {
1035
- if(tokens[i] == token) {
1036
- tokens.splice(i, 1);
1037
- }
1194
+ if(usernameToken) {
1195
+ invalidateUserToken(username, usernameToken);
1038
1196
  }
1039
1197
  }
1040
1198
 
1199
+ function invalidateUserToken(username, token) {
1200
+ invalidatedUserTokens.add(token);
1201
+ tokens.delete(username.toLowerCase());
1202
+ }
1203
+
1041
1204
  function banUserIP(socketId) {
1042
1205
  const sockets = io.of("/").sockets;
1043
1206
 
@@ -1057,17 +1220,16 @@ function banUserIP(socketId) {
1057
1220
  }
1058
1221
  }
1059
1222
 
1060
- function banUserName(token) {
1061
- jwt.verify(token, jsonWebTokenSecretKey, function(err, data) {
1062
- if(!err && data) {
1063
- if(data.username) {
1064
- config.usernameBan.push(data.username);
1065
- logger.info("username banned (" + data.username + ")");
1066
- }
1067
-
1223
+ async function banUserName(token) {
1224
+ try {
1225
+ const { payload } = await jwtVerify(token, jsonWebTokenSecretKey);
1226
+
1227
+ if(payload.username) {
1228
+ config.usernameBan.push(payload.username);
1229
+ logger.info("username banned (" + payload.username + ")");
1068
1230
  updateConfigToFile();
1069
1231
  }
1070
- });
1232
+ } catch {}
1071
1233
  }
1072
1234
 
1073
1235
  function unbanUsername(value) {
@@ -1095,13 +1257,21 @@ function manualIPBan(value) {
1095
1257
  }
1096
1258
 
1097
1259
  function resetLog() {
1098
- fs.writeFileSync(config.logFile, "", "UTF-8");
1099
- logger.info("log file reseted");
1260
+ try {
1261
+ fs.writeFileSync(config.logFile, "", "UTF-8");
1262
+ logger.info("log file reseted");
1263
+ } catch(e) {
1264
+ logger.error("failed to reset log file", e);
1265
+ }
1100
1266
  }
1101
1267
 
1102
1268
  function resetErrorLog() {
1103
- fs.writeFileSync(config.errorLogFile, "", "UTF-8");
1104
- logger.info("error log file reseted");
1269
+ try {
1270
+ fs.writeFileSync(config.errorLogFile, "", "UTF-8");
1271
+ logger.info("error log file reseted");
1272
+ } catch(e) {
1273
+ logger.error("failed to reset error log file", e);
1274
+ }
1105
1275
  }
1106
1276
 
1107
1277
  function updateConfig(value) {
@@ -1120,178 +1290,188 @@ function updateConfig(value) {
1120
1290
  }
1121
1291
  }
1122
1292
 
1123
- function verifyFormAuthenticationAdmin(body) {
1124
- return new Promise((resolve, reject) => {
1125
- verifyRecaptcha(body["g-recaptcha-response"]).then(() => {
1126
- const username = body["username"];
1127
- const password = body["password"];
1128
-
1129
- const accounts = config.adminAccounts;
1293
+ async function verifyFormAuthenticationAdmin(body) {
1294
+ try {
1295
+ await verifyRecaptcha(body["g-recaptcha-response"]);
1296
+ } catch {
1297
+ throw "INVALID_RECAPTCHA";
1298
+ }
1130
1299
 
1131
- if(accounts) {
1132
- const usernames = Object.keys(accounts);
1300
+ const { username, password } = body;
1301
+ const accounts = config.adminAccounts;
1133
1302
 
1134
- if(usernames.includes(username)) {
1135
- const hashPassword = accounts[username]["password"];
1136
- const enteredPasswordHash = require("crypto").createHash("sha512").update(password).digest("hex");
1303
+ if(!accounts || !Object.keys(accounts).includes(username)) {
1304
+ throw "INVALID";
1305
+ }
1137
1306
 
1138
- if(hashPassword === enteredPasswordHash) {
1139
- resolve();
1140
- } else {
1141
- reject("INVALID");
1142
- }
1143
- } else {
1144
- reject("INVALID");
1145
- }
1146
- }
1147
- }, () => {
1148
- reject("INVALID_RECAPTCHA");
1149
- });
1150
- });
1307
+ const hashPassword = accounts[username]["password"];
1308
+ const enteredPasswordHash = createHash("sha512").update(password).digest("hex");
1309
+
1310
+ if(hashPassword !== enteredPasswordHash) {
1311
+ throw "INVALID";
1312
+ }
1151
1313
  }
1152
1314
 
1153
- const csrfSecret = generateRandomJsonWebTokenSecretKey(jsonWebTokenSecretKeyAdmin);
1154
- const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({
1155
- getSecret: () => csrfSecret,
1156
- getSessionIdentifier: (req) => req.cookies.tokenAdmin || randomUUID(),
1157
- getCsrfTokenFromRequest: (req) => {
1158
- return (
1159
- req.headers["x-csrf-token"] ||
1160
- req.body?._csrf ||
1161
- req.query?._csrf
1162
- );
1163
- },
1164
- cookieName: productionMode ? "__Host-snakeia-server.x-csrf-token" : "snakeia-server.x-csrf-token",
1165
- cookieOptions: {
1166
- sameSite: productionMode ? "strict" : "lax",
1167
- path: "/",
1168
- secure: productionMode
1315
+ async function verifyAdminToken(req) {
1316
+ const token = req.cookies?.tokenAdmin;
1317
+
1318
+ if(!token || invalidatedAdminTokens.has(token)) {
1319
+ return null;
1169
1320
  }
1321
+
1322
+ try {
1323
+ const { payload } = await jwtVerify(token, jsonWebTokenSecretKeyAdmin);
1324
+ const usernames = Object.keys(config.adminAccounts);
1325
+
1326
+ if(payload.username && usernames.includes(payload.username)) {
1327
+ return payload;
1328
+ }
1329
+
1330
+ return null;
1331
+ } catch {
1332
+ return null;
1333
+ }
1334
+ }
1335
+
1336
+ const adminAuthentRateLimiter = rateLimit({
1337
+ windowMs: config.adminAuthentWindowMs,
1338
+ max: config.adminAuthentMaxRequest,
1339
+ validate: { trustProxy: false }
1170
1340
  });
1171
1341
 
1172
- app.get("/admin", doubleCsrfProtection, function(req, res) {
1173
- if(req.cookies) {
1174
- jwt.verify(req.cookies.tokenAdmin, jsonWebTokenSecretKeyAdmin, function(err, data) {
1175
- if(invalidatedAdminTokens.includes(req.cookies.tokenAdmin)) err = true;
1176
-
1177
- const usernames = Object.keys(config.adminAccounts);
1178
- const authenticated = !err && data && data.username && usernames.includes(data.username);
1179
- let role = "none";
1342
+ const adminActionsRateLimiter = rateLimit({
1343
+ windowMs: config.adminActionsWindowMs,
1344
+ max: config.adminActionsMaxRequest,
1345
+ validate: { trustProxy: false }
1346
+ });
1180
1347
 
1181
- if(authenticated) {
1182
- role = config.adminAccounts[data.username]["role"] || "moderator";
1183
- }
1184
-
1185
- fs.readFile(config.logFile, "UTF-8", function(e1, logFile) {
1186
- fs.readFile(config.errorLogFile, "UTF-8", function(e2, errorLogFile) {
1187
- res.render(__dirname + "/views/admin.html", {
1188
- publicKey: config.recaptchaPublicKey,
1189
- enableRecaptcha: config.enableRecaptcha,
1190
- authent: authenticated,
1191
- role: role,
1192
- username: authenticated ? data.username : "",
1193
- success: false,
1194
- errorAuthent: false,
1195
- errorRecaptcha: false,
1196
- locale: i18n.getLocale(req),
1197
- games: games,
1198
- io: io,
1199
- config: config,
1200
- csrfToken: generateCsrfToken(req, res, { overwrite: true, validateOnReuse: true }),
1201
- serverLog: logFile,
1202
- errorLog: errorLogFile,
1203
- getIPSocketIO: getIPSocketIO,
1204
- theme: req.query.theme
1205
- });
1206
- });
1207
- });
1208
- });
1209
- } else {
1210
- res.end();
1348
+ app.get("/admin", adminActionsRateLimiter, doubleCsrfProtectionAdmin, async (req, res) => {
1349
+ if(!req.cookies) {
1350
+ res.status(400);
1351
+ return res.end();
1352
+ }
1353
+
1354
+ const payload = await verifyAdminToken(req);
1355
+ const authenticated = payload != null;
1356
+ const role = authenticated ? (config.adminAccounts[payload.username]["role"] || "moderator") : "none";
1357
+
1358
+ const [logFile, errorLogFile] = await Promise.all([
1359
+ fs.promises.readFile(config.logFile, "UTF-8").catch(() => ""),
1360
+ fs.promises.readFile(config.errorLogFile, "UTF-8").catch(() => "")
1361
+ ]);
1362
+
1363
+ if(!authenticated) {
1364
+ res.status(401);
1211
1365
  }
1366
+
1367
+ res.render(__dirname + "/views/admin.html", {
1368
+ publicKey: config.recaptchaPublicKey,
1369
+ enableRecaptcha: config.enableRecaptcha,
1370
+ authent: authenticated,
1371
+ role: role,
1372
+ username: authenticated ? payload.username : "",
1373
+ success: false,
1374
+ errorAuthent: false,
1375
+ errorRecaptcha: false,
1376
+ locale: i18n.getLocale(req),
1377
+ games: games,
1378
+ io: io,
1379
+ config: role === "administrator" ? config : null,
1380
+ csrfToken: generateCsrfTokenAdmin(req, res, { overwrite: true, validateOnReuse: true }),
1381
+ serverLog: logFile,
1382
+ errorLog: errorLogFile,
1383
+ getIPSocketIO: getIPSocketIO,
1384
+ theme: req.query.theme
1385
+ });
1212
1386
  });
1213
1387
 
1214
- function adminAction(req, res, action) {
1215
- if(req.cookies) {
1216
- jwt.verify(req.cookies.tokenAdmin, jsonWebTokenSecretKeyAdmin, function(err, data) {
1217
- if(invalidatedAdminTokens.includes(req.cookies.tokenAdmin)) err = true;
1218
-
1219
- const usernames = Object.keys(config.adminAccounts);
1220
- const authenticated = !err && data && data.username && usernames.includes(data.username);
1221
-
1222
- if(authenticated) {
1223
- const username = data.username;
1224
- const role = config.adminAccounts[username]["role"] || "moderator";
1225
-
1226
- if(action == "disconnect") {
1227
- invalidatedAdminTokens.push(req.cookies.tokenAdmin);
1228
- res.cookie("tokenAdmin", { expires: -1 });
1229
- res.redirect("/admin");
1230
- return;
1231
- } else if(action) {
1232
- const socket = req.body.socket;
1233
- const token = req.body.token;
1234
- const value = req.body.value;
1235
-
1236
- switch(action) {
1237
- case "kick":
1238
- kickUser(socket, token);
1239
- break;
1240
- case "banIP":
1241
- if(value) {
1242
- manualIPBan(value);
1243
- } else {
1244
- banUserIP(socket);
1245
- kickUser(socket, token);
1246
- }
1247
- break;
1248
- case "banUserName":
1249
- if(value) {
1250
- manualUsernameBan(value);
1251
- kickUsername(value);
1252
- } else {
1253
- banUserName(token);
1254
- kickUser(socket, token);
1255
- }
1256
- break;
1257
- case "banIPUserName":
1258
- banUserIP(socket);
1259
- banUserName(token);
1260
- kickUser(socket, token);
1261
- break;
1262
- case "unbanUsername":
1263
- unbanUsername(value);
1264
- break;
1265
- case "unbanIP":
1266
- unbanIP(value);
1267
- break;
1268
- case "resetLog":
1269
- if(role === "administrator") resetLog();
1270
- break;
1271
- case "resetErrorLog":
1272
- if(role === "administrator") resetErrorLog();
1273
- break;
1274
- case "updateConfig":
1275
- if(role === "administrator") updateConfig(value);
1276
- break;
1277
- }
1388
+ async function adminAction(req, res, action) {
1389
+ if(!req.cookies) {
1390
+ res.status(400);
1391
+ return res.end();
1392
+ }
1278
1393
 
1279
- res.redirect("/admin");
1280
- return;
1281
- }
1282
- }
1394
+ const payload = await verifyAdminToken(req);
1283
1395
 
1284
- res.redirect("/admin");
1285
- return;
1286
- });
1287
- } else {
1288
- res.end();
1396
+ if(!payload) {
1397
+ return res.redirect(303, "/admin");
1398
+ }
1399
+
1400
+ const role = config.adminAccounts[payload.username]["role"] || "moderator";
1401
+
1402
+ if(action === "disconnect") {
1403
+ invalidatedAdminTokens.add(req.cookies.tokenAdmin);
1404
+ res.clearCookie("tokenAdmin");
1405
+
1406
+ return res.redirect(303, "/admin");
1407
+ }
1408
+
1409
+ const { socket, token, value } = req.body;
1410
+
1411
+ switch(action) {
1412
+ case "kick":
1413
+ kickUser(socket, token);
1414
+ break;
1415
+ case "banIP":
1416
+ if(value) {
1417
+ manualIPBan(value);
1418
+ } else {
1419
+ banUserIP(socket);
1420
+ kickUser(socket, token);
1421
+ }
1422
+ break;
1423
+ case "banUserName":
1424
+ if(value) {
1425
+ manualUsernameBan(value);
1426
+ kickUsername(value);
1427
+ } else {
1428
+ await banUserName(token);
1429
+ kickUser(socket, token);
1430
+ }
1431
+ break;
1432
+ case "banIPUserName":
1433
+ banUserIP(socket);
1434
+ await banUserName(token);
1435
+ kickUser(socket, token);
1436
+ break;
1437
+ case "unbanUsername":
1438
+ unbanUsername(value);
1439
+ break;
1440
+ case "unbanIP":
1441
+ unbanIP(value);
1442
+ break;
1443
+ case "resetLog":
1444
+ if(role === "administrator") {
1445
+ resetLog();
1446
+ } else {
1447
+ res.status(403);
1448
+ return res.end();
1449
+ }
1450
+ break;
1451
+ case "resetErrorLog":
1452
+ if(role === "administrator") {
1453
+ resetErrorLog();
1454
+ } else {
1455
+ res.status(403);
1456
+ return res.end();
1457
+ }
1458
+ break;
1459
+ case "updateConfig":
1460
+ if(role === "administrator") {
1461
+ updateConfig(value);
1462
+ } else {
1463
+ res.status(403);
1464
+ return res.end();
1465
+ }
1466
+ break;
1289
1467
  }
1468
+
1469
+ res.redirect(303, "/admin");
1290
1470
  }
1291
1471
 
1292
1472
  const jsonParser = bodyParser.json();
1293
1473
 
1294
- app.post("/admin/:action", jsonParser, doubleCsrfProtection, function(req, res) {
1474
+ app.post("/admin/:action", adminActionsRateLimiter, jsonParser, doubleCsrfProtectionAdmin, function(req, res) {
1295
1475
  adminAction(req, res, req.params.action);
1296
1476
  });
1297
1477
 
@@ -1301,113 +1481,123 @@ app.use(function (err, req, res, next) {
1301
1481
  res.send("Error: invalid CSRF token");
1302
1482
  });
1303
1483
 
1304
- const adminRateLimiter = rateLimit({
1305
- windowMs: config.authentWindowMs,
1306
- max: config.authentMaxRequest,
1307
- validate: { trustProxy: false }
1308
- });
1484
+ app.post("/admin", adminAuthentRateLimiter, async (req, res) => {
1485
+ if(!req.cookies) {
1486
+ res.status(400);
1487
+ return res.end();
1488
+ }
1309
1489
 
1310
- app.post("/admin", adminRateLimiter, function(req, res) {
1311
- if(req.cookies) {
1312
- jwt.verify(req.cookies.tokenAdmin, jsonWebTokenSecretKeyAdmin, function(err, data) {
1313
- if(invalidatedAdminTokens.includes(req.cookies.tokenAdmin)) res = true;
1490
+ const payload = await verifyAdminToken(req);
1314
1491
 
1315
- if(err) {
1316
- verifyFormAuthenticationAdmin(req.body).then(() => {
1317
- const username = req.body["username"];
1492
+ if(payload) {
1493
+ return res.redirect(303, "/admin");
1494
+ }
1318
1495
 
1319
- const token = jwt.sign({
1320
- username: username
1321
- }, jsonWebTokenSecretKeyAdmin, { expiresIn: config.authenticationTime / 1000 });
1322
-
1323
- res.cookie("tokenAdmin", token, { expires: new Date(Date.now() + config.authenticationTime), httpOnly: true, sameSite: "strict", secure: (req.protocol == "https" ? true : false) });
1324
- res.redirect("/admin");
1325
- logger.info("admin authent - username: " + username + " - ip: " + req.ip);
1326
- return;
1327
- }, (err) => {
1328
- res.render(__dirname + "/views/admin.html", {
1329
- publicKey: config.recaptchaPublicKey,
1330
- enableRecaptcha: config.enableRecaptcha,
1331
- authent: false,
1332
- errorAuthent: true,
1333
- errorRecaptcha: err == "INVALID_RECAPTCHA",
1334
- locale: i18n.getLocale(req),
1335
- games: null,
1336
- io: null,
1337
- config: null,
1338
- csrfToken: null,
1339
- serverLog: null,
1340
- errorLog: null,
1341
- getIPSocketIO: getIPSocketIO,
1342
- theme: req.query.theme
1343
- });
1344
- });
1345
- }
1496
+ try {
1497
+ await verifyFormAuthenticationAdmin(req.body);
1498
+ } catch(err) {
1499
+ res.status(403);
1500
+
1501
+ return res.render(__dirname + "/views/admin.html", {
1502
+ publicKey: config.recaptchaPublicKey,
1503
+ enableRecaptcha: config.enableRecaptcha,
1504
+ authent: false,
1505
+ errorAuthent: true,
1506
+ errorRecaptcha: err == "INVALID_RECAPTCHA",
1507
+ locale: i18n.getLocale(req),
1508
+ games: null,
1509
+ io: null,
1510
+ config: null,
1511
+ csrfToken: null,
1512
+ serverLog: null,
1513
+ errorLog: null,
1514
+ getIPSocketIO: getIPSocketIO,
1515
+ theme: req.query.theme
1346
1516
  });
1347
- } else {
1348
- res.end();
1349
1517
  }
1518
+
1519
+ const username = req.body["username"];
1520
+
1521
+ const token = await new SignJWT({ username })
1522
+ .setProtectedHeader({ alg: "HS256" })
1523
+ .setExpirationTime(Math.floor(config.authenticationTime / 1000) + "s")
1524
+ .setIssuedAt()
1525
+ .sign(jsonWebTokenSecretKeyAdmin);
1526
+
1527
+ res.cookie("tokenAdmin", token, {
1528
+ expires: new Date(Date.now() + config.authenticationTime),
1529
+ httpOnly: true,
1530
+ sameSite: "strict",
1531
+ secure: req.protocol === "https"
1532
+ });
1533
+
1534
+ res.redirect(303, "/admin");
1535
+
1536
+ logger.info("admin authent - username: " + username + " - ip: " + req.ip);
1350
1537
  });
1351
1538
 
1352
1539
  io.use(ioCookieParser());
1353
1540
 
1354
1541
  function getIPSocketIO(req) {
1355
- let ipAddress;
1356
-
1357
- if(req && req.headers) {
1542
+ if(req?.headers) {
1358
1543
  const forwardedIpsStr = req.headers["x-forwarded-for"];
1359
-
1360
- if(forwardedIpsStr && forwardedIpsStr !== undefined && config.proxyMode) {
1361
- const forwardedIps = forwardedIpsStr.split(",");
1362
- ipAddress = forwardedIps[0];
1363
- }
1364
-
1365
- if(!ipAddress) {
1366
- ipAddress = req.address;
1544
+
1545
+ if(forwardedIpsStr && config.proxyMode) {
1546
+ const forwardedIps = forwardedIpsStr.split(",").map(ip => ip.trim());
1547
+ const index = forwardedIps.length - config.numberOfProxies;
1548
+
1549
+ if(index >= 0) {
1550
+ return forwardedIps[index];
1551
+ }
1367
1552
  }
1368
- }
1369
1553
 
1370
- return ipAddress;
1554
+ return req.address;
1555
+ }
1371
1556
  }
1372
1557
 
1373
- function checkAuthentication(token) {
1374
- return new Promise((resolve, reject) => {
1375
- if(!config.enableAuthentication) {
1376
- resolve();
1377
- } else {
1378
- if(token && invalidatedUserTokens.includes(token)) reject();
1379
-
1380
- jwt.verify(token, jsonWebTokenSecretKey, function(err, data) {
1381
- if(!err) {
1382
- resolve(token);
1383
- } else {
1384
- reject();
1385
- }
1386
- });
1387
- }
1388
- });
1558
+ async function checkAuthentication(token) {
1559
+ if(!config.enableAuthentication) {
1560
+ return;
1561
+ }
1562
+
1563
+ if(!token || invalidatedUserTokens.has(token)) {
1564
+ throw new Error("UNAUTHORIZED");
1565
+ }
1566
+
1567
+ try {
1568
+ await jwtVerify(token, jsonWebTokenSecretKey);
1569
+ return token;
1570
+ } catch {
1571
+ throw new Error("UNAUTHORIZED");
1572
+ }
1389
1573
  }
1390
1574
 
1391
1575
  function checkAuthenticationSocket(socket) {
1392
- return checkAuthentication(socket?.handshake?.auth?.token || socket?.handshake?.query?.token || socket?.request?.cookies?.token);
1576
+ return checkAuthentication(Player.getSocketToken(socket));
1577
+ }
1578
+
1579
+ function getExpressUserToken(req) {
1580
+ return req.cookies.token;
1393
1581
  }
1394
1582
 
1395
1583
  function checkAuthenticationExpress(req) {
1396
- return checkAuthentication(req.cookies.token);
1584
+ return checkAuthentication(getExpressUserToken(req));
1397
1585
  }
1398
1586
 
1399
1587
  const checkBanned = function(socket, next) {
1400
- ipBanned(getIPSocketIO(socket.handshake)).then(() => {
1401
- next(new Error(GameConstants.Error.BANNED));
1402
- }, () => {
1403
- next();
1404
- });
1588
+ if(ipBanned(getIPSocketIO(socket.handshake))) {
1589
+ return next(new Error(GameConstants.Error.BANNED));
1590
+ }
1591
+
1592
+ next();
1405
1593
  };
1406
1594
 
1407
1595
  io.use(checkBanned);
1408
1596
 
1409
- io.of("/rooms").use(ioCookieParser()).use(checkBanned).on("connection", function(socket) {
1410
- checkAuthenticationSocket(socket).then(() => {
1597
+ io.of("/rooms").use(ioCookieParser()).use(checkBanned).on("connection", async (socket) => {
1598
+ try {
1599
+ await checkAuthenticationSocket(socket);
1600
+
1411
1601
  socket.emit("rooms", {
1412
1602
  rooms: getRoomsData(),
1413
1603
  serverVersion: config.version,
@@ -1421,49 +1611,59 @@ io.of("/rooms").use(ioCookieParser()).use(checkBanned).on("connection", function
1421
1611
  enableAI: config.enableAI
1422
1612
  }
1423
1613
  });
1424
- }, () => {
1425
- socket.emit("authent", GameConstants.Error.AUTHENTICATION_REQUIRED);
1426
- });
1614
+ } catch(e) {
1615
+ emitAuthenticationRequired(socket);
1616
+ }
1427
1617
  });
1428
1618
 
1429
- io.of("/createRoom").use(ioCookieParser()).use(checkBanned).on("connection", function(socket) {
1430
- socket.on("create", function(data) {
1431
- checkAuthenticationSocket(socket).then(() => {
1432
- createRoom(data, socket);
1433
- }, () => {
1434
- socket.emit("authent", GameConstants.Error.AUTHENTICATION_REQUIRED);
1435
- });
1619
+ io.of("/createRoom").use(ioCookieParser()).use(checkBanned).on("connection", (socket) => {
1620
+ socket.on("create", async (data) => {
1621
+ try {
1622
+ await checkAuthenticationSocket(socket);
1623
+ await createRoom(data, socket);
1624
+ } catch {
1625
+ emitAuthenticationRequired(socket);
1626
+ }
1436
1627
  });
1437
1628
  });
1438
1629
 
1439
- io.on("connection", function(socket) {
1440
- checkAuthenticationSocket(socket).then((token) => {
1630
+ io.on("connection", async (socket) => {
1631
+ try {
1632
+ const token = await checkAuthenticationSocket(socket);
1633
+ const username = await Player.getUsernameToken(token, jsonWebTokenSecretKey);
1634
+
1635
+ if(!username) return;
1636
+
1441
1637
  socket.emit("authent", GameConstants.GameState.AUTHENTICATION_SUCCESS);
1442
- tokens.push(token);
1638
+ tokens.set(username.toLowerCase(), token);
1443
1639
 
1444
- socket.on("join-room", function(data) {
1445
- const code = data.code;
1446
- const version = data.version;
1640
+ socket.on("join-room", async (data) => {
1641
+ const { code, version } = data;
1447
1642
  const game = games[code];
1448
-
1449
- if(game != null && !Player.containsId(game.players, socket.id) && !Player.containsId(game.spectators, socket.id) && !Player.containsToken(game.players, token) && !Player.containsToken(game.spectators, token) && !Player.containsTokenAllGames(token, games) && !Player.containsIdAllGames(socket.id, games)) {
1643
+
1644
+ if(game != null
1645
+ && !Player.containsId(game.players, socket.id)
1646
+ && !Player.containsId(game.spectators, socket.id)
1647
+ && !Player.containsToken(game.players, token)
1648
+ && !Player.containsToken(game.spectators, token)
1649
+ && !Player.containsTokenAllGames(token, games)
1650
+ && !Player.containsIdAllGames(socket.id, games)
1651
+ ) {
1450
1652
  socket.join("room-" + code);
1451
-
1653
+
1452
1654
  if(game.players.length + game.numberAIToAdd >= getMaxPlayers(code) || game.started) {
1453
- game.spectators.push(new Player(token, socket.id, null, false, version));
1655
+ game.spectators.push(new Player(token, username, socket.id, null, false, version));
1454
1656
  } else {
1455
- game.players.push(new Player(token, socket.id, null, false, version));
1657
+ game.players.push(new Player(token, username, socket.id, null, false, version));
1456
1658
  }
1457
-
1458
- socket.emit("join-room", {
1459
- success: true
1460
- });
1461
-
1659
+
1660
+ socket.emit("join-room", { success: true });
1661
+
1462
1662
  socket.once("start", () => {
1463
1663
  if(Player.containsId(game.players, socket.id)) {
1464
1664
  Player.getPlayer(game.players, socket.id).ready = true;
1465
1665
  }
1466
-
1666
+
1467
1667
  if(game.started) {
1468
1668
  socket.emit("init", {
1469
1669
  "paused": game.gameEngine.paused,
@@ -1485,6 +1685,8 @@ io.on("connection", function(socket) {
1485
1685
  "confirmExit": false,
1486
1686
  "getInfos": false,
1487
1687
  "getInfosGame": false,
1688
+ "getInfosControls": false,
1689
+ "getInfosGoal": false,
1488
1690
  "errorOccurred": game.gameEngine.errorOccurred,
1489
1691
  "timerToDisplay": config.enableMaxTimeGame ? (config.maxTimeGame - (Date.now() - game.timeStart)) / 1000 : -1,
1490
1692
  "countBeforePlay": game.gameEngine.countBeforePlay,
@@ -1502,89 +1704,120 @@ io.on("connection", function(socket) {
1502
1704
  "offsetFrame": game.gameEngine.speed * GameConstants.Setting.TIME_MULTIPLIER
1503
1705
  });
1504
1706
  }
1505
-
1707
+
1506
1708
  gameMatchmaking(game, code);
1507
-
1709
+
1508
1710
  socket.on("start", () => {
1509
- socket.emit("start", {
1510
- "paused": false
1511
- });
1711
+ socket.emit("start", { "paused": false });
1512
1712
  });
1513
1713
  });
1514
-
1515
- socket.once("exit", () => {
1516
- exitGame(game, socket, code);
1517
- });
1518
-
1519
- socket.once("kill", () => {
1520
- exitGame(game, socket, code);
1521
- });
1522
-
1523
- socket.on("key", function(key) {
1714
+
1715
+ socket.once("exit", () => exitGame(game, socket, code));
1716
+ socket.once("kill", () => exitGame(game, socket, code));
1717
+ socket.once("error", () => exitGame(game, socket, code));
1718
+ socket.once("disconnect", () => exitGame(game, socket, code));
1719
+
1720
+ socket.on("key", (key) => {
1524
1721
  if(game != null && Player.containsId(game.players, socket.id) && Player.getPlayer(game.players, socket.id).snake) {
1525
1722
  Player.getPlayer(game.players, socket.id).snake.lastKey = key;
1526
1723
  sendStatus(code);
1527
1724
  }
1528
1725
  });
1529
-
1530
- socket.on("pause", () => {
1531
- socket.emit("pause", {
1532
- "paused": true
1533
- });
1534
- });
1535
-
1726
+
1727
+ socket.on("pause", () => socket.emit("pause", { "paused": true }));
1728
+
1536
1729
  socket.on("reset", () => {
1537
1730
  if(!game.started) {
1538
1731
  gameMatchmaking(game, code);
1539
-
1540
- socket.emit("reset", {
1541
- "gameOver": false,
1542
- "gameFinished": false,
1543
- "scoreMax": false,
1544
- "gameMazeWin": false
1545
- });
1732
+ socket.emit("reset", { "gameOver": false, "gameFinished": false, "scoreMax": false, "gameMazeWin": false });
1546
1733
  }
1547
1734
  });
1548
-
1549
- socket.once("error", () => {
1550
- exitGame(game, socket, code);
1551
- });
1552
-
1553
- socket.once("disconnect", () => {
1554
- exitGame(game, socket, code);
1555
- });
1556
-
1735
+
1557
1736
  socket.on("forceStart", () => {
1558
1737
  if(game != null && Player.containsId(game.players, socket.id) && game.players[0].id == socket.id && !game.started) {
1559
1738
  startGame(code);
1560
1739
  }
1561
1740
  });
1562
-
1563
- logger.info("join room (code: " + code + ") - username: " + Player.getUsernameSocket(socket) + " - ip: " + getIPSocketIO(socket.handshake) + " - socket: " + socket.id);
1741
+
1742
+ logger.info("join room (code: " + code + ") - username: " + (await Player.getUsernameSocket(socket, jsonWebTokenSecretKey)) + " - ip: " + getIPSocketIO(socket.handshake) + " - socket: " + socket.id);
1564
1743
  } else {
1565
1744
  if(games[code] == null) {
1566
- socket.emit("join-room", {
1567
- success: false,
1568
- errorCode: GameConstants.Error.ROOM_NOT_FOUND
1569
- });
1570
- } else if(Player.containsId(game.players, socket.id) || Player.containsId(game.spectators, socket.id) || Player.containsToken(game.players, token) || Player.containsToken(game.spectators, token)) {
1571
- socket.emit("join-room", {
1572
- success: false,
1573
- errorCode: GameConstants.Error.ROOM_ALREADY_JOINED
1574
- });
1745
+ socket.emit("join-room", { success: false, errorCode: GameConstants.Error.ROOM_NOT_FOUND });
1746
+ } else if(
1747
+ Player.containsId(game.players, socket.id)
1748
+ || Player.containsId(game.spectators, socket.id)
1749
+ || Player.containsToken(game.players, token)
1750
+ || Player.containsToken(game.spectators, token)
1751
+ ) {
1752
+ socket.emit("join-room", { success: false, errorCode: GameConstants.Error.ROOM_ALREADY_JOINED });
1575
1753
  } else if(Player.containsTokenAllGames(token, games) || Player.containsIdAllGames(socket.id, games)) {
1576
- socket.emit("join-room", {
1577
- success: false,
1578
- errorCode: GameConstants.Error.ALREADY_CREATED_ROOM
1579
- });
1754
+ socket.emit("join-room", { success: false, errorCode: GameConstants.Error.ALREADY_CREATED_ROOM });
1580
1755
  }
1581
1756
  }
1582
1757
  });
1583
- }, () => {
1584
- socket.emit("authent", GameConstants.Error.AUTHENTICATION_REQUIRED);
1585
- });
1758
+ } catch {
1759
+ emitAuthenticationRequired(socket);
1760
+ }
1586
1761
  });
1587
1762
 
1763
+ function emitAuthenticationRequired(socket) {
1764
+ const sessionId = socket.request.cookies.sessionId;
1765
+
1766
+ if(sessionId) {
1767
+ socketSessions.set(sessionId, socket.id);
1768
+ }
1769
+
1770
+ socket.on("disconnect", () => {
1771
+ socketSessions.delete(sessionId);
1772
+ });
1773
+
1774
+ socket.emit("authent", GameConstants.Error.AUTHENTICATION_REQUIRED);
1775
+ }
1776
+
1777
+ function isTokenExpired(token) {
1778
+ try {
1779
+ const decoded = decodeJwt(token);
1780
+ const now = Math.floor(Date.now() / 1000);
1781
+
1782
+ return !decoded?.exp || decoded.exp < now;
1783
+ } catch {
1784
+ return true;
1785
+ }
1786
+ }
1787
+
1788
+ function cleanMap(map) {
1789
+ for(const [key, token] of map.entries()) {
1790
+ if(isTokenExpired(token)) {
1791
+ map.delete(key);
1792
+ }
1793
+ }
1794
+ }
1795
+
1796
+ function cleanSet(set) {
1797
+ for(const token of set) {
1798
+ if(isTokenExpired(token)) {
1799
+ set.delete(token);
1800
+ }
1801
+ }
1802
+ }
1803
+
1804
+ function cleanSocketSessions() {
1805
+ for(const [sessionId, socketId] of socketSessions.entries()) {
1806
+ if(!io.sockets.sockets.get(socketId)) {
1807
+ socketSessions.delete(sessionId);
1808
+ }
1809
+ }
1810
+ }
1811
+
1812
+ function cleanupTokenMaps() {
1813
+ cleanMap(tokens);
1814
+ cleanSet(invalidatedUserTokens);
1815
+ cleanSet(invalidatedAdminTokens);
1816
+ cleanSocketSessions();
1817
+ }
1818
+
1588
1819
  server.listen(config.port, () => {
1589
1820
  console.log("listening on *:" + config.port);
1590
- });
1821
+ });
1822
+
1823
+ setInterval(cleanupTokenMaps, 60 * 1000);