mirror of
https://github.com/bitwarden/browser
synced 2025-12-13 14:53:33 +00:00
[PM-23246] Add unlock with master password unlock data for lock component (#16204)
* Add unlocking with MasterPasswordUnlockData for angular lock component
This commit is contained in:
@@ -174,10 +174,12 @@ import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarde
|
|||||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
|
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
|
||||||
import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
|
import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
|
||||||
import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service";
|
import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service";
|
||||||
|
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||||
import {
|
import {
|
||||||
InternalMasterPasswordServiceAbstraction,
|
InternalMasterPasswordServiceAbstraction,
|
||||||
MasterPasswordServiceAbstraction,
|
MasterPasswordServiceAbstraction,
|
||||||
} from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
} from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||||
|
import { DefaultMasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/services/default-master-password-unlock.service";
|
||||||
import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service";
|
import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service";
|
||||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||||
import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation";
|
import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation";
|
||||||
@@ -1077,6 +1079,11 @@ const safeProviders: SafeProvider[] = [
|
|||||||
provide: MasterPasswordServiceAbstraction,
|
provide: MasterPasswordServiceAbstraction,
|
||||||
useExisting: InternalMasterPasswordServiceAbstraction,
|
useExisting: InternalMasterPasswordServiceAbstraction,
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: MasterPasswordUnlockService,
|
||||||
|
useClass: DefaultMasterPasswordUnlockService,
|
||||||
|
deps: [InternalMasterPasswordServiceAbstraction, KeyService],
|
||||||
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: KeyConnectorServiceAbstraction,
|
provide: KeyConnectorServiceAbstraction,
|
||||||
useClass: KeyConnectorService,
|
useClass: KeyConnectorService,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export enum FeatureFlag {
|
|||||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||||
EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation",
|
EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation",
|
||||||
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
|
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
|
||||||
|
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||||
|
|
||||||
/* Tools */
|
/* Tools */
|
||||||
DesktopSendUIRefresh = "desktop-send-ui-refresh",
|
DesktopSendUIRefresh = "desktop-send-ui-refresh",
|
||||||
@@ -110,6 +111,7 @@ export const DefaultFeatureFlagValue = {
|
|||||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||||
[FeatureFlag.EnrollAeadOnKeyRotation]: FALSE,
|
[FeatureFlag.EnrollAeadOnKeyRotation]: FALSE,
|
||||||
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
|
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
|
||||||
|
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||||
|
|
||||||
/* Platform */
|
/* Platform */
|
||||||
[FeatureFlag.IpcChannelFramework]: FALSE,
|
[FeatureFlag.IpcChannelFramework]: FALSE,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { UserId } from "@bitwarden/user-core";
|
||||||
|
|
||||||
|
import { UserKey } from "../../../types/key";
|
||||||
|
|
||||||
|
export abstract class MasterPasswordUnlockService {
|
||||||
|
/**
|
||||||
|
* Unlocks the user's account using the master password.
|
||||||
|
* @param masterPassword The master password provided by the user.
|
||||||
|
* @param userId The ID of the active user.
|
||||||
|
* @returns the user's decrypted userKey.
|
||||||
|
*/
|
||||||
|
abstract unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise<UserKey>;
|
||||||
|
}
|
||||||
@@ -171,4 +171,12 @@ export abstract class InternalMasterPasswordServiceAbstraction extends MasterPas
|
|||||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An observable that emits the master password unlock data for the target user.
|
||||||
|
* @param userId The user ID.
|
||||||
|
* @throws If the user ID is null or undefined.
|
||||||
|
* @returns An observable that emits the master password unlock data or null if not found.
|
||||||
|
*/
|
||||||
|
abstract masterPasswordUnlockData$(userId: UserId): Observable<MasterPasswordUnlockData | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
import { of } from "rxjs";
|
||||||
|
|
||||||
|
import { newGuid } from "@bitwarden/guid";
|
||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
|
import { Argon2KdfConfig, KeyService } from "@bitwarden/key-management";
|
||||||
|
import { UserId } from "@bitwarden/user-core";
|
||||||
|
|
||||||
|
import { HashPurpose } from "../../../platform/enums";
|
||||||
|
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||||
|
import { MasterKey, UserKey } from "../../../types/key";
|
||||||
|
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
|
||||||
|
import {
|
||||||
|
MasterKeyWrappedUserKey,
|
||||||
|
MasterPasswordSalt,
|
||||||
|
MasterPasswordUnlockData,
|
||||||
|
} from "../types/master-password.types";
|
||||||
|
|
||||||
|
import { DefaultMasterPasswordUnlockService } from "./default-master-password-unlock.service";
|
||||||
|
|
||||||
|
describe("DefaultMasterPasswordUnlockService", () => {
|
||||||
|
let sut: DefaultMasterPasswordUnlockService;
|
||||||
|
|
||||||
|
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
|
||||||
|
let keyService: MockProxy<KeyService>;
|
||||||
|
|
||||||
|
const mockMasterPassword = "testExample";
|
||||||
|
const mockUserId = newGuid() as UserId;
|
||||||
|
|
||||||
|
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||||
|
const mockMasterPasswordUnlockData: MasterPasswordUnlockData = new MasterPasswordUnlockData(
|
||||||
|
"user@example.com" as MasterPasswordSalt,
|
||||||
|
new Argon2KdfConfig(100000, 64, 1),
|
||||||
|
"encryptedMasterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
//Legacy data for tests
|
||||||
|
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey;
|
||||||
|
const mockKeyHash = "localKeyHash";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
||||||
|
keyService = mock<KeyService>();
|
||||||
|
|
||||||
|
sut = new DefaultMasterPasswordUnlockService(masterPasswordService, keyService);
|
||||||
|
|
||||||
|
masterPasswordService.masterPasswordUnlockData$.mockReturnValue(
|
||||||
|
of(mockMasterPasswordUnlockData),
|
||||||
|
);
|
||||||
|
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData.mockResolvedValue(mockUserKey);
|
||||||
|
|
||||||
|
// Legacy state mocking
|
||||||
|
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
|
||||||
|
keyService.hashMasterKey.mockResolvedValue(mockKeyHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unlockWithMasterPassword", () => {
|
||||||
|
test.each([null as unknown as string, undefined as unknown as string, ""])(
|
||||||
|
"throws when the provided master password is %s",
|
||||||
|
async (masterPassword) => {
|
||||||
|
await expect(sut.unlockWithMasterPassword(masterPassword, mockUserId)).rejects.toThrow(
|
||||||
|
"Master password is required",
|
||||||
|
);
|
||||||
|
expect(masterPasswordService.masterPasswordUnlockData$).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([null as unknown as UserId, undefined as unknown as UserId])(
|
||||||
|
"throws when the provided master password is %s",
|
||||||
|
async (userId) => {
|
||||||
|
await expect(sut.unlockWithMasterPassword(mockMasterPassword, userId)).rejects.toThrow(
|
||||||
|
"User ID is required",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("throws an error when the user doesn't have masterPasswordUnlockData", async () => {
|
||||||
|
masterPasswordService.masterPasswordUnlockData$.mockReturnValue(of(null));
|
||||||
|
|
||||||
|
await expect(sut.unlockWithMasterPassword(mockMasterPassword, mockUserId)).rejects.toThrow(
|
||||||
|
"Master password unlock data was not found for the user " + mockUserId,
|
||||||
|
);
|
||||||
|
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||||
|
expect(
|
||||||
|
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns userKey successfully", async () => {
|
||||||
|
const result = await sut.unlockWithMasterPassword(mockMasterPassword, mockUserId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockUserKey);
|
||||||
|
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||||
|
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||||
|
mockMasterPassword,
|
||||||
|
mockMasterPasswordUnlockData,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets legacy state on success", async () => {
|
||||||
|
const result = await sut.unlockWithMasterPassword(mockMasterPassword, mockUserId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockUserKey);
|
||||||
|
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||||
|
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||||
|
mockMasterPassword,
|
||||||
|
mockMasterPasswordUnlockData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||||
|
mockMasterPassword,
|
||||||
|
mockMasterPasswordUnlockData.salt,
|
||||||
|
mockMasterPasswordUnlockData.kdf,
|
||||||
|
);
|
||||||
|
expect(keyService.hashMasterKey).toHaveBeenCalledWith(
|
||||||
|
mockMasterPassword,
|
||||||
|
mockMasterKey,
|
||||||
|
HashPurpose.LocalAuthorization,
|
||||||
|
);
|
||||||
|
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(mockKeyHash, mockUserId);
|
||||||
|
expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith(mockMasterKey, mockUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws an error if masterKey construction fails", async () => {
|
||||||
|
keyService.makeMasterKey.mockResolvedValue(null as unknown as MasterKey);
|
||||||
|
|
||||||
|
await expect(sut.unlockWithMasterPassword(mockMasterPassword, mockUserId)).rejects.toThrow(
|
||||||
|
"Master key could not be created to set legacy master password state.",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||||
|
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||||
|
mockMasterPassword,
|
||||||
|
mockMasterPasswordUnlockData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||||
|
mockMasterPassword,
|
||||||
|
mockMasterPasswordUnlockData.salt,
|
||||||
|
mockMasterPasswordUnlockData.kdf,
|
||||||
|
);
|
||||||
|
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
|
||||||
|
expect(masterPasswordService.setMasterKeyHash).not.toHaveBeenCalled();
|
||||||
|
expect(masterPasswordService.setMasterKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
|
import { KeyService } from "@bitwarden/key-management";
|
||||||
|
import { UserId } from "@bitwarden/user-core";
|
||||||
|
|
||||||
|
import { HashPurpose } from "../../../platform/enums";
|
||||||
|
import { UserKey } from "../../../types/key";
|
||||||
|
import { MasterPasswordUnlockService } from "../abstractions/master-password-unlock.service";
|
||||||
|
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
|
||||||
|
import { MasterPasswordUnlockData } from "../types/master-password.types";
|
||||||
|
|
||||||
|
export class DefaultMasterPasswordUnlockService implements MasterPasswordUnlockService {
|
||||||
|
constructor(
|
||||||
|
private readonly masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||||
|
private readonly keyService: KeyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise<UserKey> {
|
||||||
|
this.validateInput(masterPassword, userId);
|
||||||
|
|
||||||
|
const masterPasswordUnlockData = await firstValueFrom(
|
||||||
|
this.masterPasswordService.masterPasswordUnlockData$(userId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (masterPasswordUnlockData == null) {
|
||||||
|
throw new Error("Master password unlock data was not found for the user " + userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userKey = await this.masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData(
|
||||||
|
masterPassword,
|
||||||
|
masterPasswordUnlockData,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.setLegacyState(masterPassword, masterPasswordUnlockData, userId);
|
||||||
|
|
||||||
|
return userKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateInput(masterPassword: string, userId: UserId): void {
|
||||||
|
if (masterPassword == null || masterPassword === "") {
|
||||||
|
throw new Error("Master password is required");
|
||||||
|
}
|
||||||
|
if (userId == null) {
|
||||||
|
throw new Error("User ID is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Previously unlocking had the side effect of setting the masterKey and masterPasswordHash in state.
|
||||||
|
// This is to preserve that behavior, once masterKey and masterPasswordHash state is removed this should be removed as well.
|
||||||
|
private async setLegacyState(
|
||||||
|
masterPassword: string,
|
||||||
|
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||||
|
userId: UserId,
|
||||||
|
): Promise<void> {
|
||||||
|
const masterKey = await this.keyService.makeMasterKey(
|
||||||
|
masterPassword,
|
||||||
|
masterPasswordUnlockData.salt,
|
||||||
|
masterPasswordUnlockData.kdf,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!masterKey) {
|
||||||
|
throw new Error("Master key could not be created to set legacy master password state.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const localKeyHash = await this.keyService.hashMasterKey(
|
||||||
|
masterPassword,
|
||||||
|
masterKey,
|
||||||
|
HashPurpose.LocalAuthorization,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId);
|
||||||
|
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,4 +119,8 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return this.mock.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
|
return this.mock.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
masterPasswordUnlockData$(userId: UserId): Observable<MasterPasswordUnlockData | null> {
|
||||||
|
return this.mock.masterPasswordUnlockData$(userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { mock, MockProxy } from "jest-mock-extended";
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
import * as rxjs from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
import { firstValueFrom, of } from "rxjs";
|
|
||||||
import { Jsonify } from "type-fest";
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||||
@@ -10,6 +9,7 @@ import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
FakeAccountService,
|
FakeAccountService,
|
||||||
|
FakeStateProvider,
|
||||||
makeSymmetricCryptoKey,
|
makeSymmetricCryptoKey,
|
||||||
mockAccountServiceWith,
|
mockAccountServiceWith,
|
||||||
} from "../../../../spec";
|
} from "../../../../spec";
|
||||||
@@ -17,7 +17,6 @@ import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-pa
|
|||||||
import { LogService } from "../../../platform/abstractions/log.service";
|
import { LogService } from "../../../platform/abstractions/log.service";
|
||||||
import { StateService } from "../../../platform/abstractions/state.service";
|
import { StateService } from "../../../platform/abstractions/state.service";
|
||||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||||
import { StateProvider } from "../../../platform/state";
|
|
||||||
import { UserId } from "../../../types/guid";
|
import { UserId } from "../../../types/guid";
|
||||||
import { MasterKey, UserKey } from "../../../types/key";
|
import { MasterKey, UserKey } from "../../../types/key";
|
||||||
import { KeyGenerationService } from "../../crypto";
|
import { KeyGenerationService } from "../../crypto";
|
||||||
@@ -30,25 +29,30 @@ import {
|
|||||||
MasterPasswordUnlockData,
|
MasterPasswordUnlockData,
|
||||||
} from "../types/master-password.types";
|
} from "../types/master-password.types";
|
||||||
|
|
||||||
import { MASTER_PASSWORD_UNLOCK_KEY, MasterPasswordService } from "./master-password.service";
|
import {
|
||||||
|
FORCE_SET_PASSWORD_REASON,
|
||||||
|
MASTER_KEY_ENCRYPTED_USER_KEY,
|
||||||
|
MASTER_PASSWORD_UNLOCK_KEY,
|
||||||
|
MasterPasswordService,
|
||||||
|
} from "./master-password.service";
|
||||||
|
|
||||||
describe("MasterPasswordService", () => {
|
describe("MasterPasswordService", () => {
|
||||||
let sut: MasterPasswordService;
|
let sut: MasterPasswordService;
|
||||||
|
|
||||||
let stateProvider: MockProxy<StateProvider>;
|
|
||||||
let stateService: MockProxy<StateService>;
|
let stateService: MockProxy<StateService>;
|
||||||
let keyGenerationService: MockProxy<KeyGenerationService>;
|
let keyGenerationService: MockProxy<KeyGenerationService>;
|
||||||
let encryptService: MockProxy<EncryptService>;
|
let encryptService: MockProxy<EncryptService>;
|
||||||
let logService: MockProxy<LogService>;
|
let logService: MockProxy<LogService>;
|
||||||
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
|
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
|
||||||
let accountService: FakeAccountService;
|
let accountService: FakeAccountService;
|
||||||
|
let stateProvider: FakeStateProvider;
|
||||||
|
|
||||||
const userId = "00000000-0000-0000-0000-000000000000" as UserId;
|
const userId = "00000000-0000-0000-0000-000000000000" as UserId;
|
||||||
const mockUserState = {
|
|
||||||
state$: of(null),
|
|
||||||
update: jest.fn().mockResolvedValue(null),
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const kdfPBKDF2: KdfConfig = new PBKDF2KdfConfig(600_000);
|
||||||
|
const kdfArgon2: KdfConfig = new Argon2KdfConfig(4, 64, 3);
|
||||||
|
const salt = "test@bitwarden.com" as MasterPasswordSalt;
|
||||||
|
const userKey = makeSymmetricCryptoKey(64, 2) as UserKey;
|
||||||
const testUserKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 1);
|
const testUserKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 1);
|
||||||
const testMasterKey: MasterKey = makeSymmetricCryptoKey(32, 2);
|
const testMasterKey: MasterKey = makeSymmetricCryptoKey(32, 2);
|
||||||
const testStretchedMasterKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 3);
|
const testStretchedMasterKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 3);
|
||||||
@@ -58,17 +62,13 @@ describe("MasterPasswordService", () => {
|
|||||||
"2.gbauOANURUHqvhLTDnva1A==|nSW+fPumiuTaDB/s12+JO88uemV6rhwRSR+YR1ZzGr5j6Ei3/h+XEli2Unpz652NlZ9NTuRpHxeOqkYYJtp7J+lPMoclgteXuAzUu9kqlRc=|DeUFkhIwgkGdZA08bDnDqMMNmZk21D+H5g8IostPKAY=";
|
"2.gbauOANURUHqvhLTDnva1A==|nSW+fPumiuTaDB/s12+JO88uemV6rhwRSR+YR1ZzGr5j6Ei3/h+XEli2Unpz652NlZ9NTuRpHxeOqkYYJtp7J+lPMoclgteXuAzUu9kqlRc=|DeUFkhIwgkGdZA08bDnDqMMNmZk21D+H5g8IostPKAY=";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
stateProvider = mock<StateProvider>();
|
|
||||||
stateService = mock<StateService>();
|
stateService = mock<StateService>();
|
||||||
keyGenerationService = mock<KeyGenerationService>();
|
keyGenerationService = mock<KeyGenerationService>();
|
||||||
encryptService = mock<EncryptService>();
|
encryptService = mock<EncryptService>();
|
||||||
logService = mock<LogService>();
|
logService = mock<LogService>();
|
||||||
cryptoFunctionService = mock<CryptoFunctionService>();
|
cryptoFunctionService = mock<CryptoFunctionService>();
|
||||||
accountService = mockAccountServiceWith(userId);
|
accountService = mockAccountServiceWith(userId);
|
||||||
|
stateProvider = new FakeStateProvider(accountService);
|
||||||
stateProvider.getUser.mockReturnValue(mockUserState as any);
|
|
||||||
|
|
||||||
mockUserState.update.mockReset();
|
|
||||||
|
|
||||||
sut = new MasterPasswordService(
|
sut = new MasterPasswordService(
|
||||||
stateProvider,
|
stateProvider,
|
||||||
@@ -88,6 +88,10 @@ describe("MasterPasswordService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
describe("saltForUser$", () => {
|
describe("saltForUser$", () => {
|
||||||
it("throws when userid not present", async () => {
|
it("throws when userid not present", async () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
@@ -111,12 +115,10 @@ describe("MasterPasswordService", () => {
|
|||||||
|
|
||||||
await sut.setForceSetPasswordReason(reason, userId);
|
await sut.setForceSetPasswordReason(reason, userId);
|
||||||
|
|
||||||
expect(stateProvider.getUser).toHaveBeenCalled();
|
const state = await firstValueFrom(
|
||||||
expect(mockUserState.update).toHaveBeenCalled();
|
stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$,
|
||||||
|
);
|
||||||
// Call the update function to verify it returns the correct reason
|
expect(state).toEqual(reason);
|
||||||
const updateFn = mockUserState.update.mock.calls[0][0];
|
|
||||||
expect(updateFn(null)).toBe(reason);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws an error if reason is null", async () => {
|
it("throws an error if reason is null", async () => {
|
||||||
@@ -132,31 +134,29 @@ describe("MasterPasswordService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not overwrite AdminForcePasswordReset with other reasons except None", async () => {
|
it("does not overwrite AdminForcePasswordReset with other reasons except None", async () => {
|
||||||
jest
|
stateProvider.singleUser
|
||||||
.spyOn(sut, "forceSetPasswordReason$")
|
.getFake(userId, FORCE_SET_PASSWORD_REASON)
|
||||||
.mockReturnValue(of(ForceSetPasswordReason.AdminForcePasswordReset));
|
.nextState(ForceSetPasswordReason.AdminForcePasswordReset);
|
||||||
|
|
||||||
jest
|
|
||||||
.spyOn(rxjs, "firstValueFrom")
|
|
||||||
.mockResolvedValue(ForceSetPasswordReason.AdminForcePasswordReset);
|
|
||||||
|
|
||||||
await sut.setForceSetPasswordReason(ForceSetPasswordReason.WeakMasterPassword, userId);
|
await sut.setForceSetPasswordReason(ForceSetPasswordReason.WeakMasterPassword, userId);
|
||||||
|
|
||||||
expect(mockUserState.update).not.toHaveBeenCalled();
|
const state = await firstValueFrom(
|
||||||
|
stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$,
|
||||||
|
);
|
||||||
|
expect(state).toEqual(ForceSetPasswordReason.AdminForcePasswordReset);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows overwriting AdminForcePasswordReset with None", async () => {
|
it("allows overwriting AdminForcePasswordReset with None", async () => {
|
||||||
jest
|
stateProvider.singleUser
|
||||||
.spyOn(sut, "forceSetPasswordReason$")
|
.getFake(userId, FORCE_SET_PASSWORD_REASON)
|
||||||
.mockReturnValue(of(ForceSetPasswordReason.AdminForcePasswordReset));
|
.nextState(ForceSetPasswordReason.AdminForcePasswordReset);
|
||||||
|
|
||||||
jest
|
|
||||||
.spyOn(rxjs, "firstValueFrom")
|
|
||||||
.mockResolvedValue(ForceSetPasswordReason.AdminForcePasswordReset);
|
|
||||||
|
|
||||||
await sut.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
|
await sut.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
|
||||||
|
|
||||||
expect(mockUserState.update).toHaveBeenCalled();
|
const state = await firstValueFrom(
|
||||||
|
stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$,
|
||||||
|
);
|
||||||
|
expect(state).toEqual(ForceSetPasswordReason.None);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe("decryptUserKeyWithMasterKey", () => {
|
describe("decryptUserKeyWithMasterKey", () => {
|
||||||
@@ -227,10 +227,10 @@ describe("MasterPasswordService", () => {
|
|||||||
|
|
||||||
await sut.setMasterKeyEncryptedUserKey(encryptedKey, userId);
|
await sut.setMasterKeyEncryptedUserKey(encryptedKey, userId);
|
||||||
|
|
||||||
expect(stateProvider.getUser).toHaveBeenCalled();
|
const state = await firstValueFrom(
|
||||||
expect(mockUserState.update).toHaveBeenCalled();
|
stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$,
|
||||||
const updateFn = mockUserState.update.mock.calls[0][0];
|
);
|
||||||
expect(updateFn(null)).toEqual(encryptedKey.toJSON());
|
expect(state).toEqual(encryptedKey.toJSON());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -328,11 +328,6 @@ describe("MasterPasswordService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("setMasterPasswordUnlockData", () => {
|
describe("setMasterPasswordUnlockData", () => {
|
||||||
const kdfPBKDF2: KdfConfig = new PBKDF2KdfConfig(600_000);
|
|
||||||
const kdfArgon2: KdfConfig = new Argon2KdfConfig(4, 64, 3);
|
|
||||||
const salt = "test@bitwarden.com" as MasterPasswordSalt;
|
|
||||||
const userKey = makeSymmetricCryptoKey(64, 2) as UserKey;
|
|
||||||
|
|
||||||
it.each([kdfPBKDF2, kdfArgon2])(
|
it.each([kdfPBKDF2, kdfArgon2])(
|
||||||
"sets the master password unlock data kdf %o in the state",
|
"sets the master password unlock data kdf %o in the state",
|
||||||
async (kdfConfig) => {
|
async (kdfConfig) => {
|
||||||
@@ -345,11 +340,10 @@ describe("MasterPasswordService", () => {
|
|||||||
|
|
||||||
await sut.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
|
await sut.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
|
||||||
|
|
||||||
expect(stateProvider.getUser).toHaveBeenCalledWith(userId, MASTER_PASSWORD_UNLOCK_KEY);
|
const state = await firstValueFrom(
|
||||||
expect(mockUserState.update).toHaveBeenCalled();
|
stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$,
|
||||||
|
);
|
||||||
const updateFn = mockUserState.update.mock.calls[0][0];
|
expect(state).toEqual(masterPasswordUnlockData.toJSON());
|
||||||
expect(updateFn(null)).toEqual(masterPasswordUnlockData.toJSON());
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -373,6 +367,40 @@ describe("MasterPasswordService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("masterPasswordUnlockData$", () => {
|
||||||
|
test.each([null as unknown as UserId, undefined as unknown as UserId])(
|
||||||
|
"throws when the provided userId is %s",
|
||||||
|
async (userId) => {
|
||||||
|
expect(() => sut.masterPasswordUnlockData$(userId)).toThrow("userId is null or undefined.");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("returns null when no data is set", async () => {
|
||||||
|
stateProvider.singleUser.getFake(userId, MASTER_PASSWORD_UNLOCK_KEY).nextState(null);
|
||||||
|
|
||||||
|
const result = await firstValueFrom(sut.masterPasswordUnlockData$(userId));
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([kdfPBKDF2, kdfArgon2])(
|
||||||
|
"returns the master password unlock data for kdf %o from state",
|
||||||
|
async (kdfConfig) => {
|
||||||
|
const masterPasswordUnlockData = await sut.makeMasterPasswordUnlockData(
|
||||||
|
"test-password",
|
||||||
|
kdfConfig,
|
||||||
|
salt,
|
||||||
|
userKey,
|
||||||
|
);
|
||||||
|
await sut.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
|
||||||
|
|
||||||
|
const result = await firstValueFrom(sut.masterPasswordUnlockData$(userId));
|
||||||
|
|
||||||
|
expect(result).toEqual(masterPasswordUnlockData.toJSON());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe("MASTER_PASSWORD_UNLOCK_KEY", () => {
|
describe("MASTER_PASSWORD_UNLOCK_KEY", () => {
|
||||||
it("has the correct configuration", () => {
|
it("has the correct configuration", () => {
|
||||||
expect(MASTER_PASSWORD_UNLOCK_KEY.stateDefinition).toBeDefined();
|
expect(MASTER_PASSWORD_UNLOCK_KEY.stateDefinition).toBeDefined();
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const MASTER_KEY_HASH = new UserKeyDefinition<string>(MASTER_PASSWORD_DISK, "mas
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Disk to persist through lock */
|
/** Disk to persist through lock */
|
||||||
const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition<EncryptedString>(
|
export const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition<EncryptedString>(
|
||||||
MASTER_PASSWORD_DISK,
|
MASTER_PASSWORD_DISK,
|
||||||
"masterKeyEncryptedUserKey",
|
"masterKeyEncryptedUserKey",
|
||||||
{
|
{
|
||||||
@@ -60,7 +60,7 @@ const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition<EncryptedString>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** Disk to persist through lock and account switches */
|
/** Disk to persist through lock and account switches */
|
||||||
const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition<ForceSetPasswordReason>(
|
export const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition<ForceSetPasswordReason>(
|
||||||
MASTER_PASSWORD_DISK,
|
MASTER_PASSWORD_DISK,
|
||||||
"forceSetPasswordReason",
|
"forceSetPasswordReason",
|
||||||
{
|
{
|
||||||
@@ -344,4 +344,10 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
|||||||
.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY)
|
.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY)
|
||||||
.update(() => masterPasswordUnlockData.toJSON());
|
.update(() => masterPasswordUnlockData.toJSON());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
masterPasswordUnlockData$(userId: UserId): Observable<MasterPasswordUnlockData | null> {
|
||||||
|
assertNonNullish(userId, "userId");
|
||||||
|
|
||||||
|
return this.stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,73 +120,87 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- MP Unlock -->
|
<!-- MP Unlock -->
|
||||||
<ng-container
|
@if (
|
||||||
*ngIf="
|
(unlockWithMasterPasswordUnlockDataFlag$ | async) &&
|
||||||
unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword
|
unlockOptions.masterPassword.enabled &&
|
||||||
"
|
activeUnlockOption === UnlockOption.MasterPassword
|
||||||
>
|
) {
|
||||||
<form [bitSubmit]="submit" [formGroup]="formGroup">
|
<bit-master-password-lock
|
||||||
<bit-form-field>
|
[(activeUnlockOption)]="activeUnlockOption"
|
||||||
<bit-label>{{ "masterPass" | i18n }}</bit-label>
|
[unlockOptions]="unlockOptions"
|
||||||
<input
|
[biometricUnlockBtnText]="biometricUnlockBtnText"
|
||||||
type="password"
|
(successfulUnlock)="successfulMasterPasswordUnlock($event)"
|
||||||
formControlName="masterPassword"
|
(logOut)="logOut()"
|
||||||
bitInput
|
></bit-master-password-lock>
|
||||||
appAutofocus
|
} @else {
|
||||||
name="masterPassword"
|
<ng-container
|
||||||
class="tw-font-mono"
|
*ngIf="
|
||||||
required
|
unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword
|
||||||
appInputVerbatim
|
"
|
||||||
/>
|
>
|
||||||
<button
|
<form [bitSubmit]="submit" [formGroup]="formGroup">
|
||||||
type="button"
|
<bit-form-field>
|
||||||
bitIconButton
|
<bit-label>{{ "masterPass" | i18n }}</bit-label>
|
||||||
bitSuffix
|
<input
|
||||||
bitPasswordInputToggle
|
type="password"
|
||||||
[(toggled)]="showPassword"
|
formControlName="masterPassword"
|
||||||
></button>
|
bitInput
|
||||||
|
appAutofocus
|
||||||
<!-- [attr.aria-pressed]="showPassword" -->
|
name="masterPassword"
|
||||||
</bit-form-field>
|
class="tw-font-mono"
|
||||||
|
required
|
||||||
<div class="tw-flex tw-flex-col tw-space-y-3">
|
appInputVerbatim
|
||||||
<button type="submit" bitButton bitFormButton buttonType="primary" block>
|
/>
|
||||||
{{ "unlock" | i18n }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p class="tw-text-center">{{ "or" | i18n }}</p>
|
|
||||||
|
|
||||||
<ng-container *ngIf="showBiometrics">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
bitButton
|
bitIconButton
|
||||||
bitFormButton
|
bitSuffix
|
||||||
buttonType="secondary"
|
bitPasswordInputToggle
|
||||||
[disabled]="!biometricsAvailable"
|
[(toggled)]="showPassword"
|
||||||
block
|
></button>
|
||||||
(click)="activeUnlockOption = UnlockOption.Biometrics"
|
|
||||||
>
|
|
||||||
<span> {{ biometricUnlockBtnText | i18n }}</span>
|
|
||||||
</button>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *ngIf="unlockOptions.pin.enabled">
|
<!-- [attr.aria-pressed]="showPassword" -->
|
||||||
<button
|
</bit-form-field>
|
||||||
type="button"
|
|
||||||
bitButton
|
|
||||||
bitFormButton
|
|
||||||
buttonType="secondary"
|
|
||||||
block
|
|
||||||
(click)="activeUnlockOption = UnlockOption.Pin"
|
|
||||||
>
|
|
||||||
{{ "unlockWithPin" | i18n }}
|
|
||||||
</button>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<button type="button" bitButton bitFormButton block (click)="logOut()">
|
<div class="tw-flex tw-flex-col tw-space-y-3">
|
||||||
{{ "logOut" | i18n }}
|
<button type="submit" bitButton bitFormButton buttonType="primary" block>
|
||||||
</button>
|
{{ "unlock" | i18n }}
|
||||||
</div>
|
</button>
|
||||||
</form>
|
|
||||||
</ng-container>
|
<p class="tw-text-center">{{ "or" | i18n }}</p>
|
||||||
|
|
||||||
|
<ng-container *ngIf="showBiometrics">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
bitFormButton
|
||||||
|
buttonType="secondary"
|
||||||
|
[disabled]="!biometricsAvailable"
|
||||||
|
block
|
||||||
|
(click)="activeUnlockOption = UnlockOption.Biometrics"
|
||||||
|
>
|
||||||
|
<span> {{ biometricUnlockBtnText | i18n }}</span>
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="unlockOptions.pin.enabled">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
bitFormButton
|
||||||
|
buttonType="secondary"
|
||||||
|
block
|
||||||
|
(click)="activeUnlockOption = UnlockOption.Pin"
|
||||||
|
>
|
||||||
|
{{ "unlockWithPin" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<button type="button" bitButton bitFormButton block (click)="logOut()">
|
||||||
|
{{ "logOut" | i18n }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/
|
|||||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
@@ -91,9 +92,10 @@ describe("LockComponent", () => {
|
|||||||
const mockLockComponentService = mock<LockComponentService>();
|
const mockLockComponentService = mock<LockComponentService>();
|
||||||
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
|
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
|
||||||
const mockBroadcasterService = mock<BroadcasterService>();
|
const mockBroadcasterService = mock<BroadcasterService>();
|
||||||
|
const mockConfigService = mock<ConfigService>();
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.resetAllMocks();
|
||||||
|
|
||||||
// Setup default mock returns
|
// Setup default mock returns
|
||||||
mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web);
|
mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web);
|
||||||
@@ -148,6 +150,7 @@ describe("LockComponent", () => {
|
|||||||
{ provide: LockComponentService, useValue: mockLockComponentService },
|
{ provide: LockComponentService, useValue: mockLockComponentService },
|
||||||
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
|
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
|
||||||
{ provide: BroadcasterService, useValue: mockBroadcasterService },
|
{ provide: BroadcasterService, useValue: mockBroadcasterService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.overrideProvider(DialogService, { useValue: mockDialogService })
|
.overrideProvider(DialogService, { useValue: mockDialogService })
|
||||||
@@ -358,6 +361,135 @@ describe("LockComponent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("successfulMasterPasswordUnlock", () => {
|
||||||
|
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||||
|
const masterPassword = "test-password";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[undefined as unknown as UserKey, undefined as unknown as string],
|
||||||
|
[null as unknown as UserKey, null as unknown as string],
|
||||||
|
[mockUserKey, undefined as unknown as string],
|
||||||
|
[mockUserKey, null as unknown as string],
|
||||||
|
[mockUserKey, ""],
|
||||||
|
[undefined as unknown as UserKey, masterPassword],
|
||||||
|
[null as unknown as UserKey, masterPassword],
|
||||||
|
])(
|
||||||
|
"logs an error and doesn't unlock when called with invalid data",
|
||||||
|
async (userKey, masterPassword) => {
|
||||||
|
await component.successfulMasterPasswordUnlock({ userKey, masterPassword });
|
||||||
|
|
||||||
|
expect(mockLogService.error).toHaveBeenCalledWith(
|
||||||
|
"[LockComponent] successfulMasterPasswordUnlock called with invalid data.",
|
||||||
|
);
|
||||||
|
expect(mockKeyService.setUserKey).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[false, undefined, false],
|
||||||
|
[false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false],
|
||||||
|
[false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true],
|
||||||
|
[true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false],
|
||||||
|
[false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true],
|
||||||
|
])(
|
||||||
|
"unlocks and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy loaded from policy service",
|
||||||
|
async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => {
|
||||||
|
mockPolicyService.masterPasswordPolicyOptions$.mockReturnValue(
|
||||||
|
of(masterPasswordPolicyOptions),
|
||||||
|
);
|
||||||
|
const passwordStrengthResult = { score: 1 } as ZXCVBNResult;
|
||||||
|
mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult);
|
||||||
|
mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword);
|
||||||
|
|
||||||
|
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
|
||||||
|
|
||||||
|
assertUnlocked();
|
||||||
|
expect(mockPolicyService.masterPasswordPolicyOptions$).toHaveBeenCalledWith(userId);
|
||||||
|
if (masterPasswordPolicyOptions?.enforceOnLogin) {
|
||||||
|
expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith(
|
||||||
|
masterPassword,
|
||||||
|
component.activeAccount!.email,
|
||||||
|
);
|
||||||
|
expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith(
|
||||||
|
passwordStrengthResult.score,
|
||||||
|
masterPassword,
|
||||||
|
masterPasswordPolicyOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (forceSetPassword) {
|
||||||
|
expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
|
||||||
|
ForceSetPasswordReason.WeakMasterPassword,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[true, ClientType.Browser],
|
||||||
|
[false, ClientType.Cli],
|
||||||
|
[false, ClientType.Desktop],
|
||||||
|
[false, ClientType.Web],
|
||||||
|
])(
|
||||||
|
"unlocks and navigate by url to previous url = %o when client type = %o and previous url was set",
|
||||||
|
async (shouldNavigate, clientType) => {
|
||||||
|
const previousUrl = "/test-url";
|
||||||
|
component.clientType = clientType;
|
||||||
|
mockLockComponentService.getPreviousUrl.mockReturnValue(previousUrl);
|
||||||
|
|
||||||
|
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
|
||||||
|
|
||||||
|
assertUnlocked();
|
||||||
|
if (shouldNavigate) {
|
||||||
|
expect(mockRouter.navigateByUrl).toHaveBeenCalledWith(previousUrl);
|
||||||
|
} else {
|
||||||
|
expect(mockRouter.navigateByUrl).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["/tabs/current", ClientType.Browser],
|
||||||
|
[undefined, ClientType.Cli],
|
||||||
|
["vault", ClientType.Desktop],
|
||||||
|
["vault", ClientType.Web],
|
||||||
|
])(
|
||||||
|
"unlocks and navigate to success url = %o when client type = %o",
|
||||||
|
async (navigateUrl, clientType) => {
|
||||||
|
component.clientType = clientType;
|
||||||
|
mockLockComponentService.getPreviousUrl.mockReturnValue(null);
|
||||||
|
|
||||||
|
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
|
||||||
|
|
||||||
|
assertUnlocked();
|
||||||
|
expect(mockRouter.navigate).toHaveBeenCalledWith([navigateUrl]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("unlocks and close browser extension popout on firefox extension", async () => {
|
||||||
|
component.shouldClosePopout = true;
|
||||||
|
mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension);
|
||||||
|
|
||||||
|
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
|
||||||
|
|
||||||
|
assertUnlocked();
|
||||||
|
expect(mockLockComponentService.closeBrowserExtensionPopout).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
function assertUnlocked(): void {
|
||||||
|
expect(mockKeyService.setUserKey).toHaveBeenCalledWith(
|
||||||
|
mockUserKey,
|
||||||
|
component.activeAccount!.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
describe("unlockViaMasterPassword", () => {
|
describe("unlockViaMasterPassword", () => {
|
||||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey;
|
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey;
|
||||||
const masterPasswordVerificationResponse: MasterPasswordVerificationResponse = {
|
const masterPasswordVerificationResponse: MasterPasswordVerificationResponse = {
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ import {
|
|||||||
MasterPasswordVerificationResponse,
|
MasterPasswordVerificationResponse,
|
||||||
} from "@bitwarden/common/auth/types/verification";
|
} from "@bitwarden/common/auth/types/verification";
|
||||||
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
@@ -64,6 +66,8 @@ import {
|
|||||||
UnlockOptionValue,
|
UnlockOptionValue,
|
||||||
} from "../services/lock-component.service";
|
} from "../services/lock-component.service";
|
||||||
|
|
||||||
|
import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component";
|
||||||
|
|
||||||
const BroadcasterSubscriptionId = "LockComponent";
|
const BroadcasterSubscriptionId = "LockComponent";
|
||||||
|
|
||||||
const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
|
const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
|
||||||
@@ -72,6 +76,12 @@ const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
|
|||||||
[ClientType.Browser]: "/tabs/current",
|
[ClientType.Browser]: "/tabs/current",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AfterUnlockActions = {
|
||||||
|
passwordEvaluation?: {
|
||||||
|
masterPassword: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/// The minimum amount of time to wait after a process reload for a biometrics auto prompt to be possible
|
/// The minimum amount of time to wait after a process reload for a biometrics auto prompt to be possible
|
||||||
/// Fixes safari autoprompt behavior
|
/// Fixes safari autoprompt behavior
|
||||||
const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000;
|
const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000;
|
||||||
@@ -87,12 +97,17 @@ const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000;
|
|||||||
FormFieldModule,
|
FormFieldModule,
|
||||||
AsyncActionsModule,
|
AsyncActionsModule,
|
||||||
IconButtonModule,
|
IconButtonModule,
|
||||||
|
MasterPasswordLockComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class LockComponent implements OnInit, OnDestroy {
|
export class LockComponent implements OnInit, OnDestroy {
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
protected loading = true;
|
protected loading = true;
|
||||||
|
|
||||||
|
protected unlockWithMasterPasswordUnlockDataFlag$ = this.configService.getFeatureFlag$(
|
||||||
|
FeatureFlag.UnlockWithMasterPasswordUnlockData,
|
||||||
|
);
|
||||||
|
|
||||||
activeAccount: Account | null = null;
|
activeAccount: Account | null = null;
|
||||||
|
|
||||||
clientType?: ClientType;
|
clientType?: ClientType;
|
||||||
@@ -160,6 +175,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
private logoutService: LogoutService,
|
private logoutService: LogoutService,
|
||||||
private lockComponentService: LockComponentService,
|
private lockComponentService: LockComponentService,
|
||||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||||
|
private configService: ConfigService,
|
||||||
// desktop deps
|
// desktop deps
|
||||||
private broadcasterService: BroadcasterService,
|
private broadcasterService: BroadcasterService,
|
||||||
) {}
|
) {}
|
||||||
@@ -379,7 +395,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// If user cancels biometric prompt, userKey is undefined.
|
// If user cancels biometric prompt, userKey is undefined.
|
||||||
if (userKey) {
|
if (userKey) {
|
||||||
await this.setUserKeyAndContinue(userKey, false);
|
await this.setUserKeyAndContinue(userKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.unlockingViaBiometrics = false;
|
this.unlockingViaBiometrics = false;
|
||||||
@@ -423,6 +439,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//TODO PM-25385 This code isn't used and should be removed when removing the UnlockWithMasterPasswordUnlockData feature flag.
|
||||||
togglePassword() {
|
togglePassword() {
|
||||||
this.showPassword = !this.showPassword;
|
this.showPassword = !this.showPassword;
|
||||||
const input = document.getElementById(
|
const input = document.getElementById(
|
||||||
@@ -498,6 +515,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag.
|
||||||
private validateMasterPassword(): boolean {
|
private validateMasterPassword(): boolean {
|
||||||
if (this.formGroup?.invalid) {
|
if (this.formGroup?.invalid) {
|
||||||
this.toastService.showToast({
|
this.toastService.showToast({
|
||||||
@@ -511,6 +529,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag.
|
||||||
async unlockViaMasterPassword() {
|
async unlockViaMasterPassword() {
|
||||||
if (!this.validateMasterPassword() || this.formGroup == null || this.activeAccount == null) {
|
if (!this.validateMasterPassword() || this.formGroup == null || this.activeAccount == null) {
|
||||||
return;
|
return;
|
||||||
@@ -568,10 +587,33 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.setUserKeyAndContinue(userKey, true);
|
await this.setUserKeyAndContinue(userKey, {
|
||||||
|
passwordEvaluation: { masterPassword },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) {
|
async successfulMasterPasswordUnlock(event: {
|
||||||
|
userKey: UserKey;
|
||||||
|
masterPassword: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (event.userKey == null || !event.masterPassword) {
|
||||||
|
this.logService.error(
|
||||||
|
"[LockComponent] successfulMasterPasswordUnlock called with invalid data.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.setUserKeyAndContinue(event.userKey, {
|
||||||
|
passwordEvaluation: {
|
||||||
|
masterPassword: event.masterPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async setUserKeyAndContinue(
|
||||||
|
key: UserKey,
|
||||||
|
afterUnlockActions: AfterUnlockActions = {},
|
||||||
|
): Promise<void> {
|
||||||
if (this.activeAccount == null) {
|
if (this.activeAccount == null) {
|
||||||
throw new Error("No active user.");
|
throw new Error("No active user.");
|
||||||
}
|
}
|
||||||
@@ -585,10 +627,10 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
// need to establish trust on the current device
|
// need to establish trust on the current device
|
||||||
await this.deviceTrustService.trustDeviceIfRequired(this.activeAccount.id);
|
await this.deviceTrustService.trustDeviceIfRequired(this.activeAccount.id);
|
||||||
|
|
||||||
await this.doContinue(evaluatePasswordAfterUnlock);
|
await this.doContinue(afterUnlockActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async doContinue(evaluatePasswordAfterUnlock: boolean) {
|
private async doContinue(afterUnlockActions: AfterUnlockActions) {
|
||||||
if (this.activeAccount == null) {
|
if (this.activeAccount == null) {
|
||||||
throw new Error("No active user.");
|
throw new Error("No active user.");
|
||||||
}
|
}
|
||||||
@@ -596,7 +638,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
await this.biometricStateService.resetUserPromptCancelled();
|
await this.biometricStateService.resetUserPromptCancelled();
|
||||||
this.messagingService.send("unlocked");
|
this.messagingService.send("unlocked");
|
||||||
|
|
||||||
if (evaluatePasswordAfterUnlock) {
|
if (afterUnlockActions.passwordEvaluation) {
|
||||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
throw new Error("No active user.");
|
throw new Error("No active user.");
|
||||||
@@ -613,7 +655,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.requirePasswordChange()) {
|
if (this.requirePasswordChange(afterUnlockActions.passwordEvaluation.masterPassword)) {
|
||||||
await this.masterPasswordService.setForceSetPasswordReason(
|
await this.masterPasswordService.setForceSetPasswordReason(
|
||||||
ForceSetPasswordReason.WeakMasterPassword,
|
ForceSetPasswordReason.WeakMasterPassword,
|
||||||
userId,
|
userId,
|
||||||
@@ -669,18 +711,15 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
* Checks if the master password meets the enforced policy requirements
|
* Checks if the master password meets the enforced policy requirements
|
||||||
* If not, returns false
|
* If not, returns false
|
||||||
*/
|
*/
|
||||||
private requirePasswordChange(): boolean {
|
private requirePasswordChange(masterPassword: string): boolean {
|
||||||
if (
|
if (
|
||||||
this.enforcedMasterPasswordOptions == undefined ||
|
this.enforcedMasterPasswordOptions == undefined ||
|
||||||
!this.enforcedMasterPasswordOptions.enforceOnLogin ||
|
!this.enforcedMasterPasswordOptions.enforceOnLogin ||
|
||||||
this.formGroup == null ||
|
|
||||||
this.activeAccount == null
|
this.activeAccount == null
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const masterPassword = this.formGroup.controls.masterPassword.value;
|
|
||||||
|
|
||||||
const passwordStrength = this.passwordStrengthService.getPasswordStrength(
|
const passwordStrength = this.passwordStrengthService.getPasswordStrength(
|
||||||
masterPassword,
|
masterPassword,
|
||||||
this.activeAccount.email,
|
this.activeAccount.email,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>{{ "masterPass" | i18n }}</bit-label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
formControlName="masterPassword"
|
||||||
|
bitInput
|
||||||
|
appAutofocus
|
||||||
|
name="masterPassword"
|
||||||
|
class="tw-font-mono"
|
||||||
|
required
|
||||||
|
appInputVerbatim
|
||||||
|
/>
|
||||||
|
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
|
||||||
|
</bit-form-field>
|
||||||
|
|
||||||
|
<div class="tw-flex tw-flex-col tw-space-y-3">
|
||||||
|
<button type="submit" bitButton bitFormButton buttonType="primary" block>
|
||||||
|
{{ "unlock" | i18n }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="tw-text-center">{{ "or" | i18n }}</p>
|
||||||
|
|
||||||
|
@if (showBiometricsSwap()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
bitFormButton
|
||||||
|
buttonType="secondary"
|
||||||
|
[disabled]="!biometricsAvailable()"
|
||||||
|
block
|
||||||
|
(click)="activeUnlockOption.set(UnlockOption.Biometrics)"
|
||||||
|
>
|
||||||
|
<span> {{ biometricUnlockBtnText() | i18n }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showPinSwap()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
bitFormButton
|
||||||
|
buttonType="secondary"
|
||||||
|
block
|
||||||
|
(click)="activeUnlockOption.set(UnlockOption.Pin)"
|
||||||
|
>
|
||||||
|
{{ "unlockWithPin" | i18n }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button type="button" bitButton bitFormButton block (click)="logOut.emit()">
|
||||||
|
{{ "logOut" | i18n }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
import { DebugElement } from "@angular/core";
|
||||||
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
|
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||||
|
import { By } from "@angular/platform-browser";
|
||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
import { of } from "rxjs";
|
||||||
|
|
||||||
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
|
import { UserKey } from "@bitwarden/common/types/key";
|
||||||
|
import {
|
||||||
|
AsyncActionsModule,
|
||||||
|
ButtonModule,
|
||||||
|
FormFieldModule,
|
||||||
|
IconButtonModule,
|
||||||
|
ToastService,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||||
|
import { UserId } from "@bitwarden/user-core";
|
||||||
|
|
||||||
|
import { UnlockOption, UnlockOptions } from "../../services/lock-component.service";
|
||||||
|
|
||||||
|
import { MasterPasswordLockComponent } from "./master-password-lock.component";
|
||||||
|
|
||||||
|
describe("MasterPasswordLockComponent", () => {
|
||||||
|
let component: MasterPasswordLockComponent;
|
||||||
|
let fixture: ComponentFixture<MasterPasswordLockComponent>;
|
||||||
|
|
||||||
|
const accountService = mock<AccountService>();
|
||||||
|
const masterPasswordUnlockService = mock<MasterPasswordUnlockService>();
|
||||||
|
const i18nService = mock<I18nService>();
|
||||||
|
const toastService = mock<ToastService>();
|
||||||
|
const logService = mock<LogService>();
|
||||||
|
|
||||||
|
const mockMasterPassword = "testExample";
|
||||||
|
const activeAccount: Account = {
|
||||||
|
id: "user-id" as UserId,
|
||||||
|
email: "user@example.com",
|
||||||
|
emailVerified: true,
|
||||||
|
name: "User",
|
||||||
|
};
|
||||||
|
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||||
|
|
||||||
|
const setupComponent = (
|
||||||
|
unlockOptions: Partial<UnlockOptions> = {},
|
||||||
|
biometricUnlockBtnText: string = "default",
|
||||||
|
account: Account | null = activeAccount,
|
||||||
|
) => {
|
||||||
|
const defaultOptions: UnlockOptions = {
|
||||||
|
masterPassword: { enabled: true },
|
||||||
|
pin: { enabled: false },
|
||||||
|
biometrics: {
|
||||||
|
enabled: false,
|
||||||
|
biometricsStatus: BiometricsStatus.NotEnabledLocally,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
accountService.activeAccount$ = of(account);
|
||||||
|
fixture.componentRef.setInput("unlockOptions", { ...defaultOptions, ...unlockOptions });
|
||||||
|
fixture.componentRef.setInput("biometricUnlockBtnText", biometricUnlockBtnText);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
return {
|
||||||
|
form: fixture.debugElement.query(By.css("form")),
|
||||||
|
component,
|
||||||
|
...getFormElements(fixture.debugElement.query(By.css("form"))),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFormElements = (form: DebugElement) => ({
|
||||||
|
masterPasswordInput: form.query(By.css('input[formControlName="masterPassword"]')),
|
||||||
|
toggleButton: form.query(By.css("button[bitPasswordInputToggle]")),
|
||||||
|
submitButton: form.query(By.css('button[type="submit"]')),
|
||||||
|
logoutButton: form.query(By.css('button[type="button"]:not([bitPasswordInputToggle])')),
|
||||||
|
secondaryButton: form.query(By.css('button[buttonType="secondary"]')),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
i18nService.t.mockImplementation((key: string) => key);
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
MasterPasswordLockComponent,
|
||||||
|
JslibModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
ButtonModule,
|
||||||
|
FormFieldModule,
|
||||||
|
AsyncActionsModule,
|
||||||
|
IconButtonModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
FormBuilder,
|
||||||
|
{ provide: AccountService, useValue: accountService },
|
||||||
|
{ provide: MasterPasswordUnlockService, useValue: masterPasswordUnlockService },
|
||||||
|
{ provide: I18nService, useValue: i18nService },
|
||||||
|
{ provide: ToastService, useValue: toastService },
|
||||||
|
{ provide: LogService, useValue: logService },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(MasterPasswordLockComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("form rendering", () => {
|
||||||
|
let elements: ReturnType<typeof setupComponent>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
elements = setupComponent();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates form with proper structure", () => {
|
||||||
|
expect(component.formGroup).toBeDefined();
|
||||||
|
expect(component.formGroup.controls.masterPassword).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
const formElementTests = [
|
||||||
|
{
|
||||||
|
name: "master password input",
|
||||||
|
selector: "masterPasswordInput",
|
||||||
|
expectations: (el: HTMLInputElement) => {
|
||||||
|
expect(el).toMatchObject({
|
||||||
|
type: "password",
|
||||||
|
name: "masterPassword",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
expect(el.attributes).toHaveProperty("bitInput");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "password toggle button",
|
||||||
|
selector: "toggleButton",
|
||||||
|
expectations: (el: HTMLButtonElement) => {
|
||||||
|
expect(el.type).toBe("button");
|
||||||
|
expect(el.attributes).toHaveProperty("bitIconButton");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unlock submit button",
|
||||||
|
selector: "submitButton",
|
||||||
|
expectations: (el: HTMLButtonElement) => {
|
||||||
|
expect(el).toMatchObject({
|
||||||
|
type: "submit",
|
||||||
|
textContent: expect.stringContaining("unlock"),
|
||||||
|
});
|
||||||
|
expect(el.attributes).toHaveProperty("bitButton");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "logout button",
|
||||||
|
selector: "logoutButton",
|
||||||
|
expectations: (el: HTMLButtonElement) => {
|
||||||
|
expect(el).toMatchObject({
|
||||||
|
type: "button",
|
||||||
|
textContent: expect.stringContaining("logOut"),
|
||||||
|
});
|
||||||
|
expect(el.attributes).toHaveProperty("bitButton");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(formElementTests)("renders $name correctly", ({ selector, expectations }) => {
|
||||||
|
const element = elements[selector as keyof typeof elements] as DebugElement;
|
||||||
|
expect(element).toBeTruthy();
|
||||||
|
expectations(element.nativeElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hiddenButtonTests = [
|
||||||
|
{
|
||||||
|
case: "biometrics swap button when biometrics is undefined",
|
||||||
|
setup: () =>
|
||||||
|
setupComponent(
|
||||||
|
{
|
||||||
|
pin: { enabled: false },
|
||||||
|
biometrics: {
|
||||||
|
enabled: undefined as unknown as boolean,
|
||||||
|
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"swapBiometrics",
|
||||||
|
),
|
||||||
|
expectHidden: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
case: "biometrics swap button when biometrics is disabled",
|
||||||
|
setup: () => setupComponent({}, "swapBiometrics"),
|
||||||
|
expectHidden: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
case: "PIN swap button when PIN is disabled",
|
||||||
|
setup: () => setupComponent({}),
|
||||||
|
expectHidden: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
case: "PIN swap button when PIN is undefined",
|
||||||
|
setup: () =>
|
||||||
|
setupComponent({
|
||||||
|
pin: { enabled: undefined as unknown as boolean },
|
||||||
|
biometrics: {
|
||||||
|
enabled: undefined as unknown as boolean,
|
||||||
|
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
expectHidden: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(hiddenButtonTests)("doesn't render $case", ({ setup, expectHidden }) => {
|
||||||
|
const { secondaryButton } = setup();
|
||||||
|
expect(!!secondaryButton).toBe(!expectHidden);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("password input", () => {
|
||||||
|
let setup: ReturnType<typeof setupComponent>;
|
||||||
|
beforeEach(() => {
|
||||||
|
setup = setupComponent();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should bind form input to masterPassword form control", async () => {
|
||||||
|
const input = setup.masterPasswordInput;
|
||||||
|
expect(input).toBeTruthy();
|
||||||
|
expect(input.nativeElement).toBeInstanceOf(HTMLInputElement);
|
||||||
|
expect(component.formGroup).toBeTruthy();
|
||||||
|
const masterPasswordControl = component.formGroup!.get("masterPassword");
|
||||||
|
expect(masterPasswordControl).toBeTruthy();
|
||||||
|
|
||||||
|
masterPasswordControl!.setValue("test-password");
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const inputElement = input.nativeElement as HTMLInputElement;
|
||||||
|
expect(inputElement.value).toEqual("test-password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate required master password field", async () => {
|
||||||
|
const formGroup = component.formGroup;
|
||||||
|
|
||||||
|
// Initially form should be invalid (empty required field)
|
||||||
|
expect(formGroup?.invalid).toEqual(true);
|
||||||
|
expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(true);
|
||||||
|
|
||||||
|
// Set a value
|
||||||
|
formGroup?.get("masterPassword")?.setValue("test-password");
|
||||||
|
|
||||||
|
expect(formGroup?.invalid).toEqual(false);
|
||||||
|
expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should toggle password visibility when toggle button is clicked", async () => {
|
||||||
|
const toggleButton = setup.toggleButton;
|
||||||
|
expect(toggleButton).toBeTruthy();
|
||||||
|
expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement);
|
||||||
|
const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement;
|
||||||
|
const input = setup.masterPasswordInput;
|
||||||
|
expect(input).toBeTruthy();
|
||||||
|
expect(input.nativeElement).toBeInstanceOf(HTMLInputElement);
|
||||||
|
const inputElement = input.nativeElement as HTMLInputElement;
|
||||||
|
|
||||||
|
// Initially password should be hidden
|
||||||
|
expect(inputElement.type).toEqual("password");
|
||||||
|
|
||||||
|
// Click toggle button
|
||||||
|
toggleButtonElement.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(inputElement.type).toEqual("text");
|
||||||
|
|
||||||
|
// Click toggle button again
|
||||||
|
toggleButtonElement.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(inputElement.type).toEqual("password");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("logout", () => {
|
||||||
|
it("emits logOut event when logout button is clicked", () => {
|
||||||
|
const setup = setupComponent();
|
||||||
|
let logoutEmitted = false;
|
||||||
|
component.logOut.subscribe(() => {
|
||||||
|
logoutEmitted = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setup.logoutButton).toBeTruthy();
|
||||||
|
expect(setup.logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement);
|
||||||
|
const logoutButtonElement = setup.logoutButton.nativeElement as HTMLButtonElement;
|
||||||
|
|
||||||
|
// Click logout button
|
||||||
|
logoutButtonElement.click();
|
||||||
|
|
||||||
|
expect(logoutEmitted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("swap buttons", () => {
|
||||||
|
const swapButtonScenarios = [
|
||||||
|
{
|
||||||
|
name: "PIN swap button when PIN is enabled",
|
||||||
|
unlockOptions: {
|
||||||
|
pin: { enabled: true },
|
||||||
|
biometrics: {
|
||||||
|
enabled: false,
|
||||||
|
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedText: "unlockWithPin",
|
||||||
|
expectedUnlockOption: UnlockOption.Pin,
|
||||||
|
shouldShow: true,
|
||||||
|
shouldEnable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PIN swap button when PIN is disabled",
|
||||||
|
unlockOptions: {
|
||||||
|
pin: { enabled: false },
|
||||||
|
biometrics: {
|
||||||
|
enabled: false,
|
||||||
|
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedText: "unlockWithPin",
|
||||||
|
expectedUnlockOption: UnlockOption.Pin,
|
||||||
|
shouldShow: false,
|
||||||
|
shouldEnable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "biometrics swap button when biometrics status is available and enabled",
|
||||||
|
unlockOptions: {
|
||||||
|
pin: { enabled: false },
|
||||||
|
biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available },
|
||||||
|
},
|
||||||
|
expectedText: "swapBiometrics",
|
||||||
|
expectedUnlockOption: UnlockOption.Biometrics,
|
||||||
|
shouldShow: true,
|
||||||
|
shouldEnable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "biometrics swap button when biometrics status is available and disabled",
|
||||||
|
unlockOptions: {
|
||||||
|
pin: { enabled: false },
|
||||||
|
biometrics: { enabled: false, biometricsStatus: BiometricsStatus.Available },
|
||||||
|
},
|
||||||
|
expectedText: "swapBiometrics",
|
||||||
|
expectedUnlockOption: UnlockOption.Biometrics,
|
||||||
|
shouldShow: true,
|
||||||
|
shouldEnable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "biometrics swap button when biometrics biometrics status is unsupported and enabled",
|
||||||
|
unlockOptions: {
|
||||||
|
pin: { enabled: false },
|
||||||
|
biometrics: { enabled: true, biometricsStatus: BiometricsStatus.PlatformUnsupported },
|
||||||
|
},
|
||||||
|
expectedText: "swapBiometrics",
|
||||||
|
expectedUnlockOption: UnlockOption.Biometrics,
|
||||||
|
shouldShow: false,
|
||||||
|
shouldEnable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "biometrics swap button when biometrics status is unsupported and disabled",
|
||||||
|
unlockOptions: {
|
||||||
|
pin: { enabled: false },
|
||||||
|
biometrics: { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported },
|
||||||
|
},
|
||||||
|
expectedText: "swapBiometrics",
|
||||||
|
expectedUnlockOption: UnlockOption.Biometrics,
|
||||||
|
shouldShow: false,
|
||||||
|
shouldEnable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(swapButtonScenarios)(
|
||||||
|
"renders and handles $name",
|
||||||
|
({ unlockOptions, expectedText, expectedUnlockOption, shouldShow, shouldEnable }) => {
|
||||||
|
const { secondaryButton, component } = setupComponent(unlockOptions, expectedText);
|
||||||
|
|
||||||
|
if (shouldShow) {
|
||||||
|
expect(secondaryButton).toBeTruthy();
|
||||||
|
expect(secondaryButton.nativeElement.textContent?.trim()).toBe(expectedText);
|
||||||
|
|
||||||
|
if (shouldEnable) {
|
||||||
|
secondaryButton.nativeElement.click();
|
||||||
|
expect(component.activeUnlockOption()).toBe(expectedUnlockOption);
|
||||||
|
} else {
|
||||||
|
expect(secondaryButton.nativeElement.getAttribute("aria-disabled")).toBe("true");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expect(secondaryButton).toBeFalsy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("submit", () => {
|
||||||
|
test.each([null, undefined as unknown as string, ""])(
|
||||||
|
"won't unlock and show password invalid toast when master password is %s",
|
||||||
|
async (value) => {
|
||||||
|
component.formGroup.controls.masterPassword.setValue(value);
|
||||||
|
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "error",
|
||||||
|
title: i18nService.t("errorOccurred"),
|
||||||
|
message: i18nService.t("masterPasswordRequired"),
|
||||||
|
});
|
||||||
|
expect(masterPasswordUnlockService.unlockWithMasterPassword).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([null as unknown as Account, undefined as unknown as Account])(
|
||||||
|
"throws error when active account is %s",
|
||||||
|
async (value) => {
|
||||||
|
accountService.activeAccount$ = of(value);
|
||||||
|
component.formGroup.controls.masterPassword.setValue(mockMasterPassword);
|
||||||
|
|
||||||
|
await expect(component.submit()).rejects.toThrow("Null or undefined account");
|
||||||
|
|
||||||
|
expect(masterPasswordUnlockService.unlockWithMasterPassword).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("shows an error toast and logs the error when unlock with master password fails", async () => {
|
||||||
|
const customError = new Error("Specialized error message");
|
||||||
|
masterPasswordUnlockService.unlockWithMasterPassword.mockRejectedValue(customError);
|
||||||
|
accountService.activeAccount$ = of(activeAccount);
|
||||||
|
component.formGroup.controls.masterPassword.setValue(mockMasterPassword);
|
||||||
|
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith(
|
||||||
|
mockMasterPassword,
|
||||||
|
activeAccount.id,
|
||||||
|
);
|
||||||
|
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "error",
|
||||||
|
title: i18nService.t("errorOccurred"),
|
||||||
|
message: i18nService.t("invalidMasterPassword"),
|
||||||
|
});
|
||||||
|
expect(logService.error).toHaveBeenCalledWith(
|
||||||
|
"[MasterPasswordLockComponent] Failed to unlock via master password",
|
||||||
|
customError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits userKey when unlock is successful", async () => {
|
||||||
|
masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey);
|
||||||
|
accountService.activeAccount$ = of(activeAccount);
|
||||||
|
component.formGroup.controls.masterPassword.setValue(mockMasterPassword);
|
||||||
|
let emittedEvent: { userKey: UserKey; masterPassword: string } | undefined;
|
||||||
|
component.successfulUnlock.subscribe(
|
||||||
|
(event: { userKey: UserKey; masterPassword: string }) => {
|
||||||
|
emittedEvent = event;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
expect(emittedEvent?.userKey).toEqual(mockUserKey);
|
||||||
|
expect(emittedEvent?.masterPassword).toEqual(mockMasterPassword);
|
||||||
|
expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith(
|
||||||
|
mockMasterPassword,
|
||||||
|
activeAccount.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { Component, computed, inject, input, model, output } from "@angular/core";
|
||||||
|
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
|
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { UserKey } from "@bitwarden/common/types/key";
|
||||||
|
import {
|
||||||
|
AsyncActionsModule,
|
||||||
|
ButtonModule,
|
||||||
|
FormFieldModule,
|
||||||
|
IconButtonModule,
|
||||||
|
ToastService,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||||
|
import { LogService } from "@bitwarden/logging";
|
||||||
|
import { UserId } from "@bitwarden/user-core";
|
||||||
|
|
||||||
|
import {
|
||||||
|
UnlockOption,
|
||||||
|
UnlockOptions,
|
||||||
|
UnlockOptionValue,
|
||||||
|
} from "../../services/lock-component.service";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "bit-master-password-lock",
|
||||||
|
templateUrl: "master-password-lock.component.html",
|
||||||
|
imports: [
|
||||||
|
JslibModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
ButtonModule,
|
||||||
|
FormFieldModule,
|
||||||
|
AsyncActionsModule,
|
||||||
|
IconButtonModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MasterPasswordLockComponent {
|
||||||
|
private readonly accountService = inject(AccountService);
|
||||||
|
private readonly masterPasswordUnlockService = inject(MasterPasswordUnlockService);
|
||||||
|
private readonly i18nService = inject(I18nService);
|
||||||
|
private readonly toastService = inject(ToastService);
|
||||||
|
private readonly logService = inject(LogService);
|
||||||
|
UnlockOption = UnlockOption;
|
||||||
|
|
||||||
|
activeUnlockOption = model.required<UnlockOptionValue>();
|
||||||
|
|
||||||
|
unlockOptions = input.required<UnlockOptions>();
|
||||||
|
biometricUnlockBtnText = input.required<string>();
|
||||||
|
showPinSwap = computed(() => this.unlockOptions().pin.enabled ?? false);
|
||||||
|
biometricsAvailable = computed(() => this.unlockOptions().biometrics.enabled ?? false);
|
||||||
|
showBiometricsSwap = computed(() => {
|
||||||
|
const status = this.unlockOptions().biometrics.biometricsStatus;
|
||||||
|
return (
|
||||||
|
status !== BiometricsStatus.PlatformUnsupported &&
|
||||||
|
status !== BiometricsStatus.NotEnabledLocally
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>();
|
||||||
|
logOut = output<void>();
|
||||||
|
|
||||||
|
formGroup = new FormGroup({
|
||||||
|
masterPassword: new FormControl("", {
|
||||||
|
validators: [Validators.required],
|
||||||
|
updateOn: "submit",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
submit = async () => {
|
||||||
|
this.formGroup.markAllAsTouched();
|
||||||
|
const masterPassword = this.formGroup.controls.masterPassword.value;
|
||||||
|
if (this.formGroup.invalid || !masterPassword) {
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: this.i18nService.t("errorOccurred"),
|
||||||
|
message: this.i18nService.t("masterPasswordRequired"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||||
|
|
||||||
|
await this.unlockViaMasterPassword(masterPassword, activeUserId);
|
||||||
|
};
|
||||||
|
|
||||||
|
private async unlockViaMasterPassword(
|
||||||
|
masterPassword: string,
|
||||||
|
activeUserId: UserId,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userKey = await this.masterPasswordUnlockService.unlockWithMasterPassword(
|
||||||
|
masterPassword,
|
||||||
|
activeUserId,
|
||||||
|
);
|
||||||
|
this.successfulUnlock.emit({ userKey, masterPassword });
|
||||||
|
} catch (error) {
|
||||||
|
this.logService.error(
|
||||||
|
"[MasterPasswordLockComponent] Failed to unlock via master password",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: this.i18nService.t("errorOccurred"),
|
||||||
|
message: this.i18nService.t("invalidMasterPassword"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user