mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🛂 feat: Allow LDAP login via username (#3463)
* Allow LDAP login via username This patch adds the option to login via username instead of using an email address since the latter may not be unique or may change. For example, our organization has two main domains and users have a log and a short form of their mail address. This makes it hard for users to identify what their primary email address is and causes a lot of confusion. Using their username instead makes it much easier. Using a username will also make it easier in the future to not need a separate bind user to get user attributes. So, this is also a bit of prep work for that. * Update config.js * feat: Enable LDAP login via username This commit enables the option to login via username instead of using an email address for LDAP authentication. This change is necessary because email addresses may not be unique or may change, causing confusion for users. By using usernames, it becomes easier for users to identify their primary email address. Additionally, this change prepares for future improvements by eliminating the need for a separate bind user to retrieve user attributes. Co-authored-by: Danny Avila <danny@librechat.ai> * chore: jsdocs * chore: import order * ci: add ldap config tests --------- Co-authored-by: Lars Kiesow <lkiesow@uos.de>
This commit is contained in:
parent
e5dfa06e6c
commit
ba9cb71245
9 changed files with 114 additions and 10 deletions
|
|
@ -374,6 +374,7 @@ LDAP_BIND_CREDENTIALS=
|
||||||
LDAP_USER_SEARCH_BASE=
|
LDAP_USER_SEARCH_BASE=
|
||||||
LDAP_SEARCH_FILTER=mail={{username}}
|
LDAP_SEARCH_FILTER=mail={{username}}
|
||||||
LDAP_CA_CERT_PATH=
|
LDAP_CA_CERT_PATH=
|
||||||
|
# LDAP_LOGIN_USES_USERNAME=true
|
||||||
# LDAP_ID=
|
# LDAP_ID=
|
||||||
# LDAP_USERNAME=
|
# LDAP_USERNAME=
|
||||||
# LDAP_FULL_NAME=
|
# LDAP_FULL_NAME=
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,9 @@ describe.skip('GET /', () => {
|
||||||
openidLoginEnabled: true,
|
openidLoginEnabled: true,
|
||||||
openidLabel: 'Test OpenID',
|
openidLabel: 'Test OpenID',
|
||||||
openidImageUrl: 'http://test-server.com',
|
openidImageUrl: 'http://test-server.com',
|
||||||
ldapLoginEnabled: true,
|
ldap: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
serverDomain: 'http://test-server.com',
|
serverDomain: 'http://test-server.com',
|
||||||
emailLoginEnabled: 'true',
|
emailLoginEnabled: 'true',
|
||||||
registrationEnabled: 'true',
|
registrationEnabled: 'true',
|
||||||
|
|
|
||||||
55
api/server/routes/__tests__/ldap.spec.js
Normal file
55
api/server/routes/__tests__/ldap.spec.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
||||||
|
const { isEnabled } = require('~/server/utils');
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Config/ldap');
|
||||||
|
jest.mock('~/server/utils');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Mock the route handler
|
||||||
|
app.get('/api/config', (req, res) => {
|
||||||
|
const ldapConfig = getLdapConfig();
|
||||||
|
res.json({ ldap: ldapConfig });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LDAP Config Tests', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return LDAP config with username property when LDAP_LOGIN_USES_USERNAME is enabled', async () => {
|
||||||
|
getLdapConfig.mockReturnValue({ enabled: true, username: true });
|
||||||
|
isEnabled.mockReturnValue(true);
|
||||||
|
|
||||||
|
const response = await request(app).get('/api/config');
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body.ldap).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
username: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return LDAP config without username property when LDAP_LOGIN_USES_USERNAME is not enabled', async () => {
|
||||||
|
getLdapConfig.mockReturnValue({ enabled: true });
|
||||||
|
isEnabled.mockReturnValue(false);
|
||||||
|
|
||||||
|
const response = await request(app).get('/api/config');
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body.ldap).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not return LDAP config when LDAP is not enabled', async () => {
|
||||||
|
getLdapConfig.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const response = await request(app).get('/api/config');
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body.ldap).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
|
const { CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
|
||||||
|
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
||||||
const { getProjectByName } = require('~/models/Project');
|
const { getProjectByName } = require('~/models/Project');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
@ -33,7 +34,8 @@ router.get('/', async function (req, res) {
|
||||||
|
|
||||||
const instanceProject = await getProjectByName('instance', '_id');
|
const instanceProject = await getProjectByName('instance', '_id');
|
||||||
|
|
||||||
const ldapLoginEnabled = !!process.env.LDAP_URL && !!process.env.LDAP_USER_SEARCH_BASE;
|
const ldap = getLdapConfig();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
/** @type {TStartupConfig} */
|
/** @type {TStartupConfig} */
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|
@ -51,10 +53,9 @@ router.get('/', async function (req, res) {
|
||||||
!!process.env.OPENID_SESSION_SECRET,
|
!!process.env.OPENID_SESSION_SECRET,
|
||||||
openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID',
|
openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID',
|
||||||
openidImageUrl: process.env.OPENID_IMAGE_URL,
|
openidImageUrl: process.env.OPENID_IMAGE_URL,
|
||||||
ldapLoginEnabled,
|
|
||||||
serverDomain: process.env.DOMAIN_SERVER || 'http://localhost:3080',
|
serverDomain: process.env.DOMAIN_SERVER || 'http://localhost:3080',
|
||||||
emailLoginEnabled,
|
emailLoginEnabled,
|
||||||
registrationEnabled: !ldapLoginEnabled && isEnabled(process.env.ALLOW_REGISTRATION),
|
registrationEnabled: !ldap?.enabled && isEnabled(process.env.ALLOW_REGISTRATION),
|
||||||
socialLoginEnabled: isEnabled(process.env.ALLOW_SOCIAL_LOGIN),
|
socialLoginEnabled: isEnabled(process.env.ALLOW_SOCIAL_LOGIN),
|
||||||
emailEnabled:
|
emailEnabled:
|
||||||
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
|
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
|
||||||
|
|
@ -76,6 +77,10 @@ router.get('/', async function (req, res) {
|
||||||
instanceProjectId: instanceProject._id.toString(),
|
instanceProjectId: instanceProject._id.toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (ldap) {
|
||||||
|
payload.ldap = ldap;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof process.env.CUSTOM_FOOTER === 'string') {
|
if (typeof process.env.CUSTOM_FOOTER === 'string') {
|
||||||
payload.customFooter = process.env.CUSTOM_FOOTER;
|
payload.customFooter = process.env.CUSTOM_FOOTER;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
api/server/services/Config/ldap.js
Normal file
22
api/server/services/Config/ldap.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
const { isEnabled } = require('~/server/utils');
|
||||||
|
|
||||||
|
/** @returns {TStartupConfig['ldap'] | undefined} */
|
||||||
|
const getLdapConfig = () => {
|
||||||
|
const ldapLoginEnabled = !!process.env.LDAP_URL && !!process.env.LDAP_USER_SEARCH_BASE;
|
||||||
|
|
||||||
|
const ldap = {
|
||||||
|
enabled: ldapLoginEnabled,
|
||||||
|
};
|
||||||
|
const ldapLoginUsesUsername = isEnabled(process.env.LDAP_LOGIN_USES_USERNAME);
|
||||||
|
if (!ldapLoginEnabled) {
|
||||||
|
return ldap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ldapLoginUsesUsername) {
|
||||||
|
ldap.username = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getLdapConfig,
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
|
||||||
import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
|
import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
|
||||||
import type { TAuthContext } from '~/common';
|
import type { TAuthContext } from '~/common';
|
||||||
import { useResendVerificationEmail } from '~/data-provider';
|
import { useResendVerificationEmail } from '~/data-provider';
|
||||||
|
|
@ -22,6 +23,9 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
||||||
} = useForm<TLoginUser>();
|
} = useForm<TLoginUser>();
|
||||||
const [showResendLink, setShowResendLink] = useState<boolean>(false);
|
const [showResendLink, setShowResendLink] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const { data: config } = useGetStartupConfig();
|
||||||
|
const useUsernameLogin = config?.ldap?.username;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error && error.includes('422') && !showResendLink) {
|
if (error && error.includes('422') && !showResendLink) {
|
||||||
setShowResendLink(true);
|
setShowResendLink(true);
|
||||||
|
|
@ -82,12 +86,15 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="email"
|
id="email"
|
||||||
autoComplete="email"
|
autoComplete={useUsernameLogin ? 'username' : 'email'}
|
||||||
aria-label={localize('com_auth_email')}
|
aria-label={localize('com_auth_email')}
|
||||||
{...register('email', {
|
{...register('email', {
|
||||||
required: localize('com_auth_email_required'),
|
required: localize('com_auth_email_required'),
|
||||||
maxLength: { value: 120, message: localize('com_auth_email_max_length') },
|
maxLength: { value: 120, message: localize('com_auth_email_max_length') },
|
||||||
pattern: { value: /\S+@\S+\.\S+/, message: localize('com_auth_email_pattern') },
|
pattern: {
|
||||||
|
value: useUsernameLogin ? /\S+/ : /\S+@\S+\.\S+/,
|
||||||
|
message: localize('com_auth_email_pattern'),
|
||||||
|
},
|
||||||
})}
|
})}
|
||||||
aria-invalid={!!errors.email}
|
aria-invalid={!!errors.email}
|
||||||
className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500"
|
className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500"
|
||||||
|
|
@ -97,7 +104,9 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
||||||
htmlFor="email"
|
htmlFor="email"
|
||||||
className="absolute start-1 top-2 z-10 origin-[0] -translate-y-4 scale-75 transform bg-white px-3 text-sm text-gray-500 duration-100 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-2 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-3 peer-focus:text-green-600 dark:bg-gray-900 dark:text-gray-400 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
|
className="absolute start-1 top-2 z-10 origin-[0] -translate-y-4 scale-75 transform bg-white px-3 text-sm text-gray-500 duration-100 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-2 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-3 peer-focus:text-green-600 dark:bg-gray-900 dark:text-gray-400 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
|
||||||
>
|
>
|
||||||
{localize('com_auth_email_address')}
|
{useUsernameLogin
|
||||||
|
? localize('com_auth_username').replace(/ \(.*$/, '')
|
||||||
|
: localize('com_auth_email_address')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{renderError('email')}
|
{renderError('email')}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ const mockStartupConfig = {
|
||||||
openidLoginEnabled: true,
|
openidLoginEnabled: true,
|
||||||
openidLabel: 'Test OpenID',
|
openidLabel: 'Test OpenID',
|
||||||
openidImageUrl: 'http://test-server.com',
|
openidImageUrl: 'http://test-server.com',
|
||||||
ldapLoginEnabled: false,
|
ldap: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
registrationEnabled: true,
|
registrationEnabled: true,
|
||||||
emailLoginEnabled: true,
|
emailLoginEnabled: true,
|
||||||
socialLoginEnabled: true,
|
socialLoginEnabled: true,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ const mockStartupConfig: TStartupConfig = {
|
||||||
passwordResetEnabled: true,
|
passwordResetEnabled: true,
|
||||||
serverDomain: 'mock-server',
|
serverDomain: 'mock-server',
|
||||||
appTitle: '',
|
appTitle: '',
|
||||||
ldapLoginEnabled: false,
|
ldap: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
emailEnabled: false,
|
emailEnabled: false,
|
||||||
checkBalance: false,
|
checkBalance: false,
|
||||||
showBirthdayIcon: false,
|
showBirthdayIcon: false,
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,13 @@ export type TStartupConfig = {
|
||||||
openidLoginEnabled: boolean;
|
openidLoginEnabled: boolean;
|
||||||
openidLabel: string;
|
openidLabel: string;
|
||||||
openidImageUrl: string;
|
openidImageUrl: string;
|
||||||
ldapLoginEnabled: boolean;
|
/** LDAP Auth Configuration */
|
||||||
|
ldap?: {
|
||||||
|
/** LDAP enabled */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Whether LDAP uses username vs. email */
|
||||||
|
username?: boolean;
|
||||||
|
};
|
||||||
serverDomain: string;
|
serverDomain: string;
|
||||||
emailLoginEnabled: boolean;
|
emailLoginEnabled: boolean;
|
||||||
registrationEnabled: boolean;
|
registrationEnabled: boolean;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue