cas-8.x-1.x-dev/src/Service/CasProxyHelper.php
src/Service/CasProxyHelper.php
<?php
declare(strict_types=1);
namespace Drupal\cas\Service;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Database\Connection;
use Drupal\cas\CasServerConfig;
use Drupal\cas\Exception\CasProxyException;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Cookie\CookieJarInterface;
use GuzzleHttp\Exception\ClientException;
use Psr\Log\LogLevel;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
/**
* Default implementation of 'cas.proxy_helper' service.
*/
class CasProxyHelper {
public function __construct(
protected readonly ClientInterface $httpClient,
protected readonly CasHelper $casHelper,
protected readonly SessionInterface $session,
protected readonly Connection $connection,
) {}
/**
* Format a CAS Server proxy ticket request URL.
*
* @param string $target_service
* The service to be proxied.
*
* @return string
* The fully formatted URL.
*/
private function getServerProxyUrl(string $target_service): string {
// @todo Consider allowing the config to be altered.
$casServerConfig = CasServerConfig::createFromModuleConfig($this->casHelper->getCasSettings());
$url = $casServerConfig->getServerBaseUrl() . 'proxy';
$params = [];
$params['pgt'] = $this->session->get('cas_pgt');
$params['targetService'] = $target_service;
return $url . '?' . UrlHelper::buildQuery($params);
}
/**
* Get a proxy ticket using a proxy granting ticket.
*
* @param string $target_service
* The service to be proxied.
*
* @return string
* The proxy ticket returned by the CAS server.
*
* @throws \Drupal\cas\Exception\CasProxyException
* Thrown if there was a problem communicating with the CAS server.
*/
public function getProxyTicket(string $target_service): string {
if (!$this->casHelper->getCasSetting('proxy.initialize') || !$this->session->has('cas_pgt')) {
// We can't perform proxy authentication in this state.
throw new CasProxyException("Session state not sufficient for proxying.");
}
// Make request to CAS server to retrieve a proxy ticket for this service.
$cas_url = $this->getServerProxyUrl($target_service);
try {
$this->casHelper->log(LogLevel::DEBUG, "Retrieving proxy ticket from %cas_url", ['%cas_url' => $cas_url]);
// @todo Consider allowing the config to be altered.
$casServerConfig = CasServerConfig::createFromModuleConfig($this->casHelper->getCasSettings());
$casServerConnectionOptions = $casServerConfig->getCasServerGuzzleConnectionOptions();
$response = $this->httpClient->get($cas_url, $casServerConnectionOptions);
}
catch (ClientException $e) {
throw new CasProxyException($e->getMessage());
}
$proxy_ticket = $this->parseProxyTicket($response->getBody());
$this->casHelper->log(LogLevel::DEBUG, "Extracted proxy ticket %ticket", ['%ticket' => $proxy_ticket]);
return $proxy_ticket;
}
/**
* Proxy authenticates to a target service.
*
* Returns cookies from the proxied service in a CookieJar object for use when
* later accessing resources.
*
* @param string $target_service
* The service to be proxied.
*
* @return \GuzzleHttp\Cookie\CookieJarInterface
* A CookieJar object (array storage) containing cookies from the proxied
* service.
*
* @throws \Drupal\cas\Exception\CasProxyException|\GuzzleHttp\Exception\GuzzleException
* Thrown if there was a problem communicating with the CAS server
* or if there was an invalid use of session data.
*/
public function proxyAuthenticate(string $target_service): CookieJarInterface {
$cas_proxy_helper = $this->session->get('cas_proxy_helper');
// Check to see if we have proxied this application already.
if (isset($cas_proxy_helper[$target_service])) {
$cookies = [];
foreach ($cas_proxy_helper[$target_service] as $cookie) {
$cookies[$cookie['Name']] = $cookie['Value'];
}
$domain = $cookie['Domain'];
$jar = CookieJar::fromArray($cookies, $domain);
$this->casHelper->log(LogLevel::DEBUG, "%target_service already proxied. Returning information from session.", ['%target_service' => $target_service]);
return $jar;
}
// Get proxy ticket and use it to initialize params.
$params = [
'ticket' => $this->getProxyTicket($target_service),
];
// Make request to target service with our new proxy ticket.
// The target service will validate this ticket against the CAS server
// and set a cookie that grants authentication for further resource calls.
$service_url = $target_service . "?" . UrlHelper::buildQuery($params);
$cookie_jar = new CookieJar();
try {
$this->casHelper->log(LogLevel::DEBUG, "Contacting service: %service", ['%service' => $service_url]);
$this->httpClient->get($service_url, [
'cookies' => $cookie_jar,
'timeout' => $this->casHelper->getCasSetting('advanced.connection_timeout'),
]);
}
catch (ClientException $e) {
throw new CasProxyException($e->getMessage());
}
// Store in session storage for later reuse.
$cas_proxy_helper[$target_service] = $cookie_jar->toArray();
$this->session->set('cas_proxy_helper', $cas_proxy_helper);
$this->casHelper->log(LogLevel::DEBUG, "Stored cookies from %service in session.", ['%service' => $target_service]);
return $cookie_jar;
}
/**
* Parse proxy ticket from CAS Server response.
*
* @param string $xml
* XML response from CAS Server.
*
* @return mixed
* A proxy ticket to be used with the target service, FALSE on failure.
*
* @throws \Drupal\cas\Exception\CasProxyException
* Thrown if there was a problem parsing the proxy validation response.
*/
private function parseProxyTicket(string $xml): mixed {
$dom = new \DOMDocument();
$dom->preserveWhiteSpace = FALSE;
$dom->encoding = "utf-8";
if (@$dom->loadXML($xml) === FALSE) {
throw new CasProxyException("CAS Server returned non-XML response.");
}
$failure_elements = $dom->getElementsByTagName("proxyFailure");
if ($failure_elements->length > 0) {
// Something went wrong with proxy ticket validation.
throw new CasProxyException("CAS Server rejected proxy request.");
}
$success_elements = $dom->getElementsByTagName("proxySuccess");
if ($success_elements->length === 0) {
// Malformed response from CAS Server.
throw new CasProxyException("CAS Server returned malformed response.");
}
$success_element = $success_elements->item(0);
$proxy_ticket = $success_element->getElementsByTagName("proxyTicket");
if ($proxy_ticket->length === 0) {
// Malformed ticket.
throw new CasProxyException("CAS Server provided invalid or malformed ticket.");
}
return $proxy_ticket->item(0)->nodeValue;
}
/**
* Store the PGT in the user session.
*
* @param string $pgt_iou
* A pgtIou to identify the PGT.
*/
public function storePgtSession(string $pgt_iou): void {
$pgt = $this->connection->select('cas_pgt_storage', 'c')
->fields('c', ['pgt'])
->condition('pgt_iou', $pgt_iou)
->execute()
->fetch();
if ($pgt) {
$this->session->set('cas_pgt', $pgt->pgt);
}
// Now that we have the pgt in the session,
// we can delete the database mapping.
$this->connection->delete('cas_pgt_storage')
->condition('pgt_iou', $pgt_iou)
->execute();
}
}
