const path = require('path'); const bcrypt = require('bcryptjs'); const readline = require('readline'); const mongoose = require('mongoose'); const { createPasswordResetUpdate, restampRetirementCutoff } = require('@librechat/api'); const { createModels, createMethods } = require('@librechat/data-schemas'); const { User, Passkey, Session } = createModels(mongoose); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const getLogStores = require('~/cache/getLogStores'); const { askSilentQuestion } = require('./helpers'); const connect = require('./connect'); const methods = createMethods(mongoose, { getCache: getLogStores }); const question = (query) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => rl.question(query, (answer) => { rl.close(); resolve(answer); }), ); }; const resetPassword = async () => { try { await connect(); const email = await question('Enter user email: '); const user = await User.findOne({ email }); if (!user) { console.error('User not found!'); process.exit(1); } let validPassword = false; let newPassword; while (!validPassword) { newPassword = await askSilentQuestion('Enter new password: '); if (newPassword.length < 8) { console.log('Password must be at least 8 characters! Please try again.'); continue; } const confirmPassword = await askSilentQuestion('Confirm new password: '); if (newPassword !== confirmPassword) { console.log('Passwords do not match! Please try again.'); continue; } validPassword = true; } const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(newPassword, salt); /** * Routes the write through the data-schemas method so the auth user doc * cache entry for this user is invalidated; a raw updateOne would leave a * cached document that keeps pre-reset access tokens verifying for the * cache TTL. */ const userId = user._id.toString(); /** Stamps credentialsChangedAt and drops any staged enrollment, as the web reset does. */ const update = createPasswordResetUpdate(hashedPassword); const updated = await methods.updateUser(userId, update, {}, { preserveExpiresAt: true }); if (!updated) { console.error('User not found during update!'); process.exit(1); } /** * A passkey signs in on its own, so leaving one in place would keep an attacker * logged in after an administrator believes the account has been recovered. */ let deletedCount; let failure; /** * The password is already changed, so every step runs and the first failure exits non-zero. * The cache settles last: an eviction before the re-stamp could leave a document carrying the * earlier cutoff, which keeps accepting a credential minted during the password write. */ for (const step of [ async () => { ({ deletedCount } = await Passkey.deleteMany({ user: user._id })); }, /** A refresh session outlives the stamp: refreshing mints a token issued after it. */ () => Session.deleteMany({ user: user._id }), () => restampRetirementCutoff( 'credentialsChangedAt', userId, update.credentialsChangedAt, (cutoff, expectedState) => methods.updateUser(userId, cutoff, expectedState, { preserveExpiresAt: true }), ), () => methods.awaitAuthUserDocEviction(userId), ]) { try { await step(); } catch (err) { console.error('Password reset step failed:', err); failure ??= err; } } if (failure) { throw failure; } console.log('Password successfully reset!'); if (deletedCount > 0) { console.log(`Removed ${deletedCount} passkey(s); the user must enroll again.`); } process.exit(0); } catch (err) { console.error('Error resetting password:', err); process.exit(1); } }; resetPassword();