Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b73f663c5d | ||
|
|
867bcaf744 | ||
|
|
a80f7f21ef | ||
|
|
c1521c5b4c | ||
|
|
280d5bf295 | ||
|
|
88268055de | ||
|
|
365e8c201a | ||
|
|
9a22d03375 | ||
|
|
0ac947e3be | ||
|
|
5123b7880a | ||
|
|
6094060381 |
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
|||||||
before_action :check_mfa_feature_available
|
before_action :check_mfa_feature_available
|
||||||
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
|
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
|
||||||
before_action :check_mfa_disabled, only: [:create, :verify]
|
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_password, only: [:destroy]
|
||||||
|
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
@@ -48,7 +48,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
|||||||
def validate_otp
|
def validate_otp
|
||||||
authenticated = Mfa::AuthenticationService.new(
|
authenticated = Mfa::AuthenticationService.new(
|
||||||
user: current_user,
|
user: current_user,
|
||||||
otp_code: mfa_params[:otp_code]
|
otp_code: mfa_params[:otp_code],
|
||||||
|
backup_code: mfa_params[:backup_code]
|
||||||
).authenticate
|
).authenticate
|
||||||
|
|
||||||
return if authenticated
|
return if authenticated
|
||||||
@@ -63,6 +64,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def mfa_params
|
def mfa_params
|
||||||
params.permit(:otp_code, :password)
|
params.permit(:otp_code, :backup_code, :password)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import NetworkNotification from './components/NetworkNotification.vue';
|
|||||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||||
|
import LowBackupCodesBanner from './components/app/LowBackupCodesBanner.vue';
|
||||||
import vueActionCable from './helper/actionCable';
|
import vueActionCable from './helper/actionCable';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useStore } from 'dashboard/composables/store';
|
import { useStore } from 'dashboard/composables/store';
|
||||||
@@ -30,6 +31,7 @@ export default {
|
|||||||
PaymentPendingBanner,
|
PaymentPendingBanner,
|
||||||
WootSnackbarBox,
|
WootSnackbarBox,
|
||||||
PendingEmailVerificationBanner,
|
PendingEmailVerificationBanner,
|
||||||
|
LowBackupCodesBanner,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -140,6 +142,7 @@ export default {
|
|||||||
<template v-if="currentAccountId">
|
<template v-if="currentAccountId">
|
||||||
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
|
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
|
||||||
<PaymentPendingBanner v-if="hideOnOnboardingView" />
|
<PaymentPendingBanner v-if="hideOnOnboardingView" />
|
||||||
|
<LowBackupCodesBanner v-if="hideOnOnboardingView" />
|
||||||
</template>
|
</template>
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<transition name="fade" mode="out-in">
|
<transition name="fade" mode="out-in">
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
|
|||||||
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
|
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
|
||||||
}
|
}
|
||||||
|
|
||||||
disable(password, otpCode) {
|
disable(password, { otpCode, backupCode } = {}) {
|
||||||
return axios.delete(this.url, {
|
return axios.delete(this.url, {
|
||||||
data: { password, otp_code: otpCode },
|
data: { password, otp_code: otpCode, backup_code: backupCode },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onUnmounted, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { parseBoolean } from '@chatwoot/utils';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
|
import mfaAPI from 'dashboard/api/mfa';
|
||||||
|
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||||
|
|
||||||
|
const LOW_BACKUP_CODES_THRESHOLD = 3;
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const mfaEnabled = ref(false);
|
||||||
|
const remainingBackupCodes = ref(null);
|
||||||
|
|
||||||
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
|
|
||||||
|
const shouldShowBanner = computed(() => {
|
||||||
|
if (!mfaEnabled.value) return false;
|
||||||
|
if (remainingBackupCodes.value === null) return false;
|
||||||
|
return remainingBackupCodes.value <= LOW_BACKUP_CODES_THRESHOLD;
|
||||||
|
});
|
||||||
|
|
||||||
|
const bannerColorScheme = computed(() =>
|
||||||
|
remainingBackupCodes.value === 0 ? 'alert' : 'warning'
|
||||||
|
);
|
||||||
|
|
||||||
|
const bannerMessage = computed(() => {
|
||||||
|
if (remainingBackupCodes.value === 0) {
|
||||||
|
return t('MFA_SETTINGS.LOW_BACKUP_CODES.NONE_LEFT');
|
||||||
|
}
|
||||||
|
return t('MFA_SETTINGS.LOW_BACKUP_CODES.MESSAGE', remainingBackupCodes.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchMfaStatus = async () => {
|
||||||
|
if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await mfaAPI.get();
|
||||||
|
mfaEnabled.value = data.enabled;
|
||||||
|
remainingBackupCodes.value = data.remaining_backup_codes ?? null;
|
||||||
|
} catch {
|
||||||
|
// ignore; banner stays hidden
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToMfaSettings = () => {
|
||||||
|
router.push({
|
||||||
|
name: 'profile_settings_mfa',
|
||||||
|
params: { accountId: currentAccountId.value },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchMfaStatus();
|
||||||
|
emitter.on(BUS_EVENTS.MFA_STATE_CHANGED, fetchMfaStatus);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
emitter.off(BUS_EVENTS.MFA_STATE_CHANGED, fetchMfaStatus);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||||
|
<template>
|
||||||
|
<Banner
|
||||||
|
v-if="shouldShowBanner"
|
||||||
|
:color-scheme="bannerColorScheme"
|
||||||
|
:banner-message="bannerMessage"
|
||||||
|
:action-button-label="$t('MFA_SETTINGS.LOW_BACKUP_CODES.ACTION')"
|
||||||
|
action-button-icon="i-lucide-key"
|
||||||
|
has-action-button
|
||||||
|
@primary-action="goToMfaSettings"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -49,12 +49,21 @@
|
|||||||
"DISABLE_MFA_DESC": "Remove two-factor authentication from your account",
|
"DISABLE_MFA_DESC": "Remove two-factor authentication from your account",
|
||||||
"DISABLE_BUTTON": "Disable Two-Factor Authentication"
|
"DISABLE_BUTTON": "Disable Two-Factor Authentication"
|
||||||
},
|
},
|
||||||
|
"LOW_BACKUP_CODES": {
|
||||||
|
"MESSAGE": "You have {n} backup code remaining. Generate new codes to avoid getting locked out if you lose access to your authenticator. | You have {n} backup codes remaining. Generate new codes to avoid getting locked out if you lose access to your authenticator.",
|
||||||
|
"NONE_LEFT": "You have no backup codes remaining. Generate new codes now to avoid getting locked out if you lose access to your authenticator.",
|
||||||
|
"ACTION": "Generate codes"
|
||||||
|
},
|
||||||
"DISABLE": {
|
"DISABLE": {
|
||||||
"TITLE": "Disable Two-Factor Authentication",
|
"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",
|
"PASSWORD": "Password",
|
||||||
"OTP_CODE": "Verification Code",
|
"OTP_CODE": "Verification Code",
|
||||||
"OTP_CODE_PLACEHOLDER": "000000",
|
"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",
|
"CONFIRM": "Disable 2FA",
|
||||||
"CANCEL": "Cancel",
|
"CANCEL": "Cancel",
|
||||||
"SUCCESS": "Two-factor authentication has been disabled",
|
"SUCCESS": "Two-factor authentication has been disabled",
|
||||||
|
|||||||
+32
-1
@@ -31,6 +31,8 @@ const backupCodesDialogRef = ref(null);
|
|||||||
// Form values
|
// Form values
|
||||||
const disablePassword = ref('');
|
const disablePassword = ref('');
|
||||||
const disableOtpCode = ref('');
|
const disableOtpCode = ref('');
|
||||||
|
const disableBackupCode = ref('');
|
||||||
|
const useBackupCodeToDisable = ref(false);
|
||||||
const regenerateOtpCode = ref('');
|
const regenerateOtpCode = ref('');
|
||||||
|
|
||||||
// Utility functions
|
// Utility functions
|
||||||
@@ -54,10 +56,17 @@ const downloadBackupCodes = () => {
|
|||||||
const handleDisableMfa = async () => {
|
const handleDisableMfa = async () => {
|
||||||
emit('disableMfa', {
|
emit('disableMfa', {
|
||||||
password: disablePassword.value,
|
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 () => {
|
const handleRegenerateBackupCodes = async () => {
|
||||||
emit('regenerateBackupCodes', {
|
emit('regenerateBackupCodes', {
|
||||||
otpCode: regenerateOtpCode.value,
|
otpCode: regenerateOtpCode.value,
|
||||||
@@ -68,6 +77,8 @@ const handleRegenerateBackupCodes = async () => {
|
|||||||
const resetDisableForm = () => {
|
const resetDisableForm = () => {
|
||||||
disablePassword.value = '';
|
disablePassword.value = '';
|
||||||
disableOtpCode.value = '';
|
disableOtpCode.value = '';
|
||||||
|
disableBackupCode.value = '';
|
||||||
|
useBackupCodeToDisable.value = false;
|
||||||
disableDialogRef.value?.close();
|
disableDialogRef.value?.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,12 +168,32 @@ defineExpose({
|
|||||||
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
|
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
v-if="!useBackupCodeToDisable"
|
||||||
v-model="disableOtpCode"
|
v-model="disableOtpCode"
|
||||||
type="text"
|
type="text"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
|
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
|
||||||
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
|
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
v-else
|
||||||
|
v-model="disableBackupCode"
|
||||||
|
type="text"
|
||||||
|
maxlength="8"
|
||||||
|
:label="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE')"
|
||||||
|
:placeholder="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE_PLACEHOLDER')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
link
|
||||||
|
sm
|
||||||
|
type="button"
|
||||||
|
:label="
|
||||||
|
useBackupCodeToDisable
|
||||||
|
? $t('MFA_SETTINGS.DISABLE.USE_OTP_CODE')
|
||||||
|
: $t('MFA_SETTINGS.DISABLE.USE_BACKUP_CODE')
|
||||||
|
"
|
||||||
|
@click="toggleDisableMethod"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useRouter, useRoute } from 'vue-router';
|
|||||||
import { parseBoolean } from '@chatwoot/utils';
|
import { parseBoolean } from '@chatwoot/utils';
|
||||||
import mfaAPI from 'dashboard/api/mfa';
|
import mfaAPI from 'dashboard/api/mfa';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import MfaStatusCard from './MfaStatusCard.vue';
|
import MfaStatusCard from './MfaStatusCard.vue';
|
||||||
import MfaSetupWizard from './MfaSetupWizard.vue';
|
import MfaSetupWizard from './MfaSetupWizard.vue';
|
||||||
import MfaManagementActions from './MfaManagementActions.vue';
|
import MfaManagementActions from './MfaManagementActions.vue';
|
||||||
@@ -95,6 +97,7 @@ const completeMfaSetup = () => {
|
|||||||
mfaEnabled.value = true;
|
mfaEnabled.value = true;
|
||||||
backupCodesGenerated.value = true;
|
backupCodesGenerated.value = true;
|
||||||
showSetup.value = false;
|
showSetup.value = false;
|
||||||
|
emitter.emit(BUS_EVENTS.MFA_STATE_CHANGED);
|
||||||
useAlert(t('MFA_SETTINGS.SETUP.SUCCESS'));
|
useAlert(t('MFA_SETTINGS.SETUP.SUCCESS'));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,12 +107,13 @@ const cancelSetup = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Disable MFA
|
// Disable MFA
|
||||||
const disableMfa = async ({ password, otpCode }) => {
|
const disableMfa = async ({ password, otpCode, backupCode }) => {
|
||||||
try {
|
try {
|
||||||
await mfaAPI.disable(password, otpCode);
|
await mfaAPI.disable(password, { otpCode, backupCode });
|
||||||
mfaEnabled.value = false;
|
mfaEnabled.value = false;
|
||||||
backupCodesGenerated.value = false;
|
backupCodesGenerated.value = false;
|
||||||
managementActionsRef.value?.resetDisableForm();
|
managementActionsRef.value?.resetDisableForm();
|
||||||
|
emitter.emit(BUS_EVENTS.MFA_STATE_CHANGED);
|
||||||
useAlert(t('MFA_SETTINGS.DISABLE.SUCCESS'));
|
useAlert(t('MFA_SETTINGS.DISABLE.SUCCESS'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(t('MFA_SETTINGS.DISABLE.ERROR'));
|
useAlert(t('MFA_SETTINGS.DISABLE.ERROR'));
|
||||||
@@ -123,6 +127,7 @@ const regenerateBackupCodes = async ({ otpCode }) => {
|
|||||||
backupCodes.value = response.data.backup_codes;
|
backupCodes.value = response.data.backup_codes;
|
||||||
managementActionsRef.value?.resetRegenerateForm();
|
managementActionsRef.value?.resetRegenerateForm();
|
||||||
managementActionsRef.value?.showBackupCodesDialog();
|
managementActionsRef.value?.showBackupCodesDialog();
|
||||||
|
emitter.emit(BUS_EVENTS.MFA_STATE_CHANGED);
|
||||||
useAlert(t('MFA_SETTINGS.REGENERATE.SUCCESS'));
|
useAlert(t('MFA_SETTINGS.REGENERATE.SUCCESS'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(t('MFA_SETTINGS.REGENERATE.ERROR'));
|
useAlert(t('MFA_SETTINGS.REGENERATE.ERROR'));
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ export const BUS_EVENTS = {
|
|||||||
NEW_CONVERSATION_MODAL: 'newConversationModal',
|
NEW_CONVERSATION_MODAL: 'newConversationModal',
|
||||||
INSERT_INTO_RICH_EDITOR: 'insertIntoRichEditor',
|
INSERT_INTO_RICH_EDITOR: 'insertIntoRichEditor',
|
||||||
INSERT_INTO_NORMAL_EDITOR: 'insertIntoNormalEditor',
|
INSERT_INTO_NORMAL_EDITOR: 'insertIntoNormalEditor',
|
||||||
|
MFA_STATE_CHANGED: 'MFA_STATE_CHANGED',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ class Mfa::ManagementService
|
|||||||
user.otp_backup_codes.present?
|
user.otp_backup_codes.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def remaining_backup_codes_count
|
||||||
|
Array(user.otp_backup_codes).count { |code| code != 'XXXXXXXX' }
|
||||||
|
end
|
||||||
|
|
||||||
def mfa_enabled?
|
def mfa_enabled?
|
||||||
user.otp_required_for_login?
|
user.otp_required_for_login?
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
json.feature_available Chatwoot.mfa_enabled?
|
json.feature_available Chatwoot.mfa_enabled?
|
||||||
json.enabled @user.mfa_enabled?
|
json.enabled @user.mfa_enabled?
|
||||||
json.backup_codes_generated @user.mfa_service.backup_codes_generated? if Chatwoot.mfa_enabled?
|
if Chatwoot.mfa_enabled?
|
||||||
|
json.backup_codes_generated @user.mfa_service.backup_codes_generated?
|
||||||
|
json.remaining_backup_codes @user.mfa_service.remaining_backup_codes_count
|
||||||
|
end
|
||||||
|
|||||||
@@ -205,6 +205,23 @@ RSpec.describe 'MFA API', type: :request do
|
|||||||
expect(json_response['error']).to include('Invalid')
|
expect(json_response['error']).to include('Invalid')
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
context 'when 2FA is not enabled' do
|
context 'when 2FA is not enabled' do
|
||||||
|
|||||||
Reference in New Issue
Block a user