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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface GetAuthenticatorAuthenticationResponseDTO {
|
||||
username: string
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface VerifyAuthenticatorDTO {
|
||||
userUuid: string
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user