Skip to content

Commit

Permalink
Support resetting passwords via email
Browse files Browse the repository at this point in the history
  • Loading branch information
mia-pi-git committed Mar 27, 2023
1 parent 970aec0 commit 23c3905
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 8 deletions.
6 changes: 6 additions & 0 deletions config/config-example.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ exports.passwordSalt = 10;
/** @type {Record<string, string>} */
exports.routes = {
root: "pokemonshowdown.com",
client: "play.pokemonshowdown.com",
};

/** @type {string} */
Expand Down Expand Up @@ -150,3 +151,8 @@ exports.standings = {
"30": "Permaban",
"100": "Disabled",
};
/** @type {{transportOpts: import('nodemailer').TransportOptions, from: string}} */
exports.passwordemails = {
transportOpts: {},
from: 'passwords@pokemonshowdown.com',
};
31 changes: 24 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
89 changes: 89 additions & 0 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <a href="https://${Config.routes.root}/resetpassword/${token}">this link</a> and follow the instructions to change your password.<br />` +
`Not you? Please contact staff by typing <code>/ht</code> in any chatroom on Pokemon Showdown. <br />` +
`If you are unable to do so, visit the <a href="${Config.routes.client}/help">Help</a> chatroom.`
),
});
return {success: true};
},
};

if (Config.actions) {
Expand Down
32 changes: 32 additions & 0 deletions src/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
}
}

0 comments on commit 23c3905

Please sign in to comment.