1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 23:33:31 +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:
Jordan Aasen
2024-10-23 10:30:25 -07:00
committed by GitHub
parent 74dabb97bf
commit 7b8aac229c
13 changed files with 560 additions and 442 deletions

View File

@@ -2,17 +2,15 @@ import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { from, map, switchMap, tap } from "rxjs";
import { map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
// eslint-disable-next-line no-restricted-imports
import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 {
BadgeModule,
@@ -28,10 +26,6 @@ import { HeaderModule } from "../../layouts/header/header.module";
import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module";
// eslint-disable-next-line no-restricted-imports
import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module";
// eslint-disable-next-line no-restricted-imports
import { cipherData } from "../reports/pages/reports-ciphers.mock";
import { userData } from "./password-health.mock";
@Component({
standalone: true,
@@ -47,24 +41,18 @@ import { userData } from "./password-health.mock";
HeaderModule,
TableModule,
],
providers: [PasswordHealthService],
})
export class PasswordHealthMembersComponent implements OnInit {
passwordStrengthMap = new Map<string, [string, BadgeVariant]>();
weakPasswordCiphers: CipherView[] = [];
passwordUseMap = new Map<string, number>();
exposedPasswordMap = new Map<string, number>();
dataSource = new TableDataSource<CipherView>();
totalMembersMap = new Map<string, number>();
reportCiphers: CipherView[] = [];
reportCipherIds: string[] = [];
organization: Organization;
dataSource = new TableDataSource<CipherView>();
loading = true;
@@ -73,7 +61,6 @@ export class PasswordHealthMembersComponent implements OnInit {
constructor(
protected cipherService: CipherService,
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
protected organizationService: OrganizationService,
protected auditService: AuditService,
protected i18nService: I18nService,
protected activatedRoute: ActivatedRoute,
@@ -83,151 +70,29 @@ export class PasswordHealthMembersComponent implements OnInit {
this.activatedRoute.paramMap
.pipe(
takeUntilDestroyed(this.destroyRef),
map((params) => params.get("organizationId")),
switchMap((organizationId) => {
return from(this.organizationService.get(organizationId));
map(async (params) => {
const organizationId = params.get("organizationId");
await this.setCiphers(organizationId);
}),
tap((organization) => {
this.organization = organization;
}),
switchMap(() => from(this.setCiphers())),
)
.subscribe();
// mock data - will be replaced with actual data
userData.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 setCiphers() {
// const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organization.id);
const allCiphers = cipherData;
allCiphers.forEach(async (cipher) => {
this.findWeakPassword(cipher);
this.findReusedPassword(cipher);
await this.findExposedPassword(cipher);
});
this.dataSource.data = this.reportCiphers;
this.loading = false;
}
protected checkForExistingCipher(ciph: CipherView) {
if (!this.reportCipherIds.includes(ciph.id)) {
this.reportCipherIds.push(ciph.id);
this.reportCiphers.push(ciph);
}
}
protected async findExposedPassword(cipher: CipherView) {
const { type, login, isDeleted, edit, viewPassword, id } = cipher;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
(!this.organization && !edit) ||
!viewPassword
) {
return;
}
const exposedCount = await this.auditService.passwordLeaked(login.password);
if (exposedCount > 0) {
this.exposedPasswordMap.set(id, exposedCount);
this.checkForExistingCipher(cipher);
}
}
protected findReusedPassword(cipher: CipherView) {
const { type, login, isDeleted, edit, viewPassword } = cipher;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
(!this.organization && !edit) ||
!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);
}
protected findWeakPassword(cipher: CipherView): void {
const { type, login, isDeleted, edit, viewPassword } = cipher;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
(!this.organization && !edit) ||
!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,
async setCiphers(organizationId: string) {
const passwordHealthService = new PasswordHealthService(
this.passwordStrengthService,
this.auditService,
this.cipherService,
organizationId,
);
if (score != null && score <= 2) {
this.passwordStrengthMap.set(cipher.id, this.scoreKey(score));
this.checkForExistingCipher(cipher);
}
}
await passwordHealthService.generateReport();
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"];
}
this.dataSource.data = passwordHealthService.reportCiphers;
this.exposedPasswordMap = passwordHealthService.exposedPasswordMap;
this.passwordStrengthMap = passwordHealthService.passwordStrengthMap;
this.passwordUseMap = passwordHealthService.passwordUseMap;
this.totalMembersMap = passwordHealthService.totalMembersMap;
this.loading = false;
}
}

View File

@@ -1,11 +1,11 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, convertToParamMap } from "@angular/router";
import { MockProxy, mock } from "jest-mock-extended";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -14,39 +14,30 @@ import { TableBodyDirective } from "@bitwarden/components/src/table/table.compon
import { LooseComponentsModule } from "../../shared";
import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module";
// eslint-disable-next-line no-restricted-imports
import { cipherData } from "../reports/pages/reports-ciphers.mock";
import { PasswordHealthComponent } from "./password-health.component";
describe("PasswordHealthComponent", () => {
let component: PasswordHealthComponent;
let fixture: ComponentFixture<PasswordHealthComponent>;
let passwordStrengthService: MockProxy<PasswordStrengthServiceAbstraction>;
let organizationService: MockProxy<OrganizationService>;
let cipherServiceMock: MockProxy<CipherService>;
let auditServiceMock: MockProxy<AuditService>;
const activeRouteParams = convertToParamMap({ organizationId: "orgId" });
beforeEach(async () => {
passwordStrengthService = mock<PasswordStrengthServiceAbstraction>();
auditServiceMock = mock<AuditService>();
organizationService = mock<OrganizationService>({
get: jest.fn().mockResolvedValue({ id: "orgId" } as Organization),
});
cipherServiceMock = mock<CipherService>({
getAllFromApiForOrganization: jest.fn().mockResolvedValue(cipherData),
});
await TestBed.configureTestingModule({
imports: [PasswordHealthComponent, PipesModule, TableModule, LooseComponentsModule],
declarations: [TableBodyDirective],
providers: [
{ provide: CipherService, useValue: cipherServiceMock },
{ provide: PasswordStrengthServiceAbstraction, useValue: passwordStrengthService },
{ provide: OrganizationService, useValue: organizationService },
{ provide: CipherService, useValue: mock<CipherService>() },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: AuditService, useValue: auditServiceMock },
{ provide: AuditService, useValue: mock<AuditService>() },
{
provide: PasswordStrengthServiceAbstraction,
useValue: mock<PasswordStrengthServiceAbstraction>(),
},
{
provide: PasswordHealthService,
useValue: mock<PasswordHealthService>(),
},
{
provide: ActivatedRoute,
useValue: {
@@ -69,46 +60,5 @@ describe("PasswordHealthComponent", () => {
expect(component).toBeTruthy();
});
it("should populate reportCiphers with ciphers that have password issues", async () => {
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 1 } as any);
auditServiceMock.passwordLeaked.mockResolvedValue(5);
await component.setCiphers();
const cipherIds = component.reportCiphers.map((c) => c.id);
expect(cipherIds).toEqual([
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
]);
expect(component.reportCiphers.length).toEqual(3);
});
it("should correctly populate passwordStrengthMap", async () => {
passwordStrengthService.getPasswordStrength.mockImplementation((password) => {
let score = 0;
if (password === "123") {
score = 1;
} else {
score = 4;
}
return { score } as any;
});
auditServiceMock.passwordLeaked.mockResolvedValue(0);
await component.setCiphers();
expect(component.passwordStrengthMap.size).toBeGreaterThan(0);
expect(component.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toEqual([
"veryWeak",
"danger",
]);
expect(component.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toEqual([
"veryWeak",
"danger",
]);
});
it("should call generateReport on init", () => {});
});

View File

@@ -2,17 +2,15 @@ import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { from, map, switchMap, tap } from "rxjs";
import { map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
// eslint-disable-next-line no-restricted-imports
import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 {
BadgeModule,
@@ -43,23 +41,17 @@ import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module";
HeaderModule,
TableModule,
],
providers: [PasswordHealthService],
})
export class PasswordHealthComponent implements OnInit {
passwordStrengthMap = new Map<string, [string, BadgeVariant]>();
weakPasswordCiphers: CipherView[] = [];
passwordUseMap = new Map<string, number>();
exposedPasswordMap = new Map<string, number>();
dataSource = new TableDataSource<CipherView>();
reportCiphers: CipherView[] = [];
reportCipherIds: string[] = [];
organization: Organization;
loading = true;
private destroyRef = inject(DestroyRef);
@@ -67,7 +59,6 @@ export class PasswordHealthComponent implements OnInit {
constructor(
protected cipherService: CipherService,
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
protected organizationService: OrganizationService,
protected auditService: AuditService,
protected i18nService: I18nService,
protected activatedRoute: ActivatedRoute,
@@ -77,153 +68,28 @@ export class PasswordHealthComponent implements OnInit {
this.activatedRoute.paramMap
.pipe(
takeUntilDestroyed(this.destroyRef),
map((params) => params.get("organizationId")),
switchMap((organizationId) => {
return from(this.organizationService.get(organizationId));
map(async (params) => {
const organizationId = params.get("organizationId");
await this.setCiphers(organizationId);
}),
tap((organization) => {
this.organization = organization;
}),
switchMap(() => from(this.setCiphers())),
)
.subscribe();
}
async setCiphers() {
const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organization.id);
allCiphers.forEach(async (cipher) => {
this.findWeakPassword(cipher);
this.findReusedPassword(cipher);
await this.findExposedPassword(cipher);
});
this.dataSource.data = this.reportCiphers;
this.loading = false;
// const reportIssues = allCiphers.map((c) => {
// if (this.passwordStrengthMap.has(c.id)) {
// return c;
// }
// if (this.passwordUseMap.has(c.id)) {
// return c;
// }
// if (this.exposedPasswordMap.has(c.id)) {
// return c;
// }
// });
}
protected checkForExistingCipher(ciph: CipherView) {
if (!this.reportCipherIds.includes(ciph.id)) {
this.reportCipherIds.push(ciph.id);
this.reportCiphers.push(ciph);
}
}
protected async findExposedPassword(cipher: CipherView) {
const { type, login, isDeleted, edit, viewPassword, id } = cipher;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
(!this.organization && !edit) ||
!viewPassword
) {
return;
}
const exposedCount = await this.auditService.passwordLeaked(login.password);
if (exposedCount > 0) {
this.exposedPasswordMap.set(id, exposedCount);
this.checkForExistingCipher(cipher);
}
}
protected findReusedPassword(cipher: CipherView) {
const { type, login, isDeleted, edit, viewPassword } = cipher;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
(!this.organization && !edit) ||
!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);
}
protected findWeakPassword(cipher: CipherView): void {
const { type, login, isDeleted, edit, viewPassword } = cipher;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
(!this.organization && !edit) ||
!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,
async setCiphers(organizationId: string) {
const passwordHealthService = new PasswordHealthService(
this.passwordStrengthService,
this.auditService,
this.cipherService,
organizationId,
);
if (score != null && score <= 2) {
this.passwordStrengthMap.set(cipher.id, this.scoreKey(score));
this.checkForExistingCipher(cipher);
}
}
await passwordHealthService.generateReport();
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"];
}
this.dataSource.data = passwordHealthService.reportCiphers;
this.exposedPasswordMap = passwordHealthService.exposedPasswordMap;
this.passwordStrengthMap = passwordHealthService.passwordStrengthMap;
this.passwordUseMap = passwordHealthService.passwordUseMap;
this.loading = false;
}
}

View File

@@ -1,66 +0,0 @@
export const userData: any[] = [
{
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",
],
},
];

View File

@@ -10,6 +10,7 @@
"@bitwarden/auth/common": ["../../libs/auth/src/common"],
"@bitwarden/auth/angular": ["../../libs/auth/src/angular"],
"@bitwarden/billing": ["../../libs/billing/src"],
"@bitwarden/bit-common/*": ["../../bitwarden_license/bit-common/src/*"],
"@bitwarden/common/*": ["../../libs/common/src/*"],
"@bitwarden/components": ["../../libs/components/src"],
"@bitwarden/generator-components": ["../../libs/tools/generator/components/src"],