Skip to content

Commit 41b3ecb

Browse files
committed
feat(keycloak): extended the Keycloak support with role-to-group assignment
1 parent 5c01541 commit 41b3ecb

13 files changed

Lines changed: 268 additions & 4 deletions

File tree

docs/administration.md

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -871,7 +871,55 @@ On this page, phpMyFAQ displays some relevant system information like PHP versio
871871
Please use this information when reporting bugs. Additionally, you can check the status of all translation files and see
872872
if there are any missing translations.
873873

874-
## 5.7 Using Microsoft Entra ID
874+
## 5.7 Using external identity providers
875+
876+
phpMyFAQ can integrate with external identity providers for administrator and frontend logins.
877+
878+
### 5.7.1 Using Keycloak
879+
880+
Keycloak support uses OpenID Connect Authorization Code flow with PKCE.
881+
You can enable it in the administration under `Configuration` -> `Security` -> `Keycloak`.
882+
883+
Recommended Keycloak client settings:
884+
885+
- Client type: confidential
886+
- Standard flow enabled
887+
- Direct access grants disabled unless you need them for other tools
888+
- Valid redirect URI: `https://faq.example.com/auth/keycloak/callback`
889+
- Valid post logout redirect URI: `https://faq.example.com/`
890+
891+
Minimum phpMyFAQ configuration:
892+
893+
1. Enable `Keycloak sign-in`
894+
2. Set the `Keycloak base URL`, for example `https://sso.example.com`
895+
3. Set the `Realm`
896+
4. Set the `Client ID`
897+
5. Set the `Client secret`
898+
6. Set the `Redirect URI` to your phpMyFAQ callback URL
899+
7. Keep `Scopes` at least on `openid profile email`
900+
901+
Optional settings:
902+
903+
- Enable automatic provisioning if phpMyFAQ should create local users on first successful Keycloak login
904+
- Enable automatic group assignment if phpMyFAQ should assign local groups from Keycloak roles
905+
- Add a JSON role-to-group mapping if Keycloak role names should map to different phpMyFAQ group names
906+
- Set a logout redirect URL if users should return to a specific page after provider logout
907+
908+
phpMyFAQ resolves users in this order:
909+
910+
1. preferred username from Keycloak
911+
2. existing user by email address
912+
3. automatic provisioning if enabled
913+
914+
If automatic provisioning is disabled, users must already exist in phpMyFAQ before they can sign in with Keycloak.
915+
916+
Group assignment is additive in the current implementation:
917+
918+
- mapped or unmapped Keycloak roles can create phpMyFAQ groups automatically
919+
- matched groups are added to the user on login
920+
- existing phpMyFAQ group memberships are not removed automatically
921+
922+
### 5.7.2 Using Microsoft Entra ID
875923

876924
You can use our experimental Microsoft Entra ID support for user authentication as well.
877925
App Registrations in Azure are used to integrate applications with Microsoft Azure services,

