Skip to content

Commit 512029e

Browse files
Copilotthorsten
andauthored
feat: added LDAP/AD Integration with groups
Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: thorsten <[email protected]>
1 parent f664a19 commit 512029e

File tree

12 files changed

+329
-2
lines changed

12 files changed

+329
-2
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"phpstan/phpstan": "^2",
6060
"phpunit/phpunit": "12.*",
6161
"rector/rector": "^2",
62-
"squizlabs/php_codesniffer": "3.*",
62+
"squizlabs/php_codesniffer": "*",
6363
"symfony/yaml": "7.*",
6464
"zircote/swagger-php": "^5.0"
6565
},

composer.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

phpmyfaq/src/phpMyFAQ/Auth/AuthLdap.php

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use phpMyFAQ\Core\Exception;
2525
use phpMyFAQ\Enums\AuthenticationSourceType;
2626
use phpMyFAQ\Ldap as LdapCore;
27+
use phpMyFAQ\Permission\MediumPermission;
2728
use phpMyFAQ\User;
2829
use SensitiveParameter;
2930

@@ -93,9 +94,71 @@ public function create(string $login, #[SensitiveParameter] string $password, st
9394
]
9495
);
9596

97+
// Handle group assignments if enabled
98+
$ldapGroupConfig = $this->configuration->getLdapGroupConfig();
99+
if ($ldapGroupConfig['auto_assign'] === 'true' && $this->configuration->get('security.permLevel') === 'medium') {
100+
$this->assignUserToGroups($login, $user->getUserId());
101+
}
102+
96103
return $result;
97104
}
98105

106+
/**
107+
* Assigns user to phpMyFAQ groups based on AD group membership
108+
*
109+
* @param string $login Username
110+
* @param int $userId User ID
111+
*/
112+
private function assignUserToGroups(string $login, int $userId): void
113+
{
114+
$ldapGroupConfig = $this->configuration->getLdapGroupConfig();
115+
$userGroups = $this->ldapCore->getGroupMemberships($login);
116+
117+
if ($userGroups === false) {
118+
$this->configuration->getLogger()->warning("Unable to retrieve group memberships for user: {$login}");
119+
return;
120+
}
121+
122+
$permission = new MediumPermission($this->configuration);
123+
$groupMapping = $ldapGroupConfig['group_mapping'];
124+
125+
foreach ($userGroups as $adGroup) {
126+
$groupName = $this->extractGroupNameFromDn($adGroup);
127+
128+
// Check if there's a specific mapping for this AD group
129+
if (!empty($groupMapping) && isset($groupMapping[$groupName])) {
130+
$faqGroupName = $groupMapping[$groupName];
131+
} else {
132+
// Default: use the AD group name
133+
$faqGroupName = $groupName;
134+
}
135+
136+
// Find or create the group
137+
$groupId = $permission->findOrCreateGroupByName($faqGroupName);
138+
139+
if ($groupId > 0) {
140+
$permission->addToGroup($userId, $groupId);
141+
$this->configuration->getLogger()->info("Added user {$login} to group {$faqGroupName}");
142+
}
143+
}
144+
}
145+
146+
/**
147+
* Extract group name from DN
148+
*
149+
* @param string $dn Group DN
150+
* @return string Group name
151+
*/
152+
private function extractGroupNameFromDn(string $dn): string
153+
{
154+
// Extract CN from DN, e.g., "CN=Domain Users,CN=Users,DC=example,DC=com" -> "Domain Users"
155+
if (preg_match('/CN=([^,]+)/', $dn, $matches)) {
156+
return $matches[1];
157+
}
158+
159+
return $dn;
160+
}
161+
99162
/**
100163
* @inheritDoc
101164
*/
@@ -160,6 +223,32 @@ public function checkCredentials(
160223
throw new AuthException($this->ldapCore->error);
161224
}
162225

226+
// Check AD group membership restrictions if enabled
227+
$ldapGroupConfig = $this->configuration->getLdapGroupConfig();
228+
if ($ldapGroupConfig['use_group_restriction'] === 'true') {
229+
$userGroups = $this->ldapCore->getGroupMemberships($login);
230+
if ($userGroups === false) {
231+
throw new AuthException('Unable to retrieve user group memberships');
232+
}
233+
234+
$allowedGroups = $ldapGroupConfig['allowed_groups'];
235+
if (!empty($allowedGroups)) {
236+
$hasAllowedGroup = false;
237+
foreach ($userGroups as $userGroup) {
238+
foreach ($allowedGroups as $allowedGroup) {
239+
if (str_contains($userGroup, trim($allowedGroup))) {
240+
$hasAllowedGroup = true;
241+
break 2;
242+
}
243+
}
244+
}
245+
246+
if (!$hasAllowedGroup) {
247+
throw new AuthException('User is not a member of any allowed LDAP/Active Directory groups');
248+
}
249+
}
250+
}
251+
163252
$this->create($login, htmlspecialchars_decode($password));
164253

