"use strict"; const uuidv4 = require("uuid/v4"); const nodemailer = require("nodemailer"); const Claim = require("../utils/claim.js"); const Session = require("../types/Session.js"); const game_accounts = require("../utils/game_accounts.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, } }); // max 3 attempts per 15 minutes if (req.app.locals.brute.consume(req, 3, 9e5)) { req.app.locals.cooldown(req, 1e3); } else { req.app.locals.logger.warn(`Vault.session: authentication request flood [${req.ip}]`); req.app.locals.cooldown(req, 3.6e6); } return; } if (session.strictIPCheck && session.ip !== req.ip) { // not the same ip res.status(403).json({ status: "error", error: "ip address mismatch", session: { expires: 0, identity: null, } }); req.app.locals.cooldown(req, 5e3); return; } if (session.authenticated === true) { // already authed, tell client res.status(200).json({ status: "success", session, }); 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), }); user.primaryIdentity = ident.id; await user.save(); req.app.locals.logger.info(`Vault.session: created a new Vault account <${user.id}@vault> [${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.strictIPCheck = user.strictIPCheck; 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}@vault> [${req.ip}]`); } req.app.locals.cooldown(req, 6e4); // pre-cache the accounts and chars in the session cache await game_accounts.get_legacy(req, session); await game_accounts.get_evol(req, session); // 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 const primary = await req.app.locals.vault.identity.findByPk(session.primaryIdentity); if (primary === null || primary === undefined) { // the vault account has no primary identity (bug): let's fix this console.warn(`Vault.session: fixing account with a deleted primary identity <${session.vault}@vault> [${req.ip}]`); await req.app.locals.vault.login.update({ primaryIdentity: session.identity, }, {where: { id: session.vault, }}); session.primaryIdentity = session.identity; } else { 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) => {}); } } res.status(200).json({ status: "success", session, }); }; 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 let uuid; do { // avoid collisions uuid = uuidv4(); } while (req.app.locals.session.get(uuid)); 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" }); // max 5 attempts per 15 minutes if (req.app.locals.brute.consume(req, 5, 9e5)) { req.app.locals.cooldown(req, 6e4); } else { req.app.locals.logger.warn(`Vault.session: account creation request flood [${req.ip}]`); req.app.locals.cooldown(req, 3.6e6); } return; } else { res.status(202).json({ status: "pending", }); // max 5 attempts per 15 minutes if (req.app.locals.brute.consume(req, 5, 9e5)) { req.app.locals.cooldown(req, 1e3); } else { req.app.locals.logger.warn(`Vault.session: email check flood [${req.ip}]`); req.app.locals.cooldown(req, 3.6e6); } 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 console.log(`Vault.session: removing dangling identity [${req.ip}]`); await identity.destroy(); res.status(409).json({ status: "error", error: "data conflict", }); req.app.locals.cooldown(req, 3e5); return; } else { // auth flow if (account.primaryIdentity === null || account.primaryIdentity === undefined) { // the vault account has no primary identity (bug): let's fix this console.warn(`Vault.session: fixing account with no primary identity <${account.id}@vault> [${req.ip}]`); account.primaryIdentity = identity.id; await account.save(); } else 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 let uuid; do { // avoid collisions uuid = uuidv4(); } while (req.app.locals.session.get(uuid)); const session = new Session(req.ip, req.body.email); session.vault = account.id; session.primaryIdentity = account.primaryIdentity; session.allowNonPrimary = account.allowNonPrimary; session.strictIPCheck = account.strictIPCheck; 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) } };