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