[PM-30826] Remove password change from CLI (#19157)

* feat(cli-login) [PM-30826]: Remove change password methods from cli.

* feat(cli-login) [PM-30826]: Update login command to be strict, remove unused constructor dependencies.

* test(cli-login) [PM-30826]: Add a unit test harness for login command.

* refactor(cli-login) [PM-30826]: Undo strict ignore.

* feat(cli-login) [PM-30826]: Accidental line omission.

* feat(cli-login) [PM-30826]: Update verbiage for password update instructions.

* refactor(cli-login) [PM-30826]: Remove redundant logout calls in login command.

* test(cli-login) [PM-30826]: Update tests to reflect authService.logOut invocation is no longer needed.

* refactor(cli-login) [PM-30826]: Remove unused authService dependency (logout invocation removed).

* test(login-command) [PM-30826]: Update two-factor test with more realistic setup.
pull/18765/head
Dave 2 months ago committed by GitHub
parent 3c4d466f25
commit dc0d251290
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,707 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import {
LoginStrategyServiceAbstraction,
PasswordLoginCredentials,
SsoUrlService,
UserApiLoginCredentials,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import {
TwoFactorService,
TwoFactorApiService,
TwoFactorProviderDetails,
} from "@bitwarden/common/auth/two-factor";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { KeyConnectorDomainConfirmation } from "@bitwarden/common/key-management/key-connector/models/key-connector-domain-confirmation";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { NodeUtils } from "@bitwarden/node/node-utils";
import { ConfirmKeyConnectorDomainCommand } from "../../key-management/confirm-key-connector-domain.command";
import { Response } from "../../models/response";
import { LoginCommand } from "./login.command";
// Mock NodeUtils.readFirstLine for passwordfile tests
jest.mock("@bitwarden/node/node-utils", () => ({
NodeUtils: {
readFirstLine: jest.fn().mockResolvedValue("filepass"),
},
}));
// --- Helpers ---
// Commander passes null for omitted positional args at runtime, but the
// run() signature types them as string. This alias keeps call sites readable.
const NULL = null as unknown as string;
const TEST_USER_ID = "test-user-id" as UserId;
const MOCK_SESSION_KEY = new Uint8Array(64) as CsprngArray;
const B64_SESSION_KEY = Utils.fromBufferToB64(MOCK_SESSION_KEY);
function mockSuccessAuthResult(userId: UserId = TEST_USER_ID): AuthResult {
const result = new AuthResult();
result.userId = userId;
result.requiresEncryptionKeyMigration = false;
result.requiresDeviceVerification = false;
// twoFactorProviders defaults to null (no 2FA required)
// ssoOrganizationIdentifier defaults to undefined (no SSO required)
return result;
}
/** Build a 2FA-required AuthResult: twoFactorProviders must be non-null */
function mock2faAuthResult(): AuthResult {
const result = mockSuccessAuthResult();
result.twoFactorProviders = { [TwoFactorProviderType.Authenticator]: {} };
return result;
}
function makeProvider(type: TwoFactorProviderType, name: string): TwoFactorProviderDetails {
return { type, name } as TwoFactorProviderDetails;
}
// --- Test Suite ---
describe("LoginCommand", () => {
let command: LoginCommand;
let loginStrategyService: MockProxy<LoginStrategyServiceAbstraction>;
let authService: MockProxy<AuthService>;
let twoFactorApiService: MockProxy<TwoFactorApiService>;
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
let environmentService: MockProxy<EnvironmentService>;
let passwordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let accountService: MockProxy<AccountService>;
let twoFactorService: MockProxy<TwoFactorService>;
let syncService: MockProxy<SyncService>;
let keyConnectorService: MockProxy<KeyConnectorService>;
let logoutCallback: jest.Mock;
let ssoUrlService: MockProxy<SsoUrlService>;
let i18nService: MockProxy<I18nService>;
let masterPasswordService: MockProxy<MasterPasswordServiceAbstraction>;
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let encryptedMigrator: MockProxy<EncryptedMigrator>;
// Env var snapshot for save/restore
const savedEnv: Record<string, string | undefined> = {};
const ENV_KEYS = ["BW_NOINTERACTION", "BW_SESSION", "BW_CLIENTID", "BW_CLIENTSECRET", "MY_PW"];
beforeEach(() => {
// Save env vars
for (const key of ENV_KEYS) {
savedEnv[key] = process.env[key];
}
// Non-interactive by default to avoid inquirer prompts
process.env.BW_NOINTERACTION = "true";
delete process.env.BW_SESSION;
delete process.env.BW_CLIENTID;
delete process.env.BW_CLIENTSECRET;
delete process.env.MY_PW;
// Create mocks
loginStrategyService = mock<LoginStrategyServiceAbstraction>();
authService = mock<AuthService>();
twoFactorApiService = mock<TwoFactorApiService>();
cryptoFunctionService = mock<CryptoFunctionService>();
environmentService = mock<EnvironmentService>();
passwordGenerationService = mock<PasswordGenerationServiceAbstraction>();
platformUtilsService = mock<PlatformUtilsService>();
accountService = mock<AccountService>();
twoFactorService = mock<TwoFactorService>();
syncService = mock<SyncService>();
keyConnectorService = mock<KeyConnectorService>();
logoutCallback = jest.fn().mockResolvedValue(undefined);
ssoUrlService = mock<SsoUrlService>();
i18nService = mock<I18nService>();
masterPasswordService = mock<MasterPasswordServiceAbstraction>();
userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
encryptedMigrator = mock<EncryptedMigrator>();
// Default mock behaviors for a successful password login
i18nService.t.mockImplementation((key: string) => key);
cryptoFunctionService.randomBytes.mockResolvedValue(MOCK_SESSION_KEY);
loginStrategyService.logIn.mockResolvedValue(mockSuccessAuthResult());
keyConnectorService.requiresDomainConfirmation$.mockReturnValue(of(null));
masterPasswordService.forceSetPasswordReason$.mockReturnValue(of(ForceSetPasswordReason.None));
syncService.fullSync.mockResolvedValue(true);
keyConnectorService.getUsesKeyConnector.mockResolvedValue(false);
encryptedMigrator.runMigrations.mockResolvedValue(undefined);
accountService.activeAccount$ = of({ id: TEST_USER_ID } as any);
authService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
environmentService.environment$ = of({
getWebVaultUrl: () => "https://vault.bitwarden.com",
} as any);
command = new LoginCommand(
loginStrategyService,
authService,
twoFactorApiService,
cryptoFunctionService,
environmentService,
passwordGenerationService,
platformUtilsService,
accountService,
twoFactorService,
syncService,
keyConnectorService,
logoutCallback,
ssoUrlService,
i18nService,
masterPasswordService,
userDecryptionOptionsService,
encryptedMigrator,
);
});
afterEach(() => {
// Restore env vars
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
jest.restoreAllMocks();
});
// =========================================================================
// 1. Input Validation
// =========================================================================
describe("input validation", () => {
describe("API key flow", () => {
const apiKeyOptions = { apikey: true };
it("returns badRequest when client_id is null", async () => {
delete process.env.BW_CLIENTID;
process.env.BW_CLIENTSECRET = "secret";
const response = await command.run(NULL, NULL, apiKeyOptions);
expect(response.success).toBe(false);
expect(response.message).toBe("client_id is required.");
});
it("returns badRequest when client_id is empty", async () => {
process.env.BW_CLIENTID = "";
process.env.BW_CLIENTSECRET = "secret";
const response = await command.run(NULL, NULL, apiKeyOptions);
expect(response.success).toBe(false);
expect(response.message).toBe("client_id is required.");
});
it("returns badRequest when client_id is whitespace", async () => {
process.env.BW_CLIENTID = " ";
process.env.BW_CLIENTSECRET = "secret";
const response = await command.run(NULL, NULL, apiKeyOptions);
expect(response.success).toBe(false);
expect(response.message).toBe("client_id is required.");
});
it("returns badRequest when client_secret is null", async () => {
process.env.BW_CLIENTID = "user.xxx";
delete process.env.BW_CLIENTSECRET;
const response = await command.run(NULL, NULL, apiKeyOptions);
expect(response.success).toBe(false);
expect(response.message).toBe("client_secret is required.");
});
it("returns badRequest when client_secret is empty", async () => {
process.env.BW_CLIENTID = "user.xxx";
process.env.BW_CLIENTSECRET = "";
const response = await command.run(NULL, NULL, apiKeyOptions);
expect(response.success).toBe(false);
expect(response.message).toBe("client_secret is required.");
});
it("returns error when client_id does not start with 'user'", async () => {
process.env.BW_CLIENTID = "organization.xxx";
process.env.BW_CLIENTSECRET = "secret";
const response = await command.run(NULL, NULL, apiKeyOptions);
expect(response.success).toBe(false);
expect(response.message).toContain("Organization API Key");
});
});
describe("password flow", () => {
it("returns badRequest when email is null (non-interactive)", async () => {
const response = await command.run(NULL, "password", {});
expect(response.success).toBe(false);
expect(response.message).toBe("Email address is required.");
});
it("returns badRequest when email is empty (non-interactive)", async () => {
const response = await command.run("", "password", {});
expect(response.success).toBe(false);
expect(response.message).toBe("Email address is required.");
});
it("returns badRequest when email is whitespace (non-interactive)", async () => {
const response = await command.run(" ", "password", {});
expect(response.success).toBe(false);
expect(response.message).toBe("Email address is required.");
});
it("returns badRequest when email has no @", async () => {
const response = await command.run("bademail", "password", {});
expect(response.success).toBe(false);
expect(response.message).toBe("Email address is invalid.");
});
it("returns badRequest when password is null (non-interactive)", async () => {
const response = await command.run("a@b.c", NULL, {});
expect(response.success).toBe(false);
expect(response.message).toBe("Master password is required.");
});
it("returns badRequest when password is empty (non-interactive)", async () => {
const response = await command.run("a@b.c", "", {});
expect(response.success).toBe(false);
expect(response.message).toBe("Master password is required.");
});
it("reads password from passwordenv option", async () => {
process.env.MY_PW = "envpass";
const response = await command.run("a@b.c", NULL, { passwordenv: "MY_PW" });
expect(response.success).toBe(true);
const creds = loginStrategyService.logIn.mock.calls[0][0] as PasswordLoginCredentials;
expect(creds.email).toBe("a@b.c");
expect(creds.masterPassword).toBe("envpass");
});
it("reads password from passwordfile option", async () => {
const response = await command.run("a@b.c", NULL, { passwordfile: "/tmp/pw.txt" });
expect(response.success).toBe(true);
expect(NodeUtils.readFirstLine).toHaveBeenCalledWith("/tmp/pw.txt");
const creds = loginStrategyService.logIn.mock.calls[0][0] as PasswordLoginCredentials;
expect(creds.masterPassword).toBe("filepass");
});
it("passes null twoFactor when no code provided", async () => {
await command.run("a@b.c", "password", {});
expect(loginStrategyService.logIn).toHaveBeenCalledWith(
expect.any(PasswordLoginCredentials),
);
// PasswordLoginCredentials constructor receives twoFactor as 3rd arg.
// When no options.code, twoFactor is null.
const creds = loginStrategyService.logIn.mock.calls[0][0] as PasswordLoginCredentials;
expect(creds.twoFactor).toBeNull();
});
});
});
// =========================================================================
// 2. Login Strategy Dispatch
// =========================================================================
describe("login strategy dispatch", () => {
it("calls logIn with UserApiLoginCredentials for API key login", async () => {
process.env.BW_CLIENTID = "user.xxx";
process.env.BW_CLIENTSECRET = "secret";
await command.run(NULL, NULL, { apikey: true });
expect(loginStrategyService.logIn).toHaveBeenCalledWith(expect.any(UserApiLoginCredentials));
});
it("returns friendly error on invalid_client API response", async () => {
process.env.BW_CLIENTID = "user.xxx";
process.env.BW_CLIENTSECRET = "secret";
loginStrategyService.logIn.mockRejectedValue({
response: { error: "invalid_client" },
});
const response = await command.run(NULL, NULL, { apikey: true });
expect(response.success).toBe(false);
expect(response.message).toBe("client_id or client_secret is incorrect. Try again.");
});
it("rethrows non-invalid_client API errors to outer catch", async () => {
process.env.BW_CLIENTID = "user.xxx";
process.env.BW_CLIENTSECRET = "secret";
const error = new ErrorResponse({ Message: "Server error" } as any, 500);
loginStrategyService.logIn.mockRejectedValue(error);
const response = await command.run(NULL, NULL, { apikey: true });
expect(response.success).toBe(false);
expect(response.message).toBe("Server error");
});
});
// =========================================================================
// 3. Post-Auth Response Handling
// =========================================================================
describe("post-auth response handling", () => {
describe("requiresEncryptionKeyMigration", () => {
it("returns error when encryption key migration required", async () => {
const authResult = mockSuccessAuthResult();
authResult.requiresEncryptionKeyMigration = true;
loginStrategyService.logIn.mockResolvedValue(authResult);
const response = await command.run("a@b.c", "password", {});
expect(response.success).toBe(false);
expect(response.message).toBe("legacyEncryptionUnsupported");
});
});
describe("requiresTwoFactor", () => {
it("returns badRequest when no 2FA providers available", async () => {
loginStrategyService.logIn.mockResolvedValue(mock2faAuthResult());
twoFactorService.getSupportedProviders.mockResolvedValue([]);
const response = await command.run("a@b.c", "password", {});
expect(response.success).toBe(false);
expect(response.message).toBe("No providers available for this client.");
});
it("auto-selects single 2FA provider and calls logInTwoFactor", async () => {
const provider = makeProvider(TwoFactorProviderType.Authenticator, "Authenticator");
loginStrategyService.logIn.mockResolvedValue(mock2faAuthResult());
twoFactorService.getSupportedProviders.mockResolvedValue([provider]);
loginStrategyService.logInTwoFactor.mockResolvedValue(mockSuccessAuthResult());
const response = await command.run("a@b.c", "password", { code: "123456" });
expect(response.success).toBe(true);
expect(loginStrategyService.logInTwoFactor).toHaveBeenCalledWith(
expect.objectContaining({
provider: TwoFactorProviderType.Authenticator,
token: "123456",
}),
);
});
it("returns error when method filter yields no match (non-interactive)", async () => {
// Need 2+ providers so auto-select (length === 1) doesn't trigger
const providers = [
makeProvider(TwoFactorProviderType.Authenticator, "Authenticator"),
makeProvider(TwoFactorProviderType.Email, "Email"),
];
loginStrategyService.logIn.mockResolvedValue(mock2faAuthResult());
twoFactorService.getSupportedProviders.mockResolvedValue(providers);
// Yubikey (3) is valid but not offered by the server; non-interactive so no prompt fallback
const response = await command.run("a@b.c", "password", { method: "3", code: "123456" });
expect(response.success).toBe(false);
expect(response.message).toBe("Login failed. No provider selected.");
});
it("sends email 2FA verification when provider is Email and no code provided", async () => {
const provider = makeProvider(TwoFactorProviderType.Email, "Email");
loginStrategyService.logIn.mockResolvedValue(mock2faAuthResult());
twoFactorService.getSupportedProviders.mockResolvedValue([provider]);
loginStrategyService.getEmail.mockResolvedValue("a@b.c");
loginStrategyService.getMasterPasswordHash.mockResolvedValue("hash");
await command.run("a@b.c", "password", {});
expect(twoFactorApiService.postTwoFactorEmail).toHaveBeenCalled();
});
it("returns badRequest when 2FA code required but empty (non-interactive)", async () => {
const provider = makeProvider(TwoFactorProviderType.Authenticator, "Authenticator");
loginStrategyService.logIn.mockResolvedValue(mock2faAuthResult());
twoFactorService.getSupportedProviders.mockResolvedValue([provider]);
// No code, non-interactive
const response = await command.run("a@b.c", "password", {});
expect(response.success).toBe(false);
expect(response.message).toBe("Code is required.");
});
it("calls logInTwoFactor with provider type and token", async () => {
const provider = makeProvider(TwoFactorProviderType.Authenticator, "Authenticator");
loginStrategyService.logIn.mockResolvedValue(mock2faAuthResult());
twoFactorService.getSupportedProviders.mockResolvedValue([provider]);
loginStrategyService.logInTwoFactor.mockResolvedValue(mockSuccessAuthResult());
const response = await command.run("a@b.c", "password", {
code: "123456",
method: "0",
});
expect(response.success).toBe(true);
expect(loginStrategyService.logInTwoFactor).toHaveBeenCalledWith(
expect.any(TokenTwoFactorRequest),
);
const tfReq = loginStrategyService.logInTwoFactor.mock.calls[0][0] as TokenTwoFactorRequest;
expect(tfReq.provider).toBe(TwoFactorProviderType.Authenticator);
expect(tfReq.token).toBe("123456");
});
});
describe("requiresDeviceVerification", () => {
it("returns badRequest when device OTP empty (non-interactive)", async () => {
const authResult = mockSuccessAuthResult();
authResult.requiresDeviceVerification = true;
loginStrategyService.logIn.mockResolvedValue(authResult);
const response = await command.run("a@b.c", "password", {});
expect(response.success).toBe(false);
expect(response.message).toBe("Code is required.");
});
});
describe("second 2FA check", () => {
it("returns error when 2FA still required after logInTwoFactor", async () => {
const provider = makeProvider(TwoFactorProviderType.Authenticator, "Authenticator");
loginStrategyService.logIn.mockResolvedValue(mock2faAuthResult());
twoFactorService.getSupportedProviders.mockResolvedValue([provider]);
// logInTwoFactor returns another 2FA-required result
loginStrategyService.logInTwoFactor.mockResolvedValue(mock2faAuthResult());
const response = await command.run("a@b.c", "password", { code: "123456" });
expect(response.success).toBe(false);
expect(response.message).toBe("Login failed.");
});
});
});
// =========================================================================
// 4. Post-Login Flows
// =========================================================================
describe("post-login flows", () => {
describe("SSO MP validation", () => {
it("skips SSO MP validation for password login", async () => {
// Password login path: ssoCode and ssoCodeVerifier are null, so validation is skipped
await command.run("a@b.c", "password", {});
expect(userDecryptionOptionsService.userDecryptionOptionsById$).not.toHaveBeenCalled();
});
});
describe("key connector domain confirmation", () => {
it("skips domain confirmation when null", async () => {
keyConnectorService.requiresDomainConfirmation$.mockReturnValue(of(null));
const response = await command.run("a@b.c", "password", {});
expect(response.success).toBe(true);
});
it("succeeds when domain confirmation command succeeds", async () => {
keyConnectorService.requiresDomainConfirmation$.mockReturnValue(
of({
keyConnectorUrl: "https://kc.example.com",
organizationSsoIdentifier: "org-sso-id",
} as KeyConnectorDomainConfirmation),
);
const confirmSpy = jest
.spyOn(ConfirmKeyConnectorDomainCommand.prototype, "run")
.mockResolvedValue(Response.success());
const response = await command.run("a@b.c", "password", {});
expect(response.success).toBe(true);
expect(confirmSpy).toHaveBeenCalled();
});
it("returns error when domain confirmation command fails", async () => {
keyConnectorService.requiresDomainConfirmation$.mockReturnValue(
of({
keyConnectorUrl: "https://kc.example.com",
organizationSsoIdentifier: "org-sso-id",
} as KeyConnectorDomainConfirmation),
);
const confirmSpy = jest
.spyOn(ConfirmKeyConnectorDomainCommand.prototype, "run")
.mockResolvedValue(Response.error("denied"));
const response = await command.run("a@b.c", "password", {});
expect(response.success).toBe(false);
expect(response.message).toBe("denied");
expect(confirmSpy).toHaveBeenCalled();
});
});
describe("sync and force password", () => {
it("calls fullSync after successful login", async () => {
await command.run("a@b.c", "password", {});
expect(syncService.fullSync).toHaveBeenCalledWith(true, { skipTokenRefresh: true });
});
it("logs out and errors on AdminForcePasswordReset", async () => {
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
of(ForceSetPasswordReason.AdminForcePasswordReset),
);
const response = await command.run("a@b.c", "password", {});
expect(response.success).toBe(false);
expect(response.message).toContain("organization administrator");
expect(logoutCallback).toHaveBeenCalled();
});
it("logs out and errors on WeakMasterPassword", async () => {
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
of(ForceSetPasswordReason.WeakMasterPassword),
);
const response = await command.run("a@b.c", "password", {});
expect(response.success).toBe(false);
expect(response.message).toContain("organization policies");
expect(logoutCallback).toHaveBeenCalled();
});
it("skips force password check for API key login", async () => {
process.env.BW_CLIENTID = "user.xxx";
process.env.BW_CLIENTSECRET = "secret";
await command.run(NULL, NULL, { apikey: true });
// forceSetPasswordReason$ should NOT be subscribed for API key flow
expect(masterPasswordService.forceSetPasswordReason$).not.toHaveBeenCalled();
});
it("calls encryptedMigrator.runMigrations", async () => {
await command.run("a@b.c", "password", {});
expect(encryptedMigrator.runMigrations).toHaveBeenCalledWith(TEST_USER_ID, "password");
});
it("continues to success when no force password reason", async () => {
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
of(ForceSetPasswordReason.None),
);
const response = await command.run("a@b.c", "password", {});
expect(response.success).toBe(true);
});
});
});
// =========================================================================
// 5. Success Response
// =========================================================================
describe("success response", () => {
it("returns unlock message for API key login (interactive, no KC)", async () => {
process.env.BW_CLIENTID = "user.xxx";
process.env.BW_CLIENTSECRET = "secret";
// Must be interactive for the SSO/apikey unlock message branch
process.env.BW_NOINTERACTION = "false";
keyConnectorService.getUsesKeyConnector.mockResolvedValue(false);
const response = await command.run(NULL, NULL, { apikey: true });
expect(response.success).toBe(true);
expect((response.data as any).title).toBe("You are logged in!");
expect((response.data as any).message).toContain("unlock");
});
it("returns session key message for password login", async () => {
const response = await command.run("a@b.c", "password", {});
expect(response.success).toBe(true);
expect((response.data as any).title).toBe("You are logged in!");
expect((response.data as any).message).toContain("BW_SESSION");
expect((response.data as any).raw).toBe(B64_SESSION_KEY);
});
it("returns session key message when uses key connector", async () => {
process.env.BW_CLIENTID = "user.xxx";
process.env.BW_CLIENTSECRET = "secret";
process.env.BW_NOINTERACTION = "false";
keyConnectorService.getUsesKeyConnector.mockResolvedValue(true);
const response = await command.run(NULL, NULL, { apikey: true });
expect(response.success).toBe(true);
// When usesKeyConnector is true, falls through to session key message
expect((response.data as any).message).toContain("BW_SESSION");
expect((response.data as any).raw).toBe(B64_SESSION_KEY);
});
it("sets BW_SESSION env var via validatedParams", async () => {
await command.run("a@b.c", "password", {});
expect(process.env.BW_SESSION).toBe(B64_SESSION_KEY);
});
});
// =========================================================================
// 6. Error Handling
// =========================================================================
describe("error handling", () => {
it("returns localized error for 'Username or password is incorrect'", async () => {
const errorResponse = new ErrorResponse(
{ Message: "Username or password is incorrect. Try again." },
400,
);
loginStrategyService.logIn.mockRejectedValue(errorResponse);
const response = await command.run("a@b.c", "password", {});
expect(response.success).toBe(false);
expect(i18nService.t).toHaveBeenCalledWith(
"invalidMasterPasswordConfirmEmailAndHost",
expect.any(String),
);
});
it("passes through generic errors", async () => {
loginStrategyService.logIn.mockRejectedValue(new Error("boom"));
const response = await command.run("a@b.c", "password", {});
expect(response.success).toBe(false);
expect(response.message).toContain("boom");
});
});
});

