SAML role-group mapping, Signature validation, Bugfixes

This commit is contained in:
Roman 2024-12-31 13:53:57 +01:00
parent 20e464776c
commit ee9ab8b7f6
7 changed files with 240 additions and 89 deletions

@ -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."); 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()) { } else if ($user->getSsoProvider()?->getIdentifier() !== $provider->getIdentifier()) {
return $this->createError("An existing user is not managed by the used identity provider"); 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; return false;
} }
@ -33,7 +36,7 @@ namespace Core\API {
return true; return true;
} }
protected function validateRedirectURL(string $url): bool { protected function validateRedirectURL(?string $url): bool {
// allow only relative paths // allow only relative paths
return empty($url) || startsWith($url, "/"); return empty($url) || startsWith($url, "/");
} }
@ -64,13 +67,27 @@ namespace Core\API\Sso {
$sql = $this->context->getSQL(); $sql = $this->context->getSQL();
$query = SsoProvider::createBuilder($sql, false); $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"); $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); $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; return true;
} }
@ -214,12 +231,7 @@ namespace Core\API\Sso {
protected function _execute(): bool { protected function _execute(): bool {
$samlResponse = base64_decode($this->getParam("SAMLResponse"));
$samlResponseEncoded = $this->getParam("SAMLResponse");
if (($samlResponse = @gzinflate(base64_decode($samlResponseEncoded))) === false) {
$samlResponse = base64_decode($samlResponseEncoded);
}
$parsedResponse = SAMLResponse::parseResponse($this->context, $samlResponse); $parsedResponse = SAMLResponse::parseResponse($this->context, $samlResponse);
if (!$parsedResponse->wasSuccessful()) { if (!$parsedResponse->wasSuccessful()) {
return $this->createError("Error parsing SAMLResponse: " . $parsedResponse->getError()); return $this->createError("Error parsing SAMLResponse: " . $parsedResponse->getError());

@ -663,7 +663,7 @@ namespace Core\API\User {
$sql = $this->context->getSQL(); $sql = $this->context->getSQL();
$user = User::findBy(User::createBuilder($sql, true) $user = User::findBy(User::createBuilder($sql, true)
->where(new Compare("User.name", $username), new Compare("User.email", $username)) ->where(new Compare("User.name", $username), new Compare("User.email", $username))
->whereEq("User.sso_provider", NULL) ->whereEq("User.sso_provider_id", NULL)
->fetchEntities()); ->fetchEntities());
if ($user !== false) { if ($user !== false) {

@ -168,7 +168,7 @@ abstract class DatabaseEntity implements ArrayAccess, JsonSerializable {
return $dbQuery->execute(); 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); $query = self::createBuilder($sql, false);
if ($condition) { if ($condition) {

@ -2,8 +2,13 @@
namespace Core\Objects\DatabaseEntity; 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\Context;
use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
use Core\Objects\DatabaseEntity\Attribute\ExtendingEnum; use Core\Objects\DatabaseEntity\Attribute\ExtendingEnum;
use Core\Objects\DatabaseEntity\Attribute\Json;
use Core\Objects\DatabaseEntity\Attribute\MaxLength; use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Attribute\Unique; use Core\Objects\DatabaseEntity\Attribute\Unique;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity; use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
@ -31,8 +36,18 @@ abstract class SsoProvider extends DatabaseEntity {
#[ExtendingEnum(self::PROTOCOLS)] #[ExtendingEnum(self::PROTOCOLS)]
private string $protocol; private string $protocol;
#[MaxLength(256)]
protected string $ssoUrl; protected string $ssoUrl;
#[MaxLength(128)]
protected string $clientId;
#[Json]
#[DefaultValue('{}')]
protected array $groupMapping;
protected string $certificate;
public function __construct(string $protocol, ?int $id = null) { public function __construct(string $protocol, ?int $id = null) {
parent::__construct($id); parent::__construct($id);
$this->protocol = $protocol; $this->protocol = $protocol;
@ -71,5 +86,42 @@ abstract class SsoProvider extends DatabaseEntity {
return $this->identifier; 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); public abstract function login(Context $context, ?string $redirectUrl);
} }

@ -2,7 +2,6 @@
namespace Core\Objects\SSO; namespace Core\Objects\SSO;
use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\Compare;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\SsoProvider; use Core\Objects\DatabaseEntity\SsoProvider;
@ -36,14 +35,59 @@ class SAMLResponse {
return $response; 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 { public static function parseResponse(Context $context, string $response) : SAMLResponse {
$sql = $context->getSQL(); $sql = $context->getSQL();
$logger = new Logger("SAML", $sql);
$xml = new DOMDocument(); $xml = new DOMDocument();
$xml->loadXML($response); $xml->loadXML($response);
if ($xml->documentElement->nodeName !== "samlp: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"); $requestId = $xml->documentElement->getAttribute("InResponseTo");
@ -65,76 +109,74 @@ class SAMLResponse {
} else if (!$ssoRequest->isValid()) { } else if (!$ssoRequest->isValid()) {
return self::createError($ssoRequest, "Authentication request expired"); return self::createError($ssoRequest, "Authentication request expired");
} else { } else {
$ssoRequest->invalidate($sql); // $ssoRequest->invalidate($sql);
} }
try {
$provider = $ssoRequest->getProvider(); $provider = $ssoRequest->getProvider();
if (!($provider instanceof SSOProviderSAML)) { if (!($provider instanceof SSOProviderSAML)) {
return self::createError(null, "Authentication request was not a SAML request"); return self::createError($ssoRequest, "Authentication request was not a SAML request");
} }
// Validate XML and extract user info // Validate XML and extract user info
if (!$xml->getElementsByTagName("Assertion")->length) { if (!$xml->getElementsByTagName("Assertion")->length) {
return self::createError(null, "Assertion tag missing"); return self::createError($ssoRequest, "Assertion tag missing");
} }
$assertion = $xml->getElementsByTagName('Assertion')->item(0); $assertion = $xml->getElementsByTagName('Assertion')->item(0);
if (!$assertion->getElementsByTagName("Signature")->length) {
return self::createError(null, "Signature tag missing"); //// <-- 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.");
} }
$signature = $assertion->getElementsByTagName("Signature")->item(0); if ($rootSignature !== null) {
// TODO: parse and validate signature self::verifyNodeSignature($provider, $rootSignature);
}
if ($assertionSignature !== null) {
self::verifyNodeSignature($provider, $assertionSignature);
}
//// Signature Validation -->
// Check status code
$statusCode = $xml->getElementsByTagName('StatusCode')->item(0); $statusCode = $xml->getElementsByTagName('StatusCode')->item(0);
if ($statusCode->getAttribute("Value") !== "urn:oasis:names:tc:SAML:2.0:status:Success") { if ($statusCode->getAttribute("Value") !== "urn:oasis:names:tc:SAML:2.0:status:Success") {
return self::createError(null, "StatusCode was not successful"); return self::createError(null, "StatusCode was not successful");
} }
$issuer = $xml->getElementsByTagName('Issuer')->item(0)->nodeValue; $groupMapping = $provider->getGroupMapping();
// TODO: validate issuer $email = $xml->getElementsByTagName('NameID')->item(0)->nodeValue;
// TODO: create a possibility to map attribute values to user properties
$username = $xml->getElementsByTagName('NameID')->item(0)->nodeValue;
$attributes = []; $attributes = [];
$groupNames = [];
foreach ($xml->getElementsByTagName('Attribute') as $attribute) { foreach ($xml->getElementsByTagName('Attribute') as $attribute) {
$name = $attribute->getAttribute('Name'); $name = $attribute->getAttribute('Name');
$value = $attribute->getElementsByTagName('AttributeValue')->item(0)->nodeValue; $value = $attribute->getElementsByTagName('AttributeValue')->item(0)->nodeValue;
if ($name === "Role") {
if (isset($groupMapping[$value])) {
$groupNames[] = $groupMapping[$value];
}
} else {
$attributes[$name] = $value; $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) $user = User::findBy(User::createBuilder($context->getSQL(), true)
->where(new Compare("email", $email), new Compare("name", $username)) ->where(new Compare("User.email", $email), new Compare("User.name", $email))
->fetchEntities()); ->fetchEntities());
if ($user === false) { if ($user === false) {
return self::createError($ssoRequest, "Error fetching user: " . $sql->getLastError()); return self::createError($ssoRequest, "Error fetching user: " . $sql->getLastError());
} else if ($user === null) { } else if ($user === null) {
$user = new User(); $user = $ssoRequest->getProvider()->createUser($context, $email, $groupNames);
$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); return self::createSuccess($ssoRequest, $user);
} catch (\Exception $ex) {
return self::createError($ssoRequest, $ex->getMessage());
}
} }
public function wasSuccessful() : bool { public function wasSuccessful() : bool {

@ -34,16 +34,42 @@ class SSOProviderSAML extends SSOProvider {
"Destination" => $this->ssoUrl, "Destination" => $this->ssoUrl,
"ProtocolBinding" => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", "ProtocolBinding" => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
"AssertionConsumerServiceURL" => $acsUrl "AssertionConsumerServiceURL" => $acsUrl
], html_tag("saml:Issuer", [], $baseUrl), false); ], html_tag("saml:Issuer", [], $this->clientId), false);
$samlRequest = base64_encode(gzdeflate($samlp)); $samlRequest = base64_encode($samlp);
$samlUrl = $this->buildUrl($this->ssoUrl, [ "SAMLRequest" => $samlRequest ]); $req = new \Core\API\Template\Render($context);
$success = $req->execute([
"file" => "sso.twig",
"parameters" => [
"sso" => [
"url" => $this->ssoUrl,
"data" => [
"SAMLRequest" => $samlRequest
]
]
]
]);
if ($samlUrl === null) { if (!$success) {
throw new \Exception("SSO Provider has an invalid URL configured"); throw new \Exception("Could not redirect: " . $req->getLastError());
} }
$context->router->redirect(302, $samlUrl); die($req->getResult()["html"]);
die(); }
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());
}
} }
} }

19
Core/Templates/sso.twig Normal file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="{{ user.lang }}">
<head>
<meta charset="utf-8" />
<script>
window.onload = () => {
document.forms["sso"].submit();
};
</script>
</head>
<body>
<form method="POST" action="{{ sso.url }}" id="sso">
{% for key, value in sso.data %}
<input type="hidden" name="{{ key }}" value="{{ value }}" />
{% endfor %}
You will be automatically redirected. If that doesn't work, click <button type="submit">here</button>.
</form>
</body>
</html>