feat: add updateEmail/verifyEmail grpc, track email changes

This commit is contained in:
limes
2026-08-26 20:42:43 +02:00
parent 81cbacfec1
commit 0bb3d86fa9
14 changed files with 165 additions and 16 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.7",
"@pretendonetwork/grpc": "^2.5.10",
"bcrypt": "^5.0.0",
"buffer-crc32": "^0.2.13",
"colors": "^1.4.0",
@@ -1669,9 +1669,9 @@
}
},
"node_modules/@pretendonetwork/grpc": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.5.7.tgz",
"integrity": "sha512-HmWyBxm/Om6S5k+PYLxVU/MaRcSUEV0NAfKccy4i02ZJTZccCJRRKiSBl3WfXSYE+TJNJhSoURGK56E1i8CXqA==",
"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==",
"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.7",
"@pretendonetwork/grpc": "^2.5.10",
"bcrypt": "^5.0.0",
"buffer-crc32": "^0.2.13",
"colors": "^1.4.0",

View File

@@ -0,0 +1,10 @@
import { Schema, model } from 'mongoose';
import type { IEmailUpdateEvent, EmailUpdateEventModel, IEmailUpdateEventMethods } from '@/types/mongoose/email-update-event';
export const EmailUpdateEventSchema = new Schema<IEmailUpdateEvent, EmailUpdateEventModel, IEmailUpdateEventMethods>({
old: String,
new: String,
on: Date
});
export const EmailUpdateEvent = model<IEmailUpdateEvent, EmailUpdateEventModel>('EmailUpdateEvent', EmailUpdateEventSchema);

View File

