diff --git a/.env.example b/.env.example index 85cf7e14dc..4352f8add7 100644 --- a/.env.example +++ b/.env.example @@ -321,6 +321,7 @@ ALLOW_SOCIAL_LOGIN=false ALLOW_SOCIAL_REGISTRATION=false ALLOW_PASSWORD_RESET=false # ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out +ALLOW_UNVERIFIED_EMAIL_LOGIN=true SESSION_EXPIRY=1000 * 60 * 15 REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7 diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index 4d064fb28f..b1385f1087 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -63,6 +63,10 @@ const namespaces = { [ViolationTypes.TTS_LIMIT]: createViolationInstance(ViolationTypes.TTS_LIMIT), [ViolationTypes.STT_LIMIT]: createViolationInstance(ViolationTypes.STT_LIMIT), [ViolationTypes.FILE_UPLOAD_LIMIT]: createViolationInstance(ViolationTypes.FILE_UPLOAD_LIMIT), + [ViolationTypes.VERIFY_EMAIL_LIMIT]: createViolationInstance(ViolationTypes.VERIFY_EMAIL_LIMIT), + [ViolationTypes.RESET_PASSWORD_LIMIT]: createViolationInstance( + ViolationTypes.RESET_PASSWORD_LIMIT, + ), [ViolationTypes.ILLEGAL_MODEL_REQUEST]: createViolationInstance( ViolationTypes.ILLEGAL_MODEL_REQUEST, ), diff --git a/api/cache/logViolation.js b/api/cache/logViolation.js index 7fe85afd8a..a3162bbfac 100644 --- a/api/cache/logViolation.js +++ b/api/cache/logViolation.js @@ -1,6 +1,6 @@ +const { isEnabled } = require('~/server/utils'); const getLogStores = require('./getLogStores'); const banViolation = require('./banViolation'); -const { isEnabled } = require('../server/utils'); /** * Logs the violation. diff --git a/api/models/Share.js b/api/models/Share.js index ea257eb021..6196ec1864 100644 --- a/api/models/Share.js +++ b/api/models/Share.js @@ -86,4 +86,21 @@ module.exports = { } return await SharedLink.findOneAndDelete({ shareId, user }); }, + /** + * Deletes all shared links for a specific user. + * @param {string} user - The user ID. + * @returns {Promise<{ message: string, deletedCount?: number }>} A result object indicating success or error message. + */ + deleteAllSharedLinks: async (user) => { + try { + const result = await SharedLink.deleteMany({ user }); + return { + message: 'All shared links have been deleted successfully', + deletedCount: result.deletedCount, + }; + } catch (error) { + logger.error('[deleteAllSharedLinks] Error deleting shared links', error); + return { message: 'Error deleting shared links' }; + } + }, }; diff --git a/api/models/User.js b/api/models/User.js index 5e18fbae0c..55750b4ae5 100644 --- a/api/models/User.js +++ b/api/models/User.js @@ -1,61 +1,5 @@ const mongoose = require('mongoose'); -const bcrypt = require('bcryptjs'); -const signPayload = require('../server/services/signPayload'); -const userSchema = require('./schema/userSchema.js'); -const { SESSION_EXPIRY } = process.env ?? {}; -const expires = eval(SESSION_EXPIRY) ?? 1000 * 60 * 15; - -userSchema.methods.toJSON = function () { - return { - id: this._id, - provider: this.provider, - email: this.email, - name: this.name, - username: this.username, - avatar: this.avatar, - role: this.role, - emailVerified: this.emailVerified, - plugins: this.plugins, - createdAt: this.createdAt, - updatedAt: this.updatedAt, - }; -}; - -userSchema.methods.generateToken = async function () { - return await signPayload({ - payload: { - id: this._id, - username: this.username, - provider: this.provider, - email: this.email, - }, - secret: process.env.JWT_SECRET, - expirationTime: expires / 1000, - }); -}; - -userSchema.methods.comparePassword = function (candidatePassword, callback) { - bcrypt.compare(candidatePassword, this.password, (err, isMatch) => { - if (err) { - return callback(err); - } - callback(null, isMatch); - }); -}; - -module.exports.hashPassword = async (password) => { - const hashedPassword = await new Promise((resolve, reject) => { - bcrypt.hash(password, 10, function (err, hash) { - if (err) { - reject(err); - } else { - resolve(hash); - } - }); - }); - - return hashedPassword; -}; +const userSchema = require('~/models/schema/userSchema'); const User = mongoose.model('User', userSchema); diff --git a/api/models/index.js b/api/models/index.js index bf88193823..1f10d251b2 100644 --- a/api/models/index.js +++ b/api/models/index.js @@ -6,9 +6,18 @@ const { deleteMessagesSince, deleteMessages, } = require('./Message'); +const { + comparePassword, + deleteUserById, + generateToken, + getUserById, + updateUser, + createUser, + countUsers, + findUser, +} = require('./userMethods'); const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversation'); const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset'); -const { hashPassword, getUser, updateUser } = require('./userMethods'); const { findFileById, createFile, @@ -29,9 +38,14 @@ module.exports = { Session, Balance, - hashPassword, + comparePassword, + deleteUserById, + generateToken, + getUserById, + countUsers, + createUser, updateUser, - getUser, + findUser, getMessages, saveMessage, diff --git a/api/models/schema/fileSchema.js b/api/models/schema/fileSchema.js index 2075538b1d..da3f31c406 100644 --- a/api/models/schema/fileSchema.js +++ b/api/models/schema/fileSchema.js @@ -3,9 +3,9 @@ const mongoose = require('mongoose'); /** * @typedef {Object} MongoFile - * @property {mongoose.Schema.Types.ObjectId} [_id] - MongoDB Document ID + * @property {ObjectId} [_id] - MongoDB Document ID * @property {number} [__v] - MongoDB Version Key - * @property {mongoose.Schema.Types.ObjectId} user - User ID + * @property {ObjectId} user - User ID * @property {string} [conversationId] - Optional conversation ID * @property {string} file_id - File identifier * @property {string} [temp_file_id] - Temporary File identifier @@ -14,17 +14,19 @@ const mongoose = require('mongoose'); * @property {string} filepath - Location of the file * @property {'file'} object - Type of object, always 'file' * @property {string} type - Type of file - * @property {number} usage - Number of uses of the file + * @property {number} [usage=0] - Number of uses of the file * @property {string} [context] - Context of the file origin - * @property {boolean} [embedded] - Whether or not the file is embedded in vector db + * @property {boolean} [embedded=false] - Whether or not the file is embedded in vector db * @property {string} [model] - The model to identify the group region of the file (for Azure OpenAI hosting) - * @property {string} [source] - The source of the file + * @property {string} [source] - The source of the file (e.g., from FileSources) * @property {number} [width] - Optional width of the file * @property {number} [height] - Optional height of the file - * @property {Date} [expiresAt] - Optional height of the file + * @property {Date} [expiresAt] - Optional expiration date of the file * @property {Date} [createdAt] - Date when the file was created * @property {Date} [updatedAt] - Date when the file was updated */ + +/** @type {MongooseSchema} */ const fileSchema = mongoose.Schema( { user: { @@ -91,7 +93,7 @@ const fileSchema = mongoose.Schema( height: Number, expiresAt: { type: Date, - expires: 3600, + expires: 3600, // 1 hour in seconds }, }, { diff --git a/api/models/schema/tokenSchema.js b/api/models/schema/tokenSchema.js index 0f085dc1de..d74637b641 100644 --- a/api/models/schema/tokenSchema.js +++ b/api/models/schema/tokenSchema.js @@ -7,6 +7,9 @@ const tokenSchema = new Schema({ required: true, ref: 'user', }, + email: { + type: String, + }, token: { type: String, required: true, diff --git a/api/models/schema/userSchema.js b/api/models/schema/userSchema.js index 25fababd91..f32da48cc9 100644 --- a/api/models/schema/userSchema.js +++ b/api/models/schema/userSchema.js @@ -1,5 +1,35 @@ const mongoose = require('mongoose'); +/** + * @typedef {Object} MongoSession + * @property {string} [refreshToken] - The refresh token + */ + +/** + * @typedef {Object} MongoUser + * @property {ObjectId} [_id] - MongoDB Document ID + * @property {string} [name] - The user's name + * @property {string} [username] - The user's username, in lowercase + * @property {string} email - The user's email address + * @property {boolean} emailVerified - Whether the user's email is verified + * @property {string} [password] - The user's password, trimmed with 8-128 characters + * @property {string} [avatar] - The URL of the user's avatar + * @property {string} provider - The provider of the user's account (e.g., 'local', 'google') + * @property {string} [role='USER'] - The role of the user + * @property {string} [googleId] - Optional Google ID for the user + * @property {string} [facebookId] - Optional Facebook ID for the user + * @property {string} [openidId] - Optional OpenID ID for the user + * @property {string} [ldapId] - Optional LDAP ID for the user + * @property {string} [githubId] - Optional GitHub ID for the user + * @property {string} [discordId] - Optional Discord ID for the user + * @property {Array} [plugins=[]] - List of plugins used by the user + * @property {Array.} [refreshToken] - List of sessions with refresh tokens + * @property {Date} [expiresAt] - Optional expiration date of the file + * @property {Date} [createdAt] - Date when the user was created (added by timestamps) + * @property {Date} [updatedAt] - Date when the user was last updated (added by timestamps) + */ + +/** @type {MongooseSchema} */ const Session = mongoose.Schema({ refreshToken: { type: String, @@ -7,6 +37,7 @@ const Session = mongoose.Schema({ }, }); +/** @type {MongooseSchema} */ const userSchema = mongoose.Schema( { name: { @@ -86,6 +117,10 @@ const userSchema = mongoose.Schema( refreshToken: { type: [Session], }, + expiresAt: { + type: Date, + expires: 604800, // 7 days in seconds + }, }, { timestamps: true }, ); diff --git a/api/models/userMethods.js b/api/models/userMethods.js index c1ccce5b52..e84931da83 100644 --- a/api/models/userMethods.js +++ b/api/models/userMethods.js @@ -1,28 +1,37 @@ const bcrypt = require('bcryptjs'); +const signPayload = require('~/server/services/signPayload'); const User = require('./User'); -const hashPassword = async (password) => { - const hashedPassword = await new Promise((resolve, reject) => { - bcrypt.hash(password, 10, function (err, hash) { - if (err) { - reject(err); - } else { - resolve(hash); - } - }); - }); - - return hashedPassword; -}; - /** * Retrieve a user by ID and convert the found user document to a plain object. * * @param {string} userId - The ID of the user to find and return as a plain object. - * @returns {Promise} A plain object representing the user document, or `null` if no user is found. + * @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document. + * @returns {Promise} A plain object representing the user document, or `null` if no user is found. */ -const getUser = async function (userId) { - return await User.findById(userId).lean(); +const getUserById = async function (userId, fieldsToSelect = null) { + const query = User.findById(userId); + + if (fieldsToSelect) { + query.select(fieldsToSelect); + } + + return await query.lean(); +}; + +/** + * Search for a single user based on partial data and return matching user document as plain object. + * @param {Partial} searchCriteria - The partial data to use for searching the user. + * @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document. + * @returns {Promise} A plain object representing the user document, or `null` if no user is found. + */ +const findUser = async function (searchCriteria, fieldsToSelect = null) { + const query = User.findOne(searchCriteria); + if (fieldsToSelect) { + query.select(fieldsToSelect); + } + + return await query.lean(); }; /** @@ -30,17 +39,132 @@ const getUser = async function (userId) { * * @param {string} userId - The ID of the user to update. * @param {Object} updateData - An object containing the properties to update. - * @returns {Promise} The updated user document as a plain object, or `null` if no user is found. + * @returns {Promise} The updated user document as a plain object, or `null` if no user is found. */ const updateUser = async function (userId, updateData) { - return await User.findByIdAndUpdate(userId, updateData, { + const updateOperation = { + $set: updateData, + $unset: { expiresAt: '' }, // Remove the expiresAt field to prevent TTL + }; + return await User.findByIdAndUpdate(userId, updateOperation, { new: true, runValidators: true, }).lean(); }; -module.exports = { - hashPassword, - updateUser, - getUser, +/** + * Creates a new user, optionally with a TTL of 1 week. + * @param {MongoUser} data - The user data to be created, must contain user_id. + * @param {boolean} [disableTTL=true] - Whether to disable the TTL. Defaults to `true`. + * @returns {Promise} A promise that resolves to the created user document ID. + * @throws {Error} If a user with the same user_id already exists. + */ +const createUser = async (data, disableTTL = true) => { + const userData = { + ...data, + expiresAt: new Date(Date.now() + 604800 * 1000), // 1 week in milliseconds + }; + + if (disableTTL) { + delete userData.expiresAt; + } + + try { + const result = await User.collection.insertOne(userData); + return result.insertedId; + } catch (error) { + if (error.code === 11000) { + // Duplicate key error code + throw new Error(`User with \`_id\` ${data._id} already exists.`); + } else { + throw error; + } + } +}; + +/** + * Count the number of user documents in the collection based on the provided filter. + * + * @param {Object} [filter={}] - The filter to apply when counting the documents. + * @returns {Promise} The count of documents that match the filter. + */ +const countUsers = async function (filter = {}) { + return await User.countDocuments(filter); +}; + +/** + * Delete a user by their unique ID. + * + * @param {string} userId - The ID of the user to delete. + * @returns {Promise<{ deletedCount: number }>} An object indicating the number of deleted documents. + */ +const deleteUserById = async function (userId) { + try { + const result = await User.deleteOne({ _id: userId }); + if (result.deletedCount === 0) { + return { deletedCount: 0, message: 'No user found with that ID.' }; + } + return { deletedCount: result.deletedCount, message: 'User was deleted successfully.' }; + } catch (error) { + throw new Error('Error deleting user: ' + error.message); + } +}; + +const { SESSION_EXPIRY } = process.env ?? {}; +const expires = eval(SESSION_EXPIRY) ?? 1000 * 60 * 15; + +/** + * Generates a JWT token for a given user. + * + * @param {MongoUser} user - ID of the user for whom the token is being generated. + * @returns {Promise} A promise that resolves to a JWT token. + */ +const generateToken = async (user) => { + if (!user) { + throw new Error('No user provided'); + } + + return await signPayload({ + payload: { + id: user._id, + username: user.username, + provider: user.provider, + email: user.email, + }, + secret: process.env.JWT_SECRET, + expirationTime: expires / 1000, + }); +}; + +/** + * Compares the provided password with the user's password. + * + * @param {MongoUser} user - the user to compare password for. + * @param {string} candidatePassword - The password to test against the user's password. + * @returns {Promise} A promise that resolves to a boolean indicating if the password matches. + */ +const comparePassword = async (user, candidatePassword) => { + if (!user) { + throw new Error('No user provided'); + } + + return new Promise((resolve, reject) => { + bcrypt.compare(candidatePassword, user.password, (err, isMatch) => { + if (err) { + reject(err); + } + resolve(isMatch); + }); + }); +}; + +module.exports = { + comparePassword, + deleteUserById, + generateToken, + getUserById, + countUsers, + createUser, + updateUser, + findUser, }; diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 25b12dbf4a..1a93254f26 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -1,42 +1,26 @@ const crypto = require('crypto'); const cookies = require('cookie'); const jwt = require('jsonwebtoken'); -const { Session, User } = require('~/models'); const { registerUser, resetPassword, setAuthTokens, requestPasswordReset, } = require('~/server/services/AuthService'); +const { Session, getUserById } = require('~/models'); const { logger } = require('~/config'); const registrationController = async (req, res) => { try { const response = await registerUser(req.body); - if (response.status === 200) { - const { status, user } = response; - let newUser = await User.findOne({ _id: user._id }); - if (!newUser) { - newUser = new User(user); - await newUser.save(); - } - const token = await setAuthTokens(user._id, res); - res.setHeader('Authorization', `Bearer ${token}`); - res.status(status).send({ user }); - } else { - const { status, message } = response; - res.status(status).send({ message }); - } + const { status, message } = response; + res.status(status).send({ message }); } catch (err) { logger.error('[registrationController]', err); return res.status(500).json({ message: err.message }); } }; -const getUserController = async (req, res) => { - return res.status(200).send(req.user); -}; - const resetPasswordRequestController = async (req, res) => { try { const resetService = await requestPasswordReset(req); @@ -77,7 +61,7 @@ const refreshController = async (req, res) => { try { const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET); - const user = await User.findOne({ _id: payload.id }); + const user = await getUserById(payload.id, '-password -__v'); if (!user) { return res.status(401).redirect('/login'); } @@ -86,8 +70,7 @@ const refreshController = async (req, res) => { if (process.env.NODE_ENV === 'CI') { const token = await setAuthTokens(userId, res); - const userObj = user.toJSON(); - return res.status(200).send({ token, user: userObj }); + return res.status(200).send({ token, user }); } // Hash the refresh token @@ -98,8 +81,7 @@ const refreshController = async (req, res) => { const session = await Session.findOne({ user: userId, refreshTokenHash: hashedToken }); if (session && session.expiration > new Date()) { const token = await setAuthTokens(userId, res, session._id); - const userObj = user.toJSON(); - res.status(200).send({ token, user: userObj }); + res.status(200).send({ token, user }); } else if (req?.query?.retry) { // Retrying from a refresh token request that failed (401) res.status(403).send('No session found'); @@ -115,7 +97,6 @@ const refreshController = async (req, res) => { }; module.exports = { - getUserController, refreshController, registrationController, resetPasswordController, diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index b658395b4a..d6ec102ddc 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -1,14 +1,16 @@ const { - User, Session, Balance, deleteFiles, deleteConvos, deletePresets, deleteMessages, + deleteUserById, } = require('~/models'); const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService'); const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService'); +const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService'); +const { deleteAllSharedLinks } = require('~/models/Share'); const { Transaction } = require('~/models/Transaction'); const { logger } = require('~/config'); @@ -59,7 +61,7 @@ const updateUserPluginsController = async (req, res) => { res.status(200).send(); } catch (err) { logger.error('[updateUserPluginsController]', err); - res.status(500).json({ message: err.message }); + return res.status(500).json({ message: 'Something went wrong.' }); } }; @@ -75,18 +77,50 @@ const deleteUserController = async (req, res) => { await deletePresets(user.id); // delete user presets await deleteConvos(user.id); // delete user convos await deleteUserPluginAuth(user.id, null, true); // delete user plugin auth - await User.deleteOne({ _id: user.id }); // delete user + await deleteUserById(user.id); // delete user await deleteFiles(null, user.id); // delete user files + await deleteAllSharedLinks(user.id); // delete user shared links + /* TODO: queue job for cleaning actions and assistants of non-existant users */ logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`); res.status(200).send({ message: 'User deleted' }); } catch (err) { logger.error('[deleteUserController]', err); - res.status(500).send({ message: err.message }); + return res.status(500).json({ message: 'Something went wrong.' }); + } +}; + +const verifyEmailController = async (req, res) => { + try { + const verifyEmailService = await verifyEmail(req); + if (verifyEmailService instanceof Error) { + return res.status(400).json(verifyEmailService); + } else { + return res.status(200).json(verifyEmailService); + } + } catch (e) { + logger.error('[verifyEmailController]', e); + return res.status(500).json({ message: 'Something went wrong.' }); + } +}; + +const resendVerificationController = async (req, res) => { + try { + const result = await resendVerificationEmail(req); + if (result instanceof Error) { + return res.status(400).json(result); + } else { + return res.status(200).json(result); + } + } catch (e) { + logger.error('[verifyEmailController]', e); + return res.status(500).json({ message: 'Something went wrong.' }); } }; module.exports = { getUserController, - updateUserPluginsController, deleteUserController, + verifyEmailController, + updateUserPluginsController, + resendVerificationController, }; diff --git a/api/server/controllers/auth/LoginController.js b/api/server/controllers/auth/LoginController.js index 1b3b6180b9..414be8253e 100644 --- a/api/server/controllers/auth/LoginController.js +++ b/api/server/controllers/auth/LoginController.js @@ -1,17 +1,21 @@ -const User = require('~/models/User'); const { setAuthTokens } = require('~/server/services/AuthService'); +const { getUserById } = require('~/models/userMethods'); +const { isEnabled } = require('~/server/utils'); const { logger } = require('~/config'); const loginController = async (req, res) => { try { - const user = await User.findById(req.user._id); + const user = await getUserById(req.user._id, '-password -__v'); // If user doesn't exist, return error if (!user) { - // typeof user !== User) { // this doesn't seem to resolve the User type ?? return res.status(400).json({ message: 'Invalid credentials' }); } + if (!user.emailVerified && !isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN)) { + return res.status(422).json({ message: 'Email not verified' }); + } + const token = await setAuthTokens(user._id, res); return res.status(200).send({ token, user }); diff --git a/api/server/middleware/checkBan.js b/api/server/middleware/checkBan.js index ee7d7171de..e5707761eb 100644 --- a/api/server/middleware/checkBan.js +++ b/api/server/middleware/checkBan.js @@ -1,11 +1,11 @@ const Keyv = require('keyv'); const uap = require('ua-parser-js'); const { ViolationTypes } = require('librechat-data-provider'); -const { isEnabled, removePorts } = require('../utils'); +const { isEnabled, removePorts } = require('~/server/utils'); const keyvMongo = require('~/cache/keyvMongo'); const denyRequest = require('./denyRequest'); const { getLogStores } = require('~/cache'); -const User = require('~/models/User'); +const { findUser } = require('~/models'); const banCache = new Keyv({ store: keyvMongo, namespace: ViolationTypes.BAN, ttl: 0 }); const message = 'Your account has been temporarily banned due to violations of our service.'; @@ -55,7 +55,7 @@ const checkBan = async (req, res, next = () => {}) => { let userId = req.user?.id ?? req.user?._id ?? null; if (!userId && req?.body?.email) { - const user = await User.findOne({ email: req.body.email }, '_id').lean(); + const user = await findUser({ email: req.body.email }, '_id'); userId = user?._id ? user._id.toString() : userId; } diff --git a/api/server/middleware/index.js b/api/server/middleware/index.js index 1dfb859b3c..8d3455af34 100644 --- a/api/server/middleware/index.js +++ b/api/server/middleware/index.js @@ -1,51 +1,43 @@ -const abortMiddleware = require('./abortMiddleware'); -const checkBan = require('./checkBan'); -const checkDomainAllowed = require('./checkDomainAllowed'); -const uaParser = require('./uaParser'); -const setHeaders = require('./setHeaders'); -const loginLimiter = require('./loginLimiter'); -const validateModel = require('./validateModel'); -const requireJwtAuth = require('./requireJwtAuth'); -const requireLdapAuth = require('./requireLdapAuth'); -const uploadLimiters = require('./uploadLimiters'); -const registerLimiter = require('./registerLimiter'); -const messageLimiters = require('./messageLimiters'); -const requireLocalAuth = require('./requireLocalAuth'); -const validateEndpoint = require('./validateEndpoint'); -const concurrentLimiter = require('./concurrentLimiter'); -const validateMessageReq = require('./validateMessageReq'); -const buildEndpointOption = require('./buildEndpointOption'); +const validatePasswordReset = require('./validatePasswordReset'); const validateRegistration = require('./validateRegistration'); const validateImageRequest = require('./validateImageRequest'); -const validatePasswordReset = require('./validatePasswordReset'); -const moderateText = require('./moderateText'); -const noIndex = require('./noIndex'); -const importLimiters = require('./importLimiters'); +const buildEndpointOption = require('./buildEndpointOption'); +const validateMessageReq = require('./validateMessageReq'); +const checkDomainAllowed = require('./checkDomainAllowed'); +const concurrentLimiter = require('./concurrentLimiter'); +const validateEndpoint = require('./validateEndpoint'); +const requireLocalAuth = require('./requireLocalAuth'); const canDeleteAccount = require('./canDeleteAccount'); +const requireLdapAuth = require('./requireLdapAuth'); +const abortMiddleware = require('./abortMiddleware'); +const requireJwtAuth = require('./requireJwtAuth'); +const validateModel = require('./validateModel'); +const moderateText = require('./moderateText'); +const setHeaders = require('./setHeaders'); +const limiters = require('./limiters'); +const uaParser = require('./uaParser'); +const checkBan = require('./checkBan'); +const noIndex = require('./noIndex'); module.exports = { - ...uploadLimiters, ...abortMiddleware, - ...messageLimiters, + ...limiters, + noIndex, checkBan, uaParser, setHeaders, - loginLimiter, + moderateText, + validateModel, requireJwtAuth, requireLdapAuth, - registerLimiter, requireLocalAuth, + canDeleteAccount, validateEndpoint, concurrentLimiter, + checkDomainAllowed, validateMessageReq, buildEndpointOption, validateRegistration, validateImageRequest, validatePasswordReset, - validateModel, - moderateText, - noIndex, - ...importLimiters, - checkDomainAllowed, - canDeleteAccount, }; diff --git a/api/server/middleware/importLimiters.js b/api/server/middleware/limiters/importLimiters.js similarity index 100% rename from api/server/middleware/importLimiters.js rename to api/server/middleware/limiters/importLimiters.js diff --git a/api/server/middleware/limiters/index.js b/api/server/middleware/limiters/index.js new file mode 100644 index 0000000000..0ae6bb5c5e --- /dev/null +++ b/api/server/middleware/limiters/index.js @@ -0,0 +1,22 @@ +const createTTSLimiters = require('./ttsLimiters'); +const createSTTLimiters = require('./sttLimiters'); + +const loginLimiter = require('./loginLimiter'); +const importLimiters = require('./importLimiters'); +const uploadLimiters = require('./uploadLimiters'); +const registerLimiter = require('./registerLimiter'); +const messageLimiters = require('./messageLimiters'); +const verifyEmailLimiter = require('./verifyEmailLimiter'); +const resetPasswordLimiter = require('./resetPasswordLimiter'); + +module.exports = { + ...uploadLimiters, + ...importLimiters, + ...messageLimiters, + loginLimiter, + registerLimiter, + createTTSLimiters, + createSTTLimiters, + verifyEmailLimiter, + resetPasswordLimiter, +}; diff --git a/api/server/middleware/loginLimiter.js b/api/server/middleware/limiters/loginLimiter.js similarity index 88% rename from api/server/middleware/loginLimiter.js rename to api/server/middleware/limiters/loginLimiter.js index bdc95e2878..937723e859 100644 --- a/api/server/middleware/loginLimiter.js +++ b/api/server/middleware/limiters/loginLimiter.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { logViolation } = require('../../cache'); -const { removePorts } = require('../utils'); +const { removePorts } = require('~/server/utils'); +const { logViolation } = require('~/cache'); const { LOGIN_WINDOW = 5, LOGIN_MAX = 7, LOGIN_VIOLATION_SCORE: score } = process.env; const windowMs = LOGIN_WINDOW * 60 * 1000; diff --git a/api/server/middleware/messageLimiters.js b/api/server/middleware/limiters/messageLimiters.js similarity index 93% rename from api/server/middleware/messageLimiters.js rename to api/server/middleware/limiters/messageLimiters.js index 63bac7e181..c84db1043c 100644 --- a/api/server/middleware/messageLimiters.js +++ b/api/server/middleware/limiters/messageLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { logViolation } = require('../../cache'); -const denyRequest = require('./denyRequest'); +const denyRequest = require('~/server/middleware/denyRequest'); +const { logViolation } = require('~/cache'); const { MESSAGE_IP_MAX = 40, diff --git a/api/server/middleware/registerLimiter.js b/api/server/middleware/limiters/registerLimiter.js similarity index 88% rename from api/server/middleware/registerLimiter.js rename to api/server/middleware/limiters/registerLimiter.js index e19e261cbe..b069798b03 100644 --- a/api/server/middleware/registerLimiter.js +++ b/api/server/middleware/limiters/registerLimiter.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { logViolation } = require('../../cache'); -const { removePorts } = require('../utils'); +const { removePorts } = require('~/server/utils'); +const { logViolation } = require('~/cache'); const { REGISTER_WINDOW = 60, REGISTER_MAX = 5, REGISTRATION_VIOLATION_SCORE: score } = process.env; const windowMs = REGISTER_WINDOW * 60 * 1000; diff --git a/api/server/middleware/limiters/resetPasswordLimiter.js b/api/server/middleware/limiters/resetPasswordLimiter.js new file mode 100644 index 0000000000..5d2deb0282 --- /dev/null +++ b/api/server/middleware/limiters/resetPasswordLimiter.js @@ -0,0 +1,35 @@ +const rateLimit = require('express-rate-limit'); +const { ViolationTypes } = require('librechat-data-provider'); +const { removePorts } = require('~/server/utils'); +const { logViolation } = require('~/cache'); + +const { + RESET_PASSWORD_WINDOW = 2, + RESET_PASSWORD_MAX = 2, + RESET_PASSWORD_VIOLATION_SCORE: score, +} = process.env; +const windowMs = RESET_PASSWORD_WINDOW * 60 * 1000; +const max = RESET_PASSWORD_MAX; +const windowInMinutes = windowMs / 60000; +const message = `Too many attempts, please try again after ${windowInMinutes} minute(s)`; + +const handler = async (req, res) => { + const type = ViolationTypes.RESET_PASSWORD_LIMIT; + const errorMessage = { + type, + max, + windowInMinutes, + }; + + await logViolation(req, res, type, errorMessage, score); + return res.status(429).json({ message }); +}; + +const resetPasswordLimiter = rateLimit({ + windowMs, + max, + handler, + keyGenerator: removePorts, +}); + +module.exports = resetPasswordLimiter; diff --git a/api/server/middleware/speech/sttLimiters.js b/api/server/middleware/limiters/sttLimiters.js similarity index 100% rename from api/server/middleware/speech/sttLimiters.js rename to api/server/middleware/limiters/sttLimiters.js diff --git a/api/server/middleware/speech/ttsLimiters.js b/api/server/middleware/limiters/ttsLimiters.js similarity index 100% rename from api/server/middleware/speech/ttsLimiters.js rename to api/server/middleware/limiters/ttsLimiters.js diff --git a/api/server/middleware/uploadLimiters.js b/api/server/middleware/limiters/uploadLimiters.js similarity index 100% rename from api/server/middleware/uploadLimiters.js rename to api/server/middleware/limiters/uploadLimiters.js diff --git a/api/server/middleware/limiters/verifyEmailLimiter.js b/api/server/middleware/limiters/verifyEmailLimiter.js new file mode 100644 index 0000000000..770090dba5 --- /dev/null +++ b/api/server/middleware/limiters/verifyEmailLimiter.js @@ -0,0 +1,35 @@ +const rateLimit = require('express-rate-limit'); +const { ViolationTypes } = require('librechat-data-provider'); +const { removePorts } = require('~/server/utils'); +const { logViolation } = require('~/cache'); + +const { + VERIFY_EMAIL_WINDOW = 2, + VERIFY_EMAIL_MAX = 2, + VERIFY_EMAIL_VIOLATION_SCORE: score, +} = process.env; +const windowMs = VERIFY_EMAIL_WINDOW * 60 * 1000; +const max = VERIFY_EMAIL_MAX; +const windowInMinutes = windowMs / 60000; +const message = `Too many attempts, please try again after ${windowInMinutes} minute(s)`; + +const handler = async (req, res) => { + const type = ViolationTypes.VERIFY_EMAIL_LIMIT; + const errorMessage = { + type, + max, + windowInMinutes, + }; + + await logViolation(req, res, type, errorMessage, score); + return res.status(429).json({ message }); +}; + +const verifyEmailLimiter = rateLimit({ + windowMs, + max, + handler, + keyGenerator: removePorts, +}); + +module.exports = verifyEmailLimiter; diff --git a/api/server/middleware/requireLocalAuth.js b/api/server/middleware/requireLocalAuth.js index 107d370e85..8319baf345 100644 --- a/api/server/middleware/requireLocalAuth.js +++ b/api/server/middleware/requireLocalAuth.js @@ -21,7 +21,13 @@ const requireLocalAuth = (req, res, next) => { log({ title: '(requireLocalAuth) Error: No user', }); - return res.status(422).send(info); + return res.status(404).send(info); + } + if (info && info.message) { + log({ + title: '(requireLocalAuth) Error: ' + info.message, + }); + return res.status(422).send({ message: info.message }); } req.user = user; next(); diff --git a/api/server/middleware/speech/index.js b/api/server/middleware/speech/index.js deleted file mode 100644 index 1cf0bc4cc9..0000000000 --- a/api/server/middleware/speech/index.js +++ /dev/null @@ -1,7 +0,0 @@ -const createTTSLimiters = require('./ttsLimiters'); -const createSTTLimiters = require('./sttLimiters'); - -module.exports = { - createTTSLimiters, - createSTTLimiters, -}; diff --git a/api/server/routes/auth.js b/api/server/routes/auth.js index 53bb6a0a35..946c6cd420 100644 --- a/api/server/routes/auth.js +++ b/api/server/routes/auth.js @@ -1,22 +1,23 @@ const express = require('express'); const { - resetPasswordRequestController, - resetPasswordController, refreshController, registrationController, -} = require('../controllers/AuthController'); -const { loginController } = require('../controllers/auth/LoginController'); -const { logoutController } = require('../controllers/auth/LogoutController'); + resetPasswordController, + resetPasswordRequestController, +} = require('~/server/controllers/AuthController'); +const { loginController } = require('~/server/controllers/auth/LoginController'); +const { logoutController } = require('~/server/controllers/auth/LogoutController'); const { checkBan, loginLimiter, - registerLimiter, requireJwtAuth, + registerLimiter, requireLdapAuth, requireLocalAuth, + resetPasswordLimiter, validateRegistration, validatePasswordReset, -} = require('../middleware'); +} = require('~/server/middleware'); const router = express.Router(); @@ -35,6 +36,7 @@ router.post('/refresh', refreshController); router.post('/register', registerLimiter, checkBan, validateRegistration, registrationController); router.post( '/requestPasswordReset', + resetPasswordLimiter, checkBan, validatePasswordReset, resetPasswordRequestController, diff --git a/api/server/routes/files/index.js b/api/server/routes/files/index.js index 7f93018d1c..2911ecb0b3 100644 --- a/api/server/routes/files/index.js +++ b/api/server/routes/files/index.js @@ -1,6 +1,12 @@ const express = require('express'); -const { uaParser, checkBan, requireJwtAuth, createFileLimiters } = require('~/server/middleware'); -const { createTTSLimiters, createSTTLimiters } = require('~/server/middleware/speech'); +const { + uaParser, + checkBan, + requireJwtAuth, + createFileLimiters, + createTTSLimiters, + createSTTLimiters, +} = require('~/server/middleware'); const { createMulterInstance } = require('./multer'); const files = require('./files'); diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index 0749436865..f84724841e 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -1,12 +1,12 @@ // file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware - -const passport = require('passport'); const express = require('express'); -const router = express.Router(); -const { setAuthTokens } = require('~/server/services/AuthService'); +const passport = require('passport'); const { loginLimiter, checkBan, checkDomainAllowed } = require('~/server/middleware'); +const { setAuthTokens } = require('~/server/services/AuthService'); const { logger } = require('~/config'); +const router = express.Router(); + const domains = { client: process.env.DOMAIN_CLIENT, server: process.env.DOMAIN_SERVER, diff --git a/api/server/routes/user.js b/api/server/routes/user.js index 96e09b446d..5f260d076d 100644 --- a/api/server/routes/user.js +++ b/api/server/routes/user.js @@ -1,16 +1,19 @@ const express = require('express'); -const requireJwtAuth = require('../middleware/requireJwtAuth'); -const canDeleteAccount = require('../middleware/canDeleteAccount'); +const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware'); const { getUserController, - updateUserPluginsController, deleteUserController, -} = require('../controllers/UserController'); + verifyEmailController, + updateUserPluginsController, + resendVerificationController, +} = require('~/server/controllers/UserController'); const router = express.Router(); router.get('/', requireJwtAuth, getUserController); router.post('/plugins', requireJwtAuth, updateUserPluginsController); router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController); +router.post('/verify', verifyEmailController); +router.post('/verify/resend', verifyEmailLimiter, resendVerificationController); module.exports = router; diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 2e71be096f..489c3f32fb 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -1,13 +1,20 @@ const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const { errorsToString } = require('librechat-data-provider'); +const { + countUsers, + createUser, + findUser, + updateUser, + generateToken, + getUserById, +} = require('~/models/userMethods'); +const { sendEmail, checkEmailConfig } = require('~/server/utils'); const { registerSchema } = require('~/strategies/validators'); const isDomainAllowed = require('./isDomainAllowed'); const Token = require('~/models/schema/tokenSchema'); -const { sendEmail } = require('~/server/utils'); const Session = require('~/models/Session'); const { logger } = require('~/config'); -const User = require('~/models/User'); const domains = { client: process.env.DOMAIN_CLIENT, @@ -15,19 +22,7 @@ const domains = { }; const isProduction = process.env.NODE_ENV === 'production'; - -/** - * Check if email configuration is set - * @returns {Boolean} - */ -function checkEmailConfig() { - return ( - (!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) && - !!process.env.EMAIL_USERNAME && - !!process.env.EMAIL_PASSWORD && - !!process.env.EMAIL_FROM - ); -} +const genericVerificationMessage = 'Please check your email to verify your email address.'; /** * Logout user @@ -58,10 +53,73 @@ const logoutUser = async (userId, refreshToken) => { }; /** - * Register a new user - * - * @param {Object} user - * @returns + * Send Verification Email + * @param {Partial & { _id: ObjectId, email: string, name: string}} user + * @returns {Promise} + */ +const sendVerificationEmail = async (user) => { + let verifyToken = crypto.randomBytes(32).toString('hex'); + const hash = bcrypt.hashSync(verifyToken, 10); + + await new Token({ + userId: user._id, + email: user.email, + token: hash, + createdAt: Date.now(), + }).save(); + + const verificationLink = `${domains.client}/verify?token=${verifyToken}&email=${user.email}`; + logger.info(`[sendVerificationEmail] Verification link issued. [Email: ${user.email}]`); + + sendEmail( + user.email, + 'Verify your email', + { + appName: process.env.APP_TITLE || 'LibreChat', + name: user.name, + verificationLink: verificationLink, + year: new Date().getFullYear(), + }, + 'verifyEmail.handlebars', + ); + return; +}; + +/** + * Verify Email + * @param {Express.Request} req + */ +const verifyEmail = async (req) => { + const { email, token } = req.body; + let emailVerificationData = await Token.findOne({ email }); + + if (!emailVerificationData) { + logger.warn(`[verifyEmail] [No email verification data found] [Email: ${email}]`); + return new Error('Invalid or expired password reset token'); + } + + const isValid = bcrypt.compareSync(token, emailVerificationData.token); + + if (!isValid) { + logger.warn(`[verifyEmail] [Invalid or expired email verification token] [Email: ${email}]`); + return new Error('Invalid or expired email verification token'); + } + + const updatedUser = await updateUser(emailVerificationData.userId, { emailVerified: true }); + if (!updatedUser) { + logger.warn(`[verifyEmail] [User not found] [Email: ${email}]`); + return new Error('User not found'); + } + + await emailVerificationData.deleteOne(); + logger.info(`[verifyEmail] Email verification successful. [Email: ${email}]`); + return { message: 'Email verification was successful' }; +}; + +/** + * Register a new user. + * @param {MongoUser} user + * @returns {Promise<{status: number, message: string, user?: MongoUser}>} */ const registerUser = async (user) => { const { error } = registerSchema.safeParse(user); @@ -73,13 +131,13 @@ const registerUser = async (user) => { { name: 'Validation error:', value: errorMessage }, ); - return { status: 422, message: errorMessage }; + return { status: 404, message: errorMessage }; } const { email, password, name, username } = user; try { - const existingUser = await User.findOne({ email }).lean(); + const existingUser = await findUser({ email }, 'email _id'); if (existingUser) { logger.info( @@ -90,38 +148,46 @@ const registerUser = async (user) => { // Sleep for 1 second await new Promise((resolve) => setTimeout(resolve, 1000)); - - // TODO: We should change the process to always email and be generic is signup works or fails (user enum) - return { status: 500, message: 'Something went wrong' }; + return { status: 200, message: genericVerificationMessage }; } if (!(await isDomainAllowed(email))) { - const errorMessage = 'Registration from this domain is not allowed.'; + const errorMessage = + 'The email address provided cannot be used. Please use a different email address.'; logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`); return { status: 403, message: errorMessage }; } //determine if this is the first registered user (not counting anonymous_user) - const isFirstRegisteredUser = (await User.countDocuments({})) === 0; + const isFirstRegisteredUser = (await countUsers()) === 0; - const newUser = await new User({ + const salt = bcrypt.genSaltSync(10); + const newUserData = { provider: 'local', email, - password, username, name, avatar: null, role: isFirstRegisteredUser ? 'ADMIN' : 'USER', - }); + password: bcrypt.hashSync(password, salt), + }; - const salt = bcrypt.genSaltSync(10); - const hash = bcrypt.hashSync(newUser.password, salt); - newUser.password = hash; - await newUser.save(); + const emailEnabled = checkEmailConfig(); + const newUserId = await createUser(newUserData, false); + if (emailEnabled) { + await sendVerificationEmail({ + _id: newUserId, + email, + name, + }); + } else { + await updateUser(newUserId, { emailVerified: true }); + } - return { status: 200, user: newUser }; + return { status: 200, message: genericVerificationMessage }; } catch (err) { - return { status: 500, message: err?.message || 'Something went wrong' }; + logger.error('[registerUser] Error in registering user:', err); + return { status: 500, message: 'Something went wrong' }; } }; @@ -131,7 +197,7 @@ const registerUser = async (user) => { */ const requestPasswordReset = async (req) => { const { email } = req.body; - const user = await User.findOne({ email }).lean(); + const user = await findUser({ email }, 'email _id'); const emailEnabled = checkEmailConfig(); logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`); @@ -208,13 +274,9 @@ const resetPassword = async (userId, token, password) => { } const hash = bcrypt.hashSync(password, 10); + const user = await updateUser(userId, { password: hash }); - await User.updateOne({ _id: userId }, { $set: { password: hash } }, { new: true }); - - const user = await User.findById({ _id: userId }); - const emailEnabled = checkEmailConfig(); - - if (emailEnabled) { + if (checkEmailConfig()) { sendEmail( user.email, 'Password Reset Successfully', @@ -242,8 +304,8 @@ const resetPassword = async (userId, token, password) => { */ const setAuthTokens = async (userId, res, sessionId = null) => { try { - const user = await User.findOne({ _id: userId }); - const token = await user.generateToken(); + const user = await getUserById(userId); + const token = await generateToken(user); let session; let refreshTokenExpires; @@ -273,11 +335,69 @@ const setAuthTokens = async (userId, res, sessionId = null) => { } }; +/** + * Resend Verification Email + * @param {Object} req + * @param {Object} req.body + * @param {String} req.body.email + * @returns {Promise<{status: number, message: string}>} + */ +const resendVerificationEmail = async (req) => { + try { + const { email } = req.body; + await Token.deleteMany({ email }); + const user = await findUser({ email }, 'email _id name'); + + if (!user) { + logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`); + return { status: 200, message: genericVerificationMessage }; + } + + let verifyToken = crypto.randomBytes(32).toString('hex'); + const hash = bcrypt.hashSync(verifyToken, 10); + + await new Token({ + userId: user._id, + email: user.email, + token: hash, + createdAt: Date.now(), + }).save(); + + const verificationLink = `${domains.client}/verify?token=${verifyToken}&email=${user.email}`; + logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`); + + sendEmail( + user.email, + 'Verify your email', + { + appName: process.env.APP_TITLE || 'LibreChat', + name: user.name, + verificationLink: verificationLink, + year: new Date().getFullYear(), + }, + 'verifyEmail.handlebars', + ); + + return { + status: 200, + message: genericVerificationMessage, + }; + } catch (error) { + logger.error(`[resendVerificationEmail] Error resending verification email: ${error.message}`); + return { + status: 500, + message: 'Something went wrong.', + }; + } +}; + module.exports = { - registerUser, logoutUser, + verifyEmail, + registerUser, + setAuthTokens, + resetPassword, isDomainAllowed, requestPasswordReset, - resetPassword, - setAuthTokens, + resendVerificationEmail, }; diff --git a/api/server/services/UserService.js b/api/server/services/UserService.js index 08f050c396..6c736e4366 100644 --- a/api/server/services/UserService.js +++ b/api/server/services/UserService.js @@ -1,6 +1,6 @@ const { ErrorTypes } = require('librechat-data-provider'); const { encrypt, decrypt } = require('~/server/utils'); -const { User, Key } = require('~/models'); +const { updateUser, Key } = require('~/models'); const { logger } = require('~/config'); /** @@ -16,16 +16,13 @@ const { logger } = require('~/config'); */ const updateUserPluginsService = async (user, pluginKey, action) => { try { + const userPlugins = user.plugins || []; if (action === 'install') { - return await User.updateOne( - { _id: user._id }, - { $set: { plugins: [...user.plugins, pluginKey] } }, - ); + return await updateUser(user._id, { plugins: [...userPlugins, pluginKey] }); } else if (action === 'uninstall') { - return await User.updateOne( - { _id: user._id }, - { $set: { plugins: user.plugins.filter((plugin) => plugin !== pluginKey) } }, - ); + return await updateUser(user._id, { + plugins: userPlugins.filter((plugin) => plugin !== pluginKey), + }); } } catch (err) { logger.error('[updateUserPluginsService]', err); @@ -166,30 +163,12 @@ const checkUserKeyExpiry = (expiresAt, endpoint) => { } }; -/** - * Retrieves a user document from the database based on the provided email. - * @async - * @param {string} email - The email of the user to find. - * @returns {Promise} The user document if found, otherwise null. - * @throws {Error} Throws an error if there is a problem during user retrieval. - */ -const findUserByEmail = async (email) => { - try { - const user = await User.findOne({ email }); - return user; - } catch (error) { - logger.error(`[findUserByEmail] Error occurred while finding user by email: ${email}`, error); - throw error; - } -}; - module.exports = { - updateUserPluginsService, getUserKey, - getUserKeyValues, - getUserKeyExpiry, updateUserKey, deleteUserKey, + getUserKeyValues, + getUserKeyExpiry, checkUserKeyExpiry, - findUserByEmail, + updateUserPluginsService, }; diff --git a/api/server/services/start/checks.js b/api/server/services/start/checks.js index 904fbf8942..2b16bf2e07 100644 --- a/api/server/services/start/checks.js +++ b/api/server/services/start/checks.js @@ -3,7 +3,7 @@ const { deprecatedAzureVariables, conflictingAzureVariables, } = require('librechat-data-provider'); -const { isEnabled } = require('~/server/utils'); +const { isEnabled, checkEmailConfig } = require('~/server/utils'); const { logger } = require('~/config'); const secretDefaults = { @@ -111,12 +111,7 @@ Latest version: ${Constants.CONFIG_VERSION} } function checkPasswordReset() { - const emailEnabled = - (!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) && - !!process.env.EMAIL_USERNAME && - !!process.env.EMAIL_PASSWORD && - !!process.env.EMAIL_FROM; - + const emailEnabled = checkEmailConfig(); const passwordResetAllowed = isEnabled(process.env.ALLOW_PASSWORD_RESET); if (!emailEnabled && passwordResetAllowed) { diff --git a/api/server/utils/emails/passwordReset.handlebars b/api/server/utils/emails/passwordReset.handlebars index d41566c598..9076b92edb 100644 --- a/api/server/utils/emails/passwordReset.handlebars +++ b/api/server/utils/emails/passwordReset.handlebars @@ -1,9 +1,11 @@ - - + - - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - -
- -
-
-
- - -
-
- -
- - - - - - - -
-
-
Hi {{name}},
-
-
- - - - - - -
-
-
-
Your password has been updated successfully!
-
-
-
- - - - - - -
-
-
Best regards,
-
The {{appName}} Team
-
-
- - - - - - -
-
-
-
© {{year}} {{appName}}. All rights - reserved.
-
-
-
- -
- -
-
- - -
-
-
- -
- - - + + + + + + + + + +
+ +
+
+
+ + +
+
+ +
+ + + + + + + +
+
+
Hi {{name}},
+
+
+ + + + + + +
+
+
+
Your password has been updated successfully!
+
+
+
+ + + + + + +
+
+
Best regards,
+
The {{appName}} Team
+
+
+ + + + + + +
+
+
+
© + {{year}} + {{appName}}. All rights reserved.
+
+
+
+ +
+ +
+
+ + +
+
+
+ +
+ + + \ No newline at end of file diff --git a/api/server/utils/emails/requestPasswordReset.handlebars b/api/server/utils/emails/requestPasswordReset.handlebars index e579ec0d5c..2600b5a9d3 100644 --- a/api/server/utils/emails/requestPasswordReset.handlebars +++ b/api/server/utils/emails/requestPasswordReset.handlebars @@ -1,9 +1,11 @@ - - + - - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - -
- -
-
-
- - -
-
- -
- - - - - - - -
- -

