feat(dev): add u2f ui for managing devices and signing in (#2182)

* feat: add u2f ui for managing devices and signing in

* refactor: change unnecessary useState to derived constant

* fix: modal refactor

* fix(web): hide u2f under feature trunk

* fix(web): jest setup

---------

Co-authored-by: Aman Harwara <amanharwara@protonmail.com>
This commit is contained in:
Karol Sójko
2023-02-03 07:54:56 +01:00
committed by GitHub
parent b4f14c668d
commit 9414774e89
48 changed files with 552 additions and 190 deletions

View File

@@ -4,5 +4,4 @@ export enum AuthenticatorApiOperations {
GenerateRegistrationOptions, GenerateRegistrationOptions,
GenerateAuthenticationOptions, GenerateAuthenticationOptions,
VerifyRegistrationResponse, VerifyRegistrationResponse,
VerifyAuthenticationResponse,
} }

View File

@@ -9,7 +9,6 @@ import {
GenerateAuthenticatorRegistrationOptionsResponse, GenerateAuthenticatorRegistrationOptionsResponse,
VerifyAuthenticatorRegistrationResponseResponse, VerifyAuthenticatorRegistrationResponseResponse,
GenerateAuthenticatorAuthenticationOptionsResponse, GenerateAuthenticatorAuthenticationOptionsResponse,
VerifyAuthenticatorAuthenticationResponseResponse,
} from '../../Response' } from '../../Response'
import { AuthenticatorServerInterface } from '../../Server/Authenticator/AuthenticatorServerInterface' import { AuthenticatorServerInterface } from '../../Server/Authenticator/AuthenticatorServerInterface'
@@ -79,7 +78,7 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface
async verifyRegistrationResponse( async verifyRegistrationResponse(
userUuid: string, userUuid: string,
name: string, name: string,
registrationCredential: Record<string, unknown>, attestationResponse: Record<string, unknown>,
): Promise<VerifyAuthenticatorRegistrationResponseResponse> { ): Promise<VerifyAuthenticatorRegistrationResponseResponse> {
if (this.operationsInProgress.get(AuthenticatorApiOperations.VerifyRegistrationResponse)) { if (this.operationsInProgress.get(AuthenticatorApiOperations.VerifyRegistrationResponse)) {
throw new ApiCallError(ErrorMessage.GenericInProgress) throw new ApiCallError(ErrorMessage.GenericInProgress)
@@ -91,7 +90,7 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface
const response = await this.authenticatorServer.verifyRegistrationResponse({ const response = await this.authenticatorServer.verifyRegistrationResponse({
userUuid, userUuid,
name, name,
registrationCredential, attestationResponse,
}) })
return response return response
@@ -102,7 +101,7 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface
} }
} }
async generateAuthenticationOptions(): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> { async generateAuthenticationOptions(username: string): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> {
if (this.operationsInProgress.get(AuthenticatorApiOperations.GenerateAuthenticationOptions)) { if (this.operationsInProgress.get(AuthenticatorApiOperations.GenerateAuthenticationOptions)) {
throw new ApiCallError(ErrorMessage.GenericInProgress) throw new ApiCallError(ErrorMessage.GenericInProgress)
} }
@@ -110,7 +109,9 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface
this.operationsInProgress.set(AuthenticatorApiOperations.GenerateAuthenticationOptions, true) this.operationsInProgress.set(AuthenticatorApiOperations.GenerateAuthenticationOptions, true)
try { try {
const response = await this.authenticatorServer.generateAuthenticationOptions() const response = await this.authenticatorServer.generateAuthenticationOptions({
username,
})
return response return response
} catch (error) { } catch (error) {
@@ -119,28 +120,4 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface
this.operationsInProgress.set(AuthenticatorApiOperations.GenerateAuthenticationOptions, false) this.operationsInProgress.set(AuthenticatorApiOperations.GenerateAuthenticationOptions, false)
} }
} }
async verifyAuthenticationResponse(
userUuid: string,
authenticationCredential: Record<string, unknown>,
): Promise<VerifyAuthenticatorAuthenticationResponseResponse> {
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)
}
}
} }

View File

@@ -4,7 +4,6 @@ import {
GenerateAuthenticatorRegistrationOptionsResponse, GenerateAuthenticatorRegistrationOptionsResponse,
VerifyAuthenticatorRegistrationResponseResponse, VerifyAuthenticatorRegistrationResponseResponse,
GenerateAuthenticatorAuthenticationOptionsResponse, GenerateAuthenticatorAuthenticationOptionsResponse,
VerifyAuthenticatorAuthenticationResponseResponse,
} from '../../Response' } from '../../Response'
export interface AuthenticatorApiServiceInterface { export interface AuthenticatorApiServiceInterface {
@@ -14,11 +13,7 @@ export interface AuthenticatorApiServiceInterface {
verifyRegistrationResponse( verifyRegistrationResponse(
userUuid: string, userUuid: string,
name: string, name: string,
registrationCredential: Record<string, unknown>, attestationResponse: Record<string, unknown>,
): Promise<VerifyAuthenticatorRegistrationResponseResponse> ): Promise<VerifyAuthenticatorRegistrationResponseResponse>
generateAuthenticationOptions(): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> generateAuthenticationOptions(username: string): Promise<GenerateAuthenticatorAuthenticationOptionsResponse>
verifyAuthenticationResponse(
userUuid: string,
authenticationCredential: Record<string, unknown>,
): Promise<VerifyAuthenticatorAuthenticationResponseResponse>
} }

View File

@@ -0,0 +1,3 @@
export interface GenerateAuthenticatorAuthenticationOptionsRequestParams {
username: string
}

View File

@@ -1,5 +0,0 @@
export interface VerifyAuthenticatorAuthenticationResponseRequestParams {
userUuid: string
authenticationCredential: Record<string, unknown>
[additionalParam: string]: unknown
}

View File

@@ -1,6 +1,6 @@
export interface VerifyAuthenticatorRegistrationResponseRequestParams { export interface VerifyAuthenticatorRegistrationResponseRequestParams {
userUuid: string userUuid: string
name: string name: string
registrationCredential: Record<string, unknown> attestationResponse: Record<string, unknown>
[additionalParam: string]: unknown [additionalParam: string]: unknown
} }

View File

@@ -1,7 +1,7 @@
export * from './ApiEndpointParam' export * from './ApiEndpointParam'
export * from './Authenticator/DeleteAuthenticatorRequestParams' export * from './Authenticator/DeleteAuthenticatorRequestParams'
export * from './Authenticator/GenerateAuthenticatorAuthenticationOptionsRequestParams'
export * from './Authenticator/ListAuthenticatorsRequestParams' export * from './Authenticator/ListAuthenticatorsRequestParams'
export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseRequestParams'
export * from './Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams' export * from './Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams'
export * from './Recovery/RecoveryKeyParamsRequestParams' export * from './Recovery/RecoveryKeyParamsRequestParams'
export * from './Recovery/SignInWithRecoveryCodesRequestParams' export * from './Recovery/SignInWithRecoveryCodesRequestParams'

View File

@@ -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<VerifyAuthenticatorAuthenticationResponseResponseBody, HttpErrorResponseBody>
}

