Initial commit: CloudOps infrastructure platform
This commit is contained in:
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user