1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

Merge branch 'master' into feature/flexible-collections

This commit is contained in:
Vincent Salucci
2023-09-14 17:07:54 -05:00
900 changed files with 69193 additions and 19393 deletions

View File

@@ -0,0 +1,291 @@
import { Directive, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormControl } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import {
firstValueFrom,
switchMap,
Subject,
catchError,
from,
of,
finalize,
takeUntil,
defer,
throwError,
} from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { DevicesServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account";
enum State {
NewUser,
ExistingUserUntrustedDevice,
}
type NewUserData = {
readonly state: State.NewUser;
readonly organizationId: string;
readonly userEmail: string;
};
type ExistingUserUntrustedDeviceData = {
readonly state: State.ExistingUserUntrustedDevice;
readonly showApproveFromOtherDeviceBtn: boolean;
readonly showReqAdminApprovalBtn: boolean;
readonly showApproveWithMasterPasswordBtn: boolean;
readonly userEmail: string;
};
type Data = NewUserData | ExistingUserUntrustedDeviceData;
@Directive()
export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected State = State;
protected data?: Data;
protected loading = true;
// Remember device means for the user to trust the device
rememberDeviceForm = this.formBuilder.group({
rememberDevice: [true],
});
get rememberDevice(): FormControl<boolean> {
return this.rememberDeviceForm?.controls.rememberDevice;
}
constructor(
protected formBuilder: FormBuilder,
protected devicesService: DevicesServiceAbstraction,
protected stateService: StateService,
protected router: Router,
protected activatedRoute: ActivatedRoute,
protected messagingService: MessagingService,
protected tokenService: TokenService,
protected loginService: LoginService,
protected organizationApiService: OrganizationApiServiceAbstraction,
protected cryptoService: CryptoService,
protected organizationUserService: OrganizationUserService,
protected apiService: ApiService,
protected i18nService: I18nService,
protected validationService: ValidationService,
protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
protected platformUtilsService: PlatformUtilsService,
protected passwordResetEnrollmentService: PasswordResetEnrollmentServiceAbstraction
) {}
async ngOnInit() {
this.loading = true;
this.setupRememberDeviceValueChanges();
// Persist user choice from state if it exists
await this.setRememberDeviceDefaultValue();
try {
const accountDecryptionOptions: AccountDecryptionOptions =
await this.stateService.getAccountDecryptionOptions();
// see sso-login.strategy - to determine if a user is new or not it just checks if there is a key on the token response..
// can we check if they have a user key or master key in crypto service? Would that be sufficient?
if (
!accountDecryptionOptions?.trustedDeviceOption?.hasAdminApproval &&
!accountDecryptionOptions?.hasMasterPassword
) {
// We are dealing with a new account if:
// - User does not have admin approval (i.e. has not enrolled into admin reset)
// - AND does not have a master password
this.loadNewUserData();
} else {
this.loadUntrustedDeviceData(accountDecryptionOptions);
}
// Note: this is probably not a comprehensive write up of all scenarios:
// If the TDE feature flag is enabled and TDE is configured for the org that the user is a member of,
// then new and existing users can be redirected here after completing the SSO flow (and 2FA if enabled).
// First we must determine user type (new or existing):
// New User
// - present user with option to remember the device or not (trust the device)
// - present a continue button to proceed to the vault
// - loadNewUserData() --> will need to load enrollment status and user email address.
// Existing User
// - Determine if user is an admin with access to account recovery in admin console
// - Determine if user has a MP or not, if not, they must be redirected to set one (see PM-1035)
// - Determine if device is trusted or not via device crypto service (method not yet written)
// - If not trusted, present user with login decryption options (approve from other device, approve with master password, request admin approval)
// - loadUntrustedDeviceData()
} catch (err) {
this.validationService.showError(err);
}
}
private async setRememberDeviceDefaultValue() {
const rememberDeviceFromState = await this.deviceTrustCryptoService.getShouldTrustDevice();
const rememberDevice = rememberDeviceFromState ?? true;
this.rememberDevice.setValue(rememberDevice);
}
private setupRememberDeviceValueChanges() {
this.rememberDevice.valueChanges
.pipe(
switchMap((value) =>
defer(() => this.deviceTrustCryptoService.setShouldTrustDevice(value))
),
takeUntil(this.destroy$)
)
.subscribe();
}
async loadNewUserData() {
const autoEnrollStatus$ = defer(() =>
this.stateService.getUserSsoOrganizationIdentifier()
).pipe(
switchMap((organizationIdentifier) => {
if (organizationIdentifier == undefined) {
return throwError(() => new Error(this.i18nService.t("ssoIdentifierRequired")));
}
return from(this.organizationApiService.getAutoEnrollStatus(organizationIdentifier));
}),
catchError((err: unknown) => {
this.validationService.showError(err);
return of(undefined);
})
);
const email$ = from(this.stateService.getEmail()).pipe(
catchError((err: unknown) => {
this.validationService.showError(err);
return of(undefined);
}),
takeUntil(this.destroy$)
);
const autoEnrollStatus = await firstValueFrom(autoEnrollStatus$);
const email = await firstValueFrom(email$);
this.data = { state: State.NewUser, organizationId: autoEnrollStatus.id, userEmail: email };
this.loading = false;
}
loadUntrustedDeviceData(accountDecryptionOptions: AccountDecryptionOptions) {
this.loading = true;
const email$ = from(this.stateService.getEmail()).pipe(
catchError((err: unknown) => {
this.validationService.showError(err);
return of(undefined);
}),
takeUntil(this.destroy$)
);
email$
.pipe(
takeUntil(this.destroy$),
finalize(() => {
this.loading = false;
})
)
.subscribe((email) => {
const showApproveFromOtherDeviceBtn =
accountDecryptionOptions?.trustedDeviceOption?.hasLoginApprovingDevice || false;
const showReqAdminApprovalBtn =
!!accountDecryptionOptions?.trustedDeviceOption?.hasAdminApproval || false;
const showApproveWithMasterPasswordBtn =
accountDecryptionOptions?.hasMasterPassword || false;
const userEmail = email;
this.data = {
state: State.ExistingUserUntrustedDevice,
showApproveFromOtherDeviceBtn,
showReqAdminApprovalBtn,
showApproveWithMasterPasswordBtn,
userEmail,
};
});
}
async approveFromOtherDevice() {
if (this.data.state !== State.ExistingUserUntrustedDevice) {
return;
}
this.loginService.setEmail(this.data.userEmail);
this.router.navigate(["/login-with-device"]);
}
async requestAdminApproval() {
this.loginService.setEmail(this.data.userEmail);
this.router.navigate(["/admin-approval-requested"]);
}
async approveWithMasterPassword() {
this.router.navigate(["/lock"], { queryParams: { from: "login-initiated" } });
}
async createUser() {
if (this.data.state !== State.NewUser) {
return;
}
// this.loading to support clients without async-actions-support
this.loading = true;
try {
const { publicKey, privateKey } = await this.cryptoService.initAccount();
const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString);
await this.apiService.postAccountKeys(keysRequest);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("accountSuccessfullyCreated")
);
await this.passwordResetEnrollmentService.enroll(this.data.organizationId);
if (this.rememberDeviceForm.value.rememberDevice) {
await this.deviceTrustCryptoService.trustDevice();
}
} catch (error) {
this.validationService.showError(error);
} finally {
this.loading = false;
}
}
logOut() {
this.loading = true; // to avoid an awkward delay in browser extension
this.messagingService.send("logout");
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -12,10 +12,10 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { MasterKey, UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { DialogService } from "@bitwarden/components";
import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog";
import { PasswordColorText } from "../../shared/components/password-strength/password-strength.component";
@Directive()
@@ -44,7 +44,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
protected platformUtilsService: PlatformUtilsService,
protected policyService: PolicyService,
protected stateService: StateService,
protected dialogService: DialogServiceAbstraction
protected dialogService: DialogService
) {}
async ngOnInit() {
@@ -79,23 +79,28 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
if (this.kdfConfig == null) {
this.kdfConfig = await this.stateService.getKdfConfig();
}
const key = await this.cryptoService.makeKey(
// Create new master key
const newMasterKey = await this.cryptoService.makeMasterKey(
this.masterPassword,
email.trim().toLowerCase(),
this.kdf,
this.kdfConfig
);
const masterPasswordHash = await this.cryptoService.hashPassword(this.masterPassword, key);
const newMasterKeyHash = await this.cryptoService.hashMasterKey(
this.masterPassword,
newMasterKey
);
let encKey: [SymmetricCryptoKey, EncString] = null;
const existingEncKey = await this.cryptoService.getEncKey();
if (existingEncKey == null) {
encKey = await this.cryptoService.makeEncKey(key);
let newProtectedUserKey: [UserKey, EncString] = null;
const userKey = await this.cryptoService.getUserKey();
if (userKey == null) {
newProtectedUserKey = await this.cryptoService.makeUserKey(newMasterKey);
} else {
encKey = await this.cryptoService.remakeEncKey(key);
newProtectedUserKey = await this.cryptoService.encryptUserKeyWithMasterKey(newMasterKey);
}
await this.performSubmitActions(masterPasswordHash, key, encKey);
await this.performSubmitActions(newMasterKeyHash, newMasterKey, newProtectedUserKey);
}
async setupSubmitActions(): Promise<boolean> {
@@ -105,9 +110,9 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
}
async performSubmitActions(
masterPasswordHash: string,
key: SymmetricCryptoKey,
encKey: [SymmetricCryptoKey, EncString]
newMasterKeyHash: string,
newMasterKey: MasterKey,
newUserKey: [UserKey, EncString]
) {
// Override in sub-class
}
@@ -162,7 +167,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
const result = await this.dialogService.openSimpleDialog({
title: { key: "weakAndExposedMasterPassword" },
content: { key: "weakAndBreachedMasterPasswordDesc" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!result) {
@@ -173,7 +178,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
const result = await this.dialogService.openSimpleDialog({
title: { key: "weakMasterPassword" },
content: { key: "weakMasterPasswordDesc" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!result) {
@@ -184,7 +189,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
const result = await this.dialogService.openSimpleDialog({
title: { key: "exposedMasterPassword" },
content: { key: "exposedMasterPasswordDesc" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!result) {
@@ -201,7 +206,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
acceptButtonText: { key: "logOut" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (confirmed) {

View File

@@ -15,7 +15,7 @@
"euDomain" | i18n
}}</label>
<label *ngSwitchCase="ServerEnvironmentType.SelfHosted" class="text-primary">{{
"selfHosted" | i18n
"selfHostedServer" | i18n
}}</label>
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
</a>
@@ -44,7 +44,6 @@
selectedEnvironment === ServerEnvironmentType.US ? 'visible' : 'hidden'
"
></i>
<img class="img-us" alt="" />
<span>{{ "usDomain" | i18n }}</span>
</button>
<br />
@@ -62,7 +61,6 @@
selectedEnvironment === ServerEnvironmentType.EU ? 'visible' : 'hidden'
"
></i>
<img class="img-eu" alt="" />
<span>{{ "euDomain" | i18n }}</span>
</button>
<br *ngIf="euServerFlagEnabled" />
@@ -79,12 +77,7 @@
selectedEnvironment === ServerEnvironmentType.SelfHosted ? 'visible' : 'hidden'
"
></i>
<i
class="bwi bwi-fw bwi-md bwi-pencil-square"
style="padding-bottom: 1px; margin-right: 5px"
aria-hidden="true"
></i>
<span>{{ "selfHosted" | i18n }}</span>
<span>{{ "selfHostedServer" | i18n }}</span>
</button>
</div>
</div>

View File

@@ -89,7 +89,7 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
async updateEnvironmentInfo() {
this.selectedEnvironment = this.environmentService.selectedRegion;
this.euServerFlagEnabled = await this.configService.getFeatureFlagBool(
this.euServerFlagEnabled = await this.configService.getFeatureFlag<boolean>(
FeatureFlag.DisplayEuEnvironmentFlag
);
}

View File

@@ -4,16 +4,18 @@ import { firstValueFrom, Subject } from "rxjs";
import { concatMap, take, takeUntil } from "rxjs/operators";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
import { HashPurpose, KeySuffixOptions } from "@bitwarden/common/enums";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -23,10 +25,10 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PinLockType } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog";
import { DialogService } from "@bitwarden/components";
@Directive()
export class LockComponent implements OnInit, OnDestroy {
@@ -34,20 +36,20 @@ export class LockComponent implements OnInit, OnDestroy {
pin = "";
showPassword = false;
email: string;
pinLock = false;
pinEnabled = false;
masterPasswordEnabled = false;
webVaultHostname = "";
formPromise: Promise<MasterPasswordPolicyResponse>;
supportsBiometric: boolean;
biometricLock: boolean;
biometricText: string;
hideInput: boolean;
protected successRoute = "vault";
protected forcePasswordResetRoute = "update-temp-password";
protected onSuccessfulSubmit: () => Promise<void>;
private invalidPinAttempts = 0;
private pinSet: [boolean, boolean];
private pinStatus: PinLockType;
private enforcedMasterPasswordOptions: MasterPasswordPolicyOptions = undefined;
@@ -65,12 +67,13 @@ export class LockComponent implements OnInit, OnDestroy {
protected stateService: StateService,
protected apiService: ApiService,
protected logService: LogService,
private keyConnectorService: KeyConnectorService,
protected ngZone: NgZone,
protected policyApiService: PolicyApiServiceAbstraction,
protected policyService: InternalPolicyService,
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
protected dialogService: DialogServiceAbstraction
protected dialogService: DialogService,
protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
protected userVerificationService: UserVerificationService
) {}
async ngOnInit() {
@@ -90,7 +93,7 @@ export class LockComponent implements OnInit, OnDestroy {
}
async submit() {
if (this.pinLock) {
if (this.pinEnabled) {
return await this.handlePinRequiredUnlock();
}
@@ -102,7 +105,7 @@ export class LockComponent implements OnInit, OnDestroy {
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
acceptButtonText: { key: "logOut" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (confirmed) {
@@ -115,18 +118,18 @@ export class LockComponent implements OnInit, OnDestroy {
return;
}
const success = (await this.cryptoService.getKey(KeySuffixOptions.Biometric)) != null;
const userKey = await this.cryptoService.getUserKeyFromStorage(KeySuffixOptions.Biometric);
if (success) {
await this.doContinue(false);
if (userKey) {
await this.setUserKeyAndContinue(userKey, false);
}
return success;
return !!userKey;
}
togglePassword() {
this.showPassword = !this.showPassword;
const input = document.getElementById(this.pinLock ? "pin" : "masterPassword");
const input = document.getElementById(this.pinEnabled ? "pin" : "masterPassword");
if (this.ngZone.isStable) {
input.focus();
} else {
@@ -152,25 +155,58 @@ export class LockComponent implements OnInit, OnDestroy {
try {
const kdf = await this.stateService.getKdfType();
const kdfConfig = await this.stateService.getKdfConfig();
if (this.pinSet[0]) {
const key = await this.cryptoService.makeKeyFromPin(
let userKeyPin: EncString;
let oldPinKey: EncString;
switch (this.pinStatus) {
case "PERSISTANT": {
userKeyPin = await this.stateService.getPinKeyEncryptedUserKey();
const oldEncryptedPinKey = await this.stateService.getEncryptedPinProtected();
oldPinKey = oldEncryptedPinKey ? new EncString(oldEncryptedPinKey) : undefined;
break;
}
case "TRANSIENT": {
userKeyPin = await this.stateService.getPinKeyEncryptedUserKeyEphemeral();
oldPinKey = await this.stateService.getDecryptedPinProtected();
break;
}
case "DISABLED": {
throw new Error("Pin is disabled");
}
default: {
const _exhaustiveCheck: never = this.pinStatus;
return _exhaustiveCheck;
}
}
let userKey: UserKey;
if (oldPinKey) {
userKey = await this.cryptoService.decryptAndMigrateOldPinKey(
this.pinStatus === "TRANSIENT",
this.pin,
this.email,
kdf,
kdfConfig,
await this.stateService.getDecryptedPinProtected()
oldPinKey
);
const encKey = await this.cryptoService.getEncKey(key);
const protectedPin = await this.stateService.getProtectedPin();
const decPin = await this.cryptoService.decryptToUtf8(new EncString(protectedPin), encKey);
failed = decPin !== this.pin;
if (!failed) {
await this.setKeyAndContinue(key);
}
} else {
const key = await this.cryptoService.makeKeyFromPin(this.pin, this.email, kdf, kdfConfig);
failed = false;
await this.setKeyAndContinue(key);
userKey = await this.cryptoService.decryptUserKeyWithPin(
this.pin,
this.email,
kdf,
kdfConfig,
userKeyPin
);
}
const protectedPin = await this.stateService.getProtectedPin();
const decryptedPin = await this.cryptoService.decryptToUtf8(
new EncString(protectedPin),
userKey
);
failed = decryptedPin !== this.pin;
if (!failed) {
await this.setUserKeyAndContinue(userKey);
}
} catch {
failed = true;
@@ -206,18 +242,28 @@ export class LockComponent implements OnInit, OnDestroy {
const kdf = await this.stateService.getKdfType();
const kdfConfig = await this.stateService.getKdfConfig();
const key = await this.cryptoService.makeKey(this.masterPassword, this.email, kdf, kdfConfig);
const storedKeyHash = await this.cryptoService.getKeyHash();
const masterKey = await this.cryptoService.makeMasterKey(
this.masterPassword,
this.email,
kdf,
kdfConfig
);
const storedPasswordHash = await this.cryptoService.getMasterKeyHash();
let passwordValid = false;
if (storedKeyHash != null) {
passwordValid = await this.cryptoService.compareAndUpdateKeyHash(this.masterPassword, key);
} else {
const request = new SecretVerificationRequest();
const serverKeyHash = await this.cryptoService.hashPassword(
if (storedPasswordHash != null) {
// Offline unlock possible
passwordValid = await this.cryptoService.compareAndUpdateKeyHash(
this.masterPassword,
key,
masterKey
);
} else {
// Online only
const request = new SecretVerificationRequest();
const serverKeyHash = await this.cryptoService.hashMasterKey(
this.masterPassword,
masterKey,
HashPurpose.ServerAuthorization
);
request.masterPasswordHash = serverKeyHash;
@@ -226,14 +272,16 @@ export class LockComponent implements OnInit, OnDestroy {
const response = await this.formPromise;
this.enforcedMasterPasswordOptions = MasterPasswordPolicyOptions.fromResponse(response);
passwordValid = true;
const localKeyHash = await this.cryptoService.hashPassword(
const localKeyHash = await this.cryptoService.hashMasterKey(
this.masterPassword,
key,
masterKey,
HashPurpose.LocalAuthorization
);
await this.cryptoService.setKeyHash(localKeyHash);
await this.cryptoService.setMasterKeyHash(localKeyHash);
} catch (e) {
this.logService.error(e);
} finally {
this.formPromise = null;
}
}
@@ -246,19 +294,18 @@ export class LockComponent implements OnInit, OnDestroy {
return;
}
if (this.pinSet[0]) {
const protectedPin = await this.stateService.getProtectedPin();
const encKey = await this.cryptoService.getEncKey(key);
const decPin = await this.cryptoService.decryptToUtf8(new EncString(protectedPin), encKey);
const pinKey = await this.cryptoService.makePinKey(decPin, this.email, kdf, kdfConfig);
await this.stateService.setDecryptedPinProtected(
await this.cryptoService.encrypt(key.key, pinKey)
);
}
await this.setKeyAndContinue(key, true);
const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey);
await this.cryptoService.setMasterKey(masterKey);
await this.setUserKeyAndContinue(userKey, true);
}
private async setKeyAndContinue(key: SymmetricCryptoKey, evaluatePasswordAfterUnlock = false) {
await this.cryptoService.setKey(key);
private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) {
await this.cryptoService.setUserKey(key);
// Now that we have a decrypted user key in memory, we can check if we
// need to establish trust on the current device
await this.deviceTrustCryptoService.trustDeviceIfRequired();
await this.doContinue(evaluatePasswordAfterUnlock);
}
@@ -296,24 +343,39 @@ export class LockComponent implements OnInit, OnDestroy {
}
private async load() {
this.pinSet = await this.vaultTimeoutSettingsService.isPinLockSet();
this.pinLock =
(this.pinSet[0] && (await this.stateService.getDecryptedPinProtected()) != null) ||
this.pinSet[1];
// TODO: Investigate PM-3515
// The loading of the lock component works as follows:
// 1. First, is locking a valid timeout action? If not, we will log the user out.
// 2. If locking IS a valid timeout action, we proceed to show the user the lock screen.
// The user will be able to unlock as follows:
// - If they have a PIN set, they will be presented with the PIN input
// - If they have a master password and no PIN, they will be presented with the master password input
// - If they have biometrics enabled, they will be presented with the biometric prompt
const availableVaultTimeoutActions = await firstValueFrom(
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$()
);
const supportsLock = availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock);
if (!supportsLock) {
return await this.vaultTimeoutService.logOut();
}
this.pinStatus = await this.vaultTimeoutSettingsService.isPinLockSet();
let ephemeralPinSet = await this.stateService.getPinKeyEncryptedUserKeyEphemeral();
ephemeralPinSet ||= await this.stateService.getDecryptedPinProtected();
this.pinEnabled =
(this.pinStatus === "TRANSIENT" && !!ephemeralPinSet) || this.pinStatus === "PERSISTANT";
this.masterPasswordEnabled = await this.userVerificationService.hasMasterPassword();
this.supportsBiometric = await this.platformUtilsService.supportsBiometric();
this.biometricLock =
(await this.vaultTimeoutSettingsService.isBiometricLockSet()) &&
((await this.cryptoService.hasKeyStored(KeySuffixOptions.Biometric)) ||
((await this.cryptoService.hasUserKeyStored(KeySuffixOptions.Biometric)) ||
!this.platformUtilsService.supportsSecureStorage());
this.biometricText = await this.stateService.getBiometricText();
this.email = await this.stateService.getEmail();
const usesKeyConnector = await this.keyConnectorService.getUsesKeyConnector();
this.hideInput = usesKeyConnector && !this.pinLock;
// Users with key connector and without biometric or pin has no MP to unlock using
if (usesKeyConnector && !(this.biometricLock || this.pinLock)) {
await this.vaultTimeoutService.logOut();
}
const webVaultUrl = this.environmentService.getWebVaultUrl();
const vaultUrl =

View File

@@ -1,16 +1,22 @@
import { Directive, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { IsActiveMatchOptions, Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { AnonymousHubService } from "@bitwarden/common/abstractions/anonymousHub.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
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 { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason";
import { PasswordlessLogInCredentials } from "@bitwarden/common/auth/models/domain/log-in-credentials";
import { PasswordlessCreateAuthRequest } from "@bitwarden/common/auth/models/request/passwordless-create-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";
@@ -22,17 +28,24 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { CaptchaProtectedComponent } from "./captcha-protected.component";
// TODO: consider renaming this component something like LoginViaAuthReqComponent
enum State {
StandardAuthRequest,
AdminAuthRequest,
}
@Directive()
export class LoginWithDeviceComponent
extends CaptchaProtectedComponent
implements OnInit, OnDestroy
{
private destroy$ = new Subject<void>();
userAuthNStatus: AuthenticationStatus;
email: string;
showResendNotification = false;
passwordlessRequest: PasswordlessCreateAuthRequest;
@@ -42,12 +55,19 @@ export class LoginWithDeviceComponent
onSuccessfulLoginNavigate: () => Promise<any>;
onSuccessfulLoginForceResetNavigate: () => Promise<any>;
protected adminApprovalRoute = "admin-approval-requested";
protected StateEnum = State;
protected state = State.StandardAuthRequest;
protected twoFactorRoute = "2fa";
protected successRoute = "vault";
protected forcePasswordResetRoute = "update-temp-password";
private resendTimeout = 12000;
private authRequestKeyPair: [publicKey: Uint8Array, privateKey: Uint8Array];
private authRequestKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array };
// TODO: in future, go to child components and remove child constructors and let deps fall through to the super class
constructor(
protected router: Router,
private cryptoService: CryptoService,
@@ -63,10 +83,14 @@ export class LoginWithDeviceComponent
private anonymousHubService: AnonymousHubService,
private validationService: ValidationService,
private stateService: StateService,
private loginService: LoginService
private loginService: LoginService,
private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
private authReqCryptoService: AuthRequestCryptoServiceAbstraction
) {
super(environmentService, i18nService, platformUtilsService);
// TODO: I don't know why this is necessary.
// Why would the existence of the email depend on the navigation?
const navigation = this.router.getCurrentNavigation();
if (navigation) {
this.email = this.loginService.getEmail();
@@ -77,25 +101,167 @@ export class LoginWithDeviceComponent
.getPushNotificationObs$()
.pipe(takeUntil(this.destroy$))
.subscribe((id) => {
this.confirmResponse(id);
// Only fires on approval currently
this.verifyAndHandleApprovedAuthReq(id);
});
}
async ngOnInit() {
if (!this.email) {
this.router.navigate(["/login"]);
return;
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 this.stateService.getEmail();
if (!this.email) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("userEmailMissing"));
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.stateService.getAdminAuthRequest();
if (adminAuthReqStorable) {
await this.handleExistingAdminAuthRequest(adminAuthReqStorable);
} else {
// No existing admin auth request; so we need to create one
await this.startPasswordlessLogin();
}
} else {
// Standard auth request
// TODO: evaluate if we can remove the setting of this.email in the constructor
this.email = this.loginService.getEmail();
if (!this.email) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("userEmailMissing"));
this.router.navigate(["/login"]);
return;
}
await this.startPasswordlessLogin();
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.anonymousHubService.stopHubConnection();
}
private async handleExistingAdminAuthRequest(adminAuthReqStorable: AdminAuthRequestStorable) {
// 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();
}
}
// Request doesn't exist anymore
if (!adminAuthReqResponse) {
return await this.handleExistingAdminAuthReqDeletedOrDenied();
}
// 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.cryptoService.getFingerprint(this.email, derivedPublicKeyArrayBuffer)
).join("-");
// Request denied
if (adminAuthReqResponse.isAnswered && !adminAuthReqResponse.requestApproved) {
return await this.handleExistingAdminAuthReqDeletedOrDenied();
}
// Request approved
if (adminAuthReqResponse.requestApproved) {
return await this.handleApprovedAdminAuthRequest(
adminAuthReqResponse,
adminAuthReqStorable.privateKey
);
}
// Request still pending response from admin
// So, create hub connection so that any approvals will be received via push notification
this.anonymousHubService.createHubConnection(adminAuthReqStorable.id);
}
private async handleExistingAdminAuthReqDeletedOrDenied() {
// clear the admin auth request from state
await this.stateService.setAdminAuthRequest(null);
// start new auth request
this.startPasswordlessLogin();
}
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({ length: 25 });
this.fingerprintPhrase = (
await this.cryptoService.getFingerprint(this.email, this.authRequestKeyPair.publicKey)
).join("-");
this.passwordlessRequest = new PasswordlessCreateAuthRequest(
this.email,
deviceIdentifier,
publicKey,
authRequestType,
accessCode
);
}
async startPasswordlessLogin() {
this.showResendNotification = false;
try {
await this.buildAuthRequest();
const reqResponse = await this.apiService.postAuthRequest(this.passwordlessRequest);
let reqResponse: AuthRequestResponse;
if (this.state === State.AdminAuthRequest) {
await this.buildAuthRequest(AuthRequestType.AdminApproval);
reqResponse = await this.apiService.postAdminAuthRequest(this.passwordlessRequest);
const adminAuthReqStorable = new AdminAuthRequestStorable({
id: reqResponse.id,
privateKey: this.authRequestKeyPair.privateKey,
});
await this.stateService.setAdminAuthRequest(adminAuthReqStorable);
} else {
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
reqResponse = await this.apiService.postAuthRequest(this.passwordlessRequest);
}
if (reqResponse.id) {
this.anonymousHubService.createHubConnection(reqResponse.id);
@@ -109,52 +275,69 @@ export class LoginWithDeviceComponent
}, this.resendTimeout);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.anonymousHubService.stopHubConnection();
}
private async confirmResponse(requestId: string) {
private async verifyAndHandleApprovedAuthReq(requestId: string) {
try {
const response = await this.apiService.getAuthResponse(
requestId,
this.passwordlessRequest.accessCode
);
// Retrieve the auth request from server and verify it's approved
let authReqResponse: AuthRequestResponse;
if (!response.requestApproved) {
switch (this.state) {
case State.StandardAuthRequest:
// Unauthed - access code required for user verification
authReqResponse = await this.apiService.getAuthResponse(
requestId,
this.passwordlessRequest.accessCode
);
break;
case State.AdminAuthRequest:
// Authed - no access code required
authReqResponse = await this.apiService.getAuthRequest(requestId);
break;
default:
break;
}
if (!authReqResponse.requestApproved) {
return;
}
const credentials = await this.buildLoginCredentials(requestId, response);
const loginResponse = await this.authService.logIn(credentials);
// Approved so proceed:
if (loginResponse.requiresTwoFactor) {
if (this.onSuccessfulLoginTwoFactorNavigate != null) {
this.onSuccessfulLoginTwoFactorNavigate();
} else {
this.router.navigate([this.twoFactorRoute]);
}
} else if (loginResponse.forcePasswordReset != ForceResetPasswordReason.None) {
if (this.onSuccessfulLoginForceResetNavigate != null) {
this.onSuccessfulLoginForceResetNavigate();
} else {
this.router.navigate([this.forcePasswordResetRoute]);
}
} else {
await this.setRememberEmailValues();
if (this.onSuccessfulLogin != null) {
this.onSuccessfulLogin();
}
if (this.onSuccessfulLoginNavigate != null) {
this.onSuccessfulLoginNavigate();
} else {
this.router.navigate([this.successRoute]);
}
// 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) {
return await this.handleApprovedAdminAuthRequest(
authReqResponse,
this.authRequestKeyPair.privateKey
);
}
// Flow 1 and 4:
const loginAuthResult = await this.loginViaPasswordlessStrategy(requestId, authReqResponse);
await this.handlePostLoginNavigation(loginAuthResult);
} catch (error) {
if (error instanceof ErrorResponse) {
this.router.navigate(["/login"]);
let errorRoute = "/login";
if (this.state === State.AdminAuthRequest) {
errorRoute = "/login-initiated";
}
this.router.navigate([errorRoute]);
this.validationService.showError(error);
return;
}
@@ -163,6 +346,112 @@ export class LoginWithDeviceComponent
}
}
async handleApprovedAdminAuthRequest(
adminAuthReqResponse: AuthRequestResponse,
privateKey: ArrayBuffer
) {
// 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.authReqCryptoService.setKeysAfterDecryptingSharedMasterKeyAndHash(
adminAuthReqResponse,
privateKey
);
} 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.authReqCryptoService.setUserKeyAfterDecryptingSharedUserKey(
adminAuthReqResponse,
privateKey
);
}
// 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.stateService.setAdminAuthRequest(null);
this.platformUtilsService.showToast("success", null, 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
await this.deviceTrustCryptoService.trustDeviceIfRequired();
// TODO: don't forget to use auto enrollment service everywhere we trust device
await this.handleSuccessfulLoginNavigation();
}
// Authentication helper
private async buildPasswordlessLoginCredentials(
requestId: string,
response: AuthRequestResponse
): Promise<PasswordlessLogInCredentials> {
// 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.authReqCryptoService.decryptPubKeyEncryptedMasterKeyAndHash(
response.key,
response.masterPasswordHash,
this.authRequestKeyPair.privateKey
);
return new PasswordlessLogInCredentials(
this.email,
this.passwordlessRequest.accessCode,
requestId,
null, // no userKey
masterKey,
masterKeyHash
);
} else {
const userKey = await this.authReqCryptoService.decryptPubKeyEncryptedUserKey(
response.key,
this.authRequestKeyPair.privateKey
);
return new PasswordlessLogInCredentials(
this.email,
this.passwordlessRequest.accessCode,
requestId,
userKey,
null, // no masterKey
null // no masterKeyHash
);
}
}
private async loginViaPasswordlessStrategy(
requestId: string,
authReqResponse: AuthRequestResponse
): Promise<AuthResult> {
// Note: credentials change based on if the authReqResponse.key is a encryptedMasterKey or UserKey
const credentials = await this.buildPasswordlessLoginCredentials(requestId, authReqResponse);
// Note: keys are set by PasswordlessLogInStrategy success handling
return await this.authService.logIn(credentials);
}
// Routing logic
private async handlePostLoginNavigation(loginResponse: AuthResult) {
if (loginResponse.requiresTwoFactor) {
if (this.onSuccessfulLoginTwoFactorNavigate != null) {
this.onSuccessfulLoginTwoFactorNavigate();
} else {
this.router.navigate([this.twoFactorRoute]);
}
} else if (loginResponse.forcePasswordReset != ForceResetPasswordReason.None) {
if (this.onSuccessfulLoginForceResetNavigate != null) {
this.onSuccessfulLoginForceResetNavigate();
} else {
this.router.navigate([this.forcePasswordResetRoute]);
}
} else {
await this.handleSuccessfulLoginNavigation();
}
}
async setRememberEmailValues() {
const rememberEmail = this.loginService.getRememberEmail();
const rememberedEmail = this.loginService.getEmail();
@@ -170,43 +459,19 @@ export class LoginWithDeviceComponent
this.loginService.clearValues();
}
private async buildAuthRequest() {
this.authRequestKeyPair = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
const deviceIdentifier = await this.appIdService.getAppId();
const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair[0]);
const accessCode = await this.passwordGenerationService.generatePassword({ length: 25 });
private async handleSuccessfulLoginNavigation() {
if (this.state === State.StandardAuthRequest) {
// Only need to set remembered email on standard login with auth req flow
await this.setRememberEmailValues();
}
this.fingerprintPhrase = (
await this.cryptoService.getFingerprint(this.email, this.authRequestKeyPair[0])
).join("-");
this.passwordlessRequest = new PasswordlessCreateAuthRequest(
this.email,
deviceIdentifier,
publicKey,
AuthRequestType.AuthenticateAndUnlock,
accessCode
);
}
private async buildLoginCredentials(
requestId: string,
response: AuthRequestResponse
): Promise<PasswordlessLogInCredentials> {
const decKey = await this.cryptoService.rsaDecrypt(response.key, this.authRequestKeyPair[1]);
const decMasterPasswordHash = await this.cryptoService.rsaDecrypt(
response.masterPasswordHash,
this.authRequestKeyPair[1]
);
const key = new SymmetricCryptoKey(decKey);
const localHashedPassword = Utils.fromBufferToUtf8(decMasterPasswordHash);
return new PasswordlessLogInCredentials(
this.email,
this.passwordlessRequest.accessCode,
requestId,
key,
localHashedPassword
);
if (this.onSuccessfulLogin != null) {
this.onSuccessfulLogin();
}
if (this.onSuccessfulLoginNavigate != null) {
this.onSuccessfulLoginNavigate();
} else {
this.router.navigate([this.successRoute]);
}
}
}

View File

@@ -3,8 +3,8 @@ import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { take } from "rxjs/operators";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices-api.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason";
@@ -42,7 +42,10 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit
formGroup = this.formBuilder.group({
email: ["", [Validators.required, Validators.email]],
masterPassword: ["", [Validators.required, Validators.minLength(8)]],
masterPassword: [
"",
[Validators.required, Validators.minLength(Utils.originalMinimumPasswordLength)],
],
rememberEmail: [false],
});
@@ -278,6 +281,8 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit
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));
}

View File

@@ -8,8 +8,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog";
import { DialogService } from "@bitwarden/components";
@Directive()
export class RemovePasswordComponent implements OnInit {
@@ -29,7 +28,7 @@ export class RemovePasswordComponent implements OnInit {
private i18nService: I18nService,
private keyConnectorService: KeyConnectorService,
private organizationApiService: OrganizationApiServiceAbstraction,
private dialogService: DialogServiceAbstraction
private dialogService: DialogService
) {}
async ngOnInit() {
@@ -61,7 +60,7 @@ export class RemovePasswordComponent implements OnInit {
const confirmed = await this.dialogService.openSimpleDialog({
title: this.organization.name,
content: { key: "leaveOrganizationConfirmation" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!confirmed) {

View File

@@ -0,0 +1,567 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, Router } from "@angular/router";
import { MockProxy, mock } from "jest-mock-extended";
import { Observable, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason";
import { KeyConnectorUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/key-connector-user-decryption-option";
import { TrustedDeviceUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
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 { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { SsoComponent } from "./sso.component";
// test component that extends the SsoComponent
@Component({})
class TestSsoComponent extends SsoComponent {}
interface SsoComponentProtected {
twoFactorRoute: string;
successRoute: string;
trustedDeviceEncRoute: string;
changePasswordRoute: string;
forcePasswordResetRoute: string;
logIn(code: string, codeVerifier: string, orgIdFromState: string): Promise<AuthResult>;
handleLoginError(e: any): Promise<void>;
}
// The ideal scenario would be to not have to test the protected / private methods of the SsoComponent
// but that will require a refactor of the SsoComponent class which is out of scope for now.
// This test suite allows us to be sure that the new Trusted Device encryption flows + mild refactors
// of the SsoComponent don't break the existing post login flows.
describe("SsoComponent", () => {
let component: TestSsoComponent;
let _component: SsoComponentProtected;
let fixture: ComponentFixture<TestSsoComponent>;
// Mock Services
let mockAuthService: MockProxy<AuthService>;
let mockRouter: MockProxy<Router>;
let mockI18nService: MockProxy<I18nService>;
let mockQueryParams: Observable<any>;
let mockActivatedRoute: ActivatedRoute;
let mockStateService: MockProxy<StateService>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
let mockApiService: MockProxy<ApiService>;
let mockCryptoFunctionService: MockProxy<CryptoFunctionService>;
let mockEnvironmentService: MockProxy<EnvironmentService>;
let mockPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
let mockLogService: MockProxy<LogService>;
let mockConfigService: MockProxy<ConfigServiceAbstraction>;
// Mock authService.logIn params
let code: string;
let codeVerifier: string;
let orgIdFromState: string;
// Mock component callbacks
let mockOnSuccessfulLogin: jest.Mock;
let mockOnSuccessfulLoginNavigate: jest.Mock;
let mockOnSuccessfulLoginTwoFactorNavigate: jest.Mock;
let mockOnSuccessfulLoginChangePasswordNavigate: jest.Mock;
let mockOnSuccessfulLoginForceResetNavigate: jest.Mock;
let mockOnSuccessfulLoginTdeNavigate: jest.Mock;
let mockAcctDecryptionOpts: {
noMasterPassword: AccountDecryptionOptions;
withMasterPassword: AccountDecryptionOptions;
withMasterPasswordAndTrustedDevice: AccountDecryptionOptions;
withMasterPasswordAndTrustedDeviceWithManageResetPassword: AccountDecryptionOptions;
withMasterPasswordAndKeyConnector: AccountDecryptionOptions;
noMasterPasswordWithTrustedDevice: AccountDecryptionOptions;
noMasterPasswordWithTrustedDeviceWithManageResetPassword: AccountDecryptionOptions;
noMasterPasswordWithKeyConnector: AccountDecryptionOptions;
};
beforeEach(() => {
// Mock Services
mockAuthService = mock<AuthService>();
mockRouter = mock<Router>();
mockI18nService = mock<I18nService>();
// Default mockQueryParams
mockQueryParams = of({ code: "code", state: "state" });
// Create a custom mock for ActivatedRoute with mock queryParams
mockActivatedRoute = {
queryParams: mockQueryParams,
} as any as ActivatedRoute;
mockStateService = mock<StateService>();
mockPlatformUtilsService = mock<PlatformUtilsService>();
mockApiService = mock<ApiService>();
mockCryptoFunctionService = mock<CryptoFunctionService>();
mockEnvironmentService = mock<EnvironmentService>();
mockPasswordGenerationService = mock<PasswordGenerationServiceAbstraction>();
mockLogService = mock<LogService>();
mockConfigService = mock<ConfigServiceAbstraction>();
// Mock authService.logIn params
code = "code";
codeVerifier = "codeVerifier";
orgIdFromState = "orgIdFromState";
// Mock component callbacks
mockOnSuccessfulLogin = jest.fn();
mockOnSuccessfulLoginNavigate = jest.fn();
mockOnSuccessfulLoginTwoFactorNavigate = jest.fn();
mockOnSuccessfulLoginChangePasswordNavigate = jest.fn();
mockOnSuccessfulLoginForceResetNavigate = jest.fn();
mockOnSuccessfulLoginTdeNavigate = jest.fn();
mockAcctDecryptionOpts = {
noMasterPassword: new AccountDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: undefined,
keyConnectorOption: undefined,
}),
withMasterPassword: new AccountDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: undefined,
keyConnectorOption: undefined,
}),
withMasterPasswordAndTrustedDevice: new AccountDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false),
keyConnectorOption: undefined,
}),
withMasterPasswordAndTrustedDeviceWithManageResetPassword: new AccountDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true),
keyConnectorOption: undefined,
}),
withMasterPasswordAndKeyConnector: new AccountDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: undefined,
keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"),
}),
noMasterPasswordWithTrustedDevice: new AccountDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false),
keyConnectorOption: undefined,
}),
noMasterPasswordWithTrustedDeviceWithManageResetPassword: new AccountDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true),
keyConnectorOption: undefined,
}),
noMasterPasswordWithKeyConnector: new AccountDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: undefined,
keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"),
}),
};
TestBed.configureTestingModule({
declarations: [TestSsoComponent],
providers: [
{ provide: AuthService, useValue: mockAuthService },
{ provide: Router, useValue: mockRouter },
{ provide: I18nService, useValue: mockI18nService },
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: StateService, useValue: mockStateService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: ApiService, useValue: mockApiService },
{ provide: CryptoFunctionService, useValue: mockCryptoFunctionService },
{ provide: EnvironmentService, useValue: mockEnvironmentService },
{ provide: PasswordGenerationServiceAbstraction, useValue: mockPasswordGenerationService },
{ provide: LogService, useValue: mockLogService },
{ provide: ConfigServiceAbstraction, useValue: mockConfigService },
],
});
fixture = TestBed.createComponent(TestSsoComponent);
component = fixture.componentInstance;
_component = component as any;
});
afterEach(() => {
// Reset all mocks after each test
jest.resetAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("navigateViaCallbackOrRoute(...)", () => {
it("calls the provided callback when callback is defined", async () => {
const callback = jest.fn().mockResolvedValue(null);
const commands = ["some", "route"];
await (component as any).navigateViaCallbackOrRoute(callback, commands);
expect(callback).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).not.toHaveBeenCalled();
});
it("calls router.navigate when callback is not defined", async () => {
const commands = ["some", "route"];
await (component as any).navigateViaCallbackOrRoute(undefined, commands);
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(commands, undefined);
});
});
describe("logIn(...)", () => {
describe("2FA scenarios", () => {
beforeEach(() => {
const authResult = new AuthResult();
authResult.twoFactorProviders = new Map([[TwoFactorProviderType.Authenticator, {}]]);
// use standard user with MP because this test is not concerned with password reset.
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.withMasterPassword
);
mockAuthService.logIn.mockResolvedValue(authResult);
});
it("calls authService.logIn and navigates to the component's defined 2FA route when the auth result requires 2FA and onSuccessfulLoginTwoFactorNavigate is not defined", async () => {
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockAuthService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginTwoFactorNavigate).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.twoFactorRoute], {
queryParams: {
identifier: orgIdFromState,
sso: "true",
},
});
expect(mockLogService.error).not.toHaveBeenCalled();
});
it("calls onSuccessfulLoginTwoFactorNavigate instead of router.navigate when response.requiresTwoFactor is true and the callback is defined", async () => {
mockOnSuccessfulLoginTwoFactorNavigate = jest.fn().mockResolvedValue(null);
component.onSuccessfulLoginTwoFactorNavigate = mockOnSuccessfulLoginTwoFactorNavigate;
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockAuthService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginTwoFactorNavigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(mockLogService.error).not.toHaveBeenCalled();
});
});
// Shared test helpers
const testChangePasswordOnSuccessfulLogin = () => {
it("navigates to the component's defined change password route when onSuccessfulLoginChangePasswordNavigate callback is undefined", async () => {
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockAuthService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginChangePasswordNavigate).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.changePasswordRoute], {
queryParams: {
identifier: orgIdFromState,
},
});
expect(mockLogService.error).not.toHaveBeenCalled();
});
};
const testOnSuccessfulLoginChangePasswordNavigate = () => {
it("calls onSuccessfulLoginChangePasswordNavigate instead of router.navigate when the callback is defined", async () => {
mockOnSuccessfulLoginChangePasswordNavigate = jest.fn().mockResolvedValue(null);
component.onSuccessfulLoginChangePasswordNavigate =
mockOnSuccessfulLoginChangePasswordNavigate;
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockAuthService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginChangePasswordNavigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(mockLogService.error).not.toHaveBeenCalled();
});
};
const testForceResetOnSuccessfulLogin = (reasonString: string) => {
it(`navigates to the component's defined forcePasswordResetRoute when response.forcePasswordReset is ${reasonString}`, async () => {
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockAuthService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginForceResetNavigate).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.forcePasswordResetRoute], {
queryParams: {
identifier: orgIdFromState,
},
});
expect(mockLogService.error).not.toHaveBeenCalled();
});
};
const testOnSuccessfulLoginForceResetNavigate = (reasonString: string) => {
it(`calls onSuccessfulLoginForceResetNavigate instead of router.navigate when response.forcePasswordReset is ${reasonString} and the callback is defined`, async () => {
mockOnSuccessfulLoginForceResetNavigate = jest.fn().mockResolvedValue(null);
component.onSuccessfulLoginForceResetNavigate = mockOnSuccessfulLoginForceResetNavigate;
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockAuthService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginForceResetNavigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(mockLogService.error).not.toHaveBeenCalled();
});
};
describe("Trusted Device Encryption scenarios", () => {
beforeEach(() => {
mockConfigService.getFeatureFlag.mockResolvedValue(true); // TDE enabled
});
describe("Given Trusted Device Encryption is enabled and user needs to set a master password", () => {
let authResult;
beforeEach(() => {
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword
);
authResult = new AuthResult();
mockAuthService.logIn.mockResolvedValue(authResult);
});
testChangePasswordOnSuccessfulLogin();
testOnSuccessfulLoginChangePasswordNavigate();
});
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => {
[
ForceResetPasswordReason.AdminForcePasswordReset,
ForceResetPasswordReason.WeakMasterPassword,
].forEach((forceResetPasswordReason) => {
const reasonString = ForceResetPasswordReason[forceResetPasswordReason];
let authResult;
beforeEach(() => {
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.withMasterPasswordAndTrustedDevice
);
authResult = new AuthResult();
authResult.forcePasswordReset = ForceResetPasswordReason.AdminForcePasswordReset;
mockAuthService.logIn.mockResolvedValue(authResult);
});
testForceResetOnSuccessfulLogin(reasonString);
testOnSuccessfulLoginForceResetNavigate(reasonString);
});
});
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => {
let authResult;
beforeEach(() => {
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.withMasterPasswordAndTrustedDevice
);
authResult = new AuthResult();
authResult.forcePasswordReset = ForceResetPasswordReason.None;
mockAuthService.logIn.mockResolvedValue(authResult);
});
it("navigates to the component's defined trusted device encryption route when login is successful and no callback is defined", async () => {
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockAuthService.logIn).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(
[_component.trustedDeviceEncRoute],
undefined
);
expect(mockLogService.error).not.toHaveBeenCalled();
});
it("calls onSuccessfulLoginTdeNavigate instead of router.navigate when the callback is defined", async () => {
mockOnSuccessfulLoginTdeNavigate = jest.fn().mockResolvedValue(null);
component.onSuccessfulLoginTdeNavigate = mockOnSuccessfulLoginTdeNavigate;
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockAuthService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginTdeNavigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(mockLogService.error).not.toHaveBeenCalled();
});
});
});
describe("Set Master Password scenarios", () => {
beforeEach(() => {
const authResult = new AuthResult();
mockAuthService.logIn.mockResolvedValue(authResult);
});
describe("Given user needs to set a master password", () => {
beforeEach(() => {
// Only need to test the case where the user has no master password to test the primary change mp flow here
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.noMasterPassword
);
});
testChangePasswordOnSuccessfulLogin();
testOnSuccessfulLoginChangePasswordNavigate();
});
it("does not navigate to the change password route when the user has key connector even if user has no master password", async () => {
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.noMasterPasswordWithKeyConnector
);
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockAuthService.logIn).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginChangePasswordNavigate).not.toHaveBeenCalled();
expect(mockRouter.navigate).not.toHaveBeenCalledWith([_component.changePasswordRoute], {
queryParams: {
identifier: orgIdFromState,
},
});
});
});
describe("Force Master Password Reset scenarios", () => {
[
ForceResetPasswordReason.AdminForcePasswordReset,
ForceResetPasswordReason.WeakMasterPassword,
].forEach((forceResetPasswordReason) => {
const reasonString = ForceResetPasswordReason[forceResetPasswordReason];
beforeEach(() => {
// use standard user with MP because this test is not concerned with password reset.
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.withMasterPassword
);
const authResult = new AuthResult();
authResult.forcePasswordReset = forceResetPasswordReason;
mockAuthService.logIn.mockResolvedValue(authResult);
});
testForceResetOnSuccessfulLogin(reasonString);
testOnSuccessfulLoginForceResetNavigate(reasonString);
});
});
describe("Success scenarios", () => {
beforeEach(() => {
const authResult = new AuthResult();
authResult.twoFactorProviders = null;
// use standard user with MP because this test is not concerned with password reset.
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.withMasterPassword
);
authResult.forcePasswordReset = ForceResetPasswordReason.None;
mockAuthService.logIn.mockResolvedValue(authResult);
});
it("calls authService.logIn and navigates to the component's defined success route when the login is successful", async () => {
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockAuthService.logIn).toHaveBeenCalled();
expect(mockOnSuccessfulLoginNavigate).not.toHaveBeenCalled();
expect(mockOnSuccessfulLogin).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined);
expect(mockLogService.error).not.toHaveBeenCalled();
});
it("calls onSuccessfulLogin if defined when login is successful", async () => {
mockOnSuccessfulLogin = jest.fn().mockResolvedValue(null);
component.onSuccessfulLogin = mockOnSuccessfulLogin;
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockAuthService.logIn).toHaveBeenCalled();
expect(mockOnSuccessfulLogin).toHaveBeenCalledTimes(1);
expect(mockOnSuccessfulLoginNavigate).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined);
expect(mockLogService.error).not.toHaveBeenCalled();
});
it("calls onSuccessfulLoginNavigate instead of router.navigate when login is successful and the callback is defined", async () => {
mockOnSuccessfulLoginNavigate = jest.fn().mockResolvedValue(null);
component.onSuccessfulLoginNavigate = mockOnSuccessfulLoginNavigate;
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(mockAuthService.logIn).toHaveBeenCalled();
expect(mockOnSuccessfulLoginNavigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(mockLogService.error).not.toHaveBeenCalled();
});
});
describe("Error scenarios", () => {
it("calls handleLoginError when an error is thrown during logIn", async () => {
const errorMessage = "Key Connector error";
const error = new Error(errorMessage);
mockAuthService.logIn.mockRejectedValue(error);
const handleLoginErrorSpy = jest.spyOn(_component, "handleLoginError");
await _component.logIn(code, codeVerifier, orgIdFromState);
expect(handleLoginErrorSpy).toHaveBeenCalledWith(error);
});
});
});
describe("handleLoginError(e)", () => {
it("logs the error and shows a toast when the error message is 'Key Connector error'", async () => {
const errorMessage = "Key Connector error";
const error = new Error(errorMessage);
mockI18nService.t.mockReturnValueOnce("ssoKeyConnectorError");
await _component.handleLoginError(error);
expect(mockLogService.error).toHaveBeenCalledTimes(1);
expect(mockLogService.error).toHaveBeenCalledWith(error);
expect(mockPlatformUtilsService.showToast).toHaveBeenCalledTimes(1);
expect(mockPlatformUtilsService.showToast).toHaveBeenCalledWith(
"error",
null,
"ssoKeyConnectorError"
);
expect(mockRouter.navigate).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,5 +1,5 @@
import { Directive } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { ActivatedRoute, NavigationExtras, Router } from "@angular/router";
import { first } from "rxjs/operators";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -7,7 +7,10 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason";
import { SsoLogInCredentials } from "@bitwarden/common/auth/models/domain/log-in-credentials";
import { TrustedDeviceUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option";
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
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";
@@ -15,6 +18,7 @@ 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 { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
@Directive()
@@ -24,14 +28,18 @@ export class SsoComponent {
formPromise: Promise<AuthResult>;
initiateSsoFormPromise: Promise<SsoPreValidateResponse>;
onSuccessfulLogin: () => Promise<any>;
onSuccessfulLoginNavigate: () => Promise<any>;
onSuccessfulLoginTwoFactorNavigate: () => Promise<any>;
onSuccessfulLoginChangePasswordNavigate: () => Promise<any>;
onSuccessfulLoginForceResetNavigate: () => Promise<any>;
onSuccessfulLogin: () => Promise<void>;
onSuccessfulLoginNavigate: () => Promise<void>;
onSuccessfulLoginTwoFactorNavigate: () => Promise<void>;
onSuccessfulLoginChangePasswordNavigate: () => Promise<void>;
onSuccessfulLoginForceResetNavigate: () => Promise<void>;
onSuccessfulLoginTde: () => Promise<void>;
onSuccessfulLoginTdeNavigate: () => Promise<void>;
protected twoFactorRoute = "2fa";
protected successRoute = "lock";
protected trustedDeviceEncRoute = "login-initiated";
protected changePasswordRoute = "set-password";
protected forcePasswordResetRoute = "update-temp-password";
protected clientId: string;
@@ -50,7 +58,8 @@ export class SsoComponent {
protected cryptoFunctionService: CryptoFunctionService,
protected environmentService: EnvironmentService,
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
protected logService: LogService
protected logService: LogService,
protected configService: ConfigServiceAbstraction
) {}
async ngOnInit() {
@@ -67,11 +76,12 @@ export class SsoComponent {
state != null &&
this.checkState(state, qParams.state)
) {
await this.logIn(
qParams.code,
codeVerifier,
this.getOrgIdentifierFromState(qParams.state)
);
// We are not using a query param to pass org identifier around specifically
// for the browser SSO case when it needs it on extension open after SSO success
// on the TDE login decryption options component
const ssoOrganizationIdentifier = this.getOrgIdentifierFromState(qParams.state);
await this.logIn(qParams.code, codeVerifier, ssoOrganizationIdentifier);
await this.stateService.setUserSsoOrganizationIdentifier(ssoOrganizationIdentifier);
}
} else if (
qParams.clientId != null &&
@@ -173,67 +183,172 @@ export class SsoComponent {
return authorizeUrl;
}
private async logIn(code: string, codeVerifier: string, orgIdFromState: string) {
private async logIn(code: string, codeVerifier: string, orgIdentifier: string) {
this.loggingIn = true;
try {
const credentials = new SsoLogInCredentials(
code,
codeVerifier,
this.redirectUri,
orgIdFromState
orgIdentifier
);
this.formPromise = this.authService.logIn(credentials);
const response = await this.formPromise;
if (response.requiresTwoFactor) {
if (this.onSuccessfulLoginTwoFactorNavigate != null) {
await this.onSuccessfulLoginTwoFactorNavigate();
} else {
this.router.navigate([this.twoFactorRoute], {
queryParams: {
identifier: orgIdFromState,
sso: "true",
},
});
}
} else if (response.resetMasterPassword) {
if (this.onSuccessfulLoginChangePasswordNavigate != null) {
await this.onSuccessfulLoginChangePasswordNavigate();
} else {
this.router.navigate([this.changePasswordRoute], {
queryParams: {
identifier: orgIdFromState,
},
});
}
} else if (response.forcePasswordReset !== ForceResetPasswordReason.None) {
if (this.onSuccessfulLoginForceResetNavigate != null) {
await this.onSuccessfulLoginForceResetNavigate();
} else {
this.router.navigate([this.forcePasswordResetRoute]);
}
} else {
if (this.onSuccessfulLogin != null) {
await this.onSuccessfulLogin();
}
if (this.onSuccessfulLoginNavigate != null) {
await this.onSuccessfulLoginNavigate();
} else {
this.router.navigate([this.successRoute]);
}
}
} catch (e) {
this.logService.error(e);
const authResult = await this.formPromise;
// TODO: Key Connector Service should pass this error message to the logout callback instead of displaying here
if (e.message === "Key Connector error") {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("ssoKeyConnectorError")
const acctDecryptionOpts: AccountDecryptionOptions =
await this.stateService.getAccountDecryptionOptions();
if (authResult.requiresTwoFactor) {
return await this.handleTwoFactorRequired(orgIdentifier);
}
const tdeEnabled = await this.isTrustedDeviceEncEnabled(
acctDecryptionOpts.trustedDeviceOption
);
if (tdeEnabled) {
return await this.handleTrustedDeviceEncryptionEnabled(
authResult,
orgIdentifier,
acctDecryptionOpts
);
}
// In the standard, non TDE case, a user must set password if they don't
// have one and they aren't using key connector.
// Note: TDE & Key connector are mutually exclusive org config options.
const requireSetPassword =
!acctDecryptionOpts.hasMasterPassword &&
acctDecryptionOpts.keyConnectorOption === undefined;
if (requireSetPassword || authResult.resetMasterPassword) {
// Change implies going no password -> password in this case
return await this.handleChangePasswordRequired(orgIdentifier);
}
// Users can be forced to reset their password via an admin or org policy
// disallowing weak passwords
if (authResult.forcePasswordReset !== ForceResetPasswordReason.None) {
return await this.handleForcePasswordReset(orgIdentifier);
}
// Standard SSO login success case
return await this.handleSuccessfulLogin();
} catch (e) {
await this.handleLoginError(e);
}
}
private async isTrustedDeviceEncEnabled(
trustedDeviceOption: TrustedDeviceUserDecryptionOption
): Promise<boolean> {
const trustedDeviceEncryptionFeatureActive = await this.configService.getFeatureFlag<boolean>(
FeatureFlag.TrustedDeviceEncryption
);
return trustedDeviceEncryptionFeatureActive && trustedDeviceOption !== undefined;
}
private async handleTwoFactorRequired(orgIdentifier: string) {
await this.navigateViaCallbackOrRoute(
this.onSuccessfulLoginTwoFactorNavigate,
[this.twoFactorRoute],
{
queryParams: {
identifier: orgIdentifier,
sso: "true",
},
}
);
}
private async handleTrustedDeviceEncryptionEnabled(
authResult: AuthResult,
orgIdentifier: string,
acctDecryptionOpts: AccountDecryptionOptions
): Promise<void> {
// If user doesn't have a MP, but has reset password permission, they must set a MP
if (
!acctDecryptionOpts.hasMasterPassword &&
acctDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission
) {
// Change implies going no password -> password in this case
return await this.handleChangePasswordRequired(orgIdentifier);
}
if (authResult.forcePasswordReset !== ForceResetPasswordReason.None) {
return await this.handleForcePasswordReset(orgIdentifier);
}
if (this.onSuccessfulLoginTde != null) {
// Don't await b/c causes hang on desktop & browser
this.onSuccessfulLoginTde();
}
this.navigateViaCallbackOrRoute(
this.onSuccessfulLoginTdeNavigate,
// Navigate to TDE page (if user was on trusted device and TDE has decrypted
// their user key, the login-initiated guard will redirect them to the vault)
[this.trustedDeviceEncRoute]
);
}
private async handleChangePasswordRequired(orgIdentifier: string) {
await this.navigateViaCallbackOrRoute(
this.onSuccessfulLoginChangePasswordNavigate,
[this.changePasswordRoute],
{
queryParams: {
identifier: orgIdentifier,
},
}
);
}
private async handleForcePasswordReset(orgIdentifier: string) {
await this.navigateViaCallbackOrRoute(
this.onSuccessfulLoginForceResetNavigate,
[this.forcePasswordResetRoute],
{
queryParams: {
identifier: orgIdentifier,
},
}
);
}
private async handleSuccessfulLogin() {
if (this.onSuccessfulLogin != null) {
// Don't await b/c causes hang on desktop & browser
this.onSuccessfulLogin();
}
await this.navigateViaCallbackOrRoute(this.onSuccessfulLoginNavigate, [this.successRoute]);
}
private async handleLoginError(e: any) {
this.logService.error(e);
// TODO: Key Connector Service should pass this error message to the logout callback instead of displaying here
if (e.message === "Key Connector error") {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("ssoKeyConnectorError")
);
}
}
private async navigateViaCallbackOrRoute(
callback: () => Promise<unknown>,
commands: unknown[],
extras?: NavigationExtras
): Promise<void> {
if (callback) {
await callback();
} else {
await this.router.navigate(commands, extras);
}
this.loggingIn = false;
}
private getOrgIdentifierFromState(state: string): string {

View File

@@ -30,7 +30,7 @@ export class TwoFactorOptionsComponent implements OnInit {
}
recover() {
this.platformUtilsService.launchUri("https://bitwarden.com/help/lost-two-step-device/");
this.platformUtilsService.launchUri("https://vault.bitwarden.com/#/recover-2fa");
this.onRecoverSelected.emit();
}
}

View File

@@ -0,0 +1,451 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, Router, convertToParamMap } from "@angular/router";
import { MockProxy, mock } from "jest-mock-extended";
// eslint-disable-next-line no-restricted-imports
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason";
import { KeyConnectorUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/key-connector-user-decryption-option";
import { TrustedDeviceUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
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 { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account";
import { TwoFactorComponent } from "./two-factor.component";
// test component that extends the TwoFactorComponent
@Component({})
class TestTwoFactorComponent extends TwoFactorComponent {}
interface TwoFactorComponentProtected {
trustedDeviceEncRoute: string;
changePasswordRoute: string;
forcePasswordResetRoute: string;
successRoute: string;
}
describe("TwoFactorComponent", () => {
let component: TestTwoFactorComponent;
let _component: TwoFactorComponentProtected;
let fixture: ComponentFixture<TestTwoFactorComponent>;
// Mock Services
let mockAuthService: MockProxy<AuthService>;
let mockRouter: MockProxy<Router>;
let mockI18nService: MockProxy<I18nService>;
let mockApiService: MockProxy<ApiService>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
let mockWin: MockProxy<Window>;
let mockEnvironmentService: MockProxy<EnvironmentService>;
let mockStateService: MockProxy<StateService>;
let mockLogService: MockProxy<LogService>;
let mockTwoFactorService: MockProxy<TwoFactorService>;
let mockAppIdService: MockProxy<AppIdService>;
let mockLoginService: MockProxy<LoginService>;
let mockConfigService: MockProxy<ConfigServiceAbstraction>;
let mockAcctDecryptionOpts: {
noMasterPassword: AccountDecryptionOptions;
withMasterPassword: AccountDecryptionOptions;
withMasterPasswordAndTrustedDevice: AccountDecryptionOptions;
withMasterPasswordAndTrustedDeviceWithManageResetPassword: AccountDecryptionOptions;
withMasterPasswordAndKeyConnector: AccountDecryptionOptions;
noMasterPasswordWithTrustedDevice: AccountDecryptionOptions;
noMasterPasswordWithTrustedDeviceWithManageResetPassword: AccountDecryptionOptions;
noMasterPasswordWithKeyConnector: AccountDecryptionOptions;
};
beforeEach(() => {
mockAuthService = mock<AuthService>();
mockRouter = mock<Router>();
mockI18nService = mock<I18nService>();
mockApiService = mock<ApiService>();
mockPlatformUtilsService = mock<PlatformUtilsService>();
mockWin = mock<Window>();
mockEnvironmentService = mock<EnvironmentService>();
mockStateService = mock<StateService>();
mockLogService = mock<LogService>();
mockTwoFactorService = mock<TwoFactorService>();
mockAppIdService = mock<AppIdService>();
mockLoginService = mock<LoginService>();
mockConfigService = mock<ConfigServiceAbstraction>();
mockAcctDecryptionOpts = {
noMasterPassword: new AccountDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: undefined,
keyConnectorOption: undefined,
}),
withMasterPassword: new AccountDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: undefined,
keyConnectorOption: undefined,
}),
withMasterPasswordAndTrustedDevice: new AccountDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false),
keyConnectorOption: undefined,
}),
withMasterPasswordAndTrustedDeviceWithManageResetPassword: new AccountDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true),
keyConnectorOption: undefined,
}),
withMasterPasswordAndKeyConnector: new AccountDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: undefined,
keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"),
}),
noMasterPasswordWithTrustedDevice: new AccountDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false),
keyConnectorOption: undefined,
}),
noMasterPasswordWithTrustedDeviceWithManageResetPassword: new AccountDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true),
keyConnectorOption: undefined,
}),
noMasterPasswordWithKeyConnector: new AccountDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: undefined,
keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"),
}),
};
TestBed.configureTestingModule({
declarations: [TestTwoFactorComponent],
providers: [
{ provide: AuthService, useValue: mockAuthService },
{ provide: Router, useValue: mockRouter },
{ provide: I18nService, useValue: mockI18nService },
{ provide: ApiService, useValue: mockApiService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: WINDOW, useValue: mockWin },
{ provide: EnvironmentService, useValue: mockEnvironmentService },
{ provide: StateService, useValue: mockStateService },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
// Default to standard 2FA flow - not SSO + 2FA
queryParamMap: convertToParamMap({ sso: "false" }),
},
},
},
{ provide: LogService, useValue: mockLogService },
{ provide: TwoFactorService, useValue: mockTwoFactorService },
{ provide: AppIdService, useValue: mockAppIdService },
{ provide: LoginService, useValue: mockLoginService },
{ provide: ConfigServiceAbstraction, useValue: mockConfigService },
],
});
fixture = TestBed.createComponent(TestTwoFactorComponent);
component = fixture.componentInstance;
_component = component as any;
});
afterEach(() => {
// Reset all mocks after each test
jest.resetAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
// Shared tests
const testChangePasswordOnSuccessfulLogin = () => {
it("navigates to the component's defined change password route when user doesn't have a MP and key connector isn't enabled", async () => {
// Act
await component.doSubmit();
// Assert
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.changePasswordRoute], {
queryParams: {
identifier: component.orgIdentifier,
},
});
});
};
const testForceResetOnSuccessfulLogin = (reasonString: string) => {
it(`navigates to the component's defined forcePasswordResetRoute route when response.forcePasswordReset is ${reasonString}`, async () => {
// Act
await component.doSubmit();
// expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.forcePasswordResetRoute], {
queryParams: {
identifier: component.orgIdentifier,
},
});
});
};
describe("Standard 2FA scenarios", () => {
describe("doSubmit", () => {
const token = "testToken";
const remember = false;
const captchaToken = "testCaptchaToken";
beforeEach(() => {
component.token = token;
component.remember = remember;
component.captchaToken = captchaToken;
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.withMasterPassword
);
});
it("calls authService.logInTwoFactor with correct parameters when form is submitted", async () => {
// Arrange
mockAuthService.logInTwoFactor.mockResolvedValue(new AuthResult());
// Act
await component.doSubmit();
// Assert
expect(mockAuthService.logInTwoFactor).toHaveBeenCalledWith(
new TokenTwoFactorRequest(component.selectedProviderType, token, remember),
captchaToken
);
});
it("should return when handleCaptchaRequired returns true", async () => {
// Arrange
const captchaSiteKey = "testCaptchaSiteKey";
const authResult = new AuthResult();
authResult.captchaSiteKey = captchaSiteKey;
mockAuthService.logInTwoFactor.mockResolvedValue(authResult);
// Note: the any casts are required b/c typescript cant recognize that
// handleCaptureRequired is a method on TwoFactorComponent b/c it is inherited
// from the CaptchaProtectedComponent
const handleCaptchaRequiredSpy = jest
.spyOn<any, any>(component, "handleCaptchaRequired")
.mockReturnValue(true);
// Act
const result = await component.doSubmit();
// Assert
expect(handleCaptchaRequiredSpy).toHaveBeenCalled();
expect(result).toBeUndefined();
});
it("calls onSuccessfulLogin when defined", async () => {
// Arrange
component.onSuccessfulLogin = jest.fn().mockResolvedValue(undefined);
mockAuthService.logInTwoFactor.mockResolvedValue(new AuthResult());
// Act
await component.doSubmit();
// Assert
expect(component.onSuccessfulLogin).toHaveBeenCalled();
});
it("calls loginService.clearValues() when login is successful", async () => {
// Arrange
mockAuthService.logInTwoFactor.mockResolvedValue(new AuthResult());
// spy on loginService.clearValues
const clearValuesSpy = jest.spyOn(mockLoginService, "clearValues");
// Act
await component.doSubmit();
// Assert
expect(clearValuesSpy).toHaveBeenCalled();
});
describe("Set Master Password scenarios", () => {
beforeEach(() => {
const authResult = new AuthResult();
mockAuthService.logInTwoFactor.mockResolvedValue(authResult);
});
describe("Given user needs to set a master password", () => {
beforeEach(() => {
// Only need to test the case where the user has no master password to test the primary change mp flow here
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.noMasterPassword
);
});
testChangePasswordOnSuccessfulLogin();
});
it("does not navigate to the change password route when the user has key connector even if user has no master password", async () => {
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.noMasterPasswordWithKeyConnector
);
await component.doSubmit();
expect(mockRouter.navigate).not.toHaveBeenCalledWith([_component.changePasswordRoute], {
queryParams: {
identifier: component.orgIdentifier,
},
});
});
});
describe("Force Master Password Reset scenarios", () => {
[
ForceResetPasswordReason.AdminForcePasswordReset,
ForceResetPasswordReason.WeakMasterPassword,
].forEach((forceResetPasswordReason) => {
const reasonString = ForceResetPasswordReason[forceResetPasswordReason];
beforeEach(() => {
// use standard user with MP because this test is not concerned with password reset.
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.withMasterPassword
);
const authResult = new AuthResult();
authResult.forcePasswordReset = forceResetPasswordReason;
mockAuthService.logInTwoFactor.mockResolvedValue(authResult);
});
testForceResetOnSuccessfulLogin(reasonString);
});
});
it("calls onSuccessfulLoginNavigate when the callback is defined", async () => {
// Arrange
component.onSuccessfulLoginNavigate = jest.fn().mockResolvedValue(undefined);
mockAuthService.logInTwoFactor.mockResolvedValue(new AuthResult());
// Act
await component.doSubmit();
// Assert
expect(component.onSuccessfulLoginNavigate).toHaveBeenCalled();
});
it("navigates to the component's defined success route when the login is successful and onSuccessfulLoginNavigate is undefined", async () => {
mockAuthService.logInTwoFactor.mockResolvedValue(new AuthResult());
// Act
await component.doSubmit();
// Assert
expect(component.onSuccessfulLoginNavigate).not.toBeDefined();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined);
});
});
});
describe("SSO > 2FA scenarios", () => {
beforeEach(() => {
const mockActivatedRoute = TestBed.inject(ActivatedRoute);
mockActivatedRoute.snapshot.queryParamMap.get = jest.fn().mockReturnValue("true");
});
describe("doSubmit", () => {
const token = "testToken";
const remember = false;
const captchaToken = "testCaptchaToken";
beforeEach(() => {
component.token = token;
component.remember = remember;
component.captchaToken = captchaToken;
});
describe("Trusted Device Encryption scenarios", () => {
beforeEach(() => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
});
describe("Given Trusted Device Encryption is enabled and user needs to set a master password", () => {
beforeEach(() => {
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword
);
const authResult = new AuthResult();
mockAuthService.logInTwoFactor.mockResolvedValue(authResult);
});
testChangePasswordOnSuccessfulLogin();
});
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => {
[
ForceResetPasswordReason.AdminForcePasswordReset,
ForceResetPasswordReason.WeakMasterPassword,
].forEach((forceResetPasswordReason) => {
const reasonString = ForceResetPasswordReason[forceResetPasswordReason];
beforeEach(() => {
// use standard user with MP because this test is not concerned with password reset.
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.withMasterPasswordAndTrustedDevice
);
const authResult = new AuthResult();
authResult.forcePasswordReset = forceResetPasswordReason;
mockAuthService.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", () => {
let authResult;
beforeEach(() => {
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
mockAcctDecryptionOpts.withMasterPasswordAndTrustedDevice
);
authResult = new AuthResult();
authResult.forcePasswordReset = ForceResetPasswordReason.None;
mockAuthService.logInTwoFactor.mockResolvedValue(authResult);
});
it("navigates to the component's defined trusted device encryption route when login is successful and onSuccessfulLoginTdeNavigate is undefined", async () => {
await component.doSubmit();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(
[_component.trustedDeviceEncRoute],
undefined
);
});
it("calls onSuccessfulLoginTdeNavigate instead of router.navigate when the callback is defined", async () => {
component.onSuccessfulLoginTdeNavigate = jest.fn().mockResolvedValue(undefined);
await component.doSubmit();
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(component.onSuccessfulLoginTdeNavigate).toHaveBeenCalled();
});
});
});
});
});
});

