mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 13:23:34 +00:00
feat(auth): [PM-8221] implement device verification for unknown devices
Add device verification flow that requires users to enter an OTP when logging in from an unrecognized device. This includes: - New device verification route and guard - Email OTP verification component - Authentication timeout handling PM-8221
This commit is contained in:
@@ -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."
|
||||
},
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { Injectable, NgModule } from "@angular/core";
|
||||
import { ActivatedRouteSnapshot, RouteReuseStrategy, RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component";
|
||||
import {
|
||||
EnvironmentSelectorComponent,
|
||||
EnvironmentSelectorRouteData,
|
||||
ExtensionDefaultOverlayPosition,
|
||||
} from "@bitwarden/angular/auth/components/environment-selector.component";
|
||||
import { TwoFactorTimeoutComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-expired.component";
|
||||
import { unauthUiRefreshRedirect } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-redirect";
|
||||
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
|
||||
import {
|
||||
authGuard,
|
||||
lockGuard,
|
||||
activeAuthGuard,
|
||||
redirectGuard,
|
||||
tdeDecryptionRequiredGuard,
|
||||
unauthGuardFn,
|
||||
} from "@bitwarden/angular/auth/guards";
|
||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||
import { twofactorRefactorSwap } from "@bitwarden/angular/utils/two-factor-component-refactor-route-swap";
|
||||
import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards";
|
||||
import {
|
||||
@@ -39,7 +41,10 @@ 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";
|
||||
import {
|
||||
NewDeviceVerificationNoticePageOneComponent,
|
||||
@@ -172,12 +177,12 @@ const routes: Routes = [
|
||||
component: ExtensionAnonLayoutWrapperComponent,
|
||||
children: [
|
||||
{
|
||||
path: "2fa-timeout",
|
||||
path: "authentication-timeout",
|
||||
canActivate: [unauthGuardFn(unauthRouteOverrides)],
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: TwoFactorTimeoutComponent,
|
||||
component: AuthenticationTimeoutComponent,
|
||||
},
|
||||
],
|
||||
data: {
|
||||
@@ -230,6 +235,27 @@ const routes: Routes = [
|
||||
],
|
||||
},
|
||||
),
|
||||
{
|
||||
path: "device-verification",
|
||||
component: ExtensionAnonLayoutWrapperComponent,
|
||||
canActivate: [
|
||||
canAccessFeature(FeatureFlag.NewDeviceVerification),
|
||||
unauthGuardFn(),
|
||||
activeAuthGuard(),
|
||||
],
|
||||
children: [{ path: "", component: NewDeviceVerificationComponent }],
|
||||
data: {
|
||||
pageIcon: DeviceVerificationIcon,
|
||||
pageTitle: {
|
||||
key: "verifyIdentity",
|
||||
},
|
||||
pageSubtitle: {
|
||||
key: "weDontRecognizeThisDevice",
|
||||
},
|
||||
showBackButton: true,
|
||||
elevation: 1,
|
||||
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
path: "set-password",
|
||||
component: SetPasswordComponent,
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component";
|
||||
import {
|
||||
DesktopDefaultOverlayPosition,
|
||||
EnvironmentSelectorComponent,
|
||||
} from "@bitwarden/angular/auth/components/environment-selector.component";
|
||||
import { TwoFactorTimeoutComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-expired.component";
|
||||
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
|
||||
import {
|
||||
authGuard,
|
||||
lockGuard,
|
||||
activeAuthGuard,
|
||||
redirectGuard,
|
||||
tdeDecryptionRequiredGuard,
|
||||
unauthGuardFn,
|
||||
} from "@bitwarden/angular/auth/guards";
|
||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||
import { twofactorRefactorSwap } from "@bitwarden/angular/utils/two-factor-component-refactor-route-swap";
|
||||
import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards";
|
||||
import {
|
||||
@@ -37,7 +39,10 @@ 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";
|
||||
import {
|
||||
NewDeviceVerificationNoticePageOneComponent,
|
||||
@@ -97,12 +102,12 @@ const routes: Routes = [
|
||||
},
|
||||
),
|
||||
{
|
||||
path: "2fa-timeout",
|
||||
path: "authentication-timeout",
|
||||
component: AnonLayoutWrapperComponent,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: TwoFactorTimeoutComponent,
|
||||
component: AuthenticationTimeoutComponent,
|
||||
},
|
||||
],
|
||||
data: {
|
||||
@@ -112,6 +117,25 @@ const routes: Routes = [
|
||||
},
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
path: "device-verification",
|
||||
component: AnonLayoutWrapperComponent,
|
||||
canActivate: [
|
||||
canAccessFeature(FeatureFlag.NewDeviceVerification),
|
||||
unauthGuardFn(),
|
||||
activeAuthGuard(),
|
||||
],
|
||||
children: [{ path: "", component: NewDeviceVerificationComponent }],
|
||||
data: {
|
||||
pageIcon: DeviceVerificationIcon,
|
||||
pageTitle: {
|
||||
key: "verifyIdentity",
|
||||
},
|
||||
pageSubtitle: {
|
||||
key: "weDontRecognizeThisDevice",
|
||||
},
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
{ path: "register", component: RegisterComponent },
|
||||
{
|
||||
path: "new-device-notice",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
import { Route, RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { TwoFactorTimeoutComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-expired.component";
|
||||
import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component";
|
||||
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
|
||||
import {
|
||||
authGuard,
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
redirectGuard,
|
||||
tdeDecryptionRequiredGuard,
|
||||
unauthGuardFn,
|
||||
activeAuthGuard,
|
||||
} from "@bitwarden/angular/auth/guards";
|
||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||
import { generatorSwap } from "@bitwarden/angular/tools/generator/generator-swap";
|
||||
import { twofactorRefactorSwap } from "@bitwarden/angular/utils/two-factor-component-refactor-route-swap";
|
||||
import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards";
|
||||
@@ -37,7 +39,10 @@ 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";
|
||||
import {
|
||||
NewDeviceVerificationNoticePageOneComponent,
|
||||
@@ -538,12 +543,12 @@ const routes: Routes = [
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
path: "2fa-timeout",
|
||||
path: "authentication-timeout",
|
||||
canActivate: [unauthGuardFn()],
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: TwoFactorTimeoutComponent,
|
||||
component: AuthenticationTimeoutComponent,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
@@ -580,6 +585,29 @@ const routes: Routes = [
|
||||
titleId: "recoverAccountTwoStep",
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
path: "device-verification",
|
||||
canActivate: [
|
||||
canAccessFeature(FeatureFlag.NewDeviceVerification),
|
||||
unauthGuardFn(),
|
||||
activeAuthGuard(),
|
||||
],
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: NewDeviceVerificationComponent,
|
||||
},
|
||||
],
|
||||
data: {
|
||||
pageIcon: DeviceVerificationIcon,
|
||||
pageTitle: {
|
||||
key: "verifyIdentity",
|
||||
},
|
||||
pageSubtitle: {
|
||||
key: "weDontRecognizeThisDevice",
|
||||
},
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
path: "accept-emergency",
|
||||
canActivate: [deepLinkGuard()],
|
||||
|
||||
@@ -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?"
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ButtonModule } from "@bitwarden/components";
|
||||
* It provides a button to navigate to the login page.
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-two-factor-expired",
|
||||
selector: "app-authentication-timeout",
|
||||
standalone: true,
|
||||
imports: [CommonModule, JslibModule, ButtonModule, RouterModule],
|
||||
template: `
|
||||
@@ -22,4 +22,4 @@ import { ButtonModule } from "@bitwarden/components";
|
||||
</a>
|
||||
`,
|
||||
})
|
||||
export class TwoFactorTimeoutComponent {}
|
||||
export class AuthenticationTimeoutComponent {}
|
||||
@@ -86,12 +86,12 @@ describe("TwoFactorComponent", () => {
|
||||
};
|
||||
|
||||
let selectedUserDecryptionOptions: BehaviorSubject<UserDecryptionOptions>;
|
||||
let twoFactorTimeoutSubject: BehaviorSubject<boolean>;
|
||||
let authenticationSessionTimeoutSubject: BehaviorSubject<boolean>;
|
||||
|
||||
beforeEach(() => {
|
||||
twoFactorTimeoutSubject = new BehaviorSubject<boolean>(false);
|
||||
authenticationSessionTimeoutSubject = new BehaviorSubject<boolean>(false);
|
||||
mockLoginStrategyService = mock<LoginStrategyServiceAbstraction>();
|
||||
mockLoginStrategyService.twoFactorTimeout$ = twoFactorTimeoutSubject;
|
||||
mockLoginStrategyService.authenticationSessionTimeout$ = authenticationSessionTimeoutSubject;
|
||||
mockRouter = mock<Router>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockApiService = mock<ApiService>();
|
||||
@@ -153,7 +153,9 @@ describe("TwoFactorComponent", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(null);
|
||||
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(
|
||||
mockUserDecryptionOpts.withMasterPassword,
|
||||
);
|
||||
mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -497,8 +499,8 @@ describe("TwoFactorComponent", () => {
|
||||
});
|
||||
|
||||
it("navigates to the timeout route when timeout expires", async () => {
|
||||
twoFactorTimeoutSubject.next(true);
|
||||
authenticationSessionTimeoutSubject.next(true);
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["2fa-timeout"]);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["authentication-timeout"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
|
||||
protected changePasswordRoute = "set-password";
|
||||
protected forcePasswordResetRoute = "update-temp-password";
|
||||
protected successRoute = "vault";
|
||||
protected twoFactorTimeoutRoute = "2fa-timeout";
|
||||
protected twoFactorTimeoutRoute = "authentication-timeout";
|
||||
|
||||
get isDuoProvider(): boolean {
|
||||
return (
|
||||
@@ -104,8 +104,8 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
|
||||
super(environmentService, i18nService, platformUtilsService, toastService);
|
||||
this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win);
|
||||
|
||||
// Add subscription to twoFactorTimeout$ and navigate to twoFactorTimeoutRoute if expired
|
||||
this.loginStrategyService.twoFactorTimeout$
|
||||
// Add subscription to authenticationSessionTimeout$ and navigate to twoFactorTimeoutRoute if expired
|
||||
this.loginStrategyService.authenticationSessionTimeout$
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(async (expired) => {
|
||||
if (!expired) {
|
||||
|
||||
71
libs/angular/src/auth/guards/active-auth.guard.spec.ts
Normal file
71
libs/angular/src/auth/guards/active-auth.guard.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Component } from "@angular/core";
|
||||
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 { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
import { activeAuthGuard } from "./active-auth.guard";
|
||||
|
||||
@Component({ template: "" })
|
||||
class EmptyComponent {}
|
||||
|
||||
describe("activeAuthGuard", () => {
|
||||
const setup = (authType: AuthenticationType | null) => {
|
||||
const loginStrategyService: MockProxy<LoginStrategyServiceAbstraction> =
|
||||
mock<LoginStrategyServiceAbstraction>();
|
||||
const currentAuthTypeSubject = new BehaviorSubject<AuthenticationType | null>(authType);
|
||||
loginStrategyService.currentAuthType$ = currentAuthTypeSubject;
|
||||
|
||||
const logService: MockProxy<LogService> = mock<LogService>();
|
||||
|
||||
const testBed = TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule.withRoutes([
|
||||
{ path: "", component: EmptyComponent },
|
||||
{
|
||||
path: "protected-route",
|
||||
component: EmptyComponent,
|
||||
canActivate: [activeAuthGuard()],
|
||||
},
|
||||
{ path: "login", component: EmptyComponent },
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
{ provide: LoginStrategyServiceAbstraction, useValue: loginStrategyService },
|
||||
{ provide: LogService, useValue: logService },
|
||||
],
|
||||
declarations: [EmptyComponent],
|
||||
});
|
||||
|
||||
return {
|
||||
router: testBed.inject(Router),
|
||||
logService,
|
||||
loginStrategyService,
|
||||
};
|
||||
};
|
||||
|
||||
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(["protected-route"]);
|
||||
expect(router.url).toBe("/protected-route");
|
||||
});
|
||||
|
||||
it("redirects to login with no active session", async () => {
|
||||
const { router, logService } = setup(null);
|
||||
|
||||
await router.navigate(["protected-route"]);
|
||||
expect(router.url).toBe("/login");
|
||||
expect(logService.error).toHaveBeenCalledWith("No active login session found.");
|
||||
});
|
||||
});
|
||||
28
libs/angular/src/auth/guards/active-auth.guard.ts
Normal file
28
libs/angular/src/auth/guards/active-auth.guard.ts
Normal file
@@ -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 activeAuthGuard(): 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;
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./auth.guard";
|
||||
export * from "./active-auth.guard";
|
||||
export * from "./lock.guard";
|
||||
export * from "./redirect.guard";
|
||||
export * from "./tde-decryption-required.guard";
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
DefaultAuthRequestApiService,
|
||||
DefaultLoginSuccessHandlerService,
|
||||
LoginSuccessHandlerService,
|
||||
PasswordLoginStrategy,
|
||||
PasswordLoginStrategyData,
|
||||
LoginApprovalComponentServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
|
||||
@@ -1436,6 +1438,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({
|
||||
|
||||
18
libs/auth/src/angular/icons/device-verification.icon.ts
Normal file
18
libs/auth/src/angular/icons/device-verification.icon.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { svgIcon } from "@bitwarden/components";
|
||||
|
||||
export const DeviceVerificationIcon = svgIcon`
|
||||
<svg viewBox="0 0 98 95" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path class="tw-stroke-art-primary" d="M12.1759 27.7453L2.54349 34.9329C1.57215 35.6577 1 36.7986 1 38.0105V89.6281C1 91.7489 2.71922 93.4681 4.84 93.4681H93.16C95.2808 93.4681 97 91.7489 97 89.6281V38.0276C97 36.806 96.4188 35.6574 95.4347 34.9338L85.6576 27.7453M61.8791 10.2622L50.9367 2.2168C49.5753 1.21588 47.7197 1.22245 46.3655 2.23297L35.6054 10.2622" stroke-width="1.92"/>
|
||||
<path class="tw-stroke-art-primary" d="M85.7661 45.4682V12.1542C85.7661 11.0938 84.9064 10.2342 83.8461 10.2342H14.1541C13.0937 10.2342 12.2341 11.0938 12.2341 12.1542V45.4682" stroke-width="1.92" stroke-linecap="round"/>
|
||||
<path class="tw-stroke-art-primary" d="M95.7335 92.1003L62.3151 61.2912C61.2514 60.3106 59.8576 59.7661 58.4109 59.7661H38.043C36.5571 59.7661 35.1286 60.3404 34.0562 61.3689L2.01148 92.1003" stroke-width="1.92"/>
|
||||
<line class="tw-stroke-art-primary" x1="96.157" y1="39.125" x2="61.0395" y2="60.0979" stroke-width="1.92" stroke-linecap="round"/>
|
||||
<path class="tw-stroke-art-primary" d="M1.84229 39.1248L36.673 59.7488" stroke-width="1.92" stroke-linecap="round"/>
|
||||
<rect class="tw-stroke-art-accent" x="23.0046" y="25.5344" width="51.925" height="17.4487" rx="8.72434" stroke-width="0.96"/>
|
||||
<circle class="tw-fill-art-accent" cx="30.2299" cy="34.2588" r="2.24846"/>
|
||||
<circle class="tw-fill-art-accent" cx="45.2196" cy="34.2587" r="2.24846"/>
|
||||
<circle class="tw-fill-art-accent" cx="60.2094" cy="34.2587" r="2.24846"/>
|
||||
<circle class="tw-fill-art-accent" cx="37.7248" cy="34.2587" r="2.24846"/>
|
||||
<circle class="tw-fill-art-accent" cx="52.7145" cy="34.2587" r="2.24846"/>
|
||||
<circle class="tw-fill-art-accent" cx="67.704" cy="34.2587" r="2.24846"/>
|
||||
</svg>
|
||||
`;
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -275,6 +275,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;
|
||||
}
|
||||
|
||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
||||
|
||||
if (authResult.forcePasswordReset != ForceSetPasswordReason.None) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-form-field class="!tw-mb-1">
|
||||
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
id="verificationCode"
|
||||
name="verificationCode"
|
||||
formControlName="code"
|
||||
appInputVerbatim
|
||||
/>
|
||||
</bit-form-field>
|
||||
|
||||
<button
|
||||
bitLink
|
||||
type="button"
|
||||
linkType="primary"
|
||||
(click)="resendOTP()"
|
||||
[disabled]="disableRequestOTP"
|
||||
class="tw-text-sm"
|
||||
>
|
||||
{{ "resendCode" | i18n }}
|
||||
</button>
|
||||
|
||||
<div class="tw-flex tw-mt-4">
|
||||
<button
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
[block]="true"
|
||||
[disabled]="formGroup.invalid"
|
||||
>
|
||||
{{ "continueLoggingIn" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,163 @@
|
||||
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<void>();
|
||||
protected authenticationSessionTimeoutRoute = "/authentication-timeout";
|
||||
|
||||
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 timeout route if session expires
|
||||
this.loginStrategyService.authenticationSessionTimeout$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((expired) => {
|
||||
if (!expired) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
void this.router.navigate([this.authenticationSessionTimeoutRoute]);
|
||||
} catch (err) {
|
||||
this.logService.error(
|
||||
`Failed to navigate to ${this.authenticationSessionTimeoutRoute} route`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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<void> => {
|
||||
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 } });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -47,7 +47,6 @@ export abstract class LoginStrategyServiceAbstraction {
|
||||
* Auth Request. Otherwise, it will return null.
|
||||
*/
|
||||
getAuthRequestId: () => Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Sends a token request to the server using the provided credentials.
|
||||
*/
|
||||
@@ -74,7 +73,11 @@ export abstract class LoginStrategyServiceAbstraction {
|
||||
*/
|
||||
makePreloginKey: (masterPassword: string, email: string) => Promise<MasterKey>;
|
||||
/**
|
||||
* Emits true if the two factor session has expired.
|
||||
* Emits true if the authentication session has expired.
|
||||
*/
|
||||
twoFactorTimeout$: Observable<boolean>;
|
||||
authenticationSessionTimeout$: Observable<boolean>;
|
||||
/**
|
||||
* Sends a token request to the server with the provided device verification OTP.
|
||||
*/
|
||||
logInNewDeviceVerification: (deviceVerificationOtp: string) => Promise<AuthResult>;
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ export * from "./models";
|
||||
export * from "./types";
|
||||
export * from "./services";
|
||||
export * from "./utilities";
|
||||
export * from "./login-strategies";
|
||||
|
||||
1
libs/auth/src/common/login-strategies/index.ts
Normal file
1
libs/auth/src/common/login-strategies/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PasswordLoginStrategy, PasswordLoginStrategyData } from "./password-login.strategy";
|
||||
@@ -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<TwoFactorProviderType, Record<string, string>>
|
||||
>;
|
||||
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<string, string>
|
||||
expected.twoFactorProviders = { 0: null } as unknown as Partial<
|
||||
Record<TwoFactorProviderType, Record<string, string>>
|
||||
>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<LoginStrategyData>;
|
||||
protected sessionTimeoutSubject = new BehaviorSubject<boolean>(false);
|
||||
sessionTimeout$: Observable<boolean> = 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<AuthResult> {
|
||||
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<AuthResult>} - A promise that resolves to an AuthResult object
|
||||
*/
|
||||
protected async processDeviceVerificationResponse(
|
||||
response: IdentityDeviceVerificationResponse,
|
||||
): Promise<AuthResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AuthResult> {
|
||||
const data = this.cache.value;
|
||||
data.tokenRequest.newDeviceOtp = deviceVerificationOtp;
|
||||
this.cache.next(data);
|
||||
|
||||
const [authResult] = await this.startLogIn();
|
||||
return authResult;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
@@ -51,12 +47,24 @@ import {
|
||||
|
||||
import { AuthRequestServiceAbstraction, LoginStrategyServiceAbstraction } from "../../abstractions";
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../../abstractions/user-decryption-options.service.abstraction";
|
||||
import { AuthRequestLoginStrategy } from "../../login-strategies/auth-request-login.strategy";
|
||||
import {
|
||||
AuthRequestLoginStrategy,
|
||||
AuthRequestLoginStrategyData,
|
||||
} from "../../login-strategies/auth-request-login.strategy";
|
||||
import { LoginStrategy } from "../../login-strategies/login.strategy";
|
||||
import { PasswordLoginStrategy } 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";
|
||||
import {
|
||||
PasswordLoginStrategy,
|
||||
PasswordLoginStrategyData,
|
||||
} from "../../login-strategies/password-login.strategy";
|
||||
import { SsoLoginStrategy, SsoLoginStrategyData } from "../../login-strategies/sso-login.strategy";
|
||||
import {
|
||||
UserApiLoginStrategy,
|
||||
UserApiLoginStrategyData,
|
||||
} from "../../login-strategies/user-api-login.strategy";
|
||||
import {
|
||||
WebAuthnLoginStrategy,
|
||||
WebAuthnLoginStrategyData,
|
||||
} from "../../login-strategies/webauthn-login.strategy";
|
||||
import {
|
||||
UserApiLoginCredentials,
|
||||
PasswordLoginCredentials,
|
||||
@@ -76,14 +84,15 @@ import {
|
||||
const sessionTimeoutLength = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
private sessionTimeoutSubscription: Subscription;
|
||||
private sessionTimeoutSubscription: Subscription | undefined;
|
||||
private currentAuthnTypeState: GlobalState<AuthenticationType | null>;
|
||||
private loginStrategyCacheState: GlobalState<CacheData | null>;
|
||||
private loginStrategyCacheExpirationState: GlobalState<Date | null>;
|
||||
private authRequestPushNotificationState: GlobalState<string>;
|
||||
private twoFactorTimeoutSubject = new BehaviorSubject<boolean>(false);
|
||||
private authRequestPushNotificationState: GlobalState<string | null>;
|
||||
private authenticationTimeoutSubject = new BehaviorSubject<boolean>(false);
|
||||
|
||||
twoFactorTimeout$: Observable<boolean> = this.twoFactorTimeoutSubject.asObservable();
|
||||
authenticationSessionTimeout$: Observable<boolean> =
|
||||
this.authenticationTimeoutSubject.asObservable();
|
||||
|
||||
private loginStrategy$: Observable<
|
||||
| UserApiLoginStrategy
|
||||
@@ -132,7 +141,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
this.taskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
async () => {
|
||||
this.twoFactorTimeoutSubject.next(true);
|
||||
this.authenticationTimeoutSubject.next(true);
|
||||
try {
|
||||
await this.clearCache();
|
||||
} catch (e) {
|
||||
@@ -153,7 +162,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
async getEmail(): Promise<string | null> {
|
||||
const strategy = await firstValueFrom(this.loginStrategy$);
|
||||
|
||||
if ("email$" in strategy) {
|
||||
if (strategy && "email$" in strategy) {
|
||||
return await firstValueFrom(strategy.email$);
|
||||
}
|
||||
return null;
|
||||
@@ -162,7 +171,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
async getMasterPasswordHash(): Promise<string | null> {
|
||||
const strategy = await firstValueFrom(this.loginStrategy$);
|
||||
|
||||
if ("serverMasterKeyHash$" in strategy) {
|
||||
if (strategy && "serverMasterKeyHash$" in strategy) {
|
||||
return await firstValueFrom(strategy.serverMasterKeyHash$);
|
||||
}
|
||||
return null;
|
||||
@@ -171,7 +180,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
async getSsoEmail2FaSessionToken(): Promise<string | null> {
|
||||
const strategy = await firstValueFrom(this.loginStrategy$);
|
||||
|
||||
if ("ssoEmail2FaSessionToken$" in strategy) {
|
||||
if (strategy && "ssoEmail2FaSessionToken$" in strategy) {
|
||||
return await firstValueFrom(strategy.ssoEmail2FaSessionToken$);
|
||||
}
|
||||
return null;
|
||||
@@ -180,7 +189,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
async getAccessCode(): Promise<string | null> {
|
||||
const strategy = await firstValueFrom(this.loginStrategy$);
|
||||
|
||||
if ("accessCode$" in strategy) {
|
||||
if (strategy && "accessCode$" in strategy) {
|
||||
return await firstValueFrom(strategy.accessCode$);
|
||||
}
|
||||
return null;
|
||||
@@ -189,7 +198,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
async getAuthRequestId(): Promise<string | null> {
|
||||
const strategy = await firstValueFrom(this.loginStrategy$);
|
||||
|
||||
if ("authRequestId$" in strategy) {
|
||||
if (strategy && "authRequestId$" in strategy) {
|
||||
return await firstValueFrom(strategy.authRequestId$);
|
||||
}
|
||||
return null;
|
||||
@@ -204,7 +213,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
| WebAuthnLoginCredentials,
|
||||
): Promise<AuthResult> {
|
||||
await this.clearCache();
|
||||
this.twoFactorTimeoutSubject.next(false);
|
||||
this.authenticationTimeoutSubject.next(false);
|
||||
|
||||
await this.currentAuthnTypeState.update((_) => credentials.type);
|
||||
|
||||
@@ -217,16 +226,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 +272,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<AuthResult> {
|
||||
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<MasterKey> {
|
||||
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 +324,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);
|
||||
@@ -289,7 +341,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
private async clearCache(): Promise<void> {
|
||||
await this.currentAuthnTypeState.update((_) => null);
|
||||
await this.loginStrategyCacheState.update((_) => null);
|
||||
this.twoFactorTimeoutSubject.next(false);
|
||||
this.authenticationTimeoutSubject.next(false);
|
||||
await this.clearSessionTimeout();
|
||||
}
|
||||
|
||||
@@ -360,7 +412,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
switch (strategy) {
|
||||
case AuthenticationType.Password:
|
||||
return new PasswordLoginStrategy(
|
||||
data?.password,
|
||||
data?.password ?? new PasswordLoginStrategyData(),
|
||||
this.passwordStrengthService,
|
||||
this.policyService,
|
||||
this,
|
||||
@@ -368,7 +420,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
);
|
||||
case AuthenticationType.Sso:
|
||||
return new SsoLoginStrategy(
|
||||
data?.sso,
|
||||
data?.sso ?? new SsoLoginStrategyData(),
|
||||
this.keyConnectorService,
|
||||
this.deviceTrustService,
|
||||
this.authRequestService,
|
||||
@@ -377,19 +429,22 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
);
|
||||
case AuthenticationType.UserApiKey:
|
||||
return new UserApiLoginStrategy(
|
||||
data?.userApiKey,
|
||||
data?.userApiKey ?? new UserApiLoginStrategyData(),
|
||||
this.environmentService,
|
||||
this.keyConnectorService,
|
||||
...sharedDeps,
|
||||
);
|
||||
case AuthenticationType.AuthRequest:
|
||||
return new AuthRequestLoginStrategy(
|
||||
data?.authRequest,
|
||||
data?.authRequest ?? new AuthRequestLoginStrategyData(),
|
||||
this.deviceTrustService,
|
||||
...sharedDeps,
|
||||
);
|
||||
case AuthenticationType.WebAuthn:
|
||||
return new WebAuthnLoginStrategy(data?.webAuthn, ...sharedDeps);
|
||||
return new WebAuthnLoginStrategy(
|
||||
data?.webAuthn ?? new WebAuthnLoginStrategyData(),
|
||||
...sharedDeps,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
@@ -151,7 +152,12 @@ export abstract class ApiService {
|
||||
| SsoTokenRequest
|
||||
| UserApiTokenRequest
|
||||
| WebAuthnLoginTokenRequest,
|
||||
) => Promise<IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse>;
|
||||
) => Promise<
|
||||
| IdentityTokenResponse
|
||||
| IdentityTwoFactorResponse
|
||||
| IdentityCaptchaResponse
|
||||
| IdentityDeviceVerificationResponse
|
||||
>;
|
||||
refreshIdentityToken: () => Promise<any>;
|
||||
|
||||
getProfile: () => Promise<ProfileResponse>;
|
||||
|
||||
@@ -22,6 +22,7 @@ export class AuthResult {
|
||||
ssoEmail2FaSessionToken?: string;
|
||||
email: string;
|
||||
requiresEncryptionKeyMigration: boolean;
|
||||
requiresDeviceVerification: boolean;
|
||||
|
||||
get requiresCaptcha() {
|
||||
return !Utils.isNullOrWhitespace(this.captchaSiteKey);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -47,6 +47,7 @@ export enum FeatureFlag {
|
||||
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
|
||||
NewDeviceVerification = "new-device-verification",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@@ -104,6 +105,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
|
||||
[FeatureFlag.NewDeviceVerification]: FALSE,
|
||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
||||
@@ -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";
|
||||
@@ -158,6 +159,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,
|
||||
@@ -197,7 +204,12 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
| PasswordTokenRequest
|
||||
| SsoTokenRequest
|
||||
| WebAuthnLoginTokenRequest,
|
||||
): Promise<IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse> {
|
||||
): Promise<
|
||||
| IdentityTokenResponse
|
||||
| IdentityTwoFactorResponse
|
||||
| IdentityCaptchaResponse
|
||||
| IdentityDeviceVerificationResponse
|
||||
> {
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||
Accept: "application/json",
|
||||
@@ -245,6 +257,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user