This commit is contained in:
Kim Ravn Hansen
2025-10-21 11:50:15 +02:00
parent 8a8434f5ac
commit 0b540414ec
9 changed files with 211 additions and 210 deletions

View File

@@ -1,8 +1,10 @@
import { PasswordPrompt } from "./passwordPrompt.js"; import { Security } from "../../utils/security.js";
import { Scene } from "../scene.js"; import { Config } from "../../config.js";
import { UsernamePrompt } from "./usernamePrompt.js";
import { PlayerCreationScene } from "../playerCreation/playerCreationSene.js";
import { GameScene } from "../gameLoop/gameScene.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 */ /** @typedef {import("../../models/player.js").Player} Player */
@@ -16,7 +18,6 @@ export class AuthenticationScene extends Scene {
player; player;
onReady() { onReady() {
// current prompt
this.show(UsernamePrompt); this.show(UsernamePrompt);
} }
@@ -44,3 +45,136 @@ export class AuthenticationScene extends Scene {
this.session.setScene(new PlayerCreationScene(this.session)); 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 <input type="password">
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();
}
}

View File

@@ -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 <input type="password">
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();
}
}

View File

@@ -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);
}
}

View File

@@ -34,7 +34,7 @@ export class GameScene extends Scene {
// If player does not have a previous session // If player does not have a previous session
// then we start in the Adventurers Guild in the Hovedstad // then we start in the Adventurers Guild in the Hovedstad
// //
this.doPrompt("new command prompt or whatever"); this.showBasicPrompt(this.castle);
} }
get castle() { get castle() {

View File

@@ -1,5 +1,5 @@
import { Prompt } from "../prompt.js"; import { Prompt } from "../prompt.js";
import * as security from "../../utils/security.js"; import { Security } from "../../utils/security.js";
import { Config } from "../../config.js"; import { Config } from "../../config.js";
export class CreatePasswordPrompt extends Prompt { export class CreatePasswordPrompt extends Prompt {
@@ -23,7 +23,7 @@ export class CreatePasswordPrompt extends Prompt {
// not hashing an insane password 1000+ times. // not hashing an insane password 1000+ times.
// This is technically bad practice, but since this is just a game, // This is technically bad practice, but since this is just a game,
// do it anyway. // do it anyway.
if (!security.isPasswordSane(text)) { if (!Security.isPasswordSane(text)) {
this.sendError("Insane password"); this.sendError("Insane password");
this.execute(); this.execute();
return; return;
@@ -50,7 +50,7 @@ export class CreatePasswordPrompt extends Prompt {
// //
// Verify the password against the hash we've stored. // 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.sendError("Incorrect password!");
this.player.failedPasswordsSinceLastLogin++; this.player.failedPasswordsSinceLastLogin++;

View File

@@ -1,5 +1,5 @@
import { Prompt } from "../prompt.js"; import { Prompt } from "../prompt.js";
import * as security from "../../utils/security.js"; import { Security } from "../../utils/security.js";
import { gGame } from "../../models/globals.js"; import { gGame } from "../../models/globals.js";
/** @typedef {import("./playerCreationScene.js").PlayerCreationScene} PlayerCreationScene */ /** @typedef {import("./playerCreationScene.js").PlayerCreationScene} PlayerCreationScene */
@@ -35,7 +35,7 @@ export class CreateUsernamePrompt extends Prompt {
onReply(username) { onReply(username) {
// //
// do basic syntax checks on usernames // do basic syntax checks on usernames
if (!security.isUsernameSane(username)) { if (!Security.isUsernameSane(username)) {
console.info("Someone entered insane username: '%s'", username); console.info("Someone entered insane username: '%s'", username);
this.sendError("Incorrect username, try again."); this.sendError("Incorrect username, try again.");
this.execute(); this.execute();

View File

@@ -1,6 +1,6 @@
import { Config } from "../../config.js"; import { Config } from "../../config.js";
import { gGame } from "../../models/globals.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 { Scene } from "../scene.js";
import { CreateUsernamePrompt } from "./createUsernamePrompt.js"; import { CreateUsernamePrompt } from "./createUsernamePrompt.js";
@@ -48,6 +48,6 @@ export class PlayerCreationScene extends Scene {
passwordAccepted(password) { passwordAccepted(password) {
this.password = password; this.password = password;
this.session.sendText("*_Success_* ✅ You will now be asked to log in again, sorry for that ;)"); 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));
} }
} }

View File

@@ -21,10 +21,13 @@ export class Scene {
*/ */
introText = ""; introText = "";
/** @constant @readonly @type {Prompt?} */
introPrompt;
/** @readonly @constant @protected @type {Session} */ /** @readonly @constant @protected @type {Session} */
_session; #session;
get session() { get session() {
return this._session; return this.#session;
} }
/** /**
@@ -34,21 +37,27 @@ export class Scene {
* @readonly * @readonly
* @type {Prompt} * @type {Prompt}
*/ */
_currentPrompt; #currentPrompt;
get currentPrompt() { get currentPrompt() {
return this._currentPrompt; return this.#currentPrompt;
} }
constructor() {} constructor() {}
/** @param {Session} session */ /** @param {Session} session */
execute(session) { execute(session) {
this._session = session; this.#session = session;
if (this.introText) { if (this.introText) {
this.session.sendText(this.introText); this.session.sendText(this.introText);
} }
if (this.introPrompt) {
this.showPrompt(this.introPrompt);
} else {
this.onReady(); this.onReady();
} }
}
/** @abstract */ /** @abstract */
onReady() { onReady() {
@@ -59,7 +68,7 @@ export class Scene {
* @param {Prompt} prompt * @param {Prompt} prompt
*/ */
showPrompt(prompt) { showPrompt(prompt) {
this._currentPrompt = prompt; this.#currentPrompt = prompt;
prompt.execute(); prompt.execute();
} }

View File

@@ -6,24 +6,25 @@ const ITERATIONS = 1000; // MAGIC CONSTANT - move to Config
const DIGEST = "sha256"; // MAGIC CONSTANT - move to Config const DIGEST = "sha256"; // MAGIC CONSTANT - move to Config
const KEYLEN = 32; // MAGIC CONSTANT - move to Config const KEYLEN = 32; // MAGIC CONSTANT - move to Config
/** export class Security {
/**
* Generate a hash from a string * Generate a hash from a string
* @param {string} source @returns {string} * @param {string} source @returns {string}
*/ */
export function generateHash(source) { static generateHash(source) {
const salt = randomBytes(16).toString("hex"); // 128-bit salt const salt = randomBytes(16).toString("hex"); // 128-bit salt
const hash = pbkdf2Sync(source, salt, ITERATIONS, KEYLEN, DIGEST).toString("hex"); const hash = pbkdf2Sync(source, salt, ITERATIONS, KEYLEN, DIGEST).toString("hex");
return `${ITERATIONS}:${salt}:${hash}`; return `${ITERATIONS}:${salt}:${hash}`;
} }
/** /**
* Verify that a password is correct against a given hash. * Verify that a password is correct against a given hash.
* *
* @param {string} password_candidate * @param {string} password_candidate
* @param {string} stored_password_hash * @param {string} stored_password_hash
* @returns {boolean} * @returns {boolean}
*/ */
export function verifyPassword(password_candidate, stored_password_hash) { static verifyPassword(password_candidate, stored_password_hash) {
const [iterations, salt, hash] = stored_password_hash.split(":"); const [iterations, salt, hash] = stored_password_hash.split(":");
const derived = pbkdf2Sync(password_candidate, salt, Number(iterations), KEYLEN, DIGEST).toString("hex"); const derived = pbkdf2Sync(password_candidate, salt, Number(iterations), KEYLEN, DIGEST).toString("hex");
const success = hash === derived; const success = hash === derived;
@@ -36,23 +37,24 @@ export function verifyPassword(password_candidate, stored_password_hash) {
" Derived : %s (the hashed version of the input password)\n" + " Derived : %s (the hashed version of the input password)\n" +
" Verified : %s (was the password valid)", " Verified : %s (was the password valid)",
password_candidate, password_candidate,
generateHash(password_candidate), this.generateHash(password_candidate),
stored_password_hash, stored_password_hash,
derived, derived,
success, success,
); );
} }
return success; return success;
} }
/** @param {string} candidate */ /** @param {string} candidate */
export function isUsernameSane(candidate) { static isUsernameSane(candidate) {
return Config.usernameSanityRegex.test(candidate); return Config.usernameSanityRegex.test(candidate);
} }
/** @param {string} candidate */ /** @param {string} candidate */
export function isPasswordSane(candidate) { static isPasswordSane(candidate) {
// We know the password must adhere to one of our client-side-hashed crypto schemes, // 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 // so we can be fairly strict with the allowed passwords
return Config.passwordHashSanityRegex.test(candidate); return Config.passwordHashSanityRegex.test(candidate);
}
} }