[PM-4195] LastPass importer flow (#6541)

* Split up import/export into separate modules

* Fix routing and apply PR feedback

* Renamed OrganizationExport exports to OrganizationVaultExport

* Make import dialogs standalone and move them to libs/importer

* Make import.component re-usable

- Move functionality which was previously present on the org-import.component into import.component
- Move import.component into libs/importer
Make import.component standalone
Create import-web.component to represent Web UI
Fix module imports and routing
Remove unused org-import-files

* Enable importing on deskop

Create import-dialog
Create file-menu entry to open import-dialog
Extend messages.json to include all the necessary messages from shared components

* Renamed filenames according to export rename

* Make ImportWebComponent standalone, simplify routing

* Pass organizationId as Input to ImportComponent

* use formLoading and formDisabled outputs

* use formLoading & formDisabled in desktop

* Emit an event when the import succeeds

Remove Angular router from base-component as other clients might not have routing (i.e. desktop)
Move logic that happened on web successful import into the import-web.component

* Enable importing on deskop

Create import-dialog
Create file-menu entry to open import-dialog
Extend messages.json to include all the necessary messages from shared components

* use formLoading & formDisabled in desktop

* Add missing message for importBlockedByPolicy callout

* Remove commented code for submit button

* Implement onSuccessfulImport to close dialog on success

* fix table themes on desktop & browser

* fix fileSelector button styles

* update selectors to use tools prefix; remove unused selectors

* update selectors

* Wall off UI components in libs/importer

Create barrel-file for libs/importer/components
Remove components and dialog exports from libs/importer/index.ts
Extend libs/shared/tsconfig.libs.json to include @bitwarden/importer/ui -> libs/importer/components
Extend apps/web/tsconfig.ts to include @bitwarden/importer/ui
Update all usages

* Rename @bitwarden/importer to @bitwarden/importer/core

Create more barrel files in libs/importer/*
Update imports within libs/importer
Extend tsconfig files
Update imports in web, desktop, browser and cli

* import-lastpass wip

* Lazy-load the ImportWebComponent via both routes

* Fix import path for ImportComponent

* add validation; add shared folders field

* clean up logic

* fill fileContent on account change

* Use SharedModule as import in import-web.component

* show spinner on pending validation; properly debounce; refactor to loadCSVData func

* fix pending submit guard

* hide on web, show on desktop & browser

* reset user agent fieldset styles

* fix validation

* File selector should be displayed as secondary

* update validation

* Fix setUserTypeContext always throwing

* refactor to password dialog approach

* remove control on destroy; dont submit on enter keydown

* helper to serialize vault accounts (#6556)

* helper to serialize vault accounts

* prettier

* add prompts

* Add missing messages for file-password-prompt

* Add missing messages for import-error-dialog

* Add missing message for import-success-dialog

* Create client-info

* Separate submit and handling import, add error-handling

* Move catch and error handling into submit

* Remove AsyncValidator logic from handleImport

* Add support for filtering shared accounts

* add sso flow to lp import (#6574)

* stub out some sso flow

* use computer props

* lastpass callback

* baseOpenIDConnectAuthority

* openIDConnectAuthorityBase

* comments

* camelCase user type context model

* processSigninResponse

* Refactor handleImport

* use large dialogSize

* remove extra setUserTypeContext

* fix passwordGenerationService provider; pass all errors to ValidationErrors

* add await SSO dialog & logic

* Move lastpass related files into separate folder

* Use bitSubmit to override submit preventDefault (#6607)

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>

* Use large dialogSize

* revert jslib changes

* PM-4398 - Add missing importWarning

* make ui class methods async

* add LastPassDirectImportService

* update error handling

* add OOB methods (manual passcode only)

* fix typo

* respond to SSO callback

* localize error messages

* remove uneeded comment

* update i18n

* add await sso i18n

* add not implemented error to service

* fix getting k2

* fix k1 bugs

* null checks should not be strict

* update awaiting sso dialog

* update approveDuoWebSdk

* refactor oob login flow

* Removing fieldset due to merge of https://github.com/bitwarden/clients/pull/6626

* Refactoring to push logic into the service vs the component

Move all methods related to MFA-UI into a LastPassDirectImportUIService
Move all logic around the import into a LastPassDirectImportService
The component now only has the necessary flows but no knowledge on how to use the lastpass import lib or the need for a OIDC client

* Remove unneeded passwordGenerationService

* move all import logic to service

* apply code review: remove name attributes; use protected fields; use formGroup.value

* rename submit method and add comment

* update textarea id

* update i18n

* remove rogue todo comment

* extract helper asyncValidatorsFinished

* Remove files related to DuoUI we didn't need to differentiate for MFA via Duo

* Add missing import

* revert formGroup.value access

* add email to signInRequest

* add try again error message

* add try again i18n

* consistent clientinfo id (#6654)



---------

Co-authored-by: William Martin <contact@willmartian.com>

* hide on browser

* add lastpass prefix

* add shared i18n copy to web and browser

* rename deeplink

* use protected field

* rename el ids

* refactor: remove nested conditional

* update form ids in consuming client components

* remove unnecessary return statement

* fix file id

* use ngIf

* use hidden because of getElementById

* Remove OIDC lib logging

---------

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
Co-authored-by: Kyle Spearrin <kspearrin@users.noreply.github.com>
Co-authored-by: Kyle Spearrin <kyle.spearrin@gmail.com>
This commit is contained in:
Will Martin 2023-10-23 13:46:49 -04:00 committed by GitHub
parent a4303fac59
commit ec866c744e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 800 additions and 118 deletions

View File

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

View File

@ -9,7 +9,7 @@
<span class="title">{{ "importData" | i18n }}</span>
</h1>
<div class="right">
<button form="importForm" type="submit" [disabled]="disabled">
<button form="import_form_importForm" type="submit" [disabled]="disabled">
<span [hidden]="loading">{{ "importData" | i18n }}</span>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!loading" aria-hidden="true"></i>
</button>

View File

@ -11,7 +11,7 @@
<button
[disabled]="disabled"
[loading]="loading"
form="importForm"
form="import_form_importForm"
bitButton
type="submit"
bitFormButton

View File

@ -2538,5 +2538,63 @@
},
"confirmFilePassword": {
"message": "Confirm file password"
},
"multifactorAuthenticationCancelled": {
"message": "Multifactor authentication cancelled"
},
"noLastPassDataFound": {
"message": "No LastPass data found"
},
"incorrectUsernameOrPassword": {
"message": "Incorrect username or password"
},
"multifactorAuthenticationFailed": {
"message": "Multifactor authentication failed"
},
"includeSharedFolders": {
"message": "Include shared folders"
},
"lastPassEmail": {
"message": "LastPass Email"
},
"importingYourAccount": {
"message": "Importing your account..."
},
"lastPassMFARequired": {
"message": "LastPass multifactor authentication required"
},
"lastPassMFADesc": {
"message": "Enter your one-time passcode from your authentication app"
},
"lastPassOOBDesc": {
"message": "Approve the login request in your authentication app or enter a one-time passcode."
},
"passcode": {
"message": "Passcode"
},
"lastPassMasterPassword": {
"message": "LastPass master password"
},
"lastPassAuthRequired": {
"message": "LastPass authentication required"
},
"awaitingSSO": {
"message": "Awaiting SSO authentication"
},
"awaitingSSODesc": {
"message": "Please continue to log in using your company credentials."
},
"seeDetailedInstructions": {
"message": "See detailed instructions on our help site at",
"description": "This is followed a by a hyperlink to the help website."
},
"importDirectlyFromLastPass": {
"message": "Import directly from LastPass"
},
"importFromCSV": {
"message": "Import from CSV"
},
"lastPassTryAgainCheckEmail": {
"message": "Try again or look for an email from LastPass to verify it's you."
}
}

View File

@ -218,9 +218,16 @@ export class Main {
const url = new URL(s);
const code = url.searchParams.get("code");
const receivedState = url.searchParams.get("state");
if (code != null && receivedState != null) {
this.messagingService.send("ssoCallback", { code: code, state: receivedState });
if (code == null || receivedState == null) {
return;
}
const message =
s.indexOf("bitwarden://import-callback-lp") === 0
? "importCallbackLastPass"
: "ssoCallback";
this.messagingService.send(message, { code: code, state: receivedState });
});
}
}

View File

@ -8,7 +8,7 @@
<button
[disabled]="disabled"
[loading]="loading"
form="importForm"
form="import_form_importForm"
bitButton
type="submit"
bitFormButton

View File

@ -7281,5 +7281,9 @@
},
"passkeyNotCopiedAlert": {
"message": "The passkey will not be copied to the cloned item. Do you want to continue cloning this item?"
},
"seeDetailedInstructions": {
"message": "See detailed instructions on our help site at",
"description": "This is followed a by a hyperlink to the help website."
}
}

View File

@ -18,7 +18,7 @@ export abstract class TokenService {
getTwoFactorToken: () => Promise<string>;
clearTwoFactorToken: () => Promise<any>;
clearToken: (userId?: string) => Promise<any>;
decodeToken: (token?: string) => any;
decodeToken: (token?: string) => Promise<any>;
getTokenExpirationDate: () => Promise<Date>;
tokenSecondsRemaining: (offsetSeconds?: number) => Promise<number>;
tokenNeedsRefresh: (minutes?: number) => Promise<boolean>;

View File

@ -11,7 +11,6 @@
<input
bitInput
type="password"
name="filePassword"
formControlName="filePassword"
appAutofocus
appInputVerbatim

View File

@ -27,7 +27,7 @@ import {
],
})
export class FilePasswordPromptComponent {
formGroup = this.formBuilder.group({
protected formGroup = this.formBuilder.group({
filePassword: ["", Validators.required],
});

View File

@ -1,7 +1,7 @@
<bit-callout type="info" *ngIf="importBlockedByPolicy">
{{ "personalOwnershipPolicyInEffectImports" | i18n }}
</bit-callout>
<form [formGroup]="formGroup" [bitSubmit]="submit" id="importForm">
<form [formGroup]="formGroup" [bitSubmit]="submit" id="import_form_importForm">
<bit-form-field>
<bit-label
>{{ "importDestination" | i18n }}
@ -77,10 +77,20 @@
>
</ng-container>
<ng-container *ngIf="format === 'lastpasscsv'">
See detailed instructions on our help site at
<a target="_blank" rel="noopener" href="https://bitwarden.com/help/import-from-lastpass/">
https://bitwarden.com/help/import-from-lastpass/</a
>
<p bitTypography="body1">
{{ "seeDetailedInstructions" | i18n }}
<a target="_blank" rel="noopener" href="https://bitwarden.com/help/import-from-lastpass/">
https://bitwarden.com/help/import-from-lastpass/</a
>
</p>
<bit-radio-group *ngIf="showLastPassToggle" formControlName="lastPassType">
<bit-radio-button class="tw-block" id="import_bit-radio-button_lp-direct" value="direct">
<bit-label>{{ "importDirectlyFromLastPass" | i18n }}</bit-label>
</bit-radio-button>
<bit-radio-button class="tw-block" id="import_bit-radio-button_lp-csv" value="csv">
<bit-label>{{ "importFromCSV" | i18n }}</bit-label>
</bit-radio-button>
</bit-radio-group>
</ng-container>
<ng-container *ngIf="format === 'keepassxcsv'">
Using the KeePassX desktop application, navigate to "Database" &rarr; "Export to CSV file" and
@ -344,33 +354,37 @@
and save the zip file.
</ng-container>
</bit-callout>
<bit-form-field>
<bit-label>{{ "selectImportFile" | i18n }}</bit-label>
<div class="file-selector">
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
{{ this.fileSelected ? this.fileSelected.name : ("noFileChosen" | i18n) }}
</div>
<input
bitInput
#fileSelector
type="file"
id="file"
class="form-control-file"
name="file"
formControlName="file"
(change)="setSelectedFile($event)"
hidden
/>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "orCopyPasteFileContents" | i18n }}</bit-label>
<textarea
id="fileContents"
bitInput
name="FileContents"
formControlName="fileContents"
></textarea>
</bit-form-field>
<import-lastpass
*ngIf="showLastPassOptions"
[formGroup]="formGroup"
(csvDataLoaded)="this.formGroup.controls.fileContents.setValue($event)"
></import-lastpass>
<div [hidden]="showLastPassOptions">
<bit-form-field>
<bit-label>{{ "selectImportFile" | i18n }}</bit-label>
<div class="file-selector">
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
{{ this.fileSelected ? this.fileSelected.name : ("noFileChosen" | i18n) }}
</div>
<input
bitInput
#fileSelector
type="file"
id="import_input_file"
formControlName="file"
(change)="setSelectedFile($event)"
hidden
/>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "orCopyPasteFileContents" | i18n }}</bit-label>
<textarea
id="import_textarea_fileContents"
bitInput
formControlName="fileContents"
></textarea>
</bit-form-field>
</div>
</form>

View File

@ -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 === "")) {

View File

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

View File

@ -0,0 +1,14 @@
<bit-simple-dialog>
<div bitDialogIcon>
<i class="bwi bwi-key bwi-2x tw-text-warning" aria-hidden="true"></i>
</div>
<span bitDialogTitle>{{ "awaitingSSO" | i18n }}</span>
<span bitDialogContent>
{{ "awaitingSSODesc" | i18n }}
</span>
<ng-container bitDialogFooter>
<button bitButton type="button" buttonType="secondary" [bitDialogClose]="true">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-simple-dialog>

View File

@ -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<boolean>(LastPassAwaitSSODialogComponent);
}
}

View File

@ -0,0 +1,25 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle>
{{ "lastPassMFARequired" | i18n }}
</span>
<div bitDialogContent>
<p>{{ description | i18n }}</p>
<bit-form-field class="!tw-mb-0">
<bit-label>{{ "passcode" | i18n }}</bit-label>
<input bitInput type="text" formControlName="passcode" appAutofocus appInputVerbatim />
<bit-hint>{{ "confirmIdentity" | i18n }}</bit-hint>
</bit-form-field>
</div>
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" type="submit" bitFormButton>
<span>{{ "continue" | i18n }}</span>
</button>
<button bitButton bitDialogClose="cancel" buttonType="secondary" type="button" bitFormButton>
<span>{{ "cancel" | i18n }}</span>
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -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<string>(LastPassMultifactorPromptComponent, { data });
}
}

View File

@ -0,0 +1,25 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle>
{{ "lastPassAuthRequired" | i18n }}
</span>
<div bitDialogContent>
<bit-form-field class="!tw-mb-0">
<bit-label>{{ "lastPassMasterPassword" | i18n }}</bit-label>
<input bitInput type="password" formControlName="password" appAutofocus appInputVerbatim />
<button type="button" bitSuffix bitIconButton bitPasswordInputToggle></button>
<bit-hint>{{ "confirmIdentity" | i18n }}</bit-hint>
</bit-form-field>
</div>
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" type="submit" bitFormButton>
<span>{{ "submit" | i18n }}</span>
</button>
<button bitButton bitDialogClose buttonType="secondary" type="button" bitFormButton>
<span>{{ "cancel" | i18n }}</span>
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -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<string>(LastPassPasswordPromptComponent);
return firstValueFrom(dialogRef.closed);
}
}

View File

@ -0,0 +1,16 @@
<form [formGroup]="formGroup" (keydown.enter)="$event.preventDefault()">
<bit-form-field>
<bit-label>{{ "lastPassEmail" | i18n }}</bit-label>
<input bitInput type="text" formControlName="email" />
<bit-hint>{{ emailHint$ | async }}</bit-hint>
</bit-form-field>
<bit-form-control>
<input
bitCheckbox
type="checkbox"
formControlName="includeSharedFolders"
id="import-lastpass_input_includeSharedFolders"
/>
<bit-label>{{ "includeSharedFolders" | i18n }}</bit-label>
</bit-form-control>
</form>

View File

@ -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<string>();
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";
}
}
}

View File

@ -0,0 +1 @@
export { ImportLastPassComponent } from "./import-lastpass.component";

View File

@ -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<string>;
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();
}
}

View File

@ -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<string> {
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<string> {
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<string> {
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<ClientInfo> {
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));
}
}

View File

@ -1,6 +0,0 @@
export enum DuoFactor {
Push,
Call,
Passcode,
SendPasscodesBySms,
}

View File

@ -1,5 +0,0 @@
export enum DuoStatus {
Success,
Error,
Info,
}

View File

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

View File

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

View File

@ -273,22 +273,9 @@ export class Client {
ui: Ui,
rest: RestClient
): Promise<Session> {
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<string, any>();
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<string, any>): Promise<Session> => {
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<string, any>();
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<string, any>();
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<string, string>,
ui: Ui,
rest: RestClient
): OobResult {
// TODO: implement this
return OobResult.cancel;
): Promise<OobResult> {
// 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;
}

View File

@ -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[];
}

View File

@ -1,2 +1 @@
export { DuoUi, DuoChoice, DuoDevice } from "./duo-ui";
export { Ui } from "./ui";

View File

@ -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<OtpResult>;
provideMicrosoftAuthPasscode: () => Promise<OtpResult>;
@ -26,4 +23,7 @@ export abstract class Ui extends DuoUi {
approveLastPassAuth: () => Promise<OobResult>;
approveDuo: () => Promise<OobResult>;
approveSalesforceAuth: () => Promise<OobResult>;
/** Close MFA dialog on import success or error */
closeMFADialog: () => void;
}

View File

@ -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;

21
package-lock.json generated
View File

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

View File

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