-
-
Notifications
You must be signed in to change notification settings - Fork 3.8k
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
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 8544629
refactored controllers
rubentalstra 46a28f9
removed `passport-totp` not used.
rubentalstra 6dd6d74
update the generateBackupCodes function to generate 10 codes by defa…
rubentalstra 57d7f71
update the backup codes to an object.
rubentalstra 3ca2b7a
fixed issue with backup codes not working
rubentalstra 05c87d9
be able to disable 2FA with backup codes.
rubentalstra 3d35cf2
removed new env. replaced with JWT_SECRET
rubentalstra 9c329ea
✨ style: improved a11y and style for TwoFactorAuthentication
berry-13 6387cce
🔒 fix: small types checks
berry-13 3cc612a
✨ feat: improve 2FA UI components
berry-13 20dfbd4
fix: remove unnecessary console log
berry-13 84d0cda
add option to disable 2FA with backup codes
rubentalstra af32d56
- add option to refresh backup codes
rubentalstra 33a9e2d
removed text to be able to merge the main.
rubentalstra 150661c
removed eng tx to be able to merge
rubentalstra 0f34c1a
Merge branch 'main' into feat/2fa
rubentalstra 5903c4d
fix: migrated lang to new format.
rubentalstra 802720e
feat: rewrote whole 2FA UI + refactored 2FA backend
berry-13 aff9709
chore: resolving conflicts
rubentalstra 910eb19
chore: resolving conflicts
rubentalstra 2040635
Merge branch 'main' into feat/2fa
rubentalstra d8b6869
fix: missing packages, because of resolving conflicts.
rubentalstra ba46e7c
fix: UI issue and improved a11y
berry-13 475da99
Merge branch 'main' into feat/2fa
rubentalstra 1ea85d2
fix: 2FA backup code not working
berry-13 64edd12
fix: update localization keys for UI consistency
berry-13 9c34b2f
fix: update button label to use localized text
berry-13 d2c940b
fix: refactor backup codes regeneration and update localization keys
berry-13 1bfe25b
Merge branch 'main' into feat/2fa
berry-13 3f9f4af
fix: remove outdated translation for shared links management
berry-13 088ff96
fix: remove outdated 2FA code prompts from translation.json
berry-13 87e1a5b
fix: add cursor styles for backup codes item based on usage state
berry-13 0427187
fix: resolve conflict issue
rubentalstra 094e9e1
fix: resolve conflict issue
rubentalstra e719e6b
fix: resolve conflict issue
rubentalstra 3d7a8dc
Merge branch 'main' into feat/2fa
rubentalstra 073fe8e
fix: missing packages in package-lock.json
rubentalstra 24aa99a
Merge branch 'main' into feat/2fa
rubentalstra 5364777
fix: add disabled opacity to the verify button in TwoFactorScreen
berry-13 d8c55ee
⚙ fix: update 2FA logic to rely on backup codes instead of TOTP status
rubentalstra d4864cf
Merge branch 'main' into feat/2fa
rubentalstra 1c038fe
⚙️ fix: Simplify user retrieval in 2FA logic by removing unnecessary …
rubentalstra 4ad677f
⚙️ test: Add unit tests for TwoFactorAuthController and twoFactorCont…
rubentalstra 9726d6e
⚙️ fix: Ensure backup codes are validated as an array before usage in…
rubentalstra fb69668
⚙️ fix: Update module path mappings in tests to use relative paths
rubentalstra 93daf7e
⚙️ fix: Update moduleNameMapper in jest.config.js to remove the caret…
rubentalstra db5a2f5
⚙️ refactor: Simplify import paths in TwoFactorAuthController and two…
rubentalstra b6fdf56
⚙️ test: Mock twoFactorService methods in twoFactorControllers tests
rubentalstra 52d3502
⚙️ refactor: Comment out unused imports and mock setups in test files…
rubentalstra cc4c3f9
⚙️ refactor: removed files
rubentalstra 5c9c4ba
refactor: Exclude totpSecret from user data retrieval in AuthControll…
danny-avila 59e6256
refactor: Consolidate backup code verification to apply DRY and remov…
danny-avila be1cb7a
refactor: Enhance two-factor authentication ux/flow with improved err…
danny-avila File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) => { | ||
danny-avila marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.