mirror of
https://github.com/PretendoNetwork/account.git
synced 2026-09-30 14:18:22 -05:00
feat: add updateEmail/verifyEmail grpc, track email changes
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
10
src/models/email-update-event.ts
Normal file
10
src/models/email-update-event.ts
Normal 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);
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
52
src/services/grpc/api/v2/update-email.ts
Normal file
52
src/services/grpc/api/v2/update-email.ts
Normal 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 {};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
39
src/services/grpc/api/v2/verify-email.ts
Normal file
39
src/services/grpc/api/v2/verify-email.ts
Normal 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 {};
|
||||
}
|
||||
@@ -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 = '';
|
||||
|
||||
15
src/types/mongoose/email-update-event.ts
Normal file
15
src/types/mongoose/email-update-event.ts
Normal 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>;
|
||||
@@ -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: {
|
||||
|
||||
32
src/util.ts
32
src/util.ts
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user