[BUGFIX] Make openid login working again
[Packages/TYPO3.CMS.git] / typo3 / sysext / openid / Classes / OpenidService.php
1 <?php
2 namespace TYPO3\CMS\Openid;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Core\Database\DatabaseConnection;
18 use TYPO3\CMS\Core\Service\AbstractService;
19 use TYPO3\CMS\Core\TimeTracker\TimeTracker;
20 use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
21 use TYPO3\CMS\Core\Utility\GeneralUtility;
22 use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
23 use TYPO3\CMS\Core\Utility\HttpUtility;
24
25 require_once ExtensionManagementUtility::extPath('openid') . 'lib/php-openid/Auth/OpenID/Interface.php';
26
27 /**
28 * Service "OpenID Authentication" for the "openid" extension.
29 *
30 * @author Dmitry Dulepov <dmitry@typo3.org>
31 */
32 class OpenidService extends AbstractService {
33
34 /**
35 * The extension key
36 *
37 * @var string
38 */
39 public $extKey = 'openid';
40
41 /**
42 * Login data as passed to initAuth()
43 *
44 * @var array
45 */
46 protected $loginData = array();
47
48 /**
49 * Additional authentication information provided by AbstractUserAuthentication.
50 * We use it to decide what database table contains user records.
51 *
52 * @var array
53 */
54 protected $authenticationInformation = array();
55
56 /**
57 * OpenID response object. It is initialized when OpenID provider returns
58 * with success/failure response to us.
59 *
60 * @var \Auth_OpenID_ConsumerResponse
61 */
62 protected $openIDResponse = NULL;
63
64 /**
65 * A reference to the calling object
66 *
67 * @var AbstractUserAuthentication
68 */
69 protected $parentObject;
70
71 /**
72 * @var DatabaseConnection
73 */
74 protected $databaseConnection;
75
76 /**
77 * If set to TRUE, than libraries are already included.
78 *
79 * @var bool
80 */
81 static protected $openIDLibrariesIncluded = FALSE;
82
83 /**
84 * Constructs the OpenID authentication service.
85 */
86 public function __construct() {
87 // Auth_Yadis_Yadis::getHTTPFetcher() will use a cURL fetcher if the functionality
88 // is available in PHP, however the TYPO3 setting is not considered here:
89 if (!defined('Auth_Yadis_CURL_OVERRIDE')) {
90 if (!$GLOBALS['TYPO3_CONF_VARS']['SYS']['curlUse']) {
91 define('Auth_Yadis_CURL_OVERRIDE', TRUE);
92 }
93 }
94
95 $this->injectDatabaseConnection();
96 }
97
98 /**
99 * @param DatabaseConnection $databaseConnection
100 */
101 protected function injectDatabaseConnection(DatabaseConnection $databaseConnection = NULL) {
102 $this->databaseConnection = $databaseConnection ?: $GLOBALS['TYPO3_DB'];
103 }
104
105 /**
106 * Checks if service is available,. In case of this service we check that
107 * prerequisites for "PHP OpenID" libraries are fulfilled:
108 * - GMP or BCMATH PHP extensions are installed and functional
109 * - set_include_path() PHP function is available
110 *
111 * @return bool TRUE if service is available
112 */
113 public function init() {
114 $available = FALSE;
115 if (extension_loaded('gmp')) {
116 $available = is_callable('gmp_init');
117 } elseif (extension_loaded('bcmath')) {
118 $available = is_callable('bcadd');
119 } else {
120 $this->writeLog('Neither bcmath, nor gmp PHP extension found. OpenID authentication will not be available.');
121 }
122 // We also need set_include_path() PHP function
123 if (!is_callable('set_include_path')) {
124 $available = FALSE;
125 $this->writeLog('set_include_path() PHP function is not available. OpenID authentication is disabled.');
126 }
127 return $available ? parent::init() : FALSE;
128 }
129
130 /**
131 * Initializes authentication for this service.
132 *
133 * @param string $subType: Subtype for authentication (either "getUserFE" or "getUserBE")
134 * @param array $loginData: Login data submitted by user and preprocessed by AbstractUserAuthentication
135 * @param array $authenticationInformation: Additional TYPO3 information for authentication services (unused here)
136 * @param AbstractUserAuthentication $parentObject Calling object
137 * @return void
138 */
139 public function initAuth($subType, array $loginData, array $authenticationInformation, AbstractUserAuthentication &$parentObject) {
140 // Store login and authentication data
141 $this->loginData = $loginData;
142 $this->authenticationInformation = $authenticationInformation;
143 // If we are here after authentication by the OpenID server, get its response.
144 if (GeneralUtility::_GP('tx_openid_mode') === 'finish' && $this->openIDResponse === NULL) {
145 $this->includePHPOpenIDLibrary();
146 $openIDConsumer = $this->getOpenIDConsumer();
147 $this->openIDResponse = $openIDConsumer->complete($this->getReturnURL(GeneralUtility::_GP('tx_openid_claimed')));
148 }
149 $this->parentObject = $parentObject;
150 }
151
152 /**
153 * Process the submitted OpenID URL if valid.
154 *
155 * @param array $loginData Credentials that are submitted and potentially modified by other services
156 * @param string $passwordTransmissionStrategy Keyword of how the password has been hashed or encrypted before submission
157 * @return bool
158 */
159 public function processLoginData(array &$loginData, $passwordTransmissionStrategy) {
160 $isProcessed = FALSE;
161 // Pre-process the login only if no password has been submitted
162 if (empty($loginData['uident_text'])) {
163 try {
164 $openIdUrl = GeneralUtility::_POST('openid_url');
165 if (!empty($openIdUrl)) {
166 $loginData['uident_openid'] = $this->normalizeOpenID($openIdUrl);
167 $isProcessed = TRUE;
168 } elseif (!empty($loginData['uname'])) {
169 // It might be the case that during frontend login the OpenID URL is submitted in the username field
170 // Since we are a low priority service, and no password has been submitted it is OK to just assume
171 // we might have gotten an OpenID URL
172 $loginData['uident_openid'] = $this->normalizeOpenID($loginData['uname']);
173 $isProcessed = TRUE;
174 }
175 } catch (Exception $e) {
176 $this->writeLog($e->getMessage());
177 }
178 }
179 return $isProcessed;
180 }
181
182 /**
183 * This function returns the user record back to the AbstractUserAuthentication.
184 * It does not mean that user is authenticated, it means only that user is found. This
185 * function makes sure that user cannot be authenticated by any other service
186 * if user tries to use OpenID to authenticate.
187 *
188 * @return mixed User record (content of fe_users/be_users as appropriate for the current mode)
189 */
190 public function getUser() {
191 if ($this->loginData['status'] !== 'login') {
192 return NULL;
193 }
194 $userRecord = NULL;
195 if ($this->openIDResponse instanceof \Auth_OpenID_ConsumerResponse) {
196 $GLOBALS['BACK_PATH'] = $this->getBackPath();
197 // We are running inside the OpenID return script
198 // Note: we cannot use $this->openIDResponse->getDisplayIdentifier()
199 // because it may return a different identifier. For example,
200 // LiveJournal server converts all underscore characters in the
201 // original identfier to dashes.
202 if ($this->openIDResponse->status === Auth_OpenID_SUCCESS) {
203 $openIDIdentifier = $this->getFinalOpenIDIdentifier();
204 if ($openIDIdentifier) {
205 $userRecord = $this->getUserRecord($openIDIdentifier);
206 if (!empty($userRecord) && is_array($userRecord)) {
207 // The above function will return user record from the OpenID. It means that
208 // user actually tried to authenticate using his OpenID. In this case
209 // we must change the password in the record to a long random string so
210 // that this user cannot be authenticated with other service.
211 $userRecord[$this->authenticationInformation['db_user']['userident_column']] = GeneralUtility::getRandomHexString(42);
212 $this->writeLog('User \'%s\' logged in with OpenID \'%s\'', $userRecord[$this->parentObject->formfield_uname], $openIDIdentifier);
213 } else {
214 $this->writeLog('Failed to login user using OpenID \'%s\'', $openIDIdentifier);
215 }
216 }
217 }
218 } elseif (!empty($this->loginData['uident_openid'])) {
219 $this->sendOpenIDRequest($this->loginData['uident_openid']);
220 }
221 return $userRecord;
222 }
223
224 /**
225 * Authenticates user using OpenID.
226 *
227 * @param array $userRecord User record
228 * @return int Code that shows if user is really authenticated.
229 */
230 public function authUser(array $userRecord) {
231 $result = 100;
232 // 100 means "we do not know, continue"
233 if ($userRecord['tx_openid_openid'] !== '') {
234 // Check if user is identified by the OpenID
235 if ($this->openIDResponse instanceof \Auth_OpenID_ConsumerResponse) {
236 // If we have a response, it means OpenID server tried to authenticate
237 // the user. Now we just look what is the status and provide
238 // corresponding response to the caller
239 if ($this->openIDResponse->status === Auth_OpenID_SUCCESS) {
240 // Success (code 200)
241 $result = 200;
242 } else {
243 $this->writeLog('OpenID authentication failed with code \'%s\'.', $this->openIDResponse->status);
244 }
245 }
246 }
247 return $result;
248 }
249
250 /**
251 * Includes necessary files for the PHP OpenID library
252 *
253 * @return void
254 */
255 protected function includePHPOpenIDLibrary() {
256 if (self::$openIDLibrariesIncluded) {
257 return;
258 }
259 // Prevent further calls
260 self::$openIDLibrariesIncluded = TRUE;
261 // PHP OpenID libraries requires adjustments of path settings
262 $oldIncludePath = get_include_path();
263 $phpOpenIDLibPath = ExtensionManagementUtility::extPath('openid') . 'lib/php-openid';
264 @set_include_path(($phpOpenIDLibPath . PATH_SEPARATOR . $phpOpenIDLibPath . PATH_SEPARATOR . 'Auth' . PATH_SEPARATOR . $oldIncludePath));
265 // Make sure that random generator is properly set up. Constant could be
266 // defined by the previous inclusion of the file
267 if (!defined('Auth_OpenID_RAND_SOURCE')) {
268 if (TYPO3_OS === 'WIN') {
269 // No random generator on Windows!
270 define('Auth_OpenID_RAND_SOURCE', NULL);
271 } elseif (!is_readable('/dev/urandom')) {
272 if (is_readable('/dev/random')) {
273 define('Auth_OpenID_RAND_SOURCE', '/dev/random');
274 } else {
275 define('Auth_OpenID_RAND_SOURCE', NULL);
276 }
277 }
278 }
279 // Include files
280 require_once $phpOpenIDLibPath . '/Auth/OpenID/Consumer.php';
281 // Restore path
282 @set_include_path($oldIncludePath);
283 if (!is_array($_SESSION)) {
284 // Yadis requires session but session is not initialized when
285 // processing Backend authentication
286 @session_start();
287 $this->writeLog('Session is initialized');
288 }
289 }
290
291 /**
292 * Gets user record for the user with the OpenID provided by the user
293 *
294 * @param string $openIDIdentifier OpenID identifier to search for
295 * @return array Database fields from the table that corresponds to the current login mode (FE/BE)
296 */
297 protected function getUserRecord($openIDIdentifier) {
298 $record = NULL;
299 try {
300 $openIDIdentifier = $this->normalizeOpenID($openIDIdentifier);
301 // $openIDIdentifier always has a trailing slash
302 // but tx_openid_openid field possibly not so check for both alternatives in database
303 $record = $this->databaseConnection->exec_SELECTgetSingleRow(
304 '*',
305 $this->authenticationInformation['db_user']['table'],
306 'tx_openid_openid IN ('
307 . $this->databaseConnection->fullQuoteStr($openIDIdentifier, $this->authenticationInformation['db_user']['table'])
308 . ',' . $this->databaseConnection->fullQuoteStr(rtrim($openIDIdentifier, '/'),
309 $this->authenticationInformation['db_user']['table']) . ')'
310 . $this->authenticationInformation['db_user']['check_pid_clause']
311 . $this->authenticationInformation['db_user']['enable_clause']
312 );
313 if ($record) {
314 // Make sure to work only with normalized OpenID during the whole process
315 $record['tx_openid_openid'] = $this->normalizeOpenID($record['tx_openid_openid']);
316 }
317 } catch (Exception $e) {
318 // This should never happen and generally means hack attempt.
319 // We just log it and do not return any records.
320 $this->writeLog($e->getMessage());
321 }
322 return $record;
323 }
324
325 /**
326 * Creates OpenID Consumer object with a TYPO3-specific store. This function
327 * is almost identical to the example from the PHP OpenID library.
328 *
329 * @return \Auth_OpenID_Consumer Consumer instance
330 */
331 protected function getOpenIDConsumer() {
332 /* @var $openIDStore OpenidStore */
333 $openIDStore = GeneralUtility::makeInstance(OpenidStore::class);
334 $openIDStore->cleanup();
335 return new \Auth_OpenID_Consumer($openIDStore);
336 }
337
338 /**
339 * Sends request to the OpenID server to authenticate the user with the
340 * given ID. This function is almost identical to the example from the PHP
341 * OpenID library. Due to the OpenID specification we cannot do a slient login.
342 * Sometimes we have to redirect to the OpenID provider web site so that
343 * user can enter his password there. In this case we will redirect and provide
344 * a return adress to the special script inside this directory, which will
345 * handle the result appropriately.
346 *
347 * This function does not return on success. If it returns, it means something
348 * went totally wrong with OpenID.
349 *
350 * @param string $openIDIdentifier The OpenID identifier for discovery and auth request
351 * @return void
352 */
353 protected function sendOpenIDRequest($openIDIdentifier) {
354 $this->includePHPOpenIDLibrary();
355 // Initialize OpenID client system, get the consumer
356 $openIDConsumer = $this->getOpenIDConsumer();
357 // Begin the OpenID authentication process
358 $authenticationRequest = $openIDConsumer->begin($openIDIdentifier);
359 if (!$authenticationRequest) {
360 // Not a valid OpenID. Since it can be some other ID, we just return
361 // and let other service handle it.
362 $this->writeLog('Could not create authentication request for OpenID identifier \'%s\'', $openIDIdentifier);
363 return;
364 }
365 // Redirect the user to the OpenID server for authentication.
366 // Store the token for this authentication so we can verify the
367 // response.
368 // For OpenID version 1, we *should* send a redirect. For OpenID version 2,
369 // we should use a Javascript form to send a POST request to the server.
370 $returnURL = $this->getReturnURL($openIDIdentifier);
371 $trustedRoot = GeneralUtility::getIndpEnv('TYPO3_SITE_URL');
372 if ($authenticationRequest->shouldSendRedirect()) {
373 $redirectURL = $authenticationRequest->redirectURL($trustedRoot, $returnURL);
374 // If the redirect URL can't be built, return. We can only return.
375 if (\Auth_OpenID::isFailure($redirectURL)) {
376 $this->writeLog('Authentication request could not create redirect URL for OpenID identifier \'%s\'', $openIDIdentifier);
377 return;
378 }
379 // Send redirect. We use 303 code because it allows to redirect POST
380 // requests without resending the form. This is exactly what we need here.
381 // See http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.4
382 @ob_end_clean();
383 HttpUtility::redirect($redirectURL, HttpUtility::HTTP_STATUS_303);
384 } else {
385 $formHtml = $authenticationRequest->htmlMarkup($trustedRoot, $returnURL, FALSE, array('id' => 'openid_message'));
386 // Display an error if the form markup couldn't be generated;
387 // otherwise, render the HTML.
388 if (\Auth_OpenID::isFailure($formHtml)) {
389 // Form markup cannot be generated
390 $this->writeLog('Could not create form markup for OpenID identifier \'%s\'', $openIDIdentifier);
391 return;
392 } else {
393 @ob_end_clean();
394 echo $formHtml;
395 }
396 }
397 // If we reached this point, we must not return!
398 die;
399 }
400
401 /**
402 * Creates return URL for the OpenID server. When a user is authenticated by
403 * the OpenID server, the user will be sent to this URL to complete
404 * authentication process with the current site. We send it to our script.
405 *
406 * @param string $claimedIdentifier The OpenID identifier for discovery and auth request
407 * @return string Return URL
408 */
409 protected function getReturnURL($claimedIdentifier) {
410 if ($this->authenticationInformation['loginType'] === 'FE') {
411 // We will use eID to send user back, create session data and
412 // return to the calling page.
413 // Notice: 'pid' and 'logintype' parameter names cannot be changed!
414 // They are essential for FE user authentication.
415 $returnURL = 'index.php?eID=tx_openid&' . 'pid=' . $this->authenticationInformation['db_user']['checkPidList'] . '&logintype=login';
416 } else {
417 // In the Backend we will use dedicated script to create session.
418 // It is much easier for the Backend to manage users.
419 // Notice: 'login_status' parameter name cannot be changed!
420 // It is essential for BE user authentication.
421 $returnURL = GeneralUtility::getIndpEnv('TYPO3_SITE_URL') . TYPO3_mainDir . 'sysext/' . $this->extKey . '/class.tx_openid_return.php?login_status=login';
422 }
423 if (GeneralUtility::_GP('tx_openid_mode') === 'finish') {
424 $requestURL = GeneralUtility::_GP('tx_openid_location');
425 } else {
426 $requestURL = GeneralUtility::getIndpEnv('TYPO3_REQUEST_URL');
427 }
428 $returnURL .= '&tx_openid_location=' . rawurlencode($requestURL) . '&tx_openid_location_signature=' . $this->getSignature($requestURL) . '&tx_openid_mode=finish&tx_openid_claimed=' . rawurlencode($claimedIdentifier) . '&tx_openid_signature=' . $this->getSignature($claimedIdentifier);
429 return GeneralUtility::locationHeaderUrl($returnURL);
430 }
431
432 /**
433 * Signs a GET parameter.
434 *
435 * @param string $parameter
436 * @return string
437 */
438 protected function getSignature($parameter) {
439 return GeneralUtility::hmac($parameter, $this->extKey);
440 }
441
442 /**
443 * Implement normalization according to OpenID 2.0 specification
444 * See http://openid.net/specs/openid-authentication-2_0.html#normalization
445 *
446 * @param string $openIDIdentifier OpenID identifier to normalize
447 * @return string Normalized OpenID identifier
448 * @throws Exception
449 */
450 protected function normalizeOpenID($openIDIdentifier) {
451 if (empty($openIDIdentifier)) {
452 throw new Exception('Empty OpenID Identifier given.', 1381922460);
453 }
454 // Strip everything with and behind the fragment delimiter character "#"
455 if (strpos($openIDIdentifier, '#') !== FALSE) {
456 $openIDIdentifier = preg_replace('/#.*$/', '', $openIDIdentifier);
457 }
458 // A URI with a missing scheme is normalized to a http URI
459 if (!preg_match('#^https?://#', $openIDIdentifier)) {
460 $escapedIdentifier = $this->databaseConnection->quoteStr($openIDIdentifier, $this->authenticationInformation['db_user']['table']);
461 $condition = 'tx_openid_openid IN ('
462 . '\'http://' . $escapedIdentifier . '\','
463 . '\'http://' . $escapedIdentifier . '/\','
464 . '\'https://' . $escapedIdentifier . '\','
465 . '\'https://' . $escapedIdentifier . '/\''
466 . ')';
467 $row = $this->databaseConnection->exec_SELECTgetSingleRow(
468 'tx_openid_openid',
469 $this->authenticationInformation['db_user']['table'],
470 $condition
471 );
472 if (is_array($row)) {
473 $openIDIdentifier = $row['tx_openid_openid'];
474 } else {
475 // This only happens when the OpenID provider will select the final OpenID identity
476 // In this case we require a valid URL as we cannot guess the scheme
477 // So we throw an Exception and do not start the OpenID handshake at all
478 throw new Exception('Trying to authenticate with OpenID but identifier is neither found in a user record nor it is a valid URL.', 1381922465);
479 }
480 }
481 // An empty path component is normalized to a slash
482 // (e.g. "http://domain.org" -> "http://domain.org/")
483 if (preg_match('#^https?://[^/]+$#', $openIDIdentifier)) {
484 $openIDIdentifier .= '/';
485 }
486 return $openIDIdentifier;
487 }
488
489 /**
490 * Calculates the path to the TYPO3 directory from the current directory
491 *
492 * @return string
493 */
494 protected function getBackPath() {
495 $extPath = ExtensionManagementUtility::siteRelPath('openid');
496 $segmentCount = count(explode('/', $extPath));
497 $path = str_pad('', $segmentCount * 3, '../') . TYPO3_mainDir;
498 return $path;
499 }
500
501 /**
502 * Obtains a real identifier for the user
503 *
504 * @return string
505 */
506 protected function getFinalOpenIDIdentifier() {
507 $result = $this->getSignedParameter('openid_identity');
508 if (!$result) {
509 $result = $this->getSignedParameter('openid_claimed_id');
510 }
511 if (!$result) {
512 $result = $this->getSignedClaimedOpenIDIdentifier();
513 }
514 return $result;
515 }
516
517 /**
518 * Gets the signed OpenID that was sent back to this service.
519 *
520 * @return string The signed OpenID, if signature did not match this is empty
521 */
522 protected function getSignedClaimedOpenIDIdentifier() {
523 $result = GeneralUtility::_GP('tx_openid_claimed');
524 $signature = $this->getSignature($result);
525 if ($signature !== GeneralUtility::_GP('tx_openid_signature')) {
526 $result = '';
527 }
528 return $result;
529 }
530
531 /**
532 * Obtains a value of the parameter if it is signed. If not signed, then
533 * empty string is returned.
534 *
535 * @param string $parameterName Must start with 'openid_'
536 * @return string
537 */
538 protected function getSignedParameter($parameterName) {
539 $signedParametersList = GeneralUtility::_GP('openid_signed');
540 if (GeneralUtility::inList($signedParametersList, substr($parameterName, 7))) {
541 $result = GeneralUtility::_GP($parameterName);
542 } else {
543 $result = '';
544 }
545 return $result;
546 }
547
548 /**
549 * Writes log message. Destination log depends on the current system mode.
550 * For FE the function writes to the admin panel log. For BE messages are
551 * sent to the system log. If developer log is enabled, messages are also
552 * sent there.
553 *
554 * This function accepts variable number of arguments and can format
555 * parameters. The syntax is the same as for sprintf()
556 *
557 * @param string $message Message to output
558 * @return void
559 * @see GeneralUtility::sysLog()
560 * @see \TYPO3\CMS\Core\TimeTracker\TimeTracker::setTSlogMessage()
561 */
562 protected function writeLog($message) {
563 if (func_num_args() > 1) {
564 $params = func_get_args();
565 array_shift($params);
566 $message = vsprintf($message, $params);
567 }
568 if (TYPO3_MODE === 'BE') {
569 GeneralUtility::sysLog($message, $this->extKey, GeneralUtility::SYSLOG_SEVERITY_NOTICE);
570 } else {
571 /** @var TimeTracker $tt */
572 $tt = $GLOBALS['TT'];
573 $tt->setTSlogMessage($message);
574 }
575 if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['enable_DLOG']) {
576 GeneralUtility::devLog($message, $this->extKey, GeneralUtility::SYSLOG_SEVERITY_NOTICE);
577 }
578 }
579
580 }