mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 05:43:41 +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:
@@ -2,17 +2,15 @@ import { CommonModule } from "@angular/common";
|
|||||||
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { ActivatedRoute } from "@angular/router";
|
import { ActivatedRoute } from "@angular/router";
|
||||||
import { from, map, switchMap, tap } from "rxjs";
|
import { map } from "rxjs";
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
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 { 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 { 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 { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import {
|
import {
|
||||||
BadgeModule,
|
BadgeModule,
|
||||||
@@ -28,10 +26,6 @@ import { HeaderModule } from "../../layouts/header/header.module";
|
|||||||
import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module";
|
import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module";
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module";
|
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({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -47,24 +41,18 @@ import { userData } from "./password-health.mock";
|
|||||||
HeaderModule,
|
HeaderModule,
|
||||||
TableModule,
|
TableModule,
|
||||||
],
|
],
|
||||||
|
providers: [PasswordHealthService],
|
||||||
})
|
})
|
||||||
export class PasswordHealthMembersComponent implements OnInit {
|
export class PasswordHealthMembersComponent implements OnInit {
|
||||||
passwordStrengthMap = new Map<string, [string, BadgeVariant]>();
|
passwordStrengthMap = new Map<string, [string, BadgeVariant]>();
|
||||||
|
|
||||||
weakPasswordCiphers: CipherView[] = [];
|
|
||||||
|
|
||||||
passwordUseMap = new Map<string, number>();
|
passwordUseMap = new Map<string, number>();
|
||||||
|
|
||||||
exposedPasswordMap = new Map<string, number>();
|
exposedPasswordMap = new Map<string, number>();
|
||||||
|
|
||||||
dataSource = new TableDataSource<CipherView>();
|
|
||||||
|
|
||||||
totalMembersMap = new Map<string, number>();
|
totalMembersMap = new Map<string, number>();
|
||||||
|
|
||||||
reportCiphers: CipherView[] = [];
|
dataSource = new TableDataSource<CipherView>();
|
||||||
reportCipherIds: string[] = [];
|
|
||||||
|
|
||||||
organization: Organization;
|
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
||||||
@@ -73,7 +61,6 @@ export class PasswordHealthMembersComponent implements OnInit {
|
|||||||
constructor(
|
constructor(
|
||||||
protected cipherService: CipherService,
|
protected cipherService: CipherService,
|
||||||
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
|
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
|
||||||
protected organizationService: OrganizationService,
|
|
||||||
protected auditService: AuditService,
|
protected auditService: AuditService,
|
||||||
protected i18nService: I18nService,
|
protected i18nService: I18nService,
|
||||||
protected activatedRoute: ActivatedRoute,
|
protected activatedRoute: ActivatedRoute,
|
||||||
@@ -83,151 +70,29 @@ export class PasswordHealthMembersComponent implements OnInit {
|
|||||||
this.activatedRoute.paramMap
|
this.activatedRoute.paramMap
|
||||||
.pipe(
|
.pipe(
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
map((params) => params.get("organizationId")),
|
map(async (params) => {
|
||||||
switchMap((organizationId) => {
|
const organizationId = params.get("organizationId");
|
||||||
return from(this.organizationService.get(organizationId));
|
await this.setCiphers(organizationId);
|
||||||
}),
|
}),
|
||||||
tap((organization) => {
|
|
||||||
this.organization = organization;
|
|
||||||
}),
|
|
||||||
switchMap(() => from(this.setCiphers())),
|
|
||||||
)
|
)
|
||||||
.subscribe();
|
.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() {
|
async setCiphers(organizationId: string) {
|
||||||
// const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organization.id);
|
const passwordHealthService = new PasswordHealthService(
|
||||||
const allCiphers = cipherData;
|
this.passwordStrengthService,
|
||||||
allCiphers.forEach(async (cipher) => {
|
this.auditService,
|
||||||
this.findWeakPassword(cipher);
|
this.cipherService,
|
||||||
this.findReusedPassword(cipher);
|
organizationId,
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (score != null && score <= 2) {
|
await passwordHealthService.generateReport();
|
||||||
this.passwordStrengthMap.set(cipher.id, this.scoreKey(score));
|
|
||||||
this.checkForExistingCipher(cipher);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private isUserNameNotEmpty(c: CipherView): boolean {
|
this.dataSource.data = passwordHealthService.reportCiphers;
|
||||||
return !Utils.isNullOrWhitespace(c.login.username);
|
this.exposedPasswordMap = passwordHealthService.exposedPasswordMap;
|
||||||
}
|
this.passwordStrengthMap = passwordHealthService.passwordStrengthMap;
|
||||||
|
this.passwordUseMap = passwordHealthService.passwordUseMap;
|
||||||
private scoreKey(score: number): [string, BadgeVariant] {
|
this.totalMembersMap = passwordHealthService.totalMembersMap;
|
||||||
switch (score) {
|
this.loading = false;
|
||||||
case 4:
|
|
||||||
return ["strong", "success"];
|
|
||||||
case 3:
|
|
||||||
return ["good", "primary"];
|
|
||||||
case 2:
|
|
||||||
return ["weak", "warning"];
|
|
||||||
default:
|
|
||||||
return ["veryWeak", "danger"];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
import { ActivatedRoute, convertToParamMap } from "@angular/router";
|
import { ActivatedRoute, convertToParamMap } from "@angular/router";
|
||||||
import { MockProxy, mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
import { of } from "rxjs";
|
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 { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { 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 { LooseComponentsModule } from "../../shared";
|
||||||
import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module";
|
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";
|
import { PasswordHealthComponent } from "./password-health.component";
|
||||||
|
|
||||||
describe("PasswordHealthComponent", () => {
|
describe("PasswordHealthComponent", () => {
|
||||||
let component: PasswordHealthComponent;
|
let component: PasswordHealthComponent;
|
||||||
let fixture: ComponentFixture<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" });
|
const activeRouteParams = convertToParamMap({ organizationId: "orgId" });
|
||||||
|
|
||||||
beforeEach(async () => {
|
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({
|
await TestBed.configureTestingModule({
|
||||||
imports: [PasswordHealthComponent, PipesModule, TableModule, LooseComponentsModule],
|
imports: [PasswordHealthComponent, PipesModule, TableModule, LooseComponentsModule],
|
||||||
declarations: [TableBodyDirective],
|
declarations: [TableBodyDirective],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: CipherService, useValue: cipherServiceMock },
|
{ provide: CipherService, useValue: mock<CipherService>() },
|
||||||
{ provide: PasswordStrengthServiceAbstraction, useValue: passwordStrengthService },
|
|
||||||
{ provide: OrganizationService, useValue: organizationService },
|
|
||||||
{ provide: I18nService, useValue: mock<I18nService>() },
|
{ 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,
|
provide: ActivatedRoute,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -69,46 +60,5 @@ describe("PasswordHealthComponent", () => {
|
|||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should populate reportCiphers with ciphers that have password issues", async () => {
|
it("should call generateReport on init", () => {});
|
||||||
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",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,17 +2,15 @@ import { CommonModule } from "@angular/common";
|
|||||||
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { ActivatedRoute } from "@angular/router";
|
import { ActivatedRoute } from "@angular/router";
|
||||||
import { from, map, switchMap, tap } from "rxjs";
|
import { map } from "rxjs";
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
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 { 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 { 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 { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import {
|
import {
|
||||||
BadgeModule,
|
BadgeModule,
|
||||||
@@ -43,23 +41,17 @@ import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module";
|
|||||||
HeaderModule,
|
HeaderModule,
|
||||||
TableModule,
|
TableModule,
|
||||||
],
|
],
|
||||||
|
providers: [PasswordHealthService],
|
||||||
})
|
})
|
||||||
export class PasswordHealthComponent implements OnInit {
|
export class PasswordHealthComponent implements OnInit {
|
||||||
passwordStrengthMap = new Map<string, [string, BadgeVariant]>();
|
passwordStrengthMap = new Map<string, [string, BadgeVariant]>();
|
||||||
|
|
||||||
weakPasswordCiphers: CipherView[] = [];
|
|
||||||
|
|
||||||
passwordUseMap = new Map<string, number>();
|
passwordUseMap = new Map<string, number>();
|
||||||
|
|
||||||
exposedPasswordMap = new Map<string, number>();
|
exposedPasswordMap = new Map<string, number>();
|
||||||
|
|
||||||
dataSource = new TableDataSource<CipherView>();
|
dataSource = new TableDataSource<CipherView>();
|
||||||
|
|
||||||
reportCiphers: CipherView[] = [];
|
|
||||||
reportCipherIds: string[] = [];
|
|
||||||
|
|
||||||
organization: Organization;
|
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
@@ -67,7 +59,6 @@ export class PasswordHealthComponent implements OnInit {
|
|||||||
constructor(
|
constructor(
|
||||||
protected cipherService: CipherService,
|
protected cipherService: CipherService,
|
||||||
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
|
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
|
||||||
protected organizationService: OrganizationService,
|
|
||||||
protected auditService: AuditService,
|
protected auditService: AuditService,
|
||||||
protected i18nService: I18nService,
|
protected i18nService: I18nService,
|
||||||
protected activatedRoute: ActivatedRoute,
|
protected activatedRoute: ActivatedRoute,
|
||||||
@@ -77,153 +68,28 @@ export class PasswordHealthComponent implements OnInit {
|
|||||||
this.activatedRoute.paramMap
|
this.activatedRoute.paramMap
|
||||||
.pipe(
|
.pipe(
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
map((params) => params.get("organizationId")),
|
map(async (params) => {
|
||||||
switchMap((organizationId) => {
|
const organizationId = params.get("organizationId");
|
||||||
return from(this.organizationService.get(organizationId));
|
await this.setCiphers(organizationId);
|
||||||
}),
|
}),
|
||||||
tap((organization) => {
|
|
||||||
this.organization = organization;
|
|
||||||
}),
|
|
||||||
switchMap(() => from(this.setCiphers())),
|
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
async setCiphers() {
|
async setCiphers(organizationId: string) {
|
||||||
const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organization.id);
|
const passwordHealthService = new PasswordHealthService(
|
||||||
allCiphers.forEach(async (cipher) => {
|
this.passwordStrengthService,
|
||||||
this.findWeakPassword(cipher);
|
this.auditService,
|
||||||
this.findReusedPassword(cipher);
|
this.cipherService,
|
||||||
await this.findExposedPassword(cipher);
|
organizationId,
|
||||||
});
|
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (score != null && score <= 2) {
|
await passwordHealthService.generateReport();
|
||||||
this.passwordStrengthMap.set(cipher.id, this.scoreKey(score));
|
|
||||||
this.checkForExistingCipher(cipher);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private isUserNameNotEmpty(c: CipherView): boolean {
|
this.dataSource.data = passwordHealthService.reportCiphers;
|
||||||
return !Utils.isNullOrWhitespace(c.login.username);
|
this.exposedPasswordMap = passwordHealthService.exposedPasswordMap;
|
||||||
}
|
this.passwordStrengthMap = passwordHealthService.passwordStrengthMap;
|
||||||
|
this.passwordUseMap = passwordHealthService.passwordUseMap;
|
||||||
private scoreKey(score: number): [string, BadgeVariant] {
|
this.loading = false;
|
||||||
switch (score) {
|
|
||||||
case 4:
|
|
||||||
return ["strong", "success"];
|
|
||||||
case 3:
|
|
||||||
return ["good", "primary"];
|
|
||||||
case 2:
|
|
||||||
return ["weak", "warning"];
|
|
||||||
default:
|
|
||||||
return ["veryWeak", "danger"];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"@bitwarden/auth/common": ["../../libs/auth/src/common"],
|
"@bitwarden/auth/common": ["../../libs/auth/src/common"],
|
||||||
"@bitwarden/auth/angular": ["../../libs/auth/src/angular"],
|
"@bitwarden/auth/angular": ["../../libs/auth/src/angular"],
|
||||||
"@bitwarden/billing": ["../../libs/billing/src"],
|
"@bitwarden/billing": ["../../libs/billing/src"],
|
||||||
|
"@bitwarden/bit-common/*": ["../../bitwarden_license/bit-common/src/*"],
|
||||||
"@bitwarden/common/*": ["../../libs/common/src/*"],
|
"@bitwarden/common/*": ["../../libs/common/src/*"],
|
||||||
"@bitwarden/components": ["../../libs/components/src"],
|
"@bitwarden/components": ["../../libs/components/src"],
|
||||||
"@bitwarden/generator-components": ["../../libs/tools/generator/components/src"],
|
"@bitwarden/generator-components": ["../../libs/tools/generator/components/src"],
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
const { pathsToModuleNameMapper } = require("ts-jest");
|
const { pathsToModuleNameMapper } = require("ts-jest");
|
||||||
|
|
||||||
const { compilerOptions } = require("./tsconfig");
|
const { compilerOptions } = require("./tsconfig");
|
||||||
|
|
||||||
const sharedConfig = require("../../libs/shared/jest.config.angular");
|
const sharedConfig = require("../../libs/shared/jest.config.angular");
|
||||||
|
|
||||||
/** @type {import('jest').Config} */
|
/** @type {import('jest').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...sharedConfig,
|
...sharedConfig,
|
||||||
displayName: "bit-common tests",
|
displayName: "bit-common tests",
|
||||||
preset: "ts-jest",
|
|
||||||
testEnvironment: "jsdom",
|
testEnvironment: "jsdom",
|
||||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||||
prefix: "<rootDir>/",
|
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