1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-21 11:35:34 +01:00

feat(auth): [PM-9723] Refresh LoginViaAuthRequestComponent (#11545)

Creates a refreshed and consolidated LoginViaAuthRequestComponent for use on all visual clients, which will be used when the UnauthenticatedExtensionUIRefresh feature flag is on.
This commit is contained in:
rr-bw 2024-11-19 14:53:01 -08:00 committed by GitHub
parent 2df8643e29
commit 9429ae1d06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1025 additions and 69 deletions

View File

@ -3170,12 +3170,27 @@
"resendNotification": { "resendNotification": {
"message": "Resend notification" "message": "Resend notification"
}, },
"viewAllLogInOptions": {
"message": "View all log in options"
},
"viewAllLoginOptions": { "viewAllLoginOptions": {
"message": "View all log in options" "message": "View all log in options"
}, },
"notificationSentDevice": { "notificationSentDevice": {
"message": "A notification has been sent to your device." "message": "A notification has been sent to your device."
}, },
"aNotificationWasSentToYourDevice": {
"message": "A notification was sent to your device"
},
"makeSureYourAccountIsUnlockedAndTheFingerprintEtc": {
"message": "Make sure your account is unlocked and the fingerprint phrase matches on the other device"
},
"youWillBeNotifiedOnceTheRequestIsApproved": {
"message": "You will be notified once the request is approved"
},
"needAnotherOptionV1": {
"message": "Need another option?"
},
"loginInitiated": { "loginInitiated": {
"message": "Login initiated" "message": "Login initiated"
}, },

View File

@ -2,7 +2,7 @@ import { Location } from "@angular/common";
import { Component } from "@angular/core"; import { Component } from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { LoginViaAuthRequestComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/auth/components/login-via-auth-request.component"; import { LoginViaAuthRequestComponentV1 as BaseLoginViaAuthRequestComponentV1 } from "@bitwarden/angular/auth/components/login-via-auth-request-v1.component";
import { import {
AuthRequestServiceAbstraction, AuthRequestServiceAbstraction,
LoginStrategyServiceAbstraction, LoginStrategyServiceAbstraction,
@ -27,9 +27,9 @@ import { KeyService } from "@bitwarden/key-management";
@Component({ @Component({
selector: "app-login-via-auth-request", selector: "app-login-via-auth-request",
templateUrl: "login-via-auth-request.component.html", templateUrl: "login-via-auth-request-v1.component.html",
}) })
export class LoginViaAuthRequestComponent extends BaseLoginWithDeviceComponent { export class LoginViaAuthRequestComponentV1 extends BaseLoginViaAuthRequestComponentV1 {
constructor( constructor(
router: Router, router: Router,
keyService: KeyService, keyService: KeyService,

View File

@ -132,6 +132,9 @@ export const routerTransition = trigger("routerTransition", [
transition("login-with-device => tabs, login-with-device => 2fa", inSlideLeft), transition("login-with-device => tabs, login-with-device => 2fa", inSlideLeft),
transition("login-with-device => login", outSlideRight), transition("login-with-device => login", outSlideRight),
transition("admin-approval-requested => tabs, admin-approval-requested => 2fa", inSlideLeft),
transition("admin-approval-requested => login", outSlideRight),
transition(tabsToCiphers, inSlideLeft), transition(tabsToCiphers, inSlideLeft),
transition(ciphersToTabs, outSlideRight), transition(ciphersToTabs, outSlideRight),

View File

@ -21,10 +21,12 @@ import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh
import { import {
AnonLayoutWrapperComponent, AnonLayoutWrapperComponent,
AnonLayoutWrapperData, AnonLayoutWrapperData,
DevicesIcon,
LoginComponent, LoginComponent,
LoginSecondaryContentComponent, LoginSecondaryContentComponent,
LockIcon, LockIcon,
LockV2Component, LockV2Component,
LoginViaAuthRequestComponent,
PasswordHintComponent, PasswordHintComponent,
RegistrationFinishComponent, RegistrationFinishComponent,
RegistrationLockAltIcon, RegistrationLockAltIcon,
@ -51,7 +53,7 @@ import { HomeComponent } from "../auth/popup/home.component";
import { LockComponent } from "../auth/popup/lock.component"; import { LockComponent } from "../auth/popup/lock.component";
import { LoginDecryptionOptionsComponent } from "../auth/popup/login-decryption-options/login-decryption-options.component"; import { LoginDecryptionOptionsComponent } from "../auth/popup/login-decryption-options/login-decryption-options.component";
import { LoginComponentV1 } from "../auth/popup/login-v1.component"; import { LoginComponentV1 } from "../auth/popup/login-v1.component";
import { LoginViaAuthRequestComponent } from "../auth/popup/login-via-auth-request.component"; import { LoginViaAuthRequestComponentV1 } from "../auth/popup/login-via-auth-request-v1.component";
import { RegisterComponent } from "../auth/popup/register.component"; import { RegisterComponent } from "../auth/popup/register.component";
import { RemovePasswordComponent } from "../auth/popup/remove-password.component"; import { RemovePasswordComponent } from "../auth/popup/remove-password.component";
import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component";
@ -171,18 +173,6 @@ const routes: Routes = [
canActivate: [fido2AuthGuard], canActivate: [fido2AuthGuard],
data: { state: "fido2" } satisfies RouteDataProperties, data: { state: "fido2" } satisfies RouteDataProperties,
}), }),
{
path: "login-with-device",
component: LoginViaAuthRequestComponent,
canActivate: [],
data: { state: "login-with-device" } satisfies RouteDataProperties,
},
{
path: "admin-approval-requested",
component: LoginViaAuthRequestComponent,
canActivate: [],
data: { state: "login-with-device" } satisfies RouteDataProperties,
},
{ {
path: "lock", path: "lock",
component: LockComponent, component: LockComponent,
@ -409,6 +399,61 @@ const routes: Routes = [
canActivate: [authGuard], canActivate: [authGuard],
data: { state: "update-temp-password" } satisfies RouteDataProperties, data: { state: "update-temp-password" } satisfies RouteDataProperties,
}, },
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
ExtensionAnonLayoutWrapperComponent,
{
path: "login-with-device",
data: { state: "login-with-device" } satisfies RouteDataProperties,
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "loginInitiated",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
showLogo: false,
showBackButton: true,
state: "login-with-device",
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
ExtensionAnonLayoutWrapperComponent,
{
path: "admin-approval-requested",
data: { state: "admin-approval-requested" } satisfies RouteDataProperties,
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
showLogo: false,
showBackButton: true,
state: "admin-approval-requested",
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
),
...unauthUiRefreshSwap( ...unauthUiRefreshSwap(
HintComponent, HintComponent,
ExtensionAnonLayoutWrapperComponent, ExtensionAnonLayoutWrapperComponent,

View File

@ -26,7 +26,7 @@ import { HomeComponent } from "../auth/popup/home.component";
import { LockComponent } from "../auth/popup/lock.component"; import { LockComponent } from "../auth/popup/lock.component";
import { LoginDecryptionOptionsComponent } from "../auth/popup/login-decryption-options/login-decryption-options.component"; import { LoginDecryptionOptionsComponent } from "../auth/popup/login-decryption-options/login-decryption-options.component";
import { LoginComponentV1 } from "../auth/popup/login-v1.component"; import { LoginComponentV1 } from "../auth/popup/login-v1.component";
import { LoginViaAuthRequestComponent } from "../auth/popup/login-via-auth-request.component"; import { LoginViaAuthRequestComponentV1 } from "../auth/popup/login-via-auth-request-v1.component";
import { RegisterComponent } from "../auth/popup/register.component"; import { RegisterComponent } from "../auth/popup/register.component";
import { RemovePasswordComponent } from "../auth/popup/remove-password.component"; import { RemovePasswordComponent } from "../auth/popup/remove-password.component";
import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component";
@ -159,8 +159,8 @@ import "../platform/popup/locales";
HintComponent, HintComponent,
HomeComponent, HomeComponent,
LockComponent, LockComponent,
LoginViaAuthRequestComponentV1,
LoginComponentV1, LoginComponentV1,
LoginViaAuthRequestComponent,
LoginDecryptionOptionsComponent, LoginDecryptionOptionsComponent,
NotificationsSettingsV1Component, NotificationsSettingsV1Component,
AppearanceComponent, AppearanceComponent,

View File

@ -18,10 +18,12 @@ import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-ref
import { import {
AnonLayoutWrapperComponent, AnonLayoutWrapperComponent,
AnonLayoutWrapperData, AnonLayoutWrapperData,
DevicesIcon,
LoginComponent, LoginComponent,
LoginSecondaryContentComponent, LoginSecondaryContentComponent,
LockIcon, LockIcon,
LockV2Component, LockV2Component,
LoginViaAuthRequestComponent,
PasswordHintComponent, PasswordHintComponent,
RegistrationFinishComponent, RegistrationFinishComponent,
RegistrationLockAltIcon, RegistrationLockAltIcon,
@ -42,7 +44,7 @@ import { HintComponent } from "../auth/hint.component";
import { LockComponent } from "../auth/lock.component"; import { LockComponent } from "../auth/lock.component";
import { LoginDecryptionOptionsComponent } from "../auth/login/login-decryption-options/login-decryption-options.component"; import { LoginDecryptionOptionsComponent } from "../auth/login/login-decryption-options/login-decryption-options.component";
import { LoginComponentV1 } from "../auth/login/login-v1.component"; import { LoginComponentV1 } from "../auth/login/login-v1.component";
import { LoginViaAuthRequestComponent } from "../auth/login/login-via-auth-request.component"; import { LoginViaAuthRequestComponentV1 } from "../auth/login/login-via-auth-request-v1.component";
import { RegisterComponent } from "../auth/register.component"; import { RegisterComponent } from "../auth/register.component";
import { RemovePasswordComponent } from "../auth/remove-password.component"; import { RemovePasswordComponent } from "../auth/remove-password.component";
import { SetPasswordComponent } from "../auth/set-password.component"; import { SetPasswordComponent } from "../auth/set-password.component";
@ -75,14 +77,6 @@ const routes: Routes = [
canActivate: [lockGuard()], canActivate: [lockGuard()],
canMatch: [extensionRefreshRedirect("/lockV2")], canMatch: [extensionRefreshRedirect("/lockV2")],
}, },
{
path: "login-with-device",
component: LoginViaAuthRequestComponent,
},
{
path: "admin-approval-requested",
component: LoginViaAuthRequestComponent,
},
...twofactorRefactorSwap( ...twofactorRefactorSwap(
TwoFactorComponent, TwoFactorComponent,
AnonLayoutWrapperComponent, AnonLayoutWrapperComponent,
@ -130,6 +124,53 @@ const routes: Routes = [
component: RemovePasswordComponent, component: RemovePasswordComponent,
canActivate: [authGuard], canActivate: [authGuard],
}, },
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
AnonLayoutWrapperComponent,
{
path: "login-with-device",
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "loginInitiated",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
AnonLayoutWrapperComponent,
{
path: "admin-approval-requested",
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
} satisfies AnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
),
...unauthUiRefreshSwap( ...unauthUiRefreshSwap(
HintComponent, HintComponent,
AnonLayoutWrapperComponent, AnonLayoutWrapperComponent,

View File

@ -2,7 +2,7 @@ import { Location } from "@angular/common";
import { Component, ViewChild, ViewContainerRef } from "@angular/core"; import { Component, ViewChild, ViewContainerRef } from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { LoginViaAuthRequestComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/auth/components/login-via-auth-request.component"; import { LoginViaAuthRequestComponentV1 as BaseLoginViaAuthRequestComponentV1 } from "@bitwarden/angular/auth/components/login-via-auth-request-v1.component";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
import { import {
AuthRequestServiceAbstraction, AuthRequestServiceAbstraction,
@ -30,9 +30,9 @@ import { EnvironmentComponent } from "../environment.component";
@Component({ @Component({
selector: "app-login-via-auth-request", selector: "app-login-via-auth-request",
templateUrl: "login-via-auth-request.component.html", templateUrl: "login-via-auth-request-v1.component.html",
}) })
export class LoginViaAuthRequestComponent extends BaseLoginWithDeviceComponent { export class LoginViaAuthRequestComponentV1 extends BaseLoginViaAuthRequestComponentV1 {
@ViewChild("environment", { read: ViewContainerRef, static: true }) @ViewChild("environment", { read: ViewContainerRef, static: true })
environmentModal: ViewContainerRef; environmentModal: ViewContainerRef;
showingModal = false; showingModal = false;

View File

@ -7,16 +7,16 @@ import { SharedModule } from "../../app/shared/shared.module";
import { LoginDecryptionOptionsComponent } from "./login-decryption-options/login-decryption-options.component"; import { LoginDecryptionOptionsComponent } from "./login-decryption-options/login-decryption-options.component";
import { LoginComponentV1 } from "./login-v1.component"; import { LoginComponentV1 } from "./login-v1.component";
import { LoginViaAuthRequestComponent } from "./login-via-auth-request.component"; import { LoginViaAuthRequestComponentV1 } from "./login-via-auth-request-v1.component";
@NgModule({ @NgModule({
imports: [SharedModule, RouterModule], imports: [SharedModule, RouterModule],
declarations: [ declarations: [
LoginComponentV1, LoginComponentV1,
LoginViaAuthRequestComponent, LoginViaAuthRequestComponentV1,
EnvironmentSelectorComponent, EnvironmentSelectorComponent,
LoginDecryptionOptionsComponent, LoginDecryptionOptionsComponent,
], ],
exports: [LoginComponentV1, LoginViaAuthRequestComponent], exports: [LoginComponentV1, LoginViaAuthRequestComponentV1],
}) })
export class LoginModule {} export class LoginModule {}

View File

@ -2689,15 +2689,30 @@
"notificationSentDevice": { "notificationSentDevice": {
"message": "A notification has been sent to your device." "message": "A notification has been sent to your device."
}, },
"aNotificationWasSentToYourDevice": {
"message": "A notification was sent to your device"
},
"makeSureYourAccountIsUnlockedAndTheFingerprintEtc": {
"message": "Make sure your account is unlocked and the fingerprint phrase matches on the other device"
},
"needAnotherOptionV1": {
"message": "Need another option?"
},
"fingerprintMatchInfo": { "fingerprintMatchInfo": {
"message": "Please make sure your vault is unlocked and Fingerprint phrase matches the other device." "message": "Please make sure your vault is unlocked and Fingerprint phrase matches the other device."
}, },
"fingerprintPhraseHeader": { "fingerprintPhraseHeader": {
"message": "Fingerprint phrase" "message": "Fingerprint phrase"
}, },
"youWillBeNotifiedOnceTheRequestIsApproved": {
"message": "You will be notified once the request is approved"
},
"needAnotherOption": { "needAnotherOption": {
"message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?" "message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?"
}, },
"viewAllLogInOptions": {
"message": "View all log in options"
},
"viewAllLoginOptions": { "viewAllLoginOptions": {
"message": "View all login options" "message": "View all login options"
}, },

View File

@ -45,6 +45,7 @@
</div> </div>
</div> </div>
</ng-container> </ng-container>
<ng-container *ngIf="state == StateEnum.AdminAuthRequest"> <ng-container *ngIf="state == StateEnum.AdminAuthRequest">
<div <div
class="tw-mt-3 tw-rounded-md tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-6" class="tw-mt-3 tw-rounded-md tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-6"

View File

@ -0,0 +1,9 @@
import { Component } from "@angular/core";
import { LoginViaAuthRequestComponentV1 as BaseLoginViaAuthRequestComponentV1 } from "@bitwarden/angular/auth/components/login-via-auth-request-v1.component";
@Component({
selector: "app-login-via-auth-request",
templateUrl: "login-via-auth-request-v1.component.html",
})
export class LoginViaAuthRequestComponentV1 extends BaseLoginViaAuthRequestComponentV1 {}

View File

@ -1,9 +0,0 @@
import { Component } from "@angular/core";
import { LoginViaAuthRequestComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/auth/components/login-via-auth-request.component";
@Component({
selector: "app-login-via-auth-request",
templateUrl: "login-via-auth-request.component.html",
})
export class LoginViaAuthRequestComponent extends BaseLoginWithDeviceComponent {}

View File

@ -6,20 +6,20 @@ import { SharedModule } from "../../../app/shared";
import { LoginDecryptionOptionsComponent } from "./login-decryption-options/login-decryption-options.component"; import { LoginDecryptionOptionsComponent } from "./login-decryption-options/login-decryption-options.component";
import { LoginComponentV1 } from "./login-v1.component"; import { LoginComponentV1 } from "./login-v1.component";
import { LoginViaAuthRequestComponent } from "./login-via-auth-request.component"; import { LoginViaAuthRequestComponentV1 } from "./login-via-auth-request-v1.component";
import { LoginViaWebAuthnComponent } from "./login-via-webauthn/login-via-webauthn.component"; import { LoginViaWebAuthnComponent } from "./login-via-webauthn/login-via-webauthn.component";
@NgModule({ @NgModule({
imports: [SharedModule, CheckboxModule], imports: [SharedModule, CheckboxModule],
declarations: [ declarations: [
LoginComponentV1, LoginComponentV1,
LoginViaAuthRequestComponent, LoginViaAuthRequestComponentV1,
LoginDecryptionOptionsComponent, LoginDecryptionOptionsComponent,
LoginViaWebAuthnComponent, LoginViaWebAuthnComponent,
], ],
exports: [ exports: [
LoginComponentV1, LoginComponentV1,
LoginViaAuthRequestComponent, LoginViaAuthRequestComponentV1,
LoginDecryptionOptionsComponent, LoginDecryptionOptionsComponent,
LoginViaWebAuthnComponent, LoginViaWebAuthnComponent,
], ],

View File

@ -27,6 +27,8 @@ import {
LockV2Component, LockV2Component,
LockIcon, LockIcon,
UserLockIcon, UserLockIcon,
LoginViaAuthRequestComponent,
DevicesIcon,
RegistrationUserAddIcon, RegistrationUserAddIcon,
RegistrationLockAltIcon, RegistrationLockAltIcon,
RegistrationExpiredLinkIcon, RegistrationExpiredLinkIcon,
@ -46,7 +48,7 @@ import { HintComponent } from "./auth/hint.component";
import { LockComponent } from "./auth/lock.component"; import { LockComponent } from "./auth/lock.component";
import { LoginDecryptionOptionsComponent } from "./auth/login/login-decryption-options/login-decryption-options.component"; import { LoginDecryptionOptionsComponent } from "./auth/login/login-decryption-options/login-decryption-options.component";
import { LoginComponentV1 } from "./auth/login/login-v1.component"; import { LoginComponentV1 } from "./auth/login/login-v1.component";
import { LoginViaAuthRequestComponent } from "./auth/login/login-via-auth-request.component"; import { LoginViaAuthRequestComponentV1 } from "./auth/login/login-via-auth-request-v1.component";
import { LoginViaWebAuthnComponent } from "./auth/login/login-via-webauthn/login-via-webauthn.component"; import { LoginViaWebAuthnComponent } from "./auth/login/login-via-webauthn/login-via-webauthn.component";
import { AcceptOrganizationComponent } from "./auth/organization-invite/accept-organization.component"; import { AcceptOrganizationComponent } from "./auth/organization-invite/accept-organization.component";
import { RecoverDeleteComponent } from "./auth/recover-delete.component"; import { RecoverDeleteComponent } from "./auth/recover-delete.component";
@ -96,21 +98,11 @@ const routes: Routes = [
children: [], // Children lets us have an empty component. children: [], // Children lets us have an empty component.
canActivate: [redirectGuard()], // Redirects either to vault, login, or lock page. canActivate: [redirectGuard()], // Redirects either to vault, login, or lock page.
}, },
{
path: "login-with-device",
component: LoginViaAuthRequestComponent,
data: { titleId: "loginWithDevice" } satisfies RouteDataProperties,
},
{ {
path: "login-with-passkey", path: "login-with-passkey",
component: LoginViaWebAuthnComponent, component: LoginViaWebAuthnComponent,
data: { titleId: "logInWithPasskey" } satisfies RouteDataProperties, data: { titleId: "logInWithPasskey" } satisfies RouteDataProperties,
}, },
{
path: "admin-approval-requested",
component: LoginViaAuthRequestComponent,
data: { titleId: "adminApprovalRequested" } satisfies RouteDataProperties,
},
{ {
path: "login-initiated", path: "login-initiated",
component: LoginDecryptionOptionsComponent, component: LoginDecryptionOptionsComponent,
@ -179,6 +171,57 @@ const routes: Routes = [
}, },
], ],
}, },
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
AnonLayoutWrapperComponent,
{
path: "login-with-device",
data: { titleId: "loginWithDevice" } satisfies RouteDataProperties,
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "loginInitiated",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
titleId: "loginInitiated",
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
AnonLayoutWrapperComponent,
{
path: "admin-approval-requested",
data: { titleId: "adminApprovalRequested" } satisfies RouteDataProperties,
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
titleId: "adminApprovalRequested",
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
),
...unauthUiRefreshSwap( ...unauthUiRefreshSwap(
AnonLayoutWrapperComponent, AnonLayoutWrapperComponent,
AnonLayoutWrapperComponent, AnonLayoutWrapperComponent,

View File

@ -984,6 +984,9 @@
"loginWithDeviceEnabledNote": { "loginWithDeviceEnabledNote": {
"message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?" "message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?"
}, },
"needAnotherOptionV1": {
"message": "Need another option?"
},
"loginWithMasterPassword": { "loginWithMasterPassword": {
"message": "Log in with master password" "message": "Log in with master password"
}, },
@ -1302,6 +1305,12 @@
"notificationSentDevice": { "notificationSentDevice": {
"message": "A notification has been sent to your device." "message": "A notification has been sent to your device."
}, },
"aNotificationWasSentToYourDevice": {
"message": "A notification was sent to your device"
},
"makeSureYourAccountIsUnlockedAndTheFingerprintEtc": {
"message": "Make sure your account is unlocked and the fingerprint phrase matches on the other device"
},
"versionNumber": { "versionNumber": {
"message": "Version $VERSION_NUMBER$", "message": "Version $VERSION_NUMBER$",
"placeholders": { "placeholders": {
@ -3387,6 +3396,9 @@
} }
} }
}, },
"viewAllLogInOptions": {
"message": "View all log in options"
},
"viewAllLoginOptions": { "viewAllLoginOptions": {
"message": "View all log in options" "message": "View all log in options"
}, },
@ -4443,6 +4455,9 @@
"message": "Never prompt to verify fingerprint phrases for invited users (not recommended)", "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." "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."
}, },
"youWillBeNotifiedOnceTheRequestIsApproved": {
"message": "You will be notified once the request is approved"
},
"free": { "free": {
"message": "Free", "message": "Free",
"description": "Free, as in 'Free beer'" "description": "Free, as in 'Free beer'"

View File

@ -18,7 +18,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable"; import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { CreateAuthRequest } from "@bitwarden/common/auth/models/request/create-auth.request"; import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { HttpStatusCode } from "@bitwarden/common/enums/http-status-code.enum"; import { HttpStatusCode } from "@bitwarden/common/enums/http-status-code.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
@ -43,7 +43,7 @@ enum State {
} }
@Directive() @Directive()
export class LoginViaAuthRequestComponent export class LoginViaAuthRequestComponentV1
extends CaptchaProtectedComponent extends CaptchaProtectedComponent
implements OnInit, OnDestroy implements OnInit, OnDestroy
{ {
@ -51,7 +51,7 @@ export class LoginViaAuthRequestComponent
userAuthNStatus: AuthenticationStatus; userAuthNStatus: AuthenticationStatus;
email: string; email: string;
showResendNotification = false; showResendNotification = false;
authRequest: CreateAuthRequest; authRequest: AuthRequest;
fingerprintPhrase: string; fingerprintPhrase: string;
onSuccessfulLoginTwoFactorNavigate: () => Promise<any>; onSuccessfulLoginTwoFactorNavigate: () => Promise<any>;
onSuccessfulLogin: () => Promise<any>; onSuccessfulLogin: () => Promise<any>;
@ -265,7 +265,7 @@ export class LoginViaAuthRequestComponent
this.authRequestKeyPair.publicKey, this.authRequestKeyPair.publicKey,
); );
this.authRequest = new CreateAuthRequest( this.authRequest = new AuthRequest(
this.email, this.email,
deviceIdentifier, deviceIdentifier,
publicKey, publicKey,

View File

@ -31,6 +31,8 @@ import {
UserDecryptionOptionsServiceAbstraction, UserDecryptionOptionsServiceAbstraction,
LogoutReason, LogoutReason,
RegisterRouteService, RegisterRouteService,
AuthRequestApiService,
DefaultAuthRequestApiService,
} from "@bitwarden/auth/common"; } from "@bitwarden/auth/common";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
@ -1377,6 +1379,11 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultCipherAuthorizationService, useClass: DefaultCipherAuthorizationService,
deps: [CollectionService, OrganizationServiceAbstraction], deps: [CollectionService, OrganizationServiceAbstraction],
}), }),
safeProvider({
provide: AuthRequestApiService,
useClass: DefaultAuthRequestApiService,
deps: [ApiServiceAbstraction, LogService],
}),
]; ];
@NgModule({ @NgModule({

View File

@ -0,0 +1,52 @@
import { svgIcon } from "@bitwarden/components";
export const DevicesIcon = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 120 100">
<path
class="tw-fill-art-primary"
fill-rule="evenodd"
d="M41.212 87.309c0-.335.271-.606.606-.606H76.97a.606.606 0 0 1 0 1.212H41.818a.606.606 0 0 1-.606-.606Z"
clip-rule="evenodd"
/>
<path
class="tw-fill-art-primary"
fill-rule="evenodd"
d="M53.176 87.31V76.542h1.212V87.31h-1.212Zm12.103 0V76.542h1.212V87.31h-1.212Z"
clip-rule="evenodd"
/>
<path
class="tw-fill-art-primary"
fill-rule="evenodd"
d="M16.363 29.733a8.485 8.485 0 0 1 8.485-8.485h70.303a8.485 8.485 0 0 1 8.485 8.485v3.637h-2.424v-3.637a6.06 6.06 0 0 0-6.06-6.06H24.847a6.06 6.06 0 0 0-6.06 6.06v9.697h-2.425v-9.697Zm9.091 44.849H76.97v2.424H25.454v-2.424Z"
clip-rule="evenodd"
/>
<path
class="tw-fill-art-accent"
fill-rule="evenodd"
d="M21.212 30.34c0-2.344 1.9-4.243 4.242-4.243h69.091c2.343 0 4.243 1.9 4.243 4.242v3.03h-1.212v-3.03a3.03 3.03 0 0 0-3.03-3.03H25.453a3.03 3.03 0 0 0-3.03 3.03v9.091h-1.212v-9.09Zm4.242 40.605H76.97v1.212H25.454v-1.212Z"
clip-rule="evenodd"
/>
<path
class="tw-fill-art-primary"
fill-rule="evenodd"
d="M75.758 38.218a6.06 6.06 0 0 1 6.06-6.06h32.122a6.06 6.06 0 0 1 6.06 6.06v48.485a6.06 6.06 0 0 1-6.06 6.06H81.818a6.06 6.06 0 0 1-6.06-6.06V38.218Zm6.06-3.636a3.636 3.636 0 0 0-3.636 3.636v48.485a3.636 3.636 0 0 0 3.636 3.636h32.122a3.636 3.636 0 0 0 3.636-3.636V38.218a3.636 3.636 0 0 0-3.636-3.636H81.818Z"
clip-rule="evenodd"
/>
<path
class="tw-fill-art-accent"
d="M99.394 87.31a1.212 1.212 0 1 1-2.424 0 1.212 1.212 0 0 1 2.424 0Z"
/>
<path
class="tw-fill-art-primary"
fill-rule="evenodd"
d="M20.606 40.642H6.061a3.636 3.636 0 0 0-3.637 3.636V80.64a3.636 3.636 0 0 0 3.637 3.637h14.545a3.636 3.636 0 0 0 3.636-3.637V44.278a3.636 3.636 0 0 0-3.636-3.636ZM6.061 38.217A6.06 6.06 0 0 0 0 44.277v36.364a6.06 6.06 0 0 0 6.06 6.061h14.546a6.06 6.06 0 0 0 6.06-6.06V44.277a6.06 6.06 0 0 0-6.06-6.06H6.061Z"
clip-rule="evenodd"
/>
<path
class="tw-fill-art-accent"
fill-rule="evenodd"
d="M12.345 43.556c0-.334.272-.606.606-.606h.753a.606.606 0 1 1 0 1.212h-.753a.606.606 0 0 1-.606-.606Z"
clip-rule="evenodd"
/>
</svg>
`;

View File

@ -1,5 +1,6 @@
export * from "./bitwarden-logo.icon"; export * from "./bitwarden-logo.icon";
export * from "./bitwarden-shield.icon"; export * from "./bitwarden-shield.icon";
export * from "./devices.icon";
export * from "./lock.icon"; export * from "./lock.icon";
export * from "./registration-check-email.icon"; export * from "./registration-check-email.icon";
export * from "./user-lock.icon"; export * from "./user-lock.icon";

View File

@ -24,6 +24,9 @@ export * from "./login/login-secondary-content.component";
export * from "./login/login-component.service"; export * from "./login/login-component.service";
export * from "./login/default-login-component.service"; export * from "./login/default-login-component.service";
// login via auth request
export * from "./login-via-auth-request/login-via-auth-request.component";
// password callout // password callout
export * from "./password-callout/password-callout.component"; export * from "./password-callout/password-callout.component";

View File

@ -0,0 +1,41 @@
<div class="tw-text-center">
<ng-container *ngIf="flow === Flow.StandardAuthRequest">
<p>{{ "makeSureYourAccountIsUnlockedAndTheFingerprintEtc" | i18n }}</p>
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
<button
*ngIf="showResendNotification"
type="button"
bitButton
block
buttonType="secondary"
class="tw-mt-4"
(click)="startStandardAuthRequestLogin()"
>
{{ "resendNotification" | i18n }}
</button>
<div *ngIf="clientType !== ClientType.Browser" class="tw-mt-4">
<span>{{ "needAnotherOptionV1" | i18n }}</span>
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
"viewAllLogInOptions" | i18n
}}</a>
</div>
</ng-container>
<ng-container *ngIf="flow === Flow.AdminAuthRequest">
<p>{{ "youWillBeNotifiedOnceTheRequestIsApproved" | i18n }}</p>
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
<div class="tw-mt-4">
<span>{{ "troubleLoggingIn" | i18n }}</span>
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
"viewAllLogInOptions" | i18n
}}</a>
</div>
</ng-container>
</div>

View File

@ -0,0 +1,569 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { IsActiveMatchOptions, Router, RouterModule } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
AuthRequestLoginCredentials,
AuthRequestServiceAbstraction,
LoginEmailServiceAbstraction,
LoginStrategyServiceAbstraction,
} from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { AuthRequestType } from "@bitwarden/common/auth/enums/auth-request-type";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ButtonModule, LinkModule, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { AuthRequestApiService } from "../../common/abstractions/auth-request-api.service";
enum Flow {
StandardAuthRequest, // when user clicks "Login with device" from /login or "Approve from your other device" from /login-initiated
AdminAuthRequest, // when user clicks "Request admin approval" from /login-initiated
}
const matchOptions: IsActiveMatchOptions = {
paths: "exact",
queryParams: "ignored",
fragment: "ignored",
matrixParams: "ignored",
};
@Component({
standalone: true,
templateUrl: "./login-via-auth-request.component.html",
imports: [ButtonModule, CommonModule, JslibModule, LinkModule, RouterModule],
})
export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
private authRequest: AuthRequest;
private authRequestKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array };
private authStatus: AuthenticationStatus;
private showResendNotificationTimeoutSeconds = 12;
protected backToRoute = "/login";
protected clientType: ClientType;
protected ClientType = ClientType;
protected email: string;
protected fingerprintPhrase: string;
protected showResendNotification = false;
protected Flow = Flow;
protected flow = Flow.StandardAuthRequest;
constructor(
private accountService: AccountService,
private anonymousHubService: AnonymousHubService,
private appIdService: AppIdService,
private authRequestApiService: AuthRequestApiService,
private authRequestService: AuthRequestServiceAbstraction,
private authService: AuthService,
private cryptoFunctionService: CryptoFunctionService,
private deviceTrustService: DeviceTrustServiceAbstraction,
private i18nService: I18nService,
private logService: LogService,
private loginEmailService: LoginEmailServiceAbstraction,
private loginStrategyService: LoginStrategyServiceAbstraction,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
private router: Router,
private syncService: SyncService,
private toastService: ToastService,
private validationService: ValidationService,
) {
this.clientType = this.platformUtilsService.getClientType();
// Gets SignalR push notification
// Only fires on approval to prevent enumeration
this.authRequestService.authRequestPushNotification$
.pipe(takeUntilDestroyed())
.subscribe((requestId) => {
this.verifyAndHandleApprovedAuthReq(requestId).catch((e: Error) => {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("error"),
message: e.message,
});
this.logService.error("Failed to use approved auth request: " + e.message);
});
});
}
async ngOnInit(): Promise<void> {
// Get the authStatus early because we use it in both flows
this.authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
if (userHasAuthenticatedViaSSO) {
this.backToRoute = "/login-initiated";
}
/**
* The LoginViaAuthRequestComponent handles both the `login-with-device` and
* the `admin-approval-requested` routes. Therefore we check the route to determine
* which flow to initialize.
*/
if (this.router.isActive("admin-approval-requested", matchOptions)) {
await this.initAdminAuthRequestFlow();
} else {
await this.initStandardAuthRequestFlow();
}
}
private async initAdminAuthRequestFlow(): Promise<void> {
this.flow = Flow.AdminAuthRequest;
// Get email from state for admin auth requests because it is available and also
// prevents it from being lost on refresh as the loginEmailService email does not persist.
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
if (!this.email) {
await this.handleMissingEmail();
return;
}
// We only allow a single admin approval request to be active at a time
// so we must check state to see if we have an existing one or not
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
const existingAdminAuthRequest = await this.authRequestService.getAdminAuthRequest(userId);
if (existingAdminAuthRequest) {
await this.handleExistingAdminAuthRequest(existingAdminAuthRequest, userId);
} else {
await this.startAdminAuthRequestLogin();
}
}
private async initStandardAuthRequestFlow(): Promise<void> {
this.flow = Flow.StandardAuthRequest;
this.email = await firstValueFrom(this.loginEmailService.loginEmail$);
if (!this.email) {
await this.handleMissingEmail();
return;
}
await this.startStandardAuthRequestLogin();
}
private async handleMissingEmail(): Promise<void> {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("userEmailMissing"),
});
await this.router.navigate([this.backToRoute]);
}
async ngOnDestroy(): Promise<void> {
await this.anonymousHubService.stopHubConnection();
}
private async startAdminAuthRequestLogin(): Promise<void> {
try {
await this.buildAuthRequest(AuthRequestType.AdminApproval);
const authRequestResponse = await this.authRequestApiService.postAdminAuthRequest(
this.authRequest,
);
const adminAuthReqStorable = new AdminAuthRequestStorable({
id: authRequestResponse.id,
privateKey: this.authRequestKeyPair.privateKey,
});
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
await this.authRequestService.setAdminAuthRequest(adminAuthReqStorable, userId);
if (authRequestResponse.id) {
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
}
} catch (e) {
this.logService.error(e);
}
}
protected async startStandardAuthRequestLogin(): Promise<void> {
this.showResendNotification = false;
try {
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
const authRequestResponse = await this.authRequestApiService.postAuthRequest(
this.authRequest,
);
if (authRequestResponse.id) {
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
}
} catch (e) {
this.logService.error(e);
}
setTimeout(() => {
this.showResendNotification = true;
}, this.showResendNotificationTimeoutSeconds * 1000);
}
private async buildAuthRequest(authRequestType: AuthRequestType): Promise<void> {
const authRequestKeyPairArray = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
this.authRequestKeyPair = {
publicKey: authRequestKeyPairArray[0],
privateKey: authRequestKeyPairArray[1],
};
const deviceIdentifier = await this.appIdService.getAppId();
const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
const accessCode = await this.passwordGenerationService.generatePassword({
type: "password",
length: 25,
});
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
this.authRequestKeyPair.publicKey,
);
this.authRequest = new AuthRequest(
this.email,
deviceIdentifier,
publicKey,
authRequestType,
accessCode,
);
}
private async handleExistingAdminAuthRequest(
adminAuthRequestStorable: AdminAuthRequestStorable,
userId: UserId,
): Promise<void> {
// Note: on login, the SSOLoginStrategy will also call to see if an existing admin auth req
// has been approved and handle it if so.
// Regardless, we always retrieve the auth request from the server and verify and handle status changes here as well
let adminAuthRequestResponse: AuthRequestResponse;
try {
adminAuthRequestResponse = await this.authRequestApiService.getAuthRequest(
adminAuthRequestStorable.id,
);
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
}
}
// Request doesn't exist anymore
if (!adminAuthRequestResponse) {
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
}
// Re-derive the user's fingerprint phrase
// It is important to not use the server's public key here as it could have been compromised via MITM
const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
adminAuthRequestStorable.privateKey,
);
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
derivedPublicKeyArrayBuffer,
);
// Request denied
if (adminAuthRequestResponse.isAnswered && !adminAuthRequestResponse.requestApproved) {
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
}
// Request approved
if (adminAuthRequestResponse.requestApproved) {
return await this.decryptViaApprovedAuthRequest(
adminAuthRequestResponse,
adminAuthRequestStorable.privateKey,
userId,
);
}
// Request still pending response from admin
// set keypair and create hub connection so that any approvals will be received via push notification
this.authRequestKeyPair = { privateKey: adminAuthRequestStorable.privateKey, publicKey: null };
await this.anonymousHubService.createHubConnection(adminAuthRequestStorable.id);
}
private async verifyAndHandleApprovedAuthReq(requestId: string): Promise<void> {
/**
* ***********************************
* Standard Auth Request Flows
* ***********************************
*
* Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory.
*
* Unauthed user clicks "Login with device" > navigates to /login-with-device which creates a StandardAuthRequest
* > receives approval from a device with authRequestPublicKey(masterKey) > decrypts masterKey > decrypts userKey > proceed to vault
*
* Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory.
*
* Unauthed user clicks "Login with device" > navigates to /login-with-device which creates a StandardAuthRequest
* > receives approval from a device with authRequestPublicKey(userKey) > decrypts userKey > proceeds to vault
*
* Note: this flow is an uncommon scenario and relates to TDE off-boarding. The following describes how a user could get into this flow:
* 1) An SSO TD user logs into a device via an Admin auth request approval, therefore this device does NOT have a masterKey in memory.
* 2) The org admin...
* (2a) Changes the member decryption options from "Trusted devices" to "Master password" AND
* (2b) Turns off the "Require single sign-on authentication" policy
* 3) On another device, the user clicks "Login with device", which they can do because the org no longer requires SSO.
* 4) The user approves from the device they had previously logged into with SSO TD, which does NOT have a masterKey in memory (see step 1 above).
*
* Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory.
*
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Approve from your other device"
* > navigates to /login-with-device which creates a StandardAuthRequest > receives approval from device with authRequestPublicKey(masterKey)
* > decrypts masterKey > decrypts userKey > establishes trust (if required) > proceeds to vault
*
* Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory.
*
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Approve from your other device"
* > navigates to /login-with-device which creates a StandardAuthRequest > receives approval from device with authRequestPublicKey(userKey)
* > decrypts userKey > establishes trust (if required) > proceeds to vault
*
* ***********************************
* Admin Auth Request Flow
* ***********************************
*
* Flow: Authed SSO TD user requests admin approval.
*
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Request admin approval"
* > navigates to /admin-approval-requested which creates an AdminAuthRequest > receives approval from device with authRequestPublicKey(userKey)
* > decrypts userKey > establishes trust (if required) > proceeds to vault
*
* Note: TDE users are required to be enrolled in admin password reset, which gives the admin access to the user's userKey.
* This is how admins are able to send over the authRequestPublicKey(userKey) to the user to allow them to unlock.
*
*
* Summary Table
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
* | Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory (see note 1) |
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
* | Standard Flow 1 | unauthed | "Login with device" [/login] | /login-with-device | yes |
* | Standard Flow 2 | unauthed | "Login with device" [/login] | /login-with-device | no |
* | Standard Flow 3 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | yes |
* | Standard Flow 4 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | no | |
* | Admin Flow | authed | "Request admin approval" [/login-initiated] | /admin-approval-requested | NA - admin requests always send encrypted userKey |
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
* * Note 1: The phrase "in memory" here is important. It is possible for a user to have a master password for their account, but not have a masterKey IN MEMORY for
* a specific device. For example, if a user registers an account with a master password, then joins an SSO TD org, then logs in to a device via SSO and
* admin auth request, they are now logged into that device but that device does not have masterKey IN MEMORY.
*/
try {
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
if (userHasAuthenticatedViaSSO) {
// Get the auth request from the server
// User is authenticated, therefore the endpoint does not require an access code.
const authRequestResponse = await this.authRequestApiService.getAuthRequest(requestId);
if (authRequestResponse.requestApproved) {
// Handles Standard Flows 3-4 and Admin Flow
await this.handleAuthenticatedFlows(authRequestResponse);
}
} else {
// Get the auth request from the server
// User is unauthenticated, therefore the endpoint requires an access code for user verification.
const authRequestResponse = await this.authRequestApiService.getAuthResponse(
requestId,
this.authRequest.accessCode,
);
if (authRequestResponse.requestApproved) {
// Handles Standard Flows 1-2
await this.handleUnauthenticatedFlows(authRequestResponse, requestId);
}
}
} catch (error) {
if (error instanceof ErrorResponse) {
await this.router.navigate([this.backToRoute]);
this.validationService.showError(error);
return;
}
this.logService.error(error);
}
}
private async handleAuthenticatedFlows(authRequestResponse: AuthRequestResponse) {
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
await this.decryptViaApprovedAuthRequest(
authRequestResponse,
this.authRequestKeyPair.privateKey,
userId,
);
}
private async handleUnauthenticatedFlows(
authRequestResponse: AuthRequestResponse,
requestId: string,
) {
const authRequestLoginCredentials = await this.buildAuthRequestLoginCredentials(
requestId,
authRequestResponse,
);
// Note: keys are set by AuthRequestLoginStrategy success handling
const authResult = await this.loginStrategyService.logIn(authRequestLoginCredentials);
await this.handlePostLoginNavigation(authResult);
}
private async decryptViaApprovedAuthRequest(
authRequestResponse: AuthRequestResponse,
privateKey: ArrayBuffer,
userId: UserId,
): Promise<void> {
/**
* See verifyAndHandleApprovedAuthReq() for flow details.
*
* We determine the type of `key` based on the presence or absence of `masterPasswordHash`:
* - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)]
* - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
*/
if (authRequestResponse.masterPasswordHash) {
// ...in Standard Auth Request Flow 3
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
authRequestResponse,
privateKey,
userId,
);
} else {
// ...in Standard Auth Request Flow 4 or Admin Auth Request Flow
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
authRequestResponse,
privateKey,
userId,
);
}
// clear the admin auth request from state so it cannot be used again (it's a one time use)
// TODO: this should eventually be enforced via deleting this on the server once it is used
await this.authRequestService.clearAdminAuthRequest(userId);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("loginApproved"),
});
// Now that we have a decrypted user key in memory, we can check if we
// need to establish trust on the current device
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
await this.deviceTrustService.trustDeviceIfRequired(activeAccount.id);
await this.handleSuccessfulLoginNavigation();
}
/**
* Takes an `AuthRequestResponse` and decrypts the `key` to build an `AuthRequestLoginCredentials`
* object for use in the `AuthRequestLoginStrategy`.
*
* The credentials object that gets built is affected by whether the `authRequestResponse.key`
* is an encrypted MasterKey or an encrypted UserKey.
*/
private async buildAuthRequestLoginCredentials(
requestId: string,
authRequestResponse: AuthRequestResponse,
): Promise<AuthRequestLoginCredentials> {
/**
* See verifyAndHandleApprovedAuthReq() for flow details.
*
* We determine the type of `key` based on the presence or absence of `masterPasswordHash`:
* - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)]
* - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
*/
if (authRequestResponse.masterPasswordHash) {
// ...in Standard Auth Request Flow 1
const { masterKey, masterKeyHash } =
await this.authRequestService.decryptPubKeyEncryptedMasterKeyAndHash(
authRequestResponse.key,
authRequestResponse.masterPasswordHash,
this.authRequestKeyPair.privateKey,
);
return new AuthRequestLoginCredentials(
this.email,
this.authRequest.accessCode,
requestId,
null, // no userKey
masterKey,
masterKeyHash,
);
} else {
// ...in Standard Auth Request Flow 2
const userKey = await this.authRequestService.decryptPubKeyEncryptedUserKey(
authRequestResponse.key,
this.authRequestKeyPair.privateKey,
);
return new AuthRequestLoginCredentials(
this.email,
this.authRequest.accessCode,
requestId,
userKey,
null, // no masterKey
null, // no masterKeyHash
);
}
}
private async handleExistingAdminAuthReqDeletedOrDenied(userId: UserId) {
// clear the admin auth request from state
await this.authRequestService.clearAdminAuthRequest(userId);
// start new auth request
await this.startAdminAuthRequestLogin();
}
private async handlePostLoginNavigation(loginResponse: AuthResult) {
if (loginResponse.requiresTwoFactor) {
await this.router.navigate(["2fa"]);
} else if (loginResponse.forcePasswordReset != ForceSetPasswordReason.None) {
await this.router.navigate(["update-temp-password"]);
} else {
await this.handleSuccessfulLoginNavigation();
}
}
private async handleSuccessfulLoginNavigation() {
if (this.flow === Flow.StandardAuthRequest) {
// Only need to set remembered email on standard login with auth req flow
await this.loginEmailService.saveEmailSettings();
}
await this.syncService.fullSync(true);
await this.router.navigate(["vault"]);
}
}

View File

@ -0,0 +1,37 @@
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
export abstract class AuthRequestApiService {
/**
* Gets an auth request by its ID.
*
* @param requestId The ID of the auth request.
* @returns A promise that resolves to the auth request response.
*/
abstract getAuthRequest: (requestId: string) => Promise<AuthRequestResponse>;
/**
* Gets an auth request response by its ID and access code.
*
* @param requestId The ID of the auth request.
* @param accessCode The access code of the auth request.
* @returns A promise that resolves to the auth request response.
*/
abstract getAuthResponse: (requestId: string, accessCode: string) => Promise<AuthRequestResponse>;
/**
* Sends an admin auth request.
*
* @param request The auth request object.
* @returns A promise that resolves to the auth request response.
*/
abstract postAdminAuthRequest: (request: AuthRequest) => Promise<AuthRequestResponse>;
/**
* Sends an auth request.
*
* @param request The auth request object.
* @returns A promise that resolves to the auth request response.
*/
abstract postAuthRequest: (request: AuthRequest) => Promise<AuthRequestResponse>;
}

View File

@ -1,3 +1,4 @@
export * from "./auth-request-api.service";
export * from "./pin.service.abstraction"; export * from "./pin.service.abstraction";
export * from "./login-email.service"; export * from "./login-email.service";
export * from "./login-strategy.service"; export * from "./login-strategy.service";

View File

@ -0,0 +1,65 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { AuthRequestApiService } from "../../abstractions/auth-request-api.service";
export class DefaultAuthRequestApiService implements AuthRequestApiService {
constructor(
private apiService: ApiService,
private logService: LogService,
) {}
async getAuthRequest(requestId: string): Promise<AuthRequestResponse> {
try {
const path = `/auth-requests/${requestId}`;
const response = await this.apiService.send("GET", path, null, true, true);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
async getAuthResponse(requestId: string, accessCode: string): Promise<AuthRequestResponse> {
try {
const path = `/auth-requests/${requestId}/response?code=${accessCode}`;
const response = await this.apiService.send("GET", path, null, false, true);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
async postAdminAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
try {
const response = await this.apiService.send(
"POST",
"/auth-requests/admin-request",
request,
true,
true,
);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
async postAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
try {
const response = await this.apiService.send("POST", "/auth-requests/", request, false, true);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
}

View File

@ -3,5 +3,6 @@ export * from "./login-email/login-email.service";
export * from "./login-strategies/login-strategy.service"; export * from "./login-strategies/login-strategy.service";
export * from "./user-decryption-options/user-decryption-options.service"; export * from "./user-decryption-options/user-decryption-options.service";
export * from "./auth-request/auth-request.service"; export * from "./auth-request/auth-request.service";
export * from "./auth-request/auth-request-api.service";
export * from "./register-route.service"; export * from "./register-route.service";
export * from "./accounts/lock.service"; export * from "./accounts/lock.service";

View File

@ -36,7 +36,7 @@ import {
ProviderUserUserDetailsResponse, ProviderUserUserDetailsResponse,
} from "../admin-console/models/response/provider/provider-user.response"; } from "../admin-console/models/response/provider/provider-user.response";
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response"; import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
import { CreateAuthRequest } from "../auth/models/request/create-auth.request"; import { AuthRequest } from "../auth/models/request/auth.request";
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request"; import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request"; import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
import { EmailTokenRequest } from "../auth/models/request/email-token.request"; import { EmailTokenRequest } from "../auth/models/request/email-token.request";
@ -186,8 +186,8 @@ export abstract class ApiService {
putUpdateTdeOffboardingPassword: (request: UpdateTdeOffboardingPasswordRequest) => Promise<any>; putUpdateTdeOffboardingPassword: (request: UpdateTdeOffboardingPasswordRequest) => Promise<any>;
postConvertToKeyConnector: () => Promise<void>; postConvertToKeyConnector: () => Promise<void>;
//passwordless //passwordless
postAuthRequest: (request: CreateAuthRequest) => Promise<AuthRequestResponse>; postAuthRequest: (request: AuthRequest) => Promise<AuthRequestResponse>;
postAdminAuthRequest: (request: CreateAuthRequest) => Promise<AuthRequestResponse>; postAdminAuthRequest: (request: AuthRequest) => Promise<AuthRequestResponse>;
getAuthResponse: (id: string, accessCode: string) => Promise<AuthRequestResponse>; getAuthResponse: (id: string, accessCode: string) => Promise<AuthRequestResponse>;
getAuthRequest: (id: string) => Promise<AuthRequestResponse>; getAuthRequest: (id: string) => Promise<AuthRequestResponse>;
putAuthRequest: (id: string, request: PasswordlessAuthRequest) => Promise<AuthRequestResponse>; putAuthRequest: (id: string, request: PasswordlessAuthRequest) => Promise<AuthRequestResponse>;

View File

@ -1,6 +1,6 @@
import { AuthRequestType } from "../../enums/auth-request-type"; import { AuthRequestType } from "../../enums/auth-request-type";
export class CreateAuthRequest { export class AuthRequest {
constructor( constructor(
readonly email: string, readonly email: string,
readonly deviceIdentifier: string, readonly deviceIdentifier: string,

View File

@ -8,8 +8,8 @@ export class AuthRequestResponse extends BaseResponse {
publicKey: string; publicKey: string;
requestDeviceType: DeviceType; requestDeviceType: DeviceType;
requestIpAddress: string; requestIpAddress: string;
key: string; key: string; // could be either an encrypted MasterKey or an encrypted UserKey
masterPasswordHash: string; masterPasswordHash: string; // if hash is present, the `key` above is an encrypted MasterKey (else `key` is an encrypted UserKey)
creationDate: string; creationDate: string;
requestApproved?: boolean; requestApproved?: boolean;
responseDate?: string; responseDate?: string;

View File

@ -42,7 +42,7 @@ import {
} from "../admin-console/models/response/provider/provider-user.response"; } from "../admin-console/models/response/provider/provider-user.response";
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response"; import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
import { TokenService } from "../auth/abstractions/token.service"; import { TokenService } from "../auth/abstractions/token.service";
import { CreateAuthRequest } from "../auth/models/request/create-auth.request"; import { AuthRequest } from "../auth/models/request/auth.request";
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request"; import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request"; import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
import { EmailTokenRequest } from "../auth/models/request/email-token.request"; import { EmailTokenRequest } from "../auth/models/request/email-token.request";
@ -260,11 +260,12 @@ export class ApiService implements ApiServiceAbstraction {
} }
// TODO: PM-3519: Create and move to AuthRequest Api service // TODO: PM-3519: Create and move to AuthRequest Api service
async postAuthRequest(request: CreateAuthRequest): Promise<AuthRequestResponse> { // TODO: PM-9724: Remove legacy auth request methods when we remove legacy LoginViaAuthRequestV1Components
async postAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
const r = await this.send("POST", "/auth-requests/", request, false, true); const r = await this.send("POST", "/auth-requests/", request, false, true);
return new AuthRequestResponse(r); return new AuthRequestResponse(r);
} }
async postAdminAuthRequest(request: CreateAuthRequest): Promise<AuthRequestResponse> { async postAdminAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
const r = await this.send("POST", "/auth-requests/admin-request", request, true, true); const r = await this.send("POST", "/auth-requests/admin-request", request, true, true);
return new AuthRequestResponse(r); return new AuthRequestResponse(r);
} }