🔏 fix: Enhance Two-Factor Authentication (#6247)

* 🌟 feat: Implement Two-Factor Authentication (2FA) functionality

* fix: Two-Factor Authentication Logic and State Management

* 🌟 feat: Add LICENSE file and update package version to 0.0.2 with MIT license
This commit is contained in:
Ruben Talstra 2025-03-08 21:28:27 +01:00 committed by GitHub
parent cc661c95ee
commit 3e3dfe5bad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 179 additions and 29 deletions

View file

@ -11,17 +11,19 @@ const { encryptV2 } = require('~/server/utils/crypto');
const enable2FAController = async (req, res) => { const enable2FAController = async (req, res) => {
const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, ''); const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');
try { try {
const userId = req.user.id; const userId = req.user.id;
const secret = generateTOTPSecret(); const secret = generateTOTPSecret();
const { plainCodes, codeObjects } = await generateBackupCodes(); const { plainCodes, codeObjects } = await generateBackupCodes();
const encryptedSecret = await encryptV2(secret); const encryptedSecret = await encryptV2(secret);
const user = await updateUser(userId, { totpSecret: encryptedSecret, backupCodes: codeObjects }); // Set twoFactorEnabled to false until the user confirms 2FA.
const user = await updateUser(userId, {
totpSecret: encryptedSecret,
backupCodes: codeObjects,
twoFactorEnabled: false,
});
const otpauthUrl = `otpauth://totp/${safeAppTitle}:${user.email}?secret=${secret}&issuer=${safeAppTitle}`; const otpauthUrl = `otpauth://totp/${safeAppTitle}:${user.email}?secret=${secret}&issuer=${safeAppTitle}`;
res.status(200).json({ res.status(200).json({
otpauthUrl, otpauthUrl,
backupCodes: plainCodes, backupCodes: plainCodes,
@ -37,6 +39,7 @@ const verify2FAController = async (req, res) => {
const userId = req.user.id; const userId = req.user.id;
const { token, backupCode } = req.body; const { token, backupCode } = req.body;
const user = await getUserById(userId); const user = await getUserById(userId);
// Ensure that 2FA is enabled for this user.
if (!user || !user.totpSecret) { if (!user || !user.totpSecret) {
return res.status(400).json({ message: '2FA not initiated' }); return res.status(400).json({ message: '2FA not initiated' });
} }
@ -52,7 +55,6 @@ const verify2FAController = async (req, res) => {
return res.status(200).json(); return res.status(200).json();
} }
} }
return res.status(400).json({ message: 'Invalid token.' }); return res.status(400).json({ message: 'Invalid token.' });
} catch (err) { } catch (err) {
logger.error('[verify2FAController]', err); logger.error('[verify2FAController]', err);
@ -74,6 +76,8 @@ const confirm2FAController = async (req, res) => {
const secret = await getTOTPSecret(user.totpSecret); const secret = await getTOTPSecret(user.totpSecret);
if (await verifyTOTP(secret, token)) { if (await verifyTOTP(secret, token)) {
// Upon successful verification, enable 2FA.
await updateUser(userId, { twoFactorEnabled: true });
return res.status(200).json(); return res.status(200).json();
} }
@ -87,7 +91,7 @@ const confirm2FAController = async (req, res) => {
const disable2FAController = async (req, res) => { const disable2FAController = async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user.id;
await updateUser(userId, { totpSecret: null, backupCodes: [] }); await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false });
res.status(200).json(); res.status(200).json();
} catch (err) { } catch (err) {
logger.error('[disable2FAController]', err); logger.error('[disable2FAController]', err);

View file

@ -8,7 +8,7 @@ const loginController = async (req, res) => {
return res.status(400).json({ message: 'Invalid credentials' }); return res.status(400).json({ message: 'Invalid credentials' });
} }
if (req.user.backupCodes != null && req.user.backupCodes.length > 0) { if (req.user.twoFactorEnabled) {
const tempToken = generate2FATempToken(req.user._id); const tempToken = generate2FATempToken(req.user._id);
return res.status(200).json({ twoFAPending: true, tempToken }); return res.status(200).json({ twoFAPending: true, tempToken });
} }

View file

@ -1,5 +1,9 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { verifyTOTP, verifyBackupCode, getTOTPSecret } = require('~/server/services/twoFactorService'); const {
verifyTOTP,
verifyBackupCode,
getTOTPSecret,
} = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('~/server/services/AuthService'); const { setAuthTokens } = require('~/server/services/AuthService');
const { getUserById } = require('~/models/userMethods'); const { getUserById } = require('~/models/userMethods');
const { logger } = require('~/config'); const { logger } = require('~/config');
@ -19,12 +23,12 @@ const verify2FA = async (req, res) => {
} }
const user = await getUserById(payload.userId); const user = await getUserById(payload.userId);
// Ensure that the user exists and has backup codes (i.e. 2FA enabled) // Ensure that the user exists and has 2FA enabled
if (!user || !(user.backupCodes && user.backupCodes.length > 0)) { if (!user || !user.twoFactorEnabled) {
return res.status(400).json({ message: '2FA is not enabled for this user' }); return res.status(400).json({ message: '2FA is not enabled for this user' });
} }
// Use the new getTOTPSecret function to retrieve (and decrypt if necessary) the TOTP secret. // Retrieve (and decrypt if necessary) the TOTP secret.
const secret = await getTOTPSecret(user.totpSecret); const secret = await getTOTPSecret(user.totpSecret);
let verified = false; let verified = false;
@ -39,9 +43,7 @@ const verify2FA = async (req, res) => {
} }
// Prepare user data for response. // Prepare user data for response.
// If the user is a plain object (from lean queries), we create a shallow copy.
const userData = user.toObject ? user.toObject() : { ...user }; const userData = user.toObject ? user.toObject() : { ...user };
// Remove sensitive fields.
delete userData.password; delete userData.password;
delete userData.__v; delete userData.__v;
delete userData.totpSecret; delete userData.totpSecret;

View file

@ -22,7 +22,7 @@ function Account() {
<div className="pb-3"> <div className="pb-3">
<EnableTwoFactorItem /> <EnableTwoFactorItem />
</div> </div>
{Array.isArray(user.user?.backupCodes) && user.user?.backupCodes.length > 0 && ( {user?.user?.twoFactorEnabled && (
<div className="pb-3"> <div className="pb-3">
<BackupCodesItem /> <BackupCodesItem />
</div> </div>

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { motion } from 'framer-motion'; // import { motion } from 'framer-motion';
import { LockIcon, UnlockIcon } from 'lucide-react'; // import { LockIcon, UnlockIcon } from 'lucide-react';
import { Label, Button } from '~/components'; import { Label, Button } from '~/components';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';

View file

@ -37,7 +37,7 @@ const TwoFactorAuthentication: React.FC = () => {
const [backupCodes, setBackupCodes] = useState<string[]>([]); const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [isDialogOpen, setDialogOpen] = useState<boolean>(false); const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
const [verificationToken, setVerificationToken] = useState<string>(''); const [verificationToken, setVerificationToken] = useState<string>('');
const [phase, setPhase] = useState<Phase>(Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? 'disable' : 'setup'); const [phase, setPhase] = useState<Phase>(user?.twoFactorEnabled ? 'disable' : 'setup');
const { mutate: confirm2FAMutate } = useConfirmTwoFactorMutation(); const { mutate: confirm2FAMutate } = useConfirmTwoFactorMutation();
const { mutate: enable2FAMutate, isLoading: isGenerating } = useEnableTwoFactorMutation(); const { mutate: enable2FAMutate, isLoading: isGenerating } = useEnableTwoFactorMutation();
@ -56,7 +56,7 @@ const TwoFactorAuthentication: React.FC = () => {
const currentStep = steps.indexOf(phasesLabel[phase]); const currentStep = steps.indexOf(phasesLabel[phase]);
const resetState = useCallback(() => { const resetState = useCallback(() => {
if (Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 && otpauthUrl) { if (user?.twoFactorEnabled && otpauthUrl) {
disable2FAMutate(undefined, { disable2FAMutate(undefined, {
onError: () => onError: () =>
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }), showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
@ -68,7 +68,7 @@ const TwoFactorAuthentication: React.FC = () => {
setBackupCodes([]); setBackupCodes([]);
setVerificationToken(''); setVerificationToken('');
setDisableToken(''); setDisableToken('');
setPhase(Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? 'disable' : 'setup'); setPhase(user?.twoFactorEnabled ? 'disable' : 'setup');
setDownloaded(false); setDownloaded(false);
}, [user, otpauthUrl, disable2FAMutate, localize, showToast]); }, [user, otpauthUrl, disable2FAMutate, localize, showToast]);
@ -136,6 +136,7 @@ const TwoFactorAuthentication: React.FC = () => {
used: false, used: false,
usedAt: null, usedAt: null,
})), })),
twoFactorEnabled: true,
}) as TUser, }) as TUser,
); );
}, [setUser, localize, showToast, backupCodes]); }, [setUser, localize, showToast, backupCodes]);
@ -171,6 +172,7 @@ const TwoFactorAuthentication: React.FC = () => {
...prev, ...prev,
totpSecret: '', totpSecret: '',
backupCodes: [], backupCodes: [],
twoFactorEnabled: false,
}) as TUser, }) as TUser,
); );
setPhase('setup'); setPhase('setup');
@ -183,7 +185,7 @@ const TwoFactorAuthentication: React.FC = () => {
onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }), onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
}); });
}, },
[disableToken, verify2FAMutate, disable2FAMutate, showToast, localize, setUser], [verify2FAMutate, disable2FAMutate, showToast, localize, setUser],
); );
return ( return (
@ -197,7 +199,7 @@ const TwoFactorAuthentication: React.FC = () => {
}} }}
> >
<DisableTwoFactorToggle <DisableTwoFactorToggle
enabled={Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0} enabled={!!user?.twoFactorEnabled}
onChange={() => setDialogOpen(true)} onChange={() => setDialogOpen(true)}
disabled={isVerifying || isDisabling || isGenerating} disabled={isVerifying || isDisabling || isGenerating}
/> />
@ -215,9 +217,11 @@ const TwoFactorAuthentication: React.FC = () => {
<OGDialogHeader> <OGDialogHeader>
<OGDialogTitle className="mb-2 flex items-center gap-3 text-2xl font-bold"> <OGDialogTitle className="mb-2 flex items-center gap-3 text-2xl font-bold">
<SmartphoneIcon className="h-6 w-6 text-primary" /> <SmartphoneIcon className="h-6 w-6 text-primary" />
{Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? localize('com_ui_2fa_disable') : localize('com_ui_2fa_setup')} {user?.twoFactorEnabled
? localize('com_ui_2fa_disable')
: localize('com_ui_2fa_setup')}
</OGDialogTitle> </OGDialogTitle>
{Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 && phase !== 'disable' && ( {user?.twoFactorEnabled && phase !== 'disable' && (
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<Progress <Progress
value={(steps.indexOf(phasesLabel[phase]) / (steps.length - 1)) * 100} value={(steps.indexOf(phasesLabel[phase]) / (steps.length - 1)) * 100}

View file

@ -74,7 +74,7 @@ export const DisablePhase: React.FC<DisablePhaseProps> = ({ onDisable, isDisabli
disabled={isDisabling || token.length !== (useBackup ? 8 : 6)} disabled={isDisabling || token.length !== (useBackup ? 8 : 6)}
className="w-full rounded-xl px-6 py-3 transition-all disabled:opacity-50" className="w-full rounded-xl px-6 py-3 transition-all disabled:opacity-50"
> >
{isDisabling === true && <Spinner className="mr-2" />} {isDisabling && <Spinner className="mr-2" />}
{isDisabling ? localize('com_ui_disabling') : localize('com_ui_2fa_disable')} {isDisabling ? localize('com_ui_disabling') : localize('com_ui_2fa_disable')}
</Button> </Button>
<button <button

View file

@ -18,7 +18,7 @@ interface SetupPhaseProps {
onGenerate: () => void; onGenerate: () => void;
} }
export const SetupPhase: React.FC<SetupPhaseProps> = ({ isGenerating, onGenerate, onNext }) => { export const SetupPhase: React.FC<SetupPhaseProps> = ({ isGenerating, onGenerate }) => {
const localize = useLocalize(); const localize = useLocalize();
return ( return (

2
package-lock.json generated
View file

@ -41151,7 +41151,7 @@
}, },
"packages/data-schemas": { "packages/data-schemas": {
"name": "@librechat/data-schemas", "name": "@librechat/data-schemas",
"version": "0.0.1", "version": "0.0.2",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"mongoose": "^8.9.5" "mongoose": "^8.9.5"

View file

@ -115,6 +115,7 @@ export type TUser = {
role: string; role: string;
provider: string; provider: string;
plugins?: string[]; plugins?: string[];
twoFactorEnabled?: boolean;
backupCodes?: TBackupCode[]; backupCodes?: TBackupCode[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 LibreChat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,114 @@
# `@librechat/data-schemas`
Mongoose schemas and models for LibreChat. This package provides a comprehensive collection of Mongoose schemas used across the LibreChat project, enabling robust data modeling and validation for various entities such as actions, agents, messages, users, and more.
## Features
- **Modular Schemas:** Includes schemas for actions, agents, assistants, balance, banners, categories, conversation tags, conversations, files, keys, messages, plugin authentication, presets, projects, prompts, prompt groups, roles, sessions, shared links, tokens, tool calls, transactions, and users.
- **TypeScript Support:** Provides TypeScript definitions for type-safe development.
- **Ready for Mongoose Integration:** Easily integrate with Mongoose to create models and interact with your MongoDB database.
- **Flexible & Extensible:** Designed to support the evolving needs of LibreChat while being adaptable to other projects.
## Installation
Install the package via npm or yarn:
```bash
npm install @librechat/data-schemas
```
Or with yarn:
```bash
yarn add @librechat/data-schemas
```
## Usage
After installation, you can import and use the schemas in your project. For example, to create a Mongoose model for a user:
```js
import mongoose from 'mongoose';
import { userSchema } from '@librechat/data-schemas';
const UserModel = mongoose.model('User', userSchema);
// Now you can use UserModel to create, read, update, and delete user documents.
```
You can also import other schemas as needed:
```js
import { actionSchema, agentSchema, messageSchema } from '@librechat/data-schemas';
```
Each schema is designed to integrate seamlessly with Mongoose and provides indexes, timestamps, and validations tailored for LibreChats use cases.
## Development
This package uses Rollup and TypeScript for building and bundling.
### Available Scripts
- **Build:**
Cleans the `dist` directory and builds the package.
```bash
npm run build
```
- **Build Watch:**
Rebuilds automatically on file changes.
```bash
npm run build:watch
```
- **Test:**
Runs tests with coverage in watch mode.
```bash
npm run test
```
- **Test (CI):**
Runs tests with coverage for CI environments.
```bash
npm run test:ci
```
- **Verify:**
Runs tests in CI mode to verify code integrity.
```bash
npm run verify
```
- **Clean:**
Removes the `dist` directory.
```bash
npm run clean
```
For those using Bun, equivalent scripts are available:
- **Bun Clean:** `bun run b:clean`
- **Bun Build:** `bun run b:build`
## Repository & Issues
The source code is maintained on GitHub.
- **Repository:** [LibreChat Repository](https://github.com/danny-avila/LibreChat.git)
- **Issues & Bug Reports:** [LibreChat Issues](https://github.com/danny-avila/LibreChat/issues)
## License
This project is licensed under the [MIT License](LICENSE).
## Contributing
Contributions to improve and expand the data schemas are welcome. If you have suggestions, improvements, or bug fixes, please open an issue or submit a pull request on the [GitHub repository](https://github.com/danny-avila/LibreChat/issues).
For more detailed documentation on each schema and model, please refer to the source code or visit the [LibreChat website](https://librechat.ai).

View file

@ -1,6 +1,6 @@
{ {
"name": "@librechat/data-schemas", "name": "@librechat/data-schemas",
"version": "0.0.1", "version": "0.0.2",
"type": "module", "type": "module",
"description": "Mongoose schemas and models for LibreChat", "description": "Mongoose schemas and models for LibreChat",
"main": "dist/index.cjs", "main": "dist/index.cjs",
@ -28,7 +28,7 @@
"url": "git+https://github.com/danny-avila/LibreChat.git" "url": "git+https://github.com/danny-avila/LibreChat.git"
}, },
"author": "", "author": "",
"license": "ISC", "license": "MIT",
"bugs": { "bugs": {
"url": "https://github.com/danny-avila/LibreChat/issues" "url": "https://github.com/danny-avila/LibreChat/issues"
}, },

View file

@ -1,4 +1,3 @@
// index.ts
import actionSchema from './schema/action'; import actionSchema from './schema/action';
import agentSchema from './schema/agent'; import agentSchema from './schema/agent';
import assistantSchema from './schema/assistant'; import assistantSchema from './schema/assistant';

View file

@ -18,6 +18,7 @@ export interface IUser extends Document {
discordId?: string; discordId?: string;
appleId?: string; appleId?: string;
plugins?: unknown[]; plugins?: unknown[];
twoFactorEnabled?: boolean;
totpSecret?: string; totpSecret?: string;
backupCodes?: Array<{ backupCodes?: Array<{
codeHash: string; codeHash: string;
@ -134,6 +135,10 @@ const User = new Schema<IUser>(
plugins: { plugins: {
type: Array, type: Array,
}, },
twoFactorEnabled: {
type: Boolean,
default: false,
},
totpSecret: { totpSecret: {
type: String, type: String,
}, },