Dev SSO: Tables, SAML

This commit is contained in:
Roman 2024-12-30 09:44:47 +01:00
parent f7d11c297d
commit 50cc0fc5be
26 changed files with 710 additions and 112 deletions

@ -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\Driver\SQL\Query\Insert;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\TwoFactorToken; use Core\Objects\DatabaseEntity\TwoFactorToken;
use Core\Objects\DatabaseEntity\User;
use Core\Objects\RateLimiting; use Core\Objects\RateLimiting;
use Core\Objects\TwoFactor\KeyBasedTwoFactorToken; use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
use PhpMqtt\Client\MqttClient; use PhpMqtt\Client\MqttClient;
@ -144,6 +145,22 @@ abstract class Request {
return true; 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 { protected function check2FA(?TwoFactorToken $tfaToken = null): bool {
// do not require 2FA for verifying endpoints // do not require 2FA for verifying endpoints

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(); $twoFactorToken = $currentUser->getTwoFactorToken();
if ($twoFactorToken && $twoFactorToken->isConfirmed()) { if ($twoFactorToken && $twoFactorToken->isConfirmed()) {
return $this->createError("You already added a two factor token"); 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)) { } else if (!($twoFactorToken instanceof TimeBasedTwoFactorToken)) {
$sql = $this->context->getSQL(); $sql = $this->context->getSQL();
$twoFactorToken = new TimeBasedTwoFactorToken(generateRandomString(32, "base32")); $twoFactorToken = new TimeBasedTwoFactorToken(generateRandomString(32, "base32"));
@ -259,6 +261,10 @@ namespace Core\API\TFA {
public function _execute(): bool { public function _execute(): bool {
$currentUser = $this->context->getUser(); $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); $clientDataJSON = json_decode($this->getParam("clientDataJSON"), true);
$attestationObjectRaw = base64_decode($this->getParam("attestationObject")); $attestationObjectRaw = base64_decode($this->getParam("attestationObject"));
$twoFactorToken = $currentUser->getTwoFactorToken(); $twoFactorToken = $currentUser->getTwoFactorToken();

@ -670,6 +670,7 @@ namespace Core\API\User {
$sql = $this->context->getSQL(); $sql = $this->context->getSQL();
$user = User::findBy(User::createBuilder($sql, true) $user = User::findBy(User::createBuilder($sql, true)
->where(new Compare("User.name", $username), new Compare("User.email", $username)) ->where(new Compare("User.name", $username), new Compare("User.email", $username))
->whereEq("User.sso_provider", NULL)
->fetchEntities()); ->fetchEntities());
if ($user !== false) { if ($user !== false) {
@ -681,17 +682,8 @@ namespace Core\API\User {
if (!$user->confirmed) { if (!$user->confirmed) {
$this->result["emailConfirmed"] = false; $this->result["emailConfirmed"] = false;
return $this->createError("Your email address has not been confirmed yet."); 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 { } else {
$tfaToken = $user->getTwoFactorToken(); return $this->createSession($user, $stayLoggedIn);
$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;
} }
} else { } else {
return $this->createError(L('Wrong username or password')); return $this->createError(L('Wrong username or password'));
@ -1068,6 +1060,9 @@ namespace Core\API\User {
} else if ($user !== null) { } else if ($user !== null) {
if (!$user->isActive()) { if (!$user->isActive()) {
return $this->createError("This user is currently disabled. Contact the server administrator, if you believe this is a mistake."); return $this->createError("This user is currently disabled. Contact the server administrator, if you believe this is a mistake.");
} else if (!$user->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 { } else {
$validHours = 1; $validHours = 1;
$token = generateRandomString(36); $token = generateRandomString(36);
@ -1234,7 +1229,9 @@ namespace Core\API\User {
} }
$user = $token->getUser(); $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; return false;
} else { } else {
$user->password = $this->hashPassword($password); $user->password = $this->hashPassword($password);
@ -1301,7 +1298,9 @@ namespace Core\API\User {
} }
if ($newPassword !== null || $newPasswordConfirm !== null) { 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; return false;
} else { } else {
if (!password_verify($oldPassword, $currentUser->password)) { if (!password_verify($oldPassword, $currentUser->password)) {

@ -15,12 +15,8 @@ class Configuration {
$this->settings = Settings::loadDefaults(); $this->settings = Settings::loadDefaults();
$className = self::className; $className = self::className;
$path = getClassPath($className, ".class"); if (isClass($className)) {
if (file_exists($path) && is_readable($path)) { $this->database = new $className();
include_once $path;
if (class_exists($className)) {
$this->database = new $className();
}
} }
} }
@ -32,7 +28,7 @@ class Configuration {
return $this->settings; return $this->settings;
} }
public static function create(string $className, $data) { public static function create(string $className, $data): bool {
$path = getClassPath($className); $path = getClassPath($className);
$classNameShort = explode("\\", $className); $classNameShort = explode("\\", $className);
$classNameShort = end($classNameShort); $classNameShort = end($classNameShort);
@ -86,7 +82,7 @@ class Configuration {
$code = "<?php"; $code = "<?php";
} }
return @file_put_contents($path, $code); return @file_put_contents($path, $code) !== false;
} }
public function delete(string $className): bool { public function delete(string $className): bool {

@ -4,8 +4,12 @@ namespace Core\Configuration;
use Core\API\Request; use Core\API\Request;
use Core\Driver\Logger\Logger; use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Column\IntColumn;
use Core\Driver\SQL\Query\CreateTable; use Core\Driver\SQL\Query\CreateTable;
use Core\Driver\SQL\SQL; 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 Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
use PHPUnit\Util\Exception; use PHPUnit\Util\Exception;
@ -45,24 +49,11 @@ class CreateDatabase {
->primaryKey("method") ->primaryKey("method")
->addBool("is_core", false); ->addBool("is_core", false);
self::loadEntityLog($sql, $queries);
self::loadDefaultACL($sql, $queries); self::loadDefaultACL($sql, $queries);
self::loadPatches($sql, $queries);
return $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 { private static function getCreatedTables(SQL $sql, array $queries): ?array {
$createdTables = $sql->listTables(); $createdTables = $sql->listTables();
@ -184,4 +175,47 @@ class CreateDatabase {
$queries[] = $query; $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 <?php
use Core\Configuration\CreateDatabase;
use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Strategy\UpdateStrategy; use Core\Driver\SQL\Strategy\UpdateStrategy;
use Core\Objects\DatabaseEntity\Group;
$queries[] = $sql->insert("Settings", ["name", "value", "private", "readonly"]) $queries[] = $sql->insert("Settings", ["name", "value", "private", "readonly"])
->onDuplicateKeyStrategy(new UpdateStrategy( ->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_gpg_key_id", null, false, true)
->addRow("mail_contact", "''", false, false); ->addRow("mail_contact", "''", false, false);
$queries[] = $sql->insert("ApiPermission", ["method", "groups", "description", "is_core"]) CreateDatabase::loadDefaultACL($sql, $queries, [
->onDuplicateKeyStrategy(new UpdateStrategy( \Core\API\Settings\ImportGPG::class,
["method"], \Core\API\Settings\RemoveGPG::class
["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);

@ -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 { namespace Documents\Install {
use Core\Configuration\Configuration; use Core\Configuration\Configuration;
use Core\Configuration\CreateDatabase;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\Elements\Body; use Core\Elements\Body;
use Core\Elements\Head; use Core\Elements\Head;
@ -384,7 +383,14 @@ namespace Documents\Install {
$msg = ""; $msg = "";
$success = true; $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 { try {
$sql->startTransaction(); $sql->startTransaction();
foreach ($queries as $query) { foreach ($queries as $query) {

@ -507,6 +507,18 @@ class MySQL extends SQL {
return null; 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 { class RowIteratorMySQL extends RowIterator {

@ -116,8 +116,17 @@ class AlterTable extends Query {
} }
} }
} else if ($action === "ADD") { } else if ($action === "ADD") {
$query .= "CONSTRAINT "; $constraintName = $constraint->getName();
$query .= $this->sql->getConstraintDefinition($constraint);
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") { } else if ($action === "MODIFY") {
$this->sql->setLastError("MODIFY CONSTRAINT foreign key is not supported."); $this->sql->setLastError("MODIFY CONSTRAINT foreign key is not supported.");
return null; return null;

@ -10,13 +10,13 @@ use Core\Objects\DatabaseEntity\Controller\NMRelation;
class Group extends DatabaseEntity { class Group extends DatabaseEntity {
const ADMIN = 1; const ADMIN = 1;
const MODERATOR = 3;
const SUPPORT = 2; const SUPPORT = 2;
const MODERATOR = 3;
const GROUPS = [ const GROUPS = [
self::ADMIN => "Administrator", self::ADMIN => "Administrator",
self::MODERATOR => "Moderator",
self::SUPPORT => "Support", self::SUPPORT => "Support",
self::MODERATOR => "Moderator",
]; ];
#[MaxLength(32)] public string $name; #[MaxLength(32)] public string $name;

@ -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 { class User extends DatabaseEntity {
#[MaxLength(32)] #[Unique] public string $name; #[MaxLength(32)]
#[Unique]
public string $name;
#[MaxLength(128)] #[MaxLength(128)]
#[Visibility(Visibility::NONE)] #[Visibility(Visibility::NONE)]
public string $password; public ?string $password;
#[MaxLength(64)] #[MaxLength(64)]
public string $fullName; public string $fullName;
@ -60,8 +62,12 @@ class User extends DatabaseEntity {
#[Multiple(Group::class)] #[Multiple(Group::class)]
public array $groups; public array $groups;
public ?SsoProvider $ssoProvider;
public function __construct(?int $id = null) { public function __construct(?int $id = null) {
parent::__construct($id); parent::__construct($id);
$this->twoFactorToken = null;
$this->gpgKey = null;
} }
public function getUsername(): string { public function getUsername(): string {
@ -166,4 +172,8 @@ class User extends DatabaseEntity {
)->from("User")->whereEq("User.id", new Column($joinColumn)), )->from("User")->whereEq("User.id", new Column($joinColumn)),
$alias); $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 { public function call(Router $router, array $params): string {
header("Location: " . $this->getDestination()); $router->redirect($this->code, $this->getDestination());
http_response_code($this->code);
return ""; return "";
} }

@ -176,4 +176,9 @@ class RouterCache extends Router {
return $this->routes; return $this->routes;
} }
public function redirect(int $code, string $location): void {
header("Location: " . $location);
http_response_code($code);
}
} }

@ -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.
}
}

@ -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.
}
}

@ -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 Core\Objects\DatabaseEntity\Attribute\Transient;
use Cose\Algorithm\Signature\ECDSA\ECSignature; use Cose\Algorithm\Signature\ECDSA\ECSignature;
use Core\Objects\DatabaseEntity\TwoFactorToken; use Core\Objects\DatabaseEntity\TwoFactorToken;
use Cose\Key\Key;
class KeyBasedTwoFactorToken extends TwoFactorToken { class KeyBasedTwoFactorToken extends TwoFactorToken {

@ -10,7 +10,7 @@ if (is_file($autoLoad)) {
require_once $autoLoad; require_once $autoLoad;
} }
const WEBBASE_VERSION = "2.4.5"; const WEBBASE_VERSION = "2.5.0-dev";
spl_autoload_extensions(".php"); spl_autoload_extensions(".php");
spl_autoload_register(function ($class) { spl_autoload_register(function ($class) {
@ -345,4 +345,20 @@ function loadEnv(?string $file = NULL, bool $putEnv = false): array|null {
} }
return $env; 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

@ -122,19 +122,27 @@ function handleDatabase(array $argv): void {
} }
$success = true; $success = true;
$autoCommit = false;
$queryCount = count($queries); $queryCount = count($queries);
$logger = new \Core\Driver\Logger\Logger("CLI", $sql); $logger = new \Core\Driver\Logger\Logger("CLI", $sql);
$logger->info("Migrating DB with: " . $fileName); $logger->info("Migrating DB with: " . $fileName);
printLine("Executing $queryCount queries"); printLine("Executing $queryCount queries");
$sql->startTransaction(); if (!$sql->startTransaction()) {
$logger->warning("Could not start transaction: " . $sql->getLastError());
$autoCommit = true;
}
$queryIndex = 1; $queryIndex = 1;
foreach ($queries as $query) { foreach ($queries as $query) {
if ($query->execute() === false) { if ($query->execute() === false) {
$success = false; $success = false;
printLine("Error executing query: " . $sql->getLastError());
$logger->error("Error while migrating db: " . $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; break;
} else { } else {
printLine("$queryIndex/$queryCount: success!"); printLine("$queryIndex/$queryCount: success!");
@ -142,8 +150,10 @@ function handleDatabase(array $argv): void {
} }
} }
if ($success) { if ($success && !$autoCommit) {
$sql->commit(); if (!$sql->commit()) {
$logger->warning("Could not commit: " . $sql->getLastError());
}
} }
printLine("Done."); printLine("Done.");
@ -964,7 +974,7 @@ namespace Site\API {
namespace Site\API\\$apiName { namespace Site\API\\$apiName {
use Core\Objects\Context; use Core\Objects\Context;
use Site\API\TestAPI; use Site\API\${apiName}API;
$methods $methods
}"; }";

@ -10,6 +10,7 @@ use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntityHandler; use Core\Objects\DatabaseEntity\Controller\DatabaseEntityHandler;
use Core\Objects\DatabaseEntity\User; use Core\Objects\DatabaseEntity\User;
// FIXME: Tests must be run in specific order (create, insert, drop)
class DatabaseEntityTest extends \PHPUnit\Framework\TestCase { class DatabaseEntityTest extends \PHPUnit\Framework\TestCase {
static User $USER; static User $USER;

@ -152,5 +152,12 @@ class ParameterTest extends \PHPUnit\Framework\TestCase {
$this->assertTrue($integerRegex->parseParam(12)); $this->assertTrue($integerRegex->parseParam(12));
$this->assertFalse($integerRegex->parseParam("012")); $this->assertFalse($integerRegex->parseParam("012"));
$this->assertFalse($integerRegex->parseParam("1.2")); $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"));
} }
} }