diff --git a/apps/browser/src/popup/accounts/login.component.html b/apps/browser/src/popup/accounts/login.component.html index 4c18805f45..1b32f63819 100644 --- a/apps/browser/src/popup/accounts/login.component.html +++ b/apps/browser/src/popup/accounts/login.component.html @@ -1,4 +1,4 @@ -
+
@@ -18,15 +18,7 @@
- +
@@ -34,10 +26,8 @@
diff --git a/apps/browser/src/popup/accounts/login.component.ts b/apps/browser/src/popup/accounts/login.component.ts index 83c654d673..5028af06bf 100644 --- a/apps/browser/src/popup/accounts/login.component.ts +++ b/apps/browser/src/popup/accounts/login.component.ts @@ -1,10 +1,12 @@ import { Component, NgZone } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; import { Router } from "@angular/router"; import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/components/login.component"; import { AuthService } from "@bitwarden/common/abstractions/auth.service"; import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; +import { FormValidationErrorsService } from "@bitwarden/common/abstractions/formValidationErrors.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service"; @@ -30,7 +32,9 @@ export class LoginComponent extends BaseLoginComponent { protected cryptoFunctionService: CryptoFunctionService, syncService: SyncService, logService: LogService, - ngZone: NgZone + ngZone: NgZone, + formBuilder: FormBuilder, + formValidationErrorService: FormValidationErrorsService ) { super( authService, @@ -42,7 +46,9 @@ export class LoginComponent extends BaseLoginComponent { passwordGenerationService, cryptoFunctionService, logService, - ngZone + ngZone, + formBuilder, + formValidationErrorService ); super.onSuccessfulLogin = async () => { await syncService.fullSync(true); diff --git a/apps/desktop/src/app/accounts/login.component.html b/apps/desktop/src/app/accounts/login.component.html index 314b055062..c11ed881b0 100644 --- a/apps/desktop/src/app/accounts/login.component.html +++ b/apps/desktop/src/app/accounts/login.component.html @@ -16,6 +16,7 @@ #form (ngSubmit)="submit()" [appApiAction]="formPromise" + [formGroup]="formGroup" attr.aria-hidden="{{ showingModal }}" >
@@ -25,14 +26,7 @@
- +
@@ -40,10 +34,8 @@
diff --git a/apps/desktop/src/app/accounts/login.component.ts b/apps/desktop/src/app/accounts/login.component.ts index 959c8a4565..33eefbd57e 100644 --- a/apps/desktop/src/app/accounts/login.component.ts +++ b/apps/desktop/src/app/accounts/login.component.ts @@ -1,4 +1,5 @@ import { Component, NgZone, OnDestroy, ViewChild, ViewContainerRef } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; import { Router } from "@angular/router"; import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/components/login.component"; @@ -7,6 +8,7 @@ import { AuthService } from "@bitwarden/common/abstractions/auth.service"; import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; +import { FormValidationErrorsService } from "@bitwarden/common/abstractions/formValidationErrors.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; @@ -47,7 +49,9 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy { private broadcasterService: BroadcasterService, ngZone: NgZone, private messagingService: MessagingService, - logService: LogService + logService: LogService, + formBuilder: FormBuilder, + formValidationErrorService: FormValidationErrorsService ) { super( authService, @@ -59,7 +63,9 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy { passwordGenerationService, cryptoFunctionService, logService, - ngZone + ngZone, + formBuilder, + formValidationErrorService ); super.onSuccessfulLogin = () => { return syncService.fullSync(true); diff --git a/apps/web/config/base.json b/apps/web/config/base.json index cab6fbe950..8eb8a31133 100644 --- a/apps/web/config/base.json +++ b/apps/web/config/base.json @@ -10,5 +10,7 @@ "port": 8080, "allowedHosts": "auto" }, - "flags": {} + "flags": { + "showPasswordless": false + } } diff --git a/apps/web/config/cloud.json b/apps/web/config/cloud.json index 96d692f7e8..5bd5e6b060 100644 --- a/apps/web/config/cloud.json +++ b/apps/web/config/cloud.json @@ -16,6 +16,7 @@ "proxyEvents": "https://events.bitwarden.com" }, "flags": { - "showTrial": true + "showTrial": true, + "showPasswordless": false } } diff --git a/apps/web/config/development.json b/apps/web/config/development.json index e3048db7a2..f460a1659a 100644 --- a/apps/web/config/development.json +++ b/apps/web/config/development.json @@ -10,6 +10,7 @@ "proxyNotifications": "http://localhost:61840" }, "flags": { - "showTrial": true + "showTrial": true, + "showPasswordless": true } } diff --git a/apps/web/config/qa.json b/apps/web/config/qa.json index 4371ea1ff9..a0d1b0e88c 100644 --- a/apps/web/config/qa.json +++ b/apps/web/config/qa.json @@ -10,6 +10,7 @@ "proxyEvents": "https://events.qa.bitwarden.pw" }, "flags": { - "showTrial": true + "showTrial": true, + "showPasswordless": true } } diff --git a/apps/web/config/selfhosted.json b/apps/web/config/selfhosted.json index 3ba61fda59..b37a922604 100644 --- a/apps/web/config/selfhosted.json +++ b/apps/web/config/selfhosted.json @@ -7,6 +7,7 @@ "port": 8081 }, "flags": { - "showTrial": false + "showTrial": false, + "showPasswordless": false } } diff --git a/apps/web/src/app/accounts/login.component.html b/apps/web/src/app/accounts/login.component.html deleted file mode 100644 index e0c4ef68db..0000000000 --- a/apps/web/src/app/accounts/login.component.html +++ /dev/null @@ -1,102 +0,0 @@ - -
-
- -

{{ "loginOrCreateNewAccount" | i18n }}

-
-
- - {{ "resetPasswordAutoEnrollInviteWarning" | i18n }} - -
- - -
-
- -
- - -
- - {{ "getMasterPasswordHint" | i18n }} - -
-
- - -
-
- -
-
-
- - - - {{ "createAccount" | i18n }} - -
- -
-
-
-
- diff --git a/apps/web/src/app/accounts/login/login-with-device.component.html b/apps/web/src/app/accounts/login/login-with-device.component.html new file mode 100644 index 0000000000..3105a639ad --- /dev/null +++ b/apps/web/src/app/accounts/login/login-with-device.component.html @@ -0,0 +1,44 @@ +
+
+ +

+ {{ "loginOrCreateNewAccount" | i18n }} +

+ +
+

{{ "logInInitiated" | i18n }}

+ +
+

{{ "notificationSentDevice" | i18n }}

+ +

+ {{ "fingerprintMatchInfo" | i18n }} +

+
+ +
+

{{ "fingerprintPhraseHeader" | i18n }}

+

+ {{ passwordlessRequest?.fingerprintPhrase }} +

+
+ + + +
+ +
+ {{ "loginWithDevciceEnabledInfo" | i18n }} + {{ "viewAllLoginOptions" | i18n }} +
+
+
+
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 new file mode 100644 index 0000000000..4c6f6268df --- /dev/null +++ b/apps/web/src/app/accounts/login/login-with-device.component.ts @@ -0,0 +1,175 @@ +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 { 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 { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { AuthRequestType } from "@bitwarden/common/enums/authRequestType"; +import { Utils } from "@bitwarden/common/misc/utils"; +import { PasswordlessLogInCredentials } from "@bitwarden/common/models/domain/logInCredentials"; +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; +import { PasswordlessCreateAuthRequest } from "@bitwarden/common/models/request/passwordlessCreateAuthRequest"; +import { AuthRequestResponse } from "@bitwarden/common/models/response/authRequestResponse"; + +@Component({ + selector: "app-login-with-device", + templateUrl: "login-with-device.component.html", +}) +export class LoginWithDeviceComponent + extends CaptchaProtectedComponent + implements OnInit, OnDestroy +{ + private destroy$ = new Subject(); + email: string; + showResendNotification = false; + passwordlessRequest: PasswordlessCreateAuthRequest; + onSuccessfulLoginTwoFactorNavigate: () => Promise; + onSuccessfulLogin: () => Promise; + onSuccessfulLoginNavigate: () => Promise; + + protected twoFactorRoute = "2fa"; + protected successRoute = "vault"; + 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, + private stateService: StateService, + environmentService: EnvironmentService, + i18nService: I18nService, + platformUtilsService: PlatformUtilsService, + private anonymousHubService: AnonymousHubService + ) { + 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; + }, 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); + await this.authService.logIn(credentials); + if (this.onSuccessfulLogin != null) { + this.onSuccessfulLogin(); + } + if (this.onSuccessfulLoginNavigate != null) { + this.onSuccessfulLoginNavigate(); + } else { + this.router.navigate([this.successRoute]); + } + } catch (error) { + 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 + ); + } +} diff --git a/apps/web/src/app/accounts/login/login.component.html b/apps/web/src/app/accounts/login/login.component.html new file mode 100644 index 0000000000..7df9777f39 --- /dev/null +++ b/apps/web/src/app/accounts/login/login.component.html @@ -0,0 +1,121 @@ +
+
+
+ +

+ {{ "loginOrCreateNewAccount" | i18n }} +

