mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-26 20:26:13 +01:00
- Move auth strategies to package/auth
- Move email and avatar functions to package/auth
This commit is contained in:
parent
e77aa92a7b
commit
f68be4727c
65 changed files with 2089 additions and 1967 deletions
|
|
@ -17,8 +17,9 @@
|
|||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"copy-templates": "mkdir -p dist/utils && cp -R src/utils/emails/* dist/utils",
|
||||
"clean": "rimraf dist",
|
||||
"build": "npm run clean && rollup -c --silent --bundleConfigAsCjs",
|
||||
"build": "npm run clean && npm run copy-templates && rollup -c --silent --bundleConfigAsCjs",
|
||||
"build:watch": "rollup -c -w",
|
||||
"test": "jest --coverage --watch",
|
||||
"test:ci": "jest --coverage --ci",
|
||||
|
|
@ -49,6 +50,7 @@
|
|||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/node": "^20.3.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/traverse": "^0.6.37",
|
||||
"jest": "^29.5.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
|
|
@ -62,13 +64,27 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@librechat/data-schemas": "^0.0.7",
|
||||
"@node-saml/passport-saml": "^5.0.1",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"crypto": "^1.0.1",
|
||||
"handlebars": "^4.7.8",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"klona": "^2.0.6",
|
||||
"mongoose": "^8.12.1",
|
||||
"nodemailer": "^7.0.3",
|
||||
"openid-client": "^6.5.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-apple": "^2.0.2",
|
||||
"passport-discord": "^0.1.4",
|
||||
"passport-facebook": "^3.0.0",
|
||||
"passport-github2": "^0.1.12",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-ldapauth": "^3.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-oauth2": "^1.8.0",
|
||||
"sharp": "^0.33.5",
|
||||
"traverse": "^0.6.11",
|
||||
"winston": "^3.17.0",
|
||||
"winston-daily-rotate-file": "^5.0.0"
|
||||
|
|
@ -86,4 +102,4 @@
|
|||
"typescript",
|
||||
"librechat"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -36,5 +36,5 @@ export default {
|
|||
}),
|
||||
],
|
||||
// Do not bundle these external dependencies
|
||||
external: ['mongoose'],
|
||||
external: ['mongoose', 'sharp'],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,241 +0,0 @@
|
|||
import { klona } from 'klona';
|
||||
import winston from 'winston';
|
||||
import traverse from 'traverse';
|
||||
|
||||
const SPLAT_SYMBOL = Symbol.for('splat');
|
||||
const MESSAGE_SYMBOL = Symbol.for('message');
|
||||
const CONSOLE_JSON_STRING_LENGTH: number =
|
||||
parseInt(process.env.CONSOLE_JSON_STRING_LENGTH || '', 10) || 255;
|
||||
|
||||
const sensitiveKeys: RegExp[] = [
|
||||
/^(sk-)[^\s]+/, // OpenAI API key pattern
|
||||
/(Bearer )[^\s]+/, // Header: Bearer token pattern
|
||||
/(api-key:? )[^\s]+/, // Header: API key pattern
|
||||
/(key=)[^\s]+/, // URL query param: sensitive key pattern (Google)
|
||||
];
|
||||
|
||||
/**
|
||||
* Determines if a given value string is sensitive and returns matching regex patterns.
|
||||
*
|
||||
* @param valueStr - The value string to check.
|
||||
* @returns An array of regex patterns that match the value string.
|
||||
*/
|
||||
function getMatchingSensitivePatterns(valueStr: string): RegExp[] {
|
||||
if (valueStr) {
|
||||
// Filter and return all regex patterns that match the value string
|
||||
return sensitiveKeys.filter((regex) => regex.test(valueStr));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacts sensitive information from a console message and trims it to a specified length if provided.
|
||||
* @param str - The console message to be redacted.
|
||||
* @param trimLength - The optional length at which to trim the redacted message.
|
||||
* @returns The redacted and optionally trimmed console message.
|
||||
*/
|
||||
function redactMessage(str: string, trimLength?: number): string {
|
||||
if (!str) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const patterns = getMatchingSensitivePatterns(str);
|
||||
patterns.forEach((pattern) => {
|
||||
str = str.replace(pattern, '$1[REDACTED]');
|
||||
});
|
||||
|
||||
if (trimLength !== undefined && str.length > trimLength) {
|
||||
return `${str.substring(0, trimLength)}...`;
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacts sensitive information from log messages if the log level is 'error'.
|
||||
* Note: Intentionally mutates the object.
|
||||
* @param info - The log information object.
|
||||
* @returns The modified log information object.
|
||||
*/
|
||||
const redactFormat = winston.format((info: winston.Logform.TransformableInfo) => {
|
||||
if (info.level === 'error') {
|
||||
// Type guard to ensure message is a string
|
||||
if (typeof info.message === 'string') {
|
||||
info.message = redactMessage(info.message);
|
||||
}
|
||||
|
||||
// Handle MESSAGE_SYMBOL with type safety
|
||||
const symbolValue = (info as Record<string | symbol, unknown>)[MESSAGE_SYMBOL];
|
||||
if (typeof symbolValue === 'string') {
|
||||
(info as Record<string | symbol, unknown>)[MESSAGE_SYMBOL] = redactMessage(symbolValue);
|
||||
}
|
||||
}
|
||||
return info;
|
||||
});
|
||||
|
||||
/**
|
||||
* Truncates long strings, especially base64 image data, within log messages.
|
||||
*
|
||||
* @param value - The value to be inspected and potentially truncated.
|
||||
* @param length - The length at which to truncate the value. Default: 100.
|
||||
* @returns The truncated or original value.
|
||||
*/
|
||||
const truncateLongStrings = (value: unknown, length = 100): unknown => {
|
||||
if (typeof value === 'string') {
|
||||
return value.length > length ? value.substring(0, length) + '... [truncated]' : value;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* An array mapping function that truncates long strings (objects converted to JSON strings).
|
||||
* @param item - The item to be condensed.
|
||||
* @returns The condensed item.
|
||||
*/
|
||||
const condenseArray = (item: unknown): string | unknown => {
|
||||
if (typeof item === 'string') {
|
||||
return truncateLongStrings(JSON.stringify(item));
|
||||
} else if (typeof item === 'object') {
|
||||
return truncateLongStrings(JSON.stringify(item));
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats log messages for debugging purposes.
|
||||
* - Truncates long strings within log messages.
|
||||
* - Condenses arrays by truncating long strings and objects as strings within array items.
|
||||
* - Redacts sensitive information from log messages if the log level is 'error'.
|
||||
* - Converts log information object to a formatted string.
|
||||
*
|
||||
* @param options - The options for formatting log messages.
|
||||
* @returns The formatted log message.
|
||||
*/
|
||||
const debugTraverse = winston.format.printf(
|
||||
({ level, message, timestamp, ...metadata }: Record<string, unknown>) => {
|
||||
if (!message) {
|
||||
return `${timestamp} ${level}`;
|
||||
}
|
||||
|
||||
// Type-safe version of the CJS logic: !message?.trim || typeof message !== 'string'
|
||||
if (typeof message !== 'string' || !message.trim) {
|
||||
return `${timestamp} ${level}: ${JSON.stringify(message)}`;
|
||||
}
|
||||
|
||||
let msg = `${timestamp} ${level}: ${truncateLongStrings(message.trim(), 150)}`;
|
||||
|
||||
try {
|
||||
if (level !== 'debug') {
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (!metadata) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Type-safe access to SPLAT_SYMBOL using bracket notation
|
||||
const metadataRecord = metadata as Record<string | symbol, unknown>;
|
||||
const splatArray = metadataRecord[SPLAT_SYMBOL];
|
||||
const debugValue = Array.isArray(splatArray) ? splatArray[0] : undefined;
|
||||
|
||||
if (!debugValue) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (debugValue && Array.isArray(debugValue)) {
|
||||
msg += `\n${JSON.stringify(debugValue.map(condenseArray))}`;
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (typeof debugValue !== 'object') {
|
||||
return (msg += ` ${debugValue}`);
|
||||
}
|
||||
|
||||
msg += '\n{';
|
||||
|
||||
const copy = klona(metadata);
|
||||
|
||||
traverse(copy).forEach(function (this: traverse.TraverseContext, value: unknown) {
|
||||
if (typeof this?.key === 'symbol') {
|
||||
return;
|
||||
}
|
||||
|
||||
let _parentKey = '';
|
||||
const parent = this.parent;
|
||||
|
||||
if (typeof parent?.key !== 'symbol' && parent?.key) {
|
||||
_parentKey = parent.key;
|
||||
}
|
||||
|
||||
const parentKey = `${parent && parent.notRoot ? _parentKey + '.' : ''}`;
|
||||
const tabs = `${parent && parent.notRoot ? ' ' : ' '}`;
|
||||
const currentKey = this?.key ?? 'unknown';
|
||||
|
||||
if (this.isLeaf && typeof value === 'string') {
|
||||
const truncatedText = truncateLongStrings(value);
|
||||
msg += `\n${tabs}${parentKey}${currentKey}: ${JSON.stringify(truncatedText)},`;
|
||||
} else if (this.notLeaf && Array.isArray(value) && value.length > 0) {
|
||||
const currentMessage = `\n${tabs}// ${value.length} ${currentKey.replace(/s$/, '')}(s)`;
|
||||
this.update(currentMessage, true);
|
||||
msg += currentMessage;
|
||||
const stringifiedArray = value.map(condenseArray);
|
||||
msg += `\n${tabs}${parentKey}${currentKey}: [${stringifiedArray}],`;
|
||||
} else if (this.isLeaf && typeof value === 'function') {
|
||||
msg += `\n${tabs}${parentKey}${currentKey}: function,`;
|
||||
} else if (this.isLeaf) {
|
||||
msg += `\n${tabs}${parentKey}${currentKey}: ${value},`;
|
||||
}
|
||||
});
|
||||
|
||||
msg += '\n}';
|
||||
return msg;
|
||||
} catch (e: unknown) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
|
||||
return (msg += `\n[LOGGER PARSING ERROR] ${errorMessage}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Truncates long string values in JSON log objects.
|
||||
* Prevents outputting extremely long values (e.g., base64, blobs).
|
||||
*/
|
||||
const jsonTruncateFormat = winston.format((info: winston.Logform.TransformableInfo) => {
|
||||
const truncateLongStrings = (str: string, maxLength: number): string =>
|
||||
str.length > maxLength ? str.substring(0, maxLength) + '...' : str;
|
||||
|
||||
const seen = new WeakSet<object>();
|
||||
|
||||
const truncateObject = (obj: unknown): unknown => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Handle circular references - now with proper object type
|
||||
if (seen.has(obj)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
seen.add(obj);
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => truncateObject(item));
|
||||
}
|
||||
|
||||
// We know this is an object at this point
|
||||
const objectRecord = obj as Record<string, unknown>;
|
||||
const newObj: Record<string, unknown> = {};
|
||||
Object.entries(objectRecord).forEach(([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
newObj[key] = truncateLongStrings(value, CONSOLE_JSON_STRING_LENGTH);
|
||||
} else {
|
||||
newObj[key] = truncateObject(value);
|
||||
}
|
||||
});
|
||||
return newObj;
|
||||
};
|
||||
|
||||
return truncateObject(info) as winston.Logform.TransformableInfo;
|
||||
});
|
||||
|
||||
export { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat };
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
import path from 'path';
|
||||
import winston from 'winston';
|
||||
import 'winston-daily-rotate-file';
|
||||
import { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat } from './parsers';
|
||||
|
||||
// Define log directory
|
||||
const logDir = path.join(__dirname, '..', 'logs');
|
||||
|
||||
// Type-safe environment variables
|
||||
const { NODE_ENV, DEBUG_LOGGING, CONSOLE_JSON, DEBUG_CONSOLE } = process.env;
|
||||
|
||||
const useConsoleJson = typeof CONSOLE_JSON === 'string' && CONSOLE_JSON.toLowerCase() === 'true';
|
||||
|
||||
const useDebugConsole = typeof DEBUG_CONSOLE === 'string' && DEBUG_CONSOLE.toLowerCase() === 'true';
|
||||
|
||||
const useDebugLogging = typeof DEBUG_LOGGING === 'string' && DEBUG_LOGGING.toLowerCase() === 'true';
|
||||
|
||||
// Define custom log levels
|
||||
const levels: winston.config.AbstractConfigSetLevels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
http: 3,
|
||||
verbose: 4,
|
||||
debug: 5,
|
||||
activity: 6,
|
||||
silly: 7,
|
||||
};
|
||||
|
||||
winston.addColors({
|
||||
info: 'green',
|
||||
warn: 'italic yellow',
|
||||
error: 'red',
|
||||
debug: 'blue',
|
||||
});
|
||||
|
||||
const level = (): string => {
|
||||
const env = NODE_ENV || 'development';
|
||||
return env === 'development' ? 'debug' : 'warn';
|
||||
};
|
||||
|
||||
const fileFormat = winston.format.combine(
|
||||
redactFormat(),
|
||||
winston.format.timestamp({ format: () => new Date().toISOString() }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.splat(),
|
||||
);
|
||||
|
||||
const transports: winston.transport[] = [
|
||||
new winston.transports.DailyRotateFile({
|
||||
level: 'error',
|
||||
filename: `${logDir}/error-%DATE%.log`,
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
maxFiles: '14d',
|
||||
format: fileFormat,
|
||||
}),
|
||||
];
|
||||
|
||||
if (useDebugLogging) {
|
||||
transports.push(
|
||||
new winston.transports.DailyRotateFile({
|
||||
level: 'debug',
|
||||
filename: `${logDir}/debug-%DATE%.log`,
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
maxFiles: '14d',
|
||||
format: winston.format.combine(fileFormat, debugTraverse),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const consoleFormat = winston.format.combine(
|
||||
redactFormat(),
|
||||
winston.format.colorize({ all: true }),
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.printf((info) => {
|
||||
const message = `${info.timestamp} ${info.level}: ${info.message}`;
|
||||
return info.level.includes('error') ? redactMessage(message) : message;
|
||||
}),
|
||||
);
|
||||
|
||||
let consoleLogLevel: string = 'info';
|
||||
if (useDebugConsole) {
|
||||
consoleLogLevel = 'debug';
|
||||
}
|
||||
|
||||
// Add console transport
|
||||
if (useDebugConsole) {
|
||||
transports.push(
|
||||
new winston.transports.Console({
|
||||
level: consoleLogLevel,
|
||||
format: useConsoleJson
|
||||
? winston.format.combine(fileFormat, jsonTruncateFormat(), winston.format.json())
|
||||
: winston.format.combine(fileFormat, debugTraverse),
|
||||
}),
|
||||
);
|
||||
} else if (useConsoleJson) {
|
||||
transports.push(
|
||||
new winston.transports.Console({
|
||||
level: consoleLogLevel,
|
||||
format: winston.format.combine(fileFormat, jsonTruncateFormat(), winston.format.json()),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
transports.push(
|
||||
new winston.transports.Console({
|
||||
level: consoleLogLevel,
|
||||
format: consoleFormat,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Create logger
|
||||
const logger = winston.createLogger({
|
||||
level: level(),
|
||||
levels,
|
||||
transports,
|
||||
});
|
||||
|
||||
export default logger;
|
||||
|
|
@ -2,32 +2,16 @@ import { Request, Response } from 'express';
|
|||
import { TokenEndpointResponse } from 'openid-client';
|
||||
import { errorsToString, SystemRoles } from 'librechat-data-provider';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { IUser } from '@librechat/data-schemas';
|
||||
import { IUser, logger } from '@librechat/data-schemas';
|
||||
import { registerSchema } from './strategies/validators';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { sendEmail } from './utils/sendEmail';
|
||||
import logger from './config/winston';
|
||||
|
||||
import { sendVerificationEmail } from './utils/email';
|
||||
import { ObjectId } from 'mongoose';
|
||||
import { initAuth, getMethods } from './initAuth';
|
||||
import { AuthenticatedRequest, LogoutResponse } from './types';
|
||||
import { checkEmailConfig, isEnabled } from './utils';
|
||||
import { initAuthModels, getMethods } from './init';
|
||||
|
||||
const genericVerificationMessage = 'Please check your email to verify your email address.';
|
||||
const domains = {
|
||||
client: process.env.DOMAIN_CLIENT,
|
||||
server: process.env.DOMAIN_SERVER,
|
||||
};
|
||||
|
||||
interface LogoutResponse {
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: { _id: string };
|
||||
session?: {
|
||||
destroy: (callback?: (err?: any) => void) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*
|
||||
|
|
@ -157,263 +141,6 @@ const registerUser = async (
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates Token and corresponding Hash for verification
|
||||
* @returns {[string, string]}
|
||||
*/
|
||||
const createTokenHash = (): [string, string] => {
|
||||
const token: string = Buffer.from(webcrypto.getRandomValues(new Uint8Array(32))).toString('hex');
|
||||
const hash: string = bcrypt.hashSync(token, 10);
|
||||
return [token, hash];
|
||||
};
|
||||
|
||||
/**
|
||||
* Send Verification Email
|
||||
* @param {Partial<MongoUser> & { _id: ObjectId, email: string, name: string}} user
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const sendVerificationEmail = async (user: Partial<IUser> & { _id: ObjectId; email: string }) => {
|
||||
const [verifyToken, hash] = createTokenHash();
|
||||
const { createToken } = getMethods();
|
||||
const verificationLink = `${
|
||||
domains.client
|
||||
}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`;
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
subject: 'Verify your email',
|
||||
payload: {
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name || user.username || user.email,
|
||||
verificationLink: verificationLink,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
template: 'verifyEmail.handlebars',
|
||||
});
|
||||
|
||||
await createToken({
|
||||
userId: user._id,
|
||||
email: user.email,
|
||||
token: hash,
|
||||
createdAt: Date.now(),
|
||||
expiresIn: 900,
|
||||
});
|
||||
|
||||
logger.info(`[sendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify Email
|
||||
* @param {Express.Request} req
|
||||
*/
|
||||
const verifyEmail = async (req: Request) => {
|
||||
const { email, token } = req.body;
|
||||
const decodedEmail = decodeURIComponent(email);
|
||||
const { findUser, findToken, updateUser, deleteTokens } = getMethods();
|
||||
|
||||
const user = await findUser({ email: decodedEmail }, 'email _id emailVerified');
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`[verifyEmail] [User not found] [Email: ${decodedEmail}]`);
|
||||
return new Error('User not found');
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
logger.info(`[verifyEmail] Email already verified [Email: ${decodedEmail}]`);
|
||||
return { message: 'Email already verified', status: 'success' };
|
||||
}
|
||||
|
||||
let emailVerificationData = await findToken({ email: decodedEmail });
|
||||
|
||||
if (!emailVerificationData) {
|
||||
logger.warn(`[verifyEmail] [No email verification data found] [Email: ${decodedEmail}]`);
|
||||
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: ${decodedEmail}]`,
|
||||
);
|
||||
return new Error('Invalid or expired email verification token');
|
||||
}
|
||||
|
||||
const updatedUser = await updateUser(emailVerificationData.userId, { emailVerified: true });
|
||||
|
||||
if (!updatedUser) {
|
||||
logger.warn(`[verifyEmail] [User update failed] [Email: ${decodedEmail}]`);
|
||||
return new Error('Failed to update user verification status');
|
||||
}
|
||||
|
||||
await deleteTokens({ token: emailVerificationData.token });
|
||||
logger.info(`[verifyEmail] Email verification successful [Email: ${decodedEmail}]`);
|
||||
return { message: 'Email verification was successful', status: 'success' };
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: Request) => {
|
||||
try {
|
||||
const { deleteTokens, findUser, createToken } = getMethods();
|
||||
const { email } = req.body;
|
||||
await deleteTokens(email);
|
||||
const user = await findUser({ email }, 'email _id name');
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`);
|
||||
return { status: 200, message: genericVerificationMessage };
|
||||
}
|
||||
|
||||
const [verifyToken, hash] = createTokenHash();
|
||||
|
||||
const verificationLink = `${
|
||||
domains.client
|
||||
}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`;
|
||||
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
subject: 'Verify your email',
|
||||
payload: {
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name || user.username || user.email,
|
||||
verificationLink: verificationLink,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
template: 'verifyEmail.handlebars',
|
||||
});
|
||||
|
||||
await createToken({
|
||||
userId: user._id,
|
||||
email: user.email,
|
||||
token: hash,
|
||||
createdAt: Date.now(),
|
||||
expiresIn: 900,
|
||||
});
|
||||
|
||||
logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
message: genericVerificationMessage,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`[resendVerificationEmail] Error resending verification email: ${error.message}`);
|
||||
return {
|
||||
status: 500,
|
||||
message: 'Something went wrong.',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset Password
|
||||
*
|
||||
* @param {*} userId
|
||||
* @param {String} token
|
||||
* @param {String} password
|
||||
* @returns
|
||||
*/
|
||||
const resetPassword = async (userId: string | ObjectId, token: string, password: string) => {
|
||||
const { findToken, updateUser, deleteTokens } = getMethods();
|
||||
let passwordResetToken = await findToken({
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!passwordResetToken) {
|
||||
return new Error('Invalid or expired password reset token');
|
||||
}
|
||||
|
||||
const isValid = bcrypt.compareSync(token, passwordResetToken.token);
|
||||
|
||||
if (!isValid) {
|
||||
return new Error('Invalid or expired password reset token');
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
const user = await updateUser(userId, { password: hash });
|
||||
|
||||
if (checkEmailConfig()) {
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
subject: 'Password Reset Successfully',
|
||||
payload: {
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name || user.username || user.email,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
template: 'passwordReset.handlebars',
|
||||
});
|
||||
}
|
||||
|
||||
await deleteTokens({ token: passwordResetToken.token });
|
||||
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
|
||||
return { message: 'Password reset was successful' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Request password reset
|
||||
* @param {Express.Request} req
|
||||
*/
|
||||
const requestPasswordReset = async (req: Request) => {
|
||||
const { email } = req.body;
|
||||
const { findUser, createToken, deleteTokens } = getMethods();
|
||||
const user = await findUser({ email }, 'email _id');
|
||||
const emailEnabled = checkEmailConfig();
|
||||
|
||||
logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`);
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`[requestPasswordReset] [No user found] [Email: ${email}] [IP: ${req.ip}]`);
|
||||
return {
|
||||
message: 'If an account with that email exists, a password reset link has been sent to it.',
|
||||
};
|
||||
}
|
||||
|
||||
await deleteTokens({ userId: user._id });
|
||||
|
||||
const [resetToken, hash] = createTokenHash();
|
||||
|
||||
await createToken({
|
||||
userId: user._id,
|
||||
token: hash,
|
||||
createdAt: Date.now(),
|
||||
expiresIn: 900,
|
||||
});
|
||||
|
||||
const link = `${domains.client}/reset-password?token=${resetToken}&userId=${user._id}`;
|
||||
|
||||
if (emailEnabled) {
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
subject: 'Password Reset Request',
|
||||
payload: {
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name || user.username || user.email,
|
||||
link: link,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
template: 'requestPasswordReset.handlebars',
|
||||
});
|
||||
logger.info(
|
||||
`[requestPasswordReset] Link emailed. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[requestPasswordReset] Link issued. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
|
||||
);
|
||||
return { link };
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'If an account with that email exists, a password reset link has been sent to it.',
|
||||
};
|
||||
};
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
/**
|
||||
* Set Auth Tokens
|
||||
|
|
@ -512,15 +239,7 @@ const setOpenIDAuthTokens = (tokenset: TokenEndpointResponse, res: Response) =>
|
|||
throw error;
|
||||
}
|
||||
};
|
||||
export {
|
||||
setOpenIDAuthTokens,
|
||||
setAuthTokens,
|
||||
logoutUser,
|
||||
registerUser,
|
||||
verifyEmail,
|
||||
resendVerificationEmail,
|
||||
resetPassword,
|
||||
requestPasswordReset,
|
||||
checkEmailConfig,
|
||||
initAuthModels,
|
||||
};
|
||||
|
||||
export { setOpenIDAuthTokens, setAuthTokens, logoutUser, registerUser, initAuth };
|
||||
export * from './strategies';
|
||||
export * from './utils';
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
import { createMethods, createModels } from '@librechat/data-schemas';
|
||||
import type { Mongoose } from 'mongoose';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
let models: any = null;
|
||||
let methods: any = {};
|
||||
|
||||
export function initAuthModels(mongoose: Mongoose) {
|
||||
if (initialized) return;
|
||||
models = createModels(mongoose);
|
||||
methods = createMethods(mongoose);
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
export function getModels() {
|
||||
if (!models) {
|
||||
throw new Error('Auth models have not been initialized. Call initAuthModels() first.');
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
export function getMethods() {
|
||||
if (!methods) {
|
||||
throw new Error('Auth methods have not been initialized. Call initAuthModels() first.');
|
||||
}
|
||||
return methods;
|
||||
}
|
||||
51
packages/auth/src/initAuth.ts
Normal file
51
packages/auth/src/initAuth.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { BalanceConfig, createMethods } from '@librechat/data-schemas';
|
||||
import type { Mongoose } from 'mongoose';
|
||||
|
||||
// Flag to prevent re-initialization
|
||||
let initialized = false;
|
||||
|
||||
// Internal references to initialized values
|
||||
let methods: any = null;
|
||||
let balanceConfig: BalanceConfig;
|
||||
let saveBuffer: Function;
|
||||
|
||||
/**
|
||||
* Initializes authentication-related components.
|
||||
* This should be called once during application setup.
|
||||
*
|
||||
* @param mongoose - The Mongoose instance used to create models and methods
|
||||
* @param config - Balance configuration used in auth flows
|
||||
* @param saveBufferStrategy - Function used to save buffered data mainly used for user avatar in the auth package
|
||||
*/
|
||||
export function initAuth(mongoose: Mongoose, config: BalanceConfig, saveBufferStrategy: Function) {
|
||||
if (initialized) return;
|
||||
methods = createMethods(mongoose);
|
||||
balanceConfig = config;
|
||||
saveBuffer = saveBufferStrategy;
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the initialized methods for auth-related operations.
|
||||
* Throws an error if not initialized.
|
||||
*/
|
||||
export function getMethods() {
|
||||
if (!methods) {
|
||||
throw new Error('Auth methods have not been initialized. Call initAuthModels() first.');
|
||||
}
|
||||
return methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the balance configuration used for auth logic.
|
||||
*/
|
||||
export function getBalanceConfig(): BalanceConfig {
|
||||
return balanceConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the function used to save buffered data.
|
||||
*/
|
||||
export function getSaveBufferStrategy(): Function {
|
||||
return saveBuffer;
|
||||
}
|
||||
53
packages/auth/src/strategies/appleStrategy.ts
Normal file
53
packages/auth/src/strategies/appleStrategy.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Strategy as AppleStrategy } from 'passport-apple';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { GetProfileDetails, GetProfileDetailsParams } from './types';
|
||||
import socialLogin from './socialLogin';
|
||||
import { Profile } from 'passport';
|
||||
|
||||
/**
|
||||
* Extract profile details from the decoded idToken
|
||||
* @param {Object} params - Parameters from the verify callback
|
||||
* @param {string} params.idToken - The ID token received from Apple
|
||||
* @param {Object} params.profile - The profile object (may contain partial info)
|
||||
* @returns {Object} - The extracted user profile details
|
||||
*/
|
||||
const getProfileDetails: GetProfileDetails = ({ profile, idToken }: GetProfileDetailsParams) => {
|
||||
if (!idToken) {
|
||||
logger.error('idToken is missing');
|
||||
throw new Error('idToken is missing');
|
||||
}
|
||||
|
||||
const decoded: any = jwt.decode(idToken);
|
||||
|
||||
logger.debug(`Decoded Apple JWT: ${JSON.stringify(decoded, null, 2)}`);
|
||||
|
||||
return {
|
||||
email: decoded.email,
|
||||
id: decoded.sub,
|
||||
avatarUrl: null, // Apple does not provide an avatar URL
|
||||
username: decoded.email ? decoded.email.split('@')[0].toLowerCase() : `user_${decoded.sub}`,
|
||||
name: decoded.name
|
||||
? `${decoded.name.firstName} ${decoded.name.lastName}`
|
||||
: profile.displayName || null,
|
||||
emailVerified: true, // Apple verifies the email
|
||||
};
|
||||
};
|
||||
|
||||
// Initialize the social login handler for Apple
|
||||
const appleStrategy = socialLogin('apple', getProfileDetails);
|
||||
|
||||
const appleLogin = () =>
|
||||
new AppleStrategy(
|
||||
{
|
||||
clientID: process.env.APPLE_CLIENT_ID,
|
||||
teamID: process.env.APPLE_TEAM_ID,
|
||||
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.APPLE_CALLBACK_URL}`,
|
||||
keyID: process.env.APPLE_KEY_ID,
|
||||
privateKeyLocation: process.env.APPLE_PRIVATE_KEY_PATH,
|
||||
passReqToCallback: false, // Set to true if you need to access the request in the callback
|
||||
},
|
||||
appleStrategy,
|
||||
);
|
||||
|
||||
export default appleLogin;
|
||||
40
packages/auth/src/strategies/discordStrategy.ts
Normal file
40
packages/auth/src/strategies/discordStrategy.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Profile } from 'passport';
|
||||
import { Strategy as DiscordStrategy } from 'passport-discord';
|
||||
import socialLogin from './socialLogin';
|
||||
import { GetProfileDetails } from './types';
|
||||
|
||||
const getProfileDetails: GetProfileDetails = ({ profile }: any) => {
|
||||
let avatarUrl;
|
||||
if (profile.avatar) {
|
||||
const format = profile.avatar.startsWith('a_') ? 'gif' : 'png';
|
||||
avatarUrl = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`;
|
||||
} else {
|
||||
const defaultAvatarNum = Number(profile.discriminator) % 5;
|
||||
avatarUrl = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNum}.png`;
|
||||
}
|
||||
|
||||
return {
|
||||
email: profile.email,
|
||||
id: profile.id,
|
||||
avatarUrl,
|
||||
username: profile.username,
|
||||
name: profile.global_name,
|
||||
emailVerified: true,
|
||||
};
|
||||
};
|
||||
|
||||
const discordStrategy = socialLogin('discord', getProfileDetails);
|
||||
|
||||
const discordLogin = () =>
|
||||
new DiscordStrategy(
|
||||
{
|
||||
clientID: process.env.DISCORD_CLIENT_ID,
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET,
|
||||
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.DISCORD_CALLBACK_URL}`,
|
||||
scope: ['identify', 'email'],
|
||||
authorizationURL: 'https://discord.com/api/oauth2/authorize?prompt=none',
|
||||
},
|
||||
discordStrategy,
|
||||
);
|
||||
|
||||
export default discordLogin;
|
||||
36
packages/auth/src/strategies/facebookStrategy.ts
Normal file
36
packages/auth/src/strategies/facebookStrategy.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Strategy as FacebookStrategy } from 'passport-facebook';
|
||||
import socialLogin from './socialLogin';
|
||||
import { GetProfileDetails } from './types';
|
||||
|
||||
const getProfileDetails: GetProfileDetails = ({ profile }: FacebookStrategy.Profile) => {
|
||||
// email or photo may not be returned
|
||||
let email =
|
||||
profile.emails?.length > 0 ? profile.emails[0]?.value : `${profile.id}@id.facebook.com`;
|
||||
let photo = profile.photos?.length > 0 ? profile.photos[0]?.value : '';
|
||||
|
||||
return {
|
||||
email: email,
|
||||
id: profile.id,
|
||||
avatarUrl: photo,
|
||||
username: profile.displayName,
|
||||
name: profile.name?.givenName + ' ' + profile.name?.familyName,
|
||||
emailVerified: true,
|
||||
};
|
||||
};
|
||||
|
||||
const facebookStrategy = socialLogin('facebook', getProfileDetails);
|
||||
|
||||
const facebookLogin = () =>
|
||||
new FacebookStrategy(
|
||||
{
|
||||
clientID: process.env.FACEBOOK_CLIENT_ID,
|
||||
clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
|
||||
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.FACEBOOK_CALLBACK_URL}`,
|
||||
proxy: true,
|
||||
scope: ['public_profile'],
|
||||
profileFields: ['id', 'email', 'name'],
|
||||
},
|
||||
facebookStrategy,
|
||||
);
|
||||
|
||||
export default facebookLogin;
|
||||
35
packages/auth/src/strategies/githubStrategy.ts
Normal file
35
packages/auth/src/strategies/githubStrategy.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Strategy as GitHubStrategy } from 'passport-github2';
|
||||
import socialLogin from './socialLogin';
|
||||
import { GetProfileDetails } from './types';
|
||||
|
||||
const getProfileDetails: GetProfileDetails = ({ profile }: any) => ({
|
||||
email: profile.emails[0].value,
|
||||
id: profile.id,
|
||||
avatarUrl: profile.photos[0].value,
|
||||
username: profile.username,
|
||||
name: profile.displayName,
|
||||
emailVerified: profile.emails[0].verified,
|
||||
});
|
||||
|
||||
const githubStrategy = socialLogin('github', getProfileDetails);
|
||||
const githubLogin = () =>
|
||||
new GitHubStrategy(
|
||||
{
|
||||
clientID: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.GITHUB_CALLBACK_URL}`,
|
||||
proxy: false,
|
||||
scope: ['user:email'],
|
||||
...(process.env.GITHUB_ENTERPRISE_BASE_URL && {
|
||||
authorizationURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/login/oauth/authorize`,
|
||||
tokenURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/login/oauth/access_token`,
|
||||
userProfileURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/api/v3/user`,
|
||||
userEmailURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/api/v3/user/emails`,
|
||||
...(process.env.GITHUB_ENTERPRISE_USER_AGENT && {
|
||||
userAgent: process.env.GITHUB_ENTERPRISE_USER_AGENT,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
githubStrategy,
|
||||
);
|
||||
export default githubLogin;
|
||||
27
packages/auth/src/strategies/googleStrategy.ts
Normal file
27
packages/auth/src/strategies/googleStrategy.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Strategy as GoogleStrategy, Profile } from 'passport-google-oauth20';
|
||||
import socialLogin from './socialLogin';
|
||||
import { GetProfileDetails } from './types';
|
||||
|
||||
const getProfileDetails: GetProfileDetails = ({ profile }: Profile) => ({
|
||||
email: profile.emails[0].value,
|
||||
id: profile.id,
|
||||
avatarUrl: profile.photos[0].value,
|
||||
username: profile.name.givenName,
|
||||
name: `${profile.name.givenName}${profile.name.familyName ? ` ${profile.name.familyName}` : ''}`,
|
||||
emailVerified: profile.emails[0].verified,
|
||||
});
|
||||
|
||||
const googleStrategy = socialLogin('google', getProfileDetails);
|
||||
|
||||
const googleLogin = () =>
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.GOOGLE_CALLBACK_URL}`,
|
||||
proxy: true,
|
||||
},
|
||||
googleStrategy,
|
||||
);
|
||||
|
||||
export default googleLogin;
|
||||
100
packages/auth/src/strategies/helpers.ts
Normal file
100
packages/auth/src/strategies/helpers.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { IUser } from '@librechat/data-schemas';
|
||||
import { FileSources } from 'librechat-data-provider';
|
||||
import { getBalanceConfig, getMethods } from '../initAuth';
|
||||
import { getAvatarProcessFunction, resizeAvatar } from '../utils/avatar';
|
||||
import { CreateSocialUserParams } from './types';
|
||||
|
||||
/**
|
||||
* 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 {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>}
|
||||
* The function updates the user's avatar and saves the user object. It does not return any value.
|
||||
*
|
||||
* @throws {Error} Throws an error if there's an issue saving the updated user object.
|
||||
*/
|
||||
const handleExistingUser = async (oldUser: IUser, avatarUrl: string) => {
|
||||
const fileStrategy = process.env.CDN_PROVIDER ?? FileSources.local;
|
||||
const isLocal = fileStrategy === FileSources.local;
|
||||
|
||||
let updatedAvatar = '';
|
||||
if (isLocal && (oldUser.avatar === null || !oldUser.avatar?.includes('?manual=true'))) {
|
||||
updatedAvatar = avatarUrl;
|
||||
} else if (!isLocal && (oldUser.avatar === null || !oldUser.avatar?.includes('?manual=true'))) {
|
||||
const userId = oldUser.id ?? '';
|
||||
const resizedBuffer = await resizeAvatar({
|
||||
userId,
|
||||
input: avatarUrl,
|
||||
});
|
||||
const processAvatar = getAvatarProcessFunction(fileStrategy);
|
||||
updatedAvatar = await processAvatar({ buffer: resizedBuffer, userId });
|
||||
}
|
||||
|
||||
if (updatedAvatar != '') {
|
||||
const { updateUser } = getMethods();
|
||||
await updateUser(oldUser._id, { avatar: updatedAvatar });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new user with the provided user details. If the file strategy is not local, the avatar URL is
|
||||
* processed using the specified file strategy. The new user is saved to the database with the processed or
|
||||
* original avatar URL.
|
||||
*
|
||||
* @param {Object} params - The parameters object for user creation.
|
||||
* @param {string} params.email - The email of the new user.
|
||||
* @param {string} params.avatarUrl - The avatar URL of the new user.
|
||||
* @param {string} params.provider - The provider of the user's account.
|
||||
* @param {string} params.providerKey - The key to identify the provider in the user model.
|
||||
* @param {string} params.providerId - The provider-specific ID of the user.
|
||||
* @param {string} params.username - The username of the new user.
|
||||
* @param {string} params.name - The name of the new user.
|
||||
* @param {boolean} [params.emailVerified=false] - Optional. Indicates whether the user's email is verified. Defaults to false.
|
||||
*
|
||||
* @returns {Promise<User>}
|
||||
* A promise that resolves to the newly created user object.
|
||||
*
|
||||
* @throws {Error} Throws an error if there's an issue creating or saving the new user object.
|
||||
*/
|
||||
const createSocialUser = async ({
|
||||
email,
|
||||
avatarUrl,
|
||||
provider,
|
||||
providerKey,
|
||||
providerId,
|
||||
username,
|
||||
name,
|
||||
emailVerified,
|
||||
}: CreateSocialUserParams): Promise<IUser> => {
|
||||
const update = {
|
||||
email,
|
||||
avatar: avatarUrl,
|
||||
provider,
|
||||
[providerKey]: providerId,
|
||||
username,
|
||||
name,
|
||||
emailVerified,
|
||||
};
|
||||
const balanceConfig = getBalanceConfig();
|
||||
const { createUser, getUserById, updateUser } = getMethods();
|
||||
const newUserId = await createUser(update, balanceConfig);
|
||||
const fileStrategy = process.env.CDN_PROVIDER ?? FileSources.local;
|
||||
const isLocal = fileStrategy === FileSources.local;
|
||||
|
||||
if (!isLocal) {
|
||||
const resizedBuffer = await resizeAvatar({
|
||||
userId: newUserId,
|
||||
input: avatarUrl,
|
||||
});
|
||||
const processAvatar = getAvatarProcessFunction(fileStrategy);
|
||||
const avatar = await processAvatar({ buffer: resizedBuffer, userId: newUserId });
|
||||
await updateUser(newUserId, { avatar });
|
||||
}
|
||||
|
||||
return await getUserById(newUserId);
|
||||
};
|
||||
export { handleExistingUser, createSocialUser };
|
||||
16
packages/auth/src/strategies/index.ts
Normal file
16
packages/auth/src/strategies/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export { setupOpenId, getOpenIdConfig } from './openidStrategy';
|
||||
export { default as openIdJwtLogin } from './openIdJwtStrategy';
|
||||
|
||||
export { default as googleLogin } from './googleStrategy';
|
||||
export { default as facebookLogin } from './facebookStrategy';
|
||||
export { default as discordLogin } from './discordStrategy';
|
||||
export { default as githubLogin } from './githubStrategy';
|
||||
export { default as socialLogin } from './socialLogin';
|
||||
export { samlLogin, getCertificateContent } from './samlStrategy';
|
||||
export { default as ldapLogin } from './ldapStrategy';
|
||||
export { default as passportLogin } from './localStrategy';
|
||||
export { default as jwtLogin } from './jwtStrategy';
|
||||
export { loginSchema, registerSchema } from './validators';
|
||||
|
||||
// export this helper so we can mock them
|
||||
export { createSocialUser, handleExistingUser } from './helpers';
|
||||
41
packages/auth/src/strategies/jwtStrategy.ts
Normal file
41
packages/auth/src/strategies/jwtStrategy.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { getMethods } from '../initAuth';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { SystemRoles } from 'librechat-data-provider';
|
||||
import {
|
||||
Strategy as JwtStrategy,
|
||||
ExtractJwt,
|
||||
StrategyOptionsWithoutRequest,
|
||||
VerifiedCallback,
|
||||
} from 'passport-jwt';
|
||||
import { Strategy as PassportStrategy } from 'passport-strategy';
|
||||
import { JwtPayload } from './types';
|
||||
|
||||
// JWT strategy
|
||||
const jwtLogin = (): PassportStrategy =>
|
||||
new JwtStrategy(
|
||||
{
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: process.env.JWT_SECRET,
|
||||
} as StrategyOptionsWithoutRequest,
|
||||
async (payload: JwtPayload, done: VerifiedCallback) => {
|
||||
const { updateUser, getUserById } = getMethods();
|
||||
try {
|
||||
const user = await getUserById(payload?.id, '-password -__v -totpSecret');
|
||||
if (user) {
|
||||
user.id = user._id.toString();
|
||||
if (!user.role) {
|
||||
user.role = SystemRoles.USER;
|
||||
await updateUser(user.id, { role: user.role });
|
||||
}
|
||||
done(null, user);
|
||||
} else {
|
||||
logger.warn('[jwtLogin] JwtStrategy => no user found: ' + payload?.id);
|
||||
done(null, false);
|
||||
}
|
||||
} catch (err) {
|
||||
done(err, false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default jwtLogin;
|
||||
150
packages/auth/src/strategies/ldapStrategy.ts
Normal file
150
packages/auth/src/strategies/ldapStrategy.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import fs from 'fs';
|
||||
import LdapStrategy, { type Options } from 'passport-ldapauth';
|
||||
import { SystemRoles } from 'librechat-data-provider';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { isEnabled } from '../utils';
|
||||
import { getBalanceConfig, getMethods } from '../initAuth';
|
||||
|
||||
const {
|
||||
LDAP_URL,
|
||||
LDAP_BIND_DN,
|
||||
LDAP_BIND_CREDENTIALS,
|
||||
LDAP_USER_SEARCH_BASE,
|
||||
LDAP_SEARCH_FILTER,
|
||||
LDAP_CA_CERT_PATH,
|
||||
LDAP_FULL_NAME,
|
||||
LDAP_ID,
|
||||
LDAP_USERNAME,
|
||||
LDAP_EMAIL,
|
||||
LDAP_TLS_REJECT_UNAUTHORIZED,
|
||||
LDAP_STARTTLS,
|
||||
} = process.env;
|
||||
|
||||
// // Check required environment variables
|
||||
// if (!LDAP_URL || !LDAP_USER_SEARCH_BASE) {
|
||||
// module.exports = null;
|
||||
// }
|
||||
|
||||
const searchAttributes = [
|
||||
'displayName',
|
||||
'mail',
|
||||
'uid',
|
||||
'cn',
|
||||
'name',
|
||||
'commonname',
|
||||
'givenName',
|
||||
'sn',
|
||||
'sAMAccountName',
|
||||
];
|
||||
|
||||
if (LDAP_FULL_NAME) {
|
||||
searchAttributes.push(...LDAP_FULL_NAME.split(','));
|
||||
}
|
||||
if (LDAP_ID) {
|
||||
searchAttributes.push(LDAP_ID);
|
||||
}
|
||||
if (LDAP_USERNAME) {
|
||||
searchAttributes.push(LDAP_USERNAME);
|
||||
}
|
||||
if (LDAP_EMAIL) {
|
||||
searchAttributes.push(LDAP_EMAIL);
|
||||
}
|
||||
const rejectUnauthorized = isEnabled(LDAP_TLS_REJECT_UNAUTHORIZED ?? '');
|
||||
const startTLS = isEnabled(LDAP_STARTTLS ?? '');
|
||||
|
||||
const ldapLogin = () => {
|
||||
const ldapOptions = {
|
||||
server: {
|
||||
url: LDAP_URL ?? '',
|
||||
bindDN: LDAP_BIND_DN,
|
||||
bindCredentials: LDAP_BIND_CREDENTIALS,
|
||||
searchBase: LDAP_USER_SEARCH_BASE ?? '',
|
||||
searchFilter: LDAP_SEARCH_FILTER || 'mail={{username}}',
|
||||
searchAttributes: [...new Set(searchAttributes)],
|
||||
...(LDAP_CA_CERT_PATH && {
|
||||
tlsOptions: {
|
||||
rejectUnauthorized,
|
||||
ca: (() => {
|
||||
try {
|
||||
return [fs.readFileSync(LDAP_CA_CERT_PATH)];
|
||||
} catch (err) {
|
||||
logger.error('[ldapStrategy]', 'Failed to read CA certificate', err);
|
||||
throw err;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
}),
|
||||
...(startTLS && { starttls: true }),
|
||||
},
|
||||
usernameField: 'email',
|
||||
passwordField: 'password',
|
||||
};
|
||||
return new LdapStrategy(ldapOptions, async (userinfo: any, done) => {
|
||||
if (!userinfo) {
|
||||
return done(null, false, { message: 'Invalid credentials' });
|
||||
}
|
||||
const { countUsers, createUser, updateUser, findUser } = getMethods();
|
||||
|
||||
try {
|
||||
const ldapId =
|
||||
(LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail;
|
||||
|
||||
let user = await findUser({ ldapId });
|
||||
|
||||
const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(',');
|
||||
const fullName =
|
||||
fullNameAttributes && fullNameAttributes.length > 0
|
||||
? fullNameAttributes.map((attr) => userinfo[attr]).join(' ')
|
||||
: userinfo.cn || userinfo.name || userinfo.commonname || userinfo.displayName;
|
||||
|
||||
const username =
|
||||
(LDAP_USERNAME && userinfo[LDAP_USERNAME]) || userinfo.givenName || userinfo.mail;
|
||||
|
||||
const mail =
|
||||
(LDAP_EMAIL && userinfo[LDAP_EMAIL]) || userinfo.mail || username + '@ldap.local';
|
||||
|
||||
if (!userinfo.mail && !(LDAP_EMAIL && userinfo[LDAP_EMAIL])) {
|
||||
logger.warn(
|
||||
'[ldapStrategy]',
|
||||
`No valid email attribute found in LDAP userinfo. Using fallback email: ${username}@ldap.local`,
|
||||
`LDAP_EMAIL env var: ${LDAP_EMAIL || 'not set'}`,
|
||||
`Available userinfo attributes: ${Object.keys(userinfo).join(', ')}`,
|
||||
'Full userinfo:',
|
||||
JSON.stringify(userinfo, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
const isFirstRegisteredUser = (await countUsers()) === 0;
|
||||
user = {
|
||||
provider: 'ldap',
|
||||
ldapId,
|
||||
username,
|
||||
email: mail,
|
||||
emailVerified: true, // The ldap server administrator should verify the email
|
||||
name: fullName,
|
||||
role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER,
|
||||
};
|
||||
const balanceConfig = getBalanceConfig();
|
||||
const userId = await createUser(user, balanceConfig);
|
||||
user._id = userId;
|
||||
} else {
|
||||
// Users registered in LDAP are assumed to have their user information managed in LDAP,
|
||||
// so update the user information with the values registered in LDAP
|
||||
user.provider = 'ldap';
|
||||
user.ldapId = ldapId;
|
||||
user.email = mail;
|
||||
user.username = username;
|
||||
user.name = fullName;
|
||||
}
|
||||
|
||||
user = await updateUser(user._id, user);
|
||||
done(null, user);
|
||||
} catch (err) {
|
||||
logger.error('[ldapStrategy]', err);
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default ldapLogin;
|
||||
115
packages/auth/src/strategies/localStrategy.ts
Normal file
115
packages/auth/src/strategies/localStrategy.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { IUser, logger } from '@librechat/data-schemas';
|
||||
import { errorsToString } from 'librechat-data-provider';
|
||||
import { Strategy as PassportLocalStrategy } from 'passport-local';
|
||||
import { getMethods } from '../initAuth';
|
||||
import { checkEmailConfig, isEnabled } from '../utils';
|
||||
import { loginSchema } from './validators';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { Request } from 'express';
|
||||
|
||||
// Unix timestamp for 2024-06-07 15:20:18 Eastern Time
|
||||
const verificationEnabledTimestamp = 1717788018;
|
||||
|
||||
async function validateLoginRequest(req) {
|
||||
const { error } = loginSchema.safeParse(req.body);
|
||||
return error ? errorsToString(error.errors) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the provided password with the user's password.
|
||||
*
|
||||
* @param {MongoUser} user - The user to compare the password for.
|
||||
* @param {string} candidatePassword - The password to test against the user's password.
|
||||
* @returns {Promise<boolean>} A promise that resolves to a boolean indicating if the password matches.
|
||||
*/
|
||||
const comparePassword = async (user: IUser, candidatePassword: string) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
async function passportStrategy(
|
||||
req: Request,
|
||||
email: string,
|
||||
password: string,
|
||||
done: (error: any, user?: any, options?: { message: string }) => void,
|
||||
) {
|
||||
try {
|
||||
const validationError = await validateLoginRequest(req);
|
||||
if (validationError) {
|
||||
logError('Passport Local Strategy - Validation Error', { reqBody: req.body });
|
||||
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
|
||||
return done(null, false, { message: validationError });
|
||||
}
|
||||
|
||||
const { findUser, updateUser } = getMethods();
|
||||
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}]`);
|
||||
return done(null, false, { message: 'Email does not exist.' });
|
||||
}
|
||||
|
||||
const isMatch = await comparePassword(user, password);
|
||||
if (!isMatch) {
|
||||
logError('Passport Local Strategy - Password does not match', { isMatch });
|
||||
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
|
||||
return done(null, false, { message: 'Incorrect password.' });
|
||||
}
|
||||
|
||||
const emailEnabled = checkEmailConfig();
|
||||
const userCreatedAtTimestamp = Math.floor(new Date(user.createdAt).getTime() / 1000);
|
||||
|
||||
if (
|
||||
!emailEnabled &&
|
||||
!user.emailVerified &&
|
||||
userCreatedAtTimestamp < verificationEnabledTimestamp
|
||||
) {
|
||||
await updateUser(user._id, { emailVerified: true });
|
||||
user.emailVerified = true;
|
||||
}
|
||||
|
||||
const unverifiedAllowed = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN ?? '');
|
||||
if (user.expiresAt && unverifiedAllowed) {
|
||||
await updateUser(user._id, {});
|
||||
}
|
||||
|
||||
if (!user.emailVerified && !unverifiedAllowed) {
|
||||
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) {
|
||||
return done(err);
|
||||
}
|
||||
}
|
||||
|
||||
function logError(title: string, parameters: any) {
|
||||
const entries = Object.entries(parameters).map(([name, value]) => ({ name, value }));
|
||||
logger.error(title, { parameters: entries });
|
||||
}
|
||||
|
||||
const passportLogin = () =>
|
||||
new PassportLocalStrategy(
|
||||
{
|
||||
usernameField: 'email',
|
||||
passwordField: 'password',
|
||||
session: false,
|
||||
passReqToCallback: true,
|
||||
},
|
||||
passportStrategy,
|
||||
);
|
||||
|
||||
export default passportLogin;
|
||||
55
packages/auth/src/strategies/openIdJwtStrategy.ts
Normal file
55
packages/auth/src/strategies/openIdJwtStrategy.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { SystemRoles } from 'librechat-data-provider';
|
||||
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
|
||||
import jwksRsa from 'jwks-rsa';
|
||||
import { isEnabled } from 'src/utils';
|
||||
import { getMethods } from 'src/initAuth';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import * as client from 'openid-client';
|
||||
|
||||
/**
|
||||
* @function openIdJwtLogin
|
||||
* @param {import('openid-client').Configuration} openIdConfig - Configuration object for the JWT strategy.
|
||||
* @returns {JwtStrategy}
|
||||
* @description This function creates a JWT strategy for OpenID authentication.
|
||||
* It uses the jwks-rsa library to retrieve the signing key from a JWKS endpoint.
|
||||
* The strategy extracts the JWT from the Authorization header as a Bearer token.
|
||||
* The JWT is then verified using the signing key, and the user is retrieved from the database.
|
||||
*/
|
||||
const openIdJwtLogin = (openIdConfig: client.Configuration) =>
|
||||
new JwtStrategy(
|
||||
{
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKeyProvider: jwksRsa.passportJwtSecret({
|
||||
cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED || 'true'),
|
||||
cacheMaxAge: process.env.OPENID_JWKS_URL_CACHE_TIME
|
||||
? eval(process.env.OPENID_JWKS_URL_CACHE_TIME)
|
||||
: 60000,
|
||||
jwksUri: openIdConfig.serverMetadata().jwks_uri ?? '',
|
||||
}),
|
||||
},
|
||||
async (payload, done) => {
|
||||
const { findUser, updateUser } = getMethods();
|
||||
try {
|
||||
const user = await findUser({ openidId: payload?.sub });
|
||||
|
||||
if (user) {
|
||||
user.id = user._id.toString();
|
||||
if (!user.role) {
|
||||
user.role = SystemRoles.USER;
|
||||
await updateUser(user.id, { role: user.role });
|
||||
}
|
||||
done(null, user);
|
||||
} else {
|
||||
logger.warn(
|
||||
'[openIdJwtLogin] openId JwtStrategy => no user found with the sub claims: ' +
|
||||
payload?.sub,
|
||||
);
|
||||
done(null, false);
|
||||
}
|
||||
} catch (err) {
|
||||
done(err, false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default openIdJwtLogin;
|
||||
405
packages/auth/src/strategies/openidStrategy.ts
Normal file
405
packages/auth/src/strategies/openidStrategy.ts
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
import passport from 'passport';
|
||||
import * as client from 'openid-client';
|
||||
// @ts-ignore
|
||||
import { Strategy as OpenIDStrategy } from 'openid-client/passport';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { hashToken, logger } from '@librechat/data-schemas';
|
||||
import { isEnabled } from '../utils';
|
||||
import * as oauth from 'oauth4webapi';
|
||||
import { getBalanceConfig, getMethods, getSaveBufferStrategy } from '../initAuth';
|
||||
|
||||
let crypto: typeof import('node:crypto') | undefined;
|
||||
|
||||
class CustomOpenIDStrategy extends OpenIDStrategy {
|
||||
constructor(options: any, verify: Function) {
|
||||
super(options, verify);
|
||||
}
|
||||
currentUrl(req: any): URL {
|
||||
const hostAndProtocol = process.env.DOMAIN_SERVER!;
|
||||
return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`);
|
||||
}
|
||||
|
||||
authorizationRequestParams(req: any, options: any) {
|
||||
const params = super.authorizationRequestParams(req, options);
|
||||
if (options?.state && !params?.has('state')) {
|
||||
params?.set('state', options.state);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
let openidConfig: client.Configuration;
|
||||
let tokensCache: any;
|
||||
|
||||
/**
|
||||
* Exchange the access token for a new access token using the on-behalf-of flow if required.
|
||||
* @param {Configuration} config
|
||||
* @param {string} accessToken access token to be exchanged if necessary
|
||||
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
|
||||
* @param {boolean} fromCache - Indicates whether to use cached tokens.
|
||||
* @returns {Promise<string>} The new access token if exchanged, otherwise the original access token.
|
||||
*/
|
||||
const exchangeAccessTokenIfNeeded = async (
|
||||
config: client.Configuration,
|
||||
accessToken: string,
|
||||
sub: string,
|
||||
fromCache: boolean = false,
|
||||
) => {
|
||||
const onBehalfFlowRequired = isEnabled(
|
||||
process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFRO_REQUIRED ?? '',
|
||||
);
|
||||
if (onBehalfFlowRequired) {
|
||||
if (fromCache) {
|
||||
const cachedToken = await tokensCache.get(sub);
|
||||
|
||||
if (cachedToken) {
|
||||
return cachedToken.access_token;
|
||||
}
|
||||
}
|
||||
const grantResponse = await client.genericGrantRequest(
|
||||
config,
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
{
|
||||
scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFRO_SCOPE || 'user.read',
|
||||
assertion: accessToken,
|
||||
requested_token_use: 'on_behalf_of',
|
||||
},
|
||||
);
|
||||
await tokensCache.set(
|
||||
sub,
|
||||
{
|
||||
access_token: grantResponse.access_token,
|
||||
},
|
||||
(grantResponse?.expires_in ?? 0) * 1000,
|
||||
);
|
||||
return grantResponse.access_token;
|
||||
}
|
||||
return accessToken;
|
||||
};
|
||||
|
||||
/**
|
||||
* get user info from openid provider
|
||||
* @param {Configuration} config
|
||||
* @param {string} accessToken access token
|
||||
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
const getUserInfo = async (
|
||||
config: client.Configuration,
|
||||
accessToken: string,
|
||||
sub: string,
|
||||
): Promise<oauth.UserInfoResponse | null> => {
|
||||
try {
|
||||
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub);
|
||||
return await client.fetchUserInfo(config, exchangedAccessToken, sub);
|
||||
} catch (error) {
|
||||
logger.warn(`[openidStrategy] getUserInfo: Error fetching user info: ${error}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Downloads an image from a URL using an access token.
|
||||
* @param {string} url
|
||||
* @param {Configuration} config
|
||||
* @param {string} accessToken access token
|
||||
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
|
||||
* @returns {Promise<Buffer | string>} The image buffer or an empty string if the download fails.
|
||||
*/
|
||||
const downloadImage = async (
|
||||
url: string,
|
||||
config: client.Configuration,
|
||||
accessToken: string,
|
||||
sub: string,
|
||||
) => {
|
||||
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub, true);
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const options: any = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${exchangedAccessToken}`,
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env.PROXY) {
|
||||
options.agent = new HttpsProxyAgent(process.env.PROXY);
|
||||
}
|
||||
const response: Response = await fetch(url, options);
|
||||
if (response.ok) {
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
return buffer;
|
||||
} else {
|
||||
throw new Error(`${response.statusText} (HTTP ${response.status})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[openidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines the full name of a user based on OpenID userinfo and environment configuration.
|
||||
*
|
||||
* @param {Object} userinfo - The user information object from OpenID Connect
|
||||
* @param {string} [userinfo.given_name] - The user's first name
|
||||
* @param {string} [userinfo.family_name] - The user's last name
|
||||
* @param {string} [userinfo.username] - The user's username
|
||||
* @param {string} [userinfo.email] - The user's email address
|
||||
* @returns {string} The determined full name of the user
|
||||
*/
|
||||
function getFullName(userinfo: client.UserInfoResponse & { username?: string }): string {
|
||||
const nameClaim = process.env.OPENID_NAME_CLAIM;
|
||||
if (nameClaim && typeof userinfo[nameClaim] === 'string') {
|
||||
return userinfo[nameClaim] as string;
|
||||
}
|
||||
|
||||
if (userinfo.given_name && userinfo.family_name) {
|
||||
return `${userinfo.given_name} ${userinfo.family_name}`;
|
||||
}
|
||||
|
||||
if (userinfo.given_name) {
|
||||
return userinfo.given_name;
|
||||
}
|
||||
|
||||
if (userinfo.family_name) {
|
||||
return userinfo.family_name;
|
||||
}
|
||||
|
||||
return (userinfo?.username || userinfo?.email) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an input into a string suitable for a username.
|
||||
* If the input is a string, it will be returned as is.
|
||||
* If the input is an array, elements will be joined with underscores.
|
||||
* In case of undefined or other falsy values, a default value will be returned.
|
||||
*
|
||||
* @param {string | string[] | undefined} input - The input value to be converted into a username.
|
||||
* @param {string} [defaultValue=''] - The default value to return if the input is falsy.
|
||||
* @returns {string} The processed input as a string suitable for a username.
|
||||
*/
|
||||
function convertToUsername(input: string | string[], defaultValue: string = '') {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
} else if (Array.isArray(input)) {
|
||||
return input.join('_');
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the OpenID strategy for authentication.
|
||||
* This function configures the OpenID client, handles proxy settings,
|
||||
* and defines the OpenID strategy for Passport.js.
|
||||
*
|
||||
* @async
|
||||
* @function setupOpenId
|
||||
* @returns {Promise<Configuration | null>} A promise that resolves when the OpenID strategy is set up and returns the openid client config object.
|
||||
* @throws {Error} If an error occurs during the setup process.
|
||||
*/
|
||||
async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
|
||||
try {
|
||||
tokensCache = tokensCacheKv;
|
||||
/** @type {ClientMetadata} */
|
||||
const clientMetadata = {
|
||||
client_id: process.env.OPENID_CLIENT_ID,
|
||||
client_secret: process.env.OPENID_CLIENT_SECRET,
|
||||
};
|
||||
/** @type {Configuration} */
|
||||
openidConfig = await client.discovery(
|
||||
new URL(process.env.OPENID_ISSUER ?? ''),
|
||||
process.env.OPENID_CLIENT_ID ?? '',
|
||||
clientMetadata,
|
||||
);
|
||||
|
||||
const { findUser, createUser, updateUser } = getMethods();
|
||||
if (process.env.PROXY) {
|
||||
const proxyAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
const customFetch: client.CustomFetch = (...args: any[]) => {
|
||||
return fetch(args[0], { ...args[1], agent: proxyAgent });
|
||||
};
|
||||
openidConfig[client.customFetch] = customFetch;
|
||||
|
||||
logger.info(`[openidStrategy] proxy agent added: ${process.env.PROXY}`);
|
||||
}
|
||||
|
||||
const requiredRole = process.env.OPENID_REQUIRED_ROLE;
|
||||
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
|
||||
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
|
||||
|
||||
const usePKCE: boolean = isEnabled(process.env.OPENID_USE_PKCE ?? '');
|
||||
const openidLogin = new CustomOpenIDStrategy(
|
||||
{
|
||||
config: openidConfig,
|
||||
scope: process.env.OPENID_SCOPE,
|
||||
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.OPENID_CALLBACK_URL}`,
|
||||
usePKCE,
|
||||
},
|
||||
async (
|
||||
tokenset: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers,
|
||||
done: passport.AuthenticateCallback,
|
||||
) => {
|
||||
try {
|
||||
const claims: oauth.IDToken | undefined = tokenset.claims();
|
||||
let user = await findUser({ openidId: claims?.sub });
|
||||
logger.info(
|
||||
`[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${claims?.sub}`,
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
user = await findUser({ email: claims?.email });
|
||||
logger.info(
|
||||
`[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${
|
||||
claims?.email
|
||||
} for openidId: ${claims?.sub}`,
|
||||
);
|
||||
}
|
||||
const userinfo: any = {
|
||||
...claims,
|
||||
...(await getUserInfo(openidConfig, tokenset.access_token, claims?.sub ?? '')),
|
||||
};
|
||||
const fullName = getFullName(userinfo);
|
||||
|
||||
if (requiredRole) {
|
||||
let decodedToken = null;
|
||||
if (requiredRoleTokenKind === 'access') {
|
||||
decodedToken = jwt.decode(tokenset.access_token);
|
||||
} else if (requiredRoleTokenKind === 'id') {
|
||||
decodedToken = jwt.decode(tokenset.id_token ?? '');
|
||||
}
|
||||
const pathParts = requiredRoleParameterPath?.split('.');
|
||||
let found = true;
|
||||
let roles: any = decodedToken;
|
||||
if (pathParts) {
|
||||
for (const key of pathParts) {
|
||||
if (roles && typeof roles === 'object' && key in roles) {
|
||||
roles = (roles as Record<string, unknown>)[key];
|
||||
} else {
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
logger.error(
|
||||
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!roles?.includes(requiredRole)) {
|
||||
return done(null, false, {
|
||||
message: `You must have the "${requiredRole}" role to log in.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let username = '';
|
||||
if (process.env.OPENID_USERNAME_CLAIM && userinfo[process.env.OPENID_USERNAME_CLAIM]) {
|
||||
username = userinfo[process.env.OPENID_USERNAME_CLAIM] as string;
|
||||
} else {
|
||||
username = convertToUsername(
|
||||
userinfo?.username ?? userinfo?.given_name ?? userinfo?.email,
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
user = {
|
||||
provider: 'openid',
|
||||
openidId: userinfo.sub,
|
||||
username,
|
||||
email: userinfo.email || '',
|
||||
emailVerified: userinfo.email_verified || false,
|
||||
name: fullName,
|
||||
};
|
||||
|
||||
const balanceConfig = getBalanceConfig();
|
||||
user = await createUser(user, balanceConfig, true, true);
|
||||
} else {
|
||||
user.provider = 'openid';
|
||||
user.openidId = userinfo.sub;
|
||||
user.username = username;
|
||||
user.name = fullName;
|
||||
}
|
||||
|
||||
if (!!userinfo && userinfo.picture && !user?.avatar?.includes('manual=true')) {
|
||||
/** @type {string | undefined} */
|
||||
const imageUrl = userinfo.picture;
|
||||
|
||||
let fileName;
|
||||
try {
|
||||
crypto = await import('node:crypto');
|
||||
} catch (err) {
|
||||
logger.error('[openidStrategy] crypto support is disabled!', err);
|
||||
}
|
||||
|
||||
if (crypto) {
|
||||
fileName = (await hashToken(userinfo.sub)) + '.png';
|
||||
} else {
|
||||
fileName = userinfo.sub + '.png';
|
||||
}
|
||||
|
||||
const imageBuffer = await downloadImage(
|
||||
imageUrl,
|
||||
openidConfig,
|
||||
tokenset.access_token,
|
||||
userinfo.sub,
|
||||
);
|
||||
if (imageBuffer) {
|
||||
const saveBuffer = getSaveBufferStrategy();
|
||||
const imagePath = await saveBuffer({
|
||||
fileName,
|
||||
userId: user._id.toString(),
|
||||
buffer: imageBuffer,
|
||||
});
|
||||
user.avatar = imagePath ?? '';
|
||||
}
|
||||
}
|
||||
user = await updateUser(user?._id, user);
|
||||
logger.info(
|
||||
`[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
|
||||
{
|
||||
user: {
|
||||
openidId: user.openidId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
done(null, { ...user, tokenset });
|
||||
} catch (err) {
|
||||
logger.error('[openidStrategy] login failed', err);
|
||||
done(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
return openidLogin;
|
||||
} catch (err) {
|
||||
logger.error('[openidStrategy]', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @function getOpenIdConfig
|
||||
* @description Returns the OpenID client instance.
|
||||
* @throws {Error} If the OpenID client is not initialized.
|
||||
* @returns {Configuration}
|
||||
*/
|
||||
function getOpenIdConfig(): client.Configuration {
|
||||
if (!openidConfig) {
|
||||
throw new Error('OpenID client is not initialized. Please call setupOpenId first.');
|
||||
}
|
||||
return openidConfig;
|
||||
}
|
||||
export { setupOpenId, getOpenIdConfig };
|
||||
285
packages/auth/src/strategies/samlStrategy.ts
Normal file
285
packages/auth/src/strategies/samlStrategy.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { hashToken, logger } from '@librechat/data-schemas';
|
||||
|
||||
import { Strategy as SamlStrategy, Profile, PassportSamlConfig } from '@node-saml/passport-saml';
|
||||
import { getBalanceConfig, getMethods, getSaveBufferStrategy } from '../initAuth';
|
||||
|
||||
let crypto: typeof import('node:crypto') | undefined;
|
||||
|
||||
/**
|
||||
* Retrieves the certificate content from the given value.
|
||||
*
|
||||
* This function determines whether the provided value is a certificate string (RFC7468 format or
|
||||
* base64-encoded without a header) or a valid file path. If the value matches one of these formats,
|
||||
* the certificate content is returned. Otherwise, an error is thrown.
|
||||
*
|
||||
* @see https://github.com/node-saml/node-saml/tree/master?tab=readme-ov-file#configuration-option-idpcert
|
||||
* @param {string} value - The certificate string or file path.
|
||||
* @returns {string} The certificate content if valid.
|
||||
* @throws {Error} If the value is not a valid certificate string or file path.
|
||||
*/
|
||||
function getCertificateContent(value: any): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('Invalid input: SAML_CERT must be a string.');
|
||||
}
|
||||
|
||||
// Check if it's an RFC7468 formatted PEM certificate
|
||||
const pemRegex = new RegExp(
|
||||
'-----BEGIN (CERTIFICATE|PUBLIC KEY)-----\n' + // header
|
||||
'([A-Za-z0-9+/=]{64}\n)+' + // base64 content (64 characters per line)
|
||||
'[A-Za-z0-9+/=]{1,64}\n' + // base64 content (last line)
|
||||
'-----END (CERTIFICATE|PUBLIC KEY)-----', // footer
|
||||
);
|
||||
if (pemRegex.test(value)) {
|
||||
logger.info('[samlStrategy] Detected RFC7468-formatted certificate string.');
|
||||
return value;
|
||||
}
|
||||
|
||||
// Check if it's a Base64-encoded certificate (no header)
|
||||
if (/^[A-Za-z0-9+/=]+$/.test(value) && value.length % 4 === 0) {
|
||||
logger.info('[samlStrategy] Detected base64-encoded certificate string (no header).');
|
||||
return value;
|
||||
}
|
||||
|
||||
// Check if file exists and is readable
|
||||
// const root = path.resolve(__dirname, '..', '..');
|
||||
const certPath = path.normalize(path.isAbsolute(value) ? value : '/');
|
||||
// const certPath = path.normalize(path.isAbsolute(value) ? value : path.join(root, value));
|
||||
if (fs.existsSync(certPath) && fs.statSync(certPath).isFile()) {
|
||||
try {
|
||||
logger.info(`[samlStrategy] Loading certificate from file: ${certPath}`);
|
||||
return fs.readFileSync(certPath, 'utf8').trim();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error(`Error reading certificate file: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid cert: SAML_CERT must be a valid file path or certificate string.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a SAML claim from a profile object based on environment configuration.
|
||||
* @param {object} profile - Saml profile
|
||||
* @param {string} envVar - Environment variable name (SAML_*)
|
||||
* @param {string} defaultKey - Default key to use if the environment variable is not set
|
||||
* @returns {string}
|
||||
*/
|
||||
function getSamlClaim(profile: Profile | null, envVar: string, defaultKey: string): string {
|
||||
if (profile) {
|
||||
const claimKey = process.env[envVar] as keyof Profile;
|
||||
let returnVal = profile[defaultKey as keyof Profile];
|
||||
// Avoids accessing `profile[""]` when the environment variable is empty string.
|
||||
if (claimKey) {
|
||||
returnVal = profile[claimKey] ?? profile[defaultKey as keyof Profile];
|
||||
}
|
||||
if (typeof returnVal == 'string') {
|
||||
return returnVal;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getEmail(profile: Profile | null) {
|
||||
return getSamlClaim(profile, 'SAML_EMAIL_CLAIM', 'email');
|
||||
}
|
||||
|
||||
function getUserName(profile: Profile | null): string {
|
||||
return getSamlClaim(profile, 'SAML_USERNAME_CLAIM', 'username');
|
||||
}
|
||||
|
||||
function getGivenName(profile: Profile | null) {
|
||||
return getSamlClaim(profile, 'SAML_GIVEN_NAME_CLAIM', 'given_name');
|
||||
}
|
||||
|
||||
function getFamilyName(profile: Profile | null) {
|
||||
return getSamlClaim(profile, 'SAML_FAMILY_NAME_CLAIM', 'family_name');
|
||||
}
|
||||
|
||||
function getPicture(profile: Profile | null) {
|
||||
return getSamlClaim(profile, 'SAML_PICTURE_CLAIM', 'picture');
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an image from a URL using an access token.
|
||||
* @param {string} url
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
const downloadImage = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
} else {
|
||||
throw new Error(`${response.statusText} (HTTP ${response.status})`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(`[samlStrategy] Error downloading image at URL "${url}": ${errorMessage}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines the full name of a user based on SAML profile and environment configuration.
|
||||
*
|
||||
* @param {Object} profile - The user profile object from SAML Connect
|
||||
* @returns {string} The determined full name of the user
|
||||
*/
|
||||
function getFullName(profile: Profile | null): string {
|
||||
const nameClaim = process.env.SAML_NAME_CLAIM;
|
||||
if (profile && nameClaim && nameClaim in profile) {
|
||||
const key = nameClaim as keyof Profile;
|
||||
logger.info(
|
||||
`[samlStrategy] Using SAML_NAME_CLAIM: ${process.env.SAML_NAME_CLAIM}, profile: ${profile[key]}`,
|
||||
);
|
||||
return profile[key] + '';
|
||||
}
|
||||
|
||||
const givenName = getGivenName(profile);
|
||||
const familyName = getFamilyName(profile);
|
||||
|
||||
if (givenName && familyName) {
|
||||
return `${givenName} ${familyName}`;
|
||||
}
|
||||
|
||||
if (givenName) {
|
||||
return givenName + '';
|
||||
}
|
||||
if (familyName) {
|
||||
return familyName + '';
|
||||
}
|
||||
|
||||
return getUserName(profile) || getEmail(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an input into a string suitable for a username.
|
||||
* If the input is a string, it will be returned as is.
|
||||
* If the input is an array, elements will be joined with underscores.
|
||||
* In case of undefined or other falsy values, a default value will be returned.
|
||||
*
|
||||
* @param {string | string[] | undefined} input - The input value to be converted into a username.
|
||||
* @param {string} [defaultValue=''] - The default value to return if the input is falsy.
|
||||
* @returns {string} The processed input as a string suitable for a username.
|
||||
*/
|
||||
function convertToUsername(input: string | string[], defaultValue: string = '') {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
} else if (Array.isArray(input)) {
|
||||
return input.join('_');
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
const signOnVerify = async (profile: Profile | null, done: (err: any, user?: any) => void) => {
|
||||
const { findUser, createUser, updateUser } = getMethods();
|
||||
try {
|
||||
logger.info(`[samlStrategy] SAML authentication received for NameID: ${profile?.nameID}`);
|
||||
logger.debug('[samlStrategy] SAML profile:', profile);
|
||||
|
||||
let user = await findUser({ samlId: profile?.nameID });
|
||||
logger.info(
|
||||
`[samlStrategy] User ${user ? 'found' : 'not found'} with SAML ID: ${profile?.nameID}`,
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
const email = getEmail(profile) || '';
|
||||
user = await findUser({ email });
|
||||
logger.info(
|
||||
`[samlStrategy] User ${user ? 'found' : 'not found'} with email: ${profile?.email}`,
|
||||
);
|
||||
}
|
||||
|
||||
const fullName = getFullName(profile);
|
||||
|
||||
const username = convertToUsername(
|
||||
getUserName(profile) || getGivenName(profile) || getEmail(profile),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
user = {
|
||||
provider: 'saml',
|
||||
samlId: profile?.nameID,
|
||||
username,
|
||||
email: getEmail(profile) || '',
|
||||
emailVerified: true,
|
||||
name: fullName,
|
||||
};
|
||||
const balanceConfig = await getBalanceConfig();
|
||||
user = await createUser(user, balanceConfig, true, true);
|
||||
} else {
|
||||
user.provider = 'saml';
|
||||
user.samlId = profile?.nameID;
|
||||
user.username = username;
|
||||
user.name = fullName;
|
||||
}
|
||||
|
||||
const picture = getPicture(profile);
|
||||
if (picture && !user.avatar?.includes('manual=true')) {
|
||||
const imageBuffer = await downloadImage(profile?.picture?.toString() ?? '');
|
||||
if (imageBuffer) {
|
||||
let fileName;
|
||||
|
||||
try {
|
||||
crypto = await import('node:crypto');
|
||||
} catch (err) {
|
||||
logger.error('[samlStrategy] crypto support is disabled!', err);
|
||||
}
|
||||
|
||||
if (crypto) {
|
||||
fileName = (await hashToken(profile?.nameID.toString() ?? '')) + '.png';
|
||||
} else {
|
||||
fileName = profile?.nameID + '.png';
|
||||
}
|
||||
|
||||
const saveBuffer = getSaveBufferStrategy();
|
||||
const imagePath = await saveBuffer({
|
||||
fileName,
|
||||
userId: user._id.toString(),
|
||||
buffer: imageBuffer,
|
||||
});
|
||||
user.avatar = imagePath ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
user = await updateUser(user._id, user);
|
||||
|
||||
logger.info(
|
||||
`[samlStrategy] Login success SAML ID: ${user.samlId} | email: ${user.email} | username: ${user.username}`,
|
||||
{
|
||||
user: {
|
||||
samlId: user.samlId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
done(null, user);
|
||||
} catch (err) {
|
||||
logger.error('[samlStrategy] Login failed', err);
|
||||
done(err);
|
||||
}
|
||||
};
|
||||
|
||||
const samlLogin = () => {
|
||||
const samlConfig: PassportSamlConfig = {
|
||||
entryPoint: process.env.SAML_ENTRY_POINT,
|
||||
issuer: process.env.SAML_ISSUER + '',
|
||||
callbackUrl: process.env.SAML_CALLBACK_URL + '',
|
||||
idpCert: getCertificateContent(process.env.SAML_CERT) ?? '',
|
||||
wantAssertionsSigned: process.env.SAML_USE_AUTHN_RESPONSE_SIGNED === 'true' ? false : true,
|
||||
wantAuthnResponseSigned: process.env.SAML_USE_AUTHN_RESPONSE_SIGNED === 'true' ? true : false,
|
||||
};
|
||||
|
||||
return new SamlStrategy(samlConfig, signOnVerify, () => {
|
||||
logger.info('saml logout!');
|
||||
});
|
||||
};
|
||||
|
||||
export { samlLogin, getCertificateContent };
|
||||
56
packages/auth/src/strategies/socialLogin.ts
Normal file
56
packages/auth/src/strategies/socialLogin.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { Profile } from 'passport';
|
||||
import { VerifyCallback } from 'passport-oauth2';
|
||||
import { getMethods } from '../initAuth';
|
||||
import { isEnabled } from '../utils';
|
||||
import { createSocialUser, handleExistingUser } from './helpers';
|
||||
import { GetProfileDetails, SocialLoginStrategy } from './types';
|
||||
|
||||
export function socialLogin(
|
||||
provider: string,
|
||||
getProfileDetails: GetProfileDetails,
|
||||
): SocialLoginStrategy {
|
||||
return async (
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
idToken: string,
|
||||
profile: Profile,
|
||||
cb: VerifyCallback,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { email, id, avatarUrl, username, name, emailVerified } = getProfileDetails({
|
||||
idToken,
|
||||
profile,
|
||||
});
|
||||
const { findUser } = getMethods();
|
||||
const oldUser = await findUser({ email: email?.trim() });
|
||||
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION ?? '');
|
||||
|
||||
if (oldUser) {
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
return cb(null, oldUser);
|
||||
}
|
||||
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createSocialUser({
|
||||
email,
|
||||
avatarUrl,
|
||||
provider,
|
||||
providerKey: `${provider}Id`,
|
||||
providerId: id,
|
||||
username,
|
||||
name,
|
||||
emailVerified,
|
||||
});
|
||||
return cb(null, newUser);
|
||||
}
|
||||
|
||||
return cb(new Error('Social registration is disabled'));
|
||||
} catch (err) {
|
||||
logger.error(`[${provider}Login]`, err);
|
||||
return cb(err as Error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default socialLogin;
|
||||
35
packages/auth/src/strategies/types.ts
Normal file
35
packages/auth/src/strategies/types.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { VerifyCallback } from 'passport-oauth2';
|
||||
import { Profile } from 'passport';
|
||||
import { IUser } from '@librechat/data-schemas';
|
||||
|
||||
export interface GetProfileDetailsParams {
|
||||
idToken: string;
|
||||
profile: Profile;
|
||||
}
|
||||
export type GetProfileDetails = (
|
||||
params: GetProfileDetailsParams,
|
||||
) => Partial<IUser> & { avatarUrl: string };
|
||||
|
||||
export type SocialLoginStrategy = (
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
idToken: string,
|
||||
profile: Profile,
|
||||
cb: VerifyCallback,
|
||||
) => Promise<void>;
|
||||
|
||||
export interface CreateSocialUserParams {
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
provider: string;
|
||||
providerKey: string;
|
||||
providerId: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
emailVerified?: boolean;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
id: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
22
packages/auth/src/types/avatar.ts
Normal file
22
packages/auth/src/types/avatar.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { EImageOutputType } from 'librechat-data-provider';
|
||||
import sharp from 'sharp';
|
||||
|
||||
export interface ResizeAvatarParams {
|
||||
userId: string;
|
||||
input: string | Buffer | File;
|
||||
desiredFormat?: typeof EImageOutputType;
|
||||
}
|
||||
|
||||
export interface ResizeAndConvertOptions {
|
||||
inputBuffer: Buffer;
|
||||
desiredFormat: keyof sharp.FormatEnum | typeof EImageOutputType;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export interface ProcessAvatarParams {
|
||||
buffer: Buffer;
|
||||
userId: string;
|
||||
manual?: string | boolean;
|
||||
basePath?: string;
|
||||
containerName?: string;
|
||||
}
|
||||
15
packages/auth/src/types/email.ts
Normal file
15
packages/auth/src/types/email.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export interface SendEmailParams {
|
||||
email: string;
|
||||
subject: string;
|
||||
payload: Record<string, string | number>;
|
||||
template: string;
|
||||
throwError?: boolean;
|
||||
}
|
||||
|
||||
export interface SendEmailResponse {
|
||||
accepted: string[];
|
||||
rejected: string[];
|
||||
response: string;
|
||||
envelope: { from: string; to: string[] };
|
||||
messageId: string;
|
||||
}
|
||||
10
packages/auth/src/types/index.ts
Normal file
10
packages/auth/src/types/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export interface LogoutResponse {
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: { _id: string };
|
||||
session?: {
|
||||
destroy: (callback?: (err?: any) => void) => void;
|
||||
};
|
||||
}
|
||||
271
packages/auth/src/utils/avatar.ts
Normal file
271
packages/auth/src/utils/avatar.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import sharp from 'sharp';
|
||||
import { FileSources } from 'librechat-data-provider';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getMethods, getSaveBufferStrategy } from '../initAuth';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { ProcessAvatarParams, ResizeAndConvertOptions, ResizeAvatarParams } from '../types/avatar';
|
||||
const { EImageOutputType } = require('librechat-data-provider');
|
||||
|
||||
const defaultBasePath = 'images';
|
||||
|
||||
const getAvatarProcessFunction = (fileSource: string): Function => {
|
||||
if (fileSource === FileSources.firebase) {
|
||||
return processFirebaseAvatar;
|
||||
} else if (fileSource === FileSources.local) {
|
||||
return processLocalAvatar;
|
||||
} else if (fileSource === FileSources.azure_blob) {
|
||||
return processAzureAvatar;
|
||||
} else if (fileSource === FileSources.s3) {
|
||||
return processS3Avatar;
|
||||
} else {
|
||||
throw new Error('Invalid file source for saving avata');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Uploads a user's avatar to Firebase Storage and returns the URL.
|
||||
* If the 'manual' flag is set to 'true', it also updates the user's avatar URL in the database.
|
||||
*
|
||||
* @param {object} params - The parameters object.
|
||||
* @param {Buffer} params.buffer - The Buffer containing the avatar image.
|
||||
* @param {string} params.userId - The user ID.
|
||||
* @param {string} params.manual - A string flag indicating whether the update is manual ('true' or 'false').
|
||||
* @returns {Promise<string>} - A promise that resolves with the URL of the uploaded avatar.
|
||||
* @throws {Error} - Throws an error if Firebase is not initialized or if there is an error in uploading.
|
||||
*/
|
||||
async function processFirebaseAvatar({
|
||||
buffer,
|
||||
userId,
|
||||
manual,
|
||||
}: ProcessAvatarParams): Promise<string> {
|
||||
try {
|
||||
const saveBufferToFirebase = getSaveBufferStrategy();
|
||||
const downloadURL = await saveBufferToFirebase({
|
||||
userId,
|
||||
buffer,
|
||||
fileName: 'avatar.png',
|
||||
});
|
||||
|
||||
const isManual = manual === 'true';
|
||||
|
||||
const url = `${downloadURL}?manual=${isManual}`;
|
||||
|
||||
if (isManual) {
|
||||
const { updateUser } = getMethods();
|
||||
await updateUser(userId, { avatar: url });
|
||||
}
|
||||
|
||||
return url;
|
||||
} catch (error) {
|
||||
logger.error('Error uploading profile picture:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a user's avatar to local server storage and returns the URL.
|
||||
* If the 'manual' flag is set to 'true', it also updates the user's avatar URL in the database.
|
||||
*
|
||||
* @param {object} params - The parameters object.
|
||||
* @param {Buffer} params.buffer - The Buffer containing the avatar image.
|
||||
* @param {string} params.userId - The user ID.
|
||||
* @param {string} params.manual - A string flag indicating whether the update is manual ('true' or 'false').
|
||||
* @returns {Promise<string>} - A promise that resolves with the URL of the uploaded avatar.
|
||||
* @throws {Error} - Throws an error if Firebase is not initialized or if there is an error in uploading.
|
||||
*/
|
||||
async function processLocalAvatar({ buffer, userId, manual }: ProcessAvatarParams) {
|
||||
const userDir = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'client',
|
||||
'public',
|
||||
'images',
|
||||
userId,
|
||||
);
|
||||
|
||||
const fileName = `avatar-${new Date().getTime()}.png`;
|
||||
const urlRoute = `/images/${userId}/${fileName}`;
|
||||
const avatarPath = path.join(userDir, fileName);
|
||||
|
||||
await fs.promises.mkdir(userDir, { recursive: true });
|
||||
await fs.promises.writeFile(avatarPath, buffer);
|
||||
|
||||
const isManual = manual === 'true';
|
||||
let url = `${urlRoute}?manual=${isManual}`;
|
||||
|
||||
if (isManual) {
|
||||
const { updateUser } = getMethods();
|
||||
await updateUser(userId, { avatar: url });
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a user's avatar image by uploading it to S3 and updating the user's avatar URL if required.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Buffer} params.buffer - Avatar image buffer.
|
||||
* @param {string} params.userId - User's unique identifier.
|
||||
* @param {string} params.manual - 'true' or 'false' flag for manual update.
|
||||
* @param {string} [params.basePath='images'] - Base path in the bucket.
|
||||
* @returns {Promise<string>} Signed URL of the uploaded avatar.
|
||||
*/
|
||||
async function processS3Avatar({
|
||||
buffer,
|
||||
userId,
|
||||
manual,
|
||||
basePath = defaultBasePath,
|
||||
}: ProcessAvatarParams): Promise<string> {
|
||||
try {
|
||||
const saveBufferToS3 = getSaveBufferStrategy();
|
||||
const downloadURL = await saveBufferToS3({ userId, buffer, fileName: 'avatar.png', basePath });
|
||||
if (manual === 'true') {
|
||||
const { updateUser } = getMethods();
|
||||
await updateUser(userId, { avatar: downloadURL });
|
||||
}
|
||||
return downloadURL;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error('Error processing S3 avatar: ' + errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads and processes a user's avatar to Azure Blob Storage.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Buffer} params.buffer - The avatar image buffer.
|
||||
* @param {string} params.userId - The user's id.
|
||||
* @param {string} params.manual - Flag to indicate manual update.
|
||||
* @param {string} [params.basePath='images'] - The base folder within the container.
|
||||
* @param {string} [params.containerName] - The Azure Blob container name.
|
||||
* @returns {Promise<string>} The URL of the avatar.
|
||||
*/
|
||||
async function processAzureAvatar({
|
||||
buffer,
|
||||
userId,
|
||||
manual,
|
||||
basePath = 'images',
|
||||
containerName,
|
||||
}: ProcessAvatarParams) {
|
||||
try {
|
||||
const saveBufferToAzure = getSaveBufferStrategy();
|
||||
const downloadURL = await saveBufferToAzure({
|
||||
userId,
|
||||
buffer,
|
||||
fileName: 'avatar.png',
|
||||
basePath,
|
||||
containerName,
|
||||
});
|
||||
const isManual = manual === 'true';
|
||||
const url = `${downloadURL}?manual=${isManual}`;
|
||||
if (isManual) {
|
||||
const { updateUser } = getMethods();
|
||||
await updateUser(userId, { avatar: url });
|
||||
}
|
||||
return url;
|
||||
} catch (error) {
|
||||
logger.error('[processAzureAvatar] Error uploading profile picture to Azure:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an avatar image for a user. This function can handle various types of input (URL, Buffer, or File object),
|
||||
* processes the image to a square format, converts it to target format, and returns the resized buffer.
|
||||
*
|
||||
* @param {Object} params - The parameters object.
|
||||
* @param {string} params.userId - The unique identifier of the user for whom the avatar is being uploaded.
|
||||
* @param {string} options.desiredFormat - The desired output format of the image.
|
||||
* @param {(string|Buffer|File)} params.input - The input representing the avatar image. Can be a URL (string),
|
||||
* a Buffer, or a File object.
|
||||
*
|
||||
* @returns {Promise<any>}
|
||||
* A promise that resolves to a resized buffer.
|
||||
*
|
||||
* @throws {Error} Throws an error if the user ID is undefined, the input type is invalid, the image fetching fails,
|
||||
* or any other error occurs during the processing.
|
||||
*/
|
||||
async function resizeAvatar({
|
||||
userId,
|
||||
input,
|
||||
desiredFormat = EImageOutputType.PNG,
|
||||
}: ResizeAvatarParams) {
|
||||
try {
|
||||
if (userId === undefined) {
|
||||
throw new Error('User ID is undefined');
|
||||
}
|
||||
|
||||
let imageBuffer: Buffer;
|
||||
if (typeof input === 'string') {
|
||||
const response = await fetch(input);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image from URL. Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
imageBuffer = Buffer.from(arrayBuffer);
|
||||
} else if (input instanceof Buffer) {
|
||||
imageBuffer = input;
|
||||
} else if (typeof input === 'object' && input instanceof File) {
|
||||
console.log(input);
|
||||
console.log('----');
|
||||
// @ts-ignore
|
||||
const fileContent = await fs.promises.readFile(input?.path);
|
||||
imageBuffer = Buffer.from(fileContent);
|
||||
} else {
|
||||
throw new Error('Invalid input type. Expected URL, Buffer, or File.');
|
||||
}
|
||||
|
||||
const metadata = await sharp(imageBuffer).metadata();
|
||||
const width = metadata.width ?? 0;
|
||||
const height = metadata.height ?? 0;
|
||||
const minSize = Math.min(width, height);
|
||||
|
||||
const squaredBuffer = await sharp(imageBuffer)
|
||||
.extract({
|
||||
left: Math.floor((width - minSize) / 2),
|
||||
top: Math.floor((height - minSize) / 2),
|
||||
width: minSize,
|
||||
height: minSize,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
const buffer = await resizeAndConvert({
|
||||
inputBuffer: squaredBuffer,
|
||||
desiredFormat,
|
||||
});
|
||||
return buffer;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error('Error uploading the avatar: ' + errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes an image buffer to a specified format and width.
|
||||
*
|
||||
* @param {ResizeAndConvertOptions} options - The options for resizing and converting the image.
|
||||
* @returns {Buffer} An object containing the resized image buffer, its size, and dimensions.
|
||||
* @throws Will throw an error if the resolution or format parameters are invalid.
|
||||
*/
|
||||
async function resizeAndConvert({
|
||||
inputBuffer,
|
||||
desiredFormat,
|
||||
width = 150,
|
||||
}: ResizeAndConvertOptions) {
|
||||
const resizedBuffer: Buffer = await sharp(inputBuffer)
|
||||
.resize({ width })
|
||||
.toFormat(desiredFormat as keyof sharp.FormatEnum)
|
||||
.toBuffer();
|
||||
|
||||
return resizedBuffer;
|
||||
}
|
||||
export { resizeAvatar, resizeAndConvert, getAvatarProcessFunction };
|
||||
222
packages/auth/src/utils/email.ts
Normal file
222
packages/auth/src/utils/email.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import nodemailer, { TransportOptions } from 'nodemailer';
|
||||
import handlebars from 'handlebars';
|
||||
import { createTokenHash, isEnabled } from '.';
|
||||
import { IUser, logger } from '@librechat/data-schemas';
|
||||
import { getMethods } from '../initAuth';
|
||||
import { ObjectId } from 'mongoose';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { Request } from 'express';
|
||||
import { SendEmailParams, SendEmailResponse } from '../types/email';
|
||||
|
||||
const genericVerificationMessage = 'Please check your email to verify your email address.';
|
||||
const domains = {
|
||||
client: process.env.DOMAIN_CLIENT,
|
||||
server: process.env.DOMAIN_SERVER,
|
||||
};
|
||||
export const sendEmail = async ({
|
||||
email,
|
||||
subject,
|
||||
payload,
|
||||
template,
|
||||
throwError = true,
|
||||
}: SendEmailParams): Promise<SendEmailResponse | Error> => {
|
||||
try {
|
||||
const transporterOptions: TransportOptions = {
|
||||
secure: process.env.EMAIL_ENCRYPTION === 'tls',
|
||||
requireTLS: process.env.EMAIL_ENCRYPTION === 'starttls',
|
||||
tls: {
|
||||
rejectUnauthorized: !isEnabled(process.env.EMAIL_ALLOW_SELFSIGNED ?? ''),
|
||||
},
|
||||
auth: {
|
||||
user: process.env.EMAIL_USERNAME,
|
||||
pass: process.env.EMAIL_PASSWORD,
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env.EMAIL_ENCRYPTION_HOSTNAME) {
|
||||
transporterOptions.tls = {
|
||||
...transporterOptions.tls,
|
||||
servername: process.env.EMAIL_ENCRYPTION_HOSTNAME,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.EMAIL_SERVICE) {
|
||||
transporterOptions.service = process.env.EMAIL_SERVICE;
|
||||
} else {
|
||||
transporterOptions.host = process.env.EMAIL_HOST;
|
||||
transporterOptions.port = Number(process.env.EMAIL_PORT ?? 25);
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(transporterOptions);
|
||||
|
||||
const templatePath = path.join(__dirname, 'utils/', template);
|
||||
const source = fs.readFileSync(templatePath, 'utf8');
|
||||
const compiledTemplate = handlebars.compile(source);
|
||||
|
||||
const mailOptions = {
|
||||
from: `"${process.env.EMAIL_FROM_NAME || process.env.APP_TITLE}" <${process.env.EMAIL_FROM}>`,
|
||||
to: `"${payload.name}" <${email}>`,
|
||||
envelope: {
|
||||
from: process.env.EMAIL_FROM!,
|
||||
to: email,
|
||||
},
|
||||
subject,
|
||||
html: compiledTemplate(payload),
|
||||
};
|
||||
|
||||
return await transporter.sendMail(mailOptions);
|
||||
} catch (error: any) {
|
||||
if (throwError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error('[sendEmail]', error);
|
||||
return error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send Verification Email
|
||||
* @param {Partial<MongoUser> & { _id: ObjectId, email: string, name: string}} user
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const sendVerificationEmail = async (
|
||||
user: Partial<IUser> & { _id: ObjectId; email: string },
|
||||
) => {
|
||||
const [verifyToken, hash] = createTokenHash();
|
||||
const { createToken } = getMethods();
|
||||
const verificationLink = `${
|
||||
domains.client
|
||||
}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`;
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
subject: 'Verify your email',
|
||||
payload: {
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name || user.username || user.email,
|
||||
verificationLink: verificationLink,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
template: 'verifyEmail.handlebars',
|
||||
});
|
||||
|
||||
await createToken({
|
||||
userId: user._id,
|
||||
email: user.email,
|
||||
token: hash,
|
||||
createdAt: Date.now(),
|
||||
expiresIn: 900,
|
||||
});
|
||||
|
||||
logger.info(`[sendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify Email
|
||||
* @param {Express.Request} req
|
||||
*/
|
||||
export const verifyEmail = async (req: Request) => {
|
||||
const { email, token } = req.body;
|
||||
const decodedEmail = decodeURIComponent(email);
|
||||
const { findUser, findToken, updateUser, deleteTokens } = getMethods();
|
||||
|
||||
const user = await findUser({ email: decodedEmail }, 'email _id emailVerified');
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`[verifyEmail] [User not found] [Email: ${decodedEmail}]`);
|
||||
return new Error('User not found');
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
logger.info(`[verifyEmail] Email already verified [Email: ${decodedEmail}]`);
|
||||
return { message: 'Email already verified', status: 'success' };
|
||||
}
|
||||
|
||||
let emailVerificationData = await findToken({ email: decodedEmail });
|
||||
|
||||
if (!emailVerificationData) {
|
||||
logger.warn(`[verifyEmail] [No email verification data found] [Email: ${decodedEmail}]`);
|
||||
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: ${decodedEmail}]`,
|
||||
);
|
||||
return new Error('Invalid or expired email verification token');
|
||||
}
|
||||
|
||||
const updatedUser = await updateUser(emailVerificationData.userId, { emailVerified: true });
|
||||
|
||||
if (!updatedUser) {
|
||||
logger.warn(`[verifyEmail] [User update failed] [Email: ${decodedEmail}]`);
|
||||
return new Error('Failed to update user verification status');
|
||||
}
|
||||
|
||||
await deleteTokens({ token: emailVerificationData.token });
|
||||
logger.info(`[verifyEmail] Email verification successful [Email: ${decodedEmail}]`);
|
||||
return { message: 'Email verification was successful', status: 'success' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Resend Verification Email
|
||||
* @param {Object} req
|
||||
* @param {Object} req.body
|
||||
* @param {String} req.body.email
|
||||
* @returns {Promise<{status: number, message: string}>}
|
||||
*/
|
||||
export const resendVerificationEmail = async (req: Request) => {
|
||||
try {
|
||||
const { deleteTokens, findUser, createToken } = getMethods();
|
||||
const { email } = req.body as { email: string };
|
||||
await deleteTokens(email);
|
||||
const user = await findUser({ email }, 'email _id name');
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`);
|
||||
return { status: 200, message: genericVerificationMessage };
|
||||
}
|
||||
|
||||
const [verifyToken, hash] = createTokenHash();
|
||||
|
||||
const verificationLink = `${
|
||||
domains.client
|
||||
}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`;
|
||||
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
subject: 'Verify your email',
|
||||
payload: {
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name || user.username || user.email,
|
||||
verificationLink: verificationLink,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
template: 'verifyEmail.handlebars',
|
||||
});
|
||||
|
||||
await createToken({
|
||||
userId: user._id,
|
||||
email: user.email,
|
||||
token: hash,
|
||||
createdAt: Date.now(),
|
||||
expiresIn: 900,
|
||||
});
|
||||
|
||||
logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
message: genericVerificationMessage,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`[resendVerificationEmail] Error resending verification email: ${error.message}`);
|
||||
return {
|
||||
status: 500,
|
||||
message: 'Something went wrong.',
|
||||
};
|
||||
}
|
||||
};
|
||||
287
packages/auth/src/utils/emails/inviteUser.handlebars
Normal file
287
packages/auth/src/utils/emails/inviteUser.handlebars
Normal 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>
|
||||
196
packages/auth/src/utils/emails/passwordReset.handlebars
Normal file
196
packages/auth/src/utils/emails/passwordReset.handlebars
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<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; } .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; }
|
||||
</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
|
||||
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'
|
||||
>
|
||||
<div
|
||||
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
|
||||
>
|
||||
<div>Hi {{name}},</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>
|
||||
<div>Your password has been updated successfully! </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>
|
||||
284
packages/auth/src/utils/emails/requestPasswordReset.handlebars
Normal file
284
packages/auth/src/utils/emails/requestPasswordReset.handlebars
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
<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 requested to reset your password.
|
||||
</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 {{name}},</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%;'>Please click the button below to
|
||||
reset your password.</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="{{link}}" style="height:37px; v-text-anchor:middle; width:142px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
|
||||
<a
|
||||
href='{{link}}'
|
||||
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;'>Reset Password</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>If you did not request a password reset, please ignore this
|
||||
email.</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>
|
||||
290
packages/auth/src/utils/emails/verifyEmail.handlebars
Normal file
290
packages/auth/src/utils/emails/verifyEmail.handlebars
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
<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; } .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>Welcome to {{appName}}!</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>
|
||||
<div>Dear {{name}},</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>
|
||||
<div>Thank you for registering with
|
||||
{{appName}}. To complete your registration and verify your
|
||||
email address, please click the button below:</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'
|
||||
>
|
||||
<!--[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="href="{{verificationLink}}"" style="height:37px; v-text-anchor:middle; width:114px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
|
||||
<a
|
||||
href='{{verificationLink}}'
|
||||
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%;'>
|
||||
<div>
|
||||
<div>Verify Email</div>
|
||||
</div>
|
||||
</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>If you did not create an account with
|
||||
{{appName}}, please ignore this email.</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>
|
||||
|
|
@ -1,4 +1,15 @@
|
|||
export * from './schemaMethods';
|
||||
export * from './avatar';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
/**
|
||||
* Creates Token and corresponding Hash for verification
|
||||
* @returns {[string, string]}
|
||||
*/
|
||||
const createTokenHash = (): [string, string] => {
|
||||
const token: string = Buffer.from(webcrypto.getRandomValues(new Uint8Array(32))).toString('hex');
|
||||
const hash: string = bcrypt.hashSync(token, 10);
|
||||
return [token, hash];
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the given value is truthy by being either the boolean `true` or a string
|
||||
|
|
@ -18,7 +29,7 @@ export * from './schemaMethods';
|
|||
* isEnabled(null); // returns false
|
||||
* isEnabled(); // returns false
|
||||
*/
|
||||
export function isEnabled(value: boolean | string) {
|
||||
function isEnabled(value: boolean | string) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
|
@ -28,7 +39,7 @@ export function isEnabled(value: boolean | string) {
|
|||
return false;
|
||||
}
|
||||
|
||||
export function checkEmailConfig() {
|
||||
function checkEmailConfig() {
|
||||
return (
|
||||
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
|
||||
!!process.env.EMAIL_USERNAME &&
|
||||
|
|
@ -36,3 +47,9 @@ export function checkEmailConfig() {
|
|||
!!process.env.EMAIL_FROM
|
||||
);
|
||||
}
|
||||
|
||||
export { checkEmailConfig, isEnabled, createTokenHash };
|
||||
// export this helper so we can mock them
|
||||
export { sendEmail, sendVerificationEmail, verifyEmail, resendVerificationEmail } from './email';
|
||||
export { resizeAvatar, resizeAndConvert, getAvatarProcessFunction } from './avatar';
|
||||
export { requestPasswordReset, resetPassword } from './password';
|
||||
|
|
|
|||
113
packages/auth/src/utils/password.ts
Normal file
113
packages/auth/src/utils/password.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { ObjectId } from 'mongoose';
|
||||
import { getMethods } from '../initAuth';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { sendEmail } from './email';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { checkEmailConfig, createTokenHash } from '.';
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Reset Password
|
||||
*
|
||||
* @param {*} userId
|
||||
* @param {String} token
|
||||
* @param {String} password
|
||||
* @returns
|
||||
*/
|
||||
const resetPassword = async (userId: string | ObjectId, token: string, password: string) => {
|
||||
const { findToken, updateUser, deleteTokens } = getMethods();
|
||||
let passwordResetToken = await findToken({
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!passwordResetToken) {
|
||||
return new Error('Invalid or expired password reset token');
|
||||
}
|
||||
|
||||
const isValid = bcrypt.compareSync(token, passwordResetToken.token);
|
||||
|
||||
if (!isValid) {
|
||||
return new Error('Invalid or expired password reset token');
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
const user = await updateUser(userId, { password: hash });
|
||||
|
||||
if (checkEmailConfig()) {
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
subject: 'Password Reset Successfully',
|
||||
payload: {
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name || user.username || user.email,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
template: 'passwordReset.handlebars',
|
||||
});
|
||||
}
|
||||
|
||||
await deleteTokens({ token: passwordResetToken.token });
|
||||
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
|
||||
return { message: 'Password reset was successful' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Request password reset
|
||||
* @param {Express.Request} req
|
||||
*/
|
||||
const requestPasswordReset = async (req: Request) => {
|
||||
const { email } = req.body;
|
||||
const { findUser, createToken, deleteTokens } = getMethods();
|
||||
const user = await findUser({ email }, 'email _id');
|
||||
const emailEnabled = checkEmailConfig();
|
||||
|
||||
logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`);
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`[requestPasswordReset] [No user found] [Email: ${email}] [IP: ${req.ip}]`);
|
||||
return {
|
||||
message: 'If an account with that email exists, a password reset link has been sent to it.',
|
||||
};
|
||||
}
|
||||
|
||||
await deleteTokens({ userId: user._id });
|
||||
|
||||
const [resetToken, hash] = createTokenHash();
|
||||
|
||||
await createToken({
|
||||
userId: user._id,
|
||||
token: hash,
|
||||
createdAt: Date.now(),
|
||||
expiresIn: 900,
|
||||
});
|
||||
|
||||
const link = `${process.env.DOMAIN_CLIENT}/reset-password?token=${resetToken}&userId=${user._id}`;
|
||||
|
||||
if (emailEnabled) {
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
subject: 'Password Reset Request',
|
||||
payload: {
|
||||
appName: process.env.APP_TITLE || 'LibreChat',
|
||||
name: user.name || user.username || user.email,
|
||||
link: link,
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
template: 'requestPasswordReset.handlebars',
|
||||
});
|
||||
logger.info(
|
||||
`[requestPasswordReset] Link emailed. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[requestPasswordReset] Link issued. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
|
||||
);
|
||||
return { link };
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'If an account with that email exists, a password reset link has been sent to it.',
|
||||
};
|
||||
};
|
||||
|
||||
export { requestPasswordReset, resetPassword };
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { createModels } from '@librechat/data-schemas';
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
const { createMethods } = require('@librechat/data-schemas');
|
||||
const methods = createMethods(mongoose);
|
||||
|
||||
const {
|
||||
findSession,
|
||||
deleteSession,
|
||||
createSession,
|
||||
findUser,
|
||||
countUsers,
|
||||
deleteUserById,
|
||||
createUser,
|
||||
updateUser,
|
||||
createToken,
|
||||
findToken,
|
||||
deleteTokens,
|
||||
generateToken,
|
||||
generateRefreshToken,
|
||||
getUserById,
|
||||
} = methods;
|
||||
|
||||
export {
|
||||
findSession,
|
||||
deleteSession,
|
||||
createSession,
|
||||
findUser,
|
||||
countUsers,
|
||||
deleteUserById,
|
||||
createUser,
|
||||
updateUser,
|
||||
createToken,
|
||||
findToken,
|
||||
deleteTokens,
|
||||
generateToken,
|
||||
generateRefreshToken,
|
||||
getUserById,
|
||||
};
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import nodemailer, { TransportOptions } from 'nodemailer';
|
||||
import handlebars from 'handlebars';
|
||||
import logger from '../config/winston';
|
||||
import { isEnabled } from '.';
|
||||
|
||||
interface SendEmailParams {
|
||||
email: string;
|
||||
subject: string;
|
||||
payload: Record<string, string | number>;
|
||||
template: string;
|
||||
throwError?: boolean;
|
||||
}
|
||||
|
||||
interface SendEmailResponse {
|
||||
accepted: string[];
|
||||
rejected: string[];
|
||||
response: string;
|
||||
envelope: { from: string; to: string[] };
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export const sendEmail = async ({
|
||||
email,
|
||||
subject,
|
||||
payload,
|
||||
template,
|
||||
throwError = true,
|
||||
}: SendEmailParams): Promise<SendEmailResponse | Error> => {
|
||||
try {
|
||||
const transporterOptions: TransportOptions = {
|
||||
secure: process.env.EMAIL_ENCRYPTION === 'tls',
|
||||
requireTLS: process.env.EMAIL_ENCRYPTION === 'starttls',
|
||||
tls: {
|
||||
rejectUnauthorized: !isEnabled(process.env.EMAIL_ALLOW_SELFSIGNED ?? ''),
|
||||
},
|
||||
auth: {
|
||||
user: process.env.EMAIL_USERNAME,
|
||||
pass: process.env.EMAIL_PASSWORD,
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env.EMAIL_ENCRYPTION_HOSTNAME) {
|
||||
transporterOptions.tls = {
|
||||
...transporterOptions.tls,
|
||||
servername: process.env.EMAIL_ENCRYPTION_HOSTNAME,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.EMAIL_SERVICE) {
|
||||
transporterOptions.service = process.env.EMAIL_SERVICE;
|
||||
} else {
|
||||
transporterOptions.host = process.env.EMAIL_HOST;
|
||||
transporterOptions.port = Number(process.env.EMAIL_PORT ?? 25);
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(transporterOptions);
|
||||
|
||||
const templatePath = path.join(__dirname, 'emails', template);
|
||||
const source = fs.readFileSync(templatePath, 'utf8');
|
||||
const compiledTemplate = handlebars.compile(source);
|
||||
|
||||
const mailOptions = {
|
||||
from: `"${process.env.EMAIL_FROM_NAME || process.env.APP_TITLE}" <${process.env.EMAIL_FROM}>`,
|
||||
to: `"${payload.name}" <${email}>`,
|
||||
envelope: {
|
||||
from: process.env.EMAIL_FROM!,
|
||||
to: email,
|
||||
},
|
||||
subject,
|
||||
html: compiledTemplate(payload),
|
||||
};
|
||||
|
||||
return await transporter.sendMail(mailOptions);
|
||||
} catch (error: any) {
|
||||
if (throwError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error('[sendEmail]', error);
|
||||
return error;
|
||||
}
|
||||
};
|
||||
|
|
@ -13,9 +13,12 @@
|
|||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@librechat/data-schemas/*": ["./packages/data-schemas/*"]
|
||||
}
|
||||
},
|
||||
|
||||
"typeRoots": ["./src/types", "./node_modules/@types"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue