feat: recovery codes UI (recovery sign in + get recovery codes) (#2139)
* feat(web): show recovery codes * feat(web): add recovery sign in * fix: copy * fix: styles * feat: add "copy to clipboard" button * style: copy * fix: copy button bg * style: singularize recovery codes * style: singularize recovery codes * feat: password validation Co-authored-by: Aman Harwara <amanharwara@protonmail.com> Co-authored-by: Mo <mo@standardnotes.com>
This commit is contained in:
@@ -333,6 +333,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
return this.actionsManager
|
||||
}
|
||||
|
||||
public get challenges(): ExternalServices.ChallengeServiceInterface {
|
||||
return this.challengeService
|
||||
}
|
||||
|
||||
public computePrivateUsername(username: string): Promise<string | undefined> {
|
||||
return ComputePrivateUsername(this.options.crypto, username)
|
||||
}
|
||||
@@ -823,10 +827,6 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
}
|
||||
}
|
||||
|
||||
public promptForCustomChallenge(challenge: Challenge): Promise<ChallengeResponse | undefined> {
|
||||
return this.challengeService?.promptForChallengeResponse(challenge)
|
||||
}
|
||||
|
||||
public addChallengeObserver(challenge: Challenge, observer: InternalServices.ChallengeObserver): () => void {
|
||||
return this.challengeService.addChallengeObserver(challenge, observer)
|
||||
}
|
||||
@@ -996,7 +996,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
false,
|
||||
)
|
||||
|
||||
void this.promptForCustomChallenge(challenge)
|
||||
void this.challengeService.promptForChallengeResponse(challenge)
|
||||
|
||||
this.isBiometricsSoftLockEngaged = true
|
||||
void this.notifyEvent(ApplicationEvent.BiometricsSoftLockEngaged)
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('GetRecoveryCodes', () => {
|
||||
settingsClient.getSetting = jest.fn().mockResolvedValue('existing-recovery-codes')
|
||||
})
|
||||
|
||||
it('should return existing recovery codes if they exist', async () => {
|
||||
it('should return existing recovery code if they exist', async () => {
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute()
|
||||
@@ -25,7 +25,7 @@ describe('GetRecoveryCodes', () => {
|
||||
expect(result.getValue()).toBe('existing-recovery-codes')
|
||||
})
|
||||
|
||||
it('should generate recovery codes if they do not exist', async () => {
|
||||
it('should generate recovery code if they do not exist', async () => {
|
||||
settingsClient.getSetting = jest.fn().mockResolvedValue(undefined)
|
||||
|
||||
const useCase = createUseCase()
|
||||
@@ -35,7 +35,7 @@ describe('GetRecoveryCodes', () => {
|
||||
expect(result.getValue()).toBe('recovery-codes')
|
||||
})
|
||||
|
||||
it('should return error if recovery codes could not be generated', async () => {
|
||||
it('should return error if recovery code could not be generated', async () => {
|
||||
settingsClient.getSetting = jest.fn().mockResolvedValue(undefined)
|
||||
authClient.generateRecoveryCodes = jest.fn().mockResolvedValue(false)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export class GetRecoveryCodes implements UseCaseInterface<string> {
|
||||
|
||||
const generatedRecoveryCodes = await this.authClient.generateRecoveryCodes()
|
||||
if (generatedRecoveryCodes === false) {
|
||||
return Result.fail('Could not generate recovery codes')
|
||||
return Result.fail('Could not generate recovery code')
|
||||
}
|
||||
|
||||
return Result.ok(generatedRecoveryCodes)
|
||||
|
||||
@@ -150,17 +150,17 @@ describe('SignInWithRecoveryCodes', () => {
|
||||
expect(result.getError()).toEqual('The protocol version associated with your account is outdated and no longer supported by this application. Please visit standardnotes.com/help/security for more information.')
|
||||
})
|
||||
|
||||
it('should fail if the sign in with recovery codes fails', async () => {
|
||||
it('should fail if the sign in with recovery code fails', async () => {
|
||||
authManager.signInWithRecoveryCodes = jest.fn().mockReturnValue(false)
|
||||
|
||||
const useCase = createUseCase()
|
||||
const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' })
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toEqual('Could not sign in with recovery codes')
|
||||
expect(result.getError()).toEqual('Could not sign in with recovery code')
|
||||
})
|
||||
|
||||
it('should sign in with recovery codes', async () => {
|
||||
it('should sign in with recovery code', async () => {
|
||||
authManager.signInWithRecoveryCodes = jest.fn().mockReturnValue({
|
||||
keyParams: {} as AnyKeyParamsContent,
|
||||
session: {} as SessionBody,
|
||||
|
||||
@@ -70,7 +70,7 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<void> {
|
||||
})
|
||||
|
||||
if (signInResult === false) {
|
||||
return Result.fail('Could not sign in with recovery codes')
|
||||
return Result.fail('Could not sign in with recovery code')
|
||||
}
|
||||
|
||||
this.inMemoryStore.removeValue(StorageKey.CodeVerifier)
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ChallengePromptInterface,
|
||||
ChallengePrompt,
|
||||
EncryptionService,
|
||||
ChallengeStrings,
|
||||
} from '@standardnotes/services'
|
||||
import { ChallengeResponse } from './ChallengeResponse'
|
||||
import { ChallengeOperation } from './ChallengeOperation'
|
||||
@@ -109,6 +110,27 @@ export class ChallengeService extends AbstractService implements ChallengeServic
|
||||
return value.value as string
|
||||
}
|
||||
|
||||
async promptForAccountPassword(): Promise<boolean> {
|
||||
if (!this.protocolService.hasAccount()) {
|
||||
throw Error('Requiring account password for challenge with no account')
|
||||
}
|
||||
|
||||
const response = await this.promptForChallengeResponse(
|
||||
new Challenge(
|
||||
[new ChallengePrompt(ChallengeValidation.AccountPassword)],
|
||||
ChallengeReason.Custom,
|
||||
true,
|
||||
ChallengeStrings.EnterAccountPassword,
|
||||
),
|
||||
)
|
||||
|
||||
if (response) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the wrapping key for operations that require resaving the root key
|
||||
* (changing the account password, signing in, registering, or upgrading protocol)
|
||||
|
||||
@@ -388,7 +388,7 @@ export class SNProtectionService extends AbstractService<ProtectionEvent> implem
|
||||
if (isNullOrUndefined(length)) {
|
||||
SNLog.error(Error('No valid protection session length found. Got ' + length))
|
||||
} else {
|
||||
await this.setSessionLength(length as UnprotectedAccessSecondsDuration)
|
||||
this.setSessionLength(length as UnprotectedAccessSecondsDuration)
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('account recovery', function () {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('should get the same recovery codes at each consecutive call', async () => {
|
||||
it('should get the same recovery code at each consecutive call', async () => {
|
||||
let recoveryCodesSetting = await application.settings.getSetting(SettingName.RecoveryCodes)
|
||||
expect(recoveryCodesSetting).to.equal(undefined)
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('account recovery', function () {
|
||||
expect(generatedRecoveryCodesAfterFirstCall.getValue()).to.equal(fetchedRecoveryCodesOnTheSecondCall.getValue())
|
||||
})
|
||||
|
||||
it('should allow to sign in with recovery codes', async () => {
|
||||
it('should allow to sign in with recovery code', async () => {
|
||||
const generatedRecoveryCodes = await application.getRecoveryCodes.execute()
|
||||
|
||||
application = await context.signout()
|
||||
@@ -55,7 +55,7 @@ describe('account recovery', function () {
|
||||
expect(await application.protocolService.getRootKey()).to.be.ok
|
||||
})
|
||||
|
||||
it('should automatically generate new recovery codes after recovery sign in', async () => {
|
||||
it('should automatically generate new recovery code after recovery sign in', async () => {
|
||||
const generatedRecoveryCodes = await application.getRecoveryCodes.execute()
|
||||
|
||||
application = await context.signout()
|
||||
@@ -91,7 +91,7 @@ describe('account recovery', function () {
|
||||
expect(await application.isMfaActivated()).to.equal(false)
|
||||
})
|
||||
|
||||
it('should not allow to sign in with recovery codes and invalid credentials', async () => {
|
||||
it('should not allow to sign in with recovery code and invalid credentials', async () => {
|
||||
const generatedRecoveryCodes = await application.getRecoveryCodes.execute()
|
||||
|
||||
application = await context.signout()
|
||||
@@ -107,7 +107,7 @@ describe('account recovery', function () {
|
||||
expect(await application.protocolService.getRootKey()).to.not.be.ok
|
||||
})
|
||||
|
||||
it('should not allow to sign in with invalid recovery codes', async () => {
|
||||
it('should not allow to sign in with invalid recovery code', async () => {
|
||||
await application.getRecoveryCodes.execute()
|
||||
|
||||
application = await context.signout()
|
||||
@@ -115,7 +115,7 @@ describe('account recovery', function () {
|
||||
expect(await application.protocolService.getRootKey()).to.not.be.ok
|
||||
|
||||
await application.signInWithRecoveryCodes.execute({
|
||||
recoveryCodes: 'invalid recovery codes',
|
||||
recoveryCodes: 'invalid recovery code',
|
||||
username: context.email,
|
||||
password: context.paswword,
|
||||
})
|
||||
@@ -123,7 +123,7 @@ describe('account recovery', function () {
|
||||
expect(await application.protocolService.getRootKey()).to.not.be.ok
|
||||
})
|
||||
|
||||
it('should not allow to sign in with recovery codes if user has none', async () => {
|
||||
it('should not allow to sign in with recovery code if user has none', async () => {
|
||||
application = await context.signout()
|
||||
|
||||
expect(await application.protocolService.getRootKey()).to.not.be.ok
|
||||
|
||||
Reference in New Issue
Block a user