This commit is contained in:
Mia 2025-12-01 20:29:31 +01:00 committed by GitHub
commit 9652d2cf4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 207 additions and 8 deletions

View File

@ -43,6 +43,7 @@ exports.passwordSalt = 10;
/** @type {Record<string, string>} */
exports.routes = {
root: "pokemonshowdown.com",
client: "play.pokemonshowdown.com",
};
/** @type {string} */
@ -192,6 +193,11 @@ exports.standings = {
"30": "Permaban",
"100": "Disabled",
};
/** @type {{transportOpts: import('nodemailer').TransportOptions, from: string}} */
exports.passwordemails = {
transportOpts: {},
from: 'passwords@pokemonshowdown.com',
};
/**
* @type {null | ((userid: string) => Promise<{[k: string]: {min: number, max: number, count: number}}>)}

31
package-lock.json generated
View File

@ -9,6 +9,9 @@
"version": "0.1.0",
"license": "AGPL-3.0",
"dependencies": {
"@types/node": "^15.12.4",
"@types/nodemailer": "^6.4.4",
"@types/pg": "^8.10.3",
"bcrypt": "^5.0.1",
"google-auth-library": "^9.14.2",
"mysql2": "^3.9.8",
@ -612,6 +615,14 @@
"undici-types": "~6.19.2"
}
},
"node_modules/@types/nodemailer": {
"version": "6.4.13",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.13.tgz",
"integrity": "sha512-889Vq/77eEpidCwh52sVWpbnqQmIwL8yVBekNbrztVEaWKOCRH3Eq6hjIJh1jwsGDEAJEH0RR+YhpH9mfELLKA==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.10.3",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.10.3.tgz",
@ -4239,9 +4250,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.6",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.6.tgz",
"integrity": "sha512-s7pDtWwe5fLMkQUhw8TkWB/wnZ7SRdd9HRZslq/s24hlZvBP3j32N/ETLmnqTpmj4xoBZL9fOWyCIZ7r2HORHg==",
"dev": true,
"engines": {
"node": ">=6.0.0"
@ -6873,6 +6884,14 @@
"undici-types": "~6.19.2"
}
},
"@types/nodemailer": {
"version": "6.4.13",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.13.tgz",
"integrity": "sha512-889Vq/77eEpidCwh52sVWpbnqQmIwL8yVBekNbrztVEaWKOCRH3Eq6hjIJh1jwsGDEAJEH0RR+YhpH9mfELLKA==",
"requires": {
"@types/node": "*"
}
},
"@types/pg": {
"version": "8.10.3",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.10.3.tgz",
@ -9577,9 +9596,9 @@
}
},
"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.6",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.6.tgz",
"integrity": "sha512-s7pDtWwe5fLMkQUhw8TkWB/wnZ7SRdd9HRZslq/s24hlZvBP3j32N/ETLmnqTpmj4xoBZL9fOWyCIZ7r2HORHg==",
"dev": true
},
"nopt": {

View File

@ -14,6 +14,9 @@
"stop": "npx pm2 stop loginserver"
},
"dependencies": {
"@types/node": "^15.12.4",
"@types/nodemailer": "^6.4.4",
"@types/pg": "^8.10.3",
"bcrypt": "^5.0.1",
"google-auth-library": "^9.14.2",
"mysql2": "^3.9.8",
@ -30,7 +33,7 @@
"eslint": "^9.21.0",
"globals": "^16.0.0",
"mocha": "^6.0.2",
"nodemailer": "^6.6.5",
"nodemailer": "^6.9.1",
"typescript": "^5.7.3",
"typescript-eslint": "^8.25.0"
},

View File

@ -7,6 +7,7 @@
import { promises as fs, readFileSync, watchFile } from 'fs';
import * as pathModule from 'path';
import * as crypto from 'crypto';
import nodemailer from 'nodemailer';
import * as url from 'url';
import { Config } from './config-loader';
import { Ladder, type LadderEntry } from './ladder';
@ -28,6 +29,9 @@ export interface Suspect {
elo: number | null;
}
// 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);
const OAUTH_TOKEN_TIME = 2 * 7 * 24 * 60 * 60 * 1000;
async function getOAuthClient(clientId?: string, origin?: string) {
@ -272,7 +276,12 @@ export const actions: { [k: string]: QueryHandler } = {
async upkeep(params) {
const challengeprefix = this.verifyCrossDomainRequest();
const res = { assertion: '', username: '', loggedin: false };
const res = {
assertion: '',
username: '',
loggedin: false,
curuser: {} as {email?: string},
};
const curuser = this.user;
let userid = '';
if (curuser.id !== 'guest') {
@ -287,6 +296,9 @@ export const actions: { [k: string]: QueryHandler } = {
);
}
res.loggedin = !!curuser.loggedIn;
if (res.loggedin) {
res.curuser = {email: this.user.email};
}
return res;
},
@ -705,6 +717,131 @@ export const actions: { [k: string]: QueryHandler } = {
matches: await tables.users.selectAll(['userid', 'banstate'])`WHERE 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 address.`);
}
const email = EMAIL_REGEX.exec(params.email)?.[0];
if (!email) throw new ActionError(`Email is invalid or already taken.`);
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 emailUsed = await tables.users.selectAll(['userid'])`WHERE email = ${email}`;
if (emailUsed.length) {
throw new ActionError(`Email is invalid or already taken.`);
}
const pass = crypto.randomBytes(10).toString('hex');
await tables.users.update(this.user.id, {
email: `!${pass}!${time()}!${email}!`,
});
const confirmURL = `https://${Config.routes.client}/api/confirmemail?token=${pass}`;
await mailer.sendMail({
from: Config.passwordemails.from,
to: email,
subject: "Pokemon Showdown email confirmation",
text: (
`Someone tried to bind this email to the Pokemon Showdown username ${this.user.id}\n` +
`Please navigate to the URL ${confirmURL}\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: (
`Someone tried to bind this email to the Pokemon Showdown username ${this.user.id}\n` +
`Click <a href="${confirmURL}">this link</a> to complete the link.<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};
},
async confirmemail(params) {
if (!this.user.loggedIn) throw new ActionError("Not logged in.");
const pass = toID(params.token);
if (!pass) throw new ActionError(`Invalid confirmation token.`);
const userData = await tables.users.get(this.user.id);
if (!userData || !userData.email || !userData?.email.startsWith('!')) {
throw new ActionError(`Invalid confirmation request.`);
}
// `!${pass}!${time()}!${email}!`,
const [, targetPass, rawTime, email] = userData.email.split('!');
if (toID(targetPass) !== pass) {
throw new ActionError(`Invalid confirmation token. Please try again later.`);
}
const validateTime = Number(rawTime);
if (time() > (validateTime + (60 * 60 * 12))) {
throw new ActionError(`Confirmation token expired. Please try again.`);
}
const result = await tables.users.update(this.user.id, {email});
return {
success: !!result.changedRows,
};
},
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 {
success: !!result.changedRows,
curuser: {loggedin: true, userid: this.user.id, username: data.username, 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()`WHERE 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};
},
// oauth is broken into a few parts
// oauth/page - public-facing part
// oauth/api/page - api part (does the actual action)

View File

@ -20,11 +20,13 @@ import {
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';
id = 'guest';
loggedIn = '';
email?: string;
group = 0;
constructor(name?: string) {
if (name) this.setName(name);
@ -172,6 +174,7 @@ export class Session {
ip,
});
this.session = res.insertId || 0;
if (info.email) this.context.user.email = info.email;
return this.context.user.login(name);
}
async logout(deleteCookie = false) {
@ -528,4 +531,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()`WHERE 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));
}
}