Merge pull request #357 from PretendoNetwork/feat/update-password
Some checks failed
Build and Publish Docker Image / Build and Publish Docker Image (amd64) (push) Has been cancelled
Build and Publish Docker Image / Build and Publish Docker Image (arm64) (push) Has been cancelled

Feat: update password endpoint, send email notice of password change
This commit is contained in:
mrjvs
2026-09-07 19:22:37 +02:00
committed by GitHub
10 changed files with 103 additions and 9 deletions

8
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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<Empt
await pnid.removeAllTokens();
await pnid.save();
await sendPasswordResetNoticeEmail(pnid);
return {};
}

View File

@@ -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',

View File

@@ -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,

View File

@@ -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';
@@ -91,5 +91,7 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
await pnid.removeAllTokens();
await pnid.save();
await sendPasswordResetNoticeEmail(pnid);
return {};
}

View File

@@ -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<UpdatePasswordResponse> {
// * 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 {};
}

View File

@@ -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;

View File

@@ -177,6 +177,21 @@ export async function sendEmailConfirmedEmail(pnid: mongoose.HydratedDocument<IP
}
}
export async function sendPasswordResetNoticeEmail(pnid: mongoose.HydratedDocument<IPNID, IPNIDMethods>): Promise<void> {
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<IPNID, IPNIDMethods>): Promise<void> {
const email = new CreateEmail()
.addHeader('Dear {{pnid}},', { pnid: pnid.username })