fix(auth): persist MFA sessions across browser restarts (#15379)
MFA-authenticated users now remain signed in across browser restarts, matching the session lifetime of password-authenticated users. ## Closes - [CW-7898](https://linear.app/chatwoot/issue/CW-7898/mfa-users-are-logged-out-when-the-browser-session-ends) ## How to reproduce 1. Enable MFA for a user. 2. Sign in and complete OTP or backup-code verification. 3. End the browser session, then reopen the browser and return to Chatwoot. 4. Before this change, the user is sent back to the login page. ## Root cause and evidence Before this change, successful MFA verification wrote `cw_d_session_info` directly through `document.cookie` without an `Expires` or `Max-Age` attribute. This makes it a session cookie: - [MFA cookie creation on the base commit](a4eae9710a/app/javascript/dashboard/components/auth/MfaVerification.vue (L70-L87)) - [MDN session-cookie behavior](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie) - [Chromium cookie persistence documentation](https://chromium.googlesource.com/chromium/src/+/master/net/cookies/README.md) Password login already uses `setAuthCredentials`, which derives a persistent cookie expiry from the authentication response: - [Existing credential helper](a4eae9710a/app/javascript/dashboard/store/utils/api.js (L28-L36)) - [Configured two-month token lifetime](a4eae9710a/config/initializers/devise_token_auth.rb (L8-L10)) This change routes successful MFA verification through the same credential helper and existing verified event. The regression test asserts that MFA credentials use this shared persistence path. Session-cookie restoration is browser and profile dependent. Chromium may persist session cookies when restoring a previous browsing session, so the problem can be masked on some restarts. This PR does not rely on a desktop-versus-mobile distinction. ## How to verify 1. On `develop`, complete an MFA login. 2. Inspect `cw_d_session_info` in browser developer tools. Its expiry is shown as `Session`. 3. Repeat on this branch. 4. Confirm that `cw_d_session_info` has a concrete expiry derived from the authentication response instead of `Session`.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import { setAuthCredentials } from 'dashboard/store/utils/api';
|
||||
import MfaVerification from './MfaVerification.vue';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
vi.mock('dashboard/store/utils/api', () => ({
|
||||
parseAPIErrorResponse: vi.fn(),
|
||||
setAuthCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/useAccount', async () => {
|
||||
const { ref } = await import('vue');
|
||||
return { useAccount: () => ({ isOnChatwootCloud: ref(true) }) };
|
||||
});
|
||||
|
||||
describe('MfaVerification', () => {
|
||||
it('stores successful MFA credentials using the persistent auth flow', async () => {
|
||||
const response = {
|
||||
data: { data: { id: 1 } },
|
||||
headers: {
|
||||
'access-token': 'token',
|
||||
'token-type': 'Bearer',
|
||||
client: 'client',
|
||||
expiry: '1789084800',
|
||||
uid: 'user@example.com',
|
||||
},
|
||||
};
|
||||
axios.post.mockResolvedValue(response);
|
||||
|
||||
const wrapper = shallowMount(MfaVerification, {
|
||||
props: { mfaToken: 'mfa-token' },
|
||||
global: { mocks: { $t: key => key } },
|
||||
});
|
||||
|
||||
const otpInputs = wrapper.findAll('input[inputmode="numeric"]');
|
||||
await otpInputs[0].setValue('1');
|
||||
await otpInputs[1].setValue('2');
|
||||
await otpInputs[2].setValue('3');
|
||||
await otpInputs[3].setValue('4');
|
||||
await otpInputs[4].setValue('5');
|
||||
await otpInputs[5].setValue('6');
|
||||
await flushPromises();
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith('/auth/sign_in', {
|
||||
mfa_token: 'mfa-token',
|
||||
otp_code: '123456',
|
||||
});
|
||||
expect(setAuthCredentials).toHaveBeenCalledWith(response);
|
||||
expect(wrapper.emitted('verified')).toEqual([[response.data]]);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,10 @@ import axios from 'axios';
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { handleOtpPaste } from 'shared/helpers/clipboard';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import {
|
||||
parseAPIErrorResponse,
|
||||
setAuthCredentials,
|
||||
} from 'dashboard/store/utils/api';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
@@ -68,26 +71,8 @@ const handleVerification = async () => {
|
||||
}
|
||||
|
||||
const response = await axios.post('/auth/sign_in', payload);
|
||||
|
||||
// Set auth credentials and redirect
|
||||
if (response.data && response.headers) {
|
||||
// Store auth credentials in cookies
|
||||
const authData = {
|
||||
'access-token': response.headers['access-token'],
|
||||
'token-type': response.headers['token-type'],
|
||||
client: response.headers.client,
|
||||
expiry: response.headers.expiry,
|
||||
uid: response.headers.uid,
|
||||
};
|
||||
|
||||
// Store in cookies for auth
|
||||
document.cookie = `cw_d_session_info=${encodeURIComponent(JSON.stringify(authData))}; path=/; SameSite=Lax`;
|
||||
|
||||
// Redirect to dashboard
|
||||
window.location.href = '/app/';
|
||||
} else {
|
||||
emit('verified', response.data);
|
||||
}
|
||||
setAuthCredentials(response);
|
||||
emit('verified', response.data);
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
parseAPIErrorResponse(error) || t('MFA_VERIFICATION.VERIFICATION_FAILED');
|
||||
|
||||
Reference in New Issue
Block a user