View File

@@ -1,8 +1,10 @@
import { Directive, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { Directive, Inject, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, NavigationExtras, Router } from "@angular/router";
import * as DuoWebSDK from "duo_web_sdk";
import { first } from "rxjs/operators";
// eslint-disable-next-line no-restricted-imports
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
@@ -10,16 +12,20 @@ import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason";
import { TrustedDeviceUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service";
import { WebAuthnIFrame } from "@bitwarden/common/auth/webauthn-iframe";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
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 { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account";
import { CaptchaProtectedComponent } from "./captcha-protected.component";
@@ -38,11 +44,18 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
twoFactorEmail: string = null;
formPromise: Promise<any>;
emailPromise: Promise<any>;
identifier: string = null;
onSuccessfulLogin: () => Promise<any>;
onSuccessfulLoginNavigate: () => Promise<any>;
orgIdentifier: string = null;
onSuccessfulLogin: () => Promise<void>;
onSuccessfulLoginNavigate: () => Promise<void>;
onSuccessfulLoginTde: () => Promise<void>;
onSuccessfulLoginTdeNavigate: () => Promise<void>;
protected loginRoute = "login";
protected trustedDeviceEncRoute = "login-initiated";
protected changePasswordRoute = "set-password";
protected forcePasswordResetRoute = "update-temp-password";
protected successRoute = "vault";
constructor(
@@ -51,14 +64,15 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
protected i18nService: I18nService,
protected apiService: ApiService,
protected platformUtilsService: PlatformUtilsService,
protected win: Window,
@Inject(WINDOW) protected win: Window,
protected environmentService: EnvironmentService,
protected stateService: StateService,
protected route: ActivatedRoute,
protected logService: LogService,
protected twoFactorService: TwoFactorService,
protected appIdService: AppIdService,
protected loginService: LoginService
protected loginService: LoginService,
protected configService: ConfigServiceAbstraction
) {
super(environmentService, i18nService, platformUtilsService);
this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win);
@@ -72,7 +86,7 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
this.route.queryParams.pipe(first()).subscribe((qParams) => {
if (qParams.identifier != null) {
this.identifier = qParams.identifier;
this.orgIdentifier = qParams.identifier;
}
});
@@ -196,32 +210,132 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
new TokenTwoFactorRequest(this.selectedProviderType, this.token, this.remember),
this.captchaToken
);
const response: AuthResult = await this.formPromise;
if (this.handleCaptchaRequired(response)) {
const authResult: AuthResult = await this.formPromise;
await this.handleLoginResponse(authResult);
}
private async handleLoginResponse(authResult: AuthResult) {
if (this.handleCaptchaRequired(authResult)) {
return;
}
if (this.onSuccessfulLogin != null) {
this.loginService.clearValues();
this.loginService.clearValues();
const acctDecryptionOpts: AccountDecryptionOptions =
await this.stateService.getAccountDecryptionOptions();
const tdeEnabled = await this.isTrustedDeviceEncEnabled(acctDecryptionOpts.trustedDeviceOption);
if (tdeEnabled) {
return await this.handleTrustedDeviceEncryptionEnabled(
authResult,
this.orgIdentifier,
acctDecryptionOpts
);
}
// User must set password if they don't have one and they aren't using either TDE or key connector.
const requireSetPassword =
!acctDecryptionOpts.hasMasterPassword && acctDecryptionOpts.keyConnectorOption === undefined;
if (requireSetPassword || authResult.resetMasterPassword) {
// Change implies going no password -> password in this case
return await this.handleChangePasswordRequired(this.orgIdentifier);
}
// Users can be forced to reset their password via an admin or org policy
// disallowing weak passwords
if (authResult.forcePasswordReset !== ForceResetPasswordReason.None) {
return await this.handleForcePasswordReset(this.orgIdentifier);
}
return await this.handleSuccessfulLogin();
}
private async isTrustedDeviceEncEnabled(
trustedDeviceOption: TrustedDeviceUserDecryptionOption
): Promise<boolean> {
const ssoTo2faFlowActive = this.route.snapshot.queryParamMap.get("sso") === "true";
const trustedDeviceEncryptionFeatureActive = await this.configService.getFeatureFlag<boolean>(
FeatureFlag.TrustedDeviceEncryption
);
return (
ssoTo2faFlowActive &&
trustedDeviceEncryptionFeatureActive &&
trustedDeviceOption !== undefined
);
}
private async handleTrustedDeviceEncryptionEnabled(
authResult: AuthResult,
orgIdentifier: string,
acctDecryptionOpts: AccountDecryptionOptions
): Promise<void> {
// If user doesn't have a MP, but has reset password permission, they must set a MP
if (
!acctDecryptionOpts.hasMasterPassword &&
acctDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission
) {
// Change implies going no password -> password in this case
return await this.handleChangePasswordRequired(orgIdentifier);
}
// Users can be forced to reset their password via an admin or org policy
// disallowing weak passwords
if (authResult.forcePasswordReset !== ForceResetPasswordReason.None) {
return await this.handleForcePasswordReset(orgIdentifier);
}
if (this.onSuccessfulLoginTde != null) {
// Note: awaiting this will currently cause a hang on desktop & browser as they will wait for a full sync to complete
// before nagivating to the success route.
// before navigating to the success route.
this.onSuccessfulLoginTde();
}
this.navigateViaCallbackOrRoute(
this.onSuccessfulLoginTdeNavigate,
// Navigate to TDE page (if user was on trusted device and TDE has decrypted
// their user key, the login-initiated guard will redirect them to the vault)
[this.trustedDeviceEncRoute]
);
}
private async handleChangePasswordRequired(orgIdentifier: string) {
await this.router.navigate([this.changePasswordRoute], {
queryParams: {
identifier: orgIdentifier,
},
});
}
private async handleForcePasswordReset(orgIdentifier: string) {
this.router.navigate([this.forcePasswordResetRoute], {
queryParams: {
identifier: orgIdentifier,
},
});
}
private async handleSuccessfulLogin() {
if (this.onSuccessfulLogin != null) {
// Note: awaiting this will currently cause a hang on desktop & browser as they will wait for a full sync to complete
// before navigating to the success route.
this.onSuccessfulLogin();
}
if (response.resetMasterPassword) {
this.successRoute = "set-password";
}
if (response.forcePasswordReset !== ForceResetPasswordReason.None) {
this.successRoute = "update-temp-password";
}
if (this.onSuccessfulLoginNavigate != null) {
this.loginService.clearValues();
await this.onSuccessfulLoginNavigate();
await this.navigateViaCallbackOrRoute(this.onSuccessfulLoginNavigate, [this.successRoute]);
}
private async navigateViaCallbackOrRoute(
callback: () => Promise<unknown>,
commands: unknown[],
extras?: NavigationExtras
): Promise<void> {
if (callback) {
await callback();
} else {
this.loginService.clearValues();
this.router.navigate([this.successRoute], {
queryParams: {
identifier: this.identifier,
},
});
await this.router.navigate(commands, extras);
}
}

View File

@@ -14,11 +14,10 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { MasterKey, UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { Verification } from "@bitwarden/common/types/verification";
import { DialogServiceAbstraction } from "../../services/dialog";
import { DialogService } from "@bitwarden/components";
import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component";
@@ -44,7 +43,7 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent {
stateService: StateService,
private userVerificationService: UserVerificationService,
private logService: LogService,
dialogService: DialogServiceAbstraction
dialogService: DialogService
) {
super(
i18nService,
@@ -95,19 +94,19 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent {
}
async performSubmitActions(
masterPasswordHash: string,
key: SymmetricCryptoKey,
encKey: [SymmetricCryptoKey, EncString]
newMasterKeyHash: string,
newMasterKey: MasterKey,
newUserKey: [UserKey, EncString]
) {
try {
// Create Request
const request = new PasswordRequest();
request.masterPasswordHash = await this.cryptoService.hashPassword(
request.masterPasswordHash = await this.cryptoService.hashMasterKey(
this.currentMasterPassword,
null
await this.cryptoService.getOrDeriveMasterKey(this.currentMasterPassword)
);
request.newMasterPasswordHash = masterPasswordHash;
request.key = encKey[1].encryptedString;
request.newMasterPasswordHash = newMasterKeyHash;
request.key = newUserKey[1].encryptedString;
// Update user's password
this.apiService.postPassword(request);

View File

@@ -16,12 +16,11 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { MasterKey, UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { Verification } from "@bitwarden/common/types/verification";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogServiceAbstraction } from "../../services/dialog";
import { DialogService } from "@bitwarden/components";
import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component";
@@ -56,7 +55,7 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent {
private logService: LogService,
private userVerificationService: UserVerificationService,
private router: Router,
dialogService: DialogServiceAbstraction
dialogService: DialogService
) {
super(
i18nService,
@@ -114,21 +113,27 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent {
try {
// Create new key and hash new password
const newKey = await this.cryptoService.makeKey(
const newMasterKey = await this.cryptoService.makeMasterKey(
this.masterPassword,
this.email.trim().toLowerCase(),
this.kdf,
this.kdfConfig
);
const newPasswordHash = await this.cryptoService.hashPassword(this.masterPassword, newKey);
const newPasswordHash = await this.cryptoService.hashMasterKey(
this.masterPassword,
newMasterKey
);
// Grab user's current enc key
const userEncKey = await this.cryptoService.getEncKey();
// Grab user key
const userKey = await this.cryptoService.getUserKey();
// Create new encKey for the User
const newEncKey = await this.cryptoService.remakeEncKey(newKey, userEncKey);
// Encrypt user key with new master key
const newProtectedUserKey = await this.cryptoService.encryptUserKeyWithMasterKey(
newMasterKey,
userKey
);
await this.performSubmitActions(newPasswordHash, newKey, newEncKey);
await this.performSubmitActions(newPasswordHash, newMasterKey, newProtectedUserKey);
} catch (e) {
this.logService.error(e);
}
@@ -136,16 +141,16 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent {
async performSubmitActions(
masterPasswordHash: string,
key: SymmetricCryptoKey,
encKey: [SymmetricCryptoKey, EncString]
masterKey: MasterKey,
userKey: [UserKey, EncString]
) {
try {
switch (this.reason) {
case ForceResetPasswordReason.AdminForcePasswordReset:
this.formPromise = this.updateTempPassword(masterPasswordHash, encKey);
this.formPromise = this.updateTempPassword(masterPasswordHash, userKey);
break;
case ForceResetPasswordReason.WeakMasterPassword:
this.formPromise = this.updatePassword(masterPasswordHash, encKey);
this.formPromise = this.updatePassword(masterPasswordHash, userKey);
break;
}
@@ -167,29 +172,23 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent {
this.logService.error(e);
}
}
private async updateTempPassword(
masterPasswordHash: string,
encKey: [SymmetricCryptoKey, EncString]
) {
private async updateTempPassword(masterPasswordHash: string, userKey: [UserKey, EncString]) {
const request = new UpdateTempPasswordRequest();
request.key = encKey[1].encryptedString;
request.key = userKey[1].encryptedString;
request.newMasterPasswordHash = masterPasswordHash;
request.masterPasswordHint = this.hint;
return this.apiService.putUpdateTempPassword(request);
}
private async updatePassword(
newMasterPasswordHash: string,
encKey: [SymmetricCryptoKey, EncString]
) {
private async updatePassword(newMasterPasswordHash: string, userKey: [UserKey, EncString]) {
const request = await this.userVerificationService.buildRequest(
this.verification,
PasswordRequest
);
request.masterPasswordHint = this.hint;
request.newMasterPasswordHash = newMasterPasswordHash;
request.key = encKey[1].encryptedString;
request.key = userKey[1].encryptedString;
return this.apiService.postPassword(request);
}

View File

@@ -2,16 +2,16 @@ import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angu
import { ControlValueAccessor, FormControl, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { Verification } from "@bitwarden/common/types/verification";
/**
* Used for general-purpose user verification throughout the app.
* Collects the user's master password, or if they are using Key Connector, prompts for an OTP via email.
* Collects the user's master password, or if they are not using a password, prompts for an OTP via email.
* This is exposed to the parent component via the ControlValueAccessor interface (e.g. bind it to a FormControl).
* Use UserVerificationService to verify the user's input.
*/
@@ -40,7 +40,7 @@ export class UserVerificationComponent implements ControlValueAccessor, OnInit,
}
@Output() invalidSecretChange = new EventEmitter<boolean>();
usesKeyConnector = true;
hasMasterPassword = true;
disableRequestOTP = false;
sentCode = false;
@@ -50,7 +50,7 @@ export class UserVerificationComponent implements ControlValueAccessor, OnInit,
if (this.invalidSecret) {
return {
invalidSecret: {
message: this.usesKeyConnector
message: this.hasMasterPassword
? this.i18nService.t("incorrectCode")
: this.i18nService.t("incorrectPassword"),
},
@@ -63,13 +63,13 @@ export class UserVerificationComponent implements ControlValueAccessor, OnInit,
private destroy$ = new Subject<void>();
constructor(
private keyConnectorService: KeyConnectorService,
private cryptoService: CryptoService,
private userVerificationService: UserVerificationService,
private i18nService: I18nService
) {}
async ngOnInit() {
this.usesKeyConnector = await this.keyConnectorService.getUsesKeyConnector();
this.hasMasterPassword = await this.userVerificationService.hasMasterPasswordAndMasterKeyHash();
this.processChanges(this.secret.value);
this.secret.valueChanges
@@ -78,7 +78,7 @@ export class UserVerificationComponent implements ControlValueAccessor, OnInit,
}
requestOTP = async () => {
if (this.usesKeyConnector) {
if (!this.hasMasterPassword) {
this.disableRequestOTP = true;
try {
await this.userVerificationService.requestOTP();
@@ -123,7 +123,7 @@ export class UserVerificationComponent implements ControlValueAccessor, OnInit,
}
this.onChange({
type: this.usesKeyConnector ? VerificationType.OTP : VerificationType.MasterPassword,
type: this.hasMasterPassword ? VerificationType.MasterPassword : VerificationType.OTP,
secret: Utils.isNullOrWhitespace(secret) ? null : secret,
});
}

View File

@@ -0,0 +1,5 @@
export * from "./auth.guard";
export * from "./lock.guard";
export * from "./redirect.guard";
export * from "./tde-decryption-required.guard";
export * from "./unauth.guard";

View File

@@ -1,25 +1,59 @@
import { Injectable } from "@angular/core";
import { CanActivate, Router } from "@angular/router";
import { inject } from "@angular/core";
import {
ActivatedRouteSnapshot,
CanActivateFn,
Router,
RouterStateSnapshot,
} from "@angular/router";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
@Injectable()
export class LockGuard implements CanActivate {
protected homepage = "vault";
protected loginpage = "login";
constructor(private authService: AuthService, private router: Router) {}
/**
* Only allow access to this route if the vault is locked.
* If TDE is enabled then the user must also have had a user key at some point.
* Otherwise redirect to root.
*/
export function lockGuard(): CanActivateFn {
return async (
activatedRouteSnapshot: ActivatedRouteSnapshot,
routerStateSnapshot: RouterStateSnapshot
) => {
const authService = inject(AuthService);
const cryptoService = inject(CryptoService);
const deviceTrustCryptoService = inject(DeviceTrustCryptoServiceAbstraction);
const router = inject(Router);
const userVerificationService = inject(UserVerificationService);
async canActivate() {
const authStatus = await this.authService.getAuthStatus();
const authStatus = await authService.getAuthStatus();
if (authStatus !== AuthenticationStatus.Locked) {
return router.createUrlTree(["/"]);
}
if (authStatus === AuthenticationStatus.Locked) {
// User is authN and in locked state.
const tdeEnabled = await deviceTrustCryptoService.supportsDeviceTrust();
// Create special exception which allows users to go from the login-initiated page to the lock page for the approve w/ MP flow
// The MP check is necessary to prevent direct manual navigation from other locked state pages for users who don't have a MP
if (
activatedRouteSnapshot.queryParams["from"] === "login-initiated" &&
tdeEnabled &&
(await userVerificationService.hasMasterPassword())
) {
return true;
}
const redirectUrl =
authStatus === AuthenticationStatus.LoggedOut ? this.loginpage : this.homepage;
// If authN user with TDE directly navigates to lock, kick them upwards so redirect guard can
// properly route them to the login decryption options component.
const everHadUserKey = await cryptoService.getEverHadUserKey();
if (tdeEnabled && !everHadUserKey) {
return router.createUrlTree(["/"]);
}
return this.router.createUrlTree([redirectUrl]);
}
return true;
};
}

View File

@@ -0,0 +1,58 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
export interface RedirectRoutes {
loggedIn: string;
loggedOut: string;
locked: string;
notDecrypted: string;
}
const defaultRoutes: RedirectRoutes = {
loggedIn: "/vault",
loggedOut: "/login",
locked: "/lock",
notDecrypted: "/login-initiated",
};
/**
* Guard that consolidates all redirection logic, should be applied to root route.
*/
export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActivateFn {
const routes = { ...defaultRoutes, ...overrides };
return async (route) => {
const authService = inject(AuthService);
const cryptoService = inject(CryptoService);
const deviceTrustCryptoService = inject(DeviceTrustCryptoServiceAbstraction);
const router = inject(Router);
const authStatus = await authService.getAuthStatus();
if (authStatus === AuthenticationStatus.LoggedOut) {
return router.createUrlTree([routes.loggedOut], { queryParams: route.queryParams });
}
if (authStatus === AuthenticationStatus.Unlocked) {
return router.createUrlTree([routes.loggedIn], { queryParams: route.queryParams });
}
// If locked, TDE is enabled, and the user hasn't decrypted yet, then redirect to the
// login decryption options component.
const tdeEnabled = await deviceTrustCryptoService.supportsDeviceTrust();
const everHadUserKey = await cryptoService.getEverHadUserKey();
if (authStatus === AuthenticationStatus.Locked && tdeEnabled && !everHadUserKey) {
return router.createUrlTree([routes.notDecrypted], { queryParams: route.queryParams });
}
if (authStatus === AuthenticationStatus.Locked) {
return router.createUrlTree([routes.locked], { queryParams: route.queryParams });
}
return router.createUrlTree(["/"]);
};
}

View File

@@ -0,0 +1,34 @@
import { inject } from "@angular/core";
import {
ActivatedRouteSnapshot,
Router,
RouterStateSnapshot,
CanActivateFn,
} from "@angular/router";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
/**
* Only allow access to this route if the vault is locked and has never been decrypted.
* Otherwise redirect to root.
*/
export function tdeDecryptionRequiredGuard(): CanActivateFn {
return async (_: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
const authService = inject(AuthService);
const cryptoService = inject(CryptoService);
const deviceTrustCryptoService = inject(DeviceTrustCryptoServiceAbstraction);
const router = inject(Router);
const authStatus = await authService.getAuthStatus();
const tdeEnabled = await deviceTrustCryptoService.supportsDeviceTrust();
const everHadUserKey = await cryptoService.getEverHadUserKey();
if (authStatus !== AuthenticationStatus.Locked || !tdeEnabled || everHadUserKey) {
return router.createUrlTree(["/"]);
}
return true;
};
}

View File

@@ -19,13 +19,13 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { DialogService } from "@bitwarden/components";
import { CaptchaProtectedComponent } from "../auth/components/captcha-protected.component";
import {
AllValidationErrors,
FormValidationErrorsService,
} from "../platform/abstractions/form-validation-errors.service";
import { DialogServiceAbstraction, SimpleDialogType } from "../services/dialog";
import { PasswordColorText } from "../shared/components/password-strength/password-strength.component";
import { InputsFieldMatch } from "../validators/inputsFieldMatch.validator";
@@ -92,7 +92,7 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
environmentService: EnvironmentService,
protected logService: LogService,
protected auditService: AuditService,
protected dialogService: DialogServiceAbstraction
protected dialogService: DialogService
) {
super(environmentService, i18nService, platformUtilsService);
this.showTerms = !platformUtilsService.isSelfHost();
@@ -232,7 +232,7 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
const result = await this.dialogService.openSimpleDialog({
title: { key: "weakAndExposedMasterPassword" },
content: { key: "weakAndBreachedMasterPasswordDesc" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!result) {
@@ -242,7 +242,7 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
const result = await this.dialogService.openSimpleDialog({
title: { key: "weakMasterPassword" },
content: { key: "weakMasterPasswordDesc" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!result) {
@@ -252,7 +252,7 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
const result = await this.dialogService.openSimpleDialog({
title: { key: "exposedMasterPassword" },
content: { key: "exposedMasterPasswordDesc" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!result) {
@@ -271,16 +271,16 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
const hint = this.formGroup.value.hint;
const kdf = DEFAULT_KDF_TYPE;
const kdfConfig = DEFAULT_KDF_CONFIG;
const key = await this.cryptoService.makeKey(masterPassword, email, kdf, kdfConfig);
const encKey = await this.cryptoService.makeEncKey(key);
const hashedPassword = await this.cryptoService.hashPassword(masterPassword, key);
const keys = await this.cryptoService.makeKeyPair(encKey[0]);
const key = await this.cryptoService.makeMasterKey(masterPassword, email, kdf, kdfConfig);
const newUserKey = await this.cryptoService.makeUserKey(key);
const masterKeyHash = await this.cryptoService.hashMasterKey(masterPassword, key);
const keys = await this.cryptoService.makeKeyPair(newUserKey[0]);
const request = new RegisterRequest(
email,
name,
hashedPassword,
masterKeyHash,
hint,
encKey[1].encryptedString,
newUserKey[1].encryptedString,
this.referenceData,
this.captchaToken,
kdf,

View File

@@ -18,12 +18,12 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { MasterKey, UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService } from "@bitwarden/components";
import { ChangePasswordComponent as BaseChangePasswordComponent } from "../auth/components/change-password.component";
import { DialogServiceAbstraction } from "../services/dialog";
@Directive()
export class SetPasswordComponent extends BaseChangePasswordComponent {
@@ -52,7 +52,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent {
stateService: StateService,
private organizationApiService: OrganizationApiServiceAbstraction,
private organizationUserService: OrganizationUserService,
dialogService: DialogServiceAbstraction
dialogService: DialogService
) {
super(
i18nService,
@@ -101,16 +101,16 @@ export class SetPasswordComponent extends BaseChangePasswordComponent {
async performSubmitActions(
masterPasswordHash: string,
key: SymmetricCryptoKey,
encKey: [SymmetricCryptoKey, EncString]
masterKey: MasterKey,
userKey: [UserKey, EncString]
) {
const keys = await this.cryptoService.makeKeyPair(encKey[0]);
const newKeyPair = await this.cryptoService.makeKeyPair(userKey[0]);
const request = new SetPasswordRequest(
masterPasswordHash,
encKey[1].encryptedString,
userKey[1].encryptedString,
this.hint,
this.identifier,
new KeysRequest(keys[0], keys[1].encryptedString),
new KeysRequest(newKeyPair[0], newKeyPair[1].encryptedString),
this.kdf,
this.kdfConfig.iterations,
this.kdfConfig.memory,
@@ -121,7 +121,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent {
this.formPromise = this.apiService
.setPassword(request)
.then(async () => {
await this.onSetPasswordSuccess(key, encKey, keys);
await this.onSetPasswordSuccess(masterKey, userKey, newKeyPair);
return this.organizationApiService.getKeys(this.orgId);
})
.then(async (response) => {
@@ -131,13 +131,13 @@ export class SetPasswordComponent extends BaseChangePasswordComponent {
const userId = await this.stateService.getUserId();
const publicKey = Utils.fromB64ToArray(response.publicKey);
// RSA Encrypt user's encKey.key with organization public key
const userEncKey = await this.cryptoService.getEncKey();
const encryptedKey = await this.cryptoService.rsaEncrypt(userEncKey.key, publicKey);
// RSA Encrypt user key with organization public key
const userKey = await this.cryptoService.getUserKey();
const encryptedUserKey = await this.cryptoService.rsaEncrypt(userKey.key, publicKey);
const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest();
resetRequest.masterPasswordHash = masterPasswordHash;
resetRequest.resetPasswordKey = encryptedKey.encryptedString;
resetRequest.resetPasswordKey = encryptedUserKey.encryptedString;
return this.organizationUserService.putOrganizationUserResetPasswordEnrollment(
this.orgId,
@@ -147,7 +147,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent {
});
} else {
this.formPromise = this.apiService.setPassword(request).then(async () => {
await this.onSetPasswordSuccess(key, encKey, keys);
await this.onSetPasswordSuccess(masterKey, userKey, newKeyPair);
});
}
@@ -169,21 +169,21 @@ export class SetPasswordComponent extends BaseChangePasswordComponent {
}
private async onSetPasswordSuccess(
key: SymmetricCryptoKey,
encKey: [SymmetricCryptoKey, EncString],
keys: [string, EncString]
masterKey: MasterKey,
userKey: [UserKey, EncString],
keyPair: [string, EncString]
) {
await this.stateService.setKdfType(this.kdf);
await this.stateService.setKdfConfig(this.kdfConfig);
await this.cryptoService.setKey(key);
await this.cryptoService.setEncKey(encKey[1].encryptedString);
await this.cryptoService.setEncPrivateKey(keys[1].encryptedString);
await this.cryptoService.setMasterKey(masterKey);
await this.cryptoService.setUserKey(userKey[0]);
await this.cryptoService.setPrivateKey(keyPair[1].encryptedString);
const localKeyHash = await this.cryptoService.hashPassword(
const localMasterKeyHash = await this.cryptoService.hashMasterKey(
this.masterPassword,
key,
masterKey,
HashPurpose.LocalAuthorization
);
await this.cryptoService.setKeyHash(localKeyHash);
await this.cryptoService.setMasterKeyHash(localMasterKeyHash);
}
}

View File

@@ -1,6 +1,7 @@
import { Directive, OnInit } from "@angular/core";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { KeySuffixOptions } from "@bitwarden/common/enums/key-suffix-options.enum";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -17,13 +18,13 @@ export class SetPinComponent implements OnInit {
constructor(
private modalRef: ModalRef,
private cryptoService: CryptoService,
private keyConnectorService: KeyConnectorService,
private userVerificationService: UserVerificationService,
private stateService: StateService
) {}
async ngOnInit() {
this.showMasterPassOnRestart = this.masterPassOnRestart =
!(await this.keyConnectorService.getUsesKeyConnector());
await this.userVerificationService.hasMasterPassword();
}
toggleVisibility() {
@@ -35,19 +36,22 @@ export class SetPinComponent implements OnInit {
this.modalRef.close(false);
}
const kdf = await this.stateService.getKdfType();
const kdfConfig = await this.stateService.getKdfConfig();
const email = await this.stateService.getEmail();
const pinKey = await this.cryptoService.makePinKey(this.pin, email, kdf, kdfConfig);
const key = await this.cryptoService.getKey();
const pinProtectedKey = await this.cryptoService.encrypt(key.key, pinKey);
const pinKey = await this.cryptoService.makePinKey(
this.pin,
await this.stateService.getEmail(),
await this.stateService.getKdfType(),
await this.stateService.getKdfConfig()
);
const userKey = await this.cryptoService.getUserKey();
const pinProtectedKey = await this.cryptoService.encrypt(userKey.key, pinKey);
const encPin = await this.cryptoService.encrypt(this.pin, userKey);
await this.stateService.setProtectedPin(encPin.encryptedString);
if (this.masterPassOnRestart) {
const encPin = await this.cryptoService.encrypt(this.pin);
await this.stateService.setProtectedPin(encPin.encryptedString);
await this.stateService.setDecryptedPinProtected(pinProtectedKey);
await this.stateService.setPinKeyEncryptedUserKeyEphemeral(pinProtectedKey);
} else {
await this.stateService.setEncryptedPinProtected(pinProtectedKey.encryptedString);
await this.stateService.setPinKeyEncryptedUserKey(pinProtectedKey);
}
await this.cryptoService.clearDeprecatedKeys(KeySuffixOptions.Pin);
this.modalRef.close(true);
}

View File

@@ -6,13 +6,23 @@ import {
ValidationErrors,
Validator,
} from "@angular/forms";
import { filter, Subject, takeUntil } from "rxjs";
import { filter, map, Observable, Subject, takeUntil } from "rxjs";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
interface VaultTimeoutFormValue {
vaultTimeout: number | null;
custom: {
hours: number | null;
minutes: number | null;
};
}
@Directive()
export class VaultTimeoutInputComponent
implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges
@@ -43,6 +53,8 @@ export class VaultTimeoutInputComponent
vaultTimeoutPolicyHours: number;
vaultTimeoutPolicyMinutes: number;
protected canLockVault$: Observable<boolean>;
private onChange: (vaultTimeout: number) => void;
private validatorChange: () => void;
private destroy$ = new Subject<void>();
@@ -50,6 +62,7 @@ export class VaultTimeoutInputComponent
constructor(
private formBuilder: FormBuilder,
private policyService: PolicyService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private i18nService: I18nService
) {}
@@ -65,27 +78,43 @@ export class VaultTimeoutInputComponent
this.applyVaultTimeoutPolicy();
});
this.form.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => {
if (this.onChange) {
this.onChange(this.getVaultTimeout(value));
}
});
this.form.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((value: VaultTimeoutFormValue) => {
if (this.onChange) {
this.onChange(this.getVaultTimeout(value));
}
});
// Assign the previous value to the custom fields
// Assign the current value to the custom fields
// so that if the user goes from a numeric value to custom
// we can initialize the custom fields with the current value
// ex: user picks 5 min, goes to custom, we want to show 0 hr, 5 min in the custom fields
this.form.controls.vaultTimeout.valueChanges
.pipe(
filter((value) => value !== VaultTimeoutInputComponent.CUSTOM_VALUE),
takeUntil(this.destroy$)
)
.subscribe((_) => {
const current = Math.max(this.form.value.vaultTimeout, 0);
this.form.patchValue({
custom: {
hours: Math.floor(current / 60),
minutes: current % 60,
.subscribe((value) => {
const current = Math.max(value, 0);
// This cannot emit an event b/c it would cause form.valueChanges to fire again
// and we are already handling that above so just silently update
// custom fields when vaultTimeout changes to a non-custom value
this.form.patchValue(
{
custom: {
hours: Math.floor(current / 60),
minutes: current % 60,
},
},
});
{ emitEvent: false }
);
});
this.canLockVault$ = this.vaultTimeoutSettingsService
.availableVaultTimeoutActions$()
.pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock)));
}
ngOnDestroy() {
@@ -104,7 +133,7 @@ export class VaultTimeoutInputComponent
}
}
getVaultTimeout(value: any) {
getVaultTimeout(value: VaultTimeoutFormValue) {
if (value.vaultTimeout !== VaultTimeoutInputComponent.CUSTOM_VALUE) {
return value.vaultTimeout;
}

View File

@@ -1,5 +1,6 @@
import { Directive, ElementRef, HostListener, Input } from "@angular/core";
import { ClientType } from "@bitwarden/common/enums";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@Directive({
@@ -15,6 +16,9 @@ export class CopyTextDirective {
return;
}
this.platformUtilsService.copyToClipboard(this.copyText, { window: window });
const timeout = this.platformUtilsService.getClientType() === ClientType.Desktop ? 100 : 0;
setTimeout(() => {
this.platformUtilsService.copyToClipboard(this.copyText, { window: window });
}, timeout);
}
}

View File

@@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock, MockProxy } from "jest-mock-extended";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { FeatureFlag, FeatureFlagValue } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -41,21 +41,12 @@ describe("IfFeatureDirective", () => {
let content: HTMLElement;
let mockConfigService: MockProxy<ConfigServiceAbstraction>;
const mockConfigFlagValue = (flag: FeatureFlag, flagValue: any) => {
if (typeof flagValue === "boolean") {
mockConfigService.getFeatureFlagBool.mockImplementation((f, defaultValue = false) =>
flag == f ? Promise.resolve(flagValue) : Promise.resolve(defaultValue)
);
} else if (typeof flagValue === "string") {
mockConfigService.getFeatureFlagString.mockImplementation((f, defaultValue = "") =>
flag == f ? Promise.resolve(flagValue) : Promise.resolve(defaultValue)
);
} else if (typeof flagValue === "number") {
mockConfigService.getFeatureFlagNumber.mockImplementation((f, defaultValue = 0) =>
flag == f ? Promise.resolve(flagValue) : Promise.resolve(defaultValue)
);
}
const mockConfigFlagValue = (flag: FeatureFlag, flagValue: FeatureFlagValue) => {
mockConfigService.getFeatureFlag.mockImplementation((f, defaultValue) =>
flag == f ? Promise.resolve(flagValue) : Promise.resolve(defaultValue)
);
};
const queryContent = (testId: string) =>
fixture.debugElement.query(By.css(`[data-testid="${testId}"]`))?.nativeElement;
@@ -126,7 +117,7 @@ describe("IfFeatureDirective", () => {
});
it("hides content when the directive throws an unexpected exception", async () => {
mockConfigService.getFeatureFlagBool.mockImplementation(() => Promise.reject("Some error"));
mockConfigService.getFeatureFlag.mockImplementation(() => Promise.reject("Some error"));
fixture.detectChanges();
await fixture.whenStable();

View File

@@ -1,12 +1,9 @@
import { Directive, Input, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { FeatureFlag, FeatureFlagValue } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
// Replace this with a type safe lookup of the feature flag values in PM-2282
type FlagValue = boolean | number | string;
/**
* Directive that conditionally renders the element when the feature flag is enabled and/or
* matches the value specified by {@link appIfFeatureValue}.
@@ -26,7 +23,7 @@ export class IfFeatureDirective implements OnInit {
* Optional value to compare against the value of the feature flag in the config service.
* @default true
*/
@Input() appIfFeatureValue: FlagValue = true;
@Input() appIfFeatureValue: FeatureFlagValue = true;
private hasView = false;
@@ -39,15 +36,7 @@ export class IfFeatureDirective implements OnInit {
async ngOnInit() {
try {
let flagValue: FlagValue;
if (typeof this.appIfFeatureValue === "boolean") {
flagValue = await this.configService.getFeatureFlagBool(this.appIfFeature);
} else if (typeof this.appIfFeatureValue === "number") {
flagValue = await this.configService.getFeatureFlagNumber(this.appIfFeature);
} else if (typeof this.appIfFeatureValue === "string") {
flagValue = await this.configService.getFeatureFlagString(this.appIfFeature);
}
const flagValue = await this.configService.getFeatureFlag(this.appIfFeature);
if (this.appIfFeatureValue === flagValue) {
if (!this.hasView) {

View File

@@ -30,15 +30,15 @@ describe("canAccessFeature", () => {
// Mock the correct getter based on the type of flagValue; also mock default values if one is not provided
if (typeof flagValue === "boolean") {
mockConfigService.getFeatureFlagBool.mockImplementation((flag, defaultValue = false) =>
mockConfigService.getFeatureFlag.mockImplementation((flag, defaultValue = false) =>
flag == testFlag ? Promise.resolve(flagValue) : Promise.resolve(defaultValue)
);
} else if (typeof flagValue === "string") {
mockConfigService.getFeatureFlagString.mockImplementation((flag, defaultValue = "") =>
mockConfigService.getFeatureFlag.mockImplementation((flag, defaultValue = "") =>
flag == testFlag ? Promise.resolve(flagValue) : Promise.resolve(defaultValue)
);
} else if (typeof flagValue === "number") {
mockConfigService.getFeatureFlagNumber.mockImplementation((flag, defaultValue = 0) =>
mockConfigService.getFeatureFlag.mockImplementation((flag, defaultValue = 0) =>
flag == testFlag ? Promise.resolve(flagValue) : Promise.resolve(defaultValue)
);
}
@@ -143,7 +143,7 @@ describe("canAccessFeature", () => {
it("fails to navigate when the config service throws an unexpected exception", async () => {
const { router } = setup(canAccessFeature(testFlag), true);
mockConfigService.getFeatureFlagBool.mockImplementation(() => Promise.reject("Some error"));
mockConfigService.getFeatureFlag.mockImplementation(() => Promise.reject("Some error"));
await router.navigate([featureRoute]);

View File

@@ -29,16 +29,8 @@ export const canAccessFeature = (
const i18nService = inject(I18nService);
const logService = inject(LogService);
let flagValue: FlagValue;
try {
if (typeof requiredFlagValue === "boolean") {
flagValue = await configService.getFeatureFlagBool(featureFlag);
} else if (typeof requiredFlagValue === "number") {
flagValue = await configService.getFeatureFlagNumber(featureFlag);
} else if (typeof requiredFlagValue === "string") {
flagValue = await configService.getFeatureFlagString(featureFlag);
}
const flagValue = await configService.getFeatureFlag(featureFlag);
if (flagValue === requiredFlagValue) {
return true;

View File

@@ -1,26 +0,0 @@
import { Dialog, DialogRef } from "@angular/cdk/dialog";
import { SimpleDialogOptions } from "./simple-dialog-options";
export abstract class DialogServiceAbstraction extends Dialog {
/**
* Opens a simple dialog, returns true if the user accepted the dialog.
*
* @param {SimpleDialogOptions} simpleDialogOptions - An object containing options for the dialog.
* @returns `boolean` - True if the user accepted the dialog, false otherwise.
*/
openSimpleDialog: (simpleDialogOptions: SimpleDialogOptions) => Promise<boolean>;
/**
* Opens a simple dialog.
*
* @deprecated Use `openSimpleDialogAcceptedPromise` instead. If you find a use case for the `dialogRef`
* please let #wg-component-library know and we can un-deprecate this method.
*
* @param {SimpleDialogOptions} simpleDialogOptions - An object containing options for the dialog.
* @returns `DialogRef` - The reference to the opened dialog.
* Contains a closed observable which can be subscribed to for determining which button
* a user pressed (see `SimpleDialogCloseType`)
*/
openSimpleDialogRef: (simpleDialogOptions: SimpleDialogOptions) => DialogRef;
}

View File

@@ -1,65 +0,0 @@
import {
DialogRef,
DialogConfig,
Dialog,
DEFAULT_DIALOG_CONFIG,
DIALOG_SCROLL_STRATEGY,
} from "@angular/cdk/dialog";
import { Overlay, OverlayContainer } from "@angular/cdk/overlay";
import { ComponentType } from "@angular/cdk/portal";
import { Inject, Injectable, Injector, Optional, SkipSelf, TemplateRef } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogServiceAbstraction } from "./dialog.service.abstraction";
import { SimpleDialogOptions } from "./simple-dialog-options";
import { Translation } from "./translation";
// This is a temporary base class for Dialogs. It is intended to be removed once the Component Library is adopted by each app.
@Injectable()
export abstract class DialogService extends Dialog implements DialogServiceAbstraction {
constructor(
/** Parent class constructor */
_overlay: Overlay,
_injector: Injector,
@Optional() @Inject(DEFAULT_DIALOG_CONFIG) _defaultOptions: DialogConfig,
@Optional() @SkipSelf() _parentDialog: Dialog,
_overlayContainer: OverlayContainer,
@Inject(DIALOG_SCROLL_STRATEGY) scrollStrategy: any,
protected i18nService: I18nService
) {
super(_overlay, _injector, _defaultOptions, _parentDialog, _overlayContainer, scrollStrategy);
}
async openSimpleDialog(options: SimpleDialogOptions): Promise<boolean> {
throw new Error("Method not implemented.");
}
openSimpleDialogRef(simpleDialogOptions: SimpleDialogOptions): DialogRef {
throw new Error("Method not implemented.");
}
override open<R = unknown, D = unknown, C = unknown>(
componentOrTemplateRef: ComponentType<C> | TemplateRef<C>,
config?: DialogConfig<D, DialogRef<R, C>>
): DialogRef<R, C> {
throw new Error("Method not implemented.");
}
protected translate(translation: string | Translation, defaultKey?: string): string {
if (translation == null && defaultKey == null) {
return null;
}
if (translation == null) {
return this.i18nService.t(defaultKey);
}
// Translation interface use implies we must localize.
if (typeof translation === "object") {
return this.i18nService.t(translation.key, ...(translation.placeholders ?? []));
}
return translation;
}
}

View File

@@ -1,6 +0,0 @@
export * from "./dialog.service.abstraction";
export * from "./simple-dialog-options";
export * from "./simple-dialog-type.enum";
export * from "./simple-dialog-close-type.enum";
export * from "./dialog.service";
export * from "./translation";

View File

@@ -1,4 +0,0 @@
export enum SimpleDialogCloseType {
ACCEPT = "accept",
CANCEL = "cancel",
}

View File

@@ -1,7 +0,0 @@
export enum SimpleDialogType {
PRIMARY = "primary",
SUCCESS = "success",
INFO = "info",
WARNING = "warning",
DANGER = "danger",
}

View File

@@ -1,4 +0,0 @@
export interface Translation {
key: string;
placeholders?: Array<string | number>;
}

View File

@@ -11,11 +11,12 @@ export const MEMORY_STORAGE = new InjectionToken<AbstractMemoryStorageService>("
export const SECURE_STORAGE = new InjectionToken<AbstractStorageService>("SECURE_STORAGE");
export const STATE_FACTORY = new InjectionToken<StateFactory>("STATE_FACTORY");
export const STATE_SERVICE_USE_CACHE = new InjectionToken<boolean>("STATE_SERVICE_USE_CACHE");
export const LOGOUT_CALLBACK = new InjectionToken<(expired: boolean, userId?: string) => void>(
"LOGOUT_CALLBACK"
export const LOGOUT_CALLBACK = new InjectionToken<
(expired: boolean, userId?: string) => Promise<void>
>("LOGOUT_CALLBACK");
export const LOCKED_CALLBACK = new InjectionToken<(userId?: string) => Promise<void>>(
"LOCKED_CALLBACK"
);
export const LOCKED_CALLBACK = new InjectionToken<() => void>("LOCKED_CALLBACK");
export const CLIENT_TYPE = new InjectionToken<boolean>("CLIENT_TYPE");
export const LOCALES_DIRECTORY = new InjectionToken<string>("LOCALES_DIRECTORY");
export const SYSTEM_LANGUAGE = new InjectionToken<string>("SYSTEM_LANGUAGE");
export const LOG_MAC_FAILURES = new InjectionToken<string>("LOG_MAC_FAILURES");

View File

@@ -4,8 +4,7 @@ import { AvatarUpdateService as AccountUpdateServiceAbstraction } from "@bitward
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/abstractions/anonymousHub.service";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
import { DeviceCryptoServiceAbstraction } from "@bitwarden/common/abstractions/device-crypto.service.abstraction";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices-api.service.abstraction";
import { DevicesServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices.service.abstraction";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
@@ -18,8 +17,8 @@ import { OrganizationUserService } from "@bitwarden/common/abstractions/organiza
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service";
import { SettingsService as SettingsServiceAbstraction } from "@bitwarden/common/abstractions/settings.service";
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/abstractions/totp.service";
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
InternalOrganizationServiceAbstraction,
@@ -41,18 +40,26 @@ import {
AccountService as AccountServiceAbstraction,
InternalAccountService,
} from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction";
import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.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 { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service";
import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction";
import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction";
import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation";
import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation";
import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service";
import { LoginService } from "@bitwarden/common/auth/services/login.service";
import { PasswordResetEnrollmentServiceImplementation } from "@bitwarden/common/auth/services/password-reset-enrollment.service.implementation";
import { TokenService } from "@bitwarden/common/auth/services/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service";
import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service";
@@ -70,7 +77,6 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/platform/abstractions/state-migration.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -87,7 +93,6 @@ import { EncryptServiceImplementation } from "@bitwarden/common/platform/service
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service";
import { StateService } from "@bitwarden/common/platform/services/state.service";
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
@@ -95,8 +100,7 @@ import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-u
import { AnonymousHubService } from "@bitwarden/common/services/anonymousHub.service";
import { ApiService } from "@bitwarden/common/services/api.service";
import { AuditService } from "@bitwarden/common/services/audit.service";
import { DeviceCryptoService } from "@bitwarden/common/services/device-crypto.service.implementation";
import { DevicesApiServiceImplementation } from "@bitwarden/common/services/devices/devices-api.service.implementation";
import { DevicesServiceImplementation } from "@bitwarden/common/services/devices/devices.service.implementation";
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/services/notifications.service";
@@ -106,8 +110,8 @@ import { OrganizationUserServiceImplementation } from "@bitwarden/common/service
import { SearchService } from "@bitwarden/common/services/search.service";
import { SettingsService } from "@bitwarden/common/services/settings.service";
import { TotpService } from "@bitwarden/common/services/totp.service";
import { VaultTimeoutService } from "@bitwarden/common/services/vaultTimeout/vaultTimeout.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vaultTimeout/vaultTimeoutSettings.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service";
import {
PasswordGenerationService,
PasswordGenerationServiceAbstraction,
@@ -148,7 +152,6 @@ import {
} from "@bitwarden/exporter/vault-export";
import { AuthGuard } from "../auth/guards/auth.guard";
import { LockGuard } from "../auth/guards/lock.guard";
import { UnauthGuard } from "../auth/guards/unauth.guard";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { BroadcasterService } from "../platform/services/broadcaster.service";
@@ -176,7 +179,6 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
providers: [
AuthGuard,
UnauthGuard,
LockGuard,
ModalService,
{ provide: WINDOW, useValue: window },
{
@@ -245,6 +247,8 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
EncryptService,
PasswordStrengthServiceAbstraction,
PolicyServiceAbstraction,
DeviceTrustCryptoServiceAbstraction,
AuthRequestCryptoServiceAbstraction,
],
},
{
@@ -441,10 +445,11 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
TokenServiceAbstraction,
PolicyServiceAbstraction,
StateServiceAbstraction,
UserVerificationServiceAbstraction,
],
},
{
provide: VaultTimeoutServiceAbstraction,
provide: VaultTimeoutService,
useClass: VaultTimeoutService,
deps: [
CipherServiceAbstraction,
@@ -454,7 +459,6 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
PlatformUtilsServiceAbstraction,
MessagingServiceAbstraction,
SearchServiceAbstraction,
KeyConnectorServiceAbstraction,
StateServiceAbstraction,
AuthServiceAbstraction,
VaultTimeoutSettingsServiceAbstraction,
@@ -462,6 +466,10 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
LOGOUT_CALLBACK,
],
},
{
provide: VaultTimeoutServiceAbstraction,
useExisting: VaultTimeoutService,
},
{
provide: StateServiceAbstraction,
useClass: StateService,
@@ -470,16 +478,10 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
SECURE_STORAGE,
MEMORY_STORAGE,
LogService,
StateMigrationServiceAbstraction,
STATE_FACTORY,
STATE_SERVICE_USE_CACHE,
],
},
{
provide: StateMigrationServiceAbstraction,
useClass: StateMigrationService,
deps: [AbstractStorageService, SECURE_STORAGE, STATE_FACTORY],
},
{
provide: VaultExportServiceAbstraction,
useClass: VaultExportService,
@@ -562,8 +564,6 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
LogService,
OrganizationServiceAbstraction,
CryptoFunctionServiceAbstraction,
SyncNotifierServiceAbstraction,
MessagingServiceAbstraction,
LOGOUT_CALLBACK,
],
},
@@ -571,6 +571,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
provide: UserVerificationServiceAbstraction,
useClass: UserVerificationService,
deps: [
StateServiceAbstraction,
CryptoServiceAbstraction,
I18nServiceAbstraction,
UserVerificationApiServiceAbstraction,
@@ -591,6 +592,17 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
useClass: OrganizationUserServiceImplementation,
deps: [ApiServiceAbstraction],
},
{
provide: PasswordResetEnrollmentServiceAbstraction,
useClass: PasswordResetEnrollmentServiceImplementation,
deps: [
OrganizationApiServiceAbstraction,
StateServiceAbstraction,
CryptoServiceAbstraction,
OrganizationUserService,
I18nServiceAbstraction,
],
},
{
provide: ProviderServiceAbstraction,
useClass: ProviderService,
@@ -628,15 +640,20 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
useClass: SyncNotifierService,
},
{
provide: ConfigServiceAbstraction,
provide: ConfigService,
useClass: ConfigService,
deps: [
StateServiceAbstraction,
ConfigApiServiceAbstraction,
AuthServiceAbstraction,
EnvironmentServiceAbstraction,
LogService,
],
},
{
provide: ConfigServiceAbstraction,
useExisting: ConfigService,
},
{
provide: ConfigApiServiceAbstraction,
useClass: ConfigApiService,
@@ -677,8 +694,13 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
deps: [ApiServiceAbstraction],
},
{
provide: DeviceCryptoServiceAbstraction,
useClass: DeviceCryptoService,
provide: DevicesServiceAbstraction,
useClass: DevicesServiceImplementation,
deps: [DevicesApiServiceAbstraction],
},
{
provide: DeviceTrustCryptoServiceAbstraction,
useClass: DeviceTrustCryptoService,
deps: [
CryptoFunctionServiceAbstraction,
CryptoServiceAbstraction,
@@ -686,8 +708,15 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
StateServiceAbstraction,
AppIdServiceAbstraction,
DevicesApiServiceAbstraction,
I18nServiceAbstraction,
PlatformUtilsServiceAbstraction,
],
},
{
provide: AuthRequestCryptoServiceAbstraction,
useClass: AuthRequestCryptoServiceImplementation,
deps: [CryptoServiceAbstraction],
},
],
})
export class JslibServicesModule {}

View File

@@ -21,6 +21,9 @@ export class ModalConfig<D = any> {
replaceTopModal?: boolean;
}
/**
* @deprecated Use the Component Library's `DialogService` instead.
*/
@Injectable()
export class ModalService {
protected modalList: ComponentRef<DynamicModalComponent>[] = [];
@@ -50,7 +53,7 @@ export class ModalService {
}
/**
* @deprecated Use `dialogService.open` (in web) or `modalService.open` (in desktop/browser) instead.
* @deprecated Use `dialogService.open` instead.
* If replacing an existing call to this method, also remove any `@ViewChild` and `<ng-template>` associated with the
* existing usage.
*/

View File

@@ -35,7 +35,7 @@ export class ExportScopeCalloutComponent implements OnInit {
}
: {
title: "exportingPersonalVaultTitle",
description: "exportingPersonalVaultDescription",
description: "exportingIndividualVaultDescription",
scopeIdentifier: await this.stateService.getEmail(),
};
this.show = true;

View File

@@ -12,10 +12,9 @@ import { FileDownloadService } from "@bitwarden/common/platform/abstractions/fil
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 { DialogService } from "@bitwarden/components";
import { VaultExportServiceAbstraction } from "@bitwarden/exporter/vault-export";
import { DialogServiceAbstraction, SimpleDialogType } from "../../../services/dialog";
@Directive()
export class ExportComponent implements OnInit, OnDestroy {
@Output() onSaved = new EventEmitter();
@@ -55,7 +54,7 @@ export class ExportComponent implements OnInit, OnDestroy {
private userVerificationService: UserVerificationService,
private formBuilder: UntypedFormBuilder,
protected fileDownloadService: FileDownloadService,
protected dialogService: DialogServiceAbstraction
protected dialogService: DialogService
) {}
async ngOnInit() {
@@ -135,14 +134,14 @@ export class ExportComponent implements OnInit, OnDestroy {
" " +
this.i18nService.t("encExportAccountWarningDesc"),
acceptButtonText: { key: "exportVault" },
type: SimpleDialogType.WARNING,
type: "warning",
});
} else {
return await this.dialogService.openSimpleDialog({
title: { key: "confirmVaultExport" },
content: { key: "exportWarningDesc" },
acceptButtonText: { key: "exportVault" },
type: SimpleDialogType.WARNING,
type: "warning",
});
}
}

View File

@@ -8,8 +8,15 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
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 { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username";
import { GeneratorOptions } from "@bitwarden/common/tools/generator/generator-options";
import {
PasswordGenerationServiceAbstraction,
PasswordGeneratorOptions,
} from "@bitwarden/common/tools/generator/password";
import {
UsernameGenerationServiceAbstraction,
UsernameGeneratorOptions,
} from "@bitwarden/common/tools/generator/username";
@Directive()
export class GeneratorComponent implements OnInit {
@@ -24,8 +31,8 @@ export class GeneratorComponent implements OnInit {
subaddressOptions: any[];
catchallOptions: any[];
forwardOptions: EmailForwarderOptions[];
usernameOptions: any = {};
passwordOptions: any = {};
usernameOptions: UsernameGeneratorOptions = {};
passwordOptions: PasswordGeneratorOptions = {};
username = "-";
password = "-";
showOptions = false;
@@ -118,7 +125,7 @@ export class GeneratorComponent implements OnInit {
}
async typeChanged() {
await this.stateService.setGeneratorOptions({ type: this.type });
await this.stateService.setGeneratorOptions({ type: this.type } as GeneratorOptions);
if (this.regenerateWithoutButtonPress()) {
await this.regenerate();
}
@@ -237,7 +244,7 @@ export class GeneratorComponent implements OnInit {
private async initForwardOptions() {
this.forwardOptions = [
{ name: "AnonAddy", value: "anonaddy", validForSelfHosted: true },
{ name: "addy.io", value: "anonaddy", validForSelfHosted: true },
{ name: "DuckDuckGo", value: "duckduckgo", validForSelfHosted: false },
{ name: "Fastmail", value: "fastmail", validForSelfHosted: true },
{ name: "Firefox Relay", value: "firefoxrelay", validForSelfHosted: false },

View File

@@ -1,5 +1,6 @@
import { DatePipe } from "@angular/common";
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -10,6 +11,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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 { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
@@ -18,8 +20,24 @@ import { SendTextView } from "@bitwarden/common/tools/send/models/view/send-text
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { DialogService } from "@bitwarden/components";
import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog";
// Value = hours
enum DatePreset {
OneHour = 1,
OneDay = 24,
TwoDays = 48,
ThreeDays = 72,
SevenDays = 168,
ThirtyDays = 720,
Custom = 0,
Never = null,
}
interface DatePresetSelectOption {
name: string;
value: DatePreset;
}
@Directive()
export class AddEditComponent implements OnInit, OnDestroy {
@@ -30,14 +48,26 @@ export class AddEditComponent implements OnInit, OnDestroy {
@Output() onDeletedSend = new EventEmitter<SendView>();
@Output() onCancelled = new EventEmitter<SendView>();
deletionDatePresets: DatePresetSelectOption[] = [
{ name: this.i18nService.t("oneHour"), value: DatePreset.OneHour },
{ name: this.i18nService.t("oneDay"), value: DatePreset.OneDay },
{ name: this.i18nService.t("days", "2"), value: DatePreset.TwoDays },
{ name: this.i18nService.t("days", "3"), value: DatePreset.ThreeDays },
{ name: this.i18nService.t("days", "7"), value: DatePreset.SevenDays },
{ name: this.i18nService.t("days", "30"), value: DatePreset.ThirtyDays },
{ name: this.i18nService.t("custom"), value: DatePreset.Custom },
];
expirationDatePresets: DatePresetSelectOption[] = [
{ name: this.i18nService.t("never"), value: DatePreset.Never },
...this.deletionDatePresets,
];
copyLink = false;
disableSend = false;
disableHideEmail = false;
send: SendView;
deletionDate: string;
expirationDate: string;
hasPassword: boolean;
password: string;
showPassword = false;
formPromise: Promise<any>;
deletePromise: Promise<any>;
@@ -52,6 +82,27 @@ export class AddEditComponent implements OnInit, OnDestroy {
private sendLinkBaseUrl: string;
private destroy$ = new Subject<void>();
protected formGroup = this.formBuilder.group({
name: ["", Validators.required],
text: [],
textHidden: [false],
fileContents: [],
file: [null, Validators.required],
link: [],
copyLink: false,
maxAccessCount: [],
accessCount: [],
password: [],
notes: [],
hideEmail: false,
disabled: false,
type: [],
defaultExpirationDateTime: [],
defaultDeletionDateTime: ["", Validators.required],
selectedDeletionDatePreset: [DatePreset.SevenDays, Validators.required],
selectedExpirationDatePreset: [],
});
constructor(
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
@@ -60,10 +111,11 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected sendService: SendService,
protected messagingService: MessagingService,
protected policyService: PolicyService,
private logService: LogService,
protected logService: LogService,
protected stateService: StateService,
protected sendApiService: SendApiService,
protected dialogService: DialogServiceAbstraction
protected dialogService: DialogService,
protected formBuilder: FormBuilder
) {
this.typeOptions = [
{ name: i18nService.t("sendTypeFile"), value: SendType.File },
@@ -73,7 +125,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
get link(): string {
if (this.send.id != null && this.send.accessId != null) {
if (this.send != null && this.send.id != null && this.send.accessId != null) {
return this.sendLinkBaseUrl + this.send.accessId + "/" + this.send.urlB64Key;
}
return null;
@@ -93,13 +145,39 @@ export class AddEditComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe((policyAppliesToActiveUser) => {
this.disableSend = policyAppliesToActiveUser;
if (this.disableSend) {
this.formGroup.disable();
}
});
this.policyService
.policyAppliesToActiveUser$(PolicyType.SendOptions, (p) => p.data.disableHideEmail)
.pipe(takeUntil(this.destroy$))
.subscribe((policyAppliesToActiveUser) => {
this.disableHideEmail = policyAppliesToActiveUser;
if ((this.disableHideEmail = policyAppliesToActiveUser)) {
this.formGroup.controls.hideEmail.disable();
}
});
this.formGroup.controls.type.valueChanges.subscribe((val) => {
this.type = val;
this.typeChanged();
});
this.formGroup.controls.selectedDeletionDatePreset.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((datePreset) => {
datePreset === DatePreset.Custom
? this.formGroup.controls.defaultDeletionDateTime.enable()
: this.formGroup.controls.defaultDeletionDateTime.disable();
});
this.formGroup.controls.hideEmail.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((val) => {
if (!val && this.disableHideEmail) {
this.formGroup.controls.hideEmail.disable();
}
});
await this.load();
@@ -118,29 +196,33 @@ export class AddEditComponent implements OnInit, OnDestroy {
return this.i18nService.t(this.editMode ? "editSend" : "createSend");
}
setDates(event: { deletionDate: string; expirationDate: string }) {
this.deletionDate = event.deletionDate;
this.expirationDate = event.expirationDate;
}
async load() {
this.canAccessPremium = await this.stateService.getCanAccessPremium();
this.emailVerified = await this.stateService.getEmailVerified();
if (!this.canAccessPremium || !this.emailVerified) {
this.type = SendType.Text;
}
this.type = !this.canAccessPremium || !this.emailVerified ? SendType.Text : SendType.File;
if (this.send == null) {
if (this.editMode) {
const send = this.loadSend();
this.send = await send.decrypt();
this.type = this.send.type;
this.updateFormValues();
if (this.send.hideEmail) {
this.formGroup.controls.hideEmail.enable();
}
} else {
this.send = new SendView();
this.send.type = this.type == null ? SendType.File : this.type;
this.send.type = this.type;
this.send.file = new SendFileView();
this.send.text = new SendTextView();
this.send.deletionDate = new Date();
this.send.deletionDate.setDate(this.send.deletionDate.getDate() + 7);
this.formGroup.controls.type.patchValue(this.send.type);
this.formGroup.patchValue({
selectedDeletionDatePreset: DatePreset.SevenDays,
selectedExpirationDatePreset: DatePreset.Never,
});
}
}
@@ -148,6 +230,8 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
async submit(): Promise<boolean> {
this.formGroup.markAllAsTouched();
if (this.disableSend) {
this.platformUtilsService.showToast(
"error",
@@ -157,7 +241,18 @@ export class AddEditComponent implements OnInit, OnDestroy {
return false;
}
if (this.send.name == null || this.send.name === "") {
this.send.name = this.formGroup.controls.name.value;
this.send.text.text = this.formGroup.controls.text.value;
this.send.text.hidden = this.formGroup.controls.textHidden.value;
this.send.maxAccessCount = this.formGroup.controls.maxAccessCount.value;
this.send.accessCount = this.formGroup.controls.accessCount.value;
this.send.password = this.formGroup.controls.password.value;
this.send.notes = this.formGroup.controls.notes.value;
this.send.hideEmail = this.formGroup.controls.hideEmail.value;
this.send.disabled = this.formGroup.controls.disabled.value;
this.send.type = this.type;
if (Utils.isNullOrWhitespace(this.send.name)) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
@@ -167,7 +262,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
let file: File = null;
if (this.send.type === SendType.File && !this.editMode) {
if (this.type === SendType.File && !this.editMode) {
const fileEl = document.getElementById("file") as HTMLInputElement;
const files = fileEl.files;
if (files == null || files.length === 0) {
@@ -191,8 +286,8 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
}
if (this.password != null && this.password.trim() === "") {
this.password = null;
if (Utils.isNullOrWhitespace(this.send.password)) {
this.send.password = null;
}
this.formPromise = this.encryptSend(file).then(async (encSend) => {
@@ -205,7 +300,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.send.accessId = encSend[0].accessId;
}
this.onSavedSend.emit(this.send);
if (this.copyLink && this.link != null) {
if (this.formGroup.controls.copyLink.value && this.link != null) {
await this.handleCopyLinkToClipboard();
return;
}
@@ -228,7 +323,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
return Promise.resolve(this.platformUtilsService.copyToClipboard(link));
}
async delete(): Promise<boolean> {
protected async delete(): Promise<boolean> {
if (this.deletePromise != null) {
return false;
}
@@ -236,7 +331,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteSend" },
content: { key: "deleteSendConfirmation" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!confirmed) {
@@ -258,7 +353,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
typeChanged() {
if (this.send.type === SendType.File && !this.alertShown) {
if (this.type === SendType.File && !this.alertShown) {
if (!this.canAccessPremium) {
this.alertShown = true;
this.messagingService.send("premiumRequired");
@@ -267,6 +362,9 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.messagingService.send("emailVerificationRequired");
}
}
this.type === SendType.Text || this.editMode
? this.formGroup.controls.file.disable()
: this.formGroup.controls.file.enable();
}
toggleOptions() {
@@ -278,17 +376,18 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
protected async encryptSend(file: File): Promise<[Send, EncArrayBuffer]> {
const sendData = await this.sendService.encrypt(this.send, file, this.password, null);
const sendData = await this.sendService.encrypt(this.send, file, this.send.password, null);
// Parse dates
try {
sendData[0].deletionDate = this.deletionDate == null ? null : new Date(this.deletionDate);
sendData[0].deletionDate =
this.formattedDeletionDate == null ? null : new Date(this.formattedDeletionDate);
} catch {
sendData[0].deletionDate = null;
}
try {
sendData[0].expirationDate =
this.expirationDate == null ? null : new Date(this.expirationDate);
this.formattedExpirationDate == null ? null : new Date(this.formattedExpirationDate);
} catch {
sendData[0].expirationDate = null;
}
@@ -300,6 +399,34 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.showPassword = !this.showPassword;
document.getElementById("password").focus();
}
updateFormValues() {
this.formGroup.patchValue({
name: this.send?.name ?? "",
text: this.send?.text?.text ?? "",
textHidden: this.send?.text?.hidden ?? false,
link: this.link ?? "",
maxAccessCount: this.send?.maxAccessCount,
accessCount: this.send?.accessCount ?? 0,
notes: this.send?.notes ?? "",
hideEmail: this.send?.hideEmail ?? false,
disabled: this.send?.disabled ?? false,
type: this.send.type ?? this.type,
password: null,
selectedDeletionDatePreset: this.editMode ? DatePreset.Custom : DatePreset.SevenDays,
selectedExpirationDatePreset: this.editMode ? DatePreset.Custom : DatePreset.Never,
defaultExpirationDateTime:
this.send.expirationDate != null
? this.datePipe.transform(new Date(this.send.expirationDate), "yyyy-MM-ddTHH:mm")
: null,
defaultDeletionDateTime: this.datePipe.transform(
new Date(this.send.deletionDate),
"yyyy-MM-ddTHH:mm"
),
});
}
private async handleCopyLinkToClipboard() {
const copySuccess = await this.copyLinkToClipboard(this.link);
if (copySuccess ?? true) {
@@ -314,10 +441,52 @@ export class AddEditComponent implements OnInit, OnDestroy {
content: { key: this.editMode ? "editedSend" : "createdSend" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: SimpleDialogType.SUCCESS,
type: "success",
});
await this.copyLinkToClipboard(this.link);
}
}
clearExpiration() {
this.formGroup.controls.defaultExpirationDateTime.patchValue(null);
}
get formattedExpirationDate(): string {
switch (this.formGroup.controls.selectedExpirationDatePreset.value as DatePreset) {
case DatePreset.Never:
return null;
case DatePreset.Custom:
if (!this.formGroup.controls.defaultExpirationDateTime.value) {
return null;
}
return this.formGroup.controls.defaultExpirationDateTime.value;
default: {
const now = new Date();
const milliseconds = now.setTime(
now.getTime() +
(this.formGroup.controls.selectedExpirationDatePreset.value as number) * 60 * 60 * 1000
);
return new Date(milliseconds).toString();
}
}
}
get formattedDeletionDate(): string {
switch (this.formGroup.controls.selectedDeletionDatePreset.value as DatePreset) {
case DatePreset.Never:
this.formGroup.controls.selectedDeletionDatePreset.patchValue(DatePreset.SevenDays);
return this.formattedDeletionDate;
case DatePreset.Custom:
return this.formGroup.controls.defaultDeletionDateTime.value;
default: {
const now = new Date();
const milliseconds = now.setTime(
now.getTime() +
(this.formGroup.controls.selectedDeletionDatePreset.value as number) * 60 * 60 * 1000
);
return new Date(milliseconds).toString();
}
}
}
}

View File

@@ -1,356 +0,0 @@
import { DatePipe } from "@angular/common";
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { UntypedFormControl, UntypedFormGroup } from "@angular/forms";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
// Different BrowserPath = different controls.
enum BrowserPath {
// Native datetime-locale.
// We are happy.
Default = "default",
// Native date and time inputs, but no datetime-locale.
// We use individual date and time inputs and create a datetime programatically on submit.
Firefox = "firefox",
// No native date, time, or datetime-locale inputs.
// We use a polyfill for dates and a dropdown for times.
Safari = "safari",
}
enum DateField {
DeletionDate = "deletion",
ExpirationDate = "expiration",
}
// Value = hours
enum DatePreset {
OneHour = 1,
OneDay = 24,
TwoDays = 48,
ThreeDays = 72,
SevenDays = 168,
ThirtyDays = 720,
Custom = 0,
Never = null,
}
// TimeOption is used for the dropdown implementation of custom times
// twelveHour = displayed time; twentyFourHour = time used in logic
interface TimeOption {
twelveHour: string;
twentyFourHour: string;
}
@Directive()
export class EffluxDatesComponent implements OnInit {
@Input() readonly initialDeletionDate: Date;
@Input() readonly initialExpirationDate: Date;
@Input() readonly editMode: boolean;
@Input() readonly disabled: boolean;
@Output() datesChanged = new EventEmitter<{ deletionDate: string; expirationDate: string }>();
get browserPath(): BrowserPath {
if (this.platformUtilsService.isFirefox()) {
return BrowserPath.Firefox;
} else if (this.platformUtilsService.isSafari()) {
return BrowserPath.Safari;
}
return BrowserPath.Default;
}
datesForm = new UntypedFormGroup({
selectedDeletionDatePreset: new UntypedFormControl(),
selectedExpirationDatePreset: new UntypedFormControl(),
defaultDeletionDateTime: new UntypedFormControl(),
defaultExpirationDateTime: new UntypedFormControl(),
fallbackDeletionDate: new UntypedFormControl(),
fallbackDeletionTime: new UntypedFormControl(),
fallbackExpirationDate: new UntypedFormControl(),
fallbackExpirationTime: new UntypedFormControl(),
});
deletionDatePresets: any[] = [
{ name: this.i18nService.t("oneHour"), value: DatePreset.OneHour },
{ name: this.i18nService.t("oneDay"), value: DatePreset.OneDay },
{ name: this.i18nService.t("days", "2"), value: DatePreset.TwoDays },
{ name: this.i18nService.t("days", "3"), value: DatePreset.ThreeDays },
{ name: this.i18nService.t("days", "7"), value: DatePreset.SevenDays },
{ name: this.i18nService.t("days", "30"), value: DatePreset.ThirtyDays },
{ name: this.i18nService.t("custom"), value: DatePreset.Custom },
];
expirationDatePresets: any[] = [
{ name: this.i18nService.t("never"), value: DatePreset.Never },
].concat([...this.deletionDatePresets]);
get selectedDeletionDatePreset(): UntypedFormControl {
return this.datesForm.get("selectedDeletionDatePreset") as UntypedFormControl;
}
get selectedExpirationDatePreset(): UntypedFormControl {
return this.datesForm.get("selectedExpirationDatePreset") as UntypedFormControl;
}
get defaultDeletionDateTime(): UntypedFormControl {
return this.datesForm.get("defaultDeletionDateTime") as UntypedFormControl;
}
get defaultExpirationDateTime(): UntypedFormControl {
return this.datesForm.get("defaultExpirationDateTime") as UntypedFormControl;
}
get fallbackDeletionDate(): UntypedFormControl {
return this.datesForm.get("fallbackDeletionDate") as UntypedFormControl;
}
get fallbackDeletionTime(): UntypedFormControl {
return this.datesForm.get("fallbackDeletionTime") as UntypedFormControl;
}
get fallbackExpirationDate(): UntypedFormControl {
return this.datesForm.get("fallbackExpirationDate") as UntypedFormControl;
}
get fallbackExpirationTime(): UntypedFormControl {
return this.datesForm.get("fallbackExpirationTime") as UntypedFormControl;
}
// Should be able to call these at any time and compute a submitable value
get formattedDeletionDate(): string {
switch (this.selectedDeletionDatePreset.value as DatePreset) {
case DatePreset.Never:
this.selectedDeletionDatePreset.setValue(DatePreset.SevenDays);
return this.formattedDeletionDate;
case DatePreset.Custom:
switch (this.browserPath) {
case BrowserPath.Safari:
case BrowserPath.Firefox:
return this.fallbackDeletionDate.value + "T" + this.fallbackDeletionTime.value;
default:
return this.defaultDeletionDateTime.value;
}
default: {
const now = new Date();
const milliseconds = now.setTime(
now.getTime() + (this.selectedDeletionDatePreset.value as number) * 60 * 60 * 1000
);
return new Date(milliseconds).toString();
}
}
}
get formattedExpirationDate(): string {
switch (this.selectedExpirationDatePreset.value as DatePreset) {
case DatePreset.Never:
return null;
case DatePreset.Custom:
switch (this.browserPath) {
case BrowserPath.Safari:
case BrowserPath.Firefox:
if (
(!this.fallbackExpirationDate.value || !this.fallbackExpirationTime.value) &&
this.editMode
) {
return null;
}
return this.fallbackExpirationDate.value + "T" + this.fallbackExpirationTime.value;
default:
if (!this.defaultExpirationDateTime.value) {
return null;
}
return this.defaultExpirationDateTime.value;
}
default: {
const now = new Date();
const milliseconds = now.setTime(
now.getTime() + (this.selectedExpirationDatePreset.value as number) * 60 * 60 * 1000
);
return new Date(milliseconds).toString();
}
}
}
//
get safariDeletionTimePresetOptions() {
return this.safariTimePresetOptions(DateField.DeletionDate);
}
get safariExpirationTimePresetOptions() {
return this.safariTimePresetOptions(DateField.ExpirationDate);
}
private get nextWeek(): Date {
const nextWeek = new Date();
nextWeek.setDate(nextWeek.getDate() + 7);
return nextWeek;
}
constructor(
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected datePipe: DatePipe
) {}
ngOnInit(): void {
this.setInitialFormValues();
this.emitDates();
this.datesForm.valueChanges.subscribe(() => {
this.emitDates();
});
}
onDeletionDatePresetSelect(value: DatePreset) {
this.selectedDeletionDatePreset.setValue(value);
}
clearExpiration() {
switch (this.browserPath) {
case BrowserPath.Safari:
case BrowserPath.Firefox:
this.fallbackExpirationDate.setValue(null);
this.fallbackExpirationTime.setValue(null);
break;
case BrowserPath.Default:
this.defaultExpirationDateTime.setValue(null);
break;
}
}
protected emitDates() {
this.datesChanged.emit({
deletionDate: this.formattedDeletionDate,
expirationDate: this.formattedExpirationDate,
});
}
protected setInitialFormValues() {
if (this.editMode) {
this.selectedDeletionDatePreset.setValue(DatePreset.Custom);
this.selectedExpirationDatePreset.setValue(DatePreset.Custom);
switch (this.browserPath) {
case BrowserPath.Safari:
case BrowserPath.Firefox:
this.fallbackDeletionDate.setValue(this.initialDeletionDate.toISOString().slice(0, 10));
this.fallbackDeletionTime.setValue(this.initialDeletionDate.toTimeString().slice(0, 5));
if (this.initialExpirationDate != null) {
this.fallbackExpirationDate.setValue(
this.initialExpirationDate.toISOString().slice(0, 10)
);
this.fallbackExpirationTime.setValue(
this.initialExpirationDate.toTimeString().slice(0, 5)
);
}
break;
case BrowserPath.Default:
if (this.initialExpirationDate) {
this.defaultExpirationDateTime.setValue(
this.datePipe.transform(new Date(this.initialExpirationDate), "yyyy-MM-ddTHH:mm")
);
}
this.defaultDeletionDateTime.setValue(
this.datePipe.transform(new Date(this.initialDeletionDate), "yyyy-MM-ddTHH:mm")
);
break;
}
} else {
this.selectedDeletionDatePreset.setValue(DatePreset.SevenDays);
this.selectedExpirationDatePreset.setValue(DatePreset.Never);
switch (this.browserPath) {
case BrowserPath.Safari:
this.fallbackDeletionDate.setValue(this.nextWeek.toISOString().slice(0, 10));
this.fallbackDeletionTime.setValue(
this.safariTimePresetOptions(DateField.DeletionDate)[1].twentyFourHour
);
break;
default:
break;
}
}
}
protected safariTimePresetOptions(field: DateField): TimeOption[] {
// init individual arrays for major sort groups
const noon: TimeOption[] = [];
const midnight: TimeOption[] = [];
const ams: TimeOption[] = [];
const pms: TimeOption[] = [];
// determine minute skip (5 min, 10 min, 15 min, etc.)
const minuteIncrementer = 15;
// loop through each hour on a 12 hour system
for (let h = 1; h <= 12; h++) {
// loop through each minute in the hour using the skip to increment
for (let m = 0; m < 60; m += minuteIncrementer) {
// init the final strings that will be added to the lists
let hour = h.toString();
let minutes = m.toString();
// add prepending 0s to single digit hours/minutes
if (h < 10) {
hour = "0" + hour;
}
if (m < 10) {
minutes = "0" + minutes;
}
// build time strings and push to relevant sort groups
if (h === 12) {
const midnightOption: TimeOption = {
twelveHour: `${hour}:${minutes} AM`,
twentyFourHour: `00:${minutes}`,
};
midnight.push(midnightOption);
const noonOption: TimeOption = {
twelveHour: `${hour}:${minutes} PM`,
twentyFourHour: `${hour}:${minutes}`,
};
noon.push(noonOption);
} else {
const amOption: TimeOption = {
twelveHour: `${hour}:${minutes} AM`,
twentyFourHour: `${hour}:${minutes}`,
};
ams.push(amOption);
const pmOption: TimeOption = {
twelveHour: `${hour}:${minutes} PM`,
twentyFourHour: `${h + 12}:${minutes}`,
};
pms.push(pmOption);
}
}
}
// bring all the arrays together in the right order
const validTimes = [...midnight, ...ams, ...noon, ...pms];
// determine if an unsupported value already exists on the send & add that to the top of the option list
// example: if the Send was created with a different client
if (field === DateField.ExpirationDate && this.initialExpirationDate != null && this.editMode) {
const previousValue: TimeOption = {
twelveHour: this.datePipe.transform(this.initialExpirationDate, "hh:mm a"),
twentyFourHour: this.datePipe.transform(this.initialExpirationDate, "HH:mm"),
};
return [previousValue, { twelveHour: null, twentyFourHour: null }, ...validTimes];
} else if (
field === DateField.DeletionDate &&
this.initialDeletionDate != null &&
this.editMode
) {
const previousValue: TimeOption = {
twelveHour: this.datePipe.transform(this.initialDeletionDate, "hh:mm a"),
twentyFourHour: this.datePipe.transform(this.initialDeletionDate, "HH:mm"),
};
return [previousValue, ...validTimes];
} else {
return [{ twelveHour: null, twentyFourHour: null }, ...validTimes];
}
}
}

View File

@@ -12,8 +12,7 @@ import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog";
import { DialogService } from "@bitwarden/components";
@Directive()
export class SendComponent implements OnInit, OnDestroy {
@@ -59,7 +58,7 @@ export class SendComponent implements OnInit, OnDestroy {
protected policyService: PolicyService,
private logService: LogService,
protected sendApiService: SendApiService,
protected dialogService: DialogServiceAbstraction
protected dialogService: DialogService
) {}
async ngOnInit() {
@@ -139,7 +138,7 @@ export class SendComponent implements OnInit, OnDestroy {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "removePassword" },
content: { key: "removePasswordConfirmation" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!confirmed) {
@@ -170,7 +169,7 @@ export class SendComponent implements OnInit, OnDestroy {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteSend" },
content: { key: "deleteSendConfirmation" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!confirmed) {

View File

@@ -33,8 +33,7 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog";
import { DialogService } from "@bitwarden/components";
@Directive()
export class AddEditComponent implements OnInit, OnDestroy {
@@ -101,7 +100,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected passwordRepromptService: PasswordRepromptService,
private organizationService: OrganizationService,
protected sendApiService: SendApiService,
protected dialogService: DialogServiceAbstraction
protected dialogService: DialogService
) {
this.typeOptions = [
{ name: i18nService.t("typeLogin"), value: CipherType.Login },
@@ -402,7 +401,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
content: {
key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation",
},
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!confirmed) {
@@ -433,16 +432,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
return false;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "restoreItem" },
content: { key: "restoreItemConfirmation" },
type: SimpleDialogType.WARNING,
});
if (!confirmed) {
return false;
}
try {
this.restorePromise = this.restoreCipher();
await this.restorePromise;
@@ -461,7 +450,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "overwriteUsername" },
content: { key: "overwriteUsernameConfirmation" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!confirmed) {
@@ -478,7 +467,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "overwritePassword" },
content: { key: "overwritePasswordConfirmation" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!confirmed) {

View File

@@ -13,8 +13,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog";
import { DialogService } from "@bitwarden/components";
@Directive()
export class AttachmentsComponent implements OnInit {
@@ -43,7 +42,7 @@ export class AttachmentsComponent implements OnInit {
protected logService: LogService,
protected stateService: StateService,
protected fileDownloadService: FileDownloadService,
protected dialogService: DialogServiceAbstraction
protected dialogService: DialogService
) {}
async ngOnInit() {
@@ -106,7 +105,7 @@ export class AttachmentsComponent implements OnInit {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteAttachment" },
content: { key: "deleteAttachmentConfirmation" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!confirmed) {
@@ -192,7 +191,7 @@ export class AttachmentsComponent implements OnInit {
this.cipherDomain = await this.loadCipher();
this.cipher = await this.cipherDomain.decrypt();
this.hasUpdatedKey = await this.cryptoService.hasEncKey();
this.hasUpdatedKey = await this.cryptoService.hasUserKey();
const canAccessPremium = await this.stateService.getCanAccessPremium();
this.canAccessAttachments = canAccessPremium || this.cipher.organizationId != null;
@@ -201,7 +200,7 @@ export class AttachmentsComponent implements OnInit {
title: { key: "premiumRequired" },
content: { key: "premiumRequiredDesc" },
acceptButtonText: { key: "learnMore" },
type: SimpleDialogType.SUCCESS,
type: "success",
});
if (confirmed) {
@@ -212,7 +211,7 @@ export class AttachmentsComponent implements OnInit {
title: { key: "featureUnavailable" },
content: { key: "updateKey" },
acceptButtonText: { key: "learnMore" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (confirmed) {

View File

@@ -7,8 +7,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog";
import { DialogService } from "@bitwarden/components";
@Directive()
export class FolderAddEditComponent implements OnInit {
@@ -33,7 +32,7 @@ export class FolderAddEditComponent implements OnInit {
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected logService: LogService,
protected dialogService: DialogServiceAbstraction,
protected dialogService: DialogService,
protected formBuilder: FormBuilder
) {}
@@ -74,7 +73,7 @@ export class FolderAddEditComponent implements OnInit {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteFolder" },
content: { key: "deleteFolderConfirmation" },
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!confirmed) {

View File

@@ -27,7 +27,8 @@ export class PasswordRepromptComponent {
}
async submit() {
if (!(await this.cryptoService.compareAndUpdateKeyHash(this.masterPassword, null))) {
const storedMasterKey = await this.cryptoService.getOrDeriveMasterKey(this.masterPassword);
if (!(await this.cryptoService.compareAndUpdateKeyHash(this.masterPassword, storedMasterKey))) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),

View File

@@ -1,18 +1,19 @@
import { Directive, OnInit } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.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 { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog";
import { DialogService } from "@bitwarden/components";
@Directive()
export class PremiumComponent implements OnInit {
isPremium = false;
price = 10;
refreshPromise: Promise<any>;
cloudWebVaultUrl: string;
constructor(
protected i18nService: I18nService,
@@ -20,8 +21,11 @@ export class PremiumComponent implements OnInit {
protected apiService: ApiService,
private logService: LogService,
protected stateService: StateService,
protected dialogService: DialogServiceAbstraction
) {}
protected dialogService: DialogService,
private environmentService: EnvironmentService
) {
this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl();
}
async ngOnInit() {
this.isPremium = await this.stateService.getCanAccessPremium();
@@ -42,11 +46,11 @@ export class PremiumComponent implements OnInit {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "premiumPurchase" },
content: { key: "premiumPurchaseAlert" },
type: SimpleDialogType.INFO,
type: "info",
});
if (confirmed) {
this.platformUtilsService.launchUri("https://vault.bitwarden.com/#/?premium=purchase");
this.platformUtilsService.launchUri(`${this.cloudWebVaultUrl}/#/?premium=purchase`);
}
}
@@ -54,11 +58,11 @@ export class PremiumComponent implements OnInit {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "premiumManage" },
content: { key: "premiumManageAlert" },
type: SimpleDialogType.INFO,
type: "info",
});
if (confirmed) {
this.platformUtilsService.launchUri("https://vault.bitwarden.com/#/?premium=manage");
this.platformUtilsService.launchUri(`${this.cloudWebVaultUrl}/#/?premium=manage`);
}
}
}

View File

@@ -34,8 +34,7 @@ import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.v
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog";
import { DialogService } from "@bitwarden/components";
const BroadcasterSubscriptionId = "ViewComponent";
@@ -87,7 +86,7 @@ export class ViewComponent implements OnDestroy, OnInit {
private logService: LogService,
protected stateService: StateService,
protected fileDownloadService: FileDownloadService,
protected dialogService: DialogServiceAbstraction
protected dialogService: DialogService
) {}
ngOnInit() {
@@ -182,7 +181,7 @@ export class ViewComponent implements OnDestroy, OnInit {
content: {
key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation",
},
type: SimpleDialogType.WARNING,
type: "warning",
});
if (!confirmed) {
@@ -209,16 +208,6 @@ export class ViewComponent implements OnDestroy, OnInit {
return false;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "restoreItem" },
content: { key: "restoreItemConfirmation" },
type: SimpleDialogType.WARNING,
});
if (!confirmed) {
return false;
}
try {
await this.restoreCipher();
this.platformUtilsService.showToast("success", null, this.i18nService.t("restoredItem"));

View File

@@ -0,0 +1,34 @@
import { MockProxy, mock } from "jest-mock-extended";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { ModalService } from "../../services/modal.service";
import { PasswordRepromptService } from "./password-reprompt.service";
describe("PasswordRepromptService", () => {
let passwordRepromptService: PasswordRepromptService;
let userVerificationService: MockProxy<UserVerificationService>;
let modalService: MockProxy<ModalService>;
beforeEach(() => {
modalService = mock<ModalService>();
userVerificationService = mock<UserVerificationService>();
passwordRepromptService = new PasswordRepromptService(modalService, userVerificationService);
});
describe("enabled()", () => {
it("returns false if a user does not have a master password", async () => {
userVerificationService.hasMasterPasswordAndMasterKeyHash.mockResolvedValue(false);
expect(await passwordRepromptService.enabled()).toBe(false);
});
it("returns true if the user has a master password", async () => {
userVerificationService.hasMasterPasswordAndMasterKeyHash.mockResolvedValue(true);
expect(await passwordRepromptService.enabled()).toBe(true);
});
});
});

View File

@@ -1,6 +1,6 @@
import { Injectable } from "@angular/core";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from "@bitwarden/common/vault/abstractions/password-reprompt.service";
import { ModalService } from "../../services/modal.service";
@@ -16,7 +16,7 @@ export class PasswordRepromptService implements PasswordRepromptServiceAbstracti
constructor(
private modalService: ModalService,
private keyConnectorService: KeyConnectorService
private userVerificationService: UserVerificationService
) {}
protectedFields() {
@@ -39,6 +39,6 @@ export class PasswordRepromptService implements PasswordRepromptServiceAbstracti
}
async enabled() {
return !(await this.keyConnectorService.getUsesKeyConnector());
return await this.userVerificationService.hasMasterPasswordAndMasterKeyHash();
}
}

3
libs/auth/README.md Normal file
View File

@@ -0,0 +1,3 @@
# Auth
This lib represents the public API of the Auth team at Bitwarden. Modules are imported using `@bitwarden/auth`.

16
libs/auth/jest.config.js Normal file
View File

@@ -0,0 +1,16 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("../shared/tsconfig.libs");
const sharedConfig = require("../../libs/shared/jest.config.angular");
/** @type {import('jest').Config} */
module.exports = {
...sharedConfig,
displayName: "libs/auth tests",
preset: "jest-preset-angular",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
prefix: "<rootDir>/",
}),
};

20
libs/auth/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "@bitwarden/auth",
"version": "0.0.0",
"description": "Common code used across Bitwarden JavaScript projects.",
"keywords": [
"bitwarden"
],
"author": "Bitwarden Inc.",
"homepage": "https://bitwarden.com",
"repository": {
"type": "git",
"url": "https://github.com/bitwarden/clients"
},
"license": "GPL-3.0",
"scripts": {
"clean": "rimraf dist",
"build": "npm run clean && tsc",
"build:watch": "npm run clean && tsc -watch"
}
}

View File

@@ -0,0 +1,23 @@
<bit-simple-dialog>
<i bitDialogIcon class="bwi bwi-info-circle tw-text-3xl" aria-hidden="true"></i>
<span bitDialogTitle>{{ "yourAccountsFingerprint" | i18n }}:</span>
<span bitDialogContent>
<strong>{{ data.fingerprint.join("-") }}</strong>
</span>
<ng-container bitDialogFooter>
<a
bitButton
href="https://bitwarden.com/help/fingerprint-phrase/"
target="_blank"
rel="noopener"
buttonType="primary"
bitDialogClose
>
{{ "learnMore" | i18n }}
<i class="bwi bwi-external-link bwi-fw" aria-hidden="true"></i>
</a>
<button bitButton type="button" buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</ng-container>
</bit-simple-dialog>

View File

@@ -0,0 +1,22 @@
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
export type FingerprintDialogData = {
fingerprint: string[];
};
@Component({
templateUrl: "fingerprint-dialog.component.html",
standalone: true,
imports: [JslibModule, ButtonModule, DialogModule],
})
export class FingerprintDialogComponent {
constructor(@Inject(DIALOG_DATA) protected data: FingerprintDialogData) {}
static open(dialogService: DialogService, data: FingerprintDialogData) {
return dialogService.open(FingerprintDialogComponent, { data });
}
}

1
libs/auth/src/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./components/fingerprint-dialog.component";

28
libs/auth/test.setup.ts Normal file
View File

@@ -0,0 +1,28 @@
import { webcrypto } from "crypto";
import "jest-preset-angular/setup-jest";
Object.defineProperty(window, "CSS", { value: null });
Object.defineProperty(window, "getComputedStyle", {
value: () => {
return {
display: "none",
appearance: ["-webkit-appearance"],
};
},
});
Object.defineProperty(document, "doctype", {
value: "<!DOCTYPE html>",
});
Object.defineProperty(document.body.style, "transform", {
value: () => {
return {
enumerable: true,
configurable: true,
};
},
});
Object.defineProperty(window, "crypto", {
value: webcrypto,
});

5
libs/auth/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "../shared/tsconfig.libs",
"include": ["src", "spec"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"files": ["./test.setup.ts"]
}

View File

@@ -200,6 +200,7 @@ export abstract class ApiService {
postConvertToKeyConnector: () => Promise<void>;
//passwordless
postAuthRequest: (request: PasswordlessCreateAuthRequest) => Promise<AuthRequestResponse>;
postAdminAuthRequest: (request: PasswordlessCreateAuthRequest) => Promise<AuthRequestResponse>;
getAuthResponse: (id: string, accessCode: string) => Promise<AuthRequestResponse>;
getAuthRequest: (id: string) => Promise<AuthRequestResponse>;
putAuthRequest: (id: string, request: PasswordlessAuthRequest) => Promise<AuthRequestResponse>;
@@ -523,7 +524,7 @@ export abstract class ApiService {
) => Promise<void>;
postResendSponsorshipOffer: (sponsoringOrgId: string) => Promise<void>;
getUserKeyFromKeyConnector: (keyConnectorUrl: string) => Promise<KeyConnectorUserKeyResponse>;
getMasterKeyFromKeyConnector: (keyConnectorUrl: string) => Promise<KeyConnectorUserKeyResponse>;
postUserKeyToKeyConnector: (
keyConnectorUrl: string,
request: KeyConnectorUserKeyRequest

View File

@@ -1,8 +0,0 @@
import { DeviceKey } from "../platform/models/domain/symmetric-crypto-key";
import { DeviceResponse } from "./devices/responses/device.response";
export abstract class DeviceCryptoServiceAbstraction {
trustDevice: () => Promise<DeviceResponse>;
getDeviceKey: () => Promise<DeviceKey>;
}

View File

@@ -1,14 +0,0 @@
import { DeviceResponse } from "./responses/device.response";
export abstract class DevicesApiServiceAbstraction {
getKnownDevice: (email: string, deviceIdentifier: string) => Promise<boolean>;
getDeviceByIdentifier: (deviceIdentifier: string) => Promise<DeviceResponse>;
updateTrustedDeviceKeys: (
deviceIdentifier: string,
devicePublicKeyEncryptedUserSymKey: string,
userSymKeyEncryptedDevicePublicKey: string,
deviceKeyEncryptedDevicePrivateKey: string
) => Promise<DeviceResponse>;
}

View File

@@ -0,0 +1,15 @@
import { Observable } from "rxjs";
import { DeviceView } from "./views/device.view";
export abstract class DevicesServiceAbstraction {
getDevices$: () => Observable<Array<DeviceView>>;
getDeviceByIdentifier$: (deviceIdentifier: string) => Observable<DeviceView>;
isDeviceKnownForUser$: (email: string, deviceIdentifier: string) => Observable<boolean>;
updateTrustedDeviceKeys$: (
deviceIdentifier: string,
devicePublicKeyEncryptedUserKey: string,
userKeyEncryptedDevicePublicKey: string,
deviceKeyEncryptedDevicePrivateKey: string
) => Observable<DeviceView>;
}

View File

@@ -3,23 +3,20 @@ import { BaseResponse } from "../../../models/response/base.response";
export class DeviceResponse extends BaseResponse {
id: string;
name: number;
userId: string;
name: string;
identifier: string;
type: DeviceType;
creationDate: string;
encryptedUserKey: string;
encryptedPublicKey: string;
encryptedPrivateKey: string;
revisionDate: string;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.userId = this.getResponseProperty("UserId");
this.name = this.getResponseProperty("Name");
this.identifier = this.getResponseProperty("Identifier");
this.type = this.getResponseProperty("Type");
this.creationDate = this.getResponseProperty("CreationDate");
this.encryptedUserKey = this.getResponseProperty("EncryptedUserKey");
this.encryptedPublicKey = this.getResponseProperty("EncryptedPublicKey");
this.encryptedPrivateKey = this.getResponseProperty("EncryptedPrivateKey");
this.revisionDate = this.getResponseProperty("RevisionDate");
}
}

View File

@@ -0,0 +1,17 @@
import { DeviceType } from "../../../enums";
import { View } from "../../../models/view/view";
import { DeviceResponse } from "../responses/device.response";
export class DeviceView implements View {
id: string;
userId: string;
name: string;
identifier: string;
type: DeviceType;
creationDate: string;
revisionDate: string;
constructor(deviceResponse: DeviceResponse) {
Object.assign(this, deviceResponse);
}
}

View File

@@ -0,0 +1,56 @@
import { Observable } from "rxjs";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { PinLockType } from "../../services/vault-timeout/vault-timeout-settings.service";
export abstract class VaultTimeoutSettingsService {
/**
* Set the vault timeout options for the user
* @param vaultTimeout The vault timeout in minutes
* @param vaultTimeoutAction The vault timeout action
* @param userId The user id to set. If not provided, the current user is used
*/
setVaultTimeoutOptions: (
vaultTimeout: number,
vaultTimeoutAction: VaultTimeoutAction
) => Promise<void>;
/**
* Get the available vault timeout actions for the current user
*
* **NOTE:** This observable is not yet connected to the state service, so it will not update when the state changes
* @param userId The user id to check. If not provided, the current user is used
*/
availableVaultTimeoutActions$: (userId?: string) => Observable<VaultTimeoutAction[]>;
/**
* Get the current vault timeout action for the user. This is not the same as the current state, it is
* calculated based on the current state, the user's policy, and the user's available unlock methods.
*/
getVaultTimeout: (userId?: string) => Promise<number>;
/**
* Observe the vault timeout action for the user. This is calculated based on users preferred lock action saved in the state,
* the user's policy, and the user's available unlock methods.
*
* **NOTE:** This observable is not yet connected to the state service, so it will not update when the state changes
* @param userId The user id to check. If not provided, the current user is used
*/
vaultTimeoutAction$: (userId?: string) => Observable<VaultTimeoutAction>;
/**
* Has the user enabled unlock with Pin.
* @param userId The user id to check. If not provided, the current user is used
* @returns PinLockType
*/
isPinLockSet: (userId?: string) => Promise<PinLockType>;
/**
* Has the user enabled unlock with Biometric.
* @param userId The user id to check. If not provided, the current user is used
* @returns boolean true if biometric lock is set
*/
isBiometricLockSet: (userId?: string) => Promise<boolean>;
clear: (userId?: string) => Promise<void>;
}

View File

@@ -1,13 +0,0 @@
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
export abstract class VaultTimeoutSettingsService {
setVaultTimeoutOptions: (
vaultTimeout: number,
vaultTimeoutAction: VaultTimeoutAction
) => Promise<void>;
getVaultTimeout: (userId?: string) => Promise<number>;
getVaultTimeoutAction: (userId?: string) => Promise<VaultTimeoutAction>;
isPinLockSet: () => Promise<[boolean, boolean]>;
isBiometricLockSet: () => Promise<boolean>;
clear: (userId?: string) => Promise<void>;
}

View File

@@ -6,7 +6,7 @@ import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
export function canAccessVaultTab(org: Organization): boolean {
return org.canViewAssignedCollections || org.canViewAllCollections || org.canManageGroups;
return org.canViewAssignedCollections || org.canViewAllCollections;
}
export function canAccessSettingsTab(org: Organization): boolean {

View File

@@ -179,7 +179,7 @@ export class Organization {
}
get canViewAllCollections() {
return this.canCreateNewCollections || this.canEditAnyCollection || this.canDeleteAnyCollection;
return this.canEditAnyCollection || this.canDeleteAnyCollection;
}
get canEditAssignedCollections() {

View File

@@ -0,0 +1,24 @@
import { UserKey, MasterKey } from "../../platform/models/domain/symmetric-crypto-key";
import { AuthRequestResponse } from "../models/response/auth-request.response";
export abstract class AuthRequestCryptoServiceAbstraction {
setUserKeyAfterDecryptingSharedUserKey: (
authReqResponse: AuthRequestResponse,
authReqPrivateKey: ArrayBuffer
) => Promise<void>;
setKeysAfterDecryptingSharedMasterKeyAndHash: (
authReqResponse: AuthRequestResponse,
authReqPrivateKey: ArrayBuffer
) => Promise<void>;
decryptPubKeyEncryptedUserKey: (
pubKeyEncryptedUserKey: string,
privateKey: ArrayBuffer
) => Promise<UserKey>;
decryptPubKeyEncryptedMasterKeyAndHash: (
pubKeyEncryptedMasterKey: string,
pubKeyEncryptedMasterKeyHash: string,
privateKey: ArrayBuffer
) => Promise<{ masterKey: MasterKey; masterKeyHash: string }>;
}

View File

@@ -1,7 +1,7 @@
import { Observable } from "rxjs";
import { AuthRequestPushNotification } from "../../models/response/notification.response";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { MasterKey } from "../../platform/models/domain/symmetric-crypto-key";
import { AuthenticationStatus } from "../enums/authentication-status";
import { AuthResult } from "../models/domain/auth-result";
import {
@@ -32,7 +32,7 @@ export abstract class AuthService {
captchaResponse: string
) => Promise<AuthResult>;
logOut: (callback: () => void) => void;
makePreloginKey: (masterPassword: string, email: string) => Promise<SymmetricCryptoKey>;
makePreloginKey: (masterPassword: string, email: string) => Promise<MasterKey>;
authingWithUserApiKey: () => boolean;
authingWithSso: () => boolean;
authingWithPassword: () => boolean;

View File

@@ -0,0 +1,25 @@
import { DeviceResponse } from "../../abstractions/devices/responses/device.response";
import { EncString } from "../../platform/models/domain/enc-string";
import { DeviceKey, UserKey } from "../../platform/models/domain/symmetric-crypto-key";
export abstract class DeviceTrustCryptoServiceAbstraction {
/**
* @description Retrieves the users choice to trust the device which can only happen after decryption
* Note: this value should only be used once and then reset
*/
getShouldTrustDevice: () => Promise<boolean | null>;
setShouldTrustDevice: (value: boolean) => Promise<void>;
trustDeviceIfRequired: () => Promise<void>;
trustDevice: () => Promise<DeviceResponse>;
getDeviceKey: () => Promise<DeviceKey>;
decryptUserKeyWithDeviceKey: (
encryptedDevicePrivateKey: EncString,
encryptedUserKey: EncString,
deviceKey?: DeviceKey
) => Promise<UserKey | null>;
rotateDevicesTrust: (newUserKey: UserKey, masterPasswordHash: string) => Promise<void>;
supportsDeviceTrust: () => Promise<boolean>;
}

View File

@@ -0,0 +1,27 @@
import { DeviceResponse } from "../../abstractions/devices/responses/device.response";
import { ListResponse } from "../../models/response/list.response";
import { SecretVerificationRequest } from "../models/request/secret-verification.request";
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
export abstract class DevicesApiServiceAbstraction {
getKnownDevice: (email: string, deviceIdentifier: string) => Promise<boolean>;
getDeviceByIdentifier: (deviceIdentifier: string) => Promise<DeviceResponse>;
getDevices: () => Promise<ListResponse<DeviceResponse>>;
updateTrustedDeviceKeys: (
deviceIdentifier: string,
devicePublicKeyEncryptedUserKey: string,
userKeyEncryptedDevicePublicKey: string,
deviceKeyEncryptedDevicePrivateKey: string
) => Promise<DeviceResponse>;
updateTrust: (updateDevicesTrustRequestModel: UpdateDevicesTrustRequest) => Promise<void>;
getDeviceKeys: (
deviceIdentifier: string,
secretVerificationRequest: SecretVerificationRequest
) => Promise<ProtectedDeviceResponse>;
}

View File

@@ -2,7 +2,7 @@ import { Organization } from "../../admin-console/models/domain/organization";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
export abstract class KeyConnectorService {
getAndSetKey: (url?: string) => Promise<void>;
setMasterKeyFromUrl: (url?: string) => Promise<void>;
getManagingOrganization: () => Promise<Organization>;
getUsesKeyConnector: () => Promise<boolean>;
migrateUser: () => Promise<void>;

View File

@@ -0,0 +1,26 @@
import { UserKey } from "../../platform/models/domain/symmetric-crypto-key";
export abstract class PasswordResetEnrollmentServiceAbstraction {
/*
* Checks the user's enrollment status and enrolls them if required
*/
abstract enrollIfRequired(organizationSsoIdentifier: string): Promise<void>;
/**
* Enroll current user in password reset
* @param organizationId - Organization in which to enroll the user
* @returns Promise that resolves when the user is enrolled
* @throws Error if the action fails
*/
abstract enroll(organizationId: string): Promise<void>;
/**
* Enroll user in password reset
* @param organizationId - Organization in which to enroll the user
* @param userId - User to enroll
* @param userKey - User's symmetric key
* @returns Promise that resolves when the user is enrolled
* @throws Error if the action fails
*/
abstract enroll(organizationId: string, userId: string, userKey: UserKey): Promise<void>;
}

View File

@@ -9,4 +9,16 @@ export abstract class UserVerificationService {
) => Promise<T>;
verifyUser: (verification: Verification) => Promise<boolean>;
requestOTP: () => Promise<void>;
/**
* Check if user has master password or only uses passwordless technologies to log in
* @param userId The user id to check. If not provided, the current user is used
* @returns True if the user has a master password
*/
hasMasterPassword: (userId?: string) => Promise<boolean>;
/**
* Check if the user has a master password and has used it during their current session
* @param userId The user id to check. If not provided, the current user id used
* @returns True if the user has a master password and has used it in the current session
*/
hasMasterPasswordAndMasterKeyHash: (userId?: string) => Promise<boolean>;
}

View File

@@ -1,4 +1,5 @@
export enum AuthRequestType {
AuthenticateAndUnlock = 0,
Unlock = 1,
AdminApproval = 2,
}

View File

@@ -9,12 +9,25 @@ import { MessagingService } from "../../platform/abstractions/messaging.service"
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import { Account, AccountProfile, AccountTokens } from "../../platform/models/domain/account";
import {
Account,
AccountDecryptionOptions,
AccountKeys,
AccountProfile,
AccountTokens,
} from "../../platform/models/domain/account";
import { EncString } from "../../platform/models/domain/enc-string";
import {
DeviceKey,
MasterKey,
SymmetricCryptoKey,
UserKey,
} from "../../platform/models/domain/symmetric-crypto-key";
import {
PasswordStrengthService,
PasswordStrengthServiceAbstraction,
} from "../../tools/password-strength";
import { CsprngArray } from "../../types/csprng";
import { AuthService } from "../abstractions/auth.service";
import { TokenService } from "../abstractions/token.service";
import { TwoFactorService } from "../abstractions/two-factor.service";
@@ -28,6 +41,7 @@ import { IdentityCaptchaResponse } from "../models/response/identity-captcha.res
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response";
import { IUserDecryptionOptionsServerResponse } from "../models/response/user-decryption-options/user-decryption-options.response";
import { PasswordLogInStrategy } from "./password-login.strategy";
@@ -37,7 +51,7 @@ const masterPassword = "password";
const deviceId = Utils.newGuid();
const accessToken = "ACCESS_TOKEN";
const refreshToken = "REFRESH_TOKEN";
const encKey = "ENC_KEY";
const userKey = "USER_KEY";
const privateKey = "PRIVATE_KEY";
const captchaSiteKey = "CAPTCHA_SITE_KEY";
const kdf = 0;
@@ -45,6 +59,9 @@ const kdfIterations = 10000;
const userId = Utils.newGuid();
const masterPasswordHash = "MASTER_PASSWORD_HASH";
const name = "NAME";
const defaultUserDecryptionOptionsServerResponse: IUserDecryptionOptionsServerResponse = {
HasMasterPassword: true,
};
const decodedToken = {
sub: userId,
@@ -58,13 +75,14 @@ const twoFactorToken = "TWO_FACTOR_TOKEN";
const twoFactorRemember = true;
export function identityTokenResponseFactory(
masterPasswordPolicyResponse: MasterPasswordPolicyResponse = null
masterPasswordPolicyResponse: MasterPasswordPolicyResponse = null,
userDecryptionOptions: IUserDecryptionOptionsServerResponse = null
) {
return new IdentityTokenResponse({
ForcePasswordReset: false,
Kdf: kdf,
KdfIterations: kdfIterations,
Key: encKey,
Key: userKey,
PrivateKey: privateKey,
ResetMasterPassword: false,
access_token: accessToken,
@@ -73,9 +91,11 @@ export function identityTokenResponseFactory(
scope: "api offline_access",
token_type: "Bearer",
MasterPasswordPolicy: masterPasswordPolicyResponse,
UserDecryptionOptions: userDecryptionOptions || defaultUserDecryptionOptionsServerResponse,
});
}
// TODO: add tests for latest changes to base class for TDE
describe("LogInStrategy", () => {
let cryptoService: MockProxy<CryptoService>;
let apiService: MockProxy<ApiService>;
@@ -129,8 +149,23 @@ describe("LogInStrategy", () => {
});
describe("base class", () => {
it("sets the local environment after a successful login", async () => {
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());
const userKeyBytesLength = 64;
const masterKeyBytesLength = 64;
let userKey: UserKey;
let masterKey: MasterKey;
beforeEach(() => {
userKey = new SymmetricCryptoKey(
new Uint8Array(userKeyBytesLength).buffer as CsprngArray
) as UserKey;
masterKey = new SymmetricCryptoKey(
new Uint8Array(masterKeyBytesLength).buffer as CsprngArray
) as MasterKey;
});
it("sets the local environment after a successful login with master password", async () => {
const idTokenResponse = identityTokenResponseFactory();
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
await passwordLogInStrategy.logIn(credentials);
@@ -154,13 +189,36 @@ describe("LogInStrategy", () => {
refreshToken: refreshToken,
},
},
keys: new AccountKeys(),
decryptionOptions: AccountDecryptionOptions.fromResponse(idTokenResponse),
})
);
expect(cryptoService.setEncKey).toHaveBeenCalledWith(encKey);
expect(cryptoService.setEncPrivateKey).toHaveBeenCalledWith(privateKey);
expect(messagingService.send).toHaveBeenCalledWith("loggedIn");
});
it("persists a device key for trusted device encryption when it exists on login", async () => {
// Arrange
const idTokenResponse = identityTokenResponseFactory();
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
const deviceKey = new SymmetricCryptoKey(
new Uint8Array(userKeyBytesLength).buffer as CsprngArray
) as DeviceKey;
stateService.getDeviceKey.mockResolvedValue(deviceKey);
const accountKeys = new AccountKeys();
accountKeys.deviceKey = deviceKey;
// Act
await passwordLogInStrategy.logIn(credentials);
// Assert
expect(stateService.addAccount).toHaveBeenCalledWith(
expect.objectContaining({ keys: accountKeys })
);
});
it("builds AuthResult", async () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.forcePasswordReset = true;
@@ -187,6 +245,8 @@ describe("LogInStrategy", () => {
});
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
cryptoService.getMasterKey.mockResolvedValue(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
const result = await passwordLogInStrategy.logIn(credentials);
@@ -204,13 +264,15 @@ describe("LogInStrategy", () => {
cryptoService.makeKeyPair.mockResolvedValue(["PUBLIC_KEY", new EncString("PRIVATE_KEY")]);
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
cryptoService.getMasterKey.mockResolvedValue(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
await passwordLogInStrategy.logIn(credentials);
// User key must be set before the new RSA keypair is generated, otherwise we can't decrypt the EncKey
expect(cryptoService.setKey).toHaveBeenCalled();
// User symmetric key must be set before the new RSA keypair is generated
expect(cryptoService.setUserKey).toHaveBeenCalled();
expect(cryptoService.makeKeyPair).toHaveBeenCalled();
expect(cryptoService.setKey.mock.invocationCallOrder[0]).toBeLessThan(
expect(cryptoService.setUserKey.mock.invocationCallOrder[0]).toBeLessThan(
cryptoService.makeKeyPair.mock.invocationCallOrder[0]
);

View File

@@ -6,7 +6,13 @@ import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Account, AccountProfile, AccountTokens } from "../../platform/models/domain/account";
import {
Account,
AccountDecryptionOptions,
AccountKeys,
AccountProfile,
AccountTokens,
} from "../../platform/models/domain/account";
import { TokenService } from "../abstractions/token.service";
import { TwoFactorService } from "../abstractions/two-factor.service";
import { TwoFactorProviderType } from "../enums/two-factor-provider-type";
@@ -53,9 +59,6 @@ export abstract class LogInStrategy {
| PasswordlessLogInCredentials
): Promise<AuthResult>;
// The user key comes from different sources depending on the login strategy
protected abstract setUserKey(response: IdentityTokenResponse): Promise<void>;
async logInTwoFactor(
twoFactor: TokenTwoFactorRequest,
captchaResponse: string = null
@@ -101,12 +104,28 @@ export abstract class LogInStrategy {
protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) {
const accountInformation = await this.tokenService.decodeToken(tokenResponse.accessToken);
// Must persist existing device key if it exists for trusted device decryption to work
// However, we must provide a user id so that the device key can be retrieved
// as the state service won't have an active account at this point in time
// even though the data exists in local storage.
const userId = accountInformation.sub;
const deviceKey = await this.stateService.getDeviceKey({ userId });
const accountKeys = new AccountKeys();
if (deviceKey) {
accountKeys.deviceKey = deviceKey;
}
// If you don't persist existing admin auth requests on login, they will get deleted.
const adminAuthRequest = await this.stateService.getAdminAuthRequest({ userId });
await this.stateService.addAccount(
new Account({
profile: {
...new AccountProfile(),
...{
userId: accountInformation.sub,
userId,
name: accountInformation.name,
email: accountInformation.email,
hasPremiumPersonally: accountInformation.premium,
@@ -123,6 +142,9 @@ export abstract class LogInStrategy {
refreshToken: tokenResponse.refreshToken,
},
},
keys: accountKeys,
decryptionOptions: AccountDecryptionOptions.fromResponse(tokenResponse),
adminAuthRequest: adminAuthRequest?.toJSON(),
})
);
}
@@ -135,28 +157,41 @@ export abstract class LogInStrategy {
result.forcePasswordReset = ForceResetPasswordReason.AdminForcePasswordReset;
}
// Must come before setting keys, user key needs email to update additional keys
await this.saveAccountInformation(response);
if (response.twoFactorToken != null) {
await this.tokenService.setTwoFactorToken(response);
}
await this.setMasterKey(response);
await this.setUserKey(response);
// Must come after the user Key is set, otherwise createKeyPairForOldAccount will fail
const newSsoUser = response.key == null;
if (!newSsoUser) {
await this.cryptoService.setEncKey(response.key);
await this.cryptoService.setEncPrivateKey(
response.privateKey ?? (await this.createKeyPairForOldAccount())
);
}
await this.setPrivateKey(response);
this.messagingService.send("loggedIn");
return result;
}
// The keys comes from different sources depending on the login strategy
protected abstract setMasterKey(response: IdentityTokenResponse): Promise<void>;
protected abstract setUserKey(response: IdentityTokenResponse): Promise<void>;
protected abstract setPrivateKey(response: IdentityTokenResponse): Promise<void>;
protected async createKeyPairForOldAccount() {
try {
const [publicKey, privateKey] = await this.cryptoService.makeKeyPair();
await this.apiService.postAccountKeys(new KeysRequest(publicKey, privateKey.encryptedString));
return privateKey.encryptedString;
} catch (e) {
this.logService.error(e);
}
}
private async processTwoFactorResponse(response: IdentityTwoFactorResponse): Promise<AuthResult> {
const result = new AuthResult();
result.twoFactorProviders = response.twoFactorProviders2;
@@ -173,14 +208,4 @@ export abstract class LogInStrategy {
result.captchaSiteKey = response.siteKey;
return result;
}
private async createKeyPairForOldAccount() {
try {
const [publicKey, privateKey] = await this.cryptoService.makeKeyPair();
await this.apiService.postAccountKeys(new KeysRequest(publicKey, privateKey.encryptedString));
return privateKey.encryptedString;
} catch (e) {
this.logService.error(e);
}
}
}

View File

@@ -10,17 +10,23 @@ import { MessagingService } from "../../platform/abstractions/messaging.service"
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import {
MasterKey,
SymmetricCryptoKey,
UserKey,
} from "../../platform/models/domain/symmetric-crypto-key";
import {
PasswordStrengthService,
PasswordStrengthServiceAbstraction,
} from "../../tools/password-strength";
import { CsprngArray } from "../../types/csprng";
import { AuthService } from "../abstractions/auth.service";
import { TokenService } from "../abstractions/token.service";
import { TwoFactorService } from "../abstractions/two-factor.service";
import { TwoFactorProviderType } from "../enums/two-factor-provider-type";
import { ForceResetPasswordReason } from "../models/domain/force-reset-password-reason";
import { PasswordLogInCredentials } from "../models/domain/log-in-credentials";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response";
@@ -31,11 +37,11 @@ const email = "hello@world.com";
const masterPassword = "password";
const hashedPassword = "HASHED_PASSWORD";
const localHashedPassword = "LOCAL_HASHED_PASSWORD";
const preloginKey = new SymmetricCryptoKey(
const masterKey = new SymmetricCryptoKey(
Utils.fromB64ToArray(
"N2KWjlLpfi5uHjv+YcfUKIpZ1l+W+6HRensmIqD+BFYBf6N/dvFpJfWwYnVBdgFCK2tJTAIMLhqzIQQEUmGFgg=="
)
);
) as MasterKey;
const deviceId = Utils.newGuid();
const masterPasswordPolicy = new MasterPasswordPolicyResponse({
EnforceOnLogin: true,
@@ -58,6 +64,7 @@ describe("PasswordLogInStrategy", () => {
let passwordLogInStrategy: PasswordLogInStrategy;
let credentials: PasswordLogInCredentials;
let tokenResponse: IdentityTokenResponse;
beforeEach(async () => {
cryptoService = mock<CryptoService>();
@@ -76,12 +83,12 @@ describe("PasswordLogInStrategy", () => {
appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeToken.mockResolvedValue({});
authService.makePreloginKey.mockResolvedValue(preloginKey);
authService.makePreloginKey.mockResolvedValue(masterKey);
cryptoService.hashPassword
cryptoService.hashMasterKey
.calledWith(masterPassword, expect.anything(), undefined)
.mockResolvedValue(hashedPassword);
cryptoService.hashPassword
cryptoService.hashMasterKey
.calledWith(masterPassword, expect.anything(), HashPurpose.LocalAuthorization)
.mockResolvedValue(localHashedPassword);
@@ -102,10 +109,9 @@ describe("PasswordLogInStrategy", () => {
authService
);
credentials = new PasswordLogInCredentials(email, masterPassword);
tokenResponse = identityTokenResponseFactory(masterPasswordPolicy);
apiService.postIdentityToken.mockResolvedValue(
identityTokenResponseFactory(masterPasswordPolicy)
);
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
});
it("sends master password credentials to the server", async () => {
@@ -127,15 +133,23 @@ describe("PasswordLogInStrategy", () => {
);
});
it("sets the local environment after a successful login", async () => {
it("sets keys after a successful authentication", async () => {
const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
cryptoService.getMasterKey.mockResolvedValue(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
await passwordLogInStrategy.logIn(credentials);
expect(cryptoService.setKey).toHaveBeenCalledWith(preloginKey);
expect(cryptoService.setKeyHash).toHaveBeenCalledWith(localHashedPassword);
expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey);
expect(cryptoService.setMasterKeyHash).toHaveBeenCalledWith(localHashedPassword);
expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey);
expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey);
});
it("does not force the user to update their master password when there are no requirements", async () => {
apiService.postIdentityToken.mockResolvedValueOnce(identityTokenResponseFactory(null));
apiService.postIdentityToken.mockResolvedValueOnce(identityTokenResponseFactory());
const result = await passwordLogInStrategy.logIn(credentials);

View File

@@ -8,7 +8,7 @@ import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { MasterKey } from "../../platform/models/domain/symmetric-crypto-key";
import { PasswordStrengthServiceAbstraction } from "../../tools/password-strength";
import { AuthService } from "../abstractions/auth.service";
import { TokenService } from "../abstractions/token.service";
@@ -35,8 +35,8 @@ export class PasswordLogInStrategy extends LogInStrategy {
tokenRequest: PasswordTokenRequest;
private localHashedPassword: string;
private key: SymmetricCryptoKey;
private localMasterKeyHash: string;
private masterKey: MasterKey;
/**
* Options to track if the user needs to update their password due to a password that does not meet an organization's
@@ -71,12 +71,7 @@ export class PasswordLogInStrategy extends LogInStrategy {
);
}
async setUserKey() {
await this.cryptoService.setKey(this.key);
await this.cryptoService.setKeyHash(this.localHashedPassword);
}
async logInTwoFactor(
override async logInTwoFactor(
twoFactor: TokenTwoFactorRequest,
captchaResponse: string
): Promise<AuthResult> {
@@ -96,28 +91,29 @@ export class PasswordLogInStrategy extends LogInStrategy {
return result;
}
async logIn(credentials: PasswordLogInCredentials) {
override async logIn(credentials: PasswordLogInCredentials) {
const { email, masterPassword, captchaToken, twoFactor } = credentials;
this.key = await this.authService.makePreloginKey(masterPassword, email);
this.masterKey = await this.authService.makePreloginKey(masterPassword, email);
// Hash the password early (before authentication) so we don't persist it in memory in plaintext
this.localHashedPassword = await this.cryptoService.hashPassword(
this.localMasterKeyHash = await this.cryptoService.hashMasterKey(
masterPassword,
this.key,
this.masterKey,
HashPurpose.LocalAuthorization
);
const hashedPassword = await this.cryptoService.hashPassword(masterPassword, this.key);
const masterKeyHash = await this.cryptoService.hashMasterKey(masterPassword, this.masterKey);
this.tokenRequest = new PasswordTokenRequest(
email,
hashedPassword,
masterKeyHash,
captchaToken,
await this.buildTwoFactor(twoFactor),
await this.buildDeviceRequest()
);
const [authResult, identityResponse] = await this.startLogIn();
const masterPasswordPolicyOptions =
this.getMasterPasswordPolicyOptionsFromResponse(identityResponse);
@@ -145,6 +141,27 @@ export class PasswordLogInStrategy extends LogInStrategy {
return authResult;
}
protected override async setMasterKey(response: IdentityTokenResponse) {
await this.cryptoService.setMasterKey(this.masterKey);
await this.cryptoService.setMasterKeyHash(this.localMasterKeyHash);
}
protected override async setUserKey(response: IdentityTokenResponse): Promise<void> {
await this.cryptoService.setMasterKeyEncryptedUserKey(response.key);
const masterKey = await this.cryptoService.getMasterKey();
if (masterKey) {
const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey);
await this.cryptoService.setUserKey(userKey);
}
}
protected override async setPrivateKey(response: IdentityTokenResponse): Promise<void> {
await this.cryptoService.setPrivateKey(
response.privateKey ?? (await this.createKeyPairForOldAccount())
);
}
private getMasterPasswordPolicyOptionsFromResponse(
response: IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse
): MasterPasswordPolicyOptions {

View File

@@ -0,0 +1,138 @@
import { mock, MockProxy } from "jest-mock-extended";
import { ApiService } from "../../abstractions/api.service";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import {
MasterKey,
SymmetricCryptoKey,
UserKey,
} from "../../platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "../../types/csprng";
import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction";
import { TokenService } from "../abstractions/token.service";
import { TwoFactorService } from "../abstractions/two-factor.service";
import { PasswordlessLogInCredentials } from "../models/domain/log-in-credentials";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { identityTokenResponseFactory } from "./login.strategy.spec";
import { PasswordlessLogInStrategy } from "./passwordless-login.strategy";
describe("PasswordlessLogInStrategy", () => {
let cryptoService: MockProxy<CryptoService>;
let apiService: MockProxy<ApiService>;
let tokenService: MockProxy<TokenService>;
let appIdService: MockProxy<AppIdService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>;
let stateService: MockProxy<StateService>;
let twoFactorService: MockProxy<TwoFactorService>;
let deviceTrustCryptoService: MockProxy<DeviceTrustCryptoServiceAbstraction>;
let passwordlessLoginStrategy: PasswordlessLogInStrategy;
let credentials: PasswordlessLogInCredentials;
let tokenResponse: IdentityTokenResponse;
const deviceId = Utils.newGuid();
const email = "EMAIL";
const accessCode = "ACCESS_CODE";
const authRequestId = "AUTH_REQUEST_ID";
const decMasterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray
) as MasterKey;
const decUserKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
const decMasterKeyHash = "LOCAL_PASSWORD_HASH";
beforeEach(async () => {
cryptoService = mock<CryptoService>();
apiService = mock<ApiService>();
tokenService = mock<TokenService>();
appIdService = mock<AppIdService>();
platformUtilsService = mock<PlatformUtilsService>();
messagingService = mock<MessagingService>();
logService = mock<LogService>();
stateService = mock<StateService>();
twoFactorService = mock<TwoFactorService>();
deviceTrustCryptoService = mock<DeviceTrustCryptoServiceAbstraction>();
tokenService.getTwoFactorToken.mockResolvedValue(null);
appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeToken.mockResolvedValue({});
passwordlessLoginStrategy = new PasswordlessLogInStrategy(
cryptoService,
apiService,
tokenService,
appIdService,
platformUtilsService,
messagingService,
logService,
stateService,
twoFactorService,
deviceTrustCryptoService
);
tokenResponse = identityTokenResponseFactory();
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
});
it("sets keys after a successful authentication when masterKey and masterKeyHash provided in login credentials", async () => {
credentials = new PasswordlessLogInCredentials(
email,
accessCode,
authRequestId,
null,
decMasterKey,
decMasterKeyHash
);
const masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
cryptoService.getMasterKey.mockResolvedValue(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
await passwordlessLoginStrategy.logIn(credentials);
expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey);
expect(cryptoService.setMasterKeyHash).toHaveBeenCalledWith(decMasterKeyHash);
expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey);
expect(deviceTrustCryptoService.trustDeviceIfRequired).toHaveBeenCalled();
expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey);
});
it("sets keys after a successful authentication when only userKey provided in login credentials", async () => {
// Initialize credentials with only userKey
credentials = new PasswordlessLogInCredentials(
email,
accessCode,
authRequestId,
decUserKey, // Pass userKey
null, // No masterKey
null // No masterKeyHash
);
// Call logIn
await passwordlessLoginStrategy.logIn(credentials);
// setMasterKey and setMasterKeyHash should not be called
expect(cryptoService.setMasterKey).not.toHaveBeenCalled();
expect(cryptoService.setMasterKeyHash).not.toHaveBeenCalled();
// setMasterKeyEncryptedUserKey, setUserKey, and setPrivateKey should still be called
expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(decUserKey);
expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey);
// trustDeviceIfRequired should be called
expect(deviceTrustCryptoService.trustDeviceIfRequired).not.toHaveBeenCalled();
});
});

View File

@@ -5,13 +5,14 @@ import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { AuthService } from "../abstractions/auth.service";
import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction";
import { TokenService } from "../abstractions/token.service";
import { TwoFactorService } from "../abstractions/two-factor.service";
import { AuthResult } from "../models/domain/auth-result";
import { PasswordlessLogInCredentials } from "../models/domain/log-in-credentials";
import { PasswordTokenRequest } from "../models/request/identity-token/password-token.request";
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { LogInStrategy } from "./login.strategy";
@@ -41,7 +42,7 @@ export class PasswordlessLogInStrategy extends LogInStrategy {
logService: LogService,
stateService: StateService,
twoFactorService: TwoFactorService,
private authService: AuthService
private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction
) {
super(
cryptoService,
@@ -56,20 +57,7 @@ export class PasswordlessLogInStrategy extends LogInStrategy {
);
}
async setUserKey() {
await this.cryptoService.setKey(this.passwordlessCredentials.decKey);
await this.cryptoService.setKeyHash(this.passwordlessCredentials.localPasswordHash);
}
async logInTwoFactor(
twoFactor: TokenTwoFactorRequest,
captchaResponse: string
): Promise<AuthResult> {
this.tokenRequest.captchaResponse = captchaResponse ?? this.captchaBypassToken;
return super.logInTwoFactor(twoFactor);
}
async logIn(credentials: PasswordlessLogInCredentials) {
override async logIn(credentials: PasswordlessLogInCredentials) {
this.passwordlessCredentials = credentials;
this.tokenRequest = new PasswordTokenRequest(
@@ -84,4 +72,52 @@ export class PasswordlessLogInStrategy extends LogInStrategy {
const [authResult] = await this.startLogIn();
return authResult;
}
override async logInTwoFactor(
twoFactor: TokenTwoFactorRequest,
captchaResponse: string
): Promise<AuthResult> {
this.tokenRequest.captchaResponse = captchaResponse ?? this.captchaBypassToken;
return super.logInTwoFactor(twoFactor);
}
protected override async setMasterKey(response: IdentityTokenResponse) {
if (
this.passwordlessCredentials.decryptedMasterKey &&
this.passwordlessCredentials.decryptedMasterKeyHash
) {
await this.cryptoService.setMasterKey(this.passwordlessCredentials.decryptedMasterKey);
await this.cryptoService.setMasterKeyHash(
this.passwordlessCredentials.decryptedMasterKeyHash
);
}
}
protected override async setUserKey(response: IdentityTokenResponse): Promise<void> {
// User now may or may not have a master password
// but set the master key encrypted user key if it exists regardless
await this.cryptoService.setMasterKeyEncryptedUserKey(response.key);
if (this.passwordlessCredentials.decryptedUserKey) {
await this.cryptoService.setUserKey(this.passwordlessCredentials.decryptedUserKey);
} else {
await this.trySetUserKeyWithMasterKey();
// Establish trust if required after setting user key
await this.deviceTrustCryptoService.trustDeviceIfRequired();
}
}
private async trySetUserKeyWithMasterKey(): Promise<void> {
const masterKey = await this.cryptoService.getMasterKey();
if (masterKey) {
const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey);
await this.cryptoService.setUserKey(userKey);
}
}
protected override async setPrivateKey(response: IdentityTokenResponse): Promise<void> {
await this.cryptoService.setPrivateKey(
response.privateKey ?? (await this.createKeyPairForOldAccount())
);
}
}

View File

@@ -3,19 +3,34 @@ import { mock, MockProxy } from "jest-mock-extended";
import { ApiService } from "../../abstractions/api.service";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import {
DeviceKey,
MasterKey,
SymmetricCryptoKey,
UserKey,
} from "../../platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "../../types/csprng";
import { AuthRequestCryptoServiceAbstraction } from "../abstractions/auth-request-crypto.service.abstraction";
import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction";
import { KeyConnectorService } from "../abstractions/key-connector.service";
import { TokenService } from "../abstractions/token.service";
import { TwoFactorService } from "../abstractions/two-factor.service";
import { SsoLogInCredentials } from "../models/domain/log-in-credentials";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { IUserDecryptionOptionsServerResponse } from "../models/response/user-decryption-options/user-decryption-options.response";
import { identityTokenResponseFactory } from "./login.strategy.spec";
import { SsoLogInStrategy } from "./sso-login.strategy";
// TODO: Add tests for new trySetUserKeyWithApprovedAdminRequestIfExists logic
// https://bitwarden.atlassian.net/browse/PM-3339
describe("SsoLogInStrategy", () => {
let cryptoService: MockProxy<CryptoService>;
let apiService: MockProxy<ApiService>;
@@ -27,6 +42,9 @@ describe("SsoLogInStrategy", () => {
let stateService: MockProxy<StateService>;
let twoFactorService: MockProxy<TwoFactorService>;
let keyConnectorService: MockProxy<KeyConnectorService>;
let deviceTrustCryptoService: MockProxy<DeviceTrustCryptoServiceAbstraction>;
let authRequestCryptoService: MockProxy<AuthRequestCryptoServiceAbstraction>;
let i18nService: MockProxy<I18nService>;
let ssoLogInStrategy: SsoLogInStrategy;
let credentials: SsoLogInCredentials;
@@ -50,6 +68,9 @@ describe("SsoLogInStrategy", () => {
stateService = mock<StateService>();
twoFactorService = mock<TwoFactorService>();
keyConnectorService = mock<KeyConnectorService>();
deviceTrustCryptoService = mock<DeviceTrustCryptoServiceAbstraction>();
authRequestCryptoService = mock<AuthRequestCryptoServiceAbstraction>();
i18nService = mock<I18nService>();
tokenService.getTwoFactorToken.mockResolvedValue(null);
appIdService.getAppId.mockResolvedValue(deviceId);
@@ -65,7 +86,10 @@ describe("SsoLogInStrategy", () => {
logService,
stateService,
twoFactorService,
keyConnectorService
keyConnectorService,
deviceTrustCryptoService,
authRequestCryptoService,
i18nService
);
credentials = new SsoLogInCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId);
});
@@ -98,33 +122,248 @@ describe("SsoLogInStrategy", () => {
await ssoLogInStrategy.logIn(credentials);
expect(cryptoService.setEncPrivateKey).not.toHaveBeenCalled();
expect(cryptoService.setEncKey).not.toHaveBeenCalled();
expect(cryptoService.setMasterKey).not.toHaveBeenCalled();
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
expect(cryptoService.setPrivateKey).not.toHaveBeenCalled();
});
it("gets and sets KeyConnector key for enrolled user", async () => {
it("sets master key encrypted user key for existing SSO users", async () => {
// Arrange
const tokenResponse = identityTokenResponseFactory();
tokenResponse.keyConnectorUrl = keyConnectorUrl;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
// Act
await ssoLogInStrategy.logIn(credentials);
expect(keyConnectorService.getAndSetKey).toHaveBeenCalledWith(keyConnectorUrl);
// Assert
expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledTimes(1);
expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key);
});
it("converts new SSO user to Key Connector on first login", async () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.keyConnectorUrl = keyConnectorUrl;
tokenResponse.key = null;
describe("Trusted Device Decryption", () => {
const deviceKeyBytesLength = 64;
const mockDeviceKeyRandomBytes = new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray;
const mockDeviceKey: DeviceKey = new SymmetricCryptoKey(mockDeviceKeyRandomBytes) as DeviceKey;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
const userKeyBytesLength = 64;
const mockUserKeyRandomBytes = new Uint8Array(userKeyBytesLength).buffer as CsprngArray;
const mockUserKey: UserKey = new SymmetricCryptoKey(mockUserKeyRandomBytes) as UserKey;
await ssoLogInStrategy.logIn(credentials);
const mockEncDevicePrivateKey =
"2.eh465OrUcluL9UpnCOUTAg==|2HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=";
expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(
tokenResponse,
ssoOrgId
);
const mockEncUserKey =
"4.Xht6K9GA9jKcSNy4TaIvdj7f9+WsgQycs/HdkrJi33aC//roKkjf3UTGpdzFLxVP3WhyOVGyo9f2Jymf1MFPdpg7AuMnpGJlcrWLDbnPjOJo4x5gUwwBUmy3nFw6+wamyS1LRmrBPcv56yKpf80k5Q3hUrum8q9YS9m2I10vklX/TaB1YML0yo+K1feWUxg8vIx+vloxhUdkkysvcV5xU3R+AgYLrwvJS8TLL7Ug/P5HxinCaIroRrNe8xcv84vyVnzPFdXe0cfZ0cpcrm586LwfEXP2seeldO/bC51Uk/mudeSALJURPC64f5ch2cOvk48GOTapGnssCqr6ky5yFw==";
const userDecryptionOptsServerResponseWithTdeOption: IUserDecryptionOptionsServerResponse = {
HasMasterPassword: true,
TrustedDeviceOption: {
HasAdminApproval: true,
HasLoginApprovingDevice: true,
HasManageResetPasswordPermission: false,
EncryptedPrivateKey: mockEncDevicePrivateKey,
EncryptedUserKey: mockEncUserKey,
},
};
const mockIdTokenResponseWithModifiedTrustedDeviceOption = (key: string, value: any) => {
const userDecryptionOpts: IUserDecryptionOptionsServerResponse = {
...userDecryptionOptsServerResponseWithTdeOption,
TrustedDeviceOption: {
...userDecryptionOptsServerResponseWithTdeOption.TrustedDeviceOption,
[key]: value,
},
};
return identityTokenResponseFactory(null, userDecryptionOpts);
};
beforeEach(() => {
jest.clearAllMocks();
});
it("decrypts and sets user key when trusted device decryption option exists with valid device key and enc key data", async () => {
// Arrange
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
null,
userDecryptionOptsServerResponseWithTdeOption
);
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
deviceTrustCryptoService.getDeviceKey.mockResolvedValue(mockDeviceKey);
deviceTrustCryptoService.decryptUserKeyWithDeviceKey.mockResolvedValue(mockUserKey);
const cryptoSvcSetUserKeySpy = jest.spyOn(cryptoService, "setUserKey");
// Act
await ssoLogInStrategy.logIn(credentials);
// Assert
expect(deviceTrustCryptoService.getDeviceKey).toHaveBeenCalledTimes(1);
expect(deviceTrustCryptoService.decryptUserKeyWithDeviceKey).toHaveBeenCalledTimes(1);
expect(cryptoSvcSetUserKeySpy).toHaveBeenCalledTimes(1);
expect(cryptoSvcSetUserKeySpy).toHaveBeenCalledWith(mockUserKey);
});
it("does not set the user key when deviceKey is missing", async () => {
// Arrange
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
null,
userDecryptionOptsServerResponseWithTdeOption
);
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
// Set deviceKey to be null
deviceTrustCryptoService.getDeviceKey.mockResolvedValue(null);
deviceTrustCryptoService.decryptUserKeyWithDeviceKey.mockResolvedValue(mockUserKey);
// Act
await ssoLogInStrategy.logIn(credentials);
// Assert
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
});
describe.each([
{
valueName: "encDevicePrivateKey",
},
{
valueName: "encUserKey",
},
])("given trusted device decryption option has missing encrypted key data", ({ valueName }) => {
it(`does not set the user key when ${valueName} is missing`, async () => {
// Arrange
const idTokenResponse = mockIdTokenResponseWithModifiedTrustedDeviceOption(valueName, null);
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
deviceTrustCryptoService.getDeviceKey.mockResolvedValue(mockDeviceKey);
// Act
await ssoLogInStrategy.logIn(credentials);
// Assert
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
});
});
it("does not set user key when decrypted user key is null", async () => {
// Arrange
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
null,
userDecryptionOptsServerResponseWithTdeOption
);
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
deviceTrustCryptoService.getDeviceKey.mockResolvedValue(mockDeviceKey);
// Set userKey to be null
deviceTrustCryptoService.decryptUserKeyWithDeviceKey.mockResolvedValue(null);
// Act
await ssoLogInStrategy.logIn(credentials);
// Assert
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
});
});
describe("Key Connector", () => {
let tokenResponse: IdentityTokenResponse;
beforeEach(() => {
tokenResponse = identityTokenResponseFactory(null, {
HasMasterPassword: false,
KeyConnectorOption: { KeyConnectorUrl: keyConnectorUrl },
});
tokenResponse.keyConnectorUrl = keyConnectorUrl;
});
it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => {
const masterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray
) as MasterKey;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
cryptoService.getMasterKey.mockResolvedValue(masterKey);
await ssoLogInStrategy.logIn(credentials);
expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl);
});
it("converts new SSO user with no master password to Key Connector on first login", async () => {
tokenResponse.key = null;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
await ssoLogInStrategy.logIn(credentials);
expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(
tokenResponse,
ssoOrgId
);
});
it("decrypts and sets the user key if Key Connector is enabled and the user doesn't have a master password", async () => {
const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
const masterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray
) as MasterKey;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
cryptoService.getMasterKey.mockResolvedValue(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
await ssoLogInStrategy.logIn(credentials);
expect(cryptoService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(masterKey);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey);
});
});
describe("Key Connector Pre-TDE", () => {
let tokenResponse: IdentityTokenResponse;
beforeEach(() => {
tokenResponse = identityTokenResponseFactory();
tokenResponse.userDecryptionOptions = null;
tokenResponse.keyConnectorUrl = keyConnectorUrl;
});
it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => {
const masterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray
) as MasterKey;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
cryptoService.getMasterKey.mockResolvedValue(masterKey);
await ssoLogInStrategy.logIn(credentials);
expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl);
});
it("converts new SSO user with no master password to Key Connector on first login", async () => {
tokenResponse.key = null;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
await ssoLogInStrategy.logIn(credentials);
expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(
tokenResponse,
ssoOrgId
);
});
it("decrypts and sets the user key if Key Connector is enabled and the user doesn't have a master password", async () => {
const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
const masterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray
) as MasterKey;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
cryptoService.getMasterKey.mockResolvedValue(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
await ssoLogInStrategy.logIn(credentials);
expect(cryptoService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(masterKey);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey);
});
});
});

View File

@@ -1,10 +1,16 @@
import { ApiService } from "../../abstractions/api.service";
import { AuthRequestResponse } from "../../auth/models/response/auth-request.response";
import { HttpStatusCode } from "../../enums";
import { ErrorResponse } from "../../models/response/error.response";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { AuthRequestCryptoServiceAbstraction } from "../abstractions/auth-request-crypto.service.abstraction";
import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction";
import { KeyConnectorService } from "../abstractions/key-connector.service";
import { TokenService } from "../abstractions/token.service";
import { TwoFactorService } from "../abstractions/two-factor.service";
@@ -34,7 +40,10 @@ export class SsoLogInStrategy extends LogInStrategy {
logService: LogService,
stateService: StateService,
twoFactorService: TwoFactorService,
private keyConnectorService: KeyConnectorService
private keyConnectorService: KeyConnectorService,
private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
private authReqCryptoService: AuthRequestCryptoServiceAbstraction,
private i18nService: I18nService
) {
super(
cryptoService,
@@ -49,18 +58,6 @@ export class SsoLogInStrategy extends LogInStrategy {
);
}
async setUserKey(tokenResponse: IdentityTokenResponse) {
const newSsoUser = tokenResponse.key == null;
if (tokenResponse.keyConnectorUrl != null) {
if (!newSsoUser) {
await this.keyConnectorService.getAndSetKey(tokenResponse.keyConnectorUrl);
} else {
await this.keyConnectorService.convertNewSsoUserToKeyConnector(tokenResponse, this.orgId);
}
}
}
async logIn(credentials: SsoLogInCredentials) {
this.orgId = credentials.orgId;
this.tokenRequest = new SsoTokenRequest(
@@ -78,4 +75,194 @@ export class SsoLogInStrategy extends LogInStrategy {
return ssoAuthResult;
}
protected override async setMasterKey(tokenResponse: IdentityTokenResponse) {
// The only way we can be setting a master key at this point is if we are using Key Connector.
// First, check to make sure that we should do so based on the token response.
if (this.shouldSetMasterKeyFromKeyConnector(tokenResponse)) {
// If we're here, we know that the user should use Key Connector (they have a KeyConnectorUrl) and does not have a master password.
// We can now check the key on the token response to see whether they are a brand new user or an existing user.
// The presence of a masterKeyEncryptedUserKey indicates that the user has already been provisioned in Key Connector.
const newSsoUser = tokenResponse.key == null;
if (newSsoUser) {
await this.keyConnectorService.convertNewSsoUserToKeyConnector(tokenResponse, this.orgId);
} else {
const keyConnectorUrl = this.getKeyConnectorUrl(tokenResponse);
await this.keyConnectorService.setMasterKeyFromUrl(keyConnectorUrl);
}
}
}
/**
* Determines if it is possible set the `masterKey` from Key Connector.
* @param tokenResponse
* @returns `true` if the master key can be set from Key Connector, `false` otherwise
*/
private shouldSetMasterKeyFromKeyConnector(tokenResponse: IdentityTokenResponse): boolean {
const userDecryptionOptions = tokenResponse?.userDecryptionOptions;
if (userDecryptionOptions != null) {
const userHasMasterPassword = userDecryptionOptions.hasMasterPassword;
const userHasKeyConnectorUrl =
userDecryptionOptions.keyConnectorOption?.keyConnectorUrl != null;
// In order for us to set the master key from Key Connector, we need to have a Key Connector URL
// and the user must not have a master password.
return userHasKeyConnectorUrl && !userHasMasterPassword;
} else {
// In pre-TDE versions of the server, the userDecryptionOptions will not be present.
// In this case, we can determine if the user has a master password and has a Key Connector URL by
// just checking the keyConnectorUrl property. This is because the server short-circuits on the response
// and will not pass back the URL in the response if the user has a master password.
// TODO: remove compatibility check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
return tokenResponse.keyConnectorUrl != null;
}
}
private getKeyConnectorUrl(tokenResponse: IdentityTokenResponse): string {
// TODO: remove tokenResponse.keyConnectorUrl reference after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
const userDecryptionOptions = tokenResponse?.userDecryptionOptions;
return (
tokenResponse.keyConnectorUrl ?? userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl
);
}
// TODO: future passkey login strategy will need to support setting user key (decrypting via TDE or admin approval request)
// so might be worth moving this logic to a common place (base login strategy or a separate service?)
protected override async setUserKey(tokenResponse: IdentityTokenResponse): Promise<void> {
const masterKeyEncryptedUserKey = tokenResponse.key;
// Note: masterKeyEncryptedUserKey is undefined for SSO JIT provisioned users
// on account creation and subsequent logins (confirmed or unconfirmed)
// but that is fine for TDE so we cannot return if it is undefined
if (masterKeyEncryptedUserKey) {
// set the master key encrypted user key if it exists
await this.cryptoService.setMasterKeyEncryptedUserKey(masterKeyEncryptedUserKey);
}
const userDecryptionOptions = tokenResponse?.userDecryptionOptions;
// Note: TDE and key connector are mutually exclusive
if (userDecryptionOptions?.trustedDeviceOption) {
await this.trySetUserKeyWithApprovedAdminRequestIfExists();
const hasUserKey = await this.cryptoService.hasUserKey();
// Only try to set user key with device key if admin approval request was not successful
if (!hasUserKey) {
await this.trySetUserKeyWithDeviceKey(tokenResponse);
}
} else if (
masterKeyEncryptedUserKey != null &&
this.getKeyConnectorUrl(tokenResponse) != null
) {
// Key connector enabled for user
await this.trySetUserKeyWithMasterKey();
}
// Note: In the traditional SSO flow with MP without key connector, the lock component
// is responsible for deriving master key from MP entry and then decrypting the user key
}
private async trySetUserKeyWithApprovedAdminRequestIfExists(): Promise<void> {
// At this point a user could have an admin auth request that has been approved
const adminAuthReqStorable = await this.stateService.getAdminAuthRequest();
if (!adminAuthReqStorable) {
return;
}
// Call server to see if admin auth request has been approved
let adminAuthReqResponse: AuthRequestResponse;
try {
adminAuthReqResponse = await this.apiService.getAuthRequest(adminAuthReqStorable.id);
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
// if we get a 404, it means the auth request has been deleted so clear it from storage
await this.stateService.setAdminAuthRequest(null);
}
// Always return on an error here as we don't want to block the user from logging in
return;
}
if (adminAuthReqResponse?.requestApproved) {
// if masterPasswordHash has a value, we will always receive authReqResponse.key
// as authRequestPublicKey(masterKey) + authRequestPublicKey(masterPasswordHash)
if (adminAuthReqResponse.masterPasswordHash) {
await this.authReqCryptoService.setKeysAfterDecryptingSharedMasterKeyAndHash(
adminAuthReqResponse,
adminAuthReqStorable.privateKey
);
} else {
// if masterPasswordHash is null, we will always receive authReqResponse.key
// as authRequestPublicKey(userKey)
await this.authReqCryptoService.setUserKeyAfterDecryptingSharedUserKey(
adminAuthReqResponse,
adminAuthReqStorable.privateKey
);
}
if (await this.cryptoService.hasUserKey()) {
// Now that we have a decrypted user key in memory, we can check if we
// need to establish trust on the current device
await this.deviceTrustCryptoService.trustDeviceIfRequired();
// if we successfully decrypted the user key, we can delete the admin auth request out of state
// TODO: eventually we post and clean up DB as well once consumed on client
await this.stateService.setAdminAuthRequest(null);
this.platformUtilsService.showToast("success", null, this.i18nService.t("loginApproved"));
}
}
}
private async trySetUserKeyWithDeviceKey(tokenResponse: IdentityTokenResponse): Promise<void> {
const trustedDeviceOption = tokenResponse.userDecryptionOptions?.trustedDeviceOption;
const deviceKey = await this.deviceTrustCryptoService.getDeviceKey();
const encDevicePrivateKey = trustedDeviceOption?.encryptedPrivateKey;
const encUserKey = trustedDeviceOption?.encryptedUserKey;
if (!deviceKey || !encDevicePrivateKey || !encUserKey) {
return;
}
const userKey = await this.deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
encDevicePrivateKey,
encUserKey,
deviceKey
);
if (userKey) {
await this.cryptoService.setUserKey(userKey);
}
}
private async trySetUserKeyWithMasterKey(): Promise<void> {
const masterKey = await this.cryptoService.getMasterKey();
// There is a scenario in which the master key is not set here. That will occur if the user
// has a master password and is using Key Connector. In that case, we cannot set the master key
// because the user hasn't entered their master password yet.
// Instead, we'll return here and let the migration to Key Connector handle setting the master key.
if (!masterKey) {
return;
}
const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey);
await this.cryptoService.setUserKey(userKey);
}
protected override async setPrivateKey(tokenResponse: IdentityTokenResponse): Promise<void> {
const newSsoUser = tokenResponse.key == null;
if (!newSsoUser) {
await this.cryptoService.setPrivateKey(
tokenResponse.privateKey ?? (await this.createKeyPairForOldAccount())
);
}
}
}

View File

@@ -9,6 +9,12 @@ import { MessagingService } from "../../platform/abstractions/messaging.service"
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import {
MasterKey,
SymmetricCryptoKey,
UserKey,
} from "../../platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "../../types/csprng";
import { KeyConnectorService } from "../abstractions/key-connector.service";
import { TokenService } from "../abstractions/token.service";
import { TwoFactorService } from "../abstractions/two-factor.service";
@@ -101,7 +107,18 @@ describe("UserApiLogInStrategy", () => {
expect(stateService.addAccount).toHaveBeenCalled();
});
it("gets and sets the Key Connector key from environmentUrl", async () => {
it("sets the encrypted user key and private key from the identity token response", async () => {
const tokenResponse = identityTokenResponseFactory();
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
await apiLogInStrategy.logIn(credentials);
expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key);
expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey);
});
it("gets and sets the master key if Key Connector is enabled", async () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.apiUseKeyConnector = true;
@@ -110,6 +127,24 @@ describe("UserApiLogInStrategy", () => {
await apiLogInStrategy.logIn(credentials);
expect(keyConnectorService.getAndSetKey).toHaveBeenCalledWith(keyConnectorUrl);
expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl);
});
it("decrypts and sets the user key if Key Connector is enabled", async () => {
const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
const masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
const tokenResponse = identityTokenResponseFactory();
tokenResponse.apiUseKeyConnector = true;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
environmentService.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl);
cryptoService.getMasterKey.mockResolvedValue(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
await apiLogInStrategy.logIn(credentials);
expect(cryptoService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(masterKey);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey);
});
});

View File

@@ -44,14 +44,7 @@ export class UserApiLogInStrategy extends LogInStrategy {
);
}
async setUserKey(tokenResponse: IdentityTokenResponse) {
if (tokenResponse.apiUseKeyConnector) {
const keyConnectorUrl = this.environmentService.getKeyConnectorUrl();
await this.keyConnectorService.getAndSetKey(keyConnectorUrl);
}
}
async logIn(credentials: UserApiLogInCredentials) {
override async logIn(credentials: UserApiLogInCredentials) {
this.tokenRequest = new UserApiTokenRequest(
credentials.clientId,
credentials.clientSecret,
@@ -63,6 +56,31 @@ export class UserApiLogInStrategy extends LogInStrategy {
return authResult;
}
protected override async setMasterKey(response: IdentityTokenResponse) {
if (response.apiUseKeyConnector) {
const keyConnectorUrl = this.environmentService.getKeyConnectorUrl();
await this.keyConnectorService.setMasterKeyFromUrl(keyConnectorUrl);
}
}
protected override async setUserKey(response: IdentityTokenResponse): Promise<void> {
await this.cryptoService.setMasterKeyEncryptedUserKey(response.key);
if (response.apiUseKeyConnector) {
const masterKey = await this.cryptoService.getMasterKey();
if (masterKey) {
const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey);
await this.cryptoService.setUserKey(userKey);
}
}
}
protected override async setPrivateKey(response: IdentityTokenResponse): Promise<void> {
await this.cryptoService.setPrivateKey(
response.privateKey ?? (await this.createKeyPairForOldAccount())
);
}
protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) {
await super.saveAccountInformation(tokenResponse);
await this.stateService.setApiKeyClientId(this.tokenRequest.clientId);

View File

@@ -0,0 +1,41 @@
import { Utils } from "../../../platform/misc/utils";
// TODO: Tech Debt: potentially create a type Storage shape vs using a class here in the future
// type StorageShape {
// id: string;
// privateKey: string;
// }
// so we can get rid of the any type passed into fromJSON and coming out of ToJSON
export class AdminAuthRequestStorable {
id: string;
privateKey: Uint8Array;
constructor(init?: Partial<AdminAuthRequestStorable>) {
if (init) {
Object.assign(this, init);
}
}
toJSON() {
return {
id: this.id,
privateKey: Utils.fromBufferToByteString(this.privateKey),
};
}
static fromJSON(obj: any): AdminAuthRequestStorable {
if (obj == null) {
return null;
}
let privateKeyBuffer = null;
if (obj.privateKey) {
privateKeyBuffer = Utils.fromByteStringToArray(obj.privateKey);
}
return new AdminAuthRequestStorable({
id: obj.id,
privateKey: privateKeyBuffer,
});
}
}

View File

@@ -5,7 +5,14 @@ import { ForceResetPasswordReason } from "./force-reset-password-reason";
export class AuthResult {
captchaSiteKey = "";
// TODO: PM-3287 - Remove this after 3 releases of backwards compatibility. - Target release 2023.12 for removal
/**
* @deprecated
* Replace with using AccountDecryptionOptions to determine if the user does
* not have a master password and is not using Key Connector.
* */
resetMasterPassword = false;
forcePasswordReset: ForceResetPasswordReason = ForceResetPasswordReason.None;
twoFactorProviders: Map<TwoFactorProviderType, { [key: string]: string }> = null;
ssoEmail2FaSessionToken?: string;

View File

@@ -1,4 +1,4 @@
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { MasterKey, UserKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { AuthenticationType } from "../../enums/authentication-type";
import { TokenTwoFactorRequest } from "../request/identity-token/token-two-factor.request";
@@ -38,8 +38,9 @@ export class PasswordlessLogInCredentials {
public email: string,
public accessCode: string,
public authRequestId: string,
public decKey: SymmetricCryptoKey,
public localPasswordHash: string,
public decryptedUserKey: UserKey,
public decryptedMasterKey: MasterKey,
public decryptedMasterKeyHash: string,
public twoFactor?: TokenTwoFactorRequest
) {}
}

View File

@@ -0,0 +1,3 @@
export class KeyConnectorUserDecryptionOption {
constructor(public keyConnectorUrl: string) {}
}

View File

@@ -0,0 +1,7 @@
export class TrustedDeviceUserDecryptionOption {
constructor(
public hasAdminApproval: boolean,
public hasLoginApprovingDevice: boolean,
public hasManageResetPasswordPermission: boolean
) {}
}

View File

@@ -0,0 +1,15 @@
import { SecretVerificationRequest } from "./secret-verification.request";
export class UpdateDevicesTrustRequest extends SecretVerificationRequest {
currentDevice: DeviceKeysUpdateRequest;
otherDevices: OtherDeviceKeysUpdateRequest[];
}
export class DeviceKeysUpdateRequest {
encryptedPublicKey: string;
encryptedUserKey: string;
}
export class OtherDeviceKeysUpdateRequest extends DeviceKeysUpdateRequest {
id: string;
}

View File

@@ -2,6 +2,7 @@ import { KdfType } from "../../../enums";
import { BaseResponse } from "../../../models/response/base.response";
import { MasterPasswordPolicyResponse } from "./master-password-policy.response";
import { UserDecryptionOptionsResponse } from "./user-decryption-options/user-decryption-options.response";
export class IdentityTokenResponse extends BaseResponse {
accessToken: string;
@@ -22,6 +23,8 @@ export class IdentityTokenResponse extends BaseResponse {
apiUseKeyConnector: boolean;
keyConnectorUrl: string;
userDecryptionOptions: UserDecryptionOptionsResponse;
constructor(response: any) {
super(response);
this.accessToken = response.access_token;
@@ -43,5 +46,11 @@ export class IdentityTokenResponse extends BaseResponse {
this.masterPasswordPolicy = new MasterPasswordPolicyResponse(
this.getResponseProperty("MasterPasswordPolicy")
);
if (response.UserDecryptionOptions) {
this.userDecryptionOptions = new UserDecryptionOptionsResponse(
this.getResponseProperty("UserDecryptionOptions")
);
}
}
}

View File

@@ -0,0 +1,39 @@
import { Jsonify } from "type-fest";
import { DeviceType } from "../../../enums";
import { BaseResponse } from "../../../models/response/base.response";
import { EncString } from "../../../platform/models/domain/enc-string";
export class ProtectedDeviceResponse extends BaseResponse {
constructor(response: Jsonify<ProtectedDeviceResponse>) {
super(response);
this.id = this.getResponseProperty("id");
this.name = this.getResponseProperty("name");
this.identifier = this.getResponseProperty("identifier");
this.type = this.getResponseProperty("type");
this.creationDate = new Date(this.getResponseProperty("creationDate"));
if (response.encryptedUserKey) {
this.encryptedUserKey = new EncString(this.getResponseProperty("encryptedUserKey"));
}
if (response.encryptedPublicKey) {
this.encryptedPublicKey = new EncString(this.getResponseProperty("encryptedPublicKey"));
}
}
id: string;
name: string;
type: DeviceType;
identifier: string;
creationDate: Date;
/**
* Intended to be the users symmetric key that is encrypted in some form, the current way to encrypt this is with
* the devices public key.
*/
encryptedUserKey: EncString;
/**
* Intended to be the public key that was generated for a device upon trust and encrypted. Currenly encrypted using
* a users symmetric key so that when trusted and unlocked a user can decrypt the public key for all their devices.
* This enabled a user to rotate the keys for all of their devices.
*/
encryptedPublicKey: EncString;
}

View File

@@ -0,0 +1,14 @@
import { BaseResponse } from "../../../../models/response/base.response";
export interface IKeyConnectorUserDecryptionOptionServerResponse {
KeyConnectorUrl: string;
}
export class KeyConnectorUserDecryptionOptionResponse extends BaseResponse {
keyConnectorUrl: string;
constructor(response: IKeyConnectorUserDecryptionOptionServerResponse) {
super(response);
this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl");
}
}

View File

@@ -0,0 +1,35 @@
import { BaseResponse } from "../../../../models/response/base.response";
import { EncString } from "../../../../platform/models/domain/enc-string";
export interface ITrustedDeviceUserDecryptionOptionServerResponse {
HasAdminApproval: boolean;
HasLoginApprovingDevice: boolean;
HasManageResetPasswordPermission: boolean;
EncryptedPrivateKey?: string;
EncryptedUserKey?: string;
}
export class TrustedDeviceUserDecryptionOptionResponse extends BaseResponse {
hasAdminApproval: boolean;
hasLoginApprovingDevice: boolean;
hasManageResetPasswordPermission: boolean;
encryptedPrivateKey: EncString;
encryptedUserKey: EncString;
constructor(response: any) {
super(response);
this.hasAdminApproval = this.getResponseProperty("HasAdminApproval");
this.hasLoginApprovingDevice = this.getResponseProperty("HasLoginApprovingDevice");
this.hasManageResetPasswordPermission = this.getResponseProperty(
"HasManageResetPasswordPermission"
);
if (response.EncryptedPrivateKey) {
this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey"));
}
if (response.EncryptedUserKey) {
this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey"));
}
}
}

Some files were not shown because too many files have changed in this diff Show More