diff --git a/Core/API/SsoAPI.class.php b/Core/API/SsoAPI.class.php index 97d8617..2c4f775 100644 --- a/Core/API/SsoAPI.class.php +++ b/Core/API/SsoAPI.class.php @@ -22,7 +22,10 @@ namespace Core\API { return $this->createError("This user is currently disabled. Contact the server administrator, if you believe this is a mistake."); } else if ($user->getSsoProvider()?->getIdentifier() !== $provider->getIdentifier()) { return $this->createError("An existing user is not managed by the used identity provider"); - } else if (!$this->createSession($user)) { + } + + // Create the session and log them in + if (!$this->createSession($user)) { return false; } @@ -33,7 +36,7 @@ namespace Core\API { return true; } - protected function validateRedirectURL(string $url): bool { + protected function validateRedirectURL(?string $url): bool { // allow only relative paths return empty($url) || startsWith($url, "/"); } @@ -64,13 +67,27 @@ namespace Core\API\Sso { $sql = $this->context->getSQL(); $query = SsoProvider::createBuilder($sql, false); + $user = $this->context->getUser(); + $canEdit = false; - if (!$this->context->getUser()) { + if (!$user) { + // only show active providers, when not logged in $query->whereTrue("active"); + } else { + $req = new \Core\API\Permission\Check($this->context); + $canEdit = $req->execute(["method" => "sso/editProvider"]); } + // show all properties, if a user is allowed to edit them $providers = SsoProvider::findBy($query); - $this->result["providers"] = SsoProvider::toJsonArray($providers); + $properties = $canEdit ? null : [ + "id", + "identifier", + "name", + "protocol" + ]; + + $this->result["providers"] = SsoProvider::toJsonArray($providers, $properties); return true; } @@ -214,12 +231,7 @@ namespace Core\API\Sso { protected function _execute(): bool { - - $samlResponseEncoded = $this->getParam("SAMLResponse"); - if (($samlResponse = @gzinflate(base64_decode($samlResponseEncoded))) === false) { - $samlResponse = base64_decode($samlResponseEncoded); - } - + $samlResponse = base64_decode($this->getParam("SAMLResponse")); $parsedResponse = SAMLResponse::parseResponse($this->context, $samlResponse); if (!$parsedResponse->wasSuccessful()) { return $this->createError("Error parsing SAMLResponse: " . $parsedResponse->getError()); diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index b87b959..d2daa8b 100644 --- a/Core/API/UserAPI.class.php +++ b/Core/API/UserAPI.class.php @@ -663,7 +663,7 @@ namespace Core\API\User { $sql = $this->context->getSQL(); $user = User::findBy(User::createBuilder($sql, true) ->where(new Compare("User.name", $username), new Compare("User.email", $username)) - ->whereEq("User.sso_provider", NULL) + ->whereEq("User.sso_provider_id", NULL) ->fetchEntities()); if ($user !== false) { diff --git a/Core/Objects/DatabaseEntity/Controller/DatabaseEntity.class.php b/Core/Objects/DatabaseEntity/Controller/DatabaseEntity.class.php index dbf7a91..89d51ec 100644 --- a/Core/Objects/DatabaseEntity/Controller/DatabaseEntity.class.php +++ b/Core/Objects/DatabaseEntity/Controller/DatabaseEntity.class.php @@ -168,7 +168,7 @@ abstract class DatabaseEntity implements ArrayAccess, JsonSerializable { return $dbQuery->execute(); } - public static function findAll(SQL $sql, ?Condition $condition = null): ?array { + public static function findAll(SQL $sql, ?Condition $condition = null): array|bool|null { $query = self::createBuilder($sql, false); if ($condition) { diff --git a/Core/Objects/DatabaseEntity/SsoProvider.class.php b/Core/Objects/DatabaseEntity/SsoProvider.class.php index 932b976..9b51c87 100644 --- a/Core/Objects/DatabaseEntity/SsoProvider.class.php +++ b/Core/Objects/DatabaseEntity/SsoProvider.class.php @@ -2,8 +2,13 @@ namespace Core\Objects\DatabaseEntity; +use Core\Driver\Logger\Logger; +use Core\Driver\SQL\Column\Column; +use Core\Driver\SQL\Condition\CondIn; use Core\Objects\Context; +use Core\Objects\DatabaseEntity\Attribute\DefaultValue; use Core\Objects\DatabaseEntity\Attribute\ExtendingEnum; +use Core\Objects\DatabaseEntity\Attribute\Json; use Core\Objects\DatabaseEntity\Attribute\MaxLength; use Core\Objects\DatabaseEntity\Attribute\Unique; use Core\Objects\DatabaseEntity\Controller\DatabaseEntity; @@ -31,8 +36,18 @@ abstract class SsoProvider extends DatabaseEntity { #[ExtendingEnum(self::PROTOCOLS)] private string $protocol; + #[MaxLength(256)] protected string $ssoUrl; + #[MaxLength(128)] + protected string $clientId; + + #[Json] + #[DefaultValue('{}')] + protected array $groupMapping; + + protected string $certificate; + public function __construct(string $protocol, ?int $id = null) { parent::__construct($id); $this->protocol = $protocol; @@ -71,5 +86,42 @@ abstract class SsoProvider extends DatabaseEntity { return $this->identifier; } + public function getGroupMapping(): array { + return $this->groupMapping; + } + + public function createUser(Context $context, string $email, array $groupNames) : User { + $sql = $context->getSQL(); + $loggerName = "SSO-" . strtoupper($this->protocol); + $logger = new Logger($loggerName, $sql); + + if (empty($groupNames)) { + $groups = []; + } else { + $groups = Group::findAll($sql, new CondIn(new Column("name"), $groupNames)); + if ($groups === false) { + throw new \Exception("Error fetching available groups: " . $sql->getLastError()); + } else if (count($groups) !== count($groupNames)) { + $availableGroups = array_map(function (Group $group) { + return $group->getName(); + }, $groups); + $nonExistentGroups = array_diff($groupNames, $availableGroups); + $logger->warning("Could not resolve group names: " . implode(', ', $nonExistentGroups)); + } + } + + // TODO: create a possibility to map attribute values to user properties + $user = new User(); + $user->email = $email; + $user->name = $email; + $user->password = null; + $user->fullName = ""; + $user->ssoProvider = $this; + $user->confirmed = true; + $user->active = true; + $user->groups = $groups; + return $user; + } + public abstract function login(Context $context, ?string $redirectUrl); } \ No newline at end of file diff --git a/Core/Objects/SSO/SAMLResponse.class.php b/Core/Objects/SSO/SAMLResponse.class.php index 590a0fd..2270710 100644 --- a/Core/Objects/SSO/SAMLResponse.class.php +++ b/Core/Objects/SSO/SAMLResponse.class.php @@ -2,7 +2,6 @@ namespace Core\Objects\SSO; -use Core\Driver\Logger\Logger; use Core\Driver\SQL\Condition\Compare; use Core\Objects\Context; use Core\Objects\DatabaseEntity\SsoProvider; @@ -36,14 +35,59 @@ class SAMLResponse { return $response; } + private static function findSignatureNode(\DOMNode $node) : ?\DOMNode { + foreach ($node->childNodes as $child) { + if ($child->nodeName === 'dsig:Signature') { + return $child; + } + } + + return null; + } + + private static function parseSignatureAlgorithm($name) : ?int { + return match ($name) { + 'http://www.w3.org/2000/09/xmldsig#sha1' => OPENSSL_ALGO_SHA1, + 'http://www.w3.org/2001/04/xmlenc#sha256' => OPENSSL_ALGO_SHA256, + 'http://www.w3.org/2001/04/xmldsig-more#sha384' => OPENSSL_ALGO_SHA384, + 'http://www.w3.org/2001/04/xmlenc#sha512' => OPENSSL_ALGO_SHA512, + 'http://www.w3.org/2001/04/xmlenc#ripemd160' => OPENSSL_ALGO_RMD160, + 'http://www.w3.org/2001/04/xmldsig-more#md5' => OPENSSL_ALGO_MD5, + default => throw new \Exception("Unsupported digest algorithm: $name"), + }; + } + + private static function verifyNodeSignature(SsoProvider $provider, \DOMNode $signatureNode) { + $signedInfoNode = $signatureNode->getElementsByTagName('SignedInfo')->item(0); + if (!$signedInfoNode) { + throw new \Exception("SignedInfo not found in the Signature element."); + } + + $signedInfo = $signedInfoNode->C14N(true, false); + $signatureValueNode = $signatureNode->getElementsByTagName('SignatureValue')->item(0); + if (!$signatureValueNode) { + throw new \Exception("SignatureValue not found in the Signature element."); + } + + $digestMethodNode = $signatureNode->getElementsByTagName('DigestMethod')->item(0); + if (!$digestMethodNode) { + throw new \Exception("DigestMethod not found in the Signature element."); + } + + $algorithm = self::parseSignatureAlgorithm($digestMethodNode->getAttribute("Algorithm")); + $signatureValue = base64_decode($signatureValueNode->nodeValue); + if (!$provider->validateSignature($signedInfo, $signatureValue, $algorithm)) { + throw new \Exception("Invalid Signature."); + } + } + public static function parseResponse(Context $context, string $response) : SAMLResponse { $sql = $context->getSQL(); - $logger = new Logger("SAML", $sql); $xml = new DOMDocument(); $xml->loadXML($response); if ($xml->documentElement->nodeName !== "samlp:Response") { - return self::createError(null, "Invalid root node"); + return self::createError(null, "Invalid root node, expected: 'samlp:Response'"); } $requestId = $xml->documentElement->getAttribute("InResponseTo"); @@ -65,76 +109,74 @@ class SAMLResponse { } else if (!$ssoRequest->isValid()) { return self::createError($ssoRequest, "Authentication request expired"); } else { - $ssoRequest->invalidate($sql); + // $ssoRequest->invalidate($sql); } - $provider = $ssoRequest->getProvider(); - if (!($provider instanceof SSOProviderSAML)) { - return self::createError(null, "Authentication request was not a SAML request"); + try { + $provider = $ssoRequest->getProvider(); + if (!($provider instanceof SSOProviderSAML)) { + return self::createError($ssoRequest, "Authentication request was not a SAML request"); + } + + // Validate XML and extract user info + if (!$xml->getElementsByTagName("Assertion")->length) { + return self::createError($ssoRequest, "Assertion tag missing"); + } + + $assertion = $xml->getElementsByTagName('Assertion')->item(0); + + //// <-- Signature Validation + $rootSignature = self::findSignatureNode($xml->documentElement); + $assertionSignature = self::findSignatureNode($assertion); + if ($rootSignature === null && $assertionSignature === null) { + return self::createError($ssoRequest, "Neither a document nor an assertion signature was present."); + } + + if ($rootSignature !== null) { + self::verifyNodeSignature($provider, $rootSignature); + } + + if ($assertionSignature !== null) { + self::verifyNodeSignature($provider, $assertionSignature); + } + //// Signature Validation --> + + // Check status code + $statusCode = $xml->getElementsByTagName('StatusCode')->item(0); + if ($statusCode->getAttribute("Value") !== "urn:oasis:names:tc:SAML:2.0:status:Success") { + return self::createError(null, "StatusCode was not successful"); + } + + $groupMapping = $provider->getGroupMapping(); + $email = $xml->getElementsByTagName('NameID')->item(0)->nodeValue; + $attributes = []; + $groupNames = []; + foreach ($xml->getElementsByTagName('Attribute') as $attribute) { + $name = $attribute->getAttribute('Name'); + $value = $attribute->getElementsByTagName('AttributeValue')->item(0)->nodeValue; + if ($name === "Role") { + if (isset($groupMapping[$value])) { + $groupNames[] = $groupMapping[$value]; + } + } else { + $attributes[$name] = $value; + } + } + + $user = User::findBy(User::createBuilder($context->getSQL(), true) + ->where(new Compare("User.email", $email), new Compare("User.name", $email)) + ->fetchEntities()); + + if ($user === false) { + return self::createError($ssoRequest, "Error fetching user: " . $sql->getLastError()); + } else if ($user === null) { + $user = $ssoRequest->getProvider()->createUser($context, $email, $groupNames); + } + + return self::createSuccess($ssoRequest, $user); + } catch (\Exception $ex) { + return self::createError($ssoRequest, $ex->getMessage()); } - - // Validate XML and extract user info - if (!$xml->getElementsByTagName("Assertion")->length) { - return self::createError(null, "Assertion tag missing"); - } - - - $assertion = $xml->getElementsByTagName('Assertion')->item(0); - if (!$assertion->getElementsByTagName("Signature")->length) { - return self::createError(null, "Signature tag missing"); - } - - $signature = $assertion->getElementsByTagName("Signature")->item(0); - // TODO: parse and validate signature - - $statusCode = $xml->getElementsByTagName('StatusCode')->item(0); - if ($statusCode->getAttribute("Value") !== "urn:oasis:names:tc:SAML:2.0:status:Success") { - return self::createError(null, "StatusCode was not successful"); - } - - $issuer = $xml->getElementsByTagName('Issuer')->item(0)->nodeValue; - // TODO: validate issuer - - // TODO: create a possibility to map attribute values to user properties - $username = $xml->getElementsByTagName('NameID')->item(0)->nodeValue; - $attributes = []; - foreach ($xml->getElementsByTagName('Attribute') as $attribute) { - $name = $attribute->getAttribute('Name'); - $value = $attribute->getElementsByTagName('AttributeValue')->item(0)->nodeValue; - $attributes[$name] = $value; - } - - $email = $attributes["email"]; - $fullName = []; - - if (isset($attributes["firstName"])) { - $fullName[] = $attributes["firstName"]; - } - - if (isset($attributes["lastName"])) { - $fullName[] = $attributes["lastName"]; - } - - $fullName = implode(" ", $fullName); - $user = User::findBy(User::createBuilder($context->getSQL(), true) - ->where(new Compare("email", $email), new Compare("name", $username)) - ->fetchEntities()); - - if ($user === false) { - return self::createError($ssoRequest, "Error fetching user: " . $sql->getLastError()); - } else if ($user === null) { - $user = new User(); - $user->fullName = $fullName; - $user->email = $email; - $user->name = $username; - $user->password = null; - $user->ssoProvider = $ssoRequest->getProvider(); - $user->confirmed = true; - $user->active = true; - $user->groups = []; // TODO: create a possibility to set auto-groups for SSO registered users - } - - return self::createSuccess($ssoRequest, $user); } public function wasSuccessful() : bool { diff --git a/Core/Objects/SSO/SSOProviderSAML.class.php b/Core/Objects/SSO/SSOProviderSAML.class.php index 04455f3..6791f06 100644 --- a/Core/Objects/SSO/SSOProviderSAML.class.php +++ b/Core/Objects/SSO/SSOProviderSAML.class.php @@ -34,16 +34,42 @@ class SSOProviderSAML extends SSOProvider { "Destination" => $this->ssoUrl, "ProtocolBinding" => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", "AssertionConsumerServiceURL" => $acsUrl - ], html_tag("saml:Issuer", [], $baseUrl), false); + ], html_tag("saml:Issuer", [], $this->clientId), false); - $samlRequest = base64_encode(gzdeflate($samlp)); - $samlUrl = $this->buildUrl($this->ssoUrl, [ "SAMLRequest" => $samlRequest ]); + $samlRequest = base64_encode($samlp); + $req = new \Core\API\Template\Render($context); + $success = $req->execute([ + "file" => "sso.twig", + "parameters" => [ + "sso" => [ + "url" => $this->ssoUrl, + "data" => [ + "SAMLRequest" => $samlRequest + ] + ] + ] + ]); - if ($samlUrl === null) { - throw new \Exception("SSO Provider has an invalid URL configured"); + if (!$success) { + throw new \Exception("Could not redirect: " . $req->getLastError()); } - $context->router->redirect(302, $samlUrl); - die(); + die($req->getResult()["html"]); + } + + public function validateSignature(string $what, string $signature, int $algorithm) : bool { + $publicKey = openssl_pkey_get_public($this->certificate); + if (!$publicKey) { + throw new \Exception("Failed to load certificate: " . openssl_error_string()); + } + + $result = openssl_verify($what, $signature, $publicKey, $algorithm); + if ($result === 1) { + return true; + } else if ($result === 0) { + return false; + } else { + throw new \Exception("Failed to validate signature: " . openssl_error_string()); + } } } \ No newline at end of file diff --git a/Core/Templates/sso.twig b/Core/Templates/sso.twig new file mode 100644 index 0000000..be6460b --- /dev/null +++ b/Core/Templates/sso.twig @@ -0,0 +1,19 @@ + + +
+ + + + + + + \ No newline at end of file