Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/src/authentication/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { AuthTokenRefreshGuard } from './guards/auth-token-refresh.guard'
import { AuthTokenRefreshStrategy } from './guards/auth-token-refresh.strategy'
import { AUTH_PROVIDER } from './providers/auth-providers.constants'
import { AuthProvider } from './providers/auth-providers.models'
import { selectAuthProvider } from './providers/auth-providers.utils'
import { selectAuthProvider } from './providers/auth-providers'
import { AuthProviderOIDCModule } from './providers/oidc/auth-provider-oidc.module'
import { AuthProvider2FA } from './providers/two-fa/auth-provider-two-fa.service'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const DEFAULT_STORAGE_QUOTA_FIELD = 'storageQuota'
export enum AUTH_PROVIDER {
MYSQL = 'mysql',
LDAP = 'ldap',
Expand Down
23 changes: 23 additions & 0 deletions backend/src/authentication/providers/auth-providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Provider } from '@nestjs/common'
import { AUTH_PROVIDER } from './auth-providers.constants'
import { AuthProvider } from './auth-providers.models'
import { AuthProviderLDAP } from './ldap/auth-provider-ldap.service'
import { AuthProviderMySQL } from './mysql/auth-provider-mysql.service'
import { AuthProviderOIDC } from './oidc/auth-provider-oidc.service'

export function selectAuthProvider(provider: AUTH_PROVIDER): Provider {
switch (provider) {
case AUTH_PROVIDER.OIDC:
// `AuthProviderOIDC` is already provided by `AuthProviderOIDCModule`
return { provide: AuthProvider, useExisting: AuthProviderOIDC }

case AUTH_PROVIDER.LDAP:
return { provide: AuthProvider, useClass: AuthProviderLDAP }

case AUTH_PROVIDER.MYSQL:
return { provide: AuthProvider, useClass: AuthProviderMySQL }

default:
return { provide: AuthProvider, useClass: AuthProviderMySQL }
}
}
38 changes: 21 additions & 17 deletions backend/src/authentication/providers/auth-providers.utils.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import { Provider } from '@nestjs/common'
import { AUTH_PROVIDER } from './auth-providers.constants'
import { DEFAULT_STORAGE_QUOTA_FIELD } from './auth-providers.constants'

import { AuthProvider } from './auth-providers.models'
import { AuthProviderLDAP } from './ldap/auth-provider-ldap.service'
import { AuthProviderMySQL } from './mysql/auth-provider-mysql.service'
import { AuthProviderOIDC } from './oidc/auth-provider-oidc.service'
interface IdentityWithStorageQuota {
storageQuota?: number | null
}