docs/configuration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,17 @@ each setting controls. These values are set during installation and can be chang
124124
| `security.enableRegistration` | Enable registration for visitors | `true` | Allows new users to register an account on the FAQ. |
125125
| `security.domainWhiteListForRegistrations` | Allowed hosts for registrations | *(empty)* | A list of allowed email domains for new registrations. Leave empty to allow all domains. |
126126
| `security.enableSignInWithMicrosoft` | Enable Sign in with Microsoft Entra ID | `false` | Enables authentication via Microsoft Entra ID (formerly Azure AD). |
127+
| `keycloak.enable` | Enable Keycloak sign-in | `false` | Enables OpenID Connect authentication via Keycloak for the frontend and admin login forms. |
128+
| `keycloak.baseUrl` | Keycloak base URL | *(empty)* | Base URL of the Keycloak server, for example `https://sso.example.com`. |
129+
| `keycloak.realm` | Realm | *(empty)* | Keycloak realm used for phpMyFAQ authentication. |
130+
| `keycloak.clientId` | Client ID | *(empty)* | OIDC client identifier configured in Keycloak. |
131+
| `keycloak.clientSecret` | Client secret | *(empty)* | Client secret configured for the Keycloak OIDC client. |
132+
| `keycloak.redirectUri` | Redirect URI | *(empty)* | Callback URL registered in the Keycloak client, usually `https://faq.example.com/auth/keycloak/callback`. |
133+
| `keycloak.scopes` | Scopes | `openid profile email` | Space-separated scopes requested during login. |
134+
| `keycloak.autoProvision` | Automatically create phpMyFAQ users on first Keycloak login | `false` | When enabled, phpMyFAQ creates a local user automatically if no matching account exists yet. |
135+
| `keycloak.groupAutoAssign` | Automatically assign phpMyFAQ groups from Keycloak roles | `false` | When enabled and permission level `medium` is active, phpMyFAQ assigns users to groups derived from Keycloak roles on login. |
136+
| `keycloak.groupMapping` | Role to group mapping | *(empty)* | JSON object mapping Keycloak role names to phpMyFAQ group names, for example `{"admin":"Administrators"}`. Unmapped roles keep their original name. |
137+
| `keycloak.logoutRedirectUrl` | Logout redirect URL | *(empty)* | URL users should be redirected to after logging out from Keycloak. |
127138
| `security.enableGoogleReCaptchaV2` | Enable Invisible Google ReCAPTCHA v2 | `false` | Enables Google reCAPTCHA v2 to protect forms from spam and abuse. |
128139
| `security.googleReCaptchaV2SiteKey` | Google ReCAPTCHA v2 site key | *(empty)* | The site key from your Google reCAPTCHA v2 registration. |
129140
| `security.googleReCaptchaV2SecretKey` | Google ReCAPTCHA v2 secret key | *(empty)* | The secret key from your Google reCAPTCHA v2 registration. |

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ phpMyFAQ also supports two-factor authentication (2FA) for enhanced security.
2020
An AI-assisted translation feature helps translate content into multiple languages using Google Cloud Translation, DeepL,
2121
Azure Translator, Amazon Translate, or LibreTranslate.
2222
A REST API is available for integration with other systems.
23-
It also supports OpenLDAP, Microsoft Active Directory, Microsoft Entra ID, and an MCP Server for AI agents.
23+
It also supports OpenLDAP, Microsoft Active Directory, Microsoft Entra ID, Keycloak, and an MCP Server for AI agents.
2424
The system is easy to install, thanks to its user-friendly installation script.
2525

2626
phpMyFAQ is versatile

phpmyfaq/admin/assets/src/configuration/configuration.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ describe('Configuration Functions', () => {
354354
<div id="keycloak">
355355
<div class="pmf-config-item" data-config-key="keycloak.enable">Enable Keycloak sign-in</div>
356356
<div class="pmf-config-item" data-config-key="keycloak.clientId">Client ID</div>
357+
<div class="pmf-config-item" data-config-key="keycloak.groupMapping">Role to group mapping</div>
357358
</div>
358359
<div id="upgrade">
359360
<div class="pmf-config-item" data-config-key="upgrade.releaseEnvironment">Release environment</div>

phpmyfaq/src/phpMyFAQ/Auth/AuthKeycloak.php

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use phpMyFAQ\Configuration;
2626
use phpMyFAQ\Core\Exception;
2727
use phpMyFAQ\Enums\AuthenticationSourceType;
28+
use phpMyFAQ\Permission\MediumPermission;
2829
use phpMyFAQ\User;
2930
use SensitiveParameter;
3031

@@ -37,6 +38,7 @@ public function __construct(
3738
private readonly array $claims,
3839
private readonly string $resolvedLogin,
3940
private readonly ?Closure $userFactory = null,
41+
private readonly ?Closure $mediumPermissionFactory = null,
4042
) {
4143
parent::__construct($configuration);
4244
}
@@ -62,6 +64,10 @@ public function create(string $login, #[SensitiveParameter] string $password, st
6264
'email' => $this->getEmail(),
6365
]);
6466

67+
if ($this->shouldAssignGroups()) {
68+
$this->assignUserToGroups($user->getUserId());
69+
}
70+
6571
return $result;
6672
}
6773

