1
0
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:
Leslie Tilton
2025-09-18 13:42:10 -05:00
committed by GitHub
parent 42ec956782
commit 20c8a1ff25
2 changed files with 189 additions and 198 deletions

View File

@@ -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,
useValue: {
getPasswordStrength: (password: string) => {
const score = password.length < 4 ? 1 : 4;
return { score };
},
},
},
{
provide: AuditService,
useValue: {
passwordLeaked: (password: string) => Promise.resolve(password === "123" ? 100 : 0),
},
},
{
provide: CipherService,
useValue: {
getAllFromApiForOrganization: jest.fn().mockResolvedValue(mockCiphers),
},
},
{
provide: MemberCipherDetailsApiService,
useValue: {
getMemberCipherDetails: jest.fn().mockResolvedValue(mockMemberCipherDetails),
},
},
{ provide: "organizationId", useValue: "org1" },
],
}); });
auditService.passwordLeaked.mockImplementation((password: string) =>
Promise.resolve(password === "leaked" ? 2 : 0),
);
service = new PasswordHealthService(passwordStrengthService, auditService);
service = TestBed.inject(PasswordHealthService); // Setup mock data
mockValidCipher = mock<CipherView>({
type: CipherType.Login,
login: { password: "weak", username: "user" },
isDeleted: false,
viewPassword: true,
});
mockInvalidCipher = mock<CipherView>({
type: CipherType.Card,
login: { password: "" },
isDeleted: true,
viewPassword: false,
});
mockExposedCiphers = [
mock<CipherView>({
id: "cipher-id-1",
type: CipherType.Login,
login: { password: "leaked", username: "user" },
isDeleted: false,
viewPassword: true,
}),
mock<CipherView>({
id: "cipher-id-2",
type: CipherType.Login,
login: { password: "safe", username: "user" },
isDeleted: false,
viewPassword: true,
}),
];
}); });
it("should be created", () => { it("should extract username parts", () => {
expect(service).toBeTruthy(); 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 initialize properties", () => { it("should identify weak passwords", () => {
expect(service.reportCiphers).toEqual([]); const result = service.findWeakPasswordDetails(mockValidCipher);
expect(service.reportCipherIds).toEqual([]); expect(result).toEqual({
expect(service.passwordStrengthMap.size).toBe(0); score: 1,
expect(service.passwordUseMap.size).toBe(0); detailValue: { label: "veryWeak", badgeVariant: "danger" },
expect(service.exposedPasswordMap.size).toBe(0); });
expect(service.totalMembersMap.size).toBe(0); });
it("should return null for invalid cipher in findWeakPasswordDetails", () => {
const cipher = { type: CipherType.Card } as any;
expect(service.findWeakPasswordDetails(cipher)).toBeNull();
});
it("should get password score info", () => {
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();
});
}); });
}); });

View File

@@ -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) { .trim()
this.exposedPasswordMap.set(id, exposedCount); .toLowerCase()
this.checkForExistingCipher(cipher); .split(/[^A-Za-z0-9]/);
}
} }
findReusedPassword(cipher: CipherView) { /**
const { type, login, isDeleted, viewPassword } = cipher; * Checks if the cipher has a weak password based on the password strength score.
if ( *
type !== CipherType.Login || * @param cipher
login.password == null || * @returns
login.password === "" || */
isDeleted || findWeakPasswordDetails(cipher: CipherView): WeakPasswordDetail | null {
!viewPassword // Validate the cipher
) { if (!this.isValidCipher(cipher)) {
return; return null;
} }
if (this.passwordUseMap.has(login.password)) { // Check the username
this.passwordUseMap.set(login.password, (this.passwordUseMap.get(login.password) || 0) + 1); const userInput = this.isUserNameNotEmpty(cipher)
} else { ? this.extractUsernameParts(cipher.login.username)
this.passwordUseMap.set(login.password, 1); : undefined;
}
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()
.toLowerCase()
.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);
}
}
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
) {
return false;
} }
} return true;
checkForExistingCipher(ciph: CipherView) {
if (!this.reportCipherIds.includes(ciph.id)) {
this.reportCipherIds.push(ciph.id);
this.reportCiphers.push(ciph);
}
}
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;
} }
} }