-
-
You have requested to reset your password. -
-
-

- -
- - - - - - -
-
-
Hi {{name}},
-
-
- - - - - - -
-
-

Please click the button below to reset your password.

-
-
- - - - - - -
- - -
- - - - - - -
-
-
-
If you did not request a password reset, please ignore this email.
-
-
-
- - - - - - -
-
-
Best regards,
-
The {{appName}} Team
-
-
- - - - - - -
-
-
-
© {{year}} {{appName}}. All rights - reserved.
-
-
-
- -
- -
-
- - -
-
-
- -
- - - + + + + + + + + + +
+ +
+
+
+ + +
+
+ +
+ + + + + + + +
+ +

+
+
You have requested to reset your password. +
+
+

+ +
+ + + + + + +
+
+
Hi {{name}},
+
+
+ + + + + + +
+
+

Please click the button below to + reset your password.

+
+
+ + + + + + +
+ + +
+ + + + + + +
+
+
+
If you did not request a password reset, please ignore this + email.
+
+
+
+ + + + + + +
+
+
Best regards,
+
The {{appName}} Team
+
+
+ + + + + + +
+
+
+
© + {{year}} + {{appName}}. All rights reserved.
+
+
+
+ +
+ +
+
+ + +
+
+
+ +
+ + + \ No newline at end of file diff --git a/api/server/utils/emails/verifyEmail.handlebars b/api/server/utils/emails/verifyEmail.handlebars index 2855d4647e..63b52e79be 100644 --- a/api/server/utils/emails/verifyEmail.handlebars +++ b/api/server/utils/emails/verifyEmail.handlebars @@ -1,9 +1,11 @@ - - + - - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - -
- -
-
-
- - -
-
- -
- - - - - - - -
- -

