From 23c3905e6bc946d895694b159a8f80163f6b8bda Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Wed, 3 Nov 2021 17:15:47 -0500 Subject: [PATCH 01/33] Support resetting passwords via email --- config/config-example.js | 6 +++ package-lock.json | 31 ++++++++++---- package.json | 3 +- src/actions.ts | 89 ++++++++++++++++++++++++++++++++++++++++ src/user.ts | 32 +++++++++++++++ 5 files changed, 153 insertions(+), 8 deletions(-) diff --git a/config/config-example.js b/config/config-example.js index 0d053c1..f60ef5b 100644 --- a/config/config-example.js +++ b/config/config-example.js @@ -25,6 +25,7 @@ exports.passwordSalt = 10; /** @type {Record} */ exports.routes = { root: "pokemonshowdown.com", + client: "play.pokemonshowdown.com", }; /** @type {string} */ @@ -150,3 +151,8 @@ exports.standings = { "30": "Permaban", "100": "Disabled", }; +/** @type {{transportOpts: import('nodemailer').TransportOptions, from: string}} */ +exports.passwordemails = { + transportOpts: {}, + from: 'passwords@pokemonshowdown.com', +}; diff --git a/package-lock.json b/package-lock.json index 76cf555..8974c98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "AGPL-3.0", "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", @@ -26,7 +27,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" } }, @@ -504,6 +505,14 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-15.14.9.tgz", "integrity": "sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A==" }, + "node_modules/@types/nodemailer": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.7.tgz", + "integrity": "sha512-f5qCBGAn/f0qtRcd4SEn88c8Fp3Swct1731X4ryPKqS61/A3LmmzN8zaEz7hneJvpjFbUUgY7lru/B/7ODTazg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ssh2": { "version": "1.11.7", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.11.7.tgz", @@ -4256,9 +4265,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.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.1.tgz", + "integrity": "sha512-qHw7dOiU5UKNnQpXktdgQ1d3OFgRAekuvbJLcdG5dnEo/GtcTHRYM7+UfJARdOFU9WUQO8OiIamgWPmiSFHYAA==", "dev": true, "engines": { "node": ">=6.0.0" @@ -6779,6 +6788,14 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-15.14.9.tgz", "integrity": "sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A==" }, + "@types/nodemailer": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.7.tgz", + "integrity": "sha512-f5qCBGAn/f0qtRcd4SEn88c8Fp3Swct1731X4ryPKqS61/A3LmmzN8zaEz7hneJvpjFbUUgY7lru/B/7ODTazg==", + "requires": { + "@types/node": "*" + } + }, "@types/ssh2": { "version": "1.11.7", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.11.7.tgz", @@ -9628,9 +9645,9 @@ "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==" }, "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.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.1.tgz", + "integrity": "sha512-qHw7dOiU5UKNnQpXktdgQ1d3OFgRAekuvbJLcdG5dnEo/GtcTHRYM7+UfJARdOFU9WUQO8OiIamgWPmiSFHYAA==", "dev": true }, "nopt": { diff --git a/package.json b/package.json index 3d093e9..2b0ddeb 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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 diff --git a/src/actions.ts b/src/actions.ts index 2852e3c..bba766b 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -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) { @@ -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 this link and follow the instructions to change your password.
` + + `Not you? Please contact staff by typing /ht in any chatroom on Pokemon Showdown.
` + + `If you are unable to do so, visit the Help chatroom.` + ), + }); + return {success: true}; + }, }; if (Config.actions) { diff --git a/src/user.ts b/src/user.ts index fcba88a..aebf1dc 100644 --- a/src/user.ts +++ b/src/user.ts @@ -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'; @@ -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)); + } } From 530d6d3cf1d22de469d533d8f430730ef76a9b44 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Mon, 27 Mar 2023 11:35:57 -0500 Subject: [PATCH 02/33] Fix queries --- src/actions.ts | 2 +- src/user.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index bba766b..bc8c5fb 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -523,7 +523,7 @@ export const actions: {[k: string]: QueryHandler} = { if (!email) { throw new ActionError(`Invalid email sent.`); } - const data = await tables.users.selectOne()`email = ${email}`; + 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) diff --git a/src/user.ts b/src/user.ts index aebf1dc..1c6cfee 100644 --- a/src/user.ts +++ b/src/user.ts @@ -513,7 +513,7 @@ export class Session { } async findPendingReset(name: string) { const id = toID(name); - const sids = await sessions.selectAll()`userid = ${id}`; + 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)); } From 75583b5292d8a7f746cafc7a84f921dab1abf6cc Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Sun, 9 Jul 2023 14:20:17 -0500 Subject: [PATCH 03/33] curuser stuff --- src/actions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index bc8c5fb..f360634 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -493,7 +493,7 @@ export const actions: {[k: string]: QueryHandler} = { delete (data as any).passwordhash; return { success: !!result.changedRows, - curuser: Object.assign(data, {email}), + curuser: {loggedin: true, userid: this.user.id, username: data.username, email}, }; }, async clearemail() { @@ -512,7 +512,7 @@ export const actions: {[k: string]: QueryHandler} = { delete (data as any).passwordhash; return { actionsuccess: !!result.changedRows, - curuser: Object.assign(data, {email: null}), + curuser: {loggedin: true, userid: this.user.id, username: data.username, email: null}, }; }, async resetpassword(params) { From ab6540ab24e3f6b229f78fafc720541e6da7bafa Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Sun, 9 Jul 2023 14:23:38 -0500 Subject: [PATCH 04/33] limit emails to one account --- src/actions.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index f360634..948a4c5 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -479,15 +479,19 @@ export const actions: {[k: string]: QueryHandler} = { 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.`); + throw new ActionError(`You must send an email address.`); } const email = EMAIL_REGEX.exec(params.email)?.[0]; - if (!email) throw new ActionError(`Invalid email sent.`); + 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 result = await tables.users.update(this.user.id, {email}); delete (data as any).passwordhash; From c4a5cba551d103d40384007a0abe7cea9cf435ec Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Mon, 27 Mar 2023 11:38:01 -0500 Subject: [PATCH 05/33] Fix standing actions --- src/actions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index 948a4c5..03c6719 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -445,7 +445,7 @@ export const actions: {[k: string]: QueryHandler} = { if (!Config.standings[standing]) { throw new ActionError("Invalid standing."); } - const matches = await tables.users.selectAll(['userid'])`ip = ${ip}`; + const matches = await tables.users.selectAll(['userid'])`WHERE ip = ${ip}`; for (const {userid} of matches) { await tables.users.update(userid, {banstate: standing}); await tables.usermodlog.insert({ @@ -471,7 +471,7 @@ export const actions: {[k: string]: QueryHandler} = { throw new ActionError(`User ${userid} not found.`); } return { - matches: await tables.users.selectAll(['userid', 'banstate'])`ip = ${res.ip}`, + matches: await tables.users.selectAll(['userid', 'banstate'])`WHERE ip = ${res.ip}`, }; }, async setemail(params) { From 8ca40efe9a7aaf6b07df3bf8d40c799dc2d3c6f4 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Mon, 27 Mar 2023 14:44:59 -0500 Subject: [PATCH 06/33] Fix GXE rounding --- src/ladder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ladder.ts b/src/ladder.ts index 2fef199..0556280 100644 --- a/src/ladder.ts +++ b/src/ladder.ts @@ -244,9 +244,9 @@ export class Ladder { rating.rprd = glicko.rd; const exp = ((1500 - glicko.rating) / 400 / Math.sqrt(1 + 0.0000100724 * (glicko.rd * glicko.rd + 130 * 130))); - rating.gxe = Math.round( + rating.gxe = Number(( 100 / (1 + Math.pow(10, exp)) - ); + ).toFixed(1)); // if ($newM) { // // compensate for Glicko2 bug: don't lose rating on win, don't gain rating on lose From 7dc77860b4e3853034b0fd8c4235868448dbcdf4 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Mon, 27 Mar 2023 17:05:40 -0500 Subject: [PATCH 07/33] Use async signing instead of sync --- src/user.ts | 7 ++----- src/utils.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/user.ts b/src/user.ts index 1c6cfee..c3fd733 100644 --- a/src/user.ts +++ b/src/user.ts @@ -13,7 +13,7 @@ import * as crypto from 'crypto'; import * as gal from 'google-auth-library'; import {SQL} from './database'; import {ActionError, ActionContext} from './server'; -import {toID, time} from './utils'; +import {toID, time, signAsync} from './utils'; import {ladder, loginthrottle, sessions, users, usermodlog} from './tables'; const SID_DURATION = 2 * 7 * 24 * 60 * 60; @@ -301,10 +301,7 @@ export class Session { ); } - const sign = crypto.createSign('RSA-SHA1'); - sign.update(data); - sign.end(); - return data + ';' + sign.sign(Config.privatekey, 'hex'); + return data + ';' + await signAsync('RSA-SHA1', data, Config.privatekey); } static getBannedNameTerms() { return [ diff --git a/src/utils.ts b/src/utils.ts index 8305ada..cd99b79 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,6 @@ import * as child_process from 'child_process'; import * as crypto from 'crypto'; +import {TextEncoder} from 'util'; export function toID(text: any): string { if (text?.id) { @@ -82,3 +83,16 @@ export function stripNonAscii(str: string) { export function md5(str: string) { return crypto.createHash('md5').update(str).digest('hex'); } + +export function encode(text: string) { + return new TextEncoder().encode(text); +} + +export function signAsync(algo: string, data: string, key: string) { + return new Promise((resolve, reject) => { + crypto.sign(algo, encode(data), key, (err, out) => { + if (err) return reject(err); + return resolve(out.toString('hex')); + }); + }); +} From 27f0491d5937b140d6deab5f83748c499a331532 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Mon, 27 Mar 2023 17:24:18 -0500 Subject: [PATCH 08/33] Further optimize string encoding --- src/utils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index cd99b79..b2a809d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,5 @@ import * as child_process from 'child_process'; import * as crypto from 'crypto'; -import {TextEncoder} from 'util'; export function toID(text: any): string { if (text?.id) { @@ -85,7 +84,7 @@ export function md5(str: string) { } export function encode(text: string) { - return new TextEncoder().encode(text); + return Uint8Array.from(Buffer.from(text)); } export function signAsync(algo: string, data: string, key: string) { From a669a2c10c3fbc5e6b5dda1a69c923168ecd5b9f Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Tue, 28 Mar 2023 20:37:36 -0500 Subject: [PATCH 09/33] Set CORS headers when sid is sent directly in the URL --- src/server.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/server.ts b/src/server.ts index 06a7f7b..b5ecab8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -96,6 +96,13 @@ export class ActionContext { const handler = actions[act]; if (!handler) throw new ActionError(`Request type "${act}" was not recognized.`, 404); + // the cookies are actually the only CSRF risk, + // so there's no problem setting CORS + // if they send it directly + if (ActionContext.parseURLRequest(this.request).sid?.length) { + this.setHeader('Access-Control-Allow-Origin', '*'); + } + try { this.user = await this.session.getUser(); const result = await handler.call(this, body); From 62ff3ddbdc77dd08b4dd695881d2bdb141ae7d43 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Tue, 28 Mar 2023 22:07:18 -0500 Subject: [PATCH 10/33] Remove trailing space from comment I really hate that local linter doesn't catch that. --- src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index b5ecab8..1dfa0aa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -96,7 +96,7 @@ export class ActionContext { const handler = actions[act]; if (!handler) throw new ActionError(`Request type "${act}" was not recognized.`, 404); - // the cookies are actually the only CSRF risk, + // the cookies are actually the only CSRF risk, // so there's no problem setting CORS // if they send it directly if (ActionContext.parseURLRequest(this.request).sid?.length) { From d13c1a4ee86fe718cfbf51772a25da3e82cf54cf Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Mon, 3 Apr 2023 09:45:37 -0500 Subject: [PATCH 11/33] Fix setting CORS for body sids --- src/user.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/user.ts b/src/user.ts index c3fd733..87b14db 100644 --- a/src/user.ts +++ b/src/user.ts @@ -435,6 +435,9 @@ export class Session { // see if we're logged in const scookie = body.sid || this.cookies.get('sid'); + if (body.sid) { + this.context.response.setHeader('Access-Control-Allow-Origin', '*'); + } if (!scookie) { // nope, not logged in return null; From d8f4ecb3a070b290451b435d3291cd244d88530b Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Thu, 13 Apr 2023 22:03:19 -0500 Subject: [PATCH 12/33] Support rebuilding the client through an action (#15) --- config/config-example.js | 5 +++++ src/actions.ts | 20 ++++++++++++++++++++ src/utils.ts | 22 +++++++++++----------- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/config/config-example.js b/config/config-example.js index f60ef5b..9959a82 100644 --- a/config/config-example.js +++ b/config/config-example.js @@ -139,6 +139,11 @@ exports.restartip = null; exports.actions = null; exports.cssdir = __dirname + "/customcss/"; +/** + * Path to the client root dir. + * @type {string | null} + */ +exports.clientpath = null; /** * @type {Record} diff --git a/src/actions.ts b/src/actions.ts index 03c6719..d006d09 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -335,6 +335,26 @@ export const actions: {[k: string]: QueryHandler} = { return {updated: update, success: true}; }, + async rebuildclient() { + await this.requireMainServer(); + + if (!Config.restartip || !Config.clientpath) { + throw new ActionError(`This feature is disabled.`); + } + if (this.getIp() !== Config.restartip) { + throw new ActionError(`Access denied for ${this.getIp()}.`); + } + let update; + try { + update = await updateserver(Config.clientpath); + } catch (e: any) { + throw new ActionError(e.message); + } + const [, , stderr] = await bash('node build', Config.clientpath); + if (stderr) throw new ActionError(`Compilation failed:\n${stderr}`); + return {updated: update, success: true}; + }, + async updatenamecolor(params) { await this.requireMainServer(); diff --git a/src/utils.ts b/src/utils.ts index b2a809d..2895775 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -27,18 +27,18 @@ export function bash(command: string, cwd?: string): Promise<[number, string, st }); } -export async function updateserver() { - let [code, stdout, stderr] = await bash(`git fetch`); +export async function updateserver(path?: string) { + let [code, stdout, stderr] = await bash(`git fetch`, path); if (code) throw new Error(`updateserver: Crash while fetching - make sure this is a Git repository`); if (!stdout && !stderr) { return true; // no changes. we're fine. } - [code, stdout, stderr] = await bash(`git rev-parse HEAD`); + [code, stdout, stderr] = await bash(`git rev-parse HEAD`, path); if (code || stderr) throw new Error(`updateserver: Crash while grabbing hash`); const oldHash = String(stdout).trim(); - [code, stdout, stderr] = await bash(`git stash save "PS /updateserver autostash"`); + [code, stdout, stderr] = await bash(`git stash save "PS /updateserver autostash"`, path); let stashedChanges = true; if (code) throw new Error(`updateserver: Crash while stashing`); if ((stdout + stderr).includes("No local changes")) { @@ -49,19 +49,19 @@ export async function updateserver() { // errors can occur while rebasing or popping the stash; make sure to recover try { - [code] = await bash(`git rebase --no-autostash FETCH_HEAD`); + [code] = await bash(`git rebase --no-autostash FETCH_HEAD`, path); if (code) { // conflict while rebasing - await bash(`git rebase --abort`); + await bash(`git rebase --abort`, path); throw new Error(`restore`); } if (stashedChanges) { - [code] = await bash(`git stash pop`); + [code] = await bash(`git stash pop`, path); if (code) { // conflict while popping stash - await bash(`git reset HEAD .`); - await bash(`git checkout .`); + await bash(`git reset HEAD .`, path); + await bash(`git checkout .`, path); throw new Error(`restore`); } } @@ -69,8 +69,8 @@ export async function updateserver() { return true; } catch { // failed while rebasing or popping the stash - await bash(`git reset --hard ${oldHash}`); - if (stashedChanges) await bash(`git stash pop`); + await bash(`git reset --hard ${oldHash}`, path); + if (stashedChanges) await bash(`git stash pop`, path); return false; } } From 6b635de64220919dc1c7edf42501f3f0aa219a29 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Thu, 13 Apr 2023 22:16:52 -0500 Subject: [PATCH 13/33] Support full rebuilds in the rebuildclient action --- src/actions.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index d006d09..bc71597 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -335,7 +335,7 @@ export const actions: {[k: string]: QueryHandler} = { return {updated: update, success: true}; }, - async rebuildclient() { + async rebuildclient(params) { await this.requireMainServer(); if (!Config.restartip || !Config.clientpath) { @@ -350,7 +350,9 @@ export const actions: {[k: string]: QueryHandler} = { } catch (e: any) { throw new ActionError(e.message); } - const [, , stderr] = await bash('node build', Config.clientpath); + const [, , stderr] = await bash( + `node build${params.full ? ' full' : ''}`, Config.clientpath + ); if (stderr) throw new ActionError(`Compilation failed:\n${stderr}`); return {updated: update, success: true}; }, From f82d81ae7cb01ffd6acae1a8bdacd2ecf4a5c3d2 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Thu, 20 Apr 2023 19:26:51 -0500 Subject: [PATCH 14/33] Actions: Ensure the right commands are executed in rebuildclient --- src/actions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index bc71597..d4764cc 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -346,12 +346,12 @@ export const actions: {[k: string]: QueryHandler} = { } let update; try { - update = await updateserver(Config.clientpath); + update = await bash('sudo -u apache git pull', Config.clientpath); } catch (e: any) { throw new ActionError(e.message); } const [, , stderr] = await bash( - `node build${params.full ? ' full' : ''}`, Config.clientpath + `sudo -u apache node build${params.full ? ' full' : ''}`, Config.clientpath ); if (stderr) throw new ActionError(`Compilation failed:\n${stderr}`); return {updated: update, success: true}; From 895819e837cbf64dbbb05d70f03859754b80f40b Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Thu, 20 Apr 2023 19:30:11 -0500 Subject: [PATCH 15/33] Ensure pulls error correctly --- src/actions.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/actions.ts b/src/actions.ts index d4764cc..a354493 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -347,6 +347,8 @@ export const actions: {[k: string]: QueryHandler} = { let update; try { update = await bash('sudo -u apache git pull', Config.clientpath); + if (update[2]) throw new Error(update[1]); + update = true; } catch (e: any) { throw new ActionError(e.message); } From 49cc574ae946f1c2150f0f9fb62ce5c428642f6d Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Wed, 17 May 2023 17:12:28 -0500 Subject: [PATCH 16/33] Session: Improve existing user check to fix crash in addUser --- src/database.ts | 3 +++ src/user.ts | 7 +++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/database.ts b/src/database.ts index 59021e3..0fd9dd5 100644 --- a/src/database.ts +++ b/src/database.ts @@ -260,6 +260,9 @@ export class DatabaseTable { insert(partialRow: PartialOrSQL, where?: SQLStatement) { return this.queryExec()`INSERT INTO \`${this.name}\` (${partialRow as SQLValue}) ${where}`; } + insertIgnore(partialRow: PartialOrSQL, where?: SQLStatement) { + return this.queryExec()`INSERT IGNORE INTO \`${this.name}\` (${partialRow as SQLValue}) ${where}`; + } async tryInsert(partialRow: PartialOrSQL, where?: SQLStatement) { try { return await this.insert(partialRow, where); diff --git a/src/user.ts b/src/user.ts index 87b14db..e5079f5 100644 --- a/src/user.ts +++ b/src/user.ts @@ -124,16 +124,15 @@ export class Session { async addUser(username: string, password: string) { const hash = await bcrypt.hash(password, Config.passwordSalt); const userid = toID(username); - const exists = await users.get(userid, ['userid']); - if (exists) return null; const ip = this.context.getIp(); - const result = await users.insert({ + const result = await users.insertIgnore({ userid, username, passwordhash: hash, email: null, registertime: time(), ip, }); if (!result.affectedRows) { - throw new Error(`User could not be created. (${userid}, ${ip})`); + // 0 affected rows almost always means user already exists + return null; } return this.login(username, password); } From ae2b617242e4017293bed03c55fd1a178ec6f307 Mon Sep 17 00:00:00 2001 From: adrivrie Date: Sun, 2 Apr 2023 16:30:37 +0200 Subject: [PATCH 17/33] Set current gen to 9 for ladder Elo decay purposes --- src/ladder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ladder.ts b/src/ladder.ts index 0556280..2761f53 100644 --- a/src/ladder.ts +++ b/src/ladder.ts @@ -194,8 +194,8 @@ export class Ladder { decay = 1 + (elo - 1400) / 50; } switch (this.formatid) { - case 'gen8randombattle': - case 'gen8ou': + case 'gen9randombattle': + case 'gen9ou': break; default: decay -= 2; From d9640014c42ee27bbab905f0acad66f57a092c88 Mon Sep 17 00:00:00 2001 From: gigalh128 Date: Mon, 24 Jul 2023 20:25:44 -0400 Subject: [PATCH 18/33] Add missing truncation --- src/ladder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ladder.ts b/src/ladder.ts index 2761f53..41c10b2 100644 --- a/src/ladder.ts +++ b/src/ladder.ts @@ -46,11 +46,11 @@ export class Ladder { this.rpoffset = 9 * 60 * 60; } getRP() { - const rpnum = ((time() - this.rpoffset) / this.rplen) + 1; + const rpnum = Math.trunc((time() - this.rpoffset) / this.rplen) + 1; return rpnum * this.rplen + this.rpoffset; } nextRP(rp: number) { - const rpnum = (rp / this.rplen); + const rpnum = Math.trunc(rp / this.rplen); return (rpnum + 1) * this.rplen + this.rpoffset; } clearRating(name: string) { From ecbf23b4d0181bdb69039fb43cf0b04d6664e590 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Mon, 14 Aug 2023 23:10:01 -0500 Subject: [PATCH 19/33] Support OAuth (#12) * WIP * rebase kinda borked, handle it * linter stuff * Dammit i swear I had linted this * Don't require a challstr * add schemas * modify some stuff * remove register action * Remove the inner action too * Prettify page * Shift stuff down * Fix jquery loading * specify message * Make client typing more consistent in token table * Sanitize redirect URL, also make the page html a top-level constant * Escape client information too * Escape URI, fix checks --- src/actions.ts | 83 +++++++++++++++++++++++- src/public/oauth-authorize.html | 111 ++++++++++++++++++++++++++++++++ src/schemas/ntbb-oauth.sql | 12 ++++ src/tables.ts | 15 +++++ src/utils.ts | 10 +++ 5 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 src/public/oauth-authorize.html create mode 100644 src/schemas/ntbb-oauth.sql diff --git a/src/actions.ts b/src/actions.ts index a354493..3804a3d 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -5,14 +5,15 @@ * @author mia-pi-git */ import {Config} from './config-loader'; -import * as fs from 'fs/promises'; +import {promises as fs, readFileSync} from 'fs'; import {Ladder} from './ladder'; import {Replays} from './replays'; -import {ActionError, QueryHandler} from './server'; -import {toID, updateserver, bash, time} from './utils'; +import {ActionError, QueryHandler, Server} from './server'; +import {toID, updateserver, bash, time, escapeHTML} from './utils'; import * as tables from './tables'; import * as pathModule from 'path'; import IPTools from './ip-tools'; +import * as crypto from 'crypto'; import nodemailer from 'nodemailer'; // eslint-disable-next-line @@ -20,6 +21,17 @@ const EMAIL_REGEX = /(?:[a-z0-9!#$%&\'*+\/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&\'*+\/=?^ const mailer = nodemailer.createTransport(Config.passwordemails.transportOpts); +async function getOAuthClient(clientId?: string) { + if (!clientId) throw new ActionError("No client_id provided."); + const data = await tables.oauthClients.get(clientId); + if (!data) throw new ActionError("Invalid client_id"); + return data; +} + +const OAUTH_AUTHORIZE_CONTENT = readFileSync( + __dirname + "/../../src/public/oauth-authorize.html", + 'utf-8' +); export const actions: {[k: string]: QueryHandler} = { async register(params) { @@ -584,6 +596,71 @@ export const actions: {[k: string]: QueryHandler} = { }); return {success: true}; }, + // oauth is broken into a few parts + // oauth/page - public-facing part + // oauth/api/page - api part (does the actual action) + async 'oauth/authorize'(params) { + if (!params.redirect_uri) { + throw new ActionError("No redirect_uri provided"); + } + const clientInfo = await getOAuthClient(params.client_id); + + this.response.setHeader('Content-Type', 'text/html'); + try { + let content = OAUTH_AUTHORIZE_CONTENT; + // table keys are owner, clientName, id + // expects client, client_name, redirect_uri + content = content.replace(/\{\{client\}\}/g, escapeHTML(clientInfo.client_title)); + content = content.replace(/\{\{client_name\}\}/g, escapeHTML(clientInfo.owner)); + this.response.setHeader('Content-Length', content.length); + return content; + } catch (e) { + Server.crashlog(e, "oauth/authorize", params); + return "The OAuth page could not be served at this time. Please try again later."; + } + }, + + // make a token if they don't already have it + async 'oauth/api/authorize'(params) { + if (!this.user.loggedIn) { + throw new ActionError("You're not logged in."); + } + const clientInfo = await getOAuthClient(params.client_id); + const existing = await ( + tables.oauthTokens.selectOne() + )`WHERE client = ${clientInfo.id} AND owner = ${this.user.id}`; + if (existing) { + if (Date.now() - existing.time > 2 * 7 * 24 * 60 * 1000) { // 2w + await tables.oauthTokens.delete(existing.id); + return {success: false}; + } else { + return existing.id; + } + } + const id = crypto.randomBytes(16).toString('hex'); + await tables.oauthTokens.insert({ + id, owner: this.user.id, client: clientInfo.id, time: Date.now(), + }); + return id; + }, + + // validate assertion & get token if it's valid + async 'oauth/api/getassertion'(params) { + if (!this.user.loggedIn) throw new ActionError("You're not logged in"); + const client = await getOAuthClient(params.client_id); + const token = (params.token || "").toString(); + if (!token) { + throw new ActionError('No token provided.'); + } + const tokenEntry = await ( + tables.oauthTokens.selectOne() + )`WHERE owner = ${this.user.id} and client = ${client.id}`; + if (!tokenEntry || tokenEntry.id !== token) { + return {success: false}; + } + const challstr = crypto.randomBytes(20).toString('hex'); + return this.session.getAssertion(this.user.id, undefined, this.user, challstr); + }, }; if (Config.actions) { diff --git a/src/public/oauth-authorize.html b/src/public/oauth-authorize.html new file mode 100644 index 0000000..2e1d9aa --- /dev/null +++ b/src/public/oauth-authorize.html @@ -0,0 +1,111 @@ + + + + + + Authorize application - Pokémon Showdown! + + + + +
+
+ +
+ +
+

Authorize {{client}}


+
+ {{client}} by {{client_name}} wants to access your Pokemon Showdown account.
+ This does not expose any private information, and will allow you to log in on their site using your Pokemon Showdown account for two weeks.


+ Authorize

+ Authorizing will redirect you to +
+
+
+ + + + + + diff --git a/src/schemas/ntbb-oauth.sql b/src/schemas/ntbb-oauth.sql new file mode 100644 index 0000000..226c99d --- /dev/null +++ b/src/schemas/ntbb-oauth.sql @@ -0,0 +1,12 @@ +CREATE TABLE `ntbb_oauth_clients` ( + owner varchar(18) NOT NULL, + client_title varchar(40) NOT NULL, + id varchar(32) NOT NULL PRIMARY KEY +); + +CREATE TABLE `ntbb_oauth_tokens` ( + owner varchar(18) NOT NULL, + client varchar(32) NOT NULL, + id varchar(32) NOT NULL PRIMARY KEY, + time BIGINT(20) NOT NULL +); diff --git a/src/tables.ts b/src/tables.ts index 757c72c..ad762ce 100644 --- a/src/tables.ts +++ b/src/tables.ts @@ -100,3 +100,18 @@ export const userstatshistory = new DatabaseTable<{ usercount: number; programid: 'showdown' | 'po'; }>(psdb, 'userstatshistory', 'id'); + +// oauth stuff + +export const oauthClients = new DatabaseTable<{ + owner: string; // ps username + client_title: string; + id: string; // hex hash +}>(psdb, 'oauth_clients', 'id'); + +export const oauthTokens = new DatabaseTable<{ + owner: string; + client: string; // id of client + id: string; + time: number; +}>(psdb, 'oauth_tokens', 'id'); diff --git a/src/utils.ts b/src/utils.ts index 2895775..7aa594d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -95,3 +95,13 @@ export function signAsync(algo: string, data: string, key: string) { }); }); } + +export function escapeHTML(str: string | number) { + if (str === null || str === undefined) return ''; + return ('' + str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} From a70e7d52af877862e7d0f4b507cbdc89ddd68fc4 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Tue, 15 Aug 2023 17:19:41 -0500 Subject: [PATCH 20/33] Implement origin checking on OAuth clients --- src/actions.ts | 14 +++++++++++--- src/schemas/ntbb-oauth.sql | 1 + src/tables.ts | 1 + 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index 3804a3d..b39fd42 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -15,16 +15,21 @@ import * as pathModule from 'path'; import IPTools from './ip-tools'; import * as crypto from 'crypto'; import nodemailer from 'nodemailer'; +import * as url from 'url'; // 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); -async function getOAuthClient(clientId?: string) { +async function getOAuthClient(clientId?: string, origin?: string) { if (!clientId) throw new ActionError("No client_id provided."); const data = await tables.oauthClients.get(clientId); if (!data) throw new ActionError("Invalid client_id"); + if (origin) { + if (new url.URL(origin).host !== new url.URL(data.origin_url).host) { + throw new ActionError("This origin is not permitted to use this OAuth client."); + } + } return data; } @@ -600,10 +605,11 @@ export const actions: {[k: string]: QueryHandler} = { // oauth/page - public-facing part // oauth/api/page - api part (does the actual action) async 'oauth/authorize'(params) { + this.setPrefix(''); if (!params.redirect_uri) { throw new ActionError("No redirect_uri provided"); } - const clientInfo = await getOAuthClient(params.client_id); + const clientInfo = await getOAuthClient(params.client_id, this.request.headers.origin); this.response.setHeader('Content-Type', 'text/html'); try { @@ -622,6 +628,7 @@ export const actions: {[k: string]: QueryHandler} = { // make a token if they don't already have it async 'oauth/api/authorize'(params) { + this.setPrefix(''); if (!this.user.loggedIn) { throw new ActionError("You're not logged in."); } @@ -646,6 +653,7 @@ export const actions: {[k: string]: QueryHandler} = { // validate assertion & get token if it's valid async 'oauth/api/getassertion'(params) { + this.setPrefix(''); if (!this.user.loggedIn) throw new ActionError("You're not logged in"); const client = await getOAuthClient(params.client_id); const token = (params.token || "").toString(); diff --git a/src/schemas/ntbb-oauth.sql b/src/schemas/ntbb-oauth.sql index 226c99d..ee12a77 100644 --- a/src/schemas/ntbb-oauth.sql +++ b/src/schemas/ntbb-oauth.sql @@ -1,6 +1,7 @@ CREATE TABLE `ntbb_oauth_clients` ( owner varchar(18) NOT NULL, client_title varchar(40) NOT NULL, + origin_url varchar(100) NOT NULL, id varchar(32) NOT NULL PRIMARY KEY ); diff --git a/src/tables.ts b/src/tables.ts index ad762ce..685be16 100644 --- a/src/tables.ts +++ b/src/tables.ts @@ -107,6 +107,7 @@ export const oauthClients = new DatabaseTable<{ owner: string; // ps username client_title: string; id: string; // hex hash + origin_url: string; }>(psdb, 'oauth_clients', 'id'); export const oauthTokens = new DatabaseTable<{ From 136d864308e1bf105c9f6ffbabd14fdf8591baca Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Wed, 16 Aug 2023 17:11:22 -0500 Subject: [PATCH 21/33] OAuth: Add refresh token endpoint --- src/actions.ts | 31 ++++++++++++++++++++++++++----- src/public/oauth-authorize.html | 16 ++++++++++------ src/server.ts | 7 +++---- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index b39fd42..2b9b66c 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -20,6 +20,7 @@ import * as url from 'url'; // 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 * 1000; async function getOAuthClient(clientId?: string, origin?: string) { if (!clientId) throw new ActionError("No client_id provided."); @@ -637,24 +638,44 @@ export const actions: {[k: string]: QueryHandler} = { tables.oauthTokens.selectOne() )`WHERE client = ${clientInfo.id} AND owner = ${this.user.id}`; if (existing) { - if (Date.now() - existing.time > 2 * 7 * 24 * 60 * 1000) { // 2w + if (Date.now() - existing.time > OAUTH_TOKEN_TIME) { // 2w await tables.oauthTokens.delete(existing.id); return {success: false}; } else { - return existing.id; + return {success: existing.id}; } } const id = crypto.randomBytes(16).toString('hex'); await tables.oauthTokens.insert({ id, owner: this.user.id, client: clientInfo.id, time: Date.now(), }); - return id; + return {success: id, expires: Date.now() + OAUTH_TOKEN_TIME}; + }, + + async 'oauth/api/refreshtoken'(params) { + this.setPrefix(''); + const clientInfo = await getOAuthClient(params.client_id); + const token = (params.token || "").toString(); + if (!token) { + throw new ActionError('No token provided.'); + } + const tokenEntry = await ( + tables.oauthTokens.selectOne() + )`WHERE owner = ${this.user.id} and client = ${clientInfo.id}`; + if (!tokenEntry || tokenEntry.id !== token) { + return {success: false}; + } + const id = crypto.randomBytes(16).toString('hex'); + await tables.oauthTokens.insert({ + id, owner: this.user.id, client: clientInfo.id, time: Date.now(), + }); + await tables.oauthTokens.delete(tokenEntry.id); + return {success: id, expires: Date.now() + OAUTH_TOKEN_TIME}; }, // validate assertion & get token if it's valid async 'oauth/api/getassertion'(params) { this.setPrefix(''); - if (!this.user.loggedIn) throw new ActionError("You're not logged in"); const client = await getOAuthClient(params.client_id); const token = (params.token || "").toString(); if (!token) { @@ -667,7 +688,7 @@ export const actions: {[k: string]: QueryHandler} = { return {success: false}; } const challstr = crypto.randomBytes(20).toString('hex'); - return this.session.getAssertion(this.user.id, undefined, this.user, challstr); + return this.session.getAssertion(tokenEntry.owner, Config.challengekeyid, this.user, challstr); }, }; diff --git a/src/public/oauth-authorize.html b/src/public/oauth-authorize.html index 2e1d9aa..c21af7b 100644 --- a/src/public/oauth-authorize.html +++ b/src/public/oauth-authorize.html @@ -42,7 +42,11 @@

Authorize {{client}}


return function (data) { if (data.length < 1) return; if (data[0] == ']') data = data.substr(1); - return callback(JSON.parse(data)); + try { + return callback(JSON.parse(data)); + } catch { + return callback({data: data}); + } }; }; var params = new URLSearchParams(location.search); @@ -69,8 +73,8 @@

Authorize {{client}}


authorize(); }); } - var redirect = new URL(params.redirect_uri); - redirect.search = new URLSearchParams({assertion: data.assertion}); + var redirect = new URL(params.get('redirect_uri')); + redirect.search = new URLSearchParams({assertion: data.data, token: token}); location.replace(redirect); })); } @@ -82,14 +86,14 @@

