Skip to content

Commit

Permalink
PM-1391-Added previous-url to global-state (#5733)
Browse files Browse the repository at this point in the history
* added previous-url to global-state

* updated storage of previousUrl for SSO/MFA flows

* revert file changes

* added post login routing

* Clear PreviousUrl from storage on new Login

* Components do not call StateService anymore

* removed needed query params

* refactored components to use RouterService

* fixed build error

* fixed mfa component

* updated logic for previous Url

* removed unneeded base implementation

* Added state call for Redirect Guard

* Fixed test cases

* Remove routing service calls

* renamed global field, changed routing to guard

* reverting constructor changes and git lint issue

* fixing constructor ordering

* fixing diffs to be clearer on actual cahnges.

* addressing accepting emergency access case

* refactor and add locked state logic

* refactor name of guard to be more clear

* Added comments and tests

* comments + support lock page deep linking + code ownership

* readability updates

* Combined guards and specs updated routing

* Update oss-routing.module.ts

* fixed stroybook build
  • Loading branch information
ike-kottlowski committed Nov 22, 2023
1 parent a6e3d4d commit f1691a5
Show file tree
Hide file tree
Showing 14 changed files with 321 additions and 90 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ import { OrganizationPermissionsGuard } from "../../admin-console/organizations/
import { OrganizationRedirectGuard } from "../../admin-console/organizations/guards/org-redirect.guard";
import { OrganizationLayoutComponent } from "../../admin-console/organizations/layouts/organization-layout.component";
import { GroupsComponent } from "../../admin-console/organizations/manage/groups.component";
import { deepLinkGuard } from "../../auth/guards/deep-link.guard";
import { VaultModule } from "../../vault/org-vault/vault.module";

const routes: Routes = [
{
path: ":organizationId",
component: OrganizationLayoutComponent,
canActivate: [AuthGuard, OrganizationPermissionsGuard],
canActivate: [deepLinkGuard(), AuthGuard, OrganizationPermissionsGuard],
data: {
organizationPermissions: canAccessOrgAdmin,
},
Expand Down
3 changes: 0 additions & 3 deletions apps/web/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,12 @@ export class AppComponent implements OnDestroy, OnInit {
this.notificationsService.updateConnection(false);
break;
case "loggedOut":
this.routerService.setPreviousUrl(null);
this.notificationsService.updateConnection(false);
break;
case "unlocked":
this.notificationsService.updateConnection(false);
break;
case "authBlocked":
this.routerService.setPreviousUrl(message.url);
this.router.navigate(["/"]);
break;
case "logout":
Expand All @@ -132,7 +130,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.router.navigate(["lock"]);
break;
case "lockedUrl":
this.routerService.setPreviousUrl(message.url);
break;
case "syncStarted":
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ export class AcceptEmergencyComponent extends BaseAcceptComponent {
async authedHandler(qParams: Params): Promise<void> {
this.actionPromise = this.emergencyAccessService.accept(qParams.id, qParams.token);
await this.actionPromise;
await this.stateService.setEmergencyAccessInvitation(null);
this.platformUtilService.showToast(
"success",
this.i18nService.t("inviteAccepted"),
Expand All @@ -52,8 +51,5 @@ export class AcceptEmergencyComponent extends BaseAcceptComponent {
// Fix URL encoding of space issue with Angular
this.name = this.name.replace(/\+/g, " ");
}

// save the invitation to state so sso logins can find it later
await this.stateService.setEmergencyAccessInvitation(qParams);
}
}
190 changes: 190 additions & 0 deletions apps/web/src/app/auth/guards/deep-link.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { Component } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { Router, provideRouter } from "@angular/router";
import { RouterTestingHarness } from "@angular/router/testing";
import { MockProxy, mock } from "jest-mock-extended";

import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";

import { RouterService } from "../../core/router.service";

import { deepLinkGuard } from "./deep-link.guard";

@Component({
template: "",
})
export class GuardedRouteTestComponent {}

@Component({
template: "",
})
export class LockTestComponent {}

@Component({
template: "",
})
export class RedirectTestComponent {}

/**
* We are assuming the guard is always being called. We are creating routes using the
* RouterTestingHarness.
*
* when persisting a URL to storage we don't care wether or not the user is locked or logged out.
* We only care about where the user is going, and has been.
*
* We are testing the activatedComponent because we are testing that the guard redirects when a user is
* unlocked.
*/
describe("Deep Link Guard", () => {
let authService: MockProxy<AuthService>;
let routerService: MockProxy<RouterService>;
let routerHarness: RouterTestingHarness;

beforeEach(async () => {
authService = mock<AuthService>();
routerService = mock<RouterService>();
TestBed.configureTestingModule({
providers: [
{ provide: AuthService, useValue: authService },
{ provide: RouterService, useValue: routerService },
provideRouter([
{
path: "guarded-route",
component: GuardedRouteTestComponent,
canActivate: [deepLinkGuard()],
},
{
path: "lock-route",
component: LockTestComponent,
canActivate: [deepLinkGuard()],
},
{
path: "redirect-route",
component: RedirectTestComponent,
},
]),
],
});

routerHarness = await RouterTestingHarness.create();
});

// Story: User's vault times out
it('should persist routerService.previousUrl when routerService.previousUrl does not contain "lock"', async () => {
// Arrange
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Locked);
routerService.getPreviousUrl.mockReturnValue("/previous-url");

// Act
await routerHarness.navigateByUrl("/lock-route");

// Assert
expect(routerService.persistLoginRedirectUrl).toHaveBeenCalledWith("/previous-url");
});

// Story: User's vault times out and previousUrl contains "lock"
it('should not persist routerService.previousUrl when routerService.previousUrl contains "lock"', async () => {
// Arrange
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Locked);
routerService.getPreviousUrl.mockReturnValue("/lock");

// Act
await routerHarness.navigateByUrl("/lock-route");

// Assert
expect(routerService.persistLoginRedirectUrl).not.toHaveBeenCalled();
});

// Story: User's vault times out and previousUrl is undefined
it("should not persist routerService.previousUrl when routerService.previousUrl is undefined", async () => {
// Arrange
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Locked);
routerService.getPreviousUrl.mockReturnValue(undefined);

// Act
await routerHarness.navigateByUrl("/lock-route");

// Assert
expect(routerService.persistLoginRedirectUrl).not.toHaveBeenCalled();
});

// Story: User tries to deep link to a guarded route and is logged out
it('should persist currentUrl when currentUrl does not contain "lock"', async () => {
// Arrange
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut);

// Act
await routerHarness.navigateByUrl("/guarded-route?item=123");

// Assert
expect(routerService.persistLoginRedirectUrl).toHaveBeenCalledWith("/guarded-route?item=123");
});

// Story: User tries to deep link to "lock"
it('should not persist currentUrl if the currentUrl contains "lock"', async () => {
// Arrange
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut);

// Act
await routerHarness.navigateByUrl("/lock-route");

// Assert
expect(routerService.persistLoginRedirectUrl).not.toHaveBeenCalled();
});

// Story: User tries to deep link to a guarded route from the lock page
it("should persist currentUrl over previousUrl", async () => {
// Arrange
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Locked);
routerService.getPreviousUrl.mockReturnValue("/previous-url");

// Act
await routerHarness.navigateByUrl("/guarded-route?item=123");

// Assert
expect(routerService.persistLoginRedirectUrl).toHaveBeenCalledWith("/guarded-route?item=123");
});

// Story: user tries to deep link and is unlocked
it("should not persist any URL if the user is unlocked", async () => {
// Arrange
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);

// Act
await routerHarness.navigateByUrl("/guarded-route");

// Assert
expect(routerService.persistLoginRedirectUrl).not.toHaveBeenCalled();
});