export function selectAuthProvider(provider: AUTH_PROVIDER): Provider {
switch (provider) {
case AUTH_PROVIDER.OIDC:
// `AuthProviderOIDC` is already provided by `AuthProviderOIDCModule`
return { provide: AuthProvider, useExisting: AuthProviderOIDC }
function parseStorageQuotaInBytes(value: unknown): number | undefined {
const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value.trim()) : NaN

case AUTH_PROVIDER.LDAP:
return { provide: AuthProvider, useClass: AuthProviderLDAP }
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined
}

case AUTH_PROVIDER.MYSQL:
return { provide: AuthProvider, useClass: AuthProviderMySQL }
export function applyStorageQuotaToIdentity<T extends IdentityWithStorageQuota>(
identity: T,
profile: Record<string, unknown>,
fieldName = DEFAULT_STORAGE_QUOTA_FIELD
): void {
if (!Object.hasOwn(profile, fieldName)) {
return
}

default:
return { provide: AuthProvider, useClass: AuthProviderMySQL }
const quota = profile[fieldName] === null ? null : parseStorageQuotaInBytes(profile[fieldName])
if (quota === undefined) {
return
}

identity.storageQuota = quota === 0 ? null : quota
}
6 changes: 6 additions & 0 deletions backend/src/authentication/providers/ldap/auth-ldap.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from 'class-validator'
import { USER_PERMISSION } from '../../../applications/users/constants/user'
import { LDAP_COMMON_ATTR, LDAP_LOGIN_ATTR } from './auth-ldap.constants'
import { DEFAULT_STORAGE_QUOTA_FIELD } from '../auth-providers.constants'

export class AuthProviderLDAPAttributesConfig {
@IsOptional()
Expand All @@ -25,6 +26,11 @@ export class AuthProviderLDAPAttributesConfig {
@IsString()
@Transform(({ value }) => value || LDAP_COMMON_ATTR.MAIL)
email: string = LDAP_COMMON_ATTR.MAIL

@IsOptional()
@IsString()
@Transform(({ value }) => value || DEFAULT_STORAGE_QUOTA_FIELD)
storageQuota: string = DEFAULT_STORAGE_QUOTA_FIELD
}

export class AuthProviderLDAPOptionsConfig {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { AdminUsersManager } from '../../../applications/users/services/admin-us
import { UsersManager } from '../../../applications/users/services/users-manager.service'
import * as commonFunctions from '../../../common/functions'
import { configuration } from '../../../configuration/config.environment'
import { DEFAULT_STORAGE_QUOTA_FIELD } from '../auth-providers.constants'
import type { AuthProviderLDAPConfig } from './auth-ldap.config'
import { LDAP_COMMON_ATTR, LDAP_LOGIN_ATTR } from './auth-ldap.constants'
import { AuthProviderLDAP } from './auth-provider-ldap.service'
Expand Down Expand Up @@ -61,7 +62,7 @@ describe(AuthProviderLDAP.name, () => {
const setLdapConfig = (overrides: LdapConfigOverrides = {}) => {
const base: AuthProviderLDAPConfig = {
servers: ['ldap://localhost:389'],
attributes: { login: LDAP_LOGIN_ATTR.UID, email: LDAP_COMMON_ATTR.MAIL },
attributes: { login: LDAP_LOGIN_ATTR.UID, email: LDAP_COMMON_ATTR.MAIL, storageQuota: DEFAULT_STORAGE_QUOTA_FIELD },
baseDN: 'ou=people,dc=example,dc=org',
filter: '',
options: {
Expand All @@ -80,6 +81,9 @@ describe(AuthProviderLDAP.name, () => {
;(authProviderLDAP as any).ldapConfig = next
;(authProviderLDAP as any).isAD = [LDAP_LOGIN_ATTR.SAM, LDAP_LOGIN_ATTR.UPN].includes(next.attributes.login)
;(authProviderLDAP as any).hasServiceBind = Boolean(next.serviceBindDN && next.serviceBindPassword)
;(authProviderLDAP as any).requestedAttributes = Array.from(
new Set([...Object.values(LDAP_LOGIN_ATTR), ...Object.values(LDAP_COMMON_ATTR), next.attributes.email, next.attributes.storageQuota])
)
;(authProviderLDAP as any).clientOptionsPromise = (authProviderLDAP as any).buildClientOptions()
}

Expand Down Expand Up @@ -311,6 +315,80 @@ describe(AuthProviderLDAP.name, () => {
expect(usersManager.updateAccesses).toHaveBeenCalledWith(createdUser, '192.168.1.10', true)
})

it('should handle LDAP storage quota mapping cases', async () => {
jest.spyOn(commonFunctions, 'comparePassword').mockResolvedValue(true)
const scenarios = [
{
mode: 'create',
entry: { uid: 'john', mail: 'john@example.org', quotaBytes: '2048' },
expectedQuota: 2048
},
{
mode: 'create',
entry: { uid: 'john', mail: 'john@example.org', quotaBytes: '0' },
expectedQuota: null
},
{
mode: 'update',
entry: { uid: 'john', mail: 'john@example.org' },
expectedUpdate: false
},
{
mode: 'update',
entry: { uid: 'john', mail: 'john@example.org', quotaBytes: null },
expectedUpdate: true,
expectedQuota: null
},
{
mode: 'update',
entry: { uid: 'john', mail: 'john@example.org', quotaBytes: 'invalid' },
expectedUpdate: false
},
{
mode: 'update',
entry: { uid: 'john', mail: 'john@example.org', quotaBytes: '9007199254740992' },
expectedUpdate: false
}
] as const

setLdapConfig({ attributes: { storageQuota: 'quotaBytes' } })

for (const [index, scenario] of scenarios.entries()) {
jest.clearAllMocks()
mockBindResolve()
mockSearchEntries([scenario.entry])

if (scenario.mode === 'create') {
const createdUser: any = { id: 22 + index, login: 'john', isGuest: false, isActive: true, makePaths: jest.fn() }
usersManager.findUser.mockResolvedValue(null)
adminUsersManager.createUserOrGuest.mockResolvedValue(createdUser)
usersManager.fromUserId.mockResolvedValue(createdUser)

await authProviderLDAP.validateUser('john', 'pwd')

expect(adminUsersManager.createUserOrGuest).toHaveBeenCalledWith(
expect.objectContaining({ storageQuota: scenario.expectedQuota }),
USER_ROLE.USER
)
continue
}

const existingUser: any = buildUser({ id: 60 + index, email: 'john@example.org', firstName: '', lastName: '', storageQuota: 4096 })
usersManager.findUser.mockResolvedValue(existingUser)

await authProviderLDAP.validateUser('john', 'pwd')

if (scenario.expectedUpdate) {
expect(adminUsersManager.updateUserOrGuest).toHaveBeenCalledWith(
existingUser.id,
expect.objectContaining({ storageQuota: scenario.expectedQuota })
)
} else {
expect(adminUsersManager.updateUserOrGuest).not.toHaveBeenCalled()
}
}
})

it('should accept adminGroup as full DN', async () => {
setLdapConfig({
options: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { comparePassword, splitFullName } from '../../../common/functions'
import { configuration } from '../../../configuration/config.environment'
import type { AUTH_SCOPE } from '../../constants/scope'
import { AuthProvider } from '../auth-providers.models'
import { applyStorageQuotaToIdentity } from '../auth-providers.utils'
import type { AuthProviderLDAPConfig } from './auth-ldap.config'
import { ALL_LDAP_ATTRIBUTES, LDAP_COMMON_ATTR, LDAP_LOGIN_ATTR, LDAP_SEARCH_ATTR } from './auth-ldap.constants'
import type { LdapCa, LdapUserEntry } from './auth-ldap.interface'
Expand All @@ -23,6 +24,9 @@ export class AuthProviderLDAP implements AuthProvider {
private readonly hasServiceBind = Boolean(this.ldapConfig.serviceBindDN && this.ldapConfig.serviceBindPassword)
private readonly isAD = this.ldapConfig.attributes.login === LDAP_LOGIN_ATTR.SAM || this.ldapConfig.attributes.login === LDAP_LOGIN_ATTR.UPN
private readonly clientOptionsPromise: Promise<ClientOptions> = this.buildClientOptions()
private readonly requestedAttributes: string[] = Array.from(
new Set([...ALL_LDAP_ATTRIBUTES, this.ldapConfig.attributes.email, this.ldapConfig.attributes.storageQuota])
)

constructor(
private readonly usersManager: UsersManager,
Expand Down Expand Up @@ -191,7 +195,7 @@ export class AuthProviderLDAP implements AuthProvider {
const { searchEntries } = await client.search(this.ldapConfig.baseDN, {
scope: LDAP_SEARCH_ATTR.SUB,
filter: searchFilter,
attributes: ALL_LDAP_ATTRIBUTES
attributes: this.requestedAttributes
})

if (searchEntries.length === 0) {
Expand Down Expand Up @@ -290,7 +294,7 @@ export class AuthProviderLDAP implements AuthProvider {

private convertToLdapUserEntry(entry: Entry): LdapUserEntry {
// Normalize memberOf and other LDAP attributes for downstream usage.
for (const attr of ALL_LDAP_ATTRIBUTES) {
for (const attr of this.requestedAttributes) {
if (attr === LDAP_COMMON_ATTR.MEMBER_OF && entry[attr]) {
const values = (Array.isArray(entry[attr]) ? entry[attr] : entry[attr] ? [entry[attr]] : []).filter(
(v: any) => typeof v === 'string'
Expand Down Expand Up @@ -320,13 +324,15 @@ export class AuthProviderLDAP implements AuthProvider {
typeof this.ldapConfig.options.adminGroup === 'string' &&
this.ldapConfig.options.adminGroup &&
entry[LDAP_COMMON_ATTR.MEMBER_OF]?.includes(this.ldapConfig.options.adminGroup)
return {
const identity: CreateUserDto = {
login: this.dbLogin(entry[this.ldapConfig.attributes.login]),
email: entry[this.ldapConfig.attributes.email] as string,
password: password,
role: isAdmin ? USER_ROLE.ADMINISTRATOR : USER_ROLE.USER,
...this.getFirstNameAndLastName(entry)
} satisfies CreateUserDto
}
applyStorageQuotaToIdentity(identity, entry as Record<string, unknown>, this.ldapConfig.attributes.storageQuota)
return identity
}

private getFirstNameAndLastName(entry: LdapUserEntry): { firstName: string; lastName: string } {
Expand Down
6 changes: 6 additions & 0 deletions backend/src/authentication/providers/oidc/auth-oidc.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from 'class-validator'
import { USER_PERMISSION } from '../../../applications/users/constants/user'
import { OAuthTokenEndpoint } from './auth-oidc.constants'
import { DEFAULT_STORAGE_QUOTA_FIELD } from '../auth-providers.constants'

export class AuthProviderOIDCSecurityConfig {
@IsString()
Expand Down Expand Up @@ -63,6 +64,11 @@ export class AuthProviderOIDCOptionsConfig {
@IsString()
adminRoleOrGroup?: string

@IsOptional()
@IsString()
@Transform(({ value }) => value || DEFAULT_STORAGE_QUOTA_FIELD)
storageQuotaClaim?: string = DEFAULT_STORAGE_QUOTA_FIELD

@IsString()
@IsNotEmpty()
buttonText: string = 'Continue with OpenID Connect'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as avatarUtils from '../../../applications/users/utils/avatar'
import * as filesUtils from '../../../applications/files/utils/files'
import * as downloadFileUtils from '../../../applications/files/utils/download-file'
import * as imageUtils from '../../../common/image'
import { DEFAULT_STORAGE_QUOTA_FIELD } from '../auth-providers.constants'
import { OAuthCookie } from './auth-oidc.constants'
import { AuthProviderOIDC } from './auth-provider-oidc.service'

Expand Down Expand Up @@ -279,6 +280,102 @@ describe(AuthProviderOIDC.name, () => {
expect(result.role).toBe(USER_ROLE.ADMINISTRATOR)
})

it('handles storage quota claim mapping cases', async () => {
const scenarios = [
{
mode: 'create',
claimName: DEFAULT_STORAGE_QUOTA_FIELD,
profile: { sub: 'x', email: 'sam@c.d', preferred_username: 'sam', [DEFAULT_STORAGE_QUOTA_FIELD]: '2048' },
expectedQuota: 2048
},
{
mode: 'create',
claimName: DEFAULT_STORAGE_QUOTA_FIELD,
profile: { sub: 'x', email: 'sam0@c.d', preferred_username: 'sam0', [DEFAULT_STORAGE_QUOTA_FIELD]: 0 },
expectedQuota: null
},
{
mode: 'create',
claimName: 'quotaBytes',
profile: { sub: 'x', email: 'samq@c.d', preferred_username: 'samq', quotaBytes: '4096' },
expectedQuota: 4096
},
{
mode: 'update',
claimName: DEFAULT_STORAGE_QUOTA_FIELD,
profile: { sub: 'x', email: 'alice@example.org', preferred_username: 'alice' },
expectedUpdate: false
},
{
mode: 'update',
claimName: DEFAULT_STORAGE_QUOTA_FIELD,
profile: { sub: 'x', email: 'alice@example.org', preferred_username: 'alice', [DEFAULT_STORAGE_QUOTA_FIELD]: null },
expectedUpdate: true,
expectedQuota: null
},
{
mode: 'update',
claimName: DEFAULT_STORAGE_QUOTA_FIELD,
profile: { sub: 'x', email: 'alice@example.org', preferred_username: 'alice', [DEFAULT_STORAGE_QUOTA_FIELD]: 'invalid' },
expectedUpdate: false
},
{
mode: 'update',
claimName: DEFAULT_STORAGE_QUOTA_FIELD,
profile: { sub: 'x', email: 'alice@example.org', preferred_username: 'alice', [DEFAULT_STORAGE_QUOTA_FIELD]: '9007199254740992' },
expectedUpdate: false
}
] as const

const originalStorageQuotaClaim = (service as any).oidcConfig.options.storageQuotaClaim
try {
for (const [index, scenario] of scenarios.entries()) {
jest.clearAllMocks()
;(service as any).oidcConfig.options.storageQuotaClaim = scenario.claimName

if (scenario.mode === 'create') {
const id = 110 + index
usersManager.findUser.mockResolvedValue(null)
adminUsersManager.createUserOrGuest.mockResolvedValue({ id, login: `user-${id}` })
usersManager.fromUserId.mockResolvedValue({ id, role: USER_ROLE.USER, login: `user-${id}`, setFullName: jest.fn() } as any)

await (service as any).processUserInfo(scenario.profile, '127.0.0.1')

expect(adminUsersManager.createUserOrGuest).toHaveBeenCalledWith(
expect.objectContaining({ storageQuota: scenario.expectedQuota }),
USER_ROLE.USER
)
continue
}

const existingUser = {
id: 210 + index,
login: 'alice',
email: 'alice@example.org',
role: USER_ROLE.USER,
firstName: '',
lastName: '',
storageQuota: 4096,
setFullName: jest.fn()
} as any
usersManager.findUser.mockResolvedValue(existingUser)

await (service as any).processUserInfo(scenario.profile, '127.0.0.1')

if (scenario.expectedUpdate) {
expect(adminUsersManager.updateUserOrGuest).toHaveBeenCalledWith(
existingUser.id,
expect.objectContaining({ storageQuota: scenario.expectedQuota })
)
} else {
expect(adminUsersManager.updateUserOrGuest).not.toHaveBeenCalled()
}
}
} finally {
;(service as any).oidcConfig.options.storageQuotaClaim = originalStorageQuotaClaim
}
})

describe('updatePictureUrl', () => {
const oidcUser = { login: 'alice', tmpPath: '/tmp/sync-in/alice/tmp' } as UserModel
const userInfo = (picture = 'https://cdn.example.test/avatar.jpg') => ({ picture }) as any
Expand Down
Loading
Loading