Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\SAML;
use LightSaml\Builder\EntityDescriptor\SimpleEntityDescriptorBuilder;
use LightSaml\Credential\X509Credential;
use LightSaml\Store\Credential\CredentialStoreInterface;
use Symfony\Component\Routing\RouterInterface;
class EntityDescriptorProviderFactory
{
public static function build(
string $ownEntityId,
RouterInterface $router,
?string $routeName,
CredentialStoreInterface $ownCredentialStore,
): SimpleEntityDescriptorBuilder {
/** @var X509Credential[] $arrOwnCredentials */
$arrOwnCredentials = $ownCredentialStore->getByEntityId($ownEntityId);
$route = $routeName ? $router->generate($routeName, [], RouterInterface::ABSOLUTE_PATH) : '';
return new SimpleEntityDescriptorBuilder(
$ownEntityId,
$route ? sprintf('%s%s', $ownEntityId, $route) : '',
'',
$arrOwnCredentials[0]->getCertificate()
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\SAML;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Symfony\Component\HttpFoundation\RequestStack;
class Helper
{
public function __construct(private CoreParametersHelper $coreParametersHelper, private RequestStack $request)
{
}
public function isSamlSession(): bool
{
return $this->isSamlEnabled() && $this->request->getSession()->has('samlsso');
}
public function isSamlEnabled(): bool
{
return (bool) $this->coreParametersHelper->get('saml_idp_metadata');
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace Mautic\UserBundle\Security\SAML\Store;
use LightSaml\Credential\KeyHelper;
use LightSaml\Credential\X509Certificate;
use LightSaml\Credential\X509Credential;
use LightSaml\Store\Credential\CredentialStoreInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use RobRichards\XMLSecLibs\XMLSecurityKey;
class CredentialsStore implements CredentialStoreInterface
{
private ?X509Credential $credentials = null;
public function __construct(
private CoreParametersHelper $coreParametersHelper,
private string $entityId,
) {
}
public function getByEntityId($entityId): array
{
// EntityIds do not match
if ($entityId !== $this->entityId) {
return [];
}
if (!$this->credentials) {
$this->delegateAndCreateCredentials();
}
return [$this->credentials];
}
private function delegateAndCreateCredentials(): void
{
// Credentials are required or SP will cause a never ending login loop as it throws an exception
$samlEnabled = (bool) $this->coreParametersHelper->get('saml_idp_metadata');
if (!$samlEnabled || !$certificateContent = $this->coreParametersHelper->get('saml_idp_own_certificate')) {
$this->credentials = $this->createDefaultCredentials();
return;
}
$this->credentials = $this->createOwnCredentials();
}
private function createOwnCredentials(): X509Credential
{
$certificateContent = base64_decode($this->coreParametersHelper->get('saml_idp_own_certificate'));
$privateKeyContent = base64_decode($this->coreParametersHelper->get('saml_idp_own_private_key'));
$keyPassword = (string) $this->coreParametersHelper->get('saml_idp_own_password');
return $this->createCredentials($certificateContent, $privateKeyContent, $keyPassword);
}
private function createDefaultCredentials(): X509Credential
{
$cache_dir = $this->coreParametersHelper->get('cache_path');
$keyPassword = '';
if (!file_exists($cache_dir.'/saml_default.key') || !file_exists($cache_dir.'/saml_default.crt')) {
$dn = ['commonName' => 'Mautic dummy cert'];
// Generate a new private (and public) key pair
$privkey = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
// Generate a certificate signing request
$csr = openssl_csr_new($dn, $privkey, ['digest_alg' => 'sha256']);
// Generate a self-signed cert, valid for 365 days
$x509 = openssl_csr_sign($csr, null, $privkey, $days=365, ['digest_alg' => 'sha256']);
openssl_x509_export_to_file($x509, $cache_dir.'/saml_default.crt');
openssl_pkey_export_to_file($privkey, $cache_dir.'/saml_default.key', $keyPassword);
}
$cert = file_get_contents($cache_dir.'/saml_default.crt');
$privateKey = file_get_contents($cache_dir.'/saml_default.key');
return $this->createCredentials($cert, $privateKey, $keyPassword);
}
private function createCertificate(string $certificateContent): X509Certificate
{
$certificate = new X509Certificate();
$certificate->loadPem($certificateContent);
return $certificate;
}
private function createPrivateKey(string $privateKeyContent, string $keyPassword, X509Certificate $certificate): XMLSecurityKey
{
return KeyHelper::createPrivateKey($privateKeyContent, $keyPassword, false, $certificate->getSignatureAlgorithm());
}
private function createCredentials(string $certificateContent, string $privateKeyContent, string $keyPassword): X509Credential
{
$certificate = $this->createCertificate($certificateContent);
$privateKey = $this->createPrivateKey($privateKeyContent, $keyPassword, $certificate);
$credentials = new X509Credential($certificate, $privateKey);
$credentials->setEntityId($this->entityId);
return $credentials;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Mautic\UserBundle\Security\SAML\Store;
use LightSaml\Model\Metadata\EntityDescriptor;
use LightSaml\Store\EntityDescriptor\EntityDescriptorStoreInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
class EntityDescriptorStore implements EntityDescriptorStoreInterface
{
/**
* @var EntityDescriptor
*/
private $entityDescriptor;
public function __construct(
private CoreParametersHelper $coreParametersHelper,
) {
}
public function get($entityId): ?EntityDescriptor
{
if ($this->entityDescriptor) {
return $this->entityDescriptor;
}
$this->createEntityDescriptor();
if ($entityId !== $this->entityDescriptor->getEntityID()) {
return null;
}
return $this->entityDescriptor;
}
public function has($entityId): bool
{
// SAML is not enabled
if (!$this->coreParametersHelper->get('saml_idp_metadata')) {
return false;
}
$entityDescriptor = $this->get($entityId);
// EntityIds do not match
if (!$entityDescriptor) {
return false;
}
return true;
}
/**
* @return array|EntityDescriptor[]
*/
public function all(): array
{
if (!$this->entityDescriptor) {
$this->createEntityDescriptor();
}
return [$this->entityDescriptor];
}
private function createEntityDescriptor(): void
{
$xml = base64_decode($this->coreParametersHelper->get('saml_idp_metadata'));
$this->entityDescriptor = EntityDescriptor::loadXml($xml);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Mautic\UserBundle\Security\SAML\Store;
use Doctrine\Persistence\ObjectManager;
use LightSaml\Provider\TimeProvider\TimeProviderInterface;
use LightSaml\Store\Id\IdStoreInterface;
use Mautic\UserBundle\Entity\IdEntry;
class IdStore implements IdStoreInterface
{
public function __construct(
private ObjectManager $manager,
private TimeProviderInterface $timeProvider,
) {
}
/**
* @param string $entityId
* @param string $id
*/
public function set($entityId, $id, \DateTime $expiryTime): void
{
$idEntry = $this->manager->find(IdEntry::class, ['entityId' => $entityId, 'id' => $id]);
if (null == $idEntry) {
$idEntry = new IdEntry();
}
$idEntry->setEntityId($entityId)
->setId($id)
->setExpiryTime($expiryTime);
$this->manager->persist($idEntry);
$this->manager->flush();
}
/**
* @param string $entityId
* @param string $id
*/
public function has($entityId, $id): bool
{
/** @var IdEntry $idEntry */
$idEntry = $this->manager->find(IdEntry::class, ['entityId' => $entityId, 'id' => $id]);
if (null == $idEntry) {
return false;
}
if ($idEntry->getExpiryTime()->getTimestamp() < $this->timeProvider->getTimestamp()) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\SAML\Store\Request;
use LightSaml\State\Request\RequestState;
use LightSaml\Store\Request\AbstractRequestStateArrayStore;
use Mautic\CacheBundle\Cache\CacheProviderInterface;
class RequestStateStore extends AbstractRequestStateArrayStore
{
private string $prefix;
public function __construct(private CacheProviderInterface $cacheProvider, string $prefix, string $suffix)
{
$this->prefix = $prefix.$suffix;
}
public function set(RequestState $state): self
{
$id = $state->getId();
$item = $this->cacheProvider->getItem($this->prefix.$id);
$item->expiresAfter(2 * 60); // The login is valid for 2 minutes.
$item->set($state);
$this->cacheProvider->save($item);
return $this;
}
public function get($id): ?RequestState
{
$item = $this->cacheProvider->getItem($this->prefix.$id);
if (!$item->isHit()) {
return null;
}
$state = $item->get();
if (!$state instanceof RequestState) {
return null;
}
return $state;
}
public function remove($id): bool
{
return $this->cacheProvider->deleteItem($this->prefix.$id);
}
public function clear(): void
{
$this->cacheProvider->clear($this->prefix);
}
/**
* @return array<mixed>
*/
protected function getArray(): array
{
throw new \LogicException('Not implemented');
}
/**
* @param array<mixed> $arr
*/
protected function setArray(array $arr): AbstractRequestStateArrayStore
{
throw new \LogicException('Not implemented');
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Mautic\UserBundle\Security\SAML\Store;
use LightSaml\Meta\TrustOptions\TrustOptions;
use LightSaml\Store\TrustOptions\TrustOptionsStoreInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
class TrustOptionsStore implements TrustOptionsStoreInterface
{
private ?TrustOptions $trustOptions = null;
public function __construct(
private CoreParametersHelper $coreParametersHelper,
private string $entityId,
) {
}
public function get($entityId): TrustOptions
{
if ($this->trustOptions) {
return $this->trustOptions;
}
return $this->createTrustOptions();
}
public function has($entityId): bool
{
// SAML is not enabled
if (!$this->coreParametersHelper->get('saml_idp_metadata')) {
return false;
}
// EntityIds do not match
if ($entityId !== $this->entityId) {
return false;
}
return true;
}
private function createTrustOptions(): TrustOptions
{
$this->trustOptions = $trustOptions = new TrustOptions();
if (!$this->coreParametersHelper->get('saml_idp_own_certificate')) {
return $trustOptions;
}
$trustOptions->setSignAuthnRequest(true);
$trustOptions->setEncryptAssertions(true);
$trustOptions->setEncryptAuthnRequest(true);
$trustOptions->setSignAssertions(true);
$trustOptions->setSignResponse(true);
return $trustOptions;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Mautic\UserBundle\Security\SAML\User;
use Doctrine\ORM\EntityManagerInterface;
use LightSaml\Model\Protocol\Response;
use LightSaml\SpBundle\Security\User\UserCreatorInterface;
use Mautic\CoreBundle\Helper\EncryptionHelper;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Model\UserModel;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\UserInterface;
class UserCreator implements UserCreatorInterface
{
private int $defaultRole;
private array $requiredFields = [
'username',
'firstname',
'lastname',
'email',
];
public function __construct(
private EntityManagerInterface $entityManager,
private UserMapper $userMapper,
private UserModel $userModel,
private UserPasswordHasher $hasher,
$defaultRole,
) {
$this->defaultRole = (int) $defaultRole;
}
/**
* @return UserInterface|null
*/
public function createUser(Response $response): User
{
if (empty($this->defaultRole)) {
throw new BadCredentialsException('User does not exist.');
}
/** @var Role $defaultRole */
$defaultRole = $this->entityManager->getReference(Role::class, $this->defaultRole);
$user = $this->userMapper->getUser($response);
$user->setPassword($this->userModel->checkNewPassword($user, $this->hasher, EncryptionHelper::generateKey()));
$user->setRole($defaultRole);
$this->validateUser($user);
$this->userModel->saveEntity($user);
return $user;
}
/**
* @throws BadCredentialsException
*/
private function validateUser(User $user): void
{
// Validate that the user has all that's required
foreach ($this->requiredFields as $field) {
$getter = 'get'.ucfirst($field);
if (!$user->$getter()) {
throw new BadCredentialsException('User does not include required fields.');
}
}
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Mautic\UserBundle\Security\SAML\User;
use LightSaml\Model\Assertion\Assertion;
use LightSaml\Model\Protocol\Response;
use LightSaml\SpBundle\Security\User\UsernameMapperInterface;
use Mautic\UserBundle\Entity\User;
class UserMapper implements UsernameMapperInterface
{
/**
* @param array<string, mixed> $attributes
*/
public function __construct(
private array $attributes,
) {
}
public function getUser(Response $response): User
{
$user = new User();
foreach ($response->getAllAssertions() as $assertion) {
$this->setValuesFromAssertion($assertion, $user);
}
return $user;
}
public function getUsername(Response $response): ?string
{
$user = $this->getUser($response);
return $user->getUserIdentifier();
}
private function setValuesFromAssertion(Assertion $assertion, User $user): void
{
$attributes = $this->extractAttributes($assertion);
// use email as the user by default
if (isset($attributes['email'])) {
$user->setEmail($attributes['email']);
$user->setUsername($attributes['email']);
}
if (isset($attributes['username']) && !empty($attributes['username'])) {
$user->setUsername($attributes['username']);
}
if (isset($attributes['firstname'])) {
$user->setFirstname($attributes['firstname']);
}
if (isset($attributes['lastname'])) {
$user->setLastName($attributes['lastname']);
}
}
private function extractAttributes(Assertion $assertion): array
{
$attributes = [];
foreach ($this->attributes as $key => $attributeName) {
if (!$attributeName) {
continue;
}
foreach ($assertion->getAllAttributeStatements() as $attributeStatement) {
$attribute = $attributeStatement->getFirstAttributeByName($attributeName);
if ($attribute && $attribute->getFirstAttributeValue()) {
$attributes[$key] = $attribute->getFirstAttributeValue();
}
}
}
return $attributes;
}
}