From 267b900a82d7870e4bcd56857887fd3f697e6e45 Mon Sep 17 00:00:00 2001 From: gumi Date: Wed, 11 Mar 2020 18:14:03 -0400 Subject: refactor the authentication and validation --- src/routers/vault/middlewares/account.js | 116 +++----------- src/routers/vault/middlewares/evol/account.js | 185 ++++----------------- src/routers/vault/middlewares/identity.js | 116 +++----------- src/routers/vault/middlewares/legacy/account.js | 184 ++++----------------- src/routers/vault/middlewares/session.js | 92 ++++------- src/routers/vault/types/Session.js | 5 + src/routers/vault/utils/validate.js | 204 ++++++++++++++++++++++++ 7 files changed, 361 insertions(+), 541 deletions(-) create mode 100644 src/routers/vault/utils/validate.js diff --git a/src/routers/vault/middlewares/account.js b/src/routers/vault/middlewares/account.js index 42a63a4..3c1cf52 100644 --- a/src/routers/vault/middlewares/account.js +++ b/src/routers/vault/middlewares/account.js @@ -1,42 +1,16 @@ "use strict"; +const validate = require("../utils/validate.js"); 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") || ""); + let 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; - } + try { + [, session] = validate.get_session(req, res); + } catch { return } // already handled res.status(200).json({ status: "success", @@ -45,6 +19,7 @@ const get_data = async (req, res, next) => { primaryIdentity: session.primaryIdentity, allowNonPrimary: session.allowNonPrimary, strictIPCheck: session.strictIPCheck, + requireSecret: true, vaultId: session.vault, }, }); @@ -52,68 +27,26 @@ const get_data = async (req, res, next) => { }; const update_account = async (req, res, next) => { - const token = String(req.get("X-VAULT-SESSION") || ""); + let 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; - } + try { + [, session] = validate.get_session(req, res); + } catch { return } // already handled - if (!req.body || !Reflect.has(req.body, "primary") || !Reflect.has(req.body, "allow") || - !Reflect.has(req.body, "strict") || !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; - } - - if (session.strictIPCheck && session.ip !== req.ip) { - // the ip is not the same - res.status(401).json({ - status: "error", - error: "ip address mismatch", - }); - req.app.locals.logger.warn(`Vault.account: ip address mismatch <${session.vault}@vault> [${req.ip}]`); - req.app.locals.cooldown(req, 3e5); - return; - } + const data = { + primary: +validate.get_prop(req, "primary"), + allow: !!validate.get_prop(req, "allow"), + strict: !!validate.get_prop(req, "strict"), + }; const update_fields = {}; - if (session.primaryIdentity !== req.body.primary) { + if (session.primaryIdentity !== data.primary) { // update primary identity let new_primary = null; for (const ident of session.identities) { - if (ident.id === req.body.primary) { + if (ident.id === data.primary) { new_primary = ident.id; break; } @@ -124,19 +57,18 @@ const update_account = async (req, res, next) => { 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) { + if (session.allowNonPrimary !== data.allow) { // update allow non-primary - update_fields.allowNonPrimary = !!req.body.allow; + update_fields.allowNonPrimary = data.allow; } - if (session.strictIPCheck !== !!req.body.strict) { + if (session.strictIPCheck !== data.strict) { // update allow non-primary - update_fields.strictIPCheck = !!req.body.strict; + update_fields.strictIPCheck = data.strict; } // update SQL @@ -147,9 +79,9 @@ const update_account = async (req, res, next) => { } // now update our cache - session.allowNonPrimary = !!req.body.allow; - session.strictIPCheck = !!req.body.strict; - session.primaryIdentity = +req.body.primary; + session.allowNonPrimary = data.allow; + session.strictIPCheck = data.strict; + session.primaryIdentity = data.primary; for (const ident of session.identities) { if (ident.id === session.primaryIdentity) { diff --git a/src/routers/vault/middlewares/evol/account.js b/src/routers/vault/middlewares/evol/account.js index 3a22158..b89d872 100644 --- a/src/routers/vault/middlewares/evol/account.js +++ b/src/routers/vault/middlewares/evol/account.js @@ -1,57 +1,13 @@ "use strict"; const EvolAccount = require("../../types/EvolAccount.js"); - -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 validate = require("../../utils/validate.js"); 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 session; - if (session.strictIPCheck && session.ip !== req.ip) { - // the ip is not the same - res.status(403).json({ - status: "error", - error: "ip address mismatch", - }); - req.app.locals.logger.warn(`Vault.evol.account: ip address mismatch <${session.vault}@vault> [${req.ip}]`); - req.app.locals.cooldown(req, 3e5); - return; - } + try { + [, session] = validate.get_session(req, res); + } catch { return } // already handled res.status(200).json({ status: "success", @@ -62,22 +18,18 @@ const get_accounts = async (req, res, next) => { }; const new_account = async (req, res, next) => { - const token = String(req.get("X-VAULT-SESSION") || ""); + let 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; - } + try { + [, session] = validate.get_session(req, res); + } catch { return } // already handled - 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) + const data = { + username: validate.get_prop(req, "username", validate.regexes.alnum23), + password: validate.get_prop(req, "password", validate.regexes.any30), + }; + + if (!data.username || !data.password) { res.status(400).json({ status: "error", error: "invalid format", @@ -86,41 +38,9 @@ const new_account = async (req, res, next) => { 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; - } - - if (session.strictIPCheck && session.ip !== req.ip) { - // the ip is not the same - res.status(403).json({ - status: "error", - error: "ip address mismatch", - }); - req.app.locals.logger.warn(`Vault.evol.account: ip address mismatch <${session.vault}@vault> [${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} + where: {userid: data.username} }); if (existing !== null) { @@ -133,8 +53,8 @@ const new_account = async (req, res, next) => { } const evol_acc = await req.app.locals.evol.login.create({ - userid: req.body.username, - userPass: req.body.password, + userid: data.username, + userPass: data.password, email: `${session.vault}@vault`, // setting an actual email is pointless }); @@ -167,22 +87,19 @@ const new_account = async (req, res, next) => { }; const update_account = async (req, res, next) => { - const token = String(req.get("X-VAULT-SESSION") || ""); + let 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; - } + try { + [, session] = validate.get_session(req, res); + } catch { return } // already handled + + const data = { + accountId: +validate.get_prop(req, "accountId", validate.regexes.gid), + username: validate.get_prop(req, "username", validate.regexes.alnum23), + password: validate.get_prop(req, "password", validate.regexes.any30), + }; - 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) + if (!data.username && !data.password) { res.status(400).json({ status: "error", error: "invalid format", @@ -191,41 +108,9 @@ const update_account = async (req, res, next) => { 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; - } - - if (session.strictIPCheck && session.ip !== req.ip) { - // the ip is not the same - res.status(403).json({ - status: "error", - error: "ip address mismatch", - }); - req.app.locals.logger.warn(`Vault.evol.account: ip address mismatch <${session.vault}@vault> [${req.ip}]`); - req.app.locals.cooldown(req, 3e5); - return; - } - let account = null; for (const acc of session.gameAccounts) { - if (acc.accountId === req.body.accountId) { + if (acc.accountId === data.accountId) { account = acc; break; } @@ -242,10 +127,10 @@ const update_account = async (req, res, next) => { } let update_fields = {}; - if (Reflect.has(req.body, "username")) { + if (data.username) { // check if the name exists const existing = await req.app.locals.evol.login.findOne({ - where: {userid: req.body.username} + where: {userid: data.username} }); if (existing !== null) { @@ -258,9 +143,9 @@ const update_account = async (req, res, next) => { } update_fields = { - userid: req.body.username, + userid: data.username, }; - account.name = req.body.username; + account.name = data.username; req.app.locals.logger.info(`Vault.evol.account: changed username of game account ${account.accountId} <${session.vault}@vault> [${req.ip}]`); req.app.locals.vault.account_log.create({ vaultId: session.vault, @@ -272,7 +157,7 @@ const update_account = async (req, res, next) => { }); } else { update_fields = { - userPass: req.body.password, + userPass: data.password, }; req.app.locals.logger.info(`Vault.evol.account: changed password of game account ${account.accountId} <${session.vault}@vault> [${req.ip}]`); req.app.locals.vault.account_log.create({ diff --git a/src/routers/vault/middlewares/identity.js b/src/routers/vault/middlewares/identity.js index 158e5df..f65d757 100644 --- a/src/routers/vault/middlewares/identity.js +++ b/src/routers/vault/middlewares/identity.js @@ -2,6 +2,7 @@ const uuidv4 = require("uuid/v4"); const nodemailer = require("nodemailer"); const Claim = require("../utils/claim.js"); +const validate = require("../utils/validate.js"); let transporter = nodemailer.createTransport({ sendmail: true, @@ -10,38 +11,11 @@ let transporter = nodemailer.createTransport({ }); 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; - } + let session; - 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; - } + try { + [, session] = validate.get_session(req, res); + } catch { return } // already handled if (session.identities.length === 0) { console.info(`Vault.identity: fetching identities <${session.vault}@vault> [${req.ip}]`); @@ -69,19 +43,19 @@ const get_identities = async (req, res, next) => { const add_identity = async (req, res, next) => { const token = String(req.get("X-VAULT-SESSION") || ""); - const validate = String(req.get("X-VAULT-TOKEN") || ""); + const secret = String(req.get("X-VAULT-TOKEN") || ""); - if (token === "" && validate !== "") { - if (!validate.match(/^[a-zA-Z0-9-_]{6,128}$/)) { + if (token === "" && secret !== "") { + if (!secret.match(validate.regexes.uuid)) { res.status(400).json({ status: "error", - error: "missing token", + error: "missing secret", }); req.app.locals.cooldown(req, 5e3); return; } - const ident = req.app.locals.identity_pending.get(validate); + const ident = req.app.locals.identity_pending.get(secret); if (ident === null || ident === undefined) { res.status(410).json({ @@ -128,7 +102,7 @@ const add_identity = async (req, res, next) => { } } - req.app.locals.identity_pending.delete(validate); + req.app.locals.identity_pending.delete(secret); if (session !== null) { console.info(`Vault.identity: added a new identity <${session.vault}@vault> [${req.ip}]`); @@ -145,50 +119,19 @@ const add_identity = async (req, res, next) => { // 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); + let session; - if (session === null || session === undefined) { - res.status(410).json({ - status: "error", - error: "session expired", - }); - req.app.locals.cooldown(req, 5e3); - return; - } + try { + [, session] = validate.get_session(req, res); + } catch { return } // already handled - 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; - } + let email; + try { + email = validate.get_email(req, res); + } catch { return } // already handled for (const [key, pending] of req.app.locals.identity_pending) { - if (pending.vault === session.vault && pending.email === req.body.email) { + if (pending.vault === session.vault && pending.email === email) { res.status(425).json({ status: "error", error: "already pending", @@ -199,7 +142,7 @@ const add_identity = async (req, res, next) => { } const find = await req.app.locals.vault.identity.findOne({ - where: {email: req.body.email} + where: {email} }); if (find !== null) { @@ -232,7 +175,7 @@ const add_identity = async (req, res, next) => { req.app.locals.identity_pending.set(uuid, { ip: req.ip, vault: session.vault, - email: req.body.email, + email: email, }); console.log(`Vault.session: starting identity validation <${session.vault}@vault> [${req.ip}]`); @@ -243,7 +186,7 @@ const add_identity = async (req, res, next) => { // TODO: limit total number of emails that can be dispatched by a single ip in an hour transporter.sendMail({ from: process.env.VAULT__MAILER__FROM, - to: req.body.email, + to: 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"+ @@ -258,14 +201,6 @@ const add_identity = async (req, res, next) => { req.app.locals.cooldown(req, 5e3); }; -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": @@ -274,11 +209,8 @@ module.exports = exports = async (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 + // TODO: 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 index 29da5a6..199ace3 100644 --- a/src/routers/vault/middlewares/legacy/account.js +++ b/src/routers/vault/middlewares/legacy/account.js @@ -5,59 +5,14 @@ const LegacyAccount = require("../../types/LegacyAccount.js"); const LegacyChar = require("../../types/LegacyChar.js"); const EvolAccount = require("../../types/EvolAccount.js"); const EvolChar = require("../../types/EvolChar.js"); - -const regexes = { - token: /^[a-zA-Z0-9-_]{6,128}$/, // UUID - any23: /^[^\s][^\t\r\n]{2,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 validate = require("../../utils/validate.js"); 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 session; - if (session.strictIPCheck && session.ip !== req.ip) { - // the ip is not the same - res.status(403).json({ - status: "error", - error: "ip address mismatch", - }); - req.app.locals.logger.warn(`Vault.legacy.account: ip address mismatch <${session.vault}@vault> [${req.ip}]`); - req.app.locals.cooldown(req, 3e5); - return; - } + try { + [, session] = validate.get_session(req, res); + } catch { return } // already handled res.status(200).json({ status: "success", @@ -68,21 +23,18 @@ const get_accounts = async (req, res, next) => { }; const claim_by_password = async (req, res, next) => { - const token = String(req.get("X-VAULT-SESSION") || ""); + let 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; - } + try { + [, session] = validate.get_session(req, res); + } catch { return } // already handled - 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)) { + const data = { + username: validate.get_prop(req, "username", validate.regexes.alnum23), + password: validate.get_prop(req, "password", validate.regexes.any23), + }; + + if (!data.username || !data.password) { res.status(400).json({ status: "error", error: "invalid format", @@ -91,40 +43,8 @@ const claim_by_password = async (req, res, next) => { 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; - } - - if (session.strictIPCheck && session.ip !== req.ip) { - // the ip is not the same - res.status(403).json({ - status: "error", - error: "ip address mismatch", - }); - req.app.locals.logger.warn(`Vault.legacy.account: ip address mismatch <${session.vault}@vault> [${req.ip}]`); - req.app.locals.cooldown(req, 3e5); - return; - } - const legacy = await req.app.locals.legacy.login.findOne({ - where: {userid: req.body.username} + where: {userid: data.username} }); if (legacy === null) { @@ -146,14 +66,14 @@ const claim_by_password = async (req, res, next) => { return; } - if (!md5saltcrypt.verify(legacy.userPass, req.body.password)) { + if (!md5saltcrypt.verify(legacy.userPass, data.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)) { + md5saltcrypt.verify(flatfile_account.password, data.password)) { // update the password in SQL (deferred) console.log(`Vault.legacy.account: updating SQL password from flatfile for account ${legacy.accountId}`); - legacy.userPass = md5saltcrypt.hash(req.body.password); + legacy.userPass = md5saltcrypt.hash(data.password); legacy.save(); } else { // the password is just plain wrong @@ -231,23 +151,19 @@ const claim_by_password = async (req, res, next) => { }; const migrate = async (req, res, next) => { - const token = String(req.get("X-VAULT-SESSION") || ""); + let 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; - } + try { + [, session] = validate.get_session(req, res); + } catch { return } // already handled + + const data = { + accountId: +validate.get_prop(req, "accountId", validate.regexes.gid), + username: validate.get_prop(req, "username", validate.regexes.alnum23), + password: validate.get_prop(req, "password", validate.regexes.any30), + }; - 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)) { + if (!data.username || !data.password) { res.status(400).json({ status: "error", error: "invalid format", @@ -256,44 +172,12 @@ const migrate = async (req, res, next) => { 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; - } - - if (session.strictIPCheck && session.ip !== req.ip) { - // the ip is not the same - res.status(403).json({ - status: "error", - error: "ip address mismatch", - }); - req.app.locals.logger.warn(`Vault.legacy.account: ip address mismatch <${session.vault}@vault> [${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) { + if (acc.accountId === data.accountId) { legacy = acc; break; } @@ -323,7 +207,7 @@ const migrate = async (req, res, next) => { // 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} + where: {userid: data.username} }); if (existing !== null) { @@ -336,8 +220,8 @@ const migrate = async (req, res, next) => { } const evol_acc = await req.app.locals.evol.login.create({ - userid: req.body.username, - userPass: req.body.password, + userid: data.username, + userPass: data.password, email: `${session.vault}@vault`, // setting an actual email is pointless }); diff --git a/src/routers/vault/middlewares/session.js b/src/routers/vault/middlewares/session.js index d5aa521..a961840 100644 --- a/src/routers/vault/middlewares/session.js +++ b/src/routers/vault/middlewares/session.js @@ -4,6 +4,7 @@ const nodemailer = require("nodemailer"); const Claim = require("../utils/claim.js"); const Session = require("../types/Session.js"); const game_accounts = require("../utils/game_accounts.js"); +const validate = require("../utils/validate.js"); let transporter = nodemailer.createTransport({ sendmail: true, @@ -11,23 +12,16 @@ let transporter = nodemailer.createTransport({ path: '/usr/sbin/sendmail' }); -const delete_session = async (req, res, next) => { - const token = String(req.get("X-VAULT-SESSION") || ""); +const delete_session = async (req, res) => { + let token, 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; - } + try { + [token, session] = validate.get_session(req, res); + } catch { return } // already handled - const session = req.app.locals.session.get(token); req.app.locals.cooldown(1e4); // cooldown no matter what - if (session === null || session === undefined) { + if (session === null) { // session is already expired res.status(200).json({ status: "success", @@ -50,22 +44,14 @@ const delete_session = async (req, res, next) => { }); }; -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 auth_session = async (req, res) => { + let token, session; - const session = req.app.locals.session.get(token); + try { + [token, session] = validate.get_raw_session(req, res); + } catch { return } // already handled - if (session === null || session === undefined) { + if (session === null) { res.status(410).json({ status: "error", error: "session expired", @@ -85,8 +71,7 @@ const auth_session = async (req, res, next) => { return; } - if (session.strictIPCheck && session.ip !== req.ip) { - // not the same ip + if (!validate.check_ip(req, session)) { res.status(403).json({ status: "error", error: "ip address mismatch", @@ -96,6 +81,7 @@ const auth_session = async (req, res, next) => { } }); + console.warn(`Vault.session: ip address mismatch <${session.vault}@vault> [${req.ip}]`); req.app.locals.cooldown(req, 5e3); return; } @@ -110,18 +96,12 @@ const auth_session = async (req, res, next) => { return; } - if (!req.query || !Reflect.has(req.query, "email") || - !req.query.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.query.email.length >= 320) { - res.status(400).json({ - status: "error", - error: "invalid email address", - }); - req.app.locals.cooldown(req, 1e3); - return; - } + let email; + try { + email = validate.get_email(req, res); + } catch { return } // already handled - if (req.query.email.toLowerCase() !== session.email) { + if (email !== session.email) { res.status(410).json({ status: "error", error: "session expired", @@ -188,6 +168,8 @@ const auth_session = async (req, res, next) => { status: "error", error: "illegal identity" }); + + console.error(`Vault.session: dangling session [${req.ip}]`); req.app.locals.session.delete(token); req.app.locals.cooldown(req, 3e5); return; @@ -246,12 +228,13 @@ const auth_session = async (req, res, next) => { // immediately change the session uuid const new_uuid = uuidv4(); req.app.locals.session.set(new_uuid, session); - req.app.locals.session.delete(token); + req.app.locals.session.delete(token); // revoke the old uuid res.status(200).json({ status: "success", session: { key: new_uuid, + secret: session.secret, // give them the session secret (only shared once) expires: session.expires, identity: session.identity, }, @@ -259,30 +242,25 @@ const auth_session = async (req, res, next) => { }; 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; - } + let email; + try { + email = validate.get_email(req, res); + } catch { return } // already handled - const identity = await req.app.locals.vault.identity.findOne({where: {email: req.body.email}}); + const identity = await req.app.locals.vault.identity.findOne({where: {email: email}}); if (identity === null) { // never logged in with this email address + const confirm = validate.get_prop(req, "confirm"); - if (Reflect.has(req.body, "confirm") && req.body.confirm === true) { + if (confirm) { // account creation request let uuid; do { // avoid collisions uuid = uuidv4(); } while (req.app.locals.session.get(uuid)); - const session = new Session(req.ip, req.body.email); + const session = new Session(req.ip, email); req.app.locals.session.set(uuid, session); console.log(`Vault.session: starting account creation process [${req.ip}]`); @@ -292,7 +270,7 @@ const new_session = async (req, res, next) => { } else { transporter.sendMail({ from: process.env.VAULT__MAILER__FROM, - to: req.body.email, + to: 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"+ @@ -361,7 +339,7 @@ const new_session = async (req, res, next) => { uuid = uuidv4(); } while (req.app.locals.session.get(uuid)); - const session = new Session(req.ip, req.body.email); + const session = new Session(req.ip, email); session.vault = account.id; session.primaryIdentity = account.primaryIdentity; session.allowNonPrimary = account.allowNonPrimary; @@ -376,7 +354,7 @@ const new_session = async (req, res, next) => { } else { transporter.sendMail({ from: process.env.VAULT__MAILER__FROM, - to: req.body.email, + to: 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 " + diff --git a/src/routers/vault/types/Session.js b/src/routers/vault/types/Session.js index 9f0cd95..17c77ef 100644 --- a/src/routers/vault/types/Session.js +++ b/src/routers/vault/types/Session.js @@ -1,3 +1,5 @@ +const uuidv4 = require("uuid/v4"); + /** * holds a cache of all the user data fetched from SQL */ @@ -12,6 +14,8 @@ module.exports = class Session { identity = null; /** the email address of the identity that was used to log in */ email; + /** the secret that is sent after authentication */ + secret; /** cache holding all identities */ identities = []; /** the main identity of the account */ @@ -30,6 +34,7 @@ module.exports = class Session { constructor (ip, email) { this.ip = ip; this.email = email.toLowerCase(); + this.secret = uuidv4(); } /** diff --git a/src/routers/vault/utils/validate.js b/src/routers/vault/utils/validate.js new file mode 100644 index 0000000..a0d0ea3 --- /dev/null +++ b/src/routers/vault/utils/validate.js @@ -0,0 +1,204 @@ +"use strict"; +const Session = require("../types/Session.js"); + +/** thrown when the user attempts to bypass security measures */ +class BypassAttempt extends Error {}; +/** thrown when the received data does not match the expected format */ +class ValidationError extends Error {}; + +/** the patterns used for parsing */ +const regexes = { + /** a Universally Unique Identifier */ + uuid: /^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i, + /** tmwa password */ + any23: /^[^\s][^\t\r\n]{2,21}[^\s]$/, + /** hercules password */ + any30: /^[^\s][^\t\r\n]{6,28}[^\s]$/, + /** username */ + alnum23: /^\w{4,23}$/i, + /** tmwa/hercules GID */ + gid: /^[23][0-9]{6}$/, + /** RFC 5322 email, but must also have a TLD */ + email: /^(?:[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})$/i, +}; + +/** + * gets the name of the endpoint for pretty-printing + * @param {Request} req + * @returns {string} the pretty name + */ +const get_endpoint = req => `Vault.${req.path.slice(1).split("/").join(".")}`; + +/** + * log a bad action + * @param {Request} req + * @param {string} msg + */ +const warn = (req, msg) => + req.app.locals.logger.warn(`${get_endpoint(req)}: ${msg} [${req.ip}]`); + +/** + * check the request ip against the session settings + * @param {Request} + * @param {Session} session + * @returns {boolean} whether the ip is allowed to use this session + */ +const check_ip = (req, session) => !session.strictIPCheck || req.ip === session.ip; + +/** + * gets a property from the body or from the query + * @param {Request} req + * @param {string} prop - the name of the property to get + * @returns {string} the property value + */ +const get_prop = (req, prop, regex = null) => { + try { + let value = ""; + + if (req.body && Reflect.has(req.body, prop)) { + value = String(req.body[prop]); + } else if (req.query && Reflect.has(req.query, prop)) { + value = String(req.query[prop]); + } + + if (regex !== null && !value.match(regex)) { + return ""; // does not match the provided regex + } + + return value; + } catch { + return ""; // couldn't convert to string + } +}; + +/** + * get the secret + * @param {Request} + * @returns {string} the session secret + */ +const get_secret = (req) => { + const token = req.get("X-VAULT-TOKEN") || ""; + + if (!token.match(regexes.uuid)) { + res.status(400).json({ + status: "error", + error: "missing secret key", + }); + warn(req, "blocked an attempt to bypass authentication (missing secret)"); + req.app.locals.cooldown(req, 3e5); + throw new BypassAttempt("missing secret"); + } + + return token; +}; + +/** + * get the session without authenticating + * @param {Request} req + * @param {Response} res + * @returns {[string, Session]} the session token and session + */ +const get_raw_session = (req, res) => { + const token = String(req.get("X-VAULT-SESSION") || ""); + + if (!token.match(regexes.uuid)) { + res.status(400).json({ + status: "error", + error: "missing session key", + }); + warn(req, "blocked an attempt to bypass authentication (missing key)"); + req.app.locals.cooldown(req, 3e5); + throw new BypassAttempt("missing session key"); + } + + return [token, req.app.locals.session.get(token) || null]; +}; + +/** + * check authentication and get the session + * @param {Request} req + * @param {Response} res + * @returns {[string, Session]} the session token and session + */ +const get_session = (req, res) => { + const [token, session] = get_raw_session(req, res); + + if (session === null) { + res.status(410).json({ + status: "error", + error: "session expired", + }); + req.app.locals.cooldown(req, 5e3); // XXX: maybe a lower cooldown here + throw new BypassAttempt("session not found"); + } + + if (get_secret(req) !== session.secret) { + res.status(410).json({ + status: "error", + error: "session expired", // yes, we lie to them + }); + + // max 3 attempts per 15 minutes + if (req.app.locals.brute.consume(req, 3, 9e5)) { + req.app.locals.cooldown(req, 5e3); + } else { + warn(req, "blocked an attempt to bypass authentication (wrong secret)"); + req.app.locals.cooldown(req, 3.6e6); + } + + throw new BypassAttempt("wrong secret"); + } + + if (!session.authenticated) { + // this should not be possible because they cannot know the secret + // before authenticating, but we check just to be safe + + res.status(401).json({ + status: "error", + error: "not authenticated", + }); + warn(req, "blocked an attempt to bypass authentication (not authed)"); + req.app.locals.cooldown(req, 3e5); + throw new BypassAttempt("session not authenticated"); + } + + if (!check_ip(req, session)) { + // ip address has changed + res.status(403).json({ + status: "error", + error: "ip address mismatch", + }); + req.app.locals.logger.warn(`${get_endpoint(req)}: ip address mismatch <${session.vault}@vault> [${req.ip}]`); + req.app.locals.cooldown(req, 3e5); + throw new ValidationError("ip mismatch"); + } + + return [token, session]; +}; + +const get_email = (req) => { + const email = get_prop(req, "email"); + + if (!email.match(regexes.email) || email.length >= 320) { + res.status(400).json({ + status: "error", + error: "invalid email address", + }); + warn(req, "blocked an attempt to bypass authentication (invalid email)"); + req.app.locals.cooldown(req, 3e5); + throw new BypassAttempt("invalid email format"); + } + + return email; +}; + + +module.exports = { + regexes, + check_ip, + get_prop, + get_email, + get_endpoint, + get_raw_session, + get_session, +}; -- cgit v1.2.3-60-g2f50