Lines 91.06% 163 / 179
Methods 70.37% 19 / 27
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 create 100.00% 40 / 40 100.00% 1 / 1 6
 update 100.00% 1 / 1 100.00% 1 / 1 1
 delete 100.00% 1 / 1 100.00% 1 / 1 1
 checkCredentials 100.00% 10 / 10 100.00% 1 / 1 5
 isValidLogin 100.00% 1 / 1 100.00% 1 / 1 2
 getDisplayName 100.00% 7 / 7 100.00% 1 / 1 3
 getEmail 100.00% 1 / 1 100.00% 1 / 1 1
 getSubject 100.00% 1 / 1 100.00% 1 / 1 1
 findUser 100.00% 2 / 2 100.00% 1 / 1 2
 shouldAssignGroups 100.00% 4 / 4 100.00% 1 / 1 2
 assignUserToGroups 86.11% 31 / 36 0.00% 0 / 1 12.39
 extractRoleNames 87.50% 14 / 16 0.00% 0 / 1 10.20
 getGroupMapping 75.00% 9 / 12 0.00% 0 / 1 6.56
 toBool 100.00% 1 / 1 100.00% 1 / 1 1
 shouldSynchronizeGroupsOnLogin 100.00% 4 / 4 100.00% 1 / 1 2
 redactIdentifier 100.00% 6 / 6 100.00% 1 / 1 3
 createUser 80.00% 4 / 5 0.00% 0 / 1 3.07
 createMediumPermission 80.00% 4 / 5 0.00% 0 / 1 3.07
 [phpMyFAQ\Auth] getEncryptionContainer 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Auth] getErrors 100.00% 2 / 2 100.00% 1 / 1 3
 [phpMyFAQ\Auth] addError 0.00% 0 / 1 0.00% 0 / 1 2
 [phpMyFAQ\Auth] selectAuth 80.00% 8 / 10 0.00% 0 / 1 4.13
 [phpMyFAQ\Auth] enableReadOnly 100.00% 3 / 3 100.00% 1 / 1 1
 [phpMyFAQ\Auth] disableReadOnly 100.00% 3 / 3 100.00% 1 / 1 1
 [phpMyFAQ\Auth] isReadOnly 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Auth] encrypt 66.66% 2 / 3 0.00% 0 / 1 2.15
