-
Notifications
You must be signed in to change notification settings - Fork 1.5k
[PM-27225] Fix nothing showing when biometrics unavailable #17209
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
39666ad
5c000d7
a85d804
8e79ec2
89f2d51
8aa5b7f
d28f549
d8d7a0c
00b1bc1
d1b11ed
8667729
3617369
5107570
6b6b851
fc79ba6
f5a53b3
214b4e3
dc6548e
a1ddda7
5e29c93
a6b2435
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; | |
| import { LogoutService } from "@bitwarden/auth/common"; | ||
| import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; | ||
| import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; | ||
| import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; | ||
| import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; | ||
| import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; | ||
| import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; | ||
| import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; | ||
|
|
@@ -56,6 +56,7 @@ import { | |
| import { | ||
| LockComponentService, | ||
| UnlockOption, | ||
| UnlockOptionValue, | ||
| UnlockOptions, | ||
| } from "../services/lock-component.service"; | ||
|
|
||
|
|
@@ -805,4 +806,250 @@ describe("LockComponent", () => { | |
| expect(mockRouter.navigate).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("setDefaultActiveUnlockOption", () => { | ||
| it.each([ | ||
| [ | ||
| "biometrics enabled", | ||
| { | ||
| biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, | ||
| pin: { enabled: false }, | ||
| masterPassword: { enabled: false }, | ||
| } as UnlockOptions, | ||
| UnlockOption.Biometrics, | ||
| ], | ||
| [ | ||
| "biometrics disabled, pin enabled", | ||
| { | ||
| biometrics: { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledLocally }, | ||
| pin: { enabled: true }, | ||
| masterPassword: { enabled: false }, | ||
| } as UnlockOptions, | ||
| UnlockOption.Pin, | ||
| ], | ||
| [ | ||
| "biometrics and pin disabled, masterPassword enabled", | ||
| { | ||
| biometrics: { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledLocally }, | ||
| pin: { enabled: false }, | ||
| masterPassword: { enabled: true }, | ||
| } as UnlockOptions, | ||
| UnlockOption.MasterPassword, | ||
| ], | ||
| [ | ||
| "hardware unavailable, no other options", | ||
| { | ||
| biometrics: { enabled: false, biometricsStatus: BiometricsStatus.HardwareUnavailable }, | ||
| pin: { enabled: false }, | ||
| masterPassword: { enabled: false }, | ||
| } as UnlockOptions, | ||
| UnlockOption.Biometrics, | ||
| ], | ||
| [ | ||
| "desktop disconnected, no other options", | ||
| { | ||
| biometrics: { enabled: false, biometricsStatus: BiometricsStatus.DesktopDisconnected }, | ||
| pin: { enabled: false }, | ||
| masterPassword: { enabled: false }, | ||
| } as UnlockOptions, | ||
| UnlockOption.Biometrics, | ||
| ], | ||
| [ | ||
| "not enabled in connected desktop app, no other options", | ||
| { | ||
| biometrics: { | ||
| enabled: false, | ||
| biometricsStatus: BiometricsStatus.NotEnabledInConnectedDesktopApp, | ||
| }, | ||
| pin: { enabled: false }, | ||
| masterPassword: { enabled: false }, | ||
| } as UnlockOptions, | ||
| UnlockOption.Biometrics, | ||
| ], | ||
| [ | ||
| "biometrics over pin priority", | ||
| { | ||
| biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, | ||
| pin: { enabled: true }, | ||
| masterPassword: { enabled: false }, | ||
| } as UnlockOptions, | ||
| UnlockOption.Biometrics, | ||
| ], | ||
| [ | ||
| "biometrics over masterPassword priority", | ||
| { | ||
| biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, | ||
| pin: { enabled: false }, | ||
| masterPassword: { enabled: true }, | ||
| } as UnlockOptions, | ||
| UnlockOption.Biometrics, | ||
| ], | ||
| [ | ||
| "pin over masterPassword priority", | ||
| { | ||
| biometrics: { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledLocally }, | ||
| pin: { enabled: true }, | ||
| masterPassword: { enabled: true }, | ||
| } as UnlockOptions, | ||
| UnlockOption.Pin, | ||
| ], | ||
| [ | ||
| "all options enabled", | ||
| { | ||
| biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, | ||
| pin: { enabled: true }, | ||
| masterPassword: { enabled: true }, | ||
| } as UnlockOptions, | ||
| UnlockOption.Biometrics, | ||
| ], | ||
| ])( | ||
| "should set active unlock option to $1 when %s", | ||
| async ( | ||
| description: string, | ||
| unlockOptions: UnlockOptions, | ||
| expectedOption: UnlockOptionValue, | ||
| ) => { | ||
| await component["setDefaultActiveUnlockOption"](unlockOptions); | ||
|
|
||
| expect(component.activeUnlockOption).toBe(expectedOption); | ||
| }, | ||
| ); | ||
| }); | ||
|
|
||
| describe("handleActiveAccountChange", () => { | ||
| const mockActiveAccount: Account = { | ||
| id: userId, | ||
| email: "[email protected]", | ||
| name: "Test User", | ||
| } as Account; | ||
|
|
||
| beforeEach(async () => { | ||
| component.activeAccount = mockActiveAccount; | ||
| }); | ||
|
|
||
| it("should return early when account already has user key", async () => { | ||
| mockKeyService.hasUserKey.mockResolvedValue(true); | ||
|
|
||
| await component["handleActiveAccountChange"](mockActiveAccount); | ||
|
|
||
| expect(mockKeyService.hasUserKey).toHaveBeenCalledWith(userId); | ||
| expect(mockAnonLayoutWrapperDataService.setAnonLayoutWrapperData).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("should set email as page subtitle when account is unlocked", async () => { | ||
| mockKeyService.hasUserKey.mockResolvedValue(false); | ||
| mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue( | ||
| of({ | ||
| biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, | ||
| pin: { enabled: false }, | ||
| masterPassword: { enabled: false }, | ||
| } as UnlockOptions), | ||
| ); | ||
| mockBiometricService.getBiometricsStatusForUser.mockResolvedValue(BiometricsStatus.Available); | ||
|
|
||
| await component["handleActiveAccountChange"](mockActiveAccount); | ||
|
|
||
| expect(mockAnonLayoutWrapperDataService.setAnonLayoutWrapperData).toHaveBeenCalledWith({ | ||
| pageSubtitle: mockActiveAccount.email, | ||
| }); | ||
| }); | ||
|
|
||
| it("should logout user when no unlock options are available", async () => { | ||
| mockKeyService.hasUserKey.mockResolvedValue(false); | ||
| mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue( | ||
| of({ | ||
| biometrics: { enabled: false, biometricsStatus: BiometricsStatus.UnlockNeeded }, | ||
| pin: { enabled: false }, | ||
| masterPassword: { enabled: false }, | ||
| } as UnlockOptions), | ||
| ); | ||
| mockBiometricService.getBiometricsStatusForUser.mockResolvedValue( | ||
| BiometricsStatus.UnlockNeeded, | ||
| ); | ||
|
|
||
| await component["handleActiveAccountChange"](mockActiveAccount); | ||
|
|
||
| expect(mockLogService.warning).toHaveBeenCalledWith( | ||
| "[LockComponent] User cannot unlock again. Logging out!", | ||
| ); | ||
| expect(mockLogoutService.logout).toHaveBeenCalledWith(userId); | ||
| }); | ||
|
|
||
| it("should not logout when master password is enabled", async () => { | ||
| mockKeyService.hasUserKey.mockResolvedValue(false); | ||
| mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue( | ||
| of({ | ||
| biometrics: { enabled: false, biometricsStatus: BiometricsStatus.UnlockNeeded }, | ||
| pin: { enabled: false }, | ||
| masterPassword: { enabled: true }, | ||
| } as UnlockOptions), | ||
| ); | ||
| mockBiometricService.getBiometricsStatusForUser.mockResolvedValue( | ||
| BiometricsStatus.UnlockNeeded, | ||
| ); | ||
|
|
||
| await component["handleActiveAccountChange"](mockActiveAccount); | ||
|
|
||
| expect(mockLogoutService.logout).not.toHaveBeenCalled(); | ||
| expect(component.activeUnlockOption).toBe(UnlockOption.MasterPassword); | ||
| }); | ||
|
|
||
| it("should not logout when pin is enabled", async () => { | ||
| mockKeyService.hasUserKey.mockResolvedValue(false); | ||
| mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue( | ||
| of({ | ||
| biometrics: { enabled: false, biometricsStatus: BiometricsStatus.UnlockNeeded }, | ||
| pin: { enabled: true }, | ||
| masterPassword: { enabled: false }, | ||
| } as UnlockOptions), | ||
| ); | ||
| mockBiometricService.getBiometricsStatusForUser.mockResolvedValue( | ||
| BiometricsStatus.UnlockNeeded, | ||
| ); | ||
|
|
||
| await component["handleActiveAccountChange"](mockActiveAccount); | ||
|
|
||
| expect(mockLogoutService.logout).not.toHaveBeenCalled(); | ||
| expect(component.activeUnlockOption).toBe(UnlockOption.Pin); | ||
| }); | ||
|
|
||
| it("should not logout when biometrics is available", async () => { | ||
| mockKeyService.hasUserKey.mockResolvedValue(false); | ||
| mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue( | ||
| of({ | ||
| biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, | ||
| pin: { enabled: false }, | ||
| masterPassword: { enabled: false }, | ||
| } as UnlockOptions), | ||
| ); | ||
| mockBiometricService.getBiometricsStatusForUser.mockResolvedValue(BiometricsStatus.Available); | ||
|
|
||
| await component["handleActiveAccountChange"](mockActiveAccount); | ||
|
|
||
| expect(mockLogoutService.logout).not.toHaveBeenCalled(); | ||
| expect(component.activeUnlockOption).toBe(UnlockOption.Biometrics); | ||
| }); | ||
|
|
||
| it("should not logout when biometrics is temporarily unavailable but no other options", async () => { | ||
| mockKeyService.hasUserKey.mockResolvedValue(false); | ||
| mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue( | ||
| of({ | ||
| biometrics: { | ||
| enabled: false, | ||
| biometricsStatus: BiometricsStatus.HardwareUnavailable, | ||
| }, | ||
| pin: { enabled: false }, | ||
| masterPassword: { enabled: false }, | ||
| } as UnlockOptions), | ||
| ); | ||
| mockBiometricService.getBiometricsStatusForUser.mockResolvedValue( | ||
| BiometricsStatus.HardwareUnavailable, | ||
| ); | ||
|
|
||
| await component["handleActiveAccountChange"](mockActiveAccount); | ||
|
|
||
| expect(mockLogoutService.logout).not.toHaveBeenCalled(); | ||
| expect(component.activeUnlockOption).toBe(UnlockOption.Biometrics); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,7 @@ import { SyncService } from "@bitwarden/common/platform/sync"; | |
| import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; | ||
| import { UserKey } from "@bitwarden/common/types/key"; | ||
| import { | ||
| TooltipDirective, | ||
| AsyncActionsModule, | ||
| AnonLayoutWrapperDataService, | ||
| ButtonModule, | ||
|
|
@@ -86,6 +87,12 @@ type AfterUnlockActions = { | |
| /// Fixes safari autoprompt behavior | ||
| const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000; | ||
|
|
||
| const BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES = [ | ||
| BiometricsStatus.HardwareUnavailable, | ||
| BiometricsStatus.DesktopDisconnected, | ||
| BiometricsStatus.NotEnabledInConnectedDesktopApp, | ||
| ]; | ||
|
|
||
| // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush | ||
| // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection | ||
| @Component({ | ||
|
|
@@ -100,6 +107,7 @@ const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000; | |
| AsyncActionsModule, | ||
| IconButtonModule, | ||
| MasterPasswordLockComponent, | ||
| TooltipDirective, | ||
| ], | ||
| }) | ||
| export class LockComponent implements OnInit, OnDestroy { | ||
|
|
@@ -276,7 +284,22 @@ export class LockComponent implements OnInit, OnDestroy { | |
| this.lockComponentService.getAvailableUnlockOptions$(activeAccount.id), | ||
| ); | ||
|
|
||
| this.setDefaultActiveUnlockOption(this.unlockOptions); | ||
| const canUseBiometrics = [ | ||
| BiometricsStatus.Available, | ||
| ...BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES, | ||
| ].includes(await this.biometricService.getBiometricsStatusForUser(activeAccount.id)); | ||
|
Comment on lines
+287
to
+290
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
As mzieniukbw suggested, use the status from const canUseBiometrics = [
BiometricsStatus.Available,
...BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES,
].includes(this.unlockOptions.biometrics.biometricsStatus);This ensures the biometric status check uses the same data already fetched in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The biometric status is already available in Suggested change: const canUseBiometrics = [
BiometricsStatus.Available,
...BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES,
].includes(this.unlockOptions?.biometrics.biometricsStatus ?? BiometricsStatus.PlatformUnsupported);This was flagged in a previous review and appears unresolved. |
||
| if ( | ||
| !this.unlockOptions?.masterPassword.enabled && | ||
| !this.unlockOptions?.pin.enabled && | ||
| !canUseBiometrics | ||
| ) { | ||
| // User has no available unlock options, force logout. This happens for TDE users without a masterpassword, that don't have a persistent unlock method set. | ||
| this.logService.warning("[LockComponent] User cannot unlock again. Logging out!"); | ||
| await this.logoutService.logout(activeAccount.id); | ||
| return; | ||
| } | ||
|
|
||
| await this.setDefaultActiveUnlockOption(this.unlockOptions); | ||
|
|
||
| if (this.unlockOptions?.biometrics.enabled) { | ||
| await this.handleBiometricsUnlockEnabled(); | ||
|
|
@@ -299,14 +322,23 @@ export class LockComponent implements OnInit, OnDestroy { | |
| }); | ||
| } | ||
|
|
||
| private setDefaultActiveUnlockOption(unlockOptions: UnlockOptions | null) { | ||
| private async setDefaultActiveUnlockOption(unlockOptions: UnlockOptions | null) { | ||
| // Priorities should be Biometrics > Pin > Master Password for speed | ||
| if (unlockOptions?.biometrics.enabled) { | ||
| this.activeUnlockOption = UnlockOption.Biometrics; | ||
| } else if (unlockOptions?.pin.enabled) { | ||
| this.activeUnlockOption = UnlockOption.Pin; | ||
| } else if (unlockOptions?.masterPassword.enabled) { | ||
| this.activeUnlockOption = UnlockOption.MasterPassword; | ||
| } else if ( | ||
| unlockOptions != null && | ||
| BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES.includes( | ||
| unlockOptions.biometrics.biometricsStatus, | ||
| ) | ||
| ) { | ||
| // If biometrics is temporarily unavailable for masterpassword-less users, but they have biometrics configured, | ||
| // then show the biometrics screen so the user knows why they can't unlock, and to give them the option to log out. | ||
| this.activeUnlockOption = UnlockOption.Biometrics; | ||
mzieniukbw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.