OAuth Provider Rewrite (#182)

This commit is contained in:
Jens L 2020-08-19 10:32:44 +02:00 committed by GitHub
parent b9076b5fd4
commit c7a2410b1d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
97 changed files with 3107 additions and 1911 deletions

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@
{% load passbook_utils %}
{% block title %}
{% trans 'Bad Request' %}
{% trans card_title %}
{% endblock %}
{% block card %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(),
}

View File

@ -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",),
),
]

View File

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

View File

@ -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"))
]
),
),
]

View File

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

View File

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

View File

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

View File

@ -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"),
}

View File

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

View File

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

View File

@ -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",
}
}

View File

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

View File

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

View File

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

View 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

View 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": "",
}

View 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"

View 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]

View 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"),
}

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

View 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",
},
),
]

View 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

View File

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

View 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",
),
]

View 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)),
]

View 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

View 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,
)

View File

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

View 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})

View 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

View 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

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

View 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",
),
]

View File

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

View 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

View 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/"

View 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(),
}

View 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",),
),
]

View 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")

View File

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

View File

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

View File

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

View File

@ -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"),
]

View File

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

View File

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

View File

@ -5,4 +5,5 @@ RECAPTCHA_PRIVATE_KEY = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe"
NOCAPTCHA = True
INSTALLED_APPS = ["captcha"]
SILENCED_SYSTEM_CHECKS = ["captcha.recaptcha_test_key_error"]

View File

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

View File

@ -1,3 +1,3 @@
[pycodestyle]
ignore = E731,E121
ignore = E731,E121,W503
max-line-length = 100

View File

@ -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,11 +6064,16 @@ 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
description: |-
<b>Confidential</b> clients are capable of maintaining the confidentiality
of their credentials. <b>Public</b> clients are incapable.
type: string
enum:
@ -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