diff --git a/.yarn/cache/@simplewebauthn-browser-npm-7.0.0-93e13e1065-eb8d7e2d92.zip b/.yarn/cache/@simplewebauthn-browser-npm-7.0.0-93e13e1065-eb8d7e2d92.zip new file mode 100644 index 000000000..34e817595 Binary files /dev/null and b/.yarn/cache/@simplewebauthn-browser-npm-7.0.0-93e13e1065-eb8d7e2d92.zip differ diff --git a/packages/api/src/Domain/Client/Authenticator/AuthenticatorApiOperations.ts b/packages/api/src/Domain/Client/Authenticator/AuthenticatorApiOperations.ts index d4ee9fe94..976049989 100644 --- a/packages/api/src/Domain/Client/Authenticator/AuthenticatorApiOperations.ts +++ b/packages/api/src/Domain/Client/Authenticator/AuthenticatorApiOperations.ts @@ -4,5 +4,4 @@ export enum AuthenticatorApiOperations { GenerateRegistrationOptions, GenerateAuthenticationOptions, VerifyRegistrationResponse, - VerifyAuthenticationResponse, } diff --git a/packages/api/src/Domain/Client/Authenticator/AuthenticatorApiService.ts b/packages/api/src/Domain/Client/Authenticator/AuthenticatorApiService.ts index bfd184210..ff58a3e49 100644 --- a/packages/api/src/Domain/Client/Authenticator/AuthenticatorApiService.ts +++ b/packages/api/src/Domain/Client/Authenticator/AuthenticatorApiService.ts @@ -9,7 +9,6 @@ import { GenerateAuthenticatorRegistrationOptionsResponse, VerifyAuthenticatorRegistrationResponseResponse, GenerateAuthenticatorAuthenticationOptionsResponse, - VerifyAuthenticatorAuthenticationResponseResponse, } from '../../Response' import { AuthenticatorServerInterface } from '../../Server/Authenticator/AuthenticatorServerInterface' @@ -79,7 +78,7 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface async verifyRegistrationResponse( userUuid: string, name: string, - registrationCredential: Record, + attestationResponse: Record, ): Promise { if (this.operationsInProgress.get(AuthenticatorApiOperations.VerifyRegistrationResponse)) { throw new ApiCallError(ErrorMessage.GenericInProgress) @@ -91,7 +90,7 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface const response = await this.authenticatorServer.verifyRegistrationResponse({ userUuid, name, - registrationCredential, + attestationResponse, }) return response @@ -102,7 +101,7 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface } } - async generateAuthenticationOptions(): Promise { + async generateAuthenticationOptions(username: string): Promise { if (this.operationsInProgress.get(AuthenticatorApiOperations.GenerateAuthenticationOptions)) { throw new ApiCallError(ErrorMessage.GenericInProgress) } @@ -110,7 +109,9 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface this.operationsInProgress.set(AuthenticatorApiOperations.GenerateAuthenticationOptions, true) try { - const response = await this.authenticatorServer.generateAuthenticationOptions() + const response = await this.authenticatorServer.generateAuthenticationOptions({ + username, + }) return response } catch (error) { @@ -119,28 +120,4 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface this.operationsInProgress.set(AuthenticatorApiOperations.GenerateAuthenticationOptions, false) } } - - async verifyAuthenticationResponse( - userUuid: string, - authenticationCredential: Record, - ): Promise { - if (this.operationsInProgress.get(AuthenticatorApiOperations.VerifyAuthenticationResponse)) { - throw new ApiCallError(ErrorMessage.GenericInProgress) - } - - this.operationsInProgress.set(AuthenticatorApiOperations.VerifyAuthenticationResponse, true) - - try { - const response = await this.authenticatorServer.verifyAuthenticationResponse({ - authenticationCredential, - userUuid, - }) - - return response - } catch (error) { - throw new ApiCallError(ErrorMessage.GenericFail) - } finally { - this.operationsInProgress.set(AuthenticatorApiOperations.VerifyAuthenticationResponse, false) - } - } } diff --git a/packages/api/src/Domain/Client/Authenticator/AuthenticatorApiServiceInterface.ts b/packages/api/src/Domain/Client/Authenticator/AuthenticatorApiServiceInterface.ts index ed814b68e..1c81ef2c3 100644 --- a/packages/api/src/Domain/Client/Authenticator/AuthenticatorApiServiceInterface.ts +++ b/packages/api/src/Domain/Client/Authenticator/AuthenticatorApiServiceInterface.ts @@ -4,7 +4,6 @@ import { GenerateAuthenticatorRegistrationOptionsResponse, VerifyAuthenticatorRegistrationResponseResponse, GenerateAuthenticatorAuthenticationOptionsResponse, - VerifyAuthenticatorAuthenticationResponseResponse, } from '../../Response' export interface AuthenticatorApiServiceInterface { @@ -14,11 +13,7 @@ export interface AuthenticatorApiServiceInterface { verifyRegistrationResponse( userUuid: string, name: string, - registrationCredential: Record, + attestationResponse: Record, ): Promise - generateAuthenticationOptions(): Promise - verifyAuthenticationResponse( - userUuid: string, - authenticationCredential: Record, - ): Promise + generateAuthenticationOptions(username: string): Promise } diff --git a/packages/api/src/Domain/Request/Authenticator/GenerateAuthenticatorAuthenticationOptionsRequestParams.ts b/packages/api/src/Domain/Request/Authenticator/GenerateAuthenticatorAuthenticationOptionsRequestParams.ts new file mode 100644 index 000000000..7ba7e4fb3 --- /dev/null +++ b/packages/api/src/Domain/Request/Authenticator/GenerateAuthenticatorAuthenticationOptionsRequestParams.ts @@ -0,0 +1,3 @@ +export interface GenerateAuthenticatorAuthenticationOptionsRequestParams { + username: string +} diff --git a/packages/api/src/Domain/Request/Authenticator/VerifyAuthenticatorAuthenticationResponseRequestParams.ts b/packages/api/src/Domain/Request/Authenticator/VerifyAuthenticatorAuthenticationResponseRequestParams.ts deleted file mode 100644 index e335b6680..000000000 --- a/packages/api/src/Domain/Request/Authenticator/VerifyAuthenticatorAuthenticationResponseRequestParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface VerifyAuthenticatorAuthenticationResponseRequestParams { - userUuid: string - authenticationCredential: Record - [additionalParam: string]: unknown -} diff --git a/packages/api/src/Domain/Request/Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams.ts b/packages/api/src/Domain/Request/Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams.ts index 2448a8228..6bcf559c5 100644 --- a/packages/api/src/Domain/Request/Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams.ts +++ b/packages/api/src/Domain/Request/Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams.ts @@ -1,6 +1,6 @@ export interface VerifyAuthenticatorRegistrationResponseRequestParams { userUuid: string name: string - registrationCredential: Record + attestationResponse: Record [additionalParam: string]: unknown } diff --git a/packages/api/src/Domain/Request/index.ts b/packages/api/src/Domain/Request/index.ts index e4744d42b..bde8a4755 100644 --- a/packages/api/src/Domain/Request/index.ts +++ b/packages/api/src/Domain/Request/index.ts @@ -1,7 +1,7 @@ export * from './ApiEndpointParam' export * from './Authenticator/DeleteAuthenticatorRequestParams' +export * from './Authenticator/GenerateAuthenticatorAuthenticationOptionsRequestParams' export * from './Authenticator/ListAuthenticatorsRequestParams' -export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseRequestParams' export * from './Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams' export * from './Recovery/RecoveryKeyParamsRequestParams' export * from './Recovery/SignInWithRecoveryCodesRequestParams' diff --git a/packages/api/src/Domain/Response/Authenticator/VerifyAuthenticatorAuthenticationResponseResponse.ts b/packages/api/src/Domain/Response/Authenticator/VerifyAuthenticatorAuthenticationResponseResponse.ts deleted file mode 100644 index ada9af679..000000000 --- a/packages/api/src/Domain/Response/Authenticator/VerifyAuthenticatorAuthenticationResponseResponse.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Either } from '@standardnotes/common' - -import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody' -import { HttpResponse } from '../../Http/HttpResponse' - -import { VerifyAuthenticatorAuthenticationResponseResponseBody } from './VerifyAuthenticatorAuthenticationResponseResponseBody' - -export interface VerifyAuthenticatorAuthenticationResponseResponse extends HttpResponse { - data: Either -} diff --git a/packages/api/src/Domain/Response/Authenticator/VerifyAuthenticatorAuthenticationResponseResponseBody.ts b/packages/api/src/Domain/Response/Authenticator/VerifyAuthenticatorAuthenticationResponseResponseBody.ts deleted file mode 100644 index 382d196f4..000000000 --- a/packages/api/src/Domain/Response/Authenticator/VerifyAuthenticatorAuthenticationResponseResponseBody.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface VerifyAuthenticatorAuthenticationResponseResponseBody { - success: boolean -} diff --git a/packages/api/src/Domain/Response/index.ts b/packages/api/src/Domain/Response/index.ts index 2a8b6870a..862d6cab6 100644 --- a/packages/api/src/Domain/Response/index.ts +++ b/packages/api/src/Domain/Response/index.ts @@ -8,8 +8,6 @@ export * from './Authenticator/GenerateAuthenticatorRegistrationOptionsResponse' export * from './Authenticator/GenerateAuthenticatorRegistrationOptionsResponseBody' export * from './Authenticator/ListAuthenticatorsResponse' export * from './Authenticator/ListAuthenticatorsResponseBody' -export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseResponse' -export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseResponseBody' export * from './Authenticator/VerifyAuthenticatorRegistrationResponseResponse' export * from './Authenticator/VerifyAuthenticatorRegistrationResponseResponseBody' export * from './Recovery/GenerateRecoveryCodesResponse' diff --git a/packages/api/src/Domain/Server/Authenticator/AuthenticatorServer.ts b/packages/api/src/Domain/Server/Authenticator/AuthenticatorServer.ts index a7e51acf9..7148af928 100644 --- a/packages/api/src/Domain/Server/Authenticator/AuthenticatorServer.ts +++ b/packages/api/src/Domain/Server/Authenticator/AuthenticatorServer.ts @@ -1,9 +1,9 @@ import { HttpServiceInterface } from '../../Http/HttpServiceInterface' import { ListAuthenticatorsRequestParams, + GenerateAuthenticatorAuthenticationOptionsRequestParams, DeleteAuthenticatorRequestParams, VerifyAuthenticatorRegistrationResponseRequestParams, - VerifyAuthenticatorAuthenticationResponseRequestParams, } from '../../Request' import { ListAuthenticatorsResponse, @@ -11,7 +11,6 @@ import { GenerateAuthenticatorRegistrationOptionsResponse, VerifyAuthenticatorRegistrationResponseResponse, GenerateAuthenticatorAuthenticationOptionsResponse, - VerifyAuthenticatorAuthenticationResponseResponse, } from '../../Response' import { AuthenticatorServerInterface } from './AuthenticatorServerInterface' import { Paths } from './Paths' @@ -45,17 +44,11 @@ export class AuthenticatorServer implements AuthenticatorServerInterface { return response as VerifyAuthenticatorRegistrationResponseResponse } - async generateAuthenticationOptions(): Promise { - const response = await this.httpService.get(Paths.v1.generateAuthenticationOptions) + async generateAuthenticationOptions( + params: GenerateAuthenticatorAuthenticationOptionsRequestParams, + ): Promise { + const response = await this.httpService.post(Paths.v1.generateAuthenticationOptions, params) return response as GenerateAuthenticatorAuthenticationOptionsResponse } - - async verifyAuthenticationResponse( - params: VerifyAuthenticatorAuthenticationResponseRequestParams, - ): Promise { - const response = await this.httpService.post(Paths.v1.verifyAuthenticationResponse, params) - - return response as VerifyAuthenticatorAuthenticationResponseResponse - } } diff --git a/packages/api/src/Domain/Server/Authenticator/AuthenticatorServerInterface.ts b/packages/api/src/Domain/Server/Authenticator/AuthenticatorServerInterface.ts index a1a8ed418..b5c00cb7a 100644 --- a/packages/api/src/Domain/Server/Authenticator/AuthenticatorServerInterface.ts +++ b/packages/api/src/Domain/Server/Authenticator/AuthenticatorServerInterface.ts @@ -2,7 +2,7 @@ import { ListAuthenticatorsRequestParams, DeleteAuthenticatorRequestParams, VerifyAuthenticatorRegistrationResponseRequestParams, - VerifyAuthenticatorAuthenticationResponseRequestParams, + GenerateAuthenticatorAuthenticationOptionsRequestParams, } from '../../Request' import { ListAuthenticatorsResponse, @@ -10,7 +10,6 @@ import { GenerateAuthenticatorRegistrationOptionsResponse, VerifyAuthenticatorRegistrationResponseResponse, GenerateAuthenticatorAuthenticationOptionsResponse, - VerifyAuthenticatorAuthenticationResponseResponse, } from '../../Response' export interface AuthenticatorServerInterface { @@ -20,8 +19,7 @@ export interface AuthenticatorServerInterface { verifyRegistrationResponse( params: VerifyAuthenticatorRegistrationResponseRequestParams, ): Promise - generateAuthenticationOptions(): Promise - verifyAuthenticationResponse( - params: VerifyAuthenticatorAuthenticationResponseRequestParams, - ): Promise + generateAuthenticationOptions( + params: GenerateAuthenticatorAuthenticationOptionsRequestParams, + ): Promise } diff --git a/packages/api/src/Domain/Server/Authenticator/Paths.ts b/packages/api/src/Domain/Server/Authenticator/Paths.ts index 9ec186b99..aeaa3956d 100644 --- a/packages/api/src/Domain/Server/Authenticator/Paths.ts +++ b/packages/api/src/Domain/Server/Authenticator/Paths.ts @@ -4,7 +4,6 @@ const AuthenticatorPaths = { generateRegistrationOptions: '/v1/authenticators/generate-registration-options', verifyRegistrationResponse: '/v1/authenticators/verify-registration', generateAuthenticationOptions: '/v1/authenticators/generate-authentication-options', - verifyAuthenticationResponse: '/v1/authenticators/verify-authentication', } export const Paths = { diff --git a/packages/services/src/Domain/Authenticator/AuthenticatorClientInterface.ts b/packages/services/src/Domain/Authenticator/AuthenticatorClientInterface.ts index aa7d9795d..08ede8f6e 100644 --- a/packages/services/src/Domain/Authenticator/AuthenticatorClientInterface.ts +++ b/packages/services/src/Domain/Authenticator/AuthenticatorClientInterface.ts @@ -1,4 +1,4 @@ -import { Uuid } from '@standardnotes/domain-core' +import { Username, Uuid } from '@standardnotes/domain-core' export interface AuthenticatorClientInterface { list(): Promise> @@ -9,6 +9,5 @@ export interface AuthenticatorClientInterface { name: string, registrationCredential: Record, ): Promise - generateAuthenticationOptions(): Promise | null> - verifyAuthenticationResponse(userUuid: Uuid, authenticationCredential: Record): Promise + generateAuthenticationOptions(username: Username): Promise | null> } diff --git a/packages/services/src/Domain/Authenticator/AuthenticatorManager.ts b/packages/services/src/Domain/Authenticator/AuthenticatorManager.ts index ddd1be832..100f86e4f 100644 --- a/packages/services/src/Domain/Authenticator/AuthenticatorManager.ts +++ b/packages/services/src/Domain/Authenticator/AuthenticatorManager.ts @@ -1,7 +1,7 @@ /* istanbul ignore file */ import { AuthenticatorApiServiceInterface } from '@standardnotes/api' -import { Uuid } from '@standardnotes/domain-core' +import { Username, Uuid } from '@standardnotes/domain-core' import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface' import { AbstractService } from '../Service/AbstractService' @@ -79,9 +79,9 @@ export class AuthenticatorManager extends AbstractService implements Authenticat } } - async generateAuthenticationOptions(): Promise | null> { + async generateAuthenticationOptions(username: Username): Promise | null> { try { - const result = await this.authenticatorApiService.generateAuthenticationOptions() + const result = await this.authenticatorApiService.generateAuthenticationOptions(username.value) if (result.data.error) { return null @@ -92,24 +92,4 @@ export class AuthenticatorManager extends AbstractService implements Authenticat return null } } - - async verifyAuthenticationResponse( - userUuid: Uuid, - authenticationCredential: Record, - ): Promise { - try { - const result = await this.authenticatorApiService.verifyAuthenticationResponse( - userUuid.value, - authenticationCredential, - ) - - if (result.data.error) { - return false - } - - return result.data.success - } catch (error) { - return false - } - } } diff --git a/packages/services/src/Domain/Challenge/Prompt/ChallengePrompt.ts b/packages/services/src/Domain/Challenge/Prompt/ChallengePrompt.ts index 801dbffbe..9bd95a544 100644 --- a/packages/services/src/Domain/Challenge/Prompt/ChallengePrompt.ts +++ b/packages/services/src/Domain/Challenge/Prompt/ChallengePrompt.ts @@ -20,6 +20,7 @@ export class ChallengePrompt implements ChallengePromptInterface { public readonly secureTextEntry = true, public readonly keyboardType?: ChallengeKeyboardType, public readonly initialValue?: ChallengeRawValue, + public readonly contextData?: Record, ) { switch (this.validation) { case ChallengeValidation.AccountPassword: @@ -37,6 +38,11 @@ export class ChallengePrompt implements ChallengePromptInterface { this.placeholder = placeholder ?? '' this.validates = true break + case ChallengeValidation.Authenticator: + this.title = title ?? ChallengePromptTitle.U2F + this.placeholder = placeholder ?? '' + this.validates = true + break case ChallengeValidation.ProtectionSessionDuration: this.title = title ?? ChallengePromptTitle.RememberFor this.placeholder = placeholder ?? '' diff --git a/packages/services/src/Domain/Challenge/Prompt/PromptTitles.ts b/packages/services/src/Domain/Challenge/Prompt/PromptTitles.ts index 5dd7f0cf4..7e37367ea 100644 --- a/packages/services/src/Domain/Challenge/Prompt/PromptTitles.ts +++ b/packages/services/src/Domain/Challenge/Prompt/PromptTitles.ts @@ -6,4 +6,5 @@ export const ChallengePromptTitle = { Biometrics: 'Biometrics', RememberFor: 'Remember For', Mfa: 'Two-factor Authentication Code', + U2F: 'Security Key', } diff --git a/packages/services/src/Domain/Challenge/Types/ChallengeRawValue.ts b/packages/services/src/Domain/Challenge/Types/ChallengeRawValue.ts index b34c6606a..ca9715ab1 100644 --- a/packages/services/src/Domain/Challenge/Types/ChallengeRawValue.ts +++ b/packages/services/src/Domain/Challenge/Types/ChallengeRawValue.ts @@ -1,3 +1,3 @@ /* istanbul ignore file */ -export type ChallengeRawValue = number | string | boolean +export type ChallengeRawValue = number | string | boolean | Record diff --git a/packages/services/src/Domain/Challenge/Types/ChallengeValidation.ts b/packages/services/src/Domain/Challenge/Types/ChallengeValidation.ts index 3f8f32b5a..fc867ca78 100644 --- a/packages/services/src/Domain/Challenge/Types/ChallengeValidation.ts +++ b/packages/services/src/Domain/Challenge/Types/ChallengeValidation.ts @@ -6,4 +6,5 @@ export enum ChallengeValidation { AccountPassword = 2, Biometric = 3, ProtectionSessionDuration = 4, + Authenticator = 5, } diff --git a/packages/services/src/Domain/Strings/Messages.ts b/packages/services/src/Domain/Strings/Messages.ts index 5b68848fc..abaed42f7 100644 --- a/packages/services/src/Domain/Strings/Messages.ts +++ b/packages/services/src/Domain/Strings/Messages.ts @@ -121,6 +121,7 @@ export const SessionStrings = { }, SessionRestored: 'Your session has been successfully restored.', EnterMfa: 'Please enter your two-factor authentication code.', + InputU2FDevice: 'Please authenticate with your U2F device.', MfaInputPlaceholder: 'Two-factor authentication code', EmailInputPlaceholder: 'Email', PasswordInputPlaceholder: 'Password', diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index 77e555106..78ed3318a 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -105,11 +105,11 @@ import { GetRecoveryCodes } from '@Lib/Domain/UseCase/GetRecoveryCodes/GetRecove import { AddAuthenticator } from '@Lib/Domain/UseCase/AddAuthenticator/AddAuthenticator' import { ListAuthenticators } from '@Lib/Domain/UseCase/ListAuthenticators/ListAuthenticators' import { DeleteAuthenticator } from '@Lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator' -import { VerifyAuthenticator } from '@Lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator' import { ListRevisions } from '@Lib/Domain/UseCase/ListRevisions/ListRevisions' import { GetRevision } from '@Lib/Domain/UseCase/GetRevision/GetRevision' import { DeleteRevision } from '@Lib/Domain/UseCase/DeleteRevision/DeleteRevision' import { RevisionMetadata } from '@Lib/Domain/Revision/RevisionMetadata' +import { GetAuthenticatorAuthenticationResponse } from '@Lib/Domain/UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponse' /** How often to automatically sync, in milliseconds */ const DEFAULT_AUTO_SYNC_INTERVAL = 30_000 @@ -193,7 +193,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private declare _addAuthenticator: AddAuthenticator private declare _listAuthenticators: ListAuthenticators private declare _deleteAuthenticator: DeleteAuthenticator - private declare _verifyAuthenticator: VerifyAuthenticator + private declare _getAuthenticatorAuthenticationResponse: GetAuthenticatorAuthenticationResponse private declare _listRevisions: ListRevisions private declare _getRevision: GetRevision private declare _deleteRevision: DeleteRevision @@ -299,8 +299,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli return this._deleteAuthenticator } - get verifyAuthenticator(): UseCaseInterface { - return this._verifyAuthenticator + get getAuthenticatorAuthenticationResponse(): UseCaseInterface> { + return this._getAuthenticatorAuthenticationResponse } get listRevisions(): UseCaseInterface> { @@ -1272,7 +1272,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli ;(this._addAuthenticator as unknown) = undefined ;(this._listAuthenticators as unknown) = undefined ;(this._deleteAuthenticator as unknown) = undefined - ;(this._verifyAuthenticator as unknown) = undefined + ;(this._getAuthenticatorAuthenticationResponse as unknown) = undefined ;(this._listRevisions as unknown) = undefined ;(this._getRevision as unknown) = undefined ;(this._deleteRevision as unknown) = undefined @@ -1849,7 +1849,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this._deleteAuthenticator = new DeleteAuthenticator(this.authenticatorManager) - this._verifyAuthenticator = new VerifyAuthenticator( + this._getAuthenticatorAuthenticationResponse = new GetAuthenticatorAuthenticationResponse( this.authenticatorManager, this.options.u2fAuthenticatorVerificationPromptFunction, ) diff --git a/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator.spec.ts b/packages/snjs/lib/Domain/UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponse.spec.ts similarity index 55% rename from packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator.spec.ts rename to packages/snjs/lib/Domain/UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponse.spec.ts index cf71cd339..882e7a9b9 100644 --- a/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator.spec.ts +++ b/packages/snjs/lib/Domain/UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponse.spec.ts @@ -1,27 +1,37 @@ import { AuthenticatorClientInterface } from '@standardnotes/services' -import { VerifyAuthenticator } from './VerifyAuthenticator' +import { GetAuthenticatorAuthenticationResponse } from './GetAuthenticatorAuthenticationResponse' -describe('VerifyAuthenticator', () => { +describe('GetAuthenticatorAuthenticationResponse', () => { let authenticatorClient: AuthenticatorClientInterface let authenticatorVerificationPromptFunction: ( authenticationOptions: Record, ) => Promise> - const createUseCase = () => new VerifyAuthenticator(authenticatorClient, authenticatorVerificationPromptFunction) + const createUseCase = () => new GetAuthenticatorAuthenticationResponse(authenticatorClient, authenticatorVerificationPromptFunction) beforeEach(() => { authenticatorClient = {} as jest.Mocked authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue({ foo: 'bar' }) - authenticatorClient.verifyAuthenticationResponse = jest.fn().mockResolvedValue(true) authenticatorVerificationPromptFunction = jest.fn() }) + it('should return an error if username is not provided', async () => { + const result = await createUseCase().execute({ + username: '', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Could not generate authenticator authentication options: Username cannot be empty') + }) + it('should return an error if authenticator client fails to generate authentication options', async () => { authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue(null) - const result = await createUseCase().execute({ userUuid: '00000000-0000-0000-0000-000000000000' }) + const result = await createUseCase().execute({ + username: 'test@test.te', + }) expect(result.isFailed()).toBe(true) expect(result.getError()).toBe('Could not generate authenticator authentication options') @@ -30,36 +40,25 @@ describe('VerifyAuthenticator', () => { it('should return an error if authenticator verification prompt function fails', async () => { authenticatorVerificationPromptFunction = jest.fn().mockRejectedValue(new Error('error')) - const result = await createUseCase().execute({ userUuid: '00000000-0000-0000-0000-000000000000' }) + const result = await createUseCase().execute({ + username: 'test@test.te', + }) expect(result.isFailed()).toBe(true) expect(result.getError()).toBe('Could not generate authenticator authentication options: error') }) - it('should return an error if authenticator client fails to verify authentication response', async () => { - authenticatorClient.verifyAuthenticationResponse = jest.fn().mockResolvedValue(false) - - const result = await createUseCase().execute({ userUuid: '00000000-0000-0000-0000-000000000000' }) - - expect(result.isFailed()).toBe(true) - expect(result.getError()).toBe('Could not generate authenticator authentication options') - }) - - it('should return ok if authenticator client succeeds to verify authentication response', async () => { - const result = await createUseCase().execute({ userUuid: '00000000-0000-0000-0000-000000000000' }) + it('should return ok if authenticator client succeeds to generate authenticator response', async () => { + const result = await createUseCase().execute({ + username: 'test@test.te', + }) expect(result.isFailed()).toBe(false) }) - it('should return an error if user uuid is invalid', async () => { - const result = await createUseCase().execute({ userUuid: 'invalid' }) - - expect(result.isFailed()).toBe(true) - }) - it('should return error if authenticatorVerificationPromptFunction is not provided', async () => { - const result = await new VerifyAuthenticator(authenticatorClient).execute({ - userUuid: '00000000-0000-0000-0000-000000000000', + const result = await new GetAuthenticatorAuthenticationResponse(authenticatorClient).execute({ + username: 'test@test.te', }) expect(result.isFailed()).toBe(true) diff --git a/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator.ts b/packages/snjs/lib/Domain/UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponse.ts similarity index 57% rename from packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator.ts rename to packages/snjs/lib/Domain/UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponse.ts index e1f0e732b..fad5fe6e1 100644 --- a/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator.ts +++ b/packages/snjs/lib/Domain/UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponse.ts @@ -1,9 +1,8 @@ import { AuthenticatorClientInterface } from '@standardnotes/services' -import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core' +import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core' +import { GetAuthenticatorAuthenticationResponseDTO } from './GetAuthenticatorAuthenticationResponseDTO' -import { VerifyAuthenticatorDTO } from './VerifyAuthenticatorDTO' - -export class VerifyAuthenticator implements UseCaseInterface { +export class GetAuthenticatorAuthenticationResponse implements UseCaseInterface> { constructor( private authenticatorClient: AuthenticatorClientInterface, private authenticatorVerificationPromptFunction?: ( @@ -11,20 +10,20 @@ export class VerifyAuthenticator implements UseCaseInterface { ) => Promise>, ) {} - async execute(dto: VerifyAuthenticatorDTO): Promise> { + async execute(dto: GetAuthenticatorAuthenticationResponseDTO): Promise>> { if (!this.authenticatorVerificationPromptFunction) { return Result.fail( 'Could not generate authenticator authentication options: No authenticator verification prompt function provided', ) } - const userUuidOrError = Uuid.create(dto.userUuid) - if (userUuidOrError.isFailed()) { - return Result.fail(`Could not generate authenticator authentication options: ${userUuidOrError.getError()}`) + const usernameOrError = Username.create(dto.username) + if (usernameOrError.isFailed()) { + return Result.fail(`Could not generate authenticator authentication options: ${usernameOrError.getError()}`) } - const userUuid = userUuidOrError.getValue() + const username = usernameOrError.getValue() - const authenticationOptions = await this.authenticatorClient.generateAuthenticationOptions() + const authenticationOptions = await this.authenticatorClient.generateAuthenticationOptions(username) if (authenticationOptions === null) { return Result.fail('Could not generate authenticator authentication options') } @@ -36,14 +35,6 @@ export class VerifyAuthenticator implements UseCaseInterface { return Result.fail(`Could not generate authenticator authentication options: ${(error as Error).message}`) } - const verificationResponse = await this.authenticatorClient.verifyAuthenticationResponse( - userUuid, - authenticatorResponse, - ) - if (!verificationResponse) { - return Result.fail('Could not generate authenticator authentication options') - } - - return Result.ok() + return Result.ok(authenticatorResponse) } } diff --git a/packages/snjs/lib/Domain/UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponseDTO.ts b/packages/snjs/lib/Domain/UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponseDTO.ts new file mode 100644 index 000000000..6f1882423 --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponseDTO.ts @@ -0,0 +1,3 @@ +export interface GetAuthenticatorAuthenticationResponseDTO { + username: string +} diff --git a/packages/snjs/lib/Domain/UseCase/UseCaseContainerInterface.ts b/packages/snjs/lib/Domain/UseCase/UseCaseContainerInterface.ts index 26ea0c7fe..4399a41e6 100644 --- a/packages/snjs/lib/Domain/UseCase/UseCaseContainerInterface.ts +++ b/packages/snjs/lib/Domain/UseCase/UseCaseContainerInterface.ts @@ -9,7 +9,7 @@ export interface UseCaseContainerInterface { get addAuthenticator(): UseCaseInterface get listAuthenticators(): UseCaseInterface> get deleteAuthenticator(): UseCaseInterface - get verifyAuthenticator(): UseCaseInterface + get getAuthenticatorAuthenticationResponse(): UseCaseInterface> get listRevisions(): UseCaseInterface> get getRevision(): UseCaseInterface get deleteRevision(): UseCaseInterface diff --git a/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticatorDTO.ts b/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticatorDTO.ts deleted file mode 100644 index 3ea9e5485..000000000 --- a/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticatorDTO.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface VerifyAuthenticatorDTO { - userUuid: string -} diff --git a/packages/snjs/lib/Services/Api/ApiService.ts b/packages/snjs/lib/Services/Api/ApiService.ts index bf22149cb..cd4767d7e 100644 --- a/packages/snjs/lib/Services/Api/ApiService.ts +++ b/packages/snjs/lib/Services/Api/ApiService.ts @@ -232,6 +232,7 @@ export class SNApiService email: string mfaKeyPath?: string mfaCode?: string + authenticatorResponse?: Record }): Promise { const codeVerifier = this.crypto.generateRandomKey(256) this.inMemoryStore.setValue(StorageKey.CodeVerifier, codeVerifier) @@ -247,6 +248,10 @@ export class SNApiService params[dto.mfaKeyPath] = dto.mfaCode } + if (dto.authenticatorResponse) { + params.authenticator_response = dto.authenticatorResponse + } + return this.request({ verb: HttpVerb.Post, url: joinPaths(this.host, Paths.v2.keyParams), diff --git a/packages/snjs/lib/Services/Challenge/ChallengeService.ts b/packages/snjs/lib/Services/Challenge/ChallengeService.ts index 567012bef..d22103d8c 100644 --- a/packages/snjs/lib/Services/Challenge/ChallengeService.ts +++ b/packages/snjs/lib/Services/Challenge/ChallengeService.ts @@ -93,6 +93,8 @@ export class ChallengeService extends AbstractService implements ChallengeServic return this.protocolService.validateAccountPassword(value.value as string) case ChallengeValidation.Biometric: return { valid: value.value === true } + case ChallengeValidation.Authenticator: + return { valid: 'id' in (value.value as Record) } case ChallengeValidation.ProtectionSessionDuration: return { valid: isValidProtectionSessionLength(value.value) } default: diff --git a/packages/snjs/lib/Services/Session/SessionManager.ts b/packages/snjs/lib/Services/Session/SessionManager.ts index e5454acb5..c8f906c9e 100644 --- a/packages/snjs/lib/Services/Session/SessionManager.ts +++ b/packages/snjs/lib/Services/Session/SessionManager.ts @@ -48,6 +48,7 @@ import { ChallengeService } from '../Challenge' import { ApiCallError, ErrorMessage, + ErrorTag, HttpErrorResponseBody, HttpServiceInterface, UserApiServiceInterface, @@ -284,6 +285,35 @@ export class SNSessionManager return (response as Responses.GetAvailableSubscriptionsResponse).data! } + private async promptForU2FVerification(username: string): Promise | undefined> { + const challenge = new Challenge( + [ + new ChallengePrompt( + ChallengeValidation.Authenticator, + ChallengePromptTitle.U2F, + undefined, + false, + undefined, + undefined, + { + username, + }, + ), + ], + ChallengeReason.Custom, + true, + SessionStrings.InputU2FDevice, + ) + + const response = await this.challengeService.promptForChallengeResponse(challenge) + + if (!response) { + return undefined + } + + return response.values[0].value as Record + } + private async promptForMfaValue(): Promise { const challenge = new Challenge( [ @@ -344,31 +374,28 @@ export class SNSessionManager return registerResponse.data } - private async retrieveKeyParams( - email: string, - mfaKeyPath?: string, - mfaCode?: string, - ): Promise<{ + private async retrieveKeyParams(dto: { + email: string + mfaKeyPath?: string + mfaCode?: string + authenticatorResponse?: Record + }): Promise<{ keyParams?: SNRootKeyParams response: Responses.KeyParamsResponse | Responses.HttpResponse mfaKeyPath?: string mfaCode?: string }> { - const response = await this.apiService.getAccountKeyParams({ - email, - mfaKeyPath, - mfaCode, - }) + const response = await this.apiService.getAccountKeyParams(dto) if (response.error || isNullOrUndefined(response.data)) { - if (mfaCode) { + if (dto.mfaCode) { await this.alertService.alert(SignInStrings.IncorrectMfa) } - if (response.error?.payload?.mfa_key) { - /** Prompt for MFA code and try again */ - const inputtedCode = await this.promptForMfaValue() - if (!inputtedCode) { - /** User dismissed window without input */ + + if ([ErrorTag.U2FRequired, ErrorTag.MfaRequired].includes(response.error?.tag as ErrorTag)) { + const isU2FRequired = response.error?.tag === ErrorTag.U2FRequired + const result = isU2FRequired ? await this.promptForU2FVerification(dto.email) : await this.promptForMfaValue() + if (!result) { return { response: this.apiService.createErrorResponse( SignInStrings.SignInCanceledMissingMfa, @@ -376,19 +403,25 @@ export class SNSessionManager ), } } - return this.retrieveKeyParams(email, response.error.payload.mfa_key, inputtedCode) + + return this.retrieveKeyParams({ + email: dto.email, + mfaKeyPath: isU2FRequired ? undefined : response.error?.payload?.mfa_key, + mfaCode: isU2FRequired ? undefined : (result as string), + authenticatorResponse: isU2FRequired ? (result as Record) : undefined, + }) } else { return { response } } } /** Make sure to use client value for identifier/email */ - const keyParams = KeyParamsFromApiResponse(response as Responses.KeyParamsResponse, email) + const keyParams = KeyParamsFromApiResponse(response as Responses.KeyParamsResponse, dto.email) if (!keyParams || !keyParams.version) { return { response: this.apiService.createErrorResponse(API_MESSAGE_FALLBACK_LOGIN_FAIL), } } - return { keyParams, response, mfaKeyPath, mfaCode } + return { keyParams, response, mfaKeyPath: dto.mfaKeyPath, mfaCode: dto.mfaCode } } public async signIn( @@ -425,7 +458,9 @@ export class SNSessionManager ephemeral = false, minAllowedVersion?: Common.ProtocolVersion, ): Promise { - const paramsResult = await this.retrieveKeyParams(email) + const paramsResult = await this.retrieveKeyParams({ + email, + }) if (paramsResult.response.error) { return { response: paramsResult.response, diff --git a/packages/snjs/package.json b/packages/snjs/package.json index 45624042f..05f81305f 100644 --- a/packages/snjs/package.json +++ b/packages/snjs/package.json @@ -37,6 +37,7 @@ "@babel/preset-env": "*", "@standardnotes/api": "workspace:*", "@standardnotes/common": "^1.46.4", + "@standardnotes/domain-core": "^1.11.1", "@standardnotes/domain-events": "^2.106.0", "@standardnotes/encryption": "workspace:*", "@standardnotes/features": "workspace:*", @@ -84,8 +85,5 @@ "webpack": "*", "webpack-cli": "*", "webpack-merge": "^5.8.0" - }, - "dependencies": { - "@standardnotes/domain-core": "^1.11.1" } } diff --git a/packages/web/jest.config.js b/packages/web/jest.config.js index 6e7d3de93..a0f4c1e52 100644 --- a/packages/web/jest.config.js +++ b/packages/web/jest.config.js @@ -14,6 +14,7 @@ module.exports = { '\\.(css|less|scss|sass)$': 'identity-obj-proxy', '@standardnotes/toast': 'identity-obj-proxy', '@standardnotes/styles': 'identity-obj-proxy', + '@simplewebauthn/browser': 'identity-obj-proxy', }, globals: { __WEB_VERSION__: '1.0.0', diff --git a/packages/web/package.json b/packages/web/package.json index c65af281d..12c3d82e4 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -116,6 +116,7 @@ "app/**/*.{js,ts,jsx,tsx,css,md}": "prettier --write" }, "dependencies": { - "@lexical/headless": "^0.7.6" + "@lexical/headless": "^0.7.6", + "@simplewebauthn/browser": "^7.0.0" } } diff --git a/packages/web/src/javascripts/Application/Application.ts b/packages/web/src/javascripts/Application/Application.ts index 692fbf18d..c87b01f51 100644 --- a/packages/web/src/javascripts/Application/Application.ts +++ b/packages/web/src/javascripts/Application/Application.ts @@ -25,6 +25,7 @@ import { ApplicationOptionsDefaults, } from '@standardnotes/snjs' import { makeObservable, observable } from 'mobx' +import { startAuthentication, startRegistration } from '@simplewebauthn/browser' import { PanelResizedData } from '@/Types/PanelResizedData' import { isAndroid, isDesktopApplication, isIOS } from '@/Utils' import { DesktopManager } from './Device/DesktopManager' @@ -83,6 +84,8 @@ export class WebApplication extends SNApplication implements WebApplicationInter deviceInterface.environment === Environment.Mobile ? 250 : ApplicationOptionsDefaults.sleepBetweenBatches, allowMultipleSelection: deviceInterface.environment !== Environment.Mobile, allowNoteSelectionStatePersistence: deviceInterface.environment !== Environment.Mobile, + u2fAuthenticatorRegistrationPromptFunction: startRegistration, + u2fAuthenticatorVerificationPromptFunction: startAuthentication, }) makeObservable(this, { diff --git a/packages/web/src/javascripts/Components/ChallengeModal/ChallengeModal.tsx b/packages/web/src/javascripts/Components/ChallengeModal/ChallengeModal.tsx index 8128c5199..10e245344 100644 --- a/packages/web/src/javascripts/Components/ChallengeModal/ChallengeModal.tsx +++ b/packages/web/src/javascripts/Components/ChallengeModal/ChallengeModal.tsx @@ -180,13 +180,23 @@ const ChallengeModal: FunctionComponent = ({ }, [application, challenge, onDismiss]) const biometricPrompt = challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.Biometric) + const authenticatorPrompt = challenge.prompts.find( + (prompt) => prompt.validation === ChallengeValidation.Authenticator, + ) const hasOnlyBiometricPrompt = challenge.prompts.length === 1 && !!biometricPrompt - const wasBiometricInputSuccessful = biometricPrompt && !!values[biometricPrompt.id].value + const hasOnlyAuthenticatorPrompt = challenge.prompts.length === 1 && !!authenticatorPrompt + const wasBiometricInputSuccessful = !!biometricPrompt && !!values[biometricPrompt.id].value + const wasAuthenticatorInputSuccessful = !!authenticatorPrompt && !!values[authenticatorPrompt.id].value const hasSecureTextPrompt = challenge.prompts.some((prompt) => prompt.secureTextEntry) + const shouldShowSubmitButton = !(hasOnlyBiometricPrompt || hasOnlyAuthenticatorPrompt) useEffect(() => { - const shouldAutoSubmit = hasOnlyBiometricPrompt && wasBiometricInputSuccessful + const shouldAutoSubmit = + (hasOnlyBiometricPrompt && wasBiometricInputSuccessful) || + (hasOnlyAuthenticatorPrompt && wasAuthenticatorInputSuccessful) + const shouldFocusSecureTextPrompt = hasSecureTextPrompt && wasBiometricInputSuccessful + if (shouldAutoSubmit) { submit() } else if (shouldFocusSecureTextPrompt) { @@ -195,7 +205,14 @@ const ChallengeModal: FunctionComponent = ({ ) as HTMLInputElement | null secureTextEntry?.focus() } - }, [wasBiometricInputSuccessful, hasOnlyBiometricPrompt, submit, hasSecureTextPrompt]) + }, [ + wasBiometricInputSuccessful, + hasOnlyBiometricPrompt, + submit, + hasSecureTextPrompt, + hasOnlyAuthenticatorPrompt, + wasAuthenticatorInputSuccessful, + ]) useEffect(() => { const removeListener = application.addAndroidBackHandlerEventListener(() => { @@ -289,12 +306,15 @@ const ChallengeModal: FunctionComponent = ({ index={index} onValueChange={onValueChange} isInvalid={values[prompt.id].invalid} + contextData={prompt.contextData} /> ))} - + {shouldShowSubmitButton && ( + + )} {shouldShowForgotPasscode && ( + + ) +} + +export default U2FPrompt diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/Security.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/Security.tsx index f1649f763..dafd12607 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Security/Security.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/Security.tsx @@ -11,6 +11,8 @@ import ErroredItems from './ErroredItems' import PreferencesPane from '@/Components/Preferences/PreferencesComponents/PreferencesPane' import BiometricsLock from '@/Components/Preferences/Panes/Security/BiometricsLock' import MultitaskingPrivacy from '@/Components/Preferences/Panes/Security/MultitaskingPrivacy' +import U2FWrapper from './U2F/U2FWrapper' +import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk' interface SecurityProps extends MfaProps { viewControllerManager: ViewControllerManager @@ -32,6 +34,9 @@ const Security: FunctionComponent = (props) => { userProvider={props.userProvider} application={props.application} /> + {featureTrunkEnabled(FeatureTrunkName.U2F) && ( + + )} {isNativeMobileWeb && } {isNativeMobileWeb && } diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FAddDeviceView.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FAddDeviceView.tsx new file mode 100644 index 000000000..d5f19118e --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FAddDeviceView.tsx @@ -0,0 +1,94 @@ +import { FunctionComponent, useCallback, useState } from 'react' +import { observer } from 'mobx-react-lite' +import { UseCaseInterface } from '@standardnotes/snjs' + +import DecoratedInput from '@/Components/Input/DecoratedInput' +import { UserProvider } from '@/Components/Preferences/Providers' +import Modal from '@/Components/Modal/Modal' +import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' + +type Props = { + userProvider: UserProvider + addAuthenticator: UseCaseInterface + onDeviceAddingModalToggle: (show: boolean) => void + onDeviceAdded: () => Promise +} + +const U2FAddDeviceView: FunctionComponent = ({ + userProvider, + addAuthenticator, + onDeviceAddingModalToggle, + onDeviceAdded, +}) => { + const [deviceName, setDeviceName] = useState('') + const [errorMessage, setErrorMessage] = useState('') + + const handleDeviceNameChange = useCallback((deviceName: string) => { + setDeviceName(deviceName) + }, []) + + const handleAddDeviceClick = useCallback(async () => { + if (!deviceName) { + setErrorMessage('Device name is required') + return + } + + const user = userProvider.getUser() + if (user === undefined) { + setErrorMessage('User not found') + return + } + + const authenticatorAddedOrError = await addAuthenticator.execute({ + userUuid: user.uuid, + authenticatorName: deviceName, + }) + if (authenticatorAddedOrError.isFailed()) { + setErrorMessage(authenticatorAddedOrError.getError()) + return + } + + onDeviceAddingModalToggle(false) + await onDeviceAdded() + }, [deviceName, setErrorMessage, userProvider, addAuthenticator, onDeviceAddingModalToggle, onDeviceAdded]) + + const closeModal = () => { + onDeviceAddingModalToggle(false) + } + + const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm) + + return ( + + Add Device + + ), + type: 'primary', + onClick: handleAddDeviceClick, + mobileSlot: 'right', + }, + ]} + > +
...Some Cool Device Picture Here...
+
+ +
+ {errorMessage &&
{errorMessage}
} +
+ ) +} + +export default observer(U2FAddDeviceView) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FProps.ts b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FProps.ts new file mode 100644 index 000000000..b868594f7 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FProps.ts @@ -0,0 +1,7 @@ +import { WebApplication } from '@/Application/Application' +import { UserProvider } from '@/Components/Preferences/Providers' + +export interface U2FProps { + userProvider: UserProvider + application: WebApplication +} diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FView/U2FDescription.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FView/U2FDescription.tsx new file mode 100644 index 000000000..c625a8942 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FView/U2FDescription.tsx @@ -0,0 +1,19 @@ +import { FunctionComponent } from 'react' +import { observer } from 'mobx-react-lite' + +import { Text } from '@/Components/Preferences/PreferencesComponents/Content' +import { UserProvider } from '@/Components/Preferences/Providers' + +type Props = { + userProvider: UserProvider +} + +const U2FDescription: FunctionComponent = ({ userProvider }) => { + if (userProvider.getUser() === undefined) { + return Sign in or register for an account to configure U2F. + } + + return Authenticate with a U2F hardware device. +} + +export default observer(U2FDescription) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FView/U2FDevicesList.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FView/U2FDevicesList.tsx new file mode 100644 index 000000000..22c8c45e3 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FView/U2FDevicesList.tsx @@ -0,0 +1,57 @@ +import { FunctionComponent, useCallback } from 'react' +import { observer } from 'mobx-react-lite' + +import { Text } from '@/Components/Preferences/PreferencesComponents/Content' +import { WebApplication } from '@/Application/Application' +import Button from '@/Components/Button/Button' + +type Props = { + application: WebApplication + devices: Array<{ id: string; name: string }> + onDeviceDeleted: () => Promise + onError: (error: string) => void +} + +const U2FDevicesList: FunctionComponent = ({ application, devices, onError, onDeviceDeleted }) => { + const handleDeleteButtonOnClick = useCallback( + async (authenticatorId: string) => { + const deleteAuthenticatorOrError = await application.deleteAuthenticator.execute({ + authenticatorId, + }) + + if (deleteAuthenticatorOrError.isFailed()) { + onError(deleteAuthenticatorOrError.getError()) + + return + } + + await onDeviceDeleted() + }, + [application, onDeviceDeleted, onError], + ) + + return ( +
+ {devices.length > 0 && ( +
+
+ Devices: +
+ {devices.map((device) => ( +
+ {device.name} + +
+ ))} +
+ )} +
+ ) +} + +export default observer(U2FDevicesList) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FView/U2FTitle.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FView/U2FTitle.tsx new file mode 100644 index 000000000..53f286b80 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FView/U2FTitle.tsx @@ -0,0 +1,19 @@ +import { FunctionComponent } from 'react' +import { observer } from 'mobx-react-lite' + +import { Title } from '@/Components/Preferences/PreferencesComponents/Content' +import { UserProvider } from '@/Components/Preferences/Providers' + +type Props = { + userProvider: UserProvider +} + +const U2FTitle: FunctionComponent = ({ userProvider }) => { + if (userProvider.getUser() === undefined) { + return Universal 2nd Factor authentication not available + } + + return Universal 2nd Factor authentication +} + +export default observer(U2FTitle) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FView/U2FView.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FView/U2FView.tsx new file mode 100644 index 000000000..b42b9ca40 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FView/U2FView.tsx @@ -0,0 +1,80 @@ +import { FunctionComponent, useCallback, useEffect, useState } from 'react' +import { observer } from 'mobx-react-lite' + +import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup' +import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment' +import { WebApplication } from '@/Application/Application' +import { UserProvider } from '@/Components/Preferences/Providers' + +import U2FTitle from './U2FTitle' +import U2FDescription from './U2FDescription' +import Button from '@/Components/Button/Button' +import U2FAddDeviceView from '../U2FAddDeviceView' +import U2FDevicesList from './U2FDevicesList' + +type Props = { + application: WebApplication + userProvider: UserProvider +} + +const U2FView: FunctionComponent = ({ application, userProvider }) => { + const [showDeviceAddingModal, setShowDeviceAddingModal] = useState(false) + const [devices, setDevices] = useState>([]) + const [error, setError] = useState('') + + const handleAddDeviceClick = useCallback(() => { + setShowDeviceAddingModal(true) + }, []) + + const loadAuthenticatorDevices = useCallback(async () => { + const authenticatorListOrError = await application.listAuthenticators.execute() + if (authenticatorListOrError.isFailed()) { + setError(authenticatorListOrError.getError()) + + return + } + + setDevices(authenticatorListOrError.getValue()) + }, [setError, setDevices, application]) + + useEffect(() => { + loadAuthenticatorDevices().catch(console.error) + }, [loadAuthenticatorDevices]) + + return ( + <> + + +
+
+ + +
+ +
+
+ + {error &&
{error}
} + +
+
+ {showDeviceAddingModal && ( + + )} + + ) +} + +export default observer(U2FView) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FWrapper.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FWrapper.tsx new file mode 100644 index 000000000..0fe008892 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/U2F/U2FWrapper.tsx @@ -0,0 +1,10 @@ +import { FunctionComponent } from 'react' + +import { U2FProps } from './U2FProps' +import U2FView from './U2FView/U2FView' + +const U2FWrapper: FunctionComponent = (props) => { + return +} + +export default U2FWrapper diff --git a/packages/web/src/javascripts/FeatureTrunk.ts b/packages/web/src/javascripts/FeatureTrunk.ts index 50130941a..42b27203a 100644 --- a/packages/web/src/javascripts/FeatureTrunk.ts +++ b/packages/web/src/javascripts/FeatureTrunk.ts @@ -3,11 +3,13 @@ import { isDev } from '@/Utils' export enum FeatureTrunkName { Super, ImportTools, + U2F, } const FeatureTrunkStatus: Record = { [FeatureTrunkName.Super]: isDev && true, [FeatureTrunkName.ImportTools]: isDev && true, + [FeatureTrunkName.U2F]: isDev && true, } export function featureTrunkEnabled(trunk: FeatureTrunkName): boolean { diff --git a/yarn.lock b/yarn.lock index d5ae53a8f..2e141b482 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4335,6 +4335,13 @@ __metadata: languageName: node linkType: hard +"@simplewebauthn/browser@npm:^7.0.0": + version: 7.0.0 + resolution: "@simplewebauthn/browser@npm:7.0.0" + checksum: eb8d7e2d923649c116275cc9bfbabfa27a180cd33dbf9d6a28c7aa9460ea79cd25204c9a7d76ed8cc24764da4a09b5939209aa30e9b295b9d54e497bb9b652a4 + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.24.1": version: 0.24.46 resolution: "@sinclair/typebox@npm:0.24.46" @@ -5264,6 +5271,7 @@ __metadata: "@reach/listbox": ^0.18.0 "@reach/tooltip": ^0.18.0 "@reach/visually-hidden": ^0.18.0 + "@simplewebauthn/browser": ^7.0.0 "@standardnotes/authenticator": ^2.3.9 "@standardnotes/autobiography-theme": ^1.2.7 "@standardnotes/blocks-editor": "workspace:*"