Compare commits
69 Commits
version/0.
...
version/0.
| Author | SHA1 | Date | |
|---|---|---|---|
| c3e64df95b | |||
| d2bf2c8896 | |||
| f27b43507c | |||
| c1058c7438 | |||
| c37901feb9 | |||
| 44b815efae | |||
| 64a71a3663 | |||
| ae435f423e | |||
| 7aa89c6d4f | |||
| 7e9d7e5198 | |||
| 2be6cd70d9 | |||
| 2b9705b33c | |||
| 502e43085f | |||
| 40f1de3b11 | |||
| 899c5b63ea | |||
| e104c74761 | |||
| 5d46c1ea5a | |||
| 7d533889bc | |||
| d9c2b32cba | |||
| 6e4ce8dbaa | |||
| 03d58b439f | |||
| ea38da441b | |||
| bdaf0111c2 | |||
| 974c2ddb11 | |||
| 769ce1c642 | |||
| f294791d41 | |||
| 4ee22f8ec1 | |||
| 74d3cfbba0 | |||
| d278acb83b | |||
| 84da454612 | |||
| 52101007aa | |||
| dc57f433fd | |||
| 3d4c5b8f4e | |||
| e66424cc49 | |||
| 8fa83a8d08 | |||
| 397892b282 | |||
| 7be50c2574 | |||
| 2aad523596 | |||
| 6982b97eb0 | |||
| 3de879496d | |||
| 4e75118a43 | |||
| 52c4fb431f | |||
| d696d854ff | |||
| 6966c119a7 | |||
| 8cf5e647e3 | |||
| 99bc6241f6 | |||
| e5f837ebb7 | |||
| 9d93da3d45 | |||
| 9f6f18f9bb | |||
| 6458b1dbf8 | |||
| 1aff9afca6 | |||
| e0bc7d3932 | |||
| 9fd9b2611c | |||
| 6f3a1dfd08 | |||
| 464b2cce88 | |||
| 4eaa46e717 | |||
| 59e8dca499 | |||
| 945d5bfaf6 | |||
| dbcdab05ff | |||
| e2cc2843d8 | |||
| 241d59be8d | |||
| 74251a8883 | |||
| 585afd1bcd | |||
| 8358574484 | |||
| cbcdaaf532 | |||
| f99eaa85ac | |||
| 5007a6befe | |||
| 50c75087b8 | |||
| 438e4efd49 |
@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 0.10.5-stable
|
current_version = 0.10.7-stable
|
||||||
tag = True
|
tag = True
|
||||||
commit = True
|
commit = True
|
||||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
|
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
|
||||||
|
|||||||
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Logs**
|
||||||
|
Output of docker-compose logs or kubectl logs respectively
|
||||||
|
|
||||||
|
**Version and Deployment (please complete the following information):**
|
||||||
|
- passbook version: [e.g. 0.10.0-stable]
|
||||||
|
- Deployment: [e.g. docker-compose, helm]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
22
.github/dependabot.yml
vendored
Normal file
22
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: gomod
|
||||||
|
directory: "/proxy"
|
||||||
|
schedule:
|
||||||
|
interval: daily
|
||||||
|
time: "04:00"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: "/passbook/static/static"
|
||||||
|
schedule:
|
||||||
|
interval: daily
|
||||||
|
time: "04:00"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
- package-ecosystem: pip
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: daily
|
||||||
|
time: "04:00"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
assignees:
|
||||||
|
- BeryJu
|
||||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@ -18,11 +18,11 @@ jobs:
|
|||||||
- name: Building Docker Image
|
- name: Building Docker Image
|
||||||
run: docker build
|
run: docker build
|
||||||
--no-cache
|
--no-cache
|
||||||
-t beryju/passbook:0.10.5-stable
|
-t beryju/passbook:0.10.7-stable
|
||||||
-t beryju/passbook:latest
|
-t beryju/passbook:latest
|
||||||
-f Dockerfile .
|
-f Dockerfile .
|
||||||
- name: Push Docker Container to Registry (versioned)
|
- name: Push Docker Container to Registry (versioned)
|
||||||
run: docker push beryju/passbook:0.10.5-stable
|
run: docker push beryju/passbook:0.10.7-stable
|
||||||
- name: Push Docker Container to Registry (latest)
|
- name: Push Docker Container to Registry (latest)
|
||||||
run: docker push beryju/passbook:latest
|
run: docker push beryju/passbook:latest
|
||||||
build-proxy:
|
build-proxy:
|
||||||
@ -48,11 +48,11 @@ jobs:
|
|||||||
cd proxy
|
cd proxy
|
||||||
docker build \
|
docker build \
|
||||||
--no-cache \
|
--no-cache \
|
||||||
-t beryju/passbook-proxy:0.10.5-stable \
|
-t beryju/passbook-proxy:0.10.7-stable \
|
||||||
-t beryju/passbook-proxy:latest \
|
-t beryju/passbook-proxy:latest \
|
||||||
-f Dockerfile .
|
-f Dockerfile .
|
||||||
- name: Push Docker Container to Registry (versioned)
|
- name: Push Docker Container to Registry (versioned)
|
||||||
run: docker push beryju/passbook-proxy:0.10.5-stable
|
run: docker push beryju/passbook-proxy:0.10.7-stable
|
||||||
- name: Push Docker Container to Registry (latest)
|
- name: Push Docker Container to Registry (latest)
|
||||||
run: docker push beryju/passbook-proxy:latest
|
run: docker push beryju/passbook-proxy:latest
|
||||||
build-static:
|
build-static:
|
||||||
@ -77,11 +77,11 @@ jobs:
|
|||||||
run: docker build
|
run: docker build
|
||||||
--no-cache
|
--no-cache
|
||||||
--network=$(docker network ls | grep github | awk '{print $1}')
|
--network=$(docker network ls | grep github | awk '{print $1}')
|
||||||
-t beryju/passbook-static:0.10.5-stable
|
-t beryju/passbook-static:0.10.7-stable
|
||||||
-t beryju/passbook-static:latest
|
-t beryju/passbook-static:latest
|
||||||
-f static.Dockerfile .
|
-f static.Dockerfile .
|
||||||
- name: Push Docker Container to Registry (versioned)
|
- name: Push Docker Container to Registry (versioned)
|
||||||
run: docker push beryju/passbook-static:0.10.5-stable
|
run: docker push beryju/passbook-static:0.10.7-stable
|
||||||
- name: Push Docker Container to Registry (latest)
|
- name: Push Docker Container to Registry (latest)
|
||||||
run: docker push beryju/passbook-static:latest
|
run: docker push beryju/passbook-static:latest
|
||||||
test-release:
|
test-release:
|
||||||
@ -114,5 +114,5 @@ jobs:
|
|||||||
SENTRY_PROJECT: passbook
|
SENTRY_PROJECT: passbook
|
||||||
SENTRY_URL: https://sentry.beryju.org
|
SENTRY_URL: https://sentry.beryju.org
|
||||||
with:
|
with:
|
||||||
tagName: 0.10.5-stable
|
tagName: 0.10.7-stable
|
||||||
environment: beryjuorg-prod
|
environment: beryjuorg-prod
|
||||||
|
|||||||
8
Makefile
8
Makefile
@ -8,12 +8,12 @@ coverage:
|
|||||||
|
|
||||||
lint-fix:
|
lint-fix:
|
||||||
isort -rc .
|
isort -rc .
|
||||||
black .
|
black passbook e2e lifecycle
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
pyright
|
pyright pyright e2e lifecycle
|
||||||
bandit -r .
|
bandit -r passbook e2e lifecycle
|
||||||
pylint passbook
|
pylint passbook e2e lifecycle
|
||||||
prospector
|
prospector
|
||||||
|
|
||||||
gen: coverage
|
gen: coverage
|
||||||
|
|||||||
90
Pipfile.lock
generated
90
Pipfile.lock
generated
@ -25,10 +25,10 @@
|
|||||||
},
|
},
|
||||||
"amqp": {
|
"amqp": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:70cdb10628468ff14e57ec2f751c7aa9e48e7e3651cfd62d431213c0c4e58f21",
|
"sha256:9881f8e6fe23e3db9faa6cfd8c05390213e1d1b95c0162bc50552cad75bffa5f",
|
||||||
"sha256:aa7f313fb887c91f15474c1229907a04dac0b8135822d6603437803424c0aa59"
|
"sha256:a8fb8151eb9d12204c9f1784c0da920476077609fa0a70f2468001e3a4258484"
|
||||||
],
|
],
|
||||||
"version": "==2.6.1"
|
"version": "==5.0.1"
|
||||||
},
|
},
|
||||||
"asgiref": {
|
"asgiref": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@ -74,17 +74,18 @@
|
|||||||
},
|
},
|
||||||
"boto3": {
|
"boto3": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:44073b1b1823ffc9edcf9027afbca908dad6bd5000f512ca73f929f6a604ae24"
|
"sha256:0c464a7de522f88b581ca0d41ffa71e9be5e17fbb0456c275421f65b7c5f6a55",
|
||||||
|
"sha256:0fce548e19d6db8e11fd0e2ae7809e1e3282080636b4062b2452bfa20e4f0233"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==1.15.1"
|
"version": "==1.15.5"
|
||||||
},
|
},
|
||||||
"botocore": {
|
"botocore": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:6bdf60281c2e80360fe904851a1a07df3dcfe066fe88dc7fba2b5e626ac05c8c",
|
"sha256:7ce7a05b98ffb3170396960273383e8aade9be6026d5a762f5f40969d5d6b761",
|
||||||
"sha256:d6bdf51c8880aa9974e6b61d2f7d9d1debe407287e2e9e60f36c789fe8ba6790"
|
"sha256:e3bf44fba058f6df16006b94a67650418a080a525c82521abb3cb516a4cba362"
|
||||||
],
|
],
|
||||||
"version": "==1.18.1"
|
"version": "==1.18.5"
|
||||||
},
|
},
|
||||||
"cachetools": {
|
"cachetools": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@ -95,11 +96,11 @@
|
|||||||
},
|
},
|
||||||
"celery": {
|
"celery": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:a92e1d56e650781fb747032a3997d16236d037c8199eacd5217d1a72893bca45",
|
"sha256:313930fddde703d8e37029a304bf91429cd11aeef63c57de6daca9d958e1f255",
|
||||||
"sha256:d220b13a8ed57c78149acf82c006785356071844afe0b27012a4991d44026f9f"
|
"sha256:72138dc3887f68dc58e1a2397e477256f80f1894c69fa4337f8ed70be460375b"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==4.4.7"
|
"version": "==5.0.0"
|
||||||
},
|
},
|
||||||
"certifi": {
|
"certifi": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@ -179,6 +180,19 @@
|
|||||||
],
|
],
|
||||||
"version": "==7.1.2"
|
"version": "==7.1.2"
|
||||||
},
|
},
|
||||||
|
"click-didyoumean": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:112229485c9704ff51362fe34b2d4f0b12fc71cc20f6d2b3afabed4b8bfa6aeb"
|
||||||
|
],
|
||||||
|
"version": "==0.0.3"
|
||||||
|
},
|
||||||
|
"click-repl": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:9c4c3d022789cae912aad8a3f5e1d7c2cdd016ee1225b5212ad3e8691563cda5",
|
||||||
|
"sha256:b9f29d52abc4d6059f8e276132a111ab8d94980afe6a5432b9d996544afa95d5"
|
||||||
|
],
|
||||||
|
"version": "==0.1.6"
|
||||||
|
},
|
||||||
"constantly": {
|
"constantly": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35",
|
"sha256:586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35",
|
||||||
@ -386,10 +400,10 @@
|
|||||||
},
|
},
|
||||||
"google-auth": {
|
"google-auth": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:7084c50c03f7a8a5696ef4500e65df0c525a0f6909f3c70b9ee65900a230c755",
|
"sha256:31941bf019fb242c04d0de32845da10180788bfddb0de87d78c4bdf55555dda1",
|
||||||
"sha256:dcf86c5adc3a8a7659be190b12bb8912ae019cfd9ee2a571ea881e289fafbe39"
|
"sha256:873051a6317294b083795cffc467bcd05b6df483ef542bfe0069ddbfbac0a096"
|
||||||
],
|
],
|
||||||
"version": "==1.21.2"
|
"version": "==1.21.3"
|
||||||
},
|
},
|
||||||
"gunicorn": {
|
"gunicorn": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@ -533,10 +547,10 @@
|
|||||||
},
|
},
|
||||||
"kombu": {
|
"kombu": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a",
|
"sha256:6dc509178ac4269b0e66ab4881f70a2035c33d3a622e20585f965986a5182006",
|
||||||
"sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74"
|
"sha256:f4965fba0a4718d47d470beeb5d6446e3357a62402b16c510b6a2f251e05ac3c"
|
||||||
],
|
],
|
||||||
"version": "==4.6.11"
|
"version": "==5.0.2"
|
||||||
},
|
},
|
||||||
"kubernetes": {
|
"kubernetes": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@ -674,6 +688,13 @@
|
|||||||
],
|
],
|
||||||
"version": "==0.8.0"
|
"version": "==0.8.0"
|
||||||
},
|
},
|
||||||
|
"prompt-toolkit": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:822f4605f28f7d2ba6b0b09a31e25e140871e96364d1d377667b547bb3bf4489",
|
||||||
|
"sha256:83074ee28ad4ba6af190593d4d4c607ff525272a504eb159199b6dd9f950c950"
|
||||||
|
],
|
||||||
|
"version": "==3.0.7"
|
||||||
|
},
|
||||||
"psycopg2-binary": {
|
"psycopg2-binary": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c",
|
"sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c",
|
||||||
@ -748,20 +769,25 @@
|
|||||||
"sha256:54bdedd28476dea8a3cd86cb67c0df1f0e3d71cae8022354b0f879c41a3d27b2",
|
"sha256:54bdedd28476dea8a3cd86cb67c0df1f0e3d71cae8022354b0f879c41a3d27b2",
|
||||||
"sha256:55eb61aca2c883db770999f50d091ff7c14016f2769ad7bca3d9b75d1d7c1b68",
|
"sha256:55eb61aca2c883db770999f50d091ff7c14016f2769ad7bca3d9b75d1d7c1b68",
|
||||||
"sha256:6276478ada411aca97c0d5104916354b3d740d368407912722bd4d11aa9ee4c2",
|
"sha256:6276478ada411aca97c0d5104916354b3d740d368407912722bd4d11aa9ee4c2",
|
||||||
|
"sha256:663f8de2b3df2e744d6e1610506e0ea4e213bde906795953c1e82279c169f0a7",
|
||||||
"sha256:67dcad1b8b201308586a8ca2ffe89df1e4f731d5a4cdd0610cc4ea790351c739",
|
"sha256:67dcad1b8b201308586a8ca2ffe89df1e4f731d5a4cdd0610cc4ea790351c739",
|
||||||
"sha256:709b9f144d23e290b9863121d1ace14a72e01f66ea9c903fbdc690520dfdfcf0",
|
"sha256:709b9f144d23e290b9863121d1ace14a72e01f66ea9c903fbdc690520dfdfcf0",
|
||||||
"sha256:8063a712fba642f78d3c506b0896846601b6de7f5c3d534e388ad0cc07f5a149",
|
"sha256:8063a712fba642f78d3c506b0896846601b6de7f5c3d534e388ad0cc07f5a149",
|
||||||
"sha256:80d57177a0b7c14d4594c62bbb47fe2f6309ad3b0a34348a291d570925c97a82",
|
"sha256:80d57177a0b7c14d4594c62bbb47fe2f6309ad3b0a34348a291d570925c97a82",
|
||||||
|
"sha256:87006cf0d81505408f1ae4f55cf8a5d95a8e029a4793360720ae17c6500f7ecc",
|
||||||
|
"sha256:9f62d21bc693f3d7d444f17ed2ad7a913b4c37c15cd807895d013c39c0517dfd",
|
||||||
"sha256:a207231a52426de3ff20f5608f0687261a3329d97a036c51f7d4c606a6f30c23",
|
"sha256:a207231a52426de3ff20f5608f0687261a3329d97a036c51f7d4c606a6f30c23",
|
||||||
"sha256:abc2e126c9490e58a36a0f83516479e781d83adfb134576a5cbe5c6af2a3e93c",
|
"sha256:abc2e126c9490e58a36a0f83516479e781d83adfb134576a5cbe5c6af2a3e93c",
|
||||||
"sha256:b56638d58a3a4be13229c6a815cd448f9e3ce40c00880a5398471b42ee86f50e",
|
"sha256:b56638d58a3a4be13229c6a815cd448f9e3ce40c00880a5398471b42ee86f50e",
|
||||||
"sha256:bcd5b8416e73e4b0d48afba3704d8c826414764dafaed7a1a93c442188d90ccc",
|
"sha256:bcd5b8416e73e4b0d48afba3704d8c826414764dafaed7a1a93c442188d90ccc",
|
||||||
"sha256:bec2bcdf7c9ce7f04d718e51887f3b05dc5c1cfaf5d2c2e9065ecddd1b2f6c9a",
|
"sha256:bec2bcdf7c9ce7f04d718e51887f3b05dc5c1cfaf5d2c2e9065ecddd1b2f6c9a",
|
||||||
"sha256:c8bf40cf6e281a4378e25846924327e728a887e8bf0ee83b2604a0f4b61692e8",
|
"sha256:c8bf40cf6e281a4378e25846924327e728a887e8bf0ee83b2604a0f4b61692e8",
|
||||||
|
"sha256:cecbf67e81d6144a50dc615629772859463b2e4f815d0c082fa421db362f040e",
|
||||||
"sha256:d8074c8448cfd0705dfa71ca333277fce9786d0b9cac75d120545de6253f996a",
|
"sha256:d8074c8448cfd0705dfa71ca333277fce9786d0b9cac75d120545de6253f996a",
|
||||||
"sha256:dd302b6ae3965afeb5ef1b0d92486f986c0e65183cd7835973f0b593800590e6",
|
"sha256:dd302b6ae3965afeb5ef1b0d92486f986c0e65183cd7835973f0b593800590e6",
|
||||||
"sha256:de6e1cd75677423ff64712c337521e62e3a7a4fc84caabbd93207752e831a85a",
|
"sha256:de6e1cd75677423ff64712c337521e62e3a7a4fc84caabbd93207752e831a85a",
|
||||||
"sha256:ef39c98d9b8c0736d91937d193653e47c3b19ddf4fc3bccdc5e09aaa4b0c5d21",
|
"sha256:ef39c98d9b8c0736d91937d193653e47c3b19ddf4fc3bccdc5e09aaa4b0c5d21",
|
||||||
|
"sha256:f2e045224074d5664dc9cbabbf4f4d4d46f1ee90f24780e3a9a668fd096ff17f",
|
||||||
"sha256:f521178e5a991ffd04182ed08f552daca1affcb826aeda0e1945cd989a9d4345",
|
"sha256:f521178e5a991ffd04182ed08f552daca1affcb826aeda0e1945cd989a9d4345",
|
||||||
"sha256:f78a68c2c820e4731e510a2df3eef0322f24fde1781ced970bf497b6c7d92982",
|
"sha256:f78a68c2c820e4731e510a2df3eef0322f24fde1781ced970bf497b6c7d92982",
|
||||||
"sha256:fbe65d5cfe04ff2f7684160d50f5118bdefb01e3af4718eeb618bfed40f19d94"
|
"sha256:fbe65d5cfe04ff2f7684160d50f5118bdefb01e3af4718eeb618bfed40f19d94"
|
||||||
@ -778,12 +804,14 @@
|
|||||||
"sha256:2275a663c9e744ee4eace816ef2d446b3060554c5773a92fbc79b05bf47debda",
|
"sha256:2275a663c9e744ee4eace816ef2d446b3060554c5773a92fbc79b05bf47debda",
|
||||||
"sha256:2710fc8d83b3352b370db932b3710033b9d630b970ff5aaa3e7458b5336e3b32",
|
"sha256:2710fc8d83b3352b370db932b3710033b9d630b970ff5aaa3e7458b5336e3b32",
|
||||||
"sha256:35b9c9177a9fe7288b19dd41554c9c8ca1063deb426dd5a02e7e2a7416b6bd11",
|
"sha256:35b9c9177a9fe7288b19dd41554c9c8ca1063deb426dd5a02e7e2a7416b6bd11",
|
||||||
|
"sha256:3b23d63030819b7d9ac7db9360305fd1241e6870ca5b7e8d59fee4db4674a490",
|
||||||
"sha256:3caa32cf807422adf33c10c88c22e9e2e08b9d9d042f12e1e25fe23113dd618f",
|
"sha256:3caa32cf807422adf33c10c88c22e9e2e08b9d9d042f12e1e25fe23113dd618f",
|
||||||
"sha256:48cc2cfc251f04a6142badeb666d1ff49ca6fdfc303fd72579f62b768aaa52b9",
|
"sha256:48cc2cfc251f04a6142badeb666d1ff49ca6fdfc303fd72579f62b768aaa52b9",
|
||||||
"sha256:4ae6379350a09339109e9b6f419bb2c3f03d3e441f4b0f5b8ca699d47cc9ff7e",
|
"sha256:4ae6379350a09339109e9b6f419bb2c3f03d3e441f4b0f5b8ca699d47cc9ff7e",
|
||||||
"sha256:4e0b27697fa1621c6d3d3b4edeec723c2e841285de6a8d378c1962da77b349be",
|
"sha256:4e0b27697fa1621c6d3d3b4edeec723c2e841285de6a8d378c1962da77b349be",
|
||||||
"sha256:58e19560814dabf5d788b95a13f6b98279cf41a49b1e49ee6cf6c79a57adb4c9",
|
"sha256:58e19560814dabf5d788b95a13f6b98279cf41a49b1e49ee6cf6c79a57adb4c9",
|
||||||
"sha256:8044eae59301dd392fbb4a7c5d64e1aea8ef0be2540549807ecbe703d6233d68",
|
"sha256:8044eae59301dd392fbb4a7c5d64e1aea8ef0be2540549807ecbe703d6233d68",
|
||||||
|
"sha256:85c108b42e47d4073344ff61d4e019f1d95bb7725ca0fe87d0a2deb237c10e49",
|
||||||
"sha256:89be1bf55e50116fe7e493a7c0c483099770dd7f81b87ac8d04a43b1a203e259",
|
"sha256:89be1bf55e50116fe7e493a7c0c483099770dd7f81b87ac8d04a43b1a203e259",
|
||||||
"sha256:8fcdda24dddf47f716400d54fc7f75cadaaba1dd47cc127e59d752c9c0fc3c48",
|
"sha256:8fcdda24dddf47f716400d54fc7f75cadaaba1dd47cc127e59d752c9c0fc3c48",
|
||||||
"sha256:914fbb18e29c54585e6aa39d300385f90d0fa3b3cc02ed829b08f95c1acf60c2",
|
"sha256:914fbb18e29c54585e6aa39d300385f90d0fa3b3cc02ed829b08f95c1acf60c2",
|
||||||
@ -793,13 +821,16 @@
|
|||||||
"sha256:a2ee8ba99d33e1a434fcd27d7d0aa7964163efeee0730fe2efc9d60edae1fc71",
|
"sha256:a2ee8ba99d33e1a434fcd27d7d0aa7964163efeee0730fe2efc9d60edae1fc71",
|
||||||
"sha256:b2d756620078570d3f940c84bc94dd30aa362b795cce8b2723300a8800b87f1c",
|
"sha256:b2d756620078570d3f940c84bc94dd30aa362b795cce8b2723300a8800b87f1c",
|
||||||
"sha256:c0d085c8187a1e4d3402f626c9e438b5861151ab132d8761d9c5ce6491a87761",
|
"sha256:c0d085c8187a1e4d3402f626c9e438b5861151ab132d8761d9c5ce6491a87761",
|
||||||
|
"sha256:c315262e26d54a9684e323e37ac9254f481d57fcc4fd94002992460898ef5c04",
|
||||||
"sha256:c990f2c58f7c67688e9e86e6557ed05952669ff6f1343e77b459007d85f7df00",
|
"sha256:c990f2c58f7c67688e9e86e6557ed05952669ff6f1343e77b459007d85f7df00",
|
||||||
"sha256:ccbbec59bf4b74226170c54476da5780c9176bae084878fc94d9a2c841218e34",
|
"sha256:ccbbec59bf4b74226170c54476da5780c9176bae084878fc94d9a2c841218e34",
|
||||||
"sha256:dc2bed32c7b138f1331794e454a953360c8cedf3ee62ae31f063822da6007489",
|
"sha256:dc2bed32c7b138f1331794e454a953360c8cedf3ee62ae31f063822da6007489",
|
||||||
|
"sha256:ddb1ae2891c8cb83a25da87a3e00111a9654fc5f0b70f18879c41aece45d6182",
|
||||||
"sha256:e070a1f91202ed34c396be5ea842b886f6fa2b90d2db437dc9fb35a26c80c060",
|
"sha256:e070a1f91202ed34c396be5ea842b886f6fa2b90d2db437dc9fb35a26c80c060",
|
||||||
"sha256:e42860fbe1292668b682f6dabd225fbe2a7a4fa1632f0c39881c019e93dea594",
|
"sha256:e42860fbe1292668b682f6dabd225fbe2a7a4fa1632f0c39881c019e93dea594",
|
||||||
"sha256:e4e1c486bf226822c8dceac81d0ec59c0a2399dbd1b9e04f03c3efa3605db677",
|
"sha256:e4e1c486bf226822c8dceac81d0ec59c0a2399dbd1b9e04f03c3efa3605db677",
|
||||||
"sha256:ea4d4b58f9bc34e224ef4b4604a6be03d72ef1f8c486391f970205f6733dbc46",
|
"sha256:ea4d4b58f9bc34e224ef4b4604a6be03d72ef1f8c486391f970205f6733dbc46",
|
||||||
|
"sha256:f5bd6891380e0fb5467251daf22525644fdf6afd9ae8bc2fe065c78ea1882e0d",
|
||||||
"sha256:f60b3484ce4be04f5da3777c51c5140d3fe21cdd6674f2b6568f41c8130bcdeb"
|
"sha256:f60b3484ce4be04f5da3777c51c5140d3fe21cdd6674f2b6568f41c8130bcdeb"
|
||||||
],
|
],
|
||||||
"version": "==3.9.8"
|
"version": "==3.9.8"
|
||||||
@ -951,11 +982,11 @@
|
|||||||
},
|
},
|
||||||
"sentry-sdk": {
|
"sentry-sdk": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1a086486ff9da15791f294f6e9915eb3747d161ef64dee2d038a4d0b4a369b24",
|
"sha256:c9c0fa1412bad87104c4eee8dd36c7bbf60b0d92ae917ab519094779b22e6d9a",
|
||||||
"sha256:45486deb031cea6bbb25a540d7adb4dd48cd8a1cc31e6a5ce9fb4f792a572e9a"
|
"sha256:e159f7c919d19ae86e5a4ff370fccc45149fab461fbeb93fb5a735a0b33a9cb1"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==0.17.6"
|
"version": "==0.17.8"
|
||||||
},
|
},
|
||||||
"service-identity": {
|
"service-identity": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@ -1085,10 +1116,17 @@
|
|||||||
},
|
},
|
||||||
"vine": {
|
"vine": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87",
|
"sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30",
|
||||||
"sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af"
|
"sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e"
|
||||||
],
|
],
|
||||||
"version": "==1.3.0"
|
"version": "==5.0.0"
|
||||||
|
},
|
||||||
|
"wcwidth": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784",
|
||||||
|
"sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"
|
||||||
|
],
|
||||||
|
"version": "==0.2.5"
|
||||||
},
|
},
|
||||||
"websocket-client": {
|
"websocket-client": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@ -1316,11 +1354,11 @@
|
|||||||
},
|
},
|
||||||
"django-debug-toolbar": {
|
"django-debug-toolbar": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:eabbefe89881bbe4ca7c980ff102e3c35c8e8ad6eb725041f538988f2f39a943",
|
"sha256:a1ce0665f7ef47d27b8df4b5d1058748e1f08500a01421a30d35164f38aaaf4c",
|
||||||
"sha256:ff94725e7aae74b133d0599b9bf89bd4eb8f5d2c964106e61d11750228c8774c"
|
"sha256:c97921a9cd421d392e7860dc4b464db8e06c8628df4dc58fedab012888c293c6"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.2"
|
"version": "==3.1.1"
|
||||||
},
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|||||||
@ -8,6 +8,10 @@ variables:
|
|||||||
POSTGRES_DB: passbook
|
POSTGRES_DB: passbook
|
||||||
POSTGRES_USER: passbook
|
POSTGRES_USER: passbook
|
||||||
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
||||||
|
${{ if startsWith(variables['Build.SourceBranch'], 'refs/heads/') }}:
|
||||||
|
branchName: ${{ replace(variables['Build.SourceBranchName'], 'refs/heads/', '') }}
|
||||||
|
${{ if startsWith(variables['Build.SourceBranch'], 'refs/pull/') }}:
|
||||||
|
branchName: ${{ replace(variables['System.PullRequest.SourceBranch'], 'refs/heads/', '') }}
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- stage: Lint
|
- stage: Lint
|
||||||
@ -26,7 +30,7 @@ stages:
|
|||||||
pipenv install --dev
|
pipenv install --dev
|
||||||
- task: CmdLine@2
|
- task: CmdLine@2
|
||||||
inputs:
|
inputs:
|
||||||
script: pipenv run pylint passbook
|
script: pipenv run pylint passbook e2e lifecycle
|
||||||
- job: black
|
- job: black
|
||||||
pool:
|
pool:
|
||||||
vmImage: 'ubuntu-latest'
|
vmImage: 'ubuntu-latest'
|
||||||
@ -41,7 +45,7 @@ stages:
|
|||||||
pipenv install --dev
|
pipenv install --dev
|
||||||
- task: CmdLine@2
|
- task: CmdLine@2
|
||||||
inputs:
|
inputs:
|
||||||
script: pipenv run black --check passbook
|
script: pipenv run black --check passbook e2e lifecycle
|
||||||
- job: prospector
|
- job: prospector
|
||||||
pool:
|
pool:
|
||||||
vmImage: 'ubuntu-latest'
|
vmImage: 'ubuntu-latest'
|
||||||
@ -57,7 +61,7 @@ stages:
|
|||||||
pipenv install --dev prospector --skip-lock
|
pipenv install --dev prospector --skip-lock
|
||||||
- task: CmdLine@2
|
- task: CmdLine@2
|
||||||
inputs:
|
inputs:
|
||||||
script: pipenv run prospector passbook
|
script: pipenv run prospector
|
||||||
- job: bandit
|
- job: bandit
|
||||||
pool:
|
pool:
|
||||||
vmImage: 'ubuntu-latest'
|
vmImage: 'ubuntu-latest'
|
||||||
@ -72,7 +76,7 @@ stages:
|
|||||||
pipenv install --dev
|
pipenv install --dev
|
||||||
- task: CmdLine@2
|
- task: CmdLine@2
|
||||||
inputs:
|
inputs:
|
||||||
script: pipenv run bandit -r passbook
|
script: pipenv run bandit -r passbook e2e lifecycle
|
||||||
- job: pyright
|
- job: pyright
|
||||||
pool:
|
pool:
|
||||||
vmImage: ubuntu-latest
|
vmImage: ubuntu-latest
|
||||||
@ -93,7 +97,7 @@ stages:
|
|||||||
pipenv install --dev
|
pipenv install --dev
|
||||||
- task: CmdLine@2
|
- task: CmdLine@2
|
||||||
inputs:
|
inputs:
|
||||||
script: pipenv run pyright
|
script: pipenv run pyright e2e lifecycle
|
||||||
- stage: Test
|
- stage: Test
|
||||||
jobs:
|
jobs:
|
||||||
- job: migrations
|
- job: migrations
|
||||||
@ -117,6 +121,41 @@ stages:
|
|||||||
- task: CmdLine@2
|
- task: CmdLine@2
|
||||||
inputs:
|
inputs:
|
||||||
script: pipenv run ./manage.py migrate
|
script: pipenv run ./manage.py migrate
|
||||||
|
- job: migrations_from_previous_release
|
||||||
|
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
|
||||||
|
displayName: Prepare Last tagged release
|
||||||
|
inputs:
|
||||||
|
script: |
|
||||||
|
git checkout $(git describe --abbrev=0 --match 'version/*')
|
||||||
|
sudo pip install -U wheel pipenv
|
||||||
|
pipenv install --dev
|
||||||
|
- task: CmdLine@2
|
||||||
|
displayName: Migrate to last tagged release
|
||||||
|
inputs:
|
||||||
|
script: pipenv run ./manage.py migrate
|
||||||
|
- task: CmdLine@2
|
||||||
|
displayName: Install current branch
|
||||||
|
inputs:
|
||||||
|
script: |
|
||||||
|
set -x
|
||||||
|
git checkout ${{ variables.branchName }}
|
||||||
|
pipenv sync --dev
|
||||||
|
- task: CmdLine@2
|
||||||
|
displayName: Migrate to current branch
|
||||||
|
inputs:
|
||||||
|
script: pipenv run ./manage.py migrate
|
||||||
- job: coverage_unittest
|
- job: coverage_unittest
|
||||||
pool:
|
pool:
|
||||||
vmImage: 'ubuntu-latest'
|
vmImage: 'ubuntu-latest'
|
||||||
@ -140,6 +179,9 @@ stages:
|
|||||||
inputs:
|
inputs:
|
||||||
script: |
|
script: |
|
||||||
pipenv run coverage run ./manage.py test passbook -v 3
|
pipenv run coverage run ./manage.py test passbook -v 3
|
||||||
|
- task: CmdLine@2
|
||||||
|
inputs:
|
||||||
|
script: |
|
||||||
mkdir output-unittest
|
mkdir output-unittest
|
||||||
mv unittest.xml output-unittest/unittest.xml
|
mv unittest.xml output-unittest/unittest.xml
|
||||||
mv .coverage output-unittest/coverage
|
mv .coverage output-unittest/coverage
|
||||||
@ -182,7 +224,7 @@ stages:
|
|||||||
displayName: Run full test suite
|
displayName: Run full test suite
|
||||||
inputs:
|
inputs:
|
||||||
script: |
|
script: |
|
||||||
pipenv run coverage run ./manage.py test e2e -v 3
|
pipenv run coverage run ./manage.py test e2e -v 3 --failfast
|
||||||
- task: CmdLine@2
|
- task: CmdLine@2
|
||||||
condition: always()
|
condition: always()
|
||||||
displayName: Cleanup
|
displayName: Cleanup
|
||||||
@ -265,7 +307,7 @@ stages:
|
|||||||
repository: 'beryju/passbook'
|
repository: 'beryju/passbook'
|
||||||
command: 'buildAndPush'
|
command: 'buildAndPush'
|
||||||
Dockerfile: 'Dockerfile'
|
Dockerfile: 'Dockerfile'
|
||||||
tags: 'gh-$(Build.SourceBranchName)'
|
tags: "gh-${{ variables.branchName }}"
|
||||||
- job: build_static
|
- job: build_static
|
||||||
pool:
|
pool:
|
||||||
vmImage: 'ubuntu-latest'
|
vmImage: 'ubuntu-latest'
|
||||||
@ -282,14 +324,14 @@ stages:
|
|||||||
repository: 'beryju/passbook-static'
|
repository: 'beryju/passbook-static'
|
||||||
command: 'build'
|
command: 'build'
|
||||||
Dockerfile: 'static.Dockerfile'
|
Dockerfile: 'static.Dockerfile'
|
||||||
tags: 'gh-$(Build.SourceBranchName)'
|
tags: "gh-${{ variables.branchName }}"
|
||||||
arguments: "--network=beryjupassbook_default"
|
arguments: "--network=beryjupassbook_default"
|
||||||
- task: Docker@2
|
- task: Docker@2
|
||||||
inputs:
|
inputs:
|
||||||
containerRegistry: 'dockerhub'
|
containerRegistry: 'dockerhub'
|
||||||
repository: 'beryju/passbook-static'
|
repository: 'beryju/passbook-static'
|
||||||
command: 'push'
|
command: 'push'
|
||||||
tags: 'gh-$(Build.SourceBranchName)'
|
tags: "gh-${{ variables.branchName }}"
|
||||||
- stage: Deploy
|
- stage: Deploy
|
||||||
jobs:
|
jobs:
|
||||||
- job: deploy_dev
|
- job: deploy_dev
|
||||||
|
|||||||
@ -23,7 +23,7 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
- traefik.enable=false
|
- traefik.enable=false
|
||||||
server:
|
server:
|
||||||
image: beryju/passbook:${PASSBOOK_TAG:-0.10.5-stable}
|
image: beryju/passbook:${PASSBOOK_TAG:-0.10.7-stable}
|
||||||
command: server
|
command: server
|
||||||
environment:
|
environment:
|
||||||
PASSBOOK_REDIS__HOST: redis
|
PASSBOOK_REDIS__HOST: redis
|
||||||
@ -41,7 +41,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
worker:
|
worker:
|
||||||
image: beryju/passbook:${PASSBOOK_TAG:-0.10.5-stable}
|
image: beryju/passbook:${PASSBOOK_TAG:-0.10.7-stable}
|
||||||
command: worker
|
command: worker
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
@ -55,7 +55,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
static:
|
static:
|
||||||
image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.5-stable}
|
image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.7-stable}
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@ -84,15 +84,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identifiers": {
|
|
||||||
"pk": "9922212c-47a2-475a-9905-abeb5e621652"
|
|
||||||
},
|
|
||||||
"model": "passbook_policies_expression.expressionpolicy",
|
|
||||||
"attrs": {
|
|
||||||
"name": "policy-enrollment-password-equals",
|
|
||||||
"expression": "# Verifies that the passwords are equal\r\nreturn request.context['password'] == request.context['password_repeat']"
|
|
||||||
}
|
|
||||||
},{
|
|
||||||
"identifiers": {
|
"identifiers": {
|
||||||
"pk": "096e6282-6b30-4695-bd03-3b143eab5580",
|
"pk": "096e6282-6b30-4695-bd03-3b143eab5580",
|
||||||
"name": "default-enrollment-email-verficiation"
|
"name": "default-enrollment-email-verficiation"
|
||||||
@ -135,9 +126,6 @@
|
|||||||
"cb954fd4-65a5-4ad9-b1ee-180ee9559cf4",
|
"cb954fd4-65a5-4ad9-b1ee-180ee9559cf4",
|
||||||
"7db91ee8-4290-4e08-8d39-63f132402515",
|
"7db91ee8-4290-4e08-8d39-63f132402515",
|
||||||
"d30b5eb4-7787-4072-b1ba-65b46e928920"
|
"d30b5eb4-7787-4072-b1ba-65b46e928920"
|
||||||
],
|
|
||||||
"validation_policies": [
|
|
||||||
"9922212c-47a2-475a-9905-abeb5e621652"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -55,16 +55,6 @@
|
|||||||
"order": 1
|
"order": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identifiers": {
|
|
||||||
"pk": "cd042fc6-cc92-4b98-b7e6-f4729df798d8"
|
|
||||||
},
|
|
||||||
"model": "passbook_policies_expression.expressionpolicy",
|
|
||||||
"attrs": {
|
|
||||||
"name": "default-password-change-password-equal",
|
|
||||||
"expression": "# Check that both passwords are equal.\nreturn request.context['password'] == request.context['password_repeat']"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identifiers": {
|
"identifiers": {
|
||||||
"pk": "e54045a7-6ecb-4ad9-ad37-28e72d8e565e",
|
"pk": "e54045a7-6ecb-4ad9-ad37-28e72d8e565e",
|
||||||
@ -118,9 +108,6 @@
|
|||||||
"fields": [
|
"fields": [
|
||||||
"7db91ee8-4290-4e08-8d39-63f132402515",
|
"7db91ee8-4290-4e08-8d39-63f132402515",
|
||||||
"d30b5eb4-7787-4072-b1ba-65b46e928920"
|
"d30b5eb4-7787-4072-b1ba-65b46e928920"
|
||||||
],
|
|
||||||
"validation_policies": [
|
|
||||||
"cd042fc6-cc92-4b98-b7e6-f4729df798d8"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -13,7 +13,7 @@ Download the latest `docker-compose.yml` from [here](https://raw.githubuserconte
|
|||||||
|
|
||||||
To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING__ENABLED=true >> .env`
|
To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING__ENABLED=true >> .env`
|
||||||
|
|
||||||
To optionally deploy a different version run `echo PASSBOOK_TAG=0.10.5-stable >> .env`
|
To optionally deploy a different version run `echo PASSBOOK_TAG=0.10.7-stable >> .env`
|
||||||
|
|
||||||
If this is a fresh passbook install run the following commands to generate a password:
|
If this is a fresh passbook install run the following commands to generate a password:
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ This installation automatically applies database migrations on startup. After th
|
|||||||
image:
|
image:
|
||||||
name: beryju/passbook
|
name: beryju/passbook
|
||||||
name_static: beryju/passbook-static
|
name_static: beryju/passbook-static
|
||||||
tag: 0.10.5-stable
|
tag: 0.10.7-stable
|
||||||
|
|
||||||
nameOverride: ""
|
nameOverride: ""
|
||||||
|
|
||||||
|
|||||||
@ -24,10 +24,49 @@ You can of course use a custom signing certificate, and adjust durations.
|
|||||||
|
|
||||||
Create a role with the permissions you desire, and note the ARN.
|
Create a role with the permissions you desire, and note the ARN.
|
||||||
|
|
||||||
AWS requires two custom PropertyMappings; `Role` and `RoleSessionName`. Create them as following:
|
After you've created the Property Mappings below, add them to the Provider.
|
||||||
|
|
||||||

|
Create an application, assign policies, and assign this provider.
|
||||||
|
|
||||||

|
Export the metadata from passbook, and create an Identity Provider [here](https://console.aws.amazon.com/iam/home#/providers).
|
||||||
|
|
||||||
Afterwards export the metadata from passbook, and create an Identity Provider [here](https://console.aws.amazon.com/iam/home#/providers).
|
#### Role Mapping
|
||||||
|
|
||||||
|
The Role mapping specifies the AWS ARN(s) of the identity provider, and the role the user should assume ([see](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_saml_assertions.html#saml_role-attribute)).
|
||||||
|
|
||||||
|
This Mapping needs to have the SAML Name field set to "https://aws.amazon.com/SAML/Attributes/Role"
|
||||||
|
|
||||||
|
As expression, you can return a static ARN like so
|
||||||
|
|
||||||
|
```python
|
||||||
|
return "arn:aws:iam::123412341234:role/saml_role,arn:aws:iam::123412341234:saml-provider/passbook"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, if you want to assign AWS Roles based on Group membership, you can add a custom attribute to the Groups, for example "aws_role", and use this snippet below. Groups are sorted by name and later groups overwrite earlier groups' attributes.
|
||||||
|
|
||||||
|
```python
|
||||||
|
role_name = user.group_attributes().get("aws_role", "")
|
||||||
|
return f"arn:aws:iam::123412341234:role/{role_name},arn:aws:iam::123412341234:saml-provider/passbook"
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to allow a user to choose from multiple roles, use this snippet
|
||||||
|
|
||||||
|
```python
|
||||||
|
return [
|
||||||
|
"arn:aws:iam::123412341234:role/role_a,arn:aws:iam::123412341234:saml-provider/passbook",
|
||||||
|
"arn:aws:iam::123412341234:role/role_b,arn:aws:iam::123412341234:saml-provider/passbook",
|
||||||
|
"arn:aws:iam::123412341234:role/role_c,arn:aws:iam::123412341234:saml-provider/passbook",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### RoleSessionName Mapping
|
||||||
|
|
||||||
|
The RoleSessionMapping specifies what identifier will be shown at the top of the Management Console ([see](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_saml_assertions.html#saml_role-session-attribute)).
|
||||||
|
|
||||||
|
This mapping needs to have the SAML Name field set to "https://aws.amazon.com/SAML/Attributes/RoleSessionName".
|
||||||
|
|
||||||
|
To use the user's username, use this snippet
|
||||||
|
|
||||||
|
```python
|
||||||
|
return user.username
|
||||||
|
```
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
@ -19,6 +19,7 @@ Create an application in passbook and note the slug, as this will be used later.
|
|||||||
- ACS URL: `https://gitlab.company/users/auth/saml/callback`
|
- ACS URL: `https://gitlab.company/users/auth/saml/callback`
|
||||||
- Audience: `https://gitlab.company`
|
- Audience: `https://gitlab.company`
|
||||||
- Issuer: `https://gitlab.company`
|
- Issuer: `https://gitlab.company`
|
||||||
|
- Binding: `Post`
|
||||||
|
|
||||||
You can of course use a custom signing certificate, and adjust durations. To get the value for `idp_cert_fingerprint`, you can use a tool like [this](https://www.samltool.com/fingerprint.php).
|
You can of course use a custom signing certificate, and adjust durations. To get the value for `idp_cert_fingerprint`, you can use a tool like [this](https://www.samltool.com/fingerprint.php).
|
||||||
|
|
||||||
@ -41,7 +42,7 @@ gitlab_rails['omniauth_providers'] = [
|
|||||||
args: {
|
args: {
|
||||||
assertion_consumer_service_url: 'https://gitlab.company/users/auth/saml/callback',
|
assertion_consumer_service_url: 'https://gitlab.company/users/auth/saml/callback',
|
||||||
idp_cert_fingerprint: '4E:1E:CD:67:4A:67:5A:E9:6A:D0:3C:E6:DD:7A:F2:44:2E:76:00:6A',
|
idp_cert_fingerprint: '4E:1E:CD:67:4A:67:5A:E9:6A:D0:3C:E6:DD:7A:F2:44:2E:76:00:6A',
|
||||||
idp_sso_target_url: 'https://passbook.company/application/saml/<passbook application slug>/login/',
|
idp_sso_target_url: 'https://passbook.company/application/saml/<passbook application slug>/sso/binding/post/',
|
||||||
issuer: 'https://gitlab.company',
|
issuer: 'https://gitlab.company',
|
||||||
name_identifier_format: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
name_identifier_format: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||||
attribute_statements: {
|
attribute_statements: {
|
||||||
|
|||||||
@ -27,4 +27,11 @@ return False
|
|||||||
- `request.context`: A dictionary with dynamic data. This depends on the origin of the execution.
|
- `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_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. Can be [compared](../expressions/index.md#comparing-ip-addresses)
|
- `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.
|
|
||||||
|
Additionally, when the policy is executed from a flow, every variable from the flow's current context is accessible under the `context` object.
|
||||||
|
|
||||||
|
This includes the following:
|
||||||
|
|
||||||
|
- `prompt_data`: Data which has been saved from a prompt stage or an external source.
|
||||||
|
- `application`: The application the user is in the process of authorizing.
|
||||||
|
- `pending_user`: The currently pending user
|
||||||
|
|||||||
@ -2,7 +2,7 @@ version: '3.7'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
chrome:
|
chrome:
|
||||||
image: selenium/standalone-chrome:3.141.59-20200525
|
image: selenium/standalone-chrome:3.141
|
||||||
volumes:
|
volumes:
|
||||||
- /dev/shm:/dev/shm
|
- /dev/shm:/dev/shm
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
|||||||
@ -2,7 +2,7 @@ version: '3.7'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
chrome:
|
chrome:
|
||||||
image: selenium/standalone-chrome-debug:3.141.59-20200719
|
image: selenium/standalone-chrome-debug:3.141
|
||||||
volumes:
|
volumes:
|
||||||
- /dev/shm:/dev/shm
|
- /dev/shm:/dev/shm
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
|||||||
@ -10,7 +10,6 @@ from selenium.webdriver.support import expected_conditions as ec
|
|||||||
|
|
||||||
from e2e.utils import USER, SeleniumTestCase
|
from e2e.utils import USER, SeleniumTestCase
|
||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||||
from passbook.policies.expression.models import ExpressionPolicy
|
|
||||||
from passbook.stages.email.models import EmailStage, EmailTemplates
|
from passbook.stages.email.models import EmailStage, EmailTemplates
|
||||||
from passbook.stages.identification.models import IdentificationStage
|
from passbook.stages.identification.models import IdentificationStage
|
||||||
from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage
|
from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage
|
||||||
@ -59,16 +58,9 @@ class TestFlowsEnroll(SeleniumTestCase):
|
|||||||
field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
|
field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
|
||||||
)
|
)
|
||||||
|
|
||||||
# Password checking policy
|
|
||||||
password_policy = ExpressionPolicy.objects.create(
|
|
||||||
name="policy-enrollment-password-equals",
|
|
||||||
expression="return request.context['password'] == request.context['password_repeat']",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Stages
|
# Stages
|
||||||
first_stage = PromptStage.objects.create(name="prompt-stage-first")
|
first_stage = PromptStage.objects.create(name="prompt-stage-first")
|
||||||
first_stage.fields.set([username_prompt, password, password_repeat])
|
first_stage.fields.set([username_prompt, password, password_repeat])
|
||||||
first_stage.validation_policies.set([password_policy])
|
|
||||||
first_stage.save()
|
first_stage.save()
|
||||||
second_stage = PromptStage.objects.create(name="prompt-stage-second")
|
second_stage = PromptStage.objects.create(name="prompt-stage-second")
|
||||||
second_stage.fields.set([name_field, email])
|
second_stage.fields.set([name_field, email])
|
||||||
@ -152,16 +144,9 @@ class TestFlowsEnroll(SeleniumTestCase):
|
|||||||
field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
|
field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
|
||||||
)
|
)
|
||||||
|
|
||||||
# Password checking policy
|
|
||||||
password_policy = ExpressionPolicy.objects.create(
|
|
||||||
name="policy-enrollment-password-equals",
|
|
||||||
expression="return request.context['password'] == request.context['password_repeat']",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Stages
|
# Stages
|
||||||
first_stage = PromptStage.objects.create(name="prompt-stage-first")
|
first_stage = PromptStage.objects.create(name="prompt-stage-first")
|
||||||
first_stage.fields.set([username_prompt, password, password_repeat])
|
first_stage.fields.set([username_prompt, password, password_repeat])
|
||||||
first_stage.validation_policies.set([password_policy])
|
|
||||||
first_stage.save()
|
first_stage.save()
|
||||||
second_stage = PromptStage.objects.create(name="prompt-stage-second")
|
second_stage = PromptStage.objects.create(name="prompt-stage-second")
|
||||||
second_stage.fields.set([name_field, email])
|
second_stage.fields.set([name_field, email])
|
||||||
|
|||||||
141
e2e/test_flows_otp.py
Normal file
141
e2e/test_flows_otp.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
"""test flow with otp stages"""
|
||||||
|
from base64 import b32decode
|
||||||
|
from sys import platform
|
||||||
|
from time import sleep
|
||||||
|
from unittest.case import skipUnless
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from django_otp.oath import TOTP
|
||||||
|
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
|
||||||
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
|
||||||
|
from e2e.utils import USER, SeleniumTestCase
|
||||||
|
from passbook.flows.models import Flow, FlowStageBinding
|
||||||
|
from passbook.stages.otp_validate.models import OTPValidateStage
|
||||||
|
|
||||||
|
|
||||||
|
@skipUnless(platform.startswith("linux"), "requires local docker")
|
||||||
|
class TestFlowsOTP(SeleniumTestCase):
|
||||||
|
"""test flow with otp stages"""
|
||||||
|
|
||||||
|
def test_otp_validate(self):
|
||||||
|
"""test flow with otp stages"""
|
||||||
|
sleep(1)
|
||||||
|
# Setup TOTP Device
|
||||||
|
user = USER()
|
||||||
|
device = TOTPDevice.objects.create(user=user, confirmed=True, digits=6)
|
||||||
|
|
||||||
|
flow: Flow = Flow.objects.get(slug="default-authentication-flow")
|
||||||
|
# Move the user_login stage to order 3
|
||||||
|
FlowStageBinding.objects.filter(target=flow, order=2).update(order=3)
|
||||||
|
FlowStageBinding.objects.create(
|
||||||
|
target=flow, order=2, stage=OTPValidateStage.objects.create()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.driver.get(f"{self.live_server_url}/flows/{flow.slug}/")
|
||||||
|
self.driver.find_element(By.ID, "id_uid_field").click()
|
||||||
|
self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username)
|
||||||
|
self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER)
|
||||||
|
self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
|
||||||
|
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
|
||||||
|
|
||||||
|
# Get expected token
|
||||||
|
totp = TOTP(device.bin_key, device.step, device.t0, device.digits, device.drift)
|
||||||
|
self.driver.find_element(By.ID, "id_code").send_keys(totp.token())
|
||||||
|
self.driver.find_element(By.ID, "id_code").send_keys(Keys.ENTER)
|
||||||
|
self.assertEqual(
|
||||||
|
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text,
|
||||||
|
USER().username,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_otp_totp_setup(self):
|
||||||
|
"""test TOTP Setup stage"""
|
||||||
|
flow: Flow = Flow.objects.get(slug="default-authentication-flow")
|
||||||
|
|
||||||
|
self.driver.get(f"{self.live_server_url}/flows/{flow.slug}/")
|
||||||
|
self.driver.find_element(By.ID, "id_uid_field").click()
|
||||||
|
self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username)
|
||||||
|
self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER)
|
||||||
|
self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
|
||||||
|
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
|
||||||
|
self.assertEqual(
|
||||||
|
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text,
|
||||||
|
USER().username,
|
||||||
|
)
|
||||||
|
|
||||||
|
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, "Time-based OTP").click()
|
||||||
|
|
||||||
|
# Remember the current URL as we should end up back here
|
||||||
|
destination_url = self.driver.current_url
|
||||||
|
|
||||||
|
self.driver.find_element(
|
||||||
|
By.CSS_SELECTOR, ".pf-c-card__body a.pf-c-button"
|
||||||
|
).click()
|
||||||
|
|
||||||
|
otp_uri = self.driver.find_element(
|
||||||
|
By.CSS_SELECTOR, "#flow-body > div > form > div:nth-child(3) > div"
|
||||||
|
).get_attribute("aria-label")
|
||||||
|
|
||||||
|
# Parse the OTP URI, extract the secret and get the next token
|
||||||
|
otp_args = urlparse(otp_uri)
|
||||||
|
self.assertEqual(otp_args.scheme, "otpauth")
|
||||||
|
otp_qs = parse_qs(otp_args.query)
|
||||||
|
secret_key = b32decode(otp_qs["secret"][0])
|
||||||
|
|
||||||
|
totp = TOTP(secret_key)
|
||||||
|
|
||||||
|
self.driver.find_element(By.ID, "id_code").send_keys(totp.token())
|
||||||
|
self.driver.find_element(By.ID, "id_code").send_keys(Keys.ENTER)
|
||||||
|
|
||||||
|
self.wait_for_url(destination_url)
|
||||||
|
sleep(1)
|
||||||
|
|
||||||
|
self.assertTrue(TOTPDevice.objects.filter(user=USER(), confirmed=True).exists())
|
||||||
|
|
||||||
|
def test_otp_static_setup(self):
|
||||||
|
"""test Static OTP Setup stage"""
|
||||||
|
flow: Flow = Flow.objects.get(slug="default-authentication-flow")
|
||||||
|
|
||||||
|
self.driver.get(f"{self.live_server_url}/flows/{flow.slug}/")
|
||||||
|
self.driver.find_element(By.ID, "id_uid_field").click()
|
||||||
|
self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username)
|
||||||
|
self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER)
|
||||||
|
self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
|
||||||
|
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
|
||||||
|
self.assertEqual(
|
||||||
|
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text,
|
||||||
|
USER().username,
|
||||||
|
)
|
||||||
|
|
||||||
|
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, "Static OTP").click()
|
||||||
|
|
||||||
|
# Remember the current URL as we should end up back here
|
||||||
|
destination_url = self.driver.current_url
|
||||||
|
|
||||||
|
self.driver.find_element(
|
||||||
|
By.CSS_SELECTOR, ".pf-c-card__body a.pf-c-button"
|
||||||
|
).click()
|
||||||
|
token = self.driver.find_element(
|
||||||
|
By.CSS_SELECTOR, ".pb-otp-tokens li:nth-child(1)"
|
||||||
|
).text
|
||||||
|
|
||||||
|
self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
|
||||||
|
|
||||||
|
self.wait_for_url(destination_url)
|
||||||
|
sleep(1)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
StaticDevice.objects.filter(user=USER(), confirmed=True).exists()
|
||||||
|
)
|
||||||
|
device = StaticDevice.objects.filter(user=USER(), confirmed=True).first()
|
||||||
|
self.assertTrue(StaticToken.objects.filter(token=token, device=device).exists())
|
||||||
@ -20,12 +20,12 @@ class TestFlowsStageSetup(SeleniumTestCase):
|
|||||||
"""test password change flow"""
|
"""test password change flow"""
|
||||||
# Ensure that password stage has change_flow set
|
# Ensure that password stage has change_flow set
|
||||||
flow = Flow.objects.get(
|
flow = Flow.objects.get(
|
||||||
slug="default-password-change", designation=FlowDesignation.STAGE_SETUP,
|
slug="default-password-change",
|
||||||
|
designation=FlowDesignation.STAGE_CONFIGURATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
stages = PasswordStage.objects.filter(name="default-authentication-password")
|
stage = PasswordStage.objects.get(name="default-authentication-password")
|
||||||
stage = stages.first()
|
stage.configure_flow = flow
|
||||||
stage.change_flow = flow
|
|
||||||
stage.save()
|
stage.save()
|
||||||
|
|
||||||
new_password = generate_client_secret()
|
new_password = generate_client_secret()
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""test OAuth Provider flow"""
|
"""test OAuth Provider flow"""
|
||||||
from sys import platform
|
from sys import platform
|
||||||
|
from time import sleep
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
from unittest.case import skipUnless
|
from unittest.case import skipUnless
|
||||||
|
|
||||||
@ -139,6 +140,8 @@ class TestProviderOAuth2Github(SeleniumTestCase):
|
|||||||
self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
|
self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
|
||||||
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
|
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
|
||||||
|
|
||||||
|
sleep(1)
|
||||||
|
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
app.name,
|
app.name,
|
||||||
self.driver.find_element(
|
self.driver.find_element(
|
||||||
|
|||||||
@ -16,16 +16,18 @@ from yaml import safe_dump
|
|||||||
|
|
||||||
from e2e.utils import SeleniumTestCase
|
from e2e.utils import SeleniumTestCase
|
||||||
from passbook.flows.models import Flow
|
from passbook.flows.models import Flow
|
||||||
from passbook.providers.oauth2.generators import generate_client_secret
|
from passbook.providers.oauth2.generators import (
|
||||||
|
generate_client_id,
|
||||||
|
generate_client_secret,
|
||||||
|
)
|
||||||
from passbook.sources.oauth.models import OAuthSource
|
from passbook.sources.oauth.models import OAuthSource
|
||||||
|
|
||||||
TOKEN_URL = "http://127.0.0.1:5556/dex/token"
|
|
||||||
CONFIG_PATH = "/tmp/dex.yml"
|
CONFIG_PATH = "/tmp/dex.yml"
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
@skipUnless(platform.startswith("linux"), "requires local docker")
|
@skipUnless(platform.startswith("linux"), "requires local docker")
|
||||||
class TestSourceOAuth(SeleniumTestCase):
|
class TestSourceOAuth2(SeleniumTestCase):
|
||||||
"""test OAuth Source flow"""
|
"""test OAuth Source flow"""
|
||||||
|
|
||||||
container: Container
|
container: Container
|
||||||
@ -91,14 +93,14 @@ class TestSourceOAuth(SeleniumTestCase):
|
|||||||
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
||||||
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
||||||
|
|
||||||
OAuthSource.objects.create(
|
OAuthSource.objects.create( # nosec
|
||||||
name="dex",
|
name="dex",
|
||||||
slug="dex",
|
slug="dex",
|
||||||
authentication_flow=authentication_flow,
|
authentication_flow=authentication_flow,
|
||||||
enrollment_flow=enrollment_flow,
|
enrollment_flow=enrollment_flow,
|
||||||
provider_type="openid-connect",
|
provider_type="openid-connect",
|
||||||
authorization_url="http://127.0.0.1:5556/dex/auth",
|
authorization_url="http://127.0.0.1:5556/dex/auth",
|
||||||
access_token_url=TOKEN_URL,
|
access_token_url="http://127.0.0.1:5556/dex/token",
|
||||||
profile_url="http://127.0.0.1:5556/dex/userinfo",
|
profile_url="http://127.0.0.1:5556/dex/userinfo",
|
||||||
consumer_key="example-app",
|
consumer_key="example-app",
|
||||||
consumer_secret=self.client_secret,
|
consumer_secret=self.client_secret,
|
||||||
@ -240,3 +242,99 @@ class TestSourceOAuth(SeleniumTestCase):
|
|||||||
self.driver.find_element(By.ID, "id_email").get_attribute("value"),
|
self.driver.find_element(By.ID, "id_email").get_attribute("value"),
|
||||||
"admin@example.com",
|
"admin@example.com",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@skipUnless(platform.startswith("linux"), "requires local docker")
|
||||||
|
class TestSourceOAuth1(SeleniumTestCase):
|
||||||
|
"""Test OAuth1 Source"""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.client_id = generate_client_id()
|
||||||
|
self.client_secret = generate_client_secret()
|
||||||
|
self.source_slug = "oauth1-test"
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
def get_container_specs(self) -> Optional[Dict[str, Any]]:
|
||||||
|
return {
|
||||||
|
"image": "beryju/oauth1-test-server",
|
||||||
|
"detach": True,
|
||||||
|
"network_mode": "host",
|
||||||
|
"auto_remove": True,
|
||||||
|
"environment": {
|
||||||
|
"OAUTH1_CLIENT_ID": self.client_id,
|
||||||
|
"OAUTH1_CLIENT_SECRET": self.client_secret,
|
||||||
|
"OAUTH1_REDIRECT_URI": (
|
||||||
|
self.url(
|
||||||
|
"passbook_sources_oauth:oauth-client-callback",
|
||||||
|
source_slug=self.source_slug,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_objects(self):
|
||||||
|
"""Create required objects"""
|
||||||
|
# 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( # nosec
|
||||||
|
name="oauth1",
|
||||||
|
slug=self.source_slug,
|
||||||
|
authentication_flow=authentication_flow,
|
||||||
|
enrollment_flow=enrollment_flow,
|
||||||
|
provider_type="twitter",
|
||||||
|
request_token_url="http://localhost:5000/oauth/request_token",
|
||||||
|
access_token_url="http://localhost:5000/oauth/access_token",
|
||||||
|
authorization_url="http://localhost:5000/oauth/authorize",
|
||||||
|
profile_url="http://localhost:5000/api/me",
|
||||||
|
consumer_key=self.client_id,
|
||||||
|
consumer_secret=self.client_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
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.NAME, "username")))
|
||||||
|
self.driver.find_element(By.NAME, "username").send_keys("example-user")
|
||||||
|
self.driver.find_element(By.NAME, "username").send_keys(Keys.ENTER)
|
||||||
|
|
||||||
|
# Wait until we're logged in
|
||||||
|
self.wait.until(
|
||||||
|
ec.presence_of_element_located((By.CSS_SELECTOR, "[name='confirm']"))
|
||||||
|
)
|
||||||
|
self.driver.find_element(By.CSS_SELECTOR, "[name='confirm']").click()
|
||||||
|
|
||||||
|
# Wait until we've loaded the user info page
|
||||||
|
self.wait.until(ec.presence_of_element_located((By.LINK_TEXT, "example-user")))
|
||||||
|
self.driver.find_element(By.LINK_TEXT, "example-user").click()
|
||||||
|
|
||||||
|
self.wait_for_url(self.url("passbook_core:user-settings"))
|
||||||
|
self.assertEqual(
|
||||||
|
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text,
|
||||||
|
"example-user",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.driver.find_element(By.ID, "id_username").get_attribute("value"),
|
||||||
|
"example-user",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.driver.find_element(By.ID, "id_name").get_attribute("value"),
|
||||||
|
"test name",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.driver.find_element(By.ID, "id_email").get_attribute("value"),
|
||||||
|
"foo@example.com",
|
||||||
|
)
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
"""passbook e2e testing utilities"""
|
"""passbook e2e testing utilities"""
|
||||||
from functools import lru_cache
|
|
||||||
from glob import glob
|
from glob import glob
|
||||||
from importlib.util import module_from_spec, spec_from_file_location
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
from inspect import getmembers, isfunction
|
from inspect import getmembers, isfunction
|
||||||
@ -23,7 +22,6 @@ from structlog import get_logger
|
|||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
def USER() -> User: # noqa
|
def USER() -> User: # noqa
|
||||||
"""Cached function that always returns pbadmin"""
|
"""Cached function that always returns pbadmin"""
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
appVersion: "0.10.5-stable"
|
appVersion: "0.10.7-stable"
|
||||||
description: A Helm chart for passbook.
|
description: A Helm chart for passbook.
|
||||||
name: passbook
|
name: passbook
|
||||||
version: "0.10.5-stable"
|
version: "0.10.7-stable"
|
||||||
icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg
|
icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg
|
||||||
dependencies:
|
dependencies:
|
||||||
- name: postgresql
|
- name: postgresql
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
image:
|
image:
|
||||||
name: beryju/passbook
|
name: beryju/passbook
|
||||||
name_static: beryju/passbook-static
|
name_static: beryju/passbook-static
|
||||||
tag: 0.10.5-stable
|
tag: 0.10.7-stable
|
||||||
|
|
||||||
nameOverride: ""
|
nameOverride: ""
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ printf '{"event": "Bootstrap completed", "level": "info", "logger": "bootstrap",
|
|||||||
if [[ "$1" == "server" ]]; then
|
if [[ "$1" == "server" ]]; then
|
||||||
gunicorn -c /lifecycle/gunicorn.conf.py passbook.root.asgi:application
|
gunicorn -c /lifecycle/gunicorn.conf.py passbook.root.asgi:application
|
||||||
elif [[ "$1" == "worker" ]]; then
|
elif [[ "$1" == "worker" ]]; then
|
||||||
celery worker --autoscale=10,3 -E -B -A=passbook.root.celery -s=/tmp/celerybeat-schedule -Q passbook,passbook_scheduled
|
celery -A passbook.root.celery worker --autoscale 10,3 -E -B -s /tmp/celerybeat-schedule -Q passbook,passbook_scheduled
|
||||||
elif [[ "$1" == "migrate" ]]; then
|
elif [[ "$1" == "migrate" ]]; then
|
||||||
# Run system migrations first, run normal migrations after
|
# Run system migrations first, run normal migrations after
|
||||||
python -m lifecycle.migrate
|
python -m lifecycle.migrate
|
||||||
|
|||||||
@ -1,16 +1,28 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""This file needs to be run from the root of the project to correctly
|
"""This file needs to be run from the root of the project to correctly
|
||||||
import passbook. This is done by the dockerfile."""
|
import passbook. This is done by the dockerfile."""
|
||||||
|
from json import dumps
|
||||||
|
from sys import stderr
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from psycopg2 import OperationalError, connect
|
from psycopg2 import OperationalError, connect
|
||||||
from redis import Redis
|
from redis import Redis
|
||||||
from redis.exceptions import RedisError
|
from redis.exceptions import RedisError
|
||||||
from structlog import get_logger
|
|
||||||
|
|
||||||
from passbook.lib.config import CONFIG
|
from passbook.lib.config import CONFIG
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
def j_print(event: str, log_level: str = "info", **kwargs):
|
||||||
|
"""Print event in the same format as structlog with JSON.
|
||||||
|
Used before structlog is configured."""
|
||||||
|
data = {
|
||||||
|
"event": event,
|
||||||
|
"level": log_level,
|
||||||
|
"logger": __name__,
|
||||||
|
}
|
||||||
|
data.update(**kwargs)
|
||||||
|
print(dumps(data), file=stderr)
|
||||||
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@ -24,7 +36,7 @@ while True:
|
|||||||
break
|
break
|
||||||
except OperationalError:
|
except OperationalError:
|
||||||
sleep(1)
|
sleep(1)
|
||||||
LOGGER.warning("PostgreSQL Connection failed, retrying...")
|
j_print("PostgreSQL Connection failed, retrying...")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@ -38,4 +50,4 @@ while True:
|
|||||||
break
|
break
|
||||||
except RedisError:
|
except RedisError:
|
||||||
sleep(1)
|
sleep(1)
|
||||||
LOGGER.warning("Redis Connection failed, retrying...")
|
j_print("Redis Connection failed, retrying...")
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
"""passbook"""
|
"""passbook"""
|
||||||
__version__ = "0.10.5-stable"
|
__version__ = "0.10.7-stable"
|
||||||
|
|||||||
10
passbook/admin/settings.py
Normal file
10
passbook/admin/settings.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"""passbook admin settings"""
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"admin_latest_version": {
|
||||||
|
"task": "passbook.admin.tasks.update_latest_version",
|
||||||
|
"schedule": crontab(minute=0), # Run every hour
|
||||||
|
"options": {"queue": "passbook_scheduled"},
|
||||||
|
}
|
||||||
|
}
|
||||||
23
passbook/admin/tasks.py
Normal file
23
passbook/admin/tasks.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"""passbook admin tasks"""
|
||||||
|
from django.core.cache import cache
|
||||||
|
from requests import RequestException, get
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from passbook.root.celery import CELERY_APP
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
VERSION_CACHE_KEY = "passbook_latest_version"
|
||||||
|
VERSION_CACHE_TIMEOUT = 2 * 60 * 60 # 2 hours
|
||||||
|
|
||||||
|
|
||||||
|
@CELERY_APP.task()
|
||||||
|
def update_latest_version():
|
||||||
|
"""Update latest version info"""
|
||||||
|
try:
|
||||||
|
data = get(
|
||||||
|
"https://api.github.com/repos/beryju/passbook/releases/latest"
|
||||||
|
).json()
|
||||||
|
tag_name = data.get("tag_name")
|
||||||
|
cache.set(VERSION_CACHE_KEY, tag_name.split("/")[1], VERSION_CACHE_TIMEOUT)
|
||||||
|
except (RequestException, IndexError):
|
||||||
|
cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
|
||||||
@ -9,24 +9,30 @@ from django.utils.translation import gettext as _
|
|||||||
from django.views.generic import ListView, UpdateView
|
from django.views.generic import ListView, UpdateView
|
||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||||
|
|
||||||
from passbook.admin.views.utils import DeleteMessageView
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
DeleteMessageView,
|
||||||
|
UserPaginateListMixin,
|
||||||
|
)
|
||||||
from passbook.core.forms.applications import ApplicationForm
|
from passbook.core.forms.applications import ApplicationForm
|
||||||
from passbook.core.models import Application
|
from passbook.core.models import Application
|
||||||
from passbook.lib.views import CreateAssignPermView
|
from passbook.lib.views import CreateAssignPermView
|
||||||
|
|
||||||
|
|
||||||
class ApplicationListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
class ApplicationListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView
|
||||||
|
):
|
||||||
"""Show list of all applications"""
|
"""Show list of all applications"""
|
||||||
|
|
||||||
model = Application
|
model = Application
|
||||||
permission_required = "passbook_core.view_application"
|
permission_required = "passbook_core.view_application"
|
||||||
ordering = "name"
|
ordering = "name"
|
||||||
paginate_by = 40
|
|
||||||
template_name = "administration/application/list.html"
|
template_name = "administration/application/list.html"
|
||||||
|
|
||||||
|
|
||||||
class ApplicationCreateView(
|
class ApplicationCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
CreateAssignPermView,
|
CreateAssignPermView,
|
||||||
@ -43,7 +49,11 @@ class ApplicationCreateView(
|
|||||||
|
|
||||||
|
|
||||||
class ApplicationUpdateView(
|
class ApplicationUpdateView(
|
||||||
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
LoginRequiredMixin,
|
||||||
|
PermissionRequiredMixin,
|
||||||
|
UpdateView,
|
||||||
):
|
):
|
||||||
"""Update application"""
|
"""Update application"""
|
||||||
|
|
||||||
|
|||||||
@ -9,24 +9,30 @@ from django.utils.translation import gettext as _
|
|||||||
from django.views.generic import ListView, UpdateView
|
from django.views.generic import ListView, UpdateView
|
||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||||
|
|
||||||
from passbook.admin.views.utils import DeleteMessageView
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
DeleteMessageView,
|
||||||
|
UserPaginateListMixin,
|
||||||
|
)
|
||||||
from passbook.crypto.forms import CertificateKeyPairForm
|
from passbook.crypto.forms import CertificateKeyPairForm
|
||||||
from passbook.crypto.models import CertificateKeyPair
|
from passbook.crypto.models import CertificateKeyPair
|
||||||
from passbook.lib.views import CreateAssignPermView
|
from passbook.lib.views import CreateAssignPermView
|
||||||
|
|
||||||
|
|
||||||
class CertificateKeyPairListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
class CertificateKeyPairListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView
|
||||||
|
):
|
||||||
"""Show list of all keypairs"""
|
"""Show list of all keypairs"""
|
||||||
|
|
||||||
model = CertificateKeyPair
|
model = CertificateKeyPair
|
||||||
permission_required = "passbook_crypto.view_certificatekeypair"
|
permission_required = "passbook_crypto.view_certificatekeypair"
|
||||||
ordering = "name"
|
ordering = "name"
|
||||||
paginate_by = 40
|
|
||||||
template_name = "administration/certificatekeypair/list.html"
|
template_name = "administration/certificatekeypair/list.html"
|
||||||
|
|
||||||
|
|
||||||
class CertificateKeyPairCreateView(
|
class CertificateKeyPairCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
CreateAssignPermView,
|
CreateAssignPermView,
|
||||||
@ -43,7 +49,11 @@ class CertificateKeyPairCreateView(
|
|||||||
|
|
||||||
|
|
||||||
class CertificateKeyPairUpdateView(
|
class CertificateKeyPairUpdateView(
|
||||||
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
LoginRequiredMixin,
|
||||||
|
PermissionRequiredMixin,
|
||||||
|
UpdateView,
|
||||||
):
|
):
|
||||||
"""Update certificatekeypair"""
|
"""Update certificatekeypair"""
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,11 @@ from django.utils.translation import gettext as _
|
|||||||
from django.views.generic import DetailView, FormView, ListView, UpdateView
|
from django.views.generic import DetailView, FormView, ListView, UpdateView
|
||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||||
|
|
||||||
from passbook.admin.views.utils import DeleteMessageView
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
DeleteMessageView,
|
||||||
|
UserPaginateListMixin,
|
||||||
|
)
|
||||||
from passbook.flows.forms import FlowForm, FlowImportForm
|
from passbook.flows.forms import FlowForm, FlowImportForm
|
||||||
from passbook.flows.models import Flow
|
from passbook.flows.models import Flow
|
||||||
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||||
@ -23,18 +27,20 @@ from passbook.lib.utils.urls import redirect_with_qs
|
|||||||
from passbook.lib.views import CreateAssignPermView
|
from passbook.lib.views import CreateAssignPermView
|
||||||
|
|
||||||
|
|
||||||
class FlowListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
class FlowListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView
|
||||||
|
):
|
||||||
"""Show list of all flows"""
|
"""Show list of all flows"""
|
||||||
|
|
||||||
model = Flow
|
model = Flow
|
||||||
permission_required = "passbook_flows.view_flow"
|
permission_required = "passbook_flows.view_flow"
|
||||||
ordering = "name"
|
ordering = "name"
|
||||||
paginate_by = 40
|
|
||||||
template_name = "administration/flow/list.html"
|
template_name = "administration/flow/list.html"
|
||||||
|
|
||||||
|
|
||||||
class FlowCreateView(
|
class FlowCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
CreateAssignPermView,
|
CreateAssignPermView,
|
||||||
@ -51,7 +57,11 @@ class FlowCreateView(
|
|||||||
|
|
||||||
|
|
||||||
class FlowUpdateView(
|
class FlowUpdateView(
|
||||||
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
LoginRequiredMixin,
|
||||||
|
PermissionRequiredMixin,
|
||||||
|
UpdateView,
|
||||||
):
|
):
|
||||||
"""Update flow"""
|
"""Update flow"""
|
||||||
|
|
||||||
|
|||||||
@ -9,24 +9,30 @@ from django.utils.translation import gettext as _
|
|||||||
from django.views.generic import ListView, UpdateView
|
from django.views.generic import ListView, UpdateView
|
||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||||
|
|
||||||
from passbook.admin.views.utils import DeleteMessageView
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
DeleteMessageView,
|
||||||
|
UserPaginateListMixin,
|
||||||
|
)
|
||||||
from passbook.core.forms.groups import GroupForm
|
from passbook.core.forms.groups import GroupForm
|
||||||
from passbook.core.models import Group
|
from passbook.core.models import Group
|
||||||
from passbook.lib.views import CreateAssignPermView
|
from passbook.lib.views import CreateAssignPermView
|
||||||
|
|
||||||
|
|
||||||
class GroupListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
class GroupListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView
|
||||||
|
):
|
||||||
"""Show list of all groups"""
|
"""Show list of all groups"""
|
||||||
|
|
||||||
model = Group
|
model = Group
|
||||||
permission_required = "passbook_core.view_group"
|
permission_required = "passbook_core.view_group"
|
||||||
ordering = "name"
|
ordering = "name"
|
||||||
paginate_by = 40
|
|
||||||
template_name = "administration/group/list.html"
|
template_name = "administration/group/list.html"
|
||||||
|
|
||||||
|
|
||||||
class GroupCreateView(
|
class GroupCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
CreateAssignPermView,
|
CreateAssignPermView,
|
||||||
@ -43,7 +49,11 @@ class GroupCreateView(
|
|||||||
|
|
||||||
|
|
||||||
class GroupUpdateView(
|
class GroupUpdateView(
|
||||||
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
LoginRequiredMixin,
|
||||||
|
PermissionRequiredMixin,
|
||||||
|
UpdateView,
|
||||||
):
|
):
|
||||||
"""Update group"""
|
"""Update group"""
|
||||||
|
|
||||||
|
|||||||
@ -12,24 +12,30 @@ from django.utils.translation import gettext as _
|
|||||||
from django.views.generic import ListView, UpdateView
|
from django.views.generic import ListView, UpdateView
|
||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||||
|
|
||||||
from passbook.admin.views.utils import DeleteMessageView
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
DeleteMessageView,
|
||||||
|
UserPaginateListMixin,
|
||||||
|
)
|
||||||
from passbook.lib.views import CreateAssignPermView
|
from passbook.lib.views import CreateAssignPermView
|
||||||
from passbook.outposts.forms import OutpostForm
|
from passbook.outposts.forms import OutpostForm
|
||||||
from passbook.outposts.models import Outpost, OutpostConfig
|
from passbook.outposts.models import Outpost, OutpostConfig
|
||||||
|
|
||||||
|
|
||||||
class OutpostListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
class OutpostListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView
|
||||||
|
):
|
||||||
"""Show list of all outposts"""
|
"""Show list of all outposts"""
|
||||||
|
|
||||||
model = Outpost
|
model = Outpost
|
||||||
permission_required = "passbook_outposts.view_outpost"
|
permission_required = "passbook_outposts.view_outpost"
|
||||||
ordering = "name"
|
ordering = "name"
|
||||||
paginate_by = 40
|
|
||||||
template_name = "administration/outpost/list.html"
|
template_name = "administration/outpost/list.html"
|
||||||
|
|
||||||
|
|
||||||
class OutpostCreateView(
|
class OutpostCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
CreateAssignPermView,
|
CreateAssignPermView,
|
||||||
@ -53,7 +59,11 @@ class OutpostCreateView(
|
|||||||
|
|
||||||
|
|
||||||
class OutpostUpdateView(
|
class OutpostUpdateView(
|
||||||
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
LoginRequiredMixin,
|
||||||
|
PermissionRequiredMixin,
|
||||||
|
UpdateView,
|
||||||
):
|
):
|
||||||
"""Update outpost"""
|
"""Update outpost"""
|
||||||
|
|
||||||
|
|||||||
@ -5,32 +5,16 @@ from django.core.cache import cache
|
|||||||
from django.shortcuts import redirect, reverse
|
from django.shortcuts import redirect, reverse
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from packaging.version import LegacyVersion, Version, parse
|
from packaging.version import LegacyVersion, Version, parse
|
||||||
from requests import RequestException, get
|
|
||||||
|
|
||||||
from passbook import __version__
|
from passbook import __version__
|
||||||
from passbook.admin.mixins import AdminRequiredMixin
|
from passbook.admin.mixins import AdminRequiredMixin
|
||||||
|
from passbook.admin.tasks import VERSION_CACHE_KEY, update_latest_version
|
||||||
from passbook.core.models import Application, Provider, Source, User
|
from passbook.core.models import Application, Provider, Source, User
|
||||||
from passbook.flows.models import Flow, Stage
|
from passbook.flows.models import Flow, Stage
|
||||||
from passbook.policies.models import Policy
|
from passbook.policies.models import Policy
|
||||||
from passbook.root.celery import CELERY_APP
|
from passbook.root.celery import CELERY_APP
|
||||||
from passbook.stages.invitation.models import Invitation
|
from passbook.stages.invitation.models import Invitation
|
||||||
|
|
||||||
VERSION_CACHE_KEY = "passbook_latest_version"
|
|
||||||
|
|
||||||
|
|
||||||
def latest_version() -> Union[LegacyVersion, Version]:
|
|
||||||
"""Get latest release from GitHub, cached"""
|
|
||||||
if not cache.get(VERSION_CACHE_KEY):
|
|
||||||
try:
|
|
||||||
data = get(
|
|
||||||
"https://api.github.com/repos/beryju/passbook/releases/latest"
|
|
||||||
).json()
|
|
||||||
tag_name = data.get("tag_name")
|
|
||||||
cache.set(VERSION_CACHE_KEY, tag_name.split("/")[1], 30)
|
|
||||||
except (RequestException, IndexError):
|
|
||||||
cache.set(VERSION_CACHE_KEY, "0.0.0", 30)
|
|
||||||
return parse(cache.get(VERSION_CACHE_KEY))
|
|
||||||
|
|
||||||
|
|
||||||
class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
|
class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
|
||||||
"""Overview View"""
|
"""Overview View"""
|
||||||
@ -44,6 +28,14 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
|
|||||||
return redirect(reverse("passbook_flows:default-authentication"))
|
return redirect(reverse("passbook_flows:default-authentication"))
|
||||||
return self.get(*args, **kwargs)
|
return self.get(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_latest_version(self) -> Union[LegacyVersion, Version]:
|
||||||
|
"""Get latest version from cache"""
|
||||||
|
version_in_cache = cache.get(VERSION_CACHE_KEY)
|
||||||
|
if not version_in_cache:
|
||||||
|
update_latest_version.delay()
|
||||||
|
return parse(__version__)
|
||||||
|
return parse(version_in_cache)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs["application_count"] = len(Application.objects.all())
|
kwargs["application_count"] = len(Application.objects.all())
|
||||||
kwargs["policy_count"] = len(Policy.objects.all())
|
kwargs["policy_count"] = len(Policy.objects.all())
|
||||||
@ -54,7 +46,7 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
|
|||||||
kwargs["flow_count"] = len(Flow.objects.all())
|
kwargs["flow_count"] = len(Flow.objects.all())
|
||||||
kwargs["invitation_count"] = len(Invitation.objects.all())
|
kwargs["invitation_count"] = len(Invitation.objects.all())
|
||||||
kwargs["version"] = parse(__version__)
|
kwargs["version"] = parse(__version__)
|
||||||
kwargs["version_latest"] = latest_version()
|
kwargs["version_latest"] = self.get_latest_version()
|
||||||
kwargs["worker_count"] = len(CELERY_APP.control.ping(timeout=0.5))
|
kwargs["worker_count"] = len(CELERY_APP.control.ping(timeout=0.5))
|
||||||
kwargs["providers_without_application"] = Provider.objects.filter(
|
kwargs["providers_without_application"] = Provider.objects.filter(
|
||||||
application=None
|
application=None
|
||||||
|
|||||||
@ -17,27 +17,31 @@ from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
|||||||
|
|
||||||
from passbook.admin.forms.policies import PolicyTestForm
|
from passbook.admin.forms.policies import PolicyTestForm
|
||||||
from passbook.admin.views.utils import (
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
DeleteMessageView,
|
DeleteMessageView,
|
||||||
InheritanceCreateView,
|
InheritanceCreateView,
|
||||||
InheritanceListView,
|
InheritanceListView,
|
||||||
InheritanceUpdateView,
|
InheritanceUpdateView,
|
||||||
|
UserPaginateListMixin,
|
||||||
)
|
)
|
||||||
from passbook.policies.models import Policy, PolicyBinding
|
from passbook.policies.models import Policy, PolicyBinding
|
||||||
from passbook.policies.process import PolicyProcess, PolicyRequest
|
from passbook.policies.process import PolicyProcess, PolicyRequest
|
||||||
|
|
||||||
|
|
||||||
class PolicyListView(LoginRequiredMixin, PermissionListMixin, InheritanceListView):
|
class PolicyListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, InheritanceListView
|
||||||
|
):
|
||||||
"""Show list of all policies"""
|
"""Show list of all policies"""
|
||||||
|
|
||||||
model = Policy
|
model = Policy
|
||||||
permission_required = "passbook_policies.view_policy"
|
permission_required = "passbook_policies.view_policy"
|
||||||
paginate_by = 10
|
|
||||||
ordering = "name"
|
ordering = "name"
|
||||||
template_name = "administration/policy/list.html"
|
template_name = "administration/policy/list.html"
|
||||||
|
|
||||||
|
|
||||||
class PolicyCreateView(
|
class PolicyCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
InheritanceCreateView,
|
InheritanceCreateView,
|
||||||
@ -54,6 +58,7 @@ class PolicyCreateView(
|
|||||||
|
|
||||||
class PolicyUpdateView(
|
class PolicyUpdateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
PermissionRequiredMixin,
|
PermissionRequiredMixin,
|
||||||
InheritanceUpdateView,
|
InheritanceUpdateView,
|
||||||
|
|||||||
@ -11,18 +11,23 @@ from django.views.generic import ListView, UpdateView
|
|||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
|
|
||||||
from passbook.admin.views.utils import DeleteMessageView
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
DeleteMessageView,
|
||||||
|
UserPaginateListMixin,
|
||||||
|
)
|
||||||
from passbook.lib.views import CreateAssignPermView
|
from passbook.lib.views import CreateAssignPermView
|
||||||
from passbook.policies.forms import PolicyBindingForm
|
from passbook.policies.forms import PolicyBindingForm
|
||||||
from passbook.policies.models import PolicyBinding
|
from passbook.policies.models import PolicyBinding
|
||||||
|
|
||||||
|
|
||||||
class PolicyBindingListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
class PolicyBindingListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView
|
||||||
|
):
|
||||||
"""Show list of all policies"""
|
"""Show list of all policies"""
|
||||||
|
|
||||||
model = PolicyBinding
|
model = PolicyBinding
|
||||||
permission_required = "passbook_policies.view_policybinding"
|
permission_required = "passbook_policies.view_policybinding"
|
||||||
paginate_by = 10
|
|
||||||
ordering = ["order", "target"]
|
ordering = ["order", "target"]
|
||||||
template_name = "administration/policy_binding/list.html"
|
template_name = "administration/policy_binding/list.html"
|
||||||
|
|
||||||
@ -47,6 +52,7 @@ class PolicyBindingListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
|||||||
|
|
||||||
class PolicyBindingCreateView(
|
class PolicyBindingCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
CreateAssignPermView,
|
CreateAssignPermView,
|
||||||
@ -63,7 +69,11 @@ class PolicyBindingCreateView(
|
|||||||
|
|
||||||
|
|
||||||
class PolicyBindingUpdateView(
|
class PolicyBindingUpdateView(
|
||||||
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
LoginRequiredMixin,
|
||||||
|
PermissionRequiredMixin,
|
||||||
|
UpdateView,
|
||||||
):
|
):
|
||||||
"""Update policybinding"""
|
"""Update policybinding"""
|
||||||
|
|
||||||
|
|||||||
@ -9,16 +9,18 @@ from django.utils.translation import gettext as _
|
|||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||||
|
|
||||||
from passbook.admin.views.utils import (
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
DeleteMessageView,
|
DeleteMessageView,
|
||||||
InheritanceCreateView,
|
InheritanceCreateView,
|
||||||
InheritanceListView,
|
InheritanceListView,
|
||||||
InheritanceUpdateView,
|
InheritanceUpdateView,
|
||||||
|
UserPaginateListMixin,
|
||||||
)
|
)
|
||||||
from passbook.core.models import PropertyMapping
|
from passbook.core.models import PropertyMapping
|
||||||
|
|
||||||
|
|
||||||
class PropertyMappingListView(
|
class PropertyMappingListView(
|
||||||
LoginRequiredMixin, PermissionListMixin, InheritanceListView
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, InheritanceListView
|
||||||
):
|
):
|
||||||
"""Show list of all property_mappings"""
|
"""Show list of all property_mappings"""
|
||||||
|
|
||||||
@ -26,11 +28,11 @@ class PropertyMappingListView(
|
|||||||
permission_required = "passbook_core.view_propertymapping"
|
permission_required = "passbook_core.view_propertymapping"
|
||||||
template_name = "administration/property_mapping/list.html"
|
template_name = "administration/property_mapping/list.html"
|
||||||
ordering = "name"
|
ordering = "name"
|
||||||
paginate_by = 40
|
|
||||||
|
|
||||||
|
|
||||||
class PropertyMappingCreateView(
|
class PropertyMappingCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
InheritanceCreateView,
|
InheritanceCreateView,
|
||||||
@ -47,6 +49,7 @@ class PropertyMappingCreateView(
|
|||||||
|
|
||||||
class PropertyMappingUpdateView(
|
class PropertyMappingUpdateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
PermissionRequiredMixin,
|
PermissionRequiredMixin,
|
||||||
InheritanceUpdateView,
|
InheritanceUpdateView,
|
||||||
|
|||||||
@ -9,26 +9,30 @@ from django.utils.translation import gettext as _
|
|||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||||
|
|
||||||
from passbook.admin.views.utils import (
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
DeleteMessageView,
|
DeleteMessageView,
|
||||||
InheritanceCreateView,
|
InheritanceCreateView,
|
||||||
InheritanceListView,
|
InheritanceListView,
|
||||||
InheritanceUpdateView,
|
InheritanceUpdateView,
|
||||||
|
UserPaginateListMixin,
|
||||||
)
|
)
|
||||||
from passbook.core.models import Provider
|
from passbook.core.models import Provider
|
||||||
|
|
||||||
|
|
||||||
class ProviderListView(LoginRequiredMixin, PermissionListMixin, InheritanceListView):
|
class ProviderListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, InheritanceListView
|
||||||
|
):
|
||||||
"""Show list of all providers"""
|
"""Show list of all providers"""
|
||||||
|
|
||||||
model = Provider
|
model = Provider
|
||||||
permission_required = "passbook_core.add_provider"
|
permission_required = "passbook_core.add_provider"
|
||||||
template_name = "administration/provider/list.html"
|
template_name = "administration/provider/list.html"
|
||||||
paginate_by = 10
|
|
||||||
ordering = "id"
|
ordering = "id"
|
||||||
|
|
||||||
|
|
||||||
class ProviderCreateView(
|
class ProviderCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
InheritanceCreateView,
|
InheritanceCreateView,
|
||||||
@ -45,6 +49,7 @@ class ProviderCreateView(
|
|||||||
|
|
||||||
class ProviderUpdateView(
|
class ProviderUpdateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
PermissionRequiredMixin,
|
PermissionRequiredMixin,
|
||||||
InheritanceUpdateView,
|
InheritanceUpdateView,
|
||||||
|
|||||||
@ -9,26 +9,30 @@ from django.utils.translation import gettext as _
|
|||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||||
|
|
||||||
from passbook.admin.views.utils import (
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
DeleteMessageView,
|
DeleteMessageView,
|
||||||
InheritanceCreateView,
|
InheritanceCreateView,
|
||||||
InheritanceListView,
|
InheritanceListView,
|
||||||
InheritanceUpdateView,
|
InheritanceUpdateView,
|
||||||
|
UserPaginateListMixin,
|
||||||
)
|
)
|
||||||
from passbook.core.models import Source
|
from passbook.core.models import Source
|
||||||
|
|
||||||
|
|
||||||
class SourceListView(LoginRequiredMixin, PermissionListMixin, InheritanceListView):
|
class SourceListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, InheritanceListView
|
||||||
|
):
|
||||||
"""Show list of all sources"""
|
"""Show list of all sources"""
|
||||||
|
|
||||||
model = Source
|
model = Source
|
||||||
permission_required = "passbook_core.view_source"
|
permission_required = "passbook_core.view_source"
|
||||||
ordering = "name"
|
ordering = "name"
|
||||||
paginate_by = 40
|
|
||||||
template_name = "administration/source/list.html"
|
template_name = "administration/source/list.html"
|
||||||
|
|
||||||
|
|
||||||
class SourceCreateView(
|
class SourceCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
InheritanceCreateView,
|
InheritanceCreateView,
|
||||||
@ -45,6 +49,7 @@ class SourceCreateView(
|
|||||||
|
|
||||||
class SourceUpdateView(
|
class SourceUpdateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
PermissionRequiredMixin,
|
PermissionRequiredMixin,
|
||||||
InheritanceUpdateView,
|
InheritanceUpdateView,
|
||||||
|
|||||||
@ -9,26 +9,30 @@ from django.utils.translation import gettext as _
|
|||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||||
|
|
||||||
from passbook.admin.views.utils import (
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
DeleteMessageView,
|
DeleteMessageView,
|
||||||
InheritanceCreateView,
|
InheritanceCreateView,
|
||||||
InheritanceListView,
|
InheritanceListView,
|
||||||
InheritanceUpdateView,
|
InheritanceUpdateView,
|
||||||
|
UserPaginateListMixin,
|
||||||
)
|
)
|
||||||
from passbook.flows.models import Stage
|
from passbook.flows.models import Stage
|
||||||
|
|
||||||
|
|
||||||
class StageListView(LoginRequiredMixin, PermissionListMixin, InheritanceListView):
|
class StageListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, InheritanceListView
|
||||||
|
):
|
||||||
"""Show list of all stages"""
|
"""Show list of all stages"""
|
||||||
|
|
||||||
model = Stage
|
model = Stage
|
||||||
template_name = "administration/stage/list.html"
|
template_name = "administration/stage/list.html"
|
||||||
permission_required = "passbook_flows.view_stage"
|
permission_required = "passbook_flows.view_stage"
|
||||||
ordering = "name"
|
ordering = "name"
|
||||||
paginate_by = 40
|
|
||||||
|
|
||||||
|
|
||||||
class StageCreateView(
|
class StageCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
InheritanceCreateView,
|
InheritanceCreateView,
|
||||||
@ -45,6 +49,7 @@ class StageCreateView(
|
|||||||
|
|
||||||
class StageUpdateView(
|
class StageUpdateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
PermissionRequiredMixin,
|
PermissionRequiredMixin,
|
||||||
InheritanceUpdateView,
|
InheritanceUpdateView,
|
||||||
|
|||||||
@ -9,24 +9,30 @@ from django.utils.translation import gettext as _
|
|||||||
from django.views.generic import ListView, UpdateView
|
from django.views.generic import ListView, UpdateView
|
||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||||
|
|
||||||
from passbook.admin.views.utils import DeleteMessageView
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
DeleteMessageView,
|
||||||
|
UserPaginateListMixin,
|
||||||
|
)
|
||||||
from passbook.flows.forms import FlowStageBindingForm
|
from passbook.flows.forms import FlowStageBindingForm
|
||||||
from passbook.flows.models import FlowStageBinding
|
from passbook.flows.models import FlowStageBinding
|
||||||
from passbook.lib.views import CreateAssignPermView
|
from passbook.lib.views import CreateAssignPermView
|
||||||
|
|
||||||
|
|
||||||
class StageBindingListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
class StageBindingListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView
|
||||||
|
):
|
||||||
"""Show list of all flows"""
|
"""Show list of all flows"""
|
||||||
|
|
||||||
model = FlowStageBinding
|
model = FlowStageBinding
|
||||||
permission_required = "passbook_flows.view_flowstagebinding"
|
permission_required = "passbook_flows.view_flowstagebinding"
|
||||||
paginate_by = 10
|
|
||||||
ordering = ["target", "order"]
|
ordering = ["target", "order"]
|
||||||
template_name = "administration/stage_binding/list.html"
|
template_name = "administration/stage_binding/list.html"
|
||||||
|
|
||||||
|
|
||||||
class StageBindingCreateView(
|
class StageBindingCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
CreateAssignPermView,
|
CreateAssignPermView,
|
||||||
@ -43,7 +49,11 @@ class StageBindingCreateView(
|
|||||||
|
|
||||||
|
|
||||||
class StageBindingUpdateView(
|
class StageBindingUpdateView(
|
||||||
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
LoginRequiredMixin,
|
||||||
|
PermissionRequiredMixin,
|
||||||
|
UpdateView,
|
||||||
):
|
):
|
||||||
"""Update FlowStageBinding"""
|
"""Update FlowStageBinding"""
|
||||||
|
|
||||||
|
|||||||
@ -10,25 +10,31 @@ from django.utils.translation import gettext as _
|
|||||||
from django.views.generic import ListView
|
from django.views.generic import ListView
|
||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||||
|
|
||||||
from passbook.admin.views.utils import DeleteMessageView
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
DeleteMessageView,
|
||||||
|
UserPaginateListMixin,
|
||||||
|
)
|
||||||
from passbook.lib.views import CreateAssignPermView
|
from passbook.lib.views import CreateAssignPermView
|
||||||
from passbook.stages.invitation.forms import InvitationForm
|
from passbook.stages.invitation.forms import InvitationForm
|
||||||
from passbook.stages.invitation.models import Invitation
|
from passbook.stages.invitation.models import Invitation
|
||||||
from passbook.stages.invitation.signals import invitation_created
|
from passbook.stages.invitation.signals import invitation_created
|
||||||
|
|
||||||
|
|
||||||
class InvitationListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
class InvitationListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView
|
||||||
|
):
|
||||||
"""Show list of all invitations"""
|
"""Show list of all invitations"""
|
||||||
|
|
||||||
model = Invitation
|
model = Invitation
|
||||||
permission_required = "passbook_stages_invitation.view_invitation"
|
permission_required = "passbook_stages_invitation.view_invitation"
|
||||||
template_name = "administration/stage_invitation/list.html"
|
template_name = "administration/stage_invitation/list.html"
|
||||||
paginate_by = 10
|
|
||||||
ordering = "-expires"
|
ordering = "-expires"
|
||||||
|
|
||||||
|
|
||||||
class InvitationCreateView(
|
class InvitationCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
CreateAssignPermView,
|
CreateAssignPermView,
|
||||||
|
|||||||
@ -9,24 +9,30 @@ from django.utils.translation import gettext as _
|
|||||||
from django.views.generic import ListView, UpdateView
|
from django.views.generic import ListView, UpdateView
|
||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||||
|
|
||||||
from passbook.admin.views.utils import DeleteMessageView
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
DeleteMessageView,
|
||||||
|
UserPaginateListMixin,
|
||||||
|
)
|
||||||
from passbook.lib.views import CreateAssignPermView
|
from passbook.lib.views import CreateAssignPermView
|
||||||
from passbook.stages.prompt.forms import PromptAdminForm
|
from passbook.stages.prompt.forms import PromptAdminForm
|
||||||
from passbook.stages.prompt.models import Prompt
|
from passbook.stages.prompt.models import Prompt
|
||||||
|
|
||||||
|
|
||||||
class PromptListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
class PromptListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView
|
||||||
|
):
|
||||||
"""Show list of all prompts"""
|
"""Show list of all prompts"""
|
||||||
|
|
||||||
model = Prompt
|
model = Prompt
|
||||||
permission_required = "passbook_stages_prompt.view_prompt"
|
permission_required = "passbook_stages_prompt.view_prompt"
|
||||||
ordering = "order"
|
ordering = "order"
|
||||||
paginate_by = 40
|
|
||||||
template_name = "administration/stage_prompt/list.html"
|
template_name = "administration/stage_prompt/list.html"
|
||||||
|
|
||||||
|
|
||||||
class PromptCreateView(
|
class PromptCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
CreateAssignPermView,
|
CreateAssignPermView,
|
||||||
@ -43,7 +49,11 @@ class PromptCreateView(
|
|||||||
|
|
||||||
|
|
||||||
class PromptUpdateView(
|
class PromptUpdateView(
|
||||||
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
LoginRequiredMixin,
|
||||||
|
PermissionRequiredMixin,
|
||||||
|
UpdateView,
|
||||||
):
|
):
|
||||||
"""Update prompt"""
|
"""Update prompt"""
|
||||||
|
|
||||||
|
|||||||
@ -5,17 +5,18 @@ from django.utils.translation import gettext as _
|
|||||||
from django.views.generic import ListView
|
from django.views.generic import ListView
|
||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||||
|
|
||||||
from passbook.admin.views.utils import DeleteMessageView
|
from passbook.admin.views.utils import DeleteMessageView, UserPaginateListMixin
|
||||||
from passbook.core.models import Token
|
from passbook.core.models import Token
|
||||||
|
|
||||||
|
|
||||||
class TokenListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
class TokenListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView
|
||||||
|
):
|
||||||
"""Show list of all tokens"""
|
"""Show list of all tokens"""
|
||||||
|
|
||||||
model = Token
|
model = Token
|
||||||
permission_required = "passbook_core.view_token"
|
permission_required = "passbook_core.view_token"
|
||||||
ordering = "expires"
|
ordering = "expires"
|
||||||
paginate_by = 40
|
|
||||||
template_name = "administration/token/list.html"
|
template_name = "administration/token/list.html"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -18,18 +18,23 @@ from guardian.mixins import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from passbook.admin.forms.users import UserForm
|
from passbook.admin.forms.users import UserForm
|
||||||
from passbook.admin.views.utils import DeleteMessageView
|
from passbook.admin.views.utils import (
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
DeleteMessageView,
|
||||||
|
UserPaginateListMixin,
|
||||||
|
)
|
||||||
from passbook.core.models import Token, User
|
from passbook.core.models import Token, User
|
||||||
from passbook.lib.views import CreateAssignPermView
|
from passbook.lib.views import CreateAssignPermView
|
||||||
|
|
||||||
|
|
||||||
class UserListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
class UserListView(
|
||||||
|
LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView
|
||||||
|
):
|
||||||
"""Show list of all users"""
|
"""Show list of all users"""
|
||||||
|
|
||||||
model = User
|
model = User
|
||||||
permission_required = "passbook_core.view_user"
|
permission_required = "passbook_core.view_user"
|
||||||
ordering = "username"
|
ordering = "username"
|
||||||
paginate_by = 40
|
|
||||||
template_name = "administration/user/list.html"
|
template_name = "administration/user/list.html"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
@ -38,6 +43,7 @@ class UserListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
|||||||
|
|
||||||
class UserCreateView(
|
class UserCreateView(
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
DjangoPermissionRequiredMixin,
|
DjangoPermissionRequiredMixin,
|
||||||
CreateAssignPermView,
|
CreateAssignPermView,
|
||||||
@ -54,7 +60,11 @@ class UserCreateView(
|
|||||||
|
|
||||||
|
|
||||||
class UserUpdateView(
|
class UserUpdateView(
|
||||||
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
|
SuccessMessageMixin,
|
||||||
|
BackSuccessUrlMixin,
|
||||||
|
LoginRequiredMixin,
|
||||||
|
PermissionRequiredMixin,
|
||||||
|
UpdateView,
|
||||||
):
|
):
|
||||||
"""Update user"""
|
"""Update user"""
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
"""passbook admin util views"""
|
"""passbook admin util views"""
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
|
from django.db.models import QuerySet
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
|
from django.http.request import HttpRequest
|
||||||
from django.views.generic import DeleteView, ListView, UpdateView
|
from django.views.generic import DeleteView, ListView, UpdateView
|
||||||
|
|
||||||
from passbook.lib.utils.reflection import all_subclasses
|
from passbook.lib.utils.reflection import all_subclasses
|
||||||
@ -69,3 +72,31 @@ class InheritanceUpdateView(UpdateView):
|
|||||||
.select_subclasses()
|
.select_subclasses()
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BackSuccessUrlMixin:
|
||||||
|
"""Checks if a relative URL has been given as ?back param, and redirect to it. Otherwise
|
||||||
|
default to self.success_url."""
|
||||||
|
|
||||||
|
request: HttpRequest
|
||||||
|
|
||||||
|
success_url: Optional[str]
|
||||||
|
|
||||||
|
def get_success_url(self) -> str:
|
||||||
|
"""get_success_url from FormMixin"""
|
||||||
|
back_param = self.request.GET.get("back")
|
||||||
|
if back_param:
|
||||||
|
if not bool(urlparse(back_param).netloc):
|
||||||
|
return back_param
|
||||||
|
return str(self.success_url)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPaginateListMixin:
|
||||||
|
"""Get paginate_by value from user's attributes, defaulting to 15"""
|
||||||
|
|
||||||
|
request: HttpRequest
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def get_paginate_by(self, queryset: QuerySet) -> int:
|
||||||
|
"""get_paginate_by Function of ListView"""
|
||||||
|
return self.request.user.attributes.get("paginate_by", 15)
|
||||||
|
|||||||
85
passbook/audit/middleware.py
Normal file
85
passbook/audit/middleware.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"""Audit middleware"""
|
||||||
|
from functools import partial
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models import Model
|
||||||
|
from django.db.models.signals import post_save, pre_delete
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
|
||||||
|
from passbook.audit.models import Event, EventAction, model_to_dict
|
||||||
|
from passbook.audit.signals import EventNewThread
|
||||||
|
from passbook.core.middleware import LOCAL
|
||||||
|
|
||||||
|
|
||||||
|
class AuditMiddleware:
|
||||||
|
"""Register handlers for duration of request-response that log creation/update/deletion
|
||||||
|
of models"""
|
||||||
|
|
||||||
|
get_response: Callable[[HttpRequest], HttpResponse]
|
||||||
|
|
||||||
|
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||||
|
# Connect signal for automatic logging
|
||||||
|
if hasattr(request, "user") and getattr(
|
||||||
|
request.user, "is_authenticated", False
|
||||||
|
):
|
||||||
|
post_save_handler = partial(
|
||||||
|
self.post_save_handler, user=request.user, request=request
|
||||||
|
)
|
||||||
|
pre_delete_handler = partial(
|
||||||
|
self.pre_delete_handler, user=request.user, request=request
|
||||||
|
)
|
||||||
|
post_save.connect(
|
||||||
|
post_save_handler,
|
||||||
|
dispatch_uid=LOCAL.passbook["request_id"],
|
||||||
|
weak=False,
|
||||||
|
)
|
||||||
|
pre_delete.connect(
|
||||||
|
pre_delete_handler,
|
||||||
|
dispatch_uid=LOCAL.passbook["request_id"],
|
||||||
|
weak=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.get_response(request)
|
||||||
|
|
||||||
|
post_save.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
|
||||||
|
pre_delete.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def process_exception(self, request: HttpRequest, exception: Exception):
|
||||||
|
"""Unregister handlers in case of exception"""
|
||||||
|
post_save.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
|
||||||
|
pre_delete.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def post_save_handler(
|
||||||
|
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
|
||||||
|
):
|
||||||
|
"""Signal handler for all object's post_save"""
|
||||||
|
if isinstance(instance, Event):
|
||||||
|
return
|
||||||
|
|
||||||
|
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
|
||||||
|
EventNewThread(action, request, user=user, model=model_to_dict(instance)).run()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def pre_delete_handler(
|
||||||
|
user: User, request: HttpRequest, sender, instance: Model, **_
|
||||||
|
):
|
||||||
|
"""Signal handler for all object's pre_delete"""
|
||||||
|
if isinstance(instance, Event):
|
||||||
|
return
|
||||||
|
|
||||||
|
EventNewThread(
|
||||||
|
EventAction.MODEL_DELETED,
|
||||||
|
request,
|
||||||
|
user=user,
|
||||||
|
model=model_to_dict(instance),
|
||||||
|
).run()
|
||||||
59
passbook/audit/migrations/0003_auto_20200917_1155.py
Normal file
59
passbook/audit/migrations/0003_auto_20200917_1155.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# Generated by Django 3.1.1 on 2020-09-17 11:55
|
||||||
|
from django.apps.registry import Apps
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
|
|
||||||
|
import passbook.audit.models
|
||||||
|
|
||||||
|
|
||||||
|
def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||||
|
Event = apps.get_model("passbook_audit", "Event")
|
||||||
|
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
for event in Event.objects.all():
|
||||||
|
event.delete()
|
||||||
|
# Because event objects cannot be updated, we have to re-create them
|
||||||
|
event.pk = None
|
||||||
|
event.user_json = (
|
||||||
|
passbook.audit.models.get_user(event.user) if event.user else {}
|
||||||
|
)
|
||||||
|
event._state.adding = True
|
||||||
|
event.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_audit", "0002_auto_20200918_2116"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="event",
|
||||||
|
name="action",
|
||||||
|
field=models.TextField(
|
||||||
|
choices=[
|
||||||
|
("LOGIN", "login"),
|
||||||
|
("LOGIN_FAILED", "login_failed"),
|
||||||
|
("LOGOUT", "logout"),
|
||||||
|
("AUTHORIZE_APPLICATION", "authorize_application"),
|
||||||
|
("SUSPICIOUS_REQUEST", "suspicious_request"),
|
||||||
|
("SIGN_UP", "sign_up"),
|
||||||
|
("PASSWORD_RESET", "password_reset"),
|
||||||
|
("INVITE_CREATED", "invitation_created"),
|
||||||
|
("INVITE_USED", "invitation_used"),
|
||||||
|
("IMPERSONATION_STARTED", "impersonation_started"),
|
||||||
|
("IMPERSONATION_ENDED", "impersonation_ended"),
|
||||||
|
("CUSTOM", "custom"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="event", name="user_json", field=models.JSONField(default=dict),
|
||||||
|
),
|
||||||
|
migrations.RunPython(convert_user_to_json),
|
||||||
|
migrations.RemoveField(model_name="event", name="user",),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="event", old_name="user_json", new_name="user"
|
||||||
|
),
|
||||||
|
]
|
||||||
37
passbook/audit/migrations/0004_auto_20200921_1829.py
Normal file
37
passbook/audit/migrations/0004_auto_20200921_1829.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Generated by Django 3.1.1 on 2020-09-21 18:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_audit", "0003_auto_20200917_1155"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="event",
|
||||||
|
name="action",
|
||||||
|
field=models.TextField(
|
||||||
|
choices=[
|
||||||
|
("login", "Login"),
|
||||||
|
("login_failed", "Login Failed"),
|
||||||
|
("logout", "Logout"),
|
||||||
|
("sign_up", "Sign Up"),
|
||||||
|
("authorize_application", "Authorize Application"),
|
||||||
|
("suspicious_request", "Suspicious Request"),
|
||||||
|
("password_set", "Password Set"),
|
||||||
|
("invitation_created", "Invite Created"),
|
||||||
|
("invitation_used", "Invite Used"),
|
||||||
|
("source_linked", "Source Linked"),
|
||||||
|
("impersonation_started", "Impersonation Started"),
|
||||||
|
("impersonation_ended", "Impersonation Ended"),
|
||||||
|
("model_created", "Model Created"),
|
||||||
|
("model_updated", "Model Updated"),
|
||||||
|
("model_deleted", "Model Deleted"),
|
||||||
|
("custom_", "Custom Prefix"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,7 +1,6 @@
|
|||||||
"""passbook audit models"""
|
"""passbook audit models"""
|
||||||
from enum import Enum
|
|
||||||
from inspect import getmodule, stack
|
from inspect import getmodule, stack
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional, Union
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -12,16 +11,17 @@ from django.db.models.base import Model
|
|||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.views.debug import SafeExceptionReporterFilter
|
from django.views.debug import SafeExceptionReporterFilter
|
||||||
from guardian.shortcuts import get_anonymous_user
|
from guardian.utils import get_anonymous_user
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.middleware import (
|
from passbook.core.middleware import (
|
||||||
SESSION_IMPERSONATE_ORIGINAL_USER,
|
SESSION_IMPERSONATE_ORIGINAL_USER,
|
||||||
SESSION_IMPERSONATE_USER,
|
SESSION_IMPERSONATE_USER,
|
||||||
)
|
)
|
||||||
|
from passbook.core.models import User
|
||||||
from passbook.lib.utils.http import get_client_ip
|
from passbook.lib.utils.http import get_client_ip
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger("passbook.audit")
|
||||||
|
|
||||||
|
|
||||||
def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||||
@ -53,6 +53,22 @@ def model_to_dict(model: Model) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(user: User, original_user: Optional[User] = None) -> Dict[str, Any]:
|
||||||
|
"""Convert user object to dictionary, optionally including the original user"""
|
||||||
|
if isinstance(user, AnonymousUser):
|
||||||
|
user = get_anonymous_user()
|
||||||
|
user_data = {
|
||||||
|
"username": user.username,
|
||||||
|
"pk": user.pk,
|
||||||
|
"email": user.email,
|
||||||
|
}
|
||||||
|
if original_user:
|
||||||
|
original_data = get_user(original_user)
|
||||||
|
original_data["on_behalf_of"] = user_data
|
||||||
|
return original_data
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
|
||||||
def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||||
"""clean source of all Models that would interfere with the JSONField.
|
"""clean source of all Models that would interfere with the JSONField.
|
||||||
Models are replaced with a dictionary of {
|
Models are replaced with a dictionary of {
|
||||||
@ -73,38 +89,39 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
|||||||
return final_dict
|
return final_dict
|
||||||
|
|
||||||
|
|
||||||
class EventAction(Enum):
|
class EventAction(models.TextChoices):
|
||||||
"""All possible actions to save into the audit log"""
|
"""All possible actions to save into the audit log"""
|
||||||
|
|
||||||
LOGIN = "login"
|
LOGIN = "login"
|
||||||
LOGIN_FAILED = "login_failed"
|
LOGIN_FAILED = "login_failed"
|
||||||
LOGOUT = "logout"
|
LOGOUT = "logout"
|
||||||
|
|
||||||
|
SIGN_UP = "sign_up"
|
||||||
AUTHORIZE_APPLICATION = "authorize_application"
|
AUTHORIZE_APPLICATION = "authorize_application"
|
||||||
SUSPICIOUS_REQUEST = "suspicious_request"
|
SUSPICIOUS_REQUEST = "suspicious_request"
|
||||||
SIGN_UP = "sign_up"
|
PASSWORD_SET = "password_set" # noqa # nosec
|
||||||
PASSWORD_RESET = "password_reset" # noqa # nosec
|
|
||||||
INVITE_CREATED = "invitation_created"
|
INVITE_CREATED = "invitation_created"
|
||||||
INVITE_USED = "invitation_used"
|
INVITE_USED = "invitation_used"
|
||||||
|
|
||||||
|
SOURCE_LINKED = "source_linked"
|
||||||
|
|
||||||
IMPERSONATION_STARTED = "impersonation_started"
|
IMPERSONATION_STARTED = "impersonation_started"
|
||||||
IMPERSONATION_ENDED = "impersonation_ended"
|
IMPERSONATION_ENDED = "impersonation_ended"
|
||||||
CUSTOM = "custom"
|
|
||||||
|
|
||||||
@staticmethod
|
MODEL_CREATED = "model_created"
|
||||||
def as_choices():
|
MODEL_UPDATED = "model_updated"
|
||||||
"""Generate choices of actions used for database"""
|
MODEL_DELETED = "model_deleted"
|
||||||
return tuple(
|
|
||||||
(x, y.value) for x, y in getattr(EventAction, "__members__").items()
|
CUSTOM_PREFIX = "custom_"
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Event(models.Model):
|
class Event(models.Model):
|
||||||
"""An individual audit log event"""
|
"""An individual audit log event"""
|
||||||
|
|
||||||
event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||||
user = models.ForeignKey(
|
user = models.JSONField(default=dict)
|
||||||
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL
|
action = models.TextField(choices=EventAction.choices)
|
||||||
)
|
|
||||||
action = models.TextField(choices=EventAction.as_choices())
|
|
||||||
date = models.DateTimeField(auto_now_add=True)
|
date = models.DateTimeField(auto_now_add=True)
|
||||||
app = models.TextField()
|
app = models.TextField()
|
||||||
context = models.JSONField(default=dict, blank=True)
|
context = models.JSONField(default=dict, blank=True)
|
||||||
@ -119,20 +136,18 @@ class Event(models.Model):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def new(
|
def new(
|
||||||
action: EventAction,
|
action: Union[str, EventAction],
|
||||||
app: Optional[str] = None,
|
app: Optional[str] = None,
|
||||||
_inspect_offset: int = 1,
|
_inspect_offset: int = 1,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> "Event":
|
) -> "Event":
|
||||||
"""Create new Event instance from arguments. Instance is NOT saved."""
|
"""Create new Event instance from arguments. Instance is NOT saved."""
|
||||||
if not isinstance(action, EventAction):
|
if not isinstance(action, EventAction):
|
||||||
raise ValueError(
|
action = EventAction.CUSTOM_PREFIX + action
|
||||||
f"action must be EventAction instance but was {type(action)}"
|
|
||||||
)
|
|
||||||
if not app:
|
if not app:
|
||||||
app = getmodule(stack()[_inspect_offset][0]).__name__
|
app = getmodule(stack()[_inspect_offset][0]).__name__
|
||||||
cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs))
|
cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs))
|
||||||
event = Event(action=action.value, app=app, context=cleaned_kwargs)
|
event = Event(action=action, app=app, context=cleaned_kwargs)
|
||||||
return event
|
return event
|
||||||
|
|
||||||
def from_http(
|
def from_http(
|
||||||
@ -142,17 +157,17 @@ class Event(models.Model):
|
|||||||
Events independently from requests.
|
Events independently from requests.
|
||||||
`user` arguments optionally overrides user from requests."""
|
`user` arguments optionally overrides user from requests."""
|
||||||
if hasattr(request, "user"):
|
if hasattr(request, "user"):
|
||||||
if isinstance(request.user, AnonymousUser):
|
self.user = get_user(
|
||||||
self.user = get_anonymous_user()
|
request.user,
|
||||||
else:
|
request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None),
|
||||||
self.user = request.user
|
)
|
||||||
if user:
|
if user:
|
||||||
self.user = user
|
self.user = get_user(user)
|
||||||
# Check if we're currently impersonating, and add that user
|
# Check if we're currently impersonating, and add that user
|
||||||
if hasattr(request, "session"):
|
if hasattr(request, "session"):
|
||||||
if SESSION_IMPERSONATE_ORIGINAL_USER in request.session:
|
if SESSION_IMPERSONATE_ORIGINAL_USER in request.session:
|
||||||
self.user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
|
self.user = get_user(request.session[SESSION_IMPERSONATE_ORIGINAL_USER])
|
||||||
self.context["on_behalf_of"] = model_to_dict(
|
self.user["on_behalf_of"] = get_user(
|
||||||
request.session[SESSION_IMPERSONATE_USER]
|
request.session[SESSION_IMPERSONATE_USER]
|
||||||
)
|
)
|
||||||
# User 255.255.255.255 as fallback if IP cannot be determined
|
# User 255.255.255.255 as fallback if IP cannot be determined
|
||||||
|
|||||||
@ -20,15 +20,18 @@ from passbook.stages.user_write.signals import user_write
|
|||||||
class EventNewThread(Thread):
|
class EventNewThread(Thread):
|
||||||
"""Create Event in background thread"""
|
"""Create Event in background thread"""
|
||||||
|
|
||||||
action: EventAction
|
action: str
|
||||||
request: HttpRequest
|
request: HttpRequest
|
||||||
kwargs: Dict[str, Any]
|
kwargs: Dict[str, Any]
|
||||||
user: Optional[User] = None
|
user: Optional[User] = None
|
||||||
|
|
||||||
def __init__(self, action: EventAction, request: HttpRequest, **kwargs):
|
def __init__(
|
||||||
|
self, action: str, request: HttpRequest, user: Optional[User] = None, **kwargs
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.action = action
|
self.action = action
|
||||||
self.request = request
|
self.request = request
|
||||||
|
self.user = user
|
||||||
self.kwargs = kwargs
|
self.kwargs = kwargs
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
@ -57,7 +60,7 @@ def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
|||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def on_user_write(sender, request: HttpRequest, user: User, data: Dict[str, Any], **_):
|
def on_user_write(sender, request: HttpRequest, user: User, data: Dict[str, Any], **_):
|
||||||
"""Log User write"""
|
"""Log User write"""
|
||||||
thread = EventNewThread(EventAction.CUSTOM, request, **data)
|
thread = EventNewThread("stages/user_write", request, **data)
|
||||||
thread.user = user
|
thread.user = user
|
||||||
thread.run()
|
thread.run()
|
||||||
|
|
||||||
|
|||||||
@ -40,12 +40,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<td role="cell">
|
<td role="cell">
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
<code>{{ entry.context }}</code>
|
<code>{{ entry.context }}</code>
|
||||||
|
</div>
|
||||||
|
{% if entry.user.on_behalf_of %}
|
||||||
|
<small>
|
||||||
|
{% blocktrans with username=entry.user.on_behalf_of.username %}
|
||||||
|
On behalf of {{ username }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td role="cell">
|
<td role="cell">
|
||||||
<span>
|
<div>
|
||||||
{{ entry.user }}
|
<div>{{ entry.user.username }}</div>
|
||||||
</span>
|
<small>
|
||||||
|
{% blocktrans with pk=entry.user.pk %}
|
||||||
|
ID: {{ pk }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td role="cell">
|
<td role="cell">
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from guardian.shortcuts import get_anonymous_user
|
from guardian.shortcuts import get_anonymous_user
|
||||||
|
|
||||||
from passbook.audit.models import Event, EventAction
|
from passbook.audit.models import Event
|
||||||
from passbook.policies.dummy.models import DummyPolicy
|
from passbook.policies.dummy.models import DummyPolicy
|
||||||
|
|
||||||
|
|
||||||
@ -13,7 +13,7 @@ class TestAuditEvent(TestCase):
|
|||||||
|
|
||||||
def test_new_with_model(self):
|
def test_new_with_model(self):
|
||||||
"""Create a new Event passing a model as kwarg"""
|
"""Create a new Event passing a model as kwarg"""
|
||||||
event = Event.new(EventAction.CUSTOM, test={"model": get_anonymous_user()})
|
event = Event.new("unittest", test={"model": get_anonymous_user()})
|
||||||
event.save() # We save to ensure nothing is un-saveable
|
event.save() # We save to ensure nothing is un-saveable
|
||||||
model_content_type = ContentType.objects.get_for_model(get_anonymous_user())
|
model_content_type = ContentType.objects.get_for_model(get_anonymous_user())
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@ -24,7 +24,7 @@ class TestAuditEvent(TestCase):
|
|||||||
def test_new_with_uuid_model(self):
|
def test_new_with_uuid_model(self):
|
||||||
"""Create a new Event passing a model (with UUID PK) as kwarg"""
|
"""Create a new Event passing a model (with UUID PK) as kwarg"""
|
||||||
temp_model = DummyPolicy.objects.create(name="test", result=True)
|
temp_model = DummyPolicy.objects.create(name="test", result=True)
|
||||||
event = Event.new(EventAction.CUSTOM, model=temp_model)
|
event = Event.new("unittest", model=temp_model)
|
||||||
event.save() # We save to ensure nothing is un-saveable
|
event.save() # We save to ensure nothing is un-saveable
|
||||||
model_content_type = ContentType.objects.get_for_model(temp_model)
|
model_content_type = ContentType.objects.get_for_model(temp_model)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@ -3,14 +3,16 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
|||||||
from django.views.generic import ListView
|
from django.views.generic import ListView
|
||||||
from guardian.mixins import PermissionListMixin
|
from guardian.mixins import PermissionListMixin
|
||||||
|
|
||||||
|
from passbook.admin.views.utils import UserPaginateListMixin
|
||||||
from passbook.audit.models import Event
|
from passbook.audit.models import Event
|
||||||
|
|
||||||
|
|
||||||
class EventListView(PermissionListMixin, LoginRequiredMixin, ListView):
|
class EventListView(
|
||||||
|
PermissionListMixin, LoginRequiredMixin, UserPaginateListMixin, ListView
|
||||||
|
):
|
||||||
"""Show list of all invitations"""
|
"""Show list of all invitations"""
|
||||||
|
|
||||||
model = Event
|
model = Event
|
||||||
template_name = "audit/list.html"
|
template_name = "audit/list.html"
|
||||||
permission_required = "passbook_audit.view_event"
|
permission_required = "passbook_audit.view_event"
|
||||||
ordering = "-created"
|
ordering = "-created"
|
||||||
paginate_by = 20
|
|
||||||
|
|||||||
@ -20,5 +20,5 @@ def admin_autoregister(app: AppConfig):
|
|||||||
|
|
||||||
for _app in apps.get_app_configs():
|
for _app in apps.get_app_configs():
|
||||||
if _app.label.startswith("passbook_"):
|
if _app.label.startswith("passbook_"):
|
||||||
LOGGER.debug("Registering application for dj-admin", app=_app.label)
|
LOGGER.debug("Registering application for dj-admin", application=_app.label)
|
||||||
admin_autoregister(_app)
|
admin_autoregister(_app)
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
"""passbook admin Middleware to impersonate users"""
|
"""passbook admin Middleware to impersonate users"""
|
||||||
|
from logging import Logger
|
||||||
|
from threading import local
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
|
||||||
SESSION_IMPERSONATE_USER = "passbook_impersonate_user"
|
SESSION_IMPERSONATE_USER = "passbook_impersonate_user"
|
||||||
SESSION_IMPERSONATE_ORIGINAL_USER = "passbook_impersonate_original_user"
|
SESSION_IMPERSONATE_ORIGINAL_USER = "passbook_impersonate_original_user"
|
||||||
|
LOCAL = local()
|
||||||
|
|
||||||
|
|
||||||
class ImpersonateMiddleware:
|
class ImpersonateMiddleware:
|
||||||
@ -24,3 +27,30 @@ class ImpersonateMiddleware:
|
|||||||
request.user = request.session[SESSION_IMPERSONATE_USER]
|
request.user = request.session[SESSION_IMPERSONATE_USER]
|
||||||
|
|
||||||
return self.get_response(request)
|
return self.get_response(request)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestIDMiddleware:
|
||||||
|
"""Add a unique ID to every request"""
|
||||||
|
|
||||||
|
get_response: Callable[[HttpRequest], HttpResponse]
|
||||||
|
|
||||||
|
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||||
|
if not hasattr(request, "request_id"):
|
||||||
|
request_id = uuid4().hex
|
||||||
|
setattr(request, "request_id", request_id)
|
||||||
|
LOCAL.passbook = {"request_id": request_id}
|
||||||
|
response = self.get_response(request)
|
||||||
|
response["X-passbook-id"] = request.request_id
|
||||||
|
del LOCAL.passbook["request_id"]
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def structlog_add_request_id(logger: Logger, method_name: str, event_dict):
|
||||||
|
"""If threadlocal has passbook defined, add request_id to log"""
|
||||||
|
if hasattr(LOCAL, "passbook"):
|
||||||
|
event_dict["request_id"] = LOCAL.passbook.get("request_id", "")
|
||||||
|
return event_dict
|
||||||
|
|||||||
@ -14,7 +14,7 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|||||||
pbadmin, _ = User.objects.using(db_alias).get_or_create(
|
pbadmin, _ = User.objects.using(db_alias).get_or_create(
|
||||||
username="pbadmin", email="root@localhost", name="passbook Default Admin"
|
username="pbadmin", email="root@localhost", name="passbook Default Admin"
|
||||||
)
|
)
|
||||||
pbadmin.set_password("pbadmin") # noqa # nosec
|
pbadmin.set_password("pbadmin", signal=False) # noqa # nosec
|
||||||
pbadmin.save()
|
pbadmin.save()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"""passbook core models"""
|
"""passbook core models"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any, Optional, Type
|
from typing import Any, Dict, Optional, Type
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
@ -9,6 +9,7 @@ from django.db import models
|
|||||||
from django.db.models import Q, QuerySet
|
from django.db.models import Q, QuerySet
|
||||||
from django.forms import ModelForm
|
from django.forms import ModelForm
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
from django.utils.functional import cached_property
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from guardian.mixins import GuardianUserMixin
|
from guardian.mixins import GuardianUserMixin
|
||||||
@ -80,7 +81,14 @@ class User(GuardianUserMixin, AbstractUser):
|
|||||||
|
|
||||||
objects = UserManager()
|
objects = UserManager()
|
||||||
|
|
||||||
@property
|
def group_attributes(self) -> Dict[str, Any]:
|
||||||
|
"""Get a dictionary containing the attributes from all groups the user belongs to"""
|
||||||
|
final_attributes = {}
|
||||||
|
for group in self.pb_groups.all().order_by("name"):
|
||||||
|
final_attributes.update(group.attributes)
|
||||||
|
return final_attributes
|
||||||
|
|
||||||
|
@cached_property
|
||||||
def is_superuser(self) -> bool:
|
def is_superuser(self) -> bool:
|
||||||
"""Get supseruser status based on membership in a group with superuser status"""
|
"""Get supseruser status based on membership in a group with superuser status"""
|
||||||
return self.pb_groups.filter(is_superuser=True).exists()
|
return self.pb_groups.filter(is_superuser=True).exists()
|
||||||
@ -88,10 +96,10 @@ class User(GuardianUserMixin, AbstractUser):
|
|||||||
@property
|
@property
|
||||||
def is_staff(self) -> bool:
|
def is_staff(self) -> bool:
|
||||||
"""superuser == staff user"""
|
"""superuser == staff user"""
|
||||||
return self.is_superuser
|
return self.is_superuser # type: ignore
|
||||||
|
|
||||||
def set_password(self, password):
|
def set_password(self, password, signal=True):
|
||||||
if self.pk:
|
if self.pk and signal:
|
||||||
password_changed.send(sender=self, user=self, password=password)
|
password_changed.send(sender=self, user=self, password=password)
|
||||||
self.password_change_date = now()
|
self.password_change_date = now()
|
||||||
return super().set_password(password)
|
return super().set_password(password)
|
||||||
|
|||||||
@ -12,7 +12,7 @@ FLOW_POLICY_EXPRESSION = """# This policy ensures that this flow can only be use
|
|||||||
return pb_is_sso_flow"""
|
return pb_is_sso_flow"""
|
||||||
PROMPT_POLICY_EXPRESSION = """# Check if we've not 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
|
# and trigger the enrollment flow
|
||||||
return 'username' not in pb_flow_plan.context.get('prompt_data', {})"""
|
return 'username' not in context.get('prompt_data', {})"""
|
||||||
|
|
||||||
|
|
||||||
def create_default_source_enrollment_flow(
|
def create_default_source_enrollment_flow(
|
||||||
@ -80,7 +80,9 @@ def create_default_source_enrollment_flow(
|
|||||||
)
|
)
|
||||||
|
|
||||||
binding, _ = FlowStageBinding.objects.using(db_alias).update_or_create(
|
binding, _ = FlowStageBinding.objects.using(db_alias).update_or_create(
|
||||||
target=flow, stage=prompt_stage, defaults={"order": 0}
|
target=flow,
|
||||||
|
stage=prompt_stage,
|
||||||
|
defaults={"order": 0, "re_evaluate_policies": True},
|
||||||
)
|
)
|
||||||
PolicyBinding.objects.using(db_alias).update_or_create(
|
PolicyBinding.objects.using(db_alias).update_or_create(
|
||||||
policy=prompt_policy, target=binding, defaults={"order": 0}
|
policy=prompt_policy, target=binding, defaults={"order": 0}
|
||||||
|
|||||||
44
passbook/flows/migrations/0013_auto_20200924_1605.py
Normal file
44
passbook/flows/migrations/0013_auto_20200924_1605.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 3.1.1 on 2020-09-24 16:05
|
||||||
|
|
||||||
|
from django.apps.registry import Apps
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
|
|
||||||
|
from passbook.flows.models import FlowDesignation
|
||||||
|
|
||||||
|
|
||||||
|
def update_flow_designation(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||||
|
Flow = apps.get_model("passbook_flows", "Flow")
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
for flow in Flow.objects.using(db_alias).all():
|
||||||
|
if flow.designation == "stage_setup":
|
||||||
|
flow.designation = FlowDesignation.STAGE_CONFIGURATION
|
||||||
|
flow.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_flows", "0012_auto_20200908_1542"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="flow",
|
||||||
|
name="designation",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("authentication", "Authentication"),
|
||||||
|
("authorization", "Authorization"),
|
||||||
|
("invalidation", "Invalidation"),
|
||||||
|
("enrollment", "Enrollment"),
|
||||||
|
("unenrollment", "Unrenollment"),
|
||||||
|
("recovery", "Recovery"),
|
||||||
|
("stage_configuration", "Stage Configuration"),
|
||||||
|
],
|
||||||
|
max_length=100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(update_flow_designation),
|
||||||
|
]
|
||||||
51
passbook/flows/migrations/0014_auto_20200925_2332.py
Normal file
51
passbook/flows/migrations/0014_auto_20200925_2332.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Generated by Django 3.1.1 on 2020-09-25 23:32
|
||||||
|
|
||||||
|
from django.apps.registry import Apps
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
|
|
||||||
|
|
||||||
|
# First stage for default-source-enrollment flow (prompt stage)
|
||||||
|
# needs to have its policy re-evaluated
|
||||||
|
def update_default_source_enrollment_flow_binding(
|
||||||
|
apps: Apps, schema_editor: BaseDatabaseSchemaEditor
|
||||||
|
):
|
||||||
|
Flow = apps.get_model("passbook_flows", "Flow")
|
||||||
|
FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
flows = Flow.objects.using(db_alias).filter(slug="default-source-enrollment")
|
||||||
|
if not flows.exists():
|
||||||
|
return
|
||||||
|
flow = flows.first()
|
||||||
|
|
||||||
|
binding = FlowStageBinding.objects.get(target=flow, order=0)
|
||||||
|
binding.re_evaluate_policies = True
|
||||||
|
binding.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_flows", "0013_auto_20200924_1605"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="flowstagebinding",
|
||||||
|
options={
|
||||||
|
"ordering": ["target", "order"],
|
||||||
|
"verbose_name": "Flow Stage Binding",
|
||||||
|
"verbose_name_plural": "Flow Stage Bindings",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="flowstagebinding",
|
||||||
|
name="re_evaluate_policies",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="When this option is enabled, the planner will re-evaluate policies bound to this binding.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(update_default_source_enrollment_flow_binding),
|
||||||
|
]
|
||||||
@ -37,7 +37,7 @@ class FlowDesignation(models.TextChoices):
|
|||||||
ENROLLMENT = "enrollment"
|
ENROLLMENT = "enrollment"
|
||||||
UNRENOLLMENT = "unenrollment"
|
UNRENOLLMENT = "unenrollment"
|
||||||
RECOVERY = "recovery"
|
RECOVERY = "recovery"
|
||||||
STAGE_SETUP = "stage_setup"
|
STAGE_CONFIGURATION = "stage_configuration"
|
||||||
|
|
||||||
|
|
||||||
class Stage(SerializerModel):
|
class Stage(SerializerModel):
|
||||||
@ -155,7 +155,10 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel):
|
|||||||
re_evaluate_policies = models.BooleanField(
|
re_evaluate_policies = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"When this option is enabled, the planner will re-evaluate policies bound to this."
|
(
|
||||||
|
"When this option is enabled, the planner will re-evaluate "
|
||||||
|
"policies bound to this binding."
|
||||||
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -170,12 +173,35 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel):
|
|||||||
return FlowStageBindingSerializer
|
return FlowStageBindingSerializer
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"'{self.target}' -> '{self.stage}' # {self.order}"
|
return f"{self.target} #{self.order} -> {self.stage}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
ordering = ["order", "target"]
|
ordering = ["target", "order"]
|
||||||
|
|
||||||
verbose_name = _("Flow Stage Binding")
|
verbose_name = _("Flow Stage Binding")
|
||||||
verbose_name_plural = _("Flow Stage Bindings")
|
verbose_name_plural = _("Flow Stage Bindings")
|
||||||
unique_together = (("target", "stage", "order"),)
|
unique_together = (("target", "stage", "order"),)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurableStage(models.Model):
|
||||||
|
"""Abstract base class for a Stage that can be configured by the enduser.
|
||||||
|
The stage should create a default flow with the configure_stage designation during
|
||||||
|
migration."""
|
||||||
|
|
||||||
|
configure_flow = models.ForeignKey(
|
||||||
|
Flow,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_(
|
||||||
|
(
|
||||||
|
"Flow used by an authenticated user to configure this Stage. "
|
||||||
|
"If empty, user will not be able to configure this stage."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
|
||||||
|
abstract = True
|
||||||
|
|||||||
@ -105,15 +105,10 @@ class TestFlowTransfer(TransactionTestCase):
|
|||||||
order=2,
|
order=2,
|
||||||
type=FieldTypes.PASSWORD,
|
type=FieldTypes.PASSWORD,
|
||||||
)
|
)
|
||||||
# Password checking policy
|
|
||||||
password_policy = ExpressionPolicy.objects.create(
|
|
||||||
name=generate_client_id(), expression="return True",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Stages
|
# Stages
|
||||||
first_stage = PromptStage.objects.create(name=generate_client_id())
|
first_stage = PromptStage.objects.create(name=generate_client_id())
|
||||||
first_stage.fields.set([username_prompt, password, password_repeat])
|
first_stage.fields.set([username_prompt, password, password_repeat])
|
||||||
first_stage.validation_policies.set([password_policy])
|
|
||||||
first_stage.save()
|
first_stage.save()
|
||||||
|
|
||||||
flow = Flow.objects.create(
|
flow = Flow.objects.create(
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from django.urls import path
|
|||||||
from passbook.flows.models import FlowDesignation
|
from passbook.flows.models import FlowDesignation
|
||||||
from passbook.flows.views import (
|
from passbook.flows.views import (
|
||||||
CancelView,
|
CancelView,
|
||||||
|
ConfigureFlowInitView,
|
||||||
FlowExecutorShellView,
|
FlowExecutorShellView,
|
||||||
FlowExecutorView,
|
FlowExecutorView,
|
||||||
ToDefaultFlow,
|
ToDefaultFlow,
|
||||||
@ -36,6 +37,11 @@ urlpatterns = [
|
|||||||
name="default-unenrollment",
|
name="default-unenrollment",
|
||||||
),
|
),
|
||||||
path("-/cancel/", CancelView.as_view(), name="cancel"),
|
path("-/cancel/", CancelView.as_view(), name="cancel"),
|
||||||
|
path(
|
||||||
|
"-/configure/<uuid:stage_uuid>/",
|
||||||
|
ConfigureFlowInitView.as_view(),
|
||||||
|
name="configure",
|
||||||
|
),
|
||||||
path("b/<slug:flow_slug>/", FlowExecutorView.as_view(), name="flow-executor"),
|
path("b/<slug:flow_slug>/", FlowExecutorView.as_view(), name="flow-executor"),
|
||||||
path(
|
path(
|
||||||
"<slug:flow_slug>/", FlowExecutorShellView.as_view(), name="flow-executor-shell"
|
"<slug:flow_slug>/", FlowExecutorShellView.as_view(), name="flow-executor-shell"
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
from traceback import format_tb
|
from traceback import format_tb
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.http import (
|
from django.http import (
|
||||||
Http404,
|
Http404,
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
@ -19,8 +20,8 @@ from structlog import get_logger
|
|||||||
from passbook.audit.models import cleanse_dict
|
from passbook.audit.models import cleanse_dict
|
||||||
from passbook.core.models import PASSBOOK_USER_DEBUG
|
from passbook.core.models import PASSBOOK_USER_DEBUG
|
||||||
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||||
from passbook.flows.models import Flow, FlowDesignation, Stage
|
from passbook.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage
|
||||||
from passbook.flows.planner import FlowPlan, FlowPlanner
|
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan, FlowPlanner
|
||||||
from passbook.lib.utils.reflection import class_to_path
|
from passbook.lib.utils.reflection import class_to_path
|
||||||
from passbook.lib.utils.urls import is_url_absolute, redirect_with_qs
|
from passbook.lib.utils.urls import is_url_absolute, redirect_with_qs
|
||||||
from passbook.policies.http import AccessDeniedResponse
|
from passbook.policies.http import AccessDeniedResponse
|
||||||
@ -156,10 +157,6 @@ class FlowExecutorView(View):
|
|||||||
stage_class=class_to_path(self.current_stage_view.__class__),
|
stage_class=class_to_path(self.current_stage_view.__class__),
|
||||||
flow_slug=self.flow.slug,
|
flow_slug=self.flow.slug,
|
||||||
)
|
)
|
||||||
# We call plan.next here to check for re-evaluate markers
|
|
||||||
# this is important so we can save the result
|
|
||||||
# and we don't have to re-evaluate the policies each request
|
|
||||||
self.plan.next()
|
|
||||||
self.plan.pop()
|
self.plan.pop()
|
||||||
self.request.session[SESSION_KEY_PLAN] = self.plan
|
self.request.session[SESSION_KEY_PLAN] = self.plan
|
||||||
if self.plan.stages:
|
if self.plan.stages:
|
||||||
@ -295,3 +292,32 @@ def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpRespons
|
|||||||
{"type": "template", "body": source.content.decode("utf-8")}
|
{"type": "template", "body": source.content.decode("utf-8")}
|
||||||
)
|
)
|
||||||
return source
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigureFlowInitView(LoginRequiredMixin, View):
|
||||||
|
"""Initiate planner for selected change flow and redirect to flow executor,
|
||||||
|
or raise Http404 if no configure_flow has been set."""
|
||||||
|
|
||||||
|
def get(self, request: HttpRequest, stage_uuid: str) -> HttpResponse:
|
||||||
|
"""Initiate planner for selected change flow and redirect to flow executor,
|
||||||
|
or raise Http404 if no configure_flow has been set."""
|
||||||
|
try:
|
||||||
|
stage: Stage = Stage.objects.get_subclass(pk=stage_uuid)
|
||||||
|
except Stage.DoesNotExist as exc:
|
||||||
|
raise Http404 from exc
|
||||||
|
if not isinstance(stage, ConfigurableStage):
|
||||||
|
LOGGER.debug("Stage does not inherit ConfigurableStage", stage=stage)
|
||||||
|
raise Http404
|
||||||
|
if not stage.configure_flow:
|
||||||
|
LOGGER.debug("Stage has no configure_flow set", stage=stage)
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
plan = FlowPlanner(stage.configure_flow).plan(
|
||||||
|
request, {PLAN_CONTEXT_PENDING_USER: request.user}
|
||||||
|
)
|
||||||
|
request.session[SESSION_KEY_PLAN] = plan
|
||||||
|
return redirect_with_qs(
|
||||||
|
"passbook_flows:flow-executor-shell",
|
||||||
|
self.request.GET,
|
||||||
|
flow_slug=stage.configure_flow.slug,
|
||||||
|
)
|
||||||
|
|||||||
@ -1,9 +1,23 @@
|
|||||||
"""logging helpers"""
|
"""logging helpers"""
|
||||||
|
from logging import Logger
|
||||||
from os import getpid
|
from os import getpid
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def add_process_id(logger, method_name, event_dict):
|
def add_process_id(logger: Logger, method_name: str, event_dict):
|
||||||
"""Add the current process ID"""
|
"""Add the current process ID"""
|
||||||
event_dict["pid"] = getpid()
|
event_dict["pid"] = getpid()
|
||||||
return event_dict
|
return event_dict
|
||||||
|
|
||||||
|
|
||||||
|
def add_common_fields(environment: str) -> Callable:
|
||||||
|
"""Add a common field to easily search for passbook logs"""
|
||||||
|
|
||||||
|
def add_common_field(logger: Logger, method_name: str, event_dict):
|
||||||
|
"""Add a common field to easily search for passbook logs"""
|
||||||
|
event_dict["app"] = "passbook"
|
||||||
|
event_dict["app_environment"] = environment
|
||||||
|
return event_dict
|
||||||
|
|
||||||
|
return add_common_field
|
||||||
|
|||||||
@ -17,7 +17,7 @@ LOGGER = get_logger()
|
|||||||
|
|
||||||
@register.simple_tag(takes_context=True)
|
@register.simple_tag(takes_context=True)
|
||||||
def back(context: Context) -> str:
|
def back(context: Context) -> str:
|
||||||
"""Return a link back (either from GET paramter or referer."""
|
"""Return a link back (either from GET parameter or referer."""
|
||||||
if "request" not in context:
|
if "request" not in context:
|
||||||
return ""
|
return ""
|
||||||
request = context.get("request")
|
request = context.get("request")
|
||||||
|
|||||||
@ -6,7 +6,6 @@ from django.http import HttpRequest
|
|||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.flows.planner import PLAN_CONTEXT_SSO
|
from passbook.flows.planner import PLAN_CONTEXT_SSO
|
||||||
from passbook.flows.views import SESSION_KEY_PLAN
|
|
||||||
from passbook.lib.expression.evaluator import BaseEvaluator
|
from passbook.lib.expression.evaluator import BaseEvaluator
|
||||||
from passbook.lib.utils.http import get_client_ip
|
from passbook.lib.utils.http import get_client_ip
|
||||||
from passbook.policies.types import PolicyRequest, PolicyResult
|
from passbook.policies.types import PolicyRequest, PolicyResult
|
||||||
@ -31,23 +30,20 @@ class PolicyEvaluator(BaseEvaluator):
|
|||||||
|
|
||||||
def set_policy_request(self, request: PolicyRequest):
|
def set_policy_request(self, request: PolicyRequest):
|
||||||
"""Update context based on policy request (if http request is given, update that too)"""
|
"""Update context based on policy request (if http request is given, update that too)"""
|
||||||
# update passbook/policies/expression/templates/policy/expression/form.html
|
|
||||||
# update docs/policies/expression/index.md
|
# update docs/policies/expression/index.md
|
||||||
self._context["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
|
self._context["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
|
||||||
if request.http_request:
|
if request.http_request:
|
||||||
self.set_http_request(request.http_request)
|
self.set_http_request(request.http_request)
|
||||||
self._context["request"] = request
|
self._context["request"] = request
|
||||||
|
self._context["context"] = request.context
|
||||||
|
|
||||||
def set_http_request(self, request: HttpRequest):
|
def set_http_request(self, request: HttpRequest):
|
||||||
"""Update context based on http request"""
|
"""Update context based on http request"""
|
||||||
# update passbook/policies/expression/templates/policy/expression/form.html
|
|
||||||
# update docs/policies/expression/index.md
|
# update docs/policies/expression/index.md
|
||||||
self._context["pb_client_ip"] = ip_address(
|
self._context["pb_client_ip"] = ip_address(
|
||||||
get_client_ip(request) or "255.255.255.255"
|
get_client_ip(request) or "255.255.255.255"
|
||||||
)
|
)
|
||||||
self._context["request"] = request
|
self._context["request"] = request
|
||||||
if SESSION_KEY_PLAN in request.session:
|
|
||||||
self._context["pb_flow_plan"] = request.session[SESSION_KEY_PLAN]
|
|
||||||
|
|
||||||
def evaluate(self, expression_source: str) -> PolicyResult:
|
def evaluate(self, expression_source: str) -> PolicyResult:
|
||||||
"""Parse and evaluate expression. Policy is expected to return a truthy object.
|
"""Parse and evaluate expression. Policy is expected to return a truthy object.
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 3.1.1 on 2020-09-26 11:56
|
||||||
|
|
||||||
|
from django.apps.registry import Apps
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
|
|
||||||
|
|
||||||
|
def remove_pb_flow_plan(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||||
|
ExpressionPolicy = apps.get_model(
|
||||||
|
"passbook_policies_expression", "ExpressionPolicy"
|
||||||
|
)
|
||||||
|
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
for policy in ExpressionPolicy.objects.using(db_alias).all():
|
||||||
|
policy.expression.replace("pb_flow_plan.", "context.")
|
||||||
|
policy.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_policies_expression", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(remove_pb_flow_plan),
|
||||||
|
]
|
||||||
@ -64,7 +64,7 @@ class PolicyBinding(SerializerModel):
|
|||||||
return PolicyBindingSerializer
|
return PolicyBindingSerializer
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"PolicyBinding policy={self.policy} target={self.target} order={self.order}"
|
return f"Policy Binding {self.target} #{self.order} {self.policy}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 3.1.1 on 2020-09-20 12:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"passbook_providers_oauth2",
|
||||||
|
"0004_remove_oauth2provider_post_logout_redirect_uris",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="oauth2provider",
|
||||||
|
name="response_type",
|
||||||
|
field=models.TextField(
|
||||||
|
choices=[
|
||||||
|
("code", "code (Authorization Code Flow)"),
|
||||||
|
(
|
||||||
|
"code#adfs",
|
||||||
|
"code (ADFS Compatibility Mode, sends id_token as access_token)",
|
||||||
|
),
|
||||||
|
("id_token", "id_token (Implicit Flow)"),
|
||||||
|
("id_token token", "id_token token (Implicit Flow)"),
|
||||||
|
("code token", "code token (Hybrid Flow)"),
|
||||||
|
("code id_token", "code id_token (Hybrid Flow)"),
|
||||||
|
("code id_token token", "code id_token token (Hybrid Flow)"),
|
||||||
|
],
|
||||||
|
default="code",
|
||||||
|
help_text="Response Type required by the client.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -218,10 +218,10 @@ class TokenView(View):
|
|||||||
if unauthorized_scopes:
|
if unauthorized_scopes:
|
||||||
raise TokenError("invalid_scope")
|
raise TokenError("invalid_scope")
|
||||||
|
|
||||||
refresh_token = self.params.refresh_token.provider.create_token(
|
provider: OAuth2Provider = self.params.refresh_token.provider
|
||||||
user=self.params.refresh_token.user,
|
|
||||||
provider=self.params.refresh_token.provider,
|
refresh_token: RefreshToken = provider.create_refresh_token(
|
||||||
scope=self.params.scope,
|
user=self.params.refresh_token.user, scope=self.params.scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
# If the Token has an id_token it's an Authentication request.
|
# If the Token has an id_token it's an Authentication request.
|
||||||
|
|||||||
@ -54,6 +54,7 @@ class ProxyProviderSerializer(ModelSerializer):
|
|||||||
"name",
|
"name",
|
||||||
"internal_host",
|
"internal_host",
|
||||||
"external_host",
|
"external_host",
|
||||||
|
"internal_host_ssl_validation",
|
||||||
"certificate",
|
"certificate",
|
||||||
"skip_path_regex",
|
"skip_path_regex",
|
||||||
]
|
]
|
||||||
@ -89,6 +90,7 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
|
|||||||
"name",
|
"name",
|
||||||
"internal_host",
|
"internal_host",
|
||||||
"external_host",
|
"external_host",
|
||||||
|
"internal_host_ssl_validation",
|
||||||
"client_id",
|
"client_id",
|
||||||
"client_secret",
|
"client_secret",
|
||||||
"oidc_configuration",
|
"oidc_configuration",
|
||||||
|
|||||||
@ -33,6 +33,7 @@ class ProxyProviderForm(forms.ModelForm):
|
|||||||
"name",
|
"name",
|
||||||
"authorization_flow",
|
"authorization_flow",
|
||||||
"internal_host",
|
"internal_host",
|
||||||
|
"internal_host_ssl_validation",
|
||||||
"external_host",
|
"external_host",
|
||||||
"certificate",
|
"certificate",
|
||||||
"skip_path_regex",
|
"skip_path_regex",
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 3.1.1 on 2020-09-23 10:17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_providers_proxy", "0006_proxyprovider_skip_path_regex"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="proxyprovider",
|
||||||
|
name="internal_host_ssl_validation",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=True, help_text="Validate SSL Certificates of upstream servers"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="proxyprovider",
|
||||||
|
name="skip_path_regex",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Regular expressions for which authentication is not required. Each new line is interpreted as a new Regular Expression.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -46,15 +46,16 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
|||||||
external_host = models.TextField(
|
external_host = models.TextField(
|
||||||
validators=[DomainlessURLValidator(schemes=("http", "https"))]
|
validators=[DomainlessURLValidator(schemes=("http", "https"))]
|
||||||
)
|
)
|
||||||
|
internal_host_ssl_validation = models.BooleanField(
|
||||||
cookie_secret = models.TextField(default=get_cookie_secret)
|
default=True, help_text=_("Validate SSL Certificates of upstream servers")
|
||||||
|
)
|
||||||
|
|
||||||
skip_path_regex = models.TextField(
|
skip_path_regex = models.TextField(
|
||||||
default="",
|
default="",
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text=_(
|
help_text=_(
|
||||||
(
|
(
|
||||||
"Regular expression for which authentication is not required. "
|
"Regular expressions for which authentication is not required. "
|
||||||
"Each new line is interpreted as a new Regular Expression."
|
"Each new line is interpreted as a new Regular Expression."
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@ -64,6 +65,8 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
|||||||
CertificateKeyPair, on_delete=models.SET_NULL, null=True, blank=True,
|
CertificateKeyPair, on_delete=models.SET_NULL, null=True, blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cookie_secret = models.TextField(default=get_cookie_secret)
|
||||||
|
|
||||||
def form(self) -> Type[ModelForm]:
|
def form(self) -> Type[ModelForm]:
|
||||||
from passbook.providers.proxy.forms import ProxyProviderForm
|
from passbook.providers.proxy.forms import ProxyProviderForm
|
||||||
|
|
||||||
|
|||||||
@ -22,8 +22,9 @@ from sentry_sdk.integrations.celery import CeleryIntegration
|
|||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
|
|
||||||
from passbook import __version__
|
from passbook import __version__
|
||||||
|
from passbook.core.middleware import structlog_add_request_id
|
||||||
from passbook.lib.config import CONFIG
|
from passbook.lib.config import CONFIG
|
||||||
from passbook.lib.logging import add_process_id
|
from passbook.lib.logging import add_common_fields, add_process_id
|
||||||
from passbook.lib.sentry import before_send
|
from passbook.lib.sentry import before_send
|
||||||
|
|
||||||
|
|
||||||
@ -175,6 +176,8 @@ MIDDLEWARE = [
|
|||||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"passbook.core.middleware.RequestIDMiddleware",
|
||||||
|
"passbook.audit.middleware.AuditMiddleware",
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
@ -330,6 +333,8 @@ structlog.configure_once(
|
|||||||
structlog.stdlib.add_log_level,
|
structlog.stdlib.add_log_level,
|
||||||
structlog.stdlib.add_logger_name,
|
structlog.stdlib.add_logger_name,
|
||||||
add_process_id,
|
add_process_id,
|
||||||
|
add_common_fields(CONFIG.y("error_reporting.environment", "customer")),
|
||||||
|
structlog_add_request_id,
|
||||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||||
structlog.processors.TimeStamper(),
|
structlog.processors.TimeStamper(),
|
||||||
structlog.processors.StackInfoRenderer(),
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
|||||||
@ -24,6 +24,7 @@ class LDAPSourceSerializer(ModelSerializer):
|
|||||||
"user_group_membership_field",
|
"user_group_membership_field",
|
||||||
"object_uniqueness_field",
|
"object_uniqueness_field",
|
||||||
"sync_users",
|
"sync_users",
|
||||||
|
"sync_users_password",
|
||||||
"sync_groups",
|
"sync_groups",
|
||||||
"sync_parent_group",
|
"sync_parent_group",
|
||||||
"property_mappings",
|
"property_mappings",
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
"""passbook LDAP Authentication Backend"""
|
"""passbook LDAP Authentication Backend"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import ldap3
|
||||||
from django.contrib.auth.backends import ModelBackend
|
from django.contrib.auth.backends import ModelBackend
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.sources.ldap.connector import Connector
|
from passbook.core.models import User
|
||||||
from passbook.sources.ldap.models import LDAPSource
|
from passbook.sources.ldap.models import LDAPSource
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
@ -18,7 +21,56 @@ class LDAPBackend(ModelBackend):
|
|||||||
return None
|
return None
|
||||||
for source in LDAPSource.objects.filter(enabled=True):
|
for source in LDAPSource.objects.filter(enabled=True):
|
||||||
LOGGER.debug("LDAP Auth attempt", source=source)
|
LOGGER.debug("LDAP Auth attempt", source=source)
|
||||||
user = Connector(source).auth_user(**kwargs)
|
user = self.auth_user(source, **kwargs)
|
||||||
if user:
|
if user:
|
||||||
return user
|
return user
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def auth_user(
|
||||||
|
self, source: LDAPSource, password: str, **filters: str
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""Try to bind as either user_dn or mail with password.
|
||||||
|
Returns True on success, otherwise False"""
|
||||||
|
users = User.objects.filter(**filters)
|
||||||
|
if not users.exists():
|
||||||
|
return None
|
||||||
|
user: User = users.first()
|
||||||
|
if "distinguishedName" not in user.attributes:
|
||||||
|
LOGGER.debug(
|
||||||
|
"User doesn't have DN set, assuming not LDAP imported.", user=user
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
# Either has unusable password,
|
||||||
|
# or has a password, but couldn't be authenticated by ModelBackend.
|
||||||
|
# This means we check with a bind to see if the LDAP password has changed
|
||||||
|
if self.auth_user_by_bind(source, user, password):
|
||||||
|
# Password given successfully binds to LDAP, so we save it in our Database
|
||||||
|
LOGGER.debug("Updating user's password in DB", user=user)
|
||||||
|
user.set_password(password, signal=False)
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
# Password doesn't match
|
||||||
|
LOGGER.debug("Failed to bind, password invalid")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def auth_user_by_bind(
|
||||||
|
self, source: LDAPSource, user: User, password: str
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""Attempt authentication by binding to the LDAP server as `user`. This
|
||||||
|
method should be avoided as its slow to do the bind."""
|
||||||
|
# Try to bind as new user
|
||||||
|
LOGGER.debug("Attempting Binding as user", user=user)
|
||||||
|
try:
|
||||||
|
temp_connection = ldap3.Connection(
|
||||||
|
source.connection.server,
|
||||||
|
user=user.attributes.get("distinguishedName"),
|
||||||
|
password=password,
|
||||||
|
raise_exceptions=True,
|
||||||
|
)
|
||||||
|
temp_connection.bind()
|
||||||
|
return user
|
||||||
|
except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception:
|
||||||
|
LOGGER.debug("LDAPInvalidCredentialsResult", user=user, error=exception)
|
||||||
|
except ldap3.core.exceptions.LDAPException as exception:
|
||||||
|
LOGGER.warning(exception)
|
||||||
|
return None
|
||||||
|
|||||||
@ -37,6 +37,7 @@ class LDAPSourceForm(forms.ModelForm):
|
|||||||
"user_group_membership_field",
|
"user_group_membership_field",
|
||||||
"object_uniqueness_field",
|
"object_uniqueness_field",
|
||||||
"sync_users",
|
"sync_users",
|
||||||
|
"sync_users_password",
|
||||||
"sync_groups",
|
"sync_groups",
|
||||||
"sync_parent_group",
|
"sync_parent_group",
|
||||||
"property_mappings",
|
"property_mappings",
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 3.1.1 on 2020-09-21 09:02
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_sources_ldap", "0006_auto_20200915_1919"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ldapsource",
|
||||||
|
name="sync_users_password",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="When a user changes their password, sync it back to LDAP. This can only be enabled on a single LDAP source.",
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -6,7 +6,7 @@ from django.core.cache import cache
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.forms import ModelForm
|
from django.forms import ModelForm
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from ldap3 import Connection, Server
|
from ldap3 import ALL, Connection, Server
|
||||||
|
|
||||||
from passbook.core.models import Group, PropertyMapping, Source
|
from passbook.core.models import Group, PropertyMapping, Source
|
||||||
from passbook.lib.models import DomainlessURLValidator
|
from passbook.lib.models import DomainlessURLValidator
|
||||||
@ -52,6 +52,16 @@ class LDAPSource(Source):
|
|||||||
)
|
)
|
||||||
|
|
||||||
sync_users = models.BooleanField(default=True)
|
sync_users = models.BooleanField(default=True)
|
||||||
|
sync_users_password = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text=_(
|
||||||
|
(
|
||||||
|
"When a user changes their password, sync it back to LDAP. "
|
||||||
|
"This can only be enabled on a single LDAP source."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
sync_groups = models.BooleanField(default=True)
|
sync_groups = models.BooleanField(default=True)
|
||||||
sync_parent_group = models.ForeignKey(
|
sync_parent_group = models.ForeignKey(
|
||||||
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
|
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
|
||||||
@ -82,7 +92,7 @@ class LDAPSource(Source):
|
|||||||
def connection(self) -> Connection:
|
def connection(self) -> Connection:
|
||||||
"""Get a fully connected and bound LDAP Connection"""
|
"""Get a fully connected and bound LDAP Connection"""
|
||||||
if not self._connection:
|
if not self._connection:
|
||||||
server = Server(self.server_uri)
|
server = Server(self.server_uri, get_info=ALL)
|
||||||
self._connection = Connection(
|
self._connection = Connection(
|
||||||
server,
|
server,
|
||||||
raise_exceptions=True,
|
raise_exceptions=True,
|
||||||
@ -112,7 +122,7 @@ class LDAPPropertyMapping(PropertyMapping):
|
|||||||
return LDAPPropertyMappingForm
|
return LDAPPropertyMappingForm
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"LDAP Property Mapping {self.expression} -> {self.object_field}"
|
return self.name
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
|
|||||||
155
passbook/sources/ldap/password.py
Normal file
155
passbook/sources/ldap/password.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
"""Help validate and update passwords in LDAP"""
|
||||||
|
from enum import IntFlag
|
||||||
|
from re import split
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import ldap3
|
||||||
|
import ldap3.core.exceptions
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
|
from passbook.sources.ldap.models import LDAPSource
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
NON_ALPHA = r"~!@#$%^&*_-+=`|\(){}[]:;\"'<>,.?/"
|
||||||
|
RE_DISPLAYNAME_SEPARATORS = r",\.–—_\s#\t"
|
||||||
|
|
||||||
|
|
||||||
|
class PwdProperties(IntFlag):
|
||||||
|
"""Possible values for the pwdProperties attribute"""
|
||||||
|
|
||||||
|
DOMAIN_PASSWORD_COMPLEX = 1
|
||||||
|
DOMAIN_PASSWORD_NO_ANON_CHANGE = 2
|
||||||
|
DOMAIN_PASSWORD_NO_CLEAR_CHANGE = 4
|
||||||
|
DOMAIN_LOCKOUT_ADMINS = 8
|
||||||
|
DOMAIN_PASSWORD_STORE_CLEARTEXT = 16
|
||||||
|
DOMAIN_REFUSE_PASSWORD_CHANGE = 32
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordCategories(IntFlag):
|
||||||
|
"""Password categories as defined by Microsoft, a category can only be counted
|
||||||
|
once, hence intflag."""
|
||||||
|
|
||||||
|
NONE = 0
|
||||||
|
ALPHA_LOWER = 1
|
||||||
|
ALPHA_UPPER = 2
|
||||||
|
ALPHA_OTHER = 4
|
||||||
|
NUMERIC = 8
|
||||||
|
SYMBOL = 16
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPPasswordChanger:
|
||||||
|
"""Help validate and update passwords in LDAP"""
|
||||||
|
|
||||||
|
_source: LDAPSource
|
||||||
|
|
||||||
|
def __init__(self, source: LDAPSource) -> None:
|
||||||
|
self._source = source
|
||||||
|
|
||||||
|
def get_domain_root_dn(self) -> str:
|
||||||
|
"""Attempt to get root DN via MS specific fields or generic LDAP fields"""
|
||||||
|
info = self._source.connection.server.info
|
||||||
|
if "rootDomainNamingContext" in info.other:
|
||||||
|
return info.other["rootDomainNamingContext"][0]
|
||||||
|
naming_contexts = info.naming_contexts
|
||||||
|
naming_contexts.sort(key=len)
|
||||||
|
return naming_contexts[0]
|
||||||
|
|
||||||
|
def check_ad_password_complexity_enabled(self) -> bool:
|
||||||
|
"""Check if DOMAIN_PASSWORD_COMPLEX is enabled"""
|
||||||
|
root_dn = self.get_domain_root_dn()
|
||||||
|
root_attrs = self._source.connection.extend.standard.paged_search(
|
||||||
|
search_base=root_dn,
|
||||||
|
search_filter="(objectClass=*)",
|
||||||
|
search_scope=ldap3.BASE,
|
||||||
|
attributes=["pwdProperties"],
|
||||||
|
)
|
||||||
|
root_attrs = list(root_attrs)[0]
|
||||||
|
pwd_properties = PwdProperties(root_attrs["attributes"]["pwdProperties"])
|
||||||
|
if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def change_password(self, user: User, password: str):
|
||||||
|
"""Change user's password"""
|
||||||
|
user_dn = user.attributes.get("distinguishedName", None)
|
||||||
|
if not user_dn:
|
||||||
|
raise AttributeError("User has no distinguishedName set.")
|
||||||
|
self._source.connection.extend.microsoft.modify_password(user_dn, password)
|
||||||
|
|
||||||
|
def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:
|
||||||
|
"""Check if a password contains sAMAccount or displayName"""
|
||||||
|
users = list(
|
||||||
|
self._source.connection.extend.standard.paged_search(
|
||||||
|
search_base=user_dn,
|
||||||
|
search_filter=self._source.user_object_filter,
|
||||||
|
search_scope=ldap3.BASE,
|
||||||
|
attributes=["displayName", "sAMAccountName"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(users) != 1:
|
||||||
|
raise AssertionError()
|
||||||
|
user_attributes = users[0]["attributes"]
|
||||||
|
# If sAMAccountName is longer than 3 chars, check if its contained in password
|
||||||
|
if len(user_attributes["sAMAccountName"]) >= 3:
|
||||||
|
if password.lower() in user_attributes["sAMAccountName"].lower():
|
||||||
|
return False
|
||||||
|
display_name_tokens = split(
|
||||||
|
RE_DISPLAYNAME_SEPARATORS, user_attributes["displayName"]
|
||||||
|
)
|
||||||
|
for token in display_name_tokens:
|
||||||
|
# Ignore tokens under 3 chars
|
||||||
|
if len(token) < 3:
|
||||||
|
continue
|
||||||
|
if token.lower() in password.lower():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def ad_password_complexity(
|
||||||
|
self, password: str, user: Optional[User] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Check if password matches Active direcotry password policies
|
||||||
|
|
||||||
|
https://docs.microsoft.com/en-us/windows/security/threat-protection/
|
||||||
|
security-policy-settings/password-must-meet-complexity-requirements
|
||||||
|
"""
|
||||||
|
if user:
|
||||||
|
# Check if password contains sAMAccountName or displayNames
|
||||||
|
if "distinguishedName" in user.attributes:
|
||||||
|
existing_user_check = self._ad_check_password_existing(
|
||||||
|
password, user.attributes.get("distinguishedName")
|
||||||
|
)
|
||||||
|
if not existing_user_check:
|
||||||
|
LOGGER.debug("Password failed name check", user=user)
|
||||||
|
return existing_user_check
|
||||||
|
|
||||||
|
# Step 2, match at least 3 of 5 categories
|
||||||
|
matched_categories = PasswordCategories.NONE
|
||||||
|
required = 3
|
||||||
|
for letter in password:
|
||||||
|
# Only match one category per letter,
|
||||||
|
if letter.islower():
|
||||||
|
matched_categories |= PasswordCategories.ALPHA_LOWER
|
||||||
|
elif letter.isupper():
|
||||||
|
matched_categories |= PasswordCategories.ALPHA_UPPER
|
||||||
|
elif not letter.isascii() and letter.isalpha():
|
||||||
|
# Not exactly matching microsoft's policy, but count it as "Other unicode" char
|
||||||
|
# when its alpha and not ascii
|
||||||
|
matched_categories |= PasswordCategories.ALPHA_OTHER
|
||||||
|
elif letter.isnumeric():
|
||||||
|
matched_categories |= PasswordCategories.NUMERIC
|
||||||
|
elif letter in NON_ALPHA:
|
||||||
|
matched_categories |= PasswordCategories.SYMBOL
|
||||||
|
if bin(matched_categories).count("1") < required:
|
||||||
|
LOGGER.debug(
|
||||||
|
"Password didn't match enough categories",
|
||||||
|
has=matched_categories,
|
||||||
|
must=required,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
LOGGER.debug(
|
||||||
|
"Password matched categories", has=matched_categories, must=required
|
||||||
|
)
|
||||||
|
return True
|
||||||
@ -1,9 +1,19 @@
|
|||||||
"""passbook ldap source signals"""
|
"""passbook ldap source signals"""
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from ldap3.core.exceptions import LDAPException
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
|
from passbook.core.signals import password_changed
|
||||||
|
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||||
from passbook.sources.ldap.models import LDAPSource
|
from passbook.sources.ldap.models import LDAPSource
|
||||||
|
from passbook.sources.ldap.password import LDAPPasswordChanger
|
||||||
from passbook.sources.ldap.tasks import sync_single
|
from passbook.sources.ldap.tasks import sync_single
|
||||||
|
from passbook.stages.prompt.signals import password_validate
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=LDAPSource)
|
@receiver(post_save, sender=LDAPSource)
|
||||||
@ -12,3 +22,38 @@ def sync_ldap_source_on_save(sender, instance: LDAPSource, **_):
|
|||||||
"""Ensure that source is synced on save (if enabled)"""
|
"""Ensure that source is synced on save (if enabled)"""
|
||||||
if instance.enabled:
|
if instance.enabled:
|
||||||
sync_single.delay(instance.pk)
|
sync_single.delay(instance.pk)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(password_validate)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def ldap_password_validate(sender, password: str, plan_context: Dict[str, Any], **__):
|
||||||
|
"""if there's an LDAP Source with enabled password sync, check the password"""
|
||||||
|
sources = LDAPSource.objects.filter(sync_users_password=True)
|
||||||
|
if not sources.exists():
|
||||||
|
return
|
||||||
|
source = sources.first()
|
||||||
|
changer = LDAPPasswordChanger(source)
|
||||||
|
if changer.check_ad_password_complexity_enabled():
|
||||||
|
passing = changer.ad_password_complexity(
|
||||||
|
password, plan_context.get(PLAN_CONTEXT_PENDING_USER, None)
|
||||||
|
)
|
||||||
|
if not passing:
|
||||||
|
raise ValidationError(
|
||||||
|
_("Password does not match Active Direcory Complexity.")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(password_changed)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def ldap_sync_password(sender, user: User, password: str, **_):
|
||||||
|
"""Connect to ldap and update password. We do this in the background to get
|
||||||
|
automatic retries on error."""
|
||||||
|
sources = LDAPSource.objects.filter(sync_users_password=True)
|
||||||
|
if not sources.exists():
|
||||||
|
return
|
||||||
|
source = sources.first()
|
||||||
|
changer = LDAPPasswordChanger(source)
|
||||||
|
try:
|
||||||
|
changer.change_password(user, password)
|
||||||
|
except LDAPException as exc:
|
||||||
|
raise ValidationError("Failed to set password") from exc
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"""Wrapper for ldap3 to easily manage user"""
|
"""Sync LDAP Users and groups into passbook"""
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict
|
||||||
|
|
||||||
import ldap3
|
import ldap3
|
||||||
import ldap3.core.exceptions
|
import ldap3.core.exceptions
|
||||||
@ -13,19 +13,14 @@ from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
|||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class Connector:
|
class LDAPSynchronizer:
|
||||||
"""Wrapper for ldap3 to easily manage user authentication and creation"""
|
"""Sync LDAP Users and groups into passbook"""
|
||||||
|
|
||||||
_source: LDAPSource
|
_source: LDAPSource
|
||||||
|
|
||||||
def __init__(self, source: LDAPSource):
|
def __init__(self, source: LDAPSource):
|
||||||
self._source = source
|
self._source = source
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def encode_pass(password: str) -> bytes:
|
|
||||||
"""Encodes a plain-text password so it can be used by AD"""
|
|
||||||
return '"{}"'.format(password).encode("utf-16-le")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def base_dn_users(self) -> str:
|
def base_dn_users(self) -> str:
|
||||||
"""Shortcut to get full base_dn for user lookups"""
|
"""Shortcut to get full base_dn for user lookups"""
|
||||||
@ -187,48 +182,3 @@ class Connector:
|
|||||||
"distinguishedName"
|
"distinguishedName"
|
||||||
)
|
)
|
||||||
return properties
|
return properties
|
||||||
|
|
||||||
def auth_user(self, password: str, **filters: str) -> Optional[User]:
|
|
||||||
"""Try to bind as either user_dn or mail with password.
|
|
||||||
Returns True on success, otherwise False"""
|
|
||||||
users = User.objects.filter(**filters)
|
|
||||||
if not users.exists():
|
|
||||||
return None
|
|
||||||
user: User = users.first()
|
|
||||||
if "distinguishedName" not in user.attributes:
|
|
||||||
LOGGER.debug(
|
|
||||||
"User doesn't have DN set, assuming not LDAP imported.", user=user
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
# Either has unusable password,
|
|
||||||
# or has a password, but couldn't be authenticated by ModelBackend.
|
|
||||||
# This means we check with a bind to see if the LDAP password has changed
|
|
||||||
if self.auth_user_by_bind(user, password):
|
|
||||||
# Password given successfully binds to LDAP, so we save it in our Database
|
|
||||||
LOGGER.debug("Updating user's password in DB", user=user)
|
|
||||||
user.set_password(password)
|
|
||||||
user.save()
|
|
||||||
return user
|
|
||||||
# Password doesn't match
|
|
||||||
LOGGER.debug("Failed to bind, password invalid")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def auth_user_by_bind(self, user: User, password: str) -> Optional[User]:
|
|
||||||
"""Attempt authentication by binding to the LDAP server as `user`. This
|
|
||||||
method should be avoided as its slow to do the bind."""
|
|
||||||
# Try to bind as new user
|
|
||||||
LOGGER.debug("Attempting Binding as user", user=user)
|
|
||||||
try:
|
|
||||||
temp_connection = ldap3.Connection(
|
|
||||||
self._source.connection.server,
|
|
||||||
user=user.attributes.get("distinguishedName"),
|
|
||||||
password=password,
|
|
||||||
raise_exceptions=True,
|
|
||||||
)
|
|
||||||
temp_connection.bind()
|
|
||||||
return user
|
|
||||||
except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception:
|
|
||||||
LOGGER.debug("LDAPInvalidCredentialsResult", user=user, error=exception)
|
|
||||||
except ldap3.core.exceptions.LDAPException as exception:
|
|
||||||
LOGGER.warning(exception)
|
|
||||||
return None
|
|
||||||
@ -4,8 +4,8 @@ from time import time
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
from passbook.root.celery import CELERY_APP
|
from passbook.root.celery import CELERY_APP
|
||||||
from passbook.sources.ldap.connector import Connector
|
|
||||||
from passbook.sources.ldap.models import LDAPSource
|
from passbook.sources.ldap.models import LDAPSource
|
||||||
|
from passbook.sources.ldap.sync import LDAPSynchronizer
|
||||||
|
|
||||||
|
|
||||||
@CELERY_APP.task()
|
@CELERY_APP.task()
|
||||||
@ -19,9 +19,9 @@ def sync():
|
|||||||
def sync_single(source_pk):
|
def sync_single(source_pk):
|
||||||
"""Sync a single source"""
|
"""Sync a single source"""
|
||||||
source: LDAPSource = LDAPSource.objects.get(pk=source_pk)
|
source: LDAPSource = LDAPSource.objects.get(pk=source_pk)
|
||||||
connector = Connector(source)
|
syncer = LDAPSynchronizer(source)
|
||||||
connector.sync_users()
|
syncer.sync_users()
|
||||||
connector.sync_groups()
|
syncer.sync_groups()
|
||||||
connector.sync_membership()
|
syncer.sync_membership()
|
||||||
cache_key = source.state_cache_prefix("last_sync")
|
cache_key = source.state_cache_prefix("last_sync")
|
||||||
cache.set(cache_key, time(), timeout=60 * 60)
|
cache.set(cache_key, time(), timeout=60 * 60)
|
||||||
|
|||||||
@ -1,149 +0,0 @@
|
|||||||
"""LDAP Source tests"""
|
|
||||||
from unittest.mock import Mock, PropertyMock, patch
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
|
|
||||||
|
|
||||||
from passbook.core.models import Group, User
|
|
||||||
from passbook.providers.oauth2.generators import generate_client_secret
|
|
||||||
from passbook.sources.ldap.auth import LDAPBackend
|
|
||||||
from passbook.sources.ldap.connector import Connector
|
|
||||||
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
|
||||||
from passbook.sources.ldap.tasks import sync
|
|
||||||
|
|
||||||
|
|
||||||
def _build_mock_connection() -> Connection:
|
|
||||||
"""Create mock connection"""
|
|
||||||
server = Server("my_fake_server", get_info=OFFLINE_AD_2012_R2)
|
|
||||||
_pass = "foo" # noqa # nosec
|
|
||||||
connection = Connection(
|
|
||||||
server,
|
|
||||||
user="cn=my_user,ou=test,o=lab",
|
|
||||||
password=_pass,
|
|
||||||
client_strategy=MOCK_SYNC,
|
|
||||||
)
|
|
||||||
connection.strategy.add_entry(
|
|
||||||
"cn=group1,ou=groups,ou=test,o=lab",
|
|
||||||
{
|
|
||||||
"name": "test-group",
|
|
||||||
"objectSid": "unique-test-group",
|
|
||||||
"objectCategory": "Group",
|
|
||||||
"distinguishedName": "cn=group1,ou=groups,ou=test,o=lab",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Group without SID
|
|
||||||
connection.strategy.add_entry(
|
|
||||||
"cn=group2,ou=groups,ou=test,o=lab",
|
|
||||||
{
|
|
||||||
"name": "test-group",
|
|
||||||
"objectCategory": "Group",
|
|
||||||
"distinguishedName": "cn=group2,ou=groups,ou=test,o=lab",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
connection.strategy.add_entry(
|
|
||||||
"cn=user0,ou=users,ou=test,o=lab",
|
|
||||||
{
|
|
||||||
"userPassword": LDAP_PASSWORD,
|
|
||||||
"sAMAccountName": "user0_sn",
|
|
||||||
"name": "user0_sn",
|
|
||||||
"revision": 0,
|
|
||||||
"objectSid": "user0",
|
|
||||||
"objectCategory": "Person",
|
|
||||||
"memberOf": "cn=group1,ou=groups,ou=test,o=lab",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# User without SID
|
|
||||||
connection.strategy.add_entry(
|
|
||||||
"cn=user1,ou=users,ou=test,o=lab",
|
|
||||||
{
|
|
||||||
"userPassword": "test1111",
|
|
||||||
"sAMAccountName": "user2_sn",
|
|
||||||
"name": "user1_sn",
|
|
||||||
"revision": 0,
|
|
||||||
"objectCategory": "Person",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Duplicate users
|
|
||||||
connection.strategy.add_entry(
|
|
||||||
"cn=user2,ou=users,ou=test,o=lab",
|
|
||||||
{
|
|
||||||
"userPassword": "test2222",
|
|
||||||
"sAMAccountName": "user2_sn",
|
|
||||||
"name": "user2_sn",
|
|
||||||
"revision": 0,
|
|
||||||
"objectSid": "unique-test2222",
|
|
||||||
"objectCategory": "Person",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
connection.strategy.add_entry(
|
|
||||||
"cn=user3,ou=users,ou=test,o=lab",
|
|
||||||
{
|
|
||||||
"userPassword": "test2222",
|
|
||||||
"sAMAccountName": "user2_sn",
|
|
||||||
"name": "user2_sn",
|
|
||||||
"revision": 0,
|
|
||||||
"objectSid": "unique-test2222",
|
|
||||||
"objectCategory": "Person",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
connection.bind()
|
|
||||||
return connection
|
|
||||||
|
|
||||||
|
|
||||||
LDAP_PASSWORD = generate_client_secret()
|
|
||||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection())
|
|
||||||
|
|
||||||
|
|
||||||
class LDAPSourceTests(TestCase):
|
|
||||||
"""LDAP Source tests"""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.source = LDAPSource.objects.create(
|
|
||||||
name="ldap",
|
|
||||||
slug="ldap",
|
|
||||||
base_dn="ou=test,o=lab",
|
|
||||||
additional_user_dn="ou=users",
|
|
||||||
additional_group_dn="ou=groups",
|
|
||||||
)
|
|
||||||
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
|
|
||||||
self.source.save()
|
|
||||||
|
|
||||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
|
||||||
def test_sync_users(self):
|
|
||||||
"""Test user sync"""
|
|
||||||
connector = Connector(self.source)
|
|
||||||
connector.sync_users()
|
|
||||||
self.assertTrue(User.objects.filter(username="user0_sn").exists())
|
|
||||||
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
|
||||||
|
|
||||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
|
||||||
def test_sync_groups(self):
|
|
||||||
"""Test group sync"""
|
|
||||||
connector = Connector(self.source)
|
|
||||||
connector.sync_groups()
|
|
||||||
connector.sync_membership()
|
|
||||||
group = Group.objects.filter(name="test-group")
|
|
||||||
self.assertTrue(group.exists())
|
|
||||||
|
|
||||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
|
||||||
def test_auth(self):
|
|
||||||
"""Test Cached auth"""
|
|
||||||
connector = Connector(self.source)
|
|
||||||
connector.sync_users()
|
|
||||||
|
|
||||||
user = User.objects.get(username="user0_sn")
|
|
||||||
auth_user_by_bind = Mock(return_value=user)
|
|
||||||
with patch(
|
|
||||||
"passbook.sources.ldap.connector.Connector.auth_user_by_bind",
|
|
||||||
auth_user_by_bind,
|
|
||||||
):
|
|
||||||
backend = LDAPBackend()
|
|
||||||
self.assertEqual(
|
|
||||||
backend.authenticate(None, username="user0_sn", password=LDAP_PASSWORD),
|
|
||||||
user,
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
|
||||||
def test_tasks(self):
|
|
||||||
"""Test Scheduled tasks"""
|
|
||||||
sync()
|
|
||||||
0
passbook/sources/ldap/tests/__init__.py
Normal file
0
passbook/sources/ldap/tests/__init__.py
Normal file
47
passbook/sources/ldap/tests/test_auth.py
Normal file
47
passbook/sources/ldap/tests/test_auth.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"""LDAP Source tests"""
|
||||||
|
from unittest.mock import Mock, PropertyMock, patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
|
from passbook.providers.oauth2.generators import generate_client_secret
|
||||||
|
from passbook.sources.ldap.auth import LDAPBackend
|
||||||
|
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||||
|
from passbook.sources.ldap.sync import LDAPSynchronizer
|
||||||
|
from passbook.sources.ldap.tests.utils import _build_mock_connection
|
||||||
|
|
||||||
|
LDAP_PASSWORD = generate_client_secret()
|
||||||
|
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPSyncTests(TestCase):
|
||||||
|
"""LDAP Sync tests"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.source = LDAPSource.objects.create(
|
||||||
|
name="ldap",
|
||||||
|
slug="ldap",
|
||||||
|
base_dn="DC=AD2012,DC=LAB",
|
||||||
|
additional_user_dn="ou=users",
|
||||||
|
additional_group_dn="ou=groups",
|
||||||
|
)
|
||||||
|
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
|
||||||
|
self.source.save()
|
||||||
|
|
||||||
|
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||||
|
def test_auth_synced_user(self):
|
||||||
|
"""Test Cached auth"""
|
||||||
|
syncer = LDAPSynchronizer(self.source)
|
||||||
|
syncer.sync_users()
|
||||||
|
|
||||||
|
user = User.objects.get(username="user0_sn")
|
||||||
|
auth_user_by_bind = Mock(return_value=user)
|
||||||
|
with patch(
|
||||||
|
"passbook.sources.ldap.auth.LDAPBackend.auth_user_by_bind",
|
||||||
|
auth_user_by_bind,
|
||||||
|
):
|
||||||
|
backend = LDAPBackend()
|
||||||
|
self.assertEqual(
|
||||||
|
backend.authenticate(None, username="user0_sn", password=LDAP_PASSWORD),
|
||||||
|
user,
|
||||||
|
)
|
||||||
54
passbook/sources/ldap/tests/test_password.py
Normal file
54
passbook/sources/ldap/tests/test_password.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""LDAP Source tests"""
|
||||||
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
|
from passbook.providers.oauth2.generators import generate_client_secret
|
||||||
|
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||||
|
from passbook.sources.ldap.password import LDAPPasswordChanger
|
||||||
|
from passbook.sources.ldap.tests.utils import _build_mock_connection
|
||||||
|
|
||||||
|
LDAP_PASSWORD = generate_client_secret()
|
||||||
|
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPPasswordTests(TestCase):
|
||||||
|
"""LDAP Password tests"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.source = LDAPSource.objects.create(
|
||||||
|
name="ldap",
|
||||||
|
slug="ldap",
|
||||||
|
base_dn="DC=AD2012,DC=LAB",
|
||||||
|
additional_user_dn="ou=users",
|
||||||
|
additional_group_dn="ou=groups",
|
||||||
|
)
|
||||||
|
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
|
||||||
|
self.source.save()
|
||||||
|
|
||||||
|
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||||
|
def test_password_complexity(self):
|
||||||
|
"""Test password without user"""
|
||||||
|
pwc = LDAPPasswordChanger(self.source)
|
||||||
|
self.assertFalse(pwc.ad_password_complexity("test")) # 1 category
|
||||||
|
self.assertFalse(pwc.ad_password_complexity("test1")) # 2 categories
|
||||||
|
self.assertTrue(pwc.ad_password_complexity("test1!")) # 2 categories
|
||||||
|
|
||||||
|
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||||
|
def test_password_complexity_user(self):
|
||||||
|
"""test password with user"""
|
||||||
|
pwc = LDAPPasswordChanger(self.source)
|
||||||
|
user = User.objects.create(
|
||||||
|
username="test",
|
||||||
|
attributes={"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB"},
|
||||||
|
)
|
||||||
|
self.assertFalse(pwc.ad_password_complexity("test", user)) # 1 category
|
||||||
|
self.assertFalse(pwc.ad_password_complexity("test1", user)) # 2 categories
|
||||||
|
self.assertTrue(pwc.ad_password_complexity("test1!", user)) # 2 categories
|
||||||
|
self.assertFalse(
|
||||||
|
pwc.ad_password_complexity("erin!qewrqewr", user)
|
||||||
|
) # displayName token
|
||||||
|
self.assertFalse(
|
||||||
|
pwc.ad_password_complexity("hagens!qewrqewr", user)
|
||||||
|
) # displayName token
|
||||||
51
passbook/sources/ldap/tests/test_sync.py
Normal file
51
passbook/sources/ldap/tests/test_sync.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"""LDAP Source tests"""
|
||||||
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from passbook.core.models import Group, User
|
||||||
|
from passbook.providers.oauth2.generators import generate_client_secret
|
||||||
|
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||||
|
from passbook.sources.ldap.sync import LDAPSynchronizer
|
||||||
|
from passbook.sources.ldap.tasks import sync
|
||||||
|
from passbook.sources.ldap.tests.utils import _build_mock_connection
|
||||||
|
|
||||||
|
LDAP_PASSWORD = generate_client_secret()
|
||||||
|
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPSyncTests(TestCase):
|
||||||
|
"""LDAP Sync tests"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.source = LDAPSource.objects.create(
|
||||||
|
name="ldap",
|
||||||
|
slug="ldap",
|
||||||
|
base_dn="DC=AD2012,DC=LAB",
|
||||||
|
additional_user_dn="ou=users",
|
||||||
|
additional_group_dn="ou=groups",
|
||||||
|
)
|
||||||
|
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
|
||||||
|
self.source.save()
|
||||||
|
|
||||||
|
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||||
|
def test_sync_users(self):
|
||||||
|
"""Test user sync"""
|
||||||
|
syncer = LDAPSynchronizer(self.source)
|
||||||
|
syncer.sync_users()
|
||||||
|
self.assertTrue(User.objects.filter(username="user0_sn").exists())
|
||||||
|
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
||||||
|
|
||||||
|
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||||
|
def test_sync_groups(self):
|
||||||
|
"""Test group sync"""
|
||||||
|
syncer = LDAPSynchronizer(self.source)
|
||||||
|
syncer.sync_groups()
|
||||||
|
syncer.sync_membership()
|
||||||
|
group = Group.objects.filter(name="test-group")
|
||||||
|
self.assertTrue(group.exists())
|
||||||
|
|
||||||
|
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||||
|
def test_tasks(self):
|
||||||
|
"""Test Scheduled tasks"""
|
||||||
|
sync()
|
||||||
93
passbook/sources/ldap/tests/utils.py
Normal file
93
passbook/sources/ldap/tests/utils.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
"""ldap testing utils"""
|
||||||
|
|
||||||
|
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mock_connection(password: str) -> Connection:
|
||||||
|
"""Create mock connection"""
|
||||||
|
server = Server("my_fake_server", get_info=OFFLINE_AD_2012_R2)
|
||||||
|
_pass = "foo" # noqa # nosec
|
||||||
|
connection = Connection(
|
||||||
|
server,
|
||||||
|
user="cn=my_user,DC=AD2012,DC=LAB",
|
||||||
|
password=_pass,
|
||||||
|
client_strategy=MOCK_SYNC,
|
||||||
|
)
|
||||||
|
# Entry for password checking
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=user,ou=users,DC=AD2012,DC=LAB",
|
||||||
|
{
|
||||||
|
"name": "test-user",
|
||||||
|
"objectSid": "unique-test-group",
|
||||||
|
"objectCategory": "Person",
|
||||||
|
"displayName": "Erin M. Hagens",
|
||||||
|
"sAMAccountName": "sAMAccountName",
|
||||||
|
"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=group1,ou=groups,DC=AD2012,DC=LAB",
|
||||||
|
{
|
||||||
|
"name": "test-group",
|
||||||
|
"objectSid": "unique-test-group",
|
||||||
|
"objectCategory": "Group",
|
||||||
|
"distinguishedName": "cn=group1,ou=groups,DC=AD2012,DC=LAB",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Group without SID
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=group2,ou=groups,DC=AD2012,DC=LAB",
|
||||||
|
{
|
||||||
|
"name": "test-group",
|
||||||
|
"objectCategory": "Group",
|
||||||
|
"distinguishedName": "cn=group2,ou=groups,DC=AD2012,DC=LAB",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=user0,ou=users,DC=AD2012,DC=LAB",
|
||||||
|
{
|
||||||
|
"userPassword": password,
|
||||||
|
"sAMAccountName": "user0_sn",
|
||||||
|
"name": "user0_sn",
|
||||||
|
"revision": 0,
|
||||||
|
"objectSid": "user0",
|
||||||
|
"objectCategory": "Person",
|
||||||
|
"memberOf": "cn=group1,ou=groups,DC=AD2012,DC=LAB",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# User without SID
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=user1,ou=users,DC=AD2012,DC=LAB",
|
||||||
|
{
|
||||||
|
"userPassword": "test1111",
|
||||||
|
"sAMAccountName": "user2_sn",
|
||||||
|
"name": "user1_sn",
|
||||||
|
"revision": 0,
|
||||||
|
"objectCategory": "Person",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Duplicate users
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=user2,ou=users,DC=AD2012,DC=LAB",
|
||||||
|
{
|
||||||
|
"userPassword": "test2222",
|
||||||
|
"sAMAccountName": "user2_sn",
|
||||||
|
"name": "user2_sn",
|
||||||
|
"revision": 0,
|
||||||
|
"objectSid": "unique-test2222",
|
||||||
|
"objectCategory": "Person",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=user3,ou=users,DC=AD2012,DC=LAB",
|
||||||
|
{
|
||||||
|
"userPassword": "test2222",
|
||||||
|
"sAMAccountName": "user2_sn",
|
||||||
|
"name": "user2_sn",
|
||||||
|
"revision": 0,
|
||||||
|
"objectSid": "unique-test2222",
|
||||||
|
"objectCategory": "Person",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.bind()
|
||||||
|
return connection
|
||||||
@ -1,278 +0,0 @@
|
|||||||
"""OAuth Clients"""
|
|
||||||
import json
|
|
||||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
|
||||||
from urllib.parse import parse_qs, urlencode
|
|
||||||
|
|
||||||
from django.http import HttpRequest
|
|
||||||
from django.utils.crypto import constant_time_compare, get_random_string
|
|
||||||
from django.utils.encoding import force_str
|
|
||||||
from requests import Session
|
|
||||||
from requests.exceptions import RequestException
|
|
||||||
from requests_oauthlib import OAuth1
|
|
||||||
from structlog import get_logger
|
|
||||||
|
|
||||||
from passbook import __version__
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from passbook.sources.oauth.models import OAuthSource
|
|
||||||
|
|
||||||
|
|
||||||
class BaseOAuthClient:
|
|
||||||
"""Base OAuth Client"""
|
|
||||||
|
|
||||||
session: Session
|
|
||||||
source: "OAuthSource"
|
|
||||||
|
|
||||||
def __init__(self, source: "OAuthSource", token=""): # nosec
|
|
||||||
self.source = source
|
|
||||||
self.token = token
|
|
||||||
self.session = Session()
|
|
||||||
self.session.headers.update({"User-Agent": "passbook %s" % __version__})
|
|
||||||
|
|
||||||
def get_access_token(
|
|
||||||
self, request: HttpRequest, callback=None
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"Fetch access token from callback request."
|
|
||||||
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
|
|
||||||
|
|
||||||
def get_profile_info(self, token: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
|
||||||
"Fetch user profile information."
|
|
||||||
try:
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"{token['token_type']} {token['access_token']}"
|
|
||||||
}
|
|
||||||
response = self.session.request(
|
|
||||||
"get", self.source.profile_url, headers=headers,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
except RequestException as exc:
|
|
||||||
LOGGER.warning("Unable to fetch user profile", exc=exc)
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def get_redirect_args(self, request, callback) -> Dict[str, str]:
|
|
||||||
"Get request parameters for redirect url."
|
|
||||||
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
|
|
||||||
|
|
||||||
def get_redirect_url(self, request, callback, parameters=None):
|
|
||||||
"Build authentication redirect url."
|
|
||||||
args = self.get_redirect_args(request, callback=callback)
|
|
||||||
additional = parameters or {}
|
|
||||||
args.update(additional)
|
|
||||||
params = urlencode(args)
|
|
||||||
LOGGER.info("redirect args", **args)
|
|
||||||
return "{0}?{1}".format(self.source.authorization_url, params)
|
|
||||||
|
|
||||||
def parse_raw_token(self, raw_token):
|
|
||||||
"Parse token and secret from raw token response."
|
|
||||||
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
|
|
||||||
|
|
||||||
@property
|
|
||||||
def session_key(self):
|
|
||||||
"""Return Session Key"""
|
|
||||||
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthClient(BaseOAuthClient):
|
|
||||||
"""OAuth1 Client"""
|
|
||||||
|
|
||||||
_default_headers = {
|
|
||||||
"Accept": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_access_token(
|
|
||||||
self, request: HttpRequest, callback=None
|
|
||||||
) -> Optional[Dict[str, str]]:
|
|
||||||
"Fetch access token from callback request."
|
|
||||||
raw_token = request.session.get(self.session_key, None)
|
|
||||||
verifier = request.GET.get("oauth_verifier", None)
|
|
||||||
if raw_token is not None and verifier is not None:
|
|
||||||
data = {
|
|
||||||
"oauth_verifier": verifier,
|
|
||||||
"oauth_callback": callback,
|
|
||||||
"token": raw_token,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
response = self.session.request(
|
|
||||||
"post",
|
|
||||||
self.source.access_token_url,
|
|
||||||
data=data,
|
|
||||||
headers=self._default_headers,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
except RequestException as exc:
|
|
||||||
LOGGER.warning("Unable to fetch access token", exc=exc)
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return response.json()
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_request_token(self, request, callback):
|
|
||||||
"Fetch the OAuth request token. Only required for OAuth 1.0."
|
|
||||||
callback = force_str(request.build_absolute_uri(callback))
|
|
||||||
try:
|
|
||||||
response = self.session.request(
|
|
||||||
"post",
|
|
||||||
self.source.request_token_url,
|
|
||||||
data={"oauth_callback": callback},
|
|
||||||
headers=self._default_headers,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
except RequestException as exc:
|
|
||||||
LOGGER.warning("Unable to fetch request token", exc=exc)
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return response.text
|
|
||||||
|
|
||||||
def get_redirect_args(self, request, callback):
|
|
||||||
"Get request parameters for redirect url."
|
|
||||||
callback = force_str(request.build_absolute_uri(callback))
|
|
||||||
raw_token = self.get_request_token(request, callback)
|
|
||||||
token, secret = self.parse_raw_token(raw_token)
|
|
||||||
if token is not None and secret is not None:
|
|
||||||
request.session[self.session_key] = raw_token
|
|
||||||
return {
|
|
||||||
"oauth_token": token,
|
|
||||||
"oauth_callback": callback,
|
|
||||||
}
|
|
||||||
|
|
||||||
def parse_raw_token(self, raw_token):
|
|
||||||
"Parse token and secret from raw token response."
|
|
||||||
if raw_token is None:
|
|
||||||
return (None, None)
|
|
||||||
query_string = parse_qs(raw_token)
|
|
||||||
token = query_string.get("oauth_token", [None])[0]
|
|
||||||
secret = query_string.get("oauth_token_secret", [None])[0]
|
|
||||||
return (token, secret)
|
|
||||||
|
|
||||||
def request(self, method, url, **kwargs):
|
|
||||||
"Build remote url request. Constructs necessary auth."
|
|
||||||
user_token = kwargs.pop("token", self.token)
|
|
||||||
token, secret = self.parse_raw_token(user_token)
|
|
||||||
callback = kwargs.pop("oauth_callback", None)
|
|
||||||
verifier = kwargs.get("data", {}).pop("oauth_verifier", None)
|
|
||||||
oauth = OAuth1(
|
|
||||||
resource_owner_key=token,
|
|
||||||
resource_owner_secret=secret,
|
|
||||||
client_key=self.source.consumer_key,
|
|
||||||
client_secret=self.source.consumer_secret,
|
|
||||||
verifier=verifier,
|
|
||||||
callback_uri=callback,
|
|
||||||
)
|
|
||||||
kwargs["auth"] = oauth
|
|
||||||
return super(OAuthClient, self).session.request(method, url, **kwargs)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def session_key(self):
|
|
||||||
return "oauth-client-{0}-request-token".format(self.source.name)
|
|
||||||
|
|
||||||
|
|
||||||
class OAuth2Client(BaseOAuthClient):
|
|
||||||
"""OAuth2 Client"""
|
|
||||||
|
|
||||||
_default_headers = {
|
|
||||||
"Accept": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def check_application_state(self, request, callback):
|
|
||||||
"Check optional state parameter."
|
|
||||||
stored = request.session.get(self.session_key, None)
|
|
||||||
returned = request.GET.get("state", None)
|
|
||||||
check = False
|
|
||||||
if stored is not None:
|
|
||||||
if returned is not None:
|
|
||||||
check = constant_time_compare(stored, returned)
|
|
||||||
else:
|
|
||||||
LOGGER.warning("No state parameter returned by the source.")
|
|
||||||
else:
|
|
||||||
LOGGER.warning("No state stored in the sesssion.")
|
|
||||||
return check
|
|
||||||
|
|
||||||
def get_access_token(self, request, callback=None, **request_kwargs):
|
|
||||||
"Fetch access token from callback request."
|
|
||||||
callback = request.build_absolute_uri(callback or request.path)
|
|
||||||
if not self.check_application_state(request, callback):
|
|
||||||
LOGGER.warning("Application state check failed.")
|
|
||||||
return None
|
|
||||||
if "code" in request.GET:
|
|
||||||
args = {
|
|
||||||
"client_id": self.source.consumer_key,
|
|
||||||
"redirect_uri": callback,
|
|
||||||
"client_secret": self.source.consumer_secret,
|
|
||||||
"code": request.GET["code"],
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
LOGGER.warning("No code returned by the source")
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
response = self.session.request(
|
|
||||||
"post",
|
|
||||||
self.source.access_token_url,
|
|
||||||
data=args,
|
|
||||||
headers=self._default_headers,
|
|
||||||
**request_kwargs,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
except RequestException as exc:
|
|
||||||
LOGGER.warning("Unable to fetch access token", exc=exc)
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get_application_state(self, request, callback):
|
|
||||||
"Generate state optional parameter."
|
|
||||||
return get_random_string(32)
|
|
||||||
|
|
||||||
def get_redirect_args(self, request, callback):
|
|
||||||
"Get request parameters for redirect url."
|
|
||||||
callback = request.build_absolute_uri(callback)
|
|
||||||
args = {
|
|
||||||
"client_id": self.source.consumer_key,
|
|
||||||
"redirect_uri": callback,
|
|
||||||
"response_type": "code",
|
|
||||||
}
|
|
||||||
state = self.get_application_state(request, callback)
|
|
||||||
if state is not None:
|
|
||||||
args["state"] = state
|
|
||||||
request.session[self.session_key] = state
|
|
||||||
return args
|
|
||||||
|
|
||||||
def parse_raw_token(self, raw_token):
|
|
||||||
"Parse token and secret from raw token response."
|
|
||||||
if raw_token is None:
|
|
||||||
return (None, None)
|
|
||||||
# Load as json first then parse as query string
|
|
||||||
try:
|
|
||||||
token_data = json.loads(raw_token)
|
|
||||||
except ValueError:
|
|
||||||
token = parse_qs(raw_token).get("access_token", [None])[0]
|
|
||||||
else:
|
|
||||||
token = token_data.get("access_token", None)
|
|
||||||
return (token, None)
|
|
||||||
|
|
||||||
def request(self, method, url, **kwargs):
|
|
||||||
"Build remote url request. Constructs necessary auth."
|
|
||||||
user_token = kwargs.pop("token", self.token)
|
|
||||||
token, _ = self.parse_raw_token(user_token)
|
|
||||||
if token is not None:
|
|
||||||
params = kwargs.get("params", {})
|
|
||||||
params["access_token"] = token
|
|
||||||
kwargs["params"] = params
|
|
||||||
return super(OAuth2Client, self).session.request(method, url, **kwargs)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def session_key(self):
|
|
||||||
return "oauth-client-{0}-request-state".format(self.source.name)
|
|
||||||
|
|
||||||
|
|
||||||
def get_client(source, token=""): # nosec
|
|
||||||
"Return the API client for the given source."
|
|
||||||
cls = OAuth2Client
|
|
||||||
if source.request_token_url:
|
|
||||||
cls = OAuthClient
|
|
||||||
return cls(source, token)
|
|
||||||
0
passbook/sources/oauth/clients/__init__.py
Normal file
0
passbook/sources/oauth/clients/__init__.py
Normal file
75
passbook/sources/oauth/clients/base.py
Normal file
75
passbook/sources/oauth/clients/base.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"""OAuth Clients"""
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from requests import Session
|
||||||
|
from requests.exceptions import RequestException
|
||||||
|
from requests.models import Response
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from passbook import __version__
|
||||||
|
from passbook.sources.oauth.models import OAuthSource
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class BaseOAuthClient:
|
||||||
|
"""Base OAuth Client"""
|
||||||
|
|
||||||
|
session: Session
|
||||||
|
|
||||||
|
source: OAuthSource
|
||||||
|
request: HttpRequest
|
||||||
|
|
||||||
|
callback: Optional[str]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, source: OAuthSource, request: HttpRequest, callback: Optional[str] = None
|
||||||
|
):
|
||||||
|
self.source = source
|
||||||
|
self.session = Session()
|
||||||
|
self.request = request
|
||||||
|
self.callback = callback
|
||||||
|
self.session.headers.update({"User-Agent": f"passbook {__version__}"})
|
||||||
|
|
||||||
|
def get_access_token(self, **request_kwargs) -> Optional[Dict[str, Any]]:
|
||||||
|
"Fetch access token from callback request."
|
||||||
|
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
|
||||||
|
|
||||||
|
def get_profile_info(self, token: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
||||||
|
"Fetch user profile information."
|
||||||
|
try:
|
||||||
|
response = self.do_request("get", self.source.profile_url, token=token)
|
||||||
|
response.raise_for_status()
|
||||||
|
except RequestException as exc:
|
||||||
|
LOGGER.warning("Unable to fetch user profile", exc=exc)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def get_redirect_args(self) -> Dict[str, str]:
|
||||||
|
"Get request parameters for redirect url."
|
||||||
|
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
|
||||||
|
|
||||||
|
def get_redirect_url(self, parameters=None):
|
||||||
|
"Build authentication redirect url."
|
||||||
|
args = self.get_redirect_args()
|
||||||
|
additional = parameters or {}
|
||||||
|
args.update(additional)
|
||||||
|
params = urlencode(args)
|
||||||
|
LOGGER.info("redirect args", **args)
|
||||||
|
return f"{self.source.authorization_url}?{params}"
|
||||||
|
|
||||||
|
def parse_raw_token(self, raw_token: str) -> Dict[str, Any]:
|
||||||
|
"Parse token and secret from raw token response."
|
||||||
|
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
|
||||||
|
|
||||||
|
def do_request(self, method: str, url: str, **kwargs) -> Response:
|
||||||
|
"""Wrapper around self.session.request, which can add special headers"""
|
||||||
|
return self.session.request(method, url, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session_key(self) -> str:
|
||||||
|
"""Return Session Key"""
|
||||||
|
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user