@ -5,7 +5,7 @@ import * as http from "http";
import { OptionValues } from "commander";
import * as inquirer from "inquirer";
import Separator from "inquirer/lib/objects/separator";
import { filter, firstValueFrom, map } from "rxjs";
import { filter, firstValueFrom } from "rxjs";
import {
LoginStrategyServiceAbstraction,
@ -15,12 +15,8 @@ import {
UserApiLoginCredentials,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import {
isTwoFactorProviderType,
@ -29,13 +25,10 @@ import {
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request";
import { TwoFactorService, TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
@ -44,12 +37,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { NodeUtils } from "@bitwarden/node/node-utils";
import { ConfirmKeyConnectorDomainCommand } from "../../key-management/confirm-key-connector-domain.command";
@ -69,22 +59,15 @@ export class LoginCommand {
protected loginStrategyService: LoginStrategyServiceAbstraction,
protected authService: AuthService,
protected twoFactorApiService: TwoFactorApiService,
protected masterPasswordApiService: MasterPasswordApiService,
protected cryptoFunctionService: CryptoFunctionService,
protected environmentService: EnvironmentService,
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
protected platformUtilsService: PlatformUtilsService,
protected accountService: AccountService,
protected keyService: KeyService,
protected policyService: PolicyService,
protected twoFactorService: TwoFactorService,
protected syncService: SyncService,
protected keyConnectorService: KeyConnectorService,
protected policyApiService: PolicyApiServiceAbstraction,
protected orgService: OrganizationService,
protected logoutCallback: () => Promise<void>,
protected kdfConfigService: KdfConfigService,
protected ssoUrlService: SsoUrlService,
protected i18nService: I18nService,
protected masterPasswordService: MasterPasswordServiceAbstraction,
@ -416,16 +399,22 @@ export class LoginCommand {
// Run full sync before handling success response or password reset flows (to get Master Password Policies)
await this.syncService.fullSync(true, { skipTokenRefresh: true });
// Handle updating passwords if NOT using an API Key for authentication
// If the user's password is out of compliance, log them out and direct them to the web app.
if (clientId == null && clientSecret == null) {
const forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(response.userId),
);
if (forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset) {
return await this.updateTempPassword(response.userId);
await this.logoutCallback();
return Response.error(
"An organization administrator recently changed your master password. In order to access the vault, you must update your master password now via the web app. You have been logged out.",
);
} else if (forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword) {
return await this.updateWeakPassword(response.userId, password);
await this.logoutCallback();
return Response.error(
"Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now via the web app. You have been logged out.",
);
}
}
@ -484,217 +473,6 @@ export class LoginCommand {
return Response.success(res);
}
private async handleUpdatePasswordSuccessResponse(): Promise<Response> {
await this.logoutCallback();
this.authService.logOut(() => {
/* Do nothing */
});
const res = new MessageResponse(
"Your master password has been updated!",
"\n" + "You have been logged out and must log in again to access the vault.",
);
return Response.success(res);
}
private async updateWeakPassword(userId: UserId, currentPassword: string) {
// If no interaction available, alert user to use web vault
if (!this.canInteract) {
await this.logoutCallback();
this.authService.logOut(() => {
/* Do nothing */
});
return Response.error(
new MessageResponse(
"Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now via the web vault. You have been logged out.",
null,
),
);
}
try {
const { newPasswordHash, newUserKey, hint } = await this.collectNewMasterPasswordDetails(
userId,
"Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now.",
);
const request = new PasswordRequest();
const masterKey = await this.keyService.getOrDeriveMasterKey(currentPassword, userId);
request.masterPasswordHash = await this.keyService.hashMasterKey(currentPassword, masterKey);
request.masterPasswordHint = hint;
request.newMasterPasswordHash = newPasswordHash;
request.key = newUserKey[1].encryptedString;
await this.masterPasswordApiService.postPassword(request);
return await this.handleUpdatePasswordSuccessResponse();
} catch (e) {
await this.logoutCallback();
this.authService.logOut(() => {
/* Do nothing */
});
return Response.error(e);
}
}
private async updateTempPassword(userId: UserId) {
// If no interaction available, alert user to use web vault
if (!this.canInteract) {
await this.logoutCallback();
this.authService.logOut(() => {
/* Do nothing */
});
return Response.error(
new MessageResponse(
"An organization administrator recently changed your master password. In order to access the vault, you must update your master password now via the web vault. You have been logged out.",
null,
),
);
}
try {
const { newPasswordHash, newUserKey, hint } = await this.collectNewMasterPasswordDetails(
userId,
"An organization administrator recently changed your master password. In order to access the vault, you must update your master password now.",
);
const request = new UpdateTempPasswordRequest();
request.key = newUserKey[1].encryptedString;
request.newMasterPasswordHash = newPasswordHash;
request.masterPasswordHint = hint;
await this.masterPasswordApiService.putUpdateTempPassword(request);
return await this.handleUpdatePasswordSuccessResponse();
} catch (e) {
await this.logoutCallback();
this.authService.logOut(() => {
/* Do nothing */
});
return Response.error(e);
}
}
/**
* Collect new master password and hint from the CLI. The collected password
* is validated against any applicable master password policies, a new master
* key is generated, and we use it to re-encrypt the user key
* @param userId - User ID of the account
* @param prompt - Message that is displayed during the initial prompt
* @param error
*/
private async collectNewMasterPasswordDetails(
userId: UserId,
prompt: string,
error?: string,
): Promise<{
newPasswordHash: string;
newUserKey: [SymmetricCryptoKey, EncString];
hint?: string;
}> {
if (this.email == null || this.email === "undefined") {
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
}
// Get New Master Password
const baseMessage = `${prompt}\n` + "Master password: ";
const firstMessage = error != null ? error + baseMessage : baseMessage;
const mp: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({
type: "password",
name: "password",
message: firstMessage,
});
const masterPassword = mp.password;
// Master Password Validation
if (masterPassword == null || masterPassword === "") {
return this.collectNewMasterPasswordDetails(userId, prompt, "Master password is required.\n");
}
if (masterPassword.length < Utils.minimumPasswordLength) {
return this.collectNewMasterPasswordDetails(
userId,
prompt,
`Master password must be at least ${Utils.minimumPasswordLength} characters long.\n`,
);
}
// Strength & Policy Validation
const strengthResult = this.passwordStrengthService.getPasswordStrength(
masterPassword,
this.email,
);
const enforcedPolicyOptions = await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$(userId),
);
// Verify master password meets policy requirements
if (
enforcedPolicyOptions != null &&
!this.policyService.evaluateMasterPassword(
strengthResult.score,
masterPassword,
enforcedPolicyOptions,
)
) {
return this.collectNewMasterPasswordDetails(
userId,
prompt,
"Your new master password does not meet the policy requirements.\n",
);
}
// Get New Master Password Re-type
const reTypeMessage = "Re-type New Master password (Strength: " + strengthResult.score + ")";
const retype: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({
type: "password",
name: "password",
message: reTypeMessage,
});
const masterPasswordRetype = retype.password;
// Re-type Validation
if (masterPassword !== masterPasswordRetype) {
return this.collectNewMasterPasswordDetails(
userId,
prompt,
"Master password confirmation does not match.\n",
);
}
// Get Hint (optional)
const hint: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({
type: "input",
name: "input",
message: "Master Password Hint (optional):",
});
const masterPasswordHint = hint.input;
const kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
// Create new key and hash new password
const newMasterKey = await this.keyService.makeMasterKey(
masterPassword,
this.email.trim().toLowerCase(),
kdfConfig,
);
const newPasswordHash = await this.keyService.hashMasterKey(masterPassword, newMasterKey);
// Grab user key
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (!userKey) {
throw new Error("User key not found.");
}
// Re-encrypt user key with new master key
const newUserKey = await this.keyService.encryptUserKeyWithMasterKey(newMasterKey, userKey);
return { newPasswordHash, newUserKey: newUserKey, hint: masterPasswordHint };
}
private async apiClientId(): Promise<string> {
let clientId: string = null;

@ -176,22 +176,15 @@ export class Program extends BaseProgram {
this.serviceContainer.loginStrategyService,
this.serviceContainer.authService,
this.serviceContainer.twoFactorApiService,
this.serviceContainer.masterPasswordApiService,
this.serviceContainer.cryptoFunctionService,
this.serviceContainer.environmentService,
this.serviceContainer.passwordGenerationService,
this.serviceContainer.passwordStrengthService,
this.serviceContainer.platformUtilsService,
this.serviceContainer.accountService,
this.serviceContainer.keyService,
this.serviceContainer.policyService,
this.serviceContainer.twoFactorService,
this.serviceContainer.syncService,
this.serviceContainer.keyConnectorService,
this.serviceContainer.policyApiService,
this.serviceContainer.organizationService,
async () => await this.serviceContainer.logout(),
this.serviceContainer.kdfConfigService,
this.serviceContainer.ssoUrlService,
this.serviceContainer.i18nService,
this.serviceContainer.masterPasswordService,

Loading…
Cancel
Save