mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 15:53:27 +00:00
[PM-14419] At-risk passwords change password service (#13279)
* [PM-14419] Introduce the change-login-password service and its default implementation * [PM-14419] Use the change login password service on the at-risk passwords page * [PM-14419] Add unit tests * [PM-14419] Use existing fixed test environment * [PM-14419] Add mock implementation for ChangeLoginPasswordService in at-risk passwords tests * [PM-14419] Linter
This commit is contained in:
10
libs/vault/src/abstractions/change-login-password.service.ts
Normal file
10
libs/vault/src/abstractions/change-login-password.service.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
export abstract class ChangeLoginPasswordService {
|
||||
/**
|
||||
* Attempts to find a well-known change password URL for the given cipher. Only works for Login ciphers with at
|
||||
* least one http/https URL. If no well-known change password URL is found, the first URL is returned.
|
||||
* Non-Login ciphers and Logins with no valid http/https URLs return null.
|
||||
*/
|
||||
abstract getChangePasswordUrl(cipher: CipherView): Promise<string | null>;
|
||||
}
|
||||
@@ -25,3 +25,6 @@ export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.compon
|
||||
export * as VaultIcons from "./icons";
|
||||
|
||||
export * from "./tasks";
|
||||
|
||||
export * from "./abstractions/change-login-password.service";
|
||||
export * from "./services/default-change-login-password.service";
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Jest needs to run in custom environment to mock Request/Response objects
|
||||
* @jest-environment ../../libs/shared/test.environment.ts
|
||||
*/
|
||||
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
|
||||
import { DefaultChangeLoginPasswordService } from "./default-change-login-password.service";
|
||||
|
||||
describe("DefaultChangeLoginPasswordService", () => {
|
||||
let service: DefaultChangeLoginPasswordService;
|
||||
|
||||
let mockShouldNotExistResponse: Response;
|
||||
let mockWellKnownResponse: Response;
|
||||
|
||||
const mockApiService = mock<ApiService>();
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiService.nativeFetch.mockClear();
|
||||
|
||||
// Default responses to success state
|
||||
mockShouldNotExistResponse = new Response("Not Found", { status: 404 });
|
||||
mockWellKnownResponse = new Response("OK", { status: 200 });
|
||||
|
||||
mockApiService.nativeFetch.mockImplementation((request) => {
|
||||
if (
|
||||
request.url.endsWith("resource-that-should-not-exist-whose-status-code-should-not-be-200")
|
||||
) {
|
||||
return Promise.resolve(mockShouldNotExistResponse);
|
||||
}
|
||||
|
||||
if (request.url.endsWith(".well-known/change-password")) {
|
||||
return Promise.resolve(mockWellKnownResponse);
|
||||
}
|
||||
|
||||
throw new Error("Unexpected request");
|
||||
});
|
||||
service = new DefaultChangeLoginPasswordService(mockApiService);
|
||||
});
|
||||
|
||||
it("should return null for non-login ciphers", async () => {
|
||||
const cipher = {
|
||||
type: CipherType.Card,
|
||||
} as CipherView;
|
||||
|
||||
const url = await service.getChangePasswordUrl(cipher);
|
||||
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for logins with no URIs", async () => {
|
||||
const cipher = {
|
||||
type: CipherType.Login,
|
||||
login: Object.assign(new LoginView(), { uris: [] as LoginUriView[] }),
|
||||
} as CipherView;
|
||||
|
||||
const url = await service.getChangePasswordUrl(cipher);
|
||||
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for logins with no valid HTTP/HTTPS URIs", async () => {
|
||||
const cipher = {
|
||||
type: CipherType.Login,
|
||||
login: Object.assign(new LoginView(), {
|
||||
uris: [{ uri: "ftp://example.com" }],
|
||||
}),
|
||||
} as CipherView;
|
||||
|
||||
const url = await service.getChangePasswordUrl(cipher);
|
||||
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it("should check the origin for a reliable status code", async () => {
|
||||
const cipher = {
|
||||
type: CipherType.Login,
|
||||
login: Object.assign(new LoginView(), {
|
||||
uris: [{ uri: "https://example.com" }],
|
||||
}),
|
||||
} as CipherView;
|
||||
|
||||
await service.getChangePasswordUrl(cipher);
|
||||
|
||||
expect(mockApiService.nativeFetch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://example.com/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should attempt to fetch the well-known change password URL", async () => {
|
||||
const cipher = {
|
||||
type: CipherType.Login,
|
||||
login: Object.assign(new LoginView(), {
|
||||
uris: [{ uri: "https://example.com" }],
|
||||
}),
|
||||
} as CipherView;
|
||||
|
||||
await service.getChangePasswordUrl(cipher);
|
||||
|
||||
expect(mockApiService.nativeFetch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://example.com/.well-known/change-password",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return the well-known change password URL when successful at verifying the response", async () => {
|
||||
const cipher = {
|
||||
type: CipherType.Login,
|
||||
login: Object.assign(new LoginView(), {
|
||||
uris: [{ uri: "https://example.com" }],
|
||||
}),
|
||||
} as CipherView;
|
||||
|
||||
const url = await service.getChangePasswordUrl(cipher);
|
||||
|
||||
expect(url).toBe("https://example.com/.well-known/change-password");
|
||||
});
|
||||
|
||||
it("should return the original URI when unable to verify the response", async () => {
|
||||
mockShouldNotExistResponse = new Response("Ok", { status: 200 });
|
||||
|
||||
const cipher = {
|
||||
type: CipherType.Login,
|
||||
login: Object.assign(new LoginView(), {
|
||||
uris: [{ uri: "https://example.com" }],
|
||||
}),
|
||||
} as CipherView;
|
||||
|
||||
const url = await service.getChangePasswordUrl(cipher);
|
||||
|
||||
expect(url).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("should return the original URI when the well-known URL is not found", async () => {
|
||||
mockWellKnownResponse = new Response("Not Found", { status: 404 });
|
||||
|
||||
const cipher = {
|
||||
type: CipherType.Login,
|
||||
login: Object.assign(new LoginView(), {
|
||||
uris: [{ uri: "https://example.com" }],
|
||||
}),
|
||||
} as CipherView;
|
||||
|
||||
const url = await service.getChangePasswordUrl(cipher);
|
||||
|
||||
expect(url).toBe("https://example.com");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { ChangeLoginPasswordService } from "../abstractions/change-login-password.service";
|
||||
|
||||
@Injectable()
|
||||
export class DefaultChangeLoginPasswordService implements ChangeLoginPasswordService {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
async getChangePasswordUrl(cipher: CipherView): Promise<string | null> {
|
||||
// Ensure we have a cipher with at least one URI
|
||||
if (cipher.type !== CipherType.Login || cipher.login == null || !cipher.login.hasUris) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the first valid URL that is an HTTP or HTTPS URL
|
||||
const url = cipher.login.uris
|
||||
.map((m) => Utils.getUrl(m.uri))
|
||||
.find((m) => m != null && (m.protocol === "http:" || m.protocol === "https:"));
|
||||
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [reliable, wellKnownChangeUrl] = await Promise.all([
|
||||
this.hasReliableHttpStatusCode(url.origin),
|
||||
this.getWellKnownChangePasswordUrl(url.origin),
|
||||
]);
|
||||
|
||||
if (!reliable || wellKnownChangeUrl == null) {
|
||||
return cipher.login.uri;
|
||||
}
|
||||
|
||||
return wellKnownChangeUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the server returns a non-200 status code for a resource that should not exist.
|
||||
* See https://w3c.github.io/webappsec-change-password-url/response-code-reliability.html#semantics
|
||||
* @param urlOrigin The origin of the URL to check
|
||||
*/
|
||||
private async hasReliableHttpStatusCode(urlOrigin: string): Promise<boolean> {
|
||||
try {
|
||||
const url = new URL(
|
||||
"./.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200",
|
||||
urlOrigin,
|
||||
);
|
||||
|
||||
const request = new Request(url, {
|
||||
method: "GET",
|
||||
mode: "same-origin",
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
const response = await this.apiService.nativeFetch(request);
|
||||
return !response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a well-known change password URL for the given origin. Attempts to fetch the URL to ensure a valid response
|
||||
* is returned. Returns null if the request throws or the response is not 200 OK.
|
||||
* See https://w3c.github.io/webappsec-change-password-url/
|
||||
* @param urlOrigin The origin of the URL to check
|
||||
*/
|
||||
private async getWellKnownChangePasswordUrl(urlOrigin: string): Promise<string | null> {
|
||||
try {
|
||||
const url = new URL("./.well-known/change-password", urlOrigin);
|
||||
|
||||
const request = new Request(url, {
|
||||
method: "GET",
|
||||
mode: "same-origin",
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
const response = await this.apiService.nativeFetch(request);
|
||||
|
||||
return response.ok ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user