+
+ + {{ "resetPasswordAutoEnrollInviteWarning" | i18n }} + + +
+ + {{ "emailAddress" | i18n }} + + +
+ +
+ + {{ "masterPass" | i18n }} + + + + {{ "getMasterPasswordHint" | i18n }} + + +
+ +
+
+ +
+ + {{ "rememberEmail" | i18n }} + +
+ +
+ +
+ +
+ +
+ + + + + {{ "createAccount" | i18n }} + +
+ +
+ +
+ + +
+
+
+
diff --git a/apps/web/src/app/accounts/login.component.ts b/apps/web/src/app/accounts/login/login.component.ts similarity index 80% rename from apps/web/src/app/accounts/login.component.ts rename to apps/web/src/app/accounts/login/login.component.ts index 6a13ff3233..8664ae4a57 100644 --- a/apps/web/src/app/accounts/login.component.ts +++ b/apps/web/src/app/accounts/login/login.component.ts @@ -1,4 +1,5 @@ import { Component, NgZone } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { first } from "rxjs/operators"; @@ -7,6 +8,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuthService } from "@bitwarden/common/abstractions/auth.service"; import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; +import { FormValidationErrorsService } from "@bitwarden/common/abstractions/formValidationErrors.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; @@ -20,7 +22,9 @@ import { Policy } from "@bitwarden/common/models/domain/policy"; import { ListResponse } from "@bitwarden/common/models/response/listResponse"; import { PolicyResponse } from "@bitwarden/common/models/response/policyResponse"; -import { RouterService, StateService } from "../core"; +import { flagEnabled } from "src/utils/flags"; + +import { RouterService, StateService } from "../../core"; @Component({ selector: "app-login", @@ -31,6 +35,7 @@ export class LoginComponent extends BaseLoginComponent { showResetPasswordAutoEnrollWarning = false; enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions; policies: ListResponse; + showPasswordless = false; constructor( authService: AuthService, @@ -48,7 +53,9 @@ export class LoginComponent extends BaseLoginComponent { ngZone: NgZone, protected stateService: StateService, private messagingService: MessagingService, - private routerService: RouterService + private routerService: RouterService, + formBuilder: FormBuilder, + formValidationErrorService: FormValidationErrorsService ) { super( authService, @@ -60,19 +67,22 @@ export class LoginComponent extends BaseLoginComponent { passwordGenerationService, cryptoFunctionService, logService, - ngZone + ngZone, + formBuilder, + formValidationErrorService ); this.onSuccessfulLogin = async () => { this.messagingService.send("setFullWidth"); }; this.onSuccessfulLoginNavigate = this.goAfterLogIn; + this.showPasswordless = flagEnabled("showPasswordless"); } async ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.queryParams.pipe(first()).subscribe(async (qParams) => { if (qParams.email != null && qParams.email.indexOf("@") > -1) { - this.email = qParams.email; + this.formGroup.get("email")?.setValue(qParams.email); } if (qParams.premium != null) { this.routerService.setPreviousUrl("/settings/premium"); @@ -91,7 +101,8 @@ export class LoginComponent extends BaseLoginComponent { this.routerService.setPreviousUrl(route.toString()); } await super.ngOnInit(); - this.rememberEmail = await this.stateService.getRememberEmail(); + const rememberEmail = await this.stateService.getRememberEmail(); + this.formGroup.get("rememberEmail")?.setValue(rememberEmail); }); const invite = await this.stateService.getOrganizationInvitation(); @@ -125,10 +136,12 @@ export class LoginComponent extends BaseLoginComponent { } async goAfterLogIn() { + const masterPassword = this.formGroup.get("masterPassword")?.value; + // Check master password against policy if (this.enforcedPasswordPolicyOptions != null) { const strengthResult = this.passwordGenerationService.passwordStrength( - this.masterPassword, + masterPassword, this.getPasswordStrengthUserInput() ); const masterPasswordScore = strengthResult == null ? null : strengthResult.score; @@ -137,7 +150,7 @@ export class LoginComponent extends BaseLoginComponent { if ( !this.policyService.evaluateMasterPassword( masterPasswordScore, - this.masterPassword, + masterPassword, this.enforcedPasswordPolicyOptions ) ) { @@ -158,19 +171,34 @@ export class LoginComponent extends BaseLoginComponent { } async submit() { - await this.stateService.setRememberEmail(this.rememberEmail); - if (!this.rememberEmail) { + const rememberEmail = this.formGroup.get("rememberEmail")?.value; + + await this.stateService.setRememberEmail(rememberEmail); + if (!rememberEmail) { await this.stateService.setRememberedEmail(null); } - await super.submit(); + await super.submit(false); + } + + 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 } }); } private getPasswordStrengthUserInput() { + const email = this.formGroup.get("email")?.value; let userInput: string[] = []; - const atPosition = this.email.indexOf("@"); + const atPosition = email.indexOf("@"); if (atPosition > -1) { userInput = userInput.concat( - this.email + email .substr(0, atPosition) .trim() .toLowerCase() diff --git a/apps/web/src/app/accounts/login/login.module.ts b/apps/web/src/app/accounts/login/login.module.ts new file mode 100644 index 0000000000..9ab8dfb3a1 --- /dev/null +++ b/apps/web/src/app/accounts/login/login.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from "@angular/core"; + +import { SharedModule } from "../../shared"; + +import { LoginWithDeviceComponent } from "./login-with-device.component"; +import { LoginComponent } from "./login.component"; + +@NgModule({ + imports: [SharedModule], + declarations: [LoginComponent, LoginWithDeviceComponent], + exports: [LoginComponent, LoginWithDeviceComponent], +}) +export class LoginModule {} diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 61daf7c82c..6e1c4e569e 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -11,7 +11,8 @@ import { AcceptEmergencyComponent } from "./accounts/accept-emergency.component" import { AcceptOrganizationComponent } from "./accounts/accept-organization.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 { RecoverDeleteComponent } from "./accounts/recover-delete.component"; import { RecoverTwoFactorComponent } from "./accounts/recover-two-factor.component"; import { RegisterComponent } from "./accounts/register.component"; @@ -60,6 +61,11 @@ const routes: Routes = [ canActivate: [HomeGuard], // Redirects either to vault, login or lock page. }, { path: "login", component: LoginComponent, canActivate: [UnauthGuard] }, + { + path: "login-with-device", + component: LoginWithDeviceComponent, + data: { titleId: "loginWithDevice" }, + }, { path: "2fa", component: TwoFactorComponent, canActivate: [UnauthGuard] }, { path: "register", diff --git a/apps/web/src/app/oss.module.ts b/apps/web/src/app/oss.module.ts index 0885d7d5d7..457200a0e9 100644 --- a/apps/web/src/app/oss.module.ts +++ b/apps/web/src/app/oss.module.ts @@ -1,5 +1,6 @@ import { NgModule } from "@angular/core"; +import { LoginModule } from "./accounts/login/login.module"; import { TrialInitiationModule } from "./accounts/trial-initiation/trial-initiation.module"; import { OrganizationCreateModule } from "./organizations/create/organization-create.module"; import { OrganizationManageModule } from "./organizations/manage/organization-manage.module"; @@ -18,6 +19,7 @@ import { VaultFilterModule } from "./vault/vault-filter/vault-filter.module"; OrganizationManageModule, OrganizationUserModule, OrganizationCreateModule, + LoginModule, ], exports: [ SharedModule, @@ -25,6 +27,7 @@ import { VaultFilterModule } from "./vault/vault-filter/vault-filter.module"; TrialInitiationModule, VaultFilterModule, OrganizationBadgeModule, + LoginModule, ], bootstrap: [], }) diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 59315f9253..c68741b5a2 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -6,7 +6,6 @@ import { AcceptEmergencyComponent } from "../accounts/accept-emergency.component import { AcceptOrganizationComponent } from "../accounts/accept-organization.component"; import { HintComponent } from "../accounts/hint.component"; import { LockComponent } from "../accounts/lock.component"; -import { LoginComponent } from "../accounts/login.component"; import { RecoverDeleteComponent } from "../accounts/recover-delete.component"; import { RecoverTwoFactorComponent } from "../accounts/recover-two-factor.component"; import { RegisterFormModule } from "../accounts/register-form/register-form.module"; @@ -210,7 +209,6 @@ import { SharedModule } from "."; FrontendLayoutComponent, HintComponent, LockComponent, - LoginComponent, MasterPasswordPolicyComponent, NavbarComponent, NestedCheckboxComponent, @@ -355,7 +353,6 @@ import { SharedModule } from "."; FrontendLayoutComponent, HintComponent, LockComponent, - LoginComponent, MasterPasswordPolicyComponent, NavbarComponent, NestedCheckboxComponent, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 80755183b9..3b8c2f772d 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -569,15 +569,27 @@ "loginOrCreateNewAccount": { "message": "Log in or create a new account to access your secure vault." }, + "loginWithDevice" : { + "message": "Log in with device" + }, + "loginWithDevciceEnabledInfo": { + "message": "Log in with device must be enabled in the settings of the Biwarden mobile app. Need another option?" + }, "createAccount": { "message": "Create Account" }, + "newAroundHere": { + "message": "New around here?" + }, "startTrial": { "message": "Start Trial" }, "logIn": { "message": "Log In" }, + "logInInitiated": { + "message": "Log in initiated" + }, "submit": { "message": "Submit" }, @@ -635,7 +647,7 @@ "confirmMasterPasswordRequired": { "message": "Master password retype is required." }, - "masterPasswordMinLength": { + "masterPasswordMinlength": { "message": "Master password must be at least 8 characters long." }, "masterPassDoesntMatch": { @@ -705,6 +717,9 @@ "noOrganizationsList": { "message": "You do not belong to any organizations. Organizations allow you to securely share items with other users." }, + "notificationSentDevice":{ + "message": "A notification has been sent to your device." + }, "versionNumber": { "message": "Version $VERSION_NUMBER$", "placeholders": { @@ -2532,6 +2547,9 @@ } } }, + "viewAllLoginOptions": { + "message": "View all log in options" + }, "viewedItemId": { "message": "Viewed item $ID$.", "placeholders": { @@ -3372,6 +3390,12 @@ "message": "To ensure the integrity of your encryption keys, please verify the user's fingerprint phrase before continuing.", "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." }, + "fingerprintMatchInfo": { + "message": "Please make sure your vault is unlocked and Fingerprint phrase matches the other device." + }, + "fingerprintPhraseHeader": { + "message": "Fingerprint phrase" + }, "dontAskFingerprintAgain": { "message": "Never prompt to verify fingerprint phrases for invited users (Not recommended)", "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." @@ -4372,6 +4396,9 @@ "reinviteSelected": { "message": "Resend Invitations" }, + "resendNotification": { + "message": "Resend notification" + }, "noSelectedUsersApplicable": { "message": "This action is not applicable to any of the selected users." }, diff --git a/apps/web/src/utils/flags.ts b/apps/web/src/utils/flags.ts index 5cc3b930bb..195bc8e5f5 100644 --- a/apps/web/src/utils/flags.ts +++ b/apps/web/src/utils/flags.ts @@ -10,6 +10,7 @@ import { /* eslint-disable-next-line @typescript-eslint/ban-types */ export type Flags = { showTrial?: boolean; + showPasswordless?: boolean; } & SharedFlags; // required to avoid linting errors when there are no flags diff --git a/libs/angular/src/components/login.component.ts b/libs/angular/src/components/login.component.ts index 1c7a8c2332..1bc2e8ed87 100644 --- a/libs/angular/src/components/login.component.ts +++ b/libs/angular/src/components/login.component.ts @@ -1,10 +1,15 @@ -import { Directive, Input, NgZone, OnInit } from "@angular/core"; +import { Directive, NgZone, OnInit } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; import { take } from "rxjs/operators"; import { AuthService } from "@bitwarden/common/abstractions/auth.service"; import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; +import { + AllValidationErrors, + FormValidationErrorsService, +} from "@bitwarden/common/abstractions/formValidationErrors.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service"; @@ -18,16 +23,19 @@ import { CaptchaProtectedComponent } from "./captchaProtected.component"; @Directive() export class LoginComponent extends CaptchaProtectedComponent implements OnInit { - @Input() email = ""; - @Input() rememberEmail = true; - - masterPassword = ""; showPassword = false; formPromise: Promise; onSuccessfulLogin: () => Promise; onSuccessfulLoginNavigate: () => Promise; onSuccessfulLoginTwoFactorNavigate: () => Promise; onSuccessfulLoginForceResetNavigate: () => Promise; + selfHosted = false; + + formGroup = this.formBuilder.group({ + email: ["", [Validators.required, Validators.email]], + masterPassword: ["", [Validators.required, Validators.minLength(8)]], + rememberEmail: [true], + }); protected twoFactorRoute = "2fa"; protected successRoute = "vault"; @@ -44,9 +52,12 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit protected passwordGenerationService: PasswordGenerationService, protected cryptoFunctionService: CryptoFunctionService, protected logService: LogService, - protected ngZone: NgZone + protected ngZone: NgZone, + protected formBuilder: FormBuilder, + protected formValidationErrorService: FormValidationErrorsService ) { super(environmentService, i18nService, platformUtilsService); + this.selfHosted = platformUtilsService.isSelfHost(); } get selfHostedDomain() { @@ -54,59 +65,53 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit } async ngOnInit() { - if (this.email == null || this.email === "") { - this.email = await this.stateService.getRememberedEmail(); - if (this.email == null) { - this.email = ""; + let email = this.formGroup.get("email")?.value; + if (email == null || email === "") { + email = await this.stateService.getRememberedEmail(); + this.formGroup.get("email")?.setValue(email); + + if (email == null) { + this.formGroup.get("email")?.setValue(""); } } if (!this.alwaysRememberEmail) { - this.rememberEmail = (await this.stateService.getRememberedEmail()) != null; - } - if (Utils.isBrowser && !Utils.isNode) { - this.focusInput(); + const rememberEmail = (await this.stateService.getRememberedEmail()) != null; + this.formGroup.get("rememberEmail")?.setValue(rememberEmail); } } - async submit() { + async submit(showToast = true) { + const email = this.formGroup.get("email")?.value; + const masterPassword = this.formGroup.get("masterPassword")?.value; + const rememberEmail = this.formGroup.get("rememberEmail")?.value; + await this.setupCaptcha(); - if (this.email == null || this.email === "") { - this.platformUtilsService.showToast( - "error", - this.i18nService.t("errorOccurred"), - this.i18nService.t("emailRequired") - ); + this.formGroup.markAllAsTouched(); + + //web + if (this.formGroup.invalid && !showToast) { return; } - if (this.email.indexOf("@") === -1) { - this.platformUtilsService.showToast( - "error", - this.i18nService.t("errorOccurred"), - this.i18nService.t("invalidEmail") - ); - return; - } - if (this.masterPassword == null || this.masterPassword === "") { - this.platformUtilsService.showToast( - "error", - this.i18nService.t("errorOccurred"), - this.i18nService.t("masterPasswordRequired") - ); + + //desktop, browser; This should be removed once all clients use reactive forms + if (this.formGroup.invalid && showToast) { + const errorText = this.getErrorToastMessage(); + this.platformUtilsService.showToast("error", this.i18nService.t("errorOccurred"), errorText); return; } try { const credentials = new PasswordLogInCredentials( - this.email, - this.masterPassword, + email, + masterPassword, this.captchaToken, null ); this.formPromise = this.authService.logIn(credentials); const response = await this.formPromise; - if (this.rememberEmail || this.alwaysRememberEmail) { - await this.stateService.setRememberedEmail(this.email); + if (rememberEmail || this.alwaysRememberEmail) { + await this.stateService.setRememberedEmail(email); } else { await this.stateService.setRememberedEmail(null); } @@ -188,9 +193,30 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit ); } + private getErrorToastMessage() { + const error: AllValidationErrors = this.formValidationErrorService + .getFormValidationErrors(this.formGroup.controls) + .shift(); + + if (error) { + switch (error.errorName) { + case "email": + return this.i18nService.t("invalidEmail"); + default: + return this.i18nService.t(this.errorTag(error)); + } + } + + return; + } + + private errorTag(error: AllValidationErrors): string { + const name = error.errorName.charAt(0).toUpperCase() + error.errorName.slice(1); + return `${error.controlName}${name}`; + } + protected focusInput() { - document - .getElementById(this.email == null || this.email === "" ? "email" : "masterPassword") - .focus(); + const email = this.formGroup.get("email")?.value; + document.getElementById(email == null || email === "" ? "email" : "masterPassword").focus(); } } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index b76ad9a590..b53aac0866 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -5,6 +5,7 @@ import { AbstractThemingService } from "@bitwarden/angular/services/theming/them import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service"; import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/abstractions/account/account-api.service.abstraction"; import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/abstractions/account/account.service.abstraction"; +import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/abstractions/anonymousHub.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/abstractions/appId.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; @@ -62,6 +63,7 @@ import { Account } from "@bitwarden/common/models/domain/account"; import { GlobalState } from "@bitwarden/common/models/domain/globalState"; import { AccountApiService } from "@bitwarden/common/services/account/account-api.service"; import { AccountService } from "@bitwarden/common/services/account/account.service"; +import { AnonymousHubService } from "@bitwarden/common/services/anonymousHub.service"; import { ApiService } from "@bitwarden/common/services/api.service"; import { AppIdService } from "@bitwarden/common/services/appId.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; @@ -544,6 +546,11 @@ import { ValidationService } from "./validation.service"; useClass: ConfigApiService, deps: [ApiServiceAbstraction], }, + { + provide: AnonymousHubServiceAbstraction, + useClass: AnonymousHubService, + deps: [EnvironmentServiceAbstraction, AuthServiceAbstraction, LogService], + }, ], }) export class JslibServicesModule {} diff --git a/libs/common/src/abstractions/anonymousHub.service.ts b/libs/common/src/abstractions/anonymousHub.service.ts new file mode 100644 index 0000000000..43bdabd512 --- /dev/null +++ b/libs/common/src/abstractions/anonymousHub.service.ts @@ -0,0 +1,4 @@ +export abstract class AnonymousHubService { + createHubConnection: (token: string) => void; + stopHubConnection: () => void; +} diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index f08e5c34af..06d3f5b4eb 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -46,6 +46,7 @@ import { OrganizationUserUpdateGroupsRequest } from "../models/request/organizat import { OrganizationUserUpdateRequest } from "../models/request/organizationUserUpdateRequest"; import { PasswordHintRequest } from "../models/request/passwordHintRequest"; import { PasswordRequest } from "../models/request/passwordRequest"; +import { PasswordlessCreateAuthRequest } from "../models/request/passwordlessCreateAuthRequest"; import { PaymentRequest } from "../models/request/paymentRequest"; import { PreloginRequest } from "../models/request/preloginRequest"; import { ProviderAddOrganizationRequest } from "../models/request/provider/providerAddOrganizationRequest"; @@ -84,6 +85,7 @@ import { VerifyEmailRequest } from "../models/request/verifyEmailRequest"; import { ApiKeyResponse } from "../models/response/apiKeyResponse"; import { AttachmentResponse } from "../models/response/attachmentResponse"; import { AttachmentUploadDataResponse } from "../models/response/attachmentUploadDataResponse"; +import { AuthRequestResponse } from "../models/response/authRequestResponse"; import { RegisterResponse } from "../models/response/authentication/registerResponse"; import { BillingHistoryResponse } from "../models/response/billingHistoryResponse"; import { BillingPaymentResponse } from "../models/response/billingPaymentResponse"; @@ -210,6 +212,9 @@ export abstract class ApiService { postUserRotateApiKey: (id: string, request: SecretVerificationRequest) => Promise; putUpdateTempPassword: (request: UpdateTempPasswordRequest) => Promise; postConvertToKeyConnector: () => Promise; + //passwordless + postAuthRequest: (request: PasswordlessCreateAuthRequest) => Promise; + getAuthResponse: (id: string, accessCode: string) => Promise; getUserBillingHistory: () => Promise; getUserBillingPayment: () => Promise; diff --git a/libs/common/src/abstractions/auth.service.ts b/libs/common/src/abstractions/auth.service.ts index 4947f21708..bbe1c01bf2 100644 --- a/libs/common/src/abstractions/auth.service.ts +++ b/libs/common/src/abstractions/auth.service.ts @@ -1,18 +1,26 @@ +import { Observable } from "rxjs"; + import { AuthenticationStatus } from "../enums/authenticationStatus"; import { AuthResult } from "../models/domain/authResult"; import { ApiLogInCredentials, PasswordLogInCredentials, SsoLogInCredentials, + PasswordlessLogInCredentials, } from "../models/domain/logInCredentials"; import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey"; import { TokenRequestTwoFactor } from "../models/request/identityToken/tokenRequestTwoFactor"; +import { AuthRequestPushNotification } from "../models/response/notificationResponse"; export abstract class AuthService { masterPasswordHash: string; email: string; logIn: ( - credentials: ApiLogInCredentials | PasswordLogInCredentials | SsoLogInCredentials + credentials: + | ApiLogInCredentials + | PasswordLogInCredentials + | SsoLogInCredentials + | PasswordlessLogInCredentials ) => Promise; logInTwoFactor: ( twoFactor: TokenRequestTwoFactor, @@ -24,4 +32,7 @@ export abstract class AuthService { authingWithSso: () => boolean; authingWithPassword: () => boolean; getAuthStatus: (userId?: string) => Promise; + authResponsePushNotifiction: (notification: AuthRequestPushNotification) => Promise; + + getPushNotifcationObs$: () => Observable; } diff --git a/libs/common/src/enums/authRequestType.ts b/libs/common/src/enums/authRequestType.ts new file mode 100644 index 0000000000..4edfa5b888 --- /dev/null +++ b/libs/common/src/enums/authRequestType.ts @@ -0,0 +1,4 @@ +export enum AuthRequestType { + AuthenticateAndUnlock = 0, + Unlock = 1, +} diff --git a/libs/common/src/enums/authenticationType.ts b/libs/common/src/enums/authenticationType.ts index ed7375c808..5133c4f648 100644 --- a/libs/common/src/enums/authenticationType.ts +++ b/libs/common/src/enums/authenticationType.ts @@ -2,4 +2,5 @@ export enum AuthenticationType { Password = 0, Sso = 1, Api = 2, + Passwordless = 3, } diff --git a/libs/common/src/enums/notificationType.ts b/libs/common/src/enums/notificationType.ts index 77ebde01fc..457ad174ca 100644 --- a/libs/common/src/enums/notificationType.ts +++ b/libs/common/src/enums/notificationType.ts @@ -17,4 +17,7 @@ export enum NotificationType { SyncSendCreate = 12, SyncSendUpdate = 13, SyncSendDelete = 14, + + AuthRequest = 15, + AuthRequestResponse = 16, } diff --git a/libs/common/src/misc/logInStrategies/logIn.strategy.ts b/libs/common/src/misc/logInStrategies/logIn.strategy.ts index 8615700681..577130156f 100644 --- a/libs/common/src/misc/logInStrategies/logIn.strategy.ts +++ b/libs/common/src/misc/logInStrategies/logIn.strategy.ts @@ -14,6 +14,7 @@ import { ApiLogInCredentials, PasswordLogInCredentials, SsoLogInCredentials, + PasswordlessLogInCredentials, } from "../../models/domain/logInCredentials"; import { DeviceRequest } from "../../models/request/deviceRequest"; import { ApiTokenRequest } from "../../models/request/identityToken/apiTokenRequest"; @@ -42,7 +43,11 @@ export abstract class LogInStrategy { ) {} abstract logIn( - credentials: ApiLogInCredentials | PasswordLogInCredentials | SsoLogInCredentials + credentials: + | ApiLogInCredentials + | PasswordLogInCredentials + | SsoLogInCredentials + | PasswordlessLogInCredentials ): Promise; async logInTwoFactor( diff --git a/libs/common/src/misc/logInStrategies/passwordlessLogin.strategy.ts b/libs/common/src/misc/logInStrategies/passwordlessLogin.strategy.ts new file mode 100644 index 0000000000..0acc4a49f0 --- /dev/null +++ b/libs/common/src/misc/logInStrategies/passwordlessLogin.strategy.ts @@ -0,0 +1,86 @@ +import { ApiService } from "../../abstractions/api.service"; +import { AppIdService } from "../../abstractions/appId.service"; +import { AuthService } from "../../abstractions/auth.service"; +import { CryptoService } from "../../abstractions/crypto.service"; +import { LogService } from "../../abstractions/log.service"; +import { MessagingService } from "../../abstractions/messaging.service"; +import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; +import { StateService } from "../../abstractions/state.service"; +import { TokenService } from "../../abstractions/token.service"; +import { TwoFactorService } from "../../abstractions/twoFactor.service"; +import { AuthResult } from "../../models/domain/authResult"; +import { PasswordlessLogInCredentials } from "../../models/domain/logInCredentials"; +import { SymmetricCryptoKey } from "../../models/domain/symmetricCryptoKey"; +import { PasswordTokenRequest } from "../../models/request/identityToken/passwordTokenRequest"; +import { TokenRequestTwoFactor } from "../../models/request/identityToken/tokenRequestTwoFactor"; + +import { LogInStrategy } from "./logIn.strategy"; + +export class PasswordlessLogInStrategy extends LogInStrategy { + get email() { + return this.tokenRequest.email; + } + + get masterPasswordHash() { + return this.tokenRequest.masterPasswordHash; + } + + tokenRequest: PasswordTokenRequest; + + private localHashedPassword: string; + private key: SymmetricCryptoKey; + + constructor( + cryptoService: CryptoService, + apiService: ApiService, + tokenService: TokenService, + appIdService: AppIdService, + platformUtilsService: PlatformUtilsService, + messagingService: MessagingService, + logService: LogService, + stateService: StateService, + twoFactorService: TwoFactorService, + private authService: AuthService + ) { + super( + cryptoService, + apiService, + tokenService, + appIdService, + platformUtilsService, + messagingService, + logService, + stateService, + twoFactorService + ); + } + + async onSuccessfulLogin() { + await this.cryptoService.setKey(this.key); + await this.cryptoService.setKeyHash(this.localHashedPassword); + } + + async logInTwoFactor( + twoFactor: TokenRequestTwoFactor, + captchaResponse: string + ): Promise { + this.tokenRequest.captchaResponse = captchaResponse ?? this.captchaBypassToken; + return super.logInTwoFactor(twoFactor); + } + + async logIn(credentials: PasswordlessLogInCredentials) { + this.localHashedPassword = credentials.localPasswordHash; + this.key = credentials.decKey; + + this.tokenRequest = new PasswordTokenRequest( + credentials.email, + credentials.accessCode, + null, + await this.buildTwoFactor(credentials.twoFactor), + await this.buildDeviceRequest() + ); + + this.tokenRequest.setPasswordlessAccessCode(credentials.authRequestId); + return this.startLogIn(); + } +} diff --git a/libs/common/src/models/domain/logInCredentials.ts b/libs/common/src/models/domain/logInCredentials.ts index c1e23610e4..5f2035fd15 100644 --- a/libs/common/src/models/domain/logInCredentials.ts +++ b/libs/common/src/models/domain/logInCredentials.ts @@ -1,3 +1,5 @@ +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; + import { AuthenticationType } from "../../enums/authenticationType"; import { TokenRequestTwoFactor } from "../request/identityToken/tokenRequestTwoFactor"; @@ -29,3 +31,16 @@ export class ApiLogInCredentials { constructor(public clientId: string, public clientSecret: string) {} } + +export class PasswordlessLogInCredentials { + readonly type = AuthenticationType.Passwordless; + + constructor( + public email: string, + public accessCode: string, + public authRequestId: string, + public decKey: SymmetricCryptoKey, + public localPasswordHash: string, + public twoFactor?: TokenRequestTwoFactor + ) {} +} diff --git a/libs/common/src/models/request/identityToken/tokenRequest.ts b/libs/common/src/models/request/identityToken/tokenRequest.ts index 82a4a394c5..5e38d2069b 100644 --- a/libs/common/src/models/request/identityToken/tokenRequest.ts +++ b/libs/common/src/models/request/identityToken/tokenRequest.ts @@ -4,6 +4,7 @@ import { TokenRequestTwoFactor } from "./tokenRequestTwoFactor"; export abstract class TokenRequest { protected device?: DeviceRequest; + protected passwordlessAuthRequest: string; constructor(protected twoFactor: TokenRequestTwoFactor, device?: DeviceRequest) { this.device = device != null ? device : null; @@ -18,6 +19,10 @@ export abstract class TokenRequest { this.twoFactor = twoFactor; } + setPasswordlessAccessCode(accessCode: string) { + this.passwordlessAuthRequest = accessCode; + } + protected toIdentityToken(clientId: string) { const obj: any = { scope: "api offline_access", @@ -32,6 +37,11 @@ export abstract class TokenRequest { // obj.devicePushToken = this.device.pushToken; } + //passswordless login + if (this.passwordlessAuthRequest) { + obj.authRequest = this.passwordlessAuthRequest; + } + if (this.twoFactor.token && this.twoFactor.provider != null) { obj.twoFactorToken = this.twoFactor.token; obj.twoFactorProvider = this.twoFactor.provider; diff --git a/libs/common/src/models/request/passwordlessCreateAuthRequest.ts b/libs/common/src/models/request/passwordlessCreateAuthRequest.ts new file mode 100644 index 0000000000..df83c54777 --- /dev/null +++ b/libs/common/src/models/request/passwordlessCreateAuthRequest.ts @@ -0,0 +1,12 @@ +import { AuthRequestType } from "../../enums/authRequestType"; + +export class PasswordlessCreateAuthRequest { + constructor( + readonly email: string, + readonly deviceIdentifier: string, + readonly publicKey: string, + readonly type: AuthRequestType, + readonly accessCode: string, + readonly fingerprintPhrase: string + ) {} +} diff --git a/libs/common/src/models/response/authRequestResponse.ts b/libs/common/src/models/response/authRequestResponse.ts new file mode 100644 index 0000000000..1a29a3da85 --- /dev/null +++ b/libs/common/src/models/response/authRequestResponse.ts @@ -0,0 +1,26 @@ +import { DeviceType } from "@bitwarden/common/enums/deviceType"; + +import { BaseResponse } from "./baseResponse"; + +export class AuthRequestResponse extends BaseResponse { + id: string; + publicKey: string; + requestDeviceType: DeviceType; + requestIpAddress: string; + key: string; + masterPasswordHash: string; + creationDate: string; + requestApproved: boolean; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.publicKey = this.getResponseProperty("PublicKey"); + this.requestDeviceType = this.getResponseProperty("RequestDeviceType"); + this.requestIpAddress = this.getResponseProperty("RequestIpAddress"); + this.key = this.getResponseProperty("Key"); + this.masterPasswordHash = this.getResponseProperty("MasterPasswordHash"); + this.creationDate = this.getResponseProperty("CreationDate"); + this.requestApproved = this.getResponseProperty("RequestApproved"); + } +} diff --git a/libs/common/src/models/response/notificationResponse.ts b/libs/common/src/models/response/notificationResponse.ts index f23de8fe8b..1e2a504506 100644 --- a/libs/common/src/models/response/notificationResponse.ts +++ b/libs/common/src/models/response/notificationResponse.ts @@ -37,6 +37,10 @@ export class NotificationResponse extends BaseResponse { case NotificationType.SyncSendDelete: this.payload = new SyncSendNotification(payload); break; + case NotificationType.AuthRequest: + case NotificationType.AuthRequestResponse: + this.payload = new AuthRequestPushNotification(payload); + break; default: break; } @@ -96,3 +100,14 @@ export class SyncSendNotification extends BaseResponse { this.revisionDate = new Date(this.getResponseProperty("RevisionDate")); } } + +export class AuthRequestPushNotification extends BaseResponse { + id: string; + userId: string; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.userId = this.getResponseProperty("UserId"); + } +} diff --git a/libs/common/src/services/anonymousHub.service.ts b/libs/common/src/services/anonymousHub.service.ts new file mode 100644 index 0000000000..13b5898b18 --- /dev/null +++ b/libs/common/src/services/anonymousHub.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from "@angular/core"; +import { + HttpTransportType, + HubConnection, + HubConnectionBuilder, + IHubProtocol, +} from "@microsoft/signalr"; +import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack"; + +import { AnonymousHubService as AnonymousHubServiceAbstraction } from "../abstractions/anonymousHub.service"; +import { AuthService } from "../abstractions/auth.service"; +import { EnvironmentService } from "../abstractions/environment.service"; +import { LogService } from "../abstractions/log.service"; + +import { + AuthRequestPushNotification, + NotificationResponse, +} from "./../models/response/notificationResponse"; + +@Injectable() +export class AnonymousHubService implements AnonymousHubServiceAbstraction { + private anonHubConnection: HubConnection; + private url: string; + + constructor( + private environmentService: EnvironmentService, + private authService: AuthService, + private logService: LogService + ) {} + + async createHubConnection(token: string) { + this.url = this.environmentService.getNotificationsUrl(); + + this.anonHubConnection = new HubConnectionBuilder() + .withUrl(this.url + "/anonymousHub?Token=" + token, { + skipNegotiation: true, + transport: HttpTransportType.WebSockets, + }) + .withHubProtocol(new MessagePackHubProtocol() as IHubProtocol) + .build(); + + this.anonHubConnection.start().catch((error) => this.logService.error(error)); + + this.anonHubConnection.on("AuthRequestResponseRecieved", (data: any) => { + this.ProcessNotification(new NotificationResponse(data)); + }); + } + + stopHubConnection() { + if (this.anonHubConnection) { + this.anonHubConnection.stop(); + } + } + + private async ProcessNotification(notification: NotificationResponse) { + await this.authService.authResponsePushNotifiction( + notification.payload as AuthRequestPushNotification + ); + } +} diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 0b96a11831..a4648e2504 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -54,6 +54,7 @@ import { OrganizationUserUpdateGroupsRequest } from "../models/request/organizat import { OrganizationUserUpdateRequest } from "../models/request/organizationUserUpdateRequest"; import { PasswordHintRequest } from "../models/request/passwordHintRequest"; import { PasswordRequest } from "../models/request/passwordRequest"; +import { PasswordlessCreateAuthRequest } from "../models/request/passwordlessCreateAuthRequest"; import { PaymentRequest } from "../models/request/paymentRequest"; import { PreloginRequest } from "../models/request/preloginRequest"; import { ProviderAddOrganizationRequest } from "../models/request/provider/providerAddOrganizationRequest"; @@ -92,6 +93,7 @@ import { VerifyEmailRequest } from "../models/request/verifyEmailRequest"; import { ApiKeyResponse } from "../models/response/apiKeyResponse"; import { AttachmentResponse } from "../models/response/attachmentResponse"; import { AttachmentUploadDataResponse } from "../models/response/attachmentUploadDataResponse"; +import { AuthRequestResponse } from "../models/response/authRequestResponse"; import { RegisterResponse } from "../models/response/authentication/registerResponse"; import { BillingHistoryResponse } from "../models/response/billingHistoryResponse"; import { BillingPaymentResponse } from "../models/response/billingPaymentResponse"; @@ -265,6 +267,17 @@ export class ApiService implements ApiServiceAbstraction { } } + async postAuthRequest(request: PasswordlessCreateAuthRequest): Promise { + const r = await this.send("POST", "/auth-requests/", request, false, true); + return new AuthRequestResponse(r); + } + + async getAuthResponse(id: string, accessCode: string): Promise { + const path = `/auth-requests/${id}/response?code=${accessCode}`; + const r = await this.send("GET", path, null, false, true); + return new AuthRequestResponse(r); + } + // Account APIs async getProfile(): Promise { diff --git a/libs/common/src/services/auth.service.ts b/libs/common/src/services/auth.service.ts index 6f77bca20b..3807eee3d6 100644 --- a/libs/common/src/services/auth.service.ts +++ b/libs/common/src/services/auth.service.ts @@ -1,3 +1,5 @@ +import { Observable, Subject } from "rxjs"; + import { ApiService } from "../abstractions/api.service"; import { AppIdService } from "../abstractions/appId.service"; import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service"; @@ -17,17 +19,20 @@ import { KdfType } from "../enums/kdfType"; import { KeySuffixOptions } from "../enums/keySuffixOptions"; import { ApiLogInStrategy } from "../misc/logInStrategies/apiLogin.strategy"; import { PasswordLogInStrategy } from "../misc/logInStrategies/passwordLogin.strategy"; +import { PasswordlessLogInStrategy } from "../misc/logInStrategies/passwordlessLogin.strategy"; import { SsoLogInStrategy } from "../misc/logInStrategies/ssoLogin.strategy"; import { AuthResult } from "../models/domain/authResult"; import { ApiLogInCredentials, PasswordLogInCredentials, SsoLogInCredentials, + PasswordlessLogInCredentials, } from "../models/domain/logInCredentials"; import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey"; import { TokenRequestTwoFactor } from "../models/request/identityToken/tokenRequestTwoFactor"; import { PreloginRequest } from "../models/request/preloginRequest"; import { ErrorResponse } from "../models/response/errorResponse"; +import { AuthRequestPushNotification } from "../models/response/notificationResponse"; const sessionTimeoutLength = 2 * 60 * 1000; // 2 minutes @@ -42,9 +47,15 @@ export class AuthService implements AuthServiceAbstraction { : null; } - private logInStrategy: ApiLogInStrategy | PasswordLogInStrategy | SsoLogInStrategy; + private logInStrategy: + | ApiLogInStrategy + | PasswordLogInStrategy + | SsoLogInStrategy + | PasswordlessLogInStrategy; private sessionTimeout: any; + private pushNotificationSubject = new Subject(); + constructor( protected cryptoService: CryptoService, protected apiService: ApiService, @@ -61,52 +72,78 @@ export class AuthService implements AuthServiceAbstraction { ) {} async logIn( - credentials: ApiLogInCredentials | PasswordLogInCredentials | SsoLogInCredentials + credentials: + | ApiLogInCredentials + | PasswordLogInCredentials + | SsoLogInCredentials + | PasswordlessLogInCredentials ): Promise { this.clearState(); - let strategy: ApiLogInStrategy | PasswordLogInStrategy | SsoLogInStrategy; + let strategy: + | ApiLogInStrategy + | PasswordLogInStrategy + | SsoLogInStrategy + | PasswordlessLogInStrategy; - if (credentials.type === AuthenticationType.Password) { - strategy = new PasswordLogInStrategy( - this.cryptoService, - this.apiService, - this.tokenService, - this.appIdService, - this.platformUtilsService, - this.messagingService, - this.logService, - this.stateService, - this.twoFactorService, - this - ); - } else if (credentials.type === AuthenticationType.Sso) { - strategy = new SsoLogInStrategy( - this.cryptoService, - this.apiService, - this.tokenService, - this.appIdService, - this.platformUtilsService, - this.messagingService, - this.logService, - this.stateService, - this.twoFactorService, - this.keyConnectorService - ); - } else if (credentials.type === AuthenticationType.Api) { - strategy = new ApiLogInStrategy( - this.cryptoService, - this.apiService, - this.tokenService, - this.appIdService, - this.platformUtilsService, - this.messagingService, - this.logService, - this.stateService, - this.twoFactorService, - this.environmentService, - this.keyConnectorService - ); + switch (credentials.type) { + case AuthenticationType.Password: + strategy = new PasswordLogInStrategy( + this.cryptoService, + this.apiService, + this.tokenService, + this.appIdService, + this.platformUtilsService, + this.messagingService, + this.logService, + this.stateService, + this.twoFactorService, + this + ); + break; + case AuthenticationType.Sso: + strategy = new SsoLogInStrategy( + this.cryptoService, + this.apiService, + this.tokenService, + this.appIdService, + this.platformUtilsService, + this.messagingService, + this.logService, + this.stateService, + this.twoFactorService, + this.keyConnectorService + ); + break; + case AuthenticationType.Api: + strategy = new ApiLogInStrategy( + this.cryptoService, + this.apiService, + this.tokenService, + this.appIdService, + this.platformUtilsService, + this.messagingService, + this.logService, + this.stateService, + this.twoFactorService, + this.environmentService, + this.keyConnectorService + ); + break; + case AuthenticationType.Passwordless: + strategy = new PasswordlessLogInStrategy( + this.cryptoService, + this.apiService, + this.tokenService, + this.appIdService, + this.platformUtilsService, + this.messagingService, + this.logService, + this.stateService, + this.twoFactorService, + this + ); + break; } const result = await strategy.logIn(credentials as any); @@ -202,7 +239,21 @@ export class AuthService implements AuthServiceAbstraction { return this.cryptoService.makeKey(masterPassword, email, kdf, kdfIterations); } - private saveState(strategy: ApiLogInStrategy | PasswordLogInStrategy | SsoLogInStrategy) { + async authResponsePushNotifiction(notification: AuthRequestPushNotification): Promise { + this.pushNotificationSubject.next(notification.id); + } + + getPushNotifcationObs$(): Observable { + return this.pushNotificationSubject.asObservable(); + } + + private saveState( + strategy: + | ApiLogInStrategy + | PasswordLogInStrategy + | SsoLogInStrategy + | PasswordlessLogInStrategy + ) { this.logInStrategy = strategy; this.startSessionTimeout(); }