Skip to content

Commit db1a9e3

Browse files
committed
fix(backend:auth): improve AD/LDAP authentication handling and normalization
1 parent 0e35a0c commit db1a9e3

File tree

5 files changed

+172
-163
lines changed

5 files changed

+172
-163
lines changed

backend/src/authentication/auth.config.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
IsArray,
1111
IsBoolean,
1212
IsDefined,
13+
IsEnum,
1314
IsIn,
1415
IsNotEmpty,
1516
IsNotEmptyObject,
@@ -21,6 +22,7 @@ import {
2122
} from 'class-validator'
2223
import { SERVER_NAME } from '../app.constants'
2324
import { ACCESS_KEY, CSRF_KEY, REFRESH_KEY, WS_KEY } from './constants/auth'
25+
import { LDAP_COMMON_ATTR, LDAP_LOGIN_ATTR } from './constants/auth-ldap'
2426

2527
export class AuthMfaTotpConfig {
2628
@IsBoolean()
@@ -112,13 +114,14 @@ export class AuthTokenConfig {
112114
export class AuthMethodLdapAttributesConfig {
113115
@IsOptional()
114116
@IsString()
115-
@Transform(({ value }) => value || 'uid')
116-
login? = 'uid'
117+
@Transform(({ value }) => value || LDAP_LOGIN_ATTR.UID)
118+
@IsEnum(LDAP_LOGIN_ATTR)
119+
login: LDAP_LOGIN_ATTR = LDAP_LOGIN_ATTR.UID
117120

118121
@IsOptional()
119122
@IsString()
120-
@Transform(({ value }) => value || 'mail')
121-
email? = 'mail'
123+
@Transform(({ value }) => value || LDAP_COMMON_ATTR.MAIL)
124+
email: string = LDAP_COMMON_ATTR.MAIL
122125
}
123126

124127
export class AuthMethodLdapConfig {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright (C) 2012-2025 Johan Legrand <johan.legrand@sync-in.com>
3+
* This file is part of Sync-in | The open source file sync and share solution
4+
* See the LICENSE file for licensing details
5+
*/
6+
7+
export enum LDAP_LOGIN_ATTR {
8+
UID = 'uid',
9+
SAM = 'sAMAccountName',
10+
UPN = 'userPrincipalName'
11+
}
12+
13+
export const LDAP_COMMON_ATTR = {
14+
MAIL: 'mail',
15+
GIVEN_NAME: 'givenName',
16+
SN: 'sn',
17+
CN: 'cn',
18+
DISPLAY_NAME: 'displayName'
19+
} as const
20+
21+
export const ALL_LDAP_ATTRIBUTES = [...Object.values(LDAP_LOGIN_ATTR), ...Object.values(LDAP_COMMON_ATTR)]

backend/src/authentication/services/auth-methods/auth-method-ldap.service.spec.ts

Lines changed: 41 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,20 @@ import { AdminUsersManager } from '../../../applications/users/services/admin-us
1313
import { UsersManager } from '../../../applications/users/services/users-manager.service'
1414
import * as commonFunctions from '../../../common/functions'
1515
import { configuration } from '../../../configuration/config.environment'
16+
import { LDAP_LOGIN_ATTR } from '../../constants/auth-ldap'
1617
import { AuthMethodLdapService } from './auth-method-ldap.service'
1718

1819
// Mock ldapts Client to simulate LDAP behaviors
1920
jest.mock('ldapts', () => {
20-
class InvalidCredentialsError extends Error {}
21+
const actual = jest.requireActual('ldapts')
2122
const mockClientInstance = {
2223
bind: jest.fn(),
2324
search: jest.fn(),
2425
unbind: jest.fn()
2526
}
2627
const Client = jest.fn().mockImplementation(() => mockClientInstance)
27-
return { Client, InvalidCredentialsError }
28+
// Conserver tous les autres exports réels (dont EqualityFilter, AndFilter, InvalidCredentialsError, etc.)
29+
return { ...actual, Client }
2830
})
2931

3032
// --- Test helpers (DRY) ---
@@ -53,6 +55,7 @@ const buildUser = (overrides: Partial<UserModel> = {}) =>
5355
isGuest: false,
5456
isActive: true,
5557
makePaths: jest.fn().mockResolvedValue(undefined),
58+
setFullName: jest.fn(), // needed when firstName/lastName change
5659
...overrides
5760
}) as any
5861

@@ -77,6 +80,13 @@ describe(AuthMethodLdapService.name, () => {
7780
const spyLoggerError = () => jest.spyOn(authMethodLdapService['logger'], 'error').mockImplementation(() => undefined as any)
7881

7982
beforeAll(async () => {
83+
configuration.auth.ldap = {
84+
servers: ['ldap://localhost:389'],
85+
attributes: { login: LDAP_LOGIN_ATTR.UID, email: 'mail' },
86+
baseDN: 'ou=people,dc=example,dc=org',
87+
filter: ''
88+
}
89+
8090
const module: TestingModule = await Test.createTestingModule({
8191
providers: [
8292
AuthMethodLdapService,
@@ -85,7 +95,9 @@ describe(AuthMethodLdapService.name, () => {
8595
useValue: {
8696
findUser: jest.fn(),
8797
logUser: jest.fn(),
88-
updateAccesses: jest.fn().mockResolvedValue(undefined)
98+
updateAccesses: jest.fn().mockResolvedValue(undefined),
99+
validateAppPassword: jest.fn(),
100+
fromUserId: jest.fn()
89101
}
90102
},
91103
{
@@ -102,12 +114,6 @@ describe(AuthMethodLdapService.name, () => {
102114
authMethodLdapService = module.get<AuthMethodLdapService>(AuthMethodLdapService)
103115
adminUsersManager = module.get<Mocked<AdminUsersManager>>(AdminUsersManager)
104116
usersManager = module.get<Mocked<UsersManager>>(UsersManager)
105-
configuration.auth.ldap = {
106-
servers: ['ldap://localhost:389'],
107-
attributes: { login: 'uid', email: 'mail' },
108-
baseDN: 'ou=people,dc=example,dc=org',
109-
filter: ''
110-
}
111117
})
112118

113119
it('should be defined', () => {
@@ -123,29 +129,25 @@ describe(AuthMethodLdapService.name, () => {
123129
usersManager.findUser.mockResolvedValue(guestUser)
124130
const dbAuthResult: any = { ...guestUser, token: 'jwt' }
125131
usersManager.logUser.mockResolvedValue(dbAuthResult)
126-
127132
const res = await authMethodLdapService.validateUser('guest1', 'pass', '127.0.0.1')
128-
129133
expect(res).toEqual(dbAuthResult)
130134
expect(usersManager.logUser).toHaveBeenCalledWith(guestUser, 'pass', '127.0.0.1')
131135
expect(Client).not.toHaveBeenCalled() // client should not be constructed
132136
})
133137

134-
it('should throw FORBIDDEN for locked account and LDAP login mismatch', async () => {
138+
it('should throw FORBIDDEN for locked account and resolve null for LDAP login mismatch', async () => {
135139
// Phase 1: locked account
136140
usersManager.findUser.mockResolvedValue({ login: 'john', isGuest: false, isActive: false } as UserModel)
137141
const loggerErrorSpy1 = jest.spyOn(authMethodLdapService['logger'], 'error').mockImplementation(() => undefined as any)
138-
139142
await expect(authMethodLdapService.validateUser('john', 'pwd')).rejects.toThrow(/account locked/i)
140143
expect(loggerErrorSpy1).toHaveBeenCalled()
141144

142-
// Phase 2: mismatch between requested login and LDAP returned login
145+
// Phase 2: mismatch between requested login and LDAP returned login -> service renvoie null
143146
const existingUser: any = buildUser({ id: 8 })
144147
usersManager.findUser.mockResolvedValue(existingUser)
145148
mockBindResolve(ldapClient)
146149
mockSearchEntries(ldapClient, [{ uid: 'jane', cn: 'john', mail: 'jane@example.org' }])
147-
148-
await expect(authMethodLdapService.validateUser('john', 'pwd')).resolves.toEqual(null)
150+
await expect(authMethodLdapService.validateUser('john', 'pwd')).rejects.toThrow(/account matching error/i)
149151
})
150152

151153
it('should handle invalid LDAP credentials for both existing and unknown users', async () => {
@@ -157,9 +159,7 @@ describe(AuthMethodLdapService.name, () => {
157159
// Force updateAccesses to reject to hit the catch and logger.error
158160
const loggerErrorSpy = jest.spyOn(authMethodLdapService['logger'], 'error').mockImplementation(() => undefined as any)
159161
usersManager.updateAccesses.mockRejectedValueOnce(new Error('updateAccesses boom'))
160-
161162
const res1 = await authMethodLdapService.validateUser('john', 'badpwd', '10.0.0.1')
162-
163163
expect(res1).toBeNull()
164164
expect(usersManager.updateAccesses).toHaveBeenCalledWith(existingUser, '10.0.0.1', false)
165165
expect(loggerErrorSpy).toHaveBeenCalled()
@@ -169,9 +169,7 @@ describe(AuthMethodLdapService.name, () => {
169169
usersManager.findUser.mockResolvedValue(null)
170170
ldapClient.bind.mockRejectedValue(new InvalidCredentialsError('invalid'))
171171
ldapClient.unbind.mockResolvedValue(undefined)
172-
173172
const res2 = await authMethodLdapService.validateUser('jane', 'badpwd')
174-
175173
expect(res2).toBeNull()
176174
expect(usersManager.updateAccesses).not.toHaveBeenCalled()
177175
})
@@ -183,42 +181,40 @@ describe(AuthMethodLdapService.name, () => {
183181
// Simulate an entry with missing mail
184182
mockSearchEntries(ldapClient, [{ uid: 'jane', cn: 'Jane Doe', mail: undefined }])
185183
const loggerErrorSpy = jest.spyOn(authMethodLdapService['logger'], 'error').mockImplementation(() => undefined as any)
186-
187184
const resA = await authMethodLdapService.validateUser('jane', 'pwd')
188-
189185
expect(resA).toBeNull()
190186
expect(adminUsersManager.createUserOrGuest).not.toHaveBeenCalled()
191187
expect(loggerErrorSpy).toHaveBeenCalled()
192188

193189
// Phase 2: create a new user (success, single email)
190+
// Stub directement checkAuth pour retourner une entrée LDAP valide
191+
const checkAuthSpy = jest.spyOn<any, any>(authMethodLdapService as any, 'checkAuth')
192+
checkAuthSpy.mockResolvedValueOnce({ uid: 'john', cn: 'John Doe', mail: 'john@example.org' } as any)
193+
adminUsersManager.createUserOrGuest.mockClear()
194194
usersManager.findUser.mockResolvedValue(null)
195-
setupLdapSuccess([{ uid: 'john', cn: 'John Doe', mail: 'john@example.org' }])
196-
197195
const createdUser: any = { id: 2, login: 'john', isGuest: false, isActive: true, makePaths: jest.fn() }
198196
adminUsersManager.createUserOrGuest.mockResolvedValue(createdUser)
197+
// If the service reloads the user via fromUserId after creation
198+
usersManager.fromUserId.mockResolvedValue(createdUser)
199199
// Cover the success-flow catch branch
200200
const loggerErrorSpy2 = spyLoggerError()
201201
usersManager.updateAccesses.mockRejectedValueOnce(new Error('updateAccesses success flow boom'))
202-
203202
const resB = await authMethodLdapService.validateUser('john', 'pwd', '192.168.1.10')
204-
205203
expect(adminUsersManager.createUserOrGuest).toHaveBeenCalledWith(
206204
{ login: 'john', email: 'john@example.org', password: 'pwd', firstName: 'John', lastName: 'Doe' },
207205
expect.anything() // USER_ROLE.USER
208206
)
209207
expect(resB).toBe(createdUser)
210208
expect(usersManager.updateAccesses).toHaveBeenCalledWith(createdUser, '192.168.1.10', true)
211209
expect(loggerErrorSpy2).toHaveBeenCalled()
212-
213210
// Phase 3: multiple emails -> keep the first
211+
adminUsersManager.createUserOrGuest.mockClear()
214212
usersManager.findUser.mockResolvedValue(null)
215213
setupLdapSuccess([{ uid: 'multi', cn: 'Multi Mail', mail: ['first@example.org', 'second@example.org'] }])
216-
217214
const createdUser2: any = { id: 9, login: 'multi', makePaths: jest.fn() }
218215
adminUsersManager.createUserOrGuest.mockResolvedValue(createdUser2)
219-
216+
usersManager.fromUserId.mockResolvedValue(createdUser2)
220217
const resC = await authMethodLdapService.validateUser('multi', 'pwd')
221-
222218
expect(adminUsersManager.createUserOrGuest).toHaveBeenCalledWith(expect.objectContaining({ email: 'first@example.org' }), expect.anything())
223219
expect(resC).toBe(createdUser2)
224220
})
@@ -227,19 +223,14 @@ describe(AuthMethodLdapService.name, () => {
227223
// Arrange: existing user with different profile and an old password
228224
const existingUser: any = buildUser({ id: 5 })
229225
usersManager.findUser.mockResolvedValue(existingUser)
230-
231226
// LDAP succeeds and returns different email and same uid
232227
setupLdapSuccess([{ uid: 'john', cn: 'John Doe', mail: 'john@example.org' }])
233-
234228
// Admin manager successfully updates a user
235229
adminUsersManager.updateUserOrGuest.mockResolvedValue(undefined)
236-
237230
// Ensure password is considered changed so the update payload includes it,
238231
// which then triggers the deletion and local assignment branches after update
239232
const compareSpy = jest.spyOn(commonFunctions, 'comparePassword').mockResolvedValue(false)
240-
241233
const res = await authMethodLdapService.validateUser('john', 'new-plain-password', '127.0.0.2')
242-
243234
expect(adminUsersManager.updateUserOrGuest).toHaveBeenCalledWith(
244235
5,
245236
expect.objectContaining({
@@ -264,9 +255,7 @@ describe(AuthMethodLdapService.name, () => {
264255
// Force another non-password change so an update occurs
265256
existingUser.email = 'old@example.org'
266257
compareSpy.mockResolvedValue(true)
267-
268258
const res2 = await authMethodLdapService.validateUser('john', 'same-plain-password', '127.0.0.3')
269-
270259
// Update should be called without password, only with changed fields
271260
expect(adminUsersManager.updateUserOrGuest).toHaveBeenCalled()
272261
const updateArgs = adminUsersManager.updateUserOrGuest.mock.calls[0]
@@ -277,22 +266,18 @@ describe(AuthMethodLdapService.name, () => {
277266
})
278267
)
279268
expect(updateArgs[1]).toEqual(expect.not.objectContaining({ password: expect.anything() }))
280-
281269
// Password remains unchanged locally
282270
expect(existingUser.password).toBe('hashed')
283271
// Accesses updated as success
284272
expect(usersManager.updateAccesses).toHaveBeenCalledWith(existingUser, '127.0.0.3', true)
285273
// Returned user is the same instance
286274
expect(res2).toBe(existingUser)
287-
288275
// Third run: no changes at all (identityHasChanged is empty) to cover the else branch
289276
adminUsersManager.updateUserOrGuest.mockClear()
290277
usersManager.updateAccesses.mockClear()
291278
compareSpy.mockResolvedValue(true)
292-
293279
// Local user already matches LDAP identity; call again
294280
const res3 = await authMethodLdapService.validateUser('john', 'same-plain-password', '127.0.0.4')
295-
296281
// No update should be triggered
297282
expect(adminUsersManager.updateUserOrGuest).not.toHaveBeenCalled()
298283
// Access should still be updated as success
@@ -306,9 +291,7 @@ describe(AuthMethodLdapService.name, () => {
306291
const existingUser: any = { id: 7, login: 'ghost', isGuest: false, isActive: true }
307292
usersManager.findUser.mockResolvedValue(existingUser)
308293
setupLdapSuccess([])
309-
310294
const resA = await authMethodLdapService.validateUser('ghost', 'pwd', '10.10.0.1')
311-
312295
expect(resA).toBeNull()
313296
expect(usersManager.updateAccesses).toHaveBeenCalledWith(existingUser, '10.10.0.1', false)
314297

@@ -318,13 +301,24 @@ describe(AuthMethodLdapService.name, () => {
318301
usersManager.findUser.mockResolvedValue(existingUser2)
319302
mockBindResolve(ldapClient)
320303
mockSearchReject(ldapClient, new Error('search failed'))
321-
322304
const resB = await authMethodLdapService.validateUser('john', 'pwd', '1.1.1.1')
323-
324305
expect(resB).toBeNull()
325306
expect(usersManager.updateAccesses).toHaveBeenCalledWith(existingUser2, '1.1.1.1', false)
326307
})
327308

309+
it('should allow app password when LDAP fails and scope is provided', async () => {
310+
const existingUser: any = buildUser({ id: 42 })
311+
usersManager.findUser.mockResolvedValue(existingUser)
312+
// LDAP invalid credentials
313+
mockBindRejectInvalid(ldapClient, InvalidCredentialsError, 'invalid credentials')
314+
// App password success
315+
usersManager.validateAppPassword.mockResolvedValue(true)
316+
const res = await authMethodLdapService.validateUser('john', 'app-password', '10.0.0.2', 'webdav' as any)
317+
expect(res).toBe(existingUser)
318+
expect(usersManager.validateAppPassword).toHaveBeenCalledWith(existingUser, 'app-password', '10.0.0.2', 'webdav')
319+
expect(usersManager.updateAccesses).toHaveBeenCalledWith(existingUser, '10.0.0.2', true)
320+
})
321+
328322
it('should throw 500 when LDAP connection error occurs during bind', async () => {
329323
// Arrange: no existing user to reach checkAuth flow
330324
usersManager.findUser.mockResolvedValue(null)
@@ -349,26 +343,18 @@ describe(AuthMethodLdapService.name, () => {
349343
expect(usersManager.updateAccesses).not.toHaveBeenCalled()
350344
})
351345

352-
it('should log update failure and still call makePaths when updating existing user', async () => {
346+
it('should log update failure when updating existing user', async () => {
353347
// Arrange: existing user with changed identity
354348
const existingUser: any = buildUser({ id: 11, email: 'old@ex.org' })
355349
usersManager.findUser.mockResolvedValue(existingUser)
356-
357350
// Ensure LDAP loginAttribute matches uid for this test (a previous test sets it to 'cn')
358-
configuration.auth.ldap.attributes.login = 'uid'
359-
360351
setupLdapSuccess([{ uid: 'john', cn: 'John Doe', mail: 'john@example.org' }])
361352
adminUsersManager.updateUserOrGuest.mockRejectedValue(new Error('db error'))
362-
363353
// Force identity to be considered changed only for this test
364354
jest.spyOn(commonFunctions, 'comparePassword').mockResolvedValue(false)
365355
jest.spyOn(commonFunctions, 'splitFullName').mockReturnValue({ firstName: 'John', lastName: 'Doe' })
366-
367356
const res = await authMethodLdapService.validateUser('john', 'pwd')
368-
369357
expect(adminUsersManager.updateUserOrGuest).toHaveBeenCalled()
370-
// makePaths still invoked
371-
expect(existingUser.makePaths).toHaveBeenCalled()
372358
// Local fields unchanged since update failed
373359
expect(existingUser.email).toBe('old@ex.org')
374360
expect(res).toBe(existingUser)
@@ -378,40 +364,30 @@ describe(AuthMethodLdapService.name, () => {
378364
// Phase A: LDAP returns an entry but loginAttribute value does not match -> checkAccess returns false (covers return after loop)
379365
const userA: any = { id: 20, login: 'john', isGuest: false, isActive: true }
380366
usersManager.findUser.mockResolvedValue(userA)
381-
configuration.auth.ldap.attributes.login = 'uid'
382367
ldapClient.bind.mockResolvedValue(undefined)
383-
// Non-matching entry: uid !== requested uid
384-
ldapClient.search.mockResolvedValue({ searchEntries: [{ uid: 'jane', cn: 'Jane Doe', mail: 'jane@example.org' }] })
385-
ldapClient.unbind.mockResolvedValue(undefined)
386-
387-
const resA = await authMethodLdapService.validateUser('john', 'pwd', '3.3.3.3')
388-
expect(resA).toBeNull()
389-
expect(usersManager.updateAccesses).toHaveBeenCalledWith(userA, '3.3.3.3', false)
390368

391369
// Phase B: Matching entry + password considered changed -> updateUserOrGuest called, password not reassigned locally
392370
jest.clearAllMocks()
393371
const userB: any = buildUser({ id: 21, email: 'old@ex.org' })
394372
usersManager.findUser.mockResolvedValue(userB)
395-
configuration.auth.ldap.attributes.login = 'uid'
396373
setupLdapSuccess([{ uid: 'john', cn: 'John Doe', mail: 'john@example.org' }])
397374
adminUsersManager.updateUserOrGuest.mockResolvedValue(undefined)
398375

399376
// Force password to be considered changed to execute deletion + Object.assign branch
400377
jest.spyOn(commonFunctions, 'comparePassword').mockResolvedValue(false)
401378
jest.spyOn(commonFunctions, 'splitFullName').mockReturnValue({ firstName: 'John', lastName: 'Doe' })
402-
403379
const resB = await authMethodLdapService.validateUser('john', 'newpwd', '4.4.4.4')
404380

405381
// Line 132: updateUserOrGuest call
406382
expect(adminUsersManager.updateUserOrGuest).toHaveBeenCalledWith(
407383
21,
408384
expect.objectContaining({ email: 'john@example.org', firstName: 'John', lastName: 'Doe' })
409385
)
386+
410387
// Lines 139-142: password removed from local assign, other fields assigned
411388
expect(userB.password).toBe('hashed')
412389
expect(userB.email).toBe('john@example.org')
413390
expect(userB).toMatchObject({ firstName: 'John', lastName: 'Doe' })
414-
expect(userB.makePaths).toHaveBeenCalled()
415391
expect(resB).toBe(userB)
416392
})
417393
})

0 commit comments

Comments
 (0)