[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.
Xnet-DigitalDemocratic-main-patch-41273
Evilham 2022-07-31 11:12:18 +02:00
parent 74b209b55b
commit c19ff6cd8d
No known key found for this signature in database
GPG Key ID: AE3EE30D970886BF
11 changed files with 907 additions and 16 deletions

View File

@ -19,6 +19,8 @@ eventlet = "*"
pyyaml = "*"
requests = "*"
python-keycloak = "*"
attrs = "*"
cryptography = "*"
[dev-packages]
mypy = "*"

View File

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

View File

@ -17,15 +17,19 @@
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# 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

View File

@ -0,0 +1,72 @@
#
# Copyright © 2022 MaadiX
# Copyright © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
import traceback
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple
from flask import request
from jose import jwt
from werkzeug.wrappers import Response
from admin.lib.api_exceptions import Error
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
def has_jws_token(
app: "AdminFlaskApp", *args: Any, **kwargs: Any
) -> Callable[..., Response]:
@wraps
def decorated(fn: Callable[..., Response]) -> Response:
get_jws_payload(app)
return fn(*args, **kwargs)
return decorated
def get_jws_payload(app: "AdminFlaskApp") -> Tuple[str, Dict]:
"""
Try to parse the Authorization header into a JWT.
By getting the key ID from the unverified headers, try to verify the token
with the associated key.
For pragmatism it returns a tuple of the (key_id, jwt_claims).
"""
kid: str = ""
try:
t: str = request.headers.get("Authorization", "")
# Get which KeyId we have to use.
# We default to 'empty', so a missing 'kid' is not an issue in an on
# itself.
# This enables using an empty key in app.api_3p as the default.
# That default is better managed in AdminFlaskApp and not here.
kid = jwt.get_unverified_headers(t).get("kid", "")
except:
raise Error(
"unauthorized", "Token is missing or malformed", traceback.format_stack()
)
try:
# Try to get payload
return (kid, app.api_3p[kid].verify_incoming_data(t))
except:
raise Error("forbidden", "Data verification failed", traceback.format_stack())

View File

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

View File

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

View File

@ -0,0 +1,115 @@
#
# Copyright © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
import copy
from typing import Any, Dict, Tuple
import requests
from attr import define
from admin.lib.keys import ThirdPartyIntegrationKeys
DDUser = Dict[str, Any]
def user_parser(dduser: DDUser) -> DDUser:
user = copy.deepcopy(dduser)
user["keycloak_id"] = user.pop("id")
user["role"] = user["roles"][0] if user.get("roles", []) else None
user["groups"] = user.get("groups", user.get("keycloak_groups", []))
return user
@define
class ThirdPartyCallbacks:
"""
If necessary this class may be inherited from and customised.
By default it uses the configured endpoints to send data to the third-party.
This data is sent using ThirdPartyIntegrationKeys, which takes care of
encrypting first, then signing, with the service-specific keys.
"""
tpkeys: ThirdPartyIntegrationKeys
"""
The ThirdPartyIntegrationKeys instance where domain and keys are setup.
"""
endpoint_add_users: Tuple[
str,
str,
] = ("POST", "/api/users/")
"""
An HTTP_METHOD, ENDPOINT tuple, where ENDPOINT is an absolute path.
"""
endpoint_update_users: Tuple[
str,
str,
] = ("PUT", "/api/users/")
"""
An HTTP_METHOD, ENDPOINT tuple, where ENDPOINT is an absolute path.
"""
endpoint_delete_users: Tuple[
str,
str,
] = ("DELETE", "/api/users/")
"""
An HTTP_METHOD, ENDPOINT tuple, where ENDPOINT is an absolute path.
"""
@property
def add_users_url(self) -> str:
return f"{self.tpkeys.their_service_domain}{self.endpoint_add_users[1]}"
@property
def update_users_url(self) -> str:
return f"{self.tpkeys.their_service_domain}{self.endpoint_update_users[1]}"
@property
def delete_users_url(self) -> str:
return f"{self.tpkeys.their_service_domain}{self.endpoint_delete_users[1]}"
def _request(self, method: str, url: str, data: DDUser) -> bool:
# The endpoints are prepared for batch operations, but the way
# the admin lib is set up, it is currently not doable.
prepared_data = [user_parser(data)]
try:
enc_data = self.tpkeys.sign_and_encrypt_outgoing_json(prepared_data)
headers = self.tpkeys.get_outgoing_request_headers()
res = requests.request(method, url, data=enc_data, headers=headers)
except:
# Something went wrong sending the request
return False
return res.status_code == 200
def add_user(self, user_id: str, user: DDUser) -> bool:
data = copy.deepcopy(user)
data["id"] = user_id
return self._request(self.endpoint_add_users[0], self.add_users_url, data)
def update_user(self, user_id: str, user: DDUser) -> bool:
data = copy.deepcopy(user)
data["id"] = user_id
return self._request(self.endpoint_update_users[0], self.update_users_url, data)
def delete_user(self, user_id: str) -> bool:
data = {"id": user_id}
return self._request(self.endpoint_delete_users[0], self.delete_users_url, data)

View File

@ -0,0 +1,411 @@
#
# Copyright © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
This file only depends on:
- attrs
- cryptography
- requests
Integrations should be able to copy this file and use it verbatim on their
codebase without using anything else from DD.
To check for changes or to update this file, head to:
https://gitlab.com/DD-workspace/DD/-/blob/main/dd-sso/admin/src/admin/lib/keys.py
"""
import json
import stat
from copy import deepcopy
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
import requests
from attr import field, frozen
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization.base import (
PRIVATE_KEY_TYPES,
PUBLIC_KEY_TYPES,
)
from jose import jwe, jws, jwt
from jose.backends.rsa_backend import RSAKey
from jose.constants import ALGORITHMS
try:
# Python 3.8
from functools import cached_property as cache
except ImportError:
from functools import cache # type: ignore # Python 3.9+
Data = Union[str, bytes]
Json = Union[Dict, str, List[Dict]]
@frozen(slots=False)
class ThirdPartyIntegrationKeys(object):
"""
This represents the keys for a a third-party integration that interacts
with us.
These services must publish their public key on an agreed endpoint.
See: {their_remote_pubkey_url}
Their key is cached in the file system: '{key_store}/their.pubkey'.
Key rotation can happen if requested by the third-party service, by
manually removing the public key file from the data store.
We also generate a private key for each third-party service.
This key is read from or generated to if needed in the file system:
'{key_store}/our.privkey'
Rotating our private key can only happen by removing the private key file
and restarting the service.
In order to use the private key, you get full access to the cryptography
primitive with {our_privkey}.
In order to publish your public key, you get it serialised with
{our_pubkey_pem}.
"""
#
# Standard attributes
#
our_name: str
"""
The identifier of our service.
"""
key_store: Path = field(converter=Path)
"""
Local path to a directory containing service keys.
"""
#
# Attributes related to the third party
#
their_name: str
"""
The identifier of the third-party.
"""
their_service_domain: str
"""
The domain on which the third party integration is running.
"""
their_pubkey_endpoint: str = "/pubkey/"
"""
The absolute path (starting with /) on {their_service_domain} that will
serve the pubkey.
"""
#
# Cryptography attributes
#
key_size_bits: int = 4096
"""
When generating private keys, how many bits will be used.
"""
key_chmod: int = 0o600
"""
Permissions to apply to private keys.
"""
encrypt_algorithm: str = ALGORITHMS.RSA_OAEP_256
"""
The encryption algorithm.
"""
sign_algorithm: str = ALGORITHMS.RS512
"""
The signature algorithm.
"""
validity: timedelta = timedelta(minutes=1)
"""
The validity of a signed token.
"""
@property
def validity_half(self) -> timedelta:
return self.validity / 2
#
# Properties related to the third party keys
#
@property
def their_pubkey_path(self) -> Path:
"""
Helper returning the full path to the cached pubkey.
"""
return self.key_store.joinpath("their.pubkey")
@property
def their_remote_pubkey_url(self) -> str:
"""
Helper returning the full remote URL to the service's pubkey endpoint.
"""
return f"https://{self.their_service_domain}{self.their_pubkey_endpoint}"
@property
def their_pubkey_bytes(self) -> bytes:
"""
Return the service's pubkey by fetching it only if necessary.
That means the key is fetched exactly once and saved to
their_pubkey_path.
In order to rotate keys, their_pubkey_path must be manually deleted.
If the key has never been fetched and downloading it fails, an empty
bytestring will be returned.
Note we do not cache this property since there might be temporary
failures.
"""
if not self.their_pubkey_path.exists():
# Download if not available
try:
res = requests.get(self.their_remote_pubkey_url)
except:
# The service is currently unavailable
return b""
self.their_pubkey_path.write_bytes(res.content)
return self.their_pubkey_path.read_bytes()
@property
def their_pubkey_jwk(self) -> Dict[str, Any]:
"""
Return the service's PublicKey in JWK form fetching if necessary or
empty dict if it was not in the store and we were unable to fetch it.
"""
pk_b = self.their_pubkey_bytes
if not pk_b:
return dict()
rsak = RSAKey(json.loads(pk_b), self.sign_algorithm)
return rsak.to_dict()
#
# Properties related to our own keys
#
@property
def our_privkey_path(self) -> Path:
"""
Helper returning the full path to our private key.
"""
return self.key_store.joinpath("our.privkey")
@cache
def our_privkey_bytes(self) -> bytes:
"""
Helper that returns our private key, generating it if necessary.
This property is cached to avoid expensive operations.
That does mean that on key rotation the service must be restarted.
"""
return self._load_privkey(self.our_privkey_path)
@cache
def our_privkey(self) -> RSAKey:
"""
Helper that returns our private key in cryptography form, generating
it if necessary.
"""
pk_b = self.our_privkey_bytes
return RSAKey(json.loads(pk_b), self.sign_algorithm)
@cache
def our_privkey_jwk(self) -> Dict[str, Any]:
"""
Return our PrivateKey in JWK for this third-party service, generating
it if necessary.
"""
return self.our_privkey.to_dict()
@cache
def our_pubkey(self) -> RSAKey:
"""
Helper that returns our public key, generating the privkey if necessary.
"""
return self.our_privkey.public_key()
@cache
def our_pubkey_jwk(self) -> Dict[str, Any]:
"""
Helper that returns our public key in JWK form.
"""
return self.our_pubkey.to_dict()
#
# Message-passing methods
#
def encrypt_outgoing_data(self, data: bytes) -> str:
return jwe.encrypt(
data,
self.their_pubkey_jwk,
algorithm=self.encrypt_algorithm,
kid=self.our_name,
).decode("utf-8")
def encrypt_outgoing_json(self, data: Json) -> str:
return self.encrypt_outgoing_data(json.dumps(data).encode("utf-8"))
def decrypt_incoming_data(self, enc_data: Data) -> bytes:
return jwe.decrypt(enc_data, self.our_privkey_jwk) # type: ignore # Wrong hint
def decrypt_incoming_json(self, enc_data: Data) -> Json:
d: Json = json.loads(self.decrypt_incoming_data(enc_data))
return d
def sign_outgoing_json(self, data: Json) -> str:
now = datetime.utcnow()
claims = {
"data": data,
"aud": self.their_name,
"iss": self.our_name,
"iat": now,
"nbf": now - self.validity_half,
"exp": now + self.validity_half,
}
return jwt.encode(
claims,
self.our_privkey_jwk,
algorithm=self.sign_algorithm,
headers={
"kid": self.our_name,
},
)
def sign_outgoing_data(self, data: Json) -> str:
return self.sign_outgoing_json(data)
def verify_incoming_data(self, data: Data) -> Any:
signed_data: Dict = jwt.decode(
data,
self.their_pubkey_jwk,
algorithms=[self.sign_algorithm],
audience=self.our_name,
issuer=self.their_name,
options={
"require_aud": True,
"require_iat": True,
"require_iss": True,
"require_nbf": True,
"require_exp": True,
},
)
return signed_data["data"]
def verify_incoming_json(self, data: bytes) -> Json:
d: Json = json.loads(self.verify_incoming_data(data))
return d
def sign_and_encrypt_outgoing_data(self, data: bytes) -> str:
return self.sign_outgoing_data(self.encrypt_outgoing_data(data))
def sign_and_encrypt_outgoing_json(self, data: Json) -> str:
return self.sign_outgoing_data(self.encrypt_outgoing_json(data))
def verify_and_decrypt_incoming_data(self, data: Data) -> bytes:
enc_data: str = self.verify_incoming_data(data)
return self.decrypt_incoming_data(enc_data.encode("utf-8"))
def verify_and_decrypt_incoming_json(self, data: Data) -> Json:
enc_data: str = self.verify_incoming_data(data)
return self.decrypt_incoming_json(enc_data.encode("utf-8"))
def get_outgoing_request_headers(self) -> Dict[str, str]:
# Use current time as ever-changing payload
now = datetime.utcnow().isoformat()
return {"Authorization": self.sign_outgoing_data(now)}
#
# Helper methods
#
def _load_privkey(self, path: Path, force_generation: bool = False) -> bytes:
# Check recommendations here
# https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#cryptography.hazmat.primitives.asymmetric.rsa.generate_private_key
#
needs_generation = force_generation or not path.exists()
# Perform further sanity checks
# by re-using needs_generation we save checking the file system
if not needs_generation:
path_st = path.stat()
# Check and fix permissions if necessary
if stat.S_IMODE(path_st.st_mode) != self.key_chmod:
path.touch(mode=self.key_chmod, exist_ok=True)
# Force generation if file is empty
needs_generation = path_st == 0
if needs_generation:
# Generate the key as needed
gpk = rsa.generate_private_key(
public_exponent=65537, key_size=self.key_size_bits
)
enc_gpk = gpk.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
enc_jwk = json.dumps(RSAKey(enc_gpk, self.sign_algorithm).to_dict())
# Ensure permissions
path.touch(mode=self.key_chmod, exist_ok=True)
# Write private key
path.write_text(enc_jwk)
# Return key
return path.read_bytes()
if __name__ == "__main__":
# TODO: convert into real tests
a = ThirdPartyIntegrationKeys(
our_name="a", their_name="b", their_service_domain="b.com", key_store="tmpa"
)
b = ThirdPartyIntegrationKeys(
our_name="b", their_name="a", their_service_domain="a.com", key_store="tmpb"
)
Path("tmpa/their.pubkey").write_text(json.dumps(b.our_pubkey_jwk))
Path("tmpb/their.pubkey").write_text(json.dumps(a.our_pubkey_jwk))
m1 = b"test message"
print("out", m1)
o1 = a.sign_and_encrypt_outgoing_data(m1)
print("enc", o1)
r1 = b.verify_and_decrypt_incoming_data(o1)
print("got", r1)
assert m1 == r1, f"Wrong result. Got: {r1!r}. Expected: {m1!r}"
m2 = {"test": 1, "hello": "world"}
m2 = {}
print("out", m2)
o2 = a.sign_and_encrypt_outgoing_json(m2)
print(jwt.get_unverified_headers(o2))
print("enc", o2)
r2 = b.verify_and_decrypt_incoming_json(o2)
print("got", r2)
assert m2 == r2, f"Wrong result. Got: {r2!r}. Expected: {m2!r}"

View File

@ -294,6 +294,7 @@ def setup_api_views(app : "AdminFlaskApp") -> None:
@app.json_route("/ddapi/user_mail/<id>", methods=["GET", "DELETE"])
@has_token
def ddapi_user_mail(id : Optional[str]=None) -> OptionalJsonResponse:
# TODO: Remove this endpoint when we ensure there are no consumers
if request.method == "GET":
return (
json.dumps("Not implemented yet"),
@ -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,

View File

@ -205,6 +205,7 @@ def setup_app_views(app : "AdminFlaskApp") -> None:
@app.json_route("/api/user/<userid>", methods=["PUT", "GET", "DELETE"])
@login_required
def user(userid : Optional[str]=None) -> OptionalJsonResponse:
# This is where changes happen from the UI
uid : str = userid if userid else ''
if request.method == "DELETE":
app.admin.delete_user(uid)

View File

@ -0,0 +1,101 @@
#
# Copyright © 2022 MaadiX
# Copyright © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
import json
import traceback
from operator import itemgetter
from typing import TYPE_CHECKING, Any, Dict, List, cast
from flask import request
from admin.auth.jws_tokens import has_jws_token
from admin.lib.callbacks import user_parser
from admin.views.decorators import JsonResponse
from ..lib.api_exceptions import Error
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
JsonHeaders = {"Content-Type": "application/json"}
def setup_mail_views(app: "AdminFlaskApp") -> None:
mail_3p = app.api_3p["correu"]
@app.json_route("/ddapi/pubkey", methods=["GET"])
def pub_key() -> JsonResponse:
key = json.dumps(mail_3p.our_pubkey_jwk)
return key, 200, {"Content-Type": "application/json"}
@app.route("/ddapi/mailusers", methods=["GET", "POST", "PUT", "DELETE"])
@has_jws_token(app)
def ddapi_mail_users() -> JsonResponse:
users: List[Dict[str, Any]] = []
if request.method == "GET":
try:
sorted_users = sorted(
app.admin.get_mix_users(), key=itemgetter("username")
)
for user in sorted_users:
users.append(user_parser(user))
# Encrypt data with mail client public key
enc = mail_3p.sign_and_encrypt_outgoing_json({"users": users})
headers = mail_3p.get_outgoing_request_headers()
headers.update(JsonHeaders)
return enc, 200, headers
except:
raise Error(
"internal_server", "Failure sending users", traceback.format_exc()
)
if request.method not in ["POST", "PUT", "DELETE"]:
# Unsupported method
return json.dumps({}), 400, JsonHeaders
try:
dec_data = cast(
Dict, mail_3p.verify_and_decrypt_incoming_json(request.get_data())
)
users = dec_data.pop("users")
for user in users:
if not app.validators["mail"].validate(user):
raise Error(
"bad_request",
"Data validation for mail failed: "
+ str(app.validators["mail"].errors),
traceback.format_exc(),
)
res: Dict
if request.method in ["POST", "PUT"]:
res = app.admin.nextcloud_mail_set(users, dec_data)
elif request.method == "DELETE":
res = app.admin.nextcloud_mail_delete(users, dec_data)
return (
json.dumps(res),
200,
{"Content-Type": "application/json"},
)
except Exception as e:
raise Error(
"internal_server",
"Failure changing user emails",
traceback.format_exc(),
)