diff --git a/models/game.js b/models/game.js index dfb4ca5..d19a101 100755 --- a/models/game.js +++ b/models/game.js @@ -9,6 +9,7 @@ import { isIdSane, miniUid } from "../utils/id.js"; import { Xorshift32 } from "../utils/random.js"; +import { Security } from "../utils/security.js"; import { ItemBlueprint } from "./item.js"; import { Player } from "./player.js"; @@ -83,18 +84,17 @@ export class Game { * @param {string?} passwordHash * @param {string?} salt * - * @returns {Player|null} Returns the player if username wasn't already taken, or null otherwise. + * @returns {Player|false} Returns the player if username wasn't already taken, or null otherwise. */ createPlayer(username, passwordHash = undefined, salt = undefined) { if (this.#players.has(username)) { return false; } - const player = new Player( - username, - typeof passwordHash === "string" ? passwordHash : "", - typeof salt === "string" && salt.length > 0 ? salt : miniUid(), - ); + passwordHash ??= ""; + salt ??= Security.generateHash(miniUid()); + + const player = new Player(username, passwordHash, salt); this.#players.set(username, player); diff --git a/models/session.js b/models/session.js index 86d9154..8165b89 100755 --- a/models/session.js +++ b/models/session.js @@ -1,7 +1,7 @@ import { Player } from "./player.js"; import { mustBeString, mustBe } from "../utils/mustbe.js"; import { Scene } from "../scenes/scene.js"; -import { formatMessage, MessageType } from "../utils/messages.js"; +import * as Messages from "../utils/messages.js"; /** @typedef {import("ws").WebSocket} WebSocket */ @@ -42,7 +42,7 @@ export class Session { * @param {Scene} scene */ setScene(scene) { - console.debug("changing scene", scene.constructor.name); + console.debug("Changing scene", { scene: scene.constructor.name }); if (!(scene instanceof Scene)) { throw new Error(`Expected instance of Scene, got a ${typeof scene}: >>${scene}<<`); } @@ -91,7 +91,7 @@ export class Session { console.error("Trying to send a message without a valid websocket", messageType, args); return; } - this._websocket.send(formatMessage(messageType, ...args)); + this._websocket.send(Messages.formatMessage(messageType, ...args)); } /** @@ -112,7 +112,7 @@ export class Session { } this.send( - MessageType.PROMPT, // message type + Messages.PROMPT, // message type text, // TODO: prompt text must be string or an array of strings mustBe(options, "object"), ); @@ -125,12 +125,17 @@ export class Session { * @param {object?} options message options for the client. */ sendText(text, options = {}) { - this.send(MessageType.TEXT, text, options); + this.send(Messages.TEXT, text, options); } /** @param {string|string[]} errorMessage */ sendError(errorMessage, options = { verbatim: true, error: true }) { - this.send(MessageType.ERROR, mustBeString(errorMessage), options); + this.send(Messages.ERROR, mustBeString(errorMessage), options); + } + + /** @param {string|string[]} debugMessage */ + sendDebug(debugMessage, options = { verbatim: true, debug: true }) { + this.send(Messages.DEBUG, debugMessage, options); } /** @@ -141,7 +146,7 @@ export class Session { // // The client should know not to format calamaties anyway, but we add “preformatted” anyway console.info("CALAMITY", errorMessage); - this.send(MessageType.CALAMITY, errorMessage, { verbatim: true, calamity: true }); + this.send(Messages.CALAMITY, errorMessage, { verbatim: true, calamity: true }); this.close(); } @@ -150,6 +155,6 @@ export class Session { * @param {any?} value */ sendSystemMessage(systemMessageType, value = undefined) { - this.send(MessageType.SYSTEM, mustBeString(systemMessageType), value); + this.send(Messages.SYSTEM, mustBeString(systemMessageType), value); } } diff --git a/scenes/authentication/authenticationScene.js b/scenes/authentication/authenticationScene.js index 30c3361..fa0c024 100755 --- a/scenes/authentication/authenticationScene.js +++ b/scenes/authentication/authenticationScene.js @@ -147,9 +147,11 @@ class PasswordPrompt extends Prompt { return; } + const player = this.scene.player; + // // Block users who enter bad passwords too many times. - if (this.player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) { + if (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; @@ -158,7 +160,7 @@ class PasswordPrompt extends Prompt { // // Handle blocked users. // They don't even get to have their password verified. - if (this.player.blockedUntil > Date.now()) { + if (player.blockedUntil > Date.now()) { // // Try to re-login too soon, and your lockout lasts longer. this.blockedUntil += Config.accountLockoutSeconds * 1000; @@ -168,23 +170,23 @@ class PasswordPrompt extends Prompt { // // Verify the password against the hash we've stored. - if (!Security.verifyPassword(text, this.player.passwordHash)) { + if (!Security.verifyPassword(text, player.passwordHash)) { this.sendError("Incorrect password!"); - this.player.failedPasswordsSinceLastLogin++; + player.failedPasswordsSinceLastLogin++; - this.session.sendDebug(`Failed login attempt #${this.player.failedPasswordsSinceLastLogin}`); + this.session.sendDebug(`Failed login attempt #${player.failedPasswordsSinceLastLogin}`); this.execute(); return; } - this.player.lastSucessfulLoginAt = new Date(); - this.player.failedPasswordsSinceLastLogin = 0; + player.lastSucessfulLoginAt = new Date(); + 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"); + if (player.loggedIn) { + this.calamity("player is already logged in"); return; } diff --git a/scenes/partyCreation/partyCreationScene.js b/scenes/partyCreation/partyCreationScene.js index c1ed327..f57c768 100755 --- a/scenes/partyCreation/partyCreationScene.js +++ b/scenes/partyCreation/partyCreationScene.js @@ -1,10 +1,3 @@ -import figlet from "figlet"; -import { Session } from "../models/session.js"; -import { WebsocketMessage } from "../utils/messages.js"; -import { frameText } from "../utils/tui.js"; -import { Config } from "../config.js"; -import { State } from "./state.js"; - // _____ ___ ____ ___ ____ _ _____ // |_ _/ _ \| _ \ / _ \ _ / ___|___ _ ____ _____ _ __| |_ |_ _|__ // | || | | | | | | | | (_) | | / _ \| '_ \ \ / / _ \ '__| __| | |/ _ \ @@ -17,93 +10,93 @@ import { State } from "./state.js"; // ___) | (_| __/ | | | __/\__ \ // |____/ \___\___|_| |_|\___||___/ -export class PartyCreationState extends State { - /** - * @proteted - * @type {(msg: WebsocketMessage) => } - * - * NOTE: Should this be a stack? - */ - _dynamicMessageHandler; - - /** @param {Session} session */ - constructor(session) { - super(); - this.session = session; - } - - /** We attach (and execute) the next state */ - onAttach() { - const charCount = this.session.player.characters.size; - - //NOTE: could use async to optimize performance - const createPartyLogo = frameText(figlet.textSync("Create Your Party"), { - vPadding: 0, - frameChars: "§=§§§§§§", - }); - - this.sendText(createPartyLogo, { preformatted: true }); - - this.session.sendText(["", `Current party size: ${charCount}`, `Max party size: ${Config.maxPartySize}`]); - const min = 1; - const max = Config.maxPartySize - charCount; - const prompt = [ - `Please enter an integer between ${min} - ${max}`, - "((type *:help* to get more info about party size))", - ]; - - this.sendText(`You can create a party with ${min} - ${max} characters, how big should your party be?`); - - /** @param {WebsocketMessage} m */ - this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m)); - } - - /** @param {WebsocketMessage} m */ - receiveCharacterCount(m) { - if (m.isHelpRequest()) { - return this.partySizeHelp(); - } - - if (!m.isInteger()) { - this.sendError("You didn't enter an integer"); - this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m)); - return; - } - - const numCharactersToCreate = Number(m.text); - if (numCharactersToCreate > Config.maxPartySize) { - this.sendError("Number too high"); - this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m)); - return; - } - - if (numCharactersToCreate < 1) { - this.sendError("Number too low"); - this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m)); - return; - } - - this.sendText(`Let's create ${numCharactersToCreate} character(s) for you :)`); - } - - partySizeHelp() { - this.sendText([ - `Your party can consist of 1 to ${Config.maxPartySize} characters.`, - "", - "* Large parties tend live longer", - `* If you have fewer than ${Config.maxPartySize} characters, you can`, - " hire extra characters in your local inn.", - "* large parties level slower because there are more", - " characters to share the Experience Points", - "* The individual members of small parties get better", - " loot because they don't have to share, but it", - " a lot of skill to accumulate loot as fast a larger", - " party can", - ]); - return; - } -} - -if (Math.PI < 0 && Session && WebsocketMessage) { - ("STFU Linda"); -} +// export class PartyCreationState extends State { +// /** +// * @proteted +// * @type {(msg: WebsocketMessage) => } +// * +// * NOTE: Should this be a stack? +// */ +// _dynamicMessageHandler; +// +// /** @param {Session} session */ +// constructor(session) { +// super(); +// this.session = session; +// } +// +// /** We attach (and execute) the next state */ +// onAttach() { +// const charCount = this.session.player.characters.size; +// +// //NOTE: could use async to optimize performance +// const createPartyLogo = frameText(figlet.textSync("Create Your Party"), { +// vPadding: 0, +// frameChars: "§=§§§§§§", +// }); +// +// this.sendText(createPartyLogo, { preformatted: true }); +// +// this.session.sendText(["", `Current party size: ${charCount}`, `Max party size: ${Config.maxPartySize}`]); +// const min = 1; +// const max = Config.maxPartySize - charCount; +// const prompt = [ +// `Please enter an integer between ${min} - ${max}`, +// "((type *:help* to get more info about party size))", +// ]; +// +// this.sendText(`You can create a party with ${min} - ${max} characters, how big should your party be?`); +// +// /** @param {WebsocketMessage} m */ +// this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m)); +// } +// +// /** @param {WebsocketMessage} m */ +// receiveCharacterCount(m) { +// if (m.isHelpRequest()) { +// return this.partySizeHelp(); +// } +// +// if (!m.isInteger()) { +// this.sendError("You didn't enter an integer"); +// this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m)); +// return; +// } +// +// const numCharactersToCreate = Number(m.text); +// if (numCharactersToCreate > Config.maxPartySize) { +// this.sendError("Number too high"); +// this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m)); +// return; +// } +// +// if (numCharactersToCreate < 1) { +// this.sendError("Number too low"); +// this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m)); +// return; +// } +// +// this.sendText(`Let's create ${numCharactersToCreate} character(s) for you :)`); +// } +// +// partySizeHelp() { +// this.sendText([ +// `Your party can consist of 1 to ${Config.maxPartySize} characters.`, +// "", +// "* Large parties tend live longer", +// `* If you have fewer than ${Config.maxPartySize} characters, you can`, +// " hire extra characters in your local inn.", +// "* large parties level slower because there are more", +// " characters to share the Experience Points", +// "* The individual members of small parties get better", +// " loot because they don't have to share, but it", +// " a lot of skill to accumulate loot as fast a larger", +// " party can", +// ]); +// return; +// } +// } +// +// if (Math.PI < 0 && Session && WebsocketMessage) { +// ("STFU Linda"); +// } diff --git a/scenes/playerCreation/createUasswprdPrompt.js b/scenes/playerCreation/createUasswprdPrompt.js deleted file mode 100755 index 54f0134..0000000 --- a/scenes/playerCreation/createUasswprdPrompt.js +++ /dev/null @@ -1,75 +0,0 @@ -import { Prompt } from "../prompt.js"; -import { Security } from "../../utils/security.js"; -import { Config } from "../../config.js"; - -export class CreatePasswordPrompt extends Prompt { - // - message = ["Enter a password"]; - - // - // Let the client know that we're asking for a password - // so it can set - options = { password: true }; - - get player() { - return this.scene.player; - } - - 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; - 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; - 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; - } - - this.scene.passwordAccepted(); - } -} diff --git a/scenes/playerCreation/createUsernamePrompt.js b/scenes/playerCreation/createUsernamePrompt.js deleted file mode 100755 index 2cb4da3..0000000 --- a/scenes/playerCreation/createUsernamePrompt.js +++ /dev/null @@ -1,58 +0,0 @@ -import { Prompt } from "../prompt.js"; -import { Security } from "../../utils/security.js"; -import { gGame } from "../../models/globals.js"; - -/** @typedef {import("./playerCreationScene.js").PlayerCreationScene} PlayerCreationScene */ - -export class CreateUsernamePrompt extends Prompt { - // - message = [ - "Enter your username", // - "((type *:help* for more info))", // - ]; - - // - // When player types :help - help = [ - "Your username.", - "It's used, along with your password, when you log in.", - "Other players can see it.", - "Other players can use it to chat or trade with you", - "It may only consist of the letters _a-z_, _A-Z_, _0-9_, and _underscore_", - ]; - - // - // Let the client know that we're asking for a username - options = { username: true }; - - /** @protected @type {PlayerCreationScene} */ - _scene; - - 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 tried to create a user with an occupied username: '%s'", username); - this.sendError("Occupied, try something else"); - this.execute(); - return; - } - - // - // Tell owner that we're done - this._scene.usernameAccepted(username); - } -} diff --git a/scenes/playerCreation/playerCreationScene.js b/scenes/playerCreation/playerCreationScene.js index dffa5df..9c5bdf5 100755 --- a/scenes/playerCreation/playerCreationScene.js +++ b/scenes/playerCreation/playerCreationScene.js @@ -1,8 +1,11 @@ import { Config } from "../../config.js"; -import { gGame } from "../../models/globals.js"; -import { Security } from "../../utils/security.js"; +import { Prompt } from "../prompt.js"; import { Scene } from "../scene.js"; -import { CreateUsernamePrompt } from "./createUsernamePrompt.js"; +import { Security } from "../../utils/security.js"; +import { gGame } from "../../models/globals.js"; +import { AuthenticationScene } from "../authentication/authenticationScene.js"; + +const MAX_PASSWORD_ATTEMPTS = 3; export class PlayerCreationScene extends Scene { intro = "= Create Player"; @@ -20,7 +23,7 @@ export class PlayerCreationScene extends Scene { this.session.calamity("Server is full, no more players can be created"); } - this.showPrompt(new CreateUsernamePrompt(this)); + this.showPrompt(new UsernamePrompt(this)); } /** @@ -33,10 +36,10 @@ export class PlayerCreationScene extends Scene { this.player = player; this.session.sendSystemMessage("salt", player.salt); - this.session.sendText(`Username _*${username}*_ is available, and I've reserved it for you :)`); - // - this.session.sendError("TODO: create a createPasswordPrompt and display it."); + this.session.sendText(`Username _*${username}*_ has been reserved for you`); + + this.show(PasswordPrompt); } /** @@ -47,7 +50,153 @@ 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(Security.generateHash(this.password)); + this.session.sendText("*_Success_* ✅ You will now be asked to log in again, sorry about that ;)"); + this.session.setScene(new AuthenticationScene(this.session)); + } +} + +// _ _ +// | | | |___ ___ _ __ _ __ __ _ _ __ ___ ___ +// | | | / __|/ _ \ '__| '_ \ / _` | '_ ` _ \ / _ \ +// | |_| \__ \ __/ | | | | | (_| | | | | | | __/ +// \___/|___/\___|_| |_| |_|\__,_|_| |_| |_|\___| +// +// ____ _ +// | _ \ _ __ ___ _ __ ___ _ __ | |_ +// | |_) | '__/ _ \| '_ ` _ \| '_ \| __| +// | __/| | | (_) | | | | | | |_) | |_ +// |_| |_| \___/|_| |_| |_| .__/ \__| +// |_| +class UsernamePrompt extends Prompt { + // + message = [ + "Enter your username", // + "((type *:help* for more info))", // + ]; + + // + // When player types :help + help = [ + "Your username.", + "It's used, along with your password, when you log in.", + "Other players can see it.", + "Other players can use it to chat or trade with you", + "It may only consist of the letters _a-z_, _A-Z_, _0-9_, and _underscore_", + ]; + + // + // Let the client know that we're asking for a username + options = { username: true }; + + /** @type {PlayerCreationScene} */ + get scene() { + return this._scene; + } + + 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 tried to create a user with an occupied username: '%s'", username); + this.sendError("Occupied, try something else"); + this.execute(); + return; + } + + this.scene.usernameAccepted(username); + } +} + +// ____ _ +// | _ \ __ _ ___ _____ _____ _ __ __| | +// | |_) / _` / __/ __\ \ /\ / / _ \| '__/ _` | +// | __/ (_| \__ \__ \\ V V / (_) | | | (_| | +// |_| \__,_|___/___/ \_/\_/ \___/|_| \__,_| +// +// ____ _ +// | _ \ _ __ ___ _ __ ___ _ __ | |_ +// | |_) | '__/ _ \| '_ ` _ \| '_ \| __| +// | __/| | | (_) | | | | | | |_) | |_ +// |_| |_| \___/|_| |_| |_| .__/ \__| +// |_| +class PasswordPrompt extends Prompt { + // + message = "Enter a password"; + + // + // Let the client know that we're asking for a password + // so it can set + options = { password: true }; + + /** @type {string?} Password that was previously entered. */ + firstPassword = undefined; + + errorCount = 0; + + /** @type {PlayerCreationScene} */ + get scene() { + return this._scene; + } + + beforeExecute() { + if (this.errorCount > MAX_PASSWORD_ATTEMPTS) { + this.firstPassword = false; + this.errorCount = 0; + this.message = ["Too many errors - starting over", "Enter password"]; + return; + } + + if (this.firstPassword && this.errorCount === 0) { + this.message = "Repeat the password"; + return; + } + + if (this.firstPassword && this.errorCount > 0) { + this.message = [ + "Repeat the password", + `((attempt nr. ${this.errorCount + 1} of ${MAX_PASSWORD_ATTEMPTS + 1}))`, + ]; + return; + } + + this.errorCount = 0; + this.message = "Enter a password"; + } + + onReply(str) { + if (!Security.isPasswordSane(str)) { + this.sendError("Invalid password format."); + this.errorCount++; + this.execute(); + return; + } + + if (!this.firstPassword) { + this.firstPassword = str; + this.execute(); + return; + } + + if (this.firstPassword !== str) { + this.errorCount++; + this.execute(); + return; + } + + this.scene.passwordAccepted(str); } } diff --git a/scenes/prompt.js b/scenes/prompt.js index 7ff5f28..368bc90 100755 --- a/scenes/prompt.js +++ b/scenes/prompt.js @@ -1,5 +1,4 @@ /** @typedef {import("../models/session.js").Session} Session */ -/** @typedef {import("../utils/message.js").MessageType} MessageType */ /** @typedef {import("../utils/message.js").WebsocketMessage} WebsocketMessage */ /** @typedef {import("./scene.js").Scene} Scene */ @@ -65,7 +64,7 @@ export class Prompt { /** @type {Session} */ get session() { - return this.scene.session; + return this._scene.session; } /** @param {Scene} scene */ @@ -80,6 +79,7 @@ export class Prompt { */ execute() { this.prepareProperties(); + this.beforeExecute(); this.sendPrompt(this.message, this.options); } @@ -98,6 +98,8 @@ export class Prompt { } } + beforeExecute() {} + /** Triggered when user types `:help [some optional topic]` */ onHelp(topic) { if (!this.help) {