mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-22 16:29:09 +01:00
[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:
parent
a4303fac59
commit
ec866c744e
@ -2610,5 +2610,9 @@
|
|||||||
},
|
},
|
||||||
"useBrowserName": {
|
"useBrowserName": {
|
||||||
"message": "Use browser"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
<span class="title">{{ "importData" | i18n }}</span>
|
<span class="title">{{ "importData" | i18n }}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="right">
|
<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>
|
<span [hidden]="loading">{{ "importData" | i18n }}</span>
|
||||||
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!loading" aria-hidden="true"></i>
|
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!loading" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
<button
|
<button
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[loading]="loading"
|
[loading]="loading"
|
||||||
form="importForm"
|
form="import_form_importForm"
|
||||||
bitButton
|
bitButton
|
||||||
type="submit"
|
type="submit"
|
||||||
bitFormButton
|
bitFormButton
|
||||||
|
@ -2538,5 +2538,63 @@
|
|||||||
},
|
},
|
||||||
"confirmFilePassword": {
|
"confirmFilePassword": {
|
||||||
"message": "Confirm file password"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -218,9 +218,16 @@ export class Main {
|
|||||||
const url = new URL(s);
|
const url = new URL(s);
|
||||||
const code = url.searchParams.get("code");
|
const code = url.searchParams.get("code");
|
||||||
const receivedState = url.searchParams.get("state");
|
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 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
<button
|
<button
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[loading]="loading"
|
[loading]="loading"
|
||||||
form="importForm"
|
form="import_form_importForm"
|
||||||
bitButton
|
bitButton
|
||||||
type="submit"
|
type="submit"
|
||||||
bitFormButton
|
bitFormButton
|
||||||
|
@ -7281,5 +7281,9 @@
|
|||||||
},
|
},
|
||||||
"passkeyNotCopiedAlert": {
|
"passkeyNotCopiedAlert": {
|
||||||
"message": "The passkey will not be copied to the cloned item. Do you want to continue cloning this item?"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ export abstract class TokenService {
|
|||||||
getTwoFactorToken: () => Promise<string>;
|
getTwoFactorToken: () => Promise<string>;
|
||||||
clearTwoFactorToken: () => Promise<any>;
|
clearTwoFactorToken: () => Promise<any>;
|
||||||
clearToken: (userId?: string) => Promise<any>;
|
clearToken: (userId?: string) => Promise<any>;
|
||||||
decodeToken: (token?: string) => any;
|
decodeToken: (token?: string) => Promise<any>;
|
||||||
getTokenExpirationDate: () => Promise<Date>;
|
getTokenExpirationDate: () => Promise<Date>;
|
||||||
tokenSecondsRemaining: (offsetSeconds?: number) => Promise<number>;
|
tokenSecondsRemaining: (offsetSeconds?: number) => Promise<number>;
|
||||||
tokenNeedsRefresh: (minutes?: number) => Promise<boolean>;
|
tokenNeedsRefresh: (minutes?: number) => Promise<boolean>;
|
||||||
|
@ -11,7 +11,6 @@
|
|||||||
<input
|
<input
|
||||||
bitInput
|
bitInput
|
||||||
type="password"
|
type="password"
|
||||||
name="filePassword"
|
|
||||||
formControlName="filePassword"
|
formControlName="filePassword"
|
||||||
appAutofocus
|
appAutofocus
|
||||||
appInputVerbatim
|
appInputVerbatim
|
||||||
|
@ -27,7 +27,7 @@ import {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FilePasswordPromptComponent {
|
export class FilePasswordPromptComponent {
|
||||||
formGroup = this.formBuilder.group({
|
protected formGroup = this.formBuilder.group({
|
||||||
filePassword: ["", Validators.required],
|
filePassword: ["", Validators.required],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<bit-callout type="info" *ngIf="importBlockedByPolicy">
|
<bit-callout type="info" *ngIf="importBlockedByPolicy">
|
||||||
{{ "personalOwnershipPolicyInEffectImports" | i18n }}
|
{{ "personalOwnershipPolicyInEffectImports" | i18n }}
|
||||||
</bit-callout>
|
</bit-callout>
|
||||||
<form [formGroup]="formGroup" [bitSubmit]="submit" id="importForm">
|
<form [formGroup]="formGroup" [bitSubmit]="submit" id="import_form_importForm">
|
||||||
<bit-form-field>
|
<bit-form-field>
|
||||||
<bit-label
|
<bit-label
|
||||||
>{{ "importDestination" | i18n }}
|
>{{ "importDestination" | i18n }}
|
||||||
@ -77,10 +77,20 @@
|
|||||||
>
|
>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="format === 'lastpasscsv'">
|
<ng-container *ngIf="format === 'lastpasscsv'">
|
||||||
See detailed instructions on our help site at
|
<p bitTypography="body1">
|
||||||
<a target="_blank" rel="noopener" href="https://bitwarden.com/help/import-from-lastpass/">
|
{{ "seeDetailedInstructions" | i18n }}
|
||||||
https://bitwarden.com/help/import-from-lastpass/</a
|
<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>
|
||||||
<ng-container *ngIf="format === 'keepassxcsv'">
|
<ng-container *ngIf="format === 'keepassxcsv'">
|
||||||
Using the KeePassX desktop application, navigate to "Database" → "Export to CSV file" and
|
Using the KeePassX desktop application, navigate to "Database" → "Export to CSV file" and
|
||||||
@ -344,33 +354,37 @@
|
|||||||
and save the zip file.
|
and save the zip file.
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</bit-callout>
|
</bit-callout>
|
||||||
<bit-form-field>
|
<import-lastpass
|
||||||
<bit-label>{{ "selectImportFile" | i18n }}</bit-label>
|
*ngIf="showLastPassOptions"
|
||||||
<div class="file-selector">
|
[formGroup]="formGroup"
|
||||||
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
|
(csvDataLoaded)="this.formGroup.controls.fileContents.setValue($event)"
|
||||||
{{ "chooseFile" | i18n }}
|
></import-lastpass>
|
||||||
</button>
|
<div [hidden]="showLastPassOptions">
|
||||||
{{ this.fileSelected ? this.fileSelected.name : ("noFileChosen" | i18n) }}
|
<bit-form-field>
|
||||||
</div>
|
<bit-label>{{ "selectImportFile" | i18n }}</bit-label>
|
||||||
<input
|
<div class="file-selector">
|
||||||
bitInput
|
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
|
||||||
#fileSelector
|
{{ "chooseFile" | i18n }}
|
||||||
type="file"
|
</button>
|
||||||
id="file"
|
{{ this.fileSelected ? this.fileSelected.name : ("noFileChosen" | i18n) }}
|
||||||
class="form-control-file"
|
</div>
|
||||||
name="file"
|
<input
|
||||||
formControlName="file"
|
bitInput
|
||||||
(change)="setSelectedFile($event)"
|
#fileSelector
|
||||||
hidden
|
type="file"
|
||||||
/>
|
id="import_input_file"
|
||||||
</bit-form-field>
|
formControlName="file"
|
||||||
<bit-form-field>
|
(change)="setSelectedFile($event)"
|
||||||
<bit-label>{{ "orCopyPasteFileContents" | i18n }}</bit-label>
|
hidden
|
||||||
<textarea
|
/>
|
||||||
id="fileContents"
|
</bit-form-field>
|
||||||
bitInput
|
<bit-form-field>
|
||||||
name="FileContents"
|
<bit-label>{{ "orCopyPasteFileContents" | i18n }}</bit-label>
|
||||||
formControlName="fileContents"
|
<textarea
|
||||||
></textarea>
|
id="import_textarea_fileContents"
|
||||||
</bit-form-field>
|
bitInput
|
||||||
|
formControlName="fileContents"
|
||||||
|
></textarea>
|
||||||
|
</bit-form-field>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -10,8 +10,8 @@ import {
|
|||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
|
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||||
import * as JSZip from "jszip";
|
import * as JSZip from "jszip";
|
||||||
import { concat, Observable, Subject, lastValueFrom, combineLatest } from "rxjs";
|
import { concat, Observable, Subject, lastValueFrom, combineLatest, firstValueFrom } from "rxjs";
|
||||||
import { map, takeUntil } from "rxjs/operators";
|
import { filter, map, takeUntil } from "rxjs/operators";
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
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 { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
@ -41,6 +42,7 @@ import {
|
|||||||
DialogService,
|
DialogService,
|
||||||
FormFieldModule,
|
FormFieldModule,
|
||||||
IconButtonModule,
|
IconButtonModule,
|
||||||
|
RadioButtonModule,
|
||||||
SelectModule,
|
SelectModule,
|
||||||
} from "@bitwarden/components";
|
} from "@bitwarden/components";
|
||||||
|
|
||||||
@ -57,6 +59,7 @@ import {
|
|||||||
ImportErrorDialogComponent,
|
ImportErrorDialogComponent,
|
||||||
ImportSuccessDialogComponent,
|
ImportSuccessDialogComponent,
|
||||||
} from "./dialog";
|
} from "./dialog";
|
||||||
|
import { ImportLastPassComponent } from "./lastpass";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "tools-import",
|
selector: "tools-import",
|
||||||
@ -72,6 +75,8 @@ import {
|
|||||||
SelectModule,
|
SelectModule,
|
||||||
CalloutModule,
|
CalloutModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
|
ImportLastPassComponent,
|
||||||
|
RadioButtonModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@ -137,6 +142,7 @@ export class ImportComponent implements OnInit, OnDestroy {
|
|||||||
format: [null as ImportType | null, [Validators.required]],
|
format: [null as ImportType | null, [Validators.required]],
|
||||||
fileContents: [],
|
fileContents: [],
|
||||||
file: [],
|
file: [],
|
||||||
|
lastPassType: ["direct" as "csv" | "direct"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ViewChild(BitSubmitDirective)
|
@ViewChild(BitSubmitDirective)
|
||||||
@ -179,6 +185,16 @@ export class ImportComponent implements OnInit, OnDestroy {
|
|||||||
return this._importBlockedByPolicy;
|
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() {
|
ngOnInit() {
|
||||||
this.setImportOptions();
|
this.setImportOptions();
|
||||||
|
|
||||||
@ -243,6 +259,8 @@ export class ImportComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
submit = async () => {
|
submit = async () => {
|
||||||
|
await this.asyncValidatorsFinished();
|
||||||
|
|
||||||
if (this.formGroup.invalid) {
|
if (this.formGroup.invalid) {
|
||||||
this.formGroup.markAllAsTouched();
|
this.formGroup.markAllAsTouched();
|
||||||
return;
|
return;
|
||||||
@ -251,6 +269,14 @@ export class ImportComponent implements OnInit, OnDestroy {
|
|||||||
await this.performImport();
|
await this.performImport();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private async asyncValidatorsFinished() {
|
||||||
|
if (this.formGroup.pending) {
|
||||||
|
await firstValueFrom(
|
||||||
|
this.formGroup.statusChanges.pipe(filter((status) => status !== "PENDING"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected async performImport() {
|
protected async performImport() {
|
||||||
if (this.organization) {
|
if (this.organization) {
|
||||||
const confirmed = await this.dialogService.openSimpleDialog({
|
const confirmed = await this.dialogService.openSimpleDialog({
|
||||||
@ -292,7 +318,7 @@ export class ImportComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileEl = document.getElementById("file") as HTMLInputElement;
|
const fileEl = document.getElementById("import_input_file") as HTMLInputElement;
|
||||||
const files = fileEl.files;
|
const files = fileEl.files;
|
||||||
let fileContents = this.formGroup.controls.fileContents.value;
|
let fileContents = this.formGroup.controls.fileContents.value;
|
||||||
if ((files == null || files.length === 0) && (fileContents == null || fileContents === "")) {
|
if ((files == null || files.length === 0) && (fileContents == null || fileContents === "")) {
|
||||||
|
3
libs/importer/src/components/lastpass/dialog/index.ts
Normal file
3
libs/importer/src/components/lastpass/dialog/index.ts
Normal 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";
|
@ -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>
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
libs/importer/src/components/lastpass/index.ts
Normal file
1
libs/importer/src/components/lastpass/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ImportLastPassComponent } from "./import-lastpass.component";
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +0,0 @@
|
|||||||
export enum DuoFactor {
|
|
||||||
Push,
|
|
||||||
Call,
|
|
||||||
Passcode,
|
|
||||||
SendPasscodesBySms,
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
export enum DuoStatus {
|
|
||||||
Success,
|
|
||||||
Error,
|
|
||||||
Info,
|
|
||||||
}
|
|
@ -1,5 +1,3 @@
|
|||||||
export { DuoFactor } from "./duo-factor";
|
|
||||||
export { DuoStatus } from "./duo-status";
|
|
||||||
export { IdpProvider } from "./idp-provider";
|
export { IdpProvider } from "./idp-provider";
|
||||||
export { LastpassLoginType } from "./lastpass-login-type";
|
export { LastpassLoginType } from "./lastpass-login-type";
|
||||||
export { OtpMethod } from "./otp-method";
|
export { OtpMethod } from "./otp-method";
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
|
||||||
|
|
||||||
import { Platform } from "../enums";
|
import { Platform } from "../enums";
|
||||||
|
|
||||||
export class ClientInfo {
|
export class ClientInfo {
|
||||||
@ -7,7 +5,7 @@ export class ClientInfo {
|
|||||||
id: string;
|
id: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
static createClientInfo(): ClientInfo {
|
static createClientInfo(id: string): ClientInfo {
|
||||||
return { platform: Platform.Desktop, id: Utils.newGuid(), description: "Importer" };
|
return { platform: Platform.Desktop, id, description: "Importer" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -273,22 +273,9 @@ export class Client {
|
|||||||
ui: Ui,
|
ui: Ui,
|
||||||
rest: RestClient
|
rest: RestClient
|
||||||
): Promise<Session> {
|
): Promise<Session> {
|
||||||
const answer = await this.approveOob(username, parameters, ui, rest);
|
// In case of the OOB auth the server doesn't respond instantly. This works more like a long poll.
|
||||||
if (answer == OobResult.cancel) {
|
// The server times out in about 10 seconds so there's no need to back off.
|
||||||
throw new Error("Out of band step is canceled by the user");
|
const attemptLogin = async (extraParameters: Map<string, any>): Promise<Session> => {
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
const response = await this.performSingleLoginRequest(
|
const response = await this.performSingleLoginRequest(
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
@ -298,9 +285,9 @@ export class Client {
|
|||||||
rest
|
rest
|
||||||
);
|
);
|
||||||
|
|
||||||
session = this.extractSessionFromLoginResponse(response, keyIterationCount, clientInfo);
|
const session = this.extractSessionFromLoginResponse(response, keyIterationCount, clientInfo);
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
break;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.getOptionalErrorAttribute(response, "cause") != "outofbandrequired") {
|
if (this.getOptionalErrorAttribute(response, "cause") != "outofbandrequired") {
|
||||||
@ -310,11 +297,37 @@ export class Client {
|
|||||||
// Retry
|
// Retry
|
||||||
extraParameters.set("outofbandretry", "1");
|
extraParameters.set("outofbandretry", "1");
|
||||||
extraParameters.set("outofbandretryid", this.getErrorAttribute(response, "retryid"));
|
extraParameters.set("outofbandretryid", this.getErrorAttribute(response, "retryid"));
|
||||||
}
|
|
||||||
|
|
||||||
if (answer.rememberMe) {
|
return attemptLogin(extraParameters);
|
||||||
await this.markDeviceAsTrusted(session, clientInfo, rest);
|
};
|
||||||
}
|
|
||||||
|
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;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -356,9 +369,9 @@ export class Client {
|
|||||||
parameters: Map<string, string>,
|
parameters: Map<string, string>,
|
||||||
ui: Ui,
|
ui: Ui,
|
||||||
rest: RestClient
|
rest: RestClient
|
||||||
): OobResult {
|
): Promise<OobResult> {
|
||||||
// TODO: implement this
|
// TODO: implement this instead of calling `approveDuo`
|
||||||
return OobResult.cancel;
|
return ui.approveDuo();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async markDeviceAsTrusted(session: Session, clientInfo: ClientInfo, rest: RestClient) {
|
private async markDeviceAsTrusted(session: Session, clientInfo: ClientInfo, rest: RestClient) {
|
||||||
@ -539,6 +552,8 @@ export class Client {
|
|||||||
return "Second factor code is incorrect";
|
return "Second factor code is incorrect";
|
||||||
case "multifactorresponsefailed":
|
case "multifactorresponsefailed":
|
||||||
return "Out of band authentication failed";
|
return "Out of band authentication failed";
|
||||||
|
case "unifiedloginresult":
|
||||||
|
return "unifiedloginresult";
|
||||||
default:
|
default:
|
||||||
return message?.value ?? cause.value;
|
return message?.value ?? cause.value;
|
||||||
}
|
}
|
||||||
|
@ -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[];
|
|
||||||
}
|
|
@ -1,2 +1 @@
|
|||||||
export { DuoUi, DuoChoice, DuoDevice } from "./duo-ui";
|
|
||||||
export { Ui } from "./ui";
|
export { Ui } from "./ui";
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
import { OobResult, OtpResult } from "../models";
|
import { OobResult, OtpResult } from "../models";
|
||||||
|
export abstract class Ui {
|
||||||
import { DuoUi } from "./duo-ui";
|
|
||||||
|
|
||||||
export abstract class Ui extends DuoUi {
|
|
||||||
// To cancel return OtpResult.Cancel, otherwise only valid data is expected.
|
// To cancel return OtpResult.Cancel, otherwise only valid data is expected.
|
||||||
provideGoogleAuthPasscode: () => Promise<OtpResult>;
|
provideGoogleAuthPasscode: () => Promise<OtpResult>;
|
||||||
provideMicrosoftAuthPasscode: () => Promise<OtpResult>;
|
provideMicrosoftAuthPasscode: () => Promise<OtpResult>;
|
||||||
@ -26,4 +23,7 @@ export abstract class Ui extends DuoUi {
|
|||||||
approveLastPassAuth: () => Promise<OobResult>;
|
approveLastPassAuth: () => Promise<OobResult>;
|
||||||
approveDuo: () => Promise<OobResult>;
|
approveDuo: () => Promise<OobResult>;
|
||||||
approveSalesforceAuth: () => Promise<OobResult>;
|
approveSalesforceAuth: () => Promise<OobResult>;
|
||||||
|
|
||||||
|
/** Close MFA dialog on import success or error */
|
||||||
|
closeMFADialog: () => void;
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ export const featuredImportOptions = [
|
|||||||
{ id: "dashlanecsv", name: "Dashlane (csv)" },
|
{ id: "dashlanecsv", name: "Dashlane (csv)" },
|
||||||
{ id: "firefoxcsv", name: "Firefox (csv)" },
|
{ id: "firefoxcsv", name: "Firefox (csv)" },
|
||||||
{ id: "keepass2xml", name: "KeePass 2 (xml)" },
|
{ id: "keepass2xml", name: "KeePass 2 (xml)" },
|
||||||
{ id: "lastpasscsv", name: "LastPass (csv)" },
|
{ id: "lastpasscsv", name: "LastPass" },
|
||||||
{ id: "safaricsv", name: "Safari and macOS (csv)" },
|
{ id: "safaricsv", name: "Safari and macOS (csv)" },
|
||||||
{ id: "1password1pux", name: "1Password (1pux/json)" },
|
{ id: "1password1pux", name: "1Password (1pux/json)" },
|
||||||
] as const;
|
] as const;
|
||||||
|
21
package-lock.json
generated
21
package-lock.json
generated
@ -56,6 +56,7 @@
|
|||||||
"node-fetch": "2.6.12",
|
"node-fetch": "2.6.12",
|
||||||
"node-forge": "1.3.1",
|
"node-forge": "1.3.1",
|
||||||
"nord": "0.2.1",
|
"nord": "0.2.1",
|
||||||
|
"oidc-client-ts": "2.3.0",
|
||||||
"open": "8.4.2",
|
"open": "8.4.2",
|
||||||
"papaparse": "5.4.1",
|
"papaparse": "5.4.1",
|
||||||
"patch-package": "6.5.1",
|
"patch-package": "6.5.1",
|
||||||
@ -19047,8 +19048,7 @@
|
|||||||
"node_modules/crypto-js": {
|
"node_modules/crypto-js": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz",
|
||||||
"integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==",
|
"integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/crypto-random-string": {
|
"node_modules/crypto-random-string": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@ -27499,6 +27499,11 @@
|
|||||||
"integrity": "sha512-qpcRocdkUmf+UTNBYx5w6dexX5J31AKK1OmPwH630a83DdVVUIngk55RSAiIGpQyoH0dlr872VHfPjnQnK1qDQ==",
|
"integrity": "sha512-qpcRocdkUmf+UTNBYx5w6dexX5J31AKK1OmPwH630a83DdVVUIngk55RSAiIGpQyoH0dlr872VHfPjnQnK1qDQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/karma-source-map-support": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz",
|
||||||
@ -31655,6 +31660,18 @@
|
|||||||
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
@ -188,6 +188,7 @@
|
|||||||
"node-fetch": "2.6.12",
|
"node-fetch": "2.6.12",
|
||||||
"node-forge": "1.3.1",
|
"node-forge": "1.3.1",
|
||||||
"nord": "0.2.1",
|
"nord": "0.2.1",
|
||||||
|
"oidc-client-ts": "2.3.0",
|
||||||
"open": "8.4.2",
|
"open": "8.4.2",
|
||||||
"papaparse": "5.4.1",
|
"papaparse": "5.4.1",
|
||||||
"patch-package": "6.5.1",
|
"patch-package": "6.5.1",
|
||||||
|
Loading…
Reference in New Issue
Block a user