summaryrefslogtreecommitdiff
path: root/src/routers/vault
diff options
context:
space:
mode:
Diffstat (limited to 'src/routers/vault')
-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
24 files changed, 2833 insertions, 0 deletions
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,
+};