1, // Association writer basic "artist-standard" => 1, // Association writer basic "school-premium" => 3, // Association writer full "artist-premium" => 3, // Association writer full "manager" => 3, // Association writer full ]; /** * The time in seconds during which the user's data in DB won't be re-updated after the last successful update * Set it to 0 to disable the delay */ CONST USER_UPDATE_DELAY = 300; /** * 0 - Authentification failed, no more services will be called... * @see https://docs.typo3.org/m/typo3/reference-coreapi/master/en-us/ApiOverview/Authentication/Index.html#the-service-chain * * @var int */ const STATUS_AUTHENTICATION_FAILURE = 0; /** * 100 - OK, but call next services... * @see https://docs.typo3.org/m/typo3/reference-coreapi/master/en-us/ApiOverview/Authentication/Index.html#the-service-chain * * @var int */ const STATUS_AUTHENTICATION_CONTINUE = 100; /** * 200 - authenticated and no more checking needed * @see https://docs.typo3.org/m/typo3/reference-coreapi/master/en-us/ApiOverview/Authentication/Index.html#the-service-chain * * @var int */ const STATUS_AUTHENTICATION_SUCCESS = 200; /** * @var object */ private object $apiService; /** * Guzzle Cookie Jar * * @var CookieJar */ private CookieJar $jar; /** * @var \TYPO3\CMS\Core\Database\ConnectionPool */ private $connectionPool; public function injectConnectionPool(ConnectionPool $connectionPool) { $this->connectionPool = $connectionPool; } /** * OtAuthenticationService constructor. */ public function __construct() { $this->jar = new CookieJar; $this->apiService = GeneralUtility::makeInstance(OpentalentApiService::class, null, null, $this->jar); $this->connectionPool = GeneralUtility::makeInstance(ConnectionPool::class); } /** * This function returns the user record back to the AbstractUserAuthentication. * It does not mean that user is authenticated, it only means that user is found. * /!\ The 'getUser' method is required by the Typo3 authentification system * @see https://docs.typo3.org/m/typo3/reference-coreapi/master/en-us/ApiOverview/Authentication/Index.html#the-auth-services-api * * @return array|bool User record or false (content of fe_users/be_users as appropriate for the current mode) * @throws GuzzleException */ public function getUser() { // Does the user already have a session on the Opentalent API? $username = $this->getAuthenticatedUsername(); $isBackend = $this->authInfo['loginType'] === 'BE'; if ($username != null && !$isBackend && $this->login['status'] === 'logout') { // This is a logout request $this->logout(); return false; } if ($username != null && $this->login['status'] === 'login' && $this->login['uname'] != $username) { // The user trying to log in is not the one authenticated on the Opentalent API // We let the TYPO3 auth service handle it return false; } else if ($username == null && $this->login['status'] != 'login') { // The user has no current session on Opentalent.fr and this is not a login request return false; } else if ($this->login['status'] === 'login' && $this->login['uname'] && $this->login['uident']) { // This is a login request $username = $this->login['uname']; $password = $this->login['uident']; // Send a login request for the user to the Opentalent Api, and return the data // of the matching user, or false if le login failed $logged = $this->logUser($username, $password); if (!$logged) { return false; } } /// At this point, username should be set if ($username === null) { return false; } // Request the latest data for the user and write it in the Typo3 DB // * The shouldUserBeUpdated() method checks if the user was already // generated in the last minutes, to avoid unnecessary operations * if ($this->shouldUserBeUpdated($username, $isBackend)) { try { $wasUpdated = $this->createOrUpdateUser($isBackend); } catch (\RuntimeException) { $wasUpdated = false; } if (!$wasUpdated) { // An error happened during the update of the user's data // since its data may have changed (credentials, rights, rôles...) // we can't allow him to connect. return false; } } // No need to check Pid for those users $this->authInfo['db_user']['checkPidList'] = ''; $this->authInfo['db_user']['check_pid_clause'] = ''; // Fetch the typo3 user from the database return $this->fetchUserRecord($username, '', $this->authInfo['db_user']); } /** * Returns the name of the user currently authenticated on the API side, or null if no user is logged in * * @return string|null * @throws GuzzleException */ protected function getAuthenticatedUsername(): ?string { $this->fillCookieJar(); try { if (!$this->jar->getCookieByName('BEARER')) { // Missing cookie : No need to ask API return null; } $response = $this->apiService->get(self::ISAUTH_URI, [], ['cookies' => $this->jar]); if ($response->getStatusCode() != 200) { return null; } return json_decode((string)$response->getBody()); } catch (ApiRequestException $e) { return null; } } /** * Update the guzzle cookie jar with the current session's ones */ private function fillCookieJar() { foreach (['BEARER', 'SFSESSID', 'AccessId'] as $cookieName) { if (array_key_exists($cookieName, $_COOKIE)) { $cookie = new SetCookie(); $cookie->setName($cookieName); $cookie->setValue($_COOKIE[$cookieName]); $cookie->setDomain(self::COOKIE_DOMAIN); $this->jar->setCookie($cookie); } } } /** * Submit a login request to the API * * @param string $username * @param string $password * @return bool Returns true if the api accepted the login request * @throws GuzzleException */ protected function logUser(string $username, string $password): bool { try { $response = $this->apiService->request( 'POST', self::LOGIN_URI, [], ['form_params' => ['_username' => $username, '_password' => $password]] ); if ($response->getStatusCode() != 200) { return false; } $data = json_decode((string)$response->getBody(), true); # Redirect the user if the password needs to be changed if (isset($data['type']) && $data['type'] === 'change_password') { $redirectTo = UrlUtils::join( OpentalentEnvService::get('ADMIN_BASE_URL'), "/#/account/", $data['organization'], "/secure/password/", $data['token'] ); NavigationUtils::redirect($redirectTo); } // The API accepted the login request // Set the cookies returned by the Api (SESSID and BEARER) $this->setCookiesFromApiResponse($response); return true; } catch (ApiRequestException $e) { return false; } } /** * Get the cookies from the API response and set them * * @param $response */ private function setCookiesFromApiResponse($response) { foreach ($response->getHeader('Set-Cookie') as $cookieStr) { $cookie = SetCookie::fromString($cookieStr); $name = $cookie->getName(); $value = $cookie->getValue(); $expires = $cookie->getExpires() ?? 0; $path = $cookie->getPath(); $secure = $cookie->getSecure(); $httpOnly = $cookie->getHttpOnly(); $_COOKIE[$name] = $value; setcookie($name, $value, $expires, $path, self::COOKIE_DOMAIN, $secure, $httpOnly); setcookie($name, $value, $expires, $path, '.' . self::COOKIE_DOMAIN, $secure, $httpOnly); if (!preg_match('/(.*\.)?opentalent\.fr/', $_SERVER['HTTP_HOST'])) { setcookie($name, $value, $expires, $path, $_SERVER['HTTP_HOST'], $secure, $httpOnly); } } } /** * Compare the last update date for the user to the GENERATION_DELAY delay * and return wether the user's data may be created/updated in the Typo3 DB * * @param string $username * @return bool */ protected function shouldUserBeUpdated(string $username, bool $isBackend = false): bool { $table = $isBackend ? 'be_users' : 'fe_users'; $cnn = $this->connectionPool->getConnectionForTable($table); $q = $cnn->select(['tx_opentalent_generationDate'], $table, ['username' => $username]); $strGenDate = $q->fetch(3)[0] ?? '1970-01-01 00:00:00'; $genDate = DateTime::createFromFormat("Y-m-d H:i:s", $strGenDate); if ($genDate == null) { return true; } $now = new DateTime(); $diff = $now->getTimestamp() - $genDate->getTimestamp(); return true || ($diff > self::USER_UPDATE_DELAY); } /** * Create or update the Frontend-user record in the typo3 database (table 'fe_users') * with the data fetched from the Api * * @return bool */ protected function createOrUpdateUser(bool $isBackend = false): bool { $table = $isBackend ? 'be_users' : 'fe_users'; $group_table = $isBackend ? 'be_groups' : 'fe_groups'; $prefix = $isBackend ? 'BE' : 'FE'; // Get user's data from the API $userApiData = $this->getUserData(); if (empty($userApiData)) { // An error happened, and even if the user was logged, we can not continue // (user's data and rights could have changed) return false; } $connection = $this->connectionPool->getConnectionForTable($table); // Since we don't want to store the password in the TYPO3 DB, we store a random string instead $randomStr = (new Random)->generateRandomHexString(30); // Front-end user $user_row = [ 'username' => $userApiData['username'], 'password' => $randomStr, 'description' => "[Warning: auto-generated record, do not modify] $prefix User", 'deleted' => 0, 'tx_opentalent_opentalentId' => $userApiData['id'], 'tx_opentalent_generationDate' => date('Y/m/d H:i:s') ]; if ($isBackend) { $user_row['lang'] = 'fr'; $user_row['options'] = "3"; $user_row['TSconfig'] = "options.uploadFieldsInTopOfEB = 1"; } else { $user_row['name'] = $userApiData['name']; $user_row['first_name'] = $userApiData['first_name']; } $groupsUid = []; if (!$isBackend) { $groupsUid[] = self::GROUP_FE_ALL_UID; } // Loop over the accesses of the user to list the matching organization groups if ($userApiData['accesses']) { foreach ($userApiData['accesses'] as $accessData) { if ($isBackend && !$accessData['isEditor'] && !$accessData['admin_access']) { continue; } if ($isBackend) { if ($accessData['admin_access']) { $mainGroupUid = $accessData['product'] === 'artist_premium' ? self::GROUP_ADMIN_PREMIUM_UID : self::GROUP_ADMIN_STANDARD_UID; } else { $mainGroupUid = $accessData['product'] === 'artist_premium' ? self::GROUP_EDITOR_PREMIUM_UID : self::GROUP_EDITOR_STANDARD_UID; } if (!in_array($mainGroupUid, $groupsUid)) { $groupsUid[] = $mainGroupUid; } } $organizationId = $accessData['organizationId']; // get the group for this organization $groupUid = $connection->fetchOne( "select g.uid from typo3.$group_table g inner join (select uid, ot_website_uid from typo3.pages where is_siteroot) p on g." . ($isBackend ? 'db_mountpoints' : 'pid') . " = p.uid inner join typo3.ot_websites w on p.ot_website_uid = w.uid where w.organization_id=:organizationId;", ['organizationId' => $organizationId] ); // <-- TODO: supprimer après la fin des tests sur cmf-test if ($organizationId === 616134) { // Groupe "hard-codé" pour le compte admincmf-test $groupUid = 4925; } // ---> if ($groupUid) { $groupsUid[] = $groupUid; } else { OtLogger::warning("Warning: no " . strtolower($prefix) . "_group found for organization " . $organizationId); } } } if ($isBackend && empty($groupsUid)) { throw new \RuntimeException("No BE_group found for user " . $userApiData['username']); } $user_row['usergroup'] = join(',', $groupsUid); // TODO: log a warning if a user with the same opentalentId exists (the user might have changed of username) $q = $connection->select( ['uid', 'tx_opentalent_opentalentId'], $table, ['username' => $userApiData['username']] ); $row = $q->fetch(3); $uid = $row[0] ?? null; $tx_opentalent_opentalentId = $row[1] ?? null; if (!$uid) { // No existing user: create $connection->insert($table, $user_row); } else { // User exists: update if (!$tx_opentalent_opentalentId > 0) { OtLogger::warning( "WARNING: $prefix user " . $userApiData['username'] . ' has been replaced by an auto-generated version.' ); } $connection->update($table, $user_row, ['uid' => $uid]); } return true; } /** * Get the data for the current authenticated user from the API * * @return array */ protected function getUserData(): ?array { $this->fillCookieJar(); try { $response = $this->apiService->request('GET', self::GET_USER_DATA_URI, [], ['cookies' => $this->jar]); } catch (ApiRequestException $e) { return []; } return json_decode($response->getBody(), true); } /** * Authenticates user using Opentalent auth service. * /!\ The 'authUser' method is required by the Typo3 authentification system * @see https://docs.typo3.org/m/typo3/reference-coreapi/master/en-us/ApiOverview/Authentication/Index.html#the-auth-services-api * * @param array $user Data of user. * @return int Code that shows if user is really authenticated. * @throws GuzzleException */ public function authUser(array $user): int { if ($user['username'] == $this->getAuthenticatedUsername()) { // Tha API just validated this user's auth return self::STATUS_AUTHENTICATION_SUCCESS; } else if ($this->authInfo['loginType'] === 'FE') { return self::STATUS_AUTHENTICATION_FAILURE; } else if (isset($user['tx_opentalent_opentalentId']) and $user['tx_opentalent_opentalentId'] != null) { // This is a user from the Opentalent DB, and the API refused its auth // (For performance only, since the password stored in the Typo3 is a random string, // the auth will be refused by the other services anyway) return self::STATUS_AUTHENTICATION_FAILURE; } // This may be a user using another auth system return self::STATUS_AUTHENTICATION_CONTINUE; } /** * Send a logout request to the API, remove the sessions cookies then logout * /!\ Frontend only */ public function logout(): bool { try { $response = $this->apiService->request( 'GET', self::LOGOUT_URI ); if ($response->getStatusCode() != 200) { return false; } // The API accepted the logout request // Unset the session cookies (SESSID and BEARER) if (isset($_COOKIE['BEARER'])) { unset($_COOKIE['BEARER']); $this->unset_cookie('BEARER'); } if (isset($_COOKIE['SFSESSID'])) { unset($_COOKIE['SFSESSID']); $this->unset_cookie('SFSESSID'); } $this->pObj->logoff(); return true; } catch (RequestException $e) { return false; } catch (GuzzleException $e) { return false; } } /** * Unset a cookie by reducing its expiration date * * @param string $name */ protected function unset_cookie(string $name) { setcookie($name, '', 1, '/', $_SERVER['HTTP_HOST']); // for custom domains (not in .opentalent.fr) setcookie($name, '', 1, '/', self::COOKIE_DOMAIN); // for opentalent.fr subdomains } /** * Get a user from DB by username * * @param string $username User name * @param string $extraWhere Additional WHERE clause: " AND ... * @param array|string $dbUserSetup User db table definition, or empty string for $this->db_user * @return mixed User array or FALSE */ public function fetchUserRecordTemp($username, $extraWhere = '', $dbUserSetup = '') { $dbUser = is_array($dbUserSetup) ? $dbUserSetup : $this->db_user; $user = false; if ($username || $extraWhere) { $query = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($dbUser['table']); $query->getRestrictions()->removeAll() ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); $constraints = array_filter([ QueryHelper::stripLogicalOperatorPrefix($dbUser['check_pid_clause']), QueryHelper::stripLogicalOperatorPrefix($dbUser['enable_clause']), QueryHelper::stripLogicalOperatorPrefix($extraWhere), ]); if (!empty($username)) { array_unshift( $constraints, $query->expr()->eq( $dbUser['username_column'], $query->createNamedParameter($username, \PDO::PARAM_STR) ) ); } $user = $query->select('*') ->from($dbUser['table']) ->where(...$constraints) ->execute() ->fetch(); } return $user; } }