Compare commits

...

8 Commits

37 changed files with 1113 additions and 178 deletions

@ -9,7 +9,7 @@ namespace Core\API {
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
$this->loginRequired = true;
$this->loginRequirements = Request::LOGGED_IN;
}
protected function fetchAPIKey(int $apiKeyId): ApiKey|bool {

@ -7,7 +7,7 @@ namespace Core\API {
abstract class GpgKeyAPI extends \Core\API\Request {
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
$this->loginRequired = true;
$this->loginRequirements = Request::LOGGED_IN;
}
}
}
@ -34,7 +34,6 @@ namespace Core\API\GpgKey {
parent::__construct($context, $externalCall, [
"publicKey" => new StringType("publicKey")
]);
$this->loginRequired = true;
$this->forbidMethod("GET");
}
@ -125,7 +124,6 @@ namespace Core\API\GpgKey {
parent::__construct($context, $externalCall, array(
"password" => new StringType("password")
));
$this->loginRequired = true;
$this->forbidMethod("GET");
}
@ -159,7 +157,6 @@ namespace Core\API\GpgKey {
parent::__construct($context, $externalCall, [
"token" => new StringType("token", 36)
]);
$this->loginRequired = true;
}
public function _execute(): bool {
@ -209,7 +206,6 @@ namespace Core\API\GpgKey {
"id" => new Parameter("id", Parameter::TYPE_INT, true, null),
"format" => new StringType("format", 16, true, "ascii")
));
$this->loginRequired = true;
$this->csrfTokenRequired = false;
}

@ -18,6 +18,7 @@ namespace Core\API\Language {
use Core\API\Parameter\Parameter;
use Core\API\Parameter\RegexType;
use Core\API\Parameter\StringType;
use Core\API\Request;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondOr;
use Core\Objects\Context;
@ -126,7 +127,7 @@ namespace Core\API\Language {
"modules" => new ArrayType("modules", Parameter::TYPE_STRING, true, false),
"compression" => new StringType("compression", -1, true, NULL, ["gzip", "zlib"])
]);
$this->loginRequired = false;
$this->loginRequirements = Request::NONE;
$this->csrfTokenRequired = false;
}

