Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🔒 feat: Two-Factor Authentication with Backup Codes & QR support #5685

Merged
merged 54 commits into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from 51 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
a9f3917
🔒 feat: add Two-Factor Authentication (2FA) with backup codes & QR su…
rubentalstra Feb 6, 2025
8544629
refactored controllers
rubentalstra Feb 6, 2025
46a28f9
removed `passport-totp` not used.
rubentalstra Feb 6, 2025
6dd6d74
update the generateBackupCodes function to generate 10 codes by defa…
rubentalstra Feb 6, 2025
57d7f71
update the backup codes to an object.
rubentalstra Feb 6, 2025
3ca2b7a
fixed issue with backup codes not working
rubentalstra Feb 6, 2025
05c87d9
be able to disable 2FA with backup codes.
rubentalstra Feb 6, 2025
3d35cf2
removed new env. replaced with JWT_SECRET
rubentalstra Feb 6, 2025
9c329ea
✨ style: improved a11y and style for TwoFactorAuthentication
berry-13 Feb 6, 2025
6387cce
🔒 fix: small types checks
berry-13 Feb 6, 2025
3cc612a
✨ feat: improve 2FA UI components
berry-13 Feb 6, 2025
20dfbd4
fix: remove unnecessary console log
berry-13 Feb 6, 2025
84d0cda
add option to disable 2FA with backup codes
rubentalstra Feb 9, 2025
af32d56
- add option to refresh backup codes
rubentalstra Feb 9, 2025
33a9e2d
removed text to be able to merge the main.
rubentalstra Feb 10, 2025
150661c
removed eng tx to be able to merge
rubentalstra Feb 10, 2025
0f34c1a
Merge branch 'main' into feat/2fa
rubentalstra Feb 10, 2025
5903c4d
fix: migrated lang to new format.
rubentalstra Feb 10, 2025
802720e
feat: rewrote whole 2FA UI + refactored 2FA backend
berry-13 Feb 11, 2025
aff9709
chore: resolving conflicts
rubentalstra Feb 12, 2025
910eb19
chore: resolving conflicts
rubentalstra Feb 12, 2025
2040635
Merge branch 'main' into feat/2fa
rubentalstra Feb 12, 2025
d8b6869
fix: missing packages, because of resolving conflicts.
rubentalstra Feb 12, 2025
ba46e7c
fix: UI issue and improved a11y
berry-13 Feb 12, 2025
475da99
Merge branch 'main' into feat/2fa
rubentalstra Feb 12, 2025
1ea85d2
fix: 2FA backup code not working
berry-13 Feb 12, 2025
64edd12
fix: update localization keys for UI consistency
berry-13 Feb 13, 2025
9c34b2f
fix: update button label to use localized text
berry-13 Feb 13, 2025
d2c940b
fix: refactor backup codes regeneration and update localization keys
berry-13 Feb 13, 2025
1bfe25b
Merge branch 'main' into feat/2fa
berry-13 Feb 13, 2025
3f9f4af
fix: remove outdated translation for shared links management
berry-13 Feb 13, 2025
088ff96
fix: remove outdated 2FA code prompts from translation.json
berry-13 Feb 13, 2025
87e1a5b
fix: add cursor styles for backup codes item based on usage state
berry-13 Feb 13, 2025
0427187
fix: resolve conflict issue
rubentalstra Feb 14, 2025
094e9e1
fix: resolve conflict issue
rubentalstra Feb 14, 2025
e719e6b
fix: resolve conflict issue
rubentalstra Feb 14, 2025
3d7a8dc
Merge branch 'main' into feat/2fa
rubentalstra Feb 14, 2025
073fe8e
fix: missing packages in package-lock.json
rubentalstra Feb 14, 2025
24aa99a
Merge branch 'main' into feat/2fa
rubentalstra Feb 14, 2025
5364777
fix: add disabled opacity to the verify button in TwoFactorScreen
berry-13 Feb 14, 2025
d8c55ee
⚙ fix: update 2FA logic to rely on backup codes instead of TOTP status
rubentalstra Feb 17, 2025
d4864cf
Merge branch 'main' into feat/2fa
rubentalstra Feb 17, 2025
1c038fe
⚙️ fix: Simplify user retrieval in 2FA logic by removing unnecessary …
rubentalstra Feb 17, 2025
4ad677f
⚙️ test: Add unit tests for TwoFactorAuthController and twoFactorCont…
rubentalstra Feb 17, 2025
9726d6e
⚙️ fix: Ensure backup codes are validated as an array before usage in…
rubentalstra Feb 17, 2025
fb69668
⚙️ fix: Update module path mappings in tests to use relative paths
rubentalstra Feb 17, 2025
93daf7e
⚙️ fix: Update moduleNameMapper in jest.config.js to remove the caret…
rubentalstra Feb 17, 2025
db5a2f5
⚙️ refactor: Simplify import paths in TwoFactorAuthController and two…
rubentalstra Feb 17, 2025
b6fdf56
⚙️ test: Mock twoFactorService methods in twoFactorControllers tests
rubentalstra Feb 17, 2025
52d3502
⚙️ refactor: Comment out unused imports and mock setups in test files…
rubentalstra Feb 17, 2025
cc4c3f9
⚙️ refactor: removed files
rubentalstra Feb 17, 2025
5c9c4ba
refactor: Exclude totpSecret from user data retrieval in AuthControll…
danny-avila Feb 17, 2025
59e6256
refactor: Consolidate backup code verification to apply DRY and remov…
danny-avila Feb 17, 2025
be1cb7a
refactor: Enhance two-factor authentication ux/flow with improved err…
danny-avila Feb 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions api/models/schema/userSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ const Session = mongoose.Schema({
},
});