165254
return true;

phpmyfaq/src/phpMyFAQ/Configuration.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ public function setLdapConfig(LdapConfiguration $ldapConfiguration): void
293293
'ldap_use_memberOf' => $this->get('ldap.ldap_use_memberOf'),
294294
'ldap_use_sasl' => $this->get('ldap.ldap_use_sasl'),
295295
'ldap_use_anonymous_login' => $this->get('ldap.ldap_use_anonymous_login'),
296+
'ldap_group_config' => $this->getLdapGroupConfig(),
296297
];
297298
}
298299

@@ -324,6 +325,24 @@ public function getLdapOptions(): array
324325
];
325326
}
326327

328+
/**
329+
* Returns the LDAP group configuration.
330+
*
331+
* @return array<string, string|array>
332+
*/
333+
public function getLdapGroupConfig(): array
334+
{
335+
$allowedGroups = $this->get('ldap.ldap_group_allowed_groups');
336+
$groupMapping = $this->get('ldap.ldap_group_mapping');
337+
338+
return [
339+
'use_group_restriction' => $this->get('ldap.ldap_use_group_restriction'),
340+
'allowed_groups' => $allowedGroups ? explode(',', $allowedGroups) : [],
341+
'auto_assign' => $this->get('ldap.ldap_group_auto_assign'),
342+
'group_mapping' => $groupMapping ? json_decode($groupMapping, true) : [],
343+
];
344+
}
345+
327346
/**
328347
* Returns the LDAP configuration.
329348
*

phpmyfaq/src/phpMyFAQ/Ldap.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,63 @@ public function getCompleteName(string $username): bool|string
306306
return $this->getLdapData($username, 'name');
307307
}
308308

309+
/**
310+
* Returns the user's AD group memberships.
311+
*
312+
* @param string $username Username
313+
* @return array<string>|false Array of group DNs or false on error
314+
*/
315+
public function getGroupMemberships(string $username): array|false
316+
{
317+
if ($this->ds === false) {
318+
$this->error = 'The LDAP connection handler is not a valid resource.';
319+
return false;
320+
}
321+
322+
$filter = sprintf(
323+
'(%s=%s)',
324+
$this->configuration->get('ldap.ldap_mapping.username'),
325+
$this->quote($username)
326+
);
327+
328+
$fields = ['memberOf'];
329+
330+
$searchResult = ldap_search($this->ds, $this->base, $filter, $fields);
331+
332+
if (!$searchResult) {
333+
$this->error = sprintf(
334+
'Unable to search for "%s" (Error: %s)',
335+
$username,
336+
ldap_error($this->ds)
337+
);
338+
339+
return false;
340+
}
341+
342+
$entryId = ldap_first_entry($this->ds, $searchResult);
343+
344+
if (!$entryId) {
345+
$this->errno = ldap_errno($this->ds);
346+
$this->error = sprintf(
347+
'Cannot get the value(s). Error: %s',
348+
ldap_error($this->ds)
349+
);
350+
351+
return false;
352+
}
353+
354+
$entries = ldap_get_entries($this->ds, $searchResult);
355+
$groups = [];
356+
357+
if ($entries['count'] > 0 && isset($entries[0]['memberof'])) {
358+
for ($i = 0; $i < $entries[0]['memberof']['count']; $i++) {
359+
$groups[] = $entries[0]['memberof'][$i];
360+
}
361+
}
362+
363+
return $groups;
364+
}
365+
309366
/**
310367
* Returns the LDAP error message of the last LDAP command.
311368
*

phpmyfaq/src/phpMyFAQ/Permission/MediumPermission.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,4 +784,29 @@ public function removeAllUsersFromGroup(int $groupId): bool
784784

785785
return (bool) $this->configuration->getDb()->query($delete);
786786
}
787+
788+
/**
789+
* Finds or creates a group by name.
790+
* Returns the group ID on success, 0 on failure.
791+
*
792+
* @param string $name Group name
793+
* @param string $description Optional group description
794+
*/
795+
public function findOrCreateGroupByName(string $name, string $description = ''): int
796+
{
797+
$groupId = $this->getGroupId($name);
798+
799+
if ($groupId > 0) {
800+
return $groupId;
801+
}
802+
803+
// Create new group if it doesn't exist
804+
$groupData = [
805+
'name' => $name,
806+
'description' => $description ?: "Auto-created group for $name",
807+
'auto_join' => false,
808+
];
809+
810+
return $this->addGroup($groupData);
811+
}
787812
}