-
-
Welcome to {{appName}}!
-
-

- -
- - - - - - -
-
-
-
Dear {{name}},
-
-
-
- - - - - - -
-
-
-
Thank you for registering with {{appName}}. To complete your registration and verify your email address, please click the button below:
-
-
-
- - - - - - -
- - -
- - - - - - -
-
-
-
If you did not create an account with {{appName}}, please ignore this email.
-
-
-
- - - - - - -
-
-
Best regards,
-
The {{appName}} Team
-
-
- - - - - - -
-
-
-
© {{year}} {{appName}}. All rights - reserved.
-
-
-
- -
- -
-
- - -
-
-
- -
- - - + + + + + + + + + +
+ +
+
+
+ + +
+
+ +
+ + + + + + + +
+ +

+
+
Welcome to {{appName}}!
+
+

+ +
+ + + + + + +
+
+
+
Dear {{name}},
+
+
+
+ + + + + + +
+
+
+
Thank you for registering with + {{appName}}. To complete your registration and verify your + email address, please click the button below:
+
+
+
+ + + + + + +
+ + +
+ + + + + + +
+
+
+
If you did not create an account with + {{appName}}, please ignore this email.
+
+
+
+ + + + + + +
+
+
Best regards,
+
The {{appName}} Team
+
+
+ + + + + + +
+
+
+
© + {{year}} + {{appName}}. All rights reserved.
+
+
+
+ +
+ +
+
+ + +
+
+
+ +
+ + + \ No newline at end of file diff --git a/api/server/utils/import/importers.js b/api/server/utils/import/importers.js index 7b512cc4c4..e262f1ae12 100644 --- a/api/server/utils/import/importers.js +++ b/api/server/utils/import/importers.js @@ -135,7 +135,7 @@ async function importLibreChatConvo( }); } - if (!firstMessageDate) { + if (!firstMessageDate && message.createdAt) { firstMessageDate = new Date(message.createdAt); } @@ -150,7 +150,7 @@ async function importLibreChatConvo( const idMapping = new Map(); for (const message of messagesToImport) { - if (!firstMessageDate) { + if (!firstMessageDate && message.createdAt) { firstMessageDate = new Date(message.createdAt); } const newMessageId = uuidv4(); @@ -171,6 +171,10 @@ async function importLibreChatConvo( throw new Error('Invalid LibreChat file format'); } + if (firstMessageDate === 'Invalid Date') { + firstMessageDate = null; + } + importBatchBuilder.finishConversation(jsonData.title, firstMessageDate ?? new Date(), options); await importBatchBuilder.saveBatch(); logger.debug(`user: ${requestUserId} | Conversation "${jsonData.title}" imported`); diff --git a/api/server/utils/index.js b/api/server/utils/index.js index e87a4680fc..315a148544 100644 --- a/api/server/utils/index.js +++ b/api/server/utils/index.js @@ -2,15 +2,29 @@ const streamResponse = require('./streamResponse'); const removePorts = require('./removePorts'); const countTokens = require('./countTokens'); const handleText = require('./handleText'); -const cryptoUtils = require('./crypto'); const citations = require('./citations'); const sendEmail = require('./sendEmail'); +const cryptoUtils = require('./crypto'); const queue = require('./queue'); const files = require('./files'); const math = require('./math'); +/** + * Check if email configuration is set + * @returns {Boolean} + */ +function checkEmailConfig() { + return ( + (!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) && + !!process.env.EMAIL_USERNAME && + !!process.env.EMAIL_PASSWORD && + !!process.env.EMAIL_FROM + ); +} + module.exports = { ...streamResponse, + checkEmailConfig, ...cryptoUtils, ...handleText, ...citations, diff --git a/api/strategies/discordStrategy.js b/api/strategies/discordStrategy.js index f338ff5e15..02bdfd631b 100644 --- a/api/strategies/discordStrategy.js +++ b/api/strategies/discordStrategy.js @@ -17,6 +17,7 @@ const getProfileDetails = (profile) => { avatarUrl, username: profile.username, name: profile.global_name, + emailVerified: true, }; }; diff --git a/api/strategies/facebookStrategy.js b/api/strategies/facebookStrategy.js index 904ee8ba2e..14c325560d 100644 --- a/api/strategies/facebookStrategy.js +++ b/api/strategies/facebookStrategy.js @@ -7,6 +7,7 @@ const getProfileDetails = (profile) => ({ avatarUrl: profile.photos[0]?.value, username: profile.displayName, name: profile.name?.givenName + ' ' + profile.name?.familyName, + emailVerified: true, }); const facebookLogin = socialLogin('facebook', getProfileDetails); diff --git a/api/strategies/joseStrategy.js b/api/strategies/joseStrategy.js deleted file mode 100644 index 83cad23ddf..0000000000 --- a/api/strategies/joseStrategy.js +++ /dev/null @@ -1,43 +0,0 @@ -/* -const jose = require('jose'); -const { logger } = require('~/config'); -// No longer using this strategy as Bun now supports JWTs natively. - -const passportCustom = require('passport-custom'); -const CustomStrategy = passportCustom.Strategy; -const User = require('~/models/User'); - -const joseLogin = async () => - new CustomStrategy(async (req, done) => { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return done(null, false, { message: 'No auth token' }); - } - - const token = authHeader.split(' ')[1]; - - try { - const secret = new TextEncoder().encode(process.env.JWT_SECRET); - const { payload } = await jose.jwtVerify(token, secret); - - const user = await User.findById(payload.id); - if (user) { - done(null, user); - } else { - logger.debug('JoseJwtStrategy => no user found'); - done(null, false, { message: 'No user found' }); - } - } catch (err) { - if (err?.code === 'ERR_JWT_EXPIRED') { - logger.error('JoseJwtStrategy => token expired'); - } else { - logger.error('JoseJwtStrategy => error'); - logger.error(err); - } - done(null, false, { message: 'Invalid token' }); - } - }); - -module.exports = joseLogin; -*/ diff --git a/api/strategies/jwtStrategy.js b/api/strategies/jwtStrategy.js index 8079ac3bce..7053ab1699 100644 --- a/api/strategies/jwtStrategy.js +++ b/api/strategies/jwtStrategy.js @@ -1,6 +1,6 @@ const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); +const { getUserById } = require('~/models'); const { logger } = require('~/config'); -const User = require('~/models/User'); // JWT strategy const jwtLogin = async () => @@ -11,7 +11,8 @@ const jwtLogin = async () => }, async (payload, done) => { try { - const user = await User.findById(payload?.id); + const user = await getUserById(payload?.id); + user.id = user._id.toString(); if (user) { done(null, user); } else { diff --git a/api/strategies/ldapStrategy.js b/api/strategies/ldapStrategy.js index 1a81bc403d..440c8482f2 100644 --- a/api/strategies/ldapStrategy.js +++ b/api/strategies/ldapStrategy.js @@ -1,5 +1,5 @@ const LdapStrategy = require('passport-ldapauth'); -const User = require('~/models/User'); +const { findUser, createUser } = require('~/models/userMethods'); const fs = require('fs'); const ldapOptions = { @@ -36,19 +36,19 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => { userinfo.mail; const username = userinfo.givenName || userinfo.mail; - let user = await User.findOne({ email: userinfo.mail }); + let user = await findUser({ email: userinfo.mail }); if (user && user.provider !== 'ldap') { return done(null, false, { message: 'Invalid credentials' }); } if (!user) { - user = new User({ + user = { provider: 'ldap', ldapId: userinfo.uid, username, email: userinfo.mail || '', emailVerified: true, name: fullName, - }); + }; } else { user.provider = 'ldap'; user.ldapId = userinfo.uid; @@ -56,7 +56,7 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => { user.name = fullName; } - await user.save(); + await createUser(user); done(null, user); } catch (err) { diff --git a/api/strategies/localStrategy.js b/api/strategies/localStrategy.js index 4408382cc4..e0e46c2bec 100644 --- a/api/strategies/localStrategy.js +++ b/api/strategies/localStrategy.js @@ -1,29 +1,15 @@ const { errorsToString } = require('librechat-data-provider'); const { Strategy: PassportLocalStrategy } = require('passport-local'); +const { findUser, comparePassword } = require('~/models'); const { loginSchema } = require('./validators'); +const { isEnabled } = require('~/server/utils'); const logger = require('~/utils/logger'); -const User = require('~/models/User'); async function validateLoginRequest(req) { const { error } = loginSchema.safeParse(req.body); return error ? errorsToString(error.errors) : null; } -async function findUserByEmail(email) { - return User.findOne({ email: email.trim() }); -} - -async function comparePassword(user, password) { - return new Promise((resolve, reject) => { - user.comparePassword(password, function (err, isMatch) { - if (err) { - return reject(err); - } - resolve(isMatch); - }); - }); -} - async function passportLogin(req, email, password, done) { try { const validationError = await validateLoginRequest(req); @@ -33,7 +19,7 @@ async function passportLogin(req, email, password, done) { return done(null, false, { message: validationError }); } - const user = await findUserByEmail(email); + const user = await findUser({ email: email.trim() }); if (!user) { logError('Passport Local Strategy - User Not Found', { email }); logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`); @@ -47,6 +33,12 @@ async function passportLogin(req, email, password, done) { return done(null, false, { message: 'Incorrect password.' }); } + if (!user.emailVerified && !isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN)) { + logError('Passport Local Strategy - Email not verified', { email }); + logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`); + return done(null, user, { message: 'Email not verified.' }); + } + logger.info(`[Login] [Login successful] [Username: ${email}] [Request-IP: ${req.ip}]`); return done(null, user); } catch (err) { diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index af82dfad8c..b69b63776d 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -3,8 +3,8 @@ const passport = require('passport'); const jwtDecode = require('jsonwebtoken/decode'); const { Issuer, Strategy: OpenIDStrategy } = require('openid-client'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { findUser, createUser, updateUser } = require('~/models/userMethods'); const { logger } = require('~/config'); -const User = require('~/models/User'); let crypto; try { @@ -88,13 +88,13 @@ async function setupOpenId() { logger.info(`[openidStrategy] verify login openidId: ${userinfo.sub}`); logger.debug('[openidStrategy] very login tokenset and userinfo', { tokenset, userinfo }); - let user = await User.findOne({ openidId: userinfo.sub }); + let user = await findUser({ openidId: userinfo.sub }); logger.info( `[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${userinfo.sub}`, ); if (!user) { - user = await User.findOne({ email: userinfo.email }); + user = await findUser({ email: userinfo.email }); logger.info( `[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${ userinfo.email @@ -148,14 +148,16 @@ async function setupOpenId() { ); if (!user) { - user = new User({ + user = { provider: 'openid', openidId: userinfo.sub, username, email: userinfo.email || '', emailVerified: userinfo.email_verified || false, name: fullName, - }); + }; + const userId = await createUser(); + user._id = userId; } else { user.provider = 'openid'; user.openidId = userinfo.sub; @@ -163,7 +165,7 @@ async function setupOpenId() { user.name = fullName; } - if (userinfo.picture) { + if (userinfo.picture && !user.avatar?.includes('manual=true')) { /** @type {string | undefined} */ const imageUrl = userinfo.picture; @@ -177,25 +179,21 @@ async function setupOpenId() { } const imageBuffer = await downloadImage(imageUrl, tokenset.access_token); - const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER); if (imageBuffer) { + const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER); const imagePath = await saveBuffer({ fileName, userId: user._id.toString(), buffer: imageBuffer, }); user.avatar = imagePath ?? ''; - } else { - user.avatar = ''; } - } else { - user.avatar = ''; } - await user.save(); + user = await updateUser(user._id, user); logger.info( - `[openidStrategy] login success openidId: ${user.openidId} username: ${user.username} email: ${user.email}`, + `[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `, { user: { openidId: user.openidId, diff --git a/api/strategies/process.js b/api/strategies/process.js index 76d2d6ae47..e9a908ffd0 100644 --- a/api/strategies/process.js +++ b/api/strategies/process.js @@ -1,14 +1,14 @@ const { FileSources } = require('librechat-data-provider'); +const { createUser, updateUser, getUserById } = require('~/models/userMethods'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { resizeAvatar } = require('~/server/services/Files/images/avatar'); -const User = require('~/models/User'); /** * Updates the avatar URL of an existing user. If the user's avatar URL does not include the query parameter * '?manual=true', it updates the user's avatar with the provided URL. For local file storage, it directly updates * the avatar URL, while for other storage types, it processes the avatar URL using the specified file strategy. * - * @param {User} oldUser - The existing user object that needs to be updated. + * @param {MongoUser} oldUser - The existing user object that needs to be updated. * @param {string} avatarUrl - The new avatar URL to be set for the user. * * @returns {Promise} @@ -20,9 +20,9 @@ const handleExistingUser = async (oldUser, avatarUrl) => { const fileStrategy = process.env.CDN_PROVIDER; const isLocal = fileStrategy === FileSources.local; + let updatedAvatar = false; if (isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) { - oldUser.avatar = avatarUrl; - await oldUser.save(); + updatedAvatar = avatarUrl; } else if (!isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) { const userId = oldUser._id; const resizedBuffer = await resizeAvatar({ @@ -30,8 +30,11 @@ const handleExistingUser = async (oldUser, avatarUrl) => { input: avatarUrl, }); const { processAvatar } = getStrategyFunctions(fileStrategy); - oldUser.avatar = await processAvatar({ buffer: resizedBuffer, userId }); - await oldUser.save(); + updatedAvatar = await processAvatar({ buffer: resizedBuffer, userId }); + } + + if (updatedAvatar) { + await updateUser(oldUser._id, { avatar: updatedAvatar }); } }; @@ -55,7 +58,7 @@ const handleExistingUser = async (oldUser, avatarUrl) => { * * @throws {Error} Throws an error if there's an issue creating or saving the new user object. */ -const createNewUser = async ({ +const createSocialUser = async ({ email, avatarUrl, provider, @@ -75,27 +78,24 @@ const createNewUser = async ({ emailVerified, }; - // TODO: remove direct access of User model - const newUser = await new User(update).save(); - + const newUserId = await createUser(update); const fileStrategy = process.env.CDN_PROVIDER; const isLocal = fileStrategy === FileSources.local; if (!isLocal) { - const userId = newUser._id; const resizedBuffer = await resizeAvatar({ - userId, + userId: newUserId, input: avatarUrl, }); const { processAvatar } = getStrategyFunctions(fileStrategy); - newUser.avatar = await processAvatar({ buffer: resizedBuffer, userId }); - await newUser.save(); + const avatar = await processAvatar({ buffer: resizedBuffer, userId: newUserId }); + await updateUser(newUserId, { avatar }); } - return newUser; + return await getUserById(newUserId); }; module.exports = { handleExistingUser, - createNewUser, + createSocialUser, }; diff --git a/api/strategies/socialLogin.js b/api/strategies/socialLogin.js index 981af6cd43..a86b17d1ca 100644 --- a/api/strategies/socialLogin.js +++ b/api/strategies/socialLogin.js @@ -1,14 +1,14 @@ -const { createNewUser, handleExistingUser } = require('./process'); -const { logger } = require('~/config'); -const { findUserByEmail } = require('~/server/services/UserService'); +const { createSocialUser, handleExistingUser } = require('./process'); const { isEnabled } = require('~/server/utils'); +const { findUser } = require('~/models'); +const { logger } = require('~/config'); const socialLogin = (provider, getProfileDetails) => async (accessToken, refreshToken, profile, cb) => { try { const { email, id, avatarUrl, username, name, emailVerified } = getProfileDetails(profile); - const oldUser = await findUserByEmail(email); + const oldUser = await findUser({ email: email.trim() }); const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION); if (oldUser) { @@ -17,7 +17,7 @@ const socialLogin = } if (ALLOW_SOCIAL_REGISTRATION) { - const newUser = await createNewUser({ + const newUser = await createSocialUser({ email, avatarUrl, provider, diff --git a/api/typedefs.js b/api/typedefs.js index 3d33230d03..445ab4f904 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -476,12 +476,30 @@ * @memberof typedefs */ +/** + * @exports MongooseSchema + * @typedef {import('mongoose').Schema} MongooseSchema + * @memberof typedefs + */ + +/** + * @exports ObjectId + * @typedef {import('mongoose').Types.ObjectId} ObjectId + * @memberof typedefs + */ + /** * @exports MongoFile * @typedef {import('~/models/schema/fileSchema.js').MongoFile} MongoFile * @memberof typedefs */ +/** + * @exports MongoUser + * @typedef {import('~/models/schema/userSchema.js').MongoUser} MongoUser + * @memberof typedefs + */ + /** * @exports uploadImageBuffer * @typedef {import('~/server/services/Files/process').uploadImageBuffer} uploadImageBuffer diff --git a/client/src/common/types.ts b/client/src/common/types.ts index a2375b075c..d0cc345615 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -324,6 +324,7 @@ export type TAuthContext = { error: string | undefined; login: (data: TLoginUser) => void; logout: () => void; + setError: React.Dispatch>; }; export type TUserContext = { diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index b97d4468a5..b3d5a22e1b 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -8,14 +8,19 @@ import LoginForm from './LoginForm'; function Login() { const localize = useLocalize(); - const { error, login } = useAuthContext(); + const { error, setError, login } = useAuthContext(); const { startupConfig } = useOutletContext(); return ( <> {error && {localize(getLoginError(error))}} {startupConfig?.emailLoginEnabled && ( - + )} {startupConfig?.registrationEnabled && (

diff --git a/client/src/components/Auth/LoginForm.tsx b/client/src/components/Auth/LoginForm.tsx index 2ab5858876..c3e156f25b 100644 --- a/client/src/components/Auth/LoginForm.tsx +++ b/client/src/components/Auth/LoginForm.tsx @@ -1,20 +1,40 @@ -import React from 'react'; import { useForm } from 'react-hook-form'; +import React, { useState, useEffect } from 'react'; import type { TLoginUser, TStartupConfig } from 'librechat-data-provider'; +import type { TAuthContext } from '~/common'; +import { useResendVerificationEmail } from '~/data-provider'; import { useLocalize } from '~/hooks'; type TLoginFormProps = { onSubmit: (data: TLoginUser) => void; startupConfig: TStartupConfig; + error: Pick['error']; + setError: Pick['setError']; }; -const LoginForm: React.FC = ({ onSubmit, startupConfig }) => { +const LoginForm: React.FC = ({ onSubmit, startupConfig, error, setError }) => { const localize = useLocalize(); const { register, + getValues, handleSubmit, formState: { errors }, } = useForm(); + const [showResendLink, setShowResendLink] = useState(false); + + useEffect(() => { + if (error && error.includes('422') && !showResendLink) { + setShowResendLink(true); + } + }, [error, showResendLink]); + + const resendLinkMutation = useResendVerificationEmail({ + onMutate: () => { + setError(undefined); + setShowResendLink(false); + }, + }); + if (!startupConfig) { return null; } @@ -28,79 +48,102 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig }) => { ) : null; }; + const handleResendEmail = () => { + const email = getValues('email'); + if (!email) { + return setShowResendLink(false); + } + resendLinkMutation.mutate({ email }); + }; + return ( -

onSubmit(data))} - > -
-
- -
-
-
- - -
- {renderError('password')} -
- {startupConfig.passwordResetEnabled && ( - - {localize('com_auth_password_forgot')} - )} -
- -
- +
onSubmit(data))} + > +
+
+ + +
+ {renderError('email')} +
+
+
+ + +
+ {renderError('password')} +
+ {startupConfig.passwordResetEnabled && ( + + {localize('com_auth_password_forgot')} + + )} +
+ +
+
+ ); }; diff --git a/client/src/components/Auth/Registration.tsx b/client/src/components/Auth/Registration.tsx index a69acd663a..086368508b 100644 --- a/client/src/components/Auth/Registration.tsx +++ b/client/src/components/Auth/Registration.tsx @@ -13,28 +13,37 @@ const Registration: React.FC = () => { const { startupConfig, startupConfigError, isFetching } = useOutletContext(); const { - register, watch, + register, handleSubmit, formState: { errors }, } = useForm({ mode: 'onChange' }); - - const [error, setError] = useState(false); - const [errorMessage, setErrorMessage] = useState(''); - const registerUser = useRegisterUserMutation(); const password = watch('password'); - const onRegisterUserFormSubmit = async (data: TRegisterUser) => { - try { - await registerUser.mutateAsync(data); - navigate('/c/new'); - } catch (error) { - setError(true); + const [errorMessage, setErrorMessage] = useState(''); + const [countdown, setCountdown] = useState(3); + + const registerUser = useRegisterUserMutation({ + onSuccess: () => { + setCountdown(3); + const timer = setInterval(() => { + setCountdown((prevCountdown) => { + if (prevCountdown <= 1) { + clearInterval(timer); + navigate('/c/new', { replace: true }); + return 0; + } else { + return prevCountdown - 1; + } + }); + }, 1000); + }, + onError: (error: unknown) => { if ((error as TError).response?.data?.message) { setErrorMessage((error as TError).response?.data?.message ?? ''); } - } - }; + }, + }); useEffect(() => { if (startupConfig?.registrationEnabled === false) { @@ -76,19 +85,32 @@ const Registration: React.FC = () => { return ( <> - {error && ( + {errorMessage && ( {localize('com_auth_error_create')} {errorMessage} )} - + {registerUser.isSuccess && countdown > 0 && ( +
+ {localize( + startupConfig?.emailEnabled + ? 'com_auth_registration_success_generic' + : 'com_auth_registration_success_insecure', + ) + + ' ' + + localize('com_auth_email_verification_redirecting', countdown.toString())} +
+ )} {!startupConfigError && !isFetching && ( <>
registerUser.mutate(data))} > {renderInput('name', 'com_auth_full_name', 'text', { required: localize('com_auth_name_required'), diff --git a/client/src/components/Auth/VerifyEmail.tsx b/client/src/components/Auth/VerifyEmail.tsx new file mode 100644 index 0000000000..acb5abe5ad --- /dev/null +++ b/client/src/components/Auth/VerifyEmail.tsx @@ -0,0 +1,134 @@ +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useVerifyEmailMutation, useResendVerificationEmail } from '~/data-provider'; +import { ThemeSelector } from '~/components/ui'; +import { Spinner } from '~/components/svg'; +import { useLocalize } from '~/hooks'; + +function RequestPasswordReset() { + const navigate = useNavigate(); + const localize = useLocalize(); + const [params] = useSearchParams(); + + const [countdown, setCountdown] = useState(3); + const [headerText, setHeaderText] = useState(''); + const [showResendLink, setShowResendLink] = useState(false); + const [verificationStatus, setVerificationStatus] = useState(false); + + const token = useMemo(() => params.get('token') || '', [params]); + const email = useMemo(() => params.get('email') || '', [params]); + + const countdownRedirect = useCallback(() => { + setCountdown(3); + const timer = setInterval(() => { + setCountdown((prevCountdown) => { + if (prevCountdown <= 1) { + clearInterval(timer); + navigate('/c/new', { replace: true }); + return 0; + } else { + return prevCountdown - 1; + } + }); + }, 1000); + }, [navigate]); + + const verifyEmailMutation = useVerifyEmailMutation({ + onSuccess: () => { + setHeaderText(localize('com_auth_email_verification_success') + ' 🎉'); + setVerificationStatus(true); + countdownRedirect(); + }, + onError: () => { + setShowResendLink(true); + setVerificationStatus(true); + setHeaderText(localize('com_auth_email_verification_failed') + ' 😢'); + setCountdown(0); + }, + }); + + const resendEmailMutation = useResendVerificationEmail({ + onSuccess: () => { + setHeaderText(localize('com_auth_email_resent_success') + ' 📧'); + countdownRedirect(); + }, + onError: () => { + setHeaderText(localize('com_auth_email_resent_failed') + ' 😢'); + countdownRedirect(); + }, + onMutate: () => setShowResendLink(false), + }); + + const handleResendEmail = () => { + resendEmailMutation.mutate({ email }); + }; + + useEffect(() => { + if (verifyEmailMutation.isLoading || verificationStatus) { + return; + } + + if (token && email) { + verifyEmailMutation.mutate({ + email, + token, + }); + return; + } else if (email) { + setHeaderText(localize('com_auth_email_verification_failed_token_missing') + ' 😢'); + } else { + setHeaderText(localize('com_auth_email_verification_invalid') + ' 🤨'); + } + + setShowResendLink(true); + setVerificationStatus(true); + setCountdown(0); + }, [localize, token, email, verificationStatus, verifyEmailMutation]); + + const VerificationSuccess = () => ( +
+

+ {headerText} +

+ {countdown > 0 && ( +

+ {localize('com_auth_email_verification_redirecting', countdown.toString())} +

+ )} + {showResendLink && countdown === 0 && ( +

+ {localize('com_auth_email_verification_resend_prompt')} + +

+ )} +
+ ); + + const VerificationInProgress = () => ( +
+

+ {localize('com_auth_email_verification_in_progress')} +

+
+ +
+
+ ); + + return ( +
+
+ +
+ {verificationStatus ? : } +
+ ); +} + +export default RequestPasswordReset; diff --git a/client/src/components/Auth/index.ts b/client/src/components/Auth/index.ts index c5bf50d0c4..cd1ac1adce 100644 --- a/client/src/components/Auth/index.ts +++ b/client/src/components/Auth/index.ts @@ -1,5 +1,6 @@ export { default as Login } from './Login'; export { default as Registration } from './Registration'; export { default as ResetPassword } from './ResetPassword'; +export { default as VerifyEmail } from './VerifyEmail'; export { default as ApiErrorWatcher } from './ApiErrorWatcher'; export { default as RequestPasswordReset } from './RequestPasswordReset'; diff --git a/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx b/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx index 5944242f7d..6991621fe7 100644 --- a/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx @@ -17,7 +17,7 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea const localize = useLocalize(); const { user, logout } = useAuthContext(); const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUserMutation({ - onSuccess: () => logout(), + onMutate: () => logout(), }); const [isDialogOpen, setDialogOpen] = useState(false); @@ -76,7 +76,6 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
  • {localize('com_nav_delete_warning')}
  • {localize('com_nav_delete_data_info')}
  • -
  • {localize('com_nav_delete_help_center')}
diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index 6ff3e4ead9..4d26e584ce 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -942,3 +942,28 @@ export const useDeleteAction = ( }, }); }; + +/** + * Hook for verifying email address + */ +export const useVerifyEmailMutation = ( + options?: t.VerifyEmailOptions, +): UseMutationResult => { + return useMutation({ + mutationFn: (variables: t.TVerifyEmail) => dataService.verifyEmail(variables), + ...(options || {}), + }); +}; + +/** + * Hook for resending verficiation email + */ +export const useResendVerificationEmail = ( + options?: t.ResendVerifcationOptions, +): UseMutationResult => { + return useMutation({ + mutationFn: (variables: t.TResendVerificationEmail) => + dataService.resendVerificationEmail(variables), + ...(options || {}), + }); +}; diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index cbd0080e05..0493f4600a 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -8,7 +8,7 @@ import { useContext, } from 'react'; import { useRecoilState } from 'recoil'; -import { TUser, TLoginResponse, setTokenHeader, TLoginUser } from 'librechat-data-provider'; +import { TLoginResponse, setTokenHeader, TLoginUser } from 'librechat-data-provider'; import { useGetUserQuery, useLoginUserMutation, @@ -169,10 +169,11 @@ const AuthContextProvider = ({ () => ({ user, token, - isAuthenticated, error, login, logout, + setError, + isAuthenticated, }), // eslint-disable-next-line react-hooks/exhaustive-deps [user, error, isAuthenticated, token], diff --git a/client/src/localization/languages/Eng.ts b/client/src/localization/languages/Eng.ts index f8a93857be..38c8423ae0 100644 --- a/client/src/localization/languages/Eng.ts +++ b/client/src/localization/languages/Eng.ts @@ -254,6 +254,8 @@ export default { 'Your account has been temporarily banned due to violations of our service.', com_auth_error_login_server: 'There was an internal server error. Please wait a few moments and try again.', + com_auth_error_login_unverified: + 'Your account has not been verified. Please check your email for a verification link.', com_auth_no_account: 'Don\'t have an account?', com_auth_sign_up: 'Sign up', com_auth_sign_in: 'Sign in', @@ -288,6 +290,8 @@ export default { com_auth_username_max_length: 'Username must be less than 20 characters', com_auth_already_have_account: 'Already have an account?', com_auth_login: 'Login', + com_auth_registration_success_insecure: 'Registration successful.', + com_auth_registration_success_generic: 'Please check your email to verify your email address.', com_auth_reset_password: 'Reset your password', com_auth_click: 'Click', com_auth_here: 'HERE', @@ -305,6 +309,17 @@ export default { com_auth_submit_registration: 'Submit registration', com_auth_welcome_back: 'Welcome back', com_auth_back_to_login: 'Back to Login', + com_auth_email_verification_failed: 'Email verification failed', + com_auth_email_verification_rate_limited: 'Too many requests. Please try again later', + com_auth_email_verification_success: 'Email verified successfully', + com_auth_email_resent_success: 'Verification email resent successfully', + com_auth_email_resent_failed: 'Failed to resend verification email', + com_auth_email_verification_failed_token_missing: 'Verification failed, token missing', + com_auth_email_verification_invalid: 'Invalid email verification', + com_auth_email_verification_in_progress: 'Verifying your email, please wait', + com_auth_email_verification_resend_prompt: 'Didn\'t receive the email?', + com_auth_email_resend_link: 'Resend Email', + com_auth_email_verification_redirecting: 'Redirecting in {0} seconds...', com_endpoint_open_menu: 'Open Menu', com_endpoint_bing_enable_sydney: 'Enable Sydney', com_endpoint_bing_to_enable_sydney: 'To enable Sydney', @@ -554,7 +569,6 @@ export default { com_nav_delete_account_confirm_placeholder: 'To proceed, type "DELETE" in the input field below', com_nav_delete_warning: 'WARNING: This will permanently delete your account.', com_nav_delete_data_info: 'All your data will be deleted.', - com_nav_delete_help_center: 'For more information, please visit our Help Center.', com_nav_conversation_mode: 'Conversation Mode', com_nav_auto_send_text: 'Auto send text (after 3 sec)', com_nav_auto_transcribe_audio: 'Auto transcribe audio', diff --git a/client/src/routes/index.tsx b/client/src/routes/index.tsx index 0f95cbde56..67aacf0f5f 100644 --- a/client/src/routes/index.tsx +++ b/client/src/routes/index.tsx @@ -4,6 +4,7 @@ import { Registration, RequestPasswordReset, ResetPassword, + VerifyEmail, ApiErrorWatcher, } from '~/components/Auth'; import { AuthContextProvider } from '~/hooks/AuthContext'; @@ -44,6 +45,10 @@ export const router = createBrowserRouter([ }, ], }, + { + path: 'verify', + element: , + }, { element: , children: [ diff --git a/client/src/utils/getLoginError.ts b/client/src/utils/getLoginError.ts index 6bd3c1ba8d..27fafed0cf 100644 --- a/client/src/utils/getLoginError.ts +++ b/client/src/utils/getLoginError.ts @@ -1,17 +1,21 @@ const getLoginError = (errorText: string) => { const defaultError = 'com_auth_error_login'; + if (!errorText) { return defaultError; } - if (errorText?.includes('429')) { - return 'com_auth_error_login_rl'; - } else if (errorText?.includes('403')) { - return 'com_auth_error_login_ban'; - } else if (errorText?.includes('500')) { - return 'com_auth_error_login_server'; - } else { - return defaultError; + switch (true) { + case errorText.includes('429'): + return 'com_auth_error_login_rl'; + case errorText.includes('403'): + return 'com_auth_error_login_ban'; + case errorText.includes('500'): + return 'com_auth_error_login_server'; + case errorText.includes('422'): + return 'com_auth_error_login_unverified'; + default: + return defaultError; } }; diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index d35e6e93ae..0650f29fdf 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -83,6 +83,10 @@ export const requestPasswordReset = () => '/api/auth/requestPasswordReset'; export const resetPassword = () => '/api/auth/resetPassword'; +export const verifyEmail = () => '/api/user/verify'; + +export const resendVerificationEmail = () => '/api/user/verify/resend'; + export const plugins = () => '/api/plugins'; export const config = () => '/api/config'; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index a6bdcacec9..24a0441a77 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -677,6 +677,14 @@ export enum ViolationTypes { * STT Request Limit Violation. */ STT_LIMIT = 'stt_limit', + /** + * Reset Password Limit Violation. + */ + RESET_PASSWORD_LIMIT = 'reset_password_limit', + /** + * Verify Email Limit Violation. + */ + VERIFY_EMAIL_LIMIT = 'verify_email_limit', } /** diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 02722965f9..5171c6df19 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -142,6 +142,16 @@ export const resetPassword = (payload: t.TResetPassword) => { return request.post(endpoints.resetPassword(), payload); }; +export const verifyEmail = (payload: t.TVerifyEmail): Promise => { + return request.post(endpoints.verifyEmail(), payload); +}; + +export const resendVerificationEmail = ( + payload: t.TResendVerificationEmail, +): Promise => { + return request.post(endpoints.resendVerificationEmail(), payload); +}; + export const getAvailablePlugins = (): Promise => { return request.get(endpoints.plugins()); }; diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index 2ad3735edc..cb46340e15 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -322,16 +322,17 @@ export const useLoginUserMutation = (): UseMutationResult< }); }; -export const useRegisterUserMutation = (): UseMutationResult< - unknown, - unknown, - t.TRegisterUser, - unknown -> => { +export const useRegisterUserMutation = ( + options?: m.RegistrationOptions, +): UseMutationResult => { const queryClient = useQueryClient(); return useMutation((payload: t.TRegisterUser) => dataService.register(payload), { - onSuccess: () => { + ...options, + onSuccess: (...args) => { queryClient.invalidateQueries([QueryKeys.user]); + if (options?.onSuccess) { + options.onSuccess(...args); + } }, }); }; diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index efe09fcf22..243d9a81a7 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -215,6 +215,10 @@ export type TSearchMessage = object; export type TSearchMessageTreeNode = object; +export type TRegisterUserResponse = { + message: string; +}; + export type TRegisterUser = { name: string; email: string; @@ -244,6 +248,15 @@ export type TResetPassword = { confirm_password?: string; }; +export type VerifyEmailResponse = { message: string }; + +export type TVerifyEmail = { + email: string; + token: string; +}; + +export type TResendVerificationEmail = Omit; + export type TInterfaceConfig = { privacyPolicy?: { externalUrl?: string; diff --git a/packages/data-provider/src/types/mutations.ts b/packages/data-provider/src/types/mutations.ts index 5ef8a92519..e0ce31a75a 100644 --- a/packages/data-provider/src/types/mutations.ts +++ b/packages/data-provider/src/types/mutations.ts @@ -109,3 +109,16 @@ export type UpdateSharedLinkOptions = MutationOptions< Partial >; export type DeleteSharedLinkOptions = MutationOptions; + +/* Auth mutations */ +export type VerifyEmailOptions = MutationOptions; +export type ResendVerifcationOptions = MutationOptions< + types.VerifyEmailResponse, + types.TResendVerificationEmail +>; +export type RegistrationOptions = MutationOptions< + types.TRegisterUserResponse, + types.TRegisterUser, + unknown, + types.TError +>;