solved conflict main merge

mejoras_instalacion
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/)
- **Admin/developer docs**: [https://dd.digitalitzacio-democratica.xnet-x.net/docs/](https://dd.digitalitzacio-democratica.xnet-x.net/docs/)
- **Source code**: [https://gitlab.com/DD-workspace/DD](https://gitlab.com/DD-workspace/DD)
# Why does git history start here?
<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:
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
# file license author source
nginx.conf AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/docker/522559eefdd56d2e49259c3b0f4a0e92882cdb87/.examples/docker-compose/with-nginx-proxy/postgres/fpm/web/nginx.conf
#nc_mail/appinfo.xml AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/MaadixNet/mail/feature/occ-account-update-command/appinfo/info.xml
#nc_mail/lib/Command/UpdateAccount.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/MaadixNet/mail/feature/occ-account-update-command/lib/Command/UpdateAccount.php
#nc_mail/lib/Db/MailAccountMapper.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/MaadixNet/mail/feature/occ-account-update-command/lib/Db/MailAccountMapper.php
nc_mail/appinfo/info.xml AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/mail/v1.12.8/appinfo/info.xml
nc_mail/lib/Command/UpdateAccount.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/mail/v1.12.8/lib/Command/UpdateAccount.php
nc_mail/lib/Db/MailAccountMapper.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/mail/v1.12.8/lib/Db/MailAccountMapper.php

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
done
# Temporary patch while upstream lands our changes
# See: https://github.com/nextcloud/mail/pull/6908
for f in appinfo/info.xml lib/Command/UpdateAccount.php lib/Db/MailAccountMapper.php; do
install -m 0644 -o 82 -g 82 "dd-apps/docker/nextcloud/nc_mail/$f" "${SRC_FOLDER}/nextcloud/custom_apps/mail/$f"
done
# Custom forms
docker exec dd-apps-nextcloud-app apk add git npm composer
docker exec -u www-data dd-apps-nextcloud-app rm -rf /var/www/html/custom_apps/forms
@ -476,11 +482,9 @@ saml_certificates(){
echo " --> Setting up SAML for wordpress"
docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 wordpress_saml.py"
# SAML PLUGIN MOODLE
# echo "To add SAML to moodle:"
# echo "1.-Activate SAML plugin in moodle extensions, regenerate certificate, lock certificate"
# echo "2.-Then run: docker exec -ti dd-sso-admin python3 /admin/nextcloud_saml.py"
# echo "3.-"
# SAML PLUGIN EMAIL
echo " --> Setting up SAML for email"
docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 email_saml.py"
}
wait_for_moodle(){

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/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
Flask==2.0.1
Flask-Login==0.5.0
eventlet==0.33.0
Flask-SocketIO==5.1.0
bcrypt==3.2.0
attrs==22.1.0
cryptography==37.0.4
Flask==2.1.3
Flask-Login==0.6.2
eventlet==0.33.1
Flask-SocketIO==5.2.0
bcrypt==3.2.2
# diceware can't be upgraded without issues
diceware==0.9.6
mysql-connector-python==8.0.25
psycopg2==2.8.6
mysql-connector-python==8.0.30
psycopg2==2.9.3
# python-keycloak can't be upgraded without issues
python-keycloak==0.26.1
minio==7.0.3
urllib3==1.26.6
minio==7.1.11
urllib3==1.26.11
schema==0.7.5
Werkzeug~=2.0.0
Werkzeug==2.2.1
python-jose==3.3.0
Cerberus==1.3.4
PyYAML==6.0

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 © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
@ -20,107 +21,23 @@
import logging as log
import os
import os.path
from flask import Flask, render_template, send_from_directory
from admin.flaskapp import AdminFlaskApp
app = Flask(__name__, static_url_path="")
app = Flask(__name__, template_folder="static/templates")
app.url_map.strict_slashes = False
def get_app() -> AdminFlaskApp:
app = AdminFlaskApp(__name__, template_folder="static/templates")
"""
App secret key for encrypting cookies
You can generate one with:
import os
os.urandom(24)
And paste it here.
"""
app.secret_key = "Change this key!/\xf7\x83\xbe\x17\xfa\xa3zT\n\\]m\xa6\x8bF\xdd\r\xf7\x9e\x1d\x1f\x14'"
"""
Debug should be removed on production!
"""
if app.debug:
log.warning("Debug mode: {}".format(app.debug))
else:
log.info("Debug mode: {}".format(app.debug))
print("Starting dd-sso api...")
return app
from admin.lib.load_config import loadConfig
try:
loadConfig(app)
except:
print("Could not get environment variables...")
from admin.lib.postup import Postup
Postup()
from admin.lib.admin import Admin
app.admin = Admin()
app.ready = False
"""
Debug should be removed on production!
"""
if app.debug:
log.warning("Debug mode: {}".format(app.debug))
else:
log.info("Debug mode: {}".format(app.debug))
"""
Serve static files
"""
@app.route("/build/<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

View File

@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
@ -21,31 +22,9 @@ import os
from flask_login import LoginManager, UserMixin
from admin import app
""" OIDC TESTS """
# from flask_oidc import OpenIDConnect
# app.config.update({
# 'SECRET_KEY': 'u\x91\xcf\xfa\x0c\xb9\x95\xe3t\xba2K\x7f\xfd\xca\xa3\x9f\x90\x88\xb8\xee\xa4\xd6\xe4',
# 'TESTING': True,
# 'DEBUG': True,
# 'OIDC_CLIENT_SECRETS': 'client_secrets.json',
# 'OIDC_ID_TOKEN_COOKIE_SECURE': False,
# 'OIDC_REQUIRE_VERIFIED_EMAIL': False,
# 'OIDC_VALID_ISSUERS': ['https://sso.mydomain.duckdns.org:8080/auth/realms/master'],
# 'OIDC_OPENID_REALM': 'https://sso.mydomain.duckdns.org//custom_callback',
# 'OVERWRITE_REDIRECT_URI': 'https://sso.mydomain.duckdns.org//custom_callback',
# })
# # 'OVERWRITE_REDIRECT_URI': 'https://sso.mydomain.duckdns.org//custom_callback',
# # 'OIDC_CALLBACK_ROUTE': '//custom_callback'
# oidc = OpenIDConnect(app)
""" OIDC TESTS """
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"
from typing import TYPE_CHECKING, Dict
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
ram_users = {
os.environ["ADMINAPP_USER"]: {
@ -67,13 +46,19 @@ ram_users = {
class User(UserMixin):
def __init__(self, dict):
self.id = dict["id"]
self.username = dict["id"]
self.password = dict["password"]
self.role = dict["role"]
def __init__(self, id : str, password : str, role : str, active : bool = True) -> None:
self.id = id
self.username = id
self.password = password
self.role = role
self.active = active
def setup_auth(app : "AdminFlaskApp") -> None:
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"
@login_manager.user_loader
def user_loader(username):
return User(ram_users[username])
@login_manager.user_loader
def user_loader(username : str) -> User:
u = ram_users[username]
return User(id = u["id"], password = u["password"], role = u["role"])

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

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

View File

@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
@ -23,10 +24,11 @@ import logging as log
import os
import traceback
from flask import jsonify, request
from typing import Any, Dict, Union, List
from admin import app
from flask import request
# TODO: Improve these constants' structure
content_type = {"Content-Type": "application/json"}
ex = {
"bad_request": {
@ -96,8 +98,10 @@ ex = {
class Error(Exception):
def __init__(self, error="bad_request", description="", debug="", data=None):
self.error = ex[error]["error"].copy()
status_code : int
content_type : Dict[str, str]
def __init__(self, error : str ="bad_request", description : str="", debug : Union[str, List[str]]="", data : Any =None):
self.error : Dict[str, str] = (ex[error]["error"]).copy() # type: ignore # bad struct
self.error["function"] = (
inspect.stack()[1][1].split(os.sep)[-1]
+ ":"
@ -123,7 +127,7 @@ class Error(Exception):
"----------- REQUEST START -----------",
request.method + " " + request.url,
"\r\n".join("{}: {}".format(k, v) for k, v in request.headers.items()),
request.body if hasattr(request, "body") else "",
getattr(request, "body", ""),
"----------- REQUEST STOP -----------",
)
if request
@ -138,7 +142,7 @@ class Error(Exception):
if data
else ""
)
self.status_code = ex[error]["status_code"]
self.status_code = ex[error]["status_code"] # type: ignore # bad struct
self.content_type = content_type
log.debug(
"%s - %s - [%s -> %s]\r\n%s\r\n%s\r\n%s"
@ -152,11 +156,3 @@ class Error(Exception):
self.error["data"],
)
)
@app.errorhandler(Error)
def handle_user_error(ex):
response = jsonify(ex.error)
response.status_code = ex.status_code
response.headers = {"content-type": content_type}
return response

View File

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

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

View File

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

View File

@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
@ -22,8 +23,11 @@ import string
from collections import Counter
from pprint import pprint
from typing import Any, Dict, Generator, Iterable, Optional, List
def get_recursive_groups(l_groups, l):
DDGroup = Dict[str, Any]
def get_recursive_groups(l_groups : Iterable[DDGroup], l : List[DDGroup]) -> List[DDGroup]:
for d_group in l_groups:
data = {}
for key, value in d_group.items():
@ -35,11 +39,11 @@ def get_recursive_groups(l_groups, l):
return l
def get_group_with_childs(keycloak_group):
def get_group_with_childs(keycloak_group : DDGroup) -> List[str]:
return [g["path"] for g in get_recursive_groups([keycloak_group], [])]
def system_username(username):
def system_username(username : str) -> bool:
return (
True
if username in ["guest", "ddadmin", "admin"] or username.startswith("system_")
@ -47,41 +51,43 @@ def system_username(username):
)
def system_group(groupname):
def system_group(groupname : str) -> bool:
return True if groupname in ["admin", "manager", "teacher", "student"] else False
def get_group_from_group_id(group_id, groups):
def get_group_from_group_id(group_id : str, groups : Iterable[DDGroup]) -> Optional[DDGroup]:
return next((d for d in groups if d.get("id") == group_id), None)
def get_kid_from_kpath(kpath, groups):
ids = [g["id"] for g in groups if g["path"] == kpath]
if not len(ids) or len(ids) > 1:
return False
def get_kid_from_kpath(kpath : str, groups : Iterable[DDGroup]) -> Optional[str]:
ids : List[str] = [g["id"] for g in groups if g["path"] == kpath]
if len(ids) != 1:
return None
return ids[0]
def get_gid_from_kgroup_id(kgroup_id, groups):
return [
def get_gid_from_kgroup_id(kgroup_id : str, groups : Iterable[DDGroup]) -> str:
# TODO: Why is this interface different from get_kid_from_kpath?
o : List[str] = [
g["path"].replace("/", ".")[1:] if len(g["path"].split("/")) else g["path"][1:]
for g in groups
if g["id"] == kgroup_id
][0]
]
return o[0]
def get_gids_from_kgroup_ids(kgroup_ids, groups):
def get_gids_from_kgroup_ids(kgroup_ids : Iterable[str], groups : Iterable[DDGroup]) -> List[str]:
return [get_gid_from_kgroup_id(kgroup_id, groups) for kgroup_id in kgroup_ids]
def kpath2gid(path):
def kpath2gid(path : str) -> str:
# print(path.replace('/','.')[1:])
if path.startswith("/"):
return path.replace("/", ".")[1:]
return path.replace("/", ".")
def kpath2gids(path):
def kpath2gids(path : str) -> List[str]:
path = kpath2gid(path)
l = []
for i in range(len(path.split("."))):
@ -89,44 +95,45 @@ def kpath2gids(path):
return l
def kpath2kpaths(path):
def kpath2kpaths(path : str) -> List[str]:
l = []
for i in range(len(path.split("/"))):
l.append("/".join(path.split("/")[: i + 1]))
return l[1:]
def gid2kpath(gid):
def gid2kpath(gid : str) -> str:
return "/" + gid.replace(".", "/")
def count_repeated(itemslist):
def count_repeated(itemslist : Iterable[Any]) -> None:
print(Counter(itemslist))
def groups_kname2gid(groups):
def groups_kname2gid(groups : Iterable[str]) -> List[str]:
return [name.replace(".", "/") for name in groups]
def groups_path2id(groups):
def groups_path2id(groups : Iterable[str]) -> List[str]:
return [g.replace("/", ".")[1:] for g in groups]
def groups_id2path(groups):
def groups_id2path(groups : Iterable[str]) -> List[str]:
return ["/" + g.replace(".", "/") for g in groups]
def filter_roles_list(role_list):
def filter_roles_list(role_list : Iterable[str]) -> List[str]:
client_roles = ["admin", "manager", "teacher", "student"]
return [r for r in role_list if r in client_roles]
def filter_roles_listofdicts(role_listofdicts):
def filter_roles_listofdicts(role_listofdicts : Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
client_roles = ["admin", "manager", "teacher", "student"]
return [r for r in role_listofdicts if r["name"] in client_roles]
def rand_password(lenght):
def rand_password(lenght : int) -> str:
# TODO: why is this not using py3's secrets?
characters = string.ascii_letters + string.digits + string.punctuation
passwd = "".join(random.choice(characters) for i in range(lenght))
while not any(ele.isupper() for ele in passwd):

View File

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

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 © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
@ -21,7 +22,6 @@ import logging as log
import os
import traceback
from admin import app
from pprint import pprint
from minio import Minio
@ -29,18 +29,22 @@ from minio.commonconfig import REPLACE, CopySource
from minio.deleteobjects import DeleteObject
from requests import get, post
legal_path= os.path.join(app.root_path, "static/templates/pages/legal/")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
def get_legal(lang):
with open(legal_path+lang, "r") as languagefile:
# TODO: Fix all this
def get_legal(app : "AdminFlaskApp", lang : str) -> str:
with open(app.legal_path+lang, "r") as languagefile:
return languagefile.read()
def gen_legal_if_not_exists(lang):
if not os.path.isfile(legal_path+lang):
def gen_legal_if_not_exists(app : "AdminFlaskApp", lang : str) -> None:
if not os.path.isfile(app.legal_path+lang):
log.debug("Creating new language file")
with open(legal_path+lang, "w") as languagefile:
with open(app.legal_path+lang, "w") as languagefile:
languagefile.write("<b>Legal</b><br>This is the default legal page for language " + lang)
def new_legal(lang,html):
with open(legal_path+lang, "w") as languagefile:
languagefile.write(html)
def new_legal(app : "AdminFlaskApp", lang : str, html : str) -> None:
with open(app.legal_path+lang, "w") as languagefile:
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
#
@ -23,11 +24,15 @@ from pprint import pprint
from requests import get, post
from admin import app
from .exceptions import UserExists, UserNotFound
from .postgres import Postgres
from typing import TYPE_CHECKING, cast, Any, Dict, Iterable, List, Optional
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
# Module variables to connect to moodle api
@ -36,18 +41,20 @@ class Moodle:
https://docs.moodle.org/dev/Web_service_API_functions
https://docs.moodle.org/311/en/Using_web_services
"""
key: str
url : str
endpoint : str
verify : bool
moodle_pg : Postgres
def __init__(
self,
key=app.config["MOODLE_WS_TOKEN"],
url="https://moodle." + app.config["DOMAIN"],
endpoint="/webservice/rest/server.php",
verify=app.config["VERIFY"],
):
self.key = key
self.url = url
app : "AdminFlaskApp",
endpoint : str="/webservice/rest/server.php",
) -> None:
self.key = app.config["MOODLE_WS_TOKEN"]
self.url = f"https://moodle.{ app.config['DOMAIN'] }"
self.endpoint = endpoint
self.verify = verify
self.verify = cast(bool, app.config["VERIFY"])
self.moodle_pg = Postgres(
"dd-apps-postgresql",
@ -56,7 +63,7 @@ class Moodle:
app.config["MOODLE_POSTGRES_PASSWORD"],
)
def rest_api_parameters(self, in_args, prefix="", out_dict=None):
def rest_api_parameters(self, in_args : Any, prefix : str="", out_dict : Optional[Dict]=None) -> Dict[Any, Any]:
"""Transform dictionary/array structure to a flat dictionary, with key names
defining the structure.
Example usage:
@ -64,24 +71,23 @@ class Moodle:
{'courses[0][id]':1,
'courses[0][name]':'course1'}
"""
if out_dict == None:
out_dict = {}
o : Dict[Any, Any] = {} if out_dict is None else out_dict
if not type(in_args) in (list, dict):
out_dict[prefix] = in_args
return out_dict
o[prefix] = in_args
return o
if prefix == "":
prefix = prefix + "{0}"
else:
prefix = prefix + "[{0}]"
if type(in_args) == list:
for idx, item in enumerate(in_args):
self.rest_api_parameters(item, prefix.format(idx), out_dict)
self.rest_api_parameters(item, prefix.format(idx), o)
elif type(in_args) == dict:
for key, item in in_args.items():
self.rest_api_parameters(item, prefix.format(key), out_dict)
return out_dict
self.rest_api_parameters(item, prefix.format(key), o)
return o
def call(self, fname, **kwargs):
def call(self, fname : str, **kwargs : Any) -> Any:
"""Calls moodle API function with function name fname and keyword arguments.
Example:
>>> call_mdl_function('core_course_update_courses',
@ -97,7 +103,7 @@ class Moodle:
raise SystemError(response)
return response
def create_user(self, email, username, password, first_name="-", last_name="-"):
def create_user(self, email : str, username : str, password : str, first_name : str="-", last_name : str="-") -> Any:
if len(self.get_user_by("username", username)["users"]):
raise UserExists
try:
@ -115,7 +121,7 @@ class Moodle:
except SystemError as se:
raise SystemError(se.args[0]["message"])
def update_user(self, username, email, first_name, last_name, enabled=True):
def update_user(self, username : str, email : str, first_name : str, last_name : str, enabled : bool=True) -> Any:
user = self.get_user_by("username", username)["users"][0]
if not len(user):
raise UserNotFound
@ -135,15 +141,15 @@ class Moodle:
except SystemError as se:
raise SystemError(se.args[0]["message"])
def delete_user(self, user_id):
def delete_user(self, user_id : str) -> Any:
user = self.call("core_user_delete_users", userids=[user_id])
return user
def delete_users(self, userids):
def delete_users(self, userids : List[str]) -> Any:
user = self.call("core_user_delete_users", userids=userids)
return user
def get_user_by(self, key, value):
def get_user_by(self, key : str, value : str) -> Any:
criteria = [{"key": key, "value": value}]
try:
user = self.call("core_user_get_users", criteria=criteria)
@ -152,7 +158,7 @@ class Moodle:
return user
# {'users': [{'id': 8, 'username': 'asdfw', 'firstname': 'afowie', 'lastname': 'aokjdnfwe', 'fullname': 'afowie aokjdnfwe', 'email': 'awfewe@ads.com', 'department': '', 'firstaccess': 0, 'lastaccess': 0, 'auth': 'manual', 'suspended': False, 'confirmed': True, 'lang': 'ca', 'theme': '', 'timezone': '99', 'mailformat': 1, 'profileimageurlsmall': 'https://moodle.mydomain.duckdns.org/theme/image.php/cbe/core/1630941606/u/f2', 'profileimageurl': 'https://DOMAIN/theme/image.php/cbe/core/1630941606/u/f1'}], 'warnings': []}
def get_users_with_groups_and_roles(self):
def get_users_with_groups_and_roles(self) -> List[Dict[Any, Any]]:
q = """select u.id as id, username, firstname as first, lastname as last, email, json_agg(h.name) as groups, json_agg(r.shortname) as roles
from mdl_user as u
LEFT JOIN mdl_cohort_members AS hm on hm.userid = u.id
@ -179,31 +185,31 @@ class Moodle:
# user['roles']=[]
# return users
def enroll_user_to_course(self, user_id, course_id, role_id=5):
def enroll_user_to_course(self, user_id : str, course_id : str, role_id : int=5) -> Any:
# 5 is student
data = [{"roleid": role_id, "userid": user_id, "courseid": course_id}]
enrolment = self.call("enrol_manual_enrol_users", enrolments=data)
return enrolment
def get_quiz_attempt(self, quiz_id, user_id):
def get_quiz_attempt(self, quiz_id : str, user_id : str) -> Any:
attempts = self.call(
"mod_quiz_get_user_attempts", quizid=quiz_id, userid=user_id
)
return attempts
def get_cohorts(self):
def get_cohorts(self) -> List[Dict[str, Any]]:
cohorts = self.call("core_cohort_get_cohorts")
return cohorts
return cast(List[Dict[str, Any]], cohorts)
def add_system_cohort(self, name, description="", visible=True):
visible = 1 if visible else 0
def add_system_cohort(self, name : str, description : str ="", visible : bool=True) -> Any:
bit_visible = 1 if visible else 0
data = [
{
"categorytype": {"type": "system", "value": ""},
"name": name,
"idnumber": name,
"description": description,
"visible": visible,
"visible": bit_visible,
}
]
cohort = self.call("core_cohort_create_cohorts", cohorts=data)
@ -214,7 +220,7 @@ class Moodle:
# user = self.call('core_cohort_add_cohort_members', criteria=criteria)
# return user
def add_user_to_cohort(self, userid, cohortid):
def add_user_to_cohort(self, userid : str, cohortid : str) -> Any:
members = [
{
"cohorttype": {"type": "id", "value": cohortid},
@ -224,21 +230,21 @@ class Moodle:
user = self.call("core_cohort_add_cohort_members", members=members)
return user
def delete_user_in_cohort(self, userid, cohortid):
def delete_user_in_cohort(self, userid : str, cohortid : str) -> Any:
members = [{"cohortid": cohortid, "userid": userid}]
user = self.call("core_cohort_delete_cohort_members", members=members)
return user
def get_cohort_members(self, cohort_ids):
def get_cohort_members(self, cohort_ids : str) -> Any:
members = self.call("core_cohort_get_cohort_members", cohortids=cohort_ids)
# [0]['userids']
return members
def delete_cohorts(self, cohortids):
def delete_cohorts(self, cohortids : Iterable[str]) -> Any:
deleted = self.call("core_cohort_delete_cohorts", cohortids=cohortids)
return deleted
def get_user_cohorts(self, user_id):
def get_user_cohorts(self, user_id : str) -> Any:
user_cohorts = []
cohorts = self.get_cohorts()
for cohort in cohorts:
@ -246,7 +252,7 @@ class Moodle:
user_cohorts.append(cohort)
return user_cohorts
def add_user_to_siteadmin(self, user_id):
def add_user_to_siteadmin(self, user_id : str) -> Any:
q = """SELECT value FROM mdl_config WHERE name='siteadmins'"""
value = self.moodle_pg.select(q)[0][0]
if str(user_id) not in value:
@ -258,6 +264,7 @@ class Moodle:
log.warning(
"MOODLE:ADDING THE USER TO ADMINS: This needs a purge cache in moodle!"
)
def unassign_user_rol(self, user_id, role_id):
unassignments = [{"roleid": role_id, "userid": user_id, "contextlevel": 'system', "instanceid": 0}]
return self.call("core_role_unassign_roles", unassignments=unassignments)

View File

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

View File

@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
@ -30,21 +31,31 @@ import urllib
import requests
from psycopg2 import sql
# from ..lib.log import *
from admin import app
from .nextcloud_exc import *
from .postgres import Postgres
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
DDUser = Dict[Any, Any]
class Nextcloud:
verify_cert : bool
apiurl : str
shareurl : str
davurl : str
auth : Tuple[str, str]
user : str
nextcloud_pg : Postgres
def __init__(
self,
url="https://nextcloud." + app.config["DOMAIN"],
username=os.environ["NEXTCLOUD_ADMIN_USER"],
password=os.environ["NEXTCLOUD_ADMIN_PASSWORD"],
verify=True,
):
app : "AdminFlaskApp",
username : str=os.environ["NEXTCLOUD_ADMIN_USER"],
password : str=os.environ["NEXTCLOUD_ADMIN_PASSWORD"],
verify : bool=True,
) -> None:
url = "https://nextcloud." + app.config["DOMAIN"]
self.verify_cert = verify
self.apiurl = url + "/ocs/v1.php/cloud/"
@ -61,9 +72,9 @@ class Nextcloud:
)
def _request(
self, method, url, data={}, headers={"OCS-APIRequest": "true"}, auth=False
):
if auth == False:
self, method : str, url : str, data : Any={}, headers : Dict[str, str]={"OCS-APIRequest": "true"}, auth : Optional[Tuple[str, str]]=None
) -> str:
if auth is None:
auth = self.auth
try:
response = requests.request(
@ -96,7 +107,7 @@ class Nextcloud:
raise ProviderConnError
raise ProviderError
def check_connection(self):
def check_connection(self) -> bool:
url = self.apiurl + "users/" + self.user + "?format=json"
try:
result = self._request("GET", url)
@ -118,7 +129,7 @@ class Nextcloud:
raise ProviderConnError
raise ProviderError
def get_user(self, userid):
def get_user(self, userid : str) -> Any:
url = self.apiurl + "users/" + userid + "?format=json"
try:
result = json.loads(self._request("GET", url))
@ -148,7 +159,7 @@ class Nextcloud:
# users_with_lists = [list(l[:-2])+([[]] if l[-2] == [None] else [list(set(l[-2]))]) + ([[]] if l[-1] == [None] else [list(set(l[-1]))]) for l in users]
# users_with_lists = [list(l[:-2])+([[]] if l[-2] == [None] else [list(set(l[-2]))]) + ([[]] if l[-1] == [None] else [list(set(l[-1]))]) for l in users_with_lists]
# list_dict_users = [dict(zip(fields, r)) for r in users_with_lists]
def get_users_list(self):
def get_users_list(self) -> List[DDUser]:
# q = """select u.uid as username, adn.value as displayname, ade.value as email, json_agg(gg.displayname) as admin_groups,json_agg(g.displayname) as groups
# from oc_users as u
# left join oc_group_user as gu on gu.uid = u.uid
@ -200,9 +211,10 @@ class Nextcloud:
# log.error(traceback.format_exc())
# raise
# TODO: Improve typing of these functions...
def add_user(
self, userid, userpassword, quota=False, group=False, email="", displayname=""
):
self, userid : str, userpassword : str, quota : Any=False, group : Any=False, email : str="", displayname : str=""
) -> bool:
data = {
"userid": userid,
"password": userpassword,
@ -247,7 +259,7 @@ class Nextcloud:
# 106 - no group specified (required for subadmins)
# 107 - all errors that contain a hint - for example “Password is among the 1,000,000 most common ones. Please make it unique.” (this code was added in 12.0.6 & 13.0.1)
def update_user(self, userid, key_values):
def update_user(self, userid : str, key_values : Dict[str, Any]) -> bool:
# key_values={'quota':quota,'email':email,'displayname':displayname}
url = self.apiurl + "users/" + userid + "?format=json"
@ -262,6 +274,8 @@ class Nextcloud:
result = json.loads(
self._request("PUT", url, data=data, headers=headers)
)
# TODO: It seems like this only sets the first item in key_values
# This function probably doesn't do what it is supposed to
if result["ocs"]["meta"]["statuscode"] == 100:
return True
if result["ocs"]["meta"]["statuscode"] == 102:
@ -273,8 +287,9 @@ class Nextcloud:
except:
# log.error(traceback.format_exc())
raise
return False
def add_user_to_group(self, userid, group_id):
def add_user_to_group(self, userid : str, group_id : str) -> bool:
data = {"groupid": group_id}
url = self.apiurl + "users/" + userid + "/groups?format=json"
@ -296,7 +311,7 @@ class Nextcloud:
# log.error(traceback.format_exc())
raise
def remove_user_from_group(self, userid, group_id):
def remove_user_from_group(self, userid : str, group_id : str) -> bool:
data = {"groupid": group_id}
url = self.apiurl + "users/" + userid + "/groups?format=json"
@ -312,18 +327,21 @@ class Nextcloud:
return True
if result["ocs"]["meta"]["statuscode"] == 102:
raise ProviderItemExists
if result["ocs"]["meta"]["statuscode"] == 104:
self.add_group(group)
# raise ProviderGroupNotExists
# TODO: It is unclear what status code 104 is, it certainly
# shouldn't the group if it doesn't exist
#if result["ocs"]["meta"]["statuscode"] == 104:
# self.add_group(group)
# # raise ProviderGroupNotExists
log.error("Get Nextcloud provider user add error: " + str(result))
raise ProviderOpError
except:
# log.error(traceback.format_exc())
raise
# TODO: Improve typing of these functions...
def add_user_with_groups(
self, userid, userpassword, quota=False, groups=[], email="", displayname=""
):
self, userid : str, userpassword : str, quota : Any=False, groups : Any=[], email : str="", displayname : str=""
) -> bool:
data = {
"userid": userid,
"password": userpassword,
@ -352,7 +370,7 @@ class Nextcloud:
raise ProviderItemExists
if result["ocs"]["meta"]["statuscode"] == 104:
# self.add_group(group)
None
pass
# raise ProviderGroupNotExists
log.error("Get Nextcloud provider user add error: " + str(result))
raise ProviderOpError
@ -368,7 +386,7 @@ class Nextcloud:
# 106 - no group specified (required for subadmins)
# 107 - all errors that contain a hint - for example “Password is among the 1,000,000 most common ones. Please make it unique.” (this code was added in 12.0.6 & 13.0.1)
def delete_user(self, userid):
def delete_user(self, userid : str) -> bool:
url = self.apiurl + "users/" + userid + "?format=json"
try:
result = json.loads(self._request("DELETE", url))
@ -384,13 +402,13 @@ class Nextcloud:
# 100 - successful
# 101 - failure
def enable_user(self, userid):
None
def enable_user(self, userid : str) -> None:
pass
def disable_user(self, userid):
None
def disable_user(self, userid : str) -> None:
pass
def exists_user_folder(self, userid, userpassword, folder="IsardVDI"):
def exists_user_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> bool:
auth = (userid, userpassword)
url = self.davurl + userid + "/" + folder + "?format=json"
headers = {
@ -407,7 +425,7 @@ class Nextcloud:
# log.error(traceback.format_exc())
raise
def add_user_folder(self, userid, userpassword, folder="IsardVDI"):
def add_user_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> bool:
auth = (userid, userpassword)
url = self.davurl + userid + "/" + folder + "?format=json"
headers = {
@ -429,7 +447,7 @@ class Nextcloud:
# log.error(traceback.format_exc())
raise
def exists_user_share_folder(self, userid, userpassword, folder="IsardVDI"):
def exists_user_share_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> Dict[str, str]:
auth = (userid, userpassword)
url = self.shareurl + "shares?format=json"
headers = {
@ -449,7 +467,7 @@ class Nextcloud:
# log.error(traceback.format_exc())
raise
def add_user_share_folder(self, userid, userpassword, folder="IsardVDI"):
def add_user_share_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> Dict[str, str]:
auth = (userid, userpassword)
data = {"path": "/" + folder, "shareType": 3}
url = self.shareurl + "shares?format=json"
@ -477,10 +495,10 @@ class Nextcloud:
# log.error(traceback.format_exc())
raise
def get_group(self, userid):
None
def get_group(self, userid : str) -> None:
pass
def get_groups_list(self):
def get_groups_list(self) -> List[Any]:
url = self.apiurl + "groups?format=json"
try:
result = json.loads(self._request("GET", url))
@ -491,7 +509,7 @@ class Nextcloud:
# log.error(traceback.format_exc())
raise
def add_group(self, groupid):
def add_group(self, groupid : str) -> bool:
data = {"groupid": groupid}
url = self.apiurl + "groups?format=json"
headers = {
@ -515,7 +533,7 @@ class Nextcloud:
# 102 - group already exists
# 103 - failed to add the group
def delete_group(self, groupid):
def delete_group(self, groupid : str) -> bool:
group = urllib.parse.quote(groupid, safe="")
url = self.apiurl + "groups/" + group + "?format=json"
headers = {
@ -538,7 +556,7 @@ class Nextcloud:
# 102 - group already exists
# 103 - failed to add the group
def set_user_mail(self, data):
def set_user_mail(self, data : DDUser) -> None:
query = """SELECT * FROM "oc_mail_accounts" WHERE "email" = '%s'"""
sql_query = sql.SQL(query.format(data["email"]))
if not len(self.nextcloud_pg.select(sql_query)):

View File

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

View File

@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
@ -18,54 +19,41 @@
#
# SPDX-License-Identifier: AGPL-3.0-or-later
import json
import logging as log
import time
import traceback
from datetime import datetime, timedelta
import psycopg2
import yaml
import psycopg2.sql
from psycopg2.extensions import connection, cursor
# from admin import app
from typing import Any, List, Tuple, Union
query = Union[str, psycopg2.sql.SQL]
class Postgres:
def __init__(self, host, database, user, password):
# TODO: Fix this whole class
cur : cursor
conn : connection
def __init__(self, host : str, database : str, user : str, password : str) -> None:
self.conn = psycopg2.connect(
host=host, database=database, user=user, password=password
)
# def __del__(self):
# self.cur.close()
# self.conn.close()
def select(self, sql):
def select(self, sql: query) -> List[Tuple[Any, ...]]:
self.cur = self.conn.cursor()
self.cur.execute(sql)
data = self.cur.fetchall()
self.cur.close()
self.cur.close() # type: ignore # psycopg2 type hint missing
return data
def update(self, sql):
def update(self, sql : query) -> None:
self.cur = self.conn.cursor()
self.cur.execute(sql)
self.conn.commit()
self.cur.close()
self.cur.close() # type: ignore # psycopg2 type hint missing
# return self.cur.fetchall()
def select_with_headers(self, sql):
def select_with_headers(self, sql : query) -> Tuple[List[Any], List[Tuple[Any, ...]]]:
self.cur = self.conn.cursor()
self.cur.execute(sql)
data = self.cur.fetchall()
fields = [a.name for a in self.cur.description]
self.cur.close()
self.cur.close() # type: ignore # psycopg2 type hint missing
return (fields, data)
# def update_moodle_saml_plugin(self):
# plugin[('idpmetadata', '<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
#
@ -23,8 +24,6 @@ import logging as log
import os
import random
# from .keycloak import Keycloak
# from .moodle import Moodle
import string
import time
import traceback
@ -33,18 +32,21 @@ from datetime import datetime, timedelta
import psycopg2
import yaml
from admin import app
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
from .postgres import Postgres
class Postup:
def __init__(self):
def __init__(self, app: "AdminFlaskApp") -> None:
ready = False
while not ready:
try:
self.pg = Postgres(
"isard-apps-postgresql",
"dd-apps-postgresql",
"moodle",
app.config["MOODLE_POSTGRES_USER"],
app.config["MOODLE_POSTGRES_PASSWORD"],
@ -93,9 +95,9 @@ class Postup:
self.select_and_configure_theme()
self.configure_tipnc()
self.add_moodle_ws_token()
self.add_moodle_ws_token(app)
def select_and_configure_theme(self, theme="cbe"):
def select_and_configure_theme(self, theme : str="cbe") -> None:
try:
self.pg.update(
"""UPDATE "mdl_config" SET value = '%s' WHERE "name" = 'theme';"""
@ -104,7 +106,6 @@ class Postup:
except:
log.error(traceback.format_exc())
exit(1)
None
try:
self.pg.update(
@ -127,9 +128,8 @@ class Postup:
except:
log.error(traceback.format_exc())
exit(1)
None
def configure_tipnc(self):
def configure_tipnc(self) -> None:
try:
self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = '%s' WHERE "plugin" = 'assignsubmission_tipnc' AND "name" = 'host';"""
@ -155,9 +155,8 @@ class Postup:
except:
log.error(traceback.format_exc())
exit(1)
None
def add_moodle_ws_token(self):
def add_moodle_ws_token(self, app: "AdminFlaskApp") -> None:
try:
token = self.pg.select(
"""SELECT * FROM "mdl_external_tokens" WHERE "externalserviceid" = 3"""
@ -166,7 +165,7 @@ class Postup:
return
except:
# log.error(traceback.format_exc())
None
pass
try:
self.pg.update(
@ -226,4 +225,3 @@ class Postup:
except:
log.error(traceback.format_exc())
exit(1)
None

View File

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

View File

@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
@ -19,6 +20,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import json
import logging as log
from operator import itemgetter
import os
import socket
import sys
@ -27,302 +29,308 @@ import traceback
from flask import request
from admin import app
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
from ..lib.api_exceptions import Error
from .decorators import has_token
from .decorators import has_token, OptionalJsonResponse
## LISTS
@app.route("/ddapi/users", methods=["GET"])
@has_token
def ddapi_users():
if request.method == "GET":
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
users = []
for user in sorted_users:
users.append(user_parser(user))
return json.dumps(users), 200, {"Content-Type": "application/json"}
def setup_api_views(app : "AdminFlaskApp") -> None:
## LISTS
@app.json_route("/ddapi/users", methods=["GET"])
@has_token
def ddapi_users() -> OptionalJsonResponse:
if request.method == "GET":
sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
users = []
for user in sorted_users:
users.append(user_parser(user))
return json.dumps(users), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/ddapi/users/filter", methods=["POST"])
@has_token
def ddapi_users_search() -> OptionalJsonResponse:
if request.method == "POST":
data = request.get_json(force=True)
if not data.get("text"):
raise Error("bad_request", "Incorrect data requested.")
users = app.admin.get_mix_users()
result = [user_parser(user) for user in filter_users(users, data["text"])]
sorted_result = sorted(result, key=itemgetter("id"))
return json.dumps(sorted_result), 200, {"Content-Type": "application/json"}
return None
@app.route("/ddapi/users/filter", methods=["POST"])
@has_token
def ddapi_users_search():
if request.method == "POST":
data = request.get_json(force=True)
if not data.get("text"):
raise Error("bad_request", "Incorrect data requested.")
users = app.admin.get_mix_users()
result = [user_parser(user) for user in filter_users(users, data["text"])]
sorted_result = sorted(result, key=lambda k: k["id"])
return json.dumps(sorted_result), 200, {"Content-Type": "application/json"}
@app.json_route("/ddapi/groups", methods=["GET"])
@has_token
def ddapi_groups() -> OptionalJsonResponse:
if request.method == "GET":
sorted_groups = sorted(app.admin.get_mix_groups(), key=itemgetter("name"))
groups = []
for group in sorted_groups:
groups.append(group_parser(group))
return json.dumps(groups), 200, {"Content-Type": "application/json"}
return None
@app.route("/ddapi/groups", methods=["GET"])
@has_token
def ddapi_groups():
if request.method == "GET":
sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: k["name"])
groups = []
for group in sorted_groups:
groups.append(group_parser(group))
return json.dumps(groups), 200, {"Content-Type": "application/json"}
@app.route("/ddapi/group/users", methods=["POST"])
@has_token
def ddapi_group_users():
if request.method == "POST":
data = request.get_json(force=True)
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
if data.get("id"):
group_users = [
user_parser(user)
for user in sorted_users
if data.get("id") in user["keycloak_groups"]
]
elif data.get("path"):
try:
name = [
g["name"]
for g in app.admin.get_mix_groups()
if g["path"] == data.get("path")
][0]
@app.json_route("/ddapi/group/users", methods=["POST"])
@has_token
def ddapi_group_users() -> OptionalJsonResponse:
if request.method == "POST":
data = request.get_json(force=True)
sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
if data.get("id"):
group_users = [
user_parser(user)
for user in sorted_users
if name in user["keycloak_groups"]
if data.get("id") in user["keycloak_groups"]
]
except:
raise Error("not_found", "Group path not found in system")
elif data.get("keycloak_id"):
try:
name = [
g["name"]
for g in app.admin.get_mix_groups()
if g["id"] == data.get("keycloak_id")
][0]
group_users = [
user_parser(user)
for user in sorted_users
if name in user["keycloak_groups"]
]
except:
raise Error("not_found", "Group keycloak_id not found in system")
else:
raise Error("bad_request", "Incorrect data requested.")
return json.dumps(group_users), 200, {"Content-Type": "application/json"}
elif data.get("path"):
try:
name = [
g["name"]
for g in app.admin.get_mix_groups()
if g["path"] == data.get("path")
][0]
group_users = [
user_parser(user)
for user in sorted_users
if name in user["keycloak_groups"]
]
except:
raise Error("not_found", "Group path not found in system")
elif data.get("keycloak_id"):
try:
name = [
g["name"]
for g in app.admin.get_mix_groups()
if g["id"] == data.get("keycloak_id")
][0]
group_users = [
user_parser(user)
for user in sorted_users
if name in user["keycloak_groups"]
]
except:
raise Error("not_found", "Group keycloak_id not found in system")
else:
raise Error("bad_request", "Incorrect data requested.")
return json.dumps(group_users), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/ddapi/roles", methods=["GET"])
@has_token
def ddapi_roles() -> OptionalJsonResponse:
if request.method == "GET":
roles = []
for role in sorted(app.admin.get_roles(), key=itemgetter("name")):
log.error(role)
roles.append(
{
"keycloak_id": role["id"],
"id": role["name"],
"name": role["name"],
"description": role.get("description", ""),
}
)
return json.dumps(roles), 200, {"Content-Type": "application/json"}
return None
@app.route("/ddapi/roles", methods=["GET"])
@has_token
def ddapi_roles():
if request.method == "GET":
roles = []
for role in sorted(app.admin.get_roles(), key=lambda k: k["name"]):
log.error(role)
roles.append(
{
"keycloak_id": role["id"],
"id": role["name"],
"name": role["name"],
"description": role.get("description", ""),
}
)
return json.dumps(roles), 200, {"Content-Type": "application/json"}
@app.route("/ddapi/role/users", methods=["POST"])
@has_token
def ddapi_role_users():
if request.method == "POST":
data = request.get_json(force=True)
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
if data.get("id", data.get("name")):
role_users = [
user_parser(user)
for user in sorted_users
if data.get("id", data.get("name")) in user["roles"]
]
elif data.get("keycloak_id"):
try:
id = [
r["id"]
for r in app.admin.get_roles()
if r["id"] == data.get("keycloak_id")
][0]
@app.json_route("/ddapi/role/users", methods=["POST"])
@has_token
def ddapi_role_users() -> OptionalJsonResponse:
if request.method == "POST":
data = request.get_json(force=True)
sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
if data.get("id", data.get("name")):
role_users = [
user_parser(user) for user in sorted_users if id in user["roles"]
user_parser(user)
for user in sorted_users
if data.get("id", data.get("name")) in user["roles"]
]
except:
raise Error("not_found", "Role keycloak_id not found in system")
else:
raise Error("bad_request", "Incorrect data requested.")
return json.dumps(role_users), 200, {"Content-Type": "application/json"}
elif data.get("keycloak_id"):
try:
id = [
r["id"]
for r in app.admin.get_roles()
if r["id"] == data.get("keycloak_id")
][0]
role_users = [
user_parser(user) for user in sorted_users if id in user["roles"]
]
except:
raise Error("not_found", "Role keycloak_id not found in system")
else:
raise Error("bad_request", "Incorrect data requested.")
return json.dumps(role_users), 200, {"Content-Type": "application/json"}
return None
## INDIVIDUAL ACTIONS
@app.route("/ddapi/user", methods=["POST"])
@app.route("/ddapi/user/<user_ddid>", methods=["PUT", "GET", "DELETE"])
@has_token
def ddapi_user(user_ddid=None):
if request.method == "GET":
user = app.admin.get_user_username(user_ddid)
if not user:
raise Error("not_found", "User id not found")
return json.dumps(user_parser(user)), 200, {"Content-Type": "application/json"}
if request.method == "DELETE":
user = app.admin.get_user_username(user_ddid)
if not user:
raise Error("not_found", "User id not found")
app.admin.delete_user(user["id"])
return json.dumps({}), 200, {"Content-Type": "application/json"}
if request.method == "POST":
data = request.get_json(force=True)
if not app.validators["user"].validate(data):
raise Error(
"bad_request",
"Data validation for user failed: ",
+str(app.validators["user"].errors),
traceback.format_exc(),
)
if app.admin.get_user_username(data["username"]):
raise Error("conflict", "User id already exists")
data = app.validators["user"].normalized(data)
keycloak_id = app.admin.add_user(data)
if not keycloak_id:
raise Error(
"precondition_required",
"Not all user groups already in system. Please create user groups before adding user.",
)
return (
json.dumps({"keycloak_id": keycloak_id}),
200,
{"Content-Type": "application/json"},
)
if request.method == "PUT":
user = app.admin.get_user_username(user_ddid)
if not user:
raise Error("not_found", "User id not found")
data = request.get_json(force=True)
if not app.validators["user_update"].validate(data):
raise Error(
"bad_request",
"Data validation for user failed: "
+ str(app.validators["user_update"].errors),
traceback.format_exc(),
)
data = {**user, **data}
data = app.validators["user_update"].normalized(data)
data = {**data, **{"username": user_ddid}}
data["roles"] = [data.pop("role")]
data["firstname"] = data.pop("first")
data["lastname"] = data.pop("last")
app.admin.user_update(data)
if data.get("password"):
app.admin.user_update_password(
user["id"], data["password"], data["password_temporary"]
)
return json.dumps({}), 200, {"Content-Type": "application/json"}
@app.route("/ddapi/username/<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):
## INDIVIDUAL ACTIONS
@app.json_route("/ddapi/user", methods=["POST"])
@app.json_route("/ddapi/user/<user_ddid>", methods=["PUT", "GET", "DELETE"])
@has_token
def ddapi_user(user_ddid : Optional[str]=None) -> OptionalJsonResponse:
uid : str = user_ddid if user_ddid else ''
if request.method == "GET":
user = app.admin.get_user_username(uid)
if not user:
raise Error("not_found", "User id not found")
return json.dumps(user_parser(user)), 200, {"Content-Type": "application/json"}
if request.method == "DELETE":
user = app.admin.get_user_username(uid)
if not user:
raise Error("not_found", "User id not found")
app.admin.delete_user(user["id"])
return json.dumps({}), 200, {"Content-Type": "application/json"}
if request.method == "POST":
data = request.get_json(force=True)
if not app.validators["user"].validate(data):
raise Error(
"bad_request",
"Data validation for mail failed: "
+ str(app.validators["mail"].errors),
"Data validation for user failed: "
+ str(app.validators["user"].errors),
traceback.format_exc(),
)
for user in data:
log.info("Added user email")
app.admin.set_nextcloud_user_mail(user)
return (
json.dumps("Users emails updated"),
200,
{"Content-Type": "application/json"},
)
if app.admin.get_user_username(data["username"]):
raise Error("conflict", "User id already exists")
data = app.validators["user"].normalized(data)
keycloak_id = app.admin.add_user(data)
if not keycloak_id:
raise Error(
"precondition_required",
"Not all user groups already in system. Please create user groups before adding user.",
)
return (
json.dumps({"keycloak_id": keycloak_id}),
200,
{"Content-Type": "application/json"},
)
def user_parser(user):
if request.method == "PUT":
user = app.admin.get_user_username(uid)
if not user:
raise Error("not_found", "User id not found")
data = request.get_json(force=True)
if not app.validators["user_update"].validate(data):
raise Error(
"bad_request",
"Data validation for user failed: "
+ str(app.validators["user_update"].errors),
traceback.format_exc(),
)
data = {**user, **data}
data = app.validators["user_update"].normalized(data)
data = {**data, **{"username": uid}}
data["roles"] = [data.pop("role")]
data["firstname"] = data.pop("first")
data["lastname"] = data.pop("last")
app.admin.user_update(data)
if data.get("password"):
app.admin.user_update_password(
user["id"], data["password"], data["password_temporary"]
)
return json.dumps({}), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/ddapi/username/<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 {
"keycloak_id": user["id"],
"id": user["username"],
@ -338,7 +346,7 @@ def user_parser(user):
}
def group_parser(group):
def group_parser(group : Dict[str, str]) -> Dict[str, Any]:
return {
"keycloak_id": group["id"],
"id": group["name"],
@ -348,7 +356,7 @@ def group_parser(group):
}
def filter_users(users, text):
def filter_users(users : Iterable[Dict[str, Any]], text : str) -> List[Dict[str, Any]]:
return [
user
for user in users

View File

@ -1,5 +1,6 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
@ -20,6 +21,7 @@
import concurrent.futures
import json
import logging as log
from operator import itemgetter
import os
import re
import sys
@ -33,12 +35,15 @@ from uuid import uuid4
from flask import Response, jsonify, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from admin import app
from typing import TYPE_CHECKING, cast, Any, Callable, Dict, List, Optional, Tuple
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
from ..lib.helpers import system_group
from .decorators import login_or_token
from .decorators import login_or_token, OptionalJsonResponse
threads = {"external": None}
# TODO: this is quirky and non-trivial to manage
threads : Dict[str, threading.Thread] = {}
# q = Queue.Queue()
from keycloak.exceptions import KeycloakGetError
@ -46,536 +51,444 @@ from keycloak.exceptions import KeycloakGetError
from ..lib.dashboard import Dashboard
from ..lib.exceptions import UserExists, UserNotFound
dashboard = Dashboard()
from ..lib.legal import get_legal, gen_legal_if_not_exists, new_legal
@app.route("/sysadmin/api/resync")
@app.route("/api/resync")
@login_required
def resync():
return (
json.dumps(app.admin.resync_data()),
200,
{"Content-Type": "application/json"},
)
@app.route("/api/users", methods=["GET", "PUT"])
@app.route("/api/users/<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":
def run_in_thread(
op : Callable[..., Any],
args : Tuple = tuple(),
err_msg : str = "Something went wrong",
err_code : int = 500,
busy_err_msg : str ="Precondition failed: already operating users"
) -> OptionalJsonResponse:
if threads.get("external", None) is not None:
if threads["external"].is_alive():
return (
json.dumps(app.admin.delete_keycloak_users()),
200,
json.dumps(
{"msg": busy_err_msg}
),
412,
{"Content-Type": "application/json"},
)
if provider == "nextcloud":
return (
json.dumps(app.admin.delete_nextcloud_users()),
200,
{"Content-Type": "application/json"},
)
if provider == "moodle":
return (
json.dumps(app.admin.delete_moodle_users()),
200,
{"Content-Type": "application/json"},
)
if request.method == "POST":
if current_user.role != "admin":
return json.dumps({}), 301, {"Content-Type": "application/json"}
if provider == "moodle":
return (
json.dumps(app.admin.sync_to_moodle()),
200,
{"Content-Type": "application/json"},
)
if provider == "nextcloud":
return (
json.dumps(app.admin.sync_to_nextcloud()),
200,
{"Content-Type": "application/json"},
)
if request.method == "PUT" and not provider:
if current_user.role != "admin":
return json.dumps({}), 301, {"Content-Type": "application/json"}
if "external" in threads.keys():
if threads["external"] is not None and threads["external"].is_alive():
return (
json.dumps(
{"msg": "Precondition failed: already working with users"}
),
412,
{"Content-Type": "application/json"},
)
else:
threads["external"] = None
try:
threads["external"] = threading.Thread(
target=app.admin.update_users_from_keycloak, args=()
)
threads["external"].start()
return json.dumps({}), 200, {"Content-Type": "application/json"}
except:
log.error(traceback.format_exc())
return (
json.dumps({"msg": "Add user error."}),
500,
{"Content-Type": "application/json"},
)
# return json.dumps(app.admin.update_users_from_keycloak()), 200, {'Content-Type': 'application/json'}
users = app.admin.get_mix_users()
if current_user.role != "admin":
for user in users:
user["keycloak_groups"] = [
g for g in user["keycloak_groups"] if not system_group(g)
]
return json.dumps(users), 200, {"Content-Type": "application/json"}
@app.route("/api/users_bulk/<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:
if not group_id:
return (
json.dumps({"error": "bad_request","msg":"Bad request"}),
400,
{"Content-Type": "application/json"},
)
res = app.admin.delete_group_by_id(group_id)
return json.dumps(res), 200, {"Content-Type": "application/json"}
@app.route("/api/groups")
@app.route("/api/groups/<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)
del threads["external"]
try:
threads["external"] = threading.Thread(
target=app.admin.sync_external, args=(data,)
target=op, args=args
)
# TODO: this probably returns immediately and client gets no real feedback
threads["external"].start()
return json.dumps({}), 200, {"Content-Type": "application/json"}
if request.method == "DELETE":
print("RESET")
app.admin.reset_external()
return json.dumps({}), 200, {"Content-Type": "application/json"}
return json.dumps({}), 500, {"Content-Type": "application/json"}
@app.route("/api/external/users")
@login_required
def external_users_list():
while threads["external"] is not None and threads["external"].is_alive():
time.sleep(0.5)
return (
json.dumps(app.admin.get_external_users()),
200,
{"Content-Type": "application/json"},
)
@app.route("/api/external/groups")
@login_required
def external_groups_list():
while threads["external"] is not None and threads["external"].is_alive():
time.sleep(0.5)
return (
json.dumps(app.admin.get_external_groups()),
200,
{"Content-Type": "application/json"},
)
@app.route("/api/external/roles", methods=["PUT"])
@login_required
def external_roles():
if request.method == "PUT":
except:
log.error(traceback.format_exc())
return (
json.dumps(app.admin.external_roleassign(request.get_json(force=True))),
json.dumps({"msg": err_msg}),
err_code,
{"Content-Type": "application/json"},
)
def setup_app_views(app : "AdminFlaskApp") -> None:
dashboard = Dashboard(app)
@app.json_route("/sysadmin/api/resync")
@app.json_route("/api/resync")
@login_required
def resync() -> OptionalJsonResponse:
return (
json.dumps(app.admin.resync_data()),
200,
{"Content-Type": "application/json"},
)
def check_upload_errors(data):
email_regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
for u in data["data"]:
try:
user_groups = [g.strip() for g in u["groups"].split(",")]
except:
resp = {
"pass": False,
"msg": "User " + u["username"] + " has invalid groups: " + u["groups"],
}
log.error(resp)
return resp
@app.json_route("/api/users", methods=["GET", "PUT"])
@app.json_route("/api/users/<provider>", methods=["POST", "PUT", "GET", "DELETE"])
@login_or_token
def users(provider : bool=False) -> OptionalJsonResponse:
if request.method == "DELETE":
if current_user.role != "admin":
return json.dumps({}), 301, {"Content-Type": "application/json"}
if provider == "keycloak":
return (
json.dumps(app.admin.delete_keycloak_users()),
200,
{"Content-Type": "application/json"},
)
if provider == "nextcloud":
return (
json.dumps(app.admin.delete_nextcloud_users()),
200,
{"Content-Type": "application/json"},
)
if provider == "moodle":
return (
json.dumps(app.admin.delete_moodle_users(app)),
200,
{"Content-Type": "application/json"},
)
if request.method == "POST":
if current_user.role != "admin":
return json.dumps({}), 301, {"Content-Type": "application/json"}
if provider == "moodle":
return (
json.dumps(app.admin.sync_to_moodle()),
200,
{"Content-Type": "application/json"},
)
if provider == "nextcloud":
return (
json.dumps(app.admin.sync_to_nextcloud()),
200,
{"Content-Type": "application/json"},
)
if request.method == "PUT" and not provider:
if current_user.role != "admin":
return json.dumps({}), 301, {"Content-Type": "application/json"}
if not re.fullmatch(email_regex, u["email"]):
resp = {
"pass": False,
"msg": "User " + u["username"] + " has invalid email: " + u["email"],
}
log.error(resp)
return resp
return run_in_thread(app.admin.update_users_from_keycloak, err_msg="Add user error.")
if u["role"] not in ["admin", "manager", "teacher", "student"]:
if u["role"] == "":
users = app.admin.get_mix_users()
if current_user.role != "admin":
for user in users:
user["keycloak_groups"] = [
g for g in user["keycloak_groups"] if not system_group(g)
]
return json.dumps(users), 200, {"Content-Type": "application/json"}
@app.json_route("/api/users_bulk/<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 = {
"pass": False,
"msg": "User " + u["username"] + " has no role assigned!",
"msg": "User " + u["username"] + " has invalid groups: " + u["groups"],
}
log.error(resp)
return resp
resp = {
"pass": False,
"msg": "User " + u["username"] + " has invalid role: " + u["role"],
}
log.error(resp)
return resp
return {"pass": True, "msg": ""}
if not re.fullmatch(email_regex, u["email"]):
resp = {
"pass": False,
"msg": "User " + u["username"] + " has invalid email: " + u["email"],
}
log.error(resp)
return resp
if u["role"] not in ["admin", "manager", "teacher", "student"]:
if u["role"] == "":
resp = {
"pass": False,
"msg": "User " + u["username"] + " has no role assigned!",
}
log.error(resp)
return resp
resp = {
"pass": False,
"msg": "User " + u["username"] + " has invalid role: " + u["role"],
}
log.error(resp)
return resp
return {"pass": True, "msg": ""}
@app.route("/api/dashboard/<item>", methods=["PUT"])
@login_required
def dashboard_put(item):
if item == "colours":
try:
data = request.get_json(force=True)
dashboard.update_colours(data)
except:
log.error(traceback.format_exc())
return json.dumps({"colours": data}), 200, {"Content-Type": "application/json"}
if item == "menu":
try:
data = request.get_json(force=True)
dashboard.update_menu(data)
except:
log.error(traceback.format_exc())
return json.dumps(data), 200, {"Content-Type": "application/json"}
if item == "logo":
dashboard.update_logo(request.files["croppedImage"])
return json.dumps({}), 200, {"Content-Type": "application/json"}
if item == "background":
dashboard.update_background(request.files["croppedImage"])
return json.dumps({}), 200, {"Content-Type": "application/json"}
return (
json.dumps(
{
"error": "update_error",
"msg": "Error updating item " + item + "\n" + traceback.format_exc(),
}
),
500,
{"Content-Type": "application/json"},
)
@app.route("/api/legal/<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
@app.json_route("/api/dashboard/<item>", methods=["PUT"])
@login_required
def dashboard_put(item : str) -> OptionalJsonResponse:
if item == "colours":
try:
data = data = request.get_json(force=True)
html = data["html"]
lang = data["lang"]
if not lang or lang not in ["ca","es","en","fr"]:
lang="ca"
new_legal(lang,html)
data = request.get_json(force=True)
dashboard.update_colours(data)
except:
log.error(traceback.format_exc())
return json.dumps({"colours": data}), 200, {"Content-Type": "application/json"}
if item == "menu":
try:
data = request.get_json(force=True)
dashboard.update_menu(data)
except:
log.error(traceback.format_exc())
return json.dumps(data), 200, {"Content-Type": "application/json"}
# if item == "privacy":
# data = None
# try:
# data = request.json
# html = data["html"]
# lang = data["lang"]
# except:
# log.error(traceback.format_exc())
# return json.dumps(data), 200, {'Content-Type': 'application/json'}
if item == "logo":
dashboard.update_logo(request.files["croppedImage"])
return json.dumps({}), 200, {"Content-Type": "application/json"}
if item == "background":
dashboard.update_background(request.files["croppedImage"])
return json.dumps({}), 200, {"Content-Type": "application/json"}
return (
json.dumps(
{
"error": "update_error",
"msg": "Error updating item " + item + "\n" + traceback.format_exc(),
}
),
500,
{"Content-Type": "application/json"},
)
@app.json_route("/api/legal/<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 © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
@ -21,41 +22,44 @@ import os
from flask import flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required, login_user, logout_user
from werkzeug.wrappers import Response
from admin import app
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
from ..auth.authentication import *
@app.route("/", methods=["GET", "POST"])
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
if request.form["user"] == "" or request.form["password"] == "":
flash("Can't leave it blank", "danger")
elif request.form["user"].startswith(" "):
flash("Username not found or incorrect password.", "warning")
else:
ram_user = ram_users.get(request.form["user"])
if ram_user and request.form["password"] == ram_user["password"]:
user = User(
{
"id": ram_user["id"],
"password": ram_user["password"],
"role": ram_user["role"],
"active": True,
}
)
login_user(user)
flash("Logged in successfully.", "success")
return redirect(url_for("web_users"))
else:
def setup_login_views(app : "AdminFlaskApp") -> None:
@app.route("/", methods=["GET", "POST"])
@app.route("/login", methods=["GET", "POST"])
def login() -> Response:
if request.method == "POST":
if request.form["user"] == "" or request.form["password"] == "":
flash("Can't leave it blank", "danger")
elif request.form["user"].startswith(" "):
flash("Username not found or incorrect password.", "warning")
return render_template("login.html")
else:
ram_user = ram_users.get(request.form["user"])
if ram_user and request.form["password"] == ram_user["password"]:
user = User(
id = ram_user["id"],
password = ram_user["password"],
role = ram_user["role"],
active = True,
)
login_user(user)
flash("Logged in successfully.", "success")
return redirect(url_for("web_users"))
else:
flash("Username not found or incorrect password.", "warning")
o : Response = app.make_response(render_template("login.html"))
return o
@app.route("/logout", methods=["GET"])
@login_required
def logout():
logout_user()
return redirect(url_for("login"))
@app.route("/logout", methods=["GET"])
@login_required
def logout() -> Response:
logout_user()
return redirect(url_for("login"))

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

View File

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

View File

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

View File

@ -88,11 +88,6 @@ engine.io@~6.1.0:
engine.io-parser "~5.0.0"
ws "~8.2.3"
font-linux@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/font-linux/-/font-linux-0.6.1.tgz#d586f46336b7da06ea3b7f10f7aee2b6346eed4f"
integrity sha1-1Yb0Yza32gbqO38Q967itjRu7U8=
gentelella@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/gentelella/-/gentelella-1.4.0.tgz#b3d15fd9c40c6ea47dc7f36290c8f89aee95efc5"

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

View File

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

View File

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

View File

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

View File

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

View File

@ -55,6 +55,39 @@ Aquí tens alguns recursos per guiar-te més:
- [Post-instal·lació](post-install.ca.md)
- [Codi font](https://gitlab.com/DD-workspace/DD)
# Per què comença la història de git aquí?
<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).
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)
- [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).
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)
- [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).
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
La instal·lació modifica el fitxer dd.conf per dd.conf.sample (el substitueix). Idealment inicialitza les variables des de la comanda dd-install.sh:
```
> DD_NETWORK_MTU=1450 ./dd-install.sh
```
## Interactivament
Podem fer servir l'script [`dd-install.sh`][dd-install.sh] sense arguments per
@ -42,14 +46,15 @@ Under which DOMAIN will you install DD? example.org
You will need to setup DNS entries for:
- [ ] moodle.example.org
- [ ] nextcloud.example.org
- [ ] wp.example.org
- [ ] oof.example.org
- [ ] sso.example.org
- [ ] pad.example.org
- [ ] admin.example.org
- [ ] api.example.org
- [ ] moodle.dd.004.es
- [ ] nextcloud.dd.004.es
- [ ] wp.dd.004.es
- [ ] oof.dd.004.es
- [ ] sso.dd.004.es
- [ ] pad.dd.004.es
- [ ] admin.dd.004.es
- [ ] api.dd.004.es
- [ ] correu.dd.004.es
What is the short title of the DD instance? [DD]
@ -77,6 +82,9 @@ Opcionalment es poden indicar els logos ubicant els fitxers <code>.png</code>
al servidor i indicant la seva ruta quan l'instal·lador les demana.
</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

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.