OAuth Provider Rewrite (#182)
This commit is contained in:
parent
b9076b5fd4
commit
c7a2410b1d
|
@ -1,6 +1,6 @@
|
|||
[MASTER]
|
||||
|
||||
disable=redefined-outer-name,arguments-differ,no-self-use,cyclic-import,fixme,locally-disabled,too-many-ancestors,too-few-public-methods,import-outside-toplevel,bad-continuation,signature-differs
|
||||
disable=arguments-differ,no-self-use,fixme,locally-disabled,too-many-ancestors,too-few-public-methods,import-outside-toplevel,bad-continuation,signature-differs,similarities,cyclic-import
|
||||
load-plugins=pylint_django,pylint.extensions.bad_builtin
|
||||
extension-pkg-whitelist=lxml
|
||||
const-rgx=[a-zA-Z0-9_]{1,40}$
|
||||
|
|
19
Pipfile
19
Pipfile
|
@ -13,8 +13,6 @@ django-dbbackup = "*"
|
|||
django-filter = "*"
|
||||
django-guardian = "*"
|
||||
django-model-utils = "*"
|
||||
django-oauth-toolkit = "*"
|
||||
django-oidc-provider = "*"
|
||||
django-otp = "*"
|
||||
django-prometheus = "*"
|
||||
django-recaptcha = "*"
|
||||
|
@ -23,13 +21,14 @@ django-rest-framework = "*"
|
|||
django-storages = "*"
|
||||
djangorestframework-guardian = "*"
|
||||
drf-yasg = "*"
|
||||
kombu = "*"
|
||||
elastic-apm = "*"
|
||||
facebook-sdk = "*"
|
||||
ldap3 = "*"
|
||||
lxml = "*"
|
||||
oauthlib = "*"
|
||||
packaging = "*"
|
||||
psycopg2-binary = "*"
|
||||
pycryptodome = "*"
|
||||
pyjwkest = "*"
|
||||
pyuwsgi = "*"
|
||||
pyyaml = "*"
|
||||
qrcode = "*"
|
||||
|
@ -40,8 +39,6 @@ signxml = "*"
|
|||
structlog = "*"
|
||||
swagger-spec-validator = "*"
|
||||
urllib3 = {extras = ["secure"],version = "*"}
|
||||
facebook-sdk = "*"
|
||||
elastic-apm = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.8"
|
||||
|
@ -49,16 +46,14 @@ python_version = "3.8"
|
|||
[dev-packages]
|
||||
autopep8 = "*"
|
||||
bandit = "*"
|
||||
black = "==19.10b0"
|
||||
bumpversion = "*"
|
||||
colorama = "*"
|
||||
coverage = "*"
|
||||
django-debug-toolbar = "*"
|
||||
docker = "*"
|
||||
pylint = "*"
|
||||
pylint-django = "*"
|
||||
unittest-xml-reporting = "*"
|
||||
black = "*"
|
||||
selenium = "*"
|
||||
docker = "*"
|
||||
|
||||
[pipenv]
|
||||
allow_prereleases = true
|
||||
unittest-xml-reporting = "*"
|
||||
prospector = "*"
|
||||
|
|
276
Pipfile.lock
generated
276
Pipfile.lock
generated
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "5c22d3a514247b663a07c6492cea09ab140346894a528db06bd805a4a3a4a320"
|
||||
"sha256": "b7ba5405c03bf3526eebb29817887744a3e31bca019ad2e566ea23096c6a5cfe"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
|
@ -46,18 +46,18 @@
|
|||
},
|
||||
"boto3": {
|
||||
"hashes": [
|
||||
"sha256:640a8372ce0edfbb84a8f63584a0b64c78d61a751a27c2a47f92d2ebaf021ce4",
|
||||
"sha256:a6c9a3d3abbad2ff2e5751af599492a9271633a7c9fef343482524464c53e451"
|
||||
"sha256:02ad765927bb46b9f45c3bce65e763960733919eee7883217995c5df5d096695",
|
||||
"sha256:4421aad9a9740ce95199460f3262859e1c3594cc6c86cbe552745f4bbff34300"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.14.43"
|
||||
"version": "==1.14.45"
|
||||
},
|
||||
"botocore": {
|
||||
"hashes": [
|
||||
"sha256:1b46ffe1d13922066c873323186cbf97e77c137e08e27039d9d684552ccc4892",
|
||||
"sha256:1f6175bf59ffa068055b65f7d703eb1f748c338594a40dfdc645a6130280d8bb"
|
||||
"sha256:17470c97435891cf40e147f533069de0109cda24c208c918f28997274bbac399",
|
||||
"sha256:bc8b1c83ccc0d77963849b66a94bbb20a666ff0225aff84de7ed0175db1fd6f7"
|
||||
],
|
||||
"version": "==1.17.44"
|
||||
"version": "==1.17.45"
|
||||
},
|
||||
"celery": {
|
||||
"hashes": [
|
||||
|
@ -207,21 +207,6 @@
|
|||
"index": "pypi",
|
||||
"version": "==4.0.0"
|
||||
},
|
||||
"django-oauth-toolkit": {
|
||||
"hashes": [
|
||||
"sha256:28508f83385ab4313936ddedfb310eaa8a1dcb737153d2956383ce47e75c2fab",
|
||||
"sha256:d5a1044af9419ddc048390c5974777ea97874e5b78e33c609e17eebb8423afb2"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.3.2"
|
||||
},
|
||||
"django-oidc-provider": {
|
||||
"hashes": [
|
||||
"sha256:3fa50d35ce614a68cde704606dbff86d8535a7679871ce3ec30100b28f3af50d"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.7.0"
|
||||
},
|
||||
"django-otp": {
|
||||
"hashes": [
|
||||
"sha256:0c9edbb3f4abc9ac6e43daf0a9e0e293e99ad917641cf8d7dbc49d613bcb5cd4",
|
||||
|
@ -232,11 +217,11 @@
|
|||
},
|
||||
"django-prometheus": {
|
||||
"hashes": [
|
||||
"sha256:402228804b190be8bdbf20300ac3ae09043c7e3144e5dd648ddeb8f81a267f16",
|
||||
"sha256:57b97be6c88af9fc5b28a9fa8df629aab2b04f6a0f910c0669880d62f8ec3c0f"
|
||||
"sha256:4c30aa8eb944fcf3cf10e20dfabbbe11ad5a84fce62abb3658feffa4e2ac2b97",
|
||||
"sha256:8f25e86a3c310f40cf32cfa1b56a2b6df9cb2521e4cb794844958697d98fb3d1"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.1.0.dev61"
|
||||
"version": "==2.0.0"
|
||||
},
|
||||
"django-recaptcha": {
|
||||
"hashes": [
|
||||
|
@ -376,10 +361,10 @@
|
|||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:c10142f819c2d22bdcd17548c46fa9b77cf4fda45097854c689666bf425e7484",
|
||||
"sha256:c922560ac46888d47384de1dbdc3daaa2ea993af4b26a436dec31fa2c19ec668"
|
||||
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
|
||||
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
|
||||
],
|
||||
"version": "==3.0.0a1"
|
||||
"version": "==2.11.2"
|
||||
},
|
||||
"jmespath": {
|
||||
"hashes": [
|
||||
|
@ -400,7 +385,6 @@
|
|||
"sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a",
|
||||
"sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==4.6.11"
|
||||
},
|
||||
"ldap3": {
|
||||
|
@ -450,37 +434,47 @@
|
|||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
"sha256:06358015a4dee8ee23ae426bf885616ab3963622defd829eb45b44e3dee3515f",
|
||||
"sha256:0b0c4fc852c5f02c6277ef3b33d23fcbe89b1b227460423e3335374da046b6db",
|
||||
"sha256:267677fc42afed5094fc5ea1c4236bbe4b6a00fe4b08e93451e65ae9048139c7",
|
||||
"sha256:303cb70893e2c345588fb5d5b86e0ca369f9bb56942f03064c5e3e75fa7a238a",
|
||||
"sha256:3c9b624a0d9ed5a5093ac4edc4e823e6b125441e60ef35d36e6f4a6fdacd5054",
|
||||
"sha256:42033e14cae1f6c86fc0c3e90d04d08ce73ac8e46ba420a0d22d545c2abd4977",
|
||||
"sha256:4e4a99b6af7bdc0856b50020c095848ec050356a001e1f751510aef6ab14d0e0",
|
||||
"sha256:4eb07faad54bb07427d848f31030a65a49ebb0cec0b30674f91cf1ddd456bfe4",
|
||||
"sha256:63a7161cd8c2bc563feeda45df62f42c860dd0675e2b8da2667f25bb3c95eaba",
|
||||
"sha256:68e0fd039b68d2945b4beb947d4023ca7f8e95b708031c345762efba214ea761",
|
||||
"sha256:8092a63397025c2f655acd42784b2a1528339b90b987beb9253f22e8cdbb36c3",
|
||||
"sha256:841218860683c0f2223e24756843d84cc49cccdae6765e04962607754a52d3e0",
|
||||
"sha256:94076b2314bd2f6cfae508ad65b4d493e3a58a50112b7a2cbb6287bdbc404ae8",
|
||||
"sha256:9d22aff1c5322e402adfb3ce40839a5056c353e711c033798cf4f02eb9f5124d",
|
||||
"sha256:b0e4584f62b3e5f5c1a7bcefd2b52f236505e6ef032cc508caa4f4c8dc8d3af1",
|
||||
"sha256:b1163ffc1384d242964426a8164da12dbcdbc0de18ea36e2c34b898ed38c3b45",
|
||||
"sha256:beac28ed60c8e838301226a7a85841d0af2068eba2dcb1a58c2d32d6c05e440e",
|
||||
"sha256:c29f096ce79c03054a1101d6e5fe6bf04b0bb489165d5e0e9653fb4fe8048ee1",
|
||||
"sha256:c58779966d53e5f14ba393d64e2402a7926601d1ac8adeb4e83893def79d0428",
|
||||
"sha256:cfe14b37908eaf7d5506302987228bff69e1b8e7071ccd4e70fd0283b1b47f0b",
|
||||
"sha256:e834249c45aa9837d0753351cdca61a4b8b383cc9ad0ff2325c97ff7b69e72a6",
|
||||
"sha256:eed1b234c4499811ee85bcefa22ef5e466e75d132502226ed29740d593316c1f"
|
||||
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
|
||||
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
|
||||
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
|
||||
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
|
||||
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
|
||||
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
|
||||
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
|
||||
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
|
||||
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
|
||||
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
|
||||
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
|
||||
"sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
|
||||
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
|
||||
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
|
||||
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
|
||||
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
|
||||
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
|
||||
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
|
||||
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
|
||||
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
|
||||
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
|
||||
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
|
||||
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
|
||||
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
|
||||
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
|
||||
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
|
||||
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
|
||||
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
|
||||
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
|
||||
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
|
||||
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
|
||||
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
|
||||
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
|
||||
],
|
||||
"version": "==2.0.0a1"
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"oauthlib": {
|
||||
"hashes": [
|
||||
"sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889",
|
||||
"sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.1.0"
|
||||
},
|
||||
"packaging": {
|
||||
|
@ -630,6 +624,7 @@
|
|||
"hashes": [
|
||||
"sha256:5560fd5ba08655f29ff6ad1df1e15dc05abc9d976fcbcec8d2b5167f49b70222"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.4.2"
|
||||
},
|
||||
"pyopenssl": {
|
||||
|
@ -641,10 +636,10 @@
|
|||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
"sha256:1060635ca5ac864c2b7bc7b05a448df4e32d7d8c65e33cbe1514810d339672a2",
|
||||
"sha256:56a551039101858c9e189ac9e66e330a03fb7079e97ba6b50193643905f450ce"
|
||||
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
|
||||
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
|
||||
],
|
||||
"version": "==3.0.0a2"
|
||||
"version": "==2.4.7"
|
||||
},
|
||||
"pyrsistent": {
|
||||
"hashes": [
|
||||
|
@ -868,10 +863,10 @@
|
|||
},
|
||||
"astroid": {
|
||||
"hashes": [
|
||||
"sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703",
|
||||
"sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386"
|
||||
"sha256:4c17cea3e592c21b6e222f673868961bad77e1f985cb1694ed077475a89229c1",
|
||||
"sha256:d8506842a3faf734b81599c8b98dcc423de863adcc1999248480b18bd31a0f38"
|
||||
],
|
||||
"version": "==2.4.2"
|
||||
"version": "==2.4.1"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
|
@ -925,39 +920,6 @@
|
|||
],
|
||||
"version": "==2020.6.20"
|
||||
},
|
||||
"cffi": {
|
||||
"hashes": [
|
||||
"sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e",
|
||||
"sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c",
|
||||
"sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e",
|
||||
"sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1",
|
||||
"sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4",
|
||||
"sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2",
|
||||
"sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c",
|
||||
"sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0",
|
||||
"sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798",
|
||||
"sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1",
|
||||
"sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4",
|
||||
"sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731",
|
||||
"sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4",
|
||||
"sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c",
|
||||
"sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487",
|
||||
"sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e",
|
||||
"sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f",
|
||||
"sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123",
|
||||
"sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c",
|
||||
"sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b",
|
||||
"sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650",
|
||||
"sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad",
|
||||
"sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75",
|
||||
"sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82",
|
||||
"sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7",
|
||||
"sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15",
|
||||
"sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa",
|
||||
"sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281"
|
||||
],
|
||||
"version": "==1.14.2"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
||||
|
@ -1020,30 +982,6 @@
|
|||
"index": "pypi",
|
||||
"version": "==5.2.1"
|
||||
},
|
||||
"cryptography": {
|
||||
"hashes": [
|
||||
"sha256:091d31c42f444c6f519485ed528d8b451d1a0c7bf30e8ca583a0cac44b8a0df6",
|
||||
"sha256:18452582a3c85b96014b45686af264563e3e5d99d226589f057ace56196ec78b",
|
||||
"sha256:1dfa985f62b137909496e7fc182dac687206d8d089dd03eaeb28ae16eec8e7d5",
|
||||
"sha256:1e4014639d3d73fbc5ceff206049c5a9a849cefd106a49fa7aaaa25cc0ce35cf",
|
||||
"sha256:22e91636a51170df0ae4dcbd250d318fd28c9f491c4e50b625a49964b24fe46e",
|
||||
"sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b",
|
||||
"sha256:651448cd2e3a6bc2bb76c3663785133c40d5e1a8c1a9c5429e4354201c6024ae",
|
||||
"sha256:726086c17f94747cedbee6efa77e99ae170caebeb1116353c6cf0ab67ea6829b",
|
||||
"sha256:844a76bc04472e5135b909da6aed84360f522ff5dfa47f93e3dd2a0b84a89fa0",
|
||||
"sha256:88c881dd5a147e08d1bdcf2315c04972381d026cdb803325c03fe2b4a8ed858b",
|
||||
"sha256:96c080ae7118c10fcbe6229ab43eb8b090fccd31a09ef55f83f690d1ef619a1d",
|
||||
"sha256:a0c30272fb4ddda5f5ffc1089d7405b7a71b0b0f51993cb4e5dbb4590b2fc229",
|
||||
"sha256:bb1f0281887d89617b4c68e8db9a2c42b9efebf2702a3c5bf70599421a8623e3",
|
||||
"sha256:c447cf087cf2dbddc1add6987bbe2f767ed5317adb2d08af940db517dd704365",
|
||||
"sha256:c4fd17d92e9d55b84707f4fd09992081ba872d1a0c610c109c18e062e06a2e55",
|
||||
"sha256:d0d5aeaedd29be304848f1c5059074a740fa9f6f26b84c5b63e8b29e73dfc270",
|
||||
"sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e",
|
||||
"sha256:e993468c859d084d5579e2ebee101de8f5a27ce8e2159959b6673b418fd8c785",
|
||||
"sha256:f118a95c7480f5be0df8afeb9a11bd199aa20afab7a96bcf20409b411a3a85f0"
|
||||
],
|
||||
"version": "==2.9.2"
|
||||
},
|
||||
"django": {
|
||||
"hashes": [
|
||||
"sha256:1a63f5bb6ff4d7c42f62a519edc2adbb37f9b78068a5a862beff858b68e3dc8b",
|
||||
|
@ -1068,6 +1006,27 @@
|
|||
"index": "pypi",
|
||||
"version": "==4.3.0"
|
||||
},
|
||||
"dodgy": {
|
||||
"hashes": [
|
||||
"sha256:28323cbfc9352139fdd3d316fa17f325cc0e9ac74438cbba51d70f9b48f86c3a",
|
||||
"sha256:51f54c0fd886fa3854387f354b19f429d38c04f984f38bc572558b703c0542a6"
|
||||
],
|
||||
"version": "==0.2.1"
|
||||
},
|
||||
"flake8": {
|
||||
"hashes": [
|
||||
"sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c",
|
||||
"sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"
|
||||
],
|
||||
"version": "==3.8.3"
|
||||
},
|
||||
"flake8-polyfill": {
|
||||
"hashes": [
|
||||
"sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9",
|
||||
"sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"
|
||||
],
|
||||
"version": "==1.0.2"
|
||||
},
|
||||
"gitdb": {
|
||||
"hashes": [
|
||||
"sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac",
|
||||
|
@ -1143,6 +1102,20 @@
|
|||
],
|
||||
"version": "==5.4.5"
|
||||
},
|
||||
"pep8-naming": {
|
||||
"hashes": [
|
||||
"sha256:5d9f1056cb9427ce344e98d1a7f5665710e2f20f748438e308995852cfa24164",
|
||||
"sha256:f3b4a5f9dd72b991bf7d8e2a341d2e1aa3a884a769b5aaac4f56825c1763bf3a"
|
||||
],
|
||||
"version": "==0.10.0"
|
||||
},
|
||||
"prospector": {
|
||||
"hashes": [
|
||||
"sha256:43e5e187c027336b0e4c4aa6a82d66d3b923b5ec5b51968126132e32f9d14a2f"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.3.0"
|
||||
},
|
||||
"pycodestyle": {
|
||||
"hashes": [
|
||||
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
|
||||
|
@ -1150,28 +1123,47 @@
|
|||
],
|
||||
"version": "==2.6.0"
|
||||
},
|
||||
"pycparser": {
|
||||
"pydocstyle": {
|
||||
"hashes": [
|
||||
"sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",
|
||||
"sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"
|
||||
"sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586",
|
||||
"sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5"
|
||||
],
|
||||
"version": "==2.20"
|
||||
"version": "==5.0.2"
|
||||
},
|
||||
"pyflakes": {
|
||||
"hashes": [
|
||||
"sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
|
||||
"sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
|
||||
],
|
||||
"version": "==2.2.0"
|
||||
},
|
||||
"pylint": {
|
||||
"hashes": [
|
||||
"sha256:7dd78437f2d8d019717dbf287772d0b2dbdfd13fc016aa7faa08d67bccc46adc",
|
||||
"sha256:d0ece7d223fe422088b0e8f13fa0a1e8eb745ebffcb8ed53d3e95394b6101a1c"
|
||||
"sha256:b95e31850f3af163c2283ed40432f053acbc8fc6eba6a069cb518d9dbf71848c",
|
||||
"sha256:dd506acce0427e9e08fb87274bcaa953d38b50a58207170dbf5b36cf3e16957b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.5.3"
|
||||
"version": "==2.5.2"
|
||||
},
|
||||
"pylint-celery": {
|
||||
"hashes": [
|
||||
"sha256:41e32094e7408d15c044178ea828dd524beedbdbe6f83f712c5e35bde1de4beb"
|
||||
],
|
||||
"version": "==0.3"
|
||||
},
|
||||
"pylint-django": {
|
||||
"hashes": [
|
||||
"sha256:770e0c55fb054c6378e1e8bb3fe22c7032a2c38ba1d1f454206ee9c6591822d7",
|
||||
"sha256:b8dcb6006ae9fa911810aba3bec047b9410b7d528f89d5aca2506b03c9235a49"
|
||||
"sha256:06a64331c498a3f049ba669dc0c174b92209e164198d43e589b1096ee616d5f8",
|
||||
"sha256:3d3436ba8d0fae576ae2db160e33a8f2746a101fda4463f2b3ff3a8b6fccec38"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.3.0"
|
||||
"version": "==2.0.15"
|
||||
},
|
||||
"pylint-flask": {
|
||||
"hashes": [
|
||||
"sha256:f4d97de2216bf7bfce07c9c08b166e978fe9f2725de2a50a9845a97de7e31517"
|
||||
],
|
||||
"version": "==0.6"
|
||||
},
|
||||
"pylint-plugin-utils": {
|
||||
"hashes": [
|
||||
|
@ -1180,13 +1172,6 @@
|
|||
],
|
||||
"version": "==0.6"
|
||||
},
|
||||
"pyopenssl": {
|
||||
"hashes": [
|
||||
"sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504",
|
||||
"sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"
|
||||
],
|
||||
"version": "==19.1.0"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed",
|
||||
|
@ -1244,13 +1229,25 @@
|
|||
],
|
||||
"version": "==2.24.0"
|
||||
},
|
||||
"requirements-detector": {
|
||||
"hashes": [
|
||||
"sha256:0d1e13e61ed243f9c3c86e6cbb19980bcb3a0e0619cde2ec1f3af70fdbee6f7b"
|
||||
],
|
||||
"version": "==0.7"
|
||||
},
|
||||
"selenium": {
|
||||
"hashes": [
|
||||
"sha256:5f5489a0c5fe2f09cc6bc3f32a0d53441ab36882c987269f2afe805979633ac1",
|
||||
"sha256:a9779ddc69cf03b75d94062c5e948f763919cf3341c77272f94cd05e6b4c7b32"
|
||||
"sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c",
|
||||
"sha256:deaf32b60ad91a4611b98d8002757f29e6f2c2d5fcaf202e1c9ad06d6772300d"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==4.0.0a6.post2"
|
||||
"version": "==3.141.0"
|
||||
},
|
||||
"setoptconf": {
|
||||
"hashes": [
|
||||
"sha256:5b0b5d8e0077713f5d5152d4f63be6f048d9a1bb66be15d089a11c898c3cf49c"
|
||||
],
|
||||
"version": "==0.2.0"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
|
@ -1266,6 +1263,13 @@
|
|||
],
|
||||
"version": "==3.0.4"
|
||||
},
|
||||
"snowballstemmer": {
|
||||
"hashes": [
|
||||
"sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0",
|
||||
"sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"
|
||||
],
|
||||
"version": "==2.0.0"
|
||||
},
|
||||
"sqlparse": {
|
||||
"hashes": [
|
||||
"sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e",
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
"""test OAuth Provider flow"""
|
||||
from time import sleep
|
||||
|
||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from structlog import get_logger
|
||||
|
@ -14,12 +13,16 @@ from passbook.core.models import Application
|
|||
from passbook.flows.models import Flow
|
||||
from passbook.policies.expression.models import ExpressionPolicy
|
||||
from passbook.policies.models import PolicyBinding
|
||||
from passbook.providers.oauth.models import OAuth2Provider
|
||||
from passbook.providers.oauth2.generators import (
|
||||
generate_client_id,
|
||||
generate_client_secret,
|
||||
)
|
||||
from passbook.providers.oauth2.models import ClientTypes, OAuth2Provider, ResponseTypes
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class TestProviderOAuth(SeleniumTestCase):
|
||||
class TestProviderOAuth2Github(SeleniumTestCase):
|
||||
"""test OAuth Provider flow"""
|
||||
|
||||
def setUp(self):
|
||||
|
@ -43,18 +46,18 @@ class TestProviderOAuth(SeleniumTestCase):
|
|||
),
|
||||
environment={
|
||||
"GF_AUTH_GITHUB_ENABLED": "true",
|
||||
"GF_AUTH_GITHUB_allow_sign_up": "true",
|
||||
"GF_AUTH_GITHUB_ALLOW_SIGN_UP": "true",
|
||||
"GF_AUTH_GITHUB_CLIENT_ID": self.client_id,
|
||||
"GF_AUTH_GITHUB_CLIENT_SECRET": self.client_secret,
|
||||
"GF_AUTH_GITHUB_SCOPES": "user:email,read:org",
|
||||
"GF_AUTH_GITHUB_AUTH_URL": self.url(
|
||||
"passbook_providers_oauth:github-authorize"
|
||||
"passbook_providers_oauth2_github:github-authorize"
|
||||
),
|
||||
"GF_AUTH_GITHUB_TOKEN_URL": self.url(
|
||||
"passbook_providers_oauth:github-access-token"
|
||||
"passbook_providers_oauth2_github:github-access-token"
|
||||
),
|
||||
"GF_AUTH_GITHUB_API_URL": self.url(
|
||||
"passbook_providers_oauth:github-user"
|
||||
"passbook_providers_oauth2_github:github-user"
|
||||
),
|
||||
"GF_LOG_LEVEL": "debug",
|
||||
},
|
||||
|
@ -80,12 +83,11 @@ class TestProviderOAuth(SeleniumTestCase):
|
|||
)
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="grafana",
|
||||
client_type=OAuth2Provider.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=OAuth2Provider.GRANT_AUTHORIZATION_CODE,
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
client_type=ClientTypes.CONFIDENTIAL,
|
||||
response_type=ResponseTypes.CODE,
|
||||
redirect_uris="http://localhost:3000/login/github",
|
||||
skip_authorization=True,
|
||||
authorization_flow=authorization_flow,
|
||||
)
|
||||
Application.objects.create(
|
||||
|
@ -134,12 +136,11 @@ class TestProviderOAuth(SeleniumTestCase):
|
|||
)
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="grafana",
|
||||
client_type=OAuth2Provider.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=OAuth2Provider.GRANT_AUTHORIZATION_CODE,
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
client_type=ClientTypes.CONFIDENTIAL,
|
||||
response_type=ResponseTypes.CODE,
|
||||
redirect_uris="http://localhost:3000/login/github",
|
||||
skip_authorization=True,
|
||||
authorization_flow=authorization_flow,
|
||||
)
|
||||
app = Application.objects.create(
|
||||
|
@ -161,7 +162,7 @@ class TestProviderOAuth(SeleniumTestCase):
|
|||
).text,
|
||||
)
|
||||
self.assertEqual(
|
||||
"GitHub Compatibility: User Email",
|
||||
"GitHub Compatibility: Access you Email addresses",
|
||||
self.driver.find_element(
|
||||
By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/ul/li[1]"
|
||||
).text,
|
||||
|
@ -203,12 +204,11 @@ class TestProviderOAuth(SeleniumTestCase):
|
|||
)
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="grafana",
|
||||
client_type=OAuth2Provider.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=OAuth2Provider.GRANT_AUTHORIZATION_CODE,
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
client_type=ClientTypes.CONFIDENTIAL,
|
||||
response_type=ResponseTypes.CODE,
|
||||
redirect_uris="http://localhost:3000/login/github",
|
||||
skip_authorization=True,
|
||||
authorization_flow=authorization_flow,
|
||||
)
|
||||
app = Application.objects.create(
|
|
@ -1,9 +1,6 @@
|
|||
"""test OpenID Provider flow"""
|
||||
"""test OAuth2 OpenID Provider flow"""
|
||||
from time import sleep
|
||||
|
||||
from django.shortcuts import reverse
|
||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
||||
from oidc_provider.models import Client, ResponseType
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.support import expected_conditions as ec
|
||||
|
@ -12,18 +9,33 @@ from structlog import get_logger
|
|||
from docker import DockerClient, from_env
|
||||
from docker.models.containers import Container
|
||||
from docker.types import Healthcheck
|
||||
from e2e.utils import USER, SeleniumTestCase, ensure_rsa_key
|
||||
from e2e.utils import USER, SeleniumTestCase
|
||||
from passbook.core.models import Application
|
||||
from passbook.crypto.models import CertificateKeyPair
|
||||
from passbook.flows.models import Flow
|
||||
from passbook.policies.expression.models import ExpressionPolicy
|
||||
from passbook.policies.models import PolicyBinding
|
||||
from passbook.providers.oidc.models import OpenIDProvider
|
||||
from passbook.providers.oauth2.constants import (
|
||||
SCOPE_OPENID,
|
||||
SCOPE_OPENID_EMAIL,
|
||||
SCOPE_OPENID_PROFILE,
|
||||
)
|
||||
from passbook.providers.oauth2.generators import (
|
||||
generate_client_id,
|
||||
generate_client_secret,
|
||||
)
|
||||
from passbook.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
OAuth2Provider,
|
||||
ResponseTypes,
|
||||
ScopeMapping,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class TestProviderOIDC(SeleniumTestCase):
|
||||
"""test OpenID Provider flow"""
|
||||
class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
"""test OAuth with OpenID Provider flow"""
|
||||
|
||||
def setUp(self):
|
||||
self.client_id = generate_client_id()
|
||||
|
@ -50,13 +62,13 @@ class TestProviderOIDC(SeleniumTestCase):
|
|||
"GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET": self.client_secret,
|
||||
"GF_AUTH_GENERIC_OAUTH_SCOPES": "openid email profile",
|
||||
"GF_AUTH_GENERIC_OAUTH_AUTH_URL": (
|
||||
self.live_server_url + reverse("passbook_providers_oidc:authorize")
|
||||
self.url("passbook_providers_oauth2:authorize")
|
||||
),
|
||||
"GF_AUTH_GENERIC_OAUTH_TOKEN_URL": (
|
||||
self.live_server_url + reverse("oidc_provider:token")
|
||||
self.url("passbook_providers_oauth2:token")
|
||||
),
|
||||
"GF_AUTH_GENERIC_OAUTH_API_URL": (
|
||||
self.live_server_url + reverse("oidc_provider:userinfo")
|
||||
self.url("passbook_providers_oauth2:userinfo")
|
||||
),
|
||||
"GF_LOG_LEVEL": "debug",
|
||||
},
|
||||
|
@ -80,23 +92,22 @@ class TestProviderOIDC(SeleniumTestCase):
|
|||
authorization_flow = Flow.objects.get(
|
||||
slug="default-provider-authorization-implicit-consent"
|
||||
)
|
||||
client = Client.objects.create(
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="grafana",
|
||||
client_type="confidential",
|
||||
client_type=ClientTypes.CONFIDENTIAL,
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
_redirect_uris="http://localhost:3000/",
|
||||
_scope="openid userinfo",
|
||||
rsa_key=CertificateKeyPair.objects.first(),
|
||||
redirect_uris="http://localhost:3000/",
|
||||
authorization_flow=authorization_flow,
|
||||
response_type=ResponseTypes.CODE,
|
||||
)
|
||||
# At least one of these objects must exist
|
||||
ensure_rsa_key()
|
||||
# This response_code object might exist or not, depending on the order the tests are run
|
||||
rp_type, _ = ResponseType.objects.get_or_create(value="code")
|
||||
client.response_types.set([rp_type])
|
||||
client.save()
|
||||
provider = OpenIDProvider.objects.create(
|
||||
oidc_client=client, authorization_flow=authorization_flow,
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE]
|
||||
)
|
||||
)
|
||||
provider.save()
|
||||
Application.objects.create(
|
||||
name="Grafana", slug="grafana", provider=provider,
|
||||
)
|
||||
|
@ -121,25 +132,22 @@ class TestProviderOIDC(SeleniumTestCase):
|
|||
authorization_flow = Flow.objects.get(
|
||||
slug="default-provider-authorization-implicit-consent"
|
||||
)
|
||||
client = Client.objects.create(
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="grafana",
|
||||
client_type="confidential",
|
||||
client_type=ClientTypes.CONFIDENTIAL,
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
_redirect_uris="http://localhost:3000/login/generic_oauth",
|
||||
_scope="openid profile email",
|
||||
reuse_consent=False,
|
||||
require_consent=False,
|
||||
rsa_key=CertificateKeyPair.objects.first(),
|
||||
redirect_uris="http://localhost:3000/login/generic_oauth",
|
||||
authorization_flow=authorization_flow,
|
||||
response_type=ResponseTypes.CODE,
|
||||
)
|
||||
# At least one of these objects must exist
|
||||
ensure_rsa_key()
|
||||
# This response_code object might exist or not, depending on the order the tests are run
|
||||
rp_type, _ = ResponseType.objects.get_or_create(value="code")
|
||||
client.response_types.set([rp_type])
|
||||
client.save()
|
||||
provider = OpenIDProvider.objects.create(
|
||||
oidc_client=client, authorization_flow=authorization_flow,
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE]
|
||||
)
|
||||
)
|
||||
provider.save()
|
||||
Application.objects.create(
|
||||
name="Grafana", slug="grafana", provider=provider,
|
||||
)
|
||||
|
@ -182,25 +190,22 @@ class TestProviderOIDC(SeleniumTestCase):
|
|||
authorization_flow = Flow.objects.get(
|
||||
slug="default-provider-authorization-explicit-consent"
|
||||
)
|
||||
client = Client.objects.create(
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="grafana",
|
||||
client_type="confidential",
|
||||
authorization_flow=authorization_flow,
|
||||
response_type=ResponseTypes.CODE,
|
||||
client_type=ClientTypes.CONFIDENTIAL,
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
_redirect_uris="http://localhost:3000/login/generic_oauth",
|
||||
_scope="openid profile email",
|
||||
reuse_consent=False,
|
||||
require_consent=False,
|
||||
rsa_key=CertificateKeyPair.objects.first(),
|
||||
redirect_uris="http://localhost:3000/login/generic_oauth",
|
||||
)
|
||||
# At least one of these objects must exist
|
||||
ensure_rsa_key()
|
||||
# This response_code object might exist or not, depending on the order the tests are run
|
||||
rp_type, _ = ResponseType.objects.get_or_create(value="code")
|
||||
client.response_types.set([rp_type])
|
||||
client.save()
|
||||
provider = OpenIDProvider.objects.create(
|
||||
oidc_client=client, authorization_flow=authorization_flow,
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE]
|
||||
)
|
||||
)
|
||||
provider.save()
|
||||
app = Application.objects.create(
|
||||
name="Grafana", slug="grafana", provider=provider,
|
||||
)
|
||||
|
@ -261,25 +266,22 @@ class TestProviderOIDC(SeleniumTestCase):
|
|||
authorization_flow = Flow.objects.get(
|
||||
slug="default-provider-authorization-explicit-consent"
|
||||
)
|
||||
client = Client.objects.create(
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="grafana",
|
||||
client_type="confidential",
|
||||
authorization_flow=authorization_flow,
|
||||
response_type=ResponseTypes.CODE,
|
||||
client_type=ClientTypes.CONFIDENTIAL,
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
_redirect_uris="http://localhost:3000/login/generic_oauth",
|
||||
_scope="openid profile email",
|
||||
reuse_consent=False,
|
||||
require_consent=False,
|
||||
rsa_key=CertificateKeyPair.objects.first(),
|
||||
redirect_uris="http://localhost:3000/login/generic_oauth",
|
||||
)
|
||||
# At least one of these objects must exist
|
||||
ensure_rsa_key()
|
||||
# This response_code object might exist or not, depending on the order the tests are run
|
||||
rp_type, _ = ResponseType.objects.get_or_create(value="code")
|
||||
client.response_types.set([rp_type])
|
||||
client.save()
|
||||
provider = OpenIDProvider.objects.create(
|
||||
oidc_client=client, authorization_flow=authorization_flow,
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE]
|
||||
)
|
||||
)
|
||||
provider.save()
|
||||
app = Application.objects.create(
|
||||
name="Grafana", slug="grafana", provider=provider,
|
||||
)
|
|
@ -2,7 +2,6 @@
|
|||
from os.path import abspath
|
||||
from time import sleep
|
||||
|
||||
from oauth2_provider.generators import generate_client_secret
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.support import expected_conditions as ec
|
||||
|
@ -14,6 +13,7 @@ from docker.models.containers import Container
|
|||
from docker.types import Healthcheck
|
||||
from e2e.utils import SeleniumTestCase
|
||||
from passbook.flows.models import Flow
|
||||
from passbook.providers.oauth2.generators import generate_client_secret
|
||||
from passbook.sources.oauth.models import OAuthSource
|
||||
|
||||
TOKEN_URL = "http://127.0.0.1:5556/dex/token"
|
||||
|
|
11
e2e/utils.py
11
e2e/utils.py
|
@ -6,7 +6,6 @@ from inspect import getmembers, isfunction
|
|||
from os import environ, makedirs
|
||||
from time import time
|
||||
|
||||
from Cryptodome.PublicKey import RSA
|
||||
from django.apps import apps
|
||||
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
|
||||
from django.db import connection, transaction
|
||||
|
@ -28,16 +27,6 @@ def USER() -> User: # noqa
|
|||
return User.objects.get(username="pbadmin")
|
||||
|
||||
|
||||
def ensure_rsa_key():
|
||||
"""Ensure that at least one RSAKey Object exists, create one if none exist"""
|
||||
from oidc_provider.models import RSAKey
|
||||
|
||||
if not RSAKey.objects.exists():
|
||||
key = RSA.generate(2048)
|
||||
rsakey = RSAKey(key=key.exportKey("PEM").decode("utf8"))
|
||||
rsakey.save()
|
||||
|
||||
|
||||
class SeleniumTestCase(StaticLiveServerTestCase):
|
||||
"""StaticLiveServerTestCase which automatically creates a Webdriver instance"""
|
||||
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
"""passbook api urls"""
|
||||
from django.urls import include, path
|
||||
|
||||
from passbook.api.v1.urls import urlpatterns as v1_urls
|
||||
from passbook.api.v2.urls import urlpatterns as v2_urls
|
||||
|
||||
urlpatterns = [
|
||||
path("v1/", include(v1_urls)),
|
||||
path("v2beta/", include(v2_urls)),
|
||||
]
|
||||
|
|
|
@ -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.password.api import PasswordPolicyViewSet
|
||||
from passbook.policies.reputation.api import ReputationPolicyViewSet
|
||||
from passbook.providers.app_gw.api import ApplicationGatewayProviderViewSet
|
||||
from passbook.providers.oauth.api import OAuth2ProviderViewSet
|
||||
from passbook.providers.oidc.api import OpenIDProviderViewSet
|
||||
from passbook.providers.oauth2.api import OAuth2ProviderViewSet, ScopeMappingViewSet
|
||||
from passbook.providers.proxy.api import ProxyProviderViewSet
|
||||
from passbook.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet
|
||||
from passbook.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet
|
||||
from passbook.sources.oauth.api import OAuthSourceViewSet
|
||||
|
@ -70,14 +69,14 @@ router.register("policies/password", PasswordPolicyViewSet)
|
|||
router.register("policies/reputation", ReputationPolicyViewSet)
|
||||
|
||||
router.register("providers/all", ProviderViewSet)
|
||||
router.register("providers/applicationgateway", ApplicationGatewayProviderViewSet)
|
||||
router.register("providers/oauth", OAuth2ProviderViewSet)
|
||||
router.register("providers/openid", OpenIDProviderViewSet)
|
||||
router.register("providers/proxy", ProxyProviderViewSet)
|
||||
router.register("providers/oauth2", OAuth2ProviderViewSet)
|
||||
router.register("providers/saml", SAMLProviderViewSet)
|
||||
|
||||
router.register("propertymappings/all", PropertyMappingViewSet)
|
||||
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
|
||||
router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
|
||||
router.register("propertymappings/scope", ScopeMappingViewSet)
|
||||
|
||||
router.register("stages/all", StageViewSet)
|
||||
router.register("stages/captcha", CaptchaStageViewSet)
|
||||
|
|
|
@ -18,7 +18,7 @@ def admin_autoregister(app: AppConfig):
|
|||
pass
|
||||
|
||||
|
||||
for app in apps.get_app_configs():
|
||||
if app.label.startswith("passbook_"):
|
||||
LOGGER.debug("Registering application for dj-admin", app=app.label)
|
||||
admin_autoregister(app)
|
||||
for _app in apps.get_app_configs():
|
||||
if _app.label.startswith("passbook_"):
|
||||
LOGGER.debug("Registering application for dj-admin", app=_app.label)
|
||||
admin_autoregister(_app)
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
{% load passbook_utils %}
|
||||
|
||||
{% block title %}
|
||||
{% trans 'Bad Request' %}
|
||||
{% trans card_title %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
"""passbook crypto models"""
|
||||
from binascii import hexlify
|
||||
from hashlib import md5
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from cryptography.x509 import Certificate, load_pem_x509_certificate
|
||||
from django.db import models
|
||||
|
@ -31,7 +32,8 @@ class CertificateKeyPair(CreatedUpdatedModel):
|
|||
)
|
||||
|
||||
_cert: Optional[Certificate] = None
|
||||
_key: Optional[RSAPrivateKey] = None
|
||||
_private_key: Optional[RSAPrivateKey] = None
|
||||
_public_key: Optional[RSAPublicKey] = None
|
||||
|
||||
@property
|
||||
def certificate(self) -> Certificate:
|
||||
|
@ -42,16 +44,23 @@ class CertificateKeyPair(CreatedUpdatedModel):
|
|||
)
|
||||
return self._cert
|
||||
|
||||
@property
|
||||
def public_key(self) -> Optional[RSAPublicKey]:
|
||||
"""Get public key of the private key"""
|
||||
if not self._public_key:
|
||||
self._public_key = self.private_key.public_key()
|
||||
return self._public_key
|
||||
|
||||
@property
|
||||
def private_key(self) -> Optional[RSAPrivateKey]:
|
||||
"""Get python cryptography PrivateKey instance"""
|
||||
if not self._key:
|
||||
self._key = load_pem_private_key(
|
||||
if not self._private_key:
|
||||
self._private_key = load_pem_private_key(
|
||||
str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])),
|
||||
password=None,
|
||||
backend=default_backend(),
|
||||
)
|
||||
return self._key
|
||||
return self._private_key
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
|
@ -60,6 +69,15 @@ class CertificateKeyPair(CreatedUpdatedModel):
|
|||
"utf-8"
|
||||
)
|
||||
|
||||
@property
|
||||
def kid(self):
|
||||
"""Get Key ID used for JWKS"""
|
||||
return "{0}".format(
|
||||
md5(self.key_data.encode("utf-8")).hexdigest() # nosec
|
||||
if self.key_data
|
||||
else ""
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Certificate-Key Pair {self.name} {self.fingerprint}"
|
||||
|
||||
|
|
|
@ -66,6 +66,8 @@ class Stage(models.Model):
|
|||
return None
|
||||
|
||||
def __str__(self):
|
||||
if hasattr(self, "__in_memory_type"):
|
||||
return f"In-memory Stage {getattr(self, '__in_memory_type')}"
|
||||
return f"Stage {self.name}"
|
||||
|
||||
|
||||
|
|
|
@ -17,6 +17,6 @@
|
|||
{% trans 'Something went wrong! Please try again later.' %}
|
||||
</h3>
|
||||
{% if debug %}
|
||||
<pre class="pb-exception">{{ tb }}</pre>
|
||||
<pre class="pb-exception">{{ tb }}{{ error }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
@ -85,7 +85,6 @@ def verbose_name(obj) -> str:
|
|||
if not obj:
|
||||
return ""
|
||||
if hasattr(obj, "verbose_name"):
|
||||
print(obj.verbose_name)
|
||||
return obj.verbose_name
|
||||
return obj._meta.verbose_name
|
||||
|
||||
|
|
|
@ -26,11 +26,13 @@ class CreateAssignPermView(CreateView):
|
|||
return response
|
||||
|
||||
|
||||
def bad_request_message(request: HttpRequest, message: str) -> TemplateResponse:
|
||||
def bad_request_message(
|
||||
request: HttpRequest, message: str, title="Bad Request"
|
||||
) -> TemplateResponse:
|
||||
"""Return generic error page with message, with status code set to 400"""
|
||||
return TemplateResponse(
|
||||
request,
|
||||
"error/generic.html",
|
||||
{"message": message, "card_title": _("Bad Request")},
|
||||
{"message": message, "card_title": _(title)},
|
||||
status=400,
|
||||
)
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
"""HIBP Policy tests"""
|
||||
from django.test import TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from oauth2_provider.generators import generate_client_secret
|
||||
|
||||
from passbook.policies.hibp.models import HaveIBeenPwendPolicy
|
||||
from passbook.policies.types import PolicyRequest, PolicyResult
|
||||
from passbook.providers.oauth2.generators import generate_client_secret
|
||||
|
||||
|
||||
class TestHIBPPolicy(TestCase):
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
<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-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">
|
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"""
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
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):
|
||||
"""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):
|
||||
class GitHubUserView(View):
|
||||
"""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"""
|
||||
user = self.verify_access_token()
|
||||
user = token.user
|
||||
return JsonResponse(
|
||||
{
|
||||
"login": user.username,
|
||||
|
@ -78,9 +60,10 @@ class GitHubUserView(GitHubPretendView):
|
|||
)
|
||||
|
||||
|
||||
class GitHubUserTeamsView(GitHubPretendView):
|
||||
class GitHubUserTeamsView(View):
|
||||
"""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"""
|
||||
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 passbook.providers.app_gw.views import K8sManifestView
|
||||
from passbook.providers.proxy.views import K8sManifestView
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
|
@ -1,4 +1,4 @@
|
|||
"""passbook app_gw views"""
|
||||
"""passbook proxy views"""
|
||||
import string
|
||||
from random import SystemRandom
|
||||
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.views import View
|
||||
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 yaml import safe_dump
|
||||
|
||||
from passbook import __version__
|
||||
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"
|
||||
LOGGER = get_logger()
|
||||
|
@ -36,13 +35,12 @@ def get_cookie_secret():
|
|||
class DockerComposeView(LoginRequiredMixin, View):
|
||||
"""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"""
|
||||
site_url = get_site_url(request=self.request)
|
||||
issuer = get_issuer(site_url=site_url, request=self.request)
|
||||
issuer = provider.get_issuer(self.request)
|
||||
env = {
|
||||
"OAUTH2_PROXY_CLIENT_ID": provider.client.client_id,
|
||||
"OAUTH2_PROXY_CLIENT_SECRET": provider.client.client_secret,
|
||||
"OAUTH2_PROXY_CLIENT_ID": provider.client_id,
|
||||
"OAUTH2_PROXY_CLIENT_SECRET": provider.client_secret,
|
||||
"OAUTH2_PROXY_REDIRECT_URL": f"{provider.external_host}/oauth2/callback",
|
||||
"OAUTH2_PROXY_OIDC_ISSUER_URL": issuer,
|
||||
"OAUTH2_PROXY_COOKIE_SECRET": get_cookie_secret(),
|
||||
|
@ -54,7 +52,7 @@ class DockerComposeView(LoginRequiredMixin, View):
|
|||
"version": "3.5",
|
||||
"services": {
|
||||
"passbook_gatekeeper": {
|
||||
"image": f"beryju/passbook-gatekeeper:{__version__}",
|
||||
"image": f"beryju/passbook-proxy:{__version__}",
|
||||
"ports": ["4180:4180"],
|
||||
"environment": env,
|
||||
}
|
||||
|
@ -64,9 +62,9 @@ class DockerComposeView(LoginRequiredMixin, View):
|
|||
|
||||
def get(self, request: HttpRequest, provider_pk: int) -> HttpResponse:
|
||||
"""Render docker-compose file"""
|
||||
provider: ApplicationGatewayProvider = get_object_for_user_or_404(
|
||||
provider: ProxyProvider = get_object_for_user_or_404(
|
||||
request.user,
|
||||
"passbook_providers_app_gw.view_applicationgatewayprovider",
|
||||
"passbook_providers_proxy.view_applicationgatewayprovider",
|
||||
pk=provider_pk,
|
||||
)
|
||||
response = HttpResponse()
|
||||
|
@ -80,21 +78,19 @@ class K8sManifestView(LoginRequiredMixin, View):
|
|||
|
||||
def get(self, request: HttpRequest, provider_pk: int) -> HttpResponse:
|
||||
"""Render deployment template"""
|
||||
provider: ApplicationGatewayProvider = get_object_for_user_or_404(
|
||||
provider: ProxyProvider = get_object_for_user_or_404(
|
||||
request.user,
|
||||
"passbook_providers_app_gw.view_applicationgatewayprovider",
|
||||
pk=provider_pk,
|
||||
)
|
||||
site_url = get_site_url(request=self.request)
|
||||
issuer = get_issuer(site_url=site_url, request=self.request)
|
||||
return render(
|
||||
request,
|
||||
"app_gw/k8s-manifest.yaml",
|
||||
"providers/proxy/k8s-manifest.yaml",
|
||||
{
|
||||
"provider": provider,
|
||||
"cookie_secret": get_cookie_secret(),
|
||||
"version": __version__,
|
||||
"issuer": issuer,
|
||||
"issuer": provider.get_issuer(request),
|
||||
},
|
||||
content_type="text/yaml",
|
||||
)
|
|
@ -87,9 +87,8 @@ INSTALLED_APPS = [
|
|||
"passbook.policies.password.apps.PassbookPoliciesPasswordConfig",
|
||||
"passbook.policies.group_membership.apps.PassbookPoliciesGroupMembershipConfig",
|
||||
"passbook.policies.reputation.apps.PassbookPolicyReputationConfig",
|
||||
"passbook.providers.app_gw.apps.PassbookApplicationApplicationGatewayConfig",
|
||||
"passbook.providers.oauth.apps.PassbookProviderOAuthConfig",
|
||||
"passbook.providers.oidc.apps.PassbookProviderOIDCConfig",
|
||||
"passbook.providers.proxy.apps.PassbookProviderProxyConfig",
|
||||
"passbook.providers.oauth2.apps.PassbookProviderOAuth2Config",
|
||||
"passbook.providers.saml.apps.PassbookProviderSAMLConfig",
|
||||
"passbook.recovery.apps.PassbookRecoveryConfig",
|
||||
"passbook.sources.ldap.apps.PassbookSourceLDAPConfig",
|
||||
|
@ -371,9 +370,6 @@ _LOGGING_HANDLER_MAP = {
|
|||
"celery": "WARNING",
|
||||
"selenium": "WARNING",
|
||||
"grpc": LOG_LEVEL,
|
||||
"oauthlib": LOG_LEVEL,
|
||||
"oauth2_provider": LOG_LEVEL,
|
||||
"oidc_provider": LOG_LEVEL,
|
||||
"docker": "WARNING",
|
||||
"urllib3": "WARNING",
|
||||
"elasticapm": "WARNING",
|
||||
|
|
|
@ -26,23 +26,29 @@ handler500 = error.ServerErrorView.as_view()
|
|||
urlpatterns = []
|
||||
|
||||
for _passbook_app in get_apps():
|
||||
mountpoints = None
|
||||
base_url_module = _passbook_app.name + ".urls"
|
||||
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(
|
||||
_passbook_app.mountpoint,
|
||||
include(
|
||||
(_passbook_app.name + ".urls", _passbook_app.label),
|
||||
namespace=_passbook_app.label,
|
||||
),
|
||||
mountpoint, include((module, _passbook_app.label), namespace=namespace,),
|
||||
)
|
||||
urlpatterns.append(_path)
|
||||
LOGGER.debug(
|
||||
"Mounted URLs",
|
||||
app_name=_passbook_app.name,
|
||||
mountpoint=_passbook_app.mountpoint,
|
||||
mountpoint=mountpoint,
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
urlpatterns += [
|
||||
# Administration
|
||||
path("administration/django/", admin.site.urls),
|
||||
path("metrics/", MetricsView.as_view(), name="metrics"),
|
||||
]
|
||||
|
|
|
@ -22,8 +22,8 @@ defuse_stdlib()
|
|||
class WSGILogger:
|
||||
""" This is the generalized WSGI middleware for any style request logging. """
|
||||
|
||||
def __init__(self, application):
|
||||
self.application = application
|
||||
def __init__(self, _application):
|
||||
self.application = _application
|
||||
self.logger = get_logger("passbook.wsgi")
|
||||
|
||||
def __healthcheck(self, start_response):
|
||||
|
|
|
@ -3,9 +3,9 @@ from unittest.mock import Mock, PropertyMock, patch
|
|||
|
||||
from django.test import TestCase
|
||||
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.providers.oauth2.generators import generate_client_secret
|
||||
from passbook.sources.ldap.auth import LDAPBackend
|
||||
from passbook.sources.ldap.connector import Connector
|
||||
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
|
|
|
@ -5,4 +5,5 @@ RECAPTCHA_PRIVATE_KEY = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe"
|
|||
|
||||
NOCAPTCHA = True
|
||||
INSTALLED_APPS = ["captcha"]
|
||||
|
||||
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]
|
||||
ignore = E731,E121
|
||||
ignore = E731,E121,W503
|
||||
max-line-length = 100
|
||||
|
|
462
swagger.yaml
462
swagger.yaml
|
@ -2183,6 +2183,133 @@ paths:
|
|||
required: true
|
||||
type: string
|
||||
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/:
|
||||
get:
|
||||
operationId: providers_all_list
|
||||
|
@ -2252,135 +2379,9 @@ paths:
|
|||
description: A unique integer value identifying this provider.
|
||||
required: true
|
||||
type: integer
|
||||
/providers/applicationgateway/:
|
||||
/providers/oauth2/:
|
||||
get:
|
||||
operationId: providers_applicationgateway_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
|
||||
operationId: providers_oauth2_list
|
||||
description: OAuth2Provider Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
|
@ -2429,7 +2430,7 @@ paths:
|
|||
tags:
|
||||
- providers
|
||||
post:
|
||||
operationId: providers_oauth_create
|
||||
operationId: providers_oauth2_create
|
||||
description: OAuth2Provider Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
|
@ -2445,9 +2446,9 @@ paths:
|
|||
tags:
|
||||
- providers
|
||||
parameters: []
|
||||
/providers/oauth/{id}/:
|
||||
/providers/oauth2/{id}/:
|
||||
get:
|
||||
operationId: providers_oauth_read
|
||||
operationId: providers_oauth2_read
|
||||
description: OAuth2Provider Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
|
@ -2458,7 +2459,7 @@ paths:
|
|||
tags:
|
||||
- providers
|
||||
put:
|
||||
operationId: providers_oauth_update
|
||||
operationId: providers_oauth2_update
|
||||
description: OAuth2Provider Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
|
@ -2474,7 +2475,7 @@ paths:
|
|||
tags:
|
||||
- providers
|
||||
patch:
|
||||
operationId: providers_oauth_partial_update
|
||||
operationId: providers_oauth2_partial_update
|
||||
description: OAuth2Provider Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
|
@ -2490,7 +2491,7 @@ paths:
|
|||
tags:
|
||||
- providers
|
||||
delete:
|
||||
operationId: providers_oauth_delete
|
||||
operationId: providers_oauth2_delete
|
||||
description: OAuth2Provider Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
|
@ -2501,13 +2502,13 @@ paths:
|
|||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
description: A unique integer value identifying this OAuth2 Provider.
|
||||
description: A unique integer value identifying this OAuth2/OpenID Provider.
|
||||
required: true
|
||||
type: integer
|
||||
/providers/openid/:
|
||||
/providers/proxy/:
|
||||
get:
|
||||
operationId: providers_openid_list
|
||||
description: OpenIDProvider Viewset
|
||||
operationId: providers_proxy_list
|
||||
description: ProxyProvider Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
in: query
|
||||
|
@ -2551,73 +2552,73 @@ paths:
|
|||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/OpenIDProvider'
|
||||
$ref: '#/definitions/ProxyProvider'
|
||||
tags:
|
||||
- providers
|
||||
post:
|
||||
operationId: providers_openid_create
|
||||
description: OpenIDProvider Viewset
|
||||
operationId: providers_proxy_create
|
||||
description: ProxyProvider Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OpenIDProvider'
|
||||
$ref: '#/definitions/ProxyProvider'
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OpenIDProvider'
|
||||
$ref: '#/definitions/ProxyProvider'
|
||||
tags:
|
||||
- providers
|
||||
parameters: []
|
||||
/providers/openid/{id}/:
|
||||
/providers/proxy/{id}/:
|
||||
get:
|
||||
operationId: providers_openid_read
|
||||
description: OpenIDProvider Viewset
|
||||
operationId: providers_proxy_read
|
||||
description: ProxyProvider Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OpenIDProvider'
|
||||
$ref: '#/definitions/ProxyProvider'
|
||||
tags:
|
||||
- providers
|
||||
put:
|
||||
operationId: providers_openid_update
|
||||
description: OpenIDProvider Viewset
|
||||
operationId: providers_proxy_update
|
||||
description: ProxyProvider Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OpenIDProvider'
|
||||
$ref: '#/definitions/ProxyProvider'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OpenIDProvider'
|
||||
$ref: '#/definitions/ProxyProvider'
|
||||
tags:
|
||||
- providers
|
||||
patch:
|
||||
operationId: providers_openid_partial_update
|
||||
description: OpenIDProvider Viewset
|
||||
operationId: providers_proxy_partial_update
|
||||
description: ProxyProvider Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/OpenIDProvider'
|
||||
$ref: '#/definitions/ProxyProvider'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/OpenIDProvider'
|
||||
$ref: '#/definitions/ProxyProvider'
|
||||
tags:
|
||||
- providers
|
||||
delete:
|
||||
operationId: providers_openid_delete
|
||||
description: OpenIDProvider Viewset
|
||||
operationId: providers_proxy_delete
|
||||
description: ProxyProvider Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'204':
|
||||
|
@ -2627,7 +2628,7 @@ paths:
|
|||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
description: A unique integer value identifying this Client.
|
||||
description: A unique integer value identifying this Proxy Provider.
|
||||
required: true
|
||||
type: integer
|
||||
/providers/saml/:
|
||||
|
@ -5996,6 +5997,36 @@ definitions:
|
|||
title: Expression
|
||||
type: string
|
||||
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:
|
||||
required:
|
||||
- authorization_flow
|
||||
|
@ -6020,11 +6051,10 @@ definitions:
|
|||
title: 'type '
|
||||
type: string
|
||||
readOnly: true
|
||||
OpenIDProvider:
|
||||
title: Client
|
||||
OAuth2Provider:
|
||||
required:
|
||||
- client_id
|
||||
- response_types
|
||||
- name
|
||||
- authorization_flow
|
||||
type: object
|
||||
properties:
|
||||
pk:
|
||||
|
@ -6034,12 +6064,17 @@ definitions:
|
|||
name:
|
||||
title: Name
|
||||
type: string
|
||||
maxLength: 100
|
||||
minLength: 1
|
||||
authorization_flow:
|
||||
title: Authorization flow
|
||||
description: Flow used when authorizing this provider.
|
||||
type: string
|
||||
format: uuid
|
||||
client_type:
|
||||
title: Client Type
|
||||
description: <b>Confidential</b> clients are capable of maintaining the confidentiality
|
||||
of their credentials. <b>Public</b> clients are incapable.
|
||||
description: |-
|
||||
<b>Confidential</b> clients are capable of maintaining the confidentiality
|
||||
of their credentials. <b>Public</b> clients are incapable.
|
||||
type: string
|
||||
enum:
|
||||
- confidential
|
||||
|
@ -6050,46 +6085,54 @@ definitions:
|
|||
maxLength: 255
|
||||
minLength: 1
|
||||
client_secret:
|
||||
title: Client SECRET
|
||||
title: Client Secret
|
||||
type: string
|
||||
maxLength: 255
|
||||
response_types:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
uniqueItems: true
|
||||
response_type:
|
||||
title: Response type
|
||||
description: Response Type required by the client.
|
||||
type: string
|
||||
enum:
|
||||
- code
|
||||
- id_token
|
||||
- id_token token
|
||||
- code token
|
||||
- code id_token
|
||||
- code id_token token
|
||||
jwt_alg:
|
||||
title: JWT Algorithm
|
||||
description: Algorithm used to encode ID Tokens.
|
||||
description: Algorithm used to sign the JWT Token
|
||||
type: string
|
||||
enum:
|
||||
- HS256
|
||||
- RS256
|
||||
reuse_consent:
|
||||
title: Reuse Consent?
|
||||
description: If enabled, server will save the user consent given to a specific
|
||||
client, so that user won't be prompted for the same authorization multiple
|
||||
times.
|
||||
type: boolean
|
||||
require_consent:
|
||||
title: Require Consent?
|
||||
description: If disabled, the Server will NEVER ask the user for consent.
|
||||
type: boolean
|
||||
_redirect_uris:
|
||||
rsa_key:
|
||||
title: RSA Key
|
||||
description: Key used to sign the tokens. Only required when JWT Algorithm
|
||||
is set to RS256.
|
||||
type: string
|
||||
format: uuid
|
||||
x-nullable: true
|
||||
redirect_uris:
|
||||
title: Redirect URIs
|
||||
description: Enter each URI on a new line.
|
||||
type: string
|
||||
minLength: 1
|
||||
_scope:
|
||||
title: Scopes
|
||||
description: Specifies the authorized scope values for the client app.
|
||||
post_logout_redirect_uris:
|
||||
title: Post Logout Redirect URIs
|
||||
description: Enter each URI on a new line.
|
||||
type: string
|
||||
ApplicationGatewayProvider:
|
||||
property_mappings:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
uniqueItems: true
|
||||
ProxyProvider:
|
||||
required:
|
||||
- name
|
||||
- internal_host
|
||||
- external_host
|
||||
- client
|
||||
type: object
|
||||
properties:
|
||||
pk:
|
||||
|
@ -6108,49 +6151,6 @@ definitions:
|
|||
title: External host
|
||||
type: string
|
||||
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:
|
||||
required:
|
||||
- name
|
||||
|
|
Reference in a new issue