diff options
author | gumi <git@gumi.ca> | 2020-02-14 12:18:00 -0500 |
---|---|---|
committer | gumi <git@gumi.ca> | 2020-03-02 15:37:17 -0500 |
commit | 2c25f53ddf418bdedd94c6142b03c80e49fc584d (patch) | |
tree | c15c45c16b7b649fb267241ffe0fe90aacf6fae5 /src/routers/vault/middlewares | |
parent | 872288426090839f2f23e60187a58ee51d6fe4ea (diff) | |
download | api-2c25f53ddf418bdedd94c6142b03c80e49fc584d.tar.gz api-2c25f53ddf418bdedd94c6142b03c80e49fc584d.tar.bz2 api-2c25f53ddf418bdedd94c6142b03c80e49fc584d.tar.xz api-2c25f53ddf418bdedd94c6142b03c80e49fc584d.zip |
add support for Vault + major refactor
Diffstat (limited to 'src/routers/vault/middlewares')
-rw-r--r-- | src/routers/vault/middlewares/account.js | 161 | ||||
-rw-r--r-- | src/routers/vault/middlewares/evol/account.js | 326 | ||||
-rw-r--r-- | src/routers/vault/middlewares/identity.js | 255 | ||||
-rw-r--r-- | src/routers/vault/middlewares/legacy/account.js | 437 | ||||
-rw-r--r-- | src/routers/vault/middlewares/session.js | 313 |
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) + } +}; |