diff --git a/package-lock.json b/package-lock.json index 9c6a503..ce68520 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@aws-sdk/client-s3": "^3.657.0", "@aws-sdk/client-ses": "^3.515.0", "@inquirer/prompts": "^7.2.0", - "@pretendonetwork/grpc": "^2.5.10", + "@pretendonetwork/grpc": "^2.6.1", "bcrypt": "^5.0.0", "buffer-crc32": "^0.2.13", "colors": "^1.4.0", @@ -1680,9 +1680,9 @@ } }, "node_modules/@pretendonetwork/grpc": { - "version": "2.5.10", - "resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.5.10.tgz", - "integrity": "sha512-1nd/nsRU+tdi08O6fHFuYvUJqByJh/qToQxvsDgWonwZLR/RsBFi3o2MU1/4ketbeGu2KmStLN7vBLDcQ+c44w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.6.1.tgz", + "integrity": "sha512-+HKJ8cTV4AV8PJgUqg2Tocz4EJTajqErY9r/Oul8WIZM2lO+0gRPSf3Mlxi9Ghvjq88ZS9pWWccAKtn77ePrew==", "license": "AGPL-3.0-only", "dependencies": { "@bufbuild/protobuf": "^2.2.2", diff --git a/package.json b/package.json index a389e69..83c140c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@aws-sdk/client-s3": "^3.657.0", "@aws-sdk/client-ses": "^3.515.0", "@inquirer/prompts": "^7.2.0", - "@pretendonetwork/grpc": "^2.5.10", + "@pretendonetwork/grpc": "^2.6.1", "bcrypt": "^5.0.0", "buffer-crc32": "^0.2.13", "colors": "^1.4.0", diff --git a/src/services/api/routes/v1/resetPassword.ts b/src/services/api/routes/v1/resetPassword.ts index 1b1f1ce..5132ddd 100644 --- a/src/services/api/routes/v1/resetPassword.ts +++ b/src/services/api/routes/v1/resetPassword.ts @@ -2,7 +2,7 @@ import crypto from 'node:crypto'; import express from 'express'; import bcrypt from 'bcrypt'; import { PasswordResetToken } from '@/models/password-reset-token'; -import { nintendoPasswordHash } from '@/util'; +import { nintendoPasswordHash, sendPasswordResetNoticeEmail } from '@/util'; import { SystemType } from '@/types/common/system-types'; import { TokenType } from '@/types/common/token-types'; import { getPNIDByPID } from '@/database'; @@ -177,6 +177,8 @@ router.post('/', passwordResetRatelimit, async (request: express.Request, respon await pnid.removeAllTokens(); await pnid.save(); + await sendPasswordResetNoticeEmail(pnid); + response.json({ app: 'api', status: 200 diff --git a/src/services/grpc/api/v1/reset-password.ts b/src/services/grpc/api/v1/reset-password.ts index 2ed2e5c..a62047f 100644 --- a/src/services/grpc/api/v1/reset-password.ts +++ b/src/services/grpc/api/v1/reset-password.ts @@ -2,7 +2,7 @@ import crypto from 'node:crypto'; import bcrypt from 'bcrypt'; import { Status, ServerError } from 'nice-grpc'; import { PasswordResetToken } from '@/models/password-reset-token'; -import { nintendoPasswordHash } from '@/util'; +import { nintendoPasswordHash, sendPasswordResetNoticeEmail } from '@/util'; import { getPNIDByPID } from '@/database'; import { SystemType } from '@/types/common/system-types'; import { TokenType } from '@/types/common/token-types'; @@ -92,5 +92,7 @@ export async function resetPassword(request: ResetPasswordRequest): Promise { + // * This is asserted in authentication-middleware, we know this is never null + const pnid = context.pnid!; + + const oldPassword = request.oldPassword.trim(); + const newPassword = request.newPassword.trim(); + const newPasswordConfirm = request.newPasswordConfirm.trim(); + + const hashedOldPassword = nintendoPasswordHash(oldPassword, pnid.pid); + + if (!bcrypt.compareSync(hashedOldPassword, pnid.password)) { + throw new ServerError(Status.INVALID_ARGUMENT, 'Password is incorrect'); + } + + if (!newPassword) { + throw new ServerError(Status.INVALID_ARGUMENT, 'Must enter a new password'); + } + + if (newPassword !== newPasswordConfirm) { + throw new ServerError(Status.INVALID_ARGUMENT, 'Passwords do not match'); + } + + if (newPassword === oldPassword) { + throw new ServerError(Status.INVALID_ARGUMENT, 'New password must not equal current password'); + } + + if (newPassword.length < 6 || newPassword.length > 16) { + throw new ServerError(Status.INVALID_ARGUMENT, 'Password must be between 6 and 16 characters long'); + } + + if (newPassword.toLowerCase() === pnid.username.toLowerCase()) { + throw new ServerError(Status.INVALID_ARGUMENT, 'Password cannot be the same as username'); + } + + if (!PASSWORD_WORD_OR_NUMBER_REGEX.test(newPassword) && !PASSWORD_WORD_OR_PUNCTUATION_REGEX.test(newPassword) && !PASSWORD_NUMBER_OR_PUNCTUATION_REGEX.test(newPassword)) { + throw new ServerError(Status.INVALID_ARGUMENT, 'Password must have combination of letters, numbers, and/or punctuation characters'); + } + + if (PASSWORD_REPEATED_CHARACTER_REGEX.test(newPassword)) { + throw new ServerError(Status.INVALID_ARGUMENT, 'Password may not have 3 repeating characters'); + } + + const primaryPasswordHash = nintendoPasswordHash(newPassword, pnid.pid); + const passwordHash = await bcrypt.hash(primaryPasswordHash, 10); + + pnid.password = passwordHash; + + await pnid.removeAllTokens(); + await pnid.save(); + + await sendPasswordResetNoticeEmail(pnid); + + return {}; +} diff --git a/src/services/nnas/routes/people.ts b/src/services/nnas/routes/people.ts index a94622b..31626a0 100644 --- a/src/services/nnas/routes/people.ts +++ b/src/services/nnas/routes/people.ts @@ -6,7 +6,7 @@ import moment from 'moment'; import deviceCertificateMiddleware from '@/middleware/device-certificate'; import { deviceRatelimit } from '@/middleware/ratelimit'; import { connection as databaseConnection, doesPNIDExist, getPNIDProfileJSONByPID } from '@/database'; -import { getAgeFromDate, getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail } from '@/util'; +import { getAgeFromDate, getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail, sendPasswordResetNoticeEmail } from '@/util'; import IP2LocationManager from '@/ip2location'; import { PNID } from '@/models/pnid'; import { NEXAccount } from '@/models/nex-account'; @@ -652,6 +652,7 @@ router.put('/@me', async (request: express.Request, response: express.Response): pnid.password = passwordHash; await pnid.removeAllTokens(); + await sendPasswordResetNoticeEmail(pnid); } pnid.gender = gender; diff --git a/src/util.ts b/src/util.ts index dc8a74d..f80ed68 100644 --- a/src/util.ts +++ b/src/util.ts @@ -177,6 +177,21 @@ export async function sendEmailConfirmedEmail(pnid: mongoose.HydratedDocument): Promise { + const noticeEmail = new CreateEmail() + .addHeader('Dear {{pnid}},', { pnid: pnid.username }) + .addParagraph('your password has been changed.') + .addParagraph('If this wasn\'t you, contact [support@pretendo.network](mailto:support@pretendo.network).'); + + const noticeOptions = { + to: pnid.email.address, + subject: '[Pretendo Network] Password changed', + email: noticeEmail + }; + + await sendMail(noticeOptions); +} + export async function sendEmailConfirmedParentalControlsEmail(pnid: mongoose.HydratedDocument): Promise { const email = new CreateEmail() .addHeader('Dear {{pnid}},', { pnid: pnid.username })