mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 15:53:27 +00:00
[PM-13456] - Password health service (#11658)
* add password health service * add spec. fix logic in password reuse * move service to bitwarden_license * revert change to tsconfig * fix spec * fix import
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
const { pathsToModuleNameMapper } = require("ts-jest");
|
||||
|
||||
const { compilerOptions } = require("./tsconfig");
|
||||
|
||||
const sharedConfig = require("../../libs/shared/jest.config.angular");
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
...sharedConfig,
|
||||
displayName: "bit-common tests",
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "jsdom",
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||
prefix: "<rootDir>/",
|
||||
}),
|
||||
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
|
||||
transformIgnorePatterns: ["node_modules/(?!(.*\\.mjs$|@angular|rxjs|@bitwarden))"],
|
||||
moduleFileExtensions: ["ts", "js", "html", "mjs"],
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./services";
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
export const mockCiphers: any[] = [
|
||||
{
|
||||
initializerKey: 1,
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Cannot Be Edited",
|
||||
notes: null,
|
||||
isDeleted: false,
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: false,
|
||||
login: {
|
||||
password: "123",
|
||||
},
|
||||
edit: false,
|
||||
viewPassword: true,
|
||||
collectionIds: [],
|
||||
revisionDate: "2023-08-03T17:40:59.793Z",
|
||||
creationDate: "2023-08-03T17:40:59.793Z",
|
||||
deletedDate: null,
|
||||
reprompt: 0,
|
||||
localData: null,
|
||||
},
|
||||
{
|
||||
initializerKey: 1,
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Can Be Edited id ending 2",
|
||||
notes: null,
|
||||
isDeleted: false,
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: false,
|
||||
login: {
|
||||
password: "123",
|
||||
hasUris: true,
|
||||
uris: [
|
||||
{
|
||||
uri: "http://nothing.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
collectionIds: [],
|
||||
revisionDate: "2023-08-03T17:40:59.793Z",
|
||||
creationDate: "2023-08-03T17:40:59.793Z",
|
||||
deletedDate: null,
|
||||
reprompt: 0,
|
||||
localData: null,
|
||||
},
|
||||
{
|
||||
initializerKey: 1,
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Can Be Edited id ending 3",
|
||||
notes: null,
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: false,
|
||||
login: {
|
||||
password: "123",
|
||||
hasUris: true,
|
||||
uris: [
|
||||
{
|
||||
uri: "http://example.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
collectionIds: [],
|
||||
revisionDate: "2023-08-03T17:40:59.793Z",
|
||||
creationDate: "2023-08-03T17:40:59.793Z",
|
||||
deletedDate: null,
|
||||
reprompt: 0,
|
||||
localData: null,
|
||||
},
|
||||
{
|
||||
initializerKey: 1,
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Can Be Edited id ending 4",
|
||||
notes: null,
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: false,
|
||||
login: {
|
||||
hasUris: true,
|
||||
uris: [{ uri: "101domain.com" }],
|
||||
},
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
collectionIds: [],
|
||||
revisionDate: "2023-08-03T17:40:59.793Z",
|
||||
creationDate: "2023-08-03T17:40:59.793Z",
|
||||
deletedDate: null,
|
||||
reprompt: 0,
|
||||
localData: null,
|
||||
},
|
||||
{
|
||||
initializerKey: 1,
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Can Be Edited id ending 5",
|
||||
notes: null,
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: false,
|
||||
login: {
|
||||
hasUris: true,
|
||||
uris: [{ uri: "123formbuilder.com" }],
|
||||
},
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
collectionIds: [],
|
||||
revisionDate: "2023-08-03T17:40:59.793Z",
|
||||
creationDate: "2023-08-03T17:40:59.793Z",
|
||||
deletedDate: null,
|
||||
reprompt: 0,
|
||||
localData: null,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./member-cipher-details-api.service";
|
||||
export * from "./password-health.service";
|
||||
@@ -0,0 +1,68 @@
|
||||
export const mockMemberCipherDetailsResponse: { data: any[] } = {
|
||||
data: [
|
||||
{
|
||||
UserName: "David Brent",
|
||||
Email: "david.brent@wernhamhogg.uk",
|
||||
UsesKeyConnector: true,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
],
|
||||
},
|
||||
{
|
||||
UserName: "Tim Canterbury",
|
||||
Email: "tim.canterbury@wernhamhogg.uk",
|
||||
UsesKeyConnector: false,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
],
|
||||
},
|
||||
{
|
||||
UserName: "Gareth Keenan",
|
||||
Email: "gareth.keenan@wernhamhogg.uk",
|
||||
UsesKeyConnector: true,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm7",
|
||||
],
|
||||
},
|
||||
{
|
||||
UserName: "Dawn Tinsley",
|
||||
Email: "dawn.tinsley@wernhamhogg.uk",
|
||||
UsesKeyConnector: true,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
],
|
||||
},
|
||||
{
|
||||
UserName: "Keith Bishop",
|
||||
Email: "keith.bishop@wernhamhogg.uk",
|
||||
UsesKeyConnector: false,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
],
|
||||
},
|
||||
{
|
||||
UserName: "Chris Finch",
|
||||
Email: "chris.finch@wernhamhogg.uk",
|
||||
UsesKeyConnector: true,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { mockCiphers } from "./ciphers.mock";
|
||||
import { PasswordHealthService } from "./password-health.service";
|
||||
|
||||
describe("PasswordHealthService", () => {
|
||||
let service: PasswordHealthService;
|
||||
let cipherService: CipherService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PasswordHealthService,
|
||||
{
|
||||
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(CipherData),
|
||||
},
|
||||
},
|
||||
{ provide: "organizationId", useValue: "org1" },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(PasswordHealthService);
|
||||
cipherService = TestBed.inject(CipherService);
|
||||
});
|
||||
|
||||
it("should be created", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should initialize properties", () => {
|
||||
expect(service.reportCiphers).toEqual([]);
|
||||
expect(service.reportCipherIds).toEqual([]);
|
||||
expect(service.passwordStrengthMap.size).toBe(0);
|
||||
expect(service.passwordUseMap.size).toBe(0);
|
||||
expect(service.exposedPasswordMap.size).toBe(0);
|
||||
expect(service.totalMembersMap.size).toBe(0);
|
||||
});
|
||||
|
||||
describe("generateReport", () => {
|
||||
beforeEach(async () => {
|
||||
await service.generateReport();
|
||||
});
|
||||
|
||||
it("should fetch all ciphers for the organization", () => {
|
||||
expect(cipherService.getAllFromApiForOrganization).toHaveBeenCalledWith("org1");
|
||||
});
|
||||
|
||||
it("should populate reportCiphers with ciphers that have issues", () => {
|
||||
expect(service.reportCiphers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should detect weak passwords", () => {
|
||||
expect(service.passwordStrengthMap.size).toBeGreaterThan(0);
|
||||
expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toEqual([
|
||||
"veryWeak",
|
||||
"danger",
|
||||
]);
|
||||
expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toEqual([
|
||||
"veryWeak",
|
||||
"danger",
|
||||
]);
|
||||
expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toEqual([
|
||||
"veryWeak",
|
||||
"danger",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should detect reused passwords", () => {
|
||||
expect(service.passwordUseMap.get("123")).toBe(3);
|
||||
});
|
||||
|
||||
it("should detect exposed passwords", () => {
|
||||
expect(service.exposedPasswordMap.size).toBeGreaterThan(0);
|
||||
expect(service.exposedPasswordMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toBe(100);
|
||||
});
|
||||
|
||||
it("should calculate total members per cipher", () => {
|
||||
expect(service.totalMembersMap.size).toBeGreaterThan(0);
|
||||
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toBe(2);
|
||||
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toBe(4);
|
||||
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toBe(5);
|
||||
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001227nm5")).toBe(4);
|
||||
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001227nm7")).toBe(1);
|
||||
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228xy4")).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findWeakPassword", () => {
|
||||
it("should add weak passwords to passwordStrengthMap", () => {
|
||||
const weakCipher = mockCiphers.find((c) => c.login?.password === "123") as CipherView;
|
||||
service.findWeakPassword(weakCipher);
|
||||
expect(service.passwordStrengthMap.get(weakCipher.id)).toEqual(["veryWeak", "danger"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findReusedPassword", () => {
|
||||
it("should detect password reuse", () => {
|
||||
mockCiphers.forEach((cipher) => {
|
||||
service.findReusedPassword(cipher as CipherView);
|
||||
});
|
||||
const reuseCounts = Array.from(service.passwordUseMap.values()).filter((count) => count > 1);
|
||||
expect(reuseCounts.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findExposedPassword", () => {
|
||||
it("should add exposed passwords to exposedPasswordMap", async () => {
|
||||
const exposedCipher = mockCiphers.find((c) => c.login?.password === "123") as CipherView;
|
||||
await service.findExposedPassword(exposedCipher);
|
||||
expect(service.exposedPasswordMap.get(exposedCipher.id)).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Inject, Injectable } from "@angular/core";
|
||||
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
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 { BadgeVariant } from "@bitwarden/components";
|
||||
|
||||
import { mockCiphers } from "./ciphers.mock";
|
||||
import { mockMemberCipherDetailsResponse } from "./member-cipher-details-response.mock";
|
||||
|
||||
@Injectable()
|
||||
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(
|
||||
private passwordStrengthService: PasswordStrengthServiceAbstraction,
|
||||
private auditService: AuditService,
|
||||
private cipherService: CipherService,
|
||||
@Inject("organizationId") private organizationId: string,
|
||||
) {}
|
||||
|
||||
async generateReport() {
|
||||
let allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organizationId);
|
||||
// TODO remove when actual user member data is available
|
||||
allCiphers = mockCiphers;
|
||||
allCiphers.forEach(async (cipher) => {
|
||||
this.findWeakPassword(cipher);
|
||||
this.findReusedPassword(cipher);
|
||||
await this.findExposedPassword(cipher);
|
||||
});
|
||||
|
||||
// TODO - fetch actual user member when data is available
|
||||
mockMemberCipherDetailsResponse.data.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;
|
||||
if (
|
||||
type !== CipherType.Login ||
|
||||
login.password == null ||
|
||||
login.password === "" ||
|
||||
isDeleted ||
|
||||
!viewPassword
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exposedCount = await this.auditService.passwordLeaked(login.password);
|
||||
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()
|
||||
.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(
|
||||
login.password,
|
||||
null,
|
||||
userInput.length > 0 ? userInput : null,
|
||||
);
|
||||
|
||||
if (score != null && score <= 2) {
|
||||
this.passwordStrengthMap.set(cipher.id, this.scoreKey(score));
|
||||
this.checkForExistingCipher(cipher);
|
||||
}
|
||||
}
|
||||
|
||||
private isUserNameNotEmpty(c: CipherView): boolean {
|
||||
return !Utils.isNullOrWhitespace(c.login.username);
|
||||
}
|
||||
|
||||
private scoreKey(score: number): [string, BadgeVariant] {
|
||||
switch (score) {
|
||||
case 4:
|
||||
return ["strong", "success"];
|
||||
case 3:
|
||||
return ["good", "primary"];
|
||||
case 2:
|
||||
return ["weak", "warning"];
|
||||
default:
|
||||
return ["veryWeak", "danger"];
|
||||
}
|
||||
}
|
||||
|
||||
private checkForExistingCipher(ciph: CipherView) {
|
||||
if (!this.reportCipherIds.includes(ciph.id)) {
|
||||
this.reportCipherIds.push(ciph.id);
|
||||
this.reportCiphers.push(ciph);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
bitwarden_license/bit-common/test.setup.ts
Normal file
1
bitwarden_license/bit-common/test.setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "jest-preset-angular/setup-jest";
|
||||
Reference in New Issue
Block a user