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:
BIN
.yarn/cache/@simplewebauthn-browser-npm-7.0.0-93e13e1065-eb8d7e2d92.zip
vendored
Normal file
BIN
.yarn/cache/@simplewebauthn-browser-npm-7.0.0-93e13e1065-eb8d7e2d92.zip
vendored
Normal file
Binary file not shown.
@@ -4,5 +4,4 @@ export enum AuthenticatorApiOperations {
|
|||||||
GenerateRegistrationOptions,
|
GenerateRegistrationOptions,
|
||||||
GenerateAuthenticationOptions,
|
GenerateAuthenticationOptions,
|
||||||
VerifyRegistrationResponse,
|
VerifyRegistrationResponse,
|
||||||
VerifyAuthenticationResponse,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface GenerateAuthenticatorAuthenticationOptionsRequestParams {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export interface VerifyAuthenticatorAuthenticationResponseRequestParams {
|
|
||||||
userUuid: string
|
|
||||||
authenticationCredential: Record<string, unknown>
|
|
||||||
[additionalParam: string]: unknown
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export interface VerifyAuthenticatorAuthenticationResponseResponseBody {
|
|
||||||
success: boolean
|
|
||||||
}
|
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? ''
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export enum ChallengeValidation {
|
|||||||
AccountPassword = 2,
|
AccountPassword = 2,
|
||||||
Biometric = 3,
|
Biometric = 3,
|
||||||
ProtectionSessionDuration = 4,
|
ProtectionSessionDuration = 4,
|
||||||
|
Authenticator = 5,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface GetAuthenticatorAuthenticationResponseDTO {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export interface VerifyAuthenticatorDTO {
|
|
||||||
userUuid: string
|
|
||||||
}
|
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { UserProvider } from '@/Components/Preferences/Providers'
|
||||||
|
|
||||||
|
export interface U2FProps {
|
||||||
|
userProvider: UserProvider
|
||||||
|
application: WebApplication
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:*"
|
||||||
|
|||||||
Reference in New Issue
Block a user