From cfbab31c429a5f10d6e81998ace48ed1c01802c9 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Wed, 9 May 2018 15:51:47 +0200 Subject: [PATCH 01/48] Update README.md --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b40012e..88b016f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ Single Sign-On for PHP (Ajax compatible) Jasny\SSO is a relatively simply and straightforward solution for an single sign on (SSO) implementation. With SSO, logging into a single website will authenticate you for all affiliate sites. -#### How it works +**This project has been adopted and will be actively supported by [LegalThings](https://legalthings.io/).** + +### How it works When using SSO, when can distinguish 3 parties: @@ -27,7 +29,7 @@ using the same session. When another broker joins in, it will also use the same For a more indepth explanation, please [read this article](https://github.com/jasny/sso/wiki). -#### How is this different from OAuth? +### How is this different from OAuth? With OAuth, you can authenticate a user at an external server and get access to their profile info. However you aren't sharing a session. @@ -46,7 +48,7 @@ Install this library through composer ## Usage -#### Server +### Server `Jasny\SSO\Server` is an abstract class. You need to create a your own class which implements the abstract methods. These methods are called fetch data from a data souce (like a DB). @@ -97,7 +99,7 @@ This will make the object throw a Jasny\SSO\Exception, rather than set the HTTP For more information, checkout the `server` example. -#### Broker +### Broker When creating a Jasny\SSO\Broker instance, you need to pass the server url, broker id and broker secret. The broker id and secret needs to be registered at the server (so fetched when using `getBrokerInfo($brokerId)`). From 9cd8be17059050fa3334cb4a45d6f417495ce31a Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Mon, 30 Sep 2019 09:25:54 +0200 Subject: [PATCH 02/48] Update README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 88b016f..e120658 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ Single Sign-On for PHP (Ajax compatible) Jasny\SSO is a relatively simply and straightforward solution for an single sign on (SSO) implementation. With SSO, logging into a single website will authenticate you for all affiliate sites. -**This project has been adopted and will be actively supported by [LegalThings](https://legalthings.io/).** - ### How it works When using SSO, when can distinguish 3 parties: From e290ec6807cdda7e5ea04ae644bcb76ced40fcb9 Mon Sep 17 00:00:00 2001 From: lujiaming Date: Sat, 5 Oct 2019 21:57:11 +0800 Subject: [PATCH 03/48] fixed compatiblity problem: getallheaders undefined (#112) --- src/Server.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Server.php b/src/Server.php index 21f28d6..1dc9a1b 100644 --- a/src/Server.php +++ b/src/Server.php @@ -96,7 +96,16 @@ public function startBrokerSession() */ protected function getBrokerSessionID() { - $headers = getallheaders(); + if (!function_exists('getallheaders')) { + $headers = array(); + foreach ($_SERVER as $name => $value) { + if (substr($name, 0, 5) == 'HTTP_') { + $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value; + } + } + } else { + $headers = getallheaders(); + } if (isset($headers['Authorization']) && strpos($headers['Authorization'], 'Bearer') === 0) { $headers['Authorization'] = substr($headers['Authorization'], 7); From 4eb94797e437b856047414f2ae805ecd541f443b Mon Sep 17 00:00:00 2001 From: Ruben Stolk Date: Thu, 7 May 2020 11:49:42 +0200 Subject: [PATCH 04/48] Implement initializeSession method, implements #113 (#114) --- src/Server.php | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/Server.php b/src/Server.php index 1dc9a1b..4665651 100644 --- a/src/Server.php +++ b/src/Server.php @@ -179,13 +179,13 @@ protected function generateSessionId($brokerId, $token) * @param string $token * @return string */ - protected function generateAttachChecksum($brokerId, $token) + protected function generateChecksum($command, $brokerId, $token) { $broker = $this->getBrokerInfo($brokerId); if (!isset($broker)) return null; - return hash('sha256', 'attach' . $token . $broker['secret']); + return hash('sha256', $command . $token . $broker['secret']); } @@ -206,6 +206,26 @@ protected function detectReturnType() } } + /** + * Initialize a user session and redirect back + */ + public function initializeSession() + { + if (empty($_REQUEST['redirect'])) return $this->fail("No redirect specified", 400); + if (empty($_REQUEST['broker'])) return $this->fail("No broker specified", 400); + + $checksum = $this->generateChecksum('initializeSession', $_REQUEST['broker'], $_REQUEST['redirect']); + + if (empty($_REQUEST['checksum']) || $checksum != $_REQUEST['checksum']) { + return $this->fail("Invalid checksum", 400); + } + + $this->startUserSession(); + + echo "One moment please ..."; + exit; + } + /** * Attach a user session to a broker session */ @@ -218,7 +238,7 @@ public function attach() if (!$this->returnType) return $this->fail("No return url specified", 400); - $checksum = $this->generateAttachChecksum($_REQUEST['broker'], $_REQUEST['token']); + $checksum = $this->generateChecksum('attach', $_REQUEST['broker'], $_REQUEST['token']); if (empty($_REQUEST['checksum']) || $checksum != $_REQUEST['checksum']) { return $this->fail("Invalid checksum", 400); From 53e822715834a8b92e8dd974635ea6425426a7e5 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Wed, 15 Jul 2020 14:13:13 +0200 Subject: [PATCH 05/48] Version 0.4 WIP PHP7.2+ Improved architecture. Optional support for PSR-7. PSR-16 for caching. --- .gitattributes | 9 + .scrutinizer.yml | 24 ++ .travis.yml | 44 +++ LICENSE | 2 +- composer.json | 13 +- {examples => demo}/ajax-broker/api.php | 0 {examples => demo}/ajax-broker/app.js | 0 {examples => demo}/ajax-broker/index.html | 0 {examples => demo}/broker/error.php | 0 {examples => demo}/broker/index.php | 2 +- {examples => demo}/broker/login.php | 2 +- demo/server/api/info.php | 40 ++ demo/server/api/login.php | 47 +++ demo/server/api/logout.php | 32 ++ demo/server/attach.php | 98 +++++ demo/server/config.php | 28 ++ examples/server/.htaccess | 6 - examples/server/MySSOServer.php | 90 ----- examples/server/index.php | 18 - phpcs.xml | 12 + phpstan.neon | 7 + src/{ => Broker}/Broker.php | 44 +-- src/Broker/NotAttachedException.php | 12 + src/Exception.php | 9 - src/NotAttachedException.php | 11 - src/Server.php | 432 ---------------------- src/Server/BrokerException.php | 13 + src/Server/ExceptionInterface.php | 9 + src/Server/GlobalSession.php | 70 ++++ src/Server/Server.php | 203 ++++++++++ src/Server/ServerException.php | 13 + src/Server/SessionInterface.php | 47 +++ 32 files changed, 738 insertions(+), 599 deletions(-) create mode 100644 .gitattributes create mode 100644 .scrutinizer.yml create mode 100644 .travis.yml rename {examples => demo}/ajax-broker/api.php (100%) rename {examples => demo}/ajax-broker/app.js (100%) rename {examples => demo}/ajax-broker/index.html (100%) rename {examples => demo}/broker/error.php (100%) rename {examples => demo}/broker/index.php (96%) rename {examples => demo}/broker/login.php (98%) create mode 100644 demo/server/api/info.php create mode 100644 demo/server/api/login.php create mode 100644 demo/server/api/logout.php create mode 100644 demo/server/attach.php create mode 100644 demo/server/config.php delete mode 100644 examples/server/.htaccess delete mode 100644 examples/server/MySSOServer.php delete mode 100644 examples/server/index.php create mode 100644 phpcs.xml create mode 100644 phpstan.neon rename src/{ => Broker}/Broker.php (83%) create mode 100644 src/Broker/NotAttachedException.php delete mode 100644 src/Exception.php delete mode 100644 src/NotAttachedException.php delete mode 100644 src/Server.php create mode 100644 src/Server/BrokerException.php create mode 100644 src/Server/ExceptionInterface.php create mode 100644 src/Server/GlobalSession.php create mode 100644 src/Server/Server.php create mode 100644 src/Server/ServerException.php create mode 100644 src/Server/SessionInterface.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1b0faf3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.scrutinizer.yml export-ignore +/.travis.yml export-ignore +/phpunit.xml.dist export-ignore +/phpcs.xml.dist export-ignore +/phpstan.neon export-ignore +/README.md export-ignore diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..985ff19 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,24 @@ +#language: php +checks: + php: true +filter: + excluded_paths: + - tests +build: + nodes: + analysis: + environment: + php: 7.4 + postgresql: false + redis: false + mongodb: false + tests: + override: + - phpcs-run src + - + command: vendor/bin/phpstan analyze --error-format=checkstyle | sed '/^\s*$/d' > phpstan-checkstyle.xml + analysis: + file: phpstan-checkstyle.xml + format: 'general-checkstyle' + - php-scrutinizer-run + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..9edfaea --- /dev/null +++ b/.travis.yml @@ -0,0 +1,44 @@ +language: php + +php: + - 7.2 + - 7.3 + - 7.4 + - nightly + +matrix: + allow_failures: + - php: nightly + +sudo: false + +cache: + directories: + - $HOME/.composer/cache/files + +branches: + only: + - master + - travis + +before_install: + - test "$TRAVIS_PHP_VERSION" != "nightly" || export COMPOSER_FLAGS="$COMPOSER_FLAGS --ignore-platform-reqs" + +install: + - composer install --prefer-source $COMPOSER_FLAGS + - wget https://scrutinizer-ci.com/ocular.phar -O "$HOME/ocular.phar" + +before_script: | + if (php -m | grep -q -i xdebug); then + export PHPUNIT_FLAGS="--coverage-clover cache/logs/clover.xml" + else + export PHPUNIT_FLAGS="--no-coverage" + fi + +script: + - vendor/bin/phpunit $PHPUNIT_FLAGS + +after_script: + - test "$PHPUNIT_FLAGS" == "--no-coverage" || vendor/bin/infection --only-covered --no-progress --no-interaction --threads=4 + - test "$PHPUNIT_FLAGS" == "--no-coverage" || php "$HOME/ocular.phar" code-coverage:upload --format=php-clover cache/logs/clover.xml + diff --git a/LICENSE b/LICENSE index d024b32..ae4f8c8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2017 Arnold Daniels +Copyright (c) 2020 Arnold Daniels Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation diff --git a/composer.json b/composer.json index 616fbe2..62e019c 100644 --- a/composer.json +++ b/composer.json @@ -16,13 +16,15 @@ "source": "https://github.com/jasny/sso" }, "require": { - "php": ">=5.5.0", - "desarrolla2/cache": "^2.0.0", - "jasny/validation-result": "^1.0.0" + "php": ">=7.4.0", + "ext-json": "*", + "psr/simple-cache": "^1.0", + "jasny/immutable": "^2.1" }, "require-dev": { - "codeception/codeception": "^2.1.0", - "jasny/php-code-quality": "^1.1.0" + "codeception/codeception": "^4.0", + "jasny/php-code-quality": "^2.6.0", + "desarrolla2/cache": "^3.0" }, "autoload": { "psr-4": { @@ -30,4 +32,3 @@ } } } - diff --git a/examples/ajax-broker/api.php b/demo/ajax-broker/api.php similarity index 100% rename from examples/ajax-broker/api.php rename to demo/ajax-broker/api.php diff --git a/examples/ajax-broker/app.js b/demo/ajax-broker/app.js similarity index 100% rename from examples/ajax-broker/app.js rename to demo/ajax-broker/app.js diff --git a/examples/ajax-broker/index.html b/demo/ajax-broker/index.html similarity index 100% rename from examples/ajax-broker/index.html rename to demo/ajax-broker/index.html diff --git a/examples/broker/error.php b/demo/broker/error.php similarity index 100% rename from examples/broker/error.php rename to demo/broker/error.php diff --git a/examples/broker/index.php b/demo/broker/index.php similarity index 96% rename from examples/broker/index.php rename to demo/broker/index.php index a3a2bf8..efece6a 100644 --- a/examples/broker/index.php +++ b/demo/broker/index.php @@ -1,6 +1,6 @@ getMessage(); } diff --git a/demo/server/api/info.php b/demo/server/api/info.php new file mode 100644 index 0000000..73843ff --- /dev/null +++ b/demo/server/api/info.php @@ -0,0 +1,40 @@ + $config['brokers'][$id] ?? null, // Callback to get the broker secret. You might fetch this from DB. + new FileCache(), // Any PSR-16 compatible cache +); + +// Start the session using the broker bearer token (rather than a session cookie). +$ssoServer->startBrokerSession(); + +// No user is logged in; respond with a 401 +if (!isset($_SESSION['user'])) { + http_response_code(401); + header('Content-Type: text/plain'); + echo "User not logged in"; + exit(); +} + +// Output user info as JSON. +$info = ['username' => $username] + $config['users'][$username]; +unset($info['password']); + +header('Content-Type: application/json'); +echo json_encode($info); diff --git a/demo/server/api/login.php b/demo/server/api/login.php new file mode 100644 index 0000000..930daa5 --- /dev/null +++ b/demo/server/api/login.php @@ -0,0 +1,47 @@ + $config['brokers'][$id] ?? null, // Callback to get the broker secret. You might fetch this from DB. + new FileCache(), // Any PSR-16 compatible cache +); + +// Start the session using the broker bearer token (rather than a session cookie). +$ssoServer->startBrokerSession(); + +// Authenticate the user. +if (!isset($config['users'][$username]) || !password_verify($password, $config['users'][$username]['password'])) { + http_response_code(400); + header('Content-Type: text/plain'); + echo "Invalid credentials"; + exit(); +} + +// Store the current user in the session. +$_SESSION['user'] = $username; + +// Output user info as JSON. +$info = ['username' => $username] + $config['users'][$username]; +unset($info['password']); + +header('Content-Type: application/json'); +echo json_encode($info); diff --git a/demo/server/api/logout.php b/demo/server/api/logout.php new file mode 100644 index 0000000..b1452f5 --- /dev/null +++ b/demo/server/api/logout.php @@ -0,0 +1,32 @@ + $config['brokers'][$id] ?? null, // Callback to get the broker secret. You might fetch this from DB. + new FileCache(), // Any PSR-16 compatible cache +); + +// Start the session using the broker bearer token (rather than a session cookie). +$ssoServer->startBrokerSession(); + +// Clear the session user. +unset($_SESSION['user']); diff --git a/demo/server/attach.php b/demo/server/attach.php new file mode 100644 index 0000000..59dfb15 --- /dev/null +++ b/demo/server/attach.php @@ -0,0 +1,98 @@ + $config['brokers'][$id] ?? null, // Callback to get the broker secret. You might fetch this from DB. + new FileCache(), // Any PSR-16 compatible cache +); + +try { + // Attach the broker token to the user session. Uses query parameters from $_GET. + $ssoServer->attach(); +} catch (SSOException $exception) { + // Something went wrong. Output the error as a 4xx or 5xx response. + http_response_code($exception->getCode()); + header('Content-Type: text/plain'); + echo $exception; + exit(); +} + +// ------ + +// The token is attached; output 'success'. +// In this demo we support multiple types of attaching the session. If you choose to support only one method, +// you don't need to detect the return type. +switch (detect_return_type()) { + case 'json': + header('Content-type: application/json'); + echo json_encode(['success' => 'attached']); + break; + + case 'jsonp': + header('Content-type: application/javascript'); + $data = json_encode(['success' => 'attached']); + echo $_REQUEST['callback'] . "($data, 200);"; + break; + + case 'image': + // Output a 1x1px transparent image + header('Content-Type: image/png'); + echo base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZg' + . 'AAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII='); + break; + + case 'redirect': + $url = $_GET['return_url'] ?? $_SERVER['HTTP_REFERER']; + header('Location: ' . $url); + echo "You're being redirected to $url"; + break; + + default: + http_response_code(400); + header('Content-Type: text/plain'); + echo "Unable to detect return type"; + break; +} + +/** + * Detect the type for the HTTP response. + */ +function detect_return_type(): ?string +{ + if (isset($_GET['return_url'])) { + return 'redirect'; + } + + if (isset($_GET['callback'])) { + return 'jsonp'; + } + + if (strpos($_SERVER['HTTP_ACCEPT'], 'image/') !== false) { + return 'image'; + } + + if (strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) { + return 'json'; + } + + if (isset($_GET['HTTP_REFERER'])) { + return 'redirect'; + } + + return null; +} diff --git a/demo/server/config.php b/demo/server/config.php new file mode 100644 index 0000000..2702113 --- /dev/null +++ b/demo/server/config.php @@ -0,0 +1,28 @@ + [ + 'Alice' => '8iwzik1bwd', + 'Greg' => '7pypoox2pc', + 'Julias' => 'ceda63kmhp', + ], + 'users' => [ + 'jackie' => [ + 'fullname' => 'Jackie Black', + 'email' => 'jackie.black@example.com', + 'password' => '$2y$10$lVUeiphXLAm4pz6l7lF9i.6IelAqRxV4gCBu8GBGhCpaRb6o0qzUO' // jackie123 + ], + 'john' => [ + 'fullname' => 'John Doe', + 'email' => 'john.doe@example.com', + 'password' => '$2y$10$RU85KDMhbh8pDhpvzL6C5.kD3qWpzXARZBzJ5oJ2mFoW7Ren.apC2' // john123 + ], + ], +]; diff --git a/examples/server/.htaccess b/examples/server/.htaccess deleted file mode 100644 index 7bb4b5b..0000000 --- a/examples/server/.htaccess +++ /dev/null @@ -1,6 +0,0 @@ -RewriteEngine On - -RewriteCond %{REQUEST_FILENAME} !-d -RewriteCond %{REQUEST_FILENAME} !-f -RewriteRule (.+) index.php?command=$1 [L] - diff --git a/examples/server/MySSOServer.php b/examples/server/MySSOServer.php deleted file mode 100644 index ca523b0..0000000 --- a/examples/server/MySSOServer.php +++ /dev/null @@ -1,90 +0,0 @@ - ['secret'=>'8iwzik1bwd'], - 'Greg' => ['secret'=>'7pypoox2pc'], - 'Julias' => ['secret'=>'ceda63kmhp'] - ]; - - /** - * System users - * @var array - */ - private static $users = array ( - 'jackie' => [ - 'fullname' => 'Jackie Black', - 'email' => 'jackie.black@example.com', - 'password' => '$2y$10$lVUeiphXLAm4pz6l7lF9i.6IelAqRxV4gCBu8GBGhCpaRb6o0qzUO' // jackie123 - ], - 'john' => [ - 'fullname' => 'John Doe', - 'email' => 'john.doe@example.com', - 'password' => '$2y$10$RU85KDMhbh8pDhpvzL6C5.kD3qWpzXARZBzJ5oJ2mFoW7Ren.apC2' // john123 - ], - ); - - /** - * Get the API secret of a broker and other info - * - * @param string $brokerId - * @return array - */ - protected function getBrokerInfo($brokerId) - { - return isset(self::$brokers[$brokerId]) ? self::$brokers[$brokerId] : null; - } - - /** - * Authenticate using user credentials - * - * @param string $username - * @param string $password - * @return ValidationResult - */ - protected function authenticate($username, $password) - { - if (!isset($username)) { - return ValidationResult::error("username isn't set"); - } - - if (!isset($password)) { - return ValidationResult::error("password isn't set"); - } - - if (!isset(self::$users[$username]) || !password_verify($password, self::$users[$username]['password'])) { - return ValidationResult::error("Invalid credentials"); - } - - return ValidationResult::success(); - } - - - /** - * Get the user information - * - * @return array - */ - protected function getUserInfo($username) - { - if (!isset(self::$users[$username])) return null; - - $user = compact('username') + self::$users[$username]; - unset($user['password']); - - return $user; - } -} diff --git a/examples/server/index.php b/examples/server/index.php deleted file mode 100644 index 5416eb9..0000000 --- a/examples/server/index.php +++ /dev/null @@ -1,18 +0,0 @@ - 'Unknown command']); - exit(); -} - -$result = $ssoServer->$command(); - diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..5897613 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,12 @@ + + + The Jasny coding standard. + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..12d99a6 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: 7 + paths: + - src + reportUnmatchedIgnoredErrors: false +includes: + - vendor/phpstan/phpstan-strict-rules/rules.neon diff --git a/src/Broker.php b/src/Broker/Broker.php similarity index 83% rename from src/Broker.php rename to src/Broker/Broker.php index 98b2ae9..a33243e 100644 --- a/src/Broker.php +++ b/src/Broker/Broker.php @@ -1,5 +1,8 @@ url = $url; $this->broker = $broker; $this->secret = $secret; - $this->cookie_lifetime = $cookie_lifetime; - - if (isset($_COOKIE[$this->getCookieName()])) $this->token = $_COOKIE[$this->getCookieName()]; + $this->cookieTtl = $cookieTtl; } /** @@ -133,13 +131,12 @@ public function getAttachUrl($params = []) $this->generateToken(); $data = [ - 'command' => 'attach', 'broker' => $this->broker, 'token' => $this->token, 'checksum' => hash('sha256', 'attach' . $this->token . $this->secret) ] + $_GET; - return $this->url . "?" . http_build_query($data + $params); + return $this->url . "/attach.php?" . http_build_query($data + $params); } /** @@ -173,8 +170,7 @@ public function attach($returnUrl = null) */ protected function getRequestUrl($command, $params = []) { - $params['command'] = $command; - return $this->url . '?' . http_build_query($params); + return $this->url . '/api/' . $command . '.php?' . http_build_query($params); } /** @@ -205,7 +201,7 @@ protected function request($method, $command, $data = null) $response = curl_exec($ch); if (curl_errno($ch) != 0) { $message = 'Server request failed: ' . curl_error($ch); - throw new Exception($message); + throw new BrokerException($message); } $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); @@ -213,7 +209,7 @@ protected function request($method, $command, $data = null) if ($contentType != 'application/json') { $message = 'Expected application/json response, got ' . $contentType; - throw new Exception($message); + throw new BrokerException($message); } $data = json_decode($response, true); @@ -221,7 +217,7 @@ protected function request($method, $command, $data = null) $this->clearToken(); throw new NotAttachedException($data['error'] ?: $response, $httpCode); } - if ($httpCode >= 400) throw new Exception($data['error'] ?: $response, $httpCode); + if ($httpCode >= 400) throw new BrokerException($data['error'] ?: $response, $httpCode); return $data; } @@ -236,7 +232,7 @@ protected function request($method, $command, $data = null) * @param string $username * @param string $password * @return array user info - * @throws Exception if login fails eg due to incorrect credentials + * @throws BrokerException if login fails eg due to incorrect credentials */ public function login($username = null, $password = null) { @@ -254,7 +250,7 @@ public function login($username = null, $password = null) */ public function logout() { - $this->request('POST', 'logout', 'logout'); + $this->request('POST', 'logout'); } /** @@ -265,7 +261,7 @@ public function logout() public function getUserInfo() { if (!isset($this->userinfo)) { - $this->userinfo = $this->request('GET', 'userInfo'); + $this->userinfo = $this->request('GET', 'info'); } return $this->userinfo; diff --git a/src/Broker/NotAttachedException.php b/src/Broker/NotAttachedException.php new file mode 100644 index 0000000..ffcb9d1 --- /dev/null +++ b/src/Broker/NotAttachedException.php @@ -0,0 +1,12 @@ + '/tmp', 'files_cache_ttl' => 36000]; - - /** - * Cache that stores the special session data for the brokers. - * - * @var Cache - */ - protected $cache; - - /** - * @var string - */ - protected $returnType; - - /** - * @var mixed - */ - protected $brokerId; - - - /** - * Class constructor - * - * @param array $options - */ - public function __construct(array $options = []) - { - $this->options = $options + $this->options; - $this->cache = $this->createCacheAdapter(); - } - - /** - * Create a cache to store the broker session id. - * - * @return Cache - */ - protected function createCacheAdapter() - { - $adapter = new Adapter\File($this->options['files_cache_directory']); - $adapter->setOption('ttl', $this->options['files_cache_ttl']); - - return new Cache($adapter); - } - - /** - * Start the session for broker requests to the SSO server - */ - public function startBrokerSession() - { - if (isset($this->brokerId)) return; - - $sid = $this->getBrokerSessionID(); - - if ($sid === false) { - return $this->fail("Broker didn't send a session key", 400); - } - - $linkedId = $this->cache->get($sid); - - if (!$linkedId) { - return $this->fail("The broker session id isn't attached to a user session", 403); - } - - if (session_status() === PHP_SESSION_ACTIVE) { - if ($linkedId !== session_id()) throw new \Exception("Session has already started", 400); - return; - } - - session_id($linkedId); - session_start(); - - $this->brokerId = $this->validateBrokerSessionId($sid); - } - - /** - * Get session ID from header Authorization or from $_GET/$_POST - */ - protected function getBrokerSessionID() - { - if (!function_exists('getallheaders')) { - $headers = array(); - foreach ($_SERVER as $name => $value) { - if (substr($name, 0, 5) == 'HTTP_') { - $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value; - } - } - } else { - $headers = getallheaders(); - } - - if (isset($headers['Authorization']) && strpos($headers['Authorization'], 'Bearer') === 0) { - $headers['Authorization'] = substr($headers['Authorization'], 7); - return $headers['Authorization']; - } - if (isset($_GET['access_token'])) { - return $_GET['access_token']; - } - if (isset($_POST['access_token'])) { - return $_POST['access_token']; - } - if (isset($_GET['sso_session'])) { - return $_GET['sso_session']; - } - - return false; - } - - /** - * Validate the broker session id - * - * @param string $sid session id - * @return string the broker id - */ - protected function validateBrokerSessionId($sid) - { - $matches = null; - - if (!preg_match('/^SSO-(\w*+)-(\w*+)-([a-z0-9]*+)$/', $this->getBrokerSessionID(), $matches)) { - return $this->fail("Invalid session id"); - } - - $brokerId = $matches[1]; - $token = $matches[2]; - - if ($this->generateSessionId($brokerId, $token) != $sid) { - return $this->fail("Checksum failed: Client IP address may have changed", 403); - } - - return $brokerId; - } - - /** - * Start the session when a user visits the SSO server - */ - protected function startUserSession() - { - if (session_status() !== PHP_SESSION_ACTIVE) session_start(); - } - - /** - * Generate session id from session token - * - * @param string $brokerId - * @param string $token - * @return string - */ - protected function generateSessionId($brokerId, $token) - { - $broker = $this->getBrokerInfo($brokerId); - - if (!isset($broker)) return null; - - return "SSO-{$brokerId}-{$token}-" . hash('sha256', 'session' . $token . $broker['secret']); - } - - /** - * Generate session id from session token - * - * @param string $brokerId - * @param string $token - * @return string - */ - protected function generateChecksum($command, $brokerId, $token) - { - $broker = $this->getBrokerInfo($brokerId); - - if (!isset($broker)) return null; - - return hash('sha256', $command . $token . $broker['secret']); - } - - - /** - * Detect the type for the HTTP response. - * Should only be done for an `attach` request. - */ - protected function detectReturnType() - { - if (!empty($_GET['return_url'])) { - $this->returnType = 'redirect'; - } elseif (!empty($_GET['callback'])) { - $this->returnType = 'jsonp'; - } elseif (strpos($_SERVER['HTTP_ACCEPT'], 'image/') !== false) { - $this->returnType = 'image'; - } elseif (strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) { - $this->returnType = 'json'; - } - } - - /** - * Initialize a user session and redirect back - */ - public function initializeSession() - { - if (empty($_REQUEST['redirect'])) return $this->fail("No redirect specified", 400); - if (empty($_REQUEST['broker'])) return $this->fail("No broker specified", 400); - - $checksum = $this->generateChecksum('initializeSession', $_REQUEST['broker'], $_REQUEST['redirect']); - - if (empty($_REQUEST['checksum']) || $checksum != $_REQUEST['checksum']) { - return $this->fail("Invalid checksum", 400); - } - - $this->startUserSession(); - - echo "One moment please ..."; - exit; - } - - /** - * Attach a user session to a broker session - */ - public function attach() - { - $this->detectReturnType(); - - if (empty($_REQUEST['broker'])) return $this->fail("No broker specified", 400); - if (empty($_REQUEST['token'])) return $this->fail("No token specified", 400); - - if (!$this->returnType) return $this->fail("No return url specified", 400); - - $checksum = $this->generateChecksum('attach', $_REQUEST['broker'], $_REQUEST['token']); - - if (empty($_REQUEST['checksum']) || $checksum != $_REQUEST['checksum']) { - return $this->fail("Invalid checksum", 400); - } - - $this->startUserSession(); - $sid = $this->generateSessionId($_REQUEST['broker'], $_REQUEST['token']); - - $this->cache->set($sid, $this->getSessionData('id')); - $this->outputAttachSuccess(); - } - - /** - * Output on a successful attach - */ - protected function outputAttachSuccess() - { - if ($this->returnType === 'image') { - $this->outputImage(); - } - - if ($this->returnType === 'json') { - header('Content-type: application/json; charset=UTF-8'); - echo json_encode(['success' => 'attached']); - } - - if ($this->returnType === 'jsonp') { - $data = json_encode(['success' => 'attached']); - echo $_REQUEST['callback'] . "($data, 200);"; - } - - if ($this->returnType === 'redirect') { - $url = $_REQUEST['return_url']; - header("Location: $url", true, 307); - echo "You're being redirected to $url"; - } - } - - /** - * Output a 1x1px transparent image - */ - protected function outputImage() - { - header('Content-Type: image/png'); - echo base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQ' - . 'MAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZg' - . 'AAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII='); - } - - - /** - * Authenticate - */ - public function login() - { - $this->startBrokerSession(); - - if (empty($_POST['username'])) $this->fail("No username specified", 400); - if (empty($_POST['password'])) $this->fail("No password specified", 400); - - $validation = $this->authenticate($_POST['username'], $_POST['password']); - - if ($validation->failed()) { - return $this->fail($validation->getError(), 400); - } - - $this->setSessionData('sso_user', $_POST['username']); - $this->userInfo(); - } - - /** - * Log out - */ - public function logout() - { - $this->startBrokerSession(); - $this->setSessionData('sso_user', null); - - header('Content-type: application/json; charset=UTF-8'); - http_response_code(204); - } - - /** - * Ouput user information as json. - */ - public function userInfo() - { - $this->startBrokerSession(); - $user = null; - - $username = $this->getSessionData('sso_user'); - - if ($username) { - $user = $this->getUserInfo($username); - if (!$user) return $this->fail("User not found", 500); // Shouldn't happen - } - - header('Content-type: application/json; charset=UTF-8'); - echo json_encode($user); - } - - - /** - * Set session data - * - * @param string $key - * @param string $value - */ - protected function setSessionData($key, $value) - { - if (!isset($value)) { - unset($_SESSION[$key]); - return; - } - - $_SESSION[$key] = $value; - } - - /** - * Get session data - * - * @param type $key - */ - protected function getSessionData($key) - { - if ($key === 'id') return session_id(); - - return isset($_SESSION[$key]) ? $_SESSION[$key] : null; - } - - - /** - * An error occured. - * - * @param string $message - * @param int $http_status - */ - protected function fail($message, $http_status = 500) - { - if (!empty($this->options['fail_exception'])) { - throw new Exception($message, $http_status); - } - - if ($http_status === 500) trigger_error($message, E_USER_WARNING); - - if ($this->returnType === 'jsonp') { - echo $_REQUEST['callback'] . "(" . json_encode(['error' => $message]) . ", $http_status);"; - exit(); - } - - if ($this->returnType === 'redirect') { - $url = $_REQUEST['return_url'] . '?sso_error=' . $message; - header("Location: $url", true, 307); - echo "You're being redirected to $url"; - exit(); - } - - http_response_code($http_status); - header('Content-type: application/json; charset=UTF-8'); - - echo json_encode(['error' => $message]); - exit(); - } - - - /** - * Authenticate using user credentials - * - * @param string $username - * @param string $password - * @return \Jasny\ValidationResult - */ - abstract protected function authenticate($username, $password); - - /** - * Get the secret key and other info of a broker - * - * @param string $brokerId - * @return array - */ - abstract protected function getBrokerInfo($brokerId); - - /** - * Get the information about a user - * - * @param string $username - * @return array|object - */ - abstract protected function getUserInfo($username); -} - diff --git a/src/Server/BrokerException.php b/src/Server/BrokerException.php new file mode 100644 index 0000000..a41f143 --- /dev/null +++ b/src/Server/BrokerException.php @@ -0,0 +1,13 @@ + $options Options passed to session_start(). + */ + public function __construct(array $options = []) + { + $this->options = $options; + } + + /** + * @inheritDoc + */ + public function getId(): string + { + return session_id(); + } + + /** + * @inheritDoc + */ + public function start(?string $id = null): void + { + $started = ($id === null || (bool)session_id($id)) + && session_start($this->options); + + if (!$started) { + throw new ServerException(error_get_last()['message'], 500); + } + } + + /** + * @inheritDoc + */ + public function isActive(): bool + { + return session_status() === PHP_SESSION_ACTIVE; + } + + + /** + * @inheritDoc + */ + public function get(string $key) + { + return $_SESSION[$key] ?? null; + } + + /** + * @inheritDoc + */ + public function set(string $key, $value): void + { + $_SESSION[$key] = $value; + } +} diff --git a/src/Server/Server.php b/src/Server/Server.php new file mode 100644 index 0000000..fe9953f --- /dev/null +++ b/src/Server/Server.php @@ -0,0 +1,203 @@ +getBrokerSecret = \Closure::fromCallable($getBrokerSecret); + $this->auth = $auth; + $this->cache = $cache; + + $this->session = new GlobalSession(); + } + + /** + * Get a copy of the service with a custom session service. + */ + public function withSession(SessionInterface $session): self + { + return $this->withProperty('session', $session); + } + + + /** + * Start the session for broker requests to the SSO server. + * + * @throws BrokerException + * @throws ServerException + */ + public function startBrokerSession(?ServerRequestInterface $request = null): void + { + if ($this->session->isActive()) { + throw new ServerException("Session is already started", 400); + } + + $bearer = $this->getBearerToken($request); + + if ($bearer === null) { + throw new BrokerException("Broker didn't use bearer authentication", 401); + } + + try { + $linkedId = $this->cache->get($bearer); + } catch (\Exception $exception) { + throw new ServerException("Failed to get session id from cache", 500, $exception); + } + + if (!$linkedId) { + throw new BrokerException("Bearer token isn't attached to a user session", 403); + } + + $this->brokerId = $this->getBrokerIdFromBearer($bearer); + $this->session->start($linkedId); + } + + /** + * Get bearer token from Authorization header. + */ + protected function getBearerToken(?ServerRequestInterface $request = null): ?string + { + $authorization = $request === null + ? ($_SERVER['HTTP_AUTHORIZATION'] ?? '') + : $request->getHeaderLine('Authorization'); + + return strpos($authorization, 'Bearer') === 0 + ? substr($authorization, 7) + : null; + } + + /** + * Get the broker id from the bearer token used by the broker. + */ + protected function getBrokerIdFromBearer(string $bearer): string + { + $matches = null; + + if (!(bool)preg_match('/^SSO-(\w*+)-(\w*+)-([a-z0-9]*+)$/', $bearer, $matches)) { + throw new BrokerException("Invalid session id"); + } + + $brokerId = $matches[1]; + $token = $matches[2]; + + if ($this->generateBearerToken($brokerId, $token) != $bearer) { + throw new BrokerException("Bearer token checksum failed", 403); + } + + return $brokerId; + } + + /** + * Generate session id from session token. + */ + protected function generateBearerToken(string $brokerId, string $token): string + { + return "SSO-{$brokerId}-{$token}-" . $this->generateChecksum('session', $brokerId, $token); + } + + /** + * Generate checksum for a broker. + */ + protected function generateChecksum(string $command, string $brokerId, string $token): string + { + $secret = ($this->getBrokerSecret)($brokerId); + + if ($secret === null) { + throw new BrokerException("Invalid broker id", 400); + } + + $checksum = hash_hmac('sha256', $command . ':' . $token, $secret); + + if ($checksum === null) { + throw new ServerException("Failed to generate $command checksum"); + } + + return $checksum; + } + + /** + * Attach a user session to a broker session. + * + * @throws BrokerException + * @throws ServerException + */ + public function attach(?ServerRequestInterface $request = null): void + { + $brokerId = $this->getQueryParam($request, 'broker', true); + $token = $this->getQueryParam($request, 'token', true); + + $checksum = $this->getQueryParam($request, 'checksum', true); + $expectedChecksum = $this->generateChecksum('attach', $brokerId, $token); + + if ($checksum !== $expectedChecksum) { + throw new BrokerException("Invalid checksum", 400); + } + + if (!$this->session->isActive()) { + $this->session->start(); + } + + $bearer = $this->generateBearerToken($brokerId, $token); + + try { + $this->cache->set($bearer, $this->session->getId()); + } catch (InvalidArgumentException $exception) { + throw new ServerException("Failed to attach bearer token to session id", 500, $exception); + } + } + + /** + * Get query parameter from PSR-7 request or $_GET. + * + * @param ServerRequestInterface $request + * @param string $key + * @param bool $required + * @return mixed + */ + protected function getQueryParam(?ServerRequestInterface $request, string $key, bool $required = false) + { + $params = $request === null ? $_GET : $request->getQueryParams(); + + if ($required && !isset($params[$key])) { + throw new BrokerException("Missing '$key' query parameter", 400); + } + + return $params[$key] ?? null; + } +} diff --git a/src/Server/ServerException.php b/src/Server/ServerException.php new file mode 100644 index 0000000..c103c65 --- /dev/null +++ b/src/Server/ServerException.php @@ -0,0 +1,13 @@ + Date: Tue, 1 Sep 2020 19:30:00 +0200 Subject: [PATCH 06/48] Broker v0.4 Modified demo --- README.md | 93 ++++----- composer.json | 3 +- demo/broker/error.php | 27 ++- demo/broker/include/functions.php | 12 ++ demo/broker/index.php | 64 +++--- demo/broker/login.php | 103 ++++++---- demo/broker/logout.php | 33 ++++ demo/server/api/info.php | 23 +-- demo/server/api/login.php | 22 +-- demo/server/api/logout.php | 19 +- demo/server/attach.php | 58 ++---- demo/server/{ => include}/config.php | 0 demo/server/include/start_broker_session.php | 34 ++++ src/Broker/Broker.php | 197 ++++++------------- src/Broker/RequestException.php | 12 ++ src/Server/GlobalSession.php | 17 +- src/Server/Server.php | 27 ++- 17 files changed, 380 insertions(+), 364 deletions(-) create mode 100644 demo/broker/include/functions.php create mode 100644 demo/broker/logout.php rename demo/server/{ => include}/config.php (100%) create mode 100644 demo/server/include/start_broker_session.php create mode 100644 src/Broker/RequestException.php diff --git a/README.md b/README.md index e120658..2f6907b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ Single Sign-On for PHP (Ajax compatible) --- -Jasny\SSO is a relatively simply and straightforward solution for an single sign on (SSO) implementation. With SSO, -logging into a single website will authenticate you for all affiliate sites. +Jasny\SSO is a relatively simply and straightforward solution for single sign on (SSO). + +With SSO, logging into a single website will authenticate you for all affiliate sites. The sites don't need to share a +toplevel domain. ### How it works @@ -16,7 +18,7 @@ The broker has an id and a secret. These are know to both the broker and server. When the client visits the broker, it creates a random token, which is stored in a cookie. The broker will then send the client to the server, passing along the broker's id and token. The server creates a hash using the broker id, broker -secret and the token. This hash is used to create a link to the users session. When the link is created the server +secret and the token. This hash is used to create a link to the user's session. When the link is created the server redirects the client back to the broker. The broker can create the same link hash using the token (from the cookie), the broker id and the broker secret. When @@ -25,11 +27,11 @@ doing requests, it passes that has as session id. The server will notice that the session id is a link and use the linked session. As such, the broker and client are using the same session. When another broker joins in, it will also use the same session. -For a more indepth explanation, please [read this article](https://github.com/jasny/sso/wiki). +For a more in depth explanation, please [read this article](https://github.com/jasny/sso/wiki). ### How is this different from OAuth? -With OAuth, you can authenticate a user at an external server and get access to their profile info. However you +With OAuth, you can authenticate a user at an external server and get access to their profile info. However, you aren't sharing a session. A user logs in to website foo.com using Google OAuth. Next he visits website bar.org which also uses Google OAuth. @@ -51,47 +53,6 @@ Install this library through composer `Jasny\SSO\Server` is an abstract class. You need to create a your own class which implements the abstract methods. These methods are called fetch data from a data souce (like a DB). -```php -class MySSOServer extends Jasny\SSO\Server -{ - /** - * Authenticate using user credentials - * - * @param string $username - * @param string $password - * @return \Jasny\ValidationResult - */ - abstract protected function authenticate($username, $password) - { - ... - } - - /** - * Get the secret key and other info of a broker - * - * @param string $brokerId - * @return array - */ - abstract protected function getBrokerInfo($brokerId) - { - ... - } - - /** - * Get the information about a user - * - * @param string $username - * @return array|object - */ - abstract protected function getUserInfo($username) - { - ... - } -} -``` - -The MySSOServer class can be used as controller in an MVC framework. - Alternatively you can use MySSOServer as library class. In that case pass option `fail_exception` to the constructor. This will make the object throw a Jasny\SSO\Exception, rather than set the HTTP response and exit. @@ -110,11 +71,25 @@ to the client's session. If the client is already attached, the function will si When the session is attached you can do actions as login/logout or get the user's info. ```php -$broker = new Jasny\SSO\Broker($serverUrl, $brokerId, $brokerSecret); -$broker->attach(); +use Jasny\SSO\Broker\Broker; + +// Configure the broker. +$broker = new Broker( + getenv('SSO_SERVER'), + getenv('SSO_BROKER_ID'), + getenv('SSO_BROKER_SECRET') +); + +// Attach through redirect if the client isn't attached yet. +if (!$broker->isAttached()) { + $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; + $attachUrl = $broker->getAttachUrl(['returnUrl' => $returnUrl]); + + redirect($attachUrl); + exit(); +} -$user = $broker->getUserInfo(); -echo json_encode($user); +$user = $broker->request('GET', '/user'); ``` For more information, checkout the `broker` and `ajax-broker` example. @@ -129,14 +104,16 @@ To proof it's working you should setup the server and two or more brokers, each On *nix (Linux / Unix / OSX) run: - php -S localhost:9000 -t examples/server/ - export SSO_SERVER=http://localhost:9000 SSO_BROKER_ID=Alice SSO_BROKER_SECRET=8iwzik1bwd; php -S localhost:9001 -t examples/broker/ - export SSO_SERVER=http://localhost:9000 SSO_BROKER_ID=Greg SSO_BROKER_SECRET=7pypoox2pc; php -S localhost:9002 -t examples/broker/ - export SSO_SERVER=http://localhost:9000 SSO_BROKER_ID=Julias SSO_BROKER_SECRET=ceda63kmhp; php -S localhost:9003 -t examples/ajax-broker/ + php -S localhost:8000 -t demo/server/ + export SSO_SERVER=http://localhost:8000 SSO_BROKER_ID=Alice SSO_BROKER_SECRET=8iwzik1bwd; php -S localhost:8001 -t demo/broker/ + export SSO_SERVER=http://localhost:8000 SSO_BROKER_ID=Greg SSO_BROKER_SECRET=7pypoox2pc; php -S localhost:8002 -t demo/broker/ + export SSO_SERVER=http://localhost:8000 SSO_BROKER_ID=Julias SSO_BROKER_SECRET=ceda63kmhp; php -S localhost:8003 -t demo/ajax-broker/ + +Now open some tabs and visit http://localhost:8001, http://localhost:8002 and http://localhost:8003. -Now open some tabs and visit http://localhost:9001, http://localhost:9002 and http://localhost:9003. -username/password -jackie/jackie123 -john/john123 +username | password +-------- | -------- +jackie | jackie123 +john | john123 _Note that after logging in, you need to refresh on the other brokers to see the effect._ diff --git a/composer.json b/composer.json index 62e019c..f2f76e3 100644 --- a/composer.json +++ b/composer.json @@ -16,8 +16,9 @@ "source": "https://github.com/jasny/sso" }, "require": { - "php": ">=7.4.0", + "php": ">=7.2.0", "ext-json": "*", + "ext-curl": "*", "psr/simple-cache": "^1.0", "jasny/immutable": "^2.1" }, diff --git a/demo/broker/error.php b/demo/broker/error.php index 52c7a53..cb05362 100644 --- a/demo/broker/error.php +++ b/demo/broker/error.php @@ -1,21 +1,34 @@ getMessage() : "Unknown error"; ?> - Single Sign-On demo (<?= $broker->broker ?>) - + Single Sign-On demo (<?= $brokerId ?>) + + + + + +
-

