From 51eb626b889e49a5210735ce5f771c804081ce99 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Tue, 28 Apr 2026 10:09:41 +0700
Subject: [PATCH] feat: allow disabling 2FA with a backup code (#14102)
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-6883/allow-disabling-2fa-using-a-backup-code
## Description
When a user loses access to their authenticator app, they can now
disable 2FA using one of their saved backup codes (in addition to their
password), so they can re-enroll a new authenticator. The disable dialog
includes a toggle to switch between entering a verification code and a
backup code.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- Via UI flows
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
.../api/v1/profile/mfa_controller.rb | 7 ++--
app/javascript/dashboard/api/mfa.js | 4 +--
.../dashboard/i18n/locale/en/mfa.json | 6 +++-
.../settings/profile/MfaManagementActions.vue | 33 ++++++++++++++++++-
.../settings/profile/MfaSettings.vue | 4 +--
.../api/v1/profile/mfa_controller_spec.rb | 17 ++++++++++
6 files changed, 62 insertions(+), 9 deletions(-)
diff --git a/app/controllers/api/v1/profile/mfa_controller.rb b/app/controllers/api/v1/profile/mfa_controller.rb
index dd874f222..8480b64fb 100644
--- a/app/controllers/api/v1/profile/mfa_controller.rb
+++ b/app/controllers/api/v1/profile/mfa_controller.rb
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
before_action :check_mfa_feature_available
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
before_action :check_mfa_disabled, only: [:create, :verify]
- before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
before_action :validate_password, only: [:destroy]
+ before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
def show; end
@@ -48,7 +48,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
def validate_otp
authenticated = Mfa::AuthenticationService.new(
user: current_user,
- otp_code: mfa_params[:otp_code]
+ otp_code: mfa_params[:otp_code],
+ backup_code: mfa_params[:backup_code]
).authenticate
return if authenticated
@@ -63,6 +64,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
end
def mfa_params
- params.permit(:otp_code, :password)
+ params.permit(:otp_code, :backup_code, :password)
end
end
diff --git a/app/javascript/dashboard/api/mfa.js b/app/javascript/dashboard/api/mfa.js
index c18bea3e9..38cb93810 100644
--- a/app/javascript/dashboard/api/mfa.js
+++ b/app/javascript/dashboard/api/mfa.js
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
}
- disable(password, otpCode) {
+ disable(password, { otpCode, backupCode } = {}) {
return axios.delete(this.url, {
- data: { password, otp_code: otpCode },
+ data: { password, otp_code: otpCode, backup_code: backupCode },
});
}
diff --git a/app/javascript/dashboard/i18n/locale/en/mfa.json b/app/javascript/dashboard/i18n/locale/en/mfa.json
index b03917bcd..8e356aad4 100644
--- a/app/javascript/dashboard/i18n/locale/en/mfa.json
+++ b/app/javascript/dashboard/i18n/locale/en/mfa.json
@@ -51,10 +51,14 @@
},
"DISABLE": {
"TITLE": "Disable Two-Factor Authentication",
- "DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
+ "DESCRIPTION": "You'll need to enter your password and either a verification code from your authenticator app or a backup code to disable two-factor authentication.",
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
+ "BACKUP_CODE": "Backup Code",
+ "BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
+ "USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
+ "USE_OTP_CODE": "Use a verification code from your authenticator app",
"CONFIRM": "Disable 2FA",
"CANCEL": "Cancel",
"SUCCESS": "Two-factor authentication has been disabled",
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MfaManagementActions.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaManagementActions.vue
index caf9e2a6a..b49bae086 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/MfaManagementActions.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaManagementActions.vue
@@ -31,6 +31,8 @@ const backupCodesDialogRef = ref(null);
// Form values
const disablePassword = ref('');
const disableOtpCode = ref('');
+const disableBackupCode = ref('');
+const useBackupCodeToDisable = ref(false);
const regenerateOtpCode = ref('');
// Utility functions
@@ -54,10 +56,17 @@ const downloadBackupCodes = () => {
const handleDisableMfa = async () => {
emit('disableMfa', {
password: disablePassword.value,
- otpCode: disableOtpCode.value,
+ otpCode: useBackupCodeToDisable.value ? '' : disableOtpCode.value,
+ backupCode: useBackupCodeToDisable.value ? disableBackupCode.value : '',
});
};
+const toggleDisableMethod = () => {
+ useBackupCodeToDisable.value = !useBackupCodeToDisable.value;
+ disableOtpCode.value = '';
+ disableBackupCode.value = '';
+};
+
const handleRegenerateBackupCodes = async () => {
emit('regenerateBackupCodes', {
otpCode: regenerateOtpCode.value,
@@ -68,6 +77,8 @@ const handleRegenerateBackupCodes = async () => {
const resetDisableForm = () => {
disablePassword.value = '';
disableOtpCode.value = '';
+ disableBackupCode.value = '';
+ useBackupCodeToDisable.value = false;
disableDialogRef.value?.close();
};
@@ -157,12 +168,32 @@ defineExpose({
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
/>
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue
index 80dce179b..bd21dfafb 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue
@@ -104,9 +104,9 @@ const cancelSetup = () => {
};
// Disable MFA
-const disableMfa = async ({ password, otpCode }) => {
+const disableMfa = async ({ password, otpCode, backupCode }) => {
try {
- await mfaAPI.disable(password, otpCode);
+ await mfaAPI.disable(password, { otpCode, backupCode });
mfaEnabled.value = false;
backupCodesGenerated.value = false;
managementActionsRef.value?.resetDisableForm();
diff --git a/spec/requests/api/v1/profile/mfa_controller_spec.rb b/spec/requests/api/v1/profile/mfa_controller_spec.rb
index 97a2e206f..eebd997ee 100644
--- a/spec/requests/api/v1/profile/mfa_controller_spec.rb
+++ b/spec/requests/api/v1/profile/mfa_controller_spec.rb
@@ -205,6 +205,23 @@ RSpec.describe 'MFA API', type: :request do
expect(json_response['error']).to include('Invalid')
end
end
+
+ context 'with valid password and backup code' do
+ it 'disables 2FA successfully' do
+ backup_code = user.otp_backup_codes.first
+
+ delete '/api/v1/profile/mfa',
+ params: { password: 'Test@123456', backup_code: backup_code },
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ user.reload
+ expect(user.otp_required_for_login).to be_falsey
+ expect(user.otp_secret).to be_nil
+ expect(user.otp_backup_codes).to be_blank
+ end
+ end
end
context 'when 2FA is not enabled' do