-
Notifications
You must be signed in to change notification settings - Fork 27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support resetting passwords via email #2
base: master
Are you sure you want to change the base?
Changes from all commits
23c3905
530d6d3
75583b5
ab6540a
c4a5cba
8ca40ef
7dc7786
27f0491
a669a2c
62ff3dd
d13c1a4
d8f4ecb
6b635de
f82d81a
895819e
49cc574
ae2b617
d964001
ecbf23b
a70e7d5
136d864
fecafa8
9991844
0eb3886
895a2b1
ba1060d
7e740de
3f56dde
9377d9e
8f14db8
8b536e7
59f87bf
7c79f52
fb15ce2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,6 +25,7 @@ exports.passwordSalt = 10; | |
/** @type {Record<string, string>} */ | ||
exports.routes = { | ||
root: "pokemonshowdown.com", | ||
client: "play.pokemonshowdown.com", | ||
}; | ||
|
||
/** @type {string} */ | ||
|
@@ -155,6 +156,11 @@ exports.standings = { | |
"30": "Permaban", | ||
"100": "Disabled", | ||
}; | ||
/** @type {{transportOpts: import('nodemailer').TransportOptions, from: string}} */ | ||
exports.passwordemails = { | ||
transportOpts: {}, | ||
from: '[email protected]', | ||
}; | ||
|
||
/** | ||
* @type {import('pg').PoolConfig | null} | ||
|
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,8 +14,12 @@ import * as tables from './tables'; | |
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); | ||
const OAUTH_TOKEN_TIME = 2 * 7 * 24 * 60 * 60 * 1000; | ||
|
||
async function getOAuthClient(clientId?: string, origin?: string) { | ||
|
@@ -148,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') { | ||
|
@@ -163,6 +172,9 @@ export const actions: {[k: string]: QueryHandler} = { | |
); | ||
} | ||
res.loggedin = !!curuser.loggedIn; | ||
if (res.loggedin) { | ||
res.curuser = {email: this.user.email}; | ||
} | ||
return res; | ||
}, | ||
|
||
|
@@ -516,6 +528,131 @@ export const actions: {[k: string]: QueryHandler} = { | |
matches: await tables.users.selectAll(['userid', 'banstate'])`WHERE ip = ${res.ip}`, | ||
}; | ||
}, | ||
async setemail(params) { | ||
if (!this.user.loggedIn) { | ||
throw new ActionError(`You must be logged in to set an email.`); | ||
} | ||
if (!params.email || typeof params.email !== 'string') { | ||
throw new ActionError(`You must send an email address.`); | ||
} | ||
const email = EMAIL_REGEX.exec(params.email)?.[0]; | ||
if (!email) throw new ActionError(`Email is invalid or already taken.`); | ||
const data = await tables.users.get(this.user.id); | ||
if (!data) throw new ActionError(`You are not registered.`); | ||
if (data.email?.endsWith('@')) { | ||
throw new ActionError(`You have 2FA, and do not need to set an email for password resets.`); | ||
} | ||
const emailUsed = await tables.users.selectAll(['userid'])`WHERE email = ${email}`; | ||
if (emailUsed.length) { | ||
throw new ActionError(`Email is invalid or already taken.`); | ||
} | ||
|
||
const pass = crypto.randomBytes(10).toString('hex'); | ||
await tables.users.update(this.user.id, { | ||
email: `!${pass}!${time()}!${email}!`, | ||
}); | ||
const confirmURL = `https://${Config.routes.client}/api/confirmemail?token=${pass}`; | ||
await mailer.sendMail({ | ||
from: Config.passwordemails.from, | ||
to: email, | ||
subject: "Pokemon Showdown email confirmation", | ||
text: ( | ||
`Someone tried to bind this email to the Pokemon Showdown username ${this.user.id}\n` + | ||
`Please navigate to the URL ${confirmURL}\n` + | ||
`Not you? Please contact staff by typing /ht in any chatroom on Pokemon Showdown. \n` + | ||
`If you are unable to do so, visit the Help chatroom.` | ||
), | ||
html: ( | ||
`Someone tried to bind this email to the Pokemon Showdown username ${this.user.id}\n` + | ||
`Click <a href="${confirmURL}">this link</a> to complete the link.<br />` + | ||
`Not you? Please contact staff by typing <code>/ht</code> in any chatroom on Pokemon Showdown. <br />` + | ||
`If you are unable to do so, visit the <a href="${Config.routes.client}/help">Help</a> chatroom.` | ||
), | ||
}); | ||
return {success: true}; | ||
}, | ||
async confirmemail(params) { | ||
if (!this.user.loggedIn) throw new ActionError("Not logged in."); | ||
const pass = toID(params.token); | ||
if (!pass) throw new ActionError(`Invalid confirmation token.`); | ||
const userData = await tables.users.get(this.user.id); | ||
if (!userData || !userData.email || !userData?.email.startsWith('!')) { | ||
throw new ActionError(`Invalid confirmation request.`); | ||
} | ||
// `!${pass}!${time()}!${email}!`, | ||
const [, targetPass, rawTime, email] = userData.email.split('!'); | ||
if (toID(targetPass) !== pass) { | ||
throw new ActionError(`Invalid confirmation token. Please try again later.`); | ||
} | ||
const validateTime = Number(rawTime); | ||
if (time() > (validateTime + (60 * 60 * 12))) { | ||
throw new ActionError(`Confirmation token expired. Please try again.`); | ||
} | ||
const result = await tables.users.update(this.user.id, {email}); | ||
return { | ||
success: !!result.changedRows, | ||
}; | ||
}, | ||
async clearemail() { | ||
if (!this.user.loggedIn) { | ||
throw new ActionError(`You must be logged in to edit your email.`); | ||
} | ||
const data = await tables.users.get(this.user.id); | ||
if (!data) throw new ActionError(`You are not registered.`); | ||
if (data.email?.endsWith('@')) { | ||
throw new ActionError( | ||
`You have 2FA, and need an administrator to set/unset your email manually.` | ||
); | ||
} | ||
const result = await tables.users.update(this.user.id, {email: null}); | ||
|
||
delete (data as any).passwordhash; | ||
return { | ||
success: !!result.changedRows, | ||
curuser: {loggedin: true, userid: this.user.id, username: data.username, email: null}, | ||
}; | ||
}, | ||
async resetpassword(params) { | ||
if (typeof params.email !== 'string' || !params.email) { | ||
throw new ActionError(`You must provide an email address.`); | ||
} | ||
const email = EMAIL_REGEX.exec(params.email)?.[0]; | ||
if (!email) { | ||
throw new ActionError(`Invalid email sent.`); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This error reads like it refers to sending an email, rather than sending an email address in a HTTP request. |
||
} | ||
const data = await tables.users.selectOne()`WHERE email = ${email}`; | ||
if (!data) { | ||
// no user associated with that email. | ||
// ...pretend like it succeeded (we don't wanna leak that it's in use, after all) | ||
return {success: true}; | ||
} | ||
if (!data.email) { | ||
// should literally never happen | ||
throw new Error(`Account data found with no email, but had an email match`); | ||
} | ||
if (data.email.endsWith('@')) { | ||
throw new ActionError(`You have 2FA, and so do not need a password reset.`); | ||
} | ||
const token = await this.session.createPasswordResetToken(data.username); | ||
Comment on lines
+623
to
+636
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will this work if an email address is associated with multiple user accounts? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was thinking about this. I think as a matter of internet standard, we probably shouldn't allow emails to be keyed to more than one account. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That sounds inconvenient for people who have multiple accounts they care about not losing the password to. Also, if we do decide to run things this way, are we normalizing email addresses? |
||
|
||
await mailer.sendMail({ | ||
from: Config.passwordemails.from, | ||
to: data.email, | ||
subject: "Pokemon Showdown account password reset", | ||
text: ( | ||
`You requested a password reset for the Pokemon Showdown account ${data.userid}. Click this link https://${Config.routes.root}/resetpassword/${token} and follow the instructions to change your password.\n` + | ||
`Not you? Please contact staff by typing /ht in any chatroom on Pokemon Showdown. \n` + | ||
`If you are unable to do so, visit the Help chatroom.` | ||
), | ||
html: ( | ||
`You requested a password reset for the Pokemon Showdown account ${data.userid}. ` + | ||
`Click <a href="https://${Config.routes.root}/resetpassword/${token}">this link</a> and follow the instructions to change your password.<br />` + | ||
`Not you? Please contact staff by typing <code>/ht</code> in any chatroom on Pokemon Showdown. <br />` + | ||
`If you are unable to do so, visit the <a href="${Config.routes.client}/help">Help</a> chatroom.` | ||
), | ||
}); | ||
return {success: true}; | ||
}, | ||
// oauth is broken into a few parts | ||
// oauth/page - public-facing part | ||
// oauth/api/page - api part (does the actual action) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this regex come from somewhere or did you create it yourself? It's hard to tell what this is doing; I assume it's to validate an email address.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I stole it from usermodlog.