mirror of
https://github.com/bitwarden/browser
synced 2026-02-25 00:53:22 +00:00
Merge branch 'main' into km/pm-18576/fix-missing-userid-on-remove-password
This commit is contained in:
@@ -39,11 +39,10 @@ export class Collection extends Domain {
|
||||
}
|
||||
|
||||
decrypt(orgKey: OrgKey): Promise<CollectionView> {
|
||||
return this.decryptObj(
|
||||
return this.decryptObj<Collection, CollectionView>(
|
||||
this,
|
||||
new CollectionView(this),
|
||||
{
|
||||
name: null,
|
||||
},
|
||||
["name"],
|
||||
this.organizationId,
|
||||
orgKey,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { animate, state, style, transition, trigger } from "@angular/animations";
|
||||
import { ConnectedPosition } from "@angular/cdk/overlay";
|
||||
import { Component, EventEmitter, Output, Input, OnInit, OnDestroy } from "@angular/core";
|
||||
@@ -7,8 +5,6 @@ import { ActivatedRoute } from "@angular/router";
|
||||
import { Observable, map, Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { SelfHostedEnvConfigDialogComponent } from "@bitwarden/auth/angular";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
@@ -88,7 +84,6 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
|
||||
protected environmentService: EnvironmentService,
|
||||
private route: ActivatedRoute,
|
||||
private dialogService: DialogService,
|
||||
private configService: ConfigService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
@@ -113,24 +108,18 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the self-hosted settings dialog.
|
||||
*
|
||||
* If the `UnauthenticatedExtensionUIRefresh` feature flag is enabled,
|
||||
* the self-hosted settings dialog is opened directly. Otherwise, the
|
||||
* `onOpenSelfHostedSettings` event is emitted.
|
||||
* Opens the self-hosted settings dialog when the self-hosted option is selected.
|
||||
*/
|
||||
if (option === Region.SelfHosted) {
|
||||
if (await this.configService.getFeatureFlag(FeatureFlag.UnauthenticatedExtensionUIRefresh)) {
|
||||
if (await SelfHostedEnvConfigDialogComponent.open(this.dialogService)) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("environmentSaved"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.onOpenSelfHostedSettings.emit();
|
||||
}
|
||||
if (
|
||||
option === Region.SelfHosted &&
|
||||
(await SelfHostedEnvConfigDialogComponent.open(this.dialogService))
|
||||
) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("environmentSaved"),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { ActivatedRoute, NavigationSkipped, Router } from "@angular/router";
|
||||
import { Subject, firstValueFrom, of } from "rxjs";
|
||||
import { switchMap, take, takeUntil } from "rxjs/operators";
|
||||
|
||||
import {
|
||||
LoginStrategyServiceAbstraction,
|
||||
LoginEmailServiceAbstraction,
|
||||
PasswordLoginCredentials,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
|
||||
import {
|
||||
AllValidationErrors,
|
||||
FormValidationErrorsService,
|
||||
} from "../../platform/abstractions/form-validation-errors.service";
|
||||
|
||||
import { CaptchaProtectedComponent } from "./captcha-protected.component";
|
||||
|
||||
@Directive()
|
||||
export class LoginComponentV1 extends CaptchaProtectedComponent implements OnInit, OnDestroy {
|
||||
@ViewChild("masterPasswordInput", { static: true }) masterPasswordInput: ElementRef;
|
||||
|
||||
showPassword = false;
|
||||
formPromise: Promise<AuthResult>;
|
||||
|
||||
onSuccessfulLogin: () => Promise<any>;
|
||||
onSuccessfulLoginNavigate: (userId: UserId) => Promise<any>;
|
||||
onSuccessfulLoginTwoFactorNavigate: () => Promise<any>;
|
||||
onSuccessfulLoginForceResetNavigate: () => Promise<any>;
|
||||
|
||||
showLoginWithDevice: boolean;
|
||||
validatedEmail = false;
|
||||
paramEmailSet = false;
|
||||
|
||||
get emailFormControl() {
|
||||
return this.formGroup.controls.email;
|
||||
}
|
||||
|
||||
formGroup = this.formBuilder.nonNullable.group({
|
||||
email: ["", [Validators.required, Validators.email]],
|
||||
masterPassword: [
|
||||
"",
|
||||
[Validators.required, Validators.minLength(Utils.originalMinimumPasswordLength)],
|
||||
],
|
||||
rememberEmail: [false],
|
||||
});
|
||||
|
||||
protected twoFactorRoute = "2fa";
|
||||
protected successRoute = "vault";
|
||||
protected forcePasswordResetRoute = "update-temp-password";
|
||||
|
||||
protected destroy$ = new Subject<void>();
|
||||
|
||||
get loggedEmail() {
|
||||
return this.formGroup.controls.email.value;
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected devicesApiService: DevicesApiServiceAbstraction,
|
||||
protected appIdService: AppIdService,
|
||||
protected loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
protected router: Router,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
i18nService: I18nService,
|
||||
protected stateService: StateService,
|
||||
environmentService: EnvironmentService,
|
||||
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
|
||||
protected cryptoFunctionService: CryptoFunctionService,
|
||||
protected logService: LogService,
|
||||
protected ngZone: NgZone,
|
||||
protected formBuilder: FormBuilder,
|
||||
protected formValidationErrorService: FormValidationErrorsService,
|
||||
protected route: ActivatedRoute,
|
||||
protected loginEmailService: LoginEmailServiceAbstraction,
|
||||
protected ssoLoginService: SsoLoginServiceAbstraction,
|
||||
protected toastService: ToastService,
|
||||
) {
|
||||
super(environmentService, i18nService, platformUtilsService, toastService);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.route?.queryParams
|
||||
.pipe(
|
||||
switchMap((params) => {
|
||||
if (!params) {
|
||||
// If no params,loadEmailSettings from state
|
||||
return this.loadEmailSettings();
|
||||
}
|
||||
|
||||
const queryParamsEmail = params.email;
|
||||
|
||||
if (queryParamsEmail != null && queryParamsEmail.indexOf("@") > -1) {
|
||||
this.formGroup.controls.email.setValue(queryParamsEmail);
|
||||
this.paramEmailSet = true;
|
||||
}
|
||||
|
||||
// If paramEmailSet is false, loadEmailSettings from state
|
||||
return this.paramEmailSet ? of(null) : this.loadEmailSettings();
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// If the user navigates to /login from /login, reset the validatedEmail flag
|
||||
// This should bring the user back to the login screen with the email field
|
||||
this.router.events.pipe(takeUntil(this.destroy$)).subscribe((event) => {
|
||||
if (event instanceof NavigationSkipped && event.url === "/login") {
|
||||
this.validatedEmail = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Backup check to handle unknown case where activatedRoute is not available
|
||||
// This shouldn't happen under normal circumstances
|
||||
if (!this.route) {
|
||||
await this.loadEmailSettings();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async submit(showToast = true) {
|
||||
await this.setupCaptcha();
|
||||
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
//web
|
||||
if (this.formGroup.invalid && !showToast) {
|
||||
return;
|
||||
}
|
||||
|
||||
//desktop, browser; This should be removed once all clients use reactive forms
|
||||
if (this.formGroup.invalid && showToast) {
|
||||
const errorText = this.getErrorToastMessage();
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: errorText,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = new PasswordLoginCredentials(
|
||||
this.formGroup.controls.email.value,
|
||||
this.formGroup.controls.masterPassword.value,
|
||||
this.captchaToken,
|
||||
undefined,
|
||||
);
|
||||
|
||||
this.formPromise = this.loginStrategyService.logIn(credentials);
|
||||
const response = await this.formPromise;
|
||||
|
||||
await this.saveEmailSettings();
|
||||
|
||||
if (this.handleCaptchaRequired(response)) {
|
||||
return;
|
||||
} else if (await this.handleMigrateEncryptionKey(response)) {
|
||||
return;
|
||||
} else if (response.requiresTwoFactor) {
|
||||
if (this.onSuccessfulLoginTwoFactorNavigate != null) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.onSuccessfulLoginTwoFactorNavigate();
|
||||
} else {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate([this.twoFactorRoute]);
|
||||
}
|
||||
} else if (response.forcePasswordReset != ForceSetPasswordReason.None) {
|
||||
if (this.onSuccessfulLoginForceResetNavigate != null) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.onSuccessfulLoginForceResetNavigate();
|
||||
} else {
|
||||
this.loginEmailService.clearValues();
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate([this.forcePasswordResetRoute]);
|
||||
}
|
||||
} else {
|
||||
if (this.onSuccessfulLogin != null) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.onSuccessfulLogin();
|
||||
}
|
||||
|
||||
if (this.onSuccessfulLoginNavigate != null) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.onSuccessfulLoginNavigate(response.userId);
|
||||
} else {
|
||||
this.loginEmailService.clearValues();
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate([this.successRoute]);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
togglePassword() {
|
||||
this.showPassword = !this.showPassword;
|
||||
if (this.ngZone.isStable) {
|
||||
document.getElementById("masterPassword").focus();
|
||||
} else {
|
||||
this.ngZone.onStable
|
||||
.pipe(take(1))
|
||||
.subscribe(() => document.getElementById("masterPassword").focus());
|
||||
}
|
||||
}
|
||||
|
||||
async startAuthRequestLogin() {
|
||||
this.formGroup.get("masterPassword")?.clearValidators();
|
||||
this.formGroup.get("masterPassword")?.updateValueAndValidity();
|
||||
|
||||
if (!this.formGroup.valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.saveEmailSettings();
|
||||
await this.router.navigate(["/login-with-device"]);
|
||||
}
|
||||
|
||||
async launchSsoBrowser(clientId: string, ssoRedirectUri: string) {
|
||||
// Save off email for SSO
|
||||
await this.ssoLoginService.setSsoEmail(this.formGroup.value.email);
|
||||
|
||||
// Generate necessary sso params
|
||||
const passwordOptions: any = {
|
||||
type: "password",
|
||||
length: 64,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
numbers: true,
|
||||
special: false,
|
||||
};
|
||||
const state = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||
const ssoCodeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||
const codeVerifierHash = await this.cryptoFunctionService.hash(ssoCodeVerifier, "sha256");
|
||||
const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
|
||||
|
||||
// Save sso params
|
||||
await this.ssoLoginService.setSsoState(state);
|
||||
await this.ssoLoginService.setCodeVerifier(ssoCodeVerifier);
|
||||
|
||||
// Build URI
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const webUrl = env.getWebVaultUrl();
|
||||
|
||||
// Launch browser
|
||||
this.platformUtilsService.launchUri(
|
||||
webUrl +
|
||||
"/#/sso?clientId=" +
|
||||
clientId +
|
||||
"&redirectUri=" +
|
||||
encodeURIComponent(ssoRedirectUri) +
|
||||
"&state=" +
|
||||
state +
|
||||
"&codeChallenge=" +
|
||||
codeChallenge +
|
||||
"&email=" +
|
||||
encodeURIComponent(this.formGroup.controls.email.value),
|
||||
);
|
||||
}
|
||||
|
||||
async validateEmail() {
|
||||
this.formGroup.controls.email.markAsTouched();
|
||||
const emailValid = this.formGroup.get("email").valid;
|
||||
|
||||
if (emailValid) {
|
||||
this.toggleValidateEmail(true);
|
||||
await this.getLoginWithDevice(this.loggedEmail);
|
||||
}
|
||||
}
|
||||
|
||||
toggleValidateEmail(value: boolean) {
|
||||
this.validatedEmail = value;
|
||||
if (!this.validatedEmail) {
|
||||
// Reset master password only when going from validated to not validated
|
||||
// so that autofill can work properly
|
||||
this.formGroup.controls.masterPassword.reset();
|
||||
} else {
|
||||
// Mark MP as untouched so that, when users enter email and hit enter,
|
||||
// the MP field doesn't load with validation errors
|
||||
this.formGroup.controls.masterPassword.markAsUntouched();
|
||||
|
||||
// When email is validated, focus on master password after
|
||||
// waiting for input to be rendered
|
||||
if (this.ngZone.isStable) {
|
||||
this.masterPasswordInput?.nativeElement?.focus();
|
||||
} else {
|
||||
this.ngZone.onStable.pipe(take(1)).subscribe(() => {
|
||||
this.masterPasswordInput?.nativeElement?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEmailSettings() {
|
||||
// Try to load from memory first
|
||||
const email = await firstValueFrom(this.loginEmailService.loginEmail$);
|
||||
const rememberEmail = this.loginEmailService.getRememberEmail();
|
||||
|
||||
if (email) {
|
||||
this.formGroup.controls.email.setValue(email);
|
||||
this.formGroup.controls.rememberEmail.setValue(rememberEmail);
|
||||
} else {
|
||||
// If not in memory, check email on disk
|
||||
const storedEmail = await firstValueFrom(this.loginEmailService.storedEmail$);
|
||||
if (storedEmail) {
|
||||
// If we have a stored email, rememberEmail should default to true
|
||||
this.formGroup.controls.email.setValue(storedEmail);
|
||||
this.formGroup.controls.rememberEmail.setValue(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async saveEmailSettings() {
|
||||
// Save off email for SSO
|
||||
await this.ssoLoginService.setSsoEmail(this.formGroup.value.email);
|
||||
|
||||
this.loginEmailService.setLoginEmail(this.formGroup.value.email);
|
||||
this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail);
|
||||
await this.loginEmailService.saveEmailSettings();
|
||||
}
|
||||
|
||||
// Legacy accounts used the master key to encrypt data. Migration is required but only performed on web
|
||||
protected async handleMigrateEncryptionKey(result: AuthResult): Promise<boolean> {
|
||||
if (!result.requiresEncryptionKeyMigration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccured"),
|
||||
message: this.i18nService.t("encryptionKeyMigrationRequired"),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private getErrorToastMessage() {
|
||||
const error: AllValidationErrors = this.formValidationErrorService
|
||||
.getFormValidationErrors(this.formGroup.controls)
|
||||
.shift();
|
||||
|
||||
if (error) {
|
||||
switch (error.errorName) {
|
||||
case "email":
|
||||
return this.i18nService.t("invalidEmail");
|
||||
case "minlength":
|
||||
return this.i18nService.t("masterPasswordMinlength", Utils.originalMinimumPasswordLength);
|
||||
default:
|
||||
return this.i18nService.t(this.errorTag(error));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private errorTag(error: AllValidationErrors): string {
|
||||
const name = error.errorName.charAt(0).toUpperCase() + error.errorName.slice(1);
|
||||
return `${error.controlName}${name}`;
|
||||
}
|
||||
|
||||
async getLoginWithDevice(email: string) {
|
||||
try {
|
||||
const deviceIdentifier = await this.appIdService.getAppId();
|
||||
this.showLoginWithDevice = await this.devicesApiService.getKnownDevice(
|
||||
email,
|
||||
deviceIdentifier,
|
||||
);
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
this.showLoginWithDevice = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, OnDestroy, OnInit } from "@angular/core";
|
||||
import { IsActiveMatchOptions, Router } from "@angular/router";
|
||||
import { Subject, firstValueFrom, map, takeUntil } from "rxjs";
|
||||
|
||||
import {
|
||||
AuthRequestLoginCredentials,
|
||||
AuthRequestServiceAbstraction,
|
||||
LoginStrategyServiceAbstraction,
|
||||
LoginEmailServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||
import { AuthRequestType } from "@bitwarden/common/auth/enums/auth-request-type";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { HttpStatusCode } from "@bitwarden/common/enums/http-status-code.enum";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { CaptchaProtectedComponent } from "./captcha-protected.component";
|
||||
|
||||
enum State {
|
||||
StandardAuthRequest,
|
||||
AdminAuthRequest,
|
||||
}
|
||||
|
||||
@Directive()
|
||||
export class LoginViaAuthRequestComponentV1
|
||||
extends CaptchaProtectedComponent
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
private destroy$ = new Subject<void>();
|
||||
userAuthNStatus: AuthenticationStatus;
|
||||
email: string;
|
||||
showResendNotification = false;
|
||||
authRequest: AuthRequest;
|
||||
fingerprintPhrase: string;
|
||||
onSuccessfulLoginTwoFactorNavigate: () => Promise<any>;
|
||||
onSuccessfulLogin: () => Promise<any>;
|
||||
onSuccessfulLoginNavigate: () => Promise<any>;
|
||||
onSuccessfulLoginForceResetNavigate: () => Promise<any>;
|
||||
|
||||
protected adminApprovalRoute = "admin-approval-requested";
|
||||
|
||||
protected StateEnum = State;
|
||||
protected state = State.StandardAuthRequest;
|
||||
protected webVaultUrl: string;
|
||||
protected twoFactorRoute = "2fa";
|
||||
protected successRoute = "vault";
|
||||
protected forcePasswordResetRoute = "update-temp-password";
|
||||
private resendTimeout = 12000;
|
||||
protected deviceManagementUrl: string;
|
||||
|
||||
private authRequestKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array };
|
||||
|
||||
constructor(
|
||||
protected router: Router,
|
||||
private keyService: KeyService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private appIdService: AppIdService,
|
||||
private passwordGenerationService: PasswordGenerationServiceAbstraction,
|
||||
private apiService: ApiService,
|
||||
private authService: AuthService,
|
||||
private logService: LogService,
|
||||
environmentService: EnvironmentService,
|
||||
i18nService: I18nService,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
private anonymousHubService: AnonymousHubService,
|
||||
private validationService: ValidationService,
|
||||
private accountService: AccountService,
|
||||
private loginEmailService: LoginEmailServiceAbstraction,
|
||||
private deviceTrustService: DeviceTrustServiceAbstraction,
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
private loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
protected toastService: ToastService,
|
||||
) {
|
||||
super(environmentService, i18nService, platformUtilsService, toastService);
|
||||
|
||||
// Get the web vault URL from the environment service
|
||||
environmentService.environment$.pipe(takeUntil(this.destroy$)).subscribe((env) => {
|
||||
this.webVaultUrl = env.getWebVaultUrl();
|
||||
this.deviceManagementUrl = `${this.webVaultUrl}/#/settings/security/device-management`;
|
||||
});
|
||||
|
||||
// Gets signalR push notification
|
||||
// Only fires on approval to prevent enumeration
|
||||
this.authRequestService.authRequestPushNotification$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((id) => {
|
||||
this.verifyAndHandleApprovedAuthReq(id).catch((e: Error) => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("error"),
|
||||
message: e.message,
|
||||
});
|
||||
this.logService.error("Failed to use approved auth request: " + e.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.email = await firstValueFrom(this.loginEmailService.loginEmail$);
|
||||
this.userAuthNStatus = await this.authService.getAuthStatus();
|
||||
|
||||
const matchOptions: IsActiveMatchOptions = {
|
||||
paths: "exact",
|
||||
queryParams: "ignored",
|
||||
fragment: "ignored",
|
||||
matrixParams: "ignored",
|
||||
};
|
||||
|
||||
if (this.router.isActive(this.adminApprovalRoute, matchOptions)) {
|
||||
this.state = State.AdminAuthRequest;
|
||||
}
|
||||
|
||||
if (this.state === State.AdminAuthRequest) {
|
||||
// Pull email from state for admin auth reqs b/c it is available
|
||||
// This also prevents it from being lost on refresh as the
|
||||
// login service email does not persist.
|
||||
this.email = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
|
||||
);
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
||||
|
||||
if (!this.email) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("userEmailMissing"),
|
||||
});
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate(["/login-initiated"]);
|
||||
return;
|
||||
}
|
||||
|
||||
// We only allow a single admin approval request to be active at a time
|
||||
// so must check state to see if we have an existing one or not
|
||||
const adminAuthReqStorable = await this.authRequestService.getAdminAuthRequest(userId);
|
||||
|
||||
if (adminAuthReqStorable) {
|
||||
await this.handleExistingAdminAuthRequest(adminAuthReqStorable, userId);
|
||||
} else {
|
||||
// No existing admin auth request; so we need to create one
|
||||
await this.startAuthRequestLogin();
|
||||
}
|
||||
} else {
|
||||
// Standard auth request
|
||||
// TODO: evaluate if we can remove the setting of this.email in the constructor
|
||||
this.email = await firstValueFrom(this.loginEmailService.loginEmail$);
|
||||
|
||||
if (!this.email) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("userEmailMissing"),
|
||||
});
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate(["/login"]);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startAuthRequestLogin();
|
||||
}
|
||||
}
|
||||
|
||||
async ngOnDestroy() {
|
||||
await this.anonymousHubService.stopHubConnection();
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private async handleExistingAdminAuthRequest(
|
||||
adminAuthReqStorable: AdminAuthRequestStorable,
|
||||
userId: UserId,
|
||||
) {
|
||||
// Note: on login, the SSOLoginStrategy will also call to see an existing admin auth req
|
||||
// has been approved and handle it if so.
|
||||
|
||||
// Regardless, we always retrieve the auth request from the server verify and handle status changes here as well
|
||||
let adminAuthReqResponse: AuthRequestResponse;
|
||||
try {
|
||||
adminAuthReqResponse = await this.apiService.getAuthRequest(adminAuthReqStorable.id);
|
||||
} catch (error) {
|
||||
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
|
||||
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Request doesn't exist anymore
|
||||
if (!adminAuthReqResponse) {
|
||||
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
|
||||
}
|
||||
|
||||
// Re-derive the user's fingerprint phrase
|
||||
// It is important to not use the server's public key here as it could have been compromised via MITM
|
||||
const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
|
||||
adminAuthReqStorable.privateKey,
|
||||
);
|
||||
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
||||
this.email,
|
||||
derivedPublicKeyArrayBuffer,
|
||||
);
|
||||
|
||||
// Request denied
|
||||
if (adminAuthReqResponse.isAnswered && !adminAuthReqResponse.requestApproved) {
|
||||
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
|
||||
}
|
||||
|
||||
// Request approved
|
||||
if (adminAuthReqResponse.requestApproved) {
|
||||
return await this.handleApprovedAdminAuthRequest(
|
||||
adminAuthReqResponse,
|
||||
adminAuthReqStorable.privateKey,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
// Request still pending response from admin
|
||||
// set keypair and create hub connection so that any approvals will be received via push notification
|
||||
this.authRequestKeyPair = { privateKey: adminAuthReqStorable.privateKey, publicKey: null };
|
||||
await this.anonymousHubService.createHubConnection(adminAuthReqStorable.id);
|
||||
}
|
||||
|
||||
private async handleExistingAdminAuthReqDeletedOrDenied(userId: UserId) {
|
||||
// clear the admin auth request from state
|
||||
await this.authRequestService.clearAdminAuthRequest(userId);
|
||||
|
||||
// start new auth request
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.startAuthRequestLogin();
|
||||
}
|
||||
|
||||
private async buildAuthRequest(authRequestType: AuthRequestType) {
|
||||
const authRequestKeyPairArray = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
|
||||
|
||||
this.authRequestKeyPair = {
|
||||
publicKey: authRequestKeyPairArray[0],
|
||||
privateKey: authRequestKeyPairArray[1],
|
||||
};
|
||||
|
||||
const deviceIdentifier = await this.appIdService.getAppId();
|
||||
const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
|
||||
const accessCode = await this.passwordGenerationService.generatePassword({
|
||||
type: "password",
|
||||
length: 25,
|
||||
});
|
||||
|
||||
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
||||
this.email,
|
||||
this.authRequestKeyPair.publicKey,
|
||||
);
|
||||
|
||||
this.authRequest = new AuthRequest(
|
||||
this.email,
|
||||
deviceIdentifier,
|
||||
publicKey,
|
||||
authRequestType,
|
||||
accessCode,
|
||||
);
|
||||
}
|
||||
|
||||
async startAuthRequestLogin() {
|
||||
this.showResendNotification = false;
|
||||
|
||||
try {
|
||||
let reqResponse: AuthRequestResponse;
|
||||
|
||||
if (this.state === State.AdminAuthRequest) {
|
||||
await this.buildAuthRequest(AuthRequestType.AdminApproval);
|
||||
reqResponse = await this.apiService.postAdminAuthRequest(this.authRequest);
|
||||
|
||||
const adminAuthReqStorable = new AdminAuthRequestStorable({
|
||||
id: reqResponse.id,
|
||||
privateKey: this.authRequestKeyPair.privateKey,
|
||||
});
|
||||
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
||||
await this.authRequestService.setAdminAuthRequest(adminAuthReqStorable, userId);
|
||||
} else {
|
||||
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
|
||||
reqResponse = await this.apiService.postAuthRequest(this.authRequest);
|
||||
}
|
||||
|
||||
if (reqResponse.id) {
|
||||
await this.anonymousHubService.createHubConnection(reqResponse.id);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.showResendNotification = true;
|
||||
}, this.resendTimeout);
|
||||
}
|
||||
|
||||
private async verifyAndHandleApprovedAuthReq(requestId: string) {
|
||||
try {
|
||||
// Retrieve the auth request from server and verify it's approved
|
||||
let authReqResponse: AuthRequestResponse;
|
||||
|
||||
switch (this.state) {
|
||||
case State.StandardAuthRequest:
|
||||
// Unauthed - access code required for user verification
|
||||
authReqResponse = await this.apiService.getAuthResponse(
|
||||
requestId,
|
||||
this.authRequest.accessCode,
|
||||
);
|
||||
break;
|
||||
|
||||
case State.AdminAuthRequest:
|
||||
// Authed - no access code required
|
||||
authReqResponse = await this.apiService.getAuthRequest(requestId);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!authReqResponse.requestApproved) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Approved so proceed:
|
||||
|
||||
// 4 Scenarios to handle for approved auth requests:
|
||||
// Existing flow 1:
|
||||
// - Anon Login with Device > User is not AuthN > receives approval from device with pubKey(masterKey)
|
||||
// > decrypt masterKey > must authenticate > gets masterKey(userKey) > decrypt userKey and proceed to vault
|
||||
|
||||
// 3 new flows from TDE:
|
||||
// Flow 2:
|
||||
// - Post SSO > User is AuthN > SSO login strategy success sets masterKey(userKey) > receives approval from device with pubKey(masterKey)
|
||||
// > decrypt masterKey > decrypt userKey > establish trust if required > proceed to vault
|
||||
// Flow 3:
|
||||
// - Post SSO > User is AuthN > Receives approval from device with pubKey(userKey) > decrypt userKey > establish trust if required > proceed to vault
|
||||
// Flow 4:
|
||||
// - Anon Login with Device > User is not AuthN > receives approval from device with pubKey(userKey)
|
||||
// > decrypt userKey > must authenticate > set userKey > proceed to vault
|
||||
|
||||
// if user has authenticated via SSO
|
||||
if (this.userAuthNStatus === AuthenticationStatus.Locked) {
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
||||
return await this.handleApprovedAdminAuthRequest(
|
||||
authReqResponse,
|
||||
this.authRequestKeyPair.privateKey,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
// Flow 1 and 4:
|
||||
const loginAuthResult = await this.loginViaAuthRequestStrategy(requestId, authReqResponse);
|
||||
await this.handlePostLoginNavigation(loginAuthResult);
|
||||
} catch (error) {
|
||||
if (error instanceof ErrorResponse) {
|
||||
let errorRoute = "/login";
|
||||
if (this.state === State.AdminAuthRequest) {
|
||||
errorRoute = "/login-initiated";
|
||||
}
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate([errorRoute]);
|
||||
this.validationService.showError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleApprovedAdminAuthRequest(
|
||||
adminAuthReqResponse: AuthRequestResponse,
|
||||
privateKey: ArrayBuffer,
|
||||
userId: UserId,
|
||||
) {
|
||||
// See verifyAndHandleApprovedAuthReq(...) for flow details
|
||||
// it's flow 2 or 3 based on presence of masterPasswordHash
|
||||
if (adminAuthReqResponse.masterPasswordHash) {
|
||||
// Flow 2: masterPasswordHash is not null
|
||||
// key is authRequestPublicKey(masterKey) + we have authRequestPublicKey(masterPasswordHash)
|
||||
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
|
||||
adminAuthReqResponse,
|
||||
privateKey,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
// Flow 3: masterPasswordHash is null
|
||||
// we can assume key is authRequestPublicKey(userKey) and we can just decrypt with userKey and proceed to vault
|
||||
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
|
||||
adminAuthReqResponse,
|
||||
privateKey,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
// clear the admin auth request from state so it cannot be used again (it's a one time use)
|
||||
// TODO: this should eventually be enforced via deleting this on the server once it is used
|
||||
await this.authRequestService.clearAdminAuthRequest(userId);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("loginApproved"),
|
||||
});
|
||||
|
||||
// Now that we have a decrypted user key in memory, we can check if we
|
||||
// need to establish trust on the current device
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
await this.deviceTrustService.trustDeviceIfRequired(activeAccount.id);
|
||||
|
||||
// TODO: don't forget to use auto enrollment service everywhere we trust device
|
||||
|
||||
await this.handleSuccessfulLoginNavigation();
|
||||
}
|
||||
|
||||
// Authentication helper
|
||||
private async buildAuthRequestLoginCredentials(
|
||||
requestId: string,
|
||||
response: AuthRequestResponse,
|
||||
): Promise<AuthRequestLoginCredentials> {
|
||||
// if masterPasswordHash has a value, we will always receive key as authRequestPublicKey(masterKey) + authRequestPublicKey(masterPasswordHash)
|
||||
// if masterPasswordHash is null, we will always receive key as authRequestPublicKey(userKey)
|
||||
if (response.masterPasswordHash) {
|
||||
const { masterKey, masterKeyHash } =
|
||||
await this.authRequestService.decryptPubKeyEncryptedMasterKeyAndHash(
|
||||
response.key,
|
||||
response.masterPasswordHash,
|
||||
this.authRequestKeyPair.privateKey,
|
||||
);
|
||||
|
||||
return new AuthRequestLoginCredentials(
|
||||
this.email,
|
||||
this.authRequest.accessCode,
|
||||
requestId,
|
||||
null, // no userKey
|
||||
masterKey,
|
||||
masterKeyHash,
|
||||
);
|
||||
} else {
|
||||
const userKey = await this.authRequestService.decryptPubKeyEncryptedUserKey(
|
||||
response.key,
|
||||
this.authRequestKeyPair.privateKey,
|
||||
);
|
||||
return new AuthRequestLoginCredentials(
|
||||
this.email,
|
||||
this.authRequest.accessCode,
|
||||
requestId,
|
||||
userKey,
|
||||
null, // no masterKey
|
||||
null, // no masterKeyHash
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async loginViaAuthRequestStrategy(
|
||||
requestId: string,
|
||||
authReqResponse: AuthRequestResponse,
|
||||
): Promise<AuthResult> {
|
||||
// Note: credentials change based on if the authReqResponse.key is a encryptedMasterKey or UserKey
|
||||
const credentials = await this.buildAuthRequestLoginCredentials(requestId, authReqResponse);
|
||||
|
||||
// Note: keys are set by AuthRequestLoginStrategy success handling
|
||||
return await this.loginStrategyService.logIn(credentials);
|
||||
}
|
||||
|
||||
// Routing logic
|
||||
private async handlePostLoginNavigation(loginResponse: AuthResult) {
|
||||
if (loginResponse.requiresTwoFactor) {
|
||||
if (this.onSuccessfulLoginTwoFactorNavigate != null) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.onSuccessfulLoginTwoFactorNavigate();
|
||||
} else {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate([this.twoFactorRoute]);
|
||||
}
|
||||
} else if (loginResponse.forcePasswordReset != ForceSetPasswordReason.None) {
|
||||
if (this.onSuccessfulLoginForceResetNavigate != null) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.onSuccessfulLoginForceResetNavigate();
|
||||
} else {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate([this.forcePasswordResetRoute]);
|
||||
}
|
||||
} else {
|
||||
await this.handleSuccessfulLoginNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSuccessfulLoginNavigation() {
|
||||
if (this.state === State.StandardAuthRequest) {
|
||||
// Only need to set remembered email on standard login with auth req flow
|
||||
await this.loginEmailService.saveEmailSettings();
|
||||
}
|
||||
|
||||
if (this.onSuccessfulLogin != null) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.onSuccessfulLogin();
|
||||
}
|
||||
|
||||
if (this.onSuccessfulLoginNavigate != null) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.onSuccessfulLoginNavigate();
|
||||
} else {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate([this.successRoute]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { OrganizationAutoEnrollStatusResponse } from "@bitwarden/common/admin-console/models/response/organization-auto-enroll-status.response";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
@@ -62,6 +63,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
|
||||
private policyApiService: PolicyApiServiceAbstraction,
|
||||
policyService: PolicyService,
|
||||
protected router: Router,
|
||||
private masterPasswordApiService: MasterPasswordApiService,
|
||||
private apiService: ApiService,
|
||||
private syncService: SyncService,
|
||||
private route: ActivatedRoute,
|
||||
@@ -195,7 +197,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
|
||||
);
|
||||
try {
|
||||
if (this.resetPasswordAutoEnroll) {
|
||||
this.formPromise = this.apiService
|
||||
this.formPromise = this.masterPasswordApiService
|
||||
.setPassword(request)
|
||||
.then(async () => {
|
||||
await this.onSetPasswordSuccess(masterKey, userKey, newKeyPair);
|
||||
@@ -222,7 +224,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
|
||||
);
|
||||
});
|
||||
} else {
|
||||
this.formPromise = this.apiService.setPassword(request).then(async () => {
|
||||
this.formPromise = this.masterPasswordApiService.setPassword(request).then(async () => {
|
||||
await this.onSetPasswordSuccess(masterKey, userKey, newKeyPair);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { Directive } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
||||
@@ -40,7 +40,7 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent {
|
||||
policyService: PolicyService,
|
||||
keyService: KeyService,
|
||||
messagingService: MessagingService,
|
||||
private apiService: ApiService,
|
||||
private masterPasswordApiService: MasterPasswordApiService,
|
||||
private userVerificationService: UserVerificationService,
|
||||
private logService: LogService,
|
||||
dialogService: DialogService,
|
||||
@@ -117,9 +117,7 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent {
|
||||
request.key = newUserKey[1].encryptedString;
|
||||
|
||||
// Update user's password
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.apiService.postPassword(request);
|
||||
await this.masterPasswordApiService.postPassword(request);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
|
||||
@@ -4,10 +4,10 @@ import { Directive, OnInit } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
||||
@@ -52,7 +52,7 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp
|
||||
policyService: PolicyService,
|
||||
keyService: KeyService,
|
||||
messagingService: MessagingService,
|
||||
private apiService: ApiService,
|
||||
private masterPasswordApiService: MasterPasswordApiService,
|
||||
private syncService: SyncService,
|
||||
private logService: LogService,
|
||||
private userVerificationService: UserVerificationService,
|
||||
@@ -202,7 +202,7 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp
|
||||
request.newMasterPasswordHash = masterPasswordHash;
|
||||
request.masterPasswordHint = this.hint;
|
||||
|
||||
return this.apiService.putUpdateTempPassword(request);
|
||||
return this.masterPasswordApiService.putUpdateTempPassword(request);
|
||||
}
|
||||
|
||||
private async updatePassword(newMasterPasswordHash: string, userKey: [UserKey, EncString]) {
|
||||
@@ -214,7 +214,7 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp
|
||||
request.newMasterPasswordHash = newMasterPasswordHash;
|
||||
request.key = userKey[1].encryptedString;
|
||||
|
||||
return this.apiService.postPassword(request);
|
||||
return this.masterPasswordApiService.postPassword(request);
|
||||
}
|
||||
|
||||
private async updateTdeOffboardingPassword(
|
||||
@@ -226,6 +226,6 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp
|
||||
request.newMasterPasswordHash = masterPasswordHash;
|
||||
request.masterPasswordHint = this.hint;
|
||||
|
||||
return this.apiService.putUpdateTdeOffboardingPassword(request);
|
||||
return this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { Navigation, Router, UrlTree } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { unauthUiRefreshRedirect } from "./unauth-ui-refresh-redirect";
|
||||
|
||||
describe("unauthUiRefreshRedirect", () => {
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let router: MockProxy<Router>;
|
||||
|
||||
beforeEach(() => {
|
||||
configService = mock<ConfigService>();
|
||||
router = mock<Router>();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
{ provide: Router, useValue: router },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns true when UnauthenticatedExtensionUIRefresh flag is disabled", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
const result = await TestBed.runInInjectionContext(() =>
|
||||
unauthUiRefreshRedirect("/redirect")(),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.UnauthenticatedExtensionUIRefresh,
|
||||
);
|
||||
expect(router.parseUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns UrlTree when UnauthenticatedExtensionUIRefresh flag is enabled and preserves query params", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const urlTree = new UrlTree();
|
||||
urlTree.queryParams = { test: "test" };
|
||||
|
||||
const navigation: Navigation = {
|
||||
extras: {},
|
||||
id: 0,
|
||||
initialUrl: new UrlTree(),
|
||||
extractedUrl: urlTree,
|
||||
trigger: "imperative",
|
||||
previousNavigation: undefined,
|
||||
};
|
||||
|
||||
router.getCurrentNavigation.mockReturnValue(navigation);
|
||||
|
||||
await TestBed.runInInjectionContext(() => unauthUiRefreshRedirect("/redirect")());
|
||||
|
||||
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.UnauthenticatedExtensionUIRefresh,
|
||||
);
|
||||
expect(router.createUrlTree).toHaveBeenCalledWith(["/redirect"], {
|
||||
queryParams: urlTree.queryParams,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { UrlTree, Router } from "@angular/router";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
/**
|
||||
* Helper function to redirect to a new URL based on the UnauthenticatedExtensionUIRefresh feature flag.
|
||||
* @param redirectUrl - The URL to redirect to if the UnauthenticatedExtensionUIRefresh flag is enabled.
|
||||
*/
|
||||
export function unauthUiRefreshRedirect(redirectUrl: string): () => Promise<boolean | UrlTree> {
|
||||
return async () => {
|
||||
const configService = inject(ConfigService);
|
||||
const router = inject(Router);
|
||||
const shouldRedirect = await configService.getFeatureFlag(
|
||||
FeatureFlag.UnauthenticatedExtensionUIRefresh,
|
||||
);
|
||||
if (shouldRedirect) {
|
||||
const currentNavigation = router.getCurrentNavigation();
|
||||
const queryParams = currentNavigation?.extractedUrl?.queryParams || {};
|
||||
|
||||
// Preserve query params when redirecting as it is likely that the refreshed component
|
||||
// will be consuming the same query params.
|
||||
return router.createUrlTree([redirectUrl], { queryParams });
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export abstract class DeviceTrustToastService {
|
||||
/**
|
||||
* An observable pipeline that observes any cross-application toast messages
|
||||
* that need to be shown as part of the trusted device encryption (TDE) process.
|
||||
*/
|
||||
abstract setupListeners$: Observable<void>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { merge, Observable, tap } from "rxjs";
|
||||
|
||||
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "./device-trust-toast.service.abstraction";
|
||||
|
||||
export class DeviceTrustToastService implements DeviceTrustToastServiceAbstraction {
|
||||
private adminLoginApproved$: Observable<void>;
|
||||
private deviceTrusted$: Observable<void>;
|
||||
|
||||
setupListeners$: Observable<void>;
|
||||
|
||||
constructor(
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
private deviceTrustService: DeviceTrustServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
) {
|
||||
this.adminLoginApproved$ = this.authRequestService.adminLoginApproved$.pipe(
|
||||
tap(() => {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("loginApproved"),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.deviceTrusted$ = this.deviceTrustService.deviceTrusted$.pipe(
|
||||
tap(() => {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("deviceTrusted"),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.setupListeners$ = merge(this.adminLoginApproved$, this.deviceTrusted$);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { EMPTY, of } from "rxjs";
|
||||
|
||||
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "./device-trust-toast.service.abstraction";
|
||||
import { DeviceTrustToastService } from "./device-trust-toast.service.implementation";
|
||||
|
||||
describe("DeviceTrustToastService", () => {
|
||||
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
|
||||
let deviceTrustService: MockProxy<DeviceTrustServiceAbstraction>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let toastService: MockProxy<ToastService>;
|
||||
|
||||
let sut: DeviceTrustToastServiceAbstraction;
|
||||
|
||||
beforeEach(() => {
|
||||
authRequestService = mock<AuthRequestServiceAbstraction>();
|
||||
deviceTrustService = mock<DeviceTrustServiceAbstraction>();
|
||||
i18nService = mock<I18nService>();
|
||||
toastService = mock<ToastService>();
|
||||
|
||||
i18nService.t.mockImplementation((key: string) => key); // just return the key that was given
|
||||
});
|
||||
|
||||
const initService = () => {
|
||||
return new DeviceTrustToastService(
|
||||
authRequestService,
|
||||
deviceTrustService,
|
||||
i18nService,
|
||||
toastService,
|
||||
);
|
||||
};
|
||||
|
||||
const loginApprovalToastOptions = {
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: "loginApproved",
|
||||
};
|
||||
|
||||
const deviceTrustedToastOptions = {
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: "deviceTrusted",
|
||||
};
|
||||
|
||||
describe("setupListeners$", () => {
|
||||
describe("given adminLoginApproved$ emits and deviceTrusted$ emits", () => {
|
||||
beforeEach(() => {
|
||||
// Arrange
|
||||
authRequestService.adminLoginApproved$ = of(undefined);
|
||||
deviceTrustService.deviceTrusted$ = of(undefined);
|
||||
sut = initService();
|
||||
});
|
||||
|
||||
it("should trigger a toast for login approval", (done) => {
|
||||
// Act
|
||||
sut.setupListeners$.subscribe({
|
||||
complete: () => {
|
||||
expect(toastService.showToast).toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should trigger a toast for device trust", (done) => {
|
||||
// Act
|
||||
sut.setupListeners$.subscribe({
|
||||
complete: () => {
|
||||
expect(toastService.showToast).toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("given adminLoginApproved$ emits and deviceTrusted$ does not emit", () => {
|
||||
beforeEach(() => {
|
||||
// Arrange
|
||||
authRequestService.adminLoginApproved$ = of(undefined);
|
||||
deviceTrustService.deviceTrusted$ = EMPTY;
|
||||
sut = initService();
|
||||
});
|
||||
|
||||
it("should trigger a toast for login approval", (done) => {
|
||||
// Act
|
||||
sut.setupListeners$.subscribe({
|
||||
complete: () => {
|
||||
expect(toastService.showToast).toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT trigger a toast for device trust", (done) => {
|
||||
// Act
|
||||
sut.setupListeners$.subscribe({
|
||||
complete: () => {
|
||||
expect(toastService.showToast).not.toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("given adminLoginApproved$ does not emit and deviceTrusted$ emits", () => {
|
||||
beforeEach(() => {
|
||||
// Arrange
|
||||
authRequestService.adminLoginApproved$ = EMPTY;
|
||||
deviceTrustService.deviceTrusted$ = of(undefined);
|
||||
sut = initService();
|
||||
});
|
||||
|
||||
it("should NOT trigger a toast for login approval", (done) => {
|
||||
// Act
|
||||
sut.setupListeners$.subscribe({
|
||||
complete: () => {
|
||||
expect(toastService.showToast).not.toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should trigger a toast for device trust", (done) => {
|
||||
// Act
|
||||
sut.setupListeners$.subscribe({
|
||||
complete: () => {
|
||||
expect(toastService.showToast).toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("given adminLoginApproved$ does not emit and deviceTrusted$ does not emit", () => {
|
||||
beforeEach(() => {
|
||||
// Arrange
|
||||
authRequestService.adminLoginApproved$ = EMPTY;
|
||||
deviceTrustService.deviceTrusted$ = EMPTY;
|
||||
sut = initService();
|
||||
});
|
||||
|
||||
it("should NOT trigger a toast for login approval", (done) => {
|
||||
// Act
|
||||
sut.setupListeners$.subscribe({
|
||||
complete: () => {
|
||||
expect(toastService.showToast).not.toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT trigger a toast for device trust", (done) => {
|
||||
// Act
|
||||
sut.setupListeners$.subscribe({
|
||||
complete: () => {
|
||||
expect(toastService.showToast).not.toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,7 @@ import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractio
|
||||
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
|
||||
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
|
||||
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/auth/abstractions/key-connector.service";
|
||||
import { MasterPasswordApiService as MasterPasswordApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import {
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
MasterPasswordServiceAbstraction,
|
||||
@@ -113,6 +114,7 @@ import { DeviceTrustService } from "@bitwarden/common/auth/services/device-trust
|
||||
import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation";
|
||||
import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation";
|
||||
import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/services/master-password/master-password-api.service.implementation";
|
||||
import { MasterPasswordService } from "@bitwarden/common/auth/services/master-password/master-password.service";
|
||||
import { PasswordResetEnrollmentServiceImplementation } from "@bitwarden/common/auth/services/password-reset-enrollment.service.implementation";
|
||||
import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service";
|
||||
@@ -304,6 +306,8 @@ import {
|
||||
import { SafeInjectionToken } from "@bitwarden/ui-common";
|
||||
import {
|
||||
DefaultTaskService,
|
||||
DefaultEndUserNotificationService,
|
||||
EndUserNotificationService,
|
||||
NewDeviceVerificationNoticeService,
|
||||
PasswordRepromptService,
|
||||
TaskService,
|
||||
@@ -317,6 +321,8 @@ import {
|
||||
IndividualVaultExportServiceAbstraction,
|
||||
} from "@bitwarden/vault-export-core";
|
||||
|
||||
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
|
||||
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
|
||||
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
|
||||
import { ViewCacheService } from "../platform/abstractions/view-cache.service";
|
||||
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
|
||||
@@ -1281,7 +1287,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: BillingApiServiceAbstraction,
|
||||
useClass: BillingApiService,
|
||||
deps: [ApiServiceAbstraction, LogService, ToastService],
|
||||
deps: [ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TaxServiceAbstraction,
|
||||
@@ -1345,6 +1351,7 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultSetPasswordJitService,
|
||||
deps: [
|
||||
ApiServiceAbstraction,
|
||||
MasterPasswordApiService,
|
||||
KeyService,
|
||||
EncryptService,
|
||||
I18nServiceAbstraction,
|
||||
@@ -1462,6 +1469,26 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultTaskService,
|
||||
deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: EndUserNotificationService,
|
||||
useClass: DefaultEndUserNotificationService,
|
||||
deps: [StateProvider, ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: DeviceTrustToastServiceAbstraction,
|
||||
useClass: DeviceTrustToastService,
|
||||
deps: [
|
||||
AuthRequestServiceAbstraction,
|
||||
DeviceTrustServiceAbstraction,
|
||||
I18nServiceAbstraction,
|
||||
ToastService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: MasterPasswordApiServiceAbstraction,
|
||||
useClass: MasterPasswordApiService,
|
||||
deps: [ApiServiceAbstraction, LogService],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -15,7 +15,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -41,7 +40,7 @@ import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { generate_ssh_key } from "@bitwarden/sdk-internal";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault";
|
||||
|
||||
@Directive()
|
||||
export class AddEditComponent implements OnInit, OnDestroy {
|
||||
@@ -131,7 +130,8 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
protected configService: ConfigService,
|
||||
protected cipherAuthorizationService: CipherAuthorizationService,
|
||||
protected toastService: ToastService,
|
||||
private sdkService: SdkService,
|
||||
protected sdkService: SdkService,
|
||||
private sshImportPromptService: SshImportPromptService,
|
||||
) {
|
||||
this.typeOptions = [
|
||||
{ name: i18nService.t("typeLogin"), value: CipherType.Login },
|
||||
@@ -207,10 +207,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
this.writeableCollections = await this.loadCollections();
|
||||
this.canUseReprompt = await this.passwordRepromptService.enabled();
|
||||
|
||||
const sshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem);
|
||||
if (sshKeysEnabled) {
|
||||
this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey });
|
||||
}
|
||||
this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey });
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
@@ -824,6 +821,15 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
return true;
|
||||
}
|
||||
|
||||
async importSshKeyFromClipboard() {
|
||||
const key = await this.sshImportPromptService.importSshKeyFromClipboard();
|
||||
if (key != null) {
|
||||
this.cipher.sshKey.privateKey = key.privateKey;
|
||||
this.cipher.sshKey.publicKey = key.publicKey;
|
||||
this.cipher.sshKey.keyFingerprint = key.keyFingerprint;
|
||||
}
|
||||
}
|
||||
|
||||
private async generateSshKey(showNotification: boolean = true) {
|
||||
await firstValueFrom(this.sdkService.client$);
|
||||
const sshKey = generate_ssh_key("Ed25519");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
|
||||
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
@@ -10,7 +10,7 @@ import { TopLevelTreeNode } from "../models/top-level-tree-node.model";
|
||||
import { VaultFilter } from "../models/vault-filter.model";
|
||||
|
||||
@Directive()
|
||||
export class CollectionFilterComponent {
|
||||
export class CollectionFilterComponent implements OnInit {
|
||||
@Input() hide = false;
|
||||
@Input() collapsedFilterNodes: Set<string>;
|
||||
@Input() collectionNodes: DynamicTreeNode<CollectionView>;
|
||||
@@ -51,4 +51,13 @@ export class CollectionFilterComponent {
|
||||
async toggleCollapse(node: ITreeNodeObject) {
|
||||
this.onNodeCollapseStateChange.emit(node);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Populate the set with all node IDs so all nodes are collapsed initially.
|
||||
if (this.collectionNodes?.fullList) {
|
||||
this.collectionNodes.fullList.forEach((node) => {
|
||||
this.collapsedFilterNodes.add(node.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"@bitwarden/generator-history": ["../tools/generator/extensions/history/src"],
|
||||
"@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"],
|
||||
"@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"],
|
||||
"@bitwarden/importer/core": ["../importer/src"],
|
||||
"@bitwarden/importer-ui": ["../importer/src/components"],
|
||||
"@bitwarden/key-management": ["../key-management/src"],
|
||||
"@bitwarden/platform": ["../platform/src"],
|
||||
"@bitwarden/ui-common": ["../ui/common/src"],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common";
|
||||
import { Component, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
|
||||
import { firstValueFrom, Subject, take, takeUntil, tap } from "rxjs";
|
||||
import { firstValueFrom, Subject, take, takeUntil } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
@@ -19,11 +19,9 @@ import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstraction
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
@@ -121,7 +119,6 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
private toastService: ToastService,
|
||||
private logService: LogService,
|
||||
private validationService: ValidationService,
|
||||
private configService: ConfigService,
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
) {
|
||||
this.clientType = this.platformUtilsService.getClientType();
|
||||
@@ -131,9 +128,6 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
// Add popstate listener to listen for browser back button clicks
|
||||
window.addEventListener("popstate", this.handlePopState);
|
||||
|
||||
// TODO: remove this when the UnauthenticatedExtensionUIRefresh feature flag is removed.
|
||||
this.listenForUnauthUiRefreshFlagChanges();
|
||||
|
||||
await this.defaultOnInit();
|
||||
|
||||
if (this.clientType === ClientType.Desktop) {
|
||||
@@ -154,30 +148,6 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private listenForUnauthUiRefreshFlagChanges() {
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.UnauthenticatedExtensionUIRefresh)
|
||||
.pipe(
|
||||
tap(async (flag) => {
|
||||
// If the flag is turned OFF, we must force a reload to ensure the correct UI is shown
|
||||
if (!flag) {
|
||||
const qParams = await firstValueFrom(this.activatedRoute.queryParams);
|
||||
const uniqueQueryParams = {
|
||||
...qParams,
|
||||
// adding a unique timestamp to the query params to force a reload
|
||||
t: new Date().getTime().toString(), // Adding a unique timestamp as a query parameter
|
||||
};
|
||||
|
||||
await this.router.navigate(["/"], {
|
||||
queryParams: uniqueQueryParams,
|
||||
});
|
||||
}
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
submit = async (): Promise<void> => {
|
||||
if (this.clientType === ClientType.Desktop) {
|
||||
if (this.loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
@@ -31,6 +32,7 @@ describe("DefaultSetPasswordJitService", () => {
|
||||
let sut: DefaultSetPasswordJitService;
|
||||
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let masterPasswordApiService: MockProxy<MasterPasswordApiService>;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
@@ -42,6 +44,7 @@ describe("DefaultSetPasswordJitService", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = mock<ApiService>();
|
||||
masterPasswordApiService = mock<MasterPasswordApiService>();
|
||||
keyService = mock<KeyService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
i18nService = mock<I18nService>();
|
||||
@@ -53,6 +56,7 @@ describe("DefaultSetPasswordJitService", () => {
|
||||
|
||||
sut = new DefaultSetPasswordJitService(
|
||||
apiService,
|
||||
masterPasswordApiService,
|
||||
keyService,
|
||||
encryptService,
|
||||
i18nService,
|
||||
@@ -148,7 +152,7 @@ describe("DefaultSetPasswordJitService", () => {
|
||||
|
||||
keyService.makeKeyPair.mockResolvedValue(keyPair);
|
||||
|
||||
apiService.setPassword.mockResolvedValue(undefined);
|
||||
masterPasswordApiService.setPassword.mockResolvedValue(undefined);
|
||||
masterPasswordService.setForceSetPasswordReason.mockResolvedValue(undefined);
|
||||
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
@@ -185,7 +189,7 @@ describe("DefaultSetPasswordJitService", () => {
|
||||
await sut.setPassword(credentials);
|
||||
|
||||
// Assert
|
||||
expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
});
|
||||
|
||||
it("should set password successfully (given no user key)", async () => {
|
||||
@@ -196,7 +200,7 @@ describe("DefaultSetPasswordJitService", () => {
|
||||
await sut.setPassword(credentials);
|
||||
|
||||
// Assert
|
||||
expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
});
|
||||
|
||||
it("should handle reset password auto enroll", async () => {
|
||||
@@ -210,7 +214,7 @@ describe("DefaultSetPasswordJitService", () => {
|
||||
await sut.setPassword(credentials);
|
||||
|
||||
// Assert
|
||||
expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(organizationApiService.getKeys).toHaveBeenCalledWith(orgId);
|
||||
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(userKey.key, orgPublicKey);
|
||||
expect(
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
export class DefaultSetPasswordJitService implements SetPasswordJitService {
|
||||
constructor(
|
||||
protected apiService: ApiService,
|
||||
protected masterPasswordApiService: MasterPasswordApiService,
|
||||
protected keyService: KeyService,
|
||||
protected encryptService: EncryptService,
|
||||
protected i18nService: I18nService,
|
||||
@@ -77,7 +79,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
|
||||
kdfConfig.iterations,
|
||||
);
|
||||
|
||||
await this.apiService.setPassword(request);
|
||||
await this.masterPasswordApiService.setPassword(request);
|
||||
|
||||
// Clear force set password reason to allow navigation back to vault.
|
||||
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
|
||||
|
||||
@@ -226,20 +226,6 @@ describe("TwoFactorAuthComponent", () => {
|
||||
});
|
||||
};
|
||||
|
||||
const testForceResetOnSuccessfulLogin = (reasonString: string) => {
|
||||
it(`navigates to the component's defined forcePasswordResetRoute route when response.forcePasswordReset is ${reasonString}`, async () => {
|
||||
// Act
|
||||
await component.submit("testToken");
|
||||
|
||||
// expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["update-temp-password"], {
|
||||
queryParams: {
|
||||
identifier: component.orgSsoIdentifier,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe("Standard 2FA scenarios", () => {
|
||||
describe("submit", () => {
|
||||
const token = "testToken";
|
||||
@@ -311,26 +297,6 @@ describe("TwoFactorAuthComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Force Master Password Reset scenarios", () => {
|
||||
[
|
||||
ForceSetPasswordReason.AdminForcePasswordReset,
|
||||
ForceSetPasswordReason.WeakMasterPassword,
|
||||
].forEach((forceResetPasswordReason) => {
|
||||
const reasonString = ForceSetPasswordReason[forceResetPasswordReason];
|
||||
|
||||
beforeEach(() => {
|
||||
// use standard user with MP because this test is not concerned with password reset.
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
|
||||
|
||||
const authResult = new AuthResult();
|
||||
authResult.forcePasswordReset = forceResetPasswordReason;
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
testForceResetOnSuccessfulLogin(reasonString);
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to the component's defined success route (vault is default) when the login is successful", async () => {
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
|
||||
|
||||
@@ -407,29 +373,7 @@ describe("TwoFactorAuthComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => {
|
||||
[
|
||||
ForceSetPasswordReason.AdminForcePasswordReset,
|
||||
ForceSetPasswordReason.WeakMasterPassword,
|
||||
].forEach((forceResetPasswordReason) => {
|
||||
const reasonString = ForceSetPasswordReason[forceResetPasswordReason];
|
||||
|
||||
beforeEach(() => {
|
||||
// use standard user with MP because this test is not concerned with password reset.
|
||||
selectedUserDecryptionOptions.next(
|
||||
mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice,
|
||||
);
|
||||
|
||||
const authResult = new AuthResult();
|
||||
authResult.forcePasswordReset = forceResetPasswordReason;
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
testForceResetOnSuccessfulLogin(reasonString);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => {
|
||||
describe("Given Trusted Device Encryption is enabled and user doesn't need to set a MP", () => {
|
||||
let authResult;
|
||||
beforeEach(() => {
|
||||
selectedUserDecryptionOptions.next(
|
||||
@@ -437,7 +381,6 @@ describe("TwoFactorAuthComponent", () => {
|
||||
);
|
||||
|
||||
authResult = new AuthResult();
|
||||
authResult.forcePasswordReset = ForceSetPasswordReason.None;
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
|
||||
@@ -396,11 +396,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
// note: this flow affects both TDE & standard users
|
||||
if (this.isForcePasswordResetRequired(authResult)) {
|
||||
return await this.handleForcePasswordReset(this.orgSsoIdentifier);
|
||||
}
|
||||
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
);
|
||||
@@ -415,6 +410,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
const requireSetPassword =
|
||||
!userDecryptionOpts.hasMasterPassword && userDecryptionOpts.keyConnectorOption === undefined;
|
||||
|
||||
// New users without a master password must set a master password before advancing.
|
||||
if (requireSetPassword || authResult.resetMasterPassword) {
|
||||
// Change implies going no password -> password in this case
|
||||
return await this.handleChangePasswordRequired(this.orgSsoIdentifier);
|
||||
@@ -524,14 +520,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
return forceResetReasons.includes(authResult.forcePasswordReset);
|
||||
}
|
||||
|
||||
private async handleForcePasswordReset(orgIdentifier: string | undefined) {
|
||||
await this.router.navigate(["update-temp-password"], {
|
||||
queryParams: {
|
||||
identifier: orgIdentifier,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
showContinueButton() {
|
||||
return (
|
||||
this.selectedProviderType != null &&
|
||||
|
||||
@@ -12,6 +12,12 @@ export abstract class AuthRequestServiceAbstraction {
|
||||
/** Emits an auth request id when an auth request has been approved. */
|
||||
authRequestPushNotification$: Observable<string>;
|
||||
|
||||
/**
|
||||
* Emits when a login has been approved by an admin. This emission is specifically for the
|
||||
* purpose of notifying the consuming component to display a toast informing the user.
|
||||
*/
|
||||
adminLoginApproved$: Observable<void>;
|
||||
|
||||
/**
|
||||
* Returns an admin auth request for the given user if it exists.
|
||||
* @param userId The user id.
|
||||
@@ -106,4 +112,13 @@ export abstract class AuthRequestServiceAbstraction {
|
||||
* @returns The dash-delimited fingerprint phrase.
|
||||
*/
|
||||
abstract getFingerprintPhrase(email: string, publicKey: Uint8Array): Promise<string>;
|
||||
|
||||
/**
|
||||
* Passes a value to the adminLoginApprovedSubject via next(), which causes the
|
||||
* adminLoginApproved$ observable to emit.
|
||||
*
|
||||
* The purpose is to notify consuming components (of adminLoginApproved$) to display
|
||||
* a toast informing the user that a login has been approved by an admin.
|
||||
*/
|
||||
abstract emitAdminLoginApproved(): void;
|
||||
}
|
||||
|
||||
@@ -306,6 +306,31 @@ describe("LoginStrategy", () => {
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it("processes a forcePasswordReset response properly", async () => {
|
||||
const tokenResponse = identityTokenResponseFactory();
|
||||
tokenResponse.forcePasswordReset = true;
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
|
||||
const result = await passwordLoginStrategy.logIn(credentials);
|
||||
|
||||
const expected = new AuthResult();
|
||||
expected.userId = userId;
|
||||
expected.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset;
|
||||
expected.resetMasterPassword = false;
|
||||
expected.twoFactorProviders = {} as Partial<
|
||||
Record<TwoFactorProviderType, Record<string, string>>
|
||||
>;
|
||||
expected.captchaSiteKey = "";
|
||||
expected.twoFactorProviders = null;
|
||||
expect(result).toEqual(expected);
|
||||
|
||||
expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith(
|
||||
ForceSetPasswordReason.AdminForcePasswordReset,
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects login if CAPTCHA is required", async () => {
|
||||
// Sample CAPTCHA response
|
||||
const tokenResponse = new IdentityCaptchaResponse({
|
||||
|
||||
@@ -271,17 +271,24 @@ export abstract class LoginStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
result.resetMasterPassword = response.resetMasterPassword;
|
||||
|
||||
// Convert boolean to enum
|
||||
if (response.forcePasswordReset) {
|
||||
result.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset;
|
||||
}
|
||||
|
||||
// Must come before setting keys, user key needs email to update additional keys
|
||||
// Must come before setting keys, user key needs email to update additional keys.
|
||||
const userId = await this.saveAccountInformation(response);
|
||||
result.userId = userId;
|
||||
|
||||
result.resetMasterPassword = response.resetMasterPassword;
|
||||
|
||||
// Convert boolean to enum and set the state for the master password service to
|
||||
// so we know when we reach the auth guard that we need to guide them properly to admin
|
||||
// password reset.
|
||||
if (response.forcePasswordReset) {
|
||||
result.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset;
|
||||
|
||||
await this.masterPasswordService.setForceSetPasswordReason(
|
||||
ForceSetPasswordReason.AdminForcePasswordReset,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.twoFactorToken != null) {
|
||||
// note: we can read email from access token b/c it was saved in saveAccountInformation
|
||||
const userEmail = await this.tokenService.getEmail();
|
||||
@@ -300,7 +307,9 @@ export abstract class LoginStrategy {
|
||||
|
||||
// The keys comes from different sources depending on the login strategy
|
||||
protected abstract setMasterKey(response: IdentityTokenResponse, userId: UserId): Promise<void>;
|
||||
|
||||
protected abstract setUserKey(response: IdentityTokenResponse, userId: UserId): Promise<void>;
|
||||
|
||||
protected abstract setPrivateKey(response: IdentityTokenResponse, userId: UserId): Promise<void>;
|
||||
|
||||
// Old accounts used master key for encryption. We are forcing migrations but only need to
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Jsonify } from "type-fest";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { SsoTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/sso-token.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
|
||||
@@ -108,14 +107,6 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
const email = ssoAuthResult.email;
|
||||
const ssoEmail2FaSessionToken = ssoAuthResult.ssoEmail2FaSessionToken;
|
||||
|
||||
// Auth guard currently handles redirects for this.
|
||||
if (ssoAuthResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
|
||||
await this.masterPasswordService.setForceSetPasswordReason(
|
||||
ssoAuthResult.forcePasswordReset,
|
||||
ssoAuthResult.userId,
|
||||
);
|
||||
}
|
||||
|
||||
this.cache.next({
|
||||
...this.cache.value,
|
||||
email,
|
||||
@@ -278,7 +269,8 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
// TODO: eventually we post and clean up DB as well once consumed on client
|
||||
await this.authRequestService.clearAdminAuthRequest(userId);
|
||||
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("loginApproved"));
|
||||
// This notification will be picked up by the SsoComponent to handle displaying a toast to the user
|
||||
this.authRequestService.emitAdminLoginApproved();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
|
||||
private authRequestPushNotificationSubject = new Subject<string>();
|
||||
authRequestPushNotification$: Observable<string>;
|
||||
|
||||
// Observable emission is used to trigger a toast in consuming components
|
||||
private adminLoginApprovedSubject = new Subject<void>();
|
||||
adminLoginApproved$: Observable<void>;
|
||||
|
||||
constructor(
|
||||
private appIdService: AppIdService,
|
||||
private accountService: AccountService,
|
||||
@@ -53,6 +57,7 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
|
||||
private stateProvider: StateProvider,
|
||||
) {
|
||||
this.authRequestPushNotification$ = this.authRequestPushNotificationSubject.asObservable();
|
||||
this.adminLoginApproved$ = this.adminLoginApprovedSubject.asObservable();
|
||||
}
|
||||
|
||||
async getAdminAuthRequest(userId: UserId): Promise<AdminAuthRequestStorable | null> {
|
||||
@@ -207,4 +212,8 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
|
||||
async getFingerprintPhrase(email: string, publicKey: Uint8Array): Promise<string> {
|
||||
return (await this.keyService.getFingerprint(email.toLowerCase(), publicKey)).join("-");
|
||||
}
|
||||
|
||||
emitAdminLoginApproved(): void {
|
||||
this.adminLoginApprovedSubject.next();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
ProviderUserUserDetailsResponse,
|
||||
} from "../admin-console/models/response/provider/provider-user.response";
|
||||
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
|
||||
import { AuthRequest } from "../auth/models/request/auth.request";
|
||||
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
|
||||
import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
|
||||
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
|
||||
@@ -49,17 +48,13 @@ import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-
|
||||
import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token/webauthn-login-token.request";
|
||||
import { KeyConnectorUserKeyRequest } from "../auth/models/request/key-connector-user-key.request";
|
||||
import { PasswordHintRequest } from "../auth/models/request/password-hint.request";
|
||||
import { PasswordRequest } from "../auth/models/request/password.request";
|
||||
import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request";
|
||||
import { SecretVerificationRequest } from "../auth/models/request/secret-verification.request";
|
||||
import { SetKeyConnectorKeyRequest } from "../auth/models/request/set-key-connector-key.request";
|
||||
import { SetPasswordRequest } from "../auth/models/request/set-password.request";
|
||||
import { TwoFactorEmailRequest } from "../auth/models/request/two-factor-email.request";
|
||||
import { TwoFactorProviderRequest } from "../auth/models/request/two-factor-provider.request";
|
||||
import { TwoFactorRecoveryRequest } from "../auth/models/request/two-factor-recovery.request";
|
||||
import { UpdateProfileRequest } from "../auth/models/request/update-profile.request";
|
||||
import { UpdateTdeOffboardingPasswordRequest } from "../auth/models/request/update-tde-offboarding-password.request";
|
||||
import { UpdateTempPasswordRequest } from "../auth/models/request/update-temp-password.request";
|
||||
import { UpdateTwoFactorAuthenticatorRequest } from "../auth/models/request/update-two-factor-authenticator.request";
|
||||
import { UpdateTwoFactorDuoRequest } from "../auth/models/request/update-two-factor-duo.request";
|
||||
import { UpdateTwoFactorEmailRequest } from "../auth/models/request/update-two-factor-email.request";
|
||||
@@ -169,8 +164,6 @@ export abstract class ApiService {
|
||||
postPrelogin: (request: PreloginRequest) => Promise<PreloginResponse>;
|
||||
postEmailToken: (request: EmailTokenRequest) => Promise<any>;
|
||||
postEmail: (request: EmailRequest) => Promise<any>;
|
||||
postPassword: (request: PasswordRequest) => Promise<any>;
|
||||
setPassword: (request: SetPasswordRequest) => Promise<any>;
|
||||
postSetKeyConnectorKey: (request: SetKeyConnectorKeyRequest) => Promise<any>;
|
||||
postSecurityStamp: (request: SecretVerificationRequest) => Promise<any>;
|
||||
getAccountRevisionDate: () => Promise<number>;
|
||||
@@ -189,13 +182,8 @@ export abstract class ApiService {
|
||||
postAccountKdf: (request: KdfRequest) => Promise<any>;
|
||||
postUserApiKey: (id: string, request: SecretVerificationRequest) => Promise<ApiKeyResponse>;
|
||||
postUserRotateApiKey: (id: string, request: SecretVerificationRequest) => Promise<ApiKeyResponse>;
|
||||
putUpdateTempPassword: (request: UpdateTempPasswordRequest) => Promise<any>;
|
||||
putUpdateTdeOffboardingPassword: (request: UpdateTdeOffboardingPasswordRequest) => Promise<any>;
|
||||
postConvertToKeyConnector: () => Promise<void>;
|
||||
//passwordless
|
||||
postAuthRequest: (request: AuthRequest) => Promise<AuthRequestResponse>;
|
||||
postAdminAuthRequest: (request: AuthRequest) => Promise<AuthRequestResponse>;
|
||||
getAuthResponse: (id: string, accessCode: string) => Promise<AuthRequestResponse>;
|
||||
getAuthRequest: (id: string) => Promise<AuthRequestResponse>;
|
||||
putAuthRequest: (id: string, request: PasswordlessAuthRequest) => Promise<AuthRequestResponse>;
|
||||
getAuthRequests: () => Promise<ListResponse<AuthRequestResponse>>;
|
||||
|
||||
@@ -16,6 +16,12 @@ export abstract class DeviceTrustServiceAbstraction {
|
||||
*/
|
||||
supportsDeviceTrust$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Emits when a device has been trusted. This emission is specifically for the purpose of notifying
|
||||
* the consuming component to display a toast informing the user the device has been trusted.
|
||||
*/
|
||||
deviceTrusted$: Observable<void>;
|
||||
|
||||
/**
|
||||
* @description Checks if the device trust feature is supported for the given user.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PasswordRequest } from "../models/request/password.request";
|
||||
import { SetPasswordRequest } from "../models/request/set-password.request";
|
||||
import { UpdateTdeOffboardingPasswordRequest } from "../models/request/update-tde-offboarding-password.request";
|
||||
import { UpdateTempPasswordRequest } from "../models/request/update-temp-password.request";
|
||||
|
||||
export abstract class MasterPasswordApiService {
|
||||
/**
|
||||
* POSTs a SetPasswordRequest to "/accounts/set-password"
|
||||
*/
|
||||
abstract setPassword: (request: SetPasswordRequest) => Promise<any>;
|
||||
|
||||
/**
|
||||
* POSTs a PasswordRequest to "/accounts/password"
|
||||
*/
|
||||
abstract postPassword: (request: PasswordRequest) => Promise<any>;
|
||||
|
||||
/**
|
||||
* PUTs an UpdateTempPasswordRequest to "/accounts/update-temp-password"
|
||||
*/
|
||||
abstract putUpdateTempPassword: (request: UpdateTempPasswordRequest) => Promise<any>;
|
||||
|
||||
/**
|
||||
* PUTs an UpdateTdeOffboardingPasswordRequest to "/accounts/update-tde-offboarding-password"
|
||||
*/
|
||||
abstract putUpdateTdeOffboardingPassword: (
|
||||
request: UpdateTdeOffboardingPasswordRequest,
|
||||
) => Promise<any>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom, map, Observable } from "rxjs";
|
||||
import { firstValueFrom, map, Observable, Subject } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
@@ -63,6 +63,10 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
|
||||
supportsDeviceTrust$: Observable<boolean>;
|
||||
|
||||
// Observable emission is used to trigger a toast in consuming components
|
||||
private deviceTrustedSubject = new Subject<void>();
|
||||
deviceTrusted$ = this.deviceTrustedSubject.asObservable();
|
||||
|
||||
constructor(
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
@@ -177,7 +181,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
// store device key in local/secure storage if enc keys posted to server successfully
|
||||
await this.setDeviceKey(userId, deviceKey);
|
||||
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted"));
|
||||
// This emission will be picked up by consuming components to handle displaying a toast to the user
|
||||
this.deviceTrustedSubject.next();
|
||||
|
||||
return deviceResponse;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
import { MasterPasswordApiService as MasterPasswordApiServiceAbstraction } from "../../abstractions/master-password-api.service.abstraction";
|
||||
import { PasswordRequest } from "../../models/request/password.request";
|
||||
import { SetPasswordRequest } from "../../models/request/set-password.request";
|
||||
import { UpdateTdeOffboardingPasswordRequest } from "../../models/request/update-tde-offboarding-password.request";
|
||||
import { UpdateTempPasswordRequest } from "../../models/request/update-temp-password.request";
|
||||
|
||||
export class MasterPasswordApiService implements MasterPasswordApiServiceAbstraction {
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
async setPassword(request: SetPasswordRequest): Promise<any> {
|
||||
try {
|
||||
const response = await this.apiService.send(
|
||||
"POST",
|
||||
"/accounts/set-password",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e: unknown) {
|
||||
this.logService.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async postPassword(request: PasswordRequest): Promise<any> {
|
||||
try {
|
||||
const response = await this.apiService.send(
|
||||
"POST",
|
||||
"/accounts/password",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e: unknown) {
|
||||
this.logService.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async putUpdateTempPassword(request: UpdateTempPasswordRequest): Promise<any> {
|
||||
try {
|
||||
const response = await this.apiService.send(
|
||||
"PUT",
|
||||
"/accounts/update-temp-password",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e: unknown) {
|
||||
this.logService.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async putUpdateTdeOffboardingPassword(
|
||||
request: UpdateTdeOffboardingPasswordRequest,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const response = await this.apiService.send(
|
||||
"PUT",
|
||||
"/accounts/update-tde-offboarding-password",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e: unknown) {
|
||||
this.logService.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { KdfType } from "@bitwarden/key-management";
|
||||
|
||||
import { PasswordRequest } from "../../models/request/password.request";
|
||||
import { SetPasswordRequest } from "../../models/request/set-password.request";
|
||||
import { UpdateTdeOffboardingPasswordRequest } from "../../models/request/update-tde-offboarding-password.request";
|
||||
import { UpdateTempPasswordRequest } from "../../models/request/update-temp-password.request";
|
||||
|
||||
import { MasterPasswordApiService } from "./master-password-api.service.implementation";
|
||||
|
||||
describe("MasterPasswordApiService", () => {
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
let sut: MasterPasswordApiService;
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = mock<ApiService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
sut = new MasterPasswordApiService(apiService, logService);
|
||||
});
|
||||
|
||||
it("should instantiate", () => {
|
||||
expect(sut).not.toBeFalsy();
|
||||
});
|
||||
|
||||
describe("setPassword", () => {
|
||||
it("should call apiService.send with the correct parameters", async () => {
|
||||
// Arrange
|
||||
const request = new SetPasswordRequest(
|
||||
"masterPasswordHash",
|
||||
"key",
|
||||
"masterPasswordHint",
|
||||
"orgIdentifier",
|
||||
{
|
||||
publicKey: "publicKey",
|
||||
encryptedPrivateKey: "encryptedPrivateKey",
|
||||
},
|
||||
KdfType.PBKDF2_SHA256,
|
||||
600_000,
|
||||
);
|
||||
|
||||
// Act
|
||||
await sut.setPassword(request);
|
||||
|
||||
// Assert
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/accounts/set-password",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("postPassword", () => {
|
||||
it("should call apiService.send with the correct parameters", async () => {
|
||||
// Arrange
|
||||
const request = {
|
||||
newMasterPasswordHash: "newMasterPasswordHash",
|
||||
masterPasswordHint: "masterPasswordHint",
|
||||
key: "key",
|
||||
masterPasswordHash: "masterPasswordHash",
|
||||
} as PasswordRequest;
|
||||
|
||||
// Act
|
||||
await sut.postPassword(request);
|
||||
|
||||
// Assert
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/accounts/password",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("putUpdateTempPassword", () => {
|
||||
it("should call apiService.send with the correct parameters", async () => {
|
||||
// Arrange
|
||||
const request = {
|
||||
masterPasswordHint: "masterPasswordHint",
|
||||
newMasterPasswordHash: "newMasterPasswordHash",
|
||||
key: "key",
|
||||
} as UpdateTempPasswordRequest;
|
||||
|
||||
// Act
|
||||
await sut.putUpdateTempPassword(request);
|
||||
|
||||
// Assert
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"PUT",
|
||||
"/accounts/update-temp-password",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("putUpdateTdeOffboardingPassword", () => {
|
||||
it("should call apiService.send with the correct parameters", async () => {
|
||||
// Arrange
|
||||
const request = {
|
||||
masterPasswordHint: "masterPasswordHint",
|
||||
newMasterPasswordHash: "newMasterPasswordHash",
|
||||
key: "key",
|
||||
} as UpdateTdeOffboardingPasswordRequest;
|
||||
|
||||
// Act
|
||||
await sut.putUpdateTdeOffboardingPassword(request);
|
||||
|
||||
// Assert
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"PUT",
|
||||
"/accounts/update-tde-offboarding-password",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
|
||||
import { ErrorResponse } from "../../models/response/error.response";
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { BillingApiServiceAbstraction } from "../abstractions";
|
||||
import { PaymentMethodType } from "../enums";
|
||||
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
|
||||
@@ -23,11 +20,7 @@ import { PlanResponse } from "../models/response/plan.response";
|
||||
import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response";
|
||||
|
||||
export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private logService: LogService,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
cancelOrganizationSubscription(
|
||||
organizationId: string,
|
||||
@@ -89,14 +82,12 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
}
|
||||
|
||||
async getOrganizationPaymentMethod(organizationId: string): Promise<PaymentMethodResponse> {
|
||||
const response = await this.execute(() =>
|
||||
this.apiService.send(
|
||||
"GET",
|
||||
"/organizations/" + organizationId + "/billing/payment-method",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
),
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
"/organizations/" + organizationId + "/billing/payment-method",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new PaymentMethodResponse(response);
|
||||
}
|
||||
@@ -120,34 +111,34 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
async getProviderClientOrganizations(
|
||||
providerId: string,
|
||||
): Promise<ListResponse<ProviderOrganizationOrganizationDetailsResponse>> {
|
||||
const response = await this.execute(() =>
|
||||
this.apiService.send("GET", "/providers/" + providerId + "/organizations", null, true, true),
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
"/providers/" + providerId + "/organizations",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new ListResponse(response, ProviderOrganizationOrganizationDetailsResponse);
|
||||
}
|
||||
|
||||
async getProviderInvoices(providerId: string): Promise<InvoicesResponse> {
|
||||
const response = await this.execute(() =>
|
||||
this.apiService.send(
|
||||
"GET",
|
||||
"/providers/" + providerId + "/billing/invoices",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
),
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
"/providers/" + providerId + "/billing/invoices",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new InvoicesResponse(response);
|
||||
}
|
||||
|
||||
async getProviderSubscription(providerId: string): Promise<ProviderSubscriptionResponse> {
|
||||
const response = await this.execute(() =>
|
||||
this.apiService.send(
|
||||
"GET",
|
||||
"/providers/" + providerId + "/billing/subscription",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
),
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
"/providers/" + providerId + "/billing/subscription",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new ProviderSubscriptionResponse(response);
|
||||
}
|
||||
@@ -227,20 +218,4 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
private async execute(request: () => Promise<any>): Promise<any> {
|
||||
try {
|
||||
return await request();
|
||||
} catch (error) {
|
||||
this.logService.error(error);
|
||||
if (error instanceof ErrorResponse) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: error.getSingleMessage(),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ export enum FeatureFlag {
|
||||
IdpAutoSubmitLogin = "idp-auto-submit-login",
|
||||
InlineMenuFieldQualification = "inline-menu-field-qualification",
|
||||
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
|
||||
InlineMenuTotp = "inline-menu-totp",
|
||||
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
|
||||
NotificationRefresh = "notification-refresh",
|
||||
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
|
||||
@@ -26,19 +25,20 @@ export enum FeatureFlag {
|
||||
ItemShare = "item-share",
|
||||
CriticalApps = "pm-14466-risk-insights-critical-application",
|
||||
EnableRiskInsightsNotifications = "enable-risk-insights-notifications",
|
||||
DesktopSendUIRefresh = "desktop-send-ui-refresh",
|
||||
|
||||
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
|
||||
VaultBulkManagementAction = "vault-bulk-management-action",
|
||||
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
|
||||
SSHKeyVaultItem = "ssh-key-vault-item",
|
||||
SSHAgent = "ssh-agent",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
SecurityTasks = "security-tasks",
|
||||
/* Vault */
|
||||
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
|
||||
NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss",
|
||||
NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss",
|
||||
VaultBulkManagementAction = "vault-bulk-management-action",
|
||||
SecurityTasks = "security-tasks",
|
||||
|
||||
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
|
||||
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
|
||||
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
|
||||
@@ -72,7 +72,6 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
|
||||
[FeatureFlag.InlineMenuFieldQualification]: FALSE,
|
||||
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
|
||||
[FeatureFlag.InlineMenuTotp]: FALSE,
|
||||
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
|
||||
[FeatureFlag.NotificationRefresh]: FALSE,
|
||||
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
|
||||
@@ -81,19 +80,20 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.ItemShare]: FALSE,
|
||||
[FeatureFlag.CriticalApps]: FALSE,
|
||||
[FeatureFlag.EnableRiskInsightsNotifications]: FALSE,
|
||||
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
|
||||
|
||||
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
|
||||
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
||||
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
|
||||
[FeatureFlag.SSHKeyVaultItem]: FALSE,
|
||||
[FeatureFlag.SSHAgent]: FALSE,
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
[FeatureFlag.SecurityTasks]: FALSE,
|
||||
/* Vault */
|
||||
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
|
||||
[FeatureFlag.NewDeviceVerificationTemporaryDismiss]: FALSE,
|
||||
[FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE,
|
||||
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
||||
[FeatureFlag.SecurityTasks]: FALSE,
|
||||
|
||||
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
|
||||
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
|
||||
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
|
||||
|
||||
@@ -36,7 +36,6 @@ export abstract class EncryptService {
|
||||
): Promise<Uint8Array | null>;
|
||||
abstract rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString>;
|
||||
abstract rsaDecrypt(data: EncString, privateKey: Uint8Array): Promise<Uint8Array>;
|
||||
abstract resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey;
|
||||
/**
|
||||
* @deprecated Replaced by BulkEncryptService, remove once the feature is tested and the featureflag PM-4154-multi-worker-encryption-service is removed
|
||||
* @param items The items to decrypt
|
||||
|
||||
@@ -78,8 +78,6 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("No key provided for decryption.");
|
||||
}
|
||||
|
||||
key = this.resolveLegacyKey(key, encString);
|
||||
|
||||
// DO NOT REMOVE OR MOVE. This prevents downgrade to mac-less CBC, which would compromise integrity and confidentiality.
|
||||
if (key.macKey != null && encString?.mac == null) {
|
||||
this.logService.error(
|
||||
@@ -145,8 +143,6 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("Nothing provided for decryption.");
|
||||
}
|
||||
|
||||
key = this.resolveLegacyKey(key, encThing);
|
||||
|
||||
// DO NOT REMOVE OR MOVE. This prevents downgrade to mac-less CBC, which would compromise integrity and confidentiality.
|
||||
if (key.macKey != null && encThing.macBytes == null) {
|
||||
this.logService.error(
|
||||
@@ -298,19 +294,4 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
this.logService.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform into new key for the old encrypt-then-mac scheme if required, otherwise return the current key unchanged
|
||||
* @param encThing The encrypted object (e.g. encString or encArrayBuffer) that you want to decrypt
|
||||
*/
|
||||
resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey {
|
||||
if (
|
||||
encThing.encryptionType === EncryptionType.AesCbc128_HmacSha256_B64 &&
|
||||
key.encType === EncryptionType.AesCbc256_B64
|
||||
) {
|
||||
return new SymmetricCryptoKey(key.key, EncryptionType.AesCbc128_HmacSha256_B64);
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +325,25 @@ describe("EncryptService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptToUtf8", () => {
|
||||
it("throws if no key is provided", () => {
|
||||
return expect(encryptService.decryptToUtf8(null, null)).rejects.toThrow(
|
||||
"No key provided for decryption.",
|
||||
);
|
||||
});
|
||||
it("returns null if key is mac key but encstring has no mac", async () => {
|
||||
const key = new SymmetricCryptoKey(
|
||||
makeStaticByteArray(64, 0),
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
);
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
|
||||
|
||||
const actual = await encryptService.decryptToUtf8(encString, key);
|
||||
expect(actual).toBeNull();
|
||||
expect(logService.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rsa", () => {
|
||||
const data = makeStaticByteArray(10, 100);
|
||||
const encryptedData = makeStaticByteArray(10, 150);
|
||||
@@ -370,17 +389,16 @@ describe("EncryptService", () => {
|
||||
return expect(encryptService.rsaDecrypt(encString, null)).rejects.toThrow("No private key");
|
||||
});
|
||||
|
||||
it.each([
|
||||
EncryptionType.AesCbc256_B64,
|
||||
EncryptionType.AesCbc128_HmacSha256_B64,
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
])("throws if encryption type is %s", async (encType) => {
|
||||
encString.encryptionType = encType;
|
||||
it.each([EncryptionType.AesCbc256_B64, EncryptionType.AesCbc256_HmacSha256_B64])(
|
||||
"throws if encryption type is %s",
|
||||
async (encType) => {
|
||||
encString.encryptionType = encType;
|
||||
|
||||
await expect(encryptService.rsaDecrypt(encString, privateKey)).rejects.toThrow(
|
||||
"Invalid encryption type",
|
||||
);
|
||||
});
|
||||
await expect(encryptService.rsaDecrypt(encString, privateKey)).rejects.toThrow(
|
||||
"Invalid encryption type",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("decrypts data with provided key", async () => {
|
||||
cryptoFunctionService.rsaDecrypt.mockResolvedValue(data);
|
||||
@@ -398,30 +416,6 @@ describe("EncryptService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLegacyKey", () => {
|
||||
it("creates a legacy key if required", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32), EncryptionType.AesCbc256_B64);
|
||||
const encString = mock<EncString>();
|
||||
encString.encryptionType = EncryptionType.AesCbc128_HmacSha256_B64;
|
||||
|
||||
const actual = encryptService.resolveLegacyKey(key, encString);
|
||||
|
||||
const expected = new SymmetricCryptoKey(key.key, EncryptionType.AesCbc128_HmacSha256_B64);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("does not create a legacy key if not required", async () => {
|
||||
const encType = EncryptionType.AesCbc256_HmacSha256_B64;
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64), encType);
|
||||
const encString = mock<EncString>();
|
||||
encString.encryptionType = encType;
|
||||
|
||||
const actual = encryptService.resolveLegacyKey(key, encString);
|
||||
|
||||
expect(actual).toEqual(key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hash", () => {
|
||||
it("hashes a string and returns b64", async () => {
|
||||
cryptoFunctionService.hash.mockResolvedValue(Uint8Array.from([1, 2, 3]));
|
||||
|
||||
@@ -73,6 +73,7 @@ export class CipherExport {
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
view.sshKey = SshKeyExport.toView(req.sshKey);
|
||||
break;
|
||||
}
|
||||
|
||||
if (req.passwordHistory != null) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { import_ssh_key } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { SshKey as SshKeyDomain } from "../../vault/models/domain/ssh-key";
|
||||
@@ -17,16 +18,18 @@ export class SshKeyExport {
|
||||
}
|
||||
|
||||
static toView(req: SshKeyExport, view = new SshKeyView()) {
|
||||
view.privateKey = req.privateKey;
|
||||
view.publicKey = req.publicKey;
|
||||
view.keyFingerprint = req.keyFingerprint;
|
||||
const parsedKey = import_ssh_key(req.privateKey);
|
||||
view.privateKey = parsedKey.privateKey;
|
||||
view.publicKey = parsedKey.publicKey;
|
||||
view.keyFingerprint = parsedKey.fingerprint;
|
||||
return view;
|
||||
}
|
||||
|
||||
static toDomain(req: SshKeyExport, domain = new SshKeyDomain()) {
|
||||
domain.privateKey = req.privateKey != null ? new EncString(req.privateKey) : null;
|
||||
domain.publicKey = req.publicKey != null ? new EncString(req.publicKey) : null;
|
||||
domain.keyFingerprint = req.keyFingerprint != null ? new EncString(req.keyFingerprint) : null;
|
||||
const parsedKey = import_ssh_key(req.privateKey);
|
||||
domain.privateKey = new EncString(parsedKey.privateKey);
|
||||
domain.publicKey = new EncString(parsedKey.publicKey);
|
||||
domain.keyFingerprint = new EncString(parsedKey.fingerprint);
|
||||
return domain;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export enum EncryptionType {
|
||||
AesCbc256_B64 = 0,
|
||||
AesCbc128_HmacSha256_B64 = 1,
|
||||
// Type 1 was the unused and removed AesCbc128_HmacSha256_B64
|
||||
AesCbc256_HmacSha256_B64 = 2,
|
||||
Rsa2048_OaepSha256_B64 = 3,
|
||||
Rsa2048_OaepSha1_B64 = 4,
|
||||
@@ -17,12 +17,10 @@ export function encryptionTypeToString(encryptionType: EncryptionType): string {
|
||||
}
|
||||
|
||||
/** The expected number of parts to a serialized EncString of the given encryption type.
|
||||
* For example, an EncString of type AesCbc256_B64 will have 2 parts, and an EncString of type
|
||||
* AesCbc128_HmacSha256_B64 will have 3 parts.
|
||||
* For example, an EncString of type AesCbc256_B64 will have 2 parts
|
||||
*
|
||||
* Example of annotated serialized EncStrings:
|
||||
* 0.iv|data
|
||||
* 1.iv|data|mac
|
||||
* 2.iv|data|mac
|
||||
* 3.data
|
||||
* 4.data
|
||||
@@ -33,7 +31,6 @@ export function encryptionTypeToString(encryptionType: EncryptionType): string {
|
||||
*/
|
||||
export const EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE = {
|
||||
[EncryptionType.AesCbc256_B64]: 2,
|
||||
[EncryptionType.AesCbc128_HmacSha256_B64]: 3,
|
||||
[EncryptionType.AesCbc256_HmacSha256_B64]: 3,
|
||||
[EncryptionType.Rsa2048_OaepSha256_B64]: 1,
|
||||
[EncryptionType.Rsa2048_OaepSha1_B64]: 1,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ConditionalExcept, ConditionalKeys, Constructor } from "type-fest";
|
||||
|
||||
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
|
||||
@@ -15,6 +13,19 @@ export type DecryptedObject<
|
||||
TDecryptedKeys extends EncStringKeys<TEncryptedObject>,
|
||||
> = Record<TDecryptedKeys, string> & Omit<TEncryptedObject, TDecryptedKeys>;
|
||||
|
||||
// extracts shared keys from the domain and view types
|
||||
type EncryptableKeys<D extends Domain, V extends View> = (keyof D &
|
||||
ConditionalKeys<D, EncString | null>) &
|
||||
(keyof V & ConditionalKeys<V, string | null>);
|
||||
|
||||
type DomainEncryptableKeys<D extends Domain> = {
|
||||
[key in ConditionalKeys<D, EncString | null>]: EncString | null;
|
||||
};
|
||||
|
||||
type ViewEncryptableKeys<V extends View> = {
|
||||
[key in ConditionalKeys<V, string | null>]: string | null;
|
||||
};
|
||||
|
||||
// https://contributing.bitwarden.com/architecture/clients/data-model#domain
|
||||
export default class Domain {
|
||||
protected buildDomainModel<D extends Domain>(
|
||||
@@ -37,6 +48,7 @@ export default class Domain {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected buildDataModel<D extends Domain>(
|
||||
domain: D,
|
||||
dataObj: any,
|
||||
@@ -58,31 +70,24 @@ export default class Domain {
|
||||
}
|
||||
}
|
||||
|
||||
protected async decryptObj<T extends View>(
|
||||
viewModel: T,
|
||||
map: any,
|
||||
orgId: string,
|
||||
key: SymmetricCryptoKey = null,
|
||||
protected async decryptObj<D extends Domain, V extends View>(
|
||||
domain: DomainEncryptableKeys<D>,
|
||||
viewModel: ViewEncryptableKeys<V>,
|
||||
props: EncryptableKeys<D, V>[],
|
||||
orgId: string | null,
|
||||
key: SymmetricCryptoKey | null = null,
|
||||
objectContext: string = "No Domain Context",
|
||||
): Promise<T> {
|
||||
const self: any = this;
|
||||
|
||||
for (const prop in map) {
|
||||
// eslint-disable-next-line
|
||||
if (!map.hasOwnProperty(prop)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapProp = map[prop] || prop;
|
||||
if (self[mapProp]) {
|
||||
(viewModel as any)[prop] = await self[mapProp].decrypt(
|
||||
): Promise<V> {
|
||||
for (const prop of props) {
|
||||
viewModel[prop] =
|
||||
(await domain[prop]?.decrypt(
|
||||
orgId,
|
||||
key,
|
||||
`Property: ${prop}; ObjectContext: ${objectContext}`,
|
||||
);
|
||||
}
|
||||
`Property: ${prop as string}; ObjectContext: ${objectContext}`,
|
||||
)) ?? null;
|
||||
}
|
||||
return viewModel;
|
||||
|
||||
return viewModel as V;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +116,7 @@ export default class Domain {
|
||||
const decryptedObjects = [];
|
||||
|
||||
for (const prop of encryptedProperties) {
|
||||
const value = (this as any)[prop] as EncString;
|
||||
const value = this[prop] as EncString;
|
||||
const decrypted = await this.decryptProperty(
|
||||
prop,
|
||||
value,
|
||||
@@ -138,11 +143,9 @@ export default class Domain {
|
||||
encryptService: EncryptService,
|
||||
decryptTrace: string,
|
||||
) {
|
||||
let decrypted: string = null;
|
||||
let decrypted: string | null = null;
|
||||
if (value) {
|
||||
decrypted = await value.decryptWithKey(key, encryptService, decryptTrace);
|
||||
} else {
|
||||
decrypted = null;
|
||||
}
|
||||
return {
|
||||
[propertyKey]: decrypted,
|
||||
|
||||
@@ -5,28 +5,28 @@ import { EncArrayBuffer } from "./enc-array-buffer";
|
||||
|
||||
describe("encArrayBuffer", () => {
|
||||
describe("parses the buffer", () => {
|
||||
test.each([
|
||||
[EncryptionType.AesCbc128_HmacSha256_B64, "AesCbc128_HmacSha256_B64"],
|
||||
[EncryptionType.AesCbc256_HmacSha256_B64, "AesCbc256_HmacSha256_B64"],
|
||||
])("with %c%s", (encType: EncryptionType) => {
|
||||
const iv = makeStaticByteArray(16, 10);
|
||||
const mac = makeStaticByteArray(32, 20);
|
||||
// We use the minimum data length of 1 to test the boundary of valid lengths
|
||||
const data = makeStaticByteArray(1, 100);
|
||||
test.each([[EncryptionType.AesCbc256_HmacSha256_B64, "AesCbc256_HmacSha256_B64"]])(
|
||||
"with %c%s",
|
||||
(encType: EncryptionType) => {
|
||||
const iv = makeStaticByteArray(16, 10);
|
||||
const mac = makeStaticByteArray(32, 20);
|
||||
// We use the minimum data length of 1 to test the boundary of valid lengths
|
||||
const data = makeStaticByteArray(1, 100);
|
||||
|
||||
const array = new Uint8Array(1 + iv.byteLength + mac.byteLength + data.byteLength);
|
||||
array.set([encType]);
|
||||
array.set(iv, 1);
|
||||
array.set(mac, 1 + iv.byteLength);
|
||||
array.set(data, 1 + iv.byteLength + mac.byteLength);
|
||||
const array = new Uint8Array(1 + iv.byteLength + mac.byteLength + data.byteLength);
|
||||
array.set([encType]);
|
||||
array.set(iv, 1);
|
||||
array.set(mac, 1 + iv.byteLength);
|
||||
array.set(data, 1 + iv.byteLength + mac.byteLength);
|
||||
|
||||
const actual = new EncArrayBuffer(array);
|
||||
const actual = new EncArrayBuffer(array);
|
||||
|
||||
expect(actual.encryptionType).toEqual(encType);
|
||||
expect(actual.ivBytes).toEqualBuffer(iv);
|
||||
expect(actual.macBytes).toEqualBuffer(mac);
|
||||
expect(actual.dataBytes).toEqualBuffer(data);
|
||||
});
|
||||
expect(actual.encryptionType).toEqual(encType);
|
||||
expect(actual.ivBytes).toEqualBuffer(iv);
|
||||
expect(actual.macBytes).toEqualBuffer(mac);
|
||||
expect(actual.dataBytes).toEqualBuffer(data);
|
||||
},
|
||||
);
|
||||
|
||||
it("with AesCbc256_B64", () => {
|
||||
const encType = EncryptionType.AesCbc256_B64;
|
||||
@@ -50,7 +50,6 @@ describe("encArrayBuffer", () => {
|
||||
|
||||
describe("throws if the buffer has an invalid length", () => {
|
||||
test.each([
|
||||
[EncryptionType.AesCbc128_HmacSha256_B64, 50, "AesCbc128_HmacSha256_B64"],
|
||||
[EncryptionType.AesCbc256_HmacSha256_B64, 50, "AesCbc256_HmacSha256_B64"],
|
||||
[EncryptionType.AesCbc256_B64, 18, "AesCbc256_B64"],
|
||||
])("with %c%c%s", (encType: EncryptionType, minLength: number) => {
|
||||
|
||||
@@ -20,7 +20,6 @@ export class EncArrayBuffer implements Encrypted {
|
||||
const encType = encBytes[0];
|
||||
|
||||
switch (encType) {
|
||||
case EncryptionType.AesCbc128_HmacSha256_B64:
|
||||
case EncryptionType.AesCbc256_HmacSha256_B64: {
|
||||
const minimumLength = ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH + MIN_DATA_LENGTH;
|
||||
if (encBytes.length < minimumLength) {
|
||||
|
||||
@@ -60,9 +60,7 @@ describe("EncString", () => {
|
||||
|
||||
const cases = [
|
||||
"aXY=|Y3Q=", // AesCbc256_B64 w/out header
|
||||
"aXY=|Y3Q=|cnNhQ3Q=", // AesCbc128_HmacSha256_B64 w/out header
|
||||
"0.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc256_B64 with header
|
||||
"1.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc128_HmacSha256_B64
|
||||
"2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc256_HmacSha256_B64
|
||||
"3.QmFzZTY0UGFydA==", // Rsa2048_OaepSha256_B64
|
||||
"4.QmFzZTY0UGFydA==", // Rsa2048_OaepSha1_B64
|
||||
|
||||
@@ -89,7 +89,6 @@ export class EncString implements Encrypted {
|
||||
}
|
||||
|
||||
switch (encType) {
|
||||
case EncryptionType.AesCbc128_HmacSha256_B64:
|
||||
case EncryptionType.AesCbc256_HmacSha256_B64:
|
||||
this.iv = encPieces[0];
|
||||
this.data = encPieces[1];
|
||||
@@ -132,10 +131,7 @@ export class EncString implements Encrypted {
|
||||
}
|
||||
} else {
|
||||
encPieces = encryptedString.split("|");
|
||||
encType =
|
||||
encPieces.length === 3
|
||||
? EncryptionType.AesCbc128_HmacSha256_B64
|
||||
: EncryptionType.AesCbc256_B64;
|
||||
encType = EncryptionType.AesCbc256_B64;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -160,7 +156,7 @@ export class EncString implements Encrypted {
|
||||
|
||||
async decrypt(
|
||||
orgId: string | null,
|
||||
key: SymmetricCryptoKey = null,
|
||||
key: SymmetricCryptoKey | null = null,
|
||||
context?: string,
|
||||
): Promise<string> {
|
||||
if (this.decryptedValue != null) {
|
||||
|
||||
@@ -27,21 +27,6 @@ describe("SymmetricCryptoKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("AesCbc128_HmacSha256_B64", () => {
|
||||
const key = makeStaticByteArray(32);
|
||||
const cryptoKey = new SymmetricCryptoKey(key, EncryptionType.AesCbc128_HmacSha256_B64);
|
||||
|
||||
expect(cryptoKey).toEqual({
|
||||
encKey: key.slice(0, 16),
|
||||
encKeyB64: "AAECAwQFBgcICQoLDA0ODw==",
|
||||
encType: 1,
|
||||
key: key,
|
||||
keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
macKey: key.slice(16, 32),
|
||||
macKeyB64: "EBESExQVFhcYGRobHB0eHw==",
|
||||
});
|
||||
});
|
||||
|
||||
it("AesCbc256_HmacSha256_B64", () => {
|
||||
const key = makeStaticByteArray(64);
|
||||
const cryptoKey = new SymmetricCryptoKey(key);
|
||||
|
||||
@@ -38,9 +38,6 @@ export class SymmetricCryptoKey {
|
||||
if (encType === EncryptionType.AesCbc256_B64 && key.byteLength === 32) {
|
||||
this.encKey = key;
|
||||
this.macKey = null;
|
||||
} else if (encType === EncryptionType.AesCbc128_HmacSha256_B64 && key.byteLength === 32) {
|
||||
this.encKey = key.slice(0, 16);
|
||||
this.macKey = key.slice(16, 32);
|
||||
} else if (encType === EncryptionType.AesCbc256_HmacSha256_B64 && key.byteLength === 64) {
|
||||
this.encKey = key.slice(0, 32);
|
||||
this.macKey = key.slice(32, 64);
|
||||
|
||||
@@ -201,3 +201,4 @@ export const NEW_DEVICE_VERIFICATION_NOTICE = new StateDefinition(
|
||||
export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
|
||||
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
|
||||
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
|
||||
export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk");
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
} from "../admin-console/models/response/provider/provider-user.response";
|
||||
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
|
||||
import { TokenService } from "../auth/abstractions/token.service";
|
||||
import { AuthRequest } from "../auth/models/request/auth.request";
|
||||
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
|
||||
import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
|
||||
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
|
||||
@@ -56,17 +55,13 @@ import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-
|
||||
import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token/webauthn-login-token.request";
|
||||
import { KeyConnectorUserKeyRequest } from "../auth/models/request/key-connector-user-key.request";
|
||||
import { PasswordHintRequest } from "../auth/models/request/password-hint.request";
|
||||
import { PasswordRequest } from "../auth/models/request/password.request";
|
||||
import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request";
|
||||
import { SecretVerificationRequest } from "../auth/models/request/secret-verification.request";
|
||||
import { SetKeyConnectorKeyRequest } from "../auth/models/request/set-key-connector-key.request";
|
||||
import { SetPasswordRequest } from "../auth/models/request/set-password.request";
|
||||
import { TwoFactorEmailRequest } from "../auth/models/request/two-factor-email.request";
|
||||
import { TwoFactorProviderRequest } from "../auth/models/request/two-factor-provider.request";
|
||||
import { TwoFactorRecoveryRequest } from "../auth/models/request/two-factor-recovery.request";
|
||||
import { UpdateProfileRequest } from "../auth/models/request/update-profile.request";
|
||||
import { UpdateTdeOffboardingPasswordRequest } from "../auth/models/request/update-tde-offboarding-password.request";
|
||||
import { UpdateTempPasswordRequest } from "../auth/models/request/update-temp-password.request";
|
||||
import { UpdateTwoFactorAuthenticatorRequest } from "../auth/models/request/update-two-factor-authenticator.request";
|
||||
import { UpdateTwoFactorDuoRequest } from "../auth/models/request/update-two-factor-duo.request";
|
||||
import { UpdateTwoFactorEmailRequest } from "../auth/models/request/update-two-factor-email.request";
|
||||
@@ -279,22 +274,6 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}
|
||||
|
||||
// TODO: PM-3519: Create and move to AuthRequest Api service
|
||||
// TODO: PM-9724: Remove legacy auth request methods when we remove legacy LoginViaAuthRequestV1Components
|
||||
async postAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
|
||||
const r = await this.send("POST", "/auth-requests/", request, false, true);
|
||||
return new AuthRequestResponse(r);
|
||||
}
|
||||
async postAdminAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
|
||||
const r = await this.send("POST", "/auth-requests/admin-request", request, true, true);
|
||||
return new AuthRequestResponse(r);
|
||||
}
|
||||
|
||||
async getAuthResponse(id: string, accessCode: string): Promise<AuthRequestResponse> {
|
||||
const path = `/auth-requests/${id}/response?code=${accessCode}`;
|
||||
const r = await this.send("GET", path, null, false, true);
|
||||
return new AuthRequestResponse(r);
|
||||
}
|
||||
|
||||
async getAuthRequest(id: string): Promise<AuthRequestResponse> {
|
||||
const path = `/auth-requests/${id}`;
|
||||
const r = await this.send("GET", path, null, true, true);
|
||||
@@ -374,14 +353,6 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return this.send("POST", "/accounts/email", request, true, false);
|
||||
}
|
||||
|
||||
postPassword(request: PasswordRequest): Promise<any> {
|
||||
return this.send("POST", "/accounts/password", request, true, false);
|
||||
}
|
||||
|
||||
setPassword(request: SetPasswordRequest): Promise<any> {
|
||||
return this.send("POST", "/accounts/set-password", request, true, false);
|
||||
}
|
||||
|
||||
postSetKeyConnectorKey(request: SetKeyConnectorKeyRequest): Promise<any> {
|
||||
return this.send("POST", "/accounts/set-key-connector-key", request, true, false);
|
||||
}
|
||||
@@ -479,14 +450,6 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return new ApiKeyResponse(r);
|
||||
}
|
||||
|
||||
putUpdateTempPassword(request: UpdateTempPasswordRequest): Promise<any> {
|
||||
return this.send("PUT", "/accounts/update-temp-password", request, true, false);
|
||||
}
|
||||
|
||||
putUpdateTdeOffboardingPassword(request: UpdateTdeOffboardingPasswordRequest): Promise<void> {
|
||||
return this.send("PUT", "/accounts/update-tde-offboarding-password", request, true, false);
|
||||
}
|
||||
|
||||
postConvertToKeyConnector(): Promise<void> {
|
||||
return this.send("POST", "/accounts/convert-to-key-connector", null, true, false);
|
||||
}
|
||||
|
||||
@@ -54,14 +54,7 @@ export class SendAccess extends Domain {
|
||||
async decrypt(key: SymmetricCryptoKey): Promise<SendAccessView> {
|
||||
const model = new SendAccessView(this);
|
||||
|
||||
await this.decryptObj(
|
||||
model,
|
||||
{
|
||||
name: null,
|
||||
},
|
||||
null,
|
||||
key,
|
||||
);
|
||||
await this.decryptObj<SendAccess, SendAccessView>(this, model, ["name"], null, key);
|
||||
|
||||
switch (this.type) {
|
||||
case SendType.File:
|
||||
|
||||
@@ -34,15 +34,13 @@ export class SendFile extends Domain {
|
||||
}
|
||||
|
||||
async decrypt(key: SymmetricCryptoKey): Promise<SendFileView> {
|
||||
const view = await this.decryptObj(
|
||||
return await this.decryptObj<SendFile, SendFileView>(
|
||||
this,
|
||||
new SendFileView(this),
|
||||
{
|
||||
fileName: null,
|
||||
},
|
||||
["fileName"],
|
||||
null,
|
||||
key,
|
||||
);
|
||||
return view;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<SendFile>) {
|
||||
|
||||
@@ -30,11 +30,10 @@ export class SendText extends Domain {
|
||||
}
|
||||
|
||||
decrypt(key: SymmetricCryptoKey): Promise<SendTextView> {
|
||||
return this.decryptObj(
|
||||
return this.decryptObj<SendText, SendTextView>(
|
||||
this,
|
||||
new SendTextView(this),
|
||||
{
|
||||
text: null,
|
||||
},
|
||||
["text"],
|
||||
null,
|
||||
key,
|
||||
);
|
||||
|
||||
@@ -87,15 +87,7 @@ export class Send extends Domain {
|
||||
// TODO: error?
|
||||
}
|
||||
|
||||
await this.decryptObj(
|
||||
model,
|
||||
{
|
||||
name: null,
|
||||
notes: null,
|
||||
},
|
||||
null,
|
||||
model.cryptoKey,
|
||||
);
|
||||
await this.decryptObj<Send, SendView>(this, model, ["name", "notes"], null, model.cryptoKey);
|
||||
|
||||
switch (this.type) {
|
||||
case SendType.File:
|
||||
|
||||
@@ -11,3 +11,4 @@ export type CipherId = Opaque<string, "CipherId">;
|
||||
export type SendId = Opaque<string, "SendId">;
|
||||
export type IndexedEntityId = Opaque<string, "IndexedEntityId">;
|
||||
export type SecurityTaskId = Opaque<string, "SecurityTaskId">;
|
||||
export type NotificationId = Opaque<string, "NotificationId">;
|
||||
|
||||
@@ -43,11 +43,10 @@ export class Attachment extends Domain {
|
||||
context = "No Cipher Context",
|
||||
encKey?: SymmetricCryptoKey,
|
||||
): Promise<AttachmentView> {
|
||||
const view = await this.decryptObj(
|
||||
const view = await this.decryptObj<Attachment, AttachmentView>(
|
||||
this,
|
||||
new AttachmentView(this),
|
||||
{
|
||||
fileName: null,
|
||||
},
|
||||
["fileName"],
|
||||
orgId,
|
||||
encKey,
|
||||
"DomainType: Attachment; " + context,
|
||||
|
||||
@@ -42,16 +42,10 @@ export class Card extends Domain {
|
||||
context = "No Cipher Context",
|
||||
encKey?: SymmetricCryptoKey,
|
||||
): Promise<CardView> {
|
||||
return this.decryptObj(
|
||||
return this.decryptObj<Card, CardView>(
|
||||
this,
|
||||
new CardView(),
|
||||
{
|
||||
cardholderName: null,
|
||||
brand: null,
|
||||
number: null,
|
||||
expMonth: null,
|
||||
expYear: null,
|
||||
code: null,
|
||||
},
|
||||
["cardholderName", "brand", "number", "expMonth", "expYear", "code"],
|
||||
orgId,
|
||||
encKey,
|
||||
"DomainType: Card; " + context,
|
||||
|
||||
@@ -154,12 +154,10 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
bypassValidation = false;
|
||||
}
|
||||
|
||||
await this.decryptObj(
|
||||
await this.decryptObj<Cipher, CipherView>(
|
||||
this,
|
||||
model,
|
||||
{
|
||||
name: null,
|
||||
notes: null,
|
||||
},
|
||||
["name", "notes"],
|
||||
this.organizationId,
|
||||
encKey,
|
||||
);
|
||||
|
||||
@@ -52,41 +52,38 @@ export class Fido2Credential extends Domain {
|
||||
}
|
||||
|
||||
async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<Fido2CredentialView> {
|
||||
const view = await this.decryptObj(
|
||||
const view = await this.decryptObj<Fido2Credential, Fido2CredentialView>(
|
||||
this,
|
||||
new Fido2CredentialView(),
|
||||
{
|
||||
credentialId: null,
|
||||
keyType: null,
|
||||
keyAlgorithm: null,
|
||||
keyCurve: null,
|
||||
keyValue: null,
|
||||
rpId: null,
|
||||
userHandle: null,
|
||||
userName: null,
|
||||
rpName: null,
|
||||
userDisplayName: null,
|
||||
discoverable: null,
|
||||
},
|
||||
[
|
||||
"credentialId",
|
||||
"keyType",
|
||||
"keyAlgorithm",
|
||||
"keyCurve",
|
||||
"keyValue",
|
||||
"rpId",
|
||||
"userHandle",
|
||||
"userName",
|
||||
"rpName",
|
||||
"userDisplayName",
|
||||
],
|
||||
orgId,
|
||||
encKey,
|
||||
);
|
||||
|
||||
const { counter } = await this.decryptObj(
|
||||
{ counter: "" },
|
||||
const { counter } = await this.decryptObj<
|
||||
Fido2Credential,
|
||||
{
|
||||
counter: null,
|
||||
},
|
||||
orgId,
|
||||
encKey,
|
||||
);
|
||||
counter: string;
|
||||
}
|
||||
>(this, { counter: "" }, ["counter"], orgId, encKey);
|
||||
// Counter will end up as NaN if this fails
|
||||
view.counter = parseInt(counter);
|
||||
|
||||
const { discoverable } = await this.decryptObj(
|
||||
const { discoverable } = await this.decryptObj<Fido2Credential, { discoverable: string }>(
|
||||
this,
|
||||
{ discoverable: "" },
|
||||
{
|
||||
discoverable: null,
|
||||
},
|
||||
["discoverable"],
|
||||
orgId,
|
||||
encKey,
|
||||
);
|
||||
|
||||
@@ -35,12 +35,10 @@ export class Field extends Domain {
|
||||
}
|
||||
|
||||
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<FieldView> {
|
||||
return this.decryptObj(
|
||||
return this.decryptObj<Field, FieldView>(
|
||||
this,
|
||||
new FieldView(this),
|
||||
{
|
||||
name: null,
|
||||
value: null,
|
||||
},
|
||||
["name", "value"],
|
||||
orgId,
|
||||
encKey,
|
||||
);
|
||||
|
||||
@@ -40,13 +40,7 @@ export class Folder extends Domain {
|
||||
}
|
||||
|
||||
decrypt(): Promise<FolderView> {
|
||||
return this.decryptObj(
|
||||
new FolderView(this),
|
||||
{
|
||||
name: null,
|
||||
},
|
||||
null,
|
||||
);
|
||||
return this.decryptObj<Folder, FolderView>(this, new FolderView(this), ["name"], null);
|
||||
}
|
||||
|
||||
async decryptWithKey(
|
||||
|
||||
@@ -66,28 +66,29 @@ export class Identity extends Domain {
|
||||
context: string = "No Cipher Context",
|
||||
encKey?: SymmetricCryptoKey,
|
||||
): Promise<IdentityView> {
|
||||
return this.decryptObj(
|
||||
return this.decryptObj<Identity, IdentityView>(
|
||||
this,
|
||||
new IdentityView(),
|
||||
{
|
||||
title: null,
|
||||
firstName: null,
|
||||
middleName: null,
|
||||
lastName: null,
|
||||
address1: null,
|
||||
address2: null,
|
||||
address3: null,
|
||||
city: null,
|
||||
state: null,
|
||||
postalCode: null,
|
||||
country: null,
|
||||
company: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
ssn: null,
|
||||
username: null,
|
||||
passportNumber: null,
|
||||
licenseNumber: null,
|
||||
},
|
||||
[
|
||||
"title",
|
||||
"firstName",
|
||||
"middleName",
|
||||
"lastName",
|
||||
"address1",
|
||||
"address2",
|
||||
"address3",
|
||||
"city",
|
||||
"state",
|
||||
"postalCode",
|
||||
"country",
|
||||
"company",
|
||||
"email",
|
||||
"phone",
|
||||
"ssn",
|
||||
"username",
|
||||
"passportNumber",
|
||||
"licenseNumber",
|
||||
],
|
||||
orgId,
|
||||
encKey,
|
||||
"DomainType: Identity; " + context,
|
||||
|
||||
@@ -38,11 +38,10 @@ export class LoginUri extends Domain {
|
||||
context: string = "No Cipher Context",
|
||||
encKey?: SymmetricCryptoKey,
|
||||
): Promise<LoginUriView> {
|
||||
return this.decryptObj(
|
||||
return this.decryptObj<LoginUri, LoginUriView>(
|
||||
this,
|
||||
new LoginUriView(this),
|
||||
{
|
||||
uri: null,
|
||||
},
|
||||
["uri"],
|
||||
orgId,
|
||||
encKey,
|
||||
context,
|
||||
|
||||
@@ -58,13 +58,10 @@ export class Login extends Domain {
|
||||
context: string = "No Cipher Context",
|
||||
encKey?: SymmetricCryptoKey,
|
||||
): Promise<LoginView> {
|
||||
const view = await this.decryptObj(
|
||||
const view = await this.decryptObj<Login, LoginView>(
|
||||
this,
|
||||
new LoginView(this),
|
||||
{
|
||||
username: null,
|
||||
password: null,
|
||||
totp: null,
|
||||
},
|
||||
["username", "password", "totp"],
|
||||
orgId,
|
||||
encKey,
|
||||
`DomainType: Login; ${context}`,
|
||||
|
||||
@@ -25,11 +25,10 @@ export class Password extends Domain {
|
||||
}
|
||||
|
||||
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<PasswordHistoryView> {
|
||||
return this.decryptObj(
|
||||
return this.decryptObj<Password, PasswordHistoryView>(
|
||||
this,
|
||||
new PasswordHistoryView(this),
|
||||
{
|
||||
password: null,
|
||||
},
|
||||
["password"],
|
||||
orgId,
|
||||
encKey,
|
||||
"DomainType: PasswordHistory",
|
||||
|
||||
@@ -36,13 +36,10 @@ export class SshKey extends Domain {
|
||||
context = "No Cipher Context",
|
||||
encKey?: SymmetricCryptoKey,
|
||||
): Promise<SshKeyView> {
|
||||
return this.decryptObj(
|
||||
return this.decryptObj<SshKey, SshKeyView>(
|
||||
this,
|
||||
new SshKeyView(),
|
||||
{
|
||||
privateKey: null,
|
||||
publicKey: null,
|
||||
keyFingerprint: null,
|
||||
},
|
||||
["privateKey", "publicKey", "keyFingerprint"],
|
||||
orgId,
|
||||
encKey,
|
||||
"DomainType: SshKey; " + context,
|
||||
|
||||
@@ -6,12 +6,8 @@
|
||||
"@bitwarden/auth/common": ["../auth/src/common"],
|
||||
// TODO: Remove once circular dependencies in admin-console, auth and key-management are resolved
|
||||
"@bitwarden/common/*": ["../common/src/*"],
|
||||
// TODO: Remove once billing stops depending on components
|
||||
"@bitwarden/components": ["../components/src"],
|
||||
"@bitwarden/key-management": ["../key-management/src"],
|
||||
"@bitwarden/platform": ["../platform/src"],
|
||||
// TODO: Remove once billing stops depending on components
|
||||
"@bitwarden/ui-common": ["../ui/common/src"]
|
||||
"@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "spec", "./custom-matchers.d.ts", "../key-management/src/index.ts"],
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *cdkVirtualFor="let r of rows$; trackBy: trackBy" bitRow>
|
||||
<tr *cdkVirtualFor="let r of rows$; trackBy: trackBy; templateCacheSize: 0" bitRow>
|
||||
<ng-container *ngTemplateOutlet="rowDef.template; context: { $implicit: r }"></ng-container>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -7,7 +7,7 @@ const sharedConfig = require("../shared/jest.config.ts");
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
...sharedConfig,
|
||||
preset: "ts-jest",
|
||||
preset: "jest-preset-angular",
|
||||
testEnvironment: "jsdom",
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||
prefix: "<rootDir>/",
|
||||
|
||||
@@ -37,6 +37,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
@@ -96,6 +97,7 @@ const safeProviders: SafeProvider[] = [
|
||||
EncryptService,
|
||||
PinServiceAbstraction,
|
||||
AccountService,
|
||||
SdkService,
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { BitwardenClient } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { BitwardenPasswordProtectedImporter } from "../importers/bitwarden/bitwarden-password-protected-importer";
|
||||
import { Importer } from "../importers/importer";
|
||||
@@ -30,6 +33,7 @@ describe("ImportService", () => {
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let pinService: MockProxy<PinServiceAbstraction>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let sdkService: MockProxy<SdkService>;
|
||||
|
||||
beforeEach(() => {
|
||||
cipherService = mock<CipherService>();
|
||||
@@ -40,6 +44,9 @@ describe("ImportService", () => {
|
||||
keyService = mock<KeyService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
pinService = mock<PinServiceAbstraction>();
|
||||
const mockClient = mock<BitwardenClient>();
|
||||
sdkService = mock<SdkService>();
|
||||
sdkService.client$ = of(mockClient, mockClient, mockClient);
|
||||
|
||||
importService = new ImportService(
|
||||
cipherService,
|
||||
@@ -51,6 +58,7 @@ describe("ImportService", () => {
|
||||
encryptService,
|
||||
pinService,
|
||||
accountService,
|
||||
sdkService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ImportOrganizationCiphersRequest } from "@bitwarden/common/models/reque
|
||||
import { KvpRequest } from "@bitwarden/common/models/request/kvp.request";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
@@ -114,6 +115,7 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
private encryptService: EncryptService,
|
||||
private pinService: PinServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
private sdkService: SdkService,
|
||||
) {}
|
||||
|
||||
getImportOptions(): ImportOption[] {
|
||||
|
||||
@@ -34,7 +34,7 @@ export class DefaultSendFormConfigService implements SendFormConfigService {
|
||||
|
||||
return {
|
||||
mode,
|
||||
sendType: sendType,
|
||||
sendType: send?.type ?? sendType ?? SendType.Text,
|
||||
areSendsAllowed,
|
||||
originalSend: send,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
@@ -39,6 +40,8 @@ import {
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests";
|
||||
|
||||
import { SshImportPromptService } from "../services/ssh-import-prompt.service";
|
||||
|
||||
import { CipherFormService } from "./abstractions/cipher-form.service";
|
||||
import { TotpCaptureService } from "./abstractions/totp-capture.service";
|
||||
import { CipherFormModule } from "./cipher-form.module";
|
||||
@@ -146,6 +149,12 @@ export default {
|
||||
enabled$: new BehaviorSubject(true),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SshImportPromptService,
|
||||
useValue: {
|
||||
importSshKeyFromClipboard: () => Promise.resolve(new SshKeyData()),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CipherFormGenerationService,
|
||||
useValue: {
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
</h2>
|
||||
</bit-section-header>
|
||||
|
||||
<bit-card>
|
||||
<bit-card cdkDropList (cdkDropListDropped)="onUriItemDrop($event)">
|
||||
<ng-container formArrayName="uris">
|
||||
<vault-autofill-uri-option
|
||||
*ngFor="let uri of uriControls; let i = index"
|
||||
cdkDrag
|
||||
[formControlName]="i"
|
||||
(remove)="removeUri(i)"
|
||||
(onKeydown)="onUriItemKeydown($event, i)"
|
||||
[canReorder]="uriControls.length > 1"
|
||||
[canRemove]="uriControls.length > 1"
|
||||
[defaultMatchDetection]="defaultMatchDetection$ | async"
|
||||
[index]="i"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LiveAnnouncer } from "@angular/cdk/a11y";
|
||||
import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop";
|
||||
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
@@ -16,6 +17,14 @@ import { CipherFormContainer } from "../../cipher-form-container";
|
||||
|
||||
import { AutofillOptionsComponent } from "./autofill-options.component";
|
||||
|
||||
jest.mock("@angular/cdk/drag-drop", () => {
|
||||
const actual = jest.requireActual("@angular/cdk/drag-drop");
|
||||
return {
|
||||
...actual,
|
||||
moveItemInArray: jest.fn(actual.moveItemInArray),
|
||||
};
|
||||
});
|
||||
|
||||
describe("AutofillOptionsComponent", () => {
|
||||
let component: AutofillOptionsComponent;
|
||||
let fixture: ComponentFixture<AutofillOptionsComponent>;
|
||||
@@ -255,4 +264,111 @@ describe("AutofillOptionsComponent", () => {
|
||||
|
||||
expect(component.autofillOptionsForm.value.uris.length).toEqual(1);
|
||||
});
|
||||
|
||||
describe("Drag & Drop Functionality", () => {
|
||||
beforeEach(() => {
|
||||
// Prevent auto‑adding an empty URI by setting a non‑null initial value.
|
||||
// This overrides the call to initNewCipher.
|
||||
|
||||
// Now clear any existing URIs (including the auto‑added one)
|
||||
component.autofillOptionsForm.controls.uris.clear();
|
||||
|
||||
// Add exactly three URIs that we want to test reordering on.
|
||||
component.addUri({ uri: "https://first.com", matchDetection: null });
|
||||
component.addUri({ uri: "https://second.com", matchDetection: null });
|
||||
component.addUri({ uri: "https://third.com", matchDetection: null });
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should reorder URI inputs on drop event", () => {
|
||||
// Simulate a drop event that moves the first URI (index 0) to the last position (index 2).
|
||||
const dropEvent: CdkDragDrop<HTMLDivElement> = {
|
||||
previousIndex: 0,
|
||||
currentIndex: 2,
|
||||
container: null,
|
||||
previousContainer: null,
|
||||
isPointerOverContainer: true,
|
||||
item: null,
|
||||
distance: { x: 0, y: 0 },
|
||||
} as any;
|
||||
|
||||
component.onUriItemDrop(dropEvent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(moveItemInArray).toHaveBeenCalledWith(
|
||||
component.autofillOptionsForm.controls.uris.controls,
|
||||
0,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("should reorder URI input via keyboard ArrowUp", async () => {
|
||||
// Clear and add exactly two URIs.
|
||||
component.autofillOptionsForm.controls.uris.clear();
|
||||
component.addUri({ uri: "https://first.com", matchDetection: null });
|
||||
component.addUri({ uri: "https://second.com", matchDetection: null });
|
||||
fixture.detectChanges();
|
||||
|
||||
// Simulate pressing ArrowUp on the second URI (index 1)
|
||||
const keyEvent = {
|
||||
key: "ArrowUp",
|
||||
preventDefault: jest.fn(),
|
||||
target: document.createElement("button"),
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
// Force requestAnimationFrame to run synchronously
|
||||
jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
|
||||
cb(new Date().getTime());
|
||||
return 0;
|
||||
});
|
||||
(liveAnnouncer.announce as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
await component.onUriItemKeydown(keyEvent, 1);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(moveItemInArray).toHaveBeenCalledWith(
|
||||
component.autofillOptionsForm.controls.uris.controls,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
expect(liveAnnouncer.announce).toHaveBeenCalledWith(
|
||||
"reorderFieldUp websiteUri 1 2",
|
||||
"assertive",
|
||||
);
|
||||
});
|
||||
|
||||
it("should reorder URI input via keyboard ArrowDown", async () => {
|
||||
// Clear and add exactly three URIs.
|
||||
component.autofillOptionsForm.controls.uris.clear();
|
||||
component.addUri({ uri: "https://first.com", matchDetection: null });
|
||||
component.addUri({ uri: "https://second.com", matchDetection: null });
|
||||
component.addUri({ uri: "https://third.com", matchDetection: null });
|
||||
fixture.detectChanges();
|
||||
|
||||
// Simulate pressing ArrowDown on the second URI (index 1)
|
||||
const keyEvent = {
|
||||
key: "ArrowDown",
|
||||
preventDefault: jest.fn(),
|
||||
target: document.createElement("button"),
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
|
||||
cb(new Date().getTime());
|
||||
return 0;
|
||||
});
|
||||
(liveAnnouncer.announce as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
await component.onUriItemKeydown(keyEvent, 1);
|
||||
|
||||
expect(moveItemInArray).toHaveBeenCalledWith(
|
||||
component.autofillOptionsForm.controls.uris.controls,
|
||||
1,
|
||||
2,
|
||||
);
|
||||
expect(liveAnnouncer.announce).toHaveBeenCalledWith(
|
||||
"reorderFieldDown websiteUri 3 3",
|
||||
"assertive",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { LiveAnnouncer } from "@angular/cdk/a11y";
|
||||
import { CdkDragDrop, DragDropModule, moveItemInArray } from "@angular/cdk/drag-drop";
|
||||
import { AsyncPipe, NgForOf, NgIf } from "@angular/common";
|
||||
import { Component, OnInit, QueryList, ViewChildren } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
@@ -41,6 +42,7 @@ interface UriField {
|
||||
templateUrl: "./autofill-options.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
DragDropModule,
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
TypographyModule,
|
||||
@@ -229,4 +231,58 @@ export class AutofillOptionsComponent implements OnInit {
|
||||
removeUri(i: number) {
|
||||
this.autofillOptionsForm.controls.uris.removeAt(i);
|
||||
}
|
||||
|
||||
/** Create a new list of LoginUriViews from the form objects and update the cipher */
|
||||
private updateUriFields() {
|
||||
this.cipherFormContainer.patchCipher((cipher) => {
|
||||
cipher.login.uris = this.uriControls.map(
|
||||
(control) =>
|
||||
Object.assign(new LoginUriView(), {
|
||||
uri: control.value.uri,
|
||||
matchDetection: control.value.matchDetection ?? null,
|
||||
}) as LoginUriView,
|
||||
);
|
||||
return cipher;
|
||||
});
|
||||
}
|
||||
|
||||
/** Reorder the controls to match the new order after a "drop" event */
|
||||
onUriItemDrop(event: CdkDragDrop<HTMLDivElement>) {
|
||||
moveItemInArray(this.uriControls, event.previousIndex, event.currentIndex);
|
||||
this.updateUriFields();
|
||||
}
|
||||
|
||||
/** Handles a uri item keyboard up or down event */
|
||||
async onUriItemKeydown(event: KeyboardEvent, index: number) {
|
||||
if (event.key === "ArrowUp" && index !== 0) {
|
||||
await this.reorderUriItems(event, index, "Up");
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown" && index !== this.uriControls.length - 1) {
|
||||
await this.reorderUriItems(event, index, "Down");
|
||||
}
|
||||
}
|
||||
|
||||
/** Reorders the uri items from a keyboard up or down event */
|
||||
async reorderUriItems(event: KeyboardEvent, previousIndex: number, direction: "Up" | "Down") {
|
||||
const currentIndex = previousIndex + (direction === "Up" ? -1 : 1);
|
||||
event.preventDefault();
|
||||
await this.liveAnnouncer.announce(
|
||||
this.i18nService.t(
|
||||
`reorderField${direction}`,
|
||||
this.i18nService.t("websiteUri"),
|
||||
currentIndex + 1,
|
||||
this.uriControls.length,
|
||||
),
|
||||
"assertive",
|
||||
);
|
||||
moveItemInArray(this.uriControls, previousIndex, currentIndex);
|
||||
this.updateUriFields();
|
||||
// Refocus the button after the reorder
|
||||
// Angular re-renders the list when moving an item up which causes the focus to be lost
|
||||
// Wait for the next tick to ensure the button is rendered before focusing
|
||||
requestAnimationFrame(() => {
|
||||
(event.target as HTMLButtonElement).focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,50 @@
|
||||
<ng-container [formGroup]="uriForm">
|
||||
<bit-form-field [class.!tw-mb-1]="showMatchDetection">
|
||||
<bit-label>{{ uriLabel }}</bit-label>
|
||||
<input bitInput formControlName="uri" #uriInput />
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-cog"
|
||||
bitSuffix
|
||||
[appA11yTitle]="toggleTitle"
|
||||
(click)="toggleMatchDetection()"
|
||||
data-testid="toggle-match-detection-button"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-minus-circle"
|
||||
buttonType="danger"
|
||||
bitSuffix
|
||||
[appA11yTitle]="'deleteWebsite' | i18n"
|
||||
*ngIf="canRemove"
|
||||
(click)="removeUri()"
|
||||
data-testid="remove-uri-button"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field *ngIf="showMatchDetection" class="!tw-mb-5">
|
||||
<bit-label>{{ "matchDetection" | i18n }}</bit-label>
|
||||
<bit-select formControlName="matchDetection" #matchDetectionSelect>
|
||||
<bit-option
|
||||
*ngFor="let o of uriMatchOptions"
|
||||
[label]="o.label"
|
||||
[value]="o.value"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
<div class="tw-mb-4 pt-1">
|
||||
<div class="tw-flex tw-pt-2" [class.!tw-mb-1]="showMatchDetection">
|
||||
<bit-form-field disableMargin class="tw-flex-1 !tw-pt-0">
|
||||
<bit-label>{{ uriLabel }}</bit-label>
|
||||
<input bitInput formControlName="uri" #uriInput />
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-cog"
|
||||
bitSuffix
|
||||
[appA11yTitle]="toggleTitle"
|
||||
(click)="toggleMatchDetection()"
|
||||
data-testid="toggle-match-detection-button"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-minus-circle"
|
||||
buttonType="danger"
|
||||
bitSuffix
|
||||
[appA11yTitle]="'deleteWebsite' | i18n"
|
||||
*ngIf="canRemove"
|
||||
(click)="removeUri()"
|
||||
data-testid="remove-uri-button"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
<div class="tw-flex tw-items-center tw-ml-1.5">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-hamburger"
|
||||
class="!tw-py-0 !tw-px-1"
|
||||
cdkDragHandle
|
||||
[appA11yTitle]="'reorderToggleButton' | i18n: uriLabel"
|
||||
(keydown)="handleKeydown($event)"
|
||||
data-testid="reorder-toggle-button"
|
||||
*ngIf="canReorder"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
<bit-form-field *ngIf="showMatchDetection" class="!tw-mb-5">
|
||||
<bit-label>{{ "matchDetection" | i18n }}</bit-label>
|
||||
<bit-select formControlName="matchDetection" #matchDetectionSelect>
|
||||
<bit-option
|
||||
*ngFor="let o of uriMatchOptions"
|
||||
[label]="o.label"
|
||||
[value]="o.value"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { DragDropModule } from "@angular/cdk/drag-drop";
|
||||
import { NgForOf, NgIf } from "@angular/common";
|
||||
import {
|
||||
Component,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
DragDropModule,
|
||||
FormFieldModule,
|
||||
ReactiveFormsModule,
|
||||
IconButtonModule,
|
||||
@@ -74,6 +76,12 @@ export class UriOptionComponent implements ControlValueAccessor {
|
||||
{ label: this.i18nService.t("never"), value: UriMatchStrategy.Never },
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether the option can be reordered. If false, the reorder button will be hidden.
|
||||
*/
|
||||
@Input({ required: true })
|
||||
canReorder: boolean;
|
||||
|
||||
/**
|
||||
* Whether the URI can be removed from the form. If false, the remove button will be hidden.
|
||||
*/
|
||||
@@ -101,6 +109,9 @@ export class UriOptionComponent implements ControlValueAccessor {
|
||||
*/
|
||||
@Input({ required: true }) index: number;
|
||||
|
||||
@Output()
|
||||
onKeydown = new EventEmitter<KeyboardEvent>();
|
||||
|
||||
/**
|
||||
* Emits when the remove button is clicked and URI should be removed from the form.
|
||||
*/
|
||||
@@ -132,6 +143,10 @@ export class UriOptionComponent implements ControlValueAccessor {
|
||||
private onChange: any = () => {};
|
||||
private onTouched: any = () => {};
|
||||
|
||||
protected handleKeydown(event: KeyboardEvent) {
|
||||
this.onKeydown.emit(event);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
(click)="openAddEditCustomFieldDialog({ index: i, label: field.value.name })"
|
||||
[appA11yTitle]="'editFieldLabel' | i18n: field.value.name"
|
||||
bitIconButton="bwi-pencil-square"
|
||||
class="tw-self-end"
|
||||
class="tw-self-center tw-mt-2"
|
||||
data-testid="edit-custom-field-button"
|
||||
*ngIf="!isPartialEdit"
|
||||
></button>
|
||||
@@ -95,7 +95,7 @@
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-hamburger"
|
||||
class="tw-self-end"
|
||||
class="tw-self-center tw-mt-2"
|
||||
cdkDragHandle
|
||||
[appA11yTitle]="'reorderToggleButton' | i18n: field.value.name"
|
||||
(keydown)="handleKeyDown($event, field.value.name, i)"
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
data-testid="toggle-privateKey-visibility"
|
||||
bitPasswordInputToggle
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-paste"
|
||||
bitSuffix
|
||||
data-testid="import-privateKey"
|
||||
*ngIf="showImport"
|
||||
(click)="importSshKeyFromClipboard()"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
|
||||
@@ -7,7 +7,8 @@ import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
import { generate_ssh_key } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SshImportPromptService } from "../../../services/ssh-import-prompt.service";
|
||||
import { CipherFormContainer } from "../../cipher-form-container";
|
||||
|
||||
@Component({
|
||||
@@ -60,11 +62,14 @@ export class SshKeySectionComponent implements OnInit {
|
||||
keyFingerprint: [""],
|
||||
});
|
||||
|
||||
showImport = false;
|
||||
|
||||
constructor(
|
||||
private cipherFormContainer: CipherFormContainer,
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
private sdkService: SdkService,
|
||||
private sshImportPromptService: SshImportPromptService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {
|
||||
this.cipherFormContainer.registerChildForm("sshKeyDetails", this.sshKeyForm);
|
||||
this.sshKeyForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
|
||||
@@ -87,6 +92,11 @@ export class SshKeySectionComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.sshKeyForm.disable();
|
||||
|
||||
// Web does not support clipboard access
|
||||
if (this.platformUtilsService.getClientType() !== ClientType.Web) {
|
||||
this.showImport = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Set form initial form values from the current cipher */
|
||||
@@ -100,6 +110,17 @@ export class SshKeySectionComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
async importSshKeyFromClipboard() {
|
||||
const key = await this.sshImportPromptService.importSshKeyFromClipboard();
|
||||
if (key != null) {
|
||||
this.sshKeyForm.setValue({
|
||||
privateKey: key.privateKey,
|
||||
publicKey: key.publicKey,
|
||||
keyFingerprint: key.keyFingerprint,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async generateSshKey() {
|
||||
await firstValueFrom(this.sdkService.client$);
|
||||
const sshKey = generate_ssh_key("Ed25519");
|
||||
|
||||
@@ -171,6 +171,10 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
async checkPendingChangePasswordTasks(userId: UserId): Promise<void> {
|
||||
if (!(await firstValueFrom(this.isSecurityTasksEnabled$))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = await firstValueFrom(this.defaultTaskService.pendingTasks$(userId));
|
||||
|
||||
this.hadPendingChangePasswordTask = tasks?.some((task) => {
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<h2 bitTypography="h6">{{ "itemHistory" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<p class="tw-mb-1 tw-text-xs tw-text-muted tw-select-none">
|
||||
<p class="tw-mb-1 tw-text-xs tw-text-muted tw-select-all">
|
||||
<span class="tw-font-bold">{{ "lastEdited" | i18n }}:</span>
|
||||
{{ cipher.revisionDate | date: "medium" }}
|
||||
</p>
|
||||
<p
|
||||
class="tw-text-xs tw-text-muted tw-select-none"
|
||||
class="tw-text-xs tw-text-muted tw-select-all"
|
||||
[ngClass]="{
|
||||
'tw-mb-1 ': cipher.hasPasswordHistory,
|
||||
'tw-mb-0': !cipher.hasPasswordHistory,
|
||||
@@ -19,7 +19,7 @@
|
||||
</p>
|
||||
<p
|
||||
*ngIf="cipher.passwordRevisionDisplayDate"
|
||||
class="tw-text-xs tw-text-muted tw-select-none"
|
||||
class="tw-text-xs tw-text-muted tw-select-all"
|
||||
[ngClass]="{ 'tw-mb-3': cipher.hasPasswordHistory }"
|
||||
>
|
||||
<span class="tw-font-bold">{{ "datePasswordUpdated" | i18n }}:</span>
|
||||
|
||||
@@ -25,8 +25,11 @@ export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.compon
|
||||
export * from "./components/carousel";
|
||||
|
||||
export * as VaultIcons from "./icons";
|
||||
|
||||
export * from "./tasks";
|
||||
export * from "./notifications";
|
||||
|
||||
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
|
||||
export { SshImportPromptService } from "./services/ssh-import-prompt.service";
|
||||
|
||||
export * from "./abstractions/change-login-password.service";
|
||||
export * from "./services/default-change-login-password.service";
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { NotificationView } from "../models";
|
||||
|
||||
/**
|
||||
* A service for retrieving and managing notifications for end users.
|
||||
*/
|
||||
export abstract class EndUserNotificationService {
|
||||
/**
|
||||
* Observable of all notifications for the given user.
|
||||
* @param userId
|
||||
*/
|
||||
abstract notifications$(userId: UserId): Observable<NotificationView[]>;
|
||||
|
||||
/**
|
||||
* Observable of all unread notifications for the given user.
|
||||
* @param userId
|
||||
*/
|
||||
abstract unreadNotifications$(userId: UserId): Observable<NotificationView[]>;
|
||||
|
||||
/**
|
||||
* Mark a notification as read.
|
||||
* @param notificationId
|
||||
* @param userId
|
||||
*/
|
||||
abstract markAsRead(notificationId: any, userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Mark a notification as deleted.
|
||||
* @param notificationId
|
||||
* @param userId
|
||||
*/
|
||||
abstract markAsDeleted(notificationId: any, userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Create/update a notification in the state for the user specified within the notification.
|
||||
* @remarks This method should only be called when a notification payload is received from the web socket.
|
||||
* @param notification
|
||||
*/
|
||||
abstract upsert(notification: Notification): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clear all notifications from state for the given user.
|
||||
* @param userId
|
||||
*/
|
||||
abstract clearState(userId: UserId): Promise<void>;
|
||||
}
|
||||
2
libs/vault/src/notifications/index.ts
Normal file
2
libs/vault/src/notifications/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./abstractions/end-user-notification.service";
|
||||
export * from "./services/default-end-user-notification.service";
|
||||
3
libs/vault/src/notifications/models/index.ts
Normal file
3
libs/vault/src/notifications/models/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./notification-view";
|
||||
export * from "./notification-view.data";
|
||||
export * from "./notification-view.response";
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { NotificationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { NotificationViewResponse } from "./notification-view.response";
|
||||
|
||||
export class NotificationViewData {
|
||||
id: NotificationId;
|
||||
priority: number;
|
||||
title: string;
|
||||
body: string;
|
||||
date: Date;
|
||||
readDate: Date | null;
|
||||
deletedDate: Date | null;
|
||||
|
||||
constructor(response: NotificationViewResponse) {
|
||||
this.id = response.id;
|
||||
this.priority = response.priority;
|
||||
this.title = response.title;
|
||||
this.body = response.body;
|
||||
this.date = response.date;
|
||||
this.readDate = response.readDate;
|
||||
this.deletedDate = response.deletedDate;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<NotificationViewData>) {
|
||||
return Object.assign(new NotificationViewData({} as NotificationViewResponse), obj, {
|
||||
id: obj.id,
|
||||
priority: obj.priority,
|
||||
title: obj.title,
|
||||
body: obj.body,
|
||||
date: new Date(obj.date),
|
||||
readDate: obj.readDate ? new Date(obj.readDate) : null,
|
||||
deletedDate: obj.deletedDate ? new Date(obj.deletedDate) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { NotificationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export class NotificationViewResponse extends BaseResponse {
|
||||
id: NotificationId;
|
||||
priority: number;
|
||||
title: string;
|
||||
body: string;
|
||||
date: Date;
|
||||
readDate: Date;
|
||||
deletedDate: Date;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.priority = this.getResponseProperty("Priority");
|
||||
this.title = this.getResponseProperty("Title");
|
||||
this.body = this.getResponseProperty("Body");
|
||||
this.date = this.getResponseProperty("Date");
|
||||
this.readDate = this.getResponseProperty("ReadDate");
|
||||
this.deletedDate = this.getResponseProperty("DeletedDate");
|
||||
}
|
||||
}
|
||||
21
libs/vault/src/notifications/models/notification-view.ts
Normal file
21
libs/vault/src/notifications/models/notification-view.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NotificationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export class NotificationView {
|
||||
id: NotificationId;
|
||||
priority: number;
|
||||
title: string;
|
||||
body: string;
|
||||
date: Date;
|
||||
readDate: Date | null;
|
||||
deletedDate: Date | null;
|
||||
|
||||
constructor(obj: any) {
|
||||
this.id = obj.id;
|
||||
this.priority = obj.priority;
|
||||
this.title = obj.title;
|
||||
this.body = obj.body;
|
||||
this.date = obj.date;
|
||||
this.readDate = obj.readDate;
|
||||
this.deletedDate = obj.deletedDate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DefaultEndUserNotificationService } from "@bitwarden/vault";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../common/spec";
|
||||
import { NotificationViewResponse } from "../models";
|
||||
import { NOTIFICATIONS } from "../state/end-user-notification.state";
|
||||
|
||||
describe("End User Notification Center Service", () => {
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
|
||||
const mockApiSend = jest.fn();
|
||||
|
||||
let testBed: TestBed;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockApiSend.mockClear();
|
||||
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
||||
|
||||
testBed = TestBed.configureTestingModule({
|
||||
imports: [],
|
||||
providers: [
|
||||
DefaultEndUserNotificationService,
|
||||
{
|
||||
provide: StateProvider,
|
||||
useValue: fakeStateProvider,
|
||||
},
|
||||
{
|
||||
provide: ApiService,
|
||||
useValue: {
|
||||
send: mockApiSend,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifications$", () => {
|
||||
it("should return notifications from state when not null", async () => {
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
|
||||
{
|
||||
id: "notification-id" as NotificationId,
|
||||
} as NotificationViewResponse,
|
||||
]);
|
||||
|
||||
const { notifications$ } = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
const result = await firstValueFrom(notifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return notifications API when state is null", async () => {
|
||||
mockApiSend.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any);
|
||||
|
||||
const { notifications$ } = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
const result = await firstValueFrom(notifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiSend).toHaveBeenCalledWith("GET", "/notifications", null, true, true);
|
||||
});
|
||||
|
||||
it("should share the same observable for the same user", async () => {
|
||||
const { notifications$ } = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
const first = notifications$("user-id" as UserId);
|
||||
const second = notifications$("user-id" as UserId);
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unreadNotifications$", () => {
|
||||
it("should return unread notifications from state when read value is null", async () => {
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
|
||||
{
|
||||
id: "notification-id" as NotificationId,
|
||||
readDate: null as any,
|
||||
} as NotificationViewResponse,
|
||||
]);
|
||||
|
||||
const { unreadNotifications$ } = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
const result = await firstValueFrom(unreadNotifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNotifications", () => {
|
||||
it("should call getNotifications returning notifications from API", async () => {
|
||||
mockApiSend.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
await service.getNotifications("user-id" as UserId);
|
||||
|
||||
expect(mockApiSend).toHaveBeenCalledWith("GET", "/notifications", null, true, true);
|
||||
});
|
||||
});
|
||||
it("should update local state when notifications are updated", async () => {
|
||||
mockApiSend.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
|
||||
const mock = fakeStateProvider.singleUser.mockFor(
|
||||
"user-id" as UserId,
|
||||
NOTIFICATIONS,
|
||||
null as any,
|
||||
);
|
||||
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
await service.getNotifications("user-id" as UserId);
|
||||
|
||||
expect(mock.nextMock).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
id: "notification-id" as NotificationId,
|
||||
} as NotificationViewResponse),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should clear the local notification state for the user", async () => {
|
||||
const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
|
||||
{
|
||||
id: "notification-id" as NotificationId,
|
||||
} as NotificationViewResponse,
|
||||
]);
|
||||
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
await service.clearState("user-id" as UserId);
|
||||
|
||||
expect(mock.nextMock).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAsDeleted", () => {
|
||||
it("should send an API request to mark the notification as deleted", async () => {
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
await service.markAsDeleted("notification-id" as NotificationId, "user-id" as UserId);
|
||||
expect(mockApiSend).toHaveBeenCalledWith(
|
||||
"DELETE",
|
||||
"/notifications/notification-id/delete",
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAsRead", () => {
|
||||
it("should send an API request to mark the notification as read", async () => {
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
await service.markAsRead("notification-id" as NotificationId, "user-id" as UserId);
|
||||
expect(mockApiSend).toHaveBeenCalledWith(
|
||||
"PATCH",
|
||||
"/notifications/notification-id/read",
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { filterOutNullish, perUserCache$ } from "../../utils/observable-utilities";
|
||||
import { EndUserNotificationService } from "../abstractions/end-user-notification.service";
|
||||
import { NotificationView, NotificationViewData, NotificationViewResponse } from "../models";
|
||||
import { NOTIFICATIONS } from "../state/end-user-notification.state";
|
||||
|
||||
/**
|
||||
* A service for retrieving and managing notifications for end users.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DefaultEndUserNotificationService implements EndUserNotificationService {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private apiService: ApiService,
|
||||
) {}
|
||||
|
||||
notifications$ = perUserCache$((userId: UserId): Observable<NotificationView[]> => {
|
||||
return this.notificationState(userId).state$.pipe(
|
||||
switchMap(async (notifications) => {
|
||||
if (notifications == null) {
|
||||
await this.fetchNotificationsFromApi(userId);
|
||||
}
|
||||
return notifications;
|
||||
}),
|
||||
filterOutNullish(),
|
||||
map((notifications) =>
|
||||
notifications.map((notification) => new NotificationView(notification)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
unreadNotifications$ = perUserCache$((userId: UserId): Observable<NotificationView[]> => {
|
||||
return this.notifications$(userId).pipe(
|
||||
map((notifications) => notifications.filter((notification) => notification.readDate == null)),
|
||||
);
|
||||
});
|
||||
|
||||
async markAsRead(notificationId: any, userId: UserId): Promise<void> {
|
||||
await this.apiService.send("PATCH", `/notifications/${notificationId}/read`, null, true, false);
|
||||
await this.getNotifications(userId);
|
||||
}
|
||||
|
||||
async markAsDeleted(notificationId: any, userId: UserId): Promise<void> {
|
||||
await this.apiService.send(
|
||||
"DELETE",
|
||||
`/notifications/${notificationId}/delete`,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
await this.getNotifications(userId);
|
||||
}
|
||||
|
||||
upsert(notification: Notification): any {}
|
||||
|
||||
async clearState(userId: UserId): Promise<void> {
|
||||
await this.updateNotificationState(userId, []);
|
||||
}
|
||||
|
||||
async getNotifications(userId: UserId) {
|
||||
await this.fetchNotificationsFromApi(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the notifications from the API and updates the local state
|
||||
* @param userId
|
||||
* @private
|
||||
*/
|
||||
private async fetchNotificationsFromApi(userId: UserId): Promise<void> {
|
||||
const res = await this.apiService.send("GET", "/notifications", null, true, true);
|
||||
const response = new ListResponse(res, NotificationViewResponse);
|
||||
const notificationData = response.data.map((n) => new NotificationView(n));
|
||||
await this.updateNotificationState(userId, notificationData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the local state with notifications and returns the updated state
|
||||
* @param userId
|
||||
* @param notifications
|
||||
* @private
|
||||
*/
|
||||
private updateNotificationState(
|
||||
userId: UserId,
|
||||
notifications: NotificationViewData[],
|
||||
): Promise<NotificationViewData[] | null> {
|
||||
return this.notificationState(userId).update(() => notifications);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local state for notifications
|
||||
* @param userId
|
||||
* @private
|
||||
*/
|
||||
private notificationState(userId: UserId) {
|
||||
return this.stateProvider.getUser(userId, NOTIFICATIONS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { NOTIFICATION_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { NotificationViewData } from "../models";
|
||||
|
||||
export const NOTIFICATIONS = UserKeyDefinition.array<NotificationViewData>(
|
||||
NOTIFICATION_DISK,
|
||||
"notifications",
|
||||
{
|
||||
deserializer: (notification: Jsonify<NotificationViewData>) =>
|
||||
NotificationViewData.fromJSON(notification),
|
||||
clearOn: ["logout", "lock"],
|
||||
},
|
||||
);
|
||||
109
libs/vault/src/services/default-ssh-import-prompt.service.ts
Normal file
109
libs/vault/src/services/default-ssh-import-prompt.service.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SshKeyApi } from "@bitwarden/common/vault/models/api/ssh-key.api";
|
||||
import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { SshKeyPasswordPromptComponent } from "@bitwarden/importer-ui";
|
||||
import { import_ssh_key, SshKeyImportError, SshKeyView } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SshImportPromptService } from "./ssh-import-prompt.service";
|
||||
|
||||
/**
|
||||
* Used to import ssh keys and prompt for their password.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DefaultSshImportPromptService implements SshImportPromptService {
|
||||
constructor(
|
||||
private dialogService: DialogService,
|
||||
private toastService: ToastService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
async importSshKeyFromClipboard(): Promise<SshKeyData | null> {
|
||||
const key = await this.platformUtilsService.readFromClipboard();
|
||||
|
||||
let isPasswordProtectedSshKey = false;
|
||||
|
||||
let parsedKey: SshKeyView | null = null;
|
||||
|
||||
try {
|
||||
parsedKey = import_ssh_key(key);
|
||||
} catch (e) {
|
||||
const error = e as SshKeyImportError;
|
||||
if (error.variant === "PasswordRequired" || error.variant === "WrongPassword") {
|
||||
isPasswordProtectedSshKey = true;
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t(this.sshImportErrorVariantToI18nKey(error.variant)),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPasswordProtectedSshKey) {
|
||||
for (;;) {
|
||||
const password = await this.getSshKeyPassword();
|
||||
if (password === "" || password == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
parsedKey = import_ssh_key(key, password);
|
||||
break;
|
||||
} catch (e) {
|
||||
const error = e as SshKeyImportError;
|
||||
if (error.variant !== "WrongPassword") {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t(this.sshImportErrorVariantToI18nKey(error.variant)),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("sshKeyImported"),
|
||||
});
|
||||
|
||||
return new SshKeyData(
|
||||
new SshKeyApi({
|
||||
privateKey: parsedKey!.privateKey,
|
||||
publicKey: parsedKey!.publicKey,
|
||||
keyFingerprint: parsedKey!.fingerprint,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private sshImportErrorVariantToI18nKey(variant: string): string {
|
||||
switch (variant) {
|
||||
case "ParsingError":
|
||||
return "invalidSshKey";
|
||||
case "UnsupportedKeyType":
|
||||
return "sshKeyTypeUnsupported";
|
||||
case "PasswordRequired":
|
||||
case "WrongPassword":
|
||||
return "sshKeyWrongPassword";
|
||||
default:
|
||||
return "errorOccurred";
|
||||
}
|
||||
}
|
||||
|
||||
private async getSshKeyPassword(): Promise<string | undefined> {
|
||||
const dialog = this.dialogService.open<string>(SshKeyPasswordPromptComponent, {
|
||||
ariaModal: true,
|
||||
});
|
||||
|
||||
return await firstValueFrom(dialog.closed);
|
||||
}
|
||||
}
|
||||
111
libs/vault/src/services/ssh-import-prompt.service.spec.ts
Normal file
111
libs/vault/src/services/ssh-import-prompt.service.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SshKeyApi } from "@bitwarden/common/vault/models/api/ssh-key.api";
|
||||
import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import * as sdkInternal from "@bitwarden/sdk-internal";
|
||||
|
||||
import { DefaultSshImportPromptService } from "./default-ssh-import-prompt.service";
|
||||
|
||||
jest.mock("@bitwarden/sdk-internal");
|
||||
|
||||
const exampleSshKey = {
|
||||
privateKey: "private_key",
|
||||
publicKey: "public_key",
|
||||
fingerprint: "key_fingerprint",
|
||||
} as sdkInternal.SshKeyView;
|
||||
|
||||
const exampleSshKeyData = new SshKeyData(
|
||||
new SshKeyApi({
|
||||
publicKey: exampleSshKey.publicKey,
|
||||
privateKey: exampleSshKey.privateKey,
|
||||
keyFingerprint: exampleSshKey.fingerprint,
|
||||
}),
|
||||
);
|
||||
|
||||
describe("SshImportPromptService", () => {
|
||||
let sshImportPromptService: DefaultSshImportPromptService;
|
||||
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let toastService: MockProxy<ToastService>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
|
||||
beforeEach(() => {
|
||||
dialogService = mock<DialogService>();
|
||||
toastService = mock<ToastService>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
i18nService = mock<I18nService>();
|
||||
|
||||
sshImportPromptService = new DefaultSshImportPromptService(
|
||||
dialogService,
|
||||
toastService,
|
||||
platformUtilsService,
|
||||
i18nService,
|
||||
);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("importSshKeyFromClipboard()", () => {
|
||||
it("imports unencrypted ssh key", async () => {
|
||||
jest.spyOn(sdkInternal, "import_ssh_key").mockReturnValue(exampleSshKey);
|
||||
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
|
||||
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(exampleSshKeyData);
|
||||
});
|
||||
|
||||
it("requests password for encrypted ssh key", async () => {
|
||||
jest
|
||||
.spyOn(sdkInternal, "import_ssh_key")
|
||||
.mockImplementationOnce(() => {
|
||||
throw { variant: "PasswordRequired" };
|
||||
})
|
||||
.mockImplementationOnce(() => exampleSshKey);
|
||||
dialogService.open.mockReturnValue({ closed: new BehaviorSubject("password") } as any);
|
||||
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
|
||||
|
||||
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(exampleSshKeyData);
|
||||
expect(dialogService.open).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels when no password was provided", async () => {
|
||||
jest.spyOn(sdkInternal, "import_ssh_key").mockImplementationOnce(() => {
|
||||
throw { variant: "PasswordRequired" };
|
||||
});
|
||||
dialogService.open.mockReturnValue({ closed: new BehaviorSubject("") } as any);
|
||||
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
|
||||
|
||||
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null);
|
||||
expect(dialogService.open).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes through error on no password", async () => {
|
||||
jest.spyOn(sdkInternal, "import_ssh_key").mockImplementationOnce(() => {
|
||||
throw { variant: "UnsupportedKeyType" };
|
||||
});
|
||||
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
|
||||
|
||||
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null);
|
||||
expect(i18nService.t).toHaveBeenCalledWith("sshKeyTypeUnsupported");
|
||||
});
|
||||
|
||||
it("passes through error with password", async () => {
|
||||
jest
|
||||
.spyOn(sdkInternal, "import_ssh_key")
|
||||
.mockClear()
|
||||
.mockImplementationOnce(() => {
|
||||
throw { variant: "PasswordRequired" };
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw { variant: "UnsupportedKeyType" };
|
||||
});
|
||||
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
|
||||
dialogService.open.mockReturnValue({ closed: new BehaviorSubject("password") } as any);
|
||||
|
||||
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null);
|
||||
expect(i18nService.t).toHaveBeenCalledWith("sshKeyTypeUnsupported");
|
||||
});
|
||||
});
|
||||
});
|
||||
5
libs/vault/src/services/ssh-import-prompt.service.ts
Normal file
5
libs/vault/src/services/ssh-import-prompt.service.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data";
|
||||
|
||||
export abstract class SshImportPromptService {
|
||||
abstract importSshKeyFromClipboard: () => Promise<SshKeyData | null>;
|
||||
}
|
||||
@@ -8,11 +8,13 @@
|
||||
"@bitwarden/auth/common": ["../auth/src/common"],
|
||||
"@bitwarden/common/*": ["../common/src/*"],
|
||||
"@bitwarden/components": ["../components/src"],
|
||||
"@bitwarden/importer-ui": ["../importer/src/components"],
|
||||
"@bitwarden/generator-components": ["../tools/generator/components/src"],
|
||||
"@bitwarden/generator-core": ["../tools/generator/core/src"],
|
||||
"@bitwarden/generator-history": ["../tools/generator/extensions/history/src"],
|
||||
"@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"],
|
||||
"@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"],
|
||||
"@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"],
|
||||
"@bitwarden/key-management": ["../key-management/src"],
|
||||
"@bitwarden/platform": ["../platform/src"],
|
||||
"@bitwarden/ui-common": ["../ui/common/src"],
|
||||
|
||||
Reference in New Issue
Block a user