📧 feat: email verification (#2344)

* feat: verification email

* chore: email verification invalid; localize: update

* fix: redirect to login when signup: fix: save emailVerified correctly

* docs: update ALLOW_UNVERIFIED_EMAIL_LOGIN; fix: don't accept login only when ALLOW_UNVERIFIED_EMAIL_LOGIN = true

* fix: user needs to be authenticated

* style: update

* fix: registration success message and redirect logic

* refactor: use `isEnabled` in ALLOW_UNVERIFIED_EMAIL_LOGIN

* refactor: move checkEmailConfig to server/utils

* refactor: use req as param for verifyEmail function

* chore: jsdoc

* chore: remove console log

* refactor: rename `createNewUser` to `createSocialUser`

* refactor: update typing and add expiresAt field to userSchema

* refactor: begin use of user methods over direct model access for User

* refactor: initial email verification rewrite

* chore: typing

* refactor: registration flow rewrite

* chore: remove help center text

* refactor: update getUser to getUserById and add findUser methods. general fixes from recent changes

* refactor: Update updateUser method to remove expiresAt field and use $set and $unset operations, createUser now returns Id only

* refactor: Update openidStrategy to use optional chaining for avatar check, move saveBuffer init to buffer condition

* refactor: logout on deleteUser mutatation

* refactor: Update openidStrategy login success message format

* refactor: Add emailVerified field to Discord and Facebook profile details

* refactor: move limiters to separate middleware dir

* refactor: Add limiters for email verification and password reset

* refactor: Remove getUserController and update routes and controllers accordingly

* refactor: Update getUserById method to exclude password and version fields

* refactor: move verification to user route, add resend verification option

* refactor: Improve email verification process and resend option

* refactor: remove more direct model access of User and remove unused code

* refactor: replace user authentication methods and token generation

* fix: add user.id to jwt user

* refactor: Update AuthContext to include setError function, add resend link to Login Form, make registration redirect shorter

* fix(updateUserPluginsService): ensure userPlugins variable is defined

* refactor: Delete all shared links for a specific user

* fix: remove use of direct User.save() in handleExistingUser

* fix(importLibreChatConvo): handle missing createdAt field in messages

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2024-06-07 21:06:47 +02:00 committed by GitHub
parent b7fef6958b
commit ee673d682e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 1863 additions and 1117 deletions

View file

@ -17,6 +17,7 @@ const getProfileDetails = (profile) => {
avatarUrl,
username: profile.username,
name: profile.global_name,
emailVerified: true,
};
};

View file

@ -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);

View file

@ -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;
*/

View file

@ -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 {

View file

@ -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) {

View file

@ -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) {

View file

@ -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,

View file

@ -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<void>}
@ -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,
};

View file

@ -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,