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
This commit is contained in:
Antonella Sgarlatta
2025-08-26 09:04:03 -03:00
committed by GitHub
parent cf4d2196de
commit 54af28aa04
29 changed files with 298 additions and 62 deletions

View File

@@ -22,7 +22,9 @@ export class AuthApiService implements AuthApiServiceInterface {
this.operationsInProgress = new Map() this.operationsInProgress = new Map()
} }
async generateRecoveryCodes(): Promise<HttpResponse<GenerateRecoveryCodesResponseBody>> { async generateRecoveryCodes(dto: {
serverPassword: string
}): Promise<HttpResponse<GenerateRecoveryCodesResponseBody>> {
if (this.operationsInProgress.get(AuthApiOperations.GenerateRecoveryCodes)) { if (this.operationsInProgress.get(AuthApiOperations.GenerateRecoveryCodes)) {
throw new ApiCallError(ErrorMessage.GenericInProgress) throw new ApiCallError(ErrorMessage.GenericInProgress)
} }
@@ -30,7 +32,9 @@ export class AuthApiService implements AuthApiServiceInterface {
this.operationsInProgress.set(AuthApiOperations.GenerateRecoveryCodes, true) this.operationsInProgress.set(AuthApiOperations.GenerateRecoveryCodes, true)
try { try {
const response = await this.authServer.generateRecoveryCodes() const response = await this.authServer.generateRecoveryCodes({
headers: [{ key: 'x-server-password', value: dto.serverPassword }],
})
return response return response
} catch (error) { } catch (error) {

View File

@@ -6,7 +6,7 @@ import {
} from '../../Response' } from '../../Response'
export interface AuthApiServiceInterface { export interface AuthApiServiceInterface {
generateRecoveryCodes(): Promise<HttpResponse<GenerateRecoveryCodesResponseBody>> generateRecoveryCodes(dto: { serverPassword: string }): Promise<HttpResponse<GenerateRecoveryCodesResponseBody>>
recoveryKeyParams(dto: { recoveryKeyParams(dto: {
username: string username: string
codeChallenge: string codeChallenge: string

View File

@@ -27,13 +27,19 @@ export class UserApiService implements UserApiServiceInterface {
this.operationsInProgress = new Map() this.operationsInProgress = new Map()
} }
async deleteAccount(userUuid: string): Promise<HttpResponse<UserDeletionResponseBody>> { async deleteAccount(dto: {
userUuid: string
serverPassword: string
}): Promise<HttpResponse<UserDeletionResponseBody>> {
this.lockOperation(UserApiOperations.DeletingAccount) this.lockOperation(UserApiOperations.DeletingAccount)
try { try {
const response = await this.userServer.deleteAccount({ const response = await this.userServer.deleteAccount(
userUuid: userUuid, {
}) userUuid: dto.userUuid,
},
{ headers: [{ key: 'x-server-password', value: dto.serverPassword }] },
)
this.unlockOperation(UserApiOperations.DeletingAccount) this.unlockOperation(UserApiOperations.DeletingAccount)

View File

@@ -22,5 +22,8 @@ export interface UserApiServiceInterface {
requestType: UserRequestType requestType: UserRequestType
}): Promise<HttpResponse<UserRequestResponseBody>> }): Promise<HttpResponse<UserRequestResponseBody>>
deleteAccount(userUuid: string): Promise<HttpResponse<UserDeletionResponseBody>> deleteAccount(dto: {
userUuid: string
serverPassword: string | undefined
}): Promise<HttpResponse<UserDeletionResponseBody>>
} }

View File

@@ -91,6 +91,7 @@ export class HttpService implements HttpServiceInterface {
params, params,
verb: HttpVerb.Get, verb: HttpVerb.Get,
authentication: options?.authentication ?? this.getSessionAccessToken(), authentication: options?.authentication ?? this.getSessionAccessToken(),
customHeaders: options?.headers,
}) })
} }
@@ -123,6 +124,7 @@ export class HttpService implements HttpServiceInterface {
params, params,
verb: HttpVerb.Put, verb: HttpVerb.Put,
authentication: options?.authentication ?? this.getSessionAccessToken(), authentication: options?.authentication ?? this.getSessionAccessToken(),
customHeaders: options?.headers,
}) })
} }
@@ -141,6 +143,7 @@ export class HttpService implements HttpServiceInterface {
params, params,
verb: HttpVerb.Delete, verb: HttpVerb.Delete,
authentication: options?.authentication ?? this.getSessionAccessToken(), authentication: options?.authentication ?? this.getSessionAccessToken(),
customHeaders: options?.headers,
}) })
} }

