From c7a2410b1d2d029b7d9870f30aee6cfe59e9f499 Mon Sep 17 00:00:00 2001 From: Jens L Date: Wed, 19 Aug 2020 10:32:44 +0200 Subject: [PATCH] OAuth Provider Rewrite (#182) --- .pylintrc | 2 +- Pipfile | 19 +- Pipfile.lock | 276 +++++------ ...auth.py => test_provider_oauth2_github.py} | 34 +- ...r_oidc.py => test_provider_oauth2_oidc.py} | 132 ++--- e2e/test_sources_oauth.py | 2 +- e2e/utils.py | 11 - passbook/api/urls.py | 2 - passbook/api/v1/openid.py | 22 - passbook/api/v1/serializers.py | 0 passbook/api/v1/urls.py | 6 - passbook/api/v2/urls.py | 11 +- passbook/core/admin.py | 8 +- passbook/core/templates/error/generic.html | 2 +- passbook/crypto/models.py | 28 +- passbook/flows/models.py | 2 + passbook/flows/templates/flows/error.html | 2 +- passbook/lib/templatetags/passbook_utils.py | 1 - passbook/lib/views.py | 6 +- passbook/policies/hibp/tests.py | 2 +- passbook/providers/app_gw/api.py | 45 -- passbook/providers/app_gw/apps.py | 11 - passbook/providers/app_gw/forms.py | 40 -- .../app_gw/migrations/0001_initial.py | 48 -- .../migrations/0002_auto_20200726_1745.py | 24 - .../migrations/0003_auto_20200801_1752.py | 32 -- passbook/providers/app_gw/models.py | 50 -- passbook/providers/oauth/api.py | 29 -- passbook/providers/oauth/apps.py | 12 - passbook/providers/oauth/forms.py | 34 -- .../oauth/migrations/0001_initial.py | 104 ---- passbook/providers/oauth/models.py | 49 -- passbook/providers/oauth/settings.py | 31 -- .../providers/oauth/setup_url_modal.html | 40 -- passbook/providers/oauth/urls.py | 39 -- passbook/providers/oauth/views/__init__.py | 0 passbook/providers/oauth/views/oauth2.py | 136 ------ .../{api/v1 => providers/oauth2}/__init__.py | 0 passbook/providers/oauth2/api.py | 50 ++ passbook/providers/oauth2/apps.py | 14 + passbook/providers/oauth2/constants.py | 19 + passbook/providers/oauth2/errors.py | 178 +++++++ passbook/providers/oauth2/forms.py | 80 +++ passbook/providers/oauth2/generators.py | 17 + .../oauth2/migrations/0001_initial.py | 357 ++++++++++++++ .../{app_gw => oauth2/migrations}/__init__.py | 0 passbook/providers/oauth2/models.py | 445 +++++++++++++++++ .../templates/providers/oauth2}/consent.html | 0 .../providers/oauth2}/setup_url_modal.html | 4 +- passbook/providers/oauth2/urls.py | 35 ++ passbook/providers/oauth2/urls_github.py | 45 ++ passbook/providers/oauth2/utils.py | 152 ++++++ .../migrations => oauth2/views}/__init__.py | 0 passbook/providers/oauth2/views/authorize.py | 374 ++++++++++++++ .../{oauth => oauth2}/views/github.py | 31 +- .../providers/oauth2/views/introspection.py | 113 +++++ passbook/providers/oauth2/views/jwks.py | 40 ++ passbook/providers/oauth2/views/provider.py | 65 +++ passbook/providers/oauth2/views/session.py | 45 ++ passbook/providers/oauth2/views/token.py | 241 +++++++++ passbook/providers/oauth2/views/userinfo.py | 92 ++++ passbook/providers/oidc/__init__.py | 0 passbook/providers/oidc/api.py | 34 -- passbook/providers/oidc/apps.py | 37 -- passbook/providers/oidc/auth.py | 67 --- passbook/providers/oidc/claims.py | 14 - passbook/providers/oidc/forms.py | 64 --- .../providers/oidc/migrations/0001_initial.py | 45 -- .../providers/oidc/migrations/__init__.py | 0 passbook/providers/oidc/models.py | 59 --- passbook/providers/oidc/settings.py | 9 - .../templates/oidc_provider/authorize.html | 74 --- .../oidc/templates/oidc_provider/error.html | 18 - .../templates/providers/oidc/consent.html | 20 - passbook/providers/oidc/urls.py | 13 - passbook/providers/oidc/views.py | 136 ------ .../{app_gw/provider => proxy}/__init__.py | 0 passbook/providers/proxy/api.py | 31 ++ passbook/providers/proxy/apps.py | 11 + passbook/providers/proxy/forms.py | 24 + .../proxy/migrations/0001_initial.py | 58 +++ .../migrations}/__init__.py | 0 passbook/providers/proxy/models.py | 73 +++ .../{oauth => proxy/provider}/__init__.py | 0 .../provider/kubernetes}/__init__.py | 0 .../providers/proxy}/k8s-manifest.yaml | 0 .../providers/proxy}/setup_modal.html | 0 passbook/providers/{app_gw => proxy}/urls.py | 4 +- passbook/providers/{app_gw => proxy}/views.py | 28 +- passbook/root/settings.py | 8 +- passbook/root/urls.py | 20 +- passbook/root/wsgi.py | 4 +- passbook/sources/ldap/tests.py | 2 +- passbook/stages/captcha/settings.py | 1 + scripts/up.sh | 11 - setup.cfg | 2 +- swagger.yaml | 462 +++++++++--------- 97 files changed, 3107 insertions(+), 1911 deletions(-) rename e2e/{test_provider_oauth.py => test_provider_oauth2_github.py} (89%) rename e2e/{test_provider_oidc.py => test_provider_oauth2_oidc.py} (73%) delete mode 100644 passbook/api/v1/openid.py delete mode 100644 passbook/api/v1/serializers.py delete mode 100644 passbook/api/v1/urls.py delete mode 100644 passbook/providers/app_gw/api.py delete mode 100644 passbook/providers/app_gw/apps.py delete mode 100644 passbook/providers/app_gw/forms.py delete mode 100644 passbook/providers/app_gw/migrations/0001_initial.py delete mode 100644 passbook/providers/app_gw/migrations/0002_auto_20200726_1745.py delete mode 100644 passbook/providers/app_gw/migrations/0003_auto_20200801_1752.py delete mode 100644 passbook/providers/app_gw/models.py delete mode 100644 passbook/providers/oauth/api.py delete mode 100644 passbook/providers/oauth/apps.py delete mode 100644 passbook/providers/oauth/forms.py delete mode 100644 passbook/providers/oauth/migrations/0001_initial.py delete mode 100644 passbook/providers/oauth/models.py delete mode 100644 passbook/providers/oauth/settings.py delete mode 100644 passbook/providers/oauth/templates/providers/oauth/setup_url_modal.html delete mode 100644 passbook/providers/oauth/urls.py delete mode 100644 passbook/providers/oauth/views/__init__.py delete mode 100644 passbook/providers/oauth/views/oauth2.py rename passbook/{api/v1 => providers/oauth2}/__init__.py (100%) create mode 100644 passbook/providers/oauth2/api.py create mode 100644 passbook/providers/oauth2/apps.py create mode 100644 passbook/providers/oauth2/constants.py create mode 100644 passbook/providers/oauth2/errors.py create mode 100644 passbook/providers/oauth2/forms.py create mode 100644 passbook/providers/oauth2/generators.py create mode 100644 passbook/providers/oauth2/migrations/0001_initial.py rename passbook/providers/{app_gw => oauth2/migrations}/__init__.py (100%) create mode 100644 passbook/providers/oauth2/models.py rename passbook/providers/{oauth/templates/providers/oauth => oauth2/templates/providers/oauth2}/consent.html (100%) rename passbook/providers/{oidc/templates/oidc_provider => oauth2/templates/providers/oauth2}/setup_url_modal.html (94%) create mode 100644 passbook/providers/oauth2/urls.py create mode 100644 passbook/providers/oauth2/urls_github.py create mode 100644 passbook/providers/oauth2/utils.py rename passbook/providers/{app_gw/migrations => oauth2/views}/__init__.py (100%) create mode 100644 passbook/providers/oauth2/views/authorize.py rename passbook/providers/{oauth => oauth2}/views/github.py (65%) create mode 100644 passbook/providers/oauth2/views/introspection.py create mode 100644 passbook/providers/oauth2/views/jwks.py create mode 100644 passbook/providers/oauth2/views/provider.py create mode 100644 passbook/providers/oauth2/views/session.py create mode 100644 passbook/providers/oauth2/views/token.py create mode 100644 passbook/providers/oauth2/views/userinfo.py delete mode 100644 passbook/providers/oidc/__init__.py delete mode 100644 passbook/providers/oidc/api.py delete mode 100644 passbook/providers/oidc/apps.py delete mode 100644 passbook/providers/oidc/auth.py delete mode 100644 passbook/providers/oidc/claims.py delete mode 100644 passbook/providers/oidc/forms.py delete mode 100644 passbook/providers/oidc/migrations/0001_initial.py delete mode 100644 passbook/providers/oidc/migrations/__init__.py delete mode 100644 passbook/providers/oidc/models.py delete mode 100644 passbook/providers/oidc/settings.py delete mode 100644 passbook/providers/oidc/templates/oidc_provider/authorize.html delete mode 100644 passbook/providers/oidc/templates/oidc_provider/error.html delete mode 100644 passbook/providers/oidc/templates/providers/oidc/consent.html delete mode 100644 passbook/providers/oidc/urls.py delete mode 100644 passbook/providers/oidc/views.py rename passbook/providers/{app_gw/provider => proxy}/__init__.py (100%) create mode 100644 passbook/providers/proxy/api.py create mode 100644 passbook/providers/proxy/apps.py create mode 100644 passbook/providers/proxy/forms.py create mode 100644 passbook/providers/proxy/migrations/0001_initial.py rename passbook/providers/{app_gw/provider/kubernetes => proxy/migrations}/__init__.py (100%) create mode 100644 passbook/providers/proxy/models.py rename passbook/providers/{oauth => proxy/provider}/__init__.py (100%) rename passbook/providers/{oauth/migrations => proxy/provider/kubernetes}/__init__.py (100%) rename passbook/providers/{app_gw/templates/app_gw => proxy/templates/providers/proxy}/k8s-manifest.yaml (100%) rename passbook/providers/{app_gw/templates/app_gw => proxy/templates/providers/proxy}/setup_modal.html (100%) rename passbook/providers/{app_gw => proxy}/urls.py (63%) rename passbook/providers/{app_gw => proxy}/views.py (73%) delete mode 100755 scripts/up.sh diff --git a/.pylintrc b/.pylintrc index 5369262af..718b46dd4 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,6 +1,6 @@ [MASTER] -disable=redefined-outer-name,arguments-differ,no-self-use,cyclic-import,fixme,locally-disabled,too-many-ancestors,too-few-public-methods,import-outside-toplevel,bad-continuation,signature-differs +disable=arguments-differ,no-self-use,fixme,locally-disabled,too-many-ancestors,too-few-public-methods,import-outside-toplevel,bad-continuation,signature-differs,similarities,cyclic-import load-plugins=pylint_django,pylint.extensions.bad_builtin extension-pkg-whitelist=lxml const-rgx=[a-zA-Z0-9_]{1,40}$ diff --git a/Pipfile b/Pipfile index 3243d403a..f3c910f7e 100644 --- a/Pipfile +++ b/Pipfile @@ -13,8 +13,6 @@ django-dbbackup = "*" django-filter = "*" django-guardian = "*" django-model-utils = "*" -django-oauth-toolkit = "*" -django-oidc-provider = "*" django-otp = "*" django-prometheus = "*" django-recaptcha = "*" @@ -23,13 +21,14 @@ django-rest-framework = "*" django-storages = "*" djangorestframework-guardian = "*" drf-yasg = "*" -kombu = "*" +elastic-apm = "*" +facebook-sdk = "*" ldap3 = "*" lxml = "*" -oauthlib = "*" packaging = "*" psycopg2-binary = "*" pycryptodome = "*" +pyjwkest = "*" pyuwsgi = "*" pyyaml = "*" qrcode = "*" @@ -40,8 +39,6 @@ signxml = "*" structlog = "*" swagger-spec-validator = "*" urllib3 = {extras = ["secure"],version = "*"} -facebook-sdk = "*" -elastic-apm = "*" [requires] python_version = "3.8" @@ -49,16 +46,14 @@ python_version = "3.8" [dev-packages] autopep8 = "*" bandit = "*" +black = "==19.10b0" bumpversion = "*" colorama = "*" coverage = "*" django-debug-toolbar = "*" +docker = "*" pylint = "*" pylint-django = "*" -unittest-xml-reporting = "*" -black = "*" selenium = "*" -docker = "*" - -[pipenv] -allow_prereleases = true +unittest-xml-reporting = "*" +prospector = "*" diff --git a/Pipfile.lock b/Pipfile.lock index e86388061..6a1a545da 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "5c22d3a514247b663a07c6492cea09ab140346894a528db06bd805a4a3a4a320" + "sha256": "b7ba5405c03bf3526eebb29817887744a3e31bca019ad2e566ea23096c6a5cfe" }, "pipfile-spec": 6, "requires": { @@ -46,18 +46,18 @@ }, "boto3": { "hashes": [ - "sha256:640a8372ce0edfbb84a8f63584a0b64c78d61a751a27c2a47f92d2ebaf021ce4", - "sha256:a6c9a3d3abbad2ff2e5751af599492a9271633a7c9fef343482524464c53e451" + "sha256:02ad765927bb46b9f45c3bce65e763960733919eee7883217995c5df5d096695", + "sha256:4421aad9a9740ce95199460f3262859e1c3594cc6c86cbe552745f4bbff34300" ], "index": "pypi", - "version": "==1.14.43" + "version": "==1.14.45" }, "botocore": { "hashes": [ - "sha256:1b46ffe1d13922066c873323186cbf97e77c137e08e27039d9d684552ccc4892", - "sha256:1f6175bf59ffa068055b65f7d703eb1f748c338594a40dfdc645a6130280d8bb" + "sha256:17470c97435891cf40e147f533069de0109cda24c208c918f28997274bbac399", + "sha256:bc8b1c83ccc0d77963849b66a94bbb20a666ff0225aff84de7ed0175db1fd6f7" ], - "version": "==1.17.44" + "version": "==1.17.45" }, "celery": { "hashes": [ @@ -207,21 +207,6 @@ "index": "pypi", "version": "==4.0.0" }, - "django-oauth-toolkit": { - "hashes": [ - "sha256:28508f83385ab4313936ddedfb310eaa8a1dcb737153d2956383ce47e75c2fab", - "sha256:d5a1044af9419ddc048390c5974777ea97874e5b78e33c609e17eebb8423afb2" - ], - "index": "pypi", - "version": "==1.3.2" - }, - "django-oidc-provider": { - "hashes": [ - "sha256:3fa50d35ce614a68cde704606dbff86d8535a7679871ce3ec30100b28f3af50d" - ], - "index": "pypi", - "version": "==0.7.0" - }, "django-otp": { "hashes": [ "sha256:0c9edbb3f4abc9ac6e43daf0a9e0e293e99ad917641cf8d7dbc49d613bcb5cd4", @@ -232,11 +217,11 @@ }, "django-prometheus": { "hashes": [ - "sha256:402228804b190be8bdbf20300ac3ae09043c7e3144e5dd648ddeb8f81a267f16", - "sha256:57b97be6c88af9fc5b28a9fa8df629aab2b04f6a0f910c0669880d62f8ec3c0f" + "sha256:4c30aa8eb944fcf3cf10e20dfabbbe11ad5a84fce62abb3658feffa4e2ac2b97", + "sha256:8f25e86a3c310f40cf32cfa1b56a2b6df9cb2521e4cb794844958697d98fb3d1" ], "index": "pypi", - "version": "==2.1.0.dev61" + "version": "==2.0.0" }, "django-recaptcha": { "hashes": [ @@ -376,10 +361,10 @@ }, "jinja2": { "hashes": [ - "sha256:c10142f819c2d22bdcd17548c46fa9b77cf4fda45097854c689666bf425e7484", - "sha256:c922560ac46888d47384de1dbdc3daaa2ea993af4b26a436dec31fa2c19ec668" + "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", + "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], - "version": "==3.0.0a1" + "version": "==2.11.2" }, "jmespath": { "hashes": [ @@ -400,7 +385,6 @@ "sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a", "sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74" ], - "index": "pypi", "version": "==4.6.11" }, "ldap3": { @@ -450,37 +434,47 @@ }, "markupsafe": { "hashes": [ - "sha256:06358015a4dee8ee23ae426bf885616ab3963622defd829eb45b44e3dee3515f", - "sha256:0b0c4fc852c5f02c6277ef3b33d23fcbe89b1b227460423e3335374da046b6db", - "sha256:267677fc42afed5094fc5ea1c4236bbe4b6a00fe4b08e93451e65ae9048139c7", - "sha256:303cb70893e2c345588fb5d5b86e0ca369f9bb56942f03064c5e3e75fa7a238a", - "sha256:3c9b624a0d9ed5a5093ac4edc4e823e6b125441e60ef35d36e6f4a6fdacd5054", - "sha256:42033e14cae1f6c86fc0c3e90d04d08ce73ac8e46ba420a0d22d545c2abd4977", - "sha256:4e4a99b6af7bdc0856b50020c095848ec050356a001e1f751510aef6ab14d0e0", - "sha256:4eb07faad54bb07427d848f31030a65a49ebb0cec0b30674f91cf1ddd456bfe4", - "sha256:63a7161cd8c2bc563feeda45df62f42c860dd0675e2b8da2667f25bb3c95eaba", - "sha256:68e0fd039b68d2945b4beb947d4023ca7f8e95b708031c345762efba214ea761", - "sha256:8092a63397025c2f655acd42784b2a1528339b90b987beb9253f22e8cdbb36c3", - "sha256:841218860683c0f2223e24756843d84cc49cccdae6765e04962607754a52d3e0", - "sha256:94076b2314bd2f6cfae508ad65b4d493e3a58a50112b7a2cbb6287bdbc404ae8", - "sha256:9d22aff1c5322e402adfb3ce40839a5056c353e711c033798cf4f02eb9f5124d", - "sha256:b0e4584f62b3e5f5c1a7bcefd2b52f236505e6ef032cc508caa4f4c8dc8d3af1", - "sha256:b1163ffc1384d242964426a8164da12dbcdbc0de18ea36e2c34b898ed38c3b45", - "sha256:beac28ed60c8e838301226a7a85841d0af2068eba2dcb1a58c2d32d6c05e440e", - "sha256:c29f096ce79c03054a1101d6e5fe6bf04b0bb489165d5e0e9653fb4fe8048ee1", - "sha256:c58779966d53e5f14ba393d64e2402a7926601d1ac8adeb4e83893def79d0428", - "sha256:cfe14b37908eaf7d5506302987228bff69e1b8e7071ccd4e70fd0283b1b47f0b", - "sha256:e834249c45aa9837d0753351cdca61a4b8b383cc9ad0ff2325c97ff7b69e72a6", - "sha256:eed1b234c4499811ee85bcefa22ef5e466e75d132502226ed29740d593316c1f" + "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", + "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", + "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", + "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", + "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", + "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", + "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", + "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", + "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", + "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", + "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", + "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", + "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", + "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", + "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", + "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", + "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", + "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", + "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", + "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", + "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", + "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", + "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", + "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", + "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", + "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", + "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", + "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", + "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", + "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", + "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", + "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", + "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], - "version": "==2.0.0a1" + "version": "==1.1.1" }, "oauthlib": { "hashes": [ "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" ], - "index": "pypi", "version": "==3.1.0" }, "packaging": { @@ -630,6 +624,7 @@ "hashes": [ "sha256:5560fd5ba08655f29ff6ad1df1e15dc05abc9d976fcbcec8d2b5167f49b70222" ], + "index": "pypi", "version": "==1.4.2" }, "pyopenssl": { @@ -641,10 +636,10 @@ }, "pyparsing": { "hashes": [ - "sha256:1060635ca5ac864c2b7bc7b05a448df4e32d7d8c65e33cbe1514810d339672a2", - "sha256:56a551039101858c9e189ac9e66e330a03fb7079e97ba6b50193643905f450ce" + "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", + "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "version": "==3.0.0a2" + "version": "==2.4.7" }, "pyrsistent": { "hashes": [ @@ -868,10 +863,10 @@ }, "astroid": { "hashes": [ - "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703", - "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386" + "sha256:4c17cea3e592c21b6e222f673868961bad77e1f985cb1694ed077475a89229c1", + "sha256:d8506842a3faf734b81599c8b98dcc423de863adcc1999248480b18bd31a0f38" ], - "version": "==2.4.2" + "version": "==2.4.1" }, "attrs": { "hashes": [ @@ -925,39 +920,6 @@ ], "version": "==2020.6.20" }, - "cffi": { - "hashes": [ - "sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e", - "sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c", - "sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e", - "sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1", - "sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4", - "sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2", - "sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c", - "sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0", - "sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798", - "sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1", - "sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4", - "sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731", - "sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4", - "sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c", - "sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487", - "sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e", - "sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f", - "sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123", - "sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c", - "sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b", - "sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650", - "sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad", - "sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75", - "sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82", - "sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7", - "sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15", - "sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa", - "sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281" - ], - "version": "==1.14.2" - }, "chardet": { "hashes": [ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", @@ -1020,30 +982,6 @@ "index": "pypi", "version": "==5.2.1" }, - "cryptography": { - "hashes": [ - "sha256:091d31c42f444c6f519485ed528d8b451d1a0c7bf30e8ca583a0cac44b8a0df6", - "sha256:18452582a3c85b96014b45686af264563e3e5d99d226589f057ace56196ec78b", - "sha256:1dfa985f62b137909496e7fc182dac687206d8d089dd03eaeb28ae16eec8e7d5", - "sha256:1e4014639d3d73fbc5ceff206049c5a9a849cefd106a49fa7aaaa25cc0ce35cf", - "sha256:22e91636a51170df0ae4dcbd250d318fd28c9f491c4e50b625a49964b24fe46e", - "sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b", - "sha256:651448cd2e3a6bc2bb76c3663785133c40d5e1a8c1a9c5429e4354201c6024ae", - "sha256:726086c17f94747cedbee6efa77e99ae170caebeb1116353c6cf0ab67ea6829b", - "sha256:844a76bc04472e5135b909da6aed84360f522ff5dfa47f93e3dd2a0b84a89fa0", - "sha256:88c881dd5a147e08d1bdcf2315c04972381d026cdb803325c03fe2b4a8ed858b", - "sha256:96c080ae7118c10fcbe6229ab43eb8b090fccd31a09ef55f83f690d1ef619a1d", - "sha256:a0c30272fb4ddda5f5ffc1089d7405b7a71b0b0f51993cb4e5dbb4590b2fc229", - "sha256:bb1f0281887d89617b4c68e8db9a2c42b9efebf2702a3c5bf70599421a8623e3", - "sha256:c447cf087cf2dbddc1add6987bbe2f767ed5317adb2d08af940db517dd704365", - "sha256:c4fd17d92e9d55b84707f4fd09992081ba872d1a0c610c109c18e062e06a2e55", - "sha256:d0d5aeaedd29be304848f1c5059074a740fa9f6f26b84c5b63e8b29e73dfc270", - "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e", - "sha256:e993468c859d084d5579e2ebee101de8f5a27ce8e2159959b6673b418fd8c785", - "sha256:f118a95c7480f5be0df8afeb9a11bd199aa20afab7a96bcf20409b411a3a85f0" - ], - "version": "==2.9.2" - }, "django": { "hashes": [ "sha256:1a63f5bb6ff4d7c42f62a519edc2adbb37f9b78068a5a862beff858b68e3dc8b", @@ -1068,6 +1006,27 @@ "index": "pypi", "version": "==4.3.0" }, + "dodgy": { + "hashes": [ + "sha256:28323cbfc9352139fdd3d316fa17f325cc0e9ac74438cbba51d70f9b48f86c3a", + "sha256:51f54c0fd886fa3854387f354b19f429d38c04f984f38bc572558b703c0542a6" + ], + "version": "==0.2.1" + }, + "flake8": { + "hashes": [ + "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c", + "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208" + ], + "version": "==3.8.3" + }, + "flake8-polyfill": { + "hashes": [ + "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9", + "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda" + ], + "version": "==1.0.2" + }, "gitdb": { "hashes": [ "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac", @@ -1143,6 +1102,20 @@ ], "version": "==5.4.5" }, + "pep8-naming": { + "hashes": [ + "sha256:5d9f1056cb9427ce344e98d1a7f5665710e2f20f748438e308995852cfa24164", + "sha256:f3b4a5f9dd72b991bf7d8e2a341d2e1aa3a884a769b5aaac4f56825c1763bf3a" + ], + "version": "==0.10.0" + }, + "prospector": { + "hashes": [ + "sha256:43e5e187c027336b0e4c4aa6a82d66d3b923b5ec5b51968126132e32f9d14a2f" + ], + "index": "pypi", + "version": "==1.3.0" + }, "pycodestyle": { "hashes": [ "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", @@ -1150,28 +1123,47 @@ ], "version": "==2.6.0" }, - "pycparser": { + "pydocstyle": { "hashes": [ - "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", - "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" + "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586", + "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5" ], - "version": "==2.20" + "version": "==5.0.2" + }, + "pyflakes": { + "hashes": [ + "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", + "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" + ], + "version": "==2.2.0" }, "pylint": { "hashes": [ - "sha256:7dd78437f2d8d019717dbf287772d0b2dbdfd13fc016aa7faa08d67bccc46adc", - "sha256:d0ece7d223fe422088b0e8f13fa0a1e8eb745ebffcb8ed53d3e95394b6101a1c" + "sha256:b95e31850f3af163c2283ed40432f053acbc8fc6eba6a069cb518d9dbf71848c", + "sha256:dd506acce0427e9e08fb87274bcaa953d38b50a58207170dbf5b36cf3e16957b" ], "index": "pypi", - "version": "==2.5.3" + "version": "==2.5.2" + }, + "pylint-celery": { + "hashes": [ + "sha256:41e32094e7408d15c044178ea828dd524beedbdbe6f83f712c5e35bde1de4beb" + ], + "version": "==0.3" }, "pylint-django": { "hashes": [ - "sha256:770e0c55fb054c6378e1e8bb3fe22c7032a2c38ba1d1f454206ee9c6591822d7", - "sha256:b8dcb6006ae9fa911810aba3bec047b9410b7d528f89d5aca2506b03c9235a49" + "sha256:06a64331c498a3f049ba669dc0c174b92209e164198d43e589b1096ee616d5f8", + "sha256:3d3436ba8d0fae576ae2db160e33a8f2746a101fda4463f2b3ff3a8b6fccec38" ], "index": "pypi", - "version": "==2.3.0" + "version": "==2.0.15" + }, + "pylint-flask": { + "hashes": [ + "sha256:f4d97de2216bf7bfce07c9c08b166e978fe9f2725de2a50a9845a97de7e31517" + ], + "version": "==0.6" }, "pylint-plugin-utils": { "hashes": [ @@ -1180,13 +1172,6 @@ ], "version": "==0.6" }, - "pyopenssl": { - "hashes": [ - "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504", - "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507" - ], - "version": "==19.1.0" - }, "pytz": { "hashes": [ "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", @@ -1244,13 +1229,25 @@ ], "version": "==2.24.0" }, + "requirements-detector": { + "hashes": [ + "sha256:0d1e13e61ed243f9c3c86e6cbb19980bcb3a0e0619cde2ec1f3af70fdbee6f7b" + ], + "version": "==0.7" + }, "selenium": { "hashes": [ - "sha256:5f5489a0c5fe2f09cc6bc3f32a0d53441ab36882c987269f2afe805979633ac1", - "sha256:a9779ddc69cf03b75d94062c5e948f763919cf3341c77272f94cd05e6b4c7b32" + "sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c", + "sha256:deaf32b60ad91a4611b98d8002757f29e6f2c2d5fcaf202e1c9ad06d6772300d" ], "index": "pypi", - "version": "==4.0.0a6.post2" + "version": "==3.141.0" + }, + "setoptconf": { + "hashes": [ + "sha256:5b0b5d8e0077713f5d5152d4f63be6f048d9a1bb66be15d089a11c898c3cf49c" + ], + "version": "==0.2.0" }, "six": { "hashes": [ @@ -1266,6 +1263,13 @@ ], "version": "==3.0.4" }, + "snowballstemmer": { + "hashes": [ + "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", + "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" + ], + "version": "==2.0.0" + }, "sqlparse": { "hashes": [ "sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e", diff --git a/e2e/test_provider_oauth.py b/e2e/test_provider_oauth2_github.py similarity index 89% rename from e2e/test_provider_oauth.py rename to e2e/test_provider_oauth2_github.py index 6c8f348f0..e5b2dde97 100644 --- a/e2e/test_provider_oauth.py +++ b/e2e/test_provider_oauth2_github.py @@ -1,7 +1,6 @@ """test OAuth Provider flow""" from time import sleep -from oauth2_provider.generators import generate_client_id, generate_client_secret from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from structlog import get_logger @@ -14,12 +13,16 @@ from passbook.core.models import Application from passbook.flows.models import Flow from passbook.policies.expression.models import ExpressionPolicy from passbook.policies.models import PolicyBinding -from passbook.providers.oauth.models import OAuth2Provider +from passbook.providers.oauth2.generators import ( + generate_client_id, + generate_client_secret, +) +from passbook.providers.oauth2.models import ClientTypes, OAuth2Provider, ResponseTypes LOGGER = get_logger() -class TestProviderOAuth(SeleniumTestCase): +class TestProviderOAuth2Github(SeleniumTestCase): """test OAuth Provider flow""" def setUp(self): @@ -43,18 +46,18 @@ class TestProviderOAuth(SeleniumTestCase): ), environment={ "GF_AUTH_GITHUB_ENABLED": "true", - "GF_AUTH_GITHUB_allow_sign_up": "true", + "GF_AUTH_GITHUB_ALLOW_SIGN_UP": "true", "GF_AUTH_GITHUB_CLIENT_ID": self.client_id, "GF_AUTH_GITHUB_CLIENT_SECRET": self.client_secret, "GF_AUTH_GITHUB_SCOPES": "user:email,read:org", "GF_AUTH_GITHUB_AUTH_URL": self.url( - "passbook_providers_oauth:github-authorize" + "passbook_providers_oauth2_github:github-authorize" ), "GF_AUTH_GITHUB_TOKEN_URL": self.url( - "passbook_providers_oauth:github-access-token" + "passbook_providers_oauth2_github:github-access-token" ), "GF_AUTH_GITHUB_API_URL": self.url( - "passbook_providers_oauth:github-user" + "passbook_providers_oauth2_github:github-user" ), "GF_LOG_LEVEL": "debug", }, @@ -80,12 +83,11 @@ class TestProviderOAuth(SeleniumTestCase): ) provider = OAuth2Provider.objects.create( name="grafana", - client_type=OAuth2Provider.CLIENT_CONFIDENTIAL, - authorization_grant_type=OAuth2Provider.GRANT_AUTHORIZATION_CODE, client_id=self.client_id, client_secret=self.client_secret, + client_type=ClientTypes.CONFIDENTIAL, + response_type=ResponseTypes.CODE, redirect_uris="http://localhost:3000/login/github", - skip_authorization=True, authorization_flow=authorization_flow, ) Application.objects.create( @@ -134,12 +136,11 @@ class TestProviderOAuth(SeleniumTestCase): ) provider = OAuth2Provider.objects.create( name="grafana", - client_type=OAuth2Provider.CLIENT_CONFIDENTIAL, - authorization_grant_type=OAuth2Provider.GRANT_AUTHORIZATION_CODE, client_id=self.client_id, client_secret=self.client_secret, + client_type=ClientTypes.CONFIDENTIAL, + response_type=ResponseTypes.CODE, redirect_uris="http://localhost:3000/login/github", - skip_authorization=True, authorization_flow=authorization_flow, ) app = Application.objects.create( @@ -161,7 +162,7 @@ class TestProviderOAuth(SeleniumTestCase): ).text, ) self.assertEqual( - "GitHub Compatibility: User Email", + "GitHub Compatibility: Access you Email addresses", self.driver.find_element( By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/ul/li[1]" ).text, @@ -203,12 +204,11 @@ class TestProviderOAuth(SeleniumTestCase): ) provider = OAuth2Provider.objects.create( name="grafana", - client_type=OAuth2Provider.CLIENT_CONFIDENTIAL, - authorization_grant_type=OAuth2Provider.GRANT_AUTHORIZATION_CODE, client_id=self.client_id, client_secret=self.client_secret, + client_type=ClientTypes.CONFIDENTIAL, + response_type=ResponseTypes.CODE, redirect_uris="http://localhost:3000/login/github", - skip_authorization=True, authorization_flow=authorization_flow, ) app = Application.objects.create( diff --git a/e2e/test_provider_oidc.py b/e2e/test_provider_oauth2_oidc.py similarity index 73% rename from e2e/test_provider_oidc.py rename to e2e/test_provider_oauth2_oidc.py index fa8ff6531..43f31c0ad 100644 --- a/e2e/test_provider_oidc.py +++ b/e2e/test_provider_oauth2_oidc.py @@ -1,9 +1,6 @@ -"""test OpenID Provider flow""" +"""test OAuth2 OpenID Provider flow""" from time import sleep -from django.shortcuts import reverse -from oauth2_provider.generators import generate_client_id, generate_client_secret -from oidc_provider.models import Client, ResponseType from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as ec @@ -12,18 +9,33 @@ from structlog import get_logger from docker import DockerClient, from_env from docker.models.containers import Container from docker.types import Healthcheck -from e2e.utils import USER, SeleniumTestCase, ensure_rsa_key +from e2e.utils import USER, SeleniumTestCase from passbook.core.models import Application +from passbook.crypto.models import CertificateKeyPair from passbook.flows.models import Flow from passbook.policies.expression.models import ExpressionPolicy from passbook.policies.models import PolicyBinding -from passbook.providers.oidc.models import OpenIDProvider +from passbook.providers.oauth2.constants import ( + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, +) +from passbook.providers.oauth2.generators import ( + generate_client_id, + generate_client_secret, +) +from passbook.providers.oauth2.models import ( + ClientTypes, + OAuth2Provider, + ResponseTypes, + ScopeMapping, +) LOGGER = get_logger() -class TestProviderOIDC(SeleniumTestCase): - """test OpenID Provider flow""" +class TestProviderOAuth2OIDC(SeleniumTestCase): + """test OAuth with OpenID Provider flow""" def setUp(self): self.client_id = generate_client_id() @@ -50,13 +62,13 @@ class TestProviderOIDC(SeleniumTestCase): "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET": self.client_secret, "GF_AUTH_GENERIC_OAUTH_SCOPES": "openid email profile", "GF_AUTH_GENERIC_OAUTH_AUTH_URL": ( - self.live_server_url + reverse("passbook_providers_oidc:authorize") + self.url("passbook_providers_oauth2:authorize") ), "GF_AUTH_GENERIC_OAUTH_TOKEN_URL": ( - self.live_server_url + reverse("oidc_provider:token") + self.url("passbook_providers_oauth2:token") ), "GF_AUTH_GENERIC_OAUTH_API_URL": ( - self.live_server_url + reverse("oidc_provider:userinfo") + self.url("passbook_providers_oauth2:userinfo") ), "GF_LOG_LEVEL": "debug", }, @@ -80,23 +92,22 @@ class TestProviderOIDC(SeleniumTestCase): authorization_flow = Flow.objects.get( slug="default-provider-authorization-implicit-consent" ) - client = Client.objects.create( + provider = OAuth2Provider.objects.create( name="grafana", - client_type="confidential", + client_type=ClientTypes.CONFIDENTIAL, client_id=self.client_id, client_secret=self.client_secret, - _redirect_uris="http://localhost:3000/", - _scope="openid userinfo", + rsa_key=CertificateKeyPair.objects.first(), + redirect_uris="http://localhost:3000/", + authorization_flow=authorization_flow, + response_type=ResponseTypes.CODE, ) - # At least one of these objects must exist - ensure_rsa_key() - # This response_code object might exist or not, depending on the order the tests are run - rp_type, _ = ResponseType.objects.get_or_create(value="code") - client.response_types.set([rp_type]) - client.save() - provider = OpenIDProvider.objects.create( - oidc_client=client, authorization_flow=authorization_flow, + provider.property_mappings.set( + ScopeMapping.objects.filter( + scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + ) ) + provider.save() Application.objects.create( name="Grafana", slug="grafana", provider=provider, ) @@ -121,25 +132,22 @@ class TestProviderOIDC(SeleniumTestCase): authorization_flow = Flow.objects.get( slug="default-provider-authorization-implicit-consent" ) - client = Client.objects.create( + provider = OAuth2Provider.objects.create( name="grafana", - client_type="confidential", + client_type=ClientTypes.CONFIDENTIAL, client_id=self.client_id, client_secret=self.client_secret, - _redirect_uris="http://localhost:3000/login/generic_oauth", - _scope="openid profile email", - reuse_consent=False, - require_consent=False, + rsa_key=CertificateKeyPair.objects.first(), + redirect_uris="http://localhost:3000/login/generic_oauth", + authorization_flow=authorization_flow, + response_type=ResponseTypes.CODE, ) - # At least one of these objects must exist - ensure_rsa_key() - # This response_code object might exist or not, depending on the order the tests are run - rp_type, _ = ResponseType.objects.get_or_create(value="code") - client.response_types.set([rp_type]) - client.save() - provider = OpenIDProvider.objects.create( - oidc_client=client, authorization_flow=authorization_flow, + provider.property_mappings.set( + ScopeMapping.objects.filter( + scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + ) ) + provider.save() Application.objects.create( name="Grafana", slug="grafana", provider=provider, ) @@ -182,25 +190,22 @@ class TestProviderOIDC(SeleniumTestCase): authorization_flow = Flow.objects.get( slug="default-provider-authorization-explicit-consent" ) - client = Client.objects.create( + provider = OAuth2Provider.objects.create( name="grafana", - client_type="confidential", + authorization_flow=authorization_flow, + response_type=ResponseTypes.CODE, + client_type=ClientTypes.CONFIDENTIAL, client_id=self.client_id, client_secret=self.client_secret, - _redirect_uris="http://localhost:3000/login/generic_oauth", - _scope="openid profile email", - reuse_consent=False, - require_consent=False, + rsa_key=CertificateKeyPair.objects.first(), + redirect_uris="http://localhost:3000/login/generic_oauth", ) - # At least one of these objects must exist - ensure_rsa_key() - # This response_code object might exist or not, depending on the order the tests are run - rp_type, _ = ResponseType.objects.get_or_create(value="code") - client.response_types.set([rp_type]) - client.save() - provider = OpenIDProvider.objects.create( - oidc_client=client, authorization_flow=authorization_flow, + provider.property_mappings.set( + ScopeMapping.objects.filter( + scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + ) ) + provider.save() app = Application.objects.create( name="Grafana", slug="grafana", provider=provider, ) @@ -261,25 +266,22 @@ class TestProviderOIDC(SeleniumTestCase): authorization_flow = Flow.objects.get( slug="default-provider-authorization-explicit-consent" ) - client = Client.objects.create( + provider = OAuth2Provider.objects.create( name="grafana", - client_type="confidential", + authorization_flow=authorization_flow, + response_type=ResponseTypes.CODE, + client_type=ClientTypes.CONFIDENTIAL, client_id=self.client_id, client_secret=self.client_secret, - _redirect_uris="http://localhost:3000/login/generic_oauth", - _scope="openid profile email", - reuse_consent=False, - require_consent=False, + rsa_key=CertificateKeyPair.objects.first(), + redirect_uris="http://localhost:3000/login/generic_oauth", ) - # At least one of these objects must exist - ensure_rsa_key() - # This response_code object might exist or not, depending on the order the tests are run - rp_type, _ = ResponseType.objects.get_or_create(value="code") - client.response_types.set([rp_type]) - client.save() - provider = OpenIDProvider.objects.create( - oidc_client=client, authorization_flow=authorization_flow, + provider.property_mappings.set( + ScopeMapping.objects.filter( + scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + ) ) + provider.save() app = Application.objects.create( name="Grafana", slug="grafana", provider=provider, ) diff --git a/e2e/test_sources_oauth.py b/e2e/test_sources_oauth.py index 62f0255cc..01b29e056 100644 --- a/e2e/test_sources_oauth.py +++ b/e2e/test_sources_oauth.py @@ -2,7 +2,6 @@ from os.path import abspath from time import sleep -from oauth2_provider.generators import generate_client_secret from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as ec @@ -14,6 +13,7 @@ from docker.models.containers import Container from docker.types import Healthcheck from e2e.utils import SeleniumTestCase from passbook.flows.models import Flow +from passbook.providers.oauth2.generators import generate_client_secret from passbook.sources.oauth.models import OAuthSource TOKEN_URL = "http://127.0.0.1:5556/dex/token" diff --git a/e2e/utils.py b/e2e/utils.py index a9684f67c..a4db8dd3b 100644 --- a/e2e/utils.py +++ b/e2e/utils.py @@ -6,7 +6,6 @@ from inspect import getmembers, isfunction from os import environ, makedirs from time import time -from Cryptodome.PublicKey import RSA from django.apps import apps from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.db import connection, transaction @@ -28,16 +27,6 @@ def USER() -> User: # noqa return User.objects.get(username="pbadmin") -def ensure_rsa_key(): - """Ensure that at least one RSAKey Object exists, create one if none exist""" - from oidc_provider.models import RSAKey - - if not RSAKey.objects.exists(): - key = RSA.generate(2048) - rsakey = RSAKey(key=key.exportKey("PEM").decode("utf8")) - rsakey.save() - - class SeleniumTestCase(StaticLiveServerTestCase): """StaticLiveServerTestCase which automatically creates a Webdriver instance""" diff --git a/passbook/api/urls.py b/passbook/api/urls.py index a67986f8d..615334c52 100644 --- a/passbook/api/urls.py +++ b/passbook/api/urls.py @@ -1,10 +1,8 @@ """passbook api urls""" from django.urls import include, path -from passbook.api.v1.urls import urlpatterns as v1_urls from passbook.api.v2.urls import urlpatterns as v2_urls urlpatterns = [ - path("v1/", include(v1_urls)), path("v2beta/", include(v2_urls)), ] diff --git a/passbook/api/v1/openid.py b/passbook/api/v1/openid.py deleted file mode 100644 index df6b3181b..000000000 --- a/passbook/api/v1/openid.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Passbook v1 OpenID API""" -from django.http import JsonResponse -from django.views import View -from oauth2_provider.views.mixins import ScopedResourceMixin - - -class OpenIDUserInfoView(ScopedResourceMixin, View): - """Passbook v1 OpenID API""" - - required_scopes = ["openid:userinfo"] - - def get(self, request, *_, **__): - """Passbook v1 OpenID API""" - payload = { - "sub": request.user.uuid.int, - "name": request.user.get_full_name(), - "given_name": request.user.name, - "family_name": "", - "preferred_username": request.user.username, - "email": request.user.email, - } - return JsonResponse(payload) diff --git a/passbook/api/v1/serializers.py b/passbook/api/v1/serializers.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/passbook/api/v1/urls.py b/passbook/api/v1/urls.py deleted file mode 100644 index 14adc4305..000000000 --- a/passbook/api/v1/urls.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Passbook API URLs""" -from django.urls import path - -from passbook.api.v1.openid import OpenIDUserInfoView - -urlpatterns = [path("openid/", OpenIDUserInfoView.as_view(), name="openid")] diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py index 3035c7bf9..1fa630d22 100644 --- a/passbook/api/v2/urls.py +++ b/passbook/api/v2/urls.py @@ -23,9 +23,8 @@ from passbook.policies.group_membership.api import GroupMembershipPolicyViewSet from passbook.policies.hibp.api import HaveIBeenPwendPolicyViewSet from passbook.policies.password.api import PasswordPolicyViewSet from passbook.policies.reputation.api import ReputationPolicyViewSet -from passbook.providers.app_gw.api import ApplicationGatewayProviderViewSet -from passbook.providers.oauth.api import OAuth2ProviderViewSet -from passbook.providers.oidc.api import OpenIDProviderViewSet +from passbook.providers.oauth2.api import OAuth2ProviderViewSet, ScopeMappingViewSet +from passbook.providers.proxy.api import ProxyProviderViewSet from passbook.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet from passbook.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet from passbook.sources.oauth.api import OAuthSourceViewSet @@ -70,14 +69,14 @@ router.register("policies/password", PasswordPolicyViewSet) router.register("policies/reputation", ReputationPolicyViewSet) router.register("providers/all", ProviderViewSet) -router.register("providers/applicationgateway", ApplicationGatewayProviderViewSet) -router.register("providers/oauth", OAuth2ProviderViewSet) -router.register("providers/openid", OpenIDProviderViewSet) +router.register("providers/proxy", ProxyProviderViewSet) +router.register("providers/oauth2", OAuth2ProviderViewSet) router.register("providers/saml", SAMLProviderViewSet) router.register("propertymappings/all", PropertyMappingViewSet) router.register("propertymappings/ldap", LDAPPropertyMappingViewSet) router.register("propertymappings/saml", SAMLPropertyMappingViewSet) +router.register("propertymappings/scope", ScopeMappingViewSet) router.register("stages/all", StageViewSet) router.register("stages/captcha", CaptchaStageViewSet) diff --git a/passbook/core/admin.py b/passbook/core/admin.py index d32203090..023981121 100644 --- a/passbook/core/admin.py +++ b/passbook/core/admin.py @@ -18,7 +18,7 @@ def admin_autoregister(app: AppConfig): pass -for app in apps.get_app_configs(): - if app.label.startswith("passbook_"): - LOGGER.debug("Registering application for dj-admin", app=app.label) - admin_autoregister(app) +for _app in apps.get_app_configs(): + if _app.label.startswith("passbook_"): + LOGGER.debug("Registering application for dj-admin", app=_app.label) + admin_autoregister(_app) diff --git a/passbook/core/templates/error/generic.html b/passbook/core/templates/error/generic.html index 2e61ea09e..30b7e9386 100644 --- a/passbook/core/templates/error/generic.html +++ b/passbook/core/templates/error/generic.html @@ -5,7 +5,7 @@ {% load passbook_utils %} {% block title %} -{% trans 'Bad Request' %} +{% trans card_title %} {% endblock %} {% block card %} diff --git a/passbook/crypto/models.py b/passbook/crypto/models.py index abcbec7df..72c4e42e3 100644 --- a/passbook/crypto/models.py +++ b/passbook/crypto/models.py @@ -1,11 +1,12 @@ """passbook crypto models""" from binascii import hexlify +from hashlib import md5 from typing import Optional from uuid import uuid4 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.x509 import Certificate, load_pem_x509_certificate from django.db import models @@ -31,7 +32,8 @@ class CertificateKeyPair(CreatedUpdatedModel): ) _cert: Optional[Certificate] = None - _key: Optional[RSAPrivateKey] = None + _private_key: Optional[RSAPrivateKey] = None + _public_key: Optional[RSAPublicKey] = None @property def certificate(self) -> Certificate: @@ -42,16 +44,23 @@ class CertificateKeyPair(CreatedUpdatedModel): ) return self._cert + @property + def public_key(self) -> Optional[RSAPublicKey]: + """Get public key of the private key""" + if not self._public_key: + self._public_key = self.private_key.public_key() + return self._public_key + @property def private_key(self) -> Optional[RSAPrivateKey]: """Get python cryptography PrivateKey instance""" - if not self._key: - self._key = load_pem_private_key( + if not self._private_key: + self._private_key = load_pem_private_key( str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])), password=None, backend=default_backend(), ) - return self._key + return self._private_key @property def fingerprint(self) -> str: @@ -60,6 +69,15 @@ class CertificateKeyPair(CreatedUpdatedModel): "utf-8" ) + @property + def kid(self): + """Get Key ID used for JWKS""" + return "{0}".format( + md5(self.key_data.encode("utf-8")).hexdigest() # nosec + if self.key_data + else "" + ) + def __str__(self) -> str: return f"Certificate-Key Pair {self.name} {self.fingerprint}" diff --git a/passbook/flows/models.py b/passbook/flows/models.py index 06338e596..a4da0fb53 100644 --- a/passbook/flows/models.py +++ b/passbook/flows/models.py @@ -66,6 +66,8 @@ class Stage(models.Model): return None def __str__(self): + if hasattr(self, "__in_memory_type"): + return f"In-memory Stage {getattr(self, '__in_memory_type')}" return f"Stage {self.name}" diff --git a/passbook/flows/templates/flows/error.html b/passbook/flows/templates/flows/error.html index 9f549077c..8b00ac92b 100644 --- a/passbook/flows/templates/flows/error.html +++ b/passbook/flows/templates/flows/error.html @@ -17,6 +17,6 @@ {% trans 'Something went wrong! Please try again later.' %} {% if debug %} -
{{ tb }}
+
{{ tb }}{{ error }}
{% endif %} diff --git a/passbook/lib/templatetags/passbook_utils.py b/passbook/lib/templatetags/passbook_utils.py index 6acca985b..2effddc1c 100644 --- a/passbook/lib/templatetags/passbook_utils.py +++ b/passbook/lib/templatetags/passbook_utils.py @@ -85,7 +85,6 @@ def verbose_name(obj) -> str: if not obj: return "" if hasattr(obj, "verbose_name"): - print(obj.verbose_name) return obj.verbose_name return obj._meta.verbose_name diff --git a/passbook/lib/views.py b/passbook/lib/views.py index c816b70fe..a82ec9e84 100644 --- a/passbook/lib/views.py +++ b/passbook/lib/views.py @@ -26,11 +26,13 @@ class CreateAssignPermView(CreateView): return response -def bad_request_message(request: HttpRequest, message: str) -> TemplateResponse: +def bad_request_message( + request: HttpRequest, message: str, title="Bad Request" +) -> TemplateResponse: """Return generic error page with message, with status code set to 400""" return TemplateResponse( request, "error/generic.html", - {"message": message, "card_title": _("Bad Request")}, + {"message": message, "card_title": _(title)}, status=400, ) diff --git a/passbook/policies/hibp/tests.py b/passbook/policies/hibp/tests.py index 6c70256d1..0e79ac3fe 100644 --- a/passbook/policies/hibp/tests.py +++ b/passbook/policies/hibp/tests.py @@ -1,10 +1,10 @@ """HIBP Policy tests""" from django.test import TestCase from guardian.shortcuts import get_anonymous_user -from oauth2_provider.generators import generate_client_secret from passbook.policies.hibp.models import HaveIBeenPwendPolicy from passbook.policies.types import PolicyRequest, PolicyResult +from passbook.providers.oauth2.generators import generate_client_secret class TestHIBPPolicy(TestCase): diff --git a/passbook/providers/app_gw/api.py b/passbook/providers/app_gw/api.py deleted file mode 100644 index c4694f8b9..000000000 --- a/passbook/providers/app_gw/api.py +++ /dev/null @@ -1,45 +0,0 @@ -"""ApplicationGatewayProvider API Views""" -from oauth2_provider.generators import generate_client_id, generate_client_secret -from oidc_provider.models import Client -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.providers.app_gw.models import ApplicationGatewayProvider -from passbook.providers.oidc.api import OpenIDProviderSerializer - - -class ApplicationGatewayProviderSerializer(ModelSerializer): - """ApplicationGatewayProvider Serializer""" - - client = OpenIDProviderSerializer() - - def create(self, validated_data): - instance = super().create(validated_data) - instance.client = Client.objects.create( - client_id=generate_client_id(), client_secret=generate_client_secret() - ) - instance.save() - return instance - - def update(self, instance, validated_data): - self.instance.client.name = self.instance.name - self.instance.client.redirect_uris = [ - f"http://{self.instance.host}/oauth2/callback", - f"https://{self.instance.host}/oauth2/callback", - ] - self.instance.client.scope = ["openid", "email"] - self.instance.client.save() - return super().update(instance, validated_data) - - class Meta: - - model = ApplicationGatewayProvider - fields = ["pk", "name", "internal_host", "external_host", "client"] - read_only_fields = ["client"] - - -class ApplicationGatewayProviderViewSet(ModelViewSet): - """ApplicationGatewayProvider Viewset""" - - queryset = ApplicationGatewayProvider.objects.all() - serializer_class = ApplicationGatewayProviderSerializer diff --git a/passbook/providers/app_gw/apps.py b/passbook/providers/app_gw/apps.py deleted file mode 100644 index a1702dcf9..000000000 --- a/passbook/providers/app_gw/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""passbook Application Security Gateway app""" -from django.apps import AppConfig - - -class PassbookApplicationApplicationGatewayConfig(AppConfig): - """passbook app_gw app""" - - name = "passbook.providers.app_gw" - label = "passbook_providers_app_gw" - verbose_name = "passbook Providers.Application Security Gateway" - mountpoint = "application/gateway/" diff --git a/passbook/providers/app_gw/forms.py b/passbook/providers/app_gw/forms.py deleted file mode 100644 index a523d78d1..000000000 --- a/passbook/providers/app_gw/forms.py +++ /dev/null @@ -1,40 +0,0 @@ -"""passbook Application Security Gateway Forms""" -from django import forms -from oauth2_provider.generators import generate_client_id, generate_client_secret -from oidc_provider.models import Client, ResponseType - -from passbook.providers.app_gw.models import ApplicationGatewayProvider - - -class ApplicationGatewayProviderForm(forms.ModelForm): - """Security Gateway Provider form""" - - def save(self, *args, **kwargs): - if not self.instance.pk: - # New instance, so we create a new OIDC client with random keys - self.instance.client = Client.objects.create( - client_id=generate_client_id(), client_secret=generate_client_secret() - ) - self.instance.client.reuse_consent = False # This is managed by passbook - self.instance.client.require_consent = False # This is managed by passbook - self.instance.client.name = self.instance.name - self.instance.client.response_types.set( - [ResponseType.objects.get_by_natural_key("code")] - ) - self.instance.client.redirect_uris = [ - f"{self.instance.external_host}/oauth2/callback", - f"{self.instance.internal_host}/oauth2/callback", - ] - self.instance.client.scope = ["openid", "email", "profile"] - self.instance.client.save() - return super().save(*args, **kwargs) - - class Meta: - - model = ApplicationGatewayProvider - fields = ["name", "authorization_flow", "internal_host", "external_host"] - widgets = { - "name": forms.TextInput(), - "internal_host": forms.TextInput(), - "external_host": forms.TextInput(), - } diff --git a/passbook/providers/app_gw/migrations/0001_initial.py b/passbook/providers/app_gw/migrations/0001_initial.py deleted file mode 100644 index a5c6d746d..000000000 --- a/passbook/providers/app_gw/migrations/0001_initial.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("oidc_provider", "0026_client_multiple_response_types"), - ("passbook_core", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="ApplicationGatewayProvider", - fields=[ - ( - "provider_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_core.Provider", - ), - ), - ("name", models.TextField()), - ("internal_host", models.TextField()), - ("external_host", models.TextField()), - ( - "client", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="oidc_provider.Client", - ), - ), - ], - options={ - "verbose_name": "Application Gateway Provider", - "verbose_name_plural": "Application Gateway Providers", - }, - bases=("passbook_core.provider",), - ), - ] diff --git a/passbook/providers/app_gw/migrations/0002_auto_20200726_1745.py b/passbook/providers/app_gw/migrations/0002_auto_20200726_1745.py deleted file mode 100644 index 09816b0dc..000000000 --- a/passbook/providers/app_gw/migrations/0002_auto_20200726_1745.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.0.8 on 2020-07-26 17:45 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_app_gw", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="applicationgatewayprovider", - name="external_host", - field=models.TextField(validators=[django.core.validators.URLValidator]), - ), - migrations.AlterField( - model_name="applicationgatewayprovider", - name="internal_host", - field=models.TextField(validators=[django.core.validators.URLValidator]), - ), - ] diff --git a/passbook/providers/app_gw/migrations/0003_auto_20200801_1752.py b/passbook/providers/app_gw/migrations/0003_auto_20200801_1752.py deleted file mode 100644 index 60bf0431f..000000000 --- a/passbook/providers/app_gw/migrations/0003_auto_20200801_1752.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 3.0.8 on 2020-08-01 17:52 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_app_gw", "0002_auto_20200726_1745"), - ] - - operations = [ - migrations.AlterField( - model_name="applicationgatewayprovider", - name="external_host", - field=models.TextField( - validators=[ - django.core.validators.URLValidator(schemes=("http", "https")) - ] - ), - ), - migrations.AlterField( - model_name="applicationgatewayprovider", - name="internal_host", - field=models.TextField( - validators=[ - django.core.validators.URLValidator(schemes=("http", "https")) - ] - ), - ), - ] diff --git a/passbook/providers/app_gw/models.py b/passbook/providers/app_gw/models.py deleted file mode 100644 index 4c6836bf0..000000000 --- a/passbook/providers/app_gw/models.py +++ /dev/null @@ -1,50 +0,0 @@ -"""passbook app_gw models""" -from typing import Optional, Type - -from django.core.validators import URLValidator -from django.db import models -from django.forms import ModelForm -from django.http import HttpRequest -from django.utils.translation import gettext as _ -from oidc_provider.models import Client - -from passbook.core.models import Provider -from passbook.lib.utils.template import render_to_string - - -class ApplicationGatewayProvider(Provider): - """Protect applications that don't support any of the other - Protocols by using a Reverse-Proxy.""" - - name = models.TextField() - internal_host = models.TextField( - validators=[URLValidator(schemes=("http", "https"))] - ) - external_host = models.TextField( - validators=[URLValidator(schemes=("http", "https"))] - ) - - client = models.ForeignKey(Client, on_delete=models.CASCADE) - - def form(self) -> Type[ModelForm]: - from passbook.providers.app_gw.forms import ApplicationGatewayProviderForm - - return ApplicationGatewayProviderForm - - def html_setup_urls(self, request: HttpRequest) -> Optional[str]: - """return template and context modal with URLs for authorize, token, openid-config, etc""" - from passbook.providers.app_gw.views import DockerComposeView - - docker_compose_yaml = DockerComposeView(request=request).get_compose(self) - return render_to_string( - "app_gw/setup_modal.html", - {"provider": self, "docker_compose": docker_compose_yaml}, - ) - - def __str__(self): - return self.name - - class Meta: - - verbose_name = _("Application Gateway Provider") - verbose_name_plural = _("Application Gateway Providers") diff --git a/passbook/providers/oauth/api.py b/passbook/providers/oauth/api.py deleted file mode 100644 index 9647b88d3..000000000 --- a/passbook/providers/oauth/api.py +++ /dev/null @@ -1,29 +0,0 @@ -"""OAuth2Provider API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.providers.oauth.models import OAuth2Provider - - -class OAuth2ProviderSerializer(ModelSerializer): - """OAuth2Provider Serializer""" - - class Meta: - - model = OAuth2Provider - fields = [ - "pk", - "name", - "redirect_uris", - "client_type", - "authorization_grant_type", - "client_id", - "client_secret", - ] - - -class OAuth2ProviderViewSet(ModelViewSet): - """OAuth2Provider Viewset""" - - queryset = OAuth2Provider.objects.all() - serializer_class = OAuth2ProviderSerializer diff --git a/passbook/providers/oauth/apps.py b/passbook/providers/oauth/apps.py deleted file mode 100644 index b3f6ccdda..000000000 --- a/passbook/providers/oauth/apps.py +++ /dev/null @@ -1,12 +0,0 @@ -"""passbook auth oauth provider app config""" - -from django.apps import AppConfig - - -class PassbookProviderOAuthConfig(AppConfig): - """passbook auth oauth provider app config""" - - name = "passbook.providers.oauth" - label = "passbook_providers_oauth" - verbose_name = "passbook Providers.OAuth" - mountpoint = "" diff --git a/passbook/providers/oauth/forms.py b/passbook/providers/oauth/forms.py deleted file mode 100644 index 8c470ef08..000000000 --- a/passbook/providers/oauth/forms.py +++ /dev/null @@ -1,34 +0,0 @@ -"""passbook OAuth2 Provider Forms""" - -from django import forms -from django.utils.translation import gettext_lazy as _ - -from passbook.flows.models import Flow, FlowDesignation -from passbook.providers.oauth.models import OAuth2Provider - - -class OAuth2ProviderForm(forms.ModelForm): - """OAuth2 Provider form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["authorization_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.AUTHORIZATION - ) - - class Meta: - - model = OAuth2Provider - fields = [ - "name", - "authorization_flow", - "redirect_uris", - "client_type", - "authorization_grant_type", - "client_id", - "client_secret", - ] - labels = { - "client_id": _("Client ID"), - "redirect_uris": _("Redirect URIs"), - } diff --git a/passbook/providers/oauth/migrations/0001_initial.py b/passbook/providers/oauth/migrations/0001_initial.py deleted file mode 100644 index 7c3e7d1c3..000000000 --- a/passbook/providers/oauth/migrations/0001_initial.py +++ /dev/null @@ -1,104 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -import oauth2_provider.generators -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - run_before = [ - ("oauth2_provider", "0001_initial"), - ] - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("passbook_core", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="OAuth2Provider", - fields=[ - ( - "provider_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_core.Provider", - ), - ), - ( - "client_id", - models.CharField( - db_index=True, - default=oauth2_provider.generators.generate_client_id, - max_length=100, - unique=True, - ), - ), - ( - "redirect_uris", - models.TextField( - blank=True, help_text="Allowed URIs list, space separated" - ), - ), - ( - "client_type", - models.CharField( - choices=[ - ("confidential", "Confidential"), - ("public", "Public"), - ], - max_length=32, - ), - ), - ( - "authorization_grant_type", - models.CharField( - choices=[ - ("authorization-code", "Authorization code"), - ("implicit", "Implicit"), - ("password", "Resource owner password-based"), - ("client-credentials", "Client credentials"), - ], - max_length=32, - ), - ), - ( - "client_secret", - models.CharField( - blank=True, - db_index=True, - default=oauth2_provider.generators.generate_client_secret, - max_length=255, - ), - ), - ("name", models.CharField(blank=True, max_length=255)), - ("skip_authorization", models.BooleanField(default=False)), - ("created", models.DateTimeField(auto_now_add=True)), - ("updated", models.DateTimeField(auto_now=True)), - ( - "user", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="passbook_providers_oauth_oauth2provider", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "verbose_name": "OAuth2 Provider", - "verbose_name_plural": "OAuth2 Providers", - }, - bases=("passbook_core.provider", models.Model), - ), - ] diff --git a/passbook/providers/oauth/models.py b/passbook/providers/oauth/models.py deleted file mode 100644 index f1a9ad522..000000000 --- a/passbook/providers/oauth/models.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Oauth2 provider product extension""" - -from typing import Optional, Type - -from django.forms import ModelForm -from django.http import HttpRequest -from django.shortcuts import reverse -from django.utils.translation import gettext as _ -from oauth2_provider.models import AbstractApplication - -from passbook.core.models import Provider -from passbook.lib.utils.template import render_to_string - - -class OAuth2Provider(Provider, AbstractApplication): - """Generic OAuth2 Provider for applications not using OpenID-Connect. - This Provider also supports the GitHub-pretend mode for Applications that don't support - generic OAuth.""" - - def form(self) -> Type[ModelForm]: - from passbook.providers.oauth.forms import OAuth2ProviderForm - - return OAuth2ProviderForm - - def __str__(self): - return self.name - - def html_setup_urls(self, request: HttpRequest) -> Optional[str]: - """return template and context modal with URLs for authorize, token, openid-config, etc""" - return render_to_string( - "providers/oauth/setup_url_modal.html", - { - "provider": self, - "authorize_url": request.build_absolute_uri( - reverse("passbook_providers_oauth:oauth2-authorize") - ), - "token_url": request.build_absolute_uri( - reverse("passbook_providers_oauth:token") - ), - "userinfo_url": request.build_absolute_uri( - reverse("passbook_api:openid") - ), - }, - ) - - class Meta: - - verbose_name = _("OAuth2 Provider") - verbose_name_plural = _("OAuth2 Providers") diff --git a/passbook/providers/oauth/settings.py b/passbook/providers/oauth/settings.py deleted file mode 100644 index 925787a30..000000000 --- a/passbook/providers/oauth/settings.py +++ /dev/null @@ -1,31 +0,0 @@ -"""passbook OAuth_Provider""" -from django.conf import settings - -CORS_ORIGIN_ALLOW_ALL = settings.DEBUG - -REQUEST_APPROVAL_PROMPT = "auto" - -INSTALLED_APPS = [ - "oauth2_provider", - "corsheaders", -] -MIDDLEWARE = [ - "oauth2_provider.middleware.OAuth2TokenMiddleware", - "corsheaders.middleware.CorsMiddleware", -] -AUTHENTICATION_BACKENDS = [ - "oauth2_provider.backends.OAuth2Backend", -] - -OAUTH2_PROVIDER_APPLICATION_MODEL = "passbook_providers_oauth.OAuth2Provider" - -OAUTH2_PROVIDER = { - # this is the list of available scopes - "SCOPES": { - "openid": "Access OpenID Userinfo", - "userinfo": "Access OpenID Userinfo", - "email": "Access OpenID Email", - "user:email": "GitHub Compatibility: User Email", - "read:org": "GitHub Compatibility: User Groups", - } -} diff --git a/passbook/providers/oauth/templates/providers/oauth/setup_url_modal.html b/passbook/providers/oauth/templates/providers/oauth/setup_url_modal.html deleted file mode 100644 index 26419ba45..000000000 --- a/passbook/providers/oauth/templates/providers/oauth/setup_url_modal.html +++ /dev/null @@ -1,40 +0,0 @@ -{% load i18n %} - - - diff --git a/passbook/providers/oauth/urls.py b/passbook/providers/oauth/urls.py deleted file mode 100644 index 4da8b3a07..000000000 --- a/passbook/providers/oauth/urls.py +++ /dev/null @@ -1,39 +0,0 @@ -"""passbook oauth_provider urls""" - -from django.urls import include, path -from oauth2_provider import views - -from passbook.providers.oauth.views import github, oauth2 - -oauth_urlpatterns = [ - # Custom OAuth2 Authorize View - path( - "authorize/", - oauth2.AuthorizationFlowInitView.as_view(), - name="oauth2-authorize", - ), - # OAuth API - path("token/", views.TokenView.as_view(), name="token"), - path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"), - path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"), -] - -github_urlpatterns = [ - path( - "login/oauth/authorize", - oauth2.AuthorizationFlowInitView.as_view(), - name="github-authorize", - ), - path( - "login/oauth/access_token", - views.TokenView.as_view(), - name="github-access-token", - ), - path("user", github.GitHubUserView.as_view(), name="github-user"), - path("user/teams", github.GitHubUserTeamsView.as_view(), name="github-user-teams"), -] - -urlpatterns = [ - path("", include(github_urlpatterns)), - path("application/oauth/", include(oauth_urlpatterns)), -] diff --git a/passbook/providers/oauth/views/__init__.py b/passbook/providers/oauth/views/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/passbook/providers/oauth/views/oauth2.py b/passbook/providers/oauth/views/oauth2.py deleted file mode 100644 index 7186c2dc1..000000000 --- a/passbook/providers/oauth/views/oauth2.py +++ /dev/null @@ -1,136 +0,0 @@ -"""passbook OAuth2 Views""" -from django.http import HttpRequest, HttpResponse, HttpResponseRedirect -from django.shortcuts import get_object_or_404 -from django.views import View -from oauth2_provider.exceptions import OAuthToolkitError -from oauth2_provider.scopes import get_scopes_backend -from oauth2_provider.views.base import AuthorizationView -from structlog import get_logger - -from passbook.audit.models import Event, EventAction -from passbook.core.models import Application -from passbook.flows.models import in_memory_stage -from passbook.flows.planner import ( - PLAN_CONTEXT_APPLICATION, - PLAN_CONTEXT_SSO, - FlowPlanner, -) -from passbook.flows.stage import StageView -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.lib.utils.urls import redirect_with_qs -from passbook.policies.mixins import PolicyAccessMixin -from passbook.providers.oauth.models import OAuth2Provider -from passbook.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE - -LOGGER = get_logger() - -PLAN_CONTEXT_CLIENT_ID = "client_id" -PLAN_CONTEXT_REDIRECT_URI = "redirect_uri" -PLAN_CONTEXT_RESPONSE_TYPE = "response_type" -PLAN_CONTEXT_STATE = "state" - -PLAN_CONTEXT_CODE_CHALLENGE = "code_challenge" -PLAN_CONTEXT_CODE_CHALLENGE_METHOD = "code_challenge_method" -PLAN_CONTEXT_SCOPE = "scope" -PLAN_CONTEXT_NONCE = "nonce" -PLAN_CONTEXT_SCOPE_DESCRIPTION = "scope_descriptions" - - -class AuthorizationFlowInitView(PolicyAccessMixin, View): - """OAuth2 Flow initializer, checks access to application and starts flow""" - - # pylint: disable=unused-argument - def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - """Check access to application, start FlowPLanner, return to flow executor shell""" - client_id = request.GET.get("client_id") - provider = get_object_or_404(OAuth2Provider, client_id=client_id) - try: - application = self.provider_to_application(provider) - except Application.DoesNotExist: - return self.handle_no_permission_authorized() - # Check if user is unauthenticated, so we pass the application - # for the identification stage - if not request.user.is_authenticated: - return self.handle_no_permission(application) - # Check permissions - result = self.user_has_access(application) - if not result.passing: - return self.handle_no_permission_authorized() - # Regardless, we start the planner and return to it - planner = FlowPlanner(provider.authorization_flow) - planner.allow_empty_flows = True - # Save scope descriptions - scopes = request.GET.get(PLAN_CONTEXT_SCOPE) - all_scopes = get_scopes_backend().get_all_scopes() - - plan = planner.plan( - self.request, - { - PLAN_CONTEXT_SSO: True, - PLAN_CONTEXT_APPLICATION: application, - PLAN_CONTEXT_CLIENT_ID: client_id, - PLAN_CONTEXT_REDIRECT_URI: request.GET.get(PLAN_CONTEXT_REDIRECT_URI), - PLAN_CONTEXT_RESPONSE_TYPE: request.GET.get(PLAN_CONTEXT_RESPONSE_TYPE), - PLAN_CONTEXT_STATE: request.GET.get(PLAN_CONTEXT_STATE), - PLAN_CONTEXT_SCOPE: scopes, - PLAN_CONTEXT_NONCE: request.GET.get(PLAN_CONTEXT_NONCE), - PLAN_CONTEXT_SCOPE_DESCRIPTION: [ - all_scopes[scope] for scope in scopes.split(" ") - ], - PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oauth/consent.html", - }, - ) - - plan.append(in_memory_stage(OAuth2Stage)) - self.request.session[SESSION_KEY_PLAN] = plan - return redirect_with_qs( - "passbook_flows:flow-executor-shell", - self.request.GET, - flow_slug=provider.authorization_flow.slug, - ) - - -class OAuth2Stage(AuthorizationView, StageView): - """OAuth2 Stage, dynamically injected into the plan""" - - def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - """Last stage in flow, finalizes OAuth Response and redirects to Client""" - application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION] - provider: OAuth2Provider = application.provider - - Event.new( - EventAction.AUTHORIZE_APPLICATION, authorized_application=application, - ).from_http(self.request) - - credentials = { - "client_id": self.executor.plan.context[PLAN_CONTEXT_CLIENT_ID], - "redirect_uri": self.executor.plan.context[PLAN_CONTEXT_REDIRECT_URI], - "response_type": self.executor.plan.context.get( - PLAN_CONTEXT_RESPONSE_TYPE, None - ), - "state": self.executor.plan.context.get(PLAN_CONTEXT_STATE, None), - "nonce": self.executor.plan.context.get(PLAN_CONTEXT_NONCE, None), - } - if self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE, False): - credentials[PLAN_CONTEXT_CODE_CHALLENGE] = self.executor.plan.context.get( - PLAN_CONTEXT_CODE_CHALLENGE - ) - if self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE_METHOD, False): - credentials[ - PLAN_CONTEXT_CODE_CHALLENGE_METHOD - ] = self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE_METHOD) - scopes = self.executor.plan.context.get(PLAN_CONTEXT_SCOPE) - - try: - uri, _headers, _body, _status = self.create_authorization_response( - request=self.request, - scopes=scopes, - credentials=credentials, - allow=True, - ) - LOGGER.debug("Success url for the request: {0}".format(uri)) - except OAuthToolkitError as error: - return self.error_response(error, provider) - - self.executor.stage_ok() - return HttpResponseRedirect(self.redirect(uri, provider).url) diff --git a/passbook/api/v1/__init__.py b/passbook/providers/oauth2/__init__.py similarity index 100% rename from passbook/api/v1/__init__.py rename to passbook/providers/oauth2/__init__.py diff --git a/passbook/providers/oauth2/api.py b/passbook/providers/oauth2/api.py new file mode 100644 index 000000000..a4dad4461 --- /dev/null +++ b/passbook/providers/oauth2/api.py @@ -0,0 +1,50 @@ +"""OAuth2Provider API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from passbook.providers.oauth2.models import OAuth2Provider, ScopeMapping + + +class OAuth2ProviderSerializer(ModelSerializer): + """OAuth2Provider Serializer""" + + class Meta: + + model = OAuth2Provider + fields = [ + "pk", + "name", + "authorization_flow", + "client_type", + "client_id", + "client_secret", + "response_type", + "jwt_alg", + "rsa_key", + "redirect_uris", + "post_logout_redirect_uris", + "property_mappings", + ] + + +class OAuth2ProviderViewSet(ModelViewSet): + """OAuth2Provider Viewset""" + + queryset = OAuth2Provider.objects.all() + serializer_class = OAuth2ProviderSerializer + + +class ScopeMappingSerializer(ModelSerializer): + """ScopeMapping Serializer""" + + class Meta: + + model = ScopeMapping + fields = ["pk", "name", "scope_name", "description", "expression"] + + +class ScopeMappingViewSet(ModelViewSet): + """ScopeMapping Viewset""" + + queryset = ScopeMapping.objects.all() + serializer_class = ScopeMappingSerializer diff --git a/passbook/providers/oauth2/apps.py b/passbook/providers/oauth2/apps.py new file mode 100644 index 000000000..e0cde620c --- /dev/null +++ b/passbook/providers/oauth2/apps.py @@ -0,0 +1,14 @@ +"""passbook auth oauth provider app config""" +from django.apps import AppConfig + + +class PassbookProviderOAuth2Config(AppConfig): + """passbook auth oauth provider app config""" + + name = "passbook.providers.oauth2" + label = "passbook_providers_oauth2" + verbose_name = "passbook Providers.OAuth2" + mountpoints = { + "passbook.providers.oauth2.urls": "application/o/", + "passbook.providers.oauth2.urls_github": "", + } diff --git a/passbook/providers/oauth2/constants.py b/passbook/providers/oauth2/constants.py new file mode 100644 index 000000000..1bd3379a3 --- /dev/null +++ b/passbook/providers/oauth2/constants.py @@ -0,0 +1,19 @@ +"""OAuth/OpenID Constants""" + +GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" +GRANT_TYPE_REFRESH_TOKEN = "refresh_token" +PROMPT_NONE = "none" +PROMPT_CONSNET = "consent" +SCOPE_OPENID = "openid" +SCOPE_OPENID_PROFILE = "profile" +SCOPE_OPENID_EMAIL = "email" +SCOPE_OPENID_INTROSPECTION = "token_introspection" + +# Read/write full user (including email) +SCOPE_GITHUB_USER = "user" +# Read user (without email) +SCOPE_GITHUB_USER_READ = "read:user" +# Read users email addresses +SCOPE_GITHUB_USER_EMAIL = "user:email" +# Read info about teams +SCOPE_GITHUB_ORG_READ = "read:org" diff --git a/passbook/providers/oauth2/errors.py b/passbook/providers/oauth2/errors.py new file mode 100644 index 000000000..4e7be553d --- /dev/null +++ b/passbook/providers/oauth2/errors.py @@ -0,0 +1,178 @@ +"""OAuth errors""" +from urllib.parse import quote + + +class OAuth2Error(Exception): + """Base class for all OAuth2 Errors""" + + error: str + description: str + + def create_dict(self): + """Return error as dict for JSON Rendering""" + return { + "error": self.error, + "error_description": self.description, + } + + def __repr__(self) -> str: + return self.error + + +class RedirectUriError(OAuth2Error): + """The request fails due to a missing, invalid, or mismatching + redirection URI (redirect_uri).""" + + error = "Redirect URI Error" + description = ( + "The request fails due to a missing, invalid, or mismatching" + " redirection URI (redirect_uri)." + ) + + +class ClientIdError(OAuth2Error): + """The client identifier (client_id) is missing or invalid.""" + + error = "Client ID Error" + description = "The client identifier (client_id) is missing or invalid." + + +class UserAuthError(OAuth2Error): + """ + Specific to the Resource Owner Password Credentials flow when + the Resource Owners credentials are not valid. + """ + + error = "access_denied" + description = "The resource owner or authorization server denied the request." + + +class TokenIntrospectionError(OAuth2Error): + """ + Specific to the introspection endpoint. This error will be converted + to an "active: false" response, as per the spec. + See https://tools.ietf.org/html/rfc7662 + """ + + +class AuthorizeError(OAuth2Error): + """General Authorization Errors""" + + _errors = { + # OAuth2 errors. + # https://tools.ietf.org/html/rfc6749#section-4.1.2.1 + "invalid_request": "The request is otherwise malformed", + "unauthorized_client": "The client is not authorized to request an " + "authorization code using this method", + "access_denied": "The resource owner or authorization server denied " + "the request", + "unsupported_response_type": "The authorization server does not " + "support obtaining an authorization code " + "using this method", + "invalid_scope": "The requested scope is invalid, unknown, or " "malformed", + "server_error": "The authorization server encountered an error", + "temporarily_unavailable": "The authorization server is currently " + "unable to handle the request due to a " + "temporary overloading or maintenance of " + "the server", + # OpenID errors. + # http://openid.net/specs/openid-connect-core-1_0.html#AuthError + "interaction_required": "The Authorization Server requires End-User " + "interaction of some form to proceed", + "login_required": "The Authorization Server requires End-User " + "authentication", + "account_selection_required": "The End-User is required to select a " + "session at the Authorization Server", + "consent_required": "The Authorization Server requires End-User" "consent", + "invalid_request_uri": "The request_uri in the Authorization Request " + "returns an error or contains invalid data", + "invalid_request_object": "The request parameter contains an invalid " + "Request Object", + "request_not_supported": "The provider does not support use of the " + "request parameter", + "request_uri_not_supported": "The provider does not support use of the " + "request_uri parameter", + "registration_not_supported": "The provider does not support use of " + "the registration parameter", + } + + def __init__(self, redirect_uri, error, grant_type): + super().__init__() + self.error = error + self.description = self._errors[error] + self.redirect_uri = redirect_uri + self.grant_type = grant_type + + def create_uri(self, redirect_uri: str, state: str) -> str: + """Get a redirect URI with the error message""" + description = quote(str(self.description)) + + # See: + # http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError + hash_or_question = "#" if self.grant_type == "implicit" else "?" + + uri = "{0}{1}error={2}&error_description={3}".format( + redirect_uri, hash_or_question, self.error, description + ) + + # Add state if present. + uri = uri + ("&state={0}".format(state) if state else "") + + return uri + + +class TokenError(OAuth2Error): + """ + OAuth2 token endpoint errors. + https://tools.ietf.org/html/rfc6749#section-5.2 + """ + + _errors = { + "invalid_request": "The request is otherwise malformed", + "invalid_client": "Client authentication failed (e.g., unknown client, " + "no client authentication included, or unsupported " + "authentication method)", + "invalid_grant": "The provided authorization grant or refresh token is " + "invalid, expired, revoked, does not match the " + "redirection URI used in the authorization request, " + "or was issued to another client", + "unauthorized_client": "The authenticated client is not authorized to " + "use this authorization grant type", + "unsupported_grant_type": "The authorization grant type is not " + "supported by the authorization server", + "invalid_scope": "The requested scope is invalid, unknown, malformed, " + "or exceeds the scope granted by the resource owner", + } + + def __init__(self, error): + super().__init__() + self.error = error + self.description = self._errors[error] + + +class BearerTokenError(OAuth2Error): + """ + OAuth2 errors. + https://tools.ietf.org/html/rfc6750#section-3.1 + """ + + _errors = { + "invalid_request": ("The request is otherwise malformed", 400), + "invalid_token": ( + "The access token provided is expired, revoked, malformed, " + "or invalid for other reasons", + 401, + ), + "insufficient_scope": ( + "The request requires higher privileges than provided by " + "the access token", + 403, + ), + } + + def __init__(self, code): + super().__init__() + self.code = code + error_tuple = self._errors.get(code, ("", "")) + self.description = error_tuple[0] + self.status = error_tuple[1] diff --git a/passbook/providers/oauth2/forms.py b/passbook/providers/oauth2/forms.py new file mode 100644 index 000000000..296cc32fa --- /dev/null +++ b/passbook/providers/oauth2/forms.py @@ -0,0 +1,80 @@ +"""passbook OAuth2 Provider Forms""" + +from django import forms +from django.utils.translation import gettext as _ + +from passbook.admin.fields import CodeMirrorWidget +from passbook.core.expression import PropertyMappingEvaluator +from passbook.crypto.models import CertificateKeyPair +from passbook.flows.models import Flow, FlowDesignation +from passbook.providers.oauth2.generators import ( + generate_client_id, + generate_client_secret, +) +from passbook.providers.oauth2.models import OAuth2Provider, ScopeMapping + + +class OAuth2ProviderForm(forms.ModelForm): + """OAuth2 Provider form""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["authorization_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.AUTHORIZATION + ) + self.fields["client_id"].initial = generate_client_id() + self.fields["client_secret"].initial = generate_client_secret() + self.fields["rsa_key"].queryset = CertificateKeyPair.objects.exclude( + key_data__exact="" + ) + self.fields["property_mappings"].queryset = ScopeMapping.objects.all() + + class Meta: + model = OAuth2Provider + fields = [ + "name", + "authorization_flow", + "client_type", + "client_id", + "client_secret", + "response_type", + "jwt_alg", + "rsa_key", + "redirect_uris", + "post_logout_redirect_uris", + "property_mappings", + ] + widgets = { + "name": forms.TextInput(), + } + labels = {"property_mappings": _("Scopes")} + help_texts = { + "property_mappings": _( + ( + "Select which scopes can be used by the client. " + "The client stil has to specify the scope to access the data." + ) + ) + } + + +class ScopeMappingForm(forms.ModelForm): + """Form to edit ScopeMappings""" + + def clean_expression(self): + """Test Syntax""" + expression = self.cleaned_data.get("expression") + evaluator = PropertyMappingEvaluator() + evaluator.validate(expression) + return expression + + class Meta: + + model = ScopeMapping + fields = ["name", "scope_name", "description", "expression"] + widgets = { + "name": forms.TextInput(), + "scope_name": forms.TextInput(), + "description": forms.TextInput(), + "expression": CodeMirrorWidget(mode="python"), + } diff --git a/passbook/providers/oauth2/generators.py b/passbook/providers/oauth2/generators.py new file mode 100644 index 000000000..57df54ea8 --- /dev/null +++ b/passbook/providers/oauth2/generators.py @@ -0,0 +1,17 @@ +"""OAuth2 Client ID/Secret Generators""" +import string +from random import SystemRandom + + +def generate_client_id(): + """Generate a random client ID""" + rand = SystemRandom() + return "".join(rand.choice(string.ascii_letters + string.digits) for x in range(40)) + + +def generate_client_secret(): + """Generate a suitable client secret""" + rand = SystemRandom() + return "".join( + rand.choice(string.ascii_letters + string.digits) for x in range(128) + ) diff --git a/passbook/providers/oauth2/migrations/0001_initial.py b/passbook/providers/oauth2/migrations/0001_initial.py new file mode 100644 index 000000000..0fca333d9 --- /dev/null +++ b/passbook/providers/oauth2/migrations/0001_initial.py @@ -0,0 +1,357 @@ +# Generated by Django 3.1 on 2020-08-18 15:59 + +import django.db.models.deletion +from django.apps.registry import Apps +from django.conf import settings +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +import passbook.core.models +import passbook.lib.utils.time +import passbook.providers.oauth2.generators + +SCOPE_OPENID_EXPRESSION = """# This is only required for OpenID Applications, but does not grant any information by itself. +return {} +""" +SCOPE_EMAIL_EXPRESSION = """return { + "email": user.email, + "email_verified": True +} +""" +SCOPE_PROFILE_EXPRESSION = """return { + "name": user.name, + "given_name": user.name, + "family_name": "", + "preferred_username": user.username, + "nickname": user.username, +} +""" + + +def create_default_scopes(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + ScopeMapping = apps.get_model("passbook_providers_oauth2", "ScopeMapping") + ScopeMapping.objects.update_or_create( + scope_name="openid", + defaults={ + "name": "Autogenerated OAuth2 Mapping: OpenID 'openid'", + "scope_name": "openid", + "description": "", + "expression": SCOPE_OPENID_EXPRESSION, + }, + ) + ScopeMapping.objects.update_or_create( + scope_name="email", + defaults={ + "name": "Autogenerated OAuth2 Mapping: OpenID 'email'", + "scope_name": "email", + "description": "Email address", + "expression": SCOPE_EMAIL_EXPRESSION, + }, + ) + ScopeMapping.objects.update_or_create( + scope_name="profile", + defaults={ + "name": "Autogenerated OAuth2 Mapping: OpenID 'profile'", + "scope_name": "profile", + "description": "General Profile Information", + "expression": SCOPE_PROFILE_EXPRESSION, + }, + ) + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("passbook_core", "0007_auto_20200815_1841"), + ("passbook_crypto", "0002_create_self_signed_kp"), + ] + + operations = [ + migrations.RunSQL( + "DROP TABLE IF EXISTS passbook_providers_oauth_oauth2provider CASCADE;" + ), + migrations.RunSQL( + "DROP TABLE IF EXISTS passbook_providers_oidc_openidprovider CASCADE;" + ), + migrations.CreateModel( + name="OAuth2Provider", + fields=[ + ( + "provider_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="passbook_core.provider", + ), + ), + ("name", models.TextField()), + ( + "client_type", + models.CharField( + choices=[ + ("confidential", "Confidential"), + ("public", "Public"), + ], + default="confidential", + help_text="Confidential clients are capable of maintaining the confidentiality\n of their credentials. Public clients are incapable.", + max_length=30, + verbose_name="Client Type", + ), + ), + ( + "client_id", + models.CharField( + default=passbook.providers.oauth2.generators.generate_client_id, + max_length=255, + unique=True, + verbose_name="Client ID", + ), + ), + ( + "client_secret", + models.CharField( + blank=True, + default=passbook.providers.oauth2.generators.generate_client_secret, + max_length=255, + verbose_name="Client Secret", + ), + ), + ( + "response_type", + models.TextField( + choices=[ + ("code", "code (Authorization Code Flow)"), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ("code token", "code token (Hybrid Flow)"), + ("code id_token", "code id_token (Hybrid Flow)"), + ( + "code id_token token", + "code id_token token (Hybrid Flow)", + ), + ], + default="code", + help_text="Response Type required by the client.", + ), + ), + ( + "jwt_alg", + models.CharField( + choices=[ + ("HS256", "HS256 (Symmetric Encryption)"), + ("RS256", "RS256 (Asymmetric Encryption)"), + ], + default="RS256", + help_text="Algorithm used to sign the JWT Token", + max_length=10, + verbose_name="JWT Algorithm", + ), + ), + ( + "redirect_uris", + models.TextField( + default="", + help_text="Enter each URI on a new line.", + verbose_name="Redirect URIs", + ), + ), + ( + "post_logout_redirect_uris", + models.TextField( + blank=True, + default="", + help_text="Enter each URI on a new line.", + verbose_name="Post Logout Redirect URIs", + ), + ), + ( + "include_claims_in_id_token", + models.BooleanField( + default=True, + help_text="Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint.", + verbose_name="Include claims in id_token", + ), + ), + ( + "token_validity", + models.TextField( + default="minutes=10", + help_text="Tokens not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).", + validators=[passbook.lib.utils.time.timedelta_string_validator], + ), + ), + ( + "rsa_key", + models.ForeignKey( + help_text="Key used to sign the tokens. Only required when JWT Algorithm is set to RS256.", + on_delete=django.db.models.deletion.CASCADE, + to="passbook_crypto.certificatekeypair", + verbose_name="RSA Key", + blank=True, + null=True, + ), + ), + ], + options={ + "verbose_name": "OAuth2/OpenID Provider", + "verbose_name_plural": "OAuth2/OpenID Providers", + }, + bases=("passbook_core.provider",), + ), + migrations.CreateModel( + name="ScopeMapping", + fields=[ + ( + "propertymapping_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="passbook_core.propertymapping", + ), + ), + ("scope_name", models.TextField(help_text="Scope used by the client")), + ( + "description", + models.TextField( + blank=True, + help_text="Description shown to the user when consenting. If left empty, the user won't be informed.", + ), + ), + ], + options={ + "verbose_name": "Scope Mapping", + "verbose_name_plural": "Scope Mappings", + }, + bases=("passbook_core.propertymapping",), + ), + migrations.RunPython(create_default_scopes), + migrations.CreateModel( + name="RefreshToken", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "expires", + models.DateTimeField( + default=passbook.core.models.default_token_duration + ), + ), + ("expiring", models.BooleanField(default=True)), + ("_scope", models.TextField(default="", verbose_name="Scopes")), + ( + "access_token", + models.CharField( + max_length=255, unique=True, verbose_name="Access Token" + ), + ), + ( + "refresh_token", + models.CharField( + max_length=255, unique=True, verbose_name="Refresh Token" + ), + ), + ("_id_token", models.TextField(verbose_name="ID Token")), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="passbook_providers_oauth2.oauth2provider", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + options={"verbose_name": "Token", "verbose_name_plural": "Tokens",}, + ), + migrations.CreateModel( + name="AuthorizationCode", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "expires", + models.DateTimeField( + default=passbook.core.models.default_token_duration + ), + ), + ("expiring", models.BooleanField(default=True)), + ("_scope", models.TextField(default="", verbose_name="Scopes")), + ( + "code", + models.CharField(max_length=255, unique=True, verbose_name="Code"), + ), + ( + "nonce", + models.CharField( + blank=True, default="", max_length=255, verbose_name="Nonce" + ), + ), + ( + "is_open_id", + models.BooleanField( + default=False, verbose_name="Is Authentication?" + ), + ), + ( + "code_challenge", + models.CharField( + max_length=255, null=True, verbose_name="Code Challenge" + ), + ), + ( + "code_challenge_method", + models.CharField( + max_length=255, null=True, verbose_name="Code Challenge Method" + ), + ), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="passbook_providers_oauth2.oauth2provider", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "Authorization Code", + "verbose_name_plural": "Authorization Codes", + }, + ), + ] diff --git a/passbook/providers/app_gw/__init__.py b/passbook/providers/oauth2/migrations/__init__.py similarity index 100% rename from passbook/providers/app_gw/__init__.py rename to passbook/providers/oauth2/migrations/__init__.py diff --git a/passbook/providers/oauth2/models.py b/passbook/providers/oauth2/models.py new file mode 100644 index 000000000..2e89d82ee --- /dev/null +++ b/passbook/providers/oauth2/models.py @@ -0,0 +1,445 @@ +"""OAuth Provider Models""" +import base64 +import binascii +import json +import time +from dataclasses import asdict, dataclass, field +from hashlib import sha256 +from typing import Any, Dict, List, Optional, Type +from uuid import uuid4 + +from django.conf import settings +from django.db import models +from django.forms import ModelForm +from django.http import HttpRequest +from django.shortcuts import reverse +from django.utils import dateformat, timezone +from django.utils.translation import ugettext_lazy as _ +from jwkest.jwk import Key, RSAKey, SYMKey, import_rsa_key +from jwkest.jws import JWS + +from passbook.core.models import ExpiringModel, PropertyMapping, Provider, User +from passbook.crypto.models import CertificateKeyPair +from passbook.lib.utils.template import render_to_string +from passbook.lib.utils.time import timedelta_from_string, timedelta_string_validator +from passbook.providers.oauth2.apps import PassbookProviderOAuth2Config +from passbook.providers.oauth2.generators import ( + generate_client_id, + generate_client_secret, +) + + +class ClientTypes(models.TextChoices): + """Confidential clients are capable of maintaining the confidentiality + of their credentials. Public clients are incapable.""" + + CONFIDENTIAL = "confidential", _("Confidential") + PUBLIC = "public", _("Public") + + +class GrantTypes(models.TextChoices): + """OAuth2 Grant types we support""" + + AUTHORIZATION_CODE = "authorization_code" + IMPLICIT = "implicit" + HYBRID = "hybrid" + + +class ResponseTypes(models.TextChoices): + """Response Type required by the client.""" + + CODE = "code", _("code (Authorization Code Flow)") + ID_TOKEN = "id_token", _("id_token (Implicit Flow)") + ID_TOKEN_TOKEN = "id_token token", _("id_token token (Implicit Flow)") + CODE_TOKEN = "code token", _("code token (Hybrid Flow)") + CODE_ID_TOKEN = "code id_token", _("code id_token (Hybrid Flow)") + CODE_ID_TOKEN_TOKEN = "code id_token token", _("code id_token token (Hybrid Flow)") + + +class JWTAlgorithms(models.TextChoices): + """Algorithm used to sign the JWT Token""" + + HS256 = "HS256", _("HS256 (Symmetric Encryption)") + RS256 = "RS256", _("RS256 (Asymmetric Encryption)") + + +class ScopeMapping(PropertyMapping): + """Map an OAuth Scope to users properties""" + + scope_name = models.TextField(help_text=_("Scope used by the client")) + description = models.TextField( + blank=True, + help_text=_( + ( + "Description shown to the user when consenting. " + "If left empty, the user won't be informed." + ) + ), + ) + + def form(self) -> Type[ModelForm]: + from passbook.providers.oauth2.forms import ScopeMappingForm + + return ScopeMappingForm + + def __str__(self): + return f"Scope Mapping '{self.scope_name}'" + + class Meta: + + verbose_name = _("Scope Mapping") + verbose_name_plural = _("Scope Mappings") + + +class OAuth2Provider(Provider): + """OAuth2 Provider for generic OAuth and OpenID Connect Applications.""" + + name = models.TextField() + + client_type = models.CharField( + max_length=30, + choices=ClientTypes.choices, + default=ClientTypes.CONFIDENTIAL, + verbose_name=_("Client Type"), + help_text=_(ClientTypes.__doc__), + ) + client_id = models.CharField( + max_length=255, + unique=True, + verbose_name=_("Client ID"), + default=generate_client_id, + ) + client_secret = models.CharField( + max_length=255, + blank=True, + verbose_name=_("Client Secret"), + default=generate_client_secret, + ) + response_type = models.TextField( + choices=ResponseTypes.choices, + default=ResponseTypes.CODE, + help_text=_(ResponseTypes.__doc__), + ) + jwt_alg = models.CharField( + max_length=10, + choices=JWTAlgorithms.choices, + default=JWTAlgorithms.RS256, + verbose_name=_("JWT Algorithm"), + help_text=_(JWTAlgorithms.__doc__), + ) + redirect_uris = models.TextField( + default="", + verbose_name=_("Redirect URIs"), + help_text=_("Enter each URI on a new line."), + ) + post_logout_redirect_uris = models.TextField( + blank=True, + default="", + verbose_name=_("Post Logout Redirect URIs"), + help_text=_("Enter each URI on a new line."), + ) + + include_claims_in_id_token = models.BooleanField( + default=True, + verbose_name=_("Include claims in id_token"), + help_text=_( + ( + "Include User claims from scopes in the id_token, for applications " + "that don't access the userinfo endpoint." + ) + ), + ) + + token_validity = models.TextField( + default="minutes=10", + validators=[timedelta_string_validator], + help_text=_( + ( + "Tokens not valid on or after current time + this value " + "(Format: hours=1;minutes=2;seconds=3)." + ) + ), + ) + + rsa_key = models.ForeignKey( + CertificateKeyPair, + verbose_name=_("RSA Key"), + on_delete=models.CASCADE, + blank=True, + null=True, + help_text=_( + "Key used to sign the tokens. Only required when JWT Algorithm is set to RS256." + ), + ) + + @property + def scope_names(self) -> List[str]: + """Return list of assigned scopes seperated with a space""" + return [pm.scope_name for pm in self.property_mappings.all()] + + def create_refresh_token( + self, user: User, scope: List[str], id_token: Optional["IDToken"] = None + ) -> "RefreshToken": + """Create and populate a RefreshToken object.""" + token = RefreshToken( + user=user, + provider=self, + access_token=uuid4().hex, + refresh_token=uuid4().hex, + expires=timezone.now() + timedelta_from_string(self.token_validity), + scope=scope, + ) + if id_token: + token.id_token = id_token + return token + + def get_jwt_keys(self) -> List[Key]: + """ + Takes a provider and returns the set of keys associated with it. + Returns a list of keys. + """ + if self.jwt_alg == JWTAlgorithms.RS256: + # if the user selected RS256 but didn't select a + # CertificateKeyPair, we fall back to HS256 + if not self.rsa_key: + self.jwt_alg = JWTAlgorithms.HS256 + self.save() + else: + # Because the JWT Library uses python cryptodome, + # we can't directly pass the RSAPublicKey + # object, but have to load it ourselves + key = import_rsa_key(self.rsa_key.key_data) + keys = [RSAKey(key=key, kid=self.rsa_key.kid)] + if not keys: + raise Exception("You must add at least one RSA Key.") + return keys + + if self.jwt_alg == JWTAlgorithms.HS256: + return [SYMKey(key=self.client_secret, alg=self.jwt_alg)] + + raise Exception("Unsupported key algorithm.") + + def get_issuer(self, request: HttpRequest) -> Optional[str]: + """Get issuer, based on request""" + try: + mountpoint = PassbookProviderOAuth2Config.mountpoints[ + "passbook.providers.oauth2.urls" + ] + # pylint: disable=no-member + return request.build_absolute_uri(f"/{mountpoint}{self.application.slug}/") + except Provider.application.RelatedObjectDoesNotExist: + return None + + def form(self) -> Type[ModelForm]: + from passbook.providers.oauth2.forms import OAuth2ProviderForm + + return OAuth2ProviderForm + + def __str__(self): + return self.name + + def html_setup_urls(self, request: HttpRequest) -> Optional[str]: + """return template and context modal with URLs for authorize, token, openid-config, etc""" + try: + # pylint: disable=no-member + return render_to_string( + "providers/oauth2/setup_url_modal.html", + { + "provider": self, + "authorize": request.build_absolute_uri( + reverse("passbook_providers_oauth2:authorize",) + ), + "token": request.build_absolute_uri( + reverse("passbook_providers_oauth2:token",) + ), + "userinfo": request.build_absolute_uri( + reverse("passbook_providers_oauth2:userinfo",) + ), + "provider_info": request.build_absolute_uri( + reverse( + "passbook_providers_oauth2:provider-info", + kwargs={"application_slug": self.application.slug}, + ) + ), + }, + ) + except Provider.application.RelatedObjectDoesNotExist: + return None + + class Meta: + + verbose_name = _("OAuth2/OpenID Provider") + verbose_name_plural = _("OAuth2/OpenID Providers") + + +class BaseGrantModel(models.Model): + """Base Model for all grants""" + + provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE) + user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE) + _scope = models.TextField(default="", verbose_name=_("Scopes")) + + @property + def scope(self) -> List[str]: + """Return scopes as list of strings""" + return self._scope.split() + + @scope.setter + def scope(self, value): + self._scope = " ".join(value) + + class Meta: + abstract = True + + +# pylint: disable=too-many-instance-attributes +class AuthorizationCode(ExpiringModel, BaseGrantModel): + """OAuth2 Authorization Code""" + + code = models.CharField(max_length=255, unique=True, verbose_name=_("Code")) + nonce = models.CharField( + max_length=255, blank=True, default="", verbose_name=_("Nonce") + ) + is_open_id = models.BooleanField( + default=False, verbose_name=_("Is Authentication?") + ) + code_challenge = models.CharField( + max_length=255, null=True, verbose_name=_("Code Challenge") + ) + code_challenge_method = models.CharField( + max_length=255, null=True, verbose_name=_("Code Challenge Method") + ) + + class Meta: + verbose_name = _("Authorization Code") + verbose_name_plural = _("Authorization Codes") + + def __str__(self): + return "{0} - {1}".format(self.provider, self.code) + + +@dataclass +# plyint: disable=too-many-instance-attributes +class IDToken: + """The primary extension that OpenID Connect makes to OAuth 2.0 to enable End-Users to be + Authenticated is the ID Token data structure. The ID Token is a security token that contains + Claims about the Authentication of an End-User by an Authorization Server when using a Client, + and potentially other requested Claims. The ID Token is represented as a + JSON Web Token (JWT) [JWT]. + + https://openid.net/specs/openid-connect-core-1_0.html#IDToken""" + + # All these fields need to optional so we can save an empty IDToken for non-OpenID flows. + iss: Optional[str] = None + sub: Optional[str] = None + aud: Optional[str] = None + exp: Optional[int] = None + iat: Optional[int] = None + auth_time: Optional[int] = None + + nonce: Optional[str] = None + at_hash: Optional[str] = None + + claims: Dict[str, Any] = field(default_factory=dict) + + @staticmethod + def from_dict(data: Dict[str, Any]) -> "IDToken": + """Reconstruct ID Token from json dictionary""" + token = IDToken() + for key, value in data.items(): + setattr(token, key, value) + return token + + def to_dict(self) -> Dict[str, Any]: + """Convert dataclass to dict, and update with keys from `claims`""" + dic = asdict(self) + dic.pop("claims") + dic.update(self.claims) + return dic + + def encode(self, provider: OAuth2Provider) -> str: + """Represent the ID Token as a JSON Web Token (JWT).""" + keys = provider.get_jwt_keys() + # If the provider does not have an RSA Key assigned, it was switched to Symmetric + provider.refresh_from_db() + jws = JWS(self.to_dict(), alg=provider.jwt_alg) + return jws.sign_compact(keys) + + +class RefreshToken(ExpiringModel, BaseGrantModel): + """OAuth2 Refresh Token""" + + access_token = models.CharField( + max_length=255, unique=True, verbose_name=_("Access Token") + ) + refresh_token = models.CharField( + max_length=255, unique=True, verbose_name=_("Refresh Token") + ) + _id_token = models.TextField(verbose_name=_("ID Token")) + + class Meta: + verbose_name = _("Token") + verbose_name_plural = _("Tokens") + + @property + def id_token(self) -> IDToken: + """Load ID Token from json""" + if self._id_token: + raw_token = json.loads(self._id_token) + return IDToken.from_dict(raw_token) + return IDToken() + + @id_token.setter + def id_token(self, value: IDToken): + self._id_token = json.dumps(asdict(value)) + + def __str__(self): + return f"{self.provider} - {self.access_token}" + + @property + def at_hash(self): + """Get hashed access_token""" + hashed_access_token = ( + sha256(self.access_token.encode("ascii")).hexdigest().encode("ascii") + ) + return ( + base64.urlsafe_b64encode( + binascii.unhexlify(hashed_access_token[: len(hashed_access_token) // 2]) + ) + .rstrip(b"=") + .decode("ascii") + ) + + def create_id_token(self, user: User, request: HttpRequest) -> IDToken: + """Creates the id_token. + See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken""" + sub = sha256(f"{user.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest() + + # Convert datetimes into timestamps. + now = int(time.time()) + iat_time = now + exp_time = int( + now + timedelta_from_string(self.provider.token_validity).seconds + ) + user_auth_time = user.last_login or user.date_joined + auth_time = int(dateformat.format(user_auth_time, "U")) + + token = IDToken( + iss=self.provider.get_issuer(request), + sub=sub, + aud=self.provider.client_id, + exp=exp_time, + iat=iat_time, + auth_time=auth_time, + ) + + # Include (or not) user standard claims in the id_token. + if self.provider.include_claims_in_id_token: + from passbook.providers.oauth2.views.userinfo import UserInfoView + + user_info = UserInfoView() + user_info.request = request + claims = user_info.get_claims(self) + token.claims = claims + + return token diff --git a/passbook/providers/oauth/templates/providers/oauth/consent.html b/passbook/providers/oauth2/templates/providers/oauth2/consent.html similarity index 100% rename from passbook/providers/oauth/templates/providers/oauth/consent.html rename to passbook/providers/oauth2/templates/providers/oauth2/consent.html diff --git a/passbook/providers/oidc/templates/oidc_provider/setup_url_modal.html b/passbook/providers/oauth2/templates/providers/oauth2/setup_url_modal.html similarity index 94% rename from passbook/providers/oidc/templates/oidc_provider/setup_url_modal.html rename to passbook/providers/oauth2/templates/providers/oauth2/setup_url_modal.html index 1805247f8..c653dabbe 100644 --- a/passbook/providers/oidc/templates/oidc_provider/setup_url_modal.html +++ b/passbook/providers/oauth2/templates/providers/oauth2/setup_url_modal.html @@ -1,8 +1,8 @@ {% load i18n %} - + -