From c378553fe4f440570465e80397624cb5871196dd Mon Sep 17 00:00:00 2001 From: limes Date: Sat, 29 Aug 2026 19:56:38 +0200 Subject: [PATCH 1/3] feat: add password update grpc endpoint --- package-lock.json | 8 +-- package.json | 2 +- .../grpc/api/v2/authentication-middleware.ts | 1 + src/services/grpc/api/v2/implementation.ts | 2 + src/services/grpc/api/v2/update-password.ts | 69 +++++++++++++++++++ src/util.ts | 15 ++++ 6 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 src/services/grpc/api/v2/update-password.ts diff --git a/package-lock.json b/package-lock.json index cc39094..7b11492 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", @@ -1669,9 +1669,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 3d00970..4dce38c 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/grpc/api/v2/authentication-middleware.ts b/src/services/grpc/api/v2/authentication-middleware.ts index 13521b0..7bf7583 100644 --- a/src/services/grpc/api/v2/authentication-middleware.ts +++ b/src/services/grpc/api/v2/authentication-middleware.ts @@ -8,6 +8,7 @@ const TOKEN_REQUIRED_PATHS = [ '/api.v2.ApiService/GetUserData', '/api.v2.ApiService/UpdateUserData', '/api.v2.ApiService/ResetPassword', // * This paths token is not an authentication token, it is a password reset token + '/api.v2.ApiService/UpdatePassword', '/api.v2.ApiService/SetDiscordConnectionData', '/api.v2.ApiService/SetStripeConnectionData', '/api.v2.ApiService/RemoveConnection', diff --git a/src/services/grpc/api/v2/implementation.ts b/src/services/grpc/api/v2/implementation.ts index 219314c..08281b3 100644 --- a/src/services/grpc/api/v2/implementation.ts +++ b/src/services/grpc/api/v2/implementation.ts @@ -5,6 +5,7 @@ import { updateUserData } from '@/services/grpc/api/v2/update-user-data'; import { updateEmail } from '@/services/grpc/api/v2/update-email'; import { verifyEmail } from '@/services/grpc/api/v2/verify-email'; import { forgotPassword } from '@/services/grpc/api/v2/forgot-password'; +import { updatePassword } from '@/services/grpc/api/v2/update-password'; import { resetPassword } from '@/services/grpc/api/v2/reset-password'; import { setDiscordConnectionData } from '@/services/grpc/api/v2/set-discord-connection-data'; import { setStripeConnectionData } from '@/services/grpc/api/v2/set-stripe-connection-data'; @@ -18,6 +19,7 @@ export const apiServiceImplementationV2 = { updateEmail, verifyEmail, forgotPassword, + updatePassword, resetPassword, setDiscordConnectionData, setStripeConnectionData, diff --git a/src/services/grpc/api/v2/update-password.ts b/src/services/grpc/api/v2/update-password.ts new file mode 100644 index 0000000..7a504c0 --- /dev/null +++ b/src/services/grpc/api/v2/update-password.ts @@ -0,0 +1,69 @@ +import bcrypt from 'bcrypt'; +import { Status, ServerError } from 'nice-grpc'; +import { nintendoPasswordHash, sendPasswordResetNoticeEmail } from '@/util'; +import type { CallContext } from 'nice-grpc'; +import type { UpdatePasswordRequest, UpdatePasswordResponse } from '@pretendonetwork/grpc/api/v2/update_password_rpc'; +import type { AuthenticationCallContextExt } from '@/services/grpc/api/v2/authentication-middleware'; + +// * This sucks +const PASSWORD_WORD_OR_NUMBER_REGEX = /(?=.*[a-zA-Z])(?=.*\d).*/; +const PASSWORD_WORD_OR_PUNCTUATION_REGEX = /(?=.*[a-zA-Z])(?=.*[_\-.]).*/; +const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX = /(?=.*\d)(?=.*[_\-.]).*/; +const PASSWORD_REPEATED_CHARACTER_REGEX = /(.)\1\1/; + +export async function updatePassword(request: UpdatePasswordRequest, + context: CallContext & AuthenticationCallContextExt +): 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); // * We know password will never be null here + + 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/util.ts b/src/util.ts index 8fc73e5..67d0376 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 }) From c1a885602d1a0c0abff2aca099c714cfd1494dd2 Mon Sep 17 00:00:00 2001 From: limes Date: Sat, 29 Aug 2026 19:57:07 +0200 Subject: [PATCH 2/3] feat: send password change notice on every endpoint --- src/services/api/routes/v1/resetPassword.ts | 4 +++- src/services/grpc/api/v1/reset-password.ts | 4 +++- src/services/grpc/api/v2/reset-password.ts | 4 +++- src/services/nnas/routes/people.ts | 3 ++- 4 files changed, 11 insertions(+), 4 deletions(-) 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 Date: Sun, 6 Sep 2026 21:10:19 +0200 Subject: [PATCH 3/3] fix: remove unnecessary non-null assertion --- src/services/grpc/api/v2/update-password.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/grpc/api/v2/update-password.ts b/src/services/grpc/api/v2/update-password.ts index 7a504c0..fdd36fe 100644 --- a/src/services/grpc/api/v2/update-password.ts +++ b/src/services/grpc/api/v2/update-password.ts @@ -21,7 +21,7 @@ export async function updatePassword(request: UpdatePasswordRequest, const newPassword = request.newPassword.trim(); const newPasswordConfirm = request.newPasswordConfirm.trim(); - const hashedOldPassword = nintendoPasswordHash(oldPassword!, pnid.pid); // * We know password will never be null here + const hashedOldPassword = nintendoPasswordHash(oldPassword, pnid.pid); if (!bcrypt.compareSync(hashedOldPassword, pnid.password)) { throw new ServerError(Status.INVALID_ARGUMENT, 'Password is incorrect');