From 8a9e59094a6dad5ab0e6d3625de5d9148826c327 Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Sun, 5 Feb 2023 10:57:21 -0500 Subject: [PATCH] Login Flows (#4411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [SG-171] Login with a device request: Desktop (#3999) * Move LoginWithDeviceComponent to libs * Create login module * Remove login component from previous location * Move startPasswordlessLogin method to base class * Register route for login with device component * Add new localizations * Add Login with Device page styles * Add desktop login with device component * Spacing fix * Add content box around page * Update wording of helper text * Make resend timeout a class variable * SG-173 - Login device approval desktop (#4232) * SG-173 Implemented UI and login for login approval request * SG-173 - Show login approval after login * SG-173 Fetch login requests if the setting is true * SG-173 Add subheading to new setting * SG-173 Handle modal dismiss denying login request * SG-173 Fix pr comments * SG-173 Implemented desktop alerts * SG-173 Replicated behaviour of openViewRef * SG-173 Fixed previous commit * SG-173 PR fix * SG-173 Fix PR comment * SG-173 Added missing service injection * SG-173 Added logo to notifications * SG-173 Fix PR comments * [SG-910] Override self hosted check for desktop (#4405) * Override base component self hosted check * Add selfhost check to environment service * [SG-170] Login with Device Request - Browser (#4198) * work: ui stuff * fix: use parent * fix: words * [SG-987] [SG-988] [SG-989] Fix passwordless login request (#4573) * SG-987 Fix notification text and button options * SG-988 Fix approval and decline confirmation toasts * SG-989 Fix methods called * SG-988 Undo previous commit * [SG-1034] [Defect] - Vault is empty upon login confirmation (#4646) * fix: sync after login * undo: whoops --------- Co-authored-by: Carlos Gonçalves Co-authored-by: Brandon Maharaj Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com> --- apps/browser/config/base.json | 4 +- apps/browser/config/development.json | 4 +- apps/browser/src/_locales/en/messages.json | 28 ++- .../browser/src/background/main.background.ts | 6 +- .../service_factories/auth-service.factory.ts | 7 +- .../accounts/login-with-device.component.html | 36 +++ .../accounts/login-with-device.component.ts | 68 ++++++ .../src/popup/accounts/login.component.html | 12 + .../src/popup/accounts/login.component.ts | 10 + .../src/popup/app-routing.animations.ts | 5 +- apps/browser/src/popup/app-routing.module.ts | 7 + apps/browser/src/popup/app.module.ts | 2 + apps/browser/src/popup/scss/base.scss | 32 +++ apps/browser/src/popup/scss/variables.scss | 4 + apps/cli/src/bw.ts | 3 +- .../login/login-approval.component.html | 43 ++++ .../login/login-approval.component.ts | 170 ++++++++++++++ .../login/login-with-device.component.html | 52 +++++ .../login/login-with-device.component.ts | 106 +++++++++ .../accounts/{ => login}/login.component.html | 6 + .../accounts/{ => login}/login.component.ts | 14 +- .../src/app/accounts/login/login.module.ts | 14 ++ .../src/app/accounts/settings.component.html | 15 ++ .../src/app/accounts/settings.component.ts | 6 + apps/desktop/src/app/app-routing.module.ts | 7 +- apps/desktop/src/app/app.component.ts | 22 ++ apps/desktop/src/app/app.module.ts | 7 +- apps/desktop/src/locales/en/messages.json | 104 ++++++++- apps/desktop/src/scss/pages.scss | 58 ++++- apps/desktop/src/scss/variables.scss | 5 + .../electron-main-messaging.service.ts | 19 +- .../src/vault/app/vault/vault.component.ts | 14 +- .../login/login-with-device.component.ts | 201 +++-------------- .../src/app/accounts/login/login.component.ts | 12 - .../components/login-with-device.component.ts | 210 ++++++++++++++++++ .../angular/src/components/login.component.ts | 16 +- .../src/services/jslib-services.module.ts | 1 + libs/common/src/abstractions/api.service.ts | 5 + libs/common/src/abstractions/auth.service.ts | 7 +- .../src/abstractions/environment.service.ts | 5 + libs/common/src/abstractions/state.service.ts | 2 + libs/common/src/misc/flags.ts | 1 + libs/common/src/models/domain/account.ts | 1 + .../request/passwordless-auth.request.ts | 8 + .../models/response/auth-request.response.ts | 35 ++- libs/common/src/services/api.service.ts | 28 +++ libs/common/src/services/auth.service.ts | 32 ++- .../src/services/environment.service.ts | 9 + .../src/services/notifications.service.ts | 11 +- libs/common/src/services/state.service.ts | 18 ++ 50 files changed, 1281 insertions(+), 211 deletions(-) create mode 100644 apps/browser/src/popup/accounts/login-with-device.component.html create mode 100644 apps/browser/src/popup/accounts/login-with-device.component.ts create mode 100644 apps/desktop/src/app/accounts/login/login-approval.component.html create mode 100644 apps/desktop/src/app/accounts/login/login-approval.component.ts create mode 100644 apps/desktop/src/app/accounts/login/login-with-device.component.html create mode 100644 apps/desktop/src/app/accounts/login/login-with-device.component.ts rename apps/desktop/src/app/accounts/{ => login}/login.component.html (94%) rename apps/desktop/src/app/accounts/{ => login}/login.component.ts (92%) create mode 100644 apps/desktop/src/app/accounts/login/login.module.ts create mode 100644 libs/angular/src/components/login-with-device.component.ts create mode 100644 libs/common/src/models/request/passwordless-auth.request.ts diff --git a/apps/browser/config/base.json b/apps/browser/config/base.json index 6df6c2cfdb1..81b11cd38b7 100644 --- a/apps/browser/config/base.json +++ b/apps/browser/config/base.json @@ -1,4 +1,6 @@ { "dev_flags": {}, - "flags": {} + "flags": { + "showPasswordless": true + } } diff --git a/apps/browser/config/development.json b/apps/browser/config/development.json index a97475823c7..972812a9c59 100644 --- a/apps/browser/config/development.json +++ b/apps/browser/config/development.json @@ -5,5 +5,7 @@ "base": "https://localhost:8080" } }, - "flags": {} + "flags": { + "showPasswordless": true + } } diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 26ecc0396f4..fa833a98af7 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1985,7 +1985,7 @@ "message": "Organization suspended." }, "disabledOrganizationFilterError": { - "message" : "Items in suspended Organizations cannot be accessed. Contact your Organization owner for assistance." + "message": "Items in suspended Organizations cannot be accessed. Contact your Organization owner for assistance." }, "cardBrandMir": { "message": "Mir" @@ -2050,12 +2050,36 @@ "rememberEmail": { "message": "Remember email" }, + "loginWithDevice": { + "message": "Log in with device" + }, + "loginWithDeviceEnabledInfo": { + "message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?" + }, + "fingerprintPhraseHeader": { + "message": "Fingerprint phrase" + }, + "fingerprintMatchInfo": { + "message": "Please make sure your vault is unlocked and the Fingerprint phrase matches on the other device." + }, + "resendNotification": { + "message": "Resend notification" + }, + "viewAllLoginOptions": { + "message": "View all log in options" + }, + "notificationSentDevice": { + "message": "A notification has been sent to your device." + }, + "logInInitiated": { + "message": "Log in initiated" + }, "exposedMasterPassword": { "message": "Exposed Master Password" }, "exposedMasterPasswordDesc": { "message": "Password found in a data breach. Use a unique password to protect your account. Are you sure you want to use an exposed password?" - }, + }, "weakAndExposedMasterPassword": { "message": "Weak and Exposed Master Password" }, diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 8561564975a..4be9a03376d 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -374,7 +374,8 @@ export default class MainBackground { this.environmentService, this.stateService, this.twoFactorService, - this.i18nService + this.i18nService, + this.encryptService ); this.vaultTimeoutSettingsService = new VaultTimeoutSettingsService( @@ -460,7 +461,8 @@ export default class MainBackground { logoutCallback, this.logService, this.stateService, - this.authService + this.authService, + this.messagingService ); this.popupUtilsService = new PopupUtilsService(isPrivateMode); diff --git a/apps/browser/src/background/service_factories/auth-service.factory.ts b/apps/browser/src/background/service_factories/auth-service.factory.ts index 6f4fb322b00..495fa1b0df0 100644 --- a/apps/browser/src/background/service_factories/auth-service.factory.ts +++ b/apps/browser/src/background/service_factories/auth-service.factory.ts @@ -4,6 +4,7 @@ import { AuthService } from "@bitwarden/common/services/auth.service"; import { apiServiceFactory, ApiServiceInitOptions } from "./api-service.factory"; import { appIdServiceFactory } from "./app-id-service.factory"; import { cryptoServiceFactory, CryptoServiceInitOptions } from "./crypto-service.factory"; +import { EncryptServiceInitOptions, encryptServiceFactory } from "./encrypt-service.factory"; import { environmentServiceFactory, EnvironmentServiceInitOptions, @@ -37,7 +38,8 @@ export type AuthServiceInitOptions = AuthServiceFactoyOptions & EnvironmentServiceInitOptions & StateServiceInitOptions & TwoFactorServiceInitOptions & - I18nServiceInitOptions; + I18nServiceInitOptions & + EncryptServiceInitOptions; export function authServiceFactory( cache: { authService?: AbstractAuthService } & CachedServices, @@ -60,7 +62,8 @@ export function authServiceFactory( await environmentServiceFactory(cache, opts), await stateServiceFactory(cache, opts), await twoFactorServiceFactory(cache, opts), - await i18nServiceFactory(cache, opts) + await i18nServiceFactory(cache, opts), + await encryptServiceFactory(cache, opts) ) ); } diff --git a/apps/browser/src/popup/accounts/login-with-device.component.html b/apps/browser/src/popup/accounts/login-with-device.component.html new file mode 100644 index 00000000000..cb2a248170c --- /dev/null +++ b/apps/browser/src/popup/accounts/login-with-device.component.html @@ -0,0 +1,36 @@ + diff --git a/apps/browser/src/popup/accounts/login-with-device.component.ts b/apps/browser/src/popup/accounts/login-with-device.component.ts new file mode 100644 index 00000000000..a286f4dd3b4 --- /dev/null +++ b/apps/browser/src/popup/accounts/login-with-device.component.ts @@ -0,0 +1,68 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Router } from "@angular/router"; + +import { LoginWithDeviceComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/components/login-with-device.component"; +import { AnonymousHubService } from "@bitwarden/common/abstractions/anonymousHub.service"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AppIdService } from "@bitwarden/common/abstractions/appId.service"; +import { AuthService } from "@bitwarden/common/abstractions/auth.service"; +import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; +import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { LoginService } from "@bitwarden/common/abstractions/login.service"; +import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; + +@Component({ + selector: "app-login-with-device", + templateUrl: "login-with-device.component.html", +}) +export class LoginWithDeviceComponent + extends BaseLoginWithDeviceComponent + implements OnInit, OnDestroy +{ + constructor( + router: Router, + cryptoService: CryptoService, + cryptoFunctionService: CryptoFunctionService, + appIdService: AppIdService, + passwordGenerationService: PasswordGenerationService, + apiService: ApiService, + authService: AuthService, + logService: LogService, + environmentService: EnvironmentService, + i18nService: I18nService, + platformUtilsService: PlatformUtilsService, + anonymousHubService: AnonymousHubService, + validationService: ValidationService, + stateService: StateService, + loginService: LoginService, + syncService: SyncService + ) { + super( + router, + cryptoService, + cryptoFunctionService, + appIdService, + passwordGenerationService, + apiService, + authService, + logService, + environmentService, + i18nService, + platformUtilsService, + anonymousHubService, + validationService, + stateService, + loginService + ); + super.onSuccessfulLogin = async () => { + await syncService.fullSync(true); + }; + } +} diff --git a/apps/browser/src/popup/accounts/login.component.html b/apps/browser/src/popup/accounts/login.component.html index a9cfb2e26d7..d3db7069644 100644 --- a/apps/browser/src/popup/accounts/login.component.html +++ b/apps/browser/src/popup/accounts/login.component.html @@ -9,6 +9,13 @@
+ + +
+ +
diff --git a/apps/browser/src/popup/accounts/login.component.ts b/apps/browser/src/popup/accounts/login.component.ts index 5defbb472b9..b191a2e2791 100644 --- a/apps/browser/src/popup/accounts/login.component.ts +++ b/apps/browser/src/popup/accounts/login.component.ts @@ -18,11 +18,14 @@ import { StateService } from "@bitwarden/common/abstractions/state.service"; import { Utils } from "@bitwarden/common/misc/utils"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { flagEnabled } from "../../flags"; + @Component({ selector: "app-login", templateUrl: "login.component.html", }) export class LoginComponent extends BaseLoginComponent { + showPasswordless = false; constructor( apiService: ApiService, appIdService: AppIdService, @@ -64,6 +67,13 @@ export class LoginComponent extends BaseLoginComponent { await syncService.fullSync(true); }; super.successRoute = "/tabs/vault"; + this.showPasswordless = flagEnabled("showPasswordless"); + + if (this.showPasswordless) { + this.formGroup.controls.email.setValue(this.loginService.getEmail()); + this.formGroup.controls.rememberEmail.setValue(this.loginService.getRememberEmail()); + this.validateEmail(); + } } settings() { diff --git a/apps/browser/src/popup/app-routing.animations.ts b/apps/browser/src/popup/app-routing.animations.ts index a37c128c80d..560016ad978 100644 --- a/apps/browser/src/popup/app-routing.animations.ts +++ b/apps/browser/src/popup/app-routing.animations.ts @@ -120,7 +120,7 @@ export const routerTransition = trigger("routerTransition", [ transition("login => home", outSlideDown), transition("login => hint", inSlideUp), - transition("login => tabs, login => 2fa", inSlideLeft), + transition("login => tabs, login => 2fa, login => login-with-device", inSlideLeft), transition("hint => login, register => home, environment => home", outSlideDown), @@ -129,6 +129,9 @@ export const routerTransition = trigger("routerTransition", [ transition("2fa-options => 2fa", outSlideDown), transition("2fa => tabs", inSlideLeft), + transition("login-with-device => tabs, login-with-device => 2fa", inSlideLeft), + transition("login-with-device => login", outSlideRight), + transition(tabsToCiphers, inSlideLeft), transition(ciphersToTabs, outSlideRight), diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 3b5995de27b..3fb599f8ffb 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -18,6 +18,7 @@ import { EnvironmentComponent } from "./accounts/environment.component"; import { HintComponent } from "./accounts/hint.component"; import { HomeComponent } from "./accounts/home.component"; import { LockComponent } from "./accounts/lock.component"; +import { LoginWithDeviceComponent } from "./accounts/login-with-device.component"; import { LoginComponent } from "./accounts/login.component"; import { RegisterComponent } from "./accounts/register.component"; import { RemovePasswordComponent } from "./accounts/remove-password.component"; @@ -67,6 +68,12 @@ const routes: Routes = [ canActivate: [UnauthGuard], data: { state: "login" }, }, + { + path: "login-with-device", + component: LoginWithDeviceComponent, + canActivate: [UnauthGuard], + data: { state: "login-with-device" }, + }, { path: "lock", component: LockComponent, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index e018375e5cd..bbed34e7c7d 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -39,6 +39,7 @@ import { EnvironmentComponent } from "./accounts/environment.component"; import { HintComponent } from "./accounts/hint.component"; import { HomeComponent } from "./accounts/home.component"; import { LockComponent } from "./accounts/lock.component"; +import { LoginWithDeviceComponent } from "./accounts/login-with-device.component"; import { LoginComponent } from "./accounts/login.component"; import { RegisterComponent } from "./accounts/register.component"; import { RemovePasswordComponent } from "./accounts/remove-password.component"; @@ -117,6 +118,7 @@ import { TabsComponent } from "./tabs.component"; HomeComponent, LockComponent, LoginComponent, + LoginWithDeviceComponent, OptionsComponent, GeneratorComponent, PasswordGeneratorHistoryComponent, diff --git a/apps/browser/src/popup/scss/base.scss b/apps/browser/src/popup/scss/base.scss index 4722e3c6f30..5b3fa0a3b68 100644 --- a/apps/browser/src/popup/scss/base.scss +++ b/apps/browser/src/popup/scss/base.scss @@ -622,3 +622,35 @@ main { position: relative; } } + +.login-with-device { + .fingerprint-phrase-header { + padding-top: 1rem; + display: block; + } + + @include themify($themes) { + .fingerprint-text { + color: themed("codeColor"); + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", + monospace; + padding: 1rem 0; + } + } + + .resend-notification { + padding-bottom: 1rem; + a { + cursor: pointer; + } + } + + .footer { + padding-top: 1rem; + + a { + padding-top: 1rem; + display: block; + } + } +} diff --git a/apps/browser/src/popup/scss/variables.scss b/apps/browser/src/popup/scss/variables.scss index 6d2842ebb70..f2ad42a8cb7 100644 --- a/apps/browser/src/popup/scss/variables.scss +++ b/apps/browser/src/popup/scss/variables.scss @@ -122,6 +122,7 @@ $themes: ( // light has no hover so use same color webkitCalendarPickerHoverFilter: invert(46%) sepia(69%) saturate(6397%) hue-rotate(211deg) brightness(85%) contrast(103%), + codeColor: #e83e8c, ), dark: ( textColor: #ffffff, @@ -183,6 +184,7 @@ $themes: ( hue-rotate(184deg) brightness(87%) contrast(93%), webkitCalendarPickerHoverFilter: brightness(0) saturate(100%) invert(100%) sepia(0%) saturate(0%) hue-rotate(93deg) brightness(103%) contrast(103%), + codeColor: #e83e8c, ), nord: ( textColor: $nord5, @@ -244,6 +246,7 @@ $themes: ( // has no hover so use same color webkitCalendarPickerHoverFilter: brightness(0) saturate(100%) invert(94%) sepia(5%) saturate(454%) hue-rotate(185deg) brightness(93%) contrast(96%), + codeColor: #e83e8c, ), solarizedDark: ( textColor: $solarizedDarkBase2, @@ -304,6 +307,7 @@ $themes: ( hue-rotate(138deg) brightness(92%) contrast(90%), webkitCalendarPickerHoverFilter: brightness(0) saturate(100%) invert(94%) sepia(10%) saturate(462%) hue-rotate(345deg) brightness(103%) contrast(87%), + codeColor: #e83e8c, ), ); diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 3a62dbf1856..74a9038e616 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -286,7 +286,8 @@ export class Main { this.environmentService, this.stateService, this.twoFactorService, - this.i18nService + this.i18nService, + this.encryptService ); const lockedCallback = async () => diff --git a/apps/desktop/src/app/accounts/login/login-approval.component.html b/apps/desktop/src/app/accounts/login/login-approval.component.html new file mode 100644 index 00000000000..57f91f521a0 --- /dev/null +++ b/apps/desktop/src/app/accounts/login/login-approval.component.html @@ -0,0 +1,43 @@ + diff --git a/apps/desktop/src/app/accounts/login/login-approval.component.ts b/apps/desktop/src/app/accounts/login/login-approval.component.ts new file mode 100644 index 00000000000..511ed81533b --- /dev/null +++ b/apps/desktop/src/app/accounts/login/login-approval.component.ts @@ -0,0 +1,170 @@ +import { Component, OnInit, OnDestroy } from "@angular/core"; +import { ipcRenderer } from "electron"; +import { Subject } from "rxjs"; + +import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; +import { ModalConfig } from "@bitwarden/angular/services/modal.service"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AppIdService } from "@bitwarden/common/abstractions/appId.service"; +import { AuthService } from "@bitwarden/common/abstractions/auth.service"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { AuthRequestResponse } from "@bitwarden/common/models/response/auth-request.response"; + +const RequestTimeOut = 60000 * 15; //15 Minutes +const RequestTimeUpdate = 60000 * 5; //5 Minutes + +@Component({ + selector: "login-approval", + templateUrl: "login-approval.component.html", +}) +export class LoginApprovalComponent implements OnInit, OnDestroy { + notificationId: string; + + private destroy$ = new Subject(); + + email: string; + authRequestResponse: AuthRequestResponse; + interval: NodeJS.Timer; + requestTimeText: string; + dismissModal: boolean; + + constructor( + protected stateService: StateService, + protected platformUtilsService: PlatformUtilsService, + protected i18nService: I18nService, + protected apiService: ApiService, + protected authService: AuthService, + protected appIdService: AppIdService, + private modalRef: ModalRef, + config: ModalConfig + ) { + this.notificationId = config.data.notificationId; + + this.dismissModal = true; + this.modalRef.onClosed + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + .subscribe(() => { + if (this.dismissModal) { + this.approveLogin(false, false); + } + }); + } + + ngOnDestroy(): void { + clearInterval(this.interval); + this.destroy$.next(); + this.destroy$.complete(); + } + + async ngOnInit() { + if (this.notificationId != null) { + this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId); + + this.email = await this.stateService.getEmail(); + this.updateTimeText(); + + this.interval = setInterval(() => { + this.updateTimeText(); + }, RequestTimeUpdate); + + const isVisible = await ipcRenderer.invoke("windowVisible"); + if (!isVisible) { + await ipcRenderer.invoke("loginRequest", { + alertTitle: this.i18nService.t("logInRequested"), + alertBody: this.i18nService.t("confirmLoginAtemptForMail", this.email), + buttonText: this.i18nService.t("close"), + }); + } + } + } + + async approveLogin(approveLogin: boolean, approveDenyButtonClicked: boolean) { + clearInterval(this.interval); + + this.dismissModal = !approveDenyButtonClicked; + if (approveDenyButtonClicked) { + this.modalRef.close(); + } + + this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId); + if (this.authRequestResponse.requestApproved || this.authRequestResponse.responseDate != null) { + this.platformUtilsService.showToast( + "info", + null, + this.i18nService.t("thisRequestIsNoLongerValid") + ); + } else { + const loginResponse = await this.authService.passwordlessLogin( + this.authRequestResponse.id, + this.authRequestResponse.publicKey, + approveLogin + ); + this.showResultToast(loginResponse); + } + } + + showResultToast(loginResponse: AuthRequestResponse) { + if (loginResponse.requestApproved) { + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t( + "logInConfirmedForEmailOnDevice", + this.email, + loginResponse.requestDeviceType + ) + ); + } else { + this.platformUtilsService.showToast( + "info", + null, + this.i18nService.t("youDeniedALogInAttemptFromAnotherDevice") + ); + } + } + + updateTimeText() { + const requestDate = new Date(this.authRequestResponse.creationDate); + const requestDateUTC = Date.UTC( + requestDate.getUTCFullYear(), + requestDate.getUTCMonth(), + requestDate.getDate(), + requestDate.getUTCHours(), + requestDate.getUTCMinutes(), + requestDate.getUTCSeconds(), + requestDate.getUTCMilliseconds() + ); + + const dateNow = new Date(Date.now()); + const dateNowUTC = Date.UTC( + dateNow.getUTCFullYear(), + dateNow.getUTCMonth(), + dateNow.getDate(), + dateNow.getUTCHours(), + dateNow.getUTCMinutes(), + dateNow.getUTCSeconds(), + dateNow.getUTCMilliseconds() + ); + + const diffInMinutes = dateNowUTC - requestDateUTC; + + if (diffInMinutes <= RequestTimeUpdate) { + this.requestTimeText = this.i18nService.t("justNow"); + } else if (diffInMinutes < RequestTimeOut) { + this.requestTimeText = this.i18nService.t( + "requestedXMinutesAgo", + (diffInMinutes / 60000).toFixed() + ); + } else { + clearInterval(this.interval); + this.modalRef.close(); + this.platformUtilsService.showToast( + "info", + null, + this.i18nService.t("loginRequestHasAlreadyExpired") + ); + } + } +} diff --git a/apps/desktop/src/app/accounts/login/login-with-device.component.html b/apps/desktop/src/app/accounts/login/login-with-device.component.html new file mode 100644 index 00000000000..c69a974d6d3 --- /dev/null +++ b/apps/desktop/src/app/accounts/login/login-with-device.component.html @@ -0,0 +1,52 @@ +
+ +
+ Bitwarden +

{{ "logInInitiated" | i18n }}

+ +
+
+
+
+

{{ "notificationSentDevice" | i18n }}

+

+ {{ "fingerprintMatchInfo" | i18n }} +

+
+ +
+

{{ "fingerprintPhraseHeader" | i18n }}

+ {{ passwordlessRequest?.fingerprintPhrase }} +
+ + + +
+

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

+
+
+
+
+
+
+ diff --git a/apps/desktop/src/app/accounts/login/login-with-device.component.ts b/apps/desktop/src/app/accounts/login/login-with-device.component.ts new file mode 100644 index 00000000000..8eedd472e50 --- /dev/null +++ b/apps/desktop/src/app/accounts/login/login-with-device.component.ts @@ -0,0 +1,106 @@ +import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; +import { Router } from "@angular/router"; + +import { LoginWithDeviceComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/components/login-with-device.component"; +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 { AppIdService } from "@bitwarden/common/abstractions/appId.service"; +import { AuthService } from "@bitwarden/common/abstractions/auth.service"; +import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; +import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { LoginService } from "@bitwarden/common/abstractions/login.service"; +import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; + +import { EnvironmentComponent } from "../environment.component"; + +@Component({ + selector: "app-login-with-device", + templateUrl: "login-with-device.component.html", +}) +export class LoginWithDeviceComponent + extends BaseLoginWithDeviceComponent + implements OnInit, OnDestroy +{ + @ViewChild("environment", { read: ViewContainerRef, static: true }) + environmentModal: ViewContainerRef; + showingModal = false; + + constructor( + protected router: Router, + cryptoService: CryptoService, + cryptoFunctionService: CryptoFunctionService, + appIdService: AppIdService, + passwordGenerationService: PasswordGenerationService, + apiService: ApiService, + authService: AuthService, + logService: LogService, + environmentService: EnvironmentService, + i18nService: I18nService, + platformUtilsService: PlatformUtilsService, + anonymousHubService: AnonymousHubService, + validationService: ValidationService, + private modalService: ModalService, + syncService: SyncService, + stateService: StateService, + loginService: LoginService + ) { + super( + router, + cryptoService, + cryptoFunctionService, + appIdService, + passwordGenerationService, + apiService, + authService, + logService, + environmentService, + i18nService, + platformUtilsService, + anonymousHubService, + validationService, + stateService, + loginService + ); + + super.onSuccessfulLogin = () => { + return syncService.fullSync(true); + }; + } + + async settings() { + const [modal, childComponent] = await this.modalService.openViewRef( + EnvironmentComponent, + this.environmentModal + ); + + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + modal.onShown.subscribe(() => { + this.showingModal = true; + }); + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + modal.onClosed.subscribe(() => { + this.showingModal = false; + }); + + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + childComponent.onSaved.subscribe(() => { + modal.close(); + }); + } + + ngOnDestroy(): void { + super.ngOnDestroy(); + } + + goToLogin() { + this.router.navigate(["/login"]); + } +} diff --git a/apps/desktop/src/app/accounts/login.component.html b/apps/desktop/src/app/accounts/login/login.component.html similarity index 94% rename from apps/desktop/src/app/accounts/login.component.html rename to apps/desktop/src/app/accounts/login/login.component.html index ad8e7b26587..0a3b5151127 100644 --- a/apps/desktop/src/app/accounts/login.component.html +++ b/apps/desktop/src/app/accounts/login/login.component.html @@ -126,6 +126,12 @@
+
+ +
+
+
+ +
+ {{ "approveLoginRequestDesc" | i18n }} +
diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index b07a049f18f..a5c5c071d4d 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -54,6 +54,7 @@ export class SettingsComponent implements OnInit { openAtLogin: boolean; requireEnableTray = false; showDuckDuckGoIntegrationOption = false; + approveLoginRequests = false; enableTrayText: string; enableTrayDescText: string; @@ -190,6 +191,7 @@ export class SettingsComponent implements OnInit { const pinSet = await this.vaultTimeoutSettingsService.isPinLockSet(); this.pin = pinSet[0] || pinSet[1]; + this.approveLoginRequests = await this.stateService.getApproveLoginRequests(); // Account preferences this.enableFavicons = !(await this.stateService.getDisableFavicon()); @@ -461,4 +463,8 @@ export class SettingsComponent implements OnInit { this.enableBrowserIntegrationFingerprint ); } + + async updateApproveLoginRequests() { + await this.stateService.setApproveLoginRequests(this.approveLoginRequests); + } } diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 1759eac3a6d..8d3c1fc3ab2 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -9,7 +9,8 @@ import { VaultComponent } from "../vault/app/vault/vault.component"; import { AccessibilityCookieComponent } from "./accounts/accessibility-cookie.component"; import { HintComponent } from "./accounts/hint.component"; import { LockComponent } from "./accounts/lock.component"; -import { LoginComponent } from "./accounts/login.component"; +import { LoginWithDeviceComponent } from "./accounts/login/login-with-device.component"; +import { LoginComponent } from "./accounts/login/login.component"; import { RegisterComponent } from "./accounts/register.component"; import { RemovePasswordComponent } from "./accounts/remove-password.component"; import { SetPasswordComponent } from "./accounts/set-password.component"; @@ -31,6 +32,10 @@ const routes: Routes = [ component: LoginComponent, canActivate: [LoginGuard], }, + { + path: "login-with-device", + component: LoginWithDeviceComponent, + }, { path: "2fa", component: TwoFactorComponent }, { path: "register", component: RegisterComponent }, { diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index b26c33f8314..24e21cae01a 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -45,6 +45,7 @@ import { PremiumComponent } from "../vault/app/accounts/premium.component"; import { FolderAddEditComponent } from "../vault/app/vault/folder-add-edit.component"; import { DeleteAccountComponent } from "./accounts/delete-account.component"; +import { LoginApprovalComponent } from "./accounts/login/login-approval.component"; import { SettingsComponent } from "./accounts/settings.component"; import { ExportComponent } from "./vault/export.component"; import { GeneratorComponent } from "./vault/generator.component"; @@ -70,6 +71,7 @@ const systemTimeoutOptions = { +
@@ -90,6 +92,8 @@ export class AppComponent implements OnInit, OnDestroy { folderAddEditModalRef: ViewContainerRef; @ViewChild("appGenerator", { read: ViewContainerRef, static: true }) generatorModalRef: ViewContainerRef; + @ViewChild("loginApproval", { read: ViewContainerRef, static: true }) + loginApprovalModalRef: ViewContainerRef; loading = false; @@ -359,6 +363,11 @@ export class AppComponent implements OnInit, OnDestroy { case "systemIdle": await this.checkForSystemTimeout(systemTimeoutOptions.onIdle); break; + case "openLoginApproval": + if (message.notificationId != null) { + await this.openLoginApproval(message.notificationId); + } + break; } }); }); @@ -427,6 +436,19 @@ export class AppComponent implements OnInit, OnDestroy { }); } + async openLoginApproval(notificationId: string) { + this.modalService.closeAll(); + + this.modal = await this.modalService.open(LoginApprovalComponent, { + data: { notificationId: notificationId }, + }); + + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + this.modal.onClosed.subscribe(() => { + this.modal = null; + }); + } + private async updateAppMenu() { let updateRequest: MenuUpdateRequest; const stateAccounts = await firstValueFrom(this.stateService.accounts$); diff --git a/apps/desktop/src/app/app.module.ts b/apps/desktop/src/app/app.module.ts index 8cd3a3fdb6f..7e945c0107e 100644 --- a/apps/desktop/src/app/app.module.ts +++ b/apps/desktop/src/app/app.module.ts @@ -27,7 +27,8 @@ import { DeleteAccountComponent } from "./accounts/delete-account.component"; import { EnvironmentComponent } from "./accounts/environment.component"; import { HintComponent } from "./accounts/hint.component"; import { LockComponent } from "./accounts/lock.component"; -import { LoginComponent } from "./accounts/login.component"; +import { LoginApprovalComponent } from "./accounts/login/login-approval.component"; +import { LoginModule } from "./accounts/login/login.module"; import { RegisterComponent } from "./accounts/register.component"; import { RemovePasswordComponent } from "./accounts/remove-password.component"; import { SetPasswordComponent } from "./accounts/set-password.component"; @@ -55,7 +56,7 @@ import { GeneratorComponent } from "./vault/generator.component"; import { PasswordGeneratorHistoryComponent } from "./vault/password-generator-history.component"; @NgModule({ - imports: [SharedModule, AppRoutingModule, VaultFilterModule], + imports: [SharedModule, AppRoutingModule, VaultFilterModule, LoginModule], declarations: [ AccessibilityCookieComponent, AccountSwitcherComponent, @@ -74,7 +75,6 @@ import { PasswordGeneratorHistoryComponent } from "./vault/password-generator-hi HeaderComponent, HintComponent, LockComponent, - LoginComponent, NavComponent, GeneratorComponent, PasswordGeneratorHistoryComponent, @@ -100,6 +100,7 @@ import { PasswordGeneratorHistoryComponent } from "./vault/password-generator-hi VaultTimeoutInputComponent, ViewComponent, ViewCustomFieldsComponent, + LoginApprovalComponent, ], bootstrap: [AppComponent], }) diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index bb82a99ad38..60bc6080508 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2022,8 +2022,8 @@ "organizationIsDisabled": { "message": "Organization suspended" }, - "disabledOrganizationFilterError" : { - "message" : "Items in suspended organizations cannot be accessed. Contact your organization owner for assistance." + "disabledOrganizationFilterError": { + "message": "Items in suspended organizations cannot be accessed. Contact your organization owner for assistance." }, "neverLockWarning": { "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." @@ -2061,10 +2061,110 @@ "logInWithAnotherDevice": { "message": "Log in with another device" }, + "logInInitiated": { + "message": "Log in initiated" + }, + "notificationSentDevice": { + "message": "A notification has been sent to your device." + }, + "fingerprintMatchInfo": { + "message": "Please make sure your vault is unlocked and Fingerprint phrase matches the other device." + }, + "fingerprintPhraseHeader": { + "message": "Fingerprint phrase" + }, + "needAnotherOption": { + "message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?" + }, + "viewAllLoginOptions": { + "message": "View all login options" + }, + "resendNotification": { + "message": "Resend notification" + }, "toggleCharacterCount": { "message": "Toggle character count", "description": "'Character count' describes a feature that displays a number next to each character of the password." }, + "areYouTryingtoLogin": { + "message": "Are you trying to log in?" + }, + "logInAttemptBy": { + "message": "Login attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "deviceType": { + "message": "Device Type" + }, + "ipAddress": { + "message": "IP Address" + }, + "time": { + "message": "Time" + }, + "confirmLogIn": { + "message": "Confirm login" + }, + "denyLogIn": { + "message": "Deny login" + }, + "approveLoginRequests": { + "message": "Approve login requests" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "approveLoginRequestDesc": { + "message": "Use this device to approve login requests made from other devices." + }, + "confirmLoginAtemptForMail": { + "message": "Confirm login attempt for $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "logInRequested": { + "message": "Log in requested" + }, "exposedMasterPassword": { "message": "Exposed Master Password" }, diff --git a/apps/desktop/src/scss/pages.scss b/apps/desktop/src/scss/pages.scss index f5ca996c447..3db75845fb0 100644 --- a/apps/desktop/src/scss/pages.scss +++ b/apps/desktop/src/scss/pages.scss @@ -1,6 +1,7 @@ @import "variables.scss"; #login-page, +#login-with-device-page, #lock-page, #sso-page, #set-password-page, @@ -187,7 +188,8 @@ } } -#login-page { +#login-page, +#login-with-device-page { flex-direction: column; justify-content: unset; padding-top: 20px; @@ -216,3 +218,57 @@ } } } + +#login-with-device-page { + .content { + display: block; + width: 350px !important; + + .fingerprint { + margin: auto; + width: 315px; + + .fingerpint-header { + padding-left: 15px; + } + } + + .section { + margin-bottom: 30px; + } + + .another-method { + display: flex; + margin: auto; + .description-text { + padding-right: 5px; + } + } + + code { + @include themify($themes) { + color: themed("codeColor"); + } + } + } +} + +#login-approval-page { + .section-title { + padding: 20px; + } + .content { + padding: 16px; + .section { + margin-bottom: 30px; + code { + @include themify($themes) { + color: themed("codeColor"); + } + } + h4.label { + font-weight: bold; + } + } + } +} diff --git a/apps/desktop/src/scss/variables.scss b/apps/desktop/src/scss/variables.scss index 67955db6b0b..983a06d7069 100644 --- a/apps/desktop/src/scss/variables.scss +++ b/apps/desktop/src/scss/variables.scss @@ -40,6 +40,8 @@ $button-color: lighten($text-color, 40%); $button-color-primary: darken($brand-primary, 8%); $button-color-danger: darken($brand-danger, 10%); +$code-color: #e83e8c; + $themes: ( light: ( textColor: $text-color, @@ -95,6 +97,7 @@ $themes: ( accountSwitcherTextColor: #ffffff, svgSuffix: "-light.svg", hrColor: #eeeeee, + codeColor: $code-color, ), dark: ( textColor: #ffffff, @@ -150,6 +153,7 @@ $themes: ( accountSwitcherTextColor: #ffffff, svgSuffix: "-dark.svg", hrColor: #a3a3a3, + codeColor: $code-color, ), nord: ( textColor: $nord5, @@ -205,6 +209,7 @@ $themes: ( accountSwitcherTextColor: $nord5, svgSuffix: "-dark.svg", hrColor: $nord4, + codeColor: $code-color, ), ); diff --git a/apps/desktop/src/services/electron-main-messaging.service.ts b/apps/desktop/src/services/electron-main-messaging.service.ts index b8d754b925b..a2a7afc7671 100644 --- a/apps/desktop/src/services/electron-main-messaging.service.ts +++ b/apps/desktop/src/services/electron-main-messaging.service.ts @@ -1,4 +1,6 @@ -import { app, dialog, ipcMain, Menu, MenuItem, nativeTheme, session } from "electron"; +import * as path from "path"; + +import { app, dialog, ipcMain, Menu, MenuItem, nativeTheme, session, Notification } from "electron"; import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; import { ThemeType } from "@bitwarden/common/enums/themeType"; @@ -51,6 +53,21 @@ export class ElectronMainMessagingService implements MessagingService { return await session.defaultSession.cookies.get(options); }); + ipcMain.handle("loginRequest", async (event, options) => { + const alert = new Notification({ + title: options.alertTitle, + body: options.alertBody, + closeButtonText: options.buttonText, + icon: path.join(__dirname, "images/icon.png"), + }); + + alert.addListener("click", () => { + this.windowMain.win.show(); + }); + + alert.show(); + }); + nativeTheme.on("updated", () => { windowMain.win?.webContents.send( "systemThemeUpdated", diff --git a/apps/desktop/src/vault/app/vault/vault.component.ts b/apps/desktop/src/vault/app/vault/vault.component.ts index 7d0f48809ab..554f73acebc 100644 --- a/apps/desktop/src/vault/app/vault/vault.component.ts +++ b/apps/desktop/src/vault/app/vault/vault.component.ts @@ -13,6 +13,7 @@ import { first } from "rxjs/operators"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; @@ -98,7 +99,8 @@ export class VaultComponent implements OnInit, OnDestroy { private totpService: TotpService, private passwordRepromptService: PasswordRepromptService, private stateService: StateService, - private searchBarService: SearchBarService + private searchBarService: SearchBarService, + private apiService: ApiService ) {} async ngOnInit() { @@ -207,6 +209,16 @@ export class VaultComponent implements OnInit, OnDestroy { this.searchBarService.setEnabled(true); this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); + + const approveLoginRequests = await this.stateService.getApproveLoginRequests(); + if (approveLoginRequests) { + const authRequest = await this.apiService.getLastAuthRequest(); + if (authRequest != null) { + this.messagingService.send("openLoginApproval", { + notificationId: authRequest.id, + }); + } + } } ngOnDestroy() { diff --git a/apps/web/src/app/accounts/login/login-with-device.component.ts b/apps/web/src/app/accounts/login/login-with-device.component.ts index 82852000832..2be7df05029 100644 --- a/apps/web/src/app/accounts/login/login-with-device.component.ts +++ b/apps/web/src/app/accounts/login/login-with-device.component.ts @@ -1,8 +1,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; -import { CaptchaProtectedComponent } from "@bitwarden/angular/components/captchaProtected.component"; +import { LoginWithDeviceComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/components/login-with-device.component"; import { AnonymousHubService } from "@bitwarden/common/abstractions/anonymousHub.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AppIdService } from "@bitwarden/common/abstractions/appId.service"; @@ -16,13 +15,6 @@ import { LoginService } from "@bitwarden/common/abstractions/login.service"; import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; -import { AuthRequestType } from "@bitwarden/common/enums/authRequestType"; -import { Utils } from "@bitwarden/common/misc/utils"; -import { PasswordlessLogInCredentials } from "@bitwarden/common/models/domain/log-in-credentials"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; -import { PasswordlessCreateAuthRequest } from "@bitwarden/common/models/request/passwordless-create-auth.request"; -import { AuthRequestResponse } from "@bitwarden/common/models/response/auth-request.response"; -import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { StateService } from "../../core/state/state.service"; @@ -31,175 +23,42 @@ import { StateService } from "../../core/state/state.service"; templateUrl: "login-with-device.component.html", }) export class LoginWithDeviceComponent - extends CaptchaProtectedComponent + extends BaseLoginWithDeviceComponent implements OnInit, OnDestroy { - private destroy$ = new Subject(); - email: string; - showResendNotification = false; - passwordlessRequest: PasswordlessCreateAuthRequest; - onSuccessfulLoginTwoFactorNavigate: () => Promise; - onSuccessfulLogin: () => Promise; - onSuccessfulLoginNavigate: () => Promise; - onSuccessfulLoginForceResetNavigate: () => Promise; - - protected twoFactorRoute = "2fa"; - protected successRoute = "vault"; - protected forcePasswordResetRoute = "update-temp-password"; - private authRequestKeyPair: [publicKey: ArrayBuffer, privateKey: ArrayBuffer]; - constructor( - private router: Router, - private cryptoService: CryptoService, - private cryptoFunctionService: CryptoFunctionService, - private appIdService: AppIdService, - private passwordGenerationService: PasswordGenerationService, - private apiService: ApiService, - private authService: AuthService, - private logService: LogService, + router: Router, + cryptoService: CryptoService, + cryptoFunctionService: CryptoFunctionService, + appIdService: AppIdService, + passwordGenerationService: PasswordGenerationService, + apiService: ApiService, + authService: AuthService, + logService: LogService, environmentService: EnvironmentService, i18nService: I18nService, platformUtilsService: PlatformUtilsService, - private anonymousHubService: AnonymousHubService, - private validationService: ValidationService, - private stateService: StateService, - private loginService: LoginService + anonymousHubService: AnonymousHubService, + validationService: ValidationService, + stateService: StateService, + loginService: LoginService ) { - super(environmentService, i18nService, platformUtilsService); - - const navigation = this.router.getCurrentNavigation(); - if (navigation) { - this.email = this.loginService.getEmail(); - } - - //gets signalR push notification - this.authService - .getPushNotifcationObs$() - .pipe(takeUntil(this.destroy$)) - .subscribe((id) => { - this.confirmResponse(id); - }); - } - - async ngOnInit() { - if (!this.email) { - this.router.navigate(["/login"]); - return; - } - - this.startPasswordlessLogin(); - } - - async startPasswordlessLogin() { - this.showResendNotification = false; - - try { - await this.buildAuthRequest(); - const reqResponse = await this.apiService.postAuthRequest(this.passwordlessRequest); - - if (reqResponse.id) { - this.anonymousHubService.createHubConnection(reqResponse.id); - } - } catch (e) { - this.logService.error(e); - } - - setTimeout(() => { - this.showResendNotification = true; - }, 12000); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - this.anonymousHubService.stopHubConnection(); - } - - private async confirmResponse(requestId: string) { - try { - const response = await this.apiService.getAuthResponse( - requestId, - this.passwordlessRequest.accessCode - ); - - if (!response.requestApproved) { - return; - } - - const credentials = await this.buildLoginCredntials(requestId, response); - const loginResponse = await this.authService.logIn(credentials); - - if (loginResponse.requiresTwoFactor) { - if (this.onSuccessfulLoginTwoFactorNavigate != null) { - this.onSuccessfulLoginTwoFactorNavigate(); - } else { - this.router.navigate([this.twoFactorRoute]); - } - } else if (loginResponse.forcePasswordReset) { - if (this.onSuccessfulLoginForceResetNavigate != null) { - this.onSuccessfulLoginForceResetNavigate(); - } else { - this.router.navigate([this.forcePasswordResetRoute]); - } - } else { - await this.loginService.saveEmailSettings(); - if (this.onSuccessfulLogin != null) { - this.onSuccessfulLogin(); - } - if (this.onSuccessfulLoginNavigate != null) { - this.onSuccessfulLoginNavigate(); - } else { - this.router.navigate([this.successRoute]); - } - } - } catch (error) { - if (error instanceof ErrorResponse) { - this.router.navigate(["/login"]); - this.validationService.showError(error); - return; - } - - this.logService.error(error); - } - } - - private async buildAuthRequest() { - this.authRequestKeyPair = await this.cryptoFunctionService.rsaGenerateKeyPair(2048); - const fingerprint = await ( - await this.cryptoService.getFingerprint(this.email, this.authRequestKeyPair[0]) - ).join("-"); - const deviceIdentifier = await this.appIdService.getAppId(); - const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair[0]); - const accessCode = await this.passwordGenerationService.generatePassword({ length: 25 }); - - this.passwordlessRequest = new PasswordlessCreateAuthRequest( - this.email, - deviceIdentifier, - publicKey, - AuthRequestType.AuthenticateAndUnlock, - accessCode, - fingerprint - ); - } - - private async buildLoginCredntials( - requestId: string, - response: AuthRequestResponse - ): Promise { - const decKey = await this.cryptoService.rsaDecrypt(response.key, this.authRequestKeyPair[1]); - const decMasterPasswordHash = await this.cryptoService.rsaDecrypt( - response.masterPasswordHash, - this.authRequestKeyPair[1] - ); - const key = new SymmetricCryptoKey(decKey); - const localHashedPassword = Utils.fromBufferToUtf8(decMasterPasswordHash); - - return new PasswordlessLogInCredentials( - this.email, - this.passwordlessRequest.accessCode, - requestId, - key, - localHashedPassword + super( + router, + cryptoService, + cryptoFunctionService, + appIdService, + passwordGenerationService, + apiService, + authService, + logService, + environmentService, + i18nService, + platformUtilsService, + anonymousHubService, + validationService, + stateService, + loginService ); } } diff --git a/apps/web/src/app/accounts/login/login.component.ts b/apps/web/src/app/accounts/login/login.component.ts index a431d47ac91..c240e0fab9e 100644 --- a/apps/web/src/app/accounts/login/login.component.ts +++ b/apps/web/src/app/accounts/login/login.component.ts @@ -209,18 +209,6 @@ export class LoginComponent extends BaseLoginComponent implements OnInit, OnDest await super.submit(false); } - async startPasswordlessLogin() { - this.formGroup.get("masterPassword")?.clearValidators(); - this.formGroup.get("masterPassword")?.updateValueAndValidity(); - - if (!this.formGroup.valid) { - return; - } - - this.setFormValues(); - this.router.navigate(["/login-with-device"]); - } - private getPasswordStrengthUserInput() { const email = this.formGroup.value.email; let userInput: string[] = []; diff --git a/libs/angular/src/components/login-with-device.component.ts b/libs/angular/src/components/login-with-device.component.ts new file mode 100644 index 00000000000..e894a3b33c4 --- /dev/null +++ b/libs/angular/src/components/login-with-device.component.ts @@ -0,0 +1,210 @@ +import { Directive, OnDestroy, OnInit } from "@angular/core"; +import { 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 { AppIdService } from "@bitwarden/common/abstractions/appId.service"; +import { AuthService } from "@bitwarden/common/abstractions/auth.service"; +import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; +import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { LoginService } from "@bitwarden/common/abstractions/login.service"; +import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; +import { AuthRequestType } from "@bitwarden/common/enums/authRequestType"; +import { Utils } from "@bitwarden/common/misc/utils"; +import { PasswordlessLogInCredentials } from "@bitwarden/common/models/domain/log-in-credentials"; +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; +import { PasswordlessCreateAuthRequest } from "@bitwarden/common/models/request/passwordless-create-auth.request"; +import { AuthRequestResponse } from "@bitwarden/common/models/response/auth-request.response"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; + +import { CaptchaProtectedComponent } from "./captchaProtected.component"; + +@Directive() +export class LoginWithDeviceComponent + extends CaptchaProtectedComponent + implements OnInit, OnDestroy +{ + private destroy$ = new Subject(); + email: string; + showResendNotification = false; + passwordlessRequest: PasswordlessCreateAuthRequest; + onSuccessfulLoginTwoFactorNavigate: () => Promise; + onSuccessfulLogin: () => Promise; + onSuccessfulLoginNavigate: () => Promise; + onSuccessfulLoginForceResetNavigate: () => Promise; + + protected twoFactorRoute = "2fa"; + protected successRoute = "vault"; + protected forcePasswordResetRoute = "update-temp-password"; + private resendTimeout = 12000; + private authRequestKeyPair: [publicKey: ArrayBuffer, privateKey: ArrayBuffer]; + + constructor( + protected router: Router, + private cryptoService: CryptoService, + private cryptoFunctionService: CryptoFunctionService, + private appIdService: AppIdService, + private passwordGenerationService: PasswordGenerationService, + private apiService: ApiService, + private authService: AuthService, + private logService: LogService, + environmentService: EnvironmentService, + i18nService: I18nService, + platformUtilsService: PlatformUtilsService, + private anonymousHubService: AnonymousHubService, + private validationService: ValidationService, + private stateService: StateService, + private loginService: LoginService + ) { + super(environmentService, i18nService, platformUtilsService); + + const navigation = this.router.getCurrentNavigation(); + if (navigation) { + this.email = navigation.extras?.state?.email; + } + + //gets signalR push notification + this.authService + .getPushNotifcationObs$() + .pipe(takeUntil(this.destroy$)) + .subscribe((id) => { + this.confirmResponse(id); + }); + } + + async ngOnInit() { + if (!this.email) { + this.router.navigate(["/login"]); + return; + } + + this.startPasswordlessLogin(); + } + + async startPasswordlessLogin() { + this.showResendNotification = false; + + try { + await this.buildAuthRequest(); + const reqResponse = await this.apiService.postAuthRequest(this.passwordlessRequest); + + if (reqResponse.id) { + this.anonymousHubService.createHubConnection(reqResponse.id); + } + } catch (e) { + this.logService.error(e); + } + + setTimeout(() => { + this.showResendNotification = true; + }, this.resendTimeout); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.anonymousHubService.stopHubConnection(); + } + + private async confirmResponse(requestId: string) { + try { + const response = await this.apiService.getAuthResponse( + requestId, + this.passwordlessRequest.accessCode + ); + + if (!response.requestApproved) { + return; + } + + const credentials = await this.buildLoginCredntials(requestId, response); + const loginResponse = await this.authService.logIn(credentials); + + if (loginResponse.requiresTwoFactor) { + if (this.onSuccessfulLoginTwoFactorNavigate != null) { + this.onSuccessfulLoginTwoFactorNavigate(); + } else { + this.router.navigate([this.twoFactorRoute]); + } + } else if (loginResponse.forcePasswordReset) { + 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]); + } + } + } catch (error) { + if (error instanceof ErrorResponse) { + this.router.navigate(["/login"]); + this.validationService.showError(error); + return; + } + + this.logService.error(error); + } + } + + async setRememberEmailValues() { + const rememberEmail = this.loginService.getRememberEmail(); + const rememberedEmail = this.loginService.getEmail(); + await this.stateService.setRememberedEmail(rememberEmail ? rememberedEmail : null); + this.loginService.clearValues(); + } + + private async buildAuthRequest() { + this.authRequestKeyPair = await this.cryptoFunctionService.rsaGenerateKeyPair(2048); + const fingerprint = await ( + await this.cryptoService.getFingerprint(this.email, this.authRequestKeyPair[0]) + ).join("-"); + const deviceIdentifier = await this.appIdService.getAppId(); + const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair[0]); + const accessCode = await this.passwordGenerationService.generatePassword({ length: 25 }); + + this.passwordlessRequest = new PasswordlessCreateAuthRequest( + this.email, + deviceIdentifier, + publicKey, + AuthRequestType.AuthenticateAndUnlock, + accessCode, + fingerprint + ); + } + + private async buildLoginCredntials( + requestId: string, + response: AuthRequestResponse + ): Promise { + const decKey = await this.cryptoService.rsaDecrypt(response.key, this.authRequestKeyPair[1]); + const decMasterPasswordHash = await this.cryptoService.rsaDecrypt( + response.masterPasswordHash, + this.authRequestKeyPair[1] + ); + const key = new SymmetricCryptoKey(decKey); + const localHashedPassword = Utils.fromBufferToUtf8(decMasterPasswordHash); + + return new PasswordlessLogInCredentials( + this.email, + this.passwordlessRequest.accessCode, + requestId, + key, + localHashedPassword + ); + } +} diff --git a/libs/angular/src/components/login.component.ts b/libs/angular/src/components/login.component.ts index 0f9f15a0568..d0c495a6055 100644 --- a/libs/angular/src/components/login.component.ts +++ b/libs/angular/src/components/login.component.ts @@ -32,7 +32,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit onSuccessfulLoginNavigate: () => Promise; onSuccessfulLoginTwoFactorNavigate: () => Promise; onSuccessfulLoginForceResetNavigate: () => Promise; - private selfHosted = false; + selfHosted = false; showLoginWithDevice: boolean; validatedEmail = false; paramEmailSet = false; @@ -176,6 +176,18 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit } } + async startPasswordlessLogin() { + this.formGroup.get("masterPassword")?.clearValidators(); + this.formGroup.get("masterPassword")?.updateValueAndValidity(); + + if (!this.formGroup.valid) { + return; + } + + const email = this.formGroup.get("email").value; + this.router.navigate(["/login-with-device"], { state: { email: email } }); + } + async launchSsoBrowser(clientId: string, ssoRedirectUri: string) { await this.saveEmailSettings(); // Generate necessary sso params @@ -259,7 +271,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit return `${error.controlName}${name}`; } - private async getLoginWithDevice(email: string) { + async getLoginWithDevice(email: string) { try { const deviceIdentifier = await this.appIdService.getAppId(); const res = await this.apiService.getKnownDevice(email, deviceIdentifier); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 8202afa1747..f1387ad9119 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -455,6 +455,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; LogService, StateServiceAbstraction, AuthServiceAbstraction, + MessagingServiceAbstraction, ], }, { diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 1262e7730f3..437c63a949e 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -26,6 +26,7 @@ import { OrganizationSponsorshipCreateRequest } from "../models/request/organiza import { OrganizationSponsorshipRedeemRequest } from "../models/request/organization/organization-sponsorship-redeem.request"; import { PasswordHintRequest } from "../models/request/password-hint.request"; import { PasswordRequest } from "../models/request/password.request"; +import { PasswordlessAuthRequest } from "../models/request/passwordless-auth.request"; import { PasswordlessCreateAuthRequest } from "../models/request/passwordless-create-auth.request"; import { PaymentRequest } from "../models/request/payment.request"; import { PreloginRequest } from "../models/request/prelogin.request"; @@ -204,6 +205,10 @@ export abstract class ApiService { //passwordless postAuthRequest: (request: PasswordlessCreateAuthRequest) => Promise; getAuthResponse: (id: string, accessCode: string) => Promise; + getAuthRequest: (id: string) => Promise; + putAuthRequest: (id: string, request: PasswordlessAuthRequest) => Promise; + getAuthRequests: () => Promise>; + getLastAuthRequest: () => Promise; getUserBillingHistory: () => Promise; getUserBillingPayment: () => Promise; diff --git a/libs/common/src/abstractions/auth.service.ts b/libs/common/src/abstractions/auth.service.ts index ca75e273b5c..6f035ef8d3d 100644 --- a/libs/common/src/abstractions/auth.service.ts +++ b/libs/common/src/abstractions/auth.service.ts @@ -10,6 +10,7 @@ import { } from "../models/domain/log-in-credentials"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request"; +import { AuthRequestResponse } from "../models/response/auth-request.response"; import { AuthRequestPushNotification } from "../models/response/notification.response"; export abstract class AuthService { @@ -37,6 +38,10 @@ export abstract class AuthService { authingWithPasswordless: () => boolean; getAuthStatus: (userId?: string) => Promise; authResponsePushNotifiction: (notification: AuthRequestPushNotification) => Promise; - + passwordlessLogin: ( + id: string, + key: string, + requestApproved: boolean + ) => Promise; getPushNotifcationObs$: () => Observable; } diff --git a/libs/common/src/abstractions/environment.service.ts b/libs/common/src/abstractions/environment.service.ts index 1d608ed02c4..27e4125a29d 100644 --- a/libs/common/src/abstractions/environment.service.ts +++ b/libs/common/src/abstractions/environment.service.ts @@ -34,4 +34,9 @@ export abstract class EnvironmentService { setUrls: (urls: Urls) => Promise; getUrls: () => Urls; isCloud: () => boolean; + /** + * @remarks For desktop and browser use only. + * For web, use PlatformUtilsService.isSelfHost() + */ + isSelfHosted: () => boolean; } diff --git a/libs/common/src/abstractions/state.service.ts b/libs/common/src/abstractions/state.service.ts index 9b88c9cb63a..4ffdec474ce 100644 --- a/libs/common/src/abstractions/state.service.ts +++ b/libs/common/src/abstractions/state.service.ts @@ -338,6 +338,8 @@ export abstract class StateService { setVaultTimeout: (value: number, options?: StorageOptions) => Promise; getVaultTimeoutAction: (options?: StorageOptions) => Promise; setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise; + getApproveLoginRequests: (options?: StorageOptions) => Promise; + setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise; getStateVersion: () => Promise; setStateVersion: (value: number) => Promise; getWindow: () => Promise; diff --git a/libs/common/src/misc/flags.ts b/libs/common/src/misc/flags.ts index 7811d3477f2..c1bdafaa0f3 100644 --- a/libs/common/src/misc/flags.ts +++ b/libs/common/src/misc/flags.ts @@ -2,6 +2,7 @@ /* eslint-disable @typescript-eslint/ban-types */ export type SharedFlags = { multithreadDecryption: boolean; + showPasswordless?: boolean; }; // required to avoid linting errors when there are no flags diff --git a/libs/common/src/models/domain/account.ts b/libs/common/src/models/domain/account.ts index 038964498b8..3c4725b343c 100644 --- a/libs/common/src/models/domain/account.ts +++ b/libs/common/src/models/domain/account.ts @@ -235,6 +235,7 @@ export class AccountSettings { vaultTimeout?: number; vaultTimeoutAction?: string = "lock"; serverConfig?: ServerConfigData; + approveLoginRequests?: boolean; avatarColor?: string; static fromJSON(obj: Jsonify): AccountSettings { diff --git a/libs/common/src/models/request/passwordless-auth.request.ts b/libs/common/src/models/request/passwordless-auth.request.ts new file mode 100644 index 00000000000..a343170c280 --- /dev/null +++ b/libs/common/src/models/request/passwordless-auth.request.ts @@ -0,0 +1,8 @@ +export class PasswordlessAuthRequest { + constructor( + readonly key: string, + readonly masterPasswordHash: string, + readonly deviceIdentifier: string, + readonly requestApproved: boolean + ) {} +} diff --git a/libs/common/src/models/response/auth-request.response.ts b/libs/common/src/models/response/auth-request.response.ts index f94980a2953..5175cbf1435 100644 --- a/libs/common/src/models/response/auth-request.response.ts +++ b/libs/common/src/models/response/auth-request.response.ts @@ -2,6 +2,8 @@ import { DeviceType } from "../../enums/deviceType"; import { BaseResponse } from "./base.response"; +const RequestTimeOut = 60000 * 15; //15 Minutes + export class AuthRequestResponse extends BaseResponse { id: string; publicKey: string; @@ -10,7 +12,11 @@ export class AuthRequestResponse extends BaseResponse { key: string; masterPasswordHash: string; creationDate: string; - requestApproved: boolean; + requestApproved?: boolean; + requestFingerprint?: string; + responseDate?: string; + isAnswered: boolean; + isExpired: boolean; constructor(response: any) { super(response); @@ -22,5 +28,32 @@ export class AuthRequestResponse extends BaseResponse { this.masterPasswordHash = this.getResponseProperty("MasterPasswordHash"); this.creationDate = this.getResponseProperty("CreationDate"); this.requestApproved = this.getResponseProperty("RequestApproved"); + this.requestFingerprint = this.getResponseProperty("RequestFingerprint"); + this.responseDate = this.getResponseProperty("ResponseDate"); + + const requestDate = new Date(this.creationDate); + const requestDateUTC = Date.UTC( + requestDate.getUTCFullYear(), + requestDate.getUTCMonth(), + requestDate.getDate(), + requestDate.getUTCHours(), + requestDate.getUTCMinutes(), + requestDate.getUTCSeconds(), + requestDate.getUTCMilliseconds() + ); + + const dateNow = new Date(Date.now()); + const dateNowUTC = Date.UTC( + dateNow.getUTCFullYear(), + dateNow.getUTCMonth(), + dateNow.getDate(), + dateNow.getUTCHours(), + dateNow.getUTCMinutes(), + dateNow.getUTCSeconds(), + dateNow.getUTCMilliseconds() + ); + + this.isExpired = dateNowUTC - requestDateUTC >= RequestTimeOut; + this.isAnswered = this.requestApproved != null && this.responseDate != null; } } diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 957183da044..11e8cb3d708 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -35,6 +35,7 @@ import { OrganizationSponsorshipCreateRequest } from "../models/request/organiza import { OrganizationSponsorshipRedeemRequest } from "../models/request/organization/organization-sponsorship-redeem.request"; import { PasswordHintRequest } from "../models/request/password-hint.request"; import { PasswordRequest } from "../models/request/password.request"; +import { PasswordlessAuthRequest } from "../models/request/passwordless-auth.request"; import { PasswordlessCreateAuthRequest } from "../models/request/passwordless-create-auth.request"; import { PaymentRequest } from "../models/request/payment.request"; import { PreloginRequest } from "../models/request/prelogin.request"; @@ -266,6 +267,33 @@ export class ApiService implements ApiServiceAbstraction { return new AuthRequestResponse(r); } + async getAuthRequest(id: string): Promise { + const path = `/auth-requests/${id}`; + const r = await this.send("GET", path, null, true, true); + return new AuthRequestResponse(r); + } + + async putAuthRequest(id: string, request: PasswordlessAuthRequest): Promise { + const path = `/auth-requests/${id}`; + const r = await this.send("PUT", path, request, true, true); + return new AuthRequestResponse(r); + } + + async getAuthRequests(): Promise> { + const path = `/auth-requests/`; + const r = await this.send("GET", path, null, true, true); + return new ListResponse(r, AuthRequestResponse); + } + + async getLastAuthRequest(): Promise { + const requests = await this.getAuthRequests(); + const activeRequests = requests.data.filter((m) => !m.isAnswered && !m.isExpired); + const lastRequest = activeRequests.sort((a: AuthRequestResponse, b: AuthRequestResponse) => + a.creationDate.localeCompare(b.creationDate) + )[activeRequests.length - 1]; + return lastRequest; + } + // Account APIs async getProfile(): Promise { diff --git a/libs/common/src/services/auth.service.ts b/libs/common/src/services/auth.service.ts index c37b2c27087..29cc10698d2 100644 --- a/libs/common/src/services/auth.service.ts +++ b/libs/common/src/services/auth.service.ts @@ -4,6 +4,7 @@ import { ApiService } from "../abstractions/api.service"; import { AppIdService } from "../abstractions/appId.service"; import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service"; import { CryptoService } from "../abstractions/crypto.service"; +import { EncryptService } from "../abstractions/encrypt.service"; import { EnvironmentService } from "../abstractions/environment.service"; import { I18nService } from "../abstractions/i18n.service"; import { KeyConnectorService } from "../abstractions/keyConnector.service"; @@ -21,6 +22,7 @@ import { PasswordLogInStrategy } from "../misc/logInStrategies/passwordLogin.str import { PasswordlessLogInStrategy } from "../misc/logInStrategies/passwordlessLogin.strategy"; import { SsoLogInStrategy } from "../misc/logInStrategies/ssoLogin.strategy"; import { UserApiLogInStrategy } from "../misc/logInStrategies/user-api-login.strategy"; +import { Utils } from "../misc/utils"; import { AuthResult } from "../models/domain/auth-result"; import { KdfConfig } from "../models/domain/kdf-config"; import { @@ -31,7 +33,9 @@ import { } from "../models/domain/log-in-credentials"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request"; +import { PasswordlessAuthRequest } from "../models/request/passwordless-auth.request"; import { PreloginRequest } from "../models/request/prelogin.request"; +import { AuthRequestResponse } from "../models/response/auth-request.response"; import { ErrorResponse } from "../models/response/error.response"; import { AuthRequestPushNotification } from "../models/response/notification.response"; @@ -88,7 +92,8 @@ export class AuthService implements AuthServiceAbstraction { protected environmentService: EnvironmentService, protected stateService: StateService, protected twoFactorService: TwoFactorService, - protected i18nService: I18nService + protected i18nService: I18nService, + protected encryptService: EncryptService ) {} async logIn( @@ -275,6 +280,31 @@ export class AuthService implements AuthServiceAbstraction { return this.pushNotificationSubject.asObservable(); } + async passwordlessLogin( + id: string, + key: string, + requestApproved: boolean + ): Promise { + const pubKey = Utils.fromB64ToArray(key); + const encryptedKey = await this.cryptoService.rsaEncrypt( + ( + await this.cryptoService.getKey() + ).encKey, + pubKey.buffer + ); + const encryptedMasterPassword = await this.cryptoService.rsaEncrypt( + Utils.fromUtf8ToArray(await this.stateService.getKeyHash()), + pubKey.buffer + ); + const request = new PasswordlessAuthRequest( + encryptedKey.encryptedString, + encryptedMasterPassword.encryptedString, + await this.appIdService.getAppId(), + requestApproved + ); + return await this.apiService.putAuthRequest(id, request); + } + private saveState( strategy: | UserApiLogInStrategy diff --git a/libs/common/src/services/environment.service.ts b/libs/common/src/services/environment.service.ts index 1d461fe45b8..0e43c6ec895 100644 --- a/libs/common/src/services/environment.service.ts +++ b/libs/common/src/services/environment.service.ts @@ -213,4 +213,13 @@ export class EnvironmentService implements EnvironmentServiceAbstraction { this.getApiUrl() ); } + + isSelfHosted(): boolean { + return ![ + "http://vault.bitwarden.com", + "https://vault.bitwarden.com", + "http://vault.qa.bitwarden.pw", + "https://vault.qa.bitwarden.pw", + ].includes(this.getWebVaultUrl()); + } } diff --git a/libs/common/src/services/notifications.service.ts b/libs/common/src/services/notifications.service.ts index e066d75c071..59b9f101e93 100644 --- a/libs/common/src/services/notifications.service.ts +++ b/libs/common/src/services/notifications.service.ts @@ -6,6 +6,7 @@ import { AppIdService } from "../abstractions/appId.service"; import { AuthService } from "../abstractions/auth.service"; import { EnvironmentService } from "../abstractions/environment.service"; import { LogService } from "../abstractions/log.service"; +import { MessagingService } from "../abstractions/messaging.service"; import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service"; import { StateService } from "../abstractions/state.service"; import { AuthenticationStatus } from "../enums/authenticationStatus"; @@ -34,7 +35,8 @@ export class NotificationsService implements NotificationsServiceAbstraction { private logoutCallback: (expired: boolean) => Promise, private logService: LogService, private stateService: StateService, - private authService: AuthService + private authService: AuthService, + private messagingService: MessagingService ) { this.environmentService.urls.subscribe(() => { if (!this.inited) { @@ -183,6 +185,13 @@ export class NotificationsService implements NotificationsServiceAbstraction { case NotificationType.SyncSendDelete: await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification); break; + case NotificationType.AuthRequest: + if (await this.stateService.getApproveLoginRequests()) { + this.messagingService.send("openLoginApproval", { + notificationId: notification.payload.id, + }); + } + break; default: break; } diff --git a/libs/common/src/services/state.service.ts b/libs/common/src/services/state.service.ts index 331ae8e1747..9c2b57988fd 100644 --- a/libs/common/src/services/state.service.ts +++ b/libs/common/src/services/state.service.ts @@ -2266,6 +2266,24 @@ export class StateService< ); } + async getApproveLoginRequests(options?: StorageOptions): Promise { + const approveLoginRequests = ( + await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) + )?.settings?.approveLoginRequests; + return approveLoginRequests; + } + + async setApproveLoginRequests(value: boolean, options?: StorageOptions): Promise { + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()) + ); + account.settings.approveLoginRequests = value; + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()) + ); + } + async getStateVersion(): Promise { return (await this.getGlobals(await this.defaultOnDiskLocalOptions())).stateVersion ?? 1; }