Dev SSO: Tables, SAML

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

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

View File

@@ -6,6 +6,7 @@ use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\TwoFactorToken;
use Core\Objects\DatabaseEntity\User;
use Core\Objects\RateLimiting;
use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
use PhpMqtt\Client\MqttClient;
@@ -144,6 +145,22 @@ abstract class Request {
return true;
}
protected function createSession(User $user, bool $stayLoggedIn = true): bool {
$sql = $this->context->getSQL();
if (!($session = $this->context->createSession($user, $stayLoggedIn))) {
return $this->createError("Error creating Session: " . $sql->getLastError());
} else {
$tfaToken = $user->getTwoFactorToken();
$this->result["loggedIn"] = true;
$this->result["user"] = $user->jsonSerialize();
$this->result["session"] = $session->jsonSerialize(["expires", "csrfToken"]);
$this->result["logoutIn"] = $session->getExpiresSeconds();
$this->check2FA($tfaToken);
$this->success = true;
return true;
}
}
protected function check2FA(?TwoFactorToken $tfaToken = null): bool {
// do not require 2FA for verifying endpoints

242
Core/API/SsoAPI.class.php Normal file
View File

@@ -0,0 +1,242 @@
<?php
namespace Core\API {
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\User;
abstract class SsoAPI extends Request {
public function __construct(Context $context, bool $externalCall = false, array $params = []) {
parent::__construct($context, $externalCall, $params);
}
protected function processLogin(User $user, ?string $redirectUrl): bool {
$sql = $this->context->getSQL();
if ($user->getId() === null) {
// user didn't exit yet. try to insert into database
if (!$user->save($sql)) {
return $this->createError("Could not create user: " . $sql->getLastError());
}
}
if (!$this->createSession($user)) {
return false;
}
if (!empty($redirectUrl)) {
$this->context->router->redirect(302, $redirectUrl);
}
return true;
}
protected function validateRedirectURL(string $url): bool {
// allow only relative paths
return empty($url) || startsWith($url, "/");
}
}
}
namespace Core\API\Sso {
use Core\API\Parameter\StringType;
use Core\API\Parameter\UuidType;
use Core\Objects\Context;
use Core\API\SsoAPI;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\SsoProvider;
class GetProviders extends SsoAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, []);
// TODO: auto-generated method stub
}
protected function _execute(): bool {
// TODO: auto-generated method stub
return $this->success;
}
public static function getDescription(): string {
// TODO: auto generated endpoint description
return "Short description, what users are able to do with this endpoint.";
}
}
class AddProvider extends SsoAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, []);
// TODO: auto-generated method stub
}
protected function _execute(): bool {
// TODO: auto-generated method stub
return $this->success;
}
public static function getDescription(): string {
// TODO: auto generated endpoint description
return "Short description, what users are able to do with this endpoint.";
}
public static function getDefaultPermittedGroups(): array {
return [Group::ADMIN];
}
}
class EditProvider extends SsoAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, []);
// TODO: auto-generated method stub
}
protected function _execute(): bool {
// TODO: auto-generated method stub
return $this->success;
}
public static function getDescription(): string {
// TODO: auto generated endpoint description
return "Short description, what users are able to do with this endpoint.";
}
public static function getDefaultPermittedGroups(): array {
return [Group::ADMIN];
}
}
class RemoveProvider extends SsoAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, []);
// TODO: auto-generated method stub
}
protected function _execute(): bool {
// TODO: auto-generated method stub
return $this->success;
}
public static function getDescription(): string {
// TODO: auto generated endpoint description
return "Short description, what users are able to do with this endpoint.";
}
public static function getDefaultPermittedGroups(): array {
return [Group::ADMIN];
}
}
class Authenticate extends SsoAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"provider" => new UuidType("provider"),
"redirect" => new StringType("redirect", StringType::UNLIMITED, true, null)
]);
$this->csrfTokenRequired = false;
}
protected function _execute(): bool {
if ($this->context->getUser()) {
return $this->createError("You are already logged in.");
}
$redirectUrl = $this->getParam("redirect");
if (!$this->validateRedirectURL($redirectUrl)) {
return $this->createError("Invalid redirect URL");
}
$sql = $this->context->getSQL();
$ssoProviderIdentifier = $this->getParam("provider");
$ssoProvider = SsoProvider::findBy(SsoProvider::createBuilder($sql, true)
->whereEq("identifier", $ssoProviderIdentifier)
->whereTrue("active")
);
if ($ssoProvider === false) {
return $this->createError("Error fetching SSO Provider: " . $sql->getLastError());
} else if ($ssoProvider === null) {
return $this->createError("SSO Provider not found");
}
try {
$ssoProvider->login($this->context, $redirectUrl);
} catch (\Exception $ex) {
return $this->createError("There was an error with the SSO provider: " . $ex->getMessage());
}
return $this->success;
}
public static function getDescription(): string {
return "Allows users to authenticate with a configured SSO provider.";
}
public static function hasConfigurablePermissions(): bool {
return false;
}
}
class SAML extends SsoAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"SAMLResponse" => new StringType("SAMLResponse"),
"provider" => new UuidType("provider"),
"redirect" => new StringType("redirect", StringType::UNLIMITED, true, null)
]);
$this->csrfTokenRequired = false;
$this->forbidMethod("GET");
}
protected function _execute(): bool {
if ($this->context->getUser()) {
return $this->createError("You are already logged in.");
}
$redirectUrl = $this->getParam("redirect");
if (!$this->validateRedirectURL($redirectUrl)) {
return $this->createError("Invalid redirect URL");
}
$sql = $this->context->getSQL();
$ssoProviderIdentifier = $this->getParam("provider");
$ssoProvider = SsoProvider::findBy(SsoProvider::createBuilder($sql, true)
->whereEq("identifier", $ssoProviderIdentifier)
->whereTrue("active")
);
if ($ssoProvider === false) {
return $this->createError("Error fetching SSO Provider: " . $sql->getLastError());
} else if ($ssoProvider === null) {
return $this->createError("SSO Provider not found");
}
$samlResponseEncoded = $this->getParam("SAMLResponse");
if (($samlResponse = @gzinflate(base64_decode($samlResponseEncoded))) === false) {
$samlResponse = base64_decode($samlResponseEncoded);
}
$parsedUser = $ssoProvider->parseResponse($this->context, $samlResponse);
if ($parsedUser === null) {
return $this->createError("Invalid SAMLResponse");
} else {
return $this->processLogin($parsedUser, $redirectUrl);
}
}
public static function getDescription(): string {
return "Return endpoint for SAML SSO authentication.";
}
public static function hasConfigurablePermissions(): bool {
return false;
}
}
}

