Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.57% covered (warning)
64.57%
82 / 127
53.85% covered (warning)
53.85%
7 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Shibboleth
64.57% covered (warning)
64.57%
82 / 127
53.85% covered (warning)
53.85%
7 / 13
140.13
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setConfig
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 validateConfig
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 authenticate
93.18% covered (success)
93.18%
41 / 44
0.00% covered (danger)
0.00%
0 / 1
15.07
 getSessionInitiator
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 isExpired
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 logout
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 connectLibraryCard
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 getConfigurationLoader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRequiredAttributes
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 storeShibbolethSession
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
8.21
 getCurrentEntityId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAttribute
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
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
36namespace VuFind\Auth;
37
38use Laminas\Http\PhpEnvironment\Request;
39use VuFind\Auth\Shibboleth\ConfigurationLoaderInterface;
40use VuFind\Db\Entity\UserEntityInterface;
41use VuFind\Db\Service\ExternalSessionServiceInterface;
42use VuFind\Db\Service\UserCardServiceInterface;
43use VuFind\Db\Table\DbTableAwareInterface;
44use VuFind\Db\Table\DbTableAwareTrait;
45use 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 */
61class 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}