From 0b861f4d0ce8627f625de379fd435df6fa994bac Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Tue, 25 Jul 2023 19:25:00 -0400 Subject: [PATCH] Auth/pm 1050/pm 1051/remaining tde approval flows (#5864) --- apps/browser/src/_locales/en/messages.json | 21 + .../auth-request-crypto-service.factory.ts | 29 ++ .../service-factories/auth-service.factory.ts | 16 +- .../device-trust-crypto-service.factory.ts | 16 +- .../popup/login-with-device.component.html | 63 ++- .../auth/popup/login-with-device.component.ts | 10 +- .../browser/src/background/main.background.ts | 12 +- apps/browser/src/popup/app-routing.module.ts | 6 + apps/cli/src/bw.ts | 12 +- apps/desktop/src/app/app-routing.module.ts | 4 + .../login/login-with-device.component.html | 86 ++-- .../auth/login/login-with-device.component.ts | 23 +- apps/desktop/src/locales/en/messages.json | 21 + .../login/login-with-device.component.html | 91 ++-- .../auth/login/login-with-device.component.ts | 10 +- apps/web/src/app/oss-routing.module.ts | 5 + apps/web/src/locales/en/messages.json | 21 + ...base-login-decryption-options.component.ts | 24 +- .../src/auth/components/lock.component.ts | 7 +- .../components/login-with-device.component.ts | 439 ++++++++++++++---- .../src/auth/components/sso.component.ts | 2 - .../src/services/jslib-services.module.ts | 10 + libs/common/src/abstractions/api.service.ts | 1 + ...auth-request-crypto.service.abstraction.ts | 24 + ...device-trust-crypto.service.abstraction.ts | 2 + ...rd-reset-enrollment.service.abstraction.ts | 5 + .../src/auth/enums/auth-request-type.ts | 1 + .../auth/login-strategies/login.strategy.ts | 4 + .../passwordless-login.strategy.spec.ts | 63 ++- .../passwordless-login.strategy.ts | 27 +- .../sso-login.strategy.spec.ts | 10 +- .../login-strategies/sso-login.strategy.ts | 90 +++- .../models/domain/admin-auth-req-storable.ts | 41 ++ .../auth/models/domain/log-in-credentials.ts | 7 +- ...h-request-crypto.service.implementation.ts | 81 ++++ .../auth-request-crypto.service.spec.ts | 169 +++++++ libs/common/src/auth/services/auth.service.ts | 47 +- ...ice-trust-crypto.service.implementation.ts | 28 +- .../device-trust-crypto.service.spec.ts | 47 +- ...-enrollment.service.implementation.spec.ts | 33 ++ ...reset-enrollment.service.implementation.ts | 10 + .../platform/abstractions/state.service.ts | 6 + .../src/platform/models/domain/account.ts | 6 + .../src/platform/services/state.service.ts | 35 +- libs/common/src/services/api.service.ts | 4 + 45 files changed, 1380 insertions(+), 289 deletions(-) create mode 100644 apps/browser/src/auth/background/service-factories/auth-request-crypto-service.factory.ts create mode 100644 libs/common/src/auth/abstractions/auth-request-crypto.service.abstraction.ts create mode 100644 libs/common/src/auth/models/domain/admin-auth-req-storable.ts create mode 100644 libs/common/src/auth/services/auth-request-crypto.service.implementation.ts create mode 100644 libs/common/src/auth/services/auth-request-crypto.service.spec.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index c7b95b1515..1d31f00f7c 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2270,5 +2270,26 @@ }, "accountSuccessfullyCreated": { "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" } } diff --git a/apps/browser/src/auth/background/service-factories/auth-request-crypto-service.factory.ts b/apps/browser/src/auth/background/service-factories/auth-request-crypto-service.factory.ts new file mode 100644 index 0000000000..e1757f9812 --- /dev/null +++ b/apps/browser/src/auth/background/service-factories/auth-request-crypto-service.factory.ts @@ -0,0 +1,29 @@ +import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; +import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation"; + +import { + CryptoServiceInitOptions, + cryptoServiceFactory, +} from "../../../platform/background/service-factories/crypto-service.factory"; +import { + CachedServices, + FactoryOptions, + factory, +} from "../../../platform/background/service-factories/factory-options"; + +type AuthRequestCryptoServiceFactoryOptions = FactoryOptions; + +export type AuthRequestCryptoServiceInitOptions = AuthRequestCryptoServiceFactoryOptions & + CryptoServiceInitOptions; + +export function authRequestCryptoServiceFactory( + cache: { authRequestCryptoService?: AuthRequestCryptoServiceAbstraction } & CachedServices, + opts: AuthRequestCryptoServiceInitOptions +): Promise { + return factory( + cache, + "authRequestCryptoService", + opts, + async () => new AuthRequestCryptoServiceImplementation(await cryptoServiceFactory(cache, opts)) + ); +} diff --git a/apps/browser/src/auth/background/service-factories/auth-service.factory.ts b/apps/browser/src/auth/background/service-factories/auth-service.factory.ts index 45e6d25014..6aaeb47636 100644 --- a/apps/browser/src/auth/background/service-factories/auth-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/auth-service.factory.ts @@ -52,7 +52,14 @@ import { PasswordStrengthServiceInitOptions, } from "../../../tools/background/service_factories/password-strength-service.factory"; -import { deviceTrustCryptoServiceFactory } from "./device-trust-crypto-service.factory"; +import { + authRequestCryptoServiceFactory, + AuthRequestCryptoServiceInitOptions, +} from "./auth-request-crypto-service.factory"; +import { + deviceTrustCryptoServiceFactory, + DeviceTrustCryptoServiceInitOptions, +} from "./device-trust-crypto-service.factory"; import { keyConnectorServiceFactory, KeyConnectorServiceInitOptions, @@ -76,7 +83,9 @@ export type AuthServiceInitOptions = AuthServiceFactoyOptions & I18nServiceInitOptions & EncryptServiceInitOptions & PolicyServiceInitOptions & - PasswordStrengthServiceInitOptions; + PasswordStrengthServiceInitOptions & + DeviceTrustCryptoServiceInitOptions & + AuthRequestCryptoServiceInitOptions; export function authServiceFactory( cache: { authService?: AbstractAuthService } & CachedServices, @@ -103,7 +112,8 @@ export function authServiceFactory( await encryptServiceFactory(cache, opts), await passwordStrengthServiceFactory(cache, opts), await policyServiceFactory(cache, opts), - await deviceTrustCryptoServiceFactory(cache, opts) + await deviceTrustCryptoServiceFactory(cache, opts), + await authRequestCryptoServiceFactory(cache, opts) ) ); } diff --git a/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts b/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts index efeca261e8..430d50fea7 100644 --- a/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts @@ -26,6 +26,14 @@ import { FactoryOptions, factory, } from "../../../platform/background/service-factories/factory-options"; +import { + I18nServiceInitOptions, + i18nServiceFactory, +} from "../../../platform/background/service-factories/i18n-service.factory"; +import { + PlatformUtilsServiceInitOptions, + platformUtilsServiceFactory, +} from "../../../platform/background/service-factories/platform-utils-service.factory"; import { StateServiceInitOptions, stateServiceFactory, @@ -39,7 +47,9 @@ export type DeviceTrustCryptoServiceInitOptions = DeviceTrustCryptoServiceFactor EncryptServiceInitOptions & StateServiceInitOptions & AppIdServiceInitOptions & - DevicesApiServiceInitOptions; + DevicesApiServiceInitOptions & + I18nServiceInitOptions & + PlatformUtilsServiceInitOptions; export function deviceTrustCryptoServiceFactory( cache: { deviceTrustCryptoService?: DeviceTrustCryptoServiceAbstraction } & CachedServices, @@ -56,7 +66,9 @@ export function deviceTrustCryptoServiceFactory( await encryptServiceFactory(cache, opts), await stateServiceFactory(cache, opts), await appIdServiceFactory(cache, opts), - await devicesApiServiceFactory(cache, opts) + await devicesApiServiceFactory(cache, opts), + await i18nServiceFactory(cache, opts), + await platformUtilsServiceFactory(cache, opts) ) ); } diff --git a/apps/browser/src/auth/popup/login-with-device.component.html b/apps/browser/src/auth/popup/login-with-device.component.html index d794b7d212..02997e5880 100644 --- a/apps/browser/src/auth/popup/login-with-device.component.html +++ b/apps/browser/src/auth/popup/login-with-device.component.html @@ -5,32 +5,57 @@
-
-

{{ "logInInitiated" | i18n }}

- +
-

{{ "notificationSentDevice" | i18n }}

+

{{ "logInInitiated" | i18n }}

-

- {{ "fingerprintMatchInfo" | i18n }} -

+
+

{{ "notificationSentDevice" | i18n }}

+ +

+ {{ "fingerprintMatchInfo" | i18n }} +

+
+ +
+ {{ "fingerprintPhraseHeader" | i18n }} +

+ {{ fingerprintPhrase }} +

+
+ + + +
+
+
- {{ "fingerprintPhraseHeader" | i18n }} -

- {{ fingerprintPhrase }} -

-
+

{{ "adminApprovalRequested" | i18n }}

- +
+

{{ "adminApprovalRequestSentToAdmins" | i18n }}

+

{{ "youWillBeNotifiedOnceApproved" | i18n }}

+
- -
+
diff --git a/apps/browser/src/auth/popup/login-with-device.component.ts b/apps/browser/src/auth/popup/login-with-device.component.ts index cf0e57b5ee..6ae42d0e53 100644 --- a/apps/browser/src/auth/popup/login-with-device.component.ts +++ b/apps/browser/src/auth/popup/login-with-device.component.ts @@ -4,7 +4,9 @@ import { Router } from "@angular/router"; import { LoginWithDeviceComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/auth/components/login-with-device.component"; import { AnonymousHubService } from "@bitwarden/common/abstractions/anonymousHub.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -42,7 +44,9 @@ export class LoginWithDeviceComponent validationService: ValidationService, stateService: StateService, loginService: LoginService, - syncService: SyncService + syncService: SyncService, + deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + authReqCryptoService: AuthRequestCryptoServiceAbstraction ) { super( router, @@ -59,7 +63,9 @@ export class LoginWithDeviceComponent anonymousHubService, validationService, stateService, - loginService + loginService, + deviceTrustCryptoService, + authReqCryptoService ); super.onSuccessfulLogin = async () => { await syncService.fullSync(true); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 61a5ab0bc1..221c8d57b1 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -15,6 +15,7 @@ import { InternalPolicyService as InternalPolicyServiceAbstraction } from "@bitw import { ProviderService as ProviderServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service"; import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service"; +import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; @@ -23,6 +24,7 @@ import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/ import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction"; import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; @@ -201,6 +203,7 @@ export default class MainBackground { configApiService: ConfigApiServiceAbstraction; devicesApiService: DevicesApiServiceAbstraction; deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction; + authRequestCryptoService: AuthRequestCryptoServiceAbstraction; // Passed to the popup for Safari to workaround issues with theming, downloading, etc. backgroundWindow = window; @@ -399,9 +402,13 @@ export default class MainBackground { this.encryptService, this.stateService, this.appIdService, - this.devicesApiService + this.devicesApiService, + this.i18nService, + this.platformUtilsService ); + this.authRequestCryptoService = new AuthRequestCryptoServiceImplementation(this.cryptoService); + this.authService = new AuthService( this.cryptoService, this.apiService, @@ -418,7 +425,8 @@ export default class MainBackground { this.encryptService, this.passwordStrengthService, this.policyService, - this.deviceTrustCryptoService + this.deviceTrustCryptoService, + this.authRequestCryptoService ); this.vaultTimeoutSettingsService = new VaultTimeoutSettingsService( diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index e6a0506962..a0794982b2 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -78,6 +78,12 @@ const routes: Routes = [ canActivate: [UnauthGuard], data: { state: "login-with-device" }, }, + { + path: "admin-approval-requested", + component: LoginWithDeviceComponent, + canActivate: [], + data: { state: "login-with-device" }, + }, { path: "lock", component: LockComponent, diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index d266e7b68c..961b6693a2 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -12,8 +12,10 @@ import { OrganizationService } from "@bitwarden/common/admin-console/services/or import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service"; import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service"; +import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; +import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; @@ -146,6 +148,7 @@ export class Main { sendApiService: SendApiService; devicesApiService: DevicesApiServiceAbstraction; deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction; + authRequestCryptoService: AuthRequestCryptoServiceAbstraction; constructor() { let p = null; @@ -328,9 +331,13 @@ export class Main { this.encryptService, this.stateService, this.appIdService, - this.devicesApiService + this.devicesApiService, + this.i18nService, + this.platformUtilsService ); + this.authRequestCryptoService = new AuthRequestCryptoServiceImplementation(this.cryptoService); + this.authService = new AuthService( this.cryptoService, this.apiService, @@ -347,7 +354,8 @@ export class Main { this.encryptService, this.passwordStrengthService, this.policyService, - this.deviceTrustCryptoService + this.deviceTrustCryptoService, + this.authRequestCryptoService ); const lockedCallback = async () => diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index c45dc04d2d..6b0ec003e7 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -39,6 +39,10 @@ const routes: Routes = [ path: "login-with-device", component: LoginWithDeviceComponent, }, + { + path: "admin-approval-requested", + component: LoginWithDeviceComponent, + }, { path: "2fa", component: TwoFactorComponent }, { path: "login-initiated", diff --git a/apps/desktop/src/auth/login/login-with-device.component.html b/apps/desktop/src/auth/login/login-with-device.component.html index 29fb103494..2c3e40540c 100644 --- a/apps/desktop/src/auth/login/login-with-device.component.html +++ b/apps/desktop/src/auth/login/login-with-device.component.html @@ -1,40 +1,72 @@
Bitwarden -

{{ "logInInitiated" | i18n }}

-
-
-
-
-

{{ "notificationSentDevice" | i18n }}

-

- {{ "fingerprintMatchInfo" | i18n }} -

-
+ +

{{ "logInInitiated" | i18n }}

-
-

{{ "fingerprintPhraseHeader" | i18n }}

- {{ fingerprintPhrase }} -
+
+
+
+
+

{{ "notificationSentDevice" | i18n }}

+

+ {{ "fingerprintMatchInfo" | i18n }} +

+
- +
+

{{ "fingerprintPhraseHeader" | i18n }}

+ {{ fingerprintPhrase }} +
-
-

- {{ "needAnotherOption" | i18n }} - - {{ "viewAllLoginOptions" | i18n }} - -

+ + +
+

+ {{ "needAnotherOption" | i18n }} + + {{ "viewAllLoginOptions" | i18n }} + +

+
-
+
+ + +

{{ "adminApprovalRequested" | i18n }}

+ +
+
+
+
+

{{ "adminApprovalRequestSentToAdmins" | i18n }}

+

{{ "youWillBeNotifiedOnceApproved" | i18n }}

+
+ +
+

{{ "fingerprintPhraseHeader" | i18n }}

+ {{ fingerprintPhrase }} +
+ +
+

+ {{ "troubleLoggingIn" | i18n }} + + {{ "viewAllLoginOptions" | i18n }} + +

+
+
+
+
+
diff --git a/apps/desktop/src/auth/login/login-with-device.component.ts b/apps/desktop/src/auth/login/login-with-device.component.ts index 3b3ee83ae4..a02c77e513 100644 --- a/apps/desktop/src/auth/login/login-with-device.component.ts +++ b/apps/desktop/src/auth/login/login-with-device.component.ts @@ -5,7 +5,9 @@ import { LoginWithDeviceComponent as BaseLoginWithDeviceComponent } from "@bitwa import { ModalService } from "@bitwarden/angular/services/modal.service"; import { AnonymousHubService } from "@bitwarden/common/abstractions/anonymousHub.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -50,7 +52,9 @@ export class LoginWithDeviceComponent private modalService: ModalService, syncService: SyncService, stateService: StateService, - loginService: LoginService + loginService: LoginService, + deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + authReqCryptoService: AuthRequestCryptoServiceAbstraction ) { super( router, @@ -67,7 +71,9 @@ export class LoginWithDeviceComponent anonymousHubService, validationService, stateService, - loginService + loginService, + deviceTrustCryptoService, + authReqCryptoService ); super.onSuccessfulLogin = () => { @@ -101,6 +107,17 @@ export class LoginWithDeviceComponent } goToLogin() { - this.router.navigate(["/login"]); + switch (this.state) { + case this.StateEnum.StandardAuthRequest: + this.router.navigate(["/login"]); + + break; + case this.StateEnum.AdminAuthRequest: + this.router.navigate(["/login-initiated"]); + break; + + default: + break; + } } } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 54122aa1f8..141ac4f686 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2289,5 +2289,26 @@ }, "accountSuccessfullyCreated": { "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" } } diff --git a/apps/web/src/app/auth/login/login-with-device.component.html b/apps/web/src/app/auth/login/login-with-device.component.html index f190f8f5c6..c28157e98e 100644 --- a/apps/web/src/app/auth/login/login-with-device.component.html +++ b/apps/web/src/app/auth/login/login-with-device.component.html @@ -5,42 +5,71 @@ >
-

- {{ "loginOrCreateNewAccount" | i18n }} -

-
-

{{ "logInInitiated" | i18n }}

+ +

+ {{ "loginOrCreateNewAccount" | i18n }} +

-
-

{{ "notificationSentDevice" | i18n }}

+
+

{{ "logInInitiated" | i18n }}

-

- {{ "fingerprintMatchInfo" | i18n }} -

+
+

{{ "notificationSentDevice" | i18n }}

+ +

+ {{ "fingerprintMatchInfo" | i18n }} +

+
+ +
+

{{ "fingerprintPhraseHeader" | i18n }}

+

+ {{ fingerprintPhrase }} +

+
+ + + +
+ +
+ {{ "loginWithDeviceEnabledNote" | i18n }} + {{ "viewAllLoginOptions" | i18n }} +
+ + +
+

{{ "adminApprovalRequested" | i18n }}

-
-

{{ "fingerprintPhraseHeader" | i18n }}

-

- {{ fingerprintPhrase }} -

+
+

{{ "adminApprovalRequestSentToAdmins" | i18n }}

+

{{ "youWillBeNotifiedOnceApproved" | i18n }}

+
+ +
+

{{ "fingerprintPhraseHeader" | i18n }}

+

+ {{ fingerprintPhrase }} +

+
+ +
+ +
+ {{ "troubleLoggingIn" | i18n }} + {{ "viewAllLoginOptions" | i18n }} +
- - - -
- -
- {{ "loginWithDeviceEnabledNote" | i18n }} - {{ "viewAllLoginOptions" | i18n }} -
-
+
diff --git a/apps/web/src/app/auth/login/login-with-device.component.ts b/apps/web/src/app/auth/login/login-with-device.component.ts index c5e73bf04b..ff66bdb886 100644 --- a/apps/web/src/app/auth/login/login-with-device.component.ts +++ b/apps/web/src/app/auth/login/login-with-device.component.ts @@ -4,7 +4,9 @@ import { Router } from "@angular/router"; import { LoginWithDeviceComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/auth/components/login-with-device.component"; import { AnonymousHubService } from "@bitwarden/common/abstractions/anonymousHub.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -41,7 +43,9 @@ export class LoginWithDeviceComponent anonymousHubService: AnonymousHubService, validationService: ValidationService, stateService: StateService, - loginService: LoginService + loginService: LoginService, + deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + authReqCryptoService: AuthRequestCryptoServiceAbstraction ) { super( router, @@ -58,7 +62,9 @@ export class LoginWithDeviceComponent anonymousHubService, validationService, stateService, - loginService + loginService, + deviceTrustCryptoService, + authReqCryptoService ); } } diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 04d5e65ee4..08fbebaac0 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -67,6 +67,11 @@ const routes: Routes = [ component: LoginWithDeviceComponent, data: { titleId: "loginWithDevice" }, }, + { + path: "admin-approval-requested", + component: LoginWithDeviceComponent, + data: { titleId: "loginWithDevice" }, + }, { path: "2fa", component: TwoFactorComponent, canActivate: [UnauthGuard] }, { path: "login-initiated", diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 7e7b24e8f5..ff7a25d0cd 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7000,5 +7000,26 @@ }, "accountSuccessfullyCreated": { "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" } } 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 d04157b776..a96d7c78d5 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 @@ -206,31 +206,21 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { }); } - approveFromOtherDevice() { - // TODO: plan is to re-use existing login-with-device component but rework it to have two flows - // (1) Standard flow for unauthN user based on AuthService status - // (2) New flow for authN user based on AuthService status b/c they have just authenticated w/ SSO + async approveFromOtherDevice() { if (this.data.state !== State.ExistingUserUntrustedDevice) { return; } + await this.deviceTrustCryptoService.setShouldTrustDevice(this.rememberDevice.value); + this.loginService.setEmail(this.data.userEmail); this.router.navigate(["/login-with-device"]); } - requestAdminApproval() { - // this.router.navigate(["/admin-approval-requested"]); // new component that doesn't exist yet - // Idea: extract logic from the existing login-with-device component into a base-auth-request-component that - // the new admin-approval-requested component and the existing login-with-device component can extend - // TODO: how to do: - // add create admin approval request on new OrganizationAuthRequestsController on the server - // once https://github.com/bitwarden/server/pull/2993 is merged - // Client will create an AuthRequest of type AdminAuthRequest WITHOUT orgId and send it to the server - // Server will look up the org id(s) based on the user id and create the AdminAuthRequest(s) - // Note: must lookup if the user has an account recovery key (resetPasswordKey) set in the org - // (means they've opted into the Admin Acct Recovery feature) - // Per discussion with Micah, fire out requests to all admins in any orgs the user is a member of - // UNTIL the Admin Console team finishes their work to turn on Single Org policy when Admin Acct Recovery is enabled. + async requestAdminApproval() { + await this.deviceTrustCryptoService.setShouldTrustDevice(this.rememberDevice.value); + this.loginService.setEmail(this.data.userEmail); + this.router.navigate(["/admin-approval-requested"]); } async approveWithMasterPassword() { diff --git a/libs/angular/src/auth/components/lock.component.ts b/libs/angular/src/auth/components/lock.component.ts index e2485ecf40..68a919655d 100644 --- a/libs/angular/src/auth/components/lock.component.ts +++ b/libs/angular/src/auth/components/lock.component.ts @@ -303,12 +303,7 @@ export class LockComponent implements OnInit, OnDestroy { // Now that we have a decrypted user key in memory, we can check if we // need to establish trust on the current device - const shouldTrustDevice = await this.deviceTrustCryptoService.getShouldTrustDevice(); - if (shouldTrustDevice) { - await this.deviceTrustCryptoService.trustDevice(); - // reset the trust choice - await this.deviceTrustCryptoService.setShouldTrustDevice(false); - } + await this.deviceTrustCryptoService.trustDeviceIfRequired(); await this.doContinue(evaluatePasswordAfterUnlock); } diff --git a/libs/angular/src/auth/components/login-with-device.component.ts b/libs/angular/src/auth/components/login-with-device.component.ts index 31ea4a209d..9b5926be86 100644 --- a/libs/angular/src/auth/components/login-with-device.component.ts +++ b/libs/angular/src/auth/components/login-with-device.component.ts @@ -1,12 +1,17 @@ import { Directive, OnDestroy, OnInit } from "@angular/core"; -import { Router } from "@angular/router"; +import { IsActiveMatchOptions, Router } from "@angular/router"; import { Subject, takeUntil } from "rxjs"; import { AnonymousHubService } from "@bitwarden/common/abstractions/anonymousHub.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { AuthRequestType } from "@bitwarden/common/auth/enums/auth-request-type"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable"; +import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason"; import { PasswordlessLogInCredentials } from "@bitwarden/common/auth/models/domain/log-in-credentials"; import { PasswordlessCreateAuthRequest } from "@bitwarden/common/auth/models/request/passwordless-create-auth.request"; @@ -22,20 +27,24 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { - MasterKey, - SymmetricCryptoKey, -} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { CaptchaProtectedComponent } from "./captcha-protected.component"; +// TODO: consider renaming this component something like LoginViaAuthReqComponent + +enum State { + StandardAuthRequest, + AdminAuthRequest, +} + @Directive() export class LoginWithDeviceComponent extends CaptchaProtectedComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); + userAuthNStatus: AuthenticationStatus; email: string; showResendNotification = false; passwordlessRequest: PasswordlessCreateAuthRequest; @@ -45,12 +54,19 @@ export class LoginWithDeviceComponent onSuccessfulLoginNavigate: () => Promise; onSuccessfulLoginForceResetNavigate: () => Promise; + protected adminApprovalRoute = "admin-approval-requested"; + + protected StateEnum = State; + protected state = State.StandardAuthRequest; + protected twoFactorRoute = "2fa"; protected successRoute = "vault"; protected forcePasswordResetRoute = "update-temp-password"; private resendTimeout = 12000; - private authRequestKeyPair: [publicKey: ArrayBuffer, privateKey: ArrayBuffer]; + private authRequestKeyPair: { publicKey: ArrayBuffer; privateKey: ArrayBuffer }; + + // TODO: in future, go to child components and remove child constructors and let deps fall through to the super class constructor( protected router: Router, private cryptoService: CryptoService, @@ -66,10 +82,14 @@ export class LoginWithDeviceComponent private anonymousHubService: AnonymousHubService, private validationService: ValidationService, private stateService: StateService, - private loginService: LoginService + private loginService: LoginService, + private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + private authReqCryptoService: AuthRequestCryptoServiceAbstraction ) { super(environmentService, i18nService, platformUtilsService); + // TODO: I don't know why this is necessary. + // Why would the existence of the email depend on the navigation? const navigation = this.router.getCurrentNavigation(); if (navigation) { this.email = this.loginService.getEmail(); @@ -80,25 +100,159 @@ export class LoginWithDeviceComponent .getPushNotificationObs$() .pipe(takeUntil(this.destroy$)) .subscribe((id) => { - this.confirmResponse(id); + // Only fires on approval currently + this.verifyAndHandleApprovedAuthReq(id); }); } async ngOnInit() { - if (!this.email) { - this.router.navigate(["/login"]); - return; + this.userAuthNStatus = await this.authService.getAuthStatus(); + + const matchOptions: IsActiveMatchOptions = { + paths: "exact", + queryParams: "ignored", + fragment: "ignored", + matrixParams: "ignored", + }; + + if (this.router.isActive(this.adminApprovalRoute, matchOptions)) { + this.state = State.AdminAuthRequest; } + if (this.state === State.AdminAuthRequest) { + // Pull email from state for admin auth reqs b/c it is available + // This also prevents it from being lost on refresh as the + // login service email does not persist. + this.email = await this.stateService.getEmail(); + + if (!this.email) { + this.platformUtilsService.showToast("error", null, this.i18nService.t("userEmailMissing")); + this.router.navigate(["/login-initiated"]); + return; + } + + // We only allow a single admin approval request to be active at a time + // so must check state to see if we have an existing one or not + const adminAuthReqStorable = await this.stateService.getAdminAuthRequest(); + + if (adminAuthReqStorable) { + await this.handleExistingAdminAuthRequest(adminAuthReqStorable); + } else { + // No existing admin auth request; so we need to create one + await this.startPasswordlessLogin(); + } + } else { + // Standard auth request + // TODO: evaluate if we can remove the setting of this.email in the constructor + this.email = this.loginService.getEmail(); + + if (!this.email) { + this.platformUtilsService.showToast("error", null, this.i18nService.t("userEmailMissing")); + this.router.navigate(["/login"]); + return; + } + + await this.startPasswordlessLogin(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.anonymousHubService.stopHubConnection(); + } + + private async handleExistingAdminAuthRequest(adminAuthReqStorable: AdminAuthRequestStorable) { + // Note: on login, the SSOLoginStrategy will also call to see an existing admin auth req + // has been approved and handle it if so. + // Regardless, we always retrieve the auth request from the server verify and handle status changes here as well + const adminAuthReqResponse = await this.apiService.getAuthRequest(adminAuthReqStorable.id); + + // Request doesn't exist + if (!adminAuthReqResponse) { + return await this.handleExistingAdminAuthReqDeletedOrDenied(); + } + + // Re-derive the user's fingerprint phrase + // It is important to not use the server's public key here as it could have been compromised via MITM + const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey( + adminAuthReqStorable.privateKey + ); + this.fingerprintPhrase = ( + await this.cryptoService.getFingerprint(this.email, derivedPublicKeyArrayBuffer) + ).join("-"); + + // Request denied + if (adminAuthReqResponse.isAnswered && !adminAuthReqResponse.requestApproved) { + return await this.handleExistingAdminAuthReqDeletedOrDenied(); + } + + // Request approved + if (adminAuthReqResponse.requestApproved) { + return await this.handleApprovedAdminAuthRequest( + adminAuthReqResponse, + adminAuthReqStorable.privateKey + ); + } + + // Request still pending response from admin + // So, create hub connection so that any approvals will be received via push notification + this.anonymousHubService.createHubConnection(adminAuthReqStorable.id); + } + + private async handleExistingAdminAuthReqDeletedOrDenied() { + // clear the admin auth request from state + await this.stateService.setAdminAuthRequest(null); + + // start new auth request this.startPasswordlessLogin(); } + private async buildAuthRequest(authRequestType: AuthRequestType) { + const authRequestKeyPairArray = await this.cryptoFunctionService.rsaGenerateKeyPair(2048); + + this.authRequestKeyPair = { + publicKey: authRequestKeyPairArray[0], + privateKey: authRequestKeyPairArray[1], + }; + + const deviceIdentifier = await this.appIdService.getAppId(); + const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey); + const accessCode = await this.passwordGenerationService.generatePassword({ length: 25 }); + + this.fingerprintPhrase = ( + await this.cryptoService.getFingerprint(this.email, this.authRequestKeyPair.publicKey) + ).join("-"); + + this.passwordlessRequest = new PasswordlessCreateAuthRequest( + this.email, + deviceIdentifier, + publicKey, + authRequestType, + accessCode + ); + } + async startPasswordlessLogin() { this.showResendNotification = false; try { - await this.buildAuthRequest(); - const reqResponse = await this.apiService.postAuthRequest(this.passwordlessRequest); + let reqResponse: AuthRequestResponse; + + if (this.state === State.AdminAuthRequest) { + await this.buildAuthRequest(AuthRequestType.AdminApproval); + reqResponse = await this.apiService.postAdminAuthRequest(this.passwordlessRequest); + + const adminAuthReqStorable = new AdminAuthRequestStorable({ + id: reqResponse.id, + privateKey: this.authRequestKeyPair.privateKey, + }); + + await this.stateService.setAdminAuthRequest(adminAuthReqStorable); + } else { + await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock); + reqResponse = await this.apiService.postAuthRequest(this.passwordlessRequest); + } if (reqResponse.id) { this.anonymousHubService.createHubConnection(reqResponse.id); @@ -112,66 +266,69 @@ export class LoginWithDeviceComponent }, this.resendTimeout); } - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - this.anonymousHubService.stopHubConnection(); - } - - private async confirmResponse(requestId: string) { + private async verifyAndHandleApprovedAuthReq(requestId: string) { try { - // TODO for TDE: We are going to have to make changes here to support the new unlock flow as the user is already AuthN via SSO - // The existing flow currently works for unauthN users and authenticates them AND unlocks their vault. - // We only need the unlock portion of the logic to run. + // Retrieve the auth request from server and verify it's approved + let authReqResponse: AuthRequestResponse; - // We need to make the approving device treats the MP hash as optional - // and make sure the server can handle that. + switch (this.state) { + case State.StandardAuthRequest: + // Unauthed - access code required for user verification + authReqResponse = await this.apiService.getAuthResponse( + requestId, + this.passwordlessRequest.accessCode + ); + break; - const response = await this.apiService.getAuthResponse( - requestId, - this.passwordlessRequest.accessCode - ); + case State.AdminAuthRequest: + // Authed - no access code required + authReqResponse = await this.apiService.getAuthRequest(requestId); + break; - if (!response.requestApproved) { + default: + break; + } + + if (!authReqResponse.requestApproved) { return; } - // TODO for TDE: - // Add a check here to see if the user is already AuthN via SSO, then we - // have to figure out how to handle the unlock portion of the logic. - // Taken from PasswordlessLogInStrategy: - // await this.cryptoService.setKey(this.passwordlessCredentials.decKey); - // navigate to vault + // Approved so proceed: - const credentials = await this.buildLoginCredentials(requestId, response); - const loginResponse = await this.authService.logIn(credentials); + // 4 Scenarios to handle for approved auth requests: + // Existing flow 1: + // - Anon Login with Device > User is not AuthN > receives approval from device with pubKey(masterKey) + // > decrypt masterKey > must authenticate > gets masterKey(userKey) > decrypt userKey and proceed to vault - if (loginResponse.requiresTwoFactor) { - if (this.onSuccessfulLoginTwoFactorNavigate != null) { - this.onSuccessfulLoginTwoFactorNavigate(); - } else { - this.router.navigate([this.twoFactorRoute]); - } - } else if (loginResponse.forcePasswordReset != ForceResetPasswordReason.None) { - if (this.onSuccessfulLoginForceResetNavigate != null) { - this.onSuccessfulLoginForceResetNavigate(); - } else { - this.router.navigate([this.forcePasswordResetRoute]); - } - } else { - await this.setRememberEmailValues(); - if (this.onSuccessfulLogin != null) { - this.onSuccessfulLogin(); - } - if (this.onSuccessfulLoginNavigate != null) { - this.onSuccessfulLoginNavigate(); - } else { - this.router.navigate([this.successRoute]); - } + // 3 new flows from TDE: + // Flow 2: + // - Post SSO > User is AuthN > SSO login strategy success sets masterKey(userKey) > receives approval from device with pubKey(masterKey) + // > decrypt masterKey > decrypt userKey > establish trust if required > proceed to vault + // Flow 3: + // - Post SSO > User is AuthN > Receives approval from device with pubKey(userKey) > decrypt userKey > establish trust if required > proceed to vault + // Flow 4: + // - Anon Login with Device > User is not AuthN > receives approval from device with pubKey(userKey) + // > decrypt userKey > must authenticate > set userKey > proceed to vault + + // if user has authenticated via SSO + if (this.userAuthNStatus === AuthenticationStatus.Locked) { + return await this.handleApprovedAdminAuthRequest( + authReqResponse, + this.authRequestKeyPair.privateKey + ); } + + // Flow 1 and 4: + const loginAuthResult = await this.loginViaPasswordlessStrategy(requestId, authReqResponse); + await this.handlePostLoginNavigation(loginAuthResult); } catch (error) { if (error instanceof ErrorResponse) { - this.router.navigate(["/login"]); + let errorRoute = "/login"; + if (this.state === State.AdminAuthRequest) { + errorRoute = "/login-initiated"; + } + + this.router.navigate([errorRoute]); this.validationService.showError(error); return; } @@ -180,53 +337,129 @@ export class LoginWithDeviceComponent } } + async handleApprovedAdminAuthRequest( + adminAuthReqResponse: AuthRequestResponse, + privateKey: ArrayBuffer + ) { + // See verifyAndHandleApprovedAuthReq(...) for flow details + // it's flow 2 or 3 based on presence of masterPasswordHash + if (adminAuthReqResponse.masterPasswordHash) { + // Flow 2: masterPasswordHash is not null + // key is authRequestPublicKey(masterKey) + we have authRequestPublicKey(masterPasswordHash) + await this.authReqCryptoService.setKeysAfterDecryptingSharedMasterKeyAndHash( + adminAuthReqResponse, + privateKey + ); + } else { + // Flow 3: masterPasswordHash is null + // we can assume key is authRequestPublicKey(userKey) and we can just decrypt with userKey and proceed to vault + await this.authReqCryptoService.setUserKeyAfterDecryptingSharedUserKey( + adminAuthReqResponse, + privateKey + ); + } + + // clear the admin auth request from state so it cannot be used again (it's a one time use) + // TODO: this should eventually be enforced via deleting this on the server once it is used + await this.stateService.setAdminAuthRequest(null); + + this.platformUtilsService.showToast("success", null, this.i18nService.t("loginApproved")); + + // Now that we have a decrypted user key in memory, we can check if we + // need to establish trust on the current device + await this.deviceTrustCryptoService.trustDeviceIfRequired(); + + // TODO: don't forget to use auto enrollment service everywhere we trust device + + await this.handleSuccessfulLoginNavigation(); + } + + // Authentication helper + private async buildPasswordlessLoginCredentials( + requestId: string, + response: AuthRequestResponse + ): Promise { + // if masterPasswordHash has a value, we will always receive key as authRequestPublicKey(masterKey) + authRequestPublicKey(masterPasswordHash) + // if masterPasswordHash is null, we will always receive key as authRequestPublicKey(userKey) + if (response.masterPasswordHash) { + const { masterKey, masterKeyHash } = + await this.authReqCryptoService.decryptPubKeyEncryptedMasterKeyAndHash( + response.key, + response.masterPasswordHash, + this.authRequestKeyPair.privateKey + ); + + return new PasswordlessLogInCredentials( + this.email, + this.passwordlessRequest.accessCode, + requestId, + null, // no userKey + masterKey, + masterKeyHash + ); + } else { + const userKey = await this.authReqCryptoService.decryptPubKeyEncryptedUserKey( + response.key, + this.authRequestKeyPair.privateKey + ); + return new PasswordlessLogInCredentials( + this.email, + this.passwordlessRequest.accessCode, + requestId, + userKey, + null, // no masterKey + null // no masterKeyHash + ); + } + } + + private async loginViaPasswordlessStrategy( + requestId: string, + authReqResponse: AuthRequestResponse + ): Promise { + // Note: credentials change based on if the authReqResponse.key is a encryptedMasterKey or UserKey + const credentials = await this.buildPasswordlessLoginCredentials(requestId, authReqResponse); + + // Note: keys are set by PasswordlessLogInStrategy success handling + return await this.authService.logIn(credentials); + } + + // Routing logic + private async handlePostLoginNavigation(loginResponse: AuthResult) { + if (loginResponse.requiresTwoFactor) { + if (this.onSuccessfulLoginTwoFactorNavigate != null) { + this.onSuccessfulLoginTwoFactorNavigate(); + } else { + this.router.navigate([this.twoFactorRoute]); + } + } else if (loginResponse.forcePasswordReset != ForceResetPasswordReason.None) { + if (this.onSuccessfulLoginForceResetNavigate != null) { + this.onSuccessfulLoginForceResetNavigate(); + } else { + this.router.navigate([this.forcePasswordResetRoute]); + } + } else { + await this.handleSuccessfulLoginNavigation(); + } + } + async setRememberEmailValues() { + // TODO: solve bug with getRememberEmail not persisting across SSO to here const rememberEmail = this.loginService.getRememberEmail(); - const rememberedEmail = this.loginService.getEmail(); + const rememberedEmail = this.loginService.getEmail(); // this does persist across SSO await this.stateService.setRememberedEmail(rememberEmail ? rememberedEmail : null); this.loginService.clearValues(); } - private async buildAuthRequest() { - this.authRequestKeyPair = await this.cryptoFunctionService.rsaGenerateKeyPair(2048); - const deviceIdentifier = await this.appIdService.getAppId(); - const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair[0]); - const accessCode = await this.passwordGenerationService.generatePassword({ length: 25 }); - - this.fingerprintPhrase = ( - await this.cryptoService.getFingerprint(this.email, this.authRequestKeyPair[0]) - ).join("-"); - - this.passwordlessRequest = new PasswordlessCreateAuthRequest( - this.email, - deviceIdentifier, - publicKey, - AuthRequestType.AuthenticateAndUnlock, - accessCode - ); - } - - private async buildLoginCredentials( - requestId: string, - response: AuthRequestResponse - ): Promise { - const decMasterKeyArray = await this.cryptoService.rsaDecrypt( - response.key, - this.authRequestKeyPair[1] - ); - const decMasterPasswordHash = await this.cryptoService.rsaDecrypt( - response.masterPasswordHash, - this.authRequestKeyPair[1] - ); - const decMasterKey = new SymmetricCryptoKey(decMasterKeyArray) as MasterKey; - const localHashedPassword = Utils.fromBufferToUtf8(decMasterPasswordHash); - - return new PasswordlessLogInCredentials( - this.email, - this.passwordlessRequest.accessCode, - requestId, - decMasterKey, - localHashedPassword - ); + private async handleSuccessfulLoginNavigation() { + await this.setRememberEmailValues(); + if (this.onSuccessfulLogin != null) { + this.onSuccessfulLogin(); + } + if (this.onSuccessfulLoginNavigate != null) { + this.onSuccessfulLoginNavigate(); + } else { + this.router.navigate([this.successRoute]); + } } } diff --git a/libs/angular/src/auth/components/sso.component.ts b/libs/angular/src/auth/components/sso.component.ts index f05caafef8..92135129c4 100644 --- a/libs/angular/src/auth/components/sso.component.ts +++ b/libs/angular/src/auth/components/sso.component.ts @@ -232,8 +232,6 @@ export class SsoComponent { return await this.handleSuccessfulLogin(); } catch (e) { await this.handleLoginError(e); - } finally { - this.loggingIn = false; } } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 382ae7343c..1cf812b137 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -40,6 +40,7 @@ import { AccountService as AccountServiceAbstraction, InternalAccountService, } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; @@ -52,6 +53,7 @@ import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/ab import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; +import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; @@ -250,6 +252,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; PasswordStrengthServiceAbstraction, PolicyServiceAbstraction, DeviceTrustCryptoServiceAbstraction, + AuthRequestCryptoServiceAbstraction, ], }, { @@ -708,8 +711,15 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; StateServiceAbstraction, AppIdServiceAbstraction, DevicesApiServiceAbstraction, + I18nServiceAbstraction, + PlatformUtilsServiceAbstraction, ], }, + { + provide: AuthRequestCryptoServiceAbstraction, + useClass: AuthRequestCryptoServiceImplementation, + deps: [CryptoServiceAbstraction], + }, ], }) export class JslibServicesModule {} diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index d21de91718..f728a22ab8 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -200,6 +200,7 @@ export abstract class ApiService { postConvertToKeyConnector: () => Promise; //passwordless postAuthRequest: (request: PasswordlessCreateAuthRequest) => Promise; + postAdminAuthRequest: (request: PasswordlessCreateAuthRequest) => Promise; getAuthResponse: (id: string, accessCode: string) => Promise; getAuthRequest: (id: string) => Promise; putAuthRequest: (id: string, request: PasswordlessAuthRequest) => Promise; diff --git a/libs/common/src/auth/abstractions/auth-request-crypto.service.abstraction.ts b/libs/common/src/auth/abstractions/auth-request-crypto.service.abstraction.ts new file mode 100644 index 0000000000..f18c8d45ba --- /dev/null +++ b/libs/common/src/auth/abstractions/auth-request-crypto.service.abstraction.ts @@ -0,0 +1,24 @@ +import { UserKey, MasterKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { AuthRequestResponse } from "../models/response/auth-request.response"; + +export abstract class AuthRequestCryptoServiceAbstraction { + setUserKeyAfterDecryptingSharedUserKey: ( + authReqResponse: AuthRequestResponse, + authReqPrivateKey: ArrayBuffer + ) => Promise; + setKeysAfterDecryptingSharedMasterKeyAndHash: ( + authReqResponse: AuthRequestResponse, + authReqPrivateKey: ArrayBuffer + ) => Promise; + + decryptPubKeyEncryptedUserKey: ( + pubKeyEncryptedUserKey: string, + privateKey: ArrayBuffer + ) => Promise; + + decryptPubKeyEncryptedMasterKeyAndHash: ( + pubKeyEncryptedMasterKey: string, + pubKeyEncryptedMasterKeyHash: string, + privateKey: ArrayBuffer + ) => Promise<{ masterKey: MasterKey; masterKeyHash: string }>; +} 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 1af1ce6689..97580c10f1 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 @@ -10,6 +10,8 @@ export abstract class DeviceTrustCryptoServiceAbstraction { getShouldTrustDevice: () => Promise; setShouldTrustDevice: (value: boolean) => Promise; + trustDeviceIfRequired: () => Promise; + trustDevice: () => Promise; getDeviceKey: () => Promise; decryptUserKeyWithDeviceKey: ( diff --git a/libs/common/src/auth/abstractions/password-reset-enrollment.service.abstraction.ts b/libs/common/src/auth/abstractions/password-reset-enrollment.service.abstraction.ts index c9c2af14b8..ccfe0d645c 100644 --- a/libs/common/src/auth/abstractions/password-reset-enrollment.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/password-reset-enrollment.service.abstraction.ts @@ -1,6 +1,11 @@ import { UserKey } from "../../platform/models/domain/symmetric-crypto-key"; export abstract class PasswordResetEnrollmentServiceAbstraction { + /* + * Checks the user's enrollment status and enrolls them if required + */ + abstract enrollIfRequired(organizationSsoIdentifier: string): Promise; + /** * Enroll current user in password reset * @param organizationId - Organization in which to enroll the user diff --git a/libs/common/src/auth/enums/auth-request-type.ts b/libs/common/src/auth/enums/auth-request-type.ts index 4edfa5b888..31db246786 100644 --- a/libs/common/src/auth/enums/auth-request-type.ts +++ b/libs/common/src/auth/enums/auth-request-type.ts @@ -1,4 +1,5 @@ export enum AuthRequestType { AuthenticateAndUnlock = 0, Unlock = 1, + AdminApproval = 2, } diff --git a/libs/common/src/auth/login-strategies/login.strategy.ts b/libs/common/src/auth/login-strategies/login.strategy.ts index 51a8015266..67586a5a22 100644 --- a/libs/common/src/auth/login-strategies/login.strategy.ts +++ b/libs/common/src/auth/login-strategies/login.strategy.ts @@ -117,6 +117,9 @@ export abstract class LogInStrategy { accountKeys.deviceKey = deviceKey; } + // If you don't persist existing admin auth requests on login, they will get deleted. + const adminAuthRequest = await this.stateService.getAdminAuthRequest({ userId }); + await this.stateService.addAccount( new Account({ profile: { @@ -143,6 +146,7 @@ export abstract class LogInStrategy { decryptionOptions: AccountDecryptionOptions.fromResponse( tokenResponse.userDecryptionOptions ), + adminAuthRequest: adminAuthRequest, }) ); } diff --git a/libs/common/src/auth/login-strategies/passwordless-login.strategy.spec.ts b/libs/common/src/auth/login-strategies/passwordless-login.strategy.spec.ts index 92de8d29de..8bbd36436c 100644 --- a/libs/common/src/auth/login-strategies/passwordless-login.strategy.spec.ts +++ b/libs/common/src/auth/login-strategies/passwordless-login.strategy.spec.ts @@ -14,6 +14,7 @@ import { UserKey, } from "../../platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "../../types/csprng"; +import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; import { TokenService } from "../abstractions/token.service"; import { TwoFactorService } from "../abstractions/two-factor.service"; import { PasswordlessLogInCredentials } from "../models/domain/log-in-credentials"; @@ -22,7 +23,7 @@ import { IdentityTokenResponse } from "../models/response/identity-token.respons import { identityTokenResponseFactory } from "./login.strategy.spec"; import { PasswordlessLogInStrategy } from "./passwordless-login.strategy"; -describe("SsoLogInStrategy", () => { +describe("PasswordlessLogInStrategy", () => { let cryptoService: MockProxy; let apiService: MockProxy; let tokenService: MockProxy; @@ -32,6 +33,7 @@ describe("SsoLogInStrategy", () => { let logService: MockProxy; let stateService: MockProxy; let twoFactorService: MockProxy; + let deviceTrustCryptoService: MockProxy; let passwordlessLoginStrategy: PasswordlessLogInStrategy; let credentials: PasswordlessLogInCredentials; @@ -42,8 +44,11 @@ describe("SsoLogInStrategy", () => { const email = "EMAIL"; const accessCode = "ACCESS_CODE"; const authRequestId = "AUTH_REQUEST_ID"; - const decKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey; - const localPasswordHash = "LOCAL_PASSWORD_HASH"; + const decMasterKey = new SymmetricCryptoKey( + new Uint8Array(64).buffer as CsprngArray + ) as MasterKey; + const decUserKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + const decMasterKeyHash = "LOCAL_PASSWORD_HASH"; beforeEach(async () => { cryptoService = mock(); @@ -55,6 +60,7 @@ describe("SsoLogInStrategy", () => { logService = mock(); stateService = mock(); twoFactorService = mock(); + deviceTrustCryptoService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); @@ -69,21 +75,24 @@ describe("SsoLogInStrategy", () => { messagingService, logService, stateService, - twoFactorService - ); - credentials = new PasswordlessLogInCredentials( - email, - accessCode, - authRequestId, - decKey, - localPasswordHash + twoFactorService, + deviceTrustCryptoService ); tokenResponse = identityTokenResponseFactory(); apiService.postIdentityToken.mockResolvedValue(tokenResponse); }); - it("sets keys after a successful authentication", async () => { + it("sets keys after a successful authentication when masterKey and masterKeyHash provided in login credentials", async () => { + credentials = new PasswordlessLogInCredentials( + email, + accessCode, + authRequestId, + null, + decMasterKey, + decMasterKeyHash + ); + const masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey; const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; @@ -93,9 +102,37 @@ describe("SsoLogInStrategy", () => { await passwordlessLoginStrategy.logIn(credentials); expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey); - expect(cryptoService.setMasterKeyHash).toHaveBeenCalledWith(localPasswordHash); + expect(cryptoService.setMasterKeyHash).toHaveBeenCalledWith(decMasterKeyHash); expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey); + expect(deviceTrustCryptoService.trustDeviceIfRequired).toHaveBeenCalled(); expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey); }); + + it("sets keys after a successful authentication when only userKey provided in login credentials", async () => { + // Initialize credentials with only userKey + credentials = new PasswordlessLogInCredentials( + email, + accessCode, + authRequestId, + decUserKey, // Pass userKey + null, // No masterKey + null // No masterKeyHash + ); + + // Call logIn + await passwordlessLoginStrategy.logIn(credentials); + + // setMasterKey and setMasterKeyHash should not be called + expect(cryptoService.setMasterKey).not.toHaveBeenCalled(); + expect(cryptoService.setMasterKeyHash).not.toHaveBeenCalled(); + + // setMasterKeyEncryptedUserKey, setUserKey, and setPrivateKey should still be called + expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); + expect(cryptoService.setUserKey).toHaveBeenCalledWith(decUserKey); + expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey); + + // trustDeviceIfRequired should be called + expect(deviceTrustCryptoService.trustDeviceIfRequired).not.toHaveBeenCalled(); + }); }); diff --git a/libs/common/src/auth/login-strategies/passwordless-login.strategy.ts b/libs/common/src/auth/login-strategies/passwordless-login.strategy.ts index cdbf57caf2..bcaacb69f1 100644 --- a/libs/common/src/auth/login-strategies/passwordless-login.strategy.ts +++ b/libs/common/src/auth/login-strategies/passwordless-login.strategy.ts @@ -5,6 +5,7 @@ import { LogService } from "../../platform/abstractions/log.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { StateService } from "../../platform/abstractions/state.service"; +import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; import { TokenService } from "../abstractions/token.service"; import { TwoFactorService } from "../abstractions/two-factor.service"; import { AuthResult } from "../models/domain/auth-result"; @@ -40,7 +41,8 @@ export class PasswordlessLogInStrategy extends LogInStrategy { messagingService: MessagingService, logService: LogService, stateService: StateService, - twoFactorService: TwoFactorService + twoFactorService: TwoFactorService, + private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction ) { super( cryptoService, @@ -80,13 +82,32 @@ export class PasswordlessLogInStrategy extends LogInStrategy { } protected override async setMasterKey(response: IdentityTokenResponse) { - await this.cryptoService.setMasterKey(this.passwordlessCredentials.decKey); - await this.cryptoService.setMasterKeyHash(this.passwordlessCredentials.localPasswordHash); + if ( + this.passwordlessCredentials.decryptedMasterKey && + this.passwordlessCredentials.decryptedMasterKeyHash + ) { + await this.cryptoService.setMasterKey(this.passwordlessCredentials.decryptedMasterKey); + await this.cryptoService.setMasterKeyHash( + this.passwordlessCredentials.decryptedMasterKeyHash + ); + } } protected override async setUserKey(response: IdentityTokenResponse): Promise { + // User now may or may not have a master password + // but set the master key encrypted user key if it exists regardless await this.cryptoService.setMasterKeyEncryptedUserKey(response.key); + if (this.passwordlessCredentials.decryptedUserKey) { + await this.cryptoService.setUserKey(this.passwordlessCredentials.decryptedUserKey); + } else { + await this.trySetUserKeyWithMasterKey(); + // Establish trust if required after setting user key + await this.deviceTrustCryptoService.trustDeviceIfRequired(); + } + } + + private async trySetUserKeyWithMasterKey(): Promise { const masterKey = await this.cryptoService.getMasterKey(); if (masterKey) { const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); diff --git a/libs/common/src/auth/login-strategies/sso-login.strategy.spec.ts b/libs/common/src/auth/login-strategies/sso-login.strategy.spec.ts index 77c108d34f..a3ddc36a10 100644 --- a/libs/common/src/auth/login-strategies/sso-login.strategy.spec.ts +++ b/libs/common/src/auth/login-strategies/sso-login.strategy.spec.ts @@ -3,6 +3,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { ApiService } from "../../abstractions/api.service"; import { AppIdService } from "../../platform/abstractions/app-id.service"; import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; import { LogService } from "../../platform/abstractions/log.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; @@ -15,6 +16,7 @@ import { UserKey, } from "../../platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "../../types/csprng"; +import { AuthRequestCryptoServiceAbstraction } from "../abstractions/auth-request-crypto.service.abstraction"; import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; import { KeyConnectorService } from "../abstractions/key-connector.service"; import { TokenService } from "../abstractions/token.service"; @@ -38,6 +40,8 @@ describe("SsoLogInStrategy", () => { let twoFactorService: MockProxy; let keyConnectorService: MockProxy; let deviceTrustCryptoService: MockProxy; + let authRequestCryptoService: MockProxy; + let i18nService: MockProxy; let ssoLogInStrategy: SsoLogInStrategy; let credentials: SsoLogInCredentials; @@ -62,6 +66,8 @@ describe("SsoLogInStrategy", () => { twoFactorService = mock(); keyConnectorService = mock(); deviceTrustCryptoService = mock(); + authRequestCryptoService = mock(); + i18nService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); @@ -78,7 +84,9 @@ describe("SsoLogInStrategy", () => { stateService, twoFactorService, keyConnectorService, - deviceTrustCryptoService + deviceTrustCryptoService, + authRequestCryptoService, + i18nService ); credentials = new SsoLogInCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId); }); diff --git a/libs/common/src/auth/login-strategies/sso-login.strategy.ts b/libs/common/src/auth/login-strategies/sso-login.strategy.ts index cf389757ed..d05da0c95b 100644 --- a/libs/common/src/auth/login-strategies/sso-login.strategy.ts +++ b/libs/common/src/auth/login-strategies/sso-login.strategy.ts @@ -1,10 +1,12 @@ import { ApiService } from "../../abstractions/api.service"; import { AppIdService } from "../../platform/abstractions/app-id.service"; import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; import { LogService } from "../../platform/abstractions/log.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { StateService } from "../../platform/abstractions/state.service"; +import { AuthRequestCryptoServiceAbstraction } from "../abstractions/auth-request-crypto.service.abstraction"; import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; import { KeyConnectorService } from "../abstractions/key-connector.service"; import { TokenService } from "../abstractions/token.service"; @@ -36,7 +38,9 @@ export class SsoLogInStrategy extends LogInStrategy { stateService: StateService, twoFactorService: TwoFactorService, private keyConnectorService: KeyConnectorService, - private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction + private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + private authReqCryptoService: AuthRequestCryptoServiceAbstraction, + private i18nService: I18nService ) { super( cryptoService, @@ -70,6 +74,8 @@ export class SsoLogInStrategy extends LogInStrategy { } protected override async setMasterKey(tokenResponse: IdentityTokenResponse) { + // TODO: discuss how this is no longer true with TDE + // eventually we’ll need to support migration of existing TDE users to Key Connector const newSsoUser = tokenResponse.key == null; if (tokenResponse.keyConnectorUrl != null) { @@ -81,33 +87,85 @@ export class SsoLogInStrategy extends LogInStrategy { } } + // TODO: future passkey login strategy will need to support setting user key (decrypting via TDE or admin approval request) + // so might be worth moving this logic to a common place (base login strategy or a separate service?) protected override async setUserKey(tokenResponse: IdentityTokenResponse): Promise { - // If new user, return b/c we can't set the user key yet - if (tokenResponse.key === null) { - return; + const masterKeyEncryptedUserKey = tokenResponse.key; + + // Note: masterKeyEncryptedUserKey is undefined for SSO JIT provisioned users + // on account creation and subsequent logins (confirmed or unconfirmed) + // but that is fine for TDE so we cannot return if it is undefined + + if (masterKeyEncryptedUserKey) { + // set the master key encrypted user key if it exists + await this.cryptoService.setMasterKeyEncryptedUserKey(masterKeyEncryptedUserKey); } - // Existing user; proceed - - // User now may or may not have a master password - // but set the master key encrypted user key if it exists regardless - await this.cryptoService.setMasterKeyEncryptedUserKey(tokenResponse.key); - - // TODO: also admin approval request existence check should go here b/c that can give us a decrypted user key to set - // TODO: future passkey login strategy will need to support setting user key (decrypting via TDE or admin approval request) - // so might be worth moving this logic to a common place (base login strategy or a separate service?) const userDecryptionOptions = tokenResponse?.userDecryptionOptions; // Note: TDE and key connector are mutually exclusive if (userDecryptionOptions?.trustedDeviceOption) { - await this.trySetUserKeyWithDeviceKey(tokenResponse); + await this.trySetUserKeyWithApprovedAdminRequestIfExists(); + + const hasUserKey = await this.cryptoService.hasUserKey(); + + // Only try to set user key with device key if admin approval request was not successful + if (!hasUserKey) { + await this.trySetUserKeyWithDeviceKey(tokenResponse); + } } else if ( // TODO: remove tokenResponse.keyConnectorUrl when it's deprecated - tokenResponse.keyConnectorUrl || - userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl + masterKeyEncryptedUserKey != null && + (tokenResponse.keyConnectorUrl || userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl) ) { + // Key connector enabled for user await this.trySetUserKeyWithMasterKey(); } + + // Note: In the traditional SSO flow with MP without key connector, the lock component + // is responsible for deriving master key from MP entry and then decrypting the user key + } + + private async trySetUserKeyWithApprovedAdminRequestIfExists(): Promise { + // At this point a user could have an admin auth request that has been approved + const adminAuthReqStorable = await this.stateService.getAdminAuthRequest(); + + if (!adminAuthReqStorable) { + return; + } + + // Call server to see if admin auth request has been approved + const adminAuthReqResponse = await this.apiService.getAuthRequest(adminAuthReqStorable.id); + + if (adminAuthReqResponse?.requestApproved) { + // if masterPasswordHash has a value, we will always receive authReqResponse.key + // as authRequestPublicKey(masterKey) + authRequestPublicKey(masterPasswordHash) + if (adminAuthReqResponse.masterPasswordHash) { + await this.authReqCryptoService.setKeysAfterDecryptingSharedMasterKeyAndHash( + adminAuthReqResponse, + adminAuthReqStorable.privateKey + ); + } else { + // if masterPasswordHash is null, we will always receive authReqResponse.key + // as authRequestPublicKey(userKey) + await this.authReqCryptoService.setUserKeyAfterDecryptingSharedUserKey( + adminAuthReqResponse, + adminAuthReqStorable.privateKey + ); + } + + if (await this.cryptoService.hasUserKey()) { + // Now that we have a decrypted user key in memory, we can check if we + // need to establish trust on the current device + await this.deviceTrustCryptoService.trustDeviceIfRequired(); + + // if we successfully decrypted the user key, we can delete the admin auth request out of state + // TODO: eventually we post and clean up DB as well once consumed on client + await this.stateService.setAdminAuthRequest(null); + + this.platformUtilsService.showToast("success", null, this.i18nService.t("loginApproved")); + } + } } private async trySetUserKeyWithDeviceKey(tokenResponse: IdentityTokenResponse): Promise { diff --git a/libs/common/src/auth/models/domain/admin-auth-req-storable.ts b/libs/common/src/auth/models/domain/admin-auth-req-storable.ts new file mode 100644 index 0000000000..1da79510c9 --- /dev/null +++ b/libs/common/src/auth/models/domain/admin-auth-req-storable.ts @@ -0,0 +1,41 @@ +import { Utils } from "../../../platform/misc/utils"; + +// TODO: Tech Debt: potentially create a type Storage shape vs using a class here in the future +// type StorageShape { +// id: string; +// privateKey: string; +// } +// so we can get rid of the any type passed into fromJSON and coming out of ToJSON +export class AdminAuthRequestStorable { + id: string; + privateKey: ArrayBuffer; + + constructor(init?: Partial) { + if (init) { + Object.assign(this, init); + } + } + + toJSON() { + return { + id: this.id, + privateKey: Utils.fromBufferToByteString(this.privateKey), + }; + } + + static fromJSON(obj: any): AdminAuthRequestStorable { + if (obj == null) { + return null; + } + + let privateKeyBuffer = null; + if (obj.privateKey) { + privateKeyBuffer = Utils.fromByteStringToArray(obj.privateKey)?.buffer; + } + + return new AdminAuthRequestStorable({ + id: obj.id, + privateKey: privateKeyBuffer, + }); + } +} diff --git a/libs/common/src/auth/models/domain/log-in-credentials.ts b/libs/common/src/auth/models/domain/log-in-credentials.ts index ccf89359f6..7022bb66de 100644 --- a/libs/common/src/auth/models/domain/log-in-credentials.ts +++ b/libs/common/src/auth/models/domain/log-in-credentials.ts @@ -1,4 +1,4 @@ -import { MasterKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { MasterKey, UserKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { AuthenticationType } from "../../enums/authentication-type"; import { TokenTwoFactorRequest } from "../request/identity-token/token-two-factor.request"; @@ -38,8 +38,9 @@ export class PasswordlessLogInCredentials { public email: string, public accessCode: string, public authRequestId: string, - public decKey: MasterKey, - public localPasswordHash: string, + public decryptedUserKey: UserKey, + public decryptedMasterKey: MasterKey, + public decryptedMasterKeyHash: string, public twoFactor?: TokenTwoFactorRequest ) {} } diff --git a/libs/common/src/auth/services/auth-request-crypto.service.implementation.ts b/libs/common/src/auth/services/auth-request-crypto.service.implementation.ts new file mode 100644 index 0000000000..33a9dd80a1 --- /dev/null +++ b/libs/common/src/auth/services/auth-request-crypto.service.implementation.ts @@ -0,0 +1,81 @@ +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { Utils } from "../../platform/misc/utils"; +import { + UserKey, + SymmetricCryptoKey, + MasterKey, +} from "../../platform/models/domain/symmetric-crypto-key"; +import { AuthRequestCryptoServiceAbstraction } from "../abstractions/auth-request-crypto.service.abstraction"; +import { AuthRequestResponse } from "../models/response/auth-request.response"; + +export class AuthRequestCryptoServiceImplementation implements AuthRequestCryptoServiceAbstraction { + constructor(private cryptoService: CryptoService) {} + + async setUserKeyAfterDecryptingSharedUserKey( + authReqResponse: AuthRequestResponse, + authReqPrivateKey: ArrayBuffer + ) { + const userKey = await this.decryptPubKeyEncryptedUserKey( + authReqResponse.key, + authReqPrivateKey + ); + await this.cryptoService.setUserKey(userKey); + } + + async setKeysAfterDecryptingSharedMasterKeyAndHash( + authReqResponse: AuthRequestResponse, + authReqPrivateKey: ArrayBuffer + ) { + const { masterKey, masterKeyHash } = await this.decryptPubKeyEncryptedMasterKeyAndHash( + authReqResponse.key, + authReqResponse.masterPasswordHash, + authReqPrivateKey + ); + + // Decrypt and set user key in state + const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); + + // Set masterKey + masterKeyHash in state after decryption (in case decryption fails) + await this.cryptoService.setMasterKey(masterKey); + await this.cryptoService.setMasterKeyHash(masterKeyHash); + + await this.cryptoService.setUserKey(userKey); + } + + // Decryption helpers + async decryptPubKeyEncryptedUserKey( + pubKeyEncryptedUserKey: string, + privateKey: ArrayBuffer + ): Promise { + const decryptedUserKeyArrayBuffer = await this.cryptoService.rsaDecrypt( + pubKeyEncryptedUserKey, + privateKey + ); + + return new SymmetricCryptoKey(decryptedUserKeyArrayBuffer) as UserKey; + } + + async decryptPubKeyEncryptedMasterKeyAndHash( + pubKeyEncryptedMasterKey: string, + pubKeyEncryptedMasterKeyHash: string, + privateKey: ArrayBuffer + ): Promise<{ masterKey: MasterKey; masterKeyHash: string }> { + const decryptedMasterKeyArrayBuffer = await this.cryptoService.rsaDecrypt( + pubKeyEncryptedMasterKey, + privateKey + ); + + const decryptedMasterKeyHashArrayBuffer = await this.cryptoService.rsaDecrypt( + pubKeyEncryptedMasterKeyHash, + privateKey + ); + + const masterKey = new SymmetricCryptoKey(decryptedMasterKeyArrayBuffer) as MasterKey; + const masterKeyHash = Utils.fromBufferToUtf8(decryptedMasterKeyHashArrayBuffer); + + return { + masterKey, + masterKeyHash, + }; + } +} diff --git a/libs/common/src/auth/services/auth-request-crypto.service.spec.ts b/libs/common/src/auth/services/auth-request-crypto.service.spec.ts new file mode 100644 index 0000000000..22a098cf4d --- /dev/null +++ b/libs/common/src/auth/services/auth-request-crypto.service.spec.ts @@ -0,0 +1,169 @@ +import { mock } from "jest-mock-extended"; + +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { Utils } from "../../platform/misc/utils"; +import { + MasterKey, + SymmetricCryptoKey, + UserKey, +} from "../../platform/models/domain/symmetric-crypto-key"; +import { AuthRequestCryptoServiceAbstraction } from "../abstractions/auth-request-crypto.service.abstraction"; +import { AuthRequestResponse } from "../models/response/auth-request.response"; + +import { AuthRequestCryptoServiceImplementation } from "./auth-request-crypto.service.implementation"; + +describe("AuthRequestCryptoService", () => { + let authReqCryptoService: AuthRequestCryptoServiceAbstraction; + const cryptoService = mock(); + let mockPrivateKey: ArrayBuffer; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + + authReqCryptoService = new AuthRequestCryptoServiceImplementation(cryptoService); + + mockPrivateKey = new ArrayBuffer(64); + }); + + it("instantiates", () => { + expect(authReqCryptoService).not.toBeFalsy(); + }); + + describe("setUserKeyAfterDecryptingSharedUserKey", () => { + it("decrypts and sets user key when given valid auth request response and private key", async () => { + // Arrange + const mockAuthReqResponse = { + key: "authReqPublicKeyEncryptedUserKey", + } as AuthRequestResponse; + + const mockDecryptedUserKey = {} as UserKey; + jest + .spyOn(authReqCryptoService, "decryptPubKeyEncryptedUserKey") + .mockResolvedValueOnce(mockDecryptedUserKey); + + cryptoService.setUserKey.mockResolvedValueOnce(undefined); + + // Act + await authReqCryptoService.setUserKeyAfterDecryptingSharedUserKey( + mockAuthReqResponse, + mockPrivateKey + ); + + // Assert + expect(authReqCryptoService.decryptPubKeyEncryptedUserKey).toBeCalledWith( + mockAuthReqResponse.key, + mockPrivateKey + ); + expect(cryptoService.setUserKey).toBeCalledWith(mockDecryptedUserKey); + }); + }); + + describe("setKeysAfterDecryptingSharedMasterKeyAndHash", () => { + it("decrypts and sets master key and hash and user key when given valid auth request response and private key", async () => { + // Arrange + const mockAuthReqResponse = { + key: "authReqPublicKeyEncryptedMasterKey", + masterPasswordHash: "authReqPublicKeyEncryptedMasterKeyHash", + } as AuthRequestResponse; + + const mockDecryptedMasterKey = {} as MasterKey; + const mockDecryptedMasterKeyHash = "mockDecryptedMasterKeyHash"; + const mockDecryptedUserKey = {} as UserKey; + + jest + .spyOn(authReqCryptoService, "decryptPubKeyEncryptedMasterKeyAndHash") + .mockResolvedValueOnce({ + masterKey: mockDecryptedMasterKey, + masterKeyHash: mockDecryptedMasterKeyHash, + }); + + cryptoService.setMasterKey.mockResolvedValueOnce(undefined); + cryptoService.setMasterKeyHash.mockResolvedValueOnce(undefined); + cryptoService.decryptUserKeyWithMasterKey.mockResolvedValueOnce(mockDecryptedUserKey); + cryptoService.setUserKey.mockResolvedValueOnce(undefined); + + // Act + await authReqCryptoService.setKeysAfterDecryptingSharedMasterKeyAndHash( + mockAuthReqResponse, + mockPrivateKey + ); + + // Assert + expect(authReqCryptoService.decryptPubKeyEncryptedMasterKeyAndHash).toBeCalledWith( + mockAuthReqResponse.key, + mockAuthReqResponse.masterPasswordHash, + mockPrivateKey + ); + expect(cryptoService.setMasterKey).toBeCalledWith(mockDecryptedMasterKey); + expect(cryptoService.setMasterKeyHash).toBeCalledWith(mockDecryptedMasterKeyHash); + expect(cryptoService.decryptUserKeyWithMasterKey).toBeCalledWith(mockDecryptedMasterKey); + expect(cryptoService.setUserKey).toBeCalledWith(mockDecryptedUserKey); + }); + }); + + describe("decryptAuthReqPubKeyEncryptedUserKey", () => { + it("returns a decrypted user key when given valid public key encrypted user key and an auth req private key", async () => { + // Arrange + const mockPubKeyEncryptedUserKey = "pubKeyEncryptedUserKey"; + const mockDecryptedUserKeyArrayBuffer = new ArrayBuffer(64); + const mockDecryptedUserKey = new SymmetricCryptoKey( + mockDecryptedUserKeyArrayBuffer + ) as UserKey; + + cryptoService.rsaDecrypt.mockResolvedValueOnce(mockDecryptedUserKeyArrayBuffer); + + // Act + const result = await authReqCryptoService.decryptPubKeyEncryptedUserKey( + mockPubKeyEncryptedUserKey, + mockPrivateKey + ); + + // Assert + expect(cryptoService.rsaDecrypt).toBeCalledWith(mockPubKeyEncryptedUserKey, mockPrivateKey); + expect(result).toEqual(mockDecryptedUserKey); + }); + }); + + describe("decryptAuthReqPubKeyEncryptedMasterKeyAndHash", () => { + it("returns a decrypted master key and hash when given a valid public key encrypted master key, public key encrypted master key hash, and an auth req private key", async () => { + // Arrange + const mockPubKeyEncryptedMasterKey = "pubKeyEncryptedMasterKey"; + const mockPubKeyEncryptedMasterKeyHash = "pubKeyEncryptedMasterKeyHash"; + + const mockDecryptedMasterKeyArrayBuffer = new ArrayBuffer(64); + const mockDecryptedMasterKey = new SymmetricCryptoKey( + mockDecryptedMasterKeyArrayBuffer + ) as MasterKey; + const mockDecryptedMasterKeyHashArrayBuffer = new ArrayBuffer(64); + const mockDecryptedMasterKeyHash = Utils.fromBufferToUtf8( + mockDecryptedMasterKeyHashArrayBuffer + ); + + cryptoService.rsaDecrypt + .mockResolvedValueOnce(mockDecryptedMasterKeyArrayBuffer) + .mockResolvedValueOnce(mockDecryptedMasterKeyHashArrayBuffer); + + // Act + const result = await authReqCryptoService.decryptPubKeyEncryptedMasterKeyAndHash( + mockPubKeyEncryptedMasterKey, + mockPubKeyEncryptedMasterKeyHash, + mockPrivateKey + ); + + // Assert + expect(cryptoService.rsaDecrypt).toHaveBeenNthCalledWith( + 1, + mockPubKeyEncryptedMasterKey, + mockPrivateKey + ); + expect(cryptoService.rsaDecrypt).toHaveBeenNthCalledWith( + 2, + mockPubKeyEncryptedMasterKeyHash, + mockPrivateKey + ); + expect(result.masterKey).toEqual(mockDecryptedMasterKey); + expect(result.masterKeyHash).toEqual(mockDecryptedMasterKeyHash); + }); + }); +}); diff --git a/libs/common/src/auth/services/auth.service.ts b/libs/common/src/auth/services/auth.service.ts index 53bf940ce6..29912feb61 100644 --- a/libs/common/src/auth/services/auth.service.ts +++ b/libs/common/src/auth/services/auth.service.ts @@ -18,6 +18,7 @@ import { StateService } from "../../platform/abstractions/state.service"; import { Utils } from "../../platform/misc/utils"; import { MasterKey } from "../../platform/models/domain/symmetric-crypto-key"; import { PasswordStrengthServiceAbstraction } from "../../tools/password-strength"; +import { AuthRequestCryptoServiceAbstraction } from "../abstractions/auth-request-crypto.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; import { KeyConnectorService } from "../abstractions/key-connector.service"; @@ -105,7 +106,8 @@ export class AuthService implements AuthServiceAbstraction { protected encryptService: EncryptService, protected passwordStrengthService: PasswordStrengthServiceAbstraction, protected policyService: PolicyService, - protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction + protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + protected authReqCryptoService: AuthRequestCryptoServiceAbstraction ) {} async logIn( @@ -152,7 +154,9 @@ export class AuthService implements AuthServiceAbstraction { this.stateService, this.twoFactorService, this.keyConnectorService, - this.deviceTrustCryptoService + this.deviceTrustCryptoService, + this.authReqCryptoService, + this.i18nService ); break; case AuthenticationType.UserApi: @@ -180,7 +184,8 @@ export class AuthService implements AuthServiceAbstraction { this.messagingService, this.logService, this.stateService, - this.twoFactorService + this.twoFactorService, + this.deviceTrustCryptoService ); break; } @@ -299,24 +304,34 @@ export class AuthService implements AuthServiceAbstraction { key: string, requestApproved: boolean ): Promise { - // TODO: This currently depends on always having the Master Key and MP Hash - // We need to change this to using a different method (possibly server auth code + user key) const pubKey = Utils.fromB64ToArray(key); + const masterKey = await this.cryptoService.getMasterKey(); - if (!masterKey) { - throw new Error("Master key not found"); - } - const encryptedKey = await this.cryptoService.rsaEncrypt(masterKey.encKey, pubKey.buffer); - let encryptedMasterPasswordHash = null; - if ((await this.stateService.getKeyHash()) != null) { - encryptedMasterPasswordHash = await this.cryptoService.rsaEncrypt( - Utils.fromUtf8ToArray(await this.stateService.getKeyHash()), - pubKey.buffer - ); + let keyToEncrypt; + let encryptedMasterKeyHash = null; + + if (masterKey) { + keyToEncrypt = masterKey.encKey; + + // Only encrypt the master password hash if masterKey exists as + // we won't have a masterKeyHash without a masterKey + const masterKeyHash = await this.stateService.getKeyHash(); + if (masterKeyHash != null) { + encryptedMasterKeyHash = await this.cryptoService.rsaEncrypt( + Utils.fromUtf8ToArray(masterKeyHash), + pubKey.buffer + ); + } + } else { + const userKey = await this.cryptoService.getUserKey(); + keyToEncrypt = userKey.key; } + + const encryptedKey = await this.cryptoService.rsaEncrypt(keyToEncrypt, pubKey.buffer); + const request = new PasswordlessAuthRequest( encryptedKey.encryptedString, - encryptedMasterPasswordHash.encryptedString, + encryptedMasterKeyHash?.encryptedString, await this.appIdService.getAppId(), requestApproved ); 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 c91e9b9f11..a1ed88c718 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 @@ -3,6 +3,8 @@ import { AppIdService } from "../../platform/abstractions/app-id.service"; import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { StateService } from "../../platform/abstractions/state.service"; import { EncString } from "../../platform/models/domain/enc-string"; import { @@ -21,12 +23,14 @@ import { export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstraction { constructor( - protected cryptoFunctionService: CryptoFunctionService, - protected cryptoService: CryptoService, - protected encryptService: EncryptService, - protected stateService: StateService, - protected appIdService: AppIdService, - protected devicesApiService: DevicesApiServiceAbstraction + private cryptoFunctionService: CryptoFunctionService, + private cryptoService: CryptoService, + private encryptService: EncryptService, + private stateService: StateService, + private appIdService: AppIdService, + private devicesApiService: DevicesApiServiceAbstraction, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService ) {} /** @@ -41,6 +45,15 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac await this.stateService.setShouldTrustDevice(value); } + async trustDeviceIfRequired(): Promise { + const shouldTrustDevice = await this.getShouldTrustDevice(); + if (shouldTrustDevice) { + await this.trustDevice(); + // reset the trust choice + await this.setShouldTrustDevice(false); + } + } + async trustDevice(): Promise { // Attempt to get user key const userKey: UserKey = await this.cryptoService.getUserKey(); @@ -84,6 +97,9 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac // store device key in local/secure storage if enc keys posted to server successfully await this.setDeviceKey(deviceKey); + + this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted")); + return deviceResponse; } diff --git a/libs/common/src/auth/services/device-trust-crypto.service.spec.ts b/libs/common/src/auth/services/device-trust-crypto.service.spec.ts index 757f94d08a..bf86f5fd0e 100644 --- a/libs/common/src/auth/services/device-trust-crypto.service.spec.ts +++ b/libs/common/src/auth/services/device-trust-crypto.service.spec.ts @@ -1,11 +1,14 @@ -import { matches, mock, mockReset } from "jest-mock-extended"; +import { matches, mock } from "jest-mock-extended"; import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; import { DeviceType } from "../../enums"; import { EncryptionType } from "../../enums/encryption-type.enum"; import { AppIdService } from "../../platform/abstractions/app-id.service"; import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { StateService } from "../../platform/abstractions/state.service"; import { EncString } from "../../platform/models/domain/enc-string"; import { @@ -13,14 +16,12 @@ import { DeviceKey, UserKey, } from "../../platform/models/domain/symmetric-crypto-key"; -import { CryptoService } from "../../platform/services/crypto.service"; import { CsprngArray } from "../../types/csprng"; import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction"; import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request"; import { ProtectedDeviceResponse } from "../models/response/protected-device.response"; import { DeviceTrustCryptoService } from "./device-trust-crypto.service.implementation"; - describe("deviceTrustCryptoService", () => { let deviceTrustCryptoService: DeviceTrustCryptoService; @@ -30,13 +31,11 @@ describe("deviceTrustCryptoService", () => { const stateService = mock(); const appIdService = mock(); const devicesApiService = mock(); + const i18nService = mock(); + const platformUtilsService = mock(); beforeEach(() => { - mockReset(cryptoFunctionService); - mockReset(encryptService); - mockReset(stateService); - mockReset(appIdService); - mockReset(devicesApiService); + jest.clearAllMocks(); deviceTrustCryptoService = new DeviceTrustCryptoService( cryptoFunctionService, @@ -44,7 +43,9 @@ describe("deviceTrustCryptoService", () => { encryptService, stateService, appIdService, - devicesApiService + devicesApiService, + i18nService, + platformUtilsService ); }); @@ -79,6 +80,34 @@ describe("deviceTrustCryptoService", () => { }); }); + describe("trustDeviceIfRequired", () => { + it("should trust device and reset when getShouldTrustDevice returns true", async () => { + jest.spyOn(deviceTrustCryptoService, "getShouldTrustDevice").mockResolvedValue(true); + jest.spyOn(deviceTrustCryptoService, "trustDevice").mockResolvedValue({} as DeviceResponse); + jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice").mockResolvedValue(); + + await deviceTrustCryptoService.trustDeviceIfRequired(); + + expect(deviceTrustCryptoService.getShouldTrustDevice).toHaveBeenCalledTimes(1); + expect(deviceTrustCryptoService.trustDevice).toHaveBeenCalledTimes(1); + expect(deviceTrustCryptoService.setShouldTrustDevice).toHaveBeenCalledWith(false); + }); + + it("should not trust device nor reset when getShouldTrustDevice returns false", async () => { + const getShouldTrustDeviceSpy = jest + .spyOn(deviceTrustCryptoService, "getShouldTrustDevice") + .mockResolvedValue(false); + const trustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "trustDevice"); + const setShouldTrustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice"); + + await deviceTrustCryptoService.trustDeviceIfRequired(); + + expect(getShouldTrustDeviceSpy).toHaveBeenCalledTimes(1); + expect(trustDeviceSpy).not.toHaveBeenCalled(); + expect(setShouldTrustDeviceSpy).not.toHaveBeenCalled(); + }); + }); + describe("Trusted Device Encryption core logic tests", () => { const deviceKeyBytesLength = 64; const userKeyBytesLength = 64; diff --git a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts index b4c32cc43c..7d1ade8332 100644 --- a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts +++ b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts @@ -2,6 +2,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { OrganizationUserService } from "../../abstractions/organization-user/organization-user.service"; import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { StateService } from "../../platform/abstractions/state.service"; @@ -31,6 +32,38 @@ describe("PasswordResetEnrollmentServiceImplementation", () => { ); }); + describe("enrollIfRequired", () => { + it("should not enroll when user is already enrolled in password reset", async () => { + const mockResponse = new OrganizationAutoEnrollStatusResponse({ + ResetPasswordEnabled: true, + Id: "orgId", + }); + organizationApiService.getAutoEnrollStatus.mockResolvedValue(mockResponse); + + const enrollSpy = jest.spyOn(service, "enroll"); + enrollSpy.mockResolvedValue(); + + await service.enrollIfRequired("ssoId"); + + expect(service.enroll).not.toHaveBeenCalled(); + }); + + it("should enroll when user is not enrolled in password reset", async () => { + const mockResponse = new OrganizationAutoEnrollStatusResponse({ + ResetPasswordEnabled: false, + Id: "orgId", + }); + organizationApiService.getAutoEnrollStatus.mockResolvedValue(mockResponse); + + const enrollSpy = jest.spyOn(service, "enroll"); + enrollSpy.mockResolvedValue(); + + await service.enrollIfRequired("ssoId"); + + expect(service.enroll).toHaveBeenCalled(); + }); + }); + describe("enroll", () => { it("should throw an error if the organization keys are not found", async () => { organizationApiService.getKeys.mockResolvedValue(null); diff --git a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts index ef700855d5..fad6459f45 100644 --- a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts +++ b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts @@ -19,6 +19,16 @@ export class PasswordResetEnrollmentServiceImplementation protected i18nService: I18nService ) {} + async enrollIfRequired(organizationSsoIdentifier: string): Promise { + const orgAutoEnrollStatusResponse = await this.organizationApiService.getAutoEnrollStatus( + organizationSsoIdentifier + ); + + if (!orgAutoEnrollStatusResponse.resetPasswordEnabled) { + await this.enroll(orgAutoEnrollStatusResponse.id, null, null); + } + } + async enroll(organizationId: string): Promise; async enroll(organizationId: string, userId: string, userKey: UserKey): Promise; async enroll(organizationId: string, userId?: string, userKey?: UserKey): Promise { diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 97ad807fc2..c52355b891 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -5,6 +5,7 @@ import { OrganizationData } from "../../admin-console/models/data/organization.d import { PolicyData } from "../../admin-console/models/data/policy.data"; import { ProviderData } from "../../admin-console/models/data/provider.data"; import { Policy } from "../../admin-console/models/domain/policy"; +import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; import { EnvironmentUrls } from "../../auth/models/domain/environment-urls"; import { ForceResetPasswordReason } from "../../auth/models/domain/force-reset-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; @@ -262,6 +263,11 @@ export abstract class StateService { setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise; getDeviceKey: (options?: StorageOptions) => Promise; setDeviceKey: (value: DeviceKey, options?: StorageOptions) => Promise; + getAdminAuthRequest: (options?: StorageOptions) => Promise; + setAdminAuthRequest: ( + adminAuthRequest: AdminAuthRequestStorable, + options?: StorageOptions + ) => Promise; getShouldTrustDevice: (options?: StorageOptions) => Promise; setShouldTrustDevice: (value: boolean, options?: StorageOptions) => Promise; getAccountDecryptionOptions: ( diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index bcf8b15773..0577bbc405 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -6,6 +6,7 @@ import { PolicyData } from "../../../admin-console/models/data/policy.data"; import { ProviderData } from "../../../admin-console/models/data/provider.data"; import { Policy } from "../../../admin-console/models/domain/policy"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable"; import { EnvironmentUrls } from "../../../auth/models/domain/environment-urls"; import { ForceResetPasswordReason } from "../../../auth/models/domain/force-reset-password-reason"; import { KeyConnectorUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/key-connector-user-decryption-option"; @@ -369,6 +370,7 @@ export class Account { settings?: AccountSettings = new AccountSettings(); tokens?: AccountTokens = new AccountTokens(); decryptionOptions?: AccountDecryptionOptions = new AccountDecryptionOptions(); + adminAuthRequest?: AdminAuthRequestStorable = null; constructor(init: Partial) { Object.assign(this, { @@ -396,6 +398,9 @@ export class Account { ...new AccountDecryptionOptions(), ...init?.decryptionOptions, }, + adminAuthRequest: init?.adminAuthRequest + ? new AdminAuthRequestStorable(init?.adminAuthRequest) + : null, }); } @@ -410,6 +415,7 @@ export class Account { settings: AccountSettings.fromJSON(json?.settings), tokens: AccountTokens.fromJSON(json?.tokens), decryptionOptions: AccountDecryptionOptions.fromJSON(json?.decryptionOptions), + adminAuthRequest: AdminAuthRequestStorable.fromJSON(json?.adminAuthRequest), }); } } diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 02984fe831..7dc641ab06 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -6,6 +6,7 @@ import { OrganizationData } from "../../admin-console/models/data/organization.d import { PolicyData } from "../../admin-console/models/data/policy.data"; import { ProviderData } from "../../admin-console/models/data/provider.data"; import { Policy } from "../../admin-console/models/domain/policy"; +import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; import { EnvironmentUrls } from "../../auth/models/domain/environment-urls"; import { ForceResetPasswordReason } from "../../auth/models/domain/force-reset-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; @@ -1326,6 +1327,37 @@ export class StateService< await this.saveAccount(account, options); } + async getAdminAuthRequest(options?: StorageOptions): Promise { + options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); + + if (options?.userId == null) { + return null; + } + + const account = await this.getAccount(options); + + return account?.adminAuthRequest + ? AdminAuthRequestStorable.fromJSON(account.adminAuthRequest) + : null; + } + + async setAdminAuthRequest( + adminAuthRequest: AdminAuthRequestStorable, + options?: StorageOptions + ): Promise { + options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); + + if (options?.userId == null) { + return; + } + + const account = await this.getAccount(options); + + account.adminAuthRequest = adminAuthRequest; + + await this.saveAccount(account, options); + } + async getShouldTrustDevice(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); @@ -3112,11 +3144,12 @@ export class StateService< } } - // settings persist even on reset, and are not effected by this method + // settings persist even on reset, and are not affected by this method protected resetAccount(account: TAccount) { const persistentAccountInformation = { settings: account.settings, keys: { deviceKey: account.keys.deviceKey }, + adminAuthRequest: account.adminAuthRequest, }; return Object.assign(this.createAccount(), persistentAccountInformation); } diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index ad7c134889..185ef91549 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -254,6 +254,10 @@ export class ApiService implements ApiServiceAbstraction { const r = await this.send("POST", "/auth-requests/", request, false, true); return new AuthRequestResponse(r); } + async postAdminAuthRequest(request: PasswordlessCreateAuthRequest): Promise { + const r = await this.send("POST", "/auth-requests/admin-request", request, true, true); + return new AuthRequestResponse(r); + } async getAuthResponse(id: string, accessCode: string): Promise { const path = `/auth-requests/${id}/response?code=${accessCode}`;