diff --git a/package-lock.json b/package-lock.json index f3f6b03..cc39094 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.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", diff --git a/package.json b/package.json index 6bf0d55..3d00970 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.7", + "@pretendonetwork/grpc": "^2.5.10", "bcrypt": "^5.0.0", "buffer-crc32": "^0.2.13", "colors": "^1.4.0", diff --git a/src/models/email-update-event.ts b/src/models/email-update-event.ts new file mode 100644 index 0000000..6ee332f --- /dev/null +++ b/src/models/email-update-event.ts @@ -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({ + old: String, + new: String, + on: Date +}); + +export const EmailUpdateEvent = model('EmailUpdateEvent', EmailUpdateEventSchema); diff --git a/src/models/pnid.ts b/src/models/pnid.ts index e15dcf8..e433581 100644 --- a/src/models/pnid.ts +++ b/src/models/pnid.ts @@ -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({ reachable: Boolean, validated: Boolean, validated_date: String, - id: Number + id: Number, + history: [EmailUpdateEventSchema] }, region: Number, timezone: { diff --git a/src/services/grpc/api/v2/authentication-middleware.ts b/src/services/grpc/api/v2/authentication-middleware.ts index 6c90561..13521b0 100644 --- a/src/services/grpc/api/v2/authentication-middleware.ts +++ b/src/services/grpc/api/v2/authentication-middleware.ts @@ -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 = { diff --git a/src/services/grpc/api/v2/get-user-data.ts b/src/services/grpc/api/v2/get-user-data.ts index 39ad73c..3d54708 100644 --- a/src/services/grpc/api/v2/get-user-data.ts +++ b/src/services/grpc/api/v2/get-user-data.ts @@ -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 diff --git a/src/services/grpc/api/v2/implementation.ts b/src/services/grpc/api/v2/implementation.ts index e06994e..219314c 100644 --- a/src/services/grpc/api/v2/implementation.ts +++ b/src/services/grpc/api/v2/implementation.ts @@ -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, diff --git a/src/services/grpc/api/v2/update-email.ts b/src/services/grpc/api/v2/update-email.ts new file mode 100644 index 0000000..5498664 --- /dev/null +++ b/src/services/grpc/api/v2/update-email.ts @@ -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 { + // * 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 {}; +} diff --git a/src/services/grpc/api/v2/update-user-data.ts b/src/services/grpc/api/v2/update-user-data.ts index f9d682b..e24280b 100644 --- a/src/services/grpc/api/v2/update-user-data.ts +++ b/src/services/grpc/api/v2/update-user-data.ts @@ -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 diff --git a/src/services/grpc/api/v2/verify-email.ts b/src/services/grpc/api/v2/verify-email.ts new file mode 100644 index 0000000..2407553 --- /dev/null +++ b/src/services/grpc/api/v2/verify-email.ts @@ -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 { + 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 {}; +} diff --git a/src/services/nnas/routes/account-settings.ts b/src/services/nnas/routes/account-settings.ts index 8162ac4..51b2b9d 100644 --- a/src/services/nnas/routes/account-settings.ts +++ b/src/services/nnas/routes/account-settings.ts @@ -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 = ''; diff --git a/src/types/mongoose/email-update-event.ts b/src/types/mongoose/email-update-event.ts new file mode 100644 index 0000000..f2cd1ee --- /dev/null +++ b/src/types/mongoose/email-update-event.ts @@ -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 {} + +export type HydratedEmailUpdateDocument = HydratedDocument; diff --git a/src/types/mongoose/pnid.ts b/src/types/mongoose/pnid.ts index 04431ad..1e6756c 100644 --- a/src/types/mongoose/pnid.ts +++ b/src/types/mongoose/pnid.ts @@ -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; }; region: number; timezone: { diff --git a/src/util.ts b/src/util.ts index fa6da85..8fc73e5 100644 --- a/src/util.ts +++ b/src/util.ts @@ -129,8 +129,8 @@ export function nascError(errorCode: string): URLSearchParams { export async function sendConfirmationEmail(pnid: mongoose.HydratedDocument): Promise { const email = new CreateEmail() .addHeader('Hello {{pnid}}!', { pnid: pnid.username }) - .addParagraph('Your Pretendo Network ID 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): Promise { - 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): Promise { @@ -191,7 +209,7 @@ export async function sendForgotPasswordEmail(pnid: mongoose.HydratedDocument