mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-21 16:18:28 +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": {
|
||||
"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>
|
||||
</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>
|
||||
|
@ -11,7 +11,7 @@
|
||||
<button
|
||||
[disabled]="disabled"
|
||||
[loading]="loading"
|
||||
form="importForm"
|
||||
form="import_form_importForm"
|
||||
bitButton
|
||||
type="submit"
|
||||
bitFormButton
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@
|
||||
<button
|
||||
[disabled]="disabled"
|
||||
[loading]="loading"
|
||||
form="importForm"
|
||||
form="import_form_importForm"
|
||||
bitButton
|
||||
type="submit"
|
||||
bitFormButton
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
@ -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>;
|
||||
|
@ -11,7 +11,6 @@
|
||||
<input
|
||||
bitInput
|
||||
type="password"
|
||||
name="filePassword"
|
||||
formControlName="filePassword"
|
||||
appAutofocus
|
||||
appInputVerbatim
|
||||
|
@ -27,7 +27,7 @@ import {
|
||||
],
|
||||
})
|
||||
export class FilePasswordPromptComponent {
|
||||
formGroup = this.formBuilder.group({
|
||||
protected formGroup = this.formBuilder.group({
|
||||
filePassword: ["", Validators.required],
|
||||
});
|
||||
|
||||
|
@ -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" → "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>
|
||||
|
@ -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 === "")) {
|
||||
|
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 { LastpassLoginType } from "./lastpass-login-type";
|
||||
export { OtpMethod } from "./otp-method";
|
||||
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
21
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user