// Story: User is redirected
it("should redirect user", async () => {
// Arrange
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
routerService.getAndClearLoginRedirectUrl.mockResolvedValue("/redirect-route");

// Act
const activatedComponent = await routerHarness.navigateByUrl("/guarded-route");

// Assert
expect(routerService.persistLoginRedirectUrl).not.toHaveBeenCalled();
expect(TestBed.inject(Router).url).toEqual("/redirect-route");
expect(activatedComponent).toBeInstanceOf(RedirectTestComponent);
});

// Story: User is not redirected
it("should not redirect user", async () => {
// Arrange
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
routerService.getAndClearLoginRedirectUrl.mockResolvedValue("");

// Act
const activatedComponent = await routerHarness.navigateByUrl("/guarded-route");

// Assert
expect(routerService.persistLoginRedirectUrl).not.toHaveBeenCalled();
expect(TestBed.inject(Router).url).toEqual("/guarded-route");
expect(activatedComponent).toBeInstanceOf(GuardedRouteTestComponent);
});
});
56 changes: 56 additions & 0 deletions apps/web/src/app/auth/guards/deep-link.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";

import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { Utils } from "@bitwarden/common/platform/misc/utils";

import { RouterService } from "../../core/router.service";

/**
* Guard to persist and apply deep links to handle users who are not unlocked.
* @returns returns true. If user is not Unlocked will store URL to state for redirect once
* user is unlocked/Authenticated.
*/
export function deepLinkGuard(): CanActivateFn {
return async (route, routerState) => {
// Inject Services
const authService = inject(AuthService);
const router = inject(Router);
const routerService = inject(RouterService);

// Fetch State
const currentUrl = routerState.url;
const transientPreviousUrl = routerService.getPreviousUrl();
const authStatus = await authService.getAuthStatus();

// Evaluate State
/** before anything else, check if the user is already unlocked. */
if (authStatus === AuthenticationStatus.Unlocked) {
const persistedPreLoginUrl = await routerService.getAndClearLoginRedirectUrl();
if (!Utils.isNullOrEmpty(persistedPreLoginUrl)) {
return router.navigateByUrl(persistedPreLoginUrl);
}
return true;
}
/**
* At this point the user is either `locked` or `loggedOut`, it doesn't matter.
* We opt to persist the currentUrl over the transient previousUrl. This supports
* the case where a user is locked out of their vault and they deep link from
* the "lock" page.
*
* When the user is locked out of their vault the currentUrl contains "lock" so it will
* not be persisted, the previousUrl will be persisted instead.
*/
if (isValidUrl(currentUrl)) {
await routerService.persistLoginRedirectUrl(currentUrl);
} else if (isValidUrl(transientPreviousUrl)) {
await routerService.persistLoginRedirectUrl(transientPreviousUrl);
}
return true;
};

function isValidUrl(url: string | null | undefined): boolean {
return !Utils.isNullOrEmpty(url) && !url?.toLocaleLowerCase().includes("lock");
}
}
7 changes: 0 additions & 7 deletions apps/web/src/app/auth/lock.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { DialogService } from "@bitwarden/components";

