diff --git a/README.md b/README.md
index 1955d8b..4512b08 100644
--- a/README.md
+++ b/README.md
@@ -87,3 +87,34 @@ resources to aid you further:
- **User handbook**: [https://dd.digitalitzacio-democratica.xnet-x.net/manual-usuari/](https://dd.digitalitzacio-democratica.xnet-x.net/manual-usuari/)
- **Admin/developer docs**: [https://dd.digitalitzacio-democratica.xnet-x.net/docs/](https://dd.digitalitzacio-democratica.xnet-x.net/docs/)
- **Source code**: [https://gitlab.com/DD-workspace/DD](https://gitlab.com/DD-workspace/DD)
+
+# Why does git history start here?
+
+Why does git history start here?
+
+A lot of work went into stabilising the code and cleaning the repo before the
+public announcement on the
+[1st International Congress on Democratic Digital Education and Open Edtech](https://congress.democratic-digitalisation.xnet-x.net/).
+
+Using that version as a clean slate got us to the repo you see here, where
+changes will be reviewed before going in and anyone is welcome.
+
+When in doubt about authorship, please check each file's license headers.
+
+The authorship of the previous commits is from:
+
+- Josep Maria Viñolas Auquer
+- Simó Albert i Beltran
+- Alberto Larraz Dalmases
+- Yoselin Ribero
+- Elena Barrios Galán
+- Melina Gamboa
+- Antonio Manzano
+- Cecilia Bayo
+- Naomi Hidalgo
+- Joan Cervan Andreu
+- Jose Antonio Exposito Garcia
+- Raúl FS
+- Unai Tolosa Pontesta
+- Evilham
+
diff --git a/dd-apps/docker/network.yml b/dd-apps/docker/network.yml
index 038d81e..d9f79fd 100644
--- a/dd-apps/docker/network.yml
+++ b/dd-apps/docker/network.yml
@@ -21,3 +21,6 @@ version: '3.7'
networks:
dd_net:
name: dd_net
+ driver: bridge
+ driver_opts:
+ com.docker.network.driver.mtu: ${NETWORK_MTU:-1500}
diff --git a/dd-apps/docker/nextcloud/dd-patch b/dd-apps/docker/nextcloud/dd-patch
index 9b8b74c..0084ea6 100644
--- a/dd-apps/docker/nextcloud/dd-patch
+++ b/dd-apps/docker/nextcloud/dd-patch
@@ -1,3 +1,9 @@
# Generate .orig and .patch files with ./dd-ctl genpatches
# file license author source
nginx.conf AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/docker/522559eefdd56d2e49259c3b0f4a0e92882cdb87/.examples/docker-compose/with-nginx-proxy/postgres/fpm/web/nginx.conf
+#nc_mail/appinfo.xml AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/MaadixNet/mail/feature/occ-account-update-command/appinfo/info.xml
+#nc_mail/lib/Command/UpdateAccount.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/MaadixNet/mail/feature/occ-account-update-command/lib/Command/UpdateAccount.php
+#nc_mail/lib/Db/MailAccountMapper.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/MaadixNet/mail/feature/occ-account-update-command/lib/Db/MailAccountMapper.php
+nc_mail/appinfo/info.xml AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/mail/v1.12.8/appinfo/info.xml
+nc_mail/lib/Command/UpdateAccount.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/mail/v1.12.8/lib/Command/UpdateAccount.php
+nc_mail/lib/Db/MailAccountMapper.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/mail/v1.12.8/lib/Db/MailAccountMapper.php
diff --git a/dd-apps/docker/nextcloud/nc_mail/appinfo/info.xml b/dd-apps/docker/nextcloud/nc_mail/appinfo/info.xml
new file mode 100644
index 0000000..a7e8311
--- /dev/null
+++ b/dd-apps/docker/nextcloud/nc_mail/appinfo/info.xml
@@ -0,0 +1,75 @@
+
+
+ mail
+ Mail
+ 💌 A mail app for Nextcloud
+
+ 1.12.8
+ agpl
+ Greta Doçi
+ Nextcloud Groupware Team
+ Mail
+
+ https://github.com/nextcloud/mail/blob/main/doc/user.md
+ https://github.com/nextcloud/mail/blob/main/doc/admin.md
+ https://github.com/nextcloud/mail/blob/main/doc/developer.md
+
+ social
+ office
+ https://github.com/nextcloud/mail#readme
+ https://github.com/nextcloud/mail/issues
+ https://github.com/nextcloud/mail.git
+ https://user-images.githubusercontent.com/1374172/79554966-278e1600-809f-11ea-82ea-7a0d72a2704f.png
+
+
+
+
+
+ OCA\Mail\BackgroundJob\CleanupJob
+ OCA\Mail\BackgroundJob\OutboxWorkerJob
+
+
+
+ OCA\Mail\Migration\AddMissingDefaultTags
+ OCA\Mail\Migration\AddMissingMessageIds
+ OCA\Mail\Migration\FixCollectedAddresses
+ OCA\Mail\Migration\FixBackgroundJobs
+ OCA\Mail\Migration\MakeItineraryExtractorExecutable
+ OCA\Mail\Migration\ProvisionAccounts
+ OCA\Mail\Migration\RepairMailTheads
+
+
+
+ OCA\Mail\Command\AddMissingTags
+ OCA\Mail\Command\CleanUp
+ OCA\Mail\Command\CreateAccount
+ OCA\Mail\Command\CreateTagMigrationJobEntry
+ OCA\Mail\Command\DeleteAccount
+ OCA\Mail\Command\DiagnoseAccount
+ OCA\Mail\Command\ExportAccount
+ OCA\Mail\Command\ExportAccountThreads
+ OCA\Mail\Command\SyncAccount
+ OCA\Mail\Command\TrainAccount
+ OCA\Mail\Command\Thread
+ OCA\Mail\Command\UpdateAccount
+
+
+ OCA\Mail\Settings\AdminSettings
+
+
+
+ Mail
+ mail.page.index
+ mail.svg
+ 3
+
+
+
diff --git a/dd-apps/docker/nextcloud/nc_mail/lib/Command/UpdateAccount.php b/dd-apps/docker/nextcloud/nc_mail/lib/Command/UpdateAccount.php
new file mode 100644
index 0000000..e6f6324
--- /dev/null
+++ b/dd-apps/docker/nextcloud/nc_mail/lib/Command/UpdateAccount.php
@@ -0,0 +1,154 @@
+
+ * @author Maadix
+ *
+ * Mail
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see
+ *
+ */
+
+namespace OCA\Mail\Command;
+
+use OCA\Mail\Db\MailAccountMapper;
+use OCP\Security\ICrypto;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class UpdateAccount extends Command {
+ public const ARGUMENT_USER_ID = 'user-id';
+ public const ARGUMENT_EMAIL = 'email';
+ public const ARGUMENT_IMAP_HOST = 'imap-host';
+ public const ARGUMENT_IMAP_PORT = 'imap-port';
+ public const ARGUMENT_IMAP_SSL_MODE = 'imap-ssl-mode';
+ public const ARGUMENT_IMAP_USER = 'imap-user';
+ public const ARGUMENT_IMAP_PASSWORD = 'imap-password';
+ public const ARGUMENT_SMTP_HOST = 'smtp-host';
+ public const ARGUMENT_SMTP_PORT = 'smtp-port';
+ public const ARGUMENT_SMTP_SSL_MODE = 'smtp-ssl-mode';
+ public const ARGUMENT_SMTP_USER = 'smtp-user';
+ public const ARGUMENT_SMTP_PASSWORD = 'smtp-password';
+
+ /** @var mapper */
+ private $mapper;
+
+ /** @var ICrypto */
+ private $crypto;
+
+ public function __construct(MailAccountMapper $mapper, ICrypto $crypto) {
+ parent::__construct();
+
+ $this->mapper = $mapper;
+ $this->crypto = $crypto;
+ }
+
+ /**
+ * @return void
+ */
+ protected function configure() {
+ $this->setName('mail:account:update');
+ $this->setDescription('Update a user\'s IMAP account');
+ $this->addArgument(self::ARGUMENT_USER_ID, InputArgument::REQUIRED);
+ $this->addArgument(self::ARGUMENT_EMAIL, InputArgument::REQUIRED);
+
+ $this->addOption(self::ARGUMENT_IMAP_HOST, '', InputOption::VALUE_OPTIONAL);
+ $this->addOption(self::ARGUMENT_IMAP_PORT, '', InputOption::VALUE_OPTIONAL);
+ $this->addOption(self::ARGUMENT_IMAP_SSL_MODE, '', InputOption::VALUE_OPTIONAL);
+ $this->addOption(self::ARGUMENT_IMAP_USER, '', InputOption::VALUE_OPTIONAL);
+ $this->addOption(self::ARGUMENT_IMAP_PASSWORD, '', InputOption::VALUE_OPTIONAL);
+
+ $this->addOption(self::ARGUMENT_SMTP_HOST, '', InputOption::VALUE_OPTIONAL);
+ $this->addOption(self::ARGUMENT_SMTP_PORT, '', InputOption::VALUE_OPTIONAL);
+ $this->addOption(self::ARGUMENT_SMTP_SSL_MODE, '', InputOption::VALUE_OPTIONAL);
+ $this->addOption(self::ARGUMENT_SMTP_USER, '', InputOption::VALUE_OPTIONAL);
+ $this->addOption(self::ARGUMENT_SMTP_PASSWORD, '', InputOption::VALUE_OPTIONAL);
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $userId = $input->getArgument(self::ARGUMENT_USER_ID);
+ $email = $input->getArgument(self::ARGUMENT_EMAIL);
+
+ $imapHost = $input->getOption(self::ARGUMENT_IMAP_HOST);
+ $imapPort = $input->getOption(self::ARGUMENT_IMAP_PORT);
+ $imapSslMode = $input->getOption(self::ARGUMENT_IMAP_SSL_MODE);
+ $imapUser = $input->getOption(self::ARGUMENT_IMAP_USER);
+ $imapPassword = $input->getOption(self::ARGUMENT_IMAP_PASSWORD);
+
+ $smtpHost = $input->getOption(self::ARGUMENT_SMTP_HOST);
+ $smtpPort = $input->getOption(self::ARGUMENT_SMTP_PORT);
+ $smtpSslMode = $input->getOption(self::ARGUMENT_SMTP_SSL_MODE);
+ $smtpUser = $input->getOption(self::ARGUMENT_SMTP_USER);
+ $smtpPassword = $input->getOption(self::ARGUMENT_SMTP_PASSWORD);
+
+ $mailAccount = $this->mapper->findByUserIdAndEmail($userId, $email);
+
+ if ($mailAccount) {
+ //INBOUND
+ if ($input->getOption(self::ARGUMENT_IMAP_HOST)) {
+ $mailAccount->setInboundHost($imapHost);
+ }
+
+ if ($input->getOption(self::ARGUMENT_IMAP_PORT)) {
+ $mailAccount->setInboundPort((int) $imapPort);
+ }
+
+ if ($input->getOption(self::ARGUMENT_IMAP_SSL_MODE)) {
+ $mailAccount->setInboundSslMode($imapSslMode);
+ }
+
+ if ($input->getOption(self::ARGUMENT_IMAP_PASSWORD)) {
+ $mailAccount->setInboundPassword($this->crypto->encrypt($imapPassword));
+ }
+
+ if ($input->getOption(self::ARGUMENT_SMTP_USER)) {
+ $mailAccount->setInboundUser($imapUser);
+ }
+
+ // OUTBOUND
+
+ if ($input->getOption(self::ARGUMENT_SMTP_HOST)) {
+ $mailAccount->setOutboundHost($smtpHost);
+ }
+
+ if ($input->getOption(self::ARGUMENT_SMTP_PORT)) {
+ $mailAccount->setOutboundPort((int) $smtpPort);
+ }
+
+ if ($input->getOption(self::ARGUMENT_SMTP_SSL_MODE)) {
+ $mailAccount->setOutboundSslMode($smtpSslMode);
+ }
+
+ if ($input->getOption(self::ARGUMENT_SMTP_PASSWORD)) {
+ $mailAccount->setOutboundPassword($this->crypto->encrypt($smtpPassword));
+ }
+
+ if ($input->getOption(self::ARGUMENT_SMTP_USER)) {
+ $mailAccount->setOutboundUser($smtpUser);
+ }
+
+ $this->mapper->save($mailAccount);
+
+ $output->writeln("Account $email for user $userId succesfully updated ");
+ return 1;
+ } else {
+ $output->writeln("No Email Account $email found for user $userId ");
+ }
+
+ return 0;
+ }
+}
diff --git a/dd-apps/docker/nextcloud/nc_mail/lib/Db/MailAccountMapper.php b/dd-apps/docker/nextcloud/nc_mail/lib/Db/MailAccountMapper.php
new file mode 100644
index 0000000..6c5113b
--- /dev/null
+++ b/dd-apps/docker/nextcloud/nc_mail/lib/Db/MailAccountMapper.php
@@ -0,0 +1,188 @@
+
+ * @author Christoph Wurst
+ * @author Lukas Reschke
+ * @author Thomas Müller
+ *
+ * Mail
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see
+ *
+ */
+
+namespace OCA\Mail\Db;
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\MultipleObjectsReturnedException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+use OCP\IUser;
+
+/**
+ * @template-extends QBMapper
+ */
+class MailAccountMapper extends QBMapper {
+
+ /**
+ * @param IDBConnection $db
+ */
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'mail_accounts');
+ }
+
+ /** Finds an Mail Account by id
+ *
+ * @param string $userId
+ * @param int $accountId
+ *
+ * @return MailAccount
+ *
+ * @throws DoesNotExistException
+ */
+ public function find(string $userId, int $accountId): MailAccount {
+ $qb = $this->db->getQueryBuilder();
+ $query = $qb
+ ->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->eq('id', $qb->createNamedParameter($accountId)));
+
+ return $this->findEntity($query);
+ }
+
+ /**
+ * Finds an mail account by id
+ *
+ * @return MailAccount
+ * @throws DoesNotExistException
+ */
+ public function findById(int $id): MailAccount {
+ $qb = $this->db->getQueryBuilder();
+ $query = $qb
+ ->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('id', $qb->createNamedParameter($id)));
+
+ return $this->findEntity($query);
+ }
+
+ /**
+ * Finds all Mail Accounts by user id existing for this user
+ *
+ * @param string $userId the id of the user that we want to find
+ *
+ * @return MailAccount[]
+ */
+ public function findByUserId(string $userId): array {
+ $qb = $this->db->getQueryBuilder();
+ $query = $qb
+ ->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
+
+ return $this->findEntities($query);
+ }
+ /**
+ * Finds an mail account by user id and email address
+ *
+ * @return MailAccount
+ * @throws DoesNotExistException
+ */
+ public function findByUserIdAndEmail(string $userId, string $email): MailAccount {
+ $qb = $this->db->getQueryBuilder();
+ $query = $qb
+ ->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR))
+ ->andWhere($qb->expr()->eq('email', $qb->createNamedParameter($email, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR));
+
+ return $this->findEntity($query);
+ }
+
+ /**
+ * @throws DoesNotExistException
+ * @throws MultipleObjectsReturnedException
+ */
+ public function findProvisionedAccount(IUser $user): MailAccount {
+ $qb = $this->db->getQueryBuilder();
+
+ $query = $qb
+ ->select('*')
+ ->from($this->getTableName())
+ ->where(
+ $qb->expr()->eq('user_id', $qb->createNamedParameter($user->getUID())),
+ $qb->expr()->isNotNull('provisioning_id')
+ );
+
+ return $this->findEntity($query);
+ }
+
+ /**
+ * Saves an User Account into the database
+ *
+ * @param MailAccount $account
+ *
+ * @return MailAccount
+ */
+ public function save(MailAccount $account): MailAccount {
+ if ($account->getId() === null) {
+ return $this->insert($account);
+ }
+
+ return $this->update($account);
+ }
+
+ public function deleteProvisionedAccounts(int $provisioningId): void {
+ $qb = $this->db->getQueryBuilder();
+
+ $delete = $qb->delete($this->getTableName())
+ ->where($qb->expr()->eq('provisioning_id', $qb->createNamedParameter($provisioningId, IQueryBuilder::PARAM_INT)));
+
+ $delete->execute();
+ }
+
+ public function deleteProvisionedAccountsByUid(string $uid): void {
+ $qb = $this->db->getQueryBuilder();
+
+ $delete = $qb->delete($this->getTableName())
+ ->where(
+ $qb->expr()->eq('user_id', $qb->createNamedParameter($uid)),
+ $qb->expr()->isNotNull('provisioning_id')
+ );
+
+ $delete->execute();
+ }
+
+ public function getAllAccounts(): array {
+ $qb = $this->db->getQueryBuilder();
+ $query = $qb
+ ->select('*')
+ ->from($this->getTableName());
+
+ return $this->findEntities($query);
+ }
+
+ public function getAllUserIdsWithAccounts(): array {
+ $qb = $this->db->getQueryBuilder();
+ $query = $qb
+ ->selectDistinct('user_id')
+ ->from($this->getTableName());
+
+ return $this->findEntities($query);
+ }
+}
diff --git a/dd-ctl b/dd-ctl
index 8557fda..87da8e0 100755
--- a/dd-ctl
+++ b/dd-ctl
@@ -306,6 +306,12 @@ setup_nextcloud(){
EOF
done
+ # Temporary patch while upstream lands our changes
+ # See: https://github.com/nextcloud/mail/pull/6908
+ for f in appinfo/info.xml lib/Command/UpdateAccount.php lib/Db/MailAccountMapper.php; do
+ install -m 0644 -o 82 -g 82 "dd-apps/docker/nextcloud/nc_mail/$f" "${SRC_FOLDER}/nextcloud/custom_apps/mail/$f"
+ done
+
# Custom forms
docker exec dd-apps-nextcloud-app apk add git npm composer
docker exec -u www-data dd-apps-nextcloud-app rm -rf /var/www/html/custom_apps/forms
@@ -476,11 +482,9 @@ saml_certificates(){
echo " --> Setting up SAML for wordpress"
docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 wordpress_saml.py"
- # SAML PLUGIN MOODLE
- # echo "To add SAML to moodle:"
- # echo "1.-Activate SAML plugin in moodle extensions, regenerate certificate, lock certificate"
- # echo "2.-Then run: docker exec -ti dd-sso-admin python3 /admin/nextcloud_saml.py"
- # echo "3.-"
+ # SAML PLUGIN EMAIL
+ echo " --> Setting up SAML for email"
+ docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 email_saml.py"
}
wait_for_moodle(){
diff --git a/dd-sso/admin/.gitignore b/dd-sso/admin/.gitignore
new file mode 100644
index 0000000..1954d2f
--- /dev/null
+++ b/dd-sso/admin/.gitignore
@@ -0,0 +1,2 @@
+secret
+.dmypy.json
diff --git a/dd-sso/admin/Pipfile b/dd-sso/admin/Pipfile
new file mode 100644
index 0000000..9dba800
--- /dev/null
+++ b/dd-sso/admin/Pipfile
@@ -0,0 +1,37 @@
+[[source]]
+url = "https://pypi.org/simple"
+verify_ssl = true
+name = "pypi"
+
+[packages]
+flask = "*"
+flask-login = "*"
+psycopg2 = "*"
+minio = "*"
+diceware = "*"
+cerberus = "*"
+pillow = "*"
+schema = "*"
+python-jose = "*"
+flask-socketio = "*"
+mysql-connector-python = "*"
+eventlet = "*"
+pyyaml = "*"
+requests = "*"
+python-keycloak = "*"
+attrs = "*"
+cryptography = "*"
+
+[dev-packages]
+mypy = "*"
+black = "*"
+isort = "*"
+types-flask = "*"
+types-requests = "*"
+types-psycopg2 = "*"
+types-pyyaml = "*"
+types-python-jose = "*"
+types-pillow = "*"
+
+[requires]
+python_version = "3.8"
diff --git a/dd-sso/admin/Pipfile.lock b/dd-sso/admin/Pipfile.lock
new file mode 100644
index 0000000..5508294
--- /dev/null
+++ b/dd-sso/admin/Pipfile.lock
@@ -0,0 +1,877 @@
+{
+ "_meta": {
+ "hash": {
+ "sha256": "8a5f88b027753cb1145b10e191326d6e9cfaa1c3333a773ac91071c3e7b7008c"
+ },
+ "pipfile-spec": 6,
+ "requires": {
+ "python_version": "3.8"
+ },
+ "sources": [
+ {
+ "name": "pypi",
+ "url": "https://pypi.org/simple",
+ "verify_ssl": true
+ }
+ ]
+ },
+ "default": {
+ "attrs": {
+ "hashes": [
+ "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6",
+ "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"
+ ],
+ "index": "pypi",
+ "version": "==22.1.0"
+ },
+ "bidict": {
+ "hashes": [
+ "sha256:415126d23a0c81e1a8c584a8fb1f6905ea090c772571803aeee0a2242e8e7ba0",
+ "sha256:5c826b3e15e97cc6e615de295756847c282a79b79c5430d3bfc909b1ac9f5bd8"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==0.22.0"
+ },
+ "cerberus": {
+ "hashes": [
+ "sha256:d1b21b3954b2498d9a79edf16b3170a3ac1021df88d197dc2ce5928ba519237c"
+ ],
+ "index": "pypi",
+ "version": "==1.3.4"
+ },
+ "certifi": {
+ "hashes": [
+ "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d",
+ "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"
+ ],
+ "markers": "python_version >= '3.6'",
+ "version": "==2022.6.15"
+ },
+ "cffi": {
+ "hashes": [
+ "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5",
+ "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef",
+ "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104",
+ "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426",
+ "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405",
+ "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375",
+ "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a",
+ "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e",
+ "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc",
+ "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf",
+ "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185",
+ "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497",
+ "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3",
+ "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35",
+ "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c",
+ "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83",
+ "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21",
+ "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca",
+ "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984",
+ "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac",
+ "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd",
+ "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee",
+ "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a",
+ "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2",
+ "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192",
+ "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7",
+ "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585",
+ "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f",
+ "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e",
+ "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27",
+ "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b",
+ "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e",
+ "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e",
+ "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d",
+ "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c",
+ "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415",
+ "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82",
+ "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02",
+ "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314",
+ "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325",
+ "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c",
+ "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3",
+ "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914",
+ "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045",
+ "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d",
+ "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9",
+ "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5",
+ "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2",
+ "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c",
+ "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3",
+ "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2",
+ "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8",
+ "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d",
+ "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d",
+ "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9",
+ "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162",
+ "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76",
+ "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4",
+ "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e",
+ "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9",
+ "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6",
+ "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b",
+ "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01",
+ "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"
+ ],
+ "version": "==1.15.1"
+ },
+ "charset-normalizer": {
+ "hashes": [
+ "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5",
+ "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"
+ ],
+ "markers": "python_version >= '3.6'",
+ "version": "==2.1.0"
+ },
+ "click": {
+ "hashes": [
+ "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e",
+ "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==8.1.3"
+ },
+ "contextlib2": {
+ "hashes": [
+ "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f",
+ "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869"
+ ],
+ "markers": "python_version >= '3.6'",
+ "version": "==21.6.0"
+ },
+ "cryptography": {
+ "hashes": [
+ "sha256:190f82f3e87033821828f60787cfa42bff98404483577b591429ed99bed39d59",
+ "sha256:2be53f9f5505673eeda5f2736bea736c40f051a739bfae2f92d18aed1eb54596",
+ "sha256:30788e070800fec9bbcf9faa71ea6d8068f5136f60029759fd8c3efec3c9dcb3",
+ "sha256:3d41b965b3380f10e4611dbae366f6dc3cefc7c9ac4e8842a806b9672ae9add5",
+ "sha256:4c590ec31550a724ef893c50f9a97a0c14e9c851c85621c5650d699a7b88f7ab",
+ "sha256:549153378611c0cca1042f20fd9c5030d37a72f634c9326e225c9f666d472884",
+ "sha256:63f9c17c0e2474ccbebc9302ce2f07b55b3b3fcb211ded18a42d5764f5c10a82",
+ "sha256:6bc95ed67b6741b2607298f9ea4932ff157e570ef456ef7ff0ef4884a134cc4b",
+ "sha256:7099a8d55cd49b737ffc99c17de504f2257e3787e02abe6d1a6d136574873441",
+ "sha256:75976c217f10d48a8b5a8de3d70c454c249e4b91851f6838a4e48b8f41eb71aa",
+ "sha256:7bc997818309f56c0038a33b8da5c0bfbb3f1f067f315f9abd6fc07ad359398d",
+ "sha256:80f49023dd13ba35f7c34072fa17f604d2f19bf0989f292cedf7ab5770b87a0b",
+ "sha256:91ce48d35f4e3d3f1d83e29ef4a9267246e6a3be51864a5b7d2247d5086fa99a",
+ "sha256:a958c52505c8adf0d3822703078580d2c0456dd1d27fabfb6f76fe63d2971cd6",
+ "sha256:b62439d7cd1222f3da897e9a9fe53bbf5c104fff4d60893ad1355d4c14a24157",
+ "sha256:b7f8dd0d4c1f21759695c05a5ec8536c12f31611541f8904083f3dc582604280",
+ "sha256:d204833f3c8a33bbe11eda63a54b1aad7aa7456ed769a982f21ec599ba5fa282",
+ "sha256:e007f052ed10cc316df59bc90fbb7ff7950d7e2919c9757fd42a2b8ecf8a5f67",
+ "sha256:f2dcb0b3b63afb6df7fd94ec6fbddac81b5492513f7b0436210d390c14d46ee8",
+ "sha256:f721d1885ecae9078c3f6bbe8a88bc0786b6e749bf32ccec1ef2b18929a05046",
+ "sha256:f7a6de3e98771e183645181b3627e2563dcde3ce94a9e42a3f427d2255190327",
+ "sha256:f8c0a6e9e1dd3eb0414ba320f85da6b0dcbd543126e30fcc546e7372a7fbf3b9"
+ ],
+ "index": "pypi",
+ "version": "==37.0.4"
+ },
+ "diceware": {
+ "hashes": [
+ "sha256:09b62e491cc98ed569bdb51459e4523bbc3fa71b031a9c4c97f6dc93cab8c321",
+ "sha256:b2b4cc9b59f568d2ef51bfdf9f7e1af941d25fb8f5c25f170191dbbabce96569"
+ ],
+ "index": "pypi",
+ "version": "==0.10"
+ },
+ "dnspython": {
+ "hashes": [
+ "sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e",
+ "sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f"
+ ],
+ "markers": "python_version >= '3.6' and python_version < '4.0'",
+ "version": "==2.2.1"
+ },
+ "ecdsa": {
+ "hashes": [
+ "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49",
+ "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"
+ ],
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==0.18.0"
+ },
+ "eventlet": {
+ "hashes": [
+ "sha256:a085922698e5029f820cf311a648ac324d73cec0e4792877609d978a4b5bbf31",
+ "sha256:afbe17f06a58491e9aebd7a4a03e70b0b63fd4cf76d8307bae07f280479b1515"
+ ],
+ "index": "pypi",
+ "version": "==0.33.1"
+ },
+ "flask": {
+ "hashes": [
+ "sha256:15972e5017df0575c3d6c090ba168b6db90259e620ac8d7ea813a396bad5b6cb",
+ "sha256:9013281a7402ad527f8fd56375164f3aa021ecfaff89bfe3825346c24f87e04c"
+ ],
+ "index": "pypi",
+ "version": "==2.1.3"
+ },
+ "flask-login": {
+ "hashes": [
+ "sha256:1ef79843f5eddd0f143c2cd994c1b05ac83c0401dc6234c143495af9a939613f",
+ "sha256:c0a7baa9fdc448cdd3dd6f0939df72eec5177b2f7abe6cb82fc934d29caac9c3"
+ ],
+ "index": "pypi",
+ "version": "==0.6.2"
+ },
+ "flask-socketio": {
+ "hashes": [
+ "sha256:19c3d0cea49c53505fa457fedc133b32cb6eeaaa30d28cdab9d6ca8f16045427",
+ "sha256:c82672b843fa271ec2961d356c608bc94a730660ac73a623bddb66c4b3d72215"
+ ],
+ "index": "pypi",
+ "version": "==5.2.0"
+ },
+ "greenlet": {
+ "hashes": [
+ "sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3",
+ "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711",
+ "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd",
+ "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073",
+ "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708",
+ "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67",
+ "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23",
+ "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1",
+ "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08",
+ "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd",
+ "sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2",
+ "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa",
+ "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8",
+ "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40",
+ "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab",
+ "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6",
+ "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc",
+ "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b",
+ "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e",
+ "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963",
+ "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3",
+ "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d",
+ "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d",
+ "sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe",
+ "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28",
+ "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3",
+ "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e",
+ "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c",
+ "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d",
+ "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0",
+ "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497",
+ "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee",
+ "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713",
+ "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58",
+ "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a",
+ "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06",
+ "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88",
+ "sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965",
+ "sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f",
+ "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4",
+ "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5",
+ "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c",
+ "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a",
+ "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1",
+ "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43",
+ "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627",
+ "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b",
+ "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168",
+ "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d",
+ "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5",
+ "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478",
+ "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf",
+ "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce",
+ "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c",
+ "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+ "version": "==1.1.2"
+ },
+ "idna": {
+ "hashes": [
+ "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
+ "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
+ ],
+ "markers": "python_version >= '3.5'",
+ "version": "==3.3"
+ },
+ "importlib-metadata": {
+ "hashes": [
+ "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670",
+ "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"
+ ],
+ "markers": "python_version < '3.10'",
+ "version": "==4.12.0"
+ },
+ "itsdangerous": {
+ "hashes": [
+ "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44",
+ "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==2.1.2"
+ },
+ "jinja2": {
+ "hashes": [
+ "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852",
+ "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==3.1.2"
+ },
+ "markupsafe": {
+ "hashes": [
+ "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003",
+ "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88",
+ "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5",
+ "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7",
+ "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a",
+ "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603",
+ "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1",
+ "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135",
+ "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247",
+ "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6",
+ "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601",
+ "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77",
+ "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02",
+ "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e",
+ "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63",
+ "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f",
+ "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980",
+ "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b",
+ "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812",
+ "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff",
+ "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96",
+ "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1",
+ "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925",
+ "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a",
+ "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6",
+ "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e",
+ "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f",
+ "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4",
+ "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f",
+ "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3",
+ "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c",
+ "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a",
+ "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417",
+ "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a",
+ "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a",
+ "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37",
+ "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452",
+ "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933",
+ "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a",
+ "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==2.1.1"
+ },
+ "minio": {
+ "hashes": [
+ "sha256:12ac2d1d4fd3cea159d625847445e1bfceba3fbc2f4ab692c2d2bf716f82246c",
+ "sha256:1cab424275749b8b5b8bb0c6cc856d667305ef549796ae56f3237fe55306a1fc"
+ ],
+ "index": "pypi",
+ "version": "==7.1.11"
+ },
+ "mysql-connector-python": {
+ "hashes": [
+ "sha256:1d9d3af14594aceda2c3096564b4c87ffac21e375806a802daeaf7adcd18d36b",
+ "sha256:234c6b156a1989bebca6eb564dc8f2e9d352f90a51bd228ccd68eb66fcd5fd7a",
+ "sha256:33c4e567547a9a1868462fda8f2b19ea186a7b1afe498171dca39c0f3aa43a75",
+ "sha256:36e763f21e62b3c9623a264f2513ee11924ea1c9cc8640c115a279d3087064be",
+ "sha256:41a04d1900e366bf6c2a645ead89ab9a567806d5ada7d417a3a31f170321dd14",
+ "sha256:47deb8c3324db7eb2bfb720ec8084d547b1bce457672ea261bc21836024249db",
+ "sha256:59a8592e154c874c299763bb8aa12c518384c364bcfd0d193e85c869ea81a895",
+ "sha256:611c6945805216104575f7143ff6497c87396ce82d3257e6da7257b65406f13e",
+ "sha256:62266d1b18cb4e286a05df0e1c99163a4955c82d41045305bcf0ab2aac107843",
+ "sha256:712cdfa97f35fec715e8d7aaa15ed9ce04f3cf71b3c177fcca273047040de9f2",
+ "sha256:7f771bd5cba3ade6d9f7a649e65d7c030f69f0e69980632b5cbbd3d19c39cee5",
+ "sha256:8876b1d51cae33cdfe7021d68206661e94dcd2666e5e14a743f8321e2b068e84",
+ "sha256:8b7d50c221320b0e609dce9ca8801ab2f2a748dfee65cd76b1e4c6940757734a",
+ "sha256:954a1fc2e9a811662c5b17cea24819c020ff9d56b2ff8e583dd0a233fb2399f6",
+ "sha256:a130c5489861c7ff2990e5b503c37beb2fb7b32211b92f9107ad864ee90654c0",
+ "sha256:b5dc0f3295e404f93b674bfaff7589a9fbb8b5ae6c1c134112a1d1beb2f664b2",
+ "sha256:ce23ca9c27e1f7b4707b3299ce515125f312736d86a7e5b2aa778484fa3ffa10",
+ "sha256:d8f74c9388176635f75c01d47d0abc783a47e58d7f36d04fb6ee40ab6fb35c9b",
+ "sha256:f1d40cac9c786e292433716c1ade7a8968cbc3ea177026697b86a63188ddba34",
+ "sha256:f1eb74eb30bb04ff314f5e19af5421d23b504e41d16ddcee2603b4100d18fd68",
+ "sha256:f5d812245754d4759ebc8c075662fef65397e1e2a438a3c391eac9d545077b8b"
+ ],
+ "index": "pypi",
+ "version": "==8.0.30"
+ },
+ "pillow": {
+ "hashes": [
+ "sha256:0030fdbd926fb85844b8b92e2f9449ba89607231d3dd597a21ae72dc7fe26927",
+ "sha256:030e3460861488e249731c3e7ab59b07c7853838ff3b8e16aac9561bb345da14",
+ "sha256:0ed2c4ef2451de908c90436d6e8092e13a43992f1860275b4d8082667fbb2ffc",
+ "sha256:136659638f61a251e8ed3b331fc6ccd124590eeff539de57c5f80ef3a9594e58",
+ "sha256:13b725463f32df1bfeacbf3dd197fb358ae8ebcd8c5548faa75126ea425ccb60",
+ "sha256:1536ad017a9f789430fb6b8be8bf99d2f214c76502becc196c6f2d9a75b01b76",
+ "sha256:15928f824870535c85dbf949c09d6ae7d3d6ac2d6efec80f3227f73eefba741c",
+ "sha256:17d4cafe22f050b46d983b71c707162d63d796a1235cdf8b9d7a112e97b15bac",
+ "sha256:1802f34298f5ba11d55e5bb09c31997dc0c6aed919658dfdf0198a2fe75d5490",
+ "sha256:1cc1d2451e8a3b4bfdb9caf745b58e6c7a77d2e469159b0d527a4554d73694d1",
+ "sha256:1fd6f5e3c0e4697fa7eb45b6e93996299f3feee73a3175fa451f49a74d092b9f",
+ "sha256:254164c57bab4b459f14c64e93df11eff5ded575192c294a0c49270f22c5d93d",
+ "sha256:2ad0d4df0f5ef2247e27fc790d5c9b5a0af8ade9ba340db4a73bb1a4a3e5fb4f",
+ "sha256:2c58b24e3a63efd22554c676d81b0e57f80e0a7d3a5874a7e14ce90ec40d3069",
+ "sha256:2d33a11f601213dcd5718109c09a52c2a1c893e7461f0be2d6febc2879ec2402",
+ "sha256:337a74fd2f291c607d220c793a8135273c4c2ab001b03e601c36766005f36885",
+ "sha256:37ff6b522a26d0538b753f0b4e8e164fdada12db6c6f00f62145d732d8a3152e",
+ "sha256:3d1f14f5f691f55e1b47f824ca4fdcb4b19b4323fe43cc7bb105988cad7496be",
+ "sha256:408673ed75594933714482501fe97e055a42996087eeca7e5d06e33218d05aa8",
+ "sha256:4134d3f1ba5f15027ff5c04296f13328fecd46921424084516bdb1b2548e66ff",
+ "sha256:4ad2f835e0ad81d1689f1b7e3fbac7b01bb8777d5a985c8962bedee0cc6d43da",
+ "sha256:50dff9cc21826d2977ef2d2a205504034e3a4563ca6f5db739b0d1026658e004",
+ "sha256:510cef4a3f401c246cfd8227b300828715dd055463cdca6176c2e4036df8bd4f",
+ "sha256:5aed7dde98403cd91d86a1115c78d8145c83078e864c1de1064f52e6feb61b20",
+ "sha256:69bd1a15d7ba3694631e00df8de65a8cb031911ca11f44929c97fe05eb9b6c1d",
+ "sha256:6bf088c1ce160f50ea40764f825ec9b72ed9da25346216b91361eef8ad1b8f8c",
+ "sha256:6e8c66f70fb539301e064f6478d7453e820d8a2c631da948a23384865cd95544",
+ "sha256:727dd1389bc5cb9827cbd1f9d40d2c2a1a0c9b32dd2261db522d22a604a6eec9",
+ "sha256:74a04183e6e64930b667d321524e3c5361094bb4af9083db5c301db64cd341f3",
+ "sha256:75e636fd3e0fb872693f23ccb8a5ff2cd578801251f3a4f6854c6a5d437d3c04",
+ "sha256:7761afe0126d046974a01e030ae7529ed0ca6a196de3ec6937c11df0df1bc91c",
+ "sha256:7888310f6214f19ab2b6df90f3f06afa3df7ef7355fc025e78a3044737fab1f5",
+ "sha256:7b0554af24df2bf96618dac71ddada02420f946be943b181108cac55a7a2dcd4",
+ "sha256:7c7b502bc34f6e32ba022b4a209638f9e097d7a9098104ae420eb8186217ebbb",
+ "sha256:808add66ea764ed97d44dda1ac4f2cfec4c1867d9efb16a33d158be79f32b8a4",
+ "sha256:831e648102c82f152e14c1a0938689dbb22480c548c8d4b8b248b3e50967b88c",
+ "sha256:93689632949aff41199090eff5474f3990b6823404e45d66a5d44304e9cdc467",
+ "sha256:96b5e6874431df16aee0c1ba237574cb6dff1dcb173798faa6a9d8b399a05d0e",
+ "sha256:9a54614049a18a2d6fe156e68e188da02a046a4a93cf24f373bffd977e943421",
+ "sha256:a138441e95562b3c078746a22f8fca8ff1c22c014f856278bdbdd89ca36cff1b",
+ "sha256:a647c0d4478b995c5e54615a2e5360ccedd2f85e70ab57fbe817ca613d5e63b8",
+ "sha256:a9c9bc489f8ab30906d7a85afac4b4944a572a7432e00698a7239f44a44e6efb",
+ "sha256:ad2277b185ebce47a63f4dc6302e30f05762b688f8dc3de55dbae4651872cdf3",
+ "sha256:b6d5e92df2b77665e07ddb2e4dbd6d644b78e4c0d2e9272a852627cdba0d75cf",
+ "sha256:bc431b065722a5ad1dfb4df354fb9333b7a582a5ee39a90e6ffff688d72f27a1",
+ "sha256:bdd0de2d64688ecae88dd8935012c4a72681e5df632af903a1dca8c5e7aa871a",
+ "sha256:c79698d4cd9318d9481d89a77e2d3fcaeff5486be641e60a4b49f3d2ecca4e28",
+ "sha256:cb6259196a589123d755380b65127ddc60f4c64b21fc3bb46ce3a6ea663659b0",
+ "sha256:d5b87da55a08acb586bad5c3aa3b86505f559b84f39035b233d5bf844b0834b1",
+ "sha256:dcd7b9c7139dc8258d164b55696ecd16c04607f1cc33ba7af86613881ffe4ac8",
+ "sha256:dfe4c1fedfde4e2fbc009d5ad420647f7730d719786388b7de0999bf32c0d9fd",
+ "sha256:ea98f633d45f7e815db648fd7ff0f19e328302ac36427343e4432c84432e7ff4",
+ "sha256:ec52c351b35ca269cb1f8069d610fc45c5bd38c3e91f9ab4cbbf0aebc136d9c8",
+ "sha256:eef7592281f7c174d3d6cbfbb7ee5984a671fcd77e3fc78e973d492e9bf0eb3f",
+ "sha256:f07f1f00e22b231dd3d9b9208692042e29792d6bd4f6639415d2f23158a80013",
+ "sha256:f3fac744f9b540148fa7715a435d2283b71f68bfb6d4aae24482a890aed18b59",
+ "sha256:fa768eff5f9f958270b081bb33581b4b569faabf8774726b283edb06617101dc",
+ "sha256:fac2d65901fb0fdf20363fbd345c01958a742f2dc62a8dd4495af66e3ff502a4"
+ ],
+ "index": "pypi",
+ "version": "==9.2.0"
+ },
+ "protobuf": {
+ "hashes": [
+ "sha256:06059eb6953ff01e56a25cd02cca1a9649a75a7e65397b5b9b4e929ed71d10cf",
+ "sha256:097c5d8a9808302fb0da7e20edf0b8d4703274d140fd25c5edabddcde43e081f",
+ "sha256:284f86a6207c897542d7e956eb243a36bb8f9564c1742b253462386e96c6b78f",
+ "sha256:32ca378605b41fd180dfe4e14d3226386d8d1b002ab31c969c366549e66a2bb7",
+ "sha256:3cc797c9d15d7689ed507b165cd05913acb992d78b379f6014e013f9ecb20996",
+ "sha256:62f1b5c4cd6c5402b4e2d63804ba49a327e0c386c99b1675c8a0fefda23b2067",
+ "sha256:69ccfdf3657ba59569c64295b7d51325f91af586f8d5793b734260dfe2e94e2c",
+ "sha256:6f50601512a3d23625d8a85b1638d914a0970f17920ff39cec63aaef80a93fb7",
+ "sha256:7403941f6d0992d40161aa8bb23e12575637008a5a02283a930addc0508982f9",
+ "sha256:755f3aee41354ae395e104d62119cb223339a8f3276a0cd009ffabfcdd46bb0c",
+ "sha256:77053d28427a29987ca9caf7b72ccafee011257561259faba8dd308fda9a8739",
+ "sha256:7e371f10abe57cee5021797126c93479f59fccc9693dafd6bd5633ab67808a91",
+ "sha256:9016d01c91e8e625141d24ec1b20fed584703e527d28512aa8c8707f105a683c",
+ "sha256:9be73ad47579abc26c12024239d3540e6b765182a91dbc88e23658ab71767153",
+ "sha256:adc31566d027f45efe3f44eeb5b1f329da43891634d61c75a5944e9be6dd42c9",
+ "sha256:adfc6cf69c7f8c50fd24c793964eef18f0ac321315439d94945820612849c388",
+ "sha256:af0ebadc74e281a517141daad9d0f2c5d93ab78e9d455113719a45a49da9db4e",
+ "sha256:cb29edb9eab15742d791e1025dd7b6a8f6fcb53802ad2f6e3adcb102051063ab",
+ "sha256:cd68be2559e2a3b84f517fb029ee611546f7812b1fdd0aa2ecc9bc6ec0e4fdde",
+ "sha256:cdee09140e1cd184ba9324ec1df410e7147242b94b5f8b0c64fc89e38a8ba531",
+ "sha256:db977c4ca738dd9ce508557d4fce0f5aebd105e158c725beec86feb1f6bc20d8",
+ "sha256:dd5789b2948ca702c17027c84c2accb552fc30f4622a98ab5c51fcfe8c50d3e7",
+ "sha256:e250a42f15bf9d5b09fe1b293bdba2801cd520a9f5ea2d7fb7536d4441811d20",
+ "sha256:ff8d8fa42675249bb456f5db06c00de6c2f4c27a065955917b28c4f15978b9c3"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==3.20.1"
+ },
+ "psycopg2": {
+ "hashes": [
+ "sha256:06f32425949bd5fe8f625c49f17ebb9784e1e4fe928b7cce72edc36fb68e4c0c",
+ "sha256:0762c27d018edbcb2d34d51596e4346c983bd27c330218c56c4dc25ef7e819bf",
+ "sha256:083707a696e5e1c330af2508d8fab36f9700b26621ccbcb538abe22e15485362",
+ "sha256:34b33e0162cfcaad151f249c2649fd1030010c16f4bbc40a604c1cb77173dcf7",
+ "sha256:4295093a6ae3434d33ec6baab4ca5512a5082cc43c0505293087b8a46d108461",
+ "sha256:8cf3878353cc04b053822896bc4922b194792df9df2f1ad8da01fb3043602126",
+ "sha256:8e841d1bf3434da985cc5ef13e6f75c8981ced601fd70cc6bf33351b91562981",
+ "sha256:9572e08b50aed176ef6d66f15a21d823bb6f6d23152d35e8451d7d2d18fdac56",
+ "sha256:a81e3866f99382dfe8c15a151f1ca5fde5815fde879348fe5a9884a7c092a305",
+ "sha256:cb10d44e6694d763fa1078a26f7f6137d69f555a78ec85dc2ef716c37447e4b2",
+ "sha256:d3ca6421b942f60c008f81a3541e8faf6865a28d5a9b48544b0ee4f40cac7fca"
+ ],
+ "index": "pypi",
+ "version": "==2.9.3"
+ },
+ "pyasn1": {
+ "hashes": [
+ "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359",
+ "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576",
+ "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf",
+ "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7",
+ "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d",
+ "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00",
+ "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8",
+ "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86",
+ "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12",
+ "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776",
+ "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba",
+ "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2",
+ "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"
+ ],
+ "version": "==0.4.8"
+ },
+ "pycparser": {
+ "hashes": [
+ "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9",
+ "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"
+ ],
+ "version": "==2.21"
+ },
+ "python-engineio": {
+ "hashes": [
+ "sha256:18474c452894c60590b2d2339d6c81b93fb9857f1be271a2e91fb2707eb4095d",
+ "sha256:e660fcbac7497f105310d933987d3a82d2e677240a6b493c0a514aa7f91d3b07"
+ ],
+ "markers": "python_version >= '3.6'",
+ "version": "==4.3.3"
+ },
+ "python-jose": {
+ "hashes": [
+ "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a",
+ "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"
+ ],
+ "index": "pypi",
+ "version": "==3.3.0"
+ },
+ "python-keycloak": {
+ "hashes": [
+ "sha256:140d95ca08a2acae1f4cdaf6130bbba3ba3309f059326f7bebd87f150561c4b4",
+ "sha256:3b504ef117f2192197732a01041f4c2c14d324baf2069f5a97533e11191cf3e6"
+ ],
+ "index": "pypi",
+ "version": "==2.1.1"
+ },
+ "python-socketio": {
+ "hashes": [
+ "sha256:5011a0cd2545c954d7df09eef7489ec424c93b001cc146599cd72f1dd20f0d46",
+ "sha256:86ee93591c1e781d339d9a61940e62fd6cbc838390653b52a7bcc4f7ce89fe47"
+ ],
+ "markers": "python_version >= '3.6'",
+ "version": "==5.7.1"
+ },
+ "pyyaml": {
+ "hashes": [
+ "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293",
+ "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b",
+ "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57",
+ "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b",
+ "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4",
+ "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07",
+ "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba",
+ "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9",
+ "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287",
+ "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513",
+ "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0",
+ "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0",
+ "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92",
+ "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f",
+ "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2",
+ "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc",
+ "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c",
+ "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86",
+ "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4",
+ "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c",
+ "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34",
+ "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b",
+ "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c",
+ "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb",
+ "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737",
+ "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3",
+ "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d",
+ "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53",
+ "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78",
+ "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803",
+ "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a",
+ "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174",
+ "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"
+ ],
+ "index": "pypi",
+ "version": "==6.0"
+ },
+ "requests": {
+ "hashes": [
+ "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983",
+ "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"
+ ],
+ "index": "pypi",
+ "version": "==2.28.1"
+ },
+ "requests-toolbelt": {
+ "hashes": [
+ "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f",
+ "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"
+ ],
+ "version": "==0.9.1"
+ },
+ "rsa": {
+ "hashes": [
+ "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7",
+ "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"
+ ],
+ "markers": "python_version >= '3.6' and python_version < '4.0'",
+ "version": "==4.9"
+ },
+ "schema": {
+ "hashes": [
+ "sha256:f06717112c61895cabc4707752b88716e8420a8819d71404501e114f91043197",
+ "sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c"
+ ],
+ "index": "pypi",
+ "version": "==0.7.5"
+ },
+ "setuptools": {
+ "hashes": [
+ "sha256:0d33c374d41c7863419fc8f6c10bfe25b7b498aa34164d135c622e52580c6b16",
+ "sha256:c04b44a57a6265fe34a4a444e965884716d34bae963119a76353434d6f18e450"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==63.2.0"
+ },
+ "six": {
+ "hashes": [
+ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
+ "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==1.16.0"
+ },
+ "urllib3": {
+ "hashes": [
+ "sha256:c33ccba33c819596124764c23a97d25f32b28433ba0dedeb77d873a38722c9bc",
+ "sha256:ea6e8fb210b19d950fab93b60c9009226c63a28808bc8386e05301e25883ac0a"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' and python_version < '4.0'",
+ "version": "==1.26.11"
+ },
+ "werkzeug": {
+ "hashes": [
+ "sha256:4d7013ef96fd197d1cdeb03e066c6c5a491ccb44758a5b2b91137319383e5a5a",
+ "sha256:7e1db6a5ba6b9a8be061e47e900456355b8714c0f238b0313f53afce1a55a79a"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==2.2.1"
+ },
+ "zipp": {
+ "hashes": [
+ "sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2",
+ "sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==3.8.1"
+ }
+ },
+ "develop": {
+ "black": {
+ "hashes": [
+ "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90",
+ "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c",
+ "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78",
+ "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4",
+ "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee",
+ "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e",
+ "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e",
+ "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6",
+ "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9",
+ "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c",
+ "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256",
+ "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f",
+ "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2",
+ "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c",
+ "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b",
+ "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807",
+ "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf",
+ "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def",
+ "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad",
+ "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d",
+ "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849",
+ "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69",
+ "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"
+ ],
+ "index": "pypi",
+ "version": "==22.6.0"
+ },
+ "click": {
+ "hashes": [
+ "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e",
+ "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==8.1.3"
+ },
+ "isort": {
+ "hashes": [
+ "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7",
+ "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"
+ ],
+ "index": "pypi",
+ "version": "==5.10.1"
+ },
+ "mypy": {
+ "hashes": [
+ "sha256:02ef476f6dcb86e6f502ae39a16b93285fef97e7f1ff22932b657d1ef1f28655",
+ "sha256:0d054ef16b071149917085f51f89555a576e2618d5d9dd70bd6eea6410af3ac9",
+ "sha256:19830b7dba7d5356d3e26e2427a2ec91c994cd92d983142cbd025ebe81d69cf3",
+ "sha256:1f7656b69974a6933e987ee8ffb951d836272d6c0f81d727f1d0e2696074d9e6",
+ "sha256:23488a14a83bca6e54402c2e6435467a4138785df93ec85aeff64c6170077fb0",
+ "sha256:23c7ff43fff4b0df93a186581885c8512bc50fc4d4910e0f838e35d6bb6b5e58",
+ "sha256:25c5750ba5609a0c7550b73a33deb314ecfb559c350bb050b655505e8aed4103",
+ "sha256:2ad53cf9c3adc43cf3bea0a7d01a2f2e86db9fe7596dfecb4496a5dda63cbb09",
+ "sha256:3fa7a477b9900be9b7dd4bab30a12759e5abe9586574ceb944bc29cddf8f0417",
+ "sha256:40b0f21484238269ae6a57200c807d80debc6459d444c0489a102d7c6a75fa56",
+ "sha256:4b21e5b1a70dfb972490035128f305c39bc4bc253f34e96a4adf9127cf943eb2",
+ "sha256:5a361d92635ad4ada1b1b2d3630fc2f53f2127d51cf2def9db83cba32e47c856",
+ "sha256:77a514ea15d3007d33a9e2157b0ba9c267496acf12a7f2b9b9f8446337aac5b0",
+ "sha256:855048b6feb6dfe09d3353466004490b1872887150c5bb5caad7838b57328cc8",
+ "sha256:9796a2ba7b4b538649caa5cecd398d873f4022ed2333ffde58eaf604c4d2cb27",
+ "sha256:98e02d56ebe93981c41211c05adb630d1d26c14195d04d95e49cd97dbc046dc5",
+ "sha256:b793b899f7cf563b1e7044a5c97361196b938e92f0a4343a5d27966a53d2ec71",
+ "sha256:d1ea5d12c8e2d266b5fb8c7a5d2e9c0219fedfeb493b7ed60cd350322384ac27",
+ "sha256:d2022bfadb7a5c2ef410d6a7c9763188afdb7f3533f22a0a32be10d571ee4bbe",
+ "sha256:d3348e7eb2eea2472db611486846742d5d52d1290576de99d59edeb7cd4a42ca",
+ "sha256:d744f72eb39f69312bc6c2abf8ff6656973120e2eb3f3ec4f758ed47e414a4bf",
+ "sha256:ef943c72a786b0f8d90fd76e9b39ce81fb7171172daf84bf43eaf937e9f220a9",
+ "sha256:f2899a3cbd394da157194f913a931edfd4be5f274a88041c9dc2d9cdcb1c315c"
+ ],
+ "index": "pypi",
+ "version": "==0.971"
+ },
+ "mypy-extensions": {
+ "hashes": [
+ "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
+ "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
+ ],
+ "version": "==0.4.3"
+ },
+ "pathspec": {
+ "hashes": [
+ "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a",
+ "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"
+ ],
+ "version": "==0.9.0"
+ },
+ "platformdirs": {
+ "hashes": [
+ "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788",
+ "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==2.5.2"
+ },
+ "tomli": {
+ "hashes": [
+ "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
+ "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"
+ ],
+ "markers": "python_version < '3.11'",
+ "version": "==2.0.1"
+ },
+ "types-click": {
+ "hashes": [
+ "sha256:8cb030a669e2e927461be9827375f83c16b8178c365852c060a34e24871e7e81",
+ "sha256:b6604968be6401dc516311ca50708a0a28baa7a0cb840efd7412f0dbbff4e092"
+ ],
+ "version": "==7.1.8"
+ },
+ "types-flask": {
+ "hashes": [
+ "sha256:6ab8a9a5e258b76539d652f6341408867298550b19b81f0e41e916825fc39087",
+ "sha256:aac777b3abfff9436e6b01f6d08171cf23ea6e5be71cbf773aaabb1c5763e9cf"
+ ],
+ "index": "pypi",
+ "version": "==1.1.6"
+ },
+ "types-jinja2": {
+ "hashes": [
+ "sha256:60a1e21e8296979db32f9374d8a239af4cb541ff66447bb915d8ad398f9c63b2",
+ "sha256:dbdc74a40aba7aed520b7e4d89e8f0fe4286518494208b35123bcf084d4b8c81"
+ ],
+ "version": "==2.11.9"
+ },
+ "types-markupsafe": {
+ "hashes": [
+ "sha256:85b3a872683d02aea3a5ac2a8ef590193c344092032f58457287fbf8e06711b1",
+ "sha256:ca2bee0f4faafc45250602567ef38d533e877d2ddca13003b319c551ff5b3cc5"
+ ],
+ "version": "==1.1.10"
+ },
+ "types-pillow": {
+ "hashes": [
+ "sha256:9781104ee2176f680576523fa2a2b83b134957aec6f4d62582cc9e74c93a60b4",
+ "sha256:d63743ef631e47f8d8669590ea976162321a9a7604588b424b6306533453fb63"
+ ],
+ "index": "pypi",
+ "version": "==9.2.1"
+ },
+ "types-psycopg2": {
+ "hashes": [
+ "sha256:14c779dcab18c31453fa1cad3cf4b1601d33540a344adead3c47a6b8091cd2fa",
+ "sha256:9b0e9e1f097b15cd9fa8aad2596a9e3082fd72f8d9cfe52b190cfa709105b6c0"
+ ],
+ "index": "pypi",
+ "version": "==2.9.18"
+ },
+ "types-python-jose": {
+ "hashes": [
+ "sha256:04fb427bc906e864e8c313db469920f71ffa85839b43fe355f7dc861330c6da3",
+ "sha256:a8331dd72da5adf03853d4c003d08d0621dd9eb7b7333571b04d81058c55fac6"
+ ],
+ "index": "pypi",
+ "version": "==3.3.4"
+ },
+ "types-pyyaml": {
+ "hashes": [
+ "sha256:7f7da2fd11e9bc1e5e9eb3ea1be84f4849747017a59fc2eee0ea34ed1147c2e0",
+ "sha256:8f890028123607379c63550179ddaec4517dc751f4c527a52bb61934bf495989"
+ ],
+ "index": "pypi",
+ "version": "==6.0.11"
+ },
+ "types-requests": {
+ "hashes": [
+ "sha256:98ab647ae88b5e2c41d6d20cfcb5117da1bea561110000b6fdeeea07b3e89877",
+ "sha256:ac618bfefcb3742eaf97c961e13e9e5a226e545eda4a3dbe293b898d40933ad1"
+ ],
+ "index": "pypi",
+ "version": "==2.28.5"
+ },
+ "types-urllib3": {
+ "hashes": [
+ "sha256:0d027fcd27dbb3cb532453b4d977e05bc1e13aefd70519866af211b3003d895d",
+ "sha256:73fd274524c3fc7cd8cd9ceb0cb67ed99b45f9cb2831013e46d50c1451044800"
+ ],
+ "version": "==1.26.17"
+ },
+ "types-werkzeug": {
+ "hashes": [
+ "sha256:194bd5715a13c598f05c63e8a739328657590943bce941e8a3619a6b5d4a54ec",
+ "sha256:5cc269604c400133d452a40cee6397655f878fc460e03fde291b9e3a5eaa518c"
+ ],
+ "version": "==1.0.9"
+ },
+ "typing-extensions": {
+ "hashes": [
+ "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02",
+ "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==4.3.0"
+ }
+ }
+}
diff --git a/dd-sso/admin/docker/requirements.pip3 b/dd-sso/admin/docker/requirements.pip3
index 697271c..91b9549 100644
--- a/dd-sso/admin/docker/requirements.pip3
+++ b/dd-sso/admin/docker/requirements.pip3
@@ -17,19 +17,23 @@
# along with DD. If not, see .
#
# SPDX-License-Identifier: AGPL-3.0-or-later
-Flask==2.0.1
-Flask-Login==0.5.0
-eventlet==0.33.0
-Flask-SocketIO==5.1.0
-bcrypt==3.2.0
+attrs==22.1.0
+cryptography==37.0.4
+Flask==2.1.3
+Flask-Login==0.6.2
+eventlet==0.33.1
+Flask-SocketIO==5.2.0
+bcrypt==3.2.2
+# diceware can't be upgraded without issues
diceware==0.9.6
-mysql-connector-python==8.0.25
-psycopg2==2.8.6
+mysql-connector-python==8.0.30
+psycopg2==2.9.3
+# python-keycloak can't be upgraded without issues
python-keycloak==0.26.1
-minio==7.0.3
-urllib3==1.26.6
+minio==7.1.11
+urllib3==1.26.11
schema==0.7.5
-Werkzeug~=2.0.0
+Werkzeug==2.2.1
python-jose==3.3.0
Cerberus==1.3.4
PyYAML==6.0
diff --git a/dd-sso/admin/mypy.ini b/dd-sso/admin/mypy.ini
new file mode 100644
index 0000000..5d04d2f
--- /dev/null
+++ b/dd-sso/admin/mypy.ini
@@ -0,0 +1,34 @@
+[mypy]
+namespace_packages=True
+#explicit_package_bases=True
+#TODO: progress so we can enable this
+#strict=True
+#
+check_untyped_defs=True
+disallow_untyped_defs=True
+warn_redundant_casts=True
+warn_unused_configs= True
+warn_unused_ignores = True
+warn_no_return=True
+warn_return_any=True
+warn_unreachable=True
+enable_error_code=unused-awaitable
+
+[mypy-cerberus.*]
+ignore_missing_imports=True
+[mypy-diceware.*]
+ignore_missing_imports=True
+[mypy-eventlet.*]
+ignore_missing_imports=True
+[mypy-flask_login.*]
+ignore_missing_imports=True
+[mypy-flask_socketio.*]
+ignore_missing_imports=True
+[mypy-keycloak.*]
+ignore_missing_imports=True
+[mypy-minio.*]
+ignore_missing_imports=True
+[mypy-mysql.*]
+ignore_missing_imports=True
+[mypy-schema.*]
+ignore_missing_imports=True
diff --git a/dd-sso/admin/pyproject.toml b/dd-sso/admin/pyproject.toml
new file mode 100644
index 0000000..5d7bf33
--- /dev/null
+++ b/dd-sso/admin/pyproject.toml
@@ -0,0 +1,2 @@
+[tool.isort]
+profile = "black"
diff --git a/dd-sso/admin/src/admin/__init__.py b/dd-sso/admin/src/admin/__init__.py
index 014aa18..5a2863e 100644
--- a/dd-sso/admin/src/admin/__init__.py
+++ b/dd-sso/admin/src/admin/__init__.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -20,107 +21,23 @@
import logging as log
import os
+import os.path
-from flask import Flask, render_template, send_from_directory
+from admin.flaskapp import AdminFlaskApp
-app = Flask(__name__, static_url_path="")
-app = Flask(__name__, template_folder="static/templates")
-app.url_map.strict_slashes = False
+def get_app() -> AdminFlaskApp:
+ app = AdminFlaskApp(__name__, template_folder="static/templates")
-"""
-App secret key for encrypting cookies
-You can generate one with:
- import os
- os.urandom(24)
-And paste it here.
-"""
-app.secret_key = "Change this key!/\xf7\x83\xbe\x17\xfa\xa3zT\n\\]m\xa6\x8bF\xdd\r\xf7\x9e\x1d\x1f\x14'"
+ """
+ Debug should be removed on production!
+ """
+ if app.debug:
+ log.warning("Debug mode: {}".format(app.debug))
+ else:
+ log.info("Debug mode: {}".format(app.debug))
-print("Starting dd-sso api...")
+ return app
-from admin.lib.load_config import loadConfig
-
-try:
- loadConfig(app)
-except:
- print("Could not get environment variables...")
-
-from admin.lib.postup import Postup
-
-Postup()
-
-from admin.lib.admin import Admin
-
-app.admin = Admin()
-
-app.ready = False
-
-"""
-Debug should be removed on production!
-"""
-if app.debug:
- log.warning("Debug mode: {}".format(app.debug))
-else:
- log.info("Debug mode: {}".format(app.debug))
-
-"""
-Serve static files
-"""
-
-
-@app.route("/build/")
-def send_build(path):
- return send_from_directory(
- os.path.join(app.root_path, "node_modules/gentelella/build"), path
- )
-
-
-@app.route("/vendors/")
-def send_vendors(path):
- return send_from_directory(
- os.path.join(app.root_path, "node_modules/gentelella/vendors"), path
- )
-
-
-@app.route("/node_modules/")
-def send_nodes(path):
- return send_from_directory(os.path.join(app.root_path, "node_modules"), path)
-
-
-@app.route("/templates/")
-def send_templates(path):
- return send_from_directory(os.path.join(app.root_path, "templates"), path)
-
-
-# @app.route('/templates/')
-# def send_templates(path):
-# return send_from_directory(os.path.join(app.root_path, 'static/templates'), path)
-
-
-@app.route("/static/")
-def send_static_js(path):
- return send_from_directory(os.path.join(app.root_path, "static"), path)
-
-
-@app.route("/avatars/")
-def send_avatars_img(path):
- return send_from_directory(
- os.path.join(app.root_path, "../avatars/master-avatars"), path
- )
-
-
-@app.route("/custom/")
-def send_custom(path):
- return send_from_directory(os.path.join(app.root_path, "../custom"), path)
-
-
-# @app.errorhandler(404)
-# def not_found_error(error):
-# return render_template('page_404.html'), 404
-
-# @app.errorhandler(500)
-# def internal_error(error):
-# return render_template('page_500.html'), 500
"""
Import all views
diff --git a/dd-sso/admin/src/admin/auth/authentication.py b/dd-sso/admin/src/admin/auth/authentication.py
index f7da0be..dfc758e 100644
--- a/dd-sso/admin/src/admin/auth/authentication.py
+++ b/dd-sso/admin/src/admin/auth/authentication.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -21,31 +22,9 @@ import os
from flask_login import LoginManager, UserMixin
-from admin import app
-
-""" OIDC TESTS """
-# from flask_oidc import OpenIDConnect
-# app.config.update({
-# 'SECRET_KEY': 'u\x91\xcf\xfa\x0c\xb9\x95\xe3t\xba2K\x7f\xfd\xca\xa3\x9f\x90\x88\xb8\xee\xa4\xd6\xe4',
-# 'TESTING': True,
-# 'DEBUG': True,
-# 'OIDC_CLIENT_SECRETS': 'client_secrets.json',
-# 'OIDC_ID_TOKEN_COOKIE_SECURE': False,
-# 'OIDC_REQUIRE_VERIFIED_EMAIL': False,
-# 'OIDC_VALID_ISSUERS': ['https://sso.mydomain.duckdns.org:8080/auth/realms/master'],
-# 'OIDC_OPENID_REALM': 'https://sso.mydomain.duckdns.org//custom_callback',
-# 'OVERWRITE_REDIRECT_URI': 'https://sso.mydomain.duckdns.org//custom_callback',
-# })
-# # 'OVERWRITE_REDIRECT_URI': 'https://sso.mydomain.duckdns.org//custom_callback',
-# # 'OIDC_CALLBACK_ROUTE': '//custom_callback'
-# oidc = OpenIDConnect(app)
-""" OIDC TESTS """
-
-
-login_manager = LoginManager()
-login_manager.init_app(app)
-login_manager.login_view = "login"
-
+from typing import TYPE_CHECKING, Dict
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
ram_users = {
os.environ["ADMINAPP_USER"]: {
@@ -67,13 +46,19 @@ ram_users = {
class User(UserMixin):
- def __init__(self, dict):
- self.id = dict["id"]
- self.username = dict["id"]
- self.password = dict["password"]
- self.role = dict["role"]
+ def __init__(self, id : str, password : str, role : str, active : bool = True) -> None:
+ self.id = id
+ self.username = id
+ self.password = password
+ self.role = role
+ self.active = active
+def setup_auth(app : "AdminFlaskApp") -> None:
+ login_manager = LoginManager()
+ login_manager.init_app(app)
+ login_manager.login_view = "login"
-@login_manager.user_loader
-def user_loader(username):
- return User(ram_users[username])
+ @login_manager.user_loader
+ def user_loader(username : str) -> User:
+ u = ram_users[username]
+ return User(id = u["id"], password = u["password"], role = u["role"])
diff --git a/dd-sso/admin/src/admin/auth/jws_tokens.py b/dd-sso/admin/src/admin/auth/jws_tokens.py
new file mode 100644
index 0000000..9ac431a
--- /dev/null
+++ b/dd-sso/admin/src/admin/auth/jws_tokens.py
@@ -0,0 +1,72 @@
+#
+# Copyright © 2022 MaadiX
+# Copyright © 2022 Evilham
+#
+# This file is part of DD
+#
+# DD is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or (at your
+# option) any later version.
+#
+# DD is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with DD. If not, see .
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+import traceback
+from functools import wraps
+from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple
+
+from flask import request
+from jose import jwt
+from werkzeug.wrappers import Response
+
+from admin.lib.api_exceptions import Error
+
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
+
+
+def has_jws_token(
+ app: "AdminFlaskApp", *args: Any, **kwargs: Any
+) -> Callable[..., Response]:
+ @wraps
+ def decorated(fn: Callable[..., Response]) -> Response:
+ get_jws_payload(app)
+ return fn(*args, **kwargs)
+
+ return decorated
+
+
+def get_jws_payload(app: "AdminFlaskApp") -> Tuple[str, Dict]:
+ """
+ Try to parse the Authorization header into a JWT.
+ By getting the key ID from the unverified headers, try to verify the token
+ with the associated key.
+
+ For pragmatism it returns a tuple of the (key_id, jwt_claims).
+ """
+ kid: str = ""
+ try:
+ t: str = request.headers.get("Authorization", "")
+ # Get which KeyId we have to use.
+ # We default to 'empty', so a missing 'kid' is not an issue in an on
+ # itself.
+ # This enables using an empty key in app.api_3p as the default.
+ # That default is better managed in AdminFlaskApp and not here.
+ kid = jwt.get_unverified_headers(t).get("kid", "")
+ except:
+ raise Error(
+ "unauthorized", "Token is missing or malformed", traceback.format_stack()
+ )
+ try:
+ # Try to get payload
+ return (kid, app.api_3p[kid].verify_incoming_data(t))
+ except:
+ raise Error("forbidden", "Data verification failed", traceback.format_stack())
diff --git a/dd-sso/admin/src/admin/auth/tokens.py b/dd-sso/admin/src/admin/auth/tokens.py
index cc7b100..a244aff 100644
--- a/dd-sso/admin/src/admin/auth/tokens.py
+++ b/dd-sso/admin/src/admin/auth/tokens.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -30,17 +31,18 @@ from functools import wraps
from flask import request
from jose import jwt
+import jose.exceptions
-from admin import app
+from typing import Any
-from ..lib.api_exceptions import Error
+from admin.lib.api_exceptions import Error
-def get_header_jwt_payload():
+def get_header_jwt_payload() -> Any:
return get_token_payload(get_token_auth_header())
-def get_token_header(header):
+def get_token_header(header : str) -> str:
"""Obtains the Access Token from the a Header"""
auth = request.headers.get(header, None)
if not auth:
@@ -70,15 +72,15 @@ def get_token_header(header):
return parts[1] # Token
-def get_token_auth_header():
+def get_token_auth_header() -> str:
return get_token_header("Authorization")
-def get_token_payload(token):
+def get_token_payload(token : str) -> Any:
# log.warning("The received token in get_token_payload is: " + str(token))
try:
claims = jwt.get_unverified_claims(token)
- secret = app.config["API_SECRET"]
+ secret = os.environ["API_SECRET"]
except:
log.warning(
@@ -97,11 +99,11 @@ def get_token_payload(token):
algorithms=["HS256"],
options=dict(verify_aud=False, verify_sub=False, verify_exp=True),
)
- except jwt.ExpiredSignatureError:
+ except jose.exceptions.ExpiredSignatureError:
log.warning("Token expired")
raise Error("unauthorized", "Token is expired", traceback.format_stack())
- except jwt.JWTClaimsError:
+ except jose.exceptions.JWTClaimsError:
raise Error(
"unauthorized",
"Incorrect claims, please check the audience and issuer",
diff --git a/dd-sso/admin/src/admin/flaskapp.py b/dd-sso/admin/src/admin/flaskapp.py
new file mode 100644
index 0000000..19a7b7b
--- /dev/null
+++ b/dd-sso/admin/src/admin/flaskapp.py
@@ -0,0 +1,255 @@
+#
+# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
+#
+# This file is part of DD
+#
+# DD is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or (at your
+# option) any later version.
+#
+# DD is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with DD. If not, see .
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+import logging as log
+import os
+import os.path
+import secrets
+import traceback
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, Tuple
+
+import yaml
+from cerberus import Validator
+from flask import Flask, Response, jsonify, render_template, send_from_directory
+
+from admin.lib.api_exceptions import Error
+from admin.lib.keys import ThirdPartyIntegrationKeys
+from admin.views.decorators import OptionalJsonResponse
+from admin.views.ApiViews import setup_api_views
+from admin.views.AppViews import setup_app_views
+from admin.views.LoginViews import setup_login_views
+from admin.views.WebViews import setup_web_views
+from admin.views.WpViews import setup_wp_views
+from admin.auth.authentication import setup_auth
+
+if TYPE_CHECKING:
+ from admin.lib.admin import Admin
+ from admin.lib.postup import Postup
+
+
+class AdminValidator(Validator): # type: ignore # cerberus type hints MIA
+ # TODO: What's the point of this class?
+ None
+ # def _normalize_default_setter_genid(self, document):
+ # return _parse_string(document["name"])
+
+ # def _normalize_default_setter_genidlower(self, document):
+ # return _parse_string(document["name"]).lower()
+
+ # def _normalize_default_setter_gengroupid(self, document):
+ # return _parse_string(
+ # document["parent_category"] + "-" + document["uid"]
+ # ).lower()
+
+
+class AdminFlaskApp(Flask):
+ """
+ Subclass Flask app to ease customisation and type checking.
+
+ In order for an instance of this class to be useful,
+ the setup method should be called after instantiating.
+ """
+
+ admin: "Admin"
+ api_3p : Dict[str, ThirdPartyIntegrationKeys]
+ custom_dir: str
+ data_dir: str
+ domain : str
+ ready: bool = False
+
+ def __init__(self, *args: Any, **kwargs: Any):
+ super().__init__(*args, **kwargs)
+ self.api_3p = {}
+ self.domain = os.environ["DOMAIN"]
+ self.url_map.strict_slashes = False
+ self._load_config()
+ # Minor setup tasks
+ self._load_validators()
+ self._setup_routes()
+ self._setup_api_3p()
+ setup_api_views(self)
+ setup_app_views(self)
+ setup_login_views(self)
+ setup_web_views(self)
+ setup_wp_views(self)
+ setup_auth(self)
+
+ @property
+ def legal_path(self) -> str:
+ return os.path.join(self.root_path, "static/templates/pages/legal/")
+
+ @property
+ def avatars_path(self) -> str:
+ return os.path.join(self.custom_dir, "avatars/")
+
+ @property
+ def secrets_dir(self) -> str:
+ return os.path.join(self.data_dir, "secrets")
+
+ def setup(self) -> None:
+ """
+ Perform setup tasks that might do network
+ """
+ from admin.lib.postup import Postup
+ Postup(self)
+ # This must happen after Postup since it, e.g. fetches moodle secrets
+ from admin.lib.admin import Admin
+ self.admin = Admin(self)
+ # We now must setup the third-party callbacks
+ from admin.lib.callbacks import ThirdPartyCallbacks
+ if "correu" in self.api_3p:
+ tp = self.api_3p["correu"]
+ self.admin.third_party_cbs.append(ThirdPartyCallbacks(tp))
+
+ def json_route(self, rule: str, **options: Any) -> Callable[..., OptionalJsonResponse]:
+ return self.route(rule, **options) # type: ignore # mypy issue #7187
+
+ def _load_validators(self, purge_unknown: bool = True) -> Dict[str, Validator]:
+ validators = {}
+ schema_path = os.path.join(self.root_path, "schemas")
+ for schema_filename in os.listdir(schema_path):
+ try:
+ with open(os.path.join(schema_path, schema_filename)) as file:
+ schema_yml = file.read()
+ schema = yaml.load(schema_yml, Loader=yaml.FullLoader)
+ validators[schema_filename.split(".")[0]] = AdminValidator(
+ schema, purge_unknown=purge_unknown
+ )
+ except IsADirectoryError:
+ None
+ return validators
+
+ def _load_config(self) -> None:
+ try:
+ self.data_dir = os.environ.get("DATA_FOLDER", ".")
+ self.custom_dir = os.environ.get("CUSTOM_FOLDER", ".")
+ # Handle secrets like Flask's session key
+ secret_key_file = os.path.join(self.secrets_dir, "secret_key")
+ if not os.path.exists(self.secrets_dir):
+ os.mkdir(self.secrets_dir, mode=0o700)
+ if not os.path.exists(secret_key_file):
+ # Generate as needed
+ # https://flask.palletsprojects.com/en/2.1.x/config/#SECRET_KEY
+ with os.fdopen(
+ os.open(secret_key_file, os.O_WRONLY | os.O_CREAT, 0o600), "w"
+ ) as f:
+ f.write(secrets.token_hex())
+ self.secret_key = open(secret_key_file, "r").read()
+
+ # Move on with settings from the environment
+ self.config.update({
+ "DOMAIN": self.domain,
+ "KEYCLOAK_POSTGRES_USER": os.environ["KEYCLOAK_DB_USER"],
+ "KEYCLOAK_POSTGRES_PASSWORD": os.environ["KEYCLOAK_DB_PASSWORD"],
+ "MOODLE_POSTGRES_USER": os.environ["MOODLE_POSTGRES_USER"],
+ "MOODLE_POSTGRES_PASSWORD": os.environ["MOODLE_POSTGRES_PASSWORD"],
+ "NEXTCLOUD_POSTGRES_USER": os.environ["NEXTCLOUD_POSTGRES_USER"],
+ "NEXTCLOUD_POSTGRES_PASSWORD": os.environ["NEXTCLOUD_POSTGRES_PASSWORD"],
+ "VERIFY": os.environ["VERIFY"] == "true",
+ "API_SECRET": os.environ.get("API_SECRET"),
+ })
+ except Exception as e:
+ log.error(traceback.format_exc())
+ raise
+
+ def _setup_api_3p(self) -> None:
+ # Register third parties if / as requested
+ email_domain = os.environ.get("MANAGED_EMAIL_DOMAIN", "")
+ integrations : List[Tuple[str, str]] = []
+ if email_domain:
+ integrations.append(("correu", f"correu.{self.domain}"))
+
+ api_3p_secrets_dir = os.path.join(self.secrets_dir, "api_3p")
+ if not os.path.exists(api_3p_secrets_dir):
+ os.mkdir(api_3p_secrets_dir, mode=0o700)
+ for integration, int_domain in integrations:
+ ks = os.path.join(api_3p_secrets_dir, integration)
+ if not os.path.exists(ks):
+ os.mkdir(ks, mode=0o700)
+ self.api_3p[integration] = ThirdPartyIntegrationKeys(key_store=ks, our_name="DD", their_name=integration, their_service_domain=int_domain)
+
+ if "correu" in self.api_3p:
+ from admin.views.MailViews import setup_mail_views
+ setup_mail_views(self)
+ # Temporary work-around while we are not receiving the 'kid'
+ # This effectively uses 'correu' as the default
+ # TODO: remove when dd-email-panel has changed this
+ self.api_3p[""] = self.api_3p["correu"]
+
+ def _setup_routes(self) -> None:
+ """
+ Setup routes to Serve static files
+ """
+
+ @self.route("/build/")
+ def send_build(path: str) -> Response:
+ return send_from_directory(
+ os.path.join(self.root_path, "node_modules/gentelella/build"), path
+ )
+
+ @self.route("/vendors/")
+ def send_vendors(path: str) -> Response:
+ return send_from_directory(
+ os.path.join(self.root_path, "node_modules/gentelella/vendors"), path
+ )
+
+ @self.route("/node_modules/")
+ def send_nodes(path: str) -> Response:
+ return send_from_directory(
+ os.path.join(self.root_path, "node_modules"), path
+ )
+
+ @self.route("/templates/")
+ def send_templates(path: str) -> Response:
+ return send_from_directory(os.path.join(self.root_path, "templates"), path)
+
+ # @self.route('/templates/')
+ # def send_templates(path):
+ # return send_from_directory(os.path.join(self.root_path, 'static/templates'), path)
+
+ @self.route("/static/")
+ def send_static_js(path: str) -> Response:
+ return send_from_directory(os.path.join(self.root_path, "static"), path)
+
+ @self.route("/avatars/")
+ def send_avatars_img(path: str) -> Response:
+ return send_from_directory(
+ os.path.join(self.root_path, "../avatars/master-avatars"), path
+ )
+
+ @self.route("/custom/")
+ def send_custom(path: str) -> Response:
+ return send_from_directory(self.custom_dir, path)
+
+ # @self.errorhandler(404)
+ # def not_found_error(error):
+ # return render_template('page_404.html'), 404
+
+ # @self.errorhandler(500)
+ # def internal_error(error):
+ # return render_template('page_500.html'), 500
+
+ @self.errorhandler(Error)
+ def handle_user_error(ex : Error) -> Response:
+ response : Response = jsonify(ex.error)
+ response.status_code = ex.status_code
+ response.headers.extend(ex.content_type) # type: ignore # werkzeug type hint MIA
+ return response
diff --git a/dd-sso/admin/src/admin/lib/admin.py b/dd-sso/admin/src/admin/lib/admin.py
index 5c251ed..35fbf67 100644
--- a/dd-sso/admin/src/admin/lib/admin.py
+++ b/dd-sso/admin/src/admin/lib/admin.py
@@ -1,5 +1,6 @@
#
-# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -26,8 +27,6 @@ from time import sleep
import diceware
-from admin import app
-
from .avatars import Avatars
from .helpers import (
filter_roles_list,
@@ -61,20 +60,37 @@ from .helpers import (
rand_password,
)
+from typing import TYPE_CHECKING, cast, Any, Dict, Iterable, List, Optional
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
+ from admin.lib.callbacks import ThirdPartyCallbacks
+
MANAGER = os.environ["CUSTOM_ROLE_MANAGER"]
TEACHER = os.environ["CUSTOM_ROLE_TEACHER"]
STUDENT = os.environ["CUSTOM_ROLE_STUDENT"]
+DDUser = Dict[str, Any]
+DDGroup = Dict[str, Any]
+DDRole = Dict[str, Any]
+
class Admin:
- def __init__(self):
- self.check_connections()
+ app : "AdminFlaskApp"
+ internal : Dict[str, Any]
+ external : Dict[str, Any]
+ third_party_cbs : List["ThirdPartyCallbacks"]
+
+ def __init__(self, app : "AdminFlaskApp") -> None:
+ self.app = app
+
+ self.check_connections(app)
self.set_custom_roles()
self.overwrite_admins()
self.default_setup()
self.internal = {}
+ self.third_party_cbs = []
ready = False
while not ready:
@@ -90,13 +106,39 @@ class Admin:
self.external = {"users": [], "groups": [], "roles": []}
log.warning(" Updating missing user avatars with defaults")
- self.av = Avatars()
+ self.av = Avatars(app.avatars_path)
# av.minio_delete_all_objects() # This will reset all avatars on usres
self.av.update_missing_avatars(self.internal["users"])
log.warning(" SYSTEM READY TO HANDLE CONNECTIONS")
- def check_connections(self):
+ def third_party_add_user(self, user_id : str, user : DDUser) -> bool:
+ res = True
+ for tp in self.third_party_cbs:
+ res = res and tp.add_user(user_id, user)
+ return res
+
+ def third_party_update_user(self, user_id : str, user : DDUser) -> bool:
+ res = True
+ for tp in self.third_party_cbs:
+ res = res and tp.update_user(user_id, user)
+ return res
+
+ def third_party_delete_user(self, user_id : str) -> bool:
+ res = True
+ for tp in self.third_party_cbs:
+ res = res and tp.delete_user(user_id)
+ return res
+
+ def nextcloud_mail_set(self, users : List[DDUser], extra_data : Dict) -> Dict:
+ # TODO: implement
+ return {}
+
+ def nextcloud_mail_delete(self, users : List[DDUser], extra_data : Dict) -> Dict:
+ # TODO: implement
+ return {}
+
+ def check_connections(self, app : "AdminFlaskApp") -> None:
ready = False
while not ready:
try:
@@ -111,7 +153,7 @@ class Admin:
ready = False
while not ready:
try:
- self.moodle = Moodle(verify=app.config["VERIFY"])
+ self.moodle = Moodle(app)
ready = True
except:
log.error("Could not connect to moodle, waiting to be online...")
@@ -136,18 +178,18 @@ class Admin:
ready = False
while not ready:
try:
- self.nextcloud = Nextcloud(verify=app.config["VERIFY"])
+ self.nextcloud = Nextcloud(verify=app.config["VERIFY"], app=app)
ready = True
except:
log.error("Could not connect to nextcloud, waiting to be online...")
sleep(2)
log.warning("Nextcloud connected.")
- def set_custom_roles(self):
+ def set_custom_roles(self) -> None:
pass
## This function should be moved to postup.py
- def overwrite_admins(self):
+ def overwrite_admins(self) -> None:
log.warning("Setting defaults...")
dduser = os.environ["DDADMIN_USER"]
ddpassword = os.environ["DDADMIN_PASSWORD"]
@@ -223,7 +265,7 @@ class Admin:
log.error(traceback.format_exc())
exit(1)
- def default_setup(self):
+ def default_setup(self) -> None:
### Add default roles
try:
log.warning("KEYCLOAK: Adding default roles")
@@ -324,7 +366,7 @@ class Admin:
# except:
# log.warning("KEYCLOAK: Seems to be there already")
- def resync_data(self):
+ def resync_data(self) -> bool:
self.internal = {
"users": self._get_mix_users(),
"groups": self._get_mix_groups(),
@@ -332,7 +374,7 @@ class Admin:
}
return True
- def get_moodle_users(self):
+ def get_moodle_users(self) -> List[Any]:
return [
u
for u in self.moodle.get_users_with_groups_and_roles()
@@ -353,7 +395,7 @@ class Admin:
# "roles": u['roles']}
# for u in users]
- def get_keycloak_users(self):
+ def get_keycloak_users(self) -> List[DDUser]:
# log.warning('Loading keycloak users... can take a long time...')
users = self.keycloak.get_users_with_groups_and_roles()
@@ -372,7 +414,7 @@ class Admin:
if not system_username(u["username"])
]
- def get_nextcloud_users(self):
+ def get_nextcloud_users(self) -> List[DDUser]:
return [
{
"id": u["username"],
@@ -414,11 +456,11 @@ class Admin:
# "roles": []})
# return users_list
- def get_mix_users(self):
- sio_event_send("get_users", {"you_win": "you got the users!"})
+ def get_mix_users(self) -> Any:
+ sio_event_send(self.app, "get_users", {"you_win": "you got the users!"})
return self.internal["users"]
- def _get_mix_users(self):
+ def _get_mix_users(self) -> List[DDUser]:
kgroups = self.keycloak.get_groups()
kusers = self.get_keycloak_users()
@@ -481,32 +523,32 @@ class Admin:
users.append(theuser)
return users
- def get_roles(self):
+ def get_roles(self) -> Any:
return self.internal["roles"]
- def _get_roles(self):
+ def _get_roles(self) -> List[DDRole]:
return filter_roles_listofdicts(self.keycloak.get_roles())
- def get_group_by_name(self, group_name):
+ def get_group_by_name(self, group_name : str) -> Any:
group = [g for g in self.internal["groups"] if g["name"] == group_name]
return group[0] if len(group) else False
- def get_keycloak_groups(self):
+ def get_keycloak_groups(self) -> Any:
log.warning("Loading keycloak groups...")
return self.keycloak.get_groups()
- def get_moodle_groups(self):
+ def get_moodle_groups(self) -> Any:
log.warning("Loading moodle groups...")
return self.moodle.get_cohorts()
- def get_nextcloud_groups(self):
+ def get_nextcloud_groups(self) -> Any:
log.warning("Loading nextcloud groups...")
return self.nextcloud.get_groups_list()
- def get_mix_groups(self):
+ def get_mix_groups(self) -> Any:
return self.internal["groups"]
- def _get_mix_groups(self):
+ def _get_mix_groups(self) -> List[Dict[str, Any]]:
kgroups = self.get_keycloak_groups()
mgroups = self.get_moodle_groups()
ngroups = self.get_nextcloud_groups()
@@ -564,7 +606,7 @@ class Admin:
groups.append(thegroup)
return groups
- def sync_groups_from_keycloak(self):
+ def sync_groups_from_keycloak(self) -> None:
self.resync_data()
for group in self.internal["groups"]:
if not group["keycloak"]:
@@ -586,22 +628,22 @@ class Admin:
self.nextcloud.add_group(group["name"])
self.resync_data()
- def get_external_users(self):
+ def get_external_users(self) -> Any:
return self.external["users"]
- def get_external_groups(self):
+ def get_external_groups(self) -> Any:
return self.external["groups"]
- def get_external_roles(self):
+ def get_external_roles(self) -> Any:
return self.external["roles"]
- def upload_csv_ug(self, data):
+ def upload_csv_ug(self, data : Dict[str, Any]) -> bool:
log.warning("Processing uploaded users...")
users = []
total = len(data["data"])
item = 1
- ev = Events("Processing uploaded users", total=len(data["data"]))
- groups = []
+ ev = Events(self.app, "Processing uploaded users", total=len(data["data"]))
+ groups : List[str] = []
for u in data["data"]:
log.warning(
"Processing ("
@@ -680,18 +722,18 @@ class Admin:
self.external["groups"] = sysgroups
return True
- def get_dice_pwd(self):
- return diceware.get_passphrase(options=options)
+ def get_dice_pwd(self) -> str:
+ return cast(str, diceware.get_passphrase(options=options))
- def reset_external(self):
+ def reset_external(self) -> bool:
self.external = {"users": [], "groups": [], "roles": []}
return True
- def upload_json_ga(self, data):
+ def upload_json_ga(self, data : Dict[str, Any]) -> bool:
groups = []
log.warning("Processing uploaded groups...")
try:
- ev = Events(
+ ev = Events(self.app,
"Processing uploaded groups",
"Group:",
total=len(data["data"]["groups"]),
@@ -718,7 +760,7 @@ class Admin:
users = []
total = len(data["data"]["users"])
item = 1
- ev = Events(
+ ev = Events(self.app,
"Processing uploaded users",
"User:",
total=len(data["data"]["users"]),
@@ -757,7 +799,8 @@ class Admin:
u["groups"] = u["groups"] + [g["name"]]
return True
- def sync_external(self, ids):
+ def sync_external(self, ids : Any) -> None:
+ # TODO: What is this endpoint for? When is it called?
# self.resync_data()
log.warning("Starting sync to keycloak")
self.sync_to_keycloak_external()
@@ -769,10 +812,10 @@ class Admin:
log.warning("All syncs finished. Resyncing from apps...")
self.resync_data()
- def add_keycloak_groups(self, groups):
+ def add_keycloak_groups(self, groups : List[Any]) -> None:
total = len(groups)
i = 0
- ev = Events(
+ ev = Events(self.app,
"Syncing import groups to keycloak", "Adding group:", total=len(groups)
)
for g in groups:
@@ -790,8 +833,8 @@ class Admin:
def sync_to_keycloak_external(
self,
- ): ### This one works from the external, moodle and nextcloud from the internal
- groups = []
+ ) -> None: ### This one works from the external, moodle and nextcloud from the internal
+ groups : List[DDGroup] = []
for u in self.external["users"]:
groups = groups + u["groups"]
groups = list(dict.fromkeys(groups))
@@ -800,7 +843,7 @@ class Admin:
total = len(self.external["users"])
index = 0
- ev = Events(
+ ev = Events(self.app,
"Syncing import users to keycloak",
"Adding user:",
total=len(self.external["users"]),
@@ -855,11 +898,11 @@ class Admin:
u["groups"].append(u["roles"][0])
self.resync_data()
- def add_moodle_groups(self, groups):
+ def add_moodle_groups(self, groups : List[Any]) -> None:
### Create all groups. Skip / in system groups
total = len(groups)
log.warning(groups)
- ev = Events("Syncing groups from external to moodle", total=len(groups))
+ ev = Events(self.app, "Syncing groups from external to moodle", total=len(groups))
i = 1
for g in groups:
moodle_groups = kpath2gids(g)
@@ -880,9 +923,9 @@ class Admin:
)
i = i + 1
- def sync_to_moodle_external(self): # works from the internal (keycloak)
+ def sync_to_moodle_external(self) -> None: # works from the internal (keycloak)
### Process all groups from the users keycloak_groups key
- groups = []
+ groups : List[DDGroup] = []
for u in self.external["users"]:
groups = groups + u["groups"]
groups = list(dict.fromkeys(groups))
@@ -893,7 +936,7 @@ class Admin:
cohorts = self.moodle.get_cohorts()
### Create users in moodle
- ev = Events(
+ ev = Events(self.app,
"Syncing users from external to moodle", total=len(self.internal["users"])
)
for u in self.external["users"]:
@@ -920,7 +963,7 @@ class Admin:
# self.resync_data()
### Add user to their cohorts (groups)
- ev = Events(
+ ev = Events(self.app,
"Syncing users groups from external to moodle cohorts",
total=len(self.internal["users"]),
)
@@ -938,16 +981,16 @@ class Admin:
log.error(self.moodle.get_user_by("username", u["username"]))
# self.resync_data()
- def delete_all_moodle_cohorts(self):
+ def delete_all_moodle_cohorts(self) -> None:
cohorts = self.moodle.get_cohorts()
ids = [c["id"] for c in cohorts]
self.moodle.delete_cohorts(ids)
- def add_nextcloud_groups(self, groups):
+ def add_nextcloud_groups(self, groups : List[Any]) -> None:
### Create all groups. Skip / in system groups
total = len(groups)
log.warning(groups)
- ev = Events("Syncing groups from external to nextcloud", total=len(groups))
+ ev = Events(self.app, "Syncing groups from external to nextcloud", total=len(groups))
i = 1
for g in groups:
nextcloud_groups = kpath2gids(g)
@@ -968,15 +1011,15 @@ class Admin:
)
i = i + 1
- def sync_to_nextcloud_external(self):
- groups = []
+ def sync_to_nextcloud_external(self) -> None:
+ groups : List[DDGroup] = []
for u in self.external["users"]:
groups = groups + u["gids"]
groups = list(dict.fromkeys(groups))
self.add_nextcloud_groups(groups)
- ev = Events(
+ ev = Events(self.app,
"Syncing users from external to nextcloud",
total=len(self.internal["users"]),
)
@@ -1009,14 +1052,14 @@ class Admin:
except:
log.error(traceback.format_exc())
- def sync_to_moodle(self): # works from the internal (keycloak)
+ def sync_to_moodle(self) -> None: # works from the internal (keycloak)
### Process all groups from the users keycloak_groups key
- groups = []
+ groups : List[str] = []
for u in self.internal["users"]:
groups = groups + u["keycloak_groups"]
groups = list(dict.fromkeys(groups))
- ev = Events("Syncing groups from keycloak to moodle", total=len(groups))
+ ev = Events(self.app, "Syncing groups from keycloak to moodle", total=len(groups))
pathslist = []
for group in groups:
pathpart = ""
@@ -1040,7 +1083,7 @@ class Admin:
cohorts = self.moodle.get_cohorts()
### Create users in moodle
- ev = Events(
+ ev = Events(self.app,
"Syncing users from keycloak to moodle", total=len(self.internal["users"])
)
for u in self.internal["users"]:
@@ -1067,7 +1110,7 @@ class Admin:
self.resync_data()
- ev = Events(
+ ev = Events(self.app,
"Syncing users with moodle cohorts", total=len(self.internal["users"])
)
cohorts = self.moodle.get_cohorts()
@@ -1106,15 +1149,15 @@ class Admin:
self.resync_data()
- def sync_to_nextcloud(self):
- groups = []
+ def sync_to_nextcloud(self) -> None:
+ groups : List[str] = []
for u in self.internal["users"]:
groups = groups + u["keycloak_groups"]
groups = list(dict.fromkeys(groups))
total = len(groups)
i = 0
- ev = Events("Syncing groups from keycloak to nextcloud", total=len(groups))
+ ev = Events(self.app, "Syncing groups from keycloak to nextcloud", total=len(groups))
for g in groups:
parts = g.split("/")
subpath = ""
@@ -1137,7 +1180,7 @@ class Admin:
)
i = i + 1
- ev = Events(
+ ev = Events(self.app,
"Syncing users from keycloak to nextcloud",
total=len(self.internal["users"]),
)
@@ -1167,13 +1210,13 @@ class Admin:
except:
log.error(traceback.format_exc())
- def delete_keycloak_user(self, userid):
- user = [u for u in self.internal["users"] if u["id"] == userid]
- if len(user) and user[0]["keycloak"]:
- user = user[0]
+ def delete_keycloak_user(self, userid : str) -> None:
+ users : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
+ if len(users) and users[0]["keycloak"]:
+ user = users[0]
keycloak_id = user["id"]
else:
- return False
+ return
log.warning("Removing keycloak user: " + user["username"])
try:
self.keycloak.delete_user(keycloak_id)
@@ -1183,10 +1226,10 @@ class Admin:
self.av.delete_user_avatar(userid)
- def delete_keycloak_users(self):
+ def delete_keycloak_users(self) -> None:
total = len(self.internal["users"])
i = 0
- ev = Events(
+ ev = Events(self.app,
"Deleting users from keycloak",
"Deleting user:",
total=len(self.internal["users"]),
@@ -1217,13 +1260,13 @@ class Admin:
)
self.av.minio_delete_all_objects()
- def delete_nextcloud_user(self, userid):
- user = [u for u in self.internal["users"] if u["id"] == userid]
- if len(user) and user[0]["nextcloud"]:
- user = user[0]
+ def delete_nextcloud_user(self, userid : str) -> None:
+ users : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
+ if len(users) and users[0]["nextcloud"]:
+ user = users[0]
nextcloud_id = user["nextcloud_id"]
else:
- return False
+ return
log.warning("Removing nextcloud user: " + user["username"])
try:
self.nextcloud.delete_user(nextcloud_id)
@@ -1231,8 +1274,8 @@ class Admin:
log.error(traceback.format_exc())
log.warning("Could not remove users: " + user["username"])
- def delete_nextcloud_users(self):
- ev = Events("Deleting users from nextcloud", total=len(self.internal["users"]))
+ def delete_nextcloud_users(self) -> None:
+ ev = Events(self.app, "Deleting users from nextcloud", total=len(self.internal["users"]))
for u in self.internal["users"]:
if u["nextcloud"] and not u["keycloak"]:
@@ -1246,13 +1289,13 @@ class Admin:
log.error(traceback.format_exc())
log.warning("Could not remove user: " + u["username"])
- def delete_moodle_user(self, userid):
- user = [u for u in self.internal["users"] if u["id"] == userid]
- if len(user) and user[0]["moodle"]:
- user = user[0]
+ def delete_moodle_user(self, userid : str) -> None:
+ users : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
+ if len(users) and users[0]["moodle"]:
+ user = users[0]
moodle_id = user["moodle_id"]
else:
- return False
+ return
log.warning("Removing moodle user: " + user["username"])
try:
self.moodle.delete_users([moodle_id])
@@ -1260,7 +1303,7 @@ class Admin:
log.error(traceback.format_exc())
log.warning("Could not remove users: " + user["username"])
- def delete_moodle_users(self):
+ def delete_moodle_users(self, app : "AdminFlaskApp") -> None:
userids = []
usernames = []
for u in self.internal["users"]:
@@ -1288,7 +1331,7 @@ class Admin:
log.error(traceback.format_exc())
log.warning("Could not remove users: " + ",".join(usernames))
- def delete_keycloak_groups(self):
+ def delete_keycloak_groups(self) -> None:
for g in self.internal["groups"]:
if not g["keycloak"]:
continue
@@ -1302,7 +1345,7 @@ class Admin:
log.error(traceback.format_exc())
log.warning("Could not remove group: " + g["name"])
- def external_roleassign(self, data):
+ def external_roleassign(self, data : Dict[str, Any]) -> bool:
for newuserid in data["ids"]:
for externaluser in self.external["users"]:
if externaluser["id"] == newuserid:
@@ -1316,10 +1359,10 @@ class Admin:
externaluser["gids"].append(data["action"])
return True
- def user_update_password(self, userid, password, password_temporary):
+ def user_update_password(self, userid : str, password : str, password_temporary : bool) -> Any:
return self.keycloak.update_user_pwd(userid, password, password_temporary)
- def update_users_from_keycloak(self):
+ def update_users_from_keycloak(self) -> None:
kgroups = self.keycloak.get_groups()
users = [
{
@@ -1339,15 +1382,15 @@ class Admin:
]
for user in users:
- ev = Events(
+ ev = Events(self.app,
"Updating users from keycloak", "User:", total=len(users), table="users"
)
self.user_update(user)
ev.increment({"name": user["username"], "data": user["groups"]})
- def user_update(self, user):
+ def user_update(self, user : DDUser) -> bool:
log.warning("Updating user moodle, nextcloud keycloak")
- ev = Events("Updating user", "Updating user in keycloak")
+ ev = Events(self.app, "Updating user", "Updating user in keycloak")
## Get actual user role
try:
@@ -1505,6 +1548,9 @@ class Admin:
ev.update_text("Updating user in nextcloud")
self.update_nextcloud_user(internaluser["id"], user, ndelete, nadd)
+ ev.update_text("Updating user in other apps")
+ self.third_party_update_user(internaluser["id"], user)
+
ev.update_text("User updated")
return True
@@ -1533,7 +1579,7 @@ class Admin:
)
return True
- def update_keycloak_user(self, user_id, user, kdelete, kadd):
+ def update_keycloak_user(self, user_id : str, user : DDUser, kdelete : List[Any], kadd : List[Any]) -> bool:
# pprint(self.keycloak.get_user_realm_roles(user_id))
self.keycloak.remove_user_realm_roles(user_id, "student")
self.keycloak.assign_realm_roles(user_id, user["roles"][0])
@@ -1549,27 +1595,27 @@ class Admin:
self.resync_data()
return True
- def enable_users(self, data):
+ def enable_users(self, data : List[DDUser]) -> None:
# data={'id':'','username':''}
- ev = Events("Bulk actions", "Enabling user:", total=len(data))
+ ev = Events(self.app, "Bulk actions", "Enabling user:", total=len(data))
for user in data:
ev.increment({"name": user["username"], "data": user["username"]})
self.keycloak.user_enable(user["id"])
self.resync_data()
- def disable_users(self, data):
+ def disable_users(self, data : List[DDUser]) -> None:
# data={'id':'','username':''}
- ev = Events("Bulk actions", "Disabling user:", total=len(data))
+ ev = Events(self.app, "Bulk actions", "Disabling user:", total=len(data))
for user in data:
ev.increment({"name": user["username"], "data": user["username"]})
self.keycloak.user_disable(user["id"])
self.resync_data()
- def update_moodle_user(self, user_id, user, mdelete, madd):
- internaluser = [u for u in self.internal["users"] if u["id"] == user_id][0]
+ def update_moodle_user(self, user_id : str, user : DDUser, mdelete : Iterable[Any], madd : Iterable[Any]) -> bool:
+ internaluser : DDUser = [u for u in self.internal["users"] if u["id"] == user_id][0]
cohorts = self.moodle.get_cohorts()
for group in mdelete:
- cohort = [c for c in cohorts if c["name"] == group]
+ cohort = [c for c in cohorts if c["name"] == group[0]][0]
try:
self.moodle.delete_user_in_cohort(
internaluser["moodle_id"], cohort["id"]
@@ -1604,29 +1650,29 @@ class Admin:
def add_moodle_user(
self,
- username,
- email,
- first_name,
- last_name,
- password="*12" + secrets.token_urlsafe(16),
- ):
+ username : str,
+ email : str,
+ first_name : str,
+ last_name : str,
+ password : str="*12" + secrets.token_urlsafe(16),
+ ) -> None:
log.warning("Creating moodle user: " + username)
- ev = Events("Add user", username)
+ ev = Events(self.app, "Add user", username)
try:
self.moodle.create_user(email, username, password, first_name, last_name)
- ev.update_text({"name": "Added to moodle", "data": []})
- except UserExists:
+ ev.update_text(str({"name": "Added to moodle", "data": []}))
+ except UserExists as ex:
log.error(" -->> User already exists")
- error = Events("User already exists.", str(se), type="error")
- except SystemError as se:
- log.error("Moodle create user error: " + str(se))
- error = Events("Moodle create user error", str(se), type="error")
+ error = Events(self.app, "User already exists.", str(ex), type="error")
+ except SystemError as ex:
+ log.error("Moodle create user error: " + str(ex))
+ error = Events(self.app, "Moodle create user error", str(ex), type="error")
except:
log.error(" -->> Error creating on moodle the user: " + username)
print(traceback.format_exc())
- error = Events("Internal error", "Check logs", type="error")
+ error = Events(self.app, "Internal error", "Check logs", type="error")
- def update_nextcloud_user(self, user_id, user, ndelete, nadd):
+ def update_nextcloud_user(self, user_id : str, user : DDUser, ndelete : Iterable[Any], nadd : Iterable[Any]) -> None:
## TODO: Disable de user? Is really needed? it is disabled in keycloak, so can't login again
## ocs/v1.php/cloud/users/{userid}/disable
@@ -1676,21 +1722,21 @@ class Admin:
def add_nextcloud_user(
self,
- username,
- email,
- quota,
- first_name,
- last_name,
- groups,
- password="*12" + secrets.token_urlsafe(16),
- ):
+ username : str,
+ email : str,
+ quota : Any,
+ first_name : str,
+ last_name : str,
+ groups : str,
+ password : str = "*12" + secrets.token_urlsafe(16),
+ ) -> None:
log.warning(
" NEXTCLOUD USERS: Creating nextcloud user: "
+ username
+ " in groups "
+ str(groups)
)
- ev = Events("Add user", username)
+ ev = Events(self.app, "Add user", username)
try:
# Quota is "1 GB", "500 MB"
self.nextcloud.add_user_with_groups(
@@ -1704,41 +1750,44 @@ class Admin:
except:
log.error(traceback.format_exc())
- def delete_users(self, data):
- ev = Events("Bulk actions", "Deleting users:", total=len(data))
+ def delete_users(self, data : List[DDUser]) -> None:
+ ev = Events(self.app, "Bulk actions", "Deleting users:", total=len(data))
for user in data:
ev.increment({"name": user["username"], "data": user["username"]})
self.delete_user(user["id"])
self.resync_data()
- def delete_user(self, userid):
+ def delete_user(self, userid : str) -> bool:
log.warning("Deleting user moodle, nextcloud keycloak")
- ev = Events("Deleting user", "Deleting from moodle")
+ ev = Events(self.app, "Deleting user", "Deleting from moodle")
self.delete_moodle_user(userid)
ev.update_text("Deleting from nextcloud")
self.delete_nextcloud_user(userid)
ev.update_text("Deleting from keycloak")
self.delete_keycloak_user(userid)
+
+ ev.update_text("Deleting in other apps")
+ self.third_party_delete_user(userid)
+
ev.update_text("Syncing data from applications...")
self.resync_data()
ev.update_text("User deleted")
- sio_event_send("delete_user", {"userid": userid})
+ sio_event_send(self.app, "delete_user", {"userid": userid})
return True
- def get_user(self, userid):
- user = [u for u in self.internal["users"] if u["id"] == userid]
+ def get_user(self, userid : str) -> Optional[DDUser]:
+ user : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
if not len(user):
- return False
+ return None
return user[0]
- def get_user_username(self, username):
- user = [u for u in self.internal["users"] if u["username"] == username]
+ def get_user_username(self, username : str) -> Optional[DDUser]:
+ user : List[DDUser] = [u for u in self.internal["users"] if u["username"] == username]
if not len(user):
- return False
+ return None
return user[0]
- def add_user(self, u):
-
+ def add_user(self, u : DDUser) -> Any:
pathslist = []
for group in u["groups"]:
pathpart = ""
@@ -1767,7 +1816,7 @@ class Admin:
### KEYCLOAK
#######################
- ev = Events("Add user", u["username"], total=5)
+ ev = Events(self.app, "Add user", u["username"], total=5)
log.warning(" KEYCLOAK USERS: Adding user: " + u["username"])
uid = self.keycloak.add_user(
u["username"],
@@ -1810,16 +1859,16 @@ class Admin:
u["last"],
)[0]["id"]
ev.increment({"name": "Added to moodle", "data": []})
- except UserExists:
+ except UserExists as ex:
log.error(" -->> User already exists")
- error = Events("User already exists.", str(se), type="error")
- except SystemError as se:
- log.error("Moodle create user error: " + str(se))
- error = Events("Moodle create user error", str(se), type="error")
+ error = Events(self.app, "User already exists.", str(ex), type="error")
+ except SystemError as ex:
+ log.error("Moodle create user error: " + str(ex))
+ error = Events(self.app, "Moodle create user error", str(ex), type="error")
except:
log.error(" -->> Error creating on moodle the user: " + u["username"])
print(traceback.format_exc())
- error = Events("Internal error", "Check logs", type="error")
+ error = Events(self.app, "Internal error", "Check logs", type="error")
# Add user to cohort
## Get all existing moodle cohorts
@@ -1874,31 +1923,32 @@ class Admin:
except:
log.error(traceback.format_exc())
+ self.third_party_add_user(uid, u)
+
self.resync_data()
- sio_event_send("new_user", u)
+ sio_event_send(self.app, "new_user", u)
return uid
- def add_group(self, g):
+ def add_group(self, g : DDGroup) -> str:
# TODO: Check if exists
# We add in keycloak with his name, will be shown in app with full path with dots
if g["parent"] != None:
g["parent"] = gid2kpath(g["parent"])
- new_path = self.keycloak.add_group(g["name"], g["parent"])
+ new_path_kc = self.keycloak.add_group(g["name"], g["parent"])
+ new_path : str = g["name"]
if g["parent"] != None:
- new_path = kpath2gid(new_path["path"])
- else:
- new_path = g["name"]
+ new_path = kpath2gid(new_path_kc["path"])
self.moodle.add_system_cohort(new_path, description=g["description"])
self.nextcloud.add_group(new_path)
self.resync_data()
return new_path
- def delete_group_by_id(self, group_id):
- ev = Events("Deleting group", "Deleting from keycloak")
+ def delete_group_by_id(self, group_id : str) -> None:
+ ev = Events(self.app, "Deleting group", "Deleting from keycloak")
try:
keycloak_group = self.keycloak.get_group_by_id(group_id)
except Exception as e:
@@ -1918,7 +1968,7 @@ class Admin:
try:
self.keycloak.delete_group(group_id)
except:
- log.error("KEYCLOAK GROUPS: Could no delete group " + group["path"])
+ log.error("KEYCLOAK GROUPS: Could no delete group " + group_id)
return
cohorts = self.moodle.get_cohorts()
@@ -1932,7 +1982,7 @@ class Admin:
self.nextcloud.delete_group(sg_gid)
self.resync_data()
- def delete_group_by_path(self, path):
+ def delete_group_by_path(self, path : str) -> None:
group = self.keycloak.get_group_by_path(path)
to_be_deleted = []
@@ -1953,6 +2003,3 @@ class Admin:
self.moodle.delete_cohorts(cohort)
self.nextcloud.delete_group(gid)
self.resync_data()
-
- def set_nextcloud_user_mail(self, data):
- self.nextcloud.set_user_mail(data)
diff --git a/dd-sso/admin/src/admin/lib/api_exceptions.py b/dd-sso/admin/src/admin/lib/api_exceptions.py
index dc11014..473d056 100644
--- a/dd-sso/admin/src/admin/lib/api_exceptions.py
+++ b/dd-sso/admin/src/admin/lib/api_exceptions.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -23,10 +24,11 @@ import logging as log
import os
import traceback
-from flask import jsonify, request
+from typing import Any, Dict, Union, List
-from admin import app
+from flask import request
+# TODO: Improve these constants' structure
content_type = {"Content-Type": "application/json"}
ex = {
"bad_request": {
@@ -96,8 +98,10 @@ ex = {
class Error(Exception):
- def __init__(self, error="bad_request", description="", debug="", data=None):
- self.error = ex[error]["error"].copy()
+ status_code : int
+ content_type : Dict[str, str]
+ def __init__(self, error : str ="bad_request", description : str="", debug : Union[str, List[str]]="", data : Any =None):
+ self.error : Dict[str, str] = (ex[error]["error"]).copy() # type: ignore # bad struct
self.error["function"] = (
inspect.stack()[1][1].split(os.sep)[-1]
+ ":"
@@ -123,7 +127,7 @@ class Error(Exception):
"----------- REQUEST START -----------",
request.method + " " + request.url,
"\r\n".join("{}: {}".format(k, v) for k, v in request.headers.items()),
- request.body if hasattr(request, "body") else "",
+ getattr(request, "body", ""),
"----------- REQUEST STOP -----------",
)
if request
@@ -138,7 +142,7 @@ class Error(Exception):
if data
else ""
)
- self.status_code = ex[error]["status_code"]
+ self.status_code = ex[error]["status_code"] # type: ignore # bad struct
self.content_type = content_type
log.debug(
"%s - %s - [%s -> %s]\r\n%s\r\n%s\r\n%s"
@@ -152,11 +156,3 @@ class Error(Exception):
self.error["data"],
)
)
-
-
-@app.errorhandler(Error)
-def handle_user_error(ex):
- response = jsonify(ex.error)
- response.status_code = ex.status_code
- response.headers = {"content-type": content_type}
- return response
diff --git a/dd-sso/admin/src/admin/lib/avatars.py b/dd-sso/admin/src/admin/lib/avatars.py
index 6805f09..a5f08d5 100644
--- a/dd-sso/admin/src/admin/lib/avatars.py
+++ b/dd-sso/admin/src/admin/lib/avatars.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -26,11 +27,13 @@ from minio.commonconfig import REPLACE, CopySource
from minio.deleteobjects import DeleteObject
from requests import get, post
-from admin import app
+from typing import Any, Callable, Dict, Iterable, List
class Avatars:
- def __init__(self):
+ avatars_path : str
+ def __init__(self, avatars_path : str):
+ self.avatars_path = avatars_path
self.mclient = Minio(
"dd-sso-avatars:9000",
access_key="AKIAIOSFODNN7EXAMPLE",
@@ -41,21 +44,22 @@ class Avatars:
self._minio_set_realm()
# self.update_missing_avatars()
- def add_user_default_avatar(self, userid, role="unknown"):
+ def add_user_default_avatar(self, userid : str, role : str="unknown") -> None:
+ path = os.path.join(self.avatars_path, role) + ".jpg",
self.mclient.fput_object(
self.bucket,
userid,
- os.path.join(app.root_path, "../custom/avatars/" + role + ".jpg"),
+ path,
content_type="image/jpeg ",
)
log.warning(
" AVATARS: Updated avatar for user " + userid + " with role " + role
)
- def delete_user_avatar(self, userid):
+ def delete_user_avatar(self, userid : str) -> None:
self.minio_delete_object(userid)
- def update_missing_avatars(self, users):
+ def update_missing_avatars(self, users : Iterable[Dict[str, Any]]) -> None:
sys_roles = ["admin", "manager", "teacher", "student"]
for u in self.get_users_without_image(users):
try:
@@ -63,10 +67,11 @@ class Avatars:
except:
img = "unknown.jpg"
+ path = os.path.join(self.avatars_path, img)
self.mclient.fput_object(
self.bucket,
u["id"],
- os.path.join(app.root_path, "../custom/avatars/" + img),
+ path,
content_type="image/jpeg ",
)
log.warning(
@@ -76,26 +81,24 @@ class Avatars:
+ img.split(".")[0]
)
- def _minio_set_realm(self):
+ def _minio_set_realm(self) -> None:
if not self.mclient.bucket_exists(self.bucket):
self.mclient.make_bucket(self.bucket)
- def minio_get_objects(self):
+ def minio_get_objects(self) -> List[Any]:
return [o.object_name for o in self.mclient.list_objects(self.bucket)]
- def minio_delete_all_objects(self):
- delete_object_list = map(
- lambda x: DeleteObject(x.object_name),
- self.mclient.list_objects(self.bucket),
- )
+ def minio_delete_all_objects(self) -> None:
+ f : Callable[[Any], Any] = lambda x: DeleteObject(x.object_name)
+ delete_object_list = map(f, self.mclient.list_objects(self.bucket))
errors = self.mclient.remove_objects(self.bucket, delete_object_list)
for error in errors:
log.error(" AVATARS: Error occured when deleting avatar object: " + error)
- def minio_delete_object(self, oid):
+ def minio_delete_object(self, oid : str) -> None:
errors = self.mclient.remove_objects(self.bucket, [DeleteObject(oid)])
for error in errors:
log.error(" AVATARS: Error occured when deleting avatar object: " + error)
- def get_users_without_image(self, users):
+ def get_users_without_image(self, users : Iterable[Dict[str, Any]]) -> Iterable[Dict[str, Any]]:
return [u for u in users if u["id"] and u["id"] not in self.minio_get_objects()]
diff --git a/dd-sso/admin/src/admin/lib/callbacks.py b/dd-sso/admin/src/admin/lib/callbacks.py
new file mode 100644
index 0000000..8f66002
--- /dev/null
+++ b/dd-sso/admin/src/admin/lib/callbacks.py
@@ -0,0 +1,115 @@
+#
+# Copyright © 2022 Evilham
+#
+# This file is part of DD
+#
+# DD is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or (at your
+# option) any later version.
+#
+# DD is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with DD. If not, see .
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+import copy
+from typing import Any, Dict, Tuple
+
+import requests
+from attr import define
+
+from admin.lib.keys import ThirdPartyIntegrationKeys
+
+DDUser = Dict[str, Any]
+
+
+def user_parser(dduser: DDUser) -> DDUser:
+ user = copy.deepcopy(dduser)
+ user["keycloak_id"] = user.pop("id")
+ user["role"] = user["roles"][0] if user.get("roles", []) else None
+ user["groups"] = user.get("groups", user.get("keycloak_groups", []))
+ return user
+
+
+@define
+class ThirdPartyCallbacks:
+ """
+ If necessary this class may be inherited from and customised.
+ By default it uses the configured endpoints to send data to the third-party.
+ This data is sent using ThirdPartyIntegrationKeys, which takes care of
+ encrypting first, then signing, with the service-specific keys.
+ """
+
+ tpkeys: ThirdPartyIntegrationKeys
+ """
+ The ThirdPartyIntegrationKeys instance where domain and keys are setup.
+ """
+
+ endpoint_add_users: Tuple[
+ str,
+ str,
+ ] = ("POST", "/api/users/")
+ """
+ An HTTP_METHOD, ENDPOINT tuple, where ENDPOINT is an absolute path.
+ """
+
+ endpoint_update_users: Tuple[
+ str,
+ str,
+ ] = ("PUT", "/api/users/")
+ """
+ An HTTP_METHOD, ENDPOINT tuple, where ENDPOINT is an absolute path.
+ """
+
+ endpoint_delete_users: Tuple[
+ str,
+ str,
+ ] = ("DELETE", "/api/users/")
+ """
+ An HTTP_METHOD, ENDPOINT tuple, where ENDPOINT is an absolute path.
+ """
+
+ @property
+ def add_users_url(self) -> str:
+ return f"{self.tpkeys.their_service_domain}{self.endpoint_add_users[1]}"
+
+ @property
+ def update_users_url(self) -> str:
+ return f"{self.tpkeys.their_service_domain}{self.endpoint_update_users[1]}"
+
+ @property
+ def delete_users_url(self) -> str:
+ return f"{self.tpkeys.their_service_domain}{self.endpoint_delete_users[1]}"
+
+ def _request(self, method: str, url: str, data: DDUser) -> bool:
+ # The endpoints are prepared for batch operations, but the way
+ # the admin lib is set up, it is currently not doable.
+ prepared_data = [user_parser(data)]
+ try:
+ enc_data = self.tpkeys.sign_and_encrypt_outgoing_json(prepared_data)
+ headers = self.tpkeys.get_outgoing_request_headers()
+ res = requests.request(method, url, data=enc_data, headers=headers)
+ except:
+ # Something went wrong sending the request
+ return False
+ return res.status_code == 200
+
+ def add_user(self, user_id: str, user: DDUser) -> bool:
+ data = copy.deepcopy(user)
+ data["id"] = user_id
+ return self._request(self.endpoint_add_users[0], self.add_users_url, data)
+
+ def update_user(self, user_id: str, user: DDUser) -> bool:
+ data = copy.deepcopy(user)
+ data["id"] = user_id
+ return self._request(self.endpoint_update_users[0], self.update_users_url, data)
+
+ def delete_user(self, user_id: str) -> bool:
+ data = {"id": user_id}
+ return self._request(self.endpoint_delete_users[0], self.delete_users_url, data)
diff --git a/dd-sso/admin/src/admin/lib/dashboard.py b/dd-sso/admin/src/admin/lib/dashboard.py
index eba50d8..b164208 100644
--- a/dd-sso/admin/src/admin/lib/dashboard.py
+++ b/dd-sso/admin/src/admin/lib/dashboard.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -29,16 +30,25 @@ import yaml
from PIL import Image
from schema import And, Optional, Schema, SchemaError, Use
-from admin import app
+from typing import TYPE_CHECKING, Any, Dict
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
+from werkzeug.datastructures import FileStorage
class Dashboard:
+ app : "AdminFlaskApp"
def __init__(
self,
- ):
- self.custom_menu = os.path.join(app.root_path, "../custom/menu/custom.yaml")
+ app : "AdminFlaskApp",
+ ) -> None:
+ self.app = app
- def _update_custom_menu(self, custom_menu_part):
+ @property
+ def custom_menu(self) -> str:
+ return os.path.join(self.app.custom_dir, "menu/custom.yaml")
+
+ def _update_custom_menu(self, custom_menu_part : Dict[str, Any]) -> bool:
with open(self.custom_menu) as yml:
menu = yaml.load(yml, Loader=yaml.FullLoader)
menu = {**menu, **custom_menu_part}
@@ -46,7 +56,7 @@ class Dashboard:
yml.write(yaml.dump(menu, default_flow_style=False))
return True
- def update_colours(self, colours):
+ def update_colours(self, colours : Dict[str, Any]) -> bool:
schema_template = Schema(
{
"background": And(Use(str)),
@@ -63,7 +73,7 @@ class Dashboard:
self._update_custom_menu({"colours": colours})
return self.apply_updates()
- def update_menu(self, menu):
+ def update_menu(self, menu : Dict[str, Any]) -> bool:
items = []
for menu_item in menu.keys():
for mustexist_key in ["href", "icon", "name", "shortname"]:
@@ -73,16 +83,16 @@ class Dashboard:
self._update_custom_menu({"apps_external": items})
return self.apply_updates()
- def update_logo(self, logo):
+ def update_logo(self, logo : FileStorage) -> bool:
img = Image.open(logo.stream)
- img.save(os.path.join(app.root_path, "../custom/img/logo.png"))
+ img.save(os.path.join(self.app.custom_dir, "img/logo.png"))
return self.apply_updates()
- def update_background(self, background):
+ def update_background(self, background : FileStorage) -> bool:
img = Image.open(background.stream)
- img.save(os.path.join(app.root_path, "../custom/img/background.png"))
+ img.save(os.path.join(self.app.custom_dir, "img/background.png"))
return self.apply_updates()
- def apply_updates(self):
+ def apply_updates(self) -> bool:
resp = requests.get("http://dd-sso-api:7039/restart")
return True
diff --git a/dd-sso/admin/src/admin/lib/events.py b/dd-sso/admin/src/admin/lib/events.py
index ae1eefa..c1d4605 100644
--- a/dd-sso/admin/src/admin/lib/events.py
+++ b/dd-sso/admin/src/admin/lib/events.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -38,34 +39,46 @@ from flask_socketio import (
send,
)
-from admin import app
+from typing import TYPE_CHECKING, Any, Dict
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
-def sio_event_send(event, data):
+def sio_event_send(app : "AdminFlaskApp", event : str, data : Dict[str, Any]) -> None:
app.socketio.emit(
event,
json.dumps(data),
namespace="/sio/events",
room="events",
)
+ # TODO: Why on earth do we find these all over the place?
sleep(0.001)
class Events:
- def __init__(self, title, text="", total=0, table=False, type="info"):
+ app : "AdminFlaskApp"
+ eid : str
+ title : str
+ text : str
+ total : int
+ table : str
+ type : str
+ def __init__(self, app : "AdminFlaskApp", title : str, text : str="", total : int=0, table : str="", type : str="info") -> None:
+ self.app = app
# notice, info, success, and error
self.eid = str(base64.b64encode(os.urandom(32))[:8])
self.title = title
self.text = text
self.total = total
+ # TODO: this is probably replacing the .table method????
self.table = table
self.item = 0
self.type = type
self.create()
- def create(self):
+ def create(self) -> None:
log.info("START " + self.eid + ": " + self.text)
- app.socketio.emit(
+ self.app.socketio.emit(
"notify-create",
json.dumps(
{
@@ -80,9 +93,9 @@ class Events:
)
sleep(0.001)
- def __del__(self):
+ def __del__(self) -> None:
log.info("END " + self.eid + ": " + self.text)
- app.socketio.emit(
+ self.app.socketio.emit(
"notify-destroy",
json.dumps({"id": self.eid}),
namespace="/sio",
@@ -90,9 +103,9 @@ class Events:
)
sleep(0.001)
- def update_text(self, text):
+ def update_text(self, text : str) -> None:
self.text = text
- app.socketio.emit(
+ self.app.socketio.emit(
"notify-update",
json.dumps(
{
@@ -105,9 +118,9 @@ class Events:
)
sleep(0.001)
- def append_text(self, text):
+ def append_text(self, text : str) -> None:
self.text = self.text + "
" + text
- app.socketio.emit(
+ self.app.socketio.emit(
"notify-update",
json.dumps(
{
@@ -120,10 +133,10 @@ class Events:
)
sleep(0.001)
- def increment(self, data={"name": "", "data": []}):
+ def increment(self, data : Dict[str, Any]={"name": "", "data": []}) -> None:
self.item += 1
log.info("INCREMENT " + self.eid + ": " + self.text)
- app.socketio.emit(
+ self.app.socketio.emit(
"notify-increment",
json.dumps(
{
@@ -149,10 +162,10 @@ class Events:
)
sleep(0.0001)
- def decrement(self, data={"name": "", "data": []}):
+ def decrement(self, data : Dict[str, Any]={"name": "", "data": []}) -> None:
self.item -= 1
log.info("DECREMENT " + self.eid + ": " + self.text)
- app.socketio.emit(
+ self.app.socketio.emit(
"notify-decrement",
json.dumps(
{
@@ -178,13 +191,13 @@ class Events:
)
sleep(0.001)
- def reload(self):
- app.socketio.emit("reload", json.dumps({}), namespace="/sio", room="admin")
+ def reload(self) -> None:
+ self.app.socketio.emit("reload", json.dumps({}), namespace="/sio", room="admin")
sleep(0.0001)
- def table(self, event, table, data={}):
+ def table(self, event : str, table : str, data : Dict[str, Any]={}) -> None:
# refresh, add, delete, update
- app.socketio.emit(
+ self.app.socketio.emit(
"table_" + event,
json.dumps({"table": table, "data": data}),
namespace="/sio",
diff --git a/dd-sso/admin/src/admin/lib/helpers.py b/dd-sso/admin/src/admin/lib/helpers.py
index 80b7ea7..ab57de7 100644
--- a/dd-sso/admin/src/admin/lib/helpers.py
+++ b/dd-sso/admin/src/admin/lib/helpers.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -22,8 +23,11 @@ import string
from collections import Counter
from pprint import pprint
+from typing import Any, Dict, Generator, Iterable, Optional, List
-def get_recursive_groups(l_groups, l):
+DDGroup = Dict[str, Any]
+
+def get_recursive_groups(l_groups : Iterable[DDGroup], l : List[DDGroup]) -> List[DDGroup]:
for d_group in l_groups:
data = {}
for key, value in d_group.items():
@@ -35,11 +39,11 @@ def get_recursive_groups(l_groups, l):
return l
-def get_group_with_childs(keycloak_group):
+def get_group_with_childs(keycloak_group : DDGroup) -> List[str]:
return [g["path"] for g in get_recursive_groups([keycloak_group], [])]
-def system_username(username):
+def system_username(username : str) -> bool:
return (
True
if username in ["guest", "ddadmin", "admin"] or username.startswith("system_")
@@ -47,41 +51,43 @@ def system_username(username):
)
-def system_group(groupname):
+def system_group(groupname : str) -> bool:
return True if groupname in ["admin", "manager", "teacher", "student"] else False
-def get_group_from_group_id(group_id, groups):
+def get_group_from_group_id(group_id : str, groups : Iterable[DDGroup]) -> Optional[DDGroup]:
return next((d for d in groups if d.get("id") == group_id), None)
-def get_kid_from_kpath(kpath, groups):
- ids = [g["id"] for g in groups if g["path"] == kpath]
- if not len(ids) or len(ids) > 1:
- return False
+def get_kid_from_kpath(kpath : str, groups : Iterable[DDGroup]) -> Optional[str]:
+ ids : List[str] = [g["id"] for g in groups if g["path"] == kpath]
+ if len(ids) != 1:
+ return None
return ids[0]
-def get_gid_from_kgroup_id(kgroup_id, groups):
- return [
+def get_gid_from_kgroup_id(kgroup_id : str, groups : Iterable[DDGroup]) -> str:
+ # TODO: Why is this interface different from get_kid_from_kpath?
+ o : List[str] = [
g["path"].replace("/", ".")[1:] if len(g["path"].split("/")) else g["path"][1:]
for g in groups
if g["id"] == kgroup_id
- ][0]
+ ]
+ return o[0]
-def get_gids_from_kgroup_ids(kgroup_ids, groups):
+def get_gids_from_kgroup_ids(kgroup_ids : Iterable[str], groups : Iterable[DDGroup]) -> List[str]:
return [get_gid_from_kgroup_id(kgroup_id, groups) for kgroup_id in kgroup_ids]
-def kpath2gid(path):
+def kpath2gid(path : str) -> str:
# print(path.replace('/','.')[1:])
if path.startswith("/"):
return path.replace("/", ".")[1:]
return path.replace("/", ".")
-def kpath2gids(path):
+def kpath2gids(path : str) -> List[str]:
path = kpath2gid(path)
l = []
for i in range(len(path.split("."))):
@@ -89,44 +95,45 @@ def kpath2gids(path):
return l
-def kpath2kpaths(path):
+def kpath2kpaths(path : str) -> List[str]:
l = []
for i in range(len(path.split("/"))):
l.append("/".join(path.split("/")[: i + 1]))
return l[1:]
-def gid2kpath(gid):
+def gid2kpath(gid : str) -> str:
return "/" + gid.replace(".", "/")
-def count_repeated(itemslist):
+def count_repeated(itemslist : Iterable[Any]) -> None:
print(Counter(itemslist))
-def groups_kname2gid(groups):
+def groups_kname2gid(groups : Iterable[str]) -> List[str]:
return [name.replace(".", "/") for name in groups]
-def groups_path2id(groups):
+def groups_path2id(groups : Iterable[str]) -> List[str]:
return [g.replace("/", ".")[1:] for g in groups]
-def groups_id2path(groups):
+def groups_id2path(groups : Iterable[str]) -> List[str]:
return ["/" + g.replace(".", "/") for g in groups]
-def filter_roles_list(role_list):
+def filter_roles_list(role_list : Iterable[str]) -> List[str]:
client_roles = ["admin", "manager", "teacher", "student"]
return [r for r in role_list if r in client_roles]
-def filter_roles_listofdicts(role_listofdicts):
+def filter_roles_listofdicts(role_listofdicts : Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
client_roles = ["admin", "manager", "teacher", "student"]
return [r for r in role_listofdicts if r["name"] in client_roles]
-def rand_password(lenght):
+def rand_password(lenght : int) -> str:
+ # TODO: why is this not using py3's secrets?
characters = string.ascii_letters + string.digits + string.punctuation
passwd = "".join(random.choice(characters) for i in range(lenght))
while not any(ele.isupper() for ele in passwd):
diff --git a/dd-sso/admin/src/admin/lib/keycloak_client.py b/dd-sso/admin/src/admin/lib/keycloak_client.py
index 5641a6b..44b9be2 100644
--- a/dd-sso/admin/src/admin/lib/keycloak_client.py
+++ b/dd-sso/admin/src/admin/lib/keycloak_client.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -34,23 +35,33 @@ from .api_exceptions import Error
from .helpers import get_recursive_groups, kpath2kpaths
from .postgres import Postgres
-# from admin import app
+from typing import cast, Any, Dict, Iterable, List, Optional
+DDUser = Dict[str, Any]
+
+# TODO: Improve typing of these class and simplify it
class KeycloakClient:
"""https://www.keycloak.org/docs-api/13.0/rest-api/index.html
https://github.com/marcospereirampj/python-keycloak
https://gist.github.com/kaqfa/99829941121188d7cef8271f93f52f1f
"""
+ url : str
+ username : str
+ password : str
+ realm : str
+ verify : bool
+ keycloak_pg : Postgres
+ keycloak_admin : KeycloakAdmin
def __init__(
self,
- url="http://dd-sso-keycloak:8080/auth/",
- username=os.environ["KEYCLOAK_USER"],
- password=os.environ["KEYCLOAK_PASSWORD"],
- realm="master",
- verify=True,
- ):
+ url : str="http://dd-sso-keycloak:8080/auth/",
+ username : str=os.environ["KEYCLOAK_USER"],
+ password : str=os.environ["KEYCLOAK_PASSWORD"],
+ realm : str="master",
+ verify : bool=True,
+ ) -> None:
self.url = url
self.username = username
self.password = password
@@ -64,7 +75,7 @@ class KeycloakClient:
os.environ["KEYCLOAK_DB_PASSWORD"],
)
- def connect(self):
+ def connect(self) -> None:
self.keycloak_admin = KeycloakAdmin(
server_url=self.url,
username=self.username,
@@ -78,15 +89,19 @@ class KeycloakClient:
""" USERS """
- def get_user_id(self, username):
+ def get_user_id(self, username : str) -> str:
self.connect()
- return self.keycloak_admin.get_user_id(username)
+ uid : str = self.keycloak_admin.get_user_id(username)
+ return uid
- def get_users(self):
+ def get_users(self) -> Iterable[Dict[str, Any]]:
+ # https://www.keycloak.org/docs-api/18.0/rest-api/index.html#_userrepresentation
self.connect()
- return self.keycloak_admin.get_users({})
+ o : Iterable[Dict[str, Any]] = self.keycloak_admin.get_users({})
+ return o
- def get_users_with_groups_and_roles(self):
+ # TODO: what is this actually doing?
+ def get_users_with_groups_and_roles(self) -> List[DDUser]:
q = """select u.id, u.username, u.email, u.first_name, u.last_name, u.realm_id, u.enabled, ua.value as quota
,json_agg(g."id") as group, json_agg(g_parent."name") as group_parent1, json_agg(g_parent2."name") as group_parent2
,json_agg(r.name) as role
@@ -125,7 +140,7 @@ class KeycloakClient:
return list_dict_users
- def getparent(self, group_id, data):
+ def getparent(self, group_id : str, data : Iterable[Any]) -> str:
# Recursively get full path from any group_id in the tree
path = ""
for item in data:
@@ -134,14 +149,14 @@ class KeycloakClient:
path = f"{path}/{item[1]}"
return path
- def get_group_path(self, group_id):
+ def get_group_path(self, group_id : str) -> str:
# Get full path using getparent recursive func
# RETURNS: String with full path
q = """SELECT * FROM keycloak_group"""
groups = self.keycloak_pg.select(q)
return self.getparent(group_id, groups)
- def get_user_groups_paths(self, user_id):
+ def get_user_groups_paths(self, user_id : str) -> List[str]:
# Get full paths for user grups
# RETURNS list of paths
q = """SELECT group_id FROM user_group_membership WHERE user_id = '%s'""" % (
@@ -165,20 +180,20 @@ class KeycloakClient:
def add_user(
self,
- username,
- first,
- last,
- email,
- password,
- group=False,
- password_temporary=True,
- enabled=True,
- ):
+ username : str,
+ first : str,
+ last : str,
+ email : str,
+ password : str,
+ group : Any=False,
+ password_temporary : bool=True,
+ enabled : bool=True,
+ ) -> Any:
# RETURNS string with keycloak user id (the main id in this app)
self.connect()
username = username.lower()
try:
- uid = self.keycloak_admin.create_user(
+ uid : Any = self.keycloak_admin.create_user(
{
"email": email,
"username": username,
@@ -213,7 +228,7 @@ class KeycloakClient:
self.keycloak_admin.group_user_add(uid, gid)
return uid
- def update_user_pwd(self, user_id, password, password_temporary=True):
+ def update_user_pwd(self, user_id : str, password : str, password_temporary : bool=True) -> Any:
# Updates
payload = {
"credentials": [
@@ -223,7 +238,7 @@ class KeycloakClient:
self.connect()
return self.keycloak_admin.update_user(user_id, payload)
- def user_update(self, user_id, enabled, email, first, last, groups=[], roles=[]):
+ def user_update(self, user_id : str, enabled : bool, email : str, first : str, last : str, groups : Iterable[str]=[], roles : Iterable[str]=[]) -> Any:
## NOTE: Roles didn't seem to be updated/added. Also not confident with groups
# Updates
payload = {
@@ -237,17 +252,17 @@ class KeycloakClient:
self.connect()
return self.keycloak_admin.update_user(user_id, payload)
- def user_enable(self, user_id):
+ def user_enable(self, user_id : str) -> Any:
payload = {"enabled": True}
self.connect()
return self.keycloak_admin.update_user(user_id, payload)
- def user_disable(self, user_id):
+ def user_disable(self, user_id : str) -> Any:
payload = {"enabled": False}
self.connect()
return self.keycloak_admin.update_user(user_id, payload)
- def group_user_remove(self, user_id, group_id):
+ def group_user_remove(self, user_id : str, group_id : str) -> Any:
self.connect()
return self.keycloak_admin.group_user_remove(user_id, group_id)
@@ -255,7 +270,7 @@ class KeycloakClient:
# self.connect()
# return self.keycloak_admin.assign_role(client_id=client_id, user_id=user_id, role_id=role_id, role_name="test")
- def remove_user_realm_roles(self, user_id, roles):
+ def remove_user_realm_roles(self, user_id : str, roles : Iterable[str]) -> Any:
self.connect()
roles = [
r
@@ -264,66 +279,66 @@ class KeycloakClient:
]
return self.keycloak_admin.delete_user_realm_role(user_id, roles)
- def delete_user(self, userid):
+ def delete_user(self, userid : str) -> Any:
self.connect()
return self.keycloak_admin.delete_user(user_id=userid)
- def get_user_groups(self, userid):
+ def get_user_groups(self, userid : str) -> Any:
self.connect()
return self.keycloak_admin.get_user_groups(user_id=userid)
- def get_user_realm_roles(self, userid):
+ def get_user_realm_roles(self, userid : str) -> Any:
self.connect()
return self.keycloak_admin.get_realm_roles_of_user(user_id=userid)
- def add_user_client_role(self, client_id, user_id, role_id, role_name):
+ def add_user_client_role(self, client_id : str, user_id : str, role_id : str, role_name : str) -> Any:
self.connect()
return self.keycloak_admin.assign_client_role(
client_id=client_id, user_id=user_id, role_id=role_id, role_name="test"
)
## GROUPS
- def get_all_groups(self):
+ def get_all_groups(self) -> Iterable[Any]:
## RETURNS ONLY MAIN GROUPS WITH NESTED subGroups list
self.connect()
- return self.keycloak_admin.get_groups()
+ return cast(Iterable[Any], self.keycloak_admin.get_groups())
- def get_groups(self, with_subgroups=True):
+ def get_groups(self, with_subgroups : bool=True) -> Iterable[Any]:
## RETURNS ALL GROUPS in root list
self.connect()
groups = self.keycloak_admin.get_groups()
return get_recursive_groups(groups, [])
- def get_group_by_id(self, group_id):
+ def get_group_by_id(self, group_id : str) -> Any:
self.connect()
return self.keycloak_admin.get_group(group_id=group_id)
- def get_group_by_path(self, path, recursive=True):
+ def get_group_by_path(self, path : str, recursive : bool=True) -> Any:
self.connect()
return self.keycloak_admin.get_group_by_path(
path=path, search_in_subgroups=recursive
)
- def add_group(self, name, parent=None, skip_exists=False):
+ def add_group(self, name : str, parent : str="", skip_exists : bool=False) -> Any:
self.connect()
- if parent != None:
+ if parent:
parent = self.get_group_by_path(parent)["id"]
return self.keycloak_admin.create_group({"name": name}, parent=parent)
- def delete_group(self, group_id):
+ def delete_group(self, group_id : str) -> Any:
self.connect()
return self.keycloak_admin.delete_group(group_id=group_id)
- def group_user_add(self, user_id, group_id):
+ def group_user_add(self, user_id : str, group_id : str) -> Any:
self.connect()
return self.keycloak_admin.group_user_add(user_id, group_id)
- def add_group_tree(self, path):
+ def add_group_tree(self, path : str) -> None:
paths = kpath2kpaths(path)
parent = "/"
for path in paths:
try:
- parent_path = None if parent == "/" else parent
+ parent_path = "" if parent == "/" else parent
# print("parent: "+str(parent_path)+" path: "+path.split("/")[-1])
self.add_group(path.split("/")[-1], parent_path, skip_exists=True)
parent = path
@@ -333,8 +348,8 @@ class KeycloakClient:
parent = path
def add_user_with_groups_and_role(
- self, username, first, last, email, password, role, groups
- ):
+ self, username : str, first : str, last : str, email : str, password : str, role : str, groups : Iterable[str]
+ ) -> None:
## Add user
uid = self.add_user(username, first, last, email, password)
## Add user to role
@@ -348,7 +363,7 @@ class KeycloakClient:
for g in groups:
log.warning("Creating keycloak group: " + g)
parts = g.split("/")
- parent_path = None
+ parent_path = ""
for i in range(1, len(parts)):
# parent_id=None if parent_path==None else self.get_group(parent_path)['id']
try:
@@ -360,10 +375,7 @@ class KeycloakClient:
+ " already exists. Skipping creation"
)
pass
- if parent_path is None:
- thepath = "/" + parts[i]
- else:
- thepath = parent_path + "/" + parts[i]
+ thepath = parent_path + "/" + parts[i]
if thepath == "/":
log.warning(
"Not adding the user "
@@ -385,53 +397,51 @@ class KeycloakClient:
)
self.keycloak_admin.group_user_add(uid, gid)
- if parent_path == None:
- parent_path = ""
- parent_path = parent_path + "/" + parts[i]
+ parent_path += "/" + parts[i]
# self.group_user_add(uid,gid)
## ROLES
- def get_roles(self):
+ def get_roles(self) -> Iterable[Any]:
self.connect()
- return self.keycloak_admin.get_realm_roles()
+ return cast(Iterable[Any], self.keycloak_admin.get_realm_roles())
- def get_role(self, name):
+ def get_role(self, name : str) -> Any:
self.connect()
return self.keycloak_admin.get_realm_role(name)
- def add_role(self, name, description=""):
+ def add_role(self, name : str, description : str="") -> Any:
self.connect()
return self.keycloak_admin.create_realm_role(
{"name": name, "description": description}
)
- def delete_role(self, name):
+ def delete_role(self, name : str) -> Any:
self.connect()
return self.keycloak_admin.delete_realm_role(name)
## CLIENTS
- def get_client_roles(self, client_id):
+ def get_client_roles(self, client_id : str) -> Any:
self.connect()
return self.keycloak_admin.get_client_roles(client_id=client_id)
- def add_client_role(self, client_id, name, description=""):
+ def add_client_role(self, client_id : str, name : str, description : str="") -> Any:
self.connect()
return self.keycloak_admin.create_client_role(
client_id, {"name": name, "description": description, "clientRole": True}
)
## SYSTEM
- def get_server_info(self):
+ def get_server_info(self) -> Any:
self.connect()
return self.keycloak_admin.get_server_info()
- def get_server_clients(self):
+ def get_server_clients(self) -> Any:
self.connect()
return self.keycloak_admin.get_clients()
- def get_server_rsa_key(self):
+ def get_server_rsa_key(self) -> Any:
self.connect()
rsa_key = [
k for k in self.keycloak_admin.get_keys()["keys"] if k["type"] == "RSA"
@@ -439,22 +449,21 @@ class KeycloakClient:
return {"name": rsa_key["kid"], "certificate": rsa_key["certificate"]}
## REALM
- def assign_realm_roles(self, user_id, role):
+ def assign_realm_roles(self, user_id : str, role : str) -> Any:
self.connect()
try:
- role = [
+ kcroles = [
r for r in self.keycloak_admin.get_realm_roles() if r["name"] == role
]
except:
return False
- return self.keycloak_admin.assign_realm_roles(user_id=user_id, roles=role)
- # return self.keycloak_admin.assign_realm_roles(user_id=user_id, client_id=None, roles=role)
+ return self.keycloak_admin.assign_realm_roles(user_id=user_id, roles=kcroles)
## CLIENTS
- def delete_client(self, clientid):
+ def delete_client(self, clientid : str) -> Any:
self.connect()
return self.keycloak_admin.delete_client(clientid)
- def add_client(self, client):
+ def add_client(self, client : str) -> Any:
self.connect()
return self.keycloak_admin.create_client(client)
diff --git a/dd-sso/admin/src/admin/lib/keys.py b/dd-sso/admin/src/admin/lib/keys.py
new file mode 100644
index 0000000..076387b
--- /dev/null
+++ b/dd-sso/admin/src/admin/lib/keys.py
@@ -0,0 +1,411 @@
+#
+# Copyright © 2022 Evilham
+#
+# This file is part of DD
+#
+# DD is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or (at your
+# option) any later version.
+#
+# DD is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with DD. If not, see .
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+
+"""
+This file only depends on:
+ - attrs
+ - cryptography
+ - requests
+
+Integrations should be able to copy this file and use it verbatim on their
+codebase without using anything else from DD.
+
+To check for changes or to update this file, head to:
+ https://gitlab.com/DD-workspace/DD/-/blob/main/dd-sso/admin/src/admin/lib/keys.py
+"""
+
+import json
+import stat
+from copy import deepcopy
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Union
+
+import requests
+from attr import field, frozen
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.primitives.serialization.base import (
+ PRIVATE_KEY_TYPES,
+ PUBLIC_KEY_TYPES,
+)
+from jose import jwe, jws, jwt
+from jose.backends.rsa_backend import RSAKey
+from jose.constants import ALGORITHMS
+
+try:
+ # Python 3.8
+ from functools import cached_property as cache
+except ImportError:
+ from functools import cache # type: ignore # Python 3.9+
+
+
+Data = Union[str, bytes]
+Json = Union[Dict, str, List[Dict]]
+
+
+@frozen(slots=False)
+class ThirdPartyIntegrationKeys(object):
+ """
+ This represents the keys for a a third-party integration that interacts
+ with us.
+
+
+ These services must publish their public key on an agreed endpoint.
+ See: {their_remote_pubkey_url}
+
+ Their key is cached in the file system: '{key_store}/their.pubkey'.
+ Key rotation can happen if requested by the third-party service, by
+ manually removing the public key file from the data store.
+
+
+ We also generate a private key for each third-party service.
+ This key is read from or generated to if needed in the file system:
+ '{key_store}/our.privkey'
+
+ Rotating our private key can only happen by removing the private key file
+ and restarting the service.
+
+ In order to use the private key, you get full access to the cryptography
+ primitive with {our_privkey}.
+
+ In order to publish your public key, you get it serialised with
+ {our_pubkey_pem}.
+ """
+
+ #
+ # Standard attributes
+ #
+ our_name: str
+ """
+ The identifier of our service.
+ """
+
+ key_store: Path = field(converter=Path)
+ """
+ Local path to a directory containing service keys.
+ """
+
+ #
+ # Attributes related to the third party
+ #
+ their_name: str
+ """
+ The identifier of the third-party.
+ """
+
+ their_service_domain: str
+ """
+ The domain on which the third party integration is running.
+ """
+
+ their_pubkey_endpoint: str = "/pubkey/"
+ """
+ The absolute path (starting with /) on {their_service_domain} that will
+ serve the pubkey.
+ """
+ #
+ # Cryptography attributes
+ #
+
+ key_size_bits: int = 4096
+ """
+ When generating private keys, how many bits will be used.
+ """
+
+ key_chmod: int = 0o600
+ """
+ Permissions to apply to private keys.
+ """
+
+ encrypt_algorithm: str = ALGORITHMS.RSA_OAEP_256
+ """
+ The encryption algorithm.
+ """
+
+ sign_algorithm: str = ALGORITHMS.RS512
+ """
+ The signature algorithm.
+ """
+
+ validity: timedelta = timedelta(minutes=1)
+ """
+ The validity of a signed token.
+ """
+
+ @property
+ def validity_half(self) -> timedelta:
+ return self.validity / 2
+
+ #
+ # Properties related to the third party keys
+ #
+ @property
+ def their_pubkey_path(self) -> Path:
+ """
+ Helper returning the full path to the cached pubkey.
+ """
+ return self.key_store.joinpath("their.pubkey")
+
+ @property
+ def their_remote_pubkey_url(self) -> str:
+ """
+ Helper returning the full remote URL to the service's pubkey endpoint.
+ """
+ return f"https://{self.their_service_domain}{self.their_pubkey_endpoint}"
+
+ @property
+ def their_pubkey_bytes(self) -> bytes:
+ """
+ Return the service's pubkey by fetching it only if necessary.
+ That means the key is fetched exactly once and saved to
+ their_pubkey_path.
+ In order to rotate keys, their_pubkey_path must be manually deleted.
+
+ If the key has never been fetched and downloading it fails, an empty
+ bytestring will be returned.
+
+ Note we do not cache this property since there might be temporary
+ failures.
+ """
+ if not self.their_pubkey_path.exists():
+ # Download if not available
+ try:
+ res = requests.get(self.their_remote_pubkey_url)
+ except:
+ # The service is currently unavailable
+ return b""
+ self.their_pubkey_path.write_bytes(res.content)
+ return self.their_pubkey_path.read_bytes()
+
+ @property
+ def their_pubkey_jwk(self) -> Dict[str, Any]:
+ """
+ Return the service's PublicKey in JWK form fetching if necessary or
+ empty dict if it was not in the store and we were unable to fetch it.
+ """
+ pk_b = self.their_pubkey_bytes
+ if not pk_b:
+ return dict()
+ rsak = RSAKey(json.loads(pk_b), self.sign_algorithm)
+ return rsak.to_dict()
+
+ #
+ # Properties related to our own keys
+ #
+ @property
+ def our_privkey_path(self) -> Path:
+ """
+ Helper returning the full path to our private key.
+ """
+ return self.key_store.joinpath("our.privkey")
+
+ @cache
+ def our_privkey_bytes(self) -> bytes:
+ """
+ Helper that returns our private key, generating it if necessary.
+
+ This property is cached to avoid expensive operations.
+ That does mean that on key rotation the service must be restarted.
+ """
+ return self._load_privkey(self.our_privkey_path)
+
+ @cache
+ def our_privkey(self) -> RSAKey:
+ """
+ Helper that returns our private key in cryptography form, generating
+ it if necessary.
+ """
+ pk_b = self.our_privkey_bytes
+ return RSAKey(json.loads(pk_b), self.sign_algorithm)
+
+ @cache
+ def our_privkey_jwk(self) -> Dict[str, Any]:
+ """
+ Return our PrivateKey in JWK for this third-party service, generating
+ it if necessary.
+ """
+ return self.our_privkey.to_dict()
+
+ @cache
+ def our_pubkey(self) -> RSAKey:
+ """
+ Helper that returns our public key, generating the privkey if necessary.
+ """
+ return self.our_privkey.public_key()
+
+ @cache
+ def our_pubkey_jwk(self) -> Dict[str, Any]:
+ """
+ Helper that returns our public key in JWK form.
+ """
+ return self.our_pubkey.to_dict()
+
+ #
+ # Message-passing methods
+ #
+ def encrypt_outgoing_data(self, data: bytes) -> str:
+ return jwe.encrypt(
+ data,
+ self.their_pubkey_jwk,
+ algorithm=self.encrypt_algorithm,
+ kid=self.our_name,
+ ).decode("utf-8")
+
+ def encrypt_outgoing_json(self, data: Json) -> str:
+ return self.encrypt_outgoing_data(json.dumps(data).encode("utf-8"))
+
+ def decrypt_incoming_data(self, enc_data: Data) -> bytes:
+ return jwe.decrypt(enc_data, self.our_privkey_jwk) # type: ignore # Wrong hint
+
+ def decrypt_incoming_json(self, enc_data: Data) -> Json:
+ d: Json = json.loads(self.decrypt_incoming_data(enc_data))
+ return d
+
+ def sign_outgoing_json(self, data: Json) -> str:
+ now = datetime.utcnow()
+ claims = {
+ "data": data,
+ "aud": self.their_name,
+ "iss": self.our_name,
+ "iat": now,
+ "nbf": now - self.validity_half,
+ "exp": now + self.validity_half,
+ }
+ return jwt.encode(
+ claims,
+ self.our_privkey_jwk,
+ algorithm=self.sign_algorithm,
+ headers={
+ "kid": self.our_name,
+ },
+ )
+
+ def sign_outgoing_data(self, data: Json) -> str:
+ return self.sign_outgoing_json(data)
+
+ def verify_incoming_data(self, data: Data) -> Any:
+ signed_data: Dict = jwt.decode(
+ data,
+ self.their_pubkey_jwk,
+ algorithms=[self.sign_algorithm],
+ audience=self.our_name,
+ issuer=self.their_name,
+ options={
+ "require_aud": True,
+ "require_iat": True,
+ "require_iss": True,
+ "require_nbf": True,
+ "require_exp": True,
+ },
+ )
+ return signed_data["data"]
+
+ def verify_incoming_json(self, data: bytes) -> Json:
+ d: Json = json.loads(self.verify_incoming_data(data))
+ return d
+
+ def sign_and_encrypt_outgoing_data(self, data: bytes) -> str:
+ return self.sign_outgoing_data(self.encrypt_outgoing_data(data))
+
+ def sign_and_encrypt_outgoing_json(self, data: Json) -> str:
+ return self.sign_outgoing_data(self.encrypt_outgoing_json(data))
+
+ def verify_and_decrypt_incoming_data(self, data: Data) -> bytes:
+ enc_data: str = self.verify_incoming_data(data)
+ return self.decrypt_incoming_data(enc_data.encode("utf-8"))
+
+ def verify_and_decrypt_incoming_json(self, data: Data) -> Json:
+ enc_data: str = self.verify_incoming_data(data)
+ return self.decrypt_incoming_json(enc_data.encode("utf-8"))
+
+ def get_outgoing_request_headers(self) -> Dict[str, str]:
+ # Use current time as ever-changing payload
+ now = datetime.utcnow().isoformat()
+ return {"Authorization": self.sign_outgoing_data(now)}
+
+ #
+ # Helper methods
+ #
+ def _load_privkey(self, path: Path, force_generation: bool = False) -> bytes:
+ # Check recommendations here
+ # https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#cryptography.hazmat.primitives.asymmetric.rsa.generate_private_key
+ #
+ needs_generation = force_generation or not path.exists()
+
+ # Perform further sanity checks
+ # by re-using needs_generation we save checking the file system
+ if not needs_generation:
+ path_st = path.stat()
+ # Check and fix permissions if necessary
+ if stat.S_IMODE(path_st.st_mode) != self.key_chmod:
+ path.touch(mode=self.key_chmod, exist_ok=True)
+ # Force generation if file is empty
+ needs_generation = path_st == 0
+
+ if needs_generation:
+ # Generate the key as needed
+ gpk = rsa.generate_private_key(
+ public_exponent=65537, key_size=self.key_size_bits
+ )
+ enc_gpk = gpk.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption(),
+ )
+ enc_jwk = json.dumps(RSAKey(enc_gpk, self.sign_algorithm).to_dict())
+ # Ensure permissions
+ path.touch(mode=self.key_chmod, exist_ok=True)
+ # Write private key
+ path.write_text(enc_jwk)
+
+ # Return key
+ return path.read_bytes()
+
+
+if __name__ == "__main__":
+ # TODO: convert into real tests
+ a = ThirdPartyIntegrationKeys(
+ our_name="a", their_name="b", their_service_domain="b.com", key_store="tmpa"
+ )
+ b = ThirdPartyIntegrationKeys(
+ our_name="b", their_name="a", their_service_domain="a.com", key_store="tmpb"
+ )
+ Path("tmpa/their.pubkey").write_text(json.dumps(b.our_pubkey_jwk))
+ Path("tmpb/their.pubkey").write_text(json.dumps(a.our_pubkey_jwk))
+
+ m1 = b"test message"
+ print("out", m1)
+ o1 = a.sign_and_encrypt_outgoing_data(m1)
+ print("enc", o1)
+ r1 = b.verify_and_decrypt_incoming_data(o1)
+ print("got", r1)
+ assert m1 == r1, f"Wrong result. Got: {r1!r}. Expected: {m1!r}"
+
+ m2 = {"test": 1, "hello": "world"}
+ m2 = {}
+ print("out", m2)
+ o2 = a.sign_and_encrypt_outgoing_json(m2)
+ print(jwt.get_unverified_headers(o2))
+ print("enc", o2)
+ r2 = b.verify_and_decrypt_incoming_json(o2)
+ print("got", r2)
+ assert m2 == r2, f"Wrong result. Got: {r2!r}. Expected: {m2!r}"
diff --git a/dd-sso/admin/src/admin/lib/legal.py b/dd-sso/admin/src/admin/lib/legal.py
index 70c27c2..4e7ce80 100644
--- a/dd-sso/admin/src/admin/lib/legal.py
+++ b/dd-sso/admin/src/admin/lib/legal.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -21,7 +22,6 @@ import logging as log
import os
import traceback
-from admin import app
from pprint import pprint
from minio import Minio
@@ -29,18 +29,22 @@ from minio.commonconfig import REPLACE, CopySource
from minio.deleteobjects import DeleteObject
from requests import get, post
-legal_path= os.path.join(app.root_path, "static/templates/pages/legal/")
+from typing import TYPE_CHECKING
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
-def get_legal(lang):
- with open(legal_path+lang, "r") as languagefile:
+
+# TODO: Fix all this
+def get_legal(app : "AdminFlaskApp", lang : str) -> str:
+ with open(app.legal_path+lang, "r") as languagefile:
return languagefile.read()
-def gen_legal_if_not_exists(lang):
- if not os.path.isfile(legal_path+lang):
+def gen_legal_if_not_exists(app : "AdminFlaskApp", lang : str) -> None:
+ if not os.path.isfile(app.legal_path+lang):
log.debug("Creating new language file")
- with open(legal_path+lang, "w") as languagefile:
+ with open(app.legal_path+lang, "w") as languagefile:
languagefile.write("Legal
This is the default legal page for language " + lang)
-def new_legal(lang,html):
- with open(legal_path+lang, "w") as languagefile:
- languagefile.write(html)
\ No newline at end of file
+def new_legal(app : "AdminFlaskApp", lang : str, html : str) -> None:
+ with open(app.legal_path+lang, "w") as languagefile:
+ languagefile.write(html)
diff --git a/dd-sso/admin/src/admin/lib/load_config.py b/dd-sso/admin/src/admin/lib/load_config.py
deleted file mode 100644
index 19ae22d..0000000
--- a/dd-sso/admin/src/admin/lib/load_config.py
+++ /dev/null
@@ -1,94 +0,0 @@
-#
-# Copyright © 2021,2022 IsardVDI S.L.
-#
-# This file is part of DD
-#
-# DD is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or (at your
-# option) any later version.
-#
-# DD is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with DD. If not, see .
-#
-# SPDX-License-Identifier: AGPL-3.0-or-later
-
-
-import logging as log
-import os
-import sys
-import traceback
-
-import yaml
-from cerberus import Validator, rules_set_registry, schema_registry
-
-from admin import app
-
-
-class AdminValidator(Validator):
- None
- # def _normalize_default_setter_genid(self, document):
- # return _parse_string(document["name"])
-
- # def _normalize_default_setter_genidlower(self, document):
- # return _parse_string(document["name"]).lower()
-
- # def _normalize_default_setter_gengroupid(self, document):
- # return _parse_string(
- # document["parent_category"] + "-" + document["uid"]
- # ).lower()
-
-
-def load_validators(purge_unknown=True):
- validators = {}
- schema_path = os.path.join(app.root_path, "schemas")
- for schema_filename in os.listdir(schema_path):
- try:
- with open(os.path.join(schema_path, schema_filename)) as file:
- schema_yml = file.read()
- schema = yaml.load(schema_yml, Loader=yaml.FullLoader)
- validators[schema_filename.split(".")[0]] = AdminValidator(
- schema, purge_unknown=purge_unknown
- )
- except IsADirectoryError:
- None
- return validators
-
-
-app.validators = load_validators()
-
-
-class loadConfig:
- def __init__(self, app=None):
- try:
- app.config.setdefault("DOMAIN", os.environ["DOMAIN"])
- app.config.setdefault(
- "KEYCLOAK_POSTGRES_USER", os.environ["KEYCLOAK_DB_USER"]
- )
- app.config.setdefault(
- "KEYCLOAK_POSTGRES_PASSWORD", os.environ["KEYCLOAK_DB_PASSWORD"]
- )
- app.config.setdefault(
- "MOODLE_POSTGRES_USER", os.environ["MOODLE_POSTGRES_USER"]
- )
- app.config.setdefault(
- "MOODLE_POSTGRES_PASSWORD", os.environ["MOODLE_POSTGRES_PASSWORD"]
- )
- app.config.setdefault(
- "NEXTCLOUD_POSTGRES_USER", os.environ["NEXTCLOUD_POSTGRES_USER"]
- )
- app.config.setdefault(
- "NEXTCLOUD_POSTGRES_PASSWORD", os.environ["NEXTCLOUD_POSTGRES_PASSWORD"]
- )
- app.config.setdefault(
- "VERIFY", True if os.environ["VERIFY"] == "true" else False
- )
- app.config.setdefault("API_SECRET", os.environ.get("API_SECRET"))
- except Exception as e:
- log.error(traceback.format_exc())
- raise
diff --git a/dd-sso/admin/src/admin/lib/moodle.py b/dd-sso/admin/src/admin/lib/moodle.py
index 9ee12f0..24e1a12 100644
--- a/dd-sso/admin/src/admin/lib/moodle.py
+++ b/dd-sso/admin/src/admin/lib/moodle.py
@@ -1,5 +1,6 @@
#
-# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -23,11 +24,15 @@ from pprint import pprint
from requests import get, post
-from admin import app
from .exceptions import UserExists, UserNotFound
from .postgres import Postgres
+from typing import TYPE_CHECKING, cast, Any, Dict, Iterable, List, Optional
+
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
+
# Module variables to connect to moodle api
@@ -36,18 +41,20 @@ class Moodle:
https://docs.moodle.org/dev/Web_service_API_functions
https://docs.moodle.org/311/en/Using_web_services
"""
-
+ key: str
+ url : str
+ endpoint : str
+ verify : bool
+ moodle_pg : Postgres
def __init__(
self,
- key=app.config["MOODLE_WS_TOKEN"],
- url="https://moodle." + app.config["DOMAIN"],
- endpoint="/webservice/rest/server.php",
- verify=app.config["VERIFY"],
- ):
- self.key = key
- self.url = url
+ app : "AdminFlaskApp",
+ endpoint : str="/webservice/rest/server.php",
+ ) -> None:
+ self.key = app.config["MOODLE_WS_TOKEN"]
+ self.url = f"https://moodle.{ app.config['DOMAIN'] }"
self.endpoint = endpoint
- self.verify = verify
+ self.verify = cast(bool, app.config["VERIFY"])
self.moodle_pg = Postgres(
"dd-apps-postgresql",
@@ -56,7 +63,7 @@ class Moodle:
app.config["MOODLE_POSTGRES_PASSWORD"],
)
- def rest_api_parameters(self, in_args, prefix="", out_dict=None):
+ def rest_api_parameters(self, in_args : Any, prefix : str="", out_dict : Optional[Dict]=None) -> Dict[Any, Any]:
"""Transform dictionary/array structure to a flat dictionary, with key names
defining the structure.
Example usage:
@@ -64,24 +71,23 @@ class Moodle:
{'courses[0][id]':1,
'courses[0][name]':'course1'}
"""
- if out_dict == None:
- out_dict = {}
+ o : Dict[Any, Any] = {} if out_dict is None else out_dict
if not type(in_args) in (list, dict):
- out_dict[prefix] = in_args
- return out_dict
+ o[prefix] = in_args
+ return o
if prefix == "":
prefix = prefix + "{0}"
else:
prefix = prefix + "[{0}]"
if type(in_args) == list:
for idx, item in enumerate(in_args):
- self.rest_api_parameters(item, prefix.format(idx), out_dict)
+ self.rest_api_parameters(item, prefix.format(idx), o)
elif type(in_args) == dict:
for key, item in in_args.items():
- self.rest_api_parameters(item, prefix.format(key), out_dict)
- return out_dict
+ self.rest_api_parameters(item, prefix.format(key), o)
+ return o
- def call(self, fname, **kwargs):
+ def call(self, fname : str, **kwargs : Any) -> Any:
"""Calls moodle API function with function name fname and keyword arguments.
Example:
>>> call_mdl_function('core_course_update_courses',
@@ -97,7 +103,7 @@ class Moodle:
raise SystemError(response)
return response
- def create_user(self, email, username, password, first_name="-", last_name="-"):
+ def create_user(self, email : str, username : str, password : str, first_name : str="-", last_name : str="-") -> Any:
if len(self.get_user_by("username", username)["users"]):
raise UserExists
try:
@@ -115,7 +121,7 @@ class Moodle:
except SystemError as se:
raise SystemError(se.args[0]["message"])
- def update_user(self, username, email, first_name, last_name, enabled=True):
+ def update_user(self, username : str, email : str, first_name : str, last_name : str, enabled : bool=True) -> Any:
user = self.get_user_by("username", username)["users"][0]
if not len(user):
raise UserNotFound
@@ -135,15 +141,15 @@ class Moodle:
except SystemError as se:
raise SystemError(se.args[0]["message"])
- def delete_user(self, user_id):
+ def delete_user(self, user_id : str) -> Any:
user = self.call("core_user_delete_users", userids=[user_id])
return user
- def delete_users(self, userids):
+ def delete_users(self, userids : List[str]) -> Any:
user = self.call("core_user_delete_users", userids=userids)
return user
- def get_user_by(self, key, value):
+ def get_user_by(self, key : str, value : str) -> Any:
criteria = [{"key": key, "value": value}]
try:
user = self.call("core_user_get_users", criteria=criteria)
@@ -152,7 +158,7 @@ class Moodle:
return user
# {'users': [{'id': 8, 'username': 'asdfw', 'firstname': 'afowie', 'lastname': 'aokjdnfwe', 'fullname': 'afowie aokjdnfwe', 'email': 'awfewe@ads.com', 'department': '', 'firstaccess': 0, 'lastaccess': 0, 'auth': 'manual', 'suspended': False, 'confirmed': True, 'lang': 'ca', 'theme': '', 'timezone': '99', 'mailformat': 1, 'profileimageurlsmall': 'https://moodle.mydomain.duckdns.org/theme/image.php/cbe/core/1630941606/u/f2', 'profileimageurl': 'https://DOMAIN/theme/image.php/cbe/core/1630941606/u/f1'}], 'warnings': []}
- def get_users_with_groups_and_roles(self):
+ def get_users_with_groups_and_roles(self) -> List[Dict[Any, Any]]:
q = """select u.id as id, username, firstname as first, lastname as last, email, json_agg(h.name) as groups, json_agg(r.shortname) as roles
from mdl_user as u
LEFT JOIN mdl_cohort_members AS hm on hm.userid = u.id
@@ -179,31 +185,31 @@ class Moodle:
# user['roles']=[]
# return users
- def enroll_user_to_course(self, user_id, course_id, role_id=5):
+ def enroll_user_to_course(self, user_id : str, course_id : str, role_id : int=5) -> Any:
# 5 is student
data = [{"roleid": role_id, "userid": user_id, "courseid": course_id}]
enrolment = self.call("enrol_manual_enrol_users", enrolments=data)
return enrolment
- def get_quiz_attempt(self, quiz_id, user_id):
+ def get_quiz_attempt(self, quiz_id : str, user_id : str) -> Any:
attempts = self.call(
"mod_quiz_get_user_attempts", quizid=quiz_id, userid=user_id
)
return attempts
- def get_cohorts(self):
+ def get_cohorts(self) -> List[Dict[str, Any]]:
cohorts = self.call("core_cohort_get_cohorts")
- return cohorts
+ return cast(List[Dict[str, Any]], cohorts)
- def add_system_cohort(self, name, description="", visible=True):
- visible = 1 if visible else 0
+ def add_system_cohort(self, name : str, description : str ="", visible : bool=True) -> Any:
+ bit_visible = 1 if visible else 0
data = [
{
"categorytype": {"type": "system", "value": ""},
"name": name,
"idnumber": name,
"description": description,
- "visible": visible,
+ "visible": bit_visible,
}
]
cohort = self.call("core_cohort_create_cohorts", cohorts=data)
@@ -214,7 +220,7 @@ class Moodle:
# user = self.call('core_cohort_add_cohort_members', criteria=criteria)
# return user
- def add_user_to_cohort(self, userid, cohortid):
+ def add_user_to_cohort(self, userid : str, cohortid : str) -> Any:
members = [
{
"cohorttype": {"type": "id", "value": cohortid},
@@ -224,21 +230,21 @@ class Moodle:
user = self.call("core_cohort_add_cohort_members", members=members)
return user
- def delete_user_in_cohort(self, userid, cohortid):
+ def delete_user_in_cohort(self, userid : str, cohortid : str) -> Any:
members = [{"cohortid": cohortid, "userid": userid}]
user = self.call("core_cohort_delete_cohort_members", members=members)
return user
- def get_cohort_members(self, cohort_ids):
+ def get_cohort_members(self, cohort_ids : str) -> Any:
members = self.call("core_cohort_get_cohort_members", cohortids=cohort_ids)
# [0]['userids']
return members
- def delete_cohorts(self, cohortids):
+ def delete_cohorts(self, cohortids : Iterable[str]) -> Any:
deleted = self.call("core_cohort_delete_cohorts", cohortids=cohortids)
return deleted
- def get_user_cohorts(self, user_id):
+ def get_user_cohorts(self, user_id : str) -> Any:
user_cohorts = []
cohorts = self.get_cohorts()
for cohort in cohorts:
@@ -246,7 +252,7 @@ class Moodle:
user_cohorts.append(cohort)
return user_cohorts
- def add_user_to_siteadmin(self, user_id):
+ def add_user_to_siteadmin(self, user_id : str) -> Any:
q = """SELECT value FROM mdl_config WHERE name='siteadmins'"""
value = self.moodle_pg.select(q)[0][0]
if str(user_id) not in value:
@@ -258,6 +264,7 @@ class Moodle:
log.warning(
"MOODLE:ADDING THE USER TO ADMINS: This needs a purge cache in moodle!"
)
+
def unassign_user_rol(self, user_id, role_id):
unassignments = [{"roleid": role_id, "userid": user_id, "contextlevel": 'system', "instanceid": 0}]
return self.call("core_role_unassign_roles", unassignments=unassignments)
diff --git a/dd-sso/admin/src/admin/lib/mysql.py b/dd-sso/admin/src/admin/lib/mysql.py
index 9f3f140..7fdad2a 100644
--- a/dd-sso/admin/src/admin/lib/mysql.py
+++ b/dd-sso/admin/src/admin/lib/mysql.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -18,32 +19,29 @@
#
# SPDX-License-Identifier: AGPL-3.0-or-later
-import json
-import logging as log
-import time
-import traceback
-from datetime import datetime, timedelta
-
import mysql.connector
-import yaml
-# from admin import app
+from typing import List, Tuple
class Mysql:
- def __init__(self, host, database, user, password):
+ # TODO: Fix this whole class
+ cur : mysql.connector.MySQLCursor
+ conn : mysql.connector.MySQLConnection
+ def __init__(self, host : str, database : str, user : str, password : str) -> None:
self.conn = mysql.connector.connect(
host=host, database=database, user=user, password=password
)
- def select(self, sql):
+ def select(self, sql : str) -> List[Tuple]:
self.cur = self.conn.cursor()
self.cur.execute(sql)
- data = self.cur.fetchall()
+ data : List[Tuple] = self.cur.fetchall()
self.cur.close()
return data
- def update(self, sql):
+ def update(self, sql : str) -> None:
+ # TODO: Fix this whole method
self.cur = self.conn.cursor()
self.cur.execute(sql)
self.conn.commit()
diff --git a/dd-sso/admin/src/admin/lib/nextcloud.py b/dd-sso/admin/src/admin/lib/nextcloud.py
index 9e2a130..1a28d13 100644
--- a/dd-sso/admin/src/admin/lib/nextcloud.py
+++ b/dd-sso/admin/src/admin/lib/nextcloud.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -30,21 +31,31 @@ import urllib
import requests
from psycopg2 import sql
-# from ..lib.log import *
-from admin import app
-
from .nextcloud_exc import *
from .postgres import Postgres
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
+
+DDUser = Dict[Any, Any]
class Nextcloud:
+ verify_cert : bool
+ apiurl : str
+ shareurl : str
+ davurl : str
+ auth : Tuple[str, str]
+ user : str
+ nextcloud_pg : Postgres
def __init__(
self,
- url="https://nextcloud." + app.config["DOMAIN"],
- username=os.environ["NEXTCLOUD_ADMIN_USER"],
- password=os.environ["NEXTCLOUD_ADMIN_PASSWORD"],
- verify=True,
- ):
+ app : "AdminFlaskApp",
+ username : str=os.environ["NEXTCLOUD_ADMIN_USER"],
+ password : str=os.environ["NEXTCLOUD_ADMIN_PASSWORD"],
+ verify : bool=True,
+ ) -> None:
+ url = "https://nextcloud." + app.config["DOMAIN"]
self.verify_cert = verify
self.apiurl = url + "/ocs/v1.php/cloud/"
@@ -61,9 +72,9 @@ class Nextcloud:
)
def _request(
- self, method, url, data={}, headers={"OCS-APIRequest": "true"}, auth=False
- ):
- if auth == False:
+ self, method : str, url : str, data : Any={}, headers : Dict[str, str]={"OCS-APIRequest": "true"}, auth : Optional[Tuple[str, str]]=None
+ ) -> str:
+ if auth is None:
auth = self.auth
try:
response = requests.request(
@@ -96,7 +107,7 @@ class Nextcloud:
raise ProviderConnError
raise ProviderError
- def check_connection(self):
+ def check_connection(self) -> bool:
url = self.apiurl + "users/" + self.user + "?format=json"
try:
result = self._request("GET", url)
@@ -118,7 +129,7 @@ class Nextcloud:
raise ProviderConnError
raise ProviderError
- def get_user(self, userid):
+ def get_user(self, userid : str) -> Any:
url = self.apiurl + "users/" + userid + "?format=json"
try:
result = json.loads(self._request("GET", url))
@@ -148,7 +159,7 @@ class Nextcloud:
# users_with_lists = [list(l[:-2])+([[]] if l[-2] == [None] else [list(set(l[-2]))]) + ([[]] if l[-1] == [None] else [list(set(l[-1]))]) for l in users]
# users_with_lists = [list(l[:-2])+([[]] if l[-2] == [None] else [list(set(l[-2]))]) + ([[]] if l[-1] == [None] else [list(set(l[-1]))]) for l in users_with_lists]
# list_dict_users = [dict(zip(fields, r)) for r in users_with_lists]
- def get_users_list(self):
+ def get_users_list(self) -> List[DDUser]:
# q = """select u.uid as username, adn.value as displayname, ade.value as email, json_agg(gg.displayname) as admin_groups,json_agg(g.displayname) as groups
# from oc_users as u
# left join oc_group_user as gu on gu.uid = u.uid
@@ -200,9 +211,10 @@ class Nextcloud:
# log.error(traceback.format_exc())
# raise
+ # TODO: Improve typing of these functions...
def add_user(
- self, userid, userpassword, quota=False, group=False, email="", displayname=""
- ):
+ self, userid : str, userpassword : str, quota : Any=False, group : Any=False, email : str="", displayname : str=""
+ ) -> bool:
data = {
"userid": userid,
"password": userpassword,
@@ -247,7 +259,7 @@ class Nextcloud:
# 106 - no group specified (required for subadmins)
# 107 - all errors that contain a hint - for example “Password is among the 1,000,000 most common ones. Please make it unique.” (this code was added in 12.0.6 & 13.0.1)
- def update_user(self, userid, key_values):
+ def update_user(self, userid : str, key_values : Dict[str, Any]) -> bool:
# key_values={'quota':quota,'email':email,'displayname':displayname}
url = self.apiurl + "users/" + userid + "?format=json"
@@ -262,6 +274,8 @@ class Nextcloud:
result = json.loads(
self._request("PUT", url, data=data, headers=headers)
)
+ # TODO: It seems like this only sets the first item in key_values
+ # This function probably doesn't do what it is supposed to
if result["ocs"]["meta"]["statuscode"] == 100:
return True
if result["ocs"]["meta"]["statuscode"] == 102:
@@ -273,8 +287,9 @@ class Nextcloud:
except:
# log.error(traceback.format_exc())
raise
+ return False
- def add_user_to_group(self, userid, group_id):
+ def add_user_to_group(self, userid : str, group_id : str) -> bool:
data = {"groupid": group_id}
url = self.apiurl + "users/" + userid + "/groups?format=json"
@@ -296,7 +311,7 @@ class Nextcloud:
# log.error(traceback.format_exc())
raise
- def remove_user_from_group(self, userid, group_id):
+ def remove_user_from_group(self, userid : str, group_id : str) -> bool:
data = {"groupid": group_id}
url = self.apiurl + "users/" + userid + "/groups?format=json"
@@ -312,18 +327,21 @@ class Nextcloud:
return True
if result["ocs"]["meta"]["statuscode"] == 102:
raise ProviderItemExists
- if result["ocs"]["meta"]["statuscode"] == 104:
- self.add_group(group)
- # raise ProviderGroupNotExists
+ # TODO: It is unclear what status code 104 is, it certainly
+ # shouldn't the group if it doesn't exist
+ #if result["ocs"]["meta"]["statuscode"] == 104:
+ # self.add_group(group)
+ # # raise ProviderGroupNotExists
log.error("Get Nextcloud provider user add error: " + str(result))
raise ProviderOpError
except:
# log.error(traceback.format_exc())
raise
+ # TODO: Improve typing of these functions...
def add_user_with_groups(
- self, userid, userpassword, quota=False, groups=[], email="", displayname=""
- ):
+ self, userid : str, userpassword : str, quota : Any=False, groups : Any=[], email : str="", displayname : str=""
+ ) -> bool:
data = {
"userid": userid,
"password": userpassword,
@@ -352,7 +370,7 @@ class Nextcloud:
raise ProviderItemExists
if result["ocs"]["meta"]["statuscode"] == 104:
# self.add_group(group)
- None
+ pass
# raise ProviderGroupNotExists
log.error("Get Nextcloud provider user add error: " + str(result))
raise ProviderOpError
@@ -368,7 +386,7 @@ class Nextcloud:
# 106 - no group specified (required for subadmins)
# 107 - all errors that contain a hint - for example “Password is among the 1,000,000 most common ones. Please make it unique.” (this code was added in 12.0.6 & 13.0.1)
- def delete_user(self, userid):
+ def delete_user(self, userid : str) -> bool:
url = self.apiurl + "users/" + userid + "?format=json"
try:
result = json.loads(self._request("DELETE", url))
@@ -384,13 +402,13 @@ class Nextcloud:
# 100 - successful
# 101 - failure
- def enable_user(self, userid):
- None
+ def enable_user(self, userid : str) -> None:
+ pass
- def disable_user(self, userid):
- None
+ def disable_user(self, userid : str) -> None:
+ pass
- def exists_user_folder(self, userid, userpassword, folder="IsardVDI"):
+ def exists_user_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> bool:
auth = (userid, userpassword)
url = self.davurl + userid + "/" + folder + "?format=json"
headers = {
@@ -407,7 +425,7 @@ class Nextcloud:
# log.error(traceback.format_exc())
raise
- def add_user_folder(self, userid, userpassword, folder="IsardVDI"):
+ def add_user_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> bool:
auth = (userid, userpassword)
url = self.davurl + userid + "/" + folder + "?format=json"
headers = {
@@ -429,7 +447,7 @@ class Nextcloud:
# log.error(traceback.format_exc())
raise
- def exists_user_share_folder(self, userid, userpassword, folder="IsardVDI"):
+ def exists_user_share_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> Dict[str, str]:
auth = (userid, userpassword)
url = self.shareurl + "shares?format=json"
headers = {
@@ -449,7 +467,7 @@ class Nextcloud:
# log.error(traceback.format_exc())
raise
- def add_user_share_folder(self, userid, userpassword, folder="IsardVDI"):
+ def add_user_share_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> Dict[str, str]:
auth = (userid, userpassword)
data = {"path": "/" + folder, "shareType": 3}
url = self.shareurl + "shares?format=json"
@@ -477,10 +495,10 @@ class Nextcloud:
# log.error(traceback.format_exc())
raise
- def get_group(self, userid):
- None
+ def get_group(self, userid : str) -> None:
+ pass
- def get_groups_list(self):
+ def get_groups_list(self) -> List[Any]:
url = self.apiurl + "groups?format=json"
try:
result = json.loads(self._request("GET", url))
@@ -491,7 +509,7 @@ class Nextcloud:
# log.error(traceback.format_exc())
raise
- def add_group(self, groupid):
+ def add_group(self, groupid : str) -> bool:
data = {"groupid": groupid}
url = self.apiurl + "groups?format=json"
headers = {
@@ -515,7 +533,7 @@ class Nextcloud:
# 102 - group already exists
# 103 - failed to add the group
- def delete_group(self, groupid):
+ def delete_group(self, groupid : str) -> bool:
group = urllib.parse.quote(groupid, safe="")
url = self.apiurl + "groups/" + group + "?format=json"
headers = {
@@ -538,7 +556,7 @@ class Nextcloud:
# 102 - group already exists
# 103 - failed to add the group
- def set_user_mail(self, data):
+ def set_user_mail(self, data : DDUser) -> None:
query = """SELECT * FROM "oc_mail_accounts" WHERE "email" = '%s'"""
sql_query = sql.SQL(query.format(data["email"]))
if not len(self.nextcloud_pg.select(sql_query)):
diff --git a/dd-sso/admin/src/admin/lib/nextcloud_exc.py b/dd-sso/admin/src/admin/lib/nextcloud_exc.py
index ec1f290..51ff233 100644
--- a/dd-sso/admin/src/admin/lib/nextcloud_exc.py
+++ b/dd-sso/admin/src/admin/lib/nextcloud_exc.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -46,6 +47,10 @@ class ProviderItemNotExists(Exception):
pass
+class ProviderUserNotExists(Exception):
+ pass
+
+
class ProviderGroupNotExists(Exception):
pass
diff --git a/dd-sso/admin/src/admin/lib/postgres.py b/dd-sso/admin/src/admin/lib/postgres.py
index 84b82ad..10febce 100644
--- a/dd-sso/admin/src/admin/lib/postgres.py
+++ b/dd-sso/admin/src/admin/lib/postgres.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -18,54 +19,41 @@
#
# SPDX-License-Identifier: AGPL-3.0-or-later
-import json
-import logging as log
-import time
-import traceback
-from datetime import datetime, timedelta
-
import psycopg2
-import yaml
+import psycopg2.sql
+from psycopg2.extensions import connection, cursor
-# from admin import app
+from typing import Any, List, Tuple, Union
+query = Union[str, psycopg2.sql.SQL]
class Postgres:
- def __init__(self, host, database, user, password):
+ # TODO: Fix this whole class
+ cur : cursor
+ conn : connection
+ def __init__(self, host : str, database : str, user : str, password : str) -> None:
self.conn = psycopg2.connect(
host=host, database=database, user=user, password=password
)
- # def __del__(self):
- # self.cur.close()
- # self.conn.close()
-
- def select(self, sql):
+ def select(self, sql: query) -> List[Tuple[Any, ...]]:
self.cur = self.conn.cursor()
self.cur.execute(sql)
data = self.cur.fetchall()
- self.cur.close()
+ self.cur.close() # type: ignore # psycopg2 type hint missing
return data
- def update(self, sql):
+ def update(self, sql : query) -> None:
self.cur = self.conn.cursor()
self.cur.execute(sql)
self.conn.commit()
- self.cur.close()
+ self.cur.close() # type: ignore # psycopg2 type hint missing
# return self.cur.fetchall()
- def select_with_headers(self, sql):
+ def select_with_headers(self, sql : query) -> Tuple[List[Any], List[Tuple[Any, ...]]]:
self.cur = self.conn.cursor()
self.cur.execute(sql)
data = self.cur.fetchall()
fields = [a.name for a in self.cur.description]
- self.cur.close()
+ self.cur.close() # type: ignore # psycopg2 type hint missing
return (fields, data)
-
- # def update_moodle_saml_plugin(self):
- # plugin[('idpmetadata', 'NrtA5ynG0htowP3SXw7dBJRIAMxn-1PwuuXwOwNhlRwMIICmzCCAYMCBgF5jb0RCTANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjEwNTIxMDcwMjI4WhcNMzEwNTIxMDcwNDA4WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCI8xh/C0+frz3kgWiUbziTDls71R2YiXLSVE+bw7gbEgZUGCLhoEI679azMtIxmnzM/snIX+yTb12+XoYkgbiLTMPQfnH+Kiab6g3HL3KPfhqS+yWkFxOoCp6Ibmp7yPlVWuHH+MBfO8OBr/r8Ao7heFbuzjiLd1KG67rcoaxfDgMuBoEomg1bgEjFgHaQIrSC6OZzH0h987/arqufZXeXlfyiqScMPUi+u5IpDWSwz06UKP0k8mxzNSlpZ93CKOUSsV0SMLxqg7FQ3SGiOk577bGW9o9BDTkkmSo3Up6smc0LzwvvUwuNd0B1irGkWZFQN9OXJnJYf1InEebIMtmPAgMBAAEwDQYJKoZIhvcNAQELBQADggEBADM34+qEGeBQ22luphVTuVJtGxcbxLx7DfsT0QfJD/OuxTTbNAa1VRyarb5juIAkqdj4y2quZna9ZXLecVo4RkwpzPoKoAkYA8b+kHnWqEwJi9iPrDvKb+GR0bBkLPN49YxIZ8IdKX/PRa3yuLHe+loiNsCaS/2ZK2KO46COsqU4QX1iVhF9kWphNLybjNAX45B6cJLsa1g0vXLdm3kv3SB4I2fErFVaOoDtFIjttoYlXdpUiThkPXBfr7N67P3dZHaS4tjJh+IZ8I6TINpcsH8dBkUhzYEIPHCePwSiC1w6WDBLNDuKt1mj1CZrLq+1x+Yhrs+QNRheEKGi89HZ8N0=urn:oasis:names:tc:SAML:2.0:nameid-format:persistenturn:oasis:names:tc:SAML:2.0:nameid-format:transienturn:oasis:names:tc:SAML:1.1:nameid-format:unspecifiedurn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress')]
- # pg_update = """UPDATE mdl_config_plugins set title = %s where plugin = auth_saml2 and name ="""
- # cursor.execute(pg_update, (title, bookid))
- # connection.commit()
- # count = cursor.rowcount
- # print(count, "Successfully Updated!")
diff --git a/dd-sso/admin/src/admin/lib/postup.py b/dd-sso/admin/src/admin/lib/postup.py
index ac50e62..81615ef 100644
--- a/dd-sso/admin/src/admin/lib/postup.py
+++ b/dd-sso/admin/src/admin/lib/postup.py
@@ -1,5 +1,6 @@
#
-# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -23,8 +24,6 @@ import logging as log
import os
import random
-# from .keycloak import Keycloak
-# from .moodle import Moodle
import string
import time
import traceback
@@ -33,18 +32,21 @@ from datetime import datetime, timedelta
import psycopg2
import yaml
-from admin import app
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
from .postgres import Postgres
class Postup:
- def __init__(self):
+ def __init__(self, app: "AdminFlaskApp") -> None:
ready = False
while not ready:
try:
self.pg = Postgres(
- "isard-apps-postgresql",
+ "dd-apps-postgresql",
"moodle",
app.config["MOODLE_POSTGRES_USER"],
app.config["MOODLE_POSTGRES_PASSWORD"],
@@ -93,9 +95,9 @@ class Postup:
self.select_and_configure_theme()
self.configure_tipnc()
- self.add_moodle_ws_token()
+ self.add_moodle_ws_token(app)
- def select_and_configure_theme(self, theme="cbe"):
+ def select_and_configure_theme(self, theme : str="cbe") -> None:
try:
self.pg.update(
"""UPDATE "mdl_config" SET value = '%s' WHERE "name" = 'theme';"""
@@ -104,7 +106,6 @@ class Postup:
except:
log.error(traceback.format_exc())
exit(1)
- None
try:
self.pg.update(
@@ -127,9 +128,8 @@ class Postup:
except:
log.error(traceback.format_exc())
exit(1)
- None
- def configure_tipnc(self):
+ def configure_tipnc(self) -> None:
try:
self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = '%s' WHERE "plugin" = 'assignsubmission_tipnc' AND "name" = 'host';"""
@@ -155,9 +155,8 @@ class Postup:
except:
log.error(traceback.format_exc())
exit(1)
- None
- def add_moodle_ws_token(self):
+ def add_moodle_ws_token(self, app: "AdminFlaskApp") -> None:
try:
token = self.pg.select(
"""SELECT * FROM "mdl_external_tokens" WHERE "externalserviceid" = 3"""
@@ -166,7 +165,7 @@ class Postup:
return
except:
# log.error(traceback.format_exc())
- None
+ pass
try:
self.pg.update(
@@ -226,4 +225,3 @@ class Postup:
except:
log.error(traceback.format_exc())
exit(1)
- None
diff --git a/dd-sso/admin/src/admin/package.json b/dd-sso/admin/src/admin/package.json
index f87ec9e..0461bf4 100644
--- a/dd-sso/admin/src/admin/package.json
+++ b/dd-sso/admin/src/admin/package.json
@@ -2,5 +2,6 @@
"dependencies": {
"gentelella": "^1.4.0",
"socket.io": "^4.1.3"
- }
+ },
+ "license": "AGPL-3.0-or-later"
}
diff --git a/dd-sso/admin/src/admin/views/ApiViews.py b/dd-sso/admin/src/admin/views/ApiViews.py
index b452c73..e371a8e 100644
--- a/dd-sso/admin/src/admin/views/ApiViews.py
+++ b/dd-sso/admin/src/admin/views/ApiViews.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -19,6 +20,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import json
import logging as log
+from operator import itemgetter
import os
import socket
import sys
@@ -27,302 +29,308 @@ import traceback
from flask import request
-from admin import app
+from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
from ..lib.api_exceptions import Error
-from .decorators import has_token
+from .decorators import has_token, OptionalJsonResponse
-## LISTS
-@app.route("/ddapi/users", methods=["GET"])
-@has_token
-def ddapi_users():
- if request.method == "GET":
- sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
- users = []
- for user in sorted_users:
- users.append(user_parser(user))
- return json.dumps(users), 200, {"Content-Type": "application/json"}
+def setup_api_views(app : "AdminFlaskApp") -> None:
+ ## LISTS
+ @app.json_route("/ddapi/users", methods=["GET"])
+ @has_token
+ def ddapi_users() -> OptionalJsonResponse:
+ if request.method == "GET":
+ sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
+ users = []
+ for user in sorted_users:
+ users.append(user_parser(user))
+ return json.dumps(users), 200, {"Content-Type": "application/json"}
+ return None
+ @app.json_route("/ddapi/users/filter", methods=["POST"])
+ @has_token
+ def ddapi_users_search() -> OptionalJsonResponse:
+ if request.method == "POST":
+ data = request.get_json(force=True)
+ if not data.get("text"):
+ raise Error("bad_request", "Incorrect data requested.")
+ users = app.admin.get_mix_users()
+ result = [user_parser(user) for user in filter_users(users, data["text"])]
+ sorted_result = sorted(result, key=itemgetter("id"))
+ return json.dumps(sorted_result), 200, {"Content-Type": "application/json"}
+ return None
-@app.route("/ddapi/users/filter", methods=["POST"])
-@has_token
-def ddapi_users_search():
- if request.method == "POST":
- data = request.get_json(force=True)
- if not data.get("text"):
- raise Error("bad_request", "Incorrect data requested.")
- users = app.admin.get_mix_users()
- result = [user_parser(user) for user in filter_users(users, data["text"])]
- sorted_result = sorted(result, key=lambda k: k["id"])
- return json.dumps(sorted_result), 200, {"Content-Type": "application/json"}
+ @app.json_route("/ddapi/groups", methods=["GET"])
+ @has_token
+ def ddapi_groups() -> OptionalJsonResponse:
+ if request.method == "GET":
+ sorted_groups = sorted(app.admin.get_mix_groups(), key=itemgetter("name"))
+ groups = []
+ for group in sorted_groups:
+ groups.append(group_parser(group))
+ return json.dumps(groups), 200, {"Content-Type": "application/json"}
+ return None
-
-@app.route("/ddapi/groups", methods=["GET"])
-@has_token
-def ddapi_groups():
- if request.method == "GET":
- sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: k["name"])
- groups = []
- for group in sorted_groups:
- groups.append(group_parser(group))
- return json.dumps(groups), 200, {"Content-Type": "application/json"}
-
-
-@app.route("/ddapi/group/users", methods=["POST"])
-@has_token
-def ddapi_group_users():
- if request.method == "POST":
- data = request.get_json(force=True)
- sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
- if data.get("id"):
- group_users = [
- user_parser(user)
- for user in sorted_users
- if data.get("id") in user["keycloak_groups"]
- ]
- elif data.get("path"):
- try:
- name = [
- g["name"]
- for g in app.admin.get_mix_groups()
- if g["path"] == data.get("path")
- ][0]
+ @app.json_route("/ddapi/group/users", methods=["POST"])
+ @has_token
+ def ddapi_group_users() -> OptionalJsonResponse:
+ if request.method == "POST":
+ data = request.get_json(force=True)
+ sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
+ if data.get("id"):
group_users = [
user_parser(user)
for user in sorted_users
- if name in user["keycloak_groups"]
+ if data.get("id") in user["keycloak_groups"]
]
- except:
- raise Error("not_found", "Group path not found in system")
- elif data.get("keycloak_id"):
- try:
- name = [
- g["name"]
- for g in app.admin.get_mix_groups()
- if g["id"] == data.get("keycloak_id")
- ][0]
- group_users = [
- user_parser(user)
- for user in sorted_users
- if name in user["keycloak_groups"]
- ]
- except:
- raise Error("not_found", "Group keycloak_id not found in system")
- else:
- raise Error("bad_request", "Incorrect data requested.")
- return json.dumps(group_users), 200, {"Content-Type": "application/json"}
+ elif data.get("path"):
+ try:
+ name = [
+ g["name"]
+ for g in app.admin.get_mix_groups()
+ if g["path"] == data.get("path")
+ ][0]
+ group_users = [
+ user_parser(user)
+ for user in sorted_users
+ if name in user["keycloak_groups"]
+ ]
+ except:
+ raise Error("not_found", "Group path not found in system")
+ elif data.get("keycloak_id"):
+ try:
+ name = [
+ g["name"]
+ for g in app.admin.get_mix_groups()
+ if g["id"] == data.get("keycloak_id")
+ ][0]
+ group_users = [
+ user_parser(user)
+ for user in sorted_users
+ if name in user["keycloak_groups"]
+ ]
+ except:
+ raise Error("not_found", "Group keycloak_id not found in system")
+ else:
+ raise Error("bad_request", "Incorrect data requested.")
+ return json.dumps(group_users), 200, {"Content-Type": "application/json"}
+ return None
+ @app.json_route("/ddapi/roles", methods=["GET"])
+ @has_token
+ def ddapi_roles() -> OptionalJsonResponse:
+ if request.method == "GET":
+ roles = []
+ for role in sorted(app.admin.get_roles(), key=itemgetter("name")):
+ log.error(role)
+ roles.append(
+ {
+ "keycloak_id": role["id"],
+ "id": role["name"],
+ "name": role["name"],
+ "description": role.get("description", ""),
+ }
+ )
+ return json.dumps(roles), 200, {"Content-Type": "application/json"}
+ return None
-@app.route("/ddapi/roles", methods=["GET"])
-@has_token
-def ddapi_roles():
- if request.method == "GET":
- roles = []
- for role in sorted(app.admin.get_roles(), key=lambda k: k["name"]):
- log.error(role)
- roles.append(
- {
- "keycloak_id": role["id"],
- "id": role["name"],
- "name": role["name"],
- "description": role.get("description", ""),
- }
- )
- return json.dumps(roles), 200, {"Content-Type": "application/json"}
-
-
-@app.route("/ddapi/role/users", methods=["POST"])
-@has_token
-def ddapi_role_users():
- if request.method == "POST":
- data = request.get_json(force=True)
- sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
- if data.get("id", data.get("name")):
- role_users = [
- user_parser(user)
- for user in sorted_users
- if data.get("id", data.get("name")) in user["roles"]
- ]
- elif data.get("keycloak_id"):
- try:
- id = [
- r["id"]
- for r in app.admin.get_roles()
- if r["id"] == data.get("keycloak_id")
- ][0]
+ @app.json_route("/ddapi/role/users", methods=["POST"])
+ @has_token
+ def ddapi_role_users() -> OptionalJsonResponse:
+ if request.method == "POST":
+ data = request.get_json(force=True)
+ sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
+ if data.get("id", data.get("name")):
role_users = [
- user_parser(user) for user in sorted_users if id in user["roles"]
+ user_parser(user)
+ for user in sorted_users
+ if data.get("id", data.get("name")) in user["roles"]
]
- except:
- raise Error("not_found", "Role keycloak_id not found in system")
- else:
- raise Error("bad_request", "Incorrect data requested.")
- return json.dumps(role_users), 200, {"Content-Type": "application/json"}
+ elif data.get("keycloak_id"):
+ try:
+ id = [
+ r["id"]
+ for r in app.admin.get_roles()
+ if r["id"] == data.get("keycloak_id")
+ ][0]
+ role_users = [
+ user_parser(user) for user in sorted_users if id in user["roles"]
+ ]
+ except:
+ raise Error("not_found", "Role keycloak_id not found in system")
+ else:
+ raise Error("bad_request", "Incorrect data requested.")
+ return json.dumps(role_users), 200, {"Content-Type": "application/json"}
+ return None
-
-## INDIVIDUAL ACTIONS
-@app.route("/ddapi/user", methods=["POST"])
-@app.route("/ddapi/user/", methods=["PUT", "GET", "DELETE"])
-@has_token
-def ddapi_user(user_ddid=None):
- if request.method == "GET":
- user = app.admin.get_user_username(user_ddid)
- if not user:
- raise Error("not_found", "User id not found")
- return json.dumps(user_parser(user)), 200, {"Content-Type": "application/json"}
- if request.method == "DELETE":
- user = app.admin.get_user_username(user_ddid)
- if not user:
- raise Error("not_found", "User id not found")
- app.admin.delete_user(user["id"])
- return json.dumps({}), 200, {"Content-Type": "application/json"}
- if request.method == "POST":
- data = request.get_json(force=True)
- if not app.validators["user"].validate(data):
- raise Error(
- "bad_request",
- "Data validation for user failed: ",
- +str(app.validators["user"].errors),
- traceback.format_exc(),
- )
-
- if app.admin.get_user_username(data["username"]):
- raise Error("conflict", "User id already exists")
- data = app.validators["user"].normalized(data)
- keycloak_id = app.admin.add_user(data)
- if not keycloak_id:
- raise Error(
- "precondition_required",
- "Not all user groups already in system. Please create user groups before adding user.",
- )
- return (
- json.dumps({"keycloak_id": keycloak_id}),
- 200,
- {"Content-Type": "application/json"},
- )
-
- if request.method == "PUT":
- user = app.admin.get_user_username(user_ddid)
- if not user:
- raise Error("not_found", "User id not found")
- data = request.get_json(force=True)
- if not app.validators["user_update"].validate(data):
- raise Error(
- "bad_request",
- "Data validation for user failed: "
- + str(app.validators["user_update"].errors),
- traceback.format_exc(),
- )
- data = {**user, **data}
- data = app.validators["user_update"].normalized(data)
- data = {**data, **{"username": user_ddid}}
- data["roles"] = [data.pop("role")]
- data["firstname"] = data.pop("first")
- data["lastname"] = data.pop("last")
- app.admin.user_update(data)
- if data.get("password"):
- app.admin.user_update_password(
- user["id"], data["password"], data["password_temporary"]
- )
- return json.dumps({}), 200, {"Content-Type": "application/json"}
-
-
-@app.route("/ddapi/username//", methods=["PUT"])
-@has_token
-def ddapi_username(old_user_ddid, new_user_did):
- user = app.admin.get_user_username(user_ddid)
- if not user:
- raise Error("not_found", "User id not found")
- # user = app.admin.update_user_username(old_user_ddid,new_user_did)
- return json.dumps("Not implemented yet!"), 419, {"Content-Type": "application/json"}
-
-
-@app.route("/ddapi/group", methods=["POST"])
-@app.route("/ddapi/group/", methods=["GET", "POST", "DELETE"])
-# @app.route("/api/group/", methods=["PUT", "GET", "DELETE"])
-@has_token
-def ddapi_group(id=None):
- if request.method == "GET":
- group = app.admin.get_group_by_name(id)
- if not group:
- Error("not found", "Group id not found")
- return (
- json.dumps(group_parser(group)),
- 200,
- {"Content-Type": "application/json"},
- )
- if request.method == "POST":
- data = request.get_json(force=True)
- if not app.validators["group"].validate(data):
- raise Error(
- "bad_request",
- "Data validation for group failed: "
- + str(app.validators["group"].errors),
- traceback.format_exc(),
- )
- data = app.validators["group"].normalized(data)
- data["parent"] = data["parent"] if data["parent"] != "" else None
-
- if app.admin.get_group_by_name(id):
- raise Error("conflict", "Group id already exists")
-
- path = app.admin.add_group(data)
- # log.error(path)
- # keycloak_id = app.admin.get_group_by_name(id)["id"]
- # log.error()
- return (
- json.dumps({"keycloak_id": None}),
- 200,
- {"Content-Type": "application/json"},
- )
- if request.method == "DELETE":
- group = app.admin.get_group_by_name(id)
- if not group:
- raise Error("not_found", "Group id not found")
- app.admin.delete_group_by_id(group["id"])
- return json.dumps({}), 200, {"Content-Type": "application/json"}
-
-
-@app.route("/ddapi/user_mail", methods=["POST"])
-@app.route("/ddapi/user_mail/", methods=["GET", "DELETE"])
-@has_token
-def ddapi_user_mail(id=None):
- if request.method == "GET":
- return (
- json.dumps("Not implemented yet"),
- 200,
- {"Content-Type": "application/json"},
- )
- if request.method == "POST":
- data = request.get_json(force=True)
-
- # if not app.validators["mails"].validate(data):
- # raise Error(
- # "bad_request",
- # "Data validation for mail failed: "
- # + str(app.validators["mail"].errors),
- # traceback.format_exc(),
- # )
- for user in data:
- if not app.validators["mail"].validate(user):
+ ## INDIVIDUAL ACTIONS
+ @app.json_route("/ddapi/user", methods=["POST"])
+ @app.json_route("/ddapi/user/", methods=["PUT", "GET", "DELETE"])
+ @has_token
+ def ddapi_user(user_ddid : Optional[str]=None) -> OptionalJsonResponse:
+ uid : str = user_ddid if user_ddid else ''
+ if request.method == "GET":
+ user = app.admin.get_user_username(uid)
+ if not user:
+ raise Error("not_found", "User id not found")
+ return json.dumps(user_parser(user)), 200, {"Content-Type": "application/json"}
+ if request.method == "DELETE":
+ user = app.admin.get_user_username(uid)
+ if not user:
+ raise Error("not_found", "User id not found")
+ app.admin.delete_user(user["id"])
+ return json.dumps({}), 200, {"Content-Type": "application/json"}
+ if request.method == "POST":
+ data = request.get_json(force=True)
+ if not app.validators["user"].validate(data):
raise Error(
"bad_request",
- "Data validation for mail failed: "
- + str(app.validators["mail"].errors),
+ "Data validation for user failed: "
+ + str(app.validators["user"].errors),
traceback.format_exc(),
)
- for user in data:
- log.info("Added user email")
- app.admin.set_nextcloud_user_mail(user)
- return (
- json.dumps("Users emails updated"),
- 200,
- {"Content-Type": "application/json"},
- )
+ if app.admin.get_user_username(data["username"]):
+ raise Error("conflict", "User id already exists")
+ data = app.validators["user"].normalized(data)
+ keycloak_id = app.admin.add_user(data)
+ if not keycloak_id:
+ raise Error(
+ "precondition_required",
+ "Not all user groups already in system. Please create user groups before adding user.",
+ )
+ return (
+ json.dumps({"keycloak_id": keycloak_id}),
+ 200,
+ {"Content-Type": "application/json"},
+ )
-def user_parser(user):
+ if request.method == "PUT":
+ user = app.admin.get_user_username(uid)
+ if not user:
+ raise Error("not_found", "User id not found")
+ data = request.get_json(force=True)
+ if not app.validators["user_update"].validate(data):
+ raise Error(
+ "bad_request",
+ "Data validation for user failed: "
+ + str(app.validators["user_update"].errors),
+ traceback.format_exc(),
+ )
+ data = {**user, **data}
+ data = app.validators["user_update"].normalized(data)
+ data = {**data, **{"username": uid}}
+ data["roles"] = [data.pop("role")]
+ data["firstname"] = data.pop("first")
+ data["lastname"] = data.pop("last")
+ app.admin.user_update(data)
+ if data.get("password"):
+ app.admin.user_update_password(
+ user["id"], data["password"], data["password_temporary"]
+ )
+ return json.dumps({}), 200, {"Content-Type": "application/json"}
+ return None
+
+ @app.json_route("/ddapi/username//", methods=["PUT"])
+ @has_token
+ def ddapi_username(old_user_ddid : str, new_user_did : str) -> OptionalJsonResponse:
+ user = app.admin.get_user_username(old_user_ddid)
+ if not user:
+ raise Error("not_found", "User id not found")
+ # user = app.admin.update_user_username(old_user_ddid,new_user_did)
+ return json.dumps("Not implemented yet!"), 419, {"Content-Type": "application/json"}
+
+ @app.json_route("/ddapi/group", methods=["POST"])
+ @app.json_route("/ddapi/group/", methods=["GET", "POST", "DELETE"])
+ # @app.json_route("/api/group/", methods=["PUT", "GET", "DELETE"])
+ @has_token
+ def ddapi_group(group_id : Optional[str]=None) -> OptionalJsonResponse:
+ uid : str = group_id if group_id else ''
+ if request.method == "GET":
+ group = app.admin.get_group_by_name(uid)
+ if not group:
+ Error("not found", "Group id not found")
+ return (
+ json.dumps(group_parser(group)),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ if request.method == "POST":
+ data = request.get_json(force=True)
+ if not app.validators["group"].validate(data):
+ raise Error(
+ "bad_request",
+ "Data validation for group failed: "
+ + str(app.validators["group"].errors),
+ traceback.format_exc(),
+ )
+ data = app.validators["group"].normalized(data)
+ data["parent"] = data["parent"] if data["parent"] != "" else None
+
+ if app.admin.get_group_by_name(uid):
+ raise Error("conflict", "Group id already exists")
+
+ path = app.admin.add_group(data)
+ # log.error(path)
+ # keycloak_id = app.admin.get_group_by_name(id)["id"]
+ # log.error()
+ return (
+ json.dumps({"keycloak_id": None}),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ if request.method == "DELETE":
+ group = app.admin.get_group_by_name(uid)
+ if not group:
+ raise Error("not_found", "Group id not found")
+ app.admin.delete_group_by_id(group["id"])
+ return json.dumps({}), 200, {"Content-Type": "application/json"}
+ return None
+
+ @app.json_route("/ddapi/user_mail", methods=["POST"])
+ @app.json_route("/ddapi/user_mail/", methods=["GET", "DELETE"])
+ @has_token
+ def ddapi_user_mail(id : Optional[str]=None) -> OptionalJsonResponse:
+ # TODO: Remove this endpoint when we ensure there are no consumers
+ if request.method == "GET":
+ return (
+ json.dumps("Not implemented yet"),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ if request.method == "POST":
+ data = request.get_json(force=True)
+
+ # if not app.validators["mails"].validate(data):
+ # raise Error(
+ # "bad_request",
+ # "Data validation for mail failed: "
+ # + str(app.validators["mail"].errors),
+ # traceback.format_exc(),
+ # )
+ for user in data:
+ if not app.validators["mail"].validate(user):
+ raise Error(
+ "bad_request",
+ "Data validation for mail failed: "
+ + str(app.validators["mail"].errors),
+ traceback.format_exc(),
+ )
+ for user in data:
+ log.info("Added user email")
+ app.admin.nextcloud_mail_set([user], dict())
+ return (
+ json.dumps("Users emails updated"),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ return None
+
+# TODO: After this line, this is all mostly duplicated from other places...
+def user_parser(user : Dict[str, Any]) -> Dict[str, Any]:
return {
"keycloak_id": user["id"],
"id": user["username"],
@@ -338,7 +346,7 @@ def user_parser(user):
}
-def group_parser(group):
+def group_parser(group : Dict[str, str]) -> Dict[str, Any]:
return {
"keycloak_id": group["id"],
"id": group["name"],
@@ -348,7 +356,7 @@ def group_parser(group):
}
-def filter_users(users, text):
+def filter_users(users : Iterable[Dict[str, Any]], text : str) -> List[Dict[str, Any]]:
return [
user
for user in users
diff --git a/dd-sso/admin/src/admin/views/AppViews.py b/dd-sso/admin/src/admin/views/AppViews.py
index baa9077..7a27c96 100644
--- a/dd-sso/admin/src/admin/views/AppViews.py
+++ b/dd-sso/admin/src/admin/views/AppViews.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -20,6 +21,7 @@
import concurrent.futures
import json
import logging as log
+from operator import itemgetter
import os
import re
import sys
@@ -33,12 +35,15 @@ from uuid import uuid4
from flask import Response, jsonify, redirect, render_template, request, url_for
from flask_login import current_user, login_required
-from admin import app
+from typing import TYPE_CHECKING, cast, Any, Callable, Dict, List, Optional, Tuple
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
from ..lib.helpers import system_group
-from .decorators import login_or_token
+from .decorators import login_or_token, OptionalJsonResponse
-threads = {"external": None}
+# TODO: this is quirky and non-trivial to manage
+threads : Dict[str, threading.Thread] = {}
# q = Queue.Queue()
from keycloak.exceptions import KeycloakGetError
@@ -46,536 +51,444 @@ from keycloak.exceptions import KeycloakGetError
from ..lib.dashboard import Dashboard
from ..lib.exceptions import UserExists, UserNotFound
-dashboard = Dashboard()
from ..lib.legal import get_legal, gen_legal_if_not_exists, new_legal
-@app.route("/sysadmin/api/resync")
-@app.route("/api/resync")
-@login_required
-def resync():
- return (
- json.dumps(app.admin.resync_data()),
- 200,
- {"Content-Type": "application/json"},
- )
-
-
-@app.route("/api/users", methods=["GET", "PUT"])
-@app.route("/api/users/", methods=["POST", "PUT", "GET", "DELETE"])
-@login_or_token
-def users(provider=False):
- if request.method == "DELETE":
- if current_user.role != "admin":
- return json.dumps({}), 301, {"Content-Type": "application/json"}
- if provider == "keycloak":
+def run_in_thread(
+ op : Callable[..., Any],
+ args : Tuple = tuple(),
+ err_msg : str = "Something went wrong",
+ err_code : int = 500,
+ busy_err_msg : str ="Precondition failed: already operating users"
+ ) -> OptionalJsonResponse:
+ if threads.get("external", None) is not None:
+ if threads["external"].is_alive():
return (
- json.dumps(app.admin.delete_keycloak_users()),
- 200,
+ json.dumps(
+ {"msg": busy_err_msg}
+ ),
+ 412,
{"Content-Type": "application/json"},
)
- if provider == "nextcloud":
- return (
- json.dumps(app.admin.delete_nextcloud_users()),
- 200,
- {"Content-Type": "application/json"},
- )
- if provider == "moodle":
- return (
- json.dumps(app.admin.delete_moodle_users()),
- 200,
- {"Content-Type": "application/json"},
- )
- if request.method == "POST":
- if current_user.role != "admin":
- return json.dumps({}), 301, {"Content-Type": "application/json"}
- if provider == "moodle":
- return (
- json.dumps(app.admin.sync_to_moodle()),
- 200,
- {"Content-Type": "application/json"},
- )
- if provider == "nextcloud":
- return (
- json.dumps(app.admin.sync_to_nextcloud()),
- 200,
- {"Content-Type": "application/json"},
- )
- if request.method == "PUT" and not provider:
- if current_user.role != "admin":
- return json.dumps({}), 301, {"Content-Type": "application/json"}
-
- if "external" in threads.keys():
- if threads["external"] is not None and threads["external"].is_alive():
- return (
- json.dumps(
- {"msg": "Precondition failed: already working with users"}
- ),
- 412,
- {"Content-Type": "application/json"},
- )
- else:
- threads["external"] = None
- try:
- threads["external"] = threading.Thread(
- target=app.admin.update_users_from_keycloak, args=()
- )
- threads["external"].start()
- return json.dumps({}), 200, {"Content-Type": "application/json"}
- except:
- log.error(traceback.format_exc())
- return (
- json.dumps({"msg": "Add user error."}),
- 500,
- {"Content-Type": "application/json"},
- )
-
- # return json.dumps(app.admin.update_users_from_keycloak()), 200, {'Content-Type': 'application/json'}
-
- users = app.admin.get_mix_users()
- if current_user.role != "admin":
- for user in users:
- user["keycloak_groups"] = [
- g for g in user["keycloak_groups"] if not system_group(g)
- ]
- return json.dumps(users), 200, {"Content-Type": "application/json"}
-
-
-@app.route("/api/users_bulk/", methods=["PUT"])
-@login_required
-def users_bulk(action):
- data = request.get_json(force=True)
- if request.method == "PUT":
- if action == "enable":
- if "external" in threads.keys():
- if threads["external"] is not None and threads["external"].is_alive():
- return (
- json.dumps(
- {"msg": "Precondition failed: already operating users"}
- ),
- 412,
- {"Content-Type": "application/json"},
- )
- else:
- threads["external"] = None
- try:
- threads["external"] = threading.Thread(
- target=app.admin.enable_users, args=(data,)
- )
- threads["external"].start()
- return json.dumps({}), 200, {"Content-Type": "application/json"}
- except:
- log.error(traceback.format_exc())
- return (
- json.dumps({"msg": "Enable users error."}),
- 500,
- {"Content-Type": "application/json"},
- )
- if action == "disable":
- if "external" in threads.keys():
- if threads["external"] is not None and threads["external"].is_alive():
- return (
- json.dumps(
- {"msg": "Precondition failed: already operating users"}
- ),
- 412,
- {"Content-Type": "application/json"},
- )
- else:
- threads["external"] = None
- try:
- threads["external"] = threading.Thread(
- target=app.admin.disable_users, args=(data,)
- )
- threads["external"].start()
- return json.dumps({}), 200, {"Content-Type": "application/json"}
- except:
- log.error(traceback.format_exc())
- return (
- json.dumps({"msg": "Disabling users error."}),
- 500,
- {"Content-Type": "application/json"},
- )
- if action == "delete":
- if "external" in threads.keys():
- if threads["external"] is not None and threads["external"].is_alive():
- return (
- json.dumps(
- {"msg": "Precondition failed: already operating users"}
- ),
- 412,
- {"Content-Type": "application/json"},
- )
- else:
- threads["external"] = None
- try:
- threads["external"] = threading.Thread(
- target=app.admin.delete_users, args=(data,)
- )
- threads["external"].start()
- return json.dumps({}), 200, {"Content-Type": "application/json"}
- except:
- log.error(traceback.format_exc())
- return (
- json.dumps({"msg": "Deleting users error."}),
- 500,
- {"Content-Type": "application/json"},
- )
- return json.dumps({}), 405, {"Content-Type": "application/json"}
-
-
-# Update pwd
-@app.route("/api/user_password", methods=["GET"])
-@app.route("/api/user_password/", methods=["PUT"])
-@login_required
-def user_password(userid=False):
- if request.method == "GET":
- return (
- json.dumps(app.admin.get_dice_pwd()),
- 200,
- {"Content-Type": "application/json"},
- )
- if request.method == "PUT":
- data = request.get_json(force=True)
- password = data["password"]
- temporary = data.get("temporary", True)
- try:
- res = app.admin.user_update_password(userid, password, temporary)
- return json.dumps({}), 200, {"Content-Type": "application/json"}
- except KeycloakGetError as e:
- log.error(e.error_message.decode("utf-8"))
- return (
- json.dumps({"msg": "Update password error."}),
- 500,
- {"Content-Type": "application/json"},
- )
-
- return json.dumps({}), 405, {"Content-Type": "application/json"}
-
-
-# User
-@app.route("/api/user", methods=["POST"])
-@app.route("/api/user/", methods=["PUT", "GET", "DELETE"])
-@login_required
-def user(userid=None):
- if request.method == "DELETE":
- app.admin.delete_user(userid)
- return json.dumps({}), 200, {"Content-Type": "application/json"}
- if request.method == "POST":
- data = request.get_json(force=True)
- if app.admin.get_user_username(data["username"]):
- return (
- json.dumps({"msg": "Add user error: already exists."}),
- 409,
- {"Content-Type": "application/json"},
- )
- data["enabled"] = data.get("enabled", False) in [True, "on"]
- data["quota"] = data["quota"] if data["quota"] != "false" else False
- data["groups"] = data["groups"] if data.get("groups", False) else []
- if "external" in threads.keys():
- if threads["external"] is not None and threads["external"].is_alive():
- return (
- json.dumps({"msg": "Precondition failed: already adding users"}),
- 412,
- {"Content-Type": "application/json"},
- )
- else:
- threads["external"] = None
- try:
- threads["external"] = threading.Thread(
- target=app.admin.add_user, args=(data,)
- )
- threads["external"].start()
- return json.dumps({}), 200, {"Content-Type": "application/json"}
- except:
- log.error(traceback.format_exc())
- return (
- json.dumps({"msg": "Add user error."}),
- 500,
- {"Content-Type": "application/json"},
- )
-
- if request.method == "PUT":
- data = request.get_json(force=True)
- data["enabled"] = True if data.get("enabled", False) else False
- data["groups"] = data["groups"] if data.get("groups", False) else []
- data["roles"] = [data.pop("role-keycloak")]
- try:
- app.admin.user_update(data)
- return json.dumps({}), 200, {"Content-Type": "application/json"}
- except UserNotFound:
- return (
- json.dumps({"msg": "User not found."}),
- 404,
- {"Content-Type": "application/json"},
- )
- if request.method == "DELETE":
- pass
- if request.method == "GET":
- user = app.admin.get_user(userid)
- if not user:
- return (
- json.dumps({"msg": "User not found."}),
- 404,
- {"Content-Type": "application/json"},
- )
- return json.dumps(user), 200, {"Content-Type": "application/json"}
-
-
-@app.route("/api/roles")
-@login_required
-def roles():
- sorted_roles = sorted(app.admin.get_roles(), key=lambda k: k["name"])
- if current_user.role != "admin":
- sorted_roles = [sr for sr in sorted_roles if sr["name"] != "admin"]
- return json.dumps(sorted_roles), 200, {"Content-Type": "application/json"}
-
-
-@app.route("/api/group", methods=["POST", "DELETE"])
-@app.route("/api/group/", methods=["PUT", "GET", "DELETE"])
-@login_required
-def group(group_id=False):
- if request.method == "POST":
- data = request.get_json(force=True)
- log.error(data)
- data["parent"] = data["parent"] if data["parent"] != "" else None
- return (
- json.dumps(app.admin.add_group(data)),
- 200,
- {"Content-Type": "application/json"},
- )
- if request.method == "DELETE":
- try:
- data = request.get_json(force=True)
- except:
- data = False
-
- if data:
- res = app.admin.delete_group_by_path(data["path"])
else:
- if not group_id:
- return (
- json.dumps({"error": "bad_request","msg":"Bad request"}),
- 400,
- {"Content-Type": "application/json"},
- )
- res = app.admin.delete_group_by_id(group_id)
- return json.dumps(res), 200, {"Content-Type": "application/json"}
-
-
-@app.route("/api/groups")
-@app.route("/api/groups/", methods=["POST", "PUT", "GET", "DELETE"])
-@login_required
-def groups(provider=False):
- if request.method == "GET":
- sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: str(k["name"]))
- if current_user.role != "admin":
- ## internal groups should be avoided as are assigned with the role
- sorted_groups = [sg for sg in sorted_groups if not system_group(sg["name"])]
- else:
- sorted_groups = [sg for sg in sorted_groups]
- return json.dumps(sorted_groups), 200, {"Content-Type": "application/json"}
- if request.method == "DELETE":
- if provider == "keycloak":
- return (
- json.dumps(app.admin.delete_keycloak_groups()),
- 200,
- {"Content-Type": "application/json"},
- )
-
-
-### SYSADM USERS ONLY
-
-
-@app.route("/api/external", methods=["POST", "PUT", "GET", "DELETE"])
-@login_required
-def external():
- if "external" in threads.keys():
- if threads["external"] is not None and threads["external"].is_alive():
- return json.dumps({}), 301, {"Content-Type": "application/json"}
- else:
- threads["external"] = None
-
- if request.method == "POST":
- data = request.get_json(force=True)
- if data["format"] == "json-ga":
- threads["external"] = threading.Thread(
- target=app.admin.upload_json_ga, args=(data,)
- )
- threads["external"].start()
- return json.dumps({}), 200, {"Content-Type": "application/json"}
- if data["format"] == "csv-ug":
- valid = check_upload_errors(data)
- if valid["pass"]:
- threads["external"] = threading.Thread(
- target=app.admin.upload_csv_ug, args=(data,)
- )
- threads["external"].start()
- return json.dumps({}), 200, {"Content-Type": "application/json"}
- else:
- return json.dumps(valid), 422, {"Content-Type": "application/json"}
- if request.method == "PUT":
- data = request.get_json(force=True)
+ del threads["external"]
+ try:
threads["external"] = threading.Thread(
- target=app.admin.sync_external, args=(data,)
+ target=op, args=args
)
+ # TODO: this probably returns immediately and client gets no real feedback
threads["external"].start()
return json.dumps({}), 200, {"Content-Type": "application/json"}
- if request.method == "DELETE":
- print("RESET")
- app.admin.reset_external()
- return json.dumps({}), 200, {"Content-Type": "application/json"}
- return json.dumps({}), 500, {"Content-Type": "application/json"}
-
-
-@app.route("/api/external/users")
-@login_required
-def external_users_list():
- while threads["external"] is not None and threads["external"].is_alive():
- time.sleep(0.5)
- return (
- json.dumps(app.admin.get_external_users()),
- 200,
- {"Content-Type": "application/json"},
- )
-
-
-@app.route("/api/external/groups")
-@login_required
-def external_groups_list():
- while threads["external"] is not None and threads["external"].is_alive():
- time.sleep(0.5)
- return (
- json.dumps(app.admin.get_external_groups()),
- 200,
- {"Content-Type": "application/json"},
- )
-
-
-@app.route("/api/external/roles", methods=["PUT"])
-@login_required
-def external_roles():
- if request.method == "PUT":
+ except:
+ log.error(traceback.format_exc())
return (
- json.dumps(app.admin.external_roleassign(request.get_json(force=True))),
+ json.dumps({"msg": err_msg}),
+ err_code,
+ {"Content-Type": "application/json"},
+ )
+
+def setup_app_views(app : "AdminFlaskApp") -> None:
+ dashboard = Dashboard(app)
+ @app.json_route("/sysadmin/api/resync")
+ @app.json_route("/api/resync")
+ @login_required
+ def resync() -> OptionalJsonResponse:
+ return (
+ json.dumps(app.admin.resync_data()),
200,
{"Content-Type": "application/json"},
)
-def check_upload_errors(data):
- email_regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
- for u in data["data"]:
- try:
- user_groups = [g.strip() for g in u["groups"].split(",")]
- except:
- resp = {
- "pass": False,
- "msg": "User " + u["username"] + " has invalid groups: " + u["groups"],
- }
- log.error(resp)
- return resp
+ @app.json_route("/api/users", methods=["GET", "PUT"])
+ @app.json_route("/api/users/", methods=["POST", "PUT", "GET", "DELETE"])
+ @login_or_token
+ def users(provider : bool=False) -> OptionalJsonResponse:
+ if request.method == "DELETE":
+ if current_user.role != "admin":
+ return json.dumps({}), 301, {"Content-Type": "application/json"}
+ if provider == "keycloak":
+ return (
+ json.dumps(app.admin.delete_keycloak_users()),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ if provider == "nextcloud":
+ return (
+ json.dumps(app.admin.delete_nextcloud_users()),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ if provider == "moodle":
+ return (
+ json.dumps(app.admin.delete_moodle_users(app)),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ if request.method == "POST":
+ if current_user.role != "admin":
+ return json.dumps({}), 301, {"Content-Type": "application/json"}
+ if provider == "moodle":
+ return (
+ json.dumps(app.admin.sync_to_moodle()),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ if provider == "nextcloud":
+ return (
+ json.dumps(app.admin.sync_to_nextcloud()),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ if request.method == "PUT" and not provider:
+ if current_user.role != "admin":
+ return json.dumps({}), 301, {"Content-Type": "application/json"}
- if not re.fullmatch(email_regex, u["email"]):
- resp = {
- "pass": False,
- "msg": "User " + u["username"] + " has invalid email: " + u["email"],
- }
- log.error(resp)
- return resp
+ return run_in_thread(app.admin.update_users_from_keycloak, err_msg="Add user error.")
- if u["role"] not in ["admin", "manager", "teacher", "student"]:
- if u["role"] == "":
+ users = app.admin.get_mix_users()
+ if current_user.role != "admin":
+ for user in users:
+ user["keycloak_groups"] = [
+ g for g in user["keycloak_groups"] if not system_group(g)
+ ]
+ return json.dumps(users), 200, {"Content-Type": "application/json"}
+
+
+ @app.json_route("/api/users_bulk/", methods=["PUT"])
+ @login_required
+ def users_bulk(action : str) -> OptionalJsonResponse:
+ data = request.get_json(force=True)
+ if request.method == "PUT":
+ if action == "enable":
+ return run_in_thread(app.admin.enable_users, args=(data,), err_msg="Enable users error.")
+ if action == "disable":
+ return run_in_thread(app.admin.disable_users, args=(data,), err_msg="Disabling users error.")
+ if action == "delete":
+ return run_in_thread(app.admin.delete_users, args=(data,), err_msg="Deleting users error.")
+
+ return json.dumps({}), 405, {"Content-Type": "application/json"}
+
+
+ # Update pwd
+ @app.json_route("/api/user_password", methods=["GET"])
+ @app.json_route("/api/user_password/", methods=["PUT"])
+ @login_required
+ def user_password(userid : Optional[str]=None) -> OptionalJsonResponse:
+ if request.method == "GET":
+ return (
+ json.dumps(app.admin.get_dice_pwd()),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ if request.method == "PUT":
+ data = request.get_json(force=True)
+ password = data["password"]
+ temporary = data.get("temporary", True)
+ uid = cast(str, userid)
+ try:
+ res = app.admin.user_update_password(uid, password, temporary)
+ return json.dumps({}), 200, {"Content-Type": "application/json"}
+ except KeycloakGetError as e:
+ log.error(e.error_message.decode("utf-8"))
+ return (
+ json.dumps({"msg": "Update password error."}),
+ 500,
+ {"Content-Type": "application/json"},
+ )
+
+ return json.dumps({}), 405, {"Content-Type": "application/json"}
+
+
+ # User
+ @app.json_route("/api/user", methods=["POST"])
+ @app.json_route("/api/user/", methods=["PUT", "GET", "DELETE"])
+ @login_required
+ def user(userid : Optional[str]=None) -> OptionalJsonResponse:
+ # This is where changes happen from the UI
+ uid : str = userid if userid else ''
+ if request.method == "DELETE":
+ app.admin.delete_user(uid)
+ return json.dumps({}), 200, {"Content-Type": "application/json"}
+ if request.method == "POST":
+ data = request.get_json(force=True)
+ if app.admin.get_user_username(data["username"]):
+ return (
+ json.dumps({"msg": "Add user error: already exists."}),
+ 409,
+ {"Content-Type": "application/json"},
+ )
+ data["enabled"] = data.get("enabled", False) in [True, "on"]
+ data["quota"] = data["quota"] if data["quota"] != "false" else False
+ data["groups"] = data["groups"] if data.get("groups", False) else []
+ return run_in_thread(app.admin.add_user, args=(data,), err_msg="Add user error")
+
+ if request.method == "PUT":
+ data = request.get_json(force=True)
+ data["enabled"] = True if data.get("enabled", False) else False
+ data["groups"] = data["groups"] if data.get("groups", False) else []
+ data["roles"] = [data.pop("role-keycloak")]
+ try:
+ app.admin.user_update(data)
+ return json.dumps({}), 200, {"Content-Type": "application/json"}
+ except UserNotFound:
+ return (
+ json.dumps({"msg": "User not found."}),
+ 404,
+ {"Content-Type": "application/json"},
+ )
+ if request.method == "DELETE":
+ pass
+ if request.method == "GET":
+ user = app.admin.get_user(uid)
+ if not user:
+ return (
+ json.dumps({"msg": "User not found."}),
+ 404,
+ {"Content-Type": "application/json"},
+ )
+ return json.dumps(user), 200, {"Content-Type": "application/json"}
+ return None
+
+ @app.json_route("/api/roles")
+ @login_required
+ def roles() -> OptionalJsonResponse:
+ sorted_roles = sorted(app.admin.get_roles(), key=itemgetter("name"))
+ if current_user.role != "admin":
+ sorted_roles = [sr for sr in sorted_roles if sr["name"] != "admin"]
+ return json.dumps(sorted_roles), 200, {"Content-Type": "application/json"}
+
+
+ @app.json_route("/api/group", methods=["POST", "DELETE"])
+ @app.json_route("/api/group/", methods=["PUT", "GET", "DELETE"])
+ @login_required
+ def group(group_id : Optional[str]=None) -> OptionalJsonResponse:
+ if request.method == "POST":
+ data = request.get_json(force=True)
+ log.error(data)
+ data["parent"] = data["parent"] if data["parent"] != "" else None
+ return (
+ json.dumps(app.admin.add_group(data)),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ if request.method == "DELETE":
+ try:
+ data = request.get_json(force=True)
+ except:
+ data = False
+
+ if data:
+ res = app.admin.delete_group_by_path(data["path"])
+ else:
+ if not group_id:
+ return (
+ json.dumps({"error": "bad_request","msg":"Bad request"}),
+ 400,
+ {"Content-Type": "application/json"},
+ )
+ res = app.admin.delete_group_by_id(group_id)
+ return json.dumps(res), 200, {"Content-Type": "application/json"}
+ return None
+
+ @app.json_route("/api/groups")
+ @app.json_route("/api/groups/", methods=["POST", "PUT", "GET", "DELETE"])
+ @login_required
+ def groups(provider : Optional[str] = None) -> OptionalJsonResponse:
+ if request.method == "GET":
+ sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: str(k["name"]))
+ if current_user.role != "admin":
+ ## internal groups should be avoided as are assigned with the role
+ sorted_groups = [sg for sg in sorted_groups if not system_group(sg["name"])]
+ else:
+ sorted_groups = [sg for sg in sorted_groups]
+ return json.dumps(sorted_groups), 200, {"Content-Type": "application/json"}
+ if request.method == "DELETE":
+ if provider == "keycloak":
+ return (
+ json.dumps(app.admin.delete_keycloak_groups()),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ return None
+
+ ### SYSADM USERS ONLY
+
+
+ @app.json_route("/api/external", methods=["POST", "PUT", "GET", "DELETE"])
+ @login_required
+ def external() -> OptionalJsonResponse:
+ if request.method == "POST":
+ data = request.get_json(force=True)
+ if data["format"] == "json-ga":
+ return run_in_thread(app.admin.upload_json_ga, args=(data,))
+ if data["format"] == "csv-ug":
+ valid = check_upload_errors(data)
+ if valid["pass"]:
+ return run_in_thread(app.admin.upload_csv_ug, args=(data,))
+ else:
+ return json.dumps(valid), 422, {"Content-Type": "application/json"}
+ if request.method == "PUT":
+ data = request.get_json(force=True)
+ return run_in_thread(app.admin.sync_external, args=(data,))
+ if request.method == "DELETE":
+ print("RESET")
+ app.admin.reset_external()
+ return json.dumps({}), 200, {"Content-Type": "application/json"}
+ return json.dumps({}), 500, {"Content-Type": "application/json"}
+
+
+ @app.json_route("/api/external/users")
+ @login_required
+ def external_users_list() -> OptionalJsonResponse:
+ while threads["external"] is not None and threads["external"].is_alive():
+ time.sleep(0.5)
+ return (
+ json.dumps(app.admin.get_external_users()),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+
+
+ @app.json_route("/api/external/groups")
+ @login_required
+ def external_groups_list() -> OptionalJsonResponse:
+ while threads["external"] is not None and threads["external"].is_alive():
+ time.sleep(0.5)
+ return (
+ json.dumps(app.admin.get_external_groups()),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+
+
+ @app.json_route("/api/external/roles", methods=["PUT"])
+ @login_required
+ def external_roles() -> OptionalJsonResponse:
+ if request.method == "PUT":
+ return (
+ json.dumps(app.admin.external_roleassign(request.get_json(force=True))),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ return None
+
+
+ def check_upload_errors(data : Dict[Any, Any]) -> Dict[Any, Any]:
+ email_regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
+ for u in data["data"]:
+ try:
+ user_groups = [g.strip() for g in u["groups"].split(",")]
+ except:
resp = {
"pass": False,
- "msg": "User " + u["username"] + " has no role assigned!",
+ "msg": "User " + u["username"] + " has invalid groups: " + u["groups"],
}
log.error(resp)
return resp
- resp = {
- "pass": False,
- "msg": "User " + u["username"] + " has invalid role: " + u["role"],
- }
- log.error(resp)
- return resp
- return {"pass": True, "msg": ""}
+
+ if not re.fullmatch(email_regex, u["email"]):
+ resp = {
+ "pass": False,
+ "msg": "User " + u["username"] + " has invalid email: " + u["email"],
+ }
+ log.error(resp)
+ return resp
+
+ if u["role"] not in ["admin", "manager", "teacher", "student"]:
+ if u["role"] == "":
+ resp = {
+ "pass": False,
+ "msg": "User " + u["username"] + " has no role assigned!",
+ }
+ log.error(resp)
+ return resp
+ resp = {
+ "pass": False,
+ "msg": "User " + u["username"] + " has invalid role: " + u["role"],
+ }
+ log.error(resp)
+ return resp
+ return {"pass": True, "msg": ""}
-@app.route("/api/dashboard/- ", methods=["PUT"])
-@login_required
-def dashboard_put(item):
- if item == "colours":
- try:
- data = request.get_json(force=True)
- dashboard.update_colours(data)
- except:
- log.error(traceback.format_exc())
- return json.dumps({"colours": data}), 200, {"Content-Type": "application/json"}
- if item == "menu":
- try:
- data = request.get_json(force=True)
- dashboard.update_menu(data)
- except:
- log.error(traceback.format_exc())
- return json.dumps(data), 200, {"Content-Type": "application/json"}
- if item == "logo":
- dashboard.update_logo(request.files["croppedImage"])
- return json.dumps({}), 200, {"Content-Type": "application/json"}
- if item == "background":
- dashboard.update_background(request.files["croppedImage"])
- return json.dumps({}), 200, {"Content-Type": "application/json"}
- return (
- json.dumps(
- {
- "error": "update_error",
- "msg": "Error updating item " + item + "\n" + traceback.format_exc(),
- }
- ),
- 500,
- {"Content-Type": "application/json"},
- )
-
-
-@app.route("/api/legal/
- ", methods=["GET"])
-# @login_required
-def legal_get(item):
- if request.method == "GET":
- if item == "legal":
- lang = request.args.get("lang")
- if not lang or lang not in ["ca","es","en","fr"]:
- lang="ca"
- gen_legal_if_not_exists(lang)
- return (
- json.dumps({"html": get_legal(lang)}),
- 200,
- {"Content-Type": "application/json"},
- )
- # if item == "privacy":
- # return json.dumps({ "html": "Privacy policy
This works!"}), 200, {'Content-Type': 'application/json'}
-
-
-@app.route("/api/legal/- ", methods=["POST"])
-@login_required
-def legal_put(item):
- if request.method == "POST":
- if item == "legal":
- data = None
+ @app.json_route("/api/dashboard/
- ", methods=["PUT"])
+ @login_required
+ def dashboard_put(item : str) -> OptionalJsonResponse:
+ if item == "colours":
try:
- data = data = request.get_json(force=True)
- html = data["html"]
- lang = data["lang"]
- if not lang or lang not in ["ca","es","en","fr"]:
- lang="ca"
- new_legal(lang,html)
+ data = request.get_json(force=True)
+ dashboard.update_colours(data)
+ except:
+ log.error(traceback.format_exc())
+ return json.dumps({"colours": data}), 200, {"Content-Type": "application/json"}
+ if item == "menu":
+ try:
+ data = request.get_json(force=True)
+ dashboard.update_menu(data)
except:
log.error(traceback.format_exc())
return json.dumps(data), 200, {"Content-Type": "application/json"}
- # if item == "privacy":
- # data = None
- # try:
- # data = request.json
- # html = data["html"]
- # lang = data["lang"]
- # except:
- # log.error(traceback.format_exc())
- # return json.dumps(data), 200, {'Content-Type': 'application/json'}
+ if item == "logo":
+ dashboard.update_logo(request.files["croppedImage"])
+ return json.dumps({}), 200, {"Content-Type": "application/json"}
+ if item == "background":
+ dashboard.update_background(request.files["croppedImage"])
+ return json.dumps({}), 200, {"Content-Type": "application/json"}
+ return (
+ json.dumps(
+ {
+ "error": "update_error",
+ "msg": "Error updating item " + item + "\n" + traceback.format_exc(),
+ }
+ ),
+ 500,
+ {"Content-Type": "application/json"},
+ )
+
+
+ @app.json_route("/api/legal/
- ", methods=["GET"])
+ # @login_required
+ def legal_get(item : str) -> OptionalJsonResponse:
+ if request.method == "GET":
+ if item == "legal":
+ lang = request.args.get("lang")
+ if not lang or lang not in ["ca","es","en","fr"]:
+ lang="ca"
+ gen_legal_if_not_exists(app, lang)
+ return (
+ json.dumps({"html": get_legal(app, lang)}),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ # if item == "privacy":
+ # return json.dumps({ "html": "Privacy policy
This works!"}), 200, {'Content-Type': 'application/json'}
+ return None
+
+
+ @app.json_route("/api/legal/- ", methods=["POST"])
+ @login_required
+ def legal_put(item : str) -> OptionalJsonResponse:
+ if request.method == "POST":
+ if item == "legal":
+ data = None
+ try:
+ data = data = request.get_json(force=True)
+ html = data["html"]
+ lang = data["lang"]
+ if not lang or lang not in ["ca","es","en","fr"]:
+ lang="ca"
+ new_legal(app, lang, html)
+ except:
+ log.error(traceback.format_exc())
+ return json.dumps(data), 200, {"Content-Type": "application/json"}
+ return None
+ # if item == "privacy":
+ # data = None
+ # try:
+ # data = request.json
+ # html = data["html"]
+ # lang = data["lang"]
+ # except:
+ # log.error(traceback.format_exc())
+ # return json.dumps(data), 200, {'Content-Type': 'application/json'}
diff --git a/dd-sso/admin/src/admin/views/LoginViews.py b/dd-sso/admin/src/admin/views/LoginViews.py
index 95f6609..d2cf432 100644
--- a/dd-sso/admin/src/admin/views/LoginViews.py
+++ b/dd-sso/admin/src/admin/views/LoginViews.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -21,41 +22,44 @@ import os
from flask import flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required, login_user, logout_user
+from werkzeug.wrappers import Response
-from admin import app
+from typing import TYPE_CHECKING
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
from ..auth.authentication import *
-@app.route("/", methods=["GET", "POST"])
-@app.route("/login", methods=["GET", "POST"])
-def login():
- if request.method == "POST":
- if request.form["user"] == "" or request.form["password"] == "":
- flash("Can't leave it blank", "danger")
- elif request.form["user"].startswith(" "):
- flash("Username not found or incorrect password.", "warning")
- else:
- ram_user = ram_users.get(request.form["user"])
- if ram_user and request.form["password"] == ram_user["password"]:
- user = User(
- {
- "id": ram_user["id"],
- "password": ram_user["password"],
- "role": ram_user["role"],
- "active": True,
- }
- )
- login_user(user)
- flash("Logged in successfully.", "success")
- return redirect(url_for("web_users"))
- else:
+def setup_login_views(app : "AdminFlaskApp") -> None:
+ @app.route("/", methods=["GET", "POST"])
+ @app.route("/login", methods=["GET", "POST"])
+ def login() -> Response:
+ if request.method == "POST":
+ if request.form["user"] == "" or request.form["password"] == "":
+ flash("Can't leave it blank", "danger")
+ elif request.form["user"].startswith(" "):
flash("Username not found or incorrect password.", "warning")
- return render_template("login.html")
+ else:
+ ram_user = ram_users.get(request.form["user"])
+ if ram_user and request.form["password"] == ram_user["password"]:
+ user = User(
+ id = ram_user["id"],
+ password = ram_user["password"],
+ role = ram_user["role"],
+ active = True,
+ )
+ login_user(user)
+ flash("Logged in successfully.", "success")
+ return redirect(url_for("web_users"))
+ else:
+ flash("Username not found or incorrect password.", "warning")
+ o : Response = app.make_response(render_template("login.html"))
+ return o
-@app.route("/logout", methods=["GET"])
-@login_required
-def logout():
- logout_user()
- return redirect(url_for("login"))
+ @app.route("/logout", methods=["GET"])
+ @login_required
+ def logout() -> Response:
+ logout_user()
+ return redirect(url_for("login"))
diff --git a/dd-sso/admin/src/admin/views/MailViews.py b/dd-sso/admin/src/admin/views/MailViews.py
new file mode 100644
index 0000000..285274e
--- /dev/null
+++ b/dd-sso/admin/src/admin/views/MailViews.py
@@ -0,0 +1,101 @@
+#
+# Copyright © 2022 MaadiX
+# Copyright © 2022 Evilham
+#
+# This file is part of DD
+#
+# DD is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or (at your
+# option) any later version.
+#
+# DD is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with DD. If not, see .
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+import json
+import traceback
+from operator import itemgetter
+from typing import TYPE_CHECKING, Any, Dict, List, cast
+
+from flask import request
+
+from admin.auth.jws_tokens import has_jws_token
+from admin.lib.callbacks import user_parser
+from admin.views.decorators import JsonResponse
+
+from ..lib.api_exceptions import Error
+
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
+
+JsonHeaders = {"Content-Type": "application/json"}
+
+
+def setup_mail_views(app: "AdminFlaskApp") -> None:
+ mail_3p = app.api_3p["correu"]
+
+ @app.json_route("/ddapi/pubkey", methods=["GET"])
+ def pub_key() -> JsonResponse:
+ key = json.dumps(mail_3p.our_pubkey_jwk)
+ return key, 200, {"Content-Type": "application/json"}
+
+ @app.route("/ddapi/mailusers", methods=["GET", "POST", "PUT", "DELETE"])
+ @has_jws_token(app)
+ def ddapi_mail_users() -> JsonResponse:
+ users: List[Dict[str, Any]] = []
+ if request.method == "GET":
+ try:
+ sorted_users = sorted(
+ app.admin.get_mix_users(), key=itemgetter("username")
+ )
+ for user in sorted_users:
+ users.append(user_parser(user))
+ # Encrypt data with mail client public key
+ enc = mail_3p.sign_and_encrypt_outgoing_json({"users": users})
+ headers = mail_3p.get_outgoing_request_headers()
+ headers.update(JsonHeaders)
+ return enc, 200, headers
+ except:
+ raise Error(
+ "internal_server", "Failure sending users", traceback.format_exc()
+ )
+ if request.method not in ["POST", "PUT", "DELETE"]:
+ # Unsupported method
+ return json.dumps({}), 400, JsonHeaders
+
+ try:
+ dec_data = cast(
+ Dict, mail_3p.verify_and_decrypt_incoming_json(request.get_data())
+ )
+ users = dec_data.pop("users")
+ for user in users:
+ if not app.validators["mail"].validate(user):
+ raise Error(
+ "bad_request",
+ "Data validation for mail failed: "
+ + str(app.validators["mail"].errors),
+ traceback.format_exc(),
+ )
+ res: Dict
+ if request.method in ["POST", "PUT"]:
+ res = app.admin.nextcloud_mail_set(users, dec_data)
+ elif request.method == "DELETE":
+ res = app.admin.nextcloud_mail_delete(users, dec_data)
+ return (
+ json.dumps(res),
+ 200,
+ {"Content-Type": "application/json"},
+ )
+ except Exception as e:
+ raise Error(
+ "internal_server",
+ "Failure changing user emails",
+ traceback.format_exc(),
+ )
diff --git a/dd-sso/admin/src/admin/views/Socketio.py b/dd-sso/admin/src/admin/views/Socketio.py
deleted file mode 100644
index a945d9c..0000000
--- a/dd-sso/admin/src/admin/views/Socketio.py
+++ /dev/null
@@ -1,39 +0,0 @@
-#
-# Copyright © 2021,2022 IsardVDI S.L.
-#
-# This file is part of DD
-#
-# DD is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or (at your
-# option) any later version.
-#
-# DD is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with DD. If not, see .
-#
-# SPDX-License-Identifier: AGPL-3.0-or-later
-
-# from flask_socketio import SocketIO, emit, join_room, leave_room, \
-# close_room, rooms, disconnect, send
-# from admin import app
-# import json
-
-# # socketio = SocketIO(app)
-# # from ...start import socketio
-
-# @socketio.on('connect', namespace='//sio')
-# def socketio_connect():
-# join_room('admin')
-# socketio.emit('update',
-# json.dumps('Joined'),
-# namespace='//sio',
-# room='admin')
-
-# @socketio.on('disconnect', namespace='//sio')
-# def socketio_domains_disconnect():
-# None
diff --git a/dd-sso/admin/src/admin/views/WebViews.py b/dd-sso/admin/src/admin/views/WebViews.py
index c8a62e7..d14feec 100644
--- a/dd-sso/admin/src/admin/views/WebViews.py
+++ b/dd-sso/admin/src/admin/views/WebViews.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -34,134 +35,110 @@ from flask import (
jsonify,
redirect,
request,
+ Response,
send_file,
url_for,
)
from flask import render_template as render_template_flask
from flask_login import login_required
-from admin import app
+from typing import TYPE_CHECKING
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
-from ..lib.avatars import Avatars
from .decorators import is_admin
-avatars = Avatars()
-
from ..lib.legal import gen_legal_if_not_exists
-""" OIDC TESTS """
-# from ..auth.authentication import oidc
-# @app.route('/custom_callback')
-# @oidc.custom_callback
-# def callback(data):
-# return 'Hello. You submitted %s' % data
-
-# @app.route('/private')
-# @oidc.require_login
-# def hello_me():
-# info = oidc.user_getinfo(['email', 'openid_id'])
-# return ('Hello, %s (%s)! Return' %
-# (info.get('email'), info.get('openid_id')))
-
-
-# @app.route('/api')
-# @oidc.accept_token(True, ['openid'])
-# def hello_api():
-# return json.dumps({'hello': 'Welcome %s' % g.oidc_token_info['sub']})
-
-
-# @app.route('/logout')
-# def logoutoidc():
-# oidc.logout()
-# return 'Hi, you have been logged out! Return'
-""" OIDC TESTS """
-
-def render_template(*args, **kwargs):
+def render_template(*args : str, **kwargs : str) -> str:
kwargs["DOMAIN"] = os.environ["DOMAIN"]
return render_template_flask(*args, **kwargs)
-@app.route("/users")
-@login_required
-def web_users():
- return render_template("pages/users.html", title="Users", nav="Users")
+def setup_web_views(app : "AdminFlaskApp") -> None:
+ @app.route("/users")
+ @login_required
+ def web_users() -> str:
+ return render_template("pages/users.html", title="Users", nav="Users")
-@app.route("/roles")
-@login_required
-def web_roles():
- return render_template("pages/roles.html", title="Roles", nav="Roles")
+ @app.route("/roles")
+ @login_required
+ def web_roles() -> str:
+ return render_template("pages/roles.html", title="Roles", nav="Roles")
-@app.route("/groups")
-@login_required
-def web_groups(provider=False):
- return render_template("pages/groups.html", title="Groups", nav="Groups")
+ @app.route("/groups")
+ @login_required
+ def web_groups(provider : bool=False) -> str:
+ return render_template("pages/groups.html", title="Groups", nav="Groups")
-@app.route("/avatar/", methods=["GET"])
-@login_required
-def avatar(userid):
- if userid != "false":
- return send_file("../avatars/master-avatars/" + userid, mimetype="image/jpeg")
- return send_file("static/img/missing.jpg", mimetype="image/jpeg")
+ @app.route("/avatar/", methods=["GET"])
+ @login_required
+ def avatar(userid : str) -> Response:
+ if userid != "false":
+ return send_file("../avatars/master-avatars/" + userid, mimetype="image/jpeg")
+ return send_file("static/img/missing.jpg", mimetype="image/jpeg")
-@app.route("/dashboard")
-@login_required
-def dashboard(provider=False):
- data = json.loads(requests.get("http://dd-sso-api/json").text)
- return render_template(
- "pages/dashboard.html", title="Customization", nav="Customization", data=data
- )
+ @app.route("/dashboard")
+ @login_required
+ def dashboard(provider : bool=False) -> str:
+ data = json.loads(requests.get("http://dd-sso-api/json").text)
+ return render_template(
+ "pages/dashboard.html", title="Customization", nav="Customization", data=data
+ )
-@app.route("/legal")
-@login_required
-def legal():
- # data = json.loads(requests.get("http://dd-sso-api/json").text)
- return render_template("pages/legal.html", title="Legal", nav="Legal", data={})
+ @app.route("/legal")
+ @login_required
+ def legal() -> str:
+ # data = json.loads(requests.get("http://dd-sso-api/json").text)
+ return render_template("pages/legal.html", title="Legal", nav="Legal", data="")
-@app.route("/legal_text")
-def legal_text():
- lang = request.args.get("lang")
- if not lang or lang not in ["ca","es","en","fr"]:
- lang="ca"
- gen_legal_if_not_exists(lang)
- return render_template("pages/legal/"+lang)
+ @app.route("/legal_text")
+ def legal_text() -> str:
+ lang = request.args.get("lang")
+ if not lang or lang not in ["ca","es","en","fr"]:
+ lang="ca"
+ gen_legal_if_not_exists(app, lang)
+ return render_template("pages/legal/"+lang)
-### SYS ADMIN
+ ### SYS ADMIN
-@app.route("/sysadmin/users")
-@login_required
-@is_admin
-def web_sysadmin_users():
- return render_template(
- "pages/sysadmin/users.html", title="SysAdmin Users", nav="SysAdminUsers"
- )
+ @app.route("/sysadmin/users")
+ @login_required
+ @is_admin
+ def web_sysadmin_users() -> Response:
+ o : Response = app.make_response(render_template(
+ "pages/sysadmin/users.html", title="SysAdmin Users", nav="SysAdminUsers"
+ ))
+ return o
-@app.route("/sysadmin/groups")
-@login_required
-@is_admin
-def web_sysadmin_groups():
- return render_template(
- "pages/sysadmin/groups.html", title="SysAdmin Groups", nav="SysAdminGroups"
- )
+ @app.route("/sysadmin/groups")
+ @login_required
+ @is_admin
+ def web_sysadmin_groups() -> Response:
+ o : Response = app.make_response(render_template(
+ "pages/sysadmin/groups.html", title="SysAdmin Groups", nav="SysAdminGroups"
+ ))
+ return o
-@app.route("/sysadmin/external")
-@login_required
-## SysAdmin role
-def web_sysadmin_external():
- return render_template(
- "pages/sysadmin/external.html", title="External", nav="External"
- )
+ @app.route("/sysadmin/external")
+ @login_required
+ ## SysAdmin role
+ def web_sysadmin_external() -> str:
+ return render_template(
+ "pages/sysadmin/external.html", title="External", nav="External"
+ )
-@app.route("/sockettest")
-def web_sockettest():
- return render_template(
- "pages/sockettest.html", title="Sockettest Users", nav="SysAdminUsers"
- )
+ @app.route("/sockettest")
+ def web_sockettest() -> str:
+ return render_template(
+ "pages/sockettest.html", title="Sockettest Users", nav="SysAdminUsers"
+ )
diff --git a/dd-sso/admin/src/admin/views/WpViews.py b/dd-sso/admin/src/admin/views/WpViews.py
index 5695bca..09d3b7f 100644
--- a/dd-sso/admin/src/admin/views/WpViews.py
+++ b/dd-sso/admin/src/admin/views/WpViews.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -20,6 +21,7 @@
import json
import logging as log
+from operator import itemgetter
import os
import socket
import sys
@@ -28,113 +30,117 @@ import traceback
from flask import request
-from admin import app
+from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List
+if TYPE_CHECKING:
+ from admin.flaskapp import AdminFlaskApp
-from .decorators import is_internal
+from admin.views.decorators import OptionalJsonResponse, is_internal
-@app.route("/api/internal/users", methods=["GET"])
-@is_internal
-def internal_users():
- log.error(socket.gethostbyname("dd-apps-wordpress"))
- if request.method == "GET":
- sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
- # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
- users = []
- for user in sorted_users:
- if not user.get("enabled"):
- continue
- users.append(user_parser(user))
- return json.dumps(users), 200, {"Content-Type": "application/json"}
+def setup_wp_views(app : "AdminFlaskApp") -> None:
+ @app.json_route("/api/internal/users", methods=["GET"])
+ @is_internal
+ def internal_users() -> OptionalJsonResponse:
+ log.error(socket.gethostbyname("dd-apps-wordpress"))
+ if request.method == "GET":
+ sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
+ # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
+ users = []
+ for user in sorted_users:
+ if not user.get("enabled"):
+ continue
+ users.append(user_parser(user))
+ return json.dumps(users), 200, {"Content-Type": "application/json"}
+ return None
-@app.route("/api/internal/users/filter", methods=["POST"])
-@is_internal
-def internal_users_search():
- if request.method == "POST":
- data = request.get_json(force=True)
- users = app.admin.get_mix_users()
- result = [user_parser(user) for user in filter_users(users, data["text"])]
- sorted_result = sorted(result, key=lambda k: k["id"])
- return json.dumps(sorted_result), 200, {"Content-Type": "application/json"}
-
-
-@app.route("/api/internal/groups", methods=["GET"])
-@is_internal
-def internal_groups():
- if request.method == "GET":
- sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: k["name"])
- groups = []
- for group in sorted_groups:
- if not group["path"].startswith("/"):
- continue
- groups.append(
- {
- "id": group["path"],
- "name": group["name"],
- "description": group.get("description", ""),
- }
- )
- return json.dumps(groups), 200, {"Content-Type": "application/json"}
-
-
-@app.route("/api/internal/group/users", methods=["POST"])
-@is_internal
-def internal_group_users():
- if request.method == "POST":
- data = request.get_json(force=True)
- sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
- # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
- users = []
- for user in sorted_users:
- if data["path"] not in user["keycloak_groups"] or not user.get("enabled"):
- continue
- users.append(user)
- if data.get("text", False) and data["text"] != "":
+ @app.json_route("/api/internal/users/filter", methods=["POST"])
+ @is_internal
+ def internal_users_search() -> OptionalJsonResponse:
+ if request.method == "POST":
+ data = request.get_json(force=True)
+ users = app.admin.get_mix_users()
result = [user_parser(user) for user in filter_users(users, data["text"])]
- else:
- result = [user_parser(user) for user in users]
- return json.dumps(result), 200, {"Content-Type": "application/json"}
+ sorted_result = sorted(result, key=itemgetter("id"))
+ return json.dumps(sorted_result), 200, {"Content-Type": "application/json"}
+ return None
+ @app.json_route("/api/internal/groups", methods=["GET"])
+ @is_internal
+ def internal_groups() -> OptionalJsonResponse:
+ if request.method == "GET":
+ sorted_groups = sorted(app.admin.get_mix_groups(), key=itemgetter("name"))
+ groups = []
+ for group in sorted_groups:
+ if not group["path"].startswith("/"):
+ continue
+ groups.append(
+ {
+ "id": group["path"],
+ "name": group["name"],
+ "description": group.get("description", ""),
+ }
+ )
+ return json.dumps(groups), 200, {"Content-Type": "application/json"}
+ return None
-@app.route("/api/internal/roles", methods=["GET"])
-@is_internal
-def internal_roles():
- if request.method == "GET":
- roles = []
- for role in sorted(app.admin.get_roles(), key=lambda k: k["name"]):
- if role["name"] == "admin":
- continue
- roles.append(
- {
- "id": role["id"],
- "name": role["name"],
- "description": role.get("description", ""),
- }
- )
- return json.dumps(roles), 200, {"Content-Type": "application/json"}
+ @app.json_route("/api/internal/group/users", methods=["POST"])
+ @is_internal
+ def internal_group_users() -> OptionalJsonResponse:
+ if request.method == "POST":
+ data = request.get_json(force=True)
+ sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
+ # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
+ users = []
+ for user in sorted_users:
+ if data["path"] not in user["keycloak_groups"] or not user.get("enabled"):
+ continue
+ users.append(user)
+ if data.get("text", False) and data["text"] != "":
+ result = [user_parser(user) for user in filter_users(users, data["text"])]
+ else:
+ result = [user_parser(user) for user in users]
+ return json.dumps(result), 200, {"Content-Type": "application/json"}
+ return None
+ @app.json_route("/api/internal/roles", methods=["GET"])
+ @is_internal
+ def internal_roles() -> OptionalJsonResponse:
+ if request.method == "GET":
+ roles = []
+ for role in sorted(app.admin.get_roles(), key=itemgetter("name")):
+ if role["name"] == "admin":
+ continue
+ roles.append(
+ {
+ "id": role["id"],
+ "name": role["name"],
+ "description": role.get("description", ""),
+ }
+ )
+ return json.dumps(roles), 200, {"Content-Type": "application/json"}
+ return None
-@app.route("/api/internal/role/users", methods=["POST"])
-@is_internal
-def internal_role_users():
- if request.method == "POST":
- data = request.get_json(force=True)
- sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
- # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
- users = []
- for user in sorted_users:
- if data["role"] not in user["roles"] or not user.get("enabled"):
- continue
- users.append(user)
- if data.get("text", False) and data["text"] != "":
- result = [user_parser(user) for user in filter_users(users, data["text"])]
- else:
- result = [user_parser(user) for user in users]
- return json.dumps(result), 200, {"Content-Type": "application/json"}
+ @app.json_route("/api/internal/role/users", methods=["POST"])
+ @is_internal
+ def internal_role_users() -> OptionalJsonResponse:
+ if request.method == "POST":
+ data = request.get_json(force=True)
+ sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
+ # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
+ users = []
+ for user in sorted_users:
+ if data["role"] not in user["roles"] or not user.get("enabled"):
+ continue
+ users.append(user)
+ if data.get("text", False) and data["text"] != "":
+ result = [user_parser(user) for user in filter_users(users, data["text"])]
+ else:
+ result = [user_parser(user) for user in users]
+ return json.dumps(result), 200, {"Content-Type": "application/json"}
+ return None
-
-def user_parser(user):
+def user_parser(user : Dict[str, Any]) -> Dict[str, Any]:
return {
"id": user["username"],
"first": user["first"],
@@ -145,7 +151,7 @@ def user_parser(user):
}
-def filter_users(users, text):
+def filter_users(users : Iterable[Dict[str, Any]], text : str) -> List[Dict[str, Any]]:
return [
user
for user in users
diff --git a/dd-sso/admin/src/admin/views/decorators.py b/dd-sso/admin/src/admin/views/decorators.py
index 033f057..69ee220 100644
--- a/dd-sso/admin/src/admin/views/decorators.py
+++ b/dd-sso/admin/src/admin/views/decorators.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -25,25 +26,28 @@ import socket
from functools import wraps
from flask import redirect, request, url_for
+from werkzeug.wrappers import Response
from flask_login import current_user, logout_user
from jose import jwt
from ..auth.tokens import get_header_jwt_payload
+from typing import Any, Callable, Dict, Optional, Tuple
+JsonResponse = Tuple[str, int, Dict[str, str]]
+OptionalJsonResponse = Optional[JsonResponse]
-def is_admin(fn):
+def is_admin(fn : Callable[..., Response]) -> Callable[..., Response]:
@wraps(fn)
- def decorated_view(*args, **kwargs):
+ def decorated_view(*args : Any, **kwargs : Any) -> Response:
if current_user.role == "admin":
return fn(*args, **kwargs)
return redirect(url_for("login"))
return decorated_view
-
-def is_internal(fn):
+def is_internal(fn : Callable[..., OptionalJsonResponse]) -> Callable[..., OptionalJsonResponse]:
@wraps(fn)
- def decorated_view(*args, **kwargs):
+ def decorated_view(*args : Any, **kwargs : Any) -> OptionalJsonResponse:
remote_addr = (
request.headers["X-Forwarded-For"].split(",")[0]
if "X-Forwarded-For" in request.headers
@@ -67,18 +71,18 @@ def is_internal(fn):
return decorated_view
-def has_token(fn):
+def has_token(fn : Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn)
- def decorated(*args, **kwargs):
+ def decorated(*args : Any, **kwargs : Any) -> Any:
payload = get_header_jwt_payload()
return fn(*args, **kwargs)
return decorated
-def is_internal_or_has_token(fn):
+def is_internal_or_has_token(fn : Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn)
- def decorated_view(*args, **kwargs):
+ def decorated_view(*args : Any, **kwargs : Any) -> Any:
remote_addr = (
request.headers["X-Forwarded-For"].split(",")[0]
if "X-Forwarded-For" in request.headers
@@ -94,9 +98,9 @@ def is_internal_or_has_token(fn):
return decorated_view
-def login_or_token(fn):
+def login_or_token(fn : Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn)
- def decorated_view(*args, **kwargs):
+ def decorated_view(*args : Any, **kwargs : Any) -> Any:
if current_user.is_authenticated:
return fn(*args, **kwargs)
payload = get_header_jwt_payload()
diff --git a/dd-sso/admin/src/admin/yarn.lock b/dd-sso/admin/src/admin/yarn.lock
index 867ce45..172b5b7 100644
--- a/dd-sso/admin/src/admin/yarn.lock
+++ b/dd-sso/admin/src/admin/yarn.lock
@@ -88,11 +88,6 @@ engine.io@~6.1.0:
engine.io-parser "~5.0.0"
ws "~8.2.3"
-font-linux@^0.6.1:
- version "0.6.1"
- resolved "https://registry.yarnpkg.com/font-linux/-/font-linux-0.6.1.tgz#d586f46336b7da06ea3b7f10f7aee2b6346eed4f"
- integrity sha1-1Yb0Yza32gbqO38Q967itjRu7U8=
-
gentelella@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/gentelella/-/gentelella-1.4.0.tgz#b3d15fd9c40c6ea47dc7f36290c8f89aee95efc5"
diff --git a/dd-sso/admin/src/client_secrets.json b/dd-sso/admin/src/client_secrets.json
deleted file mode 100644
index 091d005..0000000
--- a/dd-sso/admin/src/client_secrets.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "web": {
- "auth_uri": "https://sso.[[DOMAIN]]/auth/realms/master/protocol/openid-connect/auth",
- "client_id": "adminapp",
- "client_secret": "8a9e5a2e-3be9-43e3-9c47-1796f0d5ab72",
- "redirect_uris": [
- "https://sso.[[DOMAIN]]/oidc_callback"
- ],
- "userinfo_uri": "https://sso.[[DOMAIN]]/auth/realms/master/protocol/openid-connect/userinfo",
- "token_uri": "https://sso.[[DOMAIN]]/auth/realms/master/protocol/openid-connect/token",
- "token_introspection_uri": "https://sso.[[DOMAIN]]/auth/realms/master/protocol/openid-connect/token/introspect"
- }
-}
\ No newline at end of file
diff --git a/dd-sso/admin/src/saml_scripts/email_saml.py b/dd-sso/admin/src/saml_scripts/email_saml.py
new file mode 100644
index 0000000..9e7e4dc
--- /dev/null
+++ b/dd-sso/admin/src/saml_scripts/email_saml.py
@@ -0,0 +1,187 @@
+#
+# Copyright © 2022 Evilham
+#
+# This file is part of DD
+#
+# DD is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or (at your
+# option) any later version.
+#
+# DD is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with DD. If not, see .
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+import logging as log
+import os
+import time
+import traceback
+from typing import Any, Dict, Optional
+
+from lib.keycloak_client import KeycloakClient
+
+try:
+ # Python 3.9+
+ from functools import cache # type: ignore # Currently targetting 3.8
+except ImportError:
+ # Up to python 3.8
+ from functools import cached_property as cache
+
+
+app: Dict[str, Dict[str, str]] = {}
+app["config"] = {}
+
+
+class SamlService(object):
+ """
+ Generic class to manage a SAML service on keycloak
+ """
+
+ keycloak: KeycloakClient
+ domain: str = os.environ["DOMAIN"]
+
+ def __init__(self):
+ self.keycloak = KeycloakClient()
+
+ @cache
+ def public_cert(self) -> str:
+ """
+ Read the public SAML certificate as used by keycloak
+ """
+ ready = False
+ basepath = os.path.dirname(__file__)
+ while not ready:
+ # TODO: Check why they were using a loop
+ try:
+ with open(
+ os.path.abspath(
+ os.path.join(basepath, "../saml_certs/public.cert")
+ ),
+ "r",
+ ) as crt:
+ app["config"]["PUBLIC_CERT"] = crt.read()
+ ready = True
+ except IOError:
+ log.warning("Could not get public certificate for SAML. Retrying...")
+ log.warning(
+ " You should generate them: /admin/saml_certs # openssl req -nodes -new -x509 -keyout private.key -out public.cert"
+ )
+ time.sleep(2)
+ except:
+ log.error(traceback.format_exc())
+ log.info("Got public SAML certificate")
+ return app["config"]["PUBLIC_CERT"]
+
+ def get_client(self, client_id: str) -> Any:
+ # TODO: merge with keycloak_config.py
+ self.keycloak.connect()
+ k = self.keycloak.keycloak_admin
+
+ clients = k.get_clients()
+ client = next(filter(lambda c: c["clientId"] == client_id, clients), None)
+ return (k, client)
+
+ def set_client(self, client_id: str, client_overrides: Dict[str, Any]) -> str:
+ (k, client) = self.get_client(client_id)
+ if client is None:
+ client_uid = k.create_client(client_overrides)
+ else:
+ client_uid = client["id"]
+ k.update_client(client_uid, client_overrides)
+ return client_id
+
+ def configure(self) -> None:
+ pass
+
+
+class EmailSaml(SamlService):
+ client_name: str = "correu"
+ client_description: str = "Client for the DD-managed email service"
+ email_domain: str
+
+ def __init__(self, email_domain: str, enabled: bool = False):
+ super(EmailSaml, self).__init__()
+ self.email_domain = email_domain
+
+ @property
+ def enabled(self) -> bool:
+ return bool(self.email_domain)
+
+ def configure(self) -> None:
+ srv_base = f"https://correu.{self.domain}"
+ client_id = f"{srv_base}/metadata/"
+ client_overrides: Dict[str, Any] = {
+ "name": self.client_name,
+ "description": self.client_description,
+ "clientId": client_id,
+ "baseUrl": f"{srv_base}/login",
+ "enabled": self.enabled,
+ "redirectUris": [
+ f"{srv_base}/*",
+ ],
+ "webOrigins": [srv_base],
+ "consentRequired": False,
+ "protocol": "saml",
+ "attributes": {
+ "saml.assertion.signature": True,
+ "saml_idp_initiated_sso_relay_state": f"{srv_base}/login",
+ "saml_assertion_consumer_url_redirect": f"{srv_base}/acs",
+ "saml.force.post.binding": True,
+ "saml.multivalued.roles": False,
+ "saml.encrypt": False,
+ "saml_assertion_consumer_url_post": f"{srv_base}/acs",
+ "saml.server.signature": True,
+ "saml_idp_initiated_sso_url_name": f"{srv_base}/acs",
+ "saml.server.signature.keyinfo.ext": False,
+ "exclude.session.state.from.auth.response": False,
+ "saml_single_logout_service_url_redirect": f"{srv_base}/ls",
+ "saml.signature.algorithm": "RSA_SHA256",
+ "saml_force_name_id_format": False,
+ "saml.client.signature": False,
+ "tls.client.certificate.bound.access.tokens": False,
+ "saml.authnstatement": True,
+ "display.on.consent.screen": False,
+ "saml_name_id_format": "username",
+ "saml_signature_canonicalization_method": "http://www.w3.org/2001/10/xml-exc-c14n#",
+ "saml.onetimeuse.condition": False,
+ },
+ "protocolMappers": [
+ {
+ "name": "username",
+ "protocol": "saml",
+ "protocolMapper": "saml-user-property-mapper",
+ "consentRequired": False,
+ "config": {
+ "attribute.nameformat": "Basic",
+ "user.attribute": "username",
+ "friendly.name": "username",
+ "attribute.name": "username",
+ },
+ },
+ {
+ "name": "email",
+ "protocol": "saml",
+ "protocolMapper": "saml-user-property-mapper",
+ "consentRequired": False,
+ "config": {
+ "attribute.nameformat": "Basic",
+ "user.attribute": "email",
+ "friendly.name": "email",
+ "attribute.name": "email",
+ },
+ },
+ ],
+ }
+ self.set_client(client_id, client_overrides)
+
+
+if __name__ == "__main__":
+ email_domain = os.environ.get("MANAGED_EMAIL_DOMAIN", "")
+ log.info("Configuring SAML client for Email")
+ EmailSaml(email_domain).configure()
diff --git a/dd-sso/admin/src/start.py b/dd-sso/admin/src/start.py
index fd18efd..94fb9a5 100644
--- a/dd-sso/admin/src/start.py
+++ b/dd-sso/admin/src/start.py
@@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
+# Copyright © 2022 Evilham
#
# This file is part of DD
#
@@ -39,14 +40,17 @@ from flask_socketio import (
send,
)
-from admin import app
+from admin import get_app
+# Set up the app
+app = get_app()
+app.setup()
app.socketio = SocketIO(app)
@app.socketio.on("connect", namespace="/sio")
@login_required
-def socketio_connect():
+def socketio_connect() -> None:
if current_user.id:
join_room("admin")
app.socketio.emit(
@@ -57,12 +61,12 @@ def socketio_connect():
@app.socketio.on("disconnect", namespace="/sio")
-def socketio_disconnect():
+def socketio_disconnect() -> None:
leave_room("admin")
@app.socketio.on("connect", namespace="/sio/events")
-def socketio_connect():
+def socketio_connect() -> None:
jwt = get_token_payload(request.args.get("jwt"))
join_room("events")
@@ -75,7 +79,7 @@ def socketio_connect():
@app.socketio.on("disconnect", namespace="/sio/events")
-def socketio_events_disconnect():
+def socketio_events_disconnect() -> None:
leave_room("events")
diff --git a/dd-sso/admin/src/tests/api.py b/dd-sso/admin/src/tests/api.py
index d02b45c..e2ed963 100644
--- a/dd-sso/admin/src/tests/api.py
+++ b/dd-sso/admin/src/tests/api.py
@@ -29,8 +29,8 @@ import requests
from jose import jwt
## SETUP
-domain = "admin.[YOURDOMAIN]"
-secret = "[your API_SECRET]"
+domain = f"admin.{ os.environ['DOMAIN'] }"
+secret = os.environ['API_SECRET']
## END SETUP
diff --git a/dd-sso/docker-compose-parts/admin.yml b/dd-sso/docker-compose-parts/admin.yml
index 46c7afb..91c87b1 100644
--- a/dd-sso/docker-compose-parts/admin.yml
+++ b/dd-sso/docker-compose-parts/admin.yml
@@ -43,8 +43,12 @@ services:
- ${DATA_FOLDER}/moodle/saml2:/admin/moodledata/saml2:rw
- ${DATA_FOLDER}/saml_certs:/admin/saml_certs:rw
- ${DATA_FOLDER}/legal:/admin/admin/static/templates/pages/legal:rw
+ - ${DATA_FOLDER}/dd-admin:/data:rw
env_file:
- .env
environment:
- VERIFY="false" # In development do not verify certificates
- DOMAIN=${DOMAIN}
+ - MANAGED_EMAIL_DOMAIN=${MANAGED_EMAIL_DOMAIN}
+ - DATA_FOLDER=/data
+ - CUSTOM_FOLDER=/admin/custom
diff --git a/dd-sso/docker-compose-parts/network.yml b/dd-sso/docker-compose-parts/network.yml
index 038d81e..d9f79fd 100644
--- a/dd-sso/docker-compose-parts/network.yml
+++ b/dd-sso/docker-compose-parts/network.yml
@@ -21,3 +21,6 @@ version: '3.7'
networks:
dd_net:
name: dd_net
+ driver: bridge
+ driver_opts:
+ com.docker.network.driver.mtu: ${NETWORK_MTU:-1500}
diff --git a/dd.conf.sample b/dd.conf.sample
index 5cd9119..132534f 100644
--- a/dd.conf.sample
+++ b/dd.conf.sample
@@ -22,6 +22,8 @@
TITLE="DD"
TITLE_SHORT="DD"
DOMAIN=mydomain.com
+# If defined, DD will be managing email for this domain
+#MANAGED_EMAIL_DOMAIN=${DOMAIN}
LETSENCRYPT_DNS=
LETSENCRYPT_EMAIL=
# Generate letsencrypt certificate for root domain
@@ -195,3 +197,6 @@ POSTGRESQL_IMG=postgres:14.1-alpine3.15
## MINIO
#MINIO_IMG=mino/minio:RELEASE.2022-01-25T19-56-04Z
+
+## Network settings
+#NETWORK_MTU=1500
diff --git a/docs/index.ca.md b/docs/index.ca.md
index aa2e160..a2cb5c8 100644
--- a/docs/index.ca.md
+++ b/docs/index.ca.md
@@ -55,6 +55,39 @@ Aquí tens alguns recursos per guiar-te més:
- [Post-instal·lació](post-install.ca.md)
- [Codi font](https://gitlab.com/DD-workspace/DD)
+# Per què comença la història de git aquí?
+
+
Per què comença la història de git aquí
+
+Hi vam fer molta feina per estabilitzar el codi i netejar el repositori abans
+de l'anunci públic al I Curs Internacional d'Educació Digital Democràtica i Open Edtech.
+
+Fent servir aquella versió com a punt de partida ens ha deixat amb el repo que
+trobeu aquí, on els canvis seran revisats abans d'acceptar-los i qualsevol
+persona és benvinguda.
+
+Si mai hi ha dubtes respecte l'autoria, si us plau comproveu la capcelera de llicència de cada fitxer.
+
+L'autoria dels commits previs és de:
+
+
+ - Josep Maria Viñolas Auquer
+ - Simó Albert i Beltran
+ - Alberto Larraz Dalmases
+ - Yoselin Ribero
+ - Elena Barrios Galán
+ - Melina Gamboa
+ - Antonio Manzano
+ - Cecilia Bayo
+ - Naomi Hidalgo
+ - Joan Cervan Andreu
+ - Jose Antonio Exposito Garcia
+ - Raúl FS
+ - Unai Tolosa Pontesta
+ - Evilham
+
+
+
Aquest web està fet amb [MkDocs](https://gitlab.com/pages/mkdocs).
Podeu [veure i modificar el codi font](https://gitlab.com/DD-workspace/DD).
diff --git a/docs/index.es.md b/docs/index.es.md
index 5776570..4146f42 100644
--- a/docs/index.es.md
+++ b/docs/index.es.md
@@ -54,5 +54,43 @@ Aquí tienes algunos recursos para guiarte más:
- [Post-instalación](post-install.ca.md)
- [Código fuente](https://gitlab.com/DD-workspace/DD)
+
+# ¿Por qué comienza la historia de git aquí?
+
+¿Por qué comienza la historia de git aquí?
+
+Se hizo mucho trabajo para estabilizar el código y limpiar el repositorio antes
+del anuncio público en el
+I Curso Internacional de Educación Digital Democrática y Open Edtech.
+
+Usar esa versión como punto de partida nos dejó con el repositorio que tenemos
+aquí, donde los cambios serán revisados antes de aceptarlos y cualquier persona
+es bienvenida.
+
+Si en algún momento hay dudas respecto a la autoría, por favor comprovad las
+cabeceras de licencia en cada fichero.
+
+La autoría de los commits previos es de:
+
+
+ - Josep Maria Viñolas Auquer
+ - Simó Albert i Beltran
+ - Alberto Larraz Dalmases
+ - Yoselin Ribero
+ - Elena Barrios Galán
+ - Melina Gamboa
+ - Antonio Manzano
+ - Cecilia Bayo
+ - Naomi Hidalgo
+ - Joan Cervan Andreu
+ - Jose Antonio Exposito Garcia
+ - Raúl FS
+ - Unai Tolosa Pontesta
+ - Evilham
+
+
+
+
+
Página creada con [MkDocs](https://gitlab.com/pages/mkdocs).
Puedes [ver y modificar su código](https://gitlab.com/DD-workspace/DD).
diff --git a/docs/index.md b/docs/index.md
index 34eaa79..112b14f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -52,5 +52,41 @@ resources to aid you further:
- [Post-install](post-install.ca.md)
- [Source code](https://gitlab.com/DD-workspace/DD)
+# Why does git history start here?
+
+Why does git history start here?
+
+A lot of work went into stabilising the code and cleaning the repo before the
+public announcement on the
+1st International Congress on Democratic Digital Education and Open Edtech.
+
+Using that version as a clean slate got us to the repo you see here, where
+changes will be reviewed before going in and anyone is welcome.
+
+When in doubt about authorship, please check each file's license headers.
+
+The authorship of the previous commits is from:
+
+
+ - Josep Maria Viñolas Auquer
+ - Simó Albert i Beltran
+ - Alberto Larraz Dalmases
+ - Yoselin Ribero
+ - Elena Barrios Galán
+ - Melina Gamboa
+ - Antonio Manzano
+ - Cecilia Bayo
+ - Naomi Hidalgo
+ - Joan Cervan Andreu
+ - Jose Antonio Exposito Garcia
+ - Raúl FS
+ - Unai Tolosa Pontesta
+ - Evilham
+
+
+
+
+
This site is built with [MkDocs](https://gitlab.com/pages/mkdocs).
You can [browse and modify its source code](https://gitlab.com/DD-workspace/DD).
+
diff --git a/docs/install.ca.md b/docs/install.ca.md
index f83adb4..e2c14a3 100644
--- a/docs/install.ca.md
+++ b/docs/install.ca.md
@@ -12,7 +12,11 @@ capaç d'executar docker-compose v1.28 o més nova.
[dd.conf.sample]: https://gitlab.com/DD-workspace/DD/-/blob/main/dd.conf.sample
+La instal·lació modifica el fitxer dd.conf per dd.conf.sample (el substitueix). Idealment inicialitza les variables des de la comanda dd-install.sh:
+```
+> DD_NETWORK_MTU=1450 ./dd-install.sh
+```
## Interactivament
Podem fer servir l'script [`dd-install.sh`][dd-install.sh] sense arguments per
@@ -42,14 +46,15 @@ Under which DOMAIN will you install DD? example.org
You will need to setup DNS entries for:
-- [ ] moodle.example.org
-- [ ] nextcloud.example.org
-- [ ] wp.example.org
-- [ ] oof.example.org
-- [ ] sso.example.org
-- [ ] pad.example.org
-- [ ] admin.example.org
-- [ ] api.example.org
+- [ ] moodle.dd.004.es
+- [ ] nextcloud.dd.004.es
+- [ ] wp.dd.004.es
+- [ ] oof.dd.004.es
+- [ ] sso.dd.004.es
+- [ ] pad.dd.004.es
+- [ ] admin.dd.004.es
+- [ ] api.dd.004.es
+- [ ] correu.dd.004.es
What is the short title of the DD instance? [DD]
@@ -77,6 +82,9 @@ Opcionalment es poden indicar els logos ubicant els fitxers .png
al servidor i indicant la seva ruta quan l'instal·lador les demana.
+Certificat preexistent
+Tens la posibilitat d'utilitzar el teu propi certificat, ja sigui wildcard o bé SAN. Pots llegir-ne mes a l'apartat [wildcard](wildcard.ca.md).
+
## Automatitzat
diff --git a/docs/wildcard.ca.md b/docs/wildcard.ca.md
new file mode 100644
index 0000000..d730f37
--- /dev/null
+++ b/docs/wildcard.ca.md
@@ -0,0 +1,46 @@
+# Instal·lació d'un certificat propi wildcard
+
+Abans de res, atura la suite mitjançant la comanda del dd-ctl down:
+
+`/opt/src/DD# ./dd-ctl down`
+
+Per que el certificat sigui compatible amb DD, heu de fusionar el fullchain amb la key privada del certificat, la opció preferida es simplement imprimir els dos fitxers en un:
+
+`/tmp/certificat# cat fullchain.pem cert.key > /opt/DD/src/haproxy/certs/chain.pem`
+
+El fitxer fullchain.pem ha de contenir tota la cadena del certificat, la cert.key es la clau privada, ha de quedar mes o menys així:
+
+```
+> cat /opt/DD/src/haproxy/certs/chain.pem
+-----BEGIN CERTIFICATE-----
+YDC ...
+...
+... PnQP
+-----END CERTIFICATE-----
+
+-----BEGIN CERTIFICATE-----
+5dSf ...
+...
+... Hwgs
+-----END CERTIFICATE-----
+
+-----BEGIN CERTIFICATE-----
+sI3q ...
+...
+... vZas
+-----END CERTIFICATE-----
+
+-----BEGIN RSA PRIVATE KEY-----
+vzKJ ...
+...
+... 2dLs
+-----END RSA PRIVATE KEY-----
+```
+
+Reviseu bé la ruta on heu copiat el fitxer chain.pem, que ha de ser /opt/DD/src/haproxy/certs
+
+Un cop fet això arrenquem de nou la suite amb la comanda dd-ctl up:
+
+`/opt/src/DD# ./dd-ctl up`
+
+I ja podem disfrutar del nostre DD correctamente instal·lat amb un certificat propi.
\ No newline at end of file