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