Skip to content

Commit fa8ee6f

Browse files
feat(auth): [PM-15534] log user in when submitting recovery code
- Add recovery code enum and feature flag - Update recovery code text and warning messages - Log user in and redirect to two-factor settings page on valid recovery code - Run full sync and handle login errors silently - Move updated messaging behind feature flag PM-15534
1 parent 4c09c22 commit fa8ee6f

File tree

8 files changed

+158
-20
lines changed

8 files changed

+158
-20
lines changed

apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts

+6
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two
1818
import { getUserId } from "@bitwarden/common/auth/services/account.service";
1919
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
2020
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
21+
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
22+
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
2123
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
2224
import { DialogService } from "@bitwarden/components";
2325

@@ -41,6 +43,8 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme
4143
private organizationService: OrganizationService,
4244
billingAccountProfileStateService: BillingAccountProfileStateService,
4345
protected accountService: AccountService,
46+
configService: ConfigService,
47+
i18nService: I18nService,
4448
) {
4549
super(
4650
dialogService,
@@ -49,6 +53,8 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme
4953
policyService,
5054
billingAccountProfileStateService,
5155
accountService,
56+
configService,
57+
i18nService,
5258
);
5359
}
5460

apps/web/src/app/auth/recover-two-factor.component.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<form [formGroup]="formGroup" [bitSubmit]="submit">
22
<p bitTypography="body1">
3-
{{ "recoverAccountTwoStepDesc" | i18n }}
3+
{{ recoveryCodeMessage }}
44
<a
55
bitLink
66
href="https://bitwarden.com/help/lost-two-step-device/"
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1-
// FIXME: Update this file to be type safe and remove this and next line
2-
// @ts-strict-ignore
3-
import { Component } from "@angular/core";
1+
import { Component, OnInit } from "@angular/core";
42
import { FormControl, FormGroup, Validators } from "@angular/forms";
53
import { Router } from "@angular/router";
64

