solved conflict main merge

merge-requests/18/head
elena 2022-08-01 09:23:49 +02:00
commit 5af70cd6ea
57 changed files with 4299 additions and 1795 deletions

View File

@ -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/) - **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/) - **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) - **Source code**: [https://gitlab.com/DD-workspace/DD](https://gitlab.com/DD-workspace/DD)
# Why does git history start here?
<details><summary>Why does git history start here?</summary>
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
</details>

View File

@ -21,3 +21,6 @@ version: '3.7'
networks: networks:
dd_net: dd_net:
name: dd_net name: dd_net
driver: bridge
driver_opts:
com.docker.network.driver.mtu: ${NETWORK_MTU:-1500}

View File

@ -1,3 +1,9 @@
# Generate .orig and .patch files with ./dd-ctl genpatches # Generate .orig and .patch files with ./dd-ctl genpatches
# file license author source # 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 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

View File

@ -0,0 +1,75 @@
<?xml version="1.0"?>
<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>mail</id>
<name>Mail</name>
<summary>💌 A mail app for Nextcloud</summary>
<description><![CDATA[**💌 A mail app for Nextcloud**
- **🚀 Integration with other Nextcloud apps!** Currently Contacts, Calendar & Files more to come.
- **📥 Multiple mail accounts!** Personal and company account? No problem, and a nice unified inbox. Connect any IMAP account.
- **🔒 Send & receive encrypted mails!** Using the great [Mailvelope](https://mailvelope.com) browser extension.
- **🙈 Were not reinventing the wheel!** Based on the great [Horde](https://horde.org) libraries.
- **📬 Want to host your own mail server?** We do not have to reimplement this as you could set up [Mail-in-a-Box](https://mailinabox.email)!
]]></description>
<version>1.12.8</version>
<licence>agpl</licence>
<author>Greta Doçi</author>
<author homepage="https://github.com/nextcloud/groupware">Nextcloud Groupware Team</author>
<namespace>Mail</namespace>
<documentation>
<user>https://github.com/nextcloud/mail/blob/main/doc/user.md</user>
<admin>https://github.com/nextcloud/mail/blob/main/doc/admin.md</admin>
<developer>https://github.com/nextcloud/mail/blob/main/doc/developer.md</developer>
</documentation>
<category>social</category>
<category>office</category>
<website>https://github.com/nextcloud/mail#readme</website>
<bugs>https://github.com/nextcloud/mail/issues</bugs>
<repository type="git">https://github.com/nextcloud/mail.git</repository>
<screenshot>https://user-images.githubusercontent.com/1374172/79554966-278e1600-809f-11ea-82ea-7a0d72a2704f.png</screenshot>
<dependencies>
<php min-version="7.3" max-version="8.0" />
<nextcloud min-version="21" max-version="24" />
</dependencies>
<background-jobs>
<job>OCA\Mail\BackgroundJob\CleanupJob</job>
<job>OCA\Mail\BackgroundJob\OutboxWorkerJob</job>
</background-jobs>
<repair-steps>
<post-migration>
<step>OCA\Mail\Migration\AddMissingDefaultTags</step>
<step>OCA\Mail\Migration\AddMissingMessageIds</step>
<step>OCA\Mail\Migration\FixCollectedAddresses</step>
<step>OCA\Mail\Migration\FixBackgroundJobs</step>
<step>OCA\Mail\Migration\MakeItineraryExtractorExecutable</step>
<step>OCA\Mail\Migration\ProvisionAccounts</step>
<step>OCA\Mail\Migration\RepairMailTheads</step>
</post-migration>
</repair-steps>
<commands>
<command>OCA\Mail\Command\AddMissingTags</command>
<command>OCA\Mail\Command\CleanUp</command>
<command>OCA\Mail\Command\CreateAccount</command>
<command>OCA\Mail\Command\CreateTagMigrationJobEntry</command>
<command>OCA\Mail\Command\DeleteAccount</command>
<command>OCA\Mail\Command\DiagnoseAccount</command>
<command>OCA\Mail\Command\ExportAccount</command>
<command>OCA\Mail\Command\ExportAccountThreads</command>
<command>OCA\Mail\Command\SyncAccount</command>
<command>OCA\Mail\Command\TrainAccount</command>
<command>OCA\Mail\Command\Thread</command>
<command>OCA\Mail\Command\UpdateAccount</command>
</commands>
<settings>
<admin>OCA\Mail\Settings\AdminSettings</admin>
</settings>
<navigations>
<navigation>
<name>Mail</name>
<route>mail.page.index</route>
<icon>mail.svg</icon>
<order>3</order>
</navigation>
</navigations>
</info>

View File

@ -0,0 +1,154 @@
<?php
/**
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @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 <http://www.gnu.org/licenses/>
*
*/
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("<info>Account $email for user $userId succesfully updated </info>");
return 1;
} else {
$output->writeln("<info>No Email Account $email found for user $userId </info>");
}
return 0;
}
}

View File

@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
/**
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Christoph Wurst <wurst.christoph@gmail.com>
* @author Lukas Reschke <lukas@owncloud.com>
* @author Thomas Müller <thomas.mueller@tmit.eu>
*
* 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 <http://www.gnu.org/licenses/>
*
*/
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<MailAccount>
*/
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);
}
}

14
dd-ctl
View File

@ -306,6 +306,12 @@ setup_nextcloud(){
EOF EOF
done 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 # Custom forms
docker exec dd-apps-nextcloud-app apk add git npm composer 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 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" 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" 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 # SAML PLUGIN EMAIL
# echo "To add SAML to moodle:" echo " --> Setting up SAML for email"
# echo "1.-Activate SAML plugin in moodle extensions, regenerate certificate, lock certificate" docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 email_saml.py"
# echo "2.-Then run: docker exec -ti dd-sso-admin python3 /admin/nextcloud_saml.py"
# echo "3.-"
} }
wait_for_moodle(){ wait_for_moodle(){

2
dd-sso/admin/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
secret
.dmypy.json

37
dd-sso/admin/Pipfile Normal file
View File

@ -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"

877
dd-sso/admin/Pipfile.lock generated Normal file
View File

@ -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"
}
}
}

View File

@ -17,19 +17,23 @@
# along with DD. If not, see <https://www.gnu.org/licenses/>. # along with DD. If not, see <https://www.gnu.org/licenses/>.
# #
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
Flask==2.0.1 attrs==22.1.0
Flask-Login==0.5.0 cryptography==37.0.4
eventlet==0.33.0 Flask==2.1.3
Flask-SocketIO==5.1.0 Flask-Login==0.6.2
bcrypt==3.2.0 eventlet==0.33.1
Flask-SocketIO==5.2.0
bcrypt==3.2.2
# diceware can't be upgraded without issues
diceware==0.9.6 diceware==0.9.6
mysql-connector-python==8.0.25 mysql-connector-python==8.0.30
psycopg2==2.8.6 psycopg2==2.9.3
# python-keycloak can't be upgraded without issues
python-keycloak==0.26.1 python-keycloak==0.26.1
minio==7.0.3 minio==7.1.11
urllib3==1.26.6 urllib3==1.26.11
schema==0.7.5 schema==0.7.5
Werkzeug~=2.0.0 Werkzeug==2.2.1
python-jose==3.3.0 python-jose==3.3.0
Cerberus==1.3.4 Cerberus==1.3.4
PyYAML==6.0 PyYAML==6.0

34
dd-sso/admin/mypy.ini Normal file
View File

@ -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

View File

