diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 0a30b4fdbaac..1e343fa33903 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2254,6 +2254,9 @@ "approveWithMasterPassword": { "message": "Approve with master password" }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" diff --git a/apps/browser/src/auth/popup/services/index.ts b/apps/browser/src/auth/popup/services/index.ts index 06bfe0009bd2..63563f61fd9d 100644 --- a/apps/browser/src/auth/popup/services/index.ts +++ b/apps/browser/src/auth/popup/services/index.ts @@ -1,2 +1 @@ -export { LockGuardService } from "./lock-guard.service"; export { UnauthGuardService } from "./unauth-guard.service"; diff --git a/apps/browser/src/auth/popup/services/lock-guard.service.ts b/apps/browser/src/auth/popup/services/lock-guard.service.ts deleted file mode 100644 index ef6ebc73aca9..000000000000 --- a/apps/browser/src/auth/popup/services/lock-guard.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from "@angular/core"; - -import { LockGuard as BaseLockGuardService } from "@bitwarden/angular/auth/guards/lock.guard"; - -@Injectable() -export class LockGuardService extends BaseLockGuardService { - protected homepage = "tabs/current"; -} diff --git a/apps/browser/src/auth/popup/sso.component.ts b/apps/browser/src/auth/popup/sso.component.ts index a35adbb73922..d3607beb0ac0 100644 --- a/apps/browser/src/auth/popup/sso.component.ts +++ b/apps/browser/src/auth/popup/sso.component.ts @@ -1,9 +1,9 @@ -import { Component } from "@angular/core"; +import { Component, Inject } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { SsoComponent as BaseSsoComponent } from "@bitwarden/angular/auth/components/sso.component"; +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; @@ -37,7 +37,7 @@ export class SsoComponent extends BaseSsoComponent { environmentService: EnvironmentService, logService: LogService, configService: ConfigServiceAbstraction, - private vaultTimeoutService: VaultTimeoutService + @Inject(WINDOW) private win: Window ) { super( authService, @@ -67,8 +67,11 @@ export class SsoComponent extends BaseSsoComponent { BrowserApi.reloadOpenWindows(); } - const thisWindow = window.open("", "_self"); - thisWindow.close(); + this.win.close(); + }; + + super.onSuccessfulLoginTdeNavigate = async () => { + this.win.close(); }; } } diff --git a/apps/browser/src/auth/popup/two-factor.component.ts b/apps/browser/src/auth/popup/two-factor.component.ts index aeaaaf747405..05d5da886eda 100644 --- a/apps/browser/src/auth/popup/two-factor.component.ts +++ b/apps/browser/src/auth/popup/two-factor.component.ts @@ -74,6 +74,10 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { this.loginService.clearValues(); return syncService.fullSync(true); }; + + super.onSuccessfulLoginTdeNavigate = async () => { + this.win.close(); + }; super.successRoute = "/tabs/vault"; // FIXME: Chromium 110 has broken WebAuthn support in extensions via an iframe this.webAuthnNewTab = true; diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index a43984319a3b..dab79e1f9dc3 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -2,11 +2,13 @@ import { Injectable, NgModule } from "@angular/core"; import { ActivatedRouteSnapshot, RouteReuseStrategy, RouterModule, Routes } from "@angular/router"; import { AuthGuard } from "@bitwarden/angular/auth/guards/auth.guard"; -import { LockGuard } from "@bitwarden/angular/auth/guards/lock.guard"; +import { lockGuard } from "@bitwarden/angular/auth/guards/lock.guard"; +import { tdeDecryptionRequiredGuard } from "@bitwarden/angular/auth/guards/tde-decryption-required.guard"; import { UnauthGuard } from "@bitwarden/angular/auth/guards/unauth.guard"; import { canAccessFeature } from "@bitwarden/angular/guard/feature-flag.guard"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { redirectGuard } from "../../../../libs/angular/src/auth/guards/redirect.guard"; import { EnvironmentComponent } from "../auth/popup/environment.component"; import { HintComponent } from "../auth/popup/hint.component"; import { HomeComponent } from "../auth/popup/home.component"; @@ -52,8 +54,9 @@ import { TabsComponent } from "./tabs.component"; const routes: Routes = [ { path: "", - redirectTo: "home", pathMatch: "full", + children: [], // Children lets us have an empty component. + canActivate: [redirectGuard({ loggedIn: "/tabs/vault", loggedOut: "/home", locked: "/lock" })], }, { path: "vault", @@ -87,7 +90,7 @@ const routes: Routes = [ { path: "lock", component: LockComponent, - canActivate: [LockGuard], + canActivate: [lockGuard()], data: { state: "lock" }, }, { @@ -105,7 +108,10 @@ const routes: Routes = [ { path: "login-initiated", component: LoginDecryptionOptionsComponent, - canActivate: [LockGuard, canAccessFeature(FeatureFlag.TrustedDeviceEncryption)], + canActivate: [ + tdeDecryptionRequiredGuard(), + canAccessFeature(FeatureFlag.TrustedDeviceEncryption), + ], }, { path: "sso", diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 53bf86cfedc5..dd6b32bedab5 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -1,6 +1,5 @@ import { APP_INITIALIZER, LOCALE_ID, NgModule } from "@angular/core"; -import { LockGuard as BaseLockGuardService } from "@bitwarden/angular/auth/guards/lock.guard"; import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards/unauth.guard"; import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; import { MEMORY_STORAGE, SECURE_STORAGE } from "@bitwarden/angular/services/injection-tokens"; @@ -84,7 +83,7 @@ import { VaultExportServiceAbstraction } from "@bitwarden/exporter/vault-export" import { BrowserOrganizationService } from "../../admin-console/services/browser-organization.service"; import { BrowserPolicyService } from "../../admin-console/services/browser-policy.service"; -import { LockGuardService, UnauthGuardService } from "../../auth/popup/services"; +import { UnauthGuardService } from "../../auth/popup/services"; import { AutofillService } from "../../autofill/services/abstractions/autofill.service"; import MainBackground from "../../background/main.background"; import { Account } from "../../models/account"; @@ -144,7 +143,6 @@ function getBgService(service: keyof MainBackground) { deps: [InitService], multi: true, }, - { provide: BaseLockGuardService, useClass: LockGuardService }, { provide: BaseUnauthGuardService, useClass: UnauthGuardService }, { provide: PopupUtilsService, useFactory: () => new PopupUtilsService(isPrivateMode) }, { diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 6b0ec003e7b1..4a8ad3b8181d 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -2,7 +2,9 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { AuthGuard } from "@bitwarden/angular/auth/guards/auth.guard"; -import { LockGuard } from "@bitwarden/angular/auth/guards/lock.guard"; +import { lockGuard } from "@bitwarden/angular/auth/guards/lock.guard"; +import { redirectGuard } from "@bitwarden/angular/auth/guards/redirect.guard"; +import { tdeDecryptionRequiredGuard } from "@bitwarden/angular/auth/guards/tde-decryption-required.guard"; import { canAccessFeature } from "@bitwarden/angular/guard/feature-flag.guard"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -24,11 +26,16 @@ import { VaultComponent } from "../vault/app/vault/vault.component"; import { SendComponent } from "./tools/send/send.component"; const routes: Routes = [ - { path: "", redirectTo: "/vault", pathMatch: "full" }, + { + path: "", + pathMatch: "full", + children: [], // Children lets us have an empty component. + canActivate: [redirectGuard({ loggedIn: "/vault", loggedOut: "/login", locked: "/lock" })], + }, { path: "lock", component: LockComponent, - canActivate: [LockGuard], + canActivate: [lockGuard()], }, { path: "login", @@ -47,7 +54,10 @@ const routes: Routes = [ { path: "login-initiated", component: LoginDecryptionOptionsComponent, - canActivate: [LockGuard, canAccessFeature(FeatureFlag.TrustedDeviceEncryption)], + canActivate: [ + tdeDecryptionRequiredGuard(), + canAccessFeature(FeatureFlag.TrustedDeviceEncryption), + ], }, { path: "register", component: RegisterComponent }, { diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index ff8f98eec834..5ae882abb2e3 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2276,6 +2276,9 @@ "region": { "message": "Region" }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" diff --git a/apps/web/src/app/guards/home.guard.ts b/apps/web/src/app/guards/home.guard.ts deleted file mode 100644 index bbcf3448c7c8..000000000000 --- a/apps/web/src/app/guards/home.guard.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Injectable } from "@angular/core"; -import { ActivatedRouteSnapshot, CanActivate, Router } from "@angular/router"; - -import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; -import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; - -@Injectable({ - providedIn: "root", -}) -export class HomeGuard implements CanActivate { - constructor(private router: Router, private authService: AuthService) {} - - async canActivate(route: ActivatedRouteSnapshot) { - const authStatus = await this.authService.getAuthStatus(); - - if (authStatus === AuthenticationStatus.LoggedOut) { - return this.router.createUrlTree(["/login"], { queryParams: route.queryParams }); - } - if (authStatus === AuthenticationStatus.Locked) { - return this.router.createUrlTree(["/lock"], { queryParams: route.queryParams }); - } - return this.router.createUrlTree(["/vault"], { queryParams: route.queryParams }); - } -} diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 08fbebaac0b9..fdf31b7c0eee 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -2,7 +2,9 @@ import { NgModule } from "@angular/core"; import { Route, RouterModule, Routes } from "@angular/router"; import { AuthGuard } from "@bitwarden/angular/auth/guards/auth.guard"; -import { LockGuard } from "@bitwarden/angular/auth/guards/lock.guard"; +import { lockGuard } from "@bitwarden/angular/auth/guards/lock.guard"; +import { redirectGuard } from "@bitwarden/angular/auth/guards/redirect.guard"; +import { tdeDecryptionRequiredGuard } from "@bitwarden/angular/auth/guards/tde-decryption-required.guard"; import { UnauthGuard } from "@bitwarden/angular/auth/guards/unauth.guard"; import { canAccessFeature } from "@bitwarden/angular/guard/feature-flag.guard"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -34,7 +36,6 @@ import { UpdatePasswordComponent } from "./auth/update-password.component"; import { UpdateTempPasswordComponent } from "./auth/update-temp-password.component"; import { VerifyEmailTokenComponent } from "./auth/verify-email-token.component"; import { VerifyRecoverDeleteComponent } from "./auth/verify-recover-delete.component"; -import { HomeGuard } from "./guards/home.guard"; import { FrontendLayoutComponent } from "./layouts/frontend-layout.component"; import { UserLayoutComponent } from "./layouts/user-layout.component"; import { ReportsModule } from "./reports"; @@ -59,7 +60,7 @@ const routes: Routes = [ path: "", pathMatch: "full", children: [], // Children lets us have an empty component. - canActivate: [HomeGuard], // Redirects either to vault, login or lock page. + canActivate: [redirectGuard({ loggedIn: "/vault", loggedOut: "/login", locked: "/lock" })], }, { path: "login", component: LoginComponent, canActivate: [UnauthGuard] }, { @@ -76,7 +77,10 @@ const routes: Routes = [ { path: "login-initiated", component: LoginDecryptionOptionsComponent, - canActivate: [LockGuard, canAccessFeature(FeatureFlag.TrustedDeviceEncryption)], + canActivate: [ + tdeDecryptionRequiredGuard(), + canAccessFeature(FeatureFlag.TrustedDeviceEncryption), + ], }, { path: "register", @@ -109,7 +113,7 @@ const routes: Routes = [ { path: "lock", component: LockComponent, - canActivate: [LockGuard], + canActivate: [lockGuard()], }, { path: "verify-email", component: VerifyEmailTokenComponent }, { diff --git a/libs/angular/src/auth/components/base-login-decryption-options.component.ts b/libs/angular/src/auth/components/base-login-decryption-options.component.ts index a96d7c78d5d8..001a113e1aba 100644 --- a/libs/angular/src/auth/components/base-login-decryption-options.component.ts +++ b/libs/angular/src/auth/components/base-login-decryption-options.component.ts @@ -3,7 +3,6 @@ import { FormBuilder, FormControl } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom, - map, switchMap, Subject, catchError, @@ -11,6 +10,8 @@ import { of, finalize, takeUntil, + defer, + throwError, } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -106,11 +107,9 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { // We are dealing with a new account if: // - User does not have admin approval (i.e. has not enrolled into admin reset) // - AND does not have a master password - this.platformUtilsService.showToast( - "success", - null, - this.i18nService.t("accountSuccessfullyCreated") - ); + + // TODO: discuss how this doesn't make any sense to show here + this.loadNewUserData(); } else { this.loadUntrustedDeviceData(accountDecryptionOptions); @@ -140,14 +139,19 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { } async loadNewUserData() { - const autoEnrollStatus$ = this.activatedRoute.queryParamMap.pipe( - map((params) => params.get("identifier")), - switchMap((identifier) => { - if (identifier == null) { - return of(null); + const autoEnrollStatus$ = defer(() => + this.stateService.getUserSsoOrganizationIdentifier() + ).pipe( + switchMap((organizationIdentifier) => { + if (organizationIdentifier == undefined) { + return throwError(() => new Error(this.i18nService.t("ssoIdentifierRequired"))); } - return from(this.organizationApiService.getAutoEnrollStatus(identifier)); + return from(this.organizationApiService.getAutoEnrollStatus(organizationIdentifier)); + }), + catchError((err: unknown) => { + this.validationService.showError(err); + return of(undefined); }) ); @@ -225,7 +229,7 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { async approveWithMasterPassword() { await this.deviceTrustCryptoService.setShouldTrustDevice(this.rememberDevice.value); - this.router.navigate(["/lock"]); + this.router.navigate(["/lock"], { queryParams: { from: "login-initiated" } }); } async createUser() { @@ -240,6 +244,12 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString); await this.apiService.postAccountKeys(keysRequest); + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("accountSuccessfullyCreated") + ); + await this.passwordResetEnrollmentService.enroll(this.data.organizationId); if (this.rememberDeviceForm.value.rememberDevice) { diff --git a/libs/angular/src/auth/components/sso.component.spec.ts b/libs/angular/src/auth/components/sso.component.spec.ts index d0eb11bc5eed..68c41b661190 100644 --- a/libs/angular/src/auth/components/sso.component.spec.ts +++ b/libs/angular/src/auth/components/sso.component.spec.ts @@ -73,6 +73,7 @@ describe("SsoComponent", () => { let mockOnSuccessfulLoginTwoFactorNavigate: jest.Mock; let mockOnSuccessfulLoginChangePasswordNavigate: jest.Mock; let mockOnSuccessfulLoginForceResetNavigate: jest.Mock; + let mockOnSuccessfulLoginTdeNavigate: jest.Mock; let mockAcctDecryptionOpts: { noMasterPassword: AccountDecryptionOptions; @@ -118,6 +119,7 @@ describe("SsoComponent", () => { mockOnSuccessfulLoginTwoFactorNavigate = jest.fn(); mockOnSuccessfulLoginChangePasswordNavigate = jest.fn(); mockOnSuccessfulLoginForceResetNavigate = jest.fn(); + mockOnSuccessfulLoginTdeNavigate = jest.fn(); mockAcctDecryptionOpts = { noMasterPassword: new AccountDecryptionOptions({ @@ -381,16 +383,29 @@ describe("SsoComponent", () => { mockAuthService.logIn.mockResolvedValue(authResult); }); - it("navigates to the component's defined trusted device encryption route when login is successful", async () => { + it("navigates to the component's defined trusted device encryption route when login is successful and no callback is defined", async () => { await _component.logIn(code, codeVerifier, orgIdFromState); expect(mockAuthService.logIn).toHaveBeenCalledTimes(1); expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith([_component.trustedDeviceEncRoute], { - queryParams: { - identifier: orgIdFromState, - }, - }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + [_component.trustedDeviceEncRoute], + undefined + ); + expect(mockLogService.error).not.toHaveBeenCalled(); + }); + + it("calls onSuccessfulLoginTdeNavigate instead of router.navigate when the callback is defined", async () => { + mockOnSuccessfulLoginTdeNavigate = jest.fn().mockResolvedValue(null); + component.onSuccessfulLoginTdeNavigate = mockOnSuccessfulLoginTdeNavigate; + + await _component.logIn(code, codeVerifier, orgIdFromState); + + expect(mockAuthService.logIn).toHaveBeenCalledTimes(1); + + expect(mockOnSuccessfulLoginTdeNavigate).toHaveBeenCalledTimes(1); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); expect(mockLogService.error).not.toHaveBeenCalled(); }); }); diff --git a/libs/angular/src/auth/components/sso.component.ts b/libs/angular/src/auth/components/sso.component.ts index b18920492c0a..5fafd0f6c6d0 100644 --- a/libs/angular/src/auth/components/sso.component.ts +++ b/libs/angular/src/auth/components/sso.component.ts @@ -28,11 +28,12 @@ export class SsoComponent { formPromise: Promise; initiateSsoFormPromise: Promise; - onSuccessfulLogin: () => Promise; - onSuccessfulLoginNavigate: () => Promise; - onSuccessfulLoginTwoFactorNavigate: () => Promise; - onSuccessfulLoginChangePasswordNavigate: () => Promise; - onSuccessfulLoginForceResetNavigate: () => Promise; + onSuccessfulLogin: () => Promise; + onSuccessfulLoginNavigate: () => Promise; + onSuccessfulLoginTwoFactorNavigate: () => Promise; + onSuccessfulLoginChangePasswordNavigate: () => Promise; + onSuccessfulLoginForceResetNavigate: () => Promise; + onSuccessfulLoginTdeNavigate: () => Promise; protected twoFactorRoute = "2fa"; protected successRoute = "lock"; @@ -73,11 +74,12 @@ export class SsoComponent { state != null && this.checkState(state, qParams.state) ) { - await this.logIn( - qParams.code, - codeVerifier, - this.getOrgIdentifierFromState(qParams.state) - ); + // We are not using a query param to pass org identifier around specifically + // for the browser SSO case when it needs it on extension open after SSO success + // on the TDE login decryption options component + const ssoOrganizationIdentifier = this.getOrgIdentifierFromState(qParams.state); + await this.logIn(qParams.code, codeVerifier, ssoOrganizationIdentifier); + await this.stateService.setUserSsoOrganizationIdentifier(ssoOrganizationIdentifier); } } else if ( qParams.clientId != null && @@ -276,13 +278,12 @@ export class SsoComponent { return await this.handleForcePasswordReset(orgIdentifier); } - // Navigate to TDE page (if user was on trusted device and TDE has decrypted - // their user key, the lock guard will redirect them to the vault) - this.router.navigate([this.trustedDeviceEncRoute], { - queryParams: { - identifier: orgIdentifier, - }, - }); + this.navigateViaCallbackOrRoute( + this.onSuccessfulLoginTdeNavigate, + // Navigate to TDE page (if user was on trusted device and TDE has decrypted + // their user key, the login-initiated guard will redirect them to the vault) + [this.trustedDeviceEncRoute] + ); } private async handleChangePasswordRequired(orgIdentifier: string) { diff --git a/libs/angular/src/auth/components/two-factor.component.spec.ts b/libs/angular/src/auth/components/two-factor.component.spec.ts index 847e5b3a40d3..470c9d4eb7c5 100644 --- a/libs/angular/src/auth/components/two-factor.component.spec.ts +++ b/libs/angular/src/auth/components/two-factor.component.spec.ts @@ -426,15 +426,23 @@ describe("TwoFactorComponent", () => { mockAuthService.logInTwoFactor.mockResolvedValue(authResult); }); - it("navigates to the component's defined trusted device encryption route when login is successful", async () => { + it("navigates to the component's defined trusted device encryption route when login is successful and onSuccessfulLoginTdeNavigate is undefined", async () => { await component.doSubmit(); expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith([_component.trustedDeviceEncRoute], { - queryParams: { - identifier: component.orgIdentifier, - }, - }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + [_component.trustedDeviceEncRoute], + undefined + ); + }); + + it("calls onSuccessfulLoginTdeNavigate instead of router.navigate when the callback is defined", async () => { + component.onSuccessfulLoginTdeNavigate = jest.fn().mockResolvedValue(undefined); + + await component.doSubmit(); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + expect(component.onSuccessfulLoginTdeNavigate).toHaveBeenCalled(); }); }); }); diff --git a/libs/angular/src/auth/components/two-factor.component.ts b/libs/angular/src/auth/components/two-factor.component.ts index ab857c9de1d4..df47211deb42 100644 --- a/libs/angular/src/auth/components/two-factor.component.ts +++ b/libs/angular/src/auth/components/two-factor.component.ts @@ -45,8 +45,9 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI formPromise: Promise; emailPromise: Promise; orgIdentifier: string = null; - onSuccessfulLogin: () => Promise; - onSuccessfulLoginNavigate: () => Promise; + onSuccessfulLogin: () => Promise; + onSuccessfulLoginNavigate: () => Promise; + onSuccessfulLoginTdeNavigate: () => Promise; protected loginRoute = "login"; @@ -300,13 +301,12 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI return await this.handleForcePasswordReset(orgIdentifier); } - // Navigate to TDE page (if user was on trusted device and TDE has decrypted - // their user key, the lock guard will redirect them to the vault) - this.router.navigate([this.trustedDeviceEncRoute], { - queryParams: { - identifier: orgIdentifier, - }, - }); + this.navigateViaCallbackOrRoute( + this.onSuccessfulLoginTdeNavigate, + // Navigate to TDE page (if user was on trusted device and TDE has decrypted + // their user key, the login-initiated guard will redirect them to the vault) + [this.trustedDeviceEncRoute] + ); } private async handleChangePasswordRequired(orgIdentifier: string) { diff --git a/libs/angular/src/auth/guards/lock.guard.ts b/libs/angular/src/auth/guards/lock.guard.ts index b4cc01dc1693..68c2a7eb5c3d 100644 --- a/libs/angular/src/auth/guards/lock.guard.ts +++ b/libs/angular/src/auth/guards/lock.guard.ts @@ -1,25 +1,60 @@ -import { Injectable } from "@angular/core"; -import { CanActivate, Router } from "@angular/router"; +import { inject } from "@angular/core"; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, +} from "@angular/router"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -@Injectable() -export class LockGuard implements CanActivate { - protected homepage = "vault"; - protected loginpage = "login"; - constructor(private authService: AuthService, private router: Router) {} +/** + * Only allow access to this route if the vault is locked. + * If TDE is enabled then the user must also have had a user key at some point. + * Otherwise redirect to root. + */ +export function lockGuard(): CanActivateFn { + return async ( + activatedRouteSnapshot: ActivatedRouteSnapshot, + routerStateSnapshot: RouterStateSnapshot + ) => { + const authService = inject(AuthService); + const cryptoService = inject(CryptoService); + const deviceTrustCryptoService = inject(DeviceTrustCryptoServiceAbstraction); + const router = inject(Router); + const userVerificationService = inject(UserVerificationService); - async canActivate() { - const authStatus = await this.authService.getAuthStatus(); + const authStatus = await authService.getAuthStatus(); + if (authStatus !== AuthenticationStatus.Locked) { + return router.createUrlTree(["/"]); + } + + // User is authN and in locked state. + + const tdeEnabled = await deviceTrustCryptoService.supportsDeviceTrust(); - if (authStatus === AuthenticationStatus.Locked) { + // Create special exception which allows users to go from the login-initiated page to the lock page for the approve w/ MP flow + // The MP check is necessary to prevent direct manual navigation from other locked state pages for users who don't have a MP + if ( + activatedRouteSnapshot.queryParams["from"] === "login-initiated" && + tdeEnabled && + (await userVerificationService.hasMasterPassword()) + ) { return true; } - const redirectUrl = - authStatus === AuthenticationStatus.LoggedOut ? this.loginpage : this.homepage; + // If authN user with TDE directly navigates to lock, kick them upwards so redirect guard can + // properly route them to the login decryption options component. + const userKey = await cryptoService.getUserKey(); + + if (tdeEnabled && !userKey) { + return router.createUrlTree(["/"]); + } - return this.router.createUrlTree([redirectUrl]); - } + return true; + }; } diff --git a/libs/angular/src/auth/guards/redirect.guard.ts b/libs/angular/src/auth/guards/redirect.guard.ts new file mode 100644 index 000000000000..3143eaa31646 --- /dev/null +++ b/libs/angular/src/auth/guards/redirect.guard.ts @@ -0,0 +1,61 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; + +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; + +export interface RedirectRoutes { + loggedIn: string; + loggedOut: string; + locked: string; + notDecrypted: string; +} + +const defaultRoutes: RedirectRoutes = { + loggedIn: "/vault", + loggedOut: "/login", + locked: "/lock", + notDecrypted: "/login-initiated", +}; + +/** + * Guard that consolidates all redirection logic, should be applied to root route. + */ +export function redirectGuard(overrides: Partial = {}): CanActivateFn { + const routes = { ...defaultRoutes, ...overrides }; + return async (route) => { + const authService = inject(AuthService); + const cryptoService = inject(CryptoService); + const deviceTrustCryptoService = inject(DeviceTrustCryptoServiceAbstraction); + const router = inject(Router); + + const authStatus = await authService.getAuthStatus(); + + if (authStatus === AuthenticationStatus.LoggedOut) { + return router.createUrlTree([routes.loggedOut], { queryParams: route.queryParams }); + } + + if (authStatus === AuthenticationStatus.Unlocked) { + return router.createUrlTree([routes.loggedIn], { queryParams: route.queryParams }); + } + + // If TDE is enabled and the user hasn't decrypted yet, then redirect to the + // login decryption options component. This is especially useful for the + // Browser Post SSO open popup flow where the user is AuthN and should see + // decryption options and not the lock screen. + const tdeEnabled = await deviceTrustCryptoService.supportsDeviceTrust(); + const userKey = await cryptoService.getUserKey(); + + if (authStatus === AuthenticationStatus.Locked && tdeEnabled && !userKey) { + return router.createUrlTree([routes.notDecrypted], { queryParams: route.queryParams }); + } + + if (authStatus === AuthenticationStatus.Locked) { + return router.createUrlTree([routes.locked], { queryParams: route.queryParams }); + } + + return router.createUrlTree(["/"]); + }; +} diff --git a/libs/angular/src/auth/guards/tde-decryption-required.guard.ts b/libs/angular/src/auth/guards/tde-decryption-required.guard.ts new file mode 100644 index 000000000000..84feb77b7f3e --- /dev/null +++ b/libs/angular/src/auth/guards/tde-decryption-required.guard.ts @@ -0,0 +1,35 @@ +import { inject } from "@angular/core"; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, + CanActivateFn, +} from "@angular/router"; + +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; + +/** + * Only allow access to this route if the vault is locked and has never been decrypted. + * Otherwise redirect to root. + */ +export function tdeDecryptionRequiredGuard(): CanActivateFn { + return async (_: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + const authService = inject(AuthService); + const cryptoService = inject(CryptoService); + const deviceTrustCryptoService = inject(DeviceTrustCryptoServiceAbstraction); + const router = inject(Router); + + const authStatus = await authService.getAuthStatus(); + const tdeEnabled = await deviceTrustCryptoService.supportsDeviceTrust(); + const userKey = await cryptoService.getUserKey(); + + if (authStatus !== AuthenticationStatus.Locked || !tdeEnabled || userKey) { + return router.createUrlTree(["/"]); + } + + return true; + }; +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 47444e7dd7cf..0ab7ae25458a 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -154,7 +154,6 @@ import { } from "@bitwarden/exporter/vault-export"; import { AuthGuard } from "../auth/guards/auth.guard"; -import { LockGuard } from "../auth/guards/lock.guard"; import { UnauthGuard } from "../auth/guards/unauth.guard"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; import { BroadcasterService } from "../platform/services/broadcaster.service"; @@ -182,7 +181,6 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; providers: [ AuthGuard, UnauthGuard, - LockGuard, ModalService, { provide: WINDOW, useValue: window }, { diff --git a/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts b/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts index 97580c10f137..63632655e749 100644 --- a/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts @@ -20,4 +20,6 @@ export abstract class DeviceTrustCryptoServiceAbstraction { deviceKey?: DeviceKey ) => Promise; rotateDevicesTrust: (newUserKey: UserKey, masterPasswordHash: string) => Promise; + + supportsDeviceTrust: () => Promise; } diff --git a/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts b/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts index a1ed88c7185e..e7e1349eae54 100644 --- a/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts +++ b/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts @@ -207,4 +207,9 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac return null; } } + + async supportsDeviceTrust(): Promise { + const decryptionOptions = await this.stateService.getAccountDecryptionOptions(); + return decryptionOptions?.trustedDeviceOption != null; + } } diff --git a/libs/common/src/platform/abstractions/crypto.service.ts b/libs/common/src/platform/abstractions/crypto.service.ts index be72f977149d..847b657130c7 100644 --- a/libs/common/src/platform/abstractions/crypto.service.ts +++ b/libs/common/src/platform/abstractions/crypto.service.ts @@ -29,7 +29,6 @@ export abstract class CryptoService { * (such as auto, biometrics, or pin) */ refreshAdditionalKeys: () => Promise; - /** * Retrieves the user key * @param userId The desired user diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 9623c02b1723..8d538458c19b 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -475,6 +475,11 @@ export abstract class StateService { setSsoOrganizationIdentifier: (value: string, options?: StorageOptions) => Promise; getSsoState: (options?: StorageOptions) => Promise; setSsoState: (value: string, options?: StorageOptions) => Promise; + getUserSsoOrganizationIdentifier: (options?: StorageOptions) => Promise; + setUserSsoOrganizationIdentifier: ( + value: string | null, + options?: StorageOptions + ) => Promise; getTheme: (options?: StorageOptions) => Promise; setTheme: (value: ThemeType, options?: StorageOptions) => Promise; getTwoFactorToken: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 0577bbc40535..1c99b7e00223 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -363,6 +363,25 @@ export class AccountDecryptionOptions { } } +export class LoginState { + ssoOrganizationIdentifier?: string; + + constructor(init?: Partial) { + if (init) { + Object.assign(this, init); + } + } + + static fromJSON(obj: Jsonify): LoginState { + if (obj == null) { + return null; + } + + const loginState = Object.assign(new LoginState(), obj); + return loginState; + } +} + export class Account { data?: AccountData = new AccountData(); keys?: AccountKeys = new AccountKeys(); @@ -370,6 +389,7 @@ export class Account { settings?: AccountSettings = new AccountSettings(); tokens?: AccountTokens = new AccountTokens(); decryptionOptions?: AccountDecryptionOptions = new AccountDecryptionOptions(); + loginState?: LoginState = new LoginState(); adminAuthRequest?: AdminAuthRequestStorable = null; constructor(init: Partial) { @@ -398,6 +418,10 @@ export class Account { ...new AccountDecryptionOptions(), ...init?.decryptionOptions, }, + loginState: { + ...new LoginState(), + ...init?.loginState, + }, adminAuthRequest: init?.adminAuthRequest ? new AdminAuthRequestStorable(init?.adminAuthRequest) : null, @@ -415,6 +439,7 @@ export class Account { settings: AccountSettings.fromJSON(json?.settings), tokens: AccountTokens.fromJSON(json?.tokens), decryptionOptions: AccountDecryptionOptions.fromJSON(json?.decryptionOptions), + loginState: LoginState.fromJSON(json?.loginState), adminAuthRequest: AdminAuthRequestStorable.fromJSON(json?.adminAuthRequest), }); } diff --git a/libs/common/src/platform/services/crypto.service.ts b/libs/common/src/platform/services/crypto.service.ts index 57e4d519d202..b004d8291e2e 100644 --- a/libs/common/src/platform/services/crypto.service.ts +++ b/libs/common/src/platform/services/crypto.service.ts @@ -720,7 +720,7 @@ export class CryptoService implements CryptoServiceAbstraction { const randomBytes = await this.cryptoFunctionService.randomBytes(64); const userKey = new SymmetricCryptoKey(randomBytes) as UserKey; const [publicKey, privateKey] = await this.makeKeyPair(userKey); - await this.stateService.setUserKey(userKey); + await this.setUserKey(userKey); await this.stateService.setEncryptedPrivateKey(privateKey.encryptedString); return { diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 0ad3e9ea3109..4e642ead4452 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -2563,6 +2563,26 @@ export class StateService< ); } + async getUserSsoOrganizationIdentifier(options?: StorageOptions): Promise { + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) + )?.loginState?.ssoOrganizationIdentifier; + } + + async setUserSsoOrganizationIdentifier( + value: string | null, + options?: StorageOptions + ): Promise { + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + account.loginState.ssoOrganizationIdentifier = value; + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + } + async getTheme(options?: StorageOptions): Promise { return ( await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))