mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-15 04:06:33 +01:00
🔑 fix: Require OTP Verification for 2FA Re-Enrollment and Backup Code Regeneration (#12223)
* 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.
This commit is contained in:
parent
189cdf581d
commit
71a3b48504
14 changed files with 927 additions and 104 deletions
|
|
@ -1,5 +1,6 @@
|
|||
const { encryptV3, logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
verifyOTPOrBackupCode,
|
||||
generateBackupCodes,
|
||||
generateTOTPSecret,
|
||||
verifyBackupCode,
|
||||
|
|
@ -13,24 +14,42 @@ const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');
|
|||
/**
|
||||
* Enable 2FA for the user by generating a new TOTP secret and backup codes.
|
||||
* The secret is encrypted and stored, and 2FA is marked as disabled until confirmed.
|
||||
* If 2FA is already enabled, requires OTP or backup code verification to re-enroll.
|
||||
*/
|
||||
const enable2FA = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const existingUser = await getUserById(
|
||||
userId,
|
||||
'+totpSecret +backupCodes _id twoFactorEnabled email',
|
||||
);
|
||||
|
||||
if (existingUser && existingUser.twoFactorEnabled) {
|
||||
const { token, backupCode } = req.body;
|
||||
const result = await verifyOTPOrBackupCode({
|
||||
user: existingUser,
|
||||
token,
|
||||
backupCode,
|
||||
persistBackupUse: false,
|
||||
});
|
||||
|
||||
if (!result.verified) {
|
||||
const msg = result.message ?? 'TOTP token or backup code is required to re-enroll 2FA';
|
||||
return res.status(result.status ?? 400).json({ message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
const secret = generateTOTPSecret();
|
||||
const { plainCodes, codeObjects } = await generateBackupCodes();
|
||||
|
||||
// Encrypt the secret with v3 encryption before saving.
|
||||
const encryptedSecret = encryptV3(secret);
|
||||
|
||||
// Update the user record: store the secret & backup codes and set twoFactorEnabled to false.
|
||||
const user = await updateUser(userId, {
|
||||
totpSecret: encryptedSecret,
|
||||
backupCodes: codeObjects,
|
||||
twoFactorEnabled: false,
|
||||
pendingTotpSecret: encryptedSecret,
|
||||
pendingBackupCodes: codeObjects,
|
||||
});
|
||||
|
||||
const otpauthUrl = `otpauth://totp/${safeAppTitle}:${user.email}?secret=${secret}&issuer=${safeAppTitle}`;
|
||||
const email = user.email || (existingUser && existingUser.email) || '';
|
||||
const otpauthUrl = `otpauth://totp/${safeAppTitle}:${email}?secret=${secret}&issuer=${safeAppTitle}`;
|
||||
|
||||
return res.status(200).json({ otpauthUrl, backupCodes: plainCodes });
|
||||
} catch (err) {
|
||||
|
|
@ -46,13 +65,14 @@ const verify2FA = async (req, res) => {
|
|||
try {
|
||||
const userId = req.user.id;
|
||||
const { token, backupCode } = req.body;
|
||||
const user = await getUserById(userId, '_id totpSecret backupCodes');
|
||||
const user = await getUserById(userId, '+totpSecret +pendingTotpSecret +backupCodes _id');
|
||||
const secretSource = user?.pendingTotpSecret ?? user?.totpSecret;
|
||||
|
||||
if (!user || !user.totpSecret) {
|
||||
if (!user || !secretSource) {
|
||||
return res.status(400).json({ message: '2FA not initiated' });
|
||||
}
|
||||
|
||||
const secret = await getTOTPSecret(user.totpSecret);
|
||||
const secret = await getTOTPSecret(secretSource);
|
||||
let isVerified = false;
|
||||
|
||||
if (token) {
|
||||
|
|
@ -78,15 +98,28 @@ const confirm2FA = async (req, res) => {
|
|||
try {
|
||||
const userId = req.user.id;
|
||||
const { token } = req.body;
|
||||
const user = await getUserById(userId, '_id totpSecret');
|
||||
const user = await getUserById(
|
||||
userId,
|
||||
'+totpSecret +pendingTotpSecret +pendingBackupCodes _id',
|
||||
);
|
||||
const secretSource = user?.pendingTotpSecret ?? user?.totpSecret;
|
||||
|
||||
if (!user || !user.totpSecret) {
|
||||
if (!user || !secretSource) {
|
||||
return res.status(400).json({ message: '2FA not initiated' });
|
||||
}
|
||||
|
||||
const secret = await getTOTPSecret(user.totpSecret);
|
||||
const secret = await getTOTPSecret(secretSource);
|
||||
if (await verifyTOTP(secret, token)) {
|
||||
await updateUser(userId, { twoFactorEnabled: true });
|
||||
const update = {
|
||||
totpSecret: user.pendingTotpSecret ?? user.totpSecret,
|
||||
twoFactorEnabled: true,
|
||||
pendingTotpSecret: null,
|
||||
pendingBackupCodes: [],
|
||||
};
|
||||
if (user.pendingBackupCodes?.length) {
|
||||
update.backupCodes = user.pendingBackupCodes;
|
||||
}
|
||||
await updateUser(userId, update);
|
||||
return res.status(200).json();
|
||||
}
|
||||
return res.status(400).json({ message: 'Invalid token.' });
|
||||
|
|
@ -104,31 +137,27 @@ const disable2FA = async (req, res) => {
|
|||
try {
|
||||
const userId = req.user.id;
|
||||
const { token, backupCode } = req.body;
|
||||
const user = await getUserById(userId, '_id totpSecret backupCodes');
|
||||
const user = await getUserById(userId, '+totpSecret +backupCodes _id twoFactorEnabled');
|
||||
|
||||
if (!user || !user.totpSecret) {
|
||||
return res.status(400).json({ message: '2FA is not setup for this user' });
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
const secret = await getTOTPSecret(user.totpSecret);
|
||||
let isVerified = false;
|
||||
const result = await verifyOTPOrBackupCode({ user, token, backupCode });
|
||||
|
||||
if (token) {
|
||||
isVerified = await verifyTOTP(secret, token);
|
||||
} else if (backupCode) {
|
||||
isVerified = await verifyBackupCode({ user, backupCode });
|
||||
} else {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: 'Either token or backup code is required to disable 2FA' });
|
||||
}
|
||||
|
||||
if (!isVerified) {
|
||||
return res.status(401).json({ message: 'Invalid token or backup code' });
|
||||
if (!result.verified) {
|
||||
const msg = result.message ?? 'Either token or backup code is required to disable 2FA';
|
||||
return res.status(result.status ?? 400).json({ message: msg });
|
||||
}
|
||||
}
|
||||
await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false });
|
||||
await updateUser(userId, {
|
||||
totpSecret: null,
|
||||
backupCodes: [],
|
||||
twoFactorEnabled: false,
|
||||
pendingTotpSecret: null,
|
||||
pendingBackupCodes: [],
|
||||
});
|
||||
return res.status(200).json();
|
||||
} catch (err) {
|
||||
logger.error('[disable2FA]', err);
|
||||
|
|
@ -138,10 +167,28 @@ const disable2FA = async (req, res) => {
|
|||
|
||||
/**
|
||||
* Regenerate backup codes for the user.
|
||||
* Requires OTP or backup code verification if 2FA is already enabled.
|
||||
*/
|
||||
const regenerateBackupCodes = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const user = await getUserById(userId, '+totpSecret +backupCodes _id twoFactorEnabled');
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
const { token, backupCode } = req.body;
|
||||
const result = await verifyOTPOrBackupCode({ user, token, backupCode });
|
||||
|
||||
if (!result.verified) {
|
||||
const msg =
|
||||
result.message ?? 'TOTP token or backup code is required to regenerate backup codes';
|
||||
return res.status(result.status ?? 400).json({ message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
const { plainCodes, codeObjects } = await generateBackupCodes();
|
||||
await updateUser(userId, { backupCodes: codeObjects });
|
||||
return res.status(200).json({
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const {
|
|||
deleteMessages,
|
||||
deletePresets,
|
||||
deleteUserKey,
|
||||
getUserById,
|
||||
deleteConvos,
|
||||
deleteFiles,
|
||||
updateUser,
|
||||
|
|
@ -34,6 +35,7 @@ const {
|
|||
User,
|
||||
} = require('~/db/models');
|
||||
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
||||
const { verifyOTPOrBackupCode } = require('~/server/services/twoFactorService');
|
||||
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
||||
const { getMCPManager, getFlowStateManager, getMCPServersRegistry } = require('~/config');
|
||||
const { invalidateCachedTools } = require('~/server/services/Config/getCachedTools');
|
||||
|
|
@ -241,6 +243,22 @@ const deleteUserController = async (req, res) => {
|
|||
const { user } = req;
|
||||
|
||||
try {
|
||||
const existingUser = await getUserById(
|
||||
user.id,
|
||||
'+totpSecret +backupCodes _id twoFactorEnabled',
|
||||
);
|
||||
if (existingUser && existingUser.twoFactorEnabled) {
|
||||
const { token, backupCode } = req.body;
|
||||
const result = await verifyOTPOrBackupCode({ user: existingUser, token, backupCode });
|
||||
|
||||
if (!result.verified) {
|
||||
const msg =
|
||||
result.message ??
|
||||
'TOTP token or backup code is required to delete account with 2FA enabled';
|
||||
return res.status(result.status ?? 400).json({ message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
await deleteMessages({ user: user.id }); // delete user messages
|
||||
await deleteAllUserSessions({ userId: user.id }); // delete user sessions
|
||||
await Transaction.deleteMany({ user: user.id }); // delete user transactions
|
||||
|
|
|
|||
264
api/server/controllers/__tests__/TwoFactorController.spec.js
Normal file
264
api/server/controllers/__tests__/TwoFactorController.spec.js
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
302
api/server/controllers/__tests__/deleteUser.spec.js
Normal file
302
api/server/controllers/__tests__/deleteUser.spec.js
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
const mockGetUserById = jest.fn();
|
||||
const mockDeleteMessages = jest.fn();
|
||||
const mockDeleteAllUserSessions = jest.fn();
|
||||
const mockDeleteUserById = jest.fn();
|
||||
const mockDeleteAllSharedLinks = jest.fn();
|
||||
const mockDeletePresets = jest.fn();
|
||||
const mockDeleteUserKey = jest.fn();
|
||||
const mockDeleteConvos = jest.fn();
|
||||
const mockDeleteFiles = jest.fn();
|
||||
const mockGetFiles = jest.fn();
|
||||
const mockUpdateUserPlugins = jest.fn();
|
||||
const mockUpdateUser = jest.fn();
|
||||
const mockFindToken = jest.fn();
|
||||
const mockVerifyOTPOrBackupCode = jest.fn();
|
||||
const mockDeleteUserPluginAuth = jest.fn();
|
||||
const mockProcessDeleteRequest = jest.fn();
|
||||
const mockDeleteToolCalls = jest.fn();
|
||||
const mockDeleteUserAgents = jest.fn();
|
||||
const mockDeleteUserPrompts = jest.fn();
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), info: jest.fn() },
|
||||
webSearchKeys: [],
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
Tools: {},
|
||||
CacheKeys: {},
|
||||
Constants: { mcp_delimiter: '::', mcp_prefix: 'mcp_' },
|
||||
FileSources: {},
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
MCPOAuthHandler: {},
|
||||
MCPTokenStorage: {},
|
||||
normalizeHttpError: jest.fn(),
|
||||
extractWebSearchEnvVars: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
deleteAllUserSessions: (...args) => mockDeleteAllUserSessions(...args),
|
||||
deleteAllSharedLinks: (...args) => mockDeleteAllSharedLinks(...args),
|
||||
updateUserPlugins: (...args) => mockUpdateUserPlugins(...args),
|
||||
deleteUserById: (...args) => mockDeleteUserById(...args),
|
||||
deleteMessages: (...args) => mockDeleteMessages(...args),
|
||||
deletePresets: (...args) => mockDeletePresets(...args),
|
||||
deleteUserKey: (...args) => mockDeleteUserKey(...args),
|
||||
getUserById: (...args) => mockGetUserById(...args),
|
||||
deleteConvos: (...args) => mockDeleteConvos(...args),
|
||||
deleteFiles: (...args) => mockDeleteFiles(...args),
|
||||
updateUser: (...args) => mockUpdateUser(...args),
|
||||
findToken: (...args) => mockFindToken(...args),
|
||||
getFiles: (...args) => mockGetFiles(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/db/models', () => ({
|
||||
ConversationTag: { deleteMany: jest.fn() },
|
||||
AgentApiKey: { deleteMany: jest.fn() },
|
||||
Transaction: { deleteMany: jest.fn() },
|
||||
MemoryEntry: { deleteMany: jest.fn() },
|
||||
Assistant: { deleteMany: jest.fn() },
|
||||
AclEntry: { deleteMany: jest.fn() },
|
||||
Balance: { deleteMany: jest.fn() },
|
||||
Action: { deleteMany: jest.fn() },
|
||||
Group: { updateMany: jest.fn() },
|
||||
Token: { deleteMany: jest.fn() },
|
||||
User: {},
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/PluginService', () => ({
|
||||
updateUserPluginAuth: jest.fn(),
|
||||
deleteUserPluginAuth: (...args) => mockDeleteUserPluginAuth(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/twoFactorService', () => ({
|
||||
verifyOTPOrBackupCode: (...args) => mockVerifyOTPOrBackupCode(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/AuthService', () => ({
|
||||
verifyEmail: jest.fn(),
|
||||
resendVerificationEmail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/config', () => ({
|
||||
getMCPManager: jest.fn(),
|
||||
getFlowStateManager: jest.fn(),
|
||||
getMCPServersRegistry: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config/getCachedTools', () => ({
|
||||
invalidateCachedTools: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/S3/crud', () => ({
|
||||
needsRefresh: jest.fn(),
|
||||
getNewS3URL: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
processDeleteRequest: (...args) => mockProcessDeleteRequest(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models/ToolCall', () => ({
|
||||
deleteToolCalls: (...args) => mockDeleteToolCalls(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/models/Prompt', () => ({
|
||||
deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/models/Agent', () => ({
|
||||
deleteUserAgents: (...args) => mockDeleteUserAgents(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
getLogStores: jest.fn(),
|
||||
}));
|
||||
|
||||
const { deleteUserController } = require('~/server/controllers/UserController');
|
||||
|
||||
function createRes() {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
res.send = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
function stubDeletionMocks() {
|
||||
mockDeleteMessages.mockResolvedValue();
|
||||
mockDeleteAllUserSessions.mockResolvedValue();
|
||||
mockDeleteUserKey.mockResolvedValue();
|
||||
mockDeletePresets.mockResolvedValue();
|
||||
mockDeleteConvos.mockResolvedValue();
|
||||
mockDeleteUserPluginAuth.mockResolvedValue();
|
||||
mockDeleteUserById.mockResolvedValue();
|
||||
mockDeleteAllSharedLinks.mockResolvedValue();
|
||||
mockGetFiles.mockResolvedValue([]);
|
||||
mockProcessDeleteRequest.mockResolvedValue();
|
||||
mockDeleteFiles.mockResolvedValue();
|
||||
mockDeleteToolCalls.mockResolvedValue();
|
||||
mockDeleteUserAgents.mockResolvedValue();
|
||||
mockDeleteUserPrompts.mockResolvedValue();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
stubDeletionMocks();
|
||||
});
|
||||
|
||||
describe('deleteUserController - 2FA enforcement', () => {
|
||||
it('proceeds with deletion when 2FA is not enabled', async () => {
|
||||
const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: false });
|
||||
|
||||
await deleteUserController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
||||
expect(mockDeleteMessages).toHaveBeenCalled();
|
||||
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proceeds with deletion when user has no 2FA record', async () => {
|
||||
const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue(null);
|
||||
|
||||
await deleteUserController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
||||
});
|
||||
|
||||
it('returns error when 2FA is enabled and verification fails with 400', async () => {
|
||||
const req = { user: { id: 'user1', _id: 'user1' }, body: {} };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue({
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
});
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
|
||||
|
||||
await deleteUserController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(mockDeleteMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when 2FA is enabled and invalid TOTP token provided', async () => {
|
||||
const existingUser = {
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
};
|
||||
const req = { user: { id: 'user1', _id: 'user1' }, body: { token: 'wrong' } };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue(existingUser);
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({
|
||||
verified: false,
|
||||
status: 401,
|
||||
message: 'Invalid token or backup code',
|
||||
});
|
||||
|
||||
await deleteUserController(req, res);
|
||||
|
||||
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
||||
user: existingUser,
|
||||
token: 'wrong',
|
||||
backupCode: undefined,
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
|
||||
expect(mockDeleteMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when 2FA is enabled and invalid backup code provided', async () => {
|
||||
const existingUser = {
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
backupCodes: [],
|
||||
};
|
||||
const req = { user: { id: 'user1', _id: 'user1' }, body: { backupCode: 'bad-code' } };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue(existingUser);
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({
|
||||
verified: false,
|
||||
status: 401,
|
||||
message: 'Invalid token or backup code',
|
||||
});
|
||||
|
||||
await deleteUserController(req, res);
|
||||
|
||||
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
||||
user: existingUser,
|
||||
token: undefined,
|
||||
backupCode: 'bad-code',
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(mockDeleteMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes account when valid TOTP token provided with 2FA enabled', async () => {
|
||||
const existingUser = {
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
};
|
||||
const req = {
|
||||
user: { id: 'user1', _id: 'user1', email: 'a@b.com' },
|
||||
body: { token: '123456' },
|
||||
};
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue(existingUser);
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
||||
|
||||
await deleteUserController(req, res);
|
||||
|
||||
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
||||
user: existingUser,
|
||||
token: '123456',
|
||||
backupCode: undefined,
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
||||
expect(mockDeleteMessages).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes account when valid backup code provided with 2FA enabled', async () => {
|
||||
const existingUser = {
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
backupCodes: [{ codeHash: 'h1', used: false }],
|
||||
};
|
||||
const req = {
|
||||
user: { id: 'user1', _id: 'user1', email: 'a@b.com' },
|
||||
body: { backupCode: 'valid-code' },
|
||||
};
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue(existingUser);
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
||||
|
||||
await deleteUserController(req, res);
|
||||
|
||||
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
||||
user: existingUser,
|
||||
token: undefined,
|
||||
backupCode: 'valid-code',
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
||||
expect(mockDeleteMessages).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -63,7 +63,7 @@ router.post(
|
|||
resetPasswordController,
|
||||
);
|
||||
|
||||
router.get('/2fa/enable', middleware.requireJwtAuth, enable2FA);
|
||||
router.post('/2fa/enable', middleware.requireJwtAuth, enable2FA);
|
||||
router.post('/2fa/verify', middleware.requireJwtAuth, verify2FA);
|
||||
router.post('/2fa/verify-temp', middleware.checkBan, verify2FAWithTempToken);
|
||||
router.post('/2fa/confirm', middleware.requireJwtAuth, confirm2FA);
|
||||
|
|
|
|||
|
|
@ -153,9 +153,11 @@ const generateBackupCodes = async (count = 10) => {
|
|||
* @param {Object} params
|
||||
* @param {Object} params.user
|
||||
* @param {string} params.backupCode
|
||||
* @param {boolean} [params.persist=true] - Whether to persist the used-mark to the database.
|
||||
* Pass `false` when the caller will immediately overwrite `backupCodes` (e.g. re-enrollment).
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const verifyBackupCode = async ({ user, backupCode }) => {
|
||||
const verifyBackupCode = async ({ user, backupCode, persist = true }) => {
|
||||
if (!backupCode || !user || !Array.isArray(user.backupCodes)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -165,17 +167,50 @@ const verifyBackupCode = async ({ user, backupCode }) => {
|
|||
(codeObj) => codeObj.codeHash === hashedInput && !codeObj.used,
|
||||
);
|
||||
|
||||
if (matchingCode) {
|
||||
if (!matchingCode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (persist) {
|
||||
const updatedBackupCodes = user.backupCodes.map((codeObj) =>
|
||||
codeObj.codeHash === hashedInput && !codeObj.used
|
||||
? { ...codeObj, used: true, usedAt: new Date() }
|
||||
: codeObj,
|
||||
);
|
||||
// Update the user record with the marked backup code.
|
||||
await updateUser(user._id, { backupCodes: updatedBackupCodes });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies a user's identity via TOTP token or backup code.
|
||||
* @param {Object} params
|
||||
* @param {Object} params.user - The user document (must include totpSecret and backupCodes).
|
||||
* @param {string} [params.token] - A 6-digit TOTP token.
|
||||
* @param {string} [params.backupCode] - An 8-character backup code.
|
||||
* @param {boolean} [params.persistBackupUse=true] - Whether to mark the backup code as used in the DB.
|
||||
* @returns {Promise<{ verified: boolean, status?: number, message?: string }>}
|
||||
*/
|
||||
const verifyOTPOrBackupCode = async ({ user, token, backupCode, persistBackupUse = true }) => {
|
||||
if (!token && !backupCode) {
|
||||
return { verified: false, status: 400 };
|
||||
}
|
||||
|
||||
if (token) {
|
||||
const secret = await getTOTPSecret(user.totpSecret);
|
||||
if (!secret) {
|
||||
return { verified: false, status: 400, message: '2FA secret is missing or corrupted' };
|
||||
}
|
||||
const ok = await verifyTOTP(secret, token);
|
||||
return ok
|
||||
? { verified: true }
|
||||
: { verified: false, status: 401, message: 'Invalid token or backup code' };
|
||||
}
|
||||
|
||||
const ok = await verifyBackupCode({ user, backupCode, persist: persistBackupUse });
|
||||
return ok
|
||||
? { verified: true }
|
||||
: { verified: false, status: 401, message: 'Invalid token or backup code' };
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -213,11 +248,12 @@ const generate2FATempToken = (userId) => {
|
|||
};
|
||||
|
||||
module.exports = {
|
||||
generateTOTPSecret,
|
||||
generateTOTP,
|
||||
verifyTOTP,
|
||||
verifyOTPOrBackupCode,
|
||||
generate2FATempToken,
|
||||
generateBackupCodes,
|
||||
generateTOTPSecret,
|
||||
verifyBackupCode,
|
||||
getTOTPSecret,
|
||||
generate2FATempToken,
|
||||
generateTOTP,
|
||||
verifyTOTP,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,23 @@
|
|||
import React, { useState } from 'react';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { TBackupCode, TRegenerateBackupCodesResponse, type TUser } from 'librechat-data-provider';
|
||||
import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp';
|
||||
import type {
|
||||
TRegenerateBackupCodesResponse,
|
||||
TRegenerateBackupCodesRequest,
|
||||
TBackupCode,
|
||||
TUser,
|
||||
} from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
InputOTPSeparator,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
OGDialogContent,
|
||||
OGDialogTitle,
|
||||
OGDialogTrigger,
|
||||
OGDialog,
|
||||
InputOTP,
|
||||
Button,
|
||||
Label,
|
||||
Spinner,
|
||||
|
|
@ -15,7 +26,6 @@ import {
|
|||
} from '@librechat/client';
|
||||
import { useRegenerateBackupCodesMutation } from '~/data-provider';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import store from '~/store';
|
||||
|
||||
const BackupCodesItem: React.FC = () => {
|
||||
|
|
@ -24,25 +34,30 @@ const BackupCodesItem: React.FC = () => {
|
|||
const { showToast } = useToastContext();
|
||||
const setUser = useSetRecoilState(store.user);
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [otpToken, setOtpToken] = useState('');
|
||||
const [useBackup, setUseBackup] = useState(false);
|
||||
|
||||
const { mutate: regenerateBackupCodes, isLoading } = useRegenerateBackupCodesMutation();
|
||||
|
||||
const needs2FA = !!user?.twoFactorEnabled;
|
||||
|
||||
const fetchBackupCodes = (auto: boolean = false) => {
|
||||
regenerateBackupCodes(undefined, {
|
||||
let payload: TRegenerateBackupCodesRequest | undefined;
|
||||
if (needs2FA && otpToken.trim()) {
|
||||
payload = useBackup ? { backupCode: otpToken.trim() } : { token: otpToken.trim() };
|
||||
}
|
||||
|
||||
regenerateBackupCodes(payload, {
|
||||
onSuccess: (data: TRegenerateBackupCodesResponse) => {
|
||||
const newBackupCodes: TBackupCode[] = data.backupCodesHash.map((codeHash) => ({
|
||||
codeHash,
|
||||
used: false,
|
||||
usedAt: null,
|
||||
}));
|
||||
const newBackupCodes: TBackupCode[] = data.backupCodesHash;
|
||||
|
||||
setUser((prev) => ({ ...prev, backupCodes: newBackupCodes }) as TUser);
|
||||
setOtpToken('');
|
||||
showToast({
|
||||
message: localize('com_ui_backup_codes_regenerated'),
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
// Trigger file download only when user explicitly clicks the button.
|
||||
if (!auto && newBackupCodes.length) {
|
||||
const codesString = data.backupCodes.join('\n');
|
||||
const blob = new Blob([codesString], { type: 'text/plain;charset=utf-8' });
|
||||
|
|
@ -66,6 +81,8 @@ const BackupCodesItem: React.FC = () => {
|
|||
fetchBackupCodes(false);
|
||||
};
|
||||
|
||||
const otpReady = !needs2FA || otpToken.length === (useBackup ? 8 : 6);
|
||||
|
||||
return (
|
||||
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -161,10 +178,10 @@ const BackupCodesItem: React.FC = () => {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Button
|
||||
onClick={handleRegenerate}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || !otpReady}
|
||||
variant="default"
|
||||
className="px-8 py-3 transition-all disabled:opacity-50"
|
||||
>
|
||||
|
|
@ -183,7 +200,7 @@ const BackupCodesItem: React.FC = () => {
|
|||
<div className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<Button
|
||||
onClick={handleRegenerate}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || !otpReady}
|
||||
variant="default"
|
||||
className="px-8 py-3 transition-all disabled:opacity-50"
|
||||
>
|
||||
|
|
@ -192,6 +209,59 @@ const BackupCodesItem: React.FC = () => {
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{needs2FA && (
|
||||
<div className="mt-6 space-y-3">
|
||||
<Label className="text-sm font-medium">
|
||||
{localize('com_ui_2fa_verification_required')}
|
||||
</Label>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
value={otpToken}
|
||||
onChange={setOtpToken}
|
||||
maxLength={useBackup ? 8 : 6}
|
||||
pattern={useBackup ? REGEXP_ONLY_DIGITS_AND_CHARS : REGEXP_ONLY_DIGITS}
|
||||
className="gap-2"
|
||||
>
|
||||
{useBackup ? (
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
<InputOTPSlot index={6} />
|
||||
<InputOTPSlot index={7} />
|
||||
</InputOTPGroup>
|
||||
) : (
|
||||
<>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</>
|
||||
)}
|
||||
</InputOTP>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseBackup(!useBackup);
|
||||
setOtpToken('');
|
||||
}}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{useBackup ? localize('com_ui_use_2fa_code') : localize('com_ui_use_backup_code')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</OGDialogContent>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import { LockIcon, Trash } from 'lucide-react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { LockIcon, Trash } from 'lucide-react';
|
||||
import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp';
|
||||
import {
|
||||
Label,
|
||||
Input,
|
||||
Button,
|
||||
Spinner,
|
||||
OGDialog,
|
||||
InputOTPSeparator,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
OGDialogHeader,
|
||||
InputOTPGroup,
|
||||
OGDialogTitle,
|
||||
InputOTPSlot,
|
||||
OGDialog,
|
||||
InputOTP,
|
||||
Spinner,
|
||||
Button,
|
||||
Label,
|
||||
Input,
|
||||
} from '@librechat/client';
|
||||
import type { TDeleteUserRequest } from 'librechat-data-provider';
|
||||
import { useDeleteUserMutation } from '~/data-provider';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { LocalizeFunction } from '~/common';
|
||||
|
|
@ -21,16 +27,27 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
|
|||
const localize = useLocalize();
|
||||
const { user, logout } = useAuthContext();
|
||||
const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUserMutation({
|
||||
onMutate: () => logout(),
|
||||
onSuccess: () => logout(),
|
||||
});
|
||||
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [isLocked, setIsLocked] = useState(true);
|
||||
const [otpToken, setOtpToken] = useState('');
|
||||
const [useBackup, setUseBackup] = useState(false);
|
||||
|
||||
const needs2FA = !!user?.twoFactorEnabled;
|
||||
|
||||
const handleDeleteUser = () => {
|
||||
if (!isLocked) {
|
||||
deleteUser(undefined);
|
||||
if (isLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: TDeleteUserRequest | undefined;
|
||||
if (needs2FA && otpToken.trim()) {
|
||||
payload = useBackup ? { backupCode: otpToken.trim() } : { token: otpToken.trim() };
|
||||
}
|
||||
|
||||
deleteUser(payload);
|
||||
};
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
|
|
@ -42,6 +59,8 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
|
|||
[user?.email],
|
||||
);
|
||||
|
||||
const otpReady = !needs2FA || otpToken.length === (useBackup ? 8 : 6);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
|
||||
|
|
@ -79,7 +98,60 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
|
|||
(e) => handleInputChange(e.target.value),
|
||||
)}
|
||||
</div>
|
||||
{renderDeleteButton(handleDeleteUser, isDeleting, isLocked, localize)}
|
||||
{needs2FA && (
|
||||
<div className="mb-4 space-y-3">
|
||||
<Label className="text-sm font-medium">
|
||||
{localize('com_ui_2fa_verification_required')}
|
||||
</Label>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
value={otpToken}
|
||||
onChange={setOtpToken}
|
||||
maxLength={useBackup ? 8 : 6}
|
||||
pattern={useBackup ? REGEXP_ONLY_DIGITS_AND_CHARS : REGEXP_ONLY_DIGITS}
|
||||
className="gap-2"
|
||||
>
|
||||
{useBackup ? (
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
<InputOTPSlot index={6} />
|
||||
<InputOTPSlot index={7} />
|
||||
</InputOTPGroup>
|
||||
) : (
|
||||
<>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</>
|
||||
)}
|
||||
</InputOTP>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseBackup(!useBackup);
|
||||
setOtpToken('');
|
||||
}}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{useBackup ? localize('com_ui_use_2fa_code') : localize('com_ui_use_backup_code')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{renderDeleteButton(handleDeleteUser, isDeleting, isLocked || !otpReady, localize)}
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
|
|
|
|||
|
|
@ -68,14 +68,14 @@ export const useRefreshTokenMutation = (
|
|||
|
||||
/* User */
|
||||
export const useDeleteUserMutation = (
|
||||
options?: t.MutationOptions<unknown, undefined>,
|
||||
): UseMutationResult<unknown, unknown, undefined, unknown> => {
|
||||
options?: t.MutationOptions<unknown, t.TDeleteUserRequest | undefined>,
|
||||
): UseMutationResult<unknown, unknown, t.TDeleteUserRequest | undefined, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
const clearStates = useClearStates();
|
||||
const resetDefaultPreset = useResetRecoilState(store.defaultPreset);
|
||||
|
||||
return useMutation([MutationKeys.deleteUser], {
|
||||
mutationFn: () => dataService.deleteUser(),
|
||||
mutationFn: (payload?: t.TDeleteUserRequest) => dataService.deleteUser(payload),
|
||||
...(options || {}),
|
||||
onSuccess: (...args) => {
|
||||
resetDefaultPreset();
|
||||
|
|
@ -90,11 +90,11 @@ export const useDeleteUserMutation = (
|
|||
export const useEnableTwoFactorMutation = (): UseMutationResult<
|
||||
t.TEnable2FAResponse,
|
||||
unknown,
|
||||
void,
|
||||
t.TEnable2FARequest | undefined,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(() => dataService.enableTwoFactor(), {
|
||||
return useMutation((payload?: t.TEnable2FARequest) => dataService.enableTwoFactor(payload), {
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData([QueryKeys.user, '2fa'], data);
|
||||
},
|
||||
|
|
@ -146,15 +146,18 @@ export const useDisableTwoFactorMutation = (): UseMutationResult<
|
|||
export const useRegenerateBackupCodesMutation = (): UseMutationResult<
|
||||
t.TRegenerateBackupCodesResponse,
|
||||
unknown,
|
||||
void,
|
||||
t.TRegenerateBackupCodesRequest | undefined,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(() => dataService.regenerateBackupCodes(), {
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData([QueryKeys.user, '2fa', 'backup'], data);
|
||||
return useMutation(
|
||||
(payload?: t.TRegenerateBackupCodesRequest) => dataService.regenerateBackupCodes(payload),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData([QueryKeys.user, '2fa', 'backup'], data);
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
export const useVerifyTwoFactorTempMutation = (
|
||||
|
|
|
|||
|
|
@ -639,6 +639,7 @@
|
|||
"com_ui_2fa_generate_error": "There was an error generating two-factor authentication settings",
|
||||
"com_ui_2fa_invalid": "Invalid two-factor authentication code",
|
||||
"com_ui_2fa_setup": "Setup 2FA",
|
||||
"com_ui_2fa_verification_required": "Enter your 2FA code to continue",
|
||||
"com_ui_2fa_verified": "Successfully verified Two-Factor Authentication",
|
||||
"com_ui_accept": "I accept",
|
||||
"com_ui_action_button": "Action Button",
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ export function revokeAllUserKeys(): Promise<unknown> {
|
|||
return request.delete(endpoints.revokeAllUserKeys());
|
||||
}
|
||||
|
||||
export function deleteUser(): Promise<s.TPreset> {
|
||||
return request.delete(endpoints.deleteUser());
|
||||
export function deleteUser(payload?: t.TDeleteUserRequest): Promise<unknown> {
|
||||
return request.deleteWithOptions(endpoints.deleteUser(), { data: payload });
|
||||
}
|
||||
|
||||
export type FavoriteItem = {
|
||||
|
|
@ -970,8 +970,8 @@ export function updateFeedback(
|
|||
}
|
||||
|
||||
// 2FA
|
||||
export function enableTwoFactor(): Promise<t.TEnable2FAResponse> {
|
||||
return request.get(endpoints.enableTwoFactor());
|
||||
export function enableTwoFactor(payload?: t.TEnable2FARequest): Promise<t.TEnable2FAResponse> {
|
||||
return request.post(endpoints.enableTwoFactor(), payload);
|
||||
}
|
||||
|
||||
export function verifyTwoFactor(payload: t.TVerify2FARequest): Promise<t.TVerify2FAResponse> {
|
||||
|
|
@ -986,8 +986,10 @@ export function disableTwoFactor(payload?: t.TDisable2FARequest): Promise<t.TDis
|
|||
return request.post(endpoints.disableTwoFactor(), payload);
|
||||
}
|
||||
|
||||
export function regenerateBackupCodes(): Promise<t.TRegenerateBackupCodesResponse> {
|
||||
return request.post(endpoints.regenerateBackupCodes());
|
||||
export function regenerateBackupCodes(
|
||||
payload?: t.TRegenerateBackupCodesRequest,
|
||||
): Promise<t.TRegenerateBackupCodesResponse> {
|
||||
return request.post(endpoints.regenerateBackupCodes(), payload);
|
||||
}
|
||||
|
||||
export function verifyTwoFactorTemp(
|
||||
|
|
|
|||
|
|
@ -425,28 +425,29 @@ export type TLoginResponse = {
|
|||
tempToken?: string;
|
||||
};
|
||||
|
||||
/** Shared payload for any operation that requires OTP or backup-code verification. */
|
||||
export type TOTPVerificationPayload = {
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
};
|
||||
|
||||
export type TEnable2FARequest = TOTPVerificationPayload;
|
||||
|
||||
export type TEnable2FAResponse = {
|
||||
otpauthUrl: string;
|
||||
backupCodes: string[];
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type TVerify2FARequest = {
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
};
|
||||
export type TVerify2FARequest = TOTPVerificationPayload;
|
||||
|
||||
export type TVerify2FAResponse = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* For verifying 2FA during login with a temporary token.
|
||||
*/
|
||||
export type TVerify2FATempRequest = {
|
||||
/** For verifying 2FA during login with a temporary token. */
|
||||
export type TVerify2FATempRequest = TOTPVerificationPayload & {
|
||||
tempToken: string;
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
};
|
||||
|
||||
export type TVerify2FATempResponse = {
|
||||
|
|
@ -455,30 +456,22 @@ export type TVerify2FATempResponse = {
|
|||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request for disabling 2FA.
|
||||
*/
|
||||
export type TDisable2FARequest = {
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
};
|
||||
export type TDisable2FARequest = TOTPVerificationPayload;
|
||||
|
||||
/**
|
||||
* Response from disabling 2FA.
|
||||
*/
|
||||
export type TDisable2FAResponse = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response from regenerating backup codes.
|
||||
*/
|
||||
export type TRegenerateBackupCodesRequest = TOTPVerificationPayload;
|
||||
|
||||
export type TRegenerateBackupCodesResponse = {
|
||||
message: string;
|
||||
message?: string;
|
||||
backupCodes: string[];
|
||||
backupCodesHash: string[];
|
||||
backupCodesHash: TBackupCode[];
|
||||
};
|
||||
|
||||
export type TDeleteUserRequest = TOTPVerificationPayload;
|
||||
|
||||
export type TRequestPasswordReset = {
|
||||
email: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -121,6 +121,15 @@ const userSchema = new Schema<IUser>(
|
|||
type: [BackupCodeSchema],
|
||||
select: false,
|
||||
},
|
||||
pendingTotpSecret: {
|
||||
type: String,
|
||||
select: false,
|
||||
},
|
||||
pendingBackupCodes: {
|
||||
type: [BackupCodeSchema],
|
||||
select: false,
|
||||
default: undefined,
|
||||
},
|
||||
refreshToken: {
|
||||
type: [SessionSchema],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ export interface IUser extends Document {
|
|||
used: boolean;
|
||||
usedAt?: Date | null;
|
||||
}>;
|
||||
pendingTotpSecret?: string;
|
||||
pendingBackupCodes?: Array<{
|
||||
codeHash: string;
|
||||
used: boolean;
|
||||
usedAt?: Date | null;
|
||||
}>;
|
||||
refreshToken?: Array<{
|
||||
refreshToken: string;
|
||||
}>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue