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

@@ -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<void> {
return this._verifyAuthenticator
get getAuthenticatorAuthenticationResponse(): UseCaseInterface<Record<string, unknown>> {
return this._getAuthenticatorAuthenticationResponse
}
get listRevisions(): UseCaseInterface<Array<RevisionMetadata>> {
@@ -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,
)

View File

@@ -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<string, unknown>,
) => Promise<Record<string, unknown>>
const createUseCase = () => new VerifyAuthenticator(authenticatorClient, authenticatorVerificationPromptFunction)
const createUseCase = () => new GetAuthenticatorAuthenticationResponse(authenticatorClient, authenticatorVerificationPromptFunction)
beforeEach(() => {
authenticatorClient = {} as jest.Mocked<AuthenticatorClientInterface>
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)

View File

@@ -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<void> {
export class GetAuthenticatorAuthenticationResponse implements UseCaseInterface<Record<string, unknown>> {
constructor(
private authenticatorClient: AuthenticatorClientInterface,
private authenticatorVerificationPromptFunction?: (
@@ -11,20 +10,20 @@ export class VerifyAuthenticator implements UseCaseInterface<void> {
) => Promise<Record<string, unknown>>,
) {}
async execute(dto: VerifyAuthenticatorDTO): Promise<Result<void>> {
async execute(dto: GetAuthenticatorAuthenticationResponseDTO): Promise<Result<Record<string, unknown>>> {
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<void> {
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)
}
}

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 listAuthenticators(): UseCaseInterface<Array<{ id: string; name: string }>>
get deleteAuthenticator(): UseCaseInterface<void>
get verifyAuthenticator(): UseCaseInterface<void>
get getAuthenticatorAuthenticationResponse(): UseCaseInterface<Record<string, unknown>>
get listRevisions(): UseCaseInterface<Array<RevisionMetadata>>
get getRevision(): UseCaseInterface<HistoryEntry>
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
mfaKeyPath?: string
mfaCode?: string
authenticatorResponse?: Record<string, unknown>
}): Promise<Responses.KeyParamsResponse | Responses.HttpResponse> {
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),

View File

@@ -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<string, unknown>) }
case ChallengeValidation.ProtectionSessionDuration:
return { valid: isValidProtectionSessionLength(value.value) }
default:

View File

@@ -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<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> {
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<string, unknown>
}): 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<string, unknown>) : 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<SessionManagerResponse> {
const paramsResult = await this.retrieveKeyParams(email)
const paramsResult = await this.retrieveKeyParams({
email,
})
if (paramsResult.response.error) {
return {
response: paramsResult.response,

View File

@@ -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"
}
}