diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d3c2122 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.yaml.example linguist-language=YAML diff --git a/Classes/Command/UtilityCommandController.php b/Classes/Command/UtilityCommandController.php index c4534a0..13f58b9 100644 --- a/Classes/Command/UtilityCommandController.php +++ b/Classes/Command/UtilityCommandController.php @@ -1,4 +1,5 @@ getDirectoryService($providerName, $settingsFile); - - try { - if ($username === null && $password === null) { - $result = ldap_bind($directoryService->getConnection()); - $this->outputLine('Anonymous bind attempt %s', [$result === false ? 'failed' : 'succeeded']); - if ($result === false) { - $this->quit(1); - } - } else { - $directoryService->bind($username, $password); - $this->outputLine('Bind successful with user %s, using password is %s', [$username, $password === null ? 'NO' : 'YES']); - } - } catch (\Exception $exception) { - $this->outputLine('Failed to bind with username %s, using password is %s', [$username, $password === null ? 'NO' : 'YES']); - $this->outputLine($exception->getMessage()); - $this->quit(1); + public function bindCommand( + string $username = null, + string $password = null, + string $providerName = null, + string $settingsFile = null + ): void { + $options = $this->getOptions($providerName, $settingsFile); + $bindDn = isset($options['bind']['dn']) + ? sprintf($options['bind']['dn'], $username ?? '') + : null; + $message = 'Attempt to bind ' . ($bindDn === null ? 'anonymously' : 'to ' . $bindDn); + if ($password !== null) { + $message .= ', using password,'; } - } - - /** - * Try authenticating a user using a DirectoryService that's connected to a directory - * - * @param string $username The username to authenticate - * @param string $password The password to use while authenticating - * @param string $providerName Name of the authentication provider to use - * @param string $settingsFile Path to a yaml file containing the settings to use for testing purposes - * @return void - */ - public function authenticateCommand($username, $password, $providerName = null, $settingsFile = null) - { - $directoryService = $this->getDirectoryService($providerName, $settingsFile); - try { - $directoryService->authenticate($username, $password); - $this->outputLine('Successfully authenticated %s with given password', [$username]); - } catch (\Exception $exception) { + new DirectoryService($options, $username, $password); + $this->outputLine($message . ' succeeded'); + } catch (ConnectionException $exception) { + $this->outputLine($message . ' failed'); $this->outputLine($exception->getMessage()); $this->quit(1); } @@ -91,142 +72,116 @@ public function authenticateCommand($username, $password, $providerName = null, /** * Query the directory * - * @param string $query The query to use, for example (objectclass=*) * @param string $baseDn The base dn to search in - * @param string $providerName Name of the authentication provider to use - * @param string $settingsFile Path to a yaml file containing the settings to use for testing purposes - * @param string $displayColumns Comma separated list of columns to show, like: dn,objectclass + * @param string $query The query to use, for example "(objectclass=*)" + * @param string|null $providerName Name of the authentication provider to use + * @param string|null $settingsFile Path to a yaml file containing the settings to use for testing purposes + * @param string|null $displayColumns Comma separated list of columns to show, like "cn,objectclass" + * @param string|null $username Username to be used to bind + * @param string|null $password Password to be used to bind * @return void + * @throws StopCommandException */ public function queryCommand( - $query, - $baseDn = null, - $providerName = null, - $settingsFile = null, - $displayColumns = 'dn' - ) { - $directoryService = $this->getDirectoryService($providerName, $settingsFile); - - if ($baseDn === null) { - $baseDn = Arrays::getValueByPath($this->options, 'baseDn'); - } + string $baseDn, + string $query, + string $providerName = null, + string $settingsFile = null, + string $displayColumns = null, + string $username = null, + string $password = null + ): void { + $options = $this->getOptions($providerName, $settingsFile); - $this->outputLine('Query: %s', [$query]); $this->outputLine('Base DN: %s', [$baseDn]); + $this->outputLine('Query: %s', [$query]); - $searchResult = @ldap_search( - $directoryService->getConnection(), - $baseDn, - $query - ); + $columns = $displayColumns === null ? null : Arrays::trimExplode(',', $displayColumns); - if ($searchResult === false) { - $this->outputLine(ldap_error($directoryService->getConnection())); + try { + $directoryService = new DirectoryService($options, $username, $password); + $entries = $directoryService->query($baseDn, $query, $columns); + } catch (ConnectionException|LdapException $exception) { + $this->outputLine($exception->getMessage()); $this->quit(1); } - - $this->outputLdapSearchResultTable($directoryService->getConnection(), $searchResult, $displayColumns); - } - - /** - * @param string $providerName Name of the authentication provider to use - * @param string $settingsFile Path to a yaml file containing the settings to use for testing purposes - * @return DirectoryService - * @throws \Neos\Flow\Mvc\Exception\StopActionException - */ - protected function getDirectoryService($providerName, $settingsFile) - { - $directoryServiceOptions = $this->getOptions($providerName, $settingsFile); - if (!is_array($directoryServiceOptions)) { - $this->outputLine('No configuration found for given providerName / settingsFile'); - $this->quit(3); - } - - return new DirectoryService('cli', $directoryServiceOptions); + $this->outputEntriesTable($entries); } /** * Load options by provider name or by a settings file (first has precedence) * - * @param string $providerName Name of the authentication provider to use - * @param string $settingsFile Path to a yaml file containing the settings to use for testing purposes - * @return array|mixed - * @throws \Neos\Flow\Mvc\Exception\StopActionException + * @param string|null $providerName Name of the authentication provider to use + * @param string|null $settingsFile Path to a yaml file containing the settings to use for testing purposes + * @return array + * @throws StopCommandException */ - protected function getOptions($providerName = null, $settingsFile = null) + protected function getOptions(string $providerName = null, string $settingsFile = null): array { - if ($providerName !== null && array_key_exists($providerName, $this->authenticationProvidersConfiguration)) { - $this->options = $this->authenticationProvidersConfiguration[$providerName]['providerOptions']; - return $this->options; + if ($providerName !== null) { + if (isset($this->authenticationProvidersConfiguration[$providerName]['providerOptions']) + && \is_array($this->authenticationProvidersConfiguration[$providerName]['providerOptions']) + ) { + return $this->authenticationProvidersConfiguration[$providerName]['providerOptions']; + } + $this->outputLine('No configuration found for given providerName'); + if ($settingsFile === null) { + $this->quit(3); + } } if ($settingsFile !== null) { - if (!file_exists($settingsFile)) { + if (!\file_exists($settingsFile)) { $this->outputLine('Could not find settings file on path %s', [$settingsFile]); $this->quit(1); } - $this->options = Yaml::parse(Files::getFileContents($settingsFile)); - return $this->options; + try { + // Yaml::parseFile() introduced in symfony/yaml 3.4.0 + // When above is required, we can drop dependency on neos/utility-files + $directoryServiceOptions = method_exists(Yaml::class, 'parseFile') + ? Yaml::parseFile($settingsFile) + : Yaml::parse(Files::getFileContents($settingsFile)); + } catch (ParseException $exception) { + $this->outputLine($exception->getMessage()); + $this->quit(3); + } + if (!\is_array($directoryServiceOptions)) { + $this->outputLine('No configuration found in given settingsFile'); + $this->quit(3); + } + return $directoryServiceOptions; } - $this->outputLine('Neither providerName or settingsFile is passed as argument. You need to pass one of those.'); + $this->outputLine( + 'Neither providerName nor settingsFile is passed as argument. You need to pass one of those.' + ); $this->quit(1); } /** - * Outputs a table for given search result + * Outputs a table for given entries * - * @param resource $connection - * @param resource $searchResult - * @param $displayColumns + * @param Entry[] $entries * @return void */ - protected function outputLdapSearchResultTable($connection, $searchResult, $displayColumns) + protected function outputEntriesTable(array $entries): void { - $headers = []; + $headers = ['dn']; $rows = []; - $displayColumns = Arrays::trimExplode(',', $displayColumns); - - $entries = ldap_get_entries($connection, $searchResult); - $this->outputLine('%s results found', [$entries['count']]); + $this->outputLine('%s results found', [\count($entries)]); - foreach ($entries as $index => $ldapSearchResult) { - if ($index === 'count') { - continue; - } - - if ($headers === []) { - foreach ($ldapSearchResult as $propertyName => $propertyValue) { - if (is_integer($propertyName)) { - continue; - } - if ($displayColumns === null || in_array($propertyName, $displayColumns)) { - $headers[] = $propertyName; - } + foreach ($entries as $index => $entry) { + $rows[$index] = ['dn' => $entry->getDn()]; + foreach ($entry->getAttributes() as $propertyName => $propertyValue) { + if ($index === 0) { + $headers[] = $propertyName; } - } - $row = []; - foreach ($ldapSearchResult as $propertyName => $propertyValue) { - if (is_integer($propertyName)) { - continue; - } - if ($displayColumns !== null && !in_array($propertyName, $displayColumns)) { - continue; - } - - if (isset($propertyValue['count'])) { - unset($propertyValue['count']); - } - - if (is_array($propertyValue)) { - $row[$propertyName] = implode(", ", $propertyValue); - } else { - $row[$propertyName] = $propertyValue; - } + $rows[$index][$propertyName] = \is_array($propertyValue) + ? implode(', ', $propertyValue) + : $propertyValue; } - $rows[] = $row; } $this->output->outputTable($rows, $headers); diff --git a/Classes/Security/Authentication/Provider/LdapProvider.php b/Classes/Security/Authentication/Provider/LdapProvider.php index 6325851..1eeb5a9 100644 --- a/Classes/Security/Authentication/Provider/LdapProvider.php +++ b/Classes/Security/Authentication/Provider/LdapProvider.php @@ -1,4 +1,5 @@ directoryService = new DirectoryService($name, $options); - } + protected array $rolesConfiguration = []; /** - * Authenticate the current token. If it's not possible to connect to the LDAP server the provider - * tries to authenticate against cached credentials in the database that were - * cached on the last successful login for the user to authenticate. + * Authenticate the current token. If it's not possible to connect to the LDAP server the provider tries to + * authenticate against cached credentials in the database that were cached on the last successful login for the + * user to authenticate. * * @param TokenInterface $authenticationToken The token to be authenticated - * @throws UnsupportedAuthenticationTokenException * @return void + * @throws MissingConfigurationException + * @throws UnsupportedAuthenticationTokenException + * @throws InvalidConfigurationTypeException + * @throws SecurityException */ public function authenticate(TokenInterface $authenticationToken) { if (!($authenticationToken instanceof UsernamePassword)) { - throw new UnsupportedAuthenticationTokenException('This provider cannot authenticate the given token.', 1217339840); + throw new UnsupportedAuthenticationTokenException( + 'This provider cannot authenticate the given token.', + 1217339840 + ); } $credentials = $authenticationToken->getCredentials(); - if (!is_array($credentials) || !isset($credentials['username'])) { - $authenticationToken->setAuthenticationStatus(TokenInterface::NO_CREDENTIALS_GIVEN); + if (!\is_array($credentials) || !isset($credentials['username'], $credentials['password'])) { + try { + $authenticationToken->setAuthenticationStatus(TokenInterface::NO_CREDENTIALS_GIVEN); + } catch (InvalidAuthenticationStatusException $exception) { + // This exception is never thrown + } return; } + // Retrieve account for the credentials + $account = $this->accountRepository->findActiveByAccountIdentifierAndAuthenticationProviderName( + $credentials['username'], + $this->name + ); try { - $ldapUser = $this->directoryService->authenticate($credentials['username'], $credentials['password']); + $this->directoryService = new DirectoryService( + $this->options, + $credentials['username'], + $credentials['password'] + ); + $ldapUserData = $this->directoryService->getUserData($credentials['username']); - // Retrieve or create account for the credentials - $account = $this->accountRepository->findActiveByAccountIdentifierAndAuthenticationProviderName($credentials['username'], $this->name); + // Create account if not existent if ($account === null) { - $account = $this->createAccountForCredentials($credentials); - $this->emitAccountCreated($account, $ldapUser); + $account = $this->createAccount($credentials, $ldapUserData); + if ($account === null) { + throw new LdapException('Only existing accounts allowed'); + } + $this->emitAccountCreated($account, $ldapUserData); } + } catch (ConnectionException|LdapException $exception) { + try { + $authenticationToken->setAuthenticationStatus(TokenInterface::WRONG_CREDENTIALS); + if ($account !== null) { + $account->authenticationAttempted(TokenInterface::WRONG_CREDENTIALS); + $this->accountRepository->update($account); + $this->persistenceManager->allowObject($account); + } + } catch (InvalidAuthenticationStatusException|IllegalObjectTypeException $exception) { + // This exception is never thrown + } + return; + } - // Map security roles to account - $this->setRoles($account, $ldapUser); - $this->emitRolesSet($account, $ldapUser); + // Map security roles to account + $this->setRoles($account, $ldapUserData); + $this->emitRolesSet($account, $ldapUserData); - // Mark authentication successful + // Mark authentication successful + try { + $account->authenticationAttempted(TokenInterface::AUTHENTICATION_SUCCESSFUL); + $this->accountRepository->update($account); $authenticationToken->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL); - $authenticationToken->setAccount($account); - $this->emitAccountAuthenticated($account, $ldapUser); - } catch (\Exception $exception) { - $this->logger->alert('Authentication failed: ' . $exception->getMessage()); - $authenticationToken->setAuthenticationStatus(TokenInterface::WRONG_CREDENTIALS); + } catch (InvalidAuthenticationStatusException|IllegalObjectTypeException $exception) { + // This exception is never thrown } + $this->persistenceManager->allowObject($account); + $authenticationToken->setAccount($account); + $this->emitAccountAuthenticated($account, $ldapUserData); } /** - * Create a new account for the given credentials. Return null if you - * do not want to create a new account, that is, only authenticate - * existing accounts from the database and fail on new logins. + * @Flow\Signal + * @param Account $account + * @param string[][] $ldapUserData + * @return void + */ + public function emitAccountCreated(Account $account, array $ldapUserData): void + { + } + + /** + * @Flow\Signal + * @param Account $account + * @param string[][] $ldapUserData + * @return void + */ + public function emitAccountAuthenticated(Account $account, array $ldapUserData): void + { + } + + /** + * @Flow\Signal + * @param Account $account + * @param string[][] $ldapUserData + * @return void + */ + public function emitRolesSet(Account $account, array $ldapUserData): void + { + } + + /** + * Create a new account for the given credentials. Return null if you do not want to create a new account, that is, + * only authenticate existing accounts from the database and fail on new logins. * - * @param array $credentials array containing username and password - * @return Account + * @param string[] $credentials array containing username and password + * @param string[][] $ldapUserData + * @return Account|null */ - protected function createAccountForCredentials(array $credentials) + protected function createAccount(array $credentials, array $ldapUserData): ?Account { $account = new Account(); $account->setAccountIdentifier($credentials['username']); $account->setAuthenticationProviderName($this->name); - $this->accountRepository->add($account); + try { + $this->accountRepository->add($account); + } catch (IllegalObjectTypeException $exception) { + // This exception is never thrown + } return $account; } /** - * Sets the roles for the Ldap account. - * Extend this Provider class and implement this method to update the party - * * @param Account $account - * @param array $ldapSearchResult * @return void */ - protected function setRoles(Account $account, array $ldapSearchResult) + protected function resetRoles(Account $account): void { - $this->setDefaultRoles($account); - $this->setRolesMappedToUserDn($account, $ldapSearchResult); - $this->setRolesBasedOnGroupMembership($account, $ldapSearchResult); - - $this->accountRepository->update($account); + try { + $account->setRoles([]); + } catch (\InvalidArgumentException $exception) { + // This exception is never thrown + } } /** * Set all default roles * * @param Account $account + * @return void + * @throws InvalidConfigurationTypeException + * @throws SecurityException */ - protected function setDefaultRoles(Account $account) + protected function setDefaultRoles(Account $account): void { - if (!is_array($this->rolesConfiguration['default'])) { + if (!\is_array($this->rolesConfiguration['default'])) { return; } foreach ($this->rolesConfiguration['default'] as $roleIdentifier) { - $account->addRole($this->policyService->getRole($roleIdentifier)); + try { + $account->addRole($this->policyService->getRole($roleIdentifier)); + } catch (\InvalidArgumentException $exception) { + // This exception is never thrown + } catch (NoSuchRoleException $exception) { + // We ignore invalid roles + // todo: logging + continue; + } } } /** - * Map configured roles based on user dn + * Sets the roles for the Ldap account + * + * Extend this Provider class and implement this method to update the party * * @param Account $account - * @param array $ldapSearchResult + * @param string[][] $ldapUserData + * @return void + * @throws InvalidConfigurationTypeException + * @throws SecurityException */ - protected function setRolesMappedToUserDn(Account $account, array $ldapSearchResult) + protected function setRoles(Account $account, array $ldapUserData): void { - if (!is_array($this->rolesConfiguration['userMapping'])) { - return; + $this->resetRoles($account); + $this->setDefaultRoles($account); + $this->setRolesByUserProperties($account, $ldapUserData); + $this->setRolesByUserDn($account, $ldapUserData['dn'][0]); + try { + $this->setRolesByGroupDns($account, $this->directoryService->getGroupDnsOfUser($ldapUserData['dn'][0])); + } catch (MissingConfigurationException|LdapException $exception) { + // If groups cannot be retrieved, they won't get set + // todo: logging } - foreach ($this->rolesConfiguration['userMapping'] as $roleIdentifier => $userDns) { - if (in_array($ldapSearchResult['dn'], $userDns)) { - $account->addRole($this->policyService->getRole($roleIdentifier)); - } + try { + $this->accountRepository->update($account); + } catch (IllegalObjectTypeException $exception) { + // This exception is never thrown } } @@ -180,50 +262,107 @@ protected function setRolesMappedToUserDn(Account $account, array $ldapSearchRes * Map configured roles based on group membership * * @param Account $account - * @param array $ldapSearchResult + * @param string[] $groupDns + * @return void + * @throws InvalidConfigurationTypeException + * @throws SecurityException */ - protected function setRolesBasedOnGroupMembership(Account $account, array $ldapSearchResult) + protected function setRolesByGroupDns(Account $account, array $groupDns): void { - if (!is_array($this->rolesConfiguration['groupMapping'])) { + if (!\is_array($this->rolesConfiguration['groupMapping'])) { return; } - $memberOf = $this->directoryService->getMemberOf($ldapSearchResult['dn']); - foreach ($this->rolesConfiguration['groupMapping'] as $roleIdentifier => $groupDns) { - if (!empty(array_intersect($memberOf, $groupDns))) { - $account->addRole($this->policyService->getRole($roleIdentifier)); + foreach ($this->rolesConfiguration['groupMapping'] as $roleIdentifier => $roleGroupDns) { + if (\array_intersect($groupDns, $roleGroupDns) !== []) { + try { + $account->addRole($this->policyService->getRole($roleIdentifier)); + } catch (\InvalidArgumentException $exception) { + // This exception is never thrown + } catch (NoSuchRoleException $exception) { + // We ignore invalid roles + // todo: logging + continue; + } } } } /** + * Map configured roles based on user dn + * * @param Account $account - * @param array $ldapSearchResult + * @param string $userDn * @return void - * @Flow\Signal + * @throws InvalidConfigurationTypeException + * @throws SecurityException */ - public function emitAccountCreated(Account $account, array $ldapSearchResult) + protected function setRolesByUserDn(Account $account, string $userDn): void { - } + if (!\is_array($this->rolesConfiguration['userMapping'])) { + return; + } - /** - * @param Account $account - * @param array $ldapSearchResult - * @return void - * @Flow\Signal - */ - public function emitAccountAuthenticated(Account $account, array $ldapSearchResult) - { + foreach ($this->rolesConfiguration['userMapping'] as $roleIdentifier => $roleUserDns) { + if (\in_array($userDn, $roleUserDns, true)) { + try { + $account->addRole($this->policyService->getRole($roleIdentifier)); + } catch (\InvalidArgumentException $exception) { + // This exception is never thrown + } catch (NoSuchRoleException $exception) { + // We ignore invalid roles + // todo: logging + continue; + } + } + } } /** + * Map configured roles base on user properties + * * @param Account $account - * @param array $ldapSearchResult + * @param string[][] $ldapUserData * @return void - * @Flow\Signal + * @throws InvalidConfigurationTypeException + * @throws SecurityException */ - public function emitRolesSet(Account $account, array $ldapSearchResult) + protected function setRolesByUserProperties(Account $account, array $ldapUserData): void { - } + if (!\is_array($this->rolesConfiguration['propertyMapping'])) { + return; + } + foreach ($this->rolesConfiguration['propertyMapping'] as $roleIdentifier => $propertyConditions) { + try { + $role = $this->policyService->getRole($roleIdentifier); + } catch (NoSuchRoleException $e) { + // We ignore invalid roles + // todo: logging + continue; + } + + foreach ($propertyConditions as $propertyName => $conditions) { + if (!isset($ldapUserData[$propertyName])) { + continue; + } + + if (!\is_array($conditions)) { + $conditions = [$conditions]; + } + foreach ($conditions as $condition) { + foreach ($ldapUserData[$propertyName] as $value) { + if ($value === $condition || @\preg_match($condition, $value) === 1) { + try { + $account->addRole($role); + } catch (\InvalidArgumentException $e) { + // This exception is never thrown + } + continue 4; + } + } + } + } + } + } } diff --git a/Classes/Security/Authentication/Provider/NeosBackendLdapProvider.php b/Classes/Security/Authentication/Provider/NeosBackendLdapProvider.php new file mode 100644 index 0000000..a121870 --- /dev/null +++ b/Classes/Security/Authentication/Provider/NeosBackendLdapProvider.php @@ -0,0 +1,78 @@ + 'user.givenName[0]', + 'lastName' => 'user.sn[0]', + ], + $this->options['mapping'] ?? [] + ); + $eelContext = new Context(['user' => $ldapUserData]); + + try { + $firstName = $this->eelEvaluator->evaluate($mapping['firstName'], $eelContext); + } catch (\Exception $exception) { + // todo: logging + $firstName = 'none'; + } + try { + $lastName = $this->eelEvaluator->evaluate($mapping['lastName'], $eelContext); + } catch (\Exception $exception) { + // todo: logging + $lastName = 'none'; + } + + $user = $this->userService->createUser( + $credentials['username'], + '', + $firstName, + $lastName, + [], + $this->name + ); + return $user->getAccounts()->get(0); + } +} diff --git a/Classes/Service/BindProvider/AbstractBindProvider.php b/Classes/Service/BindProvider/AbstractBindProvider.php deleted file mode 100644 index a7ce481..0000000 --- a/Classes/Service/BindProvider/AbstractBindProvider.php +++ /dev/null @@ -1,116 +0,0 @@ -linkIdentifier = $linkIdentifier; - $this->options = $options; - } - - /** - * Return the ldap connection identifier. - * - * @return resource - */ - public function getLinkIdentifier() - { - return $this->linkIdentifier; - } - - /** - * Return the filtered username for directory search. - * - * @param string $username - * @return string - */ - public function filterUsername($username) - { - return $username; - } - - /** - * Bind to the directory server. Returns void but throws exception on failure. - * - * @param string $userDn The DN of the user. - * @param string $password The user's password. - * @throws \Exception - */ - protected function bindWithDn($userDn, $password) - { - try { - $bindIsSuccessful = ldap_bind($this->linkIdentifier, $userDn, $password); - } catch (\Exception $exception) { - $bindIsSuccessful = false; - } - - if (!$bindIsSuccessful) { - throw new \Exception('Failed to bind with DN: "' . $userDn . '"', 1327763970); - } - } - - /** - * Bind anonymously to the directory server. Returns void but throws exception on failure. - * - * @throws \Exception - */ - protected function bindAnonymously() - { - try { - $bindIsSuccessful = ldap_bind($this->linkIdentifier); - } catch (\Exception $exception) { - $bindIsSuccessful = false; - } - - if (!$bindIsSuccessful) { - throw new \Exception('Failed to bind anonymously', 1327763970); - } - } - - /** - * Verify the given user is known to the directory server and has valid credentials. - * Does not return output but throws an exception if the credentials are invalid. - * - * @param string $dn The DN of the user. - * @param string $password The user's password. - * @throws \Exception - */ - public function verifyCredentials($dn, $password) - { - $this->bindWithDn($dn, $password); - } - -} diff --git a/Classes/Service/BindProvider/ActiveDirectoryBind.php b/Classes/Service/BindProvider/ActiveDirectoryBind.php deleted file mode 100644 index 1e4521b..0000000 --- a/Classes/Service/BindProvider/ActiveDirectoryBind.php +++ /dev/null @@ -1,66 +0,0 @@ -options['domain'])) { - if (!strpos($username, '\\')) { - $username = $this->options['domain'] . '\\' . $username; - } - } - - if (!empty($this->options['usernameSuffix'])) { - if (!strpos($username, '@')) { - $username = $username . '@' . $this->options['usernameSuffix']; - } - } - - $this->bindWithDn($username, $password); - } - - /** - * Return username in format used for directory search - * - * @param string $username - * @return string - */ - public function filterUsername($username) - { - if (!empty($this->options['domain'])) { - $usernameSegments = explode('\\', $username); - $usernameWithoutDomain = array_pop($usernameSegments); - $username = $this->options['filter']['ignoreDomain'] ? $usernameWithoutDomain : addcslashes($username, '\\'); - } - return $username; - } - -} - diff --git a/Classes/Service/BindProvider/BindProviderInterface.php b/Classes/Service/BindProvider/BindProviderInterface.php deleted file mode 100644 index 5a9021d..0000000 --- a/Classes/Service/BindProvider/BindProviderInterface.php +++ /dev/null @@ -1,55 +0,0 @@ -options, 'bind.dn'); - if (!empty($username) && !empty($password)) { - // if credentials are given, use them to authenticate - $this->bindWithDn(sprintf($bindDn, $username), $password); - return; - } - - $bindPassword = Arrays::getValueByPath($this->options, 'bind.password'); - if (!empty($bindPassword)) { - // if the settings specify a bind password, we are safe to assume no anonymous authentication is needed - $this->bindWithDn($bindDn, $bindPassword); - } - - $anonymousBind = Arrays::getValueByPath($this->options, 'bind.anonymous'); - if ($anonymousBind === true) { - // if allowed, bind without username or password - $this->bindAnonymously(); - } - } - -} diff --git a/Classes/Service/DirectoryService.php b/Classes/Service/DirectoryService.php index 4348197..7c5e9a5 100644 --- a/Classes/Service/DirectoryService.php +++ b/Classes/Service/DirectoryService.php @@ -1,4 +1,5 @@ name = $name; $this->options = $options; - } - /** - * @return resource - */ - public function getConnection() - { $this->ldapConnect(); - return $this->bindProvider->getLinkIdentifier(); + $this->ldapBind($username, $password); } /** - * Initialize the Ldap server connection - * - * Connect to the server and set communication options. Further bindings will be done - * by a server specific bind provider. - * - * @throws Exception + * @param string $userDn User DN + * @return string[] Group DNs + * @throws MissingConfigurationException + * @throws LdapException */ - public function ldapConnect() + public function getGroupDnsOfUser(string $userDn): array { - if ($this->bindProvider instanceof BindProviderInterface) { - // Already connected - return; + if (!isset($this->options['queries']['group']['baseDn'], $this->options['queries']['group']['query'])) { + throw new MissingConfigurationException('Both baseDn and query have to be set for queries.group'); } - $bindProviderClass = LdapBind::class; - $connectionType = Arrays::getValueByPath($this->options, 'type'); - if ($connectionType === 'ActiveDirectory') { - $bindProviderClass = ActiveDirectoryBind::class; - } - if (!class_exists($bindProviderClass)) { - throw new Exception("Bind provider '$bindProviderClass' for the service '$this->name' could not be resolved.", 1327756744); - } + $entries = $this->query( + $this->options['queries']['group']['baseDn'], + sprintf($this->options['queries']['group']['query'], $userDn), + ['dn'] + ); - $connection = ldap_connect($this->options['host'], $this->options['port']); - $this->bindProvider = new $bindProviderClass($connection, $this->options); + $groupDns = []; + foreach ($entries as $entry) { + $groupDns[] = $entry->getDn(); + } - $this->setLdapOptions(); + return $groupDns; } /** - * Set the Ldap options configured in the settings. - * - * Loops over the ldapOptions array, and finds the corresponding Ldap option by prefixing - * LDAP_OPT_ to the uppercased array key. - * - * Example: - * protocol_version: 3 - * Becomes: - * LDAP_OPT_PROTOCOL_VERSION 3 + * Get account data from ldap server * - * @return void + * @param string $username + * @return string[][] Search result from Ldap + * @throws MissingConfigurationException + * @throws LdapException */ - protected function setLdapOptions() + public function getUserData(string $username): array { - if (!isset($this->options['ldapOptions']) || !is_array($this->options['ldapOptions'])) { - return; + if (!isset($this->options['queries']['account']['baseDn'], $this->options['queries']['account']['query'])) { + throw new MissingConfigurationException('Both baseDn and query have to be set for queries.account'); } - foreach ($this->options['ldapOptions'] as $ldapOption => $value) { - $constantName = 'LDAP_OPT_' . strtoupper($ldapOption); - ldap_set_option($this->bindProvider->getLinkIdentifier(), constant($constantName), $value); + $entries = $this->query( + $this->options['queries']['account']['baseDn'], + sprintf($this->options['queries']['account']['query'], $username), + $this->options['attributesFilter'] ?? [] + ); + if ($entries === []) { + throw new LdapException('User not found'); } + + return Arrays::arrayMergeRecursiveOverrule($entries[0]->getAttributes(), ['dn' => [$entries[0]->getDn()]]); } /** - * Authenticate a username / password against the Ldap server - * - * @param string $username - * @param string $password - * @return array Search result from Ldap - * @throws Exception + * @param string $baseDn + * @param string $queryString + * @param string[]|null $filter + * @return Entry[] + * @throws LdapException */ - public function authenticate($username, $password) + public function query(string $baseDn, string $queryString, array $filter = null): array { - $this->bind($username, $password); - - $searchResult = @ldap_search( - $this->bindProvider->getLinkIdentifier(), - $this->options['baseDn'], - sprintf($this->options['filter']['account'], $this->bindProvider->filterUsername($username)) - ); - - if (!$searchResult) { - throw new Exception('Error during Ldap user search: ' . ldap_errno($this->bindProvider->getLinkIdentifier()), 1443798372); - } - - $entries = ldap_get_entries($this->bindProvider->getLinkIdentifier(), $searchResult); - if (empty($entries) || !isset($entries[0])) { - throw new Exception('Error while authenticating: authenticated user could not be fetched from the directory', 1488289104); + $query = $this->ldap->query($baseDn, $queryString, ['filter' => $filter ?? []]); + /** @var Entry[] $entries */ + try { + $entries = $query->execute()->toArray(); + } catch (NotBoundException $exception) { + // This exception should never be thrown, since we bind in constructor } - - return $entries[0]; + return $entries; } /** * @param string|null $username * @param string|null $password * @return void - * @throws Exception + * @throws ConnectionException */ - public function bind($username = null, $password = null) + protected function ldapBind(string $username = null, string $password = null): void { - $this->ldapConnect(); - $this->bindProvider->bind($username, $password); + $this->ldap->bind( + (isset($this->options['bind']['dn']) + ? sprintf($this->options['bind']['dn'], $username ?? '') + : null + ), + $this->options['bind']['password'] ?? $password + ); } /** - * @param string $dn User or group DN. - * @return array group DN => CN mapping - * @throws Exception + * Initialize the Ldap server connection + * + * Connect to the server and set communication options. Further bindings will be done by a server specific bind + * provider. + * + * @return void */ - public function getMemberOf($dn) + protected function ldapConnect(): void { - $searchResult = @ldap_search( - $this->bindProvider->getLinkIdentifier(), - $this->options['baseDn'], - sprintf($this->options['filter']['memberOf'], $dn) - ); - - if (!$searchResult) { - throw new Exception('Error during Ldap group search: ' . ldap_errno($this->bindProvider->getLinkIdentifier()), 1443476083); + try { + $this->ldap = Ldap::create('ext_ldap', $this->options['connection'] ?? []); + } catch (DriverNotFoundException $e) { + // since we use the default driver, this cannot happen } - - return array_map( - function (array $memberOf) { return $memberOf['dn']; }, - array_filter( - ldap_get_entries($this->bindProvider->getLinkIdentifier(), $searchResult), - function ($element) { return is_array($element); } - ) - ); } - } diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 5fef3b9..1984a78 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -3,4 +3,5 @@ Neos: roles: default: [] groupMapping: [] + propertyMapping: [] userMapping: [] diff --git a/Configuration/Settings.yaml.ad.example b/Configuration/Settings.yaml.ad.example deleted file mode 100644 index 73d7232..0000000 --- a/Configuration/Settings.yaml.ad.example +++ /dev/null @@ -1,51 +0,0 @@ -Neos: - Flow: - security: - authentication: - providers: - ActiveDirectoryProvider: - provider: Neos\Ldap\Security\Authentication\Provider\LdapProvider - providerOptions: - host: localhost - port: 389 - - baseDn: dc=my-domain,dc=com - - type: 'ActiveDirectory' - - # All PHP Ldap options can be set here. Make the constant lowercase - # and remove the ldap_opt_ prefix. - # Example: LDAP_OPT_PROTOCOL_VERSION becomes protocol_version - ldapOptions: - protocol_version: 3 - network_timeout: 10 - referrals: 0 - - filter: - # %s will be replaced with the username / dn provided - account: '(samaccountname=%s)' - memberOf: '(&(member=%s)(objectClass=group))' - - # this will use the filter with domain, set it to yes to remove it for search - ignoreDomain: FALSE - - # will be prefixed to a given username if no other domain was specified - domain: 'MY-DOMAIN' - -Neos: - Ldap: - roles: - default: [] - - 'Neos.Neos:RestrictedEditor' - # map group memberships to roles - groupMapping: [] - 'Neos.Neos:Administrator': - - 'CN=Administrators,OU=Groups,DC=domain,DC=tld' - 'Neos.Neos:Editor': - - 'CN=Editors,OU=Groups,DC=domain,DC=tld' - # map certain users to roles - userMapping: [] - 'Neos.Neos:Administrator': - - 'CN=Admin,OU=Users,DC=domain,DC=tld' - 'Neos.Neos:Editor': - - 'CN=Mustermann,OU=Users,DC=domain,DC=tld' diff --git a/Configuration/Settings.yaml.example b/Configuration/Settings.yaml.example new file mode 100644 index 0000000..339958a --- /dev/null +++ b/Configuration/Settings.yaml.example @@ -0,0 +1,97 @@ +--- +Neos: + Flow: + security: + authentication: + providers: + Neos.Neos:Backend: + # Either the generic LdapProvider, oder the NeosBackendLdapProvider for Neos CMS + provider: Neos\Ldap\Security\Authentication\Provider\NeosBackendLdapProvider + providerOptions: + connection: + # 'none', 'ssl', 'tls' +# encryption: none + host: localhost + # default: 3, maps to options.protocol_version +# version: 3 + # default is built automatically from host (with optional port and encryption) +# connection_string: '' + # default: `$encryption == 'ssl' ? 636 : 389` +# port: 389 + # if true, options.debug_level will be set to 7 +# debug: false + # default value for options.referrals +# referrals: false + + # All PHP Ldap options can be set here. Make the constant lowercase and remove the ldap_opt_ prefix + # Example: LDAP_OPT_PROTOCOL_VERSION becomes protocol_version +# options: +# client_controls: [] +# debug_level: 7 +# deref: 0 +# error_number: 0 +# error_string: '' +# host_name: '' +# matched_dn: '' +# network_timeout: 0 +# protocol_version: 3 +# referrals: false +# restart: false +# server_controls: [] +# sizelimit: 0 +# timelimit: 0 +# x_sasl_authcid: '' +# x_sasl_authzid: '' +# x_sasl_mech: '' +# x_sasl_realm: '' + + # How to authenticate towards the server. Normally this is a given service account and password. + # You can also bind for each user individually, using their password: %s will be replaced with username + bind: + # For AD this can also be '%s@domain.com' or 'DOMAIN\%s' + dn: CN=ldapserviceuser,OU=Users,DC=domain,DC=tld +# password: secret + + queries: + # %s will be replaced with the username provided + account: + baseDn: OU=Users,DC=domain,DC=com + query: (uid=%s) + # Must be set if groupMapping will be used, %s will be replaced by full user dn! +# group: +# baseDn: OU=Groups,DC=domain,DC=com +# query: (&(objectClass=posixGroup)(memberUid=%s)) + + # Define what attributes are really fetched from the directory +# attributesFilter: [dn] + + # If using the NeosBackendLdapProvider, the User will have his name mapped like this +# mapping: +# firstName: user.givenName[0] +# lastName: user.sn[0] + +# Ldap: +# roles: +# default: +# - Neos.Neos:RestrictedEditor + # map group memberships to roles +# groupMapping: +# Neos.Neos:Administrator: +# - CN=Administrators,OU=Groups,DC=domain,DC=tld +# Neos.Neos:Editor: +# - CN=Administrators,OU=Groups,DC=domain,DC=tld +# - CN=Editors,OU=Groups,DC=domain,DC=tld + # map certain properties to a role, can be a regular expression (including delimeters and modifiers) +# propertyMapping: +# Neos.Neos:Administrator: +# objectClass: administrator +# Neos.Neos:Editor: +# department: +# - ~.*mathematics~i +# - /computer science/ + # map certain users to roles +# userMapping: +# Neos.Neos:Administrator: +# - CN=Admin,OU=Users,DC=domain,DC=tld +# Neos.Neos:Editor: +# - CN=Mustermann,OU=Users,DC=domain,DC=tld diff --git a/Configuration/Settings.yaml.ldap.example b/Configuration/Settings.yaml.ldap.example deleted file mode 100644 index 12641eb..0000000 --- a/Configuration/Settings.yaml.ldap.example +++ /dev/null @@ -1,56 +0,0 @@ -Neos: - Flow: - security: - authentication: - providers: - LdapProvider: - provider: Neos\Ldap\Security\Authentication\Provider\LdapProvider - providerOptions: - host: localhost - port: 389 - - baseDn: dc=my-domain,dc=com - - # How to authenticate towards the server. Normally this is a given - # service account and password. Other options are also available, - # consult the bind provider class LdapBind for more examples. - bind: - dn: 'uid=ldapserviceuser,dc=example,dc=com' - password: 'secret' - anonymous: FALSE - - # All PHP Ldap options can be set here. Make the constant lowercase - # and remove the ldap_opt_ prefix. - # Example: LDAP_OPT_PROTOCOL_VERSION becomes protocol_version - ldapOptions: - protocol_version: 3 - network_timeout: 10 - - filter: - # %s will be replaced with the username / dn provided - account: '(uid=%s)' - memberOf: '(&(objectClass=posixGroup)(memberUid=%s))' - - # this will use the filter with domain, set it to yes to remove it for search - ignoreDomain: TRUE - - # will be prefixed to a given username if no other domain was specified - domain: 'MY-DOMAIN' - -Neos: - Ldap: - roles: - default: [] - - 'Neos.Neos:RestrictedEditor' - # map group memberships to roles - groupMapping: [] - 'Neos.Neos:Administrator': - - 'CN=Administrators,OU=Groups,DC=domain,DC=tld' - 'Neos.Neos:Editor': - - 'CN=Editors,OU=Groups,DC=domain,DC=tld' - # map certain users to roles - userMapping: [] - 'Neos.Neos:Administrator': - - 'CN=Admin,OU=Users,DC=domain,DC=tld' - 'Neos.Neos:Editor': - - 'CN=Mustermann,OU=Users,DC=domain,DC=tld' diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..b6a4c2d --- /dev/null +++ b/Readme.md @@ -0,0 +1,105 @@ +# Neos.Ldap Documentation + +## Example + +### `LoginController.php` +```php +view->assign('username', $username); + } + + /** + * @param ActionRequest $originalRequest + * @return string|void + */ + public function onAuthenticationSuccess(ActionRequest $originalRequest = null) { + $this->redirect('status'); + } + + /** + * Logs out a - possibly - currently logged in account. + * + * @return void + */ + public function logoutAction() { + $this->authenticationManager->logout(); + $this->addFlashMessage('Successfully logged out.'); + $this->redirect('index'); + } + + /** + * @return void + */ + public function statusAction() { + $this->view->assign('activeTokens', $this->securityContext->getAuthenticationTokens()); + } +} +``` + +### `Index.html` +```html + + +
+ + +
+
+ + +
+ + +
+``` + +### `Status.html` +```html +Status: Logged in
+User: {activeTokens.LdapProvider.account.accountIdentifier}
+Logout +``` + +### `Policy.yaml` +Make sure you configure the policies so that the login and logout actions are available for the user. +```yaml +resources: + methods: + My_Package_LoginController: method(My\Package\Controller\LoginController->(index|status|login|authenticate|logout)Action()) + + acls: + Everybody: + methods: + My_Package_LoginController: GRANT +``` + +## Configuration examples +You can find examples of a ``Settings.yaml`` file for Ldap and Active Directory [here](Configuration/Settings.yaml.example) in the `Configuration` folder of the +Neos.Ldap package. diff --git a/Readme.rst b/Readme.rst deleted file mode 100644 index 7bd3f63..0000000 --- a/Readme.rst +++ /dev/null @@ -1,96 +0,0 @@ -Neos Ldap Documentation -======================= - -Example LoginController ------------------------ - -LoginController.php:: - - view->assign('username', $username); - } - - /** - * @param \Neos\Flow\Mvc\ActionRequest $originalRequest - * @return string|void - */ - public function onAuthenticationSuccess(\Neos\Flow\Mvc\ActionRequest $originalRequest = NULL) { - $this->redirect('status'); - } - - /** - * Logs out a - possibly - currently logged in account. - * - * @return void - */ - public function logoutAction() { - $this->authenticationManager->logout(); - $this->addFlashMessage('Successfully logged out.'); - $this->redirect('index'); - } - - /** - * @return void - */ - public function statusAction() { - $this->view->assign('activeTokens', $this->securityContext->getAuthenticationTokens()); - } - - } - -Index.html:: - - - -
- - -
-
- - -
- - -
- -Status.html:: - - Status: Logged in
- User: {activeTokens.LdapProvider.account.accountIdentifier}
- Logout - -Make sure you configure the policies so that the login and logout actions are available for the user. For that use a Policy.yaml -like:: - - resources: - methods: - My_Package_LoginController: 'method(My\Package\Controller\LoginController->(index|status|login|authenticate|logout)Action())' - - acls: - Everybody: - methods: - My_Package_LoginController: GRANT - -Configuration examples ----------------------- - -You can find examples of a ``Settings.yaml`` file for Ldap and Active Directory in the Configuration/ folder -of the Neos.Ldap package. diff --git a/Resources/Private/Schema/Settings.Neos.Flow.security.authentication.providers.schema.yaml b/Resources/Private/Schema/Settings.Neos.Flow.security.authentication.providers.schema.yaml new file mode 100644 index 0000000..8800c3a --- /dev/null +++ b/Resources/Private/Schema/Settings.Neos.Flow.security.authentication.providers.schema.yaml @@ -0,0 +1,199 @@ +type: dictionary +additionalProperties: + type: dictionary + properties: + providerOptions: + type: dictionary + additionalProperties: false + properties: + attributesFilter: + type: array + items: + type: string + bind: + required: true + type: dictionary + additionalProperties: false + properties: + dn: + required: true + type: string + password: + type: string + connection: + type: dictionary + additionalProperties: false + properties: + connection_string: + type: string + format: uri + debug: + type: boolean + encryption: + type: string + enum: + - none + - ssl + - tls + host: + type: string + format: host-name + options: + type: dictionary + additionalProperties: false + properties: + client_controls: &controls + type: array + items: + type: dictionary + additionalProperties: false + properties: + oid: + required: true + type: string + enum: + - LDAP_CONTROL_AUTHZID_REQUEST + - LDAP_CONTROL_DONTUSECOPY + - LDAP_CONTROL_MANAGEDSAIT + - LDAP_CONTROL_PASSWORDPOLICYREQUEST + - LDAP_CONTROL_PROXY_AUTHZ + - LDAP_CONTROL_SUBENTRIES + - LDAP_CONTROL_SYNC + - LDAP_CONTROL_X_DOMAIN_SCOPE + - LDAP_CONTROL_X_EXTENDED_DN + - LDAP_CONTROL_X_INCREMENTAL_VALUES + - LDAP_CONTROL_X_PERMISSIVE_MODIFY + - LDAP_CONTROL_X_SEARCH_OPTIONS + - LDAP_CONTROL_X_TREE_DELETE + + - LDAP_CONTROL_PAGEDRESULTS + - LDAP_CONTROL_ASSERT + - LDAP_CONTROL_VALUESRETURNFILTER + - LDAP_CONTROL_PRE_READ + - LDAP_CONTROL_POST_READ + - LDAP_CONTROL_SORTREQUEST + - LDAP_CONTROL_VLVREQUEST + iscritical: + type: boolean + value: + type: + - string + - type: array + items: + type: dictionary + additionalProperties: false + properties: + attr: + required: true + type: string + oid: + type: string + reverse: + type: boolean + - type: dictionary + additionalProperties: false + properties: + after: + type: number + attrs: + type: array + items: + type: string + attrvalue: + type: string + before: + type: number + context: + type: string + cookie: + type: string + count: + type: number + filter: + type: string + offset: + type: number + size: + type: number + + debug_level: + type: integer + minimum: 0 + deref: + type: integer + error_number: + type: integer + error_string: + type: string + host_name: + type: string + matched_dn: + type: string + network_timeout: + type: integer + minimum: -1 + protocol_version: + type: integer + minimum: 1 + maximum: 3 + referrals: + type: boolean + restart: + type: boolean + server_controls: *controls + sizelimit: + type: integer + minimum: 0 + timelimit: + type: integer + minimum: 0 + x_sasl_authcid: + type: string + x_sasl_authzid: + type: string + x_sasl_mech: + type: string + x_sasl_realm: + type: string + port: + type: integer + minimum: 1 + maximum: 65535 + referrals: + type: boolean + version: + type: integer + minimum: 1 + maximum: 3 + mapping: + type: dictionary + additionalProperties: false + properties: + firstName: + type: string + lastName: + type: string + queries: + required: true + type: dictionary + additionalProperties: false + properties: + account: + required: true + type: dictionary + properties: + baseDn: + required: true + type: string + query: + required: true + type: string + group: + type: dictionary + properties: + baseDn: + required: true + type: string + query: + required: true + type: string diff --git a/Resources/Private/Schema/Settings.Neos.Ldap.roles.schema.yaml b/Resources/Private/Schema/Settings.Neos.Ldap.roles.schema.yaml new file mode 100644 index 0000000..d8b4966 --- /dev/null +++ b/Resources/Private/Schema/Settings.Neos.Ldap.roles.schema.yaml @@ -0,0 +1,29 @@ +type: dictionary +additionalProperties: false +properties: + default: + type: array + items: + type: string + groupMapping: + type: dictionary + additionalProperties: + type: array + items: + type: string + propertyMapping: + type: dictionary + additionalProperties: + type: dictionary + additionalProperties: + type: + - string + - type: array + items: + type: string + userMapping: + type: dictionary + additionalProperties: + type: array + items: + type: string diff --git a/composer.json b/composer.json index 1616fb6..f5d4d9c 100644 --- a/composer.json +++ b/composer.json @@ -2,12 +2,16 @@ "name": "neos/ldap", "type": "neos-package", "description": "Ldap Authentication for Flow", - "license": [ - "MIT" - ], + "license": "MIT", "require": { + "php": "^7.4 || ^8.0", + "neos/eel": "^7.0 || ^8.0", "neos/flow": "^7.0 || ^8.0", - "ext-ldap": "*" + "neos/neos": "^7.0 || ^8.0", + "neos/utility-arrays": "^7.0 || ^8.0", + "neos/utility-files": "^7.0 || ^8.0", + "symfony/ldap": "^5.0", + "symfony/yaml": "^5.0" }, "autoload": { "psr-4": { @@ -15,8 +19,36 @@ } }, "extra": { + "neos": { + "package-key": "Neos.Ldap" + }, "branch-alias": { - "dev-master": "3.0.x-dev" - } + "dev-master": "3.1.x-dev" + }, + "applied-flow-migrations": [ + "TYPO3.FLOW3-201209201112", + "TYPO3.FLOW3-201201261636", + "TYPO3.Form-20160601101500", + "Neos.Twitter.Bootstrap-20161124204912", + "Neos.Form-20161124205254", + "Neos.Party-20161124225257", + "Neos.Imagine-20161124231742", + "Neos.SwiftMailer-20161130105617", + "Neos.ContentRepository.Search-20161210231100", + "Neos.Seo-20170127154600", + "Neos.Flow-20180415105700", + "Neos.Neos-20180907103800", + "Neos.Neos.Ui-20190319094900", + "Neos.Flow-20190425144900", + "Neos.Flow-20190515215000", + "Neos.NodeTypes-20190917101945", + "Networkteam.Neos.MailObfuscator-20190919145400", + "Neos.NodeTypes-20200120114136", + "Neos.Flow-20200813181400", + "Neos.Flow-20201003165200", + "Neos.Flow-20201109224100", + "Neos.Flow-20201205172733", + "Neos.Flow-20201207104500" + ] } -} +} \ No newline at end of file