View File

@@ -0,0 +1,3 @@
export interface GenerateRecoveryCodesRequestParams {
serverPassword: string
}

View File

@@ -2,6 +2,7 @@ export * from './Authenticator/DeleteAuthenticatorRequestParams'
export * from './Authenticator/GenerateAuthenticatorAuthenticationOptionsRequestParams' export * from './Authenticator/GenerateAuthenticatorAuthenticationOptionsRequestParams'
export * from './Authenticator/ListAuthenticatorsRequestParams' export * from './Authenticator/ListAuthenticatorsRequestParams'
export * from './Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams' export * from './Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams'
export * from './Recovery/GenerateRecoveryCodesRequestParams'
export * from './Recovery/RecoveryKeyParamsRequestParams' export * from './Recovery/RecoveryKeyParamsRequestParams'
export * from './Recovery/SignInWithRecoveryCodesRequestParams' export * from './Recovery/SignInWithRecoveryCodesRequestParams'
export * from './Revision/DeleteRevisionRequestParams' export * from './Revision/DeleteRevisionRequestParams'

View File

@@ -8,12 +8,13 @@ import {
} from '../../Response' } from '../../Response'
import { AuthServerInterface } from './AuthServerInterface' import { AuthServerInterface } from './AuthServerInterface'
import { Paths } from './Paths' import { Paths } from './Paths'
import { HttpRequestOptions } from '../../Http/HttpRequestOptions'
export class AuthServer implements AuthServerInterface { export class AuthServer implements AuthServerInterface {
constructor(private httpService: HttpServiceInterface) {} constructor(private httpService: HttpServiceInterface) {}
async generateRecoveryCodes(): Promise<HttpResponse<GenerateRecoveryCodesResponseBody>> { async generateRecoveryCodes(options?: HttpRequestOptions): Promise<HttpResponse<GenerateRecoveryCodesResponseBody>> {
return this.httpService.post(Paths.v1.generateRecoveryCodes) return this.httpService.post(Paths.v1.generateRecoveryCodes, undefined, options)
} }
async recoveryKeyParams( async recoveryKeyParams(

View File

@@ -5,9 +5,10 @@ import {
RecoveryKeyParamsResponseBody, RecoveryKeyParamsResponseBody,
SignInWithRecoveryCodesResponseBody, SignInWithRecoveryCodesResponseBody,
} from '../../Response' } from '../../Response'
import { HttpRequestOptions } from '../../Http/HttpRequestOptions'
export interface AuthServerInterface { export interface AuthServerInterface {
generateRecoveryCodes(): Promise<HttpResponse<GenerateRecoveryCodesResponseBody>> generateRecoveryCodes(options?: HttpRequestOptions): Promise<HttpResponse<GenerateRecoveryCodesResponseBody>>
recoveryKeyParams(params: RecoveryKeyParamsRequestParams): Promise<HttpResponse<RecoveryKeyParamsResponseBody>> recoveryKeyParams(params: RecoveryKeyParamsRequestParams): Promise<HttpResponse<RecoveryKeyParamsResponseBody>>
signInWithRecoveryCodes( signInWithRecoveryCodes(
params: SignInWithRecoveryCodesRequestParams, params: SignInWithRecoveryCodesRequestParams,

View File

@@ -8,12 +8,16 @@ import { UserRegistrationResponseBody } from '../../Response/User/UserRegistrati
import { Paths } from './Paths' import { Paths } from './Paths'
import { UserServerInterface } from './UserServerInterface' import { UserServerInterface } from './UserServerInterface'
import { UserUpdateRequestParams } from '../../Request/User/UserUpdateRequestParams' import { UserUpdateRequestParams } from '../../Request/User/UserUpdateRequestParams'
import { HttpRequestOptions } from '../../Http/HttpRequestOptions'
export class UserServer implements UserServerInterface { export class UserServer implements UserServerInterface {
constructor(private httpService: HttpServiceInterface) {} constructor(private httpService: HttpServiceInterface) {}
async deleteAccount(params: UserDeletionRequestParams): Promise<HttpResponse<UserDeletionResponseBody>> { async deleteAccount(
return this.httpService.delete(Paths.v1.deleteAccount(params.userUuid), params) params: UserDeletionRequestParams,
options?: HttpRequestOptions,
): Promise<HttpResponse<UserDeletionResponseBody>> {
return this.httpService.delete(Paths.v1.deleteAccount(params.userUuid), params, options)
} }
async register(params: UserRegistrationRequestParams): Promise<HttpResponse<UserRegistrationResponseBody>> { async register(params: UserRegistrationRequestParams): Promise<HttpResponse<UserRegistrationResponseBody>> {

View File

@@ -5,9 +5,13 @@ import { UserDeletionResponseBody } from '../../Response/User/UserDeletionRespon
import { UserRegistrationResponseBody } from '../../Response/User/UserRegistrationResponseBody' import { UserRegistrationResponseBody } from '../../Response/User/UserRegistrationResponseBody'
import { UserUpdateResponse } from '../../Response/User/UserUpdateResponse' import { UserUpdateResponse } from '../../Response/User/UserUpdateResponse'
import { UserUpdateRequestParams } from '../../Request/User/UserUpdateRequestParams' import { UserUpdateRequestParams } from '../../Request/User/UserUpdateRequestParams'
import { HttpRequestOptions } from '../../Http/HttpRequestOptions'
export interface UserServerInterface { export interface UserServerInterface {
register(params: UserRegistrationRequestParams): Promise<HttpResponse<UserRegistrationResponseBody>> register(params: UserRegistrationRequestParams): Promise<HttpResponse<UserRegistrationResponseBody>>
deleteAccount(params: UserDeletionRequestParams): Promise<HttpResponse<UserDeletionResponseBody>> deleteAccount(
params: UserDeletionRequestParams,
options?: HttpRequestOptions,
): Promise<HttpResponse<UserDeletionResponseBody>>
update(params: UserUpdateRequestParams): Promise<HttpResponse<UserUpdateResponse>> update(params: UserUpdateRequestParams): Promise<HttpResponse<UserUpdateResponse>>
} }

View File

@@ -2,7 +2,7 @@ import { AnyKeyParamsContent } from '@standardnotes/common'
import { SessionBody } from '@standardnotes/responses' import { SessionBody } from '@standardnotes/responses'
export interface AuthClientInterface { export interface AuthClientInterface {
generateRecoveryCodes(): Promise<string | false> generateRecoveryCodes(dto: { serverPassword: string }): Promise<string | false>
recoveryKeyParams(dto: { recoveryKeyParams(dto: {
username: string username: string
codeChallenge: string codeChallenge: string

View File

@@ -14,9 +14,9 @@ export class AuthManager extends AbstractService implements AuthClientInterface
super(internalEventBus) super(internalEventBus)
} }
async generateRecoveryCodes(): Promise<string | false> { async generateRecoveryCodes(dto: { serverPassword: string }): Promise<string | false> {
try { try {
const result = await this.authApiService.generateRecoveryCodes() const result = await this.authApiService.generateRecoveryCodes(dto)
if (isErrorResponse(result)) { if (isErrorResponse(result)) {
return false return false

View File

@@ -1,6 +1,6 @@
import { ApplicationServiceInterface } from './../Service/ApplicationServiceInterface' import { ApplicationServiceInterface } from './../Service/ApplicationServiceInterface'
import { DecryptedItem, DecryptedItemInterface, FileItem, SNNote } from '@standardnotes/models' import { DecryptedItem, DecryptedItemInterface, FileItem, SNNote } from '@standardnotes/models'
import { ChallengeInterface, ChallengeReason } from '../Challenge' import { ChallengeInterface, ChallengeReason, ChallengeResponseInterface } from '../Challenge'
import { MobileUnlockTiming } from './MobileUnlockTiming' import { MobileUnlockTiming } from './MobileUnlockTiming'
import { TimingDisplayOption } from './TimingDisplayOption' import { TimingDisplayOption } from './TimingDisplayOption'
import { ProtectionEvent } from './ProtectionEvent' import { ProtectionEvent } from './ProtectionEvent'
@@ -28,6 +28,10 @@ export interface ProtectionsClientInterface extends ApplicationServiceInterface<
reason: ChallengeReason, reason: ChallengeReason,
dto: { fallBackToAccountPassword: boolean; requireAccountPassword: boolean; forcePrompt: boolean }, dto: { fallBackToAccountPassword: boolean; requireAccountPassword: boolean; forcePrompt: boolean },
): Promise<boolean> ): Promise<boolean>
authorizeActionWithChallengeResponse(
reason: ChallengeReason,
dto: { fallBackToAccountPassword: boolean; requireAccountPassword: boolean; forcePrompt: boolean },
): Promise<{ success: boolean; challengeResponse?: ChallengeResponseInterface }>
authorizeAddingPasscode(): Promise<boolean> authorizeAddingPasscode(): Promise<boolean>
authorizeRemovingPasscode(): Promise<boolean> authorizeRemovingPasscode(): Promise<boolean>
authorizeChangingPasscode(): Promise<boolean> authorizeChangingPasscode(): Promise<boolean>
@@ -36,7 +40,8 @@ export interface ProtectionsClientInterface extends ApplicationServiceInterface<
authorizeAutolockIntervalChange(): Promise<boolean> authorizeAutolockIntervalChange(): Promise<boolean>
authorizeSearchingProtectedNotesText(): Promise<boolean> authorizeSearchingProtectedNotesText(): Promise<boolean>
authorizeBackupCreation(): Promise<boolean> authorizeBackupCreation(): Promise<boolean>
authorizeMfaDisable(): Promise<boolean> authorizeMfaDisable(): Promise<{ success: boolean; challengeResponse?: ChallengeResponseInterface }>
authorizeAccountDeletion(): Promise<{ success: boolean; challengeResponse?: ChallengeResponseInterface }>
protectItems<I extends DecryptedItemInterface>(items: I[]): Promise<I[]> protectItems<I extends DecryptedItemInterface>(items: I[]): Promise<I[]>
unprotectItems<I extends DecryptedItemInterface>(items: I[], reason: ChallengeReason): Promise<I[] | undefined> unprotectItems<I extends DecryptedItemInterface>(items: I[], reason: ChallengeReason): Promise<I[] | undefined>

View File

@@ -237,13 +237,9 @@ export class UserService
error: boolean error: boolean
message?: string message?: string
}> { }> {
if ( const { success, challengeResponse } = await this.protections.authorizeAccountDeletion()
!(await this.protections.authorizeAction(ChallengeReason.DeleteAccount, {
fallBackToAccountPassword: true, if (!success) {
requireAccountPassword: true,
forcePrompt: false,
}))
) {
return { return {
error: true, error: true,
message: Messages.INVALID_PASSWORD, message: Messages.INVALID_PASSWORD,
@@ -251,7 +247,13 @@ export class UserService
} }
const uuid = this.sessions.getSureUser().uuid 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)) { if (isErrorResponse(response)) {
return { return {
error: true, error: true,

View File

@@ -1026,6 +1026,7 @@ export class Dependencies {
return new GetRecoveryCodes( return new GetRecoveryCodes(
this.get<AuthManager>(TYPES.AuthManager), this.get<AuthManager>(TYPES.AuthManager),
this.get<SettingsService>(TYPES.SettingsService), this.get<SettingsService>(TYPES.SettingsService),
this.get<EncryptionService>(TYPES.EncryptionService),
) )
}) })
@@ -1231,6 +1232,7 @@ export class Dependencies {
this.get<PureCryptoInterface>(TYPES.Crypto), this.get<PureCryptoInterface>(TYPES.Crypto),
this.get<FeaturesService>(TYPES.FeaturesService), this.get<FeaturesService>(TYPES.FeaturesService),
this.get<ProtectionsClientInterface>(TYPES.ProtectionService), this.get<ProtectionsClientInterface>(TYPES.ProtectionService),
this.get<EncryptionService>(TYPES.EncryptionService),
this.get<InternalEventBus>(TYPES.InternalEventBus), this.get<InternalEventBus>(TYPES.InternalEventBus),
) )
}) })

View File

@@ -1,4 +1,4 @@
import { AuthClientInterface } from '@standardnotes/services' import { AuthClientInterface, EncryptionService } from '@standardnotes/services'
import { SettingsClientInterface } from '@Lib/Services/Settings/SettingsClientInterface' import { SettingsClientInterface } from '@Lib/Services/Settings/SettingsClientInterface'
import { GetRecoveryCodes } from './GetRecoveryCodes' import { GetRecoveryCodes } from './GetRecoveryCodes'
@@ -6,8 +6,9 @@ import { GetRecoveryCodes } from './GetRecoveryCodes'
describe('GetRecoveryCodes', () => { describe('GetRecoveryCodes', () => {
let authClient: AuthClientInterface let authClient: AuthClientInterface
let settingsClient: SettingsClientInterface let settingsClient: SettingsClientInterface
let encryption: EncryptionService
const createUseCase = () => new GetRecoveryCodes(authClient, settingsClient) const createUseCase = () => new GetRecoveryCodes(authClient, settingsClient, encryption)
beforeEach(() => { beforeEach(() => {
authClient = {} as jest.Mocked<AuthClientInterface> authClient = {} as jest.Mocked<AuthClientInterface>
@@ -15,12 +16,16 @@ describe('GetRecoveryCodes', () => {
settingsClient = {} as jest.Mocked<SettingsClientInterface> settingsClient = {} as jest.Mocked<SettingsClientInterface>
settingsClient.getSetting = jest.fn().mockResolvedValue('existing-recovery-codes') settingsClient.getSetting = jest.fn().mockResolvedValue('existing-recovery-codes')
encryption = {} as jest.Mocked<EncryptionService>
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 () => { it('should return existing recovery code if they exist', async () => {
const useCase = createUseCase() const useCase = createUseCase()
const result = await useCase.execute() const result = await useCase.execute({ password: 'test-password' })
expect(result.getValue()).toBe('existing-recovery-codes') expect(result.getValue()).toBe('existing-recovery-codes')
}) })
@@ -30,7 +35,7 @@ describe('GetRecoveryCodes', () => {
const useCase = createUseCase() const useCase = createUseCase()
const result = await useCase.execute() const result = await useCase.execute({ password: 'test-password' })
expect(result.getValue()).toBe('recovery-codes') expect(result.getValue()).toBe('recovery-codes')
}) })
@@ -41,7 +46,7 @@ describe('GetRecoveryCodes', () => {
const useCase = createUseCase() const useCase = createUseCase()
const result = await useCase.execute() const result = await useCase.execute({ password: 'test-password' })
expect(result.isFailed()).toBe(true) expect(result.isFailed()).toBe(true)
}) })

View File

@@ -1,23 +1,40 @@
import { AuthClientInterface } from '@standardnotes/services' import { AuthClientInterface, EncryptionService } from '@standardnotes/services'
import { Result, SettingName, UseCaseInterface } from '@standardnotes/domain-core' import { Result, SettingName, UseCaseInterface } from '@standardnotes/domain-core'
import { SettingsClientInterface } from '@Lib/Services/Settings/SettingsClientInterface' import { SettingsClientInterface } from '@Lib/Services/Settings/SettingsClientInterface'
import { GetRecoveryCodesDTO } from './GetRecoveryCodesDTO'
import { SNRootKeyParams } from '@standardnotes/encryption'
export class GetRecoveryCodes implements UseCaseInterface<string> { export class GetRecoveryCodes implements UseCaseInterface<string> {
constructor( constructor(
private authClient: AuthClientInterface, private authClient: AuthClientInterface,
private settingsClient: SettingsClientInterface, private settingsClient: SettingsClientInterface,
private encryption: EncryptionService,
) {} ) {}
async execute(): Promise<Result<string>> { async execute(dto: GetRecoveryCodesDTO): Promise<Result<string>> {
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( const existingRecoveryCodes = await this.settingsClient.getSetting(
SettingName.create(SettingName.NAMES.RecoveryCodes).getValue(), SettingName.create(SettingName.NAMES.RecoveryCodes).getValue(),
serverPassword,
) )
if (existingRecoveryCodes !== undefined) { if (existingRecoveryCodes !== undefined) {
return Result.ok(existingRecoveryCodes) return Result.ok(existingRecoveryCodes)
} }
const generatedRecoveryCodes = await this.authClient.generateRecoveryCodes() const generatedRecoveryCodes = await this.authClient.generateRecoveryCodes({ serverPassword })
if (generatedRecoveryCodes === false) { if (generatedRecoveryCodes === false) {
return Result.fail('Could not generate recovery code') return Result.fail('Could not generate recovery code')
} }

View File

@@ -0,0 +1,3 @@
export interface GetRecoveryCodesDTO {
password?: string
}

View File

@@ -578,12 +578,18 @@ export class LegacyApiService
}) })
} }
async getSetting(userUuid: UuidString, settingName: string): Promise<HttpResponse<GetSettingResponse>> { async getSetting(
userUuid: UuidString,
settingName: string,
serverPassword?: string,
): Promise<HttpResponse<GetSettingResponse>> {
const customHeaders = serverPassword ? [{ key: 'x-server-password', value: serverPassword }] : undefined
return await this.tokenRefreshableRequest<GetSettingResponse>({ return await this.tokenRefreshableRequest<GetSettingResponse>({
verb: HttpVerb.Get, verb: HttpVerb.Get,
url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName.toLowerCase())), url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName.toLowerCase())),
authentication: this.getSessionAccessToken(), authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS, fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS,
customHeaders,
}) })
} }
@@ -616,12 +622,18 @@ export class LegacyApiService
}) })
} }
async deleteSetting(userUuid: UuidString, settingName: string): Promise<HttpResponse<DeleteSettingResponse>> { async deleteSetting(
userUuid: UuidString,
settingName: string,
serverPassword?: string,
): Promise<HttpResponse<DeleteSettingResponse>> {
const customHeaders = serverPassword ? [{ key: 'x-server-password', value: serverPassword }] : undefined
return this.tokenRefreshableRequest<DeleteSettingResponse>({ return this.tokenRefreshableRequest<DeleteSettingResponse>({
verb: HttpVerb.Delete, verb: HttpVerb.Delete,
url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName)), url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName)),
authentication: this.getSessionAccessToken(), authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_UPDATE_SETTINGS, fallbackErrorMessage: API_MESSAGE_FAILED_UPDATE_SETTINGS,
customHeaders,
}) })
} }

View File

@@ -6,9 +6,12 @@ import {
InternalEventBusInterface, InternalEventBusInterface,
MfaServiceInterface, MfaServiceInterface,
ProtectionsClientInterface, ProtectionsClientInterface,
EncryptionService,
SignInStrings, SignInStrings,
ChallengeValidation,
} from '@standardnotes/services' } from '@standardnotes/services'
import { SettingName } from '@standardnotes/domain-core' import { SettingName } from '@standardnotes/domain-core'
import { SNRootKeyParams } from '@standardnotes/encryption'
export class MfaService extends AbstractService implements MfaServiceInterface { export class MfaService extends AbstractService implements MfaServiceInterface {
constructor( constructor(
@@ -16,6 +19,7 @@ export class MfaService extends AbstractService implements MfaServiceInterface {
private crypto: PureCryptoInterface, private crypto: PureCryptoInterface,
private featuresService: FeaturesService, private featuresService: FeaturesService,
private protections: ProtectionsClientInterface, private protections: ProtectionsClientInterface,
private encryption: EncryptionService,
protected override internalEventBus: InternalEventBusInterface, protected override internalEventBus: InternalEventBusInterface,
) { ) {
super(internalEventBus) super(internalEventBus)
@@ -55,11 +59,23 @@ export class MfaService extends AbstractService implements MfaServiceInterface {
} }
async disableMfa(): Promise<void> { async disableMfa(): Promise<void> {
if (!(await this.protections.authorizeMfaDisable())) { const { success, challengeResponse } = await this.protections.authorizeMfaDisable()
if (!success) {
return 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 { override deinit(): void {

View File

@@ -34,6 +34,7 @@ import {
import { ContentType } from '@standardnotes/domain-core' import { ContentType } from '@standardnotes/domain-core'
import { isValidProtectionSessionLength } from './isValidProtectionSessionLength' import { isValidProtectionSessionLength } from './isValidProtectionSessionLength'
import { UnprotectedAccessSecondsDuration } from './UnprotectedAccessSecondsDuration' import { UnprotectedAccessSecondsDuration } from './UnprotectedAccessSecondsDuration'
import { ChallengeResponse } from '../Challenge'
/** /**
* Enforces certain actions to require extra authentication, * Enforces certain actions to require extra authentication,
@@ -246,11 +247,11 @@ export class ProtectionService
}) })
} }
async authorizeMfaDisable(): Promise<boolean> { async authorizeMfaDisable(): Promise<{ success: boolean; challengeResponse?: ChallengeResponse }> {
return this.authorizeAction(ChallengeReason.DisableMfa, { return this.authorizeActionWithChallengeResponse(ChallengeReason.DisableMfa, {
fallBackToAccountPassword: true, fallBackToAccountPassword: true,
requireAccountPassword: 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( async authorizeAction(
reason: ChallengeReason, reason: ChallengeReason,
dto: { fallBackToAccountPassword: boolean; requireAccountPassword: boolean; forcePrompt: boolean }, dto: { fallBackToAccountPassword: boolean; requireAccountPassword: boolean; forcePrompt: boolean },
@@ -285,6 +294,13 @@ export class ProtectionService
return this.validateOrRenewSession(reason, dto) 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[] { getMobilePasscodeTimingOptions(): TimingDisplayOption[] {
return [ return [
{ {
@@ -353,8 +369,20 @@ export class ProtectionService
reason: ChallengeReason, reason: ChallengeReason,
{ fallBackToAccountPassword = true, requireAccountPassword = false, forcePrompt = false } = {}, { fallBackToAccountPassword = true, requireAccountPassword = false, forcePrompt = false } = {},
): Promise<boolean> { ): Promise<boolean> {
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) { if (this.getSessionExpiryDate() > new Date() && !forcePrompt) {
return true return { success: true }
} }
const prompts: ChallengePrompt[] = [] const prompts: ChallengePrompt[] = []
@@ -378,9 +406,10 @@ export class ProtectionService
if (fallBackToAccountPassword && this.encryption.hasAccount()) { if (fallBackToAccountPassword && this.encryption.hasAccount()) {
prompts.push(new ChallengePrompt(ChallengeValidation.AccountPassword)) prompts.push(new ChallengePrompt(ChallengeValidation.AccountPassword))
} else { } else {
return true return { success: true }
} }
} }
const lastSessionLength = this.getLastSessionLength() const lastSessionLength = this.getLastSessionLength()
const chosenSessionLength = isValidProtectionSessionLength(lastSessionLength) const chosenSessionLength = isValidProtectionSessionLength(lastSessionLength)
? lastSessionLength ? lastSessionLength
@@ -407,9 +436,9 @@ export class ProtectionService
} else { } else {
this.setSessionLength(length as UnprotectedAccessSecondsDuration) this.setSessionLength(length as UnprotectedAccessSecondsDuration)
} }
return true return { success: true, challengeResponse: response }
} else { } else {
return false return { success: false }
} }
} }

View File

@@ -30,8 +30,8 @@ export class SettingsService extends AbstractService implements SettingsClientIn
return this.provider.listSettings() return this.provider.listSettings()
} }
async getSetting(name: SettingName) { async getSetting(name: SettingName, serverPassword?: string) {
return this.provider.getSetting(name) return this.provider.getSetting(name, serverPassword)
} }
async getSubscriptionSetting(name: SettingName) { async getSubscriptionSetting(name: SettingName) {
@@ -50,8 +50,8 @@ export class SettingsService extends AbstractService implements SettingsClientIn
return this.provider.getDoesSensitiveSettingExist(name) return this.provider.getDoesSensitiveSettingExist(name)
} }
async deleteSetting(name: SettingName) { async deleteSetting(name: SettingName, serverPassword?: string) {
return this.provider.deleteSetting(name) return this.provider.deleteSetting(name, serverPassword)
} }
getEmailBackupFrequencyOptionLabel(frequency: EmailBackupFrequency): string { getEmailBackupFrequencyOptionLabel(frequency: EmailBackupFrequency): string {

View File

@@ -5,13 +5,13 @@ import { SettingName } from '@standardnotes/domain-core'
export interface SettingsClientInterface { export interface SettingsClientInterface {
listSettings(): Promise<SettingsList> listSettings(): Promise<SettingsList>
getSetting(name: SettingName): Promise<string | undefined> getSetting(name: SettingName, serverPassword?: string): Promise<string | undefined>
getDoesSensitiveSettingExist(name: SettingName): Promise<boolean> getDoesSensitiveSettingExist(name: SettingName): Promise<boolean>
updateSetting(name: SettingName, payload: string, sensitive?: boolean): Promise<void> updateSetting(name: SettingName, payload: string, sensitive?: boolean): Promise<void>
deleteSetting(name: SettingName): Promise<void> deleteSetting(name: SettingName, serverPassword?: string): Promise<void>
getEmailBackupFrequencyOptionLabel(frequency: EmailBackupFrequency): string getEmailBackupFrequencyOptionLabel(frequency: EmailBackupFrequency): string
} }

View File

@@ -45,8 +45,8 @@ export class SettingsGateway {
return settings return settings
} }
async getSetting(name: SettingName): Promise<string | undefined> { async getSetting(name: SettingName, serverPassword?: string): Promise<string | undefined> {
const response = await this.settingsApi.getSetting(this.userUuid, name.value) const response = await this.settingsApi.getSetting(this.userUuid, name.value, serverPassword)
if (response.status === HttpStatusCode.BadRequest) { if (response.status === HttpStatusCode.BadRequest) {
return undefined return undefined
@@ -109,8 +109,8 @@ export class SettingsGateway {
} }
} }
async deleteSetting(name: SettingName): Promise<void> { async deleteSetting(name: SettingName, serverPassword?: string): Promise<void> {
const response = await this.settingsApi.deleteSetting(this.userUuid, name.value) const response = await this.settingsApi.deleteSetting(this.userUuid, name.value, serverPassword)
if (isErrorResponse(response)) { if (isErrorResponse(response)) {
throw new Error(getErrorFromErrorResponse(response).message) throw new Error(getErrorFromErrorResponse(response).message)
} }

View File

@@ -17,7 +17,11 @@ export interface SettingsServerInterface {
sensitive: boolean, sensitive: boolean,
): Promise<HttpResponse<UpdateSettingResponse>> ): Promise<HttpResponse<UpdateSettingResponse>>
getSetting(userUuid: UuidString, settingName: string): Promise<HttpResponse<GetSettingResponse>> getSetting(
userUuid: UuidString,
settingName: string,
serverPassword?: string,
): Promise<HttpResponse<GetSettingResponse>>
getSubscriptionSetting(userUuid: UuidString, settingName: string): Promise<HttpResponse<GetSettingResponse>> getSubscriptionSetting(userUuid: UuidString, settingName: string): Promise<HttpResponse<GetSettingResponse>>
@@ -28,5 +32,9 @@ export interface SettingsServerInterface {
sensitive: boolean, sensitive: boolean,
): Promise<HttpResponse<UpdateSettingResponse>> ): Promise<HttpResponse<UpdateSettingResponse>>
deleteSetting(userUuid: UuidString, settingName: string): Promise<HttpResponse<DeleteSettingResponse>> deleteSetting(
userUuid: UuidString,
settingName: string,
serverPassword?: string,
): Promise<HttpResponse<DeleteSettingResponse>>
} }

View File

@@ -549,6 +549,24 @@ describe('basic auth', function () {
expect(sendChallengeSpy.callCount).to.equal(1) expect(sendChallengeSpy.callCount).to.equal(1)
}).timeout(Factory.TenSecondTimeout) }).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 () { it('deleting account should sign out current user', async function () {
Factory.handlePasswordChallenges(context.application, context.password) Factory.handlePasswordChallenges(context.application, context.password)
@@ -567,12 +585,40 @@ describe('basic auth', function () {
const response = await context.application.dependencies const response = await context.application.dependencies
.get(TYPES.UserApiService) .get(TYPES.UserApiService)
.deleteAccount(registerResponse.user.uuid) .deleteAccount({
userUuid: registerResponse.user.uuid,
serverPassword: 'dummy-password'
})
expect(response.status).to.equal(401) expect(response.status).to.equal(401)
expect(response.data.error.message).to.equal('Operation not allowed.') expect(response.data.error.message).to.equal('Operation not allowed.')
await secondContext.deinit() 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)
}) })
}) })

View File

@@ -65,6 +65,7 @@ describe('mfa service', () => {
const token = await application.mfa.getOtpToken(secret) const token = await application.mfa.getOtpToken(secret)
sinon.spy(application.challenges, 'sendChallenge') sinon.spy(application.challenges, 'sendChallenge')
await application.mfa.enableMfa(secret, token) await application.mfa.enableMfa(secret, token)
await application.mfa.disableMfa() await application.mfa.disableMfa()
@@ -73,4 +74,64 @@ describe('mfa service', () => {
expect(challenge.prompts).to.have.lengthOf(2) expect(challenge.prompts).to.have.lengthOf(2)
expect(challenge.prompts[0].validation).to.equal(ChallengeValidation.AccountPassword) expect(challenge.prompts[0].validation).to.equal(ChallengeValidation.AccountPassword)
}).timeout(Factory.TenSecondTimeout) }).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)
}) })

View File

@@ -10,13 +10,13 @@ const RecoveryCodeBanner = ({ application }: { application: WebApplication }) =>
const [errorMessage, setErrorMessage] = useState<string>() const [errorMessage, setErrorMessage] = useState<string>()
const onClickShow = async () => { const onClickShow = async () => {
const authorized = await application.challenges.promptForAccountPassword() const password = await application.challenges.promptForAccountPassword()
if (!authorized) { if (!password) {
return return
} }
const recoveryCodeOrError = await application.getRecoveryCodes.execute() const recoveryCodeOrError = await application.getRecoveryCodes.execute({ password })
if (recoveryCodeOrError.isFailed()) { if (recoveryCodeOrError.isFailed()) {
setErrorMessage(recoveryCodeOrError.getError()) setErrorMessage(recoveryCodeOrError.getError())
return return