From 50cc0fc5be70ac6b3cfd104212c134ef04827959 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 30 Dec 2024 09:44:47 +0100 Subject: [PATCH] Dev SSO: Tables, SAML --- Core/API/Parameter/UuidType.class.php | 13 + Core/API/Request.class.php | 17 ++ Core/API/SsoAPI.class.php | 242 ++++++++++++++++++ Core/API/TfaAPI.class.php | 6 + Core/API/UserAPI.class.php | 23 +- Core/Configuration/Configuration.class.php | 12 +- Core/Configuration/CreateDatabase.class.php | 62 ++++- .../Patch/2021-04-08_EntityLog.php | 47 ---- .../Patch/2024-05-11_Settings-GPG.php | 17 +- .../Patch/2024-12-28_SSO-integration.php | 30 +++ Core/Documents/Install.class.php | 10 +- Core/Driver/SQL/MySQL.class.php | 12 + Core/Driver/SQL/Query/AlterTable.class.php | 13 +- Core/Objects/DatabaseEntity/Group.class.php | 4 +- .../DatabaseEntity/SsoProvider.class.php | 76 ++++++ Core/Objects/DatabaseEntity/User.class.php | 14 +- Core/Objects/Router/RedirectRoute.class.php | 3 +- Core/Objects/Router/Router.class.php | 5 + Core/Objects/SSO/SSOProviderOAuth2.class.php | 24 ++ Core/Objects/SSO/SSOProviderOIDC.class.php | 24 ++ Core/Objects/SSO/SSOProviderSAML.class.php | 117 +++++++++ .../KeyBasedTwoFactorToken.class.php | 1 - Core/core.php | 20 +- cli.php | 22 +- test/DatabaseEntity.test.php | 1 + test/Parameter.test.php | 7 + 26 files changed, 710 insertions(+), 112 deletions(-) create mode 100644 Core/API/Parameter/UuidType.class.php create mode 100644 Core/API/SsoAPI.class.php delete mode 100644 Core/Configuration/Patch/2021-04-08_EntityLog.php create mode 100644 Core/Configuration/Patch/2024-12-28_SSO-integration.php create mode 100644 Core/Objects/DatabaseEntity/SsoProvider.class.php create mode 100644 Core/Objects/SSO/SSOProviderOAuth2.class.php create mode 100644 Core/Objects/SSO/SSOProviderOIDC.class.php create mode 100644 Core/Objects/SSO/SSOProviderSAML.class.php diff --git a/Core/API/Parameter/UuidType.class.php b/Core/API/Parameter/UuidType.class.php new file mode 100644 index 0000000..777325b --- /dev/null +++ b/Core/API/Parameter/UuidType.class.php @@ -0,0 +1,13 @@ +context->getSQL(); + if (!($session = $this->context->createSession($user, $stayLoggedIn))) { + return $this->createError("Error creating Session: " . $sql->getLastError()); + } else { + $tfaToken = $user->getTwoFactorToken(); + $this->result["loggedIn"] = true; + $this->result["user"] = $user->jsonSerialize(); + $this->result["session"] = $session->jsonSerialize(["expires", "csrfToken"]); + $this->result["logoutIn"] = $session->getExpiresSeconds(); + $this->check2FA($tfaToken); + $this->success = true; + return true; + } + } + protected function check2FA(?TwoFactorToken $tfaToken = null): bool { // do not require 2FA for verifying endpoints diff --git a/Core/API/SsoAPI.class.php b/Core/API/SsoAPI.class.php new file mode 100644 index 0000000..8899540 --- /dev/null +++ b/Core/API/SsoAPI.class.php @@ -0,0 +1,242 @@ +context->getSQL(); + if ($user->getId() === null) { + // user didn't exit yet. try to insert into database + if (!$user->save($sql)) { + return $this->createError("Could not create user: " . $sql->getLastError()); + } + } + + if (!$this->createSession($user)) { + return false; + } + + if (!empty($redirectUrl)) { + $this->context->router->redirect(302, $redirectUrl); + } + + return true; + } + + protected function validateRedirectURL(string $url): bool { + // allow only relative paths + return empty($url) || startsWith($url, "/"); + } + } +} + +namespace Core\API\Sso { + + use Core\API\Parameter\StringType; + use Core\API\Parameter\UuidType; + use Core\Objects\Context; + use Core\API\SsoAPI; + use Core\Objects\DatabaseEntity\Group; + use Core\Objects\DatabaseEntity\SsoProvider; + + class GetProviders extends SsoAPI { + + public function __construct(Context $context, bool $externalCall = false) { + parent::__construct($context, $externalCall, []); + // TODO: auto-generated method stub + } + + protected function _execute(): bool { + // TODO: auto-generated method stub + return $this->success; + } + + public static function getDescription(): string { + // TODO: auto generated endpoint description + return "Short description, what users are able to do with this endpoint."; + } + } + + class AddProvider extends SsoAPI { + + public function __construct(Context $context, bool $externalCall = false) { + parent::__construct($context, $externalCall, []); + // TODO: auto-generated method stub + } + + protected function _execute(): bool { + // TODO: auto-generated method stub + return $this->success; + } + + public static function getDescription(): string { + // TODO: auto generated endpoint description + return "Short description, what users are able to do with this endpoint."; + } + + public static function getDefaultPermittedGroups(): array { + return [Group::ADMIN]; + } + } + + class EditProvider extends SsoAPI { + + public function __construct(Context $context, bool $externalCall = false) { + parent::__construct($context, $externalCall, []); + // TODO: auto-generated method stub + } + + protected function _execute(): bool { + // TODO: auto-generated method stub + return $this->success; + } + + public static function getDescription(): string { + // TODO: auto generated endpoint description + return "Short description, what users are able to do with this endpoint."; + } + + public static function getDefaultPermittedGroups(): array { + return [Group::ADMIN]; + } + } + + class RemoveProvider extends SsoAPI { + + public function __construct(Context $context, bool $externalCall = false) { + parent::__construct($context, $externalCall, []); + // TODO: auto-generated method stub + } + + protected function _execute(): bool { + // TODO: auto-generated method stub + return $this->success; + } + + public static function getDescription(): string { + // TODO: auto generated endpoint description + return "Short description, what users are able to do with this endpoint."; + } + + public static function getDefaultPermittedGroups(): array { + return [Group::ADMIN]; + } + } + + class Authenticate extends SsoAPI { + + public function __construct(Context $context, bool $externalCall = false) { + parent::__construct($context, $externalCall, [ + "provider" => new UuidType("provider"), + "redirect" => new StringType("redirect", StringType::UNLIMITED, true, null) + ]); + $this->csrfTokenRequired = false; + } + + 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"); + } + + $sql = $this->context->getSQL(); + $ssoProviderIdentifier = $this->getParam("provider"); + $ssoProvider = SsoProvider::findBy(SsoProvider::createBuilder($sql, true) + ->whereEq("identifier", $ssoProviderIdentifier) + ->whereTrue("active") + ); + if ($ssoProvider === false) { + return $this->createError("Error fetching SSO Provider: " . $sql->getLastError()); + } else if ($ssoProvider === null) { + return $this->createError("SSO Provider not found"); + } + + try { + $ssoProvider->login($this->context, $redirectUrl); + } catch (\Exception $ex) { + return $this->createError("There was an error with the SSO provider: " . $ex->getMessage()); + } + + return $this->success; + } + + public static function getDescription(): string { + return "Allows users to authenticate with a configured SSO provider."; + } + + public static function hasConfigurablePermissions(): bool { + return false; + } + } + + + class SAML extends SsoAPI { + + 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) + ]); + + $this->csrfTokenRequired = false; + $this->forbidMethod("GET"); + } + + 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"); + } + + $sql = $this->context->getSQL(); + $ssoProviderIdentifier = $this->getParam("provider"); + $ssoProvider = SsoProvider::findBy(SsoProvider::createBuilder($sql, true) + ->whereEq("identifier", $ssoProviderIdentifier) + ->whereTrue("active") + ); + if ($ssoProvider === false) { + return $this->createError("Error fetching SSO Provider: " . $sql->getLastError()); + } else if ($ssoProvider === null) { + return $this->createError("SSO Provider not found"); + } + + $samlResponseEncoded = $this->getParam("SAMLResponse"); + if (($samlResponse = @gzinflate(base64_decode($samlResponseEncoded))) === false) { + $samlResponse = base64_decode($samlResponseEncoded); + } + + $parsedUser = $ssoProvider->parseResponse($this->context, $samlResponse); + if ($parsedUser === null) { + return $this->createError("Invalid SAMLResponse"); + } else { + return $this->processLogin($parsedUser, $redirectUrl); + } + } + + public static function getDescription(): string { + return "Return endpoint for SAML SSO authentication."; + } + + public static function hasConfigurablePermissions(): bool { + return false; + } + } +} \ No newline at end of file diff --git a/Core/API/TfaAPI.class.php b/Core/API/TfaAPI.class.php index 8fc82c6..07e3c56 100644 --- a/Core/API/TfaAPI.class.php +++ b/Core/API/TfaAPI.class.php @@ -148,6 +148,8 @@ 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()) { + 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(); $twoFactorToken = new TimeBasedTwoFactorToken(generateRandomString(32, "base32")); @@ -259,6 +261,10 @@ namespace Core\API\TFA { public function _execute(): bool { $currentUser = $this->context->getUser(); + if (!$currentUser->isNativeAccount()) { + return $this->createError("Cannot add a 2FA token: Your account is managed by an external identity provider (SSO)"); + } + $clientDataJSON = json_decode($this->getParam("clientDataJSON"), true); $attestationObjectRaw = base64_decode($this->getParam("attestationObject")); $twoFactorToken = $currentUser->getTwoFactorToken(); diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index c96939c..90f4277 100644 --- a/Core/API/UserAPI.class.php +++ b/Core/API/UserAPI.class.php @@ -670,6 +670,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) ->fetchEntities()); if ($user !== false) { @@ -681,17 +682,8 @@ namespace Core\API\User { if (!$user->confirmed) { $this->result["emailConfirmed"] = false; return $this->createError("Your email address has not been confirmed yet."); - } else if (!($session = $this->context->createSession($user, $stayLoggedIn))) { - return $this->createError("Error creating Session: " . $sql->getLastError()); } else { - $tfaToken = $user->getTwoFactorToken(); - - $this->result["loggedIn"] = true; - $this->result["user"] = $user->jsonSerialize(); - $this->result["session"] = $session->jsonSerialize(["expires", "csrfToken"]); - $this->result["logoutIn"] = $session->getExpiresSeconds(); - $this->check2FA($tfaToken); - $this->success = true; + return $this->createSession($user, $stayLoggedIn); } } else { return $this->createError(L('Wrong username or password')); @@ -1068,6 +1060,9 @@ 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()) { + // 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 { $validHours = 1; $token = generateRandomString(36); @@ -1234,7 +1229,9 @@ namespace Core\API\User { } $user = $token->getUser(); - if (!$this->checkPasswordRequirements($password, $confirmPassword)) { + if (!$user->isNativeAccount()) { + return $this->createError("Cannot reset password: Your account is managed by an external identity provider (SSO)"); + } else if (!$this->checkPasswordRequirements($password, $confirmPassword)) { return false; } else { $user->password = $this->hashPassword($password); @@ -1301,7 +1298,9 @@ namespace Core\API\User { } if ($newPassword !== null || $newPasswordConfirm !== null) { - if (!$this->checkPasswordRequirements($newPassword, $newPasswordConfirm)) { + if (!$currentUser->isNativeAccount()) { + return $this->createError("Cannot change password: Your account is managed by an external identity provider (SSO)"); + } else if (!$this->checkPasswordRequirements($newPassword, $newPasswordConfirm)) { return false; } else { if (!password_verify($oldPassword, $currentUser->password)) { diff --git a/Core/Configuration/Configuration.class.php b/Core/Configuration/Configuration.class.php index 29d9221..8321cb5 100644 --- a/Core/Configuration/Configuration.class.php +++ b/Core/Configuration/Configuration.class.php @@ -15,12 +15,8 @@ class Configuration { $this->settings = Settings::loadDefaults(); $className = self::className; - $path = getClassPath($className, ".class"); - if (file_exists($path) && is_readable($path)) { - include_once $path; - if (class_exists($className)) { - $this->database = new $className(); - } + if (isClass($className)) { + $this->database = new $className(); } } @@ -32,7 +28,7 @@ class Configuration { return $this->settings; } - public static function create(string $className, $data) { + public static function create(string $className, $data): bool { $path = getClassPath($className); $classNameShort = explode("\\", $className); $classNameShort = end($classNameShort); @@ -86,7 +82,7 @@ class Configuration { $code = "primaryKey("method") ->addBool("is_core", false); + self::loadEntityLog($sql, $queries); self::loadDefaultACL($sql, $queries); - self::loadPatches($sql, $queries); - return $queries; } - private static function loadPatches(SQL $sql, array &$queries): void { - $patchFiles = array_merge( - glob('Core/Configuration/Patch/*.php'), - glob('Site/Configuration/Patch/*.php') - ); - - sort($patchFiles); - foreach ($patchFiles as $file) { - @include_once $file; - } - } - private static function getCreatedTables(SQL $sql, array $queries): ?array { $createdTables = $sql->listTables(); @@ -184,4 +175,47 @@ class CreateDatabase { $queries[] = $query; } } + + private static function loadEntityLog(SQL $sql, array &$queries) { + $queries[] = $sql->createTable("EntityLog") + ->addInt("entity_id") + ->addString("table_name") + ->addDateTime("last_modified", false, $sql->now()) + ->addInt("lifetime", false, 90); + + $insertProcedure = $sql->createProcedure("InsertEntityLog") + ->param(new CurrentTable()) + ->param(new IntColumn("id")) + ->param(new IntColumn("lifetime", false, 90)) + ->returns(new Trigger()) + ->exec(array( + $sql->insert("EntityLog", ["entity_id", "table_name", "lifetime"]) + ->addRow(new CurrentColumn("id"), new CurrentTable(), new CurrentColumn("lifetime")) + )); + + $updateProcedure = $sql->createProcedure("UpdateEntityLog") + ->param(new CurrentTable()) + ->param(new IntColumn("id")) + ->returns(new Trigger()) + ->exec(array( + $sql->update("EntityLog") + ->set("last_modified", $sql->now()) + ->whereEq("entity_id", new CurrentColumn("id")) + ->whereEq("table_name", new CurrentTable()) + )); + + $deleteProcedure = $sql->createProcedure("DeleteEntityLog") + ->param(new CurrentTable()) + ->param(new IntColumn("id")) + ->returns(new Trigger()) + ->exec(array( + $sql->delete("EntityLog") + ->whereEq("entity_id", new CurrentColumn("id")) + ->whereEq("table_name", new CurrentTable()) + )); + + $queries[] = $insertProcedure; + $queries[] = $updateProcedure; + $queries[] = $deleteProcedure; + } } diff --git a/Core/Configuration/Patch/2021-04-08_EntityLog.php b/Core/Configuration/Patch/2021-04-08_EntityLog.php deleted file mode 100644 index c96a683..0000000 --- a/Core/Configuration/Patch/2021-04-08_EntityLog.php +++ /dev/null @@ -1,47 +0,0 @@ -createTable("EntityLog") - ->addInt("entityId") - ->addString("tableName") - ->addDateTime("modified", false, $sql->now()) - ->addInt("lifetime", false, 90); - -$insertProcedure = $sql->createProcedure("InsertEntityLog") - ->param(new CurrentTable()) - ->param(new IntColumn("id")) - ->param(new IntColumn("lifetime", false, 90)) - ->returns(new Trigger()) - ->exec(array( - $sql->insert("EntityLog", ["entityId", "tableName", "lifetime"]) - ->addRow(new CurrentColumn("id"), new CurrentTable(), new CurrentColumn("lifetime")) - )); - -$updateProcedure = $sql->createProcedure("UpdateEntityLog") - ->param(new CurrentTable()) - ->param(new IntColumn("id")) - ->returns(new Trigger()) - ->exec(array( - $sql->update("EntityLog") - ->set("modified", $sql->now()) - ->whereEq("entityId", new CurrentColumn("id")) - ->whereEq("tableName", new CurrentTable()) - )); - -$deleteProcedure = $sql->createProcedure("DeleteEntityLog") - ->param(new CurrentTable()) - ->param(new IntColumn("id")) - ->returns(new Trigger()) - ->exec(array( - $sql->delete("EntityLog") - ->whereEq("entityId", new CurrentColumn("id")) - ->whereEq("tableName", new CurrentTable()) - )); - -$queries[] = $insertProcedure; -$queries[] = $updateProcedure; -$queries[] = $deleteProcedure; diff --git a/Core/Configuration/Patch/2024-05-11_Settings-GPG.php b/Core/Configuration/Patch/2024-05-11_Settings-GPG.php index 34582a8..0e99830 100644 --- a/Core/Configuration/Patch/2024-05-11_Settings-GPG.php +++ b/Core/Configuration/Patch/2024-05-11_Settings-GPG.php @@ -1,8 +1,8 @@ insert("Settings", ["name", "value", "private", "readonly"]) ->onDuplicateKeyStrategy(new UpdateStrategy( @@ -12,14 +12,7 @@ $queries[] = $sql->insert("Settings", ["name", "value", "private", "readonly"]) ->addRow("mail_contact_gpg_key_id", null, false, true) ->addRow("mail_contact", "''", false, false); -$queries[] = $sql->insert("ApiPermission", ["method", "groups", "description", "is_core"]) - ->onDuplicateKeyStrategy(new UpdateStrategy( - ["method"], - ["method" => new Column("method")]) - ) - ->addRow("settings/importGPG", - json_encode(\Core\API\Settings\ImportGPG::getDefaultPermittedGroups()), - \Core\API\Settings\ImportGPG::getDescription(), true) - ->addRow("settings/removeGPG", - json_encode(\Core\API\Settings\RemoveGPG::getDefaultPermittedGroups()), - \Core\API\Settings\RemoveGPG::getDescription(), true); +CreateDatabase::loadDefaultACL($sql, $queries, [ + \Core\API\Settings\ImportGPG::class, + \Core\API\Settings\RemoveGPG::class +]); diff --git a/Core/Configuration/Patch/2024-12-28_SSO-integration.php b/Core/Configuration/Patch/2024-12-28_SSO-integration.php new file mode 100644 index 0000000..5a7bfa5 --- /dev/null +++ b/Core/Configuration/Patch/2024-12-28_SSO-integration.php @@ -0,0 +1,30 @@ +getTableName(); +$ssoProviderTable = $ssoProviderHandler->getTableName(); +$ssoProviderColumn = $userHandler->getColumnName("ssoProvider", false); +$passwordColumn = $userHandler->getColumnName("password"); + +$queries = array_merge($queries, $ssoProviderHandler->getCreateQueries($sql)); + +$queries[] = $sql->alterTable($userTable) + ->add(new IntColumn($ssoProviderColumn, true,null)); + +// make password nullable for SSO-login +$queries[] = $sql->alterTable($userTable) + ->modify(new StringColumn($passwordColumn, 128,true)); + +$constraint = new ForeignKey($ssoProviderColumn, $ssoProviderTable, "id", new CascadeStrategy()); +$constraint->setName("${userTable}_ibfk_$ssoProviderColumn"); +$queries[] = $sql->alterTable($userTable) + ->add($constraint); \ No newline at end of file diff --git a/Core/Documents/Install.class.php b/Core/Documents/Install.class.php index bcc5a51..2dfc961 100644 --- a/Core/Documents/Install.class.php +++ b/Core/Documents/Install.class.php @@ -18,7 +18,6 @@ namespace Core\Documents { namespace Documents\Install { use Core\Configuration\Configuration; - use Core\Configuration\CreateDatabase; use Core\Driver\SQL\SQL; use Core\Elements\Body; use Core\Elements\Head; @@ -384,7 +383,14 @@ namespace Documents\Install { $msg = ""; $success = true; - $queries = CreateDatabase::createQueries($sql); + + // create site specific database scheme if present + if (isClass(\Site\Configuration\CreateDatabase::class)) { + $queries = \Site\Configuration\CreateDatabase::createQueries($sql); + } else { + $queries = \Core\Configuration\CreateDatabase::createQueries($sql); + } + try { $sql->startTransaction(); foreach ($queries as $query) { diff --git a/Core/Driver/SQL/MySQL.class.php b/Core/Driver/SQL/MySQL.class.php index de46b27..adc0dd2 100644 --- a/Core/Driver/SQL/MySQL.class.php +++ b/Core/Driver/SQL/MySQL.class.php @@ -507,6 +507,18 @@ class MySQL extends SQL { return null; } + + public function startTransaction(): bool { + return $this->connection->begin_transaction(); + } + + public function commit(): bool { + return $this->connection->commit(); + } + + public function rollback(): bool { + return $this->connection->rollback(); + } } class RowIteratorMySQL extends RowIterator { diff --git a/Core/Driver/SQL/Query/AlterTable.class.php b/Core/Driver/SQL/Query/AlterTable.class.php index 823becc..403f390 100644 --- a/Core/Driver/SQL/Query/AlterTable.class.php +++ b/Core/Driver/SQL/Query/AlterTable.class.php @@ -116,8 +116,17 @@ class AlterTable extends Query { } } } else if ($action === "ADD") { - $query .= "CONSTRAINT "; - $query .= $this->sql->getConstraintDefinition($constraint); + $constraintName = $constraint->getName(); + + if ($constraintName) { + $query .= "CONSTRAINT "; + $query .= $constraintName; + $query .= " "; + $query .= $this->sql->getConstraintDefinition($constraint); + } else { + $this->sql->setLastError("Cannot ADD CONSTRAINT without a constraint name."); + return null; + } } else if ($action === "MODIFY") { $this->sql->setLastError("MODIFY CONSTRAINT foreign key is not supported."); return null; diff --git a/Core/Objects/DatabaseEntity/Group.class.php b/Core/Objects/DatabaseEntity/Group.class.php index c517342..72ab23e 100644 --- a/Core/Objects/DatabaseEntity/Group.class.php +++ b/Core/Objects/DatabaseEntity/Group.class.php @@ -10,13 +10,13 @@ use Core\Objects\DatabaseEntity\Controller\NMRelation; class Group extends DatabaseEntity { const ADMIN = 1; - const MODERATOR = 3; const SUPPORT = 2; + const MODERATOR = 3; const GROUPS = [ self::ADMIN => "Administrator", - self::MODERATOR => "Moderator", self::SUPPORT => "Support", + self::MODERATOR => "Moderator", ]; #[MaxLength(32)] public string $name; diff --git a/Core/Objects/DatabaseEntity/SsoProvider.class.php b/Core/Objects/DatabaseEntity/SsoProvider.class.php new file mode 100644 index 0000000..62a2792 --- /dev/null +++ b/Core/Objects/DatabaseEntity/SsoProvider.class.php @@ -0,0 +1,76 @@ + SSOProviderOIDC::class, + "oauth2" => SSOProviderOAuth2::class, + "saml" => SSOProviderSAML::class, + ]; + + #[MaxLength(64)] + private string $name; + + #[MaxLength(36)] + #[Unique] + private string $identifier; + + private bool $active; + + #[ExtendingEnum(self::PROTOCOLS)] + private string $protocol; + + protected string $ssoUrl; + + public function __construct(string $protocol, ?int $id = null) { + parent::__construct($id); + $this->protocol = $protocol; + } + + public static function newInstance(\ReflectionClass $reflectionClass, array $row) { + $type = $row["protocol"] ?? null; + if ($type === "saml") { + return (new \ReflectionClass(SSOProviderSAML::class))->newInstanceWithoutConstructor(); + } else if ($type === "oauth2") { + return (new \ReflectionClass(SSOProviderOAuth2::class))->newInstanceWithoutConstructor(); + } else if ($type === "oidc") { + return (new \ReflectionClass(SSOProviderOIDC::class))->newInstanceWithoutConstructor(); + } else { + return parent::newInstance($reflectionClass, $row); + } + } + + protected function buildUrl(string $url, array $params): ?string { + $parts = parse_url($url); + if ($parts === false || !isset($parts["host"])) { + return null; + } + + if (!isset($parts["query"])) { + $parts["query"] = http_build_query($params); + } else { + $parts["query"] .= "&" . http_build_query($params); + } + + $parts["scheme"] = $parts["scheme"] ?? "https"; + return unparse_url($parts); + } + + public function getIdentifier(): string { + return $this->identifier; + } + + 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/User.class.php b/Core/Objects/DatabaseEntity/User.class.php index ed499d0..c946b53 100644 --- a/Core/Objects/DatabaseEntity/User.class.php +++ b/Core/Objects/DatabaseEntity/User.class.php @@ -18,11 +18,13 @@ use Core\Objects\DatabaseEntity\Controller\DatabaseEntityHandler; class User extends DatabaseEntity { - #[MaxLength(32)] #[Unique] public string $name; + #[MaxLength(32)] + #[Unique] + public string $name; #[MaxLength(128)] #[Visibility(Visibility::NONE)] - public string $password; + public ?string $password; #[MaxLength(64)] public string $fullName; @@ -60,8 +62,12 @@ class User extends DatabaseEntity { #[Multiple(Group::class)] public array $groups; + public ?SsoProvider $ssoProvider; + public function __construct(?int $id = null) { parent::__construct($id); + $this->twoFactorToken = null; + $this->gpgKey = null; } public function getUsername(): string { @@ -166,4 +172,8 @@ class User extends DatabaseEntity { )->from("User")->whereEq("User.id", new Column($joinColumn)), $alias); } + + public function isNativeAccount(): bool { + return $this->ssoProvider === null; + } } \ No newline at end of file diff --git a/Core/Objects/Router/RedirectRoute.class.php b/Core/Objects/Router/RedirectRoute.class.php index 60d6ebd..83860ea 100644 --- a/Core/Objects/Router/RedirectRoute.class.php +++ b/Core/Objects/Router/RedirectRoute.class.php @@ -21,8 +21,7 @@ class RedirectRoute extends Route { } public function call(Router $router, array $params): string { - header("Location: " . $this->getDestination()); - http_response_code($this->code); + $router->redirect($this->code, $this->getDestination()); return ""; } diff --git a/Core/Objects/Router/Router.class.php b/Core/Objects/Router/Router.class.php index 277bef3..8ff86fa 100644 --- a/Core/Objects/Router/Router.class.php +++ b/Core/Objects/Router/Router.class.php @@ -176,4 +176,9 @@ class RouterCache extends Router { return $this->routes; } + + public function redirect(int $code, string $location): void { + header("Location: " . $location); + http_response_code($code); + } } \ No newline at end of file diff --git a/Core/Objects/SSO/SSOProviderOAuth2.class.php b/Core/Objects/SSO/SSOProviderOAuth2.class.php new file mode 100644 index 0000000..2d678cd --- /dev/null +++ b/Core/Objects/SSO/SSOProviderOAuth2.class.php @@ -0,0 +1,24 @@ +getSettings(); + $baseUrl = $settings->getBaseUrl(); + $params = ["provider" => $this->getIdentifier()]; + + if (!empty($redirectUrl)) { + $params["redirect"] = $redirectUrl; + } + + $acsUrl = $baseUrl . "/api/sso/saml?" . http_build_query($params); + $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(), + "Version" => "2.0", + "IssueInstant" => gmdate('Y-m-d\TH:i:s\Z'), + "Destination" => $this->ssoUrl, + "ProtocolBinding" => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + "AssertionConsumerServiceURL" => $acsUrl + ], html_tag("saml:Issuer", [], $baseUrl), false); + + $samlRequest = base64_encode(gzdeflate($samlp)); + $samlUrl = $this->buildUrl($this->ssoUrl, [ "SAMLRequest" => $samlRequest ]); + + if ($samlUrl === null) { + throw new \Exception("SSO Provider has an invalid URL configured"); + } + + $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/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php b/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php index a5f5e4d..43755ca 100644 --- a/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php +++ b/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php @@ -6,7 +6,6 @@ use Core\Driver\SQL\SQL; use Core\Objects\DatabaseEntity\Attribute\Transient; use Cose\Algorithm\Signature\ECDSA\ECSignature; use Core\Objects\DatabaseEntity\TwoFactorToken; -use Cose\Key\Key; class KeyBasedTwoFactorToken extends TwoFactorToken { diff --git a/Core/core.php b/Core/core.php index c75afe2..f1625db 100644 --- a/Core/core.php +++ b/Core/core.php @@ -10,7 +10,7 @@ if (is_file($autoLoad)) { require_once $autoLoad; } -const WEBBASE_VERSION = "2.4.5"; +const WEBBASE_VERSION = "2.5.0-dev"; spl_autoload_extensions(".php"); spl_autoload_register(function ($class) { @@ -345,4 +345,20 @@ function loadEnv(?string $file = NULL, bool $putEnv = false): array|null { } return $env; -} \ No newline at end of file +} + +function unparse_url($parsed_url): string { + $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : ''; + $host = $parsed_url['host'] ?? ''; + $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; + $user = $parsed_url['user'] ?? ''; + $pass = isset($parsed_url['pass']) ? ':' . $parsed_url['pass'] : ''; + $pass = ($user || $pass) ? "$pass@" : ''; + $path = $parsed_url['path'] ?? ''; + $query = isset($parsed_url['query']) ? '?' . $parsed_url['query'] : ''; + $fragment = isset($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : ''; + + return implode("", [ + $scheme, $user, $pass, $host, $port, $path, $query, $fragment, + ]); +} diff --git a/cli.php b/cli.php index 013a823..fb95f4d 100755 --- a/cli.php +++ b/cli.php @@ -122,19 +122,27 @@ function handleDatabase(array $argv): void { } $success = true; + $autoCommit = false; $queryCount = count($queries); $logger = new \Core\Driver\Logger\Logger("CLI", $sql); $logger->info("Migrating DB with: " . $fileName); printLine("Executing $queryCount queries"); - $sql->startTransaction(); + if (!$sql->startTransaction()) { + $logger->warning("Could not start transaction: " . $sql->getLastError()); + $autoCommit = true; + } + $queryIndex = 1; foreach ($queries as $query) { if ($query->execute() === false) { $success = false; - printLine("Error executing query: " . $sql->getLastError()); $logger->error("Error while migrating db: " . $sql->getLastError()); - $sql->rollback(); + if (!$autoCommit) { + if (!$sql->rollback()) { + $logger->warning("Could not roll back: " . $sql->getLastError()); + } + } break; } else { printLine("$queryIndex/$queryCount: success!"); @@ -142,8 +150,10 @@ function handleDatabase(array $argv): void { } } - if ($success) { - $sql->commit(); + if ($success && !$autoCommit) { + if (!$sql->commit()) { + $logger->warning("Could not commit: " . $sql->getLastError()); + } } printLine("Done."); @@ -964,7 +974,7 @@ namespace Site\API { namespace Site\API\\$apiName { use Core\Objects\Context; - use Site\API\TestAPI; + use Site\API\${apiName}API; $methods }"; diff --git a/test/DatabaseEntity.test.php b/test/DatabaseEntity.test.php index 292b3a9..8772992 100644 --- a/test/DatabaseEntity.test.php +++ b/test/DatabaseEntity.test.php @@ -10,6 +10,7 @@ use Core\Objects\DatabaseEntity\Controller\DatabaseEntity; use Core\Objects\DatabaseEntity\Controller\DatabaseEntityHandler; use Core\Objects\DatabaseEntity\User; +// FIXME: Tests must be run in specific order (create, insert, drop) class DatabaseEntityTest extends \PHPUnit\Framework\TestCase { static User $USER; diff --git a/test/Parameter.test.php b/test/Parameter.test.php index 18e508c..2fd447a 100644 --- a/test/Parameter.test.php +++ b/test/Parameter.test.php @@ -152,5 +152,12 @@ class ParameterTest extends \PHPUnit\Framework\TestCase { $this->assertTrue($integerRegex->parseParam(12)); $this->assertFalse($integerRegex->parseParam("012")); $this->assertFalse($integerRegex->parseParam("1.2")); + + $uuidRegex = new \Core\API\Parameter\UuidType("uuid_regex"); + $this->assertTrue($uuidRegex->parseParam("e3ad46da-556d-4c61-9d9a-ef85ba7b4053")); + $this->assertTrue($uuidRegex->parseParam("00000000-0000-0000-0000-000000000000")); + $this->assertFalse($uuidRegex->parseParam("e3ad46da-556d-4c61-9d9a-ef85ba7b4053123")); + $this->assertFalse($uuidRegex->parseParam("e3ad46da-556d-4c61-9d9a-ef85ba7")); + $this->assertFalse($uuidRegex->parseParam("not-a-valid-uuid")); } } \ No newline at end of file