Skip to content

Commit f7b9d0f

Browse files
committed
feat(backend:auth:ldap): add service bind support, adminGroup DN/CN handling, optimized search flow, tests, and updated docs
1 parent bf9759d commit f7b9d0f

File tree

5 files changed

+304
-51
lines changed

5 files changed

+304
-51
lines changed

backend/src/authentication/providers/ldap/auth-ldap.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ export class AuthProviderLDAPConfig {
7676
@IsString()
7777
netbiosName?: string
7878

79+
@IsOptional()
80+
@IsString()
81+
serviceBindDN?: string
82+
83+
@IsOptional()
84+
@IsString()
85+
serviceBindPassword?: string
86+
7987
@IsDefined()
8088
@IsNotEmptyObject()
8189
@IsObject()

backend/src/authentication/providers/ldap/auth-ldap.constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,12 @@ export const LDAP_COMMON_ATTR = {
1515
MEMBER_OF: 'memberOf'
1616
} as const
1717

18+
export const LDAP_SEARCH_ATTR = {
19+
BASE: 'base',
20+
SUB: 'sub',
21+
GROUP_OF_NAMES: 'groupOfNames',
22+
OBJECT_CLASS: 'objectClass',
23+
MEMBER: 'member'
24+
} as const
25+
1826
export const ALL_LDAP_ATTRIBUTES = [...Object.values(LDAP_LOGIN_ATTR), ...Object.values(LDAP_COMMON_ATTR)]

backend/src/authentication/providers/ldap/auth-provider-ldap.service.spec.ts

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ describe(AuthProviderLDAP.name, () => {
7676
configuration.auth.ldap = next
7777
;(authProviderLDAP as any).ldapConfig = next
7878
;(authProviderLDAP as any).isAD = [LDAP_LOGIN_ATTR.SAM, LDAP_LOGIN_ATTR.UPN].includes(next.attributes.login)
79+
;(authProviderLDAP as any).hasServiceBind = Boolean(next.serviceBindDN && next.serviceBindPassword)
7980
}
8081

8182
const mockBindResolve = () => {
@@ -306,6 +307,120 @@ describe(AuthProviderLDAP.name, () => {
306307
expect(usersManager.updateAccesses).toHaveBeenCalledWith(createdUser, '192.168.1.10', true)
307308
})
308309

310+
it('should accept adminGroup as full DN', async () => {
311+
setLdapConfig({
312+
options: {
313+
adminGroup: 'CN=Admins,OU=Groups,DC=example,DC=org'
314+
}
315+
})
316+
usersManager.findUser.mockResolvedValue(null)
317+
mockBindResolve()
318+
mockSearchEntries([
319+
{
320+
uid: 'john',
321+
givenName: 'John',
322+
sn: 'Doe',
323+
mail: 'john@example.org',
324+
memberOf: ['CN=Admins,OU=Groups,DC=example,DC=org']
325+
}
326+
])
327+
const createdUser: any = { id: 9, login: 'john', isGuest: false, isActive: true, makePaths: jest.fn() }
328+
adminUsersManager.createUserOrGuest.mockResolvedValue(createdUser)
329+
usersManager.fromUserId.mockResolvedValue(createdUser)
330+
331+
const res = await authProviderLDAP.validateUser('john', 'pwd')
332+
333+
expect(adminUsersManager.createUserOrGuest).toHaveBeenCalledWith(
334+
expect.objectContaining({ role: USER_ROLE.ADMINISTRATOR }),
335+
USER_ROLE.ADMINISTRATOR
336+
)
337+
expect(res).toBe(createdUser)
338+
})
339+
340+
it('should use groupOfNames to detect admin membership when memberOf is missing', async () => {
341+
setLdapConfig({ options: { adminGroup: 'Admins' } })
342+
usersManager.findUser.mockResolvedValue(null)
343+
mockBindResolve()
344+
ldapClient.search
345+
.mockResolvedValueOnce({
346+
searchEntries: [
347+
{
348+
uid: 'john',
349+
cn: 'John Doe',
350+
mail: 'john@example.org',
351+
dn: 'uid=john,ou=people,dc=example,dc=org'
352+
}
353+
]
354+
})
355+
.mockResolvedValueOnce({ searchEntries: [{ cn: 'Admins' }] })
356+
const createdUser: any = { id: 3, login: 'john', isGuest: false, isActive: true, makePaths: jest.fn() }
357+
adminUsersManager.createUserOrGuest.mockResolvedValue(createdUser)
358+
usersManager.fromUserId.mockResolvedValue(createdUser)
359+
360+
const res = await authProviderLDAP.validateUser('john', 'pwd')
361+
362+
expect(adminUsersManager.createUserOrGuest).toHaveBeenCalledWith(
363+
expect.objectContaining({ role: USER_ROLE.ADMINISTRATOR }),
364+
USER_ROLE.ADMINISTRATOR
365+
)
366+
expect(res).toBe(createdUser)
367+
})
368+
369+
it('should use service bind for LDAP searches when configured', async () => {
370+
setLdapConfig({
371+
serviceBindDN: 'cn=svc,dc=example,dc=org',
372+
serviceBindPassword: 'secret'
373+
})
374+
usersManager.findUser.mockResolvedValue(null)
375+
mockBindResolve()
376+
ldapClient.search.mockResolvedValueOnce({
377+
searchEntries: [{ uid: 'john', cn: 'John Doe', mail: 'john@example.org', dn: 'uid=john,ou=people,dc=example,dc=org' }]
378+
})
379+
const createdUser: any = { id: 8, login: 'john', isGuest: false, isActive: true, makePaths: jest.fn() }
380+
adminUsersManager.createUserOrGuest.mockResolvedValue(createdUser)
381+
usersManager.fromUserId.mockResolvedValue(createdUser)
382+
383+
await authProviderLDAP.validateUser('john', 'pwd')
384+
385+
expect(ldapClient.bind).toHaveBeenCalledWith('cn=svc,dc=example,dc=org', 'secret')
386+
expect(ldapClient.bind).toHaveBeenCalledWith('uid=john,ou=people,dc=example,dc=org', 'pwd')
387+
})
388+
389+
it('should return null when service bind is set but user DN is not found', async () => {
390+
setLdapConfig({
391+
serviceBindDN: 'cn=svc,dc=example,dc=org',
392+
serviceBindPassword: 'secret'
393+
})
394+
usersManager.findUser.mockResolvedValue(null)
395+
mockBindResolve()
396+
ldapClient.search.mockResolvedValueOnce({ searchEntries: [] })
397+
398+
const res = await authProviderLDAP.validateUser('john', 'pwd')
399+
400+
expect(res).toBeNull()
401+
expect(ldapClient.bind).toHaveBeenCalledWith('cn=svc,dc=example,dc=org', 'secret')
402+
expect(ldapClient.bind).not.toHaveBeenCalledWith('uid=john,ou=people,dc=example,dc=org', 'pwd')
403+
})
404+
405+
it('should return null when user bind fails after service bind', async () => {
406+
setLdapConfig({
407+
serviceBindDN: 'cn=svc,dc=example,dc=org',
408+
serviceBindPassword: 'secret'
409+
})
410+
usersManager.findUser.mockResolvedValue(null)
411+
ldapClient.unbind.mockResolvedValue(undefined)
412+
ldapClient.bind.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new InvalidCredentialsError('invalid credentials'))
413+
ldapClient.search.mockResolvedValueOnce({
414+
searchEntries: [{ dn: 'uid=john,ou=people,dc=example,dc=org', cn: 'John Doe' }]
415+
})
416+
417+
const res = await authProviderLDAP.validateUser('john', 'pwd')
418+
419+
expect(res).toBeNull()
420+
expect(ldapClient.bind).toHaveBeenCalledWith('cn=svc,dc=example,dc=org', 'secret')
421+
expect(ldapClient.bind).toHaveBeenCalledWith('uid=john,ou=people,dc=example,dc=org', 'pwd')
422+
})
423+
309424
it('should keep admin role when adminGroup is not configured', async () => {
310425
setLdapConfig({ options: { adminGroup: undefined } })
311426
const existingUser: any = buildUser({ id: 5, role: USER_ROLE.ADMINISTRATOR })
@@ -388,7 +503,7 @@ describe(AuthProviderLDAP.name, () => {
388503

389504
expect(normalized.uid).toBe('john')
390505
expect(normalized.mail).toBe('john@example.org')
391-
expect(normalized.memberOf).toEqual(['Admins', 'Staff'])
506+
expect(normalized.memberOf).toEqual(['CN=Admins,OU=Groups,DC=example,DC=org', 'Admins', 'CN=Staff,OU=Groups,DC=example,DC=org', 'Staff'])
392507
})
393508

394509
it('should build LDAP logins for SAM account name when netbiosName is set', () => {

0 commit comments

Comments
 (0)