summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorgumi <git@gumi.ca>2020-02-14 12:18:00 -0500
committergumi <git@gumi.ca>2020-03-02 15:37:17 -0500
commit2c25f53ddf418bdedd94c6142b03c80e49fc584d (patch)
treec15c45c16b7b649fb267241ffe0fe90aacf6fae5
parent872288426090839f2f23e60187a58ee51d6fe4ea (diff)
downloadapi-2c25f53ddf418bdedd94c6142b03c80e49fc584d.tar.gz
api-2c25f53ddf418bdedd94c6142b03c80e49fc584d.tar.bz2
api-2c25f53ddf418bdedd94c6142b03c80e49fc584d.tar.xz
api-2c25f53ddf418bdedd94c6142b03c80e49fc584d.zip
add support for Vault + major refactor
-rw-r--r--.env42
-rw-r--r--.env.development0
-rw-r--r--.env.production0
-rw-r--r--.gitignore4
-rw-r--r--package.json35
-rw-r--r--src/api.js111
-rw-r--r--src/limiter.js68
-rw-r--r--src/logger.js45
-rw-r--r--src/routers/tmwa/index.js4
-rw-r--r--src/routers/tmwa/middlewares/account.js54
-rw-r--r--src/routers/tmwa/middlewares/server.js1
-rw-r--r--src/routers/vault/index.js115
-rw-r--r--src/routers/vault/middlewares/account.js161
-rw-r--r--src/routers/vault/middlewares/evol/account.js326
-rw-r--r--src/routers/vault/middlewares/identity.js255
-rw-r--r--src/routers/vault/middlewares/legacy/account.js437
-rw-r--r--src/routers/vault/middlewares/session.js313
-rw-r--r--src/routers/vault/models/evol/char.js364
-rw-r--r--src/routers/vault/models/evol/char_reservation.js14
-rw-r--r--src/routers/vault/models/evol/login.js94
-rw-r--r--src/routers/vault/models/legacy/char.js143
-rw-r--r--src/routers/vault/models/legacy/login.js64
-rw-r--r--src/routers/vault/models/vault/account_log.js49
-rw-r--r--src/routers/vault/models/vault/claimed_game_accounts.js22
-rw-r--r--src/routers/vault/models/vault/claimed_legacy_accounts.js22
-rw-r--r--src/routers/vault/models/vault/identity.js36
-rw-r--r--src/routers/vault/models/vault/identity_log.js47
-rw-r--r--src/routers/vault/models/vault/login.js31
-rw-r--r--src/routers/vault/models/vault/login_log.js45
-rw-r--r--src/routers/vault/models/vault/migration_log.js35
-rw-r--r--src/routers/vault/types/Session.js21
-rw-r--r--src/routers/vault/utils/claim.js83
-rw-r--r--src/routers/vault/utils/ephemeral.js87
-rw-r--r--src/routers/vault/utils/flatfile.js31
-rw-r--r--src/routers/vault/utils/md5saltcrypt.js38
35 files changed, 3084 insertions, 113 deletions
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 <noreply@themanaworld.org>"
+
+# [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 <vault@themanaworld.org>"
+# [[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
--- /dev/null
+++ b/.env.development
diff --git a/.env.production b/.env.production
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/.env.production
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 <noreply@themanaworld.org>"
- },
- "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<route, Map<ip, {Timeout}>>
+const bad_actors = new Map(); // Map<ip, badness>
+
+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<Map<String, Session>> 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("^(?<id>[0-9]+)\t(?<name>[^\t]+)\t(?<password>[^\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,
+};