@ -0,0 +1,2 @@
[tool.isort]
profile = "black"

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -20,107 +21,23 @@
import logging as log import logging as log
import os 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="") def get_app() -> AdminFlaskApp:
app = Flask(__name__, template_folder="static/templates") app = AdminFlaskApp(__name__, template_folder="static/templates")
app.url_map.strict_slashes = False
""" """
App secret key for encrypting cookies Debug should be removed on production!
You can generate one with: """
import os if app.debug:
os.urandom(24) log.warning("Debug mode: {}".format(app.debug))
And paste it here. else:
""" log.info("Debug mode: {}".format(app.debug))
app.secret_key = "Change this key!/\xf7\x83\xbe\x17\xfa\xa3zT\n\\]m\xa6\x8bF\xdd\r\xf7\x9e\x1d\x1f\x14'"
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/<path:path>")
def send_build(path):
return send_from_directory(
os.path.join(app.root_path, "node_modules/gentelella/build"), path
)
@app.route("/vendors/<path:path>")
def send_vendors(path):
return send_from_directory(
os.path.join(app.root_path, "node_modules/gentelella/vendors"), path
)
@app.route("/node_modules/<path:path>")
def send_nodes(path):
return send_from_directory(os.path.join(app.root_path, "node_modules"), path)
@app.route("/templates/<path:path>")
def send_templates(path):
return send_from_directory(os.path.join(app.root_path, "templates"), path)
# @app.route('/templates/<path:path>')
# def send_templates(path):
# return send_from_directory(os.path.join(app.root_path, 'static/templates'), path)
@app.route("/static/<path:path>")
def send_static_js(path):
return send_from_directory(os.path.join(app.root_path, "static"), path)
@app.route("/avatars/<path:path>")
def send_avatars_img(path):
return send_from_directory(
os.path.join(app.root_path, "../avatars/master-avatars"), path
)
@app.route("/custom/<path:path>")
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 Import all views

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -21,31 +22,9 @@ import os
from flask_login import LoginManager, UserMixin from flask_login import LoginManager, UserMixin
from admin import app from typing import TYPE_CHECKING, Dict
if TYPE_CHECKING:
""" OIDC TESTS """ from admin.flaskapp import AdminFlaskApp
# 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"
ram_users = { ram_users = {
os.environ["ADMINAPP_USER"]: { os.environ["ADMINAPP_USER"]: {
@ -67,13 +46,19 @@ ram_users = {
class User(UserMixin): class User(UserMixin):
def __init__(self, dict): def __init__(self, id : str, password : str, role : str, active : bool = True) -> None:
self.id = dict["id"] self.id = id
self.username = dict["id"] self.username = id
self.password = dict["password"] self.password = password
self.role = dict["role"] 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 @login_manager.user_loader
def user_loader(username): def user_loader(username : str) -> User:
return User(ram_users[username]) u = ram_users[username]
return User(id = u["id"], password = u["password"], role = u["role"])

View File

@ -0,0 +1,72 @@
#
# Copyright © 2022 MaadiX
# Copyright © 2022 Evilham <contact@evilham.com>
#
# 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 <https://www.gnu.org/licenses/>.
#
# 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())

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -30,17 +31,18 @@ from functools import wraps
from flask import request from flask import request
from jose import jwt 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()) 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""" """Obtains the Access Token from the a Header"""
auth = request.headers.get(header, None) auth = request.headers.get(header, None)
if not auth: if not auth:
@ -70,15 +72,15 @@ def get_token_header(header):
return parts[1] # Token return parts[1] # Token
def get_token_auth_header(): def get_token_auth_header() -> str:
return get_token_header("Authorization") 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)) # log.warning("The received token in get_token_payload is: " + str(token))
try: try:
claims = jwt.get_unverified_claims(token) claims = jwt.get_unverified_claims(token)
secret = app.config["API_SECRET"] secret = os.environ["API_SECRET"]
except: except:
log.warning( log.warning(
@ -97,11 +99,11 @@ def get_token_payload(token):
algorithms=["HS256"], algorithms=["HS256"],
options=dict(verify_aud=False, verify_sub=False, verify_exp=True), options=dict(verify_aud=False, verify_sub=False, verify_exp=True),
) )
except jwt.ExpiredSignatureError: except jose.exceptions.ExpiredSignatureError:
log.warning("Token expired") log.warning("Token expired")
raise Error("unauthorized", "Token is expired", traceback.format_stack()) raise Error("unauthorized", "Token is expired", traceback.format_stack())
except jwt.JWTClaimsError: except jose.exceptions.JWTClaimsError:
raise Error( raise Error(
"unauthorized", "unauthorized",
"Incorrect claims, please check the audience and issuer", "Incorrect claims, please check the audience and issuer",

View File

@ -0,0 +1,255 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
#
# 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 <https://www.gnu.org/licenses/>.
#
# 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/<path:path>")
def send_build(path: str) -> Response:
return send_from_directory(
os.path.join(self.root_path, "node_modules/gentelella/build"), path
)
@self.route("/vendors/<path:path>")
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/<path:path>")
def send_nodes(path: str) -> Response:
return send_from_directory(
os.path.join(self.root_path, "node_modules"), path
)
@self.route("/templates/<path:path>")
def send_templates(path: str) -> Response:
return send_from_directory(os.path.join(self.root_path, "templates"), path)
# @self.route('/templates/<path:path>')
# def send_templates(path):
# return send_from_directory(os.path.join(self.root_path, 'static/templates'), path)
@self.route("/static/<path:path>")
def send_static_js(path: str) -> Response:
return send_from_directory(os.path.join(self.root_path, "static"), path)
@self.route("/avatars/<path:path>")
def send_avatars_img(path: str) -> Response:
return send_from_directory(
os.path.join(self.root_path, "../avatars/master-avatars"), path
)
@self.route("/custom/<path:path>")
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

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -26,8 +27,6 @@ from time import sleep
import diceware import diceware
from admin import app
from .avatars import Avatars from .avatars import Avatars
from .helpers import ( from .helpers import (
filter_roles_list, filter_roles_list,
@ -61,20 +60,37 @@ from .helpers import (
rand_password, 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"] MANAGER = os.environ["CUSTOM_ROLE_MANAGER"]
TEACHER = os.environ["CUSTOM_ROLE_TEACHER"] TEACHER = os.environ["CUSTOM_ROLE_TEACHER"]
STUDENT = os.environ["CUSTOM_ROLE_STUDENT"] STUDENT = os.environ["CUSTOM_ROLE_STUDENT"]
DDUser = Dict[str, Any]
DDGroup = Dict[str, Any]
DDRole = Dict[str, Any]
class Admin: class Admin:
def __init__(self): app : "AdminFlaskApp"
self.check_connections() 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.set_custom_roles()
self.overwrite_admins() self.overwrite_admins()
self.default_setup() self.default_setup()
self.internal = {} self.internal = {}
self.third_party_cbs = []
ready = False ready = False
while not ready: while not ready:
@ -90,13 +106,39 @@ class Admin:
self.external = {"users": [], "groups": [], "roles": []} self.external = {"users": [], "groups": [], "roles": []}
log.warning(" Updating missing user avatars with defaults") 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 # av.minio_delete_all_objects() # This will reset all avatars on usres
self.av.update_missing_avatars(self.internal["users"]) self.av.update_missing_avatars(self.internal["users"])
log.warning(" SYSTEM READY TO HANDLE CONNECTIONS") 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 ready = False
while not ready: while not ready:
try: try:
@ -111,7 +153,7 @@ class Admin:
ready = False ready = False
while not ready: while not ready:
try: try:
self.moodle = Moodle(verify=app.config["VERIFY"]) self.moodle = Moodle(app)
ready = True ready = True
except: except:
log.error("Could not connect to moodle, waiting to be online...") log.error("Could not connect to moodle, waiting to be online...")
@ -136,18 +178,18 @@ class Admin:
ready = False ready = False
while not ready: while not ready:
try: try:
self.nextcloud = Nextcloud(verify=app.config["VERIFY"]) self.nextcloud = Nextcloud(verify=app.config["VERIFY"], app=app)
ready = True ready = True
except: except:
log.error("Could not connect to nextcloud, waiting to be online...") log.error("Could not connect to nextcloud, waiting to be online...")
sleep(2) sleep(2)
log.warning("Nextcloud connected.") log.warning("Nextcloud connected.")
def set_custom_roles(self): def set_custom_roles(self) -> None:
pass pass
## This function should be moved to postup.py ## This function should be moved to postup.py
def overwrite_admins(self): def overwrite_admins(self) -> None:
log.warning("Setting defaults...") log.warning("Setting defaults...")
dduser = os.environ["DDADMIN_USER"] dduser = os.environ["DDADMIN_USER"]
ddpassword = os.environ["DDADMIN_PASSWORD"] ddpassword = os.environ["DDADMIN_PASSWORD"]
@ -223,7 +265,7 @@ class Admin:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
exit(1) exit(1)
def default_setup(self): def default_setup(self) -> None:
### Add default roles ### Add default roles
try: try:
log.warning("KEYCLOAK: Adding default roles") log.warning("KEYCLOAK: Adding default roles")
@ -324,7 +366,7 @@ class Admin:
# except: # except:
# log.warning("KEYCLOAK: Seems to be there already") # log.warning("KEYCLOAK: Seems to be there already")
def resync_data(self): def resync_data(self) -> bool:
self.internal = { self.internal = {
"users": self._get_mix_users(), "users": self._get_mix_users(),
"groups": self._get_mix_groups(), "groups": self._get_mix_groups(),
@ -332,7 +374,7 @@ class Admin:
} }
return True return True
def get_moodle_users(self): def get_moodle_users(self) -> List[Any]:
return [ return [
u u
for u in self.moodle.get_users_with_groups_and_roles() for u in self.moodle.get_users_with_groups_and_roles()
@ -353,7 +395,7 @@ class Admin:
# "roles": u['roles']} # "roles": u['roles']}
# for u in users] # 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...') # log.warning('Loading keycloak users... can take a long time...')
users = self.keycloak.get_users_with_groups_and_roles() users = self.keycloak.get_users_with_groups_and_roles()
@ -372,7 +414,7 @@ class Admin:
if not system_username(u["username"]) if not system_username(u["username"])
] ]
def get_nextcloud_users(self): def get_nextcloud_users(self) -> List[DDUser]:
return [ return [
{ {
"id": u["username"], "id": u["username"],
@ -414,11 +456,11 @@ class Admin:
# "roles": []}) # "roles": []})
# return users_list # return users_list
def get_mix_users(self): def get_mix_users(self) -> Any:
sio_event_send("get_users", {"you_win": "you got the users!"}) sio_event_send(self.app, "get_users", {"you_win": "you got the users!"})
return self.internal["users"] return self.internal["users"]
def _get_mix_users(self): def _get_mix_users(self) -> List[DDUser]:
kgroups = self.keycloak.get_groups() kgroups = self.keycloak.get_groups()
kusers = self.get_keycloak_users() kusers = self.get_keycloak_users()
@ -481,32 +523,32 @@ class Admin:
users.append(theuser) users.append(theuser)
return users return users
def get_roles(self): def get_roles(self) -> Any:
return self.internal["roles"] return self.internal["roles"]
def _get_roles(self): def _get_roles(self) -> List[DDRole]:
return filter_roles_listofdicts(self.keycloak.get_roles()) 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] group = [g for g in self.internal["groups"] if g["name"] == group_name]
return group[0] if len(group) else False return group[0] if len(group) else False
def get_keycloak_groups(self): def get_keycloak_groups(self) -> Any:
log.warning("Loading keycloak groups...") log.warning("Loading keycloak groups...")
return self.keycloak.get_groups() return self.keycloak.get_groups()
def get_moodle_groups(self): def get_moodle_groups(self) -> Any:
log.warning("Loading moodle groups...") log.warning("Loading moodle groups...")
return self.moodle.get_cohorts() return self.moodle.get_cohorts()
def get_nextcloud_groups(self): def get_nextcloud_groups(self) -> Any:
log.warning("Loading nextcloud groups...") log.warning("Loading nextcloud groups...")
return self.nextcloud.get_groups_list() return self.nextcloud.get_groups_list()
def get_mix_groups(self): def get_mix_groups(self) -> Any:
return self.internal["groups"] return self.internal["groups"]
def _get_mix_groups(self): def _get_mix_groups(self) -> List[Dict[str, Any]]:
kgroups = self.get_keycloak_groups() kgroups = self.get_keycloak_groups()
mgroups = self.get_moodle_groups() mgroups = self.get_moodle_groups()
ngroups = self.get_nextcloud_groups() ngroups = self.get_nextcloud_groups()
@ -564,7 +606,7 @@ class Admin:
groups.append(thegroup) groups.append(thegroup)
return groups return groups
def sync_groups_from_keycloak(self): def sync_groups_from_keycloak(self) -> None:
self.resync_data() self.resync_data()
for group in self.internal["groups"]: for group in self.internal["groups"]:
if not group["keycloak"]: if not group["keycloak"]:
@ -586,22 +628,22 @@ class Admin:
self.nextcloud.add_group(group["name"]) self.nextcloud.add_group(group["name"])
self.resync_data() self.resync_data()
def get_external_users(self): def get_external_users(self) -> Any:
return self.external["users"] return self.external["users"]
def get_external_groups(self): def get_external_groups(self) -> Any:
return self.external["groups"] return self.external["groups"]
def get_external_roles(self): def get_external_roles(self) -> Any:
return self.external["roles"] 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...") log.warning("Processing uploaded users...")
users = [] users = []
total = len(data["data"]) total = len(data["data"])
item = 1 item = 1
ev = Events("Processing uploaded users", total=len(data["data"])) ev = Events(self.app, "Processing uploaded users", total=len(data["data"]))
groups = [] groups : List[str] = []
for u in data["data"]: for u in data["data"]:
log.warning( log.warning(
"Processing (" "Processing ("
@ -680,18 +722,18 @@ class Admin:
self.external["groups"] = sysgroups self.external["groups"] = sysgroups
return True return True
def get_dice_pwd(self): def get_dice_pwd(self) -> str:
return diceware.get_passphrase(options=options) return cast(str, diceware.get_passphrase(options=options))
def reset_external(self): def reset_external(self) -> bool:
self.external = {"users": [], "groups": [], "roles": []} self.external = {"users": [], "groups": [], "roles": []}
return True return True
def upload_json_ga(self, data): def upload_json_ga(self, data : Dict[str, Any]) -> bool:
groups = [] groups = []
log.warning("Processing uploaded groups...") log.warning("Processing uploaded groups...")
try: try:
ev = Events( ev = Events(self.app,
"Processing uploaded groups", "Processing uploaded groups",
"Group:", "Group:",
total=len(data["data"]["groups"]), total=len(data["data"]["groups"]),
@ -718,7 +760,7 @@ class Admin:
users = [] users = []
total = len(data["data"]["users"]) total = len(data["data"]["users"])
item = 1 item = 1
ev = Events( ev = Events(self.app,
"Processing uploaded users", "Processing uploaded users",
"User:", "User:",
total=len(data["data"]["users"]), total=len(data["data"]["users"]),
@ -757,7 +799,8 @@ class Admin:
u["groups"] = u["groups"] + [g["name"]] u["groups"] = u["groups"] + [g["name"]]
return True 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() # self.resync_data()
log.warning("Starting sync to keycloak") log.warning("Starting sync to keycloak")
self.sync_to_keycloak_external() self.sync_to_keycloak_external()
@ -769,10 +812,10 @@ class Admin:
log.warning("All syncs finished. Resyncing from apps...") log.warning("All syncs finished. Resyncing from apps...")
self.resync_data() self.resync_data()
def add_keycloak_groups(self, groups): def add_keycloak_groups(self, groups : List[Any]) -> None:
total = len(groups) total = len(groups)
i = 0 i = 0
ev = Events( ev = Events(self.app,
"Syncing import groups to keycloak", "Adding group:", total=len(groups) "Syncing import groups to keycloak", "Adding group:", total=len(groups)
) )
for g in groups: for g in groups:
@ -790,8 +833,8 @@ class Admin:
def sync_to_keycloak_external( def sync_to_keycloak_external(
self, self,
): ### This one works from the external, moodle and nextcloud from the internal ) -> None: ### This one works from the external, moodle and nextcloud from the internal
groups = [] groups : List[DDGroup] = []
for u in self.external["users"]: for u in self.external["users"]:
groups = groups + u["groups"] groups = groups + u["groups"]
groups = list(dict.fromkeys(groups)) groups = list(dict.fromkeys(groups))
@ -800,7 +843,7 @@ class Admin:
total = len(self.external["users"]) total = len(self.external["users"])
index = 0 index = 0
ev = Events( ev = Events(self.app,
"Syncing import users to keycloak", "Syncing import users to keycloak",
"Adding user:", "Adding user:",
total=len(self.external["users"]), total=len(self.external["users"]),
@ -855,11 +898,11 @@ class Admin:
u["groups"].append(u["roles"][0]) u["groups"].append(u["roles"][0])
self.resync_data() 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 ### Create all groups. Skip / in system groups
total = len(groups) total = len(groups)
log.warning(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 i = 1
for g in groups: for g in groups:
moodle_groups = kpath2gids(g) moodle_groups = kpath2gids(g)
@ -880,9 +923,9 @@ class Admin:
) )
i = i + 1 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 ### Process all groups from the users keycloak_groups key
groups = [] groups : List[DDGroup] = []
for u in self.external["users"]: for u in self.external["users"]:
groups = groups + u["groups"] groups = groups + u["groups"]
groups = list(dict.fromkeys(groups)) groups = list(dict.fromkeys(groups))
@ -893,7 +936,7 @@ class Admin:
cohorts = self.moodle.get_cohorts() cohorts = self.moodle.get_cohorts()
### Create users in moodle ### Create users in moodle
ev = Events( ev = Events(self.app,
"Syncing users from external to moodle", total=len(self.internal["users"]) "Syncing users from external to moodle", total=len(self.internal["users"])
) )
for u in self.external["users"]: for u in self.external["users"]:
@ -920,7 +963,7 @@ class Admin:
# self.resync_data() # self.resync_data()
### Add user to their cohorts (groups) ### Add user to their cohorts (groups)
ev = Events( ev = Events(self.app,
"Syncing users groups from external to moodle cohorts", "Syncing users groups from external to moodle cohorts",
total=len(self.internal["users"]), total=len(self.internal["users"]),
) )
@ -938,16 +981,16 @@ class Admin:
log.error(self.moodle.get_user_by("username", u["username"])) log.error(self.moodle.get_user_by("username", u["username"]))
# self.resync_data() # self.resync_data()
def delete_all_moodle_cohorts(self): def delete_all_moodle_cohorts(self) -> None:
cohorts = self.moodle.get_cohorts() cohorts = self.moodle.get_cohorts()
ids = [c["id"] for c in cohorts] ids = [c["id"] for c in cohorts]
self.moodle.delete_cohorts(ids) 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 ### Create all groups. Skip / in system groups
total = len(groups) total = len(groups)
log.warning(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 i = 1
for g in groups: for g in groups:
nextcloud_groups = kpath2gids(g) nextcloud_groups = kpath2gids(g)
@ -968,15 +1011,15 @@ class Admin:
) )
i = i + 1 i = i + 1
def sync_to_nextcloud_external(self): def sync_to_nextcloud_external(self) -> None:
groups = [] groups : List[DDGroup] = []
for u in self.external["users"]: for u in self.external["users"]:
groups = groups + u["gids"] groups = groups + u["gids"]
groups = list(dict.fromkeys(groups)) groups = list(dict.fromkeys(groups))
self.add_nextcloud_groups(groups) self.add_nextcloud_groups(groups)
ev = Events( ev = Events(self.app,
"Syncing users from external to nextcloud", "Syncing users from external to nextcloud",
total=len(self.internal["users"]), total=len(self.internal["users"]),
) )
@ -1009,14 +1052,14 @@ class Admin:
except: except:
log.error(traceback.format_exc()) 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 ### Process all groups from the users keycloak_groups key
groups = [] groups : List[str] = []
for u in self.internal["users"]: for u in self.internal["users"]:
groups = groups + u["keycloak_groups"] groups = groups + u["keycloak_groups"]
groups = list(dict.fromkeys(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 = [] pathslist = []
for group in groups: for group in groups:
pathpart = "" pathpart = ""
@ -1040,7 +1083,7 @@ class Admin:
cohorts = self.moodle.get_cohorts() cohorts = self.moodle.get_cohorts()
### Create users in moodle ### Create users in moodle
ev = Events( ev = Events(self.app,
"Syncing users from keycloak to moodle", total=len(self.internal["users"]) "Syncing users from keycloak to moodle", total=len(self.internal["users"])
) )
for u in self.internal["users"]: for u in self.internal["users"]:
@ -1067,7 +1110,7 @@ class Admin:
self.resync_data() self.resync_data()
ev = Events( ev = Events(self.app,
"Syncing users with moodle cohorts", total=len(self.internal["users"]) "Syncing users with moodle cohorts", total=len(self.internal["users"])
) )
cohorts = self.moodle.get_cohorts() cohorts = self.moodle.get_cohorts()
@ -1106,15 +1149,15 @@ class Admin:
self.resync_data() self.resync_data()
def sync_to_nextcloud(self): def sync_to_nextcloud(self) -> None:
groups = [] groups : List[str] = []
for u in self.internal["users"]: for u in self.internal["users"]:
groups = groups + u["keycloak_groups"] groups = groups + u["keycloak_groups"]
groups = list(dict.fromkeys(groups)) groups = list(dict.fromkeys(groups))
total = len(groups) total = len(groups)
i = 0 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: for g in groups:
parts = g.split("/") parts = g.split("/")
subpath = "" subpath = ""
@ -1137,7 +1180,7 @@ class Admin:
) )
i = i + 1 i = i + 1
ev = Events( ev = Events(self.app,
"Syncing users from keycloak to nextcloud", "Syncing users from keycloak to nextcloud",
total=len(self.internal["users"]), total=len(self.internal["users"]),
) )
@ -1167,13 +1210,13 @@ class Admin:
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
def delete_keycloak_user(self, userid): def delete_keycloak_user(self, userid : str) -> None:
user = [u for u in self.internal["users"] if u["id"] == userid] users : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
if len(user) and user[0]["keycloak"]: if len(users) and users[0]["keycloak"]:
user = user[0] user = users[0]
keycloak_id = user["id"] keycloak_id = user["id"]
else: else:
return False return
log.warning("Removing keycloak user: " + user["username"]) log.warning("Removing keycloak user: " + user["username"])
try: try:
self.keycloak.delete_user(keycloak_id) self.keycloak.delete_user(keycloak_id)
@ -1183,10 +1226,10 @@ class Admin:
self.av.delete_user_avatar(userid) self.av.delete_user_avatar(userid)
def delete_keycloak_users(self): def delete_keycloak_users(self) -> None:
total = len(self.internal["users"]) total = len(self.internal["users"])
i = 0 i = 0
ev = Events( ev = Events(self.app,
"Deleting users from keycloak", "Deleting users from keycloak",
"Deleting user:", "Deleting user:",
total=len(self.internal["users"]), total=len(self.internal["users"]),
@ -1217,13 +1260,13 @@ class Admin:
) )
self.av.minio_delete_all_objects() self.av.minio_delete_all_objects()
def delete_nextcloud_user(self, userid): def delete_nextcloud_user(self, userid : str) -> None:
user = [u for u in self.internal["users"] if u["id"] == userid] users : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
if len(user) and user[0]["nextcloud"]: if len(users) and users[0]["nextcloud"]:
user = user[0] user = users[0]
nextcloud_id = user["nextcloud_id"] nextcloud_id = user["nextcloud_id"]
else: else:
return False return
log.warning("Removing nextcloud user: " + user["username"]) log.warning("Removing nextcloud user: " + user["username"])
try: try:
self.nextcloud.delete_user(nextcloud_id) self.nextcloud.delete_user(nextcloud_id)
@ -1231,8 +1274,8 @@ class Admin:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
log.warning("Could not remove users: " + user["username"]) log.warning("Could not remove users: " + user["username"])
def delete_nextcloud_users(self): def delete_nextcloud_users(self) -> None:
ev = Events("Deleting users from nextcloud", total=len(self.internal["users"])) ev = Events(self.app, "Deleting users from nextcloud", total=len(self.internal["users"]))
for u in self.internal["users"]: for u in self.internal["users"]:
if u["nextcloud"] and not u["keycloak"]: if u["nextcloud"] and not u["keycloak"]:
@ -1246,13 +1289,13 @@ class Admin:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
log.warning("Could not remove user: " + u["username"]) log.warning("Could not remove user: " + u["username"])
def delete_moodle_user(self, userid): def delete_moodle_user(self, userid : str) -> None:
user = [u for u in self.internal["users"] if u["id"] == userid] users : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
if len(user) and user[0]["moodle"]: if len(users) and users[0]["moodle"]:
user = user[0] user = users[0]
moodle_id = user["moodle_id"] moodle_id = user["moodle_id"]
else: else:
return False return
log.warning("Removing moodle user: " + user["username"]) log.warning("Removing moodle user: " + user["username"])
try: try:
self.moodle.delete_users([moodle_id]) self.moodle.delete_users([moodle_id])
@ -1260,7 +1303,7 @@ class Admin:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
log.warning("Could not remove users: " + user["username"]) log.warning("Could not remove users: " + user["username"])
def delete_moodle_users(self): def delete_moodle_users(self, app : "AdminFlaskApp") -> None:
userids = [] userids = []
usernames = [] usernames = []
for u in self.internal["users"]: for u in self.internal["users"]:
@ -1288,7 +1331,7 @@ class Admin:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
log.warning("Could not remove users: " + ",".join(usernames)) 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"]: for g in self.internal["groups"]:
if not g["keycloak"]: if not g["keycloak"]:
continue continue
@ -1302,7 +1345,7 @@ class Admin:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
log.warning("Could not remove group: " + g["name"]) 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 newuserid in data["ids"]:
for externaluser in self.external["users"]: for externaluser in self.external["users"]:
if externaluser["id"] == newuserid: if externaluser["id"] == newuserid:
@ -1316,10 +1359,10 @@ class Admin:
externaluser["gids"].append(data["action"]) externaluser["gids"].append(data["action"])
return True 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) 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() kgroups = self.keycloak.get_groups()
users = [ users = [
{ {
@ -1339,15 +1382,15 @@ class Admin:
] ]
for user in users: for user in users:
ev = Events( ev = Events(self.app,
"Updating users from keycloak", "User:", total=len(users), table="users" "Updating users from keycloak", "User:", total=len(users), table="users"
) )
self.user_update(user) self.user_update(user)
ev.increment({"name": user["username"], "data": user["groups"]}) 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") 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 ## Get actual user role
try: try:
@ -1505,6 +1548,9 @@ class Admin:
ev.update_text("Updating user in nextcloud") ev.update_text("Updating user in nextcloud")
self.update_nextcloud_user(internaluser["id"], user, ndelete, nadd) 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") ev.update_text("User updated")
return True return True
@ -1533,7 +1579,7 @@ class Admin:
) )
return True 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)) # pprint(self.keycloak.get_user_realm_roles(user_id))
self.keycloak.remove_user_realm_roles(user_id, "student") self.keycloak.remove_user_realm_roles(user_id, "student")
self.keycloak.assign_realm_roles(user_id, user["roles"][0]) self.keycloak.assign_realm_roles(user_id, user["roles"][0])
@ -1549,27 +1595,27 @@ class Admin:
self.resync_data() self.resync_data()
return True return True
def enable_users(self, data): def enable_users(self, data : List[DDUser]) -> None:
# data={'id':'','username':''} # 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: for user in data:
ev.increment({"name": user["username"], "data": user["username"]}) ev.increment({"name": user["username"], "data": user["username"]})
self.keycloak.user_enable(user["id"]) self.keycloak.user_enable(user["id"])
self.resync_data() self.resync_data()
def disable_users(self, data): def disable_users(self, data : List[DDUser]) -> None:
# data={'id':'','username':''} # 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: for user in data:
ev.increment({"name": user["username"], "data": user["username"]}) ev.increment({"name": user["username"], "data": user["username"]})
self.keycloak.user_disable(user["id"]) self.keycloak.user_disable(user["id"])
self.resync_data() self.resync_data()
def update_moodle_user(self, user_id, user, mdelete, madd): def update_moodle_user(self, user_id : str, user : DDUser, mdelete : Iterable[Any], madd : Iterable[Any]) -> bool:
internaluser = [u for u in self.internal["users"] if u["id"] == user_id][0] internaluser : DDUser = [u for u in self.internal["users"] if u["id"] == user_id][0]
cohorts = self.moodle.get_cohorts() cohorts = self.moodle.get_cohorts()
for group in mdelete: 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: try:
self.moodle.delete_user_in_cohort( self.moodle.delete_user_in_cohort(
internaluser["moodle_id"], cohort["id"] internaluser["moodle_id"], cohort["id"]
@ -1604,29 +1650,29 @@ class Admin:
def add_moodle_user( def add_moodle_user(
self, self,
username, username : str,
email, email : str,
first_name, first_name : str,
last_name, last_name : str,
password="*12" + secrets.token_urlsafe(16), password : str="*12" + secrets.token_urlsafe(16),
): ) -> None:
log.warning("Creating moodle user: " + username) log.warning("Creating moodle user: " + username)
ev = Events("Add user", username) ev = Events(self.app, "Add user", username)
try: try:
self.moodle.create_user(email, username, password, first_name, last_name) self.moodle.create_user(email, username, password, first_name, last_name)
ev.update_text({"name": "Added to moodle", "data": []}) ev.update_text(str({"name": "Added to moodle", "data": []}))
except UserExists: except UserExists as ex:
log.error(" -->> User already exists") log.error(" -->> User already exists")
error = Events("User already exists.", str(se), type="error") error = Events(self.app, "User already exists.", str(ex), type="error")
except SystemError as se: except SystemError as ex:
log.error("Moodle create user error: " + str(se)) log.error("Moodle create user error: " + str(ex))
error = Events("Moodle create user error", str(se), type="error") error = Events(self.app, "Moodle create user error", str(ex), type="error")
except: except:
log.error(" -->> Error creating on moodle the user: " + username) log.error(" -->> Error creating on moodle the user: " + username)
print(traceback.format_exc()) 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 ## TODO: Disable de user? Is really needed? it is disabled in keycloak, so can't login again
## ocs/v1.php/cloud/users/{userid}/disable ## ocs/v1.php/cloud/users/{userid}/disable
@ -1676,21 +1722,21 @@ class Admin:
def add_nextcloud_user( def add_nextcloud_user(
self, self,
username, username : str,
email, email : str,
quota, quota : Any,
first_name, first_name : str,
last_name, last_name : str,
groups, groups : str,
password="*12" + secrets.token_urlsafe(16), password : str = "*12" + secrets.token_urlsafe(16),
): ) -> None:
log.warning( log.warning(
" NEXTCLOUD USERS: Creating nextcloud user: " " NEXTCLOUD USERS: Creating nextcloud user: "
+ username + username
+ " in groups " + " in groups "
+ str(groups) + str(groups)
) )
ev = Events("Add user", username) ev = Events(self.app, "Add user", username)
try: try:
# Quota is "1 GB", "500 MB" # Quota is "1 GB", "500 MB"
self.nextcloud.add_user_with_groups( self.nextcloud.add_user_with_groups(
@ -1704,41 +1750,44 @@ class Admin:
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
def delete_users(self, data): def delete_users(self, data : List[DDUser]) -> None:
ev = Events("Bulk actions", "Deleting users:", total=len(data)) ev = Events(self.app, "Bulk actions", "Deleting users:", total=len(data))
for user in data: for user in data:
ev.increment({"name": user["username"], "data": user["username"]}) ev.increment({"name": user["username"], "data": user["username"]})
self.delete_user(user["id"]) self.delete_user(user["id"])
self.resync_data() self.resync_data()
def delete_user(self, userid): def delete_user(self, userid : str) -> bool:
log.warning("Deleting user moodle, nextcloud keycloak") 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) self.delete_moodle_user(userid)
ev.update_text("Deleting from nextcloud") ev.update_text("Deleting from nextcloud")
self.delete_nextcloud_user(userid) self.delete_nextcloud_user(userid)
ev.update_text("Deleting from keycloak") ev.update_text("Deleting from keycloak")
self.delete_keycloak_user(userid) 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...") ev.update_text("Syncing data from applications...")
self.resync_data() self.resync_data()
ev.update_text("User deleted") ev.update_text("User deleted")
sio_event_send("delete_user", {"userid": userid}) sio_event_send(self.app, "delete_user", {"userid": userid})
return True return True
def get_user(self, userid): def get_user(self, userid : str) -> Optional[DDUser]:
user = [u for u in self.internal["users"] if u["id"] == userid] user : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
if not len(user): if not len(user):
return False return None
return user[0] return user[0]
def get_user_username(self, username): def get_user_username(self, username : str) -> Optional[DDUser]:
user = [u for u in self.internal["users"] if u["username"] == username] user : List[DDUser] = [u for u in self.internal["users"] if u["username"] == username]
if not len(user): if not len(user):
return False return None
return user[0] return user[0]
def add_user(self, u): def add_user(self, u : DDUser) -> Any:
pathslist = [] pathslist = []
for group in u["groups"]: for group in u["groups"]:
pathpart = "" pathpart = ""
@ -1767,7 +1816,7 @@ class Admin:
### KEYCLOAK ### 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"]) log.warning(" KEYCLOAK USERS: Adding user: " + u["username"])
uid = self.keycloak.add_user( uid = self.keycloak.add_user(
u["username"], u["username"],
@ -1810,16 +1859,16 @@ class Admin:
u["last"], u["last"],
)[0]["id"] )[0]["id"]
ev.increment({"name": "Added to moodle", "data": []}) ev.increment({"name": "Added to moodle", "data": []})
except UserExists: except UserExists as ex:
log.error(" -->> User already exists") log.error(" -->> User already exists")
error = Events("User already exists.", str(se), type="error") error = Events(self.app, "User already exists.", str(ex), type="error")
except SystemError as se: except SystemError as ex:
log.error("Moodle create user error: " + str(se)) log.error("Moodle create user error: " + str(ex))
error = Events("Moodle create user error", str(se), type="error") error = Events(self.app, "Moodle create user error", str(ex), type="error")
except: except:
log.error(" -->> Error creating on moodle the user: " + u["username"]) log.error(" -->> Error creating on moodle the user: " + u["username"])
print(traceback.format_exc()) 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 # Add user to cohort
## Get all existing moodle cohorts ## Get all existing moodle cohorts
@ -1874,31 +1923,32 @@ class Admin:
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
self.third_party_add_user(uid, u)
self.resync_data() self.resync_data()
sio_event_send("new_user", u) sio_event_send(self.app, "new_user", u)
return uid return uid
def add_group(self, g): def add_group(self, g : DDGroup) -> str:
# TODO: Check if exists # TODO: Check if exists
# We add in keycloak with his name, will be shown in app with full path with dots # We add in keycloak with his name, will be shown in app with full path with dots
if g["parent"] != None: if g["parent"] != None:
g["parent"] = gid2kpath(g["parent"]) 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: if g["parent"] != None:
new_path = kpath2gid(new_path["path"]) new_path = kpath2gid(new_path_kc["path"])
else:
new_path = g["name"]
self.moodle.add_system_cohort(new_path, description=g["description"]) self.moodle.add_system_cohort(new_path, description=g["description"])
self.nextcloud.add_group(new_path) self.nextcloud.add_group(new_path)
self.resync_data() self.resync_data()
return new_path return new_path
def delete_group_by_id(self, group_id): def delete_group_by_id(self, group_id : str) -> None:
ev = Events("Deleting group", "Deleting from keycloak") ev = Events(self.app, "Deleting group", "Deleting from keycloak")
try: try:
keycloak_group = self.keycloak.get_group_by_id(group_id) keycloak_group = self.keycloak.get_group_by_id(group_id)
except Exception as e: except Exception as e:
@ -1918,7 +1968,7 @@ class Admin:
try: try:
self.keycloak.delete_group(group_id) self.keycloak.delete_group(group_id)
except: except:
log.error("KEYCLOAK GROUPS: Could no delete group " + group["path"]) log.error("KEYCLOAK GROUPS: Could no delete group " + group_id)
return return
cohorts = self.moodle.get_cohorts() cohorts = self.moodle.get_cohorts()
@ -1932,7 +1982,7 @@ class Admin:
self.nextcloud.delete_group(sg_gid) self.nextcloud.delete_group(sg_gid)
self.resync_data() 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) group = self.keycloak.get_group_by_path(path)
to_be_deleted = [] to_be_deleted = []
@ -1953,6 +2003,3 @@ class Admin:
self.moodle.delete_cohorts(cohort) self.moodle.delete_cohorts(cohort)
self.nextcloud.delete_group(gid) self.nextcloud.delete_group(gid)
self.resync_data() self.resync_data()
def set_nextcloud_user_mail(self, data):
self.nextcloud.set_user_mail(data)

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -23,10 +24,11 @@ import logging as log
import os import os
import traceback 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"} content_type = {"Content-Type": "application/json"}
ex = { ex = {
"bad_request": { "bad_request": {
@ -96,8 +98,10 @@ ex = {
class Error(Exception): class Error(Exception):
def __init__(self, error="bad_request", description="", debug="", data=None): status_code : int
self.error = ex[error]["error"].copy() 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"] = ( self.error["function"] = (
inspect.stack()[1][1].split(os.sep)[-1] inspect.stack()[1][1].split(os.sep)[-1]
+ ":" + ":"
@ -123,7 +127,7 @@ class Error(Exception):
"----------- REQUEST START -----------", "----------- REQUEST START -----------",
request.method + " " + request.url, request.method + " " + request.url,
"\r\n".join("{}: {}".format(k, v) for k, v in request.headers.items()), "\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 -----------", "----------- REQUEST STOP -----------",
) )
if request if request
@ -138,7 +142,7 @@ class Error(Exception):
if data if data
else "" else ""
) )
self.status_code = ex[error]["status_code"] self.status_code = ex[error]["status_code"] # type: ignore # bad struct
self.content_type = content_type self.content_type = content_type
log.debug( log.debug(
"%s - %s - [%s -> %s]\r\n%s\r\n%s\r\n%s" "%s - %s - [%s -> %s]\r\n%s\r\n%s\r\n%s"
@ -152,11 +156,3 @@ class Error(Exception):
self.error["data"], 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

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -26,11 +27,13 @@ from minio.commonconfig import REPLACE, CopySource
from minio.deleteobjects import DeleteObject from minio.deleteobjects import DeleteObject
from requests import get, post from requests import get, post
from admin import app from typing import Any, Callable, Dict, Iterable, List
class Avatars: class Avatars:
def __init__(self): avatars_path : str
def __init__(self, avatars_path : str):
self.avatars_path = avatars_path
self.mclient = Minio( self.mclient = Minio(
"dd-sso-avatars:9000", "dd-sso-avatars:9000",
access_key="AKIAIOSFODNN7EXAMPLE", access_key="AKIAIOSFODNN7EXAMPLE",
@ -41,21 +44,22 @@ class Avatars:
self._minio_set_realm() self._minio_set_realm()
# self.update_missing_avatars() # 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.mclient.fput_object(
self.bucket, self.bucket,
userid, userid,
os.path.join(app.root_path, "../custom/avatars/" + role + ".jpg"), path,
content_type="image/jpeg ", content_type="image/jpeg ",
) )
log.warning( log.warning(
" AVATARS: Updated avatar for user " + userid + " with role " + role " 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) 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"] sys_roles = ["admin", "manager", "teacher", "student"]
for u in self.get_users_without_image(users): for u in self.get_users_without_image(users):
try: try:
@ -63,10 +67,11 @@ class Avatars:
except: except:
img = "unknown.jpg" img = "unknown.jpg"
path = os.path.join(self.avatars_path, img)
self.mclient.fput_object( self.mclient.fput_object(
self.bucket, self.bucket,
u["id"], u["id"],
os.path.join(app.root_path, "../custom/avatars/" + img), path,
content_type="image/jpeg ", content_type="image/jpeg ",
) )
log.warning( log.warning(
@ -76,26 +81,24 @@ class Avatars:
+ img.split(".")[0] + img.split(".")[0]
) )
def _minio_set_realm(self): def _minio_set_realm(self) -> None:
if not self.mclient.bucket_exists(self.bucket): if not self.mclient.bucket_exists(self.bucket):
self.mclient.make_bucket(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)] return [o.object_name for o in self.mclient.list_objects(self.bucket)]
def minio_delete_all_objects(self): def minio_delete_all_objects(self) -> None:
delete_object_list = map( f : Callable[[Any], Any] = lambda x: DeleteObject(x.object_name)
lambda x: DeleteObject(x.object_name), delete_object_list = map(f, self.mclient.list_objects(self.bucket))
self.mclient.list_objects(self.bucket),
)
errors = self.mclient.remove_objects(self.bucket, delete_object_list) errors = self.mclient.remove_objects(self.bucket, delete_object_list)
for error in errors: for error in errors:
log.error(" AVATARS: Error occured when deleting avatar object: " + error) 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)]) errors = self.mclient.remove_objects(self.bucket, [DeleteObject(oid)])
for error in errors: for error in errors:
log.error(" AVATARS: Error occured when deleting avatar object: " + error) 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()] return [u for u in users if u["id"] and u["id"] not in self.minio_get_objects()]

View File

@ -0,0 +1,115 @@
#
# Copyright © 2022 Evilham <contact@evilham.com>
#
# 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 <https://www.gnu.org/licenses/>.
#
# 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)

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -29,16 +30,25 @@ import yaml
from PIL import Image from PIL import Image
from schema import And, Optional, Schema, SchemaError, Use 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: class Dashboard:
app : "AdminFlaskApp"
def __init__( def __init__(
self, self,
): app : "AdminFlaskApp",
self.custom_menu = os.path.join(app.root_path, "../custom/menu/custom.yaml") ) -> 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: with open(self.custom_menu) as yml:
menu = yaml.load(yml, Loader=yaml.FullLoader) menu = yaml.load(yml, Loader=yaml.FullLoader)
menu = {**menu, **custom_menu_part} menu = {**menu, **custom_menu_part}
@ -46,7 +56,7 @@ class Dashboard:
yml.write(yaml.dump(menu, default_flow_style=False)) yml.write(yaml.dump(menu, default_flow_style=False))
return True return True
def update_colours(self, colours): def update_colours(self, colours : Dict[str, Any]) -> bool:
schema_template = Schema( schema_template = Schema(
{ {
"background": And(Use(str)), "background": And(Use(str)),
@ -63,7 +73,7 @@ class Dashboard:
self._update_custom_menu({"colours": colours}) self._update_custom_menu({"colours": colours})
return self.apply_updates() return self.apply_updates()
def update_menu(self, menu): def update_menu(self, menu : Dict[str, Any]) -> bool:
items = [] items = []
for menu_item in menu.keys(): for menu_item in menu.keys():
for mustexist_key in ["href", "icon", "name", "shortname"]: for mustexist_key in ["href", "icon", "name", "shortname"]:
@ -73,16 +83,16 @@ class Dashboard:
self._update_custom_menu({"apps_external": items}) self._update_custom_menu({"apps_external": items})
return self.apply_updates() return self.apply_updates()
def update_logo(self, logo): def update_logo(self, logo : FileStorage) -> bool:
img = Image.open(logo.stream) 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() return self.apply_updates()
def update_background(self, background): def update_background(self, background : FileStorage) -> bool:
img = Image.open(background.stream) 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() return self.apply_updates()
def apply_updates(self): def apply_updates(self) -> bool:
resp = requests.get("http://dd-sso-api:7039/restart") resp = requests.get("http://dd-sso-api:7039/restart")
return True return True

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -38,34 +39,46 @@ from flask_socketio import (
send, 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( app.socketio.emit(
event, event,
json.dumps(data), json.dumps(data),
namespace="/sio/events", namespace="/sio/events",
room="events", room="events",
) )
# TODO: Why on earth do we find these all over the place?
sleep(0.001) sleep(0.001)
class Events: 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 # notice, info, success, and error
self.eid = str(base64.b64encode(os.urandom(32))[:8]) self.eid = str(base64.b64encode(os.urandom(32))[:8])
self.title = title self.title = title
self.text = text self.text = text
self.total = total self.total = total
# TODO: this is probably replacing the .table method????
self.table = table self.table = table
self.item = 0 self.item = 0
self.type = type self.type = type
self.create() self.create()
def create(self): def create(self) -> None:
log.info("START " + self.eid + ": " + self.text) log.info("START " + self.eid + ": " + self.text)
app.socketio.emit( self.app.socketio.emit(
"notify-create", "notify-create",
json.dumps( json.dumps(
{ {
@ -80,9 +93,9 @@ class Events:
) )
sleep(0.001) sleep(0.001)
def __del__(self): def __del__(self) -> None:
log.info("END " + self.eid + ": " + self.text) log.info("END " + self.eid + ": " + self.text)
app.socketio.emit( self.app.socketio.emit(
"notify-destroy", "notify-destroy",
json.dumps({"id": self.eid}), json.dumps({"id": self.eid}),
namespace="/sio", namespace="/sio",
@ -90,9 +103,9 @@ class Events:
) )
sleep(0.001) sleep(0.001)
def update_text(self, text): def update_text(self, text : str) -> None:
self.text = text self.text = text
app.socketio.emit( self.app.socketio.emit(
"notify-update", "notify-update",
json.dumps( json.dumps(
{ {
@ -105,9 +118,9 @@ class Events:
) )
sleep(0.001) sleep(0.001)
def append_text(self, text): def append_text(self, text : str) -> None:
self.text = self.text + "<br>" + text self.text = self.text + "<br>" + text
app.socketio.emit( self.app.socketio.emit(
"notify-update", "notify-update",
json.dumps( json.dumps(
{ {
@ -120,10 +133,10 @@ class Events:
) )
sleep(0.001) sleep(0.001)
def increment(self, data={"name": "", "data": []}): def increment(self, data : Dict[str, Any]={"name": "", "data": []}) -> None:
self.item += 1 self.item += 1
log.info("INCREMENT " + self.eid + ": " + self.text) log.info("INCREMENT " + self.eid + ": " + self.text)
app.socketio.emit( self.app.socketio.emit(
"notify-increment", "notify-increment",
json.dumps( json.dumps(
{ {
@ -149,10 +162,10 @@ class Events:
) )
sleep(0.0001) sleep(0.0001)
def decrement(self, data={"name": "", "data": []}): def decrement(self, data : Dict[str, Any]={"name": "", "data": []}) -> None:
self.item -= 1 self.item -= 1
log.info("DECREMENT " + self.eid + ": " + self.text) log.info("DECREMENT " + self.eid + ": " + self.text)
app.socketio.emit( self.app.socketio.emit(
"notify-decrement", "notify-decrement",
json.dumps( json.dumps(
{ {
@ -178,13 +191,13 @@ class Events:
) )
sleep(0.001) sleep(0.001)
def reload(self): def reload(self) -> None:
app.socketio.emit("reload", json.dumps({}), namespace="/sio", room="admin") self.app.socketio.emit("reload", json.dumps({}), namespace="/sio", room="admin")
sleep(0.0001) 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 # refresh, add, delete, update
app.socketio.emit( self.app.socketio.emit(
"table_" + event, "table_" + event,
json.dumps({"table": table, "data": data}), json.dumps({"table": table, "data": data}),
namespace="/sio", namespace="/sio",

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -22,8 +23,11 @@ import string
from collections import Counter from collections import Counter
from pprint import pprint 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: for d_group in l_groups:
data = {} data = {}
for key, value in d_group.items(): for key, value in d_group.items():
@ -35,11 +39,11 @@ def get_recursive_groups(l_groups, l):
return 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], [])] return [g["path"] for g in get_recursive_groups([keycloak_group], [])]
def system_username(username): def system_username(username : str) -> bool:
return ( return (
True True
if username in ["guest", "ddadmin", "admin"] or username.startswith("system_") 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 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) return next((d for d in groups if d.get("id") == group_id), None)
def get_kid_from_kpath(kpath, groups): def get_kid_from_kpath(kpath : str, groups : Iterable[DDGroup]) -> Optional[str]:
ids = [g["id"] for g in groups if g["path"] == kpath] ids : List[str] = [g["id"] for g in groups if g["path"] == kpath]
if not len(ids) or len(ids) > 1: if len(ids) != 1:
return False return None
return ids[0] return ids[0]
def get_gid_from_kgroup_id(kgroup_id, groups): def get_gid_from_kgroup_id(kgroup_id : str, groups : Iterable[DDGroup]) -> str:
return [ # 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:] g["path"].replace("/", ".")[1:] if len(g["path"].split("/")) else g["path"][1:]
for g in groups for g in groups
if g["id"] == kgroup_id 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] 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:]) # print(path.replace('/','.')[1:])
if path.startswith("/"): if path.startswith("/"):
return path.replace("/", ".")[1:] return path.replace("/", ".")[1:]
return path.replace("/", ".") return path.replace("/", ".")
def kpath2gids(path): def kpath2gids(path : str) -> List[str]:
path = kpath2gid(path) path = kpath2gid(path)
l = [] l = []
for i in range(len(path.split("."))): for i in range(len(path.split("."))):
@ -89,44 +95,45 @@ def kpath2gids(path):
return l return l
def kpath2kpaths(path): def kpath2kpaths(path : str) -> List[str]:
l = [] l = []
for i in range(len(path.split("/"))): for i in range(len(path.split("/"))):
l.append("/".join(path.split("/")[: i + 1])) l.append("/".join(path.split("/")[: i + 1]))
return l[1:] return l[1:]
def gid2kpath(gid): def gid2kpath(gid : str) -> str:
return "/" + gid.replace(".", "/") return "/" + gid.replace(".", "/")
def count_repeated(itemslist): def count_repeated(itemslist : Iterable[Any]) -> None:
print(Counter(itemslist)) print(Counter(itemslist))
def groups_kname2gid(groups): def groups_kname2gid(groups : Iterable[str]) -> List[str]:
return [name.replace(".", "/") for name in groups] 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] 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] 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"] client_roles = ["admin", "manager", "teacher", "student"]
return [r for r in role_list if r in client_roles] 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"] client_roles = ["admin", "manager", "teacher", "student"]
return [r for r in role_listofdicts if r["name"] in client_roles] 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 characters = string.ascii_letters + string.digits + string.punctuation
passwd = "".join(random.choice(characters) for i in range(lenght)) passwd = "".join(random.choice(characters) for i in range(lenght))
while not any(ele.isupper() for ele in passwd): while not any(ele.isupper() for ele in passwd):

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -34,23 +35,33 @@ from .api_exceptions import Error
from .helpers import get_recursive_groups, kpath2kpaths from .helpers import get_recursive_groups, kpath2kpaths
from .postgres import Postgres 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: class KeycloakClient:
"""https://www.keycloak.org/docs-api/13.0/rest-api/index.html """https://www.keycloak.org/docs-api/13.0/rest-api/index.html
https://github.com/marcospereirampj/python-keycloak https://github.com/marcospereirampj/python-keycloak
https://gist.github.com/kaqfa/99829941121188d7cef8271f93f52f1f https://gist.github.com/kaqfa/99829941121188d7cef8271f93f52f1f
""" """
url : str
username : str
password : str
realm : str
verify : bool
keycloak_pg : Postgres
keycloak_admin : KeycloakAdmin
def __init__( def __init__(
self, self,
url="http://dd-sso-keycloak:8080/auth/", url : str="http://dd-sso-keycloak:8080/auth/",
username=os.environ["KEYCLOAK_USER"], username : str=os.environ["KEYCLOAK_USER"],
password=os.environ["KEYCLOAK_PASSWORD"], password : str=os.environ["KEYCLOAK_PASSWORD"],
realm="master", realm : str="master",
verify=True, verify : bool=True,
): ) -> None:
self.url = url self.url = url
self.username = username self.username = username
self.password = password self.password = password
@ -64,7 +75,7 @@ class KeycloakClient:
os.environ["KEYCLOAK_DB_PASSWORD"], os.environ["KEYCLOAK_DB_PASSWORD"],
) )
def connect(self): def connect(self) -> None:
self.keycloak_admin = KeycloakAdmin( self.keycloak_admin = KeycloakAdmin(
server_url=self.url, server_url=self.url,
username=self.username, username=self.username,
@ -78,15 +89,19 @@ class KeycloakClient:
""" USERS """ """ USERS """
def get_user_id(self, username): def get_user_id(self, username : str) -> str:
self.connect() 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() 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 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(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 ,json_agg(r.name) as role
@ -125,7 +140,7 @@ class KeycloakClient:
return list_dict_users 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 # Recursively get full path from any group_id in the tree
path = "" path = ""
for item in data: for item in data:
@ -134,14 +149,14 @@ class KeycloakClient:
path = f"{path}/{item[1]}" path = f"{path}/{item[1]}"
return path 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 # Get full path using getparent recursive func
# RETURNS: String with full path # RETURNS: String with full path
q = """SELECT * FROM keycloak_group""" q = """SELECT * FROM keycloak_group"""
groups = self.keycloak_pg.select(q) groups = self.keycloak_pg.select(q)
return self.getparent(group_id, groups) 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 # Get full paths for user grups
# RETURNS list of paths # RETURNS list of paths
q = """SELECT group_id FROM user_group_membership WHERE user_id = '%s'""" % ( q = """SELECT group_id FROM user_group_membership WHERE user_id = '%s'""" % (
@ -165,20 +180,20 @@ class KeycloakClient:
def add_user( def add_user(
self, self,
username, username : str,
first, first : str,
last, last : str,
email, email : str,
password, password : str,
group=False, group : Any=False,
password_temporary=True, password_temporary : bool=True,
enabled=True, enabled : bool=True,
): ) -> Any:
# RETURNS string with keycloak user id (the main id in this app) # RETURNS string with keycloak user id (the main id in this app)
self.connect() self.connect()
username = username.lower() username = username.lower()
try: try:
uid = self.keycloak_admin.create_user( uid : Any = self.keycloak_admin.create_user(
{ {
"email": email, "email": email,
"username": username, "username": username,
@ -213,7 +228,7 @@ class KeycloakClient:
self.keycloak_admin.group_user_add(uid, gid) self.keycloak_admin.group_user_add(uid, gid)
return uid 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 # Updates
payload = { payload = {
"credentials": [ "credentials": [
@ -223,7 +238,7 @@ class KeycloakClient:
self.connect() self.connect()
return self.keycloak_admin.update_user(user_id, payload) 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 ## NOTE: Roles didn't seem to be updated/added. Also not confident with groups
# Updates # Updates
payload = { payload = {
@ -237,17 +252,17 @@ class KeycloakClient:
self.connect() self.connect()
return self.keycloak_admin.update_user(user_id, payload) 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} payload = {"enabled": True}
self.connect() self.connect()
return self.keycloak_admin.update_user(user_id, payload) 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} payload = {"enabled": False}
self.connect() self.connect()
return self.keycloak_admin.update_user(user_id, payload) 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() self.connect()
return self.keycloak_admin.group_user_remove(user_id, group_id) return self.keycloak_admin.group_user_remove(user_id, group_id)
@ -255,7 +270,7 @@ class KeycloakClient:
# self.connect() # self.connect()
# return self.keycloak_admin.assign_role(client_id=client_id, user_id=user_id, role_id=role_id, role_name="test") # 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() self.connect()
roles = [ roles = [
r r
@ -264,66 +279,66 @@ class KeycloakClient:
] ]
return self.keycloak_admin.delete_user_realm_role(user_id, roles) 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() self.connect()
return self.keycloak_admin.delete_user(user_id=userid) 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() self.connect()
return self.keycloak_admin.get_user_groups(user_id=userid) 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() self.connect()
return self.keycloak_admin.get_realm_roles_of_user(user_id=userid) 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() self.connect()
return self.keycloak_admin.assign_client_role( return self.keycloak_admin.assign_client_role(
client_id=client_id, user_id=user_id, role_id=role_id, role_name="test" client_id=client_id, user_id=user_id, role_id=role_id, role_name="test"
) )
## GROUPS ## GROUPS
def get_all_groups(self): def get_all_groups(self) -> Iterable[Any]:
## RETURNS ONLY MAIN GROUPS WITH NESTED subGroups list ## RETURNS ONLY MAIN GROUPS WITH NESTED subGroups list
self.connect() 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 ## RETURNS ALL GROUPS in root list
self.connect() self.connect()
groups = self.keycloak_admin.get_groups() groups = self.keycloak_admin.get_groups()
return get_recursive_groups(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() self.connect()
return self.keycloak_admin.get_group(group_id=group_id) 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() self.connect()
return self.keycloak_admin.get_group_by_path( return self.keycloak_admin.get_group_by_path(
path=path, search_in_subgroups=recursive 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() self.connect()
if parent != None: if parent:
parent = self.get_group_by_path(parent)["id"] parent = self.get_group_by_path(parent)["id"]
return self.keycloak_admin.create_group({"name": name}, parent=parent) 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() self.connect()
return self.keycloak_admin.delete_group(group_id=group_id) 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() self.connect()
return self.keycloak_admin.group_user_add(user_id, group_id) 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) paths = kpath2kpaths(path)
parent = "/" parent = "/"
for path in paths: for path in paths:
try: try:
parent_path = None if parent == "/" else parent parent_path = "" if parent == "/" else parent
# print("parent: "+str(parent_path)+" path: "+path.split("/")[-1]) # print("parent: "+str(parent_path)+" path: "+path.split("/")[-1])
self.add_group(path.split("/")[-1], parent_path, skip_exists=True) self.add_group(path.split("/")[-1], parent_path, skip_exists=True)
parent = path parent = path
@ -333,8 +348,8 @@ class KeycloakClient:
parent = path parent = path
def add_user_with_groups_and_role( 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 ## Add user
uid = self.add_user(username, first, last, email, password) uid = self.add_user(username, first, last, email, password)
## Add user to role ## Add user to role
@ -348,7 +363,7 @@ class KeycloakClient:
for g in groups: for g in groups:
log.warning("Creating keycloak group: " + g) log.warning("Creating keycloak group: " + g)
parts = g.split("/") parts = g.split("/")
parent_path = None parent_path = ""
for i in range(1, len(parts)): for i in range(1, len(parts)):
# parent_id=None if parent_path==None else self.get_group(parent_path)['id'] # parent_id=None if parent_path==None else self.get_group(parent_path)['id']
try: try:
@ -360,10 +375,7 @@ class KeycloakClient:
+ " already exists. Skipping creation" + " already exists. Skipping creation"
) )
pass pass
if parent_path is None: thepath = parent_path + "/" + parts[i]
thepath = "/" + parts[i]
else:
thepath = parent_path + "/" + parts[i]
if thepath == "/": if thepath == "/":
log.warning( log.warning(
"Not adding the user " "Not adding the user "
@ -385,53 +397,51 @@ class KeycloakClient:
) )
self.keycloak_admin.group_user_add(uid, gid) self.keycloak_admin.group_user_add(uid, gid)
if parent_path == None: parent_path += "/" + parts[i]
parent_path = ""
parent_path = parent_path + "/" + parts[i]
# self.group_user_add(uid,gid) # self.group_user_add(uid,gid)
## ROLES ## ROLES
def get_roles(self): def get_roles(self) -> Iterable[Any]:
self.connect() 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() self.connect()
return self.keycloak_admin.get_realm_role(name) 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() self.connect()
return self.keycloak_admin.create_realm_role( return self.keycloak_admin.create_realm_role(
{"name": name, "description": description} {"name": name, "description": description}
) )
def delete_role(self, name): def delete_role(self, name : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.delete_realm_role(name) return self.keycloak_admin.delete_realm_role(name)
## CLIENTS ## CLIENTS
def get_client_roles(self, client_id): def get_client_roles(self, client_id : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.get_client_roles(client_id=client_id) 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() self.connect()
return self.keycloak_admin.create_client_role( return self.keycloak_admin.create_client_role(
client_id, {"name": name, "description": description, "clientRole": True} client_id, {"name": name, "description": description, "clientRole": True}
) )
## SYSTEM ## SYSTEM
def get_server_info(self): def get_server_info(self) -> Any:
self.connect() self.connect()
return self.keycloak_admin.get_server_info() return self.keycloak_admin.get_server_info()
def get_server_clients(self): def get_server_clients(self) -> Any:
self.connect() self.connect()
return self.keycloak_admin.get_clients() return self.keycloak_admin.get_clients()
def get_server_rsa_key(self): def get_server_rsa_key(self) -> Any:
self.connect() self.connect()
rsa_key = [ rsa_key = [
k for k in self.keycloak_admin.get_keys()["keys"] if k["type"] == "RSA" 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"]} return {"name": rsa_key["kid"], "certificate": rsa_key["certificate"]}
## REALM ## REALM
def assign_realm_roles(self, user_id, role): def assign_realm_roles(self, user_id : str, role : str) -> Any:
self.connect() self.connect()
try: try:
role = [ kcroles = [
r for r in self.keycloak_admin.get_realm_roles() if r["name"] == role r for r in self.keycloak_admin.get_realm_roles() if r["name"] == role
] ]
except: except:
return False 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, roles=kcroles)
# return self.keycloak_admin.assign_realm_roles(user_id=user_id, client_id=None, roles=role)
## CLIENTS ## CLIENTS
def delete_client(self, clientid): def delete_client(self, clientid : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.delete_client(clientid) return self.keycloak_admin.delete_client(clientid)
def add_client(self, client): def add_client(self, client : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.create_client(client) return self.keycloak_admin.create_client(client)

View File

@ -0,0 +1,411 @@
#
# Copyright © 2022 Evilham <contact@evilham.com>
#
# 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 <https://www.gnu.org/licenses/>.
#
# 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}"

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -21,7 +22,6 @@ import logging as log
import os import os
import traceback import traceback
from admin import app
from pprint import pprint from pprint import pprint
from minio import Minio from minio import Minio
@ -29,18 +29,22 @@ from minio.commonconfig import REPLACE, CopySource
from minio.deleteobjects import DeleteObject from minio.deleteobjects import DeleteObject
from requests import get, post 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() return languagefile.read()
def gen_legal_if_not_exists(lang): def gen_legal_if_not_exists(app : "AdminFlaskApp", lang : str) -> None:
if not os.path.isfile(legal_path+lang): if not os.path.isfile(app.legal_path+lang):
log.debug("Creating new language file") 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("<b>Legal</b><br>This is the default legal page for language " + lang) languagefile.write("<b>Legal</b><br>This is the default legal page for language " + lang)
def new_legal(lang,html): def new_legal(app : "AdminFlaskApp", lang : str, html : str) -> None:
with open(legal_path+lang, "w") as languagefile: with open(app.legal_path+lang, "w") as languagefile:
languagefile.write(html) languagefile.write(html)

View File

@ -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 <https://www.gnu.org/licenses/>.
#
# 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

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -23,11 +24,15 @@ from pprint import pprint
from requests import get, post from requests import get, post
from admin import app
from .exceptions import UserExists, UserNotFound from .exceptions import UserExists, UserNotFound
from .postgres import Postgres 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 # 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/dev/Web_service_API_functions
https://docs.moodle.org/311/en/Using_web_services https://docs.moodle.org/311/en/Using_web_services
""" """
key: str
url : str
endpoint : str
verify : bool
moodle_pg : Postgres
def __init__( def __init__(
self, self,
key=app.config["MOODLE_WS_TOKEN"], app : "AdminFlaskApp",
url="https://moodle." + app.config["DOMAIN"], endpoint : str="/webservice/rest/server.php",
endpoint="/webservice/rest/server.php", ) -> None:
verify=app.config["VERIFY"], self.key = app.config["MOODLE_WS_TOKEN"]
): self.url = f"https://moodle.{ app.config['DOMAIN'] }"
self.key = key
self.url = url
self.endpoint = endpoint self.endpoint = endpoint
self.verify = verify self.verify = cast(bool, app.config["VERIFY"])
self.moodle_pg = Postgres( self.moodle_pg = Postgres(
"dd-apps-postgresql", "dd-apps-postgresql",
@ -56,7 +63,7 @@ class Moodle:
app.config["MOODLE_POSTGRES_PASSWORD"], 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 """Transform dictionary/array structure to a flat dictionary, with key names
defining the structure. defining the structure.
Example usage: Example usage:
@ -64,24 +71,23 @@ class Moodle:
{'courses[0][id]':1, {'courses[0][id]':1,
'courses[0][name]':'course1'} 'courses[0][name]':'course1'}
""" """
if out_dict == None: o : Dict[Any, Any] = {} if out_dict is None else out_dict
out_dict = {}
if not type(in_args) in (list, dict): if not type(in_args) in (list, dict):
out_dict[prefix] = in_args o[prefix] = in_args
return out_dict return o
if prefix == "": if prefix == "":
prefix = prefix + "{0}" prefix = prefix + "{0}"
else: else:
prefix = prefix + "[{0}]" prefix = prefix + "[{0}]"
if type(in_args) == list: if type(in_args) == list:
for idx, item in enumerate(in_args): 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: elif type(in_args) == dict:
for key, item in in_args.items(): for key, item in in_args.items():
self.rest_api_parameters(item, prefix.format(key), out_dict) self.rest_api_parameters(item, prefix.format(key), o)
return out_dict 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. """Calls moodle API function with function name fname and keyword arguments.
Example: Example:
>>> call_mdl_function('core_course_update_courses', >>> call_mdl_function('core_course_update_courses',
@ -97,7 +103,7 @@ class Moodle:
raise SystemError(response) raise SystemError(response)
return 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"]): if len(self.get_user_by("username", username)["users"]):
raise UserExists raise UserExists
try: try:
@ -115,7 +121,7 @@ class Moodle:
except SystemError as se: except SystemError as se:
raise SystemError(se.args[0]["message"]) 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] user = self.get_user_by("username", username)["users"][0]
if not len(user): if not len(user):
raise UserNotFound raise UserNotFound
@ -135,15 +141,15 @@ class Moodle:
except SystemError as se: except SystemError as se:
raise SystemError(se.args[0]["message"]) 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]) user = self.call("core_user_delete_users", userids=[user_id])
return user return user
def delete_users(self, userids): def delete_users(self, userids : List[str]) -> Any:
user = self.call("core_user_delete_users", userids=userids) user = self.call("core_user_delete_users", userids=userids)
return user return user
def get_user_by(self, key, value): def get_user_by(self, key : str, value : str) -> Any:
criteria = [{"key": key, "value": value}] criteria = [{"key": key, "value": value}]
try: try:
user = self.call("core_user_get_users", criteria=criteria) user = self.call("core_user_get_users", criteria=criteria)
@ -152,7 +158,7 @@ class Moodle:
return user 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': []} # {'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 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 from mdl_user as u
LEFT JOIN mdl_cohort_members AS hm on hm.userid = u.id LEFT JOIN mdl_cohort_members AS hm on hm.userid = u.id
@ -179,31 +185,31 @@ class Moodle:
# user['roles']=[] # user['roles']=[]
# return users # 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 # 5 is student
data = [{"roleid": role_id, "userid": user_id, "courseid": course_id}] data = [{"roleid": role_id, "userid": user_id, "courseid": course_id}]
enrolment = self.call("enrol_manual_enrol_users", enrolments=data) enrolment = self.call("enrol_manual_enrol_users", enrolments=data)
return enrolment 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( attempts = self.call(
"mod_quiz_get_user_attempts", quizid=quiz_id, userid=user_id "mod_quiz_get_user_attempts", quizid=quiz_id, userid=user_id
) )
return attempts return attempts
def get_cohorts(self): def get_cohorts(self) -> List[Dict[str, Any]]:
cohorts = self.call("core_cohort_get_cohorts") 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): def add_system_cohort(self, name : str, description : str ="", visible : bool=True) -> Any:
visible = 1 if visible else 0 bit_visible = 1 if visible else 0
data = [ data = [
{ {
"categorytype": {"type": "system", "value": ""}, "categorytype": {"type": "system", "value": ""},
"name": name, "name": name,
"idnumber": name, "idnumber": name,
"description": description, "description": description,
"visible": visible, "visible": bit_visible,
} }
] ]
cohort = self.call("core_cohort_create_cohorts", cohorts=data) 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) # user = self.call('core_cohort_add_cohort_members', criteria=criteria)
# return user # return user
def add_user_to_cohort(self, userid, cohortid): def add_user_to_cohort(self, userid : str, cohortid : str) -> Any:
members = [ members = [
{ {
"cohorttype": {"type": "id", "value": cohortid}, "cohorttype": {"type": "id", "value": cohortid},
@ -224,21 +230,21 @@ class Moodle:
user = self.call("core_cohort_add_cohort_members", members=members) user = self.call("core_cohort_add_cohort_members", members=members)
return user 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}] members = [{"cohortid": cohortid, "userid": userid}]
user = self.call("core_cohort_delete_cohort_members", members=members) user = self.call("core_cohort_delete_cohort_members", members=members)
return user 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) members = self.call("core_cohort_get_cohort_members", cohortids=cohort_ids)
# [0]['userids'] # [0]['userids']
return members return members
def delete_cohorts(self, cohortids): def delete_cohorts(self, cohortids : Iterable[str]) -> Any:
deleted = self.call("core_cohort_delete_cohorts", cohortids=cohortids) deleted = self.call("core_cohort_delete_cohorts", cohortids=cohortids)
return deleted return deleted
def get_user_cohorts(self, user_id): def get_user_cohorts(self, user_id : str) -> Any:
user_cohorts = [] user_cohorts = []
cohorts = self.get_cohorts() cohorts = self.get_cohorts()
for cohort in cohorts: for cohort in cohorts:
@ -246,7 +252,7 @@ class Moodle:
user_cohorts.append(cohort) user_cohorts.append(cohort)
return user_cohorts 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'""" q = """SELECT value FROM mdl_config WHERE name='siteadmins'"""
value = self.moodle_pg.select(q)[0][0] value = self.moodle_pg.select(q)[0][0]
if str(user_id) not in value: if str(user_id) not in value:
@ -258,6 +264,7 @@ class Moodle:
log.warning( log.warning(
"MOODLE:ADDING THE USER TO ADMINS: This needs a purge cache in moodle!" "MOODLE:ADDING THE USER TO ADMINS: This needs a purge cache in moodle!"
) )
def unassign_user_rol(self, user_id, role_id): def unassign_user_rol(self, user_id, role_id):
unassignments = [{"roleid": role_id, "userid": user_id, "contextlevel": 'system', "instanceid": 0}] unassignments = [{"roleid": role_id, "userid": user_id, "contextlevel": 'system', "instanceid": 0}]
return self.call("core_role_unassign_roles", unassignments=unassignments) return self.call("core_role_unassign_roles", unassignments=unassignments)

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -18,32 +19,29 @@
# #
# SPDX-License-Identifier: AGPL-3.0-or-later # 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 mysql.connector
import yaml
# from admin import app from typing import List, Tuple
class Mysql: 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( self.conn = mysql.connector.connect(
host=host, database=database, user=user, password=password 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 = self.conn.cursor()
self.cur.execute(sql) self.cur.execute(sql)
data = self.cur.fetchall() data : List[Tuple] = self.cur.fetchall()
self.cur.close() self.cur.close()
return data return data
def update(self, sql): def update(self, sql : str) -> None:
# TODO: Fix this whole method
self.cur = self.conn.cursor() self.cur = self.conn.cursor()
self.cur.execute(sql) self.cur.execute(sql)
self.conn.commit() self.conn.commit()

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -30,21 +31,31 @@ import urllib
import requests import requests
from psycopg2 import sql from psycopg2 import sql
# from ..lib.log import *
from admin import app
from .nextcloud_exc import * from .nextcloud_exc import *
from .postgres import Postgres 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: class Nextcloud:
verify_cert : bool
apiurl : str
shareurl : str
davurl : str
auth : Tuple[str, str]
user : str
nextcloud_pg : Postgres
def __init__( def __init__(
self, self,
url="https://nextcloud." + app.config["DOMAIN"], app : "AdminFlaskApp",
username=os.environ["NEXTCLOUD_ADMIN_USER"], username : str=os.environ["NEXTCLOUD_ADMIN_USER"],
password=os.environ["NEXTCLOUD_ADMIN_PASSWORD"], password : str=os.environ["NEXTCLOUD_ADMIN_PASSWORD"],
verify=True, verify : bool=True,
): ) -> None:
url = "https://nextcloud." + app.config["DOMAIN"]
self.verify_cert = verify self.verify_cert = verify
self.apiurl = url + "/ocs/v1.php/cloud/" self.apiurl = url + "/ocs/v1.php/cloud/"
@ -61,9 +72,9 @@ class Nextcloud:
) )
def _request( def _request(
self, method, url, data={}, headers={"OCS-APIRequest": "true"}, 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 == False: if auth is None:
auth = self.auth auth = self.auth
try: try:
response = requests.request( response = requests.request(
@ -96,7 +107,7 @@ class Nextcloud:
raise ProviderConnError raise ProviderConnError
raise ProviderError raise ProviderError
def check_connection(self): def check_connection(self) -> bool:
url = self.apiurl + "users/" + self.user + "?format=json" url = self.apiurl + "users/" + self.user + "?format=json"
try: try:
result = self._request("GET", url) result = self._request("GET", url)
@ -118,7 +129,7 @@ class Nextcloud:
raise ProviderConnError raise ProviderConnError
raise ProviderError raise ProviderError
def get_user(self, userid): def get_user(self, userid : str) -> Any:
url = self.apiurl + "users/" + userid + "?format=json" url = self.apiurl + "users/" + userid + "?format=json"
try: try:
result = json.loads(self._request("GET", url)) 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]
# 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] # 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] # 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 # 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 # from oc_users as u
# left join oc_group_user as gu on gu.uid = u.uid # left join oc_group_user as gu on gu.uid = u.uid
@ -200,9 +211,10 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
# raise # raise
# TODO: Improve typing of these functions...
def add_user( 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 = { data = {
"userid": userid, "userid": userid,
"password": userpassword, "password": userpassword,
@ -247,7 +259,7 @@ class Nextcloud:
# 106 - no group specified (required for subadmins) # 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) # 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} # key_values={'quota':quota,'email':email,'displayname':displayname}
url = self.apiurl + "users/" + userid + "?format=json" url = self.apiurl + "users/" + userid + "?format=json"
@ -262,6 +274,8 @@ class Nextcloud:
result = json.loads( result = json.loads(
self._request("PUT", url, data=data, headers=headers) 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: if result["ocs"]["meta"]["statuscode"] == 100:
return True return True
if result["ocs"]["meta"]["statuscode"] == 102: if result["ocs"]["meta"]["statuscode"] == 102:
@ -273,8 +287,9 @@ class Nextcloud:
except: except:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise 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} data = {"groupid": group_id}
url = self.apiurl + "users/" + userid + "/groups?format=json" url = self.apiurl + "users/" + userid + "/groups?format=json"
@ -296,7 +311,7 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise 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} data = {"groupid": group_id}
url = self.apiurl + "users/" + userid + "/groups?format=json" url = self.apiurl + "users/" + userid + "/groups?format=json"
@ -312,18 +327,21 @@ class Nextcloud:
return True return True
if result["ocs"]["meta"]["statuscode"] == 102: if result["ocs"]["meta"]["statuscode"] == 102:
raise ProviderItemExists raise ProviderItemExists
if result["ocs"]["meta"]["statuscode"] == 104: # TODO: It is unclear what status code 104 is, it certainly
self.add_group(group) # shouldn't the group if it doesn't exist
# raise ProviderGroupNotExists #if result["ocs"]["meta"]["statuscode"] == 104:
# self.add_group(group)
# # raise ProviderGroupNotExists
log.error("Get Nextcloud provider user add error: " + str(result)) log.error("Get Nextcloud provider user add error: " + str(result))
raise ProviderOpError raise ProviderOpError
except: except:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise raise
# TODO: Improve typing of these functions...
def add_user_with_groups( 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 = { data = {
"userid": userid, "userid": userid,
"password": userpassword, "password": userpassword,
@ -352,7 +370,7 @@ class Nextcloud:
raise ProviderItemExists raise ProviderItemExists
if result["ocs"]["meta"]["statuscode"] == 104: if result["ocs"]["meta"]["statuscode"] == 104:
# self.add_group(group) # self.add_group(group)
None pass
# raise ProviderGroupNotExists # raise ProviderGroupNotExists
log.error("Get Nextcloud provider user add error: " + str(result)) log.error("Get Nextcloud provider user add error: " + str(result))
raise ProviderOpError raise ProviderOpError
@ -368,7 +386,7 @@ class Nextcloud:
# 106 - no group specified (required for subadmins) # 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) # 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" url = self.apiurl + "users/" + userid + "?format=json"
try: try:
result = json.loads(self._request("DELETE", url)) result = json.loads(self._request("DELETE", url))
@ -384,13 +402,13 @@ class Nextcloud:
# 100 - successful # 100 - successful
# 101 - failure # 101 - failure
def enable_user(self, userid): def enable_user(self, userid : str) -> None:
None pass
def disable_user(self, userid): def disable_user(self, userid : str) -> None:
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) auth = (userid, userpassword)
url = self.davurl + userid + "/" + folder + "?format=json" url = self.davurl + userid + "/" + folder + "?format=json"
headers = { headers = {
@ -407,7 +425,7 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise 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) auth = (userid, userpassword)
url = self.davurl + userid + "/" + folder + "?format=json" url = self.davurl + userid + "/" + folder + "?format=json"
headers = { headers = {
@ -429,7 +447,7 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise 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) auth = (userid, userpassword)
url = self.shareurl + "shares?format=json" url = self.shareurl + "shares?format=json"
headers = { headers = {
@ -449,7 +467,7 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise 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) auth = (userid, userpassword)
data = {"path": "/" + folder, "shareType": 3} data = {"path": "/" + folder, "shareType": 3}
url = self.shareurl + "shares?format=json" url = self.shareurl + "shares?format=json"
@ -477,10 +495,10 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise raise
def get_group(self, userid): def get_group(self, userid : str) -> None:
None pass
def get_groups_list(self): def get_groups_list(self) -> List[Any]:
url = self.apiurl + "groups?format=json" url = self.apiurl + "groups?format=json"
try: try:
result = json.loads(self._request("GET", url)) result = json.loads(self._request("GET", url))
@ -491,7 +509,7 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise raise
def add_group(self, groupid): def add_group(self, groupid : str) -> bool:
data = {"groupid": groupid} data = {"groupid": groupid}
url = self.apiurl + "groups?format=json" url = self.apiurl + "groups?format=json"
headers = { headers = {
@ -515,7 +533,7 @@ class Nextcloud:
# 102 - group already exists # 102 - group already exists
# 103 - failed to add the group # 103 - failed to add the group
def delete_group(self, groupid): def delete_group(self, groupid : str) -> bool:
group = urllib.parse.quote(groupid, safe="") group = urllib.parse.quote(groupid, safe="")
url = self.apiurl + "groups/" + group + "?format=json" url = self.apiurl + "groups/" + group + "?format=json"
headers = { headers = {
@ -538,7 +556,7 @@ class Nextcloud:
# 102 - group already exists # 102 - group already exists
# 103 - failed to add the group # 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'""" query = """SELECT * FROM "oc_mail_accounts" WHERE "email" = '%s'"""
sql_query = sql.SQL(query.format(data["email"])) sql_query = sql.SQL(query.format(data["email"]))
if not len(self.nextcloud_pg.select(sql_query)): if not len(self.nextcloud_pg.select(sql_query)):

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -46,6 +47,10 @@ class ProviderItemNotExists(Exception):
pass pass
class ProviderUserNotExists(Exception):
pass
class ProviderGroupNotExists(Exception): class ProviderGroupNotExists(Exception):
pass pass

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -18,54 +19,41 @@
# #
# SPDX-License-Identifier: AGPL-3.0-or-later # 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 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: 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( self.conn = psycopg2.connect(
host=host, database=database, user=user, password=password host=host, database=database, user=user, password=password
) )
# def __del__(self): def select(self, sql: query) -> List[Tuple[Any, ...]]:
# self.cur.close()
# self.conn.close()
def select(self, sql):
self.cur = self.conn.cursor() self.cur = self.conn.cursor()
self.cur.execute(sql) self.cur.execute(sql)
data = self.cur.fetchall() data = self.cur.fetchall()
self.cur.close() self.cur.close() # type: ignore # psycopg2 type hint missing
return data return data
def update(self, sql): def update(self, sql : query) -> None:
self.cur = self.conn.cursor() self.cur = self.conn.cursor()
self.cur.execute(sql) self.cur.execute(sql)
self.conn.commit() self.conn.commit()
self.cur.close() self.cur.close() # type: ignore # psycopg2 type hint missing
# return self.cur.fetchall() # 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 = self.conn.cursor()
self.cur.execute(sql) self.cur.execute(sql)
data = self.cur.fetchall() data = self.cur.fetchall()
fields = [a.name for a in self.cur.description] fields = [a.name for a in self.cur.description]
self.cur.close() self.cur.close() # type: ignore # psycopg2 type hint missing
return (fields, data) return (fields, data)
# def update_moodle_saml_plugin(self):
# plugin[('idpmetadata', '<md:EntitiesDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Name="urn:keycloak"><md:EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="https://sso.'+app.config['DOMAIN']+'/auth/realms/master"><md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><md:KeyDescriptor use="signing"><ds:KeyInfo><ds:KeyName>NrtA5ynG0htowP3SXw7dBJRIAMxn-1PwuuXwOwNhlRw</ds:KeyName><ds:X509Data><ds:X509Certificate>MIICmzCCAYMCBgF5jb0RCTANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjEwNTIxMDcwMjI4WhcNMzEwNTIxMDcwNDA4WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCI8xh/C0+frz3kgWiUbziTDls71R2YiXLSVE+bw7gbEgZUGCLhoEI679azMtIxmnzM/snIX+yTb12+XoYkgbiLTMPQfnH+Kiab6g3HL3KPfhqS+yWkFxOoCp6Ibmp7yPlVWuHH+MBfO8OBr/r8Ao7heFbuzjiLd1KG67rcoaxfDgMuBoEomg1bgEjFgHaQIrSC6OZzH0h987/arqufZXeXlfyiqScMPUi+u5IpDWSwz06UKP0k8mxzNSlpZ93CKOUSsV0SMLxqg7FQ3SGiOk577bGW9o9BDTkkmSo3Up6smc0LzwvvUwuNd0B1irGkWZFQN9OXJnJYf1InEebIMtmPAgMBAAEwDQYJKoZIhvcNAQELBQADggEBADM34+qEGeBQ22luphVTuVJtGxcbxLx7DfsT0QfJD/OuxTTbNAa1VRyarb5juIAkqdj4y2quZna9ZXLecVo4RkwpzPoKoAkYA8b+kHnWqEwJi9iPrDvKb+GR0bBkLPN49YxIZ8IdKX/PRa3yuLHe+loiNsCaS/2ZK2KO46COsqU4QX1iVhF9kWphNLybjNAX45B6cJLsa1g0vXLdm3kv3SB4I2fErFVaOoDtFIjttoYlXdpUiThkPXBfr7N67P3dZHaS4tjJh+IZ8I6TINpcsH8dBkUhzYEIPHCePwSiC1w6WDBLNDuKt1mj1CZrLq+1x+Yhrs+QNRheEKGi89HZ8N0=</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor><md:ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml/resolve" index="0"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/></md:IDPSSODescriptor></md:EntityDescriptor></md:EntitiesDescriptor>')]
# 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!")

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -23,8 +24,6 @@ import logging as log
import os import os
import random import random
# from .keycloak import Keycloak
# from .moodle import Moodle
import string import string
import time import time
import traceback import traceback
@ -33,18 +32,21 @@ from datetime import datetime, timedelta
import psycopg2 import psycopg2
import yaml import yaml
from admin import app from typing import TYPE_CHECKING
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
from .postgres import Postgres from .postgres import Postgres
class Postup: class Postup:
def __init__(self): def __init__(self, app: "AdminFlaskApp") -> None:
ready = False ready = False
while not ready: while not ready:
try: try:
self.pg = Postgres( self.pg = Postgres(
"isard-apps-postgresql", "dd-apps-postgresql",
"moodle", "moodle",
app.config["MOODLE_POSTGRES_USER"], app.config["MOODLE_POSTGRES_USER"],
app.config["MOODLE_POSTGRES_PASSWORD"], app.config["MOODLE_POSTGRES_PASSWORD"],
@ -93,9 +95,9 @@ class Postup:
self.select_and_configure_theme() self.select_and_configure_theme()
self.configure_tipnc() 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: try:
self.pg.update( self.pg.update(
"""UPDATE "mdl_config" SET value = '%s' WHERE "name" = 'theme';""" """UPDATE "mdl_config" SET value = '%s' WHERE "name" = 'theme';"""
@ -104,7 +106,6 @@ class Postup:
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
exit(1) exit(1)
None
try: try:
self.pg.update( self.pg.update(
@ -127,9 +128,8 @@ class Postup:
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
exit(1) exit(1)
None
def configure_tipnc(self): def configure_tipnc(self) -> None:
try: try:
self.pg.update( self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = '%s' WHERE "plugin" = 'assignsubmission_tipnc' AND "name" = 'host';""" """UPDATE "mdl_config_plugins" SET value = '%s' WHERE "plugin" = 'assignsubmission_tipnc' AND "name" = 'host';"""
@ -155,9 +155,8 @@ class Postup:
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
exit(1) exit(1)
None
def add_moodle_ws_token(self): def add_moodle_ws_token(self, app: "AdminFlaskApp") -> None:
try: try:
token = self.pg.select( token = self.pg.select(
"""SELECT * FROM "mdl_external_tokens" WHERE "externalserviceid" = 3""" """SELECT * FROM "mdl_external_tokens" WHERE "externalserviceid" = 3"""
@ -166,7 +165,7 @@ class Postup:
return return
except: except:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
None pass
try: try:
self.pg.update( self.pg.update(
@ -226,4 +225,3 @@ class Postup:
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
exit(1) exit(1)
None

View File

@ -2,5 +2,6 @@
"dependencies": { "dependencies": {
"gentelella": "^1.4.0", "gentelella": "^1.4.0",
"socket.io": "^4.1.3" "socket.io": "^4.1.3"
} },
"license": "AGPL-3.0-or-later"
} }

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -19,6 +20,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
import json import json
import logging as log import logging as log
from operator import itemgetter
import os import os
import socket import socket
import sys import sys
@ -27,302 +29,308 @@ import traceback
from flask import request 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 ..lib.api_exceptions import Error
from .decorators import has_token from .decorators import has_token, OptionalJsonResponse
## LISTS def setup_api_views(app : "AdminFlaskApp") -> None:
@app.route("/ddapi/users", methods=["GET"]) ## LISTS
@has_token @app.json_route("/ddapi/users", methods=["GET"])
def ddapi_users(): @has_token
if request.method == "GET": def ddapi_users() -> OptionalJsonResponse:
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) if request.method == "GET":
users = [] sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
for user in sorted_users: users = []
users.append(user_parser(user)) for user in sorted_users:
return json.dumps(users), 200, {"Content-Type": "application/json"} 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"]) @app.json_route("/ddapi/groups", methods=["GET"])
@has_token @has_token
def ddapi_users_search(): def ddapi_groups() -> OptionalJsonResponse:
if request.method == "POST": if request.method == "GET":
data = request.get_json(force=True) sorted_groups = sorted(app.admin.get_mix_groups(), key=itemgetter("name"))
if not data.get("text"): groups = []
raise Error("bad_request", "Incorrect data requested.") for group in sorted_groups:
users = app.admin.get_mix_users() groups.append(group_parser(group))
result = [user_parser(user) for user in filter_users(users, data["text"])] return json.dumps(groups), 200, {"Content-Type": "application/json"}
sorted_result = sorted(result, key=lambda k: k["id"]) return None
return json.dumps(sorted_result), 200, {"Content-Type": "application/json"}
@app.json_route("/ddapi/group/users", methods=["POST"])
@app.route("/ddapi/groups", methods=["GET"]) @has_token
@has_token def ddapi_group_users() -> OptionalJsonResponse:
def ddapi_groups(): if request.method == "POST":
if request.method == "GET": data = request.get_json(force=True)
sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: k["name"]) sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
groups = [] if data.get("id"):
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]
group_users = [ group_users = [
user_parser(user) user_parser(user)
for user in sorted_users for user in sorted_users
if name in user["keycloak_groups"] if data.get("id") in user["keycloak_groups"]
] ]
except: elif data.get("path"):
raise Error("not_found", "Group path not found in system") try:
elif data.get("keycloak_id"): name = [
try: g["name"]
name = [ for g in app.admin.get_mix_groups()
g["name"] if g["path"] == data.get("path")
for g in app.admin.get_mix_groups() ][0]
if g["id"] == data.get("keycloak_id") group_users = [
][0] user_parser(user)
group_users = [ for user in sorted_users
user_parser(user) if name in user["keycloak_groups"]
for user in sorted_users ]
if name in user["keycloak_groups"] except:
] raise Error("not_found", "Group path not found in system")
except: elif data.get("keycloak_id"):
raise Error("not_found", "Group keycloak_id not found in system") try:
else: name = [
raise Error("bad_request", "Incorrect data requested.") g["name"]
return json.dumps(group_users), 200, {"Content-Type": "application/json"} 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"]) @app.json_route("/ddapi/role/users", methods=["POST"])
@has_token @has_token
def ddapi_roles(): def ddapi_role_users() -> OptionalJsonResponse:
if request.method == "GET": if request.method == "POST":
roles = [] data = request.get_json(force=True)
for role in sorted(app.admin.get_roles(), key=lambda k: k["name"]): sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
log.error(role) if data.get("id", data.get("name")):
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]
role_users = [ 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: elif data.get("keycloak_id"):
raise Error("not_found", "Role keycloak_id not found in system") try:
else: id = [
raise Error("bad_request", "Incorrect data requested.") r["id"]
return json.dumps(role_users), 200, {"Content-Type": "application/json"} 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
## INDIVIDUAL ACTIONS @app.json_route("/ddapi/user", methods=["POST"])
@app.route("/ddapi/user", methods=["POST"]) @app.json_route("/ddapi/user/<user_ddid>", methods=["PUT", "GET", "DELETE"])
@app.route("/ddapi/user/<user_ddid>", methods=["PUT", "GET", "DELETE"]) @has_token
@has_token def ddapi_user(user_ddid : Optional[str]=None) -> OptionalJsonResponse:
def ddapi_user(user_ddid=None): uid : str = user_ddid if user_ddid else ''
if request.method == "GET": if request.method == "GET":
user = app.admin.get_user_username(user_ddid) user = app.admin.get_user_username(uid)
if not user: if not user:
raise Error("not_found", "User id not found") raise Error("not_found", "User id not found")
return json.dumps(user_parser(user)), 200, {"Content-Type": "application/json"} return json.dumps(user_parser(user)), 200, {"Content-Type": "application/json"}
if request.method == "DELETE": if request.method == "DELETE":
user = app.admin.get_user_username(user_ddid) user = app.admin.get_user_username(uid)
if not user: if not user:
raise Error("not_found", "User id not found") raise Error("not_found", "User id not found")
app.admin.delete_user(user["id"]) app.admin.delete_user(user["id"])
return json.dumps({}), 200, {"Content-Type": "application/json"} return json.dumps({}), 200, {"Content-Type": "application/json"}
if request.method == "POST": if request.method == "POST":
data = request.get_json(force=True) data = request.get_json(force=True)
if not app.validators["user"].validate(data): 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/<old_user_ddid>/<new_user_did>", 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/<id>", methods=["GET", "POST", "DELETE"])
# @app.route("/api/group/<group_id>", 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/<id>", 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):
raise Error( raise Error(
"bad_request", "bad_request",
"Data validation for mail failed: " "Data validation for user failed: "
+ str(app.validators["mail"].errors), + str(app.validators["user"].errors),
traceback.format_exc(), 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/<old_user_ddid>/<new_user_did>", 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/<group_id>", methods=["GET", "POST", "DELETE"])
# @app.json_route("/api/group/<group_id>", 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/<id>", 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 { return {
"keycloak_id": user["id"], "keycloak_id": user["id"],
"id": user["username"], "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 { return {
"keycloak_id": group["id"], "keycloak_id": group["id"],
"id": group["name"], "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 [ return [
user user
for user in users for user in users

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -20,6 +21,7 @@
import concurrent.futures import concurrent.futures
import json import json
import logging as log import logging as log
from operator import itemgetter
import os import os
import re import re
import sys import sys
@ -33,12 +35,15 @@ from uuid import uuid4
from flask import Response, jsonify, redirect, render_template, request, url_for from flask import Response, jsonify, redirect, render_template, request, url_for
from flask_login import current_user, login_required 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 ..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() # q = Queue.Queue()
from keycloak.exceptions import KeycloakGetError from keycloak.exceptions import KeycloakGetError
@ -46,536 +51,444 @@ from keycloak.exceptions import KeycloakGetError
from ..lib.dashboard import Dashboard from ..lib.dashboard import Dashboard
from ..lib.exceptions import UserExists, UserNotFound from ..lib.exceptions import UserExists, UserNotFound
dashboard = Dashboard()
from ..lib.legal import get_legal, gen_legal_if_not_exists, new_legal from ..lib.legal import get_legal, gen_legal_if_not_exists, new_legal
@app.route("/sysadmin/api/resync") def run_in_thread(
@app.route("/api/resync") op : Callable[..., Any],
@login_required args : Tuple = tuple(),
def resync(): err_msg : str = "Something went wrong",
return ( err_code : int = 500,
json.dumps(app.admin.resync_data()), busy_err_msg : str ="Precondition failed: already operating users"
200, ) -> OptionalJsonResponse:
{"Content-Type": "application/json"}, if threads.get("external", None) is not None:
) if threads["external"].is_alive():
@app.route("/api/users", methods=["GET", "PUT"])
@app.route("/api/users/<provider>", 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":
return ( return (
json.dumps(app.admin.delete_keycloak_users()), json.dumps(
200, {"msg": busy_err_msg}
),
412,
{"Content-Type": "application/json"}, {"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/<action>", 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/<userid>", 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/<userid>", 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/<group_id>", 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: else:
if not group_id: del threads["external"]
return ( try:
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/<provider>", 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)
threads["external"] = threading.Thread( 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() threads["external"].start()
return json.dumps({}), 200, {"Content-Type": "application/json"} return json.dumps({}), 200, {"Content-Type": "application/json"}
if request.method == "DELETE": except:
print("RESET") log.error(traceback.format_exc())
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":
return ( 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, 200,
{"Content-Type": "application/json"}, {"Content-Type": "application/json"},
) )
def check_upload_errors(data): @app.json_route("/api/users", methods=["GET", "PUT"])
email_regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b" @app.json_route("/api/users/<provider>", methods=["POST", "PUT", "GET", "DELETE"])
for u in data["data"]: @login_or_token
try: def users(provider : bool=False) -> OptionalJsonResponse:
user_groups = [g.strip() for g in u["groups"].split(",")] if request.method == "DELETE":
except: if current_user.role != "admin":
resp = { return json.dumps({}), 301, {"Content-Type": "application/json"}
"pass": False, if provider == "keycloak":
"msg": "User " + u["username"] + " has invalid groups: " + u["groups"], return (
} json.dumps(app.admin.delete_keycloak_users()),
log.error(resp) 200,
return resp {"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"]): return run_in_thread(app.admin.update_users_from_keycloak, err_msg="Add user error.")
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"]: users = app.admin.get_mix_users()
if u["role"] == "": 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/<action>", 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/<userid>", 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/<userid>", 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/<group_id>", 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/<provider>", 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 = { resp = {
"pass": False, "pass": False,
"msg": "User " + u["username"] + " has no role assigned!", "msg": "User " + u["username"] + " has invalid groups: " + u["groups"],
} }
log.error(resp) log.error(resp)
return resp return resp
resp = {
"pass": False, if not re.fullmatch(email_regex, u["email"]):
"msg": "User " + u["username"] + " has invalid role: " + u["role"], resp = {
} "pass": False,
log.error(resp) "msg": "User " + u["username"] + " has invalid email: " + u["email"],
return resp }
return {"pass": True, "msg": ""} 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/<item>", methods=["PUT"]) @app.json_route("/api/dashboard/<item>", methods=["PUT"])
@login_required @login_required
def dashboard_put(item): def dashboard_put(item : str) -> OptionalJsonResponse:
if item == "colours": 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/<item>", 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": "<b>Privacy policy</b><br>This works!"}), 200, {'Content-Type': 'application/json'}
@app.route("/api/legal/<item>", methods=["POST"])
@login_required
def legal_put(item):
if request.method == "POST":
if item == "legal":
data = None
try: try:
data = data = request.get_json(force=True) data = request.get_json(force=True)
html = data["html"] dashboard.update_colours(data)
lang = data["lang"] except:
if not lang or lang not in ["ca","es","en","fr"]: log.error(traceback.format_exc())
lang="ca" return json.dumps({"colours": data}), 200, {"Content-Type": "application/json"}
new_legal(lang,html) if item == "menu":
try:
data = request.get_json(force=True)
dashboard.update_menu(data)
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
return json.dumps(data), 200, {"Content-Type": "application/json"} return json.dumps(data), 200, {"Content-Type": "application/json"}
# if item == "privacy": if item == "logo":
# data = None dashboard.update_logo(request.files["croppedImage"])
# try: return json.dumps({}), 200, {"Content-Type": "application/json"}
# data = request.json if item == "background":
# html = data["html"] dashboard.update_background(request.files["croppedImage"])
# lang = data["lang"] return json.dumps({}), 200, {"Content-Type": "application/json"}
# except: return (
# log.error(traceback.format_exc()) json.dumps(
# return json.dumps(data), 200, {'Content-Type': 'application/json'} {
"error": "update_error",
"msg": "Error updating item " + item + "\n" + traceback.format_exc(),
}
),
500,
{"Content-Type": "application/json"},
)
@app.json_route("/api/legal/<item>", 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": "<b>Privacy policy</b><br>This works!"}), 200, {'Content-Type': 'application/json'}
return None
@app.json_route("/api/legal/<item>", 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'}

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -21,41 +22,44 @@ import os
from flask import flash, redirect, render_template, request, url_for from flask import flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required, login_user, logout_user 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 * from ..auth.authentication import *
@app.route("/", methods=["GET", "POST"]) def setup_login_views(app : "AdminFlaskApp") -> None:
@app.route("/login", methods=["GET", "POST"]) @app.route("/", methods=["GET", "POST"])
def login(): @app.route("/login", methods=["GET", "POST"])
if request.method == "POST": def login() -> Response:
if request.form["user"] == "" or request.form["password"] == "": if request.method == "POST":
flash("Can't leave it blank", "danger") if request.form["user"] == "" or request.form["password"] == "":
elif request.form["user"].startswith(" "): flash("Can't leave it blank", "danger")
flash("Username not found or incorrect password.", "warning") elif request.form["user"].startswith(" "):
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") 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"]) @app.route("/logout", methods=["GET"])
@login_required @login_required
def logout(): def logout() -> Response:
logout_user() logout_user()
return redirect(url_for("login")) return redirect(url_for("login"))

View File

@ -0,0 +1,101 @@
#
# Copyright © 2022 MaadiX
# Copyright © 2022 Evilham <contact@evilham.com>
#
# 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 <https://www.gnu.org/licenses/>.
#
# 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(),
)

View File

@ -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 <https://www.gnu.org/licenses/>.
#
# 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

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -34,134 +35,110 @@ from flask import (
jsonify, jsonify,
redirect, redirect,
request, request,
Response,
send_file, send_file,
url_for, url_for,
) )
from flask import render_template as render_template_flask from flask import render_template as render_template_flask
from flask_login import login_required 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 from .decorators import is_admin
avatars = Avatars()
from ..lib.legal import gen_legal_if_not_exists from ..lib.legal import gen_legal_if_not_exists
""" OIDC TESTS """
# from ..auth.authentication import oidc
# @app.route('/custom_callback') def render_template(*args : str, **kwargs : str) -> str:
# @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)! <a href="/">Return</a>' %
# (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! <a href="/">Return</a>'
""" OIDC TESTS """
def render_template(*args, **kwargs):
kwargs["DOMAIN"] = os.environ["DOMAIN"] kwargs["DOMAIN"] = os.environ["DOMAIN"]
return render_template_flask(*args, **kwargs) return render_template_flask(*args, **kwargs)
@app.route("/users") def setup_web_views(app : "AdminFlaskApp") -> None:
@login_required @app.route("/users")
def web_users(): @login_required
return render_template("pages/users.html", title="Users", nav="Users") def web_users() -> str:
return render_template("pages/users.html", title="Users", nav="Users")
@app.route("/roles") @app.route("/roles")
@login_required @login_required
def web_roles(): def web_roles() -> str:
return render_template("pages/roles.html", title="Roles", nav="Roles") return render_template("pages/roles.html", title="Roles", nav="Roles")
@app.route("/groups") @app.route("/groups")
@login_required @login_required
def web_groups(provider=False): def web_groups(provider : bool=False) -> str:
return render_template("pages/groups.html", title="Groups", nav="Groups") return render_template("pages/groups.html", title="Groups", nav="Groups")
@app.route("/avatar/<userid>", methods=["GET"]) @app.route("/avatar/<userid>", methods=["GET"])
@login_required @login_required
def avatar(userid): def avatar(userid : str) -> Response:
if userid != "false": if userid != "false":
return send_file("../avatars/master-avatars/" + userid, mimetype="image/jpeg") return send_file("../avatars/master-avatars/" + userid, mimetype="image/jpeg")
return send_file("static/img/missing.jpg", mimetype="image/jpeg") return send_file("static/img/missing.jpg", mimetype="image/jpeg")
@app.route("/dashboard") @app.route("/dashboard")
@login_required @login_required
def dashboard(provider=False): def dashboard(provider : bool=False) -> str:
data = json.loads(requests.get("http://dd-sso-api/json").text) data = json.loads(requests.get("http://dd-sso-api/json").text)
return render_template( return render_template(
"pages/dashboard.html", title="Customization", nav="Customization", data=data "pages/dashboard.html", title="Customization", nav="Customization", data=data
) )
@app.route("/legal") @app.route("/legal")
@login_required @login_required
def legal(): def legal() -> str:
# data = json.loads(requests.get("http://dd-sso-api/json").text) # data = json.loads(requests.get("http://dd-sso-api/json").text)
return render_template("pages/legal.html", title="Legal", nav="Legal", data={}) return render_template("pages/legal.html", title="Legal", nav="Legal", data="")
@app.route("/legal_text") @app.route("/legal_text")
def legal_text(): def legal_text() -> str:
lang = request.args.get("lang") lang = request.args.get("lang")
if not lang or lang not in ["ca","es","en","fr"]: if not lang or lang not in ["ca","es","en","fr"]:
lang="ca" lang="ca"
gen_legal_if_not_exists(lang) gen_legal_if_not_exists(app, lang)
return render_template("pages/legal/"+lang) return render_template("pages/legal/"+lang)
### SYS ADMIN ### SYS ADMIN
@app.route("/sysadmin/users") @app.route("/sysadmin/users")
@login_required @login_required
@is_admin @is_admin
def web_sysadmin_users(): def web_sysadmin_users() -> Response:
return render_template( o : Response = app.make_response(render_template(
"pages/sysadmin/users.html", title="SysAdmin Users", nav="SysAdminUsers" "pages/sysadmin/users.html", title="SysAdmin Users", nav="SysAdminUsers"
) ))
return o
@app.route("/sysadmin/groups") @app.route("/sysadmin/groups")
@login_required @login_required
@is_admin @is_admin
def web_sysadmin_groups(): def web_sysadmin_groups() -> Response:
return render_template( o : Response = app.make_response(render_template(
"pages/sysadmin/groups.html", title="SysAdmin Groups", nav="SysAdminGroups" "pages/sysadmin/groups.html", title="SysAdmin Groups", nav="SysAdminGroups"
) ))
return o
@app.route("/sysadmin/external") @app.route("/sysadmin/external")
@login_required @login_required
## SysAdmin role ## SysAdmin role
def web_sysadmin_external(): def web_sysadmin_external() -> str:
return render_template( return render_template(
"pages/sysadmin/external.html", title="External", nav="External" "pages/sysadmin/external.html", title="External", nav="External"
) )
@app.route("/sockettest") @app.route("/sockettest")
def web_sockettest(): def web_sockettest() -> str:
return render_template( return render_template(
"pages/sockettest.html", title="Sockettest Users", nav="SysAdminUsers" "pages/sockettest.html", title="Sockettest Users", nav="SysAdminUsers"
) )

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -20,6 +21,7 @@
import json import json
import logging as log import logging as log
from operator import itemgetter
import os import os
import socket import socket
import sys import sys
@ -28,113 +30,117 @@ import traceback
from flask import request 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"]) @app.json_route("/api/internal/users/filter", methods=["POST"])
@is_internal @is_internal
def internal_users_search(): def internal_users_search() -> OptionalJsonResponse:
if request.method == "POST": if request.method == "POST":
data = request.get_json(force=True) data = request.get_json(force=True)
users = app.admin.get_mix_users() 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"] != "":
result = [user_parser(user) for user in filter_users(users, data["text"])] result = [user_parser(user) for user in filter_users(users, data["text"])]
else: sorted_result = sorted(result, key=itemgetter("id"))
result = [user_parser(user) for user in users] return json.dumps(sorted_result), 200, {"Content-Type": "application/json"}
return json.dumps(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"]) @app.json_route("/api/internal/group/users", methods=["POST"])
@is_internal @is_internal
def internal_roles(): def internal_group_users() -> OptionalJsonResponse:
if request.method == "GET": if request.method == "POST":
roles = [] data = request.get_json(force=True)
for role in sorted(app.admin.get_roles(), key=lambda k: k["name"]): sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
if role["name"] == "admin": # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
continue users = []
roles.append( for user in sorted_users:
{ if data["path"] not in user["keycloak_groups"] or not user.get("enabled"):
"id": role["id"], continue
"name": role["name"], users.append(user)
"description": role.get("description", ""), if data.get("text", False) and data["text"] != "":
} result = [user_parser(user) for user in filter_users(users, data["text"])]
) else:
return json.dumps(roles), 200, {"Content-Type": "application/json"} 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"]) @app.json_route("/api/internal/role/users", methods=["POST"])
@is_internal @is_internal
def internal_role_users(): def internal_role_users() -> OptionalJsonResponse:
if request.method == "POST": if request.method == "POST":
data = request.get_json(force=True) data = request.get_json(force=True)
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) 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']] # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
users = [] users = []
for user in sorted_users: for user in sorted_users:
if data["role"] not in user["roles"] or not user.get("enabled"): if data["role"] not in user["roles"] or not user.get("enabled"):
continue continue
users.append(user) users.append(user)
if data.get("text", False) and data["text"] != "": if data.get("text", False) and data["text"] != "":
result = [user_parser(user) for user in filter_users(users, data["text"])] result = [user_parser(user) for user in filter_users(users, data["text"])]
else: else:
result = [user_parser(user) for user in users] result = [user_parser(user) for user in users]
return json.dumps(result), 200, {"Content-Type": "application/json"} return json.dumps(result), 200, {"Content-Type": "application/json"}
return None
def user_parser(user : Dict[str, Any]) -> Dict[str, Any]:
def user_parser(user):
return { return {
"id": user["username"], "id": user["username"],
"first": user["first"], "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 [ return [
user user
for user in users for user in users

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -25,25 +26,28 @@ import socket
from functools import wraps from functools import wraps
from flask import redirect, request, url_for from flask import redirect, request, url_for
from werkzeug.wrappers import Response
from flask_login import current_user, logout_user from flask_login import current_user, logout_user
from jose import jwt from jose import jwt
from ..auth.tokens import get_header_jwt_payload 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) @wraps(fn)
def decorated_view(*args, **kwargs): def decorated_view(*args : Any, **kwargs : Any) -> Response:
if current_user.role == "admin": if current_user.role == "admin":
return fn(*args, **kwargs) return fn(*args, **kwargs)
return redirect(url_for("login")) return redirect(url_for("login"))
return decorated_view return decorated_view
def is_internal(fn : Callable[..., OptionalJsonResponse]) -> Callable[..., OptionalJsonResponse]:
def is_internal(fn):
@wraps(fn) @wraps(fn)
def decorated_view(*args, **kwargs): def decorated_view(*args : Any, **kwargs : Any) -> OptionalJsonResponse:
remote_addr = ( remote_addr = (
request.headers["X-Forwarded-For"].split(",")[0] request.headers["X-Forwarded-For"].split(",")[0]
if "X-Forwarded-For" in request.headers if "X-Forwarded-For" in request.headers
@ -67,18 +71,18 @@ def is_internal(fn):
return decorated_view return decorated_view
def has_token(fn): def has_token(fn : Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn) @wraps(fn)
def decorated(*args, **kwargs): def decorated(*args : Any, **kwargs : Any) -> Any:
payload = get_header_jwt_payload() payload = get_header_jwt_payload()
return fn(*args, **kwargs) return fn(*args, **kwargs)
return decorated return decorated
def is_internal_or_has_token(fn): def is_internal_or_has_token(fn : Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn) @wraps(fn)
def decorated_view(*args, **kwargs): def decorated_view(*args : Any, **kwargs : Any) -> Any:
remote_addr = ( remote_addr = (
request.headers["X-Forwarded-For"].split(",")[0] request.headers["X-Forwarded-For"].split(",")[0]
if "X-Forwarded-For" in request.headers if "X-Forwarded-For" in request.headers
@ -94,9 +98,9 @@ def is_internal_or_has_token(fn):
return decorated_view return decorated_view
def login_or_token(fn): def login_or_token(fn : Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn) @wraps(fn)
def decorated_view(*args, **kwargs): def decorated_view(*args : Any, **kwargs : Any) -> Any:
if current_user.is_authenticated: if current_user.is_authenticated:
return fn(*args, **kwargs) return fn(*args, **kwargs)
payload = get_header_jwt_payload() payload = get_header_jwt_payload()

View File

@ -88,11 +88,6 @@ engine.io@~6.1.0:
engine.io-parser "~5.0.0" engine.io-parser "~5.0.0"
ws "~8.2.3" 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: gentelella@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/gentelella/-/gentelella-1.4.0.tgz#b3d15fd9c40c6ea47dc7f36290c8f89aee95efc5" resolved "https://registry.yarnpkg.com/gentelella/-/gentelella-1.4.0.tgz#b3d15fd9c40c6ea47dc7f36290c8f89aee95efc5"

View File

@ -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"
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
#
# 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()

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -39,14 +40,17 @@ from flask_socketio import (
send, 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 = SocketIO(app)
@app.socketio.on("connect", namespace="/sio") @app.socketio.on("connect", namespace="/sio")
@login_required @login_required
def socketio_connect(): def socketio_connect() -> None:
if current_user.id: if current_user.id:
join_room("admin") join_room("admin")
app.socketio.emit( app.socketio.emit(
@ -57,12 +61,12 @@ def socketio_connect():
@app.socketio.on("disconnect", namespace="/sio") @app.socketio.on("disconnect", namespace="/sio")
def socketio_disconnect(): def socketio_disconnect() -> None:
leave_room("admin") leave_room("admin")
@app.socketio.on("connect", namespace="/sio/events") @app.socketio.on("connect", namespace="/sio/events")
def socketio_connect(): def socketio_connect() -> None:
jwt = get_token_payload(request.args.get("jwt")) jwt = get_token_payload(request.args.get("jwt"))
join_room("events") join_room("events")
@ -75,7 +79,7 @@ def socketio_connect():
@app.socketio.on("disconnect", namespace="/sio/events") @app.socketio.on("disconnect", namespace="/sio/events")
def socketio_events_disconnect(): def socketio_events_disconnect() -> None:
leave_room("events") leave_room("events")

View File

@ -29,8 +29,8 @@ import requests
from jose import jwt from jose import jwt
## SETUP ## SETUP
domain = "admin.[YOURDOMAIN]" domain = f"admin.{ os.environ['DOMAIN'] }"
secret = "[your API_SECRET]" secret = os.environ['API_SECRET']
## END SETUP ## END SETUP

View File

@ -43,8 +43,12 @@ services:
- ${DATA_FOLDER}/moodle/saml2:/admin/moodledata/saml2:rw - ${DATA_FOLDER}/moodle/saml2:/admin/moodledata/saml2:rw
- ${DATA_FOLDER}/saml_certs:/admin/saml_certs:rw - ${DATA_FOLDER}/saml_certs:/admin/saml_certs:rw
- ${DATA_FOLDER}/legal:/admin/admin/static/templates/pages/legal:rw - ${DATA_FOLDER}/legal:/admin/admin/static/templates/pages/legal:rw
- ${DATA_FOLDER}/dd-admin:/data:rw
env_file: env_file:
- .env - .env
environment: environment:
- VERIFY="false" # In development do not verify certificates - VERIFY="false" # In development do not verify certificates
- DOMAIN=${DOMAIN} - DOMAIN=${DOMAIN}
- MANAGED_EMAIL_DOMAIN=${MANAGED_EMAIL_DOMAIN}
- DATA_FOLDER=/data
- CUSTOM_FOLDER=/admin/custom

View File

@ -21,3 +21,6 @@ version: '3.7'
networks: networks:
dd_net: dd_net:
name: dd_net name: dd_net
driver: bridge
driver_opts:
com.docker.network.driver.mtu: ${NETWORK_MTU:-1500}

View File

@ -22,6 +22,8 @@
TITLE="DD" TITLE="DD"
TITLE_SHORT="DD" TITLE_SHORT="DD"
DOMAIN=mydomain.com DOMAIN=mydomain.com
# If defined, DD will be managing email for this domain
#MANAGED_EMAIL_DOMAIN=${DOMAIN}
LETSENCRYPT_DNS= LETSENCRYPT_DNS=
LETSENCRYPT_EMAIL= LETSENCRYPT_EMAIL=
# Generate letsencrypt certificate for root domain # Generate letsencrypt certificate for root domain
@ -195,3 +197,6 @@ POSTGRESQL_IMG=postgres:14.1-alpine3.15
## MINIO ## MINIO
#MINIO_IMG=mino/minio:RELEASE.2022-01-25T19-56-04Z #MINIO_IMG=mino/minio:RELEASE.2022-01-25T19-56-04Z
## Network settings
#NETWORK_MTU=1500

View File

@ -55,6 +55,39 @@ Aquí tens alguns recursos per guiar-te més:
- [Post-instal·lació](post-install.ca.md) - [Post-instal·lació](post-install.ca.md)
- [Codi font](https://gitlab.com/DD-workspace/DD) - [Codi font](https://gitlab.com/DD-workspace/DD)
# Per què comença la història de git aquí?
<details><summary>Per què comença la història de git aquí</summary>
Hi vam fer molta feina per estabilitzar el codi i netejar el repositori abans
de l'anunci públic al <a href='https://curs.digitalitzacio-democratica.xnet-x.net/'>I Curs Internacional d'Educació Digital Democràtica i Open Edtech</a>.
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 <i>commits</i> previs és de:
<ul>
<li>Josep Maria Viñolas Auquer</li>
<li>Simó Albert i Beltran</li>
<li>Alberto Larraz Dalmases</li>
<li>Yoselin Ribero</li>
<li>Elena Barrios Galán</li>
<li>Melina Gamboa</li>
<li>Antonio Manzano</li>
<li>Cecilia Bayo</li>
<li>Naomi Hidalgo</li>
<li>Joan Cervan Andreu</li>
<li>Jose Antonio Exposito Garcia</li>
<li>Raúl FS</li>
<li>Unai Tolosa Pontesta</li>
<li>Evilham</li>
</ul>
</details>
Aquest web està fet amb [MkDocs](https://gitlab.com/pages/mkdocs). Aquest web està fet amb [MkDocs](https://gitlab.com/pages/mkdocs).
Podeu [veure i modificar el codi font](https://gitlab.com/DD-workspace/DD). Podeu [veure i modificar el codi font](https://gitlab.com/DD-workspace/DD).

View File

@ -54,5 +54,43 @@ Aquí tienes algunos recursos para guiarte más:
- [Post-instalación](post-install.ca.md) - [Post-instalación](post-install.ca.md)
- [Código fuente](https://gitlab.com/DD-workspace/DD) - [Código fuente](https://gitlab.com/DD-workspace/DD)
# ¿Por qué comienza la historia de git aquí?
<details><summary>¿Por qué comienza la historia de git aquí?</summary>
Se hizo mucho trabajo para estabilizar el código y limpiar el repositorio antes
del anuncio público en el
<a href='https://curso.digitalizacion-democratica.xnet-x.net/'>I Curso Internacional de Educación Digital Democrática y Open Edtech</a>.
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 <i>commits</i> previos es de:
<ul>
<li>Josep Maria Viñolas Auquer</li>
<li>Simó Albert i Beltran</li>
<li>Alberto Larraz Dalmases</li>
<li>Yoselin Ribero</li>
<li>Elena Barrios Galán</li>
<li>Melina Gamboa</li>
<li>Antonio Manzano</li>
<li>Cecilia Bayo</li>
<li>Naomi Hidalgo</li>
<li>Joan Cervan Andreu</li>
<li>Jose Antonio Exposito Garcia</li>
<li>Raúl FS</li>
<li>Unai Tolosa Pontesta</li>
<li>Evilham</li>
</ul>
</details>
Página creada con [MkDocs](https://gitlab.com/pages/mkdocs). Página creada con [MkDocs](https://gitlab.com/pages/mkdocs).
Puedes [ver y modificar su código](https://gitlab.com/DD-workspace/DD). Puedes [ver y modificar su código](https://gitlab.com/DD-workspace/DD).

View File

@ -52,5 +52,41 @@ resources to aid you further:
- [Post-install](post-install.ca.md) - [Post-install](post-install.ca.md)
- [Source code](https://gitlab.com/DD-workspace/DD) - [Source code](https://gitlab.com/DD-workspace/DD)
# Why does git history start here?
<details><summary>Why does git history start here?</summary>
A lot of work went into stabilising the code and cleaning the repo before the
public announcement on the
<a href='https://congress.democratic-digitalisation.xnet-x.net/'>1st International Congress on Democratic Digital Education and Open Edtech</a>.
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:
<ul>
<li>Josep Maria Viñolas Auquer</li>
<li>Simó Albert i Beltran</li>
<li>Alberto Larraz Dalmases</li>
<li>Yoselin Ribero</li>
<li>Elena Barrios Galán</li>
<li>Melina Gamboa</li>
<li>Antonio Manzano</li>
<li>Cecilia Bayo</li>
<li>Naomi Hidalgo</li>
<li>Joan Cervan Andreu</li>
<li>Jose Antonio Exposito Garcia</li>
<li>Raúl FS</li>
<li>Unai Tolosa Pontesta</li>
<li>Evilham</li>
</ul>
</details>
This site is built with [MkDocs](https://gitlab.com/pages/mkdocs). 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). You can [browse and modify its source code](https://gitlab.com/DD-workspace/DD).

View File

@ -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 [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 ## Interactivament
Podem fer servir l'script [`dd-install.sh`][dd-install.sh] sense arguments per 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: You will need to setup DNS entries for:
- [ ] moodle.example.org - [ ] moodle.dd.004.es
- [ ] nextcloud.example.org - [ ] nextcloud.dd.004.es
- [ ] wp.example.org - [ ] wp.dd.004.es
- [ ] oof.example.org - [ ] oof.dd.004.es
- [ ] sso.example.org - [ ] sso.dd.004.es
- [ ] pad.example.org - [ ] pad.dd.004.es
- [ ] admin.example.org - [ ] admin.dd.004.es
- [ ] api.example.org - [ ] api.dd.004.es
- [ ] correu.dd.004.es
What is the short title of the DD instance? [DD] What is the short title of the DD instance? [DD]
@ -77,6 +82,9 @@ Opcionalment es poden indicar els logos ubicant els fitxers <code>.png</code>
al servidor i indicant la seva ruta quan l'instal·lador les demana. al servidor i indicant la seva ruta quan l'instal·lador les demana.
</details> </details>
<details><summary>Certificat preexistent</summary>
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).
</details>
## Automatitzat ## Automatitzat

46
docs/wildcard.ca.md Normal file
View File

@ -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.