From 23c3905e6bc946d895694b159a8f80163f6b8bda Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Wed, 3 Nov 2021 17:15:47 -0500 Subject: [PATCH] Support resetting passwords via email --- config/config-example.js | 6 +++ package-lock.json | 31 ++++++++++---- package.json | 3 +- src/actions.ts | 89 ++++++++++++++++++++++++++++++++++++++++ src/user.ts | 32 +++++++++++++++ 5 files changed, 153 insertions(+), 8 deletions(-) diff --git a/config/config-example.js b/config/config-example.js index 0d053c1..f60ef5b 100644 --- a/config/config-example.js +++ b/config/config-example.js @@ -25,6 +25,7 @@ exports.passwordSalt = 10; /** @type {Record} */ exports.routes = { root: "pokemonshowdown.com", + client: "play.pokemonshowdown.com", }; /** @type {string} */ @@ -150,3 +151,8 @@ exports.standings = { "30": "Permaban", "100": "Disabled", }; +/** @type {{transportOpts: import('nodemailer').TransportOptions, from: string}} */ +exports.passwordemails = { + transportOpts: {}, + from: 'passwords@pokemonshowdown.com', +}; diff --git a/package-lock.json b/package-lock.json index 76cf555..8974c98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "AGPL-3.0", "dependencies": { "@types/node": "^15.12.4", + "@types/nodemailer": "^6.4.4", "bcrypt": "^5.0.1", "eslint-plugin-import": "^2.24.2", "google-auth-library": "^3.1.2", @@ -26,7 +27,7 @@ "eslint": "^7.32.0", "eslint-plugin-import": "^2.22.1", "mocha": "^6.0.2", - "nodemailer": "^6.6.5", + "nodemailer": "^6.9.1", "typescript": "^4.4.3" } }, @@ -504,6 +505,14 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-15.14.9.tgz", "integrity": "sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A==" }, + "node_modules/@types/nodemailer": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.7.tgz", + "integrity": "sha512-f5qCBGAn/f0qtRcd4SEn88c8Fp3Swct1731X4ryPKqS61/A3LmmzN8zaEz7hneJvpjFbUUgY7lru/B/7ODTazg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ssh2": { "version": "1.11.7", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.11.7.tgz", @@ -4256,9 +4265,9 @@ } }, "node_modules/nodemailer": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.0.tgz", - "integrity": "sha512-jFaCEGTeT3E/m/5R2MHWiyQH3pSARECRUDM+1hokOYc3lQAAG7ASuy+2jIsYVf+RVa9zePopSQwKNVFH8DKUpA==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.1.tgz", + "integrity": "sha512-qHw7dOiU5UKNnQpXktdgQ1d3OFgRAekuvbJLcdG5dnEo/GtcTHRYM7+UfJARdOFU9WUQO8OiIamgWPmiSFHYAA==", "dev": true, "engines": { "node": ">=6.0.0" @@ -6779,6 +6788,14 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-15.14.9.tgz", "integrity": "sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A==" }, + "@types/nodemailer": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.7.tgz", + "integrity": "sha512-f5qCBGAn/f0qtRcd4SEn88c8Fp3Swct1731X4ryPKqS61/A3LmmzN8zaEz7hneJvpjFbUUgY7lru/B/7ODTazg==", + "requires": { + "@types/node": "*" + } + }, "@types/ssh2": { "version": "1.11.7", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.11.7.tgz", @@ -9628,9 +9645,9 @@ "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==" }, "nodemailer": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.0.tgz", - "integrity": "sha512-jFaCEGTeT3E/m/5R2MHWiyQH3pSARECRUDM+1hokOYc3lQAAG7ASuy+2jIsYVf+RVa9zePopSQwKNVFH8DKUpA==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.1.tgz", + "integrity": "sha512-qHw7dOiU5UKNnQpXktdgQ1d3OFgRAekuvbJLcdG5dnEo/GtcTHRYM7+UfJARdOFU9WUQO8OiIamgWPmiSFHYAA==", "dev": true }, "nopt": { diff --git a/package.json b/package.json index 3d093e9..2b0ddeb 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@types/node": "^15.12.4", + "@types/nodemailer": "^6.4.4", "bcrypt": "^5.0.1", "eslint-plugin-import": "^2.24.2", "google-auth-library": "^3.1.2", @@ -31,7 +32,7 @@ "eslint": "^7.32.0", "eslint-plugin-import": "^2.22.1", "mocha": "^6.0.2", - "nodemailer": "^6.6.5", + "nodemailer": "^6.9.1", "typescript": "^4.4.3" }, "private": true diff --git a/src/actions.ts b/src/actions.ts index 2852e3c..bba766b 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -13,6 +13,13 @@ import {toID, updateserver, bash, time} from './utils'; import * as tables from './tables'; import * as pathModule from 'path'; import IPTools from './ip-tools'; +import nodemailer from 'nodemailer'; + +// eslint-disable-next-line +const EMAIL_REGEX = /(?:[a-z0-9!#$%&\'*+\/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&\'*+\/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/i; + +const mailer = nodemailer.createTransport(Config.passwordemails.transportOpts); + export const actions: {[k: string]: QueryHandler} = { async register(params) { @@ -467,6 +474,88 @@ export const actions: {[k: string]: QueryHandler} = { matches: await tables.users.selectAll(['userid', 'banstate'])`ip = ${res.ip}`, }; }, + async setemail(params) { + if (!this.user.loggedIn) { + throw new ActionError(`You must be logged in to set an email.`); + } + if (!params.email || typeof params.email !== 'string') { + throw new ActionError(`You must send an email.`); + } + const email = EMAIL_REGEX.exec(params.email)?.[0]; + if (!email) throw new ActionError(`Invalid email sent.`); + const data = await tables.users.get(this.user.id); + if (!data) throw new ActionError(`You are not registered.`); + if (data.email?.endsWith('@')) { + throw new ActionError(`You have 2FA, and do not need to set an email for password resets.`); + } + const result = await tables.users.update(this.user.id, {email}); + + delete (data as any).passwordhash; + return { + success: !!result.changedRows, + curuser: Object.assign(data, {email}), + }; + }, + async clearemail() { + if (!this.user.loggedIn) { + throw new ActionError(`You must be logged in to edit your email.`); + } + const data = await tables.users.get(this.user.id); + if (!data) throw new ActionError(`You are not registered.`); + if (data.email?.endsWith('@')) { + throw new ActionError( + `You have 2FA, and need an administrator to set/unset your email manually.` + ); + } + const result = await tables.users.update(this.user.id, {email: null}); + + delete (data as any).passwordhash; + return { + actionsuccess: !!result.changedRows, + curuser: Object.assign(data, {email: null}), + }; + }, + async resetpassword(params) { + if (typeof params.email !== 'string' || !params.email) { + throw new ActionError(`You must provide an email address.`); + } + const email = EMAIL_REGEX.exec(params.email)?.[0]; + if (!email) { + throw new ActionError(`Invalid email sent.`); + } + const data = await tables.users.selectOne()`email = ${email}`; + if (!data) { + // no user associated with that email. + // ...pretend like it succeeded (we don't wanna leak that it's in use, after all) + return {success: true}; + } + if (!data.email) { + // should literally never happen + throw new Error(`Account data found with no email, but had an email match`); + } + if (data.email.endsWith('@')) { + throw new ActionError(`You have 2FA, and so do not need a password reset.`); + } + const token = await this.session.createPasswordResetToken(data.username); + + await mailer.sendMail({ + from: Config.passwordemails.from, + to: data.email, + subject: "Pokemon Showdown account password reset", + text: ( + `You requested a password reset for the Pokemon Showdown account ${data.userid}. Click this link https://${Config.routes.root}/resetpassword/${token} and follow the instructions to change your password.\n` + + `Not you? Please contact staff by typing /ht in any chatroom on Pokemon Showdown. \n` + + `If you are unable to do so, visit the Help chatroom.` + ), + html: ( + `You requested a password reset for the Pokemon Showdown account ${data.userid}. ` + + `Click this link and follow the instructions to change your password.
` + + `Not you? Please contact staff by typing /ht in any chatroom on Pokemon Showdown.
` + + `If you are unable to do so, visit the Help chatroom.` + ), + }); + return {success: true}; + }, }; if (Config.actions) { diff --git a/src/user.ts b/src/user.ts index fcba88a..aebf1dc 100644 --- a/src/user.ts +++ b/src/user.ts @@ -18,6 +18,7 @@ import {ladder, loginthrottle, sessions, users, usermodlog} from './tables'; const SID_DURATION = 2 * 7 * 24 * 60 * 60; const LOGINTIME_INTERVAL = 24 * 60 * 60; +const PASSWORD_RESET_TOKEN_SIZE = 10; export class User { name = 'Guest'; @@ -485,4 +486,35 @@ export class Session { } return pass; } + + async createPasswordResetToken(name: string, timeout: null | number = null) { + const ctime = time(); + const userid = toID(name); + if (!timeout) { + timeout = 7 * 24 * 60 * 60; + } + timeout += ctime; + // todo throttle by checking to see if pending token exists in sid table? + if (await this.findPendingReset(name)) { + throw new ActionError(`A reset token is already pending to that account.`); + } + + await usermodlog.insert({ + userid, actorid: userid, ip: this.context.getIp(), + date: ctime, entry: "Password reset token requested", + }); + + // magical character string... + const token = crypto.randomBytes(PASSWORD_RESET_TOKEN_SIZE).toString('hex'); + await sessions.insert({ + userid, sid: token, time: ctime, timeout, ip: this.context.getIp(), + }); + return token; + } + async findPendingReset(name: string) { + const id = toID(name); + const sids = await sessions.selectAll()`userid = ${id}`; + // not a fan of this but sids are normally different lengths. have to be, iirc. + return sids.some(({sid}) => sid.length === (PASSWORD_RESET_TOKEN_SIZE * 2)); + } }