mirror of
https://github.com/smogon/pokemon-showdown-loginserver.git
synced 2026-04-25 16:19:58 -05:00
Merge 67632acb24 into dd4c773553
This commit is contained in:
commit
9652d2cf4c
|
|
@ -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
31
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
139
src/actions.ts
139
src/actions.ts
|
|
@ -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)
|
||||
|
|
|
|||
34
src/user.ts
34
src/user.ts
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user