diff --git a/Core/API/ApiKeyAPI.class.php b/Core/API/ApiKeyAPI.class.php index 5510394..4707181 100644 --- a/Core/API/ApiKeyAPI.class.php +++ b/Core/API/ApiKeyAPI.class.php @@ -9,7 +9,7 @@ namespace Core\API { public function __construct(Context $context, bool $externalCall = false, array $params = array()) { parent::__construct($context, $externalCall, $params); - $this->loginRequired = true; + $this->loginRequirements = Request::LOGGED_IN; } protected function fetchAPIKey(int $apiKeyId): ApiKey|bool { diff --git a/Core/API/GpgKeyAPI.class.php b/Core/API/GpgKeyAPI.class.php index 9a596e6..c6091cf 100644 --- a/Core/API/GpgKeyAPI.class.php +++ b/Core/API/GpgKeyAPI.class.php @@ -7,7 +7,7 @@ namespace Core\API { abstract class GpgKeyAPI extends \Core\API\Request { public function __construct(Context $context, bool $externalCall = false, array $params = array()) { parent::__construct($context, $externalCall, $params); - $this->loginRequired = true; + $this->loginRequirements = Request::LOGGED_IN; } } } @@ -34,7 +34,6 @@ namespace Core\API\GpgKey { parent::__construct($context, $externalCall, [ "publicKey" => new StringType("publicKey") ]); - $this->loginRequired = true; $this->forbidMethod("GET"); } @@ -125,7 +124,6 @@ namespace Core\API\GpgKey { parent::__construct($context, $externalCall, array( "password" => new StringType("password") )); - $this->loginRequired = true; $this->forbidMethod("GET"); } @@ -159,7 +157,6 @@ namespace Core\API\GpgKey { parent::__construct($context, $externalCall, [ "token" => new StringType("token", 36) ]); - $this->loginRequired = true; } public function _execute(): bool { @@ -209,7 +206,6 @@ namespace Core\API\GpgKey { "id" => new Parameter("id", Parameter::TYPE_INT, true, null), "format" => new StringType("format", 16, true, "ascii") )); - $this->loginRequired = true; $this->csrfTokenRequired = false; } diff --git a/Core/API/LanguageAPI.class.php b/Core/API/LanguageAPI.class.php index bdb3644..0ad7a22 100644 --- a/Core/API/LanguageAPI.class.php +++ b/Core/API/LanguageAPI.class.php @@ -18,6 +18,7 @@ namespace Core\API\Language { use Core\API\Parameter\Parameter; use Core\API\Parameter\RegexType; use Core\API\Parameter\StringType; + use Core\API\Request; use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\CondOr; use Core\Objects\Context; @@ -126,7 +127,7 @@ namespace Core\API\Language { "modules" => new ArrayType("modules", Parameter::TYPE_STRING, true, false), "compression" => new StringType("compression", -1, true, NULL, ["gzip", "zlib"]) ]); - $this->loginRequired = false; + $this->loginRequirements = Request::NONE; $this->csrfTokenRequired = false; } diff --git a/Core/API/Request.class.php b/Core/API/Request.class.php index bc8a938..849927e 100644 --- a/Core/API/Request.class.php +++ b/Core/API/Request.class.php @@ -14,6 +14,11 @@ use PhpMqtt\Client\MqttClient; // TODO: many things are only checked for external calls, e.g. loginRequired. If we call the API internally, we might get null-pointers for $context->user abstract class Request { + // Login Requirements + const NONE = 0; + const LOGGED_IN = 1; + const NOT_LOGGED_IN = 2; + protected Context $context; protected Logger $logger; protected array $params; @@ -21,7 +26,7 @@ abstract class Request { protected array $result; protected bool $success; protected bool $isPublic; - protected bool $loginRequired; + protected int $loginRequirements; protected bool $variableParamCount; protected bool $isDisabled; protected bool $apiKeyAllowed; @@ -47,9 +52,9 @@ abstract class Request { // restrictions $this->isPublic = true; $this->isDisabled = false; - $this->loginRequired = false; + $this->loginRequirements = self::NONE; $this->apiKeyAllowed = true; - $this->allowedMethods = array("GET", "POST"); + $this->allowedMethods = ["GET", "POST"]; $this->csrfTokenRequired = true; $this->rateLimiting = null; } @@ -270,8 +275,8 @@ abstract class Request { } } - // Logged in or api key authorized? - if ($this->loginRequired) { + if ($this->loginRequirements === self::LOGGED_IN) { + // Logged in or api key authorized? if (!$session && !$apiKeyAuthorized) { $this->lastError = 'You are not logged in.'; $this->result["loggedIn"] = false; @@ -281,6 +286,12 @@ abstract class Request { http_response_code(401); return false; } + } else if ($this->loginRequirements === self::NOT_LOGGED_IN) { + // Request only for unauthenticated users, e.g. login endpoint + if ($session || $apiKeyAuthorized) { + $this->lastError = "You are already logged in."; + return false; + } } // CSRF Token @@ -383,7 +394,7 @@ abstract class Request { } public function loginRequired(): bool { - return $this->loginRequired; + return $this->loginRequirements === self::LOGGED_IN; } public function isExternalCall(): bool { diff --git a/Core/API/SsoAPI.class.php b/Core/API/SsoAPI.class.php index 8899540..97f0413 100644 --- a/Core/API/SsoAPI.class.php +++ b/Core/API/SsoAPI.class.php @@ -41,26 +41,39 @@ namespace Core\API\Sso { use Core\API\Parameter\StringType; use Core\API\Parameter\UuidType; + use Core\API\Request; use Core\Objects\Context; use Core\API\SsoAPI; use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\SsoProvider; + use Core\Objects\RateLimiting; + use Core\Objects\RateLimitRule; + use Core\Objects\SSO\SAMLResponse; class GetProviders extends SsoAPI { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall, []); - // TODO: auto-generated method stub + $this->csrfTokenRequired = false; } protected function _execute(): bool { - // TODO: auto-generated method stub - return $this->success; + + $sql = $this->context->getSQL(); + $query = SsoProvider::createBuilder($sql, false); + + if (!$this->context->getUser()) { + $query->whereTrue("active"); + } + + $providers = SsoProvider::findBy($query); + $this->result["providers"] = SsoProvider::toJsonArray($providers); + return true; } public static function getDescription(): string { // TODO: auto generated endpoint description - return "Short description, what users are able to do with this endpoint."; + return "Allows users to get a list of SSO providers. Unauthenticated users will only see active providers."; } } @@ -138,14 +151,13 @@ namespace Core\API\Sso { "redirect" => new StringType("redirect", StringType::UNLIMITED, true, null) ]); $this->csrfTokenRequired = false; + $this->loginRequirements = Request::NOT_LOGGED_IN; + $this->rateLimiting = new RateLimiting( + new RateLimitRule(5, 1, RateLimitRule::MINUTE) + ); } protected function _execute(): bool { - - if ($this->context->getUser()) { - return $this->createError("You are already logged in."); - } - $redirectUrl = $this->getParam("redirect"); if (!$this->validateRedirectURL($redirectUrl)) { return $this->createError("Invalid redirect URL"); @@ -186,24 +198,30 @@ namespace Core\API\Sso { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall, [ - "SAMLResponse" => new StringType("SAMLResponse"), - "provider" => new UuidType("provider"), - "redirect" => new StringType("redirect", StringType::UNLIMITED, true, null) + "SAMLResponse" => new StringType("SAMLResponse") ]); $this->csrfTokenRequired = false; + $this->loginRequirements = Request::NOT_LOGGED_IN; $this->forbidMethod("GET"); + $this->rateLimiting = new RateLimiting( + new RateLimitRule(15, 1, RateLimitRule::MINUTE) + ); } protected function _execute(): bool { - if ($this->context->getUser()) { - return $this->createError("You are already logged in."); + + $samlResponseEncoded = $this->getParam("SAMLResponse"); + if (($samlResponse = @gzinflate(base64_decode($samlResponseEncoded))) === false) { + $samlResponse = base64_decode($samlResponseEncoded); } - $redirectUrl = $this->getParam("redirect"); - if (!$this->validateRedirectURL($redirectUrl)) { - return $this->createError("Invalid redirect URL"); + $parsedResponse = SAMLResponse::parseResponse($this->context, $samlResponse); + if (!$parsedResponse->wasSuccessful()) { + return $this->createError("Error parsing SAMLResponse: " . $parsedResponse->getError()); + } else { + return $this->processLogin($parsedResponse->getUser(), $parsedResponse->getRedirectURL()); } $sql = $this->context->getSQL(); diff --git a/Core/API/TfaAPI.class.php b/Core/API/TfaAPI.class.php index 07e3c56..53d72ca 100644 --- a/Core/API/TfaAPI.class.php +++ b/Core/API/TfaAPI.class.php @@ -12,7 +12,7 @@ namespace Core\API { public function __construct(Context $context, bool $externalCall = false, array $params = array()) { parent::__construct($context, $externalCall, $params); - $this->loginRequired = true; + $this->loginRequirements = Request::LOGGED_IN; $this->apiKeyAllowed = false; $this->userVerificationRequired = false; } @@ -148,7 +148,7 @@ namespace Core\API\TFA { $twoFactorToken = $currentUser->getTwoFactorToken(); if ($twoFactorToken && $twoFactorToken->isConfirmed()) { return $this->createError("You already added a two factor token"); - } else if (!$currentUser->isNativeAccount()) { + } else if (!$currentUser->isLocalAccount()) { return $this->createError("Cannot add a 2FA token: Your account is managed by an external identity provider (SSO)"); } else if (!($twoFactorToken instanceof TimeBasedTwoFactorToken)) { $sql = $this->context->getSQL(); @@ -179,7 +179,6 @@ namespace Core\API\TFA { class ConfirmTotp extends VerifyTotp { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall); - $this->loginRequired = true; } public function _execute(): bool { @@ -216,7 +215,6 @@ namespace Core\API\TFA { parent::__construct($context, $externalCall, [ "code" => new StringType("code", 6) ]); - $this->loginRequired = true; $this->csrfTokenRequired = false; $this->rateLimiting = new RateLimiting( null, @@ -255,13 +253,12 @@ namespace Core\API\TFA { "clientDataJSON" => new StringType("clientDataJSON", 0, true, "{}"), "attestationObject" => new StringType("attestationObject", 0, true, "") ]); - $this->loginRequired = true; } public function _execute(): bool { $currentUser = $this->context->getUser(); - if (!$currentUser->isNativeAccount()) { + if (!$currentUser->isLocalAccount()) { return $this->createError("Cannot add a 2FA token: Your account is managed by an external identity provider (SSO)"); } @@ -357,7 +354,6 @@ namespace Core\API\TFA { "authData" => new StringType("authData"), "signature" => new StringType("signature"), ]); - $this->loginRequired = true; $this->csrfTokenRequired = false; $this->rateLimiting = new RateLimiting( null, diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index 90f4277..671b8ab 100644 --- a/Core/API/UserAPI.class.php +++ b/Core/API/UserAPI.class.php @@ -148,6 +148,7 @@ namespace Core\API\User { use Core\API\Parameter\IntegerType; use Core\API\Parameter\Parameter; use Core\API\Parameter\StringType; + use Core\API\Request; use Core\API\Template\Render; use Core\API\Traits\Captcha; use Core\API\Traits\Pagination; @@ -184,7 +185,7 @@ namespace Core\API\User { 'groups' => new ArrayType("groups", Parameter::TYPE_INT, true, true, []) )); - $this->loginRequired = true; + $this->loginRequirements = Request::LOGGED_IN; } public function _execute(): bool { @@ -304,7 +305,7 @@ namespace Core\API\User { parent::__construct($context, $externalCall, array( 'id' => new Parameter('id', Parameter::TYPE_INT) )); - $this->loginRequired = true; + $this->loginRequirements = Request::LOGGED_IN; } public function _execute(): bool { @@ -447,7 +448,7 @@ namespace Core\API\User { 'groups' => new ArrayType("groups", Parameter::TYPE_INT, true, true, []) )); - $this->loginRequired = true; + $this->loginRequirements = Request::LOGGED_IN; } public function _execute(): bool { @@ -545,14 +546,10 @@ namespace Core\API\User { 'confirmPassword' => new StringType('confirmPassword'), )); $this->csrfTokenRequired = false; + $this->loginRequirements = Request::NOT_LOGGED_IN; } public function _execute(): bool { - - if ($this->context->getUser()) { - return $this->createError("You are already logged in."); - } - $sql = $this->context->getSQL(); $token = $this->getParam("token"); $password = $this->getParam("password"); @@ -593,17 +590,13 @@ namespace Core\API\User { 'token' => new StringType('token', 36) )); $this->csrfTokenRequired = false; + $this->loginRequirements = Request::NOT_LOGGED_IN; $this->rateLimiting = new RateLimiting( new RateLimitRule(5, 1, RateLimitRule::MINUTE) ); } public function _execute(): bool { - - if ($this->context->getUser()) { - return $this->createError("You are already logged in."); - } - $sql = $this->context->getSQL(); $token = $this->getParam("token"); $userToken = $this->checkToken($token); @@ -708,7 +701,6 @@ namespace Core\API\User { public function __construct(Context $context, $externalCall = false) { parent::__construct($context, $externalCall); - $this->loginRequired = false; $this->apiKeyAllowed = false; $this->forbidMethod("GET"); } @@ -750,14 +742,10 @@ namespace Core\API\User { $this->addCaptchaParameters($context, $parameters); parent::__construct($context, $externalCall, $parameters); $this->csrfTokenRequired = false; + $this->loginRequirements = Request::NOT_LOGGED_IN; } public function _execute(): bool { - - if ($this->context->getUser()) { - return $this->createError(L('You are already logged in')); - } - $settings = $this->context->getSettings(); $registrationAllowed = $settings->isRegistrationAllowed(); if (!$registrationAllowed) { @@ -848,7 +836,7 @@ namespace Core\API\User { class Edit extends UserAPI { public function __construct(Context $context, bool $externalCall = false) { - parent::__construct($context, $externalCall, array( + parent::__construct($context, $externalCall, [ 'id' => new Parameter('id', Parameter::TYPE_INT), 'username' => new StringType('username', 32, true, NULL), 'fullName' => new StringType('fullName', 64, true, NULL), @@ -857,10 +845,10 @@ namespace Core\API\User { 'groups' => new ArrayType('groups', Parameter::TYPE_INT, true, true, NULL), 'confirmed' => new Parameter('confirmed', Parameter::TYPE_BOOLEAN, true, NULL), 'active' => new Parameter('active', Parameter::TYPE_BOOLEAN, true, NULL) - )); + ]); - $this->loginRequired = true; $this->forbidMethod("GET"); + $this->loginRequirements = Request::LOGGED_IN; } public function _execute(): bool { @@ -980,7 +968,7 @@ namespace Core\API\User { 'id' => new Parameter('id', Parameter::TYPE_INT) )); - $this->loginRequired = true; + $this->loginRequirements = Request::LOGGED_IN; } public function _execute(): bool { @@ -1060,7 +1048,7 @@ namespace Core\API\User { } else if ($user !== null) { if (!$user->isActive()) { return $this->createError("This user is currently disabled. Contact the server administrator, if you believe this is a mistake."); - } else if (!$user->isNativeAccount()) { + } else if (!$user->isLocalAccount()) { // TODO: this allows user enumeration for SSO accounts return $this->createError("Cannot request a password reset: Account is managed by an external identity provider (SSO)"); } else { @@ -1206,17 +1194,13 @@ namespace Core\API\User { $this->forbidMethod("GET"); $this->csrfTokenRequired = false; $this->apiKeyAllowed = false; + $this->loginRequirements = Request::NOT_LOGGED_IN; $this->rateLimiting = new RateLimiting( new RateLimitRule(5, 1, RateLimitRule::MINUTE) ); } public function _execute(): bool { - - if ($this->context->getUser()) { - return $this->createError("You are already logged in."); - } - $sql = $this->context->getSQL(); $token = $this->getParam("token"); $password = $this->getParam("password"); @@ -1228,8 +1212,8 @@ namespace Core\API\User { return $this->createError("Invalid token type"); } - $user = $token->getUser(); - if (!$user->isNativeAccount()) { + $user = $userToken->getUser(); + if (!$user->isLocalAccount()) { return $this->createError("Cannot reset password: Your account is managed by an external identity provider (SSO)"); } else if (!$this->checkPasswordRequirements($password, $confirmPassword)) { return false; @@ -1261,7 +1245,7 @@ namespace Core\API\User { 'confirmPassword' => new StringType('confirmPassword', -1, true, NULL), 'oldPassword' => new StringType('oldPassword', -1, true, NULL), )); - $this->loginRequired = true; + $this->loginRequirements = Request::LOGGED_IN; $this->csrfTokenRequired = true; $this->apiKeyAllowed = false; // prevent account takeover when an API-key is stolen $this->forbidMethod("GET"); @@ -1298,7 +1282,7 @@ namespace Core\API\User { } if ($newPassword !== null || $newPasswordConfirm !== null) { - if (!$currentUser->isNativeAccount()) { + if (!$currentUser->isLocalAccount()) { return $this->createError("Cannot change password: Your account is managed by an external identity provider (SSO)"); } else if (!$this->checkPasswordRequirements($newPassword, $newPasswordConfirm)) { return false; @@ -1338,7 +1322,7 @@ namespace Core\API\User { "y" => new FloatType("y", 0, PHP_FLOAT_MAX, true, NULL), "size" => new FloatType("size", self::MIN_SIZE, self::MAX_SIZE, true, NULL), ]); - $this->loginRequired = true; + $this->loginRequirements = Request::LOGGED_IN; $this->forbidMethod("GET"); } @@ -1415,7 +1399,7 @@ namespace Core\API\User { class RemovePicture extends UserAPI { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall, []); - $this->loginRequired = true; + $this->loginRequirements = Request::LOGGED_IN; } public function _execute(): bool { @@ -1491,7 +1475,7 @@ namespace Core\API\User { parent::__construct($context, $externalCall, [ "active" => new Parameter("active", Parameter::TYPE_BOOLEAN, true, true) ]); - $this->loginRequired = true; + $this->loginRequirements = Request::LOGGED_IN; } protected function _execute(): bool { @@ -1535,7 +1519,7 @@ namespace Core\API\User { parent::__construct($context, $externalCall, [ "id" => new Parameter("id", Parameter::TYPE_INT) ]); - $this->loginRequired = true; + $this->loginRequirements = Request::LOGGED_IN; } protected function _execute(): bool { diff --git a/Core/Configuration/Patch/2024-12-28_SSO-integration.php b/Core/Configuration/Patch/2024-12-28_SSO-integration.php index 5a7bfa5..8c1cd0f 100644 --- a/Core/Configuration/Patch/2024-12-28_SSO-integration.php +++ b/Core/Configuration/Patch/2024-12-28_SSO-integration.php @@ -5,18 +5,26 @@ use Core\Driver\SQL\Column\StringColumn; use Core\Driver\SQL\Constraint\ForeignKey; use Core\Driver\SQL\Strategy\CascadeStrategy; use Core\Objects\DatabaseEntity\SsoProvider; +use Core\Objects\DatabaseEntity\SsoRequest; use Core\Objects\DatabaseEntity\User; $userHandler = User::getHandler($sql); $ssoProviderHandler = SsoProvider::getHandler($sql); +$ssoRequestHandler = SsoRequest::getHandler($sql); $userTable = $userHandler->getTableName(); $ssoProviderTable = $ssoProviderHandler->getTableName(); $ssoProviderColumn = $userHandler->getColumnName("ssoProvider", false); $passwordColumn = $userHandler->getColumnName("password"); -$queries = array_merge($queries, $ssoProviderHandler->getCreateQueries($sql)); +// create new tables +$queries = array_merge( + $queries, + $ssoProviderHandler->getCreateQueries($sql), + $ssoRequestHandler->getCreateQueries($sql) +); +// add sso column to user table $queries[] = $sql->alterTable($userTable) ->add(new IntColumn($ssoProviderColumn, true,null)); @@ -24,6 +32,7 @@ $queries[] = $sql->alterTable($userTable) $queries[] = $sql->alterTable($userTable) ->modify(new StringColumn($passwordColumn, 128,true)); +// create foreign key constraint for sso column $constraint = new ForeignKey($ssoProviderColumn, $ssoProviderTable, "id", new CascadeStrategy()); $constraint->setName("${userTable}_ibfk_$ssoProviderColumn"); $queries[] = $sql->alterTable($userTable) diff --git a/Core/Objects/DatabaseEntity/SsoProvider.class.php b/Core/Objects/DatabaseEntity/SsoProvider.class.php index 62a2792..932b976 100644 --- a/Core/Objects/DatabaseEntity/SsoProvider.class.php +++ b/Core/Objects/DatabaseEntity/SsoProvider.class.php @@ -72,5 +72,4 @@ abstract class SsoProvider extends DatabaseEntity { } public abstract function login(Context $context, ?string $redirectUrl); - public abstract function parseResponse(Context $context, string $response) : ?User; } \ No newline at end of file diff --git a/Core/Objects/DatabaseEntity/SsoRequest.class.php b/Core/Objects/DatabaseEntity/SsoRequest.class.php new file mode 100644 index 0000000..b000764 --- /dev/null +++ b/Core/Objects/DatabaseEntity/SsoRequest.class.php @@ -0,0 +1,67 @@ +identifier = uuidv4(); + $request->ssoProvider = $ssoProvider; + $request->used = false; + $request->validUntil = (new \DateTime())->modify(sprintf('+%d minutes', self::SSO_REQUEST_DURABILITY)); + $request->redirectUrl = $redirectUrl; + if ($request->save($sql)) { + return $request; + } else { + return null; + } + } + + public function getIdentifier() : string { + return $this->identifier; + } + + public function getRedirectUrl() : ?string { + return $this->redirectUrl; + } + + public function wasUsed() : bool { + return $this->used; + } + + public function isValid() : bool { + return !isInPast($this->validUntil); + } + + public function getProvider() : SsoProvider { + return $this->ssoProvider; + } + + public function invalidate(SQL $sql) : bool { + $this->used = true; + return $this->save($sql, ["used"]); + } + +} \ No newline at end of file diff --git a/Core/Objects/DatabaseEntity/User.class.php b/Core/Objects/DatabaseEntity/User.class.php index c946b53..c215c70 100644 --- a/Core/Objects/DatabaseEntity/User.class.php +++ b/Core/Objects/DatabaseEntity/User.class.php @@ -173,7 +173,7 @@ class User extends DatabaseEntity { $alias); } - public function isNativeAccount(): bool { + public function isLocalAccount(): bool { return $this->ssoProvider === null; } } \ No newline at end of file diff --git a/Core/Objects/SSO/SAMLResponse.class.php b/Core/Objects/SSO/SAMLResponse.class.php new file mode 100644 index 0000000..d55b64d --- /dev/null +++ b/Core/Objects/SSO/SAMLResponse.class.php @@ -0,0 +1,149 @@ +user = $user; + $response->request = $request; + $response->success = true; + return $response; + } + + private static function createError(?SsoRequest $request, string $error) : SAMLResponse { + $response = new SAMLResponse(); + $response->error = $error; + $response->request = $request; + $response->success = false; + return $response; + } + + public static function parseResponse(Context $context, string $response) : SAMLResponse { + $sql = $context->getSQL(); + $xml = new DOMDocument(); + $xml->loadXML($response); + + if ($xml->documentElement->nodeName !== "samlp:Response") { + return self::createError(null, "Invalid root node"); + } + + $requestId = $xml->documentElement->getAttribute("InResponseTo"); + if (empty($requestId)) { + return self::createError(null, "Root node missing attribute 'InResponseTo'"); + } + + $ssoRequest = SsoRequest::findBy(SsoRequest::createBuilder($sql, true) + ->whereEq("SsoRequest.identifier", $requestId) + ->fetchEntities() + ); + + if ($ssoRequest === false) { + return self::createError(null, "Error fetching SSO provider: " . $sql->getLastError()); + } else if ($ssoRequest === null) { + return self::createError(null, "Request not found"); + } else if ($ssoRequest->wasUsed()) { + return self::createError($ssoRequest, "SAMLResponse already processed"); + } else if (!$ssoRequest->isValid()) { + return self::createError($ssoRequest, "Authentication request expired"); + } else { + $ssoRequest->invalidate($sql); + } + + $provider = $ssoRequest->getProvider(); + if (!($provider instanceof SSOProviderSAML)) { + return self::createError(null, "Authentication request was not a SAML request"); + } + + // 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 + + $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))); + + 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 { + return $this->success; + } + + public function getError() : string { + return $this->error; + } + + public function getUser() : User { + return $this->user; + } + + public function getRedirectURL() : ?string { + return $this->request->getRedirectUrl(); + } + +} \ No newline at end of file diff --git a/Core/Objects/SSO/SSOProviderSAML.class.php b/Core/Objects/SSO/SSOProviderSAML.class.php index d2d56b9..04455f3 100644 --- a/Core/Objects/SSO/SSOProviderSAML.class.php +++ b/Core/Objects/SSO/SSOProviderSAML.class.php @@ -2,11 +2,9 @@ namespace Core\Objects\SSO; -use Core\Driver\SQL\Condition\Compare; use Core\Objects\Context; use Core\Objects\DatabaseEntity\SsoProvider; -use Core\Objects\DatabaseEntity\User; -use DOMDocument; +use Core\Objects\DatabaseEntity\SsoRequest; class SSOProviderSAML extends SSOProvider { @@ -18,19 +16,19 @@ class SSOProviderSAML extends SSOProvider { public function login(Context $context, ?string $redirectUrl) { + $sql = $context->getSQL(); $settings = $context->getSettings(); $baseUrl = $settings->getBaseUrl(); - $params = ["provider" => $this->getIdentifier()]; - - if (!empty($redirectUrl)) { - $params["redirect"] = $redirectUrl; + $ssoRequest = SsoRequest::create($sql, $this, $redirectUrl); + if (!$ssoRequest) { + throw new \Exception("Could not save SSO request: " . $sql->getLastError()); } - $acsUrl = $baseUrl . "/api/sso/saml?" . http_build_query($params); + $acsUrl = $baseUrl . "/api/sso/saml"; $samlp = html_tag_ex("samlp:AuthnRequest", [ "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol", "xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion", - "ID" => "_" . uniqid(), + "ID" => $ssoRequest->getIdentifier(), "Version" => "2.0", "IssueInstant" => gmdate('Y-m-d\TH:i:s\Z'), "Destination" => $this->ssoUrl, @@ -48,70 +46,4 @@ class SSOProviderSAML extends SSOProvider { $context->router->redirect(302, $samlUrl); die(); } - - public function parseResponse(Context $context, string $response): ?User { - $xml = new DOMDocument(); - $xml->loadXML($response); - - // Validate XML and extract user info - if (!$xml->getElementsByTagName("Assertion")->length) { - return null; - } - - - $assertion = $xml->getElementsByTagName('Assertion')->item(0); - if (!$assertion->getElementsByTagName("Signature")->length) { - return null; - } - - $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 null; - } - - $issuer = $xml->getElementsByTagName('Issuer')->item(0)->nodeValue; - // TODO: validate issuer - - $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))); - - if ($user === false) { - return null; - } else if ($user === null) { - $user = new User(); - $user->fullName = $fullName; - $user->email = $email; - $user->name = $username; - $user->password = null; - $user->ssoProvider = $this; - $user->confirmed = true; - $user->active = true; - $user->groups = []; // TODO: create a possibility to set auto-groups for SSO registered users - } - - return $user; - } } \ No newline at end of file diff --git a/README.md b/README.md index 15fc96c..03f680e 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ php cli.php api add By default, and if not further specified or restricted, all endpoints have the following access rules: 1. Allowed methods: GET and POST (`$this->allowedMethods`) -2. No login is required (`$this->loginRequired`) +2. No login is required (`$this->loginRequirements`) 3. CSRF-Token is required, if the user is logged in (`$this->csrfTokenRequired`) 4. The function can be called from outside (`$this->isPublic`) 5. An API-Key can be used to access this method (`$this->apiKeyAllowed`) diff --git a/test/Request.test.php b/test/Request.test.php index 43f3c80..cdceeda 100644 --- a/test/Request.test.php +++ b/test/Request.test.php @@ -199,6 +199,6 @@ class RequestDisabled extends TestRequest { class RequestLoginRequired extends TestRequest { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall, []); - $this->loginRequired = true; + $this->loginRequirements = Request::LOGGED_IN; } } \ No newline at end of file