[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
parent
74b209b55b
commit
c19ff6cd8d
|
@ -19,6 +19,8 @@ eventlet = "*"
|
||||||
pyyaml = "*"
|
pyyaml = "*"
|
||||||
requests = "*"
|
requests = "*"
|
||||||
python-keycloak = "*"
|
python-keycloak = "*"
|
||||||
|
attrs = "*"
|
||||||
|
cryptography = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
mypy = "*"
|
mypy = "*"
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "e5f3be6c5adeb1d2f9b30ff0f72d15c61724b87fe49de8feec0d93cbb2fb96be"
|
"sha256": "8a5f88b027753cb1145b10e191326d6e9cfaa1c3333a773ac91071c3e7b7008c"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {
|
"requires": {
|
||||||
|
@ -16,6 +16,14 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"default": {
|
"default": {
|
||||||
|
"attrs": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6",
|
||||||
|
"sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==22.1.0"
|
||||||
|
},
|
||||||
"bidict": {
|
"bidict": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:415126d23a0c81e1a8c584a8fb1f6905ea090c772571803aeee0a2242e8e7ba0",
|
"sha256:415126d23a0c81e1a8c584a8fb1f6905ea090c772571803aeee0a2242e8e7ba0",
|
||||||
|
@ -39,6 +47,75 @@
|
||||||
"markers": "python_version >= '3.6'",
|
"markers": "python_version >= '3.6'",
|
||||||
"version": "==2022.6.15"
|
"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": {
|
"charset-normalizer": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5",
|
"sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5",
|
||||||
|
@ -63,6 +140,34 @@
|
||||||
"markers": "python_version >= '3.6'",
|
"markers": "python_version >= '3.6'",
|
||||||
"version": "==21.6.0"
|
"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": {
|
"diceware": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:09b62e491cc98ed569bdb51459e4523bbc3fa71b031a9c4c97f6dc93cab8c321",
|
"sha256:09b62e491cc98ed569bdb51459e4523bbc3fa71b031a9c4c97f6dc93cab8c321",
|
||||||
|
@ -76,7 +181,7 @@
|
||||||
"sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e",
|
"sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e",
|
||||||
"sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f"
|
"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"
|
"version": "==2.2.1"
|
||||||
},
|
},
|
||||||
"ecdsa": {
|
"ecdsa": {
|
||||||
|
@ -422,6 +527,13 @@
|
||||||
],
|
],
|
||||||
"version": "==0.4.8"
|
"version": "==0.4.8"
|
||||||
},
|
},
|
||||||
|
"pycparser": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9",
|
||||||
|
"sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"
|
||||||
|
],
|
||||||
|
"version": "==2.21"
|
||||||
|
},
|
||||||
"python-engineio": {
|
"python-engineio": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:18474c452894c60590b2d2339d6c81b93fb9857f1be271a2e91fb2707eb4095d",
|
"sha256:18474c452894c60590b2d2339d6c81b93fb9857f1be271a2e91fb2707eb4095d",
|
||||||
|
@ -513,7 +625,7 @@
|
||||||
"sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7",
|
"sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7",
|
||||||
"sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"
|
"sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.6' and python_version < '4'",
|
"markers": "python_version >= '3.6' and python_version < '4.0'",
|
||||||
"version": "==4.9"
|
"version": "==4.9"
|
||||||
},
|
},
|
||||||
"schema": {
|
"schema": {
|
||||||
|
@ -545,7 +657,7 @@
|
||||||
"sha256:c33ccba33c819596124764c23a97d25f32b28433ba0dedeb77d873a38722c9bc",
|
"sha256:c33ccba33c819596124764c23a97d25f32b28433ba0dedeb77d873a38722c9bc",
|
||||||
"sha256:ea6e8fb210b19d950fab93b60c9009226c63a28808bc8386e05301e25883ac0a"
|
"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"
|
"version": "==1.26.11"
|
||||||
},
|
},
|
||||||
"werkzeug": {
|
"werkzeug": {
|
||||||
|
@ -701,11 +813,11 @@
|
||||||
},
|
},
|
||||||
"types-pillow": {
|
"types-pillow": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:6823851e179dcc157424175b5dc0e1204b1c949e1de32417ff2fbfa7e3d3f45b",
|
"sha256:9781104ee2176f680576523fa2a2b83b134957aec6f4d62582cc9e74c93a60b4",
|
||||||
"sha256:f367d22b54239b09607fcd8d4514b86bac6bf7d6ed1d5bdfa41782ea62083b2a"
|
"sha256:d63743ef631e47f8d8669590ea976162321a9a7604588b424b6306533453fb63"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==9.2.0"
|
"version": "==9.2.1"
|
||||||
},
|
},
|
||||||
"types-psycopg2": {
|
"types-psycopg2": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|
|
@ -17,15 +17,19 @@
|
||||||
# along with DD. If not, see <https://www.gnu.org/licenses/>.
|
# along with DD. If not, see <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
attrs==22.1.0
|
||||||
|
cryptography==37.0.4
|
||||||
Flask==2.1.3
|
Flask==2.1.3
|
||||||
Flask-Login==0.6.2
|
Flask-Login==0.6.2
|
||||||
eventlet==0.33.1
|
eventlet==0.33.1
|
||||||
Flask-SocketIO==5.2.0
|
Flask-SocketIO==5.2.0
|
||||||
bcrypt==3.2.2
|
bcrypt==3.2.2
|
||||||
diceware==0.10
|
# diceware can't be upgraded without issues
|
||||||
|
diceware==0.9.6
|
||||||
mysql-connector-python==8.0.30
|
mysql-connector-python==8.0.30
|
||||||
psycopg2==2.9.3
|
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
|
minio==7.1.11
|
||||||
urllib3==1.26.11
|
urllib3==1.26.11
|
||||||
schema==0.7.5
|
schema==0.7.5
|
||||||
|
|
|
@ -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())
|
|
@ -24,13 +24,14 @@ import os
|
||||||
import os.path
|
import os.path
|
||||||
import secrets
|
import secrets
|
||||||
import traceback
|
import traceback
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Dict
|
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Tuple
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from cerberus import Validator
|
from cerberus import Validator
|
||||||
from flask import Flask, Response, jsonify, render_template, send_from_directory
|
from flask import Flask, Response, jsonify, render_template, send_from_directory
|
||||||
|
|
||||||
from admin.lib.api_exceptions import Error
|
from admin.lib.api_exceptions import Error
|
||||||
|
from admin.lib.keys import ThirdPartyIntegrationKeys
|
||||||
from admin.views.decorators import OptionalJsonResponse
|
from admin.views.decorators import OptionalJsonResponse
|
||||||
from admin.views.ApiViews import setup_api_views
|
from admin.views.ApiViews import setup_api_views
|
||||||
from admin.views.AppViews import setup_app_views
|
from admin.views.AppViews import setup_app_views
|
||||||
|
@ -68,17 +69,22 @@ class AdminFlaskApp(Flask):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
admin: "Admin"
|
admin: "Admin"
|
||||||
data_dir: str
|
api_3p : Dict[str, ThirdPartyIntegrationKeys]
|
||||||
custom_dir: str
|
custom_dir: str
|
||||||
|
data_dir: str
|
||||||
|
domain : str
|
||||||
ready: bool = False
|
ready: bool = False
|
||||||
|
|
||||||
def __init__(self, *args: Any, **kwargs: Any):
|
def __init__(self, *args: Any, **kwargs: Any):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
self.api_3p = {}
|
||||||
|
self.domain = os.environ["DOMAIN"]
|
||||||
self.url_map.strict_slashes = False
|
self.url_map.strict_slashes = False
|
||||||
self._load_config()
|
self._load_config()
|
||||||
# Minor setup tasks
|
# Minor setup tasks
|
||||||
self._load_validators()
|
self._load_validators()
|
||||||
self._setup_routes()
|
self._setup_routes()
|
||||||
|
self._setup_api_3p()
|
||||||
setup_api_views(self)
|
setup_api_views(self)
|
||||||
setup_app_views(self)
|
setup_app_views(self)
|
||||||
setup_login_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
|
# This must happen after Postup since it, e.g. fetches moodle secrets
|
||||||
from admin.lib.admin import Admin
|
from admin.lib.admin import Admin
|
||||||
self.admin = Admin(self)
|
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]:
|
def json_route(self, rule: str, **options: Any) -> Callable[..., OptionalJsonResponse]:
|
||||||
return self.route(rule, **options) # type: ignore # mypy issue #7187
|
return self.route(rule, **options) # type: ignore # mypy issue #7187
|
||||||
|
@ -145,7 +156,7 @@ class AdminFlaskApp(Flask):
|
||||||
|
|
||||||
# Move on with settings from the environment
|
# Move on with settings from the environment
|
||||||
self.config.update({
|
self.config.update({
|
||||||
"DOMAIN": os.environ["DOMAIN"],
|
"DOMAIN": self.domain,
|
||||||
"KEYCLOAK_POSTGRES_USER": os.environ["KEYCLOAK_DB_USER"],
|
"KEYCLOAK_POSTGRES_USER": os.environ["KEYCLOAK_DB_USER"],
|
||||||
"KEYCLOAK_POSTGRES_PASSWORD": os.environ["KEYCLOAK_DB_PASSWORD"],
|
"KEYCLOAK_POSTGRES_PASSWORD": os.environ["KEYCLOAK_DB_PASSWORD"],
|
||||||
"MOODLE_POSTGRES_USER": os.environ["MOODLE_POSTGRES_USER"],
|
"MOODLE_POSTGRES_USER": os.environ["MOODLE_POSTGRES_USER"],
|
||||||
|
@ -159,6 +170,30 @@ class AdminFlaskApp(Flask):
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
raise
|
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:
|
def _setup_routes(self) -> None:
|
||||||
"""
|
"""
|
||||||
Setup routes to Serve static files
|
Setup routes to Serve static files
|
||||||
|
|
|
@ -63,6 +63,7 @@ from .helpers import (
|
||||||
from typing import TYPE_CHECKING, cast, Any, Dict, Iterable, List, Optional
|
from typing import TYPE_CHECKING, cast, Any, Dict, Iterable, List, Optional
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from admin.flaskapp import AdminFlaskApp
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
from admin.lib.callbacks import ThirdPartyCallbacks
|
||||||
|
|
||||||
MANAGER = os.environ["CUSTOM_ROLE_MANAGER"]
|
MANAGER = os.environ["CUSTOM_ROLE_MANAGER"]
|
||||||
TEACHER = os.environ["CUSTOM_ROLE_TEACHER"]
|
TEACHER = os.environ["CUSTOM_ROLE_TEACHER"]
|
||||||
|
@ -77,6 +78,8 @@ class Admin:
|
||||||
app : "AdminFlaskApp"
|
app : "AdminFlaskApp"
|
||||||
internal : Dict[str, Any]
|
internal : Dict[str, Any]
|
||||||
external : Dict[str, Any]
|
external : Dict[str, Any]
|
||||||
|
third_party_cbs : List["ThirdPartyCallbacks"]
|
||||||
|
|
||||||
def __init__(self, app : "AdminFlaskApp") -> None:
|
def __init__(self, app : "AdminFlaskApp") -> None:
|
||||||
self.app = app
|
self.app = app
|
||||||
|
|
||||||
|
@ -87,6 +90,7 @@ class Admin:
|
||||||
|
|
||||||
self.default_setup()
|
self.default_setup()
|
||||||
self.internal = {}
|
self.internal = {}
|
||||||
|
self.third_party_cbs = []
|
||||||
|
|
||||||
ready = False
|
ready = False
|
||||||
while not ready:
|
while not ready:
|
||||||
|
@ -108,6 +112,32 @@ class Admin:
|
||||||
|
|
||||||
log.warning(" SYSTEM READY TO HANDLE CONNECTIONS")
|
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:
|
def check_connections(self, app : "AdminFlaskApp") -> None:
|
||||||
ready = False
|
ready = False
|
||||||
while not ready:
|
while not ready:
|
||||||
|
@ -770,6 +800,7 @@ class Admin:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def sync_external(self, ids : Any) -> None:
|
def sync_external(self, ids : Any) -> None:
|
||||||
|
# TODO: What is this endpoint for? When is it called?
|
||||||
# self.resync_data()
|
# self.resync_data()
|
||||||
log.warning("Starting sync to keycloak")
|
log.warning("Starting sync to keycloak")
|
||||||
self.sync_to_keycloak_external()
|
self.sync_to_keycloak_external()
|
||||||
|
@ -1514,6 +1545,9 @@ class Admin:
|
||||||
ev.update_text("Updating user in nextcloud")
|
ev.update_text("Updating user in nextcloud")
|
||||||
self.update_nextcloud_user(internaluser["id"], user, ndelete, nadd)
|
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")
|
ev.update_text("User updated")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -1703,6 +1737,10 @@ class Admin:
|
||||||
self.delete_nextcloud_user(userid)
|
self.delete_nextcloud_user(userid)
|
||||||
ev.update_text("Deleting from keycloak")
|
ev.update_text("Deleting from keycloak")
|
||||||
self.delete_keycloak_user(userid)
|
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...")
|
ev.update_text("Syncing data from applications...")
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
ev.update_text("User deleted")
|
ev.update_text("User deleted")
|
||||||
|
@ -1857,6 +1895,8 @@ class Admin:
|
||||||
except:
|
except:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
|
|
||||||
|
self.third_party_add_user(uid, u)
|
||||||
|
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
sio_event_send(self.app, "new_user", u)
|
sio_event_send(self.app, "new_user", u)
|
||||||
return uid
|
return uid
|
||||||
|
@ -1935,6 +1975,3 @@ class Admin:
|
||||||
self.moodle.delete_cohorts(cohort)
|
self.moodle.delete_cohorts(cohort)
|
||||||
self.nextcloud.delete_group(gid)
|
self.nextcloud.delete_group(gid)
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
|
|
||||||
def set_nextcloud_user_mail(self, data : Any) -> None:
|
|
||||||
self.nextcloud.set_user_mail(data)
|
|
||||||
|
|
|
@ -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)
|
|
@ -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}"
|
|
@ -294,6 +294,7 @@ def setup_api_views(app : "AdminFlaskApp") -> None:
|
||||||
@app.json_route("/ddapi/user_mail/<id>", methods=["GET", "DELETE"])
|
@app.json_route("/ddapi/user_mail/<id>", methods=["GET", "DELETE"])
|
||||||
@has_token
|
@has_token
|
||||||
def ddapi_user_mail(id : Optional[str]=None) -> OptionalJsonResponse:
|
def ddapi_user_mail(id : Optional[str]=None) -> OptionalJsonResponse:
|
||||||
|
# TODO: Remove this endpoint when we ensure there are no consumers
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
return (
|
return (
|
||||||
json.dumps("Not implemented yet"),
|
json.dumps("Not implemented yet"),
|
||||||
|
@ -320,7 +321,7 @@ def setup_api_views(app : "AdminFlaskApp") -> None:
|
||||||
)
|
)
|
||||||
for user in data:
|
for user in data:
|
||||||
log.info("Added user email")
|
log.info("Added user email")
|
||||||
app.admin.set_nextcloud_user_mail(user)
|
app.admin.nextcloud_mail_set([user], dict())
|
||||||
return (
|
return (
|
||||||
json.dumps("Users emails updated"),
|
json.dumps("Users emails updated"),
|
||||||
200,
|
200,
|
||||||
|
|
|
@ -205,6 +205,7 @@ def setup_app_views(app : "AdminFlaskApp") -> None:
|
||||||
@app.json_route("/api/user/<userid>", methods=["PUT", "GET", "DELETE"])
|
@app.json_route("/api/user/<userid>", methods=["PUT", "GET", "DELETE"])
|
||||||
@login_required
|
@login_required
|
||||||
def user(userid : Optional[str]=None) -> OptionalJsonResponse:
|
def user(userid : Optional[str]=None) -> OptionalJsonResponse:
|
||||||
|
# This is where changes happen from the UI
|
||||||
uid : str = userid if userid else ''
|
uid : str = userid if userid else ''
|
||||||
if request.method == "DELETE":
|
if request.method == "DELETE":
|
||||||
app.admin.delete_user(uid)
|
app.admin.delete_user(uid)
|
||||||
|
|
|
@ -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(),
|
||||||
|
)
|
Loading…
Reference in New Issue