Compare commits
147 Commits
version/0.
...
version/0.
| Author | SHA1 | Date | |
|---|---|---|---|
| c58658d820 | |||
| a9b5e6ea13 | |||
| ddb0fdee98 | |||
| 83205f1b49 | |||
| 7221800a16 | |||
| 4515cb6bbe | |||
| 7f9da11eba | |||
| da69d2611d | |||
| 3b4be5695a | |||
| 9d68c9550b | |||
| 3b2d469780 | |||
| ae629d1159 | |||
| 72a6f9cbe0 | |||
| 9793b7461b | |||
| 9c1a824dc4 | |||
| 738ced3327 | |||
| ed1ee1fa55 | |||
| 95776bbc56 | |||
| 62a4beb3d6 | |||
| 466a825f5b | |||
| 3ffed279d7 | |||
| 4b6b36b2d2 | |||
| 2a8f63bf86 | |||
| 3c12cf96a9 | |||
| d787caf0e4 | |||
| 0fc2f32d3d | |||
| 894d5da1d8 | |||
| 985d20d025 | |||
| 94f3e6d0c5 | |||
| 0a196608c7 | |||
| d33f0fb2cf | |||
| ffff69ada0 | |||
| 37a432267d | |||
| 88029a4335 | |||
| 4040eb9619 | |||
| c9663a08da | |||
| a3d92ebc0a | |||
| 6fa825e372 | |||
| 6aefd072c8 | |||
| ac2dd3611f | |||
| 74e628ce9c | |||
| d4ee18ee32 | |||
| 9ff3ee7c0c | |||
| 418b94a45a | |||
| 1393078fe6 | |||
| 50612991fa | |||
| 37b2400cdb | |||
| 05c3393669 | |||
| c60d1e1f9a | |||
| 2be7d3191f | |||
| aa692fdacb | |||
| c163637bfd | |||
| 5552aca079 | |||
| ff2456dcfa | |||
| 539264c396 | |||
| 1acfaf1562 | |||
| a81e277cfa | |||
| b4cb78f33f | |||
| 35c0a9532f | |||
| aff074420b | |||
| edbea9ccff | |||
| 6b26e10ea2 | |||
| a737335fdd | |||
| e15f7d7f28 | |||
| fbf9554a9e | |||
| 5f34b08433 | |||
| f67a03ad66 | |||
| 6095301337 | |||
| 4a774b5885 | |||
| aa8fac3a06 | |||
| b8407f5bf6 | |||
| 989c426211 | |||
| 9a888cfcf1 | |||
| 72ec871729 | |||
| 8d58842c9b | |||
| a90aa5e069 | |||
| 639020a2e1 | |||
| 8e6f915ec6 | |||
| 6631471566 | |||
| b452e751ea | |||
| a3baa100d4 | |||
| f7b9de1261 | |||
| 47ca566d06 | |||
| a943d060d2 | |||
| 1675dab314 | |||
| 996aa367d3 | |||
| be6f342e58 | |||
| 464b558a02 | |||
| d1151091cd | |||
| f8e5383ba2 | |||
| 06f73512df | |||
| 0ff4545bab | |||
| ff6e270886 | |||
| 8aa0b72b67 | |||
| 91766a2162 | |||
| a393097504 | |||
| 2056b86ce7 | |||
| 1b0c013d8e | |||
| 92a09be8c0 | |||
| 1e31cd03ed | |||
| dc863a6e87 | |||
| d74366f413 | |||
| 5bcf2aef8c | |||
| 8de3c4fbd6 | |||
| c191b62245 | |||
| 0babbde00e | |||
| b8af312ab1 | |||
| 38cabfb325 | |||
| 0a3528b5f4 | |||
| 30a672758a | |||
| 723a825085 | |||
| 40e794099a | |||
| 111b037512 | |||
| 52f66717d3 | |||
| 7ac4242a38 | |||
| 4caa4be476 | |||
| c6d8bae147 | |||
| c70310730a | |||
| 2d2b2d08f4 | |||
| 8fe6a5b62d | |||
| 5e6221deb8 | |||
| c3b493f7d4 | |||
| dbcb5b4f63 | |||
| f0640fcea9 | |||
| 64c47a59f8 | |||
| 3450b8f1fe | |||
| 9518cefdd7 | |||
| 32d5c26577 | |||
| ef2cdf27b3 | |||
| e58ac7ae90 | |||
| d786fa4b7c | |||
| 0e3e73989d | |||
| d831599608 | |||
| 1e57926603 | |||
| 1524880eec | |||
| 0bfb623f97 | |||
| 429627494c | |||
| 9feea155fe | |||
| 2717e02d93 | |||
| 18bd803b0d | |||
| c7f078ffcc | |||
| 571cb3d65f | |||
| 8c500c38b1 | |||
| 5644e57e6a | |||
| cfc181eed1 | |||
| 91bea38b8e | |||
| d95c5aa739 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.9.0-pre6
|
||||
current_version = 0.9.0-rc2
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
|
||||
@ -15,6 +15,10 @@ values =
|
||||
beta
|
||||
stable
|
||||
|
||||
[bumpversion:file:README.md]
|
||||
|
||||
[bumpversion:file:docs/installation/docker-compose.md]
|
||||
|
||||
[bumpversion:file:helm/values.yaml]
|
||||
|
||||
[bumpversion:file:helm/Chart.yaml]
|
||||
|
||||
@ -5,8 +5,6 @@ omit =
|
||||
manage.py
|
||||
*/migrations/*
|
||||
*/apps.py
|
||||
passbook/management/commands/web.py
|
||||
passbook/management/commands/worker.py
|
||||
docs/
|
||||
|
||||
[report]
|
||||
|
||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@ -16,11 +16,11 @@ jobs:
|
||||
- name: Building Docker Image
|
||||
run: docker build
|
||||
--no-cache
|
||||
-t beryju/passbook:0.9.0-pre6
|
||||
-t beryju/passbook:0.9.0-rc2
|
||||
-t beryju/passbook:latest
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook:0.9.0-pre6
|
||||
run: docker push beryju/passbook:0.9.0-rc2
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook:latest
|
||||
build-gatekeeper:
|
||||
@ -37,11 +37,11 @@ jobs:
|
||||
cd gatekeeper
|
||||
docker build \
|
||||
--no-cache \
|
||||
-t beryju/passbook-gatekeeper:0.9.0-pre6 \
|
||||
-t beryju/passbook-gatekeeper:0.9.0-rc2 \
|
||||
-t beryju/passbook-gatekeeper:latest \
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook-gatekeeper:0.9.0-pre6
|
||||
run: docker push beryju/passbook-gatekeeper:0.9.0-rc2
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook-gatekeeper:latest
|
||||
build-static:
|
||||
@ -66,11 +66,11 @@ jobs:
|
||||
run: docker build
|
||||
--no-cache
|
||||
--network=$(docker network ls | grep github | awk '{print $1}')
|
||||
-t beryju/passbook-static:0.9.0-pre6
|
||||
-t beryju/passbook-static:0.9.0-rc2
|
||||
-t beryju/passbook-static:latest
|
||||
-f static.Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook-static:0.9.0-pre6
|
||||
run: docker push beryju/passbook-static:0.9.0-rc2
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook-static:latest
|
||||
test-release:
|
||||
@ -100,5 +100,5 @@ jobs:
|
||||
SENTRY_PROJECT: passbook
|
||||
SENTRY_URL: https://sentry.beryju.org
|
||||
with:
|
||||
tagName: 0.9.0-pre6
|
||||
tagName: 0.9.0-rc2
|
||||
environment: production
|
||||
|
||||
215
Pipfile.lock
generated
215
Pipfile.lock
generated
@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "f90d79a67bbd689ca4e7ccbfd528e4ed45078e848c36d84e53ff9c6b2a1e92ed"
|
||||
"sha256": "5c22d3a514247b663a07c6492cea09ab140346894a528db06bd805a4a3a4a320"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@ -46,18 +46,18 @@
|
||||
},
|
||||
"boto3": {
|
||||
"hashes": [
|
||||
"sha256:ae57df1fbad7e29954a160d77cbf650d6562eb0d304c1206afa71d914e771a66",
|
||||
"sha256:cbe618d61cb8f75cd9495ea36e69bad7c8984eb11f02ad247be4c9a2eb7eb647"
|
||||
"sha256:9a3c6b6f93d381f1d7a06ec0f50a9ad3d7b81509443f80ba222eb66befa8976a",
|
||||
"sha256:ec3a7662e318455727b4b36f4a57ba473a90b6526cebf1fc10e847182f1fd917"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.14.17"
|
||||
"version": "==1.14.28"
|
||||
},
|
||||
"botocore": {
|
||||
"hashes": [
|
||||
"sha256:5528c04c360019c24f2706ce82872c9ab767a8c581beffdfdaf006cce7499cac",
|
||||
"sha256:d65b5574dad8c221344496352245828d9ffecaa0868199eb04ccd2eb2ff09133"
|
||||
"sha256:71d45ae51c4c1a7ae485836016170a817d8d53292d940d04d72e49e473b98127",
|
||||
"sha256:e855254817e289bb9a5fa3c143920c21ea8aeb424f1d4eed1e6c32d84dfd277d"
|
||||
],
|
||||
"version": "==1.17.17"
|
||||
"version": "==1.17.28"
|
||||
},
|
||||
"celery": {
|
||||
"hashes": [
|
||||
@ -232,11 +232,11 @@
|
||||
},
|
||||
"django-prometheus": {
|
||||
"hashes": [
|
||||
"sha256:054924b6aedd41f3f76940578127e7fac852a696805f956da39b7941c78bba91",
|
||||
"sha256:208e55e3a11ac9edd53a3b342b033e140ffe27914af1202f0b064edf99e83fc3"
|
||||
"sha256:441bd85531ecdeddacbe73c930f16de926c426869ce388fa1e8c8092f7ee5a1b",
|
||||
"sha256:6e824cd407b56c01810c69d2e296940d00afe609b58818794525f9760a9a5364"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.1.0.dev46"
|
||||
"version": "==2.1.0.dev52"
|
||||
},
|
||||
"django-recaptcha": {
|
||||
"hashes": [
|
||||
@ -308,35 +308,36 @@
|
||||
},
|
||||
"elastic-apm": {
|
||||
"hashes": [
|
||||
"sha256:0ffd86d8449d7b63c6053c5032e09abf398c753214a55de8a4e15cb9b56108d1",
|
||||
"sha256:18006ade25a91a8030b5fcfa825d5c364b13bc5e902b818725341f8c9a00895a",
|
||||
"sha256:1d8335f94660c246d5475ec3b15452cd0f5b51affb2e1d16eb2fbc36380308a8",
|
||||
"sha256:25fea9cb6c99efc229b1449d7fbdda76260404cc74abefcc0cc86b3a5102d99d",
|
||||
"sha256:2841bee5650b736d5ebb199d728a18b415dfed22cb367cd913619a691dfe39e8",
|
||||
"sha256:301d159933f19115b21f92bc1ff7f0073bfea13ca24c6ff34023c23077a08e44",
|
||||
"sha256:4eaaebd088315d7ba2726b21fea06279598cad128be073b28c0462049f093d5a",
|
||||
"sha256:515d027d380df818ec304d4d28121c39069a0d919cb2eb7f8e29019a14d62c2a",
|
||||
"sha256:5ad2b431298567f642d44826be2c557d9aca5761c0240be23f9a52b66833cc93",
|
||||
"sha256:5dbf19570bdf97e169b5901913e9a3e271ff5e10d298a608e214802dca8c9065",
|
||||
"sha256:724ded78cc24d2c7d8bc81642a938d9bfc2dcb8b5bdad1b1da242300f9f4ec73",
|
||||
"sha256:7e859162f4c187defe26fb00c974a128eee2bd8988cd30ccffdcaaeb56cb2248",
|
||||
"sha256:9180ed12b9c12cc794f3b57069d1e7b2a04352af02f8d0bc89c9251231f8660e",
|
||||
"sha256:92f885cc67a9d78e72b174feaa979ddb5188d7cef2b5a7739be740955e07c5ed",
|
||||
"sha256:976eaaf3825df760946f31b5426544fecc4c32fd66e124565ede7151f8152689",
|
||||
"sha256:abafeff08ff285cc03c33e822633c6e25a9434174413f72a5032393e9f95a1e0",
|
||||
"sha256:ad21169ebee7ae35d6c42cd6ac9e7658d6e07bc6a3f34dcc4f0a32e03d736fdc",
|
||||
"sha256:b2b4ff079a20d620d7f87a345d37cf9b7f2bc1c8cc8c9317fb0c3979371f0d41",
|
||||
"sha256:b3b72d26104de89124cca965b234b6b67be4604518e168aedcd52c7229c923e9",
|
||||
"sha256:c4a144ecb0b1570c1f6a285cd6f28f2eda89c0696ed494892e3250bb6fed7909",
|
||||
"sha256:dc368bbac6401fa0c9d7a35429257190759f4f33099783d9e0557ce12d64ca6c",
|
||||
"sha256:e28a81802784ea80d21c294a4ab4e47f658a4031caa5c320147925ab62c6a0d4",
|
||||
"sha256:e7832a5ad503d6cd4a7eaa4cee782ccdf113afa99708e3d005fe9aef539a8222",
|
||||
"sha256:f2674a3aee0c38df82dedade353c944a2f55b215c7d5b0776e1bb89ce87de57c",
|
||||
"sha256:f7b37f65c0ca971038f6b69c7581ea762fbc89d9631107babc04c646898686d2",
|
||||
"sha256:fbd1a68b4cf32298e09652958ec3cf13462a5269408522211cfd3e02b451c3b2"
|
||||
"sha256:0c766621a4d15ed4ff7dd195499df1af6d7eb8c13790a727bf05773de2952de0",
|
||||
"sha256:2187a0fd080cac7ed65dabfd64d7693ff187ae9b5ad4a810772387dca6877160",
|
||||
"sha256:2a0bb663d3f9388db233784356f218807b9cfe1f4d4fa4569f41b567c068b50f",
|
||||
"sha256:317e2a897b2a81d79bce42688975cfe0ccf6a3dc8025540c47093ea8ac5f1771",
|
||||
"sha256:3a91d2df89af564dbf0abccb3d370940083205247903fe6d708fa771b16fca38",
|
||||
"sha256:44fe2ce3ea57f97fce5fb32e747f6a9c9b361f5055608d59747c39ae06d1c526",
|
||||
"sha256:4ca9f42d4b841ce598819f2f3a4d516c549cd5c02ab43c8283ca406c3b92a2db",
|
||||
"sha256:56b34b30420aebf9566eeee3ffd633131ce51d1e2a4da6061f143a2b547d1980",
|
||||
"sha256:5a56d20734771a4f7823ec12492fcd17a15dac761ecf1452d034a9b9b8b83388",
|
||||
"sha256:6279cc28bd2f2bc2da478cebd5ace711b52549f736d138f950ebe0fa8f706a6f",
|
||||
"sha256:69bcac2cee8f16a093f57000128caab7d1d3d8ac1474e24ce45190264ffc5ebe",
|
||||
"sha256:7021b931210140e02540f3e56fdc8be07542eed10de82c9e5464dbe449a4c9aa",
|
||||
"sha256:70237e1242ae461500ed455f47a5518abdbdc565e47265eddf3ca1dad530a541",
|
||||
"sha256:7545f27703151ce71d73271a95662735cffb537189c214f778195a6fdab58533",
|
||||
"sha256:8525ba800fbd955b65af667c43889df2358c22b1ef66ee92a846f5f4bc8d7286",
|
||||
"sha256:8ba4239862f0b043d191a19e021637a25c3490f677cb8b1dd752bc425bb382e0",
|
||||
"sha256:8c98625cb825c404954763ca5a6f82e06b833a6e6a9e2035065dc9894b4dc6dc",
|
||||
"sha256:b02394f4d55af4f39086aee7bacf8652fde703f7226c5a564cdae9f7e2bf3f71",
|
||||
"sha256:b3b1815765638ce01f9dbd136822d79e887d8d09cd10bc8770d4cc1d530bb853",
|
||||
"sha256:b7bce10060abd98198d8a96e7f3e2e0e169dbd860c76e2c09e6a8874384eebb7",
|
||||
"sha256:b8f849202dffe97512843dd366c4104d07d3b319e42916e3e031cff3db7475db",
|
||||
"sha256:bc677614c198486ca4ef1026bde0c4efd74b936598ff9d64ea109f978a6381bb",
|
||||
"sha256:d19fe00915c60ceabee42ae8c0aa76c6a48c2ffa67c5ba7f0d0fbb856ac36c09",
|
||||
"sha256:d5561eb57eaa43c721258797dfab67b13938fdc94b7daec7a6ccb56dc524fe02",
|
||||
"sha256:dc04aa32c7a3a17c688e3cc4c6293f2176be2482d67efccc651ff1fbb5c00ed6",
|
||||
"sha256:e0d2c3463061b0e50ca53530bd5317498517d208618d90cf6e9933e93f9c727e",
|
||||
"sha256:e9a416418cb2f6deb7a18b68bd75dad0552b4fd85d3e72e59ae4add0e8739b1c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==5.8.0"
|
||||
"version": "==5.8.1"
|
||||
},
|
||||
"facebook-sdk": {
|
||||
"hashes": [
|
||||
@ -412,36 +413,40 @@
|
||||
},
|
||||
"lxml": {
|
||||
"hashes": [
|
||||
"sha256:06748c7192eab0f48e3d35a7adae609a329c6257495d5e53878003660dc0fec6",
|
||||
"sha256:0790ddca3f825dd914978c94c2545dbea5f56f008b050e835403714babe62a5f",
|
||||
"sha256:1aa7a6197c1cdd65d974f3e4953764eee3d9c7b67e3966616b41fab7f8f516b7",
|
||||
"sha256:22c6d34fdb0e65d5f782a4d1a1edb52e0a8365858dafb1c08cb1d16546cf0786",
|
||||
"sha256:2754d4406438c83144f9ffd3628bbe2dcc6d62b20dbc5c1ec4bc4385e5d44b42",
|
||||
"sha256:27ee0faf8077c7c1a589573b1450743011117f1aa1a91d5ae776bbc5ca6070f2",
|
||||
"sha256:2b02c106709466a93ed424454ce4c970791c486d5fcdf52b0d822a7e29789626",
|
||||
"sha256:2d1ddce96cf15f1254a68dba6935e6e0f1fe39247de631c115e84dd404a6f031",
|
||||
"sha256:4f282737d187ae723b2633856085c31ae5d4d432968b7f3f478a48a54835f5c4",
|
||||
"sha256:51bb4edeb36d24ec97eb3e6a6007be128b720114f9a875d6b370317d62ac80b9",
|
||||
"sha256:7eee37c1b9815e6505847aa5e68f192e8a1b730c5c7ead39ff317fde9ce29448",
|
||||
"sha256:7fd88cb91a470b383aafad554c3fe1ccf6dfb2456ff0e84b95335d582a799804",
|
||||
"sha256:9144ce36ca0824b29ebc2e02ca186e54040ebb224292072250467190fb613b96",
|
||||
"sha256:925baf6ff1ef2c45169f548cc85204433e061360bfa7d01e1be7ae38bef73194",
|
||||
"sha256:a636346c6c0e1092ffc202d97ec1843a75937d8c98aaf6771348ad6422e44bb0",
|
||||
"sha256:a87dbee7ad9dce3aaefada2081843caf08a44a8f52e03e0a4cc5819f8398f2f4",
|
||||
"sha256:a9e3b8011388e7e373565daa5e92f6c9cb844790dc18e43073212bb3e76f7007",
|
||||
"sha256:afb53edf1046599991fb4a7d03e601ab5f5422a5435c47ee6ba91ec3b61416a6",
|
||||
"sha256:b26719890c79a1dae7d53acac5f089d66fd8cc68a81f4e4bd355e45470dc25e1",
|
||||
"sha256:b7462cdab6fffcda853338e1741ce99706cdf880d921b5a769202ea7b94e8528",
|
||||
"sha256:b77975465234ff49fdad871c08aa747aae06f5e5be62866595057c43f8d2f62c",
|
||||
"sha256:c47a8a5d00060122ca5908909478abce7bbf62d812e3fc35c6c802df8fb01fe7",
|
||||
"sha256:c79e5debbe092e3c93ca4aee44c9a7631bdd407b2871cb541b979fd350bbbc29",
|
||||
"sha256:d8d40e0121ca1606aa9e78c28a3a7d88a05c06b3ca61630242cded87d8ce55fa",
|
||||
"sha256:ee2be8b8f72a2772e72ab926a3bccebf47bb727bda41ae070dc91d1fb759b726",
|
||||
"sha256:f95d28193c3863132b1f55c1056036bf580b5a488d908f7d22a04ace8935a3a9",
|
||||
"sha256:fadd2a63a2bfd7fb604508e553d1cf68eca250b2fbdbd81213b5f6f2fbf23529"
|
||||
"sha256:05a444b207901a68a6526948c7cc8f9fe6d6f24c70781488e32fd74ff5996e3f",
|
||||
"sha256:08fc93257dcfe9542c0a6883a25ba4971d78297f63d7a5a26ffa34861ca78730",
|
||||
"sha256:107781b213cf7201ec3806555657ccda67b1fccc4261fb889ef7fc56976db81f",
|
||||
"sha256:121b665b04083a1e85ff1f5243d4a93aa1aaba281bc12ea334d5a187278ceaf1",
|
||||
"sha256:1fa21263c3aba2b76fd7c45713d4428dbcc7644d73dcf0650e9d344e433741b3",
|
||||
"sha256:2b30aa2bcff8e958cd85d907d5109820b01ac511eae5b460803430a7404e34d7",
|
||||
"sha256:4b4a111bcf4b9c948e020fd207f915c24a6de3f1adc7682a2d92660eb4e84f1a",
|
||||
"sha256:5591c4164755778e29e69b86e425880f852464a21c7bb53c7ea453bbe2633bbe",
|
||||
"sha256:59daa84aef650b11bccd18f99f64bfe44b9f14a08a28259959d33676554065a1",
|
||||
"sha256:5a9c8d11aa2c8f8b6043d845927a51eb9102eb558e3f936df494e96393f5fd3e",
|
||||
"sha256:5dd20538a60c4cc9a077d3b715bb42307239fcd25ef1ca7286775f95e9e9a46d",
|
||||
"sha256:74f48ec98430e06c1fa8949b49ebdd8d27ceb9df8d3d1c92e1fdc2773f003f20",
|
||||
"sha256:786aad2aa20de3dbff21aab86b2fb6a7be68064cbbc0219bde414d3a30aa47ae",
|
||||
"sha256:7ad7906e098ccd30d8f7068030a0b16668ab8aa5cda6fcd5146d8d20cbaa71b5",
|
||||
"sha256:80a38b188d20c0524fe8959c8ce770a8fdf0e617c6912d23fc97c68301bb9aba",
|
||||
"sha256:8f0ec6b9b3832e0bd1d57af41f9238ea7709bbd7271f639024f2fc9d3bb01293",
|
||||
"sha256:92282c83547a9add85ad658143c76a64a8d339028926d7dc1998ca029c88ea6a",
|
||||
"sha256:94150231f1e90c9595ccc80d7d2006c61f90a5995db82bccbca7944fd457f0f6",
|
||||
"sha256:9dc9006dcc47e00a8a6a029eb035c8f696ad38e40a27d073a003d7d1443f5d88",
|
||||
"sha256:a76979f728dd845655026ab991df25d26379a1a8fc1e9e68e25c7eda43004bed",
|
||||
"sha256:aa8eba3db3d8761db161003e2d0586608092e217151d7458206e243be5a43843",
|
||||
"sha256:bea760a63ce9bba566c23f726d72b3c0250e2fa2569909e2d83cda1534c79443",
|
||||
"sha256:c3f511a3c58676147c277eff0224c061dd5a6a8e1373572ac817ac6324f1b1e0",
|
||||
"sha256:c9d317efde4bafbc1561509bfa8a23c5cab66c44d49ab5b63ff690f5159b2304",
|
||||
"sha256:cc411ad324a4486b142c41d9b2b6a722c534096963688d879ea6fa8a35028258",
|
||||
"sha256:cdc13a1682b2a6241080745b1953719e7fe0850b40a5c71ca574f090a1391df6",
|
||||
"sha256:cfd7c5dd3c35c19cec59c63df9571c67c6d6e5c92e0fe63517920e97f61106d1",
|
||||
"sha256:e1cacf4796b20865789083252186ce9dc6cc59eca0c2e79cca332bdff24ac481",
|
||||
"sha256:e70d4e467e243455492f5de463b72151cc400710ac03a0678206a5f27e79ddef",
|
||||
"sha256:ecc930ae559ea8a43377e8b60ca6f8d61ac532fc57efb915d899de4a67928efd",
|
||||
"sha256:f161af26f596131b63b236372e4ce40f3167c1b5b5d459b29d2514bd8c9dc9ee"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==4.5.1"
|
||||
"version": "==4.5.2"
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
@ -767,11 +772,11 @@
|
||||
},
|
||||
"sentry-sdk": {
|
||||
"hashes": [
|
||||
"sha256:da06bc3641e81ec2c942f87a0676cd9180044fa3d1697524a0005345997542e2",
|
||||
"sha256:e80d61af85d99a1222c1a3e2a24023618374cd50a99673aa7fa3cf920e7d813b"
|
||||
"sha256:2de15b13836fa3522815a933bd9c887c77f4868071043349f94f1b896c1bcfb8",
|
||||
"sha256:38bb09d0277117f76507c8728d9a5156f09a47ac5175bb8072513859d19a593b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.16.0"
|
||||
"version": "==0.16.2"
|
||||
},
|
||||
"service-identity": {
|
||||
"hashes": [
|
||||
@ -831,12 +836,12 @@
|
||||
"secure"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527",
|
||||
"sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"
|
||||
"sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
|
||||
"sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": null,
|
||||
"version": "==1.25.9"
|
||||
"version": "==1.25.10"
|
||||
},
|
||||
"vine": {
|
||||
"hashes": [
|
||||
@ -1072,10 +1077,10 @@
|
||||
},
|
||||
"gitpython": {
|
||||
"hashes": [
|
||||
"sha256:e107af4d873daed64648b4f4beb89f89f0cfbe3ef558fc7821ed2331c2f8da1a",
|
||||
"sha256:ef1d60b01b5ce0040ad3ec20bc64f783362d41fa0822a2742d3586e1f49bb8ac"
|
||||
"sha256:2db287d71a284e22e5c2846042d0602465c7434d910406990d5b74df4afb0858",
|
||||
"sha256:fa3b92da728a457dd75d62bb5f3eb2816d99a7fe6c67398e260637a40e3fafb5"
|
||||
],
|
||||
"version": "==3.1.3"
|
||||
"version": "==3.1.7"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
@ -1162,11 +1167,11 @@
|
||||
},
|
||||
"pylint-django": {
|
||||
"hashes": [
|
||||
"sha256:06a64331c498a3f049ba669dc0c174b92209e164198d43e589b1096ee616d5f8",
|
||||
"sha256:3d3436ba8d0fae576ae2db160e33a8f2746a101fda4463f2b3ff3a8b6fccec38"
|
||||
"sha256:20e4d5f3987e96d29ce51ef24f13187f0d23f37a0558b6eed9b5571487ba3f4c",
|
||||
"sha256:d47f278f2ef9244decc006a7412d0ea6bebe1594e6b5402703febbac036ba401"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.0.15"
|
||||
"version": "==2.2.0"
|
||||
},
|
||||
"pylint-plugin-utils": {
|
||||
"hashes": [
|
||||
@ -1208,29 +1213,29 @@
|
||||
},
|
||||
"regex": {
|
||||
"hashes": [
|
||||
"sha256:08997a37b221a3e27d68ffb601e45abfb0093d39ee770e4257bd2f5115e8cb0a",
|
||||
"sha256:112e34adf95e45158c597feea65d06a8124898bdeac975c9087fe71b572bd938",
|
||||
"sha256:1700419d8a18c26ff396b3b06ace315b5f2a6e780dad387e4c48717a12a22c29",
|
||||
"sha256:2f6f211633ee8d3f7706953e9d3edc7ce63a1d6aad0be5dcee1ece127eea13ae",
|
||||
"sha256:52e1b4bef02f4040b2fd547357a170fc1146e60ab310cdbdd098db86e929b387",
|
||||
"sha256:55b4c25cbb3b29f8d5e63aeed27b49fa0f8476b0d4e1b3171d85db891938cc3a",
|
||||
"sha256:5aaa5928b039ae440d775acea11d01e42ff26e1561c0ffcd3d805750973c6baf",
|
||||
"sha256:654cb773b2792e50151f0e22be0f2b6e1c3a04c5328ff1d9d59c0398d37ef610",
|
||||
"sha256:690f858d9a94d903cf5cada62ce069b5d93b313d7d05456dbcd99420856562d9",
|
||||
"sha256:6ad8663c17db4c5ef438141f99e291c4d4edfeaacc0ce28b5bba2b0bf273d9b5",
|
||||
"sha256:89cda1a5d3e33ec9e231ece7307afc101b5217523d55ef4dc7fb2abd6de71ba3",
|
||||
"sha256:92d8a043a4241a710c1cf7593f5577fbb832cf6c3a00ff3fc1ff2052aff5dd89",
|
||||
"sha256:95fa7726d073c87141f7bbfb04c284901f8328e2d430eeb71b8ffdd5742a5ded",
|
||||
"sha256:97712e0d0af05febd8ab63d2ef0ab2d0cd9deddf4476f7aa153f76feef4b2754",
|
||||
"sha256:b2ba0f78b3ef375114856cbdaa30559914d081c416b431f2437f83ce4f8b7f2f",
|
||||
"sha256:bae83f2a56ab30d5353b47f9b2a33e4aac4de9401fb582b55c42b132a8ac3868",
|
||||
"sha256:c78e66a922de1c95a208e4ec02e2e5cf0bb83a36ceececc10a72841e53fbf2bd",
|
||||
"sha256:cf59bbf282b627130f5ba68b7fa3abdb96372b24b66bdf72a4920e8153fc7910",
|
||||
"sha256:e3cdc9423808f7e1bb9c2e0bdb1c9dc37b0607b30d646ff6faf0d4e41ee8fee3",
|
||||
"sha256:e9b64e609d37438f7d6e68c2546d2cb8062f3adb27e6336bc129b51be20773ac",
|
||||
"sha256:fbff901c54c22425a5b809b914a3bfaf4b9570eee0e5ce8186ac71eb2025191c"
|
||||
"sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204",
|
||||
"sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162",
|
||||
"sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f",
|
||||
"sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb",
|
||||
"sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6",
|
||||
"sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7",
|
||||
"sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88",
|
||||
"sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99",
|
||||
"sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644",
|
||||
"sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a",
|
||||
"sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840",
|
||||
"sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067",
|
||||
"sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd",
|
||||
"sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4",
|
||||
"sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e",
|
||||
"sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89",
|
||||
"sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e",
|
||||
"sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc",
|
||||
"sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf",
|
||||
"sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341",
|
||||
"sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"
|
||||
],
|
||||
"version": "==2020.6.8"
|
||||
"version": "==2020.7.14"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
@ -1270,10 +1275,10 @@
|
||||
},
|
||||
"stevedore": {
|
||||
"hashes": [
|
||||
"sha256:609912b87df5ad338ff8e44d13eaad4f4170a65b79ae9cb0aa5632598994a1b7",
|
||||
"sha256:c4724f8d7b8f6be42130663855d01a9c2414d6046055b5a65ab58a0e38637688"
|
||||
"sha256:38791aa5bed922b0a844513c5f9ed37774b68edc609e5ab8ab8d8fe0ce4315e5",
|
||||
"sha256:c8f4f0ebbc394e52ddf49de8bcc3cf8ad2b4425ebac494106bbc5e3661ac7633"
|
||||
],
|
||||
"version": "==2.0.1"
|
||||
"version": "==3.2.0"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
@ -1321,12 +1326,12 @@
|
||||
"secure"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527",
|
||||
"sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"
|
||||
"sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
|
||||
"sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": null,
|
||||
"version": "==1.25.9"
|
||||
"version": "==1.25.10"
|
||||
},
|
||||
"websocket-client": {
|
||||
"hashes": [
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<img src="passbook/static/static/passbook/logo.svg" height="50" alt="passbook logo"><img src="passbook/static/static/passbook/brand_inverted.svg" height="50" alt="passbook">
|
||||
|
||||
=======
|
||||

|
||||
[](https://dev.azure.com/beryjuorg/passbook/_build?definitionId=1)
|
||||

|
||||
[](https://codecov.io/gh/BeryJu/passbook)
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## What is passbook?
|
||||
|
||||
@ -21,7 +21,7 @@ wget https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml
|
||||
# Optionally enable Error-reporting
|
||||
# export PASSBOOK_ERROR_REPORTING=true
|
||||
# Optionally deploy a different version
|
||||
# export PASSBOOK_TAG=0.8.15-beta
|
||||
# export PASSBOOK_TAG=0.9.0-rc2
|
||||
# If this is a productive installation, set a different PostgreSQL Password
|
||||
# export PG_PASS=$(pwgen 40 1)
|
||||
docker-compose pull
|
||||
|
||||
@ -106,7 +106,7 @@ stages:
|
||||
- task: DockerCompose@0
|
||||
displayName: Run services
|
||||
inputs:
|
||||
dockerComposeFile: 'scripts/docker-compose.yml'
|
||||
dockerComposeFile: 'scripts/ci.docker-compose.yml'
|
||||
action: 'Run services'
|
||||
buildImages: false
|
||||
- task: CmdLine@2
|
||||
@ -117,7 +117,7 @@ stages:
|
||||
- task: CmdLine@2
|
||||
inputs:
|
||||
script: pipenv run ./manage.py migrate
|
||||
- job: coverage
|
||||
- job: coverage_unittest
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
steps:
|
||||
@ -127,7 +127,38 @@ stages:
|
||||
- task: DockerCompose@0
|
||||
displayName: Run services
|
||||
inputs:
|
||||
dockerComposeFile: 'scripts/docker-compose.yml'
|
||||
dockerComposeFile: 'scripts/ci.docker-compose.yml'
|
||||
action: 'Run services'
|
||||
buildImages: false
|
||||
- task: CmdLine@2
|
||||
inputs:
|
||||
script: |
|
||||
sudo pip install -U wheel pipenv
|
||||
pipenv install --dev
|
||||
- task: CmdLine@2
|
||||
displayName: Run full test suite
|
||||
inputs:
|
||||
script: |
|
||||
pipenv run coverage run ./manage.py test passbook
|
||||
mkdir output-unittest
|
||||
mv unittest.xml output-unittest/unittest.xml
|
||||
mv .coverage output-unittest/coverage
|
||||
- task: PublishPipelineArtifact@1
|
||||
inputs:
|
||||
targetPath: 'output-unittest/'
|
||||
artifact: 'coverage-unittest'
|
||||
publishLocation: 'pipeline'
|
||||
- job: coverage_e2e
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
steps:
|
||||
- task: UsePythonVersion@0
|
||||
inputs:
|
||||
versionSpec: '3.8'
|
||||
- task: DockerCompose@0
|
||||
displayName: Run services
|
||||
inputs:
|
||||
dockerComposeFile: 'scripts/ci.docker-compose.yml'
|
||||
action: 'Run services'
|
||||
buildImages: false
|
||||
- task: CmdLine@2
|
||||
@ -138,7 +169,7 @@ stages:
|
||||
- task: DockerCompose@0
|
||||
displayName: Run ChromeDriver
|
||||
inputs:
|
||||
dockerComposeFile: 'e2e/docker-compose.yml'
|
||||
dockerComposeFile: 'e2e/ci.docker-compose.yml'
|
||||
action: 'Run a specific service'
|
||||
serviceName: 'chrome'
|
||||
- task: CmdLine@2
|
||||
@ -150,28 +181,68 @@ stages:
|
||||
- task: CmdLine@2
|
||||
displayName: Run full test suite
|
||||
inputs:
|
||||
script: pipenv run coverage run ./manage.py test --failfast
|
||||
- task: PublishBuildArtifacts@1
|
||||
script: pipenv run coverage run ./manage.py test e2e
|
||||
- task: CmdLine@2
|
||||
displayName: Prepare unittests and coverage for upload
|
||||
inputs:
|
||||
script: |
|
||||
mkdir output-e2e
|
||||
mv unittest.xml output-e2e/unittest.xml
|
||||
mv .coverage output-e2e/coverage
|
||||
- task: PublishPipelineArtifact@1
|
||||
condition: failed()
|
||||
displayName: Upload screenshots if selenium tests fail
|
||||
inputs:
|
||||
PathtoPublish: 'selenium_screenshots/'
|
||||
ArtifactName: 'drop'
|
||||
publishLocation: 'Container'
|
||||
targetPath: 'selenium_screenshots/'
|
||||
artifact: 'selenium screenshots'
|
||||
publishLocation: 'pipeline'
|
||||
- task: PublishPipelineArtifact@1
|
||||
inputs:
|
||||
targetPath: 'output-e2e/'
|
||||
artifact: 'coverage-e2e'
|
||||
publishLocation: 'pipeline'
|
||||
- stage: test_combine
|
||||
jobs:
|
||||
- job: test_coverage_combine
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
steps:
|
||||
- task: DownloadPipelineArtifact@2
|
||||
inputs:
|
||||
buildType: 'current'
|
||||
artifactName: 'coverage-e2e'
|
||||
path: "coverage-e2e/"
|
||||
- task: DownloadPipelineArtifact@2
|
||||
inputs:
|
||||
buildType: 'current'
|
||||
artifactName: 'coverage-unittest'
|
||||
path: "coverage-unittest/"
|
||||
- task: UsePythonVersion@0
|
||||
inputs:
|
||||
versionSpec: '3.8'
|
||||
- task: CmdLine@2
|
||||
inputs:
|
||||
script: |
|
||||
sudo pip install -U wheel pipenv
|
||||
pipenv install --dev
|
||||
find .
|
||||
pipenv run coverage combine coverage-e2e/coverage coverage-unittest/coverage
|
||||
pipenv run coverage xml
|
||||
pipenv run coverage html
|
||||
find .
|
||||
- task: PublishCodeCoverageResults@1
|
||||
inputs:
|
||||
codeCoverageTool: Cobertura
|
||||
codeCoverageTool: 'Cobertura'
|
||||
summaryFileLocation: 'coverage.xml'
|
||||
pathToSources: '$(System.DefaultWorkingDirectory)'
|
||||
- task: PublishTestResults@2
|
||||
condition: succeededOrFailed()
|
||||
inputs:
|
||||
testRunTitle: 'Publish test results for Python $(python.version)'
|
||||
testResultsFiles: 'unittest.xml'
|
||||
testResultsFormat: 'JUnit'
|
||||
testResultsFiles: |
|
||||
coverage-e2e/unittest.xml
|
||||
coverage-unittest/unittest.xml
|
||||
mergeTestResults: true
|
||||
- task: CmdLine@2
|
||||
env:
|
||||
CODECOV_TOKEN: $(CODECOV_TOKEN)
|
||||
@ -209,7 +280,7 @@ stages:
|
||||
- task: DockerCompose@0
|
||||
displayName: Run services
|
||||
inputs:
|
||||
dockerComposeFile: 'scripts/docker-compose.yml'
|
||||
dockerComposeFile: 'scripts/ci.docker-compose.yml'
|
||||
action: 'Run services'
|
||||
buildImages: false
|
||||
- task: Docker@2
|
||||
|
||||
@ -62,7 +62,7 @@ services:
|
||||
networks:
|
||||
- internal
|
||||
labels:
|
||||
- traefik.frontend.rule=PathPrefix:/static, /robots.txt
|
||||
- traefik.frontend.rule=PathPrefix:/static, /robots.txt, /favicon.ico
|
||||
- traefik.port=80
|
||||
- traefik.docker.network=internal
|
||||
traefik:
|
||||
|
||||
@ -53,3 +53,14 @@ Example:
|
||||
```python
|
||||
other_user = pb_user_by(username="other_user")
|
||||
```
|
||||
|
||||
## Comparing IP Addresses
|
||||
|
||||
To compare IP Addresses or check if an IP Address is within a given subnet, you can use the functions `ip_address('192.0.2.1')` and `ip_network('192.0.2.0/24')`. With these objects you can do [arithmetic operations](https://docs.python.org/3/library/ipaddress.html#operators).
|
||||
|
||||
You can also check if an IP Address is within a subnet by writing the following:
|
||||
|
||||
```python
|
||||
ip_address('192.0.2.1') in ip_network('192.0.2.0/24')
|
||||
# evaluates to True
|
||||
```
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 253 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 338 KiB |
@ -16,7 +16,7 @@ wget https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml
|
||||
# Optionally enable Error-reporting
|
||||
# export PASSBOOK_ERROR_REPORTING=true
|
||||
# Optionally deploy a different version
|
||||
# export PASSBOOK_TAG=0.8.15-beta
|
||||
# export PASSBOOK_TAG=0.9.0-rc2
|
||||
# If this is a productive installation, set a different PostgreSQL Password
|
||||
# export PG_PASS=$(pwgen 40 1)
|
||||
docker-compose pull
|
||||
|
||||
@ -27,6 +27,7 @@ config:
|
||||
enabled: false
|
||||
server_url: ""
|
||||
secret_token: ""
|
||||
verify_server_cert: true
|
||||
|
||||
# This Helm chart ships with built-in Prometheus ServiceMonitors and Rules.
|
||||
# This requires the CoreOS Prometheus Operator.
|
||||
|
||||
@ -26,5 +26,5 @@ return False
|
||||
- `request.obj`: A Django Model instance. This is only set if the policy is ran against an object.
|
||||
- `request.context`: A dictionary with dynamic data. This depends on the origin of the execution.
|
||||
- `pb_is_sso_flow`: Boolean which is true if request was initiated by authenticating through an external provider.
|
||||
- `pb_client_ip`: Client's IP Address or '255.255.255.255' if no IP Address could be extracted.
|
||||
- `pb_client_ip`: Client's IP Address or '255.255.255.255' if no IP Address could be extracted. Can be [compared](../expressions/index.md#comparing-ip-addresses)
|
||||
- `pb_flow_plan`: Current Plan if Policy is called from the Flow Planner.
|
||||
|
||||
8
e2e/ci.docker-compose.yml
Normal file
8
e2e/ci.docker-compose.yml
Normal file
@ -0,0 +1,8 @@
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
chrome:
|
||||
image: selenium/standalone-chrome
|
||||
volumes:
|
||||
- /dev/shm:/dev/shm
|
||||
network_mode: host
|
||||
@ -6,15 +6,3 @@ services:
|
||||
volumes:
|
||||
- /dev/shm:/dev/shm
|
||||
network_mode: host
|
||||
|
||||
postgresql:
|
||||
image: postgres:11
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
POSTGRES_DB: passbook
|
||||
network_mode: host
|
||||
redis:
|
||||
image: redis
|
||||
restart: always
|
||||
network_mode: host
|
||||
|
||||
@ -1,498 +0,0 @@
|
||||
{
|
||||
"id": "7d9b2407-1520-4c04-b040-68e8ada9aecc",
|
||||
"version": "2.0",
|
||||
"name": "passbook",
|
||||
"url": "http://localhost:8000",
|
||||
"tests": [{
|
||||
"id": "94b39863-74ec-4b7d-98c5-2b380b6d2c55",
|
||||
"name": "passbook login simple",
|
||||
"commands": [{
|
||||
"id": "e60e4382-4f96-44c3-ba06-5e18609c9c2b",
|
||||
"comment": "",
|
||||
"command": "open",
|
||||
"target": "/flows/default-authentication-flow/?next=%2F",
|
||||
"targets": [],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "b2652f24-931e-45b0-b01d-2f0ac0f74db8",
|
||||
"comment": "",
|
||||
"command": "click",
|
||||
"target": "id=id_uid_field",
|
||||
"targets": [
|
||||
["id=id_uid_field", "id"],
|
||||
["name=uid_field", "name"],
|
||||
["css=#id_uid_field", "css:finder"],
|
||||
["xpath=//input[@id='id_uid_field']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
|
||||
["xpath=//div/input", "xpath:position"]
|
||||
],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "f1930f8a-984a-4076-a925-20937bb2f8d3",
|
||||
"comment": "",
|
||||
"command": "type",
|
||||
"target": "id=id_uid_field",
|
||||
"targets": [
|
||||
["id=id_uid_field", "id"],
|
||||
["name=uid_field", "name"],
|
||||
["css=#id_uid_field", "css:finder"],
|
||||
["xpath=//input[@id='id_uid_field']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
|
||||
["xpath=//div/input", "xpath:position"]
|
||||
],
|
||||
"value": "admin@example.tld"
|
||||
}, {
|
||||
"id": "0b568ee3-1bed-4821-a3bc-f6b960dbed9d",
|
||||
"comment": "",
|
||||
"command": "sendKeys",
|
||||
"target": "id=id_uid_field",
|
||||
"targets": [
|
||||
["id=id_uid_field", "id"],
|
||||
["name=uid_field", "name"],
|
||||
["css=#id_uid_field", "css:finder"],
|
||||
["xpath=//input[@id='id_uid_field']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
|
||||
["xpath=//div/input", "xpath:position"]
|
||||
],
|
||||
"value": "${KEY_ENTER}"
|
||||
}, {
|
||||
"id": "6d98e479-2825-484d-996a-ccf350d2761f",
|
||||
"comment": "",
|
||||
"command": "type",
|
||||
"target": "id=id_password",
|
||||
"targets": [
|
||||
["id=id_password", "id"],
|
||||
["name=password", "name"],
|
||||
["css=#id_password", "css:finder"],
|
||||
["xpath=//input[@id='id_password']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"],
|
||||
["xpath=//div[2]/input", "xpath:position"]
|
||||
],
|
||||
"value": "pbadmin"
|
||||
}, {
|
||||
"id": "6f7abec6-ff44-4eb5-ae23-520c1c29a706",
|
||||
"comment": "",
|
||||
"command": "sendKeys",
|
||||
"target": "id=id_password",
|
||||
"targets": [
|
||||
["id=id_password", "id"],
|
||||
["name=password", "name"],
|
||||
["css=#id_password", "css:finder"],
|
||||
["xpath=//input[@id='id_password']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"],
|
||||
["xpath=//div[2]/input", "xpath:position"]
|
||||
],
|
||||
"value": "${KEY_ENTER}"
|
||||
}, {
|
||||
"id": "04c5876f-1405-4077-a98b-e911f09113d7",
|
||||
"comment": "",
|
||||
"command": "assertText",
|
||||
"target": "xpath=//a[contains(@href, '/-/user/')]",
|
||||
"targets": [
|
||||
["linkText=pbadmin", "linkText"],
|
||||
["css=.pf-c-page__header-tools-group:nth-child(2) > .pf-c-button", "css:finder"],
|
||||
["xpath=//a[contains(text(),'pbadmin')]", "xpath:link"],
|
||||
["xpath=//div[@id='page-default-nav-example']/header/div[3]/div[2]/a", "xpath:idRelative"],
|
||||
["xpath=//a[contains(@href, '/-/user/')]", "xpath:href"],
|
||||
["xpath=//div[2]/a", "xpath:position"],
|
||||
["xpath=//a[contains(.,'pbadmin')]", "xpath:innerText"]
|
||||
],
|
||||
"value": "pbadmin"
|
||||
}]
|
||||
}, {
|
||||
"id": "61948b3c-3012-4f97-aa52-bc8f34fec333",
|
||||
"name": "passbook enroll simple",
|
||||
"commands": [{
|
||||
"id": "0f4884b3-4891-41bc-956d-1fa433e892e9",
|
||||
"comment": "",
|
||||
"command": "open",
|
||||
"target": "/flows/default-authentication-flow/?next=%2F",
|
||||
"targets": [],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "84d3861f-a60c-4650-8689-535f82b39577",
|
||||
"comment": "",
|
||||
"command": "click",
|
||||
"target": "linkText=Sign up.",
|
||||
"targets": [
|
||||
["linkText=Sign up.", "linkText"],
|
||||
["css=.pf-c-login__main-footer-band-item > a", "css:finder"],
|
||||
["xpath=//a[contains(text(),'Sign up.')]", "xpath:link"],
|
||||
["xpath=//main[@id='flow-body']/footer/div/p/a", "xpath:idRelative"],
|
||||
["xpath=//a[contains(@href, '/flows/default-enrollment-flow/')]", "xpath:href"],
|
||||
["xpath=//a", "xpath:position"],
|
||||
["xpath=//a[contains(.,'Sign up.')]", "xpath:innerText"]
|
||||
],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "a32435ca-d84a-41e7-a915-fcbbc5f88341",
|
||||
"comment": "",
|
||||
"command": "type",
|
||||
"target": "id=id_username",
|
||||
"targets": [
|
||||
["id=id_username", "id"],
|
||||
["name=username", "name"],
|
||||
["css=#id_username", "css:finder"],
|
||||
["xpath=//input[@id='id_username']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
|
||||
["xpath=//div/input", "xpath:position"]
|
||||
],
|
||||
"value": "foo"
|
||||
}, {
|
||||
"id": "3b5dcf53-8297-46c5-88b7-11c2eb25f34f",
|
||||
"comment": "",
|
||||
"command": "type",
|
||||
"target": "id=id_password",
|
||||
"targets": [
|
||||
["id=id_password", "id"],
|
||||
["name=password", "name"],
|
||||
["css=#id_password", "css:finder"],
|
||||
["xpath=//input[@id='id_password']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"],
|
||||
["xpath=//div[2]/input", "xpath:position"]
|
||||
],
|
||||
"value": "pbadmin"
|
||||
}, {
|
||||
"id": "e948d61c-dae6-4994-b56f-ff130892b342",
|
||||
"comment": "",
|
||||
"command": "type",
|
||||
"target": "id=id_password_repeat",
|
||||
"targets": [
|
||||
["id=id_password_repeat", "id"],
|
||||
["name=password_repeat", "name"],
|
||||
["css=#id_password_repeat", "css:finder"],
|
||||
["xpath=//input[@id='id_password_repeat']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div[3]/input", "xpath:idRelative"],
|
||||
["xpath=//div[3]/input", "xpath:position"]
|
||||
],
|
||||
"value": "pbadmin"
|
||||
}, {
|
||||
"id": "e7527bfc-ec74-4d96-86f0-5a3a55a59025",
|
||||
"comment": "",
|
||||
"command": "click",
|
||||
"target": "css=.pf-c-button",
|
||||
"targets": [
|
||||
["css=.pf-c-button", "css:finder"],
|
||||
["xpath=//button[@type='submit']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div[4]/button", "xpath:idRelative"],
|
||||
["xpath=//button", "xpath:position"],
|
||||
["xpath=//button[contains(.,'Continue')]", "xpath:innerText"]
|
||||
],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "434b842c-a659-4ff5-aca8-06a6a3489597",
|
||||
"comment": "",
|
||||
"command": "type",
|
||||
"target": "id=id_name",
|
||||
"targets": [
|
||||
["id=id_name", "id"],
|
||||
["name=name", "name"],
|
||||
["css=#id_name", "css:finder"],
|
||||
["xpath=//input[@id='id_name']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
|
||||
["xpath=//div/input", "xpath:position"]
|
||||
],
|
||||
"value": "some name"
|
||||
}, {
|
||||
"id": "cbc43a1b-2cfe-46e2-85bc-476fb32c6cb1",
|
||||
"comment": "",
|
||||
"command": "type",
|
||||
"target": "id=id_email",
|
||||
"targets": [
|
||||
["id=id_email", "id"],
|
||||
["name=email", "name"],
|
||||
["css=#id_email", "css:finder"],
|
||||
["xpath=//input[@id='id_email']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"],
|
||||
["xpath=//div[2]/input", "xpath:position"]
|
||||
],
|
||||
"value": "foo@bar.baz"
|
||||
}, {
|
||||
"id": "e74389a0-228b-4312-9677-e9add6358de3",
|
||||
"comment": "",
|
||||
"command": "click",
|
||||
"target": "css=.pf-c-button",
|
||||
"targets": [
|
||||
["css=.pf-c-button", "css:finder"],
|
||||
["xpath=//button[@type='submit']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div[3]/button", "xpath:idRelative"],
|
||||
["xpath=//button", "xpath:position"],
|
||||
["xpath=//button[contains(.,'Continue')]", "xpath:innerText"]
|
||||
],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "3e22f9c2-5ebd-49c2-81b1-340fa0435bbc",
|
||||
"comment": "",
|
||||
"command": "click",
|
||||
"target": "linkText=foo",
|
||||
"targets": [
|
||||
["linkText=foo", "linkText"],
|
||||
["css=.pf-c-page__header-tools-group:nth-child(2) > .pf-c-button", "css:finder"],
|
||||
["xpath=//a[contains(text(),'foo')]", "xpath:link"],
|
||||
["xpath=//div[@id='page-default-nav-example']/header/div[3]/div[2]/a", "xpath:idRelative"],
|
||||
["xpath=//a[contains(@href, '/-/user/')]", "xpath:href"],
|
||||
["xpath=//div[2]/a", "xpath:position"],
|
||||
["xpath=//a[contains(.,'foo')]", "xpath:innerText"]
|
||||
],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "60124cfd-f11c-4d7f-8b01-bef54c8cbd73",
|
||||
"comment": "",
|
||||
"command": "assertText",
|
||||
"target": "xpath=//a[contains(@href, '/-/user/')]",
|
||||
"targets": [
|
||||
["linkText=foo", "linkText"],
|
||||
["css=.pf-c-page__header-tools-group:nth-child(2) > .pf-c-button", "css:finder"],
|
||||
["xpath=//a[contains(text(),'foo')]", "xpath:link"],
|
||||
["xpath=//div[@id='page-default-nav-example']/header/div[3]/div[2]/a", "xpath:idRelative"],
|
||||
["xpath=//a[contains(@href, '/-/user/')]", "xpath:href"],
|
||||
["xpath=//div[2]/a", "xpath:position"],
|
||||
["xpath=//a[contains(.,'foo')]", "xpath:innerText"]
|
||||
],
|
||||
"value": "foo"
|
||||
}, {
|
||||
"id": "429ee61b-9991-4919-8131-55f8e1bd9a0d",
|
||||
"comment": "",
|
||||
"command": "assertValue",
|
||||
"target": "id=id_username",
|
||||
"targets": [],
|
||||
"value": "foo"
|
||||
}, {
|
||||
"id": "f6c50760-52ed-4c1d-b232-30f8afe144eb",
|
||||
"comment": "",
|
||||
"command": "assertText",
|
||||
"target": "id=id_name",
|
||||
"targets": [
|
||||
["id=id_name", "id"],
|
||||
["name=name", "name"],
|
||||
["css=#id_name", "css:finder"],
|
||||
["xpath=//input[@id='id_name']", "xpath:attributes"],
|
||||
["xpath=//main[@id='main-content']/section/div/div/div/div[2]/form/div[2]/div/input", "xpath:idRelative"],
|
||||
["xpath=//div[2]/div/input", "xpath:position"]
|
||||
],
|
||||
"value": "some name"
|
||||
}, {
|
||||
"id": "b26905b5-89b5-4b41-abf5-a9f848f08622",
|
||||
"comment": "",
|
||||
"command": "assertText",
|
||||
"target": "id=id_email",
|
||||
"targets": [
|
||||
["id=id_email", "id"],
|
||||
["name=email", "name"],
|
||||
["css=#id_email", "css:finder"],
|
||||
["xpath=//input[@id='id_email']", "xpath:attributes"],
|
||||
["xpath=//main[@id='main-content']/section/div/div/div/div[2]/form/div[3]/div/input", "xpath:idRelative"],
|
||||
["xpath=//div[3]/div/input", "xpath:position"]
|
||||
],
|
||||
"value": "foo@bar.baz"
|
||||
}]
|
||||
}, {
|
||||
"id": "1a3172e0-ac23-4781-9367-19afccee4f4a",
|
||||
"name": "flows stage setup password",
|
||||
"commands": [{
|
||||
"id": "77784f77-d840-4b3d-a42f-7928f02fb7e1",
|
||||
"comment": "",
|
||||
"command": "open",
|
||||
"target": "/flows/default-authentication-flow/?next=%2F",
|
||||
"targets": [],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "783aa9a6-81e5-49c6-8789-2f360a5750b1",
|
||||
"comment": "",
|
||||
"command": "setWindowSize",
|
||||
"target": "1699x1417",
|
||||
"targets": [],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "cb0cd63e-30e9-4443-af59-5345fe26dc88",
|
||||
"comment": "",
|
||||
"command": "click",
|
||||
"target": "id=id_uid_field",
|
||||
"targets": [
|
||||
["id=id_uid_field", "id"],
|
||||
["name=uid_field", "name"],
|
||||
["css=#id_uid_field", "css:finder"],
|
||||
["xpath=//input[@id='id_uid_field']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
|
||||
["xpath=//div/input", "xpath:position"]
|
||||
],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "8466ded1-c5f6-451c-b63f-0889da38503a",
|
||||
"comment": "",
|
||||
"command": "type",
|
||||
"target": "id=id_uid_field",
|
||||
"targets": [
|
||||
["id=id_uid_field", "id"],
|
||||
["name=uid_field", "name"],
|
||||
["css=#id_uid_field", "css:finder"],
|
||||
["xpath=//input[@id='id_uid_field']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
|
||||
["xpath=//div/input", "xpath:position"]
|
||||
],
|
||||
"value": "pbadmin"
|
||||
}, {
|
||||
"id": "27383093-d01a-4416-8fc6-9caad4926cd3",
|
||||
"comment": "",
|
||||
"command": "sendKeys",
|
||||
"target": "id=id_uid_field",
|
||||
"targets": [
|
||||
["id=id_uid_field", "id"],
|
||||
["name=uid_field", "name"],
|
||||
["css=#id_uid_field", "css:finder"],
|
||||
["xpath=//input[@id='id_uid_field']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
|
||||
["xpath=//div/input", "xpath:position"]
|
||||
],
|
||||
"value": "${KEY_ENTER}"
|
||||
}, {
|
||||
"id": "4602745a-0ebb-4425-a841-a1ed4899659d",
|
||||
"comment": "",
|
||||
"command": "type",
|
||||
"target": "id=id_password",
|
||||
"targets": [
|
||||
["id=id_password", "id"],
|
||||
["name=password", "name"],
|
||||
["css=#id_password", "css:finder"],
|
||||
["xpath=//input[@id='id_password']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"],
|
||||
["xpath=//div[2]/input", "xpath:position"]
|
||||
],
|
||||
"value": "pbadmin"
|
||||
}, {
|
||||
"id": "d1ff4f81-d8f9-45dc-ad5d-f99b54c0cd18",
|
||||
"comment": "",
|
||||
"command": "sendKeys",
|
||||
"target": "id=id_password",
|
||||
"targets": [
|
||||
["id=id_password", "id"],
|
||||
["name=password", "name"],
|
||||
["css=#id_password", "css:finder"],
|
||||
["xpath=//input[@id='id_password']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"],
|
||||
["xpath=//div[2]/input", "xpath:position"]
|
||||
],
|
||||
"value": "${KEY_ENTER}"
|
||||
}, {
|
||||
"id": "014c8f57-7ef2-469c-b700-efa94ba81b66",
|
||||
"comment": "",
|
||||
"command": "click",
|
||||
"target": "css=.pf-c-page__header",
|
||||
"targets": [
|
||||
["css=.pf-c-page__header", "css:finder"],
|
||||
["xpath=//div[@id='page-default-nav-example']/header", "xpath:idRelative"],
|
||||
["xpath=//header", "xpath:position"]
|
||||
],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "14e86b6f-6add-4bcc-913a-42b1e7322c79",
|
||||
"comment": "",
|
||||
"command": "click",
|
||||
"target": "linkText=pbadmin",
|
||||
"targets": [
|
||||
["linkText=pbadmin", "linkText"],
|
||||
["css=.pf-c-page__header-tools-group:nth-child(2) > .pf-c-button", "css:finder"],
|
||||
["xpath=//a[contains(text(),'pbadmin')]", "xpath:link"],
|
||||
["xpath=//div[@id='page-default-nav-example']/header/div[3]/div[2]/a", "xpath:idRelative"],
|
||||
["xpath=//a[contains(@href, '/-/user/')]", "xpath:href"],
|
||||
["xpath=//div[2]/a", "xpath:position"],
|
||||
["xpath=//a[contains(.,'pbadmin')]", "xpath:innerText"]
|
||||
],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "8280da13-632e-4cba-9e18-ecae0d57d052",
|
||||
"comment": "",
|
||||
"command": "click",
|
||||
"target": "linkText=Change password",
|
||||
"targets": [
|
||||
["linkText=Change password", "linkText"],
|
||||
["css=.pf-c-nav__section:nth-child(2) .pf-c-nav__link", "css:finder"],
|
||||
["xpath=//a[contains(text(),'Change password')]", "xpath:link"],
|
||||
["xpath=//nav[@id='page-default-nav-example-primary-nav']/section[2]/ul/li/a", "xpath:idRelative"],
|
||||
["xpath=//a[contains(@href, '/-/user/stage/password/b929b529-e384-4409-8d40-ac4a195fcab2/change/?next=%2F-%2Fuser%2F')]", "xpath:href"],
|
||||
["xpath=//section[2]/ul/li/a", "xpath:position"],
|
||||
["xpath=//a[contains(.,'Change password')]", "xpath:innerText"]
|
||||
],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "716d7e0c-79dc-469b-a31f-dceaa0765e9c",
|
||||
"comment": "",
|
||||
"command": "click",
|
||||
"target": "id=id_password",
|
||||
"targets": [
|
||||
["id=id_password", "id"],
|
||||
["name=password", "name"],
|
||||
["css=#id_password", "css:finder"],
|
||||
["xpath=//input[@id='id_password']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
|
||||
["xpath=//div/input", "xpath:position"]
|
||||
],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "77005d70-adf0-4add-8329-b092d43f829a",
|
||||
"comment": "",
|
||||
"command": "type",
|
||||
"target": "id=id_password",
|
||||
"targets": [
|
||||
["id=id_password", "id"],
|
||||
["name=password", "name"],
|
||||
["css=#id_password", "css:finder"],
|
||||
["xpath=//input[@id='id_password']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
|
||||
["xpath=//div/input", "xpath:position"]
|
||||
],
|
||||
"value": "test"
|
||||
}, {
|
||||
"id": "965ca365-99f4-45d1-97c3-c944269341b9",
|
||||
"comment": "",
|
||||
"command": "click",
|
||||
"target": "id=id_password_repeat",
|
||||
"targets": [
|
||||
["id=id_password_repeat", "id"],
|
||||
["name=password_repeat", "name"],
|
||||
["css=#id_password_repeat", "css:finder"],
|
||||
["xpath=//input[@id='id_password_repeat']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"],
|
||||
["xpath=//div[2]/input", "xpath:position"]
|
||||
],
|
||||
"value": ""
|
||||
}, {
|
||||
"id": "9b421468-c65e-4943-b6b1-1e80410a6b87",
|
||||
"comment": "",
|
||||
"command": "type",
|
||||
"target": "id=id_password_repeat",
|
||||
"targets": [
|
||||
["id=id_password_repeat", "id"],
|
||||
["name=password_repeat", "name"],
|
||||
["css=#id_password_repeat", "css:finder"],
|
||||
["xpath=//input[@id='id_password_repeat']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"],
|
||||
["xpath=//div[2]/input", "xpath:position"]
|
||||
],
|
||||
"value": "test"
|
||||
}, {
|
||||
"id": "572c1400-a0f2-499f-808a-18c1f56bf13f",
|
||||
"comment": "",
|
||||
"command": "click",
|
||||
"target": "css=.pf-c-button",
|
||||
"targets": [
|
||||
["css=.pf-c-button", "css:finder"],
|
||||
["xpath=//button[@type='submit']", "xpath:attributes"],
|
||||
["xpath=//main[@id='flow-body']/div/form/div[3]/button", "xpath:idRelative"],
|
||||
["xpath=//button", "xpath:position"],
|
||||
["xpath=//button[contains(.,'Continue')]", "xpath:innerText"]
|
||||
],
|
||||
"value": ""
|
||||
}]
|
||||
}],
|
||||
"suites": [{
|
||||
"id": "495657fb-3f5e-4431-877c-4d0b248c0841",
|
||||
"name": "Default Suite",
|
||||
"persistSession": false,
|
||||
"parallel": false,
|
||||
"timeout": 300,
|
||||
"tests": ["94b39863-74ec-4b7d-98c5-2b380b6d2c55"]
|
||||
}],
|
||||
"urls": ["http://localhost:8000/"],
|
||||
"plugins": []
|
||||
}
|
||||
@ -23,8 +23,8 @@ class TestFlowsEnroll(SeleniumTestCase):
|
||||
"""Test Enroll flow"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.container = self.setup_client()
|
||||
super().setUp()
|
||||
|
||||
def setup_client(self) -> Container:
|
||||
"""Setup test IdP container"""
|
||||
|
||||
@ -8,6 +8,8 @@ from selenium.webdriver.common.keys import Keys
|
||||
|
||||
from e2e.utils import USER, SeleniumTestCase
|
||||
from passbook.core.models import User
|
||||
from passbook.flows.models import Flow, FlowDesignation
|
||||
from passbook.stages.password.models import PasswordStage
|
||||
|
||||
|
||||
class TestFlowsStageSetup(SeleniumTestCase):
|
||||
@ -15,6 +17,16 @@ class TestFlowsStageSetup(SeleniumTestCase):
|
||||
|
||||
def test_password_change(self):
|
||||
"""test password change flow"""
|
||||
# Ensure that password stage has change_flow set
|
||||
flow = Flow.objects.get(
|
||||
slug="default-password-change", designation=FlowDesignation.STAGE_SETUP,
|
||||
)
|
||||
|
||||
stages = PasswordStage.objects.filter(name="default-authentication-password")
|
||||
stage = stages.first()
|
||||
stage.change_flow = flow
|
||||
stage.save()
|
||||
|
||||
new_password = "".join(
|
||||
SystemRandom().choice(string.ascii_uppercase + string.digits)
|
||||
for _ in range(8)
|
||||
@ -29,6 +41,7 @@ class TestFlowsStageSetup(SeleniumTestCase):
|
||||
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
|
||||
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-page__header").click()
|
||||
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").click()
|
||||
self.wait_for_url(self.url("passbook_core:user-settings"))
|
||||
self.driver.find_element(By.LINK_TEXT, "Change password").click()
|
||||
self.driver.find_element(By.ID, "id_password").send_keys(new_password)
|
||||
self.driver.find_element(By.ID, "id_password_repeat").click()
|
||||
|
||||
@ -20,16 +20,16 @@ class TestProviderOAuth(SeleniumTestCase):
|
||||
"""test OAuth Provider flow"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client_id = generate_client_id()
|
||||
self.client_secret = generate_client_secret()
|
||||
self.container = self.setup_client()
|
||||
super().setUp()
|
||||
|
||||
def setup_client(self) -> Container:
|
||||
"""Setup client grafana container which we test OAuth against"""
|
||||
client: DockerClient = from_env()
|
||||
container = client.containers.run(
|
||||
image="grafana/grafana:latest",
|
||||
image="grafana/grafana:7.1.0",
|
||||
detach=True,
|
||||
network_mode="host",
|
||||
auto_remove=True,
|
||||
@ -103,23 +103,20 @@ class TestProviderOAuth(SeleniumTestCase):
|
||||
USER().username,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(
|
||||
By.XPATH,
|
||||
"/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[1]/div/input",
|
||||
).get_attribute("value"),
|
||||
self.driver.find_element(By.CSS_SELECTOR, "input[name=name]").get_attribute(
|
||||
"value"
|
||||
),
|
||||
USER().username,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(
|
||||
By.XPATH,
|
||||
"/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[2]/div/input",
|
||||
By.CSS_SELECTOR, "input[name=email]"
|
||||
).get_attribute("value"),
|
||||
USER().email,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(
|
||||
By.XPATH,
|
||||
"/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[3]/div/input",
|
||||
By.CSS_SELECTOR, "input[name=login]"
|
||||
).get_attribute("value"),
|
||||
USER().username,
|
||||
)
|
||||
@ -165,6 +162,7 @@ class TestProviderOAuth(SeleniumTestCase):
|
||||
By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/ul/li[1]"
|
||||
).text,
|
||||
)
|
||||
sleep(1)
|
||||
self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click()
|
||||
|
||||
self.wait_for_url("http://localhost:3000/?orgId=1")
|
||||
@ -174,23 +172,20 @@ class TestProviderOAuth(SeleniumTestCase):
|
||||
USER().username,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(
|
||||
By.XPATH,
|
||||
"/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[1]/div/input",
|
||||
).get_attribute("value"),
|
||||
self.driver.find_element(By.CSS_SELECTOR, "input[name=name]").get_attribute(
|
||||
"value"
|
||||
),
|
||||
USER().username,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(
|
||||
By.XPATH,
|
||||
"/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[2]/div/input",
|
||||
By.CSS_SELECTOR, "input[name=email]"
|
||||
).get_attribute("value"),
|
||||
USER().email,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(
|
||||
By.XPATH,
|
||||
"/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[3]/div/input",
|
||||
By.CSS_SELECTOR, "input[name=login]"
|
||||
).get_attribute("value"),
|
||||
USER().username,
|
||||
)
|
||||
@ -230,6 +225,6 @@ class TestProviderOAuth(SeleniumTestCase):
|
||||
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
|
||||
self.wait_for_url(self.url("passbook_flows:denied"))
|
||||
self.assertEqual(
|
||||
self.driver.find_element(By.CSS_SELECTOR, "#flow-body > header > h1").text,
|
||||
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
|
||||
"Permission denied",
|
||||
)
|
||||
|
||||
@ -23,16 +23,16 @@ class TestProviderOIDC(SeleniumTestCase):
|
||||
"""test OpenID Provider flow"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client_id = generate_client_id()
|
||||
self.client_secret = generate_client_secret()
|
||||
self.container = self.setup_client()
|
||||
super().setUp()
|
||||
|
||||
def setup_client(self) -> Container:
|
||||
"""Setup client grafana container which we test OIDC against"""
|
||||
client: DockerClient = from_env()
|
||||
container = client.containers.run(
|
||||
image="grafana/grafana:latest",
|
||||
image="grafana/grafana:7.1.0",
|
||||
detach=True,
|
||||
network_mode="host",
|
||||
auto_remove=True,
|
||||
@ -153,23 +153,20 @@ class TestProviderOIDC(SeleniumTestCase):
|
||||
USER().name,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(
|
||||
By.XPATH,
|
||||
"/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[1]/div/input",
|
||||
).get_attribute("value"),
|
||||
self.driver.find_element(By.CSS_SELECTOR, "input[name=name]").get_attribute(
|
||||
"value"
|
||||
),
|
||||
USER().name,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(
|
||||
By.XPATH,
|
||||
"/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[2]/div/input",
|
||||
By.CSS_SELECTOR, "input[name=email]"
|
||||
).get_attribute("value"),
|
||||
USER().email,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(
|
||||
By.XPATH,
|
||||
"/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[3]/div/input",
|
||||
By.CSS_SELECTOR, "input[name=login]"
|
||||
).get_attribute("value"),
|
||||
USER().email,
|
||||
)
|
||||
@ -221,6 +218,7 @@ class TestProviderOIDC(SeleniumTestCase):
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located((By.CSS_SELECTOR, "[type=submit]"))
|
||||
)
|
||||
sleep(1)
|
||||
self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click()
|
||||
|
||||
self.wait.until(
|
||||
@ -234,23 +232,20 @@ class TestProviderOIDC(SeleniumTestCase):
|
||||
USER().name,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(
|
||||
By.XPATH,
|
||||
"/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[1]/div/input",
|
||||
).get_attribute("value"),
|
||||
self.driver.find_element(By.CSS_SELECTOR, "input[name=name]").get_attribute(
|
||||
"value"
|
||||
),
|
||||
USER().name,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(
|
||||
By.XPATH,
|
||||
"/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[2]/div/input",
|
||||
By.CSS_SELECTOR, "input[name=email]"
|
||||
).get_attribute("value"),
|
||||
USER().email,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(
|
||||
By.XPATH,
|
||||
"/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[3]/div/input",
|
||||
By.CSS_SELECTOR, "input[name=login]"
|
||||
).get_attribute("value"),
|
||||
USER().email,
|
||||
)
|
||||
@ -298,6 +293,6 @@ class TestProviderOIDC(SeleniumTestCase):
|
||||
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
|
||||
self.wait_for_url(self.url("passbook_flows:denied"))
|
||||
self.assertEqual(
|
||||
self.driver.find_element(By.CSS_SELECTOR, "#flow-body > header > h1").text,
|
||||
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
|
||||
"Permission denied",
|
||||
)
|
||||
|
||||
@ -11,7 +11,6 @@ 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.lib.utils.reflection import class_to_path
|
||||
from passbook.policies.expression.models import ExpressionPolicy
|
||||
from passbook.policies.models import PolicyBinding
|
||||
from passbook.providers.saml.models import (
|
||||
@ -19,7 +18,6 @@ from passbook.providers.saml.models import (
|
||||
SAMLPropertyMapping,
|
||||
SAMLProvider,
|
||||
)
|
||||
from passbook.providers.saml.processors.generic import GenericProcessor
|
||||
|
||||
|
||||
class TestProviderSAML(SeleniumTestCase):
|
||||
@ -70,7 +68,6 @@ class TestProviderSAML(SeleniumTestCase):
|
||||
)
|
||||
provider: SAMLProvider = SAMLProvider.objects.create(
|
||||
name="saml-test",
|
||||
processor_path=class_to_path(GenericProcessor),
|
||||
acs_url="http://localhost:9009/saml/acs",
|
||||
audience="passbook-e2e",
|
||||
issuer="passbook-e2e",
|
||||
@ -104,7 +101,6 @@ class TestProviderSAML(SeleniumTestCase):
|
||||
)
|
||||
provider: SAMLProvider = SAMLProvider.objects.create(
|
||||
name="saml-test",
|
||||
processor_path=class_to_path(GenericProcessor),
|
||||
acs_url="http://localhost:9009/saml/acs",
|
||||
audience="passbook-e2e",
|
||||
issuer="passbook-e2e",
|
||||
@ -146,7 +142,6 @@ class TestProviderSAML(SeleniumTestCase):
|
||||
)
|
||||
provider: SAMLProvider = SAMLProvider.objects.create(
|
||||
name="saml-test",
|
||||
processor_path=class_to_path(GenericProcessor),
|
||||
acs_url="http://localhost:9009/saml/acs",
|
||||
audience="passbook-e2e",
|
||||
issuer="passbook-e2e",
|
||||
@ -188,7 +183,6 @@ class TestProviderSAML(SeleniumTestCase):
|
||||
)
|
||||
provider: SAMLProvider = SAMLProvider.objects.create(
|
||||
name="saml-test",
|
||||
processor_path=class_to_path(GenericProcessor),
|
||||
acs_url="http://localhost:9009/saml/acs",
|
||||
audience="passbook-e2e",
|
||||
issuer="passbook-e2e",
|
||||
@ -211,6 +205,6 @@ class TestProviderSAML(SeleniumTestCase):
|
||||
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
|
||||
self.wait_for_url(self.url("passbook_flows:denied"))
|
||||
self.assertEqual(
|
||||
self.driver.find_element(By.CSS_SELECTOR, "#flow-body > header > h1").text,
|
||||
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
|
||||
"Permission denied",
|
||||
)
|
||||
|
||||
@ -35,13 +35,42 @@ NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG
|
||||
aQ==
|
||||
-----END CERTIFICATE-----"""
|
||||
|
||||
IDP_KEY = """-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDNQIWjOA1vWHUz
|
||||
SPM1FIKOE4GdH65VtWlpZ9dghH4CFYN0R7mvJj4KBq86Dxt8vJvLMV16GVh0NGCR
|
||||
50QH8aMbxonDTqXSoXiMM4DDSQTKBYK7aZwftc7FG35gAfdNUdr8e7VbdaPOShuq
|
||||
qotDyCQpZYzbt86ABnoaJ5okE3pUFIwxw97LcdYsGZz5Ngma/V1to7aMeEqHyl8r
|
||||
DRbXZUzw/U8g7yC/g+G7+64liJ4FYqLEETLLSUePKLFgUJHXbF2HgIDjur3nxlEa
|
||||
ecNQYVUTVCGBFpwkI5n1t3m32avwotpUFhMImjkRETyPKZpvl0+p7mop8mwJmKpa
|
||||
CVuNSj23AgMBAAECggEABn4I/B20xxXcNzASiVZJvua9DdRHtmxTlkLznBj0x2oY
|
||||
y1/Nbs3d3oFRn5uEuhBZOTcphsgwdRSHDXZsP3gUObew+d2N/zieUIj8hLDVlvJP
|
||||
rU/s4U/l53Q0LiNByE9ThvL+zJLPCKJtd5uHZjB5fFm69+Q7gu8xg4xHIub+0pP5
|
||||
PHanmHCDrbgNN/oqlar4FZ2MXTgekW6Amyc/koE9hIn4Baa2Ke/B/AUGY4pMRLqp
|
||||
TArt+GTVeWeoFY9QACUpaHpJhGb/Piou6tlU57e42cLoki1f0+SARsBBKyXA7BB1
|
||||
1fMH10KQYFA68dTYWlKzQau/K4xaqg4FKmtwF66GQQKBgQD9OpNUS7oRxMHVJaBR
|
||||
TNWW+V1FXycqojekFpDijPb2X5CWV16oeWgaXp0nOHFdy9EWs3GtGpfZasaRVHsX
|
||||
SHtPh4Nb8JqHdGE0/CD6t0+4Dns8Bn9cSqtdQB7R3Jn7IMXi9X/U8LDKo+A18/Jq
|
||||
V8VgUngMny9YjMkQIbK8TRWkYQKBgQDPf4nxO6ju+tOHHORQty3bYDD0+OV3I0+L
|
||||
0yz0uPreryBVi9nY43KakH52D7UZEwwsBjjGXD+WH8xEsmBWsGNXJu025PvzIJoz
|
||||
lAEiXvMp/NmYp+tY4rDmO8RhyVocBqWHzh38m0IFOd4ByFD5nLEDrA3pDVo0aNgY
|
||||
n0GwRysZFwKBgQDkCj3m6ZMUsUWEty+aR0EJhmKyODBDOnY09IVhH2S/FexVFzUN
|
||||
LtfK9206hp/Awez3Ln2uT4Zzqq5K7fMzUniJdBWdVB004l8voeXpIe9OZuwfcBJ9
|
||||
gFi1zypx/uFDv421BzQpBN+QfOdKbvbdQVFjnqCxbSDr80yVlGMrI5fbwQKBgG09
|
||||
oRrepO7EIO8GN/GCruLK/ptKGkyhy3Q6xnVEmdb47hX7ncJA5IoZPmrblCVSUNsw
|
||||
n11XHabksL8OBgg9rt8oQEThQv/aDzTOW9aDlJNragejiBTwq99aYeZ1gjo1CZq4
|
||||
2jKubpCfyZC4rGDtrIfZYi1q+S2UcQhtd8DdhwQbAoGAAM4EpDA4yHB5yiek1p/o
|
||||
CbqRCta/Dx6Eyo0KlNAyPuFPAshupG4NBx7mT2ASfL+2VBHoi6mHSri+BDX5ryYF
|
||||
fMYvp7URYoq7w7qivRlvvEg5yoYrK13F2+Gj6xJ4jEN9m0KdM/g3mJGq0HBTIQrp
|
||||
Sm75WXsflOxuTn08LbgGc4s=
|
||||
-----END PRIVATE KEY-----"""
|
||||
|
||||
|
||||
class TestSourceSAML(SeleniumTestCase):
|
||||
"""test SAML Source flow"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.container = self.setup_client()
|
||||
super().setUp()
|
||||
|
||||
def setup_client(self) -> Container:
|
||||
"""Setup test IdP container"""
|
||||
@ -81,7 +110,7 @@ class TestSourceSAML(SeleniumTestCase):
|
||||
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
||||
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
||||
keypair = CertificateKeyPair.objects.create(
|
||||
name="test-idp-cert", certificate_data=IDP_CERT
|
||||
name="test-idp-cert", certificate_data=IDP_CERT, key_data=IDP_KEY,
|
||||
)
|
||||
|
||||
SAMLSource.objects.create(
|
||||
@ -125,3 +154,109 @@ class TestSourceSAML(SeleniumTestCase):
|
||||
self.assertNotEqual(
|
||||
self.driver.find_element(By.ID, "id_username").get_attribute("value"), ""
|
||||
)
|
||||
|
||||
def test_idp_post(self):
|
||||
"""test SAML Source With post binding"""
|
||||
sleep(1)
|
||||
# Bootstrap all needed objects
|
||||
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
||||
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
||||
keypair = CertificateKeyPair.objects.create(
|
||||
name="test-idp-cert", certificate_data=IDP_CERT, key_data=IDP_KEY,
|
||||
)
|
||||
|
||||
SAMLSource.objects.create(
|
||||
name="saml-idp-test",
|
||||
slug="saml-idp-test",
|
||||
authentication_flow=authentication_flow,
|
||||
enrollment_flow=enrollment_flow,
|
||||
issuer="entity-id",
|
||||
sso_url="http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
|
||||
binding_type=SAMLBindingTypes.POST,
|
||||
signing_kp=keypair,
|
||||
)
|
||||
|
||||
self.driver.get(self.live_server_url)
|
||||
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
|
||||
)
|
||||
)
|
||||
self.driver.find_element(
|
||||
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
||||
).click()
|
||||
sleep(1)
|
||||
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
|
||||
|
||||
# Now we should be at the IDP, wait for the username field
|
||||
self.wait.until(ec.presence_of_element_located((By.ID, "username")))
|
||||
self.driver.find_element(By.ID, "username").send_keys("user1")
|
||||
self.driver.find_element(By.ID, "password").send_keys("user1pass")
|
||||
self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER)
|
||||
|
||||
# Wait until we're logged in
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.XPATH, "//a[contains(@href, '/-/user/')]")
|
||||
)
|
||||
)
|
||||
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").click()
|
||||
|
||||
# Wait until we've loaded the user info page
|
||||
self.wait.until(ec.presence_of_element_located((By.ID, "id_username")))
|
||||
self.assertNotEqual(
|
||||
self.driver.find_element(By.ID, "id_username").get_attribute("value"), ""
|
||||
)
|
||||
|
||||
def test_idp_post_auto(self):
|
||||
"""test SAML Source With post binding (auto redirect)"""
|
||||
sleep(1)
|
||||
# Bootstrap all needed objects
|
||||
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
||||
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
||||
keypair = CertificateKeyPair.objects.create(
|
||||
name="test-idp-cert", certificate_data=IDP_CERT, key_data=IDP_KEY,
|
||||
)
|
||||
|
||||
SAMLSource.objects.create(
|
||||
name="saml-idp-test",
|
||||
slug="saml-idp-test",
|
||||
authentication_flow=authentication_flow,
|
||||
enrollment_flow=enrollment_flow,
|
||||
issuer="entity-id",
|
||||
sso_url="http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
|
||||
binding_type=SAMLBindingTypes.POST_AUTO,
|
||||
signing_kp=keypair,
|
||||
)
|
||||
|
||||
self.driver.get(self.live_server_url)
|
||||
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
|
||||
)
|
||||
)
|
||||
self.driver.find_element(
|
||||
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
||||
).click()
|
||||
|
||||
# Now we should be at the IDP, wait for the username field
|
||||
self.wait.until(ec.presence_of_element_located((By.ID, "username")))
|
||||
self.driver.find_element(By.ID, "username").send_keys("user1")
|
||||
self.driver.find_element(By.ID, "password").send_keys("user1pass")
|
||||
self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER)
|
||||
|
||||
# Wait until we're logged in
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.XPATH, "//a[contains(@href, '/-/user/')]")
|
||||
)
|
||||
)
|
||||
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").click()
|
||||
|
||||
# Wait until we've loaded the user info page
|
||||
self.wait.until(ec.presence_of_element_located((By.ID, "id_username")))
|
||||
self.assertNotEqual(
|
||||
self.driver.find_element(By.ID, "id_username").get_attribute("value"), ""
|
||||
)
|
||||
|
||||
212
e2e/test_sources_oauth.py
Normal file
212
e2e/test_sources_oauth.py
Normal file
@ -0,0 +1,212 @@
|
||||
"""test OAuth Source"""
|
||||
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
|
||||
from yaml import safe_dump
|
||||
|
||||
from docker import DockerClient, from_env
|
||||
from docker.models.containers import Container
|
||||
from docker.types import Healthcheck
|
||||
from e2e.utils import SeleniumTestCase
|
||||
from passbook.flows.models import Flow
|
||||
from passbook.sources.oauth.models import OAuthSource
|
||||
|
||||
TOKEN_URL = "http://127.0.0.1:5556/dex/token"
|
||||
CONFIG_PATH = "/tmp/dex.yml"
|
||||
|
||||
|
||||
class TestSourceOAuth(SeleniumTestCase):
|
||||
"""test OAuth Source flow"""
|
||||
|
||||
container: Container
|
||||
|
||||
def setUp(self):
|
||||
self.client_secret = generate_client_secret()
|
||||
self.container = self.setup_client()
|
||||
super().setUp()
|
||||
|
||||
def prepare_dex_config(self):
|
||||
"""Since Dex does not document which environment
|
||||
variables can be used to configure clients"""
|
||||
config = {
|
||||
"enablePasswordDB": True,
|
||||
"issuer": "http://127.0.0.1:5556/dex",
|
||||
"logger": {"level": "debug"},
|
||||
"staticClients": [
|
||||
{
|
||||
"id": "example-app",
|
||||
"name": "Example App",
|
||||
"redirectURIs": [
|
||||
self.url(
|
||||
"passbook_sources_oauth:oauth-client-callback",
|
||||
source_slug="dex",
|
||||
)
|
||||
],
|
||||
"secret": self.client_secret,
|
||||
}
|
||||
],
|
||||
"staticPasswords": [
|
||||
{
|
||||
"email": "admin@example.com",
|
||||
# hash for password
|
||||
"hash": "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W",
|
||||
"userID": "08a8684b-db88-4b73-90a9-3cd1661f5466",
|
||||
"username": "admin",
|
||||
}
|
||||
],
|
||||
"storage": {"config": {"file": "/tmp/dex.db"}, "type": "sqlite3"},
|
||||
"web": {"http": "0.0.0.0:5556"},
|
||||
}
|
||||
with open(CONFIG_PATH, "w+") as _file:
|
||||
safe_dump(config, _file)
|
||||
|
||||
def setup_client(self) -> Container:
|
||||
"""Setup test Dex container"""
|
||||
self.prepare_dex_config()
|
||||
client: DockerClient = from_env()
|
||||
container = client.containers.run(
|
||||
image="quay.io/dexidp/dex:v2.24.0",
|
||||
detach=True,
|
||||
network_mode="host",
|
||||
auto_remove=True,
|
||||
command="serve /config.yml",
|
||||
healthcheck=Healthcheck(
|
||||
test=["CMD", "wget", "--spider", "http://localhost:5556/dex/healthz"],
|
||||
interval=5 * 100 * 1000000,
|
||||
start_period=1 * 100 * 1000000,
|
||||
),
|
||||
volumes={abspath(CONFIG_PATH): {"bind": "/config.yml", "mode": "ro",}},
|
||||
)
|
||||
while True:
|
||||
container.reload()
|
||||
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
|
||||
if status == "healthy":
|
||||
return container
|
||||
sleep(1)
|
||||
|
||||
def create_objects(self):
|
||||
"""Create required objects"""
|
||||
sleep(1)
|
||||
# Bootstrap all needed objects
|
||||
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
||||
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
||||
|
||||
OAuthSource.objects.create(
|
||||
name="dex",
|
||||
slug="dex",
|
||||
authentication_flow=authentication_flow,
|
||||
enrollment_flow=enrollment_flow,
|
||||
provider_type="openid-connect",
|
||||
authorization_url="http://127.0.0.1:5556/dex/auth",
|
||||
access_token_url=TOKEN_URL,
|
||||
profile_url="http://127.0.0.1:5556/dex/userinfo",
|
||||
consumer_key="example-app",
|
||||
consumer_secret=self.client_secret,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.container.kill()
|
||||
super().tearDown()
|
||||
|
||||
def test_oauth_enroll(self):
|
||||
"""test OAuth Source With With OIDC"""
|
||||
self.create_objects()
|
||||
self.driver.get(self.live_server_url)
|
||||
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
|
||||
)
|
||||
)
|
||||
self.driver.find_element(
|
||||
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
||||
).click()
|
||||
|
||||
# Now we should be at the IDP, wait for the login field
|
||||
self.wait.until(ec.presence_of_element_located((By.ID, "login")))
|
||||
self.driver.find_element(By.ID, "login").send_keys("admin@example.com")
|
||||
self.driver.find_element(By.ID, "password").send_keys("password")
|
||||
self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER)
|
||||
|
||||
# Wait until we're logged in
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located((By.CSS_SELECTOR, "button[type=submit]"))
|
||||
)
|
||||
self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
|
||||
|
||||
# At this point we've been redirected back
|
||||
# and we're asked for the username
|
||||
self.driver.find_element(By.NAME, "username").click()
|
||||
self.driver.find_element(By.NAME, "username").send_keys("foo")
|
||||
self.driver.find_element(By.NAME, "username").send_keys(Keys.ENTER)
|
||||
|
||||
# Wait until we've loaded the user info page
|
||||
self.wait.until(ec.presence_of_element_located((By.LINK_TEXT, "foo")))
|
||||
self.driver.find_element(By.LINK_TEXT, "foo").click()
|
||||
|
||||
self.wait_for_url(self.url("passbook_core:user-settings"))
|
||||
self.assertEqual(
|
||||
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text,
|
||||
"foo",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(By.ID, "id_username").get_attribute("value"), "foo"
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(By.ID, "id_name").get_attribute("value"), "admin",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(By.ID, "id_email").get_attribute("value"),
|
||||
"admin@example.com",
|
||||
)
|
||||
|
||||
def test_oauth_enroll_auth(self):
|
||||
"""test OAuth Source With With OIDC (enroll and authenticate again)"""
|
||||
self.test_oauth_enroll()
|
||||
# We're logged in at the end of this, log out and re-login
|
||||
self.driver.find_element(By.CSS_SELECTOR, "[aria-label=logout]").click()
|
||||
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
|
||||
)
|
||||
)
|
||||
self.driver.find_element(
|
||||
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
||||
).click()
|
||||
|
||||
# Now we should be at the IDP, wait for the login field
|
||||
self.wait.until(ec.presence_of_element_located((By.ID, "login")))
|
||||
self.driver.find_element(By.ID, "login").send_keys("admin@example.com")
|
||||
self.driver.find_element(By.ID, "password").send_keys("password")
|
||||
self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER)
|
||||
|
||||
# Wait until we're logged in
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located((By.CSS_SELECTOR, "button[type=submit]"))
|
||||
)
|
||||
self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
|
||||
|
||||
# Wait until we've loaded the user info page
|
||||
self.wait.until(ec.presence_of_element_located((By.LINK_TEXT, "foo")))
|
||||
self.driver.find_element(By.LINK_TEXT, "foo").click()
|
||||
|
||||
self.wait_for_url(self.url("passbook_core:user-settings"))
|
||||
self.assertEqual(
|
||||
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text,
|
||||
"foo",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(By.ID, "id_username").get_attribute("value"), "foo"
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(By.ID, "id_name").get_attribute("value"), "admin",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(By.ID, "id_email").get_attribute("value"),
|
||||
"admin@example.com",
|
||||
)
|
||||
15
e2e/utils.py
15
e2e/utils.py
@ -3,7 +3,7 @@ from functools import lru_cache
|
||||
from glob import glob
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from inspect import getmembers, isfunction
|
||||
from os import makedirs
|
||||
from os import environ, makedirs
|
||||
from time import time
|
||||
|
||||
from Cryptodome.PublicKey import RSA
|
||||
@ -46,8 +46,8 @@ class SeleniumTestCase(StaticLiveServerTestCase):
|
||||
makedirs("selenium_screenshots/", exist_ok=True)
|
||||
self.driver = self._get_driver()
|
||||
self.driver.maximize_window()
|
||||
self.driver.implicitly_wait(300)
|
||||
self.wait = WebDriverWait(self.driver, 500)
|
||||
self.driver.implicitly_wait(30)
|
||||
self.wait = WebDriverWait(self.driver, 50)
|
||||
self.apply_default_data()
|
||||
self.logger = get_logger()
|
||||
|
||||
@ -58,9 +58,12 @@ class SeleniumTestCase(StaticLiveServerTestCase):
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.driver.save_screenshot(
|
||||
f"selenium_screenshots/{self.__class__.__name__}_{time()}.png"
|
||||
)
|
||||
if "TF_BUILD" in environ:
|
||||
screenshot_file = (
|
||||
f"selenium_screenshots/{self.__class__.__name__}_{time()}.png"
|
||||
)
|
||||
self.driver.save_screenshot(screenshot_file)
|
||||
self.logger.warning("Saved screenshot", file=screenshot_file)
|
||||
for line in self.driver.get_log("browser"):
|
||||
self.logger.warning(
|
||||
line["message"], source=line["source"], level=line["level"]
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
FROM quay.io/oauth2-proxy/oauth2-proxy
|
||||
|
||||
COPY templates /templates
|
||||
|
||||
ENV OAUTH2_PROXY_EMAIL_DOMAINS=*
|
||||
ENV OAUTH2_PROXY_PROVIDER=oidc
|
||||
ENV OAUTH2_PROXY_CUSTOM_TEMPLATES_DIR=/templates
|
||||
ENV OAUTH2_PROXY_HTTP_ADDRESS=:4180
|
||||
# TODO: If service is access over HTTPS, this needs to be set to true (default), otherwise needs to be false
|
||||
# ENV OAUTH2_PROXY_COOKIE_SECURE=true
|
||||
ENV OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
{{define "error.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" charset="utf-8">
|
||||
|
||||
<head>
|
||||
<title>{{.Title}}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>{{.Title}}</h2>
|
||||
<p>{{.Message}}</p>
|
||||
<hr>
|
||||
<p><a href="{{.ProxyPrefix}}/sign_in">Sign In</a></p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{end}}
|
||||
@ -1,119 +0,0 @@
|
||||
{{define "sign_in.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" charset="utf-8">
|
||||
<head>
|
||||
<title>Sign In with passbook</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<style>
|
||||
body {
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.42857143;
|
||||
color: #333;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.signin {
|
||||
display: block;
|
||||
margin: 20px auto;
|
||||
max-width: 400px;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
color: #fff;
|
||||
background-color: #428bca;
|
||||
border: 1px solid #357ebd;
|
||||
-webkit-border-radius: 4;
|
||||
-moz-border-radius: 4;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
padding: 6px 12px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #3071a9;
|
||||
border-color: #285e8e;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.42857143;
|
||||
color: #555;
|
||||
background-color: #fff;
|
||||
background-image: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
|
||||
-webkit-transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s;
|
||||
-o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
|
||||
transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
footer a {
|
||||
display: inline-block;
|
||||
height: 25px;
|
||||
line-height: 25px;
|
||||
color: #aaa;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
color: #aaa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="signin center">
|
||||
<form method="GET" action="{{.ProxyPrefix}}/start">
|
||||
<input type="hidden" name="rd" value="{{.Redirect}}">
|
||||
<button type="submit" class="btn">Sign in with passbook</button><br />
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
if (window.location.hash) {
|
||||
(function () {
|
||||
var inputs = document.getElementsByName('rd');
|
||||
for (var i = 0; i < inputs.length; i++) {
|
||||
inputs[i].value += window.location.hash;
|
||||
}
|
||||
})();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{end}}
|
||||
@ -1,6 +1,6 @@
|
||||
apiVersion: v1
|
||||
appVersion: "0.9.0-pre6"
|
||||
appVersion: "0.9.0-rc2"
|
||||
description: A Helm chart for passbook.
|
||||
name: passbook
|
||||
version: "0.9.0-pre6"
|
||||
version: "0.9.0-rc2"
|
||||
icon: https://git.beryju.org/uploads/-/system/project/avatar/108/logo.png
|
||||
|
||||
@ -25,3 +25,4 @@ data:
|
||||
enabled: {{ .Values.config.apm.enabled }}
|
||||
server_url: "{{ .Values.config.apm.server_url }}"
|
||||
secret_token: "{{ .Values.config.apm.server_token }}"
|
||||
verify_server_cert: {{ .Values.config.apm.verify_server_cert }}
|
||||
|
||||
@ -41,5 +41,9 @@ spec:
|
||||
backend:
|
||||
serviceName: {{ $fullName }}-static
|
||||
servicePort: http
|
||||
- path: /favicon.ico
|
||||
backend:
|
||||
serviceName: {{ $fullName }}-static
|
||||
servicePort: http
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
# This is a YAML-formatted file.
|
||||
# Declare variables to be passed into your templates.
|
||||
image:
|
||||
tag: 0.9.0-pre6
|
||||
tag: 0.9.0-rc2
|
||||
|
||||
nameOverride: ""
|
||||
|
||||
@ -19,6 +19,7 @@ config:
|
||||
enabled: false
|
||||
server_url: ""
|
||||
secret_token: ""
|
||||
verify_server_cert: true
|
||||
|
||||
# This Helm chart ships with built-in Prometheus ServiceMonitors and Rules.
|
||||
# This requires the CoreOS Prometheus Operator.
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
"""passbook"""
|
||||
__version__ = "0.9.0-pre6"
|
||||
__version__ = "0.9.0-rc2"
|
||||
|
||||
@ -6,7 +6,7 @@ from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.http import Http404
|
||||
from django.views.generic import DeleteView, ListView, UpdateView
|
||||
|
||||
from passbook.lib.utils.reflection import all_subclasses, path_to_class
|
||||
from passbook.lib.utils.reflection import all_subclasses
|
||||
from passbook.lib.views import CreateAssignPermView
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@ class InheritanceCreateView(CreateAssignPermView):
|
||||
)
|
||||
except StopIteration as exc:
|
||||
raise Http404 from exc
|
||||
return path_to_class(model.form)
|
||||
return model.form(model)
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
@ -61,9 +61,7 @@ class InheritanceUpdateView(UpdateView):
|
||||
return kwargs
|
||||
|
||||
def get_form_class(self):
|
||||
form_class_path = self.get_object().form
|
||||
form_class = path_to_class(form_class_path)
|
||||
return form_class
|
||||
return self.get_object().form()
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
return (
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
"""passbook Event administration"""
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views.generic import ListView
|
||||
from guardian.mixins import PermissionListMixin
|
||||
|
||||
from passbook.audit.models import Event
|
||||
|
||||
|
||||
class EventListView(PermissionListMixin, ListView):
|
||||
class EventListView(PermissionListMixin, LoginRequiredMixin, ListView):
|
||||
"""Show list of all invitations"""
|
||||
|
||||
model = Event
|
||||
|
||||
20
passbook/core/migrations/0006_auto_20200709_1608.py
Normal file
20
passbook/core/migrations/0006_auto_20200709_1608.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.0.8 on 2020-07-09 16:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_core", "0005_token_intent"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="source",
|
||||
name="slug",
|
||||
field=models.SlugField(
|
||||
help_text="Internal source name, used in URLs.", unique=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,12 +1,13 @@
|
||||
"""passbook core models"""
|
||||
from datetime import timedelta
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Optional, Type
|
||||
from uuid import uuid4
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.postgres.fields import JSONField
|
||||
from django.db import models
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.forms import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -92,6 +93,10 @@ class Provider(models.Model):
|
||||
|
||||
objects = InheritanceManager()
|
||||
|
||||
def form(self) -> Type[ModelForm]:
|
||||
"""Return Form class used to edit this object"""
|
||||
raise NotImplementedError
|
||||
|
||||
# This class defines no field for easier inheritance
|
||||
def __str__(self):
|
||||
if hasattr(self, "name"):
|
||||
@ -134,7 +139,9 @@ class Source(PolicyBindingModel):
|
||||
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
|
||||
|
||||
name = models.TextField(help_text=_("Source's display Name."))
|
||||
slug = models.SlugField(help_text=_("Internal source name, used in URLs."))
|
||||
slug = models.SlugField(
|
||||
help_text=_("Internal source name, used in URLs."), unique=True
|
||||
)
|
||||
|
||||
enabled = models.BooleanField(default=True)
|
||||
property_mappings = models.ManyToManyField(
|
||||
@ -160,10 +167,12 @@ class Source(PolicyBindingModel):
|
||||
related_name="source_enrollment",
|
||||
)
|
||||
|
||||
form = "" # ModelForm-based class ued to create/edit instance
|
||||
|
||||
objects = InheritanceManager()
|
||||
|
||||
def form(self) -> Type[ModelForm]:
|
||||
"""Return Form class used to edit this object"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def ui_login_button(self) -> Optional[UILoginButton]:
|
||||
"""If source uses a http-based flow, return UI Information about the login
|
||||
@ -196,6 +205,31 @@ class UserSourceConnection(CreatedUpdatedModel):
|
||||
unique_together = (("user", "source"),)
|
||||
|
||||
|
||||
class ExpiringModel(models.Model):
|
||||
"""Base Model which can expire, and is automatically cleaned up."""
|
||||
|
||||
expires = models.DateTimeField(default=default_token_duration)
|
||||
expiring = models.BooleanField(default=True)
|
||||
|
||||
@classmethod
|
||||
def filter_not_expired(cls, **kwargs) -> QuerySet:
|
||||
"""Filer for tokens which are not expired yet or are not expiring,
|
||||
and match filters in `kwargs`"""
|
||||
query = Q(**kwargs)
|
||||
query_not_expired_yet = Q(expires__lt=now(), expiring=True)
|
||||
query_not_expiring = Q(expiring=False)
|
||||
return cls.objects.filter(query & (query_not_expired_yet | query_not_expiring))
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if token is expired yet."""
|
||||
return now() > self.expires
|
||||
|
||||
class Meta:
|
||||
|
||||
abstract = True
|
||||
|
||||
|
||||
class TokenIntents(models.TextChoices):
|
||||
"""Intents a Token can be created for."""
|
||||
|
||||
@ -206,34 +240,16 @@ class TokenIntents(models.TextChoices):
|
||||
INTENT_API = "api"
|
||||
|
||||
|
||||
class Token(models.Model):
|
||||
class Token(ExpiringModel):
|
||||
"""Token used to authenticate the User for API Access or confirm another Stage like Email."""
|
||||
|
||||
token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
intent = models.TextField(
|
||||
choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION
|
||||
)
|
||||
expires = models.DateTimeField(default=default_token_duration)
|
||||
user = models.ForeignKey("User", on_delete=models.CASCADE, related_name="+")
|
||||
expiring = models.BooleanField(default=True)
|
||||
description = models.TextField(default="", blank=True)
|
||||
|
||||
@staticmethod
|
||||
def filter_not_expired(**kwargs) -> QuerySet:
|
||||
"""Filer for tokens which are not expired yet or are not expiring,
|
||||
and match filters in `kwargs`"""
|
||||
query = Q(**kwargs)
|
||||
query_not_expired_yet = Q(expires__lt=now(), expiring=True)
|
||||
query_not_expiring = Q(expiring=False)
|
||||
return Token.objects.filter(
|
||||
query & (query_not_expired_yet | query_not_expiring)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if token is expired yet."""
|
||||
return now() > self.expires
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"Token {self.token_uuid.hex} {self.description} (expires={self.expires})"
|
||||
@ -252,9 +268,12 @@ class PropertyMapping(models.Model):
|
||||
name = models.TextField()
|
||||
expression = models.TextField()
|
||||
|
||||
form = ""
|
||||
objects = InheritanceManager()
|
||||
|
||||
def form(self) -> Type[ModelForm]:
|
||||
"""Return Form class used to edit this object"""
|
||||
raise NotImplementedError
|
||||
|
||||
def evaluate(
|
||||
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
|
||||
) -> Any:
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
"""passbook core tasks"""
|
||||
from django.utils.timezone import now
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import Token
|
||||
from passbook.core.models import ExpiringModel
|
||||
from passbook.root.celery import CELERY_APP
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def clean_tokens():
|
||||
"""Remove expired tokens"""
|
||||
amount, _ = Token.objects.filter(expires__lt=now(), expiring=True).delete()
|
||||
LOGGER.debug("Deleted expired tokens", amount=amount)
|
||||
def clean_expired_models():
|
||||
"""Remove expired objects"""
|
||||
for cls in ExpiringModel.__subclasses__():
|
||||
cls: ExpiringModel
|
||||
amount, _ = cls.filter_not_expired().delete()
|
||||
LOGGER.debug("Deleted expired models", model=cls, amount=amount)
|
||||
|
||||
@ -1,57 +1,20 @@
|
||||
{% extends 'base/skeleton.html' %}
|
||||
{% extends 'login/base_full.html' %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load passbook_utils %}
|
||||
|
||||
{% block body %}
|
||||
<div class="pf-c-background-image">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
|
||||
<filter id="image_overlay">
|
||||
<feColorMatrix type="matrix" values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"></feColorMatrix>
|
||||
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone">
|
||||
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR>
|
||||
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG>
|
||||
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB>
|
||||
<feFuncA type="table" tableValues="0 1"></feFuncA>
|
||||
</feComponentTransfer>
|
||||
</filter>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="pf-c-login">
|
||||
<div class="pf-c-login__container">
|
||||
<header class="pf-c-login__header">
|
||||
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;" alt="passbook icon" />
|
||||
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;" alt="passbook branding" />
|
||||
</header>
|
||||
<main class="pf-c-login__main" id="flow-body">
|
||||
<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
{% trans 'Bad Request' %}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
{% block card %}
|
||||
<form method="POST" class="pf-c-form">
|
||||
{% if message %}
|
||||
<h3>{% trans message %}</h3>
|
||||
{% endif %}
|
||||
{% if 'back' in request.GET %}
|
||||
<a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
<footer class="pf-c-login__footer">
|
||||
<p></p>
|
||||
<ul class="pf-c-list pf-m-inline">
|
||||
<li>
|
||||
<a href="https://passbook.beryju.org/">{% trans 'Documentation' %}</a>
|
||||
</li>
|
||||
<!-- TODO:load config.passbook.footer.links -->
|
||||
</ul>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{% block title %}
|
||||
{% trans 'Bad Request' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<form method="POST" class="pf-c-form">
|
||||
{% if message %}
|
||||
<h3>{% trans message %}</h3>
|
||||
{% endif %}
|
||||
{% if 'back' in request.GET %}
|
||||
<a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
31
passbook/core/templates/generic/autosubmit_form.html
Normal file
31
passbook/core/templates/generic/autosubmit_form.html
Normal file
@ -0,0 +1,31 @@
|
||||
{% extends "login/base.html" %}
|
||||
|
||||
{% load passbook_utils %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}
|
||||
{{ title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<form method="POST" action="{{ url }}" autosubmit>
|
||||
{% csrf_token %}
|
||||
{% for key, value in attrs.items %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endfor %}
|
||||
<div class="pf-c-form__group pf-u-display-flex pf-u-justify-content-center">
|
||||
<div class="pf-c-form__group-control">
|
||||
<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>
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<div class="pf-c-form__actions">
|
||||
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans 'Continue' %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
34
passbook/core/templates/generic/autosubmit_form_full.html
Normal file
34
passbook/core/templates/generic/autosubmit_form_full.html
Normal file
@ -0,0 +1,34 @@
|
||||
{% extends "login/base_full.html" %}
|
||||
|
||||
{% load passbook_utils %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}
|
||||
{{ title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<form method="POST" action="{{ url }}" autosubmit>
|
||||
{% csrf_token %}
|
||||
{% for key, value in attrs.items %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endfor %}
|
||||
<div class="pf-c-form__group pf-u-display-flex pf-u-justify-content-center">
|
||||
<div class="pf-c-form__group-control">
|
||||
<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>
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<div class="pf-c-form__actions">
|
||||
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans 'Continue' %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
document.querySelector("form").submit();
|
||||
</script>
|
||||
{% endblock %}
|
||||
54
passbook/core/templates/login/base_full.html
Normal file
54
passbook/core/templates/login/base_full.html
Normal file
@ -0,0 +1,54 @@
|
||||
{% extends 'base/skeleton.html' %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load passbook_utils %}
|
||||
|
||||
{% block body %}
|
||||
<div class="pf-c-background-image">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
|
||||
<filter id="image_overlay">
|
||||
<feColorMatrix type="matrix" values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"></feColorMatrix>
|
||||
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone">
|
||||
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR>
|
||||
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG>
|
||||
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB>
|
||||
<feFuncA type="table" tableValues="0 1"></feFuncA>
|
||||
</feComponentTransfer>
|
||||
</filter>
|
||||
</svg>
|
||||
</div>
|
||||
{% include 'partials/messages.html' %}
|
||||
<div class="pf-c-login">
|
||||
<div class="pf-c-login__container">
|
||||
<header class="pf-c-login__header">
|
||||
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;" alt="passbook icon" />
|
||||
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;" alt="passbook branding" />
|
||||
</header>
|
||||
{% block main_container %}
|
||||
<main class="pf-c-login__main">
|
||||
<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
{% block title %}
|
||||
{% endblock %}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
{% block card %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
<footer class="pf-c-login__footer">
|
||||
<p></p>
|
||||
<ul class="pf-c-list pf-m-inline">
|
||||
{% for link in config.passbook.footer_links %}
|
||||
<li>
|
||||
<a href="{{ link.href }}">{{ link.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,62 +1,25 @@
|
||||
{% extends 'base/skeleton.html' %}
|
||||
{% extends 'login/base_full.html' %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load passbook_utils %}
|
||||
|
||||
{% block body %}
|
||||
<div class="pf-c-background-image">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
|
||||
<filter id="image_overlay">
|
||||
<feColorMatrix type="matrix" values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"></feColorMatrix>
|
||||
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone">
|
||||
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR>
|
||||
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG>
|
||||
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB>
|
||||
<feFuncA type="table" tableValues="0 1"></feFuncA>
|
||||
</feComponentTransfer>
|
||||
</filter>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="pf-c-login">
|
||||
<div class="pf-c-login__container">
|
||||
<header class="pf-c-login__header">
|
||||
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;" alt="passbook icon" />
|
||||
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;" alt="passbook branding" />
|
||||
</header>
|
||||
<main class="pf-c-login__main" id="flow-body">
|
||||
<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
{% trans 'Permission denied' %}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
{% block card %}
|
||||
<form method="POST" class="pf-c-form">
|
||||
{% csrf_token %}
|
||||
{% include 'partials/form.html' %}
|
||||
<div class="pf-c-form__group">
|
||||
<p>
|
||||
<i class="pf-icon pf-icon-error-circle-o"></i>
|
||||
{% trans 'Access denied' %}
|
||||
</p>
|
||||
</div>
|
||||
{% if 'back' in request.GET %}
|
||||
<a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
<footer class="pf-c-login__footer">
|
||||
<p></p>
|
||||
<ul class="pf-c-list pf-m-inline">
|
||||
<li>
|
||||
<a href="https://passbook.beryju.org/">{% trans 'Documentation' %}</a>
|
||||
</li>
|
||||
<!-- TODO: load config.passbook.footer.links -->
|
||||
</ul>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{% block title %}
|
||||
{% trans 'Permission denied' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<form method="POST" class="pf-c-form">
|
||||
{% csrf_token %}
|
||||
{% include 'partials/form.html' %}
|
||||
<div class="pf-c-form__group">
|
||||
<p>
|
||||
<i class="pf-icon pf-icon-error-circle-o"></i>
|
||||
{% trans 'Access denied' %}
|
||||
</p>
|
||||
</div>
|
||||
{% if 'back' in request.GET %}
|
||||
<a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
{% if messages %}
|
||||
<ul class="pf-c-alert-group pf-m-toast">
|
||||
{% for msg in messages %}
|
||||
<li class="pf-c-alert-group__item">
|
||||
@ -21,4 +20,3 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
"""passbook user view tests"""
|
||||
"""passbook core task tests"""
|
||||
from django.test import TestCase
|
||||
from django.utils.timezone import now
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from passbook.core.models import Token
|
||||
from passbook.core.tasks import clean_tokens
|
||||
from passbook.core.tasks import clean_expired_models
|
||||
|
||||
|
||||
class TestTasks(TestCase):
|
||||
@ -14,5 +14,5 @@ class TestTasks(TestCase):
|
||||
"""Test Token cleanup task"""
|
||||
Token.objects.create(expires=now(), user=get_anonymous_user())
|
||||
self.assertEqual(Token.objects.all().count(), 1)
|
||||
clean_tokens()
|
||||
clean_expired_models()
|
||||
self.assertEqual(Token.objects.all().count(), 0)
|
||||
|
||||
@ -10,9 +10,9 @@ from passbook.stages.prompt.models import FieldTypes
|
||||
FLOW_POLICY_EXPRESSION = """# This policy ensures that this flow can only be used when the user
|
||||
# is in a SSO Flow (meaning they come from an external IdP)
|
||||
return pb_is_sso_flow"""
|
||||
PROMPT_POLICY_EXPRESSION = """# Check if we've been given a username by the external IdP
|
||||
PROMPT_POLICY_EXPRESSION = """# Check if we've not been given a username by the external IdP
|
||||
# and trigger the enrollment flow
|
||||
return 'username' in pb_flow_plan.context.get('prompt_data', {})"""
|
||||
return 'username' not in pb_flow_plan.context.get('prompt_data', {})"""
|
||||
|
||||
|
||||
def create_default_source_enrollment_flow(
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
"""Flow models"""
|
||||
from typing import Callable, Optional
|
||||
from typing import TYPE_CHECKING, Optional, Type
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from model_utils.managers import InheritanceManager
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.types import UIUserSettings
|
||||
from passbook.lib.utils.reflection import class_to_path
|
||||
from passbook.policies.models import PolicyBindingModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from passbook.flows.stage import StageView
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@ -44,8 +47,17 @@ class Stage(models.Model):
|
||||
name = models.TextField()
|
||||
|
||||
objects = InheritanceManager()
|
||||
type = ""
|
||||
form = ""
|
||||
|
||||
def type(self) -> Type["StageView"]:
|
||||
"""Return StageView class that implements logic for this stage"""
|
||||
# This is a bit of a workaround, since we can't set class methods with setattr
|
||||
if hasattr(self, "__in_memory_type"):
|
||||
return getattr(self, "__in_memory_type")
|
||||
raise NotImplementedError
|
||||
|
||||
def form(self) -> Type[ModelForm]:
|
||||
"""Return Form class used to edit this object"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def ui_user_settings(self) -> Optional[UIUserSettings]:
|
||||
@ -57,11 +69,13 @@ class Stage(models.Model):
|
||||
return f"Stage {self.name}"
|
||||
|
||||
|
||||
def in_memory_stage(_type: Callable) -> Stage:
|
||||
def in_memory_stage(view: Type["StageView"]) -> Stage:
|
||||
"""Creates an in-memory stage instance, based on a `_type` as view."""
|
||||
class_path = class_to_path(_type)
|
||||
stage = Stage()
|
||||
stage.type = class_path
|
||||
# Because we can't pickle a locally generated function,
|
||||
# we set the view as a separate property and reference a generic function
|
||||
# that returns that member
|
||||
setattr(stage, "__in_memory_type", view)
|
||||
return stage
|
||||
|
||||
|
||||
|
||||
@ -52,7 +52,8 @@ class FlowPlan:
|
||||
stage = self.stages[0]
|
||||
marker = self.markers[0]
|
||||
|
||||
LOGGER.debug("f(plan_inst): stage has marker", stage=stage, marker=marker)
|
||||
if marker.__class__ is not StageMarker:
|
||||
LOGGER.debug("f(plan_inst): stage has marker", stage=stage, marker=marker)
|
||||
marked_stage = marker.process(self, stage)
|
||||
if not marked_stage:
|
||||
LOGGER.debug("f(plan_inst): marker returned none, next stage", stage=stage)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{% extends 'base/skeleton.html' %}
|
||||
{% extends 'login/base_full.html' %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
@ -20,50 +20,16 @@
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="pf-c-background-image">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
|
||||
<filter id="image_overlay">
|
||||
<feColorMatrix type="matrix" values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"></feColorMatrix>
|
||||
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone">
|
||||
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR>
|
||||
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG>
|
||||
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB>
|
||||
<feFuncA type="table" tableValues="0 1"></feFuncA>
|
||||
</feComponentTransfer>
|
||||
</filter>
|
||||
</svg>
|
||||
</div>
|
||||
<ul class="pf-c-alert-group pf-m-toast">
|
||||
</ul>
|
||||
<div class="pf-c-login">
|
||||
<div class="pf-c-login__container">
|
||||
<header class="pf-c-login__header">
|
||||
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;"
|
||||
alt="passbook icon" />
|
||||
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;"
|
||||
alt="passbook branding" />
|
||||
</header>
|
||||
<main class="pf-c-login__main" id="flow-body">
|
||||
<div class="pf-c-login__main-body pb-loading">
|
||||
<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>
|
||||
</main>
|
||||
<footer class="pf-c-login__footer">
|
||||
<p></p>
|
||||
<ul class="pf-c-list pf-m-inline">
|
||||
<li>
|
||||
<a href="https://passbook.beryju.org/">{% trans 'Documentation' %}</a>
|
||||
</li>
|
||||
<!-- TODO:load config.passbook.footer.links -->
|
||||
</ul>
|
||||
</footer>
|
||||
{% block main_container %}
|
||||
<main class="pf-c-login__main" id="flow-body">
|
||||
<div class="pf-c-login__main-body pb-loading">
|
||||
<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>
|
||||
</main>
|
||||
<script>
|
||||
const flowBodyUrl = "{{ exec_url }}";
|
||||
const messagesUrl = "{{ msg_url }}";
|
||||
@ -171,6 +137,7 @@ const setFormSubmitHandlers = () => {
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
let formData = new FormData(form);
|
||||
showSpinner();
|
||||
fetch(flowBodyUrl, {
|
||||
method: 'post',
|
||||
body: formData,
|
||||
|
||||
@ -21,14 +21,15 @@ from passbook.core.views.utils import PermissionDeniedView
|
||||
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||
from passbook.flows.models import Flow, FlowDesignation, Stage
|
||||
from passbook.flows.planner import FlowPlan, FlowPlanner
|
||||
from passbook.lib.utils.reflection import class_to_path, path_to_class
|
||||
from passbook.lib.utils.urls import redirect_with_qs
|
||||
from passbook.lib.utils.reflection import class_to_path
|
||||
from passbook.lib.utils.urls import is_url_absolute, redirect_with_qs
|
||||
from passbook.lib.views import bad_request_message
|
||||
|
||||
LOGGER = get_logger()
|
||||
# Argument used to redirect user after login
|
||||
NEXT_ARG_NAME = "next"
|
||||
SESSION_KEY_PLAN = "passbook_flows_plan"
|
||||
SESSION_KEY_APPLICATION_PRE = "passbook_flows_application_pre"
|
||||
SESSION_KEY_GET = "passbook_flows_get"
|
||||
|
||||
|
||||
@ -49,8 +50,9 @@ class FlowExecutorView(View):
|
||||
def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
|
||||
"""When a flow is non-applicable check if user is on the correct domain"""
|
||||
if NEXT_ARG_NAME in self.request.GET:
|
||||
LOGGER.debug("f(exec): Redirecting to next on fail")
|
||||
return redirect(self.request.GET.get(NEXT_ARG_NAME))
|
||||
if not is_url_absolute(self.request.GET.get(NEXT_ARG_NAME)):
|
||||
LOGGER.debug("f(exec): Redirecting to next on fail")
|
||||
return redirect(self.request.GET.get(NEXT_ARG_NAME))
|
||||
message = exc.__doc__ if exc.__doc__ else str(exc)
|
||||
return bad_request_message(self.request, message)
|
||||
|
||||
@ -92,7 +94,7 @@ class FlowExecutorView(View):
|
||||
if not self.current_stage:
|
||||
LOGGER.debug("f(exec): no more stages, flow is done.")
|
||||
return self._flow_done()
|
||||
stage_cls = path_to_class(self.current_stage.type)
|
||||
stage_cls = self.current_stage.type()
|
||||
self.current_stage_view = stage_cls(self)
|
||||
self.current_stage_view.args = self.args
|
||||
self.current_stage_view.kwargs = self.kwargs
|
||||
@ -151,11 +153,12 @@ class FlowExecutorView(View):
|
||||
|
||||
def _flow_done(self) -> HttpResponse:
|
||||
"""User Successfully passed all stages"""
|
||||
self.cancel()
|
||||
# Since this is wrapped by the ExecutorShell, the next argument is saved in the session
|
||||
# extract the next param before cancel as that cleans it
|
||||
next_param = self.request.session.get(SESSION_KEY_GET, {}).get(
|
||||
NEXT_ARG_NAME, "passbook_core:overview"
|
||||
)
|
||||
self.cancel()
|
||||
return redirect_with_qs(next_param)
|
||||
|
||||
def stage_ok(self) -> HttpResponse:
|
||||
@ -198,8 +201,14 @@ class FlowExecutorView(View):
|
||||
|
||||
def cancel(self):
|
||||
"""Cancel current execution and return a redirect"""
|
||||
if SESSION_KEY_PLAN in self.request.session:
|
||||
del self.request.session[SESSION_KEY_PLAN]
|
||||
keys_to_delete = [
|
||||
SESSION_KEY_APPLICATION_PRE,
|
||||
SESSION_KEY_PLAN,
|
||||
SESSION_KEY_GET,
|
||||
]
|
||||
for key in keys_to_delete:
|
||||
if key in self.request.session:
|
||||
del self.request.session[key]
|
||||
|
||||
|
||||
class FlowPermissionDeniedView(PermissionDeniedView):
|
||||
|
||||
@ -3,11 +3,12 @@ import os
|
||||
from collections.abc import Mapping
|
||||
from contextlib import contextmanager
|
||||
from glob import glob
|
||||
from typing import Any
|
||||
from typing import Any, Dict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
from django.conf import ImproperlyConfigured
|
||||
from django.http import HttpRequest
|
||||
from structlog import get_logger
|
||||
|
||||
SEARCH_PATHS = ["passbook/lib/default.yml", "/etc/passbook/config.yml", ""] + glob(
|
||||
@ -18,6 +19,12 @@ ENV_PREFIX = "PASSBOOK"
|
||||
ENVIRONMENT = os.getenv(f"{ENV_PREFIX}_ENV", "local")
|
||||
|
||||
|
||||
def context_processor(request: HttpRequest) -> Dict[str, Any]:
|
||||
"""Context Processor that injects config object into every template"""
|
||||
kwargs = {"config": CONFIG.raw}
|
||||
return kwargs
|
||||
|
||||
|
||||
class ConfigLoader:
|
||||
"""Search through SEARCH_PATHS and load configuration. Environment variables starting with
|
||||
`ENV_PREFIX` are also applied.
|
||||
|
||||
@ -18,7 +18,9 @@ log_level: warning
|
||||
error_reporting: false
|
||||
|
||||
passbook:
|
||||
# Optionally add links to the footer on the login page
|
||||
footer_links:
|
||||
# Optionally add links to the footer on the login page
|
||||
- name: Documentation
|
||||
href: https://passbook.beryju.org/
|
||||
# - name: test
|
||||
# href: https://test
|
||||
|
||||
@ -64,7 +64,9 @@ class BaseEvaluator:
|
||||
def wrap_expression(self, expression: str, params: Iterable[str]) -> str:
|
||||
"""Wrap expression in a function, call it, and save the result as `result`"""
|
||||
handler_signature = ",".join(params)
|
||||
full_expression = f"def handler({handler_signature}):\n"
|
||||
full_expression = ""
|
||||
full_expression += "from ipaddress import ip_address, ip_network\n"
|
||||
full_expression += f"def handler({handler_signature}):\n"
|
||||
full_expression += indent(expression, " ")
|
||||
full_expression += f"\nresult = handler({handler_signature})"
|
||||
return full_expression
|
||||
|
||||
@ -4,6 +4,7 @@ from botocore.client import ClientError
|
||||
from django.core.exceptions import DisallowedHost, ValidationError
|
||||
from django.db import InternalError, OperationalError, ProgrammingError
|
||||
from django_redis.exceptions import ConnectionInterrupted
|
||||
from elasticapm.transport.http import TransportException
|
||||
from redis.exceptions import RedisError
|
||||
from rest_framework.exceptions import APIException
|
||||
from structlog import get_logger
|
||||
@ -33,6 +34,7 @@ def before_send(event, hint):
|
||||
OSError,
|
||||
RedisError,
|
||||
SentryIgnoredException,
|
||||
TransportException,
|
||||
)
|
||||
if "exc_info" in hint:
|
||||
_, exc_value, _ = hint["exc_info"]
|
||||
|
||||
@ -36,7 +36,7 @@ def back(context: Context) -> str:
|
||||
def fieldtype(field):
|
||||
"""Return classname"""
|
||||
if isinstance(field.__class__, Model) or issubclass(field.__class__, Model):
|
||||
return field._meta.verbose_name
|
||||
return verbose_name(field)
|
||||
return field.__class__.__name__
|
||||
|
||||
|
||||
@ -84,6 +84,9 @@ def verbose_name(obj) -> str:
|
||||
"""Return Object's Verbose Name"""
|
||||
if not obj:
|
||||
return ""
|
||||
if hasattr(obj, "verbose_name"):
|
||||
print(obj.verbose_name)
|
||||
return obj.verbose_name
|
||||
return obj._meta.verbose_name
|
||||
|
||||
|
||||
@ -92,7 +95,7 @@ def form_verbose_name(obj) -> str:
|
||||
"""Return ModelForm's Object's Verbose Name"""
|
||||
if not obj:
|
||||
return ""
|
||||
return obj._meta.model._meta.verbose_name
|
||||
return verbose_name(obj._meta.model)
|
||||
|
||||
|
||||
@register.filter
|
||||
|
||||
38
passbook/lib/utils/time.py
Normal file
38
passbook/lib/utils/time.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""Time utilities"""
|
||||
import datetime
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
ALLOWED_KEYS = (
|
||||
"days",
|
||||
"seconds",
|
||||
"microseconds",
|
||||
"milliseconds",
|
||||
"minutes",
|
||||
"hours",
|
||||
"weeks",
|
||||
)
|
||||
|
||||
|
||||
def timedelta_string_validator(value: str):
|
||||
"""Validator for Django that checks if value can be parsed with `timedelta_from_string`"""
|
||||
try:
|
||||
timedelta_from_string(value)
|
||||
except ValueError as exc:
|
||||
raise ValidationError(
|
||||
_("%(value)s is not in the correct format of 'hours=3;minutes=1'."),
|
||||
params={"value": value},
|
||||
) from exc
|
||||
|
||||
|
||||
def timedelta_from_string(expr: str) -> datetime.timedelta:
|
||||
"""Convert a string with the format of 'hours=1;minute=3;seconds=5' to a
|
||||
`datetime.timedelta` Object with hours = 1, minutes = 3, seconds = 5"""
|
||||
kwargs = {}
|
||||
for duration_pair in expr.split(";"):
|
||||
key, value = duration_pair.split("=")
|
||||
if key.lower() not in ALLOWED_KEYS:
|
||||
continue
|
||||
kwargs[key.lower()] = float(value)
|
||||
return datetime.timedelta(**kwargs)
|
||||
@ -1,8 +1,10 @@
|
||||
"""Dummy policy"""
|
||||
from random import SystemRandom
|
||||
from time import sleep
|
||||
from typing import Type
|
||||
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from structlog import get_logger
|
||||
|
||||
@ -22,7 +24,10 @@ class DummyPolicy(Policy):
|
||||
wait_min = models.IntegerField(default=5)
|
||||
wait_max = models.IntegerField(default=30)
|
||||
|
||||
form = "passbook.policies.dummy.forms.DummyPolicyForm"
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.policies.dummy.forms import DummyPolicyForm
|
||||
|
||||
return DummyPolicyForm
|
||||
|
||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||
"""Wait random time then return result"""
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
"""passbook password_expiry_policy Models"""
|
||||
from datetime import timedelta
|
||||
from typing import Type
|
||||
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
from structlog import get_logger
|
||||
@ -19,7 +21,10 @@ class PasswordExpiryPolicy(Policy):
|
||||
deny_only = models.BooleanField(default=False)
|
||||
days = models.IntegerField()
|
||||
|
||||
form = "passbook.policies.expiry.forms.PasswordExpiryPolicyForm"
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.policies.expiry.forms import PasswordExpiryPolicyForm
|
||||
|
||||
return PasswordExpiryPolicyForm
|
||||
|
||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||
"""If password change date is more than x days in the past, call set_unusable_password
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"""passbook expression policy evaluator"""
|
||||
from ipaddress import ip_address
|
||||
from typing import List
|
||||
|
||||
from django.http import HttpRequest
|
||||
@ -41,7 +42,9 @@ class PolicyEvaluator(BaseEvaluator):
|
||||
"""Update context based on http request"""
|
||||
# update passbook/policies/expression/templates/policy/expression/form.html
|
||||
# update docs/policies/expression/index.md
|
||||
self._context["pb_client_ip"] = get_client_ip(request) or "255.255.255.255"
|
||||
self._context["pb_client_ip"] = ip_address(
|
||||
get_client_ip(request) or "255.255.255.255"
|
||||
)
|
||||
self._context["request"] = request
|
||||
if SESSION_KEY_PLAN in request.session:
|
||||
self._context["pb_flow_plan"] = request.session[SESSION_KEY_PLAN]
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
"""passbook expression Policy Models"""
|
||||
from typing import Type
|
||||
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.policies.expression.evaluator import PolicyEvaluator
|
||||
@ -12,7 +15,10 @@ class ExpressionPolicy(Policy):
|
||||
|
||||
expression = models.TextField()
|
||||
|
||||
form = "passbook.policies.expression.forms.ExpressionPolicyForm"
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.policies.expression.forms import ExpressionPolicyForm
|
||||
|
||||
return ExpressionPolicyForm
|
||||
|
||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||
"""Evaluate and render expression. Returns PolicyResult(false) on error."""
|
||||
|
||||
@ -36,7 +36,7 @@ class TestEvaluator(TestCase):
|
||||
evaluator.set_policy_request(self.request)
|
||||
result = evaluator.evaluate(template)
|
||||
self.assertEqual(result.passing, False)
|
||||
self.assertEqual(result.messages, ("invalid syntax (test, line 2)",))
|
||||
self.assertEqual(result.messages, ("invalid syntax (test, line 3)",))
|
||||
|
||||
def test_undefined(self):
|
||||
"""test undefined result"""
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
"""user field matcher models"""
|
||||
from typing import Type
|
||||
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.core.models import Group
|
||||
@ -12,7 +15,10 @@ class GroupMembershipPolicy(Policy):
|
||||
|
||||
group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
|
||||
form = "passbook.policies.group_membership.forms.GroupMembershipPolicyForm"
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.policies.group_membership.forms import GroupMembershipPolicyForm
|
||||
|
||||
return GroupMembershipPolicyForm
|
||||
|
||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||
return PolicyResult(self.group.user_set.filter(pk=request.user.pk).exists())
|
||||
|
||||
@ -11,7 +11,7 @@ class HaveIBeenPwendPolicySerializer(ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = HaveIBeenPwendPolicy
|
||||
fields = GENERAL_SERIALIZER_FIELDS + ["allowed_count"]
|
||||
fields = GENERAL_SERIALIZER_FIELDS + ["password_field", "allowed_count"]
|
||||
|
||||
|
||||
class HaveIBeenPwendPolicyViewSet(ModelViewSet):
|
||||
|
||||
@ -14,9 +14,9 @@ class HaveIBeenPwnedPolicyForm(forms.ModelForm):
|
||||
class Meta:
|
||||
|
||||
model = HaveIBeenPwendPolicy
|
||||
fields = GENERAL_FIELDS + ["allowed_count"]
|
||||
fields = GENERAL_FIELDS + ["password_field", "allowed_count"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"order": forms.NumberInput(),
|
||||
"password_field": forms.TextInput(),
|
||||
"policies": FilteredSelectMultiple(_("policies"), False),
|
||||
}
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.0.8 on 2020-07-10 18:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_policies_hibp", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="haveibeenpwendpolicy",
|
||||
name="password_field",
|
||||
field=models.TextField(
|
||||
default="password",
|
||||
help_text="Field key to check, field keys defined in Prompt stages are available.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,13 +1,15 @@
|
||||
"""passbook HIBP Models"""
|
||||
from hashlib import sha1
|
||||
from typing import Type
|
||||
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext as _
|
||||
from requests import get
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.policies.models import Policy, PolicyResult
|
||||
from passbook.policies.types import PolicyRequest
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -16,20 +18,34 @@ class HaveIBeenPwendPolicy(Policy):
|
||||
"""Check if password is on HaveIBeenPwned's list by uploading the first
|
||||
5 characters of the SHA1 Hash."""
|
||||
|
||||
password_field = models.TextField(
|
||||
default="password",
|
||||
help_text=_(
|
||||
"Field key to check, field keys defined in Prompt stages are available."
|
||||
),
|
||||
)
|
||||
|
||||
allowed_count = models.IntegerField(default=0)
|
||||
|
||||
form = "passbook.policies.hibp.forms.HaveIBeenPwnedPolicyForm"
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.policies.hibp.forms import HaveIBeenPwnedPolicyForm
|
||||
|
||||
def passes(self, user: User) -> PolicyResult:
|
||||
return HaveIBeenPwnedPolicyForm
|
||||
|
||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||
"""Check if password is in HIBP DB. Hashes given Password with SHA1, uses the first 5
|
||||
characters of Password in request and checks if full hash is in response. Returns 0
|
||||
if Password is not in result otherwise the count of how many times it was used."""
|
||||
# Only check if password is being set
|
||||
if not hasattr(user, "__password__"):
|
||||
return PolicyResult(True)
|
||||
password = getattr(user, "__password__")
|
||||
if self.password_field not in request.context:
|
||||
LOGGER.warning(
|
||||
"Password field not set in Policy Request",
|
||||
field=self.password_field,
|
||||
fields=request.context.keys(),
|
||||
)
|
||||
password = request.context[self.password_field]
|
||||
|
||||
pw_hash = sha1(password.encode("utf-8")).hexdigest() # nosec
|
||||
url = "https://api.pwnedpasswords.com/range/%s" % pw_hash[:5]
|
||||
url = f"https://api.pwnedpasswords.com/range/{pw_hash[:5]}"
|
||||
result = get(url).text
|
||||
final_count = 0
|
||||
for line in result.split("\r\n"):
|
||||
|
||||
29
passbook/policies/hibp/tests.py
Normal file
29
passbook/policies/hibp/tests.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""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
|
||||
|
||||
|
||||
class TestHIBPPolicy(TestCase):
|
||||
"""Test HIBP Policy"""
|
||||
|
||||
def test_false(self):
|
||||
"""Failing password case"""
|
||||
policy = HaveIBeenPwendPolicy.objects.create(name="test_false",)
|
||||
request = PolicyRequest(get_anonymous_user())
|
||||
request.context["password"] = "password"
|
||||
result: PolicyResult = policy.passes(request)
|
||||
self.assertFalse(result.passing)
|
||||
self.assertTrue(result.messages[0].startswith("Password exists on "))
|
||||
|
||||
def test_true(self):
|
||||
"""Positive password case"""
|
||||
policy = HaveIBeenPwendPolicy.objects.create(name="test_true",)
|
||||
request = PolicyRequest(get_anonymous_user())
|
||||
request.context["password"] = generate_client_secret()
|
||||
result: PolicyResult = policy.passes(request)
|
||||
self.assertTrue(result.passing)
|
||||
self.assertEqual(result.messages, tuple())
|
||||
@ -3,12 +3,14 @@ from typing import Optional
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import AccessMixin
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import gettext as _
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import Application, Provider, User
|
||||
from passbook.flows.views import SESSION_KEY_APPLICATION_PRE
|
||||
from passbook.policies.engine import PolicyEngine
|
||||
from passbook.policies.types import PolicyResult
|
||||
|
||||
@ -25,8 +27,18 @@ class PolicyAccessMixin(BaseMixin, AccessMixin):
|
||||
"""Mixin class for usage in Authorization views.
|
||||
Provider functions to check application access, etc"""
|
||||
|
||||
def handle_no_permission(self, application: Optional[Application] = None):
|
||||
if application:
|
||||
self.request.session[SESSION_KEY_APPLICATION_PRE] = application
|
||||
return redirect_to_login(
|
||||
self.request.get_full_path(),
|
||||
self.get_login_url(),
|
||||
self.get_redirect_field_name(),
|
||||
)
|
||||
|
||||
def handle_no_permission_authorized(self) -> HttpResponse:
|
||||
"""Function called when user has no permissions but is authorized"""
|
||||
# TODO: Remove this URL and render the view instead
|
||||
return redirect("passbook_flows:denied")
|
||||
|
||||
def provider_to_application(self, provider: Provider) -> Application:
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
"""Policy base models"""
|
||||
from typing import Type
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from model_utils.managers import InheritanceManager
|
||||
|
||||
@ -73,6 +75,10 @@ class Policy(CreatedUpdatedModel):
|
||||
|
||||
objects = InheritanceAutoManager()
|
||||
|
||||
def form(self) -> Type[ModelForm]:
|
||||
"""Return Form class used to edit this object"""
|
||||
raise NotImplementedError
|
||||
|
||||
def __str__(self):
|
||||
return f"Policy {self.name}"
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ class PasswordPolicySerializer(ModelSerializer):
|
||||
class Meta:
|
||||
model = PasswordPolicy
|
||||
fields = GENERAL_SERIALIZER_FIELDS + [
|
||||
"password_field",
|
||||
"amount_uppercase",
|
||||
"amount_lowercase",
|
||||
"amount_symbols",
|
||||
|
||||
@ -14,6 +14,7 @@ class PasswordPolicyForm(forms.ModelForm):
|
||||
|
||||
model = PasswordPolicy
|
||||
fields = GENERAL_FIELDS + [
|
||||
"password_field",
|
||||
"amount_uppercase",
|
||||
"amount_lowercase",
|
||||
"amount_symbols",
|
||||
@ -23,6 +24,7 @@ class PasswordPolicyForm(forms.ModelForm):
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"password_field": forms.TextInput(),
|
||||
"symbol_charset": forms.TextInput(),
|
||||
"error_message": forms.TextInput(),
|
||||
}
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.0.8 on 2020-07-10 18:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_policies_password", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="passwordpolicy",
|
||||
name="password_field",
|
||||
field=models.TextField(
|
||||
default="password",
|
||||
help_text="Field key to check, field keys defined in Prompt stages are available.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,7 +1,9 @@
|
||||
"""user field matcher models"""
|
||||
import re
|
||||
from typing import Type
|
||||
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext as _
|
||||
from structlog import get_logger
|
||||
|
||||
@ -14,6 +16,13 @@ LOGGER = get_logger()
|
||||
class PasswordPolicy(Policy):
|
||||
"""Policy to make sure passwords have certain properties"""
|
||||
|
||||
password_field = models.TextField(
|
||||
default="password",
|
||||
help_text=_(
|
||||
"Field key to check, field keys defined in Prompt stages are available."
|
||||
),
|
||||
)
|
||||
|
||||
amount_uppercase = models.IntegerField(default=0)
|
||||
amount_lowercase = models.IntegerField(default=0)
|
||||
amount_symbols = models.IntegerField(default=0)
|
||||
@ -21,22 +30,35 @@ class PasswordPolicy(Policy):
|
||||
symbol_charset = models.TextField(default=r"!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ ")
|
||||
error_message = models.TextField()
|
||||
|
||||
form = "passbook.policies.password.forms.PasswordPolicyForm"
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.policies.password.forms import PasswordPolicyForm
|
||||
|
||||
return PasswordPolicyForm
|
||||
|
||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||
# Only check if password is being set
|
||||
if not hasattr(request.user, "__password__"):
|
||||
return PolicyResult(True)
|
||||
password = getattr(request.user, "__password__")
|
||||
if self.password_field not in request.context:
|
||||
LOGGER.warning(
|
||||
"Password field not set in Policy Request",
|
||||
field=self.password_field,
|
||||
fields=request.context.keys(),
|
||||
)
|
||||
password = request.context[self.password_field]
|
||||
|
||||
filter_regex = r""
|
||||
filter_regex = []
|
||||
if self.amount_lowercase > 0:
|
||||
filter_regex += r"[a-z]{%d,}" % self.amount_lowercase
|
||||
filter_regex.append(r"[a-z]{%d,}" % self.amount_lowercase)
|
||||
if self.amount_uppercase > 0:
|
||||
filter_regex += r"[A-Z]{%d,}" % self.amount_uppercase
|
||||
filter_regex.append(r"[A-Z]{%d,}" % self.amount_uppercase)
|
||||
if self.amount_symbols > 0:
|
||||
filter_regex += r"[%s]{%d,}" % (self.symbol_charset, self.amount_symbols)
|
||||
result = bool(re.compile(filter_regex).match(password))
|
||||
filter_regex.append(
|
||||
r"[%s]{%d,}" % (self.symbol_charset, self.amount_symbols)
|
||||
)
|
||||
full_regex = "|".join(filter_regex)
|
||||
LOGGER.debug("Built regex", regexp=full_regex)
|
||||
result = bool(re.compile(full_regex).match(password))
|
||||
|
||||
result = result and len(password) >= self.length_min
|
||||
|
||||
if not result:
|
||||
return PolicyResult(result, self.error_message)
|
||||
return PolicyResult(result)
|
||||
|
||||
42
passbook/policies/password/tests.py
Normal file
42
passbook/policies/password/tests.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Password Policy tests"""
|
||||
from django.test import TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from passbook.policies.password.models import PasswordPolicy
|
||||
from passbook.policies.types import PolicyRequest, PolicyResult
|
||||
|
||||
|
||||
class TestPasswordPolicy(TestCase):
|
||||
"""Test Password Policy"""
|
||||
|
||||
def test_false(self):
|
||||
"""Failing password case"""
|
||||
policy = PasswordPolicy.objects.create(
|
||||
name="test_false",
|
||||
amount_uppercase=1,
|
||||
amount_lowercase=2,
|
||||
amount_symbols=3,
|
||||
length_min=24,
|
||||
error_message="test message",
|
||||
)
|
||||
request = PolicyRequest(get_anonymous_user())
|
||||
request.context["password"] = "test"
|
||||
result: PolicyResult = policy.passes(request)
|
||||
self.assertFalse(result.passing)
|
||||
self.assertEqual(result.messages, ("test message",))
|
||||
|
||||
def test_true(self):
|
||||
"""Positive password case"""
|
||||
policy = PasswordPolicy.objects.create(
|
||||
name="test_true",
|
||||
amount_uppercase=1,
|
||||
amount_lowercase=2,
|
||||
amount_symbols=3,
|
||||
length_min=3,
|
||||
error_message="test message",
|
||||
)
|
||||
request = PolicyRequest(get_anonymous_user())
|
||||
request.context["password"] = "Test()!"
|
||||
result: PolicyResult = policy.passes(request)
|
||||
self.assertTrue(result.passing)
|
||||
self.assertEqual(result.messages, tuple())
|
||||
@ -1,6 +1,9 @@
|
||||
"""passbook reputation request policy"""
|
||||
from typing import Type
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.core.models import User
|
||||
@ -19,7 +22,10 @@ class ReputationPolicy(Policy):
|
||||
check_username = models.BooleanField(default=True)
|
||||
threshold = models.IntegerField(default=-5)
|
||||
|
||||
form = "passbook.policies.reputation.forms.ReputationPolicyForm"
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.policies.reputation.forms import ReputationPolicyForm
|
||||
|
||||
return ReputationPolicyForm
|
||||
|
||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||
remote_ip = get_client_ip(request.http_request) or "255.255.255.255"
|
||||
|
||||
@ -32,7 +32,8 @@ def update_score(request: HttpRequest, username: str, amount: int):
|
||||
# pylint: disable=unused-argument
|
||||
def handle_failed_login(sender, request, credentials, **_):
|
||||
"""Lower Score for failed loging attempts"""
|
||||
update_score(request, credentials.get("username"), -1)
|
||||
if "username" in credentials:
|
||||
update_score(request, credentials.get("username"), -1)
|
||||
|
||||
|
||||
@receiver(user_logged_in)
|
||||
|
||||
@ -21,7 +21,6 @@ def save_ip_reputation():
|
||||
for key in keys:
|
||||
score = cache.get(key)
|
||||
remote_ip = key.replace(CACHE_KEY_IP_PREFIX, "")
|
||||
print(remote_ip)
|
||||
rep, _ = IPReputation.objects.get_or_create(ip=remote_ip)
|
||||
rep.score = score
|
||||
objects_to_update.append(rep)
|
||||
|
||||
11
passbook/policies/utils.py
Normal file
11
passbook/policies/utils.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""Policy Utils"""
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def delete_none_keys(dict_: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||
"""Remove any keys from `dict_` that are None."""
|
||||
new_dict = {}
|
||||
for key, value in dict_.items():
|
||||
if value is not None:
|
||||
new_dict[key] = value
|
||||
return new_dict
|
||||
@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.0.8 on 2020-07-26 17:45
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_app_gw", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="applicationgatewayprovider",
|
||||
name="external_host",
|
||||
field=models.TextField(validators=[django.core.validators.URLValidator]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="applicationgatewayprovider",
|
||||
name="internal_host",
|
||||
field=models.TextField(validators=[django.core.validators.URLValidator]),
|
||||
),
|
||||
]
|
||||
@ -1,14 +1,13 @@
|
||||
"""passbook app_gw models"""
|
||||
import string
|
||||
from random import SystemRandom
|
||||
from typing import Optional
|
||||
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 import __version__
|
||||
from passbook.core.models import Provider
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
|
||||
@ -18,22 +17,24 @@ class ApplicationGatewayProvider(Provider):
|
||||
Protocols by using a Reverse-Proxy."""
|
||||
|
||||
name = models.TextField()
|
||||
internal_host = models.TextField()
|
||||
external_host = models.TextField()
|
||||
internal_host = models.TextField(validators=[URLValidator])
|
||||
external_host = models.TextField(validators=[URLValidator])
|
||||
|
||||
client = models.ForeignKey(Client, on_delete=models.CASCADE)
|
||||
|
||||
form = "passbook.providers.app_gw.forms.ApplicationGatewayProviderForm"
|
||||
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"""
|
||||
cookie_secret = "".join(
|
||||
SystemRandom().choice(string.ascii_uppercase + string.digits)
|
||||
for _ in range(50)
|
||||
)
|
||||
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, "cookie_secret": cookie_secret, "version": __version__},
|
||||
{"provider": self, "docker_compose": docker_compose_yaml},
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
version: "3.5"
|
||||
|
||||
services:
|
||||
passbook_gatekeeper:
|
||||
image: beryju/passbook-gatekeeper:{{ version }}
|
||||
ports:
|
||||
- 4180:4180
|
||||
environment:
|
||||
OAUTH2_PROXY_CLIENT_ID: {{ provider.client.client_id }}
|
||||
OAUTH2_PROXY_CLIENT_SECRET: {{ provider.client.client_secret }}
|
||||
OAUTH2_PROXY_REDIRECT_URL: https://{{ provider.external_host }}/oauth2/callback
|
||||
OAUTH2_PROXY_OIDC_ISSUER_URL: https://{{ request.META.HTTP_HOST }}/application/oidc
|
||||
OAUTH2_PROXY_COOKIE_SECRET: {{ cookie_secret }}
|
||||
OAUTH2_PROXY_UPSTREAMS: http://{{ provider.internal_host }}
|
||||
@ -26,7 +26,7 @@
|
||||
</div>
|
||||
<div class="pf-c-modal-box__body">
|
||||
{% trans 'Add the following snippet to your docker-compose file.' %}
|
||||
<textarea class="codemirror" readonly data-cm-mode="yaml">{% include 'app_gw/docker-compose.yml' %}</textarea>
|
||||
<textarea class="codemirror" readonly data-cm-mode="yaml">{{ docker_compose }}</textarea>
|
||||
</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>
|
||||
@ -49,8 +49,8 @@
|
||||
<a href="{% url 'passbook_providers_app_gw:k8s-manifest' provider=provider.pk %}">{% trans 'Here' %}</a>
|
||||
<p>{% trans 'Afterwards, add the following annotations to the Ingress you want to secure:' %}</p>
|
||||
<textarea class="codemirror" readonly data-cm-mode="yaml">
|
||||
nginx.ingress.kubernetes.io/auth-url: "https://{{ provider.external_host }}/oauth2/auth"
|
||||
nginx.ingress.kubernetes.io/auth-signin: "https://{{ provider.external_host }}/oauth2/start?rd=$escaped_request_uri"
|
||||
nginx.ingress.kubernetes.io/auth-url: "{{ provider.external_host }}/oauth2/auth"
|
||||
nginx.ingress.kubernetes.io/auth-signin: "{{ provider.external_host }}/oauth2/start?rd=$escaped_request_uri"
|
||||
</textarea>
|
||||
</div>
|
||||
<footer class="pf-c-modal-box__footer pf-m-align-left">
|
||||
|
||||
@ -1,33 +1,90 @@
|
||||
"""passbook app_gw views"""
|
||||
import string
|
||||
from random import SystemRandom
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db.models import Model
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.shortcuts import get_object_or_404, render, reverse
|
||||
from django.views import View
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
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
|
||||
|
||||
ORIGINAL_URL = "HTTP_X_ORIGINAL_URL"
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def get_object_for_user_or_404(user: User, perm: str, **filters) -> Model:
|
||||
"""Wrapper that combines get_objects_for_user and get_object_or_404"""
|
||||
return get_object_or_404(get_objects_for_user(user, perm), **filters)
|
||||
|
||||
|
||||
def get_cookie_secret():
|
||||
"""Generate random 50-character string for cookie-secret"""
|
||||
"""Generate random 32-character string for cookie-secret"""
|
||||
return "".join(
|
||||
SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(50)
|
||||
SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(32)
|
||||
)
|
||||
|
||||
|
||||
class DockerComposeView(LoginRequiredMixin, View):
|
||||
"""Generate docker-compose yaml"""
|
||||
|
||||
def get_compose(self, provider: ApplicationGatewayProvider) -> str:
|
||||
"""Generate docker-compose yaml, version 3.5"""
|
||||
full_issuer_user = self.request.build_absolute_uri(
|
||||
reverse("passbook_providers_oidc:authorize")
|
||||
)
|
||||
env = {
|
||||
"OAUTH2_PROXY_CLIENT_ID": provider.client.client_id,
|
||||
"OAUTH2_PROXY_CLIENT_SECRET": provider.client.client_secret,
|
||||
"OAUTH2_PROXY_REDIRECT_URL": f"{provider.external_host}/oauth2/callback",
|
||||
"OAUTH2_PROXY_OIDC_ISSUER_URL": full_issuer_user,
|
||||
"OAUTH2_PROXY_COOKIE_SECRET": get_cookie_secret(),
|
||||
"OAUTH2_PROXY_UPSTREAMS": provider.internal_host,
|
||||
}
|
||||
if urlparse(provider.external_host).scheme != "https":
|
||||
env["OAUTH2_PROXY_COOKIE_SECURE"] = "false"
|
||||
compose = {
|
||||
"version": "3.5",
|
||||
"services": {
|
||||
"passbook_gatekeeper": {
|
||||
"image": f"beryju/passbook-gatekeeper:{__version__}",
|
||||
"ports": ["4180:4180"],
|
||||
"environment": env,
|
||||
}
|
||||
},
|
||||
}
|
||||
return safe_dump(compose, default_flow_style=False)
|
||||
|
||||
def get(self, request: HttpRequest, provider_pk: int) -> HttpResponse:
|
||||
"""Render docker-compose file"""
|
||||
provider: ApplicationGatewayProvider = get_object_for_user_or_404(
|
||||
request.user,
|
||||
"passbook_providers_app_gw.view_applicationgatewayprovider",
|
||||
pk=provider_pk,
|
||||
)
|
||||
response = HttpResponse()
|
||||
response.content_type = "application/x-yaml"
|
||||
response.content = self.get_compose(provider.pk)
|
||||
return response
|
||||
|
||||
|
||||
class K8sManifestView(LoginRequiredMixin, View):
|
||||
"""Generate K8s Deployment and SVC for gatekeeper"""
|
||||
|
||||
def get(self, request: HttpRequest, provider: int) -> HttpResponse:
|
||||
def get(self, request: HttpRequest, provider_pk: int) -> HttpResponse:
|
||||
"""Render deployment template"""
|
||||
provider = get_object_or_404(ApplicationGatewayProvider, pk=provider)
|
||||
provider: ApplicationGatewayProvider = get_object_for_user_or_404(
|
||||
request.user,
|
||||
"passbook_providers_app_gw.view_applicationgatewayprovider",
|
||||
pk=provider_pk,
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"app_gw/k8s-manifest.yaml",
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"""Oauth2 provider product extension"""
|
||||
|
||||
from typing import Optional
|
||||
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 _
|
||||
@ -16,7 +17,10 @@ class OAuth2Provider(Provider, AbstractApplication):
|
||||
This Provider also supports the GitHub-pretend mode for Applications that don't support
|
||||
generic OAuth."""
|
||||
|
||||
form = "passbook.providers.oauth.forms.OAuth2ProviderForm"
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.providers.oauth.forms import OAuth2ProviderForm
|
||||
|
||||
return OAuth2ProviderForm
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""passbook OAuth2 Views"""
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views import View
|
||||
@ -37,7 +36,7 @@ PLAN_CONTEXT_NONCE = "nonce"
|
||||
PLAN_CONTEXT_SCOPE_DESCRIPTION = "scope_descriptions"
|
||||
|
||||
|
||||
class AuthorizationFlowInitView(PolicyAccessMixin, LoginRequiredMixin, View):
|
||||
class AuthorizationFlowInitView(PolicyAccessMixin, View):
|
||||
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@ -49,6 +48,10 @@ class AuthorizationFlowInitView(PolicyAccessMixin, LoginRequiredMixin, View):
|
||||
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:
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"""oidc models"""
|
||||
from typing import Optional
|
||||
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 _
|
||||
@ -20,7 +21,10 @@ class OpenIDProvider(Provider):
|
||||
|
||||
oidc_client = models.OneToOneField(Client, on_delete=models.CASCADE)
|
||||
|
||||
form = "passbook.providers.oidc.forms.OIDCProviderForm"
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.providers.oidc.forms import OIDCProviderForm
|
||||
|
||||
return OIDCProviderForm
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""passbook OIDC Views"""
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, reverse
|
||||
from django.views import View
|
||||
@ -30,7 +29,7 @@ PLAN_CONTEXT_PARAMS = "params"
|
||||
PLAN_CONTEXT_SCOPES = "scopes"
|
||||
|
||||
|
||||
class AuthorizationFlowInitView(PolicyAccessMixin, LoginRequiredMixin, View):
|
||||
class AuthorizationFlowInitView(PolicyAccessMixin, View):
|
||||
"""OIDC Flow initializer, checks access to application and starts flow"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@ -42,6 +41,10 @@ class AuthorizationFlowInitView(PolicyAccessMixin, LoginRequiredMixin, View):
|
||||
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:
|
||||
|
||||
@ -14,7 +14,6 @@ class SAMLProviderSerializer(ModelSerializer):
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"processor_path",
|
||||
"acs_url",
|
||||
"audience",
|
||||
"issuer",
|
||||
|
||||
@ -1,26 +1,12 @@
|
||||
"""passbook mod saml_idp app config"""
|
||||
from importlib import import_module
|
||||
"""passbook SAML IdP app config"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from structlog import get_logger
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class PassbookProviderSAMLConfig(AppConfig):
|
||||
"""passbook saml_idp app config"""
|
||||
"""passbook SAML IdP app config"""
|
||||
|
||||
name = "passbook.providers.saml"
|
||||
label = "passbook_providers_saml"
|
||||
verbose_name = "passbook Providers.SAML"
|
||||
mountpoint = "application/saml/"
|
||||
|
||||
def ready(self):
|
||||
"""Load source_types from config file"""
|
||||
for source_type in settings.PASSBOOK_PROVIDERS_SAML_PROCESSORS:
|
||||
try:
|
||||
import_module(source_type)
|
||||
LOGGER.info("Loaded SAML Processor", processor_class=source_type)
|
||||
except ImportError as exc:
|
||||
LOGGER.debug(exc)
|
||||
|
||||
@ -8,11 +8,7 @@ from django.utils.translation import gettext as _
|
||||
from passbook.admin.fields import CodeMirrorWidget
|
||||
from passbook.core.expression import PropertyMappingEvaluator
|
||||
from passbook.flows.models import Flow, FlowDesignation
|
||||
from passbook.providers.saml.models import (
|
||||
SAMLPropertyMapping,
|
||||
SAMLProvider,
|
||||
get_provider_choices,
|
||||
)
|
||||
from passbook.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
|
||||
|
||||
class SAMLProviderForm(forms.ModelForm):
|
||||
@ -21,9 +17,6 @@ class SAMLProviderForm(forms.ModelForm):
|
||||
authorization_flow = forms.ModelChoiceField(
|
||||
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION)
|
||||
)
|
||||
processor_path = forms.ChoiceField(
|
||||
choices=get_provider_choices(), label="Processor"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -31,7 +24,6 @@ class SAMLProviderForm(forms.ModelForm):
|
||||
fields = [
|
||||
"name",
|
||||
"authorization_flow",
|
||||
"processor_path",
|
||||
"acs_url",
|
||||
"audience",
|
||||
"issuer",
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import passbook.providers.saml.utils.time
|
||||
import passbook.lib.utils.time
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@ -66,9 +66,7 @@ class Migration(migrations.Migration):
|
||||
models.TextField(
|
||||
default="minutes=-5",
|
||||
help_text="Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).",
|
||||
validators=[
|
||||
passbook.providers.saml.utils.time.timedelta_string_validator
|
||||
],
|
||||
validators=[passbook.lib.utils.time.timedelta_string_validator],
|
||||
),
|
||||
),
|
||||
(
|
||||
@ -76,9 +74,7 @@ class Migration(migrations.Migration):
|
||||
models.TextField(
|
||||
default="minutes=5",
|
||||
help_text="Assertion not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
|
||||
validators=[
|
||||
passbook.providers.saml.utils.time.timedelta_string_validator
|
||||
],
|
||||
validators=[passbook.lib.utils.time.timedelta_string_validator],
|
||||
),
|
||||
),
|
||||
(
|
||||
@ -86,9 +82,7 @@ class Migration(migrations.Migration):
|
||||
models.TextField(
|
||||
default="minutes=86400",
|
||||
help_text="Session not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
|
||||
validators=[
|
||||
passbook.providers.saml.utils.time.timedelta_string_validator
|
||||
],
|
||||
validators=[passbook.lib.utils.time.timedelta_string_validator],
|
||||
),
|
||||
),
|
||||
(
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
# Generated by Django 3.0.8 on 2020-07-11 00:02
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_saml", "0004_auto_20200620_1950"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name="samlprovider", name="processor_path",),
|
||||
]
|
||||
@ -1,7 +1,8 @@
|
||||
"""passbook saml_idp Models"""
|
||||
from typing import Optional
|
||||
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 ugettext_lazy as _
|
||||
@ -9,10 +10,8 @@ from structlog import get_logger
|
||||
|
||||
from passbook.core.models import PropertyMapping, Provider
|
||||
from passbook.crypto.models import CertificateKeyPair
|
||||
from passbook.lib.utils.reflection import class_to_path, path_to_class
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
from passbook.providers.saml.processors.base import Processor
|
||||
from passbook.providers.saml.utils.time import timedelta_string_validator
|
||||
from passbook.lib.utils.time import timedelta_string_validator
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -28,7 +27,6 @@ class SAMLProvider(Provider):
|
||||
"""SAML 2.0 Endpoint for applications which support SAML."""
|
||||
|
||||
name = models.TextField()
|
||||
processor_path = models.CharField(max_length=255, choices=[])
|
||||
|
||||
acs_url = models.URLField(verbose_name=_("ACS URL"))
|
||||
audience = models.TextField(default="")
|
||||
@ -104,23 +102,10 @@ class SAMLProvider(Provider):
|
||||
),
|
||||
)
|
||||
|
||||
form = "passbook.providers.saml.forms.SAMLProviderForm"
|
||||
_processor = None
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.providers.saml.forms import SAMLProviderForm
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._meta.get_field("processor_path").choices = get_provider_choices()
|
||||
|
||||
@property
|
||||
def processor(self) -> Optional[Processor]:
|
||||
"""Return selected processor as instance"""
|
||||
if not self._processor:
|
||||
try:
|
||||
self._processor = path_to_class(self.processor_path)(self)
|
||||
except ImportError as exc:
|
||||
LOGGER.warning(exc)
|
||||
self._processor = None
|
||||
return self._processor
|
||||
return SAMLProviderForm
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@ -162,7 +147,10 @@ class SAMLPropertyMapping(PropertyMapping):
|
||||
saml_name = models.TextField(verbose_name="SAML Name")
|
||||
friendly_name = models.TextField(default=None, blank=True, null=True)
|
||||
|
||||
form = "passbook.providers.saml.forms.SAMLPropertyMappingForm"
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.providers.saml.forms import SAMLPropertyMappingForm
|
||||
|
||||
return SAMLPropertyMappingForm
|
||||
|
||||
def __str__(self):
|
||||
return f"SAML Property Mapping {self.saml_name}"
|
||||
@ -171,10 +159,3 @@ class SAMLPropertyMapping(PropertyMapping):
|
||||
|
||||
verbose_name = _("SAML Property Mapping")
|
||||
verbose_name_plural = _("SAML Property Mappings")
|
||||
|
||||
|
||||
def get_provider_choices():
|
||||
"""Return tuple of class_path, class name of all providers."""
|
||||
return [
|
||||
(class_to_path(x), x.__name__) for x in getattr(Processor, "__subclasses__")()
|
||||
]
|
||||
|
||||
241
passbook/providers/saml/processors/assertion.py
Normal file
241
passbook/providers/saml/processors/assertion.py
Normal file
@ -0,0 +1,241 @@
|
||||
"""SAML Assertion generator"""
|
||||
from hashlib import sha256
|
||||
from types import GeneratorType
|
||||
|
||||
from django.http import HttpRequest
|
||||
from lxml import etree # nosec
|
||||
from lxml.etree import Element, SubElement # nosec
|
||||
from signxml import XMLSigner, XMLVerifier
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.exceptions import PropertyMappingExpressionException
|
||||
from passbook.lib.utils.time import timedelta_from_string
|
||||
from passbook.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from passbook.providers.saml.processors.request_parser import AuthNRequest
|
||||
from passbook.providers.saml.utils import get_random_id
|
||||
from passbook.providers.saml.utils.time import get_time_string
|
||||
from passbook.sources.saml.exceptions import UnsupportedNameIDFormat
|
||||
from passbook.sources.saml.processors.constants import (
|
||||
NS_MAP,
|
||||
NS_SAML_ASSERTION,
|
||||
NS_SAML_PROTOCOL,
|
||||
NS_SIGNATURE,
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SAML_NAME_ID_FORMAT_PERSISTENT,
|
||||
SAML_NAME_ID_FORMAT_TRANSIENT,
|
||||
SAML_NAME_ID_FORMAT_X509,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class AssertionProcessor:
|
||||
"""Generate a SAML Response from an AuthNRequest"""
|
||||
|
||||
provider: SAMLProvider
|
||||
http_request: HttpRequest
|
||||
auth_n_request: AuthNRequest
|
||||
|
||||
_issue_instant: str
|
||||
_assertion_id: str
|
||||
|
||||
_valid_not_before: str
|
||||
_valid_not_on_or_after: str
|
||||
|
||||
def __init__(
|
||||
self, provider: SAMLProvider, request: HttpRequest, auth_n_request: AuthNRequest
|
||||
):
|
||||
self.provider = provider
|
||||
self.http_request = request
|
||||
self.auth_n_request = auth_n_request
|
||||
|
||||
self._issue_instant = get_time_string()
|
||||
self._assertion_id = get_random_id()
|
||||
|
||||
self._valid_not_before = get_time_string(
|
||||
timedelta_from_string(self.provider.assertion_valid_not_before)
|
||||
)
|
||||
self._valid_not_on_or_after = get_time_string(
|
||||
timedelta_from_string(self.provider.assertion_valid_not_on_or_after)
|
||||
)
|
||||
|
||||
def get_attributes(self) -> Element:
|
||||
"""Get AttributeStatement Element with Attributes from Property Mappings."""
|
||||
# https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
|
||||
attribute_statement = Element(f"{{{NS_SAML_ASSERTION}}}AttributeStatement")
|
||||
for mapping in self.provider.property_mappings.all().select_subclasses():
|
||||
if not isinstance(mapping, SAMLPropertyMapping):
|
||||
continue
|
||||
try:
|
||||
mapping: SAMLPropertyMapping
|
||||
value = mapping.evaluate(
|
||||
user=self.http_request.user,
|
||||
request=self.http_request,
|
||||
provider=self.provider,
|
||||
)
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
attribute = Element(f"{{{NS_SAML_ASSERTION}}}Attribute")
|
||||
attribute.attrib["FriendlyName"] = mapping.friendly_name
|
||||
attribute.attrib["Name"] = mapping.saml_name
|
||||
|
||||
if not isinstance(value, (list, GeneratorType)):
|
||||
value = [value]
|
||||
|
||||
for value_item in value:
|
||||
attribute_value = SubElement(
|
||||
attribute, f"{{{NS_SAML_ASSERTION}}}AttributeValue"
|
||||
)
|
||||
if not isinstance(value_item, str):
|
||||
value_item = str(value_item)
|
||||
attribute_value.text = value_item
|
||||
|
||||
attribute_statement.append(attribute)
|
||||
|
||||
except PropertyMappingExpressionException as exc:
|
||||
LOGGER.warning(exc)
|
||||
continue
|
||||
return attribute_statement
|
||||
|
||||
def get_issuer(self) -> Element:
|
||||
"""Get Issuer Element"""
|
||||
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer", nsmap=NS_MAP)
|
||||
issuer.text = self.provider.issuer
|
||||
return issuer
|
||||
|
||||
def get_assertion_auth_n_statement(self) -> Element:
|
||||
"""Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
|
||||
auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
|
||||
auth_n_statement.attrib["AuthnInstant"] = self._valid_not_before
|
||||
auth_n_statement.attrib["SessionIndex"] = self._assertion_id
|
||||
|
||||
auth_n_context = SubElement(
|
||||
auth_n_statement, f"{{{NS_SAML_ASSERTION}}}AuthnContext"
|
||||
)
|
||||
auth_n_context_class_ref = SubElement(
|
||||
auth_n_context, f"{{{NS_SAML_ASSERTION}}}AuthnContextClassRef"
|
||||
)
|
||||
auth_n_context_class_ref.text = (
|
||||
"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
|
||||
)
|
||||
return auth_n_statement
|
||||
|
||||
def get_assertion_conditions(self) -> Element:
|
||||
"""Generate Conditions with AudienceRestriction and Audience Elements."""
|
||||
conditions = Element(f"{{{NS_SAML_ASSERTION}}}Conditions")
|
||||
conditions.attrib["NotBefore"] = self._valid_not_before
|
||||
conditions.attrib["NotOnOrAfter"] = self._valid_not_on_or_after
|
||||
audience_restriction = SubElement(
|
||||
conditions, f"{{{NS_SAML_ASSERTION}}}AudienceRestriction"
|
||||
)
|
||||
audience = SubElement(audience_restriction, f"{{{NS_SAML_ASSERTION}}}Audience")
|
||||
audience.text = self.provider.audience
|
||||
return conditions
|
||||
|
||||
def get_name_id(self) -> Element:
|
||||
"""Get NameID Element"""
|
||||
name_id = Element(f"{{{NS_SAML_ASSERTION}}}NameID")
|
||||
name_id.attrib["Format"] = self.auth_n_request.name_id_policy
|
||||
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_EMAIL:
|
||||
name_id.text = self.http_request.user.email
|
||||
return name_id
|
||||
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_PERSISTENT:
|
||||
name_id.text = self.http_request.user.username
|
||||
return name_id
|
||||
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_X509:
|
||||
# This attribute is statically set by the LDAP source
|
||||
name_id.text = self.http_request.user.attributes.get(
|
||||
"distinguishedName", ""
|
||||
)
|
||||
return name_id
|
||||
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
|
||||
# This attribute is statically set by the LDAP source
|
||||
session_key: str = self.http_request.user.session.session_key
|
||||
name_id.text = sha256(session_key.encode()).hexdigest()
|
||||
return name_id
|
||||
raise UnsupportedNameIDFormat(
|
||||
f"Assertion contains NameID with unsupported format {name_id.attrib['Format']}."
|
||||
)
|
||||
|
||||
def get_assertion_subject(self) -> Element:
|
||||
"""Generate Subject Element with NameID and SubjectConfirmation Objects"""
|
||||
subject = Element(f"{{{NS_SAML_ASSERTION}}}Subject")
|
||||
subject.append(self.get_name_id())
|
||||
|
||||
subject_confirmation = SubElement(
|
||||
subject, f"{{{NS_SAML_ASSERTION}}}SubjectConfirmation"
|
||||
)
|
||||
subject_confirmation.attrib["Method"] = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
|
||||
|
||||
subject_confirmation_data = SubElement(
|
||||
subject_confirmation, f"{{{NS_SAML_ASSERTION}}}SubjectConfirmationData"
|
||||
)
|
||||
if self.auth_n_request.id:
|
||||
subject_confirmation_data.attrib["InResponseTo"] = self.auth_n_request.id
|
||||
subject_confirmation_data.attrib["NotOnOrAfter"] = self._issue_instant
|
||||
subject_confirmation_data.attrib["Recipient"] = self.provider.acs_url
|
||||
return subject
|
||||
|
||||
def get_assertion(self) -> Element:
|
||||
"""Generate Main Assertion Element"""
|
||||
assertion = Element(f"{{{NS_SAML_ASSERTION}}}Assertion", nsmap=NS_MAP)
|
||||
assertion.attrib["Version"] = "2.0"
|
||||
assertion.attrib["ID"] = self._assertion_id
|
||||
assertion.attrib["IssueInstant"] = self._issue_instant
|
||||
assertion.append(self.get_issuer())
|
||||
|
||||
if self.provider.signing_kp:
|
||||
# We need a placeholder signature as SAML requires the signature to be between
|
||||
# Issuer and subject
|
||||
signature_placeholder = SubElement(
|
||||
assertion, f"{{{NS_SIGNATURE}}}Signature", nsmap=NS_MAP
|
||||
)
|
||||
signature_placeholder.attrib["Id"] = "placeholder"
|
||||
|
||||
assertion.append(self.get_assertion_subject())
|
||||
assertion.append(self.get_assertion_conditions())
|
||||
assertion.append(self.get_assertion_auth_n_statement())
|
||||
|
||||
assertion.append(self.get_attributes())
|
||||
return assertion
|
||||
|
||||
def get_response(self) -> Element:
|
||||
"""Generate Root response element"""
|
||||
response = Element(f"{{{NS_SAML_PROTOCOL}}}Response", nsmap=NS_MAP)
|
||||
response.attrib["Version"] = "2.0"
|
||||
response.attrib["IssueInstant"] = self._issue_instant
|
||||
response.attrib["Destination"] = self.provider.acs_url
|
||||
response.attrib["ID"] = get_random_id()
|
||||
if self.auth_n_request.id:
|
||||
response.attrib["InResponseTo"] = self.auth_n_request.id
|
||||
|
||||
response.append(self.get_issuer())
|
||||
|
||||
status = SubElement(response, f"{{{NS_SAML_PROTOCOL}}}Status")
|
||||
status_code = SubElement(status, f"{{{NS_SAML_PROTOCOL}}}StatusCode")
|
||||
status_code.attrib["Value"] = "urn:oasis:names:tc:SAML:2.0:status:Success"
|
||||
|
||||
response.append(self.get_assertion())
|
||||
return response
|
||||
|
||||
def build_response(self) -> str:
|
||||
"""Build string XML Response and sign if signing is enabled."""
|
||||
root_response = self.get_response()
|
||||
if self.provider.signing_kp:
|
||||
signer = XMLSigner(
|
||||
c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#",
|
||||
signature_algorithm=self.provider.signature_algorithm,
|
||||
digest_algorithm=self.provider.digest_algorithm,
|
||||
)
|
||||
signed = signer.sign(
|
||||
root_response,
|
||||
key=self.provider.signing_kp.private_key,
|
||||
cert=[self.provider.signing_kp.certificate_data],
|
||||
reference_uri=self._assertion_id,
|
||||
)
|
||||
XMLVerifier().verify(
|
||||
signed, x509_cert=self.provider.signing_kp.certificate_data
|
||||
)
|
||||
return etree.tostring(signed).decode("utf-8") # nosec
|
||||
return etree.tostring(root_response).decode("utf-8") # nosec
|
||||
@ -1,247 +0,0 @@
|
||||
"""Basic SAML Processor"""
|
||||
from types import GeneratorType
|
||||
from typing import TYPE_CHECKING, Dict, List, Union
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from defusedxml import ElementTree
|
||||
from django.http import HttpRequest
|
||||
from signxml import XMLVerifier
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.exceptions import PropertyMappingExpressionException
|
||||
from passbook.providers.saml.exceptions import CannotHandleAssertion
|
||||
from passbook.providers.saml.processors.types import SAMLResponseParams
|
||||
from passbook.providers.saml.utils import get_random_id
|
||||
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate, nice64
|
||||
from passbook.providers.saml.utils.time import get_time_string, timedelta_from_string
|
||||
from passbook.providers.saml.utils.xml_render import get_assertion_xml, get_response_xml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class Processor:
|
||||
"""Base SAML 2.0 Auth-N-Request to Response Processor.
|
||||
Sub-classes should provide Service Provider-specific functionality."""
|
||||
|
||||
is_idp_initiated = False
|
||||
|
||||
_remote: "SAMLProvider"
|
||||
_http_request: HttpRequest
|
||||
|
||||
_assertion_xml: str
|
||||
_response_xml: str
|
||||
_saml_response: str
|
||||
|
||||
_relay_state: str
|
||||
_saml_request: str
|
||||
|
||||
_assertion_params: Dict[str, Union[str, List[Dict[str, str]]]]
|
||||
_request_params: Dict[str, str]
|
||||
_response_params: Dict[str, str]
|
||||
|
||||
@property
|
||||
def subject_format(self) -> str:
|
||||
"""Get subject Format"""
|
||||
return "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
||||
|
||||
def __init__(self, remote: "SAMLProvider"):
|
||||
self.name = remote.name
|
||||
self._remote = remote
|
||||
self._logger = get_logger()
|
||||
|
||||
def _build_assertion(self):
|
||||
"""Builds _assertion_params."""
|
||||
self._assertion_params = {
|
||||
"ASSERTION_ID": get_random_id(),
|
||||
"ASSERTION_SIGNATURE": "", # it's unsigned
|
||||
"AUDIENCE": self._remote.audience,
|
||||
"AUTH_INSTANT": get_time_string(),
|
||||
"ISSUE_INSTANT": get_time_string(),
|
||||
"NOT_BEFORE": get_time_string(
|
||||
timedelta_from_string(self._remote.assertion_valid_not_before)
|
||||
),
|
||||
"NOT_ON_OR_AFTER": get_time_string(
|
||||
timedelta_from_string(self._remote.assertion_valid_not_on_or_after)
|
||||
),
|
||||
"SESSION_INDEX": self._http_request.session.session_key,
|
||||
"SESSION_NOT_ON_OR_AFTER": get_time_string(
|
||||
timedelta_from_string(self._remote.session_valid_not_on_or_after)
|
||||
),
|
||||
"SP_NAME_QUALIFIER": self._remote.audience,
|
||||
"SUBJECT": self._http_request.user.email,
|
||||
"SUBJECT_FORMAT": self.subject_format,
|
||||
"ISSUER": self._remote.issuer,
|
||||
}
|
||||
self._assertion_params.update(self._request_params)
|
||||
|
||||
def _build_response(self):
|
||||
"""Builds _response_params."""
|
||||
self._response_params = {
|
||||
"ASSERTION": self._assertion_xml,
|
||||
"ISSUE_INSTANT": get_time_string(),
|
||||
"RESPONSE_ID": get_random_id(),
|
||||
"RESPONSE_SIGNATURE": "", # initially unsigned
|
||||
"ISSUER": self._remote.issuer,
|
||||
}
|
||||
self._response_params.update(self._request_params)
|
||||
|
||||
def _encode_response(self):
|
||||
"""Encodes _response_xml to _encoded_xml."""
|
||||
self._saml_response = nice64(str.encode(self._response_xml))
|
||||
|
||||
def _extract_saml_request(self):
|
||||
"""Retrieves the _saml_request AuthnRequest from the _http_request."""
|
||||
self._saml_request = self._http_request.session["SAMLRequest"]
|
||||
self._relay_state = self._http_request.session["RelayState"]
|
||||
|
||||
def _format_assertion(self):
|
||||
"""Formats _assertion_params as _assertion_xml."""
|
||||
# https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
|
||||
attributes = []
|
||||
from passbook.providers.saml.models import SAMLPropertyMapping
|
||||
|
||||
for mapping in self._remote.property_mappings.all().select_subclasses():
|
||||
if not isinstance(mapping, SAMLPropertyMapping):
|
||||
continue
|
||||
try:
|
||||
mapping: SAMLPropertyMapping
|
||||
value = mapping.evaluate(
|
||||
user=self._http_request.user,
|
||||
request=self._http_request,
|
||||
provider=self._remote,
|
||||
)
|
||||
if value is None:
|
||||
continue
|
||||
mapping_payload = {
|
||||
"Name": mapping.saml_name,
|
||||
"FriendlyName": mapping.friendly_name,
|
||||
}
|
||||
# Normal values and arrays need different dict keys as they are handeled
|
||||
# differently in the template
|
||||
if isinstance(value, list):
|
||||
mapping_payload["ValueArray"] = value
|
||||
elif isinstance(value, GeneratorType):
|
||||
mapping_payload["ValueArray"] = list(value)
|
||||
else:
|
||||
mapping_payload["Value"] = value
|
||||
attributes.append(mapping_payload)
|
||||
except PropertyMappingExpressionException as exc:
|
||||
self._logger.warning(exc)
|
||||
continue
|
||||
self._assertion_params["ATTRIBUTES"] = attributes
|
||||
self._assertion_xml = get_assertion_xml(
|
||||
"providers/saml/xml/assertions/generic.xml",
|
||||
self._assertion_params,
|
||||
signed=True,
|
||||
)
|
||||
|
||||
def _format_response(self):
|
||||
"""Formats _response_params as _response_xml."""
|
||||
assertion_id = self._assertion_params["ASSERTION_ID"]
|
||||
self._response_xml = get_response_xml(
|
||||
self._response_params, saml_provider=self._remote, assertion_id=assertion_id
|
||||
)
|
||||
|
||||
def _get_saml_response_params(self) -> SAMLResponseParams:
|
||||
"""Returns a dictionary of parameters for the response template."""
|
||||
return SAMLResponseParams(
|
||||
acs_url=self._request_params["ACS_URL"],
|
||||
saml_response=self._saml_response,
|
||||
relay_state=self._relay_state,
|
||||
)
|
||||
|
||||
def _decode_and_parse_request(self):
|
||||
"""Parses various parameters from _request_xml into _request_params."""
|
||||
decoded_xml = decode_base64_and_inflate(self._saml_request)
|
||||
|
||||
if self._remote.require_signing and self._remote.signing_kp:
|
||||
self._logger.debug("Verifying Request signature")
|
||||
try:
|
||||
XMLVerifier().verify(
|
||||
decoded_xml, x509_cert=self._remote.signing_kp.certificate_data
|
||||
)
|
||||
except InvalidSignature as exc:
|
||||
raise CannotHandleAssertion("Failed to verify signature") from exc
|
||||
|
||||
root = ElementTree.fromstring(decoded_xml)
|
||||
|
||||
params = {}
|
||||
params["ACS_URL"] = root.attrib.get(
|
||||
"AssertionConsumerServiceURL", self._remote.acs_url
|
||||
)
|
||||
params["REQUEST_ID"] = root.attrib["ID"]
|
||||
params["DESTINATION"] = root.attrib.get("Destination", "")
|
||||
params["PROVIDER_NAME"] = root.attrib.get("ProviderName", "")
|
||||
self._request_params = params
|
||||
|
||||
def _validate_request(self):
|
||||
"""
|
||||
Validates the SAML request against the SP configuration of this
|
||||
processor. Sub-classes should override this and raise a
|
||||
`CannotHandleAssertion` exception if the validation fails.
|
||||
|
||||
Raises:
|
||||
CannotHandleAssertion: if the ACS URL specified in the SAML request
|
||||
doesn't match the one specified in the processor config.
|
||||
"""
|
||||
request_acs_url = self._request_params["ACS_URL"]
|
||||
|
||||
if self._remote.acs_url != request_acs_url:
|
||||
msg = (
|
||||
f"ACS URL of {request_acs_url} doesn't match Provider "
|
||||
f"ACS URL of {self._remote.acs_url}."
|
||||
)
|
||||
self._logger.info(msg)
|
||||
raise CannotHandleAssertion(msg)
|
||||
|
||||
def can_handle(self, request: HttpRequest) -> bool:
|
||||
"""Returns true if this processor can handle this request."""
|
||||
self._http_request = request
|
||||
# Read the request.
|
||||
try:
|
||||
self._extract_saml_request()
|
||||
except KeyError:
|
||||
raise CannotHandleAssertion("Couldn't find SAML request in user session")
|
||||
|
||||
try:
|
||||
self._decode_and_parse_request()
|
||||
except Exception as exc:
|
||||
raise CannotHandleAssertion(f"Couldn't parse SAML request: {exc}") from exc
|
||||
|
||||
self._validate_request()
|
||||
return True
|
||||
|
||||
def generate_response(self) -> SAMLResponseParams:
|
||||
"""Processes request and returns template variables suitable for a response."""
|
||||
# Build the assertion and response.
|
||||
# Only call can_handle if SP initiated Request, otherwise we have no Request
|
||||
if not self.is_idp_initiated:
|
||||
self.can_handle(self._http_request)
|
||||
|
||||
self._build_assertion()
|
||||
self._format_assertion()
|
||||
self._build_response()
|
||||
self._format_response()
|
||||
self._encode_response()
|
||||
|
||||
# Return proper template params.
|
||||
return self._get_saml_response_params()
|
||||
|
||||
def init_deep_link(self, request: HttpRequest):
|
||||
"""Initialize this Processor to make an IdP-initiated call to the SP's
|
||||
deep-linked URL."""
|
||||
self._http_request = request
|
||||
acs_url = self._remote.acs_url
|
||||
# NOTE: The following request params are made up. Some are blank,
|
||||
# because they comes over in the AuthnRequest, but we don't have an
|
||||
# AuthnRequest in this case:
|
||||
# - Destination: Should be this IdP's SSO endpoint URL. Not used in the response?
|
||||
# - ProviderName: According to the spec, this is optional.
|
||||
self._request_params = {
|
||||
"ACS_URL": acs_url,
|
||||
"DESTINATION": "",
|
||||
"PROVIDER_NAME": "",
|
||||
}
|
||||
self._relay_state = ""
|
||||
@ -1,7 +0,0 @@
|
||||
"""Generic Processor"""
|
||||
|
||||
from passbook.providers.saml.processors.base import Processor
|
||||
|
||||
|
||||
class GenericProcessor(Processor):
|
||||
"""Generic SAML2 Processor"""
|
||||
108
passbook/providers/saml/processors/metadata.py
Normal file
108
passbook/providers/saml/processors/metadata.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""SAML Identity Provider Metadata Processor"""
|
||||
from typing import Iterator, Optional
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import reverse
|
||||
from lxml.etree import Element, SubElement, tostring # nosec
|
||||
from signxml.util import strip_pem_header
|
||||
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
from passbook.sources.saml.processors.constants import (
|
||||
NS_MAP,
|
||||
NS_SAML_METADATA,
|
||||
NS_SIGNATURE,
|
||||
SAML_BINDING_POST,
|
||||
SAML_BINDING_REDIRECT,
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SAML_NAME_ID_FORMAT_PERSISTENT,
|
||||
SAML_NAME_ID_FORMAT_TRANSIENT,
|
||||
SAML_NAME_ID_FORMAT_X509,
|
||||
)
|
||||
|
||||
|
||||
class MetadataProcessor:
|
||||
"""SAML Identity Provider Metadata Processor"""
|
||||
|
||||
provider: SAMLProvider
|
||||
http_request: HttpRequest
|
||||
|
||||
def __init__(self, provider: SAMLProvider, request: HttpRequest):
|
||||
self.provider = provider
|
||||
self.http_request = request
|
||||
|
||||
def get_signing_key_descriptor(self) -> Optional[Element]:
|
||||
"""Get Singing KeyDescriptor, if enabled for the provider"""
|
||||
if self.provider.signing_kp:
|
||||
key_descriptor = Element(f"{{{NS_SAML_METADATA}}}KeyDescriptor")
|
||||
key_descriptor.attrib["use"] = "signing"
|
||||
key_info = SubElement(key_descriptor, f"{{{NS_SIGNATURE}}}KeyInfo")
|
||||
x509_data = SubElement(key_info, f"{{{NS_SIGNATURE}}}X509Data")
|
||||
x509_certificate = SubElement(
|
||||
x509_data, f"{{{NS_SIGNATURE}}}X509Certificate"
|
||||
)
|
||||
x509_certificate.text = strip_pem_header(
|
||||
self.provider.signing_kp.certificate_data.replace("\r", "")
|
||||
).replace("\n", "")
|
||||
return key_descriptor
|
||||
return None
|
||||
|
||||
def get_name_id_formats(self) -> Iterator[Element]:
|
||||
"""Get compatible NameID Formats"""
|
||||
formats = [
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SAML_NAME_ID_FORMAT_PERSISTENT,
|
||||
SAML_NAME_ID_FORMAT_X509,
|
||||
SAML_NAME_ID_FORMAT_TRANSIENT,
|
||||
]
|
||||
for name_id_format in formats:
|
||||
element = Element(f"{{{NS_SAML_METADATA}}}NameIDFormat")
|
||||
element.text = name_id_format
|
||||
yield element
|
||||
|
||||
def get_bindings(self) -> Iterator[Element]:
|
||||
"""Get all Bindings supported"""
|
||||
binding_url_map = {
|
||||
SAML_BINDING_POST: self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_saml:sso-post",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
),
|
||||
SAML_BINDING_REDIRECT: self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_saml:sso-redirect",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
),
|
||||
}
|
||||
for binding, url in binding_url_map.items():
|
||||
element = Element(f"{{{NS_SAML_METADATA}}}SingleSignOnService")
|
||||
element.attrib["Binding"] = binding
|
||||
element.attrib["Location"] = url
|
||||
yield element
|
||||
|
||||
def build_entity_descriptor(self) -> str:
|
||||
"""Build full EntityDescriptor"""
|
||||
entity_descriptor = Element(
|
||||
f"{{{NS_SAML_METADATA}}}EntityDescriptor", nsmap=NS_MAP
|
||||
)
|
||||
entity_descriptor.attrib["entityID"] = self.provider.issuer
|
||||
|
||||
idp_sso_descriptor = SubElement(
|
||||
entity_descriptor, f"{{{NS_SAML_METADATA}}}IDPSSODescriptor"
|
||||
)
|
||||
idp_sso_descriptor.attrib[
|
||||
"protocolSupportEnumeration"
|
||||
] = "urn:oasis:names:tc:SAML:2.0:protocol"
|
||||
|
||||
signing_descriptor = self.get_signing_key_descriptor()
|
||||
if signing_descriptor is not None:
|
||||
idp_sso_descriptor.append(signing_descriptor)
|
||||
|
||||
for name_id_format in self.get_name_id_formats():
|
||||
idp_sso_descriptor.append(name_id_format)
|
||||
|
||||
for binding in self.get_bindings():
|
||||
idp_sso_descriptor.append(binding)
|
||||
|
||||
return tostring(entity_descriptor).decode()
|
||||
117
passbook/providers/saml/processors/request_parser.py
Normal file
117
passbook/providers/saml/processors/request_parser.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""SAML AuthNRequest Parser and dataclass"""
|
||||
from base64 import b64decode
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from defusedxml import ElementTree
|
||||
from signxml import XMLVerifier
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.providers.saml.exceptions import CannotHandleAssertion
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
from passbook.sources.saml.processors.constants import (
|
||||
NS_SAML_PROTOCOL,
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthNRequest:
|
||||
"""AuthNRequest Dataclass"""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
id: Optional[str] = None
|
||||
|
||||
relay_state: Optional[str] = None
|
||||
|
||||
name_id_policy: str = SAML_NAME_ID_FORMAT_EMAIL
|
||||
|
||||
|
||||
class AuthNRequestParser:
|
||||
"""AuthNRequest Parser"""
|
||||
|
||||
provider: SAMLProvider
|
||||
|
||||
def __init__(self, provider: SAMLProvider):
|
||||
self.provider = provider
|
||||
|
||||
def _parse_xml(self, decoded_xml: str, relay_state: Optional[str]) -> AuthNRequest:
|
||||
root = ElementTree.fromstring(decoded_xml)
|
||||
|
||||
request_acs_url = root.attrib["AssertionConsumerServiceURL"]
|
||||
|
||||
if self.provider.acs_url != request_acs_url:
|
||||
msg = (
|
||||
f"ACS URL of {request_acs_url} doesn't match Provider "
|
||||
f"ACS URL of {self.provider.acs_url}."
|
||||
)
|
||||
LOGGER.info(msg)
|
||||
raise CannotHandleAssertion(msg)
|
||||
|
||||
auth_n_request = AuthNRequest(id=root.attrib["ID"], relay_state=relay_state)
|
||||
|
||||
# Check if AuthnRequest has a NameID Policy object
|
||||
name_id_policies = root.findall(f"{{{NS_SAML_PROTOCOL}}}:NameIDPolicy")
|
||||
if len(name_id_policies) > 0:
|
||||
name_id_policy = name_id_policies[0]
|
||||
auth_n_request.name_id_policy = name_id_policy.attrib["Format"]
|
||||
|
||||
return auth_n_request
|
||||
|
||||
def parse(self, saml_request: str, relay_state: Optional[str]) -> AuthNRequest:
|
||||
"""Validate and parse raw request with enveloped signautre."""
|
||||
decoded_xml = decode_base64_and_inflate(saml_request)
|
||||
|
||||
if self.provider.signing_kp:
|
||||
try:
|
||||
XMLVerifier().verify(
|
||||
decoded_xml, x509_cert=self.provider.signing_kp.certificate_data
|
||||
)
|
||||
except InvalidSignature as exc:
|
||||
raise CannotHandleAssertion("Failed to verify signature") from exc
|
||||
|
||||
return self._parse_xml(decoded_xml, relay_state)
|
||||
|
||||
def parse_detached(
|
||||
self,
|
||||
saml_request: str,
|
||||
relay_state: Optional[str],
|
||||
signature: Optional[str] = None,
|
||||
sig_alg: Optional[str] = None,
|
||||
) -> AuthNRequest:
|
||||
"""Validate and parse raw request with detached signature"""
|
||||
decoded_xml = decode_base64_and_inflate(saml_request)
|
||||
|
||||
if signature and sig_alg:
|
||||
# if sig_alg == "http://www.w3.org/2000/09/xmldsig#rsa-sha1":
|
||||
sig_hash = hashes.SHA1() # nosec
|
||||
|
||||
querystring = f"SAMLRequest={quote_plus(saml_request)}&"
|
||||
if relay_state is not None:
|
||||
querystring += f"RelayState={quote_plus(relay_state)}&"
|
||||
querystring += f"SigAlg={sig_alg}"
|
||||
|
||||
public_key = self.provider.signing_kp.private_key.public_key()
|
||||
try:
|
||||
public_key.verify(
|
||||
b64decode(signature),
|
||||
querystring.encode(),
|
||||
padding.PSS(
|
||||
mgf=padding.MGF1(sig_hash), salt_length=padding.PSS.MAX_LENGTH
|
||||
),
|
||||
sig_hash,
|
||||
)
|
||||
except InvalidSignature as exc:
|
||||
raise CannotHandleAssertion("Failed to verify signature") from exc
|
||||
return self._parse_xml(decoded_xml, relay_state)
|
||||
|
||||
def idp_initiated(self) -> AuthNRequest:
|
||||
"""Create IdP Initiated AuthNRequest"""
|
||||
return AuthNRequest()
|
||||
@ -1,16 +0,0 @@
|
||||
"""Salesforce Processor"""
|
||||
|
||||
from passbook.providers.saml.processors.generic import GenericProcessor
|
||||
from passbook.providers.saml.utils.xml_render import get_assertion_xml
|
||||
|
||||
|
||||
class SalesForceProcessor(GenericProcessor):
|
||||
"""SalesForce.com-specific SAML 2.0 AuthnRequest to Response Handler Processor."""
|
||||
|
||||
def _format_assertion(self):
|
||||
super()._format_assertion()
|
||||
self._assertion_xml = get_assertion_xml(
|
||||
"providers/saml/xml/assertions/salesforce.xml",
|
||||
self._assertion_params,
|
||||
signed=True,
|
||||
)
|
||||
@ -1,11 +0,0 @@
|
||||
"""passbook saml provider types"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SAMLResponseParams:
|
||||
"""Class to keep track of SAML Response Parameters"""
|
||||
|
||||
acs_url: str
|
||||
saml_response: str
|
||||
relay_state: str
|
||||
@ -1,29 +0,0 @@
|
||||
{% extends "login/base.html" %}
|
||||
|
||||
{% load passbook_utils %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block card_title %}
|
||||
{% blocktrans with app=application.name %}
|
||||
Redirecting to {{ app }}...
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<form method="POST" action="{{ url }}" autosubmit>
|
||||
{% csrf_token %}
|
||||
{% for key, value in attrs.items %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endfor %}
|
||||
<div class="pf-c-form__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 class="pf-c-form__group pf-m-action">
|
||||
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans 'Continue' %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user