Skip to content

Commit aa19d54

Browse files
committed
Support resetting passwords via email
1 parent e79cd07 commit aa19d54

File tree

4 files changed

+128
-2
lines changed

4 files changed

+128
-2
lines changed

config/config-example.js

+8-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ exports.passwordSalt = 10;
2222
// routes
2323
exports.routes = {
2424
root: "pokemonshowdown.com",
25+
client: "play.pokemonshowdown.com",
2526
};
2627

2728
exports.mainserver = 'showdown';
@@ -120,4 +121,10 @@ exports.watchconfig = true;
120121
* An IP to allow restart requests from.
121122
* @type {null | string}
122123
*/
123-
exports.restartip = null;
124+
exports.restartip = null;
125+
126+
/** @type {{transportOpts: import('nodemailer').TransportOptions, from: string}} */
127+
exports.passwordemails = {
128+
transportOpts: {},
129+
130+
};

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"dependencies": {
1313
"@types/node": "^15.12.4",
14+
"@types/nodemailer": "^6.4.4",
1415
"bcrypt": "^5.0.1",
1516
"eslint-plugin-import": "^2.24.2",
1617
"google-auth-library": "^3.1.2",
@@ -27,7 +28,7 @@
2728
"eslint": "^7.32.0",
2829
"eslint-plugin-import": "^2.22.1",
2930
"mocha": "^6.0.2",
30-
"nodemailer": "^6.6.5",
31+
"nodemailer": "^6.7.0",
3132
"typescript": "^4.4.3"
3233
},
3334
"private": true

src/actions.ts

+88
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import {NTBBLadder} from './ladder';
1313
import {Replays, md5} from './replays';
1414
import {toID} from './server';
1515
import * as tables from './tables';
16+
import * as 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);
1622

1723
// shamelessly stolen from PS main
1824
function bash(command: string, cwd?: string): Promise<[number, string, string]> {
@@ -416,4 +422,86 @@ export const actions: {[k: string]: QueryHandler} = {
416422
if (stderr) throw new ActionError(stderr);
417423
return {updated: update, success: true};
418424
},
425+
async setemail(params) {
426+
if (!this.user.loggedin) {
427+
throw new ActionError(`You must be logged in to set an email.`);
428+
}
429+
if (!params.email || typeof params.email !== 'string') {
430+
throw new ActionError(`You must send an email.`);
431+
}
432+
const email = EMAIL_REGEX.exec(params.email)?.[0];
433+
if (!email) throw new ActionError(`Invalid email sent.`);
434+
const data = await tables.users.get('*', this.user.id);
435+
if (!data) throw new ActionError(`You are not registered.`);
436+
if (data.email?.endsWith('@')) {
437+
throw new ActionError(`You have 2FA, and do not need to set an email for password resets.`);
438+
}
439+
const result = await tables.users.update(this.user.id, {email});
440+
441+
delete (data as any).passwordhash;
442+
return {
443+
success: !!result.changedRows,
444+
curuser: Object.assign(data, {email}),
445+
};
446+
},
447+
async clearemail() {
448+
if (!this.user.loggedin) {
449+
throw new ActionError(`You must be logged in to edit your email.`);
450+
}
451+
const data = await tables.users.get('*', this.user.id);
452+
if (!data) throw new ActionError(`You are not registered.`);
453+
if (data.email?.endsWith('@')) {
454+
throw new ActionError(
455+
`You have 2FA, and need an administrator to set/unset your email manually.`
456+
);
457+
}
458+
const result = await tables.users.update(this.user.id, {email: null});
459+
460+
delete (data as any).passwordhash;
461+
return {
462+
actionsuccess: !!result.changedRows,
463+
curuser: Object.assign(data, {email: null}),
464+
};
465+
},
466+
async resetpassword(params) {
467+
if (typeof params.email !== 'string' || !params.email) {
468+
throw new ActionError(`You must provide an email address.`);
469+
}
470+
const email = EMAIL_REGEX.exec(params.email)?.[0];
471+
if (!email) {
472+
throw new ActionError(`Invalid email sent.`);
473+
}
474+
const data = await tables.users.selectOne('*', SQL`email = ${email}`);
475+
if (!data) {
476+
// no user associated with that email.
477+
// ...pretend like it succeeded (we don't wanna leak that it's in use, after all)
478+
return {success: true};
479+
}
480+
if (!data.email) {
481+
// should literally never happen
482+
throw new Error(`Account data found with no email, but had an email match`);
483+
}
484+
if (data.email.endsWith('@')) {
485+
throw new ActionError(`You have 2FA, and so do not need a password reset.`);
486+
}
487+
const token = await this.session.createPasswordResetToken(data.username);
488+
489+
await mailer.sendMail({
490+
from: Config.passwordemails.from,
491+
to: data.email,
492+
subject: "Pokemon Showdown account password reset",
493+
text: (
494+
`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` +
495+
`Not you? Please contact staff by typing /ht in any chatroom on Pokemon Showdown. \n` +
496+
`If you are unable to do so, visit the Help chatroom.`
497+
),
498+
html: (
499+
`You requested a password reset for the Pokemon Showdown account ${data.userid}. ` +
500+
`Click <a href="https://${Config.routes.root}/resetpassword/${token}">this link</a> and follow the instructions to change your password.<br />` +
501+
`Not you? Please contact staff by typing <code>/ht</code> in any chatroom on Pokemon Showdown. <br />` +
502+
`If you are unable to do so, visit the <a href="${Config.routes.client}/help">Help</a> chatroom.`
503+
),
504+
});
505+
return {success: true};
506+
},
419507
};

src/session.ts

+30
Original file line numberDiff line numberDiff line change
@@ -446,4 +446,34 @@ export class Session {
446446
}
447447
return Promise.resolve(cachedSid === databaseSid);
448448
}
449+
async createPasswordResetToken(name: string, timeout: null | number = null) {
450+
const ctime = time();
451+
const userid = toID(name);
452+
if (!timeout) {
453+
timeout = 7 * 24 * 60 * 60;
454+
}
455+
timeout += ctime;
456+
// todo throttle by checking to see if pending token exists in sid table?
457+
if (await this.findPendingReset(name)) {
458+
throw new ActionError(`A reset token is already pending to that account.`);
459+
}
460+
461+
await usermodlog.insert({
462+
userid, actorid: userid, ip: this.dispatcher.getIp(),
463+
date: ctime, entry: "Password reset token requested",
464+
});
465+
466+
// magical character string...
467+
const token = crypto.randomBytes(15).toString('hex');
468+
await sessions.insert({
469+
userid, sid: token, time: ctime, timeout, ip: this.dispatcher.getIp(),
470+
});
471+
return token;
472+
}
473+
async findPendingReset(name: string) {
474+
const id = toID(name);
475+
const sids = await sessions.selectAll('*', SQL`userid = ${id}`);
476+
// not a fan of this but sids are normally different lengths. have to be, iirc.
477+
return sids.some(({sid}) => sid.length === 15);
478+
}
449479
}

0 commit comments

Comments
 (0)