From 2c25f53ddf418bdedd94c6142b03c80e49fc584d Mon Sep 17 00:00:00 2001 From: gumi Date: Fri, 14 Feb 2020 12:18:00 -0500 Subject: add support for Vault + major refactor --- .env | 42 ++ .env.development | 0 .env.production | 0 .gitignore | 4 + package.json | 35 +- src/api.js | 111 +++--- src/limiter.js | 68 ++++ src/logger.js | 45 +++ src/routers/tmwa/index.js | 4 +- src/routers/tmwa/middlewares/account.js | 54 +-- src/routers/tmwa/middlewares/server.js | 1 + src/routers/vault/index.js | 115 ++++++ src/routers/vault/middlewares/account.js | 161 ++++++++ src/routers/vault/middlewares/evol/account.js | 326 +++++++++++++++ src/routers/vault/middlewares/identity.js | 255 ++++++++++++ src/routers/vault/middlewares/legacy/account.js | 437 +++++++++++++++++++++ src/routers/vault/middlewares/session.js | 313 +++++++++++++++ src/routers/vault/models/evol/char.js | 364 +++++++++++++++++ src/routers/vault/models/evol/char_reservation.js | 14 + src/routers/vault/models/evol/login.js | 94 +++++ src/routers/vault/models/legacy/char.js | 143 +++++++ src/routers/vault/models/legacy/login.js | 64 +++ src/routers/vault/models/vault/account_log.js | 49 +++ .../vault/models/vault/claimed_game_accounts.js | 22 ++ .../vault/models/vault/claimed_legacy_accounts.js | 22 ++ src/routers/vault/models/vault/identity.js | 36 ++ src/routers/vault/models/vault/identity_log.js | 47 +++ src/routers/vault/models/vault/login.js | 31 ++ src/routers/vault/models/vault/login_log.js | 45 +++ src/routers/vault/models/vault/migration_log.js | 35 ++ src/routers/vault/types/Session.js | 21 + src/routers/vault/utils/claim.js | 83 ++++ src/routers/vault/utils/ephemeral.js | 87 ++++ src/routers/vault/utils/flatfile.js | 31 ++ src/routers/vault/utils/md5saltcrypt.js | 38 ++ 35 files changed, 3084 insertions(+), 113 deletions(-) create mode 100644 .env create mode 100644 .env.development create mode 100644 .env.production create mode 100644 src/limiter.js create mode 100644 src/logger.js create mode 100644 src/routers/vault/index.js create mode 100644 src/routers/vault/middlewares/account.js create mode 100644 src/routers/vault/middlewares/evol/account.js create mode 100644 src/routers/vault/middlewares/identity.js create mode 100644 src/routers/vault/middlewares/legacy/account.js create mode 100644 src/routers/vault/middlewares/session.js create mode 100644 src/routers/vault/models/evol/char.js create mode 100644 src/routers/vault/models/evol/char_reservation.js create mode 100644 src/routers/vault/models/evol/login.js create mode 100644 src/routers/vault/models/legacy/char.js create mode 100644 src/routers/vault/models/legacy/login.js create mode 100644 src/routers/vault/models/vault/account_log.js create mode 100644 src/routers/vault/models/vault/claimed_game_accounts.js create mode 100644 src/routers/vault/models/vault/claimed_legacy_accounts.js create mode 100644 src/routers/vault/models/vault/identity.js create mode 100644 src/routers/vault/models/vault/identity_log.js create mode 100644 src/routers/vault/models/vault/login.js create mode 100644 src/routers/vault/models/vault/login_log.js create mode 100644 src/routers/vault/models/vault/migration_log.js create mode 100644 src/routers/vault/types/Session.js create mode 100644 src/routers/vault/utils/claim.js create mode 100644 src/routers/vault/utils/ephemeral.js create mode 100644 src/routers/vault/utils/flatfile.js create mode 100644 src/routers/vault/utils/md5saltcrypt.js diff --git a/.env b/.env new file mode 100644 index 0000000..be77861 --- /dev/null +++ b/.env @@ -0,0 +1,42 @@ +PORT=8080 +TZ=UTC + +# [RECAPTCHA] +RECAPTCHA__SECRET="recaptcha secret key" + +# [MAILER] +MAILER__FROM="The Mana World " + +# [LOGGER] +LOGGER__WEBHOOK= + +# [TMWA] +TMWA__NAME="The Mana World Legacy Server" +TMWA__URI="tmwa://server.themanaworld.org:6901" +TMWA__ROOT="/mnt/tmwAthena/tmwa-server-data" +TMWA__HOME="/home/tmw" +TMWA__RESET="https://www.themanaworld.org/recover/password/" + +# [VAULT] +# [[MAILER]] +VAULT__MAILER__FROM="The Mana World " +# [[URL]] +VAULT__URL__AUTH="https://vault.themanaworld.org/auth/" +VAULT__URL__IDENTITY="https://vault.themanaworld.org/validate/" + +# [SQL] +# [[VAULT]] +SQL__VAULT__HOST="localhost" +SQL__VAULT__DB="vault" +SQL__VAULT__USER="api" +SQL__VAULT__PASS="" +# [[LEGACY]] +SQL__LEGACY__HOST="localhost" +SQL__LEGACY__DB="legacy" +SQL__LEGACY__USER="api" +SQL__LEGACY__PASS="" +# [[EVOL]] +SQL__EVOL__HOST="localhost" +SQL__EVOL__DB="revolt" +SQL__EVOL__USER="api" +SQL__EVOL__PASS="" diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..e69de29 diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index 5b55916..e0e2c80 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ /online.txt /account.txt /athena.txt + +# local env files +.env.local +.env.*.local diff --git a/package.json b/package.json index 7966a21..ea8a632 100644 --- a/package.json +++ b/package.json @@ -4,26 +4,6 @@ "description": "TMW RESTful API", "author": "The Mana World", "license": "CC0-1.0", - "config": { - "port": 8080, - "timezone": "UTC", - "recaptcha": { - "secret": "recaptcha secret key" - }, - "tmwa": { - "name": "The Mana World Legacy Server", - "url": "tmwa://server.themanaworld.org:6901", - "root": "/mnt/tmwAthena/tmwa-server-data", - "home": "/home/tmw", - "reset": "https://www.themanaworld.org/recover/password/#" - }, - "mailer": { - "from": "The Mana World " - }, - "logger": { - "webhook": "" - } - }, "repository": { "type": "git", "url": "https://github.com/themanaworld/api.git" @@ -34,14 +14,21 @@ "main": "src/api.js", "private": true, "scripts": { - "start": "node ." + "start": "env NODE_ENV=production node .", + "dev": "env NODE_ENV=development node ." }, "dependencies": { - "express": "^4.17.0", + "express": "^4.17.1", + "iconv-lite": "^0.5.1", + "lazy-universal-dotenv": "^3.0.1", + "mariadb": "^2.2.0", + "mysql": "^2.18.1", "node-fetch": "^2.6.0", - "nodemailer": "^6.2.1", + "nodemailer": "^6.4.2", "ripgrep-bin": "^11.0.1", - "uuid": "^3.3.2" + "sequelize": "^5.21.4", + "sequelize-cli": "^5.5.1", + "uuid": "^3.4.0" }, "devDependencies": {} } diff --git a/src/api.js b/src/api.js index b5c573b..8347e1c 100644 --- a/src/api.js +++ b/src/api.js @@ -1,44 +1,33 @@ const express = require("express"); // from npm registry -const Fetch = require("node-fetch"); // from npm registry const https = require("https"); // built-in +const Limiter = require("./limiter.js"); +const Logger = require("./logger.js"); const api = express(); -if (process.env.npm_package_config_port === undefined) { - console.error("Please run this package with `npm start`"); +if (!process.env.NODE_ENV) { + console.error("must be started with 'npm run start'"); process.exit(1); } -const send_hook = (msg) => { - Fetch(process.env.npm_package_config_logger_webhook, { - method: "POST", - cache: "no-cache", - redirect: "follow", - headers: { - "Accept": "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: msg, - }), - }); +// env-based config +const dotenv = require("lazy-universal-dotenv"); +const [nodeEnv, buildTarget] = [process.env.NODE_ENV, process.env.BUILD_TARGET]; +const conf = dotenv.getEnvironment({ nodeEnv, buildTarget }).raw; +Object.assign(process.env, conf); // override - console.log(msg); -}; -const logger = { - log: msg => send_hook(`${msg}`), - info: msg => send_hook(`ℹ ${msg}`), - warn: msg => send_hook(`⚠ ${msg}`), - error: msg => send_hook(`❌ ${msg}`), -}; +if (process.env.PORT === undefined) { + console.error("Please run this package with `npm start`"); + process.exit(1); +} // config common to all routers: api.locals = Object.assign({ - rate_limiting: new Set(), // XXX: or do we want routers to each have their own rate limiter? + cooldown: Limiter.cooldown, mailer: { - from: process.env.npm_package_config_mailer_from, + from: process.env.MAILER__FROM, }, - logger: logger, + logger: Logger, }, api.locals); @@ -47,18 +36,6 @@ api.locals = Object.assign({ BEGIN MIDDLEWARES ********************************/ -const checkRateLimiting = (req, res, next) => { - if (req.app.locals.rate_limiting.has(req.ip)) { - res.status(429).json({ - status: "error", - error: "too many requests" - }); - } else { - next(); - } - return; -}; - const checkCaptcha = (req, res, next) => { const token = String(req.get("X-CAPTCHA-TOKEN") || ""); @@ -67,12 +44,17 @@ const checkCaptcha = (req, res, next) => { status: "error", error: "no token sent" }); - req.app.locals.rate_limiting.add(req.ip); - setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000); + req.app.locals.cooldown(req, 300000); return false; } - https.get(`https://www.google.com/recaptcha/api/siteverify?secret=${process.env.npm_package_config_recaptcha_secret}&response=${token}`, re => { + if (process.env.NODE_ENV === "development") { + // local development: no challenge check + next(); + return; + } + + https.get(`https://www.google.com/recaptcha/api/siteverify?secret=${process.env.RECAPTCHA__SECRET}&response=${token}`, re => { re.setEncoding("utf8"); re.on("data", response => { const data = JSON.parse(response); @@ -87,8 +69,7 @@ const checkCaptcha = (req, res, next) => { status: "error", error: "captcha validation failed" }); - req.app.locals.rate_limiting.add(req.ip); - setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000); + req.app.locals.cooldown(req, 300000); return false; } @@ -105,6 +86,24 @@ const checkCaptcha = (req, res, next) => { }) }; +// enables rapid local prototyping +api.use((req, res, next) => { + if (process.env.NODE_ENV === "development") { + res.append("Access-Control-Allow-Origin", "*"); + + if (req.method === "OPTIONS") { + res.append("Access-Control-Allow-Methods", "*"); + res.append("Access-Control-Allow-Headers", "*"); + res.status(200).json({}); + return; + } + } + next(); +}); + +// always check rate limiting +api.use(Limiter.check); + /******************************* END MIDDLEWARES ********************************/ @@ -118,15 +117,22 @@ const checkCaptcha = (req, res, next) => { const global_router = express.Router(["caseSensitive", "strict"]); const tmwa_router = new (require("./routers/tmwa"))({ - timezone: process.env.npm_package_config_timezone, - name: process.env.npm_package_config_tmwa_name, - url: process.env.npm_package_config_tmwa_url, - root: process.env.npm_package_config_tmwa_root, - home: process.env.npm_package_config_tmwa_home, - reset: process.env.npm_package_config_tmwa_reset, -}, api, checkCaptcha, checkRateLimiting); + timezone: process.env.TZ, + name: process.env.TMWA__NAME, + url: process.env.TMWA__URI, + root: process.env.TMWA__ROOT, + home: process.env.TMWA__HOME, + reset: process.env.TMWA__RESET, +}, api, checkCaptcha); + +const vault = new (require("./routers/vault")) + (api, checkCaptcha); global_router.use("/tmwa", tmwa_router); + +vault.init().then(() => { + global_router.use("/vault", vault.router); +}) api.use("/api", global_router); /******************************* @@ -145,4 +151,5 @@ api.use((req, res, next) => { api.set("trust proxy", "loopback"); // only allow localhost to communicate with the API api.disable("x-powered-by"); // we don't need this header -api.listen(process.env.npm_package_config_port, () => console.info("Listening on port %d", process.env.npm_package_config_port)); +console.log(`Running in ${process.env.NODE_ENV} mode`); +api.listen(process.env.PORT, () => console.info(`Listening on port ${process.env.PORT}`)); diff --git a/src/limiter.js b/src/limiter.js new file mode 100644 index 0000000..2760ad3 --- /dev/null +++ b/src/limiter.js @@ -0,0 +1,68 @@ +const limiters = new Map(); // Map> +const bad_actors = new Map(); // Map + +const MAX_DANGER = 5; // ban after X bad things +const BAN_HOURS = 6; // ban X hours on max danger + +const setLimiter = (req, cooldown = 1e3) => { + const route = req.method + req.baseUrl + req.path; + let route_map = limiters.get(route); + if (route_map === undefined || route_map === null) { + route_map = limiters.set(route, new Map()).get(route); + } + + const active_timer = route_map.get(req.ip); + if (active_timer) { + clearTimeout(active_timer.timer); + } + + // if cooldown is above 5min, assume they did something bad + if (cooldown >= 3e5) { + const bad_level = (bad_actors.get(req.ip) || 0) + 1; + bad_actors.set(req.ip, bad_level); + + setTimeout(() => { + bad_actors.set(req.ip, (bad_actors.get(req.ip) || 1) - 1); + console.info(`Limiter: decreasing threat level of IP [${req.ip}]`); + }, BAN_HOURS * 3.6e6); // decrease danger level every X hours + + if (bad_level >= MAX_DANGER) { + req.app.locals.logger.warn(`Limiter: banning IP for ${BAN_HOURS} hours [${req.ip}]`); + } else { + console.warn(`Limiter: bad actor [${req.ip}]`); + } + } + + route_map.set(req.ip, { + timer: setTimeout(() => limiters.get(route).delete(req.ip), cooldown), + expires: Date.now() + cooldown, + }); +}; + +const checkRateLimiter = (req, res, next) => { + const route = req.method + req.path; + const route_map = limiters.get(route); + let timer; + if (route_map && (timer = route_map.get(req.ip))) { + const left = Math.ceil((timer.expires - Date.now()) / 1000); + res.append("Retry-After", left); + res.status(429).json({ + status: "error", + error: "too many requests", + retry: left, + }); + } else if ((bad_actors.get(req.ip) || 0) >= MAX_DANGER) { + // refuse to process request + res.status(418).json({ + status: "GTFO", + }); + } else { + next(); + } + return; +}; + +module.exports = { + cooldown: setLimiter, + check: checkRateLimiter, +}; diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..19a1b46 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,45 @@ +const Fetch = require("node-fetch"); // from npm registry + +// wraps the (match group) with some markdown syntax +const markdown = [ + [ /^(?:[^ ]{1,3} )?([a-z.]+)/i, "**" ], // endpoint + [ /\{(\d+)\} (?:\[[a-z0-9.:]+\])?$/i, "_" ], // vault account + [ /\[([a-z0-9.:]+)\]$/i, "`" ], // ip address +]; + +const md_prettify = (msg) => { + for (const R of markdown) { + msg = msg.replace(R[0], (m, p) => { + const i = m.indexOf(p), l = p.length; + return m.slice(0, i) + R[1] + p + R[1] + m.slice(i + l); + }); + } + + return msg; +}; + +const send_hook = (msg) => { + console.log(msg); + + if (process.env.LOGGER__WEBHOOK) { + Fetch(process.env.LOGGER__WEBHOOK, { + method: "POST", + cache: "no-cache", + redirect: "follow", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: md_prettify(msg), + }), + }); + } +}; + +module.exports = { + log: msg => send_hook(`${msg}`), + info: msg => send_hook(`ℹ ${msg}`), + warn: msg => send_hook(`⚠ ${msg}`), + error: msg => send_hook(`❌ ${msg}`), +}; diff --git a/src/routers/tmwa/index.js b/src/routers/tmwa/index.js index f3eeb72..f89c6bd 100644 --- a/src/routers/tmwa/index.js +++ b/src/routers/tmwa/index.js @@ -7,7 +7,7 @@ const middlewares = { }; module.exports = exports = class TMWA { - constructor(config, api, challenge, rate_limit) { + constructor(config, api, challenge) { // XXX: having to pass a reference to `api` is weird, we should instead // store config in this.config and make the middlewares (somehow) // access this.config. the problem is that we can't pass arguments @@ -22,7 +22,7 @@ module.exports = exports = class TMWA { this.router.get("/server", middlewares.server); - this.router.all("/account", rate_limit, challenge); // flood limit + captcha + this.router.all("/account", challenge); // require captcha this.router.all("/account", express.json(), middlewares.account); tmwa_poll(this); // first heartbeat diff --git a/src/routers/tmwa/middlewares/account.js b/src/routers/tmwa/middlewares/account.js index 7828191..393d0d5 100644 --- a/src/routers/tmwa/middlewares/account.js +++ b/src/routers/tmwa/middlewares/account.js @@ -100,8 +100,7 @@ const create_account = (req, res, next) => { status: "error", error: "malformed request" }); - req.app.locals.rate_limiting.add(req.ip); - setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000); + req.app.locals.cooldown(req, 300000); return; } @@ -111,8 +110,7 @@ const create_account = (req, res, next) => { status: "error", error: "already exists" }); - req.app.locals.rate_limiting.add(req.ip); - setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 2000); + req.app.locals.cooldown(req, 2000); return; } @@ -141,9 +139,8 @@ const create_account = (req, res, next) => { res.status(201).json({ status: "success" }); - req.app.locals.logger.info(`TMWA.account: an account was created: ${req.body.username} [${req.ip}]`); - req.app.locals.rate_limiting.add(req.ip); - setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000); + req.app.locals.logger.info(`TMWA.account: a Legacy account was created: ${req.body.username} [${req.ip}]`); + req.app.locals.cooldown(req, 300000); if (email === "a@a.com") return; @@ -153,9 +150,7 @@ const create_account = (req, res, next) => { to: email, subject: "The Mana World account registration", text: `Your account (\"${req.body.username}\") was created successfully.\nHave fun playing The Mana World!` - }, (err, info) => { - req.app.locals.logger.info(`TMWA.account: sent account creation email: ${req.body.username} ${info.messageId}`); - }); + }, (err, info) => {}); }); child.stdin.end(); }); @@ -176,8 +171,7 @@ const reset_password = async (req, res, next) => { status: "error", error: "no accounts found" }); - req.app.locals.rate_limiting.add(req.ip); - setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 8000); + req.app.locals.cooldown(req, 8000); return; } @@ -186,12 +180,11 @@ const reset_password = async (req, res, next) => { continue; for (const account of op.accounts) { if (account.email === req.body.email) { - res.status(429).json({ + res.status(425).json({ status: "error", error: "operation already pending" }); - req.app.locals.rate_limiting.add(req.ip); - setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 5000); + req.app.locals.cooldown(req, 5000); return; } } @@ -209,7 +202,7 @@ const reset_password = async (req, res, next) => { subject: "The Mana World password reset", text: "You are receiving this email because someone (you?) has requested a password reset on The Mana World "+ "with your email address.\nIf you did not request a password reset please ignore this email.\n\n"+ - "The following accounts are associated with this email address:\n" + account_names + "\n"+ + "The following Legacy accounts are associated with this email address:\n" + account_names + "\n"+ "To proceed with the password reset:\n" + `${req.app.locals.tmwa.reset}${uuid}` }, (err, info) => { pending_operations.set(uuid, { @@ -222,11 +215,9 @@ const reset_password = async (req, res, next) => { res.status(200).json({ status: "success" }); - req.app.locals.logger.info(`TMWA.account: initiated password reset: ${info.messageId} [${req.ip}]`); }); - req.app.locals.rate_limiting.add(req.ip); - setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 8000); + req.app.locals.cooldown(req, 8000); return; } else if (req.body && Reflect.has(req.body, "username") && !Reflect.has(req.body, "password") && @@ -250,8 +241,7 @@ const reset_password = async (req, res, next) => { status: "error", error: "malformed request" }); - req.app.locals.rate_limiting.add(req.ip); - setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000); + req.app.locals.cooldown(req, 300000); return; } @@ -261,8 +251,7 @@ const reset_password = async (req, res, next) => { status: "error", error: "request expired" }); - req.app.locals.rate_limiting.add(req.ip); - setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000); + req.app.locals.cooldown(req, 300000); return; } @@ -271,10 +260,9 @@ const reset_password = async (req, res, next) => { status: "error", error: "invalid type" }); - req.app.locals.rate_limiting.add(req.ip); - setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000); + req.app.locals.cooldown(req, 300000); pending_operations.delete(req.body.code); - req.app.locals.logger.warn(`TMWA.account: attempted reset account with invalid uuid: ${req.body.username} [${req.ip}]`); + req.app.locals.logger.warn(`TMWA.account: attempted to reset a Legacy account using an invalid uuid: ${req.body.username} [${req.ip}]`); return; } @@ -305,17 +293,14 @@ const reset_password = async (req, res, next) => { status: "success" }); req.app.locals.logger.info(`TMWA.account: password has been reset: ${req.body.username} [${req.ip}]`); - req.app.locals.rate_limiting.add(req.ip); - setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000); + req.app.locals.cooldown(req, 300000); transporter.sendMail({ from: req.app.locals.mailer.from, to: account.email, subject: "The Mana World password reset", - text: `You have successfully reset the password for account \"${req.body.username}\".\nHave fun playing The Mana World!\n\n⚠ If you did not perform this password reset, please contact us ASAP to secure your account.` - }, (err, info) => { - req.app.locals.logger.info(`TMWA.account: sent password reset confirmation email: ${req.body.username} ${info.messageId}`); - }); + text: `You have successfully reset the password for Legacy account \"${req.body.username}\".\nHave fun playing The Mana World!\n\n⚠ If you did not perform this password reset, please contact us ASAP to secure your account.` + }, (err, info) => {}); }); child.stdin.end(); return; @@ -326,10 +311,9 @@ const reset_password = async (req, res, next) => { status: "error", error: "foreign account" }); - req.app.locals.rate_limiting.add(req.ip); - setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000); + req.app.locals.cooldown(req, 300000); pending_operations.delete(req.body.code); - req.app.locals.logger.warn(`TMWA.account: attempted reset account not owned by user: ${req.body.username} [${req.ip}]`); + req.app.locals.logger.warn(`TMWA.account: attempted to reset a Legacy account not owned by the user: ${req.body.username} [${req.ip}]`); return; }; diff --git a/src/routers/tmwa/middlewares/server.js b/src/routers/tmwa/middlewares/server.js index 261ecfd..51c293a 100644 --- a/src/routers/tmwa/middlewares/server.js +++ b/src/routers/tmwa/middlewares/server.js @@ -8,4 +8,5 @@ module.exports = exports = (req, res, next) => { playersOnline: req.app.locals.tmwa.num_online, serverStatus: req.app.locals.tmwa.status, }); + req.app.locals.cooldown(req, 500); }; diff --git a/src/routers/vault/index.js b/src/routers/vault/index.js new file mode 100644 index 0000000..4213dcb --- /dev/null +++ b/src/routers/vault/index.js @@ -0,0 +1,115 @@ +const express = require("express"); // from npm registry +const Sequelize = require("sequelize"); // from npm registry +const Ephemeral = require("./utils/ephemeral.js"); + +const models = { + vault: [ + "login", "login_log", + "identity", "identity_log", + "claimed_game_accounts", + "claimed_legacy_accounts", + "account_log", + "migration_log", + ], + legacy: [ + "login", + "char", + //"inventory", + //"storage", + //"global_acc_reg", + //"acc_reg", + //"char_reg", + //"party", + ], + evol: [ + "login", + "char", + "char_reservation", + ], +}; + +const middlewares = { + session: require("./middlewares/session.js"), + identity: require("./middlewares/identity.js"), + account: require("./middlewares/account.js"), + legacy_account: require("./middlewares/legacy/account.js"), + evol_account: require("./middlewares/evol/account.js"), +}; + + + +module.exports = exports = class Vault { + constructor (api, challenge) { + // XXX: having to pass a reference to `api` is weird, we should instead + // store config in this.config and make the middlewares (somehow) + // access this.config. the problem is that we can't pass arguments + // to middlewares, so we might have to curry them + + this.api = api; + this.api.locals.session = Ephemeral.session_handler; + this.api.locals.identity_pending = Ephemeral.identity_handler; + this.router = express.Router(["caseSensitive", "strict"]); + this.sequelize = {}; + + this.router.all("/session", /*challenge,*/ express.json(), middlewares.session); + this.router.all("/identity", express.json(), middlewares.identity); + this.router.all("/account", express.json(), middlewares.account); + + // legacy + this.router.all("/legacy/account", express.json(), middlewares.legacy_account); + + // new server + this.router.all("/evol/account", express.json(), middlewares.evol_account); + + + console.info("Loaded Vault router"); + return this; + } + + async init () { + console.info("Vault: initializing database"); + + for (const [db, db_models] of Object.entries(models)) { + const DB = db.toUpperCase(); + this.sequelize[db] = await new Sequelize( + process.env[`SQL__${DB}__DB`], + process.env[`SQL__${DB}__USER`], + process.env[`SQL__${DB}__PASS`], { + host: process.env[`SQL__${DB}__HOST`], + dialect: "mariadb", + dialectOptions: { + timezone: process.env.TZ, + }, + logging: false, // don't print queries to console + benchmark: false, + pool: { + max: 10, + min: 1, // always have at least one connection open + idle: 10000, + }, + define: { + engine: "ROCKSDB", + underscored: true, // convert camelCase to snake_case + freezeTableName: true, // why the fuck would you want it pluralized? + timestamps: false, // no thanks, I'll add my own timestamps + }, + }); + + this.api.locals[db] = {}; + + for (const table of db_models) { + const model = require(`./models/${db}/${table}.js`); + this.api.locals[db][table] = await this.sequelize[db].define(table, model.fields, model.options); + } + + console.info(`Vault: loaded models for ${DB}`); + } + + await this.sequelize.vault.sync({alter: {drop: false}}); // update SQL tables + + this.api.locals.sequelize = this.sequelize; // for access to sequelize.fn + console.info("Vault: database ready"); + + return Promise.resolve(true); + } +}; diff --git a/src/routers/vault/middlewares/account.js b/src/routers/vault/middlewares/account.js new file mode 100644 index 0000000..03c5ce0 --- /dev/null +++ b/src/routers/vault/middlewares/account.js @@ -0,0 +1,161 @@ +"use strict"; + +const regexes = { + token: /^[a-zA-Z0-9-_]{6,128}$/, // UUID +}; + +const get_data = async (req, res, next) => { + const token = String(req.get("X-VAULT-SESSION") || ""); + + if (!token.match(/^[a-zA-Z0-9-_]{6,128}$/)) { + res.status(400).json({ + status: "error", + error: "missing session key", + }); + req.app.locals.logger.warn(`Vault.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + const session = req.app.locals.session.get(token); + + if (session === null || session === undefined) { + res.status(410).json({ + status: "error", + error: "session expired", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + if (session.authenticated !== true) { + res.status(401).json({ + status: "error", + error: "not authenticated", + }); + req.app.locals.logger.warn(`Vault.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + res.status(200).json({ + status: "success", + data: { + primaryIdentity: session.primaryIdentity, + allowNonPrimary: session.allowNonPrimary, + }, + }); + req.app.locals.cooldown(req, 1e3); +}; + +const update_account = async (req, res, next) => { + const token = String(req.get("X-VAULT-SESSION") || ""); + + if (!token.match(regexes.token)) { + res.status(400).json({ + status: "error", + error: "missing session key", + }); + req.app.locals.logger.warn(`Vault.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + if (!req.body || !Reflect.has(req.body, "primary") || !Reflect.has(req.body, "allow") || + !Number.isInteger(req.body.primary)) { + res.status(400).json({ + status: "error", + error: "invalid format", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + const session = req.app.locals.session.get(token); + + if (session === null || session === undefined) { + res.status(410).json({ + status: "error", + error: "session expired", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + if (session.authenticated !== true) { + res.status(401).json({ + status: "error", + error: "not authenticated", + }); + req.app.locals.logger.warn(`Vault.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + const update_fields = {}; + + if (session.primaryIdentity !== req.body.primary) { + // update primary identity + let new_primary = null; + + for (const ident of session.identities) { + if (ident.id === req.body.primary) { + new_primary = ident.id; + break; + } + } + + if (new_primary === null) { + res.status(404).json({ + status: "error", + error: "not owned by you", + }); + req.app.locals.logger.warn(`Vault.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + } + + update_fields.primaryIdentity = new_primary; + } + if (session.allowNonPrimary !== !!req.body.allow) { + // update allow non-primary + update_fields.allowNonPrimary = !!req.body.allow; + } + + // update SQL + if (Object.keys(update_fields).length) { + await req.app.locals.vault.login.update(update_fields, { + where: { id: session.vault } + }); + } + + // now update our cache + session.allowNonPrimary = !!req.body.allow; + session.primaryIdentity = +req.body.primary; + + for (const ident of session.identities) { + if (ident.id === session.primaryIdentity) { + ident.primary = true; + } else if (ident.primary === true) { + ident.primary = false; + } + } + + res.status(200).json({ + status: "success", + }); + + req.app.locals.cooldown(req, 1e3); +}; + +module.exports = exports = async (req, res, next) => { + switch(req.method) { + case "GET": + // get account data + return await get_data(req, res, next); + case "PATCH": + // change account data + return await update_account(req, res, next); + default: + next(); // fallthrough to default endpoint (404) + } +}; diff --git a/src/routers/vault/middlewares/evol/account.js b/src/routers/vault/middlewares/evol/account.js new file mode 100644 index 0000000..5067334 --- /dev/null +++ b/src/routers/vault/middlewares/evol/account.js @@ -0,0 +1,326 @@ +"use strict"; + +const regexes = { + token: /^[a-zA-Z0-9-_]{6,128}$/, // UUID + any30: /^[^\s][^\t\r\n]{6,28}[^\s]$/, // herc password (this looks scary) + alnum23: /^[a-zA-Z0-9_]{4,23}$/, // mostly for username + gid: /^[23][0-9]{6}$/, // account id +}; + +const get_account_list = async (req, vault_id) => { + const accounts = []; + const claimed = await req.app.locals.vault.claimed_game_accounts.findAll({ + where: {vaultId: vault_id}, + }); + + for (let acc of claimed) { + acc = await req.app.locals.evol.login.findByPk(acc.accountId); + const chars = []; + const chars_ = await req.app.locals.evol.char.findAll({ + where: {accountId: acc.accountId}, + }); + + for (const char of chars_) { + chars.push({ + // TODO: make this a class + name: char.name, + charId: char.charId, + level: char.baseLevel, + sex: char.sex, + }); + } + + accounts.push({ + // TODO: make this a class + name: acc.userid, + accountId: acc.accountId, + chars, + }); + } + + return accounts; +}; + +const get_accounts = async (req, res, next) => { + const token = String(req.get("X-VAULT-SESSION") || ""); + + if (!token.match(regexes.token)) { + res.status(400).json({ + status: "error", + error: "missing session key", + }); + req.app.locals.logger.warn(`Vault.evol.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + const session = req.app.locals.session.get(token); + + if (session === null || session === undefined) { + res.status(410).json({ + status: "error", + error: "session expired", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + if (session.authenticated !== true) { + res.status(401).json({ + status: "error", + error: "not authenticated", + }); + req.app.locals.logger.warn(`Vault.evol.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + let accounts = session.gameAccounts; + + if (accounts.length < 1) { + console.info(`Vault.evol.account: fetching evol accounts {${session.vault}} [${req.ip}]`); + accounts = await get_account_list(req, session.vault); + session.gameAccounts = accounts; + req.app.locals.cooldown(req, 3e3); + } else { + req.app.locals.cooldown(req, 1e3); + } + + res.status(200).json({ + status: "success", + accounts, + }); +}; + +const new_account = async (req, res, next) => { + const token = String(req.get("X-VAULT-SESSION") || ""); + + if (!token.match(regexes.token)) { + res.status(400).json({ + status: "error", + error: "missing session key", + }); + req.app.locals.logger.warn(`Vault.evol.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + if (!req.body || + !Reflect.has(req.body, "username") || !Reflect.has(req.body, "password") || + !req.body.username.match(regexes.alnum23) || + !req.body.password.match(regexes.any30)) { // FIXME: this is unsafe: can cause a promise rejection if something else than a string is passed (no Number.match() exists) + res.status(400).json({ + status: "error", + error: "invalid format", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + const session = req.app.locals.session.get(token); + + if (session === null || session === undefined) { + res.status(410).json({ + status: "error", + error: "session expired", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + if (session.authenticated !== true) { + res.status(401).json({ + status: "error", + error: "not authenticated", + }); + req.app.locals.logger.warn(`Vault.evol.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + // this check is necessary because login.userid has no UNIQUE constraint + const existing = await req.app.locals.evol.login.findOne({ + where: {userid: req.body.username} + }); + + if (existing !== null) { + res.status(409).json({ + status: "error", + error: "already exists", + }); + req.app.locals.cooldown(1e3); + return; + } + + const evol_acc = await req.app.locals.evol.login.create({ + userid: req.body.username, + userPass: req.body.password, + email: `${session.vault}@vault`, // setting an actual email is pointless + }); + + req.app.locals.vault.account_log.create({ + vaultId: session.vault, + accountType: "EVOL", + actionType: "CREATE", + accountId: evol_acc.accountId, + ip: req.app.locals.sequelize.vault.fn("INET6_ATON", req.ip), + }); + + // immediately claim it + await req.app.locals.vault.claimed_game_accounts.create({ + accountId: evol_acc.accountId, + vaultId: session.vault, + }); + + // now add it to the evol cache + const account = { + name: evol_acc.userid, + accountId: evol_acc.accountId, + chars: [], + }; + session.gameAccounts.push(account); + + req.app.locals.logger.info(`Vault.evol.account: created a new game account: ${account.accountId} {${session.vault}} [${req.ip}]`); + + res.status(200).json({ + status: "success", + account, + }); + + req.app.locals.cooldown(5e3); +}; + +const update_account = async (req, res, next) => { + const token = String(req.get("X-VAULT-SESSION") || ""); + + if (!token.match(regexes.token)) { + res.status(400).json({ + status: "error", + error: "missing session key", + }); + req.app.locals.logger.warn(`Vault.evol.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + if (!req.body || !Reflect.has(req.body, "accountId") || + !String(req.body.accountId).match(regexes.gid) || !( + (Reflect.has(req.body, "username") && req.body.username.match(regexes.alnum23)) || + (Reflect.has(req.body, "password") && req.body.password.match(regexes.any30)))) { // FIXME: this is unsafe: can cause a promise rejection if something else than a string is passed (no Number.match() exists) + res.status(400).json({ + status: "error", + error: "invalid format", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + const session = req.app.locals.session.get(token); + + if (session === null || session === undefined) { + res.status(410).json({ + status: "error", + error: "session expired", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + if (session.authenticated !== true) { + res.status(401).json({ + status: "error", + error: "not authenticated", + }); + req.app.locals.logger.warn(`Vault.evol.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + let account = null; + for (const acc of session.gameAccounts) { + if (acc.accountId === req.body.accountId) { + account = acc; + break; + } + } + + if (account === null) { + res.status(404).json({ + status: "error", + error: "account not found", + }); + req.app.locals.logger.warn(`Vault.evol.account: blocked an attempt to modify a game account not owned by the user {${session.vault}} [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + let update_fields = {}; + if (Reflect.has(req.body, "username")) { + // check if the name exists + const existing = await req.app.locals.evol.login.findOne({ + where: {userid: req.body.username} + }); + + if (existing !== null) { + res.status(409).json({ + status: "error", + error: "already exists", + }); + req.app.locals.cooldown(req, 500); + return; + } + + update_fields = { + userid: req.body.username, + }; + account.name = req.body.username; + req.app.locals.logger.info(`Vault.evol.account: changed username of game account ${account.accountId} {${session.vault}} [${req.ip}]`); + req.app.locals.vault.account_log.create({ + vaultId: session.vault, + accountType: "EVOL", + actionType: "UPDATE", + details: "username", + accountId: account.accountId, + ip: req.app.locals.sequelize.vault.fn("INET6_ATON", req.ip), + }); + } else { + update_fields = { + userPass: req.body.password, + }; + req.app.locals.logger.info(`Vault.evol.account: changed password of game account ${account.accountId} {${session.vault}} [${req.ip}]`); + req.app.locals.vault.account_log.create({ + vaultId: session.vault, + accountType: "EVOL", + actionType: "UPDATE", + details: "password", + accountId: account.accountId, + ip: req.app.locals.sequelize.vault.fn("INET6_ATON", req.ip), + }); + } + + await req.app.locals.evol.login.update(update_fields, {where: { + accountId: account.accountId, + }}); + + res.status(200).json({ + status: "success", + account, + }); + + req.app.locals.cooldown(req, 5e3); +}; + +module.exports = exports = async (req, res, next) => { + switch(req.method) { + case "GET": // list accounts + return await get_accounts(req, res, next); + case "POST": // new account + return await new_account(req, res, next); + case "PATCH": // change username/password + return await update_account(req, res, next); + // TODO: PUT: move char + // TODO: DELETE: delete account and related data + default: + next(); // fallthrough to default endpoint (404) + } +}; diff --git a/src/routers/vault/middlewares/identity.js b/src/routers/vault/middlewares/identity.js new file mode 100644 index 0000000..acd1574 --- /dev/null +++ b/src/routers/vault/middlewares/identity.js @@ -0,0 +1,255 @@ +"use strict"; +const uuidv4 = require("uuid/v4"); +const nodemailer = require("nodemailer"); +const Claim = require("../utils/claim.js"); + +let transporter = nodemailer.createTransport({ + sendmail: true, + newline: 'unix', + path: '/usr/sbin/sendmail' +}); + +const get_identities = async (req, res, next) => { + const token = String(req.get("X-VAULT-SESSION") || ""); + + if (!token.match(/^[a-zA-Z0-9-_]{6,128}$/)) { + res.status(400).json({ + status: "error", + error: "missing session key", + }); + req.app.locals.logger.warn(`Vault.identity: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + const session = req.app.locals.session.get(token); + + if (session === null || session === undefined) { + res.status(410).json({ + status: "error", + error: "session expired", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + if (session.authenticated !== true) { + res.status(401).json({ + status: "error", + error: "not authenticated", + }); + req.app.locals.logger.warn(`Vault.identity: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + if (session.identities.length === 0) { + console.info(`Vault.identity: fetching identities {${session.vault}} [${req.ip}]`); + const rows = await req.app.locals.vault.identity.findAll({ + where: {userId: session.vault} + }); + + for (const row of rows) { + session.identities.push({ + // TODO: make this a class! + id: row.id, + email: row.email, + added: row.addedDate, + primary: session.primaryIdentity === row.id, + }); + } + } + + res.status(200).json({ + status: "success", + identities: session.identities, // cached in the session + }); + req.app.locals.cooldown(req, 1e3); +}; + +const add_identity = async (req, res, next) => { + const token = String(req.get("X-VAULT-SESSION") || ""); + const validate = String(req.get("X-VAULT-TOKEN") || ""); + + if (token === "" && validate !== "") { + if (!validate.match(/^[a-zA-Z0-9-_]{6,128}$/)) { + res.status(400).json({ + status: "error", + error: "missing token", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + const ident = req.app.locals.identity_pending.get(validate); + + if (ident === null || ident === undefined) { + res.status(410).json({ + status: "error", + error: "token has expired", + }); + req.app.locals.cooldown(req, 15e3); + return; + } + + const newIdent = await req.app.locals.vault.identity.create({ + userId: ident.vault, + email: ident.email, + }); + + req.app.locals.vault.identity_log.create({ + userId: ident.vault, + identityId: newIdent.id, + action: "ADD", + ip: req.app.locals.sequelize.vault.fn("INET6_ATON", req.ip), + }); + + await Claim.claim_accounts(req, ident.email, ident.vault); + + for (const [key, session] of req.app.locals.session) { + if (session.vault === ident.vault && session.authenticated) { + session.identities.push({ + // TODO: make this a class! + id: newIdent.id, + email: newIdent.email, + added: newIdent.addedDate, + primary: false, + }); + break; + } + } + + req.app.locals.identity_pending.delete(validate); + console.info(`Vault.identity: added a new identity {${session.vault}} [${req.ip}]`); + + res.status(201).json({ + status: "success", + }); + req.app.locals.cooldown(req, 6e4); + return; + } + + // request to add + + if (!token.match(/^[a-zA-Z0-9-_]{6,128}$/)) { + res.status(400).json({ + status: "error", + error: "missing session key", + }); + req.app.locals.logger.warn(`Vault.identity: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + if (!req.body || !Reflect.has(req.body, "email") || + !req.body.email.match(/^(?:[a-zA-Z0-9.$&+=_~-]{1,255}@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,255}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,255}[a-zA-Z0-9])?){1,9})$/) || + req.body.email.length >= 320) { + res.status(400).json({ + status: "error", + error: "invalid email address", + }); + req.app.locals.cooldown(req, 1e3); + return; + } + + const session = req.app.locals.session.get(token); + + if (session === null || session === undefined) { + res.status(410).json({ + status: "error", + error: "session expired", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + if (session.authenticated !== true) { + res.status(401).json({ + status: "error", + error: "not authenticated", + }); + req.app.locals.logger.warn(`Vault.identity: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + const find = await req.app.locals.vault.identity.findOne({ + where: {email: req.body.email} + }); + + if (find !== null) { + res.status(409).json({ + status: "error", + error: "already assigned", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + const count = await req.app.locals.vault.identity.count({ + where: {userId: session.vault} + }); + + if (count >= 20) { + res.status(416).json({ + status: "error", + error: "too many identities", + }); + req.app.locals.cooldown(req, 3e4); + return; + } + + const uuid = uuidv4(); + req.app.locals.identity_pending.set(uuid, { + ip: req.ip, + vault: session.vault, + email: req.body.email, + }); + + console.log(`Vault.session: starting identity validation {${session.vault}} [${req.ip}]`); + + if (process.env.NODE_ENV === "development") { + console.log(`uuid: ${uuid}`); + } else { + transporter.sendMail({ + from: process.env.VAULT__MAILER__FROM, + to: req.body.email, + subject: "The Mana World identity validation", + text: "You are receiving this email because someone (you?) has requested to link your email address "+ + "to a TMW Vault account.\nIf you did not initiate this process, please ignore this email.\n\n"+ + "To confirm, use this link:\n" + `${process.env.VAULT__URL__IDENTITY}${uuid}` + }, (err, info) => {}); + } + + res.status(200).json({ + status: "success" + }); + req.app.locals.cooldown(req, 6e4); +}; + +const update_identity = async (req, res, next) => { + // TODO +}; + +const drop_identity = async (req, res, next) => { + // TODO +}; + +module.exports = exports = async (req, res, next) => { + switch(req.method) { + case "GET": + // list identities + return await get_identities(req, res, next); + case "POST": + // add identity + return await add_identity(req, res, next); + case "PATCH": + // set as primary + //return await update_identity(req, res, next); + case "DELETE": + // remove an identity + //return await drop_identity(req, res, next); + default: + next(); // fallthrough to default endpoint (404) + } +}; diff --git a/src/routers/vault/middlewares/legacy/account.js b/src/routers/vault/middlewares/legacy/account.js new file mode 100644 index 0000000..14ecbe1 --- /dev/null +++ b/src/routers/vault/middlewares/legacy/account.js @@ -0,0 +1,437 @@ +"use strict"; +const uuidv4 = require("uuid/v4"); +const md5saltcrypt = require("../../utils/md5saltcrypt.js"); +const flatfile = require("../../utils/flatfile.js"); + +const regexes = { + token: /^[a-zA-Z0-9-_]{6,128}$/, // UUID + any23: /^[^\s][^\t\r\n]{6,21}[^\s]$/, // tmwa password (this looks scary) + any30: /^[^\s][^\t\r\n]{6,28}[^\s]$/, // herc password (this looks scary) + alnum23: /^[a-zA-Z0-9_]{4,23}$/, // mostly for username + gid: /^[23][0-9]{6}$/, // account id +}; + +const get_account_list = async (req, vault_id) => { + const accounts = []; + const claimed = await req.app.locals.vault.claimed_legacy_accounts.findAll({ + where: {vaultId: vault_id}, + }); + + for (let acc of claimed) { + acc = await req.app.locals.legacy.login.findByPk(acc.accountId); + const chars = []; + const chars_ = await req.app.locals.legacy.char.findAll({ + where: {accountId: acc.accountId}, + }); + + for (const char of chars_) { + chars.push({ + name: char.name, + charId: char.charId, + revoltId: char.revoltId, + level: char.baseLevel, + sex: char.sex, + }); + } + + accounts.push({ + name: acc.userid, + accountId: acc.accountId, + revoltId: acc.revoltId, + chars, + }); + } + + return accounts; +}; + +const get_accounts = async (req, res, next) => { + const token = String(req.get("X-VAULT-SESSION") || ""); + + if (!token.match(regexes.token)) { + res.status(400).json({ + status: "error", + error: "missing session key", + }); + req.app.locals.logger.warn(`Vault.legacy.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + const session = req.app.locals.session.get(token); + + if (session === null || session === undefined) { + res.status(410).json({ + status: "error", + error: "session expired", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + if (session.authenticated !== true) { + res.status(401).json({ + status: "error", + error: "not authenticated", + }); + req.app.locals.logger.warn(`Vault.legacy.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + let accounts = session.legacyAccounts; + + if (accounts.length < 1) { + console.info(`Vault.legacy.account: fetching legacy accounts {${session.vault}} [${req.ip}]`); + accounts = await get_account_list(req, session.vault); + session.legacyAccounts = accounts; + req.app.locals.cooldown(req, 3e3); + } else { + req.app.locals.cooldown(req, 1e3); + } + + res.status(200).json({ + status: "success", + accounts, + }); +}; + +const claim_by_password = async (req, res, next) => { + const token = String(req.get("X-VAULT-SESSION") || ""); + + if (!token.match(regexes.token)) { + res.status(400).json({ + status: "error", + error: "missing session key", + }); + req.app.locals.logger.warn(`Vault.legacy.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + if (!req.body || !Reflect.has(req.body, "username") || !Reflect.has(req.body, "password") || + !req.body.username.match(regexes.alnum23) || + !req.body.password.match(regexes.any23)) { + res.status(400).json({ + status: "error", + error: "invalid format", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + const session = req.app.locals.session.get(token); + + if (session === null || session === undefined) { + res.status(410).json({ + status: "error", + error: "session expired", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + if (session.authenticated !== true) { + res.status(401).json({ + status: "error", + error: "not authenticated", + }); + req.app.locals.logger.warn(`Vault.legacy.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + const legacy = await req.app.locals.legacy.login.findOne({ + where: {userid: req.body.username} + }); + + if (legacy === null) { + res.status(404).json({ + status: "error", + error: "not found", + }); + req.app.locals.cooldown(req, 1e3); + return; + } + + if (!md5saltcrypt.verify(legacy.userPass, req.body.password)) { + // check to see if the password has been updated since it was dumped to SQL + const flatfile_account = await flatfile.findAccount(legacy.accountId, legacy.userid); // this operation is costly + if (flatfile_account !== null && + md5saltcrypt.verify(flatfile_account.password, req.body.password)) { + // update the password in SQL (deferred) + console.log(`Vault.legacy.account: updating SQL password from flatfile for account ${legacy.accountId}`); + req.app.locals.legacy.login.update({ + userPass: md5saltcrypt.hash(req.body.password), + }, {where: { + accountId: legacy.accountId, + }}); + } else { + // the password is just plain wrong + res.status(404).json({ + status: "error", + error: "not found", + }); + console.warn(`Vault.legacy.account: failed to log in to Legacy account {${session.vault}} [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + // TODO: huge cooldown after 8 attempts + return; + } + } + + const claimed = await req.app.locals.vault.claimed_legacy_accounts.findByPk(legacy.accountId); + + if (claimed !== null) { + res.status(409).json({ + status: "error", + error: "already assigned", + }); + req.app.locals.logger.warn(`Vault.legacy.account: blocked an attempt to link an already-linked account {${session.vault}} [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + await req.app.locals.vault.claimed_legacy_accounts.create({ + accountId: legacy.accountId, + vaultId: session.vault, + }); + + // log this action: + req.app.locals.vault.account_log.create({ + vaultId: session.vault, + accountType: "LEGACY", + actionType: "LINK", + accountId: legacy.accountId, + ip: req.app.locals.sequelize.vault.fn("INET6_ATON", req.ip), + }); + + // now we must update the session cache: + const chars = []; + const chars_ = await req.app.locals.legacy.char.findAll({ + where: {accountId: legacy.accountId}, + }); + + for (const char of chars_) { + chars.push({ + // TODO: make this a class + name: char.name, + charId: char.charId, + revoltId: char.revoltId, + level: char.baseLevel, + sex: char.sex, + }); + } + + const account = { + name: legacy.userid, + accountId: legacy.accountId, + revoltId: legacy.revoltId, + chars, + }; + session.legacyAccounts.push(account); + + res.status(200).json({ + status: "success", + account + }); + + req.app.locals.logger.info(`Vault.legacy.account: linked Legacy account ${legacy.accountId} to Vault account {${session.vault}} [${req.ip}]`); + req.app.locals.cooldown(req, 8e3); +}; + +const migrate = async (req, res, next) => { + const token = String(req.get("X-VAULT-SESSION") || ""); + + if (!token.match(regexes.token)) { + res.status(400).json({ + status: "error", + error: "missing session key", + }); + req.app.locals.logger.warn(`Vault.legacy.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + if (!req.body || !Reflect.has(req.body, "accountId") || + !Reflect.has(req.body, "username") || !Reflect.has(req.body, "password") || + !req.body.username.match(regexes.alnum23) || + !req.body.password.match(regexes.any30) || // FIXME: this is unsafe: can cause a promise rejection if something else than a string is passed (no Number.match() exists) + !String(req.body.accountId).match(regexes.gid)) { + res.status(400).json({ + status: "error", + error: "invalid format", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + const session = req.app.locals.session.get(token); + + if (session === null || session === undefined) { + res.status(410).json({ + status: "error", + error: "session expired", + }); + req.app.locals.cooldown(req, 5e3); + return; + } + + if (session.authenticated !== true) { + res.status(401).json({ + status: "error", + error: "not authenticated", + }); + req.app.locals.logger.warn(`Vault.legacy.account: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + let legacy = null; + + // check if we own it + // NOTE: this cached data is never stale because we update it when operations are performed + for (const acc of session.legacyAccounts) { + if (acc.accountId === req.body.accountId) { + legacy = acc; + break; + } + } + + if (legacy === null) { + res.status(404).json({ + status: "error", + error: "not found", + }); + req.app.locals.logger.warn(`Vault.legacy.account: blocked an attempt to migrate a Legacy account not owned by the user {${session.vault}} [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + if (legacy.revoltId) { + res.status(409).json({ + status: "error", + error: "already migrated", + }); + req.app.locals.logger.warn(`Vault.legacy.account: blocked an attempt to migrate an already-migrated Legacy account {${session.vault}} [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + // lots of queries (expensive!): + + // this check is necessary because login.userid has no UNIQUE constraint + const existing = await req.app.locals.evol.login.findOne({ + where: {userid: req.body.username} + }); + + if (existing !== null) { + res.status(409).json({ + status: "error", + error: "already exists", + }); + req.app.locals.cooldown(req, 2e3); + return; + } + + const evol_acc = await req.app.locals.evol.login.create({ + userid: req.body.username, + userPass: req.body.password, + email: `${session.vault}@vault`, // setting an actual email is pointless + }); + + req.app.locals.vault.migration_log.create({ + vaultId: session.vault, + legacyId: legacy.accountId, + accountId: evol_acc.accountId, + ip: req.app.locals.sequelize.vault.fn("INET6_ATON", req.ip), + }); + + // immediately claim it + await req.app.locals.vault.claimed_game_accounts.create({ + accountId: evol_acc.accountId, + vaultId: session.vault, + }); + + // now add it to the evol cache + const cache_key = session.gameAccounts.push({ + name: evol_acc.userid, + accountId: evol_acc.accountId, + chars: [], + }) - 1; + + legacy.revoltId = evol_acc.accountId; // update legacy cache + await req.app.locals.legacy.login.update({ // update sql + revoltId: evol_acc.accountId, + }, {where: { + accountId: legacy.accountId, + }}); + + // XXX: ideally we should be using createBulk but we also want to update + for (const [num, char] of legacy.chars.entries()) { + if (char.revoltId) { + continue; + } + + try { + const evol_char = await req.app.locals.evol.char.create({ + name: char.name, + charNum: num, + accountId: evol_acc.accountId, + hairColor: Math.floor(Math.random() * 21), // range: [0,21[ + hair: (Math.floor(Math.random() * 28) + 1), // range: [1,28] + sex: char.sex === "F" ? "F" : (char.sex === "M" ? "M" : "U"), // non-binary is undefined in evol + }); + } catch (err) { + // char.name has a UNIQUE constraint but an actual collision would never happen + console.error(err); + continue; + } + + // remove the name reservation + req.app.locals.evol.char_reservation.destroy({ + where: { name: char.name } + }); + + // update the evol cache + session.gameAccounts[cache_key].chars.push({ + name: evol_char.name, + charId: evol_char.charId, + level: 1, + sex: evol_char.sex, + }); + + char.revoltId = evol_char.charId; // update legacy cache + await req.app.locals.legacy.char.update({ // update sql + revoltId: evol_char.charId, + }, {where: { + charId: char.charId, + }}); + } + + // TODO: try/catch each of the await operations + + res.status(200).json({ + status: "success", + account: session.gameAccounts[cache_key], + }); + + req.app.locals.logger.info(`Vault.legacy.account: migrated Legacy account ${legacy.accountId} {${session.vault}} [${req.ip}]`); + req.app.locals.cooldown(req, 15e3); +}; + +module.exports = exports = async (req, res, next) => { + switch(req.method) { + case "GET": + // list accounts + return await get_accounts(req, res, next); + case "POST": + // add account (by password) + return await claim_by_password(req, res, next); + case "PATCH": + // migrate to new server + return await migrate(req, res, next); + // TODO: password reset + default: + next(); // fallthrough to default endpoint (404) + } +}; diff --git a/src/routers/vault/middlewares/session.js b/src/routers/vault/middlewares/session.js new file mode 100644 index 0000000..6661578 --- /dev/null +++ b/src/routers/vault/middlewares/session.js @@ -0,0 +1,313 @@ +"use strict"; +const uuidv4 = require("uuid/v4"); +const nodemailer = require("nodemailer"); +const Claim = require("../utils/claim.js"); +const Session = require("../types/Session.js"); + +let transporter = nodemailer.createTransport({ + sendmail: true, + newline: 'unix', + path: '/usr/sbin/sendmail' +}); + +const delete_session = async (req, res, next) => { + const token = String(req.get("X-VAULT-SESSION") || ""); + + if (!token.match(/^[a-zA-Z0-9-_]{6,128}$/)) { + res.status(400).json({ + status: "error", + error: "missing session key", + }); + req.app.locals.logger.warn(`Vault.session: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + const session = req.app.locals.session.get(token); + req.app.locals.cooldown(1e4); // cooldown no matter what + + if (session === null || session === undefined) { + // session is already expired + res.status(200).json({ + status: "success", + }); + return; + } + + req.app.locals.vault.login_log.create({ + userId: session.vault, + action: "LOGOUT", + ip: req.app.locals.sequelize.vault.fn("INET6_ATON", req.ip), + }); + + req.app.locals.session.delete(token); + + console.log(`Vault.session: invalidating session ${token} (logout) [${req.ip}]`); + + res.status(200).json({ + status: "success", + }); +}; + +const auth_session = async (req, res, next) => { + const token = String(req.get("X-VAULT-SESSION") || ""); + + if (!token.match(/^[a-zA-Z0-9-_]{6,128}$/)) { + res.status(400).json({ + status: "error", + error: "missing session key", + }); + req.app.locals.logger.warn(`Vault.session: blocked an attempt to bypass authentication [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + return; + } + + const session = req.app.locals.session.get(token); + + if (session === null || session === undefined) { + res.status(410).json({ + status: "error", + error: "session expired", + session: { + expires: 0, + identity: null, + } + }); + // don't log: this can get spammy + req.app.locals.cooldown(req, 1e3); + return; + } + + if (session.authenticated === true) { + // already authed, tell client + res.status(200).json({ + status: "success", + session: { + expires: session.expires, + identity: session.identity, + } + }); + req.app.locals.cooldown(req, 500); + return; + } + + if (session.vault === null && session.identity === null) { + // this is a new account + const user = await req.app.locals.vault.login.create({}); + + req.app.locals.vault.login_log.create({ + userId: user.id, + action: "CREATE", + ip: req.app.locals.sequelize.vault.fn("INET6_ATON", req.ip), + }); + + const ident = await req.app.locals.vault.identity.create({ + userId: user.id, + email: session.email, + }); + + req.app.locals.vault.identity_log.create({ + userId: user.id, + identityId: ident.id, + action: "ADD", + ip: req.app.locals.sequelize.vault.fn("INET6_ATON", req.ip), + }); + + await req.app.locals.vault.identity.update({ + primaryIdentity: ident.id, + }, {where: { + id: user.id, + }}); + + req.app.locals.logger.info(`Vault.session: created a new Vault account {${user.id}} [${req.ip}]`); + await Claim.claim_accounts(req, session.email, user.id, session); + + // update current session + session.vault = user.id; + session.identity = ident.id; + session.primaryIdentity = ident.id; + session.allowNonPrimary = user.allowNonPrimary; + session.identities = [{ + // TODO: make this a class! + email: ident.email, + added: ident.addedDate, + primary: true, + }]; + } else { + if (session.identity !== session.primaryIdentity && !session.allowNonPrimary) { + // unexpected: a session was created when it shouldn't have been + res.status(403).json({ + status: "error", + error: "illegal identity" + }); + req.app.locals.session.delete(token); + req.app.locals.cooldown(req, 3e5); + return; + } + + // invalidate any active session + for (const [key, sess] of req.app.locals.session) { + if (sess.vault === session.vault && key !== token) { + console.log(`Vault.session: invalidating token ${key}`); + req.app.locals.session.delete(key); + } + } + console.info(`Vault.session: accepted login {${session.vault}} [${req.ip}]`); + } + + req.app.locals.cooldown(req, 6e4); + + // authenticate this session + session.authenticated = true; + + req.app.locals.vault.login_log.create({ + userId: session.vault, + action: "LOGIN", + ip: req.app.locals.sequelize.vault.fn("INET6_ATON", req.ip), + }); + + if (session.identity !== session.primaryIdentity) { + // user did not log in with their primary identity + // TODO: allow to block logging in with non-primary identities + const primary = await req.app.locals.vault.identity.findByPk(session.primaryIdentity); + transporter.sendMail({ + from: process.env.VAULT__MAILER__FROM, + to: primary.email, + subject: "The Mana World security notice", + text: "Someone has logged in to your Vault account using an email address that " + + "is not your primary address. If this wasn't you, please contact us immediately.\n\n" + + "To stop receiving login notices, use your primary email address when logging in." + }, (err, info) => {}); + } + + // TODO: already cache the identities and accounts in the session + + res.status(200).json({ + status: "success", + session: { + expires: session.expires, + identity: session.identity, + } + }); +}; + +const new_session = async (req, res, next) => { + if (!req.body || !Reflect.has(req.body, "email") || + !req.body.email.match(/^(?:[a-zA-Z0-9.$&+=_~-]{1,255}@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,255}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,255}[a-zA-Z0-9])?){1,9})$/) || + req.body.email.length >= 320) { + res.status(400).json({ + status: "error", + error: "invalid email address", + }); + req.app.locals.cooldown(req, 1e3); + return; + } + + const identity = await req.app.locals.vault.identity.findOne({where: {email: req.body.email}}); + + if (identity === null) { + // never logged in with this email address + + if (Reflect.has(req.body, "confirm") && req.body.confirm === true) { + // account creation request + const uuid = uuidv4(); + const session = new Session(req.ip, req.body.email); + req.app.locals.session.set(uuid, session); + + console.log(`Vault.session: starting account creation process [${req.ip}]`); + + if (process.env.NODE_ENV === "development") { + console.log(`uuid: ${uuid}`); + } else { + transporter.sendMail({ + from: process.env.VAULT__MAILER__FROM, + to: req.body.email, + subject: "The Mana World account creation", + text: "You are receiving this email because someone (you?) has requested to link your email address "+ + "to a new TMW Vault account.\nIf you did not initiate this process, please ignore this email.\n\n"+ + "To confirm, use this link:\n" + `${process.env.VAULT__URL__AUTH}${uuid}` + }, (err, info) => {}); + } + + res.status(200).json({ + status: "success" + }); + req.app.locals.cooldown(req, 6e4); + return; + } else { + res.status(202).json({ + status: "pending", + }); + req.app.locals.cooldown(req, 1e3); + return; + } + } else { + const account = await req.app.locals.vault.login.findOne({where: {id: identity.userId}}); + if (account === null) { + // unexpected: the account was deleted but not its identities + await req.app.locals.vault.identity.destroy({where: {email: req.body.email}}); + res.status(409).json({ + status: "error", + error: "data conflict", + }); + req.app.locals.cooldown(req, 3e5); + return; + } else { + // auth flow + if (identity.id !== account.primaryIdentity && !account.allowNonPrimary) { + res.status(423).json({ + status: "error", + error: "non-primary login is disabled", + }); + req.app.locals.cooldown(5e3); + return; + } + + // TODO: if account has WebAuthn do WebAuthn authentication flow + + const uuid = uuidv4(); + const session = new Session(req.ip, req.body.email); + session.vault = account.id; + session.primaryIdentity = account.primaryIdentity; + session.allowNonPrimary = account.allowNonPrimary; + session.identity = identity.id; + req.app.locals.session.set(uuid, session); + + console.log(`Vault.session: starting authentication with identity ${identity.id} [${req.ip}]`); + + if (process.env.NODE_ENV === "development") { + console.log(`uuid: ${uuid}`); + } else { + transporter.sendMail({ + from: process.env.VAULT__MAILER__FROM, + to: req.body.email, + subject: "TMW Vault login", + text: `Here is your login link:\n${process.env.VAULT__URL__AUTH}${uuid}\n\n` + + "TMW staff members will never ask for your login link. Please do not " + + "share it with anyone." + }, (err, info) => {}); + } + + res.status(200).json({ + status: "success" + }); + req.app.locals.cooldown(req, 6e4); + } + } +}; + +module.exports = exports = async (req, res, next) => { + switch(req.method) { + case "GET": + // authenticate a session + return await auth_session(req, res, next); + case "PUT": + // request a new session + return await new_session(req, res, next); + case "DELETE": + // explicit log out + return await delete_session(req, res, next); + default: + next(); // fallthrough to default endpoint (404) + } +}; diff --git a/src/routers/vault/models/evol/char.js b/src/routers/vault/models/evol/char.js new file mode 100644 index 0000000..b905fde --- /dev/null +++ b/src/routers/vault/models/evol/char.js @@ -0,0 +1,364 @@ +const Sequelize = require("sequelize"); // from npm registry + +module.exports = { + fields: { + charId: { + type: Sequelize.INTEGER.UNSIGNED, + primaryKey: true, + allowNull: false, + autoIncrement: true, + }, + accountId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + charNum: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + name: { // char name + type: Sequelize.STRING(30), + allowNull: false, + defaultValue: "", + }, + class: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + baseLevel: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 1, + }, + jobLevel: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 1, + }, + baseExp: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + jobExp: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + zeny: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + str: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 1, + }, + agi: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 1, + }, + vit: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 1, + }, + int: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 1, + }, + dex: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 1, + }, + luk: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 1, + }, + maxHp: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + hp: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 1, + }, + maxSp: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + sp: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + statusPoint: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 48, + }, + skillPoint: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + option: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + karma: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + manner: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + partyId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + guildId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + clanId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + petId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + homunId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + elementalId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + hair: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 1, + }, + hairColor: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + clothesColor: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + body: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + weapon: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + shield: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + headTop: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + headMid: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + headBottom: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + robe: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + lastLogin: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + lastMap: { + type: Sequelize.STRING(11), + allowNull: false, + defaultValue: "000-0", + }, + lastX: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 22, + }, + lastY: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 24, + }, + saveMap: { + type: Sequelize.STRING(11), + allowNull: false, + defaultValue: "000-0", + }, + saveX: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 22, + }, + saveY: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 24, + }, + partnerId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + online: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + father: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + mother: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + child: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + fame: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + rename: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + deleteDate: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + slotchange: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + charOpt: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + font: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + unbanTime: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + uniqueitemCounter: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + sex: { + type: Sequelize.ENUM("M", "F", "U"), + allowNull: false, + defaultValue: "U", + }, + hotkeyRowshift: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + hotkeyRowshift2: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + attendanceTimer: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + titleId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + inventorySize: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 100, + }, + }, + options: { + engine: "InnoDB", + initialAutoIncrement: 150000, + indexes: [ + { + fields: ["name"], + unique: true, + }, + { + fields: ["account_id"], + }, + { + fields: ["party_id"], + }, + { + fields: ["guild_id"], + }, + { + fields: ["online"], + }, + ] + } +}; diff --git a/src/routers/vault/models/evol/char_reservation.js b/src/routers/vault/models/evol/char_reservation.js new file mode 100644 index 0000000..f22c960 --- /dev/null +++ b/src/routers/vault/models/evol/char_reservation.js @@ -0,0 +1,14 @@ +const Sequelize = require("sequelize"); // from npm registry + +module.exports = { + fields: { + name: { // char name + type: Sequelize.STRING(30), + primaryKey: true, + allowNull: false, + }, + }, + options: { + engine: "InnoDB", + } +}; diff --git a/src/routers/vault/models/evol/login.js b/src/routers/vault/models/evol/login.js new file mode 100644 index 0000000..be37f07 --- /dev/null +++ b/src/routers/vault/models/evol/login.js @@ -0,0 +1,94 @@ +const Sequelize = require("sequelize"); // from npm registry + +module.exports = { + fields: { + accountId: { + type: Sequelize.INTEGER.UNSIGNED, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + userid: { // username + type: Sequelize.STRING(23), + allowNull: false, + defaultValue: "", + }, + userPass: { // plaintext + type: Sequelize.STRING(32), + allowNull: false, + defaultValue: "", + }, + sex: { // NOTE: we must exclude S + type: Sequelize.ENUM("M", "F", "S"), // TODO: add N when evol-hercules supports it + allowNull: false, + defaultValue: "M", // TODO: change to N + }, + email: { // limited email length + type: Sequelize.STRING(39), + allowNull: false, + defaultValue: "", + }, + groupId: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + state: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + unbanTime: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + expirationTime: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + logincount: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + lastlogin: { + type: Sequelize.DATE, + allowNull: true, + }, + lastIp: { + type: Sequelize.STRING(100), + allowNull: false, + defaultValue: "", + }, + birthdate: { + type: Sequelize.DATE, + allowNull: true, + }, + characterSlots: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, // 0 means MAX_CHARS(12) + }, + pincode: { + type: Sequelize.STRING(4), // TODO: use this for TOTP + allowNull: false, + defaultValue: "", + }, + pincodeChange: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + }, + options: { + engine: "InnoDB", + initialAutoIncrement: 2000000, + indexes: [ + { + fields: ["userid"], + } + ] + } +}; diff --git a/src/routers/vault/models/legacy/char.js b/src/routers/vault/models/legacy/char.js new file mode 100644 index 0000000..a13c977 --- /dev/null +++ b/src/routers/vault/models/legacy/char.js @@ -0,0 +1,143 @@ +const Sequelize = require("sequelize"); // from npm registry + +module.exports = { + fields: { + charId: { + type: Sequelize.INTEGER.UNSIGNED, + primaryKey: true, + allowNull: false, + }, + revoltId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: true, + }, + accountId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + name: { // char name + type: Sequelize.STRING(30), + allowNull: false, + defaultValue: "", + }, + class: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + baseLevel: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 1, + }, + jobLevel: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 1, + }, + baseExp: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + jobExp: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + zeny: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + str: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + agi: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + vit: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + int: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + dex: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + luk: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + statusPoint: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + skillPoint: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + partyId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + partyIsleader: { // single BIT + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: 0, + }, + hair: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + hairColor: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + partnerId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + sex: { // NOTE: we must exclude S + type: Sequelize.ENUM("M", "F", "N", "S"), + allowNull: false, + defaultValue: "N", + }, + }, + options: { + indexes: [ + { + fields: ["revolt_id"], + unique: true, + }, + { + fields: ["name"], + unique: true, + }, + { + fields: ["account_id"], + }, + { + fields: ["party_id"], + } + ] + } +}; diff --git a/src/routers/vault/models/legacy/login.js b/src/routers/vault/models/legacy/login.js new file mode 100644 index 0000000..12e17b9 --- /dev/null +++ b/src/routers/vault/models/legacy/login.js @@ -0,0 +1,64 @@ +const Sequelize = require("sequelize"); // from npm registry + +module.exports = { + fields: { + accountId: { + type: Sequelize.INTEGER.UNSIGNED, + primaryKey: true, + allowNull: false, + }, + revoltId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: true, + }, + userid: { // username + type: Sequelize.STRING(23), + allowNull: false, + defaultValue: "", + }, + userPass: { // weak athena hashing + type: Sequelize.STRING(32), + allowNull: false, + defaultValue: "", + }, + lastlogin: { + type: Sequelize.DATE, + allowNull: true, + }, + logincount: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + state: { // ideally this should've been an enum, but whatever + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + email: { // tmwa has a very limited email length + type: Sequelize.STRING(39), + allowNull: true, + }, + lastIp: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + unbanTime: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }, + }, + options: { + indexes: [ + { + fields: ["revolt_id"], + unique: true, + }, + { + fields: ["userid"], + } + ] + } +}; diff --git a/src/routers/vault/models/vault/account_log.js b/src/routers/vault/models/vault/account_log.js new file mode 100644 index 0000000..1f8b05b --- /dev/null +++ b/src/routers/vault/models/vault/account_log.js @@ -0,0 +1,49 @@ +const Sequelize = require("sequelize"); // from npm registry + +module.exports = { + fields: { + id: { + type: Sequelize.INTEGER.UNSIGNED, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + vaultId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + }, + accountType: { + type: Sequelize.ENUM("EVOL", "LEGACY", "FORUMS", "WIKI"), + allowNull: false, + }, + accountId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + }, + actionType: { + type: Sequelize.ENUM("CREATE", "DELETE", "LINK", "UNLINK", "UPDATE"), + allowNull: false, + defaultValue: "CREATE", + }, + details: { + type: Sequelize.STRING, + allowNull: true, + }, + ip: { + type: "VARBINARY(16)", + allowNull: false, + }, + date: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }, + options: { + indexes: [ + { fields: ["vault_id"] }, + { fields: ["account_id"] }, + { fields: ["ip"] }, + ] + } +}; diff --git a/src/routers/vault/models/vault/claimed_game_accounts.js b/src/routers/vault/models/vault/claimed_game_accounts.js new file mode 100644 index 0000000..743376d --- /dev/null +++ b/src/routers/vault/models/vault/claimed_game_accounts.js @@ -0,0 +1,22 @@ +const Sequelize = require("sequelize"); // from npm registry + +module.exports = { + fields: { + accountId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + primaryKey: true, + }, + vaultId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + }, + }, + options: { + indexes: [ + { + fields: ["vault_id"], // BUG: {underscored: true} does not work on indexes + }, + ] + } +}; diff --git a/src/routers/vault/models/vault/claimed_legacy_accounts.js b/src/routers/vault/models/vault/claimed_legacy_accounts.js new file mode 100644 index 0000000..743376d --- /dev/null +++ b/src/routers/vault/models/vault/claimed_legacy_accounts.js @@ -0,0 +1,22 @@ +const Sequelize = require("sequelize"); // from npm registry + +module.exports = { + fields: { + accountId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + primaryKey: true, + }, + vaultId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + }, + }, + options: { + indexes: [ + { + fields: ["vault_id"], // BUG: {underscored: true} does not work on indexes + }, + ] + } +}; diff --git a/src/routers/vault/models/vault/identity.js b/src/routers/vault/models/vault/identity.js new file mode 100644 index 0000000..e10970f --- /dev/null +++ b/src/routers/vault/models/vault/identity.js @@ -0,0 +1,36 @@ +const Sequelize = require("sequelize"); // from npm registry + +module.exports = { + fields: { + id: { + type: Sequelize.INTEGER.UNSIGNED, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + userId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + }, + email: { + type: Sequelize.STRING(320), + allowNull: false, + }, + addedDate: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }, + options: { + indexes: [ + { + fields: ["user_id"], // BUG: table option {underscored: true} does not work on indexes + }, + { + fields: ["email"], + unique: true, + } + ] + } +}; diff --git a/src/routers/vault/models/vault/identity_log.js b/src/routers/vault/models/vault/identity_log.js new file mode 100644 index 0000000..4e881f1 --- /dev/null +++ b/src/routers/vault/models/vault/identity_log.js @@ -0,0 +1,47 @@ +const Sequelize = require("sequelize"); // from npm registry + +module.exports = { + fields: { + id: { + type: Sequelize.INTEGER.UNSIGNED, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + userId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + }, + identityId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + }, + action: { + type: Sequelize.ENUM("ADD", "REMOVE"), + allowNull: false, + defaultValue: "ADD", + }, + ip: { + type: "VARBINARY(16)", + allowNull: false, + }, + date: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }, + options: { + indexes: [ + { + fields: ["user_id"], // BUG: {underscored: true} does not work on indexes + }, + { + fields: ["identity_id"], // BUG: {underscored: true} does not work on indexes + }, + { + fields: ["ip"], + } + ] + } +}; diff --git a/src/routers/vault/models/vault/login.js b/src/routers/vault/models/vault/login.js new file mode 100644 index 0000000..1c9c51e --- /dev/null +++ b/src/routers/vault/models/vault/login.js @@ -0,0 +1,31 @@ +const Sequelize = require("sequelize"); // from npm registry + +module.exports = { + fields: { + id: { + type: Sequelize.INTEGER.UNSIGNED, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + primaryIdentity: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: true, + }, + allowNonPrimary: { + type: Sequelize.BOOLEAN, + defaultValue: true, + allowNull: false, + }, + creationDate: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + state: { + type: Sequelize.ENUM("OK", "BANNED"), + allowNull: false, + defaultValue: "OK", + }, + } +}; diff --git a/src/routers/vault/models/vault/login_log.js b/src/routers/vault/models/vault/login_log.js new file mode 100644 index 0000000..5f42469 --- /dev/null +++ b/src/routers/vault/models/vault/login_log.js @@ -0,0 +1,45 @@ +const Sequelize = require("sequelize"); // from npm registry + +// NOTE: to get the ip, use something like +// select *, INET6_NTOA(ip) as ip_ from vault.login_log; +// and to search by ip use something like +// select * from vault.login_log where ip = INET6_ATON("ip addr"); + +module.exports = { + fields: { + id: { + type: Sequelize.INTEGER.UNSIGNED, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + userId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + }, + action: { + type: Sequelize.ENUM("LOGIN", "LOGOUT", "CREATE"), + allowNull: false, + defaultValue: "LOGIN", + }, + ip: { + type: "VARBINARY(16)", + allowNull: false, + }, + date: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }, + options: { + indexes: [ + { + fields: ["user_id"], // BUG: {underscored: true} does not work on indexes + }, + { + fields: ["ip"], + } + ] + } +}; diff --git a/src/routers/vault/models/vault/migration_log.js b/src/routers/vault/models/vault/migration_log.js new file mode 100644 index 0000000..5b2e651 --- /dev/null +++ b/src/routers/vault/models/vault/migration_log.js @@ -0,0 +1,35 @@ +const Sequelize = require("sequelize"); // from npm registry + +module.exports = { + fields: { + legacyId: { + type: Sequelize.INTEGER.UNSIGNED, + primaryKey: true, + allowNull: false, + }, + accountId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + }, + vaultId: { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + }, + ip: { + type: "VARBINARY(16)", + allowNull: false, + }, + date: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }, + options: { + indexes: [ + { fields: ["vault_id"] }, + { fields: ["account_id"] }, + { fields: ["ip"] }, + ] + } +}; diff --git a/src/routers/vault/types/Session.js b/src/routers/vault/types/Session.js new file mode 100644 index 0000000..34bd250 --- /dev/null +++ b/src/routers/vault/types/Session.js @@ -0,0 +1,21 @@ +/** + * holds a cache of all the user data fetched from SQL + */ +module.exports = class Session { + expires = new Date(); // expiry Date + vault = null; // Vault account id + authenticated = false; // whether the user logged in + identity = null; // the identity that was used to log in + email; // the email address of the identity that was used to log in + identities = []; // cache holding all identities + primaryIdentity = null; // the main identity of the account + allowNonPrimary = true; // whether to allow logging in with a non-primary ident + legacyAccounts = []; // cache holding all legacy game accounts + gameAccounts = []; // cache holding all evol game accounts + ip; // ip that was used to init the session + + constructor (ip, email) { + this.ip = ip; + this.email = email; + } +} diff --git a/src/routers/vault/utils/claim.js b/src/routers/vault/utils/claim.js new file mode 100644 index 0000000..472e05a --- /dev/null +++ b/src/routers/vault/utils/claim.js @@ -0,0 +1,83 @@ +const { Op } = require("sequelize"); + +// claim by email // TODO: DRY this +const claim_accounts = async (req, email, vault_id, session = null) => { + const locals = req.app.locals; + + if (email === null || email.length < 5 || email === "a@a.com") + return Promise.resolve(false); + + if (session === null) { + for (const [key, sess] of locals.session) { + // try to find the session + if (sess.authenticated === true && sess.vault === vault_id) { + session = sess; + } + } + } + + // TODO: make these operations less expensive (foreign keys could help) + let already_claimed = await locals.vault.claimed_legacy_accounts.findAll({ + where: {vaultId: vault_id}, + }); + + already_claimed = already_claimed.map(acc => { + return {accountId: { + [Op.not]: acc.accountId, // NOTE: if query is larger than 65535 this will throw + }}; + }); + + const to_claim = await locals.legacy.login.findAll({ + where: { + email: email, + [Op.and]: already_claimed, + }, + }); + + for (const acc of to_claim) { + await locals.vault.claimed_legacy_accounts.create({ + accountId: acc.accountId, + vaultId: vault_id, + }); + + req.app.locals.vault.account_log.create({ + vaultId: vault_id, + accountType: "LEGACY", + actionType: "LINK", + accountId: acc.accountId, + ip: req.app.locals.sequelize.vault.fn("INET6_ATON", req.ip), + }); + + if (session !== null) { + const chars = []; + const chars_ = await locals.legacy.char.findAll({ + where: {accountId: acc.accountId}, + }); + + for (const char of chars_) { + chars.push({ + name: char.name, + charId: char.charId, + revoltId: char.revoltId, + level: char.baseLevel, + sex: char.sex, + }); + } + // add to session cache + session.legacyAccounts.push({ + name: acc.userid, + accountId: acc.accountId, + revoltId: acc.revoltId, + chars, + }); + } + + locals.logger.info(`Vault: linked Legacy account ${acc.accountId} to Vault account {${vault_id}} [${req.ip}]`); + } + + // TODO: split TMWA claiming into its own function, add forums and wiki claiming +}; + +module.exports = { + claim_accounts, +}; diff --git a/src/routers/vault/utils/ephemeral.js b/src/routers/vault/utils/ephemeral.js new file mode 100644 index 0000000..211e84b --- /dev/null +++ b/src/routers/vault/utils/ephemeral.js @@ -0,0 +1,87 @@ +// TODO: use Redis for in-memory caching of sessions + +// this was originally a Proxy> but was replaced because of a nodejs bug +// XXX: maybe we should use an already-existing Express session manager // NIH syndrome +const timeout_symbol = Symbol("timeout"); +const hydrate_symbol = Symbol("hydrate"); +const container_symbol = Symbol("container"); +const session_handler = { + [container_symbol]: new Map(), + [hydrate_symbol] (key, obj) { + if (obj === null || obj === undefined) + return obj; + + if (Reflect.has(obj, timeout_symbol)) + clearTimeout(obj[timeout_symbol]); + + let expires = new Date(); + expires.setUTCHours(expires.getUTCHours() + 6); + obj.expires = expires // this could also be a symbol + obj[timeout_symbol] = setTimeout(() => session_handler.delete(key), 6 * 3600000); // 6 hours + + return obj; + }, + has (key) { + return session_handler[container_symbol].has(key); + }, + get (key) { + return session_handler[hydrate_symbol](key, session_handler[container_symbol].get(key)); + }, + set (key, obj) { + return session_handler[container_symbol].set(key, session_handler[hydrate_symbol](key, obj)); + }, + delete (key) { + if (session_handler[container_symbol].get(key) && session_handler[container_symbol].get(key)[timeout_symbol]) + clearTimeout(session_handler[container_symbol].get(key)[timeout_symbol]); + return session_handler[container_symbol].delete(key); + }, + [Symbol.iterator]: function* () { + for (const [key, obj] of session_handler[container_symbol]) { + yield [key, obj]; + } + }, +}; + +// TODO: DRY this shit +const identity_handler = { + [container_symbol]: new Map(), + [hydrate_symbol] (key, obj) { + if (obj === null || obj === undefined) + return obj; + + if (Reflect.has(obj, timeout_symbol)) + clearTimeout(obj[timeout_symbol]); + + let expires = new Date(); + expires.setUTCMinutes(expires.getUTCMinutes() + 30); + obj.expires = expires // this could also be a symbol + obj[timeout_symbol] = setTimeout(() => identity_handler.delete(key), 30 * 60000); // 30 minutes + + return obj; + }, + has (key) { + return identity_handler[container_symbol].has(key); + }, + get (key) { + return identity_handler[container_symbol].get(key); + }, + set (key, obj) { + return identity_handler[container_symbol].set(key, identity_handler[hydrate_symbol](key, obj)); + }, + delete (key) { + if (identity_handler[container_symbol].get(key) && identity_handler[container_symbol].get(key)[timeout_symbol]) + clearTimeout(identity_handler[container_symbol].get(key)[timeout_symbol]); + return identity_handler[container_symbol].delete(key); + }, + [Symbol.iterator]: function* () { + for (const [key, obj] of identity_handler[container_symbol]) { + yield [key, obj]; + } + }, +}; + + +module.exports = { + session_handler, + identity_handler, +} diff --git a/src/routers/vault/utils/flatfile.js b/src/routers/vault/utils/flatfile.js new file mode 100644 index 0000000..e9d6fee --- /dev/null +++ b/src/routers/vault/utils/flatfile.js @@ -0,0 +1,31 @@ +const execFile = require("child_process").execFile; +const ripgrep = require("ripgrep-bin"); + +const execAsync = (cmd, par) => + new Promise((resolve, reject) => + execFile(cmd, par, (error, stdout, stderr) => + resolve(error ? "" : (stdout ? stdout : stderr)))); + +const tmwa_account_regex = new RegExp("^(?[0-9]+)\t(?[^\t]+)\t(?[^\t]+)\t"); + +const parseAccountLine = (line) => { + const { groups: account } = tmwa_account_regex.exec(line); + return { + id: +account.id, + name: account.name, + password: account.password, + }; +} + +const findAccount = async (account_id, name) => { + const regex = `^${account_id}\t${name}\t`; + const stdout = await execAsync(ripgrep, ["--case-sensitive", `--max-count=1`, regex, "account.txt"]); + let account = null; + if (stdout.length) + account = parseAccountLine(stdout.slice(0, -1).split("\n")[0]); + return account; +}; + +module.exports = { + findAccount, +}; diff --git a/src/routers/vault/utils/md5saltcrypt.js b/src/routers/vault/utils/md5saltcrypt.js new file mode 100644 index 0000000..55933f5 --- /dev/null +++ b/src/routers/vault/utils/md5saltcrypt.js @@ -0,0 +1,38 @@ +// password hashing for the Legacy server +// https://gitlab.com/evol/evol-hercules/blob/master/src/elogin/md5calc.c +// https://github.com/themanaworld/tmwa/blob/c82c9741bc1a0b110bccce1bcc76903a6e747a00/src/high/md5more.cpp + +const crypto = require("crypto"); // native + +// generate md5 from string +const md5 = (str) => crypto.createHash("md5").update(str).digest("hex"); + +// weak md5 password hashing and salting (eAthena) +const md5saltcrypt = (salt, plain) => md5(md5(plain) + md5(salt)).slice(0, -8); + +// check plain password against its salted hash +const verify = (salt, hashed, plain) => md5saltcrypt(salt, plain) === hashed; + +// takes apart a password string (!salt$hash) and verifies it +const verify_ea = (raw, plain) => verify(raw.slice(1, 6), raw.slice(-24), plain); + +// generate a new salt +const new_salt = () => { + let salt = ""; + do { + salt += String.fromCharCode(Math.floor(78 * Math.random() + 48)); + } while (salt.length < 5); + return salt; +}; + +// generate a password string with the given salt +const hash = (salt, plain) => `!${salt}$${md5saltcrypt(salt, plain)}`; + +// generate a password string with a new salt +const hash_new = (plain) => hash(new_salt(), plain); + + +module.exports = { + verify: verify_ea, + hash: hash_new, +}; -- cgit v1.2.3-60-g2f50