Authorize {{client}}


if (data.actionerror) { return alert(`Error: ${data.actionerror}`); } - params.set('token', data.token); - localStorage.setItem(tokenName, token); + params.set('token', data.success); + localStorage.setItem(tokenName, data.success); getAssertion(); })); } var jqueryEl = document.createElement('script'); - jqueryEl.src = '/js/jquery-1.9.1.min.js'; + jqueryEl.src = '/js/lib/jquery-2.1.4.min.js'; jqueryEl.onload = function() { if (!params.get('token')) { $('#auth').on('click', () => { // this should send a req to get a token diff --git a/src/server.ts b/src/server.ts index 1dfa0aa..c07f643 100644 --- a/src/server.ts +++ b/src/server.ts @@ -78,6 +78,7 @@ export class ActionContext { readonly request: http.IncomingMessage; readonly response: http.ServerResponse; readonly session: Session; + readonly ActionError = ActionError; user: User; private prefix: string | null = null; readonly body: ActionRequest; @@ -171,8 +172,7 @@ export class ActionContext { static parseURLRequest(req: http.IncomingMessage) { if (!req.url) return {}; const [pathname, params] = req.url.split('?'); - const actPart = pathname.split('/api/')[1]; - const act = actPart?.split('/')[0]; + const act = pathname.split('/api/').slice(1).join('/api/'); const result = params ? Object.fromEntries(new URLSearchParams(params)) : {}; if (act) result.act = act; return result; @@ -364,7 +364,7 @@ export class Server { } this.ensureHeaders(res); res.writeHead(200).end(Server.stringify(result)); - } catch (e) { + } catch (e: any) { this.ensureHeaders(res); if (e instanceof ActionError) { if (e.httpStatus) { @@ -377,7 +377,6 @@ export class Server { res.writeHead(500).end("Internal Server Error"); } } - this.activeRequests--; if (!this.activeRequests) this.awaitingEnd?.(); } From fecafa846ee242949d192bd1ba3bf8732bc5f366 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Fri, 18 Aug 2023 16:41:47 -0500 Subject: [PATCH 22/33] Add documentation for OAuth2 functionality --- OAUTH.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 OAUTH.md diff --git a/OAUTH.md b/OAUTH.md new file mode 100644 index 0000000..649ff93 --- /dev/null +++ b/OAUTH.md @@ -0,0 +1,38 @@ +**OAuth2** +======================================================================== +The main Pokemon Showdown loginserver is fully equipped to serve as an OAuth2 provider. To make use of this functionality, you only need a `client_id` and a server able to handle the redirect. + +Getting a client ID is as simple as filling out [this form](https://forms.gle/VAoSjqHn4zwem7tp9). We will try to get back to you as quickly as possible. + +**Functionality documentation** +------------------------------------------------------------------------ + +The root URL for these APIs is `https://play.pokemonshowdown.com/api`. + +`/oauth/authorize` - Serves the front-end page for users to authorize an application to use their account. You must provide `client_id`, `redirect_uri`, and `challenge` in the querystrying. Once the user clicks the button to authorize the use, it will redirect them to the `redirect_uri` with an `assertion` and `token` in the new querystring. The assertion can be used for an immediate login, and you can store the token in a user's browser to get assertions without opening the page on future logins. + +`/oauth/api/getassertion` - Requires `challenge`, `client_id`, and `token` parameters. This endpoint allows you to get a new assertion for a user without opening the `authorize` page. The `challenge` parameter is the `challstr` [provided by the Pokemon Showdown server on login](https://github.com/smogon/pokemon-showdown/blob/master/PROTOCOL.md#global-messages), and the `token` can be acquired from `/oauth/authorize`. + +`/oauth/api/refreshtoken` - Requires `client_id` and `token` parameters. This endpoint allows you to refresh an expiring token (which happens after two weeks) without having to make the end user open `/oauth/authorize` again. You provide the old token, the server verifies it, invalidates the old one, and provides a new one for another two weeks of use. + +**Examples** +------------------------------------------------------------------------ +Here's a simple functionality example for getting a token for a user. + +```ts +const url = `https://play.pokemonshowdown.com/api/oauth/authorize?redirect_uri=https://mysite.com/oauth-demo&client_id=${clientId}`; +const nWindow = window.n = open(url, null, 'popup=1'); +const checkIfUpdated = () => { + if (nWindow.location.host === 'mysite.com') { + const url = new URL(nWindow.location.href); + runLoginWithAssertion(url.searchParams.get('assertion')); + localStorage.setItem('ps-token', url.searchParams.get('token')); + nWindow.close(); + } else { + setTimeout(checkIfUpdated(1000)); + } +}; +setTimeout(checkIfUpdated, 1500); +``` + +This opens the OAuth authorization page in a new window, waits for the user to click the button in the new window, then once the window's URL has changed, extracts the assertion and token from the new querystring, caches the token, and uses the assertion to log in. From 9991844e283718171b9029fc16679530363703e3 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Fri, 18 Aug 2023 17:03:05 -0500 Subject: [PATCH 23/33] OAuth: Inform users what account they're logged in as --- src/public/oauth-authorize.html | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/public/oauth-authorize.html b/src/public/oauth-authorize.html index c21af7b..98a17fb 100644 --- a/src/public/oauth-authorize.html +++ b/src/public/oauth-authorize.html @@ -23,7 +23,7 @@

Authorize {{client}}


- {{client}} by {{client_name}} wants to access your Pokemon Showdown account.
+ {{client}} by {{client_name}} wants to access your Pokemon Showdown account.
This does not expose any private information, and will allow you to log in on their site using your Pokemon Showdown account for two weeks.


Authorize

Authorizing will redirect you to @@ -52,6 +52,13 @@

Authorize {{client}}


var params = new URLSearchParams(location.search); var tokenName = 'ps-' + params.get('client_id') + "-token"; var token = localStorage.getItem(tokenName); + var username = localStorage.getItem('showdown_username'); + if (!username) { + alert("Please log in on Pokemon Showdown before using this page."); + throw ''; + } else { + $('#username').text(` (logged in as "${username}")`); + } if (token) { params.set('token', token); } @@ -108,7 +115,7 @@

Authorize {{client}}


} document.body.appendChild(jqueryEl); - var redirectUri = document.createTextNode(params.redirect_uri || ""); + var redirectUri = document.createTextNode(params.get('redirect_uri') || ""); document.getElementById('uri').appendChild(redirectUri); From 0eb3886b0d423b9a06f408892091ffbb3e186a78 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Fri, 18 Aug 2023 17:37:43 -0500 Subject: [PATCH 24/33] OAuth: Add a page to list authorized applicationons for a user --- src/actions.ts | 44 ++++++++++++++++++ src/public/oauth-authorize.html | 9 ++-- src/public/oauth-authorized.html | 79 ++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 src/public/oauth-authorized.html diff --git a/src/actions.ts b/src/actions.ts index 2b9b66c..1885f5a 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -38,6 +38,10 @@ const OAUTH_AUTHORIZE_CONTENT = readFileSync( __dirname + "/../../src/public/oauth-authorize.html", 'utf-8' ); +const OAUTH_AUTHORIZED_CONTENT = readFileSync( + __dirname + "/../../src/public/oauth-authorized.html", + 'utf-8' +); export const actions: {[k: string]: QueryHandler} = { async register(params) { @@ -690,6 +694,46 @@ export const actions: {[k: string]: QueryHandler} = { const challstr = crypto.randomBytes(20).toString('hex'); return this.session.getAssertion(tokenEntry.owner, Config.challengekeyid, this.user, challstr); }, + + 'oauth/authorized'() { + this.setPrefix(''); + this.response.setHeader('Content-Type', 'text/html'); + const content = OAUTH_AUTHORIZED_CONTENT; + this.response.setHeader('Content-Length', content.length); + return content; + }, + + async 'oauth/api/authorized'() { + if (!this.user.loggedIn) { + throw new ActionError("You're not logged in."); + } + const applications = []; + const tokens = await tables.oauthTokens.selectAll()`WHERE owner = ${this.user.id}`; + for (const token of tokens) { + const client = await tables.oauthClients.get(token.client, ['client_title']); + if (!client) throw new Error("Tokens exist for nonexistent application"); + applications.push({title: client.client_title, url: client.origin_url}); + } + return { + username: this.user.id, + applications, + }; + }, + + async 'oauth/api/revoke'(params) { + if (!this.user.loggedIn) { + throw new ActionError("You're not logged in."); + } + if (!params.uri) { + throw new ActionError("Specify the URL of the application you wish to revoke access for."); + } + const tokenEntry = await tables.oauthTokens.selectOne()`WHERE origin_url = ${params.uri}`; + if (!tokenEntry) { + throw new ActionError("That application doesn't have access granted to your account."); + } + await tables.oauthTokens.deleteOne()`WHERE origin_url = ${params.url}`; + return {success: true}; + }, }; if (Config.actions) { diff --git a/src/public/oauth-authorize.html b/src/public/oauth-authorize.html index 98a17fb..c15aef3 100644 --- a/src/public/oauth-authorize.html +++ b/src/public/oauth-authorize.html @@ -52,12 +52,15 @@

Authorize {{client}}


var params = new URLSearchParams(location.search); var tokenName = 'ps-' + params.get('client_id') + "-token"; var token = localStorage.getItem(tokenName); - var username = localStorage.getItem('showdown_username'); + var username = document.cookie + .split('; ') + .filter(x => x.startsWith('showdown_username')) + .map(x => x.split('=').slice(1).join('='))[0]; if (!username) { alert("Please log in on Pokemon Showdown before using this page."); throw ''; } else { - $('#username').text(` (logged in as "${username}")`); + document.getElementById('username').innerText = ` (logged in as "${username}")`; } if (token) { params.set('token', token); @@ -119,4 +122,4 @@

Authorize {{client}}


document.getElementById('uri').appendChild(redirectUri); - + diff --git a/src/public/oauth-authorized.html b/src/public/oauth-authorized.html new file mode 100644 index 0000000..03ca3f4 --- /dev/null +++ b/src/public/oauth-authorized.html @@ -0,0 +1,79 @@ + + + + + + + Authorized applications - Pokémon Showdown! + + + + +
+
+ +
+ +
+

Authorized OAuth2 Applications


+
+ OAuth2 applications you have authorized to use your Pokemon Showdown account + will appear here. +
+
+
+ + + + + + From 895a2b1eb03878a548bdc0d86b96b42bea3a1b55 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Fri, 18 Aug 2023 17:44:11 -0500 Subject: [PATCH 25/33] Fix typo --- src/public/oauth-authorized.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/public/oauth-authorized.html b/src/public/oauth-authorized.html index 03ca3f4..23a48a2 100644 --- a/src/public/oauth-authorized.html +++ b/src/public/oauth-authorized.html @@ -49,7 +49,7 @@

Authorized OAuth2 Applications


}; }; function loadApplications() { - $.get('/api/oauth/authorized', safeJSON(function (data) { + $.get('/api/oauth/api/authorized', safeJSON(function (data) { if (data.actionerror) { return alert(data.actionerror); } From ba1060dc22ccc0557fdad9ba0e5b9c0995bb6f5e Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Mon, 21 Aug 2023 13:54:34 -0500 Subject: [PATCH 26/33] Ensure CORS headers are always set on oauth endpoints --- src/actions.ts | 20 ++++++++++++-------- src/public/oauth-authorized.html | 10 ++++++++-- src/server.ts | 8 ++++++-- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index 1885f5a..49f3e5a 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -610,7 +610,7 @@ export const actions: {[k: string]: QueryHandler} = { // oauth/page - public-facing part // oauth/api/page - api part (does the actual action) async 'oauth/authorize'(params) { - this.setPrefix(''); + this.allowCORS(); if (!params.redirect_uri) { throw new ActionError("No redirect_uri provided"); } @@ -633,7 +633,7 @@ export const actions: {[k: string]: QueryHandler} = { // make a token if they don't already have it async 'oauth/api/authorize'(params) { - this.setPrefix(''); + this.allowCORS(); if (!this.user.loggedIn) { throw new ActionError("You're not logged in."); } @@ -657,7 +657,7 @@ export const actions: {[k: string]: QueryHandler} = { }, async 'oauth/api/refreshtoken'(params) { - this.setPrefix(''); + this.allowCORS(); const clientInfo = await getOAuthClient(params.client_id); const token = (params.token || "").toString(); if (!token) { @@ -679,7 +679,7 @@ export const actions: {[k: string]: QueryHandler} = { // validate assertion & get token if it's valid async 'oauth/api/getassertion'(params) { - this.setPrefix(''); + this.allowCORS(); const client = await getOAuthClient(params.client_id); const token = (params.token || "").toString(); if (!token) { @@ -696,7 +696,7 @@ export const actions: {[k: string]: QueryHandler} = { }, 'oauth/authorized'() { - this.setPrefix(''); + this.allowCORS(); this.response.setHeader('Content-Type', 'text/html'); const content = OAUTH_AUTHORIZED_CONTENT; this.response.setHeader('Content-Length', content.length); @@ -710,7 +710,7 @@ export const actions: {[k: string]: QueryHandler} = { const applications = []; const tokens = await tables.oauthTokens.selectAll()`WHERE owner = ${this.user.id}`; for (const token of tokens) { - const client = await tables.oauthClients.get(token.client, ['client_title']); + const client = await tables.oauthClients.get(token.client); if (!client) throw new Error("Tokens exist for nonexistent application"); applications.push({title: client.client_title, url: client.origin_url}); } @@ -727,11 +727,15 @@ export const actions: {[k: string]: QueryHandler} = { if (!params.uri) { throw new ActionError("Specify the URL of the application you wish to revoke access for."); } - const tokenEntry = await tables.oauthTokens.selectOne()`WHERE origin_url = ${params.uri}`; + const client = await tables.oauthClients.selectOne()`WHERE origin_url = ${params.uri}`; + if (!client) { + throw new ActionError('No client found with that URL.'); + } + const tokenEntry = await tables.oauthTokens.selectOne()`WHERE client = ${client.id}`; if (!tokenEntry) { throw new ActionError("That application doesn't have access granted to your account."); } - await tables.oauthTokens.deleteOne()`WHERE origin_url = ${params.url}`; + await tables.oauthTokens.deleteAll()`WHERE client = ${client.id} and owner = ${this.user.id}`; return {success: true}; }, }; diff --git a/src/public/oauth-authorized.html b/src/public/oauth-authorized.html index 23a48a2..fa5c197 100644 --- a/src/public/oauth-authorized.html +++ b/src/public/oauth-authorized.html @@ -42,10 +42,11 @@

Authorized OAuth2 Applications


if (data.length < 1) return; if (data[0] == ']') data = data.substr(1); try { - return callback(JSON.parse(data)); + data = (JSON.parse(data)); } catch { return callback({data: data}); } + return callback(data); }; }; function loadApplications() { @@ -54,12 +55,16 @@

Authorized OAuth2 Applications


return alert(data.actionerror); } let buffer = `Applications authorized for account ${data.username}`; + if (!data.applications.length) { + buffer += ` None.`; + return $('#applications').html(buffer); + } buffer += `
    `; for (var [i, application] of data.applications.entries()) { buffer += `
  • `; buffer += `${application.title} (${application.url}) `; buffer += ` `; - buf += `
  • `; + buffer += ``; } $('#applications').html(buffer); $('button').on('click', ev => { @@ -74,6 +79,7 @@

    Authorized OAuth2 Applications


    }); })); } + setTimeout(loadApplications, 10); diff --git a/src/server.ts b/src/server.ts index c07f643..2f46a67 100644 --- a/src/server.ts +++ b/src/server.ts @@ -177,6 +177,11 @@ export class ActionContext { if (act) result.act = act; return result; } + allowCORS(origin?: string) { + if (!origin) origin = this.request.headers.origin || "*"; + this.setHeader('Access-Control-Allow-Origin', origin); + this.setHeader('Access-Control-Allow-Credentials', 'true'); + } verifyCrossDomainRequest(): string { if (typeof this.prefix === 'string') return this.prefix; // No cross-domain multi-requests for security reasons. @@ -197,8 +202,7 @@ export class ActionContext { } // Valid CORS request. - this.setHeader('Access-Control-Allow-Origin', origin); - this.setHeader('Access-Control-Allow-Credentials', 'true'); + this.allowCORS(origin); this.prefix = prefix; return prefix; } From 7e740de7be1e8bde315b72fb01bd0c4dba3462a0 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Mon, 21 Aug 2023 14:02:24 -0500 Subject: [PATCH 27/33] OAuth: Ensure the getassertion action accepts a challstr properly --- src/actions.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index 49f3e5a..9eb7286 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -685,14 +685,19 @@ export const actions: {[k: string]: QueryHandler} = { if (!token) { throw new ActionError('No token provided.'); } + const challstr = params.challenge || params.challstr; + if (!challstr) { + throw new ActionError('No challstr provided.'); + } const tokenEntry = await ( tables.oauthTokens.selectOne() )`WHERE owner = ${this.user.id} and client = ${client.id}`; if (!tokenEntry || tokenEntry.id !== token) { return {success: false}; } - const challstr = crypto.randomBytes(20).toString('hex'); - return this.session.getAssertion(tokenEntry.owner, Config.challengekeyid, this.user, challstr); + return this.session.getAssertion( + tokenEntry.owner, Config.challengekeyid, this.user, challstr + ); }, 'oauth/authorized'() { From 3f56ddef33ede27ef9276ed1e7cdb779a6444325 Mon Sep 17 00:00:00 2001 From: Async10 <83778583+Async10@users.noreply.github.com> Date: Mon, 11 Sep 2023 16:14:31 +0200 Subject: [PATCH 28/33] Fix code sample for getting a token (#18) The code sample had multiple problems. The most important one was that it did not run because the call to `checkIfUpdated` was incorrect and the code didn't take into account that a DOMException is thrown when trying to read `popup.location.href` before the redirect happened. Apart from that I added url encoding of the query parameters and some simple sanity checks on the received `token` and `assertion` values in order to make the sample a bit more robust. --- OAUTH.md | 44 +++++++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/OAUTH.md b/OAUTH.md index 649ff93..d10c526 100644 --- a/OAUTH.md +++ b/OAUTH.md @@ -20,19 +20,41 @@ The root URL for these APIs is `https://play.pokemonshowdown.com/api`. Here's a simple functionality example for getting a token for a user. ```ts -const url = `https://play.pokemonshowdown.com/api/oauth/authorize?redirect_uri=https://mysite.com/oauth-demo&client_id=${clientId}`; -const nWindow = window.n = open(url, null, 'popup=1'); +const authorizeUrl = new URL('https://play.pokemonshowdown.com/api/oauth/authorize'); +authorizeUrl.searchParams.append('redirect_uri', 'https://mysite.com/oauth-demo'); +authorizeUrl.searchParams.append('client_id', clientId); +authorizeUrl.searchParams.append('challenge', challenge); + +const popup = window.open(authorizeUrl, undefined, 'popup=1'); const checkIfUpdated = () => { - if (nWindow.location.host === 'mysite.com') { - const url = new URL(nWindow.location.href); - runLoginWithAssertion(url.searchParams.get('assertion')); - localStorage.setItem('ps-token', url.searchParams.get('token')); - nWindow.close(); - } else { - setTimeout(checkIfUpdated(1000)); - } + try { + if (popup?.location?.href?.startsWith(redirectUri)) { + const url = new URL(popup.location.href); + const assertion = url.searchParams.get('assertion'); + if (!assertion) { + console.error('Received no assertion'); + return; + } + + runLoginWithAssertion(url.searchParams.get('assertion')); + + const token = url.searchParams.get('token'); + if (!token) { + console.error('Received no token') + return; + } + + localStorage.setItem('ps-token', token); + popup.close(); + } else { + setTimeout(checkIfUpdated, 500); + } + } catch (DOMException) { + setTimeout(checkIfUpdated, 500); + } }; -setTimeout(checkIfUpdated, 1500); + +checkIfUpdated(); ``` This opens the OAuth authorization page in a new window, waits for the user to click the button in the new window, then once the window's URL has changed, extracts the assertion and token from the new querystring, caches the token, and uses the assertion to log in. From 9377d9e914497750d62d771b5afa5daab21af6c1 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Sun, 24 Sep 2023 19:02:08 -0500 Subject: [PATCH 29/33] Fix bugs in OAuth UI --- src/actions.ts | 25 +++++++++++++------------ src/public/oauth-authorize.html | 2 +- src/server.ts | 12 ++++++++---- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index 9eb7286..f2c6154 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -20,7 +20,7 @@ import * as url from 'url'; // 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 * 1000; +const OAUTH_TOKEN_TIME = 2 * 7 * 24 * 60 * 60 * 1000; async function getOAuthClient(clientId?: string, origin?: string) { if (!clientId) throw new ActionError("No client_id provided."); @@ -278,7 +278,7 @@ export const actions: {[k: string]: QueryHandler} = { }, async getassertion(params) { - this.setPrefix(''); + this.verifyCrossDomainRequest(); params.userid = toID(params.userid) || this.user.id; // NaN is falsy so this validates const challengekeyid = Number(params.challengekeyid) || -1; @@ -663,15 +663,13 @@ export const actions: {[k: string]: QueryHandler} = { if (!token) { throw new ActionError('No token provided.'); } - const tokenEntry = await ( - tables.oauthTokens.selectOne() - )`WHERE owner = ${this.user.id} and client = ${clientInfo.id}`; - if (!tokenEntry || tokenEntry.id !== token) { + const tokenEntry = await tables.oauthTokens.get(token); + if (!tokenEntry) { return {success: false}; } const id = crypto.randomBytes(16).toString('hex'); await tables.oauthTokens.insert({ - id, owner: this.user.id, client: clientInfo.id, time: Date.now(), + id, owner: tokenEntry.owner, client: clientInfo.id, time: Date.now(), }); await tables.oauthTokens.delete(tokenEntry.id); return {success: id, expires: Date.now() + OAUTH_TOKEN_TIME}; @@ -680,7 +678,7 @@ export const actions: {[k: string]: QueryHandler} = { // validate assertion & get token if it's valid async 'oauth/api/getassertion'(params) { this.allowCORS(); - const client = await getOAuthClient(params.client_id); + await getOAuthClient(params.client_id); const token = (params.token || "").toString(); if (!token) { throw new ActionError('No token provided.'); @@ -689,14 +687,17 @@ export const actions: {[k: string]: QueryHandler} = { if (!challstr) { throw new ActionError('No challstr provided.'); } - const tokenEntry = await ( - tables.oauthTokens.selectOne() - )`WHERE owner = ${this.user.id} and client = ${client.id}`; + const tokenEntry = await tables.oauthTokens.get(token); if (!tokenEntry || tokenEntry.id !== token) { return {success: false}; } + if ((Date.now() - tokenEntry.time) > OAUTH_TOKEN_TIME) { // 2w + await tables.oauthTokens.delete(tokenEntry.id); + return {success: false}; + } + this.user.login(tokenEntry.owner); return this.session.getAssertion( - tokenEntry.owner, Config.challengekeyid, this.user, challstr + this.user.id, Config.challengekeyid, this.user, challstr ); }, diff --git a/src/public/oauth-authorize.html b/src/public/oauth-authorize.html index c15aef3..7ad581f 100644 --- a/src/public/oauth-authorize.html +++ b/src/public/oauth-authorize.html @@ -71,7 +71,7 @@

    Authorize {{client}}


    $.get('/api/oauth/api/getassertion', { token: params.get('token'), client_id: params.get('client_id'), - challenge: params.get('challenge'), + challenge: params.get('challenge') || params.get('challstr'), }, safeJSON(function (data) { if (data.success === false) { params.delete('token'); diff --git a/src/server.ts b/src/server.ts index 2f46a67..5cce798 100644 --- a/src/server.ts +++ b/src/server.ts @@ -178,7 +178,7 @@ export class ActionContext { return result; } allowCORS(origin?: string) { - if (!origin) origin = this.request.headers.origin || "*"; + if (!origin) origin = this.request.headers.origin || '*'; this.setHeader('Access-Control-Allow-Origin', origin); this.setHeader('Access-Control-Allow-Credentials', 'true'); } @@ -211,17 +211,21 @@ export class ActionContext { } isTrustedProxy(ip: string) { // account for shit like ::ffff:127.0.0.1 - return Config.trustedproxies.some(f => IPTools.checkPattern(f, ip)); + return ip === '::ffff:127.0.0.1' || Config.trustedproxies.some(f => IPTools.checkPattern(f, ip)); } + _ip = ''; getIp() { + if (this._ip) return this._ip; const ip = this.request.socket.remoteAddress || ""; let forwarded = this.request.headers['x-forwarded-for'] || ''; if (!Array.isArray(forwarded)) forwarded = forwarded.split(','); const notProxy = forwarded.filter(f => !this.isTrustedProxy(f)); if (notProxy.length !== forwarded.length) { - return notProxy.pop() || ip; + this._ip = notProxy.pop() || ip; + return this._ip; } - return ip || ''; + this._ip = ip || ''; + return this._ip; } setHeader(name: string, value: string | string[]) { this.response.setHeader(name, value); From 8f14db826de13438fadb80c0b9e612d7759ebd64 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Sun, 24 Sep 2023 21:12:15 -0500 Subject: [PATCH 30/33] Add an action to query teams from a database (#19) --- config/config-example.js | 5 ++ package-lock.json | 169 ++++++++++++++++++++++++++++++++++++--- package.json | 1 + src/actions.ts | 16 ++++ src/database.ts | 14 ++++ src/tables.ts | 3 +- 6 files changed, 195 insertions(+), 13 deletions(-) diff --git a/config/config-example.js b/config/config-example.js index 9959a82..04d3071 100644 --- a/config/config-example.js +++ b/config/config-example.js @@ -161,3 +161,8 @@ exports.passwordemails = { transportOpts: {}, from: 'passwords@pokemonshowdown.com', }; + +/** + * @type {import('pg').PoolConfig | null} + */ +exports.postgres = null; diff --git a/package-lock.json b/package-lock.json index 8974c98..6ff820a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@types/node": "^15.12.4", "@types/nodemailer": "^6.4.4", + "@types/pg": "^8.10.3", "bcrypt": "^5.0.1", "eslint-plugin-import": "^2.24.2", "google-auth-library": "^3.1.2", @@ -506,13 +507,75 @@ "integrity": "sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A==" }, "node_modules/@types/nodemailer": { - "version": "6.4.7", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.7.tgz", - "integrity": "sha512-f5qCBGAn/f0qtRcd4SEn88c8Fp3Swct1731X4ryPKqS61/A3LmmzN8zaEz7hneJvpjFbUUgY7lru/B/7ODTazg==", + "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", + "integrity": "sha512-BACzsw64lCZesclRpZGu55tnqgFAYcrCBP92xLh1KLypZLCOsvJTSTgaoFVTy3lCys/aZTQzfeDxtjwrvdzL2g==", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, + "node_modules/@types/pg/node_modules/pg-types": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.1.tgz", + "integrity": "sha512-hRCSDuLII9/LE3smys1hRHcu5QGcLs9ggT7I/TCs0IE+2Eesxi9+9RWAAwZ0yaGjxoWICF/YHLOEjydGujoJ+g==", + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.0.1", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/pg/node_modules/postgres-array": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-bytea": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/pg/node_modules/postgres-date": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.0.1.tgz", + "integrity": "sha512-YtMKdsDt5Ojv1wQRvUhnyDJNSr2dGIC96mQVKz7xufp07nfuFONzdaowrMHjlAzY6GDLd4f+LUHHAAM1h4MdUw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-interval": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "engines": { + "node": ">=12" + } + }, "node_modules/@types/ssh2": { "version": "1.11.7", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.11.7.tgz", @@ -4265,9 +4328,9 @@ } }, "node_modules/nodemailer": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.1.tgz", - "integrity": "sha512-qHw7dOiU5UKNnQpXktdgQ1d3OFgRAekuvbJLcdG5dnEo/GtcTHRYM7+UfJARdOFU9WUQO8OiIamgWPmiSFHYAA==", + "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" @@ -4402,6 +4465,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4595,6 +4663,14 @@ "node": ">=4.0.0" } }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "engines": { + "node": ">=4" + } + }, "node_modules/pg-pool": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.0.tgz", @@ -4842,6 +4918,11 @@ "node": ">=0.10.0" } }, + "node_modules/postgres-range": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.3.tgz", + "integrity": "sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g==" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6789,13 +6870,62 @@ "integrity": "sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A==" }, "@types/nodemailer": { - "version": "6.4.7", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.7.tgz", - "integrity": "sha512-f5qCBGAn/f0qtRcd4SEn88c8Fp3Swct1731X4ryPKqS61/A3LmmzN8zaEz7hneJvpjFbUUgY7lru/B/7ODTazg==", + "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", + "integrity": "sha512-BACzsw64lCZesclRpZGu55tnqgFAYcrCBP92xLh1KLypZLCOsvJTSTgaoFVTy3lCys/aZTQzfeDxtjwrvdzL2g==", + "requires": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + }, + "dependencies": { + "pg-types": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.1.tgz", + "integrity": "sha512-hRCSDuLII9/LE3smys1hRHcu5QGcLs9ggT7I/TCs0IE+2Eesxi9+9RWAAwZ0yaGjxoWICF/YHLOEjydGujoJ+g==", + "requires": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.0.1", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + } + }, + "postgres-array": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==" + }, + "postgres-bytea": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "requires": { + "obuf": "~1.1.2" + } + }, + "postgres-date": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.0.1.tgz", + "integrity": "sha512-YtMKdsDt5Ojv1wQRvUhnyDJNSr2dGIC96mQVKz7xufp07nfuFONzdaowrMHjlAzY6GDLd4f+LUHHAAM1h4MdUw==" + }, + "postgres-interval": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==" + } + } + }, "@types/ssh2": { "version": "1.11.7", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.11.7.tgz", @@ -9645,9 +9775,9 @@ "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==" }, "nodemailer": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.1.tgz", - "integrity": "sha512-qHw7dOiU5UKNnQpXktdgQ1d3OFgRAekuvbJLcdG5dnEo/GtcTHRYM7+UfJARdOFU9WUQO8OiIamgWPmiSFHYAA==", + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.6.tgz", + "integrity": "sha512-s7pDtWwe5fLMkQUhw8TkWB/wnZ7SRdd9HRZslq/s24hlZvBP3j32N/ETLmnqTpmj4xoBZL9fOWyCIZ7r2HORHg==", "dev": true }, "nopt": { @@ -9742,6 +9872,11 @@ "es-abstract": "^1.20.4" } }, + "obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -9885,6 +10020,11 @@ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" }, + "pg-numeric": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==" + }, "pg-pool": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.0.tgz", @@ -10075,6 +10215,11 @@ "xtend": "^4.0.0" } }, + "postgres-range": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.3.tgz", + "integrity": "sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g==" + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 2b0ddeb..89176b5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@types/node": "^15.12.4", "@types/nodemailer": "^6.4.4", + "@types/pg": "^8.10.3", "bcrypt": "^5.0.1", "eslint-plugin-import": "^2.24.2", "google-auth-library": "^3.1.2", diff --git a/src/actions.ts b/src/actions.ts index f2c6154..41cbe2f 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -744,6 +744,22 @@ export const actions: {[k: string]: QueryHandler} = { await tables.oauthTokens.deleteAll()`WHERE client = ${client.id} and owner = ${this.user.id}`; return {success: true}; }, + + async getteams(params) { + if (!this.user.loggedIn || this.user.id === 'guest') { + return {teams: []}; // don't wanna nag people with popups if they aren't logged in + } + let teams = []; + try { + teams = await tables.pgdb.query( + 'SELECT teamid, team, format, title as name FROM teams WHERE ownerid = $1', [this.user.id] + ) ?? []; + } catch (e) { + Server.crashlog(e, 'a teams database query', params); + throw new ActionError('The server could not load your teams. Please try again later.'); + } + return {teams}; + }, }; if (Config.actions) { diff --git a/src/database.ts b/src/database.ts index 0fd9dd5..fb778b7 100644 --- a/src/database.ts +++ b/src/database.ts @@ -7,6 +7,7 @@ */ import * as mysql from 'mysql2'; +import * as pg from 'pg'; export type BasicSQLValue = string | number | null; export type SQLRow = {[k: string]: BasicSQLValue}; @@ -290,3 +291,16 @@ export class DatabaseTable { return this.updateAll(data)`WHERE \`${this.primaryKeyName}\` = ${primaryKey} LIMIT 1`; } } + +export class PGDatabase { + database: pg.Pool | null; + constructor(config: pg.PoolConfig | null) { + this.database = config ? new pg.Pool(config) : null; + } + async query(query: string, values: BasicSQLValue[]) { + if (!this.database) return null; + const result = await this.database.query(query, values); + return result.rows as O[]; + } +} + diff --git a/src/tables.ts b/src/tables.ts index 685be16..5860ff2 100644 --- a/src/tables.ts +++ b/src/tables.ts @@ -1,7 +1,7 @@ /** * Login server database tables */ -import {Database, DatabaseTable} from './database'; +import {Database, DatabaseTable, PGDatabase} from './database'; import {Config} from './config-loader'; import type {LadderEntry} from './ladder'; @@ -9,6 +9,7 @@ import type {ReplayData} from './replays'; // direct access export const psdb = new Database(Config.mysql); +export const pgdb = new PGDatabase(Config.postgres); export const replaysDB = Config.replaysdb ? new Database(Config.replaysdb!) : psdb; export const ladderDB = Config.ladderdb ? new Database(Config.ladderdb!) : psdb; From 8b536e7f926a236f755dc42979e1ebf81df211d8 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Fri, 29 Sep 2023 09:44:56 -0500 Subject: [PATCH 31/33] Actions: Split up getteams to use less bandwidth --- src/actions.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/actions.ts b/src/actions.ts index 41cbe2f..9fe8d49 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -746,6 +746,7 @@ export const actions: {[k: string]: QueryHandler} = { }, async getteams(params) { + this.verifyCrossDomainRequest(); if (!this.user.loggedIn || this.user.id === 'guest') { return {teams: []}; // don't wanna nag people with popups if they aren't logged in } @@ -758,8 +759,42 @@ export const actions: {[k: string]: QueryHandler} = { Server.crashlog(e, 'a teams database query', params); throw new ActionError('The server could not load your teams. Please try again later.'); } + for (const t of teams) { + const mons = []; + const sets = t.team.split(']'); + for (const s of sets) { + const parts = s.split('|'); + // defer to species if exists, otherwise name + mons.push(parts[1] || parts[0]); + } + // feed it only the species names, that way we can render it in teambuilder + // and fetch the team later + t.team = mons.map(species => `${species}|||||||||||`).join(']'); + } return {teams}; }, + async getteam(params) { + if (!this.user.loggedIn || this.user.id === 'guest') { + throw new ActionError("Access denied"); + } + let {teamid} = params; + teamid = toID(teamid); + if (!teamid) { + throw new ActionError("Invalid team ID"); + } + try { + const data = await tables.pgdb.query( + `SELECT ownerid, team, private as privacy FROM teams WHERE teamid = $1`, [teamid] + ); + if (!data || !data.length || data[0].ownerid !== this.user.id) { + return {team: null}; + } + return data[0]; + } catch (e) { + Server.crashlog(e, 'a teams database request', params); + throw new ActionError("Failed to fetch team. Please try again later."); + } + }, }; if (Config.actions) { From 59f87bf8d0eacd66381fa0c6c2cd4ecd4ce02b81 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Wed, 3 Nov 2021 17:15:47 -0500 Subject: [PATCH 32/33] Support resetting passwords via email --- config/config-example.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config/config-example.js b/config/config-example.js index 04d3071..47f8dbf 100644 --- a/config/config-example.js +++ b/config/config-example.js @@ -166,3 +166,8 @@ exports.passwordemails = { * @type {import('pg').PoolConfig | null} */ exports.postgres = null; +/** @type {{transportOpts: import('nodemailer').TransportOptions, from: string}} */ +exports.passwordemails = { + transportOpts: {}, + from: 'passwords@pokemonshowdown.com', +}; From fb15ce2fe6a30914cea8648c7ae8648a73c0ca58 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Wed, 18 Oct 2023 16:45:56 -0500 Subject: [PATCH 33/33] Require confirmation --- src/actions.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++----- src/user.ts | 2 ++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index 9fe8d49..8325bad 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -152,7 +152,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') { @@ -167,6 +172,9 @@ export const actions: {[k: string]: QueryHandler} = { ); } res.loggedin = !!curuser.loggedIn; + if (res.loggedin) { + res.curuser = {email: this.user.email}; + } return res; }, @@ -538,12 +546,51 @@ export const actions: {[k: string]: QueryHandler} = { if (emailUsed.length) { throw new ActionError(`Email is invalid or already taken.`); } - const result = await tables.users.update(this.user.id, {email}); - delete (data as any).passwordhash; + 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 this link to complete the link.
    ` + + `Not you? Please contact staff by typing /ht in any chatroom on Pokemon Showdown.
    ` + + `If you are unable to do so, visit the Help 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, - curuser: {loggedin: true, userid: this.user.id, username: data.username, email}, }; }, async clearemail() { @@ -561,7 +608,7 @@ export const actions: {[k: string]: QueryHandler} = { delete (data as any).passwordhash; return { - actionsuccess: !!result.changedRows, + success: !!result.changedRows, curuser: {loggedin: true, userid: this.user.id, username: data.username, email: null}, }; }, diff --git a/src/user.ts b/src/user.ts index e5079f5..f3e2cdc 100644 --- a/src/user.ts +++ b/src/user.ts @@ -24,6 +24,7 @@ export class User { name = 'Guest'; id = 'guest'; loggedIn = ''; + email?: string; constructor(name?: string) { if (name) this.setName(name); } @@ -162,6 +163,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) {