Single Sign-On demo (broker ?>)

+

Single Sign-On demo ()

-
+
diff --git a/demo/broker/include/functions.php b/demo/broker/include/functions.php new file mode 100644 index 0000000..faf6bf5 --- /dev/null +++ b/demo/broker/include/functions.php @@ -0,0 +1,12 @@ +$url"; +} diff --git a/demo/broker/index.php b/demo/broker/index.php index efece6a..d16cb22 100644 --- a/demo/broker/index.php +++ b/demo/broker/index.php @@ -1,45 +1,59 @@ attach(true); +require_once __DIR__ . '/../../vendor/autoload.php'; +require_once __DIR__ . '/include/functions.php'; + +// Configure the broker. +$broker = new Broker( + getenv('SSO_SERVER'), + getenv('SSO_BROKER_ID'), + getenv('SSO_BROKER_SECRET') +); + +// Attach through redirect if the client isn't attached yet. +if (!$broker->isAttached()) { + $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; + $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]); + + redirect($attachUrl); + exit(); +} +// Get the user info from the SSO server via the API. try { - $user = $broker->getUserInfo(); -} catch (NotAttachedException $e) { - header('Location: ' . $_SERVER['REQUEST_URI']); - exit; -} catch (SsoException $e) { - header("Location: error.php?sso_error=" . $e->getMessage(), true, 307); + $userInfo = $broker->request('GET', '/api/info.php'); +} catch (\RuntimeException $exception) { + require __DIR__ . '/error.php'; + exit(); } -if (!$user) { - header("Location: login.php", true, 307); - exit; -} ?> - <?= $broker->broker ?> (Single Sign-On demo) - + <?= $broker->getBrokerId() ?> (Single Sign-On demo) + + + +
-

broker ?> (Single Sign-On demo)

-

Logged in

+

getBrokerId() ?> (Single Sign-On demo)

-
+ +

Logged out

+ Login + +

Logged in

+
- Logout + Logout +
diff --git a/demo/broker/login.php b/demo/broker/login.php index bfed5fc..8af4541 100644 --- a/demo/broker/login.php +++ b/demo/broker/login.php @@ -1,64 +1,81 @@ attach(true); +// Configure the broker. +$broker = new Broker( + getenv('SSO_SERVER'), + getenv('SSO_BROKER_ID'), + getenv('SSO_BROKER_SECRET') +); -try { - if (!empty($_GET['logout'])) { - $broker->logout(); - } elseif ($broker->getUserInfo() || ($_SERVER['REQUEST_METHOD'] == 'POST' && $broker->login($_POST['username'], $_POST['password']))) { - header("Location: index.php", true, 303); - exit; - } +// Attach through redirect if the client isn't attached yet. +if (!$broker->isAttached()) { + $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; + $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]); + + redirect($attachUrl); + exit(); +} + +// Handle POST request +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + try { + $credentials = [ + 'username' => $_POST['username'], + 'password' => $_POST['password'] + ]; - if ($_SERVER['REQUEST_METHOD'] == 'POST') $errmsg = "Login failed"; -} catch (NotAttachedException $e) { - header('Location: ' . $_SERVER['REQUEST_URI']); - exit; -} catch (Jasny\SSO\BrokerException $e) { - $errmsg = $e->getMessage(); + $broker->request('POST', '/api/login.php', $credentials); + + redirect('index.php'); + exit(); + } catch (\RuntimeException $exception) { + $error = $exception->getMessage(); + } } +// Show the form in case of GET request ?> - <?= $broker->broker ?> | Login (Single Sign-On demo) - + <?= $broker->getBrokerId() ?> | Login (Single Sign-On demo) + + + +
-

broker ?> (Single Sign-On demo)

- -
- -
-
- -
- -
-
-
- -
- -
-
- -
-
- -
-
+

getBrokerId() ?> (Single Sign-On demo)

+ + +
+ + + + + + + + + +
diff --git a/demo/broker/logout.php b/demo/broker/logout.php new file mode 100644 index 0000000..a2ee868 --- /dev/null +++ b/demo/broker/logout.php @@ -0,0 +1,33 @@ +isAttached()) { + $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; + $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]); + + redirect($attachUrl); + exit(); +} + +try { + $broker->request('POST', 'api/logout.php'); +} catch (\RuntimeException $exception) { + require __DIR__ . '/error.php'; + exit(); +} + +redirect('index.php'); diff --git a/demo/server/api/info.php b/demo/server/api/info.php index 73843ff..955f44b 100644 --- a/demo/server/api/info.php +++ b/demo/server/api/info.php @@ -9,29 +9,20 @@ require_once __DIR__ . '/../../../vendor/autoload.php'; -use Jasny\SSO\Server\Server; -use Desarrolla2\Cache\File as FileCache; - -// Config contains the user and broker info -$config = require '../config.php'; - -// Instantiate the SSO server. -$ssoServer = new Server( - fn($id) => $config['brokers'][$id] ?? null, // Callback to get the broker secret. You might fetch this from DB. - new FileCache(), // Any PSR-16 compatible cache -); - -// Start the session using the broker bearer token (rather than a session cookie). -$ssoServer->startBrokerSession(); +// Instantiate the SSO server and start the broker session +require __DIR__ . '/../include/start_broker_session.php'; // No user is logged in; respond with a 401 if (!isset($_SESSION['user'])) { http_response_code(401); - header('Content-Type: text/plain'); - echo "User not logged in"; + header('Content-Type: application/json'); + echo json_encode(['error' => "User not logged in"]); exit(); } +// Get the username from the session +$username = $_SESSION['user']; + // Output user info as JSON. $info = ['username' => $username] + $config['users'][$username]; unset($info['password']); diff --git a/demo/server/api/login.php b/demo/server/api/login.php index 930daa5..26a8cb9 100644 --- a/demo/server/api/login.php +++ b/demo/server/api/login.php @@ -13,26 +13,18 @@ require_once __DIR__ . '/../../../vendor/autoload.php'; -use Jasny\SSO\Server\Server; -use Desarrolla2\Cache\File as FileCache; +// Instantiate the SSO server and start the broker session +require __DIR__ . '/../include/start_broker_session.php'; -// Config contains the user and broker info -$config = require '../config.php'; - -// Instantiate the SSO server. -$ssoServer = new Server( - fn($id) => $config['brokers'][$id] ?? null, // Callback to get the broker secret. You might fetch this from DB. - new FileCache(), // Any PSR-16 compatible cache -); - -// Start the session using the broker bearer token (rather than a session cookie). -$ssoServer->startBrokerSession(); +// Take the username and password from the POST params. +$username = $_POST['username']; +$password = $_POST['password']; // Authenticate the user. if (!isset($config['users'][$username]) || !password_verify($password, $config['users'][$username]['password'])) { http_response_code(400); - header('Content-Type: text/plain'); - echo "Invalid credentials"; + header('Content-Type: application/json'); + echo json_encode(['error' => "Invalid credentials"]); exit(); } diff --git a/demo/server/api/logout.php b/demo/server/api/logout.php index b1452f5..ffea202 100644 --- a/demo/server/api/logout.php +++ b/demo/server/api/logout.php @@ -13,20 +13,11 @@ require_once __DIR__ . '/../../../vendor/autoload.php'; -use Jasny\SSO\Server\Server; -use Desarrolla2\Cache\File as FileCache; - -// Config contains the user and broker info -$config = require '../config.php'; - -// Instantiate the SSO server. -$ssoServer = new Server( - fn($id) => $config['brokers'][$id] ?? null, // Callback to get the broker secret. You might fetch this from DB. - new FileCache(), // Any PSR-16 compatible cache -); - -// Start the session using the broker bearer token (rather than a session cookie). -$ssoServer->startBrokerSession(); +// Instantiate the SSO server and start the broker session +require __DIR__ . '/../include/start_broker_session.php'; // Clear the session user. unset($_SESSION['user']); + +// Done (no output) +http_response_code(201); diff --git a/demo/server/attach.php b/demo/server/attach.php index 59dfb15..1dee132 100644 --- a/demo/server/attach.php +++ b/demo/server/attach.php @@ -6,19 +6,21 @@ declare(strict_types=1); -require_once __DIR__ . '/../../vendor/autoload.php'; - use Jasny\SSO\Server\Server; -use Jasny\SSO\Server\ExceptionInterface as SSOException; use Desarrolla2\Cache\File as FileCache; +use Jasny\SSO\Server\ExceptionInterface as SSOException; + +require_once __DIR__ . '/../../vendor/autoload.php'; // Config contains the secret keys of the brokers for this demo. -$config = require 'config.php'; +$config = require __DIR__ . '/include/config.php'; // Instantiate the SSO server. $ssoServer = new Server( - fn($id) => $config['brokers'][$id] ?? null, // Callback to get the broker secret. You might fetch this from DB. - new FileCache(), // Any PSR-16 compatible cache + function (string $id) use ($config) { + return $config['brokers'][$id] ?? null; // Callback to get the broker secret. You might fetch this from DB. + }, + new FileCache(sys_get_temp_dir()) // Any PSR-16 compatible cache ); try { @@ -32,12 +34,20 @@ exit(); } -// ------ - // The token is attached; output 'success'. + // In this demo we support multiple types of attaching the session. If you choose to support only one method, // you don't need to detect the return type. -switch (detect_return_type()) { + +$returnType = + (isset($_GET['return_url']) ? 'redirect' : null) ?? + (isset($_GET['callback']) ? 'jsonp' : null) ?? + (strpos($_SERVER['HTTP_ACCEPT'], 'text/html') !== false ? 'html' : null) ?? + (strpos($_SERVER['HTTP_ACCEPT'], 'image/') !== false ? 'image' : null) ?? + (strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false ? 'json' : null) ?? + (isset($_GET['HTTP_REFERER']) ? 'redirect' : null); + +switch ($returnType) { case 'json': header('Content-type: application/json'); echo json_encode(['success' => 'attached']); @@ -58,7 +68,7 @@ case 'redirect': $url = $_GET['return_url'] ?? $_SERVER['HTTP_REFERER']; - header('Location: ' . $url); + header('Location: ' . $url, true, 303); echo "You're being redirected to $url"; break; @@ -68,31 +78,3 @@ echo "Unable to detect return type"; break; } - -/** - * Detect the type for the HTTP response. - */ -function detect_return_type(): ?string -{ - if (isset($_GET['return_url'])) { - return 'redirect'; - } - - if (isset($_GET['callback'])) { - return 'jsonp'; - } - - if (strpos($_SERVER['HTTP_ACCEPT'], 'image/') !== false) { - return 'image'; - } - - if (strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) { - return 'json'; - } - - if (isset($_GET['HTTP_REFERER'])) { - return 'redirect'; - } - - return null; -} diff --git a/demo/server/config.php b/demo/server/include/config.php similarity index 100% rename from demo/server/config.php rename to demo/server/include/config.php diff --git a/demo/server/include/start_broker_session.php b/demo/server/include/start_broker_session.php new file mode 100644 index 0000000..05591d7 --- /dev/null +++ b/demo/server/include/start_broker_session.php @@ -0,0 +1,34 @@ +startBrokerSession(); +} catch (SsoException $exception) { + http_response_code($exception->getCode()); + header('Content-Type: application/json'); + echo json_encode(['error' => $exception->getMessage()]); + exit(); +} + +return $ssoServer; diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php index a33243e..7288515 100644 --- a/src/Broker/Broker.php +++ b/src/Broker/Broker.php @@ -22,7 +22,7 @@ class Broker * My identifier, given by SSO provider. * @var string */ - public $broker; + protected $broker; /** * My secret word, given by SSO provider. @@ -32,21 +32,15 @@ class Broker /** * Session token of the client - * @var string - */ - public $token; - - /** - * User info recieved from the server. - * @var array + * @var string|null */ - protected array $userinfo; + protected $token; /** * Cookie lifetime * @var int */ - protected int $cookieTtl; + protected $cookieTtl; /** * Class constructor @@ -56,38 +50,44 @@ class Broker * @param string $secret My secret word, given by SSO provider. * @param int $cookieTtl Cookie lifetime in seconds */ - public function __construct($url, $broker, $secret, $cookieTtl = 3600) + public function __construct(string $url, string $broker, string $secret, int $cookieTtl = 3600) { $this->url = $url; $this->broker = $broker; $this->secret = $secret; $this->cookieTtl = $cookieTtl; + + $this->token = $_COOKIE[$this->getCookieName()] ?? null; + } + + /** + * Get the broker identifier. + */ + public function getBrokerId(): string + { + return $this->broker; } /** * Get the cookie name. * - * Note: Using the broker name in the cookie name. - * This resolves issues when multiple brokers are on the same domain. - * - * @return string + * The broker name is part of the cookie name. This resolves issues when multiple brokers are on the same domain. */ - protected function getCookieName() + protected function getCookieName(): string { return 'sso_token_' . preg_replace('/[_\W]+/', '_', strtolower($this->broker)); } /** * Generate session id from session key - * - * @return string */ - protected function getSessionId() + protected function generateBearerToken(): ?string { - if (!isset($this->token)) return null; + if ($this->token === null) { + return null; + } - $checksum = hash('sha256', 'session' . $this->token . $this->secret); - return "SSO-{$this->broker}-{$this->token}-$checksum"; + return "SSO-{$this->broker}-{$this->token}-" . $this->generateChecksum('bearer'); } /** @@ -95,10 +95,13 @@ protected function getSessionId() */ public function generateToken() { - if (isset($this->token)) return; + if (isset($this->token)) { + return; + } - $this->token = base_convert(md5(uniqid(rand(), true)), 16, 36); - setcookie($this->getCookieName(), $this->token, time() + $this->cookie_lifetime, '/'); + $this->token = base_convert(bin2hex(random_bytes(16)), 16, 36); + + setcookie($this->getCookieName(), $this->token, time() + $this->cookieTtl, '/'); } /** @@ -112,86 +115,75 @@ public function clearToken() /** * Check if we have an SSO token. - * - * @return boolean */ - public function isAttached() + public function isAttached(): bool { - return isset($this->token); + return $this->token !== null; } /** * Get URL to attach session at SSO server. - * - * @param array $params - * @return string */ - public function getAttachUrl($params = []) + public function getAttachUrl(array $params = []): string { $this->generateToken(); $data = [ 'broker' => $this->broker, 'token' => $this->token, - 'checksum' => hash('sha256', 'attach' . $this->token . $this->secret) + 'checksum' => $this->generateChecksum('attach') ] + $_GET; return $this->url . "/attach.php?" . http_build_query($data + $params); } /** - * Attach our session to the user's session on the SSO server. - * - * @param string|true $returnUrl The URL the client should be returned to after attaching + * Generate checksum for a broker. */ - public function attach($returnUrl = null) + protected function generateChecksum(string $command): string { - if ($this->isAttached()) return; - - if ($returnUrl === true) { - $protocol = !empty($_SERVER['HTTPS']) ? 'https://' : 'http://'; - $returnUrl = $protocol . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; - } - - $params = ['return_url' => $returnUrl]; - $url = $this->getAttachUrl($params); - - header("Location: $url", true, 307); - echo "You're redirected to $url"; - exit(); + return hash_hmac('sha256', $command . ':' . $this->token, $this->secret); } /** * Get the request url for a command * - * @param string $command - * @param array $params Query parameters + * @param string $path + * @param array|string $params Query parameters * @return string */ - protected function getRequestUrl($command, $params = []) + protected function getRequestUrl(string $path, $params = '') { - return $this->url . '/api/' . $command . '.php?' . http_build_query($params); + $query = is_array($params) ? http_build_query($params) : $params; + + return $this->url . '/' . ltrim($path, '/') . ($query !== '' ? '?' . $query : ''); } + /** - * Execute on SSO server. + * Send an HTTP request to the SSO server. * * @param string $method HTTP method: 'GET', 'POST', 'DELETE' - * @param string $command Command + * @param string $path Relative path * @param array|string $data Query or post parameters - * @return array|object + * @return mixed */ - protected function request($method, $command, $data = null) + public function request(string $method, string $path, $data = null) { if (!$this->isAttached()) { - throw new NotAttachedException('No token'); + throw new NotAttachedException("The client isn't attached to the SSO server for this broker. " + . "Make sure that the '" . $this->getCookieName() . "' cookie is set."); } - $url = $this->getRequestUrl($command, !$data || $method === 'POST' ? [] : $data); + + $url = $this->getRequestUrl($path, $method === 'POST' ? '' : $data); $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/json', 'Authorization: Bearer '. $this->getSessionID()]); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Accept: application/json', + 'Authorization: Bearer '. $this->generateBearerToken() + ]); if ($method === 'POST' && !empty($data)) { $post = is_string($data) ? $data : http_build_query($data); @@ -200,90 +192,29 @@ protected function request($method, $command, $data = null) $response = curl_exec($ch); if (curl_errno($ch) != 0) { - $message = 'Server request failed: ' . curl_error($ch); - throw new BrokerException($message); + throw new RequestException('Server request failed: ' . curl_error($ch)); } $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); list($contentType) = explode(';', curl_getinfo($ch, CURLINFO_CONTENT_TYPE)); + if ($httpCode === 201 || $httpCode === 401) { + return null; + } + if ($contentType != 'application/json') { - $message = 'Expected application/json response, got ' . $contentType; - throw new BrokerException($message); + throw new RequestException("Expected 'application/json' response, got '$contentType'"); } $data = json_decode($response, true); - if ($httpCode == 403) { + + if ($httpCode === 403) { $this->clearToken(); throw new NotAttachedException($data['error'] ?: $response, $httpCode); + } elseif ($httpCode >= 400) { + throw new RequestException($data['error'] ?: $response, $httpCode); } - if ($httpCode >= 400) throw new BrokerException($data['error'] ?: $response, $httpCode); return $data; } - - - /** - * Log the client in at the SSO server. - * - * Only brokers marked trused can collect and send the user's credentials. Other brokers should omit $username and - * $password. - * - * @param string $username - * @param string $password - * @return array user info - * @throws BrokerException if login fails eg due to incorrect credentials - */ - public function login($username = null, $password = null) - { - if (!isset($username) && isset($_POST['username'])) $username = $_POST['username']; - if (!isset($password) && isset($_POST['password'])) $password = $_POST['password']; - - $result = $this->request('POST', 'login', compact('username', 'password')); - $this->userinfo = $result; - - return $this->userinfo; - } - - /** - * Logout at sso server. - */ - public function logout() - { - $this->request('POST', 'logout'); - } - - /** - * Get user information. - * - * @return object|null - */ - public function getUserInfo() - { - if (!isset($this->userinfo)) { - $this->userinfo = $this->request('GET', 'info'); - } - - return $this->userinfo; - } - - /** - * Magic method to do arbitrary request - * - * @param string $fn - * @param array $args - * @return mixed - */ - public function __call($fn, $args) - { - $sentence = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $fn)); - $parts = explode(' ', $sentence); - - $method = count($parts) > 1 && in_array(strtoupper($parts[0]), ['GET', 'DELETE']) - ? strtoupper(array_shift($parts)) - : 'POST'; - $command = join('-', $parts); - - return $this->request($method, $command, $args); - } } diff --git a/src/Broker/RequestException.php b/src/Broker/RequestException.php new file mode 100644 index 0000000..701002f --- /dev/null +++ b/src/Broker/RequestException.php @@ -0,0 +1,12 @@ +options); + if ($id !== null) { + session_id($id); + } + + $started = session_start($this->options); if (!$started) { - throw new ServerException(error_get_last()['message'], 500); + $err = error_get_last() ?? ['message' => 'Failed to start session']; + throw new ServerException($err['message'], 500); } } diff --git a/src/Server/Server.php b/src/Server/Server.php index fe9953f..b47241f 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -17,19 +17,29 @@ class Server { use Immutable\With; - /** Callback to get the secret for a broker. */ - protected \Closure $getBrokerSecret; + /** + * Callback to get the secret for a broker. + * @var \Closure + */ + protected $getBrokerSecret; - /** Storage for broker session links. */ - protected CacheInterface $cache; + /** + * Storage for broker session links. + * @var CacheInterface + */ + protected $cache; - /** Service to interact with sessions. */ - protected SessionInterface $session; + /** + * Service to interact with sessions. + * @var SessionInterface + */ + protected $session; /** * Broker of the current session. + * @var string|null */ - protected ?string $brokerId = null; + protected $brokerId = null; /** @@ -41,7 +51,6 @@ class Server public function __construct(callable $getBrokerSecret, CacheInterface $cache) { $this->getBrokerSecret = \Closure::fromCallable($getBrokerSecret); - $this->auth = $auth; $this->cache = $cache; $this->session = new GlobalSession(); @@ -128,7 +137,7 @@ protected function getBrokerIdFromBearer(string $bearer): string */ protected function generateBearerToken(string $brokerId, string $token): string { - return "SSO-{$brokerId}-{$token}-" . $this->generateChecksum('session', $brokerId, $token); + return "SSO-{$brokerId}-{$token}-" . $this->generateChecksum('bearer', $brokerId, $token); } /** From d1281d7bac599dad629bec902562593f923c76ef Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Tue, 8 Sep 2020 22:07:19 +0200 Subject: [PATCH 07/48] Update README --- README.md | 170 +++++++++++++++++++++++++++++++++--------- src/Broker/Broker.php | 39 ++++++---- 2 files changed, 160 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 2f6907b..05dc9f8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Single Sign-On for PHP (Ajax compatible) --- -Jasny\SSO is a relatively simply and straightforward solution for single sign on (SSO). +Jasny SSO is a relatively simply and straightforward solution for single sign on (SSO). With SSO, logging into a single website will authenticate you for all affiliate sites. The sites don't need to share a toplevel domain. @@ -37,7 +37,7 @@ aren't sharing a session. A user logs in to website foo.com using Google OAuth. Next he visits website bar.org which also uses Google OAuth. Regardless of that, he is still required to press on the 'login' button on bar.org. -With Jasny/SSO both websites use the same session. So when the user visits bar.org, he's automatically logged in. +With Jasny SSO both websites use the same session. So when the user visits bar.org, he's automatically logged in. When he logs out (on either of the sites), he's logged out for both. ## Installation @@ -46,29 +46,130 @@ Install this library through composer composer require jasny/sso +## Demo + +There is a demo server and two demo brokers as example. One with normal redirects and one using +[JSONP](https://en.wikipedia.org/wiki/JSONP) / AJAX. + +To proof it's working you should setup the server and two or more brokers, each on their own machine and their own +(sub)domain. However, you can also run both server and brokers on your own machine, simply to test it out. + +On *nix (Linux / Unix / OSX) run: + + php -S localhost:8000 -t demo/server/ + export SSO_SERVER=http://localhost:8000 SSO_BROKER_ID=Alice SSO_BROKER_SECRET=8iwzik1bwd; php -S localhost:8001 -t demo/broker/ + export SSO_SERVER=http://localhost:8000 SSO_BROKER_ID=Greg SSO_BROKER_SECRET=7pypoox2pc; php -S localhost:8002 -t demo/broker/ + export SSO_SERVER=http://localhost:8000 SSO_BROKER_ID=Julias SSO_BROKER_SECRET=ceda63kmhp; php -S localhost:8003 -t demo/ajax-broker/ + +Now open some tabs and visit + + * http://localhost:8001 + * http://localhost:8002 + * http://localhost:8003 + +username | password +-------- | -------- +jackie | jackie123 +john | john123 + +_Note that after logging in, you need to refresh on the other brokers to see the effect._ + ## Usage ### Server -`Jasny\SSO\Server` is an abstract class. You need to create a your own class which implements the abstract methods. -These methods are called fetch data from a data souce (like a DB). +The `Server` class takes a callback as first constructor argument. This callback should lookup the secret +for a broker based on the id. + +The second argument must be a PSR-16 compatible cache object. It's used to store the link between broker token and +client session. + +```php +use Jasny\SSO\Server\Server; + +$server = new Server( + fn (string $id): string => $brokerSecrets[$id], // Unique secret for each broker + new Cache() // Any PSR-16 compatible cache +); +``` + +#### Attach + +A client needs attach the broker token to the session id by doing an HTTP request to the server. This request can be +handled by calling `attach()`. + +```php +$server->attach(); +``` + +If it's not possible to attach (for instance in case of an incorrect checksum), an Exception is thrown. + +#### Handle broker API request + +After the client session is attached to the broker token, the broker is able to send API requests on behalf of the +client. Calling the `startBrokerSession()` method with start the session of the client based on the bearer token. This +means that these request the server can access the session information of the client through `$_SESSION`. + +``` +$server->startBrokerSession(); +``` + +The broker could use this to login, logout, get user information, etc. The API for handling such requests is outside +the scope of the project. However since the broker uses normal sessions, any existing the authentication can be used. + +_If you're lookup for an authentication library, consider using [Jasny Auth](https://github.com/jasny/auth)._ -Alternatively you can use MySSOServer as library class. In that case pass option `fail_exception` to the constructor. -This will make the object throw a Jasny\SSO\Exception, rather than set the HTTP response and exit. +#### PSR-7 -For more information, checkout the `server` example. +By default, the library works with superglobals like `$_GET` and `$_SERVER`. Alternatively it can use a PSR-7 server +request. This can be passed to `attach()` and `startBrokerSession()` as argument. + +```php +$server->attach($serverRequest); +``` + +#### Session interface + +By default, the library uses the superglobal `$_SESSION` and the `php_session_*()` functions. It does this through +the `GlobalSession` object, which implements `SessionInterface`. + +For projects that use alternative sessions, it's possible to create a wrapper that implements `SessionInterface`. + +```php +use Jasny\SSO\Server\SessionInterface; + +class CustomerSessionHandler implements SessionInterface +{ + // ... +} +``` + +The `withSession()` methods creates a copy of the Server object with the custom session interface. + +```php +$server = (new Server($callback, $cache)) + ->withSession(new CustomerSessionHandler()); +``` + +The `withSession()` method can also be used with a mock object for testing. ### Broker -When creating a Jasny\SSO\Broker instance, you need to pass the server url, broker id and broker secret. The broker id -and secret needs to be registered at the server (so fetched when using `getBrokerInfo($brokerId)`). +When creating a `Broker` instance, you need to pass the server url, broker id and broker secret. The broker id +and secret needs to match the secret registered at the server. **Be careful**: *The broker id SHOULD be alphanumeric. In any case it MUST NOT contain the "-" character.* -Next you need to call `attach()`. This will generate a token an redirect the client to the server to attach the token -to the client's session. If the client is already attached, the function will simply return. +#### Attach + +Before the broker can do API requests on the client's behalve, the client needs to attach the broker token to the client +session. For this, the client must do an HTTP request to the SSO Server. + +The `getAttachUrl()` method will generate a broker token for the client and use it to create an attach URL. The method +takes an array of query parameters as single argument. -When the session is attached you can do actions as login/logout or get the user's info. +There are several methods in making the client do an HTTP request. The broker can redirect the client or do a request +via the browser using AJAX or loading an image. ```php use Jasny\SSO\Broker\Broker; @@ -83,37 +184,34 @@ $broker = new Broker( // Attach through redirect if the client isn't attached yet. if (!$broker->isAttached()) { $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; - $attachUrl = $broker->getAttachUrl(['returnUrl' => $returnUrl]); + $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]); - redirect($attachUrl); + header("Location: $attachUrl", true, 303); + echo "You're redirected to $attachUrl"; exit(); } - -$user = $broker->request('GET', '/user'); ``` -For more information, checkout the `broker` and `ajax-broker` example. +#### API requests -## Examples +Once attached, the broker is able to do API requests on behalf of the client. -There is an example server and two example brokers. One with normal redirects and one using -[JSONP](https://en.wikipedia.org/wiki/JSONP) / AJAX. - -To proof it's working you should setup the server and two or more brokers, each on their own machine and their own -(sub)domain. However you can also run both server and brokers on your own machine, simply to test it out. - -On *nix (Linux / Unix / OSX) run: - - php -S localhost:8000 -t demo/server/ - export SSO_SERVER=http://localhost:8000 SSO_BROKER_ID=Alice SSO_BROKER_SECRET=8iwzik1bwd; php -S localhost:8001 -t demo/broker/ - export SSO_SERVER=http://localhost:8000 SSO_BROKER_ID=Greg SSO_BROKER_SECRET=7pypoox2pc; php -S localhost:8002 -t demo/broker/ - export SSO_SERVER=http://localhost:8000 SSO_BROKER_ID=Julias SSO_BROKER_SECRET=ceda63kmhp; php -S localhost:8003 -t demo/ajax-broker/ +``` +$user = $broker->request('GET', '/user'); +``` -Now open some tabs and visit http://localhost:8001, http://localhost:8002 and http://localhost:8003. +The `request()` method uses Curl to send HTTP requests, adding the bearer token for authentication. It expects a JSON +response and will automatically decode it. -username | password --------- | -------- -jackie | jackie123 -john | john123 +To use a library like [Guzzle](http://docs.guzzlephp.org/) or [Httplug](http://httplug.io/), get the bearer token using +`getBearerToken()` and set the `Authorization` header + +```php +$guzzle = new GuzzleHttp\Client(['base_uri' => 'https://sso-server.example.com']); -_Note that after logging in, you need to refresh on the other brokers to see the effect._ +$res = $guzzle->request('GET', '/user', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $broker->getBearerToken() + ] +]); +``` diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php index 7288515..6bff1ed 100644 --- a/src/Broker/Broker.php +++ b/src/Broker/Broker.php @@ -80,20 +80,23 @@ protected function getCookieName(): string /** * Generate session id from session key + * + * @throws NotAttachedException */ - protected function generateBearerToken(): ?string + public function getBearerToken(): ?string { if ($this->token === null) { - return null; + throw new NotAttachedException("The client isn't attached to the SSO server for this broker. " + . "Make sure that the '" . $this->getCookieName() . "' cookie is set."); } return "SSO-{$this->broker}-{$this->token}-" . $this->generateChecksum('bearer'); } /** - * Generate session token + * Generate session token. */ - public function generateToken() + public function generateToken(): void { if (isset($this->token)) { return; @@ -105,9 +108,9 @@ public function generateToken() } /** - * Clears session token + * Clears session token. */ - public function clearToken() + public function clearToken(): void { setcookie($this->getCookieName(), null, 1, '/'); $this->token = null; @@ -152,7 +155,7 @@ protected function generateChecksum(string $command): string * @param array|string $params Query parameters * @return string */ - protected function getRequestUrl(string $path, $params = '') + protected function getRequestUrl(string $path, $params = ''): string { $query = is_array($params) ? http_build_query($params) : $params; @@ -167,14 +170,11 @@ protected function getRequestUrl(string $path, $params = '') * @param string $path Relative path * @param array|string $data Query or post parameters * @return mixed + * @throws NotAttachedException */ public function request(string $method, string $path, $data = null) { - if (!$this->isAttached()) { - throw new NotAttachedException("The client isn't attached to the SSO server for this broker. " - . "Make sure that the '" . $this->getCookieName() . "' cookie is set."); - } - + $bearer = $this->getBearerToken(); $url = $this->getRequestUrl($path, $method === 'POST' ? '' : $data); $ch = curl_init($url); @@ -182,7 +182,7 @@ public function request(string $method, string $path, $data = null) curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Accept: application/json', - 'Authorization: Bearer '. $this->generateBearerToken() + 'Authorization: Bearer '. $bearer ]); if ($method === 'POST' && !empty($data)) { @@ -191,6 +191,19 @@ public function request(string $method, string $path, $data = null) } $response = curl_exec($ch); + + return $this->handleResponse($ch, $response); + } + + /** + * Handle response of Curl request. + * + * @param resource $ch Curl handler + * @param string $response + * @return mixed + */ + protected function handleResponse($ch, string $response) + { if (curl_errno($ch) != 0) { throw new RequestException('Server request failed: ' . curl_error($ch)); } From 141dac7af284ed3275b25281cbda27d2cfc5f27b Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Wed, 9 Sep 2020 00:39:43 +0200 Subject: [PATCH 08/48] Cookies interface for SSO broker. --- README.md | 23 +++++++++++ src/Broker/Broker.php | 59 +++++++++++++++++++++------- src/Broker/CookiesInterface.php | 35 +++++++++++++++++ src/Broker/GlobalCookies.php | 69 +++++++++++++++++++++++++++++++++ src/Server/GlobalSession.php | 2 + 5 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 src/Broker/CookiesInterface.php create mode 100644 src/Broker/GlobalCookies.php diff --git a/README.md b/README.md index 05dc9f8..d6fa650 100644 --- a/README.md +++ b/README.md @@ -215,3 +215,26 @@ $res = $guzzle->request('GET', '/user', [ ] ]); ``` + +#### Custom cookie handler + +By default, the Broker uses the superglobal `$_COOKIE` and `setcookie()` function via the `GlobalCookie` class. +Alternative, you can make a custom class that implements `CookieInterface`. + +```php +use Jasny\SSO\Broker\CookiesInterface; + +class CustomCookieHandler implements CookiesInterface +{ + // ... +} +``` + +The `withCookies()` methods creates a copy of the Broker object with the custom cookies interface. + +```php +$server = (new Broker(getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET'))) + ->withCookies(new CustomCookieHandler()); +``` + +The `withCookies()` method can also be used with a mock object for testing. diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php index 6bff1ed..8c118e3 100644 --- a/src/Broker/Broker.php +++ b/src/Broker/Broker.php @@ -30,6 +30,11 @@ class Broker */ protected $secret; + /** + * @var bool + */ + protected $initialized = false; + /** * Session token of the client * @var string|null @@ -37,10 +42,9 @@ class Broker protected $token; /** - * Cookie lifetime - * @var int + * @var CookiesInterface */ - protected $cookieTtl; + protected $cookies; /** * Class constructor @@ -48,16 +52,32 @@ class Broker * @param string $url Url of SSO server * @param string $broker My identifier, given by SSO provider. * @param string $secret My secret word, given by SSO provider. - * @param int $cookieTtl Cookie lifetime in seconds */ - public function __construct(string $url, string $broker, string $secret, int $cookieTtl = 3600) + public function __construct(string $url, string $broker, string $secret) { $this->url = $url; $this->broker = $broker; $this->secret = $secret; - $this->cookieTtl = $cookieTtl; - $this->token = $_COOKIE[$this->getCookieName()] ?? null; + $this->cookies = new GlobalCookies(); + } + + /** + * Get a copy with a custom cookie handler. + * + * @param CookiesInterface $cookies + * @return static + */ + public function withCookies(CookiesInterface $cookies): self + { + if ($this->cookies === $cookies) { + return $this; + } + + $clone = clone $this; + $clone->cookies = $cookies; + + return $clone; } /** @@ -68,6 +88,18 @@ public function getBrokerId(): string return $this->broker; } + /** + * @return string|null + */ + protected function getToken(): ?string + { + if (!$this->initialized) { + $this->token = $this->cookies->get($this->getCookieName()); + } + + return $this->token; + } + /** * Get the cookie name. * @@ -85,7 +117,7 @@ protected function getCookieName(): string */ public function getBearerToken(): ?string { - if ($this->token === null) { + if ($this->getToken() === null) { throw new NotAttachedException("The client isn't attached to the SSO server for this broker. " . "Make sure that the '" . $this->getCookieName() . "' cookie is set."); } @@ -98,13 +130,12 @@ public function getBearerToken(): ?string */ public function generateToken(): void { - if (isset($this->token)) { + if ($this->getToken() === null) { return; } $this->token = base_convert(bin2hex(random_bytes(16)), 16, 36); - - setcookie($this->getCookieName(), $this->token, time() + $this->cookieTtl, '/'); + $this->cookies->set($this->getCookieName(), $this->token); } /** @@ -112,7 +143,7 @@ public function generateToken(): void */ public function clearToken(): void { - setcookie($this->getCookieName(), null, 1, '/'); + $this->cookies->clear($this->getCookieName()); $this->token = null; } @@ -121,7 +152,7 @@ public function clearToken(): void */ public function isAttached(): bool { - return $this->token !== null; + return $this->getToken() !== null; } /** @@ -135,7 +166,7 @@ public function getAttachUrl(array $params = []): string 'broker' => $this->broker, 'token' => $this->token, 'checksum' => $this->generateChecksum('attach') - ] + $_GET; + ]; return $this->url . "/attach.php?" . http_build_query($data + $params); } diff --git a/src/Broker/CookiesInterface.php b/src/Broker/CookiesInterface.php new file mode 100644 index 0000000..9d6236a --- /dev/null +++ b/src/Broker/CookiesInterface.php @@ -0,0 +1,35 @@ +ttl = $ttl; + $this->path = $path; + $this->domain = $domain; + $this->secure = $secure; + } + + /** + * @inheritDoc + */ + public function set(string $name, $value): void + { + $success = setcookie($name, $value, time() + $this->ttl, $this->domain, $this->path, $this->secure, true); + + if (!$success) { + throw new \RuntimeException("Failed to set cookie '$name'"); + } + } + + /** + * @inheritDoc + */ + public function clear(string $name): void + { + setcookie($name, null, 1, $this->domain, $this->path, $this->secure, true); + } + + /** + * @inheritDoc + */ + public function get(string $name) + { + return $_COOKIE[$name] ?? null; + } +} diff --git a/src/Server/GlobalSession.php b/src/Server/GlobalSession.php index e7e6af3..284ca62 100644 --- a/src/Server/GlobalSession.php +++ b/src/Server/GlobalSession.php @@ -6,6 +6,8 @@ /** * Interact with session using $_SESSION and PHP's session_* functions. + * + * @codeCoverageIgnore */ class GlobalSession implements SessionInterface { From a5867c6625b50f889b36aa50ece5cca0128fd991 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Wed, 9 Sep 2020 02:22:01 +0200 Subject: [PATCH 09/48] Fixed AJAX demo --- demo/ajax-broker/api.php | 31 ++--- demo/ajax-broker/app.js | 218 ++++++++++++++++++----------------- demo/ajax-broker/attach.php | 25 ++++ demo/ajax-broker/index.html | 2 +- demo/broker/index.php | 1 - src/Broker/Broker.php | 5 +- src/Broker/GlobalCookies.php | 2 +- 7 files changed, 156 insertions(+), 128 deletions(-) create mode 100644 demo/ajax-broker/attach.php diff --git a/demo/ajax-broker/api.php b/demo/ajax-broker/api.php index 304febd..8108bee 100644 --- a/demo/ajax-broker/api.php +++ b/demo/ajax-broker/api.php @@ -1,33 +1,26 @@ 'Command not specified']); - return; -} +require_once __DIR__ . '/../../vendor/autoload.php'; + +// Configure the broker. +$broker = new Broker( + getenv('SSO_SERVER'), + getenv('SSO_BROKER_ID'), + getenv('SSO_BROKER_SECRET') +); try { - $result = $broker->{$_REQUEST['command']}(); + $path = '/api/' . $_GET['command'] . '.php'; + $result = $broker->request($_SERVER['REQUEST_METHOD'], $path, $_POST); } catch (Exception $e) { $status = $e->getCode() ?: 500; $result = ['error' => $e->getMessage()]; } -// JSONP -if (!empty($_GET['callback'])) { - if (!isset($result)) $result = null; - if (!isset($status)) $status = isset($result) ? 200 : 204; - - header('Content-Type: application/javascript'); - echo $_GET['callback'] . '(' . json_encode($result) . ', ' . $status . ')'; - return; -} - // REST if (!$result) { http_response_code(204); diff --git a/demo/ajax-broker/app.js b/demo/ajax-broker/app.js index 7a627c9..6af65f8 100644 --- a/demo/ajax-broker/app.js +++ b/demo/ajax-broker/app.js @@ -1,107 +1,117 @@ -+function($) { - // Init - attach(); - - /** - * Attach session. - * Will redirect to SSO server. - */ - function attach() { - var req = $.ajax({ - url: 'api.php?command=attach', - crossDomain: true, - dataType: 'jsonp' - }); - - req.done(function(data, code) { - if (code && code >= 400) { // jsonp failure - showError(data.error); - return; - } - - loadUserInfo(); - }); - - req.fail(function(jqxhr) { - showError(jqxhr.responseJSON || jqxhr.textResponse) - }); - } - - /** - * Do an AJAX request to the API - * - * @param command API command - * @param params POST data - * @param callback Callback function - */ - function doApiRequest(command, params, callback) { - var req = $.ajax({ - url: 'api.php?command=' + command, - method: params ? 'POST' : 'GET', - data: params, - dataType: 'json' - }); ++function ($) { + // Init + attach(); - req.done(callback); - - req.fail(function(jqxhr) { - showError(jqxhr.responseJSON || jqxhr.textResponse); - }); - } - - /** - * Display the error message - * - * @param data - */ - function showError(data) { - var message = typeof data === 'object' && data.error ? data.error : 'Unexpected error'; - $('#error').text(message).show(); - } - - /** - * Load and display user info - */ - function loadUserInfo() { - doApiRequest('getUserinfo', null, showUserInfo); - } - - /** - * Display user info - * - * @param info - */ - function showUserInfo(info) { - $('body').removeClass('anonymous authenticated'); - $('#user-info').html(''); - - if (info) { - for (var key in info) { - $('#user-info').append($('
').text(key)); - $('#user-info').append($('
').text(info[key])); - } + /** + * Attach session. + * Will redirect to SSO server. + */ + function attach() + { + const req = $.ajax({ + url: 'attach.php', + crossDomain: true, + dataType: 'jsonp' + }); + + req.done(function (data, code) { + if (code && code >= 400) { // jsonp failure + showError(data); + return; + } + + loadUserInfo(); + }); + + req.fail(function (jqxhr) { + showError(jqxhr.responseJSON || jqxhr.textResponse) + }); + } + + /** + * Do an AJAX request to the API + * + * @param command API command + * @param params POST data + * @param callback Callback function + */ + function doApiRequest(command, params, callback) + { + const req = $.ajax({ + url: 'api.php?command=' + command, + method: params ? 'POST' : 'GET', + data: params, + dataType: 'json' + }); + + req.done(callback); + + req.fail(function (jqxhr) { + showError(jqxhr.responseJSON || jqxhr.textResponse); + }); + } + + /** + * Display the error message + * + * @param data + */ + function showError(data) + { + const message = typeof data === 'object' && data.error ? data.error : 'Unexpected error'; + $('#error').text(message).show(); } - - $('body').addClass(info ? 'authenticated' : 'anonymous'); - } - - /** - * Submit login form through AJAX - */ - $('#login-form').on('submit', function(e) { - e.preventDefault(); - - $('#error').text('').hide(); - - var data = { - username: this.username.value, - password: this.password.value - }; - - doApiRequest('login', data, showUserInfo); - }); - - $('#logout').on('click', function() { - doApiRequest('logout', {}, function() { showUserInfo(null); }); - }) + + /** + * Load and display user info + */ + function loadUserInfo() + { + doApiRequest('info', null, showUserInfo); + } + + /** + * Display user info + * + * @param info + */ + function showUserInfo(info) + { + const body = $('body'); + const userInfo = $('#user-info'); + + body.removeClass('anonymous authenticated'); + userInfo.html(''); + + if (info) { + for (var key in info) { + userInfo.append($('
').text(key)); + userInfo.append($('
').text(info[key])); + } + } + + body.addClass(info ? 'authenticated' : 'anonymous'); + } + + /** + * Submit login form through AJAX + */ + $('#login-form').on('submit', function (e) { + e.preventDefault(); + + $('#error').text('').hide(); + + var data = { + username: this.username.value, + password: this.password.value + }; + + doApiRequest('login', data, showUserInfo); + }); + + $('#logout').on('click', function () { + doApiRequest('logout', {}, function () { + showUserInfo(null); + }); + }) }(jQuery); diff --git a/demo/ajax-broker/attach.php b/demo/ajax-broker/attach.php new file mode 100644 index 0000000..daefa43 --- /dev/null +++ b/demo/ajax-broker/attach.php @@ -0,0 +1,25 @@ +isAttached()) { + echo $jsCallback . '(null, 200)'; +} + +// Attach through redirect if the client isn't attached yet. +$url = $broker->getAttachUrl(['callback' => $jsCallback]); +header("Location: $url", true, 303); diff --git a/demo/ajax-broker/index.html b/demo/ajax-broker/index.html index 8b8a98b..d0fc436 100644 --- a/demo/ajax-broker/index.html +++ b/demo/ajax-broker/index.html @@ -13,7 +13,7 @@ display: initial; } - + k