View File

@@ -1,3 +0,0 @@
export interface VerifyAuthenticatorAuthenticationResponseResponseBody {
success: boolean
}

View File

@@ -8,8 +8,6 @@ export * from './Authenticator/GenerateAuthenticatorRegistrationOptionsResponse'
export * from './Authenticator/GenerateAuthenticatorRegistrationOptionsResponseBody' export * from './Authenticator/GenerateAuthenticatorRegistrationOptionsResponseBody'
export * from './Authenticator/ListAuthenticatorsResponse' export * from './Authenticator/ListAuthenticatorsResponse'
export * from './Authenticator/ListAuthenticatorsResponseBody' export * from './Authenticator/ListAuthenticatorsResponseBody'
export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseResponse'
export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseResponseBody'
export * from './Authenticator/VerifyAuthenticatorRegistrationResponseResponse' export * from './Authenticator/VerifyAuthenticatorRegistrationResponseResponse'
export * from './Authenticator/VerifyAuthenticatorRegistrationResponseResponseBody' export * from './Authenticator/VerifyAuthenticatorRegistrationResponseResponseBody'
export * from './Recovery/GenerateRecoveryCodesResponse' export * from './Recovery/GenerateRecoveryCodesResponse'

View File

@@ -1,9 +1,9 @@
import { HttpServiceInterface } from '../../Http/HttpServiceInterface' import { HttpServiceInterface } from '../../Http/HttpServiceInterface'
import { import {
ListAuthenticatorsRequestParams, ListAuthenticatorsRequestParams,
GenerateAuthenticatorAuthenticationOptionsRequestParams,
DeleteAuthenticatorRequestParams, DeleteAuthenticatorRequestParams,
VerifyAuthenticatorRegistrationResponseRequestParams, VerifyAuthenticatorRegistrationResponseRequestParams,
VerifyAuthenticatorAuthenticationResponseRequestParams,
} from '../../Request' } from '../../Request'
import { import {
ListAuthenticatorsResponse, ListAuthenticatorsResponse,
@@ -11,7 +11,6 @@ import {
GenerateAuthenticatorRegistrationOptionsResponse, GenerateAuthenticatorRegistrationOptionsResponse,
VerifyAuthenticatorRegistrationResponseResponse, VerifyAuthenticatorRegistrationResponseResponse,
GenerateAuthenticatorAuthenticationOptionsResponse, GenerateAuthenticatorAuthenticationOptionsResponse,
VerifyAuthenticatorAuthenticationResponseResponse,
} from '../../Response' } from '../../Response'
import { AuthenticatorServerInterface } from './AuthenticatorServerInterface' import { AuthenticatorServerInterface } from './AuthenticatorServerInterface'
import { Paths } from './Paths' import { Paths } from './Paths'
@@ -45,17 +44,11 @@ export class AuthenticatorServer implements AuthenticatorServerInterface {
return response as VerifyAuthenticatorRegistrationResponseResponse return response as VerifyAuthenticatorRegistrationResponseResponse
} }
async generateAuthenticationOptions(): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> { async generateAuthenticationOptions(
const response = await this.httpService.get(Paths.v1.generateAuthenticationOptions) params: GenerateAuthenticatorAuthenticationOptionsRequestParams,
): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> {
const response = await this.httpService.post(Paths.v1.generateAuthenticationOptions, params)
return response as GenerateAuthenticatorAuthenticationOptionsResponse return response as GenerateAuthenticatorAuthenticationOptionsResponse
} }
async verifyAuthenticationResponse(
params: VerifyAuthenticatorAuthenticationResponseRequestParams,
): Promise<VerifyAuthenticatorAuthenticationResponseResponse> {
const response = await this.httpService.post(Paths.v1.verifyAuthenticationResponse, params)
return response as VerifyAuthenticatorAuthenticationResponseResponse
}
} }

View File