7-
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
5+
import {
6+
LoginStrategyServiceAbstraction,
7+
PasswordLoginCredentials,
8+
LoginSuccessHandlerService,
9+
} from "@bitwarden/auth/common";
810
import { ApiService } from "@bitwarden/common/abstractions/api.service";
11+
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
12+
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
913
import { TwoFactorRecoveryRequest } from "@bitwarden/common/auth/models/request/two-factor-recovery.request";
14+
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
15+
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
1016
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
17+
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
1118
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
1219
import { ToastService } from "@bitwarden/components";
1320
import { KeyService } from "@bitwarden/key-management";
@@ -16,13 +23,23 @@ import { KeyService } from "@bitwarden/key-management";
1623
selector: "app-recover-two-factor",
1724
templateUrl: "recover-two-factor.component.html",
1825
})
19-
export class RecoverTwoFactorComponent {
26+
export class RecoverTwoFactorComponent implements OnInit {
2027
protected formGroup = new FormGroup({
21-
email: new FormControl(null, [Validators.required]),
22-
masterPassword: new FormControl(null, [Validators.required]),
23-
recoveryCode: new FormControl(null, [Validators.required]),
28+
email: new FormControl("", [Validators.required]),
29+
masterPassword: new FormControl("", [Validators.required]),
30+
recoveryCode: new FormControl("", [Validators.required]),
2431
});
2532

33+
/**
34+
* Message to display to the user about the recovery code
35+
*/
36+
recoveryCodeMessage = "";
37+
38+
/**
39+
* Whether the recovery code login feature flag is enabled
40+
*/
41+
private recoveryCodeLoginFeatureFlagEnabled = false;
42+
2643
constructor(
2744
private router: Router,
2845
private apiService: ApiService,
@@ -31,20 +48,35 @@ export class RecoverTwoFactorComponent {
3148
private keyService: KeyService,
3249
private loginStrategyService: LoginStrategyServiceAbstraction,
3350
private toastService: ToastService,
51+
private configService: ConfigService,
52+
private loginSuccessHandlerService: LoginSuccessHandlerService,
53+
private logService: LogService,
3454
) {}
3555

56+
async ngOnInit() {
57+
this.recoveryCodeLoginFeatureFlagEnabled = await this.configService.getFeatureFlag(
58+
FeatureFlag.RecoveryCodeLogin,
59+
);
60+
this.recoveryCodeMessage = this.recoveryCodeLoginFeatureFlagEnabled
61+
? this.i18nService.t("logInBelowUsingYourSingleUseRecoveryCode")
62+
: this.i18nService.t("recoverAccountTwoStepDesc");
63+
}
64+
3665
get email(): string {
37-
return this.formGroup.value.email;
66+
return this.formGroup.get("email")?.value ?? "";
3867
}
3968

4069
get masterPassword(): string {
41-
return this.formGroup.value.masterPassword;
70+
return this.formGroup.get("masterPassword")?.value ?? "";
4271
}
4372

4473
get recoveryCode(): string {
45-
return this.formGroup.value.recoveryCode;
74+
return this.formGroup.get("recoveryCode")?.value ?? "";
4675
}
4776

77+
/**
78+
* Handles the submission of the recovery code form.
79+
*/
4880
submit = async () => {
4981
this.formGroup.markAllAsTouched();
5082
if (this.formGroup.invalid) {
@@ -56,12 +88,90 @@ export class RecoverTwoFactorComponent {
5688
request.email = this.email.trim().toLowerCase();
5789
const key = await this.loginStrategyService.makePreloginKey(this.masterPassword, request.email);
5890
request.masterPasswordHash = await this.keyService.hashMasterKey(this.masterPassword, key);
59-
await this.apiService.postTwoFactorRecover(request);
60-
this.toastService.showToast({
61-
variant: "success",
62-
title: null,
63-
message: this.i18nService.t("twoStepRecoverDisabled"),
64-
});
65-
await this.router.navigate(["/"]);
91+
92+
try {
93+
await this.apiService.postTwoFactorRecover(request);
94+
95+
this.toastService.showToast({
96+
variant: "success",
97+
title: "",
98+
message: this.i18nService.t("twoStepRecoverDisabled"),
99+
});
100+
101+
if (!this.recoveryCodeLoginFeatureFlagEnabled) {
102+
await this.router.navigate(["/"]);
103+
return;
104+
}
105+
106+
// Handle login after recovery if the feature flag is enabled
107+
await this.handleRecoveryLogin(request);
108+
} catch (e) {
109+
const errorMessage = this.extractErrorMessage(e);
110+
this.toastService.showToast({
111+
variant: "error",
112+
title: this.i18nService.t("error"),
113+
message: errorMessage,
114+
});
115+
}
66116
};
117+
118+
/**
119+
* Handles the login process after a successful account recovery.
120+
*/
121+
private async handleRecoveryLogin(request: TwoFactorRecoveryRequest) {
122+
// Build two-factor request to pass into PasswordLoginCredentials request using the 2FA recovery code and RecoveryCode type
123+
const twoFactorRequest: TokenTwoFactorRequest = {
124+
provider: TwoFactorProviderType.RecoveryCode,
125+
token: request.recoveryCode,
126+
remember: false,
127+
};
128+
129+
const credentials = new PasswordLoginCredentials(
130+
request.email,
131+
this.masterPassword,
132+
"",
133+
twoFactorRequest,
134+
);
135+
136+
try {
137+
const authResult = await this.loginStrategyService.logIn(credentials);
138+
this.toastService.showToast({
139+
variant: "success",
140+
title: "",
141+
message: this.i18nService.t("youHaveBeenLoggedIn"),
142+
});
143+
await this.loginSuccessHandlerService.run(authResult.userId);
144+
await this.router.navigate(["/settings/security/two-factor"]);
145+
} catch (error) {
146+
// If login errors, redirect to login page per product. Don't show error
147+
this.logService.error("Error logging in automatically: ", (error as Error).message);
148+
await this.router.navigate(["/login"], { queryParams: { email: request.email } });
149+
}
150+
}
151+
152+
/**
153+
* Extracts an error message from the error object.
154+
*/
155+
private extractErrorMessage(error: unknown): string {
156+
let errorMessage: string = this.i18nService.t("unexpectedError");
157+
if (error && typeof error === "object" && "validationErrors" in error) {
158+
const validationErrors = error.validationErrors;
159+
if (validationErrors && typeof validationErrors === "object") {
160+
errorMessage = Object.keys(validationErrors)
161+
.map((key) => {
162+
const messages = (validationErrors as Record<string, string | string[]>)[key];
163+
return Array.isArray(messages) ? messages.join(" ") : messages;
164+
})
165+
.join(" ");
166+
}
167+
} else if (
168+
error &&
169+
typeof error === "object" &&
170+
"message" in error &&
171+
typeof error.message === "string"
172+
) {
173+
errorMessage = error.message;
174+
}
175+
return errorMessage;
176+
}
67177
}

apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ <h1 *ngIf="organizationId && isEnterpriseOrg">{{ "twoStepLoginEnforcement" | i18
2626
</p>
2727
</ng-container>
2828
<bit-callout type="warning" *ngIf="!organizationId">
29-
<p>{{ "twoStepLoginRecoveryWarning" | i18n }}</p>
29+
<p>{{ recoveryCodeWarningMessage }}</p>
3030
<button type="button" bitButton buttonType="secondary" (click)="recoveryCode()">
3131
{{ "viewRecoveryCode" | i18n }}
3232
</button>

apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts

+13
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.s
2929
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
3030
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
3131
import { ProductTierType } from "@bitwarden/common/billing/enums";
32+
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
33+
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
34+
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
3235
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
3336
import { DialogService } from "@bitwarden/components";
3437

@@ -52,6 +55,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy {
5255
organization: Organization;
5356
providers: any[] = [];
5457
canAccessPremium$: Observable<boolean>;
58+
recoveryCodeWarningMessage: string;
5559
showPolicyWarning = false;
5660
loading = true;
5761
modal: ModalRef;
@@ -70,6 +74,8 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy {
7074
protected policyService: PolicyService,
7175
billingAccountProfileStateService: BillingAccountProfileStateService,
7276
protected accountService: AccountService,
77+
protected configService: ConfigService,
78+
protected i18nService: I18nService,
7379
) {
7480
this.canAccessPremium$ = this.accountService.activeAccount$.pipe(
7581
switchMap((account) =>
@@ -79,6 +85,13 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy {
7985
}
8086

8187
async ngOnInit() {
88+
const recoveryCodeLoginFeatureFlagEnabled = await this.configService.getFeatureFlag(
89+
FeatureFlag.RecoveryCodeLogin,
90+
);
91+
this.recoveryCodeWarningMessage = recoveryCodeLoginFeatureFlagEnabled
92+
? this.i18nService.t("yourSingleUseRecoveryCode")
93+
: this.i18nService.t("twoStepLoginRecoveryWarning");
94+
8295
for (const key in TwoFactorProviders) {
8396
// eslint-disable-next-line
8497
if (!TwoFactorProviders.hasOwnProperty(key)) {

apps/web/src/locales/en/messages.json

+6
Original file line numberDiff line numberDiff line change
@@ -2183,6 +2183,9 @@
21832183
"twoStepLoginRecoveryWarning": {
21842184
"message": "Setting up two-step login can permanently lock you out of your Bitwarden account. A recovery code allows you to access your account in the event that you can no longer use your normal two-step login provider (example: you lose your device). Bitwarden support will not be able to assist you if you lose access to your account. We recommend you write down or print the recovery code and keep it in a safe place."
21852185
},
2186+
"yourSingleUseRecoveryCode": {
2187+
"message": "Your single-use recovery code can be used to turn off two-step login in the event that you lose access to your two-step login provider. Bitwarden recommends you write down the recovery code and keep it in a safe place."
2188+
},
21862189
"viewRecoveryCode": {
21872190
"message": "View recovery code"
21882191
},
@@ -4193,6 +4196,9 @@
41934196
"recoverAccountTwoStepDesc": {
41944197
"message": "If you cannot access your account through your normal two-step login methods, you can use your two-step login recovery code to turn off all two-step providers on your account."
41954198
},
4199+
"logInBelowUsingYourSingleUseRecoveryCode": {
4200+
"message": "Log in below using your single-use recovery code. This will turn off all two-step providers on your account."
4201+
},
41964202
"recoverAccountTwoStep": {
41974203
"message": "Recover account two-step login"
41984204
},

libs/common/src/auth/enums/two-factor-provider-type.ts

+1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ export enum TwoFactorProviderType {
77
Remember = 5,
88
OrganizationDuo = 6,
99
WebAuthn = 7,
10+
RecoveryCode = 8,
1011
}

libs/common/src/enums/feature-flag.enum.ts

+2
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export enum FeatureFlag {
5050
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
5151
NewDeviceVerification = "new-device-verification",
5252
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
53+
RecoveryCodeLogin = "pm-17128-recovery-code-login",
5354
}
5455

5556
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -110,6 +111,7 @@ export const DefaultFeatureFlagValue = {
110111
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
111112
[FeatureFlag.NewDeviceVerification]: FALSE,
112113
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
114+
[FeatureFlag.RecoveryCodeLogin]: FALSE,
113115
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
114116

115117
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

0 commit comments

Comments
 (0)