Skip to content

Commit ec95085

Browse files
authored
fix(2fa-recovery-code-error): [Auth/PM-19885] Better error handling when 2FA recovery code is invalid (#16145)
Implements better error handling when a user enters an invalid 2FA recovery code. Upon entering an invalid code: - Keep the user on the `/recover-2fa` page (This also makes it so the incorrect code remains in the form field so the user can see what they entered, if they mistyped the code, etc.) - Show an inline error: "Invalid recovery code"
1 parent e0da267 commit ec95085

File tree

4 files changed

+90
-21
lines changed

4 files changed

+90
-21
lines changed

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
<bit-label>{{ "recoveryCodeTitle" | i18n }}</bit-label>
2929
<input bitInput type="text" formControlName="recoveryCode" appInputVerbatim />
3030
</bit-form-field>
31-
<hr />
3231
<div class="tw-flex tw-gap-2">
3332
<button type="submit" bitButton bitFormButton buttonType="primary" [block]="true">
3433
{{ "submit" | i18n }}

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

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import {
99
} from "@bitwarden/auth/common";
1010
import { ApiService } from "@bitwarden/common/abstractions/api.service";
1111
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
12+
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
1213
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
1314
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
1415
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
1516
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
17+
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
1618
import { ToastService } from "@bitwarden/components";
1719
import { KeyService } from "@bitwarden/key-management";
1820
import { I18nPipe } from "@bitwarden/ui-common";
@@ -34,6 +36,7 @@ describe("RecoverTwoFactorComponent", () => {
3436
let mockConfigService: MockProxy<ConfigService>;
3537
let mockLoginSuccessHandlerService: MockProxy<LoginSuccessHandlerService>;
3638
let mockLogService: MockProxy<LogService>;
39+
let mockValidationService: MockProxy<ValidationService>;
3740

3841
beforeEach(() => {
3942
mockRouter = mock<Router>();
@@ -46,6 +49,7 @@ describe("RecoverTwoFactorComponent", () => {
4649
mockConfigService = mock<ConfigService>();
4750
mockLoginSuccessHandlerService = mock<LoginSuccessHandlerService>();
4851
mockLogService = mock<LogService>();
52+
mockValidationService = mock<ValidationService>();
4953

5054
TestBed.configureTestingModule({
5155
declarations: [RecoverTwoFactorComponent],
@@ -60,6 +64,7 @@ describe("RecoverTwoFactorComponent", () => {
6064
{ provide: ConfigService, useValue: mockConfigService },
6165
{ provide: LoginSuccessHandlerService, useValue: mockLoginSuccessHandlerService },
6266
{ provide: LogService, useValue: mockLogService },
67+
{ provide: ValidationService, useValue: mockValidationService },
6368
],
6469
imports: [I18nPipe],
6570
// FIXME(PM-18598): Replace unknownElements and unknownProperties with actual imports
@@ -70,16 +75,21 @@ describe("RecoverTwoFactorComponent", () => {
7075
component = fixture.componentInstance;
7176
});
7277

73-
afterEach(() => {
74-
jest.resetAllMocks();
75-
});
76-
7778
describe("handleRecoveryLogin", () => {
79+
let email: string;
80+
let recoveryCode: string;
81+
82+
beforeEach(() => {
83+
email = "[email protected]";
84+
recoveryCode = "testRecoveryCode";
85+
});
86+
87+
afterEach(() => {
88+
jest.resetAllMocks();
89+
});
90+
7891
it("should log in successfully and navigate to the two-factor settings page", async () => {
7992
// Arrange
80-
const email = "[email protected]";
81-
const recoveryCode = "testRecoveryCode";
82-
8393
const authResult = new AuthResult();
8494
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
8595

@@ -98,14 +108,16 @@ describe("RecoverTwoFactorComponent", () => {
98108
expect(mockRouter.navigate).toHaveBeenCalledWith(["/settings/security/two-factor"]);
99109
});
100110

101-
it("should handle login errors and redirect to login page", async () => {
111+
it("should log an error and set an inline error on the recoveryCode form control upon receiving an ErrorResponse due to an invalid token", async () => {
102112
// Arrange
103-
const email = "[email protected]";
104-
const recoveryCode = "testRecoveryCode";
105-
106-
const error = new Error("Login failed");
113+
const error = new ErrorResponse("mockError", 400);
114+
error.message = "Two-step token is invalid";
107115
mockLoginStrategyService.logIn.mockRejectedValue(error);
108116

117+
const recoveryCodeControl = component.formGroup.get("recoveryCode");
118+
jest.spyOn(recoveryCodeControl, "setErrors");
119+
mockI18nService.t.mockReturnValue("Invalid recovery code");
120+
109121
// Act
110122
await component["loginWithRecoveryCode"](email, recoveryCode);
111123

@@ -114,9 +126,43 @@ describe("RecoverTwoFactorComponent", () => {
114126
"Error logging in automatically: ",
115127
error.message,
116128
);
117-
expect(mockRouter.navigate).toHaveBeenCalledWith(["/login"], {
118-
queryParams: { email: email },
129+
expect(recoveryCodeControl.setErrors).toHaveBeenCalledWith({
130+
invalidRecoveryCode: { message: "Invalid recovery code" },
119131
});
120132
});
133+
134+
it("should log an error and show validation but not set an inline error on the recoveryCode form control upon receiving some other ErrorResponse", async () => {
135+
// Arrange
136+
const error = new ErrorResponse("mockError", 400);
137+
error.message = "Some other error";
138+
mockLoginStrategyService.logIn.mockRejectedValue(error);
139+
140+
const recoveryCodeControl = component.formGroup.get("recoveryCode");
141+
jest.spyOn(recoveryCodeControl, "setErrors");
142+
143+
// Act
144+
await component["loginWithRecoveryCode"](email, recoveryCode);
145+
146+
// Assert
147+
expect(mockLogService.error).toHaveBeenCalledWith(
148+
"Error logging in automatically: ",
149+
error.message,
150+
);
151+
expect(mockValidationService.showError).toHaveBeenCalledWith(error.message);
152+
expect(recoveryCodeControl.setErrors).not.toHaveBeenCalled();
153+
});
154+
155+
it("should log an error and show validation upon receiving a non-ErrorResponse error", async () => {
156+
// Arrange
157+
const error = new Error("Generic error");
158+
mockLoginStrategyService.logIn.mockRejectedValue(error);
159+
160+
// Act
161+
await component["loginWithRecoveryCode"](email, recoveryCode);
162+
163+
// Assert
164+
expect(mockLogService.error).toHaveBeenCalledWith("Error logging in automatically: ", error);
165+
expect(mockValidationService.showError).toHaveBeenCalledWith(error);
166+
});
121167
});
122168
});

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

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Component, OnInit } from "@angular/core";
1+
import { Component, DestroyRef, OnInit } from "@angular/core";
2+
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
23
import { FormControl, FormGroup, Validators } from "@angular/forms";
34
import { Router } from "@angular/router";
45

@@ -9,8 +10,10 @@ import {
910
} from "@bitwarden/auth/common";
1011
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
1112
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
13+
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
1214
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
1315
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
16+
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
1417
import { ToastService } from "@bitwarden/components";
1518

1619
@Component({
@@ -19,7 +22,7 @@ import { ToastService } from "@bitwarden/components";
1922
standalone: false,
2023
})
2124
export class RecoverTwoFactorComponent implements OnInit {
22-
protected formGroup = new FormGroup({
25+
formGroup = new FormGroup({
2326
email: new FormControl("", [Validators.required]),
2427
masterPassword: new FormControl("", [Validators.required]),
2528
recoveryCode: new FormControl("", [Validators.required]),
@@ -31,16 +34,23 @@ export class RecoverTwoFactorComponent implements OnInit {
3134
recoveryCodeMessage = "";
3235

3336
constructor(
37+
private destroyRef: DestroyRef,
3438
private router: Router,
3539
private i18nService: I18nService,
3640
private loginStrategyService: LoginStrategyServiceAbstraction,
3741
private toastService: ToastService,
3842
private loginSuccessHandlerService: LoginSuccessHandlerService,
3943
private logService: LogService,
44+
private validationService: ValidationService,
4045
) {}
4146

4247
async ngOnInit() {
4348
this.recoveryCodeMessage = this.i18nService.t("logInBelowUsingYourSingleUseRecoveryCode");
49+
50+
// Clear any existing recovery code inline error when user updates the form
51+
this.formGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
52+
this.formGroup.get("recoveryCode")?.setErrors(null);
53+
});
4454
}
4555

4656
get email(): string {
@@ -99,10 +109,21 @@ export class RecoverTwoFactorComponent implements OnInit {
99109
await this.loginSuccessHandlerService.run(authResult.userId);
100110

101111
await this.router.navigate(["/settings/security/two-factor"]);
102-
} catch (error) {
103-
// If login errors, redirect to login page per product. Don't show error
104-
this.logService.error("Error logging in automatically: ", (error as Error).message);
105-
await this.router.navigate(["/login"], { queryParams: { email: email } });
112+
} catch (error: unknown) {
113+
if (error instanceof ErrorResponse) {
114+
this.logService.error("Error logging in automatically: ", error.message);
115+
116+
if (error.message.includes("Two-step token is invalid")) {
117+
this.formGroup.get("recoveryCode")?.setErrors({
118+
invalidRecoveryCode: { message: this.i18nService.t("invalidRecoveryCode") },
119+
});
120+
} else {
121+
this.validationService.showError(error.message);
122+
}
123+
} else {
124+
this.logService.error("Error logging in automatically: ", error);
125+
this.validationService.showError(error);
126+
}
106127
}
107128
}
108129
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1517,6 +1517,9 @@
15171517
"recoveryCodeTitle": {
15181518
"message": "Recovery code"
15191519
},
1520+
"invalidRecoveryCode": {
1521+
"message": "Invalid recovery code"
1522+
},
15201523
"authenticatorAppTitle": {
15211524
"message": "Authenticator app"
15221525
},

0 commit comments

Comments
 (0)