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