Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.54% covered (danger)
44.54%
53 / 119
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
CAS
44.54% covered (danger)
44.54%
53 / 119
44.44% covered (danger)
44.44%
4 / 9
358.45
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
 validateConfig
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
7
 authenticate
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
110
 getSessionInitiator
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 isExpired
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 logout
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getRequiredAttributes
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getServiceBaseUrl
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 setupCAS
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3/**
4 * CAS authentication module.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Authentication
25 * @author   Franck Borel <franck.borel@gbv.de>
26 * @author   Demian Katz <demian.katz@villanova.edu>
27 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
28 * @link     https://vufind.org Main Page
29 */
30
31namespace VuFind\Auth;
32
33use Laminas\Log\PsrLoggerAdapter;
34use VuFind\Db\Entity\UserEntityInterface;
35use VuFind\Exception\Auth as AuthException;
36
37use function constant;
38
39/**
40 * CAS authentication module.
41 *
42 * @category VuFind
43 * @package  Authentication
44 * @author   Tom Misilo <tmisilo@gmail.com>
45 * @author   Franck Borel <franck.borel@gbv.de>
46 * @author   Demian Katz <demian.katz@villanova.edu>
47 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
48 * @link     https://vufind.org Main Page
49 */
50class CAS extends AbstractBase
51{
52    use \VuFind\Log\LoggerAwareTrait;
53
54    /**
55     * Already Setup phpCAS
56     *
57     * @var bool
58     */
59    protected $phpCASSetup = false;
60
61    /**
62     * Constructor
63     *
64     * @param ILSAuthenticator $ilsAuthenticator ILS authenticator
65     */
66    public function __construct(protected ILSAuthenticator $ilsAuthenticator)
67    {
68    }
69
70    /**
71     * Validate configuration parameters. This is a support method for getConfig(),
72     * so the configuration MUST be accessed using $this->config; do not call
73     * $this->getConfig() from within this method!
74     *
75     * @throws AuthException
76     * @return void
77     */
78    protected function validateConfig()
79    {
80        $cas = $this->config->CAS;
81        // Throw an exception if the required server setting is missing.
82        if (!isset($cas->server)) {
83            throw new AuthException(
84                'CAS server configuration parameter is not set.'
85            );
86        }
87
88        // Throw an exception if the required port setting is missing.
89        if (!isset($cas->port)) {
90            throw new AuthException(
91                'CAS port configuration parameter is not set.'
92            );
93        }
94
95        // Throw an exception if the required context setting is missing.
96        if (!isset($cas->context)) {
97            throw new AuthException(
98                'CAS context configuration parameter is not set.'
99            );
100        }
101
102        // Throw an exception if the required CACert setting is missing.
103        if (!isset($cas->CACert)) {
104            throw new AuthException(
105                'CAS CACert configuration parameter is not set.'
106            );
107        }
108
109        // Throw an exception if the required login setting is missing.
110        if (!isset($cas->login)) {
111            throw new AuthException(
112                'CAS login configuration parameter is not set.'
113            );
114        }
115
116        // Throw an exception if the required logout setting is missing.
117        if (!isset($cas->logout)) {
118            throw new AuthException(
119                'CAS logout configuration parameter is not set.'
120            );
121        }
122    }
123
124    /**
125     * Attempt to authenticate the current user. Throws exception if login fails.
126     *
127     * @param \Laminas\Http\PhpEnvironment\Request $request Request object containing
128     * account credentials.
129     *
130     * @throws AuthException
131     * @return UserEntityInterface Object representing logged-in user.
132     */
133    public function authenticate($request)
134    {
135        // Configure phpCAS
136        $cas = $this->getConfig()->CAS;
137        $casauth = $this->setupCAS();
138        $casauth->forceAuthentication();
139
140        // Check if username is set.
141        if (isset($cas->username) && !empty($cas->username)) {
142            $username = $casauth->getAttribute($cas->username);
143        } else {
144            $username = $casauth->getUser();
145        }
146        if (empty($username)) {
147            throw new AuthException('authentication_error_admin');
148        }
149
150        // If we made it this far, we should log in the user!
151        $userService = $this->getUserService();
152        $user = $this->getOrCreateUserByUsername($username);
153
154        // Has the user configured attributes to use for populating the user table?
155        $attribsToCheck = [
156            'cat_username', 'cat_password', 'email', 'lastname', 'firstname',
157            'college', 'major', 'home_library',
158        ];
159        $catPassword = null;
160        foreach ($attribsToCheck as $attribute) {
161            if (isset($cas->$attribute)) {
162                $value = $casauth->getAttribute($cas->$attribute);
163                if ($attribute == 'email') {
164                    $userService->updateUserEmail($user, $value);
165                } elseif ($attribute != 'cat_password') {
166                    $this->setUserValueByField($user, $attribute, $value ?? '');
167                } else {
168                    $catPassword = $value;
169                }
170            }
171        }
172
173        // Save credentials if applicable. Note that we want to allow empty
174        // passwords (see https://github.com/vufind-org/vufind/pull/532), but
175        // we also want to be careful not to replace a non-blank password with a
176        // blank one in case the auth mechanism fails to provide a password on
177        // an occasion after the user has manually stored one. (For discussion,
178        // see https://github.com/vufind-org/vufind/pull/612). Note that in the
179        // (unlikely) scenario that a password can actually change from non-blank
180        // to blank, additional work may need to be done here.
181        if (!empty($catUsername = $user->getCatUsername())) {
182            $this->ilsAuthenticator->setUserCatalogCredentials(
183                $user,
184                $catUsername,
185                empty($catPassword) ? $this->ilsAuthenticator->getCatPasswordForUser($user) : $catPassword
186            );
187        }
188
189        // Save and return the user object:
190        $this->getUserService()->persistEntity($user);
191        return $user;
192    }
193
194    /**
195     * Get the URL to establish a session (needed when the internal VuFind login
196     * form is inadequate). Returns false when no session initiator is needed.
197     *
198     * @param string $target Full URL where external authentication method should
199     * send user after login (some drivers may override this).
200     *
201     * @return bool|string
202     */
203    public function getSessionInitiator($target)
204    {
205        $config = $this->getConfig();
206        if (isset($config->CAS->target)) {
207            $casTarget = $config->CAS->target;
208        } else {
209            $casTarget = $target;
210        }
211        $append = (str_contains($casTarget, '?')) ? '&' : '?';
212        $sessionInitiator = $config->CAS->login
213            . '?service=' . urlencode($casTarget)
214            . urlencode($append . 'auth_method=CAS');
215
216        return $sessionInitiator;
217    }
218
219    /**
220     * Has the user's login expired?
221     *
222     * @return bool
223     */
224    public function isExpired()
225    {
226        $config = $this->getConfig();
227        if (
228            isset($config->CAS->username)
229            && isset($config->CAS->logout)
230        ) {
231            $casauth = $this->setupCAS();
232            if ($casauth->checkAuthentication() === false) {
233                return true;
234            }
235        }
236        return false;
237    }
238
239    /**
240     * Perform cleanup at logout time.
241     *
242     * @param string $url URL to redirect user to after logging out.
243     *
244     * @return string     Redirect URL (usually same as $url, but modified in
245     * some authentication modules).
246     */
247    public function logout($url)
248    {
249        // If single log-out is enabled, use a special URL:
250        $config = $this->getConfig();
251        if (
252            isset($config->CAS->logout)
253            && !empty($config->CAS->logout)
254        ) {
255            $url = $config->CAS->logout . '?service=' . urlencode($url);
256        }
257
258        // Send back the redirect URL (possibly modified):
259        return $url;
260    }
261
262    /**
263     * Extract required user attributes from the configuration.
264     *
265     * @return array      Only username and attribute-related values
266     */
267    protected function getRequiredAttributes()
268    {
269        // Special case -- store username as-is to establish return array:
270        $sortedUserAttributes = [];
271
272        // Now extract user attribute values:
273        $cas = $this->getConfig()->CAS;
274        foreach ($cas as $key => $value) {
275            if (preg_match('/userattribute_[0-9]{1,}/', $key)) {
276                $valueKey = 'userattribute_value_' . substr($key, 14);
277                $sortedUserAttributes[$value] = $cas->$valueKey ?? null;
278
279                // Throw an exception if attributes are missing/empty.
280                if (empty($sortedUserAttributes[$value])) {
281                    throw new AuthException(
282                        'User attribute value of ' . $value . ' is missing!'
283                    );
284                }
285            }
286        }
287
288        return $sortedUserAttributes;
289    }
290
291    /**
292     * Return an array of service base URLs for the CAS client.
293     *
294     * @return string[]
295     * @throws AuthException
296     */
297    protected function getServiceBaseUrl(): array
298    {
299        $config = $this->getConfig();
300        $cas = $config->CAS;
301        if (isset($cas->service_base_url)) {
302            return $cas->service_base_url->toArray();
303        } elseif (isset($config->Site->url)) {
304            // fallback method
305            $siteUrl = parse_url($config->Site->url);
306            if (isset($siteUrl['scheme']) && isset($siteUrl['host'])) {
307                return [
308                    $siteUrl['scheme'] . '://' . $siteUrl['host'] .
309                    (isset($siteUrl['port']) ? ':' . $siteUrl['port'] : ''),
310                ];
311            }
312        }
313        throw new AuthException(
314            'Valid CAS/service_base_url or Site/url config parameters are required.'
315        );
316    }
317
318    /**
319     * Establishes phpCAS Configuration and Enables the phpCAS Client
320     *
321     * @return object     Returns phpCAS Object
322     */
323    protected function setupCAS()
324    {
325        $casauth = new \phpCAS();
326
327        // Check to see if phpCAS has already been setup. If it has, than skip as
328        // client can only be called once.
329        if (!$this->phpCASSetup) {
330            $cas = $this->getConfig()->CAS;
331
332            $casauth->setLogger(new PsrLoggerAdapter($this->logger));
333
334            if ($cas->debug ?? false) {
335                $casauth->setVerbose(true);
336            }
337
338            $protocol = constant($cas->protocol ?? 'SAML_VERSION_1_1');
339
340            $casauth->client(
341                $protocol,
342                $cas->server,
343                (int)$cas->port,
344                $cas->context,
345                $this->getServiceBaseUrl(),
346                false
347            );
348
349            if (isset($cas->CACert) && !empty($cas->CACert)) {
350                $casauth->setCasServerCACert($cas->CACert);
351            } else {
352                $casauth->setNoCasServerValidation();
353            }
354
355            $this->phpCASSetup = true;
356        }
357
358        return $casauth;
359    }
360}