@ -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;
@ -13,6 +14,11 @@ use PhpMqtt\Client\MqttClient;
// TODO: many things are only checked for external calls, e.g. loginRequired. If we call the API internally, we might get null-pointers for $context->user
abstract class Request {
// Login Requirements
const NONE = 0;
const LOGGED_IN = 1;
const NOT_LOGGED_IN = 2;
protected Context $context;
protected Logger $logger;
protected array $params;
@ -20,7 +26,7 @@ abstract class Request {
protected array $result;
protected bool $success;
protected bool $isPublic;
protected bool $loginRequired;
protected int $loginRequirements;
protected bool $variableParamCount;
protected bool $isDisabled;
protected bool $apiKeyAllowed;
@ -46,9 +52,9 @@ abstract class Request {
// restrictions
$this->isPublic = true;
$this->isDisabled = false;
$this->loginRequired = false;
$this->loginRequirements = self::NONE;
$this->apiKeyAllowed = true;
$this->allowedMethods = array("GET", "POST");
$this->allowedMethods = ["GET", "POST"];
$this->csrfTokenRequired = true;
$this->rateLimiting = null;
}
@ -144,6 +150,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
@ -253,8 +275,8 @@ abstract class Request {
}
}
// Logged in or api key authorized?
if ($this->loginRequired) {
if ($this->loginRequirements === self::LOGGED_IN) {
// Logged in or api key authorized?
if (!$session && !$apiKeyAuthorized) {
$this->lastError = 'You are not logged in.';
$this->result["loggedIn"] = false;
@ -264,6 +286,12 @@ abstract class Request {
http_response_code(401);
return false;
}
} else if ($this->loginRequirements === self::NOT_LOGGED_IN) {
// Request only for unauthenticated users, e.g. login endpoint
if ($session || $apiKeyAuthorized) {
$this->lastError = "You are already logged in.";
return false;
}
}
// CSRF Token
@ -366,7 +394,7 @@ abstract class Request {
}
public function loginRequired(): bool {
return $this->loginRequired;
return $this->loginRequirements === self::LOGGED_IN;
}
public function isExternalCall(): bool {

260
Core/API/SsoAPI.class.php Normal file

@ -0,0 +1,260 @@
<?php
namespace Core\API {
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\SsoProvider;
use Core\Objects\DatabaseEntity\SsoRequest;
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(SsoRequest $ssoRequest, User $user): bool {
$sql = $this->context->getSQL();
$provider = $ssoRequest->getProvider();
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());
}
} else 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->getSsoProvider()?->getIdentifier() !== $provider->getIdentifier()) {
return $this->createError("An existing user is not managed by the used identity provider");
}
// Create the session and log them in
if (!$this->createSession($user)) {
return false;
}
$ssoRequest->invalidate($sql, $this->context->getSession());
$redirectUrl = $ssoRequest->getRedirectUrl();
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\API\Request;
use Core\Objects\Context;
use Core\API\SsoAPI;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\SsoProvider;
use Core\Objects\RateLimiting;
use Core\Objects\RateLimitRule;
use Core\Objects\SSO\SAMLResponse;
class GetProviders extends SsoAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, []);
$this->csrfTokenRequired = false;
}
protected function _execute(): bool {
$sql = $this->context->getSQL();
$query = SsoProvider::createBuilder($sql, false);
$user = $this->context->getUser();
$canEdit = false;
if (!$user) {
// only show active providers, when not logged in
$query->whereTrue("active");
} else {
$req = new \Core\API\Permission\Check($this->context);
$canEdit = $req->execute(["method" => "sso/editProvider"]);
}
// show all properties, if a user is allowed to edit them
$providers = SsoProvider::findBy($query);
$properties = $canEdit ? null : [
"id",
"identifier",
"name",
"protocol"
];
$this->result["providers"] = SsoProvider::toJsonArray($providers, $properties);
return true;
}
public static function getDescription(): string {
// TODO: auto generated endpoint description
return "Allows users to get a list of SSO providers. Unauthenticated users will only see active providers.";
}
}
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;
$this->loginRequirements = Request::NOT_LOGGED_IN;
$this->rateLimiting = new RateLimiting(
new RateLimitRule(5, 1, RateLimitRule::MINUTE)
);
}
protected function _execute(): bool {
$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")
]);
$this->csrfTokenRequired = false;
$this->loginRequirements = Request::NOT_LOGGED_IN;
$this->forbidMethod("GET");
$this->rateLimiting = new RateLimiting(
new RateLimitRule(15, 1, RateLimitRule::MINUTE)
);
}
protected function _execute(): bool {
$sql = $this->context->getSQL();
$samlResponse = base64_decode($this->getParam("SAMLResponse"));
$parsedResponse = SAMLResponse::parseResponse($this->context, $samlResponse);
$ssoRequest = $parsedResponse->getRequest();
if (!$parsedResponse->wasSuccessful()) {
$ssoRequest?->invalidate($sql);
return $this->createError("Error parsing SAMLResponse: " . $parsedResponse->getError());
} else if (!$this->processLogin($ssoRequest, $parsedResponse->getUser())) {
$ssoRequest->invalidate($sql);
return false;
} else {
return true;
}
}
public static function getDescription(): string {
return "Return endpoint for SAML SSO authentication.";
}
public static function hasConfigurablePermissions(): bool {
return false;
}
}
}

@ -12,7 +12,7 @@ namespace Core\API {
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
$this->loginRequired = true;
$this->loginRequirements = Request::LOGGED_IN;
$this->apiKeyAllowed = false;
$this->userVerificationRequired = false;
}
@ -148,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->isLocalAccount()) {
return $this->createError("Cannot add a 2FA token: Your account is managed by an external identity provider (SSO)");
} else if (!($twoFactorToken instanceof TimeBasedTwoFactorToken)) {
$sql = $this->context->getSQL();
$twoFactorToken = new TimeBasedTwoFactorToken(generateRandomString(32, "base32"));
@ -177,7 +179,6 @@ namespace Core\API\TFA {
class ConfirmTotp extends VerifyTotp {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall);
$this->loginRequired = true;
}
public function _execute(): bool {
@ -214,7 +215,6 @@ namespace Core\API\TFA {
parent::__construct($context, $externalCall, [
"code" => new StringType("code", 6)
]);
$this->loginRequired = true;
$this->csrfTokenRequired = false;
$this->rateLimiting = new RateLimiting(
null,
@ -253,12 +253,15 @@ namespace Core\API\TFA {
"clientDataJSON" => new StringType("clientDataJSON", 0, true, "{}"),
"attestationObject" => new StringType("attestationObject", 0, true, "")
]);
$this->loginRequired = true;
}
public function _execute(): bool {
$currentUser = $this->context->getUser();
if (!$currentUser->isLocalAccount()) {
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();
@ -351,7 +354,6 @@ namespace Core\API\TFA {
"authData" => new StringType("authData"),
"signature" => new StringType("signature"),
]);
$this->loginRequired = true;
$this->csrfTokenRequired = false;
$this->rateLimiting = new RateLimiting(
null,

@ -148,6 +148,7 @@ namespace Core\API\User {
use Core\API\Parameter\IntegerType;
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\API\Request;
use Core\API\Template\Render;
use Core\API\Traits\Captcha;
use Core\API\Traits\Pagination;
@ -184,7 +185,7 @@ namespace Core\API\User {
'groups' => new ArrayType("groups", Parameter::TYPE_INT, true, true, [])
));
$this->loginRequired = true;
$this->loginRequirements = Request::LOGGED_IN;
}
public function _execute(): bool {
@ -304,7 +305,7 @@ namespace Core\API\User {
parent::__construct($context, $externalCall, array(
'id' => new Parameter('id', Parameter::TYPE_INT)
));
$this->loginRequired = true;
$this->loginRequirements = Request::LOGGED_IN;
}
public function _execute(): bool {
@ -447,7 +448,7 @@ namespace Core\API\User {
'groups' => new ArrayType("groups", Parameter::TYPE_INT, true, true, [])
));
$this->loginRequired = true;
$this->loginRequirements = Request::LOGGED_IN;
}
public function _execute(): bool {
@ -545,14 +546,10 @@ namespace Core\API\User {
'confirmPassword' => new StringType('confirmPassword'),
));
$this->csrfTokenRequired = false;
$this->loginRequirements = Request::NOT_LOGGED_IN;
}
public function _execute(): bool {
if ($this->context->getUser()) {
return $this->createError("You are already logged in.");
}
$sql = $this->context->getSQL();
$token = $this->getParam("token");
$password = $this->getParam("password");
@ -593,17 +590,13 @@ namespace Core\API\User {
'token' => new StringType('token', 36)
));
$this->csrfTokenRequired = false;
$this->loginRequirements = Request::NOT_LOGGED_IN;
$this->rateLimiting = new RateLimiting(
new RateLimitRule(5, 1, RateLimitRule::MINUTE)
);
}
public function _execute(): bool {
if ($this->context->getUser()) {
return $this->createError("You are already logged in.");
}
$sql = $this->context->getSQL();
$token = $this->getParam("token");
$userToken = $this->checkToken($token);
@ -670,6 +663,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_id", NULL)
->fetchEntities());
if ($user !== false) {
@ -681,17 +675,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'));
@ -716,7 +701,6 @@ namespace Core\API\User {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall);
$this->loginRequired = false;
$this->apiKeyAllowed = false;
$this->forbidMethod("GET");
}
@ -758,14 +742,10 @@ namespace Core\API\User {
$this->addCaptchaParameters($context, $parameters);
parent::__construct($context, $externalCall, $parameters);
$this->csrfTokenRequired = false;
$this->loginRequirements = Request::NOT_LOGGED_IN;
}
public function _execute(): bool {
if ($this->context->getUser()) {
return $this->createError(L('You are already logged in'));
}
$settings = $this->context->getSettings();
$registrationAllowed = $settings->isRegistrationAllowed();
if (!$registrationAllowed) {
@ -856,7 +836,7 @@ namespace Core\API\User {
class Edit extends UserAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
parent::__construct($context, $externalCall, [
'id' => new Parameter('id', Parameter::TYPE_INT),
'username' => new StringType('username', 32, true, NULL),
'fullName' => new StringType('fullName', 64, true, NULL),
@ -865,10 +845,10 @@ namespace Core\API\User {
'groups' => new ArrayType('groups', Parameter::TYPE_INT, true, true, NULL),
'confirmed' => new Parameter('confirmed', Parameter::TYPE_BOOLEAN, true, NULL),
'active' => new Parameter('active', Parameter::TYPE_BOOLEAN, true, NULL)
));
]);
$this->loginRequired = true;
$this->forbidMethod("GET");
$this->loginRequirements = Request::LOGGED_IN;
}
public function _execute(): bool {
@ -899,12 +879,12 @@ namespace Core\API\User {
return $this->createError("Cannot remove Administrator group from own user.");
} else if (in_array(Group::ADMIN, $groupIds) && !$currentUser->hasGroup(Group::ADMIN)) {
return $this->createError("You cannot add the administrator group to other users.");
}
$availableGroups = Group::findAll($sql, new CondIn(new Column("id"), $groupIds));
foreach ($groupIds as $groupId) {
if (!isset($availableGroups[$groupId])) {
return $this->createError("Group with id=$groupId does not exist.");
} else if (!empty($groups)) {
$availableGroups = Group::findAll($sql, new CondIn(new Column("id"), $groupIds));
foreach ($groupIds as $groupId) {
if (!isset($availableGroups[$groupId])) {
return $this->createError("Group with id=$groupId does not exist.");
}
}
}
@ -938,8 +918,12 @@ namespace Core\API\User {
}
if (!is_null($password)) {
$user->password = $this->hashPassword($password);
$columnsToUpdate[] = "password";
if ($user->isLocalAccount()) {
$user->password = $this->hashPassword($password);
$columnsToUpdate[] = "password";
} else {
return $this->createError("Cannot change password of an externally managed user account.");
}
}
if (!is_null($confirmed)) {
@ -988,7 +972,7 @@ namespace Core\API\User {
'id' => new Parameter('id', Parameter::TYPE_INT)
));
$this->loginRequired = true;
$this->loginRequirements = Request::LOGGED_IN;
}
public function _execute(): bool {
@ -1068,6 +1052,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->isLocalAccount()) {
// TODO: this allows user enumeration for SSO accounts
return $this->createError("Cannot request a password reset: Account is managed by an external identity provider (SSO)");
} else {
$validHours = 1;
$token = generateRandomString(36);
@ -1211,17 +1198,13 @@ namespace Core\API\User {
$this->forbidMethod("GET");
$this->csrfTokenRequired = false;
$this->apiKeyAllowed = false;
$this->loginRequirements = Request::NOT_LOGGED_IN;
$this->rateLimiting = new RateLimiting(
new RateLimitRule(5, 1, RateLimitRule::MINUTE)
);
}
public function _execute(): bool {
if ($this->context->getUser()) {
return $this->createError("You are already logged in.");
}
$sql = $this->context->getSQL();
$token = $this->getParam("token");
$password = $this->getParam("password");
@ -1233,8 +1216,10 @@ namespace Core\API\User {
return $this->createError("Invalid token type");
}
$user = $token->getUser();
if (!$this->checkPasswordRequirements($password, $confirmPassword)) {
$user = $userToken->getUser();
if (!$user->isLocalAccount()) {
return $this->createError("Cannot reset password: Your account is managed by an external identity provider (SSO)");
} else if (!$this->checkPasswordRequirements($password, $confirmPassword)) {
return false;
} else {
$user->password = $this->hashPassword($password);
@ -1264,7 +1249,7 @@ namespace Core\API\User {
'confirmPassword' => new StringType('confirmPassword', -1, true, NULL),
'oldPassword' => new StringType('oldPassword', -1, true, NULL),
));
$this->loginRequired = true;
$this->loginRequirements = Request::LOGGED_IN;
$this->csrfTokenRequired = true;
$this->apiKeyAllowed = false; // prevent account takeover when an API-key is stolen
$this->forbidMethod("GET");
@ -1301,7 +1286,9 @@ namespace Core\API\User {
}
if ($newPassword !== null || $newPasswordConfirm !== null) {
if (!$this->checkPasswordRequirements($newPassword, $newPasswordConfirm)) {
if (!$currentUser->isLocalAccount()) {
return $this->createError("Cannot change password: Your account is managed by an external identity provider (SSO)");
} else if (!$this->checkPasswordRequirements($newPassword, $newPasswordConfirm)) {
return false;
} else {
if (!password_verify($oldPassword, $currentUser->password)) {
@ -1339,7 +1326,7 @@ namespace Core\API\User {
"y" => new FloatType("y", 0, PHP_FLOAT_MAX, true, NULL),
"size" => new FloatType("size", self::MIN_SIZE, self::MAX_SIZE, true, NULL),
]);
$this->loginRequired = true;
$this->loginRequirements = Request::LOGGED_IN;
$this->forbidMethod("GET");
}
@ -1416,7 +1403,7 @@ namespace Core\API\User {
class RemovePicture extends UserAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, []);
$this->loginRequired = true;
$this->loginRequirements = Request::LOGGED_IN;
}
public function _execute(): bool {
@ -1492,7 +1479,7 @@ namespace Core\API\User {
parent::__construct($context, $externalCall, [
"active" => new Parameter("active", Parameter::TYPE_BOOLEAN, true, true)
]);
$this->loginRequired = true;
$this->loginRequirements = Request::LOGGED_IN;
}
protected function _execute(): bool {
@ -1536,7 +1523,7 @@ namespace Core\API\User {
parent::__construct($context, $externalCall, [
"id" => new Parameter("id", Parameter::TYPE_INT)
]);
$this->loginRequired = true;
$this->loginRequirements = Request::LOGGED_IN;
}
protected function _execute(): bool {

@ -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
]);

@ -0,0 +1,39 @@
<?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\SsoRequest;
use Core\Objects\DatabaseEntity\User;
$userHandler = User::getHandler($sql);
$ssoProviderHandler = SsoProvider::getHandler($sql);
$ssoRequestHandler = SsoRequest::getHandler($sql);
$userTable = $userHandler->getTableName();
$ssoProviderTable = $ssoProviderHandler->getTableName();
$ssoProviderColumn = $userHandler->getColumnName("ssoProvider", false);
$passwordColumn = $userHandler->getColumnName("password");
// create new tables
$queries = array_merge(
$queries,
$ssoProviderHandler->getCreateQueries($sql),
$ssoRequestHandler->getCreateQueries($sql)
);
// add sso column to user table
$queries[] = $sql->alterTable($userTable)
->add(new IntColumn($ssoProviderColumn, true,null));
// make password nullable for SSO-login
$queries[] = $sql->alterTable($userTable)
->modify(new StringColumn($passwordColumn, 128,true));
// create foreign key constraint for sso column
$constraint = new ForeignKey($ssoProviderColumn, $ssoProviderTable, "id", new CascadeStrategy());
$constraint->setName("${userTable}_ibfk_$ssoProviderColumn");
$queries[] = $sql->alterTable($userTable)
->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) {

@ -92,7 +92,7 @@ class Logger {
// database logging failed, try to log to file
$module = preg_replace("/[^a-zA-Z0-9-]/", "-", $this->module);
$date = (\DateTime::createFromFormat('U.u', microtime(true)))->format(self::LOG_FILE_DATE_FORMAT);
$logFile = implode("_", [$module, $severity, $date]) . ".log";
$logFile = implode("_", [$date, $module, $severity]) . ".log";
$logPath = implode(DIRECTORY_SEPARATOR, [WEBROOT, "Site", "Logs", $logFile]);
@file_put_contents($logPath, $message);
}

@ -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;

@ -89,13 +89,12 @@ class Context {
if ($this->sql->isConnected()) {
$settings = $this->configuration->getSettings();
$settings->loadFromDatabase($this);
return $this->sql;
}
} else {
$this->sql = null;
}
return null;
return $this->sql;
}
public function getSQL(): ?SQL {

@ -168,7 +168,7 @@ abstract class DatabaseEntity implements ArrayAccess, JsonSerializable {
return $dbQuery->execute();
}
public static function findAll(SQL $sql, ?Condition $condition = null): ?array {
public static function findAll(SQL $sql, ?Condition $condition = null): array|bool|null {
$query = self::createBuilder($sql, false);
if ($condition) {

@ -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;

@ -0,0 +1,127 @@
<?php
namespace Core\Objects\DatabaseEntity;
use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\CondIn;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
use Core\Objects\DatabaseEntity\Attribute\ExtendingEnum;
use Core\Objects\DatabaseEntity\Attribute\Json;
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;
#[MaxLength(256)]
protected string $ssoUrl;
#[MaxLength(128)]
protected string $clientId;
#[Json]
#[DefaultValue('{}')]
protected array $groupMapping;
protected string $certificate;
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 function getGroupMapping(): array {
return $this->groupMapping;
}
public function createUser(Context $context, string $email, array $groupNames) : User {
$sql = $context->getSQL();
$loggerName = "SSO-" . strtoupper($this->protocol);
$logger = new Logger($loggerName, $sql);
if (empty($groupNames)) {
$groups = [];
} else {
$groups = Group::findAll($sql, new CondIn(new Column("name"), $groupNames));
if ($groups === false) {
throw new \Exception("Error fetching available groups: " . $sql->getLastError());
} else if (count($groups) !== count($groupNames)) {
$availableGroups = array_map(function (Group $group) {
return $group->getName();
}, $groups);
$nonExistentGroups = array_diff($groupNames, $availableGroups);
$logger->warning("Could not resolve group names: " . implode(', ', $nonExistentGroups));
}
}
// TODO: create a possibility to map attribute values to user properties
$user = new User();
$user->email = $email;
$user->name = $email;
$user->password = null;
$user->fullName = "";
$user->ssoProvider = $this;
$user->confirmed = true;
$user->active = true;
$user->groups = $groups;
return $user;
}
public abstract function login(Context $context, ?string $redirectUrl);
}

@ -0,0 +1,83 @@
<?php
namespace Core\Objects\DatabaseEntity;
use Core\Driver\SQL\SQL;
use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Attribute\Unique;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
class SsoRequest extends DatabaseEntity {
const SSO_REQUEST_DURABILITY = 15; // in minutes
// auto-delete sso requests after 30 days after creation
protected static array $entityLogConfig = [
"update" => false,
"delete" => true,
"insert" => true,
"lifetime" => 30
];
#[MaxLength(128)]
#[Unique]
private string $identifier;
private SsoProvider $ssoProvider;
private ?Session $session;
private \DateTime $validUntil;
#[DefaultValue(false)]
private bool $used;
private ?string $redirectUrl;
public static function create(SQL $sql, SsoProvider $ssoProvider, ?string $redirectUrl): ?SsoRequest {
$request = new SsoRequest();
$request->identifier = uuidv4();
$request->ssoProvider = $ssoProvider;
$request->used = false;
$request->session = null;
$request->validUntil = (new \DateTime())->modify(sprintf('+%d minutes', self::SSO_REQUEST_DURABILITY));
$request->redirectUrl = $redirectUrl;
if ($request->save($sql)) {
return $request;
} else {
return null;
}
}
public function getIdentifier() : string {
return $this->identifier;
}
public function getRedirectUrl() : ?string {
return $this->redirectUrl;
}
public function wasUsed() : bool {
return $this->used;
}
public function isValid() : bool {
return !isInPast($this->validUntil);
}
public function getProvider() : SsoProvider {
return $this->ssoProvider;
}
public function invalidate(SQL $sql, ?Session $session = null) : bool {
$this->used = true;
if ($session) {
$this->session = $session;
return $this->save($sql, ["used", "session"]);
} else {
return $this->save($sql, ["used"]);
}
}
}

@ -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,12 @@ class User extends DatabaseEntity {
)->from("User")->whereEq("User.id", new Column($joinColumn)),
$alias);
}
public function isLocalAccount(): bool {
return $this->ssoProvider === null;
}
public function getSsoProvider(): ?SsoProvider {
return $this->ssoProvider;
}
}

@ -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);
}
}

@ -0,0 +1,204 @@
<?php
namespace Core\Objects\SSO;
use Core\Driver\SQL\Condition\Compare;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\SsoProvider;
use Core\Objects\DatabaseEntity\SsoRequest;
use Core\Objects\DatabaseEntity\User;
use DOMDocument;
class SAMLResponse {
private bool $success;
private string $error;
private ?User $user;
private ?SsoRequest $request;
private function __construct() {
}
private static function createSuccess(SsoRequest $request, User $user) : SAMLResponse {
$response = new SAMLResponse();
$response->user = $user;
$response->request = $request;
$response->success = true;
return $response;
}
private static function createError(?SsoRequest $request, string $error) : SAMLResponse {
$response = new SAMLResponse();
$response->error = $error;
$response->request = $request;
$response->success = false;
return $response;
}
private static function findSignatureNode(\DOMNode $node) : ?\DOMNode {
foreach ($node->childNodes as $child) {
if ($child->nodeName === 'dsig:Signature') {
return $child;
}
}
return null;
}
private static function parseSignatureAlgorithm($name) : ?int {
return match ($name) {
'http://www.w3.org/2000/09/xmldsig#sha1' => OPENSSL_ALGO_SHA1,
'http://www.w3.org/2001/04/xmlenc#sha256' => OPENSSL_ALGO_SHA256,
'http://www.w3.org/2001/04/xmldsig-more#sha384' => OPENSSL_ALGO_SHA384,
'http://www.w3.org/2001/04/xmlenc#sha512' => OPENSSL_ALGO_SHA512,
'http://www.w3.org/2001/04/xmlenc#ripemd160' => OPENSSL_ALGO_RMD160,
'http://www.w3.org/2001/04/xmldsig-more#md5' => OPENSSL_ALGO_MD5,
default => throw new \Exception("Unsupported digest algorithm: $name"),
};
}
private static function verifyNodeSignature(SsoProvider $provider, \DOMNode $signatureNode): void {
$signedInfoNode = $signatureNode->getElementsByTagName('SignedInfo')->item(0);
if (!$signedInfoNode) {
throw new \Exception("SignedInfo not found in the Signature element.");
}
$signedInfo = $signedInfoNode->C14N(true, false);
$signatureValueNode = $signatureNode->getElementsByTagName('SignatureValue')->item(0);
if (!$signatureValueNode) {
throw new \Exception("SignatureValue not found in the Signature element.");
}
$digestMethodNode = $signatureNode->getElementsByTagName('DigestMethod')->item(0);
if (!$digestMethodNode) {
throw new \Exception("DigestMethod not found in the Signature element.");
}
$algorithm = self::parseSignatureAlgorithm($digestMethodNode->getAttribute("Algorithm"));
$signatureValue = base64_decode($signatureValueNode->nodeValue);
if (!$provider->validateSignature($signedInfo, $signatureValue, $algorithm)) {
throw new \Exception("Invalid Signature.");
}
}
public static function parseResponse(Context $context, string $response) : SAMLResponse {
$sql = $context->getSQL();
$xml = new DOMDocument();
$xml->loadXML($response);
if ($xml->documentElement->nodeName !== "samlp:Response") {
return self::createError(null, "Invalid root node, expected: 'samlp:Response'");
}
$requestId = $xml->documentElement->getAttribute("InResponseTo");
if (empty($requestId)) {
return self::createError(null, "Root node missing attribute 'InResponseTo'");
}
$ssoRequest = SsoRequest::findBy(SsoRequest::createBuilder($sql, true)
->whereEq("SsoRequest.identifier", $requestId)
->fetchEntities()
);
if ($ssoRequest === false) {
return self::createError(null, "Error fetching SSO provider: " . $sql->getLastError());
} else if ($ssoRequest === null) {
return self::createError(null, "Request not found");
} else if ($ssoRequest->wasUsed()) {
return self::createError($ssoRequest, "SAMLResponse already processed");
} else if (!$ssoRequest->isValid()) {
return self::createError($ssoRequest, "Authentication request expired");
}
try {
$provider = $ssoRequest->getProvider();
if (!($provider instanceof SSOProviderSAML)) {
return self::createError($ssoRequest, "Authentication request was not a SAML request");
}
// Validate XML and extract user info
if (!$xml->getElementsByTagName("Assertion")->length) {
return self::createError($ssoRequest, "Assertion tag missing");
}
$assertion = $xml->getElementsByTagName('Assertion')->item(0);
//// <-- Signature Validation
$rootSignature = self::findSignatureNode($xml->documentElement);
$assertionSignature = self::findSignatureNode($assertion);
if ($rootSignature === null && $assertionSignature === null) {
return self::createError($ssoRequest, "Neither a document nor an assertion signature was present.");
}
if ($rootSignature !== null) {
self::verifyNodeSignature($provider, $rootSignature);
}
if ($assertionSignature !== null) {
self::verifyNodeSignature($provider, $assertionSignature);
}
//// Signature Validation -->
// Check status code
$statusCode = $xml->getElementsByTagName('StatusCode')->item(0);
if ($statusCode->getAttribute("Value") !== "urn:oasis:names:tc:SAML:2.0:status:Success") {
return self::createError(null, "StatusCode was not successful");
}
$groupMapping = $provider->getGroupMapping();
$email = $xml->getElementsByTagName('NameID')->item(0)->nodeValue;
$attributes = [];
$groupNames = [];
foreach ($xml->getElementsByTagName('Attribute') as $attribute) {
$name = $attribute->getAttribute('Name');
$value = $attribute->getElementsByTagName('AttributeValue')->item(0)->nodeValue;
if ($name === "Role") {
if (isset($groupMapping[$value])) {
$groupNames[] = $groupMapping[$value];
}
} else {
$attributes[$name] = $value;
}
}
$user = User::findBy(User::createBuilder($context->getSQL(), true)
->where(new Compare("User.email", $email), new Compare("User.name", $email))
->fetchEntities());
if ($user === false) {
return self::createError($ssoRequest, "Error fetching user: " . $sql->getLastError());
} else if ($user === null) {
$user = $ssoRequest->getProvider()->createUser($context, $email, $groupNames);
}
return self::createSuccess($ssoRequest, $user);
} catch (\Exception $ex) {
return self::createError($ssoRequest, $ex->getMessage());
}
}
public function wasSuccessful() : bool {
return $this->success;
}
public function getError() : string {
return $this->error;
}
public function getUser() : User {
return $this->user;
}
public function getRedirectURL() : ?string {
return $this->request->getRedirectUrl();
}
public function getProvider(): SSOProvider {
return $this->request->getProvider();
}
public function getRequest() : ?SsoRequest {
return $this->request;
}
}

@ -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,75 @@
<?php
namespace Core\Objects\SSO;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\SsoProvider;
use Core\Objects\DatabaseEntity\SsoRequest;
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) {
$sql = $context->getSQL();
$settings = $context->getSettings();
$baseUrl = $settings->getBaseUrl();
$ssoRequest = SsoRequest::create($sql, $this, $redirectUrl);
if (!$ssoRequest) {
throw new \Exception("Could not save SSO request: " . $sql->getLastError());
}
$acsUrl = $baseUrl . "/api/sso/saml";
$samlp = html_tag_ex("samlp:AuthnRequest", [
"xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol",
"xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion",
"ID" => $ssoRequest->getIdentifier(),
"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", [], $this->clientId), false);
$samlRequest = base64_encode($samlp);
$req = new \Core\API\Template\Render($context);
$success = $req->execute([
"file" => "sso.twig",
"parameters" => [
"sso" => [
"url" => $this->ssoUrl,
"data" => [
"SAMLRequest" => $samlRequest
]
]
]
]);
if (!$success) {
throw new \Exception("Could not redirect: " . $req->getLastError());
}
die($req->getResult()["html"]);
}
public function validateSignature(string $what, string $signature, int $algorithm) : bool {
$publicKey = openssl_pkey_get_public($this->certificate);
if (!$publicKey) {
throw new \Exception("Failed to load certificate: " . openssl_error_string());
}
$result = openssl_verify($what, $signature, $publicKey, $algorithm);
if ($result === 1) {
return true;
} else if ($result === 0) {
return false;
} else {
throw new \Exception("Failed to validate signature: " . openssl_error_string());
}
}
}

@ -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 {

19
Core/Templates/sso.twig Normal file

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

@ -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;
}
}
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,
]);
}

@ -112,7 +112,7 @@ php cli.php api add
By default, and if not further specified or restricted, all endpoints have the following access rules:
1. Allowed methods: GET and POST (`$this->allowedMethods`)
2. No login is required (`$this->loginRequired`)
2. No login is required (`$this->loginRequirements`)
3. CSRF-Token is required, if the user is logged in (`$this->csrfTokenRequired`)
4. The function can be called from outside (`$this->isPublic`)
5. An API-Key can be used to access this method (`$this->apiKeyAllowed`)

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"));
}
}

@ -199,6 +199,6 @@ class RequestDisabled extends TestRequest {
class RequestLoginRequired extends TestRequest {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, []);
$this->loginRequired = true;
$this->loginRequirements = Request::LOGGED_IN;
}
}