import { RouterService } from "../core";

@Component({
selector: "app-lock",
templateUrl: "lock.component.html",
Expand All @@ -35,7 +33,6 @@ export class LockComponent extends BaseLockComponent {
vaultTimeoutService: VaultTimeoutService,
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
environmentService: EnvironmentService,
private routerService: RouterService,
stateService: StateService,
apiService: ApiService,
logService: LogService,
Expand Down Expand Up @@ -72,10 +69,6 @@ export class LockComponent extends BaseLockComponent {
async ngOnInit() {
await super.ngOnInit();
this.onSuccessfulSubmit = async () => {
const previousUrl = this.routerService.getPreviousUrl();
if (previousUrl && previousUrl !== "/" && previousUrl.indexOf("lock") === -1) {
this.successRoute = previousUrl;
}
this.router.navigateByUrl(this.successRoute);
};
}
Expand Down
9 changes: 2 additions & 7 deletions apps/web/src/app/auth/login/login.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,8 @@ export class LoginComponent extends BaseLoginComponent implements OnInit {
}
}

const previousUrl = this.routerService.getPreviousUrl();
if (previousUrl) {
this.router.navigateByUrl(previousUrl);
} else {
this.loginService.clearValues();
this.router.navigate([this.successRoute]);
}
this.loginService.clearValues();
this.router.navigate([this.successRoute]);
}

goToHint() {
Expand Down
17 changes: 0 additions & 17 deletions apps/web/src/app/auth/sso.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction";
import { OrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/organization-domain-sso-details.response";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
import { HttpStatusCode } from "@bitwarden/common/enums";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
Expand Down Expand Up @@ -39,7 +38,6 @@ export class SsoComponent extends BaseSsoComponent {
passwordGenerationService: PasswordGenerationServiceAbstraction,
logService: LogService,
private orgDomainApiService: OrgDomainApiServiceAbstraction,
private loginService: LoginService,
private validationService: ValidationService,
configService: ConfigServiceAbstraction
) {
Expand All @@ -64,21 +62,6 @@ export class SsoComponent extends BaseSsoComponent {
async ngOnInit() {
super.ngOnInit();

// if we have an emergency access invite, redirect to emergency access
const emergencyAccessInvite = await this.stateService.getEmergencyAccessInvitation();
if (emergencyAccessInvite != null) {
this.onSuccessfulLoginNavigate = async () => {
this.router.navigate(["/accept-emergency"], {
queryParams: {
id: emergencyAccessInvite.id,
name: emergencyAccessInvite.name,
email: emergencyAccessInvite.email,
token: emergencyAccessInvite.token,
},
});
};
}

// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
if (qParams.identifier != null) {
Expand Down
Loading

0 comments on commit f1691a5

Please sign in to comment.