Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
64.57% |
82 / 127 |
|
53.85% |
7 / 13 |
CRAP | |
0.00% |
0 / 1 |
Shibboleth | |
64.57% |
82 / 127 |
|
53.85% |
7 / 13 |
140.13 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setConfig | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
validateConfig | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
authenticate | |
93.18% |
41 / 44 |
|
0.00% |
0 / 1 |
15.07 | |||
getSessionInitiator | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
3.07 | |||
isExpired | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
logout | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
connectLibraryCard | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
12 | |||
getConfigurationLoader | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getRequiredAttributes | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
storeShibbolethSession | |
16.67% |
2 / 12 |
|
0.00% |
0 / 1 |
8.21 | |||
getCurrentEntityId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getAttribute | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 |
1 | <?php |
2 | |
3 | /** |
4 | * Shibboleth authentication module. |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) Villanova University 2014. |
9 | * Copyright (C) The National Library of Finland 2016. |
10 | * |
11 | * This program is free software; you can redistribute it and/or modify |
12 | * it under the terms of the GNU General Public License version 2, |
13 | * as published by the Free Software Foundation. |
14 | * |
15 | * This program is distributed in the hope that it will be useful, |
16 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
17 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
18 | * GNU General Public License for more details. |
19 | * |
20 | * You should have received a copy of the GNU General Public License |
21 | * along with this program; if not, write to the Free Software |
22 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
23 | * |
24 | * @category VuFind |
25 | * @package Authentication |
26 | * @author Franck Borel <franck.borel@gbv.de> |
27 | * @author Jochen Lienhard <lienhard@ub.uni-freiburg.de> |
28 | * @author Bernd Oberknapp <bo@ub.uni-freiburg.de> |
29 | * @author Demian Katz <demian.katz@villanova.edu> |
30 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
31 | * @author Vaclav Rosecky <vaclav.rosecky@mzk.cz> |
32 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
33 | * @link https://vufind.org Main Page |
34 | */ |
35 | |
36 | namespace VuFind\Auth; |
37 | |
38 | use Laminas\Http\PhpEnvironment\Request; |
39 | use VuFind\Auth\Shibboleth\ConfigurationLoaderInterface; |
40 | use VuFind\Db\Entity\UserEntityInterface; |
41 | use VuFind\Db\Service\ExternalSessionServiceInterface; |
42 | use VuFind\Db\Service\UserCardServiceInterface; |
43 | use VuFind\Db\Table\DbTableAwareInterface; |
44 | use VuFind\Db\Table\DbTableAwareTrait; |
45 | use VuFind\Exception\Auth as AuthException; |
46 | |
47 | /** |
48 | * Shibboleth authentication module. |
49 | * |
50 | * @category VuFind |
51 | * @package Authentication |
52 | * @author Franck Borel <franck.borel@gbv.de> |
53 | * @author Jochen Lienhard <lienhard@ub.uni-freiburg.de> |
54 | * @author Bernd Oberknapp <bo@ub.uni-freiburg.de> |
55 | * @author Demian Katz <demian.katz@villanova.edu> |
56 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
57 | * @author Vaclav Rosecky <vaclav.rosecky@mzk.cz> |
58 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
59 | * @link https://vufind.org Main Page |
60 | */ |
61 | class Shibboleth extends AbstractBase implements DbTableAwareInterface |
62 | { |
63 | use DbTableAwareTrait; |
64 | |
65 | /** |
66 | * Header name for entityID of the IdP that authenticated the user. |
67 | */ |
68 | public const DEFAULT_IDPSERVERPARAM = 'Shib-Identity-Provider'; |
69 | |
70 | /** |
71 | * This is array of attributes which $this->authenticate() |
72 | * method should check for. |
73 | * |
74 | * WARNING: can contain only such attributes, which are writeable to user table! |
75 | * |
76 | * @var array attribsToCheck |
77 | */ |
78 | protected $attribsToCheck = [ |
79 | 'cat_username', 'cat_password', 'email', 'lastname', 'firstname', |
80 | 'college', 'major', 'home_library', |
81 | ]; |
82 | |
83 | /** |
84 | * Read attributes from headers instead of environment variables |
85 | * |
86 | * @var boolean |
87 | */ |
88 | protected $useHeaders = false; |
89 | |
90 | /** |
91 | * Name of attribute with shibboleth identity provider |
92 | * |
93 | * @var string |
94 | */ |
95 | protected $shibIdentityProvider = self::DEFAULT_IDPSERVERPARAM; |
96 | |
97 | /** |
98 | * Name of attribute with shibboleth session ID |
99 | * |
100 | * @var string |
101 | */ |
102 | protected $shibSessionId = null; |
103 | |
104 | /** |
105 | * Constructor |
106 | * |
107 | * @param \Laminas\Session\ManagerInterface $sessionManager Session manager |
108 | * @param ConfigurationLoaderInterface $configurationLoader Configuration loader |
109 | * @param Request $request Http request object |
110 | * @param ILSAuthenticator $ilsAuthenticator ILS authenticator |
111 | */ |
112 | public function __construct( |
113 | protected \Laminas\Session\ManagerInterface $sessionManager, |
114 | protected ConfigurationLoaderInterface $configurationLoader, |
115 | protected Request $request, |
116 | protected ILSAuthenticator $ilsAuthenticator |
117 | ) { |
118 | } |
119 | |
120 | /** |
121 | * Set configuration. |
122 | * |
123 | * @param \Laminas\Config\Config $config Configuration to set |
124 | * |
125 | * @return void |
126 | */ |
127 | public function setConfig($config) |
128 | { |
129 | parent::setConfig($config); |
130 | $this->useHeaders = $this->config->Shibboleth->use_headers ?? false; |
131 | $this->shibIdentityProvider = $this->config->Shibboleth->idpserverparam |
132 | ?? self::DEFAULT_IDPSERVERPARAM; |
133 | $this->shibSessionId = $this->config->Shibboleth->session_id ?? null; |
134 | } |
135 | |
136 | /** |
137 | * Validate configuration parameters. This is a support method for getConfig(), |
138 | * so the configuration MUST be accessed using $this->config; do not call |
139 | * $this->getConfig() from within this method! |
140 | * |
141 | * @throws AuthException |
142 | * @return void |
143 | */ |
144 | protected function validateConfig() |
145 | { |
146 | // Throw an exception if the required username setting is missing. |
147 | $shib = $this->config->Shibboleth; |
148 | if (!isset($shib->username) || empty($shib->username)) { |
149 | throw new AuthException( |
150 | 'Shibboleth username is missing in your configuration file.' |
151 | ); |
152 | } |
153 | |
154 | // Throw an exception if no login endpoint is available. |
155 | if (!isset($shib->login)) { |
156 | throw new AuthException( |
157 | 'Shibboleth login configuration parameter is not set.' |
158 | ); |
159 | } |
160 | } |
161 | |
162 | /** |
163 | * Attempt to authenticate the current user. Throws exception if login fails. |
164 | * |
165 | * @param Request $request Request object containing account credentials. |
166 | * |
167 | * @throws AuthException |
168 | * @return UserEntityInterface Object representing logged-in user. |
169 | */ |
170 | public function authenticate($request) |
171 | { |
172 | // validate config before authentication |
173 | $this->validateConfig(); |
174 | // Check if username is set. |
175 | $entityId = $this->getCurrentEntityId($request); |
176 | $shib = $this->getConfigurationLoader()->getConfiguration($entityId); |
177 | $username = $this->getAttribute($request, $shib['username']); |
178 | if (empty($username)) { |
179 | $details = ($this->useHeaders) ? $request->getHeaders()->toArray() |
180 | : $request->getServer()->toArray(); |
181 | $this->debug( |
182 | "No username attribute ({$shib['username']}) present in request: " |
183 | . $this->varDump($details) |
184 | ); |
185 | throw new AuthException('authentication_error_admin'); |
186 | } |
187 | |
188 | // Check if required attributes match up: |
189 | foreach ($this->getRequiredAttributes($shib) as $key => $value) { |
190 | if (!preg_match("/$value/", $this->getAttribute($request, $key) ?? '')) { |
191 | $details = ($this->useHeaders) ? $request->getHeaders()->toArray() |
192 | : $request->getServer()->toArray(); |
193 | $this->debug( |
194 | "Attribute '$key' does not match required value '$value' in" |
195 | . ' request: ' . $this->varDump($details) |
196 | ); |
197 | throw new AuthException('authentication_error_denied'); |
198 | } |
199 | } |
200 | |
201 | // If we made it this far, we should log in the user! |
202 | $userService = $this->getUserService(); |
203 | $user = $this->getOrCreateUserByUsername($username); |
204 | |
205 | // Variable to hold catalog password (handled separately from other |
206 | // attributes since we need to use setUserCatalogCredentials method to store it): |
207 | $catPassword = null; |
208 | |
209 | // Has the user configured attributes to use for populating the user table? |
210 | foreach ($this->attribsToCheck as $attribute) { |
211 | if (isset($shib[$attribute])) { |
212 | $value = $this->getAttribute($request, $shib[$attribute]); |
213 | if ($attribute == 'email') { |
214 | $userService->updateUserEmail($user, $value); |
215 | } elseif ( |
216 | $attribute == 'cat_username' && isset($shib['prefix']) |
217 | && !empty($value) |
218 | ) { |
219 | $user->setCatUsername($shib['prefix'] . '.' . $value); |
220 | } elseif ($attribute == 'cat_password') { |
221 | $catPassword = $value; |
222 | } else { |
223 | $this->setUserValueByField($user, $attribute, $value ?? ''); |
224 | } |
225 | } |
226 | } |
227 | |
228 | // Save credentials if applicable. Note that we want to allow empty |
229 | // passwords (see https://github.com/vufind-org/vufind/pull/532), but |
230 | // we also want to be careful not to replace a non-blank password with a |
231 | // blank one in case the auth mechanism fails to provide a password on |
232 | // an occasion after the user has manually stored one. (For discussion, |
233 | // see https://github.com/vufind-org/vufind/pull/612). Note that in the |
234 | // (unlikely) scenario that a password can actually change from non-blank |
235 | // to blank, additional work may need to be done here. |
236 | if (!empty($catUsername = $user->getCatUsername())) { |
237 | $this->ilsAuthenticator->setUserCatalogCredentials( |
238 | $user, |
239 | $catUsername, |
240 | empty($catPassword) ? $this->ilsAuthenticator->getCatPasswordForUser($user) : $catPassword |
241 | ); |
242 | } |
243 | |
244 | $this->storeShibbolethSession($request); |
245 | |
246 | // Save and return the user object: |
247 | $userService->persistEntity($user); |
248 | return $user; |
249 | } |
250 | |
251 | /** |
252 | * Get the URL to establish a session (needed when the internal VuFind login |
253 | * form is inadequate). Returns false when no session initiator is needed. |
254 | * |
255 | * @param string $target Full URL where external authentication method should |
256 | * send user after login (some drivers may override this). |
257 | * |
258 | * @return bool|string |
259 | */ |
260 | public function getSessionInitiator($target) |
261 | { |
262 | $config = $this->getConfig(); |
263 | $shibTarget = $config->Shibboleth->target ?? $target; |
264 | $append = (str_contains($shibTarget, '?')) ? '&' : '?'; |
265 | // Adding the auth_method parameter makes it possible to handle logins when |
266 | // using an auth method that proxies others. |
267 | $sessionInitiator = $config->Shibboleth->login |
268 | . '?target=' . urlencode($shibTarget) |
269 | . urlencode($append . 'auth_method=Shibboleth'); |
270 | |
271 | if (isset($config->Shibboleth->provider_id)) { |
272 | $sessionInitiator = $sessionInitiator . '&entityID=' . |
273 | urlencode($config->Shibboleth->provider_id); |
274 | } |
275 | |
276 | return $sessionInitiator; |
277 | } |
278 | |
279 | /** |
280 | * Has the user's login expired? |
281 | * |
282 | * @return bool |
283 | */ |
284 | public function isExpired() |
285 | { |
286 | $config = $this->getConfig(); |
287 | if ( |
288 | !isset($this->shibSessionId) |
289 | || !($config->Shibboleth->checkExpiredSession ?? true) |
290 | ) { |
291 | return false; |
292 | } |
293 | $sessionId = $this->getAttribute($this->request, $this->shibSessionId); |
294 | return !isset($sessionId); |
295 | } |
296 | |
297 | /** |
298 | * Perform cleanup at logout time. |
299 | * |
300 | * @param string $url URL to redirect user to after logging out. |
301 | * |
302 | * @return string Redirect URL (usually same as $url, but modified in |
303 | * some authentication modules). |
304 | */ |
305 | public function logout($url) |
306 | { |
307 | // If single log-out is enabled, use a special URL: |
308 | $config = $this->getConfig(); |
309 | if ( |
310 | isset($config->Shibboleth->logout) |
311 | && !empty($config->Shibboleth->logout) |
312 | ) { |
313 | $append = (str_contains($config->Shibboleth->logout, '?')) ? '&' |
314 | : '?'; |
315 | $url = $config->Shibboleth->logout . $append . 'return=' |
316 | . urlencode($url); |
317 | } |
318 | |
319 | // Send back the redirect URL (possibly modified): |
320 | return $url; |
321 | } |
322 | |
323 | /** |
324 | * Connect user authenticated by Shibboleth to library card. |
325 | * |
326 | * @param Request $request Request object containing account credentials. |
327 | * @param UserEntityInterface $connectingUser Connect newly created library card to this user. |
328 | * |
329 | * @return void |
330 | */ |
331 | public function connectLibraryCard($request, $connectingUser) |
332 | { |
333 | $entityId = $this->getCurrentEntityId($request); |
334 | $shib = $this->getConfigurationLoader()->getConfiguration($entityId); |
335 | $username = $this->getAttribute($request, $shib['cat_username']); |
336 | if (!$username) { |
337 | throw new \VuFind\Exception\LibraryCard('Missing username'); |
338 | } |
339 | $prefix = $shib['prefix'] ?? ''; |
340 | if (!empty($prefix)) { |
341 | $username = $shib['prefix'] . '.' . $username; |
342 | } |
343 | $password = $shib['cat_password'] ?? null; |
344 | $this->getDbService(UserCardServiceInterface::class)->persistLibraryCardData( |
345 | $connectingUser, |
346 | null, |
347 | $shib['prefix'], |
348 | $username, |
349 | $password |
350 | ); |
351 | } |
352 | |
353 | /** |
354 | * Return configuration loader |
355 | * |
356 | * @return ConfigurationLoaderInterface configuration loader |
357 | */ |
358 | protected function getConfigurationLoader() |
359 | { |
360 | return $this->configurationLoader; |
361 | } |
362 | |
363 | /** |
364 | * Extract required user attributes from the configuration. |
365 | * |
366 | * @param array $config Shibboleth configuration |
367 | * |
368 | * @return array Only username and attribute-related values |
369 | * @throws AuthException |
370 | */ |
371 | protected function getRequiredAttributes($config) |
372 | { |
373 | // Special case -- store username as-is to establish return array: |
374 | $sortedUserAttributes = []; |
375 | |
376 | // Now extract user attribute values: |
377 | foreach ($config as $key => $value) { |
378 | if (preg_match('/userattribute_[0-9]{1,}/', $key)) { |
379 | $valueKey = 'userattribute_value_' . substr($key, 14); |
380 | $sortedUserAttributes[$value] = $config[$valueKey] ?? null; |
381 | |
382 | // Throw an exception if attributes are missing/empty. |
383 | if (empty($sortedUserAttributes[$value])) { |
384 | throw new AuthException( |
385 | 'User attribute value of ' . $value . ' is missing!' |
386 | ); |
387 | } |
388 | } |
389 | } |
390 | |
391 | return $sortedUserAttributes; |
392 | } |
393 | |
394 | /** |
395 | * Add session id mapping to external_session table for single logout support |
396 | * |
397 | * @param Request $request Request object containing account credentials. |
398 | * |
399 | * @return void |
400 | */ |
401 | protected function storeShibbolethSession($request) |
402 | { |
403 | if (!isset($this->shibSessionId)) { |
404 | return; |
405 | } |
406 | $shibSessionId = $this->getAttribute($request, $this->shibSessionId); |
407 | if (null === $shibSessionId) { |
408 | return; |
409 | } |
410 | $localSessionId = $this->sessionManager->getId(); |
411 | $this->getDbService(ExternalSessionServiceInterface::class) |
412 | ->addSessionMapping($localSessionId, $shibSessionId); |
413 | $this->debug( |
414 | "Cached Shibboleth session id '$shibSessionId' for local session" |
415 | . " '$localSessionId'" |
416 | ); |
417 | } |
418 | |
419 | /** |
420 | * Fetch entityId used for authentication |
421 | * |
422 | * @param Request $request Request object |
423 | * |
424 | * @return string entityId of IdP |
425 | */ |
426 | protected function getCurrentEntityId($request) |
427 | { |
428 | return $this->getAttribute($request, $this->shibIdentityProvider) ?? ''; |
429 | } |
430 | |
431 | /** |
432 | * Extract attribute from request. |
433 | * |
434 | * @param Request $request Request object |
435 | * @param string $attribute Attribute name |
436 | * |
437 | * @return ?string attribute value |
438 | */ |
439 | protected function getAttribute($request, $attribute): ?string |
440 | { |
441 | if ($this->useHeaders) { |
442 | $header = $request->getHeader($attribute); |
443 | return ($header) ? $header->getFieldValue() : null; |
444 | } else { |
445 | return $request->getServer()->get($attribute, null); |
446 | } |
447 | } |
448 | } |