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

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

* refactor: change unnecessary useState to derived constant

* fix: modal refactor

* fix(web): hide u2f under feature trunk

* fix(web): jest setup

---------

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

View File

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

View File

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

View File

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