32class AuthKeycloak extends Auth implements AuthDriverInterface
33{
34    /** @param array<string, mixed> $claims */
35    public function __construct(
36        Configuration $configuration,
37        private readonly OidcProviderConfig $providerConfig,
38        private readonly array $claims,
39        private readonly string $resolvedLogin,
40        private readonly ?Closure $userFactory = null,
41        private readonly ?Closure $mediumPermissionFactory = null,
42    ) {
43        parent::__construct($configuration);
44    }
45
46    /**
47     * @throws Exception
48     */
49    public function create(string $login, #[SensitiveParameter] string $password, string $domain = ''): bool
50    {
51        $user = $this->createUser();
52
53        try {
54            $result = $user->createUser($login, '', $domain);
55        } catch (\Exception $exception) {
56            $this->configuration
57                ->getLogger()
58                ->error(sprintf(
59                    'Keycloak user creation failed for "%s": %s',
60                    $this->redactIdentifier($login),
61                    $exception->getMessage(),
62                ));
63            return false;
64        }
65
66        if (!$result) {
67            return false;
68        }
69
70        try {
71            $saved = $user->setUserData([
72                'display_name' => $this->getDisplayName(),
73                'email' => $this->getEmail(),
74                'keycloak_sub' => $this->getSubject(),
75            ]);
76        } catch (\Exception $exception) {
77            $this->configuration
78                ->getLogger()
79                ->error(sprintf(
80                    'Keycloak user data persistence failed for "%s": %s',
81                    $this->redactIdentifier($login),
82                    $exception->getMessage(),
83                ));
84            return false;
85        }
86
87        if (!$saved) {
88            $this->configuration
89                ->getLogger()
90                ->error(sprintf(
91                    'Keycloak user data persistence returned false for "%s"',
92                    $this->redactIdentifier($login),
93                ));
94            return false;
95        }
96
97        $user->setStatus('active');
98        $user->setAuthSource(AuthenticationSourceType::AUTH_KEYCLOAK->value);
99
100        if ($this->shouldAssignGroups()) {
101            $this->assignUserToGroups($user->getUserId());
102        }
103
104        return true;
105    }
106
107    public function update(string $login, #[SensitiveParameter] string $password): bool
108    {
109        return true;
110    }
111
112    public function delete(string $login): bool
113    {
114        return true;
115    }
116
117    /**
118     * @throws Exception
119     */
120    public function checkCredentials(
121        string $login,
122        #[SensitiveParameter]
123        string $password,
124        ?array $optionalData = null,
125    ): bool {
126        if ($login !== $this->resolvedLogin) {
127            return false;
128        }
129
130        $existingUser = $this->findUser($login);
131        if ($existingUser instanceof User) {
132            if ($this->shouldSynchronizeGroupsOnLogin()) {
133                $this->assignUserToGroups($existingUser->getUserId());
134            }
135
136            return true;
137        }
138
139        if (!$this->providerConfig->autoProvision) {
140            return false;
141        }
142
143        return $this->create($login, '');
144    }
145
146    public function isValidLogin(string $login, ?array $optionalData = null): int
147    {
148        return $login === $this->resolvedLogin ? 1 : 0;
149    }
150
151    private function getDisplayName(): string
152    {
153        $name = trim((string) ($this->claims['name'] ?? ''));
154        if ($name !== '') {
155            return $name;
156        }
157
158        $preferredUsername = trim((string) ($this->claims['preferred_username'] ?? ''));
159        if ($preferredUsername !== '') {
160            return $preferredUsername;
161        }
162
163        return $this->resolvedLogin;
164    }
165
166    private function getEmail(): string
167    {
168        return trim((string) ($this->claims['email'] ?? ''));
169    }
170
171    private function getSubject(): string
172    {
173        return trim((string) ($this->claims['sub'] ?? ''));
174    }
175
176    private function findUser(string $login): ?User
177    {
178        $user = $this->createUser();
179        return $user->getUserByLogin($login, false) ? $user : null;
180    }
181
182    private function shouldAssignGroups(): bool
183    {
184        return (
185            $this->toBool($this->configuration->get(item: 'keycloak.groupAutoAssign'))
186            && $this->configuration->get(item: 'security.permLevel') === 'medium'
187        );
188    }
189
190    private function assignUserToGroups(int $userId): void
191    {
192        if ($userId <= 0) {
193            return;
194        }
195
196        $mediumPermission = $this->createMediumPermission();
197        $groupMapping = $this->getGroupMapping();
198        if ($groupMapping === []) {
199            return;
200        }
201
202        $currentGroupIds = $mediumPermission->getUserGroups($userId);
203        $desiredGroupIds = [];
204        $roleNames = $this->extractRoleNames();
205
206        foreach ($roleNames as $roleName) {
207            if (!array_key_exists($roleName, $groupMapping)) {
208                continue;
209            }
210            $faqGroupName = $groupMapping[$roleName];
211            $groupId = $mediumPermission->findOrCreateGroupByName($faqGroupName);
212            if ($groupId <= 0) {
213                continue;
214            }
215
216            $desiredGroupIds[] = $groupId;
217            if (in_array($groupId, $currentGroupIds, strict: true)) {
218                continue;
219            }
220
221            $mediumPermission->addToGroup($userId, $groupId);
222            $this->configuration
223                ->getLogger()
224                ->info(sprintf('Added Keycloak user #%d to group %s', $userId, $faqGroupName));
225        }
226
227        if (!$this->shouldSynchronizeGroupsOnLogin()) {
228            return;
229        }
230
231        foreach (array_values(array_unique($groupMapping)) as $groupName) {
232            $groupId = $mediumPermission->getGroupId($groupName);
233            if ($groupId <= 0) {
234                continue;
235            }
236
237            if (
238                !in_array($groupId, $currentGroupIds, strict: true)
239                || in_array($groupId, $desiredGroupIds, strict: true)
240            ) {
241                continue;
242            }
243
244            $mediumPermission->removeFromGroup($userId, $groupId);
245            $this->configuration
246                ->getLogger()
247                ->info(sprintf('Removed Keycloak user #%d from group %s', $userId, $groupName));
248        }
249    }
250
251    /**
252     * @return array<string>
253     */
254    private function extractRoleNames(): array
255    {
256        $roleNames = [];
257
258        $realmRoles = $this->claims['realm_access']['roles'] ?? [];
259        if (is_array($realmRoles)) {
260            foreach ($realmRoles as $realmRole) {
261                if (!is_string($realmRole) || $realmRole === '') {
262                    continue;
263                }
264
265                $roleNames[] = $realmRole;
266            }
267        }
268
269        $clientId = trim((string) $this->configuration->get(item: 'keycloak.clientId'));
270        if ($clientId !== '') {
271            $clientRoles = $this->claims['resource_access'][$clientId]['roles'] ?? [];
272            if (is_array($clientRoles)) {
273                foreach ($clientRoles as $clientRole) {
274                    if (!is_string($clientRole) || $clientRole === '') {
275                        continue;
276                    }
277
278                    $roleNames[] = $clientRole;
279                }
280            }
281        }
282
283        return array_values(array_unique($roleNames));
284    }
285
286    /**
287     * @return array<string, string>
288     */
289    private function getGroupMapping(): array
290    {
291        $groupMapping = $this->configuration->get(item: 'keycloak.groupMapping');
292        if (!is_string($groupMapping) || trim($groupMapping) === '') {
293            return [];
294        }
295
296        $decoded = json_decode($groupMapping, associative: true);
297        if (!is_array($decoded)) {
298            return [];
299        }
300
301        $mapping = [];
302        foreach ($decoded as $keycloakGroup => $faqGroup) {
303            if (!is_string($faqGroup)) {
304                continue;
305            }
306
307            $mapping[(string) $keycloakGroup] = $faqGroup;
308        }
309
310        return $mapping;
311    }
312
313    private function toBool(mixed $value): bool
314    {
315        return filter_var($value, FILTER_VALIDATE_BOOLEAN);
316    }
317
318    private function shouldSynchronizeGroupsOnLogin(): bool
319    {
320        return (
321            $this->toBool($this->configuration->get(item: 'keycloak.groupSyncOnLogin'))
322            && $this->configuration->get(item: 'security.permLevel') === 'medium'
323        );
324    }
325
326    private function redactIdentifier(string $identifier): string
327    {
328        if (str_contains($identifier, '@')) {
329            [$local, $domain] = explode('@', string: $identifier, limit: 2);
330            return $local[0] . '***@' . $domain;
331        }
332
333        if (mb_strlen($identifier) <= 3) {
334            return str_repeat('*', mb_strlen($identifier));
335        }
336
337        return mb_substr($identifier, start: 0, length: 3) . '…';
338    }
339
340    /**
341     * @throws Exception
342     */
343    private function createUser(): User
344    {
345        if ($this->userFactory instanceof Closure) {
346            $user = ($this->userFactory)();
347            if ($user instanceof User) {
348                return $user;
349            }
350        }
351
352        return new User($this->configuration);
353    }
354
355    private function createMediumPermission(): MediumPermission
356    {
357        if ($this->mediumPermissionFactory instanceof Closure) {
358            $mediumPermission = ($this->mediumPermissionFactory)();
359            if ($mediumPermission instanceof MediumPermission) {
360                return $mediumPermission;
361            }
362        }
363
364        return new MediumPermission($this->configuration);
365    }
366}

Inherited from phpMyFAQ\Auth

73    public function getEncryptionContainer(string $encType): Encryption
74    {
75        $this->encContainer = Encryption::getInstance($encType, $this->configuration);
76        return $this->encContainer;
77    }
82    public function getErrors(): string
83    {
84        $message = $this->errors !== [] ? implode(separator: PHP_EOL, array: $this->errors) . PHP_EOL : '';
85        return $message . ($this->encContainer instanceof \phpMyFAQ\Encryption ? $this->encContainer->error() : '');
86    }
91    public function addError(string $message): void
92    {
93        $this->errors[] = $message;
94    }
101    public function selectAuth(string $method): Auth&AuthDriverInterface
102    {
103        $method = ucfirst(strtolower($method));
104        $authClass = '\\phpMyFAQ\\Auth\\Auth' . $method;
105
106        if (!class_exists($authClass) || !is_subclass_of($authClass, self::class)) {
107            $this->errors[] = self::PMF_ERROR_USER_NO_AUTH_TYPE;
108            throw new Exception(message: self::PMF_ERROR_USER_NO_AUTH_TYPE);
109        }
110
111        $auth = new $authClass($this->configuration);
112        if (!$auth instanceof AuthDriverInterface) {
113            $this->errors[] = self::PMF_ERROR_USER_NO_AUTH_TYPE;
114            throw new Exception(message: self::PMF_ERROR_USER_NO_AUTH_TYPE);
115        }
116
117        return $auth;
118    }
123    public function enableReadOnly(): bool
124    {
125        $oldReadOnly = $this->readOnly;
126        $this->readOnly = true;
127
128        return $oldReadOnly;
129    }
134    public function disableReadOnly(): bool
135    {
136        $oldReadOnly = $this->readOnly;
137        $this->readOnly = false;
138
139        return $oldReadOnly;
140    }
145    public function isReadOnly(): bool
146    {
147        return $this->readOnly;
148    }
155    public function encrypt(#[SensitiveParameter] string $string): string
156    {
157        if (!$this->encContainer instanceof \phpMyFAQ\Encryption) {
158            throw new Exception(message: 'No encryption container configured. Call getEncryptionContainer() first.');
159        }
160
161        return $this->encContainer->encrypt($string);
162    }