OAuth Provider Rewrite (#182)
This commit is contained in:
parent
b9076b5fd4
commit
c7a2410b1d
|
@ -1,6 +1,6 @@
|
||||||
[MASTER]
|
[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
|
load-plugins=pylint_django,pylint.extensions.bad_builtin
|
||||||
extension-pkg-whitelist=lxml
|
extension-pkg-whitelist=lxml
|
||||||
const-rgx=[a-zA-Z0-9_]{1,40}$
|
const-rgx=[a-zA-Z0-9_]{1,40}$
|
||||||
|
|
19
Pipfile
19
Pipfile
|
@ -13,8 +13,6 @@ django-dbbackup = "*"
|
||||||
django-filter = "*"
|
django-filter = "*"
|
||||||
django-guardian = "*"
|
django-guardian = "*"
|
||||||
django-model-utils = "*"
|
django-model-utils = "*"
|
||||||
django-oauth-toolkit = "*"
|
|
||||||
django-oidc-provider = "*"
|
|
||||||
django-otp = "*"
|
django-otp = "*"
|
||||||
django-prometheus = "*"
|
django-prometheus = "*"
|
||||||
django-recaptcha = "*"
|
django-recaptcha = "*"
|
||||||
|
@ -23,13 +21,14 @@ django-rest-framework = "*"
|
||||||
django-storages = "*"
|
django-storages = "*"
|
||||||
djangorestframework-guardian = "*"
|
djangorestframework-guardian = "*"
|
||||||
drf-yasg = "*"
|
drf-yasg = "*"
|
||||||
kombu = "*"
|
elastic-apm = "*"
|
||||||
|
facebook-sdk = "*"
|
||||||
ldap3 = "*"
|
ldap3 = "*"
|
||||||
lxml = "*"
|
lxml = "*"
|
||||||
oauthlib = "*"
|
|
||||||
packaging = "*"
|
packaging = "*"
|
||||||
psycopg2-binary = "*"
|
psycopg2-binary = "*"
|
||||||
pycryptodome = "*"
|
pycryptodome = "*"
|
||||||
|
pyjwkest = "*"
|
||||||
pyuwsgi = "*"
|
pyuwsgi = "*"
|
||||||
pyyaml = "*"
|
pyyaml = "*"
|
||||||
qrcode = "*"
|
qrcode = "*"
|
||||||
|
@ -40,8 +39,6 @@ signxml = "*"
|
||||||
structlog = "*"
|
structlog = "*"
|
||||||
swagger-spec-validator = "*"
|
swagger-spec-validator = "*"
|
||||||
urllib3 = {extras = ["secure"],version = "*"}
|
urllib3 = {extras = ["secure"],version = "*"}
|
||||||
facebook-sdk = "*"
|
|
||||||
elastic-apm = "*"
|
|
||||||
|
|
||||||
[requires]
|
[requires]
|
||||||
python_version = "3.8"
|
python_version = "3.8"
|
||||||
|
@ -49,16 +46,14 @@ python_version = "3.8"
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
autopep8 = "*"
|
autopep8 = "*"
|
||||||
bandit = "*"
|
bandit = "*"
|
||||||
|
black = "==19.10b0"
|
||||||
bumpversion = "*"
|
bumpversion = "*"
|
||||||
colorama = "*"
|
colorama = "*"
|
||||||
coverage = "*"
|
coverage = "*"
|
||||||
django-debug-toolbar = "*"
|
django-debug-toolbar = "*"
|
||||||
|
docker = "*"
|
||||||
pylint = "*"
|
pylint = "*"
|
||||||
pylint-django = "*"
|
pylint-django = "*"
|
||||||
unittest-xml-reporting = "*"
|
|
||||||
black = "*"
|
|
||||||
selenium = "*"
|
selenium = "*"
|
||||||
docker = "*"
|
unittest-xml-reporting = "*"
|
||||||
|
prospector = "*"
|
||||||
[pipenv]
|
|
||||||
allow_prereleases = true
|
|
||||||
|
|
276
Pipfile.lock
generated
276
Pipfile.lock
generated
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "5c22d3a514247b663a07c6492cea09ab140346894a528db06bd805a4a3a4a320"
|
"sha256": "b7ba5405c03bf3526eebb29817887744a3e31bca019ad2e566ea23096c6a5cfe"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {
|
"requires": {
|
||||||
|
@ -46,18 +46,18 @@
|
||||||
},
|
},
|
||||||
"boto3": {
|
"boto3": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:640a8372ce0edfbb84a8f63584a0b64c78d61a751a27c2a47f92d2ebaf021ce4",
|
"sha256:02ad765927bb46b9f45c3bce65e763960733919eee7883217995c5df5d096695",
|
||||||
"sha256:a6c9a3d3abbad2ff2e5751af599492a9271633a7c9fef343482524464c53e451"
|
"sha256:4421aad9a9740ce95199460f3262859e1c3594cc6c86cbe552745f4bbff34300"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==1.14.43"
|
"version": "==1.14.45"
|
||||||
},
|
},
|
||||||
"botocore": {
|
"botocore": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1b46ffe1d13922066c873323186cbf97e77c137e08e27039d9d684552ccc4892",
|
"sha256:17470c97435891cf40e147f533069de0109cda24c208c918f28997274bbac399",
|
||||||
"sha256:1f6175bf59ffa068055b65f7d703eb1f748c338594a40dfdc645a6130280d8bb"
|
"sha256:bc8b1c83ccc0d77963849b66a94bbb20a666ff0225aff84de7ed0175db1fd6f7"
|
||||||
],
|
],
|
||||||
"version": "==1.17.44"
|
"version": "==1.17.45"
|
||||||
},
|
},
|
||||||
"celery": {
|
"celery": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -207,21 +207,6 @@
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==4.0.0"
|
"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": {
|
"django-otp": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0c9edbb3f4abc9ac6e43daf0a9e0e293e99ad917641cf8d7dbc49d613bcb5cd4",
|
"sha256:0c9edbb3f4abc9ac6e43daf0a9e0e293e99ad917641cf8d7dbc49d613bcb5cd4",
|
||||||
|
@ -232,11 +217,11 @@
|
||||||
},
|
},
|
||||||
"django-prometheus": {
|
"django-prometheus": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:402228804b190be8bdbf20300ac3ae09043c7e3144e5dd648ddeb8f81a267f16",
|
"sha256:4c30aa8eb944fcf3cf10e20dfabbbe11ad5a84fce62abb3658feffa4e2ac2b97",
|
||||||
"sha256:57b97be6c88af9fc5b28a9fa8df629aab2b04f6a0f910c0669880d62f8ec3c0f"
|
"sha256:8f25e86a3c310f40cf32cfa1b56a2b6df9cb2521e4cb794844958697d98fb3d1"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.1.0.dev61"
|
"version": "==2.0.0"
|
||||||
},
|
},
|
||||||
"django-recaptcha": {
|
"django-recaptcha": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -376,10 +361,10 @@
|
||||||
},
|
},
|
||||||
"jinja2": {
|
"jinja2": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:c10142f819c2d22bdcd17548c46fa9b77cf4fda45097854c689666bf425e7484",
|
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
|
||||||
"sha256:c922560ac46888d47384de1dbdc3daaa2ea993af4b26a436dec31fa2c19ec668"
|
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
|
||||||
],
|
],
|
||||||
"version": "==3.0.0a1"
|
"version": "==2.11.2"
|
||||||
},
|
},
|
||||||
"jmespath": {
|
"jmespath": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -400,7 +385,6 @@
|
||||||
"sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a",
|
"sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a",
|
||||||
"sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74"
|
"sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
|
||||||
"version": "==4.6.11"
|
"version": "==4.6.11"
|
||||||
},
|
},
|
||||||
"ldap3": {
|
"ldap3": {
|
||||||
|
@ -450,37 +434,47 @@
|
||||||
},
|
},
|
||||||
"markupsafe": {
|
"markupsafe": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:06358015a4dee8ee23ae426bf885616ab3963622defd829eb45b44e3dee3515f",
|
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
|
||||||
"sha256:0b0c4fc852c5f02c6277ef3b33d23fcbe89b1b227460423e3335374da046b6db",
|
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
|
||||||
"sha256:267677fc42afed5094fc5ea1c4236bbe4b6a00fe4b08e93451e65ae9048139c7",
|
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
|
||||||
"sha256:303cb70893e2c345588fb5d5b86e0ca369f9bb56942f03064c5e3e75fa7a238a",
|
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
|
||||||
"sha256:3c9b624a0d9ed5a5093ac4edc4e823e6b125441e60ef35d36e6f4a6fdacd5054",
|
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
|
||||||
"sha256:42033e14cae1f6c86fc0c3e90d04d08ce73ac8e46ba420a0d22d545c2abd4977",
|
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
|
||||||
"sha256:4e4a99b6af7bdc0856b50020c095848ec050356a001e1f751510aef6ab14d0e0",
|
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
|
||||||
"sha256:4eb07faad54bb07427d848f31030a65a49ebb0cec0b30674f91cf1ddd456bfe4",
|
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
|
||||||
"sha256:63a7161cd8c2bc563feeda45df62f42c860dd0675e2b8da2667f25bb3c95eaba",
|
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
|
||||||
"sha256:68e0fd039b68d2945b4beb947d4023ca7f8e95b708031c345762efba214ea761",
|
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
|
||||||
"sha256:8092a63397025c2f655acd42784b2a1528339b90b987beb9253f22e8cdbb36c3",
|
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
|
||||||
"sha256:841218860683c0f2223e24756843d84cc49cccdae6765e04962607754a52d3e0",
|
"sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
|
||||||
"sha256:94076b2314bd2f6cfae508ad65b4d493e3a58a50112b7a2cbb6287bdbc404ae8",
|
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
|
||||||
"sha256:9d22aff1c5322e402adfb3ce40839a5056c353e711c033798cf4f02eb9f5124d",
|
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
|
||||||
"sha256:b0e4584f62b3e5f5c1a7bcefd2b52f236505e6ef032cc508caa4f4c8dc8d3af1",
|
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
|
||||||
"sha256:b1163ffc1384d242964426a8164da12dbcdbc0de18ea36e2c34b898ed38c3b45",
|
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
|
||||||
"sha256:beac28ed60c8e838301226a7a85841d0af2068eba2dcb1a58c2d32d6c05e440e",
|
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
|
||||||
"sha256:c29f096ce79c03054a1101d6e5fe6bf04b0bb489165d5e0e9653fb4fe8048ee1",
|
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
|
||||||
"sha256:c58779966d53e5f14ba393d64e2402a7926601d1ac8adeb4e83893def79d0428",
|
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
|
||||||
"sha256:cfe14b37908eaf7d5506302987228bff69e1b8e7071ccd4e70fd0283b1b47f0b",
|
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
|
||||||
"sha256:e834249c45aa9837d0753351cdca61a4b8b383cc9ad0ff2325c97ff7b69e72a6",
|
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
|
||||||
"sha256:eed1b234c4499811ee85bcefa22ef5e466e75d132502226ed29740d593316c1f"
|
"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": {
|
"oauthlib": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889",
|
"sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889",
|
||||||
"sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"
|
"sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
|
||||||
"version": "==3.1.0"
|
"version": "==3.1.0"
|
||||||
},
|
},
|
||||||
"packaging": {
|
"packaging": {
|
||||||
|
@ -630,6 +624,7 @@
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:5560fd5ba08655f29ff6ad1df1e15dc05abc9d976fcbcec8d2b5167f49b70222"
|
"sha256:5560fd5ba08655f29ff6ad1df1e15dc05abc9d976fcbcec8d2b5167f49b70222"
|
||||||
],
|
],
|
||||||
|
"index": "pypi",
|
||||||
"version": "==1.4.2"
|
"version": "==1.4.2"
|
||||||
},
|
},
|
||||||
"pyopenssl": {
|
"pyopenssl": {
|
||||||
|
@ -641,10 +636,10 @@
|
||||||
},
|
},
|
||||||
"pyparsing": {
|
"pyparsing": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1060635ca5ac864c2b7bc7b05a448df4e32d7d8c65e33cbe1514810d339672a2",
|
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
|
||||||
"sha256:56a551039101858c9e189ac9e66e330a03fb7079e97ba6b50193643905f450ce"
|
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
|
||||||
],
|
],
|
||||||
"version": "==3.0.0a2"
|
"version": "==2.4.7"
|
||||||
},
|
},
|
||||||
"pyrsistent": {
|
"pyrsistent": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -868,10 +863,10 @@
|
||||||
},
|
},
|
||||||
"astroid": {
|
"astroid": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703",
|
"sha256:4c17cea3e592c21b6e222f673868961bad77e1f985cb1694ed077475a89229c1",
|
||||||
"sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386"
|
"sha256:d8506842a3faf734b81599c8b98dcc423de863adcc1999248480b18bd31a0f38"
|
||||||
],
|
],
|
||||||
"version": "==2.4.2"
|
"version": "==2.4.1"
|
||||||
},
|
},
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -925,39 +920,6 @@
|
||||||
],
|
],
|
||||||
"version": "==2020.6.20"
|
"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": {
|
"chardet": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
||||||
|
@ -1020,30 +982,6 @@
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==5.2.1"
|
"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": {
|
"django": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1a63f5bb6ff4d7c42f62a519edc2adbb37f9b78068a5a862beff858b68e3dc8b",
|
"sha256:1a63f5bb6ff4d7c42f62a519edc2adbb37f9b78068a5a862beff858b68e3dc8b",
|
||||||
|
@ -1068,6 +1006,27 @@
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==4.3.0"
|
"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": {
|
"gitdb": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac",
|
"sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac",
|
||||||
|
@ -1143,6 +1102,20 @@
|
||||||
],
|
],
|
||||||
"version": "==5.4.5"
|
"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": {
|
"pycodestyle": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
|
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
|
||||||
|
@ -1150,28 +1123,47 @@
|
||||||
],
|
],
|
||||||
"version": "==2.6.0"
|
"version": "==2.6.0"
|
||||||
},
|
},
|
||||||
"pycparser": {
|
"pydocstyle": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",
|
"sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586",
|
||||||
"sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"
|
"sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5"
|
||||||
],
|
],
|
||||||
"version": "==2.20"
|
"version": "==5.0.2"
|
||||||
|
},
|
||||||
|
"pyflakes": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
|
||||||
|
"sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
|
||||||
|
],
|
||||||
|
"version": "==2.2.0"
|
||||||
},
|
},
|
||||||
"pylint": {
|
"pylint": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:7dd78437f2d8d019717dbf287772d0b2dbdfd13fc016aa7faa08d67bccc46adc",
|
"sha256:b95e31850f3af163c2283ed40432f053acbc8fc6eba6a069cb518d9dbf71848c",
|
||||||
"sha256:d0ece7d223fe422088b0e8f13fa0a1e8eb745ebffcb8ed53d3e95394b6101a1c"
|
"sha256:dd506acce0427e9e08fb87274bcaa953d38b50a58207170dbf5b36cf3e16957b"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.5.3"
|
"version": "==2.5.2"
|
||||||
|
},
|
||||||
|
"pylint-celery": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:41e32094e7408d15c044178ea828dd524beedbdbe6f83f712c5e35bde1de4beb"
|
||||||
|
],
|
||||||
|
"version": "==0.3"
|
||||||
},
|
},
|
||||||
"pylint-django": {
|
"pylint-django": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:770e0c55fb054c6378e1e8bb3fe22c7032a2c38ba1d1f454206ee9c6591822d7",
|
"sha256:06a64331c498a3f049ba669dc0c174b92209e164198d43e589b1096ee616d5f8",
|
||||||
"sha256:b8dcb6006ae9fa911810aba3bec047b9410b7d528f89d5aca2506b03c9235a49"
|
"sha256:3d3436ba8d0fae576ae2db160e33a8f2746a101fda4463f2b3ff3a8b6fccec38"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.3.0"
|
"version": "==2.0.15"
|
||||||
|
},
|
||||||
|
"pylint-flask": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:f4d97de2216bf7bfce07c9c08b166e978fe9f2725de2a50a9845a97de7e31517"
|
||||||
|
],
|
||||||
|
"version": "==0.6"
|
||||||
},
|
},
|
||||||
"pylint-plugin-utils": {
|
"pylint-plugin-utils": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -1180,13 +1172,6 @@
|
||||||
],
|
],
|
||||||
"version": "==0.6"
|
"version": "==0.6"
|
||||||
},
|
},
|
||||||
"pyopenssl": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504",
|
|
||||||
"sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"
|
|
||||||
],
|
|
||||||
"version": "==19.1.0"
|
|
||||||
},
|
|
||||||
"pytz": {
|
"pytz": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed",
|
"sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed",
|
||||||
|
@ -1244,13 +1229,25 @@
|
||||||
],
|
],
|
||||||
"version": "==2.24.0"
|
"version": "==2.24.0"
|
||||||
},
|
},
|
||||||
|
"requirements-detector": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:0d1e13e61ed243f9c3c86e6cbb19980bcb3a0e0619cde2ec1f3af70fdbee6f7b"
|
||||||
|
],
|
||||||
|
"version": "==0.7"
|
||||||
|
},
|
||||||
"selenium": {
|
"selenium": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:5f5489a0c5fe2f09cc6bc3f32a0d53441ab36882c987269f2afe805979633ac1",
|
"sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c",
|
||||||
"sha256:a9779ddc69cf03b75d94062c5e948f763919cf3341c77272f94cd05e6b4c7b32"
|
"sha256:deaf32b60ad91a4611b98d8002757f29e6f2c2d5fcaf202e1c9ad06d6772300d"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==4.0.0a6.post2"
|
"version": "==3.141.0"
|
||||||
|
},
|
||||||
|
"setoptconf": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:5b0b5d8e0077713f5d5152d4f63be6f048d9a1bb66be15d089a11c898c3cf49c"
|
||||||
|
],
|
||||||
|
"version": "==0.2.0"
|
||||||
},
|
},
|
||||||
"six": {
|
"six": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -1266,6 +1263,13 @@
|
||||||
],
|
],
|
||||||
"version": "==3.0.4"
|
"version": "==3.0.4"
|
||||||
},
|
},
|
||||||
|
"snowballstemmer": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0",
|
||||||
|
"sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"
|
||||||
|
],
|
||||||
|
"version": "==2.0.0"
|
||||||
|
},
|
||||||
"sqlparse": {
|
"sqlparse": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e",
|
"sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e",
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
"""test OAuth Provider flow"""
|
"""test OAuth Provider flow"""
|
||||||
from time import sleep
|
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.by import By
|
||||||
from selenium.webdriver.common.keys import Keys
|
from selenium.webdriver.common.keys import Keys
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
@ -14,12 +13,16 @@ from passbook.core.models import Application
|
||||||
from passbook.flows.models import Flow
|
from passbook.flows.models import Flow
|
||||||
from passbook.policies.expression.models import ExpressionPolicy
|
from passbook.policies.expression.models import ExpressionPolicy
|
||||||
from passbook.policies.models import PolicyBinding
|
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()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class TestProviderOAuth(SeleniumTestCase):
|
class TestProviderOAuth2Github(SeleniumTestCase):
|
||||||
"""test OAuth Provider flow"""
|
"""test OAuth Provider flow"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
@ -43,18 +46,18 @@ class TestProviderOAuth(SeleniumTestCase):
|
||||||
),
|
),
|
||||||
environment={
|
environment={
|
||||||
"GF_AUTH_GITHUB_ENABLED": "true",
|
"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_ID": self.client_id,
|
||||||
"GF_AUTH_GITHUB_CLIENT_SECRET": self.client_secret,
|
"GF_AUTH_GITHUB_CLIENT_SECRET": self.client_secret,
|
||||||
"GF_AUTH_GITHUB_SCOPES": "user:email,read:org",
|
"GF_AUTH_GITHUB_SCOPES": "user:email,read:org",
|
||||||
"GF_AUTH_GITHUB_AUTH_URL": self.url(
|
"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(
|
"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(
|
"GF_AUTH_GITHUB_API_URL": self.url(
|
||||||
"passbook_providers_oauth:github-user"
|
"passbook_providers_oauth2_github:github-user"
|
||||||
),
|
),
|
||||||
"GF_LOG_LEVEL": "debug",
|
"GF_LOG_LEVEL": "debug",
|
||||||
},
|
},
|
||||||
|
@ -80,12 +83,11 @@ class TestProviderOAuth(SeleniumTestCase):
|
||||||
)
|
)
|
||||||
provider = OAuth2Provider.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
name="grafana",
|
name="grafana",
|
||||||
client_type=OAuth2Provider.CLIENT_CONFIDENTIAL,
|
|
||||||
authorization_grant_type=OAuth2Provider.GRANT_AUTHORIZATION_CODE,
|
|
||||||
client_id=self.client_id,
|
client_id=self.client_id,
|
||||||
client_secret=self.client_secret,
|
client_secret=self.client_secret,
|
||||||
|
client_type=ClientTypes.CONFIDENTIAL,
|
||||||
|
response_type=ResponseTypes.CODE,
|
||||||
redirect_uris="http://localhost:3000/login/github",
|
redirect_uris="http://localhost:3000/login/github",
|
||||||
skip_authorization=True,
|
|
||||||
authorization_flow=authorization_flow,
|
authorization_flow=authorization_flow,
|
||||||
)
|
)
|
||||||
Application.objects.create(
|
Application.objects.create(
|
||||||
|
@ -134,12 +136,11 @@ class TestProviderOAuth(SeleniumTestCase):
|
||||||
)
|
)
|
||||||
provider = OAuth2Provider.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
name="grafana",
|
name="grafana",
|
||||||
client_type=OAuth2Provider.CLIENT_CONFIDENTIAL,
|
|
||||||
authorization_grant_type=OAuth2Provider.GRANT_AUTHORIZATION_CODE,
|
|
||||||
client_id=self.client_id,
|
client_id=self.client_id,
|
||||||
client_secret=self.client_secret,
|
client_secret=self.client_secret,
|
||||||
|
client_type=ClientTypes.CONFIDENTIAL,
|
||||||
|
response_type=ResponseTypes.CODE,
|
||||||
redirect_uris="http://localhost:3000/login/github",
|
redirect_uris="http://localhost:3000/login/github",
|
||||||
skip_authorization=True,
|
|
||||||
authorization_flow=authorization_flow,
|
authorization_flow=authorization_flow,
|
||||||
)
|
)
|
||||||
app = Application.objects.create(
|
app = Application.objects.create(
|
||||||
|
@ -161,7 +162,7 @@ class TestProviderOAuth(SeleniumTestCase):
|
||||||
).text,
|
).text,
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"GitHub Compatibility: User Email",
|
"GitHub Compatibility: Access you Email addresses",
|
||||||
self.driver.find_element(
|
self.driver.find_element(
|
||||||
By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/ul/li[1]"
|
By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/ul/li[1]"
|
||||||
).text,
|
).text,
|
||||||
|
@ -203,12 +204,11 @@ class TestProviderOAuth(SeleniumTestCase):
|
||||||
)
|
)
|
||||||
provider = OAuth2Provider.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
name="grafana",
|
name="grafana",
|
||||||
client_type=OAuth2Provider.CLIENT_CONFIDENTIAL,
|
|
||||||
authorization_grant_type=OAuth2Provider.GRANT_AUTHORIZATION_CODE,
|
|
||||||
client_id=self.client_id,
|
client_id=self.client_id,
|
||||||
client_secret=self.client_secret,
|
client_secret=self.client_secret,
|
||||||
|
client_type=ClientTypes.CONFIDENTIAL,
|
||||||
|
response_type=ResponseTypes.CODE,
|
||||||
redirect_uris="http://localhost:3000/login/github",
|
redirect_uris="http://localhost:3000/login/github",
|
||||||
skip_authorization=True,
|
|
||||||
authorization_flow=authorization_flow,
|
authorization_flow=authorization_flow,
|
||||||
)
|
)
|
||||||
app = Application.objects.create(
|
app = Application.objects.create(
|
|
@ -1,9 +1,6 @@
|
||||||
"""test OpenID Provider flow"""
|
"""test OAuth2 OpenID Provider flow"""
|
||||||
from time import sleep
|
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.by import By
|
||||||
from selenium.webdriver.common.keys import Keys
|
from selenium.webdriver.common.keys import Keys
|
||||||
from selenium.webdriver.support import expected_conditions as ec
|
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 import DockerClient, from_env
|
||||||
from docker.models.containers import Container
|
from docker.models.containers import Container
|
||||||
from docker.types import Healthcheck
|
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.core.models import Application
|
||||||
|
from passbook.crypto.models import CertificateKeyPair
|
||||||
from passbook.flows.models import Flow
|
from passbook.flows.models import Flow
|
||||||
from passbook.policies.expression.models import ExpressionPolicy
|
from passbook.policies.expression.models import ExpressionPolicy
|
||||||
from passbook.policies.models import PolicyBinding
|
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()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class TestProviderOIDC(SeleniumTestCase):
|
class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||||
"""test OpenID Provider flow"""
|
"""test OAuth with OpenID Provider flow"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.client_id = generate_client_id()
|
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_CLIENT_SECRET": self.client_secret,
|
||||||
"GF_AUTH_GENERIC_OAUTH_SCOPES": "openid email profile",
|
"GF_AUTH_GENERIC_OAUTH_SCOPES": "openid email profile",
|
||||||
"GF_AUTH_GENERIC_OAUTH_AUTH_URL": (
|
"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": (
|
"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": (
|
"GF_AUTH_GENERIC_OAUTH_API_URL": (
|
||||||
self.live_server_url + reverse("oidc_provider:userinfo")
|
self.url("passbook_providers_oauth2:userinfo")
|
||||||
),
|
),
|
||||||
"GF_LOG_LEVEL": "debug",
|
"GF_LOG_LEVEL": "debug",
|
||||||
},
|
},
|
||||||
|
@ -80,23 +92,22 @@ class TestProviderOIDC(SeleniumTestCase):
|
||||||
authorization_flow = Flow.objects.get(
|
authorization_flow = Flow.objects.get(
|
||||||
slug="default-provider-authorization-implicit-consent"
|
slug="default-provider-authorization-implicit-consent"
|
||||||
)
|
)
|
||||||
client = Client.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
name="grafana",
|
name="grafana",
|
||||||
client_type="confidential",
|
client_type=ClientTypes.CONFIDENTIAL,
|
||||||
client_id=self.client_id,
|
client_id=self.client_id,
|
||||||
client_secret=self.client_secret,
|
client_secret=self.client_secret,
|
||||||
_redirect_uris="http://localhost:3000/",
|
rsa_key=CertificateKeyPair.objects.first(),
|
||||||
_scope="openid userinfo",
|
redirect_uris="http://localhost:3000/",
|
||||||
|
authorization_flow=authorization_flow,
|
||||||
|
response_type=ResponseTypes.CODE,
|
||||||
)
|
)
|
||||||
# At least one of these objects must exist
|
provider.property_mappings.set(
|
||||||
ensure_rsa_key()
|
ScopeMapping.objects.filter(
|
||||||
# This response_code object might exist or not, depending on the order the tests are run
|
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE]
|
||||||
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.save()
|
||||||
Application.objects.create(
|
Application.objects.create(
|
||||||
name="Grafana", slug="grafana", provider=provider,
|
name="Grafana", slug="grafana", provider=provider,
|
||||||
)
|
)
|
||||||
|
@ -121,25 +132,22 @@ class TestProviderOIDC(SeleniumTestCase):
|
||||||
authorization_flow = Flow.objects.get(
|
authorization_flow = Flow.objects.get(
|
||||||
slug="default-provider-authorization-implicit-consent"
|
slug="default-provider-authorization-implicit-consent"
|
||||||
)
|
)
|
||||||
client = Client.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
name="grafana",
|
name="grafana",
|
||||||
client_type="confidential",
|
client_type=ClientTypes.CONFIDENTIAL,
|
||||||
client_id=self.client_id,
|
client_id=self.client_id,
|
||||||
client_secret=self.client_secret,
|
client_secret=self.client_secret,
|
||||||
_redirect_uris="http://localhost:3000/login/generic_oauth",
|
rsa_key=CertificateKeyPair.objects.first(),
|
||||||
_scope="openid profile email",
|
redirect_uris="http://localhost:3000/login/generic_oauth",
|
||||||
reuse_consent=False,
|
authorization_flow=authorization_flow,
|
||||||
require_consent=False,
|
response_type=ResponseTypes.CODE,
|
||||||
)
|
)
|
||||||
# At least one of these objects must exist
|
provider.property_mappings.set(
|
||||||
ensure_rsa_key()
|
ScopeMapping.objects.filter(
|
||||||
# This response_code object might exist or not, depending on the order the tests are run
|
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE]
|
||||||
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.save()
|
||||||
Application.objects.create(
|
Application.objects.create(
|
||||||
name="Grafana", slug="grafana", provider=provider,
|
name="Grafana", slug="grafana", provider=provider,
|
||||||
)
|
)
|
||||||
|
@ -182,25 +190,22 @@ class TestProviderOIDC(SeleniumTestCase):
|
||||||
authorization_flow = Flow.objects.get(
|
authorization_flow = Flow.objects.get(
|
||||||
slug="default-provider-authorization-explicit-consent"
|
slug="default-provider-authorization-explicit-consent"
|
||||||
)
|
)
|
||||||
client = Client.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
name="grafana",
|
name="grafana",
|
||||||
client_type="confidential",
|
authorization_flow=authorization_flow,
|
||||||
|
response_type=ResponseTypes.CODE,
|
||||||
|
client_type=ClientTypes.CONFIDENTIAL,
|
||||||
client_id=self.client_id,
|
client_id=self.client_id,
|
||||||
client_secret=self.client_secret,
|
client_secret=self.client_secret,
|
||||||
_redirect_uris="http://localhost:3000/login/generic_oauth",
|
rsa_key=CertificateKeyPair.objects.first(),
|
||||||
_scope="openid profile email",
|
redirect_uris="http://localhost:3000/login/generic_oauth",
|
||||||
reuse_consent=False,
|
|
||||||
require_consent=False,
|
|
||||||
)
|
)
|
||||||
# At least one of these objects must exist
|
provider.property_mappings.set(
|
||||||
ensure_rsa_key()
|
ScopeMapping.objects.filter(
|
||||||
# This response_code object might exist or not, depending on the order the tests are run
|
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE]
|
||||||
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.save()
|
||||||
app = Application.objects.create(
|
app = Application.objects.create(
|
||||||
name="Grafana", slug="grafana", provider=provider,
|
name="Grafana", slug="grafana", provider=provider,
|
||||||
)
|
)
|
||||||
|
@ -261,25 +266,22 @@ class TestProviderOIDC(SeleniumTestCase):
|
||||||
authorization_flow = Flow.objects.get(
|
authorization_flow = Flow.objects.get(
|
||||||
slug="default-provider-authorization-explicit-consent"
|
slug="default-provider-authorization-explicit-consent"
|
||||||
)
|
)
|
||||||
client = Client.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
name="grafana",
|
name="grafana",
|
||||||
client_type="confidential",
|
authorization_flow=authorization_flow,
|
||||||
|
response_type=ResponseTypes.CODE,
|
||||||
|
client_type=ClientTypes.CONFIDENTIAL,
|
||||||
client_id=self.client_id,
|
client_id=self.client_id,
|
||||||
client_secret=self.client_secret,
|
client_secret=self.client_secret,
|
||||||
_redirect_uris="http://localhost:3000/login/generic_oauth",
|
rsa_key=CertificateKeyPair.objects.first(),
|
||||||
_scope="openid profile email",
|
redirect_uris="http://localhost:3000/login/generic_oauth",
|
||||||
reuse_consent=False,
|
|
||||||
require_consent=False,
|
|
||||||
)
|
)
|
||||||
# At least one of these objects must exist
|
provider.property_mappings.set(
|
||||||
ensure_rsa_key()
|
ScopeMapping.objects.filter(
|
||||||
# This response_code object might exist or not, depending on the order the tests are run
|
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE]
|
||||||
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.save()
|
||||||
app = Application.objects.create(
|
app = Application.objects.create(
|
||||||
name="Grafana", slug="grafana", provider=provider,
|
name="Grafana", slug="grafana", provider=provider,
|
||||||
)
|
)
|
|
@ -2,7 +2,6 @@
|
||||||
from os.path import abspath
|
from os.path import abspath
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from oauth2_provider.generators import generate_client_secret
|
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.common.keys import Keys
|
from selenium.webdriver.common.keys import Keys
|
||||||
from selenium.webdriver.support import expected_conditions as ec
|
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 docker.types import Healthcheck
|
||||||
from e2e.utils import SeleniumTestCase
|
from e2e.utils import SeleniumTestCase
|
||||||
from passbook.flows.models import Flow
|
from passbook.flows.models import Flow
|
||||||
|
from passbook.providers.oauth2.generators import generate_client_secret
|
||||||
from passbook.sources.oauth.models import OAuthSource
|
from passbook.sources.oauth.models import OAuthSource
|
||||||
|
|
||||||
TOKEN_URL = "http://127.0.0.1:5556/dex/token"
|
TOKEN_URL = "http://127.0.0.1:5556/dex/token"
|
||||||
|
|
11
e2e/utils.py
11
e2e/utils.py
|
@ -6,7 +6,6 @@ from inspect import getmembers, isfunction
|
||||||
from os import environ, makedirs
|
from os import environ, makedirs
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
from Cryptodome.PublicKey import RSA
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
|
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
|
||||||
from django.db import connection, transaction
|
from django.db import connection, transaction
|
||||||
|
@ -28,16 +27,6 @@ def USER() -> User: # noqa
|
||||||
return User.objects.get(username="pbadmin")
|
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):
|
class SeleniumTestCase(StaticLiveServerTestCase):
|
||||||
"""StaticLiveServerTestCase which automatically creates a Webdriver instance"""
|
"""StaticLiveServerTestCase which automatically creates a Webdriver instance"""
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
"""passbook api urls"""
|
"""passbook api urls"""
|
||||||
from django.urls import include, path
|
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
|
from passbook.api.v2.urls import urlpatterns as v2_urls
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("v1/", include(v1_urls)),
|
|
||||||
path("v2beta/", include(v2_urls)),
|
path("v2beta/", include(v2_urls)),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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)
|
|
|
@ -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")]
|
|
|
@ -23,9 +23,8 @@ from passbook.policies.group_membership.api import GroupMembershipPolicyViewSet
|
||||||
from passbook.policies.hibp.api import HaveIBeenPwendPolicyViewSet
|
from passbook.policies.hibp.api import HaveIBeenPwendPolicyViewSet
|
||||||
from passbook.policies.password.api import PasswordPolicyViewSet
|
from passbook.policies.password.api import PasswordPolicyViewSet
|
||||||
from passbook.policies.reputation.api import ReputationPolicyViewSet
|
from passbook.policies.reputation.api import ReputationPolicyViewSet
|
||||||
from passbook.providers.app_gw.api import ApplicationGatewayProviderViewSet
|
from passbook.providers.oauth2.api import OAuth2ProviderViewSet, ScopeMappingViewSet
|
||||||
from passbook.providers.oauth.api import OAuth2ProviderViewSet
|
from passbook.providers.proxy.api import ProxyProviderViewSet
|
||||||
from passbook.providers.oidc.api import OpenIDProviderViewSet
|
|
||||||
from passbook.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet
|
from passbook.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet
|
||||||
from passbook.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet
|
from passbook.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet
|
||||||
from passbook.sources.oauth.api import OAuthSourceViewSet
|
from passbook.sources.oauth.api import OAuthSourceViewSet
|
||||||
|
@ -70,14 +69,14 @@ router.register("policies/password", PasswordPolicyViewSet)
|
||||||
router.register("policies/reputation", ReputationPolicyViewSet)
|
router.register("policies/reputation", ReputationPolicyViewSet)
|
||||||
|
|
||||||
router.register("providers/all", ProviderViewSet)
|
router.register("providers/all", ProviderViewSet)
|
||||||
router.register("providers/applicationgateway", ApplicationGatewayProviderViewSet)
|
router.register("providers/proxy", ProxyProviderViewSet)
|
||||||
router.register("providers/oauth", OAuth2ProviderViewSet)
|
router.register("providers/oauth2", OAuth2ProviderViewSet)
|
||||||
router.register("providers/openid", OpenIDProviderViewSet)
|
|
||||||
router.register("providers/saml", SAMLProviderViewSet)
|
router.register("providers/saml", SAMLProviderViewSet)
|
||||||
|
|
||||||
router.register("propertymappings/all", PropertyMappingViewSet)
|
router.register("propertymappings/all", PropertyMappingViewSet)
|
||||||
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
|
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
|
||||||
router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
|
router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
|
||||||
|
router.register("propertymappings/scope", ScopeMappingViewSet)
|
||||||
|
|
||||||
router.register("stages/all", StageViewSet)
|
router.register("stages/all", StageViewSet)
|
||||||
router.register("stages/captcha", CaptchaStageViewSet)
|
router.register("stages/captcha", CaptchaStageViewSet)
|
||||||
|
|
|
@ -18,7 +18,7 @@ def admin_autoregister(app: AppConfig):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
for app in apps.get_app_configs():
|
for _app in apps.get_app_configs():
|
||||||
if app.label.startswith("passbook_"):
|
if _app.label.startswith("passbook_"):
|
||||||
LOGGER.debug("Registering application for dj-admin", app=app.label)
|
LOGGER.debug("Registering application for dj-admin", app=_app.label)
|
||||||
admin_autoregister(app)
|
admin_autoregister(_app)
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
{% load passbook_utils %}
|
{% load passbook_utils %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% trans 'Bad Request' %}
|
{% trans card_title %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block card %}
|
{% block card %}
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
"""passbook crypto models"""
|
"""passbook crypto models"""
|
||||||
from binascii import hexlify
|
from binascii import hexlify
|
||||||
|
from hashlib import md5
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
from cryptography.hazmat.primitives import hashes
|
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.hazmat.primitives.serialization import load_pem_private_key
|
||||||
from cryptography.x509 import Certificate, load_pem_x509_certificate
|
from cryptography.x509 import Certificate, load_pem_x509_certificate
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
@ -31,7 +32,8 @@ class CertificateKeyPair(CreatedUpdatedModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
_cert: Optional[Certificate] = None
|
_cert: Optional[Certificate] = None
|
||||||
_key: Optional[RSAPrivateKey] = None
|
_private_key: Optional[RSAPrivateKey] = None
|
||||||
|
_public_key: Optional[RSAPublicKey] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def certificate(self) -> Certificate:
|
def certificate(self) -> Certificate:
|
||||||
|
@ -42,16 +44,23 @@ class CertificateKeyPair(CreatedUpdatedModel):
|
||||||
)
|
)
|
||||||
return self._cert
|
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
|
@property
|
||||||
def private_key(self) -> Optional[RSAPrivateKey]:
|
def private_key(self) -> Optional[RSAPrivateKey]:
|
||||||
"""Get python cryptography PrivateKey instance"""
|
"""Get python cryptography PrivateKey instance"""
|
||||||
if not self._key:
|
if not self._private_key:
|
||||||
self._key = load_pem_private_key(
|
self._private_key = load_pem_private_key(
|
||||||
str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])),
|
str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])),
|
||||||
password=None,
|
password=None,
|
||||||
backend=default_backend(),
|
backend=default_backend(),
|
||||||
)
|
)
|
||||||
return self._key
|
return self._private_key
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def fingerprint(self) -> str:
|
def fingerprint(self) -> str:
|
||||||
|
@ -60,6 +69,15 @@ class CertificateKeyPair(CreatedUpdatedModel):
|
||||||
"utf-8"
|
"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:
|
def __str__(self) -> str:
|
||||||
return f"Certificate-Key Pair {self.name} {self.fingerprint}"
|
return f"Certificate-Key Pair {self.name} {self.fingerprint}"
|
||||||
|
|
||||||
|
|
|
@ -66,6 +66,8 @@ class Stage(models.Model):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
if hasattr(self, "__in_memory_type"):
|
||||||
|
return f"In-memory Stage {getattr(self, '__in_memory_type')}"
|
||||||
return f"Stage {self.name}"
|
return f"Stage {self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,6 @@
|
||||||
{% trans 'Something went wrong! Please try again later.' %}
|
{% trans 'Something went wrong! Please try again later.' %}
|
||||||
</h3>
|
</h3>
|
||||||
{% if debug %}
|
{% if debug %}
|
||||||
<pre class="pb-exception">{{ tb }}</pre>
|
<pre class="pb-exception">{{ tb }}{{ error }}</pre>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -85,7 +85,6 @@ def verbose_name(obj) -> str:
|
||||||
if not obj:
|
if not obj:
|
||||||
return ""
|
return ""
|
||||||
if hasattr(obj, "verbose_name"):
|
if hasattr(obj, "verbose_name"):
|
||||||
print(obj.verbose_name)
|
|
||||||
return obj.verbose_name
|
return obj.verbose_name
|
||||||
return obj._meta.verbose_name
|
return obj._meta.verbose_name
|
||||||
|
|
||||||
|
|
|
@ -26,11 +26,13 @@ class CreateAssignPermView(CreateView):
|
||||||
return response
|
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 generic error page with message, with status code set to 400"""
|
||||||
return TemplateResponse(
|
return TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"error/generic.html",
|
"error/generic.html",
|
||||||
{"message": message, "card_title": _("Bad Request")},
|
{"message": message, "card_title": _(title)},
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
"""HIBP Policy tests"""
|
"""HIBP Policy tests"""
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from guardian.shortcuts import get_anonymous_user
|
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.hibp.models import HaveIBeenPwendPolicy
|
||||||
from passbook.policies.types import PolicyRequest, PolicyResult
|
from passbook.policies.types import PolicyRequest, PolicyResult
|
||||||
|
from passbook.providers.oauth2.generators import generate_client_secret
|
||||||
|
|
||||||
|
|
||||||
class TestHIBPPolicy(TestCase):
|
class TestHIBPPolicy(TestCase):
|
||||||
|
|
|
@ -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
|
|
|
@ -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/"
|
|
|
@ -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(),
|
|
||||||
}
|
|
|
@ -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",),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -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]),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -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"))
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -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")
|
|
|
@ -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
|
|
|
@ -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 = ""
|
|
|
@ -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"),
|
|
||||||
}
|
|
|
@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -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")
|
|
|
@ -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",
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,40 +0,0 @@
|
||||||
{% load i18n %}
|
|
||||||
<button class="pf-c-button pf-m-tertiary" data-target="modal" data-modal="oauth-{{ provider.pk }}">{% trans 'View Setup URLs' %}</button>
|
|
||||||
|
|
||||||
<div class="pf-c-backdrop" id="oauth-{{ provider.pk }}" hidden>
|
|
||||||
<div class="pf-l-bullseye">
|
|
||||||
<div class="pf-c-modal-box pf-m-lg" role="dialog">
|
|
||||||
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
|
|
||||||
<i class="fas fa-times" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
<div class="pf-c-modal-box__header">
|
|
||||||
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Setup URLs' %}</h1>
|
|
||||||
</div>
|
|
||||||
<div class="pf-c-modal-box__body" id="modal-description">
|
|
||||||
<form class="pf-c-form">
|
|
||||||
<div class="pf-c-form__group">
|
|
||||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
|
||||||
<span class="pf-c-form__label-text">{% trans 'Authorize URL' %}</span>
|
|
||||||
</label>
|
|
||||||
<input class="pf-c-form-control" readonly type="text" value="{{ authorize_url }}" />
|
|
||||||
</div>
|
|
||||||
<div class="pf-c-form__group">
|
|
||||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
|
||||||
<span class="pf-c-form__label-text">{% trans 'Token URL' %}</span>
|
|
||||||
</label>
|
|
||||||
<input class="pf-c-form-control" readonly type="text" value="{{ token_url }}" />
|
|
||||||
</div>
|
|
||||||
<div class="pf-c-form__group">
|
|
||||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
|
||||||
<span class="pf-c-form__label-text">{% trans 'Userinfo Endpoint' %}</span>
|
|
||||||
</label>
|
|
||||||
<input class="pf-c-form-control" readonly type="text" value="{{ userinfo_url }}" />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<footer class="pf-c-modal-box__footer pf-m-align-left">
|
|
||||||
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -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)),
|
|
||||||
]
|
|
|
@ -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)
|
|
50
passbook/providers/oauth2/api.py
Normal file
50
passbook/providers/oauth2/api.py
Normal file
|
@ -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
|
14
passbook/providers/oauth2/apps.py
Normal file
14
passbook/providers/oauth2/apps.py
Normal file
|
@ -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": "",
|
||||||
|
}
|
19
passbook/providers/oauth2/constants.py
Normal file
19
passbook/providers/oauth2/constants.py
Normal file
|
@ -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"
|
178
passbook/providers/oauth2/errors.py
Normal file
178
passbook/providers/oauth2/errors.py
Normal file
|
@ -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]
|
80
passbook/providers/oauth2/forms.py
Normal file
80
passbook/providers/oauth2/forms.py
Normal file
|
@ -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 <b>can</b> 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"),
|
||||||
|
}
|
17
passbook/providers/oauth2/generators.py
Normal file
17
passbook/providers/oauth2/generators.py
Normal file
|
@ -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)
|
||||||
|
)
|
357
passbook/providers/oauth2/migrations/0001_initial.py
Normal file
357
passbook/providers/oauth2/migrations/0001_initial.py
Normal file
|
@ -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="<b>Confidential</b> clients are capable of maintaining the confidentiality\n of their credentials. <b>Public</b> 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",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
445
passbook/providers/oauth2/models.py
Normal file
445
passbook/providers/oauth2/models.py
Normal file
|
@ -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):
|
||||||
|
"""<b>Confidential</b> clients are capable of maintaining the confidentiality
|
||||||
|
of their credentials. <b>Public</b> 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
|
|
@ -1,8 +1,8 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
<button class="pf-c-button pf-m-tertiary" data-target="modal" data-modal="oidc-{{ provider.pk }}">{% trans 'View Setup URLs' %}</button>
|
<button class="pf-c-button pf-m-tertiary" data-target="modal" data-modal="oauth2-{{ provider.pk }}">{% trans 'View Setup URLs' %}</button>
|
||||||
|
|
||||||
<div class="pf-c-backdrop" id="oidc-{{ provider.pk }}" hidden>
|
<div class="pf-c-backdrop" id="oauth2-{{ provider.pk }}" hidden>
|
||||||
<div class="pf-l-bullseye">
|
<div class="pf-l-bullseye">
|
||||||
<div class="pf-c-modal-box pf-m-lg" role="dialog">
|
<div class="pf-c-modal-box pf-m-lg" role="dialog">
|
||||||
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
|
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
|
35
passbook/providers/oauth2/urls.py
Normal file
35
passbook/providers/oauth2/urls.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
"""OAuth provider URLs"""
|
||||||
|
from django.urls import path
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
|
from passbook.providers.oauth2.constants import SCOPE_OPENID
|
||||||
|
from passbook.providers.oauth2.utils import protected_resource_view
|
||||||
|
from passbook.providers.oauth2.views.authorize import AuthorizationFlowInitView
|
||||||
|
from passbook.providers.oauth2.views.introspection import TokenIntrospectionView
|
||||||
|
from passbook.providers.oauth2.views.jwks import JWKSView
|
||||||
|
from passbook.providers.oauth2.views.provider import ProviderInfoView
|
||||||
|
from passbook.providers.oauth2.views.session import EndSessionView
|
||||||
|
from passbook.providers.oauth2.views.token import TokenView
|
||||||
|
from passbook.providers.oauth2.views.userinfo import UserInfoView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("authorize/", AuthorizationFlowInitView.as_view(), name="authorize",),
|
||||||
|
path("token/", csrf_exempt(TokenView.as_view()), name="token"),
|
||||||
|
path(
|
||||||
|
"userinfo/",
|
||||||
|
csrf_exempt(protected_resource_view([SCOPE_OPENID])(UserInfoView.as_view())),
|
||||||
|
name="userinfo",
|
||||||
|
),
|
||||||
|
path("end-session/", EndSessionView.as_view(), name="end-session",),
|
||||||
|
path(
|
||||||
|
"introspect/",
|
||||||
|
csrf_exempt(TokenIntrospectionView.as_view()),
|
||||||
|
name="token-introspection",
|
||||||
|
),
|
||||||
|
path("<slug:application_slug>/jwks/", JWKSView.as_view(), name="jwks"),
|
||||||
|
path(
|
||||||
|
"<slug:application_slug>/.well-known/openid-configuration",
|
||||||
|
ProviderInfoView.as_view(),
|
||||||
|
name="provider-info",
|
||||||
|
),
|
||||||
|
]
|
45
passbook/providers/oauth2/urls_github.py
Normal file
45
passbook/providers/oauth2/urls_github.py
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
"""passbook oauth_provider urls"""
|
||||||
|
from django.urls import include, path
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
|
from passbook.providers.oauth2.constants import (
|
||||||
|
SCOPE_GITHUB_ORG_READ,
|
||||||
|
SCOPE_GITHUB_USER_EMAIL,
|
||||||
|
)
|
||||||
|
from passbook.providers.oauth2.utils import protected_resource_view
|
||||||
|
from passbook.providers.oauth2.views.authorize import AuthorizationFlowInitView
|
||||||
|
from passbook.providers.oauth2.views.github import GitHubUserTeamsView, GitHubUserView
|
||||||
|
from passbook.providers.oauth2.views.token import TokenView
|
||||||
|
|
||||||
|
github_urlpatterns = [
|
||||||
|
path(
|
||||||
|
"login/oauth/authorize",
|
||||||
|
AuthorizationFlowInitView.as_view(),
|
||||||
|
name="github-authorize",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"login/oauth/access_token",
|
||||||
|
csrf_exempt(TokenView.as_view()),
|
||||||
|
name="github-access-token",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"user",
|
||||||
|
csrf_exempt(
|
||||||
|
protected_resource_view([SCOPE_GITHUB_USER_EMAIL])(GitHubUserView.as_view())
|
||||||
|
),
|
||||||
|
name="github-user",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"user/teams",
|
||||||
|
csrf_exempt(
|
||||||
|
protected_resource_view([SCOPE_GITHUB_ORG_READ])(
|
||||||
|
GitHubUserTeamsView.as_view()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
name="github-user-teams",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(github_urlpatterns)),
|
||||||
|
]
|
152
passbook/providers/oauth2/utils.py
Normal file
152
passbook/providers/oauth2/utils.py
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
"""OAuth2/OpenID Utils"""
|
||||||
|
import re
|
||||||
|
from base64 import b64decode
|
||||||
|
from binascii import Error
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||||
|
from django.utils.cache import patch_vary_headers
|
||||||
|
from jwkest.jwt import JWT
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from passbook.providers.oauth2.errors import BearerTokenError
|
||||||
|
from passbook.providers.oauth2.models import RefreshToken
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(JsonResponse):
|
||||||
|
"""JSON Response with headers that it should never be cached
|
||||||
|
|
||||||
|
https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self["Cache-Control"] = "no-store"
|
||||||
|
self["Pragma"] = "no-cache"
|
||||||
|
|
||||||
|
|
||||||
|
def cors_allow_any(request, response):
|
||||||
|
"""
|
||||||
|
Add headers to permit CORS requests from any origin, with or without credentials,
|
||||||
|
with any headers.
|
||||||
|
"""
|
||||||
|
origin = request.META.get("HTTP_ORIGIN")
|
||||||
|
if not origin:
|
||||||
|
return response
|
||||||
|
|
||||||
|
# From the CORS spec: The string "*" cannot be used for a resource that supports credentials.
|
||||||
|
response["Access-Control-Allow-Origin"] = origin
|
||||||
|
patch_vary_headers(response, ["Origin"])
|
||||||
|
response["Access-Control-Allow-Credentials"] = "true"
|
||||||
|
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
if "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" in request.META:
|
||||||
|
response["Access-Control-Allow-Headers"] = request.META[
|
||||||
|
"HTTP_ACCESS_CONTROL_REQUEST_HEADERS"
|
||||||
|
]
|
||||||
|
response["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def extract_access_token(request: HttpRequest) -> str:
|
||||||
|
"""
|
||||||
|
Get the access token using Authorization Request Header Field method.
|
||||||
|
Or try getting via GET.
|
||||||
|
See: http://tools.ietf.org/html/rfc6750#section-2.1
|
||||||
|
|
||||||
|
Return a string.
|
||||||
|
"""
|
||||||
|
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||||
|
|
||||||
|
if re.compile(r"^[Bb]earer\s{1}.+$").match(auth_header):
|
||||||
|
access_token = auth_header.split()[1]
|
||||||
|
else:
|
||||||
|
access_token = request.GET.get("access_token", "")
|
||||||
|
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
|
def extract_client_auth(request: HttpRequest) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Get client credentials using HTTP Basic Authentication method.
|
||||||
|
Or try getting parameters via POST.
|
||||||
|
See: http://tools.ietf.org/html/rfc6750#section-2.1
|
||||||
|
|
||||||
|
Return a tuple `(client_id, client_secret)`.
|
||||||
|
"""
|
||||||
|
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||||
|
|
||||||
|
if re.compile(r"^Basic\s{1}.+$").match(auth_header):
|
||||||
|
b64_user_pass = auth_header.split()[1]
|
||||||
|
try:
|
||||||
|
user_pass = b64decode(b64_user_pass).decode("utf-8").split(":")
|
||||||
|
client_id, client_secret = tuple(user_pass)
|
||||||
|
except (ValueError, Error):
|
||||||
|
client_id = client_secret = ""
|
||||||
|
else:
|
||||||
|
client_id = request.POST.get("client_id", "")
|
||||||
|
client_secret = request.POST.get("client_secret", "")
|
||||||
|
|
||||||
|
return (client_id, client_secret)
|
||||||
|
|
||||||
|
|
||||||
|
def protected_resource_view(scopes: List[str]):
|
||||||
|
"""View decorator. The client accesses protected resources by presenting the
|
||||||
|
access token to the resource server.
|
||||||
|
|
||||||
|
https://tools.ietf.org/html/rfc6749#section-7
|
||||||
|
|
||||||
|
This decorator also injects the token into `kwargs`"""
|
||||||
|
|
||||||
|
def wrapper(view):
|
||||||
|
def view_wrapper(request, *args, **kwargs):
|
||||||
|
access_token = extract_access_token(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
kwargs["token"] = RefreshToken.objects.get(
|
||||||
|
access_token=access_token
|
||||||
|
)
|
||||||
|
except RefreshToken.DoesNotExist:
|
||||||
|
LOGGER.debug("Token does not exist", access_token=access_token)
|
||||||
|
raise BearerTokenError("invalid_token")
|
||||||
|
|
||||||
|
if kwargs["token"].is_expired:
|
||||||
|
LOGGER.debug("Token has expired", access_token=access_token)
|
||||||
|
raise BearerTokenError("invalid_token")
|
||||||
|
|
||||||
|
if not set(scopes).issubset(set(kwargs["token"].scope)):
|
||||||
|
LOGGER.warning(
|
||||||
|
"Scope missmatch.",
|
||||||
|
required=set(scopes),
|
||||||
|
token_has=set(kwargs["token"].scope),
|
||||||
|
)
|
||||||
|
raise BearerTokenError("insufficient_scope")
|
||||||
|
except BearerTokenError as error:
|
||||||
|
response = HttpResponse(status=error.status)
|
||||||
|
response[
|
||||||
|
"WWW-Authenticate"
|
||||||
|
] = f'error="{error.code}", error_description="{error.description}"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
return view(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return view_wrapper
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def client_id_from_id_token(id_token):
|
||||||
|
"""
|
||||||
|
Extracts the client id from a JSON Web Token (JWT).
|
||||||
|
Returns a string or None.
|
||||||
|
"""
|
||||||
|
payload = JWT().unpack(id_token).payload()
|
||||||
|
aud = payload.get("aud", None)
|
||||||
|
if aud is None:
|
||||||
|
return None
|
||||||
|
if isinstance(aud, list):
|
||||||
|
return aud[0]
|
||||||
|
return aud
|
374
passbook/providers/oauth2/views/authorize.py
Normal file
374
passbook/providers/oauth2/views/authorize.py
Normal file
|
@ -0,0 +1,374 @@
|
||||||
|
"""passbook OAuth2 Authorization views"""
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional, Set
|
||||||
|
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views import View
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from passbook.core.models import Application, Token
|
||||||
|
from passbook.flows.models import in_memory_stage
|
||||||
|
from passbook.flows.planner import (
|
||||||
|
PLAN_CONTEXT_APPLICATION,
|
||||||
|
PLAN_CONTEXT_SSO,
|
||||||
|
FlowPlan,
|
||||||
|
FlowPlanner,
|
||||||
|
)
|
||||||
|
from passbook.flows.stage import StageView
|
||||||
|
from passbook.flows.views import SESSION_KEY_PLAN
|
||||||
|
from passbook.lib.utils.time import timedelta_from_string
|
||||||
|
from passbook.lib.utils.urls import redirect_with_qs
|
||||||
|
from passbook.lib.views import bad_request_message
|
||||||
|
from passbook.policies.mixins import PolicyAccessMixin
|
||||||
|
from passbook.providers.oauth2.constants import (
|
||||||
|
PROMPT_CONSNET,
|
||||||
|
PROMPT_NONE,
|
||||||
|
SCOPE_OPENID,
|
||||||
|
)
|
||||||
|
from passbook.providers.oauth2.errors import (
|
||||||
|
AuthorizeError,
|
||||||
|
ClientIdError,
|
||||||
|
OAuth2Error,
|
||||||
|
RedirectUriError,
|
||||||
|
)
|
||||||
|
from passbook.providers.oauth2.models import (
|
||||||
|
AuthorizationCode,
|
||||||
|
GrantTypes,
|
||||||
|
OAuth2Provider,
|
||||||
|
ResponseTypes,
|
||||||
|
)
|
||||||
|
from passbook.providers.oauth2.views.userinfo import UserInfoView
|
||||||
|
from passbook.stages.consent.models import ConsentMode, ConsentStage
|
||||||
|
from passbook.stages.consent.stage import (
|
||||||
|
PLAN_CONTEXT_CONSENT_TEMPLATE,
|
||||||
|
ConsentStageView,
|
||||||
|
)
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
PLAN_CONTEXT_PARAMS = "params"
|
||||||
|
PLAN_CONTEXT_SCOPE_DESCRIPTIONS = "scope_descriptions"
|
||||||
|
|
||||||
|
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
# pylint: disable=too-many-instance-attributes
|
||||||
|
class OAuthAuthorizationParams:
|
||||||
|
"""Parameteres required to authorize an OAuth Client"""
|
||||||
|
|
||||||
|
client_id: str
|
||||||
|
redirect_uri: str
|
||||||
|
response_type: str
|
||||||
|
scope: List[str]
|
||||||
|
state: str
|
||||||
|
nonce: str
|
||||||
|
prompt: Set[str]
|
||||||
|
grant_type: str
|
||||||
|
|
||||||
|
provider: OAuth2Provider = field(default_factory=OAuth2Provider)
|
||||||
|
|
||||||
|
code_challenge: Optional[str] = None
|
||||||
|
code_challenge_method: Optional[str] = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_request(request: HttpRequest) -> "OAuthAuthorizationParams":
|
||||||
|
"""
|
||||||
|
Get all the params used by the Authorization Code Flow
|
||||||
|
(and also for the Implicit and Hybrid).
|
||||||
|
|
||||||
|
See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
|
||||||
|
"""
|
||||||
|
# Because in this endpoint we handle both GET
|
||||||
|
# and POST request.
|
||||||
|
query_dict = request.POST if request.method == "POST" else request.GET
|
||||||
|
|
||||||
|
response_type = query_dict.get("response_type", "")
|
||||||
|
grant_type = None
|
||||||
|
# Determine which flow to use.
|
||||||
|
if response_type in [ResponseTypes.CODE]:
|
||||||
|
grant_type = GrantTypes.AUTHORIZATION_CODE
|
||||||
|
elif response_type in [
|
||||||
|
ResponseTypes.id_token,
|
||||||
|
ResponseTypes.id_token_token,
|
||||||
|
ResponseTypes.token,
|
||||||
|
]:
|
||||||
|
grant_type = GrantTypes.IMPLICIT
|
||||||
|
elif response_type in [
|
||||||
|
ResponseTypes.CODE_TOKEN,
|
||||||
|
ResponseTypes.CODE_ID_TOKEN,
|
||||||
|
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||||
|
]:
|
||||||
|
grant_type = GrantTypes.HYBRID
|
||||||
|
|
||||||
|
# Grant type validation.
|
||||||
|
if not grant_type:
|
||||||
|
LOGGER.warning("Invalid response type", type=response_type)
|
||||||
|
raise AuthorizeError(
|
||||||
|
query_dict.get("redirect_uri", ""),
|
||||||
|
"unsupported_response_type",
|
||||||
|
grant_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return OAuthAuthorizationParams(
|
||||||
|
client_id=query_dict.get("client_id", ""),
|
||||||
|
redirect_uri=query_dict.get("redirect_uri", ""),
|
||||||
|
response_type=response_type,
|
||||||
|
grant_type=grant_type,
|
||||||
|
scope=query_dict.get("scope", "").split(),
|
||||||
|
state=query_dict.get("state", ""),
|
||||||
|
nonce=query_dict.get("nonce", ""),
|
||||||
|
prompt=ALLOWED_PROMPT_PARAMS.intersection(
|
||||||
|
set(query_dict.get("prompt", "").split())
|
||||||
|
),
|
||||||
|
code_challenge=query_dict.get("code_challenge"),
|
||||||
|
code_challenge_method=query_dict.get("code_challenge_method"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
try:
|
||||||
|
self.provider: OAuth2Provider = OAuth2Provider.objects.get(
|
||||||
|
client_id=self.client_id
|
||||||
|
)
|
||||||
|
except OAuth2Provider.DoesNotExist:
|
||||||
|
LOGGER.warning("Invalid client identifier", client_id=self.client_id)
|
||||||
|
raise ClientIdError()
|
||||||
|
is_open_id = SCOPE_OPENID in self.scope
|
||||||
|
|
||||||
|
# Redirect URI validation.
|
||||||
|
if is_open_id and not self.redirect_uri:
|
||||||
|
LOGGER.warning("Missing redirect uri.")
|
||||||
|
raise RedirectUriError()
|
||||||
|
if self.redirect_uri not in self.provider.redirect_uris:
|
||||||
|
LOGGER.warning("Invalid redirect uri", redirect_uri=self.redirect_uri)
|
||||||
|
raise RedirectUriError()
|
||||||
|
|
||||||
|
if not is_open_id and (
|
||||||
|
self.grant_type == GrantTypes.HYBRID
|
||||||
|
or self.response_type
|
||||||
|
in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN]
|
||||||
|
):
|
||||||
|
LOGGER.warning("Missing 'openid' scope.")
|
||||||
|
raise AuthorizeError(self.redirect_uri, "invalid_scope", self.grant_type)
|
||||||
|
|
||||||
|
# Nonce parameter validation.
|
||||||
|
if is_open_id and self.grant_type == GrantTypes.IMPLICIT and not self.nonce:
|
||||||
|
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type)
|
||||||
|
|
||||||
|
# Response type parameter validation.
|
||||||
|
if is_open_id and self.response_type != self.provider.response_type:
|
||||||
|
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type)
|
||||||
|
|
||||||
|
# PKCE validation of the transformation method.
|
||||||
|
if self.code_challenge:
|
||||||
|
if not (self.code_challenge_method in ["plain", "S256"]):
|
||||||
|
raise AuthorizeError(
|
||||||
|
self.redirect_uri, "invalid_request", self.grant_type
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_code(self, request: HttpRequest) -> AuthorizationCode:
|
||||||
|
"""Create an AuthorizationCode object for the request"""
|
||||||
|
code = AuthorizationCode()
|
||||||
|
code.user = request.user
|
||||||
|
code.provider = self.provider
|
||||||
|
|
||||||
|
code.code = uuid4().hex
|
||||||
|
|
||||||
|
if self.code_challenge and self.code_challenge_method:
|
||||||
|
code.code_challenge = self.code_challenge
|
||||||
|
code.code_challenge_method = self.code_challenge_method
|
||||||
|
|
||||||
|
code.expires_at = timezone.now() + timedelta_from_string(
|
||||||
|
self.provider.token_validity
|
||||||
|
)
|
||||||
|
code.scope = self.scope
|
||||||
|
code.nonce = self.nonce
|
||||||
|
code.is_open_id = SCOPE_OPENID in self.scope
|
||||||
|
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthFulfillmentStage(StageView):
|
||||||
|
"""Final stage, restores params from Flow."""
|
||||||
|
|
||||||
|
params: OAuthAuthorizationParams
|
||||||
|
provider: OAuth2Provider
|
||||||
|
|
||||||
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
|
self.params: OAuthAuthorizationParams = self.executor.plan.context.pop(
|
||||||
|
PLAN_CONTEXT_PARAMS
|
||||||
|
)
|
||||||
|
application: Application = self.executor.plan.context.pop(
|
||||||
|
PLAN_CONTEXT_APPLICATION
|
||||||
|
)
|
||||||
|
self.provider = get_object_or_404(OAuth2Provider, pk=application.provider_id)
|
||||||
|
try:
|
||||||
|
# At this point we don't need to check permissions anymore
|
||||||
|
if {PROMPT_NONE, PROMPT_CONSNET}.issubset(self.params.prompt):
|
||||||
|
raise AuthorizeError(
|
||||||
|
self.params.redirect_uri,
|
||||||
|
"consent_required",
|
||||||
|
self.params.grant_type,
|
||||||
|
)
|
||||||
|
return redirect(self.create_response_uri())
|
||||||
|
except (ClientIdError, RedirectUriError) as error:
|
||||||
|
# pylint: disable=no-member
|
||||||
|
return bad_request_message(request, error.description, title=error.error)
|
||||||
|
except AuthorizeError as error:
|
||||||
|
uri = error.create_uri(self.params.redirect_uri, self.params.state)
|
||||||
|
return redirect(uri)
|
||||||
|
|
||||||
|
def create_response_uri(self) -> str:
|
||||||
|
"""Create a final Response URI the user is redirected to."""
|
||||||
|
uri = urlsplit(self.params.redirect_uri)
|
||||||
|
query_params = parse_qs(uri.query)
|
||||||
|
query_fragment = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
code = None
|
||||||
|
|
||||||
|
if self.params.grant_type in [
|
||||||
|
GrantTypes.AUTHORIZATION_CODE,
|
||||||
|
GrantTypes.HYBRID,
|
||||||
|
]:
|
||||||
|
code = self.params.create_code(self.request)
|
||||||
|
code.save()
|
||||||
|
|
||||||
|
if self.params.grant_type == GrantTypes.AUTHORIZATION_CODE:
|
||||||
|
query_params["code"] = code.code
|
||||||
|
query_params["state"] = [
|
||||||
|
str(self.params.state) if self.params.state else ""
|
||||||
|
]
|
||||||
|
elif self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||||
|
token: Token = self.provider.create_token(
|
||||||
|
user=self.request.user, scope=self.params.scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if response_type must include access_token in the response.
|
||||||
|
if self.params.response_type in [
|
||||||
|
ResponseTypes.id_token_token,
|
||||||
|
ResponseTypes.code_id_token_token,
|
||||||
|
ResponseTypes.token,
|
||||||
|
ResponseTypes.code_token,
|
||||||
|
]:
|
||||||
|
query_fragment["access_token"] = token.access_token
|
||||||
|
|
||||||
|
# We don't need id_token if it's an OAuth2 request.
|
||||||
|
if SCOPE_OPENID in self.params.scope:
|
||||||
|
id_token = token.create_id_token(
|
||||||
|
user=self.request.user,
|
||||||
|
request=self.request,
|
||||||
|
scope=self.params.scope,
|
||||||
|
)
|
||||||
|
id_token.nonce = self.params.nonce
|
||||||
|
id_token.scope = self.params.scope
|
||||||
|
# Include at_hash when access_token is being returned.
|
||||||
|
if "access_token" in query_fragment:
|
||||||
|
id_token.at_hash = token.at_hash
|
||||||
|
|
||||||
|
# Check if response_type must include id_token in the response.
|
||||||
|
if self.params.response_type in [
|
||||||
|
ResponseTypes.ID_TOKEN,
|
||||||
|
ResponseTypes.ID_TOKEN_TOKEN,
|
||||||
|
ResponseTypes.CODE_ID_TOKEN,
|
||||||
|
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||||
|
]:
|
||||||
|
query_fragment["id_token"] = id_token.encode(self.provider)
|
||||||
|
token.id_token = id_token
|
||||||
|
else:
|
||||||
|
token.id_token = {}
|
||||||
|
|
||||||
|
# Store the token.
|
||||||
|
token.save()
|
||||||
|
|
||||||
|
# Code parameter must be present if it's Hybrid Flow.
|
||||||
|
if self.params.grant_type == GrantTypes.HYBRID:
|
||||||
|
query_fragment["code"] = code.code
|
||||||
|
|
||||||
|
query_fragment["token_type"] = "bearer"
|
||||||
|
query_fragment["expires_in"] = timedelta_from_string(
|
||||||
|
self.provider.token_validity
|
||||||
|
).seconds
|
||||||
|
query_fragment["state"] = self.params.state if self.params.state else ""
|
||||||
|
|
||||||
|
except OAuth2Error as error:
|
||||||
|
LOGGER.exception("Error when trying to create response uri", error=error)
|
||||||
|
raise AuthorizeError(
|
||||||
|
self.params.redirect_uri, "server_error", self.params.grant_type
|
||||||
|
)
|
||||||
|
|
||||||
|
uri = uri._replace(
|
||||||
|
query=urlencode(query_params, doseq=True),
|
||||||
|
fragment=uri.fragment + urlencode(query_fragment, doseq=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
return urlunsplit(uri)
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
# TODO: This whole block should be moved to a base class
|
||||||
|
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()
|
||||||
|
# TODO: End block
|
||||||
|
# Extract params so we can save them in the plan context
|
||||||
|
try:
|
||||||
|
params = OAuthAuthorizationParams.from_request(request)
|
||||||
|
except (ClientIdError, RedirectUriError) as error:
|
||||||
|
# pylint: disable=no-member
|
||||||
|
return bad_request_message(request, error.description, title=error.error)
|
||||||
|
# Regardless, we start the planner and return to it
|
||||||
|
planner = FlowPlanner(provider.authorization_flow)
|
||||||
|
# planner.use_cache = False
|
||||||
|
planner.allow_empty_flows = True
|
||||||
|
plan: FlowPlan = planner.plan(
|
||||||
|
self.request,
|
||||||
|
{
|
||||||
|
PLAN_CONTEXT_SSO: True,
|
||||||
|
PLAN_CONTEXT_APPLICATION: application,
|
||||||
|
# OAuth2 related params
|
||||||
|
PLAN_CONTEXT_PARAMS: params,
|
||||||
|
PLAN_CONTEXT_SCOPE_DESCRIPTIONS: UserInfoView().get_scope_descriptions(
|
||||||
|
params.scope
|
||||||
|
),
|
||||||
|
# Consent related params
|
||||||
|
PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oauth2/consent.html",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# OpenID clients can specify a `prompt` parameter, and if its set to consent we
|
||||||
|
# need to inject a consent stage
|
||||||
|
if PROMPT_CONSNET in params.prompt:
|
||||||
|
if not any([isinstance(x, ConsentStageView) for x in plan.stages]):
|
||||||
|
# Plan does not have any consent stage, so we add an in-memory one
|
||||||
|
stage = ConsentStage(
|
||||||
|
name="OAuth2 Provider In-memory consent stage",
|
||||||
|
mode=ConsentMode.ALWAYS_REQUIRE,
|
||||||
|
)
|
||||||
|
plan.append(stage)
|
||||||
|
plan.append(in_memory_stage(OAuthFulfillmentStage))
|
||||||
|
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,
|
||||||
|
)
|
|
@ -1,34 +1,16 @@
|
||||||
"""passbook pretend GitHub Views"""
|
"""passbook pretend GitHub Views"""
|
||||||
from django.core.exceptions import PermissionDenied
|
|
||||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from oauth2_provider.models import AccessToken
|
|
||||||
|
|
||||||
from passbook.core.models import User
|
from passbook.providers.oauth2.models import RefreshToken
|
||||||
|
|
||||||
|
|
||||||
class GitHubPretendView(View):
|
class GitHubUserView(View):
|
||||||
"""Emulate GitHub's API Endpoints"""
|
|
||||||
|
|
||||||
def verify_access_token(self) -> User:
|
|
||||||
"""Verify access token manually since github uses /user?access_token=..."""
|
|
||||||
if "HTTP_AUTHORIZATION" in self.request.META:
|
|
||||||
full_token = self.request.META.get("HTTP_AUTHORIZATION")
|
|
||||||
_, token = full_token.split(" ")
|
|
||||||
elif "access_token" in self.request.GET:
|
|
||||||
token = self.request.GET.get("access_token", "")
|
|
||||||
else:
|
|
||||||
raise PermissionDenied("No access token passed.")
|
|
||||||
return get_object_or_404(AccessToken, token=token).user
|
|
||||||
|
|
||||||
|
|
||||||
class GitHubUserView(GitHubPretendView):
|
|
||||||
"""Emulate GitHub's /user API Endpoint"""
|
"""Emulate GitHub's /user API Endpoint"""
|
||||||
|
|
||||||
def get(self, request: HttpRequest) -> HttpResponse:
|
def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse:
|
||||||
"""Emulate GitHub's /user API Endpoint"""
|
"""Emulate GitHub's /user API Endpoint"""
|
||||||
user = self.verify_access_token()
|
user = token.user
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"login": user.username,
|
"login": user.username,
|
||||||
|
@ -78,9 +60,10 @@ class GitHubUserView(GitHubPretendView):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class GitHubUserTeamsView(GitHubPretendView):
|
class GitHubUserTeamsView(View):
|
||||||
"""Emulate GitHub's /user/teams API Endpoint"""
|
"""Emulate GitHub's /user/teams API Endpoint"""
|
||||||
|
|
||||||
def get(self, request: HttpRequest) -> HttpResponse:
|
# pylint: disable=unused-argument
|
||||||
|
def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse:
|
||||||
"""Emulate GitHub's /user/teams API Endpoint"""
|
"""Emulate GitHub's /user/teams API Endpoint"""
|
||||||
return JsonResponse([], safe=False)
|
return JsonResponse([], safe=False)
|
113
passbook/providers/oauth2/views/introspection.py
Normal file
113
passbook/providers/oauth2/views/introspection.py
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
"""passbook OAuth2 Token Introspection Views"""
|
||||||
|
from dataclasses import InitVar, dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.views import View
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from passbook.providers.oauth2.constants import SCOPE_OPENID_INTROSPECTION
|
||||||
|
from passbook.providers.oauth2.errors import TokenIntrospectionError
|
||||||
|
from passbook.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken
|
||||||
|
from passbook.providers.oauth2.utils import TokenResponse, extract_client_auth
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TokenIntrospectionParams:
|
||||||
|
"""Parameters for Token Introspection"""
|
||||||
|
|
||||||
|
client_id: str
|
||||||
|
client_secret: str
|
||||||
|
|
||||||
|
raw_token: InitVar[str]
|
||||||
|
|
||||||
|
token: Optional[RefreshToken] = None
|
||||||
|
|
||||||
|
provider: Optional[OAuth2Provider] = None
|
||||||
|
id_token: Optional[IDToken] = None
|
||||||
|
|
||||||
|
def __post_init__(self, raw_token: str):
|
||||||
|
try:
|
||||||
|
self.token = RefreshToken.objects.get(access_token=raw_token)
|
||||||
|
except RefreshToken.DoesNotExist:
|
||||||
|
LOGGER.debug("Token does not exist", token=raw_token)
|
||||||
|
raise TokenIntrospectionError()
|
||||||
|
if self.token.has_expired():
|
||||||
|
LOGGER.debug("Token is not valid", token=raw_token)
|
||||||
|
raise TokenIntrospectionError()
|
||||||
|
try:
|
||||||
|
self.provider = OAuth2Provider.objects.get(
|
||||||
|
client_id=self.client_id, client_secret=self.client_secret,
|
||||||
|
)
|
||||||
|
except OAuth2Provider.DoesNotExist:
|
||||||
|
LOGGER.debug("provider for ID not found", client_id=self.client_id)
|
||||||
|
raise TokenIntrospectionError()
|
||||||
|
if SCOPE_OPENID_INTROSPECTION not in self.provider.scope_names:
|
||||||
|
LOGGER.debug(
|
||||||
|
"OAuth2Provider does not have introspection scope",
|
||||||
|
client_id=self.client_id,
|
||||||
|
)
|
||||||
|
raise TokenIntrospectionError()
|
||||||
|
|
||||||
|
self.id_token = self.token.id_token
|
||||||
|
|
||||||
|
if not self.token.id_token:
|
||||||
|
LOGGER.debug(
|
||||||
|
"token not an authentication token", token=self.token,
|
||||||
|
)
|
||||||
|
raise TokenIntrospectionError()
|
||||||
|
|
||||||
|
audience = self.token.id_token.aud
|
||||||
|
if not audience:
|
||||||
|
LOGGER.debug(
|
||||||
|
"No audience found for token", token=self.token,
|
||||||
|
)
|
||||||
|
raise TokenIntrospectionError()
|
||||||
|
|
||||||
|
if audience not in self.provider.scope_names:
|
||||||
|
LOGGER.debug(
|
||||||
|
"provider does not audience scope",
|
||||||
|
client_id=self.client_id,
|
||||||
|
audience=audience,
|
||||||
|
)
|
||||||
|
raise TokenIntrospectionError()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_request(request: HttpRequest) -> "TokenIntrospectionParams":
|
||||||
|
"""Extract required Parameters from HTTP Request"""
|
||||||
|
# Introspection only supports POST requests
|
||||||
|
client_id, client_secret = extract_client_auth(request)
|
||||||
|
return TokenIntrospectionParams(
|
||||||
|
raw_token=request.POST.get("token"),
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenIntrospectionView(View):
|
||||||
|
"""Token Introspection
|
||||||
|
https://tools.ietf.org/html/rfc7662"""
|
||||||
|
|
||||||
|
token: RefreshToken
|
||||||
|
params: TokenIntrospectionParams
|
||||||
|
provider: OAuth2Provider
|
||||||
|
id_token: IDToken
|
||||||
|
|
||||||
|
def post(self, request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Introspection handler"""
|
||||||
|
self.params = TokenIntrospectionParams.from_request(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_dic = {}
|
||||||
|
if self.id_token:
|
||||||
|
token_dict = self.id_token.to_dict()
|
||||||
|
for k in ("aud", "sub", "exp", "iat", "iss"):
|
||||||
|
response_dic[k] = token_dict[k]
|
||||||
|
response_dic["active"] = True
|
||||||
|
response_dic["client_id"] = self.token.provider.client_id
|
||||||
|
|
||||||
|
return TokenResponse(response_dic)
|
||||||
|
except TokenIntrospectionError:
|
||||||
|
return TokenResponse({"active": False})
|
40
passbook/providers/oauth2/views/jwks.py
Normal file
40
passbook/providers/oauth2/views/jwks.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
"""passbook OAuth2 JWKS Views"""
|
||||||
|
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.views import View
|
||||||
|
from jwkest import long_to_base64
|
||||||
|
from jwkest.jwk import import_rsa_key
|
||||||
|
|
||||||
|
from passbook.core.models import Application
|
||||||
|
from passbook.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
|
||||||
|
|
||||||
|
|
||||||
|
class JWKSView(View):
|
||||||
|
"""Show RSA Key data for Provider"""
|
||||||
|
|
||||||
|
def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||||
|
"""Show RSA Key data for Provider"""
|
||||||
|
application = get_object_or_404(Application, slug=application_slug)
|
||||||
|
provider: OAuth2Provider = get_object_or_404(
|
||||||
|
OAuth2Provider, pk=application.provider_id
|
||||||
|
)
|
||||||
|
|
||||||
|
response_data = {}
|
||||||
|
|
||||||
|
if provider.jwt_alg == JWTAlgorithms.RS256:
|
||||||
|
public_key = import_rsa_key(provider.rsa_key.key_data).publickey()
|
||||||
|
response_data["keys"] = [
|
||||||
|
{
|
||||||
|
"kty": "RSA",
|
||||||
|
"alg": "RS256",
|
||||||
|
"use": "sig",
|
||||||
|
"kid": provider.rsa_key.kid,
|
||||||
|
"n": long_to_base64(public_key.n),
|
||||||
|
"e": long_to_base64(public_key.e),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
response = JsonResponse(response_data)
|
||||||
|
response["Access-Control-Allow-Origin"] = "*"
|
||||||
|
|
||||||
|
return response
|
65
passbook/providers/oauth2/views/provider.py
Normal file
65
passbook/providers/oauth2/views/provider.py
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
"""passbook OAuth2 OpenID well-known views"""
|
||||||
|
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404, reverse
|
||||||
|
from django.views import View
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from passbook.core.models import Application
|
||||||
|
from passbook.providers.oauth2.models import OAuth2Provider
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
PLAN_CONTEXT_PARAMS = "params"
|
||||||
|
PLAN_CONTEXT_SCOPES = "scopes"
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderInfoView(View):
|
||||||
|
"""OpenID-compliant Provider Info"""
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def get(
|
||||||
|
self, request: HttpRequest, application_slug: str, *args, **kwargs
|
||||||
|
) -> HttpResponse:
|
||||||
|
"""OpenID-compliant Provider Info"""
|
||||||
|
|
||||||
|
application = get_object_or_404(Application, slug=application_slug)
|
||||||
|
provider: OAuth2Provider = get_object_or_404(
|
||||||
|
OAuth2Provider, pk=application.provider_id
|
||||||
|
)
|
||||||
|
response = JsonResponse(
|
||||||
|
{
|
||||||
|
"issuer": provider.get_issuer(request),
|
||||||
|
"authorization_endpoint": request.build_absolute_uri(
|
||||||
|
reverse("passbook_providers_oauth2:authorize")
|
||||||
|
),
|
||||||
|
"token_endpoint": request.build_absolute_uri(
|
||||||
|
reverse("passbook_providers_oauth2:token")
|
||||||
|
),
|
||||||
|
"userinfo_endpoint": request.build_absolute_uri(
|
||||||
|
reverse("passbook_providers_oauth2:userinfo")
|
||||||
|
),
|
||||||
|
"end_session_endpoint": request.build_absolute_uri(
|
||||||
|
reverse("passbook_providers_oauth2:end-session")
|
||||||
|
),
|
||||||
|
"introspection_endpoint": request.build_absolute_uri(
|
||||||
|
reverse("passbook_providers_oauth2:token-introspection")
|
||||||
|
),
|
||||||
|
"response_types_supported": [provider.response_type],
|
||||||
|
"jwks_uri": request.build_absolute_uri(
|
||||||
|
reverse(
|
||||||
|
"passbook_providers_oauth2:jwks",
|
||||||
|
kwargs={"application_slug": application.slug},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"id_token_signing_alg_values_supported": [provider.jwt_alg],
|
||||||
|
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
|
||||||
|
"subject_types_supported": ["public"],
|
||||||
|
"token_endpoint_auth_methods_supported": [
|
||||||
|
"client_secret_post",
|
||||||
|
"client_secret_basic",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response["Access-Control-Allow-Origin"] = "*"
|
||||||
|
|
||||||
|
return response
|
45
passbook/providers/oauth2/views/session.py
Normal file
45
passbook/providers/oauth2/views/session.py
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
"""passbook OAuth2 Session Views"""
|
||||||
|
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from django.contrib.auth.views import LogoutView
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from passbook.core.models import Application
|
||||||
|
from passbook.providers.oauth2.models import OAuth2Provider
|
||||||
|
from passbook.providers.oauth2.utils import client_id_from_id_token
|
||||||
|
|
||||||
|
|
||||||
|
class EndSessionView(LogoutView):
|
||||||
|
"""Allow the client to end the Session"""
|
||||||
|
|
||||||
|
def dispatch(
|
||||||
|
self, request: HttpRequest, application_slug: str, *args, **kwargs
|
||||||
|
) -> HttpResponse:
|
||||||
|
|
||||||
|
application = get_object_or_404(Application, slug=application_slug)
|
||||||
|
provider: OAuth2Provider = get_object_or_404(
|
||||||
|
OAuth2Provider, pk=application.provider_id
|
||||||
|
)
|
||||||
|
|
||||||
|
id_token_hint = request.GET.get("id_token_hint", "")
|
||||||
|
post_logout_redirect_uri = request.GET.get("post_logout_redirect_uri", "")
|
||||||
|
state = request.GET.get("state", "")
|
||||||
|
|
||||||
|
if id_token_hint:
|
||||||
|
client_id = client_id_from_id_token(id_token_hint)
|
||||||
|
try:
|
||||||
|
provider = OAuth2Provider.objects.get(client_id=client_id)
|
||||||
|
if post_logout_redirect_uri in provider.post_logout_redirect_uris:
|
||||||
|
if state:
|
||||||
|
uri = urlsplit(post_logout_redirect_uri)
|
||||||
|
query_params = parse_qs(uri.query)
|
||||||
|
query_params["state"] = state
|
||||||
|
uri = uri._replace(query=urlencode(query_params, doseq=True))
|
||||||
|
self.next_page = urlunsplit(uri)
|
||||||
|
else:
|
||||||
|
self.next_page = post_logout_redirect_uri
|
||||||
|
except OAuth2Provider.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
241
passbook/providers/oauth2/views/token.py
Normal file
241
passbook/providers/oauth2/views/token.py
Normal file
|
@ -0,0 +1,241 @@
|
||||||
|
"""passbook OAuth2 Token views"""
|
||||||
|
from base64 import urlsafe_b64encode
|
||||||
|
from dataclasses import InitVar, dataclass
|
||||||
|
from hashlib import sha256
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.views import View
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from passbook.lib.utils.time import timedelta_from_string
|
||||||
|
from passbook.providers.oauth2.constants import (
|
||||||
|
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
|
GRANT_TYPE_REFRESH_TOKEN,
|
||||||
|
)
|
||||||
|
from passbook.providers.oauth2.errors import TokenError, UserAuthError
|
||||||
|
from passbook.providers.oauth2.models import (
|
||||||
|
AuthorizationCode,
|
||||||
|
OAuth2Provider,
|
||||||
|
RefreshToken,
|
||||||
|
)
|
||||||
|
from passbook.providers.oauth2.utils import TokenResponse, extract_client_auth
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
# pylint: disable=too-many-instance-attributes
|
||||||
|
class TokenParams:
|
||||||
|
"""Token params"""
|
||||||
|
|
||||||
|
client_id: str
|
||||||
|
client_secret: str
|
||||||
|
redirect_uri: str
|
||||||
|
grant_type: str
|
||||||
|
state: str
|
||||||
|
scope: List[str]
|
||||||
|
|
||||||
|
authorization_code: Optional[AuthorizationCode] = None
|
||||||
|
refresh_token: Optional[RefreshToken] = None
|
||||||
|
|
||||||
|
code_verifier: Optional[str] = None
|
||||||
|
|
||||||
|
raw_code: InitVar[str] = ""
|
||||||
|
raw_token: InitVar[str] = ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_request(request: HttpRequest) -> "TokenParams":
|
||||||
|
"""Extract Token Parameters from http request"""
|
||||||
|
client_id, client_secret = extract_client_auth(request)
|
||||||
|
|
||||||
|
return TokenParams(
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret,
|
||||||
|
redirect_uri=request.POST.get("redirect_uri", ""),
|
||||||
|
grant_type=request.POST.get("grant_type", ""),
|
||||||
|
raw_code=request.POST.get("code", ""),
|
||||||
|
raw_token=request.POST.get("refresh_token", ""),
|
||||||
|
state=request.POST.get("state", ""),
|
||||||
|
scope=request.POST.get("scope", "").split(),
|
||||||
|
# PKCE parameter.
|
||||||
|
code_verifier=request.POST.get("code_verifier"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self, raw_code, raw_token):
|
||||||
|
try:
|
||||||
|
provider: OAuth2Provider = OAuth2Provider.objects.get(
|
||||||
|
client_id=self.client_id
|
||||||
|
)
|
||||||
|
self.provider = provider
|
||||||
|
except OAuth2Provider.DoesNotExist:
|
||||||
|
LOGGER.warning("OAuth2Provider does not exist", client_id=self.client_id)
|
||||||
|
raise TokenError("invalid_client")
|
||||||
|
|
||||||
|
if self.provider.client_type == "confidential":
|
||||||
|
if self.provider.client_secret != self.client_secret:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Invalid client secret: client does not have secret",
|
||||||
|
client_id=self.provider.client_id,
|
||||||
|
secret=self.provider.client_secret,
|
||||||
|
)
|
||||||
|
raise TokenError("invalid_client")
|
||||||
|
|
||||||
|
if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||||
|
self.__post_init_code(raw_code)
|
||||||
|
|
||||||
|
elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||||
|
if not raw_token:
|
||||||
|
LOGGER.warning("Missing refresh token")
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.refresh_token = RefreshToken.objects.get(
|
||||||
|
refresh_token=raw_token, client=self.provider
|
||||||
|
)
|
||||||
|
|
||||||
|
except RefreshToken.DoesNotExist:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Refresh token does not exist", token=raw_token,
|
||||||
|
)
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
|
else:
|
||||||
|
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
||||||
|
raise TokenError("unsupported_grant_type")
|
||||||
|
|
||||||
|
def __post_init_code(self, raw_code):
|
||||||
|
if not raw_code:
|
||||||
|
LOGGER.warning("Missing authorization code")
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
|
if self.redirect_uri not in self.provider.redirect_uris:
|
||||||
|
LOGGER.warning("Invalid redirect uri", uri=self.redirect_uri)
|
||||||
|
raise TokenError("invalid_client")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.authorization_code = AuthorizationCode.objects.get(code=raw_code)
|
||||||
|
except AuthorizationCode.DoesNotExist:
|
||||||
|
LOGGER.warning("Code does not exist", code=raw_code)
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.authorization_code.provider != self.provider
|
||||||
|
or self.authorization_code.is_expired
|
||||||
|
):
|
||||||
|
LOGGER.warning("Invalid code: invalid client or code has expired")
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
|
# Validate PKCE parameters.
|
||||||
|
if self.code_verifier:
|
||||||
|
if self.authorization_code.code_challenge_method == "S256":
|
||||||
|
new_code_challenge = (
|
||||||
|
urlsafe_b64encode(
|
||||||
|
sha256(self.code_verifier.encode("ascii")).digest()
|
||||||
|
)
|
||||||
|
.decode("utf-8")
|
||||||
|
.replace("=", "")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
new_code_challenge = self.code_verifier
|
||||||
|
|
||||||
|
if new_code_challenge != self.authorization_code.code_challenge:
|
||||||
|
LOGGER.warning("Code challenge not matching")
|
||||||
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
|
|
||||||
|
class TokenView(View):
|
||||||
|
"""Generate tokens for clients"""
|
||||||
|
|
||||||
|
params: TokenParams
|
||||||
|
|
||||||
|
def post(self, request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Generate tokens for clients"""
|
||||||
|
try:
|
||||||
|
self.params = TokenParams.from_request(request)
|
||||||
|
|
||||||
|
if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||||
|
return TokenResponse(self.create_code_response_dic())
|
||||||
|
if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||||
|
return TokenResponse(self.create_refresh_response_dic())
|
||||||
|
raise ValueError(f"Invalid grant_type: {self.params.grant_type}")
|
||||||
|
except TokenError as error:
|
||||||
|
return TokenResponse(error.create_dict(), status=400)
|
||||||
|
except UserAuthError as error:
|
||||||
|
return TokenResponse(error.create_dict(), status=403)
|
||||||
|
|
||||||
|
def create_code_response_dic(self) -> Dict[str, Any]:
|
||||||
|
"""See https://tools.ietf.org/html/rfc6749#section-4.1"""
|
||||||
|
|
||||||
|
refresh_token = self.params.authorization_code.provider.create_refresh_token(
|
||||||
|
user=self.params.authorization_code.user,
|
||||||
|
scope=self.params.authorization_code.scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.params.authorization_code.is_open_id:
|
||||||
|
id_token = refresh_token.create_id_token(
|
||||||
|
user=self.params.authorization_code.user, request=self.request,
|
||||||
|
)
|
||||||
|
id_token.nonce = self.params.authorization_code.nonce
|
||||||
|
id_token.at_hash = refresh_token.at_hash
|
||||||
|
refresh_token.id_token = id_token
|
||||||
|
|
||||||
|
# Store the token.
|
||||||
|
refresh_token.save()
|
||||||
|
|
||||||
|
# We don't need to store the code anymore.
|
||||||
|
self.params.authorization_code.delete()
|
||||||
|
|
||||||
|
dic = {
|
||||||
|
"access_token": refresh_token.access_token,
|
||||||
|
"refresh_token": refresh_token.refresh_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": timedelta_from_string(
|
||||||
|
self.params.provider.token_validity
|
||||||
|
).seconds,
|
||||||
|
"id_token": refresh_token.id_token.encode(refresh_token.provider),
|
||||||
|
}
|
||||||
|
|
||||||
|
return dic
|
||||||
|
|
||||||
|
def create_refresh_response_dic(self) -> Dict[str, Any]:
|
||||||
|
"""See https://tools.ietf.org/html/rfc6749#section-6"""
|
||||||
|
|
||||||
|
unauthorized_scopes = set(self.params.scope) - set(
|
||||||
|
self.params.refresh_token.scope
|
||||||
|
)
|
||||||
|
if unauthorized_scopes:
|
||||||
|
raise TokenError("invalid_scope")
|
||||||
|
|
||||||
|
refresh_token = self.params.refresh_token.provider.create_token(
|
||||||
|
user=self.params.refresh_token.user,
|
||||||
|
provider=self.params.refresh_token.provider,
|
||||||
|
scope=self.params.scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
# If the Token has an id_token it's an Authentication request.
|
||||||
|
if self.params.refresh_token.id_token:
|
||||||
|
refresh_token.id_token = refresh_token.create_id_token(
|
||||||
|
user=self.params.refresh_token.user, request=self.request,
|
||||||
|
)
|
||||||
|
refresh_token.id_token.at_hash = refresh_token.at_hash
|
||||||
|
|
||||||
|
# Store the refresh_token.
|
||||||
|
refresh_token.save()
|
||||||
|
|
||||||
|
# Forget the old token.
|
||||||
|
self.params.refresh_token.delete()
|
||||||
|
|
||||||
|
dic = {
|
||||||
|
"access_token": refresh_token.access_token,
|
||||||
|
"refresh_token": refresh_token.refresh_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": timedelta_from_string(
|
||||||
|
refresh_token.provider.token_validity
|
||||||
|
).seconds,
|
||||||
|
"id_token": refresh_token.id_token.encode(
|
||||||
|
self.params.refresh_token.provider
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return dic
|
92
passbook/providers/oauth2/views/userinfo.py
Normal file
92
passbook/providers/oauth2/views/userinfo.py
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
"""passbook OAuth2 OpenID Userinfo views"""
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from django.views import View
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from passbook.providers.oauth2.constants import (
|
||||||
|
SCOPE_GITHUB_ORG_READ,
|
||||||
|
SCOPE_GITHUB_USER,
|
||||||
|
SCOPE_GITHUB_USER_EMAIL,
|
||||||
|
SCOPE_GITHUB_USER_READ,
|
||||||
|
)
|
||||||
|
from passbook.providers.oauth2.models import RefreshToken, ScopeMapping
|
||||||
|
from passbook.providers.oauth2.utils import TokenResponse, cors_allow_any
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class UserInfoView(View):
|
||||||
|
"""Create a dictionary with all the requested claims about the End-User.
|
||||||
|
See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse"""
|
||||||
|
|
||||||
|
def get_scope_descriptions(self, scopes: List[str]) -> List[str]:
|
||||||
|
"""Get a list of all Scopes's descriptions"""
|
||||||
|
scope_descriptions = []
|
||||||
|
for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by(
|
||||||
|
"scope_name"
|
||||||
|
):
|
||||||
|
if scope.description != "":
|
||||||
|
scope_descriptions.append(scope.description)
|
||||||
|
# GitHub Compatibility Scopes are handeled differently, since they required custom paths
|
||||||
|
# Hence they don't exist as Scope objects
|
||||||
|
github_scope_map = {
|
||||||
|
SCOPE_GITHUB_USER: _("GitHub Compatibility: Access your User Information"),
|
||||||
|
SCOPE_GITHUB_USER_READ: _(
|
||||||
|
"GitHub Compatibility: Access your User Information"
|
||||||
|
),
|
||||||
|
SCOPE_GITHUB_USER_EMAIL: _(
|
||||||
|
"GitHub Compatibility: Access you Email addresses"
|
||||||
|
),
|
||||||
|
SCOPE_GITHUB_ORG_READ: _("GitHub Compatibility: Access your Groups"),
|
||||||
|
}
|
||||||
|
for scope in scopes:
|
||||||
|
if scope in github_scope_map:
|
||||||
|
scope_descriptions.append(github_scope_map[scope])
|
||||||
|
return scope_descriptions
|
||||||
|
|
||||||
|
def get_claims(self, token: RefreshToken) -> Dict[str, Any]:
|
||||||
|
"""Get a dictionary of claims from scopes that the token
|
||||||
|
requires and are assigned to the provider."""
|
||||||
|
|
||||||
|
scopes_from_client = token.scope
|
||||||
|
final_claims = {}
|
||||||
|
for scope in ScopeMapping.objects.filter(
|
||||||
|
provider=token.provider, scope_name__in=scopes_from_client
|
||||||
|
).order_by("scope_name"):
|
||||||
|
value = scope.evaluate(
|
||||||
|
user=token.user,
|
||||||
|
request=self.request,
|
||||||
|
provider=token.provider,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
LOGGER.warning(
|
||||||
|
"Scope returned a non-dict value, ignoring",
|
||||||
|
scope=scope,
|
||||||
|
value=value,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
LOGGER.debug("updated scope", scope=scope)
|
||||||
|
final_claims.update(value)
|
||||||
|
return final_claims
|
||||||
|
|
||||||
|
def options(self, request: HttpRequest) -> HttpResponse:
|
||||||
|
return cors_allow_any(self.request, TokenResponse({}))
|
||||||
|
|
||||||
|
def get(self, request: HttpRequest, **kwargs) -> HttpResponse:
|
||||||
|
"""Handle GET Requests for UserInfo"""
|
||||||
|
token: RefreshToken = kwargs["token"]
|
||||||
|
claims = self.get_claims(token)
|
||||||
|
claims["sub"] = token.id_token.sub
|
||||||
|
response = TokenResponse(claims)
|
||||||
|
cors_allow_any(self.request, response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def post(self, request: HttpRequest, **kwargs) -> HttpResponse:
|
||||||
|
"""POST Requests behave the same as GET Requests, so the get handler is called here"""
|
||||||
|
return self.get(request, **kwargs)
|
|
@ -1,34 +0,0 @@
|
||||||
"""OpenIDProvider API Views"""
|
|
||||||
from oidc_provider.models import Client
|
|
||||||
from rest_framework.serializers import ModelSerializer
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
|
||||||
|
|
||||||
# from passbook.providers.oidc.models import OpenIDProvider
|
|
||||||
|
|
||||||
|
|
||||||
class OpenIDProviderSerializer(ModelSerializer):
|
|
||||||
"""OpenIDProvider Serializer"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
|
|
||||||
model = Client
|
|
||||||
fields = [
|
|
||||||
"pk",
|
|
||||||
"name",
|
|
||||||
"client_type",
|
|
||||||
"client_id",
|
|
||||||
"client_secret",
|
|
||||||
"response_types",
|
|
||||||
"jwt_alg",
|
|
||||||
"reuse_consent",
|
|
||||||
"require_consent",
|
|
||||||
"_redirect_uris",
|
|
||||||
"_scope",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class OpenIDProviderViewSet(ModelViewSet):
|
|
||||||
"""OpenIDProvider Viewset"""
|
|
||||||
|
|
||||||
queryset = Client.objects.all()
|
|
||||||
serializer_class = OpenIDProviderSerializer
|
|
|
@ -1,37 +0,0 @@
|
||||||
"""passbook auth oidc provider app config"""
|
|
||||||
from django.apps import AppConfig
|
|
||||||
from django.db.utils import InternalError, OperationalError, ProgrammingError
|
|
||||||
from django.urls import include, path
|
|
||||||
from structlog import get_logger
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
class PassbookProviderOIDCConfig(AppConfig):
|
|
||||||
"""passbook auth oidc provider app config"""
|
|
||||||
|
|
||||||
name = "passbook.providers.oidc"
|
|
||||||
label = "passbook_providers_oidc"
|
|
||||||
verbose_name = "passbook Providers.OIDC"
|
|
||||||
mountpoint = "application/oidc/"
|
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
try:
|
|
||||||
from Cryptodome.PublicKey import RSA
|
|
||||||
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()
|
|
||||||
LOGGER.info("Created key")
|
|
||||||
except (OperationalError, ProgrammingError, InternalError):
|
|
||||||
pass
|
|
||||||
from passbook.root import urls
|
|
||||||
|
|
||||||
urls.urlpatterns.append(
|
|
||||||
path(
|
|
||||||
"application/oidc/",
|
|
||||||
include("oidc_provider.urls", namespace="oidc_provider"),
|
|
||||||
),
|
|
||||||
)
|
|
|
@ -1,67 +0,0 @@
|
||||||
"""OIDC Permission checking"""
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from django.contrib import messages
|
|
||||||
from django.db.models.deletion import Collector
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
|
||||||
from django.shortcuts import redirect
|
|
||||||
from oidc_provider.models import Client
|
|
||||||
from structlog import get_logger
|
|
||||||
|
|
||||||
from passbook.audit.models import Event, EventAction
|
|
||||||
from passbook.core.models import Application, Provider, User
|
|
||||||
from passbook.flows.planner import FlowPlan
|
|
||||||
from passbook.flows.views import SESSION_KEY_PLAN
|
|
||||||
from passbook.policies.engine import PolicyEngine
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
def client_related_provider(client: Client) -> Optional[Provider]:
|
|
||||||
"""Lookup related Application from Client"""
|
|
||||||
# because oidc_provider is also used by app_gw, we can't be
|
|
||||||
# sure an OpenIDProvider instance exists. hence we look through all related models
|
|
||||||
# and choose the one that inherits from Provider, which is guaranteed to
|
|
||||||
# have the application property
|
|
||||||
collector = Collector(using="default")
|
|
||||||
collector.collect([client])
|
|
||||||
for _, related in collector.data.items():
|
|
||||||
related_object = next(iter(related))
|
|
||||||
if isinstance(related_object, Provider):
|
|
||||||
return related_object
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def check_permissions(
|
|
||||||
request: HttpRequest, user: User, client: Client
|
|
||||||
) -> Optional[HttpResponse]:
|
|
||||||
"""Check permissions, used for
|
|
||||||
https://django-oidc-provider.readthedocs.io/en/latest/
|
|
||||||
sections/settings.html#oidc-after-userlogin-hook"""
|
|
||||||
provider = client_related_provider(client)
|
|
||||||
if not provider:
|
|
||||||
return redirect("passbook_flows:denied")
|
|
||||||
try:
|
|
||||||
application = provider.application
|
|
||||||
except Application.DoesNotExist:
|
|
||||||
return redirect("passbook_flows:denied")
|
|
||||||
LOGGER.debug(
|
|
||||||
"Checking permissions for application", user=user, application=application
|
|
||||||
)
|
|
||||||
policy_engine = PolicyEngine(application, user, request)
|
|
||||||
policy_engine.build()
|
|
||||||
|
|
||||||
# Check permissions
|
|
||||||
result = policy_engine.result
|
|
||||||
if not result.passing:
|
|
||||||
for policy_message in result.messages:
|
|
||||||
messages.error(request, policy_message)
|
|
||||||
return redirect("passbook_flows:denied")
|
|
||||||
|
|
||||||
plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
|
||||||
Event.new(
|
|
||||||
EventAction.AUTHORIZE_APPLICATION,
|
|
||||||
authorized_application=application,
|
|
||||||
flow=plan.flow_pk,
|
|
||||||
).from_http(request)
|
|
||||||
return None
|
|
|
@ -1,14 +0,0 @@
|
||||||
"""passbook oidc claim helpers"""
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from passbook.core.models import User
|
|
||||||
|
|
||||||
|
|
||||||
def userinfo(claims: Dict[str, Any], user: User) -> Dict[str, Any]:
|
|
||||||
"""Populate claims from userdata"""
|
|
||||||
claims["name"] = user.name
|
|
||||||
claims["given_name"] = user.name
|
|
||||||
claims["family_name"] = user.name
|
|
||||||
claims["email"] = user.email
|
|
||||||
claims["preferred_username"] = user.username
|
|
||||||
return claims
|
|
|
@ -1,64 +0,0 @@
|
||||||
"""passbook OIDC IDP Forms"""
|
|
||||||
|
|
||||||
from django import forms
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
|
||||||
from oidc_provider.models import Client
|
|
||||||
|
|
||||||
from passbook.flows.models import Flow, FlowDesignation
|
|
||||||
from passbook.providers.oidc.models import OpenIDProvider
|
|
||||||
|
|
||||||
|
|
||||||
class OIDCProviderForm(forms.ModelForm):
|
|
||||||
"""OpenID Client form"""
|
|
||||||
|
|
||||||
authorization_flow = forms.ModelChoiceField(
|
|
||||||
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION),
|
|
||||||
help_text=_("Flow used when authorizing this provider."),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
# Correctly load data from 1:1 rel
|
|
||||||
if "instance" in kwargs and kwargs["instance"]:
|
|
||||||
kwargs["instance"] = kwargs["instance"].oidc_client
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.fields["client_id"].initial = generate_client_id()
|
|
||||||
self.fields["client_secret"].initial = generate_client_secret()
|
|
||||||
try:
|
|
||||||
self.fields[
|
|
||||||
"authorization_flow"
|
|
||||||
].initial = self.instance.openidprovider.authorization_flow
|
|
||||||
# pylint: disable=no-member
|
|
||||||
except Client.openidprovider.RelatedObjectDoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
self.instance.reuse_consent = False # This is managed by passbook
|
|
||||||
self.instance.require_consent = False # This is managed by passbook
|
|
||||||
response = super().save(*args, **kwargs)
|
|
||||||
# Check if openidprovider class instance exists
|
|
||||||
if not OpenIDProvider.objects.filter(oidc_client=self.instance).exists():
|
|
||||||
OpenIDProvider.objects.create(
|
|
||||||
oidc_client=self.instance,
|
|
||||||
authorization_flow=self.cleaned_data.get("authorization_flow"),
|
|
||||||
)
|
|
||||||
self.instance.openidprovider.authorization_flow = self.cleaned_data.get(
|
|
||||||
"authorization_flow"
|
|
||||||
)
|
|
||||||
self.instance.openidprovider.save()
|
|
||||||
return response
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Client
|
|
||||||
fields = [
|
|
||||||
"name",
|
|
||||||
"authorization_flow",
|
|
||||||
"client_type",
|
|
||||||
"client_id",
|
|
||||||
"client_secret",
|
|
||||||
"response_types",
|
|
||||||
"jwt_alg",
|
|
||||||
"_redirect_uris",
|
|
||||||
"_scope",
|
|
||||||
]
|
|
||||||
labels = {"client_secret": "Client Secret"}
|
|
|
@ -1,45 +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="OpenIDProvider",
|
|
||||||
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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"oidc_client",
|
|
||||||
models.OneToOneField(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
to="oidc_provider.Client",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "OpenID Provider",
|
|
||||||
"verbose_name_plural": "OpenID Providers",
|
|
||||||
},
|
|
||||||
bases=("passbook_core.provider",),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,59 +0,0 @@
|
||||||
"""oidc models"""
|
|
||||||
from typing import Optional, Type
|
|
||||||
|
|
||||||
from django.db import models
|
|
||||||
from django.forms import ModelForm
|
|
||||||
from django.http import HttpRequest
|
|
||||||
from django.shortcuts import reverse
|
|
||||||
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 OpenIDProvider(Provider):
|
|
||||||
"""OpenID Connect Provider for applications that support OIDC."""
|
|
||||||
|
|
||||||
# Since oidc_provider doesn't currently support swappable models
|
|
||||||
# (https://github.com/juanifioren/django-oidc-provider/pull/305)
|
|
||||||
# we have a 1:1 relationship, and update oidc_client when the form is saved.
|
|
||||||
|
|
||||||
oidc_client = models.OneToOneField(Client, on_delete=models.CASCADE)
|
|
||||||
|
|
||||||
def form(self) -> Type[ModelForm]:
|
|
||||||
from passbook.providers.oidc.forms import OIDCProviderForm
|
|
||||||
|
|
||||||
return OIDCProviderForm
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
"""Name property for UI"""
|
|
||||||
return self.oidc_client.name
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.oidc_client.__str__()
|
|
||||||
|
|
||||||
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(
|
|
||||||
"oidc_provider/setup_url_modal.html",
|
|
||||||
{
|
|
||||||
"provider": self,
|
|
||||||
"authorize": request.build_absolute_uri(
|
|
||||||
reverse("passbook_providers_oidc:authorize")
|
|
||||||
),
|
|
||||||
"token": request.build_absolute_uri(reverse("oidc_provider:token")),
|
|
||||||
"userinfo": request.build_absolute_uri(
|
|
||||||
reverse("oidc_provider:userinfo")
|
|
||||||
),
|
|
||||||
"provider_info": request.build_absolute_uri(
|
|
||||||
reverse("passbook_providers_oidc:provider-info")
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
|
|
||||||
verbose_name = _("OpenID Provider")
|
|
||||||
verbose_name_plural = _("OpenID Providers")
|
|
|
@ -1,9 +0,0 @@
|
||||||
"""passbook OIDC Provider"""
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
"oidc_provider",
|
|
||||||
]
|
|
||||||
|
|
||||||
OIDC_AFTER_USERLOGIN_HOOK = "passbook.providers.oidc.auth.check_permissions"
|
|
||||||
OIDC_IDTOKEN_INCLUDE_CLAIMS = True
|
|
||||||
OIDC_USERINFO = "passbook.providers.oidc.claims.userinfo"
|
|
|
@ -1,74 +0,0 @@
|
||||||
{% extends "login/base.html" %}
|
|
||||||
|
|
||||||
{% load passbook_utils %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block card_title %}
|
|
||||||
{% trans 'Authorize Application' %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block card %}
|
|
||||||
<form method="POST" class="pf-c-form">
|
|
||||||
{% csrf_token %}
|
|
||||||
{% if not error %}
|
|
||||||
{% csrf_token %}
|
|
||||||
{% for field in form %}
|
|
||||||
{% if field.is_hidden %}
|
|
||||||
{{ field }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<div class="pf-c-form__group">
|
|
||||||
<p class="subtitle">
|
|
||||||
{% blocktrans with remote=client.name %}
|
|
||||||
You're about to sign into {{ remote }}.
|
|
||||||
{% endblocktrans %}
|
|
||||||
</p>
|
|
||||||
<p>{% trans "Application requires following permissions" %}</p>
|
|
||||||
<ul class="pf-c-list">
|
|
||||||
{% for scope in scopes %}
|
|
||||||
<li>{{ scope.name }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{{ hidden_inputs }}
|
|
||||||
{{ form.errors }}
|
|
||||||
{{ form.non_field_errors }}
|
|
||||||
</div>
|
|
||||||
<div class="pf-c-form__group">
|
|
||||||
<p>
|
|
||||||
{% blocktrans with user=user %}
|
|
||||||
You are logged in as {{ user }}. Not you?
|
|
||||||
{% endblocktrans %}
|
|
||||||
<a href="{% url 'passbook_flows:cancel' %}">{% trans 'Logout' %}</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="pf-c-form__group pf-m-action">
|
|
||||||
<input type="submit" class="pf-c-button pf-m-primary" name="allow" value="{% trans 'Continue' %}">
|
|
||||||
<a href="{% back %}" class="pf-c-button pf-m-secondary">{% trans "Cancel" %}</a>
|
|
||||||
</div>
|
|
||||||
<div class="pf-c-form__group" style="display: none;" id="loading">
|
|
||||||
<div class="pf-c-form__horizontal-group">
|
|
||||||
<span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
|
|
||||||
<span class="pf-c-spinner__clipper"></span>
|
|
||||||
<span class="pf-c-spinner__lead-ball"></span>
|
|
||||||
<span class="pf-c-spinner__tail-ball"></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="login-group">
|
|
||||||
<p class="subtitle">
|
|
||||||
{% blocktrans with err=error.error %}Error: {{ err }}{% endblocktrans %}
|
|
||||||
</p>
|
|
||||||
<p>{{ error.description }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script>
|
|
||||||
document.querySelector("form").addEventListener("submit", (e) => {
|
|
||||||
document.getElementById("loading").removeAttribute("style");
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
|
@ -1,18 +0,0 @@
|
||||||
{% extends 'login/base.html' %}
|
|
||||||
|
|
||||||
{% load static %}
|
|
||||||
{% load i18n %}
|
|
||||||
{% load passbook_utils %}
|
|
||||||
|
|
||||||
{% block card_title %}
|
|
||||||
{% trans error %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block card %}
|
|
||||||
<form>
|
|
||||||
<h3>{% trans description %}</h3>
|
|
||||||
{% if 'back' in request.GET %}
|
|
||||||
<a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
|
|
||||||
{% endif %}
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{% extends 'login/form_with_user.html' %}
|
|
||||||
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block beneath_form %}
|
|
||||||
<div class="pf-c-form__group">
|
|
||||||
<p>
|
|
||||||
{% blocktrans with name=context.application.name %}
|
|
||||||
You're about to sign into {{ name }}.
|
|
||||||
{% endblocktrans %}
|
|
||||||
</p>
|
|
||||||
<p>{% trans "Application requires following permissions" %}</p>
|
|
||||||
<ul class="pf-c-list">
|
|
||||||
{% for scope in context.scopes %}
|
|
||||||
<li>{{ scope.name }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{{ hidden_inputs }}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
|
@ -1,13 +0,0 @@
|
||||||
"""oidc provider URLs"""
|
|
||||||
from django.conf.urls import url
|
|
||||||
|
|
||||||
from passbook.providers.oidc.views import AuthorizationFlowInitView, ProviderInfoView
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
url(r"^authorize/?$", AuthorizationFlowInitView.as_view(), name="authorize"),
|
|
||||||
url(
|
|
||||||
r"^\.well-known/openid-configuration/?$",
|
|
||||||
ProviderInfoView.as_view(),
|
|
||||||
name="provider-info",
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,136 +0,0 @@
|
||||||
"""passbook OIDC Views"""
|
|
||||||
from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
|
|
||||||
from django.shortcuts import get_object_or_404, reverse
|
|
||||||
from django.views import View
|
|
||||||
from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint
|
|
||||||
from oidc_provider.lib.utils.common import get_issuer, get_site_url
|
|
||||||
from oidc_provider.models import Client, ResponseType
|
|
||||||
from oidc_provider.views import AuthorizeView
|
|
||||||
from structlog import get_logger
|
|
||||||
|
|
||||||
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,
|
|
||||||
FlowPlan,
|
|
||||||
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.oidc.auth import client_related_provider
|
|
||||||
from passbook.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
PLAN_CONTEXT_PARAMS = "params"
|
|
||||||
PLAN_CONTEXT_SCOPES = "scopes"
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationFlowInitView(PolicyAccessMixin, View):
|
|
||||||
"""OIDC 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")
|
|
||||||
client: Client = get_object_or_404(Client, client_id=client_id)
|
|
||||||
provider = client_related_provider(client)
|
|
||||||
if not provider:
|
|
||||||
LOGGER.debug(f"Cannot find related provider to client '{client}")
|
|
||||||
raise Http404
|
|
||||||
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()
|
|
||||||
# Extract params so we can save them in the plan context
|
|
||||||
endpoint = AuthorizeEndpoint(request)
|
|
||||||
# Regardless, we start the planner and return to it
|
|
||||||
planner = FlowPlanner(provider.authorization_flow)
|
|
||||||
# planner.use_cache = False
|
|
||||||
planner.allow_empty_flows = True
|
|
||||||
plan = planner.plan(
|
|
||||||
self.request,
|
|
||||||
{
|
|
||||||
PLAN_CONTEXT_SSO: True,
|
|
||||||
PLAN_CONTEXT_APPLICATION: application,
|
|
||||||
PLAN_CONTEXT_PARAMS: endpoint.params,
|
|
||||||
PLAN_CONTEXT_SCOPES: endpoint.get_scopes_information(),
|
|
||||||
PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oidc/consent.html",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
plan.append(in_memory_stage(OIDCStage))
|
|
||||||
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 FlowAuthorizeEndpoint(AuthorizeEndpoint):
|
|
||||||
"""Restore params from flow context"""
|
|
||||||
|
|
||||||
def _extract_params(self):
|
|
||||||
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
|
|
||||||
self.params = plan.context[PLAN_CONTEXT_PARAMS]
|
|
||||||
|
|
||||||
|
|
||||||
class OIDCStage(AuthorizeView, StageView):
|
|
||||||
"""Finall stage, restores params from Flow."""
|
|
||||||
|
|
||||||
authorize_endpoint_class = FlowAuthorizeEndpoint
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderInfoView(View):
|
|
||||||
"""Custom ProviderInfo View which shows our URLs instead"""
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
"""Custom ProviderInfo View which shows our URLs instead"""
|
|
||||||
dic = dict()
|
|
||||||
|
|
||||||
site_url = get_site_url(request=request)
|
|
||||||
dic["issuer"] = get_issuer(site_url=site_url, request=request)
|
|
||||||
|
|
||||||
dic["authorization_endpoint"] = site_url + reverse(
|
|
||||||
"passbook_providers_oidc:authorize"
|
|
||||||
)
|
|
||||||
dic["token_endpoint"] = site_url + reverse("oidc_provider:token")
|
|
||||||
dic["userinfo_endpoint"] = site_url + reverse("oidc_provider:userinfo")
|
|
||||||
dic["end_session_endpoint"] = site_url + reverse("oidc_provider:end-session")
|
|
||||||
dic["introspection_endpoint"] = site_url + reverse(
|
|
||||||
"oidc_provider:token-introspection"
|
|
||||||
)
|
|
||||||
|
|
||||||
types_supported = [
|
|
||||||
response_type.value for response_type in ResponseType.objects.all()
|
|
||||||
]
|
|
||||||
dic["response_types_supported"] = types_supported
|
|
||||||
|
|
||||||
dic["jwks_uri"] = site_url + reverse("oidc_provider:jwks")
|
|
||||||
|
|
||||||
dic["id_token_signing_alg_values_supported"] = ["HS256", "RS256"]
|
|
||||||
|
|
||||||
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
|
|
||||||
dic["subject_types_supported"] = ["public"]
|
|
||||||
|
|
||||||
dic["token_endpoint_auth_methods_supported"] = [
|
|
||||||
"client_secret_post",
|
|
||||||
"client_secret_basic",
|
|
||||||
]
|
|
||||||
|
|
||||||
response = JsonResponse(dic)
|
|
||||||
response["Access-Control-Allow-Origin"] = "*"
|
|
||||||
|
|
||||||
return response
|
|
31
passbook/providers/proxy/api.py
Normal file
31
passbook/providers/proxy/api.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
"""ProxyProvider API Views"""
|
||||||
|
from rest_framework.serializers import ModelSerializer
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
|
from passbook.providers.proxy.models import ProxyProvider
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyProviderSerializer(ModelSerializer):
|
||||||
|
"""ProxyProvider Serializer"""
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
instance: ProxyProvider = super().create(validated_data)
|
||||||
|
instance.set_oauth_defaults()
|
||||||
|
instance.save()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def update(self, instance: ProxyProvider, validated_data):
|
||||||
|
instance.set_oauth_defaults()
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
|
||||||
|
model = ProxyProvider
|
||||||
|
fields = ["pk", "name", "internal_host", "external_host"]
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyProviderViewSet(ModelViewSet):
|
||||||
|
"""ProxyProvider Viewset"""
|
||||||
|
|
||||||
|
queryset = ProxyProvider.objects.all()
|
||||||
|
serializer_class = ProxyProviderSerializer
|
11
passbook/providers/proxy/apps.py
Normal file
11
passbook/providers/proxy/apps.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
"""passbook Proxy app"""
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PassbookProviderProxyConfig(AppConfig):
|
||||||
|
"""passbook proxy app"""
|
||||||
|
|
||||||
|
name = "passbook.providers.proxy"
|
||||||
|
label = "passbook_providers_proxy"
|
||||||
|
verbose_name = "passbook Providers.Proxy"
|
||||||
|
mountpoint = "application/proxy/"
|
24
passbook/providers/proxy/forms.py
Normal file
24
passbook/providers/proxy/forms.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
"""passbook Proxy Provider Forms"""
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
from passbook.providers.proxy.models import ProxyProvider
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyProviderForm(forms.ModelForm):
|
||||||
|
"""Security Gateway Provider form"""
|
||||||
|
|
||||||
|
instance: ProxyProvider
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.instance.set_oauth_defaults()
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
|
||||||
|
model = ProxyProvider
|
||||||
|
fields = ["name", "authorization_flow", "internal_host", "external_host"]
|
||||||
|
widgets = {
|
||||||
|
"name": forms.TextInput(),
|
||||||
|
"internal_host": forms.TextInput(),
|
||||||
|
"external_host": forms.TextInput(),
|
||||||
|
}
|
58
passbook/providers/proxy/migrations/0001_initial.py
Normal file
58
passbook/providers/proxy/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
# Generated by Django 3.1 on 2020-08-18 18:16
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_providers_oauth2", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ProxyProvider",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"oauth2provider_ptr",
|
||||||
|
models.OneToOneField(
|
||||||
|
auto_created=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
parent_link=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
to="passbook_providers_oauth2.oauth2provider",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"internal_host",
|
||||||
|
models.TextField(
|
||||||
|
validators=[
|
||||||
|
django.core.validators.URLValidator(
|
||||||
|
schemes=("http", "https")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"external_host",
|
||||||
|
models.TextField(
|
||||||
|
validators=[
|
||||||
|
django.core.validators.URLValidator(
|
||||||
|
schemes=("http", "https")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Proxy Provider",
|
||||||
|
"verbose_name_plural": "Proxy Providers",
|
||||||
|
},
|
||||||
|
bases=("passbook_providers_oauth2.oauth2provider",),
|
||||||
|
),
|
||||||
|
]
|
73
passbook/providers/proxy/models.py
Normal file
73
passbook/providers/proxy/models.py
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
"""passbook proxy 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 passbook.lib.utils.template import render_to_string
|
||||||
|
from passbook.providers.oauth2.constants import (
|
||||||
|
SCOPE_OPENID,
|
||||||
|
SCOPE_OPENID_EMAIL,
|
||||||
|
SCOPE_OPENID_PROFILE,
|
||||||
|
)
|
||||||
|
from passbook.providers.oauth2.models import (
|
||||||
|
ClientTypes,
|
||||||
|
JWTAlgorithms,
|
||||||
|
OAuth2Provider,
|
||||||
|
ResponseTypes,
|
||||||
|
ScopeMapping,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyProvider(OAuth2Provider):
|
||||||
|
"""Protect applications that don't support any of the other
|
||||||
|
Protocols by using a Reverse-Proxy."""
|
||||||
|
|
||||||
|
internal_host = models.TextField(
|
||||||
|
validators=[URLValidator(schemes=("http", "https"))]
|
||||||
|
)
|
||||||
|
external_host = models.TextField(
|
||||||
|
validators=[URLValidator(schemes=("http", "https"))]
|
||||||
|
)
|
||||||
|
|
||||||
|
def form(self) -> Type[ModelForm]:
|
||||||
|
from passbook.providers.proxy.forms import ProxyProviderForm
|
||||||
|
|
||||||
|
return ProxyProviderForm
|
||||||
|
|
||||||
|
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.proxy.views import DockerComposeView
|
||||||
|
|
||||||
|
docker_compose_yaml = DockerComposeView(request=request).get_compose(self)
|
||||||
|
return render_to_string(
|
||||||
|
"providers/proxy/setup_modal.html",
|
||||||
|
{"provider": self, "docker_compose": docker_compose_yaml},
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_oauth_defaults(self):
|
||||||
|
"""Ensure all OAuth2-related settings are correct"""
|
||||||
|
self.client_type = ClientTypes.CONFIDENTIAL
|
||||||
|
self.response_type = ResponseTypes.CODE
|
||||||
|
self.jwt_alg = JWTAlgorithms.HS256
|
||||||
|
scopes = ScopeMapping.objects.filter(
|
||||||
|
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_PROFILE, SCOPE_OPENID_EMAIL]
|
||||||
|
)
|
||||||
|
self.property_mappings.set(scopes)
|
||||||
|
self.redirect_uris = "\n".join(
|
||||||
|
[
|
||||||
|
f"{self.external_host}/oauth2/callback",
|
||||||
|
f"{self.internal_host}/oauth2/callback",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
|
||||||
|
verbose_name = _("Proxy Provider")
|
||||||
|
verbose_name_plural = _("Proxy Providers")
|
|
@ -1,7 +1,7 @@
|
||||||
"""passbook app_gw urls"""
|
"""passbook proxy urls"""
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from passbook.providers.app_gw.views import K8sManifestView
|
from passbook.providers.proxy.views import K8sManifestView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
|
@ -1,4 +1,4 @@
|
||||||
"""passbook app_gw views"""
|
"""passbook proxy views"""
|
||||||
import string
|
import string
|
||||||
from random import SystemRandom
|
from random import SystemRandom
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
@ -9,13 +9,12 @@ from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
from oidc_provider.lib.utils.common import get_issuer, get_site_url
|
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
from yaml import safe_dump
|
from yaml import safe_dump
|
||||||
|
|
||||||
from passbook import __version__
|
from passbook import __version__
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.providers.app_gw.models import ApplicationGatewayProvider
|
from passbook.providers.proxy.models import ProxyProvider
|
||||||
|
|
||||||
ORIGINAL_URL = "HTTP_X_ORIGINAL_URL"
|
ORIGINAL_URL = "HTTP_X_ORIGINAL_URL"
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
@ -36,13 +35,12 @@ def get_cookie_secret():
|
||||||
class DockerComposeView(LoginRequiredMixin, View):
|
class DockerComposeView(LoginRequiredMixin, View):
|
||||||
"""Generate docker-compose yaml"""
|
"""Generate docker-compose yaml"""
|
||||||
|
|
||||||
def get_compose(self, provider: ApplicationGatewayProvider) -> str:
|
def get_compose(self, provider: ProxyProvider) -> str:
|
||||||
"""Generate docker-compose yaml, version 3.5"""
|
"""Generate docker-compose yaml, version 3.5"""
|
||||||
site_url = get_site_url(request=self.request)
|
issuer = provider.get_issuer(self.request)
|
||||||
issuer = get_issuer(site_url=site_url, request=self.request)
|
|
||||||
env = {
|
env = {
|
||||||
"OAUTH2_PROXY_CLIENT_ID": provider.client.client_id,
|
"OAUTH2_PROXY_CLIENT_ID": provider.client_id,
|
||||||
"OAUTH2_PROXY_CLIENT_SECRET": provider.client.client_secret,
|
"OAUTH2_PROXY_CLIENT_SECRET": provider.client_secret,
|
||||||
"OAUTH2_PROXY_REDIRECT_URL": f"{provider.external_host}/oauth2/callback",
|
"OAUTH2_PROXY_REDIRECT_URL": f"{provider.external_host}/oauth2/callback",
|
||||||
"OAUTH2_PROXY_OIDC_ISSUER_URL": issuer,
|
"OAUTH2_PROXY_OIDC_ISSUER_URL": issuer,
|
||||||
"OAUTH2_PROXY_COOKIE_SECRET": get_cookie_secret(),
|
"OAUTH2_PROXY_COOKIE_SECRET": get_cookie_secret(),
|
||||||
|
@ -54,7 +52,7 @@ class DockerComposeView(LoginRequiredMixin, View):
|
||||||
"version": "3.5",
|
"version": "3.5",
|
||||||
"services": {
|
"services": {
|
||||||
"passbook_gatekeeper": {
|
"passbook_gatekeeper": {
|
||||||
"image": f"beryju/passbook-gatekeeper:{__version__}",
|
"image": f"beryju/passbook-proxy:{__version__}",
|
||||||
"ports": ["4180:4180"],
|
"ports": ["4180:4180"],
|
||||||
"environment": env,
|
"environment": env,
|
||||||
}
|
}
|
||||||
|
@ -64,9 +62,9 @@ class DockerComposeView(LoginRequiredMixin, View):
|
||||||
|
|
||||||
def get(self, request: HttpRequest, provider_pk: int) -> HttpResponse:
|
def get(self, request: HttpRequest, provider_pk: int) -> HttpResponse:
|
||||||
"""Render docker-compose file"""
|
"""Render docker-compose file"""
|
||||||
provider: ApplicationGatewayProvider = get_object_for_user_or_404(
|
provider: ProxyProvider = get_object_for_user_or_404(
|
||||||
request.user,
|
request.user,
|
||||||
"passbook_providers_app_gw.view_applicationgatewayprovider",
|
"passbook_providers_proxy.view_applicationgatewayprovider",
|
||||||
pk=provider_pk,
|
pk=provider_pk,
|
||||||
)
|
)
|
||||||
response = HttpResponse()
|
response = HttpResponse()
|
||||||
|
@ -80,21 +78,19 @@ class K8sManifestView(LoginRequiredMixin, View):
|
||||||
|
|
||||||
def get(self, request: HttpRequest, provider_pk: int) -> HttpResponse:
|
def get(self, request: HttpRequest, provider_pk: int) -> HttpResponse:
|
||||||
"""Render deployment template"""
|
"""Render deployment template"""
|
||||||
provider: ApplicationGatewayProvider = get_object_for_user_or_404(
|
provider: ProxyProvider = get_object_for_user_or_404(
|
||||||
request.user,
|
request.user,
|
||||||
"passbook_providers_app_gw.view_applicationgatewayprovider",
|
"passbook_providers_app_gw.view_applicationgatewayprovider",
|
||||||
pk=provider_pk,
|
pk=provider_pk,
|
||||||
)
|
)
|
||||||
site_url = get_site_url(request=self.request)
|
|
||||||
issuer = get_issuer(site_url=site_url, request=self.request)
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"app_gw/k8s-manifest.yaml",
|
"providers/proxy/k8s-manifest.yaml",
|
||||||
{
|
{
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"cookie_secret": get_cookie_secret(),
|
"cookie_secret": get_cookie_secret(),
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"issuer": issuer,
|
"issuer": provider.get_issuer(request),
|
||||||
},
|
},
|
||||||
content_type="text/yaml",
|
content_type="text/yaml",
|
||||||
)
|
)
|
|
@ -87,9 +87,8 @@ INSTALLED_APPS = [
|
||||||
"passbook.policies.password.apps.PassbookPoliciesPasswordConfig",
|
"passbook.policies.password.apps.PassbookPoliciesPasswordConfig",
|
||||||
"passbook.policies.group_membership.apps.PassbookPoliciesGroupMembershipConfig",
|
"passbook.policies.group_membership.apps.PassbookPoliciesGroupMembershipConfig",
|
||||||
"passbook.policies.reputation.apps.PassbookPolicyReputationConfig",
|
"passbook.policies.reputation.apps.PassbookPolicyReputationConfig",
|
||||||
"passbook.providers.app_gw.apps.PassbookApplicationApplicationGatewayConfig",
|
"passbook.providers.proxy.apps.PassbookProviderProxyConfig",
|
||||||
"passbook.providers.oauth.apps.PassbookProviderOAuthConfig",
|
"passbook.providers.oauth2.apps.PassbookProviderOAuth2Config",
|
||||||
"passbook.providers.oidc.apps.PassbookProviderOIDCConfig",
|
|
||||||
"passbook.providers.saml.apps.PassbookProviderSAMLConfig",
|
"passbook.providers.saml.apps.PassbookProviderSAMLConfig",
|
||||||
"passbook.recovery.apps.PassbookRecoveryConfig",
|
"passbook.recovery.apps.PassbookRecoveryConfig",
|
||||||
"passbook.sources.ldap.apps.PassbookSourceLDAPConfig",
|
"passbook.sources.ldap.apps.PassbookSourceLDAPConfig",
|
||||||
|
@ -371,9 +370,6 @@ _LOGGING_HANDLER_MAP = {
|
||||||
"celery": "WARNING",
|
"celery": "WARNING",
|
||||||
"selenium": "WARNING",
|
"selenium": "WARNING",
|
||||||
"grpc": LOG_LEVEL,
|
"grpc": LOG_LEVEL,
|
||||||
"oauthlib": LOG_LEVEL,
|
|
||||||
"oauth2_provider": LOG_LEVEL,
|
|
||||||
"oidc_provider": LOG_LEVEL,
|
|
||||||
"docker": "WARNING",
|
"docker": "WARNING",
|
||||||
"urllib3": "WARNING",
|
"urllib3": "WARNING",
|
||||||
"elasticapm": "WARNING",
|
"elasticapm": "WARNING",
|
||||||
|
|
|
@ -26,23 +26,29 @@ handler500 = error.ServerErrorView.as_view()
|
||||||
urlpatterns = []
|
urlpatterns = []
|
||||||
|
|
||||||
for _passbook_app in get_apps():
|
for _passbook_app in get_apps():
|
||||||
|
mountpoints = None
|
||||||
|
base_url_module = _passbook_app.name + ".urls"
|
||||||
if hasattr(_passbook_app, "mountpoint"):
|
if hasattr(_passbook_app, "mountpoint"):
|
||||||
|
mountpoint = getattr(_passbook_app, "mountpoint")
|
||||||
|
mountpoints = {base_url_module: mountpoint}
|
||||||
|
if hasattr(_passbook_app, "mountpoints"):
|
||||||
|
mountpoints = getattr(_passbook_app, "mountpoints")
|
||||||
|
if not mountpoints:
|
||||||
|
continue
|
||||||
|
for module, mountpoint in mountpoints.items():
|
||||||
|
namespace = _passbook_app.label + module.replace(base_url_module, "")
|
||||||
_path = path(
|
_path = path(
|
||||||
_passbook_app.mountpoint,
|
mountpoint, include((module, _passbook_app.label), namespace=namespace,),
|
||||||
include(
|
|
||||||
(_passbook_app.name + ".urls", _passbook_app.label),
|
|
||||||
namespace=_passbook_app.label,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
urlpatterns.append(_path)
|
urlpatterns.append(_path)
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Mounted URLs",
|
"Mounted URLs",
|
||||||
app_name=_passbook_app.name,
|
app_name=_passbook_app.name,
|
||||||
mountpoint=_passbook_app.mountpoint,
|
mountpoint=mountpoint,
|
||||||
|
namespace=namespace,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns += [
|
urlpatterns += [
|
||||||
# Administration
|
|
||||||
path("administration/django/", admin.site.urls),
|
path("administration/django/", admin.site.urls),
|
||||||
path("metrics/", MetricsView.as_view(), name="metrics"),
|
path("metrics/", MetricsView.as_view(), name="metrics"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -22,8 +22,8 @@ defuse_stdlib()
|
||||||
class WSGILogger:
|
class WSGILogger:
|
||||||
""" This is the generalized WSGI middleware for any style request logging. """
|
""" This is the generalized WSGI middleware for any style request logging. """
|
||||||
|
|
||||||
def __init__(self, application):
|
def __init__(self, _application):
|
||||||
self.application = application
|
self.application = _application
|
||||||
self.logger = get_logger("passbook.wsgi")
|
self.logger = get_logger("passbook.wsgi")
|
||||||
|
|
||||||
def __healthcheck(self, start_response):
|
def __healthcheck(self, start_response):
|
||||||
|
|
|
@ -3,9 +3,9 @@ from unittest.mock import Mock, PropertyMock, patch
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
|
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
|
||||||
from oauth2_provider.generators import generate_client_secret
|
|
||||||
|
|
||||||
from passbook.core.models import Group, User
|
from passbook.core.models import Group, User
|
||||||
|
from passbook.providers.oauth2.generators import generate_client_secret
|
||||||
from passbook.sources.ldap.auth import LDAPBackend
|
from passbook.sources.ldap.auth import LDAPBackend
|
||||||
from passbook.sources.ldap.connector import Connector
|
from passbook.sources.ldap.connector import Connector
|
||||||
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||||
|
|
|
@ -5,4 +5,5 @@ RECAPTCHA_PRIVATE_KEY = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe"
|
||||||
|
|
||||||
NOCAPTCHA = True
|
NOCAPTCHA = True
|
||||||
INSTALLED_APPS = ["captcha"]
|
INSTALLED_APPS = ["captcha"]
|
||||||
|
|
||||||
SILENCED_SYSTEM_CHECKS = ["captcha.recaptcha_test_key_error"]
|
SILENCED_SYSTEM_CHECKS = ["captcha.recaptcha_test_key_error"]
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
#!/bin/bash -x
|
|
||||||
|
|
||||||
# macos specific setting, for some reason
|
|
||||||
export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
|
|
||||||
export DEBUG=false
|
|
||||||
|
|
||||||
export POSTGRES_USER=postgres
|
|
||||||
|
|
||||||
# ./manage.py generate_swagger > storhappy-ui/swagger.json
|
|
||||||
|
|
||||||
uwsgi docker/uwsgi.ini
|
|
|
@ -1,3 +1,3 @@
|
||||||
[pycodestyle]
|
[pycodestyle]
|
||||||
ignore = E731,E121
|
ignore = E731,E121,W503
|
||||||
max-line-length = 100
|
max-line-length = 100
|
||||||
|
|
462
swagger.yaml
462
swagger.yaml
|
@ -2183,6 +2183,133 @@ paths:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
/propertymappings/scope/:
|
||||||
|
get:
|
||||||
|
operationId: propertymappings_scope_list
|
||||||
|
description: ScopeMapping Viewset
|
||||||
|
parameters:
|
||||||
|
- name: ordering
|
||||||
|
in: query
|
||||||
|
description: Which field to use when ordering the results.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
- name: search
|
||||||
|
in: query
|
||||||
|
description: A search term.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
description: Number of results to return per page.
|
||||||
|
required: false
|
||||||
|
type: integer
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
description: The initial index from which to return the results.
|
||||||
|
required: false
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
required:
|
||||||
|
- count
|
||||||
|
- results
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
next:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
x-nullable: true
|
||||||
|
previous:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
x-nullable: true
|
||||||
|
results:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/ScopeMapping'
|
||||||
|
tags:
|
||||||
|
- propertymappings
|
||||||
|
post:
|
||||||
|
operationId: propertymappings_scope_create
|
||||||
|
description: ScopeMapping Viewset
|
||||||
|
parameters:
|
||||||
|
- name: data
|
||||||
|
in: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/ScopeMapping'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/ScopeMapping'
|
||||||
|
tags:
|
||||||
|
- propertymappings
|
||||||
|
parameters: []
|
||||||
|
/propertymappings/scope/{pm_uuid}/:
|
||||||
|
get:
|
||||||
|
operationId: propertymappings_scope_read
|
||||||
|
description: ScopeMapping Viewset
|
||||||
|
parameters: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/ScopeMapping'
|
||||||
|
tags:
|
||||||
|
- propertymappings
|
||||||
|
put:
|
||||||
|
operationId: propertymappings_scope_update
|
||||||
|
description: ScopeMapping Viewset
|
||||||
|
parameters:
|
||||||
|
- name: data
|
||||||
|
in: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/ScopeMapping'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/ScopeMapping'
|
||||||
|
tags:
|
||||||
|
- propertymappings
|
||||||
|
patch:
|
||||||
|
operationId: propertymappings_scope_partial_update
|
||||||
|
description: ScopeMapping Viewset
|
||||||
|
parameters:
|
||||||
|
- name: data
|
||||||
|
in: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/ScopeMapping'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/ScopeMapping'
|
||||||
|
tags:
|
||||||
|
- propertymappings
|
||||||
|
delete:
|
||||||
|
operationId: propertymappings_scope_delete
|
||||||
|
description: ScopeMapping Viewset
|
||||||
|
parameters: []
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: ''
|
||||||
|
tags:
|
||||||
|
- propertymappings
|
||||||
|
parameters:
|
||||||
|
- name: pm_uuid
|
||||||
|
in: path
|
||||||
|
description: A UUID string identifying this Scope Mapping.
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
/providers/all/:
|
/providers/all/:
|
||||||
get:
|
get:
|
||||||
operationId: providers_all_list
|
operationId: providers_all_list
|
||||||
|
@ -2252,135 +2379,9 @@ paths:
|
||||||
description: A unique integer value identifying this provider.
|
description: A unique integer value identifying this provider.
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
/providers/applicationgateway/:
|
/providers/oauth2/:
|
||||||
get:
|
get:
|
||||||
operationId: providers_applicationgateway_list
|
operationId: providers_oauth2_list
|
||||||
description: ApplicationGatewayProvider Viewset
|
|
||||||
parameters:
|
|
||||||
- name: ordering
|
|
||||||
in: query
|
|
||||||
description: Which field to use when ordering the results.
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
- name: search
|
|
||||||
in: query
|
|
||||||
description: A search term.
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
- name: limit
|
|
||||||
in: query
|
|
||||||
description: Number of results to return per page.
|
|
||||||
required: false
|
|
||||||
type: integer
|
|
||||||
- name: offset
|
|
||||||
in: query
|
|
||||||
description: The initial index from which to return the results.
|
|
||||||
required: false
|
|
||||||
type: integer
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: ''
|
|
||||||
schema:
|
|
||||||
required:
|
|
||||||
- count
|
|
||||||
- results
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
count:
|
|
||||||
type: integer
|
|
||||||
next:
|
|
||||||
type: string
|
|
||||||
format: uri
|
|
||||||
x-nullable: true
|
|
||||||
previous:
|
|
||||||
type: string
|
|
||||||
format: uri
|
|
||||||
x-nullable: true
|
|
||||||
results:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/definitions/ApplicationGatewayProvider'
|
|
||||||
tags:
|
|
||||||
- providers
|
|
||||||
post:
|
|
||||||
operationId: providers_applicationgateway_create
|
|
||||||
description: ApplicationGatewayProvider Viewset
|
|
||||||
parameters:
|
|
||||||
- name: data
|
|
||||||
in: body
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/ApplicationGatewayProvider'
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: ''
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/ApplicationGatewayProvider'
|
|
||||||
tags:
|
|
||||||
- providers
|
|
||||||
parameters: []
|
|
||||||
/providers/applicationgateway/{id}/:
|
|
||||||
get:
|
|
||||||
operationId: providers_applicationgateway_read
|
|
||||||
description: ApplicationGatewayProvider Viewset
|
|
||||||
parameters: []
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: ''
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/ApplicationGatewayProvider'
|
|
||||||
tags:
|
|
||||||
- providers
|
|
||||||
put:
|
|
||||||
operationId: providers_applicationgateway_update
|
|
||||||
description: ApplicationGatewayProvider Viewset
|
|
||||||
parameters:
|
|
||||||
- name: data
|
|
||||||
in: body
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/ApplicationGatewayProvider'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: ''
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/ApplicationGatewayProvider'
|
|
||||||
tags:
|
|
||||||
- providers
|
|
||||||
patch:
|
|
||||||
operationId: providers_applicationgateway_partial_update
|
|
||||||
description: ApplicationGatewayProvider Viewset
|
|
||||||
parameters:
|
|
||||||
- name: data
|
|
||||||
in: body
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/ApplicationGatewayProvider'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: ''
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/ApplicationGatewayProvider'
|
|
||||||
tags:
|
|
||||||
- providers
|
|
||||||
delete:
|
|
||||||
operationId: providers_applicationgateway_delete
|
|
||||||
description: ApplicationGatewayProvider Viewset
|
|
||||||
parameters: []
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: ''
|
|
||||||
tags:
|
|
||||||
- providers
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
description: A unique integer value identifying this Application Gateway Provider.
|
|
||||||
required: true
|
|
||||||
type: integer
|
|
||||||
/providers/oauth/:
|
|
||||||
get:
|
|
||||||
operationId: providers_oauth_list
|
|
||||||
description: OAuth2Provider Viewset
|
description: OAuth2Provider Viewset
|
||||||
parameters:
|
parameters:
|
||||||
- name: ordering
|
- name: ordering
|
||||||
|
@ -2429,7 +2430,7 @@ paths:
|
||||||
tags:
|
tags:
|
||||||
- providers
|
- providers
|
||||||
post:
|
post:
|
||||||
operationId: providers_oauth_create
|
operationId: providers_oauth2_create
|
||||||
description: OAuth2Provider Viewset
|
description: OAuth2Provider Viewset
|
||||||
parameters:
|
parameters:
|
||||||
- name: data
|
- name: data
|
||||||
|
@ -2445,9 +2446,9 @@ paths:
|
||||||
tags:
|
tags:
|
||||||
- providers
|
- providers
|
||||||
parameters: []
|
parameters: []
|
||||||
/providers/oauth/{id}/:
|
/providers/oauth2/{id}/:
|
||||||
get:
|
get:
|
||||||
operationId: providers_oauth_read
|
operationId: providers_oauth2_read
|
||||||
description: OAuth2Provider Viewset
|
description: OAuth2Provider Viewset
|
||||||
parameters: []
|
parameters: []
|
||||||
responses:
|
responses:
|
||||||
|
@ -2458,7 +2459,7 @@ paths:
|
||||||
tags:
|
tags:
|
||||||
- providers
|
- providers
|
||||||
put:
|
put:
|
||||||
operationId: providers_oauth_update
|
operationId: providers_oauth2_update
|
||||||
description: OAuth2Provider Viewset
|
description: OAuth2Provider Viewset
|
||||||
parameters:
|
parameters:
|
||||||
- name: data
|
- name: data
|
||||||
|
@ -2474,7 +2475,7 @@ paths:
|
||||||
tags:
|
tags:
|
||||||
- providers
|
- providers
|
||||||
patch:
|
patch:
|
||||||
operationId: providers_oauth_partial_update
|
operationId: providers_oauth2_partial_update
|
||||||
description: OAuth2Provider Viewset
|
description: OAuth2Provider Viewset
|
||||||
parameters:
|
parameters:
|
||||||
- name: data
|
- name: data
|
||||||
|
@ -2490,7 +2491,7 @@ paths:
|
||||||
tags:
|
tags:
|
||||||
- providers
|
- providers
|
||||||
delete:
|
delete:
|
||||||
operationId: providers_oauth_delete
|
operationId: providers_oauth2_delete
|
||||||
description: OAuth2Provider Viewset
|
description: OAuth2Provider Viewset
|
||||||
parameters: []
|
parameters: []
|
||||||
responses:
|
responses:
|
||||||
|
@ -2501,13 +2502,13 @@ paths:
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
in: path
|
in: path
|
||||||
description: A unique integer value identifying this OAuth2 Provider.
|
description: A unique integer value identifying this OAuth2/OpenID Provider.
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
/providers/openid/:
|
/providers/proxy/:
|
||||||
get:
|
get:
|
||||||
operationId: providers_openid_list
|
operationId: providers_proxy_list
|
||||||
description: OpenIDProvider Viewset
|
description: ProxyProvider Viewset
|
||||||
parameters:
|
parameters:
|
||||||
- name: ordering
|
- name: ordering
|
||||||
in: query
|
in: query
|
||||||
|
@ -2551,73 +2552,73 @@ paths:
|
||||||
results:
|
results:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/definitions/OpenIDProvider'
|
$ref: '#/definitions/ProxyProvider'
|
||||||
tags:
|
tags:
|
||||||
- providers
|
- providers
|
||||||
post:
|
post:
|
||||||
operationId: providers_openid_create
|
operationId: providers_proxy_create
|
||||||
description: OpenIDProvider Viewset
|
description: ProxyProvider Viewset
|
||||||
parameters:
|
parameters:
|
||||||
- name: data
|
- name: data
|
||||||
in: body
|
in: body
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/OpenIDProvider'
|
$ref: '#/definitions/ProxyProvider'
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: ''
|
description: ''
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/OpenIDProvider'
|
$ref: '#/definitions/ProxyProvider'
|
||||||
tags:
|
tags:
|
||||||
- providers
|
- providers
|
||||||
parameters: []
|
parameters: []
|
||||||
/providers/openid/{id}/:
|
/providers/proxy/{id}/:
|
||||||
get:
|
get:
|
||||||
operationId: providers_openid_read
|
operationId: providers_proxy_read
|
||||||
description: OpenIDProvider Viewset
|
description: ProxyProvider Viewset
|
||||||
parameters: []
|
parameters: []
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: ''
|
description: ''
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/OpenIDProvider'
|
$ref: '#/definitions/ProxyProvider'
|
||||||
tags:
|
tags:
|
||||||
- providers
|
- providers
|
||||||
put:
|
put:
|
||||||
operationId: providers_openid_update
|
operationId: providers_proxy_update
|
||||||
description: OpenIDProvider Viewset
|
description: ProxyProvider Viewset
|
||||||
parameters:
|
parameters:
|
||||||
- name: data
|
- name: data
|
||||||
in: body
|
in: body
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/OpenIDProvider'
|
$ref: '#/definitions/ProxyProvider'
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: ''
|
description: ''
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/OpenIDProvider'
|
$ref: '#/definitions/ProxyProvider'
|
||||||
tags:
|
tags:
|
||||||
- providers
|
- providers
|
||||||
patch:
|
patch:
|
||||||
operationId: providers_openid_partial_update
|
operationId: providers_proxy_partial_update
|
||||||
description: OpenIDProvider Viewset
|
description: ProxyProvider Viewset
|
||||||
parameters:
|
parameters:
|
||||||
- name: data
|
- name: data
|
||||||
in: body
|
in: body
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/OpenIDProvider'
|
$ref: '#/definitions/ProxyProvider'
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: ''
|
description: ''
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/OpenIDProvider'
|
$ref: '#/definitions/ProxyProvider'
|
||||||
tags:
|
tags:
|
||||||
- providers
|
- providers
|
||||||
delete:
|
delete:
|
||||||
operationId: providers_openid_delete
|
operationId: providers_proxy_delete
|
||||||
description: OpenIDProvider Viewset
|
description: ProxyProvider Viewset
|
||||||
parameters: []
|
parameters: []
|
||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
|
@ -2627,7 +2628,7 @@ paths:
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
in: path
|
in: path
|
||||||
description: A unique integer value identifying this Client.
|
description: A unique integer value identifying this Proxy Provider.
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
/providers/saml/:
|
/providers/saml/:
|
||||||
|
@ -5996,6 +5997,36 @@ definitions:
|
||||||
title: Expression
|
title: Expression
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
|
ScopeMapping:
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- scope_name
|
||||||
|
- expression
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
pk:
|
||||||
|
title: Pm uuid
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
readOnly: true
|
||||||
|
name:
|
||||||
|
title: Name
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
scope_name:
|
||||||
|
title: Scope name
|
||||||
|
description: Scope used by the client
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
description:
|
||||||
|
title: Description
|
||||||
|
description: Description shown to the user when consenting. If left empty,
|
||||||
|
the user won't be informed.
|
||||||
|
type: string
|
||||||
|
expression:
|
||||||
|
title: Expression
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
Provider:
|
Provider:
|
||||||
required:
|
required:
|
||||||
- authorization_flow
|
- authorization_flow
|
||||||
|
@ -6020,11 +6051,10 @@ definitions:
|
||||||
title: 'type '
|
title: 'type '
|
||||||
type: string
|
type: string
|
||||||
readOnly: true
|
readOnly: true
|
||||||
OpenIDProvider:
|
OAuth2Provider:
|
||||||
title: Client
|
|
||||||
required:
|
required:
|
||||||
- client_id
|
- name
|
||||||
- response_types
|
- authorization_flow
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
pk:
|
pk:
|
||||||
|
@ -6034,12 +6064,17 @@ definitions:
|
||||||
name:
|
name:
|
||||||
title: Name
|
title: Name
|
||||||
type: string
|
type: string
|
||||||
maxLength: 100
|
|
||||||
minLength: 1
|
minLength: 1
|
||||||
|
authorization_flow:
|
||||||
|
title: Authorization flow
|
||||||
|
description: Flow used when authorizing this provider.
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
client_type:
|
client_type:
|
||||||
title: Client Type
|
title: Client Type
|
||||||
description: <b>Confidential</b> clients are capable of maintaining the confidentiality
|
description: |-
|
||||||
of their credentials. <b>Public</b> clients are incapable.
|
<b>Confidential</b> clients are capable of maintaining the confidentiality
|
||||||
|
of their credentials. <b>Public</b> clients are incapable.
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- confidential
|
- confidential
|
||||||
|
@ -6050,46 +6085,54 @@ definitions:
|
||||||
maxLength: 255
|
maxLength: 255
|
||||||
minLength: 1
|
minLength: 1
|
||||||
client_secret:
|
client_secret:
|
||||||
title: Client SECRET
|
title: Client Secret
|
||||||
type: string
|
type: string
|
||||||
maxLength: 255
|
maxLength: 255
|
||||||
response_types:
|
response_type:
|
||||||
type: array
|
title: Response type
|
||||||
items:
|
description: Response Type required by the client.
|
||||||
type: integer
|
type: string
|
||||||
uniqueItems: true
|
enum:
|
||||||
|
- code
|
||||||
|
- id_token
|
||||||
|
- id_token token
|
||||||
|
- code token
|
||||||
|
- code id_token
|
||||||
|
- code id_token token
|
||||||
jwt_alg:
|
jwt_alg:
|
||||||
title: JWT Algorithm
|
title: JWT Algorithm
|
||||||
description: Algorithm used to encode ID Tokens.
|
description: Algorithm used to sign the JWT Token
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- HS256
|
- HS256
|
||||||
- RS256
|
- RS256
|
||||||
reuse_consent:
|
rsa_key:
|
||||||
title: Reuse Consent?
|
title: RSA Key
|
||||||
description: If enabled, server will save the user consent given to a specific
|
description: Key used to sign the tokens. Only required when JWT Algorithm
|
||||||
client, so that user won't be prompted for the same authorization multiple
|
is set to RS256.
|
||||||
times.
|
type: string
|
||||||
type: boolean
|
format: uuid
|
||||||
require_consent:
|
x-nullable: true
|
||||||
title: Require Consent?
|
redirect_uris:
|
||||||
description: If disabled, the Server will NEVER ask the user for consent.
|
|
||||||
type: boolean
|
|
||||||
_redirect_uris:
|
|
||||||
title: Redirect URIs
|
title: Redirect URIs
|
||||||
description: Enter each URI on a new line.
|
description: Enter each URI on a new line.
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
_scope:
|
post_logout_redirect_uris:
|
||||||
title: Scopes
|
title: Post Logout Redirect URIs
|
||||||
description: Specifies the authorized scope values for the client app.
|
description: Enter each URI on a new line.
|
||||||
type: string
|
type: string
|
||||||
ApplicationGatewayProvider:
|
property_mappings:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
uniqueItems: true
|
||||||
|
ProxyProvider:
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
- internal_host
|
- internal_host
|
||||||
- external_host
|
- external_host
|
||||||
- client
|
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
pk:
|
pk:
|
||||||
|
@ -6108,49 +6151,6 @@ definitions:
|
||||||
title: External host
|
title: External host
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
client:
|
|
||||||
$ref: '#/definitions/OpenIDProvider'
|
|
||||||
OAuth2Provider:
|
|
||||||
required:
|
|
||||||
- client_type
|
|
||||||
- authorization_grant_type
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
pk:
|
|
||||||
title: ID
|
|
||||||
type: integer
|
|
||||||
readOnly: true
|
|
||||||
name:
|
|
||||||
title: Name
|
|
||||||
type: string
|
|
||||||
maxLength: 255
|
|
||||||
redirect_uris:
|
|
||||||
title: Redirect uris
|
|
||||||
description: Allowed URIs list, space separated
|
|
||||||
type: string
|
|
||||||
client_type:
|
|
||||||
title: Client type
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- confidential
|
|
||||||
- public
|
|
||||||
authorization_grant_type:
|
|
||||||
title: Authorization grant type
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- authorization-code
|
|
||||||
- implicit
|
|
||||||
- password
|
|
||||||
- client-credentials
|
|
||||||
client_id:
|
|
||||||
title: Client id
|
|
||||||
type: string
|
|
||||||
maxLength: 100
|
|
||||||
minLength: 1
|
|
||||||
client_secret:
|
|
||||||
title: Client secret
|
|
||||||
type: string
|
|
||||||
maxLength: 255
|
|
||||||
SAMLProvider:
|
SAMLProvider:
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
|
|
Reference in a new issue