📩 feat: invite user (#3012)

* feat: basic invite-user script

* feat: add invite user functionality and registration validation middleware

* fix: invite user fixes

* refactor: consolidate direct model access to a central place of functions

* style(Registration): add spinner to continue button

* refactor: import ordrer

* feat: improve invite user script and error handling

* fix: merge conflict

* refactor: remove `console.log` and use `logger`

* fix: token operation and checkinvite issues

* bring back comment and remove console log

* fix: return invalid token when token is not found

* fix: getInvite fix

* refactor: Update Token.js to use async/await syntax for update and delete operations

* feat: Refactor Token.js to use async/await syntax for createToken and findToken functions

* refactor(inviteUser): define functions outside of module.exports

* Update AuthService.js

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2024-08-18 06:23:38 +02:00 committed by GitHub
parent a45b384bbc
commit bbb9324447
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 695 additions and 61 deletions

View file

@ -0,0 +1,27 @@
const { getInvite } = require('~/models/inviteUser');
const { deleteTokens } = require('~/models/Token');
async function checkInviteUser(req, res, next) {
const token = req.body.token;
if (!token || token === 'undefined') {
next();
return;
}
try {
const invite = await getInvite(token, req.body.email);
if (!invite || invite.error === true) {
return res.status(400).json({ message: 'Invalid invite token' });
}
await deleteTokens({ token: invite.token });
req.invite = invite;
next();
} catch (error) {
return res.status(429).json({ message: error.message });
}
}
module.exports = checkInviteUser;

View file

@ -10,6 +10,7 @@ const requireLocalAuth = require('./requireLocalAuth');
const canDeleteAccount = require('./canDeleteAccount');
const requireLdapAuth = require('./requireLdapAuth');
const abortMiddleware = require('./abortMiddleware');
const checkInviteUser = require('./checkInviteUser');
const requireJwtAuth = require('./requireJwtAuth');
const validateModel = require('./validateModel');
const moderateText = require('./moderateText');
@ -33,6 +34,7 @@ module.exports = {
moderateText,
validateModel,
requireJwtAuth,
checkInviteUser,
requireLdapAuth,
requireLocalAuth,
canDeleteAccount,

View file

@ -1,10 +1,16 @@
const { isEnabled } = require('~/server/utils');
function validateRegistration(req, res, next) {
if (req.invite) {
return next();
}
if (isEnabled(process.env.ALLOW_REGISTRATION)) {
next();
} else {
res.status(403).send('Registration is not allowed.');
return res.status(403).json({
message: 'Registration is not allowed.',
});
}
}

View file

@ -11,6 +11,7 @@ const {
checkBan,
loginLimiter,
requireJwtAuth,
checkInviteUser,
registerLimiter,
requireLdapAuth,
requireLocalAuth,
@ -32,7 +33,14 @@ router.post(
loginController,
);
router.post('/refresh', refreshController);
router.post('/register', registerLimiter, checkBan, validateRegistration, registrationController);
router.post(
'/register',
registerLimiter,
checkBan,
checkInviteUser,
validateRegistration,
registrationController,
);
router.post(
'/requestPasswordReset',
resetPasswordLimiter,

View file

@ -10,12 +10,11 @@ const {
generateToken,
deleteUserById,
} = require('~/models/userMethods');
const { createToken, findToken, deleteTokens, Session } = require('~/models');
const { sendEmail, checkEmailConfig } = require('~/server/utils');
const { registerSchema } = require('~/strategies/validators');
const { hashToken } = require('~/server/utils/crypto');
const isDomainAllowed = require('./isDomainAllowed');
const Token = require('~/models/schema/tokenSchema');
const Session = require('~/models/Session');
const { logger } = require('~/config');
const domains = {
@ -87,12 +86,13 @@ const sendVerificationEmail = async (user) => {
template: 'verifyEmail.handlebars',
});
await new Token({
await createToken({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
}).save();
expiresIn: 900,
});
logger.info(`[sendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
};
@ -103,7 +103,7 @@ const sendVerificationEmail = async (user) => {
*/
const verifyEmail = async (req) => {
const { email, token } = req.body;
let emailVerificationData = await Token.findOne({ email: decodeURIComponent(email) });
let emailVerificationData = await findToken({ email: decodeURIComponent(email) });
if (!emailVerificationData) {
logger.warn(`[verifyEmail] [No email verification data found] [Email: ${email}]`);
@ -123,7 +123,7 @@ const verifyEmail = async (req) => {
return new Error('User not found');
}
await emailVerificationData.deleteOne();
await deleteTokens({ token: emailVerificationData.token });
logger.info(`[verifyEmail] Email verification successful. [Email: ${email}]`);
return { message: 'Email verification was successful' };
};
@ -231,18 +231,16 @@ const requestPasswordReset = async (req) => {
};
}
let token = await Token.findOne({ userId: user._id });
if (token) {
await token.deleteOne();
}
await deleteTokens({ userId: user._id });
const [resetToken, hash] = createTokenHash();
await new Token({
await createToken({
userId: user._id,
token: hash,
createdAt: Date.now(),
}).save();
expiresIn: 900,
});
const link = `${domains.client}/reset-password?token=${resetToken}&userId=${user._id}`;
@ -282,7 +280,10 @@ const requestPasswordReset = async (req) => {
* @returns
*/
const resetPassword = async (userId, token, password) => {
let passwordResetToken = await Token.findOne({ userId });
let passwordResetToken = await createToken({
userId,
expiresIn: 900,
});
if (!passwordResetToken) {
return new Error('Invalid or expired password reset token');
@ -366,7 +367,7 @@ const setAuthTokens = async (userId, res, sessionId = null) => {
const resendVerificationEmail = async (req) => {
try {
const { email } = req.body;
await Token.deleteMany({ email });
await deleteTokens(email);
const user = await findUser({ email }, 'email _id name');
if (!user) {
@ -392,12 +393,13 @@ const resendVerificationEmail = async (req) => {
template: 'verifyEmail.handlebars',
});
await new Token({
await createToken({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
}).save();
expiresIn: 900,
});
logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`);

View file

@ -0,0 +1,287 @@
<html
xmlns='http://www.w3.org/1999/xhtml'
xmlns:v='urn:schemas-microsoft-com:vml'
xmlns:o='urn:schemas-microsoft-com:office:office'
>
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='x-apple-disable-message-reformatting' />
<meta name='color-scheme' content='light dark' />
<!--[if !mso]><!-->
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) { .darkmode { background-color: #212121 !important; }
.darkmode p { color: #ffffff !important; } } @media only screen and (min-width: 520px) {
.u-row { width: 500px !important; } .u-row .u-col { vertical-align: top; } .u-row .u-col-100 {
width: 500px !important; } } @media (max-width: 520px) { .u-row-container { max-width: 100%
!important; padding-left: 0px !important; padding-right: 0px !important; } .u-row .u-col {
min-width: 320px !important; max-width: 100% !important; display: block !important; } .u-row {
width: 100% !important; } .u-col { width: 100% !important; } .u-col>div { margin: 0 auto; } }
body { margin: 0; padding: 0; } table, tr, td { vertical-align: top; border-collapse:
collapse; } p { margin: 0; } .ie-container table, .mso-container table { table-layout: fixed;
} * { line-height: inherit; } a[x-apple-data-detectors='true'] { color: inherit !important;
text-decoration: none !important; } table, td { color: #ffffff; } #u_body a { color: #0000ee;
text-decoration: underline; }
</style>
</head>
<body
class='clean-body u_body'
style='margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff'
>
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
id='u_body'
style='border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%'
cellpadding='0'
cellspacing='0'
>
<tbody>
<tr style='vertical-align: top'>
<td
style='word-break: break-word;border-collapse: collapse !important;vertical-align: top'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class='u-row-container' style='padding: 0px;background-color: transparent'>
<div
class='u-row'
style='margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;'
>
<div
style='border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div
class='u-col u-col-100'
style='max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;'
>
<div
style='background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--[if (!mso)&(!IE)]><!-->
<div
style='box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--<![endif]-->
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><table width="100%"><tr><td><![endif]-->
<h1
style='margin: 0px; line-height: 140%; text-align: left; word-wrap: break-word; font-size: 22px; font-weight: 700;'
>
<div>
<div>You have been invited to join {{appName}}!</div>
</div>
</div>
</h1>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Hi,</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<p style='line-height: 140%;'>You have been invited to join {{appName}}. Click the
button below to create your account and get started.</p>
</p>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
<div align='left'>
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{inviteLink}}" style="height:37px; v-text-anchor:middle; width:142px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<a
href='{{inviteLink}}'
target='_blank'
class='v-button'
style='box-sizing: border-box;display: inline-block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #FFFFFF; background-color: #10a37f; border-radius: 4px;-webkit-border-radius: 4px; -moz-border-radius: 4px; width:auto; max-width:100%; overflow-wrap: break-word; word-break: break-word; word-wrap:break-word; mso-border-alt: none;font-size: 14px;'
>
<span
style='display:block;padding:10px 20px;line-height:120%;'
><span style='line-height: 16.8px;'>Create Account</span></span>
</span></span>
</a>
<!--[if mso]></center></v:roundrect><![endif]-->
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>
Hurry up, the invite will expiry in 7 days
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;'
>
<div>
<div><sub>©
{{year}}
{{appName}}. All rights reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>