@@ -2,7 +2,7 @@ import {
ListAuthenticatorsRequestParams, ListAuthenticatorsRequestParams,
DeleteAuthenticatorRequestParams, DeleteAuthenticatorRequestParams,
VerifyAuthenticatorRegistrationResponseRequestParams, VerifyAuthenticatorRegistrationResponseRequestParams,
VerifyAuthenticatorAuthenticationResponseRequestParams, GenerateAuthenticatorAuthenticationOptionsRequestParams,
} from '../../Request' } from '../../Request'
import { import {
ListAuthenticatorsResponse, ListAuthenticatorsResponse,
@@ -10,7 +10,6 @@ import {
GenerateAuthenticatorRegistrationOptionsResponse, GenerateAuthenticatorRegistrationOptionsResponse,
VerifyAuthenticatorRegistrationResponseResponse, VerifyAuthenticatorRegistrationResponseResponse,
GenerateAuthenticatorAuthenticationOptionsResponse, GenerateAuthenticatorAuthenticationOptionsResponse,
VerifyAuthenticatorAuthenticationResponseResponse,
} from '../../Response' } from '../../Response'
export interface AuthenticatorServerInterface { export interface AuthenticatorServerInterface {
@@ -20,8 +19,7 @@ export interface AuthenticatorServerInterface {
verifyRegistrationResponse( verifyRegistrationResponse(
params: VerifyAuthenticatorRegistrationResponseRequestParams, params: VerifyAuthenticatorRegistrationResponseRequestParams,
): Promise<VerifyAuthenticatorRegistrationResponseResponse> ): Promise<VerifyAuthenticatorRegistrationResponseResponse>
generateAuthenticationOptions(): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> generateAuthenticationOptions(
verifyAuthenticationResponse( params: GenerateAuthenticatorAuthenticationOptionsRequestParams,
params: VerifyAuthenticatorAuthenticationResponseRequestParams, ): Promise<GenerateAuthenticatorAuthenticationOptionsResponse>
): Promise<VerifyAuthenticatorAuthenticationResponseResponse>
} }

View File

@@ -4,7 +4,6 @@ const AuthenticatorPaths = {
generateRegistrationOptions: '/v1/authenticators/generate-registration-options', generateRegistrationOptions: '/v1/authenticators/generate-registration-options',
verifyRegistrationResponse: '/v1/authenticators/verify-registration', verifyRegistrationResponse: '/v1/authenticators/verify-registration',
generateAuthenticationOptions: '/v1/authenticators/generate-authentication-options', generateAuthenticationOptions: '/v1/authenticators/generate-authentication-options',
verifyAuthenticationResponse: '/v1/authenticators/verify-authentication',
} }
export const Paths = { export const Paths = {

View File

@@ -1,4 +1,4 @@
import { Uuid } from '@standardnotes/domain-core' import { Username, Uuid } from '@standardnotes/domain-core'
export interface AuthenticatorClientInterface { export interface AuthenticatorClientInterface {
list(): Promise<Array<{ id: string; name: string }>> list(): Promise<Array<{ id: string; name: string }>>
@@ -9,6 +9,5 @@ export interface AuthenticatorClientInterface {
name: string, name: string,
registrationCredential: Record<string, unknown>, registrationCredential: Record<string, unknown>,
): Promise<boolean> ): Promise<boolean>
generateAuthenticationOptions(): Promise<Record<string, unknown> | null> generateAuthenticationOptions(username: Username): Promise<Record<string, unknown> | null>
verifyAuthenticationResponse(userUuid: Uuid, authenticationCredential: Record<string, unknown>): Promise<boolean>
} }

View File

@@ -1,7 +1,7 @@
/* istanbul ignore file */ /* istanbul ignore file */
import { AuthenticatorApiServiceInterface } from '@standardnotes/api' import { AuthenticatorApiServiceInterface } from '@standardnotes/api'
import { Uuid } from '@standardnotes/domain-core' import { Username, Uuid } from '@standardnotes/domain-core'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface' import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { AbstractService } from '../Service/AbstractService' import { AbstractService } from '../Service/AbstractService'
@@ -79,9 +79,9 @@ export class AuthenticatorManager extends AbstractService implements Authenticat
} }
} }
async generateAuthenticationOptions(): Promise<Record<string, unknown> | null> { async generateAuthenticationOptions(username: Username): Promise<Record<string, unknown> | null> {
try { try {
const result = await this.authenticatorApiService.generateAuthenticationOptions() const result = await this.authenticatorApiService.generateAuthenticationOptions(username.value)
if (result.data.error) { if (result.data.error) {
return null return null
@@ -92,24 +92,4 @@ export class AuthenticatorManager extends AbstractService implements Authenticat
return null return null
} }
} }
async verifyAuthenticationResponse(
userUuid: Uuid,
authenticationCredential: Record<string, unknown>,
): Promise<boolean> {
try {
const result = await this.authenticatorApiService.verifyAuthenticationResponse(
userUuid.value,
authenticationCredential,
)
if (result.data.error) {
return false
}
return result.data.success
} catch (error) {
return false
}
}
} }

View File

