diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 51fb3a0a770..9efe5d100ed 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -647,6 +647,12 @@ "verifyIdentity": { "message": "Verify identity" }, + "weDontRecognizeThisDevice": { + "message": "We don't recognize this device. Enter the code sent to your email to verify your identity." + }, + "continueLoggingIn": { + "message": "Continue logging in" + }, "yourVaultIsLocked": { "message": "Your vault is locked. Verify your identity to continue." }, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index d55bebfa0c3..5fb346f9200 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -12,6 +12,7 @@ import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui import { authGuard, lockGuard, + newDeviceVerificationGuard, redirectGuard, tdeDecryptionRequiredGuard, unauthGuardFn, @@ -40,6 +41,8 @@ import { DevicesIcon, SsoComponent, TwoFactorTimeoutIcon, + NewDeviceVerificationComponent, + DeviceVerificationIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { LockComponent } from "@bitwarden/key-management/angular"; @@ -232,6 +235,22 @@ const routes: Routes = [ ], }, ), + { + path: "device-verification", + component: ExtensionAnonLayoutWrapperComponent, + canActivate: [newDeviceVerificationGuard()], + children: [{ path: "", component: NewDeviceVerificationComponent }], + data: { + pageIcon: DeviceVerificationIcon, + pageTitle: { + key: "verifyIdentity", + }, + pageSubtitle: { + key: "weDontRecognizeThisDevice", + }, + elevation: 1, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + }, { path: "set-password", component: SetPasswordComponent, diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index e565681de93..0bf05b4cdd6 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -10,6 +10,7 @@ import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui import { authGuard, lockGuard, + newDeviceVerificationGuard, redirectGuard, tdeDecryptionRequiredGuard, unauthGuardFn, @@ -38,6 +39,8 @@ import { DevicesIcon, SsoComponent, TwoFactorTimeoutIcon, + NewDeviceVerificationComponent, + DeviceVerificationIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { LockComponent } from "@bitwarden/key-management/angular"; @@ -114,6 +117,21 @@ const routes: Routes = [ }, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, + { + path: "device-verification", + component: AnonLayoutWrapperComponent, + canActivate: [newDeviceVerificationGuard()], + children: [{ path: "", component: NewDeviceVerificationComponent }], + data: { + pageIcon: DeviceVerificationIcon, + pageTitle: { + key: "verifyIdentity", + }, + pageSubtitle: { + key: "weDontRecognizeThisDevice", + }, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + }, { path: "register", component: RegisterComponent }, { path: "new-device-notice", diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index bca12f16a7d..a637218d841 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -885,6 +885,15 @@ "message": "Verify with Duo Security for your organization using the Duo Mobile app, SMS, phone call, or U2F security key.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, + "verifyIdentity": { + "message": "Verify your Identity" + }, + "weDontRecognizeThisDevice": { + "message": "We don't recognize this device. Enter the code sent to your email to verify your identity." + }, + "continueLoggingIn": { + "message": "Continue logging in" + }, "webAuthnTitle": { "message": "FIDO2 WebAuthn" }, diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index fadcc28f832..2f44d83deab 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -9,6 +9,7 @@ import { redirectGuard, tdeDecryptionRequiredGuard, unauthGuardFn, + newDeviceVerificationGuard, } from "@bitwarden/angular/auth/guards"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { generatorSwap } from "@bitwarden/angular/tools/generator/generator-swap"; @@ -37,6 +38,8 @@ import { SsoComponent, VaultIcon, LoginDecryptionOptionsComponent, + NewDeviceVerificationComponent, + DeviceVerificationIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { LockComponent } from "@bitwarden/key-management/angular"; @@ -586,6 +589,25 @@ const routes: Routes = [ titleId: "recoverAccountTwoStep", } satisfies RouteDataProperties & AnonLayoutWrapperData, }, + { + path: "device-verification", + canActivate: [newDeviceVerificationGuard()], + children: [ + { + path: "", + component: NewDeviceVerificationComponent, + }, + ], + data: { + pageIcon: DeviceVerificationIcon, + pageTitle: { + key: "verifyIdentity", + }, + pageSubtitle: { + key: "weDontRecognizeThisDevice", + }, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + }, { path: "accept-emergency", canActivate: [deepLinkGuard()], diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index eacba623ecd..6051ab8407c 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1182,6 +1182,12 @@ "verifyIdentity": { "message": "Verify your Identity" }, + "weDontRecognizeThisDevice": { + "message": "We don't recognize this device. Enter the code sent to your email to verify your identity." + }, + "continueLoggingIn": { + "message": "Continue logging in" + }, "whatIsADevice": { "message": "What is a device?" }, diff --git a/libs/angular/src/auth/guards/index.ts b/libs/angular/src/auth/guards/index.ts index 1760a870b3a..c1dccf7af30 100644 --- a/libs/angular/src/auth/guards/index.ts +++ b/libs/angular/src/auth/guards/index.ts @@ -3,3 +3,4 @@ export * from "./lock.guard"; export * from "./redirect.guard"; export * from "./tde-decryption-required.guard"; export * from "./unauth.guard"; +export * from "./new-device-verification.guard"; diff --git a/libs/angular/src/auth/guards/new-device-verification.guard.spec.ts b/libs/angular/src/auth/guards/new-device-verification.guard.spec.ts new file mode 100644 index 00000000000..af2c801ce7a --- /dev/null +++ b/libs/angular/src/auth/guards/new-device-verification.guard.spec.ts @@ -0,0 +1,66 @@ +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { MockProxy, mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec"; +import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common"; +import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +import { newDeviceVerificationGuard } from "./new-device-verification.guard"; + +describe("NewDeviceVerificationGuard", () => { + const setup = (authType: AuthenticationType | null) => { + const loginStrategyService: MockProxy = + mock(); + const currentAuthTypeSubject = new BehaviorSubject(authType); + loginStrategyService.currentAuthType$ = currentAuthTypeSubject; + + const logService: MockProxy = mock(); + + const testBed = TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([ + { path: "", component: EmptyComponent }, + { + path: "device-verification", + component: EmptyComponent, + canActivate: [newDeviceVerificationGuard()], + }, + { path: "login", component: EmptyComponent }, + ]), + ], + providers: [ + { provide: LoginStrategyServiceAbstraction, useValue: loginStrategyService }, + { provide: LogService, useValue: logService }, + ], + }); + + return { + router: testBed.inject(Router), + logService, + }; + }; + + it("creates the guard", () => { + const { router } = setup(AuthenticationType.Password); + expect(router).toBeTruthy(); + }); + + it("allows access with an active login session", async () => { + const { router } = setup(AuthenticationType.Password); + + await router.navigate(["device-verification"]); + expect(router.url).toBe("/device-verification"); + }); + + it("redirects to login with no active session", async () => { + const { router, logService } = setup(null); + + await router.navigate(["device-verification"]); + expect(router.url).toBe("/login"); + expect(logService.error).toHaveBeenCalledWith("No active login session found."); + }); +}); diff --git a/libs/angular/src/auth/guards/new-device-verification.guard.ts b/libs/angular/src/auth/guards/new-device-verification.guard.ts new file mode 100644 index 00000000000..8a6c7d414dd --- /dev/null +++ b/libs/angular/src/auth/guards/new-device-verification.guard.ts @@ -0,0 +1,28 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +/** + * Guard that ensures there is an active login session before allowing access + * to the new device verification route. + * If not, redirects to login. + */ +export function newDeviceVerificationGuard(): CanActivateFn { + return async () => { + const loginStrategyService = inject(LoginStrategyServiceAbstraction); + const logService = inject(LogService); + const router = inject(Router); + + // Check if we have a valid login session + const authType = await firstValueFrom(loginStrategyService.currentAuthType$); + if (authType === null) { + logService.error("No active login session found."); + return router.createUrlTree(["/login"]); + } + + return true; + }; +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 803808612cf..a31091c2c07 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -40,6 +40,8 @@ import { DefaultAuthRequestApiService, DefaultLoginSuccessHandlerService, LoginSuccessHandlerService, + PasswordLoginStrategy, + PasswordLoginStrategyData, LoginApprovalComponentServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; @@ -1441,6 +1443,37 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultLoginSuccessHandlerService, deps: [SyncService, UserAsymmetricKeysRegenerationService], }), + safeProvider({ + provide: PasswordLoginStrategy, + useClass: PasswordLoginStrategy, + deps: [ + PasswordLoginStrategyData, + PasswordStrengthServiceAbstraction, + PolicyServiceAbstraction, + LoginStrategyServiceAbstraction, + AccountServiceAbstraction, + InternalMasterPasswordServiceAbstraction, + KeyService, + EncryptService, + ApiServiceAbstraction, + TokenServiceAbstraction, + AppIdServiceAbstraction, + PlatformUtilsServiceAbstraction, + MessagingServiceAbstraction, + LogService, + StateServiceAbstraction, + TwoFactorServiceAbstraction, + InternalUserDecryptionOptionsServiceAbstraction, + BillingAccountProfileStateService, + VaultTimeoutSettingsServiceAbstraction, + KdfConfigService, + ], + }), + safeProvider({ + provide: PasswordLoginStrategyData, + useClass: PasswordLoginStrategyData, + deps: [], + }), ]; @NgModule({ diff --git a/libs/auth/src/angular/icons/device-verification.icon.ts b/libs/auth/src/angular/icons/device-verification.icon.ts new file mode 100644 index 00000000000..b1be4efdfb3 --- /dev/null +++ b/libs/auth/src/angular/icons/device-verification.icon.ts @@ -0,0 +1,18 @@ +import { svgIcon } from "@bitwarden/components"; + +export const DeviceVerificationIcon = svgIcon` + + + + + + + + + + + + + + +`; diff --git a/libs/auth/src/angular/icons/index.ts b/libs/auth/src/angular/icons/index.ts index 0e86ee7fc8e..0ec92d54547 100644 --- a/libs/auth/src/angular/icons/index.ts +++ b/libs/auth/src/angular/icons/index.ts @@ -12,3 +12,4 @@ export * from "./registration-lock-alt.icon"; export * from "./registration-expired-link.icon"; export * from "./sso-key.icon"; export * from "./two-factor-timeout.icon"; +export * from "./device-verification.icon"; diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 66111f3e5af..67ab68852b2 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -71,3 +71,6 @@ export * from "./self-hosted-env-config-dialog/self-hosted-env-config-dialog.com // login approval export * from "./login-approval/login-approval.component"; export * from "./login-approval/default-login-approval-component.service"; + +// device verification +export * from "./new-device-verification/new-device-verification.component"; diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index f9aaa5d1e05..c3961eb3b91 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -283,6 +283,12 @@ export class LoginComponent implements OnInit, OnDestroy { return; } + // Redirect to device verification if this is an unknown device + if (authResult.requiresDeviceVerification) { + await this.router.navigate(["device-verification"]); + return; + } + // If none of the above cases are true, proceed with login... await this.evaluatePassword(); diff --git a/libs/auth/src/angular/new-device-verification/new-device-verification.component.html b/libs/auth/src/angular/new-device-verification/new-device-verification.component.html new file mode 100644 index 00000000000..911c31eaf95 --- /dev/null +++ b/libs/auth/src/angular/new-device-verification/new-device-verification.component.html @@ -0,0 +1,35 @@ +
+ + {{ "verificationCode" | i18n }} + + + + + +
+ +
+
diff --git a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts new file mode 100644 index 00000000000..221bbe57a32 --- /dev/null +++ b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts @@ -0,0 +1,159 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { Router } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { + AsyncActionsModule, + ButtonModule, + FormFieldModule, + IconButtonModule, + LinkModule, + ToastService, +} from "@bitwarden/components"; + +import { LoginEmailServiceAbstraction } from "../../common/abstractions/login-email.service"; +import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login-strategy.service"; +import { PasswordLoginStrategy } from "../../common/login-strategies/password-login.strategy"; + +/** + * Component for verifying a new device via a one-time password (OTP). + */ +@Component({ + standalone: true, + selector: "app-new-device-verification", + templateUrl: "./new-device-verification.component.html", + imports: [ + CommonModule, + ReactiveFormsModule, + AsyncActionsModule, + JslibModule, + ButtonModule, + FormFieldModule, + IconButtonModule, + LinkModule, + ], +}) +export class NewDeviceVerificationComponent implements OnInit, OnDestroy { + formGroup = this.formBuilder.group({ + code: [ + "", + { + validators: [Validators.required], + updateOn: "change", + }, + ], + }); + + protected disableRequestOTP = false; + private destroy$ = new Subject(); + + constructor( + private router: Router, + private formBuilder: FormBuilder, + private passwordLoginStrategy: PasswordLoginStrategy, + private apiService: ApiService, + private loginStrategyService: LoginStrategyServiceAbstraction, + private logService: LogService, + private toastService: ToastService, + private i18nService: I18nService, + private syncService: SyncService, + private loginEmailService: LoginEmailServiceAbstraction, + ) {} + + async ngOnInit() { + // Redirect to login if session times out + this.passwordLoginStrategy.sessionTimeout$ + .pipe(takeUntil(this.destroy$)) + .subscribe((timedOut) => { + if (timedOut) { + this.logService.error("Session timed out."); + this.toastService.showToast({ + title: "", + message: this.i18nService.t("sessionTimeout"), + variant: "error", + }); + void this.router.navigate(["/login"]); + } + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Resends the OTP for device verification. + */ + async resendOTP() { + this.disableRequestOTP = true; + try { + const email = await this.loginStrategyService.getEmail(); + const masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash(); + + if (!email || !masterPasswordHash) { + throw new Error("Missing email or master password hash"); + } + + await this.apiService.send( + "POST", + "/accounts/resend-new-device-otp", + { + email: email, + masterPasswordHash: masterPasswordHash, + }, + false, + false, + ); + } catch (e) { + this.logService.error(e); + } finally { + this.disableRequestOTP = false; + } + } + + /** + * Submits the OTP for device verification. + */ + submit = async (): Promise => { + const codeControl = this.formGroup.get("code"); + if (!codeControl || !codeControl.value) { + return; + } + + try { + const authResult = await this.loginStrategyService.logInNewDeviceVerification( + codeControl.value, + ); + + if (authResult.requiresTwoFactor) { + await this.router.navigate(["/2fa"]); + return; + } + + if (authResult.forcePasswordReset) { + await this.router.navigate(["/update-temp-password"]); + return; + } + + this.loginEmailService.clearValues(); + + await this.syncService.fullSync(true); + + // If verification succeeds, navigate to vault + await this.router.navigate(["/vault"]); + } catch (e) { + this.logService.error(e); + const errorMessage = + (e as any)?.response?.error_description ?? this.i18nService.t("errorOccurred"); + codeControl.setErrors({ serverError: { message: errorMessage } }); + } + }; +} diff --git a/libs/auth/src/common/abstractions/login-strategy.service.ts b/libs/auth/src/common/abstractions/login-strategy.service.ts index 1088d6de736..7f9e276369d 100644 --- a/libs/auth/src/common/abstractions/login-strategy.service.ts +++ b/libs/auth/src/common/abstractions/login-strategy.service.ts @@ -47,7 +47,6 @@ export abstract class LoginStrategyServiceAbstraction { * Auth Request. Otherwise, it will return null. */ getAuthRequestId: () => Promise; - /** * Sends a token request to the server using the provided credentials. */ @@ -77,4 +76,8 @@ export abstract class LoginStrategyServiceAbstraction { * Emits true if the two factor session has expired. */ twoFactorTimeout$: Observable; + /** + * Sends a token request to the server with the provided device verification OTP. + */ + logInNewDeviceVerification: (deviceVerificationOtp: string) => Promise; } diff --git a/libs/auth/src/common/index.ts b/libs/auth/src/common/index.ts index 43efd7c6387..97909bdc449 100644 --- a/libs/auth/src/common/index.ts +++ b/libs/auth/src/common/index.ts @@ -6,3 +6,4 @@ export * from "./models"; export * from "./types"; export * from "./services"; export * from "./utilities"; +export * from "./login-strategies"; diff --git a/libs/auth/src/common/login-strategies/index.ts b/libs/auth/src/common/login-strategies/index.ts new file mode 100644 index 00000000000..166ef935e08 --- /dev/null +++ b/libs/auth/src/common/login-strategies/index.ts @@ -0,0 +1 @@ +export { PasswordLoginStrategy, PasswordLoginStrategyData } from "./password-login.strategy"; diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index 50443bab0ea..a8208a1e0ad 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -4,6 +4,7 @@ import { BehaviorSubject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; @@ -12,6 +13,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/identity-captcha.response"; +import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; @@ -76,8 +78,8 @@ const twoFactorToken = "TWO_FACTOR_TOKEN"; const twoFactorRemember = true; export function identityTokenResponseFactory( - masterPasswordPolicyResponse: MasterPasswordPolicyResponse = null, - userDecryptionOptions: IUserDecryptionOptionsServerResponse = null, + masterPasswordPolicyResponse: MasterPasswordPolicyResponse | undefined = undefined, + userDecryptionOptions: IUserDecryptionOptionsServerResponse | undefined = undefined, ) { return new IdentityTokenResponse({ ForcePasswordReset: false, @@ -155,7 +157,7 @@ describe("LoginStrategy", () => { passwordStrengthService, policyService, loginStrategyService, - accountService, + accountService as unknown as AccountService, masterPasswordService, keyService, encryptService, @@ -286,13 +288,16 @@ describe("LoginStrategy", () => { const result = await passwordLoginStrategy.logIn(credentials); - expect(result).toEqual({ - userId: userId, - forcePasswordReset: ForceSetPasswordReason.AdminForcePasswordReset, - resetMasterPassword: true, - twoFactorProviders: null, - captchaSiteKey: "", - } as AuthResult); + const expected = new AuthResult(); + expected.userId = userId; + expected.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset; + expected.resetMasterPassword = true; + expected.twoFactorProviders = {} as Partial< + Record> + >; + expected.captchaSiteKey = ""; + expected.twoFactorProviders = null; + expect(result).toEqual(expected); }); it("rejects login if CAPTCHA is required", async () => { @@ -377,10 +382,11 @@ describe("LoginStrategy", () => { expect(tokenService.clearTwoFactorToken).toHaveBeenCalled(); const expected = new AuthResult(); - expected.twoFactorProviders = { 0: null } as Record< - TwoFactorProviderType, - Record + expected.twoFactorProviders = { 0: null } as unknown as Partial< + Record> >; + expected.email = ""; + expected.ssoEmail2FaSessionToken = undefined; expect(result).toEqual(expected); }); @@ -460,14 +466,19 @@ describe("LoginStrategy", () => { it("sends 2FA token provided by user to server (two-step)", async () => { // Simulate a partially completed login cache = new PasswordLoginStrategyData(); - cache.tokenRequest = new PasswordTokenRequest(email, masterPasswordHash, null, null); + cache.tokenRequest = new PasswordTokenRequest( + email, + masterPasswordHash, + "", + new TokenTwoFactorRequest(), + ); passwordLoginStrategy = new PasswordLoginStrategy( cache, passwordStrengthService, policyService, loginStrategyService, - accountService, + accountService as AccountService, masterPasswordService, keyService, encryptService, @@ -489,7 +500,7 @@ describe("LoginStrategy", () => { await passwordLoginStrategy.logInTwoFactor( new TokenTwoFactorRequest(twoFactorProviderType, twoFactorToken, twoFactorRemember), - null, + "", ); expect(apiService.postIdentityToken).toHaveBeenCalledWith( @@ -503,4 +514,54 @@ describe("LoginStrategy", () => { ); }); }); + + describe("Device verification", () => { + it("processes device verification response", async () => { + const captchaToken = "test-captcha-token"; + const deviceVerificationResponse = new IdentityDeviceVerificationResponse({ + error: "invalid_grant", + error_description: "Device verification required.", + email: "test@bitwarden.com", + deviceVerificationRequest: true, + captchaToken: captchaToken, + }); + + apiService.postIdentityToken.mockResolvedValue(deviceVerificationResponse); + + cache = new PasswordLoginStrategyData(); + cache.tokenRequest = new PasswordTokenRequest( + email, + masterPasswordHash, + "", + new TokenTwoFactorRequest(), + ); + + passwordLoginStrategy = new PasswordLoginStrategy( + cache, + passwordStrengthService, + policyService, + loginStrategyService, + accountService as AccountService, + masterPasswordService, + keyService, + encryptService, + apiService, + tokenService, + appIdService, + platformUtilsService, + messagingService, + logService, + stateService, + twoFactorService, + userDecryptionOptionsService, + billingAccountProfileStateService, + vaultTimeoutSettingsService, + kdfConfigService, + ); + + const result = await passwordLoginStrategy.logIn(credentials); + + expect(result.requiresDeviceVerification).toBe(true); + }); + }); }); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 25f99f47840..6b1dcfb155c 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -1,6 +1,4 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { BehaviorSubject, filter, firstValueFrom, timeout } from "rxjs"; +import { BehaviorSubject, filter, firstValueFrom, timeout, Observable } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; @@ -18,6 +16,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request"; import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request"; import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/identity-captcha.response"; +import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; @@ -51,14 +50,19 @@ import { import { UserDecryptionOptions } from "../models/domain/user-decryption-options"; import { CacheData } from "../services/login-strategies/login-strategy.state"; -type IdentityResponse = IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse; +type IdentityResponse = + | IdentityTokenResponse + | IdentityTwoFactorResponse + | IdentityCaptchaResponse + | IdentityDeviceVerificationResponse; export abstract class LoginStrategyData { tokenRequest: | UserApiTokenRequest | PasswordTokenRequest | SsoTokenRequest - | WebAuthnLoginTokenRequest; + | WebAuthnLoginTokenRequest + | undefined; captchaBypassToken?: string; /** User's entered email obtained pre-login. */ @@ -67,6 +71,8 @@ export abstract class LoginStrategyData { export abstract class LoginStrategy { protected abstract cache: BehaviorSubject; + protected sessionTimeoutSubject = new BehaviorSubject(false); + sessionTimeout$: Observable = this.sessionTimeoutSubject.asObservable(); constructor( protected accountService: AccountService, @@ -100,9 +106,12 @@ export abstract class LoginStrategy { async logInTwoFactor( twoFactor: TokenTwoFactorRequest, - captchaResponse: string = null, + captchaResponse: string | null = null, ): Promise { const data = this.cache.value; + if (!data.tokenRequest) { + throw new Error("Token request is undefined"); + } data.tokenRequest.setTwoFactor(twoFactor); this.cache.next(data); const [authResult] = await this.startLogIn(); @@ -113,6 +122,9 @@ export abstract class LoginStrategy { await this.twoFactorService.clearSelectedProvider(); const tokenRequest = this.cache.value.tokenRequest; + if (!tokenRequest) { + throw new Error("Token request is undefined"); + } const response = await this.apiService.postIdentityToken(tokenRequest); if (response instanceof IdentityTwoFactorResponse) { @@ -121,6 +133,8 @@ export abstract class LoginStrategy { return [await this.processCaptchaResponse(response), response]; } else if (response instanceof IdentityTokenResponse) { return [await this.processTokenResponse(response), response]; + } else if (response instanceof IdentityDeviceVerificationResponse) { + return [await this.processDeviceVerificationResponse(response), response]; } throw new Error("Invalid response object."); @@ -176,8 +190,8 @@ export abstract class LoginStrategy { await this.accountService.addAccount(userId, { name: accountInformation.name, - email: accountInformation.email, - emailVerified: accountInformation.email_verified, + email: accountInformation.email ?? "", + emailVerified: accountInformation.email_verified ?? false, }); await this.accountService.switchAccount(userId); @@ -230,7 +244,7 @@ export abstract class LoginStrategy { ); await this.billingAccountProfileStateService.setHasPremium( - accountInformation.premium, + accountInformation.premium ?? false, false, userId, ); @@ -291,6 +305,9 @@ export abstract class LoginStrategy { try { const userKey = await this.keyService.getUserKeyWithLegacySupport(userId); const [publicKey, privateKey] = await this.keyService.makeKeyPair(userKey); + if (!privateKey.encryptedString) { + throw new Error("Failed to create encrypted private key"); + } await this.apiService.postAccountKeys(new KeysRequest(publicKey, privateKey.encryptedString)); return privateKey.encryptedString; } catch (e) { @@ -316,7 +333,8 @@ export abstract class LoginStrategy { await this.twoFactorService.setProviders(response); this.cache.next({ ...this.cache.value, captchaBypassToken: response.captchaToken ?? null }); result.ssoEmail2FaSessionToken = response.ssoEmail2faSessionToken; - result.email = response.email; + + result.email = response.email ?? ""; return result; } @@ -355,4 +373,22 @@ export abstract class LoginStrategy { ), ); } + + /** + * Handles the response from the server when a device verification is required. + * It sets the requiresDeviceVerification flag to true and caches the captcha token if it came back. + * + * @param {IdentityDeviceVerificationResponse} response - The response from the server indicating that device verification is required. + * @returns {Promise} - A promise that resolves to an AuthResult object + */ + protected async processDeviceVerificationResponse( + response: IdentityDeviceVerificationResponse, + ): Promise { + const result = new AuthResult(); + result.requiresDeviceVerification = true; + + // Extend cached data with captcha bypass token if it came back. + this.cache.next({ ...this.cache.value, captchaBypassToken: response.captchaToken ?? null }); + return result; + } } diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts index 4ee4fcaeb38..d572710a2fd 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts @@ -276,4 +276,24 @@ describe("PasswordLoginStrategy", () => { ); expect(secondResult.forcePasswordReset).toEqual(ForceSetPasswordReason.WeakMasterPassword); }); + + it("handles new device verification login with OTP", async () => { + const deviceVerificationOtp = "123456"; + const tokenResponse = identityTokenResponseFactory(); + apiService.postIdentityToken.mockResolvedValueOnce(tokenResponse); + tokenService.decodeAccessToken.mockResolvedValue({ sub: userId }); + + await passwordLoginStrategy.logIn(credentials); + + const result = await passwordLoginStrategy.logInNewDeviceVerification(deviceVerificationOtp); + + expect(apiService.postIdentityToken).toHaveBeenCalledWith( + expect.objectContaining({ + newDeviceOtp: deviceVerificationOtp, + }), + ); + expect(result.forcePasswordReset).toBe(ForceSetPasswordReason.None); + expect(result.resetMasterPassword).toBe(false); + expect(result.userId).toBe(userId); + }); }); diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index c496b7c9674..f0a8d40f914 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -10,6 +10,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/identity-captcha.response"; +import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { HashPurpose } from "@bitwarden/common/platform/enums"; @@ -208,9 +209,12 @@ export class PasswordLoginStrategy extends LoginStrategy { } private getMasterPasswordPolicyOptionsFromResponse( - response: IdentityTokenResponse | IdentityTwoFactorResponse, + response: + | IdentityTokenResponse + | IdentityTwoFactorResponse + | IdentityDeviceVerificationResponse, ): MasterPasswordPolicyOptions { - if (response == null) { + if (response == null || response instanceof IdentityDeviceVerificationResponse) { return null; } return MasterPasswordPolicyOptions.fromResponse(response.masterPasswordPolicy); @@ -233,4 +237,13 @@ export class PasswordLoginStrategy extends LoginStrategy { password: this.cache.value, }; } + + async logInNewDeviceVerification(deviceVerificationOtp: string): Promise { + const data = this.cache.value; + data.tokenRequest.newDeviceOtp = deviceVerificationOtp; + this.cache.next(data); + + const [authResult] = await this.startLogIn(); + return authResult; + } } diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts index 5fcbefbef2f..3b03e8754bc 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts @@ -321,4 +321,67 @@ describe("LoginStrategyService", () => { `PBKDF2 iterations must be at least ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN}, but was ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN - 1}; possible pre-login downgrade attack detected.`, ); }); + + it("returns an AuthResult on successful new device verification", async () => { + const credentials = new PasswordLoginCredentials("EMAIL", "MASTER_PASSWORD"); + const deviceVerificationOtp = "123456"; + + // Setup initial login and device verification response + apiService.postPrelogin.mockResolvedValue( + new PreloginResponse({ + Kdf: KdfType.Argon2id, + KdfIterations: 2, + KdfMemory: 16, + KdfParallelism: 1, + }), + ); + + apiService.postIdentityToken.mockResolvedValueOnce( + new IdentityTwoFactorResponse({ + TwoFactorProviders: ["0"], + TwoFactorProviders2: { 0: null }, + error: "invalid_grant", + error_description: "Two factor required.", + email: undefined, + ssoEmail2faSessionToken: undefined, + }), + ); + + await sut.logIn(credentials); + + // Successful device verification login + apiService.postIdentityToken.mockResolvedValueOnce( + new IdentityTokenResponse({ + ForcePasswordReset: false, + Kdf: KdfType.Argon2id, + KdfIterations: 2, + KdfMemory: 16, + KdfParallelism: 1, + Key: "KEY", + PrivateKey: "PRIVATE_KEY", + ResetMasterPassword: false, + access_token: "ACCESS_TOKEN", + expires_in: 3600, + refresh_token: "REFRESH_TOKEN", + scope: "api offline_access", + token_type: "Bearer", + }), + ); + + tokenService.decodeAccessToken.calledWith("ACCESS_TOKEN").mockResolvedValue({ + sub: "USER_ID", + name: "NAME", + email: "EMAIL", + premium: false, + }); + + const result = await sut.logInNewDeviceVerification(deviceVerificationOtp); + + expect(result).toBeInstanceOf(AuthResult); + expect(apiService.postIdentityToken).toHaveBeenCalledWith( + expect.objectContaining({ + newDeviceOtp: deviceVerificationOtp, + }), + ); + }); }); diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts index 57a653b205e..fbe6505f14e 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { combineLatestWith, distinctUntilChanged, @@ -15,6 +13,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; @@ -35,9 +34,6 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { TaskSchedulerService, ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; import { GlobalState, GlobalStateProvider } from "@bitwarden/common/platform/state"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { DeviceTrustServiceAbstraction } from "@bitwarden/common/src/auth/abstractions/device-trust.service.abstraction"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { MasterKey } from "@bitwarden/common/types/key"; import { @@ -53,7 +49,10 @@ import { AuthRequestServiceAbstraction, LoginStrategyServiceAbstraction } from " import { InternalUserDecryptionOptionsServiceAbstraction } from "../../abstractions/user-decryption-options.service.abstraction"; import { AuthRequestLoginStrategy } from "../../login-strategies/auth-request-login.strategy"; import { LoginStrategy } from "../../login-strategies/login.strategy"; -import { PasswordLoginStrategy } from "../../login-strategies/password-login.strategy"; +import { + PasswordLoginStrategy, + PasswordLoginStrategyData, +} from "../../login-strategies/password-login.strategy"; import { SsoLoginStrategy } from "../../login-strategies/sso-login.strategy"; import { UserApiLoginStrategy } from "../../login-strategies/user-api-login.strategy"; import { WebAuthnLoginStrategy } from "../../login-strategies/webauthn-login.strategy"; @@ -76,11 +75,11 @@ import { const sessionTimeoutLength = 5 * 60 * 1000; // 5 minutes export class LoginStrategyService implements LoginStrategyServiceAbstraction { - private sessionTimeoutSubscription: Subscription; + private sessionTimeoutSubscription: Subscription | undefined; private currentAuthnTypeState: GlobalState; private loginStrategyCacheState: GlobalState; private loginStrategyCacheExpirationState: GlobalState; - private authRequestPushNotificationState: GlobalState; + private authRequestPushNotificationState: GlobalState; private twoFactorTimeoutSubject = new BehaviorSubject(false); twoFactorTimeout$: Observable = this.twoFactorTimeoutSubject.asObservable(); @@ -153,7 +152,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { async getEmail(): Promise { const strategy = await firstValueFrom(this.loginStrategy$); - if ("email$" in strategy) { + if (strategy && "email$" in strategy) { return await firstValueFrom(strategy.email$); } return null; @@ -162,7 +161,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { async getMasterPasswordHash(): Promise { const strategy = await firstValueFrom(this.loginStrategy$); - if ("serverMasterKeyHash$" in strategy) { + if (strategy && "serverMasterKeyHash$" in strategy) { return await firstValueFrom(strategy.serverMasterKeyHash$); } return null; @@ -171,7 +170,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { async getSsoEmail2FaSessionToken(): Promise { const strategy = await firstValueFrom(this.loginStrategy$); - if ("ssoEmail2FaSessionToken$" in strategy) { + if (strategy && "ssoEmail2FaSessionToken$" in strategy) { return await firstValueFrom(strategy.ssoEmail2FaSessionToken$); } return null; @@ -180,7 +179,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { async getAccessCode(): Promise { const strategy = await firstValueFrom(this.loginStrategy$); - if ("accessCode$" in strategy) { + if (strategy && "accessCode$" in strategy) { return await firstValueFrom(strategy.accessCode$); } return null; @@ -189,7 +188,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { async getAuthRequestId(): Promise { const strategy = await firstValueFrom(this.loginStrategy$); - if ("authRequestId$" in strategy) { + if (strategy && "authRequestId$" in strategy) { return await firstValueFrom(strategy.authRequestId$); } return null; @@ -217,16 +216,19 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { // If the popup uses its own instance of this service, this can be removed. const ownedCredentials = { ...credentials }; - const result = await strategy.logIn(ownedCredentials as any); + const result = await strategy?.logIn(ownedCredentials as any); - if (result != null && !result.requiresTwoFactor) { + if (result != null && !result.requiresTwoFactor && !result.requiresDeviceVerification) { await this.clearCache(); } else { - // Cache the strategy data so we can attempt again later with 2fa. Cache supports different contexts - await this.loginStrategyCacheState.update((_) => strategy.exportCache()); + // Cache the strategy data so we can attempt again later with 2fa or device verification + await this.loginStrategyCacheState.update((_) => strategy?.exportCache() ?? null); await this.startSessionTimeout(); } + if (!result) { + throw new Error("No auth result returned"); + } return result; } @@ -260,9 +262,46 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { } } + /** + * Sends a token request to the server with the provided device verification OTP. + * Returns an error if no session data is found or if the current login strategy does not support device verification. + * @param deviceVerificationOtp The OTP to send to the server for device verification. + * @returns The result of the token request. + */ + async logInNewDeviceVerification(deviceVerificationOtp: string): Promise { + if (!(await this.isSessionValid())) { + throw new Error(this.i18nService.t("sessionTimeout")); + } + + const strategy = await firstValueFrom(this.loginStrategy$); + if (strategy == null) { + throw new Error("No login strategy found."); + } + + if (!("logInNewDeviceVerification" in strategy)) { + throw new Error("Current login strategy does not support device verification."); + } + + try { + const result = await strategy.logInNewDeviceVerification(deviceVerificationOtp); + + // Only clear cache if device verification succeeds + if (result !== null && !result.requiresDeviceVerification) { + await this.clearCache(); + } + return result; + } catch (e) { + // Clear the cache if there is an unhandled client-side error + if (!(e instanceof ErrorResponse)) { + await this.clearCache(); + } + throw e; + } + } + async makePreloginKey(masterPassword: string, email: string): Promise { email = email.trim().toLowerCase(); - let kdfConfig: KdfConfig = null; + let kdfConfig: KdfConfig | undefined; try { const preloginResponse = await this.apiService.postPrelogin(new PreloginRequest(email)); if (preloginResponse != null) { @@ -275,12 +314,15 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { preloginResponse.kdfParallelism, ); } - } catch (e) { + } catch (e: any) { if (e == null || e.statusCode !== 404) { throw e; } } + if (!kdfConfig) { + throw new Error("KDF config is required"); + } kdfConfig.validateKdfConfigForPrelogin(); return await this.keyService.makeMasterKey(masterPassword, email, kdfConfig); @@ -360,15 +402,18 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { switch (strategy) { case AuthenticationType.Password: return new PasswordLoginStrategy( - data?.password, + data?.password ?? new PasswordLoginStrategyData(), this.passwordStrengthService, this.policyService, this, ...sharedDeps, ); case AuthenticationType.Sso: + if (!data?.sso) { + throw new Error("Sso is required"); + } return new SsoLoginStrategy( - data?.sso, + data.sso, this.keyConnectorService, this.deviceTrustService, this.authRequestService, @@ -376,20 +421,29 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { ...sharedDeps, ); case AuthenticationType.UserApiKey: + if (!data?.userApiKey) { + throw new Error("User API key is required"); + } return new UserApiLoginStrategy( - data?.userApiKey, + data.userApiKey, this.environmentService, this.keyConnectorService, ...sharedDeps, ); case AuthenticationType.AuthRequest: + if (!data?.authRequest) { + throw new Error("Auth request is required"); + } return new AuthRequestLoginStrategy( - data?.authRequest, + data.authRequest, this.deviceTrustService, ...sharedDeps, ); case AuthenticationType.WebAuthn: - return new WebAuthnLoginStrategy(data?.webAuthn, ...sharedDeps); + if (!data?.webAuthn) { + throw new Error("WebAuthn is required"); + } + return new WebAuthnLoginStrategy(data.webAuthn, ...sharedDeps); } }), ); diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 997974e0581..87389fcf7f4 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -70,6 +70,7 @@ import { ApiKeyResponse } from "../auth/models/response/api-key.response"; import { AuthRequestResponse } from "../auth/models/response/auth-request.response"; import { DeviceVerificationResponse } from "../auth/models/response/device-verification.response"; import { IdentityCaptchaResponse } from "../auth/models/response/identity-captcha.response"; +import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response"; import { IdentityTokenResponse } from "../auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response"; @@ -152,7 +153,12 @@ export abstract class ApiService { | SsoTokenRequest | UserApiTokenRequest | WebAuthnLoginTokenRequest, - ) => Promise; + ) => Promise< + | IdentityTokenResponse + | IdentityTwoFactorResponse + | IdentityCaptchaResponse + | IdentityDeviceVerificationResponse + >; refreshIdentityToken: () => Promise; getProfile: () => Promise; diff --git a/libs/common/src/auth/models/domain/auth-result.ts b/libs/common/src/auth/models/domain/auth-result.ts index 1c176c2b84b..fdc8c963a1b 100644 --- a/libs/common/src/auth/models/domain/auth-result.ts +++ b/libs/common/src/auth/models/domain/auth-result.ts @@ -22,6 +22,7 @@ export class AuthResult { ssoEmail2FaSessionToken?: string; email: string; requiresEncryptionKeyMigration: boolean; + requiresDeviceVerification: boolean; get requiresCaptcha() { return !Utils.isNullOrWhitespace(this.captchaSiteKey); diff --git a/libs/common/src/auth/models/request/identity-token/password-token.request.ts b/libs/common/src/auth/models/request/identity-token/password-token.request.ts index 456e058a234..3fe466e143b 100644 --- a/libs/common/src/auth/models/request/identity-token/password-token.request.ts +++ b/libs/common/src/auth/models/request/identity-token/password-token.request.ts @@ -13,6 +13,7 @@ export class PasswordTokenRequest extends TokenRequest implements CaptchaProtect public captchaResponse: string, protected twoFactor: TokenTwoFactorRequest, device?: DeviceRequest, + public newDeviceOtp?: string, ) { super(twoFactor, device); } @@ -28,6 +29,10 @@ export class PasswordTokenRequest extends TokenRequest implements CaptchaProtect obj.captchaResponse = this.captchaResponse; } + if (this.newDeviceOtp) { + obj.newDeviceOtp = this.newDeviceOtp; + } + return obj; } diff --git a/libs/common/src/auth/models/response/identity-device-verification.response.ts b/libs/common/src/auth/models/response/identity-device-verification.response.ts new file mode 100644 index 00000000000..b45f47e99e1 --- /dev/null +++ b/libs/common/src/auth/models/response/identity-device-verification.response.ts @@ -0,0 +1,13 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class IdentityDeviceVerificationResponse extends BaseResponse { + deviceVerified: boolean; + captchaToken: string; + + constructor(response: any) { + super(response); + this.deviceVerified = this.getResponseProperty("DeviceVerified") ?? false; + + this.captchaToken = this.getResponseProperty("CaptchaBypassToken"); + } +} diff --git a/libs/common/src/auth/models/response/identity-response.ts b/libs/common/src/auth/models/response/identity-response.ts new file mode 100644 index 00000000000..26503a9cc2f --- /dev/null +++ b/libs/common/src/auth/models/response/identity-response.ts @@ -0,0 +1,8 @@ +import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response"; +import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; + +export type IdentityResponse = + | IdentityTokenResponse + | IdentityTwoFactorResponse + | IdentityDeviceVerificationResponse; diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index dc0a8d61f64..0b29a084756 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -78,6 +78,7 @@ import { ApiKeyResponse } from "../auth/models/response/api-key.response"; import { AuthRequestResponse } from "../auth/models/response/auth-request.response"; import { DeviceVerificationResponse } from "../auth/models/response/device-verification.response"; import { IdentityCaptchaResponse } from "../auth/models/response/identity-captcha.response"; +import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response"; import { IdentityTokenResponse } from "../auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response"; @@ -159,6 +160,12 @@ export class ApiService implements ApiServiceAbstraction { private isWebClient = false; private isDesktopClient = false; + /** + * The message (responseJson.ErrorModel.Message) that comes back from the server when a new device verification is required. + */ + private static readonly NEW_DEVICE_VERIFICATION_REQUIRED_MESSAGE = + "new device verification required"; + constructor( private tokenService: TokenService, private platformUtilsService: PlatformUtilsService, @@ -198,7 +205,12 @@ export class ApiService implements ApiServiceAbstraction { | PasswordTokenRequest | SsoTokenRequest | WebAuthnLoginTokenRequest, - ): Promise { + ): Promise< + | IdentityTokenResponse + | IdentityTwoFactorResponse + | IdentityCaptchaResponse + | IdentityDeviceVerificationResponse + > { const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", Accept: "application/json", @@ -246,6 +258,11 @@ export class ApiService implements ApiServiceAbstraction { Object.keys(responseJson.HCaptcha_SiteKey).length ) { return new IdentityCaptchaResponse(responseJson); + } else if ( + response.status === 400 && + responseJson?.ErrorModel?.Message === ApiService.NEW_DEVICE_VERIFICATION_REQUIRED_MESSAGE + ) { + return new IdentityDeviceVerificationResponse(responseJson); } }