diff --git a/packages/backend/src/server/api/endpoints/request-reset-password.ts b/packages/backend/src/server/api/endpoints/request-reset-password.ts index 3aae59e33..402df9ffa 100644 --- a/packages/backend/src/server/api/endpoints/request-reset-password.ts +++ b/packages/backend/src/server/api/endpoints/request-reset-password.ts @@ -4,7 +4,7 @@ import config from '@/config/index.js'; import { Users, UserProfiles, PasswordResetRequests } from '@/models/index.js'; import { sendEmail } from '@/services/send-email.js'; import { genId } from '@/misc/gen-id.js'; -import { HOUR } from '@/const.js'; +import { DAY } from '@/const.js'; import define from '../define.js'; export const meta = { @@ -15,12 +15,8 @@ export const meta = { description: 'Request a users password to be reset.', limit: { - duration: HOUR, - max: 3, - }, - - errors: { - + duration: DAY, + max: 1, }, } as const; @@ -35,25 +31,26 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps) => { - const user = await Users.findOneBy({ - usernameLower: ps.username.toLowerCase(), - host: IsNull(), + const [user, profile] = await Promise.all([ + Users.findOneBy({ + usernameLower: ps.username.toLowerCase(), + host: IsNull(), + }), + UserProfiles.findOneByOrFail({ userId: user.id }), + ]); + + const resetRequestExists = await PasswordResetRequests.countBy({ + userId: user.id, }); - // 合致するユーザーが登録されていなかったら無視 - if (user == null) { - return; - } - - const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); - - // 合致するメアドが登録されていなかったら無視 - if (profile.email !== ps.email) { - return; - } - - // メアドが認証されていなかったら無視 - if (!profile.emailVerified) { + // This is an "arithmetical or" to try to prevent timing attacks which might reveal + // a users email address. + if ( + user == null + | profile.email !== ps.email + | !profile.emailVerified + | resetRequestExists > 0 + ) { return; } @@ -69,6 +66,6 @@ export default define(meta, paramDef, async (ps) => { const link = `${config.url}/reset-password/${token}`; sendEmail(ps.email, 'Password reset requested', - `To reset password, please click this link:
${link}`, - `To reset password, please click this link: ${link}`); + `Hello ${user.username}!

You requested to reset your password. To do so, please follow this link:

${link}

The link is valid for 30 minutes.

`, + `Hello ${user.username}!\nYou requested to reset your password. To do so, please follow this link: ${link}\nThe link is valid for 30 minutes.`); }); diff --git a/packages/backend/src/server/api/endpoints/reset-password.ts b/packages/backend/src/server/api/endpoints/reset-password.ts index ea7df52c0..ba32f475c 100644 --- a/packages/backend/src/server/api/endpoints/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/reset-password.ts @@ -1,5 +1,6 @@ import bcrypt from 'bcryptjs'; import { UserProfiles, PasswordResetRequests } from '@/models/index.js'; +import { MINUTE } from '@/const.js'; import define from '../define.js'; export const meta = { @@ -10,7 +11,11 @@ export const meta = { description: 'Complete the password reset that was previously requested.', errors: { - + noSuchResetRequest: { + message: 'No such password reset request.', + code: 'NO_SUCH_RESET_REQUEST', + id: '2c455231-675b-41be-b313-1181646a3ad3', + }, }, } as const; @@ -25,13 +30,20 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, user) => { - const req = await PasswordResetRequests.findOneByOrFail({ + const req = await PasswordResetRequests.findOneBy({ token: ps.token, }); - // 発行してから30分以上経過していたら無効 - if (Date.now() - req.createdAt.getTime() > 1000 * 60 * 30) { - throw new Error(); // TODO + if (req == null) throw new ApiError(meta.errors.noSuchResetRequest); + + // Check that the has not expired yet after 30 minutes. + // + // This is a secondary check just in case the expiry task is broken, + // the expiry task schedule is not aligned to the timing, or something + // else strange is going on. After all this is security critical. + if (Date.now() - req.createdAt.getTime() > 30 * MINUTE) { + await PasswordResetRequests.delete(req.id); + throw new ApiError(meta.errors.noSuchResetRequest); } // Generate hash of password @@ -42,5 +54,5 @@ export default define(meta, paramDef, async (ps, user) => { password: hash, }); - PasswordResetRequests.delete(req.id); + await PasswordResetRequests.delete(req.id); });