Browse Source

feat(auth): add sending email to old email address when the address is changed (#894)

Karol Sójko 1 year ago
parent
commit
eb8c704d84

+ 17 - 2
packages/auth/src/Bootstrap/Container.ts

@@ -274,6 +274,7 @@ import { TypeORMSetting } from '../Infra/TypeORM/TypeORMSetting'
 import { SettingPersistenceMapper } from '../Mapping/Persistence/SettingPersistenceMapper'
 import { SettingPersistenceMapper } from '../Mapping/Persistence/SettingPersistenceMapper'
 import { SubscriptionSettingPersistenceMapper } from '../Mapping/Persistence/SubscriptionSettingPersistenceMapper'
 import { SubscriptionSettingPersistenceMapper } from '../Mapping/Persistence/SubscriptionSettingPersistenceMapper'
 import { ApplyDefaultSettings } from '../Domain/UseCase/ApplyDefaultSettings/ApplyDefaultSettings'
 import { ApplyDefaultSettings } from '../Domain/UseCase/ApplyDefaultSettings/ApplyDefaultSettings'
+import { AuthResponseFactoryResolverInterface } from '../Domain/Auth/AuthResponseFactoryResolverInterface'
 
 
 export class ContainerConfigLoader {
 export class ContainerConfigLoader {
   constructor(private mode: 'server' | 'worker' = 'server') {}
   constructor(private mode: 'server' | 'worker' = 'server') {}
@@ -723,7 +724,9 @@ export class ContainerConfigLoader {
     container.bind<AuthResponseFactory20161215>(TYPES.Auth_AuthResponseFactory20161215).to(AuthResponseFactory20161215)
     container.bind<AuthResponseFactory20161215>(TYPES.Auth_AuthResponseFactory20161215).to(AuthResponseFactory20161215)
     container.bind<AuthResponseFactory20190520>(TYPES.Auth_AuthResponseFactory20190520).to(AuthResponseFactory20190520)
     container.bind<AuthResponseFactory20190520>(TYPES.Auth_AuthResponseFactory20190520).to(AuthResponseFactory20190520)
     container.bind<AuthResponseFactory20200115>(TYPES.Auth_AuthResponseFactory20200115).to(AuthResponseFactory20200115)
     container.bind<AuthResponseFactory20200115>(TYPES.Auth_AuthResponseFactory20200115).to(AuthResponseFactory20200115)
-    container.bind<AuthResponseFactoryResolver>(TYPES.Auth_AuthResponseFactoryResolver).to(AuthResponseFactoryResolver)
+    container
+      .bind<AuthResponseFactoryResolverInterface>(TYPES.Auth_AuthResponseFactoryResolver)
+      .to(AuthResponseFactoryResolver)
     container.bind<KeyParamsFactory>(TYPES.Auth_KeyParamsFactory).to(KeyParamsFactory)
     container.bind<KeyParamsFactory>(TYPES.Auth_KeyParamsFactory).to(KeyParamsFactory)
     container
     container
       .bind<TokenDecoderInterface<SessionTokenData>>(TYPES.Auth_SessionTokenDecoder)
       .bind<TokenDecoderInterface<SessionTokenData>>(TYPES.Auth_SessionTokenDecoder)
@@ -1020,7 +1023,19 @@ export class ContainerConfigLoader {
     container.bind<GetActiveSessionsForUser>(TYPES.Auth_GetActiveSessionsForUser).to(GetActiveSessionsForUser)
     container.bind<GetActiveSessionsForUser>(TYPES.Auth_GetActiveSessionsForUser).to(GetActiveSessionsForUser)
     container.bind<DeleteOtherSessionsForUser>(TYPES.Auth_DeleteOtherSessionsForUser).to(DeleteOtherSessionsForUser)
     container.bind<DeleteOtherSessionsForUser>(TYPES.Auth_DeleteOtherSessionsForUser).to(DeleteOtherSessionsForUser)
     container.bind<DeleteSessionForUser>(TYPES.Auth_DeleteSessionForUser).to(DeleteSessionForUser)
     container.bind<DeleteSessionForUser>(TYPES.Auth_DeleteSessionForUser).to(DeleteSessionForUser)
-    container.bind<ChangeCredentials>(TYPES.Auth_ChangeCredentials).to(ChangeCredentials)
+    container
+      .bind<ChangeCredentials>(TYPES.Auth_ChangeCredentials)
+      .toConstantValue(
+        new ChangeCredentials(
+          container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
+          container.get<AuthResponseFactoryResolverInterface>(TYPES.Auth_AuthResponseFactoryResolver),
+          container.get<DomainEventPublisherInterface>(TYPES.Auth_DomainEventPublisher),
+          container.get<DomainEventFactoryInterface>(TYPES.Auth_DomainEventFactory),
+          container.get<TimerInterface>(TYPES.Auth_Timer),
+          container.get<DeleteOtherSessionsForUser>(TYPES.Auth_DeleteOtherSessionsForUser),
+          container.get<winston.Logger>(TYPES.Auth_Logger),
+        ),
+      )
     container
     container
       .bind<GetSettings>(TYPES.Auth_GetSettings)
       .bind<GetSettings>(TYPES.Auth_GetSettings)
       .toConstantValue(
       .toConstantValue(

+ 9 - 0
packages/auth/src/Domain/Email/UserEmailChanged.ts

@@ -0,0 +1,9 @@
+import { html } from './user-email-changed.html'
+
+export function getSubject(): string {
+  return 'Confirmation: Your Email Address Has Been Successfully Updated'
+}
+
+export function getBody(newEmail: string): string {
+  return html(newEmail)
+}

+ 14 - 0
packages/auth/src/Domain/Email/user-email-changed.html.ts

@@ -0,0 +1,14 @@
+export const html = (newEmail: string) => `
+<p>Dear User,</p>
+<p>We trust you're doing well. We are writing to inform you that your request to update your email address in our application has been successfully processed. Your email address associated with your Standard Notes account has now been changed to the following:</p>
+<p>New Email Address: ${newEmail}</p>
+<p>We understand that email address changes may occur for various reasons, and we appreciate your diligence in ensuring your account information is up to date.</p>
+<p>From now on, you can log in to your account using your new email address. Your password and all other account details remain the same. If you did not initiate this change or have any concerns about the update, please contact our support team by visiting our <a href="https://standardnotes.com/help">help page</a>
+or by replying directly to this email.</p>
+<p>We thank you for choosing Standard Notes, and we are committed to providing you with the best experience possible. Should you have any questions or require further assistance, please do not hesitate to reach out to our support team.</p>
+<p>Your satisfaction and security are our top priorities, and we appreciate your continued trust in our services.</p>
+<p>Best regards,</p>
+<p>
+Standard Notes
+</p>
+`

+ 7 - 1
packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.spec.ts

@@ -15,6 +15,7 @@ import { Result, Username } from '@standardnotes/domain-core'
 import { DeleteOtherSessionsForUser } from '../DeleteOtherSessionsForUser'
 import { DeleteOtherSessionsForUser } from '../DeleteOtherSessionsForUser'
 import { ApiVersion } from '../../Api/ApiVersion'
 import { ApiVersion } from '../../Api/ApiVersion'
 import { Session } from '../../Session/Session'
 import { Session } from '../../Session/Session'
+import { Logger } from 'winston'
 
 
 describe('ChangeCredentials', () => {
 describe('ChangeCredentials', () => {
   let userRepository: UserRepositoryInterface
   let userRepository: UserRepositoryInterface
@@ -25,6 +26,7 @@ describe('ChangeCredentials', () => {
   let timer: TimerInterface
   let timer: TimerInterface
   let user: User
   let user: User
   let deleteOtherSessionsForUser: DeleteOtherSessionsForUser
   let deleteOtherSessionsForUser: DeleteOtherSessionsForUser
+  let logger: Logger
 
 
   const createUseCase = () =>
   const createUseCase = () =>
     new ChangeCredentials(
     new ChangeCredentials(
@@ -34,9 +36,13 @@ describe('ChangeCredentials', () => {
       domainEventFactory,
       domainEventFactory,
       timer,
       timer,
       deleteOtherSessionsForUser,
       deleteOtherSessionsForUser,
+      logger,
     )
     )
 
 
   beforeEach(() => {
   beforeEach(() => {
+    logger = {} as jest.Mocked<Logger>
+    logger.error = jest.fn()
+
     authResponseFactory = {} as jest.Mocked<AuthResponseFactoryInterface>
     authResponseFactory = {} as jest.Mocked<AuthResponseFactoryInterface>
     authResponseFactory.createResponse = jest
     authResponseFactory.createResponse = jest
       .fn()
       .fn()
@@ -51,7 +57,7 @@ describe('ChangeCredentials', () => {
     user.email = 'test@test.te'
     user.email = 'test@test.te'
 
 
     userRepository = {} as jest.Mocked<UserRepositoryInterface>
     userRepository = {} as jest.Mocked<UserRepositoryInterface>
-    userRepository.save = jest.fn()
+    userRepository.save = jest.fn().mockImplementation((user: User) => Promise.resolve(user))
     userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(user)
     userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(user)
 
 
     domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
     domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>

+ 28 - 10
packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.ts

@@ -1,10 +1,8 @@
 import * as bcrypt from 'bcryptjs'
 import * as bcrypt from 'bcryptjs'
-import { inject, injectable } from 'inversify'
 import { DomainEventPublisherInterface, UserEmailChangedEvent } from '@standardnotes/domain-events'
 import { DomainEventPublisherInterface, UserEmailChangedEvent } from '@standardnotes/domain-events'
 import { TimerInterface } from '@standardnotes/time'
 import { TimerInterface } from '@standardnotes/time'
-import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core'
+import { EmailLevel, Result, UseCaseInterface, Username } from '@standardnotes/domain-core'
 
 
-import TYPES from '../../../Bootstrap/Types'
 import { AuthResponseFactoryResolverInterface } from '../../Auth/AuthResponseFactoryResolverInterface'
 import { AuthResponseFactoryResolverInterface } from '../../Auth/AuthResponseFactoryResolverInterface'
 import { User } from '../../User/User'
 import { User } from '../../User/User'
 import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
 import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
@@ -14,18 +12,18 @@ import { DeleteOtherSessionsForUser } from '../DeleteOtherSessionsForUser'
 import { AuthResponse20161215 } from '../../Auth/AuthResponse20161215'
 import { AuthResponse20161215 } from '../../Auth/AuthResponse20161215'
 import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
 import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
 import { Session } from '../../Session/Session'
 import { Session } from '../../Session/Session'
+import { getBody, getSubject } from '../../Email/UserEmailChanged'
+import { Logger } from 'winston'
 
 
-@injectable()
 export class ChangeCredentials implements UseCaseInterface<AuthResponse20161215 | AuthResponse20200115> {
 export class ChangeCredentials implements UseCaseInterface<AuthResponse20161215 | AuthResponse20200115> {
   constructor(
   constructor(
-    @inject(TYPES.Auth_UserRepository) private userRepository: UserRepositoryInterface,
-    @inject(TYPES.Auth_AuthResponseFactoryResolver)
+    private userRepository: UserRepositoryInterface,
     private authResponseFactoryResolver: AuthResponseFactoryResolverInterface,
     private authResponseFactoryResolver: AuthResponseFactoryResolverInterface,
-    @inject(TYPES.Auth_DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface,
-    @inject(TYPES.Auth_DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
-    @inject(TYPES.Auth_Timer) private timer: TimerInterface,
-    @inject(TYPES.Auth_DeleteOtherSessionsForUser)
+    private domainEventPublisher: DomainEventPublisherInterface,
+    private domainEventFactory: DomainEventFactoryInterface,
+    private timer: TimerInterface,
     private deleteOtherSessionsForUserUseCase: DeleteOtherSessionsForUser,
     private deleteOtherSessionsForUserUseCase: DeleteOtherSessionsForUser,
+    private logger: Logger,
   ) {}
   ) {}
 
 
   async execute(dto: ChangeCredentialsDTO): Promise<Result<AuthResponse20161215 | AuthResponse20200115>> {
   async execute(dto: ChangeCredentialsDTO): Promise<Result<AuthResponse20161215 | AuthResponse20200115>> {
@@ -41,6 +39,7 @@ export class ChangeCredentials implements UseCaseInterface<AuthResponse20161215
     user.encryptedPassword = await bcrypt.hash(dto.newPassword, User.PASSWORD_HASH_COST)
     user.encryptedPassword = await bcrypt.hash(dto.newPassword, User.PASSWORD_HASH_COST)
 
 
     let userEmailChangedEvent: UserEmailChangedEvent | undefined = undefined
     let userEmailChangedEvent: UserEmailChangedEvent | undefined = undefined
+    const existingEmailAddress = user.email
     if (dto.newEmail !== undefined) {
     if (dto.newEmail !== undefined) {
       const newUsernameOrError = Username.create(dto.newEmail)
       const newUsernameOrError = Username.create(dto.newEmail)
       if (newUsernameOrError.isFailed()) {
       if (newUsernameOrError.isFailed()) {
@@ -78,6 +77,8 @@ export class ChangeCredentials implements UseCaseInterface<AuthResponse20161215
 
 
     if (userEmailChangedEvent !== undefined) {
     if (userEmailChangedEvent !== undefined) {
       await this.domainEventPublisher.publish(userEmailChangedEvent)
       await this.domainEventPublisher.publish(userEmailChangedEvent)
+
+      await this.sendEmailChangedNotification(existingEmailAddress, updatedUser.email)
     }
     }
 
 
     const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(dto.apiVersion)
     const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(dto.apiVersion)
@@ -113,4 +114,21 @@ export class ChangeCredentials implements UseCaseInterface<AuthResponse20161215
       })
       })
     }
     }
   }
   }
+
+  private async sendEmailChangedNotification(oldEmail: string, newEmail: string): Promise<void> {
+    try {
+      await this.domainEventPublisher.publish(
+        this.domainEventFactory.createEmailRequestedEvent({
+          userEmail: oldEmail,
+          level: EmailLevel.LEVELS.System,
+          body: getBody(newEmail),
+          messageIdentifier: 'EMAIL_CHANGED',
+          subject: getSubject(),
+        }),
+      )
+    } catch (error) {
+      /* istanbul ignore next */
+      this.logger.error(`Could not publish email changed request for email: ${(error as Error).message}`)
+    }
+  }
 }
 }