1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-21 11:35:34 +01:00

[PM-5951] Migrate org invite state (#9014)

* use deep linked url for org invite instead of separate state

* remove organization invite state & fix tests

* clear login redirect for SSO JIT users since they are accepted when setting MP

* create accept org invite service and consolidate components in module

* finish switch to accept org invite service

* move logic to accept org service

* the rest of the owl

* clear org invite along with deep linked route

* pr feedback

* fix test and add error to catch null invite

* pr feedback

* clear stored invite if it doesn't match provided one
This commit is contained in:
Jake Fink 2024-05-30 12:03:17 -04:00 committed by GitHub
parent f79d1dac92
commit e29025df28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 797 additions and 467 deletions

View File

@ -1,244 +0,0 @@
import { Component } from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import {
OrganizationUserAcceptInitRequest,
OrganizationUserAcceptRequest,
} from "@bitwarden/common/admin-console/abstractions/organization-user/requests";
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 { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrgKey } from "@bitwarden/common/types/key";
import { BaseAcceptComponent } from "../common/base.accept.component";
@Component({
selector: "app-accept-organization",
templateUrl: "accept-organization.component.html",
})
export class AcceptOrganizationComponent extends BaseAcceptComponent {
orgName: string;
protected requiredParameters: string[] = ["organizationId", "organizationUserId", "token"];
constructor(
router: Router,
platformUtilsService: PlatformUtilsService,
i18nService: I18nService,
route: ActivatedRoute,
stateService: StateService,
private cryptoService: CryptoService,
private policyApiService: PolicyApiServiceAbstraction,
private policyService: PolicyService,
private logService: LogService,
private organizationApiService: OrganizationApiServiceAbstraction,
private organizationUserService: OrganizationUserService,
private messagingService: MessagingService,
private apiService: ApiService,
) {
super(router, platformUtilsService, i18nService, route, stateService);
}
async authedHandler(qParams: Params): Promise<void> {
const initOrganization =
qParams.initOrganization != null && qParams.initOrganization.toLocaleLowerCase() === "true";
if (initOrganization) {
this.actionPromise = this.acceptInitOrganizationFlow(qParams);
} else {
const needsReAuth = (await this.stateService.getOrganizationInvitation()) == null;
if (needsReAuth) {
// Accepting an org invite requires authentication from a logged out state
this.messagingService.send("logout", { redirect: false });
await this.prepareOrganizationInvitation(qParams);
return;
}
// User has already logged in and passed the Master Password policy check
this.actionPromise = this.acceptFlow(qParams);
}
await this.actionPromise;
await this.apiService.refreshIdentityToken();
await this.stateService.setOrganizationInvitation(null);
this.platformUtilService.showToast(
"success",
this.i18nService.t("inviteAccepted"),
initOrganization
? this.i18nService.t("inviteInitAcceptedDesc")
: this.i18nService.t("inviteAcceptedDesc"),
{ timeout: 10000 },
);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/vault"]);
}
async unauthedHandler(qParams: Params): Promise<void> {
await this.prepareOrganizationInvitation(qParams);
// In certain scenarios, we want to accelerate the user through the accept org invite process
// For example, if the user has a BW account already, we want them to be taken to login instead of creation.
await this.accelerateInviteAcceptIfPossible(qParams);
}
private async acceptInitOrganizationFlow(qParams: Params): Promise<any> {
return this.prepareAcceptInitRequest(qParams).then((request) =>
this.organizationUserService.postOrganizationUserAcceptInit(
qParams.organizationId,
qParams.organizationUserId,
request,
),
);
}
private async acceptFlow(qParams: Params): Promise<any> {
return this.prepareAcceptRequest(qParams).then((request) =>
this.organizationUserService.postOrganizationUserAccept(
qParams.organizationId,
qParams.organizationUserId,
request,
),
);
}
private async prepareAcceptInitRequest(
qParams: Params,
): Promise<OrganizationUserAcceptInitRequest> {
const request = new OrganizationUserAcceptInitRequest();
request.token = qParams.token;
const [encryptedOrgKey, orgKey] = await this.cryptoService.makeOrgKey<OrgKey>();
const [orgPublicKey, encryptedOrgPrivateKey] = await this.cryptoService.makeKeyPair(orgKey);
const collection = await this.cryptoService.encrypt(
this.i18nService.t("defaultCollection"),
orgKey,
);
request.key = encryptedOrgKey.encryptedString;
request.keys = new OrganizationKeysRequest(
orgPublicKey,
encryptedOrgPrivateKey.encryptedString,
);
request.collectionName = collection.encryptedString;
return request;
}
private async prepareAcceptRequest(qParams: Params): Promise<OrganizationUserAcceptRequest> {
const request = new OrganizationUserAcceptRequest();
request.token = qParams.token;
if (await this.performResetPasswordAutoEnroll(qParams)) {
const response = await this.organizationApiService.getKeys(qParams.organizationId);
if (response == null) {
throw new Error(this.i18nService.t("resetPasswordOrgKeysError"));
}
const publicKey = Utils.fromB64ToArray(response.publicKey);
// RSA Encrypt user's encKey.key with organization public key
const userKey = await this.cryptoService.getUserKey();
const encryptedKey = await this.cryptoService.rsaEncrypt(userKey.key, publicKey);
// Add reset password key to accept request
request.resetPasswordKey = encryptedKey.encryptedString;
}
return request;
}
private async performResetPasswordAutoEnroll(qParams: Params): Promise<boolean> {
let policyList: Policy[] = null;
try {
const policies = await this.policyApiService.getPoliciesByToken(
qParams.organizationId,
qParams.token,
qParams.email,
qParams.organizationUserId,
);
policyList = Policy.fromListResponse(policies);
} catch (e) {
this.logService.error(e);
}
if (policyList != null) {
const result = this.policyService.getResetPasswordPolicyOptions(
policyList,
qParams.organizationId,
);
// Return true if policy enabled and auto-enroll enabled
return result[1] && result[0].autoEnrollEnabled;
}
return false;
}
private async prepareOrganizationInvitation(qParams: Params): Promise<void> {
this.orgName = qParams.organizationName;
if (this.orgName != null) {
// Fix URL encoding of space issue with Angular
this.orgName = this.orgName.replace(/\+/g, " ");
}
await this.stateService.setOrganizationInvitation(qParams);
}
private async accelerateInviteAcceptIfPossible(qParams: Params): Promise<void> {
// Extract the query params we need to make routing acceleration decisions
const orgSsoIdentifier = qParams.orgSsoIdentifier;
const orgUserHasExistingUser = this.stringToNullOrBool(qParams.orgUserHasExistingUser);
// if orgUserHasExistingUser is null, short circuit for backwards compatibility w/ older servers
if (orgUserHasExistingUser == null) {
return;
}
// if user exists, send user to login
if (orgUserHasExistingUser) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/login"], {
queryParams: { email: qParams.email },
});
return;
}
// no user exists; so either sign in via SSO and JIT provision one or simply register.
if (orgSsoIdentifier) {
// We only send sso org identifier if the org has SSO enabled and the SSO policy required.
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/sso"], {
queryParams: { email: qParams.email, identifier: orgSsoIdentifier },
});
return;
}
// if SSO is disabled OR if sso is enabled but the SSO login required policy is not enabled
// then send user to create account
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/register"], {
queryParams: { email: qParams.email, fromOrgInvite: true },
});
return;
}
private stringToNullOrBool(s: string | undefined): boolean | null {
if (s === undefined) {
return null;
}
return s.toLowerCase() === "true";
}
}

