Skip to content

Commit 23c3905

Browse files
committed
Support resetting passwords via email
1 parent 970aec0 commit 23c3905

File tree

5 files changed

+153
-8
lines changed

5 files changed

+153
-8
lines changed

config/config-example.js

+6
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ exports.passwordSalt = 10;
2525
/** @type {Record<string, string>} */
2626
exports.routes = {
2727
root: "pokemonshowdown.com",
28+
client: "play.pokemonshowdown.com",
2829
};
2930

3031
/** @type {string} */
@@ -150,3 +151,8 @@ exports.standings = {
150151
"30": "Permaban",
151152
"100": "Disabled",
152153
};
154+
/** @type {{transportOpts: import('nodemailer').TransportOptions, from: string}} */
155+
exports.passwordemails = {
156+
transportOpts: {},
157+
158+
};

package-lock.json

+24-7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
},
1616
"dependencies": {
1717
"@types/node": "^15.12.4",
18+
"@types/nodemailer": "^6.4.4",
1819
"bcrypt": "^5.0.1",
1920
"eslint-plugin-import": "^2.24.2",
2021
"google-auth-library": "^3.1.2",
@@ -31,7 +32,7 @@
3132
"eslint": "^7.32.0",
3233
"eslint-plugin-import": "^2.22.1",
3334
"mocha": "^6.0.2",
34-
"nodemailer": "^6.6.5",
35+
"nodemailer": "^6.9.1",
3536
"typescript": "^4.4.3"
3637
},
3738
"private": true

src/actions.ts

+89
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ import {toID, updateserver, bash, time} from './utils';
1313
import * as tables from './tables';
1414
import * as pathModule from 'path';
1515
import IPTools from './ip-tools';
16+
import nodemailer from 'nodemailer';
17+
18+
// eslint-disable-next-line
19+
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;
20+
21+
const mailer = nodemailer.createTransport(Config.passwordemails.transportOpts);
22+
1623

1724
export const actions: {[k: string]: QueryHandler} = {
1825
async register(params) {
@@ -467,6 +474,88 @@ export const actions: {[k: string]: QueryHandler} = {
467474
matches: await tables.users.selectAll(['userid', 'banstate'])`ip = ${res.ip}`,
468475
};
469476
},
477+
async setemail(params) {
478+
if (!this.user.loggedIn) {
479+
throw new ActionError(`You must be logged in to set an email.`);
480+
}
481+
if (!params.email || typeof params.email !== 'string') {
482+
throw new ActionError(`You must send an email.`);
483+
}
484+
const email = EMAIL_REGEX.exec(params.email)?.[0];
485+
if (!email) throw new ActionError(`Invalid email sent.`);
486+
const data = await tables.users.get(this.user.id);
487+
if (!data) throw new ActionError(`You are not registered.`);
488+
if (data.email?.endsWith('@')) {
489+
throw new ActionError(`You have 2FA, and do not need to set an email for password resets.`);
490+
}
491+
const result = await tables.users.update(this.user.id, {email});
492+
493+
delete (data as any).passwordhash;
494+
return {
495+
success: !!result.changedRows,
496+
curuser: Object.assign(data, {email}),
497+
};
498+
},
499+
async clearemail() {
500+
if (!this.user.loggedIn) {
501+
throw new ActionError(`You must be logged in to edit your email.`);
502+
}
503+
const data = await tables.users.get(this.user.id);
504+
if (!data) throw new ActionError(`You are not registered.`);
505+
if (data.email?.endsWith('@')) {
506+
throw new ActionError(
507+
`You have 2FA, and need an administrator to set/unset your email manually.`
508+
);
509+
}
510+
const result = await tables.users.update(this.user.id, {email: null});
511+
512+
delete (data as any).passwordhash;
513+
return {
514+
actionsuccess: !!result.changedRows,
515+
curuser: Object.assign(data, {email: null}),
516+
};
517+
},
518+
async resetpassword(params) {
519+
if (typeof params.email !== 'string' || !params.email) {
520+
throw new ActionError(`You must provide an email address.`);
521+
}
522+
const email = EMAIL_REGEX.exec(params.email)?.[0];
523+
if (!email) {
524+
throw new ActionError(`Invalid email sent.`);
525+
}
526+
const data = await tables.users.selectOne()`email = ${email}`;
527+
if (!data) {
528+
// no user associated with that email.
529+
// ...pretend like it succeeded (we don't wanna leak that it's in use, after all)
530+
return {success: true};
531+
}
532+
if (!data.email) {
533+
// should literally never happen
534+
throw new Error(`Account data found with no email, but had an email match`);
535+
}
536+
if (data.email.endsWith('@')) {
537+
throw new ActionError(`You have 2FA, and so do not need a password reset.`);
538+
}
539+
const token = await this.session.createPasswordResetToken(data.username);
540+
541+
await mailer.sendMail({
542+
from: Config.passwordemails.from,
543+
to: data.email,
544+
subject: "Pokemon Showdown account password reset",
545+
text: (
546+
`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` +
547+
`Not you? Please contact staff by typing /ht in any chatroom on Pokemon Showdown. \n` +
548+
`If you are unable to do so, visit the Help chatroom.`
549+
),
550+
html: (
551+
`You requested a password reset for the Pokemon Showdown account ${data.userid}. ` +
552+
`Click <a href="https://${Config.routes.root}/resetpassword/${token}">this link</a> and follow the instructions to change your password.<br />` +
553+
`Not you? Please contact staff by typing <code>/ht</code> in any chatroom on Pokemon Showdown. <br />` +
554+
`If you are unable to do so, visit the <a href="${Config.routes.client}/help">Help</a> chatroom.`
555+
),
556+
});
557+
return {success: true};
558+
},
470559
};
471560

472561
if (Config.actions) {

src/user.ts

+32
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {ladder, loginthrottle, sessions, users, usermodlog} from './tables';
1818

1919
const SID_DURATION = 2 * 7 * 24 * 60 * 60;
2020
const LOGINTIME_INTERVAL = 24 * 60 * 60;
21+
const PASSWORD_RESET_TOKEN_SIZE = 10;
2122

2223
export class User {
2324
name = 'Guest';
@@ -485,4 +486,35 @@ export class Session {
485486
}
486487
return pass;
487488
}
489+
490+
async createPasswordResetToken(name: string, timeout: null | number = null) {
491+
const ctime = time();
492+
const userid = toID(name);
493+
if (!timeout) {
494+
timeout = 7 * 24 * 60 * 60;
495+
}
496+
timeout += ctime;
497+
// todo throttle by checking to see if pending token exists in sid table?
498+
if (await this.findPendingReset(name)) {
499+
throw new ActionError(`A reset token is already pending to that account.`);
500+
}
501+
502+
await usermodlog.insert({
503+
userid, actorid: userid, ip: this.context.getIp(),
504+
date: ctime, entry: "Password reset token requested",
505+
});
506+
507+
// magical character string...
508+
const token = crypto.randomBytes(PASSWORD_RESET_TOKEN_SIZE).toString('hex');
509+
await sessions.insert({
510+
userid, sid: token, time: ctime, timeout, ip: this.context.getIp(),
511+
});
512+
return token;
513+
}
514+
async findPendingReset(name: string) {
515+
const id = toID(name);
516+
const sids = await sessions.selectAll()`userid = ${id}`;
517+
// not a fan of this but sids are normally different lengths. have to be, iirc.
518+
return sids.some(({sid}) => sid.length === (PASSWORD_RESET_TOKEN_SIZE * 2));
519+
}
488520
}

0 commit comments

Comments
 (0)