Dev SSO: Tables, SAML
This commit is contained in:
parent
f7d11c297d
commit
50cc0fc5be
13
Core/API/Parameter/UuidType.class.php
Normal file
13
Core/API/Parameter/UuidType.class.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Core\API\Parameter;
|
||||
|
||||
class UuidType extends RegexType {
|
||||
|
||||
const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
||||
|
||||
public function __construct(string $name, bool $optional = FALSE, ?string $defaultValue = NULL) {
|
||||
parent::__construct($name, self::UUID_PATTERN, $optional, $defaultValue);
|
||||
}
|
||||
|
||||
}
|
@ -6,6 +6,7 @@ use Core\Driver\Logger\Logger;
|
||||
use Core\Driver\SQL\Query\Insert;
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\TwoFactorToken;
|
||||
use Core\Objects\DatabaseEntity\User;
|
||||
use Core\Objects\RateLimiting;
|
||||
use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
|
||||
use PhpMqtt\Client\MqttClient;
|
||||
@ -144,6 +145,22 @@ abstract class Request {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function createSession(User $user, bool $stayLoggedIn = true): bool {
|
||||
$sql = $this->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
|
||||
|
242
Core/API/SsoAPI.class.php
Normal file
242
Core/API/SsoAPI.class.php
Normal file
@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace Core\API {
|
||||
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\User;
|
||||
|
||||
abstract class SsoAPI extends Request {
|
||||
public function __construct(Context $context, bool $externalCall = false, array $params = []) {
|
||||
parent::__construct($context, $externalCall, $params);
|
||||
}
|
||||
|
||||
protected function processLogin(User $user, ?string $redirectUrl): bool {
|
||||
$sql = $this->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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -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)) {
|
||||
|
@ -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 = "<?php";
|
||||
}
|
||||
|
||||
return @file_put_contents($path, $code);
|
||||
return @file_put_contents($path, $code) !== false;
|
||||
}
|
||||
|
||||
public function delete(string $className): bool {
|
||||
|
@ -4,8 +4,12 @@ namespace Core\Configuration;
|
||||
|
||||
use Core\API\Request;
|
||||
use Core\Driver\Logger\Logger;
|
||||
use Core\Driver\SQL\Column\IntColumn;
|
||||
use Core\Driver\SQL\Query\CreateTable;
|
||||
use Core\Driver\SQL\SQL;
|
||||
use Core\Driver\SQL\Type\CurrentColumn;
|
||||
use Core\Driver\SQL\Type\CurrentTable;
|
||||
use Core\Driver\SQL\Type\Trigger;
|
||||
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
|
||||
use PHPUnit\Util\Exception;
|
||||
|
||||
@ -45,24 +49,11 @@ class CreateDatabase {
|
||||
->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;
|
||||
}
|
||||
}
|
||||
|
@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Core\Driver\SQL\Column\IntColumn;
|
||||
use Core\Driver\SQL\Type\CurrentColumn;
|
||||
use Core\Driver\SQL\Type\CurrentTable;
|
||||
use Core\Driver\SQL\Type\Trigger;
|
||||
|
||||
$queries[] = $sql->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;
|
@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Core\Configuration\CreateDatabase;
|
||||
use Core\Driver\SQL\Column\Column;
|
||||
use Core\Driver\SQL\Strategy\UpdateStrategy;
|
||||
use Core\Objects\DatabaseEntity\Group;
|
||||
|
||||
$queries[] = $sql->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
|
||||
]);
|
||||
|
30
Core/Configuration/Patch/2024-12-28_SSO-integration.php
Normal file
30
Core/Configuration/Patch/2024-12-28_SSO-integration.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Core\Driver\SQL\Column\IntColumn;
|
||||
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\User;
|
||||
|
||||
$userHandler = User::getHandler($sql);
|
||||
$ssoProviderHandler = SsoProvider::getHandler($sql);
|
||||
|
||||
$userTable = $userHandler->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);
|
@ -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) {
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
76
Core/Objects/DatabaseEntity/SsoProvider.class.php
Normal file
76
Core/Objects/DatabaseEntity/SsoProvider.class.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Core\Objects\DatabaseEntity;
|
||||
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\Attribute\ExtendingEnum;
|
||||
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
|
||||
use Core\Objects\DatabaseEntity\Attribute\Unique;
|
||||
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
|
||||
use Core\Objects\SSO\SSOProviderOAuth2;
|
||||
use Core\Objects\SSO\SSOProviderOIDC;
|
||||
use Core\Objects\SSO\SSOProviderSAML;
|
||||
|
||||
abstract class SsoProvider extends DatabaseEntity {
|
||||
|
||||
const PROTOCOLS = [
|
||||
"oidc" => 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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 "";
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
24
Core/Objects/SSO/SSOProviderOAuth2.class.php
Normal file
24
Core/Objects/SSO/SSOProviderOAuth2.class.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Core\Objects\SSO;
|
||||
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\SsoProvider;
|
||||
use Core\Objects\DatabaseEntity\User;
|
||||
|
||||
class SSOProviderOAuth2 extends SSOProvider {
|
||||
|
||||
const TYPE = "oauth2";
|
||||
|
||||
public function __construct(?int $id = null) {
|
||||
parent::__construct(self::TYPE, $id);
|
||||
}
|
||||
|
||||
public function login(Context $context, ?string $redirectUrl) {
|
||||
// TODO: Implement login() method.
|
||||
}
|
||||
|
||||
public function parseResponse(Context $context, string $response): ?User {
|
||||
// TODO: Implement parseResponse() method.
|
||||
}
|
||||
}
|
24
Core/Objects/SSO/SSOProviderOIDC.class.php
Normal file
24
Core/Objects/SSO/SSOProviderOIDC.class.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Core\Objects\SSO;
|
||||
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\SsoProvider;
|
||||
use Core\Objects\DatabaseEntity\User;
|
||||
|
||||
class SSOProviderOIDC extends SSOProvider {
|
||||
|
||||
const TYPE = "oidc";
|
||||
|
||||
public function __construct(?int $id = null) {
|
||||
parent::__construct(self::TYPE, $id);
|
||||
}
|
||||
|
||||
public function login(Context $context, ?string $redirectUrl) {
|
||||
|
||||
}
|
||||
|
||||
public function parseResponse(Context $context, string $response): ?User {
|
||||
// TODO: Implement parseResponse() method.
|
||||
}
|
||||
}
|
117
Core/Objects/SSO/SSOProviderSAML.class.php
Normal file
117
Core/Objects/SSO/SSOProviderSAML.class.php
Normal file
@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
class SSOProviderSAML extends SSOProvider {
|
||||
|
||||
const TYPE = "saml";
|
||||
|
||||
public function __construct(?int $id = null) {
|
||||
parent::__construct(self::TYPE, $id);
|
||||
}
|
||||
|
||||
public function login(Context $context, ?string $redirectUrl) {
|
||||
|
||||
$settings = $context->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;
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
||||
|
@ -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) {
|
||||
@ -346,3 +346,19 @@ function loadEnv(?string $file = NULL, bool $putEnv = false): array|null {
|
||||
|
||||
return $env;
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
22
cli.php
22
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
|
||||
}";
|
||||
|
@ -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;
|
||||
|
@ -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"));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user