@@ -127,6 +133,102 @@ private function userExists(string $login): bool
127133
return $user->getUserByLogin($login, false);
128134
}
129135

136+
private function shouldAssignGroups(): bool
137+
{
138+
return (
139+
$this->toBool($this->configuration->get(item: 'keycloak.groupAutoAssign'))
140+
&& $this->configuration->get(item: 'security.permLevel') === 'medium'
141+
);
142+
}
143+
144+
private function assignUserToGroups(int $userId): void
145+
{
146+
if ($userId <= 0) {
147+
return;
148+
}
149+
150+
$roleNames = $this->extractRoleNames();
151+
if ($roleNames === []) {
152+
return;
153+
}
154+
155+
$mediumPermission = $this->createMediumPermission();
156+
$groupMapping = $this->getGroupMapping();
157+
158+
foreach ($roleNames as $roleName) {
159+
$faqGroupName = $groupMapping[$roleName] ?? $roleName;
160+
$groupId = $mediumPermission->findOrCreateGroupByName($faqGroupName);
161+
162+
if ($groupId <= 0) {
163+
continue;
164+
}
165+
166+
$mediumPermission->addToGroup($userId, $groupId);
167+
$this->configuration
168+
->getLogger()
169+
->info(sprintf('Added Keycloak user %s to group %s', $this->resolvedLogin, $faqGroupName));
170+
}
171+
}
172+
173+
/**
174+
* @return array<string>
175+
*/
176+
private function extractRoleNames(): array
177+
{
178+
$roleNames = [];
179+
180+
$realmRoles = $this->claims['realm_access']['roles'] ?? [];
181+
if (is_array($realmRoles)) {
182+
foreach ($realmRoles as $realmRole) {
183+
if (!is_string($realmRole) || $realmRole === '') {
184+
continue;
185+
}
186+
187+
$roleNames[] = $realmRole;
188+
}
189+
}
190+
191+
$resourceAccess = $this->claims['resource_access'] ?? [];
192+
if (is_array($resourceAccess)) {
193+
foreach ($resourceAccess as $resource) {
194+
$resourceRoles = is_array($resource) ? $resource['roles'] ?? null : null;
195+
if (!is_array($resourceRoles)) {
196+
continue;
197+
}
198+
199+
foreach ($resourceRoles as $resourceRole) {
200+
if (!is_string($resourceRole) || $resourceRole === '') {
201+
continue;
202+
}
203+
204+
$roleNames[] = $resourceRole;
205+
}
206+
}
207+
}
208+
209+
return array_values(array_unique($roleNames));
210+
}
211+
212+
/**
213+
* @return array<string, string>
214+
*/
215+
private function getGroupMapping(): array
216+
{
217+
$groupMapping = $this->configuration->get(item: 'keycloak.groupMapping');
218+
if (!is_string($groupMapping) || trim($groupMapping) === '') {
219+
return [];
220+
}
221+
222+
$decoded = json_decode($groupMapping, associative: true);
223+
224+
return is_array($decoded) ? array_filter($decoded, is_string(...)) : [];
225+
}
226+
227+
private function toBool(mixed $value): bool
228+
{
229+
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
230+
}
231+
130232
private function createUser(): User
131233
{
132234
if ($this->userFactory instanceof Closure) {
@@ -135,4 +237,13 @@ private function createUser(): User
135237

136238
return new User($this->configuration);
137239
}
240+
241+
private function createMediumPermission(): MediumPermission
242+
{
243+
if ($this->mediumPermissionFactory instanceof Closure) {
244+
return ($this->mediumPermissionFactory)();
245+
}
246+
247+
return new MediumPermission($this->configuration);
248+
}
138249
}

phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationTabController.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,8 @@ private function logSecurityConfigChanges(array $changedKeys, array $oldConfig,
330330
'keycloak.redirectUri',
331331
'keycloak.scopes',
332332
'keycloak.autoProvision',
333+
'keycloak.groupAutoAssign',
334+
'keycloak.groupMapping',
333335
'keycloak.logoutRedirectUrl',
334336
];
335337

phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ private static function buildDefaultConfig(): array
214214
'keycloak.redirectUri' => '',
215215
'keycloak.scopes' => 'openid profile email',
216216
'keycloak.autoProvision' => 'false',
217+
'keycloak.groupAutoAssign' => 'false',
218+
'keycloak.groupMapping' => '',
217219
'keycloak.logoutRedirectUrl' => '',
218220
'spam.checkBannedWords' => 'true',
219221
'spam.enableCaptchaCode' => null,

phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,8 @@ public function up(OperationRecorder $recorder): void
810810
$recorder->addConfig('keycloak.redirectUri', '');
811811
$recorder->addConfig('keycloak.scopes', 'openid profile email');
812812
$recorder->addConfig('keycloak.autoProvision', 'false');
813+
$recorder->addConfig('keycloak.groupAutoAssign', 'false');
814+
$recorder->addConfig('keycloak.groupMapping', '');
813815
$recorder->addConfig('keycloak.logoutRedirectUrl', '');
814816

815817
// Recent news widget

phpmyfaq/translations/language_de.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1702,6 +1702,8 @@
17021702
$LANG_CONF['keycloak.redirectUri'] = ['input', 'Redirect-URI', 'Im Keycloak-Client registrierte Callback-URL'];
17031703
$LANG_CONF['keycloak.scopes'] = ['input', 'Scopes', 'Leerzeichengetrennte Scopes, z.B. openid profile email'];
17041704
$LANG_CONF['keycloak.autoProvision'] = ['checkbox', 'phpMyFAQ-Benutzer beim ersten Keycloak-Login automatisch anlegen'];
1705+
$LANG_CONF['keycloak.groupAutoAssign'] = ['checkbox', 'phpMyFAQ-Gruppen automatisch aus Keycloak-Rollen zuweisen'];
1706+
$LANG_CONF['keycloak.groupMapping'] = ['input', 'Rollen-zu-Gruppen-Zuordnung', 'JSON-Objekt zur Abbildung von Keycloak-Rollen auf phpMyFAQ-Gruppennamen, z.B. {"admin":"Administratoren"}'];
17051707
$LANG_CONF['keycloak.logoutRedirectUrl'] = ['input', 'Logout-Redirect-URL', 'URL für die Weiterleitung nach dem Keycloak-Logout'];
17061708

17071709
$LANG_CONF['mail.provider'] = ['select', 'E-Mail-Anbieter'];

phpmyfaq/translations/language_en.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1694,6 +1694,8 @@
16941694
$LANG_CONF['keycloak.redirectUri'] = ['input', 'Redirect URI', 'Callback URL registered in the Keycloak client'];
16951695
$LANG_CONF['keycloak.scopes'] = ['input', 'Scopes', 'Space-separated scopes, e.g. openid profile email'];
16961696
$LANG_CONF['keycloak.autoProvision'] = ['checkbox', 'Automatically create phpMyFAQ users on first Keycloak login'];
1697+
$LANG_CONF['keycloak.groupAutoAssign'] = ['checkbox', 'Automatically assign phpMyFAQ groups from Keycloak roles'];
1698+
$LANG_CONF['keycloak.groupMapping'] = ['input', 'Role to group mapping', 'JSON object mapping Keycloak roles to phpMyFAQ group names, e.g. {"admin":"Administrators"}'];
16971699
$LANG_CONF['keycloak.logoutRedirectUrl'] = ['input', 'Logout redirect URL', 'URL to redirect to after Keycloak logout'];
16981700

16991701
$LANG_CONF['mail.provider'] = ['select', 'Mail provider'];

0 commit comments

Comments
 (0)