LibreChat/api/server/controllers/TwoFactorController.js
Marco Beretta 76c25680ce
Some checks failed
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Vote Gate / Gate (push) Waiting to run
Codegraph E2E Votes / vote (shard 1/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 10/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 2/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 3/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 4/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 5/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 6/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 7/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 8/10) (push) Waiting to run
Codegraph E2E Votes / vote (shard 9/10) (push) Waiting to run
Docker Dev Branch Images Build / publish (push) Waiting to run
Frontend Unit Tests / Client build recovery regression (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/chat (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
🔐 fix: Retire Credentials by Their Committing Write and on Opt-In 2FA (#17504)
* fix: Retire Credentials by Their Committing Write and on Opt-In 2FA

Re-date password reset and enrollment cutoffs once the write that set them commits, so a credential minted while the write is in flight is retired. Opt-in 2FA confirmation now stamps twoFactorEnrolledAt and rotates the session like required enrollment. OpenID refresh ends a session established before a reset or enrollment instead of spending its IdP grant.

* fix: Bind Opt-In 2FA Rotation to the Caller's Refresh Session

Confirmation now requires the refresh session the request was issued with, rotates it in place and revokes only the others, so an access token on its own cannot trade enrollment for a long-lived session. The OpenID retirement check moves into the refresh recovery service and ends the session through the existing session-missing path, leaving the CJS controller unchanged.

* fix: Clear a Retired OpenID Session Against the User It Was Checked For

* refactor: Inject the Refresh Secret Into OpenID Recovery

* fix: Report a Reset Whose Cutoff Could Not Be Re-Dated

A failed re-stamp now propagates as RETIREMENT_RESTAMP_FAILED. The reset controller still revokes sessions and passkeys and answers CREDENTIAL_RETIREMENT_INCOMPLETE; the CLI re-stamps after its revocations and exits non-zero; enrollment revokes sessions before the re-stamp and mints nothing when it fails.

* fix: Re-Date the Reset Cutoff Only After the Link Is Consumed

The web reset re-stamps credentialsChangedAt in the controller once resetPassword has committed, consumed the token and sent the notice, so a failed re-stamp can no longer leave the link replayable. Revocations still run and the response reports CREDENTIAL_RETIREMENT_INCOMPLETE.

* fix: Retire Credentials on Every Exit Path After a Reset Commits

resetPasswordWithToken owns the post-commit steps: deleting the link and sending the notice no longer throw, and a link issued before the account's last credentialsChangedAt is refused, so it is spent even when its deletion fails. The reset write is conditioned on the cutoff it read, so one link commits once. The controller re-dates the cutoff and revokes sessions and passkeys whenever the password changed, and answers CREDENTIAL_RETIREMENT_INCOMPLETE if any of that fails.

* fix: Spend Undated Reset Links and Move the Reset Notice Into TypeScript

* fix: Tell the User When a Reset Could Not Sign Out Every Session

The reset page now recognizes CREDENTIAL_RETIREMENT_INCOMPLETE, shared from librechat-data-provider, and shows a localized warning that the new password is active with a sign-in action, instead of reporting the spent link as invalid.

* perf: Re-Date the Reset Cutoff From the Committed Write

The committed reset hands its credentialsChangedAt back through PasswordResetResult, so the controller re-stamps without a second user read.

* fix: Retire Every Superseded Credential Before Minting on 2FA Enrollment

Opt-in confirmation and setup finalization now revoke sessions, re-date twoFactorEnrolledAt and wait for the auth cache to drop the pre-enrollment document, running every step even when one fails. If any step fails they mint nothing and answer CREDENTIAL_RETIREMENT_INCOMPLETE; the settings dialog keeps 2FA on with a warning and the setup screen sends the user back to sign in with the new factor.

* test: Cover the Settings Warning When 2FA Enrollment Leaves Sessions Behind

* fix: Send the User Back to Sign In After an Incomplete Opt-In Enrollment

Enrollment retires the confirming tab's bearer, and an incomplete retirement mints no replacement, so the settings dialog now shows the backup codes and then signs out to the login page, whether the user completes setup or closes the dialog.

* test: Run the Retirement Warning Scenarios Against Their Own Signed-Out or Fresh Accounts

* test: Open the Sidebar Drawer Before the Account Menu on Phones

* fix: Report a Committed Opt-In Enrollment When Rotating the Session Fails

Once 2FA is promoted, a later failure in confirm2FA withdraws whatever session it may have minted and answers CREDENTIAL_RETIREMENT_INCOMPLETE, so the settings dialog hands over the backup codes and signs the user in again instead of reporting an invalid code.

* test: Prove an Incomplete Reset Does Not Resume the Retired Session

The Sign in action sends a browser with a live session through /login for about 150 ms before it lands in chat, so the signed-in version of the warning scenario only passed when the URL check caught that window. The warning scenario runs signed out, and a new scenario holds a pre-reset session and asserts its refresh is refused and the user stays on the login page.

* fix: Settle the Auth Cache After the CLI Reset's Final Cutoff Write

* test: Require the Login Form After Signing In From a Signed-Out Reset

* fix: Explain an Incomplete Session Retirement Before Restarting Sign-In After Required 2FA Setup

* perf: Start the Opt-In Enrollment User and Session Reads Together
2026-11-05 22:53:11 +01:00

205 lines
5.9 KiB
JavaScript

const { encryptV3, logger } = require('@librechat/data-schemas');
const {
clearCloudFrontCookies,
clearEnrollmentNonces,
createOptInTwoFactorConfirmation,
} = require('@librechat/api');
const {
verifyOTPOrBackupCode,
generateBackupCodes,
generateTOTPSecret,
verifyBackupCode,
getTOTPSecret,
verifyTOTP,
} = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('~/server/services/AuthService');
const {
getUserById,
updateUser,
findSession,
deleteAllUserSessions,
awaitAuthUserDocEviction,
} = require('~/models');
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();
const encryptedSecret = encryptV3(secret);
const user = await updateUser(
userId,
clearEnrollmentNonces({
pendingTotpSecret: encryptedSecret,
pendingBackupCodes: codeObjects,
}),
);
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) {
logger.error('[enable2FA]', err);
return res.status(500).json({ message: err.message });
}
};
/**
* Verify a 2FA code (either TOTP or backup code) during setup.
*/
const verify2FA = async (req, res) => {
try {
const userId = req.user.id;
const { token, backupCode } = req.body;
const user = await getUserById(userId, '+totpSecret +pendingTotpSecret +backupCodes _id');
const secretSource = user?.pendingTotpSecret ?? user?.totpSecret;
if (!user || !secretSource) {
return res.status(400).json({ message: '2FA not initiated' });
}
const secret = await getTOTPSecret(secretSource);
let isVerified = false;
if (token) {
isVerified = await verifyTOTP(secret, token);
} else if (backupCode) {
isVerified = await verifyBackupCode({ user, backupCode });
}
if (isVerified) {
return res.status(200).json();
}
return res.status(400).json({ message: 'Invalid token or backup code.' });
} catch (err) {
logger.error('[verify2FA]', err);
return res.status(500).json({ message: err.message });
}
};
/**
* Confirm and enable 2FA after a successful verification.
*/
const confirm2FA = createOptInTwoFactorConfirmation({
getUserById,
getTOTPSecret,
verifyTOTP,
updateUser,
findSession,
deleteAllUserSessions,
awaitAuthUserDocEviction,
clearCloudFrontCookies,
setAuthTokens,
});
/**
* Disable 2FA by clearing the stored secret and backup codes.
* Requires verification with either TOTP token or backup code if 2FA is fully enabled.
*/
const disable2FA = async (req, res) => {
try {
const userId = req.user.id;
const { token, backupCode } = req.body;
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 result = await verifyOTPOrBackupCode({ user, token, backupCode });
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,
clearEnrollmentNonces({
totpSecret: null,
backupCodes: [],
twoFactorEnabled: false,
pendingTotpSecret: null,
pendingBackupCodes: [],
}),
);
return res.status(200).json();
} catch (err) {
logger.error('[disable2FA]', err);
return res.status(500).json({ message: err.message });
}
};
/**
* 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({
backupCodes: plainCodes,
backupCodesHash: codeObjects,
});
} catch (err) {
logger.error('[regenerateBackupCodes]', err);
return res.status(500).json({ message: err.message });
}
};
module.exports = {
enable2FA,
verify2FA,
confirm2FA,
disable2FA,
regenerateBackupCodes,
};