@@ -16,6 +16,7 @@ import { IndependentServiceToken } from '@/models/independent-service-token';
import { NEXToken } from '@/models/nex-token';
import { OAuthToken } from '@/models/oauth-token';
import { PasswordResetToken } from '@/models/password-reset-token';
import { EmailUpdateEventSchema } from '@/models/email-update-event';
import type { IPNID, IPNIDMethods, PNIDModel } from '@/types/mongoose/pnid';
import type { PNIDPermissionFlag } from '@/types/common/permission-flags';
@@ -82,7 +83,8 @@ const PNIDSchema = new Schema<IPNID, PNIDModel, IPNIDMethods>({
reachable: Boolean,
validated: Boolean,
validated_date: String,
id: Number
id: Number,
history: [EmailUpdateEventSchema]
},
region: Number,
timezone: {

View File

@@ -10,7 +10,8 @@ const TOKEN_REQUIRED_PATHS = [
'/api.v2.ApiService/ResetPassword', // * This paths token is not an authentication token, it is a password reset token
'/api.v2.ApiService/SetDiscordConnectionData',
'/api.v2.ApiService/SetStripeConnectionData',
'/api.v2.ApiService/RemoveConnection'
'/api.v2.ApiService/RemoveConnection',
'/api.v2.ApiService/UpdateEmail'
];
export type AuthenticationCallContextExt = {

View File

@@ -28,6 +28,7 @@ export async function getUserData(_request: Empty, context: CallContext & Authen
timezone: pnid.timezone.name,
language: pnid.language,
emailAddress: pnid.email.address,
emailValidated: pnid.email.validated,
connections: {
discord: {
id: pnid.connections.discord.id

View File

@@ -2,6 +2,8 @@ import { register } from '@/services/grpc/api/v2/register';
import { login } from '@/services/grpc/api/v2/login';
import { getUserData } from '@/services/grpc/api/v2/get-user-data';
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 { resetPassword } from '@/services/grpc/api/v2/reset-password';
import { setDiscordConnectionData } from '@/services/grpc/api/v2/set-discord-connection-data';
@@ -13,6 +15,8 @@ export const apiServiceImplementationV2 = {
login,
getUserData,
updateUserData,
updateEmail,
verifyEmail,
forgotPassword,
resetPassword,
setDiscordConnectionData,

View File

@@ -0,0 +1,52 @@
import crypto from 'node:crypto';
import validator from 'validator';
import { ServerError, Status } from 'nice-grpc';
import { sendConfirmationEmail } from '@/util';
import type { CallContext } from 'nice-grpc';
import type {
UpdateEmailRequest,
UpdateEmailResponse
} from '@pretendonetwork/grpc/api/v2/update_email_rpc';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v1/authentication-middleware';
export async function updateEmail(
request: UpdateEmailRequest,
context: CallContext & AuthenticationCallContextExt
): Promise<UpdateEmailResponse> {
// * This is asserted in authentication-middleware, we know this is never null
const pnid = context.pnid!;
const newEmail = request.email?.trim().toLowerCase();
if (!newEmail) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Must provide new email address');
}
if (!validator.isEmail(newEmail)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid email address');
}
/* We allow the new email to equal the old email, and treat this as a verification email resend request
if (newEmail === pnid.email.address) {
throw new ServerError(Status.INVALID_ARGUMENT, 'New email address must differ from current');
}
*/
const emailUpdateEvent = { old: pnid.email.address, new: newEmail, on: new Date() };
pnid.email.history.unshift(emailUpdateEvent);
pnid.email.address = newEmail;
pnid.email.reachable = false;
pnid.email.validated = false;
pnid.email.validated_date = '';
pnid.email.id = crypto.randomBytes(4).readUInt32LE();
await pnid.generateEmailValidationCode();
await pnid.generateEmailValidationToken();
await sendConfirmationEmail(pnid);
await pnid.save();
return {};
}

View File

@@ -160,6 +160,7 @@ export async function updateUserData(
timezone: pnid.timezone.name,
language: pnid.language,
emailAddress: pnid.email.address,
emailValidated: pnid.email.validated,
connections: {
discord: {
id: pnid.connections.discord.id

View File

@@ -0,0 +1,39 @@
import moment from 'moment';
import { ServerError, Status } from 'nice-grpc';
import { sendEmailConfirmedEmail } from '@/util';
import { PNID } from '@/models/pnid';
import type {
VerifyEmailRequest,
VerifyEmailResponse
} from '@pretendonetwork/grpc/api/v2/verify_email_rpc';
export async function verifyEmail(
request: VerifyEmailRequest
): Promise<VerifyEmailResponse> {
const token = request?.token?.trim();
if (!token) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing email token');
}
const pnid = await PNID.findOne({
'identification.email_token': token
});
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid email token');
}
if (!pnid.email.validated) {
const validatedDate = moment().format('YYYY-MM-DDTHH:MM:SS');
pnid.email.reachable = true;
pnid.email.validated = true;
pnid.email.validated_date = validatedDate;
await pnid.save();
await sendEmailConfirmedEmail(pnid);
}
return {};
}

View File

@@ -220,9 +220,13 @@ router.post('/update', async function (request: express.Request, response: expre
pnid.server_access_level = environment;
}
if (person.data.email.trim().toLowerCase() !== pnid.email.address) {
const newEmail = person.data.email.trim().toLowerCase();
if (newEmail !== pnid.email.address) {
pnid.email.history.unshift({ old: pnid.email.address, new: newEmail, on: new Date() });
// TODO - Better email check
pnid.email.address = person.data.email.trim().toLowerCase();
pnid.email.address = newEmail;
pnid.email.reachable = false;
pnid.email.validated = false;
pnid.email.validated_date = '';

View File

@@ -0,0 +1,15 @@
import type { Model, HydratedDocument } from 'mongoose';
export interface IEmailUpdateEvent {
new: string;
old: string;
on: Date;
}
export interface IEmailUpdateEventMethods {}
interface IEmailUpdateEventQueryHelpers {}
export interface EmailUpdateEventModel extends Model<IEmailUpdateEvent, IEmailUpdateEventQueryHelpers, IEmailUpdateEventMethods> {}
export type HydratedEmailUpdateDocument = HydratedDocument<IEmailUpdateEvent, IEmailUpdateEventMethods>;

View File

@@ -1,5 +1,6 @@
import type { Model, Types, HydratedDocument } from 'mongoose';
import type { IDevice } from '@/types/mongoose/device';
import type { IEmailUpdateEvent } from '@/types/mongoose/email-update-event';
import type { PNIDPermissionFlag } from '@/types/common/permission-flags';
export interface IPNID {
@@ -27,6 +28,7 @@ export interface IPNID {
validated: boolean;
validated_date: string;
id: number;
history: Types.DocumentArray<IEmailUpdateEvent>;
};
region: number;
timezone: {

View File

@@ -129,8 +129,8 @@ export function nascError(errorCode: string): URLSearchParams {
export async function sendConfirmationEmail(pnid: mongoose.HydratedDocument<IPNID, IPNIDMethods>): Promise<void> {
const email = new CreateEmail()
.addHeader('Hello {{pnid}}!', { pnid: pnid.username })
.addParagraph('Your <b>Pretendo Network ID</b> activation is almost complete. Please click the link below to confirm your e-mail address and complete the activation process.')
.addButton('Confirm email address', `https://api.pretendo.cc/v1/email/verify?token=${pnid.identification.email_token}`)
.addParagraph('Please click the link below to confirm your e-mail address.')
.addButton('Confirm email address', `${config.website_base}/account/verify-email?token=${pnid.identification.email_token}`)
.addParagraph('You may also enter the following 6-digit code on your console:')
.addButton(pnid.identification.email_code, '', false)
.addParagraph('We hope you have fun using our services!');
@@ -145,18 +145,36 @@ export async function sendConfirmationEmail(pnid: mongoose.HydratedDocument<IPNI
}
export async function sendEmailConfirmedEmail(pnid: mongoose.HydratedDocument<IPNID, IPNIDMethods>): Promise<void> {
const email = new CreateEmail()
const noticeEmail = new CreateEmail()
.addHeader('Dear {{pnid}}!', { pnid: pnid.username })
.addParagraph('Your email address has been confirmed.')
.addParagraph('We hope you have fun on Pretendo Network!');
const options = {
const noticeOptions = {
to: pnid.email.address,
subject: '[Pretendo Network] Email address confirmed',
email
email: noticeEmail
};
await sendMail(options);
await sendMail(noticeOptions);
if (pnid.email.history.length > 0) {
// we can just grab the latest email update event, since it's guaranteed to be the relevant one (or the tokens wouldn't be valid)
const emailUpdateEvent = pnid.email.history[0];
const warningEmail = new CreateEmail()
.addHeader('Dear {{pnid}},', { pnid: pnid.username })
.addParagraph('your email address has been changed.')
.addParagraph('If this wasn\'t you, contact [support@pretendo.network](mailto:support@pretendo.network).');
const warningOptions = {
to: emailUpdateEvent.old,
subject: '[Pretendo Network] Email address changed',
email: warningEmail
};
await sendMail(warningOptions);
}
}
export async function sendEmailConfirmedParentalControlsEmail(pnid: mongoose.HydratedDocument<IPNID, IPNIDMethods>): Promise<void> {
@@ -191,7 +209,7 @@ export async function sendForgotPasswordEmail(pnid: mongoose.HydratedDocument<IP
const email = new CreateEmail()
.addHeader('Dear {{pnid}},', { pnid: pnid.username })
.addParagraph('a password reset has been requested from this account.')
.addParagraph('If you did not request the password reset, please ignore this email. If you did request this password reset, please click the link below to reset your password.')
.addParagraph('If you did not request the password reset, please ignore this email. Otherwise, please click the link below to reset your password.')
.addButton('Reset password', `${config.website_base}/account/reset-password?token=${encodeURIComponent(token)}`);
const mailerOptions = {