diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 24e1bc3ce3..ec59a1fef0 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2610,5 +2610,9 @@ }, "useBrowserName": { "message": "Use browser" + }, + "seeDetailedInstructions": { + "message": "See detailed instructions on our help site at", + "description": "This is followed a by a hyperlink to the help website." } } diff --git a/apps/browser/src/tools/popup/settings/import/import-browser.component.html b/apps/browser/src/tools/popup/settings/import/import-browser.component.html index b305e6c395..c00e72c90a 100644 --- a/apps/browser/src/tools/popup/settings/import/import-browser.component.html +++ b/apps/browser/src/tools/popup/settings/import/import-browser.component.html @@ -9,7 +9,7 @@ {{ "importData" | i18n }}
- diff --git a/apps/desktop/src/app/tools/import/import-desktop.component.html b/apps/desktop/src/app/tools/import/import-desktop.component.html index 74d4098255..9fe2ee47c2 100644 --- a/apps/desktop/src/app/tools/import/import-desktop.component.html +++ b/apps/desktop/src/app/tools/import/import-desktop.component.html @@ -11,7 +11,7 @@ - {{ this.fileSelected ? this.fileSelected.name : ("noFileChosen" | i18n) }} -
- - - - {{ "orCopyPasteFileContents" | i18n }} - - + +
+ + {{ "selectImportFile" | i18n }} +
+ + {{ this.fileSelected ? this.fileSelected.name : ("noFileChosen" | i18n) }} +
+ +
+ + {{ "orCopyPasteFileContents" | i18n }} + + +
diff --git a/libs/importer/src/components/import.component.ts b/libs/importer/src/components/import.component.ts index 3a111720ab..a72e3c347c 100644 --- a/libs/importer/src/components/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -10,8 +10,8 @@ import { } from "@angular/core"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import * as JSZip from "jszip"; -import { concat, Observable, Subject, lastValueFrom, combineLatest } from "rxjs"; -import { map, takeUntil } from "rxjs/operators"; +import { concat, Observable, Subject, lastValueFrom, combineLatest, firstValueFrom } from "rxjs"; +import { filter, map, takeUntil } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -22,6 +22,7 @@ import { import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ClientType } from "@bitwarden/common/enums"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -41,6 +42,7 @@ import { DialogService, FormFieldModule, IconButtonModule, + RadioButtonModule, SelectModule, } from "@bitwarden/components"; @@ -57,6 +59,7 @@ import { ImportErrorDialogComponent, ImportSuccessDialogComponent, } from "./dialog"; +import { ImportLastPassComponent } from "./lastpass"; @Component({ selector: "tools-import", @@ -72,6 +75,8 @@ import { SelectModule, CalloutModule, ReactiveFormsModule, + ImportLastPassComponent, + RadioButtonModule, ], providers: [ { @@ -137,6 +142,7 @@ export class ImportComponent implements OnInit, OnDestroy { format: [null as ImportType | null, [Validators.required]], fileContents: [], file: [], + lastPassType: ["direct" as "csv" | "direct"], }); @ViewChild(BitSubmitDirective) @@ -179,6 +185,16 @@ export class ImportComponent implements OnInit, OnDestroy { return this._importBlockedByPolicy; } + protected get showLastPassToggle(): boolean { + return ( + this.format === "lastpasscsv" && + this.platformUtilsService.getClientType() === ClientType.Desktop + ); + } + protected get showLastPassOptions(): boolean { + return this.showLastPassToggle && this.formGroup.controls.lastPassType.value === "direct"; + } + ngOnInit() { this.setImportOptions(); @@ -243,6 +259,8 @@ export class ImportComponent implements OnInit, OnDestroy { } submit = async () => { + await this.asyncValidatorsFinished(); + if (this.formGroup.invalid) { this.formGroup.markAllAsTouched(); return; @@ -251,6 +269,14 @@ export class ImportComponent implements OnInit, OnDestroy { await this.performImport(); }; + private async asyncValidatorsFinished() { + if (this.formGroup.pending) { + await firstValueFrom( + this.formGroup.statusChanges.pipe(filter((status) => status !== "PENDING")) + ); + } + } + protected async performImport() { if (this.organization) { const confirmed = await this.dialogService.openSimpleDialog({ @@ -292,7 +318,7 @@ export class ImportComponent implements OnInit, OnDestroy { return; } - const fileEl = document.getElementById("file") as HTMLInputElement; + const fileEl = document.getElementById("import_input_file") as HTMLInputElement; const files = fileEl.files; let fileContents = this.formGroup.controls.fileContents.value; if ((files == null || files.length === 0) && (fileContents == null || fileContents === "")) { diff --git a/libs/importer/src/components/lastpass/dialog/index.ts b/libs/importer/src/components/lastpass/dialog/index.ts new file mode 100644 index 0000000000..1313b979ab --- /dev/null +++ b/libs/importer/src/components/lastpass/dialog/index.ts @@ -0,0 +1,3 @@ +export { LastPassAwaitSSODialogComponent } from "./lastpass-await-sso-dialog.component"; +export { LastPassMultifactorPromptComponent } from "./lastpass-multifactor-prompt.component"; +export { LastPassPasswordPromptComponent } from "./lastpass-password-prompt.component"; diff --git a/libs/importer/src/components/lastpass/dialog/lastpass-await-sso-dialog.component.html b/libs/importer/src/components/lastpass/dialog/lastpass-await-sso-dialog.component.html new file mode 100644 index 0000000000..a1a5b28ba9 --- /dev/null +++ b/libs/importer/src/components/lastpass/dialog/lastpass-await-sso-dialog.component.html @@ -0,0 +1,14 @@ + +
+ +
+ {{ "awaitingSSO" | i18n }} + + {{ "awaitingSSODesc" | i18n }} + + + + +
diff --git a/libs/importer/src/components/lastpass/dialog/lastpass-await-sso-dialog.component.ts b/libs/importer/src/components/lastpass/dialog/lastpass-await-sso-dialog.component.ts new file mode 100644 index 0000000000..bceed5e6ff --- /dev/null +++ b/libs/importer/src/components/lastpass/dialog/lastpass-await-sso-dialog.component.ts @@ -0,0 +1,15 @@ +import { Component } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; + +@Component({ + templateUrl: "lastpass-await-sso-dialog.component.html", + standalone: true, + imports: [JslibModule, ButtonModule, DialogModule], +}) +export class LastPassAwaitSSODialogComponent { + static open(dialogService: DialogService) { + return dialogService.open(LastPassAwaitSSODialogComponent); + } +} diff --git a/libs/importer/src/components/lastpass/dialog/lastpass-multifactor-prompt.component.html b/libs/importer/src/components/lastpass/dialog/lastpass-multifactor-prompt.component.html new file mode 100644 index 0000000000..1b933ec987 --- /dev/null +++ b/libs/importer/src/components/lastpass/dialog/lastpass-multifactor-prompt.component.html @@ -0,0 +1,25 @@ +
+ + + {{ "lastPassMFARequired" | i18n }} + + +
+

{{ description | i18n }}

+ + {{ "passcode" | i18n }} + + {{ "confirmIdentity" | i18n }} + +
+ + + + + +
+
diff --git a/libs/importer/src/components/lastpass/dialog/lastpass-multifactor-prompt.component.ts b/libs/importer/src/components/lastpass/dialog/lastpass-multifactor-prompt.component.ts new file mode 100644 index 0000000000..f0094111c9 --- /dev/null +++ b/libs/importer/src/components/lastpass/dialog/lastpass-multifactor-prompt.component.ts @@ -0,0 +1,62 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + AsyncActionsModule, + ButtonModule, + DialogModule, + DialogService, + FormFieldModule, + IconButtonModule, + TypographyModule, +} from "@bitwarden/components"; + +type LastPassMultifactorPromptData = { + isOOB?: boolean; +}; + +@Component({ + templateUrl: "lastpass-multifactor-prompt.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + ReactiveFormsModule, + DialogModule, + FormFieldModule, + AsyncActionsModule, + ButtonModule, + IconButtonModule, + TypographyModule, + ], +}) +export class LastPassMultifactorPromptComponent { + protected description = this.data?.isOOB ? "lastPassOOBDesc" : "lastPassMFADesc"; + + protected formGroup = new FormGroup({ + passcode: new FormControl("", { + validators: Validators.required, + updateOn: "submit", + }), + }); + + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) protected data: LastPassMultifactorPromptData + ) {} + + submit = () => { + this.formGroup.markAsTouched(); + if (!this.formGroup.valid) { + return; + } + this.dialogRef.close(this.formGroup.value.passcode); + }; + + static open(dialogService: DialogService, data?: LastPassMultifactorPromptData) { + return dialogService.open(LastPassMultifactorPromptComponent, { data }); + } +} diff --git a/libs/importer/src/components/lastpass/dialog/lastpass-password-prompt.component.html b/libs/importer/src/components/lastpass/dialog/lastpass-password-prompt.component.html new file mode 100644 index 0000000000..3ba66376ea --- /dev/null +++ b/libs/importer/src/components/lastpass/dialog/lastpass-password-prompt.component.html @@ -0,0 +1,25 @@ +
+ + + {{ "lastPassAuthRequired" | i18n }} + + +
+ + {{ "lastPassMasterPassword" | i18n }} + + + {{ "confirmIdentity" | i18n }} + +
+ + + + + +
+
diff --git a/libs/importer/src/components/lastpass/dialog/lastpass-password-prompt.component.ts b/libs/importer/src/components/lastpass/dialog/lastpass-password-prompt.component.ts new file mode 100644 index 0000000000..115004cddd --- /dev/null +++ b/libs/importer/src/components/lastpass/dialog/lastpass-password-prompt.component.ts @@ -0,0 +1,55 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + AsyncActionsModule, + ButtonModule, + DialogModule, + DialogService, + FormFieldModule, + IconButtonModule, + TypographyModule, +} from "@bitwarden/components"; + +@Component({ + templateUrl: "lastpass-password-prompt.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + ReactiveFormsModule, + DialogModule, + FormFieldModule, + AsyncActionsModule, + ButtonModule, + IconButtonModule, + TypographyModule, + ], +}) +export class LastPassPasswordPromptComponent { + protected formGroup = new FormGroup({ + password: new FormControl("", { + validators: Validators.required, + updateOn: "submit", + }), + }); + + constructor(public dialogRef: DialogRef) {} + + submit = () => { + this.formGroup.markAsTouched(); + if (!this.formGroup.valid) { + return; + } + this.dialogRef.close(this.formGroup.controls.password.value); + }; + + static open(dialogService: DialogService) { + const dialogRef = dialogService.open(LastPassPasswordPromptComponent); + return firstValueFrom(dialogRef.closed); + } +} diff --git a/libs/importer/src/components/lastpass/import-lastpass.component.html b/libs/importer/src/components/lastpass/import-lastpass.component.html new file mode 100644 index 0000000000..ed85edcf0d --- /dev/null +++ b/libs/importer/src/components/lastpass/import-lastpass.component.html @@ -0,0 +1,16 @@ +
+ + {{ "lastPassEmail" | i18n }} + + {{ emailHint$ | async }} + + + + {{ "includeSharedFolders" | i18n }} + +
diff --git a/libs/importer/src/components/lastpass/import-lastpass.component.ts b/libs/importer/src/components/lastpass/import-lastpass.component.ts new file mode 100644 index 0000000000..281d93c40e --- /dev/null +++ b/libs/importer/src/components/lastpass/import-lastpass.component.ts @@ -0,0 +1,128 @@ +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, OnDestroy, OnInit, Output } from "@angular/core"; +import { + AsyncValidatorFn, + ControlContainer, + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from "@angular/forms"; +import { map } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { + CalloutModule, + CheckboxModule, + FormFieldModule, + IconButtonModule, + TypographyModule, +} from "@bitwarden/components"; + +import { LastPassDirectImportService } from "./lastpass-direct-import.service"; + +@Component({ + selector: "import-lastpass", + templateUrl: "import-lastpass.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + CalloutModule, + TypographyModule, + FormFieldModule, + ReactiveFormsModule, + IconButtonModule, + CheckboxModule, + ], +}) +export class ImportLastPassComponent implements OnInit, OnDestroy { + private _parentFormGroup: FormGroup; + protected formGroup = this.formBuilder.group({ + email: [ + "", + { + validators: [Validators.required, Validators.email], + asyncValidators: [this.validateAndEmitData()], + updateOn: "submit", + }, + ], + includeSharedFolders: [false], + }); + protected emailHint$ = this.formGroup.controls.email.statusChanges.pipe( + map((status) => { + if (status === "PENDING") { + return this.i18nService.t("importingYourAccount"); + } + }) + ); + + @Output() csvDataLoaded = new EventEmitter(); + + constructor( + private formBuilder: FormBuilder, + private controlContainer: ControlContainer, + private logService: LogService, + private lastPassDirectImportService: LastPassDirectImportService, + private i18nService: I18nService + ) {} + + ngOnInit(): void { + this._parentFormGroup = this.controlContainer.control as FormGroup; + this._parentFormGroup.addControl("lastpassOptions", this.formGroup); + } + + ngOnDestroy(): void { + this._parentFormGroup.removeControl("lastpassOptions"); + } + + /** + * Attempts to login to the provided LastPass email and retrieve account contents. + * Will return a validation error if unable to login or fetch. + * Emits account contents to `csvDataLoaded` + */ + validateAndEmitData(): AsyncValidatorFn { + return async () => { + try { + const csvData = await this.lastPassDirectImportService.handleImport( + this.formGroup.controls.email.value, + this.formGroup.controls.includeSharedFolders.value + ); + this.csvDataLoaded.emit(csvData); + return null; + } catch (error) { + this.logService.error(`LP importer error: ${error}`); + return { + errors: { + message: this.i18nService.t(this.getValidationErrorI18nKey(error)), + }, + }; + } + }; + } + + private getValidationErrorI18nKey(error: any): string { + const message = typeof error === "string" ? error : error?.message; + switch (message) { + case "SSO auth cancelled": + case "Second factor step is canceled by the user": + case "Out of band step is canceled by the user": + return "multifactorAuthenticationCancelled"; + case "No accounts to transform": + case "Vault has not opened any accounts.": + return "noLastPassDataFound"; + case "Invalid username": + case "Invalid password": + return "incorrectUsernameOrPassword"; + case "Second factor code is incorrect": + case "Out of band authentication failed": + return "multifactorAuthenticationFailed"; + case "unifiedloginresult": + return "lastPassTryAgainCheckEmail"; + default: + return "errorOccurred"; + } + } +} diff --git a/libs/importer/src/components/lastpass/index.ts b/libs/importer/src/components/lastpass/index.ts new file mode 100644 index 0000000000..688eef2465 --- /dev/null +++ b/libs/importer/src/components/lastpass/index.ts @@ -0,0 +1 @@ +export { ImportLastPassComponent } from "./import-lastpass.component"; diff --git a/libs/importer/src/components/lastpass/lastpass-direct-import-ui.service.ts b/libs/importer/src/components/lastpass/lastpass-direct-import-ui.service.ts new file mode 100644 index 0000000000..036f340299 --- /dev/null +++ b/libs/importer/src/components/lastpass/lastpass-direct-import-ui.service.ts @@ -0,0 +1,59 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; + +import { OtpResult, OobResult } from "../../importers/lastpass/access/models"; +import { Ui } from "../../importers/lastpass/access/ui"; + +import { LastPassMultifactorPromptComponent } from "./dialog"; + +@Injectable({ + providedIn: "root", +}) +export class LastPassDirectImportUIService implements Ui { + private mfaDialogRef: DialogRef; + + constructor(private dialogService: DialogService) {} + + private async getOTPResult() { + this.mfaDialogRef = LastPassMultifactorPromptComponent.open(this.dialogService); + const passcode = await firstValueFrom(this.mfaDialogRef.closed); + return new OtpResult(passcode, false); + } + + private async getOOBResult() { + this.mfaDialogRef = LastPassMultifactorPromptComponent.open(this.dialogService, { + isOOB: true, + }); + const passcode = await firstValueFrom(this.mfaDialogRef.closed); + return new OobResult(false, passcode, false); + } + + closeMFADialog() { + this.mfaDialogRef?.close(); + } + + async provideGoogleAuthPasscode() { + return this.getOTPResult(); + } + + async provideMicrosoftAuthPasscode() { + return this.getOTPResult(); + } + + async provideYubikeyPasscode() { + return this.getOTPResult(); + } + + async approveLastPassAuth() { + return this.getOOBResult(); + } + async approveDuo() { + return this.getOOBResult(); + } + async approveSalesforceAuth() { + return this.getOOBResult(); + } +} diff --git a/libs/importer/src/components/lastpass/lastpass-direct-import.service.ts b/libs/importer/src/components/lastpass/lastpass-direct-import.service.ts new file mode 100644 index 0000000000..63a59737f6 --- /dev/null +++ b/libs/importer/src/components/lastpass/lastpass-direct-import.service.ts @@ -0,0 +1,173 @@ +import { Injectable, NgZone } from "@angular/core"; +import { OidcClient } from "oidc-client-ts"; +import { Subject, firstValueFrom } from "rxjs"; + +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; + +import { DialogService } from "../../../../components/src/dialog"; +import { ClientInfo, Vault } from "../../importers/lastpass/access"; +import { FederatedUserContext } from "../../importers/lastpass/access/models"; + +import { LastPassAwaitSSODialogComponent } from "./dialog/lastpass-await-sso-dialog.component"; +import { LastPassPasswordPromptComponent } from "./dialog/lastpass-password-prompt.component"; +import { LastPassDirectImportUIService } from "./lastpass-direct-import-ui.service"; + +@Injectable({ + providedIn: "root", +}) +export class LastPassDirectImportService { + private vault: Vault; + + private oidcClient: OidcClient; + + private _ssoImportCallback$ = new Subject<{ oidcCode: string; oidcState: string }>(); + ssoImportCallback$ = this._ssoImportCallback$.asObservable(); + + constructor( + private tokenService: TokenService, + private cryptoFunctionService: CryptoFunctionService, + private appIdService: AppIdService, + private lastPassDirectImportUIService: LastPassDirectImportUIService, + private passwordGenerationService: PasswordGenerationServiceAbstraction, + private broadcasterService: BroadcasterService, + private ngZone: NgZone, + private dialogService: DialogService, + private platformUtilsService: PlatformUtilsService + ) { + this.vault = new Vault(this.cryptoFunctionService, this.tokenService); + + /** TODO: remove this in favor of dedicated service */ + this.broadcasterService.subscribe("LastPassDirectImportService", (message: any) => { + this.ngZone.run(async () => { + switch (message.command) { + case "importCallbackLastPass": + this._ssoImportCallback$.next({ oidcCode: message.code, oidcState: message.state }); + break; + default: + break; + } + }); + }); + } + + /** + * Import a LastPass account by email + * @param email + * @param includeSharedFolders + * @returns The CSV export data of the account + */ + async handleImport(email: string, includeSharedFolders: boolean): Promise { + await this.verifyLastPassAccountExists(email); + + if (this.isAccountFederated) { + const oidc = await this.handleFederatedLogin(email); + const csvData = await this.handleFederatedImport( + oidc.oidcCode, + oidc.oidcState, + includeSharedFolders + ); + return csvData; + } + const password = await LastPassPasswordPromptComponent.open(this.dialogService); + const csvData = await this.handleStandardImport(email, password, includeSharedFolders); + + return csvData; + } + + private get isAccountFederated(): boolean { + return this.vault.userType.isFederated(); + } + + private async verifyLastPassAccountExists(email: string) { + await this.vault.setUserTypeContext(email); + } + + private async handleFederatedLogin(email: string) { + const ssoCallbackPromise = firstValueFrom(this.ssoImportCallback$); + const request = await this.createOidcSigninRequest(email); + this.platformUtilsService.launchUri(request.url); + + const cancelDialogRef = LastPassAwaitSSODialogComponent.open(this.dialogService); + const cancelled = firstValueFrom(cancelDialogRef.closed).then((_didCancel) => { + throw Error("SSO auth cancelled"); + }); + + return Promise.race<{ + oidcCode: string; + oidcState: string; + }>([cancelled, ssoCallbackPromise]).finally(() => { + cancelDialogRef.close(); + }); + } + + private async createOidcSigninRequest(email: string) { + this.oidcClient = new OidcClient({ + authority: this.vault.userType.openIDConnectAuthorityBase, + client_id: this.vault.userType.openIDConnectClientId, + // TODO: this is different per client + redirect_uri: "bitwarden://import-callback-lp", + response_type: "code", + scope: this.vault.userType.oidcScope, + response_mode: "query", + loadUserInfo: true, + }); + + return await this.oidcClient.createSigninRequest({ + state: { + email, + }, + nonce: await this.passwordGenerationService.generatePassword({ + length: 20, + uppercase: true, + lowercase: true, + number: true, + }), + }); + } + + private async handleStandardImport( + email: string, + password: string, + includeSharedFolders: boolean + ): Promise { + const clientInfo = await this.createClientInfo(email); + await this.vault.open(email, password, clientInfo, this.lastPassDirectImportUIService); + + return this.vault.accountsToExportedCsvString(!includeSharedFolders); + } + + private async handleFederatedImport( + oidcCode: string, + oidcState: string, + includeSharedFolders: boolean + ): Promise { + const response = await this.oidcClient.processSigninResponse( + this.oidcClient.settings.redirect_uri + "/?code=" + oidcCode + "&state=" + oidcState + ); + const userState = response.userState as any; + + const federatedUser = new FederatedUserContext(); + federatedUser.idToken = response.id_token; + federatedUser.accessToken = response.access_token; + federatedUser.idpUserInfo = response.profile; + federatedUser.username = userState.email; + + const clientInfo = await this.createClientInfo(federatedUser.username); + await this.vault.openFederated(federatedUser, clientInfo, this.lastPassDirectImportUIService); + + return this.vault.accountsToExportedCsvString(!includeSharedFolders); + } + + private async createClientInfo(email: string): Promise { + const appId = await this.appIdService.getAppId(); + const id = "lastpass" + appId + email; + const idHash = await this.cryptoFunctionService.hash(id, "sha256"); + return ClientInfo.createClientInfo(Utils.fromBufferToHex(idHash)); + } +} diff --git a/libs/importer/src/importers/lastpass/access/enums/duo-factor.ts b/libs/importer/src/importers/lastpass/access/enums/duo-factor.ts deleted file mode 100644 index aa65583935..0000000000 --- a/libs/importer/src/importers/lastpass/access/enums/duo-factor.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum DuoFactor { - Push, - Call, - Passcode, - SendPasscodesBySms, -} diff --git a/libs/importer/src/importers/lastpass/access/enums/duo-status.ts b/libs/importer/src/importers/lastpass/access/enums/duo-status.ts deleted file mode 100644 index 6397db5dc9..0000000000 --- a/libs/importer/src/importers/lastpass/access/enums/duo-status.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum DuoStatus { - Success, - Error, - Info, -} diff --git a/libs/importer/src/importers/lastpass/access/enums/index.ts b/libs/importer/src/importers/lastpass/access/enums/index.ts index 0059030e0a..08a8035e6f 100644 --- a/libs/importer/src/importers/lastpass/access/enums/index.ts +++ b/libs/importer/src/importers/lastpass/access/enums/index.ts @@ -1,5 +1,3 @@ -export { DuoFactor } from "./duo-factor"; -export { DuoStatus } from "./duo-status"; export { IdpProvider } from "./idp-provider"; export { LastpassLoginType } from "./lastpass-login-type"; export { OtpMethod } from "./otp-method"; diff --git a/libs/importer/src/importers/lastpass/access/models/client-info.ts b/libs/importer/src/importers/lastpass/access/models/client-info.ts index 1f87512780..89749acf9a 100644 --- a/libs/importer/src/importers/lastpass/access/models/client-info.ts +++ b/libs/importer/src/importers/lastpass/access/models/client-info.ts @@ -1,5 +1,3 @@ -import { Utils } from "@bitwarden/common/platform/misc/utils"; - import { Platform } from "../enums"; export class ClientInfo { @@ -7,7 +5,7 @@ export class ClientInfo { id: string; description: string; - static createClientInfo(): ClientInfo { - return { platform: Platform.Desktop, id: Utils.newGuid(), description: "Importer" }; + static createClientInfo(id: string): ClientInfo { + return { platform: Platform.Desktop, id, description: "Importer" }; } } diff --git a/libs/importer/src/importers/lastpass/access/services/client.ts b/libs/importer/src/importers/lastpass/access/services/client.ts index b185ada888..f55863e96c 100644 --- a/libs/importer/src/importers/lastpass/access/services/client.ts +++ b/libs/importer/src/importers/lastpass/access/services/client.ts @@ -273,22 +273,9 @@ export class Client { ui: Ui, rest: RestClient ): Promise { - const answer = await this.approveOob(username, parameters, ui, rest); - if (answer == OobResult.cancel) { - throw new Error("Out of band step is canceled by the user"); - } - - const extraParameters = new Map(); - if (answer.waitForOutOfBand) { - extraParameters.set("outofbandrequest", 1); - } else { - extraParameters.set("otp", answer.passcode); - } - - let session: Session = null; - for (;;) { - // In case of the OOB auth the server doesn't respond instantly. This works more like a long poll. - // The server times out in about 10 seconds so there's no need to back off. + // In case of the OOB auth the server doesn't respond instantly. This works more like a long poll. + // The server times out in about 10 seconds so there's no need to back off. + const attemptLogin = async (extraParameters: Map): Promise => { const response = await this.performSingleLoginRequest( username, password, @@ -298,9 +285,9 @@ export class Client { rest ); - session = this.extractSessionFromLoginResponse(response, keyIterationCount, clientInfo); + const session = this.extractSessionFromLoginResponse(response, keyIterationCount, clientInfo); if (session != null) { - break; + return session; } if (this.getOptionalErrorAttribute(response, "cause") != "outofbandrequired") { @@ -310,11 +297,37 @@ export class Client { // Retry extraParameters.set("outofbandretry", "1"); extraParameters.set("outofbandretryid", this.getErrorAttribute(response, "retryid")); - } - if (answer.rememberMe) { - await this.markDeviceAsTrusted(session, clientInfo, rest); - } + return attemptLogin(extraParameters); + }; + + const pollingLoginSession = () => { + const extraParameters = new Map(); + extraParameters.set("outofbandrequest", 1); + return attemptLogin(extraParameters); + }; + + const passcodeLoginSession = async () => { + const answer = await this.approveOob(username, parameters, ui, rest); + + if (answer == OobResult.cancel) { + throw new Error("Out of band step is canceled by the user"); + } + const extraParameters = new Map(); + extraParameters.set("otp", answer.passcode); + const session = await attemptLogin(extraParameters); + if (answer.rememberMe) { + await this.markDeviceAsTrusted(session, clientInfo, rest); + } + return session; + }; + + const session: Session = await Promise.race([ + pollingLoginSession(), + passcodeLoginSession(), + ]).finally(() => { + ui.closeMFADialog(); + }); return session; } @@ -356,9 +369,9 @@ export class Client { parameters: Map, ui: Ui, rest: RestClient - ): OobResult { - // TODO: implement this - return OobResult.cancel; + ): Promise { + // TODO: implement this instead of calling `approveDuo` + return ui.approveDuo(); } private async markDeviceAsTrusted(session: Session, clientInfo: ClientInfo, rest: RestClient) { @@ -539,6 +552,8 @@ export class Client { return "Second factor code is incorrect"; case "multifactorresponsefailed": return "Out of band authentication failed"; + case "unifiedloginresult": + return "unifiedloginresult"; default: return message?.value ?? cause.value; } diff --git a/libs/importer/src/importers/lastpass/access/ui/duo-ui.ts b/libs/importer/src/importers/lastpass/access/ui/duo-ui.ts deleted file mode 100644 index 60afd0ad9d..0000000000 --- a/libs/importer/src/importers/lastpass/access/ui/duo-ui.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { DuoFactor, DuoStatus } from "../enums"; - -// Adds Duo functionality to the module-specific Ui class. -export abstract class DuoUi { - // To cancel return null - chooseDuoFactor: (devices: [DuoDevice]) => DuoChoice; - // To cancel return null or blank - provideDuoPasscode: (device: DuoDevice) => string; - // This updates the UI with the messages from the server. - updateDuoStatus: (status: DuoStatus, text: string) => void; -} - -export interface DuoChoice { - device: DuoDevice; - factor: DuoFactor; - rememberMe: boolean; -} - -export interface DuoDevice { - id: string; - name: string; - factors: DuoFactor[]; -} diff --git a/libs/importer/src/importers/lastpass/access/ui/index.ts b/libs/importer/src/importers/lastpass/access/ui/index.ts index e4edc3b6b4..ad10c5a09d 100644 --- a/libs/importer/src/importers/lastpass/access/ui/index.ts +++ b/libs/importer/src/importers/lastpass/access/ui/index.ts @@ -1,2 +1 @@ -export { DuoUi, DuoChoice, DuoDevice } from "./duo-ui"; export { Ui } from "./ui"; diff --git a/libs/importer/src/importers/lastpass/access/ui/ui.ts b/libs/importer/src/importers/lastpass/access/ui/ui.ts index b1640d325f..6480359262 100644 --- a/libs/importer/src/importers/lastpass/access/ui/ui.ts +++ b/libs/importer/src/importers/lastpass/access/ui/ui.ts @@ -1,8 +1,5 @@ import { OobResult, OtpResult } from "../models"; - -import { DuoUi } from "./duo-ui"; - -export abstract class Ui extends DuoUi { +export abstract class Ui { // To cancel return OtpResult.Cancel, otherwise only valid data is expected. provideGoogleAuthPasscode: () => Promise; provideMicrosoftAuthPasscode: () => Promise; @@ -26,4 +23,7 @@ export abstract class Ui extends DuoUi { approveLastPassAuth: () => Promise; approveDuo: () => Promise; approveSalesforceAuth: () => Promise; + + /** Close MFA dialog on import success or error */ + closeMFADialog: () => void; } diff --git a/libs/importer/src/models/import-options.ts b/libs/importer/src/models/import-options.ts index 03c4e72e04..fc0b4ce2bb 100644 --- a/libs/importer/src/models/import-options.ts +++ b/libs/importer/src/models/import-options.ts @@ -10,7 +10,7 @@ export const featuredImportOptions = [ { id: "dashlanecsv", name: "Dashlane (csv)" }, { id: "firefoxcsv", name: "Firefox (csv)" }, { id: "keepass2xml", name: "KeePass 2 (xml)" }, - { id: "lastpasscsv", name: "LastPass (csv)" }, + { id: "lastpasscsv", name: "LastPass" }, { id: "safaricsv", name: "Safari and macOS (csv)" }, { id: "1password1pux", name: "1Password (1pux/json)" }, ] as const; diff --git a/package-lock.json b/package-lock.json index e5e6e5912a..8558b8169a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "node-fetch": "2.6.12", "node-forge": "1.3.1", "nord": "0.2.1", + "oidc-client-ts": "2.3.0", "open": "8.4.2", "papaparse": "5.4.1", "patch-package": "6.5.1", @@ -19047,8 +19048,7 @@ "node_modules/crypto-js": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", - "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==", - "dev": true + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" }, "node_modules/crypto-random-string": { "version": "2.0.0", @@ -27499,6 +27499,11 @@ "integrity": "sha512-qpcRocdkUmf+UTNBYx5w6dexX5J31AKK1OmPwH630a83DdVVUIngk55RSAiIGpQyoH0dlr872VHfPjnQnK1qDQ==", "dev": true }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "node_modules/karma-source-map-support": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", @@ -31655,6 +31660,18 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "dev": true }, + "node_modules/oidc-client-ts": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.3.0.tgz", + "integrity": "sha512-7RUKU+TJFQo+4X9R50IGJAIDF18uRBaFXyZn4VVCfwmwbSUhKcdDnw4zgeut3uEXkiD3NqURq+d88sDPxjf1FA==", + "dependencies": { + "crypto-js": "^4.1.1", + "jwt-decode": "^3.1.2" + }, + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", diff --git a/package.json b/package.json index 0c94ba0166..3610dcc973 100644 --- a/package.json +++ b/package.json @@ -188,6 +188,7 @@ "node-fetch": "2.6.12", "node-forge": "1.3.1", "nord": "0.2.1", + "oidc-client-ts": "2.3.0", "open": "8.4.2", "papaparse": "5.4.1", "patch-package": "6.5.1",