Compare commits
8 Commits
master
...
feature-ss
Author | SHA1 | Date | |
---|---|---|---|
5d28b5a9fa | |||
01c0f84272 | |||
ae0e37ebab | |||
ee9ab8b7f6 | |||
20e464776c | |||
9788289260 | |||
2861eaa9a9 | |||
50cc0fc5be |
@ -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;
|
||||
}
|
||||
|
||||
|
13
Core/API/Parameter/UuidType.class.php
Normal file
13
Core/API/Parameter/UuidType.class.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Core\API\Parameter;
|
||||
|
||||
class UuidType extends RegexType {
|
||||
|
||||
const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
||||
|
||||
public function __construct(string $name, bool $optional = FALSE, ?string $defaultValue = NULL) {
|
||||
parent::__construct($name, self::UUID_PATTERN, $optional, $defaultValue);
|
||||
}
|
||||
|
||||
}
|
@ -6,6 +6,7 @@ use Core\Driver\Logger\Logger;
|
||||
use Core\Driver\SQL\Query\Insert;
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\TwoFactorToken;
|
||||
use Core\Objects\DatabaseEntity\User;
|
||||
use Core\Objects\RateLimiting;
|
||||
use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
|
||||
use PhpMqtt\Client\MqttClient;
|
||||
@ -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
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
|
||||
]);
|
||||
|
39
Core/Configuration/Patch/2024-12-28_SSO-integration.php
Normal file
39
Core/Configuration/Patch/2024-12-28_SSO-integration.php
Normal file
@ -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;
|
||||
|
127
Core/Objects/DatabaseEntity/SsoProvider.class.php
Normal file
127
Core/Objects/DatabaseEntity/SsoProvider.class.php
Normal file
@ -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);
|
||||
}
|
83
Core/Objects/DatabaseEntity/SsoRequest.class.php
Normal file
83
Core/Objects/DatabaseEntity/SsoRequest.class.php
Normal file
@ -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);
|
||||
}
|
||||
}
|
204
Core/Objects/SSO/SAMLResponse.class.php
Normal file
204
Core/Objects/SSO/SAMLResponse.class.php
Normal file
@ -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;
|
||||
}
|
||||
|
||||
}
|
24
Core/Objects/SSO/SSOProviderOAuth2.class.php
Normal file
24
Core/Objects/SSO/SSOProviderOAuth2.class.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Core\Objects\SSO;
|
||||
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\SsoProvider;
|
||||
use Core\Objects\DatabaseEntity\User;
|
||||
|
||||
class SSOProviderOAuth2 extends SSOProvider {
|
||||
|
||||
const TYPE = "oauth2";
|
||||
|
||||
public function __construct(?int $id = null) {
|
||||
parent::__construct(self::TYPE, $id);
|
||||
}
|
||||
|
||||
public function login(Context $context, ?string $redirectUrl) {
|
||||
// TODO: Implement login() method.
|
||||
}
|
||||
|
||||
public function parseResponse(Context $context, string $response): ?User {
|
||||
// TODO: Implement parseResponse() method.
|
||||
}
|
||||
}
|
24
Core/Objects/SSO/SSOProviderOIDC.class.php
Normal file
24
Core/Objects/SSO/SSOProviderOIDC.class.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Core\Objects\SSO;
|
||||
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\SsoProvider;
|
||||
use Core\Objects\DatabaseEntity\User;
|
||||
|
||||
class SSOProviderOIDC extends SSOProvider {
|
||||
|
||||
const TYPE = "oidc";
|
||||
|
||||
public function __construct(?int $id = null) {
|
||||
parent::__construct(self::TYPE, $id);
|
||||
}
|
||||
|
||||
public function login(Context $context, ?string $redirectUrl) {
|
||||
|
||||
}
|
||||
|
||||
public function parseResponse(Context $context, string $response): ?User {
|
||||
// TODO: Implement parseResponse() method.
|
||||
}
|
||||
}
|
75
Core/Objects/SSO/SSOProviderSAML.class.php
Normal file
75
Core/Objects/SSO/SSOProviderSAML.class.php
Normal file
@ -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
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
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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user