diff options
Diffstat (limited to 'src/routers/vault/middlewares/session.js')
-rw-r--r-- | src/routers/vault/middlewares/session.js | 313 |
1 files changed, 313 insertions, 0 deletions
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) + } +}; |