From 0b540414ecde2a027e2ab737f47011a782b6fb73 Mon Sep 17 00:00:00 2001 From: Kim Ravn Hansen Date: Tue, 21 Oct 2025 11:50:15 +0200 Subject: [PATCH] Refactor --- scenes/authentication/authenticationScene.js | 144 +++++++++++++++++- scenes/authentication/passwordPrompt.js | 84 ---------- scenes/authentication/usernamePrompt.js | 60 -------- scenes/gameLoop/gameScene.js | 2 +- scenes/playerCreation/createUasswprdPrompt.js | 6 +- scenes/playerCreation/createUsernamePrompt.js | 4 +- scenes/playerCreation/playerCreationSene.js | 4 +- scenes/scene.js | 23 ++- utils/security.js | 94 ++++++------ 9 files changed, 211 insertions(+), 210 deletions(-) delete mode 100755 scenes/authentication/passwordPrompt.js delete mode 100755 scenes/authentication/usernamePrompt.js diff --git a/scenes/authentication/authenticationScene.js b/scenes/authentication/authenticationScene.js index aafb79a..77b8d9f 100755 --- a/scenes/authentication/authenticationScene.js +++ b/scenes/authentication/authenticationScene.js @@ -1,8 +1,10 @@ -import { PasswordPrompt } from "./passwordPrompt.js"; -import { Scene } from "../scene.js"; -import { UsernamePrompt } from "./usernamePrompt.js"; -import { PlayerCreationScene } from "../playerCreation/playerCreationSene.js"; +import { Security } from "../../utils/security.js"; +import { Config } from "../../config.js"; import { GameScene } from "../gameLoop/gameScene.js"; +import { PlayerCreationScene } from "../playerCreation/playerCreationSene.js"; +import { Prompt } from "../prompt.js"; +import { Scene } from "../scene.js"; +import { gGame } from "../../models/globals.js"; /** @typedef {import("../../models/player.js").Player} Player */ @@ -16,7 +18,6 @@ export class AuthenticationScene extends Scene { player; onReady() { - // current prompt this.show(UsernamePrompt); } @@ -44,3 +45,136 @@ export class AuthenticationScene extends Scene { this.session.setScene(new PlayerCreationScene(this.session)); } } + +class UsernamePrompt extends Prompt { + // + promptText = [ + "Please enter your username:", // + "(((type *:create* if you want to create a new user)))", // + ]; + + // + // When player types :help + helpText = [ + "This is where you log in.", + "If you don't already have a player profile on this server, you can type *:create* to create one", + ]; + + // + // Let the client know that we're asking for a username + promptOptions = { username: true }; + + /** @returns {AuthenticationScene} */ + get scene() { + return this._scene; + } + + // + // User replied to our prompt + onReply(username) { + // + // do basic syntax checks on usernames + if (!Security.isUsernameSane(username)) { + console.info("Someone entered insane username: '%s'", username); + this.sendError("Incorrect username, try again"); + this.execute(); + return; + } + + // + // try and fetch the player object from the game + const player = gGame.getPlayerByUsername(username); + + // + // handle invalid username + if (!player) { + console.info("Someone entered incorrect username: '%s'", username); + this.sendError("Incorrect username, try again"); + this.execute(); + return; + } + + // + // Tell daddy that we're done + this.scene.usernameAccepted(player); + } +} + +class PasswordPrompt extends Prompt { + // + promptText = "Please enter your password"; + + // + // Let the client know that we're asking for a password + // so it can set + promptOptions = { password: true }; + + get player() { + return this.scene.player; + } + + /** @returns {AuthenticationScene} */ + get scene() { + return this._scene; + } + + onReply(text) { + // + // Check of the password is sane. This is both bad from a security point + // of view, and technically not necessary as insane passwords couldn't + // reside in the player lists. However, let's save some CPU cycles on + // not hashing an insane password 1000+ times. + // This is technically bad practice, but since this is just a game, + // do it anyway. + if (!Security.isPasswordSane(text)) { + this.sendError("Insane password"); + this.execute(); + return; + } + + // + // Block users who enter bad passwords too many times. + if (this.player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) { + this.blockedUntil = Date.now() + Config.accountLockoutSeconds * 1000; + this.calamity("You have been locked out for too many failed password attempts, come back later"); + return; + } + + // + // Handle blocked users. + // They don't even get to have their password verified. + if (this.player.blockedUntil > Date.now()) { + // + // Try to re-login too soon, and your lockout lasts longer. + this.blockedUntil += Config.accountLockoutSeconds * 1000; + this.calamity("You have been locked out for too many failed password attempts, come back later"); + return; + } + + // + // Verify the password against the hash we've stored. + if (!Security.verifyPassword(text, this.player.passwordHash)) { + this.sendError("Incorrect password!"); + this.player.failedPasswordsSinceLastLogin++; + + this.session.sendDebug(`Failed login attempt #${this.player.failedPasswordsSinceLastLogin}`); + this.execute(); + + return; + } + + this.player.lastSucessfulLoginAt = new Date(); + this.player.failedPasswordsSinceLastLogin = 0; + + // + // We do not allow a player to be logged in more than once! + if (this.player.loggedIn) { + this.calamity("This player is already logged in"); + return; + } + + // Password was correct, go to main game + // this.scene.passwordAccepted(); + this.scene.passwordAccepted(); + } +} diff --git a/scenes/authentication/passwordPrompt.js b/scenes/authentication/passwordPrompt.js deleted file mode 100755 index 511bad8..0000000 --- a/scenes/authentication/passwordPrompt.js +++ /dev/null @@ -1,84 +0,0 @@ -import { Prompt } from "../prompt.js"; -import * as security from "../../utils/security.js"; -import { Config } from "../../config.js"; - -/** @typedef {import("./authentication.js").AuthenticationScene} AuthenticationScene */ - -export class PasswordPrompt extends Prompt { - // - promptText = "Please enter your password"; - - // - // Let the client know that we're asking for a password - // so it can set - promptOptions = { password: true }; - - get player() { - return this.scene.player; - } - - /** @returns {AuthenticationScene} */ - get scene() { - return this._scene; - } - - onReply(text) { - // - // Check of the password is sane. This is both bad from a security point - // of view, and technically not necessary as insane passwords couldn't - // reside in the player lists. However, let's save some CPU cycles on - // not hashing an insane password 1000+ times. - // This is technically bad practice, but since this is just a game, - // do it anyway. - if (!security.isPasswordSane(text)) { - this.sendError("Insane password"); - this.execute(); - return; - } - - // - // Block users who enter bad passwords too many times. - if (this.player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) { - this.blockedUntil = Date.now() + Config.accountLockoutSeconds * 1000; - this.calamity("You have been locked out for too many failed password attempts, come back later"); - return; - } - - // - // Handle blocked users. - // They don't even get to have their password verified. - if (this.player.blockedUntil > Date.now()) { - // - // Try to re-login too soon, and your lockout lasts longer. - this.blockedUntil += Config.accountLockoutSeconds * 1000; - this.calamity("You have been locked out for too many failed password attempts, come back later"); - return; - } - - // - // Verify the password against the hash we've stored. - if (!security.verifyPassword(text, this.player.passwordHash)) { - this.sendError("Incorrect password!"); - this.player.failedPasswordsSinceLastLogin++; - - this.session.sendDebug(`Failed login attempt #${this.player.failedPasswordsSinceLastLogin}`); - this.execute(); - - return; - } - - this.player.lastSucessfulLoginAt = new Date(); - this.player.failedPasswordsSinceLastLogin = 0; - - // - // We do not allow a player to be logged in more than once! - if (this.player.loggedIn) { - this.calamity("This player is already logged in"); - return; - } - - // Password was correct, go to main game - // this.scene.passwordAccepted(); - this.scene.passwordAccepted(); - } -} diff --git a/scenes/authentication/usernamePrompt.js b/scenes/authentication/usernamePrompt.js deleted file mode 100755 index 0b52c1e..0000000 --- a/scenes/authentication/usernamePrompt.js +++ /dev/null @@ -1,60 +0,0 @@ -import { Prompt } from "../prompt.js"; -import { gGame } from "../../models/globals.js"; -import * as security from "../../utils/security.js"; - -/** @typedef {import("./authenticationScene.js").AuthenticationScene} AuthenticationScene */ -/** @typedef {import("../../models/player.js").Player} Player */ - -export class UsernamePrompt extends Prompt { - // - promptText = [ - "Please enter your username:", // - "(((type *:create* if you want to create a new user)))", // - ]; - - // - // When player types :help - helpText = [ - "This is where you log in.", - "If you don't already have a player profile on this server, you can type *:create* to create one", - ]; - - // - // Let the client know that we're asking for a username - promptOptions = { username: true }; - - /** @returns {AuthenticationScene} */ - get scene() { - return this._scene; - } - - // - // User replied to our prompt - onReply(username) { - // - // do basic syntax checks on usernames - if (!security.isUsernameSane(username)) { - console.info("Someone entered insane username: '%s'", username); - this.sendError("Incorrect username, try again"); - this.execute(); - return; - } - - // - // try and fetch the player object from the game - const player = gGame.getPlayerByUsername(username); - - // - // handle invalid username - if (!player) { - console.info("Someone entered incorrect username: '%s'", username); - this.sendError("Incorrect username, try again"); - this.execute(); - return; - } - - // - // Tell daddy that we're done - this.scene.usernameAccepted(player); - } -} diff --git a/scenes/gameLoop/gameScene.js b/scenes/gameLoop/gameScene.js index 972d617..43db875 100755 --- a/scenes/gameLoop/gameScene.js +++ b/scenes/gameLoop/gameScene.js @@ -34,7 +34,7 @@ export class GameScene extends Scene { // If player does not have a previous session // then we start in the Adventurers Guild in the Hovedstad // - this.doPrompt("new command prompt or whatever"); + this.showBasicPrompt(this.castle); } get castle() { diff --git a/scenes/playerCreation/createUasswprdPrompt.js b/scenes/playerCreation/createUasswprdPrompt.js index 1fee7ce..e879219 100755 --- a/scenes/playerCreation/createUasswprdPrompt.js +++ b/scenes/playerCreation/createUasswprdPrompt.js @@ -1,5 +1,5 @@ import { Prompt } from "../prompt.js"; -import * as security from "../../utils/security.js"; +import { Security } from "../../utils/security.js"; import { Config } from "../../config.js"; export class CreatePasswordPrompt extends Prompt { @@ -23,7 +23,7 @@ export class CreatePasswordPrompt extends Prompt { // not hashing an insane password 1000+ times. // This is technically bad practice, but since this is just a game, // do it anyway. - if (!security.isPasswordSane(text)) { + if (!Security.isPasswordSane(text)) { this.sendError("Insane password"); this.execute(); return; @@ -50,7 +50,7 @@ export class CreatePasswordPrompt extends Prompt { // // Verify the password against the hash we've stored. - if (!security.verifyPassword(text, this.player.passwordHash)) { + if (!Security.verifyPassword(text, this.player.passwordHash)) { this.sendError("Incorrect password!"); this.player.failedPasswordsSinceLastLogin++; diff --git a/scenes/playerCreation/createUsernamePrompt.js b/scenes/playerCreation/createUsernamePrompt.js index a75219a..71056d2 100755 --- a/scenes/playerCreation/createUsernamePrompt.js +++ b/scenes/playerCreation/createUsernamePrompt.js @@ -1,5 +1,5 @@ import { Prompt } from "../prompt.js"; -import * as security from "../../utils/security.js"; +import { Security } from "../../utils/security.js"; import { gGame } from "../../models/globals.js"; /** @typedef {import("./playerCreationScene.js").PlayerCreationScene} PlayerCreationScene */ @@ -35,7 +35,7 @@ export class CreateUsernamePrompt extends Prompt { onReply(username) { // // do basic syntax checks on usernames - if (!security.isUsernameSane(username)) { + if (!Security.isUsernameSane(username)) { console.info("Someone entered insane username: '%s'", username); this.sendError("Incorrect username, try again."); this.execute(); diff --git a/scenes/playerCreation/playerCreationSene.js b/scenes/playerCreation/playerCreationSene.js index 8f69fcd..3de20dd 100755 --- a/scenes/playerCreation/playerCreationSene.js +++ b/scenes/playerCreation/playerCreationSene.js @@ -1,6 +1,6 @@ import { Config } from "../../config.js"; import { gGame } from "../../models/globals.js"; -import { generateHash } from "../../utils/security.js"; +import { Security } from "../../utils/security.js"; import { Scene } from "../scene.js"; import { CreateUsernamePrompt } from "./createUsernamePrompt.js"; @@ -48,6 +48,6 @@ export class PlayerCreationScene extends Scene { passwordAccepted(password) { this.password = password; this.session.sendText("*_Success_* ✅ You will now be asked to log in again, sorry for that ;)"); - this.player.setPasswordHash(generateHash(this.password)); + this.player.setPasswordHash(Security.generateHash(this.password)); } } diff --git a/scenes/scene.js b/scenes/scene.js index d8c040f..425f02c 100755 --- a/scenes/scene.js +++ b/scenes/scene.js @@ -21,10 +21,13 @@ export class Scene { */ introText = ""; + /** @constant @readonly @type {Prompt?} */ + introPrompt; + /** @readonly @constant @protected @type {Session} */ - _session; + #session; get session() { - return this._session; + return this.#session; } /** @@ -34,20 +37,26 @@ export class Scene { * @readonly * @type {Prompt} */ - _currentPrompt; + #currentPrompt; get currentPrompt() { - return this._currentPrompt; + return this.#currentPrompt; } constructor() {} /** @param {Session} session */ execute(session) { - this._session = session; + this.#session = session; + if (this.introText) { this.session.sendText(this.introText); } - this.onReady(); + + if (this.introPrompt) { + this.showPrompt(this.introPrompt); + } else { + this.onReady(); + } } /** @abstract */ @@ -59,7 +68,7 @@ export class Scene { * @param {Prompt} prompt */ showPrompt(prompt) { - this._currentPrompt = prompt; + this.#currentPrompt = prompt; prompt.execute(); } diff --git a/utils/security.js b/utils/security.js index cae5770..914235c 100755 --- a/utils/security.js +++ b/utils/security.js @@ -6,53 +6,55 @@ const ITERATIONS = 1000; // MAGIC CONSTANT - move to Config const DIGEST = "sha256"; // MAGIC CONSTANT - move to Config const KEYLEN = 32; // MAGIC CONSTANT - move to Config -/** - * Generate a hash from a string - * @param {string} source @returns {string} - */ -export function generateHash(source) { - const salt = randomBytes(16).toString("hex"); // 128-bit salt - const hash = pbkdf2Sync(source, salt, ITERATIONS, KEYLEN, DIGEST).toString("hex"); - return `${ITERATIONS}:${salt}:${hash}`; -} - -/** - * Verify that a password is correct against a given hash. - * - * @param {string} password_candidate - * @param {string} stored_password_hash - * @returns {boolean} - */ -export function verifyPassword(password_candidate, stored_password_hash) { - const [iterations, salt, hash] = stored_password_hash.split(":"); - const derived = pbkdf2Sync(password_candidate, salt, Number(iterations), KEYLEN, DIGEST).toString("hex"); - const success = hash === derived; - if (Config.dev) { - console.debug( - "Verifying password:\n" + - " Input : %s (the password as it was sent to us by the client)\n" + - " Given : %s (the input password hashed by us (not necessary for validation))\n" + - " Stored : %s (the password hash we have on file for the player)\n" + - " Derived : %s (the hashed version of the input password)\n" + - " Verified : %s (was the password valid)", - password_candidate, - generateHash(password_candidate), - stored_password_hash, - derived, - success, - ); +export class Security { + /** + * Generate a hash from a string + * @param {string} source @returns {string} + */ + static generateHash(source) { + const salt = randomBytes(16).toString("hex"); // 128-bit salt + const hash = pbkdf2Sync(source, salt, ITERATIONS, KEYLEN, DIGEST).toString("hex"); + return `${ITERATIONS}:${salt}:${hash}`; } - return success; -} -/** @param {string} candidate */ -export function isUsernameSane(candidate) { - return Config.usernameSanityRegex.test(candidate); -} + /** + * Verify that a password is correct against a given hash. + * + * @param {string} password_candidate + * @param {string} stored_password_hash + * @returns {boolean} + */ + static verifyPassword(password_candidate, stored_password_hash) { + const [iterations, salt, hash] = stored_password_hash.split(":"); + const derived = pbkdf2Sync(password_candidate, salt, Number(iterations), KEYLEN, DIGEST).toString("hex"); + const success = hash === derived; + if (Config.dev) { + console.debug( + "Verifying password:\n" + + " Input : %s (the password as it was sent to us by the client)\n" + + " Given : %s (the input password hashed by us (not necessary for validation))\n" + + " Stored : %s (the password hash we have on file for the player)\n" + + " Derived : %s (the hashed version of the input password)\n" + + " Verified : %s (was the password valid)", + password_candidate, + this.generateHash(password_candidate), + stored_password_hash, + derived, + success, + ); + } + return success; + } -/** @param {string} candidate */ -export function isPasswordSane(candidate) { - // We know the password must adhere to one of our client-side-hashed crypto schemes, - // so we can be fairly strict with the allowed passwords - return Config.passwordHashSanityRegex.test(candidate); + /** @param {string} candidate */ + static isUsernameSane(candidate) { + return Config.usernameSanityRegex.test(candidate); + } + + /** @param {string} candidate */ + static isPasswordSane(candidate) { + // We know the password must adhere to one of our client-side-hashed crypto schemes, + // so we can be fairly strict with the allowed passwords + return Config.passwordHashSanityRegex.test(candidate); + } }