mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-15 12:16:33 +01:00
* fix: require OTP verification for 2FA re-enrollment and backup code regeneration * fix: require OTP verification for account deletion when 2FA is enabled * refactor: Improve code formatting and readability in TwoFactorController and UserController - Reformatted code in TwoFactorController and UserController for better readability by aligning parameters and breaking long lines. - Updated test cases in deleteUser.spec.js and TwoFactorController.spec.js to enhance clarity by formatting object parameters consistently. * refactor: Consolidate OTP and backup code verification logic in TwoFactorController and UserController - Introduced a new `verifyOTPOrBackupCode` function to streamline the verification process for TOTP tokens and backup codes across multiple controllers. - Updated the `enable2FA`, `disable2FA`, and `deleteUserController` methods to utilize the new verification function, enhancing code reusability and readability. - Adjusted related tests to reflect the changes in verification logic, ensuring consistent behavior across different scenarios. - Improved error handling and response messages for verification failures, providing clearer feedback to users. * chore: linting * refactor: Update BackupCodesItem component to enhance OTP verification logic - Consolidated OTP input handling by moving the 2FA verification UI logic to a more consistent location within the component. - Improved the state management for OTP readiness, ensuring the regenerate button is only enabled when the OTP is ready. - Cleaned up imports by removing redundant type imports, enhancing code clarity and maintainability. * chore: lint * fix: stage 2FA re-enrollment in pending fields to prevent disarmament window enable2FA now writes to pendingTotpSecret/pendingBackupCodes instead of overwriting the live fields. confirm2FA performs the atomic swap only after the new TOTP code is verified. If the user abandons mid-flow, their existing 2FA remains active and intact.
264 lines
8.9 KiB
JavaScript
264 lines
8.9 KiB
JavaScript
const mockGetUserById = jest.fn();
|
|
const mockUpdateUser = jest.fn();
|
|
const mockVerifyOTPOrBackupCode = jest.fn();
|
|
const mockGenerateTOTPSecret = jest.fn();
|
|
const mockGenerateBackupCodes = jest.fn();
|
|
const mockEncryptV3 = jest.fn();
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
encryptV3: (...args) => mockEncryptV3(...args),
|
|
logger: { error: jest.fn() },
|
|
}));
|
|
|
|
jest.mock('~/server/services/twoFactorService', () => ({
|
|
verifyOTPOrBackupCode: (...args) => mockVerifyOTPOrBackupCode(...args),
|
|
generateBackupCodes: (...args) => mockGenerateBackupCodes(...args),
|
|
generateTOTPSecret: (...args) => mockGenerateTOTPSecret(...args),
|
|
verifyBackupCode: jest.fn(),
|
|
getTOTPSecret: jest.fn(),
|
|
verifyTOTP: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/models', () => ({
|
|
getUserById: (...args) => mockGetUserById(...args),
|
|
updateUser: (...args) => mockUpdateUser(...args),
|
|
}));
|
|
|
|
const { enable2FA, regenerateBackupCodes } = require('~/server/controllers/TwoFactorController');
|
|
|
|
function createRes() {
|
|
const res = {};
|
|
res.status = jest.fn().mockReturnValue(res);
|
|
res.json = jest.fn().mockReturnValue(res);
|
|
return res;
|
|
}
|
|
|
|
const PLAIN_CODES = ['code1', 'code2', 'code3'];
|
|
const CODE_OBJECTS = [
|
|
{ codeHash: 'h1', used: false, usedAt: null },
|
|
{ codeHash: 'h2', used: false, usedAt: null },
|
|
{ codeHash: 'h3', used: false, usedAt: null },
|
|
];
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockGenerateTOTPSecret.mockReturnValue('NEWSECRET');
|
|
mockGenerateBackupCodes.mockResolvedValue({ plainCodes: PLAIN_CODES, codeObjects: CODE_OBJECTS });
|
|
mockEncryptV3.mockReturnValue('encrypted-secret');
|
|
});
|
|
|
|
describe('enable2FA', () => {
|
|
it('allows first-time setup without token — writes to pending fields', async () => {
|
|
const req = { user: { id: 'user1' }, body: {} };
|
|
const res = createRes();
|
|
mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: false, email: 'a@b.com' });
|
|
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
|
|
|
|
await enable2FA(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ otpauthUrl: expect.any(String), backupCodes: PLAIN_CODES }),
|
|
);
|
|
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
|
|
const updateCall = mockUpdateUser.mock.calls[0][1];
|
|
expect(updateCall).toHaveProperty('pendingTotpSecret', 'encrypted-secret');
|
|
expect(updateCall).toHaveProperty('pendingBackupCodes', CODE_OBJECTS);
|
|
expect(updateCall).not.toHaveProperty('twoFactorEnabled');
|
|
expect(updateCall).not.toHaveProperty('totpSecret');
|
|
expect(updateCall).not.toHaveProperty('backupCodes');
|
|
});
|
|
|
|
it('re-enrollment writes to pending fields, leaving live 2FA intact', async () => {
|
|
const req = { user: { id: 'user1' }, body: { token: '123456' } };
|
|
const res = createRes();
|
|
const existingUser = {
|
|
_id: 'user1',
|
|
twoFactorEnabled: true,
|
|
totpSecret: 'enc-secret',
|
|
email: 'a@b.com',
|
|
};
|
|
mockGetUserById.mockResolvedValue(existingUser);
|
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
|
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
|
|
|
|
await enable2FA(req, res);
|
|
|
|
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
|
user: existingUser,
|
|
token: '123456',
|
|
backupCode: undefined,
|
|
persistBackupUse: false,
|
|
});
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
const updateCall = mockUpdateUser.mock.calls[0][1];
|
|
expect(updateCall).toHaveProperty('pendingTotpSecret', 'encrypted-secret');
|
|
expect(updateCall).toHaveProperty('pendingBackupCodes', CODE_OBJECTS);
|
|
expect(updateCall).not.toHaveProperty('twoFactorEnabled');
|
|
expect(updateCall).not.toHaveProperty('totpSecret');
|
|
});
|
|
|
|
it('allows re-enrollment with valid backup code (persistBackupUse: false)', async () => {
|
|
const req = { user: { id: 'user1' }, body: { backupCode: 'backup123' } };
|
|
const res = createRes();
|
|
const existingUser = {
|
|
_id: 'user1',
|
|
twoFactorEnabled: true,
|
|
totpSecret: 'enc-secret',
|
|
email: 'a@b.com',
|
|
};
|
|
mockGetUserById.mockResolvedValue(existingUser);
|
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
|
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
|
|
|
|
await enable2FA(req, res);
|
|
|
|
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith(
|
|
expect.objectContaining({ persistBackupUse: false }),
|
|
);
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
});
|
|
|
|
it('returns error when no token provided and 2FA is enabled', async () => {
|
|
const req = { user: { id: 'user1' }, body: {} };
|
|
const res = createRes();
|
|
mockGetUserById.mockResolvedValue({
|
|
_id: 'user1',
|
|
twoFactorEnabled: true,
|
|
totpSecret: 'enc-secret',
|
|
});
|
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
|
|
|
|
await enable2FA(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(mockUpdateUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 401 when invalid token provided and 2FA is enabled', async () => {
|
|
const req = { user: { id: 'user1' }, body: { token: 'wrong' } };
|
|
const res = createRes();
|
|
mockGetUserById.mockResolvedValue({
|
|
_id: 'user1',
|
|
twoFactorEnabled: true,
|
|
totpSecret: 'enc-secret',
|
|
});
|
|
mockVerifyOTPOrBackupCode.mockResolvedValue({
|
|
verified: false,
|
|
status: 401,
|
|
message: 'Invalid token or backup code',
|
|
});
|
|
|
|
await enable2FA(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
|
|
expect(mockUpdateUser).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('regenerateBackupCodes', () => {
|
|
it('returns 404 when user not found', async () => {
|
|
const req = { user: { id: 'user1' }, body: {} };
|
|
const res = createRes();
|
|
mockGetUserById.mockResolvedValue(null);
|
|
|
|
await regenerateBackupCodes(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(404);
|
|
expect(res.json).toHaveBeenCalledWith({ message: 'User not found' });
|
|
});
|
|
|
|
it('requires OTP when 2FA is enabled', async () => {
|
|
const req = { user: { id: 'user1' }, body: { token: '123456' } };
|
|
const res = createRes();
|
|
mockGetUserById.mockResolvedValue({
|
|
_id: 'user1',
|
|
twoFactorEnabled: true,
|
|
totpSecret: 'enc-secret',
|
|
});
|
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
|
mockUpdateUser.mockResolvedValue({});
|
|
|
|
await regenerateBackupCodes(req, res);
|
|
|
|
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalled();
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
backupCodes: PLAIN_CODES,
|
|
backupCodesHash: CODE_OBJECTS,
|
|
});
|
|
});
|
|
|
|
it('returns error when no token provided and 2FA is enabled', async () => {
|
|
const req = { user: { id: 'user1' }, body: {} };
|
|
const res = createRes();
|
|
mockGetUserById.mockResolvedValue({
|
|
_id: 'user1',
|
|
twoFactorEnabled: true,
|
|
totpSecret: 'enc-secret',
|
|
});
|
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
|
|
|
|
await regenerateBackupCodes(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
});
|
|
|
|
it('returns 401 when invalid token provided and 2FA is enabled', async () => {
|
|
const req = { user: { id: 'user1' }, body: { token: 'wrong' } };
|
|
const res = createRes();
|
|
mockGetUserById.mockResolvedValue({
|
|
_id: 'user1',
|
|
twoFactorEnabled: true,
|
|
totpSecret: 'enc-secret',
|
|
});
|
|
mockVerifyOTPOrBackupCode.mockResolvedValue({
|
|
verified: false,
|
|
status: 401,
|
|
message: 'Invalid token or backup code',
|
|
});
|
|
|
|
await regenerateBackupCodes(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
|
|
});
|
|
|
|
it('includes backupCodesHash in response', async () => {
|
|
const req = { user: { id: 'user1' }, body: { token: '123456' } };
|
|
const res = createRes();
|
|
mockGetUserById.mockResolvedValue({
|
|
_id: 'user1',
|
|
twoFactorEnabled: true,
|
|
totpSecret: 'enc-secret',
|
|
});
|
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
|
mockUpdateUser.mockResolvedValue({});
|
|
|
|
await regenerateBackupCodes(req, res);
|
|
|
|
const responseBody = res.json.mock.calls[0][0];
|
|
expect(responseBody).toHaveProperty('backupCodesHash', CODE_OBJECTS);
|
|
expect(responseBody).toHaveProperty('backupCodes', PLAIN_CODES);
|
|
});
|
|
|
|
it('allows regeneration without token when 2FA is not enabled', async () => {
|
|
const req = { user: { id: 'user1' }, body: {} };
|
|
const res = createRes();
|
|
mockGetUserById.mockResolvedValue({
|
|
_id: 'user1',
|
|
twoFactorEnabled: false,
|
|
});
|
|
mockUpdateUser.mockResolvedValue({});
|
|
|
|
await regenerateBackupCodes(req, res);
|
|
|
|
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
backupCodes: PLAIN_CODES,
|
|
backupCodesHash: CODE_OBJECTS,
|
|
});
|
|
});
|
|
});
|