View File

@ -1,9 +1,10 @@
import { NgModule } from "@angular/core";
import { AcceptOrganizationInviteModule } from "./organization-invite/accept-organization.module";
import { AuthSettingsModule } from "./settings/settings.module";
@NgModule({
imports: [AuthSettingsModule],
imports: [AuthSettingsModule, AcceptOrganizationInviteModule],
declarations: [],
providers: [],
exports: [AuthSettingsModule],

View File

@ -1,9 +1,9 @@
import { Component } from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { BaseAcceptComponent } from "../../../common/base.accept.component";
import { SharedModule } from "../../../shared";
@ -27,10 +27,10 @@ export class AcceptEmergencyComponent extends BaseAcceptComponent {
platformUtilsService: PlatformUtilsService,
i18nService: I18nService,
route: ActivatedRoute,
stateService: StateService,
authService: AuthService,
private emergencyAccessService: EmergencyAccessService,
) {
super(router, platformUtilsService, i18nService, route, stateService);
super(router, platformUtilsService, i18nService, route, authService);
}
async authedHandler(qParams: Params): Promise<void> {

View File

@ -1,14 +1,27 @@
import { Component } from "@angular/core";
import { Component, inject } from "@angular/core";
import { BaseLoginDecryptionOptionsComponent } from "@bitwarden/angular/auth/components/base-login-decryption-options.component";
import { RouterService } from "../../../core";
import { AcceptOrganizationInviteService } from "../../organization-invite/accept-organization.service";
@Component({
selector: "web-login-decryption-options",
templateUrl: "login-decryption-options.component.html",
})
export class LoginDecryptionOptionsComponent extends BaseLoginDecryptionOptionsComponent {
protected routerService = inject(RouterService);
protected acceptOrganizationInviteService = inject(AcceptOrganizationInviteService);
override async createUser(): Promise<void> {
try {
await super.createUser();
// Invites from TDE orgs go through here, but the invite is
// accepted while being enrolled in admin recovery. So we need to clear
// the redirect and stored org invite.
await this.routerService.getAndClearLoginRedirectUrl();
await this.acceptOrganizationInviteService.clearOrganizationInvitation();
await this.router.navigate(["/vault"]);
} catch (error) {
this.validationService.showError(error);

View File

@ -15,12 +15,10 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@ -32,6 +30,8 @@ import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/pass
import { flagEnabled } from "../../../utils/flags";
import { RouterService, StateService } from "../../core";
import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service";
import { OrganizationInvite } from "../organization-invite/organization-invite";
@Component({
selector: "app-login",
@ -41,10 +41,11 @@ import { RouterService, StateService } from "../../core";
export class LoginComponent extends BaseLoginComponent implements OnInit {
showResetPasswordAutoEnrollWarning = false;
enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions;
policies: ListResponse<PolicyResponse>;
policies: Policy[];
showPasswordless = false;
constructor(
private acceptOrganizationInviteService: AcceptOrganizationInviteService,
devicesApiService: DevicesApiServiceAbstraction,
appIdService: AppIdService,
loginStrategyService: LoginStrategyServiceAbstraction,
@ -112,37 +113,10 @@ export class LoginComponent extends BaseLoginComponent implements OnInit {
await super.ngOnInit();
});
const invite = await this.stateService.getOrganizationInvitation();
if (invite != null) {
let policyList: Policy[] = null;
try {
this.policies = await this.policyApiService.getPoliciesByToken(
invite.organizationId,
invite.token,
invite.email,
invite.organizationUserId,
);
policyList = Policy.fromListResponse(this.policies);
} catch (e) {
this.logService.error(e);
}
if (policyList != null) {
const resetPasswordPolicy = this.policyService.getResetPasswordPolicyOptions(
policyList,
invite.organizationId,
);
// Set to true if policy enabled and auto-enroll enabled
this.showResetPasswordAutoEnrollWarning =
resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled;
this.policyService
.masterPasswordPolicyOptions$(policyList)
.pipe(takeUntil(this.destroy$))
.subscribe((enforcedPasswordPolicyOptions) => {
this.enforcedPasswordPolicyOptions = enforcedPasswordPolicyOptions;
});
}
// If there's an existing org invite, use it to get the password policies
const orgInvite = await this.acceptOrganizationInviteService.getOrganizationInvite();
if (orgInvite != null) {
await this.initPasswordPolicies(orgInvite);
}
}
@ -166,50 +140,69 @@ export class LoginComponent extends BaseLoginComponent implements OnInit {
)
) {
const policiesData: { [id: string]: PolicyData } = {};
this.policies.data.map((p) => (policiesData[p.id] = new PolicyData(p)));
this.policies.map((p) => (policiesData[p.id] = PolicyData.fromPolicy(p)));
await this.policyService.replace(policiesData);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["update-password"]);
await this.router.navigate(["update-password"]);
return;
}
}
this.loginEmailService.clearValues();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate([this.successRoute]);
await this.router.navigate([this.successRoute]);
}
goToHint() {
async goToHint() {
this.setLoginEmailValues();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigateByUrl("/hint");
await this.router.navigateByUrl("/hint");
}
goToRegister() {
async goToRegister() {
const email = this.formGroup.value.email;
if (email) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/register"], { queryParams: { email: email } });
await this.router.navigate(["/register"], { queryParams: { email: email } });
return;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/register"]);
await this.router.navigate(["/register"]);
}
protected override handleMigrateEncryptionKey(result: AuthResult): boolean {
protected override async handleMigrateEncryptionKey(result: AuthResult): Promise<boolean> {
if (!result.requiresEncryptionKeyMigration) {
return false;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["migrate-legacy-encryption"]);
await this.router.navigate(["migrate-legacy-encryption"]);
return true;
}
private async initPasswordPolicies(invite: OrganizationInvite): Promise<void> {
try {
this.policies = await this.policyApiService.getPoliciesByToken(
invite.organizationId,
invite.token,
invite.email,
invite.organizationUserId,
);
} catch (e) {
this.logService.error(e);
}
if (this.policies == null) {
return;
}
const resetPasswordPolicy = this.policyService.getResetPasswordPolicyOptions(
this.policies,
invite.organizationId,
);
// Set to true if policy enabled and auto-enroll enabled
this.showResetPasswordAutoEnrollWarning =
resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled;
this.policyService
.masterPasswordPolicyOptions$(this.policies)
.pipe(takeUntil(this.destroy$))
.subscribe((enforcedPasswordPolicyOptions) => {
this.enforcedPasswordPolicyOptions = enforcedPasswordPolicyOptions;
});
}
}

View File

@ -18,7 +18,7 @@
<div class="card d-block">
<div class="card-body">
<p class="text-center">
{{ orgName }}
{{ orgName$ | async }}
<strong class="d-block mt-2">{{ email }}</strong>
</p>
<p>{{ "joinOrganizationDesc" | i18n }}</p>

View File

@ -0,0 +1,94 @@
import { Component } from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BaseAcceptComponent } from "../../common/base.accept.component";
import { AcceptOrganizationInviteService } from "./accept-organization.service";
import { OrganizationInvite } from "./organization-invite";
@Component({
templateUrl: "accept-organization.component.html",
})
export class AcceptOrganizationComponent extends BaseAcceptComponent {
orgName$ = this.acceptOrganizationInviteService.orgName$;
protected requiredParameters: string[] = ["organizationId", "organizationUserId", "token"];
constructor(
router: Router,
platformUtilsService: PlatformUtilsService,
i18nService: I18nService,
route: ActivatedRoute,
authService: AuthService,
private acceptOrganizationInviteService: AcceptOrganizationInviteService,
) {
super(router, platformUtilsService, i18nService, route, authService);
}
async authedHandler(qParams: Params): Promise<void> {
const invite = OrganizationInvite.fromParams(qParams);
const success = await this.acceptOrganizationInviteService.validateAndAcceptInvite(invite);
if (!success) {
return;
}
this.platformUtilService.showToast(
"success",
this.i18nService.t("inviteAccepted"),
invite.initOrganization
? this.i18nService.t("inviteInitAcceptedDesc")
: this.i18nService.t("inviteAcceptedDesc"),
{ timeout: 10000 },
);
await this.router.navigate(["/vault"]);
}
async unauthedHandler(qParams: Params): Promise<void> {
const invite = OrganizationInvite.fromParams(qParams);
await this.acceptOrganizationInviteService.setOrganizationInvitation(invite);
await this.accelerateInviteAcceptIfPossible(invite);
}
/**
* In certain scenarios, we want to accelerate the user through the accept org invite process
* For example, if the user has a BW account already, we want them to be taken to login instead of creation.
*/
private async accelerateInviteAcceptIfPossible(invite: OrganizationInvite): Promise<void> {
// if orgUserHasExistingUser is null, we can't determine the user's status
// so we don't want to accelerate the process
if (invite.orgUserHasExistingUser == null) {
return;
}
// if user exists, send user to login
if (invite.orgUserHasExistingUser) {
await this.router.navigate(["/login"], {
queryParams: { email: invite.email },
});
return;
}
if (invite.orgSsoIdentifier) {
// We only send sso org identifier if the org has SSO enabled and the SSO policy required.
// Will JIT provision the user.
// Note: If the organization has Admin Recovery enabled, the user will be accepted into the org
// upon enrollment. The user should not be returned here.
await this.router.navigate(["/sso"], {
queryParams: { email: invite.email, identifier: invite.orgSsoIdentifier },
});
return;
}
// if SSO is disabled OR if sso is enabled but the SSO login required policy is not enabled
// then send user to create account
await this.router.navigate(["/register"], {
queryParams: { email: invite.email, fromOrgInvite: true },
});
return;
}
}

View File

@ -0,0 +1,13 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../shared";
import { AcceptOrganizationComponent } from "./accept-organization.component";
import { AcceptOrganizationInviteService } from "./accept-organization.service";
@NgModule({
declarations: [AcceptOrganizationComponent],
imports: [SharedModule],
providers: [AcceptOrganizationInviteService],
})
export class AcceptOrganizationInviteModule {}

View File

@ -0,0 +1,185 @@
import { FakeGlobalStateProvider } from "@bitwarden/common/../spec/fake-state-provider";
import { MockProxy, mock } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
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 { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { FakeGlobalState } from "@bitwarden/common/spec/fake-state";
import { OrgKey } from "@bitwarden/common/types/key";
import { I18nService } from "../../core/i18n.service";
import {
AcceptOrganizationInviteService,
ORGANIZATION_INVITE,
} from "./accept-organization.service";
import { OrganizationInvite } from "./organization-invite";
describe("AcceptOrganizationInviteService", () => {
let sut: AcceptOrganizationInviteService;
let apiService: MockProxy<ApiService>;
let authService: MockProxy<AuthService>;
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<EncryptService>;
let policyApiService: MockProxy<PolicyApiServiceAbstraction>;
let policyService: MockProxy<PolicyService>;
let logService: MockProxy<LogService>;
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
let organizationUserService: MockProxy<OrganizationUserService>;
let i18nService: MockProxy<I18nService>;
let globalStateProvider: FakeGlobalStateProvider;
let globalState: FakeGlobalState<OrganizationInvite>;
beforeEach(() => {
apiService = mock();
authService = mock();
cryptoService = mock();
encryptService = mock();
policyApiService = mock();
policyService = mock();
logService = mock();
organizationApiService = mock();
organizationUserService = mock();
i18nService = mock();
globalStateProvider = new FakeGlobalStateProvider();
globalState = globalStateProvider.getFake(ORGANIZATION_INVITE);
sut = new AcceptOrganizationInviteService(
apiService,
authService,
cryptoService,
encryptService,
policyApiService,
policyService,
logService,
organizationApiService,
organizationUserService,
i18nService,
globalStateProvider,
);
});
describe("validateAndAcceptInvite", () => {
it("initializes an organization when given an invite where initOrganization is true", async () => {
cryptoService.makeOrgKey.mockResolvedValue([
{ encryptedString: "string" } as EncString,
"orgPrivateKey" as unknown as OrgKey,
]);
cryptoService.makeKeyPair.mockResolvedValue([
"orgPublicKey",
{ encryptedString: "string" } as EncString,
]);
encryptService.encrypt.mockResolvedValue({ encryptedString: "string" } as EncString);
const invite = createOrgInvite({ initOrganization: true });
const result = await sut.validateAndAcceptInvite(invite);
expect(result).toBe(true);
expect(organizationUserService.postOrganizationUserAcceptInit).toHaveBeenCalled();
expect(apiService.refreshIdentityToken).toHaveBeenCalled();
expect(globalState.nextMock).toHaveBeenCalledWith(null);
expect(organizationUserService.postOrganizationUserAccept).not.toHaveBeenCalled();
expect(authService.logOut).not.toHaveBeenCalled();
});
it("logs out the user and stores the invite when a master password policy check is required", async () => {
const invite = createOrgInvite();
policyApiService.getPoliciesByToken.mockResolvedValue([
{
type: PolicyType.MasterPassword,
enabled: true,
} as Policy,
]);
const result = await sut.validateAndAcceptInvite(invite);
expect(result).toBe(false);
expect(authService.logOut).toHaveBeenCalled();
expect(globalState.nextMock).toHaveBeenCalledWith(invite);
});
it("clears the stored invite when a master password policy check is required but the stored invite doesn't match the provided one", async () => {
const storedInvite = createOrgInvite({ email: "wrongemail@example.com" });
const providedInvite = createOrgInvite();
await globalState.update(() => storedInvite);
policyApiService.getPoliciesByToken.mockResolvedValue([
{
type: PolicyType.MasterPassword,
enabled: true,
} as Policy,
]);
const result = await sut.validateAndAcceptInvite(providedInvite);
expect(result).toBe(false);
expect(authService.logOut).toHaveBeenCalled();
expect(globalState.nextMock).toHaveBeenCalledWith(providedInvite);
});
it("accepts the invitation request when the organization doesn't have a master password policy", async () => {
const invite = createOrgInvite();
policyApiService.getPoliciesByToken.mockResolvedValue([]);
const result = await sut.validateAndAcceptInvite(invite);
expect(result).toBe(true);
expect(organizationUserService.postOrganizationUserAccept).toHaveBeenCalled();
expect(apiService.refreshIdentityToken).toHaveBeenCalled();
expect(globalState.nextMock).toHaveBeenCalledWith(null);
expect(organizationUserService.postOrganizationUserAcceptInit).not.toHaveBeenCalled();
expect(authService.logOut).not.toHaveBeenCalled();
});
it("accepts the invitation request when the org has a master password policy, but the user has already passed it", async () => {
const invite = createOrgInvite();
policyApiService.getPoliciesByToken.mockResolvedValue([
{
type: PolicyType.MasterPassword,
enabled: true,
} as Policy,
]);
// an existing invite means the user has already passed the master password policy
await globalState.update(() => invite);
policyService.getResetPasswordPolicyOptions.mockReturnValue([
{
autoEnrollEnabled: false,
} as ResetPasswordPolicyOptions,
false,
]);
const result = await sut.validateAndAcceptInvite(invite);
expect(result).toBe(true);
expect(organizationUserService.postOrganizationUserAccept).toHaveBeenCalled();
expect(organizationUserService.postOrganizationUserAcceptInit).not.toHaveBeenCalled();
expect(authService.logOut).not.toHaveBeenCalled();
});
});
});
function createOrgInvite(custom: Partial<OrganizationInvite> = {}): OrganizationInvite {
return Object.assign(
{
email: "user@example.com",
initOrganization: false,
orgSsoIdentifier: null,
orgUserHasExistingUser: false,
organizationId: "organizationId",
organizationName: "organizationName",
organizationUserId: "organizationUserId",
token: "token",
},
custom,
);
}

View File

@ -0,0 +1,248 @@
import { Injectable } from "@angular/core";
import { BehaviorSubject, firstValueFrom, map } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import {
OrganizationUserAcceptRequest,
OrganizationUserAcceptInitRequest,
} from "@bitwarden/common/admin-console/abstractions/organization-user/requests";
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 { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
GlobalState,
GlobalStateProvider,
KeyDefinition,
ORGANIZATION_INVITE_DISK,
} from "@bitwarden/common/platform/state";
import { OrgKey } from "@bitwarden/common/types/key";
import { OrganizationInvite } from "./organization-invite";
// We're storing the organization invite for 2 reasons:
// 1. If the org requires a MP policy check, we need to keep track that the user has already been redirected when they return.
// 2. The MP policy check happens on login/register flows, we need to store the token to retrieve the policies then.
export const ORGANIZATION_INVITE = new KeyDefinition<OrganizationInvite>(
ORGANIZATION_INVITE_DISK,
"organizationInvite",
{
deserializer: (invite) => OrganizationInvite.fromJSON(invite),
},
);
@Injectable()
export class AcceptOrganizationInviteService {
private organizationInvitationState: GlobalState<OrganizationInvite>;
private orgNameSubject: BehaviorSubject<string> = new BehaviorSubject<string>(null);
private policyCache: Policy[];
// Fix URL encoding of space issue with Angular
orgName$ = this.orgNameSubject.pipe(map((orgName) => orgName.replace(/\+/g, " ")));
constructor(
private readonly apiService: ApiService,
private readonly authService: AuthService,
private readonly cryptoService: CryptoService,
private readonly encryptService: EncryptService,
private readonly policyApiService: PolicyApiServiceAbstraction,
private readonly policyService: PolicyService,
private readonly logService: LogService,
private readonly organizationApiService: OrganizationApiServiceAbstraction,
private readonly organizationUserService: OrganizationUserService,
private readonly i18nService: I18nService,
private readonly globalStateProvider: GlobalStateProvider,
) {
this.organizationInvitationState = this.globalStateProvider.get(ORGANIZATION_INVITE);
}
/** Returns the currently stored organization invite */
async getOrganizationInvite(): Promise<OrganizationInvite> {
return await firstValueFrom(this.organizationInvitationState.state$);
}
/**
* Stores a new organization invite
* @param invite an organization invite
* @throws if the invite is nullish
*/
async setOrganizationInvitation(invite: OrganizationInvite): Promise<void> {
if (invite == null) {
throw new Error("Invite cannot be null. Use clearOrganizationInvitation instead.");
}
await this.organizationInvitationState.update(() => invite);
}
/** Clears the currently stored organization invite */
async clearOrganizationInvitation(): Promise<void> {
await this.organizationInvitationState.update(() => null);
}
/**
* Validates and accepts the organization invitation if possible.
* Note: Users might need to pass a MP policy check before accepting an invite to an existing organization. If the user
* has not passed this check, they will be logged out and the invite will be stored for later use.
* @param invite an organization invite
* @returns a promise that resolves a boolean indicating if the invite was accepted.
*/
async validateAndAcceptInvite(invite: OrganizationInvite): Promise<boolean> {
if (invite == null) {
throw new Error("Invite cannot be null.");
}
// Creation of a new org
if (invite.initOrganization) {
await this.acceptAndInitOrganization(invite);
return true;
}
// Accepting an org invite from existing org
if (await this.masterPasswordPolicyCheckRequired(invite)) {
await this.setOrganizationInvitation(invite);
this.authService.logOut(() => {
/* Do nothing */
});
return false;
}
// We know the user has already logged in and passed a MP policy check
await this.accept(invite);
return true;
}
private async acceptAndInitOrganization(invite: OrganizationInvite): Promise<void> {
await this.prepareAcceptAndInitRequest(invite).then((request) =>
this.organizationUserService.postOrganizationUserAcceptInit(
invite.organizationId,
invite.organizationUserId,
request,
),
);
await this.apiService.refreshIdentityToken();
await this.clearOrganizationInvitation();
}
private async prepareAcceptAndInitRequest(
invite: OrganizationInvite,
): Promise<OrganizationUserAcceptInitRequest> {
const request = new OrganizationUserAcceptInitRequest();
request.token = invite.token;
const [encryptedOrgKey, orgKey] = await this.cryptoService.makeOrgKey<OrgKey>();
const [orgPublicKey, encryptedOrgPrivateKey] = await this.cryptoService.makeKeyPair(orgKey);
const collection = await this.encryptService.encrypt(
this.i18nService.t("defaultCollection"),
orgKey,
);
request.key = encryptedOrgKey.encryptedString;
request.keys = new OrganizationKeysRequest(
orgPublicKey,
encryptedOrgPrivateKey.encryptedString,
);
request.collectionName = collection.encryptedString;
return request;
}
private async accept(invite: OrganizationInvite): Promise<void> {
await this.prepareAcceptRequest(invite).then((request) =>
this.organizationUserService.postOrganizationUserAccept(
invite.organizationId,
invite.organizationUserId,
request,
),
);
await this.apiService.refreshIdentityToken();
await this.clearOrganizationInvitation();
}
private async prepareAcceptRequest(
invite: OrganizationInvite,
): Promise<OrganizationUserAcceptRequest> {
const request = new OrganizationUserAcceptRequest();
request.token = invite.token;
if (await this.resetPasswordEnrollRequired(invite)) {
const response = await this.organizationApiService.getKeys(invite.organizationId);
if (response == null) {
throw new Error(this.i18nService.t("resetPasswordOrgKeysError"));
}
const publicKey = Utils.fromB64ToArray(response.publicKey);
// RSA Encrypt user's encKey.key with organization public key
const userKey = await this.cryptoService.getUserKey();
const encryptedKey = await this.cryptoService.rsaEncrypt(userKey.key, publicKey);
// Add reset password key to accept request
request.resetPasswordKey = encryptedKey.encryptedString;
}
return request;
}
private async resetPasswordEnrollRequired(invite: OrganizationInvite): Promise<boolean> {
const policies = await this.getPolicies(invite);
if (policies == null || policies.length === 0) {
return false;
}
const result = this.policyService.getResetPasswordPolicyOptions(
policies,
invite.organizationId,
);
// Return true if policy enabled and auto-enroll enabled
return result[1] && result[0].autoEnrollEnabled;
}
private async masterPasswordPolicyCheckRequired(invite: OrganizationInvite): Promise<boolean> {
const policies = await this.getPolicies(invite);
if (policies == null || policies.length === 0) {
return false;
}
const hasMasterPasswordPolicy = policies.some(
(p) => p.type === PolicyType.MasterPassword && p.enabled,
);
let storedInvite = await this.getOrganizationInvite();
if (storedInvite?.email !== invite.email) {
// clear stored invites if the email doesn't match
await this.clearOrganizationInvitation();
storedInvite = null;
}
// if we don't have an org invite stored, we know the user hasn't been redirected yet to check the MP policy
const hasNotCheckedMasterPasswordYet = storedInvite == null;
return hasMasterPasswordPolicy && hasNotCheckedMasterPasswordYet;
}
private async getPolicies(invite: OrganizationInvite): Promise<Policy[] | null> {
// if policies are not cached, fetch them
if (this.policyCache == null) {
try {
this.policyCache = await this.policyApiService.getPoliciesByToken(
invite.organizationId,
invite.token,
invite.email,
invite.organizationUserId,
);
} catch (e) {
this.logService.error(e);
}
}
return this.policyCache;
}
}

View File

@ -0,0 +1,30 @@
import { Params } from "@angular/router";
import { Jsonify } from "type-fest";
export class OrganizationInvite {
email: string;
initOrganization: boolean;
orgSsoIdentifier: string;
orgUserHasExistingUser: boolean;
organizationId: string;
organizationName: string;
organizationUserId: string;
token: string;
static fromJSON(json: Jsonify<OrganizationInvite>) {
return Object.assign(new OrganizationInvite(), json);
}
static fromParams(params: Params): OrganizationInvite {
return Object.assign(new OrganizationInvite(), {
email: params.email,
initOrganization: params.initOrganization?.toLocaleLowerCase() === "true",
orgSsoIdentifier: params.orgSsoIdentifier,
orgUserHasExistingUser: params.orgUserHasExistingUser?.toLocaleLowerCase() === "true",
organizationId: params.organizationId,
organizationName: params.organizationName,
organizationUserId: params.organizationUserId,
token: params.token,
});
}
}

View File

@ -10,6 +10,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
import { RegisterRequest } from "@bitwarden/common/models/request/register.request";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -19,6 +20,8 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { DialogService } from "@bitwarden/components";
import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service";
@Component({
selector: "app-register-form",
templateUrl: "./register-form.component.html",
@ -48,6 +51,7 @@ export class RegisterFormComponent extends BaseRegisterComponent {
logService: LogService,
auditService: AuditService,
dialogService: DialogService,
acceptOrgInviteService: AcceptOrganizationInviteService,
) {
super(
formValidationErrorService,
@ -65,6 +69,16 @@ export class RegisterFormComponent extends BaseRegisterComponent {
auditService,
dialogService,
);
super.modifyRegisterRequest = async (request: RegisterRequest) => {
// Org invites are deep linked. Non-existent accounts are redirected to the register page.
// Org user id and token are included here only for validation and two factor purposes.
const orgInvite = await acceptOrgInviteService.getOrganizationInvite();
if (orgInvite != null) {
request.organizationUserId = orgInvite.organizationUserId;
request.token = orgInvite.token;
}
// Invite is accepted after login (on deep link redirect).
};
}
async ngOnInit() {

View File

@ -1,9 +1,30 @@
import { Component } from "@angular/core";
import { Component, inject } from "@angular/core";
import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/auth/components/set-password.component";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { RouterService } from "../core";
import { AcceptOrganizationInviteService } from "./organization-invite/accept-organization.service";
@Component({
selector: "app-set-password",
templateUrl: "set-password.component.html",
})
export class SetPasswordComponent extends BaseSetPasswordComponent {}
export class SetPasswordComponent extends BaseSetPasswordComponent {
routerService = inject(RouterService);
acceptOrganizationInviteService = inject(AcceptOrganizationInviteService);
protected override async onSetPasswordSuccess(
masterKey: MasterKey,
userKey: [UserKey, EncString],
keyPair: [string, EncString],
): Promise<void> {
await super.onSetPasswordSuccess(masterKey, userKey, keyPair);
// SSO JIT accepts org invites when setting their MP, meaning
// we can clear the deep linked url for accepting it.
await this.routerService.getAndClearLoginRedirectUrl();
await this.acceptOrganizationInviteService.clearOrganizationInvitation();
}
}

View File

@ -12,15 +12,16 @@ import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
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 { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { PlanType } from "@bitwarden/common/billing/enums";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { RouterService } from "../../core";
import { SharedModule } from "../../shared";
import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service";
import { OrganizationInvite } from "../organization-invite/organization-invite";
import { TrialInitiationComponent } from "./trial-initiation.component";
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
@ -36,12 +37,16 @@ describe("TrialInitiationComponent", () => {
let stateServiceMock: MockProxy<StateService>;
let policyApiServiceMock: MockProxy<PolicyApiServiceAbstraction>;
let policyServiceMock: MockProxy<PolicyService>;
let routerServiceMock: MockProxy<RouterService>;
let acceptOrgInviteServiceMock: MockProxy<AcceptOrganizationInviteService>;
beforeEach(() => {
// only define services directly that we want to mock return values in this component
stateServiceMock = mock<StateService>();
policyApiServiceMock = mock<PolicyApiServiceAbstraction>();
policyServiceMock = mock<PolicyService>();
routerServiceMock = mock<RouterService>();
acceptOrgInviteServiceMock = mock<AcceptOrganizationInviteService>();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@ -81,7 +86,11 @@ describe("TrialInitiationComponent", () => {
},
{
provide: RouterService,
useValue: mock<RouterService>(),
useValue: routerServiceMock,
},
{
provide: AcceptOrganizationInviteService,
useValue: acceptOrgInviteServiceMock,
},
],
schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component)
@ -100,8 +109,8 @@ describe("TrialInitiationComponent", () => {
// These tests demonstrate mocking service calls
describe("onInit() enforcedPolicyOptions", () => {
it("should not set enforcedPolicyOptions if state service returns no invite", async () => {
stateServiceMock.getOrganizationInvitation.mockReturnValueOnce(null);
it("should not set enforcedPolicyOptions if there isn't an org invite in deep linked url", async () => {
acceptOrgInviteServiceMock.getOrganizationInvite.mockResolvedValueOnce(null);
// Need to recreate component with new service mock
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
@ -109,37 +118,31 @@ describe("TrialInitiationComponent", () => {
expect(component.enforcedPolicyOptions).toBe(undefined);
});
it("should set enforcedPolicyOptions if state service returns an invite", async () => {
it("should set enforcedPolicyOptions if the deep linked url has an org invite", async () => {
// Set up service method mocks
stateServiceMock.getOrganizationInvitation.mockReturnValueOnce(
Promise.resolve({
organizationId: testOrgId,
token: "token",
email: "testEmail",
organizationUserId: "123",
}),
);
acceptOrgInviteServiceMock.getOrganizationInvite.mockResolvedValueOnce({
organizationId: testOrgId,
token: "token",
email: "testEmail",
organizationUserId: "123",
} as OrganizationInvite);
policyApiServiceMock.getPoliciesByToken.mockReturnValueOnce(
Promise.resolve({
data: [
{
id: "345",
organizationId: testOrgId,
type: 1,
data: [
{
minComplexity: 4,
minLength: 10,
requireLower: null,
requireNumbers: null,
requireSpecial: null,
requireUpper: null,
},
],
enabled: true,
Promise.resolve([
{
id: "345",
organizationId: testOrgId,
type: 1,
data: {
minComplexity: 4,
minLength: 10,
requireLower: null,
requireNumbers: null,
requireSpecial: null,
requireUpper: null,
},
],
} as ListResponse<PolicyResponse>),
enabled: true,
},
] as Policy[]),
);
policyServiceMock.masterPasswordPolicyOptions$.mockReturnValue(
of({

View File

@ -14,13 +14,14 @@ import { ProductType } from "@bitwarden/common/enums";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import {
OrganizationCreatedEvent,
SubscriptionProduct,
TrialOrganizationType,
} from "../../billing/accounts/trial-initiation/trial-billing-step.component";
import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service";
import { OrganizationInvite } from "../organization-invite/organization-invite";
import { RouterService } from "./../../core/router.service";
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
@ -121,12 +122,12 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
protected router: Router,
private formBuilder: UntypedFormBuilder,
private titleCasePipe: TitleCasePipe,
private stateService: StateService,
private logService: LogService,
private policyApiService: PolicyApiServiceAbstraction,
private policyService: PolicyService,
private i18nService: I18nService,
private routerService: RouterService,
private acceptOrgInviteService: AcceptOrganizationInviteService,
) {}
async ngOnInit(): Promise<void> {
@ -180,30 +181,10 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
: "Password Manager trial from marketing website";
});
const invite = await this.stateService.getOrganizationInvitation();
if (invite != null) {
try {
const policies = await this.policyApiService.getPoliciesByToken(
invite.organizationId,
invite.token,
invite.email,
invite.organizationUserId,
);
if (policies.data != null) {
this.policies = Policy.fromListResponse(policies);
}
} catch (e) {
this.logService.error(e);
}
}
if (this.policies != null) {
this.policyService
.masterPasswordPolicyOptions$(this.policies)
.pipe(takeUntil(this.destroy$))
.subscribe((enforcedPasswordPolicyOptions) => {
this.enforcedPolicyOptions = enforcedPasswordPolicyOptions;
});
// If there's a deep linked org invite, use it to get the password policies
const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite();
if (orgInvite != null) {
await this.initPasswordPolicies(orgInvite);
}
this.orgInfoFormGroup.controls.name.valueChanges
@ -304,5 +285,31 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
}
}
private async initPasswordPolicies(invite: OrganizationInvite): Promise<void> {
if (invite == null) {
return;
}
try {
this.policies = await this.policyApiService.getPoliciesByToken(
invite.organizationId,
invite.token,
invite.email,
invite.organizationUserId,
);
} catch (e) {
this.logService.error(e);
}
if (this.policies != null) {
this.policyService
.masterPasswordPolicyOptions$(this.policies)
.pipe(takeUntil(this.destroy$))
.subscribe((enforcedPasswordPolicyOptions) => {
this.enforcedPolicyOptions = enforcedPasswordPolicyOptions;
});
}
}
protected readonly SubscriptionProduct = SubscriptionProduct;
}

View File

@ -1,60 +1,24 @@
import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { Component, inject } from "@angular/core";
import { UpdatePasswordComponent as BaseUpdatePasswordComponent } from "@bitwarden/angular/auth/components/update-password.component";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { DialogService } from "@bitwarden/components";
import { RouterService } from "../core";
import { AcceptOrganizationInviteService } from "./organization-invite/accept-organization.service";
@Component({
selector: "app-update-password",
templateUrl: "update-password.component.html",
})
export class UpdatePasswordComponent extends BaseUpdatePasswordComponent {
constructor(
router: Router,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
passwordGenerationService: PasswordGenerationServiceAbstraction,
policyService: PolicyService,
cryptoService: CryptoService,
messagingService: MessagingService,
apiService: ApiService,
logService: LogService,
stateService: StateService,
userVerificationService: UserVerificationService,
dialogService: DialogService,
kdfConfigService: KdfConfigService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
accountService: AccountService,
) {
super(
router,
i18nService,
platformUtilsService,
passwordGenerationService,
policyService,
cryptoService,
messagingService,
apiService,
stateService,
userVerificationService,
logService,
dialogService,
kdfConfigService,
masterPasswordService,
accountService,
);
private routerService = inject(RouterService);
private acceptOrganizationInviteService = inject(AcceptOrganizationInviteService);
override async cancel() {
// clearing the login redirect url so that the user
// does not join the organization if they cancel
await this.routerService.getAndClearLoginRedirectUrl();
await this.acceptOrganizationInviteService.clearOrganizationInvitation();
await super.cancel();
}
}

View File

@ -1,11 +1,12 @@
import { Directive, OnInit } from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router";
import { Subject } from "rxjs";
import { Subject, firstValueFrom } from "rxjs";
import { first, switchMap, takeUntil } from "rxjs/operators";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
@Directive()
export abstract class BaseAcceptComponent implements OnInit {
@ -25,7 +26,7 @@ export abstract class BaseAcceptComponent implements OnInit {
protected platformUtilService: PlatformUtilsService,
protected i18nService: I18nService,
protected route: ActivatedRoute,
protected stateService: StateService,
protected authService: AuthService,
) {}
abstract authedHandler(qParams: Params): Promise<void>;
@ -41,10 +42,10 @@ export abstract class BaseAcceptComponent implements OnInit {
);
let errorMessage: string = null;
if (!error) {
this.authed = await this.stateService.getIsAuthenticated();
this.email = qParams.email;
if (this.authed) {
const status = await firstValueFrom(this.authService.activeAccountStatus$);
if (status !== AuthenticationStatus.LoggedOut) {
try {
await this.authedHandler(qParams);
} catch (e) {

View File

@ -92,7 +92,7 @@ export class RouterService {
/**
* Fetch and clear persisted LoginRedirectUrl if present in state
*/
async getAndClearLoginRedirectUrl(): Promise<string> | undefined {
async getAndClearLoginRedirectUrl(): Promise<string | undefined> {
const persistedPreLoginUrl = await firstValueFrom(this.deepLinkRedirectUrlState.state$);
if (!Utils.isNullOrEmpty(persistedPreLoginUrl)) {

View File

@ -17,7 +17,6 @@ import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizatio
import { VerifyRecoverDeleteProviderComponent } from "./admin-console/providers/verify-recover-delete-provider.component";
import { CreateOrganizationComponent } from "./admin-console/settings/create-organization.component";
import { SponsoredFamiliesComponent } from "./admin-console/settings/sponsored-families.component";
import { AcceptOrganizationComponent } from "./auth/accept-organization.component";
import { deepLinkGuard } from "./auth/guards/deep-link.guard";
import { HintComponent } from "./auth/hint.component";
import { LockComponent } from "./auth/lock.component";
@ -25,6 +24,7 @@ import { LoginDecryptionOptionsComponent } from "./auth/login/login-decryption-o
import { LoginViaAuthRequestComponent } from "./auth/login/login-via-auth-request.component";
import { LoginViaWebAuthnComponent } from "./auth/login/login-via-webauthn/login-via-webauthn.component";
import { LoginComponent } from "./auth/login/login.component";
import { AcceptOrganizationComponent } from "./auth/organization-invite/accept-organization.component";
import { RecoverDeleteComponent } from "./auth/recover-delete.component";
import { RecoverTwoFactorComponent } from "./auth/recover-two-factor.component";
import { RemovePasswordComponent } from "./auth/remove-password.component";
@ -120,8 +120,8 @@ const routes: Routes = [
{ path: "verify-email", component: VerifyEmailTokenComponent },
{
path: "accept-organization",
component: AcceptOrganizationComponent,
canActivate: [deepLinkGuard()],
component: AcceptOrganizationComponent,
data: { titleId: "joinOrganization", doNotSaveUrl: false },
},
{

View File

@ -20,7 +20,6 @@ import { ProvidersComponent } from "../admin-console/providers/providers.compone
import { VerifyRecoverDeleteProviderComponent } from "../admin-console/providers/verify-recover-delete-provider.component";
import { SponsoredFamiliesComponent } from "../admin-console/settings/sponsored-families.component";
import { SponsoringOrgRowComponent } from "../admin-console/settings/sponsoring-org-row.component";
import { AcceptOrganizationComponent } from "../auth/accept-organization.component";
import { HintComponent } from "../auth/hint.component";
import { LockComponent } from "../auth/lock.component";
import { RecoverDeleteComponent } from "../auth/recover-delete.component";
@ -120,7 +119,6 @@ import { SharedModule } from "./shared.module";
],
declarations: [
AcceptFamilySponsorshipComponent,
AcceptOrganizationComponent,
AccountComponent,
AddEditComponent,
AddEditCustomFieldsComponent,
@ -193,7 +191,6 @@ import { SharedModule } from "./shared.module";
exports: [
UserVerificationModule,
PremiumBadgeComponent,
AcceptOrganizationComponent,
AccountComponent,
AddEditComponent,
AddEditCustomFieldsComponent,

View File

@ -3,9 +3,9 @@ import { ActivatedRoute, Params, Router } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ProviderUserAcceptRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-accept.request";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.component";
@Component({
@ -23,11 +23,11 @@ export class AcceptProviderComponent extends BaseAcceptComponent {
router: Router,
i18nService: I18nService,
route: ActivatedRoute,
stateService: StateService,
authService: AuthService,
private apiService: ApiService,
platformUtilService: PlatformUtilsService,
) {
super(router, platformUtilService, i18nService, route, stateService);
super(router, platformUtilService, i18nService, route, authService);
}
async authedHandler(qParams: Params) {

View File

@ -155,7 +155,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
if (this.handleCaptchaRequired(response)) {
return;
} else if (this.handleMigrateEncryptionKey(response)) {
} else if (await this.handleMigrateEncryptionKey(response)) {
return;
} else if (response.requiresTwoFactor) {
if (this.onSuccessfulLoginTwoFactorNavigate != null) {
@ -218,9 +218,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
}
this.setLoginEmailValues();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/login-with-device"]);
await this.router.navigate(["/login-with-device"]);
}
async launchSsoBrowser(clientId: string, ssoRedirectUri: string) {
@ -310,7 +308,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
// Legacy accounts used the master key to encrypt data. Migration is required
// but only performed on web
protected handleMigrateEncryptionKey(result: AuthResult): boolean {
protected async handleMigrateEncryptionKey(result: AuthResult): Promise<boolean> {
if (!result.requiresEncryptionKeyMigration) {
return false;
}

View File

@ -78,6 +78,10 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
protected captchaBypassToken: string = null;
// allows for extending classes to modify the register request before sending
// currently used by web to add organization invitation details
protected modifyRegisterRequest: (request: RegisterRequest) => Promise<void>;
constructor(
protected formValidationErrorService: FormValidationErrorsService,
protected formBuilder: UntypedFormBuilder,
@ -290,10 +294,8 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
kdfConfig.iterations,
);
request.keys = new KeysRequest(keys[0], keys[1].encryptedString);
const orgInvite = await this.stateService.getOrganizationInvitation();
if (orgInvite != null && orgInvite.token != null && orgInvite.organizationUserId != null) {
request.token = orgInvite.token;
request.organizationUserId = orgInvite.organizationUserId;
if (this.modifyRegisterRequest) {
await this.modifyRegisterRequest(request);
}
return request;
}

View File

@ -72,10 +72,7 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent {
}
async cancel() {
await this.stateService.setOrganizationInvitation(null);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/vault"]);
await this.router.navigate(["/vault"]);
}
async setupSubmitActions(): Promise<boolean> {

View File

@ -1,6 +1,7 @@
import { ListResponse } from "../../../models/response/list.response";
import { PolicyType } from "../../enums";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
import { Policy } from "../../models/domain/policy";
import { PolicyRequest } from "../../models/request/policy.request";
import { PolicyResponse } from "../../models/response/policy.response";
@ -13,7 +14,7 @@ export class PolicyApiServiceAbstraction {
token: string,
email: string,
organizationUserId: string,
) => Promise<ListResponse<PolicyResponse>>;
) => Promise<Policy[] | undefined>;
getMasterPasswordPolicyOptsForOrgUser: (orgId: string) => Promise<MasterPasswordPolicyOptions>;
putPolicy: (organizationId: string, type: PolicyType, request: PolicyRequest) => Promise<any>;

View File

@ -1,5 +1,6 @@
import { PolicyId } from "../../../types/guid";
import { PolicyType } from "../../enums";
import { Policy } from "../domain/policy";
import { PolicyResponse } from "../response/policy.response";
export class PolicyData {
@ -20,4 +21,8 @@ export class PolicyData {
this.data = response.data;
this.enabled = response.enabled;
}
static fromPolicy(policy: Policy): PolicyData {
return Object.assign(new PolicyData(), policy);
}
}

View File

@ -47,7 +47,7 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
token: string,
email: string,
organizationUserId: string,
): Promise<ListResponse<PolicyResponse>> {
): Promise<Policy[] | undefined> {
const r = await this.apiService.send(
"GET",
"/organizations/" +
@ -63,7 +63,7 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
false,
true,
);
return new ListResponse(r, PolicyResponse);
return Policy.fromListResponse(new ListResponse(r, PolicyResponse));
}
private async getMasterPasswordPolicyResponseForOrgUser(

View File

@ -3,11 +3,15 @@ import { UserKey } from "../../types/key";
export abstract class PasswordResetEnrollmentServiceAbstraction {
/*
* Checks the user's enrollment status and enrolls them if required
* NOTE: Will also enroll the user in the organization if in the
* invited status
*/
abstract enrollIfRequired(organizationSsoIdentifier: string): Promise<void>;
/**
* Enroll current user in password reset
* NOTE: Will also enroll the user in the organization if in the
* invited status
* @param organizationId - Organization in which to enroll the user
* @returns Promise that resolves when the user is enrolled
* @throws Error if the action fails
@ -16,6 +20,8 @@ export abstract class PasswordResetEnrollmentServiceAbstraction {
/**
* Enroll user in password reset
* NOTE: Will also enroll the user in the organization if in the
* invited status
* @param organizationId - Organization in which to enroll the user
* @param userId - User to enroll
* @param userKey - User's symmetric key

View File

@ -101,8 +101,6 @@ export abstract class StateService<T extends Account = Account> {
setLastSync: (value: string, options?: StorageOptions) => Promise<void>;
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise<void>;
getOrganizationInvitation: (options?: StorageOptions) => Promise<any>;
setOrganizationInvitation: (value: any, options?: StorageOptions) => Promise<void>;
getPasswordGenerationOptions: (options?: StorageOptions) => Promise<PasswordGeneratorOptions>;
setPasswordGenerationOptions: (
value: PasswordGeneratorOptions,

View File

@ -1,5 +1,4 @@
export class GlobalState {
organizationInvitation?: any;
enableBrowserIntegration?: boolean;
enableBrowserIntegrationFingerprint?: boolean;
enableDuckDuckGoBrowserIntegration?: boolean;

View File

@ -474,23 +474,6 @@ export class StateService<
);
}
async getOrganizationInvitation(options?: StorageOptions): Promise<any> {
return (
await this.getGlobals(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
)?.organizationInvitation;
}
async setOrganizationInvitation(value: any, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
);
globals.organizationInvitation = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
);
}
async getPasswordGenerationOptions(options?: StorageOptions): Promise<PasswordGeneratorOptions> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))

View File

@ -63,6 +63,7 @@ export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", {
export const TOKEN_MEMORY = new StateDefinition("token", "memory");
export const TWO_FACTOR_MEMORY = new StateDefinition("twoFactor", "memory");
export const USER_DECRYPTION_OPTIONS_DISK = new StateDefinition("userDecryptionOptions", "disk");
export const ORGANIZATION_INVITE_DISK = new StateDefinition("organizationInvite", "disk");
export const VAULT_TIMEOUT_SETTINGS_DISK_LOCAL = new StateDefinition(
"vaultTimeoutSettings",
"disk",