From 54af28aa0421136f6605487b03481ebe37975947 Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Tue, 26 Aug 2025 09:04:03 -0300 Subject: [PATCH] chore: Add serverPassword param to endpoints (#2919) [skip e2e] * chore: send server password param to delete account endpoint * chore: send server password param to disable mfa endpoint * chore: modify tests * chore: force challenge prompt for mfa disable * chore: fix eslint errors * chore: add server passsword to get recovery codes * chore: fix tests * chore: pass server password as header --- .../src/Domain/Client/Auth/AuthApiService.ts | 8 ++- .../Client/Auth/AuthApiServiceInterface.ts | 2 +- .../src/Domain/Client/User/UserApiService.ts | 14 +++-- .../Client/User/UserApiServiceInterface.ts | 5 +- packages/api/src/Domain/Http/HttpService.ts | 3 + .../GenerateRecoveryCodesRequestParams.ts | 3 + packages/api/src/Domain/Request/index.ts | 1 + .../api/src/Domain/Server/Auth/AuthServer.ts | 5 +- .../Domain/Server/Auth/AuthServerInterface.ts | 3 +- .../api/src/Domain/Server/User/UserServer.ts | 8 ++- .../Domain/Server/User/UserServerInterface.ts | 6 +- .../src/Domain/Auth/AuthClientInterface.ts | 2 +- .../services/src/Domain/Auth/AuthManager.ts | 4 +- .../Protection/ProtectionClientInterface.ts | 9 ++- .../services/src/Domain/User/UserService.ts | 18 +++--- .../Application/Dependencies/Dependencies.ts | 2 + .../GetRecoveryCodes/GetRecoveryCodes.spec.ts | 15 +++-- .../GetRecoveryCodes/GetRecoveryCodes.ts | 23 ++++++- .../GetRecoveryCodes/GetRecoveryCodesDTO.ts | 3 + packages/snjs/lib/Services/Api/ApiService.ts | 16 ++++- packages/snjs/lib/Services/Mfa/MfaService.ts | 20 +++++- .../Services/Protection/ProtectionService.ts | 43 ++++++++++--- .../Services/Settings/SNSettingsService.ts | 8 +-- .../Settings/SettingsClientInterface.ts | 4 +- .../lib/Services/Settings/SettingsGateway.ts | 8 +-- .../Settings/SettingsServerInterface.ts | 12 +++- packages/snjs/mocha/auth.test.js | 48 ++++++++++++++- packages/snjs/mocha/mfa_service.test.js | 61 +++++++++++++++++++ .../RecoveryCodeBanner/RecoveryCodeBanner.tsx | 6 +- 29 files changed, 298 insertions(+), 62 deletions(-) create mode 100644 packages/api/src/Domain/Request/Recovery/GenerateRecoveryCodesRequestParams.ts create mode 100644 packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodesDTO.ts diff --git a/packages/api/src/Domain/Client/Auth/AuthApiService.ts b/packages/api/src/Domain/Client/Auth/AuthApiService.ts index 8a0113523..00033762c 100644 --- a/packages/api/src/Domain/Client/Auth/AuthApiService.ts +++ b/packages/api/src/Domain/Client/Auth/AuthApiService.ts @@ -22,7 +22,9 @@ export class AuthApiService implements AuthApiServiceInterface { this.operationsInProgress = new Map() } - async generateRecoveryCodes(): Promise> { + async generateRecoveryCodes(dto: { + serverPassword: string + }): Promise> { if (this.operationsInProgress.get(AuthApiOperations.GenerateRecoveryCodes)) { throw new ApiCallError(ErrorMessage.GenericInProgress) } @@ -30,7 +32,9 @@ export class AuthApiService implements AuthApiServiceInterface { this.operationsInProgress.set(AuthApiOperations.GenerateRecoveryCodes, true) try { - const response = await this.authServer.generateRecoveryCodes() + const response = await this.authServer.generateRecoveryCodes({ + headers: [{ key: 'x-server-password', value: dto.serverPassword }], + }) return response } catch (error) { diff --git a/packages/api/src/Domain/Client/Auth/AuthApiServiceInterface.ts b/packages/api/src/Domain/Client/Auth/AuthApiServiceInterface.ts index a2bb31cd0..804a32717 100644 --- a/packages/api/src/Domain/Client/Auth/AuthApiServiceInterface.ts +++ b/packages/api/src/Domain/Client/Auth/AuthApiServiceInterface.ts @@ -6,7 +6,7 @@ import { } from '../../Response' export interface AuthApiServiceInterface { - generateRecoveryCodes(): Promise> + generateRecoveryCodes(dto: { serverPassword: string }): Promise> recoveryKeyParams(dto: { username: string codeChallenge: string diff --git a/packages/api/src/Domain/Client/User/UserApiService.ts b/packages/api/src/Domain/Client/User/UserApiService.ts index 98b112fb6..894fee7a2 100644 --- a/packages/api/src/Domain/Client/User/UserApiService.ts +++ b/packages/api/src/Domain/Client/User/UserApiService.ts @@ -27,13 +27,19 @@ export class UserApiService implements UserApiServiceInterface { this.operationsInProgress = new Map() } - async deleteAccount(userUuid: string): Promise> { + async deleteAccount(dto: { + userUuid: string + serverPassword: string + }): Promise> { this.lockOperation(UserApiOperations.DeletingAccount) try { - const response = await this.userServer.deleteAccount({ - userUuid: userUuid, - }) + const response = await this.userServer.deleteAccount( + { + userUuid: dto.userUuid, + }, + { headers: [{ key: 'x-server-password', value: dto.serverPassword }] }, + ) this.unlockOperation(UserApiOperations.DeletingAccount) diff --git a/packages/api/src/Domain/Client/User/UserApiServiceInterface.ts b/packages/api/src/Domain/Client/User/UserApiServiceInterface.ts index a574dd865..34927a7fa 100644 --- a/packages/api/src/Domain/Client/User/UserApiServiceInterface.ts +++ b/packages/api/src/Domain/Client/User/UserApiServiceInterface.ts @@ -22,5 +22,8 @@ export interface UserApiServiceInterface { requestType: UserRequestType }): Promise> - deleteAccount(userUuid: string): Promise> + deleteAccount(dto: { + userUuid: string + serverPassword: string | undefined + }): Promise> } diff --git a/packages/api/src/Domain/Http/HttpService.ts b/packages/api/src/Domain/Http/HttpService.ts index 3d10bdddd..891e583b3 100644 --- a/packages/api/src/Domain/Http/HttpService.ts +++ b/packages/api/src/Domain/Http/HttpService.ts @@ -91,6 +91,7 @@ export class HttpService implements HttpServiceInterface { params, verb: HttpVerb.Get, authentication: options?.authentication ?? this.getSessionAccessToken(), + customHeaders: options?.headers, }) } @@ -123,6 +124,7 @@ export class HttpService implements HttpServiceInterface { params, verb: HttpVerb.Put, authentication: options?.authentication ?? this.getSessionAccessToken(), + customHeaders: options?.headers, }) } @@ -141,6 +143,7 @@ export class HttpService implements HttpServiceInterface { params, verb: HttpVerb.Delete, authentication: options?.authentication ?? this.getSessionAccessToken(), + customHeaders: options?.headers, }) } diff --git a/packages/api/src/Domain/Request/Recovery/GenerateRecoveryCodesRequestParams.ts b/packages/api/src/Domain/Request/Recovery/GenerateRecoveryCodesRequestParams.ts new file mode 100644 index 000000000..9642097cd --- /dev/null +++ b/packages/api/src/Domain/Request/Recovery/GenerateRecoveryCodesRequestParams.ts @@ -0,0 +1,3 @@ +export interface GenerateRecoveryCodesRequestParams { + serverPassword: string +} diff --git a/packages/api/src/Domain/Request/index.ts b/packages/api/src/Domain/Request/index.ts index dbad01c5e..e74c1a39e 100644 --- a/packages/api/src/Domain/Request/index.ts +++ b/packages/api/src/Domain/Request/index.ts @@ -2,6 +2,7 @@ export * from './Authenticator/DeleteAuthenticatorRequestParams' export * from './Authenticator/GenerateAuthenticatorAuthenticationOptionsRequestParams' export * from './Authenticator/ListAuthenticatorsRequestParams' export * from './Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams' +export * from './Recovery/GenerateRecoveryCodesRequestParams' export * from './Recovery/RecoveryKeyParamsRequestParams' export * from './Recovery/SignInWithRecoveryCodesRequestParams' export * from './Revision/DeleteRevisionRequestParams' diff --git a/packages/api/src/Domain/Server/Auth/AuthServer.ts b/packages/api/src/Domain/Server/Auth/AuthServer.ts index e53e83c49..77dff5336 100644 --- a/packages/api/src/Domain/Server/Auth/AuthServer.ts +++ b/packages/api/src/Domain/Server/Auth/AuthServer.ts @@ -8,12 +8,13 @@ import { } from '../../Response' import { AuthServerInterface } from './AuthServerInterface' import { Paths } from './Paths' +import { HttpRequestOptions } from '../../Http/HttpRequestOptions' export class AuthServer implements AuthServerInterface { constructor(private httpService: HttpServiceInterface) {} - async generateRecoveryCodes(): Promise> { - return this.httpService.post(Paths.v1.generateRecoveryCodes) + async generateRecoveryCodes(options?: HttpRequestOptions): Promise> { + return this.httpService.post(Paths.v1.generateRecoveryCodes, undefined, options) } async recoveryKeyParams( diff --git a/packages/api/src/Domain/Server/Auth/AuthServerInterface.ts b/packages/api/src/Domain/Server/Auth/AuthServerInterface.ts index e0d38a8a8..46fd1950b 100644 --- a/packages/api/src/Domain/Server/Auth/AuthServerInterface.ts +++ b/packages/api/src/Domain/Server/Auth/AuthServerInterface.ts @@ -5,9 +5,10 @@ import { RecoveryKeyParamsResponseBody, SignInWithRecoveryCodesResponseBody, } from '../../Response' +import { HttpRequestOptions } from '../../Http/HttpRequestOptions' export interface AuthServerInterface { - generateRecoveryCodes(): Promise> + generateRecoveryCodes(options?: HttpRequestOptions): Promise> recoveryKeyParams(params: RecoveryKeyParamsRequestParams): Promise> signInWithRecoveryCodes( params: SignInWithRecoveryCodesRequestParams, diff --git a/packages/api/src/Domain/Server/User/UserServer.ts b/packages/api/src/Domain/Server/User/UserServer.ts index cafe32514..fed5c21bc 100644 --- a/packages/api/src/Domain/Server/User/UserServer.ts +++ b/packages/api/src/Domain/Server/User/UserServer.ts @@ -8,12 +8,16 @@ import { UserRegistrationResponseBody } from '../../Response/User/UserRegistrati import { Paths } from './Paths' import { UserServerInterface } from './UserServerInterface' import { UserUpdateRequestParams } from '../../Request/User/UserUpdateRequestParams' +import { HttpRequestOptions } from '../../Http/HttpRequestOptions' export class UserServer implements UserServerInterface { constructor(private httpService: HttpServiceInterface) {} - async deleteAccount(params: UserDeletionRequestParams): Promise> { - return this.httpService.delete(Paths.v1.deleteAccount(params.userUuid), params) + async deleteAccount( + params: UserDeletionRequestParams, + options?: HttpRequestOptions, + ): Promise> { + return this.httpService.delete(Paths.v1.deleteAccount(params.userUuid), params, options) } async register(params: UserRegistrationRequestParams): Promise> { diff --git a/packages/api/src/Domain/Server/User/UserServerInterface.ts b/packages/api/src/Domain/Server/User/UserServerInterface.ts index 1ce40c19d..1b2147756 100644 --- a/packages/api/src/Domain/Server/User/UserServerInterface.ts +++ b/packages/api/src/Domain/Server/User/UserServerInterface.ts @@ -5,9 +5,13 @@ import { UserDeletionResponseBody } from '../../Response/User/UserDeletionRespon import { UserRegistrationResponseBody } from '../../Response/User/UserRegistrationResponseBody' import { UserUpdateResponse } from '../../Response/User/UserUpdateResponse' import { UserUpdateRequestParams } from '../../Request/User/UserUpdateRequestParams' +import { HttpRequestOptions } from '../../Http/HttpRequestOptions' export interface UserServerInterface { register(params: UserRegistrationRequestParams): Promise> - deleteAccount(params: UserDeletionRequestParams): Promise> + deleteAccount( + params: UserDeletionRequestParams, + options?: HttpRequestOptions, + ): Promise> update(params: UserUpdateRequestParams): Promise> } diff --git a/packages/services/src/Domain/Auth/AuthClientInterface.ts b/packages/services/src/Domain/Auth/AuthClientInterface.ts index e471442e8..a3ed7a818 100644 --- a/packages/services/src/Domain/Auth/AuthClientInterface.ts +++ b/packages/services/src/Domain/Auth/AuthClientInterface.ts @@ -2,7 +2,7 @@ import { AnyKeyParamsContent } from '@standardnotes/common' import { SessionBody } from '@standardnotes/responses' export interface AuthClientInterface { - generateRecoveryCodes(): Promise + generateRecoveryCodes(dto: { serverPassword: string }): Promise recoveryKeyParams(dto: { username: string codeChallenge: string diff --git a/packages/services/src/Domain/Auth/AuthManager.ts b/packages/services/src/Domain/Auth/AuthManager.ts index 5dcb48bc5..1d3784f15 100644 --- a/packages/services/src/Domain/Auth/AuthManager.ts +++ b/packages/services/src/Domain/Auth/AuthManager.ts @@ -14,9 +14,9 @@ export class AuthManager extends AbstractService implements AuthClientInterface super(internalEventBus) } - async generateRecoveryCodes(): Promise { + async generateRecoveryCodes(dto: { serverPassword: string }): Promise { try { - const result = await this.authApiService.generateRecoveryCodes() + const result = await this.authApiService.generateRecoveryCodes(dto) if (isErrorResponse(result)) { return false diff --git a/packages/services/src/Domain/Protection/ProtectionClientInterface.ts b/packages/services/src/Domain/Protection/ProtectionClientInterface.ts index 8f8aad6e3..3437938ca 100644 --- a/packages/services/src/Domain/Protection/ProtectionClientInterface.ts +++ b/packages/services/src/Domain/Protection/ProtectionClientInterface.ts @@ -1,6 +1,6 @@ import { ApplicationServiceInterface } from './../Service/ApplicationServiceInterface' import { DecryptedItem, DecryptedItemInterface, FileItem, SNNote } from '@standardnotes/models' -import { ChallengeInterface, ChallengeReason } from '../Challenge' +import { ChallengeInterface, ChallengeReason, ChallengeResponseInterface } from '../Challenge' import { MobileUnlockTiming } from './MobileUnlockTiming' import { TimingDisplayOption } from './TimingDisplayOption' import { ProtectionEvent } from './ProtectionEvent' @@ -28,6 +28,10 @@ export interface ProtectionsClientInterface extends ApplicationServiceInterface< reason: ChallengeReason, dto: { fallBackToAccountPassword: boolean; requireAccountPassword: boolean; forcePrompt: boolean }, ): Promise + authorizeActionWithChallengeResponse( + reason: ChallengeReason, + dto: { fallBackToAccountPassword: boolean; requireAccountPassword: boolean; forcePrompt: boolean }, + ): Promise<{ success: boolean; challengeResponse?: ChallengeResponseInterface }> authorizeAddingPasscode(): Promise authorizeRemovingPasscode(): Promise authorizeChangingPasscode(): Promise @@ -36,7 +40,8 @@ export interface ProtectionsClientInterface extends ApplicationServiceInterface< authorizeAutolockIntervalChange(): Promise authorizeSearchingProtectedNotesText(): Promise authorizeBackupCreation(): Promise - authorizeMfaDisable(): Promise + authorizeMfaDisable(): Promise<{ success: boolean; challengeResponse?: ChallengeResponseInterface }> + authorizeAccountDeletion(): Promise<{ success: boolean; challengeResponse?: ChallengeResponseInterface }> protectItems(items: I[]): Promise unprotectItems(items: I[], reason: ChallengeReason): Promise diff --git a/packages/services/src/Domain/User/UserService.ts b/packages/services/src/Domain/User/UserService.ts index 1eb8b4b96..8c3d72625 100644 --- a/packages/services/src/Domain/User/UserService.ts +++ b/packages/services/src/Domain/User/UserService.ts @@ -237,13 +237,9 @@ export class UserService error: boolean message?: string }> { - if ( - !(await this.protections.authorizeAction(ChallengeReason.DeleteAccount, { - fallBackToAccountPassword: true, - requireAccountPassword: true, - forcePrompt: false, - })) - ) { + const { success, challengeResponse } = await this.protections.authorizeAccountDeletion() + + if (!success) { return { error: true, message: Messages.INVALID_PASSWORD, @@ -251,7 +247,13 @@ export class UserService } const uuid = this.sessions.getSureUser().uuid - const response = await this.userApi.deleteAccount(uuid) + const password = challengeResponse?.getValueForType(ChallengeValidation.AccountPassword).value as string + const currentRootKey = await this.encryption.computeRootKey( + password, + this.encryption.getRootKeyParams() as SNRootKeyParams, + ) + const serverPassword = currentRootKey.serverPassword + const response = await this.userApi.deleteAccount({ userUuid: uuid, serverPassword: serverPassword }) if (isErrorResponse(response)) { return { error: true, diff --git a/packages/snjs/lib/Application/Dependencies/Dependencies.ts b/packages/snjs/lib/Application/Dependencies/Dependencies.ts index c65c204c5..69197f564 100644 --- a/packages/snjs/lib/Application/Dependencies/Dependencies.ts +++ b/packages/snjs/lib/Application/Dependencies/Dependencies.ts @@ -1026,6 +1026,7 @@ export class Dependencies { return new GetRecoveryCodes( this.get(TYPES.AuthManager), this.get(TYPES.SettingsService), + this.get(TYPES.EncryptionService), ) }) @@ -1231,6 +1232,7 @@ export class Dependencies { this.get(TYPES.Crypto), this.get(TYPES.FeaturesService), this.get(TYPES.ProtectionService), + this.get(TYPES.EncryptionService), this.get(TYPES.InternalEventBus), ) }) diff --git a/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.spec.ts b/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.spec.ts index 8a9b4bbae..c4940b041 100644 --- a/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.spec.ts +++ b/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.spec.ts @@ -1,4 +1,4 @@ -import { AuthClientInterface } from '@standardnotes/services' +import { AuthClientInterface, EncryptionService } from '@standardnotes/services' import { SettingsClientInterface } from '@Lib/Services/Settings/SettingsClientInterface' import { GetRecoveryCodes } from './GetRecoveryCodes' @@ -6,8 +6,9 @@ import { GetRecoveryCodes } from './GetRecoveryCodes' describe('GetRecoveryCodes', () => { let authClient: AuthClientInterface let settingsClient: SettingsClientInterface + let encryption: EncryptionService - const createUseCase = () => new GetRecoveryCodes(authClient, settingsClient) + const createUseCase = () => new GetRecoveryCodes(authClient, settingsClient, encryption) beforeEach(() => { authClient = {} as jest.Mocked @@ -15,12 +16,16 @@ describe('GetRecoveryCodes', () => { settingsClient = {} as jest.Mocked settingsClient.getSetting = jest.fn().mockResolvedValue('existing-recovery-codes') + + encryption = {} as jest.Mocked + encryption.computeRootKey = jest.fn().mockResolvedValue({ serverPassword: 'test-server-password' }) + encryption.getRootKeyParams = jest.fn().mockReturnValue({ algorithm: 'test-algorithm' }) }) it('should return existing recovery code if they exist', async () => { const useCase = createUseCase() - const result = await useCase.execute() + const result = await useCase.execute({ password: 'test-password' }) expect(result.getValue()).toBe('existing-recovery-codes') }) @@ -30,7 +35,7 @@ describe('GetRecoveryCodes', () => { const useCase = createUseCase() - const result = await useCase.execute() + const result = await useCase.execute({ password: 'test-password' }) expect(result.getValue()).toBe('recovery-codes') }) @@ -41,7 +46,7 @@ describe('GetRecoveryCodes', () => { const useCase = createUseCase() - const result = await useCase.execute() + const result = await useCase.execute({ password: 'test-password' }) expect(result.isFailed()).toBe(true) }) diff --git a/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.ts b/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.ts index 94a9257f2..ea167ba58 100644 --- a/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.ts +++ b/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes.ts @@ -1,23 +1,40 @@ -import { AuthClientInterface } from '@standardnotes/services' +import { AuthClientInterface, EncryptionService } from '@standardnotes/services' import { Result, SettingName, UseCaseInterface } from '@standardnotes/domain-core' import { SettingsClientInterface } from '@Lib/Services/Settings/SettingsClientInterface' +import { GetRecoveryCodesDTO } from './GetRecoveryCodesDTO' +import { SNRootKeyParams } from '@standardnotes/encryption' export class GetRecoveryCodes implements UseCaseInterface { constructor( private authClient: AuthClientInterface, private settingsClient: SettingsClientInterface, + private encryption: EncryptionService, ) {} - async execute(): Promise> { + async execute(dto: GetRecoveryCodesDTO): Promise> { + if (!dto.password) { + return Result.fail('Password is required to get recovery code.') + } + const currentRootKey = await this.encryption.computeRootKey( + dto.password, + this.encryption.getRootKeyParams() as SNRootKeyParams, + ) + const serverPassword = currentRootKey.serverPassword + + if (!serverPassword) { + return Result.fail('Could not compute server password') + } + const existingRecoveryCodes = await this.settingsClient.getSetting( SettingName.create(SettingName.NAMES.RecoveryCodes).getValue(), + serverPassword, ) if (existingRecoveryCodes !== undefined) { return Result.ok(existingRecoveryCodes) } - const generatedRecoveryCodes = await this.authClient.generateRecoveryCodes() + const generatedRecoveryCodes = await this.authClient.generateRecoveryCodes({ serverPassword }) if (generatedRecoveryCodes === false) { return Result.fail('Could not generate recovery code') } diff --git a/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodesDTO.ts b/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodesDTO.ts new file mode 100644 index 000000000..0f260fb19 --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodesDTO.ts @@ -0,0 +1,3 @@ +export interface GetRecoveryCodesDTO { + password?: string +} diff --git a/packages/snjs/lib/Services/Api/ApiService.ts b/packages/snjs/lib/Services/Api/ApiService.ts index 18d4533ce..9cccbec7f 100644 --- a/packages/snjs/lib/Services/Api/ApiService.ts +++ b/packages/snjs/lib/Services/Api/ApiService.ts @@ -578,12 +578,18 @@ export class LegacyApiService }) } - async getSetting(userUuid: UuidString, settingName: string): Promise> { + async getSetting( + userUuid: UuidString, + settingName: string, + serverPassword?: string, + ): Promise> { + const customHeaders = serverPassword ? [{ key: 'x-server-password', value: serverPassword }] : undefined return await this.tokenRefreshableRequest({ verb: HttpVerb.Get, url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName.toLowerCase())), authentication: this.getSessionAccessToken(), fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS, + customHeaders, }) } @@ -616,12 +622,18 @@ export class LegacyApiService }) } - async deleteSetting(userUuid: UuidString, settingName: string): Promise> { + async deleteSetting( + userUuid: UuidString, + settingName: string, + serverPassword?: string, + ): Promise> { + const customHeaders = serverPassword ? [{ key: 'x-server-password', value: serverPassword }] : undefined return this.tokenRefreshableRequest({ verb: HttpVerb.Delete, url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName)), authentication: this.getSessionAccessToken(), fallbackErrorMessage: API_MESSAGE_FAILED_UPDATE_SETTINGS, + customHeaders, }) } diff --git a/packages/snjs/lib/Services/Mfa/MfaService.ts b/packages/snjs/lib/Services/Mfa/MfaService.ts index 495af062f..5929aa60c 100644 --- a/packages/snjs/lib/Services/Mfa/MfaService.ts +++ b/packages/snjs/lib/Services/Mfa/MfaService.ts @@ -6,9 +6,12 @@ import { InternalEventBusInterface, MfaServiceInterface, ProtectionsClientInterface, + EncryptionService, SignInStrings, + ChallengeValidation, } from '@standardnotes/services' import { SettingName } from '@standardnotes/domain-core' +import { SNRootKeyParams } from '@standardnotes/encryption' export class MfaService extends AbstractService implements MfaServiceInterface { constructor( @@ -16,6 +19,7 @@ export class MfaService extends AbstractService implements MfaServiceInterface { private crypto: PureCryptoInterface, private featuresService: FeaturesService, private protections: ProtectionsClientInterface, + private encryption: EncryptionService, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) @@ -55,11 +59,23 @@ export class MfaService extends AbstractService implements MfaServiceInterface { } async disableMfa(): Promise { - if (!(await this.protections.authorizeMfaDisable())) { + const { success, challengeResponse } = await this.protections.authorizeMfaDisable() + + if (!success) { return } - return await this.settingsService.deleteSetting(SettingName.create(SettingName.NAMES.MfaSecret).getValue()) + const password = challengeResponse?.getValueForType(ChallengeValidation.AccountPassword).value as string + const currentRootKey = await this.encryption.computeRootKey( + password, + this.encryption.getRootKeyParams() as SNRootKeyParams, + ) + const serverPassword = currentRootKey.serverPassword + + return await this.settingsService.deleteSetting( + SettingName.create(SettingName.NAMES.MfaSecret).getValue(), + serverPassword, + ) } override deinit(): void { diff --git a/packages/snjs/lib/Services/Protection/ProtectionService.ts b/packages/snjs/lib/Services/Protection/ProtectionService.ts index 0f90920b1..de827eb2f 100644 --- a/packages/snjs/lib/Services/Protection/ProtectionService.ts +++ b/packages/snjs/lib/Services/Protection/ProtectionService.ts @@ -34,6 +34,7 @@ import { import { ContentType } from '@standardnotes/domain-core' import { isValidProtectionSessionLength } from './isValidProtectionSessionLength' import { UnprotectedAccessSecondsDuration } from './UnprotectedAccessSecondsDuration' +import { ChallengeResponse } from '../Challenge' /** * Enforces certain actions to require extra authentication, @@ -246,11 +247,11 @@ export class ProtectionService }) } - async authorizeMfaDisable(): Promise { - return this.authorizeAction(ChallengeReason.DisableMfa, { + async authorizeMfaDisable(): Promise<{ success: boolean; challengeResponse?: ChallengeResponse }> { + return this.authorizeActionWithChallengeResponse(ChallengeReason.DisableMfa, { fallBackToAccountPassword: true, requireAccountPassword: true, - forcePrompt: false, + forcePrompt: true, }) } @@ -278,6 +279,14 @@ export class ProtectionService }) } + async authorizeAccountDeletion(): Promise<{ success: boolean; challengeResponse?: ChallengeResponse }> { + return this.authorizeActionWithChallengeResponse(ChallengeReason.DeleteAccount, { + fallBackToAccountPassword: true, + requireAccountPassword: true, + forcePrompt: true, + }) + } + async authorizeAction( reason: ChallengeReason, dto: { fallBackToAccountPassword: boolean; requireAccountPassword: boolean; forcePrompt: boolean }, @@ -285,6 +294,13 @@ export class ProtectionService return this.validateOrRenewSession(reason, dto) } + async authorizeActionWithChallengeResponse( + reason: ChallengeReason, + dto: { fallBackToAccountPassword: boolean; requireAccountPassword: boolean; forcePrompt: boolean }, + ): Promise<{ success: boolean; challengeResponse?: ChallengeResponse }> { + return this.validateOrRenewSessionWithChallengeResponse(reason, dto) + } + getMobilePasscodeTimingOptions(): TimingDisplayOption[] { return [ { @@ -353,8 +369,20 @@ export class ProtectionService reason: ChallengeReason, { fallBackToAccountPassword = true, requireAccountPassword = false, forcePrompt = false } = {}, ): Promise { + const response = await this.validateOrRenewSessionWithChallengeResponse(reason, { + fallBackToAccountPassword, + requireAccountPassword, + forcePrompt, + }) + return response.success + } + + private async validateOrRenewSessionWithChallengeResponse( + reason: ChallengeReason, + { fallBackToAccountPassword = true, requireAccountPassword = false, forcePrompt = false } = {}, + ): Promise<{ success: boolean; challengeResponse?: ChallengeResponse }> { if (this.getSessionExpiryDate() > new Date() && !forcePrompt) { - return true + return { success: true } } const prompts: ChallengePrompt[] = [] @@ -378,9 +406,10 @@ export class ProtectionService if (fallBackToAccountPassword && this.encryption.hasAccount()) { prompts.push(new ChallengePrompt(ChallengeValidation.AccountPassword)) } else { - return true + return { success: true } } } + const lastSessionLength = this.getLastSessionLength() const chosenSessionLength = isValidProtectionSessionLength(lastSessionLength) ? lastSessionLength @@ -407,9 +436,9 @@ export class ProtectionService } else { this.setSessionLength(length as UnprotectedAccessSecondsDuration) } - return true + return { success: true, challengeResponse: response } } else { - return false + return { success: false } } } diff --git a/packages/snjs/lib/Services/Settings/SNSettingsService.ts b/packages/snjs/lib/Services/Settings/SNSettingsService.ts index 47113914d..4b403a832 100644 --- a/packages/snjs/lib/Services/Settings/SNSettingsService.ts +++ b/packages/snjs/lib/Services/Settings/SNSettingsService.ts @@ -30,8 +30,8 @@ export class SettingsService extends AbstractService implements SettingsClientIn return this.provider.listSettings() } - async getSetting(name: SettingName) { - return this.provider.getSetting(name) + async getSetting(name: SettingName, serverPassword?: string) { + return this.provider.getSetting(name, serverPassword) } async getSubscriptionSetting(name: SettingName) { @@ -50,8 +50,8 @@ export class SettingsService extends AbstractService implements SettingsClientIn return this.provider.getDoesSensitiveSettingExist(name) } - async deleteSetting(name: SettingName) { - return this.provider.deleteSetting(name) + async deleteSetting(name: SettingName, serverPassword?: string) { + return this.provider.deleteSetting(name, serverPassword) } getEmailBackupFrequencyOptionLabel(frequency: EmailBackupFrequency): string { diff --git a/packages/snjs/lib/Services/Settings/SettingsClientInterface.ts b/packages/snjs/lib/Services/Settings/SettingsClientInterface.ts index a9e361373..0e1373f36 100644 --- a/packages/snjs/lib/Services/Settings/SettingsClientInterface.ts +++ b/packages/snjs/lib/Services/Settings/SettingsClientInterface.ts @@ -5,13 +5,13 @@ import { SettingName } from '@standardnotes/domain-core' export interface SettingsClientInterface { listSettings(): Promise - getSetting(name: SettingName): Promise + getSetting(name: SettingName, serverPassword?: string): Promise getDoesSensitiveSettingExist(name: SettingName): Promise updateSetting(name: SettingName, payload: string, sensitive?: boolean): Promise - deleteSetting(name: SettingName): Promise + deleteSetting(name: SettingName, serverPassword?: string): Promise getEmailBackupFrequencyOptionLabel(frequency: EmailBackupFrequency): string } diff --git a/packages/snjs/lib/Services/Settings/SettingsGateway.ts b/packages/snjs/lib/Services/Settings/SettingsGateway.ts index 4cdb7930b..8a7c5c577 100644 --- a/packages/snjs/lib/Services/Settings/SettingsGateway.ts +++ b/packages/snjs/lib/Services/Settings/SettingsGateway.ts @@ -45,8 +45,8 @@ export class SettingsGateway { return settings } - async getSetting(name: SettingName): Promise { - const response = await this.settingsApi.getSetting(this.userUuid, name.value) + async getSetting(name: SettingName, serverPassword?: string): Promise { + const response = await this.settingsApi.getSetting(this.userUuid, name.value, serverPassword) if (response.status === HttpStatusCode.BadRequest) { return undefined @@ -109,8 +109,8 @@ export class SettingsGateway { } } - async deleteSetting(name: SettingName): Promise { - const response = await this.settingsApi.deleteSetting(this.userUuid, name.value) + async deleteSetting(name: SettingName, serverPassword?: string): Promise { + const response = await this.settingsApi.deleteSetting(this.userUuid, name.value, serverPassword) if (isErrorResponse(response)) { throw new Error(getErrorFromErrorResponse(response).message) } diff --git a/packages/snjs/lib/Services/Settings/SettingsServerInterface.ts b/packages/snjs/lib/Services/Settings/SettingsServerInterface.ts index db3a253df..7e7af662f 100644 --- a/packages/snjs/lib/Services/Settings/SettingsServerInterface.ts +++ b/packages/snjs/lib/Services/Settings/SettingsServerInterface.ts @@ -17,7 +17,11 @@ export interface SettingsServerInterface { sensitive: boolean, ): Promise> - getSetting(userUuid: UuidString, settingName: string): Promise> + getSetting( + userUuid: UuidString, + settingName: string, + serverPassword?: string, + ): Promise> getSubscriptionSetting(userUuid: UuidString, settingName: string): Promise> @@ -28,5 +32,9 @@ export interface SettingsServerInterface { sensitive: boolean, ): Promise> - deleteSetting(userUuid: UuidString, settingName: string): Promise> + deleteSetting( + userUuid: UuidString, + settingName: string, + serverPassword?: string, + ): Promise> } diff --git a/packages/snjs/mocha/auth.test.js b/packages/snjs/mocha/auth.test.js index dc3f7e141..9c8e84352 100644 --- a/packages/snjs/mocha/auth.test.js +++ b/packages/snjs/mocha/auth.test.js @@ -549,6 +549,24 @@ describe('basic auth', function () { expect(sendChallengeSpy.callCount).to.equal(1) }).timeout(Factory.TenSecondTimeout) + it('should send server password when deleting account', async function () { + Factory.handlePasswordChallenges(context.application, context.password) + + const userApiService = context.application.dependencies.get(TYPES.UserApiService) + const deleteAccountSpy = sinon.spy(userApiService, 'deleteAccount') + + await context.application.user.deleteAccount() + + expect(deleteAccountSpy.callCount).to.equal(1) + const deleteAccountCall = deleteAccountSpy.getCall(0) + const callArgs = deleteAccountCall.args[0] + + expect(callArgs).to.have.property('serverPassword') + expect(callArgs.serverPassword).to.not.be.undefined + expect(typeof callArgs.serverPassword).to.equal('string') + expect(callArgs.serverPassword.length).to.be.above(0) + }).timeout(Factory.TenSecondTimeout) + it('deleting account should sign out current user', async function () { Factory.handlePasswordChallenges(context.application, context.password) @@ -567,12 +585,40 @@ describe('basic auth', function () { const response = await context.application.dependencies .get(TYPES.UserApiService) - .deleteAccount(registerResponse.user.uuid) + .deleteAccount({ + userUuid: registerResponse.user.uuid, + serverPassword: 'dummy-password' + }) expect(response.status).to.equal(401) expect(response.data.error.message).to.equal('Operation not allowed.') await secondContext.deinit() }) + + it('should not allow deleting account if server password is not sent', async function () { + Factory.handlePasswordChallenges(context.application, context.password) + + const response = await context.application.dependencies + .get(TYPES.UserApiService) + .deleteAccount({ + userUuid: context.application.user.uuid, + }) + + expect(response.status).to.equal(400) + }).timeout(Factory.TenSecondTimeout) + + it('should not allow deleting account if server password is incorrect', async function () { + Factory.handlePasswordChallenges(context.application, context.password) + + const response = await context.application.dependencies + .get(TYPES.UserApiService) + .deleteAccount({ + userUuid: context.application.user.uuid, + serverPassword: 'wrong-password' + }) + + expect(response.status).to.equal(400) + }).timeout(Factory.TenSecondTimeout) }) }) diff --git a/packages/snjs/mocha/mfa_service.test.js b/packages/snjs/mocha/mfa_service.test.js index 0940388a5..f09ac1f05 100644 --- a/packages/snjs/mocha/mfa_service.test.js +++ b/packages/snjs/mocha/mfa_service.test.js @@ -65,6 +65,7 @@ describe('mfa service', () => { const token = await application.mfa.getOtpToken(secret) sinon.spy(application.challenges, 'sendChallenge') + await application.mfa.enableMfa(secret, token) await application.mfa.disableMfa() @@ -73,4 +74,64 @@ describe('mfa service', () => { expect(challenge.prompts).to.have.lengthOf(2) expect(challenge.prompts[0].validation).to.equal(ChallengeValidation.AccountPassword) }).timeout(Factory.TenSecondTimeout) + + it('sends server password when disabling mfa', async () => { + await registerApp(application) + + Factory.handlePasswordChallenges(application, accountPassword) + const secret = await application.mfa.generateMfaSecret() + const token = await application.mfa.getOtpToken(secret) + + await application.mfa.enableMfa(secret, token) + + sinon.spy(application.settings.settingsApi, 'deleteSetting') + + await application.mfa.disableMfa() + + const deleteSettingCall = application.settings.settingsApi.deleteSetting.getCall(0) + const [serverPassword] = deleteSettingCall.args + expect(typeof serverPassword).to.equal('string') + expect(serverPassword.length).to.be.above(0) + }).timeout(Factory.TenSecondTimeout) + + it('should not allow disabling mfa if server password is not sent', async function () { + await registerApp(application) + + Factory.handlePasswordChallenges(application, accountPassword) + + const secret = await application.mfa.generateMfaSecret() + const token = await application.mfa.getOtpToken(secret) + + await application.mfa.enableMfa(secret, token) + + const response = await application.dependencies + .get(TYPES.SettingsApiService) + .deleteSetting({ + userUuid: application.user.uuid, + settingName: 'MFA_SECRET', + }) + + expect(response.status).to.equal(400) + }).timeout(Factory.TenSecondTimeout) + + it('should not allow disabling mfa if server password is incorrect', async function () { + await registerApp(application) + + Factory.handlePasswordChallenges(application, accountPassword) + + const secret = await application.mfa.generateMfaSecret() + const token = await application.mfa.getOtpToken(secret) + + await application.mfa.enableMfa(secret, token) + + const response = await application.dependencies + .get(TYPES.SettingsApiService) + .deleteSetting({ + userUuid: application.user.uuid, + settingName: 'MFA_SECRET', + serverPassword: 'wrong-password' + }) + + expect(response.status).to.equal(400) + }).timeout(Factory.TenSecondTimeout) }) diff --git a/packages/web/src/javascripts/Components/RecoveryCodeBanner/RecoveryCodeBanner.tsx b/packages/web/src/javascripts/Components/RecoveryCodeBanner/RecoveryCodeBanner.tsx index 3c6dd1911..64588b82f 100644 --- a/packages/web/src/javascripts/Components/RecoveryCodeBanner/RecoveryCodeBanner.tsx +++ b/packages/web/src/javascripts/Components/RecoveryCodeBanner/RecoveryCodeBanner.tsx @@ -10,13 +10,13 @@ const RecoveryCodeBanner = ({ application }: { application: WebApplication }) => const [errorMessage, setErrorMessage] = useState() const onClickShow = async () => { - const authorized = await application.challenges.promptForAccountPassword() + const password = await application.challenges.promptForAccountPassword() - if (!authorized) { + if (!password) { return } - const recoveryCodeOrError = await application.getRecoveryCodes.execute() + const recoveryCodeOrError = await application.getRecoveryCodes.execute({ password }) if (recoveryCodeOrError.isFailed()) { setErrorMessage(recoveryCodeOrError.getError()) return