Single Sign-On Ajax demo

diff --git a/demo/broker/index.php b/demo/broker/index.php index d16cb22..597afc5 100644 --- a/demo/broker/index.php +++ b/demo/broker/index.php @@ -57,4 +57,3 @@
- diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php index 8c118e3..5244a45 100644 --- a/src/Broker/Broker.php +++ b/src/Broker/Broker.php @@ -95,6 +95,7 @@ protected function getToken(): ?string { if (!$this->initialized) { $this->token = $this->cookies->get($this->getCookieName()); + $this->initialized = true; } return $this->token; @@ -130,7 +131,7 @@ public function getBearerToken(): ?string */ public function generateToken(): void { - if ($this->getToken() === null) { + if ($this->getToken() !== null) { return; } @@ -164,7 +165,7 @@ public function getAttachUrl(array $params = []): string $data = [ 'broker' => $this->broker, - 'token' => $this->token, + 'token' => $this->getToken(), 'checksum' => $this->generateChecksum('attach') ]; diff --git a/src/Broker/GlobalCookies.php b/src/Broker/GlobalCookies.php index 2649d09..8f520ee 100644 --- a/src/Broker/GlobalCookies.php +++ b/src/Broker/GlobalCookies.php @@ -56,7 +56,7 @@ public function set(string $name, $value): void */ public function clear(string $name): void { - setcookie($name, null, 1, $this->domain, $this->path, $this->secure, true); + setcookie($name, '', 1, $this->domain, $this->path, $this->secure, true); } /** From 43e4ff655f24195fea78015d13cf42e097ffc58e Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Mon, 14 Sep 2020 11:26:12 +0200 Subject: [PATCH 10/48] AJAX demo with latest jQuery and milligram instead of Bootstrap. --- demo/ajax-broker/app.js | 20 ++++------------ demo/ajax-broker/index.html | 46 +++++++++++++++---------------------- demo/broker/index.php | 4 ++-- 3 files changed, 26 insertions(+), 44 deletions(-) diff --git a/demo/ajax-broker/app.js b/demo/ajax-broker/app.js index 6af65f8..0969800 100644 --- a/demo/ajax-broker/app.js +++ b/demo/ajax-broker/app.js @@ -20,7 +20,7 @@ return; } - loadUserInfo(); + doApiRequest('info', null, showUserInfo); }); req.fail(function (jqxhr) { @@ -59,15 +59,7 @@ function showError(data) { const message = typeof data === 'object' && data.error ? data.error : 'Unexpected error'; - $('#error').text(message).show(); - } - - /** - * Load and display user info - */ - function loadUserInfo() - { - doApiRequest('info', null, showUserInfo); + $.growl.error({message: message}); } /** @@ -84,7 +76,7 @@ userInfo.html(''); if (info) { - for (var key in info) { + for (const key in info) { userInfo.append($('
').text(key)); userInfo.append($('
').text(info[key])); } @@ -110,8 +102,6 @@ }); $('#logout').on('click', function () { - doApiRequest('logout', {}, function () { - showUserInfo(null); - }); - }) + doApiRequest('logout', {}, () => showUserInfo(null)); + }); }(jQuery); diff --git a/demo/ajax-broker/index.html b/demo/ajax-broker/index.html index d0fc436..2a97c2f 100644 --- a/demo/ajax-broker/index.html +++ b/demo/ajax-broker/index.html @@ -2,8 +2,11 @@ Single Sign-On Ajax demo - - + + + + + - k +

Single Sign-On Ajax demo

- - -
-
- -
- -
-
-
- -
- -
-
- -
-
- -
-
+ + + + + + + +

Logged in

-
+
- Logout +
- + + + diff --git a/demo/broker/index.php b/demo/broker/index.php index 597afc5..c6825e5 100644 --- a/demo/broker/index.php +++ b/demo/broker/index.php @@ -35,7 +35,7 @@ - <?= $broker->getBrokerId() ?> (Single Sign-On demo) + <?= $broker->getBrokerId() ?> — Single Sign-On demo @@ -43,7 +43,7 @@
-

getBrokerId() ?> (Single Sign-On demo)

+

Single Sign-On demo (Broker: getBrokerId() ?>)

Logged out

From 61fabc59d27d51e780defabf8f41493e3e45ddc8 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Mon, 14 Sep 2020 13:20:08 +0200 Subject: [PATCH 11/48] Added logging for SSO Server. Pass script name in URL arg for Broker. Better error messages. Fixes #72 --- README.md | 2 +- composer.json | 6 +- demo/ajax-broker/api.php | 2 +- demo/ajax-broker/attach.php | 2 +- demo/broker/index.php | 2 +- demo/broker/login.php | 4 +- demo/broker/logout.php | 2 +- demo/server/attach.php | 4 +- demo/server/include/start_broker_session.php | 4 +- src/Broker/Broker.php | 24 +++- src/Server/Server.php | 139 +++++++++++++------ 11 files changed, 128 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index d6fa650..0f73495 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ The `withSession()` method can also be used with a mock object for testing. When creating a `Broker` instance, you need to pass the server url, broker id and broker secret. The broker id and secret needs to match the secret registered at the server. -**Be careful**: *The broker id SHOULD be alphanumeric. In any case it MUST NOT contain the "-" character.* +**CAVEAT**: *The broker id MUST be alphanumeric.* #### Attach diff --git a/composer.json b/composer.json index f2f76e3..03d4a4c 100644 --- a/composer.json +++ b/composer.json @@ -20,12 +20,14 @@ "ext-json": "*", "ext-curl": "*", "psr/simple-cache": "^1.0", - "jasny/immutable": "^2.1" + "jasny/immutable": "^2.1", + "psr/log": "^1.1" }, "require-dev": { "codeception/codeception": "^4.0", "jasny/php-code-quality": "^2.6.0", - "desarrolla2/cache": "^3.0" + "desarrolla2/cache": "^3.0", + "yubb/loggy": "^2.1" }, "autoload": { "psr-4": { diff --git a/demo/ajax-broker/api.php b/demo/ajax-broker/api.php index 8108bee..1b44687 100644 --- a/demo/ajax-broker/api.php +++ b/demo/ajax-broker/api.php @@ -8,7 +8,7 @@ // Configure the broker. $broker = new Broker( - getenv('SSO_SERVER'), + getenv('SSO_SERVER') . '/attach.php', getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET') ); diff --git a/demo/ajax-broker/attach.php b/demo/ajax-broker/attach.php index daefa43..6e047e7 100644 --- a/demo/ajax-broker/attach.php +++ b/demo/ajax-broker/attach.php @@ -8,7 +8,7 @@ // Configure the broker. $broker = new Broker( - getenv('SSO_SERVER'), + getenv('SSO_SERVER') . '/attach.php', getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET') ); diff --git a/demo/broker/index.php b/demo/broker/index.php index c6825e5..67128e7 100644 --- a/demo/broker/index.php +++ b/demo/broker/index.php @@ -9,7 +9,7 @@ // Configure the broker. $broker = new Broker( - getenv('SSO_SERVER'), + getenv('SSO_SERVER') . '/attach.php', getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET') ); diff --git a/demo/broker/login.php b/demo/broker/login.php index 8af4541..5e17329 100644 --- a/demo/broker/login.php +++ b/demo/broker/login.php @@ -9,7 +9,7 @@ // Configure the broker. $broker = new Broker( - getenv('SSO_SERVER'), + getenv('SSO_SERVER') . '/attach.php', getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET') ); @@ -62,7 +62,7 @@
-

getBrokerId() ?> (Single Sign-On demo)

+

Single Sign-On demo (Broker: getBrokerId() ?>)

diff --git a/demo/broker/logout.php b/demo/broker/logout.php index a2ee868..830e27f 100644 --- a/demo/broker/logout.php +++ b/demo/broker/logout.php @@ -9,7 +9,7 @@ // Configure the broker. $broker = new Broker( - getenv('SSO_SERVER'), + getenv('SSO_SERVER') . '/attach.php', getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET') ); diff --git a/demo/server/attach.php b/demo/server/attach.php index 1dee132..1fbca5d 100644 --- a/demo/server/attach.php +++ b/demo/server/attach.php @@ -16,12 +16,12 @@ $config = require __DIR__ . '/include/config.php'; // Instantiate the SSO server. -$ssoServer = new Server( +$ssoServer = (new Server( function (string $id) use ($config) { return $config['brokers'][$id] ?? null; // Callback to get the broker secret. You might fetch this from DB. }, new FileCache(sys_get_temp_dir()) // Any PSR-16 compatible cache -); +))->withLogger(new Loggy('SSO')); try { // Attach the broker token to the user session. Uses query parameters from $_GET. diff --git a/demo/server/include/start_broker_session.php b/demo/server/include/start_broker_session.php index 05591d7..e504bfc 100644 --- a/demo/server/include/start_broker_session.php +++ b/demo/server/include/start_broker_session.php @@ -14,12 +14,12 @@ $config = require __DIR__ . '/config.php'; // Instantiate the SSO server. -$ssoServer = new Server( +$ssoServer = (new Server( function (string $id) use ($config) { return $config['brokers'][$id] ?? null; // Callback to get the broker secret. You might fetch this from DB. }, new FileCache(sys_get_temp_dir()) // Any PSR-16 compatible cache -); +))->withLogger(new Loggy('SSO')); // Start the session using the broker bearer token (rather than a session cookie). try { diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php index 5244a45..91aa462 100644 --- a/src/Broker/Broker.php +++ b/src/Broker/Broker.php @@ -13,7 +13,7 @@ class Broker { /** - * Url of SSO server + * URL of SSO server. * @var string */ protected $url; @@ -49,12 +49,20 @@ class Broker /** * Class constructor * - * @param string $url Url of SSO server - * @param string $broker My identifier, given by SSO provider. - * @param string $secret My secret word, given by SSO provider. + * @param string $url Url of SSO server + * @param string $broker My identifier, given by SSO provider. + * @param string $secret My secret word, given by SSO provider. */ public function __construct(string $url, string $broker, string $secret) { + if (!preg_match('~^https?://~', $url)) { + throw new \InvalidArgumentException("Invalid SSO server URL '$url'"); + } + + if (preg_match('/\W/', $broker)) { + throw new \InvalidArgumentException("The broker id must be alphanumeric"); + } + $this->url = $url; $this->broker = $broker; $this->secret = $secret; @@ -169,7 +177,7 @@ public function getAttachUrl(array $params = []): string 'checksum' => $this->generateChecksum('attach') ]; - return $this->url . "/attach.php?" . http_build_query($data + $params); + return $this->url . "?" . http_build_query($data + $params); } /** @@ -191,7 +199,11 @@ protected function getRequestUrl(string $path, $params = ''): string { $query = is_array($params) ? http_build_query($params) : $params; - return $this->url . '/' . ltrim($path, '/') . ($query !== '' ? '?' . $query : ''); + $base = $path[0] === '/' + ? preg_replace('~^(\w+://[^/]+).*~', '$1', $this->url) + : preg_replace('~/[^/]*$~', '', $this->url); + + return $base . '/' . ltrim($path, '/') . ((string)$query !== '' ? '?' . $query : ''); } diff --git a/src/Server/Server.php b/src/Server/Server.php index b47241f..e73ae6c 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -6,8 +6,9 @@ use Jasny\Immutable; use Psr\Http\Message\ServerRequestInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Psr\SimpleCache\CacheInterface; -use Psr\SimpleCache\InvalidArgumentException; /** * Single sign-on server. @@ -30,17 +31,15 @@ class Server protected $cache; /** - * Service to interact with sessions. - * @var SessionInterface + * @var LoggerInterface */ - protected $session; + protected $logger; /** - * Broker of the current session. - * @var string|null + * Service to interact with sessions. + * @var SessionInterface */ - protected $brokerId = null; - + protected $session; /** * Class constructor. @@ -53,11 +52,24 @@ public function __construct(callable $getBrokerSecret, CacheInterface $cache) $this->getBrokerSecret = \Closure::fromCallable($getBrokerSecret); $this->cache = $cache; + $this->logger = new NullLogger(); $this->session = new GlobalSession(); } + /** + * Get a copy of the service with logging. + * + * @return static + */ + public function withLogger(LoggerInterface $logger): self + { + return $this->withProperty('logger', $logger); + } + /** * Get a copy of the service with a custom session service. + * + * @return static */ public function withSession(SessionInterface $session): self { @@ -83,18 +95,32 @@ public function startBrokerSession(?ServerRequestInterface $request = null): voi throw new BrokerException("Broker didn't use bearer authentication", 401); } + [$brokerId, $token] = $this->parseBearer($bearer); + try { - $linkedId = $this->cache->get($bearer); + $sessionId = $this->cache->get('SSO-' . $brokerId . '-' . $token); } catch (\Exception $exception) { - throw new ServerException("Failed to get session id from cache", 500, $exception); + $this->logger->error( + "Failed to get session id: " . $exception->getMessage(), + ['broker' => $brokerId, 'token' => $token] + ); + throw new ServerException("Failed to get session id", 500, $exception); } - if (!$linkedId) { - throw new BrokerException("Bearer token isn't attached to a user session", 403); + if (!$sessionId) { + $this->logger->warning( + "Bearer token isn't attached to a client session", + ['broker' => $brokerId, 'token' => $token] + ); + throw new BrokerException("Bearer token isn't attached to a client session", 403); } - $this->brokerId = $this->getBrokerIdFromBearer($bearer); - $this->session->start($linkedId); + $this->session->start($sessionId); + + $this->logger->debug( + "Broker request with session", + ['broker' => $brokerId, 'token' => $token, 'session' => $sessionId] + ); } /** @@ -112,32 +138,32 @@ protected function getBearerToken(?ServerRequestInterface $request = null): ?str } /** - * Get the broker id from the bearer token used by the broker. + * Get the broker id and token from the bearer token used by the broker. + * + * @return string[] + * @throws BrokerException */ - protected function getBrokerIdFromBearer(string $bearer): string + protected function parseBearer(string $bearer): array { $matches = null; if (!(bool)preg_match('/^SSO-(\w*+)-(\w*+)-([a-z0-9]*+)$/', $bearer, $matches)) { - throw new BrokerException("Invalid session id"); + $this->logger->warning("Invalid bearer token", ['bearer' => $bearer]); + throw new BrokerException("Invalid bearer token"); } - $brokerId = $matches[1]; - $token = $matches[2]; - - if ($this->generateBearerToken($brokerId, $token) != $bearer) { - throw new BrokerException("Bearer token checksum failed", 403); - } + [, $brokerId, $token, $checksum] = $matches; + $this->validateChecksum($checksum, 'bearer', $brokerId, $token); - return $brokerId; + return [$brokerId, $token]; } /** * Generate session id from session token. */ - protected function generateBearerToken(string $brokerId, string $token): string + protected function getCacheKey(string $brokerId, string $token): string { - return "SSO-{$brokerId}-{$token}-" . $this->generateChecksum('bearer', $brokerId, $token); + return "SSO-{$brokerId}-{$token}"; } /** @@ -145,23 +171,48 @@ protected function generateBearerToken(string $brokerId, string $token): string */ protected function generateChecksum(string $command, string $brokerId, string $token): string { - $secret = ($this->getBrokerSecret)($brokerId); + try { + $secret = ($this->getBrokerSecret)($brokerId); + } catch (\Exception $exception) { + $this->logger->warning( + "Failed to get broker secret: " . $exception->getMessage(), + ['broker' => $brokerId, 'token' => $token] + ); + throw new ServerException("Failed to get broker secret", 500, $exception); + } if ($secret === null) { - throw new BrokerException("Invalid broker id", 400); + $this->logger->warning("Unknown broker id", ['broker' => $brokerId, 'token' => $token]); + throw new BrokerException("Unknown broker id", 400); } - $checksum = hash_hmac('sha256', $command . ':' . $token, $secret); + return hash_hmac('sha256', $command . ':' . $token, $secret); + } - if ($checksum === null) { - throw new ServerException("Failed to generate $command checksum"); - } + /** + * Assert that the checksum matches the expected checksum. + * + * @param string $expected + * @param string $command + * @param string $brokerId + * @param string $token + * @throws BrokerException + */ + protected function validateChecksum(string $checksum, string $command, string $brokerId, string $token): void + { + $expected = $this->generateChecksum($command, $brokerId, $token); - return $checksum; + if ($checksum !== $expected) { + $this->logger->warning( + "Invalid $command checksum", + ['expected' => $expected, 'received' => $checksum, 'broker' => $brokerId, 'token' => $token] + ); + throw new BrokerException("Invalid checksum", 400); + } } /** - * Attach a user session to a broker session. + * Attach a client session to a broker session. * * @throws BrokerException * @throws ServerException @@ -172,23 +223,23 @@ public function attach(?ServerRequestInterface $request = null): void $token = $this->getQueryParam($request, 'token', true); $checksum = $this->getQueryParam($request, 'checksum', true); - $expectedChecksum = $this->generateChecksum('attach', $brokerId, $token); - - if ($checksum !== $expectedChecksum) { - throw new BrokerException("Invalid checksum", 400); - } + $this->validateChecksum($checksum, 'attach', $brokerId, $token); if (!$this->session->isActive()) { $this->session->start(); } - $bearer = $this->generateBearerToken($brokerId, $token); + $key = $this->getCacheKey($brokerId, $token); + $cached = $this->cache->set($key, $this->session->getId()); - try { - $this->cache->set($bearer, $this->session->getId()); - } catch (InvalidArgumentException $exception) { - throw new ServerException("Failed to attach bearer token to session id", 500, $exception); + $info = ['broker' => $brokerId, 'token' => $token, 'session' => $this->session->getId()]; + + if (!$cached) { + $this->logger->error("Failed to attach attach bearer token to session id due to cache issue", $info); + throw new ServerException("Failed to attach bearer token to session id"); } + + $this->logger->info("Attached broker token to session", $info); } /** From 0158583c38f65774a9fcecd039587b6ab0175cf3 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Mon, 14 Sep 2020 13:28:51 +0200 Subject: [PATCH 12/48] Put full URL with script name in env var (for demo). --- README.md | 18 +++++++++++++++--- demo/ajax-broker/api.php | 2 +- demo/ajax-broker/attach.php | 2 +- demo/broker/index.php | 2 +- demo/broker/login.php | 2 +- demo/broker/logout.php | 2 +- 6 files changed, 20 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0f73495..b882b62 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,9 @@ To proof it's working you should setup the server and two or more brokers, each On *nix (Linux / Unix / OSX) run: php -S localhost:8000 -t demo/server/ - export SSO_SERVER=http://localhost:8000 SSO_BROKER_ID=Alice SSO_BROKER_SECRET=8iwzik1bwd; php -S localhost:8001 -t demo/broker/ - export SSO_SERVER=http://localhost:8000 SSO_BROKER_ID=Greg SSO_BROKER_SECRET=7pypoox2pc; php -S localhost:8002 -t demo/broker/ - export SSO_SERVER=http://localhost:8000 SSO_BROKER_ID=Julias SSO_BROKER_SECRET=ceda63kmhp; php -S localhost:8003 -t demo/ajax-broker/ + export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Alice SSO_BROKER_SECRET=8iwzik1bwd; php -S localhost:8001 -t demo/broker/ + export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Greg SSO_BROKER_SECRET=7pypoox2pc; php -S localhost:8002 -t demo/broker/ + export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Julias SSO_BROKER_SECRET=ceda63kmhp; php -S localhost:8003 -t demo/ajax-broker/ Now open some tabs and visit @@ -153,6 +153,18 @@ $server = (new Server($callback, $cache)) The `withSession()` method can also be used with a mock object for testing. +#### Logging + +Enable logging for debugging and catching issues. + +```php +$server = (new Server($callback, $cache)) + ->withLogging(new Logger()); +``` + +Any PSR-3 compatible logger can be used, like [Monolog](https://packagist.org/packages/monolog/monolog) or +[Loggy](https://packagist.org/packages/yubb/loggy). The `context` may contain the broker id, token, and session id. + ### Broker When creating a `Broker` instance, you need to pass the server url, broker id and broker secret. The broker id diff --git a/demo/ajax-broker/api.php b/demo/ajax-broker/api.php index 1b44687..8108bee 100644 --- a/demo/ajax-broker/api.php +++ b/demo/ajax-broker/api.php @@ -8,7 +8,7 @@ // Configure the broker. $broker = new Broker( - getenv('SSO_SERVER') . '/attach.php', + getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET') ); diff --git a/demo/ajax-broker/attach.php b/demo/ajax-broker/attach.php index 6e047e7..daefa43 100644 --- a/demo/ajax-broker/attach.php +++ b/demo/ajax-broker/attach.php @@ -8,7 +8,7 @@ // Configure the broker. $broker = new Broker( - getenv('SSO_SERVER') . '/attach.php', + getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET') ); diff --git a/demo/broker/index.php b/demo/broker/index.php index 67128e7..c6825e5 100644 --- a/demo/broker/index.php +++ b/demo/broker/index.php @@ -9,7 +9,7 @@ // Configure the broker. $broker = new Broker( - getenv('SSO_SERVER') . '/attach.php', + getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET') ); diff --git a/demo/broker/login.php b/demo/broker/login.php index 5e17329..7167341 100644 --- a/demo/broker/login.php +++ b/demo/broker/login.php @@ -9,7 +9,7 @@ // Configure the broker. $broker = new Broker( - getenv('SSO_SERVER') . '/attach.php', + getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET') ); diff --git a/demo/broker/logout.php b/demo/broker/logout.php index 830e27f..a2ee868 100644 --- a/demo/broker/logout.php +++ b/demo/broker/logout.php @@ -9,7 +9,7 @@ // Configure the broker. $broker = new Broker( - getenv('SSO_SERVER') . '/attach.php', + getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET') ); From 6069cabc5c877c30a7bb858b037a409a069b86d2 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Tue, 15 Sep 2020 12:37:45 +0200 Subject: [PATCH 13/48] Fixes for PHPStan --- codeception.yml | 9 +---- composer.json | 23 +++++++++-- src/Broker/Broker.php | 34 ++++++++++------- src/Server/GlobalSession.php | 2 +- src/Server/Server.php | 2 +- tests/_data/dump.sql | 1 - tests/_support/AcceptanceTester.php | 26 ------------- tests/_support/Helper/Acceptance.php | 9 ----- tests/_support/Helper/BrokerApi.php | 9 ----- tests/_support/Helper/Functional.php | 9 ----- tests/_support/Helper/ServerApi.php | 9 ----- tests/_support/Helper/Unit.php | 9 ----- tests/_support/ServerApiTester.php | 39 ------------------- tests/brokerApi.suite.yml | 7 ---- tests/brokerApi/BrokerTesterCept.php | 54 -------------------------- tests/brokerApi/_bootstrap.php | 2 - tests/serverApi.suite.yml | 7 ---- tests/serverApi/ServerApiCept.php | 57 ---------------------------- tests/serverApi/_bootstrap.php | 2 - 19 files changed, 43 insertions(+), 267 deletions(-) delete mode 100644 tests/_data/dump.sql delete mode 100644 tests/_support/AcceptanceTester.php delete mode 100644 tests/_support/Helper/Acceptance.php delete mode 100644 tests/_support/Helper/BrokerApi.php delete mode 100644 tests/_support/Helper/Functional.php delete mode 100644 tests/_support/Helper/ServerApi.php delete mode 100644 tests/_support/Helper/Unit.php delete mode 100644 tests/_support/ServerApiTester.php delete mode 100644 tests/brokerApi.suite.yml delete mode 100644 tests/brokerApi/BrokerTesterCept.php delete mode 100644 tests/brokerApi/_bootstrap.php delete mode 100644 tests/serverApi.suite.yml delete mode 100644 tests/serverApi/ServerApiCept.php delete mode 100644 tests/serverApi/_bootstrap.php diff --git a/codeception.yml b/codeception.yml index 3a8fca8..172fb93 100644 --- a/codeception.yml +++ b/codeception.yml @@ -5,17 +5,10 @@ paths: data: tests/_data support: tests/_support envs: tests/_envs +bootstrap: _bootstrap.php settings: - bootstrap: _bootstrap.php colors: true memory_limit: 1024M extensions: enabled: - Codeception\Extension\RunFailed -modules: - config: - Db: - dsn: '' - user: '' - password: '' - dump: tests/_data/dump.sql diff --git a/composer.json b/composer.json index 03d4a4c..918562b 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Simple Single Sign-On", "keywords": ["sso", "auth"], "license": "MIT", - "homepage": "http://www.jasny.net/articles/simple-single-sign-on-for-php/", + "homepage": "https://github.com/jasny/sso/wiki", "authors": [ { "name": "Arnold Daniels", @@ -19,19 +19,34 @@ "php": ">=7.2.0", "ext-json": "*", "ext-curl": "*", - "psr/simple-cache": "^1.0", "jasny/immutable": "^2.1", + "psr/simple-cache": "^1.0", "psr/log": "^1.1" }, "require-dev": { "codeception/codeception": "^4.0", - "jasny/php-code-quality": "^2.6.0", + "codeception/module-phpbrowser": "^1.0", + "codeception/module-rest": "^1.2", "desarrolla2/cache": "^3.0", + "jasny/php-code-quality": "^2.6.0", "yubb/loggy": "^2.1" }, "autoload": { "psr-4": { "Jasny\\SSO\\": "src/" } - } + }, + "scripts": { + "test": [ + "phpstan analyse", + "phpcs -p src" + ] + }, + "config": { + "preferred-install": "dist", + "sort-packages": true, + "optimize-autoloader": true + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php index 91aa462..637c949 100644 --- a/src/Broker/Broker.php +++ b/src/Broker/Broker.php @@ -55,11 +55,11 @@ class Broker */ public function __construct(string $url, string $broker, string $secret) { - if (!preg_match('~^https?://~', $url)) { + if (!(bool)preg_match('~^https?://~', $url)) { throw new \InvalidArgumentException("Invalid SSO server URL '$url'"); } - if (preg_match('/\W/', $broker)) { + if ((bool)preg_match('/\W/', $broker)) { throw new \InvalidArgumentException("The broker id must be alphanumeric"); } @@ -166,6 +166,9 @@ public function isAttached(): bool /** * Get URL to attach session at SSO server. + * + * @param array $params + * @return string */ public function getAttachUrl(array $params = []): string { @@ -191,8 +194,8 @@ protected function generateChecksum(string $command): string /** * Get the request url for a command * - * @param string $path - * @param array|string $params Query parameters + * @param string $path + * @param array|string $params Query parameters * @return string */ protected function getRequestUrl(string $path, $params = ''): string @@ -203,25 +206,30 @@ protected function getRequestUrl(string $path, $params = ''): string ? preg_replace('~^(\w+://[^/]+).*~', '$1', $this->url) : preg_replace('~/[^/]*$~', '', $this->url); - return $base . '/' . ltrim($path, '/') . ((string)$query !== '' ? '?' . $query : ''); + return $base . '/' . ltrim($path, '/') . ($query !== '' ? '?' . $query : ''); } /** * Send an HTTP request to the SSO server. * - * @param string $method HTTP method: 'GET', 'POST', 'DELETE' - * @param string $path Relative path - * @param array|string $data Query or post parameters + * @param string $method HTTP method: 'GET', 'POST', 'DELETE' + * @param string $path Relative path + * @param array|string $data Query or post parameters * @return mixed * @throws NotAttachedException */ - public function request(string $method, string $path, $data = null) + public function request(string $method, string $path, $data = '') { $bearer = $this->getBearerToken(); $url = $this->getRequestUrl($path, $method === 'POST' ? '' : $data); $ch = curl_init($url); + + if ($ch === false) { + throw new \RuntimeException("Failed to initialize a cURL session"); + } + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_HTTPHEADER, [ @@ -229,12 +237,12 @@ public function request(string $method, string $path, $data = null) 'Authorization: Bearer '. $bearer ]); - if ($method === 'POST' && !empty($data)) { + if ($method === 'POST' && ($data !== [] && $data !== '')) { $post = is_string($data) ? $data : http_build_query($data); curl_setopt($ch, CURLOPT_POSTFIELDS, $post); } - $response = curl_exec($ch); + $response = (string)curl_exec($ch); return $this->handleResponse($ch, $response); } @@ -267,9 +275,9 @@ protected function handleResponse($ch, string $response) if ($httpCode === 403) { $this->clearToken(); - throw new NotAttachedException($data['error'] ?: $response, $httpCode); + throw new NotAttachedException($data['error'] ?? $response, $httpCode); } elseif ($httpCode >= 400) { - throw new RequestException($data['error'] ?: $response, $httpCode); + throw new RequestException($data['error'] ?? $response, $httpCode); } return $data; diff --git a/src/Server/GlobalSession.php b/src/Server/GlobalSession.php index 284ca62..660116f 100644 --- a/src/Server/GlobalSession.php +++ b/src/Server/GlobalSession.php @@ -13,7 +13,7 @@ class GlobalSession implements SessionInterface { /** * Options passed to session_start(). - * @var array + * @var array */ protected $options; diff --git a/src/Server/Server.php b/src/Server/Server.php index e73ae6c..d8d2ece 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -192,7 +192,7 @@ protected function generateChecksum(string $command, string $brokerId, string $t /** * Assert that the checksum matches the expected checksum. * - * @param string $expected + * @param string $checksum * @param string $command * @param string $brokerId * @param string $token diff --git a/tests/_data/dump.sql b/tests/_data/dump.sql deleted file mode 100644 index 4bc742c..0000000 --- a/tests/_data/dump.sql +++ /dev/null @@ -1 +0,0 @@ -/* Replace this file with actual dump of your database */ \ No newline at end of file diff --git a/tests/_support/AcceptanceTester.php b/tests/_support/AcceptanceTester.php deleted file mode 100644 index bfa59cb..0000000 --- a/tests/_support/AcceptanceTester.php +++ /dev/null @@ -1,26 +0,0 @@ -defaultArgs; - $args['command'] = $command; - - foreach ($extraArgs as $key => $value) { - $args[$key] = $value; - } - - $this->sendPost('/', $args); - } -} diff --git a/tests/brokerApi.suite.yml b/tests/brokerApi.suite.yml deleted file mode 100644 index fa84d78..0000000 --- a/tests/brokerApi.suite.yml +++ /dev/null @@ -1,7 +0,0 @@ -class_name: ServerApiTester -modules: - enabled: - - REST: - url: http://127.0.0.1:9001/examples/ajax-broker/ajax.php - depends: PhpBrowser - part: Json \ No newline at end of file diff --git a/tests/brokerApi/BrokerTesterCept.php b/tests/brokerApi/BrokerTesterCept.php deleted file mode 100644 index 86e7eea..0000000 --- a/tests/brokerApi/BrokerTesterCept.php +++ /dev/null @@ -1,54 +0,0 @@ -wantTo('login through broker and view user data'); -$I->sendServerRequest('getUserInfo'); -$I->seeResponseIsJson(); -$I->seeResponseCodeIs(200); -$I->seeResponseEquals('null'); - -$I->sendServerRequest('attach'); -$I->seeResponseIsJson(); - -$I->sendServerRequest('getUserInfo'); -$I->seeResponseIsJson(); -$I->seeResponseCodeIs(200); -$I->seeResponseEquals('null'); - - -$I->sendServerRequest('login', [ - 'password' => $username, - 'username' => $password -]); -$I->seeResponseCodeIs(200); -$I->seeResponseIsJson(['token' => $token]); - -$I->sendServerRequest('getUserInfo'); -$I->seeResponseCodeIs(200); -$I->seeResponseIsJson(); -$I->seeResponseContainsJson([ - 'fullname' => 'jackie', - 'email' => 'jackie@admin.com', - 'username' => 'admin' -]); - -$I->sendServerRequest('detach'); -$I->sendServerRequest('attach'); - -$I->sendServerRequest('getUserInfo'); -$I->seeResponseCodeIs(200); -$I->seeResponseIsJson(); -$I->seeResponseContainsJson([ - 'fullname' => 'jackie', - 'email' => 'jackie@admin.com', - 'username' => 'admin' -]); - -$I->sendServerRequest('logout'); -$I->seeResponseCodeIs(200); -$I->seeResponseIsJson('null'); \ No newline at end of file diff --git a/tests/brokerApi/_bootstrap.php b/tests/brokerApi/_bootstrap.php deleted file mode 100644 index 8a88555..0000000 --- a/tests/brokerApi/_bootstrap.php +++ /dev/null @@ -1,2 +0,0 @@ -defaultArgs = [ - 'token' => $token, - 'broker' => $broker, 'checksum' => $checksum, - 'PHPSESSID' => 'SSO-ServerApi-hello_world-0949c41dd2c747f8e1d4bfd85dd2f4d8' -]; - -$I->wantTo('attach session and view user info and logout'); -$I->sendServerRequest('attach', ['PHPSESSID' => '']); -$I->seeResponseIsJson(); -$I->seeResponseCodeIs(200); -$I->seeResponseContainsJson(['token' => $token]); - -$I->sendServerRequest('userInfo'); -$I->seeResponseCodeIs(401); -$I->seeResponseIsJson(); -$I->seeResponseContainsJson(['error' => 'Not logged in']); - -$I->sendServerRequest('login', [ - 'password' => 'wrong', - 'username' => 'wrong' -]); - -$I->seeResponseCodeIs(401); -$I->seeResponseIsJson(['error' => 'Incorrect credentials']); - -$I->sendServerRequest('login', [ - 'password' => $username, - 'username' => $password -]); -$I->seeResponseCodeIs(200); -$I->seeResponseIsJson(['token' => $token]); - -$I->sendServerRequest('userInfo'); -$I->seeResponseCodeIs(200); -$I->seeResponseIsJson(); -$I->seeResponseContainsJson([ - 'fullname' => 'jackie', - 'email' => 'jackie@admin.com', - 'username' => 'admin' -]); - -$I->sendServerRequest('logout'); -$I->seeResponseCodeIs(200); -$I->seeResponseIsJson(); - -$I->sendServerRequest('userInfo'); -$I->seeResponseCodeIs(401); -$I->seeResponseIsJson(); -$I->seeResponseContainsJson(['error' => 'Not logged in']); \ No newline at end of file diff --git a/tests/serverApi/_bootstrap.php b/tests/serverApi/_bootstrap.php deleted file mode 100644 index 8a88555..0000000 --- a/tests/serverApi/_bootstrap.php +++ /dev/null @@ -1,2 +0,0 @@ - Date: Tue, 15 Sep 2020 14:00:31 +0200 Subject: [PATCH 14/48] Use any `ArrayAccess` object to store token instead of specifically cookies. Add `Broker\Session` class to store token in PHP session. Rename `withCookies()` to `withTokenIn()`. --- README.md | 67 +- src/Broker/Broker.php | 22 +- src/Broker/{GlobalCookies.php => Cookies.php} | 21 +- src/Broker/CookiesInterface.php | 35 - src/Broker/Session.php | 45 + .../_generated/AcceptanceTesterActions.php | 1972 ----------------- .../_support/_generated/ApiTesterActions.php | 905 -------- .../_generated/FunctionalTesterActions.php | 18 - .../_support/_generated/UnitTesterActions.php | 348 --- 9 files changed, 116 insertions(+), 3317 deletions(-) rename src/Broker/{GlobalCookies.php => Cookies.php} (73%) delete mode 100644 src/Broker/CookiesInterface.php create mode 100644 src/Broker/Session.php delete mode 100644 tests/_support/_generated/AcceptanceTesterActions.php delete mode 100644 tests/_support/_generated/ApiTesterActions.php delete mode 100644 tests/_support/_generated/FunctionalTesterActions.php delete mode 100644 tests/_support/_generated/UnitTesterActions.php diff --git a/README.md b/README.md index b882b62..e6f0598 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ john | john123 _Note that after logging in, you need to refresh on the other brokers to see the effect._ -## Usage +# Usage -### Server +## Server The `Server` class takes a callback as first constructor argument. This callback should lookup the secret for a broker based on the id. @@ -88,12 +88,12 @@ client session. use Jasny\SSO\Server\Server; $server = new Server( - fn (string $id): string => $brokerSecrets[$id], // Unique secret for each broker + fn(string $id): string => $brokerSecrets[$id], // Unique secret for each broker new Cache() // Any PSR-16 compatible cache ); ``` -#### Attach +### Attach A client needs attach the broker token to the session id by doing an HTTP request to the server. This request can be handled by calling `attach()`. @@ -104,7 +104,7 @@ $server->attach(); If it's not possible to attach (for instance in case of an incorrect checksum), an Exception is thrown. -#### Handle broker API request +### Handle broker API request After the client session is attached to the broker token, the broker is able to send API requests on behalf of the client. Calling the `startBrokerSession()` method with start the session of the client based on the bearer token. This @@ -119,7 +119,7 @@ the scope of the project. However since the broker uses normal sessions, any exi _If you're lookup for an authentication library, consider using [Jasny Auth](https://github.com/jasny/auth)._ -#### PSR-7 +### PSR-7 By default, the library works with superglobals like `$_GET` and `$_SERVER`. Alternatively it can use a PSR-7 server request. This can be passed to `attach()` and `startBrokerSession()` as argument. @@ -128,7 +128,7 @@ request. This can be passed to `attach()` and `startBrokerSession()` as argument $server->attach($serverRequest); ``` -#### Session interface +### Session interface By default, the library uses the superglobal `$_SESSION` and the `php_session_*()` functions. It does this through the `GlobalSession` object, which implements `SessionInterface`. @@ -153,7 +153,7 @@ $server = (new Server($callback, $cache)) The `withSession()` method can also be used with a mock object for testing. -#### Logging +### Logging Enable logging for debugging and catching issues. @@ -165,14 +165,14 @@ $server = (new Server($callback, $cache)) Any PSR-3 compatible logger can be used, like [Monolog](https://packagist.org/packages/monolog/monolog) or [Loggy](https://packagist.org/packages/yubb/loggy). The `context` may contain the broker id, token, and session id. -### Broker +## Broker When creating a `Broker` instance, you need to pass the server url, broker id and broker secret. The broker id and secret needs to match the secret registered at the server. **CAVEAT**: *The broker id MUST be alphanumeric.* -#### Attach +### Attach Before the broker can do API requests on the client's behalve, the client needs to attach the broker token to the client session. For this, the client must do an HTTP request to the SSO Server. @@ -204,7 +204,7 @@ if (!$broker->isAttached()) { } ``` -#### API requests +### API requests Once attached, the broker is able to do API requests on behalf of the client. @@ -228,25 +228,46 @@ $res = $guzzle->request('GET', '/user', [ ]); ``` -#### Custom cookie handler +### Client state -By default, the Broker uses the superglobal `$_COOKIE` and `setcookie()` function via the `GlobalCookie` class. -Alternative, you can make a custom class that implements `CookieInterface`. +By default, the Broker uses the cookies (`$_COOKIE` and `setcookie()`) via the `Cookies` class to persist the client's +SSO token. + +#### Cookie + +Instantiate a new `Cookies` object with custom parameters to modify things like cookie TTL, domain and https only. ```php -use Jasny\SSO\Broker\CookiesInterface; +use Jasny\SSO\Broker\{Broker,Cookies}; -class CustomCookieHandler implements CookiesInterface -{ - // ... -} +$broker = (new Broker(getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET'))) + ->withTokenIn(new Cookies(7200, '/myapp', 'example.com', true)); ``` -The `withCookies()` methods creates a copy of the Broker object with the custom cookies interface. +_(The cookie can never be accessed by the browser.)_ + +#### Session + +Alternative, you can store the SSO token in a PHP session for the broker by using `SessionState`. ```php -$server = (new Broker(getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET'))) - ->withCookies(new CustomCookieHandler()); +use Jasny\SSO\Broker\{Broker,Session}; + +session_start(); + +$broker = (new Broker(getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET'))) + ->withTokenIn(new Session()); +``` + +#### Custom + +The method accepts any object that implements `ArrayAccess`, allowing you to create a custom handler if needed. + +```php +class CustomStateHandler implements \ArrayAccess +{ + // ... +} ``` -The `withCookies()` method can also be used with a mock object for testing. +This can also be used with a mock object for testing. diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php index 637c949..0b5cbe2 100644 --- a/src/Broker/Broker.php +++ b/src/Broker/Broker.php @@ -42,9 +42,9 @@ class Broker protected $token; /** - * @var CookiesInterface + * @var \ArrayAccess */ - protected $cookies; + protected $state; /** * Class constructor @@ -67,23 +67,23 @@ public function __construct(string $url, string $broker, string $secret) $this->broker = $broker; $this->secret = $secret; - $this->cookies = new GlobalCookies(); + $this->state = new Cookies(); } /** - * Get a copy with a custom cookie handler. + * Get a copy with a different handler for the user state (like cookie or session). * - * @param CookiesInterface $cookies + * @param \ArrayAccess $handler * @return static */ - public function withCookies(CookiesInterface $cookies): self + public function withTokenIn(\ArrayAccess $handler): self { - if ($this->cookies === $cookies) { + if ($this->state === $handler) { return $this; } $clone = clone $this; - $clone->cookies = $cookies; + $clone->state = $handler; return $clone; } @@ -102,7 +102,7 @@ public function getBrokerId(): string protected function getToken(): ?string { if (!$this->initialized) { - $this->token = $this->cookies->get($this->getCookieName()); + $this->token = $this->state[$this->getCookieName()]; $this->initialized = true; } @@ -144,7 +144,7 @@ public function generateToken(): void } $this->token = base_convert(bin2hex(random_bytes(16)), 16, 36); - $this->cookies->set($this->getCookieName(), $this->token); + $this->state[$this->getCookieName()] = $this->token; } /** @@ -152,7 +152,7 @@ public function generateToken(): void */ public function clearToken(): void { - $this->cookies->clear($this->getCookieName()); + unset($this->state[$this->getCookieName()]); $this->token = null; } diff --git a/src/Broker/GlobalCookies.php b/src/Broker/Cookies.php similarity index 73% rename from src/Broker/GlobalCookies.php rename to src/Broker/Cookies.php index 8f520ee..df9498a 100644 --- a/src/Broker/GlobalCookies.php +++ b/src/Broker/Cookies.php @@ -5,11 +5,11 @@ namespace Jasny\SSO\Broker; /** - * Use global $_COOKIES and setcookie(). + * Use global $_COOKIE and setcookie() to persist the client token. * * @codeCoverageIgnore */ -class GlobalCookies implements CookiesInterface +class Cookies implements \ArrayAccess { /** @var int */ protected $ttl; @@ -42,28 +42,39 @@ public function __construct(int $ttl = 3600, string $path = '', string $domain = /** * @inheritDoc */ - public function set(string $name, $value): void + public function offsetSet($name, $value) { $success = setcookie($name, $value, time() + $this->ttl, $this->domain, $this->path, $this->secure, true); if (!$success) { throw new \RuntimeException("Failed to set cookie '$name'"); } + + $_COOKIE[$name] = $value; } /** * @inheritDoc */ - public function clear(string $name): void + public function offsetUnset($name): void { setcookie($name, '', 1, $this->domain, $this->path, $this->secure, true); + unset($_COOKIE[$name]); } /** * @inheritDoc */ - public function get(string $name) + public function offsetGet($name) { return $_COOKIE[$name] ?? null; } + + /** + * @inheritDoc + */ + public function offsetExists($name) + { + return isset($_COOKIE[$name]); + } } diff --git a/src/Broker/CookiesInterface.php b/src/Broker/CookiesInterface.php deleted file mode 100644 index 9d6236a..0000000 --- a/src/Broker/CookiesInterface.php +++ /dev/null @@ -1,35 +0,0 @@ -setHeader('X-Requested-With', 'Codeception'); - * $I->amOnPage('test-headers.php'); - * ?> - * ``` - * - * @param string $name the name of the request header - * @param string $value the value to set it to for subsequent - * requests - * @see \Codeception\Module\PhpBrowser::setHeader() - */ - public function setHeader($name, $value) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('setHeader', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Deletes the header with the passed name. Subsequent requests - * will not have the deleted header in its request. - * - * Example: - * ```php - * setHeader('X-Requested-With', 'Codeception'); - * $I->amOnPage('test-headers.php'); - * // ... - * $I->deleteHeader('X-Requested-With'); - * $I->amOnPage('some-other-page.php'); - * ?> - * ``` - * - * @param string $name the name of the header to delete. - * @see \Codeception\Module\PhpBrowser::deleteHeader() - */ - public function deleteHeader($name) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('deleteHeader', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Authenticates user for HTTP_AUTH - * - * @param $username - * @param $password - * @see \Codeception\Module\PhpBrowser::amHttpAuthenticated() - */ - public function amHttpAuthenticated($username, $password) { - return $this->getScenario()->runStep(new \Codeception\Step\Condition('amHttpAuthenticated', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Open web page at the given absolute URL and sets its hostname as the base host. - * - * ``` php - * amOnUrl('http://codeception.com'); - * $I->amOnPage('/quickstart'); // moves to http://codeception.com/quickstart - * ?> - * ``` - * @see \Codeception\Module\PhpBrowser::amOnUrl() - */ - public function amOnUrl($url) { - return $this->getScenario()->runStep(new \Codeception\Step\Condition('amOnUrl', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Changes the subdomain for the 'url' configuration parameter. - * Does not open a page; use `amOnPage` for that. - * - * ``` php - * amOnSubdomain('user'); - * $I->amOnPage('/'); - * // moves to http://user.mysite.com/ - * ?> - * ``` - * - * @param $subdomain - * - * @return mixed - * @see \Codeception\Module\PhpBrowser::amOnSubdomain() - */ - public function amOnSubdomain($subdomain) { - return $this->getScenario()->runStep(new \Codeception\Step\Condition('amOnSubdomain', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Low-level API method. - * If Codeception commands are not enough, use [Guzzle HTTP Client](http://guzzlephp.org/) methods directly - * - * Example: - * - * ``` php - * executeInGuzzle(function (\GuzzleHttp\Client $client) { - * $client->get('/get', ['query' => ['foo' => 'bar']]); - * }); - * ?> - * ``` - * - * It is not recommended to use this command on a regular basis. - * If Codeception lacks important Guzzle Client methods, implement them and submit patches. - * - * @param callable $function - * @see \Codeception\Module\PhpBrowser::executeInGuzzle() - */ - public function executeInGuzzle($function) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('executeInGuzzle', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Opens the page for the given relative URI. - * - * ``` php - * amOnPage('/'); - * // opens /register page - * $I->amOnPage('/register'); - * ?> - * ``` - * - * @param $page - * @see \Codeception\Lib\InnerBrowser::amOnPage() - */ - public function amOnPage($page) { - return $this->getScenario()->runStep(new \Codeception\Step\Condition('amOnPage', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Perform a click on a link or a button, given by a locator. - * If a fuzzy locator is given, the page will be searched for a button, link, or image matching the locator string. - * For buttons, the "value" attribute, "name" attribute, and inner text are searched. - * For links, the link text is searched. - * For images, the "alt" attribute and inner text of any parent links are searched. - * - * The second parameter is a context (CSS or XPath locator) to narrow the search. - * - * Note that if the locator matches a button of type `submit`, the form will be submitted. - * - * ``` php - * click('Logout'); - * // button of form - * $I->click('Submit'); - * // CSS button - * $I->click('#form input[type=submit]'); - * // XPath - * $I->click('//form/*[@type=submit]'); - * // link in context - * $I->click('Logout', '#nav'); - * // using strict locator - * $I->click(['link' => 'Login']); - * ?> - * ``` - * - * @param $link - * @param $context - * @see \Codeception\Lib\InnerBrowser::click() - */ - public function click($link, $context = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('click', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current page contains the given string. - * Specify a locator as the second parameter to match a specific region. - * - * ``` php - * see('Logout'); // I can suppose user is logged in - * $I->see('Sign Up','h1'); // I can suppose it's a signup page - * $I->see('Sign Up','//body/h1'); // with XPath - * ?> - * ``` - * - * @param $text - * @param null $selector - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::see() - */ - public function canSee($text, $selector = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('see', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current page contains the given string. - * Specify a locator as the second parameter to match a specific region. - * - * ``` php - * see('Logout'); // I can suppose user is logged in - * $I->see('Sign Up','h1'); // I can suppose it's a signup page - * $I->see('Sign Up','//body/h1'); // with XPath - * ?> - * ``` - * - * @param $text - * @param null $selector - * @see \Codeception\Lib\InnerBrowser::see() - */ - public function see($text, $selector = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('see', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current page doesn't contain the text specified. - * Give a locator as the second parameter to match a specific region. - * - * ```php - * dontSee('Login'); // I can suppose user is already logged in - * $I->dontSee('Sign Up','h1'); // I can suppose it's not a signup page - * $I->dontSee('Sign Up','//body/h1'); // with XPath - * ?> - * ``` - * - * @param $text - * @param null $selector - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSee() - */ - public function cantSee($text, $selector = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSee', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current page doesn't contain the text specified. - * Give a locator as the second parameter to match a specific region. - * - * ```php - * dontSee('Login'); // I can suppose user is already logged in - * $I->dontSee('Sign Up','h1'); // I can suppose it's not a signup page - * $I->dontSee('Sign Up','//body/h1'); // with XPath - * ?> - * ``` - * - * @param $text - * @param null $selector - * @see \Codeception\Lib\InnerBrowser::dontSee() - */ - public function dontSee($text, $selector = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSee', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that there's a link with the specified text. - * Give a full URL as the second parameter to match links with that exact URL. - * - * ``` php - * seeLink('Logout'); // matches Logout - * $I->seeLink('Logout','/logout'); // matches Logout - * ?> - * ``` - * - * @param $text - * @param null $url - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeLink() - */ - public function canSeeLink($text, $url = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeLink', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that there's a link with the specified text. - * Give a full URL as the second parameter to match links with that exact URL. - * - * ``` php - * seeLink('Logout'); // matches Logout - * $I->seeLink('Logout','/logout'); // matches Logout - * ?> - * ``` - * - * @param $text - * @param null $url - * @see \Codeception\Lib\InnerBrowser::seeLink() - */ - public function seeLink($text, $url = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeLink', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the page doesn't contain a link with the given string. - * If the second parameter is given, only links with a matching "href" attribute will be checked. - * - * ``` php - * dontSeeLink('Logout'); // I suppose user is not logged in - * $I->dontSeeLink('Checkout now', '/store/cart.php'); - * ?> - * ``` - * - * @param $text - * @param null $url - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeLink() - */ - public function cantSeeLink($text, $url = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeLink', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the page doesn't contain a link with the given string. - * If the second parameter is given, only links with a matching "href" attribute will be checked. - * - * ``` php - * dontSeeLink('Logout'); // I suppose user is not logged in - * $I->dontSeeLink('Checkout now', '/store/cart.php'); - * ?> - * ``` - * - * @param $text - * @param null $url - * @see \Codeception\Lib\InnerBrowser::dontSeeLink() - */ - public function dontSeeLink($text, $url = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeLink', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that current URI contains the given string. - * - * ``` php - * seeInCurrentUrl('home'); - * // to match: /users/1 - * $I->seeInCurrentUrl('/users/'); - * ?> - * ``` - * - * @param $uri - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeInCurrentUrl() - */ - public function canSeeInCurrentUrl($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeInCurrentUrl', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that current URI contains the given string. - * - * ``` php - * seeInCurrentUrl('home'); - * // to match: /users/1 - * $I->seeInCurrentUrl('/users/'); - * ?> - * ``` - * - * @param $uri - * @see \Codeception\Lib\InnerBrowser::seeInCurrentUrl() - */ - public function seeInCurrentUrl($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeInCurrentUrl', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URI doesn't contain the given string. - * - * ``` php - * dontSeeInCurrentUrl('/users/'); - * ?> - * ``` - * - * @param $uri - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeInCurrentUrl() - */ - public function cantSeeInCurrentUrl($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeInCurrentUrl', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URI doesn't contain the given string. - * - * ``` php - * dontSeeInCurrentUrl('/users/'); - * ?> - * ``` - * - * @param $uri - * @see \Codeception\Lib\InnerBrowser::dontSeeInCurrentUrl() - */ - public function dontSeeInCurrentUrl($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeInCurrentUrl', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URL is equal to the given string. - * Unlike `seeInCurrentUrl`, this only matches the full URL. - * - * ``` php - * seeCurrentUrlEquals('/'); - * ?> - * ``` - * - * @param $uri - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeCurrentUrlEquals() - */ - public function canSeeCurrentUrlEquals($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeCurrentUrlEquals', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URL is equal to the given string. - * Unlike `seeInCurrentUrl`, this only matches the full URL. - * - * ``` php - * seeCurrentUrlEquals('/'); - * ?> - * ``` - * - * @param $uri - * @see \Codeception\Lib\InnerBrowser::seeCurrentUrlEquals() - */ - public function seeCurrentUrlEquals($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeCurrentUrlEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URL doesn't equal the given string. - * Unlike `dontSeeInCurrentUrl`, this only matches the full URL. - * - * ``` php - * dontSeeCurrentUrlEquals('/'); - * ?> - * ``` - * - * @param $uri - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeCurrentUrlEquals() - */ - public function cantSeeCurrentUrlEquals($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeCurrentUrlEquals', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URL doesn't equal the given string. - * Unlike `dontSeeInCurrentUrl`, this only matches the full URL. - * - * ``` php - * dontSeeCurrentUrlEquals('/'); - * ?> - * ``` - * - * @param $uri - * @see \Codeception\Lib\InnerBrowser::dontSeeCurrentUrlEquals() - */ - public function dontSeeCurrentUrlEquals($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeCurrentUrlEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URL matches the given regular expression. - * - * ``` php - * seeCurrentUrlMatches('~$/users/(\d+)~'); - * ?> - * ``` - * - * @param $uri - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeCurrentUrlMatches() - */ - public function canSeeCurrentUrlMatches($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeCurrentUrlMatches', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URL matches the given regular expression. - * - * ``` php - * seeCurrentUrlMatches('~$/users/(\d+)~'); - * ?> - * ``` - * - * @param $uri - * @see \Codeception\Lib\InnerBrowser::seeCurrentUrlMatches() - */ - public function seeCurrentUrlMatches($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeCurrentUrlMatches', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that current url doesn't match the given regular expression. - * - * ``` php - * dontSeeCurrentUrlMatches('~$/users/(\d+)~'); - * ?> - * ``` - * - * @param $uri - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeCurrentUrlMatches() - */ - public function cantSeeCurrentUrlMatches($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeCurrentUrlMatches', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that current url doesn't match the given regular expression. - * - * ``` php - * dontSeeCurrentUrlMatches('~$/users/(\d+)~'); - * ?> - * ``` - * - * @param $uri - * @see \Codeception\Lib\InnerBrowser::dontSeeCurrentUrlMatches() - */ - public function dontSeeCurrentUrlMatches($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeCurrentUrlMatches', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Executes the given regular expression against the current URI and returns the first match. - * If no parameters are provided, the full URI is returned. - * - * ``` php - * grabFromCurrentUrl('~$/user/(\d+)/~'); - * $uri = $I->grabFromCurrentUrl(); - * ?> - * ``` - * - * @param null $uri - * - * @internal param $url - * @return mixed - * @see \Codeception\Lib\InnerBrowser::grabFromCurrentUrl() - */ - public function grabFromCurrentUrl($uri = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabFromCurrentUrl', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the specified checkbox is checked. - * - * ``` php - * seeCheckboxIsChecked('#agree'); // I suppose user agreed to terms - * $I->seeCheckboxIsChecked('#signup_form input[type=checkbox]'); // I suppose user agreed to terms, If there is only one checkbox in form. - * $I->seeCheckboxIsChecked('//form/input[@type=checkbox and @name=agree]'); - * ?> - * ``` - * - * @param $checkbox - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeCheckboxIsChecked() - */ - public function canSeeCheckboxIsChecked($checkbox) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeCheckboxIsChecked', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the specified checkbox is checked. - * - * ``` php - * seeCheckboxIsChecked('#agree'); // I suppose user agreed to terms - * $I->seeCheckboxIsChecked('#signup_form input[type=checkbox]'); // I suppose user agreed to terms, If there is only one checkbox in form. - * $I->seeCheckboxIsChecked('//form/input[@type=checkbox and @name=agree]'); - * ?> - * ``` - * - * @param $checkbox - * @see \Codeception\Lib\InnerBrowser::seeCheckboxIsChecked() - */ - public function seeCheckboxIsChecked($checkbox) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeCheckboxIsChecked', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Check that the specified checkbox is unchecked. - * - * ``` php - * dontSeeCheckboxIsChecked('#agree'); // I suppose user didn't agree to terms - * $I->seeCheckboxIsChecked('#signup_form input[type=checkbox]'); // I suppose user didn't check the first checkbox in form. - * ?> - * ``` - * - * @param $checkbox - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeCheckboxIsChecked() - */ - public function cantSeeCheckboxIsChecked($checkbox) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeCheckboxIsChecked', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Check that the specified checkbox is unchecked. - * - * ``` php - * dontSeeCheckboxIsChecked('#agree'); // I suppose user didn't agree to terms - * $I->seeCheckboxIsChecked('#signup_form input[type=checkbox]'); // I suppose user didn't check the first checkbox in form. - * ?> - * ``` - * - * @param $checkbox - * @see \Codeception\Lib\InnerBrowser::dontSeeCheckboxIsChecked() - */ - public function dontSeeCheckboxIsChecked($checkbox) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeCheckboxIsChecked', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given input field or textarea contains the given value. - * For fuzzy locators, fields are matched by label text, the "name" attribute, CSS, and XPath. - * - * ``` php - * seeInField('Body','Type your comment here'); - * $I->seeInField('form textarea[name=body]','Type your comment here'); - * $I->seeInField('form input[type=hidden]','hidden_value'); - * $I->seeInField('#searchform input','Search'); - * $I->seeInField('//form/*[@name=search]','Search'); - * $I->seeInField(['name' => 'search'], 'Search'); - * ?> - * ``` - * - * @param $field - * @param $value - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeInField() - */ - public function canSeeInField($field, $value) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeInField', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given input field or textarea contains the given value. - * For fuzzy locators, fields are matched by label text, the "name" attribute, CSS, and XPath. - * - * ``` php - * seeInField('Body','Type your comment here'); - * $I->seeInField('form textarea[name=body]','Type your comment here'); - * $I->seeInField('form input[type=hidden]','hidden_value'); - * $I->seeInField('#searchform input','Search'); - * $I->seeInField('//form/*[@name=search]','Search'); - * $I->seeInField(['name' => 'search'], 'Search'); - * ?> - * ``` - * - * @param $field - * @param $value - * @see \Codeception\Lib\InnerBrowser::seeInField() - */ - public function seeInField($field, $value) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeInField', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that an input field or textarea doesn't contain the given value. - * For fuzzy locators, the field is matched by label text, CSS and XPath. - * - * ``` php - * dontSeeInField('Body','Type your comment here'); - * $I->dontSeeInField('form textarea[name=body]','Type your comment here'); - * $I->dontSeeInField('form input[type=hidden]','hidden_value'); - * $I->dontSeeInField('#searchform input','Search'); - * $I->dontSeeInField('//form/*[@name=search]','Search'); - * $I->dontSeeInField(['name' => 'search'], 'Search'); - * ?> - * ``` - * - * @param $field - * @param $value - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeInField() - */ - public function cantSeeInField($field, $value) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeInField', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that an input field or textarea doesn't contain the given value. - * For fuzzy locators, the field is matched by label text, CSS and XPath. - * - * ``` php - * dontSeeInField('Body','Type your comment here'); - * $I->dontSeeInField('form textarea[name=body]','Type your comment here'); - * $I->dontSeeInField('form input[type=hidden]','hidden_value'); - * $I->dontSeeInField('#searchform input','Search'); - * $I->dontSeeInField('//form/*[@name=search]','Search'); - * $I->dontSeeInField(['name' => 'search'], 'Search'); - * ?> - * ``` - * - * @param $field - * @param $value - * @see \Codeception\Lib\InnerBrowser::dontSeeInField() - */ - public function dontSeeInField($field, $value) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeInField', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if the array of form parameters (name => value) are set on the form matched with the - * passed selector. - * - * ``` php - * seeInFormFields('form[name=myform]', [ - * 'input1' => 'value', - * 'input2' => 'other value', - * ]); - * ?> - * ``` - * - * For multi-select elements, or to check values of multiple elements with the same name, an - * array may be passed: - * - * ``` php - * seeInFormFields('.form-class', [ - * 'multiselect' => [ - * 'value1', - * 'value2', - * ], - * 'checkbox[]' => [ - * 'a checked value', - * 'another checked value', - * ], - * ]); - * ?> - * ``` - * - * Additionally, checkbox values can be checked with a boolean. - * - * ``` php - * seeInFormFields('#form-id', [ - * 'checkbox1' => true, // passes if checked - * 'checkbox2' => false, // passes if unchecked - * ]); - * ?> - * ``` - * - * Pair this with submitForm for quick testing magic. - * - * ``` php - * 'value', - * 'field2' => 'another value', - * 'checkbox1' => true, - * // ... - * ]; - * $I->submitForm('//form[@id=my-form]', $form, 'submitButton'); - * // $I->amOnPage('/path/to/form-page') may be needed - * $I->seeInFormFields('//form[@id=my-form]', $form); - * ?> - * ``` - * - * @param $formSelector - * @param $params - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeInFormFields() - */ - public function canSeeInFormFields($formSelector, $params) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeInFormFields', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if the array of form parameters (name => value) are set on the form matched with the - * passed selector. - * - * ``` php - * seeInFormFields('form[name=myform]', [ - * 'input1' => 'value', - * 'input2' => 'other value', - * ]); - * ?> - * ``` - * - * For multi-select elements, or to check values of multiple elements with the same name, an - * array may be passed: - * - * ``` php - * seeInFormFields('.form-class', [ - * 'multiselect' => [ - * 'value1', - * 'value2', - * ], - * 'checkbox[]' => [ - * 'a checked value', - * 'another checked value', - * ], - * ]); - * ?> - * ``` - * - * Additionally, checkbox values can be checked with a boolean. - * - * ``` php - * seeInFormFields('#form-id', [ - * 'checkbox1' => true, // passes if checked - * 'checkbox2' => false, // passes if unchecked - * ]); - * ?> - * ``` - * - * Pair this with submitForm for quick testing magic. - * - * ``` php - * 'value', - * 'field2' => 'another value', - * 'checkbox1' => true, - * // ... - * ]; - * $I->submitForm('//form[@id=my-form]', $form, 'submitButton'); - * // $I->amOnPage('/path/to/form-page') may be needed - * $I->seeInFormFields('//form[@id=my-form]', $form); - * ?> - * ``` - * - * @param $formSelector - * @param $params - * @see \Codeception\Lib\InnerBrowser::seeInFormFields() - */ - public function seeInFormFields($formSelector, $params) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeInFormFields', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if the array of form parameters (name => value) are not set on the form matched with - * the passed selector. - * - * ``` php - * dontSeeInFormFields('form[name=myform]', [ - * 'input1' => 'non-existent value', - * 'input2' => 'other non-existent value', - * ]); - * ?> - * ``` - * - * To check that an element hasn't been assigned any one of many values, an array can be passed - * as the value: - * - * ``` php - * dontSeeInFormFields('.form-class', [ - * 'fieldName' => [ - * 'This value shouldn\'t be set', - * 'And this value shouldn\'t be set', - * ], - * ]); - * ?> - * ``` - * - * Additionally, checkbox values can be checked with a boolean. - * - * ``` php - * dontSeeInFormFields('#form-id', [ - * 'checkbox1' => true, // fails if checked - * 'checkbox2' => false, // fails if unchecked - * ]); - * ?> - * ``` - * - * @param $formSelector - * @param $params - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeInFormFields() - */ - public function cantSeeInFormFields($formSelector, $params) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeInFormFields', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if the array of form parameters (name => value) are not set on the form matched with - * the passed selector. - * - * ``` php - * dontSeeInFormFields('form[name=myform]', [ - * 'input1' => 'non-existent value', - * 'input2' => 'other non-existent value', - * ]); - * ?> - * ``` - * - * To check that an element hasn't been assigned any one of many values, an array can be passed - * as the value: - * - * ``` php - * dontSeeInFormFields('.form-class', [ - * 'fieldName' => [ - * 'This value shouldn\'t be set', - * 'And this value shouldn\'t be set', - * ], - * ]); - * ?> - * ``` - * - * Additionally, checkbox values can be checked with a boolean. - * - * ``` php - * dontSeeInFormFields('#form-id', [ - * 'checkbox1' => true, // fails if checked - * 'checkbox2' => false, // fails if unchecked - * ]); - * ?> - * ``` - * - * @param $formSelector - * @param $params - * @see \Codeception\Lib\InnerBrowser::dontSeeInFormFields() - */ - public function dontSeeInFormFields($formSelector, $params) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeInFormFields', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Submits the given form on the page, optionally with the given form - * values. Give the form fields values as an array. - * - * Skipped fields will be filled by their values from the page. - * You don't need to click the 'Submit' button afterwards. - * This command itself triggers the request to form's action. - * - * You can optionally specify what button's value to include - * in the request with the last parameter as an alternative to - * explicitly setting its value in the second parameter, as - * button values are not otherwise included in the request. - * - * Examples: - * - * ``` php - * submitForm('#login', [ - * 'login' => 'davert', - * 'password' => '123456' - * ]); - * // or - * $I->submitForm('#login', [ - * 'login' => 'davert', - * 'password' => '123456' - * ], 'submitButtonName'); - * - * ``` - * - * For example, given this sample "Sign Up" form: - * - * ``` html - *
- * Login: - *
- * Password: - *
- * Do you agree to our terms? - *
- * Select pricing plan: - * - * - *
- * ``` - * - * You could write the following to submit it: - * - * ``` php - * submitForm( - * '#userForm', - * [ - * 'user' => [ - * 'login' => 'Davert', - * 'password' => '123456', - * 'agree' => true - * ] - * ], - * 'submitButton' - * ); - * ``` - * Note that "2" will be the submitted value for the "plan" field, as it is - * the selected option. - * - * You can also emulate a JavaScript submission by not specifying any - * buttons in the third parameter to submitForm. - * - * ```php - * submitForm( - * '#userForm', - * [ - * 'user' => [ - * 'login' => 'Davert', - * 'password' => '123456', - * 'agree' => true - * ] - * ] - * ); - * ``` - * - * Pair this with seeInFormFields for quick testing magic. - * - * ``` php - * 'value', - * 'field2' => 'another value', - * 'checkbox1' => true, - * // ... - * ]; - * $I->submitForm('//form[@id=my-form]', $form, 'submitButton'); - * // $I->amOnPage('/path/to/form-page') may be needed - * $I->seeInFormFields('//form[@id=my-form]', $form); - * ?> - * ``` - * - * Parameter values can be set to arrays for multiple input fields - * of the same name, or multi-select combo boxes. For checkboxes, - * either the string value can be used, or boolean values which will - * be replaced by the checkbox's value in the DOM. - * - * ``` php - * submitForm('#my-form', [ - * 'field1' => 'value', - * 'checkbox' => [ - * 'value of first checkbox', - * 'value of second checkbox, - * ], - * 'otherCheckboxes' => [ - * true, - * false, - * false - * ], - * 'multiselect' => [ - * 'first option value', - * 'second option value' - * ] - * ]); - * ?> - * ``` - * - * Mixing string and boolean values for a checkbox's value is not supported - * and may produce unexpected results. - * - * Field names ending in "[]" must be passed without the trailing square - * bracket characters, and must contain an array for its value. This allows - * submitting multiple values with the same name, consider: - * - * ```php - * $I->submitForm('#my-form', [ - * 'field[]' => 'value', - * 'field[]' => 'another value', // 'field[]' is already a defined key - * ]); - * ``` - * - * The solution is to pass an array value: - * - * ```php - * // this way both values are submitted - * $I->submitForm('#my-form', [ - * 'field' => [ - * 'value', - * 'another value', - * ] - * ]); - * ``` - * - * @param $selector - * @param $params - * @param $button - * @see \Codeception\Lib\InnerBrowser::submitForm() - */ - public function submitForm($selector, $params, $button = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('submitForm', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Fills a text field or textarea with the given string. - * - * ``` php - * fillField("//input[@type='text']", "Hello World!"); - * $I->fillField(['name' => 'email'], 'jon@mail.com'); - * ?> - * ``` - * - * @param $field - * @param $value - * @see \Codeception\Lib\InnerBrowser::fillField() - */ - public function fillField($field, $value) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('fillField', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Selects an option in a select tag or in radio button group. - * - * ``` php - * selectOption('form select[name=account]', 'Premium'); - * $I->selectOption('form input[name=payment]', 'Monthly'); - * $I->selectOption('//form/select[@name=account]', 'Monthly'); - * ?> - * ``` - * - * Provide an array for the second argument to select multiple options: - * - * ``` php - * selectOption('Which OS do you use?', array('Windows','Linux')); - * ?> - * ``` - * - * @param $select - * @param $option - * @see \Codeception\Lib\InnerBrowser::selectOption() - */ - public function selectOption($select, $option) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('selectOption', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Ticks a checkbox. For radio buttons, use the `selectOption` method instead. - * - * ``` php - * checkOption('#agree'); - * ?> - * ``` - * - * @param $option - * @see \Codeception\Lib\InnerBrowser::checkOption() - */ - public function checkOption($option) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('checkOption', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Unticks a checkbox. - * - * ``` php - * uncheckOption('#notify'); - * ?> - * ``` - * - * @param $option - * @see \Codeception\Lib\InnerBrowser::uncheckOption() - */ - public function uncheckOption($option) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('uncheckOption', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Attaches a file relative to the Codeception data directory to the given file upload field. - * - * ``` php - * attachFile('input[@type="file"]', 'prices.xls'); - * ?> - * ``` - * - * @param $field - * @param $filename - * @see \Codeception\Lib\InnerBrowser::attachFile() - */ - public function attachFile($field, $filename) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('attachFile', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * If your page triggers an ajax request, you can perform it manually. - * This action sends a GET ajax request with specified params. - * - * See ->sendAjaxPostRequest for examples. - * - * @param $uri - * @param $params - * @see \Codeception\Lib\InnerBrowser::sendAjaxGetRequest() - */ - public function sendAjaxGetRequest($uri, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendAjaxGetRequest', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * If your page triggers an ajax request, you can perform it manually. - * This action sends a POST ajax request with specified params. - * Additional params can be passed as array. - * - * Example: - * - * Imagine that by clicking checkbox you trigger ajax request which updates user settings. - * We emulate that click by running this ajax request manually. - * - * ``` php - * sendAjaxPostRequest('/updateSettings', array('notifications' => true)); // POST - * $I->sendAjaxGetRequest('/updateSettings', array('notifications' => true)); // GET - * - * ``` - * - * @param $uri - * @param $params - * @see \Codeception\Lib\InnerBrowser::sendAjaxPostRequest() - */ - public function sendAjaxPostRequest($uri, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendAjaxPostRequest', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * If your page triggers an ajax request, you can perform it manually. - * This action sends an ajax request with specified method and params. - * - * Example: - * - * You need to perform an ajax request specifying the HTTP method. - * - * ``` php - * sendAjaxRequest('PUT', '/posts/7', array('title' => 'new title')); - * - * ``` - * - * @param $method - * @param $uri - * @param $params - * @see \Codeception\Lib\InnerBrowser::sendAjaxRequest() - */ - public function sendAjaxRequest($method, $uri, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendAjaxRequest', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Finds and returns the text contents of the given element. - * If a fuzzy locator is used, the element is found using CSS, XPath, and by matching the full page source by regular expression. - * - * ``` php - * grabTextFrom('h1'); - * $heading = $I->grabTextFrom('descendant-or-self::h1'); - * $value = $I->grabTextFrom('~ - * ``` - * - * @param $cssOrXPathOrRegex - * - * @return mixed - * @see \Codeception\Lib\InnerBrowser::grabTextFrom() - */ - public function grabTextFrom($cssOrXPathOrRegex) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabTextFrom', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Grabs the value of the given attribute value from the given element. - * Fails if element is not found. - * - * ``` php - * grabAttributeFrom('#tooltip', 'title'); - * ?> - * ``` - * - * - * @param $cssOrXpath - * @param $attribute - * @internal param $element - * @return mixed - * @see \Codeception\Lib\InnerBrowser::grabAttributeFrom() - */ - public function grabAttributeFrom($cssOrXpath, $attribute) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabAttributeFrom', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * - * @see \Codeception\Lib\InnerBrowser::grabMultiple() - */ - public function grabMultiple($cssOrXpath, $attribute = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabMultiple', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * @param $field - * - * @return array|mixed|null|string - * @see \Codeception\Lib\InnerBrowser::grabValueFrom() - */ - public function grabValueFrom($field) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabValueFrom', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sets a cookie with the given name and value. - * You can set additional cookie params like `domain`, `path`, `expire`, `secure` in array passed as last argument. - * - * ``` php - * setCookie('PHPSESSID', 'el4ukv0kqbvoirg7nkp4dncpk3'); - * ?> - * ``` - * - * @param $name - * @param $val - * @param array $params - * - * @return mixed - * @see \Codeception\Lib\InnerBrowser::setCookie() - */ - public function setCookie($name, $val, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('setCookie', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Grabs a cookie value. - * You can set additional cookie params like `domain`, `path` in array passed as last argument. - * - * @param $cookie - * - * @param array $params - * @return mixed - * @see \Codeception\Lib\InnerBrowser::grabCookie() - */ - public function grabCookie($cookie, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabCookie', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that a cookie with the given name is set. - * You can set additional cookie params like `domain`, `path` as array passed in last argument. - * - * ``` php - * seeCookie('PHPSESSID'); - * ?> - * ``` - * - * @param $cookie - * @param array $params - * @return mixed - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeCookie() - */ - public function canSeeCookie($cookie, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeCookie', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that a cookie with the given name is set. - * You can set additional cookie params like `domain`, `path` as array passed in last argument. - * - * ``` php - * seeCookie('PHPSESSID'); - * ?> - * ``` - * - * @param $cookie - * @param array $params - * @return mixed - * @see \Codeception\Lib\InnerBrowser::seeCookie() - */ - public function seeCookie($cookie, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeCookie', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that there isn't a cookie with the given name. - * You can set additional cookie params like `domain`, `path` as array passed in last argument. - * - * @param $cookie - * - * @param array $params - * @return mixed - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeCookie() - */ - public function cantSeeCookie($cookie, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeCookie', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that there isn't a cookie with the given name. - * You can set additional cookie params like `domain`, `path` as array passed in last argument. - * - * @param $cookie - * - * @param array $params - * @return mixed - * @see \Codeception\Lib\InnerBrowser::dontSeeCookie() - */ - public function dontSeeCookie($cookie, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeCookie', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Unsets cookie with the given name. - * You can set additional cookie params like `domain`, `path` in array passed as last argument. - * - * @param $cookie - * - * @param array $params - * @return mixed - * @see \Codeception\Lib\InnerBrowser::resetCookie() - */ - public function resetCookie($name, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('resetCookie', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given element exists on the page and is visible. - * You can also specify expected attributes of this element. - * - * ``` php - * seeElement('.error'); - * $I->seeElement('//form/input[1]'); - * $I->seeElement('input', ['name' => 'login']); - * $I->seeElement('input', ['value' => '123456']); - * - * // strict locator in first arg, attributes in second - * $I->seeElement(['css' => 'form input'], ['name' => 'login']); - * ?> - * ``` - * - * @param $selector - * @param array $attributes - * @return - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeElement() - */ - public function canSeeElement($selector, $attributes = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeElement', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given element exists on the page and is visible. - * You can also specify expected attributes of this element. - * - * ``` php - * seeElement('.error'); - * $I->seeElement('//form/input[1]'); - * $I->seeElement('input', ['name' => 'login']); - * $I->seeElement('input', ['value' => '123456']); - * - * // strict locator in first arg, attributes in second - * $I->seeElement(['css' => 'form input'], ['name' => 'login']); - * ?> - * ``` - * - * @param $selector - * @param array $attributes - * @return - * @see \Codeception\Lib\InnerBrowser::seeElement() - */ - public function seeElement($selector, $attributes = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeElement', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given element is invisible or not present on the page. - * You can also specify expected attributes of this element. - * - * ``` php - * dontSeeElement('.error'); - * $I->dontSeeElement('//form/input[1]'); - * $I->dontSeeElement('input', ['name' => 'login']); - * $I->dontSeeElement('input', ['value' => '123456']); - * ?> - * ``` - * - * @param $selector - * @param array $attributes - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeElement() - */ - public function cantSeeElement($selector, $attributes = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeElement', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given element is invisible or not present on the page. - * You can also specify expected attributes of this element. - * - * ``` php - * dontSeeElement('.error'); - * $I->dontSeeElement('//form/input[1]'); - * $I->dontSeeElement('input', ['name' => 'login']); - * $I->dontSeeElement('input', ['value' => '123456']); - * ?> - * ``` - * - * @param $selector - * @param array $attributes - * @see \Codeception\Lib\InnerBrowser::dontSeeElement() - */ - public function dontSeeElement($selector, $attributes = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeElement', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that there are a certain number of elements matched by the given locator on the page. - * - * ``` php - * seeNumberOfElements('tr', 10); - * $I->seeNumberOfElements('tr', [0,10]); //between 0 and 10 elements - * ?> - * ``` - * @param $selector - * @param mixed $expected : - * - string: strict number - * - array: range of numbers [0,10] - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeNumberOfElements() - */ - public function canSeeNumberOfElements($selector, $expected) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeNumberOfElements', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that there are a certain number of elements matched by the given locator on the page. - * - * ``` php - * seeNumberOfElements('tr', 10); - * $I->seeNumberOfElements('tr', [0,10]); //between 0 and 10 elements - * ?> - * ``` - * @param $selector - * @param mixed $expected : - * - string: strict number - * - array: range of numbers [0,10] - * @see \Codeception\Lib\InnerBrowser::seeNumberOfElements() - */ - public function seeNumberOfElements($selector, $expected) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeNumberOfElements', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given option is selected. - * - * ``` php - * seeOptionIsSelected('#form input[name=payment]', 'Visa'); - * ?> - * ``` - * - * @param $selector - * @param $optionText - * - * @return mixed - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeOptionIsSelected() - */ - public function canSeeOptionIsSelected($selector, $optionText) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeOptionIsSelected', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given option is selected. - * - * ``` php - * seeOptionIsSelected('#form input[name=payment]', 'Visa'); - * ?> - * ``` - * - * @param $selector - * @param $optionText - * - * @return mixed - * @see \Codeception\Lib\InnerBrowser::seeOptionIsSelected() - */ - public function seeOptionIsSelected($selector, $optionText) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeOptionIsSelected', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given option is not selected. - * - * ``` php - * dontSeeOptionIsSelected('#form input[name=payment]', 'Visa'); - * ?> - * ``` - * - * @param $selector - * @param $optionText - * - * @return mixed - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeOptionIsSelected() - */ - public function cantSeeOptionIsSelected($selector, $optionText) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeOptionIsSelected', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given option is not selected. - * - * ``` php - * dontSeeOptionIsSelected('#form input[name=payment]', 'Visa'); - * ?> - * ``` - * - * @param $selector - * @param $optionText - * - * @return mixed - * @see \Codeception\Lib\InnerBrowser::dontSeeOptionIsSelected() - */ - public function dontSeeOptionIsSelected($selector, $optionText) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeOptionIsSelected', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that current page has 404 response status code. - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seePageNotFound() - */ - public function canSeePageNotFound() { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seePageNotFound', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that current page has 404 response status code. - * @see \Codeception\Lib\InnerBrowser::seePageNotFound() - */ - public function seePageNotFound() { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seePageNotFound', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that response code is equal to value provided. - * - * @param $code - * - * @return mixed - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeResponseCodeIs() - */ - public function canSeeResponseCodeIs($code) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseCodeIs', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that response code is equal to value provided. - * - * @param $code - * - * @return mixed - * @see \Codeception\Lib\InnerBrowser::seeResponseCodeIs() - */ - public function seeResponseCodeIs($code) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseCodeIs', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the page title contains the given string. - * - * ``` php - * seeInTitle('Blog - Post #1'); - * ?> - * ``` - * - * @param $title - * - * @return mixed - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeInTitle() - */ - public function canSeeInTitle($title) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeInTitle', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the page title contains the given string. - * - * ``` php - * seeInTitle('Blog - Post #1'); - * ?> - * ``` - * - * @param $title - * - * @return mixed - * @see \Codeception\Lib\InnerBrowser::seeInTitle() - */ - public function seeInTitle($title) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeInTitle', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the page title does not contain the given string. - * - * @param $title - * - * @return mixed - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeInTitle() - */ - public function cantSeeInTitle($title) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeInTitle', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the page title does not contain the given string. - * - * @param $title - * - * @return mixed - * @see \Codeception\Lib\InnerBrowser::dontSeeInTitle() - */ - public function dontSeeInTitle($title) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeInTitle', func_get_args())); - } -} diff --git a/tests/_support/_generated/ApiTesterActions.php b/tests/_support/_generated/ApiTesterActions.php deleted file mode 100644 index 48a1129..0000000 --- a/tests/_support/_generated/ApiTesterActions.php +++ /dev/null @@ -1,905 +0,0 @@ -getScenario()->runStep(new \Codeception\Step\Action('haveHttpHeader', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks over the given HTTP header and (optionally) - * its value, asserting that are there - * - * @param $name - * @param $value - * @part json - * @part xml - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeHttpHeader() - */ - public function canSeeHttpHeader($name, $value = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeHttpHeader', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks over the given HTTP header and (optionally) - * its value, asserting that are there - * - * @param $name - * @param $value - * @part json - * @part xml - * @see \Codeception\Module\REST::seeHttpHeader() - */ - public function seeHttpHeader($name, $value = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeHttpHeader', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks over the given HTTP header and (optionally) - * its value, asserting that are not there - * - * @param $name - * @param $value - * @part json - * @part xml - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::dontSeeHttpHeader() - */ - public function cantSeeHttpHeader($name, $value = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeHttpHeader', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks over the given HTTP header and (optionally) - * its value, asserting that are not there - * - * @param $name - * @param $value - * @part json - * @part xml - * @see \Codeception\Module\REST::dontSeeHttpHeader() - */ - public function dontSeeHttpHeader($name, $value = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeHttpHeader', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that http response header is received only once. - * HTTP RFC2616 allows multiple response headers with the same name. - * You can check that you didn't accidentally sent the same header twice. - * - * ``` php - * seeHttpHeaderOnce('Cache-Control'); - * ?>> - * ``` - * - * @param $name - * @part json - * @part xml - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeHttpHeaderOnce() - */ - public function canSeeHttpHeaderOnce($name) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeHttpHeaderOnce', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that http response header is received only once. - * HTTP RFC2616 allows multiple response headers with the same name. - * You can check that you didn't accidentally sent the same header twice. - * - * ``` php - * seeHttpHeaderOnce('Cache-Control'); - * ?>> - * ``` - * - * @param $name - * @part json - * @part xml - * @see \Codeception\Module\REST::seeHttpHeaderOnce() - */ - public function seeHttpHeaderOnce($name) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeHttpHeaderOnce', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Returns the value of the specified header name - * - * @param $name - * @param Boolean $first Whether to return the first value or all header values - * - * @return string|array The first header value if $first is true, an array of values otherwise - * @part json - * @part xml - * @see \Codeception\Module\REST::grabHttpHeader() - */ - public function grabHttpHeader($name, $first = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabHttpHeader', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Adds HTTP authentication via username/password. - * - * @param $username - * @param $password - * @part json - * @part xml - * @see \Codeception\Module\REST::amHttpAuthenticated() - */ - public function amHttpAuthenticated($username, $password) { - return $this->getScenario()->runStep(new \Codeception\Step\Condition('amHttpAuthenticated', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Adds Digest authentication via username/password. - * - * @param $username - * @param $password - * @part json - * @part xml - * @see \Codeception\Module\REST::amDigestAuthenticated() - */ - public function amDigestAuthenticated($username, $password) { - return $this->getScenario()->runStep(new \Codeception\Step\Condition('amDigestAuthenticated', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Adds Bearer authentication via access token. - * - * @param $accessToken - * @part json - * @part xml - * @see \Codeception\Module\REST::amBearerAuthenticated() - */ - public function amBearerAuthenticated($accessToken) { - return $this->getScenario()->runStep(new \Codeception\Step\Condition('amBearerAuthenticated', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends a POST request to given uri. - * - * Parameters and files (as array of filenames) can be provided. - * - * @param $url - * @param array|\JsonSerializable $params - * @param array $files - * @part json - * @part xml - * @see \Codeception\Module\REST::sendPOST() - */ - public function sendPOST($url, $params = null, $files = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendPOST', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends a HEAD request to given uri. - * - * @param $url - * @param array $params - * @part json - * @part xml - * @see \Codeception\Module\REST::sendHEAD() - */ - public function sendHEAD($url, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendHEAD', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends an OPTIONS request to given uri. - * - * @param $url - * @param array $params - * @part json - * @part xml - * @see \Codeception\Module\REST::sendOPTIONS() - */ - public function sendOPTIONS($url, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendOPTIONS', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends a GET request to given uri. - * - * @param $url - * @param array $params - * @part json - * @part xml - * @see \Codeception\Module\REST::sendGET() - */ - public function sendGET($url, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendGET', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends PUT request to given uri. - * - * @param $url - * @param array $params - * @param array $files - * @part json - * @part xml - * @see \Codeception\Module\REST::sendPUT() - */ - public function sendPUT($url, $params = null, $files = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendPUT', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends PATCH request to given uri. - * - * @param $url - * @param array $params - * @param array $files - * @part json - * @part xml - * @see \Codeception\Module\REST::sendPATCH() - */ - public function sendPATCH($url, $params = null, $files = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendPATCH', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends DELETE request to given uri. - * - * @param $url - * @param array $params - * @param array $files - * @part json - * @part xml - * @see \Codeception\Module\REST::sendDELETE() - */ - public function sendDELETE($url, $params = null, $files = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendDELETE', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends LINK request to given uri. - * - * @param $url - * @param array $linkEntries (entry is array with keys "uri" and "link-param") - * - * @link http://tools.ietf.org/html/rfc2068#section-19.6.2.4 - * - * @author samva.ua@gmail.com - * @part json - * @part xml - * @see \Codeception\Module\REST::sendLINK() - */ - public function sendLINK($url, $linkEntries) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendLINK', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends UNLINK request to given uri. - * - * @param $url - * @param array $linkEntries (entry is array with keys "uri" and "link-param") - * @link http://tools.ietf.org/html/rfc2068#section-19.6.2.4 - * @author samva.ua@gmail.com - * @part json - * @part xml - * @see \Codeception\Module\REST::sendUNLINK() - */ - public function sendUNLINK($url, $linkEntries) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendUNLINK', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether last response was valid JSON. - * This is done with json_last_error function. - * - * @part json - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeResponseIsJson() - */ - public function canSeeResponseIsJson() { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseIsJson', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether last response was valid JSON. - * This is done with json_last_error function. - * - * @part json - * @see \Codeception\Module\REST::seeResponseIsJson() - */ - public function seeResponseIsJson() { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseIsJson', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether the last response contains text. - * - * @param $text - * @part json - * @part xml - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeResponseContains() - */ - public function canSeeResponseContains($text) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseContains', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether the last response contains text. - * - * @param $text - * @part json - * @part xml - * @see \Codeception\Module\REST::seeResponseContains() - */ - public function seeResponseContains($text) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseContains', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether last response do not contain text. - * - * @param $text - * @part json - * @part xml - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::dontSeeResponseContains() - */ - public function cantSeeResponseContains($text) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeResponseContains', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether last response do not contain text. - * - * @param $text - * @part json - * @part xml - * @see \Codeception\Module\REST::dontSeeResponseContains() - */ - public function dontSeeResponseContains($text) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeResponseContains', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether the last JSON response contains provided array. - * The response is converted to array with json_decode($response, true) - * Thus, JSON is represented by associative array. - * This method matches that response array contains provided array. - * - * Examples: - * - * ``` php - * seeResponseContainsJson(array('name' => 'john')); - * - * // response {user: john, profile: { email: john@gmail.com }} - * $I->seeResponseContainsJson(array('email' => 'john@gmail.com')); - * - * ?> - * ``` - * - * This method recursively checks if one array can be found inside of another. - * - * @param array $json - * @part json - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeResponseContainsJson() - */ - public function canSeeResponseContainsJson($json = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseContainsJson', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether the last JSON response contains provided array. - * The response is converted to array with json_decode($response, true) - * Thus, JSON is represented by associative array. - * This method matches that response array contains provided array. - * - * Examples: - * - * ``` php - * seeResponseContainsJson(array('name' => 'john')); - * - * // response {user: john, profile: { email: john@gmail.com }} - * $I->seeResponseContainsJson(array('email' => 'john@gmail.com')); - * - * ?> - * ``` - * - * This method recursively checks if one array can be found inside of another. - * - * @param array $json - * @part json - * @see \Codeception\Module\REST::seeResponseContainsJson() - */ - public function seeResponseContainsJson($json = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseContainsJson', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Returns current response so that it can be used in next scenario steps. - * - * Example: - * - * ``` php - * grabResponse(); - * $I->sendPUT('/user', array('id' => $user_id, 'name' => 'davert')); - * ?> - * ``` - * - * @version 1.1 - * @return string - * @part json - * @part xml - * @see \Codeception\Module\REST::grabResponse() - */ - public function grabResponse() { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabResponse', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Returns data from the current JSON response using [JSONPath](http://goessner.net/articles/JsonPath/) as selector. - * JsonPath is XPath equivalent for querying Json structures. Try your JsonPath expressions [online](http://jsonpath.curiousconcept.com/). - * Even for a single value an array is returned. - * - * This method **require [`flow/jsonpath` > 0.2](https://github.com/FlowCommunications/JSONPath/) library to be installed**. - * - * Example: - * - * ``` php - * grabDataFromJsonResponse('$..users[0].id'); - * $I->sendPUT('/user', array('id' => $firstUser[0], 'name' => 'davert')); - * ?> - * ``` - * - * @param $jsonPath - * @return array - * @version 2.0.9 - * @throws \Exception - * @part json - * @see \Codeception\Module\REST::grabDataFromResponseByJsonPath() - */ - public function grabDataFromResponseByJsonPath($jsonPath) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabDataFromResponseByJsonPath', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if json structure in response matches the xpath provided. - * JSON is not supposed to be checked against XPath, yet it can be converted to xml and used with XPath. - * This assertion allows you to check the structure of response json. - * * - * ```json - * { "store": { - * "book": [ - * { "category": "reference", - * "author": "Nigel Rees", - * "title": "Sayings of the Century", - * "price": 8.95 - * }, - * { "category": "fiction", - * "author": "Evelyn Waugh", - * "title": "Sword of Honour", - * "price": 12.99 - * } - * ], - * "bicycle": { - * "color": "red", - * "price": 19.95 - * } - * } - * } - * ``` - * - * ```php - * seeResponseJsonMatchesXpath('//store/book/author'); - * // first book in store has author - * $I->seeResponseJsonMatchesXpath('//store/book[1]/author'); - * // at least one item in store has price - * $I->seeResponseJsonMatchesXpath('/store//price'); - * ?> - * ``` - * @part json - * @version 2.0.9 - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeResponseJsonMatchesXpath() - */ - public function canSeeResponseJsonMatchesXpath($xpath) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseJsonMatchesXpath', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if json structure in response matches the xpath provided. - * JSON is not supposed to be checked against XPath, yet it can be converted to xml and used with XPath. - * This assertion allows you to check the structure of response json. - * * - * ```json - * { "store": { - * "book": [ - * { "category": "reference", - * "author": "Nigel Rees", - * "title": "Sayings of the Century", - * "price": 8.95 - * }, - * { "category": "fiction", - * "author": "Evelyn Waugh", - * "title": "Sword of Honour", - * "price": 12.99 - * } - * ], - * "bicycle": { - * "color": "red", - * "price": 19.95 - * } - * } - * } - * ``` - * - * ```php - * seeResponseJsonMatchesXpath('//store/book/author'); - * // first book in store has author - * $I->seeResponseJsonMatchesXpath('//store/book[1]/author'); - * // at least one item in store has price - * $I->seeResponseJsonMatchesXpath('/store//price'); - * ?> - * ``` - * @part json - * @version 2.0.9 - * @see \Codeception\Module\REST::seeResponseJsonMatchesXpath() - */ - public function seeResponseJsonMatchesXpath($xpath) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseJsonMatchesXpath', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if json structure in response matches [JsonPath](http://goessner.net/articles/JsonPath/). - * JsonPath is XPath equivalent for querying Json structures. Try your JsonPath expressions [online](http://jsonpath.curiousconcept.com/). - * This assertion allows you to check the structure of response json. - * - * This method **require [`flow/jsonpath` > 0.2](https://github.com/FlowCommunications/JSONPath/) library to be installed**. - * - * ```json - * { "store": { - * "book": [ - * { "category": "reference", - * "author": "Nigel Rees", - * "title": "Sayings of the Century", - * "price": 8.95 - * }, - * { "category": "fiction", - * "author": "Evelyn Waugh", - * "title": "Sword of Honour", - * "price": 12.99 - * } - * ], - * "bicycle": { - * "color": "red", - * "price": 19.95 - * } - * } - * } - * ``` - * - * ```php - * seeResponseJsonMatchesJsonPath('$.store.book[*].author'); - * // first book in store has author - * $I->seeResponseJsonMatchesJsonPath('$.store.book[0].author'); - * // at least one item in store has price - * $I->seeResponseJsonMatchesJsonPath('$.store..price'); - * ?> - * ``` - * - * @part json - * @version 2.0.9 - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeResponseJsonMatchesJsonPath() - */ - public function canSeeResponseJsonMatchesJsonPath($jsonPath) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseJsonMatchesJsonPath', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if json structure in response matches [JsonPath](http://goessner.net/articles/JsonPath/). - * JsonPath is XPath equivalent for querying Json structures. Try your JsonPath expressions [online](http://jsonpath.curiousconcept.com/). - * This assertion allows you to check the structure of response json. - * - * This method **require [`flow/jsonpath` > 0.2](https://github.com/FlowCommunications/JSONPath/) library to be installed**. - * - * ```json - * { "store": { - * "book": [ - * { "category": "reference", - * "author": "Nigel Rees", - * "title": "Sayings of the Century", - * "price": 8.95 - * }, - * { "category": "fiction", - * "author": "Evelyn Waugh", - * "title": "Sword of Honour", - * "price": 12.99 - * } - * ], - * "bicycle": { - * "color": "red", - * "price": 19.95 - * } - * } - * } - * ``` - * - * ```php - * seeResponseJsonMatchesJsonPath('$.store.book[*].author'); - * // first book in store has author - * $I->seeResponseJsonMatchesJsonPath('$.store.book[0].author'); - * // at least one item in store has price - * $I->seeResponseJsonMatchesJsonPath('$.store..price'); - * ?> - * ``` - * - * @part json - * @version 2.0.9 - * @see \Codeception\Module\REST::seeResponseJsonMatchesJsonPath() - */ - public function seeResponseJsonMatchesJsonPath($jsonPath) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseJsonMatchesJsonPath', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Opposite to seeResponseJsonMatchesJsonPath - * - * @param array $jsonPath - * @part json - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::dontSeeResponseJsonMatchesJsonPath() - */ - public function cantSeeResponseJsonMatchesJsonPath($jsonPath) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeResponseJsonMatchesJsonPath', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Opposite to seeResponseJsonMatchesJsonPath - * - * @param array $jsonPath - * @part json - * @see \Codeception\Module\REST::dontSeeResponseJsonMatchesJsonPath() - */ - public function dontSeeResponseJsonMatchesJsonPath($jsonPath) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeResponseJsonMatchesJsonPath', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Opposite to seeResponseContainsJson - * - * @part json - * @param array $json - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::dontSeeResponseContainsJson() - */ - public function cantSeeResponseContainsJson($json = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeResponseContainsJson', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Opposite to seeResponseContainsJson - * - * @part json - * @param array $json - * @see \Codeception\Module\REST::dontSeeResponseContainsJson() - */ - public function dontSeeResponseContainsJson($json = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeResponseContainsJson', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if response is exactly the same as provided. - * - * @part json - * @part xml - * @param $response - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeResponseEquals() - */ - public function canSeeResponseEquals($response) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseEquals', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if response is exactly the same as provided. - * - * @part json - * @part xml - * @param $response - * @see \Codeception\Module\REST::seeResponseEquals() - */ - public function seeResponseEquals($response) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks response code equals to provided value. - * - * @part json - * @part xml - * @param $code - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeResponseCodeIs() - */ - public function canSeeResponseCodeIs($code) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseCodeIs', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks response code equals to provided value. - * - * @part json - * @part xml - * @param $code - * @see \Codeception\Module\REST::seeResponseCodeIs() - */ - public function seeResponseCodeIs($code) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseCodeIs', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that response code is not equal to provided value. - * - * @part json - * @part xml - * @param $code - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::dontSeeResponseCodeIs() - */ - public function cantSeeResponseCodeIs($code) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeResponseCodeIs', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that response code is not equal to provided value. - * - * @part json - * @part xml - * @param $code - * @see \Codeception\Module\REST::dontSeeResponseCodeIs() - */ - public function dontSeeResponseCodeIs($code) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeResponseCodeIs', func_get_args())); - } -} diff --git a/tests/_support/_generated/FunctionalTesterActions.php b/tests/_support/_generated/FunctionalTesterActions.php deleted file mode 100644 index 86a9312..0000000 --- a/tests/_support/_generated/FunctionalTesterActions.php +++ /dev/null @@ -1,18 +0,0 @@ -getScenario()->runStep(new \Codeception\Step\Action('assertEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that two variables are not equal - * - * @param $expected - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertNotEquals() - */ - public function assertNotEquals($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that two variables are same - * - * @param $expected - * @param $actual - * @param string $message - * - * @return mixed - * @see \Codeception\Module\Asserts::assertSame() - */ - public function assertSame($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertSame', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that two variables are not same - * - * @param $expected - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertNotSame() - */ - public function assertNotSame($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotSame', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that actual is greater than expected - * - * @param $expected - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertGreaterThan() - */ - public function assertGreaterThan($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertGreaterThan', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * @deprecated - * @see \Codeception\Module\Asserts::assertGreaterThen() - */ - public function assertGreaterThen($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertGreaterThen', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that actual is greater or equal than expected - * - * @param $expected - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertGreaterThanOrEqual() - */ - public function assertGreaterThanOrEqual($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertGreaterThanOrEqual', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * @deprecated - * @see \Codeception\Module\Asserts::assertGreaterThenOrEqual() - */ - public function assertGreaterThenOrEqual($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertGreaterThenOrEqual', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that actual is less than expected - * - * @param $expected - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertLessThan() - */ - public function assertLessThan($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertLessThan', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that actual is less or equal than expected - * - * @param $expected - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertLessThanOrEqual() - */ - public function assertLessThanOrEqual($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertLessThanOrEqual', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that haystack contains needle - * - * @param $needle - * @param $haystack - * @param string $message - * @see \Codeception\Module\Asserts::assertContains() - */ - public function assertContains($needle, $haystack, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertContains', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that haystack doesn't contain needle. - * - * @param $needle - * @param $haystack - * @param string $message - * @see \Codeception\Module\Asserts::assertNotContains() - */ - public function assertNotContains($needle, $haystack, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotContains', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that string match with pattern - * - * @param string $pattern - * @param string $string - * @param string $message - * @see \Codeception\Module\Asserts::assertRegExp() - */ - public function assertRegExp($pattern, $string, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertRegExp', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that string not match with pattern - * - * @param string $pattern - * @param string $string - * @param string $message - * @see \Codeception\Module\Asserts::assertNotRegExp() - */ - public function assertNotRegExp($pattern, $string, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotRegExp', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that variable is empty. - * - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertEmpty() - */ - public function assertEmpty($actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertEmpty', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that variable is not empty. - * - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertNotEmpty() - */ - public function assertNotEmpty($actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotEmpty', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that variable is NULL - * - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertNull() - */ - public function assertNull($actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNull', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that variable is not NULL - * - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertNotNull() - */ - public function assertNotNull($actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotNull', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that condition is positive. - * - * @param $condition - * @param string $message - * @see \Codeception\Module\Asserts::assertTrue() - */ - public function assertTrue($condition, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertTrue', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that condition is negative. - * - * @param $condition - * @param string $message - * @see \Codeception\Module\Asserts::assertFalse() - */ - public function assertFalse($condition, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFalse', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if file exists - * - * @param string $filename - * @param string $message - * @see \Codeception\Module\Asserts::assertFileExists() - */ - public function assertFileExists($filename, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileExists', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if file doesn't exist - * - * @param string $filename - * @param string $message - * @see \Codeception\Module\Asserts::assertFileNotExists() - */ - public function assertFileNotExists($filename, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileNotExists', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Fails the test with message. - * - * @param $message - * @see \Codeception\Module\Asserts::fail() - */ - public function fail($message) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('fail', func_get_args())); - } -} From 9a203b98d676672d3ee065908a3dc5922088ab98 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Fri, 18 Sep 2020 09:49:16 +0200 Subject: [PATCH 15/48] Configured allowed domains for broker. Deny request where domain of `Origin` or `Referer` header isn't allowed. Deny request where domain of `return_url` query parameter isn't allowed. --- README.md | 2 +- demo/server/attach.php | 3 +- demo/server/include/config.php | 15 ++++- src/Server/Server.php | 101 +++++++++++++++++++++++++++------ 4 files changed, 99 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index e6f0598..95e24f6 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ On *nix (Linux / Unix / OSX) run: php -S localhost:8000 -t demo/server/ export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Alice SSO_BROKER_SECRET=8iwzik1bwd; php -S localhost:8001 -t demo/broker/ export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Greg SSO_BROKER_SECRET=7pypoox2pc; php -S localhost:8002 -t demo/broker/ - export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Julias SSO_BROKER_SECRET=ceda63kmhp; php -S localhost:8003 -t demo/ajax-broker/ + export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Julius SSO_BROKER_SECRET=ceda63kmhp; php -S localhost:8003 -t demo/ajax-broker/ Now open some tabs and visit diff --git a/demo/server/attach.php b/demo/server/attach.php index 1fbca5d..225a114 100644 --- a/demo/server/attach.php +++ b/demo/server/attach.php @@ -44,8 +44,7 @@ function (string $id) use ($config) { (isset($_GET['callback']) ? 'jsonp' : null) ?? (strpos($_SERVER['HTTP_ACCEPT'], 'text/html') !== false ? 'html' : null) ?? (strpos($_SERVER['HTTP_ACCEPT'], 'image/') !== false ? 'image' : null) ?? - (strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false ? 'json' : null) ?? - (isset($_GET['HTTP_REFERER']) ? 'redirect' : null); + (strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false ? 'json' : null); switch ($returnType) { case 'json': diff --git a/demo/server/include/config.php b/demo/server/include/config.php index 2702113..d4e08ad 100644 --- a/demo/server/include/config.php +++ b/demo/server/include/config.php @@ -9,9 +9,18 @@ return [ 'brokers' => [ - 'Alice' => '8iwzik1bwd', - 'Greg' => '7pypoox2pc', - 'Julias' => 'ceda63kmhp', + 'Alice' => [ + 'secret' => '8iwzik1bwd', + 'domains' => ['localhost'], + ], + 'Greg' => [ + 'secret' => '7pypoox2pc', + 'domains' => ['localhost'], + ], + 'Julius' => [ + 'secret' => 'ceda63kmhp', + 'domains' => ['localhost'], + ], ], 'users' => [ 'jackie' => [ diff --git a/src/Server/Server.php b/src/Server/Server.php index d8d2ece..2915824 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -22,7 +22,7 @@ class Server * Callback to get the secret for a broker. * @var \Closure */ - protected $getBrokerSecret; + protected $getBrokerInfo; /** * Storage for broker session links. @@ -44,12 +44,12 @@ class Server /** * Class constructor. * - * @param callable(string):?string $getBrokerSecret - * @param CacheInterface $cache + * @param callable(string):?array $getBrokerInfo + * @param CacheInterface $cache */ - public function __construct(callable $getBrokerSecret, CacheInterface $cache) + public function __construct(callable $getBrokerInfo, CacheInterface $cache) { - $this->getBrokerSecret = \Closure::fromCallable($getBrokerSecret); + $this->getBrokerInfo = \Closure::fromCallable($getBrokerInfo); $this->cache = $cache; $this->logger = new NullLogger(); @@ -159,20 +159,31 @@ protected function parseBearer(string $bearer): array } /** - * Generate session id from session token. + * Generate cache key for linking the broker token to the client session. */ protected function getCacheKey(string $brokerId, string $token): string { return "SSO-{$brokerId}-{$token}"; } + /** + * Get the broker secret using the configured callback. + * + * @param string $brokerId + * @return string|null + */ + protected function getBrokerSecret(string $brokerId): ?string + { + return ($this->getBrokerInfo)($brokerId)['secret'] ?? null; + } + /** * Generate checksum for a broker. */ protected function generateChecksum(string $command, string $brokerId, string $token): string { try { - $secret = ($this->getBrokerSecret)($brokerId); + $secret = $this->getBrokerSecret($brokerId); } catch (\Exception $exception) { $this->logger->warning( "Failed to get broker secret: " . $exception->getMessage(), @@ -192,10 +203,6 @@ protected function generateChecksum(string $command, string $brokerId, string $t /** * Assert that the checksum matches the expected checksum. * - * @param string $checksum - * @param string $command - * @param string $brokerId - * @param string $token * @throws BrokerException */ protected function validateChecksum(string $checksum, string $command, string $brokerId, string $token): void @@ -211,6 +218,25 @@ protected function validateChecksum(string $checksum, string $command, string $b } } + /** + * Assert that the URL has a domain that is allowed for the broker. + * + * @throws BrokerException + */ + public function validateDomain(string $type, string $url, string $brokerId, ?string $token = null): void + { + $domains = ($this->getBrokerInfo)($brokerId)['domains'] ?? null; + $host = parse_url($url, PHP_URL_HOST); + + if (!in_array($host, $domains, true)) { + $this->logger->warning( + "Domain of $type is not allowed for broker", + [$type => $url, 'domain' => $host, 'broker' => $brokerId, 'token' => $token] + ); + throw new BrokerException("Domain of $type is not allowed", 400); + } + } + /** * Attach a client session to a broker session. * @@ -219,11 +245,7 @@ protected function validateChecksum(string $checksum, string $command, string $b */ public function attach(?ServerRequestInterface $request = null): void { - $brokerId = $this->getQueryParam($request, 'broker', true); - $token = $this->getQueryParam($request, 'token', true); - - $checksum = $this->getQueryParam($request, 'checksum', true); - $this->validateChecksum($checksum, 'attach', $brokerId, $token); + ['broker' => $brokerId, 'token' => $token] = $this->processAttachRequest($request); if (!$this->session->isActive()) { $this->session->start(); @@ -242,6 +264,39 @@ public function attach(?ServerRequestInterface $request = null): void $this->logger->info("Attached broker token to session", $info); } + /** + * Validate attach request and return broker id and token. + * + * @param ServerRequestInterface|null $request + * @return array{broker:string,token:string} + * @throws BrokerException + */ + protected function processAttachRequest(?ServerRequestInterface $request): array + { + $brokerId = $this->getQueryParam($request, 'broker', true); + $token = $this->getQueryParam($request, 'token', true); + + $checksum = $this->getQueryParam($request, 'checksum', true); + $this->validateChecksum($checksum, 'attach', $brokerId, $token); + + $origin = $this->getHeader($request, 'Origin'); + if ($origin !== '') { + $this->validateDomain('origin', $origin, $brokerId, $token); + } + + $referer = $this->getHeader($request, 'Referer'); + if ($referer !== '') { + $this->validateDomain('referer', $referer, $brokerId, $token); + } + + $returnUrl = $this->getQueryParam($request, 'return_url', false); + if ($returnUrl !== null) { + $this->validateDomain('return_url', $returnUrl, $brokerId); + } + + return ['broker' => $brokerId, 'token' => $token]; + } + /** * Get query parameter from PSR-7 request or $_GET. * @@ -260,4 +315,18 @@ protected function getQueryParam(?ServerRequestInterface $request, string $key, return $params[$key] ?? null; } + + /** + * Get HTTP Header from PSR-7 request or $_SERVER. + * + * @param ServerRequestInterface $request + * @param string $key + * @return string + */ + protected function getHeader(?ServerRequestInterface $request, string $key): string + { + return $request === null + ? ($_SERVER['HTTP_' . str_replace('-', '_', strtoupper($key))] ?? '') + : $request->getHeaderLine($key); + } } From 8f93e337930578f1db32cee83f33ec179161a7c4 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Fri, 18 Sep 2020 12:13:59 +0200 Subject: [PATCH 16/48] Return verification code to prevent session hijacking using the attach link. Display SSO errors from attaching in the Broker. Fixes #124 --- README.md | 31 ++++++++++-- demo/ajax-broker/app.js | 4 +- demo/ajax-broker/verify.php | 20 ++++++++ demo/broker/error.php | 15 +++++- demo/broker/include/attach.php | 41 +++++++++++++++ demo/broker/index.php | 18 +------ demo/broker/login.php | 17 +------ demo/broker/logout.php | 18 +------ demo/server/attach.php | 31 +++++------- src/Broker/Broker.php | 92 +++++++++++++++++++++++++++------- src/Server/Server.php | 40 ++++++++++++--- 11 files changed, 229 insertions(+), 98 deletions(-) create mode 100644 demo/ajax-broker/verify.php create mode 100644 demo/broker/include/attach.php diff --git a/README.md b/README.md index 95e24f6..9b8c6bf 100644 --- a/README.md +++ b/README.md @@ -87,19 +87,29 @@ client session. ```php use Jasny\SSO\Server\Server; +$brokers = [ + 'foo' => ['secret' => '8OyRi6Ix1x', 'domains' => ['example.com']], + // ... +]; + $server = new Server( - fn(string $id): string => $brokerSecrets[$id], // Unique secret for each broker + fn(string $id): string => $brokers[$id] ?? null, // Unique secret and allowed domains for each broker. new Cache() // Any PSR-16 compatible cache ); ``` +_In this example the brokers are simply configured as array. But typically you want to fetch the broker info from a DB._ + ### Attach A client needs attach the broker token to the session id by doing an HTTP request to the server. This request can be handled by calling `attach()`. +The `attach()` method returns a verification code. This code must be returned to the broker, as it's needed to +calculate the checksum. + ```php -$server->attach(); +$verificationCode = $server->attach(); ``` If it's not possible to attach (for instance in case of an incorrect checksum), an Exception is thrown. @@ -125,7 +135,7 @@ By default, the library works with superglobals like `$_GET` and `$_SERVER`. Alt request. This can be passed to `attach()` and `startBrokerSession()` as argument. ```php -$server->attach($serverRequest); +$verificationCode = $server->attach($serverRequest); ``` ### Session interface @@ -167,8 +177,8 @@ Any PSR-3 compatible logger can be used, like [Monolog](https://packagist.org/pa ## Broker -When creating a `Broker` instance, you need to pass the server url, broker id and broker secret. The broker id -and secret needs to match the secret registered at the server. +When creating a `Broker` instance, you need to pass the server url, broker id and broker secret. The broker id and +secret needs to match the secret registered at the server. **CAVEAT**: *The broker id MUST be alphanumeric.* @@ -204,6 +214,17 @@ if (!$broker->isAttached()) { } ``` +### Verify + +Upon verification the SSO Server will return a verification code (as query parameter of in the JSON response). The code +is used to calculate the checksum. The verification code prevents session hijacking using an attach link. + +```php +if (isset($_GET['sso_verify'])) { + $broker->verify($_GET['sso_verify']); +} +``` + ### API requests Once attached, the broker is able to do API requests on behalf of the client. diff --git a/demo/ajax-broker/app.js b/demo/ajax-broker/app.js index 0969800..5399a77 100644 --- a/demo/ajax-broker/app.js +++ b/demo/ajax-broker/app.js @@ -20,7 +20,9 @@ return; } - doApiRequest('info', null, showUserInfo); + $.ajax({method: 'POST', url: 'verify.php', data: data}).done(function () { + doApiRequest('info', null, showUserInfo); + }); }); req.fail(function (jqxhr) { diff --git a/demo/ajax-broker/verify.php b/demo/ajax-broker/verify.php new file mode 100644 index 0000000..107906e --- /dev/null +++ b/demo/ajax-broker/verify.php @@ -0,0 +1,20 @@ +verify($_POST['verify']); + +http_response_code(204); diff --git a/demo/broker/error.php b/demo/broker/error.php index cb05362..8c09af7 100644 --- a/demo/broker/error.php +++ b/demo/broker/error.php @@ -3,7 +3,11 @@ declare(strict_types=1); $brokerId = getenv('SSO_BROKER_ID'); -$error = isset($exception) ? $exception->getMessage() : "Unknown error"; + +$error = isset($exception) ? $exception->getMessage() : ($_GET['sso_error'] ?? "Unknown error"); +$errorDetails = isset($exception) && $exception->getPrevious() !== null + ? $exception->getPrevious()->getMessage() + : null; ?> @@ -29,7 +33,14 @@

Single Sign-On demo ()

- + + + +
+ + +
+
Try again diff --git a/demo/broker/include/attach.php b/demo/broker/include/attach.php new file mode 100644 index 0000000..eca50f9 --- /dev/null +++ b/demo/broker/include/attach.php @@ -0,0 +1,41 @@ +verify($_GET['sso_verify']); + + $url = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; + $redirectUrl = preg_replace('/sso_verify=\w+&|[\?&]sso_verify=\w+$/', '', $url); + redirect($redirectUrl); + exit(); +} + +// Attach through redirect if the client isn't attached yet. +if (!$broker->isAttached()) { + $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; + $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]); + + redirect($attachUrl); + exit(); +} + +return $broker; diff --git a/demo/broker/index.php b/demo/broker/index.php index c6825e5..c835c31 100644 --- a/demo/broker/index.php +++ b/demo/broker/index.php @@ -5,23 +5,9 @@ use Jasny\SSO\Broker\Broker; require_once __DIR__ . '/../../vendor/autoload.php'; -require_once __DIR__ . '/include/functions.php'; -// Configure the broker. -$broker = new Broker( - getenv('SSO_SERVER'), - getenv('SSO_BROKER_ID'), - getenv('SSO_BROKER_SECRET') -); - -// Attach through redirect if the client isn't attached yet. -if (!$broker->isAttached()) { - $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; - $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]); - - redirect($attachUrl); - exit(); -} +/** @var Broker $broker */ +$broker = require_once __DIR__ . '/include/attach.php'; // Get the user info from the SSO server via the API. try { diff --git a/demo/broker/login.php b/demo/broker/login.php index 7167341..f81e12e 100644 --- a/demo/broker/login.php +++ b/demo/broker/login.php @@ -7,21 +7,8 @@ require_once __DIR__ . '/../../vendor/autoload.php'; require_once __DIR__ . '/include/functions.php'; -// Configure the broker. -$broker = new Broker( - getenv('SSO_SERVER'), - getenv('SSO_BROKER_ID'), - getenv('SSO_BROKER_SECRET') -); - -// Attach through redirect if the client isn't attached yet. -if (!$broker->isAttached()) { - $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; - $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]); - - redirect($attachUrl); - exit(); -} +/** @var Broker $broker */ +$broker = require_once __DIR__ . '/include/attach.php'; // Handle POST request if ($_SERVER['REQUEST_METHOD'] === 'POST') { diff --git a/demo/broker/logout.php b/demo/broker/logout.php index a2ee868..9289c86 100644 --- a/demo/broker/logout.php +++ b/demo/broker/logout.php @@ -5,23 +5,9 @@ use Jasny\SSO\Broker\Broker; require_once __DIR__ . '/../../vendor/autoload.php'; -require_once __DIR__ . '/include/functions.php'; -// Configure the broker. -$broker = new Broker( - getenv('SSO_SERVER'), - getenv('SSO_BROKER_ID'), - getenv('SSO_BROKER_SECRET') -); - -// Attach through redirect if the client isn't attached yet. -if (!$broker->isAttached()) { - $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; - $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]); - - redirect($attachUrl); - exit(); -} +/** @var Broker $broker */ +$broker = require_once __DIR__ . '/include/attach.php'; try { $broker->request('POST', 'api/logout.php'); diff --git a/demo/server/attach.php b/demo/server/attach.php index 225a114..9cb7eab 100644 --- a/demo/server/attach.php +++ b/demo/server/attach.php @@ -25,13 +25,11 @@ function (string $id) use ($config) { try { // Attach the broker token to the user session. Uses query parameters from $_GET. - $ssoServer->attach(); + $verificationCode = $ssoServer->attach(); + $error = null; } catch (SSOException $exception) { - // Something went wrong. Output the error as a 4xx or 5xx response. - http_response_code($exception->getCode()); - header('Content-Type: text/plain'); - echo $exception; - exit(); + $verificationCode = null; + $error = ['code' => $exception->getCode(), 'message' => $exception->getMessage()]; } // The token is attached; output 'success'. @@ -43,30 +41,25 @@ function (string $id) use ($config) { (isset($_GET['return_url']) ? 'redirect' : null) ?? (isset($_GET['callback']) ? 'jsonp' : null) ?? (strpos($_SERVER['HTTP_ACCEPT'], 'text/html') !== false ? 'html' : null) ?? - (strpos($_SERVER['HTTP_ACCEPT'], 'image/') !== false ? 'image' : null) ?? (strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false ? 'json' : null); switch ($returnType) { case 'json': header('Content-type: application/json'); - echo json_encode(['success' => 'attached']); + http_response_code($error['code'] ?? 200); + echo json_encode($error ?? ['verify' => $verificationCode]); break; case 'jsonp': header('Content-type: application/javascript'); - $data = json_encode(['success' => 'attached']); - echo $_REQUEST['callback'] . "($data, 200);"; - break; - - case 'image': - // Output a 1x1px transparent image - header('Content-Type: image/png'); - echo base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZg' - . 'AAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII='); + $data = json_encode($error ?? ['verify' => $verificationCode]); + $responseCode = $error['code'] ?? 200; + echo $_REQUEST['callback'] . "($data, $responseCode);"; break; case 'redirect': - $url = $_GET['return_url'] ?? $_SERVER['HTTP_REFERER']; + $query = isset($error) ? 'sso_error=' . $error['message'] : 'sso_verify=' . $verificationCode; + $url = $_GET['return_url'] . (strpos($_GET['return_url'], '?') === false ? '?' : '&') . $query; header('Location: ' . $url, true, 303); echo "You're being redirected to $url"; break; @@ -74,6 +67,6 @@ function (string $id) use ($config) { default: http_response_code(400); header('Content-Type: text/plain'); - echo "Unable to detect return type"; + echo "Missing 'return_url' query parameter"; break; } diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php index 0b5cbe2..23259fa 100644 --- a/src/Broker/Broker.php +++ b/src/Broker/Broker.php @@ -36,11 +36,17 @@ class Broker protected $initialized = false; /** - * Session token of the client + * Session token of the client. * @var string|null */ protected $token; + /** + * Verification code returned by the server. + * @var string|null + */ + protected $verificationCode; + /** * @var \ArrayAccess */ @@ -96,27 +102,49 @@ public function getBrokerId(): string return $this->broker; } + /** + * Get information from cookie. + */ + protected function initialize(): void + { + if ($this->initialized) { + return; + } + + $this->token = $this->state[$this->getCookieName('token')]; + $this->verificationCode = $this->state[$this->getCookieName('verify')]; + $this->initialized = true; + } + /** * @return string|null */ protected function getToken(): ?string { - if (!$this->initialized) { - $this->token = $this->state[$this->getCookieName()]; - $this->initialized = true; - } + $this->initialize(); return $this->token; } + /** + * @return string|null + */ + protected function getVerificationCode(): ?string + { + $this->initialize(); + + return $this->verificationCode; + } + /** * Get the cookie name. - * * The broker name is part of the cookie name. This resolves issues when multiple brokers are on the same domain. */ - protected function getCookieName(): string + protected function getCookieName(string $type): string { - return 'sso_token_' . preg_replace('/[_\W]+/', '_', strtolower($this->broker)); + $brokerName = preg_replace('/[_\W]+/', '_', strtolower($this->broker)); + + return "sso_{$type}_{$brokerName}"; } /** @@ -126,12 +154,15 @@ protected function getCookieName(): string */ public function getBearerToken(): ?string { - if ($this->getToken() === null) { + $token = $this->getToken(); + $verificationCode = $this->getVerificationCode(); + + if ($verificationCode === null) { throw new NotAttachedException("The client isn't attached to the SSO server for this broker. " - . "Make sure that the '" . $this->getCookieName() . "' cookie is set."); + . "Make sure that the '" . $this->getCookieName('verify') . "' cookie is set."); } - return "SSO-{$this->broker}-{$this->token}-" . $this->generateChecksum('bearer'); + return "SSO-{$this->broker}-{$token}-" . $this->generateChecksum("bearer:$verificationCode"); } /** @@ -143,8 +174,8 @@ public function generateToken(): void return; } - $this->token = base_convert(bin2hex(random_bytes(16)), 16, 36); - $this->state[$this->getCookieName()] = $this->token; + $this->token = base_convert(bin2hex(random_bytes(32)), 16, 36); + $this->state[$this->getCookieName('token')] = $this->token; } /** @@ -152,8 +183,11 @@ public function generateToken(): void */ public function clearToken(): void { - unset($this->state[$this->getCookieName()]); + unset($this->state[$this->getCookieName('token')]); + unset($this->state[$this->getCookieName('verify')]); + $this->token = null; + $this->verificationCode = null; } /** @@ -161,7 +195,7 @@ public function clearToken(): void */ public function isAttached(): bool { - return $this->getToken() !== null; + return $this->getVerificationCode() !== null; } /** @@ -183,12 +217,32 @@ public function getAttachUrl(array $params = []): string return $this->url . "?" . http_build_query($data + $params); } + /** + * Verify attaching to the SSO server by providing the verification code. + */ + public function verify(string $code): void + { + $this->initialize(); + + if ($this->verificationCode === $code) { + return; + } + + if ($this->verificationCode !== null) { + trigger_error("SSO attach already verified", E_USER_WARNING); + return; + } + + $this->verificationCode = $code; + $this->state[$this->getCookieName('verify')] = $code; + } + /** * Generate checksum for a broker. */ protected function generateChecksum(string $command): string { - return hash_hmac('sha256', $command . ':' . $this->token, $this->secret); + return base_convert(hash_hmac('sha256', $command . ':' . $this->token, $this->secret), 16, 36); } /** @@ -268,7 +322,11 @@ protected function handleResponse($ch, string $response) } if ($contentType != 'application/json') { - throw new RequestException("Expected 'application/json' response, got '$contentType'"); + throw new RequestException( + "Expected 'application/json' response, got '$contentType'", + 0, + new RequestException($response, $httpCode) + ); } $data = json_decode($response, true); diff --git a/src/Server/Server.php b/src/Server/Server.php index 2915824..c493a9f 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -95,7 +95,7 @@ public function startBrokerSession(?ServerRequestInterface $request = null): voi throw new BrokerException("Broker didn't use bearer authentication", 401); } - [$brokerId, $token] = $this->parseBearer($bearer); + [$brokerId, $token, $checksum] = $this->parseBearer($bearer); try { $sessionId = $this->cache->get('SSO-' . $brokerId . '-' . $token); @@ -115,6 +115,9 @@ public function startBrokerSession(?ServerRequestInterface $request = null): voi throw new BrokerException("Bearer token isn't attached to a client session", 403); } + $command = 'bearer:' . $this->getVerificationCode($brokerId, $token, $sessionId); + $this->validateChecksum($checksum, $command, $brokerId, $token); + $this->session->start($sessionId); $this->logger->debug( @@ -152,10 +155,7 @@ protected function parseBearer(string $bearer): array throw new BrokerException("Invalid bearer token"); } - [, $brokerId, $token, $checksum] = $matches; - $this->validateChecksum($checksum, 'bearer', $brokerId, $token); - - return [$brokerId, $token]; + return array_slice($matches, 1); } /** @@ -177,6 +177,14 @@ protected function getBrokerSecret(string $brokerId): ?string return ($this->getBrokerInfo)($brokerId)['secret'] ?? null; } + /** + * Generate the verification code based on the token using the server secret. + */ + protected function getVerificationCode(string $brokerId, string $token, string $sessionId): string + { + return base_convert(hash('sha256', $brokerId . $token . $sessionId), 16, 36); + } + /** * Generate checksum for a broker. */ @@ -197,7 +205,7 @@ protected function generateChecksum(string $command, string $brokerId, string $t throw new BrokerException("Unknown broker id", 400); } - return hash_hmac('sha256', $command . ':' . $token, $secret); + return base_convert(hash_hmac('sha256', $command . ':' . $token, $secret), 16, 36); } /** @@ -239,11 +247,12 @@ public function validateDomain(string $type, string $url, string $brokerId, ?str /** * Attach a client session to a broker session. + * Returns the verification code. * * @throws BrokerException * @throws ServerException */ - public function attach(?ServerRequestInterface $request = null): void + public function attach(?ServerRequestInterface $request = null): string { ['broker' => $brokerId, 'token' => $token] = $this->processAttachRequest($request); @@ -251,6 +260,8 @@ public function attach(?ServerRequestInterface $request = null): void $this->session->start(); } + $this->assertNotAttached($brokerId, $token); + $key = $this->getCacheKey($brokerId, $token); $cached = $this->cache->set($key, $this->session->getId()); @@ -262,6 +273,21 @@ public function attach(?ServerRequestInterface $request = null): void } $this->logger->info("Attached broker token to session", $info); + + return $this->getVerificationCode($brokerId, $token, $this->session->getId()); + } + + /** + * Assert that the token isn't already attached to a different session. + */ + protected function assertNotAttached(string $brokerId, string $token): void + { + $key = $this->getCacheKey($brokerId, $token); + $attached = $this->cache->get($key); + + if ($attached !== null && $attached !== $this->session->getId()) { + throw new BrokerException("Token is already attached"); + } } /** From 0327c8e409a63b6efcbb85a69d29d2287e2bc945 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Fri, 25 Sep 2020 14:14:11 +0200 Subject: [PATCH 17/48] Unit tests for Server class. SessionInterface doesn't need to access session data. Don't return why the token isn't valid, only log. Minor fixes. --- .gitattributes | 1 + composer.json | 6 + src/Server/GlobalSession.php | 19 +- src/Server/Server.php | 92 +++--- src/Server/SessionInterface.php | 19 +- tests/_support/DemoTester.php | 26 ++ tests/_support/Helper/Demo.php | 13 + tests/_support/Helper/Unit.php | 13 + tests/_support/UnitTester.php | 26 ++ tests/demo.suite.yml | 4 + tests/unit.suite.yml | 8 + tests/unit/Server/AttachTest.php | 391 ++++++++++++++++++++++++ tests/unit/Server/BrokerSessionTest.php | 295 ++++++++++++++++++ tests/unit/Server/ServerTestTrait.php | 28 ++ 14 files changed, 859 insertions(+), 82 deletions(-) create mode 100644 tests/_support/DemoTester.php create mode 100644 tests/_support/Helper/Demo.php create mode 100644 tests/_support/Helper/Unit.php create mode 100644 tests/_support/UnitTester.php create mode 100644 tests/demo.suite.yml create mode 100644 tests/unit.suite.yml create mode 100644 tests/unit/Server/AttachTest.php create mode 100644 tests/unit/Server/BrokerSessionTest.php create mode 100644 tests/unit/Server/ServerTestTrait.php diff --git a/.gitattributes b/.gitattributes index 1b0faf3..021c06c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ +/demo export-ignore /tests export-ignore /.gitattributes export-ignore /.gitignore export-ignore diff --git a/composer.json b/composer.json index 918562b..ab2d84c 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "codeception/module-phpbrowser": "^1.0", "codeception/module-rest": "^1.2", "desarrolla2/cache": "^3.0", + "jasny/http-message": "^1.3", "jasny/php-code-quality": "^2.6.0", "yubb/loggy": "^2.1" }, @@ -36,6 +37,11 @@ "Jasny\\SSO\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Jasny\\Tests\\SSO\\": "tests/unit/" + } + }, "scripts": { "test": [ "phpstan analyse", diff --git a/src/Server/GlobalSession.php b/src/Server/GlobalSession.php index 660116f..ac95736 100644 --- a/src/Server/GlobalSession.php +++ b/src/Server/GlobalSession.php @@ -5,7 +5,7 @@ namespace Jasny\SSO\Server; /** - * Interact with session using $_SESSION and PHP's session_* functions. + * Interact with the global session using PHP's session_* functions. * * @codeCoverageIgnore */ @@ -59,21 +59,4 @@ public function isActive(): bool { return session_status() === PHP_SESSION_ACTIVE; } - - - /** - * @inheritDoc - */ - public function get(string $key) - { - return $_SESSION[$key] ?? null; - } - - /** - * @inheritDoc - */ - public function set(string $key, $value): void - { - $_SESSION[$key] = $value; - } } diff --git a/src/Server/Server.php b/src/Server/Server.php index c493a9f..2e7ca37 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -86,37 +86,25 @@ public function withSession(SessionInterface $session): self public function startBrokerSession(?ServerRequestInterface $request = null): void { if ($this->session->isActive()) { - throw new ServerException("Session is already started", 400); + throw new ServerException("Session is already started", 500); } $bearer = $this->getBearerToken($request); - if ($bearer === null) { - throw new BrokerException("Broker didn't use bearer authentication", 401); - } - [$brokerId, $token, $checksum] = $this->parseBearer($bearer); - try { - $sessionId = $this->cache->get('SSO-' . $brokerId . '-' . $token); - } catch (\Exception $exception) { - $this->logger->error( - "Failed to get session id: " . $exception->getMessage(), - ['broker' => $brokerId, 'token' => $token] - ); - throw new ServerException("Failed to get session id", 500, $exception); - } + $sessionId = $this->cache->get($this->getCacheKey($brokerId, $token)); - if (!$sessionId) { + if ($sessionId === null) { $this->logger->warning( "Bearer token isn't attached to a client session", ['broker' => $brokerId, 'token' => $token] ); - throw new BrokerException("Bearer token isn't attached to a client session", 403); + throw new BrokerException("Invalid or expired bearer token", 403); } - $command = 'bearer:' . $this->getVerificationCode($brokerId, $token, $sessionId); - $this->validateChecksum($checksum, $command, $brokerId, $token); + $code = $this->getVerificationCode($brokerId, $token, $sessionId); + $this->validateChecksum($checksum, 'bearer', $brokerId, $token, $code); $this->session->start($sessionId); @@ -129,15 +117,21 @@ public function startBrokerSession(?ServerRequestInterface $request = null): voi /** * Get bearer token from Authorization header. */ - protected function getBearerToken(?ServerRequestInterface $request = null): ?string + protected function getBearerToken(?ServerRequestInterface $request = null): string { $authorization = $request === null - ? ($_SERVER['HTTP_AUTHORIZATION'] ?? '') + ? ($_SERVER['HTTP_AUTHORIZATION'] ?? '') // @codeCoverageIgnore : $request->getHeaderLine('Authorization'); - return strpos($authorization, 'Bearer') === 0 - ? substr($authorization, 7) - : null; + [$type, $token] = explode(' ', $authorization, 2) + ['', '']; + + if ($type !== 'Bearer') { + $this->logger->warning("Broker didn't use bearer authentication: " + . ($authorization === '' ? "No 'Authorization' header" : "$type authorization used")); + throw new BrokerException("Broker didn't use bearer authentication", 401); + } + + return $token; } /** @@ -152,7 +146,7 @@ protected function parseBearer(string $bearer): array if (!(bool)preg_match('/^SSO-(\w*+)-(\w*+)-([a-z0-9]*+)$/', $bearer, $matches)) { $this->logger->warning("Invalid bearer token", ['bearer' => $bearer]); - throw new BrokerException("Invalid bearer token"); + throw new BrokerException("Invalid or expired bearer token", 403); } return array_slice($matches, 1); @@ -190,19 +184,11 @@ protected function getVerificationCode(string $brokerId, string $token, string $ */ protected function generateChecksum(string $command, string $brokerId, string $token): string { - try { - $secret = $this->getBrokerSecret($brokerId); - } catch (\Exception $exception) { - $this->logger->warning( - "Failed to get broker secret: " . $exception->getMessage(), - ['broker' => $brokerId, 'token' => $token] - ); - throw new ServerException("Failed to get broker secret", 500, $exception); - } + $secret = $this->getBrokerSecret($brokerId); if ($secret === null) { - $this->logger->warning("Unknown broker id", ['broker' => $brokerId, 'token' => $token]); - throw new BrokerException("Unknown broker id", 400); + $this->logger->warning("Unknown broker", ['broker' => $brokerId, 'token' => $token]); + throw new BrokerException("Broker is unknown or disabled", 403); } return base_convert(hash_hmac('sha256', $command . ':' . $token, $secret), 16, 36); @@ -213,16 +199,22 @@ protected function generateChecksum(string $command, string $brokerId, string $t * * @throws BrokerException */ - protected function validateChecksum(string $checksum, string $command, string $brokerId, string $token): void - { - $expected = $this->generateChecksum($command, $brokerId, $token); + protected function validateChecksum( + string $checksum, + string $command, + string $brokerId, + string $token, + ?string $code = null + ): void { + $expected = $this->generateChecksum($command . ($code !== null ? ":$code" : ''), $brokerId, $token); if ($checksum !== $expected) { $this->logger->warning( "Invalid $command checksum", ['expected' => $expected, 'received' => $checksum, 'broker' => $brokerId, 'token' => $token] + + ($code !== null ? ['verification_code' => $code] : []) ); - throw new BrokerException("Invalid checksum", 400); + throw new BrokerException("Invalid or expired bearer token", 403); } } @@ -233,15 +225,15 @@ protected function validateChecksum(string $checksum, string $command, string $b */ public function validateDomain(string $type, string $url, string $brokerId, ?string $token = null): void { - $domains = ($this->getBrokerInfo)($brokerId)['domains'] ?? null; + $domains = ($this->getBrokerInfo)($brokerId)['domains'] ?? []; $host = parse_url($url, PHP_URL_HOST); if (!in_array($host, $domains, true)) { $this->logger->warning( "Domain of $type is not allowed for broker", - [$type => $url, 'domain' => $host, 'broker' => $brokerId, 'token' => $token] + [$type => $url, 'broker' => $brokerId] + ($token !== null ? ['token' => $token] : []) ); - throw new BrokerException("Domain of $type is not allowed", 400); + throw new BrokerException("Domain of $type is not allowed", 403); } } @@ -268,7 +260,7 @@ public function attach(?ServerRequestInterface $request = null): string $info = ['broker' => $brokerId, 'token' => $token, 'session' => $this->session->getId()]; if (!$cached) { - $this->logger->error("Failed to attach attach bearer token to session id due to cache issue", $info); + $this->logger->error("Failed to attach bearer token to session id due to cache issue", $info); throw new ServerException("Failed to attach bearer token to session id"); } @@ -286,6 +278,12 @@ protected function assertNotAttached(string $brokerId, string $token): void $attached = $this->cache->get($key); if ($attached !== null && $attached !== $this->session->getId()) { + $this->logger->warning("Token is already attached", [ + 'broker' => $brokerId, + 'token' => $token, + 'attached_to' => $attached, + 'session' => $this->session->getId() + ]); throw new BrokerException("Token is already attached"); } } @@ -317,7 +315,7 @@ protected function processAttachRequest(?ServerRequestInterface $request): array $returnUrl = $this->getQueryParam($request, 'return_url', false); if ($returnUrl !== null) { - $this->validateDomain('return_url', $returnUrl, $brokerId); + $this->validateDomain('return_url', $returnUrl, $brokerId, $token); } return ['broker' => $brokerId, 'token' => $token]; @@ -333,7 +331,9 @@ protected function processAttachRequest(?ServerRequestInterface $request): array */ protected function getQueryParam(?ServerRequestInterface $request, string $key, bool $required = false) { - $params = $request === null ? $_GET : $request->getQueryParams(); + $params = $request === null + ? $_GET // @codeCoverageIgnore + : $request->getQueryParams(); if ($required && !isset($params[$key])) { throw new BrokerException("Missing '$key' query parameter", 400); @@ -352,7 +352,7 @@ protected function getQueryParam(?ServerRequestInterface $request, string $key, protected function getHeader(?ServerRequestInterface $request, string $key): string { return $request === null - ? ($_SERVER['HTTP_' . str_replace('-', '_', strtoupper($key))] ?? '') + ? ($_SERVER['HTTP_' . str_replace('-', '_', strtoupper($key))] ?? '') // @codeCoverageIgnore : $request->getHeaderLine($key); } } diff --git a/src/Server/SessionInterface.php b/src/Server/SessionInterface.php index 4dc8eef..ef5fba7 100644 --- a/src/Server/SessionInterface.php +++ b/src/Server/SessionInterface.php @@ -5,7 +5,7 @@ namespace Jasny\SSO\Server; /** - * Interface to interact with sessions. + * Interface to start a session. */ interface SessionInterface { @@ -27,21 +27,4 @@ public function start(?string $id = null): void; * @see session_status() */ public function isActive(): bool; - - - /** - * Get session data. - * - * @param string $key - * @return mixed - */ - public function get(string $key); - - /** - * Set session data. - * - * @param string $key - * @param mixed $value - */ - public function set(string $key, $value): void; } diff --git a/tests/_support/DemoTester.php b/tests/_support/DemoTester.php new file mode 100644 index 0000000..e624dba --- /dev/null +++ b/tests/_support/DemoTester.php @@ -0,0 +1,26 @@ +createCallbackMock( + $this->atLeastOnce(), + ['foo'], + ['secret' => 'bar', 'domains' => ['broker.example.com']] + ); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams([ + 'broker' => 'foo', + 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), + 'token' => '123456', + 'return_url' => 'https://broker.example.com/attached' + ]) + ->withHeader('Referer', 'https://broker.example.com/login') + ->withHeader('Origin', 'https://broker.example.com/'); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->once())->method('start')->id('start'); + $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn(null); + $cache->expects($this->once())->method('set') + ->with('SSO-foo-123456', 'abc123') + ->willReturn(true); + + $logger->expects($this->once())->method('info') + ->with( + "Attached broker token to session", + ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123'] + ); + + $code = $server->attach($request); + + $this->assertEquals( + $this->getVerificationCode('foo', '123456', 'abc123'), + $code + ); + } + + public function missingQueryParameterProvider() + { + return [ + 'broker' => ['broker'], + 'checksum' => ['checksum'], + 'token' => ['token'], + ]; + } + + /** + * @dataProvider missingQueryParameterProvider + */ + public function testMissingQueryParameter(string $key) + { + $callback = $this->createCallbackMock($this->never()); + + $cache = $this->createMock(CacheInterface::class); + + $queryParams = [ + 'broker' => 'foo', + 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), + 'token' => '123456', + 'return_url' => 'https://return_url.example.com/' + ]; + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams(array_without($queryParams, [$key])) + ->withHeader('Referer', 'https://referer.example.com/') + ->withHeader('Origin', 'https://origin.example.com/'); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->any())->method('isActive')->willReturn(false); + $session->expects($this->never())->method('start'); + + $cache->expects($this->never())->method('get'); + $cache->expects($this->never())->method('set'); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Missing '$key' query parameter"); + + $server->attach($request); + } + + public function domainProvider() + { + return [ + 'return_url' => ['return_url', ['origin.example.com', 'referer.example.com']], + 'origin' => ['origin', ['referer.example.com', 'return_url.example.com']], + 'referer' => ['referer', ['origin.example.com', 'return_url.example.com']], + ]; + } + + /** + * @dataProvider domainProvider + */ + public function testInvalidDomain(string $type, array $domains) + { + $callback = $this->createCallbackMock( + $this->atLeastOnce(), + ['foo'], + ['secret' => 'bar', 'domains' => $domains] + ); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams([ + 'broker' => 'foo', + 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), + 'token' => '123456', + 'return_url' => 'https://return_url.example.com/' + ]) + ->withHeader('Referer', 'https://referer.example.com/') + ->withHeader('Origin', 'https://origin.example.com/'); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->any())->method('isActive')->willReturn(false); + $session->expects($this->never())->method('start'); + + $cache->expects($this->never())->method('get'); + $cache->expects($this->never())->method('set'); + + $logger->expects($this->once())->method('warning') + ->with( + "Domain of $type is not allowed for broker", + [$type => "https://$type.example.com/", 'broker' => 'foo', 'token' => '123456'] + ); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Domain of $type is not allowed"); + + $server->attach($request); + } + + public function testInvalidChecksum() + { + $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams([ + 'broker' => 'foo', + 'checksum' => '0000000000', + 'token' => '123456' + ]); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $cache->expects($this->never())->method('get'); + $cache->expects($this->never())->method('set'); + + $checksum = $this->generateChecksum('attach', 'bar', '123456'); + $logger->expects($this->once())->method('warning') + ->with( + "Invalid attach checksum", + ['expected' => $checksum, 'received' => '0000000000', 'broker' => 'foo', 'token' => '123456'] + ); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Invalid checksum"); + + $server->attach($request); + } + + public function testUnknownBroker() + { + $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], null); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams([ + 'broker' => 'foo', + 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), + 'token' => '123456' + ]); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $cache->expects($this->never())->method('get'); + $cache->expects($this->never())->method('set'); + + $logger->expects($this->once())->method('warning') + ->with("Unknown broker", ['broker' => 'foo', 'token' => '123456']); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Broker is unknown or disabled"); + + $server->attach($request); + } + + public function testAlreadyAttached() + { + $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams([ + 'broker' => 'foo', + 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), + 'token' => '123456' + ]); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->once())->method('start')->id('start'); + $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn('xyz543'); + $cache->expects($this->never())->method('set'); + + $logger->expects($this->once())->method('warning') + ->with( + "Token is already attached", + ['broker' => 'foo', 'token' => '123456', 'attached_to' => 'xyz543', 'session' => 'abc123'] + ); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Token is already attached"); + + $server->attach($request); + } + + public function testAttachIsIdempotent() + { + $callback = $this->createCallbackMock( + $this->atLeastOnce(), + ['foo'], + ['secret' => 'bar', 'domains' => ['broker.example.com']] + ); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams([ + 'broker' => 'foo', + 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), + 'token' => '123456', + 'return_url' => 'https://broker.example.com/attached' + ]) + ->withHeader('Referer', 'https://broker.example.com/login') + ->withHeader('Origin', 'https://broker.example.com/'); + + $session = $this->createMock(SessionInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->once())->method('start')->id('start'); + $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn('abc123'); + $cache->expects($this->once())->method('set') + ->with('SSO-foo-123456', 'abc123') + ->willReturn(true); + + $code = $server->attach($request); + + $this->assertEquals( + $this->getVerificationCode('foo', '123456', 'abc123'), + $code + ); + } + + public function testCacheIssue() + { + $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams([ + 'broker' => 'foo', + 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), + 'token' => '123456' + ]); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->once())->method('start')->id('start'); + $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn(null); + $cache->expects($this->once())->method('set') + ->with('SSO-foo-123456', 'abc123') + ->willReturn(false); + + $logger->expects($this->once())->method('error') + ->with( + "Failed to attach bearer token to session id due to cache issue", + ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123'] + ); + + $this->expectException(ServerException::class); + $this->expectExceptionMessage("Failed to attach bearer token to session id"); + + $server->attach($request); + } +} diff --git a/tests/unit/Server/BrokerSessionTest.php b/tests/unit/Server/BrokerSessionTest.php new file mode 100644 index 0000000..f9f0bb5 --- /dev/null +++ b/tests/unit/Server/BrokerSessionTest.php @@ -0,0 +1,295 @@ +createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); + + $cache = $this->createMock(CacheInterface::class); + + $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withHeader('Authorization', "Bearer $bearer"); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn('abc123'); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->once())->method('start')->with('abc123'); + + $logger->expects($this->once())->method('debug') + ->with( + "Broker request with session", + ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123'] + ); + + $server->startBrokerSession($request); + } + + public function testSessionAlreadyStarted() + { + $callback = $this->createCallbackMock($this->never()); + + $cache = $this->createMock(CacheInterface::class); + + $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withHeader('Authorization', "Bearer $bearer"); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->once())->method('isActive')->willReturn(true); + $session->expects($this->never())->method('start'); + + $this->expectException(ServerException::class); + $this->expectExceptionMessage("Session is already started"); + + $server->startBrokerSession($request); + } + + public function testMissingAuthorizationHeader() + { + $callback = $this->createCallbackMock($this->never()); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->never())->method('start'); + + $logger->expects($this->once())->method('warning') + ->with("Broker didn't use bearer authentication: No 'Authorization' header"); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Broker didn't use bearer authentication"); + + $server->startBrokerSession($request); + } + + public function testNoBearerAuthorization() + { + $callback = $this->createCallbackMock($this->never()); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withHeader("Authorization", "Basic dXNlcjpwYXNz"); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->never())->method('start'); + + $logger->expects($this->once())->method('warning') + ->with("Broker didn't use bearer authentication: Basic authorization used"); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Broker didn't use bearer authentication"); + + $server->startBrokerSession($request); + } + + public function testInvalidBearerToken() + { + $callback = $this->createCallbackMock($this->never()); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withHeader('Authorization', "Bearer 000000"); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->never())->method('start'); + + $logger->expects($this->once())->method('warning') + ->with("Invalid bearer token", ['bearer' => '000000']); + + $this->expectException(BrokerException::class); + $this->expectExceptionCode(403); + $this->expectExceptionMessage("Invalid or expired bearer token"); + + $server->startBrokerSession($request); + } + + public function testInvalidChecksum() + { + $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withHeader('Authorization', "Bearer SSO-foo-123456-000000"); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn('abc123'); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->never())->method('start'); + + $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); + + $logger->expects($this->once())->method('warning') + ->with( + "Invalid bearer checksum", + [ + 'expected' => str_replace('SSO-foo-123456-', '', $bearer), + 'received' => '000000', + 'broker' => 'foo', + 'token' => '123456', + 'verification_code' => $this->getVerificationCode('foo', '123456', 'abc123') + ] + ); + + $this->expectException(BrokerException::class); + $this->expectExceptionCode(403); + $this->expectExceptionMessage("Invalid or expired bearer token"); + + $server->startBrokerSession($request); + } + + public function testUnattachedToken() + { + $callback = $this->createCallbackMock($this->any(), ['foo'], ['secret' => 'bar']); + + $cache = $this->createMock(CacheInterface::class); + + $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withHeader('Authorization', "Bearer $bearer"); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn(null); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->never())->method('start'); + + $logger->expects($this->once())->method('warning') + ->with( + "Bearer token isn't attached to a client session", + ['broker' => 'foo', 'token' => '123456'] + ); + + $this->expectException(BrokerException::class); + $this->expectExceptionCode(403); + $this->expectExceptionMessage("Invalid or expired bearer token"); + + $server->startBrokerSession($request); + } + + public function testUnknownBroker() + { + $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], null); + + $cache = $this->createMock(CacheInterface::class); + + $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withHeader('Authorization', "Bearer $bearer"); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn('abc123'); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->never())->method('start'); + + $logger->expects($this->once())->method('warning') + ->with("Unknown broker", ['broker' => 'foo', 'token' => '123456']); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Broker is unknown or disabled"); + + $server->startBrokerSession($request); + } +} diff --git a/tests/unit/Server/ServerTestTrait.php b/tests/unit/Server/ServerTestTrait.php new file mode 100644 index 0000000..24eaefa --- /dev/null +++ b/tests/unit/Server/ServerTestTrait.php @@ -0,0 +1,28 @@ +getVerificationCode($broker, $token, $sessionId); + + return "SSO-{$broker}-{$token}-" . $this->generateChecksum("bearer:$code", $secret, $token); + } +} From e18981499d465ba9a151bafdf518b7fe5060d3a8 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Mon, 5 Oct 2020 10:21:32 +0200 Subject: [PATCH 18/48] Broker unit tests + fixes --- README.md | 13 +- composer.json | 3 +- demo/broker/error.php | 2 +- demo/broker/include/attach.php | 5 +- demo/server/api/info.php | 9 +- demo/server/include/start_broker_session.php | 14 +- src/Broker/Broker.php | 117 +++++------ src/Broker/Curl.php | 64 ++++++ src/Server/GlobalSession.php | 26 ++- src/Server/Server.php | 22 +- src/Server/SessionInterface.php | 12 +- tests/unit/Broker/AttachTest.php | 158 ++++++++++++++ tests/unit/Broker/RequestTest.php | 198 ++++++++++++++++++ tests/unit/Server/AttachTest.php | 11 +- tests/unit/Server/BrokerSessionTest.php | 13 +- .../ServerTestTrait.php => TokenTrait.php} | 4 +- 16 files changed, 564 insertions(+), 107 deletions(-) create mode 100644 src/Broker/Curl.php create mode 100644 tests/unit/Broker/AttachTest.php create mode 100644 tests/unit/Broker/RequestTest.php rename tests/unit/{Server/ServerTestTrait.php => TokenTrait.php} (93%) diff --git a/README.md b/README.md index 9b8c6bf..d5375b5 100644 --- a/README.md +++ b/README.md @@ -227,15 +227,26 @@ if (isset($_GET['sso_verify'])) { ### API requests -Once attached, the broker is able to do API requests on behalf of the client. +Once attached, the broker is able to do API requests on behalf of the client. This can be done by + +- using the broker `request()` method, or by +- using any HTTP client like Guzzle + +#### Broker request ``` +// Post to modify the user info +$broker->request('POST', '/login', $credentials); + +// Get user info $user = $broker->request('GET', '/user'); ``` The `request()` method uses Curl to send HTTP requests, adding the bearer token for authentication. It expects a JSON response and will automatically decode it. +#### HTTP library (Guzzle) + To use a library like [Guzzle](http://docs.guzzlephp.org/) or [Httplug](http://httplug.io/), get the bearer token using `getBearerToken()` and set the `Authorization` header diff --git a/composer.json b/composer.json index ab2d84c..6155131 100644 --- a/composer.json +++ b/composer.json @@ -16,9 +16,8 @@ "source": "https://github.com/jasny/sso" }, "require": { - "php": ">=7.2.0", + "php": ">=7.3.0", "ext-json": "*", - "ext-curl": "*", "jasny/immutable": "^2.1", "psr/simple-cache": "^1.0", "psr/log": "^1.1" diff --git a/demo/broker/error.php b/demo/broker/error.php index 8c09af7..4ec2e90 100644 --- a/demo/broker/error.php +++ b/demo/broker/error.php @@ -43,7 +43,7 @@
- Try again + Try again
diff --git a/demo/broker/include/attach.php b/demo/broker/include/attach.php index eca50f9..f741ca5 100644 --- a/demo/broker/include/attach.php +++ b/demo/broker/include/attach.php @@ -30,8 +30,9 @@ } // Attach through redirect if the client isn't attached yet. -if (!$broker->isAttached()) { - $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; +if (!$broker->isAttached() || ($_GET['reattach'] ?? false)) { + $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . + preg_replace('/reattach=1&?/', '', $_SERVER['REQUEST_URI']); $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]); redirect($attachUrl); diff --git a/demo/server/api/info.php b/demo/server/api/info.php index 955f44b..55429e4 100644 --- a/demo/server/api/info.php +++ b/demo/server/api/info.php @@ -12,17 +12,18 @@ // Instantiate the SSO server and start the broker session require __DIR__ . '/../include/start_broker_session.php'; -// No user is logged in; respond with a 401 +// No user is logged in; respond with a 204 No content if (!isset($_SESSION['user'])) { - http_response_code(401); - header('Content-Type: application/json'); - echo json_encode(['error' => "User not logged in"]); + http_response_code(204); exit(); } // Get the username from the session $username = $_SESSION['user']; +// Read config with user info +$config = require __DIR__ . '/../include/config.php'; + // Output user info as JSON. $info = ['username' => $username] + $config['users'][$username]; unset($info['password']); diff --git a/demo/server/include/start_broker_session.php b/demo/server/include/start_broker_session.php index e504bfc..960cd18 100644 --- a/demo/server/include/start_broker_session.php +++ b/demo/server/include/start_broker_session.php @@ -25,9 +25,19 @@ function (string $id) use ($config) { try { $ssoServer->startBrokerSession(); } catch (SsoException $exception) { - http_response_code($exception->getCode()); + $code = $exception->getCode(); + $message = $code === 403 + ? "Invalid or expired bearer token" + : $exception->getMessage(); + + http_response_code($code); + if ($code === 401) { + header('WWW-Authenticate: Bearer'); + } + header('Content-Type: application/json'); - echo json_encode(['error' => $exception->getMessage()]); + echo json_encode(['error' => $message]); + exit(); } diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php index 23259fa..f3fe8d0 100644 --- a/src/Broker/Broker.php +++ b/src/Broker/Broker.php @@ -4,6 +4,8 @@ namespace Jasny\SSO\Broker; +use Jasny\Immutable; + /** * Single sign-on broker. * @@ -12,6 +14,8 @@ */ class Broker { + use Immutable\With; + /** * URL of SSO server. * @var string @@ -52,6 +56,11 @@ class Broker */ protected $state; + /** + * @var Curl + */ + protected $curl; + /** * Class constructor * @@ -66,7 +75,7 @@ public function __construct(string $url, string $broker, string $secret) } if ((bool)preg_match('/\W/', $broker)) { - throw new \InvalidArgumentException("The broker id must be alphanumeric"); + throw new \InvalidArgumentException("Invalid broker id '$broker': must be alphanumeric"); } $this->url = $url; @@ -84,14 +93,30 @@ public function __construct(string $url, string $broker, string $secret) */ public function withTokenIn(\ArrayAccess $handler): self { - if ($this->state === $handler) { - return $this; - } + return $this->withProperty('state', $handler); + } + + /** + * Set a custom wrapper for cURL. + * + * @param Curl $curl + * @return static + */ + public function withCurl(Curl $curl): self + { + return $this->withProperty('curl', $curl); + } - $clone = clone $this; - $clone->state = $handler; + /** + * Get Wrapped cURL. + */ + protected function getCurl(): Curl + { + if (!isset($this->curl)) { + $this->curl = new Curl(); // @codeCoverageIgnore + } - return $clone; + return $this->curl; } /** @@ -111,8 +136,8 @@ protected function initialize(): void return; } - $this->token = $this->state[$this->getCookieName('token')]; - $this->verificationCode = $this->state[$this->getCookieName('verify')]; + $this->token = $this->state[$this->getCookieName('token')] ?? null; + $this->verificationCode = $this->state[$this->getCookieName('verify')] ?? null; $this->initialized = true; } @@ -152,7 +177,7 @@ protected function getCookieName(string $type): string * * @throws NotAttachedException */ - public function getBearerToken(): ?string + public function getBearerToken(): string { $token = $this->getToken(); $verificationCode = $this->getVerificationCode(); @@ -168,12 +193,8 @@ public function getBearerToken(): ?string /** * Generate session token. */ - public function generateToken(): void + protected function generateToken(): void { - if ($this->getToken() !== null) { - return; - } - $this->token = base_convert(bin2hex(random_bytes(32)), 16, 36); $this->state[$this->getCookieName('token')] = $this->token; } @@ -206,7 +227,9 @@ public function isAttached(): bool */ public function getAttachUrl(array $params = []): string { - $this->generateToken(); + if ($this->getToken() === null) { + $this->generateToken(); + } $data = [ 'broker' => $this->broker, @@ -275,67 +298,37 @@ protected function getRequestUrl(string $path, $params = ''): string */ public function request(string $method, string $path, $data = '') { - $bearer = $this->getBearerToken(); $url = $this->getRequestUrl($path, $method === 'POST' ? '' : $data); - - $ch = curl_init($url); - - if ($ch === false) { - throw new \RuntimeException("Failed to initialize a cURL session"); - } - - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ + $headers = [ 'Accept: application/json', - 'Authorization: Bearer '. $bearer - ]); - - if ($method === 'POST' && ($data !== [] && $data !== '')) { - $post = is_string($data) ? $data : http_build_query($data); - curl_setopt($ch, CURLOPT_POSTFIELDS, $post); - } - - $response = (string)curl_exec($ch); - - return $this->handleResponse($ch, $response); - } + 'Authorization: Bearer ' . $this->getBearerToken() + ]; - /** - * Handle response of Curl request. - * - * @param resource $ch Curl handler - * @param string $response - * @return mixed - */ - protected function handleResponse($ch, string $response) - { - if (curl_errno($ch) != 0) { - throw new RequestException('Server request failed: ' . curl_error($ch)); - } + ['httpCode' => $httpCode, 'contentType' => $contentTypeHeader, 'body' => $body] = + $this->getCurl()->request($method, $url, $headers, $method === 'POST' ? $data : ''); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - list($contentType) = explode(';', curl_getinfo($ch, CURLINFO_CONTENT_TYPE)); + [$contentType] = explode(';', $contentTypeHeader, 2); - if ($httpCode === 201 || $httpCode === 401) { + if ($httpCode === 204) { return null; } if ($contentType != 'application/json') { throw new RequestException( "Expected 'application/json' response, got '$contentType'", - 0, - new RequestException($response, $httpCode) + 500, + new RequestException($body, $httpCode) ); } - $data = json_decode($response, true); + try { + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new RequestException("Invalid JSON response from server", 500, $exception); + } - if ($httpCode === 403) { - $this->clearToken(); - throw new NotAttachedException($data['error'] ?? $response, $httpCode); - } elseif ($httpCode >= 400) { - throw new RequestException($data['error'] ?? $response, $httpCode); + if ($httpCode >= 400) { + throw new RequestException($data['error'] ?? $body, $httpCode); } return $data; diff --git a/src/Broker/Curl.php b/src/Broker/Curl.php new file mode 100644 index 0000000..7a525ee --- /dev/null +++ b/src/Broker/Curl.php @@ -0,0 +1,64 @@ +|string $data Query or post parameters + * @return array{httpCode:int,contentType:string,body:string} + * @throws RequestException + */ + public function request(string $method, string $url, array $headers, $data = '') + { + $ch = curl_init($url); + + if ($ch === false) { + throw new \RuntimeException("Failed to initialize a cURL session"); + } + + if ($data !== [] && $data !== '') { + $post = is_string($data) ? $data : http_build_query($data); + curl_setopt($ch, CURLOPT_POSTFIELDS, $post); + } + + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + + $responseBody = (string)curl_exec($ch); + + if (curl_errno($ch) != 0) { + throw new RequestException('Server request failed: ' . curl_error($ch)); + } + + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? 'text/html'; + + return ['httpCode' => $httpCode, 'contentType' => $contentType, 'body' => $responseBody]; + } +} diff --git a/src/Server/GlobalSession.php b/src/Server/GlobalSession.php index ac95736..1b583e8 100644 --- a/src/Server/GlobalSession.php +++ b/src/Server/GlobalSession.php @@ -38,18 +38,38 @@ public function getId(): string /** * @inheritDoc */ - public function start(?string $id = null): void + public function start(): void { - if ($id !== null) { - session_id($id); + $started = session_status() !== PHP_SESSION_ACTIVE + ? session_start($this->options) + : true; + + if (!$started) { + $err = error_get_last() ?? ['message' => 'Failed to start session']; + throw new ServerException($err['message'], 500); } + // Session shouldn't be empty when resumed. + $_SESSION['_sso_init'] = 1; + } + + /** + * @inheritDoc + */ + public function resume(string $id): void + { + session_id($id); $started = session_start($this->options); if (!$started) { $err = error_get_last() ?? ['message' => 'Failed to start session']; throw new ServerException($err['message'], 500); } + + if ($_SESSION === []) { + session_abort(); + throw new BrokerException("Session has expired. Client must attach with new token.", 401); + } } /** diff --git a/src/Server/Server.php b/src/Server/Server.php index 2e7ca37..6595279 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -100,13 +100,13 @@ public function startBrokerSession(?ServerRequestInterface $request = null): voi "Bearer token isn't attached to a client session", ['broker' => $brokerId, 'token' => $token] ); - throw new BrokerException("Invalid or expired bearer token", 403); + throw new BrokerException("Bearer token isn't attached to a client session", 403); } $code = $this->getVerificationCode($brokerId, $token, $sessionId); $this->validateChecksum($checksum, 'bearer', $brokerId, $token, $code); - $this->session->start($sessionId); + $this->session->resume($sessionId); $this->logger->debug( "Broker request with session", @@ -146,7 +146,7 @@ protected function parseBearer(string $bearer): array if (!(bool)preg_match('/^SSO-(\w*+)-(\w*+)-([a-z0-9]*+)$/', $bearer, $matches)) { $this->logger->warning("Invalid bearer token", ['bearer' => $bearer]); - throw new BrokerException("Invalid or expired bearer token", 403); + throw new BrokerException("Invalid bearer token", 403); } return array_slice($matches, 1); @@ -214,14 +214,12 @@ protected function validateChecksum( ['expected' => $expected, 'received' => $checksum, 'broker' => $brokerId, 'token' => $token] + ($code !== null ? ['verification_code' => $code] : []) ); - throw new BrokerException("Invalid or expired bearer token", 403); + throw new BrokerException("Invalid $command checksum", 403); } } /** - * Assert that the URL has a domain that is allowed for the broker. - * - * @throws BrokerException + * Validate that the URL has a domain that is allowed for the broker. */ public function validateDomain(string $type, string $url, string $brokerId, ?string $token = null): void { @@ -233,7 +231,7 @@ public function validateDomain(string $type, string $url, string $brokerId, ?str "Domain of $type is not allowed for broker", [$type => $url, 'broker' => $brokerId] + ($token !== null ? ['token' => $token] : []) ); - throw new BrokerException("Domain of $type is not allowed", 403); + throw new BrokerException("Domain of $type is not allowed", 400); } } @@ -248,9 +246,7 @@ public function attach(?ServerRequestInterface $request = null): string { ['broker' => $brokerId, 'token' => $token] = $this->processAttachRequest($request); - if (!$this->session->isActive()) { - $this->session->start(); - } + $this->session->start(); $this->assertNotAttached($brokerId, $token); @@ -261,7 +257,7 @@ public function attach(?ServerRequestInterface $request = null): string if (!$cached) { $this->logger->error("Failed to attach bearer token to session id due to cache issue", $info); - throw new ServerException("Failed to attach bearer token to session id"); + throw new ServerException("Failed to attach bearer token to session id", 500); } $this->logger->info("Attached broker token to session", $info); @@ -284,7 +280,7 @@ protected function assertNotAttached(string $brokerId, string $token): void 'attached_to' => $attached, 'session' => $this->session->getId() ]); - throw new BrokerException("Token is already attached"); + throw new BrokerException("Token is already attached", 400); } } diff --git a/src/Server/SessionInterface.php b/src/Server/SessionInterface.php index ef5fba7..7abc623 100644 --- a/src/Server/SessionInterface.php +++ b/src/Server/SessionInterface.php @@ -15,12 +15,20 @@ interface SessionInterface public function getId(): string; /** - * Start the session. Optionally with a specific session id. + * Start a new session. * @see session_start() * * @throws ServerException if session can't be started. */ - public function start(?string $id = null): void; + public function start(): void; + + /** + * Resume an existing session. + * + * @throws ServerException if session can't be started. + * @throws BrokerException if session is expired + */ + public function resume(string $id): void; /** * Check if a session is active. (status PHP_SESSION_ACTIVE) diff --git a/tests/unit/Broker/AttachTest.php b/tests/unit/Broker/AttachTest.php new file mode 100644 index 0000000..f77dc15 --- /dev/null +++ b/tests/unit/Broker/AttachTest.php @@ -0,0 +1,158 @@ +session = new \ArrayObject(); + $this->curl = $this->createMock(Curl::class); + + $this->broker = (new Broker('https://example.com/attach', 'foo', 'bar')) + ->withTokenIn($this->session) + ->withCurl($this->curl); + } + + public function testUrlValidationInConstruct() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid SSO server URL 'example'"); + + new Broker('example', 'foo', 'bar'); + } + + public function testBrokerIdValidationInConstruct() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid broker id 'foo-1': must be alphanumeric"); + + new Broker('https://example.com', 'foo-1', 'bar'); + } + + public function testGetBrokerId() + { + $this->assertEquals('foo', $this->broker->getBrokerId()); + } + + public function testGetAttachUrl() + { + $url = $this->broker->getAttachUrl(); + + $this->assertArrayHasKey('sso_token_foo', $this->session); + + $token = $this->session["sso_token_foo"]; + $checksum = $this->generateChecksum('attach', 'bar', $token); + + $this->assertEquals("https://example.com/attach?broker=foo&token=$token&checksum=$checksum", $url); + $this->assertFalse($this->broker->isAttached()); + } + + public function testGetAttachUrlWithParams() + { + $url = $this->broker->getAttachUrl([ + 'return_url' => 'http://broker.example.com/', + 'color' => 'red', + ]); + + $this->assertArrayHasKey('sso_token_foo', $this->session); + + $token = $this->session["sso_token_foo"]; + $checksum = $this->generateChecksum('attach', 'bar', $token); + + $expectedUrl = "https://example.com/attach?broker=foo&token=$token&checksum=$checksum&return_url=" + . urlencode('http://broker.example.com/') . '&color=red'; + $this->assertEquals($expectedUrl, $url); + } + + public function testVerify() + { + $this->session['sso_token_foo'] = '123456'; + + $this->assertFalse($this->broker->isAttached()); + + $code = $this->getVerificationCode('foo', '123456', 'abc123'); + $this->broker->verify($code); + + $this->assertArrayHasKey('sso_verify_foo', $this->session); + $this->assertEquals($code, $this->session['sso_verify_foo']); + $this->assertTrue($this->broker->isAttached()); + } + + public function testVerifyIsIdempotent() + { + $code = $this->getVerificationCode('foo', '123456', 'abc123'); + + $this->session['sso_token_foo'] = '123456'; + $this->session['sso_verify_foo'] = $code; + + $this->broker->verify($code); + + $this->assertArrayHasKey('sso_verify_foo', $this->session); + $this->assertEquals($code, $this->session['sso_verify_foo']); + } + + public function testVerifyIsImmutable() + { + $this->session['sso_token_foo'] = '123456'; + $this->session['sso_verify_foo'] = '000000'; + + $code = $this->getVerificationCode('foo', '123456', 'abc123'); + + $this->expectWarningMessage("SSO attach already verified"); + + $this->broker->verify($code); + + $this->assertArrayHasKey('sso_verify_foo', $this->session); + $this->assertEquals('000000', $this->session['sso_verify_foo']); + } + + public function testClearToken() + { + $this->session['sso_token_foo'] = '123456'; + $this->session['sso_verify_foo'] = $this->getVerificationCode('foo', '123456', 'abc123'); + + $this->assertTrue($this->broker->isAttached()); + + $this->broker->clearToken(); + + $this->assertFalse($this->broker->isAttached()); + $this->assertArrayNotHasKey('sso_token_foo', $this->session); + $this->assertArrayNotHasKey('sso_verify_foo', $this->session); + } +} diff --git a/tests/unit/Broker/RequestTest.php b/tests/unit/Broker/RequestTest.php new file mode 100644 index 0000000..c68327b --- /dev/null +++ b/tests/unit/Broker/RequestTest.php @@ -0,0 +1,198 @@ +session = new \ArrayObject([ + 'sso_token_foo' => '123456', + 'sso_verify_foo' => $this->getVerificationCode('foo', '123456', 'abc123'), + ]); + $this->curl = $this->createMock(Curl::class); + + $this->broker = (new Broker('https://example.com/attach', 'foo', 'bar')) + ->withTokenIn($this->session) + ->withCurl($this->curl); + } + + public function testGetBearerToken() + { + $this->assertTrue($this->broker->isAttached()); + + $bearer = $this->broker->getBearerToken(); + + $this->assertEquals( + $this->getBearerToken('foo', 'bar', '123456', 'abc123'), + $bearer + ); + } + + public function testGetBearerTokenWhenNotAttached() + { + unset($this->session['sso_verify_foo']); + + $this->assertFalse($this->broker->isAttached()); + + $this->expectException(NotAttachedException::class); + $this->expectExceptionMessage("The client isn't attached to the SSO server for this broker. " + . "Make sure that the 'sso_verify_foo' cookie is set."); + + $this->broker->getBearerToken(); + } + + + public function testGetRequest() + { + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') + ]; + $this->curl->expects($this->once())->method('request') + ->with('GET', 'https://example.com/info', $headers, '') + ->willReturn([ + 'httpCode' => 200, + 'contentType' => 'application/json; charset=utf-8', + 'body' => '{"name": "John", "email": "john@example.com"}', + ]); + + $info = $this->broker->request('GET', '/info'); + + $this->assertEquals(['name' => 'John', 'email' => 'john@example.com'], $info); + } + + public function testPostRequest() + { + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') + ]; + $this->curl->expects($this->once())->method('request') + ->with('POST', 'https://example.com/user', $headers, ['name' => 'John', 'color' => 'red']) + ->willReturn([ + 'httpCode' => 200, + 'contentType' => 'application/json; charset=utf-8', + 'body' => '{"name": "John", "email": "john@example.com"}', + ]); + + $info = $this->broker->request('POST', '/user', ['name' => 'John', 'color' => 'red']); + + $this->assertEquals(['name' => 'John', 'email' => 'john@example.com'], $info); + } + + public function testNoContent() + { + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') + ]; + $this->curl->expects($this->once())->method('request') + ->with('POST', 'https://example.com/go', $headers, '') + ->willReturn([ + 'httpCode' => 204, + 'contentType' => '', + 'body' => '', + ]); + + $info = $this->broker->request('POST', '/go'); + + $this->assertNull($info); + } + + public function testBadRequest() + { + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') + ]; + $this->curl->expects($this->once())->method('request') + ->with('GET', 'https://example.com/', $headers, '') + ->willReturn([ + 'httpCode' => 400, + 'contentType' => 'application/json', + 'body' => '{"error": "something is wrong"}', + ]); + + $this->expectException(RequestException::class); + $this->expectExceptionMessage("something is wrong"); + + $this->broker->request('GET', '/'); + } + + public function testInvalidContentType() + { + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') + ]; + $this->curl->expects($this->once())->method('request') + ->with('GET', 'https://example.com/', $headers, '') + ->willReturn([ + 'httpCode' => 200, + 'contentType' => 'text/html', + 'body' => '

Foo

', + ]); + + $this->expectException(RequestException::class); + $this->expectExceptionMessage("Expected 'application/json' response, got 'text/html'"); + + $this->broker->request('GET', '/'); + } + + public function testInvalidJson() + { + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') + ]; + $this->curl->expects($this->once())->method('request') + ->with('GET', 'https://example.com/', $headers, '') + ->willReturn([ + 'httpCode' => 200, + 'contentType' => 'application/json', + 'body' => 'not json', + ]); + + $this->expectException(RequestException::class); + $this->expectExceptionMessage("Invalid JSON response from server"); + + $this->broker->request('GET', '/'); + } +} diff --git a/tests/unit/Server/AttachTest.php b/tests/unit/Server/AttachTest.php index c1728e3..3935549 100644 --- a/tests/unit/Server/AttachTest.php +++ b/tests/unit/Server/AttachTest.php @@ -12,6 +12,7 @@ use Jasny\SSO\Server\Server; use Jasny\SSO\Server\ServerException; use Jasny\SSO\Server\SessionInterface; +use Jasny\Tests\SSO\TokenTrait; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use function Jasny\array_without; @@ -23,7 +24,7 @@ */ class AttachTest extends \Codeception\Test\Unit { - use ServerTestTrait; + use TokenTrait; use CallbackMockTrait; use SafeMocksTrait; @@ -55,7 +56,6 @@ public function testSuccessfulAttach() ->withSession($session) ->withLogger($logger); - $session->expects($this->once())->method('isActive')->willReturn(false); $session->expects($this->once())->method('start')->id('start'); $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); @@ -118,7 +118,6 @@ public function testMissingQueryParameter(string $key) ->withSession($session) ->withLogger($logger); - $session->expects($this->any())->method('isActive')->willReturn(false); $session->expects($this->never())->method('start'); $cache->expects($this->never())->method('get'); @@ -170,7 +169,6 @@ public function testInvalidDomain(string $type, array $domains) ->withSession($session) ->withLogger($logger); - $session->expects($this->any())->method('isActive')->willReturn(false); $session->expects($this->never())->method('start'); $cache->expects($this->never())->method('get'); @@ -220,7 +218,7 @@ public function testInvalidChecksum() ); $this->expectException(BrokerException::class); - $this->expectExceptionMessage("Invalid checksum"); + $this->expectExceptionMessage("Invalid attach checksum"); $server->attach($request); } @@ -279,7 +277,6 @@ public function testAlreadyAttached() ->withSession($session) ->withLogger($logger); - $session->expects($this->once())->method('isActive')->willReturn(false); $session->expects($this->once())->method('start')->id('start'); $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); @@ -326,7 +323,6 @@ public function testAttachIsIdempotent() $server = (new Server($callback, $cache)) ->withSession($session); - $session->expects($this->once())->method('isActive')->willReturn(false); $session->expects($this->once())->method('start')->id('start'); $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); @@ -366,7 +362,6 @@ public function testCacheIssue() ->withSession($session) ->withLogger($logger); - $session->expects($this->once())->method('isActive')->willReturn(false); $session->expects($this->once())->method('start')->id('start'); $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); diff --git a/tests/unit/Server/BrokerSessionTest.php b/tests/unit/Server/BrokerSessionTest.php index f9f0bb5..281247b 100644 --- a/tests/unit/Server/BrokerSessionTest.php +++ b/tests/unit/Server/BrokerSessionTest.php @@ -12,6 +12,7 @@ use Jasny\SSO\Server\Server; use Jasny\SSO\Server\ServerException; use Jasny\SSO\Server\SessionInterface; +use Jasny\Tests\SSO\TokenTrait; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; @@ -22,7 +23,7 @@ */ class BrokerSessionTest extends \Codeception\Test\Unit { - use ServerTestTrait; + use TokenTrait; use CallbackMockTrait; use SafeMocksTrait; @@ -49,7 +50,7 @@ public function testSuccessfulStart() ->willReturn('abc123'); $session->expects($this->once())->method('isActive')->willReturn(false); - $session->expects($this->once())->method('start')->with('abc123'); + $session->expects($this->once())->method('resume')->with('abc123'); $logger->expects($this->once())->method('debug') ->with( @@ -163,13 +164,14 @@ public function testInvalidBearerToken() $session->expects($this->once())->method('isActive')->willReturn(false); $session->expects($this->never())->method('start'); + $session->expects($this->never())->method('resume'); $logger->expects($this->once())->method('warning') ->with("Invalid bearer token", ['bearer' => '000000']); $this->expectException(BrokerException::class); $this->expectExceptionCode(403); - $this->expectExceptionMessage("Invalid or expired bearer token"); + $this->expectExceptionMessage("Invalid bearer token"); $server->startBrokerSession($request); } @@ -214,7 +216,7 @@ public function testInvalidChecksum() $this->expectException(BrokerException::class); $this->expectExceptionCode(403); - $this->expectExceptionMessage("Invalid or expired bearer token"); + $this->expectExceptionMessage("Invalid bearer checksum"); $server->startBrokerSession($request); } @@ -253,7 +255,7 @@ public function testUnattachedToken() $this->expectException(BrokerException::class); $this->expectExceptionCode(403); - $this->expectExceptionMessage("Invalid or expired bearer token"); + $this->expectExceptionMessage("Bearer token isn't attached to a client session"); $server->startBrokerSession($request); } @@ -288,6 +290,7 @@ public function testUnknownBroker() ->with("Unknown broker", ['broker' => 'foo', 'token' => '123456']); $this->expectException(BrokerException::class); + $this->expectExceptionCode(403); $this->expectExceptionMessage("Broker is unknown or disabled"); $server->startBrokerSession($request); diff --git a/tests/unit/Server/ServerTestTrait.php b/tests/unit/TokenTrait.php similarity index 93% rename from tests/unit/Server/ServerTestTrait.php rename to tests/unit/TokenTrait.php index 24eaefa..ed4d65a 100644 --- a/tests/unit/Server/ServerTestTrait.php +++ b/tests/unit/TokenTrait.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Jasny\Tests\SSO\Server; +namespace Jasny\Tests\SSO; /** * Traits for server tests. */ -trait ServerTestTrait +trait TokenTrait { protected function generateChecksum(string $command, string $secret, string $token): string { From b1adc6509d58d1c1d48b7d60f385872fe22ace55 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Mon, 5 Oct 2020 21:01:19 +0200 Subject: [PATCH 19/48] Added demo test Fixed logout in demo. --- demo/broker/error.php | 2 +- demo/broker/include/attach.php | 7 +- demo/server/api/logout.php | 2 +- tests/_bootstrap.php | 4 +- tests/_support/Helper/Demo.php | 68 +++++++++++ tests/_support/PhpBuiltInServer.php | 167 ++++++++++++++++++++++++++++ tests/demo.suite.yml | 4 +- tests/demo/DemoCept.php | 52 +++++++++ 8 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 tests/_support/PhpBuiltInServer.php create mode 100644 tests/demo/DemoCept.php diff --git a/demo/broker/error.php b/demo/broker/error.php index 4ec2e90..8c09af7 100644 --- a/demo/broker/error.php +++ b/demo/broker/error.php @@ -43,7 +43,7 @@
- Try again + Try again
diff --git a/demo/broker/include/attach.php b/demo/broker/include/attach.php index f741ca5..0133502 100644 --- a/demo/broker/include/attach.php +++ b/demo/broker/include/attach.php @@ -24,15 +24,14 @@ $broker->verify($_GET['sso_verify']); $url = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; - $redirectUrl = preg_replace('/sso_verify=\w+&|[\?&]sso_verify=\w+$/', '', $url); + $redirectUrl = preg_replace('/sso_verify=\w+&|[?&]sso_verify=\w+$/', '', $url); redirect($redirectUrl); exit(); } // Attach through redirect if the client isn't attached yet. -if (!$broker->isAttached() || ($_GET['reattach'] ?? false)) { - $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . - preg_replace('/reattach=1&?/', '', $_SERVER['REQUEST_URI']); +if (!$broker->isAttached()) { + $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]); redirect($attachUrl); diff --git a/demo/server/api/logout.php b/demo/server/api/logout.php index ffea202..3f9287b 100644 --- a/demo/server/api/logout.php +++ b/demo/server/api/logout.php @@ -20,4 +20,4 @@ unset($_SESSION['user']); // Done (no output) -http_response_code(201); +http_response_code(204); diff --git a/tests/_bootstrap.php b/tests/_bootstrap.php index 6c8c4f5..844b15b 100644 --- a/tests/_bootstrap.php +++ b/tests/_bootstrap.php @@ -1,3 +1,5 @@ server = new PhpBuiltInServer(ROOT_DIR . '/demo/server/', 8200); + + $this->broker1 = new PhpBuiltInServer( + ROOT_DIR . '/demo/broker/', + 8201, + [ + 'SSO_SERVER' => 'http://localhost:8200/attach.php', + 'SSO_BROKER_ID' => 'Alice', + 'SSO_BROKER_SECRET' => '8iwzik1bwd' + ] + ); + + $this->broker2 = new PhpBuiltInServer( + ROOT_DIR . '/demo/broker/', + 8202, + [ + 'SSO_SERVER' => 'http://localhost:8200/attach.php', + 'SSO_BROKER_ID' => 'Greg', + 'SSO_BROKER_SECRET' => '7pypoox2pc' + ] + ); + } + + /** + * Hook runs after all test of the suite is run + */ + public function _afterSuite() + { + $this->server = null; + $this->broker1 = null; + $this->broker2 = null; + + parent::_afterSuite(); + } + + /** + * Set URL of broker as base host. + * + * @param int $nr + */ + public function amOnBroker(int $nr): void + { + if ($nr < 1 || $nr > 2) { + throw new \Exception("Invalid broker number $nr"); + } + + $port = $nr + 8200; + + /** @var PhpBrowser $phpBrowser */ + $phpBrowser =$this->getModule('PhpBrowser'); + $phpBrowser->amOnUrl("http://localhost:$port"); + } } diff --git a/tests/_support/PhpBuiltInServer.php b/tests/_support/PhpBuiltInServer.php new file mode 100644 index 0000000..c905100 --- /dev/null +++ b/tests/_support/PhpBuiltInServer.php @@ -0,0 +1,167 @@ +port = $port; + + $this->run($documentRoot, $env); + $this->testConnection(); + } + + /** + * Start the web server + * + * @param string $documentRoot Path to router file. + * @param string[] $env Environment variables + */ + protected function run(string $documentRoot, array $env): void + { + if ($this->handle) { + trigger_error("Built-in webserver on port {$this->port} already started", E_USER_NOTICE); + return; + } + + $cmd = $this->getCommand($documentRoot); + $descriptorSpec = [ + ["pipe", "r"], + ['file', Configuration::logDir() . "phpbuiltinserver.{$this->port}.output.txt", 'w'], + ['file', Configuration::logDir() . "phpbuiltinserver.{$this->port}.errors.txt", 'a'] + ]; + $pipes = []; + + $this->handle = proc_open($cmd, $descriptorSpec, $this->pipes, ROOT_DIR, $env, ['bypass_shell' => true]); + fclose($this->pipes[0]); // close stdin + + $this->registerShutdown(); + + usleep(10000); + $status = proc_get_status($this->handle); + + if (!$status['running']) { + proc_close($this->handle); + + $error = stream_get_contents($pipes[2]) ?: stream_get_contents($pipes[1]); + throw new \Exception("Failed to start PHP built-in web server. $error"); + } + } + + /** + * Get the executable command to start the webserver. + */ + protected function getCommand(string $documentRoot): string + { + // Platform uses POSIX process handling. Use exec to avoid controlling the shell process instead of the PHP + // interpreter. + $exec = (PHP_OS !== 'WINNT' && PHP_OS !== 'WIN32') ? 'exec ' : ''; + + return $exec . escapeshellcmd(PHP_BINARY) + . " -S localhost:{$this->port}" + . " -t " . escapeshellarg($documentRoot) + . ($this->isRemoteDebug() ? ' -dxdebug.remote_enable=1' : ''); + } + + /** + * Check if codeception remote debugging is available. + */ + protected function isRemoteDebug(): bool + { + return Configuration::isExtensionEnabled('Codeception\Extension\RemoteDebug'); + } + + /** + * Make sure we can connect to the webserver + */ + protected function testConnection() + { + for ($i=0; $i < 5; $i++) { + if ($this->connect()) { + return; + } + sleep(1); + } + + $err = error_get_last(); + throw new \Exception("Failed to connect to built-in web server: {$err['message']}"); + } + + /** + * Connect to the webserver + */ + protected function connect(): bool + { + $sock = @fsockopen('localhost', $this->port, $errno, $errstr, 1); + + return is_resource($sock) && $errno === 0; + } + + /** + * Stop the web server + */ + public function __destruct() + { + $this->stop(); + } + + /** + * Stop the web server + */ + public function stop(): void + { + if ($this->handle === null) { + return; + } + + foreach ($this->pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + proc_terminate($this->handle, 15); + unset($this->handle); + } + + /** + * Register shutdown function to stop webserver on an error. + */ + protected function registerShutdown(): void + { + $handle = $this->handle; + + register_shutdown_function(function () use ($handle) { + if (is_resource($handle)) { + proc_terminate($handle); + } + }); + } +} diff --git a/tests/demo.suite.yml b/tests/demo.suite.yml index 263f41e..2af681e 100644 --- a/tests/demo.suite.yml +++ b/tests/demo.suite.yml @@ -1,4 +1,6 @@ actor: DemoTester modules: enabled: - - \Helper\Demo \ No newline at end of file + - \Helper\Demo + - PhpBrowser: + url: 'http://localhost:8201' \ No newline at end of file diff --git a/tests/demo/DemoCept.php b/tests/demo/DemoCept.php new file mode 100644 index 0000000..84a9b65 --- /dev/null +++ b/tests/demo/DemoCept.php @@ -0,0 +1,52 @@ +wantTo("login at broker 1 and see I'm also logged in at broker 2"); + +// --- +$I->amGoingTo("login at Alice (broker 1)"); + +$I->amOnBroker(1); +$I->see('Alice'); +$I->see('Logged out'); + +$I->click('Login'); +$I->seeElement('form', ['action' => 'login.php']); +$I->submitForm('form', [ + 'username' => 'john', + 'password' => 'john123' +]); + +$I->see('Logged in'); +$I->see('John Doe'); +$I->see('john.doe@example.com'); + +// --- +$I->amGoingTo("visit Greg (broker 2)"); +$I->expect("john to be logged in through SSO"); + +$I->amOnBroker(2); +$I->see('Greg'); + +$I->see('Logged in'); +$I->see('John Doe'); +$I->see('john.doe@example.com'); + +// --- +$I->amGoingTo("logout at Greg (broker 2)"); + +$I->amOnBroker(2); +$I->see('Greg'); + +$I->click('Logout'); +$I->see('Logged out'); + +// --- +$I->amGoingTo("visit Alice (broker 1)"); +$I->expect("john to be logged out through SSO"); + +$I->amOnBroker(1); +$I->see('Alice'); + +$I->see('Logged out'); From cabf9d78e0c30756e5821a1ea7fa947cc343d9e7 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Mon, 5 Oct 2020 21:06:21 +0200 Subject: [PATCH 20/48] Make PHPStan happy --- src/Broker/Broker.php | 4 ++-- src/Broker/Cookies.php | 1 + src/Broker/Session.php | 1 + src/Server/Server.php | 4 ++-- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php index f3fe8d0..aceb272 100644 --- a/src/Broker/Broker.php +++ b/src/Broker/Broker.php @@ -52,7 +52,7 @@ class Broker protected $verificationCode; /** - * @var \ArrayAccess + * @var \ArrayAccess */ protected $state; @@ -88,7 +88,7 @@ public function __construct(string $url, string $broker, string $secret) /** * Get a copy with a different handler for the user state (like cookie or session). * - * @param \ArrayAccess $handler + * @param \ArrayAccess $handler * @return static */ public function withTokenIn(\ArrayAccess $handler): self diff --git a/src/Broker/Cookies.php b/src/Broker/Cookies.php index df9498a..e716176 100644 --- a/src/Broker/Cookies.php +++ b/src/Broker/Cookies.php @@ -7,6 +7,7 @@ /** * Use global $_COOKIE and setcookie() to persist the client token. * + * @implements \ArrayAccess * @codeCoverageIgnore */ class Cookies implements \ArrayAccess diff --git a/src/Broker/Session.php b/src/Broker/Session.php index 4c96267..a823b85 100644 --- a/src/Broker/Session.php +++ b/src/Broker/Session.php @@ -7,6 +7,7 @@ /** * Use global $_SESSION to persist the client token. * + * @implements \ArrayAccess * @codeCoverageIgnore */ class Session implements \ArrayAccess diff --git a/src/Server/Server.php b/src/Server/Server.php index 6595279..c78ef43 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -44,8 +44,8 @@ class Server /** * Class constructor. * - * @param callable(string):?array $getBrokerInfo - * @param CacheInterface $cache + * @phpstan-param callable(string):?array{secret:string,domains:string[]} $getBrokerInfo + * @phpstan-param CacheInterface $cache */ public function __construct(callable $getBrokerInfo, CacheInterface $cache) { From f3f6fc972f1092b40fdff41a48f30dfaf023ed5f Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Mon, 5 Oct 2020 21:14:55 +0200 Subject: [PATCH 21/48] Fix CI on Travis No support for PHP 7.2 (travis) --- .travis.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9edfaea..8bbb292 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: php php: - - 7.2 - 7.3 - 7.4 - nightly @@ -30,15 +29,14 @@ install: before_script: | if (php -m | grep -q -i xdebug); then - export PHPUNIT_FLAGS="--coverage-clover cache/logs/clover.xml" + export COVERAGE_FLAGS="--coverage --coverage-xml" else - export PHPUNIT_FLAGS="--no-coverage" + export COVERAGE_FLAGS="" fi script: - - vendor/bin/phpunit $PHPUNIT_FLAGS + - vendor/bin/codecept run $COVERAGE_FLAGS after_script: - - test "$PHPUNIT_FLAGS" == "--no-coverage" || vendor/bin/infection --only-covered --no-progress --no-interaction --threads=4 - - test "$PHPUNIT_FLAGS" == "--no-coverage" || php "$HOME/ocular.phar" code-coverage:upload --format=php-clover cache/logs/clover.xml + - test "$COVERAGE_FLAGS" == "" || php "$HOME/ocular.phar" code-coverage:upload --format=php-clover tests/_output/coverage.xml From a51c4bfa71d9ea9d90a2b86f740a594c599662c7 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Mon, 5 Oct 2020 22:05:39 +0200 Subject: [PATCH 22/48] Scrutinize --- src/Broker/Broker.php | 20 +++++++++++++++++--- src/Server/ExceptionInterface.php | 13 +++++++++++++ src/Server/Server.php | 31 +++++++++++++++++++------------ 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php index aceb272..3a2c0bd 100644 --- a/src/Broker/Broker.php +++ b/src/Broker/Broker.php @@ -294,7 +294,7 @@ protected function getRequestUrl(string $path, $params = ''): string * @param string $path Relative path * @param array|string $data Query or post parameters * @return mixed - * @throws NotAttachedException + * @throws RequestException */ public function request(string $method, string $path, $data = '') { @@ -304,15 +304,29 @@ public function request(string $method, string $path, $data = '') 'Authorization: Bearer ' . $this->getBearerToken() ]; - ['httpCode' => $httpCode, 'contentType' => $contentTypeHeader, 'body' => $body] = + ['httpCode' => $httpCode, 'contentType' => $contentType, 'body' => $body] = $this->getCurl()->request($method, $url, $headers, $method === 'POST' ? $data : ''); - [$contentType] = explode(';', $contentTypeHeader, 2); + return $this->handleResponse($httpCode, $contentType, $body); + } + /** + * Handle the response of the cURL request. + * + * @param int $httpCode HTTP status code + * @param string $ctHeader Content-Type header + * @param string $body Response body + * @return mixed + * @throws RequestException + */ + protected function handleResponse(int $httpCode, string $ctHeader, string $body) + { if ($httpCode === 204) { return null; } + [$contentType] = explode(';', $ctHeader, 2); + if ($contentType != 'application/json') { throw new RequestException( "Expected 'application/json' response, got '$contentType'", diff --git a/src/Server/ExceptionInterface.php b/src/Server/ExceptionInterface.php index 09e6604..cbde4a8 100644 --- a/src/Server/ExceptionInterface.php +++ b/src/Server/ExceptionInterface.php @@ -6,4 +6,17 @@ interface ExceptionInterface { + /** + * Gets the Exception message. + * + * @return string + */ + public function getMessage(); + + /** + * Gets the Exception code. + * + * @return int + */ + public function getCode(); } diff --git a/src/Server/Server.php b/src/Server/Server.php index c78ef43..c993693 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -293,10 +293,10 @@ protected function assertNotAttached(string $brokerId, string $token): void */ protected function processAttachRequest(?ServerRequestInterface $request): array { - $brokerId = $this->getQueryParam($request, 'broker', true); - $token = $this->getQueryParam($request, 'token', true); + $brokerId = $this->getRequiredQueryParam($request, 'broker'); + $token = $this->getRequiredQueryParam($request, 'token'); + $checksum = $this->getRequiredQueryParam($request, 'checksum'); - $checksum = $this->getQueryParam($request, 'checksum', true); $this->validateChecksum($checksum, 'attach', $brokerId, $token); $origin = $this->getHeader($request, 'Origin'); @@ -309,7 +309,7 @@ protected function processAttachRequest(?ServerRequestInterface $request): array $this->validateDomain('referer', $referer, $brokerId, $token); } - $returnUrl = $this->getQueryParam($request, 'return_url', false); + $returnUrl = $this->getQueryParam($request, 'return_url'); if ($returnUrl !== null) { $this->validateDomain('return_url', $returnUrl, $brokerId, $token); } @@ -319,23 +319,30 @@ protected function processAttachRequest(?ServerRequestInterface $request): array /** * Get query parameter from PSR-7 request or $_GET. - * - * @param ServerRequestInterface $request - * @param string $key - * @param bool $required - * @return mixed */ - protected function getQueryParam(?ServerRequestInterface $request, string $key, bool $required = false) + protected function getQueryParam(?ServerRequestInterface $request, string $key): ?string { $params = $request === null ? $_GET // @codeCoverageIgnore : $request->getQueryParams(); - if ($required && !isset($params[$key])) { + return $params[$key] ?? null; + } + + /** + * Get required query parameter from PSR-7 request or $_GET. + * + * @throws BrokerException if query parameter isn't set + */ + protected function getRequiredQueryParam(?ServerRequestInterface $request, string $key): string + { + $value = $this->getQueryParam($request, $key); + + if ($value === null) { throw new BrokerException("Missing '$key' query parameter", 400); } - return $params[$key] ?? null; + return $value; } /** From cdb56c5431d2bf63c7a135c313204c4610a0a1d4 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Mon, 5 Oct 2020 22:34:53 +0200 Subject: [PATCH 23/48] Update README.md [skip ci] --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d5375b5..43e7b19 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ +![jasny-banner](https://user-images.githubusercontent.com/100821/62123924-4c501c80-b2c9-11e9-9677-2ebc21d9b713.png) + Single Sign-On for PHP (Ajax compatible) ---- +======== + +[![Build Status](https://travis-ci.com/jasny/sso.svg?branch=master)](https://travis-ci.com/jasny/sso) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/jasny/sso/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/jasny/sso/?branch=master) +[![Code Coverage](https://scrutinizer-ci.com/g/jasny/sso/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/jasny/sso/?branch=master) +[![Packagist Stable Version](https://img.shields.io/packagist/v/jasny/sso.svg)](https://packagist.org/packages/jasny/sso) +[![Packagist License](https://img.shields.io/packagist/l/jasny/sso.svg)](https://packagist.org/packages/jasny/sso) Jasny SSO is a relatively simply and straightforward solution for single sign on (SSO). From c6240eb1131525ea171c348fdc9fa4ff07594079 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Wed, 4 Nov 2020 00:01:41 +0100 Subject: [PATCH 24/48] In demo, check that JSONP callback is a valid function name. --- demo/server/attach.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/demo/server/attach.php b/demo/server/attach.php index 9cb7eab..6a18cbe 100644 --- a/demo/server/attach.php +++ b/demo/server/attach.php @@ -51,10 +51,18 @@ function (string $id) use ($config) { break; case 'jsonp': + $callback = $_GET['callback']; + if (!preg_match('/^[a-z_]\w*$/i', $callback)) { + http_response_code(400); + header('Content-Type: text/plain'); + echo "JSONP callback must be a valid js function name"; + break; + } + header('Content-type: application/javascript'); $data = json_encode($error ?? ['verify' => $verificationCode]); $responseCode = $error['code'] ?? 200; - echo $_REQUEST['callback'] . "($data, $responseCode);"; + echo "{$callback}($data, $responseCode);"; break; case 'redirect': From 4b44fec78a55e6518c28c4f95ad43a452c30910c Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Wed, 4 Nov 2020 00:22:45 +0100 Subject: [PATCH 25/48] Allow CORS for attach request. --- demo/server/attach.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/demo/server/attach.php b/demo/server/attach.php index 6a18cbe..9bc50df 100644 --- a/demo/server/attach.php +++ b/demo/server/attach.php @@ -12,6 +12,12 @@ require_once __DIR__ . '/../../vendor/autoload.php'; +// Preflight for CORS +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(200); + exit; +} + // Config contains the secret keys of the brokers for this demo. $config = require __DIR__ . '/include/config.php'; @@ -45,6 +51,7 @@ function (string $id) use ($config) { switch ($returnType) { case 'json': + header('Access-Control-Allow-Origin: *'); header('Content-type: application/json'); http_response_code($error['code'] ?? 200); echo json_encode($error ?? ['verify' => $verificationCode]); From 75f248955945e62933f4d4548c7c2a0e9c07f1ec Mon Sep 17 00:00:00 2001 From: mikhail Date: Thu, 19 Nov 2020 15:48:59 +0300 Subject: [PATCH 26/48] fixed wrong setcookies() arguments order --- src/Broker/Cookies.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Broker/Cookies.php b/src/Broker/Cookies.php index e716176..ff290bc 100644 --- a/src/Broker/Cookies.php +++ b/src/Broker/Cookies.php @@ -45,7 +45,7 @@ public function __construct(int $ttl = 3600, string $path = '', string $domain = */ public function offsetSet($name, $value) { - $success = setcookie($name, $value, time() + $this->ttl, $this->domain, $this->path, $this->secure, true); + $success = setcookie($name, $value, time() + $this->ttl, $this->path, $this->domain, $this->secure, true); if (!$success) { throw new \RuntimeException("Failed to set cookie '$name'"); @@ -59,7 +59,7 @@ public function offsetSet($name, $value) */ public function offsetUnset($name): void { - setcookie($name, '', 1, $this->domain, $this->path, $this->secure, true); + setcookie($name, '', 1, $this->path, $this->domain, $this->secure, true); unset($_COOKIE[$name]); } From c435abe2b2baba2eddca35dda8db3d4e69b33145 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Fri, 20 Nov 2020 16:48:36 +0100 Subject: [PATCH 27/48] Update README.md [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 43e7b19..2f9ad3f 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ if (!$broker->isAttached()) { ### Verify -Upon verification the SSO Server will return a verification code (as query parameter of in the JSON response). The code +Upon verification the SSO Server will return a verification code (as query parameter or in the JSON response). The code is used to calculate the checksum. The verification code prevents session hijacking using an attach link. ```php From 16a4cf1c2784d69f39874a0eb28a297ecba4f188 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Thu, 10 Dec 2020 00:22:48 +0100 Subject: [PATCH 28/48] Use samesite=None for session cookie by default. Fixes #128 --- src/Server/GlobalSession.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Server/GlobalSession.php b/src/Server/GlobalSession.php index 1b583e8..8575f12 100644 --- a/src/Server/GlobalSession.php +++ b/src/Server/GlobalSession.php @@ -24,7 +24,7 @@ class GlobalSession implements SessionInterface */ public function __construct(array $options = []) { - $this->options = $options; + $this->options = $options + ['cookie_samesite' => 'None']; } /** From e1594bf0af7bd6df1611cc2e3de3cd8fd0c697fd Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Thu, 10 Dec 2020 01:02:23 +0100 Subject: [PATCH 29/48] Test using GitHub actions --- .github/workflows/php.yml | 58 +++++++++++++++++++++++++++++++++++++++ .travis.yml | 42 ---------------------------- composer.json | 1 + 3 files changed, 59 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/php.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..20bfcfb --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,58 @@ +name: PHP + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + run: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - php: 7.3 + composer: '--prefer-lowest' + desc: "Lowest versions" + - php: 7.3 + - php: 7.4 + coverage: '--coverage --coverage-xml' + - php: 8.0 + name: PHP ${{ matrix.php }} ${{ matrix.desc }} + + steps: + - uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + + - name: Validate composer.json and composer.lock + run: composer validate + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + if: steps.composer-cache.outputs.cache-hit != 'true' + run: composer update --prefer-dist --no-progress --no-suggest ${{ matrix.composer }} + + - name: Run Codeception + run: vendor/bin/codecept run ${{ matrix.coverage }} + + - name: Upload coverage to Scrutinizer + if: ${{ matrix.coverage }} + run: > + wget https://scrutinizer-ci.com/ocular.phar -O "/tmp/ocular.phar" && + php "/tmp/ocular.phar" code-coverage:upload --format=php-clover tests/_output/coverage.xml + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 8bbb292..0000000 --- a/.travis.yml +++ /dev/null @@ -1,42 +0,0 @@ -language: php - -php: - - 7.3 - - 7.4 - - nightly - -matrix: - allow_failures: - - php: nightly - -sudo: false - -cache: - directories: - - $HOME/.composer/cache/files - -branches: - only: - - master - - travis - -before_install: - - test "$TRAVIS_PHP_VERSION" != "nightly" || export COMPOSER_FLAGS="$COMPOSER_FLAGS --ignore-platform-reqs" - -install: - - composer install --prefer-source $COMPOSER_FLAGS - - wget https://scrutinizer-ci.com/ocular.phar -O "$HOME/ocular.phar" - -before_script: | - if (php -m | grep -q -i xdebug); then - export COVERAGE_FLAGS="--coverage --coverage-xml" - else - export COVERAGE_FLAGS="" - fi - -script: - - vendor/bin/codecept run $COVERAGE_FLAGS - -after_script: - - test "$COVERAGE_FLAGS" == "" || php "$HOME/ocular.phar" code-coverage:upload --format=php-clover tests/_output/coverage.xml - diff --git a/composer.json b/composer.json index 6155131..b7cb710 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ "scripts": { "test": [ "phpstan analyse", + "codecept run", "phpcs -p src" ] }, From ec5b855db9018830a2cdc225afb389ae37a5a6c3 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Thu, 10 Dec 2020 01:15:07 +0100 Subject: [PATCH 30/48] GitHub actions don't run against lowest --- .github/workflows/php.yml | 7 ++----- composer.json | 3 ++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 20bfcfb..5191190 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -13,14 +13,11 @@ jobs: fail-fast: false matrix: include: - - php: 7.3 - composer: '--prefer-lowest' - desc: "Lowest versions" - php: 7.3 - php: 7.4 coverage: '--coverage --coverage-xml' - php: 8.0 - name: PHP ${{ matrix.php }} ${{ matrix.desc }} + name: PHP ${{ matrix.php }} steps: - uses: actions/checkout@v2 @@ -45,7 +42,7 @@ jobs: - name: Install dependencies if: steps.composer-cache.outputs.cache-hit != 'true' - run: composer update --prefer-dist --no-progress --no-suggest ${{ matrix.composer }} + run: composer update --prefer-dist --no-progress --no-suggest - name: Run Codeception run: vendor/bin/codecept run ${{ matrix.coverage }} diff --git a/composer.json b/composer.json index b7cb710..57ebf21 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "psr/log": "^1.1" }, "require-dev": { - "codeception/codeception": "^4.0", + "phpstan/phpstan": "^0.12.59", + "codeception/codeception": "^4.1", "codeception/module-phpbrowser": "^1.0", "codeception/module-rest": "^1.2", "desarrolla2/cache": "^3.0", From d43928797fa0e5e1fcfe981d31c4e469609cc6b9 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Wed, 16 Dec 2020 04:47:50 +0100 Subject: [PATCH 31/48] Update README.md Update status badge to GitHub actions. [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2f9ad3f..42e96f4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Single Sign-On for PHP (Ajax compatible) ======== -[![Build Status](https://travis-ci.com/jasny/sso.svg?branch=master)](https://travis-ci.com/jasny/sso) +[![PHP](https://github.com/jasny/sso/workflows/PHP/badge.svg)](https://github.com/jasny/sso/actions) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/jasny/sso/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/jasny/sso/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/jasny/sso/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/jasny/sso/?branch=master) [![Packagist Stable Version](https://img.shields.io/packagist/v/jasny/sso.svg)](https://packagist.org/packages/jasny/sso) From b6907573a0cb3b149c0e05b5b7d463de8d2519d5 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Thu, 17 Dec 2020 12:00:36 +0100 Subject: [PATCH 32/48] Update jasny/phpunit-extension --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 57ebf21..5cab83a 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "desarrolla2/cache": "^3.0", "jasny/http-message": "^1.3", "jasny/php-code-quality": "^2.6.0", + "jasny/phpunit-extension": "^0.3.1", "yubb/loggy": "^2.1" }, "autoload": { From ac491ab70671b081a8c6de514e6efbc143f8eb78 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Thu, 17 Dec 2020 12:08:54 +0100 Subject: [PATCH 33/48] Update GitHub action Don't cache composer packages. --- .github/workflows/php.yml | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 5191190..86f3d74 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -28,18 +28,9 @@ jobs: php-version: ${{ matrix.php }} coverage: xdebug - - name: Validate composer.json and composer.lock + - name: Validate composer.json run: composer validate - - name: Cache Composer packages - id: composer-cache - uses: actions/cache@v2 - with: - path: vendor - key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-php- - - name: Install dependencies if: steps.composer-cache.outputs.cache-hit != 'true' run: composer update --prefer-dist --no-progress --no-suggest From b2d269424537f3ee70539ab52aa0bc495e9a25f4 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Thu, 17 Dec 2020 12:26:40 +0100 Subject: [PATCH 34/48] Fixup GitHub actions --- .github/workflows/php.yml | 1 - composer.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 86f3d74..5544366 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -32,7 +32,6 @@ jobs: run: composer validate - name: Install dependencies - if: steps.composer-cache.outputs.cache-hit != 'true' run: composer update --prefer-dist --no-progress --no-suggest - name: Run Codeception diff --git a/composer.json b/composer.json index 5cab83a..1e05dca 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "desarrolla2/cache": "^3.0", "jasny/http-message": "^1.3", "jasny/php-code-quality": "^2.6.0", - "jasny/phpunit-extension": "^0.3.1", + "jasny/phpunit-extension": "^0.3.2", "yubb/loggy": "^2.1" }, "autoload": { From c563d9769829fbbedcdcce9d71802392520e9a35 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Sun, 31 Jan 2021 03:12:18 +0100 Subject: [PATCH 35/48] Update README.md Don't put typed args in the callback to make it more readable. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 42e96f4..c66987e 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,8 @@ $brokers = [ ]; $server = new Server( - fn(string $id): string => $brokers[$id] ?? null, // Unique secret and allowed domains for each broker. - new Cache() // Any PSR-16 compatible cache + fn($id) => $brokers[$id] ?? null, // Unique secret and allowed domains for each broker. + new Cache() // Any PSR-16 compatible cache ); ``` From 9010ea33a5c1cd06c770ee1cfd1bc64d982fa5af Mon Sep 17 00:00:00 2001 From: Swiss Date: Thu, 4 Mar 2021 04:40:11 +0700 Subject: [PATCH 36/48] Add secure to session cookie (#132) Due to Chrome 80 update Chrome has changed some policy regard to cookie. https://blog.chromium.org/2019/10/developers-get-ready-for-new.html SameSite=None and Secure needed for cross-site cookies This commit was to solve this issues https://github.com/jasny/sso/issues/131 Due to change you need SSL/HTTPS connection for it to work --- src/Server/GlobalSession.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Server/GlobalSession.php b/src/Server/GlobalSession.php index 8575f12..7e68887 100644 --- a/src/Server/GlobalSession.php +++ b/src/Server/GlobalSession.php @@ -24,7 +24,7 @@ class GlobalSession implements SessionInterface */ public function __construct(array $options = []) { - $this->options = $options + ['cookie_samesite' => 'None']; + $this->options = $options + ['cookie_samesite' => 'None', 'cookie_secure' => true]; } /** From 5dbebb1d6299559f679e823127b72c8a889d58b5 Mon Sep 17 00:00:00 2001 From: nishat-propertyloop <85307453+nishat-propertyloop@users.noreply.github.com> Date: Sun, 13 Jun 2021 05:51:22 +0530 Subject: [PATCH 37/48] Fixed typo in README (#135) Corrected typo and changed "has" to "hash" --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c66987e..bc7cb6a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ secret and the token. This hash is used to create a link to the user's session. redirects the client back to the broker. The broker can create the same link hash using the token (from the cookie), the broker id and the broker secret. When -doing requests, it passes that has as session id. +doing requests, it passes that hash as a session id. The server will notice that the session id is a link and use the linked session. As such, the broker and client are using the same session. When another broker joins in, it will also use the same session. From 1fd9d60e865eb6f71dae69e1c288e91ffa0e0ca1 Mon Sep 17 00:00:00 2001 From: Casper Lai Date: Fri, 14 May 2021 20:08:27 +0800 Subject: [PATCH 38/48] remove header string type when response http code 204, the header is null --- src/Broker/Broker.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php index 3a2c0bd..90b5e9d 100644 --- a/src/Broker/Broker.php +++ b/src/Broker/Broker.php @@ -314,12 +314,12 @@ public function request(string $method, string $path, $data = '') * Handle the response of the cURL request. * * @param int $httpCode HTTP status code - * @param string $ctHeader Content-Type header + * @param string|null $ctHeader Content-Type header * @param string $body Response body * @return mixed * @throws RequestException */ - protected function handleResponse(int $httpCode, string $ctHeader, string $body) + protected function handleResponse(int $httpCode, $ctHeader, string $body) { if ($httpCode === 204) { return null; From b644392d3744fb616563b56e1813ac01539c00c6 Mon Sep 17 00:00:00 2001 From: Azraar Azward Date: Sat, 5 Mar 2022 21:18:35 +0530 Subject: [PATCH 39/48] Fixed typo in README Corrected typo, changed "know" to "known" --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bc7cb6a..8082521 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ When using SSO, when can distinguish 3 parties: * Broker - The website which is visited * Server - The place that holds the user info and credentials -The broker has an id and a secret. These are know to both the broker and server. +The broker has an id and a secret. These are known to both the broker and server. When the client visits the broker, it creates a random token, which is stored in a cookie. The broker will then send the client to the server, passing along the broker's id and token. The server creates a hash using the broker id, broker From fb4f0916911b00797425237abfb073ffee8cba1c Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Sat, 30 Apr 2022 20:31:10 +0200 Subject: [PATCH 40/48] Composer accept any version of psr/log. Fixes #142 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 1e05dca..c35c5dc 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "ext-json": "*", "jasny/immutable": "^2.1", "psr/simple-cache": "^1.0", - "psr/log": "^1.1" + "psr/log": "*" }, "require-dev": { "phpstan/phpstan": "^0.12.59", From 69cc85b116bb5b6e657510237601871edc351c86 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Tue, 24 Jan 2023 07:00:54 -0400 Subject: [PATCH 41/48] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8082521..9373391 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![jasny-banner](https://user-images.githubusercontent.com/100821/62123924-4c501c80-b2c9-11e9-9677-2ebc21d9b713.png) -Single Sign-On for PHP (Ajax compatible) +Single Sign-On for PHP ======== [![PHP](https://github.com/jasny/sso/workflows/PHP/badge.svg)](https://github.com/jasny/sso/actions) From 9469117ed025589112a300f4cf7af38cfb729b28 Mon Sep 17 00:00:00 2001 From: Arnold Daniels Date: Tue, 24 Jan 2023 07:07:09 -0400 Subject: [PATCH 42/48] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9373391..56af60b 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ Once attached, the broker is able to do API requests on behalf of the client. Th #### Broker request -``` +```php // Post to modify the user info $broker->request('POST', '/login', $credentials); @@ -288,7 +288,7 @@ _(The cookie can never be accessed by the browser.)_ #### Session -Alternative, you can store the SSO token in a PHP session for the broker by using `SessionState`. +Alternative, you can store the SSO token in a PHP session for the broker by using `Session`. ```php use Jasny\SSO\Broker\{Broker,Session}; From e6fdbc15ec8fc22c6784b97172acc56174dbaf44 Mon Sep 17 00:00:00 2001 From: Aldino Kemal Date: Fri, 28 Jul 2023 21:15:19 +0700 Subject: [PATCH 43/48] feat: add compatible with php 8 --- .gitignore | 1 + src/Broker/Broker.php | 7 +++- src/Broker/Cookies8.php | 73 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/Broker/Cookies8.php diff --git a/.gitignore b/.gitignore index 622097a..9a51957 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ tests/_output/* !.elasticbeanstalk/*.cfg.yml !.elasticbeanstalk/*.global.yml /tests/_support/_generated/ +.idea diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php index 90b5e9d..2c34dda 100644 --- a/src/Broker/Broker.php +++ b/src/Broker/Broker.php @@ -82,7 +82,12 @@ public function __construct(string $url, string $broker, string $secret) $this->broker = $broker; $this->secret = $secret; - $this->state = new Cookies(); + // check if PHP version >=8.0 + if (phpversion() >= '8.0.0') { + $this->state = new Cookies8(); + } else { + $this->state = new Cookies(); + } } /** diff --git a/src/Broker/Cookies8.php b/src/Broker/Cookies8.php new file mode 100644 index 0000000..897b96d --- /dev/null +++ b/src/Broker/Cookies8.php @@ -0,0 +1,73 @@ + + * @codeCoverageIgnore + */ +class Cookies8 implements \ArrayAccess +{ + /** @var int */ + protected int $ttl; + + /** @var string */ + protected string $path; + + /** @var string */ + protected string $domain; + + /** @var bool */ + protected bool $secure; + + public function __construct(int $ttl = 3600, string $path = '', string $domain = '', bool $secure = false) + { + $this->ttl = $ttl; + $this->path = $path; + $this->domain = $domain; + $this->secure = $secure; + } + + /** + * @inheritDoc + */ + public function offsetExists(mixed $offset): bool + { + return isset($_COOKIE[$offset]); + } + + /** + * @inheritDoc + */ + public function offsetGet(mixed $offset): mixed + { + return $_COOKIE[$offset] ?? null; + } + + /** + * @inheritDoc + */ + public function offsetSet(mixed $offset, mixed $value): void + { + $success = setcookie($offset, $value, time() + $this->ttl, $this->path, $this->domain, $this->secure, true); + + if (!$success) { + throw new \RuntimeException("Failed to set cookie '$offset'"); + } + + $_COOKIE[$offset] = $value; + } + + /** + * @inheritDoc + */ + public function offsetUnset(mixed $offset): void + { + setcookie($offset, '', 1, $this->path, $this->domain, $this->secure, true); + unset($_COOKIE[$offset]); + } +} From 32fd030851311177fc9a651a8f7660d6efc2e161 Mon Sep 17 00:00:00 2001 From: Aldino Kemal Date: Mon, 31 Jul 2023 21:23:15 +0700 Subject: [PATCH 44/48] feat: update scrutinizer --- .scrutinizer.yml | 2 +- src/Broker/Broker.php | 7 +--- src/Broker/Cookies.php | 46 +++++++++++--------------- src/Broker/Cookies8.php | 73 ----------------------------------------- 4 files changed, 21 insertions(+), 107 deletions(-) delete mode 100644 src/Broker/Cookies8.php diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 985ff19..f891c68 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -8,7 +8,7 @@ build: nodes: analysis: environment: - php: 7.4 + php: 8.2 postgresql: false redis: false mongodb: false diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php index 2c34dda..90b5e9d 100644 --- a/src/Broker/Broker.php +++ b/src/Broker/Broker.php @@ -82,12 +82,7 @@ public function __construct(string $url, string $broker, string $secret) $this->broker = $broker; $this->secret = $secret; - // check if PHP version >=8.0 - if (phpversion() >= '8.0.0') { - $this->state = new Cookies8(); - } else { - $this->state = new Cookies(); - } + $this->state = new Cookies(); } /** diff --git a/src/Broker/Cookies.php b/src/Broker/Cookies.php index ff290bc..62c4721 100644 --- a/src/Broker/Cookies.php +++ b/src/Broker/Cookies.php @@ -13,25 +13,17 @@ class Cookies implements \ArrayAccess { /** @var int */ - protected $ttl; + protected int $ttl; /** @var string */ - protected $path; + protected string $path; /** @var string */ - protected $domain; + protected string $domain; /** @var bool */ - protected $secure; + protected bool $secure; - /** - * Cookies constructor. - * - * @param int $ttl Cookie TTL in seconds - * @param string $path - * @param string $domain - * @param bool $secure - */ public function __construct(int $ttl = 3600, string $path = '', string $domain = '', bool $secure = false) { $this->ttl = $ttl; @@ -43,39 +35,39 @@ public function __construct(int $ttl = 3600, string $path = '', string $domain = /** * @inheritDoc */ - public function offsetSet($name, $value) + public function offsetExists(mixed $offset): bool { - $success = setcookie($name, $value, time() + $this->ttl, $this->path, $this->domain, $this->secure, true); - - if (!$success) { - throw new \RuntimeException("Failed to set cookie '$name'"); - } - - $_COOKIE[$name] = $value; + return isset($_COOKIE[$offset]); } /** * @inheritDoc */ - public function offsetUnset($name): void + public function offsetGet(mixed $offset): mixed { - setcookie($name, '', 1, $this->path, $this->domain, $this->secure, true); - unset($_COOKIE[$name]); + return $_COOKIE[$offset] ?? null; } /** * @inheritDoc */ - public function offsetGet($name) + public function offsetSet(mixed $offset, mixed $value): void { - return $_COOKIE[$name] ?? null; + $success = setcookie($offset, $value, time() + $this->ttl, $this->path, $this->domain, $this->secure, true); + + if (!$success) { + throw new \RuntimeException("Failed to set cookie '$offset'"); + } + + $_COOKIE[$offset] = $value; } /** * @inheritDoc */ - public function offsetExists($name) + public function offsetUnset(mixed $offset): void { - return isset($_COOKIE[$name]); + setcookie($offset, '', 1, $this->path, $this->domain, $this->secure, true); + unset($_COOKIE[$offset]); } } diff --git a/src/Broker/Cookies8.php b/src/Broker/Cookies8.php deleted file mode 100644 index 897b96d..0000000 --- a/src/Broker/Cookies8.php +++ /dev/null @@ -1,73 +0,0 @@ - - * @codeCoverageIgnore - */ -class Cookies8 implements \ArrayAccess -{ - /** @var int */ - protected int $ttl; - - /** @var string */ - protected string $path; - - /** @var string */ - protected string $domain; - - /** @var bool */ - protected bool $secure; - - public function __construct(int $ttl = 3600, string $path = '', string $domain = '', bool $secure = false) - { - $this->ttl = $ttl; - $this->path = $path; - $this->domain = $domain; - $this->secure = $secure; - } - - /** - * @inheritDoc - */ - public function offsetExists(mixed $offset): bool - { - return isset($_COOKIE[$offset]); - } - - /** - * @inheritDoc - */ - public function offsetGet(mixed $offset): mixed - { - return $_COOKIE[$offset] ?? null; - } - - /** - * @inheritDoc - */ - public function offsetSet(mixed $offset, mixed $value): void - { - $success = setcookie($offset, $value, time() + $this->ttl, $this->path, $this->domain, $this->secure, true); - - if (!$success) { - throw new \RuntimeException("Failed to set cookie '$offset'"); - } - - $_COOKIE[$offset] = $value; - } - - /** - * @inheritDoc - */ - public function offsetUnset(mixed $offset): void - { - setcookie($offset, '', 1, $this->path, $this->domain, $this->secure, true); - unset($_COOKIE[$offset]); - } -} From 2095cfc1652742d508ffc2553884d68310d99806 Mon Sep 17 00:00:00 2001 From: Aldino Kemal Date: Mon, 31 Jul 2023 21:25:25 +0700 Subject: [PATCH 45/48] feat: change php version --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c35c5dc..9d68956 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "source": "https://github.com/jasny/sso" }, "require": { - "php": ">=7.3.0", + "php": "^8.2", "ext-json": "*", "jasny/immutable": "^2.1", "psr/simple-cache": "^1.0", From 7504a1674440dda07611cfcca4c8f6ef00d74243 Mon Sep 17 00:00:00 2001 From: arnold Date: Wed, 9 Aug 2023 08:34:24 -0400 Subject: [PATCH 46/48] Set minimal PHP version to 8.0 --- .github/workflows/php.yml | 16 +++++++++------- composer.json | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 5544366..75c5844 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -13,15 +13,17 @@ jobs: fail-fast: false matrix: include: - - php: 7.3 - - php: 7.4 - coverage: '--coverage --coverage-xml' - php: 8.0 + - php: 8.1 + - php: 8.2 + coverage: '--coverage --coverage-xml' name: PHP ${{ matrix.php }} steps: - uses: actions/checkout@v2 - + with: + fetch-depth: 10 + - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -39,7 +41,7 @@ jobs: - name: Upload coverage to Scrutinizer if: ${{ matrix.coverage }} - run: > - wget https://scrutinizer-ci.com/ocular.phar -O "/tmp/ocular.phar" && - php "/tmp/ocular.phar" code-coverage:upload --format=php-clover tests/_output/coverage.xml + uses: sudo-bot/action-scrutinizer@latest + with: + cli-args: "--format=php-clover build/logs/clover.xml --revision=${{ github.event.pull_request.head.sha || github.sha }}" diff --git a/composer.json b/composer.json index 9d68956..e16c02a 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "source": "https://github.com/jasny/sso" }, "require": { - "php": "^8.2", + "php": "^8.0", "ext-json": "*", "jasny/immutable": "^2.1", "psr/simple-cache": "^1.0", From 372dcd120db240d580b3bef414cc86a92a08f027 Mon Sep 17 00:00:00 2001 From: Aldino Kemal Date: Mon, 14 Aug 2023 12:23:00 +0700 Subject: [PATCH 47/48] feat: update version to * --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e16c02a..8867b8a 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ "php": "^8.0", "ext-json": "*", "jasny/immutable": "^2.1", - "psr/simple-cache": "^1.0", + "psr/simple-cache": "*", "psr/log": "*" }, "require-dev": { From e96dbdfe0b25984f0d7c276b47ba7a8986bdb385 Mon Sep 17 00:00:00 2001 From: Bob Brown Date: Sun, 11 Feb 2024 22:00:41 +1300 Subject: [PATCH 48/48] Update README.md Small changes to wording --- README.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 56af60b..efb3b95 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,14 @@ Single Sign-On for PHP [![Packagist Stable Version](https://img.shields.io/packagist/v/jasny/sso.svg)](https://packagist.org/packages/jasny/sso) [![Packagist License](https://img.shields.io/packagist/l/jasny/sso.svg)](https://packagist.org/packages/jasny/sso) -Jasny SSO is a relatively simply and straightforward solution for single sign on (SSO). +Jasny SSO is a relatively simple and straightforward solution for single sign on (SSO). With SSO, logging into a single website will authenticate you for all affiliate sites. The sites don't need to share a toplevel domain. ### How it works -When using SSO, when can distinguish 3 parties: +When using SSO, we can distinguish 3 parties: * Client - This is the browser of the visitor * Broker - The website which is visited @@ -42,11 +42,11 @@ For a more in depth explanation, please [read this article](https://github.com/j With OAuth, you can authenticate a user at an external server and get access to their profile info. However, you aren't sharing a session. -A user logs in to website foo.com using Google OAuth. Next he visits website bar.org which also uses Google OAuth. -Regardless of that, he is still required to press on the 'login' button on bar.org. +A user logs in to website foo.com using Google OAuth. Next they visit website bar.org which also uses Google OAuth. +Regardless of that, they are still required to press the 'login' button on bar.org. -With Jasny SSO both websites use the same session. So when the user visits bar.org, he's automatically logged in. -When he logs out (on either of the sites), he's logged out for both. +With Jasny SSO both websites use the same session. So when the user visits bar.org, they are automatically logged in. +When they log out (on either of the sites), they are logged out for both. ## Installation @@ -59,7 +59,7 @@ Install this library through composer There is a demo server and two demo brokers as example. One with normal redirects and one using [JSONP](https://en.wikipedia.org/wiki/JSONP) / AJAX. -To proof it's working you should setup the server and two or more brokers, each on their own machine and their own +To prove it's working you should setup the server and two or more brokers, each on their own machine and their own (sub)domain. However, you can also run both server and brokers on your own machine, simply to test it out. On *nix (Linux / Unix / OSX) run: @@ -86,7 +86,7 @@ _Note that after logging in, you need to refresh on the other brokers to see the ## Server -The `Server` class takes a callback as first constructor argument. This callback should lookup the secret +The `Server` class takes a callback as first constructor argument. This callback should look up the secret for a broker based on the id. The second argument must be a PSR-16 compatible cache object. It's used to store the link between broker token and @@ -106,11 +106,11 @@ $server = new Server( ); ``` -_In this example the brokers are simply configured as array. But typically you want to fetch the broker info from a DB._ +_In this example the brokers are simply configured as an array, but typically you want to fetch the broker info from a DB._ ### Attach -A client needs attach the broker token to the session id by doing an HTTP request to the server. This request can be +A client needs to attach the broker token to the session id by doing an HTTP request to the server. This request can be handled by calling `attach()`. The `attach()` method returns a verification code. This code must be returned to the broker, as it's needed to @@ -192,7 +192,7 @@ secret needs to match the secret registered at the server. ### Attach -Before the broker can do API requests on the client's behalve, the client needs to attach the broker token to the client +Before the broker can do API requests on the client's behalf, the client needs to attach the broker token to the client session. For this, the client must do an HTTP request to the SSO Server. The `getAttachUrl()` method will generate a broker token for the client and use it to create an attach URL. The method @@ -224,7 +224,7 @@ if (!$broker->isAttached()) { ### Verify -Upon verification the SSO Server will return a verification code (as query parameter or in the JSON response). The code +Upon verification the SSO Server will return a verification code (as a query parameter or in the JSON response). The code is used to calculate the checksum. The verification code prevents session hijacking using an attach link. ```php @@ -288,7 +288,7 @@ _(The cookie can never be accessed by the browser.)_ #### Session -Alternative, you can store the SSO token in a PHP session for the broker by using `Session`. +Alternatively, you can store the SSO token in a PHP session for the broker by using `Session`. ```php use Jasny\SSO\Broker\{Broker,Session};