diff --git a/tequila.php b/tequila.php index 441ba53..d1348ba 100644 --- a/tequila.php +++ b/tequila.php @@ -1,388 +1,388 @@ // Copyright (C) 2021 Liip SA // Copyright (C) 2021 Doran Kayoumi // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see require __DIR__ . '/vendor/autoload.php'; require_once "exception.php"; class TequilaClient { const VERSION = "4.0.0"; const TEQUILA_BIN = "/cgi-bin/tequila"; const COOKIE_NAME = "TequilaPHP"; const COOKIE_LIFE = 0; const BODY_GLUE = "+"; const SESSION_KEY = "Tequila-Session-Key"; const SESSION_CREATION = "Tequila-Session-Creation"; const LANGUAGE_FRENCH = 0; const LANGUAGE_ENGLISH = 1; const LANGUAGE_GERMAN = 2; const SERVER_ENDPOINTS = ["createrequest", "fetchattributes", "logout"]; private $serverURL; private $timeout; private $logoutURL; private $language; private $applicationURL; private $applicationName; private $attributes = []; private $key; private $stack; /** * @brief Class constructor * * @codeCoverageIgnore * * @param $server - Tequila server URL * @param $timeout - Session timeout * @param $applicationName - Name of the application using the client * @param $applicationURL - (optional) URL the application using the client * @param $language - (Default: English) Language of the application */ function __construct( string $server, int $timeout, string $applicationName, string $applicationURL = "", int $language = self::LANGUAGE_ENGLISH, callable $handler = null, bool $debug = false ) { $this->serverURL = $server . self::TEQUILA_BIN; $this->timeout = $timeout; $this->language = $language; $this->applicationURL = $applicationURL; $this->applicationName = $applicationName; $this->debug = (bool) $debug; $this->stack = GuzzleHttp\HandlerStack::create($handler); // if no application URL was specified, we try to generate it if (empty($this->applicationURL)) { $this->applicationURL = $this->serverApplicationURL(); } } /** * @brief User authentication to server * * @param $wantedAttributes - (optional) List of attributes about the user that the server will return * @param $filters - (optional) Filters that will be applied to the user attributes * @param $authorised - (optional) Tequila server restrictions to lift */ public function authenticate( array $wantedAttributes = [], string $filters = "", string $authorised = "", array $allowedRequestHosts = [] ) { $this->log(__FUNCTION__ . "(...)"); if ($this->preExistingSession()) { return; } // check if a session creation was already started. if (!empty($_COOKIE[self::COOKIE_NAME])) { $attributes = $this->fetchAttributes($_COOKIE[self::COOKIE_NAME], $allowedRequestHosts); if ( !empty($attributes) && isset($attributes['uniqueid']) && isset($attributes['user']) && isset($attributes['key']) ) { // Only create a valid session and keep the key if the mandatory attributes are present. $this->key = $_COOKIE[self::COOKIE_NAME]; $this->createSession($attributes); return; } } $this->key = $this->createRequest($wantedAttributes, $filters, $authorised); setcookie(self::COOKIE_NAME, $this->key, self::COOKIE_LIFE, "", "", (strpos($this->applicationURL, "https://") === 0), true ); header("Location: {$this->serverURL}/requestauth?requestkey={$this->key}"); exit; } /** * @brief Logout from Tequila server * * @param $redirectUrl - (optional) URL to redirect to after logout */ public function logout(string $redirectUrl = "") { $this->log(__FUNCTION__ . "(...)"); // Delete cookie by setting expiration time in the past with root path setcookie(self::COOKIE_NAME, "", time() - 3600); unset($_SESSION[self::SESSION_KEY]); unset($_SESSION[self::SESSION_CREATION]); $this->contactServer("logout"); $redirectUrl = empty($redirectUrl) ? $this->applicationURL : urlencode($redirectUrl); header("Location: {$this->serverURL}/logout?urlaccess={$redirectUrl}"); unset($this->key); } /** * @brief Sends an authentication request * * @param $wantedAttributes - (optional) List of attributes about the user that the server will return * @param $filters - (optional) Filters that will be applied to the user attributes * @param $authorised - (optional) Tequila server restrictions to lift * * @return Key returned by the Tequila server */ private function createRequest( array $wantedAttributes = [], string $filters = "", string $authorised = "" ) : string { - $this->log(__FUNCTION__ . "(...)"); $body = []; $body["urlaccess"] = $this->applicationURL; $body["dontappendkey"] = "1"; $body["language"] = $this->language; $body["service"] = $this->applicationName; $body["request"] = implode(self::BODY_GLUE, $wantedAttributes); $body["allows"] = $filters; $body["require"] = $authorised; $res = $this->contactServer('createrequest', $body); - preg_match('/^(?P\w+)=(?P\w+)\b$/', $res, $matches); - if ($matches["key"] == "key") { + preg_match('/^(?P\w+)=(?P\w+)\s*$/', $res, $matches); + if (!empty($matches["key"]) && $matches["key"] == "key") { + $this->log(__FUNCTION__ . "(...): ".$matches["value"]); return $matches["value"]; } + $this->log(__FUNCTION__ . "(...): got no requestkey"); throw new TequilaException("No requestkey obtained from createRequest"); } /** * @brief Retrieve the attributes of an authenticated user * (i.e. the ones requested when establishing an authentication) * * @param $key - the request key * * @return Array containing all the user attributes */ private function fetchAttributes(string $key, array $allowedRequestHosts = []) : array { $this->log(__FUNCTION__ . "(...)"); $body = []; $body["key"] = $key; if (!empty($allowedRequestHosts)) { $this->log("arh: ".var_export($allowedRequestHosts, true)); } $body["allowedrequesthosts"] = implode("|", $allowedRequestHosts); try { $res = $this->contactServer('fetchattributes', $body); } catch (TequilaException $e) { // fetchAttributes failed, return empty return []; } $result = []; $attributes = explode("\n", $res); foreach ($attributes as $attribute) { $attribute = trim($attribute); if (!$attribute) { continue; } list($key, $val) = explode("=", $attribute, 2); $result[$key] = $val; } return $result; } /** * @brief Sends a POST request to one of the servers endpoints * * @param $endpoint - the server endpoint to contact * @param $fields - (optional) the fields to add to the requests body * * @throws TequilaException if the server returns a code other than 200, that no connection could be established * or that we're trying to acces an unknow endpoint * * @return Body of the server response */ private function contactServer($endpoint, $fields = []) : string { // check if it's a valid endpoint if (!in_array($endpoint, self::SERVER_ENDPOINTS)) { throw new TequilaException("Unknown endpoint {$endpoint}"); } $this->log(__FUNCTION__ ."(".$endpoint.",".var_export($fields, true).")"); try { $client = new GuzzleHttp\Client([ 'base_uri' => $this->serverURL . "/", "handler" => $this->stack ]); /** * Note: First things first, sorry for the horrible code that follows. * So, the Tequila server doesn't understand/use normal POST requests and * only works cleartext body. */ $reqBody = []; if (is_array($fields) && count($fields)) { foreach ($fields as $key => $val) { $reqBody[] = "{$key}={$val}"; } } $response = $client->request("POST", $endpoint, [ "headers" => ["User-Agent" => "Tequila-PHP-Client/".self::VERSION], "body" => implode("\n", $reqBody) . "\n", ]); return $response->getBody(); } catch (GuzzleHttp\Exception\RequestException $e) { if (!$e->hasResponse()) { throw new TequilaException("No response from server : {$e->getMessage()}", 1, $e); } $response = $e->getResponse(); throw new TequilaException("Unexpected return from server : [{$response->getStatusCode()}] {$response->getReasonPhrase()}", 1, $e); } catch (GuzzleHttp\Exception\ConnectException $e) { throw new TequilaException("Connection to {$this->serverURL} server failed", 0, $e); } } /** * @brief Check if a session was previously established * * @return True if a session was previously established * @return False if no session was previously established */ private function preExistingSession() : bool { if (empty($_SESSION)) { return false; } if (empty($this->attributes)) { return false; } // check if the session hasn't expired. if ( !array_key_exists(self::SESSION_CREATION, $_SESSION) or (time() - $_SESSION[self::SESSION_CREATION]) > $this->timeout ) { return false; } if (!array_key_exists(self::SESSION_KEY, $_SESSION)) { return false; } $this->key = $_SESSION[self::SESSION_KEY]; return true; } /** * @brief Establish a new session * * @param $attributes - the user attributes returned by the server */ private function createSession(array $attributes) { $_SESSION[self::SESSION_CREATION] = time(); foreach ($attributes as $key => $val) { $this->attributes[$key] = $val; } if (array_key_exists("key", $attributes)) { $_SESSION[self::SESSION_KEY] = $attributes["key"]; } } /** * @brief Determine the applicationURL from $_SERVER */ private function serverApplicationURL() : string { global $_SERVER; $protocol = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on' ? "https://" : "http://"; $applicationURL = $protocol . $_SERVER['SERVER_NAME'] . ":" . $_SERVER['SERVER_PORT'] . $_SERVER['PHP_SELF']; if (!empty($_SERVER['PATH_INFO'])) { $applicationURL .= $_SERVER['PATH_INFO']; } if (!empty($_SERVER['QUERY_STRING'])) { $applicationURL .= "?" . $_SERVER['QUERY_STRING']; } $this->log(__FUNCTION__ ."(): {$applicationURL}"); return $applicationURL; } public function getKey() : string { return $this->key; } public function getAttributes() : array { return $this->attributes; } /** * If debug mode enabled * - * @codeCoverageIgnore * @return bool */ public function is_debugging() { return $this->debug; } /** * A debug function, dumps to the php log * * @codeCoverageIgnore * @param string $msg Log message */ private function log($msg) { if ($this->is_debugging()) { error_log('tequila-php-client: ' . $msg); } } } diff --git a/tests/TequilaClientTest.php b/tests/TequilaClientTest.php index 88e3349..e27c72c 100644 --- a/tests/TequilaClientTest.php +++ b/tests/TequilaClientTest.php @@ -1,171 +1,200 @@ // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation; version 2. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public Licensáe // along with this program. If not, see . use PHPUnit\Framework\TestCase; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Exception\ConnectException; require_once('tequila.php'); require_once("exception.php"); /** * @coversDefaultClass \TequilaClient * @uses \TequilaException */ final class TequilaClientTest extends TestCase { /** * Call protected/private method of a class. * * @param object &$object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ private function invokeNonPublicMethod(&$object, $methodName, array $parameters = array()) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } /** * @covers ::createRequest - * @covers ::contactServer + * @uses \TequilaClient::contactServer + * @uses \TequilaClient::is_debugging */ public function testCreateRequest() { $mock = new MockHandler([ new Response(200, ["X-Foo" => "Bar"], "key=veryRandomKey"), + new Response(200, ["X-Foo" => "Bar"], "key=veryRandomKeyWithTrailingSpace "), + new Response(200, ["X-Foo" => "Bar"], "otherhandle=thisShouldRaise"), ]); $client = new TequilaClient( "https://tequila.server.lo", 0, "Unit testing the client", "http://localhost/", TequilaClient::LANGUAGE_ENGLISH, $mock, ); $key = $this->invokeNonPublicMethod($client, "createRequest"); - $this->assertEquals($key, "veryRandomKey"); + + $key = $this->invokeNonPublicMethod($client, "createRequest"); + $this->assertEquals($key, "veryRandomKeyWithTrailingSpace"); + $this->expectException('\TequilaException'); + $this->invokeNonPublicMethod($client, "createRequest"); } /** * @covers ::fetchAttributes - * @covers ::contactServer + * @uses \TequilaClient::contactServer + * @uses \TequilaClient::is_debugging */ public function testFetchAttributes() { $mock = new MockHandler([ new Response(200, ["X-Foo" => "Bar"], "attr1=val1\nattr2=val2\nattr3=val3"), ]); $client = new TequilaClient( "https://tequila.server.lo", 0, "Unit testing the client", "http://localhost/", TequilaClient::LANGUAGE_ENGLISH, $mock ); $attributes = $this->invokeNonPublicMethod($client, "fetchAttributes", [ "veryRandomKey" ]); $this->assertEquals($attributes, [ "attr1" => "val1", "attr2" => "val2", "attr3" => "val3", ]); } /** * @covers ::contactServer */ public function testContactServerWithUnknownEndpoint() { $client = new TequilaClient( "https://tequila.server.lo", 0, "Unit testing the client", "http://localhost/" ); $this->expectException(TequilaException::class); $this->expectExceptionMessage("Unknown endpoint"); $this->invokeNonPublicMethod($client, "contactServer", [ "invalid endpoint" ]); } /** * @covers ::contactServer + * @uses \TequilaClient::is_debugging */ public function testContactServerWithUnknownServer() { $mock = new MockHandler([ new ConnectException('Error Communicating with Server', new Request('POST', 'fetchattributes')) ]); $client = new TequilaClient( "https://tequila.server.lo", 0, "Unit testing the client", "http://localhost/", TequilaClient::LANGUAGE_ENGLISH, $mock ); $this->expectException(TequilaException::class); $this->expectExceptionMessage("Connection to https://tequila.server.lo/cgi-bin/tequila server failed"); $this->invokeNonPublicMethod($client, "contactServer", [ "fetchattributes" ]); } /** * @covers ::contactServer + * @uses \TequilaClient::is_debugging */ public function testContactServerUnexpectedHTTPCode() { $mock = new MockHandler([ new Response(458, ["X-Foo" => "Bar"], ""), ]); $client = new TequilaClient( "https://tequila.server.lo", 0, "Unit testing the client", "http://localhost/", TequilaClient::LANGUAGE_ENGLISH, $mock ); $this->expectException(TequilaException::class); $this->expectExceptionMessage("Unexpected return from server"); $this->invokeNonPublicMethod($client, "contactServer", [ "fetchattributes" ]); } + + /** + * @covers ::is_debugging + */ + public function testIsDebugging() { + $client = new TequilaClient( + "https://tequila.server.lo", + 0, + "Unit testing the client", + "http://localhost/", + TequilaClient::LANGUAGE_ENGLISH, + null, + false, + ); + $this->assertFalse($client->is_debugging()); + + $client->debug = true; + $this->assertTrue($client->is_debugging()); + } } \ No newline at end of file