phpmyfaq/src/phpMyFAQ/Setup/Installer.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,10 @@ class Installer extends Setup
391391
'ldap.ldap_use_anonymous_login' => 'false',
392392
'ldap.ldap_use_dynamic_login' => 'false',
393393
'ldap.ldap_dynamic_login_attribute' => 'uid',
394+
'ldap.ldap_use_group_restriction' => 'false',
395+
'ldap.ldap_group_allowed_groups' => '',
396+
'ldap.ldap_group_auto_assign' => 'false',
397+
'ldap.ldap_group_mapping' => '',
394398

395399
'api.enableAccess' => 'true',
396400
'api.apiClientToken' => '',

phpmyfaq/src/phpMyFAQ/Setup/Update.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1182,6 +1182,12 @@ private function applyUpdates410Alpha3(): void
11821182
"For more information about this FAQ system, visit: https://www.phpmyfaq.de";
11831183

11841184
$this->configuration->add('seo.contentLlmsText', $llmsText);
1185+
1186+
// LDAP group integration
1187+
$this->configuration->add('ldap.ldap_use_group_restriction', 'false');
1188+
$this->configuration->add('ldap.ldap_group_allowed_groups', '');
1189+
$this->configuration->add('ldap.ldap_group_auto_assign', 'false');
1190+
$this->configuration->add('ldap.ldap_group_mapping', '');
11851191
}
11861192
}
11871193

phpmyfaq/translations/language_en.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,6 +1167,10 @@
11671167
$LANG_CONF['ldap.ldap_use_anonymous_login'] = ['checkbox', 'Enable anonymous LDAP connections'];
11681168
$LANG_CONF['ldap.ldap_use_dynamic_login'] = ['checkbox', 'Enable LDAP dynamic user binding'];
11691169
$LANG_CONF['ldap.ldap_dynamic_login_attribute'] = ['input', 'LDAP attribute for dynamic user binding, "uid" when using an ADS'];
1170+
$LANG_CONF['ldap.ldap_use_group_restriction'] = ['checkbox', 'Restrict login to specific Active Directory groups'];
1171+
$LANG_CONF['ldap.ldap_group_allowed_groups'] = ['input', 'Comma-separated list of allowed AD groups (partial matches supported)'];
1172+
$LANG_CONF['ldap.ldap_group_auto_assign'] = ['checkbox', 'Automatically assign users to phpMyFAQ groups based on AD membership'];
1173+
$LANG_CONF['ldap.ldap_group_mapping'] = ['input', 'JSON mapping of AD groups to phpMyFAQ groups, e.g. {"Domain Admins": "Administrators"}'];
11701174
$LANG_CONF['seo.enableXMLSitemap'] = ['checkbox', 'Enable XML sitemap'];
11711175
$PMF_LANG['categoryImageLabel'] = 'Category image';
11721176
$PMF_LANG["categoryShowHomeLabel"] = "Show on startpage";

tests/phpMyFAQ/ConfigurationTest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,4 +200,32 @@ public function testSetLdapConfigWithMultipleServersButDisabled(): void
200200

201201
$this->assertEquals($expected, $this->configuration->getLdapServer());
202202
}
203+
204+
public function testGetLdapGroupConfig(): void
205+
{
206+
// Test default values
207+
$expected = [
208+
'use_group_restriction' => 'false',
209+
'allowed_groups' => [],
210+
'auto_assign' => 'false',
211+
'group_mapping' => [],
212+
];
213+
214+
$this->assertEquals($expected, $this->configuration->getLdapGroupConfig());
215+
216+
// Test with configured values
217+
$this->configuration->set('ldap.ldap_use_group_restriction', 'true');
218+
$this->configuration->set('ldap.ldap_group_allowed_groups', 'Domain Users,Domain Admins');
219+
$this->configuration->set('ldap.ldap_group_auto_assign', 'true');
220+
$this->configuration->set('ldap.ldap_group_mapping', '{"Domain Admins": "Administrators"}');
221+
222+
$expected = [
223+
'use_group_restriction' => 'true',
224+
'allowed_groups' => ['Domain Users', 'Domain Admins'],
225+
'auto_assign' => 'true',
226+
'group_mapping' => ['Domain Admins' => 'Administrators'],
227+
];
228+
229+
$this->assertEquals($expected, $this->configuration->getLdapGroupConfig());
230+
}
203231
}

0 commit comments

Comments
 (0)