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(),
+ )