From 66c2544a7100787f02420f1d1d74de03b8213214 Mon Sep 17 00:00:00 2001 From: Evilham Date: Mon, 18 Jul 2022 19:12:04 +0200 Subject: [PATCH 01/18] [net] Add environment variable for MTU This enables proper function in certain setups where MTU is lower than the default (1500). Closes #1 Reported by: @palaueb --- dd-apps/docker/network.yml | 3 +++ dd-sso/docker-compose-parts/network.yml | 3 +++ dd.conf.sample | 3 +++ 3 files changed, 9 insertions(+) diff --git a/dd-apps/docker/network.yml b/dd-apps/docker/network.yml index 038d81e..d9f79fd 100644 --- a/dd-apps/docker/network.yml +++ b/dd-apps/docker/network.yml @@ -21,3 +21,6 @@ version: '3.7' networks: dd_net: name: dd_net + driver: bridge + driver_opts: + com.docker.network.driver.mtu: ${NETWORK_MTU:-1500} diff --git a/dd-sso/docker-compose-parts/network.yml b/dd-sso/docker-compose-parts/network.yml index 038d81e..d9f79fd 100644 --- a/dd-sso/docker-compose-parts/network.yml +++ b/dd-sso/docker-compose-parts/network.yml @@ -21,3 +21,6 @@ version: '3.7' networks: dd_net: name: dd_net + driver: bridge + driver_opts: + com.docker.network.driver.mtu: ${NETWORK_MTU:-1500} diff --git a/dd.conf.sample b/dd.conf.sample index 5cd9119..0b924c7 100644 --- a/dd.conf.sample +++ b/dd.conf.sample @@ -195,3 +195,6 @@ POSTGRESQL_IMG=postgres:14.1-alpine3.15 ## MINIO #MINIO_IMG=mino/minio:RELEASE.2022-01-25T19-56-04Z + +## Network settings +#NETWORK_MTU=1500 From 4ce03f8690c4f6a4c16eb879648c3009f1ca6c1c Mon Sep 17 00:00:00 2001 From: palaueb Date: Wed, 20 Jul 2022 16:58:38 +0200 Subject: [PATCH 02/18] =?UTF-8?q?Afegim=20informaci=C3=B3=20sobre=20com=20?= =?UTF-8?q?fer=20que=20funcioni=20amb=20un=20certificat=20extern=20a=20let?= =?UTF-8?q?'s=20encrypt.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/install.ca.md | 7 +++++++ docs/wildcard.ca.md | 46 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 docs/wildcard.ca.md diff --git a/docs/install.ca.md b/docs/install.ca.md index f83adb4..25688be 100644 --- a/docs/install.ca.md +++ b/docs/install.ca.md @@ -12,7 +12,11 @@ capaç d'executar docker-compose v1.28 o més nova. [dd.conf.sample]: https://gitlab.com/DD-workspace/DD/-/blob/main/dd.conf.sample +La instal·lació modifica el fitxer dd.conf per dd.conf.sample (el substitueix). Idealment edita la configuració en el fitxer dd.conf.sample, o bé 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 @@ -77,6 +81,9 @@ Opcionalment es poden indicar els logos ubicant els fitxers .png al servidor i indicant la seva ruta quan l'instal·lador les demana. +
Certificat preexistent +Tens la posibilitat d'utilitzar el teu propi certificat, ja sigui wildcard o bé SAN. Pots llegir-ne mes a l'apartat [wildcard](wildcard.ca.md). +
## Automatitzat diff --git a/docs/wildcard.ca.md b/docs/wildcard.ca.md new file mode 100644 index 0000000..d730f37 --- /dev/null +++ b/docs/wildcard.ca.md @@ -0,0 +1,46 @@ +# Instal·lació d'un certificat propi wildcard + +Abans de res, atura la suite mitjançant la comanda del dd-ctl down: + +`/opt/src/DD# ./dd-ctl down` + +Per que el certificat sigui compatible amb DD, heu de fusionar el fullchain amb la key privada del certificat, la opció preferida es simplement imprimir els dos fitxers en un: + +`/tmp/certificat# cat fullchain.pem cert.key > /opt/DD/src/haproxy/certs/chain.pem` + +El fitxer fullchain.pem ha de contenir tota la cadena del certificat, la cert.key es la clau privada, ha de quedar mes o menys així: + +``` +> cat /opt/DD/src/haproxy/certs/chain.pem +-----BEGIN CERTIFICATE----- +YDC ... +... +... PnQP +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +5dSf ... +... +... Hwgs +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +sI3q ... +... +... vZas +-----END CERTIFICATE----- + +-----BEGIN RSA PRIVATE KEY----- +vzKJ ... +... +... 2dLs +-----END RSA PRIVATE KEY----- +``` + +Reviseu bé la ruta on heu copiat el fitxer chain.pem, que ha de ser /opt/DD/src/haproxy/certs + +Un cop fet això arrenquem de nou la suite amb la comanda dd-ctl up: + +`/opt/src/DD# ./dd-ctl up` + +I ja podem disfrutar del nostre DD correctamente instal·lat amb un certificat propi. \ No newline at end of file From 71b005a42bf8c7993978da8b270c0191ed184717 Mon Sep 17 00:00:00 2001 From: palaueb Date: Wed, 20 Jul 2022 17:17:41 +0200 Subject: [PATCH 03/18] Idealment no toquis el dd.conf.sample --- docs/install.ca.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/install.ca.md b/docs/install.ca.md index 25688be..e2c14a3 100644 --- a/docs/install.ca.md +++ b/docs/install.ca.md @@ -12,7 +12,7 @@ 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 edita la configuració en el fitxer dd.conf.sample, o bé inicialitza les variables des de la comanda dd-install.sh: +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 @@ -46,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] From c7dfd2f9d354643ff388bb1859e15f5efb971d9a Mon Sep 17 00:00:00 2001 From: Evilham Date: Mon, 18 Jul 2022 18:46:05 +0200 Subject: [PATCH 04/18] [README] Clarify why repo starts populated --- README.md | 17 +++++++++++++++++ docs/index.ca.md | 18 ++++++++++++++++++ docs/index.es.md | 23 +++++++++++++++++++++++ docs/index.md | 20 ++++++++++++++++++++ 4 files changed, 78 insertions(+) diff --git a/README.md b/README.md index 1955d8b..5495c42 100644 --- a/README.md +++ b/README.md @@ -87,3 +87,20 @@ resources to aid you further: - **User handbook**: [https://dd.digitalitzacio-democratica.xnet-x.net/manual-usuari/](https://dd.digitalitzacio-democratica.xnet-x.net/manual-usuari/) - **Admin/developer docs**: [https://dd.digitalitzacio-democratica.xnet-x.net/docs/](https://dd.digitalitzacio-democratica.xnet-x.net/docs/) - **Source code**: [https://gitlab.com/DD-workspace/DD](https://gitlab.com/DD-workspace/DD) + +# Why does git history start here? + +
Why does git history start here? + +In a constant-changing, high-pressure environment development sometimes gets +driven by deadlines and things can squeeze in that wouldn't have otherwise. + +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. +
diff --git a/docs/index.ca.md b/docs/index.ca.md index aa2e160..a2ddac9 100644 --- a/docs/index.ca.md +++ b/docs/index.ca.md @@ -55,6 +55,24 @@ Aquí tens alguns recursos per guiar-te més: - [Post-instal·lació](post-install.ca.md) - [Codi font](https://gitlab.com/DD-workspace/DD) +# Per què comença la història de git aquí? + +
Per què comença la història de git aquí + +En un desenvolupament amb canvis constants i molta pressió, a vegades el +desenvolupament es veu afectat per les dates d'entrega i passen coses que no +haguéssin passat d'una altra manera. + +Hi vam fer molta feina per estabilitzar el codi i netejar el repositori abans +de l'anunci públic al I Curs Internacional d'Educació Digital Democràtica i Open Edtech. + +Fent servir aquella versió com a punt de partida ens ha deixat amb el repo que +trobeu aquí, on els canvis seran revisats abans d'acceptar-los i qualsevol +persona és benvinguda. + +Si mai hi ha dubtes respecte l'autoria, si us plau comproveu la capcelera de llicència de cada fitxer. +
+ Aquest web està fet amb [MkDocs](https://gitlab.com/pages/mkdocs). Podeu [veure i modificar el codi font](https://gitlab.com/DD-workspace/DD). diff --git a/docs/index.es.md b/docs/index.es.md index 5776570..baee3b5 100644 --- a/docs/index.es.md +++ b/docs/index.es.md @@ -54,5 +54,28 @@ Aquí tienes algunos recursos para guiarte más: - [Post-instalación](post-install.ca.md) - [Código fuente](https://gitlab.com/DD-workspace/DD) + +# ¿Por qué comienza la historia de git aquí? + +
¿Por qué comienza la historia de git aquí? + +En un entorno con cambios contantes y mucha presión, a veces el desarrollo se +ve afectado por las fechas de entrega y pasan cosas que no habrían pasado de +otra forma. + +Se hizo mucho trabajo para estabilizar el código y limpiar el repositorio antes +del anuncio público en el +I Curso Internacional de Educación Digital Democrática y Open Edtech. + +Usar esa versión como punto de partida nos dejó con el repositorio que tenemos +aquí, donde los cambios serán revisados antes de aceptarlos y cualquier persona +es bienvenida. + +Si en algún momento hay dudas respecto a la autoría, por favor comprovad las +cabeceras de licencia en cada fichero. +
+ + + Página creada con [MkDocs](https://gitlab.com/pages/mkdocs). Puedes [ver y modificar su código](https://gitlab.com/DD-workspace/DD). diff --git a/docs/index.md b/docs/index.md index 34eaa79..1dd7f33 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,5 +52,25 @@ resources to aid you further: - [Post-install](post-install.ca.md) - [Source code](https://gitlab.com/DD-workspace/DD) +# Why does git history start here? + +
Why does git history start here? + +In a constant-changing, high-pressure environment development sometimes gets +driven by deadlines and things can squeeze in that wouldn't have otherwise. + +A lot of work went into stabilising the code and cleaning the repo before the +public announcement on the +1st International Congress on Democratic Digital Education and Open Edtech. + +Using that version as a clean slate got us to the repo you see here, where +changes will be reviewed before going in and anyone is welcome. + +When in doubt about authorship, please check each file's license headers. +
+ + + 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). + From 4e625e22134d06b4eeca4876217e51d98d89a5dd Mon Sep 17 00:00:00 2001 From: Evilham Date: Sun, 10 Jul 2022 19:57:25 +0200 Subject: [PATCH 05/18] [admin] Update yarn.lock to prevent dirty workdirs --- dd-sso/admin/src/admin/yarn.lock | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dd-sso/admin/src/admin/yarn.lock b/dd-sso/admin/src/admin/yarn.lock index 867ce45..172b5b7 100644 --- a/dd-sso/admin/src/admin/yarn.lock +++ b/dd-sso/admin/src/admin/yarn.lock @@ -88,11 +88,6 @@ engine.io@~6.1.0: engine.io-parser "~5.0.0" ws "~8.2.3" -font-linux@^0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/font-linux/-/font-linux-0.6.1.tgz#d586f46336b7da06ea3b7f10f7aee2b6346eed4f" - integrity sha1-1Yb0Yza32gbqO38Q967itjRu7U8= - gentelella@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/gentelella/-/gentelella-1.4.0.tgz#b3d15fd9c40c6ea47dc7f36290c8f89aee95efc5" From 9dd719a71237a179e1eaa6afaf997808b82cae79 Mon Sep 17 00:00:00 2001 From: Evilham Date: Fri, 22 Jul 2022 13:25:13 +0200 Subject: [PATCH 06/18] [README] List commiters before git changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 20 +++++++++++++++++--- docs/index.ca.md | 23 +++++++++++++++++++---- docs/index.es.md | 25 ++++++++++++++++++++----- docs/index.md | 22 +++++++++++++++++++--- 4 files changed, 75 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5495c42..4512b08 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,6 @@ resources to aid you further:
Why does git history start here? -In a constant-changing, high-pressure environment development sometimes gets -driven by deadlines and things can squeeze in that wouldn't have otherwise. - 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/). @@ -103,4 +100,21 @@ Using that version as a clean slate got us to the repo you see here, where changes will be reviewed before going in and anyone is welcome. When in doubt about authorship, please check each file's license headers. + +The authorship of the previous commits is from: + +- Josep Maria Viñolas Auquer +- Simó Albert i Beltran +- Alberto Larraz Dalmases +- Yoselin Ribero +- Elena Barrios Galán +- Melina Gamboa +- Antonio Manzano +- Cecilia Bayo +- Naomi Hidalgo +- Joan Cervan Andreu +- Jose Antonio Exposito Garcia +- Raúl FS +- Unai Tolosa Pontesta +- Evilham
diff --git a/docs/index.ca.md b/docs/index.ca.md index a2ddac9..a2cb5c8 100644 --- a/docs/index.ca.md +++ b/docs/index.ca.md @@ -59,10 +59,6 @@ Aquí tens alguns recursos per guiar-te més:
Per què comença la història de git aquí -En un desenvolupament amb canvis constants i molta pressió, a vegades el -desenvolupament es veu afectat per les dates d'entrega i passen coses que no -haguéssin passat d'una altra manera. - Hi vam fer molta feina per estabilitzar el codi i netejar el repositori abans de l'anunci públic al I Curs Internacional d'Educació Digital Democràtica i Open Edtech. @@ -71,6 +67,25 @@ trobeu aquí, on els canvis seran revisats abans d'acceptar-los i qualsevol persona és benvinguda. Si mai hi ha dubtes respecte l'autoria, si us plau comproveu la capcelera de llicència de cada fitxer. + +L'autoria dels commits previs és de: + +
    +
  • Josep Maria Viñolas Auquer
  • +
  • Simó Albert i Beltran
  • +
  • Alberto Larraz Dalmases
  • +
  • Yoselin Ribero
  • +
  • Elena Barrios Galán
  • +
  • Melina Gamboa
  • +
  • Antonio Manzano
  • +
  • Cecilia Bayo
  • +
  • Naomi Hidalgo
  • +
  • Joan Cervan Andreu
  • +
  • Jose Antonio Exposito Garcia
  • +
  • Raúl FS
  • +
  • Unai Tolosa Pontesta
  • +
  • Evilham
  • +
diff --git a/docs/index.es.md b/docs/index.es.md index baee3b5..4146f42 100644 --- a/docs/index.es.md +++ b/docs/index.es.md @@ -59,12 +59,8 @@ Aquí tienes algunos recursos para guiarte más:
¿Por qué comienza la historia de git aquí? -En un entorno con cambios contantes y mucha presión, a veces el desarrollo se -ve afectado por las fechas de entrega y pasan cosas que no habrían pasado de -otra forma. - Se hizo mucho trabajo para estabilizar el código y limpiar el repositorio antes -del anuncio público en el +del anuncio público en el I Curso Internacional de Educación Digital Democrática y Open Edtech. Usar esa versión como punto de partida nos dejó con el repositorio que tenemos @@ -73,6 +69,25 @@ es bienvenida. Si en algún momento hay dudas respecto a la autoría, por favor comprovad las cabeceras de licencia en cada fichero. + +La autoría de los commits previos es de: + +
    +
  • Josep Maria Viñolas Auquer
  • +
  • Simó Albert i Beltran
  • +
  • Alberto Larraz Dalmases
  • +
  • Yoselin Ribero
  • +
  • Elena Barrios Galán
  • +
  • Melina Gamboa
  • +
  • Antonio Manzano
  • +
  • Cecilia Bayo
  • +
  • Naomi Hidalgo
  • +
  • Joan Cervan Andreu
  • +
  • Jose Antonio Exposito Garcia
  • +
  • Raúl FS
  • +
  • Unai Tolosa Pontesta
  • +
  • Evilham
  • +
diff --git a/docs/index.md b/docs/index.md index 1dd7f33..112b14f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -56,9 +56,6 @@ resources to aid you further:
Why does git history start here? -In a constant-changing, high-pressure environment development sometimes gets -driven by deadlines and things can squeeze in that wouldn't have otherwise. - 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. @@ -67,6 +64,25 @@ 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
  • +
From 43248128079c700b761b9f7fca1c0b1642fdbb13 Mon Sep 17 00:00:00 2001 From: Evilham Date: Sun, 10 Jul 2022 20:31:57 +0200 Subject: [PATCH 07/18] [correu] Add registration for SAML client --- dd-ctl | 8 +- dd-sso/admin/src/saml_scripts/email_saml.py | 187 ++++++++++++++++++++ dd-sso/docker-compose-parts/admin.yml | 1 + dd.conf.sample | 2 + 4 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 dd-sso/admin/src/saml_scripts/email_saml.py diff --git a/dd-ctl b/dd-ctl index 8557fda..1412e2b 100755 --- a/dd-ctl +++ b/dd-ctl @@ -476,11 +476,9 @@ saml_certificates(){ echo " --> Setting up SAML for wordpress" docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 wordpress_saml.py" - # SAML PLUGIN MOODLE - # echo "To add SAML to moodle:" - # echo "1.-Activate SAML plugin in moodle extensions, regenerate certificate, lock certificate" - # echo "2.-Then run: docker exec -ti dd-sso-admin python3 /admin/nextcloud_saml.py" - # echo "3.-" + # SAML PLUGIN EMAIL + echo " --> Setting up SAML for email" + docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 email_saml.py" } wait_for_moodle(){ diff --git a/dd-sso/admin/src/saml_scripts/email_saml.py b/dd-sso/admin/src/saml_scripts/email_saml.py new file mode 100644 index 0000000..9e7e4dc --- /dev/null +++ b/dd-sso/admin/src/saml_scripts/email_saml.py @@ -0,0 +1,187 @@ +# +# Copyright © 2022 Evilham +# +# This file is part of DD +# +# DD is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# DD is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with DD. If not, see . +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import logging as log +import os +import time +import traceback +from typing import Any, Dict, Optional + +from lib.keycloak_client import KeycloakClient + +try: + # Python 3.9+ + from functools import cache # type: ignore # Currently targetting 3.8 +except ImportError: + # Up to python 3.8 + from functools import cached_property as cache + + +app: Dict[str, Dict[str, str]] = {} +app["config"] = {} + + +class SamlService(object): + """ + Generic class to manage a SAML service on keycloak + """ + + keycloak: KeycloakClient + domain: str = os.environ["DOMAIN"] + + def __init__(self): + self.keycloak = KeycloakClient() + + @cache + def public_cert(self) -> str: + """ + Read the public SAML certificate as used by keycloak + """ + ready = False + basepath = os.path.dirname(__file__) + while not ready: + # TODO: Check why they were using a loop + try: + with open( + os.path.abspath( + os.path.join(basepath, "../saml_certs/public.cert") + ), + "r", + ) as crt: + app["config"]["PUBLIC_CERT"] = crt.read() + ready = True + except IOError: + log.warning("Could not get public certificate for SAML. Retrying...") + log.warning( + " You should generate them: /admin/saml_certs # openssl req -nodes -new -x509 -keyout private.key -out public.cert" + ) + time.sleep(2) + except: + log.error(traceback.format_exc()) + log.info("Got public SAML certificate") + return app["config"]["PUBLIC_CERT"] + + def get_client(self, client_id: str) -> Any: + # TODO: merge with keycloak_config.py + self.keycloak.connect() + k = self.keycloak.keycloak_admin + + clients = k.get_clients() + client = next(filter(lambda c: c["clientId"] == client_id, clients), None) + return (k, client) + + def set_client(self, client_id: str, client_overrides: Dict[str, Any]) -> str: + (k, client) = self.get_client(client_id) + if client is None: + client_uid = k.create_client(client_overrides) + else: + client_uid = client["id"] + k.update_client(client_uid, client_overrides) + return client_id + + def configure(self) -> None: + pass + + +class EmailSaml(SamlService): + client_name: str = "correu" + client_description: str = "Client for the DD-managed email service" + email_domain: str + + def __init__(self, email_domain: str, enabled: bool = False): + super(EmailSaml, self).__init__() + self.email_domain = email_domain + + @property + def enabled(self) -> bool: + return bool(self.email_domain) + + def configure(self) -> None: + srv_base = f"https://correu.{self.domain}" + client_id = f"{srv_base}/metadata/" + client_overrides: Dict[str, Any] = { + "name": self.client_name, + "description": self.client_description, + "clientId": client_id, + "baseUrl": f"{srv_base}/login", + "enabled": self.enabled, + "redirectUris": [ + f"{srv_base}/*", + ], + "webOrigins": [srv_base], + "consentRequired": False, + "protocol": "saml", + "attributes": { + "saml.assertion.signature": True, + "saml_idp_initiated_sso_relay_state": f"{srv_base}/login", + "saml_assertion_consumer_url_redirect": f"{srv_base}/acs", + "saml.force.post.binding": True, + "saml.multivalued.roles": False, + "saml.encrypt": False, + "saml_assertion_consumer_url_post": f"{srv_base}/acs", + "saml.server.signature": True, + "saml_idp_initiated_sso_url_name": f"{srv_base}/acs", + "saml.server.signature.keyinfo.ext": False, + "exclude.session.state.from.auth.response": False, + "saml_single_logout_service_url_redirect": f"{srv_base}/ls", + "saml.signature.algorithm": "RSA_SHA256", + "saml_force_name_id_format": False, + "saml.client.signature": False, + "tls.client.certificate.bound.access.tokens": False, + "saml.authnstatement": True, + "display.on.consent.screen": False, + "saml_name_id_format": "username", + "saml_signature_canonicalization_method": "http://www.w3.org/2001/10/xml-exc-c14n#", + "saml.onetimeuse.condition": False, + }, + "protocolMappers": [ + { + "name": "username", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": False, + "config": { + "attribute.nameformat": "Basic", + "user.attribute": "username", + "friendly.name": "username", + "attribute.name": "username", + }, + }, + { + "name": "email", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": False, + "config": { + "attribute.nameformat": "Basic", + "user.attribute": "email", + "friendly.name": "email", + "attribute.name": "email", + }, + }, + ], + } + self.set_client(client_id, client_overrides) + + +if __name__ == "__main__": + email_domain = os.environ.get("MANAGED_EMAIL_DOMAIN", "") + log.info("Configuring SAML client for Email") + EmailSaml(email_domain).configure() diff --git a/dd-sso/docker-compose-parts/admin.yml b/dd-sso/docker-compose-parts/admin.yml index 46c7afb..8f65a3b 100644 --- a/dd-sso/docker-compose-parts/admin.yml +++ b/dd-sso/docker-compose-parts/admin.yml @@ -48,3 +48,4 @@ services: environment: - VERIFY="false" # In development do not verify certificates - DOMAIN=${DOMAIN} + - MANAGED_EMAIL_DOMAIN=${MANAGED_EMAIL_DOMAIN} diff --git a/dd.conf.sample b/dd.conf.sample index 0b924c7..132534f 100644 --- a/dd.conf.sample +++ b/dd.conf.sample @@ -22,6 +22,8 @@ TITLE="DD" TITLE_SHORT="DD" DOMAIN=mydomain.com +# If defined, DD will be managing email for this domain +#MANAGED_EMAIL_DOMAIN=${DOMAIN} LETSENCRYPT_DNS= LETSENCRYPT_EMAIL= # Generate letsencrypt certificate for root domain From 4fb3b02a4600c7ed4419319e288192b9ab992a95 Mon Sep 17 00:00:00 2001 From: Evilham Date: Thu, 28 Jul 2022 17:59:18 +0200 Subject: [PATCH 08/18] [sso-admin] Remove left over OIDC code This was left over by a previous contributor and is not being used and was never really used; it looks like we can safely remove these. --- dd-sso/admin/src/admin/auth/authentication.py | 18 ------------- dd-sso/admin/src/admin/views/WebViews.py | 27 ------------------- dd-sso/admin/src/client_secrets.json | 13 --------- 3 files changed, 58 deletions(-) delete mode 100644 dd-sso/admin/src/client_secrets.json diff --git a/dd-sso/admin/src/admin/auth/authentication.py b/dd-sso/admin/src/admin/auth/authentication.py index f7da0be..5d9ea9b 100644 --- a/dd-sso/admin/src/admin/auth/authentication.py +++ b/dd-sso/admin/src/admin/auth/authentication.py @@ -23,24 +23,6 @@ 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) diff --git a/dd-sso/admin/src/admin/views/WebViews.py b/dd-sso/admin/src/admin/views/WebViews.py index c8a62e7..d72db1e 100644 --- a/dd-sso/admin/src/admin/views/WebViews.py +++ b/dd-sso/admin/src/admin/views/WebViews.py @@ -49,33 +49,6 @@ avatars = Avatars() from ..lib.legal import gen_legal_if_not_exists -""" OIDC TESTS """ -# from ..auth.authentication import oidc - -# @app.route('/custom_callback') -# @oidc.custom_callback -# def callback(data): -# return 'Hello. You submitted %s' % data - -# @app.route('/private') -# @oidc.require_login -# def hello_me(): -# info = oidc.user_getinfo(['email', 'openid_id']) -# return ('Hello, %s (%s)! Return' % -# (info.get('email'), info.get('openid_id'))) - - -# @app.route('/api') -# @oidc.accept_token(True, ['openid']) -# def hello_api(): -# return json.dumps({'hello': 'Welcome %s' % g.oidc_token_info['sub']}) - - -# @app.route('/logout') -# def logoutoidc(): -# oidc.logout() -# return 'Hi, you have been logged out! Return' -""" OIDC TESTS """ def render_template(*args, **kwargs): kwargs["DOMAIN"] = os.environ["DOMAIN"] diff --git a/dd-sso/admin/src/client_secrets.json b/dd-sso/admin/src/client_secrets.json deleted file mode 100644 index 091d005..0000000 --- a/dd-sso/admin/src/client_secrets.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "web": { - "auth_uri": "https://sso.[[DOMAIN]]/auth/realms/master/protocol/openid-connect/auth", - "client_id": "adminapp", - "client_secret": "8a9e5a2e-3be9-43e3-9c47-1796f0d5ab72", - "redirect_uris": [ - "https://sso.[[DOMAIN]]/oidc_callback" - ], - "userinfo_uri": "https://sso.[[DOMAIN]]/auth/realms/master/protocol/openid-connect/userinfo", - "token_uri": "https://sso.[[DOMAIN]]/auth/realms/master/protocol/openid-connect/token", - "token_introspection_uri": "https://sso.[[DOMAIN]]/auth/realms/master/protocol/openid-connect/token/introspect" - } -} \ No newline at end of file From 1f962dbef734106988d288847e43ce5f1e32d8c6 Mon Sep 17 00:00:00 2001 From: Evilham Date: Thu, 28 Jul 2022 18:28:22 +0200 Subject: [PATCH 09/18] [sso-admin] Fix secret handling in check script --- dd-sso/admin/src/tests/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dd-sso/admin/src/tests/api.py b/dd-sso/admin/src/tests/api.py index d02b45c..e2ed963 100644 --- a/dd-sso/admin/src/tests/api.py +++ b/dd-sso/admin/src/tests/api.py @@ -29,8 +29,8 @@ import requests from jose import jwt ## SETUP -domain = "admin.[YOURDOMAIN]" -secret = "[your API_SECRET]" +domain = f"admin.{ os.environ['DOMAIN'] }" +secret = os.environ['API_SECRET'] ## END SETUP From e98323913d5846f3c7d525fcae18d18729a90395 Mon Sep 17 00:00:00 2001 From: Evilham Date: Thu, 28 Jul 2022 22:28:55 +0200 Subject: [PATCH 10/18] [sso-admin] Add base for QA checks This is all relative to the dd-sso/admin directory. With https://pipenv.pypa.io/en/latest/ it is simple to setup a development environment (pipenv install --dev). By running: echo "PYTHONPATH=$(pwd)/src" > .env The admin module will be loaded in the virtualenvironment and e.g. running mypy src/admin will throw different errors in the existing code. --- dd-sso/admin/.gitignore | 2 + dd-sso/admin/Pipfile | 35 ++ dd-sso/admin/Pipfile.lock | 765 ++++++++++++++++++++++++++++++++++++ dd-sso/admin/mypy.ini | 34 ++ dd-sso/admin/pyproject.toml | 2 + 5 files changed, 838 insertions(+) create mode 100644 dd-sso/admin/.gitignore create mode 100644 dd-sso/admin/Pipfile create mode 100644 dd-sso/admin/Pipfile.lock create mode 100644 dd-sso/admin/mypy.ini create mode 100644 dd-sso/admin/pyproject.toml diff --git a/dd-sso/admin/.gitignore b/dd-sso/admin/.gitignore new file mode 100644 index 0000000..1954d2f --- /dev/null +++ b/dd-sso/admin/.gitignore @@ -0,0 +1,2 @@ +secret +.dmypy.json diff --git a/dd-sso/admin/Pipfile b/dd-sso/admin/Pipfile new file mode 100644 index 0000000..d2298b5 --- /dev/null +++ b/dd-sso/admin/Pipfile @@ -0,0 +1,35 @@ +[[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 = "*" + +[dev-packages] +mypy = "*" +black = "*" +isort = "*" +types-flask = "*" +types-requests = "*" +types-psycopg2 = "*" +types-pyyaml = "*" +types-python-jose = "*" +types-pillow = "*" + +[requires] +python_version = "3.8" diff --git a/dd-sso/admin/Pipfile.lock b/dd-sso/admin/Pipfile.lock new file mode 100644 index 0000000..3116d71 --- /dev/null +++ b/dd-sso/admin/Pipfile.lock @@ -0,0 +1,765 @@ +{ + "_meta": { + "hash": { + "sha256": "e5f3be6c5adeb1d2f9b30ff0f72d15c61724b87fe49de8feec0d93cbb2fb96be" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.8" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "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" + }, + "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" + }, + "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'", + "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" + }, + "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'", + "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'", + "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:6823851e179dcc157424175b5dc0e1204b1c949e1de32417ff2fbfa7e3d3f45b", + "sha256:f367d22b54239b09607fcd8d4514b86bac6bf7d6ed1d5bdfa41782ea62083b2a" + ], + "index": "pypi", + "version": "==9.2.0" + }, + "types-psycopg2": { + "hashes": [ + "sha256:14c779dcab18c31453fa1cad3cf4b1601d33540a344adead3c47a6b8091cd2fa", + "sha256:9b0e9e1f097b15cd9fa8aad2596a9e3082fd72f8d9cfe52b190cfa709105b6c0" + ], + "index": "pypi", + "version": "==2.9.18" + }, + "types-python-jose": { + "hashes": [ + "sha256:04fb427bc906e864e8c313db469920f71ffa85839b43fe355f7dc861330c6da3", + "sha256:a8331dd72da5adf03853d4c003d08d0621dd9eb7b7333571b04d81058c55fac6" + ], + "index": "pypi", + "version": "==3.3.4" + }, + "types-pyyaml": { + "hashes": [ + "sha256:7f7da2fd11e9bc1e5e9eb3ea1be84f4849747017a59fc2eee0ea34ed1147c2e0", + "sha256:8f890028123607379c63550179ddaec4517dc751f4c527a52bb61934bf495989" + ], + "index": "pypi", + "version": "==6.0.11" + }, + "types-requests": { + "hashes": [ + "sha256:98ab647ae88b5e2c41d6d20cfcb5117da1bea561110000b6fdeeea07b3e89877", + "sha256:ac618bfefcb3742eaf97c961e13e9e5a226e545eda4a3dbe293b898d40933ad1" + ], + "index": "pypi", + "version": "==2.28.5" + }, + "types-urllib3": { + "hashes": [ + "sha256:0d027fcd27dbb3cb532453b4d977e05bc1e13aefd70519866af211b3003d895d", + "sha256:73fd274524c3fc7cd8cd9ceb0cb67ed99b45f9cb2831013e46d50c1451044800" + ], + "version": "==1.26.17" + }, + "types-werkzeug": { + "hashes": [ + "sha256:194bd5715a13c598f05c63e8a739328657590943bce941e8a3619a6b5d4a54ec", + "sha256:5cc269604c400133d452a40cee6397655f878fc460e03fde291b9e3a5eaa518c" + ], + "version": "==1.0.9" + }, + "typing-extensions": { + "hashes": [ + "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02", + "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6" + ], + "markers": "python_version >= '3.7'", + "version": "==4.3.0" + } + } +} diff --git a/dd-sso/admin/mypy.ini b/dd-sso/admin/mypy.ini new file mode 100644 index 0000000..5d04d2f --- /dev/null +++ b/dd-sso/admin/mypy.ini @@ -0,0 +1,34 @@ +[mypy] +namespace_packages=True +#explicit_package_bases=True +#TODO: progress so we can enable this +#strict=True +# +check_untyped_defs=True +disallow_untyped_defs=True +warn_redundant_casts=True +warn_unused_configs= True +warn_unused_ignores = True +warn_no_return=True +warn_return_any=True +warn_unreachable=True +enable_error_code=unused-awaitable + +[mypy-cerberus.*] +ignore_missing_imports=True +[mypy-diceware.*] +ignore_missing_imports=True +[mypy-eventlet.*] +ignore_missing_imports=True +[mypy-flask_login.*] +ignore_missing_imports=True +[mypy-flask_socketio.*] +ignore_missing_imports=True +[mypy-keycloak.*] +ignore_missing_imports=True +[mypy-minio.*] +ignore_missing_imports=True +[mypy-mysql.*] +ignore_missing_imports=True +[mypy-schema.*] +ignore_missing_imports=True diff --git a/dd-sso/admin/pyproject.toml b/dd-sso/admin/pyproject.toml new file mode 100644 index 0000000..5d7bf33 --- /dev/null +++ b/dd-sso/admin/pyproject.toml @@ -0,0 +1,2 @@ +[tool.isort] +profile = "black" From 81fff214d5fc17de113feebf64ed36ed8194f1c2 Mon Sep 17 00:00:00 2001 From: Evilham Date: Fri, 29 Jul 2022 14:02:49 +0200 Subject: [PATCH 11/18] [sso-admin] Disentangle module and add type hints With this commit, code from the admin module can be re-used and thanks to adding type-hints in most places we are able to discover some bugs. This commit attempts to fix only that which was necessary to: - Add a reasonable amount of type hints - Disentangle the module There are already some issues that have been discovered by mypy. --- dd-sso/admin/src/admin/__init__.py | 109 +- dd-sso/admin/src/admin/auth/authentication.py | 33 +- dd-sso/admin/src/admin/auth/tokens.py | 20 +- dd-sso/admin/src/admin/flaskapp.py | 225 ++++ dd-sso/admin/src/admin/lib/admin.py | 306 +++--- dd-sso/admin/src/admin/lib/api_exceptions.py | 24 +- dd-sso/admin/src/admin/lib/avatars.py | 35 +- dd-sso/admin/src/admin/lib/dashboard.py | 27 +- dd-sso/admin/src/admin/lib/events.py | 51 +- dd-sso/admin/src/admin/lib/helpers.py | 55 +- dd-sso/admin/src/admin/lib/keycloak_client.py | 155 +-- dd-sso/admin/src/admin/lib/legal.py | 24 +- dd-sso/admin/src/admin/lib/load_config.py | 94 -- dd-sso/admin/src/admin/lib/moodle.py | 80 +- dd-sso/admin/src/admin/lib/mysql.py | 22 +- dd-sso/admin/src/admin/lib/nextcloud.py | 90 +- dd-sso/admin/src/admin/lib/postgres.py | 42 +- dd-sso/admin/src/admin/lib/postup.py | 24 +- dd-sso/admin/src/admin/views/ApiViews.py | 559 +++++----- dd-sso/admin/src/admin/views/AppViews.py | 975 +++++++++--------- dd-sso/admin/src/admin/views/LoginViews.py | 64 +- dd-sso/admin/src/admin/views/Socketio.py | 39 - dd-sso/admin/src/admin/views/WebViews.py | 142 +-- dd-sso/admin/src/admin/views/WpViews.py | 200 ++-- dd-sso/admin/src/admin/views/decorators.py | 26 +- dd-sso/admin/src/start.py | 14 +- dd-sso/docker-compose-parts/admin.yml | 2 + 27 files changed, 1773 insertions(+), 1664 deletions(-) create mode 100644 dd-sso/admin/src/admin/flaskapp.py delete mode 100644 dd-sso/admin/src/admin/lib/load_config.py delete mode 100644 dd-sso/admin/src/admin/views/Socketio.py diff --git a/dd-sso/admin/src/admin/__init__.py b/dd-sso/admin/src/admin/__init__.py index 014aa18..5a2863e 100644 --- a/dd-sso/admin/src/admin/__init__.py +++ b/dd-sso/admin/src/admin/__init__.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -20,107 +21,23 @@ import logging as log import os +import os.path -from flask import Flask, render_template, send_from_directory +from admin.flaskapp import AdminFlaskApp -app = Flask(__name__, static_url_path="") -app = Flask(__name__, template_folder="static/templates") -app.url_map.strict_slashes = False +def get_app() -> AdminFlaskApp: + app = AdminFlaskApp(__name__, template_folder="static/templates") -""" -App secret key for encrypting cookies -You can generate one with: - import os - os.urandom(24) -And paste it here. -""" -app.secret_key = "Change this key!/\xf7\x83\xbe\x17\xfa\xa3zT\n\\]m\xa6\x8bF\xdd\r\xf7\x9e\x1d\x1f\x14'" + """ + Debug should be removed on production! + """ + if app.debug: + log.warning("Debug mode: {}".format(app.debug)) + else: + log.info("Debug mode: {}".format(app.debug)) -print("Starting dd-sso api...") + return app -from admin.lib.load_config import loadConfig - -try: - loadConfig(app) -except: - print("Could not get environment variables...") - -from admin.lib.postup import Postup - -Postup() - -from admin.lib.admin import Admin - -app.admin = Admin() - -app.ready = False - -""" -Debug should be removed on production! -""" -if app.debug: - log.warning("Debug mode: {}".format(app.debug)) -else: - log.info("Debug mode: {}".format(app.debug)) - -""" -Serve static files -""" - - -@app.route("/build/") -def send_build(path): - return send_from_directory( - os.path.join(app.root_path, "node_modules/gentelella/build"), path - ) - - -@app.route("/vendors/") -def send_vendors(path): - return send_from_directory( - os.path.join(app.root_path, "node_modules/gentelella/vendors"), path - ) - - -@app.route("/node_modules/") -def send_nodes(path): - return send_from_directory(os.path.join(app.root_path, "node_modules"), path) - - -@app.route("/templates/") -def send_templates(path): - return send_from_directory(os.path.join(app.root_path, "templates"), path) - - -# @app.route('/templates/') -# def send_templates(path): -# return send_from_directory(os.path.join(app.root_path, 'static/templates'), path) - - -@app.route("/static/") -def send_static_js(path): - return send_from_directory(os.path.join(app.root_path, "static"), path) - - -@app.route("/avatars/") -def send_avatars_img(path): - return send_from_directory( - os.path.join(app.root_path, "../avatars/master-avatars"), path - ) - - -@app.route("/custom/") -def send_custom(path): - return send_from_directory(os.path.join(app.root_path, "../custom"), path) - - -# @app.errorhandler(404) -# def not_found_error(error): -# return render_template('page_404.html'), 404 - -# @app.errorhandler(500) -# def internal_error(error): -# return render_template('page_500.html'), 500 """ Import all views diff --git a/dd-sso/admin/src/admin/auth/authentication.py b/dd-sso/admin/src/admin/auth/authentication.py index 5d9ea9b..dfc758e 100644 --- a/dd-sso/admin/src/admin/auth/authentication.py +++ b/dd-sso/admin/src/admin/auth/authentication.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -21,13 +22,9 @@ import os from flask_login import LoginManager, UserMixin -from admin import app - - -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"]: { @@ -49,13 +46,19 @@ ram_users = { class User(UserMixin): - def __init__(self, dict): - self.id = dict["id"] - self.username = dict["id"] - self.password = dict["password"] - self.role = dict["role"] + def __init__(self, id : str, password : str, role : str, active : bool = True) -> None: + self.id = id + self.username = id + self.password = password + self.role = role + self.active = active +def setup_auth(app : "AdminFlaskApp") -> None: + login_manager = LoginManager() + login_manager.init_app(app) + login_manager.login_view = "login" -@login_manager.user_loader -def user_loader(username): - return User(ram_users[username]) + @login_manager.user_loader + def user_loader(username : str) -> User: + u = ram_users[username] + return User(id = u["id"], password = u["password"], role = u["role"]) diff --git a/dd-sso/admin/src/admin/auth/tokens.py b/dd-sso/admin/src/admin/auth/tokens.py index cc7b100..a244aff 100644 --- a/dd-sso/admin/src/admin/auth/tokens.py +++ b/dd-sso/admin/src/admin/auth/tokens.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -30,17 +31,18 @@ from functools import wraps from flask import request from jose import jwt +import jose.exceptions -from admin import app +from typing import Any -from ..lib.api_exceptions import Error +from admin.lib.api_exceptions import Error -def get_header_jwt_payload(): +def get_header_jwt_payload() -> Any: return get_token_payload(get_token_auth_header()) -def get_token_header(header): +def get_token_header(header : str) -> str: """Obtains the Access Token from the a Header""" auth = request.headers.get(header, None) if not auth: @@ -70,15 +72,15 @@ def get_token_header(header): return parts[1] # Token -def get_token_auth_header(): +def get_token_auth_header() -> str: return get_token_header("Authorization") -def get_token_payload(token): +def get_token_payload(token : str) -> Any: # log.warning("The received token in get_token_payload is: " + str(token)) try: claims = jwt.get_unverified_claims(token) - secret = app.config["API_SECRET"] + secret = os.environ["API_SECRET"] except: log.warning( @@ -97,11 +99,11 @@ def get_token_payload(token): algorithms=["HS256"], options=dict(verify_aud=False, verify_sub=False, verify_exp=True), ) - except jwt.ExpiredSignatureError: + except jose.exceptions.ExpiredSignatureError: log.warning("Token expired") raise Error("unauthorized", "Token is expired", traceback.format_stack()) - except jwt.JWTClaimsError: + except jose.exceptions.JWTClaimsError: raise Error( "unauthorized", "Incorrect claims, please check the audience and issuer", diff --git a/dd-sso/admin/src/admin/flaskapp.py b/dd-sso/admin/src/admin/flaskapp.py new file mode 100644 index 0000000..18b3238 --- /dev/null +++ b/dd-sso/admin/src/admin/flaskapp.py @@ -0,0 +1,225 @@ +# +# Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham +# +# This file is part of DD +# +# DD is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# DD is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with DD. If not, see . +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import logging as log +import os +import os.path +import secrets +import traceback +from typing import TYPE_CHECKING, Any, Callable, Dict + +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.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" + secrets_dir: str + ready: bool = False + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.url_map.strict_slashes = False + from admin.lib.admin import Admin + self.admin = Admin(self) + # Minor setup tasks + self._load_validators() + self._load_config() + self._setup_routes() + 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.root_path, "../custom/avatars/") + + def setup(self) -> None: + """ + Perform setup tasks that might do network + """ + from admin.lib.postup import Postup + Postup(self) + + 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: + # Handle secrets like Flask's session key + self.secrets_dir = os.environ.get("SECRETS", "secret") + secret_key_file = os.path.join(self.secrets_dir, "secret_key") + if not os.path.exists(self.secrets_dir): + os.mkdir(self.secrets_dir) + 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 ISARD's settings + self.config.setdefault("DOMAIN", os.environ["DOMAIN"]) + self.config.setdefault( + "KEYCLOAK_POSTGRES_USER", os.environ["KEYCLOAK_DB_USER"] + ) + self.config.setdefault( + "KEYCLOAK_POSTGRES_PASSWORD", os.environ["KEYCLOAK_DB_PASSWORD"] + ) + self.config.setdefault( + "MOODLE_POSTGRES_USER", os.environ["MOODLE_POSTGRES_USER"] + ) + self.config.setdefault( + "MOODLE_POSTGRES_PASSWORD", os.environ["MOODLE_POSTGRES_PASSWORD"] + ) + self.config.setdefault( + "NEXTCLOUD_POSTGRES_USER", os.environ["NEXTCLOUD_POSTGRES_USER"] + ) + self.config.setdefault( + "NEXTCLOUD_POSTGRES_PASSWORD", os.environ["NEXTCLOUD_POSTGRES_PASSWORD"] + ) + self.config.setdefault( + "VERIFY", True if os.environ["VERIFY"] == "true" else False + ) + self.config.setdefault("API_SECRET", os.environ.get("API_SECRET")) + except Exception as e: + log.error(traceback.format_exc()) + raise + + def _setup_routes(self) -> None: + """ + Setup routes to Serve static files + """ + + @self.route("/build/") + def send_build(path: str) -> Response: + return send_from_directory( + os.path.join(self.root_path, "node_modules/gentelella/build"), path + ) + + @self.route("/vendors/") + def send_vendors(path: str) -> Response: + return send_from_directory( + os.path.join(self.root_path, "node_modules/gentelella/vendors"), path + ) + + @self.route("/node_modules/") + def send_nodes(path: str) -> Response: + return send_from_directory( + os.path.join(self.root_path, "node_modules"), path + ) + + @self.route("/templates/") + def send_templates(path: str) -> Response: + return send_from_directory(os.path.join(self.root_path, "templates"), path) + + # @self.route('/templates/') + # def send_templates(path): + # return send_from_directory(os.path.join(self.root_path, 'static/templates'), path) + + @self.route("/static/") + def send_static_js(path: str) -> Response: + return send_from_directory(os.path.join(self.root_path, "static"), path) + + @self.route("/avatars/") + def send_avatars_img(path: str) -> Response: + return send_from_directory( + os.path.join(self.root_path, "../avatars/master-avatars"), path + ) + + @self.route("/custom/") + def send_custom(path: str) -> Response: + return send_from_directory(os.path.join(self.root_path, "../custom"), path) + + # @self.errorhandler(404) + # def not_found_error(error): + # return render_template('page_404.html'), 404 + + # @self.errorhandler(500) + # def internal_error(error): + # return render_template('page_500.html'), 500 + + @self.errorhandler(Error) + def handle_user_error(ex : Error) -> Response: + response : Response = jsonify(ex.error) + response.status_code = ex.status_code + response.headers.extend(ex.content_type) # type: ignore # werkzeug type hint MIA + return response diff --git a/dd-sso/admin/src/admin/lib/admin.py b/dd-sso/admin/src/admin/lib/admin.py index 361c3a8..a426ccd 100644 --- a/dd-sso/admin/src/admin/lib/admin.py +++ b/dd-sso/admin/src/admin/lib/admin.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -26,8 +27,6 @@ from time import sleep import diceware -from admin import app - from .avatars import Avatars from .helpers import ( filter_roles_list, @@ -61,14 +60,27 @@ from .helpers import ( rand_password, ) +from typing import TYPE_CHECKING, cast, Any, Dict, Iterable, List, Optional +if TYPE_CHECKING: + from admin.flaskapp import AdminFlaskApp + 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] + def __init__(self, app : "AdminFlaskApp") -> None: + self.app = app + + self.check_connections(app) self.set_custom_roles() self.overwrite_admins() @@ -90,13 +102,13 @@ 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 check_connections(self, app : "AdminFlaskApp") -> None: ready = False while not ready: try: @@ -111,7 +123,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 +148,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 +235,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 +336,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 +344,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 +365,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 +384,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 +426,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 +493,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 +576,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 +598,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 +692,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 +730,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 +769,7 @@ class Admin: u["groups"] = u["groups"] + [g["name"]] return True - def sync_external(self, ids): + def sync_external(self, ids : Any) -> None: # self.resync_data() log.warning("Starting sync to keycloak") self.sync_to_keycloak_external() @@ -769,10 +781,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 +802,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 +812,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 +867,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 +892,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 +905,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 +932,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 +950,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 +980,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 +1021,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 +1052,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 +1079,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 +1118,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 +1149,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 +1179,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 +1195,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 +1229,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 +1243,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 +1258,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 +1272,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 +1300,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 +1314,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 +1328,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 +1351,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,7 +1517,7 @@ class Admin: ev.update_text("User updated") 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]) @@ -1521,24 +1533,24 @@ 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[0]] @@ -1576,29 +1588,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": []}) + ev.update_text(str({"name": "Added to moodle", "data": []})) except UserExists: log.error(" -->> User already exists") - error = Events("User already exists.", str(se), type="error") + error = Events(self.app, "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, "Moodle create user error", str(se), 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 @@ -1648,21 +1660,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( @@ -1676,16 +1688,16 @@ 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) @@ -1694,23 +1706,22 @@ class Admin: 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 = "" @@ -1739,7 +1750,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"], @@ -1784,14 +1795,14 @@ class Admin: ev.increment({"name": "Added to moodle", "data": []}) except UserExists: log.error(" -->> User already exists") - error = Events("User already exists.", str(se), type="error") + error = Events(self.app, "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, "Moodle create user error", str(se), 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 @@ -1847,30 +1858,29 @@ class Admin: log.error(traceback.format_exc()) 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: @@ -1904,7 +1914,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 = [] @@ -1926,5 +1936,5 @@ class Admin: self.nextcloud.delete_group(gid) self.resync_data() - def set_nextcloud_user_mail(self, data): + def set_nextcloud_user_mail(self, data : Any) -> None: self.nextcloud.set_user_mail(data) diff --git a/dd-sso/admin/src/admin/lib/api_exceptions.py b/dd-sso/admin/src/admin/lib/api_exceptions.py index dc11014..473d056 100644 --- a/dd-sso/admin/src/admin/lib/api_exceptions.py +++ b/dd-sso/admin/src/admin/lib/api_exceptions.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -23,10 +24,11 @@ import logging as log import os import traceback -from flask import jsonify, request +from typing import Any, Dict, Union, List -from admin import app +from flask import request +# TODO: Improve these constants' structure content_type = {"Content-Type": "application/json"} ex = { "bad_request": { @@ -96,8 +98,10 @@ ex = { class Error(Exception): - def __init__(self, error="bad_request", description="", debug="", data=None): - self.error = ex[error]["error"].copy() + status_code : int + content_type : Dict[str, str] + def __init__(self, error : str ="bad_request", description : str="", debug : Union[str, List[str]]="", data : Any =None): + self.error : Dict[str, str] = (ex[error]["error"]).copy() # type: ignore # bad struct self.error["function"] = ( inspect.stack()[1][1].split(os.sep)[-1] + ":" @@ -123,7 +127,7 @@ class Error(Exception): "----------- REQUEST START -----------", request.method + " " + request.url, "\r\n".join("{}: {}".format(k, v) for k, v in request.headers.items()), - request.body if hasattr(request, "body") else "", + getattr(request, "body", ""), "----------- REQUEST STOP -----------", ) if request @@ -138,7 +142,7 @@ class Error(Exception): if data else "" ) - self.status_code = ex[error]["status_code"] + self.status_code = ex[error]["status_code"] # type: ignore # bad struct self.content_type = content_type log.debug( "%s - %s - [%s -> %s]\r\n%s\r\n%s\r\n%s" @@ -152,11 +156,3 @@ class Error(Exception): self.error["data"], ) ) - - -@app.errorhandler(Error) -def handle_user_error(ex): - response = jsonify(ex.error) - response.status_code = ex.status_code - response.headers = {"content-type": content_type} - return response diff --git a/dd-sso/admin/src/admin/lib/avatars.py b/dd-sso/admin/src/admin/lib/avatars.py index 6805f09..a5f08d5 100644 --- a/dd-sso/admin/src/admin/lib/avatars.py +++ b/dd-sso/admin/src/admin/lib/avatars.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -26,11 +27,13 @@ from minio.commonconfig import REPLACE, CopySource from minio.deleteobjects import DeleteObject from requests import get, post -from admin import app +from typing import Any, Callable, Dict, Iterable, List class Avatars: - def __init__(self): + avatars_path : str + def __init__(self, avatars_path : str): + self.avatars_path = avatars_path self.mclient = Minio( "dd-sso-avatars:9000", access_key="AKIAIOSFODNN7EXAMPLE", @@ -41,21 +44,22 @@ class Avatars: self._minio_set_realm() # self.update_missing_avatars() - def add_user_default_avatar(self, userid, role="unknown"): + def add_user_default_avatar(self, userid : str, role : str="unknown") -> None: + path = os.path.join(self.avatars_path, role) + ".jpg", self.mclient.fput_object( self.bucket, userid, - os.path.join(app.root_path, "../custom/avatars/" + role + ".jpg"), + path, content_type="image/jpeg ", ) log.warning( " AVATARS: Updated avatar for user " + userid + " with role " + role ) - def delete_user_avatar(self, userid): + def delete_user_avatar(self, userid : str) -> None: self.minio_delete_object(userid) - def update_missing_avatars(self, users): + def update_missing_avatars(self, users : Iterable[Dict[str, Any]]) -> None: sys_roles = ["admin", "manager", "teacher", "student"] for u in self.get_users_without_image(users): try: @@ -63,10 +67,11 @@ class Avatars: except: img = "unknown.jpg" + path = os.path.join(self.avatars_path, img) self.mclient.fput_object( self.bucket, u["id"], - os.path.join(app.root_path, "../custom/avatars/" + img), + path, content_type="image/jpeg ", ) log.warning( @@ -76,26 +81,24 @@ class Avatars: + img.split(".")[0] ) - def _minio_set_realm(self): + def _minio_set_realm(self) -> None: if not self.mclient.bucket_exists(self.bucket): self.mclient.make_bucket(self.bucket) - def minio_get_objects(self): + def minio_get_objects(self) -> List[Any]: return [o.object_name for o in self.mclient.list_objects(self.bucket)] - def minio_delete_all_objects(self): - delete_object_list = map( - lambda x: DeleteObject(x.object_name), - self.mclient.list_objects(self.bucket), - ) + def minio_delete_all_objects(self) -> None: + f : Callable[[Any], Any] = lambda x: DeleteObject(x.object_name) + delete_object_list = map(f, self.mclient.list_objects(self.bucket)) errors = self.mclient.remove_objects(self.bucket, delete_object_list) for error in errors: log.error(" AVATARS: Error occured when deleting avatar object: " + error) - def minio_delete_object(self, oid): + def minio_delete_object(self, oid : str) -> None: errors = self.mclient.remove_objects(self.bucket, [DeleteObject(oid)]) for error in errors: log.error(" AVATARS: Error occured when deleting avatar object: " + error) - def get_users_without_image(self, users): + def get_users_without_image(self, users : Iterable[Dict[str, Any]]) -> Iterable[Dict[str, Any]]: return [u for u in users if u["id"] and u["id"] not in self.minio_get_objects()] diff --git a/dd-sso/admin/src/admin/lib/dashboard.py b/dd-sso/admin/src/admin/lib/dashboard.py index eba50d8..c23e879 100644 --- a/dd-sso/admin/src/admin/lib/dashboard.py +++ b/dd-sso/admin/src/admin/lib/dashboard.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -29,16 +30,22 @@ 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 import FileStorage class Dashboard: + app : "AdminFlaskApp" def __init__( self, - ): + app : "AdminFlaskApp", + ) -> None: + self.app = app self.custom_menu = os.path.join(app.root_path, "../custom/menu/custom.yaml") - def _update_custom_menu(self, custom_menu_part): + 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 +53,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 +70,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 +80,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.root_path, "../custom/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.root_path, "../custom/img/background.png")) return self.apply_updates() - def apply_updates(self): + def apply_updates(self) -> bool: resp = requests.get("http://dd-sso-api:7039/restart") return True diff --git a/dd-sso/admin/src/admin/lib/events.py b/dd-sso/admin/src/admin/lib/events.py index ae1eefa..da7205d 100644 --- a/dd-sso/admin/src/admin/lib/events.py +++ b/dd-sso/admin/src/admin/lib/events.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -38,34 +39,46 @@ from flask_socketio import ( send, ) -from admin import app +from typing import TYPE_CHECKING, Any, Dict +if TYPE_CHECKING: + from admin.flaskapp import AdminFlaskApp -def sio_event_send(event, data): +def sio_event_send(app : "AdminFlaskApp", event : str, data : Dict[str, Any]) -> None: app.socketio.emit( event, json.dumps(data), namespace="/sio/events", room="events", ) + # TODO: Why on earth do we find these all over the place? sleep(0.001) class Events: - def __init__(self, title, text="", total=0, table=False, type="info"): + app : "AdminFlaskApp" + eid : str + title : str + text : str + total : int + table : bool + type : str + def __init__(self, app : "AdminFlaskApp", title : str, text : str="", total : int=0, table : bool=False, type : str="info") -> None: + self.app = app # notice, info, success, and error self.eid = str(base64.b64encode(os.urandom(32))[:8]) self.title = title self.text = text self.total = total + # TODO: this is probably replacing the .table method???? self.table = table self.item = 0 self.type = type self.create() - def create(self): + def create(self) -> None: log.info("START " + self.eid + ": " + self.text) - app.socketio.emit( + self.app.socketio.emit( "notify-create", json.dumps( { @@ -80,9 +93,9 @@ class Events: ) sleep(0.001) - def __del__(self): + def __del__(self) -> None: log.info("END " + self.eid + ": " + self.text) - app.socketio.emit( + self.app.socketio.emit( "notify-destroy", json.dumps({"id": self.eid}), namespace="/sio", @@ -90,9 +103,9 @@ class Events: ) sleep(0.001) - def update_text(self, text): + def update_text(self, text : str) -> None: self.text = text - app.socketio.emit( + self.app.socketio.emit( "notify-update", json.dumps( { @@ -105,9 +118,9 @@ class Events: ) sleep(0.001) - def append_text(self, text): + def append_text(self, text : str) -> None: self.text = self.text + "
" + text - app.socketio.emit( + self.app.socketio.emit( "notify-update", json.dumps( { @@ -120,10 +133,10 @@ class Events: ) sleep(0.001) - def increment(self, data={"name": "", "data": []}): + def increment(self, data : Dict[str, Any]={"name": "", "data": []}) -> None: self.item += 1 log.info("INCREMENT " + self.eid + ": " + self.text) - app.socketio.emit( + self.app.socketio.emit( "notify-increment", json.dumps( { @@ -149,10 +162,10 @@ class Events: ) sleep(0.0001) - def decrement(self, data={"name": "", "data": []}): + def decrement(self, data : Dict[str, Any]={"name": "", "data": []}) -> None: self.item -= 1 log.info("DECREMENT " + self.eid + ": " + self.text) - app.socketio.emit( + self.app.socketio.emit( "notify-decrement", json.dumps( { @@ -178,13 +191,13 @@ class Events: ) sleep(0.001) - def reload(self): - app.socketio.emit("reload", json.dumps({}), namespace="/sio", room="admin") + def reload(self) -> None: + self.app.socketio.emit("reload", json.dumps({}), namespace="/sio", room="admin") sleep(0.0001) - def table(self, event, table, data={}): + def table(self, event : str, table : bool, data : Dict[str, Any]={}) -> None: # refresh, add, delete, update - app.socketio.emit( + self.app.socketio.emit( "table_" + event, json.dumps({"table": table, "data": data}), namespace="/sio", diff --git a/dd-sso/admin/src/admin/lib/helpers.py b/dd-sso/admin/src/admin/lib/helpers.py index 80b7ea7..ab57de7 100644 --- a/dd-sso/admin/src/admin/lib/helpers.py +++ b/dd-sso/admin/src/admin/lib/helpers.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -22,8 +23,11 @@ import string from collections import Counter from pprint import pprint +from typing import Any, Dict, Generator, Iterable, Optional, List -def get_recursive_groups(l_groups, l): +DDGroup = Dict[str, Any] + +def get_recursive_groups(l_groups : Iterable[DDGroup], l : List[DDGroup]) -> List[DDGroup]: for d_group in l_groups: data = {} for key, value in d_group.items(): @@ -35,11 +39,11 @@ def get_recursive_groups(l_groups, l): return l -def get_group_with_childs(keycloak_group): +def get_group_with_childs(keycloak_group : DDGroup) -> List[str]: return [g["path"] for g in get_recursive_groups([keycloak_group], [])] -def system_username(username): +def system_username(username : str) -> bool: return ( True if username in ["guest", "ddadmin", "admin"] or username.startswith("system_") @@ -47,41 +51,43 @@ def system_username(username): ) -def system_group(groupname): +def system_group(groupname : str) -> bool: return True if groupname in ["admin", "manager", "teacher", "student"] else False -def get_group_from_group_id(group_id, groups): +def get_group_from_group_id(group_id : str, groups : Iterable[DDGroup]) -> Optional[DDGroup]: return next((d for d in groups if d.get("id") == group_id), None) -def get_kid_from_kpath(kpath, groups): - ids = [g["id"] for g in groups if g["path"] == kpath] - if not len(ids) or len(ids) > 1: - return False +def get_kid_from_kpath(kpath : str, groups : Iterable[DDGroup]) -> Optional[str]: + ids : List[str] = [g["id"] for g in groups if g["path"] == kpath] + if len(ids) != 1: + return None return ids[0] -def get_gid_from_kgroup_id(kgroup_id, groups): - return [ +def get_gid_from_kgroup_id(kgroup_id : str, groups : Iterable[DDGroup]) -> str: + # TODO: Why is this interface different from get_kid_from_kpath? + o : List[str] = [ g["path"].replace("/", ".")[1:] if len(g["path"].split("/")) else g["path"][1:] for g in groups if g["id"] == kgroup_id - ][0] + ] + return o[0] -def get_gids_from_kgroup_ids(kgroup_ids, groups): +def get_gids_from_kgroup_ids(kgroup_ids : Iterable[str], groups : Iterable[DDGroup]) -> List[str]: return [get_gid_from_kgroup_id(kgroup_id, groups) for kgroup_id in kgroup_ids] -def kpath2gid(path): +def kpath2gid(path : str) -> str: # print(path.replace('/','.')[1:]) if path.startswith("/"): return path.replace("/", ".")[1:] return path.replace("/", ".") -def kpath2gids(path): +def kpath2gids(path : str) -> List[str]: path = kpath2gid(path) l = [] for i in range(len(path.split("."))): @@ -89,44 +95,45 @@ def kpath2gids(path): return l -def kpath2kpaths(path): +def kpath2kpaths(path : str) -> List[str]: l = [] for i in range(len(path.split("/"))): l.append("/".join(path.split("/")[: i + 1])) return l[1:] -def gid2kpath(gid): +def gid2kpath(gid : str) -> str: return "/" + gid.replace(".", "/") -def count_repeated(itemslist): +def count_repeated(itemslist : Iterable[Any]) -> None: print(Counter(itemslist)) -def groups_kname2gid(groups): +def groups_kname2gid(groups : Iterable[str]) -> List[str]: return [name.replace(".", "/") for name in groups] -def groups_path2id(groups): +def groups_path2id(groups : Iterable[str]) -> List[str]: return [g.replace("/", ".")[1:] for g in groups] -def groups_id2path(groups): +def groups_id2path(groups : Iterable[str]) -> List[str]: return ["/" + g.replace(".", "/") for g in groups] -def filter_roles_list(role_list): +def filter_roles_list(role_list : Iterable[str]) -> List[str]: client_roles = ["admin", "manager", "teacher", "student"] return [r for r in role_list if r in client_roles] -def filter_roles_listofdicts(role_listofdicts): +def filter_roles_listofdicts(role_listofdicts : Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]: client_roles = ["admin", "manager", "teacher", "student"] return [r for r in role_listofdicts if r["name"] in client_roles] -def rand_password(lenght): +def rand_password(lenght : int) -> str: + # TODO: why is this not using py3's secrets? characters = string.ascii_letters + string.digits + string.punctuation passwd = "".join(random.choice(characters) for i in range(lenght)) while not any(ele.isupper() for ele in passwd): diff --git a/dd-sso/admin/src/admin/lib/keycloak_client.py b/dd-sso/admin/src/admin/lib/keycloak_client.py index 5641a6b..44b9be2 100644 --- a/dd-sso/admin/src/admin/lib/keycloak_client.py +++ b/dd-sso/admin/src/admin/lib/keycloak_client.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -34,23 +35,33 @@ from .api_exceptions import Error from .helpers import get_recursive_groups, kpath2kpaths from .postgres import Postgres -# from admin import app +from typing import cast, Any, Dict, Iterable, List, Optional +DDUser = Dict[str, Any] + +# TODO: Improve typing of these class and simplify it class KeycloakClient: """https://www.keycloak.org/docs-api/13.0/rest-api/index.html https://github.com/marcospereirampj/python-keycloak https://gist.github.com/kaqfa/99829941121188d7cef8271f93f52f1f """ + url : str + username : str + password : str + realm : str + verify : bool + keycloak_pg : Postgres + keycloak_admin : KeycloakAdmin def __init__( self, - url="http://dd-sso-keycloak:8080/auth/", - username=os.environ["KEYCLOAK_USER"], - password=os.environ["KEYCLOAK_PASSWORD"], - realm="master", - verify=True, - ): + url : str="http://dd-sso-keycloak:8080/auth/", + username : str=os.environ["KEYCLOAK_USER"], + password : str=os.environ["KEYCLOAK_PASSWORD"], + realm : str="master", + verify : bool=True, + ) -> None: self.url = url self.username = username self.password = password @@ -64,7 +75,7 @@ class KeycloakClient: os.environ["KEYCLOAK_DB_PASSWORD"], ) - def connect(self): + def connect(self) -> None: self.keycloak_admin = KeycloakAdmin( server_url=self.url, username=self.username, @@ -78,15 +89,19 @@ class KeycloakClient: """ USERS """ - def get_user_id(self, username): + def get_user_id(self, username : str) -> str: self.connect() - return self.keycloak_admin.get_user_id(username) + uid : str = self.keycloak_admin.get_user_id(username) + return uid - def get_users(self): + def get_users(self) -> Iterable[Dict[str, Any]]: + # https://www.keycloak.org/docs-api/18.0/rest-api/index.html#_userrepresentation self.connect() - return self.keycloak_admin.get_users({}) + o : Iterable[Dict[str, Any]] = self.keycloak_admin.get_users({}) + return o - def get_users_with_groups_and_roles(self): + # TODO: what is this actually doing? + def get_users_with_groups_and_roles(self) -> List[DDUser]: q = """select u.id, u.username, u.email, u.first_name, u.last_name, u.realm_id, u.enabled, ua.value as quota ,json_agg(g."id") as group, json_agg(g_parent."name") as group_parent1, json_agg(g_parent2."name") as group_parent2 ,json_agg(r.name) as role @@ -125,7 +140,7 @@ class KeycloakClient: return list_dict_users - def getparent(self, group_id, data): + def getparent(self, group_id : str, data : Iterable[Any]) -> str: # Recursively get full path from any group_id in the tree path = "" for item in data: @@ -134,14 +149,14 @@ class KeycloakClient: path = f"{path}/{item[1]}" return path - def get_group_path(self, group_id): + def get_group_path(self, group_id : str) -> str: # Get full path using getparent recursive func # RETURNS: String with full path q = """SELECT * FROM keycloak_group""" groups = self.keycloak_pg.select(q) return self.getparent(group_id, groups) - def get_user_groups_paths(self, user_id): + def get_user_groups_paths(self, user_id : str) -> List[str]: # Get full paths for user grups # RETURNS list of paths q = """SELECT group_id FROM user_group_membership WHERE user_id = '%s'""" % ( @@ -165,20 +180,20 @@ class KeycloakClient: def add_user( self, - username, - first, - last, - email, - password, - group=False, - password_temporary=True, - enabled=True, - ): + username : str, + first : str, + last : str, + email : str, + password : str, + group : Any=False, + password_temporary : bool=True, + enabled : bool=True, + ) -> Any: # RETURNS string with keycloak user id (the main id in this app) self.connect() username = username.lower() try: - uid = self.keycloak_admin.create_user( + uid : Any = self.keycloak_admin.create_user( { "email": email, "username": username, @@ -213,7 +228,7 @@ class KeycloakClient: self.keycloak_admin.group_user_add(uid, gid) return uid - def update_user_pwd(self, user_id, password, password_temporary=True): + def update_user_pwd(self, user_id : str, password : str, password_temporary : bool=True) -> Any: # Updates payload = { "credentials": [ @@ -223,7 +238,7 @@ class KeycloakClient: self.connect() return self.keycloak_admin.update_user(user_id, payload) - def user_update(self, user_id, enabled, email, first, last, groups=[], roles=[]): + def user_update(self, user_id : str, enabled : bool, email : str, first : str, last : str, groups : Iterable[str]=[], roles : Iterable[str]=[]) -> Any: ## NOTE: Roles didn't seem to be updated/added. Also not confident with groups # Updates payload = { @@ -237,17 +252,17 @@ class KeycloakClient: self.connect() return self.keycloak_admin.update_user(user_id, payload) - def user_enable(self, user_id): + def user_enable(self, user_id : str) -> Any: payload = {"enabled": True} self.connect() return self.keycloak_admin.update_user(user_id, payload) - def user_disable(self, user_id): + def user_disable(self, user_id : str) -> Any: payload = {"enabled": False} self.connect() return self.keycloak_admin.update_user(user_id, payload) - def group_user_remove(self, user_id, group_id): + def group_user_remove(self, user_id : str, group_id : str) -> Any: self.connect() return self.keycloak_admin.group_user_remove(user_id, group_id) @@ -255,7 +270,7 @@ class KeycloakClient: # self.connect() # return self.keycloak_admin.assign_role(client_id=client_id, user_id=user_id, role_id=role_id, role_name="test") - def remove_user_realm_roles(self, user_id, roles): + def remove_user_realm_roles(self, user_id : str, roles : Iterable[str]) -> Any: self.connect() roles = [ r @@ -264,66 +279,66 @@ class KeycloakClient: ] return self.keycloak_admin.delete_user_realm_role(user_id, roles) - def delete_user(self, userid): + def delete_user(self, userid : str) -> Any: self.connect() return self.keycloak_admin.delete_user(user_id=userid) - def get_user_groups(self, userid): + def get_user_groups(self, userid : str) -> Any: self.connect() return self.keycloak_admin.get_user_groups(user_id=userid) - def get_user_realm_roles(self, userid): + def get_user_realm_roles(self, userid : str) -> Any: self.connect() return self.keycloak_admin.get_realm_roles_of_user(user_id=userid) - def add_user_client_role(self, client_id, user_id, role_id, role_name): + def add_user_client_role(self, client_id : str, user_id : str, role_id : str, role_name : str) -> Any: self.connect() return self.keycloak_admin.assign_client_role( client_id=client_id, user_id=user_id, role_id=role_id, role_name="test" ) ## GROUPS - def get_all_groups(self): + def get_all_groups(self) -> Iterable[Any]: ## RETURNS ONLY MAIN GROUPS WITH NESTED subGroups list self.connect() - return self.keycloak_admin.get_groups() + return cast(Iterable[Any], self.keycloak_admin.get_groups()) - def get_groups(self, with_subgroups=True): + def get_groups(self, with_subgroups : bool=True) -> Iterable[Any]: ## RETURNS ALL GROUPS in root list self.connect() groups = self.keycloak_admin.get_groups() return get_recursive_groups(groups, []) - def get_group_by_id(self, group_id): + def get_group_by_id(self, group_id : str) -> Any: self.connect() return self.keycloak_admin.get_group(group_id=group_id) - def get_group_by_path(self, path, recursive=True): + def get_group_by_path(self, path : str, recursive : bool=True) -> Any: self.connect() return self.keycloak_admin.get_group_by_path( path=path, search_in_subgroups=recursive ) - def add_group(self, name, parent=None, skip_exists=False): + def add_group(self, name : str, parent : str="", skip_exists : bool=False) -> Any: self.connect() - if parent != None: + if parent: parent = self.get_group_by_path(parent)["id"] return self.keycloak_admin.create_group({"name": name}, parent=parent) - def delete_group(self, group_id): + def delete_group(self, group_id : str) -> Any: self.connect() return self.keycloak_admin.delete_group(group_id=group_id) - def group_user_add(self, user_id, group_id): + def group_user_add(self, user_id : str, group_id : str) -> Any: self.connect() return self.keycloak_admin.group_user_add(user_id, group_id) - def add_group_tree(self, path): + def add_group_tree(self, path : str) -> None: paths = kpath2kpaths(path) parent = "/" for path in paths: try: - parent_path = None if parent == "/" else parent + parent_path = "" if parent == "/" else parent # print("parent: "+str(parent_path)+" path: "+path.split("/")[-1]) self.add_group(path.split("/")[-1], parent_path, skip_exists=True) parent = path @@ -333,8 +348,8 @@ class KeycloakClient: parent = path def add_user_with_groups_and_role( - self, username, first, last, email, password, role, groups - ): + self, username : str, first : str, last : str, email : str, password : str, role : str, groups : Iterable[str] + ) -> None: ## Add user uid = self.add_user(username, first, last, email, password) ## Add user to role @@ -348,7 +363,7 @@ class KeycloakClient: for g in groups: log.warning("Creating keycloak group: " + g) parts = g.split("/") - parent_path = None + parent_path = "" for i in range(1, len(parts)): # parent_id=None if parent_path==None else self.get_group(parent_path)['id'] try: @@ -360,10 +375,7 @@ class KeycloakClient: + " already exists. Skipping creation" ) pass - if parent_path is None: - thepath = "/" + parts[i] - else: - thepath = parent_path + "/" + parts[i] + thepath = parent_path + "/" + parts[i] if thepath == "/": log.warning( "Not adding the user " @@ -385,53 +397,51 @@ class KeycloakClient: ) self.keycloak_admin.group_user_add(uid, gid) - if parent_path == None: - parent_path = "" - parent_path = parent_path + "/" + parts[i] + parent_path += "/" + parts[i] # self.group_user_add(uid,gid) ## ROLES - def get_roles(self): + def get_roles(self) -> Iterable[Any]: self.connect() - return self.keycloak_admin.get_realm_roles() + return cast(Iterable[Any], self.keycloak_admin.get_realm_roles()) - def get_role(self, name): + def get_role(self, name : str) -> Any: self.connect() return self.keycloak_admin.get_realm_role(name) - def add_role(self, name, description=""): + def add_role(self, name : str, description : str="") -> Any: self.connect() return self.keycloak_admin.create_realm_role( {"name": name, "description": description} ) - def delete_role(self, name): + def delete_role(self, name : str) -> Any: self.connect() return self.keycloak_admin.delete_realm_role(name) ## CLIENTS - def get_client_roles(self, client_id): + def get_client_roles(self, client_id : str) -> Any: self.connect() return self.keycloak_admin.get_client_roles(client_id=client_id) - def add_client_role(self, client_id, name, description=""): + def add_client_role(self, client_id : str, name : str, description : str="") -> Any: self.connect() return self.keycloak_admin.create_client_role( client_id, {"name": name, "description": description, "clientRole": True} ) ## SYSTEM - def get_server_info(self): + def get_server_info(self) -> Any: self.connect() return self.keycloak_admin.get_server_info() - def get_server_clients(self): + def get_server_clients(self) -> Any: self.connect() return self.keycloak_admin.get_clients() - def get_server_rsa_key(self): + def get_server_rsa_key(self) -> Any: self.connect() rsa_key = [ k for k in self.keycloak_admin.get_keys()["keys"] if k["type"] == "RSA" @@ -439,22 +449,21 @@ class KeycloakClient: return {"name": rsa_key["kid"], "certificate": rsa_key["certificate"]} ## REALM - def assign_realm_roles(self, user_id, role): + def assign_realm_roles(self, user_id : str, role : str) -> Any: self.connect() try: - role = [ + kcroles = [ r for r in self.keycloak_admin.get_realm_roles() if r["name"] == role ] except: return False - return self.keycloak_admin.assign_realm_roles(user_id=user_id, roles=role) - # return self.keycloak_admin.assign_realm_roles(user_id=user_id, client_id=None, roles=role) + return self.keycloak_admin.assign_realm_roles(user_id=user_id, roles=kcroles) ## CLIENTS - def delete_client(self, clientid): + def delete_client(self, clientid : str) -> Any: self.connect() return self.keycloak_admin.delete_client(clientid) - def add_client(self, client): + def add_client(self, client : str) -> Any: self.connect() return self.keycloak_admin.create_client(client) diff --git a/dd-sso/admin/src/admin/lib/legal.py b/dd-sso/admin/src/admin/lib/legal.py index 70c27c2..4e7ce80 100644 --- a/dd-sso/admin/src/admin/lib/legal.py +++ b/dd-sso/admin/src/admin/lib/legal.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -21,7 +22,6 @@ import logging as log import os import traceback -from admin import app from pprint import pprint from minio import Minio @@ -29,18 +29,22 @@ from minio.commonconfig import REPLACE, CopySource from minio.deleteobjects import DeleteObject from requests import get, post -legal_path= os.path.join(app.root_path, "static/templates/pages/legal/") +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from admin.flaskapp import AdminFlaskApp -def get_legal(lang): - with open(legal_path+lang, "r") as languagefile: + +# TODO: Fix all this +def get_legal(app : "AdminFlaskApp", lang : str) -> str: + with open(app.legal_path+lang, "r") as languagefile: return languagefile.read() -def gen_legal_if_not_exists(lang): - if not os.path.isfile(legal_path+lang): +def gen_legal_if_not_exists(app : "AdminFlaskApp", lang : str) -> None: + if not os.path.isfile(app.legal_path+lang): log.debug("Creating new language file") - with open(legal_path+lang, "w") as languagefile: + with open(app.legal_path+lang, "w") as languagefile: languagefile.write("Legal
This is the default legal page for language " + lang) -def new_legal(lang,html): - with open(legal_path+lang, "w") as languagefile: - languagefile.write(html) \ No newline at end of file +def new_legal(app : "AdminFlaskApp", lang : str, html : str) -> None: + with open(app.legal_path+lang, "w") as languagefile: + languagefile.write(html) diff --git a/dd-sso/admin/src/admin/lib/load_config.py b/dd-sso/admin/src/admin/lib/load_config.py deleted file mode 100644 index 19ae22d..0000000 --- a/dd-sso/admin/src/admin/lib/load_config.py +++ /dev/null @@ -1,94 +0,0 @@ -# -# Copyright © 2021,2022 IsardVDI S.L. -# -# This file is part of DD -# -# DD is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. -# -# DD is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more -# details. -# -# You should have received a copy of the GNU Affero General Public License -# along with DD. If not, see . -# -# SPDX-License-Identifier: AGPL-3.0-or-later - - -import logging as log -import os -import sys -import traceback - -import yaml -from cerberus import Validator, rules_set_registry, schema_registry - -from admin import app - - -class AdminValidator(Validator): - None - # def _normalize_default_setter_genid(self, document): - # return _parse_string(document["name"]) - - # def _normalize_default_setter_genidlower(self, document): - # return _parse_string(document["name"]).lower() - - # def _normalize_default_setter_gengroupid(self, document): - # return _parse_string( - # document["parent_category"] + "-" + document["uid"] - # ).lower() - - -def load_validators(purge_unknown=True): - validators = {} - schema_path = os.path.join(app.root_path, "schemas") - for schema_filename in os.listdir(schema_path): - try: - with open(os.path.join(schema_path, schema_filename)) as file: - schema_yml = file.read() - schema = yaml.load(schema_yml, Loader=yaml.FullLoader) - validators[schema_filename.split(".")[0]] = AdminValidator( - schema, purge_unknown=purge_unknown - ) - except IsADirectoryError: - None - return validators - - -app.validators = load_validators() - - -class loadConfig: - def __init__(self, app=None): - try: - app.config.setdefault("DOMAIN", os.environ["DOMAIN"]) - app.config.setdefault( - "KEYCLOAK_POSTGRES_USER", os.environ["KEYCLOAK_DB_USER"] - ) - app.config.setdefault( - "KEYCLOAK_POSTGRES_PASSWORD", os.environ["KEYCLOAK_DB_PASSWORD"] - ) - app.config.setdefault( - "MOODLE_POSTGRES_USER", os.environ["MOODLE_POSTGRES_USER"] - ) - app.config.setdefault( - "MOODLE_POSTGRES_PASSWORD", os.environ["MOODLE_POSTGRES_PASSWORD"] - ) - app.config.setdefault( - "NEXTCLOUD_POSTGRES_USER", os.environ["NEXTCLOUD_POSTGRES_USER"] - ) - app.config.setdefault( - "NEXTCLOUD_POSTGRES_PASSWORD", os.environ["NEXTCLOUD_POSTGRES_PASSWORD"] - ) - app.config.setdefault( - "VERIFY", True if os.environ["VERIFY"] == "true" else False - ) - app.config.setdefault("API_SECRET", os.environ.get("API_SECRET")) - except Exception as e: - log.error(traceback.format_exc()) - raise diff --git a/dd-sso/admin/src/admin/lib/moodle.py b/dd-sso/admin/src/admin/lib/moodle.py index 67ba429..03d798f 100644 --- a/dd-sso/admin/src/admin/lib/moodle.py +++ b/dd-sso/admin/src/admin/lib/moodle.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -23,11 +24,15 @@ from pprint import pprint from requests import get, post -from admin import app from .exceptions import UserExists, UserNotFound from .postgres import Postgres +from typing import TYPE_CHECKING, cast, Any, Dict, Iterable, List, Optional + +if TYPE_CHECKING: + from admin.flaskapp import AdminFlaskApp + # Module variables to connect to moodle api @@ -36,18 +41,20 @@ class Moodle: https://docs.moodle.org/dev/Web_service_API_functions https://docs.moodle.org/311/en/Using_web_services """ - + key: str + url : str + endpoint : str + verify : bool + moodle_pg : Postgres def __init__( self, - key=app.config["MOODLE_WS_TOKEN"], - url="https://moodle." + app.config["DOMAIN"], - endpoint="/webservice/rest/server.php", - verify=app.config["VERIFY"], - ): - self.key = key - self.url = url + app : "AdminFlaskApp", + endpoint : str="/webservice/rest/server.php", + ) -> None: + self.key = app.config["MOODLE_WS_TOKEN"] + self.url = f"https://moodle.{ app.config['DOMAIN'] }" self.endpoint = endpoint - self.verify = verify + self.verify = cast(bool, app.config["VERIFY"]) self.moodle_pg = Postgres( "dd-apps-postgresql", @@ -56,7 +63,7 @@ class Moodle: app.config["MOODLE_POSTGRES_PASSWORD"], ) - def rest_api_parameters(self, in_args, prefix="", out_dict=None): + def rest_api_parameters(self, in_args : Any, prefix : str="", out_dict : Optional[Dict]=None) -> Dict[Any, Any]: """Transform dictionary/array structure to a flat dictionary, with key names defining the structure. Example usage: @@ -64,24 +71,23 @@ class Moodle: {'courses[0][id]':1, 'courses[0][name]':'course1'} """ - if out_dict == None: - out_dict = {} + o : Dict[Any, Any] = {} if out_dict is None else out_dict if not type(in_args) in (list, dict): - out_dict[prefix] = in_args - return out_dict + o[prefix] = in_args + return o if prefix == "": prefix = prefix + "{0}" else: prefix = prefix + "[{0}]" if type(in_args) == list: for idx, item in enumerate(in_args): - self.rest_api_parameters(item, prefix.format(idx), out_dict) + self.rest_api_parameters(item, prefix.format(idx), o) elif type(in_args) == dict: for key, item in in_args.items(): - self.rest_api_parameters(item, prefix.format(key), out_dict) - return out_dict + self.rest_api_parameters(item, prefix.format(key), o) + return o - def call(self, fname, **kwargs): + def call(self, fname : str, **kwargs : Any) -> Any: """Calls moodle API function with function name fname and keyword arguments. Example: >>> call_mdl_function('core_course_update_courses', @@ -97,7 +103,7 @@ class Moodle: raise SystemError(response) return response - def create_user(self, email, username, password, first_name="-", last_name="-"): + def create_user(self, email : str, username : str, password : str, first_name : str="-", last_name : str="-") -> Any: if len(self.get_user_by("username", username)["users"]): raise UserExists try: @@ -115,7 +121,7 @@ class Moodle: except SystemError as se: raise SystemError(se.args[0]["message"]) - def update_user(self, username, email, first_name, last_name, enabled=True): + def update_user(self, username : str, email : str, first_name : str, last_name : str, enabled : bool=True) -> Any: user = self.get_user_by("username", username)["users"][0] if not len(user): raise UserNotFound @@ -135,15 +141,15 @@ class Moodle: except SystemError as se: raise SystemError(se.args[0]["message"]) - def delete_user(self, user_id): + def delete_user(self, user_id : str) -> Any: user = self.call("core_user_delete_users", userids=[user_id]) return user - def delete_users(self, userids): + def delete_users(self, userids : List[str]) -> Any: user = self.call("core_user_delete_users", userids=userids) return user - def get_user_by(self, key, value): + def get_user_by(self, key : str, value : str) -> Any: criteria = [{"key": key, "value": value}] try: user = self.call("core_user_get_users", criteria=criteria) @@ -152,7 +158,7 @@ class Moodle: return user # {'users': [{'id': 8, 'username': 'asdfw', 'firstname': 'afowie', 'lastname': 'aokjdnfwe', 'fullname': 'afowie aokjdnfwe', 'email': 'awfewe@ads.com', 'department': '', 'firstaccess': 0, 'lastaccess': 0, 'auth': 'manual', 'suspended': False, 'confirmed': True, 'lang': 'ca', 'theme': '', 'timezone': '99', 'mailformat': 1, 'profileimageurlsmall': 'https://moodle.mydomain.duckdns.org/theme/image.php/cbe/core/1630941606/u/f2', 'profileimageurl': 'https://DOMAIN/theme/image.php/cbe/core/1630941606/u/f1'}], 'warnings': []} - def get_users_with_groups_and_roles(self): + def get_users_with_groups_and_roles(self) -> List[Dict[Any, Any]]: q = """select u.id as id, username, firstname as first, lastname as last, email, json_agg(h.name) as groups, json_agg(r.shortname) as roles from mdl_user as u LEFT JOIN mdl_cohort_members AS hm on hm.userid = u.id @@ -179,31 +185,31 @@ class Moodle: # user['roles']=[] # return users - def enroll_user_to_course(self, user_id, course_id, role_id=5): + def enroll_user_to_course(self, user_id : str, course_id : str, role_id : int=5) -> Any: # 5 is student data = [{"roleid": role_id, "userid": user_id, "courseid": course_id}] enrolment = self.call("enrol_manual_enrol_users", enrolments=data) return enrolment - def get_quiz_attempt(self, quiz_id, user_id): + def get_quiz_attempt(self, quiz_id : str, user_id : str) -> Any: attempts = self.call( "mod_quiz_get_user_attempts", quizid=quiz_id, userid=user_id ) return attempts - def get_cohorts(self): + def get_cohorts(self) -> Any: cohorts = self.call("core_cohort_get_cohorts") return 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: diff --git a/dd-sso/admin/src/admin/lib/mysql.py b/dd-sso/admin/src/admin/lib/mysql.py index 9f3f140..7fdad2a 100644 --- a/dd-sso/admin/src/admin/lib/mysql.py +++ b/dd-sso/admin/src/admin/lib/mysql.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -18,32 +19,29 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -import json -import logging as log -import time -import traceback -from datetime import datetime, timedelta - import mysql.connector -import yaml -# from admin import app +from typing import List, Tuple class Mysql: - def __init__(self, host, database, user, password): + # TODO: Fix this whole class + cur : mysql.connector.MySQLCursor + conn : mysql.connector.MySQLConnection + def __init__(self, host : str, database : str, user : str, password : str) -> None: self.conn = mysql.connector.connect( host=host, database=database, user=user, password=password ) - def select(self, sql): + def select(self, sql : str) -> List[Tuple]: self.cur = self.conn.cursor() self.cur.execute(sql) - data = self.cur.fetchall() + data : List[Tuple] = self.cur.fetchall() self.cur.close() return data - def update(self, sql): + def update(self, sql : str) -> None: + # TODO: Fix this whole method self.cur = self.conn.cursor() self.cur.execute(sql) self.conn.commit() diff --git a/dd-sso/admin/src/admin/lib/nextcloud.py b/dd-sso/admin/src/admin/lib/nextcloud.py index 9e2a130..80ed9bb 100644 --- a/dd-sso/admin/src/admin/lib/nextcloud.py +++ b/dd-sso/admin/src/admin/lib/nextcloud.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -30,21 +31,31 @@ import urllib import requests from psycopg2 import sql -# from ..lib.log import * -from admin import app - from .nextcloud_exc import * from .postgres import Postgres +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +if TYPE_CHECKING: + from admin.flaskapp import AdminFlaskApp + +DDUser = Dict[Any, Any] class Nextcloud: + verify_cert : bool + apiurl : str + shareurl : str + davurl : str + auth : Tuple[str, str] + user : str + nextcloud_pg : Postgres def __init__( self, - url="https://nextcloud." + app.config["DOMAIN"], - username=os.environ["NEXTCLOUD_ADMIN_USER"], - password=os.environ["NEXTCLOUD_ADMIN_PASSWORD"], - verify=True, - ): + app : "AdminFlaskApp", + username : str=os.environ["NEXTCLOUD_ADMIN_USER"], + password : str=os.environ["NEXTCLOUD_ADMIN_PASSWORD"], + verify : bool=True, + ) -> None: + url = "https://nextcloud." + app.config["DOMAIN"] self.verify_cert = verify self.apiurl = url + "/ocs/v1.php/cloud/" @@ -61,9 +72,9 @@ class Nextcloud: ) def _request( - self, method, url, data={}, headers={"OCS-APIRequest": "true"}, auth=False - ): - if auth == False: + self, method : str, url : str, data : Any={}, headers : Dict[str, str]={"OCS-APIRequest": "true"}, auth : Optional[Tuple[str, str]]=None + ) -> str: + if auth is None: auth = self.auth try: response = requests.request( @@ -96,7 +107,7 @@ class Nextcloud: raise ProviderConnError raise ProviderError - def check_connection(self): + def check_connection(self) -> bool: url = self.apiurl + "users/" + self.user + "?format=json" try: result = self._request("GET", url) @@ -118,7 +129,7 @@ class Nextcloud: raise ProviderConnError raise ProviderError - def get_user(self, userid): + def get_user(self, userid : str) -> Any: url = self.apiurl + "users/" + userid + "?format=json" try: result = json.loads(self._request("GET", url)) @@ -148,7 +159,7 @@ class Nextcloud: # users_with_lists = [list(l[:-2])+([[]] if l[-2] == [None] else [list(set(l[-2]))]) + ([[]] if l[-1] == [None] else [list(set(l[-1]))]) for l in users] # users_with_lists = [list(l[:-2])+([[]] if l[-2] == [None] else [list(set(l[-2]))]) + ([[]] if l[-1] == [None] else [list(set(l[-1]))]) for l in users_with_lists] # list_dict_users = [dict(zip(fields, r)) for r in users_with_lists] - def get_users_list(self): + def get_users_list(self) -> List[DDUser]: # q = """select u.uid as username, adn.value as displayname, ade.value as email, json_agg(gg.displayname) as admin_groups,json_agg(g.displayname) as groups # from oc_users as u # left join oc_group_user as gu on gu.uid = u.uid @@ -200,9 +211,10 @@ class Nextcloud: # log.error(traceback.format_exc()) # raise + # TODO: Improve typing of these functions... def add_user( - self, userid, userpassword, quota=False, group=False, email="", displayname="" - ): + self, userid : str, userpassword : str, quota : Any=False, group : Any=False, email : str="", displayname : str="" + ) -> bool: data = { "userid": userid, "password": userpassword, @@ -247,7 +259,7 @@ class Nextcloud: # 106 - no group specified (required for subadmins) # 107 - all errors that contain a hint - for example “Password is among the 1,000,000 most common ones. Please make it unique.” (this code was added in 12.0.6 & 13.0.1) - def update_user(self, userid, key_values): + def update_user(self, userid : str, key_values : Dict[str, Any]) -> bool: # key_values={'quota':quota,'email':email,'displayname':displayname} url = self.apiurl + "users/" + userid + "?format=json" @@ -262,6 +274,8 @@ class Nextcloud: result = json.loads( self._request("PUT", url, data=data, headers=headers) ) + # TODO: It seems like this only sets the first item in key_values + # This function probably doesn't do what it is supposed to if result["ocs"]["meta"]["statuscode"] == 100: return True if result["ocs"]["meta"]["statuscode"] == 102: @@ -273,8 +287,9 @@ class Nextcloud: except: # log.error(traceback.format_exc()) raise + return False - def add_user_to_group(self, userid, group_id): + def add_user_to_group(self, userid : str, group_id : str) -> bool: data = {"groupid": group_id} url = self.apiurl + "users/" + userid + "/groups?format=json" @@ -296,7 +311,7 @@ class Nextcloud: # log.error(traceback.format_exc()) raise - def remove_user_from_group(self, userid, group_id): + def remove_user_from_group(self, userid : str, group_id : str) -> bool: data = {"groupid": group_id} url = self.apiurl + "users/" + userid + "/groups?format=json" @@ -321,9 +336,10 @@ class Nextcloud: # 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 +368,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 +384,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 +400,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 +423,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 +445,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 +465,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 +493,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 +507,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 +531,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 +554,7 @@ class Nextcloud: # 102 - group already exists # 103 - failed to add the group - def set_user_mail(self, data): + def set_user_mail(self, data : DDUser) -> None: query = """SELECT * FROM "oc_mail_accounts" WHERE "email" = '%s'""" sql_query = sql.SQL(query.format(data["email"])) if not len(self.nextcloud_pg.select(sql_query)): diff --git a/dd-sso/admin/src/admin/lib/postgres.py b/dd-sso/admin/src/admin/lib/postgres.py index 84b82ad..10febce 100644 --- a/dd-sso/admin/src/admin/lib/postgres.py +++ b/dd-sso/admin/src/admin/lib/postgres.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -18,54 +19,41 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -import json -import logging as log -import time -import traceback -from datetime import datetime, timedelta - import psycopg2 -import yaml +import psycopg2.sql +from psycopg2.extensions import connection, cursor -# from admin import app +from typing import Any, List, Tuple, Union +query = Union[str, psycopg2.sql.SQL] class Postgres: - def __init__(self, host, database, user, password): + # TODO: Fix this whole class + cur : cursor + conn : connection + def __init__(self, host : str, database : str, user : str, password : str) -> None: self.conn = psycopg2.connect( host=host, database=database, user=user, password=password ) - # def __del__(self): - # self.cur.close() - # self.conn.close() - - def select(self, sql): + def select(self, sql: query) -> List[Tuple[Any, ...]]: self.cur = self.conn.cursor() self.cur.execute(sql) data = self.cur.fetchall() - self.cur.close() + self.cur.close() # type: ignore # psycopg2 type hint missing return data - def update(self, sql): + def update(self, sql : query) -> None: self.cur = self.conn.cursor() self.cur.execute(sql) self.conn.commit() - self.cur.close() + self.cur.close() # type: ignore # psycopg2 type hint missing # return self.cur.fetchall() - def select_with_headers(self, sql): + def select_with_headers(self, sql : query) -> Tuple[List[Any], List[Tuple[Any, ...]]]: self.cur = self.conn.cursor() self.cur.execute(sql) data = self.cur.fetchall() fields = [a.name for a in self.cur.description] - self.cur.close() + self.cur.close() # type: ignore # psycopg2 type hint missing return (fields, data) - - # def update_moodle_saml_plugin(self): - # plugin[('idpmetadata', 'NrtA5ynG0htowP3SXw7dBJRIAMxn-1PwuuXwOwNhlRwMIICmzCCAYMCBgF5jb0RCTANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjEwNTIxMDcwMjI4WhcNMzEwNTIxMDcwNDA4WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCI8xh/C0+frz3kgWiUbziTDls71R2YiXLSVE+bw7gbEgZUGCLhoEI679azMtIxmnzM/snIX+yTb12+XoYkgbiLTMPQfnH+Kiab6g3HL3KPfhqS+yWkFxOoCp6Ibmp7yPlVWuHH+MBfO8OBr/r8Ao7heFbuzjiLd1KG67rcoaxfDgMuBoEomg1bgEjFgHaQIrSC6OZzH0h987/arqufZXeXlfyiqScMPUi+u5IpDWSwz06UKP0k8mxzNSlpZ93CKOUSsV0SMLxqg7FQ3SGiOk577bGW9o9BDTkkmSo3Up6smc0LzwvvUwuNd0B1irGkWZFQN9OXJnJYf1InEebIMtmPAgMBAAEwDQYJKoZIhvcNAQELBQADggEBADM34+qEGeBQ22luphVTuVJtGxcbxLx7DfsT0QfJD/OuxTTbNAa1VRyarb5juIAkqdj4y2quZna9ZXLecVo4RkwpzPoKoAkYA8b+kHnWqEwJi9iPrDvKb+GR0bBkLPN49YxIZ8IdKX/PRa3yuLHe+loiNsCaS/2ZK2KO46COsqU4QX1iVhF9kWphNLybjNAX45B6cJLsa1g0vXLdm3kv3SB4I2fErFVaOoDtFIjttoYlXdpUiThkPXBfr7N67P3dZHaS4tjJh+IZ8I6TINpcsH8dBkUhzYEIPHCePwSiC1w6WDBLNDuKt1mj1CZrLq+1x+Yhrs+QNRheEKGi89HZ8N0=urn:oasis:names:tc:SAML:2.0:nameid-format:persistenturn:oasis:names:tc:SAML:2.0:nameid-format:transienturn:oasis:names:tc:SAML:1.1:nameid-format:unspecifiedurn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress')] - # pg_update = """UPDATE mdl_config_plugins set title = %s where plugin = auth_saml2 and name =""" - # cursor.execute(pg_update, (title, bookid)) - # connection.commit() - # count = cursor.rowcount - # print(count, "Successfully Updated!") diff --git a/dd-sso/admin/src/admin/lib/postup.py b/dd-sso/admin/src/admin/lib/postup.py index 4d5a4df..0e0107f 100644 --- a/dd-sso/admin/src/admin/lib/postup.py +++ b/dd-sso/admin/src/admin/lib/postup.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -23,8 +24,6 @@ import logging as log import os import random -# from .keycloak import Keycloak -# from .moodle import Moodle import string import time import traceback @@ -33,13 +32,16 @@ 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: @@ -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( @@ -225,4 +224,3 @@ class Postup: except: log.error(traceback.format_exc()) exit(1) - None diff --git a/dd-sso/admin/src/admin/views/ApiViews.py b/dd-sso/admin/src/admin/views/ApiViews.py index b452c73..046b740 100644 --- a/dd-sso/admin/src/admin/views/ApiViews.py +++ b/dd-sso/admin/src/admin/views/ApiViews.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -19,6 +20,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import json import logging as log +from operator import itemgetter import os import socket import sys @@ -27,302 +29,307 @@ import traceback from flask import request -from admin import app +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional +if TYPE_CHECKING: + from admin.flaskapp import AdminFlaskApp from ..lib.api_exceptions import Error -from .decorators import has_token +from .decorators import has_token, OptionalJsonResponse -## LISTS -@app.route("/ddapi/users", methods=["GET"]) -@has_token -def ddapi_users(): - if request.method == "GET": - sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) - users = [] - for user in sorted_users: - users.append(user_parser(user)) - return json.dumps(users), 200, {"Content-Type": "application/json"} +def setup_api_views(app : "AdminFlaskApp") -> None: + ## LISTS + @app.json_route("/ddapi/users", methods=["GET"]) + @has_token + def ddapi_users() -> OptionalJsonResponse: + if request.method == "GET": + sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username")) + users = [] + for user in sorted_users: + users.append(user_parser(user)) + return json.dumps(users), 200, {"Content-Type": "application/json"} + return None + @app.json_route("/ddapi/users/filter", methods=["POST"]) + @has_token + def ddapi_users_search() -> OptionalJsonResponse: + if request.method == "POST": + data = request.get_json(force=True) + if not data.get("text"): + raise Error("bad_request", "Incorrect data requested.") + users = app.admin.get_mix_users() + result = [user_parser(user) for user in filter_users(users, data["text"])] + sorted_result = sorted(result, key=itemgetter("id")) + return json.dumps(sorted_result), 200, {"Content-Type": "application/json"} + return None -@app.route("/ddapi/users/filter", methods=["POST"]) -@has_token -def ddapi_users_search(): - if request.method == "POST": - data = request.get_json(force=True) - if not data.get("text"): - raise Error("bad_request", "Incorrect data requested.") - users = app.admin.get_mix_users() - result = [user_parser(user) for user in filter_users(users, data["text"])] - sorted_result = sorted(result, key=lambda k: k["id"]) - return json.dumps(sorted_result), 200, {"Content-Type": "application/json"} + @app.json_route("/ddapi/groups", methods=["GET"]) + @has_token + def ddapi_groups() -> OptionalJsonResponse: + if request.method == "GET": + sorted_groups = sorted(app.admin.get_mix_groups(), key=itemgetter("name")) + groups = [] + for group in sorted_groups: + groups.append(group_parser(group)) + return json.dumps(groups), 200, {"Content-Type": "application/json"} + return None - -@app.route("/ddapi/groups", methods=["GET"]) -@has_token -def ddapi_groups(): - if request.method == "GET": - sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: k["name"]) - groups = [] - for group in sorted_groups: - groups.append(group_parser(group)) - return json.dumps(groups), 200, {"Content-Type": "application/json"} - - -@app.route("/ddapi/group/users", methods=["POST"]) -@has_token -def ddapi_group_users(): - if request.method == "POST": - data = request.get_json(force=True) - sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) - if data.get("id"): - group_users = [ - user_parser(user) - for user in sorted_users - if data.get("id") in user["keycloak_groups"] - ] - elif data.get("path"): - try: - name = [ - g["name"] - for g in app.admin.get_mix_groups() - if g["path"] == data.get("path") - ][0] + @app.json_route("/ddapi/group/users", methods=["POST"]) + @has_token + def ddapi_group_users() -> OptionalJsonResponse: + if request.method == "POST": + data = request.get_json(force=True) + sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username")) + if data.get("id"): group_users = [ user_parser(user) for user in sorted_users - if name in user["keycloak_groups"] + if data.get("id") in user["keycloak_groups"] ] - except: - raise Error("not_found", "Group path not found in system") - elif data.get("keycloak_id"): - try: - name = [ - g["name"] - for g in app.admin.get_mix_groups() - if g["id"] == data.get("keycloak_id") - ][0] - group_users = [ - user_parser(user) - for user in sorted_users - if name in user["keycloak_groups"] - ] - except: - raise Error("not_found", "Group keycloak_id not found in system") - else: - raise Error("bad_request", "Incorrect data requested.") - return json.dumps(group_users), 200, {"Content-Type": "application/json"} + elif data.get("path"): + try: + name = [ + g["name"] + for g in app.admin.get_mix_groups() + if g["path"] == data.get("path") + ][0] + group_users = [ + user_parser(user) + for user in sorted_users + if name in user["keycloak_groups"] + ] + except: + raise Error("not_found", "Group path not found in system") + elif data.get("keycloak_id"): + try: + name = [ + g["name"] + for g in app.admin.get_mix_groups() + if g["id"] == data.get("keycloak_id") + ][0] + group_users = [ + user_parser(user) + for user in sorted_users + if name in user["keycloak_groups"] + ] + except: + raise Error("not_found", "Group keycloak_id not found in system") + else: + raise Error("bad_request", "Incorrect data requested.") + return json.dumps(group_users), 200, {"Content-Type": "application/json"} + return None + @app.json_route("/ddapi/roles", methods=["GET"]) + @has_token + def ddapi_roles() -> OptionalJsonResponse: + if request.method == "GET": + roles = [] + for role in sorted(app.admin.get_roles(), key=itemgetter("name")): + log.error(role) + roles.append( + { + "keycloak_id": role["id"], + "id": role["name"], + "name": role["name"], + "description": role.get("description", ""), + } + ) + return json.dumps(roles), 200, {"Content-Type": "application/json"} + return None -@app.route("/ddapi/roles", methods=["GET"]) -@has_token -def ddapi_roles(): - if request.method == "GET": - roles = [] - for role in sorted(app.admin.get_roles(), key=lambda k: k["name"]): - log.error(role) - roles.append( - { - "keycloak_id": role["id"], - "id": role["name"], - "name": role["name"], - "description": role.get("description", ""), - } - ) - return json.dumps(roles), 200, {"Content-Type": "application/json"} - - -@app.route("/ddapi/role/users", methods=["POST"]) -@has_token -def ddapi_role_users(): - if request.method == "POST": - data = request.get_json(force=True) - sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) - if data.get("id", data.get("name")): - role_users = [ - user_parser(user) - for user in sorted_users - if data.get("id", data.get("name")) in user["roles"] - ] - elif data.get("keycloak_id"): - try: - id = [ - r["id"] - for r in app.admin.get_roles() - if r["id"] == data.get("keycloak_id") - ][0] + @app.json_route("/ddapi/role/users", methods=["POST"]) + @has_token + def ddapi_role_users() -> OptionalJsonResponse: + if request.method == "POST": + data = request.get_json(force=True) + sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username")) + if data.get("id", data.get("name")): role_users = [ - user_parser(user) for user in sorted_users if id in user["roles"] + user_parser(user) + for user in sorted_users + if data.get("id", data.get("name")) in user["roles"] ] - except: - raise Error("not_found", "Role keycloak_id not found in system") - else: - raise Error("bad_request", "Incorrect data requested.") - return json.dumps(role_users), 200, {"Content-Type": "application/json"} + elif data.get("keycloak_id"): + try: + id = [ + r["id"] + for r in app.admin.get_roles() + if r["id"] == data.get("keycloak_id") + ][0] + role_users = [ + user_parser(user) for user in sorted_users if id in user["roles"] + ] + except: + raise Error("not_found", "Role keycloak_id not found in system") + else: + raise Error("bad_request", "Incorrect data requested.") + return json.dumps(role_users), 200, {"Content-Type": "application/json"} + return None - -## INDIVIDUAL ACTIONS -@app.route("/ddapi/user", methods=["POST"]) -@app.route("/ddapi/user/", methods=["PUT", "GET", "DELETE"]) -@has_token -def ddapi_user(user_ddid=None): - if request.method == "GET": - user = app.admin.get_user_username(user_ddid) - if not user: - raise Error("not_found", "User id not found") - return json.dumps(user_parser(user)), 200, {"Content-Type": "application/json"} - if request.method == "DELETE": - user = app.admin.get_user_username(user_ddid) - if not user: - raise Error("not_found", "User id not found") - app.admin.delete_user(user["id"]) - return json.dumps({}), 200, {"Content-Type": "application/json"} - if request.method == "POST": - data = request.get_json(force=True) - if not app.validators["user"].validate(data): - raise Error( - "bad_request", - "Data validation for user failed: ", - +str(app.validators["user"].errors), - traceback.format_exc(), - ) - - if app.admin.get_user_username(data["username"]): - raise Error("conflict", "User id already exists") - data = app.validators["user"].normalized(data) - keycloak_id = app.admin.add_user(data) - if not keycloak_id: - raise Error( - "precondition_required", - "Not all user groups already in system. Please create user groups before adding user.", - ) - return ( - json.dumps({"keycloak_id": keycloak_id}), - 200, - {"Content-Type": "application/json"}, - ) - - if request.method == "PUT": - user = app.admin.get_user_username(user_ddid) - if not user: - raise Error("not_found", "User id not found") - data = request.get_json(force=True) - if not app.validators["user_update"].validate(data): - raise Error( - "bad_request", - "Data validation for user failed: " - + str(app.validators["user_update"].errors), - traceback.format_exc(), - ) - data = {**user, **data} - data = app.validators["user_update"].normalized(data) - data = {**data, **{"username": user_ddid}} - data["roles"] = [data.pop("role")] - data["firstname"] = data.pop("first") - data["lastname"] = data.pop("last") - app.admin.user_update(data) - if data.get("password"): - app.admin.user_update_password( - user["id"], data["password"], data["password_temporary"] - ) - return json.dumps({}), 200, {"Content-Type": "application/json"} - - -@app.route("/ddapi/username//", methods=["PUT"]) -@has_token -def ddapi_username(old_user_ddid, new_user_did): - user = app.admin.get_user_username(user_ddid) - if not user: - raise Error("not_found", "User id not found") - # user = app.admin.update_user_username(old_user_ddid,new_user_did) - return json.dumps("Not implemented yet!"), 419, {"Content-Type": "application/json"} - - -@app.route("/ddapi/group", methods=["POST"]) -@app.route("/ddapi/group/", methods=["GET", "POST", "DELETE"]) -# @app.route("/api/group/", methods=["PUT", "GET", "DELETE"]) -@has_token -def ddapi_group(id=None): - if request.method == "GET": - group = app.admin.get_group_by_name(id) - if not group: - Error("not found", "Group id not found") - return ( - json.dumps(group_parser(group)), - 200, - {"Content-Type": "application/json"}, - ) - if request.method == "POST": - data = request.get_json(force=True) - if not app.validators["group"].validate(data): - raise Error( - "bad_request", - "Data validation for group failed: " - + str(app.validators["group"].errors), - traceback.format_exc(), - ) - data = app.validators["group"].normalized(data) - data["parent"] = data["parent"] if data["parent"] != "" else None - - if app.admin.get_group_by_name(id): - raise Error("conflict", "Group id already exists") - - path = app.admin.add_group(data) - # log.error(path) - # keycloak_id = app.admin.get_group_by_name(id)["id"] - # log.error() - return ( - json.dumps({"keycloak_id": None}), - 200, - {"Content-Type": "application/json"}, - ) - if request.method == "DELETE": - group = app.admin.get_group_by_name(id) - if not group: - raise Error("not_found", "Group id not found") - app.admin.delete_group_by_id(group["id"]) - return json.dumps({}), 200, {"Content-Type": "application/json"} - - -@app.route("/ddapi/user_mail", methods=["POST"]) -@app.route("/ddapi/user_mail/", methods=["GET", "DELETE"]) -@has_token -def ddapi_user_mail(id=None): - if request.method == "GET": - return ( - json.dumps("Not implemented yet"), - 200, - {"Content-Type": "application/json"}, - ) - if request.method == "POST": - data = request.get_json(force=True) - - # if not app.validators["mails"].validate(data): - # raise Error( - # "bad_request", - # "Data validation for mail failed: " - # + str(app.validators["mail"].errors), - # traceback.format_exc(), - # ) - for user in data: - if not app.validators["mail"].validate(user): + ## INDIVIDUAL ACTIONS + @app.json_route("/ddapi/user", methods=["POST"]) + @app.json_route("/ddapi/user/", methods=["PUT", "GET", "DELETE"]) + @has_token + def ddapi_user(user_ddid : Optional[str]=None) -> OptionalJsonResponse: + uid : str = user_ddid if user_ddid else '' + if request.method == "GET": + user = app.admin.get_user_username(uid) + if not user: + raise Error("not_found", "User id not found") + return json.dumps(user_parser(user)), 200, {"Content-Type": "application/json"} + if request.method == "DELETE": + user = app.admin.get_user_username(uid) + if not user: + raise Error("not_found", "User id not found") + app.admin.delete_user(user["id"]) + return json.dumps({}), 200, {"Content-Type": "application/json"} + if request.method == "POST": + data = request.get_json(force=True) + if not app.validators["user"].validate(data): raise Error( "bad_request", - "Data validation for mail failed: " - + str(app.validators["mail"].errors), + "Data validation for user failed: ", + +str(app.validators["user"].errors), traceback.format_exc(), ) - for user in data: - log.info("Added user email") - app.admin.set_nextcloud_user_mail(user) - return ( - json.dumps("Users emails updated"), - 200, - {"Content-Type": "application/json"}, - ) + if app.admin.get_user_username(data["username"]): + raise Error("conflict", "User id already exists") + data = app.validators["user"].normalized(data) + keycloak_id = app.admin.add_user(data) + if not keycloak_id: + raise Error( + "precondition_required", + "Not all user groups already in system. Please create user groups before adding user.", + ) + return ( + json.dumps({"keycloak_id": keycloak_id}), + 200, + {"Content-Type": "application/json"}, + ) -def user_parser(user): + if request.method == "PUT": + user = app.admin.get_user_username(uid) + if not user: + raise Error("not_found", "User id not found") + data = request.get_json(force=True) + if not app.validators["user_update"].validate(data): + raise Error( + "bad_request", + "Data validation for user failed: " + + str(app.validators["user_update"].errors), + traceback.format_exc(), + ) + data = {**user, **data} + data = app.validators["user_update"].normalized(data) + data = {**data, **{"username": uid}} + data["roles"] = [data.pop("role")] + data["firstname"] = data.pop("first") + data["lastname"] = data.pop("last") + app.admin.user_update(data) + if data.get("password"): + app.admin.user_update_password( + user["id"], data["password"], data["password_temporary"] + ) + return json.dumps({}), 200, {"Content-Type": "application/json"} + return None + + @app.json_route("/ddapi/username//", methods=["PUT"]) + @has_token + def ddapi_username(old_user_ddid : str, new_user_did : str) -> OptionalJsonResponse: + user = app.admin.get_user_username(user_ddid) + if not user: + raise Error("not_found", "User id not found") + # user = app.admin.update_user_username(old_user_ddid,new_user_did) + return json.dumps("Not implemented yet!"), 419, {"Content-Type": "application/json"} + + @app.json_route("/ddapi/group", methods=["POST"]) + @app.json_route("/ddapi/group/", methods=["GET", "POST", "DELETE"]) + # @app.json_route("/api/group/", methods=["PUT", "GET", "DELETE"]) + @has_token + def ddapi_group(group_id : Optional[str]=None) -> OptionalJsonResponse: + uid : str = group_id if group_id else '' + if request.method == "GET": + group = app.admin.get_group_by_name(uid) + if not group: + Error("not found", "Group id not found") + return ( + json.dumps(group_parser(group)), + 200, + {"Content-Type": "application/json"}, + ) + if request.method == "POST": + data = request.get_json(force=True) + if not app.validators["group"].validate(data): + raise Error( + "bad_request", + "Data validation for group failed: " + + str(app.validators["group"].errors), + traceback.format_exc(), + ) + data = app.validators["group"].normalized(data) + data["parent"] = data["parent"] if data["parent"] != "" else None + + if app.admin.get_group_by_name(uid): + raise Error("conflict", "Group id already exists") + + path = app.admin.add_group(data) + # log.error(path) + # keycloak_id = app.admin.get_group_by_name(id)["id"] + # log.error() + return ( + json.dumps({"keycloak_id": None}), + 200, + {"Content-Type": "application/json"}, + ) + if request.method == "DELETE": + group = app.admin.get_group_by_name(uid) + if not group: + raise Error("not_found", "Group id not found") + app.admin.delete_group_by_id(group["id"]) + return json.dumps({}), 200, {"Content-Type": "application/json"} + return None + + @app.json_route("/ddapi/user_mail", methods=["POST"]) + @app.json_route("/ddapi/user_mail/", methods=["GET", "DELETE"]) + @has_token + def ddapi_user_mail(id : Optional[str]=None) -> OptionalJsonResponse: + 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.set_nextcloud_user_mail(user) + 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 +345,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 +355,7 @@ def group_parser(group): } -def filter_users(users, text): +def filter_users(users : Iterable[Dict[str, Any]], text : str) -> List[Dict[str, Any]]: return [ user for user in users diff --git a/dd-sso/admin/src/admin/views/AppViews.py b/dd-sso/admin/src/admin/views/AppViews.py index baa9077..ed221e3 100644 --- a/dd-sso/admin/src/admin/views/AppViews.py +++ b/dd-sso/admin/src/admin/views/AppViews.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -33,10 +34,12 @@ 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, Dict, Optional +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} # q = Queue.Queue() @@ -46,536 +49,542 @@ from keycloak.exceptions import KeycloakGetError from ..lib.dashboard import Dashboard from ..lib.exceptions import UserExists, UserNotFound -dashboard = Dashboard() from ..lib.legal import get_legal, gen_legal_if_not_exists, new_legal -@app.route("/sysadmin/api/resync") -@app.route("/api/resync") -@login_required -def resync(): - return ( - json.dumps(app.admin.resync_data()), - 200, - {"Content-Type": "application/json"}, - ) - - -@app.route("/api/users", methods=["GET", "PUT"]) -@app.route("/api/users/", methods=["POST", "PUT", "GET", "DELETE"]) -@login_or_token -def users(provider=False): - if request.method == "DELETE": - if current_user.role != "admin": - return json.dumps({}), 301, {"Content-Type": "application/json"} - if provider == "keycloak": - 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()), - 200, - {"Content-Type": "application/json"}, - ) - if request.method == "POST": - if current_user.role != "admin": - return json.dumps({}), 301, {"Content-Type": "application/json"} - if provider == "moodle": - return ( - json.dumps(app.admin.sync_to_moodle()), - 200, - {"Content-Type": "application/json"}, - ) - if provider == "nextcloud": - return ( - json.dumps(app.admin.sync_to_nextcloud()), - 200, - {"Content-Type": "application/json"}, - ) - if request.method == "PUT" and not provider: - if current_user.role != "admin": - return json.dumps({}), 301, {"Content-Type": "application/json"} - - if "external" in threads.keys(): - if threads["external"] is not None and threads["external"].is_alive(): - return ( - json.dumps( - {"msg": "Precondition failed: already working with users"} - ), - 412, - {"Content-Type": "application/json"}, - ) - else: - threads["external"] = None - try: - threads["external"] = threading.Thread( - target=app.admin.update_users_from_keycloak, args=() - ) - threads["external"].start() - return json.dumps({}), 200, {"Content-Type": "application/json"} - except: - log.error(traceback.format_exc()) - return ( - json.dumps({"msg": "Add user error."}), - 500, - {"Content-Type": "application/json"}, - ) - - # return json.dumps(app.admin.update_users_from_keycloak()), 200, {'Content-Type': 'application/json'} - - users = app.admin.get_mix_users() - if current_user.role != "admin": - for user in users: - user["keycloak_groups"] = [ - g for g in user["keycloak_groups"] if not system_group(g) - ] - return json.dumps(users), 200, {"Content-Type": "application/json"} - - -@app.route("/api/users_bulk/", methods=["PUT"]) -@login_required -def users_bulk(action): - data = request.get_json(force=True) - if request.method == "PUT": - if action == "enable": - if "external" in threads.keys(): - if threads["external"] is not None and threads["external"].is_alive(): - return ( - json.dumps( - {"msg": "Precondition failed: already operating users"} - ), - 412, - {"Content-Type": "application/json"}, - ) - else: - threads["external"] = None - try: - threads["external"] = threading.Thread( - target=app.admin.enable_users, args=(data,) - ) - threads["external"].start() - return json.dumps({}), 200, {"Content-Type": "application/json"} - except: - log.error(traceback.format_exc()) - return ( - json.dumps({"msg": "Enable users error."}), - 500, - {"Content-Type": "application/json"}, - ) - if action == "disable": - if "external" in threads.keys(): - if threads["external"] is not None and threads["external"].is_alive(): - return ( - json.dumps( - {"msg": "Precondition failed: already operating users"} - ), - 412, - {"Content-Type": "application/json"}, - ) - else: - threads["external"] = None - try: - threads["external"] = threading.Thread( - target=app.admin.disable_users, args=(data,) - ) - threads["external"].start() - return json.dumps({}), 200, {"Content-Type": "application/json"} - except: - log.error(traceback.format_exc()) - return ( - json.dumps({"msg": "Disabling users error."}), - 500, - {"Content-Type": "application/json"}, - ) - if action == "delete": - if "external" in threads.keys(): - if threads["external"] is not None and threads["external"].is_alive(): - return ( - json.dumps( - {"msg": "Precondition failed: already operating users"} - ), - 412, - {"Content-Type": "application/json"}, - ) - else: - threads["external"] = None - try: - threads["external"] = threading.Thread( - target=app.admin.delete_users, args=(data,) - ) - threads["external"].start() - return json.dumps({}), 200, {"Content-Type": "application/json"} - except: - log.error(traceback.format_exc()) - return ( - json.dumps({"msg": "Deleting users error."}), - 500, - {"Content-Type": "application/json"}, - ) - return json.dumps({}), 405, {"Content-Type": "application/json"} - - -# Update pwd -@app.route("/api/user_password", methods=["GET"]) -@app.route("/api/user_password/", methods=["PUT"]) -@login_required -def user_password(userid=False): - if request.method == "GET": +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.get_dice_pwd()), + json.dumps(app.admin.resync_data()), 200, {"Content-Type": "application/json"}, ) - if request.method == "PUT": - data = request.get_json(force=True) - password = data["password"] - temporary = data.get("temporary", True) - try: - res = app.admin.user_update_password(userid, password, temporary) - return json.dumps({}), 200, {"Content-Type": "application/json"} - except KeycloakGetError as e: - log.error(e.error_message.decode("utf-8")) - return ( - json.dumps({"msg": "Update password error."}), - 500, - {"Content-Type": "application/json"}, - ) - - return json.dumps({}), 405, {"Content-Type": "application/json"} -# User -@app.route("/api/user", methods=["POST"]) -@app.route("/api/user/", methods=["PUT", "GET", "DELETE"]) -@login_required -def user(userid=None): - if request.method == "DELETE": - app.admin.delete_user(userid) - return json.dumps({}), 200, {"Content-Type": "application/json"} - if request.method == "POST": - data = request.get_json(force=True) - if app.admin.get_user_username(data["username"]): - return ( - json.dumps({"msg": "Add user error: already exists."}), - 409, - {"Content-Type": "application/json"}, - ) - data["enabled"] = data.get("enabled", False) in [True, "on"] - data["quota"] = data["quota"] if data["quota"] != "false" else False - data["groups"] = data["groups"] if data.get("groups", False) else [] - if "external" in threads.keys(): - if threads["external"] is not None and threads["external"].is_alive(): + @app.json_route("/api/users", methods=["GET", "PUT"]) + @app.json_route("/api/users/", methods=["POST", "PUT", "GET", "DELETE"]) + @login_or_token + def users(provider : bool=False) -> OptionalJsonResponse: + if request.method == "DELETE": + if current_user.role != "admin": + return json.dumps({}), 301, {"Content-Type": "application/json"} + if provider == "keycloak": return ( - json.dumps({"msg": "Precondition failed: already adding users"}), - 412, + json.dumps(app.admin.delete_keycloak_users()), + 200, {"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 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 request.method == "PUT": + 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.json_route("/api/users_bulk/", methods=["PUT"]) + @login_required + def users_bulk(action : str) -> OptionalJsonResponse: 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: + 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.json_route("/api/user_password", methods=["GET"]) + @app.json_route("/api/user_password/", methods=["PUT"]) + @login_required + def user_password(userid : Optional[str]=None) -> OptionalJsonResponse: + if request.method == "GET": return ( - json.dumps({"msg": "User not found."}), - 404, + json.dumps(app.admin.get_dice_pwd()), + 200, {"Content-Type": "application/json"}, ) - if request.method == "DELETE": - pass - if request.method == "GET": - user = app.admin.get_user(userid) - if not user: - return ( - json.dumps({"msg": "User not found."}), - 404, - {"Content-Type": "application/json"}, - ) - return json.dumps(user), 200, {"Content-Type": "application/json"} - - -@app.route("/api/roles") -@login_required -def roles(): - sorted_roles = sorted(app.admin.get_roles(), key=lambda k: k["name"]) - if current_user.role != "admin": - sorted_roles = [sr for sr in sorted_roles if sr["name"] != "admin"] - return json.dumps(sorted_roles), 200, {"Content-Type": "application/json"} - - -@app.route("/api/group", methods=["POST", "DELETE"]) -@app.route("/api/group/", methods=["PUT", "GET", "DELETE"]) -@login_required -def group(group_id=False): - if request.method == "POST": - data = request.get_json(force=True) - log.error(data) - data["parent"] = data["parent"] if data["parent"] != "" else None - return ( - json.dumps(app.admin.add_group(data)), - 200, - {"Content-Type": "application/json"}, - ) - if request.method == "DELETE": - try: + if request.method == "PUT": 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: + 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({"error": "bad_request","msg":"Bad request"}), - 400, + json.dumps({"msg": "Update password error."}), + 500, {"Content-Type": "application/json"}, ) - res = app.admin.delete_group_by_id(group_id) - return json.dumps(res), 200, {"Content-Type": "application/json"} + + return json.dumps({}), 405, {"Content-Type": "application/json"} -@app.route("/api/groups") -@app.route("/api/groups/", methods=["POST", "PUT", "GET", "DELETE"]) -@login_required -def groups(provider=False): - if request.method == "GET": - sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: str(k["name"])) - if current_user.role != "admin": - ## internal groups should be avoided as are assigned with the role - sorted_groups = [sg for sg in sorted_groups if not system_group(sg["name"])] - else: - sorted_groups = [sg for sg in sorted_groups] - return json.dumps(sorted_groups), 200, {"Content-Type": "application/json"} - if request.method == "DELETE": - if provider == "keycloak": - return ( - json.dumps(app.admin.delete_keycloak_groups()), - 200, - {"Content-Type": "application/json"}, - ) - - -### SYSADM USERS ONLY - - -@app.route("/api/external", methods=["POST", "PUT", "GET", "DELETE"]) -@login_required -def external(): - if "external" in threads.keys(): - if threads["external"] is not None and threads["external"].is_alive(): - return json.dumps({}), 301, {"Content-Type": "application/json"} - else: - threads["external"] = None - - if request.method == "POST": - data = request.get_json(force=True) - if data["format"] == "json-ga": - threads["external"] = threading.Thread( - target=app.admin.upload_json_ga, args=(data,) - ) - threads["external"].start() + # User + @app.json_route("/api/user", methods=["POST"]) + @app.json_route("/api/user/", methods=["PUT", "GET", "DELETE"]) + @login_required + def user(userid : Optional[str]=None) -> OptionalJsonResponse: + uid : str = userid if userid else '' + if request.method == "DELETE": + app.admin.delete_user(uid) return json.dumps({}), 200, {"Content-Type": "application/json"} - if data["format"] == "csv-ug": - valid = check_upload_errors(data) - if valid["pass"]: + 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.upload_csv_ug, args=(data,) + 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(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=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.json_route("/api/group", methods=["POST", "DELETE"]) + @app.json_route("/api/group/", methods=["PUT", "GET", "DELETE"]) + @login_required + def group(group_id : Optional[str]=None) -> OptionalJsonResponse: + if request.method == "POST": + data = request.get_json(force=True) + log.error(data) + data["parent"] = data["parent"] if data["parent"] != "" else None + return ( + json.dumps(app.admin.add_group(data)), + 200, + {"Content-Type": "application/json"}, + ) + if request.method == "DELETE": + try: + data = request.get_json(force=True) + except: + data = False + + if data: + res = app.admin.delete_group_by_path(data["path"]) else: - return json.dumps(valid), 422, {"Content-Type": "application/json"} - if request.method == "PUT": - data = request.get_json(force=True) - threads["external"] = threading.Thread( - target=app.admin.sync_external, args=(data,) - ) - 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"} + if not group_id: + return ( + json.dumps({"error": "bad_request","msg":"Bad request"}), + 400, + {"Content-Type": "application/json"}, + ) + res = app.admin.delete_group_by_id(group_id) + return json.dumps(res), 200, {"Content-Type": "application/json"} + return None + + @app.json_route("/api/groups") + @app.json_route("/api/groups/", methods=["POST", "PUT", "GET", "DELETE"]) + @login_required + def groups(provider : Optional[str] = None) -> OptionalJsonResponse: + if request.method == "GET": + sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: str(k["name"])) + if current_user.role != "admin": + ## internal groups should be avoided as are assigned with the role + sorted_groups = [sg for sg in sorted_groups if not system_group(sg["name"])] + else: + sorted_groups = [sg for sg in sorted_groups] + return json.dumps(sorted_groups), 200, {"Content-Type": "application/json"} + if request.method == "DELETE": + if provider == "keycloak": + return ( + json.dumps(app.admin.delete_keycloak_groups()), + 200, + {"Content-Type": "application/json"}, + ) + return None + + ### SYSADM USERS ONLY -@app.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.json_route("/api/external", methods=["POST", "PUT", "GET", "DELETE"]) + @login_required + def external() -> OptionalJsonResponse: + if "external" in threads.keys(): + if threads["external"] is not None and threads["external"].is_alive(): + return json.dumps({}), 301, {"Content-Type": "application/json"} + else: + threads["external"] = None + + if request.method == "POST": + data = request.get_json(force=True) + if data["format"] == "json-ga": + threads["external"] = threading.Thread( + target=app.admin.upload_json_ga, args=(data,) + ) + threads["external"].start() + return json.dumps({}), 200, {"Content-Type": "application/json"} + if data["format"] == "csv-ug": + valid = check_upload_errors(data) + if valid["pass"]: + threads["external"] = threading.Thread( + target=app.admin.upload_csv_ug, args=(data,) + ) + threads["external"].start() + return json.dumps({}), 200, {"Content-Type": "application/json"} + else: + return json.dumps(valid), 422, {"Content-Type": "application/json"} + if request.method == "PUT": + data = request.get_json(force=True) + threads["external"] = threading.Thread( + target=app.admin.sync_external, args=(data,) + ) + 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/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": + @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.external_roleassign(request.get_json(force=True))), + json.dumps(app.admin.get_external_users()), 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/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"}, + ) - 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"] == "": + @app.json_route("/api/external/roles", methods=["PUT"]) + @login_required + def external_roles() -> OptionalJsonResponse: + if request.method == "PUT": + return ( + json.dumps(app.admin.external_roleassign(request.get_json(force=True))), + 200, + {"Content-Type": "application/json"}, + ) + return None + + + def check_upload_errors(data : Dict[Any, Any]) -> Dict[Any, Any]: + email_regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b" + for u in data["data"]: + try: + user_groups = [g.strip() for g in u["groups"].split(",")] + except: resp = { "pass": False, - "msg": "User " + u["username"] + " has no role assigned!", + "msg": "User " + u["username"] + " has invalid groups: " + u["groups"], } log.error(resp) return resp - resp = { - "pass": False, - "msg": "User " + u["username"] + " has invalid role: " + u["role"], - } - log.error(resp) - return resp - return {"pass": True, "msg": ""} + + if not re.fullmatch(email_regex, u["email"]): + resp = { + "pass": False, + "msg": "User " + u["username"] + " has invalid email: " + u["email"], + } + log.error(resp) + return resp + + if u["role"] not in ["admin", "manager", "teacher", "student"]: + if u["role"] == "": + resp = { + "pass": False, + "msg": "User " + u["username"] + " has no role assigned!", + } + log.error(resp) + return resp + resp = { + "pass": False, + "msg": "User " + u["username"] + " has invalid role: " + u["role"], + } + log.error(resp) + return resp + return {"pass": True, "msg": ""} -@app.route("/api/dashboard/", methods=["PUT"]) -@login_required -def dashboard_put(item): - if item == "colours": - try: - data = request.get_json(force=True) - dashboard.update_colours(data) - except: - log.error(traceback.format_exc()) - return json.dumps({"colours": data}), 200, {"Content-Type": "application/json"} - if item == "menu": - try: - data = request.get_json(force=True) - dashboard.update_menu(data) - except: - log.error(traceback.format_exc()) - return json.dumps(data), 200, {"Content-Type": "application/json"} - if item == "logo": - dashboard.update_logo(request.files["croppedImage"]) - return json.dumps({}), 200, {"Content-Type": "application/json"} - if item == "background": - dashboard.update_background(request.files["croppedImage"]) - return json.dumps({}), 200, {"Content-Type": "application/json"} - return ( - json.dumps( - { - "error": "update_error", - "msg": "Error updating item " + item + "\n" + traceback.format_exc(), - } - ), - 500, - {"Content-Type": "application/json"}, - ) - - -@app.route("/api/legal/", methods=["GET"]) -# @login_required -def legal_get(item): - if request.method == "GET": - if item == "legal": - lang = request.args.get("lang") - if not lang or lang not in ["ca","es","en","fr"]: - lang="ca" - gen_legal_if_not_exists(lang) - return ( - json.dumps({"html": get_legal(lang)}), - 200, - {"Content-Type": "application/json"}, - ) - # if item == "privacy": - # return json.dumps({ "html": "Privacy policy
This works!"}), 200, {'Content-Type': 'application/json'} - - -@app.route("/api/legal/", methods=["POST"]) -@login_required -def legal_put(item): - if request.method == "POST": - if item == "legal": - data = None + @app.json_route("/api/dashboard/", methods=["PUT"]) + @login_required + def dashboard_put(item : str) -> OptionalJsonResponse: + if item == "colours": try: - data = data = request.get_json(force=True) - html = data["html"] - lang = data["lang"] - if not lang or lang not in ["ca","es","en","fr"]: - lang="ca" - new_legal(lang,html) + data = request.get_json(force=True) + dashboard.update_colours(data) + except: + log.error(traceback.format_exc()) + return json.dumps({"colours": data}), 200, {"Content-Type": "application/json"} + if item == "menu": + try: + data = request.get_json(force=True) + dashboard.update_menu(data) except: log.error(traceback.format_exc()) return json.dumps(data), 200, {"Content-Type": "application/json"} - # if item == "privacy": - # data = None - # try: - # data = request.json - # html = data["html"] - # lang = data["lang"] - # except: - # log.error(traceback.format_exc()) - # return json.dumps(data), 200, {'Content-Type': 'application/json'} + if item == "logo": + dashboard.update_logo(request.files["croppedImage"]) + return json.dumps({}), 200, {"Content-Type": "application/json"} + if item == "background": + dashboard.update_background(request.files["croppedImage"]) + return json.dumps({}), 200, {"Content-Type": "application/json"} + return ( + json.dumps( + { + "error": "update_error", + "msg": "Error updating item " + item + "\n" + traceback.format_exc(), + } + ), + 500, + {"Content-Type": "application/json"}, + ) + + + @app.json_route("/api/legal/", methods=["GET"]) + # @login_required + def legal_get(item : str) -> OptionalJsonResponse: + if request.method == "GET": + if item == "legal": + lang = request.args.get("lang") + if not lang or lang not in ["ca","es","en","fr"]: + lang="ca" + gen_legal_if_not_exists(app, lang) + return ( + json.dumps({"html": get_legal(app, lang)}), + 200, + {"Content-Type": "application/json"}, + ) + # if item == "privacy": + # return json.dumps({ "html": "Privacy policy
This works!"}), 200, {'Content-Type': 'application/json'} + return None + + + @app.json_route("/api/legal/", methods=["POST"]) + @login_required + def legal_put(item : str) -> OptionalJsonResponse: + if request.method == "POST": + if item == "legal": + data = None + try: + data = data = request.get_json(force=True) + html = data["html"] + lang = data["lang"] + if not lang or lang not in ["ca","es","en","fr"]: + lang="ca" + new_legal(app, lang, html) + except: + log.error(traceback.format_exc()) + return json.dumps(data), 200, {"Content-Type": "application/json"} + return None + # if item == "privacy": + # data = None + # try: + # data = request.json + # html = data["html"] + # lang = data["lang"] + # except: + # log.error(traceback.format_exc()) + # return json.dumps(data), 200, {'Content-Type': 'application/json'} diff --git a/dd-sso/admin/src/admin/views/LoginViews.py b/dd-sso/admin/src/admin/views/LoginViews.py index 95f6609..d2cf432 100644 --- a/dd-sso/admin/src/admin/views/LoginViews.py +++ b/dd-sso/admin/src/admin/views/LoginViews.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -21,41 +22,44 @@ import os from flask import flash, redirect, render_template, request, url_for from flask_login import current_user, login_required, login_user, logout_user +from werkzeug.wrappers import Response -from admin import app +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from admin.flaskapp import AdminFlaskApp from ..auth.authentication import * -@app.route("/", methods=["GET", "POST"]) -@app.route("/login", methods=["GET", "POST"]) -def login(): - if request.method == "POST": - if request.form["user"] == "" or request.form["password"] == "": - flash("Can't leave it blank", "danger") - elif request.form["user"].startswith(" "): - flash("Username not found or incorrect password.", "warning") - else: - ram_user = ram_users.get(request.form["user"]) - if ram_user and request.form["password"] == ram_user["password"]: - user = User( - { - "id": ram_user["id"], - "password": ram_user["password"], - "role": ram_user["role"], - "active": True, - } - ) - login_user(user) - flash("Logged in successfully.", "success") - return redirect(url_for("web_users")) - else: +def setup_login_views(app : "AdminFlaskApp") -> None: + @app.route("/", methods=["GET", "POST"]) + @app.route("/login", methods=["GET", "POST"]) + def login() -> Response: + if request.method == "POST": + if request.form["user"] == "" or request.form["password"] == "": + flash("Can't leave it blank", "danger") + elif request.form["user"].startswith(" "): flash("Username not found or incorrect password.", "warning") - return render_template("login.html") + else: + ram_user = ram_users.get(request.form["user"]) + if ram_user and request.form["password"] == ram_user["password"]: + user = User( + id = ram_user["id"], + password = ram_user["password"], + role = ram_user["role"], + active = True, + ) + login_user(user) + flash("Logged in successfully.", "success") + return redirect(url_for("web_users")) + else: + flash("Username not found or incorrect password.", "warning") + o : Response = app.make_response(render_template("login.html")) + return o -@app.route("/logout", methods=["GET"]) -@login_required -def logout(): - logout_user() - return redirect(url_for("login")) + @app.route("/logout", methods=["GET"]) + @login_required + def logout() -> Response: + logout_user() + return redirect(url_for("login")) diff --git a/dd-sso/admin/src/admin/views/Socketio.py b/dd-sso/admin/src/admin/views/Socketio.py deleted file mode 100644 index a945d9c..0000000 --- a/dd-sso/admin/src/admin/views/Socketio.py +++ /dev/null @@ -1,39 +0,0 @@ -# -# Copyright © 2021,2022 IsardVDI S.L. -# -# This file is part of DD -# -# DD is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. -# -# DD is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more -# details. -# -# You should have received a copy of the GNU Affero General Public License -# along with DD. If not, see . -# -# SPDX-License-Identifier: AGPL-3.0-or-later - -# from flask_socketio import SocketIO, emit, join_room, leave_room, \ -# close_room, rooms, disconnect, send -# from admin import app -# import json - -# # socketio = SocketIO(app) -# # from ...start import socketio - -# @socketio.on('connect', namespace='//sio') -# def socketio_connect(): -# join_room('admin') -# socketio.emit('update', -# json.dumps('Joined'), -# namespace='//sio', -# room='admin') - -# @socketio.on('disconnect', namespace='//sio') -# def socketio_domains_disconnect(): -# None diff --git a/dd-sso/admin/src/admin/views/WebViews.py b/dd-sso/admin/src/admin/views/WebViews.py index d72db1e..d14feec 100644 --- a/dd-sso/admin/src/admin/views/WebViews.py +++ b/dd-sso/admin/src/admin/views/WebViews.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -34,107 +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 -def render_template(*args, **kwargs): +def render_template(*args : str, **kwargs : str) -> str: kwargs["DOMAIN"] = os.environ["DOMAIN"] return render_template_flask(*args, **kwargs) -@app.route("/users") -@login_required -def web_users(): - return render_template("pages/users.html", title="Users", nav="Users") +def setup_web_views(app : "AdminFlaskApp") -> None: + @app.route("/users") + @login_required + def web_users() -> str: + return render_template("pages/users.html", title="Users", nav="Users") -@app.route("/roles") -@login_required -def web_roles(): - return render_template("pages/roles.html", title="Roles", nav="Roles") + @app.route("/roles") + @login_required + def web_roles() -> str: + return render_template("pages/roles.html", title="Roles", nav="Roles") -@app.route("/groups") -@login_required -def web_groups(provider=False): - return render_template("pages/groups.html", title="Groups", nav="Groups") + @app.route("/groups") + @login_required + def web_groups(provider : bool=False) -> str: + return render_template("pages/groups.html", title="Groups", nav="Groups") -@app.route("/avatar/", methods=["GET"]) -@login_required -def avatar(userid): - if userid != "false": - return send_file("../avatars/master-avatars/" + userid, mimetype="image/jpeg") - return send_file("static/img/missing.jpg", mimetype="image/jpeg") + @app.route("/avatar/", methods=["GET"]) + @login_required + def avatar(userid : str) -> Response: + if userid != "false": + return send_file("../avatars/master-avatars/" + userid, mimetype="image/jpeg") + return send_file("static/img/missing.jpg", mimetype="image/jpeg") -@app.route("/dashboard") -@login_required -def dashboard(provider=False): - data = json.loads(requests.get("http://dd-sso-api/json").text) - return render_template( - "pages/dashboard.html", title="Customization", nav="Customization", data=data - ) + @app.route("/dashboard") + @login_required + def dashboard(provider : bool=False) -> str: + data = json.loads(requests.get("http://dd-sso-api/json").text) + return render_template( + "pages/dashboard.html", title="Customization", nav="Customization", data=data + ) -@app.route("/legal") -@login_required -def legal(): - # data = json.loads(requests.get("http://dd-sso-api/json").text) - return render_template("pages/legal.html", title="Legal", nav="Legal", data={}) + @app.route("/legal") + @login_required + def legal() -> str: + # data = json.loads(requests.get("http://dd-sso-api/json").text) + return render_template("pages/legal.html", title="Legal", nav="Legal", data="") -@app.route("/legal_text") -def legal_text(): - lang = request.args.get("lang") - if not lang or lang not in ["ca","es","en","fr"]: - lang="ca" - gen_legal_if_not_exists(lang) - return render_template("pages/legal/"+lang) + @app.route("/legal_text") + def legal_text() -> str: + lang = request.args.get("lang") + if not lang or lang not in ["ca","es","en","fr"]: + lang="ca" + gen_legal_if_not_exists(app, lang) + return render_template("pages/legal/"+lang) -### SYS ADMIN + ### SYS ADMIN -@app.route("/sysadmin/users") -@login_required -@is_admin -def web_sysadmin_users(): - return render_template( - "pages/sysadmin/users.html", title="SysAdmin Users", nav="SysAdminUsers" - ) + @app.route("/sysadmin/users") + @login_required + @is_admin + def web_sysadmin_users() -> Response: + o : Response = app.make_response(render_template( + "pages/sysadmin/users.html", title="SysAdmin Users", nav="SysAdminUsers" + )) + return o -@app.route("/sysadmin/groups") -@login_required -@is_admin -def web_sysadmin_groups(): - return render_template( - "pages/sysadmin/groups.html", title="SysAdmin Groups", nav="SysAdminGroups" - ) + @app.route("/sysadmin/groups") + @login_required + @is_admin + def web_sysadmin_groups() -> Response: + o : Response = app.make_response(render_template( + "pages/sysadmin/groups.html", title="SysAdmin Groups", nav="SysAdminGroups" + )) + return o -@app.route("/sysadmin/external") -@login_required -## SysAdmin role -def web_sysadmin_external(): - return render_template( - "pages/sysadmin/external.html", title="External", nav="External" - ) + @app.route("/sysadmin/external") + @login_required + ## SysAdmin role + def web_sysadmin_external() -> str: + return render_template( + "pages/sysadmin/external.html", title="External", nav="External" + ) -@app.route("/sockettest") -def web_sockettest(): - return render_template( - "pages/sockettest.html", title="Sockettest Users", nav="SysAdminUsers" - ) + @app.route("/sockettest") + def web_sockettest() -> str: + return render_template( + "pages/sockettest.html", title="Sockettest Users", nav="SysAdminUsers" + ) diff --git a/dd-sso/admin/src/admin/views/WpViews.py b/dd-sso/admin/src/admin/views/WpViews.py index 5695bca..09d3b7f 100644 --- a/dd-sso/admin/src/admin/views/WpViews.py +++ b/dd-sso/admin/src/admin/views/WpViews.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -20,6 +21,7 @@ import json import logging as log +from operator import itemgetter import os import socket import sys @@ -28,113 +30,117 @@ import traceback from flask import request -from admin import app +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List +if TYPE_CHECKING: + from admin.flaskapp import AdminFlaskApp -from .decorators import is_internal +from admin.views.decorators import OptionalJsonResponse, is_internal -@app.route("/api/internal/users", methods=["GET"]) -@is_internal -def internal_users(): - log.error(socket.gethostbyname("dd-apps-wordpress")) - if request.method == "GET": - sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) - # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']] - users = [] - for user in sorted_users: - if not user.get("enabled"): - continue - users.append(user_parser(user)) - return json.dumps(users), 200, {"Content-Type": "application/json"} +def setup_wp_views(app : "AdminFlaskApp") -> None: + @app.json_route("/api/internal/users", methods=["GET"]) + @is_internal + def internal_users() -> OptionalJsonResponse: + log.error(socket.gethostbyname("dd-apps-wordpress")) + if request.method == "GET": + sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username")) + # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']] + users = [] + for user in sorted_users: + if not user.get("enabled"): + continue + users.append(user_parser(user)) + return json.dumps(users), 200, {"Content-Type": "application/json"} + return None -@app.route("/api/internal/users/filter", methods=["POST"]) -@is_internal -def internal_users_search(): - if request.method == "POST": - data = request.get_json(force=True) - users = app.admin.get_mix_users() - result = [user_parser(user) for user in filter_users(users, data["text"])] - sorted_result = sorted(result, key=lambda k: k["id"]) - return json.dumps(sorted_result), 200, {"Content-Type": "application/json"} - - -@app.route("/api/internal/groups", methods=["GET"]) -@is_internal -def internal_groups(): - if request.method == "GET": - sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: k["name"]) - groups = [] - for group in sorted_groups: - if not group["path"].startswith("/"): - continue - groups.append( - { - "id": group["path"], - "name": group["name"], - "description": group.get("description", ""), - } - ) - return json.dumps(groups), 200, {"Content-Type": "application/json"} - - -@app.route("/api/internal/group/users", methods=["POST"]) -@is_internal -def internal_group_users(): - if request.method == "POST": - data = request.get_json(force=True) - sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) - # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']] - users = [] - for user in sorted_users: - if data["path"] not in user["keycloak_groups"] or not user.get("enabled"): - continue - users.append(user) - if data.get("text", False) and data["text"] != "": + @app.json_route("/api/internal/users/filter", methods=["POST"]) + @is_internal + def internal_users_search() -> OptionalJsonResponse: + if request.method == "POST": + data = request.get_json(force=True) + users = app.admin.get_mix_users() result = [user_parser(user) for user in filter_users(users, data["text"])] - else: - result = [user_parser(user) for user in users] - return json.dumps(result), 200, {"Content-Type": "application/json"} + sorted_result = sorted(result, key=itemgetter("id")) + return json.dumps(sorted_result), 200, {"Content-Type": "application/json"} + return None + @app.json_route("/api/internal/groups", methods=["GET"]) + @is_internal + def internal_groups() -> OptionalJsonResponse: + if request.method == "GET": + sorted_groups = sorted(app.admin.get_mix_groups(), key=itemgetter("name")) + groups = [] + for group in sorted_groups: + if not group["path"].startswith("/"): + continue + groups.append( + { + "id": group["path"], + "name": group["name"], + "description": group.get("description", ""), + } + ) + return json.dumps(groups), 200, {"Content-Type": "application/json"} + return None -@app.route("/api/internal/roles", methods=["GET"]) -@is_internal -def internal_roles(): - if request.method == "GET": - roles = [] - for role in sorted(app.admin.get_roles(), key=lambda k: k["name"]): - if role["name"] == "admin": - continue - roles.append( - { - "id": role["id"], - "name": role["name"], - "description": role.get("description", ""), - } - ) - return json.dumps(roles), 200, {"Content-Type": "application/json"} + @app.json_route("/api/internal/group/users", methods=["POST"]) + @is_internal + def internal_group_users() -> OptionalJsonResponse: + if request.method == "POST": + data = request.get_json(force=True) + sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username")) + # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']] + users = [] + for user in sorted_users: + if data["path"] not in user["keycloak_groups"] or not user.get("enabled"): + continue + users.append(user) + if data.get("text", False) and data["text"] != "": + result = [user_parser(user) for user in filter_users(users, data["text"])] + else: + result = [user_parser(user) for user in users] + return json.dumps(result), 200, {"Content-Type": "application/json"} + return None + @app.json_route("/api/internal/roles", methods=["GET"]) + @is_internal + def internal_roles() -> OptionalJsonResponse: + if request.method == "GET": + roles = [] + for role in sorted(app.admin.get_roles(), key=itemgetter("name")): + if role["name"] == "admin": + continue + roles.append( + { + "id": role["id"], + "name": role["name"], + "description": role.get("description", ""), + } + ) + return json.dumps(roles), 200, {"Content-Type": "application/json"} + return None -@app.route("/api/internal/role/users", methods=["POST"]) -@is_internal -def internal_role_users(): - if request.method == "POST": - data = request.get_json(force=True) - sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) - # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']] - users = [] - for user in sorted_users: - if data["role"] not in user["roles"] or not user.get("enabled"): - continue - users.append(user) - if data.get("text", False) and data["text"] != "": - result = [user_parser(user) for user in filter_users(users, data["text"])] - else: - result = [user_parser(user) for user in users] - return json.dumps(result), 200, {"Content-Type": "application/json"} + @app.json_route("/api/internal/role/users", methods=["POST"]) + @is_internal + def internal_role_users() -> OptionalJsonResponse: + if request.method == "POST": + data = request.get_json(force=True) + sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username")) + # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']] + users = [] + for user in sorted_users: + if data["role"] not in user["roles"] or not user.get("enabled"): + continue + users.append(user) + if data.get("text", False) and data["text"] != "": + result = [user_parser(user) for user in filter_users(users, data["text"])] + else: + result = [user_parser(user) for user in users] + return json.dumps(result), 200, {"Content-Type": "application/json"} + return None - -def user_parser(user): +def user_parser(user : Dict[str, Any]) -> Dict[str, Any]: return { "id": user["username"], "first": user["first"], @@ -145,7 +151,7 @@ def user_parser(user): } -def filter_users(users, text): +def filter_users(users : Iterable[Dict[str, Any]], text : str) -> List[Dict[str, Any]]: return [ user for user in users diff --git a/dd-sso/admin/src/admin/views/decorators.py b/dd-sso/admin/src/admin/views/decorators.py index 033f057..69ee220 100644 --- a/dd-sso/admin/src/admin/views/decorators.py +++ b/dd-sso/admin/src/admin/views/decorators.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -25,25 +26,28 @@ import socket from functools import wraps from flask import redirect, request, url_for +from werkzeug.wrappers import Response from flask_login import current_user, logout_user from jose import jwt from ..auth.tokens import get_header_jwt_payload +from typing import Any, Callable, Dict, Optional, Tuple +JsonResponse = Tuple[str, int, Dict[str, str]] +OptionalJsonResponse = Optional[JsonResponse] -def is_admin(fn): +def is_admin(fn : Callable[..., Response]) -> Callable[..., Response]: @wraps(fn) - def decorated_view(*args, **kwargs): + def decorated_view(*args : Any, **kwargs : Any) -> Response: if current_user.role == "admin": return fn(*args, **kwargs) return redirect(url_for("login")) return decorated_view - -def is_internal(fn): +def is_internal(fn : Callable[..., OptionalJsonResponse]) -> Callable[..., OptionalJsonResponse]: @wraps(fn) - def decorated_view(*args, **kwargs): + def decorated_view(*args : Any, **kwargs : Any) -> OptionalJsonResponse: remote_addr = ( request.headers["X-Forwarded-For"].split(",")[0] if "X-Forwarded-For" in request.headers @@ -67,18 +71,18 @@ def is_internal(fn): return decorated_view -def has_token(fn): +def has_token(fn : Callable[..., Any]) -> Callable[..., Any]: @wraps(fn) - def decorated(*args, **kwargs): + def decorated(*args : Any, **kwargs : Any) -> Any: payload = get_header_jwt_payload() return fn(*args, **kwargs) return decorated -def is_internal_or_has_token(fn): +def is_internal_or_has_token(fn : Callable[..., Any]) -> Callable[..., Any]: @wraps(fn) - def decorated_view(*args, **kwargs): + def decorated_view(*args : Any, **kwargs : Any) -> Any: remote_addr = ( request.headers["X-Forwarded-For"].split(",")[0] if "X-Forwarded-For" in request.headers @@ -94,9 +98,9 @@ def is_internal_or_has_token(fn): return decorated_view -def login_or_token(fn): +def login_or_token(fn : Callable[..., Any]) -> Callable[..., Any]: @wraps(fn) - def decorated_view(*args, **kwargs): + def decorated_view(*args : Any, **kwargs : Any) -> Any: if current_user.is_authenticated: return fn(*args, **kwargs) payload = get_header_jwt_payload() diff --git a/dd-sso/admin/src/start.py b/dd-sso/admin/src/start.py index fd18efd..94fb9a5 100644 --- a/dd-sso/admin/src/start.py +++ b/dd-sso/admin/src/start.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -39,14 +40,17 @@ from flask_socketio import ( send, ) -from admin import app +from admin import get_app +# Set up the app +app = get_app() +app.setup() app.socketio = SocketIO(app) @app.socketio.on("connect", namespace="/sio") @login_required -def socketio_connect(): +def socketio_connect() -> None: if current_user.id: join_room("admin") app.socketio.emit( @@ -57,12 +61,12 @@ def socketio_connect(): @app.socketio.on("disconnect", namespace="/sio") -def socketio_disconnect(): +def socketio_disconnect() -> None: leave_room("admin") @app.socketio.on("connect", namespace="/sio/events") -def socketio_connect(): +def socketio_connect() -> None: jwt = get_token_payload(request.args.get("jwt")) join_room("events") @@ -75,7 +79,7 @@ def socketio_connect(): @app.socketio.on("disconnect", namespace="/sio/events") -def socketio_events_disconnect(): +def socketio_events_disconnect() -> None: leave_room("events") diff --git a/dd-sso/docker-compose-parts/admin.yml b/dd-sso/docker-compose-parts/admin.yml index 8f65a3b..4691ef1 100644 --- a/dd-sso/docker-compose-parts/admin.yml +++ b/dd-sso/docker-compose-parts/admin.yml @@ -43,9 +43,11 @@ 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} + - SECRETS=/data/secret From 6b4fd5482e5a75dc2b1aad9dbcea843c4644954b Mon Sep 17 00:00:00 2001 From: Evilham Date: Fri, 29 Jul 2022 17:25:25 +0200 Subject: [PATCH 12/18] [sso-admin] Fix issues detected with mypy While there, refactor thread handling in AppViews since it was not practical. Some issues found with mypy and fixed by this commit: src/admin/views/ApiViews.py:240: error: Name "user_ddid" is not defined src/admin/lib/nextcloud.py:331: error: Name "group" is not defined src/admin/lib/nextcloud.py:394: error: Name "ProviderUserNotExists" is not defined src/admin/lib/admin.py:1604: error: Trying to read deleted variable "se" src/admin/lib/admin.py:1798: error: Trying to read deleted variable "se" src/admin/lib/admin.py:1903: error: Name "group" is not defined --- dd-sso/admin/src/admin/lib/admin.py | 24 +-- dd-sso/admin/src/admin/lib/events.py | 6 +- dd-sso/admin/src/admin/lib/moodle.py | 4 +- dd-sso/admin/src/admin/lib/nextcloud.py | 8 +- dd-sso/admin/src/admin/lib/nextcloud_exc.py | 5 + dd-sso/admin/src/admin/views/ApiViews.py | 6 +- dd-sso/admin/src/admin/views/AppViews.py | 191 +++++--------------- 7 files changed, 77 insertions(+), 167 deletions(-) diff --git a/dd-sso/admin/src/admin/lib/admin.py b/dd-sso/admin/src/admin/lib/admin.py index a426ccd..30a0eb0 100644 --- a/dd-sso/admin/src/admin/lib/admin.py +++ b/dd-sso/admin/src/admin/lib/admin.py @@ -1553,7 +1553,7 @@ class Admin: 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[0]] + cohort = [c for c in cohorts if c["name"] == group[0]][0] try: self.moodle.delete_user_in_cohort( internaluser["moodle_id"], cohort["id"] @@ -1599,12 +1599,12 @@ class Admin: try: self.moodle.create_user(email, username, password, first_name, last_name) ev.update_text(str({"name": "Added to moodle", "data": []})) - except UserExists: + except UserExists as ex: log.error(" -->> User already exists") - error = Events(self.app, "User already exists.", str(se), type="error") - except SystemError as se: - log.error("Moodle create user error: " + str(se)) - error = Events(self.app, "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()) @@ -1793,12 +1793,12 @@ 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(self.app, "User already exists.", str(se), type="error") - except SystemError as se: - log.error("Moodle create user error: " + str(se)) - error = Events(self.app, "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()) @@ -1900,7 +1900,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() diff --git a/dd-sso/admin/src/admin/lib/events.py b/dd-sso/admin/src/admin/lib/events.py index da7205d..c1d4605 100644 --- a/dd-sso/admin/src/admin/lib/events.py +++ b/dd-sso/admin/src/admin/lib/events.py @@ -61,9 +61,9 @@ class Events: title : str text : str total : int - table : bool + table : str type : str - def __init__(self, app : "AdminFlaskApp", title : str, text : str="", total : int=0, table : bool=False, type : str="info") -> None: + 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]) @@ -195,7 +195,7 @@ class Events: self.app.socketio.emit("reload", json.dumps({}), namespace="/sio", room="admin") sleep(0.0001) - def table(self, event : str, table : bool, data : Dict[str, Any]={}) -> None: + def table(self, event : str, table : str, data : Dict[str, Any]={}) -> None: # refresh, add, delete, update self.app.socketio.emit( "table_" + event, diff --git a/dd-sso/admin/src/admin/lib/moodle.py b/dd-sso/admin/src/admin/lib/moodle.py index 03d798f..972b563 100644 --- a/dd-sso/admin/src/admin/lib/moodle.py +++ b/dd-sso/admin/src/admin/lib/moodle.py @@ -197,9 +197,9 @@ class Moodle: ) return attempts - def get_cohorts(self) -> Any: + 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 : str, description : str ="", visible : bool=True) -> Any: bit_visible = 1 if visible else 0 diff --git a/dd-sso/admin/src/admin/lib/nextcloud.py b/dd-sso/admin/src/admin/lib/nextcloud.py index 80ed9bb..1a28d13 100644 --- a/dd-sso/admin/src/admin/lib/nextcloud.py +++ b/dd-sso/admin/src/admin/lib/nextcloud.py @@ -327,9 +327,11 @@ 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: diff --git a/dd-sso/admin/src/admin/lib/nextcloud_exc.py b/dd-sso/admin/src/admin/lib/nextcloud_exc.py index ec1f290..51ff233 100644 --- a/dd-sso/admin/src/admin/lib/nextcloud_exc.py +++ b/dd-sso/admin/src/admin/lib/nextcloud_exc.py @@ -1,5 +1,6 @@ # # Copyright © 2021,2022 IsardVDI S.L. +# Copyright © 2022 Evilham # # This file is part of DD # @@ -46,6 +47,10 @@ class ProviderItemNotExists(Exception): pass +class ProviderUserNotExists(Exception): + pass + + class ProviderGroupNotExists(Exception): pass diff --git a/dd-sso/admin/src/admin/views/ApiViews.py b/dd-sso/admin/src/admin/views/ApiViews.py index 046b740..13481de 100644 --- a/dd-sso/admin/src/admin/views/ApiViews.py +++ b/dd-sso/admin/src/admin/views/ApiViews.py @@ -188,8 +188,8 @@ def setup_api_views(app : "AdminFlaskApp") -> None: if not app.validators["user"].validate(data): raise Error( "bad_request", - "Data validation for user failed: ", - +str(app.validators["user"].errors), + "Data validation for user failed: " + + str(app.validators["user"].errors), traceback.format_exc(), ) @@ -237,7 +237,7 @@ def setup_api_views(app : "AdminFlaskApp") -> None: @app.json_route("/ddapi/username//", methods=["PUT"]) @has_token def ddapi_username(old_user_ddid : str, new_user_did : str) -> OptionalJsonResponse: - user = app.admin.get_user_username(user_ddid) + 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) diff --git a/dd-sso/admin/src/admin/views/AppViews.py b/dd-sso/admin/src/admin/views/AppViews.py index ed221e3..5a3dfbc 100644 --- a/dd-sso/admin/src/admin/views/AppViews.py +++ b/dd-sso/admin/src/admin/views/AppViews.py @@ -21,6 +21,7 @@ import concurrent.futures import json import logging as log +from operator import itemgetter import os import re import sys @@ -34,14 +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 typing import TYPE_CHECKING, cast, Any, Dict, Optional +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, 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 @@ -52,6 +54,39 @@ from ..lib.exceptions import UserExists, UserNotFound from ..lib.legal import get_legal, gen_legal_if_not_exists, new_legal +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( + {"msg": busy_err_msg} + ), + 412, + {"Content-Type": "application/json"}, + ) + else: + del threads["external"] + try: + threads["external"] = threading.Thread( + 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"} + except: + log.error(traceback.format_exc()) + return ( + 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") @@ -109,32 +144,7 @@ def setup_app_views(app : "AdminFlaskApp") -> None: 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'} + return run_in_thread(app.admin.update_users_from_keycloak, err_msg="Add user error.") users = app.admin.get_mix_users() if current_user.role != "admin": @@ -151,80 +161,12 @@ def setup_app_views(app : "AdminFlaskApp") -> None: 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"}, - ) + return run_in_thread(app.admin.enable_users, args=(data,), err_msg="Enable users error.") 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"}, - ) + return run_in_thread(app.admin.disable_users, args=(data,), err_msg="Disabling users error.") 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 run_in_thread(app.admin.delete_users, args=(data,), err_msg="Deleting users error.") + return json.dumps({}), 405, {"Content-Type": "application/json"} @@ -278,28 +220,7 @@ def setup_app_views(app : "AdminFlaskApp") -> None: 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"}, - ) + 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) @@ -331,7 +252,7 @@ def setup_app_views(app : "AdminFlaskApp") -> None: @app.json_route("/api/roles") @login_required def roles() -> OptionalJsonResponse: - sorted_roles = sorted(app.admin.get_roles(), key=lambda k: k["name"]) + 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"} @@ -396,37 +317,19 @@ def setup_app_views(app : "AdminFlaskApp") -> None: @app.json_route("/api/external", methods=["POST", "PUT", "GET", "DELETE"]) @login_required def external() -> OptionalJsonResponse: - 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"} + return run_in_thread(app.admin.upload_json_ga, args=(data,)) 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"} + 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) - threads["external"] = threading.Thread( - target=app.admin.sync_external, args=(data,) - ) - threads["external"].start() - return json.dumps({}), 200, {"Content-Type": "application/json"} + return run_in_thread(app.admin.sync_external, args=(data,)) if request.method == "DELETE": print("RESET") app.admin.reset_external() From f80664a38b1732e471594c5eef7bdcc1f3599061 Mon Sep 17 00:00:00 2001 From: Evilham Date: Fri, 29 Jul 2022 19:05:04 +0200 Subject: [PATCH 13/18] [sso-admin] Add license entry to package.json This was the intended license as stated in all source files. This commit silences a warning when building the corresponding docker images. --- dd-sso/admin/src/admin/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dd-sso/admin/src/admin/package.json b/dd-sso/admin/src/admin/package.json index f87ec9e..0461bf4 100644 --- a/dd-sso/admin/src/admin/package.json +++ b/dd-sso/admin/src/admin/package.json @@ -2,5 +2,6 @@ "dependencies": { "gentelella": "^1.4.0", "socket.io": "^4.1.3" - } + }, + "license": "AGPL-3.0-or-later" } From 64c0869e464c07a9975568b2cec7254d206e821e Mon Sep 17 00:00:00 2001 From: Evilham Date: Fri, 29 Jul 2022 19:17:09 +0200 Subject: [PATCH 14/18] [sso-admin] Update requirements file --- dd-sso/admin/docker/requirements.pip3 | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/dd-sso/admin/docker/requirements.pip3 b/dd-sso/admin/docker/requirements.pip3 index 697271c..a48894e 100644 --- a/dd-sso/admin/docker/requirements.pip3 +++ b/dd-sso/admin/docker/requirements.pip3 @@ -17,19 +17,19 @@ # along with DD. If not, see . # # SPDX-License-Identifier: AGPL-3.0-or-later -Flask==2.0.1 -Flask-Login==0.5.0 -eventlet==0.33.0 -Flask-SocketIO==5.1.0 -bcrypt==3.2.0 -diceware==0.9.6 -mysql-connector-python==8.0.25 -psycopg2==2.8.6 -python-keycloak==0.26.1 -minio==7.0.3 -urllib3==1.26.6 +Flask==2.1.3 +Flask-Login==0.6.2 +eventlet==0.33.1 +Flask-SocketIO==5.2.0 +bcrypt==3.2.2 +diceware==0.10 +mysql-connector-python==8.0.30 +psycopg2==2.9.3 +python-keycloak==2.1.1 +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 From 4421c5a5dfcbcf094574718209574261e5dd4c15 Mon Sep 17 00:00:00 2001 From: Evilham Date: Fri, 29 Jul 2022 19:34:59 +0200 Subject: [PATCH 15/18] [sso-admin] Fix import and config issues FileStorage is in werkzeug.datastructures, this didn't get caught by mypy due to lack of type hints. AdminFlaskApp now loads the configuration earlier, otherwise the connection to other systems gets started with the wrong values. While there, use .update since values from the environment are exactly what we want to be using and the way it was written, they are expected to be set up. We also had swapped creation of the admin.lib.admin.Admin object and processing admin.lib.postup.Postup; which loads some secrets needed for moodle. --- dd-sso/admin/src/admin/flaskapp.py | 43 +++++++++---------------- dd-sso/admin/src/admin/lib/dashboard.py | 2 +- 2 files changed, 17 insertions(+), 28 deletions(-) diff --git a/dd-sso/admin/src/admin/flaskapp.py b/dd-sso/admin/src/admin/flaskapp.py index 18b3238..cfee4c5 100644 --- a/dd-sso/admin/src/admin/flaskapp.py +++ b/dd-sso/admin/src/admin/flaskapp.py @@ -74,11 +74,9 @@ class AdminFlaskApp(Flask): def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.url_map.strict_slashes = False - from admin.lib.admin import Admin - self.admin = Admin(self) + self._load_config() # Minor setup tasks self._load_validators() - self._load_config() self._setup_routes() setup_api_views(self) setup_app_views(self) @@ -101,6 +99,9 @@ class AdminFlaskApp(Flask): """ 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) def json_route(self, rule: str, **options: Any) -> Callable[..., OptionalJsonResponse]: return self.route(rule, **options) # type: ignore # mypy issue #7187 @@ -136,30 +137,18 @@ class AdminFlaskApp(Flask): f.write(secrets.token_hex()) self.secret_key = open(secret_key_file, "r").read() - # Move on with ISARD's settings - self.config.setdefault("DOMAIN", os.environ["DOMAIN"]) - self.config.setdefault( - "KEYCLOAK_POSTGRES_USER", os.environ["KEYCLOAK_DB_USER"] - ) - self.config.setdefault( - "KEYCLOAK_POSTGRES_PASSWORD", os.environ["KEYCLOAK_DB_PASSWORD"] - ) - self.config.setdefault( - "MOODLE_POSTGRES_USER", os.environ["MOODLE_POSTGRES_USER"] - ) - self.config.setdefault( - "MOODLE_POSTGRES_PASSWORD", os.environ["MOODLE_POSTGRES_PASSWORD"] - ) - self.config.setdefault( - "NEXTCLOUD_POSTGRES_USER", os.environ["NEXTCLOUD_POSTGRES_USER"] - ) - self.config.setdefault( - "NEXTCLOUD_POSTGRES_PASSWORD", os.environ["NEXTCLOUD_POSTGRES_PASSWORD"] - ) - self.config.setdefault( - "VERIFY", True if os.environ["VERIFY"] == "true" else False - ) - self.config.setdefault("API_SECRET", os.environ.get("API_SECRET")) + # Move on with settings from the environment + self.config.update({ + "DOMAIN": os.environ["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 diff --git a/dd-sso/admin/src/admin/lib/dashboard.py b/dd-sso/admin/src/admin/lib/dashboard.py index c23e879..20cccf3 100644 --- a/dd-sso/admin/src/admin/lib/dashboard.py +++ b/dd-sso/admin/src/admin/lib/dashboard.py @@ -34,7 +34,7 @@ from typing import TYPE_CHECKING, Any, Dict if TYPE_CHECKING: from admin.flaskapp import AdminFlaskApp -from werkzeug import FileStorage +from werkzeug.datastructures import FileStorage class Dashboard: app : "AdminFlaskApp" From 38cc2a05647f21b0ce8b050d4349cbb20074ab73 Mon Sep 17 00:00:00 2001 From: Evilham Date: Sat, 30 Jul 2022 09:38:14 +0200 Subject: [PATCH 16/18] [sso-admin] Improve data and custom dir handling While there also improve the default permissions for the secrets directory. --- dd-sso/admin/src/admin/flaskapp.py | 16 +++++++++++----- dd-sso/admin/src/admin/lib/dashboard.py | 9 ++++++--- dd-sso/docker-compose-parts/admin.yml | 3 ++- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/dd-sso/admin/src/admin/flaskapp.py b/dd-sso/admin/src/admin/flaskapp.py index cfee4c5..619318e 100644 --- a/dd-sso/admin/src/admin/flaskapp.py +++ b/dd-sso/admin/src/admin/flaskapp.py @@ -68,7 +68,8 @@ class AdminFlaskApp(Flask): """ admin: "Admin" - secrets_dir: str + data_dir: str + custom_dir: str ready: bool = False def __init__(self, *args: Any, **kwargs: Any): @@ -91,7 +92,11 @@ class AdminFlaskApp(Flask): @property def avatars_path(self) -> str: - return os.path.join(self.root_path, "../custom/avatars/") + 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: """ @@ -123,11 +128,12 @@ class AdminFlaskApp(Flask): 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 - self.secrets_dir = os.environ.get("SECRETS", "secret") secret_key_file = os.path.join(self.secrets_dir, "secret_key") if not os.path.exists(self.secrets_dir): - os.mkdir(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 @@ -196,7 +202,7 @@ class AdminFlaskApp(Flask): @self.route("/custom/") def send_custom(path: str) -> Response: - return send_from_directory(os.path.join(self.root_path, "../custom"), path) + return send_from_directory(self.custom_dir, path) # @self.errorhandler(404) # def not_found_error(error): diff --git a/dd-sso/admin/src/admin/lib/dashboard.py b/dd-sso/admin/src/admin/lib/dashboard.py index 20cccf3..b164208 100644 --- a/dd-sso/admin/src/admin/lib/dashboard.py +++ b/dd-sso/admin/src/admin/lib/dashboard.py @@ -43,7 +43,10 @@ class Dashboard: app : "AdminFlaskApp", ) -> None: self.app = app - self.custom_menu = os.path.join(app.root_path, "../custom/menu/custom.yaml") + + @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: @@ -82,12 +85,12 @@ class Dashboard: def update_logo(self, logo : FileStorage) -> bool: img = Image.open(logo.stream) - img.save(os.path.join(self.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 : FileStorage) -> bool: img = Image.open(background.stream) - img.save(os.path.join(self.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) -> bool: diff --git a/dd-sso/docker-compose-parts/admin.yml b/dd-sso/docker-compose-parts/admin.yml index 4691ef1..91c87b1 100644 --- a/dd-sso/docker-compose-parts/admin.yml +++ b/dd-sso/docker-compose-parts/admin.yml @@ -50,4 +50,5 @@ services: - VERIFY="false" # In development do not verify certificates - DOMAIN=${DOMAIN} - MANAGED_EMAIL_DOMAIN=${MANAGED_EMAIL_DOMAIN} - - SECRETS=/data/secret + - DATA_FOLDER=/data + - CUSTOM_FOLDER=/admin/custom From 74b209b55bfc06d6b6394b4c60e3ba93b0398759 Mon Sep 17 00:00:00 2001 From: Evilham Date: Sat, 30 Jul 2022 23:05:51 +0200 Subject: [PATCH 17/18] [dd-ctl] [nc] Add patches while they land upstream See: https://github.com/nextcloud/mail/pull/6908 --- dd-apps/docker/nextcloud/dd-patch | 6 + .../docker/nextcloud/nc_mail/appinfo/info.xml | 75 +++++++ .../nc_mail/lib/Command/UpdateAccount.php | 154 ++++++++++++++ .../nc_mail/lib/Db/MailAccountMapper.php | 188 ++++++++++++++++++ dd-ctl | 6 + 5 files changed, 429 insertions(+) create mode 100644 dd-apps/docker/nextcloud/nc_mail/appinfo/info.xml create mode 100644 dd-apps/docker/nextcloud/nc_mail/lib/Command/UpdateAccount.php create mode 100644 dd-apps/docker/nextcloud/nc_mail/lib/Db/MailAccountMapper.php diff --git a/dd-apps/docker/nextcloud/dd-patch b/dd-apps/docker/nextcloud/dd-patch index 9b8b74c..0084ea6 100644 --- a/dd-apps/docker/nextcloud/dd-patch +++ b/dd-apps/docker/nextcloud/dd-patch @@ -1,3 +1,9 @@ # Generate .orig and .patch files with ./dd-ctl genpatches # file license author source nginx.conf AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/docker/522559eefdd56d2e49259c3b0f4a0e92882cdb87/.examples/docker-compose/with-nginx-proxy/postgres/fpm/web/nginx.conf +#nc_mail/appinfo.xml AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/MaadixNet/mail/feature/occ-account-update-command/appinfo/info.xml +#nc_mail/lib/Command/UpdateAccount.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/MaadixNet/mail/feature/occ-account-update-command/lib/Command/UpdateAccount.php +#nc_mail/lib/Db/MailAccountMapper.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/MaadixNet/mail/feature/occ-account-update-command/lib/Db/MailAccountMapper.php +nc_mail/appinfo/info.xml AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/mail/v1.12.8/appinfo/info.xml +nc_mail/lib/Command/UpdateAccount.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/mail/v1.12.8/lib/Command/UpdateAccount.php +nc_mail/lib/Db/MailAccountMapper.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/mail/v1.12.8/lib/Db/MailAccountMapper.php diff --git a/dd-apps/docker/nextcloud/nc_mail/appinfo/info.xml b/dd-apps/docker/nextcloud/nc_mail/appinfo/info.xml new file mode 100644 index 0000000..a7e8311 --- /dev/null +++ b/dd-apps/docker/nextcloud/nc_mail/appinfo/info.xml @@ -0,0 +1,75 @@ + + + mail + Mail + 💌 A mail app for Nextcloud + + 1.12.8 + agpl + Greta Doçi + Nextcloud Groupware Team + Mail + + https://github.com/nextcloud/mail/blob/main/doc/user.md + https://github.com/nextcloud/mail/blob/main/doc/admin.md + https://github.com/nextcloud/mail/blob/main/doc/developer.md + + social + office + https://github.com/nextcloud/mail#readme + https://github.com/nextcloud/mail/issues + https://github.com/nextcloud/mail.git + https://user-images.githubusercontent.com/1374172/79554966-278e1600-809f-11ea-82ea-7a0d72a2704f.png + + + + + + OCA\Mail\BackgroundJob\CleanupJob + OCA\Mail\BackgroundJob\OutboxWorkerJob + + + + OCA\Mail\Migration\AddMissingDefaultTags + OCA\Mail\Migration\AddMissingMessageIds + OCA\Mail\Migration\FixCollectedAddresses + OCA\Mail\Migration\FixBackgroundJobs + OCA\Mail\Migration\MakeItineraryExtractorExecutable + OCA\Mail\Migration\ProvisionAccounts + OCA\Mail\Migration\RepairMailTheads + + + + OCA\Mail\Command\AddMissingTags + OCA\Mail\Command\CleanUp + OCA\Mail\Command\CreateAccount + OCA\Mail\Command\CreateTagMigrationJobEntry + OCA\Mail\Command\DeleteAccount + OCA\Mail\Command\DiagnoseAccount + OCA\Mail\Command\ExportAccount + OCA\Mail\Command\ExportAccountThreads + OCA\Mail\Command\SyncAccount + OCA\Mail\Command\TrainAccount + OCA\Mail\Command\Thread + OCA\Mail\Command\UpdateAccount + + + OCA\Mail\Settings\AdminSettings + + + + Mail + mail.page.index + mail.svg + 3 + + + diff --git a/dd-apps/docker/nextcloud/nc_mail/lib/Command/UpdateAccount.php b/dd-apps/docker/nextcloud/nc_mail/lib/Command/UpdateAccount.php new file mode 100644 index 0000000..e6f6324 --- /dev/null +++ b/dd-apps/docker/nextcloud/nc_mail/lib/Command/UpdateAccount.php @@ -0,0 +1,154 @@ + + * @author Maadix + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\Command; + +use OCA\Mail\Db\MailAccountMapper; +use OCP\Security\ICrypto; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class UpdateAccount extends Command { + public const ARGUMENT_USER_ID = 'user-id'; + public const ARGUMENT_EMAIL = 'email'; + public const ARGUMENT_IMAP_HOST = 'imap-host'; + public const ARGUMENT_IMAP_PORT = 'imap-port'; + public const ARGUMENT_IMAP_SSL_MODE = 'imap-ssl-mode'; + public const ARGUMENT_IMAP_USER = 'imap-user'; + public const ARGUMENT_IMAP_PASSWORD = 'imap-password'; + public const ARGUMENT_SMTP_HOST = 'smtp-host'; + public const ARGUMENT_SMTP_PORT = 'smtp-port'; + public const ARGUMENT_SMTP_SSL_MODE = 'smtp-ssl-mode'; + public const ARGUMENT_SMTP_USER = 'smtp-user'; + public const ARGUMENT_SMTP_PASSWORD = 'smtp-password'; + + /** @var mapper */ + private $mapper; + + /** @var ICrypto */ + private $crypto; + + public function __construct(MailAccountMapper $mapper, ICrypto $crypto) { + parent::__construct(); + + $this->mapper = $mapper; + $this->crypto = $crypto; + } + + /** + * @return void + */ + protected function configure() { + $this->setName('mail:account:update'); + $this->setDescription('Update a user\'s IMAP account'); + $this->addArgument(self::ARGUMENT_USER_ID, InputArgument::REQUIRED); + $this->addArgument(self::ARGUMENT_EMAIL, InputArgument::REQUIRED); + + $this->addOption(self::ARGUMENT_IMAP_HOST, '', InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_IMAP_PORT, '', InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_IMAP_SSL_MODE, '', InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_IMAP_USER, '', InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_IMAP_PASSWORD, '', InputOption::VALUE_OPTIONAL); + + $this->addOption(self::ARGUMENT_SMTP_HOST, '', InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_SMTP_PORT, '', InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_SMTP_SSL_MODE, '', InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_SMTP_USER, '', InputOption::VALUE_OPTIONAL); + $this->addOption(self::ARGUMENT_SMTP_PASSWORD, '', InputOption::VALUE_OPTIONAL); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $userId = $input->getArgument(self::ARGUMENT_USER_ID); + $email = $input->getArgument(self::ARGUMENT_EMAIL); + + $imapHost = $input->getOption(self::ARGUMENT_IMAP_HOST); + $imapPort = $input->getOption(self::ARGUMENT_IMAP_PORT); + $imapSslMode = $input->getOption(self::ARGUMENT_IMAP_SSL_MODE); + $imapUser = $input->getOption(self::ARGUMENT_IMAP_USER); + $imapPassword = $input->getOption(self::ARGUMENT_IMAP_PASSWORD); + + $smtpHost = $input->getOption(self::ARGUMENT_SMTP_HOST); + $smtpPort = $input->getOption(self::ARGUMENT_SMTP_PORT); + $smtpSslMode = $input->getOption(self::ARGUMENT_SMTP_SSL_MODE); + $smtpUser = $input->getOption(self::ARGUMENT_SMTP_USER); + $smtpPassword = $input->getOption(self::ARGUMENT_SMTP_PASSWORD); + + $mailAccount = $this->mapper->findByUserIdAndEmail($userId, $email); + + if ($mailAccount) { + //INBOUND + if ($input->getOption(self::ARGUMENT_IMAP_HOST)) { + $mailAccount->setInboundHost($imapHost); + } + + if ($input->getOption(self::ARGUMENT_IMAP_PORT)) { + $mailAccount->setInboundPort((int) $imapPort); + } + + if ($input->getOption(self::ARGUMENT_IMAP_SSL_MODE)) { + $mailAccount->setInboundSslMode($imapSslMode); + } + + if ($input->getOption(self::ARGUMENT_IMAP_PASSWORD)) { + $mailAccount->setInboundPassword($this->crypto->encrypt($imapPassword)); + } + + if ($input->getOption(self::ARGUMENT_SMTP_USER)) { + $mailAccount->setInboundUser($imapUser); + } + + // OUTBOUND + + if ($input->getOption(self::ARGUMENT_SMTP_HOST)) { + $mailAccount->setOutboundHost($smtpHost); + } + + if ($input->getOption(self::ARGUMENT_SMTP_PORT)) { + $mailAccount->setOutboundPort((int) $smtpPort); + } + + if ($input->getOption(self::ARGUMENT_SMTP_SSL_MODE)) { + $mailAccount->setOutboundSslMode($smtpSslMode); + } + + if ($input->getOption(self::ARGUMENT_SMTP_PASSWORD)) { + $mailAccount->setOutboundPassword($this->crypto->encrypt($smtpPassword)); + } + + if ($input->getOption(self::ARGUMENT_SMTP_USER)) { + $mailAccount->setOutboundUser($smtpUser); + } + + $this->mapper->save($mailAccount); + + $output->writeln("Account $email for user $userId succesfully updated "); + return 1; + } else { + $output->writeln("No Email Account $email found for user $userId "); + } + + return 0; + } +} diff --git a/dd-apps/docker/nextcloud/nc_mail/lib/Db/MailAccountMapper.php b/dd-apps/docker/nextcloud/nc_mail/lib/Db/MailAccountMapper.php new file mode 100644 index 0000000..6c5113b --- /dev/null +++ b/dd-apps/docker/nextcloud/nc_mail/lib/Db/MailAccountMapper.php @@ -0,0 +1,188 @@ + + * @author Christoph Wurst + * @author Lukas Reschke + * @author Thomas Müller + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\IUser; + +/** + * @template-extends QBMapper + */ +class MailAccountMapper extends QBMapper { + + /** + * @param IDBConnection $db + */ + public function __construct(IDBConnection $db) { + parent::__construct($db, 'mail_accounts'); + } + + /** Finds an Mail Account by id + * + * @param string $userId + * @param int $accountId + * + * @return MailAccount + * + * @throws DoesNotExistException + */ + public function find(string $userId, int $accountId): MailAccount { + $qb = $this->db->getQueryBuilder(); + $query = $qb + ->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere($qb->expr()->eq('id', $qb->createNamedParameter($accountId))); + + return $this->findEntity($query); + } + + /** + * Finds an mail account by id + * + * @return MailAccount + * @throws DoesNotExistException + */ + public function findById(int $id): MailAccount { + $qb = $this->db->getQueryBuilder(); + $query = $qb + ->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + + return $this->findEntity($query); + } + + /** + * Finds all Mail Accounts by user id existing for this user + * + * @param string $userId the id of the user that we want to find + * + * @return MailAccount[] + */ + public function findByUserId(string $userId): array { + $qb = $this->db->getQueryBuilder(); + $query = $qb + ->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))); + + return $this->findEntities($query); + } + /** + * Finds an mail account by user id and email address + * + * @return MailAccount + * @throws DoesNotExistException + */ + public function findByUserIdAndEmail(string $userId, string $email): MailAccount { + $qb = $this->db->getQueryBuilder(); + $query = $qb + ->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR)) + ->andWhere($qb->expr()->eq('email', $qb->createNamedParameter($email, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR)); + + return $this->findEntity($query); + } + + /** + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function findProvisionedAccount(IUser $user): MailAccount { + $qb = $this->db->getQueryBuilder(); + + $query = $qb + ->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('user_id', $qb->createNamedParameter($user->getUID())), + $qb->expr()->isNotNull('provisioning_id') + ); + + return $this->findEntity($query); + } + + /** + * Saves an User Account into the database + * + * @param MailAccount $account + * + * @return MailAccount + */ + public function save(MailAccount $account): MailAccount { + if ($account->getId() === null) { + return $this->insert($account); + } + + return $this->update($account); + } + + public function deleteProvisionedAccounts(int $provisioningId): void { + $qb = $this->db->getQueryBuilder(); + + $delete = $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('provisioning_id', $qb->createNamedParameter($provisioningId, IQueryBuilder::PARAM_INT))); + + $delete->execute(); + } + + public function deleteProvisionedAccountsByUid(string $uid): void { + $qb = $this->db->getQueryBuilder(); + + $delete = $qb->delete($this->getTableName()) + ->where( + $qb->expr()->eq('user_id', $qb->createNamedParameter($uid)), + $qb->expr()->isNotNull('provisioning_id') + ); + + $delete->execute(); + } + + public function getAllAccounts(): array { + $qb = $this->db->getQueryBuilder(); + $query = $qb + ->select('*') + ->from($this->getTableName()); + + return $this->findEntities($query); + } + + public function getAllUserIdsWithAccounts(): array { + $qb = $this->db->getQueryBuilder(); + $query = $qb + ->selectDistinct('user_id') + ->from($this->getTableName()); + + return $this->findEntities($query); + } +} diff --git a/dd-ctl b/dd-ctl index 1412e2b..87da8e0 100755 --- a/dd-ctl +++ b/dd-ctl @@ -306,6 +306,12 @@ setup_nextcloud(){ EOF done + # Temporary patch while upstream lands our changes + # See: https://github.com/nextcloud/mail/pull/6908 + for f in appinfo/info.xml lib/Command/UpdateAccount.php lib/Db/MailAccountMapper.php; do + install -m 0644 -o 82 -g 82 "dd-apps/docker/nextcloud/nc_mail/$f" "${SRC_FOLDER}/nextcloud/custom_apps/mail/$f" + done + # Custom forms docker exec dd-apps-nextcloud-app apk add git npm composer docker exec -u www-data dd-apps-nextcloud-app rm -rf /var/www/html/custom_apps/forms From c19ff6cd8d1a1a3d2fdb9ba71db2e1a4efd7df33 Mon Sep 17 00:00:00 2001 From: Evilham Date: Sun, 31 Jul 2022 11:12:18 +0200 Subject: [PATCH 18/18] [sso-admin] Add third-party integrations The endpoints for the mail integration are added here. The ThirdPartyIntegrationKeys class in admin.lib.keys is intended to be used on both the sending and receiving part of communications. Implementations in other languages should closely follow its design, so we are sure communication happens as it is expected. Broadly speaking: - Each party receives a name (DD is always "DD") that is well-known to all communicating parties - Each party sets up an endpoint sharing their public key in JWK format See: https://datatracker.ietf.org/doc/html/rfc7517 And the many JWK implementations around. This class uses python-jose's - In a key_store folder, the remote party's public key will be cached and the local private key will be generated and saved - Any data exchanged between the two parties must: - Be first encrypted with the remote party's public key See: https://datatracker.ietf.org/doc/html/rfc7516 - Then signed with the local party's private key, by adding its payload to a 'data' claim. See: https://datatracker.ietf.org/doc/html/rfc7515 - Have an Authorization header with a signed JWT containing the local party's name as the 'kid' header. This aids the remote party in deciding which key needs to be used. --- dd-sso/admin/Pipfile | 2 + dd-sso/admin/Pipfile.lock | 126 ++++++- dd-sso/admin/docker/requirements.pip3 | 8 +- dd-sso/admin/src/admin/auth/jws_tokens.py | 72 ++++ dd-sso/admin/src/admin/flaskapp.py | 41 ++- dd-sso/admin/src/admin/lib/admin.py | 43 ++- dd-sso/admin/src/admin/lib/callbacks.py | 115 ++++++ dd-sso/admin/src/admin/lib/keys.py | 411 ++++++++++++++++++++++ dd-sso/admin/src/admin/views/ApiViews.py | 3 +- dd-sso/admin/src/admin/views/AppViews.py | 1 + dd-sso/admin/src/admin/views/MailViews.py | 101 ++++++ 11 files changed, 907 insertions(+), 16 deletions(-) create mode 100644 dd-sso/admin/src/admin/auth/jws_tokens.py create mode 100644 dd-sso/admin/src/admin/lib/callbacks.py create mode 100644 dd-sso/admin/src/admin/lib/keys.py create mode 100644 dd-sso/admin/src/admin/views/MailViews.py diff --git a/dd-sso/admin/Pipfile b/dd-sso/admin/Pipfile index d2298b5..9dba800 100644 --- a/dd-sso/admin/Pipfile +++ b/dd-sso/admin/Pipfile @@ -19,6 +19,8 @@ eventlet = "*" pyyaml = "*" requests = "*" python-keycloak = "*" +attrs = "*" +cryptography = "*" [dev-packages] mypy = "*" diff --git a/dd-sso/admin/Pipfile.lock b/dd-sso/admin/Pipfile.lock index 3116d71..5508294 100644 --- a/dd-sso/admin/Pipfile.lock +++ b/dd-sso/admin/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e5f3be6c5adeb1d2f9b30ff0f72d15c61724b87fe49de8feec0d93cbb2fb96be" + "sha256": "8a5f88b027753cb1145b10e191326d6e9cfaa1c3333a773ac91071c3e7b7008c" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,14 @@ ] }, "default": { + "attrs": { + "hashes": [ + "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6", + "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c" + ], + "index": "pypi", + "version": "==22.1.0" + }, "bidict": { "hashes": [ "sha256:415126d23a0c81e1a8c584a8fb1f6905ea090c772571803aeee0a2242e8e7ba0", @@ -39,6 +47,75 @@ "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", @@ -63,6 +140,34 @@ "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", @@ -76,7 +181,7 @@ "sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e", "sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f" ], - "markers": "python_version >= '3.6' and python_version < '4'", + "markers": "python_version >= '3.6' and python_version < '4.0'", "version": "==2.2.1" }, "ecdsa": { @@ -422,6 +527,13 @@ ], "version": "==0.4.8" }, + "pycparser": { + "hashes": [ + "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", + "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" + ], + "version": "==2.21" + }, "python-engineio": { "hashes": [ "sha256:18474c452894c60590b2d2339d6c81b93fb9857f1be271a2e91fb2707eb4095d", @@ -513,7 +625,7 @@ "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21" ], - "markers": "python_version >= '3.6' and python_version < '4'", + "markers": "python_version >= '3.6' and python_version < '4.0'", "version": "==4.9" }, "schema": { @@ -545,7 +657,7 @@ "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'", + "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": { @@ -701,11 +813,11 @@ }, "types-pillow": { "hashes": [ - "sha256:6823851e179dcc157424175b5dc0e1204b1c949e1de32417ff2fbfa7e3d3f45b", - "sha256:f367d22b54239b09607fcd8d4514b86bac6bf7d6ed1d5bdfa41782ea62083b2a" + "sha256:9781104ee2176f680576523fa2a2b83b134957aec6f4d62582cc9e74c93a60b4", + "sha256:d63743ef631e47f8d8669590ea976162321a9a7604588b424b6306533453fb63" ], "index": "pypi", - "version": "==9.2.0" + "version": "==9.2.1" }, "types-psycopg2": { "hashes": [ diff --git a/dd-sso/admin/docker/requirements.pip3 b/dd-sso/admin/docker/requirements.pip3 index a48894e..91b9549 100644 --- a/dd-sso/admin/docker/requirements.pip3 +++ b/dd-sso/admin/docker/requirements.pip3 @@ -17,15 +17,19 @@ # along with DD. If not, see . # # SPDX-License-Identifier: AGPL-3.0-or-later +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==0.10 +# diceware can't be upgraded without issues +diceware==0.9.6 mysql-connector-python==8.0.30 psycopg2==2.9.3 -python-keycloak==2.1.1 +# python-keycloak can't be upgraded without issues +python-keycloak==0.26.1 minio==7.1.11 urllib3==1.26.11 schema==0.7.5 diff --git a/dd-sso/admin/src/admin/auth/jws_tokens.py b/dd-sso/admin/src/admin/auth/jws_tokens.py new file mode 100644 index 0000000..9ac431a --- /dev/null +++ b/dd-sso/admin/src/admin/auth/jws_tokens.py @@ -0,0 +1,72 @@ +# +# Copyright © 2022 MaadiX +# Copyright © 2022 Evilham +# +# This file is part of DD +# +# DD is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# DD is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with DD. If not, see . +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import traceback +from functools import wraps +from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple + +from flask import request +from jose import jwt +from werkzeug.wrappers import Response + +from admin.lib.api_exceptions import Error + +if TYPE_CHECKING: + from admin.flaskapp import AdminFlaskApp + + +def has_jws_token( + app: "AdminFlaskApp", *args: Any, **kwargs: Any +) -> Callable[..., Response]: + @wraps + def decorated(fn: Callable[..., Response]) -> Response: + get_jws_payload(app) + return fn(*args, **kwargs) + + return decorated + + +def get_jws_payload(app: "AdminFlaskApp") -> Tuple[str, Dict]: + """ + Try to parse the Authorization header into a JWT. + By getting the key ID from the unverified headers, try to verify the token + with the associated key. + + For pragmatism it returns a tuple of the (key_id, jwt_claims). + """ + kid: str = "" + try: + t: str = request.headers.get("Authorization", "") + # Get which KeyId we have to use. + # We default to 'empty', so a missing 'kid' is not an issue in an on + # itself. + # This enables using an empty key in app.api_3p as the default. + # That default is better managed in AdminFlaskApp and not here. + kid = jwt.get_unverified_headers(t).get("kid", "") + except: + raise Error( + "unauthorized", "Token is missing or malformed", traceback.format_stack() + ) + try: + # Try to get payload + return (kid, app.api_3p[kid].verify_incoming_data(t)) + except: + raise Error("forbidden", "Data verification failed", traceback.format_stack()) diff --git a/dd-sso/admin/src/admin/flaskapp.py b/dd-sso/admin/src/admin/flaskapp.py index 619318e..19a7b7b 100644 --- a/dd-sso/admin/src/admin/flaskapp.py +++ b/dd-sso/admin/src/admin/flaskapp.py @@ -24,13 +24,14 @@ import os import os.path import secrets import traceback -from typing import TYPE_CHECKING, Any, Callable, Dict +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 @@ -68,17 +69,22 @@ class AdminFlaskApp(Flask): """ admin: "Admin" - data_dir: str + 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) @@ -107,6 +113,11 @@ class AdminFlaskApp(Flask): # 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 @@ -145,7 +156,7 @@ class AdminFlaskApp(Flask): # Move on with settings from the environment self.config.update({ - "DOMAIN": os.environ["DOMAIN"], + "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"], @@ -159,6 +170,30 @@ class AdminFlaskApp(Flask): 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 diff --git a/dd-sso/admin/src/admin/lib/admin.py b/dd-sso/admin/src/admin/lib/admin.py index 30a0eb0..0d8be94 100644 --- a/dd-sso/admin/src/admin/lib/admin.py +++ b/dd-sso/admin/src/admin/lib/admin.py @@ -63,6 +63,7 @@ from .helpers import ( 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"] @@ -77,6 +78,8 @@ class Admin: app : "AdminFlaskApp" internal : Dict[str, Any] external : Dict[str, Any] + third_party_cbs : List["ThirdPartyCallbacks"] + def __init__(self, app : "AdminFlaskApp") -> None: self.app = app @@ -87,6 +90,7 @@ class Admin: self.default_setup() self.internal = {} + self.third_party_cbs = [] ready = False while not ready: @@ -108,6 +112,32 @@ class Admin: log.warning(" SYSTEM READY TO HANDLE CONNECTIONS") + 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: @@ -770,6 +800,7 @@ class Admin: return True 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() @@ -1514,6 +1545,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 @@ -1703,6 +1737,10 @@ class Admin: 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") @@ -1857,6 +1895,8 @@ class Admin: except: log.error(traceback.format_exc()) + self.third_party_add_user(uid, u) + self.resync_data() sio_event_send(self.app, "new_user", u) return uid @@ -1935,6 +1975,3 @@ class Admin: self.moodle.delete_cohorts(cohort) self.nextcloud.delete_group(gid) self.resync_data() - - def set_nextcloud_user_mail(self, data : Any) -> None: - self.nextcloud.set_user_mail(data) diff --git a/dd-sso/admin/src/admin/lib/callbacks.py b/dd-sso/admin/src/admin/lib/callbacks.py new file mode 100644 index 0000000..8f66002 --- /dev/null +++ b/dd-sso/admin/src/admin/lib/callbacks.py @@ -0,0 +1,115 @@ +# +# Copyright © 2022 Evilham +# +# This file is part of DD +# +# DD is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# DD is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with DD. If not, see . +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import copy +from typing import Any, Dict, Tuple + +import requests +from attr import define + +from admin.lib.keys import ThirdPartyIntegrationKeys + +DDUser = Dict[str, Any] + + +def user_parser(dduser: DDUser) -> DDUser: + user = copy.deepcopy(dduser) + user["keycloak_id"] = user.pop("id") + user["role"] = user["roles"][0] if user.get("roles", []) else None + user["groups"] = user.get("groups", user.get("keycloak_groups", [])) + return user + + +@define +class ThirdPartyCallbacks: + """ + If necessary this class may be inherited from and customised. + By default it uses the configured endpoints to send data to the third-party. + This data is sent using ThirdPartyIntegrationKeys, which takes care of + encrypting first, then signing, with the service-specific keys. + """ + + tpkeys: ThirdPartyIntegrationKeys + """ + The ThirdPartyIntegrationKeys instance where domain and keys are setup. + """ + + endpoint_add_users: Tuple[ + str, + str, + ] = ("POST", "/api/users/") + """ + An HTTP_METHOD, ENDPOINT tuple, where ENDPOINT is an absolute path. + """ + + endpoint_update_users: Tuple[ + str, + str, + ] = ("PUT", "/api/users/") + """ + An HTTP_METHOD, ENDPOINT tuple, where ENDPOINT is an absolute path. + """ + + endpoint_delete_users: Tuple[ + str, + str, + ] = ("DELETE", "/api/users/") + """ + An HTTP_METHOD, ENDPOINT tuple, where ENDPOINT is an absolute path. + """ + + @property + def add_users_url(self) -> str: + return f"{self.tpkeys.their_service_domain}{self.endpoint_add_users[1]}" + + @property + def update_users_url(self) -> str: + return f"{self.tpkeys.their_service_domain}{self.endpoint_update_users[1]}" + + @property + def delete_users_url(self) -> str: + return f"{self.tpkeys.their_service_domain}{self.endpoint_delete_users[1]}" + + def _request(self, method: str, url: str, data: DDUser) -> bool: + # The endpoints are prepared for batch operations, but the way + # the admin lib is set up, it is currently not doable. + prepared_data = [user_parser(data)] + try: + enc_data = self.tpkeys.sign_and_encrypt_outgoing_json(prepared_data) + headers = self.tpkeys.get_outgoing_request_headers() + res = requests.request(method, url, data=enc_data, headers=headers) + except: + # Something went wrong sending the request + return False + return res.status_code == 200 + + def add_user(self, user_id: str, user: DDUser) -> bool: + data = copy.deepcopy(user) + data["id"] = user_id + return self._request(self.endpoint_add_users[0], self.add_users_url, data) + + def update_user(self, user_id: str, user: DDUser) -> bool: + data = copy.deepcopy(user) + data["id"] = user_id + return self._request(self.endpoint_update_users[0], self.update_users_url, data) + + def delete_user(self, user_id: str) -> bool: + data = {"id": user_id} + return self._request(self.endpoint_delete_users[0], self.delete_users_url, data) diff --git a/dd-sso/admin/src/admin/lib/keys.py b/dd-sso/admin/src/admin/lib/keys.py new file mode 100644 index 0000000..076387b --- /dev/null +++ b/dd-sso/admin/src/admin/lib/keys.py @@ -0,0 +1,411 @@ +# +# Copyright © 2022 Evilham +# +# This file is part of DD +# +# DD is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# DD is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with DD. If not, see . +# +# SPDX-License-Identifier: AGPL-3.0-or-later + + +""" +This file only depends on: + - attrs + - cryptography + - requests + +Integrations should be able to copy this file and use it verbatim on their +codebase without using anything else from DD. + +To check for changes or to update this file, head to: + https://gitlab.com/DD-workspace/DD/-/blob/main/dd-sso/admin/src/admin/lib/keys.py +""" + +import json +import stat +from copy import deepcopy +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +import requests +from attr import field, frozen +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization.base import ( + PRIVATE_KEY_TYPES, + PUBLIC_KEY_TYPES, +) +from jose import jwe, jws, jwt +from jose.backends.rsa_backend import RSAKey +from jose.constants import ALGORITHMS + +try: + # Python 3.8 + from functools import cached_property as cache +except ImportError: + from functools import cache # type: ignore # Python 3.9+ + + +Data = Union[str, bytes] +Json = Union[Dict, str, List[Dict]] + + +@frozen(slots=False) +class ThirdPartyIntegrationKeys(object): + """ + This represents the keys for a a third-party integration that interacts + with us. + + + These services must publish their public key on an agreed endpoint. + See: {their_remote_pubkey_url} + + Their key is cached in the file system: '{key_store}/their.pubkey'. + Key rotation can happen if requested by the third-party service, by + manually removing the public key file from the data store. + + + We also generate a private key for each third-party service. + This key is read from or generated to if needed in the file system: + '{key_store}/our.privkey' + + Rotating our private key can only happen by removing the private key file + and restarting the service. + + In order to use the private key, you get full access to the cryptography + primitive with {our_privkey}. + + In order to publish your public key, you get it serialised with + {our_pubkey_pem}. + """ + + # + # Standard attributes + # + our_name: str + """ + The identifier of our service. + """ + + key_store: Path = field(converter=Path) + """ + Local path to a directory containing service keys. + """ + + # + # Attributes related to the third party + # + their_name: str + """ + The identifier of the third-party. + """ + + their_service_domain: str + """ + The domain on which the third party integration is running. + """ + + their_pubkey_endpoint: str = "/pubkey/" + """ + The absolute path (starting with /) on {their_service_domain} that will + serve the pubkey. + """ + # + # Cryptography attributes + # + + key_size_bits: int = 4096 + """ + When generating private keys, how many bits will be used. + """ + + key_chmod: int = 0o600 + """ + Permissions to apply to private keys. + """ + + encrypt_algorithm: str = ALGORITHMS.RSA_OAEP_256 + """ + The encryption algorithm. + """ + + sign_algorithm: str = ALGORITHMS.RS512 + """ + The signature algorithm. + """ + + validity: timedelta = timedelta(minutes=1) + """ + The validity of a signed token. + """ + + @property + def validity_half(self) -> timedelta: + return self.validity / 2 + + # + # Properties related to the third party keys + # + @property + def their_pubkey_path(self) -> Path: + """ + Helper returning the full path to the cached pubkey. + """ + return self.key_store.joinpath("their.pubkey") + + @property + def their_remote_pubkey_url(self) -> str: + """ + Helper returning the full remote URL to the service's pubkey endpoint. + """ + return f"https://{self.their_service_domain}{self.their_pubkey_endpoint}" + + @property + def their_pubkey_bytes(self) -> bytes: + """ + Return the service's pubkey by fetching it only if necessary. + That means the key is fetched exactly once and saved to + their_pubkey_path. + In order to rotate keys, their_pubkey_path must be manually deleted. + + If the key has never been fetched and downloading it fails, an empty + bytestring will be returned. + + Note we do not cache this property since there might be temporary + failures. + """ + if not self.their_pubkey_path.exists(): + # Download if not available + try: + res = requests.get(self.their_remote_pubkey_url) + except: + # The service is currently unavailable + return b"" + self.their_pubkey_path.write_bytes(res.content) + return self.their_pubkey_path.read_bytes() + + @property + def their_pubkey_jwk(self) -> Dict[str, Any]: + """ + Return the service's PublicKey in JWK form fetching if necessary or + empty dict if it was not in the store and we were unable to fetch it. + """ + pk_b = self.their_pubkey_bytes + if not pk_b: + return dict() + rsak = RSAKey(json.loads(pk_b), self.sign_algorithm) + return rsak.to_dict() + + # + # Properties related to our own keys + # + @property + def our_privkey_path(self) -> Path: + """ + Helper returning the full path to our private key. + """ + return self.key_store.joinpath("our.privkey") + + @cache + def our_privkey_bytes(self) -> bytes: + """ + Helper that returns our private key, generating it if necessary. + + This property is cached to avoid expensive operations. + That does mean that on key rotation the service must be restarted. + """ + return self._load_privkey(self.our_privkey_path) + + @cache + def our_privkey(self) -> RSAKey: + """ + Helper that returns our private key in cryptography form, generating + it if necessary. + """ + pk_b = self.our_privkey_bytes + return RSAKey(json.loads(pk_b), self.sign_algorithm) + + @cache + def our_privkey_jwk(self) -> Dict[str, Any]: + """ + Return our PrivateKey in JWK for this third-party service, generating + it if necessary. + """ + return self.our_privkey.to_dict() + + @cache + def our_pubkey(self) -> RSAKey: + """ + Helper that returns our public key, generating the privkey if necessary. + """ + return self.our_privkey.public_key() + + @cache + def our_pubkey_jwk(self) -> Dict[str, Any]: + """ + Helper that returns our public key in JWK form. + """ + return self.our_pubkey.to_dict() + + # + # Message-passing methods + # + def encrypt_outgoing_data(self, data: bytes) -> str: + return jwe.encrypt( + data, + self.their_pubkey_jwk, + algorithm=self.encrypt_algorithm, + kid=self.our_name, + ).decode("utf-8") + + def encrypt_outgoing_json(self, data: Json) -> str: + return self.encrypt_outgoing_data(json.dumps(data).encode("utf-8")) + + def decrypt_incoming_data(self, enc_data: Data) -> bytes: + return jwe.decrypt(enc_data, self.our_privkey_jwk) # type: ignore # Wrong hint + + def decrypt_incoming_json(self, enc_data: Data) -> Json: + d: Json = json.loads(self.decrypt_incoming_data(enc_data)) + return d + + def sign_outgoing_json(self, data: Json) -> str: + now = datetime.utcnow() + claims = { + "data": data, + "aud": self.their_name, + "iss": self.our_name, + "iat": now, + "nbf": now - self.validity_half, + "exp": now + self.validity_half, + } + return jwt.encode( + claims, + self.our_privkey_jwk, + algorithm=self.sign_algorithm, + headers={ + "kid": self.our_name, + }, + ) + + def sign_outgoing_data(self, data: Json) -> str: + return self.sign_outgoing_json(data) + + def verify_incoming_data(self, data: Data) -> Any: + signed_data: Dict = jwt.decode( + data, + self.their_pubkey_jwk, + algorithms=[self.sign_algorithm], + audience=self.our_name, + issuer=self.their_name, + options={ + "require_aud": True, + "require_iat": True, + "require_iss": True, + "require_nbf": True, + "require_exp": True, + }, + ) + return signed_data["data"] + + def verify_incoming_json(self, data: bytes) -> Json: + d: Json = json.loads(self.verify_incoming_data(data)) + return d + + def sign_and_encrypt_outgoing_data(self, data: bytes) -> str: + return self.sign_outgoing_data(self.encrypt_outgoing_data(data)) + + def sign_and_encrypt_outgoing_json(self, data: Json) -> str: + return self.sign_outgoing_data(self.encrypt_outgoing_json(data)) + + def verify_and_decrypt_incoming_data(self, data: Data) -> bytes: + enc_data: str = self.verify_incoming_data(data) + return self.decrypt_incoming_data(enc_data.encode("utf-8")) + + def verify_and_decrypt_incoming_json(self, data: Data) -> Json: + enc_data: str = self.verify_incoming_data(data) + return self.decrypt_incoming_json(enc_data.encode("utf-8")) + + def get_outgoing_request_headers(self) -> Dict[str, str]: + # Use current time as ever-changing payload + now = datetime.utcnow().isoformat() + return {"Authorization": self.sign_outgoing_data(now)} + + # + # Helper methods + # + def _load_privkey(self, path: Path, force_generation: bool = False) -> bytes: + # Check recommendations here + # https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#cryptography.hazmat.primitives.asymmetric.rsa.generate_private_key + # + needs_generation = force_generation or not path.exists() + + # Perform further sanity checks + # by re-using needs_generation we save checking the file system + if not needs_generation: + path_st = path.stat() + # Check and fix permissions if necessary + if stat.S_IMODE(path_st.st_mode) != self.key_chmod: + path.touch(mode=self.key_chmod, exist_ok=True) + # Force generation if file is empty + needs_generation = path_st == 0 + + if needs_generation: + # Generate the key as needed + gpk = rsa.generate_private_key( + public_exponent=65537, key_size=self.key_size_bits + ) + enc_gpk = gpk.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + enc_jwk = json.dumps(RSAKey(enc_gpk, self.sign_algorithm).to_dict()) + # Ensure permissions + path.touch(mode=self.key_chmod, exist_ok=True) + # Write private key + path.write_text(enc_jwk) + + # Return key + return path.read_bytes() + + +if __name__ == "__main__": + # TODO: convert into real tests + a = ThirdPartyIntegrationKeys( + our_name="a", their_name="b", their_service_domain="b.com", key_store="tmpa" + ) + b = ThirdPartyIntegrationKeys( + our_name="b", their_name="a", their_service_domain="a.com", key_store="tmpb" + ) + Path("tmpa/their.pubkey").write_text(json.dumps(b.our_pubkey_jwk)) + Path("tmpb/their.pubkey").write_text(json.dumps(a.our_pubkey_jwk)) + + m1 = b"test message" + print("out", m1) + o1 = a.sign_and_encrypt_outgoing_data(m1) + print("enc", o1) + r1 = b.verify_and_decrypt_incoming_data(o1) + print("got", r1) + assert m1 == r1, f"Wrong result. Got: {r1!r}. Expected: {m1!r}" + + m2 = {"test": 1, "hello": "world"} + m2 = {} + print("out", m2) + o2 = a.sign_and_encrypt_outgoing_json(m2) + print(jwt.get_unverified_headers(o2)) + print("enc", o2) + r2 = b.verify_and_decrypt_incoming_json(o2) + print("got", r2) + assert m2 == r2, f"Wrong result. Got: {r2!r}. Expected: {m2!r}" diff --git a/dd-sso/admin/src/admin/views/ApiViews.py b/dd-sso/admin/src/admin/views/ApiViews.py index 13481de..e371a8e 100644 --- a/dd-sso/admin/src/admin/views/ApiViews.py +++ b/dd-sso/admin/src/admin/views/ApiViews.py @@ -294,6 +294,7 @@ def setup_api_views(app : "AdminFlaskApp") -> None: @app.json_route("/ddapi/user_mail/", methods=["GET", "DELETE"]) @has_token def ddapi_user_mail(id : Optional[str]=None) -> OptionalJsonResponse: + # TODO: Remove this endpoint when we ensure there are no consumers if request.method == "GET": return ( json.dumps("Not implemented yet"), @@ -320,7 +321,7 @@ def setup_api_views(app : "AdminFlaskApp") -> None: ) for user in data: log.info("Added user email") - app.admin.set_nextcloud_user_mail(user) + app.admin.nextcloud_mail_set([user], dict()) return ( json.dumps("Users emails updated"), 200, diff --git a/dd-sso/admin/src/admin/views/AppViews.py b/dd-sso/admin/src/admin/views/AppViews.py index 5a3dfbc..7a27c96 100644 --- a/dd-sso/admin/src/admin/views/AppViews.py +++ b/dd-sso/admin/src/admin/views/AppViews.py @@ -205,6 +205,7 @@ def setup_app_views(app : "AdminFlaskApp") -> None: @app.json_route("/api/user/", methods=["PUT", "GET", "DELETE"]) @login_required def user(userid : Optional[str]=None) -> OptionalJsonResponse: + # This is where changes happen from the UI uid : str = userid if userid else '' if request.method == "DELETE": app.admin.delete_user(uid) diff --git a/dd-sso/admin/src/admin/views/MailViews.py b/dd-sso/admin/src/admin/views/MailViews.py new file mode 100644 index 0000000..285274e --- /dev/null +++ b/dd-sso/admin/src/admin/views/MailViews.py @@ -0,0 +1,101 @@ +# +# Copyright © 2022 MaadiX +# Copyright © 2022 Evilham +# +# This file is part of DD +# +# DD is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# DD is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with DD. If not, see . +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import json +import traceback +from operator import itemgetter +from typing import TYPE_CHECKING, Any, Dict, List, cast + +from flask import request + +from admin.auth.jws_tokens import has_jws_token +from admin.lib.callbacks import user_parser +from admin.views.decorators import JsonResponse + +from ..lib.api_exceptions import Error + +if TYPE_CHECKING: + from admin.flaskapp import AdminFlaskApp + +JsonHeaders = {"Content-Type": "application/json"} + + +def setup_mail_views(app: "AdminFlaskApp") -> None: + mail_3p = app.api_3p["correu"] + + @app.json_route("/ddapi/pubkey", methods=["GET"]) + def pub_key() -> JsonResponse: + key = json.dumps(mail_3p.our_pubkey_jwk) + return key, 200, {"Content-Type": "application/json"} + + @app.route("/ddapi/mailusers", methods=["GET", "POST", "PUT", "DELETE"]) + @has_jws_token(app) + def ddapi_mail_users() -> JsonResponse: + users: List[Dict[str, Any]] = [] + if request.method == "GET": + try: + sorted_users = sorted( + app.admin.get_mix_users(), key=itemgetter("username") + ) + for user in sorted_users: + users.append(user_parser(user)) + # Encrypt data with mail client public key + enc = mail_3p.sign_and_encrypt_outgoing_json({"users": users}) + headers = mail_3p.get_outgoing_request_headers() + headers.update(JsonHeaders) + return enc, 200, headers + except: + raise Error( + "internal_server", "Failure sending users", traceback.format_exc() + ) + if request.method not in ["POST", "PUT", "DELETE"]: + # Unsupported method + return json.dumps({}), 400, JsonHeaders + + try: + dec_data = cast( + Dict, mail_3p.verify_and_decrypt_incoming_json(request.get_data()) + ) + users = dec_data.pop("users") + for user in users: + if not app.validators["mail"].validate(user): + raise Error( + "bad_request", + "Data validation for mail failed: " + + str(app.validators["mail"].errors), + traceback.format_exc(), + ) + res: Dict + if request.method in ["POST", "PUT"]: + res = app.admin.nextcloud_mail_set(users, dec_data) + elif request.method == "DELETE": + res = app.admin.nextcloud_mail_delete(users, dec_data) + return ( + json.dumps(res), + 200, + {"Content-Type": "application/json"}, + ) + except Exception as e: + raise Error( + "internal_server", + "Failure changing user emails", + traceback.format_exc(), + )