diff --git a/api/cache/getCustomConfig.js b/api/cache/getCustomConfig.js index 62082c5cbae..6d5cf8f275b 100644 --- a/api/cache/getCustomConfig.js +++ b/api/cache/getCustomConfig.js @@ -4,7 +4,9 @@ const getLogStores = require('./getLogStores'); /** * Retrieves the configuration object - * @function getCustomConfig */ + * @function getCustomConfig + * @returns {Promise} + * */ async function getCustomConfig() { const cache = getLogStores(CacheKeys.CONFIG_STORE); let customConfig = await cache.get(CacheKeys.CUSTOM_CONFIG); diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index a60ae370efe..62e3ee83966 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -1,6 +1,7 @@ const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const { registerSchema, errorsToString } = require('~/strategies/validators'); +const getCustomConfig = require('~/cache/getCustomConfig'); const Token = require('~/models/schema/tokenSchema'); const { sendEmail } = require('~/server/utils'); const Session = require('~/models/Session'); @@ -12,6 +13,27 @@ const domains = { server: process.env.DOMAIN_SERVER, }; +async function isDomainAllowed(email) { + if (!email) { + return false; + } + + const domain = email.split('@')[1]; + + if (!domain) { + return false; + } + + const customConfig = await getCustomConfig(); + if (!customConfig) { + return true; + } else if (!customConfig?.registration?.allowedDomains) { + return true; + } + + return customConfig.registration.allowedDomains.includes(domain); +} + const isProduction = process.env.NODE_ENV === 'production'; /** @@ -80,6 +102,12 @@ const registerUser = async (user) => { return { status: 500, message: 'Something went wrong' }; } + if (!(await isDomainAllowed(email))) { + const errorMessage = 'Registration from this domain is not allowed.'; + logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`); + return { status: 403, message: errorMessage }; + } + //determine if this is the first registered user (not counting anonymous_user) const isFirstRegisteredUser = (await User.countDocuments({})) === 0; @@ -239,6 +267,7 @@ const setAuthTokens = async (userId, res, sessionId = null) => { module.exports = { registerUser, logoutUser, + isDomainAllowed, requestPasswordReset, resetPassword, setAuthTokens, diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js new file mode 100644 index 00000000000..23aad21d3fe --- /dev/null +++ b/api/server/services/AuthService.spec.js @@ -0,0 +1,39 @@ +const getCustomConfig = require('~/cache/getCustomConfig'); +const { isDomainAllowed } = require('./AuthService'); + +jest.mock('~/cache/getCustomConfig', () => jest.fn()); + +describe('isDomainAllowed', () => { + it('should allow domain when customConfig is not available', async () => { + getCustomConfig.mockResolvedValue(null); + await expect(isDomainAllowed('test@domain1.com')).resolves.toBe(true); + }); + + it('should allow domain when allowedDomains is not defined in customConfig', async () => { + getCustomConfig.mockResolvedValue({}); + await expect(isDomainAllowed('test@domain1.com')).resolves.toBe(true); + }); + + it('should reject an email if it is falsy', async () => { + getCustomConfig.mockResolvedValue({}); + await expect(isDomainAllowed('')).resolves.toBe(false); + }); + + it('should allow a domain if it is included in the allowedDomains', async () => { + getCustomConfig.mockResolvedValue({ + registration: { + allowedDomains: ['domain1.com', 'domain2.com'], + }, + }); + await expect(isDomainAllowed('user@domain1.com')).resolves.toBe(true); + }); + + it('should reject a domain if it is not included in the allowedDomains', async () => { + getCustomConfig.mockResolvedValue({ + registration: { + allowedDomains: ['domain1.com', 'domain2.com'], + }, + }); + await expect(isDomainAllowed('user@domain3.com')).resolves.toBe(false); + }); +}); diff --git a/api/typedefs.js b/api/typedefs.js index 35b4e993fa5..dce3db037f2 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -26,6 +26,12 @@ * @memberof typedefs */ +/** + * @exports TCustomConfig + * @typedef {import('librechat-data-provider').TCustomConfig} TCustomConfig + * @memberof typedefs + */ + /** * @exports TMessage * @typedef {import('librechat-data-provider').TMessage} TMessage diff --git a/docs/install/configuration/custom_config.md b/docs/install/configuration/custom_config.md index 20cb2905194..73652f59031 100644 --- a/docs/install/configuration/custom_config.md +++ b/docs/install/configuration/custom_config.md @@ -4,21 +4,6 @@ description: Comprehensive guide for configuring the `librechat.yaml` file AKA t weight: -10 --- - - # LibreChat Configuration Guide Welcome to the guide for configuring the **librechat.yaml** file in LibreChat. @@ -43,8 +28,11 @@ Stay tuned for ongoing enhancements to customize your LibreChat instance! - [Version](#version) - [Cache Settings](#cache-settings) - [File Strategy](#file-strategy) + - [Registration](#registration) - [Endpoints](#endpoints) - - [Endpoint Object Structure](#endpoint-object-structure) + - [Registration Object Structure](#registration-object-structure) + - [**allowedDomains**:](#allowedDomains) + - [Custom Endpoint Object Structure](#custom-endpoint-object-structure) - [**name**:](#name) - [**apiKey**:](#apikey) - [**baseURL**:](#baseurl) @@ -120,6 +108,15 @@ docker-compose up # no need to rebuild - **Description**: Determines where to save user uploaded/generated files. Defaults to `"local"` if omitted. - **Example**: `fileStrategy: "firebase"` +### Registration +- **Key**: `registration` +- **Type**: Object +- **Description**: Configures registration-related settings for the application. + - **Sub-Key**: `allowedDomains` + - **Type**: Array of Strings + - **Description**: Specifies a list of allowed email domains for user registration. Users attempting to register with email domains not listed here will be restricted from registering. +- [Registration Object Structure](#registration-object-structure) + ### Endpoints - **Key**: `endpoints` - **Type**: Object @@ -127,9 +124,34 @@ docker-compose up # no need to rebuild - **Sub-Key**: `custom` - **Type**: Array of Objects - **Description**: Each object in the array represents a unique endpoint configuration. + - [Custom Endpoint Object Structure](#custom-endpoint-object-structure) - **Required** -## Endpoint Object Structure +## Registration Object Structure + +```yaml +# Example Registration Object Structure +registration: + allowedDomains: + - "gmail.com" + - "protonmail.com" +``` + +### **allowedDomains**: + + > A list specifying allowed email domains for registration. + + - Type: Array of Strings + - Example: + ```yaml + allowedDomains: + - "gmail.com" + - "protonmail.com" + ``` + - **Required** + - **Note**: Users with email domains not listed will be restricted from registering. + +## Custom Endpoint Object Structure Each endpoint in the `custom` array should have the following structure: ```yaml @@ -345,8 +367,12 @@ Custom endpoints share logic with the OpenAI endpoint, and thus have default par ## Example Config ```yaml -version: 1.0.1 +version: 1.0.2 cache: true +# Example Registration Object Structure +registration: + allowedDomains: + - "gmail.com" endpoints: custom: # Mistral AI API diff --git a/docs/install/configuration/dotenv.md b/docs/install/configuration/dotenv.md index 24e10e42ffc..3dff8728a7b 100644 --- a/docs/install/configuration/dotenv.md +++ b/docs/install/configuration/dotenv.md @@ -665,7 +665,8 @@ see: **[User/Auth System](../configuration/user_auth_system.md)** ```bash ALLOW_EMAIL_LOGIN=true -ALLOW_REGISTRATION=true +ALLOW_REGISTRATION=true +ALLOWED_REGISTRATION_DOMAINS= ALLOW_SOCIAL_LOGIN=false ALLOW_SOCIAL_REGISTRATION=false ``` diff --git a/librechat.example.yaml b/librechat.example.yaml index faf7b5e147a..0c61e400b8d 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -1,9 +1,14 @@ # Configuration version (required) -version: 1.0.1 +version: 1.0.2 # Cache settings: Set to true to enable caching cache: true +# Example Registration Object Structure (optional) +# registration: + # allowedDomains: + # - "gmail.com" + # Definition of custom endpoints endpoints: custom: diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 396bcfbd06c..9357e9fa309 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -30,6 +30,11 @@ export const configSchema = z.object({ version: z.string(), cache: z.boolean(), fileStrategy: fileSourceSchema.optional(), + registration: z + .object({ + allowedDomains: z.array(z.string()).optional(), + }) + .optional(), endpoints: z .object({ custom: z.array(endpointSchema.partial()), @@ -37,6 +42,8 @@ export const configSchema = z.object({ .strict(), }); +export type TCustomConfig = z.infer; + export enum KnownEndpoints { mistral = 'mistral', openrouter = 'openrouter',