View File

@@ -148,6 +148,8 @@ namespace Core\API\TFA {
$twoFactorToken = $currentUser->getTwoFactorToken();
if ($twoFactorToken && $twoFactorToken->isConfirmed()) {
return $this->createError("You already added a two factor token");
} else if (!$currentUser->isNativeAccount()) {
return $this->createError("Cannot add a 2FA token: Your account is managed by an external identity provider (SSO)");
} else if (!($twoFactorToken instanceof TimeBasedTwoFactorToken)) {
$sql = $this->context->getSQL();
$twoFactorToken = new TimeBasedTwoFactorToken(generateRandomString(32, "base32"));
@@ -259,6 +261,10 @@ namespace Core\API\TFA {
public function _execute(): bool {
$currentUser = $this->context->getUser();
if (!$currentUser->isNativeAccount()) {
return $this->createError("Cannot add a 2FA token: Your account is managed by an external identity provider (SSO)");
}
$clientDataJSON = json_decode($this->getParam("clientDataJSON"), true);
$attestationObjectRaw = base64_decode($this->getParam("attestationObject"));
$twoFactorToken = $currentUser->getTwoFactorToken();

View File

@@ -670,6 +670,7 @@ namespace Core\API\User {
$sql = $this->context->getSQL();
$user = User::findBy(User::createBuilder($sql, true)
->where(new Compare("User.name", $username), new Compare("User.email", $username))
->whereEq("User.sso_provider", NULL)
->fetchEntities());
if ($user !== false) {
@@ -681,17 +682,8 @@ namespace Core\API\User {
if (!$user->confirmed) {
$this->result["emailConfirmed"] = false;
return $this->createError("Your email address has not been confirmed yet.");
} else if (!($session = $this->context->createSession($user, $stayLoggedIn))) {
return $this->createError("Error creating Session: " . $sql->getLastError());
} else {
$tfaToken = $user->getTwoFactorToken();
$this->result["loggedIn"] = true;
$this->result["user"] = $user->jsonSerialize();
$this->result["session"] = $session->jsonSerialize(["expires", "csrfToken"]);
$this->result["logoutIn"] = $session->getExpiresSeconds();
$this->check2FA($tfaToken);
$this->success = true;
return $this->createSession($user, $stayLoggedIn);
}
} else {
return $this->createError(L('Wrong username or password'));
@@ -1068,6 +1060,9 @@ namespace Core\API\User {
} else if ($user !== null) {
if (!$user->isActive()) {
return $this->createError("This user is currently disabled. Contact the server administrator, if you believe this is a mistake.");
} else if (!$user->isNativeAccount()) {
// TODO: this allows user enumeration for SSO accounts
return $this->createError("Cannot request a password reset: Account is managed by an external identity provider (SSO)");
} else {
$validHours = 1;
$token = generateRandomString(36);
@@ -1234,7 +1229,9 @@ namespace Core\API\User {
}
$user = $token->getUser();
if (!$this->checkPasswordRequirements($password, $confirmPassword)) {
if (!$user->isNativeAccount()) {
return $this->createError("Cannot reset password: Your account is managed by an external identity provider (SSO)");
} else if (!$this->checkPasswordRequirements($password, $confirmPassword)) {
return false;
} else {
$user->password = $this->hashPassword($password);
@@ -1301,7 +1298,9 @@ namespace Core\API\User {
}
if ($newPassword !== null || $newPasswordConfirm !== null) {
if (!$this->checkPasswordRequirements($newPassword, $newPasswordConfirm)) {
if (!$currentUser->isNativeAccount()) {
return $this->createError("Cannot change password: Your account is managed by an external identity provider (SSO)");
} else if (!$this->checkPasswordRequirements($newPassword, $newPasswordConfirm)) {
return false;
} else {
if (!password_verify($oldPassword, $currentUser->password)) {