diff --git a/README.md b/README.md index 76efb0f90..cae9530cb 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Unless otherwise noted, all code is released under the APACHE 2.0 License http:/ ## Installation and further documentation Detailed installation instructions for a virtual machine environment using Vagrant are located at: -[Vagrant virtual machine installation](./installation.md) +[Vagrant virtual machine installation](./installation.md) Information for installation to a local machine environment can be found at: @@ -41,7 +41,7 @@ configuration file for this should be located here openstack/_config/cloudassets.yml -and with following content +and with following content * https://docs.openstack.org/keystone/rocky/user/application_credentials.html @@ -62,8 +62,8 @@ CloudAssets: ApplicationCredentialId: application credential id ApplicationCredentialSecret: application credential secret ProjectName: your project name - AuthURL: keystone base url - LocalCopy: false + AuthURL: keystone base url + LocalCopy: false ```` ## OIDC @@ -73,19 +73,23 @@ settings for oidc configuration on _ss_environment.php file ````PHP // OIDC define('OIDC_CLIENT', ''); - define('OIDC_CLIENT_SECRET', ''); +//set true on production mode, otherwise false +define('OIDC_VERIFY_HOST',false); +define('SESSION_CHECKER_OAUTH2_APP_CLIENT_ID', ''); +define('SESSION_CHECKER_OAUTH2_APP_CLIENT_SECRET', ''); //set true on production mode, otherwise false -define('OIDC_VERIFY_HOST',false); +define('SESSION_CHECKER_OAUTH2_APP_VERIFY_HOST', false); + ```` on idp under allowed URIs you need to register following one * https://hostname/openstackidauthenticator -under security settings you need to set Id Token Signed Response Algorithm +under security settings you need to set Id Token Signed Response Algorithm diff --git a/composer.json b/composer.json index 74b2d5163..011fca2b3 100644 --- a/composer.json +++ b/composer.json @@ -92,7 +92,8 @@ "php-opencloud/openstack": "dev-master", "jumbojett/openid-connect-php": "dev-master", "mikehaertl/phpwkhtmltopdf": "dev-master", - "spatie/dropbox-api": "dev-master" + "spatie/dropbox-api": "dev-master", + "caseyamcl/guzzle_retry_middleware": "2.13.0" }, "require-dev": { "phpunit/phpunit": "^7.0", diff --git a/composer.lock b/composer.lock index 512d58615..b1415c125 100644 --- a/composer.lock +++ b/composer.lock @@ -1,11 +1,84 @@ { "_readme": [ "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "588fea3911bc0293ed0a1a2e87f1ce79", + "content-hash": "abf949ebcacd420c81c40530c4f73ff3", "packages": [ + { + "name": "caseyamcl/guzzle_retry_middleware", + "version": "v2.13.0", + "source": { + "type": "git", + "url": "https://github.com/caseyamcl/guzzle_retry_middleware.git", + "reference": "17c9299cde438b00bbeb099c6480319a81636a60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/caseyamcl/guzzle_retry_middleware/zipball/17c9299cde438b00bbeb099c6480319a81636a60", + "reference": "17c9299cde438b00bbeb099c6480319a81636a60", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.3|^7.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "jaschilz/php-coverage-badger": "^2.0", + "nesbot/carbon": "^2.0|^3.0", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^7.5|^8.0|^9.0", + "squizlabs/php_codesniffer": "^3.5", + "symfony/var-dumper": "^5.0|^6.0|^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleRetry\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Casey McLaughlin", + "email": "caseyamcl@gmail.com", + "homepage": "https://caseymclaughlin.com", + "role": "Developer" + } + ], + "description": "Guzzle v6+ retry middleware that handles 429/503 status codes and connection timeouts", + "homepage": "https://github.com/caseyamcl/guzzle_retry_middleware", + "keywords": [ + "Guzzle", + "back-off", + "caseyamcl", + "guzzle_retry_middleware", + "middleware", + "retry", + "retry-after" + ], + "support": { + "issues": "https://github.com/caseyamcl/guzzle_retry_middleware/issues", + "source": "https://github.com/caseyamcl/guzzle_retry_middleware/tree/v2.13.0" + }, + "funding": [ + { + "url": "https://github.com/caseyamcl", + "type": "github" + } + ], + "time": "2025-07-11T12:33:22+00:00" + }, { "name": "composer/installers", "version": "dev-master", @@ -1480,11 +1553,6 @@ "reference": "38654be8419d4abb59114ff6b9982fb094406bff", "shasum": "" }, - "archive": { - "exclude": [ - ".*" - ] - }, "require": { "ext-curl": "*", "ext-json": "*", @@ -1497,6 +1565,11 @@ "src/" ] }, + "archive": { + "exclude": [ + ".*" + ] + }, "license": [ "Apache-2.0" ], @@ -6089,25 +6162,26 @@ "aliases": [], "minimum-stability": "dev", "stability-flags": { - "unclecheese/dropzone": 20, - "unclecheese/bootstrap-tagfield": 20, - "silverstripe/gridfieldajaxrefresh": 20, - "tractorcow/silverstripe-colorpicker": 20, - "tractorcow/silverstripe-opengraph": 20, - "nnnick/chartjs": 20, - "openid/php-openid": 20, "google/recaptcha": 20, - "smarcet/silverstripe-cloudassets-swift": 20, - "markguinn/silverstripe-cloudassets": 20, - "php-opencloud/openstack": 20, "jumbojett/openid-connect-php": 20, + "markguinn/silverstripe-cloudassets": 20, "mikehaertl/phpwkhtmltopdf": 20, - "spatie/dropbox-api": 20 + "nnnick/chartjs": 20, + "openid/php-openid": 20, + "php-opencloud/openstack": 20, + "silverstripe/gridfieldajaxrefresh": 20, + "smarcet/silverstripe-cloudassets-swift": 20, + "spatie/dropbox-api": 20, + "tractorcow/silverstripe-colorpicker": 20, + "tractorcow/silverstripe-opengraph": 20, + "unclecheese/bootstrap-tagfield": 20, + "unclecheese/dropzone": 20 }, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": "^7.1.3" }, - "platform-dev": [] + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/openstack/code/Page.php b/openstack/code/Page.php index 1097f949f..719582c9a 100644 --- a/openstack/code/Page.php +++ b/openstack/code/Page.php @@ -137,7 +137,7 @@ public static function IconShortCodeHandler($arguments, $caption = null, $parser //return the customized template return $template->process(new ArrayData($customise)); - } + } function requireDefaultRecords() @@ -274,6 +274,22 @@ public function getTime() return time(); } + public function getSecurityToken() + { + return SecurityToken::inst() ? str_replace('"', '\\"', SecurityToken::inst()->getValue()) : null; + } + + public function getIsSSOBootstrapEnabled() + { + $enabled = (bool) (defined('SHELL_SSO_BOOTSTRAP_ENABLED') and SHELL_SSO_BOOTSTRAP_ENABLED); + return $enabled and Member::currentUserID() !== null; + } + + public function getMemberIsLoggedIn() + { + return Member::currentUserID() !== null; + } + protected function CustomScripts() { $js_files = [ diff --git a/openstack/code/utils/apis/AbstractRestfulJsonApi.php b/openstack/code/utils/apis/AbstractRestfulJsonApi.php index 41ea3fb24..1ac820db1 100644 --- a/openstack/code/utils/apis/AbstractRestfulJsonApi.php +++ b/openstack/code/utils/apis/AbstractRestfulJsonApi.php @@ -11,8 +11,8 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ -use Doctrine\Common\Annotations\AnnotationReader; use Openstack\Annotations\CachedMethod; +use Doctrine\Common\Annotations\AnnotationReader; /** * Class AbstractRestfulJsonApi */ @@ -521,6 +521,19 @@ protected function published() return $response; } + /** + * @return SS_HTTPResponse + */ + protected function noContent(): SS_HTTPResponse + { + $response = new SS_HTTPResponse(); + $response->setStatusCode(204); + $response->addHeader('Content-Type', 'application/json'); + $response->setBody(''); + + return $response; + } + /** * @return SS_HTTPResponse */ @@ -550,10 +563,10 @@ public function forbiddenError() * @param $messages * @return SS_HTTPResponse */ - public function validationError($messages) + public function validationError($messages, $code = 412) { $response = new SS_HTTPResponse(); - $response->setStatusCode(412); + $response->setStatusCode($code); $response->addHeader('Content-Type', 'application/json'); if (!is_array($messages)) { $messages = [['message' => $messages]]; @@ -579,6 +592,21 @@ protected function created($id) return $response; } + /** + * @return SS_HTTPResponse + */ + public function badRequest($message = "Bad Request") + { + $response = new SS_HTTPResponse(); + $response->setStatusCode(400); + $response->addHeader('Content-Type', 'application/json'); + $response->setBody(json_encode( + ['error' => 'validation', 'messages' => [['message' => $message]]] + )); + + return $response; + } + /** * @return SS_HTTPResponse diff --git a/openstackid/_config/routes.yml b/openstackid/_config/routes.yml index 9379bbb60..4adedd4f0 100644 --- a/openstackid/_config/routes.yml +++ b/openstackid/_config/routes.yml @@ -4,4 +4,5 @@ Name: openstackidroutes Director: rules: 'OpenStackIdAuthenticator': 'OpenStackIdAuthenticator' + 'oidc/session/bootstrap': 'OIDCSessionBootstrapApi' diff --git a/openstackid/code/interfaces/restfull_api/Libs/OAuth2/InvalidGrantTypeException.php b/openstackid/code/interfaces/restfull_api/Libs/OAuth2/InvalidGrantTypeException.php new file mode 100644 index 000000000..90292de60 --- /dev/null +++ b/openstackid/code/interfaces/restfull_api/Libs/OAuth2/InvalidGrantTypeException.php @@ -0,0 +1,31 @@ + self::OAuth2Protocol_ResponseType_Code, + self::OAuth2Protocol_ResponseType_Token => self::OAuth2Protocol_ResponseType_Token + ); + public static $protocol_definition = array( + self::OAuth2Protocol_ResponseType => self::OAuth2Protocol_ResponseType, + self::OAuth2Protocol_ClientId => self::OAuth2Protocol_ClientId, + self::OAuth2Protocol_RedirectUri => self::OAuth2Protocol_RedirectUri, + self::OAuth2Protocol_Scope => self::OAuth2Protocol_Scope, + self::OAuth2Protocol_State => self::OAuth2Protocol_State + ); + +} \ No newline at end of file diff --git a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php new file mode 100644 index 000000000..381ee3d2a --- /dev/null +++ b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php @@ -0,0 +1,383 @@ + 'index', + ]; + + /** + * @var array + */ + private static $allowed_actions = [ + 'index', + ]; + + /** + * @return bool + */ + protected function isApiCall() + { + $request = $this->getRequest(); + if (is_null($request)) + return false; + return true; + } + + /** + * @return bool + */ + protected function authorize() + { + return true; + } + + /** + * @return bool + */ + protected function authenticate() + { + return true; + } + + /** + * Bootstrap OIDC session + * @param SS_HTTPRequest $request + * @return SS_HTTPResponse + */ + public function index(SS_HTTPRequest $request) + { + try { + $response = $this->validateRequestData($request); + if ($response instanceof SS_HTTPResponse) { + return $response; + } + $accessToken = &$response; + + try { + $tokenData = $this->doIntrospectionRequest($accessToken); + + if ($tokenData instanceof SS_HTTPResponse) { + return $tokenData; + } + + if ($tokenData === false) { + SS_Log::log(sprintf("%s token %s introspection returned false", __METHOD__, $accessToken), SS_Log::WARN); + } + + if ( + $tokenData === false or + (is_array($tokenData) and isset($tokenData['error'])) + ) { + $error = 'Invalid or expired access token'; + if (is_array($tokenData) and !empty($tokenData['error'])) { + $error = $tokenData['error']; + } + return $this->validationError([$error], 400); + } + + // at this point we have a valid and active token + // we can create a session for the user + $member = null; + + if (!$member && $tokenData['user_external_id']) { + $member = Member::get()->filter('ID', $tokenData['user_external_id'])->first(); + } + + if (!$member && $tokenData['user_email']) { + $member = Member::get()->filter('Email', $tokenData['user_email'])->first(); + } + + if (!$member) { + return $this->validationError(['User not found'], 404); + } + // log in the user + $member->logIn(); + SS_Log::log( + sprintf('OIDC session bootstrap successful for token: %s', substr($accessToken, 0, 10) . '...'), + SS_Log::DEBUG + ); + + return $this->noContent(); + } catch (Exception $ex) { + return $this->validationError([$ex->getMessage()], 400); + } + + } catch (EntityValidationException $ex1) { + SS_Log::log($ex1, SS_Log::WARN); + return $this->validationError($ex1->getMessages()); + } catch (Exception $ex) { + SS_Log::log($ex, SS_Log::ERR); + return $this->serverError(); + } + } + + /** + * Validate request data + * @param array $data + * @return array|SS_HTTPResponse + */ + private function validateRequestData(SS_HTTPRequest $request) + { + // Check if request method is POST + if (!$request->isPOST()) { + return $this->methodNotAllowed(); + } + // Check Authorization header + $authHeader = $request->getHeader('Authorization'); + $accessToken = str_replace('Bearer ', '', $authHeader ?: ''); + if (empty($authHeader) || strpos($authHeader, 'Bearer ') !== 0) { + return $this->badRequest('Authorization header with Bearer token is required'); + } + + // Check X-CSRF-Token header + $csrfToken = $request->getHeader('X-CSRF-Token') ?: $request->getHeader('X-Csrf-Token'); + if (empty($csrfToken)) { + return $this->badRequest('X-CSRF-Token header is required'); + } + + // Check X-CSRF-Token header value + if ($csrfToken !== $this->getSecurityToken()) { + return $this->badRequest('X-CSRF-Token header is invalid'); + } + + // Check Sec-Fetch-Site header + $SecFetchSite = $request->getHeader('Sec-Fetch-Site') ?: ""; + if ($SecFetchSite !== 'same-origin') { + return $this->badRequest('Sec-Fetch-Site header is required and must be same-origin'); + } + + // Check Referer and Origin headers for same-origin + $origin = $request->getHeader('Origin') ?: ""; + $referer = $request->getHeader('Referer') ?: ""; + + if (empty($referer) || empty($origin)) { + return $this->badRequest('same-origin is in place, Referer and Origin headers must be present'); + } + + // Parse origins from both URLs + $originParsed = parse_url($origin); + $refererParsed = parse_url($referer); + + if (!$originParsed || !$refererParsed) { + return $this->badRequest('Invalid Origin or Referer URL format'); + } + + // Compare scheme, host, and port for same-origin check + $originScheme = $originParsed['scheme'] ?? ''; + $originHost = $originParsed['host'] ?? ''; + $originPort = $originParsed['port'] ?? ($originScheme === 'https' ? 443 : 80); + + $refererScheme = $refererParsed['scheme'] ?? ''; + $refererHost = $refererParsed['host'] ?? ''; + $refererPort = $refererParsed['port'] ?? ($refererScheme === 'https' ? 443 : 80); + + if ($originScheme !== $refererScheme || $originHost !== $refererHost || $originPort !== $refererPort) { + SS_Log::log( + sprintf( + 'Same-origin check failed: Origin=%s (scheme=%s, host=%s, port=%d) vs Referer=%s (scheme=%s, host=%s, port=%d)', + $origin, + $originScheme, + $originHost, + $originPort, + $referer, + $refererScheme, + $refererHost, + $refererPort + ), + SS_Log::DEBUG + ); + return $this->badRequest('same-origin check failed: Origin and Referer headers must have the same origin'); + } + + return $accessToken; + } + + /** + * Make a dummy call to an external server + * @param string $token_value + * @param array $data + * @return array|bool|SS_HTTPResponse + */ + private function doIntrospectionRequest(string $token_value) + { + try { + SS_Log::log(sprintf(__METHOD__ . " token %s", $token_value), SS_Log::DEBUG); + + $client_id = defined('SESSION_CHECKER_OAUTH2_APP_CLIENT_ID') ? SESSION_CHECKER_OAUTH2_APP_CLIENT_ID : ''; + $client_secret = defined('SESSION_CHECKER_OAUTH2_APP_CLIENT_SECRET') ? SESSION_CHECKER_OAUTH2_APP_CLIENT_SECRET : ''; + $auth_server_url = defined('IDP_OPENSTACKID_URL') ? IDP_OPENSTACKID_URL : ''; + $verify = defined('SESSION_CHECKER_OAUTH2_APP_VERIFY_HOST') ? SESSION_CHECKER_OAUTH2_APP_VERIFY_HOST : false; + + if (empty($client_id)) { + return $this->validationError('SESSION_CHECKER_OAUTH2_APP_CLIENT_ID is not configured'); + } + + if (empty($client_secret)) { + return $this->validationError('SESSION_CHECKER_OAUTH2_APP_CLIENT_SECRET is not configured'); + } + + if (empty($auth_server_url)) { + return $this->validationError('IDP_OPENSTACKID_URL is not configured'); + } + + $stack = HandlerStack::create(); + $stack->push(GuzzleRetryMiddleware::factory()); + + $client = new Client([ + 'base_uri' => $auth_server_url, + 'handler' => $stack, + 'verify' => $verify, + 'timeout' => 120, + ]); + + // http://docs.guzzlephp.org/en/stable/request-options.html + $options = [ + 'form_params' => ['token' => $token_value], + 'auth' => [$client_id, $client_secret], + 'http_errors' => true, + 'synchronous' => true, + ]; + $endpoint = "/oauth2/token/introspection"; + SS_Log::log(sprintf(__METHOD__ . " DEBUG options %s", json_encode($options)), SS_Log::DEBUG); + $response = $client->request('POST', $endpoint, $options); + + $content_type = $response->getHeaderLine('content-type'); + $is_json = in_array("application/json", explode(';', $content_type)); + $body = $response->getBody()->getContents(); + if (!$is_json) { + // invalid content type + $status = $response->getStatusCode(); + SS_Log::log(sprintf(__METHOD__ . " status %s content type %s body %s", $status, $content_type, $body), SS_Log::WARN); + return $this->validationError($body, 500); + } + SS_Log::log(sprintf(__METHOD__ . " DEBUG response %s", $body), SS_Log::DEBUG); + + $jsonResponse = json_decode($body, true); + if (!$jsonResponse) { + SS_Log::log(sprintf(__METHOD__ . " invalid JSON response %s", $body), SS_Log::ERR); + return $this->validationError('Invalid JSON response from IDP', 500); + } + + SS_Log::log(sprintf(__METHOD__ . " DEBUG JSON RESPONSE %s", json_encode($jsonResponse, JSON_PRETTY_PRINT)), SS_Log::DEBUG); + $defaults = array_fill_keys(["error", "user_external_id", "user_identifier", "user_email"], null); + $defaults['active'] = false; + $jsonResponse = array_merge($defaults, $jsonResponse); + + $json = [ + 'user_external_id' => $jsonResponse['user_external_id'], + 'user_email' => $jsonResponse['user_email'] ?: $jsonResponse['user_identifier'], + ]; + SS_Log::log(sprintf(__METHOD__ . " DEBUG JSON RETURN %s", json_encode($json, JSON_PRETTY_PRINT)), SS_Log::DEBUG); + return $json; + + } catch (RequestException $ex) { + $this->handleInstropectionException($ex, $token_value); + } catch (Exception $ex) { + SS_Log::log($ex, SS_Log::ERR); + + $data = [ + "error" => "Server Error", + "message" => $ex->getMessage(), + ]; + if (defined('SS_ENVIRONMENT_TYPE') && SS_ENVIRONMENT_TYPE !== 'production') { + $data['trace'] = $ex->getTrace(); + } + $response = new SS_HTTPResponse(); + $response->setStatusCode(500); + $response->addHeader('Content-Type', 'application/json'); + $response->setBody(json_encode($data)); + return $response; + } + + return false; + } + + protected function handleInstropectionException(RequestException &$ex, string $token_value) + { + SS_Log::log($ex, SS_Log::WARN); + $response = $ex->getResponse(); + + if (is_null($response)) + throw new Exception(sprintf('http code %s', $ex->getCode())); + + $content_type = $response->getHeaderLine('content-type'); + $is_json = in_array("application/json", explode(';', $content_type)); + $body = $response->getBody()->getContents(); + $code = $response->getStatusCode(); + + if ($is_json) { + $body = json_decode($body, true); + } + SS_Log::log("Error Response:", SS_Log::WARN, $body); + + $invalid = [ + OAuth2Protocol::OAuth2Protocol_Error_InvalidToken, + OAuth2Protocol::OAuth2Protocol_Error_InvalidGrant + ]; + + if ($code === 400 && $is_json && isset($body['error']) && in_array($body['error'], $invalid)) { + SS_Log::log(sprintf("%s token %s invalid (400 %s)", __METHOD__, $token_value, $body['error']), SS_Log::WARN); + throw new InvalidGrantTypeException($body['error']); + } + + if ($code == 503) { + SS_Log::log(sprintf("%s token %s invalid (503 offline IDP)", __METHOD__, $token_value), SS_Log::WARN); + throw new InvalidGrantTypeException(OAuth2Protocol::OAuth2Protocol_Error_InvalidToken); + } + SS_Log::log(sprintf("%s token %s OAuth2InvalidIntrospectionResponse (%s %s)", __METHOD__, $token_value, $ex->getCode(), $body), SS_Log::WARN); + throw new OAuth2InvalidIntrospectionResponse(sprintf('http code %s - body %s', $code, $body)); + } + + /** + * Return method not allowed response + * @param string $message + * @return SS_HTTPResponse + */ + protected function methodNotAllowed() + { + return parent::methodNotAllowed() + ->setBody(json_encode("Only POST requests are allowed")) + ->addHeader('Allow', 'POST'); + } + + protected function getSecurityToken() + { + return SecurityToken::inst() ? SecurityToken::inst()->getValue() : null; + } +} \ No newline at end of file diff --git a/sample._ss_environment.php b/sample._ss_environment.php index 7cc1cf59d..3b6015650 100644 --- a/sample._ss_environment.php +++ b/sample._ss_environment.php @@ -1,12 +1,12 @@ + +<% end_if %> \ No newline at end of file diff --git a/themes/openstack/templates/Page.ss b/themes/openstack/templates/Page.ss index 3c16e53b8..f82667ae7 100644 --- a/themes/openstack/templates/Page.ss +++ b/themes/openstack/templates/Page.ss @@ -32,6 +32,7 @@ <% include Page_GoogleAnalytics %> <% include Page_MicrosoftAdvertising %> <% include Page_LinkedinInsightTracker %> + <% include Page_LoggedInVerification %>