mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 22:03:36 +00:00
[PM-25608] PasswordHealthService cleanup (#16471)
* Update password health service and test cases * Fix linting errors
This commit is contained in:
@@ -1,65 +1,106 @@
|
|||||||
import { TestBed } from "@angular/core/testing";
|
import { mock } from "jest-mock-extended";
|
||||||
|
import { ZXCVBNResult } from "zxcvbn";
|
||||||
|
|
||||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
|
||||||
import { mockCiphers } from "./ciphers.mock";
|
|
||||||
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
|
|
||||||
import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec";
|
|
||||||
import { PasswordHealthService } from "./password-health.service";
|
import { PasswordHealthService } from "./password-health.service";
|
||||||
|
|
||||||
// FIXME: Remove password-health report service after PR-15498 completion
|
|
||||||
describe("PasswordHealthService", () => {
|
describe("PasswordHealthService", () => {
|
||||||
let service: PasswordHealthService;
|
let service: PasswordHealthService;
|
||||||
|
|
||||||
|
// Mock services
|
||||||
|
const passwordStrengthService = mock<PasswordStrengthServiceAbstraction>();
|
||||||
|
const auditService = mock<AuditService>();
|
||||||
|
|
||||||
|
// Mock data
|
||||||
|
let mockValidCipher: CipherView;
|
||||||
|
let mockInvalidCipher: CipherView;
|
||||||
|
let mockExposedCiphers: CipherView[];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({
|
// Setup mock service implementations
|
||||||
providers: [
|
passwordStrengthService.getPasswordStrength.mockImplementation((password: string) => {
|
||||||
PasswordHealthService,
|
return { score: password === "weak" ? 1 : 4 } as ZXCVBNResult;
|
||||||
{
|
});
|
||||||
provide: PasswordStrengthServiceAbstraction,
|
auditService.passwordLeaked.mockImplementation((password: string) =>
|
||||||
useValue: {
|
Promise.resolve(password === "leaked" ? 2 : 0),
|
||||||
getPasswordStrength: (password: string) => {
|
);
|
||||||
const score = password.length < 4 ? 1 : 4;
|
service = new PasswordHealthService(passwordStrengthService, auditService);
|
||||||
return { score };
|
|
||||||
},
|
// Setup mock data
|
||||||
},
|
mockValidCipher = mock<CipherView>({
|
||||||
},
|
type: CipherType.Login,
|
||||||
{
|
login: { password: "weak", username: "user" },
|
||||||
provide: AuditService,
|
isDeleted: false,
|
||||||
useValue: {
|
viewPassword: true,
|
||||||
passwordLeaked: (password: string) => Promise.resolve(password === "123" ? 100 : 0),
|
});
|
||||||
},
|
mockInvalidCipher = mock<CipherView>({
|
||||||
},
|
type: CipherType.Card,
|
||||||
{
|
login: { password: "" },
|
||||||
provide: CipherService,
|
isDeleted: true,
|
||||||
useValue: {
|
viewPassword: false,
|
||||||
getAllFromApiForOrganization: jest.fn().mockResolvedValue(mockCiphers),
|
});
|
||||||
},
|
mockExposedCiphers = [
|
||||||
},
|
mock<CipherView>({
|
||||||
{
|
id: "cipher-id-1",
|
||||||
provide: MemberCipherDetailsApiService,
|
type: CipherType.Login,
|
||||||
useValue: {
|
login: { password: "leaked", username: "user" },
|
||||||
getMemberCipherDetails: jest.fn().mockResolvedValue(mockMemberCipherDetails),
|
isDeleted: false,
|
||||||
},
|
viewPassword: true,
|
||||||
},
|
}),
|
||||||
{ provide: "organizationId", useValue: "org1" },
|
mock<CipherView>({
|
||||||
],
|
id: "cipher-id-2",
|
||||||
|
type: CipherType.Login,
|
||||||
|
login: { password: "safe", username: "user" },
|
||||||
|
isDeleted: false,
|
||||||
|
viewPassword: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
service = TestBed.inject(PasswordHealthService);
|
it("should extract username parts", () => {
|
||||||
|
expect(service.extractUsernameParts("john.doe@example.com")).toEqual(["john", "doe"]);
|
||||||
|
expect(service.extractUsernameParts("a@b.com")).toEqual(["a"]);
|
||||||
|
expect(service.extractUsernameParts("user_name")).toEqual(["user", "name"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be created", () => {
|
it("should identify weak passwords", () => {
|
||||||
expect(service).toBeTruthy();
|
const result = service.findWeakPasswordDetails(mockValidCipher);
|
||||||
|
expect(result).toEqual({
|
||||||
|
score: 1,
|
||||||
|
detailValue: { label: "veryWeak", badgeVariant: "danger" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should initialize properties", () => {
|
it("should return null for invalid cipher in findWeakPasswordDetails", () => {
|
||||||
expect(service.reportCiphers).toEqual([]);
|
const cipher = { type: CipherType.Card } as any;
|
||||||
expect(service.reportCipherIds).toEqual([]);
|
expect(service.findWeakPasswordDetails(cipher)).toBeNull();
|
||||||
expect(service.passwordStrengthMap.size).toBe(0);
|
});
|
||||||
expect(service.passwordUseMap.size).toBe(0);
|
|
||||||
expect(service.exposedPasswordMap.size).toBe(0);
|
it("should get password score info", () => {
|
||||||
expect(service.totalMembersMap.size).toBe(0);
|
expect(service.getPasswordScoreInfo(4)).toEqual({ label: "strong", badgeVariant: "success" });
|
||||||
|
expect(service.getPasswordScoreInfo(2)).toEqual({ label: "weak", badgeVariant: "warning" });
|
||||||
|
expect(service.getPasswordScoreInfo(0)).toEqual({ label: "veryWeak", badgeVariant: "danger" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check if username is not empty", () => {
|
||||||
|
expect(service.isUserNameNotEmpty({ login: { username: "user" } } as any)).toBe(true);
|
||||||
|
expect(service.isUserNameNotEmpty({ login: { username: "" } } as any)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate cipher correctly", () => {
|
||||||
|
expect(service.isValidCipher(mockValidCipher)).toBe(true);
|
||||||
|
|
||||||
|
expect(service.isValidCipher(mockInvalidCipher)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should audit password leaks", (done) => {
|
||||||
|
service.auditPasswordLeaks$(mockExposedCiphers).subscribe((result) => {
|
||||||
|
expect(result).toEqual([{ exposedXTimes: 2, cipherId: "cipher-id-1" }]);
|
||||||
|
done();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,186 +1,136 @@
|
|||||||
// FIXME: Update this file to be type safe and remove this and next line
|
import { filter, from, map, mergeMap, Observable, toArray } from "rxjs";
|
||||||
// @ts-strict-ignore
|
|
||||||
import { Inject, Injectable } from "@angular/core";
|
|
||||||
|
|
||||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { BadgeVariant } from "@bitwarden/components";
|
|
||||||
|
|
||||||
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
|
import {
|
||||||
|
ExposedPasswordDetail,
|
||||||
|
WeakPasswordDetail,
|
||||||
|
WeakPasswordScore,
|
||||||
|
} from "../models/password-health";
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PasswordHealthService {
|
export class PasswordHealthService {
|
||||||
reportCiphers: CipherView[] = [];
|
|
||||||
|
|
||||||
reportCipherIds: string[] = [];
|
|
||||||
|
|
||||||
passwordStrengthMap = new Map<string, [string, BadgeVariant]>();
|
|
||||||
|
|
||||||
passwordUseMap = new Map<string, number>();
|
|
||||||
|
|
||||||
exposedPasswordMap = new Map<string, number>();
|
|
||||||
|
|
||||||
totalMembersMap = new Map<string, number>();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private passwordStrengthService: PasswordStrengthServiceAbstraction,
|
private passwordStrengthService: PasswordStrengthServiceAbstraction,
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
private cipherService: CipherService,
|
|
||||||
private memberCipherDetailsApiService: MemberCipherDetailsApiService,
|
|
||||||
@Inject("organizationId") private organizationId: string,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateReport() {
|
/**
|
||||||
const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organizationId);
|
* Finds exposed passwords in a list of ciphers.
|
||||||
allCiphers.forEach(async (cipher) => {
|
*
|
||||||
this.findWeakPassword(cipher);
|
* @param ciphers The list of ciphers to check.
|
||||||
this.findReusedPassword(cipher);
|
* @returns An observable that emits an array of ExposedPasswordDetail.
|
||||||
await this.findExposedPassword(cipher);
|
*/
|
||||||
});
|
auditPasswordLeaks$(ciphers: CipherView[]): Observable<ExposedPasswordDetail[]> {
|
||||||
|
return from(ciphers).pipe(
|
||||||
const memberCipherDetails = await this.memberCipherDetailsApiService.getMemberCipherDetails(
|
filter((cipher) => this.isValidCipher(cipher)),
|
||||||
this.organizationId,
|
mergeMap((cipher) =>
|
||||||
|
this.auditService
|
||||||
|
.passwordLeaked(cipher.login.password)
|
||||||
|
.then((exposedCount) => ({ cipher, exposedCount })),
|
||||||
|
),
|
||||||
|
filter(({ exposedCount }) => exposedCount > 0),
|
||||||
|
map(({ cipher, exposedCount }) => ({
|
||||||
|
exposedXTimes: exposedCount,
|
||||||
|
cipherId: cipher.id,
|
||||||
|
})),
|
||||||
|
toArray(),
|
||||||
);
|
);
|
||||||
|
|
||||||
memberCipherDetails.forEach((user) => {
|
|
||||||
user.cipherIds.forEach((cipherId: string) => {
|
|
||||||
if (this.totalMembersMap.has(cipherId)) {
|
|
||||||
this.totalMembersMap.set(cipherId, (this.totalMembersMap.get(cipherId) || 0) + 1);
|
|
||||||
} else {
|
|
||||||
this.totalMembersMap.set(cipherId, 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findExposedPassword(cipher: CipherView) {
|
/**
|
||||||
const { type, login, isDeleted, viewPassword, id } = cipher;
|
* Extracts username parts from the cipher's username.
|
||||||
if (
|
* This is used to help determine password strength.
|
||||||
type !== CipherType.Login ||
|
*
|
||||||
login.password == null ||
|
* @param cipherUsername The username from the cipher.
|
||||||
login.password === "" ||
|
* @returns An array of username parts.
|
||||||
isDeleted ||
|
*/
|
||||||
!viewPassword
|
extractUsernameParts(cipherUsername: string) {
|
||||||
) {
|
const atPosition = cipherUsername.indexOf("@");
|
||||||
return;
|
const userNameToProcess =
|
||||||
}
|
atPosition > -1 ? cipherUsername.substring(0, atPosition) : cipherUsername;
|
||||||
|
|
||||||
const exposedCount = await this.auditService.passwordLeaked(login.password);
|
return userNameToProcess
|
||||||
if (exposedCount > 0) {
|
|
||||||
this.exposedPasswordMap.set(id, exposedCount);
|
|
||||||
this.checkForExistingCipher(cipher);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
findReusedPassword(cipher: CipherView) {
|
|
||||||
const { type, login, isDeleted, viewPassword } = cipher;
|
|
||||||
if (
|
|
||||||
type !== CipherType.Login ||
|
|
||||||
login.password == null ||
|
|
||||||
login.password === "" ||
|
|
||||||
isDeleted ||
|
|
||||||
!viewPassword
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.passwordUseMap.has(login.password)) {
|
|
||||||
this.passwordUseMap.set(login.password, (this.passwordUseMap.get(login.password) || 0) + 1);
|
|
||||||
} else {
|
|
||||||
this.passwordUseMap.set(login.password, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.checkForExistingCipher(cipher);
|
|
||||||
}
|
|
||||||
|
|
||||||
findWeakPassword(cipher: CipherView): void {
|
|
||||||
const { type, login, isDeleted, viewPassword } = cipher;
|
|
||||||
if (
|
|
||||||
type !== CipherType.Login ||
|
|
||||||
login.password == null ||
|
|
||||||
login.password === "" ||
|
|
||||||
isDeleted ||
|
|
||||||
!viewPassword
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUserName = this.isUserNameNotEmpty(cipher);
|
|
||||||
let userInput: string[] = [];
|
|
||||||
if (hasUserName) {
|
|
||||||
const atPosition = login.username.indexOf("@");
|
|
||||||
if (atPosition > -1) {
|
|
||||||
userInput = userInput
|
|
||||||
.concat(
|
|
||||||
login.username
|
|
||||||
.substring(0, atPosition)
|
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split(/[^A-Za-z0-9]/),
|
.split(/[^A-Za-z0-9]/);
|
||||||
)
|
|
||||||
.filter((i) => i.length >= 3);
|
|
||||||
} else {
|
|
||||||
userInput = login.username
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/[^A-Za-z0-9]/)
|
|
||||||
.filter((i) => i.length >= 3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the cipher has a weak password based on the password strength score.
|
||||||
|
*
|
||||||
|
* @param cipher
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
findWeakPasswordDetails(cipher: CipherView): WeakPasswordDetail | null {
|
||||||
|
// Validate the cipher
|
||||||
|
if (!this.isValidCipher(cipher)) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check the username
|
||||||
|
const userInput = this.isUserNameNotEmpty(cipher)
|
||||||
|
? this.extractUsernameParts(cipher.login.username)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const { score } = this.passwordStrengthService.getPasswordStrength(
|
const { score } = this.passwordStrengthService.getPasswordStrength(
|
||||||
login.password,
|
cipher.login.password,
|
||||||
null,
|
undefined, // No email available in this context
|
||||||
userInput.length > 0 ? userInput : null,
|
userInput,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If a score is not found or a score is less than 3, it's weak
|
||||||
if (score != null && score <= 2) {
|
if (score != null && score <= 2) {
|
||||||
this.passwordStrengthMap.set(cipher.id, this.scoreKey(score));
|
return { score: score, detailValue: this.getPasswordScoreInfo(score) };
|
||||||
this.checkForExistingCipher(cipher);
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the password score information based on the score.
|
||||||
|
*
|
||||||
|
* @param score
|
||||||
|
* @returns An object containing the label and badge variant for the password score.
|
||||||
|
*/
|
||||||
|
getPasswordScoreInfo(score: number): WeakPasswordScore {
|
||||||
|
switch (score) {
|
||||||
|
case 4:
|
||||||
|
return { label: "strong", badgeVariant: "success" };
|
||||||
|
case 3:
|
||||||
|
return { label: "good", badgeVariant: "primary" };
|
||||||
|
case 2:
|
||||||
|
return { label: "weak", badgeVariant: "warning" };
|
||||||
|
default:
|
||||||
|
return { label: "veryWeak", badgeVariant: "danger" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private isUserNameNotEmpty(c: CipherView): boolean {
|
/**
|
||||||
|
* Checks if the username on the cipher is not empty.
|
||||||
|
*/
|
||||||
|
isUserNameNotEmpty(c: CipherView): boolean {
|
||||||
return !Utils.isNullOrWhitespace(c.login.username);
|
return !Utils.isNullOrWhitespace(c.login.username);
|
||||||
}
|
}
|
||||||
|
|
||||||
private scoreKey(score: number): [string, BadgeVariant] {
|
/**
|
||||||
switch (score) {
|
* Validates that the cipher is a login item, has a password
|
||||||
case 4:
|
* is not deleted, and the user can view the password
|
||||||
return ["strong", "success"];
|
* @param c the input cipher
|
||||||
case 3:
|
*/
|
||||||
return ["good", "primary"];
|
isValidCipher(c: CipherView): boolean {
|
||||||
case 2:
|
const { type, login, isDeleted, viewPassword } = c;
|
||||||
return ["weak", "warning"];
|
if (
|
||||||
default:
|
type !== CipherType.Login ||
|
||||||
return ["veryWeak", "danger"];
|
login.password == null ||
|
||||||
}
|
login.password === "" ||
|
||||||
}
|
isDeleted ||
|
||||||
|
!viewPassword
|
||||||
checkForExistingCipher(ciph: CipherView) {
|
) {
|
||||||
if (!this.reportCipherIds.includes(ciph.id)) {
|
return false;
|
||||||
this.reportCipherIds.push(ciph.id);
|
}
|
||||||
this.reportCiphers.push(ciph);
|
return true;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
groupCiphersByLoginUri(): CipherView[] {
|
|
||||||
const cipherViews: CipherView[] = [];
|
|
||||||
const cipherUris: string[] = [];
|
|
||||||
const ciphers = this.reportCiphers;
|
|
||||||
|
|
||||||
ciphers.forEach((ciph) => {
|
|
||||||
const uris = ciph.login?.uris ?? [];
|
|
||||||
uris.map((u: { uri: string }) => {
|
|
||||||
const uri = Utils.getHostname(u.uri).replace("www.", "");
|
|
||||||
cipherUris.push(uri);
|
|
||||||
cipherViews.push({ ...ciph, hostURI: uri } as CipherView & { hostURI: string });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return cipherViews;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user