@@ -20,6 +20,7 @@ export class ChallengePrompt implements ChallengePromptInterface {
public readonly secureTextEntry = true, public readonly secureTextEntry = true,
public readonly keyboardType?: ChallengeKeyboardType, public readonly keyboardType?: ChallengeKeyboardType,
public readonly initialValue?: ChallengeRawValue, public readonly initialValue?: ChallengeRawValue,
public readonly contextData?: Record<string, unknown>,
) { ) {
switch (this.validation) { switch (this.validation) {
case ChallengeValidation.AccountPassword: case ChallengeValidation.AccountPassword:
@@ -37,6 +38,11 @@ export class ChallengePrompt implements ChallengePromptInterface {
this.placeholder = placeholder ?? '' this.placeholder = placeholder ?? ''
this.validates = true this.validates = true
break break
case ChallengeValidation.Authenticator:
this.title = title ?? ChallengePromptTitle.U2F
this.placeholder = placeholder ?? ''
this.validates = true
break
case ChallengeValidation.ProtectionSessionDuration: case ChallengeValidation.ProtectionSessionDuration:
this.title = title ?? ChallengePromptTitle.RememberFor this.title = title ?? ChallengePromptTitle.RememberFor
this.placeholder = placeholder ?? '' this.placeholder = placeholder ?? ''

View File

@@ -6,4 +6,5 @@ export const ChallengePromptTitle = {
Biometrics: 'Biometrics', Biometrics: 'Biometrics',
RememberFor: 'Remember For', RememberFor: 'Remember For',
Mfa: 'Two-factor Authentication Code', Mfa: 'Two-factor Authentication Code',
U2F: 'Security Key',
} }

View File

@@ -1,3 +1,3 @@
/* istanbul ignore file */ /* istanbul ignore file */
export type ChallengeRawValue = number | string | boolean export type ChallengeRawValue = number | string | boolean | Record<string, unknown>

View File

@@ -6,4 +6,5 @@ export enum ChallengeValidation {
AccountPassword = 2, AccountPassword = 2,
Biometric = 3, Biometric = 3,
ProtectionSessionDuration = 4, ProtectionSessionDuration = 4,
Authenticator = 5,
} }

View File

@@ -121,6 +121,7 @@ export const SessionStrings = {
}, },
SessionRestored: 'Your session has been successfully restored.', SessionRestored: 'Your session has been successfully restored.',
EnterMfa: 'Please enter your two-factor authentication code.', EnterMfa: 'Please enter your two-factor authentication code.',
InputU2FDevice: 'Please authenticate with your U2F device.',
MfaInputPlaceholder: 'Two-factor authentication code', MfaInputPlaceholder: 'Two-factor authentication code',
EmailInputPlaceholder: 'Email', EmailInputPlaceholder: 'Email',
PasswordInputPlaceholder: 'Password', PasswordInputPlaceholder: 'Password',

View File

@@ -105,11 +105,11 @@ import { GetRecoveryCodes } from '@Lib/Domain/UseCase/GetRecoveryCodes/GetRecove
import { AddAuthenticator } from '@Lib/Domain/UseCase/AddAuthenticator/AddAuthenticator' import { AddAuthenticator } from '@Lib/Domain/UseCase/AddAuthenticator/AddAuthenticator'
import { ListAuthenticators } from '@Lib/Domain/UseCase/ListAuthenticators/ListAuthenticators' import { ListAuthenticators } from '@Lib/Domain/UseCase/ListAuthenticators/ListAuthenticators'
import { DeleteAuthenticator } from '@Lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator' 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 { ListRevisions } from '@Lib/Domain/UseCase/ListRevisions/ListRevisions'
import { GetRevision } from '@Lib/Domain/UseCase/GetRevision/GetRevision' import { GetRevision } from '@Lib/Domain/UseCase/GetRevision/GetRevision'
import { DeleteRevision } from '@Lib/Domain/UseCase/DeleteRevision/DeleteRevision' import { DeleteRevision } from '@Lib/Domain/UseCase/DeleteRevision/DeleteRevision'
import { RevisionMetadata } from '@Lib/Domain/Revision/RevisionMetadata' import { RevisionMetadata } from '@Lib/Domain/Revision/RevisionMetadata'
import { GetAuthenticatorAuthenticationResponse } from '@Lib/Domain/UseCase/GetAuthenticatorAuthenticationResponse/GetAuthenticatorAuthenticationResponse'
/** How often to automatically sync, in milliseconds */ /** How often to automatically sync, in milliseconds */
const DEFAULT_AUTO_SYNC_INTERVAL = 30_000 const DEFAULT_AUTO_SYNC_INTERVAL = 30_000
@@ -193,7 +193,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private declare _addAuthenticator: AddAuthenticator private declare _addAuthenticator: AddAuthenticator
private declare _listAuthenticators: ListAuthenticators private declare _listAuthenticators: ListAuthenticators
private declare _deleteAuthenticator: DeleteAuthenticator private declare _deleteAuthenticator: DeleteAuthenticator
private declare _verifyAuthenticator: VerifyAuthenticator private declare _getAuthenticatorAuthenticationResponse: GetAuthenticatorAuthenticationResponse
private declare _listRevisions: ListRevisions private declare _listRevisions: ListRevisions
private declare _getRevision: GetRevision private declare _getRevision: GetRevision
private declare _deleteRevision: DeleteRevision private declare _deleteRevision: DeleteRevision
@@ -299,8 +299,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
return this._deleteAuthenticator return this._deleteAuthenticator
} }
get verifyAuthenticator(): UseCaseInterface<void> { get getAuthenticatorAuthenticationResponse(): UseCaseInterface<Record<string, unknown>> {
return this._verifyAuthenticator return this._getAuthenticatorAuthenticationResponse
} }
get listRevisions(): UseCaseInterface<Array<RevisionMetadata>> { get listRevisions(): UseCaseInterface<Array<RevisionMetadata>> {
@@ -1272,7 +1272,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
;(this._addAuthenticator as unknown) = undefined ;(this._addAuthenticator as unknown) = undefined
;(this._listAuthenticators as unknown) = undefined ;(this._listAuthenticators as unknown) = undefined
;(this._deleteAuthenticator as unknown) = undefined ;(this._deleteAuthenticator as unknown) = undefined
;(this._verifyAuthenticator as unknown) = undefined ;(this._getAuthenticatorAuthenticationResponse as unknown) = undefined
;(this._listRevisions as unknown) = undefined ;(this._listRevisions as unknown) = undefined
;(this._getRevision as unknown) = undefined ;(this._getRevision as unknown) = undefined
;(this._deleteRevision 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._deleteAuthenticator = new DeleteAuthenticator(this.authenticatorManager)
this._verifyAuthenticator = new VerifyAuthenticator( this._getAuthenticatorAuthenticationResponse = new GetAuthenticatorAuthenticationResponse(
this.authenticatorManager, this.authenticatorManager,
this.options.u2fAuthenticatorVerificationPromptFunction, this.options.u2fAuthenticatorVerificationPromptFunction,
) )

View File

@@ -1,27 +1,37 @@
import { AuthenticatorClientInterface } from '@standardnotes/services' import { AuthenticatorClientInterface } from '@standardnotes/services'
import { VerifyAuthenticator } from './VerifyAuthenticator' import { GetAuthenticatorAuthenticationResponse } from './GetAuthenticatorAuthenticationResponse'
describe('VerifyAuthenticator', () => { describe('GetAuthenticatorAuthenticationResponse', () => {
let authenticatorClient: AuthenticatorClientInterface let authenticatorClient: AuthenticatorClientInterface
let authenticatorVerificationPromptFunction: ( let authenticatorVerificationPromptFunction: (
authenticationOptions: Record<string, unknown>, authenticationOptions: Record<string, unknown>,
) => Promise<Record<string, unknown>> ) => Promise<Record<string, unknown>>
const createUseCase = () => new VerifyAuthenticator(authenticatorClient, authenticatorVerificationPromptFunction) const createUseCase = () => new GetAuthenticatorAuthenticationResponse(authenticatorClient, authenticatorVerificationPromptFunction)
beforeEach(() => { beforeEach(() => {
authenticatorClient = {} as jest.Mocked<AuthenticatorClientInterface> authenticatorClient = {} as jest.Mocked<AuthenticatorClientInterface>
authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue({ foo: 'bar' }) authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue({ foo: 'bar' })
authenticatorClient.verifyAuthenticationResponse = jest.fn().mockResolvedValue(true)
authenticatorVerificationPromptFunction = jest.fn() 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 () => { it('should return an error if authenticator client fails to generate authentication options', async () => {
authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue(null) 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.isFailed()).toBe(true)
expect(result.getError()).toBe('Could not generate authenticator authentication options') 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 () => { it('should return an error if authenticator verification prompt function fails', async () => {
authenticatorVerificationPromptFunction = jest.fn().mockRejectedValue(new Error('error')) 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.isFailed()).toBe(true)
expect(result.getError()).toBe('Could not generate authenticator authentication options: error') 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 () => { it('should return ok if authenticator client succeeds to generate authenticator response', async () => {
authenticatorClient.verifyAuthenticationResponse = jest.fn().mockResolvedValue(false) const result = await createUseCase().execute({
username: 'test@test.te',
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' })
expect(result.isFailed()).toBe(false) 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 () => { it('should return error if authenticatorVerificationPromptFunction is not provided', async () => {
const result = await new VerifyAuthenticator(authenticatorClient).execute({ const result = await new GetAuthenticatorAuthenticationResponse(authenticatorClient).execute({
userUuid: '00000000-0000-0000-0000-000000000000', username: 'test@test.te',
}) })
expect(result.isFailed()).toBe(true) expect(result.isFailed()).toBe(true)

View File

@@ -1,9 +1,8 @@
import { AuthenticatorClientInterface } from '@standardnotes/services' 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 GetAuthenticatorAuthenticationResponse implements UseCaseInterface<Record<string, unknown>> {
export class VerifyAuthenticator implements UseCaseInterface<void> {
constructor( constructor(
private authenticatorClient: AuthenticatorClientInterface, private authenticatorClient: AuthenticatorClientInterface,
private authenticatorVerificationPromptFunction?: ( private authenticatorVerificationPromptFunction?: (
@@ -11,20 +10,20 @@ export class VerifyAuthenticator implements UseCaseInterface<void> {
) => Promise<Record<string, unknown>>, ) => Promise<Record<string, unknown>>,
) {} ) {}
async execute(dto: VerifyAuthenticatorDTO): Promise<Result<void>> { async execute(dto: GetAuthenticatorAuthenticationResponseDTO): Promise<Result<Record<string, unknown>>> {
if (!this.authenticatorVerificationPromptFunction) { if (!this.authenticatorVerificationPromptFunction) {
return Result.fail( return Result.fail(
'Could not generate authenticator authentication options: No authenticator verification prompt function provided', 'Could not generate authenticator authentication options: No authenticator verification prompt function provided',
) )
} }
const userUuidOrError = Uuid.create(dto.userUuid) const usernameOrError = Username.create(dto.username)
if (userUuidOrError.isFailed()) { if (usernameOrError.isFailed()) {
return Result.fail(`Could not generate authenticator authentication options: ${userUuidOrError.getError()}`) 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) { if (authenticationOptions === null) {
return Result.fail('Could not generate authenticator authentication options') return Result.fail('Could not generate authenticator authentication options')
} }
@@ -36,14 +35,6 @@ export class VerifyAuthenticator implements UseCaseInterface<void> {
return Result.fail(`Could not generate authenticator authentication options: ${(error as Error).message}`) return Result.fail(`Could not generate authenticator authentication options: ${(error as Error).message}`)
} }
const verificationResponse = await this.authenticatorClient.verifyAuthenticationResponse( return Result.ok(authenticatorResponse)
userUuid,
authenticatorResponse,
)
if (!verificationResponse) {
return Result.fail('Could not generate authenticator authentication options')
}
return Result.ok()
} }
} }

View File

@@ -0,0 +1,3 @@
export interface GetAuthenticatorAuthenticationResponseDTO {
username: string
}

View File

@@ -9,7 +9,7 @@ export interface UseCaseContainerInterface {
get addAuthenticator(): UseCaseInterface<void> get addAuthenticator(): UseCaseInterface<void>
get listAuthenticators(): UseCaseInterface<Array<{ id: string; name: string }>> get listAuthenticators(): UseCaseInterface<Array<{ id: string; name: string }>>
get deleteAuthenticator(): UseCaseInterface<void> get deleteAuthenticator(): UseCaseInterface<void>
get verifyAuthenticator(): UseCaseInterface<void> get getAuthenticatorAuthenticationResponse(): UseCaseInterface<Record<string, unknown>>
get listRevisions(): UseCaseInterface<Array<RevisionMetadata>> get listRevisions(): UseCaseInterface<Array<RevisionMetadata>>
get getRevision(): UseCaseInterface<HistoryEntry> get getRevision(): UseCaseInterface<HistoryEntry>
get deleteRevision(): UseCaseInterface<void> get deleteRevision(): UseCaseInterface<void>

View File

@@ -1,3 +0,0 @@
export interface VerifyAuthenticatorDTO {
userUuid: string
}

View File

@@ -232,6 +232,7 @@ export class SNApiService
email: string email: string
mfaKeyPath?: string mfaKeyPath?: string
mfaCode?: string mfaCode?: string
authenticatorResponse?: Record<string, unknown>
}): Promise<Responses.KeyParamsResponse | Responses.HttpResponse> { }): Promise<Responses.KeyParamsResponse | Responses.HttpResponse> {
const codeVerifier = this.crypto.generateRandomKey(256) const codeVerifier = this.crypto.generateRandomKey(256)
this.inMemoryStore.setValue(StorageKey.CodeVerifier, codeVerifier) this.inMemoryStore.setValue(StorageKey.CodeVerifier, codeVerifier)
@@ -247,6 +248,10 @@ export class SNApiService
params[dto.mfaKeyPath] = dto.mfaCode params[dto.mfaKeyPath] = dto.mfaCode
} }
if (dto.authenticatorResponse) {
params.authenticator_response = dto.authenticatorResponse
}
return this.request({ return this.request({
verb: HttpVerb.Post, verb: HttpVerb.Post,
url: joinPaths(this.host, Paths.v2.keyParams), url: joinPaths(this.host, Paths.v2.keyParams),

View File

@@ -93,6 +93,8 @@ export class ChallengeService extends AbstractService implements ChallengeServic
return this.protocolService.validateAccountPassword(value.value as string) return this.protocolService.validateAccountPassword(value.value as string)
case ChallengeValidation.Biometric: case ChallengeValidation.Biometric:
return { valid: value.value === true } return { valid: value.value === true }
case ChallengeValidation.Authenticator:
return { valid: 'id' in (value.value as Record<string, unknown>) }
case ChallengeValidation.ProtectionSessionDuration: case ChallengeValidation.ProtectionSessionDuration:
return { valid: isValidProtectionSessionLength(value.value) } return { valid: isValidProtectionSessionLength(value.value) }
default: default:

View File

@@ -48,6 +48,7 @@ import { ChallengeService } from '../Challenge'
import { import {
ApiCallError, ApiCallError,
ErrorMessage, ErrorMessage,
ErrorTag,
HttpErrorResponseBody, HttpErrorResponseBody,
HttpServiceInterface, HttpServiceInterface,
UserApiServiceInterface, UserApiServiceInterface,
@@ -284,6 +285,35 @@ export class SNSessionManager
return (response as Responses.GetAvailableSubscriptionsResponse).data! return (response as Responses.GetAvailableSubscriptionsResponse).data!
} }
private async promptForU2FVerification(username: string): Promise<Record<string, unknown> | 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<string, unknown>
}
private async promptForMfaValue(): Promise<string | undefined> { private async promptForMfaValue(): Promise<string | undefined> {
const challenge = new Challenge( const challenge = new Challenge(
[ [
@@ -344,31 +374,28 @@ export class SNSessionManager
return registerResponse.data return registerResponse.data
} }
private async retrieveKeyParams( private async retrieveKeyParams(dto: {
email: string, email: string
mfaKeyPath?: string, mfaKeyPath?: string
mfaCode?: string, mfaCode?: string
): Promise<{ authenticatorResponse?: Record<string, unknown>
}): Promise<{
keyParams?: SNRootKeyParams keyParams?: SNRootKeyParams
response: Responses.KeyParamsResponse | Responses.HttpResponse response: Responses.KeyParamsResponse | Responses.HttpResponse
mfaKeyPath?: string mfaKeyPath?: string
mfaCode?: string mfaCode?: string
}> { }> {
const response = await this.apiService.getAccountKeyParams({ const response = await this.apiService.getAccountKeyParams(dto)
email,
mfaKeyPath,
mfaCode,
})
if (response.error || isNullOrUndefined(response.data)) { if (response.error || isNullOrUndefined(response.data)) {
if (mfaCode) { if (dto.mfaCode) {
await this.alertService.alert(SignInStrings.IncorrectMfa) await this.alertService.alert(SignInStrings.IncorrectMfa)
} }
if (response.error?.payload?.mfa_key) {
/** Prompt for MFA code and try again */ if ([ErrorTag.U2FRequired, ErrorTag.MfaRequired].includes(response.error?.tag as ErrorTag)) {
const inputtedCode = await this.promptForMfaValue() const isU2FRequired = response.error?.tag === ErrorTag.U2FRequired
if (!inputtedCode) { const result = isU2FRequired ? await this.promptForU2FVerification(dto.email) : await this.promptForMfaValue()
/** User dismissed window without input */ if (!result) {
return { return {
response: this.apiService.createErrorResponse( response: this.apiService.createErrorResponse(
SignInStrings.SignInCanceledMissingMfa, 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<string, unknown>) : undefined,
})
} else { } else {
return { response } return { response }
} }
} }
/** Make sure to use client value for identifier/email */ /** 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) { if (!keyParams || !keyParams.version) {
return { return {
response: this.apiService.createErrorResponse(API_MESSAGE_FALLBACK_LOGIN_FAIL), 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( public async signIn(
@@ -425,7 +458,9 @@ export class SNSessionManager
ephemeral = false, ephemeral = false,
minAllowedVersion?: Common.ProtocolVersion, minAllowedVersion?: Common.ProtocolVersion,
): Promise<SessionManagerResponse> { ): Promise<SessionManagerResponse> {
const paramsResult = await this.retrieveKeyParams(email) const paramsResult = await this.retrieveKeyParams({
email,
})
if (paramsResult.response.error) { if (paramsResult.response.error) {
return { return {
response: paramsResult.response, response: paramsResult.response,

View File

@@ -37,6 +37,7 @@
"@babel/preset-env": "*", "@babel/preset-env": "*",
"@standardnotes/api": "workspace:*", "@standardnotes/api": "workspace:*",
"@standardnotes/common": "^1.46.4", "@standardnotes/common": "^1.46.4",
"@standardnotes/domain-core": "^1.11.1",
"@standardnotes/domain-events": "^2.106.0", "@standardnotes/domain-events": "^2.106.0",
"@standardnotes/encryption": "workspace:*", "@standardnotes/encryption": "workspace:*",
"@standardnotes/features": "workspace:*", "@standardnotes/features": "workspace:*",
@@ -84,8 +85,5 @@
"webpack": "*", "webpack": "*",
"webpack-cli": "*", "webpack-cli": "*",
"webpack-merge": "^5.8.0" "webpack-merge": "^5.8.0"
},
"dependencies": {
"@standardnotes/domain-core": "^1.11.1"
} }
} }

View File

@@ -14,6 +14,7 @@ module.exports = {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy', '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'@standardnotes/toast': 'identity-obj-proxy', '@standardnotes/toast': 'identity-obj-proxy',
'@standardnotes/styles': 'identity-obj-proxy', '@standardnotes/styles': 'identity-obj-proxy',
'@simplewebauthn/browser': 'identity-obj-proxy',
}, },
globals: { globals: {
__WEB_VERSION__: '1.0.0', __WEB_VERSION__: '1.0.0',

View File

@@ -116,6 +116,7 @@
"app/**/*.{js,ts,jsx,tsx,css,md}": "prettier --write" "app/**/*.{js,ts,jsx,tsx,css,md}": "prettier --write"
}, },
"dependencies": { "dependencies": {
"@lexical/headless": "^0.7.6" "@lexical/headless": "^0.7.6",
"@simplewebauthn/browser": "^7.0.0"
} }
} }

View File

@@ -25,6 +25,7 @@ import {
ApplicationOptionsDefaults, ApplicationOptionsDefaults,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { makeObservable, observable } from 'mobx' import { makeObservable, observable } from 'mobx'
import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
import { PanelResizedData } from '@/Types/PanelResizedData' import { PanelResizedData } from '@/Types/PanelResizedData'
import { isAndroid, isDesktopApplication, isIOS } from '@/Utils' import { isAndroid, isDesktopApplication, isIOS } from '@/Utils'
import { DesktopManager } from './Device/DesktopManager' import { DesktopManager } from './Device/DesktopManager'
@@ -83,6 +84,8 @@ export class WebApplication extends SNApplication implements WebApplicationInter
deviceInterface.environment === Environment.Mobile ? 250 : ApplicationOptionsDefaults.sleepBetweenBatches, deviceInterface.environment === Environment.Mobile ? 250 : ApplicationOptionsDefaults.sleepBetweenBatches,
allowMultipleSelection: deviceInterface.environment !== Environment.Mobile, allowMultipleSelection: deviceInterface.environment !== Environment.Mobile,
allowNoteSelectionStatePersistence: deviceInterface.environment !== Environment.Mobile, allowNoteSelectionStatePersistence: deviceInterface.environment !== Environment.Mobile,
u2fAuthenticatorRegistrationPromptFunction: startRegistration,
u2fAuthenticatorVerificationPromptFunction: startAuthentication,
}) })
makeObservable(this, { makeObservable(this, {

View File

@@ -180,13 +180,23 @@ const ChallengeModal: FunctionComponent<Props> = ({
}, [application, challenge, onDismiss]) }, [application, challenge, onDismiss])
const biometricPrompt = challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.Biometric) 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 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 hasSecureTextPrompt = challenge.prompts.some((prompt) => prompt.secureTextEntry)
const shouldShowSubmitButton = !(hasOnlyBiometricPrompt || hasOnlyAuthenticatorPrompt)
useEffect(() => { useEffect(() => {
const shouldAutoSubmit = hasOnlyBiometricPrompt && wasBiometricInputSuccessful const shouldAutoSubmit =
(hasOnlyBiometricPrompt && wasBiometricInputSuccessful) ||
(hasOnlyAuthenticatorPrompt && wasAuthenticatorInputSuccessful)
const shouldFocusSecureTextPrompt = hasSecureTextPrompt && wasBiometricInputSuccessful const shouldFocusSecureTextPrompt = hasSecureTextPrompt && wasBiometricInputSuccessful
if (shouldAutoSubmit) { if (shouldAutoSubmit) {
submit() submit()
} else if (shouldFocusSecureTextPrompt) { } else if (shouldFocusSecureTextPrompt) {
@@ -195,7 +205,14 @@ const ChallengeModal: FunctionComponent<Props> = ({
) as HTMLInputElement | null ) as HTMLInputElement | null
secureTextEntry?.focus() secureTextEntry?.focus()
} }
}, [wasBiometricInputSuccessful, hasOnlyBiometricPrompt, submit, hasSecureTextPrompt]) }, [
wasBiometricInputSuccessful,
hasOnlyBiometricPrompt,
submit,
hasSecureTextPrompt,
hasOnlyAuthenticatorPrompt,
wasAuthenticatorInputSuccessful,
])
useEffect(() => { useEffect(() => {
const removeListener = application.addAndroidBackHandlerEventListener(() => { const removeListener = application.addAndroidBackHandlerEventListener(() => {
@@ -289,12 +306,15 @@ const ChallengeModal: FunctionComponent<Props> = ({
index={index} index={index}
onValueChange={onValueChange} onValueChange={onValueChange}
isInvalid={values[prompt.id].invalid} isInvalid={values[prompt.id].invalid}
contextData={prompt.contextData}
/> />
))} ))}
</form> </form>
<Button primary disabled={isProcessing} className="mt-1 mb-3.5 min-w-76" onClick={submit}> {shouldShowSubmitButton && (
{isProcessing ? 'Generating Keys...' : 'Submit'} <Button primary disabled={isProcessing} className="mt-1 mb-3.5 min-w-76" onClick={submit}>
</Button> {isProcessing ? 'Generating Keys...' : 'Submit'}
</Button>
)}
{shouldShowForgotPasscode && ( {shouldShowForgotPasscode && (
<Button <Button
className="flex min-w-76 items-center justify-center" className="flex min-w-76 items-center justify-center"

View File

@@ -11,6 +11,7 @@ import { ChallengeModalValues } from './ChallengeModalValues'
import { WebApplication } from '@/Application/Application' import { WebApplication } from '@/Application/Application'
import { InputValue } from './InputValue' import { InputValue } from './InputValue'
import BiometricsPrompt from './BiometricsPrompt' import BiometricsPrompt from './BiometricsPrompt'
import U2FPrompt from './U2FPrompt'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -19,6 +20,7 @@ type Props = {
index: number index: number
onValueChange: (value: InputValue['value'], prompt: ChallengePrompt) => void onValueChange: (value: InputValue['value'], prompt: ChallengePrompt) => void
isInvalid: boolean isInvalid: boolean
contextData?: Record<string, unknown>
} }
const ChallengeModalPrompt: FunctionComponent<Props> = ({ const ChallengeModalPrompt: FunctionComponent<Props> = ({
@@ -28,9 +30,11 @@ const ChallengeModalPrompt: FunctionComponent<Props> = ({
index, index,
onValueChange, onValueChange,
isInvalid, isInvalid,
contextData,
}) => { }) => {
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const biometricsButtonRef = useRef<HTMLButtonElement>(null) const biometricsButtonRef = useRef<HTMLButtonElement>(null)
const authenticatorButtonRef = useRef<HTMLButtonElement>(null)
const activatePrompt = useCallback(async () => { const activatePrompt = useCallback(async () => {
if (prompt.validation === ChallengeValidation.Biometric) { if (prompt.validation === ChallengeValidation.Biometric) {
@@ -137,6 +141,14 @@ const ChallengeModalPrompt: FunctionComponent<Props> = ({
prompt={prompt} prompt={prompt}
buttonRef={biometricsButtonRef} buttonRef={biometricsButtonRef}
/> />
) : prompt.validation === ChallengeValidation.Authenticator ? (
<U2FPrompt
application={application}
onValueChange={onValueChange}
prompt={prompt}
buttonRef={authenticatorButtonRef}
contextData={contextData}
/>
) : prompt.secureTextEntry ? ( ) : prompt.secureTextEntry ? (
<DecoratedPasswordInput <DecoratedPasswordInput
ref={inputRef} ref={inputRef}

View File

@@ -2,6 +2,6 @@ import { ChallengePrompt } from '@standardnotes/snjs'
export type InputValue = { export type InputValue = {
prompt: ChallengePrompt prompt: ChallengePrompt
value: string | number | boolean value: string | number | boolean | Record<string, unknown>
invalid: boolean invalid: boolean
} }

View File

@@ -0,0 +1,62 @@
import { WebApplication } from '@/Application/Application'
import { ChallengePrompt } from '@standardnotes/services'
import { RefObject, useState } from 'react'
import Button from '../Button/Button'
import Icon from '../Icon/Icon'
import { InputValue } from './InputValue'
type Props = {
application: WebApplication
onValueChange: (value: InputValue['value'], prompt: ChallengePrompt) => void
prompt: ChallengePrompt
buttonRef: RefObject<HTMLButtonElement>
contextData?: Record<string, unknown>
}
const U2FPrompt = ({ application, onValueChange, prompt, buttonRef, contextData }: Props) => {
const [authenticatorResponse, setAuthenticatorResponse] = useState<Record<string, unknown> | null>(null)
const [error, setError] = useState('')
return (
<div className="min-w-76">
{error && <div className="text-red-500">{error}</div>}
<Button
primary
fullWidth
colorStyle={authenticatorResponse ? 'success' : 'info'}
onClick={async () => {
if (!contextData || contextData.username === undefined) {
setError('No username provided')
return
}
const authenticatorResponseOrError = await application.getAuthenticatorAuthenticationResponse.execute({
username: contextData.username,
})
if (authenticatorResponseOrError.isFailed()) {
setError(authenticatorResponseOrError.getError())
return
}
const authenticatorResponse = authenticatorResponseOrError.getValue()
setAuthenticatorResponse(authenticatorResponse)
onValueChange(authenticatorResponse, prompt)
}}
ref={buttonRef}
>
{authenticatorResponse ? (
<span className="flex items-center justify-center gap-3">
<Icon type="check-circle" />
Obtained Device Response
</span>
) : (
'Authenticate Device'
)}
</Button>
</div>
)
}
export default U2FPrompt

View File

@@ -11,6 +11,8 @@ import ErroredItems from './ErroredItems'
import PreferencesPane from '@/Components/Preferences/PreferencesComponents/PreferencesPane' import PreferencesPane from '@/Components/Preferences/PreferencesComponents/PreferencesPane'
import BiometricsLock from '@/Components/Preferences/Panes/Security/BiometricsLock' import BiometricsLock from '@/Components/Preferences/Panes/Security/BiometricsLock'
import MultitaskingPrivacy from '@/Components/Preferences/Panes/Security/MultitaskingPrivacy' import MultitaskingPrivacy from '@/Components/Preferences/Panes/Security/MultitaskingPrivacy'
import U2FWrapper from './U2F/U2FWrapper'
import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk'
interface SecurityProps extends MfaProps { interface SecurityProps extends MfaProps {
viewControllerManager: ViewControllerManager viewControllerManager: ViewControllerManager
@@ -32,6 +34,9 @@ const Security: FunctionComponent<SecurityProps> = (props) => {
userProvider={props.userProvider} userProvider={props.userProvider}
application={props.application} application={props.application}
/> />
{featureTrunkEnabled(FeatureTrunkName.U2F) && (
<U2FWrapper userProvider={props.userProvider} application={props.application} />
)}
{isNativeMobileWeb && <MultitaskingPrivacy application={props.application} />} {isNativeMobileWeb && <MultitaskingPrivacy application={props.application} />}
<PasscodeLock viewControllerManager={props.viewControllerManager} application={props.application} /> <PasscodeLock viewControllerManager={props.viewControllerManager} application={props.application} />
{isNativeMobileWeb && <BiometricsLock application={props.application} />} {isNativeMobileWeb && <BiometricsLock application={props.application} />}

View File

@@ -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<void>
onDeviceAddingModalToggle: (show: boolean) => void
onDeviceAdded: () => Promise<void>
}
const U2FAddDeviceView: FunctionComponent<Props> = ({
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 (
<Modal
title="Add U2F Device"
close={closeModal}
actions={[
{
label: 'Cancel',
type: 'cancel',
onClick: closeModal,
mobileSlot: 'left',
hidden: !isMobileScreen,
},
{
label: (
<>
Add <span className="hidden md:inline">Device</span>
</>
),
type: 'primary',
onClick: handleAddDeviceClick,
mobileSlot: 'right',
},
]}
>
<div className="w-25 h-25 flex items-center justify-center bg-info">...Some Cool Device Picture Here...</div>
<div className="flex flex-grow flex-col gap-2">
<DecoratedInput className={{ container: 'w-92 ml-4' }} value={deviceName} onChange={handleDeviceNameChange} />
</div>
{errorMessage && <div className="text-error">{errorMessage}</div>}
</Modal>
)
}
export default observer(U2FAddDeviceView)

View File

@@ -0,0 +1,7 @@
import { WebApplication } from '@/Application/Application'
import { UserProvider } from '@/Components/Preferences/Providers'
export interface U2FProps {
userProvider: UserProvider
application: WebApplication
}

View File

@@ -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<Props> = ({ userProvider }) => {
if (userProvider.getUser() === undefined) {
return <Text>Sign in or register for an account to configure U2F.</Text>
}
return <Text>Authenticate with a U2F hardware device.</Text>
}
export default observer(U2FDescription)

View File

@@ -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<void>
onError: (error: string) => void
}
const U2FDevicesList: FunctionComponent<Props> = ({ 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 (
<div className="flex flex-row items-center">
{devices.length > 0 && (
<div className="flex flex-grow flex-col">
<div>
<Text>Devices:</Text>
</div>
{devices.map((device) => (
<div key="device-{device.id}">
<Text>{device.name}</Text>
<Button
key={device.id}
primary={true}
label="Delete"
onClick={async () => handleDeleteButtonOnClick(device.id)}
></Button>
</div>
))}
</div>
)}
</div>
)
}
export default observer(U2FDevicesList)

View File

@@ -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<Props> = ({ userProvider }) => {
if (userProvider.getUser() === undefined) {
return <Title>Universal 2nd Factor authentication not available</Title>
}
return <Title>Universal 2nd Factor authentication</Title>
}
export default observer(U2FTitle)

View File

@@ -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<Props> = ({ application, userProvider }) => {
const [showDeviceAddingModal, setShowDeviceAddingModal] = useState(false)
const [devices, setDevices] = useState<Array<{ id: string; name: string }>>([])
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 (
<>
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-row items-center">
<div className="flex flex-grow flex-col">
<U2FTitle userProvider={userProvider} />
<U2FDescription userProvider={userProvider} />
</div>
<PreferencesSegment>
<Button label="Add Device" primary onClick={handleAddDeviceClick} />
</PreferencesSegment>
</div>
</PreferencesSegment>
<PreferencesSegment>
{error && <div className="text-red-500">{error}</div>}
<U2FDevicesList
application={application}
devices={devices}
onError={setError}
onDeviceDeleted={loadAuthenticatorDevices}
/>
</PreferencesSegment>
</PreferencesGroup>
{showDeviceAddingModal && (
<U2FAddDeviceView
onDeviceAddingModalToggle={setShowDeviceAddingModal}
onDeviceAdded={loadAuthenticatorDevices}
userProvider={userProvider}
addAuthenticator={application.addAuthenticator}
/>
)}
</>
)
}
export default observer(U2FView)

View File

@@ -0,0 +1,10 @@
import { FunctionComponent } from 'react'
import { U2FProps } from './U2FProps'
import U2FView from './U2FView/U2FView'
const U2FWrapper: FunctionComponent<U2FProps> = (props) => {
return <U2FView application={props.application} userProvider={props.userProvider} />
}
export default U2FWrapper

View File

@@ -3,11 +3,13 @@ import { isDev } from '@/Utils'
export enum FeatureTrunkName { export enum FeatureTrunkName {
Super, Super,
ImportTools, ImportTools,
U2F,
} }
const FeatureTrunkStatus: Record<FeatureTrunkName, boolean> = { const FeatureTrunkStatus: Record<FeatureTrunkName, boolean> = {
[FeatureTrunkName.Super]: isDev && true, [FeatureTrunkName.Super]: isDev && true,
[FeatureTrunkName.ImportTools]: isDev && true, [FeatureTrunkName.ImportTools]: isDev && true,
[FeatureTrunkName.U2F]: isDev && true,
} }
export function featureTrunkEnabled(trunk: FeatureTrunkName): boolean { export function featureTrunkEnabled(trunk: FeatureTrunkName): boolean {

View File

@@ -4335,6 +4335,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@sinclair/typebox@npm:^0.24.1":
version: 0.24.46 version: 0.24.46
resolution: "@sinclair/typebox@npm:0.24.46" resolution: "@sinclair/typebox@npm:0.24.46"
@@ -5264,6 +5271,7 @@ __metadata:
"@reach/listbox": ^0.18.0 "@reach/listbox": ^0.18.0
"@reach/tooltip": ^0.18.0 "@reach/tooltip": ^0.18.0
"@reach/visually-hidden": ^0.18.0 "@reach/visually-hidden": ^0.18.0
"@simplewebauthn/browser": ^7.0.0
"@standardnotes/authenticator": ^2.3.9 "@standardnotes/authenticator": ^2.3.9
"@standardnotes/autobiography-theme": ^1.2.7 "@standardnotes/autobiography-theme": ^1.2.7
"@standardnotes/blocks-editor": "workspace:*" "@standardnotes/blocks-editor": "workspace:*"