const backupCodeSchema = mongoose.Schema({
codeHash: { type: String, required: true },
used: { type: Boolean, default: false },
usedAt: { type: Date, default: null },
});

/** @type {MongooseSchema<MongoUser>} */
const userSchema = mongoose.Schema(
{
Expand Down Expand Up @@ -121,6 +127,13 @@ const userSchema = mongoose.Schema(
type: Array,
default: [],
},
totpSecret: {
type: String,
},
backupCodes: {
type: [backupCodeSchema],
default: [],
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we remove the default?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no we can not. this is used to check if the user has backupCodes or not. I'm sorry. it's part of the login controller logic as well

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be removed, I'll do it

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know I wrote it to you 😀. Thank you.

},
refreshToken: {
type: [Session],
},
Expand Down
145 changes: 145 additions & 0 deletions api/server/controllers/TwoFactorController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
const { webcrypto } = require('node:crypto');
const {
generateTOTPSecret,
generateBackupCodes,
verifyTOTP,
} = require('~/server/services/twoFactorService');
const { updateUser, getUserById } = require('~/models');
const { logger } = require('~/config');

/**
* Computes SHA-256 hash for the given input using WebCrypto
* @param {string} input
* @returns {Promise<string>} - Hex hash string
*/
const hashBackupCode = async (input) => {
const encoder = new TextEncoder();
const data = encoder.encode(input);
const hashBuffer = await webcrypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
};

const enable2FAController = async (req, res) => {
const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');

try {
const userId = req.user.id;
const secret = generateTOTPSecret();
const { plainCodes, codeObjects } = await generateBackupCodes();

const user = await updateUser(
userId,
{ totpSecret: secret, backupCodes: codeObjects },
);

const otpauthUrl = `otpauth://totp/${safeAppTitle}:${user.email}?secret=${secret}&issuer=${safeAppTitle}`;

res.status(200).json({
otpauthUrl,
backupCodes: plainCodes,
});
} catch (err) {
logger.error('[enable2FAController]', err);
res.status(500).json({ message: err.message });
}
};

const verify2FAController = async (req, res) => {
try {
const userId = req.user.id;
const { token, backupCode } = req.body;
const user = await getUserById(userId);
if (!user || !user.totpSecret) {
return res.status(400).json({ message: '2FA not initiated' });
}

if (token && (await verifyTOTP(user.totpSecret, token))) {
return res.status(200).json();
} else if (backupCode) {
const backupCodeInput = backupCode.trim();
const hashedInput = await hashBackupCode(backupCodeInput);
const matchingCode = user.backupCodes.find(
(codeObj) => codeObj.codeHash === hashedInput && codeObj.used === false,
);

if (matchingCode) {
const updatedBackupCodes = user.backupCodes.map((codeObj) => {
if (codeObj.codeHash === hashedInput && codeObj.used === false) {
return { ...codeObj, used: true, usedAt: new Date() };
}
return codeObj;
});

await updateUser(user._id, { backupCodes: updatedBackupCodes });
return res.status(200).json();
}
}

return res.status(400).json({ message: 'Invalid token.' });
} catch (err) {
logger.error('[verify2FAController]', err);
res.status(500).json({ message: err.message });
}
};

const confirm2FAController = async (req, res) => {
try {
const userId = req.user.id;
const { token } = req.body;
const user = await getUserById(userId);

if (!user || !user.totpSecret) {
return res.status(400).json({ message: '2FA not initiated' });
}

if (await verifyTOTP(user.totpSecret, token)) {
return res.status(200).json();
}

return res.status(400).json({ message: 'Invalid token.' });
} catch (err) {
logger.error('[confirm2FAController]', err);
res.status(500).json({ message: err.message });
}
};

const disable2FAController = async (req, res) => {
try {
const userId = req.user.id;
await updateUser(
userId,
{ totpSecret: null, backupCodes: [] },
);
res.status(200).json();
} catch (err) {
logger.error('[disable2FAController]', err);
res.status(500).json({ message: err.message });
}
};

const regenerateBackupCodesController = async (req, res) => {
try {
const userId = req.user.id;
const { plainCodes, codeObjects } = await generateBackupCodes();
await updateUser(
userId,
{ backupCodes: codeObjects },
);
res.status(200).json({
backupCodes: plainCodes,
backupCodesHash: codeObjects,
});
} catch (err) {
logger.error('[regenerateBackupCodesController]', err);
res.status(500).json({ message: err.message });
}
};

module.exports = {
enable2FAController,
verify2FAController,
confirm2FAController,
disable2FAController,
regenerateBackupCodesController,
};
4 changes: 3 additions & 1 deletion api/server/controllers/UserController.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ const { Transaction } = require('~/models/Transaction');
const { logger } = require('~/config');

const getUserController = async (req, res) => {
res.status(200).send(req.user);
const userData = req.user.toObject ? req.user.toObject() : { ...req.user };
delete userData.totpSecret;
res.status(200).send(userData);
};

const getTermsStatusController = async (req, res) => {
Expand Down
7 changes: 7 additions & 0 deletions api/server/controllers/auth/LoginController.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
const { setAuthTokens } = require('~/server/services/AuthService');
const { logger } = require('~/config');
const { generate2FATempToken } = require('~/server/services/twoFactorService');
const { getUserById } = require('~/models');

const loginController = async (req, res) => {
try {
if (!req.user) {
return res.status(400).json({ message: 'Invalid credentials' });
}

if (req.user.backupCodes.length > 0) {
const tempToken = generate2FATempToken(req.user._id);
return res.status(200).json({ twoFAPending: true, tempToken });
}

const { password: _, __v, ...user } = req.user;
user.id = user._id.toString();

Expand Down
84 changes: 84 additions & 0 deletions api/server/controllers/auth/TwoFactorAuthController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const jwt = require('jsonwebtoken');
const { webcrypto } = require('node:crypto');
const { verifyTOTP } = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('~/server/services/AuthService');
const { getUserById, updateUser } = require('~/models');
const { logger } = require('~/config');

/**
* Computes SHA-256 hash for the given input using WebCrypto
* @param {string} input
* @returns {Promise<string>} - Hex hash string
*/
const hashBackupCode = async (input) => {
const encoder = new TextEncoder();
const data = encoder.encode(input);
const hashBuffer = await webcrypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
};

const verify2FA = async (req, res) => {
try {
const { tempToken, token, backupCode } = req.body;
if (!tempToken) {
return res.status(400).json({ message: 'Missing temporary token' });
}

let payload;
try {
payload = jwt.verify(tempToken, process.env.JWT_SECRET);
} catch (err) {
return res.status(401).json({ message: 'Invalid or expired temporary token' });
}

const user = await getUserById(payload.userId);
// Ensure that the user exists and has backup codes (i.e. 2FA enabled)
if (!user || !(user.backupCodes && user.backupCodes.length > 0)) {
return res.status(400).json({ message: '2FA is not enabled for this user' });
}

let verified = false;

if (token && (await verifyTOTP(user.totpSecret, token))) {
verified = true;
} else if (backupCode) {
const hashedInput = await hashBackupCode(backupCode.trim());
const matchingCode = user.backupCodes.find(
(codeObj) => codeObj.codeHash === hashedInput && !codeObj.used,
);

if (matchingCode) {
verified = true;
const updatedBackupCodes = user.backupCodes.map((codeObj) =>
codeObj.codeHash === hashedInput && !codeObj.used
? { ...codeObj, used: true, usedAt: new Date() }
: codeObj,
);

await updateUser(user._id, { backupCodes: updatedBackupCodes });
}
}

if (!verified) {
return res.status(401).json({ message: 'Invalid 2FA code or backup code' });
}

// 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 };
// Remove sensitive fields
delete userData.password;
delete userData.__v;
delete userData.totpSecret; // Ensure totpSecret is not returned
userData.id = user._id.toString();

const authToken = await setAuthTokens(user._id, res);
return res.status(200).json({ token: authToken, user: userData });
} catch (err) {
logger.error('[verify2FA]', err);
return res.status(500).json({ message: 'Something went wrong' });
}
};

module.exports = { verify2FA };
14 changes: 14 additions & 0 deletions api/server/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ const {
} = require('~/server/controllers/AuthController');
const { loginController } = require('~/server/controllers/auth/LoginController');
const { logoutController } = require('~/server/controllers/auth/LogoutController');
const { verify2FA } = require('~/server/controllers/auth/TwoFactorAuthController');
const {
enable2FAController,
verify2FAController,
disable2FAController,
regenerateBackupCodesController, confirm2FAController,
} = require('~/server/controllers/TwoFactorController');
const {
checkBan,
loginLimiter,
Expand Down Expand Up @@ -50,4 +57,11 @@ router.post(
);
router.post('/resetPassword', checkBan, validatePasswordReset, resetPasswordController);

router.get('/2fa/enable', requireJwtAuth, enable2FAController);
router.post('/2fa/verify', requireJwtAuth, verify2FAController);
router.post('/2fa/verify-temp', checkBan, verify2FA);
router.post('/2fa/confirm', requireJwtAuth, confirm2FAController);
router.post('/2fa/disable', requireJwtAuth, disable2FAController);
router.post('/2fa/backup/regenerate', requireJwtAuth, regenerateBackupCodesController);

module.exports = router;
Loading